diff --git a/packages/hoppscotch-backend/src/prisma/prisma-error-codes.ts b/packages/hoppscotch-backend/src/prisma/prisma-error-codes.ts index 0ae4819c..432a5715 100644 --- a/packages/hoppscotch-backend/src/prisma/prisma-error-codes.ts +++ b/packages/hoppscotch-backend/src/prisma/prisma-error-codes.ts @@ -2,6 +2,7 @@ export enum PrismaError { DATABASE_UNREACHABLE = 'P1001', TABLE_DOES_NOT_EXIST = 'P2021', UNIQUE_CONSTRAINT_VIOLATION = 'P2002', + RECORD_NOT_FOUND = 'P2025', TRANSACTION_TIMEOUT = 'P2028', TRANSACTION_DEADLOCK = 'P2034', // write conflict or a deadlock } diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts index ba9258de..40a98e76 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.service.ts @@ -569,23 +569,24 @@ export class TeamCollectionService { collection.parentID, ); - const deletedCollection = await tx.teamCollection.delete({ - where: { id: collection.id }, - }); - - // if collection is deleted, update siblings orderIndexes - // if collection was deleted before the transaction started (race condition), do not update siblings orderIndexes - if (deletedCollection) { - // update siblings orderIndexes - await tx.teamCollection.updateMany({ - where: { - teamID: collection.teamID, - parentID: collection.parentID, - orderIndex: orderIndexCondition, - }, - data: { orderIndex: dataCondition }, + try { + await tx.teamCollection.delete({ + where: { id: collection.id }, }); + } catch (deleteError) { + // P2025: Record not found — already deleted by a concurrent transaction + if (deleteError?.code === PrismaError.RECORD_NOT_FOUND) return; + throw deleteError; } + + await tx.teamCollection.updateMany({ + where: { + teamID: collection.teamID, + parentID: collection.parentID, + orderIndex: orderIndexCondition, + }, + data: { orderIndex: dataCondition }, + }); } catch (error) { throw new ConflictException(error); } @@ -749,12 +750,7 @@ export class TeamCollectionService { // Get collection details of collectionID const collection = await this.getCollection(collectionID, tx); if (E.isLeft(collection)) return E.left(collection.left); - // lock the rows of the collection and its siblings - await this.prisma.lockTeamCollectionByTeamAndParent( - tx, - collection.right.teamID, - collection.right.parentID, - ); + // destCollectionID == null i.e move collection to root if (!destCollectionID) { if (!collection.right.parentID) { @@ -763,6 +759,12 @@ export class TeamCollectionService { return E.left(TEAM_COL_ALREADY_ROOT); } + await this.prisma.lockTeamCollectionByTeamAndParent( + tx, + collection.right.teamID, + collection.right.parentID, + ); + // Change parent from child to root i.e child collection becomes a root collection // Move child collection into root and update orderIndexes for root teamCollections const updatedCollection = await this.changeParentAndUpdateOrderIndex( @@ -806,12 +808,41 @@ export class TeamCollectionService { return E.left(TEAM_COLL_IS_PARENT_COLL); } - // lock the rows of the destination collection and its siblings - await this.prisma.lockTeamCollectionByTeamAndParent( - tx, - destCollection.right.teamID, - destCollection.right.parentID, - ); + // Acquire locks in deterministic order (sorted by parentID) to prevent deadlocks + // when two concurrent moves happen in opposite directions + const srcParentID = collection.right.parentID ?? ''; + const destParentID = destCollection.right.parentID ?? ''; + const teamID = collection.right.teamID; + + if (srcParentID === destParentID) { + await this.prisma.lockTeamCollectionByTeamAndParent( + tx, + teamID, + collection.right.parentID, + ); + } else if (srcParentID < destParentID) { + await this.prisma.lockTeamCollectionByTeamAndParent( + tx, + teamID, + collection.right.parentID, + ); + await this.prisma.lockTeamCollectionByTeamAndParent( + tx, + teamID, + destCollection.right.parentID, + ); + } else { + await this.prisma.lockTeamCollectionByTeamAndParent( + tx, + teamID, + destCollection.right.parentID, + ); + await this.prisma.lockTeamCollectionByTeamAndParent( + tx, + teamID, + collection.right.parentID, + ); + } // Change parent from null to teamCollection i.e collection becomes a child collection // Move root/child collection into another child collection and update orderIndexes of the previous parent diff --git a/packages/hoppscotch-backend/src/team-request/team-request.service.ts b/packages/hoppscotch-backend/src/team-request/team-request.service.ts index 19275881..13c0e90d 100644 --- a/packages/hoppscotch-backend/src/team-request/team-request.service.ts +++ b/packages/hoppscotch-backend/src/team-request/team-request.service.ts @@ -20,6 +20,7 @@ import { TeamRequest as DbTeamRequest, } from 'src/generated/prisma/client'; import { SortOptions } from 'src/types/SortOptions'; +import { PrismaError } from 'src/prisma/prisma-error-codes'; @Injectable() export class TeamRequestService { @@ -124,21 +125,23 @@ export class TeamRequestService { dbTeamReq.collectionID, ]); - const deletedTeamRequest = await tx.teamRequest.delete({ - where: { id: requestID }, - }); - - // if request is deleted, update orderIndexes of siblings - // if request was deleted before the transaction started (race condition), do not update siblings orderIndexes - if (deletedTeamRequest) { - await tx.teamRequest.updateMany({ - where: { - collectionID: dbTeamReq.collectionID, - orderIndex: { gte: dbTeamReq.orderIndex }, - }, - data: { orderIndex: { decrement: 1 } }, + try { + await tx.teamRequest.delete({ + where: { id: requestID }, }); + } catch (deleteError) { + // P2025: Record not found — already deleted by a concurrent transaction + if (deleteError?.code === PrismaError.RECORD_NOT_FOUND) return; + throw deleteError; } + + await tx.teamRequest.updateMany({ + where: { + collectionID: dbTeamReq.collectionID, + orderIndex: { gte: dbTeamReq.orderIndex }, + }, + data: { orderIndex: { decrement: 1 } }, + }); } catch (error) { throw new ConflictException(error); } diff --git a/packages/hoppscotch-backend/src/user-collection/user-collection.service.spec.ts b/packages/hoppscotch-backend/src/user-collection/user-collection.service.spec.ts index 405fe9ea..7fa2b58d 100644 --- a/packages/hoppscotch-backend/src/user-collection/user-collection.service.spec.ts +++ b/packages/hoppscotch-backend/src/user-collection/user-collection.service.spec.ts @@ -2380,3 +2380,165 @@ describe('exportUserCollectionToJSONObject', () => { expect(result).toEqualLeft(USER_COLL_NOT_FOUND); }); }); + +describe('importCollectionsFromJSON — collection-level script fields', () => { + // The backend treats `data` as an opaque JSON blob, so the script fields + // ride through transparently. The test asserts on both ends: the create + // call payload must carry script fields (proving import wrote them), and + // the export payload must surface them unchanged. Guards against any + // future refactor that destructures `data` and drops scripts on either + // side. + test('preRequestScript and testScript on root and folder survive import → export round-trip', async () => { + const importJSON = JSON.stringify([ + { + name: 'root-with-scripts', + folders: [ + { + name: 'child-folder', + folders: [], + requests: [], + data: JSON.stringify({ + auth: { authType: 'inherit', authActive: true }, + headers: [], + variables: [], + preRequestScript: 'pw.env.set("FOLDER_RAN", "yes");', + testScript: 'pw.test("folder", () => {});', + }), + }, + ], + requests: [], + data: JSON.stringify({ + auth: { authType: 'none', authActive: false }, + headers: [], + variables: [], + preRequestScript: 'pw.env.set("ROOT_RAN", "yes");', + testScript: 'pw.test("root", () => {});', + }), + }, + ]); + + const rootRowId = 'imported-root-id'; + const folderRowId = 'imported-folder-id'; + + // Capture what generatePrismaQueryObj writes into Prisma so the export + // path sees the same blob shape the import wrote. + const rootDataAtCreate = { + auth: { authType: 'none', authActive: false }, + headers: [], + variables: [], + preRequestScript: 'pw.env.set("ROOT_RAN", "yes");', + testScript: 'pw.test("root", () => {});', + }; + const folderDataAtCreate = { + auth: { authType: 'inherit', authActive: true }, + headers: [], + variables: [], + preRequestScript: 'pw.env.set("FOLDER_RAN", "yes");', + testScript: 'pw.test("folder", () => {});', + }; + + mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma)); + mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined); + mockPrisma.userCollection.findFirst.mockResolvedValueOnce(null); + mockPrisma.userCollection.create.mockResolvedValueOnce({ + id: rootRowId, + orderIndex: 1, + parentID: null, + title: 'root-with-scripts', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: rootDataAtCreate, + }); + + // Export-side mocks: root resolves once, then its child folder resolves. + mockPrisma.userCollection.findUniqueOrThrow + .mockResolvedValueOnce({ + id: rootRowId, + orderIndex: 1, + parentID: null, + title: 'root-with-scripts', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: rootDataAtCreate, + }) + .mockResolvedValueOnce({ + id: folderRowId, + orderIndex: 1, + parentID: rootRowId, + title: 'child-folder', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: folderDataAtCreate, + }); + + mockPrisma.userCollection.findMany + .mockResolvedValueOnce([ + { + id: folderRowId, + orderIndex: 1, + parentID: rootRowId, + title: 'child-folder', + userUid: user.uid, + type: ReqType.REST, + createdOn: currentTime, + updatedOn: currentTime, + data: folderDataAtCreate, + }, + ]) + .mockResolvedValueOnce([]); + + mockPrisma.userRequest.findMany + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + const result = await userCollectionService.importCollectionsFromJSON( + importJSON, + user.uid, + null, + ReqType.REST, + ); + + expect(E.isRight(result)).toBe(true); + + // Import side: `userCollection.create` must receive script fields inside + // its `data` payload (root) and inside `children.create[0].data` (folder). + // Asserting against the create call args proves import preserved scripts; + // export-side mocks alone would only round-trip the values we pre-loaded. + const createCallArg = mockPrisma.userCollection.create.mock.calls[0][0] + .data as any; + expect(createCallArg.data.preRequestScript).toBe( + 'pw.env.set("ROOT_RAN", "yes");', + ); + expect(createCallArg.data.testScript).toBe( + 'pw.test("root", () => {});', + ); + const childCreateArg = createCallArg.children.create[0]; + expect(childCreateArg.data.preRequestScript).toBe( + 'pw.env.set("FOLDER_RAN", "yes");', + ); + expect(childCreateArg.data.testScript).toBe( + 'pw.test("folder", () => {});', + ); + + if (E.isRight(result)) { + const exported = JSON.parse(result.right.exportedCollection); + // `data` is JSON-stringified by transformCollectionData on export. + const rootData = JSON.parse(exported[0].data); + const folderData = JSON.parse(exported[0].folders[0].data); + expect(rootData.preRequestScript).toBe( + 'pw.env.set("ROOT_RAN", "yes");', + ); + expect(rootData.testScript).toBe('pw.test("root", () => {});'); + expect(folderData.preRequestScript).toBe( + 'pw.env.set("FOLDER_RAN", "yes");', + ); + expect(folderData.testScript).toBe('pw.test("folder", () => {});'); + } + }); +}); diff --git a/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts b/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts index 4bc13c90..87b180f7 100644 --- a/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts +++ b/packages/hoppscotch-backend/src/user-collection/user-collection.service.ts @@ -520,23 +520,24 @@ export class UserCollectionService { collection.parentID, ); - const deletedCollection = await tx.userCollection.delete({ - where: { id: collection.id }, - }); - - // if collection is deleted, update siblings orderIndexes - // if collection was deleted before the transaction started (race condition), do not update siblings orderIndexes - if (deletedCollection) { - // update orderIndexes - await tx.userCollection.updateMany({ - where: { - userUid: collection.userUid, - parentID: collection.parentID, - orderIndex: orderIndexCondition, - }, - data: { orderIndex: dataCondition }, + try { + await tx.userCollection.delete({ + where: { id: collection.id }, }); + } catch (deleteError) { + // P2025: Record not found — already deleted by a concurrent transaction + if (deleteError?.code === PrismaError.RECORD_NOT_FOUND) return; + throw deleteError; } + + await tx.userCollection.updateMany({ + where: { + userUid: collection.userUid, + parentID: collection.parentID, + orderIndex: orderIndexCondition, + }, + data: { orderIndex: dataCondition }, + }); } catch (error) { throw new ConflictException(error); } @@ -596,6 +597,12 @@ export class UserCollectionService { return E.left(USER_COLL_ALREADY_ROOT); } + await this.prisma.lockUserCollectionByParent( + tx, + userID, + collection.right.parentID, + ); + // Change parent from child to root i.e child collection becomes a root collection // Move child collection into root and update orderIndexes for child userCollections const updatedCollection = await this.changeParentAndUpdateOrderIndex( @@ -643,7 +650,40 @@ export class UserCollectionService { return E.left(USER_COLL_IS_PARENT_COLL); } - // Change parent from null to teamCollection i.e collection becomes a child collection + // Acquire locks in deterministic order (sorted by parentID) to prevent deadlocks + const srcParentID = collection.right.parentID ?? ''; + const destParentID = destCollection.right.parentID ?? ''; + + if (srcParentID === destParentID) { + await this.prisma.lockUserCollectionByParent( + tx, + userID, + collection.right.parentID, + ); + } else if (srcParentID < destParentID) { + await this.prisma.lockUserCollectionByParent( + tx, + userID, + collection.right.parentID, + ); + await this.prisma.lockUserCollectionByParent( + tx, + userID, + destCollection.right.parentID, + ); + } else { + await this.prisma.lockUserCollectionByParent( + tx, + userID, + destCollection.right.parentID, + ); + await this.prisma.lockUserCollectionByParent( + tx, + userID, + collection.right.parentID, + ); + } + // Move root/child collection into another child collection and update orderIndexes of the previous parent const updatedCollection = await this.changeParentAndUpdateOrderIndex( tx, diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts b/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts index afdb5e6d..42f7df62 100644 --- a/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts +++ b/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts @@ -539,6 +539,21 @@ describe("hopp test [options] ", { timeout: 100000 }, () => { // Clean up fs.unlinkSync(junitPath); }, 600000); // 600 second (10 minute) timeout + + test("Inherited collection-level scripts run in order across both sandboxes", async () => { + const args = `test ${getTestJsonFilePath( + "collection-level-scripts-coll.json", + "collection" + )}`; + + const defaultResult = await runCLIWithNetworkRetry(args); + if (defaultResult === null) return; + expect(defaultResult.error).toBeNull(); + + const legacyResult = await runCLIWithNetworkRetry(`${args} --legacy-sandbox`); + if (legacyResult === null) return; + expect(legacyResult.error).toBeNull(); + }); }); describe("Test `hopp test --env ` command:", () => { diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-coll.json new file mode 100644 index 00000000..38bae839 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-coll.json @@ -0,0 +1,114 @@ +{ + "v": 12, + "name": "collection-level-scripts-coll", + "variables": [], + "description": null, + "folders": [ + { + "v": 12, + "name": "target-folder", + "variables": [], + "description": null, + "folders": [], + "requests": [ + { + "v": "17", + "id": "cl-script-req-1", + "name": "target-request", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "pw.env.set(\"REQ_RAN\", \"yes\");\npw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->target-req\");", + "testScript": "pw.env.set(\"TEST_ORDER\", \"target-req\");\npw.env.set(\"ORDER_AT_REQ\", pw.env.get(\"TEST_ORDER\"));\npw.test(\"pre-script cascade ran in root->target-folder->target-req order\", () => {\n pw.expect(pw.env.get(\"PRE_ORDER\")).toBe(\"root->target-folder->target-req\");\n});\npw.test(\"all cascade pre-scripts committed env vars\", () => {\n pw.expect(pw.env.get(\"ROOT_RAN\")).toBe(\"yes\");\n pw.expect(pw.env.get(\"TARGET_FOLDER_RAN\")).toBe(\"yes\");\n pw.expect(pw.env.get(\"REQ_RAN\")).toBe(\"yes\");\n});\npw.test(\"request-level test observed request position in test-cascade\", () => {\n pw.expect(pw.env.get(\"ORDER_AT_REQ\")).toBe(\"target-req\");\n});", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + }, + { + "v": "17", + "id": "cl-script-req-2", + "name": "sibling-request-in-target-folder", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "pw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->sibling-req-in-target\");", + "testScript": "pw.env.set(\"TEST_ORDER\", \"sibling-req-in-target\");\npw.test(\"sibling request cascade is root->target-folder->this-request\", () => {\n pw.expect(pw.env.get(\"PRE_ORDER\")).toBe(\"root->target-folder->sibling-req-in-target\");\n});", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "preRequestScript": "pw.env.set(\"TARGET_FOLDER_RAN\", \"yes\");\npw.env.set(\"TARGET_FOLDER_RUN_COUNT\", String((parseInt(pw.env.get(\"TARGET_FOLDER_RUN_COUNT\") || \"0\", 10)) + 1));\npw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->target-folder\");", + "testScript": "pw.env.set(\"TEST_ORDER\", pw.env.get(\"TEST_ORDER\") + \"->target-folder\");\npw.env.set(\"ORDER_AT_TARGET_FOLDER\", pw.env.get(\"TEST_ORDER\"));" + }, + { + "v": 12, + "name": "sibling-folder", + "variables": [], + "description": null, + "folders": [], + "requests": [ + { + "v": "17", + "id": "cl-script-req-3", + "name": "sibling-request-in-sibling-folder", + "method": "GET", + "endpoint": "https://echo.hoppscotch.io", + "params": [], + "headers": [], + "preRequestScript": "pw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->sibling-req-in-sibling\");", + "testScript": "pw.env.set(\"TEST_ORDER\", \"sibling-req-in-sibling\");\npw.test(\"sibling-folder cascade is root->sibling-folder->this-request (no target-folder leak)\", () => {\n pw.expect(pw.env.get(\"PRE_ORDER\")).toBe(\"root->sibling-folder->sibling-req-in-sibling\");\n});\npw.test(\"target-folder pre-script ran exactly twice (one per request in target-folder)\", () => {\n pw.expect(pw.env.get(\"TARGET_FOLDER_RUN_COUNT\")).toBe(\"2\");\n});", + "auth": { + "authType": "inherit", + "authActive": true + }, + "body": { + "contentType": null, + "body": null + }, + "requestVariables": [], + "responses": {}, + "description": null + } + ], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "preRequestScript": "pw.env.set(\"SIBLING_FOLDER_RAN\", \"yes\");\npw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->sibling-folder\");", + "testScript": "pw.env.set(\"TEST_ORDER\", pw.env.get(\"TEST_ORDER\") + \"->sibling-folder\");" + } + ], + "requests": [], + "auth": { + "authType": "inherit", + "authActive": true + }, + "headers": [], + "preRequestScript": "pw.env.set(\"ROOT_RAN\", \"yes\");\npw.env.set(\"PRE_ORDER\", \"root\");", + "testScript": "pw.env.set(\"TEST_ORDER\", pw.env.get(\"TEST_ORDER\") + \"->root\");\npw.test(\"test-script cascade ran in request->folder->root order for every request\", () => {\n pw.expect([\"target-req->target-folder->root\", \"sibling-req-in-target->target-folder->root\", \"sibling-req-in-sibling->sibling-folder->root\"].includes(pw.env.get(\"TEST_ORDER\"))).toBe(true);\n});" +} diff --git a/packages/hoppscotch-cli/src/__tests__/unit/fixtures/workspace-access.mock.ts b/packages/hoppscotch-cli/src/__tests__/unit/fixtures/workspace-access.mock.ts index cf11cf07..774be70e 100644 --- a/packages/hoppscotch-cli/src/__tests__/unit/fixtures/workspace-access.mock.ts +++ b/packages/hoppscotch-cli/src/__tests__/unit/fixtures/workspace-access.mock.ts @@ -144,6 +144,8 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M headers: [], variables: [], description: null, + preRequestScript: "", + testScript: "", }, ], requests: [ @@ -192,6 +194,8 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M }, ], description: null, + preRequestScript: "", + testScript: "", }, ], requests: [ @@ -224,6 +228,8 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M headers: [], variables: [], description: null, + preRequestScript: "", + testScript: "", }, ], requests: [ @@ -273,6 +279,8 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M }, ], description: null, + preRequestScript: "", + testScript: "", }, ]; @@ -513,12 +521,12 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp name: "Multiple child collections with authorization, headers and variables set at each level", folders: [ { - v: 11, + v: CollectionSchemaVersion, id: "clx1fjgah000110f8a5bs68gd", name: "folder-1", folders: [ { - v: 11, + v: CollectionSchemaVersion, id: "clx1fjwmm000410f8l1gkkr1a", name: "folder-11", folders: [], @@ -567,9 +575,11 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp }, ], description: null, + preRequestScript: "", + testScript: "", }, { - v: 11, + v: CollectionSchemaVersion, id: "clx1fjyxm000510f8pv90dt43", name: "folder-12", folders: [], @@ -634,9 +644,11 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp }, ], description: null, + preRequestScript: "", + testScript: "", }, { - v: 11, + v: CollectionSchemaVersion, id: "clx1fk1cv000610f88kc3aupy", name: "folder-13", folders: [], @@ -719,6 +731,8 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp }, ], description: null, + preRequestScript: "", + testScript: "", }, ], requests: [ @@ -764,14 +778,16 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp }, ], description: null, + preRequestScript: "", + testScript: "", }, { - v: 11, + v: CollectionSchemaVersion, id: "clx1fjk9o000210f8j0573pls", name: "folder-2", folders: [ { - v: 11, + v: CollectionSchemaVersion, id: "clx1fk516000710f87sfpw6bo", name: "folder-21", folders: [], @@ -818,9 +834,11 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp }, ], description: null, + preRequestScript: "", + testScript: "", }, { - v: 11, + v: CollectionSchemaVersion, id: "clx1fk72t000810f8gfwkpi5y", name: "folder-22", folders: [], @@ -885,9 +903,11 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp }, ], description: null, + preRequestScript: "", + testScript: "", }, { - v: 11, + v: CollectionSchemaVersion, id: "clx1fk95g000910f8bunhaoo8", name: "folder-23", folders: [], @@ -957,6 +977,8 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp }, ], description: null, + preRequestScript: "", + testScript: "", }, ], requests: [ @@ -1008,15 +1030,17 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp }, ], description: null, + preRequestScript: "", + testScript: "", }, { - v: 11, + v: CollectionSchemaVersion, id: "clx1fjmlq000310f86o4d3w2o", name: "folder-3", folders: [ { - v: 11, + v: CollectionSchemaVersion, id: "clx1iwq0p003e10f8u8zg0p85", name: "folder-31", folders: [], @@ -1063,9 +1087,11 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp }, ], description: null, + preRequestScript: "", + testScript: "", }, { - v: 11, + v: CollectionSchemaVersion, id: "clx1izut7003m10f894ip59zg", name: "folder-32", folders: [], @@ -1130,9 +1156,11 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp }, ], description: null, + preRequestScript: "", + testScript: "", }, { - v: 11, + v: CollectionSchemaVersion, id: "clx1j2ka9003q10f8cdbzpgpg", name: "folder-33", folders: [], @@ -1202,6 +1230,8 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp }, ], description: null, + preRequestScript: "", + testScript: "", }, ], requests: [ @@ -1266,6 +1296,8 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp }, ], description: null, + preRequestScript: "", + testScript: "", }, ], requests: [ @@ -1321,6 +1353,8 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp }, ], description: null, + preRequestScript: "", + testScript: "", }, ]; @@ -1428,6 +1462,8 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L headers: [], variables: [], description: null, + preRequestScript: "", + testScript: "", }, { v: CollectionSchemaVersion, @@ -1476,6 +1512,8 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L }, ], description: null, + preRequestScript: "", + testScript: "", }, { v: CollectionSchemaVersion, @@ -1490,6 +1528,8 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L headers: [], variables: [], description: null, + preRequestScript: "", + testScript: "", }, { v: CollectionSchemaVersion, @@ -1518,6 +1558,8 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L }, ], description: null, + preRequestScript: "", + testScript: "", }, ], requests: [], @@ -1528,6 +1570,8 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L headers: [], variables: [], description: null, + preRequestScript: "", + testScript: "", }, ]; diff --git a/packages/hoppscotch-cli/src/__tests__/unit/pre-request-inheritance.spec.ts b/packages/hoppscotch-cli/src/__tests__/unit/pre-request-inheritance.spec.ts new file mode 100644 index 00000000..d101b44c --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/unit/pre-request-inheritance.spec.ts @@ -0,0 +1,216 @@ +import { describe, expect, test } from "vitest"; +import { makeRESTRequest } from "@hoppscotch/data"; +import * as E from "fp-ts/Either"; + +import { preRequestScriptRunner } from "../../utils/pre-request"; +import { HoppEnvs } from "../../types/request"; + +const SAMPLE_ENVS: HoppEnvs = { + global: [], + selected: [], +}; + +const SAMPLE_REQUEST = makeRESTRequest({ + name: "request", + method: "GET", + endpoint: "https://example.com", + params: [], + headers: [], + preRequestScript: "", + testScript: "", + auth: { authActive: false, authType: "none" }, + body: { + contentType: null, + body: null, + }, + requestVariables: [], + description: null, + responses: {}, +}); + +describe("preRequestScriptRunner - inheritance", () => { + test("Inherited scripts execute in root → parent → request order", async () => { + const rootScript = `pw.env.set("ORDER", "root");`; + const parentScript = ` + const prev = pw.env.get("ORDER"); + pw.env.set("ORDER", prev + ",parent"); + `; + const request = makeRESTRequest({ + ...SAMPLE_REQUEST, + preRequestScript: ` + const prev = pw.env.get("ORDER"); + pw.env.set("ORDER", prev + ",request"); + `, + }); + + const result = await preRequestScriptRunner( + request, + SAMPLE_ENVS, + false, + undefined, + [rootScript, parentScript] + )(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + const orderVar = result.right.updatedEnvs.selected.find( + (v) => v.key === "ORDER" + ); + expect(orderVar?.currentValue).toBe("root,parent,request"); + } + }); + + test("Inherited scripts set ENVs used in request endpoint resolution", async () => { + const rootScript = `pw.env.set("ENDPOINT", "https://example.com");`; + + const request = makeRESTRequest({ + ...SAMPLE_REQUEST, + endpoint: "<>", + preRequestScript: "", + }); + + const result = await preRequestScriptRunner( + request, + SAMPLE_ENVS, + false, + undefined, + [rootScript] + )(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + expect(result.right.effectiveRequest.effectiveFinalURL).toBe( + "https://example.com" + ); + } + }); + + test("Scripts with same local variable names do not collide (IIFE isolation)", async () => { + const rootScript = `const x = "root"; pw.env.set("ROOT_VAR", x);`; + const parentScript = `const x = "parent"; pw.env.set("PARENT_VAR", x);`; + + const request = makeRESTRequest({ + ...SAMPLE_REQUEST, + preRequestScript: `const x = "request"; pw.env.set("REQUEST_VAR", x);`, + }); + + const result = await preRequestScriptRunner( + request, + SAMPLE_ENVS, + false, + undefined, + [rootScript, parentScript] + )(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + const envVars = result.right.updatedEnvs.selected; + expect(envVars.find((v) => v.key === "ROOT_VAR")?.currentValue).toBe( + "root" + ); + expect(envVars.find((v) => v.key === "PARENT_VAR")?.currentValue).toBe( + "parent" + ); + expect(envVars.find((v) => v.key === "REQUEST_VAR")?.currentValue).toBe( + "request" + ); + } + }); + + test("Empty inherited scripts are filtered out gracefully", async () => { + const validScript = `pw.env.set("ENDPOINT", "https://example.com");`; + + const request = makeRESTRequest({ + ...SAMPLE_REQUEST, + endpoint: "<>", + preRequestScript: "", + }); + + const result = await preRequestScriptRunner( + request, + SAMPLE_ENVS, + false, + undefined, + ["", " ", validScript, "\n"] + )(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + expect(result.right.effectiveRequest.effectiveFinalURL).toBe( + "https://example.com" + ); + } + }); + + test("Works correctly with no inherited scripts (backward compatibility)", async () => { + const request = makeRESTRequest({ + ...SAMPLE_REQUEST, + endpoint: "<>", + preRequestScript: `pw.env.set("ENDPOINT", "https://example.com");`, + }); + + const result = await preRequestScriptRunner( + request, + SAMPLE_ENVS, + false, + undefined, + [] + )(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + expect(result.right.effectiveRequest.effectiveFinalURL).toBe( + "https://example.com" + ); + } + }); + + // Regression: in the legacy (isolated-vm) sandbox, inherited scripts must + // execute sequentially in order. Pre-fix combineScriptsWithIIFE emitted an + // outer detached Promise that script.run did not await; sequential ordering + // was undefined. + // + // Note: the unit test exercises the sequential-ordering path. The + // post-`await` drop only surfaces with macrotask awaits (setTimeout) or + // cross-isolate Reference calls returning Promises — neither is exposed + // through the synchronous `pw.*` surface in node/legacy.ts, so it is not + // currently user-reachable in the CLI. The web worker path is where the + // post-`await` drop is user-reachable; covered by the smoke fixture in + // packages/hoppscotch-cli/src/__tests__/e2e/. + test("Legacy sandbox executes inherited scripts in order", async () => { + const rootScript = `pw.env.set("ORDER", "root");`; + const parentScript = ` + const prev = pw.env.get("ORDER"); + pw.env.set("ORDER", prev + ",parent"); + `; + const request = makeRESTRequest({ + ...SAMPLE_REQUEST, + preRequestScript: ` + const prev = pw.env.get("ORDER"); + pw.env.set("ORDER", prev + ",request"); + `, + }); + + const result = await preRequestScriptRunner( + request, + SAMPLE_ENVS, + true, + undefined, + [rootScript, parentScript] + )(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + const orderVar = result.right.updatedEnvs.selected.find( + (v) => v.key === "ORDER" + ); + expect(orderVar?.currentValue).toBe("root,parent,request"); + } + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/unit/scripting.spec.ts b/packages/hoppscotch-cli/src/__tests__/unit/scripting.spec.ts new file mode 100644 index 00000000..a2aa4d46 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/unit/scripting.spec.ts @@ -0,0 +1,168 @@ +import { describe, expect, test } from "vitest"; + +import { + combineScriptsWithIIFE, + stripModulePrefix, + MODULE_PREFIX, +} from "../../utils/scripting"; + +describe("scripting", () => { + describe("stripModulePrefix", () => { + test("strips 'export {};\\n' prefix", () => { + expect(stripModulePrefix("export {};\nconst x = 1;")).toBe( + "const x = 1;" + ); + }); + + test("strips 'export {};' prefix without newline", () => { + expect(stripModulePrefix("export {};const x = 1;")).toBe("const x = 1;"); + }); + + test("returns script unchanged if no prefix", () => { + expect(stripModulePrefix("const x = 1;")).toBe("const x = 1;"); + }); + + test("returns empty string unchanged", () => { + expect(stripModulePrefix("")).toBe(""); + }); + }); + + describe("combineScriptsWithIIFE", () => { + test("returns empty string for empty array", () => { + expect(combineScriptsWithIIFE([])).toBe(""); + }); + + test("returns empty string when all scripts are empty", () => { + expect(combineScriptsWithIIFE(["", " ", "\n"])).toBe(""); + }); + + test("wraps a single script in a sequential async IIFE", () => { + const result = combineScriptsWithIIFE(["const x = 1;"]); + + expect(result).toContain("async"); + expect(result).toContain("await"); + expect(result).toContain("const x = 1;"); + }); + + test("preserves script order (root → parent → child → request) for pre-request scripts", () => { + const rootScript = 'pw.env.set("token", "root");'; + const parentScript = 'pw.env.set("parent", "true");'; + const requestScript = 'pw.env.set("request", "true");'; + + const result = combineScriptsWithIIFE([ + rootScript, + parentScript, + requestScript, + ]); + + const rootIndex = result.indexOf(rootScript); + const parentIndex = result.indexOf(parentScript); + const requestIndex = result.indexOf(requestScript); + + expect(rootIndex).toBeLessThan(parentIndex); + expect(parentIndex).toBeLessThan(requestIndex); + }); + + test("preserves script order (request → child → parent → root) for test scripts", () => { + const requestScript = 'pw.test("request test", () => {});'; + const childScript = 'pw.test("child test", () => {});'; + const rootScript = 'pw.test("root test", () => {});'; + + // Simulates the reversal pattern used in test runner: + // combineScriptsWithIIFE([requestScript, ...inheritedTestScripts.slice().reverse()]) + const inheritedTestScripts = [rootScript, childScript]; + const result = combineScriptsWithIIFE([ + requestScript, + ...inheritedTestScripts.slice().reverse(), + ]); + + const requestIndex = result.indexOf(requestScript); + const childIndex = result.indexOf(childScript); + const rootIndex = result.indexOf(rootScript); + + expect(requestIndex).toBeLessThan(childIndex); + expect(childIndex).toBeLessThan(rootIndex); + }); + + test("filters out empty scripts while preserving non-empty ones", () => { + const script1 = "const a = 1;"; + const script2 = "const b = 2;"; + + const result = combineScriptsWithIIFE([script1, "", " ", script2]); + + expect(result).toContain(script1); + expect(result).toContain(script2); + + // Should only have 2 await statements (not 4) + const awaitCount = (result.match(/await/g) || []).length; + expect(awaitCount).toBe(2); + }); + + test("isolates variable scope between scripts (each wrapped in its own function)", () => { + const script1 = "const x = 1;"; + const script2 = "const x = 2;"; + + const result = combineScriptsWithIIFE([script1, script2]); + + // Both scripts should appear in separate async functions + const fnCount = (result.match(/async function\(\)/g) || []).length; + expect(fnCount).toBe(2); + }); + + test("strips module prefix from scripts before wrapping", () => { + const script = `${MODULE_PREFIX}const x = 1;`; + + const result = combineScriptsWithIIFE([script]); + + // The module prefix should be stripped + expect(result).not.toContain("export {};"); + expect(result).toContain("const x = 1;"); + }); + + test("experimental target generates sequential await chain wrapped in try/catch", () => { + const result = combineScriptsWithIIFE( + ["const a = 1;", "const b = 2;", "const c = 3;"], + "experimental" + ); + + // Outer wrapper captures the reporter lexically so user code that + // deletes the globalThis property cannot suppress error reporting. + expect(result).toMatch( + /^const __hoppReporter = globalThis\.__hoppReportScriptExecutionError;\s*try \{/ + ); + expect(result).toContain("await (async function() {"); + // Each script contributes one `await` in the body. + const awaitCount = (result.match(/\bawait\b/g) || []).length; + expect(awaitCount).toBe(3); + // Catch hands the error to the lexically captured reporter. + expect(result).toContain( + "} catch (__hoppScriptExecutionError) {" + ); + expect(result).toContain("__hoppReporter(__hoppScriptExecutionError);"); + }); + + test("legacy target generates sync IIFE chain with no await", () => { + const result = combineScriptsWithIIFE( + ["const a = 1;", "const b = 2;", "const c = 3;"], + "legacy" + ); + + // No `async` keyword, no `await` — legacy sandbox is sync-only. + expect(result).not.toContain("async"); + expect(result).not.toContain("await"); + // Leading `;` guards against ASI on the host script. + expect(result).toMatch(/^;\(function\(\) \{/); + // Each script wrapped in its own IIFE + const iifeCount = (result.match(/\.call\(this\);/g) || []).length; + expect(iifeCount).toBe(3); + }); + + test("default target is experimental (wrapped in try/catch)", () => { + const result = combineScriptsWithIIFE(["const x = 1;"]); + expect(result).toMatch( + /^const __hoppReporter = globalThis\.__hoppReportScriptExecutionError;\s*try \{/ + ); + expect(result).toContain("await (async function() {"); + }); + }); +}); diff --git a/packages/hoppscotch-cli/src/__tests__/unit/test-runner-inheritance.spec.ts b/packages/hoppscotch-cli/src/__tests__/unit/test-runner-inheritance.spec.ts new file mode 100644 index 00000000..9e371aa7 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/unit/test-runner-inheritance.spec.ts @@ -0,0 +1,234 @@ +import { describe, expect, test } from "vitest"; +import { makeRESTRequest } from "@hoppscotch/data"; +import * as E from "fp-ts/Either"; + +import { testRunner } from "../../utils/test"; +import { HoppEnvs } from "../../types/request"; + +const SAMPLE_ENVS: HoppEnvs = { + global: [], + selected: [], +}; + +const SAMPLE_RESPONSE = { + status: 200, + headers: [], + body: {}, + statusText: "OK", + responseTime: 100, +}; + +const SAMPLE_REQUEST = makeRESTRequest({ + name: "request", + method: "GET", + endpoint: "https://example.com", + params: [], + headers: [], + preRequestScript: "", + testScript: "", + auth: { authActive: false, authType: "none" }, + body: { + contentType: null, + body: null, + }, + requestVariables: [], + description: null, + responses: {}, +}); + +describe("testRunner - inheritance", () => { + test("Inherited test scripts are executed and register test cases", async () => { + const rootTestScript = ` + pw.test("Root collection test", () => { + pw.expect(pw.response.status).toBe(200); + }); + `; + + const result = await testRunner({ + request: makeRESTRequest({ + ...SAMPLE_REQUEST, + testScript: ` + pw.test("Request test", () => { + pw.expect(pw.response.status).toBe(200); + }); + `, + }), + envs: SAMPLE_ENVS, + response: SAMPLE_RESPONSE, + legacySandbox: false, + inheritedTestScripts: [rootTestScript], + })(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + const { testsReport } = result.right; + const descriptors = testsReport.map((r) => r.descriptor); + + expect(descriptors).toContain("Request test"); + expect(descriptors).toContain("Root collection test"); + } + }); + + test("Inherited test scripts execute in request → child → parent → root order", async () => { + const rootTestScript = ` + const prev = pw.env.get("ORDER"); + pw.env.set("ORDER", prev + ",root"); + `; + const parentTestScript = ` + const prev = pw.env.get("ORDER"); + pw.env.set("ORDER", prev + ",parent"); + `; + + const result = await testRunner({ + request: makeRESTRequest({ + ...SAMPLE_REQUEST, + testScript: `pw.env.set("ORDER", "request");`, + }), + envs: SAMPLE_ENVS, + response: SAMPLE_RESPONSE, + legacySandbox: false, + // Stored as root → parent, reversed to parent → root during execution + inheritedTestScripts: [rootTestScript, parentTestScript], + })(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + const orderVar = result.right.envs.selected.find( + (v) => v.key === "ORDER" + ); + expect(orderVar?.currentValue).toBe("request,parent,root"); + } + }); + + test("Scripts with same local variable names do not collide (IIFE isolation)", async () => { + const rootTestScript = `const x = "root"; pw.env.set("ROOT_VAR", x);`; + const parentTestScript = `const x = "parent"; pw.env.set("PARENT_VAR", x);`; + + const result = await testRunner({ + request: makeRESTRequest({ + ...SAMPLE_REQUEST, + testScript: `const x = "request"; pw.env.set("REQUEST_VAR", x);`, + }), + envs: SAMPLE_ENVS, + response: SAMPLE_RESPONSE, + legacySandbox: false, + inheritedTestScripts: [rootTestScript, parentTestScript], + })(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + const envVars = result.right.envs.selected; + expect(envVars.find((v) => v.key === "ROOT_VAR")?.currentValue).toBe( + "root" + ); + expect(envVars.find((v) => v.key === "PARENT_VAR")?.currentValue).toBe( + "parent" + ); + expect(envVars.find((v) => v.key === "REQUEST_VAR")?.currentValue).toBe( + "request" + ); + } + }); + + test("Empty inherited test scripts are filtered out gracefully", async () => { + const validScript = ` + pw.test("Valid inherited test", () => { + pw.expect(pw.response.status).toBe(200); + }); + `; + + const result = await testRunner({ + request: makeRESTRequest({ + ...SAMPLE_REQUEST, + testScript: ` + pw.test("Request test", () => { + pw.expect(pw.response.status).toBe(200); + }); + `, + }), + envs: SAMPLE_ENVS, + response: SAMPLE_RESPONSE, + legacySandbox: false, + inheritedTestScripts: ["", " ", validScript, "\n"], + })(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + const { testsReport } = result.right; + const descriptors = testsReport.map((r) => r.descriptor); + + expect(descriptors).toContain("Request test"); + expect(descriptors).toContain("Valid inherited test"); + } + }); + + test("Works correctly with no inherited test scripts (backward compatibility)", async () => { + const result = await testRunner({ + request: makeRESTRequest({ + ...SAMPLE_REQUEST, + testScript: ` + pw.test("Solo request test", () => { + pw.expect(pw.response.status).toBe(200); + }); + `, + }), + envs: SAMPLE_ENVS, + response: SAMPLE_RESPONSE, + legacySandbox: false, + inheritedTestScripts: [], + })(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + const { testsReport } = result.right; + expect(testsReport.map((r) => r.descriptor)).toContain( + "Solo request test" + ); + } + }); + + // Regression: in the legacy (isolated-vm) sandbox, all `pw.test` blocks + // declared in inherited test scripts must register before the runner + // captures results. Pre-fix combineScriptsWithIIFE emitted an outer + // detached Promise so the registration order was undefined when multiple + // inherited scripts were present. + // + // Note: the post-`await` drop is not currently user-reachable here either + // (see pre-request-inheritance.spec.ts for context). User-facing coverage + // for the web worker async path is in packages/hoppscotch-cli/src/__tests__/e2e/. + test("Legacy sandbox registers inherited test scripts", async () => { + const rootTestScript = ` + pw.test("Root collection test", () => { + pw.expect(pw.response.status).toBe(200); + }); + `; + + const result = await testRunner({ + request: makeRESTRequest({ + ...SAMPLE_REQUEST, + testScript: ` + pw.test("Request test", () => { + pw.expect(pw.response.status).toBe(200); + }); + `, + }), + envs: SAMPLE_ENVS, + response: SAMPLE_RESPONSE, + legacySandbox: true, + inheritedTestScripts: [rootTestScript], + })(); + + expect(result).toBeRight(); + + if (E.isRight(result)) { + const descriptors = result.right.testsReport.map((r) => r.descriptor); + expect(descriptors).toContain("Request test"); + expect(descriptors).toContain("Root collection test"); + } + }); +}); diff --git a/packages/hoppscotch-cli/src/interfaces/response.ts b/packages/hoppscotch-cli/src/interfaces/response.ts index 2ce16f21..aa426af8 100644 --- a/packages/hoppscotch-cli/src/interfaces/response.ts +++ b/packages/hoppscotch-cli/src/interfaces/response.ts @@ -39,12 +39,14 @@ export interface RequestRunnerResponse extends TestResponse { * @property {TestResponse} response Response structure for test script runner. * @property {HoppEnvs} envs Environment variables for test script runner. * @property {boolean} legacySandbox Whether to use the legacy sandbox. + * @property {string[]} inheritedTestScripts Test scripts inherited from parent collections. */ export interface TestScriptParams { request: HoppRESTRequest; response: TestResponse; envs: HoppEnvs; legacySandbox: boolean; + inheritedTestScripts?: string[]; } /** diff --git a/packages/hoppscotch-cli/src/types/request.ts b/packages/hoppscotch-cli/src/types/request.ts index 023f86e9..1aaca15c 100644 --- a/packages/hoppscotch-cli/src/types/request.ts +++ b/packages/hoppscotch-cli/src/types/request.ts @@ -44,4 +44,6 @@ export type ProcessRequestParams = { delay: number; legacySandbox?: boolean; collectionVariables?: HoppCollectionVariable[]; + inheritedPreRequestScripts?: string[]; + inheritedTestScripts?: string[]; }; diff --git a/packages/hoppscotch-cli/src/utils/collections.ts b/packages/hoppscotch-cli/src/utils/collections.ts index 124b0471..313e957e 100644 --- a/packages/hoppscotch-cli/src/utils/collections.ts +++ b/packages/hoppscotch-cli/src/utils/collections.ts @@ -34,6 +34,7 @@ import { processRequest, } from "./request"; import { getTestMetrics } from "./test"; +import { filterValidScripts } from "./scripting"; const { WARN, FAIL, INFO } = exceptionColors; @@ -109,8 +110,21 @@ const processCollection = async ( envs: HoppEnvs, delay: number, requestsReport: RequestReport[], - legacySandbox?: boolean + legacySandbox?: boolean, + ancestorPreRequestScripts: string[] = [], + ancestorTestScripts: string[] = [] ) => { + // Accumulate scripts from root -> current collection for inheritance + // filterValidScripts strips empty, whitespace-only, and module-prefix-only scripts + const inheritedPreRequestScripts = filterValidScripts([ + ...ancestorPreRequestScripts, + collection.preRequestScript, + ]); + const inheritedTestScripts = filterValidScripts([ + ...ancestorTestScripts, + collection.testScript, + ]); + // Process each request in the collection for (const request of collection.requests) { const _request = preProcessRequest(request as HoppRESTRequest, collection); @@ -127,6 +141,8 @@ const processCollection = async ( delay, legacySandbox, collectionVariables, + inheritedPreRequestScripts, + inheritedTestScripts, }; // Request processing initiated message. @@ -188,7 +204,9 @@ const processCollection = async ( envs, delay, requestsReport, - legacySandbox + legacySandbox, + inheritedPreRequestScripts, + inheritedTestScripts ); } }; diff --git a/packages/hoppscotch-cli/src/utils/mutators.ts b/packages/hoppscotch-cli/src/utils/mutators.ts index 5348ba17..245486cd 100644 --- a/packages/hoppscotch-cli/src/utils/mutators.ts +++ b/packages/hoppscotch-cli/src/utils/mutators.ts @@ -9,6 +9,9 @@ import { FormDataEntry } from "../types/request"; import { isHoppErrnoException } from "./checks"; import { getResourceContents } from "./getters"; +// Re-export from the canonical implementation in scripting.ts +export { stripModulePrefix } from "./scripting"; + const getValidRequests = ( collections: HoppCollection[], collectionFilePath: string @@ -158,19 +161,3 @@ export async function parseCollectionData( return getValidRequests(collectionSchemaParsedResult.data, pathOrId); } - -/** - * Module prefix added by Monaco editor for TypeScript module mode. - */ -const MODULE_PREFIX = "export {};\n" as const; - -/** - * Strips `export {};\n` prefix from scripts before sandbox execution. - * The prefix is added by the web app's Monaco editor for IntelliSense - * and must be removed before execution. - */ -export const stripModulePrefix = (script: string): string => { - return script.startsWith(MODULE_PREFIX) - ? script.slice(MODULE_PREFIX.length) - : script; -}; diff --git a/packages/hoppscotch-cli/src/utils/pre-request.ts b/packages/hoppscotch-cli/src/utils/pre-request.ts index dea46d10..283f3721 100644 --- a/packages/hoppscotch-cli/src/utils/pre-request.ts +++ b/packages/hoppscotch-cli/src/utils/pre-request.ts @@ -35,13 +35,17 @@ import { isHoppCLIError } from "./checks"; import { arrayFlatMap, arraySort, tupleToRecord } from "./functions/array"; import { getEffectiveFinalMetaData, getResolvedVariables } from "./getters"; import { stripComments } from "./jsonc"; -import { stripModulePrefix, toFormData } from "./mutators"; +import { toFormData } from "./mutators"; +import { combineScriptsWithIIFE, filterValidScripts } from "./scripting"; /** * Runs pre-request-script runner over given request which extracts set ENVs and * applies them on current request to generate updated request. * @param request HoppRESTRequest to be converted to EffectiveHoppRESTRequest. * @param envs Environment variables related to request. + * @param legacySandbox Whether to use the legacy sandbox. + * @param collectionVariables Collection variables to use. + * @param inheritedPreRequestScripts Pre-request scripts inherited from parent collections. * @returns EffectiveHoppRESTRequest that includes parsed ENV variables with in * request OR HoppCLIError with error code and related information. */ @@ -49,7 +53,8 @@ export const preRequestScriptRunner = ( request: HoppRESTRequest, envs: HoppEnvs, legacySandbox: boolean, - collectionVariables?: HoppCollectionVariable[] + collectionVariables?: HoppCollectionVariable[], + inheritedPreRequestScripts: string[] = [] ): TE.TaskEither< HoppCLIError, { effectiveRequest: EffectiveHoppRESTRequest } & { updatedEnvs: HoppEnvs } @@ -57,10 +62,19 @@ export const preRequestScriptRunner = ( const experimentalScriptingSandbox = !legacySandbox; const hoppFetchHook = createHoppFetchHook(); + // Pre-request order: root → request. + const combinedScript = combineScriptsWithIIFE( + filterValidScripts([ + ...inheritedPreRequestScripts, + request.preRequestScript, + ]), + experimentalScriptingSandbox ? "experimental" : "legacy" + ); + return pipe( TE.of(request), - TE.chain(({ preRequestScript }) => - runPreRequestScript(stripModulePrefix(preRequestScript), { + TE.chain(() => + runPreRequestScript(combinedScript, { envs, experimentalScriptingSandbox, request, diff --git a/packages/hoppscotch-cli/src/utils/request.ts b/packages/hoppscotch-cli/src/utils/request.ts index 3a5dda05..3c42dd38 100644 --- a/packages/hoppscotch-cli/src/utils/request.ts +++ b/packages/hoppscotch-cli/src/utils/request.ts @@ -232,8 +232,16 @@ export const processRequest = params: ProcessRequestParams ): T.Task<{ envs: HoppEnvs; report: RequestReport }> => async () => { - const { envs, path, request, delay, legacySandbox, collectionVariables } = - params; + const { + envs, + path, + request, + delay, + legacySandbox, + collectionVariables, + inheritedPreRequestScripts = [], + inheritedTestScripts = [], + } = params; // Initialising updatedEnvs with given parameter envs, will eventually get updated. const result = { @@ -258,17 +266,21 @@ export const processRequest = effectiveFinalParams: [], effectiveFinalURL: "", }; - let updatedEnvs = {}; // Fetch values for secret environment variables from system environment const processedEnvs = processEnvs(envs); - // Executing pre-request-script + // Default envs to the pre-script state so downstream consumers + // (test-runner, effectiveRequest builder) receive a well-shaped + // HoppEnvs even if the pre-request script fails. + let updatedEnvs: HoppEnvs = processedEnvs; + const preRequestRes = await preRequestScriptRunner( request, processedEnvs, legacySandbox ?? false, - collectionVariables + collectionVariables, + inheritedPreRequestScripts )(); if (E.isLeft(preRequestRes)) { printPreRequestRunner.fail(); @@ -317,12 +329,12 @@ export const processRequest = printRequestRunner.success(_requestRunnerRes); } - // Extracting test-script-runner parameters. const testScriptParams = getTestScriptParams( _requestRunnerRes, effectiveRequest, updatedEnvs, - legacySandbox ?? false + legacySandbox ?? false, + inheritedTestScripts ); // Executing test-runner. diff --git a/packages/hoppscotch-cli/src/utils/scripting.ts b/packages/hoppscotch-cli/src/utils/scripting.ts new file mode 100644 index 00000000..5b04271b --- /dev/null +++ b/packages/hoppscotch-cli/src/utils/scripting.ts @@ -0,0 +1,71 @@ +/** + * Module prefix added by Monaco editor for TypeScript module mode. + * Enables IntelliSense and isolates variables across editor instances. + */ +export const MODULE_PREFIX = "export {};\n" as const; + +/** + * Strips `export {};` prefix (with or without newline) from scripts before execution + * (non-module context) or when exporting collections. + */ +export const stripModulePrefix = (script: string): string => { + if (script.startsWith(MODULE_PREFIX)) { + return script.slice(MODULE_PREFIX.length); + } + if (script.startsWith("export {};")) { + return script.slice("export {};".length); + } + return script; +}; + +export type CombineScriptsTarget = "experimental" | "legacy"; + +const wrapScript = (script: string, target: CombineScriptsTarget): string => { + const stripped = stripModulePrefix(script.trim()); + if (!stripped) return ""; + const asyncKeyword = target === "experimental" ? "async " : ""; + return `${asyncKeyword}function() {\n${stripped}\n}`; +}; + +/** + * Combines inherited scripts into a sequential chain. Each script runs in + * its own function for scope isolation. + * + * - `experimental`: `await (async function(){...})();` lines, evaluated in + * an async host context so each `await` settles before the next runs. + * - `legacy`: sync `(function(){...}).call(this);` lines. Top-level `await` + * is rejected at parse time. + */ +export const combineScriptsWithIIFE = ( + scripts: string[], + target: CombineScriptsTarget = "experimental" +): string => { + const fns = scripts.map((s) => wrapScript(s, target)).filter((s) => s); + if (fns.length === 0) return ""; + if (target === "experimental") { + // Wrap the awaited chain in try/catch so top-level throws / rejected + // awaits reach the host reporter; faraday-cage otherwise swallows + // async-boundary errors via its keepAlive loop. + const body = fns.map((fn) => `await (${fn})();`).join("\n"); + return [ + "const __hoppReporter = globalThis.__hoppReportScriptExecutionError;", + "try {", + body, + "} catch (__hoppScriptExecutionError) {", + " __hoppReporter(__hoppScriptExecutionError);", + "}", + ].join("\n"); + } + // Leading `;` guards against ASI: a prior `})` on the host line would + // otherwise be read as a call against our IIFE expression. + return fns.map((fn) => `;(${fn}).call(this);`).join("\n"); +}; + +export const filterValidScripts = ( + scripts: (string | undefined | null)[] +): string[] => + scripts.filter( + (script): script is string => + typeof script === "string" && + stripModulePrefix(script).trim().length > 0 + ); diff --git a/packages/hoppscotch-cli/src/utils/test.ts b/packages/hoppscotch-cli/src/utils/test.ts index 820ed06f..d885b850 100644 --- a/packages/hoppscotch-cli/src/utils/test.ts +++ b/packages/hoppscotch-cli/src/utils/test.ts @@ -18,7 +18,7 @@ import { HoppEnvs } from "../types/request"; import { ExpectResult, TestMetrics, TestRunnerRes } from "../types/response"; import { getDurationInSeconds } from "./getters"; import { createHoppFetchHook } from "./hopp-fetch"; -import { stripModulePrefix } from "./mutators"; +import { combineScriptsWithIIFE, filterValidScripts } from "./scripting"; /** * Executes test script and runs testDescriptorParser to generate test-report using @@ -39,28 +39,46 @@ export const testRunner = ( TE.bind("test_response", () => pipe( TE.of(testScriptData), - TE.chain(({ request, response, envs, legacySandbox }) => { - const { status, statusText, headers, responseTime, body } = response; - - const effectiveResponse = { - status, - statusText, - headers, - responseTime, - body, - }; - - const experimentalScriptingSandbox = !legacySandbox; - const hoppFetchHook = createHoppFetchHook(); - - return runTestScript(stripModulePrefix(request.testScript), { - envs, + TE.chain( + ({ request, - response: effectiveResponse, - experimentalScriptingSandbox, - hoppFetchHook, - }); - }) + response, + envs, + legacySandbox, + inheritedTestScripts = [], + }) => { + const { status, statusText, headers, responseTime, body } = + response; + + const effectiveResponse = { + status, + statusText, + headers, + responseTime, + body, + }; + + const experimentalScriptingSandbox = !legacySandbox; + const hoppFetchHook = createHoppFetchHook(); + + // Test order: request → root (reverse of pre-request). + const combinedScript = combineScriptsWithIIFE( + filterValidScripts([ + request.testScript, + ...inheritedTestScripts.slice().reverse(), + ]), + experimentalScriptingSandbox ? "experimental" : "legacy" + ); + + return runTestScript(combinedScript, { + envs, + request, + response: effectiveResponse, + experimentalScriptingSandbox, + hoppFetchHook, + }); + } + ) ) ), @@ -160,7 +178,8 @@ export const getTestScriptParams = ( reqRunnerRes: RequestRunnerResponse, request: HoppRESTRequest, envs: HoppEnvs, - legacySandbox: boolean + legacySandbox: boolean, + inheritedTestScripts: string[] = [] ) => { const testScriptParams: TestScriptParams = { request, @@ -173,6 +192,7 @@ export const getTestScriptParams = ( }, envs, legacySandbox, + inheritedTestScripts, }; return testScriptParams; }; diff --git a/packages/hoppscotch-cli/src/utils/workspace-access.ts b/packages/hoppscotch-cli/src/utils/workspace-access.ts index 6c47ba83..2c9a947f 100644 --- a/packages/hoppscotch-cli/src/utils/workspace-access.ts +++ b/packages/hoppscotch-cli/src/utils/workspace-access.ts @@ -179,6 +179,8 @@ export const transformWorkspaceCollections = ( headers?: HoppRESTHeaders; variables: HoppCollectionVariable[]; description: string | null; + preRequestScript?: string; + testScript?: string; } = data ? JSON.parse(data) : {}; const { @@ -186,6 +188,8 @@ export const transformWorkspaceCollections = ( headers = [], variables = [], description = null, + preRequestScript = "", + testScript = "", } = parsedData; const transformedAuth = transformAuth(auth); @@ -211,6 +215,8 @@ export const transformWorkspaceCollections = ( headers: transformedHeaders, variables: filteredCollectionVariables, description, + preRequestScript, + testScript, }; }); }; diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 44610f11..a6e89a0e 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -841,6 +841,7 @@ "authorization": "The authorization header will be automatically generated when you send the request.", "collection_properties_authorization": " This authorization will be set for every request in this collection.", "collection_properties_header": "This header will be set for every request in this collection.", + "collection_properties_scripts": "These scripts will run for every request in this collection. Pre-request scripts run before the request, test scripts run after the response.", "generate_documentation_first": "Generate documentation first", "network_fail": "Unable to reach the API endpoint. Check your network connection or select a different Interceptor and try again.", "offline": "You're using Hoppscotch offline. Updates will sync when you're online, based on workspace settings.", @@ -1256,6 +1257,12 @@ "saved": "Response saved", "invalid_name": "Please provide a name for the response" }, + "script": { + "inheriting": "Inheriting scripts from", + "inheriting_from_count": "Inheriting from {count} collection | Inheriting from {count} collections", + "inherited_scripts": "Inherited Scripts", + "view_inherited": "View inherited scripts" + }, "settings": { "accent_color": "Accent color", "account": "Account", @@ -1781,6 +1788,7 @@ "parameters": "Parameters", "post_request_script": "Post-request Script", "pre_request_script": "Pre-request Script", + "scripts": "Scripts", "queries": "Queries", "query": "Query", "schema": "Schema", diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts index 6a8215ea..0a108a93 100644 --- a/packages/hoppscotch-common/src/components.d.ts +++ b/packages/hoppscotch-common/src/components.d.ts @@ -245,6 +245,7 @@ declare module 'vue' { IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default'] IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default'] IconLucideFileQuestion: typeof import('~icons/lucide/file-question')['default'] + IconLucideFileSymlink: typeof import('~icons/lucide/file-symlink')['default'] IconLucideFileText: typeof import('~icons/lucide/file-text')['default'] IconLucideFileX: typeof import('~icons/lucide/file-x')['default'] IconLucideFolder: typeof import('~icons/lucide/folder')['default'] diff --git a/packages/hoppscotch-common/src/components/MonacoScriptEditor.vue b/packages/hoppscotch-common/src/components/MonacoScriptEditor.vue index 9cc7295c..0c9c15d1 100644 --- a/packages/hoppscotch-common/src/components/MonacoScriptEditor.vue +++ b/packages/hoppscotch-common/src/components/MonacoScriptEditor.vue @@ -26,9 +26,11 @@ const props = withDefaults( defineProps<{ modelValue: string type: "pre-request" | "post-request" + readOnly?: boolean }>(), { modelValue: "", + readOnly: false, } ) @@ -40,12 +42,15 @@ const emit = defineEmits<{ const editorModel = ref(null) -const MONACO_EDITOR_OPTIONS: Readonly = - { - automaticLayout: true, - formatOnType: true, - formatOnPaste: true, - } +const MONACO_EDITOR_OPTIONS = computed< + Readonly +>(() => ({ + automaticLayout: true, + formatOnType: !props.readOnly, + formatOnPaste: !props.readOnly, + readOnly: props.readOnly, + domReadOnly: props.readOnly, +})) // Static imports: import X from "URL" const staticImportRegex = @@ -237,3 +242,27 @@ const monacoEditorTheme = computed(() => ["dark", "black"].includes(theme.value) ? "vs-dark" : "vs" ) + + diff --git a/packages/hoppscotch-common/src/components/collections/Properties.vue b/packages/hoppscotch-common/src/components/collections/Properties.vue index a11e1025..9d97acb7 100644 --- a/packages/hoppscotch-common/src/components/collections/Properties.vue +++ b/packages/hoppscotch-common/src/components/collections/Properties.vue @@ -64,6 +64,83 @@ /> + +
+ + +
+
+ +
+
+
+
+ + +
+
+ +
+
+
+
+
+ +
+ + {{ t("helpers.collection_properties_scripts") }} +
+
+
+ diff --git a/packages/hoppscotch-common/src/components/http/PreRequestScript.vue b/packages/hoppscotch-common/src/components/http/PreRequestScript.vue index fba723da..a94a1c46 100644 --- a/packages/hoppscotch-common/src/components/http/PreRequestScript.vue +++ b/packages/hoppscotch-common/src/components/http/PreRequestScript.vue @@ -3,9 +3,26 @@
- +
+ + +
+ () const emit = defineEmits<{ (e: "update:modelValue", value: string): void @@ -121,6 +148,16 @@ const emit = defineEmits<{ const preRequestScript = useVModel(props, "modelValue", emit) +const showInheritedModal = ref(false) + +const inheritedScripts = computed(() => { + return ( + props.inheritedProperties?.scripts?.filter((script) => + hasActualScript(script.preRequestScript) + ) ?? [] + ) +}) + const preRequestEditor = ref(null) const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpPreRequest") diff --git a/packages/hoppscotch-common/src/components/http/RequestOptions.vue b/packages/hoppscotch-common/src/components/http/RequestOptions.vue index 7619b92b..d9ac58a7 100644 --- a/packages/hoppscotch-common/src/components/http/RequestOptions.vue +++ b/packages/hoppscotch-common/src/components/http/RequestOptions.vue @@ -54,17 +54,16 @@ :id="'preRequestScript'" :label="`${t('tab.pre_request_script')}`" :indicator=" - 'preRequestScript' in request && - request.preRequestScript && - request.preRequestScript.length > 0 - ? true - : false + ('preRequestScript' in request && + hasActualScript(request.preRequestScript)) || + hasInheritedPreRequestScripts " >
{ return Boolean(request.value.body.body && request.value.body.body.length > 0) }) +const hasInheritedPreRequestScripts = computed(() => { + return ( + props.inheritedProperties?.scripts?.some((script) => + hasActualScript(script.preRequestScript) + ) ?? false + ) +}) + +const hasInheritedTestScripts = computed(() => { + return ( + props.inheritedProperties?.scripts?.some((script) => + hasActualScript(script.testScript) + ) ?? false + ) +}) + defineActionHandler("request.open-tab", ({ tab }) => { selectedOptionTab.value = tab as RESTOptionTabs }) diff --git a/packages/hoppscotch-common/src/components/http/Tests.vue b/packages/hoppscotch-common/src/components/http/Tests.vue index 9b9ddd25..f00afa83 100644 --- a/packages/hoppscotch-common/src/components/http/Tests.vue +++ b/packages/hoppscotch-common/src/components/http/Tests.vue @@ -3,9 +3,26 @@
- +
+ + +
+ () + const emit = defineEmits(["update:modelValue"]) const testScript = useVModel(props, "modelValue", emit) + +const showInheritedModal = ref(false) + +const inheritedScripts = computed(() => { + return ( + props.inheritedProperties?.scripts?.filter((script) => + hasActualScript(script.testScript) + ) ?? [] + ) +}) + const testScriptEditor = ref(null) const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpTest") diff --git a/packages/hoppscotch-common/src/components/http/test/Runner.vue b/packages/hoppscotch-common/src/components/http/test/Runner.vue index 5600a81a..9290cae1 100644 --- a/packages/hoppscotch-common/src/components/http/test/Runner.vue +++ b/packages/hoppscotch-common/src/components/http/test/Runner.vue @@ -249,6 +249,11 @@ const runTests = async () => { ) let resolvedCollection: HoppCollection = collection.value + // Scripts declared on ancestors above the selected run-start node — must be + // seeded into the runner so partial-scope runs still honor the documented + // Root → Parent → Child → Request inheritance chain. + let ancestorPreRequestScripts: string[] = [] + let ancestorTestScripts: string[] = [] if (!isPersonalWorkspace) { const requestAuth = tab.value.document.inheritedProperties?.auth @@ -270,6 +275,19 @@ const runTests = async () => { tab.value.document.inheritedProperties?.variables ?? [] ) + // Team cascade includes the selected node itself in its scripts array; + // drop it here because runTestCollection will cascade that node's scripts + // as part of the normal tree walk, and we must not double-run them. + const inheritedScripts = ( + tab.value.document.inheritedProperties?.scripts ?? [] + ).filter((s) => s.parentID !== collectionID) + ancestorPreRequestScripts = inheritedScripts + .map((s) => s.preRequestScript) + .filter((s) => s && s.trim().length > 0) + ancestorTestScripts = inheritedScripts + .map((s) => s.testScript) + .filter((s) => s && s.trim().length > 0) + resolvedCollection = { ...collection.value, auth: requestAuth, @@ -277,12 +295,23 @@ const runTests = async () => { variables: parentVariables, } } else { - const { auth, headers, variables } = collectionInheritedProps ?? { + const { + auth, + headers, + variables, + ancestorPreRequestScripts: preAncestors, + ancestorTestScripts: testAncestors, + } = collectionInheritedProps ?? { auth: { authActive: true, authType: "none" }, headers: [], variables: [], + ancestorPreRequestScripts: [], + ancestorTestScripts: [], } + ancestorPreRequestScripts = preAncestors + ancestorTestScripts = testAncestors + resolvedCollection = { ...collection.value, auth, @@ -292,10 +321,16 @@ const runTests = async () => { } testRunnerStopRef.value = false // when testRunnerStopRef is false, the test runner will start running - testRunnerService.runTests(tab, resolvedCollection, { - ...testRunnerConfig.value, - stopRef: testRunnerStopRef, - }) + testRunnerService.runTests( + tab, + resolvedCollection, + { + ...testRunnerConfig.value, + stopRef: testRunnerStopRef, + }, + ancestorPreRequestScripts, + ancestorTestScripts + ) } const stopTests = () => { diff --git a/packages/hoppscotch-common/src/helpers/RequestRunner.ts b/packages/hoppscotch-common/src/helpers/RequestRunner.ts index 52e223c9..2252b421 100644 --- a/packages/hoppscotch-common/src/helpers/RequestRunner.ts +++ b/packages/hoppscotch-common/src/helpers/RequestRunner.ts @@ -27,7 +27,10 @@ import { map } from "fp-ts/Either" import { runPreRequestScript, runTestScript } from "@hoppscotch/js-sandbox/web" import { useSetting } from "~/composables/settings" import { getService } from "~/modules/dioc" -import { stripModulePrefix } from "~/helpers/scripting" +import { + combineScriptsWithIIFE, + hasActualScript, +} from "~/helpers/scripting" import { createHoppFetchHook } from "~/helpers/hopp-fetch" import { KernelInterceptorService } from "~/services/kernel-interceptor.service" import { @@ -364,14 +367,21 @@ const delegatePreRequestScriptRunner = ( selected: Environment["variables"] temp: Environment["variables"] }, - cookies: Cookie[] | null + cookies: Cookie[] | null, + inheritedPreRequestScripts: string[] = [] ): Promise> => { const { preRequestScript } = request + const experimentalScriptingSandbox = EXPERIMENTAL_SCRIPTING_SANDBOX.value + const target = experimentalScriptingSandbox ? "experimental" : "legacy" - const cleanScript = stripModulePrefix(preRequestScript) + // Pre-request order: root → request. + const combinedScript = combineScriptsWithIIFE( + [...inheritedPreRequestScripts, preRequestScript], + target + ) // Short-circuit empty scripts to avoid unnecessary WASM initialization - if (cleanScript.trim().length === 0) { + if (combinedScript.length === 0) { return Promise.resolve( E.right({ updatedEnvs: envs, @@ -380,19 +390,16 @@ const delegatePreRequestScriptRunner = ( ) } - if (!EXPERIMENTAL_SCRIPTING_SANDBOX.value) { - // Strip `export {};\n` before executing in legacy sandbox to prevent syntax errors - - return runPreRequestScript(cleanScript, { + if (!experimentalScriptingSandbox) { + return runPreRequestScript(combinedScript, { envs, experimentalScriptingSandbox: false, }) } - // Experimental sandbox enabled - use faraday-cage with hook const hoppFetchHook = createHoppFetchHook(kernelInterceptorService) - return runPreRequestScript(cleanScript, { + return runPreRequestScript(combinedScript, { envs, request, cookies, @@ -405,14 +412,21 @@ const runPostRequestScript = ( envs: TestResult["envs"], request: HoppRESTRequest, response: HoppRESTResponse, - cookies: Cookie[] | null + cookies: Cookie[] | null, + inheritedTestScripts: string[] = [] ): Promise> => { const { testScript } = request + const experimentalScriptingSandbox = EXPERIMENTAL_SCRIPTING_SANDBOX.value + const target = experimentalScriptingSandbox ? "experimental" : "legacy" - const cleanScript = stripModulePrefix(testScript) + // Test order: request → root (reverse of pre-request). + const combinedScript = combineScriptsWithIIFE( + [testScript, ...inheritedTestScripts.slice().reverse()], + target + ) // Short-circuit empty scripts to avoid unnecessary WASM initialization - if (cleanScript.trim().length === 0) { + if (combinedScript.length === 0) { return Promise.resolve( E.right({ tests: { descriptor: "root", expectResults: [], children: [] }, @@ -423,20 +437,17 @@ const runPostRequestScript = ( ) } - if (!EXPERIMENTAL_SCRIPTING_SANDBOX.value) { - // Strip `export {};\n` before executing in legacy sandbox to prevent syntax errors - - return runTestScript(cleanScript, { + if (!experimentalScriptingSandbox) { + return runTestScript(combinedScript, { envs, response, experimentalScriptingSandbox: false, }) } - // Experimental sandbox enabled - use faraday-cage with hook const hoppFetchHook = createHoppFetchHook(kernelInterceptorService) - return runTestScript(cleanScript, { + return runTestScript(combinedScript, { envs, request, response, @@ -497,10 +508,20 @@ export function runRESTRequest$( initialEnvsForComparison, } = captureInitialEnvironmentState() + // Extract inherited scripts from collection hierarchy, filtering out empty/module-prefix-only scripts + const inheritedScripts = inheritedProperties?.scripts ?? [] + const inheritedPreRequestScripts = inheritedScripts + .map((s) => s.preRequestScript) + .filter(hasActualScript) + const inheritedTestScripts = inheritedScripts + .map((s) => s.testScript) + .filter(hasActualScript) + const res = delegatePreRequestScriptRunner( resolvedRequest, initialEnvs, - cookieJarEntries + cookieJarEntries, + inheritedPreRequestScripts ).then(async (preRequestScriptResult) => { if (cancelCalled) return E.left("cancellation" as const) @@ -579,7 +600,8 @@ export function runRESTRequest$( statusText: res.statusText, responseTime: res.meta.responseDuration, }, - preRequestScriptResult.right.updatedCookies ?? null + preRequestScriptResult.right.updatedCookies ?? null, + inheritedTestScripts ) if (E.isRight(postRequestScriptResult)) { @@ -795,7 +817,9 @@ export async function runTestRunnerRequest( request: HoppRESTRequest, persistEnv = true, inheritedVariables: HoppCollectionVariable[] = [], - initialEnvironmentState: InitialEnvironmentState + initialEnvironmentState: InitialEnvironmentState, + inheritedPreRequestScripts: string[] = [], + inheritedTestScripts: string[] = [] ): Promise< | E.Left<"script_fail"> | E.Right<{ @@ -824,7 +848,8 @@ export async function runTestRunnerRequest( return delegatePreRequestScriptRunner( request, initialEnvs, - cookieJarEntries + cookieJarEntries, + inheritedPreRequestScripts ).then(async (preRequestScriptResult) => { if (E.isLeft(preRequestScriptResult)) { console.error("[Pre-Request Script Error]", preRequestScriptResult.left) @@ -883,7 +908,8 @@ export async function runTestRunnerRequest( statusText: res.statusText, responseTime: res.meta.responseDuration, }, - preRequestScriptResult.right.updatedCookies ?? null + preRequestScriptResult.right.updatedCookies ?? null, + inheritedTestScripts ) if (E.isRight(postRequestScriptResult)) { diff --git a/packages/hoppscotch-common/src/helpers/backend/helpers.ts b/packages/hoppscotch-common/src/helpers/backend/helpers.ts index a7a2848e..e75312c8 100644 --- a/packages/hoppscotch-common/src/helpers/backend/helpers.ts +++ b/packages/hoppscotch-common/src/helpers/backend/helpers.ts @@ -40,6 +40,8 @@ export type CollectionDataProps = { headers: HoppRESTHeaders variables: HoppCollectionVariable[] description: string | null + preRequestScript: string + testScript: string } export const BACKEND_PAGE_SIZE = 10 @@ -121,6 +123,8 @@ const parseCollectionData = ( headers: [], variables: [], description: null, + preRequestScript: "", + testScript: "", } if (!data) { @@ -159,11 +163,23 @@ const parseCollectionData = ( ? parsedData.description : defaultDataProps.description + const preRequestScript = + typeof parsedData?.preRequestScript === "string" + ? parsedData.preRequestScript + : defaultDataProps.preRequestScript + + const testScript = + typeof parsedData?.testScript === "string" + ? parsedData.testScript + : defaultDataProps.testScript + return { auth, headers, variables, description, + preRequestScript, + testScript, } } @@ -171,9 +187,14 @@ const parseCollectionData = ( export const teamCollectionJSONToHoppRESTColl = ( coll: TeamCollectionJSON ): HoppCollection => { - const { auth, headers, variables, description } = parseCollectionData( - coll.data - ) + const { + auth, + headers, + variables, + description, + preRequestScript, + testScript, + } = parseCollectionData(coll.data) return makeCollection({ id: coll.id, @@ -184,6 +205,8 @@ export const teamCollectionJSONToHoppRESTColl = ( headers, variables, description, + preRequestScript, + testScript, }) } @@ -247,7 +270,14 @@ export const teamCollToHoppRESTColl = ( description: null, } - const { auth, headers, variables, description } = parseCollectionData(data) + const { + auth, + headers, + variables, + description, + preRequestScript, + testScript, + } = parseCollectionData(data) return makeCollection({ id: coll.id, @@ -258,6 +288,8 @@ export const teamCollToHoppRESTColl = ( headers: headers ?? [], variables: variables ?? [], description: description ?? null, + preRequestScript: preRequestScript ?? "", + testScript: testScript ?? "", }) } diff --git a/packages/hoppscotch-common/src/helpers/backend/queries/PublishedDocs.ts b/packages/hoppscotch-common/src/helpers/backend/queries/PublishedDocs.ts index 05b73ac3..806ef75c 100644 --- a/packages/hoppscotch-common/src/helpers/backend/queries/PublishedDocs.ts +++ b/packages/hoppscotch-common/src/helpers/backend/queries/PublishedDocs.ts @@ -98,6 +98,8 @@ function parseCollectionDataFromString(data?: string): CollectionDataProps { headers: [], variables: [], description: null, + preRequestScript: "", + testScript: "", } if (!data) { @@ -111,6 +113,9 @@ function parseCollectionDataFromString(data?: string): CollectionDataProps { headers: parsed.headers || defaultDataProps.headers, variables: parsed.variables || defaultDataProps.variables, description: parsed.description || defaultDataProps.description, + preRequestScript: + parsed.preRequestScript || defaultDataProps.preRequestScript, + testScript: parsed.testScript || defaultDataProps.testScript, } } catch (error) { console.error("Failed to parse collection data:", error) @@ -127,8 +132,14 @@ export function collectionFolderToHoppCollection( folder: CollectionFolder ): HoppCollection { // Parse the data field to extract auth, headers, variables, and description - const { auth, headers, variables, description } = - parseCollectionDataFromString(folder.data) + const { + auth, + headers, + variables, + description, + preRequestScript, + testScript, + } = parseCollectionDataFromString(folder.data) return makeCollection({ name: folder.name, @@ -139,6 +150,8 @@ export function collectionFolderToHoppCollection( variables, description, id: folder.id, + preRequestScript: preRequestScript ?? "", + testScript: testScript ?? "", }) } diff --git a/packages/hoppscotch-common/src/helpers/collection/collection.ts b/packages/hoppscotch-common/src/helpers/collection/collection.ts index 58aa4abf..f56c656c 100644 --- a/packages/hoppscotch-common/src/helpers/collection/collection.ts +++ b/packages/hoppscotch-common/src/helpers/collection/collection.ts @@ -311,6 +311,8 @@ export function transformCollectionForImport( headers: collection.headers, variables: collection.variables, description: collection.description, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", } const obj: CollectionFolder = { diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/har.ts b/packages/hoppscotch-common/src/helpers/import-export/import/har.ts index ec937877..3ca073b0 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/har.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/har.ts @@ -39,6 +39,8 @@ export const harImporter = ( headers: [], description: null, variables: [], + preRequestScript: "", + testScript: "", }) return E.right([collection]) diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/insomnia/insomniaColl.ts b/packages/hoppscotch-common/src/helpers/import-export/import/insomnia/insomniaColl.ts index 6d1bb907..c2b84b16 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/insomnia/insomniaColl.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/insomnia/insomniaColl.ts @@ -245,6 +245,8 @@ const getHoppFolder = ( headers: [], variables: getCollectionVariables(undefined, folderRes), // undefined is used to indicate no environment variables for v4 and below description: folderRes.meta?.description ?? null, + preRequestScript: "", + testScript: "", }) const getHoppCollections = (docs: InsomniaDoc[]) => { @@ -283,6 +285,8 @@ const getParsedHoppFolder = ( headers: [], variables: getCollectionVariables(collection.environment), description: collection.meta.description ?? null, + preRequestScript: "", + testScript: "", }) } @@ -323,6 +327,8 @@ const getParsedHoppCollections = (docs: InsomniaDocV5[]): HoppCollection[] => headers: [], variables: getCollectionVariables(doc.environments?.data), description: doc.meta.description ?? null, + preRequestScript: "", + testScript: "", }) } diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/openapi/index.ts b/packages/hoppscotch-common/src/helpers/import-export/import/openapi/index.ts index 7cccec7e..001ea7de 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/openapi/index.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/openapi/index.ts @@ -1186,6 +1186,8 @@ const convertOpenApiDocsToHopp = ( auth: { authType: "inherit", authActive: true }, headers: [], variables: [], + preRequestScript: "", + testScript: "", }) ), requests: requestsWithoutTags, @@ -1193,6 +1195,8 @@ const convertOpenApiDocsToHopp = ( headers: [], variables: [], description, + preRequestScript: "", + testScript: "", }) }) diff --git a/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts b/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts index 797136b2..0245eae4 100644 --- a/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts +++ b/packages/hoppscotch-common/src/helpers/import-export/import/postman.ts @@ -550,6 +550,36 @@ const getHoppScripts = ( return { preRequestScript, testScript } } +/** + * Extracts pre-request and test scripts from a Postman ItemGroup (collection/folder) + * Postman collections and folders can have their own scripts that run before/after all requests + */ +const getHoppCollectionScripts = ( + ig: ItemGroup, + importScripts: boolean +): { preRequestScript: string; testScript: string } => { + if (!importScripts) { + return { preRequestScript: "", testScript: "" } + } + + let preRequestScript = "" + let testScript = "" + + // ItemGroup (collection/folder) stores scripts in the events property + if (ig.events) { + const events = ig.events.all() + events.forEach((event: any) => { + if (event.listen === "prerequest") { + preRequestScript = extractScriptFromEvent(event) + } else if (event.listen === "test") { + testScript = extractScriptFromEvent(event) + } + }) + } + + return { preRequestScript, testScript } +} + const getCollectionDescription = ( docField?: string | DescriptionDefinition ): string | null => { @@ -609,8 +639,13 @@ const getHoppRequest = ( const getHoppFolder = ( ig: ItemGroup, importScripts: boolean -): HoppCollection => - makeCollection({ +): HoppCollection => { + const { preRequestScript, testScript } = getHoppCollectionScripts( + ig, + importScripts + ) + + return makeCollection({ name: ig.name, folders: pipe( ig.items.all(), @@ -626,7 +661,10 @@ const getHoppFolder = ( headers: [], variables: getHoppCollVariables(ig), description: getCollectionDescription(ig.description), + preRequestScript, + testScript, }) +} export const getHoppCollections = ( collections: PMCollection[], diff --git a/packages/hoppscotch-common/src/helpers/mockServer/exampleCollection.ts b/packages/hoppscotch-common/src/helpers/mockServer/exampleCollection.ts index 2a268076..a69aeef0 100644 --- a/packages/hoppscotch-common/src/helpers/mockServer/exampleCollection.ts +++ b/packages/hoppscotch-common/src/helpers/mockServer/exampleCollection.ts @@ -323,5 +323,9 @@ export function createExamplePetStoreCollection( authActive: true, }, headers: [], + variables: [], + description: null, + preRequestScript: "", + testScript: "", }) } diff --git a/packages/hoppscotch-common/src/helpers/mockServer/exampleMockCollection.ts b/packages/hoppscotch-common/src/helpers/mockServer/exampleMockCollection.ts index d1a98586..a4f85bae 100644 --- a/packages/hoppscotch-common/src/helpers/mockServer/exampleMockCollection.ts +++ b/packages/hoppscotch-common/src/helpers/mockServer/exampleMockCollection.ts @@ -328,6 +328,9 @@ export async function createMockCollectionForPersonal( auth: data.auth, headers: data.headers, variables: data.variables, + description: null, + preRequestScript: "", + testScript: "", }) // Add the backend ID to the collection diff --git a/packages/hoppscotch-common/src/helpers/scripting.ts b/packages/hoppscotch-common/src/helpers/scripting.ts index b7cda9fa..39b5f4c1 100644 --- a/packages/hoppscotch-common/src/helpers/scripting.ts +++ b/packages/hoppscotch-common/src/helpers/scripting.ts @@ -9,14 +9,77 @@ export const MODULE_PREFIX = "export {};\n" as const * (non-module context) or when exporting collections. */ export const stripModulePrefix = (script: string): string => { - return script.startsWith(MODULE_PREFIX) - ? script.slice(MODULE_PREFIX.length) - : script + if (script.startsWith(MODULE_PREFIX)) { + return script.slice(MODULE_PREFIX.length) + } + if (script.startsWith("export {};")) { + return script.slice("export {};".length) + } + return script } /** - * Regex for stripping the JSON-serialized module prefix (`export {};\\n`) - * from scripts during collection exports. - * Note: This matches the literal backslash-n (`\\n`), not an actual newline character. + * Anchored to JSON value-opening delimiters so it only matches inside JSON + * string values during collection export, not inside script source. Matches + * both `export {};\\n` and `export {};` (`\\n` is the literal backslash-n + * pair, not a newline). */ -export const MODULE_PREFIX_REGEX_JSON_SERIALIZED = /export \{\};\\n/g +export const MODULE_PREFIX_REGEX_JSON_SERIALIZED = + /(?<=:\s*")export \{\};(?:\\n)?/g + +export type CombineScriptsTarget = "experimental" | "legacy" + +const wrapScript = (script: string, target: CombineScriptsTarget): string => { + const stripped = stripModulePrefix(script.trim()) + if (!stripped) return "" + const asyncKeyword = target === "experimental" ? "async " : "" + return `${asyncKeyword}function() {\n${stripped}\n}` +} + +/** + * Combines inherited scripts into a sequential chain. Each script runs in + * its own function for scope isolation. + * + * - `experimental`: `await (async function(){...})();` lines, evaluated in + * an async host context so each `await` settles before the next runs. + * - `legacy`: sync `(function(){...}).call(this);` lines. Top-level `await` + * is rejected at parse time. + */ +export const combineScriptsWithIIFE = ( + scripts: string[], + target: CombineScriptsTarget = "experimental" +): string => { + const fns = scripts.map((s) => wrapScript(s, target)).filter((s) => s) + if (fns.length === 0) return "" + if (target === "experimental") { + // Wrap the entire awaited chain in try/catch so a top-level throw (or a + // rejected await) surfaces synchronously via the host reporter. + // faraday-cage swallows rejected keepAlive promises and does not await + // afterScriptExecutionHooks, so this is the only reliable channel for + // async-boundary errors to reach the host caller. + // + // The reporter is captured in a const before the try so a user script + // that tampers with `globalThis.__hoppReportScriptExecutionError` + // inside the try body cannot suppress the report. Bootstrap installs + // the property as non-writable and non-configurable for defense in + // depth; the lexical capture makes that redundant but explicit. + const body = fns.map((fn) => `await (${fn})();`).join("\n") + return [ + "const __hoppReporter = globalThis.__hoppReportScriptExecutionError;", + "try {", + body, + "} catch (__hoppScriptExecutionError) {", + " __hoppReporter(__hoppScriptExecutionError);", + "}", + ].join("\n") + } + // Leading `;` guards against ASI: a prior `})` on the host line would + // otherwise be read as a call against our IIFE expression. + return fns.map((fn) => `;(${fn}).call(this);`).join("\n") +} + +// Monaco prepends "export {};\n" to empty scripts — strip before checking. +export const hasActualScript = (script: string | undefined | null): boolean => { + if (!script) return false + return stripModulePrefix(script.trim()).length > 0 +} diff --git a/packages/hoppscotch-common/src/helpers/teams/TeamCollectionAdapter.ts b/packages/hoppscotch-common/src/helpers/teams/TeamCollectionAdapter.ts index b4b4998e..0850fbea 100644 --- a/packages/hoppscotch-common/src/helpers/teams/TeamCollectionAdapter.ts +++ b/packages/hoppscotch-common/src/helpers/teams/TeamCollectionAdapter.ts @@ -1,12 +1,9 @@ import * as E from "fp-ts/Either" import { BehaviorSubject, Subscription } from "rxjs" -import { - HoppCollectionVariable, - HoppRESTAuth, - HoppRESTHeader, - translateToNewRequest, -} from "@hoppscotch/data" +import { HoppCollectionVariable, translateToNewRequest } from "@hoppscotch/data" import { pull, remove } from "lodash-es" +import { hasActualScript } from "~/helpers/scripting" +import { CollectionDataProps } from "~/helpers/backend/helpers" import { Subscription as WSubscription } from "wonka" import { runGQLQuery, runGQLSubscription } from "../backend/GQLClient" import { TeamCollection } from "./TeamCollection" @@ -1093,14 +1090,16 @@ export default class NewTeamCollectionAdapter { const variables: HoppInheritedProperty["variables"] = [] - if (!folderPath) return { auth, headers, variables } + const scripts: HoppInheritedProperty["scripts"] = [] + + if (!folderPath) return { auth, headers, variables, scripts } const path = folderPath.split("/") // Check if the path is empty or invalid if (!path || path.length === 0) { console.error("Invalid path:", folderPath) - return { auth, headers, variables } + return { auth, headers, variables, scripts } } // Loop through the path and get the last parent folder with authType other than 'inherit' @@ -1110,20 +1109,12 @@ export default class NewTeamCollectionAdapter { // Check if parentFolder is undefined or null if (!parentFolder) { console.error("Parent folder not found for path:", path) - return { auth, headers, variables } + return { auth, headers, variables, scripts } } - const data: { - auth: HoppRESTAuth - headers: HoppRESTHeader[] - variables: HoppCollectionVariable[] - } = parentFolder.data + const data: Partial = parentFolder.data ? JSON.parse(parentFolder.data) - : { - auth: null, - headers: null, - variables: null, - } + : {} if (!data.auth) { data.auth = { @@ -1141,6 +1132,8 @@ export default class NewTeamCollectionAdapter { const parentFolderAuth = data.auth const parentFolderHeaders = data.headers const parentFolderVariables = data.variables + const parentFolderPreRequestScript = data.preRequestScript ?? "" + const parentFolderTestScript = data.testScript ?? "" if ( parentFolderAuth?.authType === "inherit" && @@ -1200,8 +1193,23 @@ export default class NewTeamCollectionAdapter { ), }) } + + // Update scripts + if ( + hasActualScript(parentFolderPreRequestScript) || + hasActualScript(parentFolderTestScript) + ) { + const currentPath = [...path.slice(0, i + 1)].join("/") + + scripts.push({ + parentID: parentFolder.id ?? currentPath, + parentName: parentFolder.title, + preRequestScript: parentFolderPreRequestScript, + testScript: parentFolderTestScript, + }) + } } - return { auth, headers, variables } + return { auth, headers, variables, scripts } } } diff --git a/packages/hoppscotch-common/src/helpers/teams/TeamsSearch.service.ts b/packages/hoppscotch-common/src/helpers/teams/TeamsSearch.service.ts index 4d75a395..d7fa53df 100644 --- a/packages/hoppscotch-common/src/helpers/teams/TeamsSearch.service.ts +++ b/packages/hoppscotch-common/src/helpers/teams/TeamsSearch.service.ts @@ -1,7 +1,5 @@ import { - HoppCollectionVariable, HoppRESTAuth, - HoppRESTHeader, HoppRESTRequest, getDefaultRESTRequest, } from "@hoppscotch/data" @@ -10,6 +8,7 @@ import { Service } from "dioc" import * as E from "fp-ts/Either" import { Ref, ref } from "vue" import { getSingleCollection, TeamCollection } from "./TeamCollection" +import { hasActualScript } from "~/helpers/scripting" import { platform } from "~/platform" import { HoppInheritedProperty } from "../types/HoppInheritedProperties" @@ -19,6 +18,24 @@ import { TeamRequest, getCollectionChildCollections, } from "./TeamRequest" +import { CollectionDataProps } from "../backend/helpers" + +/** + * Parses collection data that may be double-encoded JSON + * Handles both single and double JSON stringification + */ +const parseCollectionData = (data: string): CollectionDataProps | null => { + try { + let parsed = JSON.parse(data) + // Handle double-encoded JSON (string containing JSON string) + if (typeof parsed === "string") { + parsed = JSON.parse(parsed) + } + return parsed as CollectionDataProps + } catch { + return null + } +} type CollectionSearchMeta = { isSearchResult?: boolean @@ -354,6 +371,8 @@ export class TeamSearchService extends Service { const defaultInheritedVariables: HoppInheritedProperty["variables"] = [] + const defaultInheritedScripts: HoppInheritedProperty["scripts"] = [] + const collection = Object.values(this.searchResultsCollections).find( (col) => col.id === collectionID ) @@ -363,12 +382,14 @@ export class TeamSearchService extends Service { auth: defaultInheritedAuth, headers: defaultInheritedHeaders, variables: defaultInheritedVariables, + scripts: defaultInheritedScripts, } const inheritedAuthData = this.findInheritableParentAuth(collectionID) const inheritedHeadersData = this.findInheritableParentHeaders(collectionID) const inheritedVariablesData = this.findInheritableParentVariables(collectionID) + const inheritedScriptsData = this.findInheritableParentScripts(collectionID) return { auth: E.isRight(inheritedAuthData) @@ -380,6 +401,9 @@ export class TeamSearchService extends Service { variables: E.isRight(inheritedVariablesData) ? Object.values(inheritedVariablesData.right) : defaultInheritedVariables, + scripts: E.isRight(inheritedScriptsData) + ? Object.values(inheritedScriptsData.right) + : defaultInheritedScripts, } } @@ -403,13 +427,9 @@ export class TeamSearchService extends Service { // has inherited data if (collection.data) { - const parentInheritedData = JSON.parse(collection.data) as { - auth?: HoppRESTAuth - headers?: HoppRESTHeader[] - variables?: HoppCollectionVariable[] - } + const parentInheritedData = parseCollectionData(collection.data) - const inheritedAuth = parentInheritedData.auth + const inheritedAuth = parentInheritedData?.auth if (inheritedAuth && inheritedAuth.authType !== "inherit") { return E.right({ @@ -447,13 +467,9 @@ export class TeamSearchService extends Service { // see if it has headers to inherit, if yes, add it to the existing headers if (collection.data) { - const parentInheritedData = JSON.parse(collection.data) as { - auth?: HoppRESTAuth - headers?: HoppRESTHeader[] - variables?: HoppCollectionVariable[] - } + const parentInheritedData = parseCollectionData(collection.data) - const inheritedHeaders = parentInheritedData.headers + const inheritedHeaders = parentInheritedData?.headers if (inheritedHeaders) { inheritedHeaders.forEach((header) => { @@ -493,13 +509,9 @@ export class TeamSearchService extends Service { } if (collection.data) { - const parentData = JSON.parse(collection.data) as { - auth?: HoppRESTAuth - headers?: HoppRESTHeader[] - variables?: HoppCollectionVariable[] - } + const parentData = parseCollectionData(collection.data) - const variables = parentData.variables + const variables = parentData?.variables if (variables) { vars.push({ @@ -517,6 +529,51 @@ export class TeamSearchService extends Service { return E.right(vars) } + findInheritableParentScripts = ( + collectionID: string, + existingScripts: HoppInheritedProperty["scripts"] = [] + ): E.Either => { + const collection = Object.values(this.searchResultsCollections).find( + (col) => col.id === collectionID + ) + + if (!collection) { + return E.left("PARENT_NOT_FOUND" as const) + } + + // Recurse to parent first to build root→parent→child order + let scripts = [...existingScripts] + if (collection.parentID) { + const parentResult = this.findInheritableParentScripts( + collection.parentID, + scripts + ) + if (E.isLeft(parentResult)) { + return parentResult + } + scripts = parentResult.right + } + + // Then add current collection's scripts + if (collection.data) { + const parentData = parseCollectionData(collection.data) + + const preRequestScript = parentData?.preRequestScript ?? "" + const testScript = parentData?.testScript ?? "" + + if (hasActualScript(preRequestScript) || hasActualScript(testScript)) { + scripts.push({ + parentID: collection.id, + parentName: collection.title, + preRequestScript, + testScript, + }) + } + } + + return E.right(scripts) + } + expandCollection = async (collectionID: string) => { if (this.expandingCollections.value.includes(collectionID)) return diff --git a/packages/hoppscotch-common/src/helpers/types/HoppInheritedProperties.ts b/packages/hoppscotch-common/src/helpers/types/HoppInheritedProperties.ts index 05609b43..bfb986ac 100644 --- a/packages/hoppscotch-common/src/helpers/types/HoppInheritedProperties.ts +++ b/packages/hoppscotch-common/src/helpers/types/HoppInheritedProperties.ts @@ -23,4 +23,10 @@ export type HoppInheritedProperty = { parentName: string inheritedVariables: HoppCollectionVariable[] }[] + scripts: { + parentID: string + parentName: string + preRequestScript: string + testScript: string + }[] } diff --git a/packages/hoppscotch-common/src/newstore/collections.ts b/packages/hoppscotch-common/src/newstore/collections.ts index ddbf795a..f33567a7 100644 --- a/packages/hoppscotch-common/src/newstore/collections.ts +++ b/packages/hoppscotch-common/src/newstore/collections.ts @@ -11,6 +11,7 @@ import { GQLHeader, } from "@hoppscotch/data" import { cloneDeep } from "lodash-es" +import { hasActualScript } from "~/helpers/scripting" import { pluck } from "rxjs/operators" import { resolveSaveContextOnRequestReorder } from "~/helpers/collection/request" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" @@ -38,6 +39,8 @@ const defaultRESTCollectionState = { headers: [], variables: [], description: null, + preRequestScript: "", + testScript: "", }), ], } @@ -55,6 +58,8 @@ const defaultGraphqlCollectionState = { headers: [], variables: [], description: null, + preRequestScript: "", + testScript: "", }), ], } @@ -142,14 +147,16 @@ export function cascadeParentCollectionForProperties( const variables: HoppInheritedProperty["variables"] = [] - if (!folderPath) return { auth, headers, variables } + const scripts: HoppInheritedProperty["scripts"] = [] + + if (!folderPath) return { auth, headers, variables, scripts } const path = folderPath.split("/").map((i) => parseInt(i)) // Check if the path is empty or invalid if (!path || path.length === 0) { console.error("Invalid path:", folderPath) - return { auth, headers, variables } + return { auth, headers, variables, scripts } } // Loop through the path and get the last parent folder with authType other than 'inherit' @@ -162,7 +169,7 @@ export function cascadeParentCollectionForProperties( // Check if parentFolder is undefined or null if (!parentFolder) { console.error("Parent folder not found for path:", path) - return { auth, headers, variables } + return { auth, headers, variables, scripts } } const parentFolderAuth = parentFolder.auth as HoppRESTAuth | HoppGQLAuth @@ -223,9 +230,27 @@ export function cascadeParentCollectionForProperties( ), }) } + + // Collect scripts from the collection hierarchy (root to child order) + const parentPreRequestScript = parentFolder.preRequestScript ?? "" + const parentTestScript = parentFolder.testScript ?? "" + + if ( + hasActualScript(parentPreRequestScript) || + hasActualScript(parentTestScript) + ) { + const currentPath = [...path.slice(0, i + 1)].join("/") + + scripts.push({ + parentID: parentFolder._ref_id ?? parentFolder.id ?? currentPath, + parentName: parentFolder.name, + preRequestScript: parentPreRequestScript, + testScript: parentTestScript, + }) + } } - return { auth, headers, variables } + return { auth, headers, variables, scripts } } function reorderItems(array: unknown[], from: number, to: number) { @@ -365,6 +390,8 @@ const restCollectionDispatchers = defineDispatchers({ headers: [], variables: [], description: null, + preRequestScript: "", + testScript: "", }) const newState = state @@ -1026,6 +1053,8 @@ const gqlCollectionDispatchers = defineDispatchers({ headers: [], variables: [], description: null, + preRequestScript: "", + testScript: "", }) const newState = state const indexPaths = path.split("/").map((x) => parseInt(x)) @@ -1367,18 +1396,26 @@ export function getRESTCollection(collectionIndex: number) { return restCollectionStore.value.state[collectionIndex] } +export type RESTCollectionInheritedProps = { + auth: HoppRESTAuth + headers: HoppRESTHeaders + variables: HoppCollectionVariable[] + // Ancestor scripts for partial-scope runs (root → target's parent). + // Empty when running from the topmost collection. + ancestorPreRequestScripts: string[] + ancestorTestScripts: string[] +} + function computeCollectionInheritedProps( collection: HoppCollection, ref_id: string, type: "my-collections" | "team-collections" = "my-collections", parentAuth: HoppRESTAuth | null = null, parentHeaders: HoppRESTHeaders | null = null, - parentVariables: HoppCollectionVariable[] | null = null -): { - auth: HoppRESTAuth - headers: HoppRESTHeaders - variables: HoppCollectionVariable[] -} | null { + parentVariables: HoppCollectionVariable[] | null = null, + parentPreRequestScripts: string[] = [], + parentTestScripts: string[] = [] +): RESTCollectionInheritedProps | null { // Determine the inherited authentication and headers const inheritedAuth = collection.auth?.authType === "inherit" && collection.auth.authActive @@ -1406,9 +1443,19 @@ function computeCollectionInheritedProps( auth: inheritedAuth, headers: inheritedHeaders, variables: inheritedVariables, + ancestorPreRequestScripts: parentPreRequestScripts, + ancestorTestScripts: parentTestScripts, } } + // Append this collection's scripts before descending so children see them. + const nextPreRequestScripts = hasActualScript(collection.preRequestScript) + ? [...parentPreRequestScripts, collection.preRequestScript] + : parentPreRequestScripts + const nextTestScripts = hasActualScript(collection.testScript) + ? [...parentTestScripts, collection.testScript] + : parentTestScripts + // Recursively search in folders for (const folder of collection.folders) { const result = computeCollectionInheritedProps( @@ -1417,7 +1464,9 @@ function computeCollectionInheritedProps( type, inheritedAuth, inheritedHeaders, - inheritedVariables + inheritedVariables, + nextPreRequestScripts, + nextTestScripts ) if (result) return result // Return as soon as a match is found } @@ -1429,11 +1478,7 @@ export function getRESTCollectionInheritedProps( collectionID: string, collections: HoppCollection[] = restCollectionStore.value.state, type: "my-collections" | "team-collections" = "my-collections" -): { - auth: HoppRESTAuth - headers: HoppRESTHeaders - variables: HoppCollectionVariable[] -} | null { +): RESTCollectionInheritedProps | null { for (const collection of collections) { const result = computeCollectionInheritedProps( collection, diff --git a/packages/hoppscotch-common/src/pages/view/_id/_version.vue b/packages/hoppscotch-common/src/pages/view/_id/_version.vue index 04a8f320..cb6aa944 100644 --- a/packages/hoppscotch-common/src/pages/view/_id/_version.vue +++ b/packages/hoppscotch-common/src/pages/view/_id/_version.vue @@ -53,6 +53,7 @@ import { translateToNewEnvironmentVariables, } from "@hoppscotch/data" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" +import { hasActualScript } from "~/helpers/scripting" import { PublishedDocREST, PublishedDocsVersion, @@ -151,6 +152,23 @@ const flattenCollection = ( })), }, ], + scripts: [ + ...(inheritedProperties?.scripts || []), + ...(hasActualScript(collection.preRequestScript) || + hasActualScript(collection.testScript) + ? [ + { + parentID: + collection.id || + collection._ref_id || + `collection-${collection.name}`, + parentName: collection.name, + preRequestScript: collection.preRequestScript || "", + testScript: collection.testScript || "", + }, + ] + : []), + ], } if (collection.folders && collection.folders.length > 0) { diff --git a/packages/hoppscotch-common/src/services/__tests__/documentation.service.spec.ts b/packages/hoppscotch-common/src/services/__tests__/documentation.service.spec.ts index 963f106a..409fa94e 100644 --- a/packages/hoppscotch-common/src/services/__tests__/documentation.service.spec.ts +++ b/packages/hoppscotch-common/src/services/__tests__/documentation.service.spec.ts @@ -40,6 +40,8 @@ describe("DocumentationService", () => { variables: [], id: "collection-123", description: null, + preRequestScript: "", + testScript: "", }) const mockRequest: HoppRESTRequest = makeRESTRequest({ diff --git a/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts b/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts index 4716f464..e4ee651a 100644 --- a/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts +++ b/packages/hoppscotch-common/src/services/persistence/__tests__/__mocks__/index.ts @@ -1,4 +1,5 @@ import { + CollectionSchemaVersion, Environment, GlobalEnvironment, HoppCollection, @@ -25,7 +26,7 @@ const DEFAULT_SETTINGS = getDefaultSettings() export const REST_COLLECTIONS_MOCK: HoppCollection[] = [ { - v: 11, + v: CollectionSchemaVersion, name: "Echo", requests: [ { @@ -54,13 +55,15 @@ export const REST_COLLECTIONS_MOCK: HoppCollection[] = [ headers: [], variables: [], description: null, + preRequestScript: "", + testScript: "", folders: [], }, ] export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [ { - v: 11, + v: CollectionSchemaVersion, name: "Echo", requests: [ { @@ -80,6 +83,8 @@ export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [ headers: [], variables: [], description: null, + preRequestScript: "", + testScript: "", folders: [], }, ] diff --git a/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts b/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts index a6592513..65716f33 100644 --- a/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts +++ b/packages/hoppscotch-common/src/services/persistence/validation-schemas/index.ts @@ -325,6 +325,16 @@ const HoppInheritedPropertySchema = z }) ) .catch([]), + scripts: z + .array( + z.object({ + parentID: z.string(), + parentName: z.string(), + preRequestScript: z.string(), + testScript: z.string(), + }) + ) + .catch([]), }) .strict() diff --git a/packages/hoppscotch-common/src/services/team-collection.service.ts b/packages/hoppscotch-common/src/services/team-collection.service.ts index 1e1daaa0..d6decf27 100644 --- a/packages/hoppscotch-common/src/services/team-collection.service.ts +++ b/packages/hoppscotch-common/src/services/team-collection.service.ts @@ -1,11 +1,6 @@ import * as E from "fp-ts/Either" import { Subscription } from "rxjs" -import { - HoppCollectionVariable, - HoppRESTAuth, - HoppRESTHeader, - translateToNewRequest, -} from "@hoppscotch/data" +import { HoppCollectionVariable, translateToNewRequest } from "@hoppscotch/data" import { pull, remove } from "lodash-es" import { Subscription as WSubscription } from "wonka" import { @@ -34,6 +29,8 @@ import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" import { ref, watch } from "vue" import { Service } from "dioc" import { updateInheritedPropertiesForAffectedRequests } from "~/helpers/collection/collection" +import { hasActualScript } from "~/helpers/scripting" +import { CollectionDataProps } from "~/helpers/backend/helpers" export const TEAMS_BACKEND_PAGE_SIZE = 10 @@ -1123,14 +1120,16 @@ export class TeamCollectionsService extends Service { const variables: HoppInheritedProperty["variables"] = [] - if (!folderPath) return { auth, headers, variables } + const scripts: HoppInheritedProperty["scripts"] = [] + + if (!folderPath) return { auth, headers, variables, scripts } const path = folderPath.split("/") // Check if the path is empty or invalid if (!path || path.length === 0) { console.error("Invalid path:", folderPath) - return { auth, headers, variables } + return { auth, headers, variables, scripts } } // Loop through the path and get the last parent folder with authType other than 'inherit' @@ -1140,20 +1139,12 @@ export class TeamCollectionsService extends Service { // Check if parentFolder is undefined or null if (!parentFolder) { console.error("Parent folder not found for path:", path) - return { auth, headers, variables } + return { auth, headers, variables, scripts } } - const data: { - auth?: HoppRESTAuth - headers?: HoppRESTHeader[] - variables?: HoppCollectionVariable[] - } = parentFolder.data + const data: Partial = parentFolder.data ? JSON.parse(parentFolder.data) - : { - auth: null, - headers: null, - variables: null, - } + : {} if (!data.auth) { data.auth = { @@ -1230,9 +1221,27 @@ export class TeamCollectionsService extends Service { ), }) } + + // Collect scripts from the collection hierarchy (root to child order) + const parentPreRequestScript = data.preRequestScript ?? "" + const parentTestScript = data.testScript ?? "" + + if ( + hasActualScript(parentPreRequestScript) || + hasActualScript(parentTestScript) + ) { + const currentPath = path.slice(0, i + 1).join("/") + + scripts.push({ + parentID: parentFolder.id ?? currentPath, + parentName: parentFolder.title, + preRequestScript: parentPreRequestScript, + testScript: parentTestScript, + }) + } } - return { auth, headers, variables } + return { auth, headers, variables, scripts } } private async waitForCollectionLoading(collectionID: string) { @@ -1260,6 +1269,7 @@ export class TeamCollectionsService extends Service { }, headers: [], variables: [], + scripts: [], } const path = folderPath.split("/") @@ -1278,6 +1288,7 @@ export class TeamCollectionsService extends Service { }, headers: [], variables: [], + scripts: [], } } diff --git a/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts b/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts index 2f7361f4..2a2033af 100644 --- a/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts +++ b/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts @@ -5,6 +5,7 @@ import { HoppRESTRequest, } from "@hoppscotch/data" import { Service } from "dioc" +import { hasActualScript } from "~/helpers/scripting" import * as E from "fp-ts/Either" import { cloneDeep } from "lodash-es" import { nextTick, Ref } from "vue" @@ -52,7 +53,9 @@ export class TestRunnerService extends Service { public runTests( tab: Ref>, collection: HoppCollection, - options: TestRunnerOptions + options: TestRunnerOptions, + ancestorPreRequestScripts: string[] = [], + ancestorTestScripts: string[] = [] ) { // Reset the result collection tab.value.document.status = "running" @@ -66,9 +69,22 @@ export class TestRunnerService extends Service { requests: [], variables: [], description: collection.description ?? null, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", } - this.runTestCollection(tab, collection, options) + this.runTestCollection( + tab, + collection, + options, + [], + undefined, + undefined, + [], + undefined, + ancestorPreRequestScripts, + ancestorTestScripts + ) .then(() => { tab.value.document.status = "stopped" }) @@ -96,7 +112,9 @@ export class TestRunnerService extends Service { parentHeaders?: HoppRESTHeaders, parentAuth?: HoppRESTRequest["auth"], parentVariables: HoppCollection["variables"] = [], - parentID?: string + parentID?: string, + parentPreRequestScripts: string[] = [], + parentTestScripts: string[] = [] ) { try { // Compute inherited auth and headers for this collection @@ -121,6 +139,19 @@ export class TestRunnerService extends Service { ) || []), ] + const inheritedPreRequestScripts = [ + ...parentPreRequestScripts, + ...(hasActualScript(collection.preRequestScript) + ? [collection.preRequestScript] + : []), + ] + const inheritedTestScripts = [ + ...parentTestScripts, + ...(hasActualScript(collection.testScript) + ? [collection.testScript] + : []), + ] + // Process folders progressively for (let i = 0; i < collection.folders.length; i++) { if (options.stopRef?.value) { @@ -142,7 +173,6 @@ export class TestRunnerService extends Service { } ) - // Pass inherited headers and auth to the folder await this.runTestCollection( tab, folder, @@ -151,7 +181,9 @@ export class TestRunnerService extends Service { inheritedHeaders, inheritedAuth, inheritedVariables, - collection._ref_id || collection.id + collection._ref_id || collection.id, + inheritedPreRequestScripts, + inheritedTestScripts ) } @@ -188,7 +220,9 @@ export class TestRunnerService extends Service { collection, options, currentPath, - inheritedVariables + inheritedVariables, + inheritedPreRequestScripts, + inheritedTestScripts ) if (options.delay && options.delay > 0) { @@ -279,7 +313,9 @@ export class TestRunnerService extends Service { collection: HoppCollection, options: TestRunnerOptions, path: number[], - inheritedVariables: HoppCollectionVariable[] = [] + inheritedVariables: HoppCollectionVariable[] = [], + inheritedPreRequestScripts: string[] = [], + inheritedTestScripts: string[] = [] ) { if (options.stopRef?.value) { throw new Error("Test execution stopped") @@ -305,7 +341,9 @@ export class TestRunnerService extends Service { request, options.keepVariableValues, inheritedVariables, - initialEnvironmentState + initialEnvironmentState, + inheritedPreRequestScripts, + inheritedTestScripts ) if (options.stopRef?.value) { diff --git a/packages/hoppscotch-data/src/collection/index.ts b/packages/hoppscotch-data/src/collection/index.ts index ddb0a4e8..9ec50135 100644 --- a/packages/hoppscotch-data/src/collection/index.ts +++ b/packages/hoppscotch-data/src/collection/index.ts @@ -11,6 +11,7 @@ import V8_VERSION from "./v/8" import V9_VERSION from "./v/9" import V10_VERSION from "./v/10" import V11_VERSION from "./v/11" +import V12_VERSION from "./v/12" export { CollectionVariable } from "./v/10" @@ -24,7 +25,7 @@ const versionedObject = z.object({ }) export const HoppCollection = createVersionedEntity({ - latestVersion: 11, + latestVersion: 12, versionMap: { 1: V1_VERSION, 2: V2_VERSION, @@ -37,6 +38,7 @@ export const HoppCollection = createVersionedEntity({ 9: V9_VERSION, 10: V10_VERSION, 11: V11_VERSION, + 12: V12_VERSION, }, getVersion(data) { const versionCheck = versionedObject.safeParse(data) @@ -56,7 +58,7 @@ export type HoppCollectionVariable = InferredEntity< typeof HoppCollection >["variables"][number] -export const CollectionSchemaVersion = 11 +export const CollectionSchemaVersion = 12 /** * Generates a Collection object. This ignores the version number object @@ -88,6 +90,9 @@ export function translateToNewRESTCollection(x: any): HoppCollection { const description = x.description ?? null + const preRequestScript = x.preRequestScript ?? "" + const testScript = x.testScript ?? "" + const obj = makeCollection({ name, folders, @@ -96,6 +101,8 @@ export function translateToNewRESTCollection(x: any): HoppCollection { headers, variables, description, + preRequestScript, + testScript, }) if (x.id) obj.id = x.id @@ -123,6 +130,9 @@ export function translateToNewGQLCollection(x: any): HoppCollection { const description = x.description ?? null + const preRequestScript = x.preRequestScript ?? "" + const testScript = x.testScript ?? "" + const obj = makeCollection({ name, folders, @@ -131,6 +141,8 @@ export function translateToNewGQLCollection(x: any): HoppCollection { headers, variables, description, + preRequestScript, + testScript, }) if (x.id) obj.id = x.id diff --git a/packages/hoppscotch-data/src/collection/v/10.ts b/packages/hoppscotch-data/src/collection/v/10.ts index a9a9cc7e..21759c59 100644 --- a/packages/hoppscotch-data/src/collection/v/10.ts +++ b/packages/hoppscotch-data/src/collection/v/10.ts @@ -2,7 +2,7 @@ import { defineVersion, entityRefUptoVersion } from "verzod" import { z } from "zod" import { HoppCollection } from ".." -import { v9_baseCollectionSchema } from "./9" +import { v9_baseCollectionSchema, V9_SCHEMA } from "./9" export const CollectionVariable = z.object({ key: z.string(), @@ -33,7 +33,7 @@ export const V10_SCHEMA = v10_baseCollectionSchema.extend({ export default defineVersion({ initial: false, schema: V10_SCHEMA, - up(old: z.infer) { + up(old: z.infer) { const result: z.infer = { ...old, v: 10 as const, diff --git a/packages/hoppscotch-data/src/collection/v/11.ts b/packages/hoppscotch-data/src/collection/v/11.ts index 3ce93120..dcc77a77 100644 --- a/packages/hoppscotch-data/src/collection/v/11.ts +++ b/packages/hoppscotch-data/src/collection/v/11.ts @@ -2,7 +2,7 @@ import { defineVersion, entityRefUptoVersion } from "verzod" import { z } from "zod" import { HoppCollection } from ".." -import { v10_baseCollectionSchema } from "./10" +import { v10_baseCollectionSchema, V10_SCHEMA } from "./10" export const v11_baseCollectionSchema = v10_baseCollectionSchema.extend({ v: z.literal(11), @@ -24,11 +24,11 @@ export const V11_SCHEMA = v11_baseCollectionSchema.extend({ export default defineVersion({ initial: false, schema: V11_SCHEMA, - up(old: z.infer) { + up(old: z.infer) { const result: z.infer = { ...old, v: 11 as const, - description: old.description ?? null, + description: null, folders: old.folders.map((folder) => { const result = HoppCollection.safeParseUpToVersion(folder, 11) diff --git a/packages/hoppscotch-data/src/collection/v/12.ts b/packages/hoppscotch-data/src/collection/v/12.ts new file mode 100644 index 00000000..985d77ad --- /dev/null +++ b/packages/hoppscotch-data/src/collection/v/12.ts @@ -0,0 +1,47 @@ +import { defineVersion, entityRefUptoVersion } from "verzod" +import { z } from "zod" + +import { HoppCollection } from ".." +import { v11_baseCollectionSchema, V11_SCHEMA } from "./11" + +export const v12_baseCollectionSchema = v11_baseCollectionSchema.extend({ + v: z.literal(12), + preRequestScript: z.string().catch(""), + testScript: z.string().catch(""), +}) + +type Input = z.input & { + folders: Input[] +} + +type Output = z.output & { + folders: Output[] +} + +export const V12_SCHEMA = v12_baseCollectionSchema.extend({ + folders: z.lazy(() => z.array(entityRefUptoVersion(HoppCollection, 12))), +}) as z.ZodType + +export default defineVersion({ + initial: false, + schema: V12_SCHEMA, + up(old: z.infer) { + const result: z.infer = { + ...old, + v: 12 as const, + preRequestScript: "", + testScript: "", + folders: old.folders.map((folder) => { + const result = HoppCollection.safeParseUpToVersion(folder, 12) + + if (result.type !== "ok") { + throw new Error("Failed to migrate child collections") + } + + return result.value + }), + } + + return result + }, +}) diff --git a/packages/hoppscotch-data/src/graphql/v/6.ts b/packages/hoppscotch-data/src/graphql/v/6.ts index 24255eba..9dfde90a 100644 --- a/packages/hoppscotch-data/src/graphql/v/6.ts +++ b/packages/hoppscotch-data/src/graphql/v/6.ts @@ -53,7 +53,7 @@ export const V6_SCHEMA = V5_SCHEMA.extend({ export default defineVersion({ schema: V6_SCHEMA, initial: false, - up(old: z.infer) { + up(old: z.infer) { const headers = old.headers.map((header) => { return { ...header, diff --git a/packages/hoppscotch-data/src/rest/v/16.ts b/packages/hoppscotch-data/src/rest/v/16.ts index 79f85ded..aaa40d75 100644 --- a/packages/hoppscotch-data/src/rest/v/16.ts +++ b/packages/hoppscotch-data/src/rest/v/16.ts @@ -11,11 +11,11 @@ export const V16_SCHEMA = V15_SCHEMA.extend({ const V16_VERSION = defineVersion({ schema: V16_SCHEMA, initial: false, - up(old: z.infer) { + up(old: z.infer) { return { ...old, v: "16" as const, - _ref_id: old._ref_id ?? generateUniqueRefId("req"), + _ref_id: generateUniqueRefId("req"), } }, }) diff --git a/packages/hoppscotch-data/src/rest/v/17.ts b/packages/hoppscotch-data/src/rest/v/17.ts index b5381812..f7aac36e 100644 --- a/packages/hoppscotch-data/src/rest/v/17.ts +++ b/packages/hoppscotch-data/src/rest/v/17.ts @@ -10,11 +10,11 @@ export const V17_SCHEMA = V16_SCHEMA.extend({ const V17_VERSION = defineVersion({ schema: V17_SCHEMA, initial: false, - up(old: z.infer) { + up(old: z.infer) { return { ...old, v: "17" as const, - description: old.description ?? null, + description: null, } }, }) diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-execution-error-propagation.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-execution-error-propagation.spec.ts new file mode 100644 index 00000000..23c158a3 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-execution-error-propagation.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "vitest" +import { runPreRequest, runTest } from "~/utils/test-helpers" + +/** + * The experimental sandbox silently swallowed top-level throws inside the + * generated `await (async function(){...})()` wrapper. The fix routes the + * error through a lexically-captured host reporter installed by bootstrap, + * read back on the host side as the Left branch of the TaskEither pipeline. + * + * These tests exercise the sandbox contract directly by reproducing the + * generated wrapper shape that `combineScriptsWithIIFE` emits from + * `@hoppscotch/cli` and `@hoppscotch/common`. + */ +describe("script execution error propagation", () => { + const envs = { global: [], selected: [] } + + // Mirrors `combineScriptsWithIIFE("experimental")` in the CLI and common + // packages. Kept local to avoid a cross-package import. + const wrapExperimental = (body: string): string => + [ + "const __hoppReporter = globalThis.__hoppReportScriptExecutionError;", + "try {", + `await (async function() {\n${body}\n})();`, + "} catch (__hoppScriptExecutionError) {", + " __hoppReporter(__hoppScriptExecutionError);", + "}", + ].join("\n") + + test("experimental pre-request: synchronous top-level throw returns Left", async () => { + const script = wrapExperimental( + `throw new Error("pre-request top-level throw");` + ) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeLeft() + if (result._tag === "Left") { + expect(result.left).toContain("pre-request top-level throw") + } + }) + + test("experimental pre-request: rejected await inside IIFE returns Left", async () => { + const script = wrapExperimental( + `await Promise.reject(new Error("pre-request rejected await"));` + ) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeLeft() + if (result._tag === "Left") { + expect(result.left).toContain("pre-request rejected await") + } + }) + + test("experimental pre-request: valid script still returns Right", async () => { + const script = wrapExperimental(`pw.env.set("FLAG", "ok");`) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeRight() + }) + + test("experimental test-runner: synchronous top-level throw returns Left", async () => { + const script = wrapExperimental( + `throw new Error("test-runner top-level throw");` + ) + + const result = await runTest(script, envs)() + + expect(result).toBeLeft() + if (result._tag === "Left") { + expect(result.left).toContain("test-runner top-level throw") + } + }) + + test("user script cannot tamper with the reporter to suppress error reporting", async () => { + // User script attempts to delete + overwrite the globalThis reporter + // before throwing; both defenses (immutable property + lexical capture + // in the wrapper) keep the report path intact. + const script = wrapExperimental( + ` + try { + delete globalThis.__hoppReportScriptExecutionError; + globalThis.__hoppReportScriptExecutionError = () => {}; + } catch (_e) { + // strict mode may throw on immutable-property tamper; ignore + } + throw new Error("tamper-attempt throw"); + ` + ) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeLeft() + if (result._tag === "Left") { + expect(result.left).toContain("tamper-attempt throw") + } + }) +}) diff --git a/packages/hoppscotch-js-sandbox/src/bootstrap-code/post-request.js b/packages/hoppscotch-js-sandbox/src/bootstrap-code/post-request.js index 17a357e4..0486334f 100644 --- a/packages/hoppscotch-js-sandbox/src/bootstrap-code/post-request.js +++ b/packages/hoppscotch-js-sandbox/src/bootstrap-code/post-request.js @@ -3,6 +3,27 @@ // Keep strict mode scoped to this IIFE to avoid leaking strictness to concatenated/bootstrapped code "use strict" + // Exposes a host reporter for the generated experimental `try/catch` + // wrapper in combineScriptsWithIIFE. Locked down (non-writable, + // non-configurable) so user scripts cannot delete or overwrite it to + // suppress error reporting. faraday-cage's `runCode` creates a fresh + // QuickJS runtime per call, so re-definition across calls is not a + // concern — the property lives only for the current run's VM. + Object.defineProperty(globalThis, "__hoppReportScriptExecutionError", { + value: (error) => { + const safe = error && typeof error === "object" ? error : {} + inputs.setScriptExecutionError({ + name: typeof safe.name === "string" ? safe.name : "", + message: + typeof safe.message === "string" ? safe.message : String(error), + stack: typeof safe.stack === "string" ? safe.stack : "", + }) + }, + enumerable: false, + configurable: false, + writable: false, + }) + // Sequential test execution promise chain // Initialize with a resolved promise to start the chain // Store on globalThis so pm.sendRequest() and test() can both access and modify it diff --git a/packages/hoppscotch-js-sandbox/src/bootstrap-code/pre-request.js b/packages/hoppscotch-js-sandbox/src/bootstrap-code/pre-request.js index 28477245..4f41249f 100644 --- a/packages/hoppscotch-js-sandbox/src/bootstrap-code/pre-request.js +++ b/packages/hoppscotch-js-sandbox/src/bootstrap-code/pre-request.js @@ -2,6 +2,28 @@ ;(inputs) => { // Keep strict mode scoped to this IIFE to avoid leaking strictness to concatenated/bootstrapped code "use strict" + + // Exposes a host reporter for the generated experimental `try/catch` + // wrapper in combineScriptsWithIIFE. Locked down (non-writable, + // non-configurable) so user scripts cannot delete or overwrite it to + // suppress error reporting. faraday-cage's `runCode` creates a fresh + // QuickJS runtime per call, so re-definition across calls is not a + // concern — the property lives only for the current run's VM. + Object.defineProperty(globalThis, "__hoppReportScriptExecutionError", { + value: (error) => { + const safe = error && typeof error === "object" ? error : {} + inputs.setScriptExecutionError({ + name: typeof safe.name === "string" ? safe.name : "", + message: + typeof safe.message === "string" ? safe.message : String(error), + stack: typeof safe.stack === "string" ? safe.stack : "", + }) + }, + enumerable: false, + configurable: false, + writable: false, + }) + globalThis.pw = { env: { get: (key) => convertMarkerToValue(inputs.envGet(key)), diff --git a/packages/hoppscotch-js-sandbox/src/cage-modules/scripting-modules.ts b/packages/hoppscotch-js-sandbox/src/cage-modules/scripting-modules.ts index fe818528..9be183f4 100644 --- a/packages/hoppscotch-js-sandbox/src/cage-modules/scripting-modules.ts +++ b/packages/hoppscotch-js-sandbox/src/cage-modules/scripting-modules.ts @@ -386,7 +386,11 @@ const createScriptingModule = ( type: ModuleType, bootstrapCode: string, config: ModuleConfig, - captureHook?: { capture?: () => void; bootstrapError?: unknown } + captureHook?: { + capture?: () => void + bootstrapError?: unknown + scriptExecutionError?: { name: string; message: string; stack: string } + } ) => { return defineCageModule((ctx) => { // Track test promises for keepAlive (only for post-request scripts) @@ -471,6 +475,31 @@ const createScriptingModule = ( } } + // Reporter invoked from the generated `catch` block that wraps the + // experimental IIFE chain. This is a synchronous host callback — unlike + // rejected keepAlive promises or async afterScriptExecutionHooks, these + // calls cross the QuickJS boundary before the script returns, so the + // host sees the error without relying on faraday-cage's loop behaviour. + ;(inputsObj as Record).setScriptExecutionError = + defineSandboxFn( + ctx, + "setScriptExecutionError", + (error: unknown) => { + if (!captureHook || captureHook.scriptExecutionError) return + const err = (error ?? {}) as { + name?: unknown + message?: unknown + stack?: unknown + } + captureHook.scriptExecutionError = { + name: typeof err.name === "string" ? err.name : "", + message: + typeof err.message === "string" ? err.message : String(error), + stack: typeof err.stack === "string" ? err.stack : "", + } + } + ) + const sandboxInputsObj = defineSandboxObject(ctx, inputsObj) const bootstrapResult = ctx.vm.callFunction( diff --git a/packages/hoppscotch-js-sandbox/src/node/pre-request/experimental.ts b/packages/hoppscotch-js-sandbox/src/node/pre-request/experimental.ts index 6068a2ab..945302bc 100644 --- a/packages/hoppscotch-js-sandbox/src/node/pre-request/experimental.ts +++ b/packages/hoppscotch-js-sandbox/src/node/pre-request/experimental.ts @@ -23,7 +23,11 @@ const executePreRequestOnCage = async ( let finalRequest = request let finalCookies = cookies - const captureHook: { capture?: () => void; bootstrapError?: unknown } = {} + const captureHook: { + capture?: () => void + bootstrapError?: unknown + scriptExecutionError?: { name: string; message: string; stack: string } + } = {} const result = await cage.runCode(preRequestScript, [ ...defaultModules({ @@ -72,6 +76,16 @@ const executePreRequestOnCage = async ( return E.left(`Script execution failed: ${String(result.err)}`) } + // Check for errors reported via the generated try/catch wrapper. + // faraday-cage's keepAlive loop swallows rejected promises and does not + // await afterScriptExecutionHooks, so async-boundary errors reach us + // only via this synchronous host reporter. + if (captureHook.scriptExecutionError) { + const { name, message } = captureHook.scriptExecutionError + const prefix = name ? `${name}: ` : "" + return E.left(`Script execution failed: ${prefix}${message}`) + } + if (captureHook.capture) { captureHook.capture() } diff --git a/packages/hoppscotch-js-sandbox/src/node/test-runner/experimental.ts b/packages/hoppscotch-js-sandbox/src/node/test-runner/experimental.ts index d06bf920..a5b65685 100644 --- a/packages/hoppscotch-js-sandbox/src/node/test-runner/experimental.ts +++ b/packages/hoppscotch-js-sandbox/src/node/test-runner/experimental.ts @@ -32,7 +32,11 @@ const executeTestOnCage = async ( let finalTestResults = testRunStack const testPromises: Promise[] = [] - const captureHook: { capture?: () => void; bootstrapError?: unknown } = {} + const captureHook: { + capture?: () => void + bootstrapError?: unknown + scriptExecutionError?: { name: string; message: string; stack: string } + } = {} const result = await cage.runCode(testScript, [ ...defaultModules({ @@ -92,6 +96,16 @@ const executeTestOnCage = async ( } } + // Check for errors reported via the generated try/catch wrapper. + // faraday-cage's keepAlive loop swallows rejected promises and does not + // await afterScriptExecutionHooks, so async-boundary errors reach us + // only via this synchronous host reporter. + if (captureHook.scriptExecutionError) { + const { name, message } = captureHook.scriptExecutionError + const prefix = name ? `${name}: ` : "" + return E.left(`Script execution failed: ${prefix}${message}`) + } + if (captureHook.capture) { captureHook.capture() } diff --git a/packages/hoppscotch-js-sandbox/src/web/pre-request/index.ts b/packages/hoppscotch-js-sandbox/src/web/pre-request/index.ts index c80815b3..6cf6d613 100644 --- a/packages/hoppscotch-js-sandbox/src/web/pre-request/index.ts +++ b/packages/hoppscotch-js-sandbox/src/web/pre-request/index.ts @@ -53,7 +53,11 @@ const executePreRequestOnCage = async ( let finalRequest = request let finalCookies = cookies - const captureHook: { capture?: () => void; bootstrapError?: unknown } = {} + const captureHook: { + capture?: () => void + bootstrapError?: unknown + scriptExecutionError?: { name: string; message: string; stack: string } + } = {} const result = await cage.runCode(preRequestScript, [ ...defaultModules({ @@ -103,6 +107,16 @@ const executePreRequestOnCage = async ( return E.left(`Script execution failed: ${String(result.err)}`) } + // Check for errors reported via the generated try/catch wrapper. + // faraday-cage's keepAlive loop swallows rejected promises and does not + // await afterScriptExecutionHooks, so async-boundary errors reach us + // only via this synchronous host reporter. + if (captureHook.scriptExecutionError) { + const { name, message } = captureHook.scriptExecutionError + const prefix = name ? `${name}: ` : "" + return E.left(`Script execution failed: ${prefix}${message}`) + } + if (captureHook.capture) { captureHook.capture() } diff --git a/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts b/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts index d9ee9854..73d5b87d 100644 --- a/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts +++ b/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts @@ -62,7 +62,11 @@ const executeTestOnCage = async ( let finalCookies = cookies const testPromises: Promise[] = [] - const captureHook: { capture?: () => void; bootstrapError?: unknown } = {} + const captureHook: { + capture?: () => void + bootstrapError?: unknown + scriptExecutionError?: { name: string; message: string; stack: string } + } = {} const result = await cage.runCode(testScript, [ ...defaultModules({ @@ -122,6 +126,16 @@ const executeTestOnCage = async ( await Promise.all(testPromises) } + // Check for errors reported via the generated try/catch wrapper. + // faraday-cage's keepAlive loop swallows rejected promises and does not + // await afterScriptExecutionHooks, so async-boundary errors reach us + // only via this synchronous host reporter. + if (captureHook.scriptExecutionError) { + const { name, message } = captureHook.scriptExecutionError + const prefix = name ? `${name}: ` : "" + return E.left(`Script execution failed: ${prefix}${message}`) + } + if (captureHook.capture) { captureHook.capture() } diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/gqlCollections.sync.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/gqlCollections.sync.ts index ccfa7533..c1144d98 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/gqlCollections.sync.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/gqlCollections.sync.ts @@ -45,6 +45,9 @@ const transformCollectionForBackend = (collection: HoppCollection): any => { headers: collection.headers ?? [], variables: collection.variables ?? [], _ref_id: collection._ref_id, + description: collection.description ?? null, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", } return { @@ -79,6 +82,9 @@ const recursivelySyncCollections = async ( headers: collection.headers ?? [], variables: collection.variables ?? [], _ref_id: collection._ref_id, + description: collection.description ?? null, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", } const res = await createGQLRootUserCollection( collection.name, @@ -98,6 +104,9 @@ const recursivelySyncCollections = async ( headers: [], variables: [], _ref_id: collection._ref_id ?? generateUniqueRefId("coll"), + description: null, + preRequestScript: "", + testScript: "", } collection.id = parentCollectionID @@ -105,6 +114,9 @@ const recursivelySyncCollections = async ( collection.headers = returnedData.headers collection.variables = returnedData.variables collection._ref_id = returnedData._ref_id ?? generateUniqueRefId("coll") + collection.description = returnedData.description ?? null + collection.preRequestScript = returnedData.preRequestScript ?? "" + collection.testScript = returnedData.testScript ?? "" removeDuplicateGraphqlCollectionOrFolder( parentCollectionID, @@ -124,6 +136,9 @@ const recursivelySyncCollections = async ( headers: collection.headers ?? [], variables: collection.variables ?? [], _ref_id: collection._ref_id, + description: collection.description ?? null, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", } const res = await createGQLChildUserCollection( @@ -145,6 +160,9 @@ const recursivelySyncCollections = async ( headers: [], variables: [], _ref_id: collection._ref_id ?? generateUniqueRefId("coll"), + description: null, + preRequestScript: "", + testScript: "", } collection.id = childCollectionId @@ -153,6 +171,9 @@ const recursivelySyncCollections = async ( parentCollectionID = childCollectionId collection.variables = returnedData.variables collection._ref_id = returnedData._ref_id ?? generateUniqueRefId("coll") + collection.description = returnedData.description ?? null + collection.preRequestScript = returnedData.preRequestScript ?? "" + collection.testScript = returnedData.testScript ?? "" removeDuplicateGraphqlCollectionOrFolder( childCollectionId, @@ -268,6 +289,9 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< headers: collection.headers, variables: collection.variables, _ref_id: collection._ref_id, + description: collection.description ?? null, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", } if (collectionID) { @@ -314,6 +338,9 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< headers: folder.headers, variables: folder.variables, _ref_id: folder._ref_id, + description: folder.description ?? null, + preRequestScript: folder.preRequestScript ?? "", + testScript: folder.testScript ?? "", } if (folderBackendId) { diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/index.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/index.ts index 88ced221..bd2452c6 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/index.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/index.ts @@ -141,12 +141,14 @@ export function exportedCollectionToHoppCollection( _ref_id: generateUniqueRefId("coll"), variables: [], description: null, + preRequestScript: "", + testScript: "", } return { id: restCollection.id, _ref_id: data._ref_id ?? generateUniqueRefId("coll"), - v: 11, + v: 12, name: restCollection.name, folders: restCollection.folders.map((folder) => exportedCollectionToHoppCollection(folder, collectionType) @@ -200,6 +202,8 @@ export function exportedCollectionToHoppCollection( headers: addDescriptionField(data.headers), variables: data.variables ?? [], description: data.description ?? null, + preRequestScript: data.preRequestScript ?? "", + testScript: data.testScript ?? "", } } else { const gqlCollection = collection as ExportedUserCollectionGQL @@ -213,12 +217,14 @@ export function exportedCollectionToHoppCollection( _ref_id: generateUniqueRefId("coll"), variables: [], description: null, + preRequestScript: "", + testScript: "", } return { id: gqlCollection.id, _ref_id: data._ref_id ?? generateUniqueRefId("coll"), - v: 11, + v: 12, name: gqlCollection.name, folders: gqlCollection.folders.map((folder) => exportedCollectionToHoppCollection(folder, collectionType) @@ -248,6 +254,8 @@ export function exportedCollectionToHoppCollection( headers: addDescriptionField(data.headers), variables: data.variables ?? [], description: data.description ?? null, + preRequestScript: data.preRequestScript ?? "", + testScript: data.testScript ?? "", } } } @@ -398,6 +406,9 @@ function setupUserCollectionCreatedSubscription() { headers: [], _ref_id: generateUniqueRefId("coll"), variables: [], + description: null, + preRequestScript: "", + testScript: "", } runDispatchWithOutSyncing(() => { @@ -406,23 +417,27 @@ function setupUserCollectionCreatedSubscription() { name: res.right.userCollectionCreated.title, folders: [], requests: [], - v: 11, + v: 12, _ref_id: data._ref_id, auth: data.auth, headers: addDescriptionField(data.headers), variables: data.variables ?? [], description: data.description ?? null, + preRequestScript: data.preRequestScript ?? "", + testScript: data.testScript ?? "", }) : addRESTCollection({ name: res.right.userCollectionCreated.title, folders: [], requests: [], - v: 11, + v: 12, _ref_id: data._ref_id, auth: data.auth, headers: addDescriptionField(data.headers), variables: data.variables ?? [], description: data.description ?? null, + preRequestScript: data.preRequestScript ?? "", + testScript: data.testScript ?? "", }) const localIndex = collectionStore.value.state.length - 1 @@ -625,7 +640,14 @@ function setupUserCollectionDuplicatedSubscription() { ) // Incoming data transformed to the respective internal representations - const { auth, headers, variables, description } = + const { + auth, + headers, + variables, + description, + preRequestScript, + testScript, + } = data && data != "null" ? JSON.parse(data) : { @@ -633,6 +655,8 @@ function setupUserCollectionDuplicatedSubscription() { headers: [], variables: [], description: null, + preRequestScript: "", + testScript: "", } // Duplicated collection will have a unique ref id const _ref_id = generateUniqueRefId("coll") @@ -649,12 +673,14 @@ function setupUserCollectionDuplicatedSubscription() { name, folders, requests, - v: 11, + v: 12, _ref_id, auth, headers: addDescriptionField(headers), variables: variables ?? [], description: description ?? null, + preRequestScript: preRequestScript ?? "", + testScript: testScript ?? "", } // only folders will have parent collection id @@ -1122,7 +1148,14 @@ function transformDuplicatedCollections( requests: userRequests, title: name, }) => { - const { auth, headers, variables, description } = + const { + auth, + headers, + variables, + description, + preRequestScript, + testScript, + } = data && data !== "null" ? JSON.parse(data) : { @@ -1130,6 +1163,8 @@ function transformDuplicatedCollections( headers: [], variables: [], description: null, + preRequestScript: "", + testScript: "", } const _ref_id = generateUniqueRefId("coll") @@ -1144,11 +1179,13 @@ function transformDuplicatedCollections( folders, requests, _ref_id, - v: 11, + v: 12, auth, headers: addDescriptionField(headers), variables: variables ?? [], description: description ?? null, + preRequestScript: preRequestScript ?? "", + testScript: testScript ?? "", } } ) diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/sync.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/sync.ts index 32fbdc6d..9e0d44bf 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/sync.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/desktop/sync.ts @@ -48,6 +48,8 @@ const transformCollectionForBackend = (collection: HoppCollection): any => { variables: collection.variables ?? [], _ref_id: collection._ref_id, description: collection.description ?? null, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", } return { @@ -83,6 +85,8 @@ const recursivelySyncCollections = async ( variables: collection.variables ?? [], _ref_id: collection._ref_id, description: collection.description ?? null, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", } const res = await createRESTRootUserCollection( collection.name, @@ -102,6 +106,8 @@ const recursivelySyncCollections = async ( variables: [], _ref_id: generateUniqueRefId("coll"), description: null, + preRequestScript: "", + testScript: "", } collection.id = parentCollectionID @@ -110,6 +116,9 @@ const recursivelySyncCollections = async ( collection.headers = returnedData.headers collection.variables = returnedData.variables collection.description = returnedData.description ?? null + collection.preRequestScript = returnedData.preRequestScript ?? "" + collection.testScript = returnedData.testScript ?? "" + removeDuplicateRESTCollectionOrFolder(parentCollectionID, collectionPath) } else { parentCollectionID = undefined @@ -125,6 +134,8 @@ const recursivelySyncCollections = async ( variables: collection.variables ?? [], _ref_id: collection._ref_id, description: collection.description ?? null, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", } const res = await createRESTChildUserCollection( @@ -147,6 +158,8 @@ const recursivelySyncCollections = async ( variables: [], _ref_id: generateUniqueRefId("coll"), description: null, + preRequestScript: "", + testScript: "", } collection.id = childCollectionId @@ -156,6 +169,8 @@ const recursivelySyncCollections = async ( parentCollectionID = childCollectionId collection.variables = returnedData.variables collection.description = returnedData.description ?? null + collection.preRequestScript = returnedData.preRequestScript ?? "" + collection.testScript = returnedData.testScript ?? "" removeDuplicateRESTCollectionOrFolder( childCollectionId, @@ -268,6 +283,8 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< variables: collection.variables, _ref_id: collection._ref_id, description: collection.description ?? null, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", } if (collectionID) { @@ -351,6 +368,8 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< variables: folder.variables, _ref_id: folder._ref_id, description: folder.description ?? null, + preRequestScript: folder.preRequestScript ?? "", + testScript: folder.testScript ?? "", } if (folderID) { updateUserCollection(folderID, folderName, JSON.stringify(data)) diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/web/gqlCollections.sync.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/web/gqlCollections.sync.ts index 5f50988d..8151d011 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/web/gqlCollections.sync.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/web/gqlCollections.sync.ts @@ -45,6 +45,9 @@ const transformCollectionForBackend = (collection: HoppCollection): any => { headers: collection.headers ?? [], variables: collection.variables ?? [], _ref_id: collection._ref_id, + description: collection.description ?? null, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", } return { @@ -79,6 +82,9 @@ const recursivelySyncCollections = async ( headers: collection.headers ?? [], variables: collection.variables ?? [], _ref_id: collection._ref_id, + description: collection.description ?? null, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", } const res = await createGQLRootUserCollection( collection.name, @@ -98,6 +104,9 @@ const recursivelySyncCollections = async ( headers: [], variables: [], _ref_id: generateUniqueRefId("coll"), + description: null, + preRequestScript: "", + testScript: "", } collection.id = parentCollectionID @@ -105,6 +114,9 @@ const recursivelySyncCollections = async ( collection.auth = returnedData.auth collection.headers = returnedData.headers collection.variables = returnedData.variables + collection.description = returnedData.description ?? null + collection.preRequestScript = returnedData.preRequestScript ?? "" + collection.testScript = returnedData.testScript ?? "" removeDuplicateGraphqlCollectionOrFolder( parentCollectionID, @@ -124,6 +136,9 @@ const recursivelySyncCollections = async ( headers: collection.headers ?? [], variables: collection.variables ?? [], _ref_id: collection._ref_id, + description: collection.description ?? null, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", } const res = await createGQLChildUserCollection( @@ -145,6 +160,9 @@ const recursivelySyncCollections = async ( headers: [], variables: [], _ref_id: generateUniqueRefId("coll"), + description: null, + preRequestScript: "", + testScript: "", } collection.id = childCollectionId @@ -153,6 +171,9 @@ const recursivelySyncCollections = async ( collection.headers = returnedData.headers parentCollectionID = childCollectionId collection.variables = returnedData.variables + collection.description = returnedData.description ?? null + collection.preRequestScript = returnedData.preRequestScript ?? "" + collection.testScript = returnedData.testScript ?? "" removeDuplicateGraphqlCollectionOrFolder( childCollectionId, @@ -268,6 +289,9 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< headers: collection.headers, variables: collection.variables, _ref_id: collection._ref_id, + description: collection.description ?? null, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", } if (collectionID) { @@ -314,6 +338,9 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< headers: folder.headers, variables: folder.variables, _ref_id: folder._ref_id, + description: folder.description ?? null, + preRequestScript: folder.preRequestScript ?? "", + testScript: folder.testScript ?? "", } if (folderBackendId) { diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/web/import.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/web/import.ts index 39834a27..8ae5154f 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/web/import.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/web/import.ts @@ -70,6 +70,8 @@ export function translateToPersonalCollectionFormat(x: HoppCollection) { headers: x.headers, variables: x.variables, description: x.description, + preRequestScript: x.preRequestScript ?? "", + testScript: x.testScript ?? "", } const obj = { diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/web/index.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/web/index.ts index a018b795..631fb3e9 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/web/index.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/web/index.ts @@ -142,12 +142,14 @@ export function exportedCollectionToHoppCollection( _ref_id: generateUniqueRefId("coll"), variables: [], description: null, + preRequestScript: "", + testScript: "", } return { id: restCollection.id, _ref_id: data._ref_id ?? generateUniqueRefId("coll"), - v: 11, + v: 12, name: restCollection.name, folders: restCollection.folders.map((folder) => exportedCollectionToHoppCollection(folder, collectionType) @@ -201,6 +203,8 @@ export function exportedCollectionToHoppCollection( auth: data.auth, headers: addDescriptionField(data.headers), variables: data.variables ?? [], + preRequestScript: data.preRequestScript ?? "", + testScript: data.testScript ?? "", } } else { const gqlCollection = collection as ExportedUserCollectionGQL @@ -219,7 +223,7 @@ export function exportedCollectionToHoppCollection( return { id: gqlCollection.id, _ref_id: data._ref_id ?? generateUniqueRefId("coll"), - v: 11, + v: 12, name: gqlCollection.name, folders: gqlCollection.folders.map((folder) => exportedCollectionToHoppCollection(folder, collectionType) @@ -249,6 +253,8 @@ export function exportedCollectionToHoppCollection( headers: addDescriptionField(data.headers), variables: data.variables ?? [], description: data.description ?? null, + preRequestScript: data.preRequestScript ?? "", + testScript: data.testScript ?? "", } } } @@ -400,6 +406,8 @@ function setupUserCollectionCreatedSubscription() { _ref_id: generateUniqueRefId("coll"), variables: [], description: null, + preRequestScript: "", + testScript: "", } runDispatchWithOutSyncing(() => { @@ -408,23 +416,27 @@ function setupUserCollectionCreatedSubscription() { name: res.right.userCollectionCreated.title, folders: [], requests: [], - v: 11, + v: 12, _ref_id: data._ref_id, auth: data.auth, headers: addDescriptionField(data.headers), variables: data.variables ?? [], description: data.description ?? null, + preRequestScript: data.preRequestScript ?? "", + testScript: data.testScript ?? "", }) : addRESTCollection({ name: res.right.userCollectionCreated.title, folders: [], requests: [], - v: 11, + v: 12, _ref_id: data._ref_id, auth: data.auth, headers: addDescriptionField(data.headers), variables: data.variables ?? [], description: data.description ?? null, + preRequestScript: data.preRequestScript ?? "", + testScript: data.testScript ?? "", }) const localIndex = collectionStore.value.state.length - 1 @@ -627,7 +639,14 @@ function setupUserCollectionDuplicatedSubscription() { ) // Incoming data transformed to the respective internal representations - const { auth, headers, variables, description } = + const { + auth, + headers, + variables, + description, + preRequestScript, + testScript, + } = data && data != "null" ? JSON.parse(data) : { @@ -635,6 +654,8 @@ function setupUserCollectionDuplicatedSubscription() { headers: [], variables: [], description: null, + preRequestScript: "", + testScript: "", } // Duplicated collection will have a unique ref id const _ref_id = generateUniqueRefId("coll") @@ -651,12 +672,14 @@ function setupUserCollectionDuplicatedSubscription() { name, folders, requests, - v: 11, + v: 12, _ref_id, auth, headers: addDescriptionField(headers), variables: variables ?? [], description: description ?? null, + preRequestScript: preRequestScript ?? "", + testScript: testScript ?? "", } // only folders will have parent collection id @@ -1122,7 +1145,14 @@ function transformDuplicatedCollections( requests: userRequests, title: name, }) => { - const { auth, headers, variables, description } = + const { + auth, + headers, + variables, + description, + preRequestScript, + testScript, + } = data && data !== "null" ? JSON.parse(data) : { @@ -1130,6 +1160,8 @@ function transformDuplicatedCollections( headers: [], variables: [], description: null, + preRequestScript: "", + testScript: "", } const _ref_id = generateUniqueRefId("coll") @@ -1144,11 +1176,13 @@ function transformDuplicatedCollections( folders, requests, _ref_id, - v: 11, + v: 12, auth, headers: addDescriptionField(headers), variables: variables ?? [], description: description ?? null, + preRequestScript: preRequestScript ?? "", + testScript: testScript ?? "", } } ) diff --git a/packages/hoppscotch-selfhost-web/src/platform/collections/web/sync.ts b/packages/hoppscotch-selfhost-web/src/platform/collections/web/sync.ts index a552258a..0a5b93f0 100644 --- a/packages/hoppscotch-selfhost-web/src/platform/collections/web/sync.ts +++ b/packages/hoppscotch-selfhost-web/src/platform/collections/web/sync.ts @@ -48,6 +48,8 @@ const transformCollectionForBackend = (collection: HoppCollection): any => { variables: collection.variables ?? [], _ref_id: collection._ref_id, description: collection.description ?? null, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", } return { @@ -83,6 +85,8 @@ const recursivelySyncCollections = async ( variables: collection.variables ?? [], _ref_id: collection._ref_id, description: collection.description ?? null, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", } const res = await createRESTRootUserCollection( collection.name, @@ -102,6 +106,8 @@ const recursivelySyncCollections = async ( variables: [], _ref_id: generateUniqueRefId("coll"), description: null, + preRequestScript: "", + testScript: "", } collection.id = parentCollectionID @@ -110,6 +116,8 @@ const recursivelySyncCollections = async ( collection.headers = returnedData.headers collection.variables = returnedData.variables collection.description = returnedData.description ?? null + collection.preRequestScript = returnedData.preRequestScript ?? "" + collection.testScript = returnedData.testScript ?? "" removeDuplicateRESTCollectionOrFolder( parentCollectionID, `${collectionPath}` @@ -128,6 +136,8 @@ const recursivelySyncCollections = async ( variables: collection.variables ?? [], _ref_id: collection._ref_id, description: collection.description ?? null, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", } const res = await createRESTChildUserCollection( @@ -150,6 +160,8 @@ const recursivelySyncCollections = async ( variables: [], _ref_id: generateUniqueRefId("coll"), description: null, + preRequestScript: "", + testScript: "", } collection.id = childCollectionId @@ -159,6 +171,8 @@ const recursivelySyncCollections = async ( parentCollectionID = childCollectionId collection.variables = returnedData.variables collection.description = returnedData.description ?? null + collection.preRequestScript = returnedData.preRequestScript ?? "" + collection.testScript = returnedData.testScript ?? "" removeDuplicateRESTCollectionOrFolder( childCollectionId, @@ -271,6 +285,8 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< variables: collection.variables, _ref_id: collection._ref_id, description: collection.description ?? null, + preRequestScript: collection.preRequestScript ?? "", + testScript: collection.testScript ?? "", } if (collectionID) { @@ -355,6 +371,8 @@ export const storeSyncDefinition: StoreSyncDefinitionOf< variables: folder.variables, _ref_id: folder._ref_id, description: folder.description, + preRequestScript: folder.preRequestScript ?? "", + testScript: folder.testScript ?? "", } if (folderID) { updateUserCollection(folderID, folderName, JSON.stringify(data))