feat: add collection-level pre-request and test scripts (#5745)

Co-authored-by: nivedin <nivedinp@gmail.com>
Co-authored-by: “mirarifhasan” <arif.ishan05@gmail.com>
Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
John An 2026-04-28 04:18:52 +10:00 committed by GitHub
parent 00c75b9de0
commit 696ddc336c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 2793 additions and 307 deletions

View file

@ -2,6 +2,7 @@ export enum PrismaError {
DATABASE_UNREACHABLE = 'P1001', DATABASE_UNREACHABLE = 'P1001',
TABLE_DOES_NOT_EXIST = 'P2021', TABLE_DOES_NOT_EXIST = 'P2021',
UNIQUE_CONSTRAINT_VIOLATION = 'P2002', UNIQUE_CONSTRAINT_VIOLATION = 'P2002',
RECORD_NOT_FOUND = 'P2025',
TRANSACTION_TIMEOUT = 'P2028', TRANSACTION_TIMEOUT = 'P2028',
TRANSACTION_DEADLOCK = 'P2034', // write conflict or a deadlock TRANSACTION_DEADLOCK = 'P2034', // write conflict or a deadlock
} }

View file

@ -569,23 +569,24 @@ export class TeamCollectionService {
collection.parentID, collection.parentID,
); );
const deletedCollection = await tx.teamCollection.delete({ try {
where: { id: collection.id }, 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 },
}); });
} 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) { } catch (error) {
throw new ConflictException(error); throw new ConflictException(error);
} }
@ -749,12 +750,7 @@ export class TeamCollectionService {
// Get collection details of collectionID // Get collection details of collectionID
const collection = await this.getCollection(collectionID, tx); const collection = await this.getCollection(collectionID, tx);
if (E.isLeft(collection)) return E.left(collection.left); 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 // destCollectionID == null i.e move collection to root
if (!destCollectionID) { if (!destCollectionID) {
if (!collection.right.parentID) { if (!collection.right.parentID) {
@ -763,6 +759,12 @@ export class TeamCollectionService {
return E.left(TEAM_COL_ALREADY_ROOT); 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 // 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 // Move child collection into root and update orderIndexes for root teamCollections
const updatedCollection = await this.changeParentAndUpdateOrderIndex( const updatedCollection = await this.changeParentAndUpdateOrderIndex(
@ -806,12 +808,41 @@ export class TeamCollectionService {
return E.left(TEAM_COLL_IS_PARENT_COLL); return E.left(TEAM_COLL_IS_PARENT_COLL);
} }
// lock the rows of the destination collection and its siblings // Acquire locks in deterministic order (sorted by parentID) to prevent deadlocks
await this.prisma.lockTeamCollectionByTeamAndParent( // when two concurrent moves happen in opposite directions
tx, const srcParentID = collection.right.parentID ?? '';
destCollection.right.teamID, const destParentID = destCollection.right.parentID ?? '';
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 // 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 // Move root/child collection into another child collection and update orderIndexes of the previous parent

View file

@ -20,6 +20,7 @@ import {
TeamRequest as DbTeamRequest, TeamRequest as DbTeamRequest,
} from 'src/generated/prisma/client'; } from 'src/generated/prisma/client';
import { SortOptions } from 'src/types/SortOptions'; import { SortOptions } from 'src/types/SortOptions';
import { PrismaError } from 'src/prisma/prisma-error-codes';
@Injectable() @Injectable()
export class TeamRequestService { export class TeamRequestService {
@ -124,21 +125,23 @@ export class TeamRequestService {
dbTeamReq.collectionID, dbTeamReq.collectionID,
]); ]);
const deletedTeamRequest = await tx.teamRequest.delete({ try {
where: { id: requestID }, 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 } },
}); });
} 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) { } catch (error) {
throw new ConflictException(error); throw new ConflictException(error);
} }

View file

@ -2380,3 +2380,165 @@ describe('exportUserCollectionToJSONObject', () => {
expect(result).toEqualLeft(USER_COLL_NOT_FOUND); 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", () => {});');
}
});
});

View file

@ -520,23 +520,24 @@ export class UserCollectionService {
collection.parentID, collection.parentID,
); );
const deletedCollection = await tx.userCollection.delete({ try {
where: { id: collection.id }, 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 },
}); });
} 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) { } catch (error) {
throw new ConflictException(error); throw new ConflictException(error);
} }
@ -596,6 +597,12 @@ export class UserCollectionService {
return E.left(USER_COLL_ALREADY_ROOT); 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 // 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 // Move child collection into root and update orderIndexes for child userCollections
const updatedCollection = await this.changeParentAndUpdateOrderIndex( const updatedCollection = await this.changeParentAndUpdateOrderIndex(
@ -643,7 +650,40 @@ export class UserCollectionService {
return E.left(USER_COLL_IS_PARENT_COLL); 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 // Move root/child collection into another child collection and update orderIndexes of the previous parent
const updatedCollection = await this.changeParentAndUpdateOrderIndex( const updatedCollection = await this.changeParentAndUpdateOrderIndex(
tx, tx,

View file

@ -539,6 +539,21 @@ describe("hopp test [options] <file_path_or_id>", { timeout: 100000 }, () => {
// Clean up // Clean up
fs.unlinkSync(junitPath); fs.unlinkSync(junitPath);
}, 600000); // 600 second (10 minute) timeout }, 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 <file_path_or_id> --env <file_path_or_id>` command:", () => { describe("Test `hopp test <file_path_or_id> --env <file_path_or_id>` command:", () => {

View file

@ -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});"
}

View file

@ -144,6 +144,8 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M
headers: [], headers: [],
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
], ],
requests: [ requests: [
@ -192,6 +194,8 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M
}, },
], ],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
], ],
requests: [ requests: [
@ -224,6 +228,8 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M
headers: [], headers: [],
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
], ],
requests: [ requests: [
@ -273,6 +279,8 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M
}, },
], ],
description: null, 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", name: "Multiple child collections with authorization, headers and variables set at each level",
folders: [ folders: [
{ {
v: 11, v: CollectionSchemaVersion,
id: "clx1fjgah000110f8a5bs68gd", id: "clx1fjgah000110f8a5bs68gd",
name: "folder-1", name: "folder-1",
folders: [ folders: [
{ {
v: 11, v: CollectionSchemaVersion,
id: "clx1fjwmm000410f8l1gkkr1a", id: "clx1fjwmm000410f8l1gkkr1a",
name: "folder-11", name: "folder-11",
folders: [], folders: [],
@ -567,9 +575,11 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
}, },
], ],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
{ {
v: 11, v: CollectionSchemaVersion,
id: "clx1fjyxm000510f8pv90dt43", id: "clx1fjyxm000510f8pv90dt43",
name: "folder-12", name: "folder-12",
folders: [], folders: [],
@ -634,9 +644,11 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
}, },
], ],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
{ {
v: 11, v: CollectionSchemaVersion,
id: "clx1fk1cv000610f88kc3aupy", id: "clx1fk1cv000610f88kc3aupy",
name: "folder-13", name: "folder-13",
folders: [], folders: [],
@ -719,6 +731,8 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
}, },
], ],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
], ],
requests: [ requests: [
@ -764,14 +778,16 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
}, },
], ],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
{ {
v: 11, v: CollectionSchemaVersion,
id: "clx1fjk9o000210f8j0573pls", id: "clx1fjk9o000210f8j0573pls",
name: "folder-2", name: "folder-2",
folders: [ folders: [
{ {
v: 11, v: CollectionSchemaVersion,
id: "clx1fk516000710f87sfpw6bo", id: "clx1fk516000710f87sfpw6bo",
name: "folder-21", name: "folder-21",
folders: [], folders: [],
@ -818,9 +834,11 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
}, },
], ],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
{ {
v: 11, v: CollectionSchemaVersion,
id: "clx1fk72t000810f8gfwkpi5y", id: "clx1fk72t000810f8gfwkpi5y",
name: "folder-22", name: "folder-22",
folders: [], folders: [],
@ -885,9 +903,11 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
}, },
], ],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
{ {
v: 11, v: CollectionSchemaVersion,
id: "clx1fk95g000910f8bunhaoo8", id: "clx1fk95g000910f8bunhaoo8",
name: "folder-23", name: "folder-23",
folders: [], folders: [],
@ -957,6 +977,8 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
}, },
], ],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
], ],
requests: [ requests: [
@ -1008,15 +1030,17 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
}, },
], ],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
{ {
v: 11, v: CollectionSchemaVersion,
id: "clx1fjmlq000310f86o4d3w2o", id: "clx1fjmlq000310f86o4d3w2o",
name: "folder-3", name: "folder-3",
folders: [ folders: [
{ {
v: 11, v: CollectionSchemaVersion,
id: "clx1iwq0p003e10f8u8zg0p85", id: "clx1iwq0p003e10f8u8zg0p85",
name: "folder-31", name: "folder-31",
folders: [], folders: [],
@ -1063,9 +1087,11 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
}, },
], ],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
{ {
v: 11, v: CollectionSchemaVersion,
id: "clx1izut7003m10f894ip59zg", id: "clx1izut7003m10f894ip59zg",
name: "folder-32", name: "folder-32",
folders: [], folders: [],
@ -1130,9 +1156,11 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
}, },
], ],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
{ {
v: 11, v: CollectionSchemaVersion,
id: "clx1j2ka9003q10f8cdbzpgpg", id: "clx1j2ka9003q10f8cdbzpgpg",
name: "folder-33", name: "folder-33",
folders: [], folders: [],
@ -1202,6 +1230,8 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
}, },
], ],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
], ],
requests: [ requests: [
@ -1266,6 +1296,8 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
}, },
], ],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
], ],
requests: [ requests: [
@ -1321,6 +1353,8 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
}, },
], ],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
]; ];
@ -1428,6 +1462,8 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
headers: [], headers: [],
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
{ {
v: CollectionSchemaVersion, v: CollectionSchemaVersion,
@ -1476,6 +1512,8 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
}, },
], ],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
{ {
v: CollectionSchemaVersion, v: CollectionSchemaVersion,
@ -1490,6 +1528,8 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
headers: [], headers: [],
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
{ {
v: CollectionSchemaVersion, v: CollectionSchemaVersion,
@ -1518,6 +1558,8 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
}, },
], ],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
], ],
requests: [], requests: [],
@ -1528,6 +1570,8 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
headers: [], headers: [],
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
}, },
]; ];

View file

@ -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: "<<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: "<<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: "<<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");
}
});
});

View file

@ -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() {");
});
});
});

View file

@ -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");
}
});
});

View file

