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',
TABLE_DOES_NOT_EXIST = 'P2021',
UNIQUE_CONSTRAINT_VIOLATION = 'P2002',
RECORD_NOT_FOUND = 'P2025',
TRANSACTION_TIMEOUT = 'P2028',
TRANSACTION_DEADLOCK = 'P2034', // write conflict or a deadlock
}

View file

@ -569,23 +569,24 @@ export class TeamCollectionService {
collection.parentID,
);
const deletedCollection = await tx.teamCollection.delete({
where: { id: collection.id },
});
// if collection is deleted, update siblings orderIndexes
// if collection was deleted before the transaction started (race condition), do not update siblings orderIndexes
if (deletedCollection) {
// update siblings orderIndexes
await tx.teamCollection.updateMany({
where: {
teamID: collection.teamID,
parentID: collection.parentID,
orderIndex: orderIndexCondition,
},
data: { orderIndex: dataCondition },
try {
await tx.teamCollection.delete({
where: { id: collection.id },
});
} catch (deleteError) {
// P2025: Record not found — already deleted by a concurrent transaction
if (deleteError?.code === PrismaError.RECORD_NOT_FOUND) return;
throw deleteError;
}
await tx.teamCollection.updateMany({
where: {
teamID: collection.teamID,
parentID: collection.parentID,
orderIndex: orderIndexCondition,
},
data: { orderIndex: dataCondition },
});
} catch (error) {
throw new ConflictException(error);
}
@ -749,12 +750,7 @@ export class TeamCollectionService {
// Get collection details of collectionID
const collection = await this.getCollection(collectionID, tx);
if (E.isLeft(collection)) return E.left(collection.left);
// lock the rows of the collection and its siblings
await this.prisma.lockTeamCollectionByTeamAndParent(
tx,
collection.right.teamID,
collection.right.parentID,
);
// destCollectionID == null i.e move collection to root
if (!destCollectionID) {
if (!collection.right.parentID) {
@ -763,6 +759,12 @@ export class TeamCollectionService {
return E.left(TEAM_COL_ALREADY_ROOT);
}
await this.prisma.lockTeamCollectionByTeamAndParent(
tx,
collection.right.teamID,
collection.right.parentID,
);
// Change parent from child to root i.e child collection becomes a root collection
// Move child collection into root and update orderIndexes for root teamCollections
const updatedCollection = await this.changeParentAndUpdateOrderIndex(
@ -806,12 +808,41 @@ export class TeamCollectionService {
return E.left(TEAM_COLL_IS_PARENT_COLL);
}
// lock the rows of the destination collection and its siblings
await this.prisma.lockTeamCollectionByTeamAndParent(
tx,
destCollection.right.teamID,
destCollection.right.parentID,
);
// Acquire locks in deterministic order (sorted by parentID) to prevent deadlocks
// when two concurrent moves happen in opposite directions
const srcParentID = collection.right.parentID ?? '';
const destParentID = destCollection.right.parentID ?? '';
const teamID = collection.right.teamID;
if (srcParentID === destParentID) {
await this.prisma.lockTeamCollectionByTeamAndParent(
tx,
teamID,
collection.right.parentID,
);
} else if (srcParentID < destParentID) {
await this.prisma.lockTeamCollectionByTeamAndParent(
tx,
teamID,
collection.right.parentID,
);
await this.prisma.lockTeamCollectionByTeamAndParent(
tx,
teamID,
destCollection.right.parentID,
);
} else {
await this.prisma.lockTeamCollectionByTeamAndParent(
tx,
teamID,
destCollection.right.parentID,
);
await this.prisma.lockTeamCollectionByTeamAndParent(
tx,
teamID,
collection.right.parentID,
);
}
// Change parent from null to teamCollection i.e collection becomes a child collection
// Move root/child collection into another child collection and update orderIndexes of the previous parent

View file

@ -20,6 +20,7 @@ import {
TeamRequest as DbTeamRequest,
} from 'src/generated/prisma/client';
import { SortOptions } from 'src/types/SortOptions';
import { PrismaError } from 'src/prisma/prisma-error-codes';
@Injectable()
export class TeamRequestService {
@ -124,21 +125,23 @@ export class TeamRequestService {
dbTeamReq.collectionID,
]);
const deletedTeamRequest = await tx.teamRequest.delete({
where: { id: requestID },
});
// if request is deleted, update orderIndexes of siblings
// if request was deleted before the transaction started (race condition), do not update siblings orderIndexes
if (deletedTeamRequest) {
await tx.teamRequest.updateMany({
where: {
collectionID: dbTeamReq.collectionID,
orderIndex: { gte: dbTeamReq.orderIndex },
},
data: { orderIndex: { decrement: 1 } },
try {
await tx.teamRequest.delete({
where: { id: requestID },
});
} catch (deleteError) {
// P2025: Record not found — already deleted by a concurrent transaction
if (deleteError?.code === PrismaError.RECORD_NOT_FOUND) return;
throw deleteError;
}
await tx.teamRequest.updateMany({
where: {
collectionID: dbTeamReq.collectionID,
orderIndex: { gte: dbTeamReq.orderIndex },
},
data: { orderIndex: { decrement: 1 } },
});
} catch (error) {
throw new ConflictException(error);
}

View file

@ -2380,3 +2380,165 @@ describe('exportUserCollectionToJSONObject', () => {
expect(result).toEqualLeft(USER_COLL_NOT_FOUND);
});
});
describe('importCollectionsFromJSON — collection-level script fields', () => {
// The backend treats `data` as an opaque JSON blob, so the script fields
// ride through transparently. The test asserts on both ends: the create
// call payload must carry script fields (proving import wrote them), and
// the export payload must surface them unchanged. Guards against any
// future refactor that destructures `data` and drops scripts on either
// side.
test('preRequestScript and testScript on root and folder survive import → export round-trip', async () => {
const importJSON = JSON.stringify([
{
name: 'root-with-scripts',
folders: [
{
name: 'child-folder',
folders: [],
requests: [],
data: JSON.stringify({
auth: { authType: 'inherit', authActive: true },
headers: [],
variables: [],
preRequestScript: 'pw.env.set("FOLDER_RAN", "yes");',
testScript: 'pw.test("folder", () => {});',
}),
},
],
requests: [],
data: JSON.stringify({
auth: { authType: 'none', authActive: false },
headers: [],
variables: [],
preRequestScript: 'pw.env.set("ROOT_RAN", "yes");',
testScript: 'pw.test("root", () => {});',
}),
},
]);
const rootRowId = 'imported-root-id';
const folderRowId = 'imported-folder-id';
// Capture what generatePrismaQueryObj writes into Prisma so the export
// path sees the same blob shape the import wrote.
const rootDataAtCreate = {
auth: { authType: 'none', authActive: false },
headers: [],
variables: [],
preRequestScript: 'pw.env.set("ROOT_RAN", "yes");',
testScript: 'pw.test("root", () => {});',
};
const folderDataAtCreate = {
auth: { authType: 'inherit', authActive: true },
headers: [],
variables: [],
preRequestScript: 'pw.env.set("FOLDER_RAN", "yes");',
testScript: 'pw.test("folder", () => {});',
};
mockPrisma.$transaction.mockImplementation(async (fn) => fn(mockPrisma));
mockPrisma.lockUserCollectionByParent.mockResolvedValue(undefined);
mockPrisma.userCollection.findFirst.mockResolvedValueOnce(null);
mockPrisma.userCollection.create.mockResolvedValueOnce({
id: rootRowId,
orderIndex: 1,
parentID: null,
title: 'root-with-scripts',
userUid: user.uid,
type: ReqType.REST,
createdOn: currentTime,
updatedOn: currentTime,
data: rootDataAtCreate,
});
// Export-side mocks: root resolves once, then its child folder resolves.
mockPrisma.userCollection.findUniqueOrThrow
.mockResolvedValueOnce({
id: rootRowId,
orderIndex: 1,
parentID: null,
title: 'root-with-scripts',
userUid: user.uid,
type: ReqType.REST,
createdOn: currentTime,
updatedOn: currentTime,
data: rootDataAtCreate,
})
.mockResolvedValueOnce({
id: folderRowId,
orderIndex: 1,
parentID: rootRowId,
title: 'child-folder',
userUid: user.uid,
type: ReqType.REST,
createdOn: currentTime,
updatedOn: currentTime,
data: folderDataAtCreate,
});
mockPrisma.userCollection.findMany
.mockResolvedValueOnce([
{
id: folderRowId,
orderIndex: 1,
parentID: rootRowId,
title: 'child-folder',
userUid: user.uid,
type: ReqType.REST,
createdOn: currentTime,
updatedOn: currentTime,
data: folderDataAtCreate,
},
])
.mockResolvedValueOnce([]);
mockPrisma.userRequest.findMany
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
const result = await userCollectionService.importCollectionsFromJSON(
importJSON,
user.uid,
null,
ReqType.REST,
);
expect(E.isRight(result)).toBe(true);
// Import side: `userCollection.create` must receive script fields inside
// its `data` payload (root) and inside `children.create[0].data` (folder).
// Asserting against the create call args proves import preserved scripts;
// export-side mocks alone would only round-trip the values we pre-loaded.
const createCallArg = mockPrisma.userCollection.create.mock.calls[0][0]
.data as any;
expect(createCallArg.data.preRequestScript).toBe(
'pw.env.set("ROOT_RAN", "yes");',
);
expect(createCallArg.data.testScript).toBe(
'pw.test("root", () => {});',
);
const childCreateArg = createCallArg.children.create[0];
expect(childCreateArg.data.preRequestScript).toBe(
'pw.env.set("FOLDER_RAN", "yes");',
);
expect(childCreateArg.data.testScript).toBe(
'pw.test("folder", () => {});',
);
if (E.isRight(result)) {
const exported = JSON.parse(result.right.exportedCollection);
// `data` is JSON-stringified by transformCollectionData on export.
const rootData = JSON.parse(exported[0].data);
const folderData = JSON.parse(exported[0].folders[0].data);
expect(rootData.preRequestScript).toBe(
'pw.env.set("ROOT_RAN", "yes");',
);
expect(rootData.testScript).toBe('pw.test("root", () => {});');
expect(folderData.preRequestScript).toBe(
'pw.env.set("FOLDER_RAN", "yes");',
);
expect(folderData.testScript).toBe('pw.test("folder", () => {});');
}
});
});

View file

@ -520,23 +520,24 @@ export class UserCollectionService {
collection.parentID,
);
const deletedCollection = await tx.userCollection.delete({
where: { id: collection.id },
});
// if collection is deleted, update siblings orderIndexes
// if collection was deleted before the transaction started (race condition), do not update siblings orderIndexes
if (deletedCollection) {
// update orderIndexes
await tx.userCollection.updateMany({
where: {
userUid: collection.userUid,
parentID: collection.parentID,
orderIndex: orderIndexCondition,
},
data: { orderIndex: dataCondition },
try {
await tx.userCollection.delete({
where: { id: collection.id },
});
} catch (deleteError) {
// P2025: Record not found — already deleted by a concurrent transaction
if (deleteError?.code === PrismaError.RECORD_NOT_FOUND) return;
throw deleteError;
}
await tx.userCollection.updateMany({
where: {
userUid: collection.userUid,
parentID: collection.parentID,
orderIndex: orderIndexCondition,
},
data: { orderIndex: dataCondition },
});
} catch (error) {
throw new ConflictException(error);
}
@ -596,6 +597,12 @@ export class UserCollectionService {
return E.left(USER_COLL_ALREADY_ROOT);
}
await this.prisma.lockUserCollectionByParent(
tx,
userID,
collection.right.parentID,
);
// Change parent from child to root i.e child collection becomes a root collection
// Move child collection into root and update orderIndexes for child userCollections
const updatedCollection = await this.changeParentAndUpdateOrderIndex(
@ -643,7 +650,40 @@ export class UserCollectionService {
return E.left(USER_COLL_IS_PARENT_COLL);
}
// Change parent from null to teamCollection i.e collection becomes a child collection
// Acquire locks in deterministic order (sorted by parentID) to prevent deadlocks
const srcParentID = collection.right.parentID ?? '';
const destParentID = destCollection.right.parentID ?? '';
if (srcParentID === destParentID) {
await this.prisma.lockUserCollectionByParent(
tx,
userID,
collection.right.parentID,
);
} else if (srcParentID < destParentID) {
await this.prisma.lockUserCollectionByParent(
tx,
userID,
collection.right.parentID,
);
await this.prisma.lockUserCollectionByParent(
tx,
userID,
destCollection.right.parentID,
);
} else {
await this.prisma.lockUserCollectionByParent(
tx,
userID,
destCollection.right.parentID,
);
await this.prisma.lockUserCollectionByParent(
tx,
userID,
collection.right.parentID,
);
}
// Move root/child collection into another child collection and update orderIndexes of the previous parent
const updatedCollection = await this.changeParentAndUpdateOrderIndex(
tx,

View file

@ -539,6 +539,21 @@ describe("hopp test [options] <file_path_or_id>", { timeout: 100000 }, () => {
// Clean up
fs.unlinkSync(junitPath);
}, 600000); // 600 second (10 minute) timeout
test("Inherited collection-level scripts run in order across both sandboxes", async () => {
const args = `test ${getTestJsonFilePath(
"collection-level-scripts-coll.json",
"collection"
)}`;
const defaultResult = await runCLIWithNetworkRetry(args);
if (defaultResult === null) return;
expect(defaultResult.error).toBeNull();
const legacyResult = await runCLIWithNetworkRetry(`${args} --legacy-sandbox`);
if (legacyResult === null) return;
expect(legacyResult.error).toBeNull();
});
});
describe("Test `hopp test <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: [],
variables: [],
description: null,
preRequestScript: "",
testScript: "",
},
],
requests: [
@ -192,6 +194,8 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M
},
],
description: null,
preRequestScript: "",
testScript: "",
},
],
requests: [
@ -224,6 +228,8 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M
headers: [],
variables: [],
description: null,
preRequestScript: "",
testScript: "",
},
],
requests: [
@ -273,6 +279,8 @@ export const TRANSFORMED_DEEPLY_NESTED_COLLECTIONS_WITH_AUTH_HEADERS_VARIABLES_M
},
],
description: null,
preRequestScript: "",
testScript: "",
},
];
@ -513,12 +521,12 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
name: "Multiple child collections with authorization, headers and variables set at each level",
folders: [
{
v: 11,
v: CollectionSchemaVersion,
id: "clx1fjgah000110f8a5bs68gd",
name: "folder-1",
folders: [
{
v: 11,
v: CollectionSchemaVersion,
id: "clx1fjwmm000410f8l1gkkr1a",
name: "folder-11",
folders: [],
@ -567,9 +575,11 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
},
],
description: null,
preRequestScript: "",
testScript: "",
},
{
v: 11,
v: CollectionSchemaVersion,
id: "clx1fjyxm000510f8pv90dt43",
name: "folder-12",
folders: [],
@ -634,9 +644,11 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
},
],
description: null,
preRequestScript: "",
testScript: "",
},
{
v: 11,
v: CollectionSchemaVersion,
id: "clx1fk1cv000610f88kc3aupy",
name: "folder-13",
folders: [],
@ -719,6 +731,8 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
},
],
description: null,
preRequestScript: "",
testScript: "",
},
],
requests: [
@ -764,14 +778,16 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
},
],
description: null,
preRequestScript: "",
testScript: "",
},
{
v: 11,
v: CollectionSchemaVersion,
id: "clx1fjk9o000210f8j0573pls",
name: "folder-2",
folders: [
{
v: 11,
v: CollectionSchemaVersion,
id: "clx1fk516000710f87sfpw6bo",
name: "folder-21",
folders: [],
@ -818,9 +834,11 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
},
],
description: null,
preRequestScript: "",
testScript: "",
},
{
v: 11,
v: CollectionSchemaVersion,
id: "clx1fk72t000810f8gfwkpi5y",
name: "folder-22",
folders: [],
@ -885,9 +903,11 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
},
],
description: null,
preRequestScript: "",
testScript: "",
},
{
v: 11,
v: CollectionSchemaVersion,
id: "clx1fk95g000910f8bunhaoo8",
name: "folder-23",
folders: [],
@ -957,6 +977,8 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
},
],
description: null,
preRequestScript: "",
testScript: "",
},
],
requests: [
@ -1008,15 +1030,17 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
},
],
description: null,
preRequestScript: "",
testScript: "",
},
{
v: 11,
v: CollectionSchemaVersion,
id: "clx1fjmlq000310f86o4d3w2o",
name: "folder-3",
folders: [
{
v: 11,
v: CollectionSchemaVersion,
id: "clx1iwq0p003e10f8u8zg0p85",
name: "folder-31",
folders: [],
@ -1063,9 +1087,11 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
},
],
description: null,
preRequestScript: "",
testScript: "",
},
{
v: 11,
v: CollectionSchemaVersion,
id: "clx1izut7003m10f894ip59zg",
name: "folder-32",
folders: [],
@ -1130,9 +1156,11 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
},
],
description: null,
preRequestScript: "",
testScript: "",
},
{
v: 11,
v: CollectionSchemaVersion,
id: "clx1j2ka9003q10f8cdbzpgpg",
name: "folder-33",
folders: [],
@ -1202,6 +1230,8 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
},
],
description: null,
preRequestScript: "",
testScript: "",
},
],
requests: [
@ -1266,6 +1296,8 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
},
],
description: null,
preRequestScript: "",
testScript: "",
},
],
requests: [
@ -1321,6 +1353,8 @@ export const TRANSFORMED_MULTIPLE_CHILD_COLLECTIONS_WITH_AUTH_HEADERS_MOCK: Hopp
},
],
description: null,
preRequestScript: "",
testScript: "",
},
];
@ -1428,6 +1462,8 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
headers: [],
variables: [],
description: null,
preRequestScript: "",
testScript: "",
},
{
v: CollectionSchemaVersion,
@ -1476,6 +1512,8 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
},
],
description: null,
preRequestScript: "",
testScript: "",
},
{
v: CollectionSchemaVersion,
@ -1490,6 +1528,8 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
headers: [],
variables: [],
description: null,
preRequestScript: "",
testScript: "",
},
{
v: CollectionSchemaVersion,
@ -1518,6 +1558,8 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
},
],
description: null,
preRequestScript: "",
testScript: "",
},
],
requests: [],
@ -1528,6 +1570,8 @@ export const TRANSFORMED_COLLECTIONS_WITHOUT_AUTH_HEADERS_VARIABLES_AT_CERTAIN_L
headers: [],
variables: [],
description: null,
preRequestScript: "",
testScript: "",
},
];

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 {HoppEnvs} envs Environment variables for test script runner.
* @property {boolean} legacySandbox Whether to use the legacy sandbox.
* @property {string[]} inheritedTestScripts Test scripts inherited from parent collections.
*/
export interface TestScriptParams {
request: HoppRESTRequest;
response: TestResponse;
envs: HoppEnvs;
legacySandbox: boolean;
inheritedTestScripts?: string[];
}
/**

View file

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

View file

@ -34,6 +34,7 @@ import {
processRequest,
} from "./request";
import { getTestMetrics } from "./test";
import { filterValidScripts } from "./scripting";
const { WARN, FAIL, INFO } = exceptionColors;
@ -109,8 +110,21 @@ const processCollection = async (
envs: HoppEnvs,
delay: number,
requestsReport: RequestReport[],
legacySandbox?: boolean
legacySandbox?: boolean,
ancestorPreRequestScripts: string[] = [],
ancestorTestScripts: string[] = []
) => {
// Accumulate scripts from root -> current collection for inheritance
// filterValidScripts strips empty, whitespace-only, and module-prefix-only scripts
const inheritedPreRequestScripts = filterValidScripts([
...ancestorPreRequestScripts,
collection.preRequestScript,
]);
const inheritedTestScripts = filterValidScripts([
...ancestorTestScripts,
collection.testScript,
]);
// Process each request in the collection
for (const request of collection.requests) {
const _request = preProcessRequest(request as HoppRESTRequest, collection);
@ -127,6 +141,8 @@ const processCollection = async (
delay,
legacySandbox,
collectionVariables,
inheritedPreRequestScripts,
inheritedTestScripts,
};
// Request processing initiated message.
@ -188,7 +204,9 @@ const processCollection = async (
envs,
delay,
requestsReport,
legacySandbox
legacySandbox,
inheritedPreRequestScripts,
inheritedTestScripts
);
}
};

View file

@ -9,6 +9,9 @@ import { FormDataEntry } from "../types/request";
import { isHoppErrnoException } from "./checks";
import { getResourceContents } from "./getters";
// Re-export from the canonical implementation in scripting.ts
export { stripModulePrefix } from "./scripting";
const getValidRequests = (
collections: HoppCollection[],
collectionFilePath: string
@ -158,19 +161,3 @@ export async function parseCollectionData(
return getValidRequests(collectionSchemaParsedResult.data, pathOrId);
}
/**
* Module prefix added by Monaco editor for TypeScript module mode.
*/
const MODULE_PREFIX = "export {};\n" as const;
/**
* Strips `export {};\n` prefix from scripts before sandbox execution.
* The prefix is added by the web app's Monaco editor for IntelliSense
* and must be removed before execution.
*/
export const stripModulePrefix = (script: string): string => {
return script.startsWith(MODULE_PREFIX)
? script.slice(MODULE_PREFIX.length)
: script;
};

