Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/hoppscotch-backend/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "hoppscotch-backend",
"version": "2026.4.0",
"version": "2026.4.1",
"description": "",
"author": "",
"private": true,
Expand Down
2 changes: 1 addition & 1 deletion packages/hoppscotch-cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hoppscotch/cli",
"version": "0.31.1",
"version": "0.31.2",
"description": "A CLI to run Hoppscotch test scripts in CI environments.",
"homepage": "https://hoppscotch.io",
"type": "module",
Expand Down
28 changes: 26 additions & 2 deletions packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,7 @@ describe("hopp test [options] <file_path_or_id>", { timeout: 100000 }, () => {
fs.unlinkSync(junitPath);
}, 600000); // 600 second (10 minute) timeout

test("Inherited collection-level scripts run in order across both sandboxes", async () => {
test("Inherited collection-level scripts run in order on the experimental sandbox (default)", async () => {
const args = `test ${getTestJsonFilePath(
"collection-level-scripts-coll.json",
"collection"
Expand All @@ -549,11 +549,35 @@ describe("hopp test [options] <file_path_or_id>", { timeout: 100000 }, () => {
const defaultResult = await runCLIWithNetworkRetry(args);
if (defaultResult === null) return;
expect(defaultResult.error).toBeNull();
});

// The legacy sandbox uses a non-module evaluator that rejects top-level
// ESM imports at parse time, so it runs against a pruned fixture that
// omits the import-using request.
test("Inherited collection-level scripts run in order on the legacy sandbox", async () => {
const args = `test ${getTestJsonFilePath(
"collection-level-scripts-legacy-coll.json",
"collection"
)} --legacy-sandbox`;

const legacyResult = await runCLIWithNetworkRetry(`${args} --legacy-sandbox`);
const legacyResult = await runCLIWithNetworkRetry(args);
if (legacyResult === null) return;
expect(legacyResult.error).toBeNull();
});

test("Surfaces a SyntaxError when the same import binding appears in multiple scripts in a request's cascade", async () => {
const args = `test ${getTestJsonFilePath(
"collection-level-scripts-duplicate-import-coll.json",
"collection"
)}`;
const { error, stderr } = await runCLI(args);

expect(error).not.toBeNull();
expect(stderr).toContain("PRE_REQUEST_SCRIPT_ERROR");
expect(stderr).toContain(
"'dup' is imported from different sources across scripts in this request's chain"
);
});
});

describe("Test `hopp test <file_path_or_id> --env <file_path_or_id>` command:", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,50 @@
"requestVariables": [],
"responses": {},
"description": null
},
{
"v": "17",
"id": "cl-script-req-with-import",
"name": "request-with-top-level-import",
"method": "GET",
"endpoint": "https://echo.hoppscotch.io",
"params": [],
"headers": [],
"preRequestScript": "import { value } from \"data:text/javascript,export const value = 'esm-import-ok'\";\npw.env.set(\"IMPORTED_VALUE\", value);\npw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->req-with-import\");",
"testScript": "pw.env.set(\"TEST_ORDER\", \"req-with-import\");\npw.test(\"top-level ESM import in pre-request script resolved\", () => {\n pw.expect(pw.env.get(\"IMPORTED_VALUE\")).toBe(\"esm-import-ok\");\n});\npw.test(\"cascade order preserved with import-using request\", () => {\n pw.expect(pw.env.get(\"PRE_ORDER\")).toBe(\"root->target-folder->req-with-import\");\n});",
"auth": {
"authType": "inherit",
"authActive": true
},
"body": {
"contentType": null,
"body": null
},
"requestVariables": [],
"responses": {},
"description": null
},
{
"v": "17",
"id": "cl-script-req-with-test-import",
"name": "request-with-test-script-imports",
"method": "GET",
"endpoint": "https://echo.hoppscotch.io",
"params": [],
"headers": [],
"preRequestScript": "pw.env.set(\"PRE_ORDER\", pw.env.get(\"PRE_ORDER\") + \"->req-with-test-import\");",
"testScript": "import lodash from \"data:text/javascript,export default { pick: (obj, keys) => keys.reduce((acc, k) => (k in obj ? Object.assign(acc, { [k]: obj[k] }) : acc), {}) }\";\nimport axios from \"data:text/javascript,export default { name: 'axios-stub', version: '1.6.0' }\";\nimport { format } from \"data:text/javascript,export const format = (_d, fmt) => fmt.replace('yyyy', '2026').replace('MM', '05').replace('dd', '07')\";\nimport * as ns from \"data:text/javascript,export const a = 1; export const b = 2\";\nimport combo, { tag } from \"data:text/javascript,export default 7; export const tag = 'mixed'\";\nconst picked = lodash.pick({ id: 1, name: \"hopp\", email: \"x@y.z\", extra: \"drop\" }, [\"id\", \"name\", \"email\"]);\npw.env.set(\"TEST_IMPORT_PICKED\", JSON.stringify(picked));\npw.env.set(\"TEST_IMPORT_AXIOS\", axios.name);\npw.env.set(\"TEST_IMPORT_FORMATTED\", format(new Date(), \"yyyy-MM-dd\"));\npw.env.set(\"TEST_IMPORT_NAMESPACE_SUM\", String(ns.a + ns.b));\npw.env.set(\"TEST_IMPORT_MIXED\", String(combo) + \"-\" + tag);\npw.env.set(\"TEST_ORDER\", \"req-with-test-import\");\npw.test(\"test-script default imports resolve\", () => {\n pw.expect(pw.env.get(\"TEST_IMPORT_AXIOS\")).toBe(\"axios-stub\");\n});\npw.test(\"test-script named import resolves\", () => {\n pw.expect(pw.env.get(\"TEST_IMPORT_FORMATTED\")).toBe(\"2026-05-07\");\n});\npw.test(\"test-script namespace import resolves\", () => {\n pw.expect(pw.env.get(\"TEST_IMPORT_NAMESPACE_SUM\")).toBe(\"3\");\n});\npw.test(\"test-script mixed default and named import resolves\", () => {\n pw.expect(pw.env.get(\"TEST_IMPORT_MIXED\")).toBe(\"7-mixed\");\n});\npw.test(\"test-script imports run alongside test logic\", () => {\n pw.expect(pw.env.get(\"TEST_IMPORT_PICKED\")).toBe(JSON.stringify({ id: 1, name: \"hopp\", email: \"x@y.z\" }));\n});",
"auth": {
"authType": "inherit",
"authActive": true
},
"body": {
"contentType": null,
"body": null
},
"requestVariables": [],
"responses": {},
"description": null
}
],
"auth": {
Expand All @@ -80,7 +124,7 @@
"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});",
"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 once per request in target-folder\", () => {\n pw.expect(pw.env.get(\"TARGET_FOLDER_RUN_COUNT\")).toBe(\"4\");\n});",
"auth": {
"authType": "inherit",
"authActive": true
Expand Down Expand Up @@ -110,5 +154,5 @@
},
"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});"
"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\", \"req-with-import->target-folder->root\", \"req-with-test-import->target-folder->root\", \"sibling-req-in-sibling->sibling-folder->root\"].includes(pw.env.get(\"TEST_ORDER\"))).toBe(true);\n});"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"v": 12,
"name": "collection-level-scripts-duplicate-import-coll",
"variables": [],
"description": null,
"folders": [],
"requests": [
{
"v": "17",
"id": "cl-script-dup-req",
"name": "request-with-duplicate-import-binding",
"method": "GET",
"endpoint": "https://echo.hoppscotch.io",
"params": [],
"headers": [],
"preRequestScript": "import dup from \"data:text/javascript,export default 2\";\npw.env.set(\"REQ_BINDING\", String(dup));",
"testScript": "",
"auth": {
"authType": "inherit",
"authActive": true
},
"body": {
"contentType": null,
"body": null
},
"requestVariables": [],
"responses": {},
"description": null
}
],
"auth": {
"authType": "inherit",
"authActive": true
},
"headers": [],
"preRequestScript": "import dup from \"data:text/javascript,export default 1\";\npw.env.set(\"ROOT_BINDING\", String(dup));",
"testScript": ""
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
{
"v": 12,
"name": "collection-level-scripts-legacy-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});"
}
75 changes: 74 additions & 1 deletion packages/hoppscotch-cli/src/__tests__/unit/scripting.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
combineScriptsWithIIFE,
stripModulePrefix,
MODULE_PREFIX,
} from "../../utils/scripting";
} from "@hoppscotch/js-sandbox/scripting";

describe("scripting", () => {
describe("stripModulePrefix", () => {
Expand Down Expand Up @@ -164,5 +164,78 @@ describe("scripting", () => {
);
expect(result).toContain("await (async function() {");
});

test("hoists top-level imports outside the IIFE wrapper", () => {
const script = `import { value } from "data:text/javascript,export const value=1";\npw.env.set("x", value);`;
const result = combineScriptsWithIIFE([script]);

const importIdx = result.indexOf("import { value }");
const tryIdx = result.indexOf("try {");
expect(importIdx).toBeGreaterThanOrEqual(0);
expect(importIdx).toBeLessThan(tryIdx);
expect(result).toContain('pw.env.set("x", value);');
});

test("preserves imports across an inheritance chain", () => {
const root = `import { rootVal } from "data:text/javascript,export const rootVal=1";`;
const folder = `import { folderVal } from "data:text/javascript,export const folderVal=2";`;
const request = `import { reqVal } from "data:text/javascript,export const reqVal=3";\npw.env.set("sum", String(rootVal + folderVal + reqVal));`;
const result = combineScriptsWithIIFE([root, folder, request]);

expect(result).toContain("import { rootVal }");
expect(result).toContain("import { folderVal }");
expect(result).toContain("import { reqVal }");

const tryIdx = result.indexOf("try {");
expect(result.indexOf("import { rootVal }")).toBeLessThan(tryIdx);
expect(result.indexOf("import { folderVal }")).toBeLessThan(tryIdx);
expect(result.indexOf("import { reqVal }")).toBeLessThan(tryIdx);
});

test("dedupes identical imports across scripts to a single emit", () => {
const folder = `import lodash from "data:text/javascript,export default {}";`;
const request = `import lodash from "data:text/javascript,export default {}";`;
const result = combineScriptsWithIIFE([folder, request]);

const importMatches = result.match(/^import lodash from /gm) ?? [];
expect(importMatches).toHaveLength(1);
expect(result).not.toContain("imported from different sources");
});

test("emits a synthetic SyntaxError when same name imports clash across sources", () => {
const folder = `import lodash from "data:text/javascript,export default 'A'";`;
const request = `import lodash from "data:text/javascript,export default 'B'";`;
const result = combineScriptsWithIIFE([folder, request]);

expect(result).toContain(
"'lodash' is imported from different sources across scripts in this request's chain"
);
expect(result).not.toContain("import lodash");
});

test("leaves output unchanged when no scripts use imports", () => {
const result = combineScriptsWithIIFE(["const x = 1;", "const y = 2;"]);
expect(result.startsWith("const __hoppReporter")).toBe(true);
expect(result).not.toContain("import ");
});

test("legacy target preserves original wrapping (no import hoisting)", () => {
const script = `import { value } from "data:text/javascript,export const value=1";`;
const result = combineScriptsWithIIFE([script], "legacy");
expect(result).toContain("import { value }");
expect(result).toMatch(/^;\(function\(\) \{/);
});

test("hoists imports even when the script body uses top-level return", () => {
// IIFE semantics let user scripts early-return; the AST parse must
// permit that or imports stay trapped inside the wrapper.
const script = `import { value } from "data:text/javascript,export const value=1";\nif (!value) return;\npw.env.set("OK", "yes");`;
const result = combineScriptsWithIIFE([script]);
const importIdx = result.indexOf("import { value }");
const tryIdx = result.indexOf("try {");
expect(importIdx).toBeGreaterThanOrEqual(0);
expect(importIdx).toBeLessThan(tryIdx);
expect(result).toContain("if (!value) return;");
});
});
});
2 changes: 1 addition & 1 deletion packages/hoppscotch-cli/src/utils/collections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import {
processRequest,
} from "./request";
import { getTestMetrics } from "./test";
import { filterValidScripts } from "./scripting";
import { filterValidScripts } from "@hoppscotch/js-sandbox/scripting";

const { WARN, FAIL, INFO } = exceptionColors;

Expand Down
3 changes: 0 additions & 3 deletions packages/hoppscotch-cli/src/utils/mutators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ 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
Expand Down
Loading
Loading