@ -39,12 +39,14 @@ export interface RequestRunnerResponse extends TestResponse {
* @property {TestResponse} response Response structure for test script runner. * @property {TestResponse} response Response structure for test script runner.
* @property {HoppEnvs} envs Environment variables for test script runner. * @property {HoppEnvs} envs Environment variables for test script runner.
* @property {boolean} legacySandbox Whether to use the legacy sandbox. * @property {boolean} legacySandbox Whether to use the legacy sandbox.
* @property {string[]} inheritedTestScripts Test scripts inherited from parent collections.
*/ */
export interface TestScriptParams { export interface TestScriptParams {
request: HoppRESTRequest; request: HoppRESTRequest;
response: TestResponse; response: TestResponse;
envs: HoppEnvs; envs: HoppEnvs;
legacySandbox: boolean; legacySandbox: boolean;
inheritedTestScripts?: string[];
} }
/** /**

View file

@ -44,4 +44,6 @@ export type ProcessRequestParams = {
delay: number; delay: number;
legacySandbox?: boolean; legacySandbox?: boolean;
collectionVariables?: HoppCollectionVariable[]; collectionVariables?: HoppCollectionVariable[];
inheritedPreRequestScripts?: string[];
inheritedTestScripts?: string[];
}; };

View file

@ -34,6 +34,7 @@ import {
processRequest, processRequest,
} from "./request"; } from "./request";
import { getTestMetrics } from "./test"; import { getTestMetrics } from "./test";
import { filterValidScripts } from "./scripting";
const { WARN, FAIL, INFO } = exceptionColors; const { WARN, FAIL, INFO } = exceptionColors;
@ -109,8 +110,21 @@ const processCollection = async (
envs: HoppEnvs, envs: HoppEnvs,
delay: number, delay: number,
requestsReport: RequestReport[], 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 // Process each request in the collection
for (const request of collection.requests) { for (const request of collection.requests) {
const _request = preProcessRequest(request as HoppRESTRequest, collection); const _request = preProcessRequest(request as HoppRESTRequest, collection);
@ -127,6 +141,8 @@ const processCollection = async (
delay, delay,
legacySandbox, legacySandbox,
collectionVariables, collectionVariables,
inheritedPreRequestScripts,
inheritedTestScripts,
}; };
// Request processing initiated message. // Request processing initiated message.
@ -188,7 +204,9 @@ const processCollection = async (
envs, envs,
delay, delay,
requestsReport, requestsReport,
legacySandbox legacySandbox,
inheritedPreRequestScripts,
inheritedTestScripts
); );
} }
}; };

View file

@ -9,6 +9,9 @@ import { FormDataEntry } from "../types/request";
import { isHoppErrnoException } from "./checks"; import { isHoppErrnoException } from "./checks";
import { getResourceContents } from "./getters"; import { getResourceContents } from "./getters";
// Re-export from the canonical implementation in scripting.ts
export { stripModulePrefix } from "./scripting";
const getValidRequests = ( const getValidRequests = (
collections: HoppCollection[], collections: HoppCollection[],
collectionFilePath: string collectionFilePath: string
@ -158,19 +161,3 @@ export async function parseCollectionData(
return getValidRequests(collectionSchemaParsedResult.data, pathOrId); 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;
};

View file

@ -35,13 +35,17 @@ import { isHoppCLIError } from "./checks";
import { arrayFlatMap, arraySort, tupleToRecord } from "./functions/array"; import { arrayFlatMap, arraySort, tupleToRecord } from "./functions/array";
import { getEffectiveFinalMetaData, getResolvedVariables } from "./getters"; import { getEffectiveFinalMetaData, getResolvedVariables } from "./getters";
import { stripComments } from "./jsonc"; 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 * Runs pre-request-script runner over given request which extracts set ENVs and
* applies them on current request to generate updated request. * applies them on current request to generate updated request.
* @param request HoppRESTRequest to be converted to EffectiveHoppRESTRequest. * @param request HoppRESTRequest to be converted to EffectiveHoppRESTRequest.
* @param envs Environment variables related to request. * @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 * @returns EffectiveHoppRESTRequest that includes parsed ENV variables with in
* request OR HoppCLIError with error code and related information. * request OR HoppCLIError with error code and related information.
*/ */
@ -49,7 +53,8 @@ export const preRequestScriptRunner = (
request: HoppRESTRequest, request: HoppRESTRequest,
envs: HoppEnvs, envs: HoppEnvs,
legacySandbox: boolean, legacySandbox: boolean,
collectionVariables?: HoppCollectionVariable[] collectionVariables?: HoppCollectionVariable[],
inheritedPreRequestScripts: string[] = []
): TE.TaskEither< ): TE.TaskEither<
HoppCLIError, HoppCLIError,
{ effectiveRequest: EffectiveHoppRESTRequest } & { updatedEnvs: HoppEnvs } { effectiveRequest: EffectiveHoppRESTRequest } & { updatedEnvs: HoppEnvs }
@ -57,10 +62,19 @@ export const preRequestScriptRunner = (
const experimentalScriptingSandbox = !legacySandbox; const experimentalScriptingSandbox = !legacySandbox;
const hoppFetchHook = createHoppFetchHook(); const hoppFetchHook = createHoppFetchHook();
// Pre-request order: root → request.
const combinedScript = combineScriptsWithIIFE(
filterValidScripts([
...inheritedPreRequestScripts,
request.preRequestScript,
]),
experimentalScriptingSandbox ? "experimental" : "legacy"
);
return pipe( return pipe(
TE.of(request), TE.of(request),
TE.chain(({ preRequestScript }) => TE.chain(() =>
runPreRequestScript(stripModulePrefix(preRequestScript), { runPreRequestScript(combinedScript, {
envs, envs,
experimentalScriptingSandbox, experimentalScriptingSandbox,
request, request,

View file

@ -232,8 +232,16 @@ export const processRequest =
params: ProcessRequestParams params: ProcessRequestParams
): T.Task<{ envs: HoppEnvs; report: RequestReport }> => ): T.Task<{ envs: HoppEnvs; report: RequestReport }> =>
async () => { async () => {
const { envs, path, request, delay, legacySandbox, collectionVariables } = const {
params; envs,
path,
request,
delay,
legacySandbox,
collectionVariables,
inheritedPreRequestScripts = [],
inheritedTestScripts = [],
} = params;
// Initialising updatedEnvs with given parameter envs, will eventually get updated. // Initialising updatedEnvs with given parameter envs, will eventually get updated.
const result = { const result = {
@ -258,17 +266,21 @@ export const processRequest =
effectiveFinalParams: [], effectiveFinalParams: [],
effectiveFinalURL: "", effectiveFinalURL: "",
}; };
let updatedEnvs = <HoppEnvs>{};
// Fetch values for secret environment variables from system environment // Fetch values for secret environment variables from system environment
const processedEnvs = processEnvs(envs); 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( const preRequestRes = await preRequestScriptRunner(
request, request,
processedEnvs, processedEnvs,
legacySandbox ?? false, legacySandbox ?? false,
collectionVariables collectionVariables,
inheritedPreRequestScripts
)(); )();
if (E.isLeft(preRequestRes)) { if (E.isLeft(preRequestRes)) {
printPreRequestRunner.fail(); printPreRequestRunner.fail();
@ -317,12 +329,12 @@ export const processRequest =
printRequestRunner.success(_requestRunnerRes); printRequestRunner.success(_requestRunnerRes);
} }
// Extracting test-script-runner parameters.
const testScriptParams = getTestScriptParams( const testScriptParams = getTestScriptParams(
_requestRunnerRes, _requestRunnerRes,
effectiveRequest, effectiveRequest,
updatedEnvs, updatedEnvs,
legacySandbox ?? false legacySandbox ?? false,
inheritedTestScripts
); );
// Executing test-runner. // Executing test-runner.

View file

@ -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
);

View file

@ -18,7 +18,7 @@ import { HoppEnvs } from "../types/request";
import { ExpectResult, TestMetrics, TestRunnerRes } from "../types/response"; import { ExpectResult, TestMetrics, TestRunnerRes } from "../types/response";
import { getDurationInSeconds } from "./getters"; import { getDurationInSeconds } from "./getters";
import { createHoppFetchHook } from "./hopp-fetch"; 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 * Executes test script and runs testDescriptorParser to generate test-report using
@ -39,28 +39,46 @@ export const testRunner = (
TE.bind("test_response", () => TE.bind("test_response", () =>
pipe( pipe(
TE.of(testScriptData), TE.of(testScriptData),
TE.chain(({ request, response, envs, legacySandbox }) => { TE.chain(
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,
request, request,
response: effectiveResponse, response,
experimentalScriptingSandbox, envs,
hoppFetchHook, 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, reqRunnerRes: RequestRunnerResponse,
request: HoppRESTRequest, request: HoppRESTRequest,
envs: HoppEnvs, envs: HoppEnvs,
legacySandbox: boolean legacySandbox: boolean,
inheritedTestScripts: string[] = []
) => { ) => {
const testScriptParams: TestScriptParams = { const testScriptParams: TestScriptParams = {
request, request,
@ -173,6 +192,7 @@ export const getTestScriptParams = (
}, },
envs, envs,
legacySandbox, legacySandbox,
inheritedTestScripts,
}; };
return testScriptParams; return testScriptParams;
}; };

View file

@ -179,6 +179,8 @@ export const transformWorkspaceCollections = (
headers?: HoppRESTHeaders; headers?: HoppRESTHeaders;
variables: HoppCollectionVariable[]; variables: HoppCollectionVariable[];
description: string | null; description: string | null;
preRequestScript?: string;
testScript?: string;
} = data ? JSON.parse(data) : {}; } = data ? JSON.parse(data) : {};
const { const {
@ -186,6 +188,8 @@ export const transformWorkspaceCollections = (
headers = [], headers = [],
variables = [], variables = [],
description = null, description = null,
preRequestScript = "",
testScript = "",
} = parsedData; } = parsedData;
const transformedAuth = transformAuth(auth); const transformedAuth = transformAuth(auth);
@ -211,6 +215,8 @@ export const transformWorkspaceCollections = (
headers: transformedHeaders, headers: transformedHeaders,
variables: filteredCollectionVariables, variables: filteredCollectionVariables,
description, description,
preRequestScript,
testScript,
}; };
}); });
}; };

View file

@ -841,6 +841,7 @@
"authorization": "The authorization header will be automatically generated when you send the request.", "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_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_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", "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.", "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.", "offline": "You're using Hoppscotch offline. Updates will sync when you're online, based on workspace settings.",
@ -1256,6 +1257,12 @@
"saved": "Response saved", "saved": "Response saved",
"invalid_name": "Please provide a name for the response" "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": { "settings": {
"accent_color": "Accent color", "accent_color": "Accent color",
"account": "Account", "account": "Account",
@ -1781,6 +1788,7 @@
"parameters": "Parameters", "parameters": "Parameters",
"post_request_script": "Post-request Script", "post_request_script": "Post-request Script",
"pre_request_script": "Pre-request Script", "pre_request_script": "Pre-request Script",
"scripts": "Scripts",
"queries": "Queries", "queries": "Queries",
"query": "Query", "query": "Query",
"schema": "Schema", "schema": "Schema",

View file

@ -245,6 +245,7 @@ declare module 'vue' {
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default'] IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default'] IconLucideCircleCheck: typeof import('~icons/lucide/circle-check')['default']
IconLucideFileQuestion: typeof import('~icons/lucide/file-question')['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'] IconLucideFileText: typeof import('~icons/lucide/file-text')['default']
IconLucideFileX: typeof import('~icons/lucide/file-x')['default'] IconLucideFileX: typeof import('~icons/lucide/file-x')['default']
IconLucideFolder: typeof import('~icons/lucide/folder')['default'] IconLucideFolder: typeof import('~icons/lucide/folder')['default']

View file

@ -26,9 +26,11 @@ const props = withDefaults(
defineProps<{ defineProps<{
modelValue: string modelValue: string
type: "pre-request" | "post-request" type: "pre-request" | "post-request"
readOnly?: boolean
}>(), }>(),
{ {
modelValue: "", modelValue: "",
readOnly: false,
} }
) )
@ -40,12 +42,15 @@ const emit = defineEmits<{
const editorModel = ref<monaco.editor.ITextModel | null>(null) const editorModel = ref<monaco.editor.ITextModel | null>(null)
const MONACO_EDITOR_OPTIONS: Readonly<monaco.editor.IStandaloneEditorConstructionOptions> = const MONACO_EDITOR_OPTIONS = computed<
{ Readonly<monaco.editor.IStandaloneEditorConstructionOptions>
automaticLayout: true, >(() => ({
formatOnType: true, automaticLayout: true,
formatOnPaste: true, formatOnType: !props.readOnly,
} formatOnPaste: !props.readOnly,
readOnly: props.readOnly,
domReadOnly: props.readOnly,
}))
// Static imports: import X from "URL" // Static imports: import X from "URL"
const staticImportRegex = const staticImportRegex =
@ -237,3 +242,27 @@ const monacoEditorTheme = computed(() =>
["dark", "black"].includes(theme.value) ? "vs-dark" : "vs" ["dark", "black"].includes(theme.value) ? "vs-dark" : "vs"
) )
</script> </script>
<style scoped lang="scss">
/* Override Monaco editor colors with Hoppscotch CSS variables
to keep visual consistency with the CodeMirror editors.
:deep() penetrates into Monaco's rendered DOM within this component. */
:deep(.monaco-editor),
:deep(.monaco-editor .overflow-guard),
:deep(.monaco-editor-background),
:deep(.monaco-editor .margin) {
background-color: var(--primary-color) !important;
}
:deep(.monaco-editor .line-numbers) {
color: var(--secondary-light-color) !important;
}
:deep(.monaco-editor .cursor) {
border-color: var(--secondary-color) !important;
}
:deep(.monaco-editor .selected-text) {
background-color: var(--accent-dark-color) !important;
}
</style>

View file

@ -64,6 +64,83 @@
/> />
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab
v-if="source === 'REST'"
id="scripts"
:label="`${t('tab.scripts')}`"
>
<div class="flex flex-col flex-1">
<HoppSmartTabs
v-model="activeScriptsTab"
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-0 z-10"
render-inactive-tabs
>
<HoppSmartTab
id="pre-request"
:label="`${t('tab.pre_request_script')}`"
:indicator="
hasActualScript(editableCollection.preRequestScript)
"
>
<div class="flex flex-col flex-1">
<div class="h-64 overflow-hidden relative">
<MonacoScriptEditor
v-if="
EXPERIMENTAL_SCRIPTING_SANDBOX &&
activeTab === 'scripts' &&
activeScriptsTab === 'pre-request'
"
v-model="editableCollection.preRequestScript"
type="pre-request"
:read-only="!hasTeamWriteAccess"
/>
<div
v-else
ref="preRequestEditor"
class="h-full absolute inset-0"
></div>
</div>
</div>
</HoppSmartTab>
<HoppSmartTab
id="test-script"
:label="`${t('tab.post_request_script')}`"
:indicator="hasActualScript(editableCollection.testScript)"
>
<div class="flex flex-col flex-1">
<div
class="h-64 border-b border-dividerLight overflow-hidden relative"
>
<MonacoScriptEditor
v-if="
EXPERIMENTAL_SCRIPTING_SANDBOX &&
activeTab === 'scripts' &&
activeScriptsTab === 'test-script'
"
v-model="editableCollection.testScript"
type="post-request"
:read-only="!hasTeamWriteAccess"
/>
<div
v-else
ref="testScriptEditor"
class="h-full absolute inset-0"
></div>
</div>
</div>
</HoppSmartTab>
</HoppSmartTabs>
<div
class="bg-bannerInfo px-4 py-2 flex items-center sticky bottom-0"
>
<icon-lucide-info class="svg-icons mr-2" />
{{ t("helpers.collection_properties_scripts") }}
</div>
</div>
</HoppSmartTab>
<HoppSmartTab <HoppSmartTab
v-if="showDetails" v-if="showDetails"
:id="'details'" :id="'details'"
@ -138,11 +215,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue" import { computed, reactive, ref, watch } from "vue"
import { refAutoReset, useVModel } from "@vueuse/core" import { refAutoReset, useVModel } from "@vueuse/core"
import { clone } from "lodash-es" import { clone } from "lodash-es"
import { useCodemirror } from "@composables/codemirror"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { useSetting } from "~/composables/settings"
import { useToast } from "~/composables/toast" import { useToast } from "~/composables/toast"
import preRequestCompleter from "~/helpers/editor/completion/preRequest"
import testScriptCompleter from "~/helpers/editor/completion/testScript"
import preRequestLinter from "~/helpers/editor/linting/preRequest"
import testScriptLinter from "~/helpers/editor/linting/testScript"
import { copyToClipboard } from "~/helpers/utils/clipboard" import { copyToClipboard } from "~/helpers/utils/clipboard"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
@ -154,6 +237,7 @@ import {
HoppRESTHeaders, HoppRESTHeaders,
GQLHeader, GQLHeader,
} from "@hoppscotch/data" } from "@hoppscotch/data"
import { hasActualScript } from "~/helpers/scripting"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { PersistenceService } from "~/services/persistence" import { PersistenceService } from "~/services/persistence"
@ -206,10 +290,14 @@ const editableCollection = ref<{
headers: HoppCollectionHeaders headers: HoppCollectionHeaders
auth: HoppCollectionAuth auth: HoppCollectionAuth
variables: HoppCollectionVariable[] variables: HoppCollectionVariable[]
preRequestScript: string
testScript: string
}>({ }>({
headers: [], headers: [],
auth: { authType: "inherit", authActive: false }, auth: { authType: "inherit", authActive: false },
variables: [], variables: [],
preRequestScript: "",
testScript: "",
}) })
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>( const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
@ -217,9 +305,65 @@ const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
1000 1000
) )
const activeTab = useVModel(props, "modelValue", emit) const activeTab = useVModel(props, "modelValue", emit)
const activeScriptsTab = ref<"pre-request" | "test-script">("pre-request")
const activeTabIsDetails = computed(() => activeTab.value === "details") const activeTabIsDetails = computed(() => activeTab.value === "details")
const EXPERIMENTAL_SCRIPTING_SANDBOX = useSetting(
"EXPERIMENTAL_SCRIPTING_SANDBOX"
)
const preRequestEditor = ref<any | null>(null)
const testScriptEditor = ref<any | null>(null)
const preRequestScriptModel = computed({
get: () => editableCollection.value.preRequestScript,
set: (val: string) => {
editableCollection.value.preRequestScript = val
},
})
const testScriptModel = computed({
get: () => editableCollection.value.testScript,
set: (val: string) => {
editableCollection.value.testScript = val
},
})
useCodemirror(
preRequestEditor,
preRequestScriptModel,
reactive({
extendedEditorConfig: {
mode: "application/javascript",
lineWrapping: true,
placeholder: `${t("preRequest.javascript_code")}`,
readOnly: !props.hasTeamWriteAccess,
},
linter: preRequestLinter,
completer: preRequestCompleter,
environmentHighlights: false,
contextMenuEnabled: false,
})
)
useCodemirror(
testScriptEditor,
testScriptModel,
reactive({
extendedEditorConfig: {
mode: "application/javascript",
lineWrapping: true,
placeholder: `${t("test.javascript_code")}`,
readOnly: !props.hasTeamWriteAccess,
},
linter: testScriptLinter,
completer: testScriptCompleter,
environmentHighlights: false,
contextMenuEnabled: false,
})
)
const persistUnsavedChanges = async ( const persistUnsavedChanges = async (
updated: typeof editableCollection.value updated: typeof editableCollection.value
) => { ) => {
@ -258,23 +402,34 @@ const enforceTabAccessRules = () => {
["headers", "authorization"].includes(activeTab.value) ["headers", "authorization"].includes(activeTab.value)
) )
activeTab.value = "variables" activeTab.value = "variables"
// `Scripts` tab only exists for REST collections
// Switch to `Variables` tab if scripts tab becomes unavailable
if (activeTab.value === "scripts" && props.source !== "REST")
activeTab.value = "variables"
} }
const loadEditableCollection = () => { const loadEditableCollection = () => {
activeScriptsTab.value = "pre-request"
editableCollection.value = { editableCollection.value = {
auth: clone(props.editingProperties.collection!.auth as HoppCollectionAuth), auth: clone(props.editingProperties.collection!.auth as HoppCollectionAuth),
headers: clone( headers: clone(
props.editingProperties.collection!.headers as HoppCollectionHeaders props.editingProperties.collection!.headers as HoppCollectionHeaders
), ),
variables: clone(props.editingProperties.collection!.variables || []), variables: clone(props.editingProperties.collection!.variables || []),
preRequestScript:
props.editingProperties.collection!.preRequestScript || "",
testScript: props.editingProperties.collection!.testScript || "",
} }
} }
const resetEditableCollection = () => { const resetEditableCollection = () => {
activeScriptsTab.value = "pre-request"
editableCollection.value = { editableCollection.value = {
headers: [], headers: [],
auth: { authType: "inherit", authActive: false }, auth: { authType: "inherit", authActive: false },
variables: [], variables: [],
preRequestScript: "",
testScript: "",
} }
} }

View file

@ -737,6 +737,8 @@ const saveCollectionDocumentation = async () => {
headers: collection.headers || [], headers: collection.headers || [],
variables: collection.variables || [], variables: collection.variables || [],
description: documentationDescription.value, description: documentationDescription.value,
preRequestScript: collection.preRequestScript || "",
testScript: collection.testScript || "",
} }
pipe( pipe(
@ -831,6 +833,8 @@ const saveCollectionDocumentationById = async (
headers: collectionData.headers || [], headers: collectionData.headers || [],
variables: collectionData.variables || [], variables: collectionData.variables || [],
description: documentation, description: documentation,
preRequestScript: collectionData.preRequestScript || "",
testScript: collectionData.testScript || "",
} }
const result = await pipe( const result = await pipe(

View file

@ -70,6 +70,9 @@ const addNewCollection = () => {
}, },
headers: [], headers: [],
variables: [], variables: [],
description: "",
preRequestScript: "",
testScript: "",
}) })
) )