View file

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

View file

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

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 { getDurationInSeconds } from "./getters";
import { createHoppFetchHook } from "./hopp-fetch";
import { stripModulePrefix } from "./mutators";
import { combineScriptsWithIIFE, filterValidScripts } from "./scripting";
/**
* Executes test script and runs testDescriptorParser to generate test-report using
@ -39,28 +39,46 @@ export const testRunner = (
TE.bind("test_response", () =>
pipe(
TE.of(testScriptData),
TE.chain(({ request, response, envs, legacySandbox }) => {
const { status, statusText, headers, responseTime, body } = response;
const effectiveResponse = {
status,
statusText,
headers,
responseTime,
body,
};
const experimentalScriptingSandbox = !legacySandbox;
const hoppFetchHook = createHoppFetchHook();
return runTestScript(stripModulePrefix(request.testScript), {
envs,
TE.chain(
({
request,
response: effectiveResponse,
experimentalScriptingSandbox,
hoppFetchHook,
});
})
response,
envs,
legacySandbox,
inheritedTestScripts = [],
}) => {
const { status, statusText, headers, responseTime, body } =
response;
const effectiveResponse = {
status,
statusText,
headers,
responseTime,
body,
};
const experimentalScriptingSandbox = !legacySandbox;
const hoppFetchHook = createHoppFetchHook();
// Test order: request → root (reverse of pre-request).
const combinedScript = combineScriptsWithIIFE(
filterValidScripts([
request.testScript,
...inheritedTestScripts.slice().reverse(),
]),
experimentalScriptingSandbox ? "experimental" : "legacy"
);
return runTestScript(combinedScript, {
envs,
request,
response: effectiveResponse,
experimentalScriptingSandbox,
hoppFetchHook,
});
}
)
)
),
@ -160,7 +178,8 @@ export const getTestScriptParams = (
reqRunnerRes: RequestRunnerResponse,
request: HoppRESTRequest,
envs: HoppEnvs,
legacySandbox: boolean
legacySandbox: boolean,
inheritedTestScripts: string[] = []
) => {
const testScriptParams: TestScriptParams = {
request,
@ -173,6 +192,7 @@ export const getTestScriptParams = (
},
envs,
legacySandbox,
inheritedTestScripts,
};
return testScriptParams;
};

View file

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

View file

@ -841,6 +841,7 @@
"authorization": "The authorization header will be automatically generated when you send the request.",
"collection_properties_authorization": " This authorization will be set for every request in this collection.",
"collection_properties_header": "This header will be set for every request in this collection.",
"collection_properties_scripts": "These scripts will run for every request in this collection. Pre-request scripts run before the request, test scripts run after the response.",
"generate_documentation_first": "Generate documentation first",
"network_fail": "Unable to reach the API endpoint. Check your network connection or select a different Interceptor and try again.",
"offline": "You're using Hoppscotch offline. Updates will sync when you're online, based on workspace settings.",
@ -1256,6 +1257,12 @@
"saved": "Response saved",
"invalid_name": "Please provide a name for the response"
},
"script": {
"inheriting": "Inheriting scripts from",
"inheriting_from_count": "Inheriting from {count} collection | Inheriting from {count} collections",
"inherited_scripts": "Inherited Scripts",
"view_inherited": "View inherited scripts"
},
"settings": {
"accent_color": "Accent color",
"account": "Account",
@ -1781,6 +1788,7 @@
"parameters": "Parameters",
"post_request_script": "Post-request Script",
"pre_request_script": "Pre-request Script",
"scripts": "Scripts",
"queries": "Queries",
"query": "Query",
"schema": "Schema",

View file

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

View file

@ -26,9 +26,11 @@ const props = withDefaults(
defineProps<{
modelValue: string
type: "pre-request" | "post-request"
readOnly?: boolean
}>(),
{
modelValue: "",
readOnly: false,
}
)
@ -40,12 +42,15 @@ const emit = defineEmits<{
const editorModel = ref<monaco.editor.ITextModel | null>(null)
const MONACO_EDITOR_OPTIONS: Readonly<monaco.editor.IStandaloneEditorConstructionOptions> =
{
automaticLayout: true,
formatOnType: true,
formatOnPaste: true,
}
const MONACO_EDITOR_OPTIONS = computed<
Readonly<monaco.editor.IStandaloneEditorConstructionOptions>
>(() => ({
automaticLayout: true,
formatOnType: !props.readOnly,
formatOnPaste: !props.readOnly,
readOnly: props.readOnly,
domReadOnly: props.readOnly,
}))
// Static imports: import X from "URL"
const staticImportRegex =
@ -237,3 +242,27 @@ const monacoEditorTheme = computed(() =>
["dark", "black"].includes(theme.value) ? "vs-dark" : "vs"
)
</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
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
v-if="showDetails"
:id="'details'"
@ -138,11 +215,17 @@
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { computed, reactive, ref, watch } from "vue"
import { refAutoReset, useVModel } from "@vueuse/core"
import { clone } from "lodash-es"
import { useCodemirror } from "@composables/codemirror"
import { useI18n } from "@composables/i18n"
import { useSetting } from "~/composables/settings"
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 { useService } from "dioc/vue"
@ -154,6 +237,7 @@ import {
HoppRESTHeaders,
GQLHeader,
} from "@hoppscotch/data"
import { hasActualScript } from "~/helpers/scripting"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { PersistenceService } from "~/services/persistence"
@ -206,10 +290,14 @@ const editableCollection = ref<{
headers: HoppCollectionHeaders
auth: HoppCollectionAuth
variables: HoppCollectionVariable[]
preRequestScript: string
testScript: string
}>({
headers: [],
auth: { authType: "inherit", authActive: false },
variables: [],
preRequestScript: "",
testScript: "",
})
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
@ -217,9 +305,65 @@ const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
1000
)
const activeTab = useVModel(props, "modelValue", emit)
const activeScriptsTab = ref<"pre-request" | "test-script">("pre-request")
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 (
updated: typeof editableCollection.value
) => {
@ -258,23 +402,34 @@ const enforceTabAccessRules = () => {
["headers", "authorization"].includes(activeTab.value)
)
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 = () => {
activeScriptsTab.value = "pre-request"
editableCollection.value = {
auth: clone(props.editingProperties.collection!.auth as HoppCollectionAuth),
headers: clone(
props.editingProperties.collection!.headers as HoppCollectionHeaders
),
variables: clone(props.editingProperties.collection!.variables || []),
preRequestScript:
props.editingProperties.collection!.preRequestScript || "",
testScript: props.editingProperties.collection!.testScript || "",
}
}
const resetEditableCollection = () => {
activeScriptsTab.value = "pre-request"
editableCollection.value = {
headers: [],
auth: { authType: "inherit", authActive: false },
variables: [],
preRequestScript: "",
testScript: "",
}
}

View file

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

View file

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

View file

@ -906,6 +906,8 @@ const addNewRootCollection = async (name: string) => {
},
variables: [],
description: "",
preRequestScript: "",
testScript: "",
})
)
@ -3254,6 +3256,7 @@ const editProperties = async (payload: {
},
headers: [],
variables: [],
scripts: [],
}
if (parentIndex) {
@ -3307,16 +3310,19 @@ const editProperties = async (payload: {
description: null as string | null,
folders: null,
requests: null,
preRequestScript: "",
testScript: "",
}
if (parentIndex) {
const { auth, headers, variables } =
const { auth, headers, variables, scripts } =
teamCollectionService.cascadeParentCollectionForProperties(parentIndex)
inheritedProperties = {
auth,
headers,
variables,
scripts,
}
}
@ -3337,6 +3343,8 @@ const editProperties = async (payload: {
headers: data.headers,
variables: collectionVariables,
description: data.description,
preRequestScript: data.preRequestScript ?? "",
testScript: data.testScript ?? "",
}
coll = {
@ -3448,6 +3456,8 @@ const setCollectionProperties = (newCollection: {
headers: collection.headers ?? [],
variables: collection.variables ?? [],
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

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
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">
{{ t("preRequest.javascript_code") }}
</label>
<div class="flex items-center gap-2">
<label class="truncate font-semibold text-secondaryLight">
{{ 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">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
@ -76,6 +93,12 @@
</div>
</div>
</div>
<HttpInheritedScriptsModal
:show="showInheritedModal"
:scripts="inheritedScripts"
script-type="preRequestScript"
@close="showInheritedModal = false"
/>
<AiexperimentsModifyPreRequestModal
v-if="isModifyPreRequestModalOpen && currentRequest"
:current-script="preRequestScript"
@ -101,9 +124,12 @@ import { useReadonlyStream } from "~/composables/stream"
import { invokeAction } from "~/helpers/actions"
import completer from "~/helpers/editor/completion/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 { platform } from "~/platform"
import { RESTTabService } from "~/services/tab/rest"
import IconFileSymlink from "~icons/lucide/file-symlink"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconSparkles from "~icons/lucide/sparkles"
import IconTrash2 from "~icons/lucide/trash-2"
@ -114,6 +140,7 @@ const t = useI18n()
const props = defineProps<{
modelValue: string
isActive?: boolean
inheritedProperties?: HoppInheritedProperty
}>()
const emit = defineEmits<{
(e: "update:modelValue", value: string): void
@ -121,6 +148,16 @@ const emit = defineEmits<{
const preRequestScript = useVModel(props, "modelValue", emit)
const showInheritedModal = ref(false)
const inheritedScripts = computed(() => {
return (
props.inheritedProperties?.scripts?.filter((script) =>
hasActualScript(script.preRequestScript)
) ?? []
)
})
const preRequestEditor = ref<any | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpPreRequest")

View file

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

View file

@ -3,9 +3,26 @@
<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"
>
<label class="truncate font-semibold text-secondaryLight">
{{ t("test.javascript_code") }}
</label>
<div class="flex items-center gap-2">
<label class="truncate font-semibold text-secondaryLight">
{{ 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">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
@ -76,6 +93,12 @@
</div>
</div>
</div>
<HttpInheritedScriptsModal
:show="showInheritedModal"
:scripts="inheritedScripts"
script-type="testScript"
@close="showInheritedModal = false"
/>
<AiexperimentsModifyTestScriptModal
v-if="isModifyTestScriptModalOpen && currentRequest"
:current-script="testScript"
@ -99,10 +122,13 @@ import { useReadonlyStream } from "~/composables/stream"
import { invokeAction } from "~/helpers/actions"
import completer from "~/helpers/editor/completion/testScript"
import linter from "~/helpers/editor/linting/testScript"
import { hasActualScript } from "~/helpers/scripting"
import testSnippets from "~/helpers/testSnippets"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { toggleNestedSetting } from "~/newstore/settings"
import { platform } from "~/platform"
import { RESTTabService } from "~/services/tab/rest"
import IconFileSymlink from "~icons/lucide/file-symlink"
import IconHelpCircle from "~icons/lucide/help-circle"
import IconSparkles from "~icons/lucide/sparkles"
import IconTrash2 from "~icons/lucide/trash-2"
@ -113,9 +139,22 @@ const t = useI18n()
const props = defineProps<{
modelValue: string
isActive?: boolean
inheritedProperties?: HoppInheritedProperty
}>()
const emit = defineEmits(["update:modelValue"])
const testScript = useVModel(props, "modelValue", emit)
const showInheritedModal = ref(false)
const inheritedScripts = computed(() => {
return (
props.inheritedProperties?.scripts?.filter((script) =>
hasActualScript(script.testScript)
) ?? []
)
})
const testScriptEditor = ref<any | null>(null)
const WRAP_LINES = useNestedSetting("WRAP_LINES", "httpTest")

View file

@ -249,6 +249,11 @@ const runTests = async () => {
)
let resolvedCollection: HoppCollection = collection.value
// Scripts declared on ancestors above the selected run-start node must be
// seeded into the runner so partial-scope runs still honor the documented
// Root Parent Child Request inheritance chain.
let ancestorPreRequestScripts: string[] = []
let ancestorTestScripts: string[] = []
if (!isPersonalWorkspace) {
const requestAuth = tab.value.document.inheritedProperties?.auth
@ -270,6 +275,19 @@ const runTests = async () => {
tab.value.document.inheritedProperties?.variables ?? []
)
// Team cascade includes the selected node itself in its scripts array;
// drop it here because runTestCollection will cascade that node's scripts
// as part of the normal tree walk, and we must not double-run them.
const inheritedScripts = (
tab.value.document.inheritedProperties?.scripts ?? []
).filter((s) => s.parentID !== collectionID)
ancestorPreRequestScripts = inheritedScripts
.map((s) => s.preRequestScript)
.filter((s) => s && s.trim().length > 0)
ancestorTestScripts = inheritedScripts
.map((s) => s.testScript)
.filter((s) => s && s.trim().length > 0)
resolvedCollection = {
...collection.value,
auth: requestAuth,
@ -277,12 +295,23 @@ const runTests = async () => {
variables: parentVariables,
}
} else {
const { auth, headers, variables } = collectionInheritedProps ?? {
const {
auth,
headers,
variables,
ancestorPreRequestScripts: preAncestors,
ancestorTestScripts: testAncestors,
} = collectionInheritedProps ?? {
auth: { authActive: true, authType: "none" },
headers: [],
variables: [],
ancestorPreRequestScripts: [],
ancestorTestScripts: [],
}
ancestorPreRequestScripts = preAncestors
ancestorTestScripts = testAncestors
resolvedCollection = {
...collection.value,
auth,
@ -292,10 +321,16 @@ const runTests = async () => {
}
testRunnerStopRef.value = false // when testRunnerStopRef is false, the test runner will start running
testRunnerService.runTests(tab, resolvedCollection, {
...testRunnerConfig.value,
stopRef: testRunnerStopRef,
})
testRunnerService.runTests(
tab,
resolvedCollection,
{
...testRunnerConfig.value,
stopRef: testRunnerStopRef,
},
ancestorPreRequestScripts,
ancestorTestScripts
)
}
const stopTests = () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -550,6 +550,36 @@ const getHoppScripts = (
return { preRequestScript, testScript }
}
/**
* Extracts pre-request and test scripts from a Postman ItemGroup (collection/folder)
* Postman collections and folders can have their own scripts that run before/after all requests
*/
const getHoppCollectionScripts = (
ig: ItemGroup<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 = (
docField?: string | DescriptionDefinition
): string | null => {
@ -609,8 +639,13 @@ const getHoppRequest = (
const getHoppFolder = (
ig: ItemGroup<Item>,
importScripts: boolean
): HoppCollection =>
makeCollection({
): HoppCollection => {
const { preRequestScript, testScript } = getHoppCollectionScripts(
ig,
importScripts
)
return makeCollection({
name: ig.name,
folders: pipe(
ig.items.all(),
@ -626,7 +661,10 @@ const getHoppFolder = (
headers: [],
variables: getHoppCollVariables(ig),
description: getCollectionDescription(ig.description),
preRequestScript,
testScript,
})
}
export const getHoppCollections = (
collections: PMCollection[],

View file

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

View file

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

View file

@ -9,14 +9,77 @@ export const MODULE_PREFIX = "export {};\n" as const
* (non-module context) or when exporting collections.
*/
export const stripModulePrefix = (script: string): string => {
return script.startsWith(MODULE_PREFIX)
? script.slice(MODULE_PREFIX.length)
: script
if (script.startsWith(MODULE_PREFIX)) {
return script.slice(MODULE_PREFIX.length)
}
if (script.startsWith("export {};")) {
return script.slice("export {};".length)
}
return script
}
/**
* Regex for stripping the JSON-serialized module prefix (`export {};\\n`)
* from scripts during collection exports.
* Note: This matches the literal backslash-n (`\\n`), not an actual newline character.
* Anchored to JSON value-opening delimiters so it only matches inside JSON
* string values during collection export, not inside script source. Matches
* both `export {};\\n` and `export {};` (`\\n` is the literal backslash-n
* pair, not a newline).
*/
export const MODULE_PREFIX_REGEX_JSON_SERIALIZED = /export \{\};\\n/g
export const MODULE_PREFIX_REGEX_JSON_SERIALIZED =
/(?<=:\s*")export \{\};(?:\\n)?/g
export type CombineScriptsTarget = "experimental" | "legacy"
const wrapScript = (script: string, target: CombineScriptsTarget): string => {
const stripped = stripModulePrefix(script.trim())
if (!stripped) return ""
const asyncKeyword = target === "experimental" ? "async " : ""
return `${asyncKeyword}function() {\n${stripped}\n}`
}
/**
* Combines inherited scripts into a sequential chain. Each script runs in
* its own function for scope isolation.
*
* - `experimental`: `await (async function(){...})();` lines, evaluated in
* an async host context so each `await` settles before the next runs.
* - `legacy`: sync `(function(){...}).call(this);` lines. Top-level `await`
* is rejected at parse time.
*/
export const combineScriptsWithIIFE = (
scripts: string[],
target: CombineScriptsTarget = "experimental"
): string => {
const fns = scripts.map((s) => wrapScript(s, target)).filter((s) => s)
if (fns.length === 0) return ""
if (target === "experimental") {
// Wrap the entire awaited chain in try/catch so a top-level throw (or a
// rejected await) surfaces synchronously via the host reporter.
// faraday-cage swallows rejected keepAlive promises and does not await
// afterScriptExecutionHooks, so this is the only reliable channel for
// async-boundary errors to reach the host caller.
//
// The reporter is captured in a const before the try so a user script
// that tampers with `globalThis.__hoppReportScriptExecutionError`
// inside the try body cannot suppress the report. Bootstrap installs
// the property as non-writable and non-configurable for defense in
// depth; the lexical capture makes that redundant but explicit.
const body = fns.map((fn) => `await (${fn})();`).join("\n")
return [
"const __hoppReporter = globalThis.__hoppReportScriptExecutionError;",
"try {",
body,
"} catch (__hoppScriptExecutionError) {",
" __hoppReporter(__hoppScriptExecutionError);",
"}",
].join("\n")
}
// Leading `;` guards against ASI: a prior `})` on the host line would
// otherwise be read as a call against our IIFE expression.
return fns.map((fn) => `;(${fn}).call(this);`).join("\n")
}
// Monaco prepends "export {};\n" to empty scripts — strip before checking.
export const hasActualScript = (script: string | undefined | null): boolean => {
if (!script) return false
return stripModulePrefix(script.trim()).length > 0
}

View file

@ -1,12 +1,9 @@
import * as E from "fp-ts/Either"
import { BehaviorSubject, Subscription } from "rxjs"
import {
HoppCollectionVariable,
HoppRESTAuth,
HoppRESTHeader,
translateToNewRequest,
} from "@hoppscotch/data"
import { HoppCollectionVariable, translateToNewRequest } from "@hoppscotch/data"
import { pull, remove } from "lodash-es"
import { hasActualScript } from "~/helpers/scripting"
import { CollectionDataProps } from "~/helpers/backend/helpers"
import { Subscription as WSubscription } from "wonka"
import { runGQLQuery, runGQLSubscription } from "../backend/GQLClient"
import { TeamCollection } from "./TeamCollection"
@ -1093,14 +1090,16 @@ export default class NewTeamCollectionAdapter {
const variables: HoppInheritedProperty["variables"] = []
if (!folderPath) return { auth, headers, variables }
const scripts: HoppInheritedProperty["scripts"] = []
if (!folderPath) return { auth, headers, variables, scripts }
const path = folderPath.split("/")
// Check if the path is empty or invalid
if (!path || path.length === 0) {
console.error("Invalid path:", folderPath)
return { auth, headers, variables }
return { auth, headers, variables, scripts }
}
// Loop through the path and get the last parent folder with authType other than 'inherit'
@ -1110,20 +1109,12 @@ export default class NewTeamCollectionAdapter {
// Check if parentFolder is undefined or null
if (!parentFolder) {
console.error("Parent folder not found for path:", path)
return { auth, headers, variables }
return { auth, headers, variables, scripts }
}
const data: {
auth: HoppRESTAuth
headers: HoppRESTHeader[]
variables: HoppCollectionVariable[]
} = parentFolder.data
const data: Partial<CollectionDataProps> = parentFolder.data
? JSON.parse(parentFolder.data)
: {
auth: null,
headers: null,
variables: null,
}
: {}
if (!data.auth) {
data.auth = {
@ -1141,6 +1132,8 @@ export default class NewTeamCollectionAdapter {
const parentFolderAuth = data.auth
const parentFolderHeaders = data.headers
const parentFolderVariables = data.variables
const parentFolderPreRequestScript = data.preRequestScript ?? ""
const parentFolderTestScript = data.testScript ?? ""
if (
parentFolderAuth?.authType === "inherit" &&
@ -1200,8 +1193,23 @@ export default class NewTeamCollectionAdapter {
),
})
}
// Update scripts
if (
hasActualScript(parentFolderPreRequestScript) ||
hasActualScript(parentFolderTestScript)
) {
const currentPath = [...path.slice(0, i + 1)].join("/")
scripts.push({
parentID: parentFolder.id ?? currentPath,
parentName: parentFolder.title,
preRequestScript: parentFolderPreRequestScript,
testScript: parentFolderTestScript,
})
}
}
return { auth, headers, variables }
return { auth, headers, variables, scripts }
}
}

View file

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

View file

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

View file

@ -11,6 +11,7 @@ import {
GQLHeader,
} from "@hoppscotch/data"
import { cloneDeep } from "lodash-es"
import { hasActualScript } from "~/helpers/scripting"
import { pluck } from "rxjs/operators"
import { resolveSaveContextOnRequestReorder } from "~/helpers/collection/request"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
@ -38,6 +39,8 @@ const defaultRESTCollectionState = {
headers: [],
variables: [],
description: null,
preRequestScript: "",
testScript: "",
}),
],
}
@ -55,6 +58,8 @@ const defaultGraphqlCollectionState = {
headers: [],
variables: [],
description: null,
preRequestScript: "",
testScript: "",
}),
],
}
@ -142,14 +147,16 @@ export function cascadeParentCollectionForProperties(
const variables: HoppInheritedProperty["variables"] = []
if (!folderPath) return { auth, headers, variables }
const scripts: HoppInheritedProperty["scripts"] = []
if (!folderPath) return { auth, headers, variables, scripts }
const path = folderPath.split("/").map((i) => parseInt(i))
// Check if the path is empty or invalid
if (!path || path.length === 0) {
console.error("Invalid path:", folderPath)
return { auth, headers, variables }
return { auth, headers, variables, scripts }
}
// Loop through the path and get the last parent folder with authType other than 'inherit'
@ -162,7 +169,7 @@ export function cascadeParentCollectionForProperties(
// Check if parentFolder is undefined or null
if (!parentFolder) {
console.error("Parent folder not found for path:", path)
return { auth, headers, variables }
return { auth, headers, variables, scripts }
}
const parentFolderAuth = parentFolder.auth as HoppRESTAuth | HoppGQLAuth
@ -223,9 +230,27 @@ export function cascadeParentCollectionForProperties(
),
})
}
// Collect scripts from the collection hierarchy (root to child order)
const parentPreRequestScript = parentFolder.preRequestScript ?? ""
const parentTestScript = parentFolder.testScript ?? ""
if (
hasActualScript(parentPreRequestScript) ||
hasActualScript(parentTestScript)
) {
const currentPath = [...path.slice(0, i + 1)].join("/")
scripts.push({
parentID: parentFolder._ref_id ?? parentFolder.id ?? currentPath,
parentName: parentFolder.name,
preRequestScript: parentPreRequestScript,
testScript: parentTestScript,
})
}
}
return { auth, headers, variables }
return { auth, headers, variables, scripts }
}
function reorderItems(array: unknown[], from: number, to: number) {
@ -365,6 +390,8 @@ const restCollectionDispatchers = defineDispatchers({
headers: [],
variables: [],
description: null,
preRequestScript: "",
testScript: "",
})
const newState = state
@ -1026,6 +1053,8 @@ const gqlCollectionDispatchers = defineDispatchers({
headers: [],
variables: [],
description: null,
preRequestScript: "",
testScript: "",
})
const newState = state
const indexPaths = path.split("/").map((x) => parseInt(x))
@ -1367,18 +1396,26 @@ export function getRESTCollection(collectionIndex: number) {
return restCollectionStore.value.state[collectionIndex]
}
export type RESTCollectionInheritedProps = {
auth: HoppRESTAuth
headers: HoppRESTHeaders
variables: HoppCollectionVariable[]
// Ancestor scripts for partial-scope runs (root → target's parent).
// Empty when running from the topmost collection.
ancestorPreRequestScripts: string[]
ancestorTestScripts: string[]
}
function computeCollectionInheritedProps(
collection: HoppCollection,
ref_id: string,
type: "my-collections" | "team-collections" = "my-collections",
parentAuth: HoppRESTAuth | null = null,
parentHeaders: HoppRESTHeaders | null = null,
parentVariables: HoppCollectionVariable[] | null = null
): {
auth: HoppRESTAuth
headers: HoppRESTHeaders
variables: HoppCollectionVariable[]
} | null {
parentVariables: HoppCollectionVariable[] | null = null,
parentPreRequestScripts: string[] = [],
parentTestScripts: string[] = []
): RESTCollectionInheritedProps | null {
// Determine the inherited authentication and headers
const inheritedAuth =
collection.auth?.authType === "inherit" && collection.auth.authActive
@ -1406,9 +1443,19 @@ function computeCollectionInheritedProps(
auth: inheritedAuth,
headers: inheritedHeaders,
variables: inheritedVariables,
ancestorPreRequestScripts: parentPreRequestScripts,
ancestorTestScripts: parentTestScripts,
}
}
// Append this collection's scripts before descending so children see them.
const nextPreRequestScripts = hasActualScript(collection.preRequestScript)
? [...parentPreRequestScripts, collection.preRequestScript]
: parentPreRequestScripts
const nextTestScripts = hasActualScript(collection.testScript)
? [...parentTestScripts, collection.testScript]
: parentTestScripts
// Recursively search in folders
for (const folder of collection.folders) {
const result = computeCollectionInheritedProps(
@ -1417,7 +1464,9 @@ function computeCollectionInheritedProps(
type,
inheritedAuth,
inheritedHeaders,
inheritedVariables
inheritedVariables,
nextPreRequestScripts,
nextTestScripts
)
if (result) return result // Return as soon as a match is found
}
@ -1429,11 +1478,7 @@ export function getRESTCollectionInheritedProps(
collectionID: string,
collections: HoppCollection[] = restCollectionStore.value.state,
type: "my-collections" | "team-collections" = "my-collections"
): {
auth: HoppRESTAuth
headers: HoppRESTHeaders
variables: HoppCollectionVariable[]
} | null {
): RESTCollectionInheritedProps | null {
for (const collection of collections) {
const result = computeCollectionInheritedProps(
collection,

View file

@ -53,6 +53,7 @@ import {
translateToNewEnvironmentVariables,
} from "@hoppscotch/data"
import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { hasActualScript } from "~/helpers/scripting"
import {
PublishedDocREST,
PublishedDocsVersion,
@ -151,6 +152,23 @@ const flattenCollection = (
})),
},
],
scripts: [
...(inheritedProperties?.scripts || []),
...(hasActualScript(collection.preRequestScript) ||
hasActualScript(collection.testScript)
? [
{
parentID:
collection.id ||
collection._ref_id ||
`collection-${collection.name}`,
parentName: collection.name,
preRequestScript: collection.preRequestScript || "",
testScript: collection.testScript || "",
},
]
: []),
],
}
if (collection.folders && collection.folders.length > 0) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,11 +11,11 @@ export const V16_SCHEMA = V15_SCHEMA.extend({
const V16_VERSION = defineVersion({
schema: V16_SCHEMA,
initial: false,
up(old: z.infer<typeof V16_SCHEMA>) {
up(old: z.infer<typeof V15_SCHEMA>) {
return {
...old,
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({
schema: V17_SCHEMA,
initial: false,
up(old: z.infer<typeof V17_SCHEMA>) {
up(old: z.infer<typeof V16_SCHEMA>) {
return {
...old,
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
"use strict"
// Exposes a host reporter for the generated experimental `try/catch`
// wrapper in combineScriptsWithIIFE. Locked down (non-writable,
// non-configurable) so user scripts cannot delete or overwrite it to
// suppress error reporting. faraday-cage's `runCode` creates a fresh
// QuickJS runtime per call, so re-definition across calls is not a
// concern — the property lives only for the current run's VM.
Object.defineProperty(globalThis, "__hoppReportScriptExecutionError", {
value: (error) => {
const safe = error && typeof error === "object" ? error : {}
inputs.setScriptExecutionError({
name: typeof safe.name === "string" ? safe.name : "",
message:
typeof safe.message === "string" ? safe.message : String(error),
stack: typeof safe.stack === "string" ? safe.stack : "",
})
},
enumerable: false,
configurable: false,
writable: false,
})
// Sequential test execution promise chain
// Initialize with a resolved promise to start the chain
// Store on globalThis so pm.sendRequest() and test() can both access and modify it

View file

@ -2,6 +2,28 @@
;(inputs) => {
// Keep strict mode scoped to this IIFE to avoid leaking strictness to concatenated/bootstrapped code
"use strict"
// Exposes a host reporter for the generated experimental `try/catch`
// wrapper in combineScriptsWithIIFE. Locked down (non-writable,
// non-configurable) so user scripts cannot delete or overwrite it to
// suppress error reporting. faraday-cage's `runCode` creates a fresh
// QuickJS runtime per call, so re-definition across calls is not a
// concern — the property lives only for the current run's VM.
Object.defineProperty(globalThis, "__hoppReportScriptExecutionError", {
value: (error) => {
const safe = error && typeof error === "object" ? error : {}
inputs.setScriptExecutionError({
name: typeof safe.name === "string" ? safe.name : "",
message:
typeof safe.message === "string" ? safe.message : String(error),
stack: typeof safe.stack === "string" ? safe.stack : "",
})
},
enumerable: false,
configurable: false,
writable: false,
})
globalThis.pw = {
env: {
get: (key) => convertMarkerToValue(inputs.envGet(key)),

View file

@ -386,7 +386,11 @@ const createScriptingModule = (
type: ModuleType,
bootstrapCode: string,
config: ModuleConfig,
captureHook?: { capture?: () => void; bootstrapError?: unknown }
captureHook?: {
capture?: () => void
bootstrapError?: unknown
scriptExecutionError?: { name: string; message: string; stack: string }
}
) => {
return defineCageModule((ctx) => {
// Track test promises for keepAlive (only for post-request scripts)
@ -471,6 +475,31 @@ const createScriptingModule = (
}
}
// Reporter invoked from the generated `catch` block that wraps the
// experimental IIFE chain. This is a synchronous host callback — unlike
// rejected keepAlive promises or async afterScriptExecutionHooks, these
// calls cross the QuickJS boundary before the script returns, so the
// host sees the error without relying on faraday-cage's loop behaviour.
;(inputsObj as Record<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 bootstrapResult = ctx.vm.callFunction(

View file

@ -23,7 +23,11 @@ const executePreRequestOnCage = async (
let finalRequest = request
let finalCookies = cookies
const captureHook: { capture?: () => void; bootstrapError?: unknown } = {}
const captureHook: {
capture?: () => void
bootstrapError?: unknown
scriptExecutionError?: { name: string; message: string; stack: string }
} = {}
const result = await cage.runCode(preRequestScript, [
...defaultModules({
@ -72,6 +76,16 @@ const executePreRequestOnCage = async (
return E.left(`Script execution failed: ${String(result.err)}`)
}
// Check for errors reported via the generated try/catch wrapper.
// faraday-cage's keepAlive loop swallows rejected promises and does not
// await afterScriptExecutionHooks, so async-boundary errors reach us
// only via this synchronous host reporter.
if (captureHook.scriptExecutionError) {
const { name, message } = captureHook.scriptExecutionError
const prefix = name ? `${name}: ` : ""
return E.left(`Script execution failed: ${prefix}${message}`)
}
if (captureHook.capture) {
captureHook.capture()
}

View file

@ -32,7 +32,11 @@ const executeTestOnCage = async (
let finalTestResults = testRunStack
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, [
...defaultModules({
@ -92,6 +96,16 @@ const executeTestOnCage = async (
}
}
// Check for errors reported via the generated try/catch wrapper.
// faraday-cage's keepAlive loop swallows rejected promises and does not
// await afterScriptExecutionHooks, so async-boundary errors reach us
// only via this synchronous host reporter.
if (captureHook.scriptExecutionError) {
const { name, message } = captureHook.scriptExecutionError
const prefix = name ? `${name}: ` : ""
return E.left(`Script execution failed: ${prefix}${message}`)
}
if (captureHook.capture) {
captureHook.capture()
}

View file

@ -53,7 +53,11 @@ const executePreRequestOnCage = async (
let finalRequest = request
let finalCookies = cookies
const captureHook: { capture?: () => void; bootstrapError?: unknown } = {}
const captureHook: {
capture?: () => void
bootstrapError?: unknown
scriptExecutionError?: { name: string; message: string; stack: string }
} = {}
const result = await cage.runCode(preRequestScript, [
...defaultModules({
@ -103,6 +107,16 @@ const executePreRequestOnCage = async (
return E.left(`Script execution failed: ${String(result.err)}`)
}
// Check for errors reported via the generated try/catch wrapper.
// faraday-cage's keepAlive loop swallows rejected promises and does not
// await afterScriptExecutionHooks, so async-boundary errors reach us
// only via this synchronous host reporter.
if (captureHook.scriptExecutionError) {
const { name, message } = captureHook.scriptExecutionError
const prefix = name ? `${name}: ` : ""
return E.left(`Script execution failed: ${prefix}${message}`)
}
if (captureHook.capture) {
captureHook.capture()
}

View file

@ -62,7 +62,11 @@ const executeTestOnCage = async (
let finalCookies = cookies
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, [
...defaultModules({
@ -122,6 +126,16 @@ const executeTestOnCage = async (
await Promise.all(testPromises)
}
// Check for errors reported via the generated try/catch wrapper.
// faraday-cage's keepAlive loop swallows rejected promises and does not
// await afterScriptExecutionHooks, so async-boundary errors reach us
// only via this synchronous host reporter.
if (captureHook.scriptExecutionError) {
const { name, message } = captureHook.scriptExecutionError
const prefix = name ? `${name}: ` : ""
return E.left(`Script execution failed: ${prefix}${message}`)
}
if (captureHook.capture) {
captureHook.capture()
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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