View file

@ -906,6 +906,8 @@ const addNewRootCollection = async (name: string) => {
}, },
variables: [], variables: [],
description: "", description: "",
preRequestScript: "",
testScript: "",
}) })
) )
@ -3254,6 +3256,7 @@ const editProperties = async (payload: {
}, },
headers: [], headers: [],
variables: [], variables: [],
scripts: [],
} }
if (parentIndex) { if (parentIndex) {
@ -3307,16 +3310,19 @@ const editProperties = async (payload: {
description: null as string | null, description: null as string | null,
folders: null, folders: null,
requests: null, requests: null,
preRequestScript: "",
testScript: "",
} }
if (parentIndex) { if (parentIndex) {
const { auth, headers, variables } = const { auth, headers, variables, scripts } =
teamCollectionService.cascadeParentCollectionForProperties(parentIndex) teamCollectionService.cascadeParentCollectionForProperties(parentIndex)
inheritedProperties = { inheritedProperties = {
auth, auth,
headers, headers,
variables, variables,
scripts,
} }
} }
@ -3337,6 +3343,8 @@ const editProperties = async (payload: {
headers: data.headers, headers: data.headers,
variables: collectionVariables, variables: collectionVariables,
description: data.description, description: data.description,
preRequestScript: data.preRequestScript ?? "",
testScript: data.testScript ?? "",
} }
coll = { coll = {
@ -3448,6 +3456,8 @@ const setCollectionProperties = (newCollection: {
headers: collection.headers ?? [], headers: collection.headers ?? [],
variables: collection.variables ?? [], variables: collection.variables ?? [],
description: collection.description ?? null, description: collection.description ?? null,
preRequestScript: collection.preRequestScript ?? "",
testScript: collection.testScript ?? "",
} }
// Mark as loading BEFORE triggering async update to avoid race conditions and push the collectionId to the loading array // Mark as loading BEFORE triggering async update to avoid race conditions and push the collectionId to the loading array

View file

@ -0,0 +1,148 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="t('script.inherited_scripts')"
styles="sm:max-w-3xl"
full-width-body
@close="emit('close')"
>
<template #body>
<div class="flex h-[24rem]">
<div
class="flex w-52 flex-shrink-0 flex-col border-r border-dividerLight overflow-auto bg-primary"
>
<button
v-for="script in scripts"
:key="script.parentID"
class="group flex items-center gap-2 px-3 py-2.5 text-left text-tiny transition"
:class="
isSelected(script.parentID)
? 'bg-primaryLight text-secondaryDark'
: 'text-secondaryLight hover:bg-primaryLight/50 hover:text-secondary'
"
@click="selectedScriptID = script.parentID"
>
<icon-lucide-folder
class="svg-icons !w-3.5 !h-3.5 flex-shrink-0"
:class="
isSelected(script.parentID)
? 'text-secondaryDark'
: 'text-secondaryLight opacity-50 group-hover:opacity-75'
"
aria-hidden="true"
/>
<span class="flex-1 truncate font-bold">
{{ script.parentName }}
</span>
</button>
</div>
<div class="relative flex flex-1 flex-col overflow-hidden">
<HoppButtonSecondary
v-if="selectedScript"
v-tippy="{ theme: 'tooltip' }"
:title="t('action.copy')"
:icon="copyIcon"
class="!absolute right-2 top-2 z-10"
@click="copyScriptContent(displayedScript)"
/>
<div
ref="scriptEditor"
class="flex-1 overflow-auto"
></div>
</div>
</div>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { useCodemirror } from "@composables/codemirror"
import { useI18n } from "@composables/i18n"
import { useNestedSetting } from "~/composables/settings"
import { refAutoReset } from "@vueuse/core"
import { computed, reactive, ref, watch } from "vue"
import { stripModulePrefix } from "~/helpers/scripting"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import IconCheck from "~icons/lucide/check"
import IconCopy from "~icons/lucide/copy"
type InheritedScript = {
parentID: string
parentName: string
preRequestScript: string
testScript: string
}
const props = defineProps<{
show: boolean
scripts: InheritedScript[]
scriptType: "preRequestScript" | "testScript"
}>()
const emit = defineEmits<{
(e: "close"): void
}>()
const t = useI18n()
const selectedScriptID = ref<string | null>(null)
// Reset selection when modal reopens to avoid stale state
watch(
() => props.show,
(isVisible) => {
if (isVisible) {
selectedScriptID.value = null
}
}
)
const isSelected = (id: string) =>
selectedScriptID.value === id ||
(selectedScriptID.value === null && props.scripts[0]?.parentID === id)
const selectedScript = computed(
() =>
props.scripts.find((s) => s.parentID === selectedScriptID.value) ??
props.scripts[0] ??
null
)
const displayedScript = computed(() =>
selectedScript.value
? stripModulePrefix(selectedScript.value[props.scriptType])
: ""
)
const scriptEditor = ref<any | null>(null)
const WRAP_LINES = useNestedSetting(
"WRAP_LINES",
props.scriptType === "preRequestScript" ? "httpPreRequest" : "httpTest"
)
useCodemirror(
scriptEditor,
displayedScript,
reactive({
extendedEditorConfig: {
mode: "application/javascript",
readOnly: true,
lineWrapping: WRAP_LINES,
},
linter: null,
completer: null,
environmentHighlights: false,
})
)
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
IconCopy,
1000
)
const copyScriptContent = (script: string) => {
copyToClipboard(script)
copyIcon.value = IconCheck
}
</script>

View file

@ -3,9 +3,26 @@
<div <div
class="sticky top-upperMobileSecondaryStickyFold z-10 flex flex-shrink-0 items-center justify-between overflow-x-auto border-b border-dividerLight bg-primary pl-4 sm:top-upperSecondaryStickyFold" class="sticky top-upperMobileSecondaryStickyFold z-10 flex flex-shrink-0 items-center justify-between overflow-x-auto border-b border-dividerLight bg-primary pl-4 sm:top-upperSecondaryStickyFold"
> >
<label class="truncate font-semibold text-secondaryLight"> <div class="flex items-center gap-2">
{{ t("preRequest.javascript_code") }} <label class="truncate font-semibold text-secondaryLight">
</label> {{ t("preRequest.javascript_code") }}
</label>
<HoppButtonSecondary
v-if="inheritedScripts.length > 0"
v-tippy="{ theme: 'tooltip' }"
:title="t('script.view_inherited')"
:label="
t('script.inheriting_from_count', {
count: inheritedScripts.length,
})
"
:icon="IconFileSymlink"
class="!px-1 !py-0.5 text-yellow-500 hover:text-yellow-500"
filled
outline
@click="showInheritedModal = true"
/>
</div>
<div class="flex"> <div class="flex">
<HoppButtonSecondary <HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
@ -76,6 +93,12 @@
</div> </div>
</div> </div>
</div> </div>
<HttpInheritedScriptsModal
:show="showInheritedModal"
:scripts="inheritedScripts"
script-type="preRequestScript"
@close="showInheritedModal = false"
/>
<AiexperimentsModifyPreRequestModal <AiexperimentsModifyPreRequestModal
v-if="isModifyPreRequestModalOpen && currentRequest" v-if="isModifyPreRequestModalOpen && currentRequest"
:current-script="preRequestScript" :current-script="preRequestScript"
@ -101,9 +124,12 @@ import { useReadonlyStream } from "~/composables/stream"
import { invokeAction } from "~/helpers/actions" import { invokeAction } from "~/helpers/actions"
import completer from "~/helpers/editor/completion/preRequest" import completer from "~/helpers/editor/completion/preRequest"
import linter from "~/helpers/editor/linting/preRequest" import linter from "~/helpers/editor/linting/preRequest"
import { hasActualScript } from "~/helpers/scripting"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { toggleNestedSetting } from "~/newstore/settings" import { toggleNestedSetting } from "~/newstore/settings"
import { platform } from "~/platform" import { platform } from "~/platform"
import { RESTTabService } from "~/services/tab/rest" import { RESTTabService } from "~/services/tab/rest"
import IconFileSymlink from "~icons/lucide/file-symlink"
import IconHelpCircle from "~icons/lucide/help-circle" import IconHelpCircle from "~icons/lucide/help-circle"
import IconSparkles from "~icons/lucide/sparkles" import IconSparkles from "~icons/lucide/sparkles"
import IconTrash2 from "~icons/lucide/trash-2" import IconTrash2 from "~icons/lucide/trash-2"
@ -114,6 +140,7 @@ const t = useI18n()
const props = defineProps<{ const props = defineProps<{
modelValue: string modelValue: string
isActive?: boolean isActive?: boolean
inheritedProperties?: HoppInheritedProperty
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: "update:modelValue", value: string): void (e: "update:modelValue", value: string): void
@ -121,6 +148,16 @@ const emit = defineEmits<{
const preRequestScript = useVModel(props, "modelValue", emit) 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<any | null>(null) const preRequestEditor = ref<any | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpPreRequest") const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpPreRequest")

View file

@ -54,17 +54,16 @@
:id="'preRequestScript'" :id="'preRequestScript'"
:label="`${t('tab.pre_request_script')}`" :label="`${t('tab.pre_request_script')}`"
:indicator=" :indicator="
'preRequestScript' in request && ('preRequestScript' in request &&
request.preRequestScript && hasActualScript(request.preRequestScript)) ||
request.preRequestScript.length > 0 hasInheritedPreRequestScripts
? true
: false
" "
> >
<HttpPreRequestScript <HttpPreRequestScript
v-if="'preRequestScript' in request" v-if="'preRequestScript' in request"
v-model="request.preRequestScript" v-model="request.preRequestScript"
:is-active="selectedOptionTab === 'preRequestScript'" :is-active="selectedOptionTab === 'preRequestScript'"
:inherited-properties="inheritedProperties"
/> />
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab <HoppSmartTab
@ -72,17 +71,15 @@
:id="'tests'" :id="'tests'"
:label="`${t('tab.post_request_script')}`" :label="`${t('tab.post_request_script')}`"
:indicator=" :indicator="
'testScript' in request && ('testScript' in request && hasActualScript(request.testScript)) ||
request.testScript && hasInheritedTestScripts
request.testScript.length > 0
? true
: false
" "
> >
<HttpTests <HttpTests
v-if="'testScript' in request" v-if="'testScript' in request"
v-model="request.testScript" v-model="request.testScript"
:is-active="selectedOptionTab === 'tests'" :is-active="selectedOptionTab === 'tests'"
:inherited-properties="inheritedProperties"
/> />
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab <HoppSmartTab
@ -107,6 +104,7 @@ import { useVModel } from "@vueuse/core"
import { computed } from "vue" import { computed } from "vue"
import { defineActionHandler } from "~/helpers/actions" import { defineActionHandler } from "~/helpers/actions"
import { hasActualScript } from "~/helpers/scripting"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { AggregateEnvironment } from "~/newstore/environments" import { AggregateEnvironment } from "~/newstore/environments"
@ -188,6 +186,22 @@ const isBodyFilled = computed(() => {
return Boolean(request.value.body.body && request.value.body.body.length > 0) 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 }) => { defineActionHandler("request.open-tab", ({ tab }) => {
selectedOptionTab.value = tab as RESTOptionTabs selectedOptionTab.value = tab as RESTOptionTabs
}) })

View file

@ -3,9 +3,26 @@
<div <div
class="sticky top-upperMobileSecondaryStickyFold z-10 flex flex-shrink-0 items-center justify-between overflow-x-auto border-b border-dividerLight bg-primary pl-4 sm:top-upperSecondaryStickyFold" class="sticky top-upperMobileSecondaryStickyFold z-10 flex flex-shrink-0 items-center justify-between overflow-x-auto border-b border-dividerLight bg-primary pl-4 sm:top-upperSecondaryStickyFold"
> >
<label class="truncate font-semibold text-secondaryLight"> <div class="flex items-center gap-2">
{{ t("test.javascript_code") }} <label class="truncate font-semibold text-secondaryLight">
</label> {{ t("test.javascript_code") }}
</label>
<HoppButtonSecondary
v-if="inheritedScripts.length > 0"
v-tippy="{ theme: 'tooltip' }"
:title="t('script.view_inherited')"
:label="
t('script.inheriting_from_count', {
count: inheritedScripts.length,
})
"
:icon="IconFileSymlink"
class="!px-1 !py-0.5 text-yellow-500 hover:text-yellow-500"
filled
outline
@click="showInheritedModal = true"
/>
</div>
<div class="flex"> <div class="flex">
<HoppButtonSecondary <HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
@ -76,6 +93,12 @@
</div> </div>
</div> </div>
</div> </div>
<HttpInheritedScriptsModal
:show="showInheritedModal"
:scripts="inheritedScripts"
script-type="testScript"
@close="showInheritedModal = false"
/>
<AiexperimentsModifyTestScriptModal <AiexperimentsModifyTestScriptModal
v-if="isModifyTestScriptModalOpen && currentRequest" v-if="isModifyTestScriptModalOpen && currentRequest"
:current-script="testScript" :current-script="testScript"
@ -99,10 +122,13 @@ import { useReadonlyStream } from "~/composables/stream"
import { invokeAction } from "~/helpers/actions" import { invokeAction } from "~/helpers/actions"
import completer from "~/helpers/editor/completion/testScript" import completer from "~/helpers/editor/completion/testScript"
import linter from "~/helpers/editor/linting/testScript" import linter from "~/helpers/editor/linting/testScript"
import { hasActualScript } from "~/helpers/scripting"
import testSnippets from "~/helpers/testSnippets" import testSnippets from "~/helpers/testSnippets"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { toggleNestedSetting } from "~/newstore/settings" import { toggleNestedSetting } from "~/newstore/settings"
import { platform } from "~/platform" import { platform } from "~/platform"
import { RESTTabService } from "~/services/tab/rest" import { RESTTabService } from "~/services/tab/rest"
import IconFileSymlink from "~icons/lucide/file-symlink"
import IconHelpCircle from "~icons/lucide/help-circle" import IconHelpCircle from "~icons/lucide/help-circle"
import IconSparkles from "~icons/lucide/sparkles" import IconSparkles from "~icons/lucide/sparkles"
import IconTrash2 from "~icons/lucide/trash-2" import IconTrash2 from "~icons/lucide/trash-2"
@ -113,9 +139,22 @@ const t = useI18n()
const props = defineProps<{ const props = defineProps<{
modelValue: string modelValue: string
isActive?: boolean isActive?: boolean
inheritedProperties?: HoppInheritedProperty
}>() }>()
const emit = defineEmits(["update:modelValue"]) const emit = defineEmits(["update:modelValue"])
const testScript = useVModel(props, "modelValue", emit) 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<any | null>(null) const testScriptEditor = ref<any | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpTest") const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpTest")

View file

@ -249,6 +249,11 @@ const runTests = async () => {
) )
let resolvedCollection: HoppCollection = collection.value 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) { if (!isPersonalWorkspace) {
const requestAuth = tab.value.document.inheritedProperties?.auth const requestAuth = tab.value.document.inheritedProperties?.auth
@ -270,6 +275,19 @@ const runTests = async () => {
tab.value.document.inheritedProperties?.variables ?? [] 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 = { resolvedCollection = {
...collection.value, ...collection.value,
auth: requestAuth, auth: requestAuth,
@ -277,12 +295,23 @@ const runTests = async () => {
variables: parentVariables, variables: parentVariables,
} }
} else { } else {
const { auth, headers, variables } = collectionInheritedProps ?? { const {
auth,
headers,
variables,
ancestorPreRequestScripts: preAncestors,
ancestorTestScripts: testAncestors,
} = collectionInheritedProps ?? {
auth: { authActive: true, authType: "none" }, auth: { authActive: true, authType: "none" },
headers: [], headers: [],
variables: [], variables: [],
ancestorPreRequestScripts: [],
ancestorTestScripts: [],
} }
ancestorPreRequestScripts = preAncestors
ancestorTestScripts = testAncestors
resolvedCollection = { resolvedCollection = {
...collection.value, ...collection.value,
auth, auth,
@ -292,10 +321,16 @@ const runTests = async () => {
} }
testRunnerStopRef.value = false // when testRunnerStopRef is false, the test runner will start running testRunnerStopRef.value = false // when testRunnerStopRef is false, the test runner will start running
testRunnerService.runTests(tab, resolvedCollection, { testRunnerService.runTests(
...testRunnerConfig.value, tab,
stopRef: testRunnerStopRef, resolvedCollection,
}) {
...testRunnerConfig.value,
stopRef: testRunnerStopRef,
},
ancestorPreRequestScripts,
ancestorTestScripts
)
} }
const stopTests = () => { const stopTests = () => {

View file

@ -27,7 +27,10 @@ import { map } from "fp-ts/Either"
import { runPreRequestScript, runTestScript } from "@hoppscotch/js-sandbox/web" import { runPreRequestScript, runTestScript } from "@hoppscotch/js-sandbox/web"
import { useSetting } from "~/composables/settings" import { useSetting } from "~/composables/settings"
import { getService } from "~/modules/dioc" import { getService } from "~/modules/dioc"
import { stripModulePrefix } from "~/helpers/scripting" import {
combineScriptsWithIIFE,
hasActualScript,
} from "~/helpers/scripting"
import { createHoppFetchHook } from "~/helpers/hopp-fetch" import { createHoppFetchHook } from "~/helpers/hopp-fetch"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service" import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import { import {
@ -364,14 +367,21 @@ const delegatePreRequestScriptRunner = (
selected: Environment["variables"] selected: Environment["variables"]
temp: Environment["variables"] temp: Environment["variables"]
}, },
cookies: Cookie[] | null cookies: Cookie[] | null,
inheritedPreRequestScripts: string[] = []
): Promise<E.Either<string, SandboxPreRequestResult>> => { ): Promise<E.Either<string, SandboxPreRequestResult>> => {
const { preRequestScript } = request 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 // Short-circuit empty scripts to avoid unnecessary WASM initialization
if (cleanScript.trim().length === 0) { if (combinedScript.length === 0) {
return Promise.resolve( return Promise.resolve(
E.right({ E.right({
updatedEnvs: envs, updatedEnvs: envs,
@ -380,19 +390,16 @@ const delegatePreRequestScriptRunner = (
) )
} }
if (!EXPERIMENTAL_SCRIPTING_SANDBOX.value) { if (!experimentalScriptingSandbox) {
// Strip `export {};\n` before executing in legacy sandbox to prevent syntax errors return runPreRequestScript(combinedScript, {
return runPreRequestScript(cleanScript, {
envs, envs,
experimentalScriptingSandbox: false, experimentalScriptingSandbox: false,
}) })
} }
// Experimental sandbox enabled - use faraday-cage with hook
const hoppFetchHook = createHoppFetchHook(kernelInterceptorService) const hoppFetchHook = createHoppFetchHook(kernelInterceptorService)
return runPreRequestScript(cleanScript, { return runPreRequestScript(combinedScript, {
envs, envs,
request, request,
cookies, cookies,
@ -405,14 +412,21 @@ const runPostRequestScript = (
envs: TestResult["envs"], envs: TestResult["envs"],
request: HoppRESTRequest, request: HoppRESTRequest,
response: HoppRESTResponse, response: HoppRESTResponse,
cookies: Cookie[] | null cookies: Cookie[] | null,
inheritedTestScripts: string[] = []
): Promise<E.Either<string, SandboxTestResult>> => { ): Promise<E.Either<string, SandboxTestResult>> => {
const { testScript } = request 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 // Short-circuit empty scripts to avoid unnecessary WASM initialization
if (cleanScript.trim().length === 0) { if (combinedScript.length === 0) {
return Promise.resolve( return Promise.resolve(
E.right({ E.right({
tests: { descriptor: "root", expectResults: [], children: [] }, tests: { descriptor: "root", expectResults: [], children: [] },
@ -423,20 +437,17 @@ const runPostRequestScript = (
) )
} }
if (!EXPERIMENTAL_SCRIPTING_SANDBOX.value) { if (!experimentalScriptingSandbox) {
// Strip `export {};\n` before executing in legacy sandbox to prevent syntax errors return runTestScript(combinedScript, {
return runTestScript(cleanScript, {
envs, envs,
response, response,
experimentalScriptingSandbox: false, experimentalScriptingSandbox: false,
}) })
} }
// Experimental sandbox enabled - use faraday-cage with hook
const hoppFetchHook = createHoppFetchHook(kernelInterceptorService) const hoppFetchHook = createHoppFetchHook(kernelInterceptorService)
return runTestScript(cleanScript, { return runTestScript(combinedScript, {
envs, envs,
request, request,
response, response,
@ -497,10 +508,20 @@ export function runRESTRequest$(
initialEnvsForComparison, initialEnvsForComparison,
} = captureInitialEnvironmentState() } = 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( const res = delegatePreRequestScriptRunner(
resolvedRequest, resolvedRequest,
initialEnvs, initialEnvs,
cookieJarEntries cookieJarEntries,
inheritedPreRequestScripts
).then(async (preRequestScriptResult) => { ).then(async (preRequestScriptResult) => {
if (cancelCalled) return E.left("cancellation" as const) if (cancelCalled) return E.left("cancellation" as const)
@ -579,7 +600,8 @@ export function runRESTRequest$(
statusText: res.statusText, statusText: res.statusText,
responseTime: res.meta.responseDuration, responseTime: res.meta.responseDuration,
}, },
preRequestScriptResult.right.updatedCookies ?? null preRequestScriptResult.right.updatedCookies ?? null,
inheritedTestScripts
) )
if (E.isRight(postRequestScriptResult)) { if (E.isRight(postRequestScriptResult)) {
@ -795,7 +817,9 @@ export async function runTestRunnerRequest(
request: HoppRESTRequest, request: HoppRESTRequest,
persistEnv = true, persistEnv = true,
inheritedVariables: HoppCollectionVariable[] = [], inheritedVariables: HoppCollectionVariable[] = [],
initialEnvironmentState: InitialEnvironmentState initialEnvironmentState: InitialEnvironmentState,
inheritedPreRequestScripts: string[] = [],
inheritedTestScripts: string[] = []
): Promise< ): Promise<
| E.Left<"script_fail"> | E.Left<"script_fail">
| E.Right<{ | E.Right<{
@ -824,7 +848,8 @@ export async function runTestRunnerRequest(
return delegatePreRequestScriptRunner( return delegatePreRequestScriptRunner(
request, request,
initialEnvs, initialEnvs,
cookieJarEntries cookieJarEntries,
inheritedPreRequestScripts
).then(async (preRequestScriptResult) => { ).then(async (preRequestScriptResult) => {
if (E.isLeft(preRequestScriptResult)) { if (E.isLeft(preRequestScriptResult)) {
console.error("[Pre-Request Script Error]", preRequestScriptResult.left) console.error("[Pre-Request Script Error]", preRequestScriptResult.left)
@ -883,7 +908,8 @@ export async function runTestRunnerRequest(
statusText: res.statusText, statusText: res.statusText,
responseTime: res.meta.responseDuration, responseTime: res.meta.responseDuration,
}, },
preRequestScriptResult.right.updatedCookies ?? null preRequestScriptResult.right.updatedCookies ?? null,
inheritedTestScripts
) )
if (E.isRight(postRequestScriptResult)) { if (E.isRight(postRequestScriptResult)) {

View file

@ -40,6 +40,8 @@ export type CollectionDataProps = {
headers: HoppRESTHeaders headers: HoppRESTHeaders
variables: HoppCollectionVariable[] variables: HoppCollectionVariable[]
description: string | null description: string | null
preRequestScript: string
testScript: string
} }
export const BACKEND_PAGE_SIZE = 10 export const BACKEND_PAGE_SIZE = 10
@ -121,6 +123,8 @@ const parseCollectionData = (
headers: [], headers: [],
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
} }
if (!data) { if (!data) {
@ -159,11 +163,23 @@ const parseCollectionData = (
? parsedData.description ? parsedData.description
: defaultDataProps.description : defaultDataProps.description
const preRequestScript =
typeof parsedData?.preRequestScript === "string"
? parsedData.preRequestScript
: defaultDataProps.preRequestScript
const testScript =
typeof parsedData?.testScript === "string"
? parsedData.testScript
: defaultDataProps.testScript
return { return {
auth, auth,
headers, headers,
variables, variables,
description, description,
preRequestScript,
testScript,
} }
} }
@ -171,9 +187,14 @@ const parseCollectionData = (
export const teamCollectionJSONToHoppRESTColl = ( export const teamCollectionJSONToHoppRESTColl = (
coll: TeamCollectionJSON coll: TeamCollectionJSON
): HoppCollection => { ): HoppCollection => {
const { auth, headers, variables, description } = parseCollectionData( const {
coll.data auth,
) headers,
variables,
description,
preRequestScript,
testScript,
} = parseCollectionData(coll.data)
return makeCollection({ return makeCollection({
id: coll.id, id: coll.id,
@ -184,6 +205,8 @@ export const teamCollectionJSONToHoppRESTColl = (
headers, headers,
variables, variables,
description, description,
preRequestScript,
testScript,
}) })
} }
@ -247,7 +270,14 @@ export const teamCollToHoppRESTColl = (
description: null, description: null,
} }
const { auth, headers, variables, description } = parseCollectionData(data) const {
auth,
headers,
variables,
description,
preRequestScript,
testScript,
} = parseCollectionData(data)
return makeCollection({ return makeCollection({
id: coll.id, id: coll.id,
@ -258,6 +288,8 @@ export const teamCollToHoppRESTColl = (
headers: headers ?? [], headers: headers ?? [],
variables: variables ?? [], variables: variables ?? [],
description: description ?? null, description: description ?? null,
preRequestScript: preRequestScript ?? "",
testScript: testScript ?? "",
}) })
} }

View file

@ -98,6 +98,8 @@ function parseCollectionDataFromString(data?: string): CollectionDataProps {
headers: [], headers: [],
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
} }
if (!data) { if (!data) {
@ -111,6 +113,9 @@ function parseCollectionDataFromString(data?: string): CollectionDataProps {
headers: parsed.headers || defaultDataProps.headers, headers: parsed.headers || defaultDataProps.headers,
variables: parsed.variables || defaultDataProps.variables, variables: parsed.variables || defaultDataProps.variables,
description: parsed.description || defaultDataProps.description, description: parsed.description || defaultDataProps.description,
preRequestScript:
parsed.preRequestScript || defaultDataProps.preRequestScript,
testScript: parsed.testScript || defaultDataProps.testScript,
} }
} catch (error) { } catch (error) {
console.error("Failed to parse collection data:", error) console.error("Failed to parse collection data:", error)
@ -127,8 +132,14 @@ export function collectionFolderToHoppCollection(
folder: CollectionFolder folder: CollectionFolder
): HoppCollection { ): HoppCollection {
// Parse the data field to extract auth, headers, variables, and description // Parse the data field to extract auth, headers, variables, and description
const { auth, headers, variables, description } = const {
parseCollectionDataFromString(folder.data) auth,
headers,
variables,
description,
preRequestScript,
testScript,
} = parseCollectionDataFromString(folder.data)
return makeCollection({ return makeCollection({
name: folder.name, name: folder.name,
@ -139,6 +150,8 @@ export function collectionFolderToHoppCollection(
variables, variables,
description, description,
id: folder.id, id: folder.id,
preRequestScript: preRequestScript ?? "",
testScript: testScript ?? "",
}) })
} }

View file

@ -311,6 +311,8 @@ export function transformCollectionForImport(
headers: collection.headers, headers: collection.headers,
variables: collection.variables, variables: collection.variables,
description: collection.description, description: collection.description,
preRequestScript: collection.preRequestScript ?? "",
testScript: collection.testScript ?? "",
} }
const obj: CollectionFolder = { const obj: CollectionFolder = {

View file

@ -39,6 +39,8 @@ export const harImporter = (
headers: [], headers: [],
description: null, description: null,
variables: [], variables: [],
preRequestScript: "",
testScript: "",
}) })
return E.right([collection]) return E.right([collection])

View file

@ -245,6 +245,8 @@ const getHoppFolder = (
headers: [], headers: [],
variables: getCollectionVariables(undefined, folderRes), // undefined is used to indicate no environment variables for v4 and below variables: getCollectionVariables(undefined, folderRes), // undefined is used to indicate no environment variables for v4 and below
description: folderRes.meta?.description ?? null, description: folderRes.meta?.description ?? null,
preRequestScript: "",
testScript: "",
}) })
const getHoppCollections = (docs: InsomniaDoc[]) => { const getHoppCollections = (docs: InsomniaDoc[]) => {
@ -283,6 +285,8 @@ const getParsedHoppFolder = (
headers: [], headers: [],
variables: getCollectionVariables(collection.environment), variables: getCollectionVariables(collection.environment),
description: collection.meta.description ?? null, description: collection.meta.description ?? null,
preRequestScript: "",
testScript: "",
}) })
} }
@ -323,6 +327,8 @@ const getParsedHoppCollections = (docs: InsomniaDocV5[]): HoppCollection[] =>
headers: [], headers: [],
variables: getCollectionVariables(doc.environments?.data), variables: getCollectionVariables(doc.environments?.data),
description: doc.meta.description ?? null, description: doc.meta.description ?? null,
preRequestScript: "",
testScript: "",
}) })
} }

View file

@ -1186,6 +1186,8 @@ const convertOpenApiDocsToHopp = (
auth: { authType: "inherit", authActive: true }, auth: { authType: "inherit", authActive: true },
headers: [], headers: [],
variables: [], variables: [],
preRequestScript: "",
testScript: "",
}) })
), ),
requests: requestsWithoutTags, requests: requestsWithoutTags,
@ -1193,6 +1195,8 @@ const convertOpenApiDocsToHopp = (
headers: [], headers: [],
variables: [], variables: [],
description, description,
preRequestScript: "",
testScript: "",
}) })
}) })

View file

@ -550,6 +550,36 @@ const getHoppScripts = (
return { preRequestScript, testScript } 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<Item>,
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 = ( const getCollectionDescription = (
docField?: string | DescriptionDefinition docField?: string | DescriptionDefinition
): string | null => { ): string | null => {
@ -609,8 +639,13 @@ const getHoppRequest = (
const getHoppFolder = ( const getHoppFolder = (
ig: ItemGroup<Item>, ig: ItemGroup<Item>,
importScripts: boolean importScripts: boolean
): HoppCollection => ): HoppCollection => {
makeCollection({ const { preRequestScript, testScript } = getHoppCollectionScripts(
ig,
importScripts
)
return makeCollection({
name: ig.name, name: ig.name,
folders: pipe( folders: pipe(
ig.items.all(), ig.items.all(),
@ -626,7 +661,10 @@ const getHoppFolder = (
headers: [], headers: [],
variables: getHoppCollVariables(ig), variables: getHoppCollVariables(ig),
description: getCollectionDescription(ig.description), description: getCollectionDescription(ig.description),
preRequestScript,
testScript,
}) })
}
export const getHoppCollections = ( export const getHoppCollections = (
collections: PMCollection[], collections: PMCollection[],

View file

@ -323,5 +323,9 @@ export function createExamplePetStoreCollection(
authActive: true, authActive: true,
}, },
headers: [], headers: [],
variables: [],
description: null,
preRequestScript: "",
testScript: "",
}) })
} }

View file

@ -328,6 +328,9 @@ export async function createMockCollectionForPersonal(
auth: data.auth, auth: data.auth,
headers: data.headers, headers: data.headers,
variables: data.variables, variables: data.variables,
description: null,
preRequestScript: "",
testScript: "",
}) })
// Add the backend ID to the collection // Add the backend ID to the collection

View file

@ -9,14 +9,77 @@ export const MODULE_PREFIX = "export {};\n" as const
* (non-module context) or when exporting collections. * (non-module context) or when exporting collections.
*/ */
export const stripModulePrefix = (script: string): string => { export const stripModulePrefix = (script: string): string => {
return script.startsWith(MODULE_PREFIX) if (script.startsWith(MODULE_PREFIX)) {
? script.slice(MODULE_PREFIX.length) return script.slice(MODULE_PREFIX.length)
: script }
if (script.startsWith("export {};")) {
return script.slice("export {};".length)
}
return script
} }
/** /**
* Regex for stripping the JSON-serialized module prefix (`export {};\\n`) * Anchored to JSON value-opening delimiters so it only matches inside JSON
* from scripts during collection exports. * string values during collection export, not inside script source. Matches
* Note: This matches the literal backslash-n (`\\n`), not an actual newline character. * 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
}

View file

@ -1,12 +1,9 @@
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import { BehaviorSubject, Subscription } from "rxjs" import { BehaviorSubject, Subscription } from "rxjs"
import { import { HoppCollectionVariable, translateToNewRequest } from "@hoppscotch/data"
HoppCollectionVariable,
HoppRESTAuth,
HoppRESTHeader,
translateToNewRequest,
} from "@hoppscotch/data"
import { pull, remove } from "lodash-es" import { pull, remove } from "lodash-es"
import { hasActualScript } from "~/helpers/scripting"
import { CollectionDataProps } from "~/helpers/backend/helpers"
import { Subscription as WSubscription } from "wonka" import { Subscription as WSubscription } from "wonka"
import { runGQLQuery, runGQLSubscription } from "../backend/GQLClient" import { runGQLQuery, runGQLSubscription } from "../backend/GQLClient"
import { TeamCollection } from "./TeamCollection" import { TeamCollection } from "./TeamCollection"
@ -1093,14 +1090,16 @@ export default class NewTeamCollectionAdapter {
const variables: HoppInheritedProperty["variables"] = [] 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("/") const path = folderPath.split("/")
// Check if the path is empty or invalid // Check if the path is empty or invalid
if (!path || path.length === 0) { if (!path || path.length === 0) {
console.error("Invalid path:", folderPath) 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' // 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 // Check if parentFolder is undefined or null
if (!parentFolder) { if (!parentFolder) {
console.error("Parent folder not found for path:", path) console.error("Parent folder not found for path:", path)
return { auth, headers, variables } return { auth, headers, variables, scripts }
} }
const data: { const data: Partial<CollectionDataProps> = parentFolder.data
auth: HoppRESTAuth
headers: HoppRESTHeader[]
variables: HoppCollectionVariable[]
} = parentFolder.data
? JSON.parse(parentFolder.data) ? JSON.parse(parentFolder.data)
: { : {}
auth: null,
headers: null,
variables: null,
}
if (!data.auth) { if (!data.auth) {
data.auth = { data.auth = {
@ -1141,6 +1132,8 @@ export default class NewTeamCollectionAdapter {
const parentFolderAuth = data.auth const parentFolderAuth = data.auth
const parentFolderHeaders = data.headers const parentFolderHeaders = data.headers
const parentFolderVariables = data.variables const parentFolderVariables = data.variables
const parentFolderPreRequestScript = data.preRequestScript ?? ""
const parentFolderTestScript = data.testScript ?? ""
if ( if (
parentFolderAuth?.authType === "inherit" && 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 }
} }
} }

View file

@ -1,7 +1,5 @@
import { import {
HoppCollectionVariable,
HoppRESTAuth, HoppRESTAuth,
HoppRESTHeader,
HoppRESTRequest, HoppRESTRequest,
getDefaultRESTRequest, getDefaultRESTRequest,
} from "@hoppscotch/data" } from "@hoppscotch/data"
@ -10,6 +8,7 @@ import { Service } from "dioc"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import { Ref, ref } from "vue" import { Ref, ref } from "vue"
import { getSingleCollection, TeamCollection } from "./TeamCollection" import { getSingleCollection, TeamCollection } from "./TeamCollection"
import { hasActualScript } from "~/helpers/scripting"
import { platform } from "~/platform" import { platform } from "~/platform"
import { HoppInheritedProperty } from "../types/HoppInheritedProperties" import { HoppInheritedProperty } from "../types/HoppInheritedProperties"
@ -19,6 +18,24 @@ import {
TeamRequest, TeamRequest,
getCollectionChildCollections, getCollectionChildCollections,
} from "./TeamRequest" } 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 = { type CollectionSearchMeta = {
isSearchResult?: boolean isSearchResult?: boolean
@ -354,6 +371,8 @@ export class TeamSearchService extends Service {
const defaultInheritedVariables: HoppInheritedProperty["variables"] = [] const defaultInheritedVariables: HoppInheritedProperty["variables"] = []
const defaultInheritedScripts: HoppInheritedProperty["scripts"] = []
const collection = Object.values(this.searchResultsCollections).find( const collection = Object.values(this.searchResultsCollections).find(
(col) => col.id === collectionID (col) => col.id === collectionID
) )
@ -363,12 +382,14 @@ export class TeamSearchService extends Service {
auth: defaultInheritedAuth, auth: defaultInheritedAuth,
headers: defaultInheritedHeaders, headers: defaultInheritedHeaders,
variables: defaultInheritedVariables, variables: defaultInheritedVariables,
scripts: defaultInheritedScripts,
} }
const inheritedAuthData = this.findInheritableParentAuth(collectionID) const inheritedAuthData = this.findInheritableParentAuth(collectionID)
const inheritedHeadersData = this.findInheritableParentHeaders(collectionID) const inheritedHeadersData = this.findInheritableParentHeaders(collectionID)
const inheritedVariablesData = const inheritedVariablesData =
this.findInheritableParentVariables(collectionID) this.findInheritableParentVariables(collectionID)
const inheritedScriptsData = this.findInheritableParentScripts(collectionID)
return { return {
auth: E.isRight(inheritedAuthData) auth: E.isRight(inheritedAuthData)
@ -380,6 +401,9 @@ export class TeamSearchService extends Service {
variables: E.isRight(inheritedVariablesData) variables: E.isRight(inheritedVariablesData)
? Object.values(inheritedVariablesData.right) ? Object.values(inheritedVariablesData.right)
: defaultInheritedVariables, : defaultInheritedVariables,
scripts: E.isRight(inheritedScriptsData)
? Object.values(inheritedScriptsData.right)
: defaultInheritedScripts,
} }
} }
@ -403,13 +427,9 @@ export class TeamSearchService extends Service {
// has inherited data // has inherited data
if (collection.data) { if (collection.data) {
const parentInheritedData = JSON.parse(collection.data) as { const parentInheritedData = parseCollectionData(collection.data)
auth?: HoppRESTAuth
headers?: HoppRESTHeader[]
variables?: HoppCollectionVariable[]
}
const inheritedAuth = parentInheritedData.auth const inheritedAuth = parentInheritedData?.auth
if (inheritedAuth && inheritedAuth.authType !== "inherit") { if (inheritedAuth && inheritedAuth.authType !== "inherit") {
return E.right({ 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 // see if it has headers to inherit, if yes, add it to the existing headers
if (collection.data) { if (collection.data) {
const parentInheritedData = JSON.parse(collection.data) as { const parentInheritedData = parseCollectionData(collection.data)
auth?: HoppRESTAuth
headers?: HoppRESTHeader[]
variables?: HoppCollectionVariable[]
}
const inheritedHeaders = parentInheritedData.headers const inheritedHeaders = parentInheritedData?.headers
if (inheritedHeaders) { if (inheritedHeaders) {
inheritedHeaders.forEach((header) => { inheritedHeaders.forEach((header) => {
@ -493,13 +509,9 @@ export class TeamSearchService extends Service {
} }
if (collection.data) { if (collection.data) {
const parentData = JSON.parse(collection.data) as { const parentData = parseCollectionData(collection.data)
auth?: HoppRESTAuth
headers?: HoppRESTHeader[]
variables?: HoppCollectionVariable[]
}
const variables = parentData.variables const variables = parentData?.variables
if (variables) { if (variables) {
vars.push({ vars.push({
@ -517,6 +529,51 @@ export class TeamSearchService extends Service {
return E.right(vars) return E.right(vars)
} }
findInheritableParentScripts = (
collectionID: string,
existingScripts: HoppInheritedProperty["scripts"] = []
): E.Either<string, HoppInheritedProperty["scripts"]> => {
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) => { expandCollection = async (collectionID: string) => {
if (this.expandingCollections.value.includes(collectionID)) return if (this.expandingCollections.value.includes(collectionID)) return

View file

@ -23,4 +23,10 @@ export type HoppInheritedProperty = {
parentName: string parentName: string
inheritedVariables: HoppCollectionVariable[] inheritedVariables: HoppCollectionVariable[]
}[] }[]
scripts: {
parentID: string
parentName: string
preRequestScript: string
testScript: string
}[]
} }

View file

@ -11,6 +11,7 @@ import {
GQLHeader, GQLHeader,
} from "@hoppscotch/data" } from "@hoppscotch/data"
import { cloneDeep } from "lodash-es" import { cloneDeep } from "lodash-es"
import { hasActualScript } from "~/helpers/scripting"
import { pluck } from "rxjs/operators" import { pluck } from "rxjs/operators"
import { resolveSaveContextOnRequestReorder } from "~/helpers/collection/request" import { resolveSaveContextOnRequestReorder } from "~/helpers/collection/request"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
@ -38,6 +39,8 @@ const defaultRESTCollectionState = {
headers: [], headers: [],
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
}), }),
], ],
} }
@ -55,6 +58,8 @@ const defaultGraphqlCollectionState = {
headers: [], headers: [],
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
}), }),
], ],
} }
@ -142,14 +147,16 @@ export function cascadeParentCollectionForProperties(
const variables: HoppInheritedProperty["variables"] = [] 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)) const path = folderPath.split("/").map((i) => parseInt(i))
// Check if the path is empty or invalid // Check if the path is empty or invalid
if (!path || path.length === 0) { if (!path || path.length === 0) {
console.error("Invalid path:", folderPath) 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' // 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 // Check if parentFolder is undefined or null
if (!parentFolder) { if (!parentFolder) {
console.error("Parent folder not found for path:", path) 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 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) { function reorderItems(array: unknown[], from: number, to: number) {
@ -365,6 +390,8 @@ const restCollectionDispatchers = defineDispatchers({
headers: [], headers: [],
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
}) })
const newState = state const newState = state
@ -1026,6 +1053,8 @@ const gqlCollectionDispatchers = defineDispatchers({
headers: [], headers: [],
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
}) })
const newState = state const newState = state
const indexPaths = path.split("/").map((x) => parseInt(x)) const indexPaths = path.split("/").map((x) => parseInt(x))
@ -1367,18 +1396,26 @@ export function getRESTCollection(collectionIndex: number) {
return restCollectionStore.value.state[collectionIndex] 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( function computeCollectionInheritedProps(
collection: HoppCollection, collection: HoppCollection,
ref_id: string, ref_id: string,
type: "my-collections" | "team-collections" = "my-collections", type: "my-collections" | "team-collections" = "my-collections",
parentAuth: HoppRESTAuth | null = null, parentAuth: HoppRESTAuth | null = null,
parentHeaders: HoppRESTHeaders | null = null, parentHeaders: HoppRESTHeaders | null = null,
parentVariables: HoppCollectionVariable[] | null = null parentVariables: HoppCollectionVariable[] | null = null,
): { parentPreRequestScripts: string[] = [],
auth: HoppRESTAuth parentTestScripts: string[] = []
headers: HoppRESTHeaders ): RESTCollectionInheritedProps | null {
variables: HoppCollectionVariable[]
} | null {
// Determine the inherited authentication and headers // Determine the inherited authentication and headers
const inheritedAuth = const inheritedAuth =
collection.auth?.authType === "inherit" && collection.auth.authActive collection.auth?.authType === "inherit" && collection.auth.authActive
@ -1406,9 +1443,19 @@ function computeCollectionInheritedProps(
auth: inheritedAuth, auth: inheritedAuth,
headers: inheritedHeaders, headers: inheritedHeaders,
variables: inheritedVariables, 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 // Recursively search in folders
for (const folder of collection.folders) { for (const folder of collection.folders) {
const result = computeCollectionInheritedProps( const result = computeCollectionInheritedProps(
@ -1417,7 +1464,9 @@ function computeCollectionInheritedProps(
type, type,
inheritedAuth, inheritedAuth,
inheritedHeaders, inheritedHeaders,
inheritedVariables inheritedVariables,
nextPreRequestScripts,
nextTestScripts
) )
if (result) return result // Return as soon as a match is found if (result) return result // Return as soon as a match is found
} }
@ -1429,11 +1478,7 @@ export function getRESTCollectionInheritedProps(
collectionID: string, collectionID: string,
collections: HoppCollection[] = restCollectionStore.value.state, collections: HoppCollection[] = restCollectionStore.value.state,
type: "my-collections" | "team-collections" = "my-collections" type: "my-collections" | "team-collections" = "my-collections"
): { ): RESTCollectionInheritedProps | null {
auth: HoppRESTAuth
headers: HoppRESTHeaders
variables: HoppCollectionVariable[]
} | null {
for (const collection of collections) { for (const collection of collections) {
const result = computeCollectionInheritedProps( const result = computeCollectionInheritedProps(
collection, collection,

View file

@ -53,6 +53,7 @@ import {
translateToNewEnvironmentVariables, translateToNewEnvironmentVariables,
} from "@hoppscotch/data" } from "@hoppscotch/data"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { hasActualScript } from "~/helpers/scripting"
import { import {
PublishedDocREST, PublishedDocREST,
PublishedDocsVersion, 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) { if (collection.folders && collection.folders.length > 0) {

View file

@ -40,6 +40,8 @@ describe("DocumentationService", () => {
variables: [], variables: [],
id: "collection-123", id: "collection-123",
description: null, description: null,
preRequestScript: "",
testScript: "",
}) })
const mockRequest: HoppRESTRequest = makeRESTRequest({ const mockRequest: HoppRESTRequest = makeRESTRequest({

View file

@ -1,4 +1,5 @@
import { import {
CollectionSchemaVersion,
Environment, Environment,
GlobalEnvironment, GlobalEnvironment,
HoppCollection, HoppCollection,
@ -25,7 +26,7 @@ const DEFAULT_SETTINGS = getDefaultSettings()
export const REST_COLLECTIONS_MOCK: HoppCollection[] = [ export const REST_COLLECTIONS_MOCK: HoppCollection[] = [
{ {
v: 11, v: CollectionSchemaVersion,
name: "Echo", name: "Echo",
requests: [ requests: [
{ {
@ -54,13 +55,15 @@ export const REST_COLLECTIONS_MOCK: HoppCollection[] = [
headers: [], headers: [],
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
folders: [], folders: [],
}, },
] ]
export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [ export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [
{ {
v: 11, v: CollectionSchemaVersion,
name: "Echo", name: "Echo",
requests: [ requests: [
{ {
@ -80,6 +83,8 @@ export const GQL_COLLECTIONS_MOCK: HoppCollection[] = [
headers: [], headers: [],
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
folders: [], folders: [],
}, },
] ]

View file

@ -325,6 +325,16 @@ const HoppInheritedPropertySchema = z
}) })
) )
.catch([]), .catch([]),
scripts: z
.array(
z.object({
parentID: z.string(),
parentName: z.string(),
preRequestScript: z.string(),
testScript: z.string(),
})
)
.catch([]),
}) })
.strict() .strict()

View file

@ -1,11 +1,6 @@
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import { Subscription } from "rxjs" import { Subscription } from "rxjs"
import { import { HoppCollectionVariable, translateToNewRequest } from "@hoppscotch/data"
HoppCollectionVariable,
HoppRESTAuth,
HoppRESTHeader,
translateToNewRequest,
} from "@hoppscotch/data"
import { pull, remove } from "lodash-es" import { pull, remove } from "lodash-es"
import { Subscription as WSubscription } from "wonka" import { Subscription as WSubscription } from "wonka"
import { import {
@ -34,6 +29,8 @@ import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { ref, watch } from "vue" import { ref, watch } from "vue"
import { Service } from "dioc" import { Service } from "dioc"
import { updateInheritedPropertiesForAffectedRequests } from "~/helpers/collection/collection" import { updateInheritedPropertiesForAffectedRequests } from "~/helpers/collection/collection"
import { hasActualScript } from "~/helpers/scripting"
import { CollectionDataProps } from "~/helpers/backend/helpers"
export const TEAMS_BACKEND_PAGE_SIZE = 10 export const TEAMS_BACKEND_PAGE_SIZE = 10
@ -1123,14 +1120,16 @@ export class TeamCollectionsService extends Service<void> {
const variables: HoppInheritedProperty["variables"] = [] 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("/") const path = folderPath.split("/")
// Check if the path is empty or invalid // Check if the path is empty or invalid
if (!path || path.length === 0) { if (!path || path.length === 0) {
console.error("Invalid path:", folderPath) 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' // Loop through the path and get the last parent folder with authType other than 'inherit'
@ -1140,20 +1139,12 @@ export class TeamCollectionsService extends Service<void> {
// Check if parentFolder is undefined or null // Check if parentFolder is undefined or null
if (!parentFolder) { if (!parentFolder) {
console.error("Parent folder not found for path:", path) console.error("Parent folder not found for path:", path)
return { auth, headers, variables } return { auth, headers, variables, scripts }
} }
const data: { const data: Partial<CollectionDataProps> = parentFolder.data
auth?: HoppRESTAuth
headers?: HoppRESTHeader[]
variables?: HoppCollectionVariable[]
} = parentFolder.data
? JSON.parse(parentFolder.data) ? JSON.parse(parentFolder.data)
: { : {}
auth: null,
headers: null,
variables: null,
}
if (!data.auth) { if (!data.auth) {
data.auth = { data.auth = {
@ -1230,9 +1221,27 @@ export class TeamCollectionsService extends Service<void> {
), ),
}) })
} }
// 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) { private async waitForCollectionLoading(collectionID: string) {
@ -1260,6 +1269,7 @@ export class TeamCollectionsService extends Service<void> {
}, },
headers: [], headers: [],
variables: [], variables: [],
scripts: [],
} }
const path = folderPath.split("/") const path = folderPath.split("/")
@ -1278,6 +1288,7 @@ export class TeamCollectionsService extends Service<void> {
}, },
headers: [], headers: [],
variables: [], variables: [],
scripts: [],
} }
} }

View file

@ -5,6 +5,7 @@ import {
HoppRESTRequest, HoppRESTRequest,
} from "@hoppscotch/data" } from "@hoppscotch/data"
import { Service } from "dioc" import { Service } from "dioc"
import { hasActualScript } from "~/helpers/scripting"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import { cloneDeep } from "lodash-es" import { cloneDeep } from "lodash-es"
import { nextTick, Ref } from "vue" import { nextTick, Ref } from "vue"
@ -52,7 +53,9 @@ export class TestRunnerService extends Service {
public runTests( public runTests(
tab: Ref<HoppTab<HoppTestRunnerDocument>>, tab: Ref<HoppTab<HoppTestRunnerDocument>>,
collection: HoppCollection, collection: HoppCollection,
options: TestRunnerOptions options: TestRunnerOptions,
ancestorPreRequestScripts: string[] = [],
ancestorTestScripts: string[] = []
) { ) {
// Reset the result collection // Reset the result collection
tab.value.document.status = "running" tab.value.document.status = "running"
@ -66,9 +69,22 @@ export class TestRunnerService extends Service {
requests: [], requests: [],
variables: [], variables: [],
description: collection.description ?? null, 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(() => { .then(() => {
tab.value.document.status = "stopped" tab.value.document.status = "stopped"
}) })
@ -96,7 +112,9 @@ export class TestRunnerService extends Service {
parentHeaders?: HoppRESTHeaders, parentHeaders?: HoppRESTHeaders,
parentAuth?: HoppRESTRequest["auth"], parentAuth?: HoppRESTRequest["auth"],
parentVariables: HoppCollection["variables"] = [], parentVariables: HoppCollection["variables"] = [],
parentID?: string parentID?: string,
parentPreRequestScripts: string[] = [],
parentTestScripts: string[] = []
) { ) {
try { try {
// Compute inherited auth and headers for this collection // 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 // Process folders progressively
for (let i = 0; i < collection.folders.length; i++) { for (let i = 0; i < collection.folders.length; i++) {
if (options.stopRef?.value) { if (options.stopRef?.value) {
@ -142,7 +173,6 @@ export class TestRunnerService extends Service {
} }
) )
// Pass inherited headers and auth to the folder
await this.runTestCollection( await this.runTestCollection(
tab, tab,
folder, folder,
@ -151,7 +181,9 @@ export class TestRunnerService extends Service {
inheritedHeaders, inheritedHeaders,
inheritedAuth, inheritedAuth,
inheritedVariables, inheritedVariables,
collection._ref_id || collection.id collection._ref_id || collection.id,
inheritedPreRequestScripts,
inheritedTestScripts
) )
} }
@ -188,7 +220,9 @@ export class TestRunnerService extends Service {
collection, collection,
options, options,
currentPath, currentPath,
inheritedVariables inheritedVariables,
inheritedPreRequestScripts,
inheritedTestScripts
) )
if (options.delay && options.delay > 0) { if (options.delay && options.delay > 0) {
@ -279,7 +313,9 @@ export class TestRunnerService extends Service {
collection: HoppCollection, collection: HoppCollection,
options: TestRunnerOptions, options: TestRunnerOptions,
path: number[], path: number[],
inheritedVariables: HoppCollectionVariable[] = [] inheritedVariables: HoppCollectionVariable[] = [],
inheritedPreRequestScripts: string[] = [],
inheritedTestScripts: string[] = []
) { ) {
if (options.stopRef?.value) { if (options.stopRef?.value) {
throw new Error("Test execution stopped") throw new Error("Test execution stopped")
@ -305,7 +341,9 @@ export class TestRunnerService extends Service {
request, request,
options.keepVariableValues, options.keepVariableValues,
inheritedVariables, inheritedVariables,
initialEnvironmentState initialEnvironmentState,
inheritedPreRequestScripts,
inheritedTestScripts
) )
if (options.stopRef?.value) { if (options.stopRef?.value) {

View file

@ -11,6 +11,7 @@ import V8_VERSION from "./v/8"
import V9_VERSION from "./v/9" import V9_VERSION from "./v/9"
import V10_VERSION from "./v/10" import V10_VERSION from "./v/10"
import V11_VERSION from "./v/11" import V11_VERSION from "./v/11"
import V12_VERSION from "./v/12"
export { CollectionVariable } from "./v/10" export { CollectionVariable } from "./v/10"
@ -24,7 +25,7 @@ const versionedObject = z.object({
}) })
export const HoppCollection = createVersionedEntity({ export const HoppCollection = createVersionedEntity({
latestVersion: 11, latestVersion: 12,
versionMap: { versionMap: {
1: V1_VERSION, 1: V1_VERSION,
2: V2_VERSION, 2: V2_VERSION,
@ -37,6 +38,7 @@ export const HoppCollection = createVersionedEntity({
9: V9_VERSION, 9: V9_VERSION,
10: V10_VERSION, 10: V10_VERSION,
11: V11_VERSION, 11: V11_VERSION,
12: V12_VERSION,
}, },
getVersion(data) { getVersion(data) {
const versionCheck = versionedObject.safeParse(data) const versionCheck = versionedObject.safeParse(data)
@ -56,7 +58,7 @@ export type HoppCollectionVariable = InferredEntity<
typeof HoppCollection typeof HoppCollection
>["variables"][number] >["variables"][number]
export const CollectionSchemaVersion = 11 export const CollectionSchemaVersion = 12
/** /**
* Generates a Collection object. This ignores the version number object * 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 description = x.description ?? null
const preRequestScript = x.preRequestScript ?? ""
const testScript = x.testScript ?? ""
const obj = makeCollection({ const obj = makeCollection({
name, name,
folders, folders,
@ -96,6 +101,8 @@ export function translateToNewRESTCollection(x: any): HoppCollection {
headers, headers,
variables, variables,
description, description,
preRequestScript,
testScript,
}) })
if (x.id) obj.id = x.id if (x.id) obj.id = x.id
@ -123,6 +130,9 @@ export function translateToNewGQLCollection(x: any): HoppCollection {
const description = x.description ?? null const description = x.description ?? null
const preRequestScript = x.preRequestScript ?? ""
const testScript = x.testScript ?? ""
const obj = makeCollection({ const obj = makeCollection({
name, name,
folders, folders,
@ -131,6 +141,8 @@ export function translateToNewGQLCollection(x: any): HoppCollection {
headers, headers,
variables, variables,
description, description,
preRequestScript,
testScript,
}) })
if (x.id) obj.id = x.id if (x.id) obj.id = x.id

View file

@ -2,7 +2,7 @@ import { defineVersion, entityRefUptoVersion } from "verzod"
import { z } from "zod" import { z } from "zod"
import { HoppCollection } from ".." import { HoppCollection } from ".."
import { v9_baseCollectionSchema } from "./9" import { v9_baseCollectionSchema, V9_SCHEMA } from "./9"
export const CollectionVariable = z.object({ export const CollectionVariable = z.object({
key: z.string(), key: z.string(),
@ -33,7 +33,7 @@ export const V10_SCHEMA = v10_baseCollectionSchema.extend({
export default defineVersion({ export default defineVersion({
initial: false, initial: false,
schema: V10_SCHEMA, schema: V10_SCHEMA,
up(old: z.infer<typeof V10_SCHEMA>) { up(old: z.infer<typeof V9_SCHEMA>) {
const result: z.infer<typeof V10_SCHEMA> = { const result: z.infer<typeof V10_SCHEMA> = {
...old, ...old,
v: 10 as const, v: 10 as const,

View file

@ -2,7 +2,7 @@ import { defineVersion, entityRefUptoVersion } from "verzod"
import { z } from "zod" import { z } from "zod"
import { HoppCollection } from ".." import { HoppCollection } from ".."
import { v10_baseCollectionSchema } from "./10" import { v10_baseCollectionSchema, V10_SCHEMA } from "./10"
export const v11_baseCollectionSchema = v10_baseCollectionSchema.extend({ export const v11_baseCollectionSchema = v10_baseCollectionSchema.extend({
v: z.literal(11), v: z.literal(11),
@ -24,11 +24,11 @@ export const V11_SCHEMA = v11_baseCollectionSchema.extend({
export default defineVersion({ export default defineVersion({
initial: false, initial: false,
schema: V11_SCHEMA, schema: V11_SCHEMA,
up(old: z.infer<typeof V11_SCHEMA>) { up(old: z.infer<typeof V10_SCHEMA>) {
const result: z.infer<typeof V11_SCHEMA> = { const result: z.infer<typeof V11_SCHEMA> = {
...old, ...old,
v: 11 as const, v: 11 as const,
description: old.description ?? null, description: null,
folders: old.folders.map((folder) => { folders: old.folders.map((folder) => {
const result = HoppCollection.safeParseUpToVersion(folder, 11) const result = HoppCollection.safeParseUpToVersion(folder, 11)

View file

@ -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<typeof v12_baseCollectionSchema> & {
folders: Input[]
}
type Output = z.output<typeof v12_baseCollectionSchema> & {
folders: Output[]
}
export const V12_SCHEMA = v12_baseCollectionSchema.extend({
folders: z.lazy(() => z.array(entityRefUptoVersion(HoppCollection, 12))),
}) as z.ZodType<Output, z.ZodTypeDef, Input>
export default defineVersion({
initial: false,
schema: V12_SCHEMA,
up(old: z.infer<typeof V11_SCHEMA>) {
const result: z.infer<typeof V12_SCHEMA> = {
...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
},
})

View file

@ -53,7 +53,7 @@ export const V6_SCHEMA = V5_SCHEMA.extend({
export default defineVersion({ export default defineVersion({
schema: V6_SCHEMA, schema: V6_SCHEMA,
initial: false, initial: false,
up(old: z.infer<typeof V6_SCHEMA>) { up(old: z.infer<typeof V5_SCHEMA>) {
const headers = old.headers.map((header) => { const headers = old.headers.map((header) => {
return { return {
...header, ...header,

View file

@ -11,11 +11,11 @@ export const V16_SCHEMA = V15_SCHEMA.extend({
const V16_VERSION = defineVersion({ const V16_VERSION = defineVersion({
schema: V16_SCHEMA, schema: V16_SCHEMA,
initial: false, initial: false,
up(old: z.infer<typeof V16_SCHEMA>) { up(old: z.infer<typeof V15_SCHEMA>) {
return { return {
...old, ...old,
v: "16" as const, v: "16" as const,
_ref_id: old._ref_id ?? generateUniqueRefId("req"), _ref_id: generateUniqueRefId("req"),
} }
}, },
}) })

View file

@ -10,11 +10,11 @@ export const V17_SCHEMA = V16_SCHEMA.extend({
const V17_VERSION = defineVersion({ const V17_VERSION = defineVersion({
schema: V17_SCHEMA, schema: V17_SCHEMA,
initial: false, initial: false,
up(old: z.infer<typeof V17_SCHEMA>) { up(old: z.infer<typeof V16_SCHEMA>) {
return { return {
...old, ...old,
v: "17" as const, v: "17" as const,
description: old.description ?? null, description: null,
} }
}, },
}) })

View file

@ -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")
}
})
})

View file

@ -3,6 +3,27 @@
// Keep strict mode scoped to this IIFE to avoid leaking strictness to concatenated/bootstrapped code // Keep strict mode scoped to this IIFE to avoid leaking strictness to concatenated/bootstrapped code
"use strict" "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 // Sequential test execution promise chain
// Initialize with a resolved promise to start the chain // Initialize with a resolved promise to start the chain
// Store on globalThis so pm.sendRequest() and test() can both access and modify it // Store on globalThis so pm.sendRequest() and test() can both access and modify it

View file

@ -2,6 +2,28 @@
;(inputs) => { ;(inputs) => {
// Keep strict mode scoped to this IIFE to avoid leaking strictness to concatenated/bootstrapped code // Keep strict mode scoped to this IIFE to avoid leaking strictness to concatenated/bootstrapped code
"use strict" "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 = { globalThis.pw = {
env: { env: {
get: (key) => convertMarkerToValue(inputs.envGet(key)), get: (key) => convertMarkerToValue(inputs.envGet(key)),

View file

@ -386,7 +386,11 @@ const createScriptingModule = (
type: ModuleType, type: ModuleType,
bootstrapCode: string, bootstrapCode: string,
config: ModuleConfig, config: ModuleConfig,
captureHook?: { capture?: () => void; bootstrapError?: unknown } captureHook?: {
capture?: () => void
bootstrapError?: unknown
scriptExecutionError?: { name: string; message: string; stack: string }
}
) => { ) => {
return defineCageModule((ctx) => { return defineCageModule((ctx) => {
// Track test promises for keepAlive (only for post-request scripts) // 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<string, unknown>).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 sandboxInputsObj = defineSandboxObject(ctx, inputsObj)
const bootstrapResult = ctx.vm.callFunction( const bootstrapResult = ctx.vm.callFunction(

View file

@ -23,7 +23,11 @@ const executePreRequestOnCage = async (
let finalRequest = request let finalRequest = request
let finalCookies = cookies 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, [ const result = await cage.runCode(preRequestScript, [
...defaultModules({ ...defaultModules({
@ -72,6 +76,16 @@ const executePreRequestOnCage = async (
return E.left(`Script execution failed: ${String(result.err)}`) 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) { if (captureHook.capture) {
captureHook.capture() captureHook.capture()
} }

View file

@ -32,7 +32,11 @@ const executeTestOnCage = async (
let finalTestResults = testRunStack let finalTestResults = testRunStack
const testPromises: Promise<void>[] = [] const testPromises: Promise<void>[] = []
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, [ const result = await cage.runCode(testScript, [
...defaultModules({ ...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) { if (captureHook.capture) {
captureHook.capture() captureHook.capture()
} }

View file

@ -53,7 +53,11 @@ const executePreRequestOnCage = async (
let finalRequest = request let finalRequest = request
let finalCookies = cookies 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, [ const result = await cage.runCode(preRequestScript, [
...defaultModules({ ...defaultModules({
@ -103,6 +107,16 @@ const executePreRequestOnCage = async (
return E.left(`Script execution failed: ${String(result.err)}`) 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) { if (captureHook.capture) {
captureHook.capture() captureHook.capture()
} }

View file

@ -62,7 +62,11 @@ const executeTestOnCage = async (
let finalCookies = cookies let finalCookies = cookies
const testPromises: Promise<void>[] = [] const testPromises: Promise<void>[] = []
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, [ const result = await cage.runCode(testScript, [
...defaultModules({ ...defaultModules({
@ -122,6 +126,16 @@ const executeTestOnCage = async (
await Promise.all(testPromises) 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) { if (captureHook.capture) {
captureHook.capture() captureHook.capture()
} }

View file

@ -45,6 +45,9 @@ const transformCollectionForBackend = (collection: HoppCollection): any => {
headers: collection.headers ?? [], headers: collection.headers ?? [],
variables: collection.variables ?? [], variables: collection.variables ?? [],
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null,
preRequestScript: collection.preRequestScript ?? "",
testScript: collection.testScript ?? "",
} }
return { return {
@ -79,6 +82,9 @@ const recursivelySyncCollections = async (
headers: collection.headers ?? [], headers: collection.headers ?? [],
variables: collection.variables ?? [], variables: collection.variables ?? [],
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null,
preRequestScript: collection.preRequestScript ?? "",
testScript: collection.testScript ?? "",
} }
const res = await createGQLRootUserCollection( const res = await createGQLRootUserCollection(
collection.name, collection.name,
@ -98,6 +104,9 @@ const recursivelySyncCollections = async (
headers: [], headers: [],
variables: [], variables: [],
_ref_id: collection._ref_id ?? generateUniqueRefId("coll"), _ref_id: collection._ref_id ?? generateUniqueRefId("coll"),
description: null,
preRequestScript: "",
testScript: "",
} }
collection.id = parentCollectionID collection.id = parentCollectionID
@ -105,6 +114,9 @@ const recursivelySyncCollections = async (
collection.headers = returnedData.headers collection.headers = returnedData.headers
collection.variables = returnedData.variables collection.variables = returnedData.variables
collection._ref_id = returnedData._ref_id ?? generateUniqueRefId("coll") collection._ref_id = returnedData._ref_id ?? generateUniqueRefId("coll")
collection.description = returnedData.description ?? null
collection.preRequestScript = returnedData.preRequestScript ?? ""
collection.testScript = returnedData.testScript ?? ""
removeDuplicateGraphqlCollectionOrFolder( removeDuplicateGraphqlCollectionOrFolder(
parentCollectionID, parentCollectionID,
@ -124,6 +136,9 @@ const recursivelySyncCollections = async (
headers: collection.headers ?? [], headers: collection.headers ?? [],
variables: collection.variables ?? [], variables: collection.variables ?? [],
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null,
preRequestScript: collection.preRequestScript ?? "",
testScript: collection.testScript ?? "",
} }
const res = await createGQLChildUserCollection( const res = await createGQLChildUserCollection(
@ -145,6 +160,9 @@ const recursivelySyncCollections = async (
headers: [], headers: [],
variables: [], variables: [],
_ref_id: collection._ref_id ?? generateUniqueRefId("coll"), _ref_id: collection._ref_id ?? generateUniqueRefId("coll"),
description: null,
preRequestScript: "",
testScript: "",
} }
collection.id = childCollectionId collection.id = childCollectionId
@ -153,6 +171,9 @@ const recursivelySyncCollections = async (
parentCollectionID = childCollectionId parentCollectionID = childCollectionId
collection.variables = returnedData.variables collection.variables = returnedData.variables
collection._ref_id = returnedData._ref_id ?? generateUniqueRefId("coll") collection._ref_id = returnedData._ref_id ?? generateUniqueRefId("coll")
collection.description = returnedData.description ?? null
collection.preRequestScript = returnedData.preRequestScript ?? ""
collection.testScript = returnedData.testScript ?? ""
removeDuplicateGraphqlCollectionOrFolder( removeDuplicateGraphqlCollectionOrFolder(
childCollectionId, childCollectionId,
@ -268,6 +289,9 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
headers: collection.headers, headers: collection.headers,
variables: collection.variables, variables: collection.variables,
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null,
preRequestScript: collection.preRequestScript ?? "",
testScript: collection.testScript ?? "",
} }
if (collectionID) { if (collectionID) {
@ -314,6 +338,9 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
headers: folder.headers, headers: folder.headers,
variables: folder.variables, variables: folder.variables,
_ref_id: folder._ref_id, _ref_id: folder._ref_id,
description: folder.description ?? null,
preRequestScript: folder.preRequestScript ?? "",
testScript: folder.testScript ?? "",
} }
if (folderBackendId) { if (folderBackendId) {

View file

@ -141,12 +141,14 @@ export function exportedCollectionToHoppCollection(
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
} }
return { return {
id: restCollection.id, id: restCollection.id,
_ref_id: data._ref_id ?? generateUniqueRefId("coll"), _ref_id: data._ref_id ?? generateUniqueRefId("coll"),
v: 11, v: 12,
name: restCollection.name, name: restCollection.name,
folders: restCollection.folders.map((folder) => folders: restCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType) exportedCollectionToHoppCollection(folder, collectionType)
@ -200,6 +202,8 @@ export function exportedCollectionToHoppCollection(
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
description: data.description ?? null, description: data.description ?? null,
preRequestScript: data.preRequestScript ?? "",
testScript: data.testScript ?? "",
} }
} else { } else {
const gqlCollection = collection as ExportedUserCollectionGQL const gqlCollection = collection as ExportedUserCollectionGQL
@ -213,12 +217,14 @@ export function exportedCollectionToHoppCollection(
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
} }
return { return {
id: gqlCollection.id, id: gqlCollection.id,
_ref_id: data._ref_id ?? generateUniqueRefId("coll"), _ref_id: data._ref_id ?? generateUniqueRefId("coll"),
v: 11, v: 12,
name: gqlCollection.name, name: gqlCollection.name,
folders: gqlCollection.folders.map((folder) => folders: gqlCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType) exportedCollectionToHoppCollection(folder, collectionType)
@ -248,6 +254,8 @@ export function exportedCollectionToHoppCollection(
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
description: data.description ?? null, description: data.description ?? null,
preRequestScript: data.preRequestScript ?? "",
testScript: data.testScript ?? "",
} }
} }
} }
@ -398,6 +406,9 @@ function setupUserCollectionCreatedSubscription() {
headers: [], headers: [],
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
variables: [], variables: [],
description: null,
preRequestScript: "",
testScript: "",
} }
runDispatchWithOutSyncing(() => { runDispatchWithOutSyncing(() => {
@ -406,23 +417,27 @@ function setupUserCollectionCreatedSubscription() {
name: res.right.userCollectionCreated.title, name: res.right.userCollectionCreated.title,
folders: [], folders: [],
requests: [], requests: [],
v: 11, v: 12,
_ref_id: data._ref_id, _ref_id: data._ref_id,
auth: data.auth, auth: data.auth,
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
description: data.description ?? null, description: data.description ?? null,
preRequestScript: data.preRequestScript ?? "",
testScript: data.testScript ?? "",
}) })
: addRESTCollection({ : addRESTCollection({
name: res.right.userCollectionCreated.title, name: res.right.userCollectionCreated.title,
folders: [], folders: [],
requests: [], requests: [],
v: 11, v: 12,
_ref_id: data._ref_id, _ref_id: data._ref_id,
auth: data.auth, auth: data.auth,
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
description: data.description ?? null, description: data.description ?? null,
preRequestScript: data.preRequestScript ?? "",
testScript: data.testScript ?? "",
}) })
const localIndex = collectionStore.value.state.length - 1 const localIndex = collectionStore.value.state.length - 1
@ -625,7 +640,14 @@ function setupUserCollectionDuplicatedSubscription() {
) )
// Incoming data transformed to the respective internal representations // Incoming data transformed to the respective internal representations
const { auth, headers, variables, description } = const {
auth,
headers,
variables,
description,
preRequestScript,
testScript,
} =
data && data != "null" data && data != "null"
? JSON.parse(data) ? JSON.parse(data)
: { : {
@ -633,6 +655,8 @@ function setupUserCollectionDuplicatedSubscription() {
headers: [], headers: [],
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
} }
// Duplicated collection will have a unique ref id // Duplicated collection will have a unique ref id
const _ref_id = generateUniqueRefId("coll") const _ref_id = generateUniqueRefId("coll")
@ -649,12 +673,14 @@ function setupUserCollectionDuplicatedSubscription() {
name, name,
folders, folders,
requests, requests,
v: 11, v: 12,
_ref_id, _ref_id,
auth, auth,
headers: addDescriptionField(headers), headers: addDescriptionField(headers),
variables: variables ?? [], variables: variables ?? [],
description: description ?? null, description: description ?? null,
preRequestScript: preRequestScript ?? "",
testScript: testScript ?? "",
} }
// only folders will have parent collection id // only folders will have parent collection id
@ -1122,7 +1148,14 @@ function transformDuplicatedCollections(
requests: userRequests, requests: userRequests,
title: name, title: name,
}) => { }) => {
const { auth, headers, variables, description } = const {
auth,
headers,
variables,
description,
preRequestScript,
testScript,
} =
data && data !== "null" data && data !== "null"
? JSON.parse(data) ? JSON.parse(data)
: { : {
@ -1130,6 +1163,8 @@ function transformDuplicatedCollections(
headers: [], headers: [],
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
} }
const _ref_id = generateUniqueRefId("coll") const _ref_id = generateUniqueRefId("coll")
@ -1144,11 +1179,13 @@ function transformDuplicatedCollections(
folders, folders,
requests, requests,
_ref_id, _ref_id,
v: 11, v: 12,
auth, auth,
headers: addDescriptionField(headers), headers: addDescriptionField(headers),
variables: variables ?? [], variables: variables ?? [],
description: description ?? null, description: description ?? null,
preRequestScript: preRequestScript ?? "",
testScript: testScript ?? "",
} }
} }
) )

View file

@ -48,6 +48,8 @@ const transformCollectionForBackend = (collection: HoppCollection): any => {
variables: collection.variables ?? [], variables: collection.variables ?? [],
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null, description: collection.description ?? null,
preRequestScript: collection.preRequestScript ?? "",
testScript: collection.testScript ?? "",
} }
return { return {
@ -83,6 +85,8 @@ const recursivelySyncCollections = async (
variables: collection.variables ?? [], variables: collection.variables ?? [],
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null, description: collection.description ?? null,
preRequestScript: collection.preRequestScript ?? "",
testScript: collection.testScript ?? "",
} }
const res = await createRESTRootUserCollection( const res = await createRESTRootUserCollection(
collection.name, collection.name,
@ -102,6 +106,8 @@ const recursivelySyncCollections = async (
variables: [], variables: [],
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
description: null, description: null,
preRequestScript: "",
testScript: "",
} }
collection.id = parentCollectionID collection.id = parentCollectionID
@ -110,6 +116,9 @@ const recursivelySyncCollections = async (
collection.headers = returnedData.headers collection.headers = returnedData.headers
collection.variables = returnedData.variables collection.variables = returnedData.variables
collection.description = returnedData.description ?? null collection.description = returnedData.description ?? null
collection.preRequestScript = returnedData.preRequestScript ?? ""
collection.testScript = returnedData.testScript ?? ""
removeDuplicateRESTCollectionOrFolder(parentCollectionID, collectionPath) removeDuplicateRESTCollectionOrFolder(parentCollectionID, collectionPath)
} else { } else {
parentCollectionID = undefined parentCollectionID = undefined
@ -125,6 +134,8 @@ const recursivelySyncCollections = async (
variables: collection.variables ?? [], variables: collection.variables ?? [],
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null, description: collection.description ?? null,
preRequestScript: collection.preRequestScript ?? "",
testScript: collection.testScript ?? "",
} }
const res = await createRESTChildUserCollection( const res = await createRESTChildUserCollection(
@ -147,6 +158,8 @@ const recursivelySyncCollections = async (
variables: [], variables: [],
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
description: null, description: null,
preRequestScript: "",
testScript: "",
} }
collection.id = childCollectionId collection.id = childCollectionId
@ -156,6 +169,8 @@ const recursivelySyncCollections = async (
parentCollectionID = childCollectionId parentCollectionID = childCollectionId
collection.variables = returnedData.variables collection.variables = returnedData.variables
collection.description = returnedData.description ?? null collection.description = returnedData.description ?? null
collection.preRequestScript = returnedData.preRequestScript ?? ""
collection.testScript = returnedData.testScript ?? ""
removeDuplicateRESTCollectionOrFolder( removeDuplicateRESTCollectionOrFolder(
childCollectionId, childCollectionId,
@ -268,6 +283,8 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
variables: collection.variables, variables: collection.variables,
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null, description: collection.description ?? null,
preRequestScript: collection.preRequestScript ?? "",
testScript: collection.testScript ?? "",
} }
if (collectionID) { if (collectionID) {
@ -351,6 +368,8 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
variables: folder.variables, variables: folder.variables,
_ref_id: folder._ref_id, _ref_id: folder._ref_id,
description: folder.description ?? null, description: folder.description ?? null,
preRequestScript: folder.preRequestScript ?? "",
testScript: folder.testScript ?? "",
} }
if (folderID) { if (folderID) {
updateUserCollection(folderID, folderName, JSON.stringify(data)) updateUserCollection(folderID, folderName, JSON.stringify(data))

View file

@ -45,6 +45,9 @@ const transformCollectionForBackend = (collection: HoppCollection): any => {
headers: collection.headers ?? [], headers: collection.headers ?? [],
variables: collection.variables ?? [], variables: collection.variables ?? [],
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null,
preRequestScript: collection.preRequestScript ?? "",
testScript: collection.testScript ?? "",
} }
return { return {
@ -79,6 +82,9 @@ const recursivelySyncCollections = async (
headers: collection.headers ?? [], headers: collection.headers ?? [],
variables: collection.variables ?? [], variables: collection.variables ?? [],
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null,
preRequestScript: collection.preRequestScript ?? "",
testScript: collection.testScript ?? "",
} }
const res = await createGQLRootUserCollection( const res = await createGQLRootUserCollection(
collection.name, collection.name,
@ -98,6 +104,9 @@ const recursivelySyncCollections = async (
headers: [], headers: [],
variables: [], variables: [],
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
description: null,
preRequestScript: "",
testScript: "",
} }
collection.id = parentCollectionID collection.id = parentCollectionID
@ -105,6 +114,9 @@ const recursivelySyncCollections = async (
collection.auth = returnedData.auth collection.auth = returnedData.auth
collection.headers = returnedData.headers collection.headers = returnedData.headers
collection.variables = returnedData.variables collection.variables = returnedData.variables
collection.description = returnedData.description ?? null
collection.preRequestScript = returnedData.preRequestScript ?? ""
collection.testScript = returnedData.testScript ?? ""
removeDuplicateGraphqlCollectionOrFolder( removeDuplicateGraphqlCollectionOrFolder(
parentCollectionID, parentCollectionID,
@ -124,6 +136,9 @@ const recursivelySyncCollections = async (
headers: collection.headers ?? [], headers: collection.headers ?? [],
variables: collection.variables ?? [], variables: collection.variables ?? [],
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null,
preRequestScript: collection.preRequestScript ?? "",
testScript: collection.testScript ?? "",
} }
const res = await createGQLChildUserCollection( const res = await createGQLChildUserCollection(
@ -145,6 +160,9 @@ const recursivelySyncCollections = async (
headers: [], headers: [],
variables: [], variables: [],
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
description: null,
preRequestScript: "",
testScript: "",
} }
collection.id = childCollectionId collection.id = childCollectionId
@ -153,6 +171,9 @@ const recursivelySyncCollections = async (
collection.headers = returnedData.headers collection.headers = returnedData.headers
parentCollectionID = childCollectionId parentCollectionID = childCollectionId
collection.variables = returnedData.variables collection.variables = returnedData.variables
collection.description = returnedData.description ?? null
collection.preRequestScript = returnedData.preRequestScript ?? ""
collection.testScript = returnedData.testScript ?? ""
removeDuplicateGraphqlCollectionOrFolder( removeDuplicateGraphqlCollectionOrFolder(
childCollectionId, childCollectionId,
@ -268,6 +289,9 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
headers: collection.headers, headers: collection.headers,
variables: collection.variables, variables: collection.variables,
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null,
preRequestScript: collection.preRequestScript ?? "",
testScript: collection.testScript ?? "",
} }
if (collectionID) { if (collectionID) {
@ -314,6 +338,9 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
headers: folder.headers, headers: folder.headers,
variables: folder.variables, variables: folder.variables,
_ref_id: folder._ref_id, _ref_id: folder._ref_id,
description: folder.description ?? null,
preRequestScript: folder.preRequestScript ?? "",
testScript: folder.testScript ?? "",
} }
if (folderBackendId) { if (folderBackendId) {

View file

@ -70,6 +70,8 @@ export function translateToPersonalCollectionFormat(x: HoppCollection) {
headers: x.headers, headers: x.headers,
variables: x.variables, variables: x.variables,
description: x.description, description: x.description,
preRequestScript: x.preRequestScript ?? "",
testScript: x.testScript ?? "",
} }
const obj = { const obj = {

View file

@ -142,12 +142,14 @@ export function exportedCollectionToHoppCollection(
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
} }
return { return {
id: restCollection.id, id: restCollection.id,
_ref_id: data._ref_id ?? generateUniqueRefId("coll"), _ref_id: data._ref_id ?? generateUniqueRefId("coll"),
v: 11, v: 12,
name: restCollection.name, name: restCollection.name,
folders: restCollection.folders.map((folder) => folders: restCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType) exportedCollectionToHoppCollection(folder, collectionType)
@ -201,6 +203,8 @@ export function exportedCollectionToHoppCollection(
auth: data.auth, auth: data.auth,
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
preRequestScript: data.preRequestScript ?? "",
testScript: data.testScript ?? "",
} }
} else { } else {
const gqlCollection = collection as ExportedUserCollectionGQL const gqlCollection = collection as ExportedUserCollectionGQL
@ -219,7 +223,7 @@ export function exportedCollectionToHoppCollection(
return { return {
id: gqlCollection.id, id: gqlCollection.id,
_ref_id: data._ref_id ?? generateUniqueRefId("coll"), _ref_id: data._ref_id ?? generateUniqueRefId("coll"),
v: 11, v: 12,
name: gqlCollection.name, name: gqlCollection.name,
folders: gqlCollection.folders.map((folder) => folders: gqlCollection.folders.map((folder) =>
exportedCollectionToHoppCollection(folder, collectionType) exportedCollectionToHoppCollection(folder, collectionType)
@ -249,6 +253,8 @@ export function exportedCollectionToHoppCollection(
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
description: data.description ?? null, description: data.description ?? null,
preRequestScript: data.preRequestScript ?? "",
testScript: data.testScript ?? "",
} }
} }
} }
@ -400,6 +406,8 @@ function setupUserCollectionCreatedSubscription() {
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
} }
runDispatchWithOutSyncing(() => { runDispatchWithOutSyncing(() => {
@ -408,23 +416,27 @@ function setupUserCollectionCreatedSubscription() {
name: res.right.userCollectionCreated.title, name: res.right.userCollectionCreated.title,
folders: [], folders: [],
requests: [], requests: [],
v: 11, v: 12,
_ref_id: data._ref_id, _ref_id: data._ref_id,
auth: data.auth, auth: data.auth,
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
description: data.description ?? null, description: data.description ?? null,
preRequestScript: data.preRequestScript ?? "",
testScript: data.testScript ?? "",
}) })
: addRESTCollection({ : addRESTCollection({
name: res.right.userCollectionCreated.title, name: res.right.userCollectionCreated.title,
folders: [], folders: [],
requests: [], requests: [],
v: 11, v: 12,
_ref_id: data._ref_id, _ref_id: data._ref_id,
auth: data.auth, auth: data.auth,
headers: addDescriptionField(data.headers), headers: addDescriptionField(data.headers),
variables: data.variables ?? [], variables: data.variables ?? [],
description: data.description ?? null, description: data.description ?? null,
preRequestScript: data.preRequestScript ?? "",
testScript: data.testScript ?? "",
}) })
const localIndex = collectionStore.value.state.length - 1 const localIndex = collectionStore.value.state.length - 1
@ -627,7 +639,14 @@ function setupUserCollectionDuplicatedSubscription() {
) )
// Incoming data transformed to the respective internal representations // Incoming data transformed to the respective internal representations
const { auth, headers, variables, description } = const {
auth,
headers,
variables,
description,
preRequestScript,
testScript,
} =
data && data != "null" data && data != "null"
? JSON.parse(data) ? JSON.parse(data)
: { : {
@ -635,6 +654,8 @@ function setupUserCollectionDuplicatedSubscription() {
headers: [], headers: [],
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
} }
// Duplicated collection will have a unique ref id // Duplicated collection will have a unique ref id
const _ref_id = generateUniqueRefId("coll") const _ref_id = generateUniqueRefId("coll")
@ -651,12 +672,14 @@ function setupUserCollectionDuplicatedSubscription() {
name, name,
folders, folders,
requests, requests,
v: 11, v: 12,
_ref_id, _ref_id,
auth, auth,
headers: addDescriptionField(headers), headers: addDescriptionField(headers),
variables: variables ?? [], variables: variables ?? [],
description: description ?? null, description: description ?? null,
preRequestScript: preRequestScript ?? "",
testScript: testScript ?? "",
} }
// only folders will have parent collection id // only folders will have parent collection id
@ -1122,7 +1145,14 @@ function transformDuplicatedCollections(
requests: userRequests, requests: userRequests,
title: name, title: name,
}) => { }) => {
const { auth, headers, variables, description } = const {
auth,
headers,
variables,
description,
preRequestScript,
testScript,
} =
data && data !== "null" data && data !== "null"
? JSON.parse(data) ? JSON.parse(data)
: { : {
@ -1130,6 +1160,8 @@ function transformDuplicatedCollections(
headers: [], headers: [],
variables: [], variables: [],
description: null, description: null,
preRequestScript: "",
testScript: "",
} }
const _ref_id = generateUniqueRefId("coll") const _ref_id = generateUniqueRefId("coll")
@ -1144,11 +1176,13 @@ function transformDuplicatedCollections(
folders, folders,
requests, requests,
_ref_id, _ref_id,
v: 11, v: 12,
auth, auth,
headers: addDescriptionField(headers), headers: addDescriptionField(headers),
variables: variables ?? [], variables: variables ?? [],
description: description ?? null, description: description ?? null,
preRequestScript: preRequestScript ?? "",
testScript: testScript ?? "",
} }
} }
) )

View file

@ -48,6 +48,8 @@ const transformCollectionForBackend = (collection: HoppCollection): any => {
variables: collection.variables ?? [], variables: collection.variables ?? [],
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null, description: collection.description ?? null,
preRequestScript: collection.preRequestScript ?? "",
testScript: collection.testScript ?? "",
} }
return { return {
@ -83,6 +85,8 @@ const recursivelySyncCollections = async (
variables: collection.variables ?? [], variables: collection.variables ?? [],
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null, description: collection.description ?? null,
preRequestScript: collection.preRequestScript ?? "",
testScript: collection.testScript ?? "",
} }
const res = await createRESTRootUserCollection( const res = await createRESTRootUserCollection(
collection.name, collection.name,
@ -102,6 +106,8 @@ const recursivelySyncCollections = async (
variables: [], variables: [],
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
description: null, description: null,
preRequestScript: "",
testScript: "",
} }
collection.id = parentCollectionID collection.id = parentCollectionID
@ -110,6 +116,8 @@ const recursivelySyncCollections = async (
collection.headers = returnedData.headers collection.headers = returnedData.headers
collection.variables = returnedData.variables collection.variables = returnedData.variables
collection.description = returnedData.description ?? null collection.description = returnedData.description ?? null
collection.preRequestScript = returnedData.preRequestScript ?? ""
collection.testScript = returnedData.testScript ?? ""
removeDuplicateRESTCollectionOrFolder( removeDuplicateRESTCollectionOrFolder(
parentCollectionID, parentCollectionID,
`${collectionPath}` `${collectionPath}`
@ -128,6 +136,8 @@ const recursivelySyncCollections = async (
variables: collection.variables ?? [], variables: collection.variables ?? [],
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null, description: collection.description ?? null,
preRequestScript: collection.preRequestScript ?? "",
testScript: collection.testScript ?? "",
} }
const res = await createRESTChildUserCollection( const res = await createRESTChildUserCollection(
@ -150,6 +160,8 @@ const recursivelySyncCollections = async (
variables: [], variables: [],
_ref_id: generateUniqueRefId("coll"), _ref_id: generateUniqueRefId("coll"),
description: null, description: null,
preRequestScript: "",
testScript: "",
} }
collection.id = childCollectionId collection.id = childCollectionId
@ -159,6 +171,8 @@ const recursivelySyncCollections = async (
parentCollectionID = childCollectionId parentCollectionID = childCollectionId
collection.variables = returnedData.variables collection.variables = returnedData.variables
collection.description = returnedData.description ?? null collection.description = returnedData.description ?? null
collection.preRequestScript = returnedData.preRequestScript ?? ""
collection.testScript = returnedData.testScript ?? ""
removeDuplicateRESTCollectionOrFolder( removeDuplicateRESTCollectionOrFolder(
childCollectionId, childCollectionId,
@ -271,6 +285,8 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
variables: collection.variables, variables: collection.variables,
_ref_id: collection._ref_id, _ref_id: collection._ref_id,
description: collection.description ?? null, description: collection.description ?? null,
preRequestScript: collection.preRequestScript ?? "",
testScript: collection.testScript ?? "",
} }
if (collectionID) { if (collectionID) {
@ -355,6 +371,8 @@ export const storeSyncDefinition: StoreSyncDefinitionOf<
variables: folder.variables, variables: folder.variables,
_ref_id: folder._ref_id, _ref_id: folder._ref_id,
description: folder.description, description: folder.description,
preRequestScript: folder.preRequestScript ?? "",
testScript: folder.testScript ?? "",
} }
if (folderID) { if (folderID) {
updateUserCollection(folderID, folderName, JSON.stringify(data)) updateUserCollection(folderID, folderName, JSON.stringify(data))