From d1fc70d724250a1a94422912688e389519dff685 Mon Sep 17 00:00:00 2001 From: James George <25279263+jamesgeorge007@users.noreply.github.com> Date: Tue, 12 May 2026 11:17:31 +0530 Subject: [PATCH 1/5] fix: preserve script imports and avoid WebKit lookbehind (#6306) --- .../src/__tests__/e2e/commands/test.spec.ts | 28 +- .../collection-level-scripts-coll.json | 48 ++- ...n-level-scripts-duplicate-import-coll.json | 38 ++ .../collection-level-scripts-legacy-coll.json | 114 ++++++ .../src/__tests__/unit/scripting.spec.ts | 75 +++- .../hoppscotch-cli/src/utils/collections.ts | 2 +- packages/hoppscotch-cli/src/utils/mutators.ts | 3 - .../hoppscotch-cli/src/utils/pre-request.ts | 2 +- .../hoppscotch-cli/src/utils/scripting.ts | 71 ---- packages/hoppscotch-cli/src/utils/test.ts | 2 +- .../src/components/MonacoScriptEditor.vue | 2 +- .../src/components/collections/Properties.vue | 2 +- .../src/components/collections/index.vue | 14 +- .../components/http/InheritedScriptsModal.vue | 2 +- .../src/components/http/PreRequestScript.vue | 2 +- .../src/components/http/RequestOptions.vue | 2 +- .../src/components/http/Tests.vue | 2 +- .../src/composables/codemirror.ts | 2 +- .../src/helpers/RequestRunner.ts | 5 +- .../src/helpers/__tests__/scripting.spec.ts | 64 ++++ .../src/helpers/scripting.ts | 85 ----- .../helpers/teams/TeamCollectionAdapter.ts | 2 +- .../src/helpers/teams/TeamsSearch.service.ts | 2 +- .../src/newstore/collections.ts | 2 +- .../src/pages/view/_id/_version.vue | 2 +- .../src/services/team-collection.service.ts | 2 +- .../test-runner/test-runner.service.ts | 2 +- packages/hoppscotch-js-sandbox/package.json | 8 +- packages/hoppscotch-js-sandbox/scripting.d.ts | 1 + .../__tests__/combined/script-imports.spec.ts | 344 ++++++++++++++++++ .../src/node/test-runner/index.ts | 30 +- .../hoppscotch-js-sandbox/src/scripting.ts | 11 + .../src/utils/scripting.ts | 282 ++++++++++++++ .../src/web/test-runner/index.ts | 30 +- packages/hoppscotch-js-sandbox/vite.config.ts | 1 + pnpm-lock.yaml | 3 + 36 files changed, 1068 insertions(+), 219 deletions(-) create mode 100644 packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-duplicate-import-coll.json create mode 100644 packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-legacy-coll.json delete mode 100644 packages/hoppscotch-cli/src/utils/scripting.ts create mode 100644 packages/hoppscotch-common/src/helpers/__tests__/scripting.spec.ts delete mode 100644 packages/hoppscotch-common/src/helpers/scripting.ts create mode 100644 packages/hoppscotch-js-sandbox/scripting.d.ts create mode 100644 packages/hoppscotch-js-sandbox/src/__tests__/combined/script-imports.spec.ts create mode 100644 packages/hoppscotch-js-sandbox/src/scripting.ts create mode 100644 packages/hoppscotch-js-sandbox/src/utils/scripting.ts diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts b/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts index 42f7df625cd..48b80b6652b 100644 --- a/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts +++ b/packages/hoppscotch-cli/src/__tests__/e2e/commands/test.spec.ts @@ -540,7 +540,7 @@ describe("hopp test [options] ", { 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" @@ -549,11 +549,35 @@ describe("hopp test [options] ", { 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 --env ` command:", () => { diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-coll.json index 38bae83959e..e0904941099 100644 --- a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-coll.json +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-coll.json @@ -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": { @@ -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 @@ -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});" } diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-duplicate-import-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-duplicate-import-coll.json new file mode 100644 index 00000000000..ae5ba7fd375 --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-duplicate-import-coll.json @@ -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": "" +} diff --git a/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-legacy-coll.json b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-legacy-coll.json new file mode 100644 index 00000000000..0d53b17598c --- /dev/null +++ b/packages/hoppscotch-cli/src/__tests__/e2e/fixtures/collections/collection-level-scripts-legacy-coll.json @@ -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});" +} diff --git a/packages/hoppscotch-cli/src/__tests__/unit/scripting.spec.ts b/packages/hoppscotch-cli/src/__tests__/unit/scripting.spec.ts index a2aa4d46a89..ad9bedff1f0 100644 --- a/packages/hoppscotch-cli/src/__tests__/unit/scripting.spec.ts +++ b/packages/hoppscotch-cli/src/__tests__/unit/scripting.spec.ts @@ -4,7 +4,7 @@ import { combineScriptsWithIIFE, stripModulePrefix, MODULE_PREFIX, -} from "../../utils/scripting"; +} from "@hoppscotch/js-sandbox/scripting"; describe("scripting", () => { describe("stripModulePrefix", () => { @@ -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;"); + }); }); }); diff --git a/packages/hoppscotch-cli/src/utils/collections.ts b/packages/hoppscotch-cli/src/utils/collections.ts index 313e957ed7b..276d1542c65 100644 --- a/packages/hoppscotch-cli/src/utils/collections.ts +++ b/packages/hoppscotch-cli/src/utils/collections.ts @@ -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; diff --git a/packages/hoppscotch-cli/src/utils/mutators.ts b/packages/hoppscotch-cli/src/utils/mutators.ts index 245486cd174..153e66b24a9 100644 --- a/packages/hoppscotch-cli/src/utils/mutators.ts +++ b/packages/hoppscotch-cli/src/utils/mutators.ts @@ -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 diff --git a/packages/hoppscotch-cli/src/utils/pre-request.ts b/packages/hoppscotch-cli/src/utils/pre-request.ts index 283f3721519..83979862ebb 100644 --- a/packages/hoppscotch-cli/src/utils/pre-request.ts +++ b/packages/hoppscotch-cli/src/utils/pre-request.ts @@ -36,7 +36,7 @@ import { arrayFlatMap, arraySort, tupleToRecord } from "./functions/array"; import { getEffectiveFinalMetaData, getResolvedVariables } from "./getters"; import { stripComments } from "./jsonc"; import { toFormData } from "./mutators"; -import { combineScriptsWithIIFE, filterValidScripts } from "./scripting"; +import { combineScriptsWithIIFE, filterValidScripts } from "@hoppscotch/js-sandbox/scripting"; /** * Runs pre-request-script runner over given request which extracts set ENVs and diff --git a/packages/hoppscotch-cli/src/utils/scripting.ts b/packages/hoppscotch-cli/src/utils/scripting.ts deleted file mode 100644 index 5b04271bf18..00000000000 --- a/packages/hoppscotch-cli/src/utils/scripting.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Module prefix added by Monaco editor for TypeScript module mode. - * Enables IntelliSense and isolates variables across editor instances. - */ -export const MODULE_PREFIX = "export {};\n" as const; - -/** - * Strips `export {};` prefix (with or without newline) from scripts before execution - * (non-module context) or when exporting collections. - */ -export const stripModulePrefix = (script: string): string => { - if (script.startsWith(MODULE_PREFIX)) { - return script.slice(MODULE_PREFIX.length); - } - if (script.startsWith("export {};")) { - return script.slice("export {};".length); - } - return script; -}; - -export type CombineScriptsTarget = "experimental" | "legacy"; - -const wrapScript = (script: string, target: CombineScriptsTarget): string => { - const stripped = stripModulePrefix(script.trim()); - if (!stripped) return ""; - const asyncKeyword = target === "experimental" ? "async " : ""; - return `${asyncKeyword}function() {\n${stripped}\n}`; -}; - -/** - * Combines inherited scripts into a sequential chain. Each script runs in - * its own function for scope isolation. - * - * - `experimental`: `await (async function(){...})();` lines, evaluated in - * an async host context so each `await` settles before the next runs. - * - `legacy`: sync `(function(){...}).call(this);` lines. Top-level `await` - * is rejected at parse time. - */ -export const combineScriptsWithIIFE = ( - scripts: string[], - target: CombineScriptsTarget = "experimental" -): string => { - const fns = scripts.map((s) => wrapScript(s, target)).filter((s) => s); - if (fns.length === 0) return ""; - if (target === "experimental") { - // Wrap the awaited chain in try/catch so top-level throws / rejected - // awaits reach the host reporter; faraday-cage otherwise swallows - // async-boundary errors via its keepAlive loop. - const body = fns.map((fn) => `await (${fn})();`).join("\n"); - return [ - "const __hoppReporter = globalThis.__hoppReportScriptExecutionError;", - "try {", - body, - "} catch (__hoppScriptExecutionError) {", - " __hoppReporter(__hoppScriptExecutionError);", - "}", - ].join("\n"); - } - // Leading `;` guards against ASI: a prior `})` on the host line would - // otherwise be read as a call against our IIFE expression. - return fns.map((fn) => `;(${fn}).call(this);`).join("\n"); -}; - -export const filterValidScripts = ( - scripts: (string | undefined | null)[] -): string[] => - scripts.filter( - (script): script is string => - typeof script === "string" && - stripModulePrefix(script).trim().length > 0 - ); diff --git a/packages/hoppscotch-cli/src/utils/test.ts b/packages/hoppscotch-cli/src/utils/test.ts index d885b85060e..33da6fd2600 100644 --- a/packages/hoppscotch-cli/src/utils/test.ts +++ b/packages/hoppscotch-cli/src/utils/test.ts @@ -18,7 +18,7 @@ import { HoppEnvs } from "../types/request"; import { ExpectResult, TestMetrics, TestRunnerRes } from "../types/response"; import { getDurationInSeconds } from "./getters"; import { createHoppFetchHook } from "./hopp-fetch"; -import { combineScriptsWithIIFE, filterValidScripts } from "./scripting"; +import { combineScriptsWithIIFE, filterValidScripts } from "@hoppscotch/js-sandbox/scripting"; /** * Executes test script and runs testDescriptorParser to generate test-report using diff --git a/packages/hoppscotch-common/src/components/MonacoScriptEditor.vue b/packages/hoppscotch-common/src/components/MonacoScriptEditor.vue index 0c9c15d1bd3..0b1e602cbfe 100644 --- a/packages/hoppscotch-common/src/components/MonacoScriptEditor.vue +++ b/packages/hoppscotch-common/src/components/MonacoScriptEditor.vue @@ -16,7 +16,7 @@ import { v4 as uuidv4 } from "uuid" import { computed, onMounted, onUnmounted, ref } from "vue" import { useColorMode } from "~/composables/theming" -import { MODULE_PREFIX } from "~/helpers/scripting" +import { MODULE_PREFIX } from "@hoppscotch/js-sandbox/scripting" // Import type definitions as raw strings import postRequestTypes from "~/types/post-request.d.ts?raw" diff --git a/packages/hoppscotch-common/src/components/collections/Properties.vue b/packages/hoppscotch-common/src/components/collections/Properties.vue index 9d97acb71f1..5edb531c2d7 100644 --- a/packages/hoppscotch-common/src/components/collections/Properties.vue +++ b/packages/hoppscotch-common/src/components/collections/Properties.vue @@ -237,7 +237,7 @@ import { HoppRESTHeaders, GQLHeader, } from "@hoppscotch/data" -import { hasActualScript } from "~/helpers/scripting" +import { hasActualScript } from "@hoppscotch/js-sandbox/scripting" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" import { PersistenceService } from "~/services/persistence" diff --git a/packages/hoppscotch-common/src/components/collections/index.vue b/packages/hoppscotch-common/src/components/collections/index.vue index 9ef25a8711f..f9387c91fbe 100644 --- a/packages/hoppscotch-common/src/components/collections/index.vue +++ b/packages/hoppscotch-common/src/components/collections/index.vue @@ -305,7 +305,7 @@ import { makeHoppRESTResponseOriginalRequest, } from "@hoppscotch/data" import { useService } from "dioc/vue" -import { MODULE_PREFIX_REGEX_JSON_SERIALIZED } from "~/helpers/scripting" +import { stripJsonSerializedModulePrefix } from "@hoppscotch/js-sandbox/scripting" import * as TE from "fp-ts/TaskEither" import { pipe } from "fp-ts/function" @@ -3158,10 +3158,8 @@ const exportData = async (collection: HoppCollection | TeamCollection) => { const collectionJSON = JSON.stringify(collection, stripRefIdReplacer, 2) // Strip `export {};\n` from `testScript` and `preRequestScript` fields - const cleanedCollectionJSON = collectionJSON.replace( - MODULE_PREFIX_REGEX_JSON_SERIALIZED, - "" - ) + const cleanedCollectionJSON = + stripJsonSerializedModulePrefix(collectionJSON) const name = (collection as HoppCollection).name @@ -3187,10 +3185,8 @@ const exportData = async (collection: HoppCollection | TeamCollection) => { ) // Strip `export {};\n` from `testScript` and `preRequestScript` fields - const cleanedCollectionJSON = collectionJSONString.replace( - MODULE_PREFIX_REGEX_JSON_SERIALIZED, - "" - ) + const cleanedCollectionJSON = + stripJsonSerializedModulePrefix(collectionJSONString) await initializeDownloadCollection( cleanedCollectionJSON, diff --git a/packages/hoppscotch-common/src/components/http/InheritedScriptsModal.vue b/packages/hoppscotch-common/src/components/http/InheritedScriptsModal.vue index 3e3b8c2810d..3f0b348bb97 100644 --- a/packages/hoppscotch-common/src/components/http/InheritedScriptsModal.vue +++ b/packages/hoppscotch-common/src/components/http/InheritedScriptsModal.vue @@ -59,7 +59,7 @@ 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 { stripModulePrefix } from "@hoppscotch/js-sandbox/scripting" import { copyToClipboard } from "~/helpers/utils/clipboard" import IconCheck from "~icons/lucide/check" import IconCopy from "~icons/lucide/copy" diff --git a/packages/hoppscotch-common/src/components/http/PreRequestScript.vue b/packages/hoppscotch-common/src/components/http/PreRequestScript.vue index a94a1c461b0..12fab90da0f 100644 --- a/packages/hoppscotch-common/src/components/http/PreRequestScript.vue +++ b/packages/hoppscotch-common/src/components/http/PreRequestScript.vue @@ -124,7 +124,7 @@ 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 { hasActualScript } from "@hoppscotch/js-sandbox/scripting" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" import { toggleNestedSetting } from "~/newstore/settings" import { platform } from "~/platform" diff --git a/packages/hoppscotch-common/src/components/http/RequestOptions.vue b/packages/hoppscotch-common/src/components/http/RequestOptions.vue index d9ac58a7add..91db5289a97 100644 --- a/packages/hoppscotch-common/src/components/http/RequestOptions.vue +++ b/packages/hoppscotch-common/src/components/http/RequestOptions.vue @@ -104,7 +104,7 @@ import { useVModel } from "@vueuse/core" import { computed } from "vue" import { defineActionHandler } from "~/helpers/actions" -import { hasActualScript } from "~/helpers/scripting" +import { hasActualScript } from "@hoppscotch/js-sandbox/scripting" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" import { AggregateEnvironment } from "~/newstore/environments" diff --git a/packages/hoppscotch-common/src/components/http/Tests.vue b/packages/hoppscotch-common/src/components/http/Tests.vue index f00afa83e80..5ef7852e968 100644 --- a/packages/hoppscotch-common/src/components/http/Tests.vue +++ b/packages/hoppscotch-common/src/components/http/Tests.vue @@ -122,7 +122,7 @@ 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 { hasActualScript } from "@hoppscotch/js-sandbox/scripting" import testSnippets from "~/helpers/testSnippets" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" import { toggleNestedSetting } from "~/newstore/settings" diff --git a/packages/hoppscotch-common/src/composables/codemirror.ts b/packages/hoppscotch-common/src/composables/codemirror.ts index 5bc30c4d80e..11275235574 100644 --- a/packages/hoppscotch-common/src/composables/codemirror.ts +++ b/packages/hoppscotch-common/src/composables/codemirror.ts @@ -49,7 +49,7 @@ import { isJSONContentType } from "@helpers/utils/contenttypes" import { useStreamSubscriber } from "@composables/stream" import { Completer } from "@helpers/editor/completion" import { LinterDefinition } from "@helpers/editor/linting/linter" -import { MODULE_PREFIX } from "@helpers/scripting" +import { MODULE_PREFIX } from "@hoppscotch/js-sandbox/scripting" import { basicSetup, baseTheme, diff --git a/packages/hoppscotch-common/src/helpers/RequestRunner.ts b/packages/hoppscotch-common/src/helpers/RequestRunner.ts index 299d2f832f9..b4aba50cd2f 100644 --- a/packages/hoppscotch-common/src/helpers/RequestRunner.ts +++ b/packages/hoppscotch-common/src/helpers/RequestRunner.ts @@ -27,7 +27,10 @@ import { map } from "fp-ts/Either" import { runPreRequestScript, runTestScript } from "@hoppscotch/js-sandbox/web" import { useSetting } from "~/composables/settings" import { getService } from "~/modules/dioc" -import { combineScriptsWithIIFE, hasActualScript } from "~/helpers/scripting" +import { + combineScriptsWithIIFE, + hasActualScript, +} from "@hoppscotch/js-sandbox/scripting" import { createHoppFetchHook } from "~/helpers/hopp-fetch" import { KernelInterceptorService } from "~/services/kernel-interceptor.service" import { diff --git a/packages/hoppscotch-common/src/helpers/__tests__/scripting.spec.ts b/packages/hoppscotch-common/src/helpers/__tests__/scripting.spec.ts new file mode 100644 index 00000000000..b24eacf53fb --- /dev/null +++ b/packages/hoppscotch-common/src/helpers/__tests__/scripting.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "vitest" + +import { + hasActualScript, + stripJsonSerializedModulePrefix, +} from "@hoppscotch/js-sandbox/scripting" + +describe("hasActualScript", () => { + test("returns false for null, undefined, or empty input", () => { + expect(hasActualScript(null)).toBe(false) + expect(hasActualScript(undefined)).toBe(false) + expect(hasActualScript("")).toBe(false) + }) + + test("returns false for whitespace-only input", () => { + expect(hasActualScript(" ")).toBe(false) + expect(hasActualScript("\n\t \n")).toBe(false) + }) + + test("returns false when only the Monaco module prefix is present", () => { + expect(hasActualScript("export {};\n")).toBe(false) + expect(hasActualScript("export {};")).toBe(false) + expect(hasActualScript("export {};\n ")).toBe(false) + }) + + test("returns true when script body exists after the prefix", () => { + expect(hasActualScript("export {};\nconst x = 1;")).toBe(true) + expect(hasActualScript("const x = 1;")).toBe(true) + }) +}) + +describe("stripJsonSerializedModulePrefix", () => { + test("strips `export {};\\n` from JSON string values", () => { + const json = JSON.stringify({ + preRequestScript: "export {};\nconst x = 1;", + testScript: "export {};const y = 2;", + }) + const out = stripJsonSerializedModulePrefix(json) + const parsed = JSON.parse(out) as Record + expect(parsed.preRequestScript).toBe("const x = 1;") + expect(parsed.testScript).toBe("const y = 2;") + }) + + test("leaves values without the prefix untouched", () => { + const json = JSON.stringify({ + name: "request name", + preRequestScript: "const z = 3;", + }) + expect(stripJsonSerializedModulePrefix(json)).toBe(json) + }) + + test("preserves spacing between key delimiter and the stripped value", () => { + const json = `{"preRequestScript": "export {};const a = 1;"}` + const out = stripJsonSerializedModulePrefix(json) + expect(out).toBe(`{"preRequestScript": "const a = 1;"}`) + }) + + test("does not strip when the prefix appears mid-value", () => { + const json = JSON.stringify({ + preRequestScript: "const a = 1;\nexport {};\nconst b = 2;", + }) + expect(stripJsonSerializedModulePrefix(json)).toBe(json) + }) +}) diff --git a/packages/hoppscotch-common/src/helpers/scripting.ts b/packages/hoppscotch-common/src/helpers/scripting.ts deleted file mode 100644 index 39b5f4c1120..00000000000 --- a/packages/hoppscotch-common/src/helpers/scripting.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * 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 {};\n` prefix from scripts before legacy sandbox 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 -} - -/** - * 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 = - /(?<=:\s*")export \{\};(?:\\n)?/g - -export type CombineScriptsTarget = "experimental" | "legacy" - -const wrapScript = (script: string, target: CombineScriptsTarget): string => { - const stripped = stripModulePrefix(script.trim()) - if (!stripped) return "" - const asyncKeyword = target === "experimental" ? "async " : "" - return `${asyncKeyword}function() {\n${stripped}\n}` -} - -/** - * Combines inherited scripts into a sequential chain. Each script runs in - * its own function for scope isolation. - * - * - `experimental`: `await (async function(){...})();` lines, evaluated in - * an async host context so each `await` settles before the next runs. - * - `legacy`: sync `(function(){...}).call(this);` lines. Top-level `await` - * is rejected at parse time. - */ -export const combineScriptsWithIIFE = ( - scripts: string[], - target: CombineScriptsTarget = "experimental" -): string => { - const fns = scripts.map((s) => wrapScript(s, target)).filter((s) => s) - if (fns.length === 0) return "" - if (target === "experimental") { - // Wrap the entire awaited chain in try/catch so a top-level throw (or a - // rejected await) surfaces synchronously via the host reporter. - // faraday-cage swallows rejected keepAlive promises and does not await - // afterScriptExecutionHooks, so this is the only reliable channel for - // async-boundary errors to reach the host caller. - // - // The reporter is captured in a const before the try so a user script - // that tampers with `globalThis.__hoppReportScriptExecutionError` - // inside the try body cannot suppress the report. Bootstrap installs - // the property as non-writable and non-configurable for defense in - // depth; the lexical capture makes that redundant but explicit. - const body = fns.map((fn) => `await (${fn})();`).join("\n") - return [ - "const __hoppReporter = globalThis.__hoppReportScriptExecutionError;", - "try {", - body, - "} catch (__hoppScriptExecutionError) {", - " __hoppReporter(__hoppScriptExecutionError);", - "}", - ].join("\n") - } - // Leading `;` guards against ASI: a prior `})` on the host line would - // otherwise be read as a call against our IIFE expression. - return fns.map((fn) => `;(${fn}).call(this);`).join("\n") -} - -// Monaco prepends "export {};\n" to empty scripts — strip before checking. -export const hasActualScript = (script: string | undefined | null): boolean => { - if (!script) return false - return stripModulePrefix(script.trim()).length > 0 -} diff --git a/packages/hoppscotch-common/src/helpers/teams/TeamCollectionAdapter.ts b/packages/hoppscotch-common/src/helpers/teams/TeamCollectionAdapter.ts index 0850fbea532..94ff86993ce 100644 --- a/packages/hoppscotch-common/src/helpers/teams/TeamCollectionAdapter.ts +++ b/packages/hoppscotch-common/src/helpers/teams/TeamCollectionAdapter.ts @@ -2,7 +2,7 @@ import * as E from "fp-ts/Either" import { BehaviorSubject, Subscription } from "rxjs" import { HoppCollectionVariable, translateToNewRequest } from "@hoppscotch/data" import { pull, remove } from "lodash-es" -import { hasActualScript } from "~/helpers/scripting" +import { hasActualScript } from "@hoppscotch/js-sandbox/scripting" import { CollectionDataProps } from "~/helpers/backend/helpers" import { Subscription as WSubscription } from "wonka" import { runGQLQuery, runGQLSubscription } from "../backend/GQLClient" diff --git a/packages/hoppscotch-common/src/helpers/teams/TeamsSearch.service.ts b/packages/hoppscotch-common/src/helpers/teams/TeamsSearch.service.ts index d7fa53df851..9a95ffc59dd 100644 --- a/packages/hoppscotch-common/src/helpers/teams/TeamsSearch.service.ts +++ b/packages/hoppscotch-common/src/helpers/teams/TeamsSearch.service.ts @@ -8,7 +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 { hasActualScript } from "@hoppscotch/js-sandbox/scripting" import { platform } from "~/platform" import { HoppInheritedProperty } from "../types/HoppInheritedProperties" diff --git a/packages/hoppscotch-common/src/newstore/collections.ts b/packages/hoppscotch-common/src/newstore/collections.ts index f33567a78b7..1db29f39d9b 100644 --- a/packages/hoppscotch-common/src/newstore/collections.ts +++ b/packages/hoppscotch-common/src/newstore/collections.ts @@ -11,7 +11,7 @@ import { GQLHeader, } from "@hoppscotch/data" import { cloneDeep } from "lodash-es" -import { hasActualScript } from "~/helpers/scripting" +import { hasActualScript } from "@hoppscotch/js-sandbox/scripting" import { pluck } from "rxjs/operators" import { resolveSaveContextOnRequestReorder } from "~/helpers/collection/request" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" diff --git a/packages/hoppscotch-common/src/pages/view/_id/_version.vue b/packages/hoppscotch-common/src/pages/view/_id/_version.vue index cb6aa9441ea..ea93548ebfc 100644 --- a/packages/hoppscotch-common/src/pages/view/_id/_version.vue +++ b/packages/hoppscotch-common/src/pages/view/_id/_version.vue @@ -53,7 +53,7 @@ import { translateToNewEnvironmentVariables, } from "@hoppscotch/data" import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties" -import { hasActualScript } from "~/helpers/scripting" +import { hasActualScript } from "@hoppscotch/js-sandbox/scripting" import { PublishedDocREST, PublishedDocsVersion, diff --git a/packages/hoppscotch-common/src/services/team-collection.service.ts b/packages/hoppscotch-common/src/services/team-collection.service.ts index d6decf27bbd..d4d0833fe2f 100644 --- a/packages/hoppscotch-common/src/services/team-collection.service.ts +++ b/packages/hoppscotch-common/src/services/team-collection.service.ts @@ -29,7 +29,7 @@ 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 { hasActualScript } from "@hoppscotch/js-sandbox/scripting" import { CollectionDataProps } from "~/helpers/backend/helpers" export const TEAMS_BACKEND_PAGE_SIZE = 10 diff --git a/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts b/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts index 2a2033af741..d2e8f6052fd 100644 --- a/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts +++ b/packages/hoppscotch-common/src/services/test-runner/test-runner.service.ts @@ -5,7 +5,7 @@ import { HoppRESTRequest, } from "@hoppscotch/data" import { Service } from "dioc" -import { hasActualScript } from "~/helpers/scripting" +import { hasActualScript } from "@hoppscotch/js-sandbox/scripting" import * as E from "fp-ts/Either" import { cloneDeep } from "lodash-es" import { nextTick, Ref } from "vue" diff --git a/packages/hoppscotch-js-sandbox/package.json b/packages/hoppscotch-js-sandbox/package.json index 474b3b2df44..be4b439e4cc 100644 --- a/packages/hoppscotch-js-sandbox/package.json +++ b/packages/hoppscotch-js-sandbox/package.json @@ -5,7 +5,7 @@ "type": "module", "files": [ "dist", - "index.d.ts" + "*.d.ts" ], "exports": { ".": { @@ -20,6 +20,11 @@ "types": "./dist/node.d.ts", "import": "./dist/node.js", "require": "./dist/node.cjs" + }, + "./scripting": { + "types": "./dist/scripting.d.ts", + "import": "./dist/scripting.js", + "require": "./dist/scripting.cjs" } }, "types": "./index.d.ts", @@ -52,6 +57,7 @@ "dependencies": { "@hoppscotch/data": "workspace:^", "@types/lodash-es": "4.17.12", + "acorn": "8.16.0", "chai": "6.2.2", "faraday-cage": "0.1.0", "fp-ts": "2.16.11", diff --git a/packages/hoppscotch-js-sandbox/scripting.d.ts b/packages/hoppscotch-js-sandbox/scripting.d.ts new file mode 100644 index 00000000000..876119fd820 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/scripting.d.ts @@ -0,0 +1 @@ +export * from "./dist/scripting" diff --git a/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-imports.spec.ts b/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-imports.spec.ts new file mode 100644 index 00000000000..07eb6137e5c --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/__tests__/combined/script-imports.spec.ts @@ -0,0 +1,344 @@ +import * as E from "fp-ts/Either" +import { describe, expect, test } from "vitest" +import { combineScriptsWithIIFE } from "~/utils/scripting" +import { runPreRequest, runTestAndGetEnvs } from "~/utils/test-helpers" + +const envs = { global: [], selected: [] } + +describe("script ESM imports — pre-request scripts", () => { + test("named import binding is reachable from the script body", async () => { + const script = combineScriptsWithIIFE([ + `import { value } from "data:text/javascript,export const value = 'esm-ok'";\npw.env.set("IMPORTED_VALUE", value);`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeRight() + if (E.isRight(result)) { + const updated = result.right.selected.find( + (v) => v.key === "IMPORTED_VALUE" + ) + expect(updated?.currentValue).toBe("esm-ok") + } + }) + + test("default import binding is reachable from the script body", async () => { + const script = combineScriptsWithIIFE([ + `import obj from "data:text/javascript,export default { greet: 'hi' }";\npw.env.set("GREETING", obj.greet);`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeRight() + if (E.isRight(result)) { + const updated = result.right.selected.find((v) => v.key === "GREETING") + expect(updated?.currentValue).toBe("hi") + } + }) + + test("multiple imports across cascade reach the consuming script body", async () => { + const script = combineScriptsWithIIFE([ + `import { rootVal } from "data:text/javascript,export const rootVal = 1";`, + `import { folderVal } from "data:text/javascript,export const folderVal = 2";`, + `import { reqVal } from "data:text/javascript,export const reqVal = 3";\npw.env.set("SUM", String(rootVal + folderVal + reqVal));`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeRight() + if (E.isRight(result)) { + const updated = result.right.selected.find((v) => v.key === "SUM") + expect(updated?.currentValue).toBe("6") + } + }) + + test("namespace import binding is reachable from the script body", async () => { + const script = combineScriptsWithIIFE([ + `import * as ns from "data:text/javascript,export const a = 1; export const b = 2";\npw.env.set("NS_SUM", String(ns.a + ns.b));`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeRight() + if (E.isRight(result)) { + const updated = result.right.selected.find((v) => v.key === "NS_SUM") + expect(updated?.currentValue).toBe("3") + } + }) + + test("mixed default + named imports resolve from one source", async () => { + const script = combineScriptsWithIIFE([ + `import obj, { extra } from "data:text/javascript,export default { v: 7 }; export const extra = 5";\npw.env.set("MIXED", String(obj.v + extra));`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeRight() + if (E.isRight(result)) { + const updated = result.right.selected.find((v) => v.key === "MIXED") + expect(updated?.currentValue).toBe("12") + } + }) + + test("identical imports across scripts are deduped to a single emit", async () => { + const sharedSource = `data:text/javascript,export default 'shared'` + const script = combineScriptsWithIIFE([ + `import shared from "${sharedSource}";\npw.env.set("FROM_FIRST", shared);`, + `import shared from "${sharedSource}";\npw.env.set("FROM_SECOND", shared);`, + ]) + + const importMatches = script.match(/^import shared from /gm) ?? [] + expect(importMatches).toHaveLength(1) + + const result = await runPreRequest(script, envs)() + expect(result).toBeRight() + if (E.isRight(result)) { + expect( + result.right.selected.find((v) => v.key === "FROM_FIRST")?.currentValue + ).toBe("shared") + expect( + result.right.selected.find((v) => v.key === "FROM_SECOND")?.currentValue + ).toBe("shared") + } + }) + + // Dedup is by literal string match; cosmetic differences (whitespace, quote + // style, alias-renames) from the same source are NOT deduped and surface as + // a duplicate-declaration error from the module evaluator. + test("cosmetically different but semantically identical imports are NOT deduped", async () => { + const script = combineScriptsWithIIFE([ + `import dup from "data:text/javascript,export default 1";`, + `import dup from "data:text/javascript,export default 1";`, + ]) + + const importMatches = script.match(/^import dup\s+from /gm) ?? [] + expect(importMatches).toHaveLength(2) + }) + + // Mixing import shapes for the same local name from the same source + // (e.g. `import * as foo` + `import { foo }`) emits both lines. The friendly + // pre-cage check only fires on cross-source collisions, so this surfaces as + // a duplicate-declaration error from the module evaluator. + test("namespace + named imports for the same local name emit both lines", async () => { + const sharedSource = `data:text/javascript,export const foo = 1` + const script = combineScriptsWithIIFE([ + `import * as foo from "${sharedSource}";`, + `import { foo } from "${sharedSource}";`, + ]) + + const importMatches = script.match(/^import .*foo.* from /gm) ?? [] + expect(importMatches).toHaveLength(2) + }) + + test("same-name imports from different sources surface a SyntaxError", async () => { + const script = combineScriptsWithIIFE([ + `import dup from "data:text/javascript,export default 1";`, + `import dup from "data:text/javascript,export default 2";\npw.env.set("SHOULD_NOT_RUN", "yes");`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeLeft() + if (E.isLeft(result)) { + expect(result.left).toMatch( + /'dup' is imported from different sources across scripts in this request's chain/ + ) + } + }) + + test("parse failure surfaces the original Acorn message, not a misleading wrapper error", async () => { + // Pre-fix: wrapper would re-evaluate the raw script inside an IIFE + // and surface a misleading "import declarations may only appear at + // top level" error instead of the actual syntax error. + const script = combineScriptsWithIIFE([ + `import { foo } from "data:text/javascript,export const foo = 1";\nconst x = ;`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeLeft() + if (E.isLeft(result)) { + expect(result.left).toMatch(/\[Hoppscotch\] Script failed to parse/) + expect(result.left).not.toMatch( + /import declarations may only appear at top level/ + ) + } + }) + + test("import-only cascade emits clean output without an empty try/catch", () => { + // Import-only cascade: no awaited bodies → no try/catch needed. + const script = combineScriptsWithIIFE([ + `import "data:text/javascript,globalThis.__a = 1";`, + `import "data:text/javascript,globalThis.__b = 2";`, + ]) + + expect(script).not.toContain("try {") + expect(script).not.toContain("__hoppReporter") + expect(script).toContain('import "data:text/javascript,globalThis.__a = 1"') + expect(script).toContain('import "data:text/javascript,globalThis.__b = 2"') + }) + + test("import-only cascade with cross-source clash still surfaces the friendly conflict error", async () => { + // The import-only short-circuit must not bypass conflict detection. + const script = combineScriptsWithIIFE([ + `import dup from "data:text/javascript,export default 1";`, + `import dup from "data:text/javascript,export default 2";`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeLeft() + if (E.isLeft(result)) { + expect(result.left).toMatch( + /'dup' is imported from different sources across scripts in this request's chain/ + ) + } + }) + + test("user import binding to a wrapper-reserved name surfaces a friendly error", async () => { + const script = combineScriptsWithIIFE([ + `import __hoppReporter from "data:text/javascript,export default {}";\npw.env.set("SHOULD_NOT_RUN", "yes");`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeLeft() + if (E.isLeft(result)) { + expect(result.left).toMatch( + /'__hoppReporter' is reserved by Hoppscotch's script wrapper/ + ) + } + }) + + test("user import binding 'globalThis' is also reserved", async () => { + // Wrapper reads `globalThis.__hoppReportScriptExecutionError`; a user + // import shadowing `globalThis` would silently break error reporting. + const script = combineScriptsWithIIFE([ + `import globalThis from "data:text/javascript,export default {}";`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeLeft() + if (E.isLeft(result)) { + expect(result.left).toMatch( + /'globalThis' is reserved by Hoppscotch's script wrapper/ + ) + } + }) + + test("named re-export-from declarations are hoisted alongside imports", async () => { + const script = combineScriptsWithIIFE([ + `export { value } from "data:text/javascript,export const value = 're-export-ok'";\nimport { value as v } from "data:text/javascript,export const value = 're-export-ok'";\npw.env.set("RE_EXPORT", v);`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeRight() + if (E.isRight(result)) { + const updated = result.right.selected.find((v) => v.key === "RE_EXPORT") + expect(updated?.currentValue).toBe("re-export-ok") + } + }) + + test("export-all-from declarations are hoisted alongside imports", async () => { + const script = combineScriptsWithIIFE([ + `export * from "data:text/javascript,export const a = 1";\nimport { a } from "data:text/javascript,export const a = 1";\npw.env.set("EXPORT_ALL", String(a));`, + ]) + + const result = await runPreRequest(script, envs)() + + expect(result).toBeRight() + if (E.isRight(result)) { + const updated = result.right.selected.find((v) => v.key === "EXPORT_ALL") + expect(updated?.currentValue).toBe("1") + } + }) +}) + +describe("script ESM imports — test scripts", () => { + test("named import binding resolves in test script", async () => { + const script = combineScriptsWithIIFE([ + `import { value } from "data:text/javascript,export const value = 'test-esm-ok'";\npw.env.set("IMPORTED_VALUE", value);`, + ]) + + const result = await runTestAndGetEnvs(script, envs)() + + expect(result).toBeRight() + if (E.isRight(result)) { + const updated = result.right.selected.find( + (v) => v.key === "IMPORTED_VALUE" + ) + expect(updated?.currentValue).toBe("test-esm-ok") + } + }) + + test("default import binding resolves in test script", async () => { + const script = combineScriptsWithIIFE([ + `import obj from "data:text/javascript,export default { greet: 'hello-test' }";\npw.env.set("GREETING", obj.greet);`, + ]) + + const result = await runTestAndGetEnvs(script, envs)() + + expect(result).toBeRight() + if (E.isRight(result)) { + const updated = result.right.selected.find((v) => v.key === "GREETING") + expect(updated?.currentValue).toBe("hello-test") + } + }) + + test("malformed test script surfaces a friendly SyntaxError pre-cage", async () => { + const result = await runTestAndGetEnvs("const x = ;", envs)() + + expect(result).toBeLeft() + if (E.isLeft(result)) { + expect(result.left).toMatch(/Script execution failed:.*SyntaxError/) + } + }) +}) + +// Live network coverage against esm.sh — opt-in to keep CI deterministic. +const networkTest = process.env.HOPP_NETWORK_TESTS === "1" ? test : test.skip + +describe("script ESM imports — live esm.sh (opt-in)", () => { + networkTest( + "real-world ESM import shape resolves end-to-end", + async () => { + const script = combineScriptsWithIIFE([ + [ + `import lodash from "https://esm.sh/lodash@4.17.21";`, + `import axios from "https://esm.sh/axios@1.6.0";`, + `import { format } from "https://esm.sh/date-fns@2.30.0";`, + `pw.env.set("PICKED", JSON.stringify(lodash.pick({ a: 1, b: 2 }, ["a"])));`, + `pw.env.set("AXIOS_TYPE", typeof axios);`, + `pw.env.set("FORMATTED", format(new Date(2026, 4, 7), "yyyy-MM-dd"));`, + ].join("\n"), + ]) + + // Soft-pass on esm.sh degradation — the assertions only run when the + // module loader actually delivers a usable result. + let result + try { + result = await runTestAndGetEnvs(script, envs)() + } catch (e) { + console.warn("[skip] esm.sh appears degraded:", e) + return + } + if (E.isLeft(result)) { + console.warn("[skip] esm.sh appears degraded:", result.left) + return + } + + expect( + result.right.selected.find((v) => v.key === "PICKED")?.currentValue + ).toBe(JSON.stringify({ a: 1 })) + expect( + result.right.selected.find((v) => v.key === "AXIOS_TYPE")?.currentValue + ).toMatch(/object|function/) + expect( + result.right.selected.find((v) => v.key === "FORMATTED")?.currentValue + ).toBe("2026-05-07") + }, + 30_000 + ) +}) diff --git a/packages/hoppscotch-js-sandbox/src/node/test-runner/index.ts b/packages/hoppscotch-js-sandbox/src/node/test-runner/index.ts index 059fa6de19c..213a021f5bc 100644 --- a/packages/hoppscotch-js-sandbox/src/node/test-runner/index.ts +++ b/packages/hoppscotch-js-sandbox/src/node/test-runner/index.ts @@ -3,6 +3,7 @@ import * as TE from "fp-ts/TaskEither" import { pipe } from "fp-ts/function" import { RunPostRequestScriptOptions, TestResponse, TestResult } from "~/types" +import { parseScriptForSyntax } from "~/utils/scripting" import { preventCyclicObjects } from "~/utils/shared" import { runPostRequestScriptWithFaradayCage } from "./experimental" @@ -12,20 +13,6 @@ export const runTestScript = ( testScript: string, options: RunPostRequestScriptOptions ): TE.TaskEither => { - // Pre-parse the script to catch syntax errors before execution - // Use AsyncFunction to support top-level await (required for hopp.fetch, etc.) - try { - // eslint-disable-next-line no-new-func - const AsyncFunction = Object.getPrototypeOf( - async function () {} - ).constructor - new (AsyncFunction as any)(testScript) - } catch (e) { - const err = e as Error - const reason = `${"name" in err ? (err as any).name : "SyntaxError"}: ${err.message}` - return TE.left(`Script execution failed: ${reason}`) - } - const responseObjHandle = preventCyclicObjects(options.response) if (E.isLeft(responseObjHandle)) { @@ -35,6 +22,21 @@ export const runTestScript = ( const resolvedResponse = responseObjHandle.right const { envs, experimentalScriptingSandbox = true } = options + // Pre-parse before sandbox spin-up so syntax errors surface as a friendly + // host-side message. Each target uses the grammar that matches its eventual + // executor: experimental → ESM module (top-level imports + await accepted); + // legacy → script mode (top-level imports + await rejected). + try { + parseScriptForSyntax( + testScript, + experimentalScriptingSandbox ? "experimental" : "legacy" + ) + } catch (e) { + const err = e as Error + const reason = `${"name" in err ? (err as any).name : "SyntaxError"}: ${err.message}` + return TE.left(`Script execution failed: ${reason}`) + } + if (experimentalScriptingSandbox) { const { request, hoppFetchHook } = options as Extract< RunPostRequestScriptOptions, diff --git a/packages/hoppscotch-js-sandbox/src/scripting.ts b/packages/hoppscotch-js-sandbox/src/scripting.ts new file mode 100644 index 00000000000..d75185ac261 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/scripting.ts @@ -0,0 +1,11 @@ +// Subpath barrel for string helpers; lets consumers skip the runner-module +// Vite worker imports. Relative path keeps the emitted `.d.ts` portable. +export { + MODULE_PREFIX, + combineScriptsWithIIFE, + filterValidScripts, + hasActualScript, + stripJsonSerializedModulePrefix, + stripModulePrefix, + type CombineScriptsTarget, +} from "./utils/scripting" diff --git a/packages/hoppscotch-js-sandbox/src/utils/scripting.ts b/packages/hoppscotch-js-sandbox/src/utils/scripting.ts new file mode 100644 index 00000000000..b692653b463 --- /dev/null +++ b/packages/hoppscotch-js-sandbox/src/utils/scripting.ts @@ -0,0 +1,282 @@ +import { + Parser, + type ExportAllDeclaration, + type ExportNamedDeclaration, + type ImportDeclaration, + type Program, +} from "acorn" + +// Monaco prepends this to TS-mode editor buffers so each script parses as a +// module. Strip it before legacy execution and before serializing to JSON. +export const MODULE_PREFIX = "export {};\n" as const + +/** + * Strips `export {};\n` prefix from scripts before legacy sandbox 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 +} + +/** + * Strips the JSON-serialized `export {};` prefix (with optional `\n` literal) + * from the start of any JSON string value during collection export. + * Capture-and-reinsert is used in place of a lookbehind so the regex parses + * on WebKit < 16.4 (Tauri's macOS WKWebView before Ventura 13.3). + */ +export const stripJsonSerializedModulePrefix = (json: string): string => + json.replace(/(:\s*")export \{\};(?:\\n)?/g, "$1") + +export type CombineScriptsTarget = "experimental" | "legacy" + +// Shared parser options. The experimental path admits ESM grammar (top-level +// imports + await) so they reach faraday-cage's module evaluator. The legacy +// path mirrors its executor's script-mode grammar — top-level `await` and +// `import` are rejected pre-cage to match what the legacy evaluator would. +const PARSE_OPTIONS = { + experimental: { + ecmaVersion: "latest", + sourceType: "module", + allowReturnOutsideFunction: true, + }, + legacy: { + ecmaVersion: "latest", + sourceType: "script", + allowReturnOutsideFunction: true, + }, +} as const + +export const parseScriptForSyntax = ( + script: string, + target: CombineScriptsTarget = "experimental" +): void => { + Parser.parse(script, PARSE_OPTIONS[target]) +} + +type ImportBinding = { + name: string + source: string +} + +type ExtractedImports = { + importStatements: string[] + body: string + bindings: ImportBinding[] + // Set when Acorn rejects the script, so the wrapper surfaces the original + // parse error instead of a downstream "import declarations may only appear + // at top level" from re-evaluating the unmodified body inside an IIFE. + parseError?: string +} + +// Wrapper-declared module-scope names + `globalThis` (which the wrapper +// reads for the reporter). User imports binding these would duplicate- +// declare or shadow them post-hoist, so we reject pre-cage. +const RESERVED_WRAPPER_NAMES = new Set(["__hoppReporter", "globalThis"]) + +// Top-level node shapes that resolve a module URL and therefore must reach +// module scope outside the IIFE wrapper: `import` declarations, plus +// re-export-from forms (`export { x } from "y"`, `export * from "y"`, +// `export * as ns from "y"`). Local-only `export const` / `export { x }` +// don't carry a `source`, so they stay in the body. +type ModuleResolvingDeclaration = + | ImportDeclaration + | (ExportNamedDeclaration & { + source: NonNullable + }) + | ExportAllDeclaration + +const isModuleResolvingDeclaration = ( + n: Program["body"][number] +): n is ModuleResolvingDeclaration => + n.type === "ImportDeclaration" || + n.type === "ExportAllDeclaration" || + (n.type === "ExportNamedDeclaration" && n.source !== null) + +// Lifts top-level module-resolving declarations so they can be hoisted to +// module scope; the IIFE wrapper would otherwise reject them as `SyntaxError`. +const extractTopLevelImports = (script: string): ExtractedImports => { + const empty: ExtractedImports = { + importStatements: [], + body: script, + bindings: [], + } + if (!script.trim()) return empty + + let ast: Program + try { + ast = Parser.parse(script, PARSE_OPTIONS.experimental) + } catch (err) { + return { + ...empty, + parseError: err instanceof Error ? err.message : String(err), + } + } + + const moduleNodes = ast.body.filter(isModuleResolvingDeclaration) + if (moduleNodes.length === 0) return empty + + let body = "" + let cursor = 0 + for (const node of moduleNodes) { + body += script.slice(cursor, node.start) + cursor = node.end + } + body += script.slice(cursor) + + // Only `import` declarations introduce local bindings that could collide + // across cascade levels. Re-exports rebind to the consumer, not to a + // local name, so they don't participate in the duplicate-binding check. + const bindings: ImportBinding[] = moduleNodes + .filter((n): n is ImportDeclaration => n.type === "ImportDeclaration") + .flatMap((n) => + n.specifiers.map((s) => ({ + name: s.local.name, + source: String(n.source.value ?? ""), + })) + ) + + return { + importStatements: moduleNodes.map((n) => script.slice(n.start, n.end)), + body, + bindings, + } +} + +const wrapLegacyScript = (script: string): string => { + const stripped = stripModulePrefix(script.trim()) + if (!stripped) return "" + return `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. + * Top-level `import` and `export … from` declarations are hoisted out of + * the IIFEs so module resolution (e.g. faraday-cage's esmModuleLoader) + * can see them. Identical import statements across scripts are deduped; + * same-name imports from different sources, parse failures, and bindings + * that collide with wrapper internals all surface a friendly `SyntaxError` + * pre-cage. + * - `legacy`: sync `(function(){...}).call(this);` lines. Top-level `await` + * is rejected at parse time. + * + * Side-effect imports run at module-evaluation time, before any cascade + * body. The body-order guarantee (root → folder → request) does not extend + * to top-level effects in imported modules. Value imports are unaffected. + */ +export const combineScriptsWithIIFE = ( + scripts: string[], + target: CombineScriptsTarget = "experimental" +): string => { + if (target === "legacy") { + const fns = scripts.map(wrapLegacyScript).filter((s) => s) + if (fns.length === 0) return "" + // 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") + } + + const extracted = scripts.map((s) => + extractTopLevelImports(stripModulePrefix(s.trim())) + ) + + const fns = extracted + .map(({ body }) => + body.trim() ? `async function() {\n${body.trim()}\n}` : "" + ) + .filter((s) => s) + + // Identical import statements (literal string match across scripts) are + // deduped to a single emitted line. Same name from different sources is a + // real conflict and surfaces a friendly `SyntaxError` pre-cage. + const allImports = [ + ...new Set(extracted.flatMap((e) => e.importStatements)), + ].filter(Boolean) + + const parseError = extracted.find((e) => e.parseError)?.parseError + + const sourcesByName = new Map>() + for (const { name, source } of extracted.flatMap((e) => e.bindings)) { + if (!sourcesByName.has(name)) sourcesByName.set(name, new Set()) + sourcesByName.get(name)!.add(source) + } + const conflictingName = [...sourcesByName.entries()].find( + ([, sources]) => sources.size > 1 + )?.[0] + + const allBindingNames = new Set( + extracted.flatMap((e) => e.bindings.map((b) => b.name)) + ) + const reservedConflict = [...RESERVED_WRAPPER_NAMES].find((n) => + allBindingNames.has(n) + ) + + if (fns.length === 0 && allImports.length === 0 && !parseError) return "" + + // Errors short-circuit before synthesis; reserved-name check sits before + // the import-only return so reserved bindings still surface. + if (parseError !== undefined) { + return synthesizeReporterWrapper( + `throw new SyntaxError(${JSON.stringify(`[Hoppscotch] Script failed to parse: ${parseError}`)});` + ) + } + + if (conflictingName !== undefined) { + return synthesizeReporterWrapper( + `throw new SyntaxError(${JSON.stringify(`[Hoppscotch] '${conflictingName}' is imported from different sources across scripts in this request's chain. Please import it from a single source, or rename one of the imports to resolve the conflict.`)});` + ) + } + + if (reservedConflict !== undefined) { + return synthesizeReporterWrapper( + `throw new SyntaxError(${JSON.stringify(`[Hoppscotch] '${reservedConflict}' is reserved by Hoppscotch's script wrapper and cannot be used as an import binding. Please rename the import.`)});` + ) + } + + // Import-only cascade: skip the try/catch — no awaited bodies to route + // errors from. Module-evaluation errors propagate via faraday-cage. + if (fns.length === 0) return allImports.join("\n") + + // 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") + const tryBlock = synthesizeReporterWrapper(body) + + if (allImports.length === 0) return tryBlock + + return [allImports.join("\n"), tryBlock].join("\n") +} + +const synthesizeReporterWrapper = (bodyLines: string): string => + [ + "const __hoppReporter = globalThis.__hoppReportScriptExecutionError;", + "try {", + bodyLines, + "} catch (__hoppScriptExecutionError) {", + " __hoppReporter(__hoppScriptExecutionError);", + "}", + ].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 +} + +export const filterValidScripts = ( + scripts: (string | undefined | null)[] +): string[] => + scripts.filter( + (script): script is string => + typeof script === "string" && stripModulePrefix(script).trim().length > 0 + ) diff --git a/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts b/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts index 73d5b87df3e..fc7a14d6b02 100644 --- a/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts +++ b/packages/hoppscotch-js-sandbox/src/web/test-runner/index.ts @@ -12,6 +12,7 @@ import { TestResult, } from "~/types" import { acquireCage, resetCage, isInfraError } from "~/utils/cage" +import { parseScriptForSyntax } from "~/utils/scripting" import { preventCyclicObjects } from "~/utils/shared" import { Cookie, HoppRESTRequest } from "@hoppscotch/data" @@ -209,20 +210,6 @@ export const runTestScript = async ( testScript: string, options: RunPostRequestScriptOptions ): Promise> => { - // Pre-parse the script to catch syntax errors before execution - // Use AsyncFunction to support top-level await (required for hopp.fetch, etc.) - try { - // eslint-disable-next-line no-new-func - const AsyncFunction = Object.getPrototypeOf( - async function () {} - ).constructor - new (AsyncFunction as any)(testScript) - } catch (e) { - const err = e as Error - const reason = `${"name" in err ? (err as any).name : "SyntaxError"}: ${err.message}` - return E.left(`Script execution failed: ${reason}`) - } - const responseObjHandle = preventCyclicObjects(options.response) if (E.isLeft(responseObjHandle)) { @@ -233,6 +220,21 @@ export const runTestScript = async ( const { envs, experimentalScriptingSandbox = true } = options + // Pre-parse before sandbox spin-up so syntax errors surface as a friendly + // host-side message. Each target uses the grammar that matches its eventual + // executor: experimental → ESM module (top-level imports + await accepted); + // legacy → script mode (top-level imports + await rejected). + try { + parseScriptForSyntax( + testScript, + experimentalScriptingSandbox ? "experimental" : "legacy" + ) + } catch (e) { + const err = e as Error + const reason = `${"name" in err ? (err as any).name : "SyntaxError"}: ${err.message}` + return E.left(`Script execution failed: ${reason}`) + } + if (experimentalScriptingSandbox) { const { request, cookies, hoppFetchHook } = options as Extract< RunPostRequestScriptOptions, diff --git a/packages/hoppscotch-js-sandbox/vite.config.ts b/packages/hoppscotch-js-sandbox/vite.config.ts index c47ddd4c7d6..7bb2360dc3e 100644 --- a/packages/hoppscotch-js-sandbox/vite.config.ts +++ b/packages/hoppscotch-js-sandbox/vite.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ entry: { web: "./src/web/index.ts", node: "./src/node/index.ts", + scripting: "./src/scripting.ts", }, name: "js-sandbox", formats: ["es", "cjs"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 810356c4e19..1ad7e914aeb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1231,6 +1231,9 @@ importers: '@types/lodash-es': specifier: 4.17.12 version: 4.17.12 + acorn: + specifier: 8.16.0 + version: 8.16.0 chai: specifier: 6.2.2 version: 6.2.2 From e04ef445c4bad5dd1222f701a07e06ba1f714c42 Mon Sep 17 00:00:00 2001 From: Shreyas Date: Tue, 12 May 2026 13:33:34 +0530 Subject: [PATCH 2/5] feat(desktop): settings phase 3a - keyboard layout strategy (#6289) Co-authored-by: James George <25279263+jamesgeorge007@users.noreply.github.com> --- packages/hoppscotch-common/locales/en.json | 11 +- .../hoppscotch-common/src/components.d.ts | 3 +- .../src/components/settings/Desktop.vue | 76 ++++++- .../src/composables/desktop-settings.ts | 17 ++ .../src/helpers/__tests__/keybindings.spec.ts | 198 ++++++++++++++++++ .../src/helpers/keybindings.ts | 119 ++++++++--- .../src/helpers/keyboard-strategy.ts | 30 +++ .../src/platform/desktop-settings.ts | 8 + packages/hoppscotch-selfhost-web/src/main.ts | 146 +++++-------- 9 files changed, 480 insertions(+), 128 deletions(-) create mode 100644 packages/hoppscotch-common/src/helpers/__tests__/keybindings.spec.ts create mode 100644 packages/hoppscotch-common/src/helpers/keyboard-strategy.ts diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 2a73e6e4752..e44b6607cad 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -1289,7 +1289,16 @@ "delete_account": "Delete account", "delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", "desktop": "Desktop", - "desktop_description": "Preferences that apply only to the Hoppscotch desktop app.", + "desktop_description": "Update behavior and keyboard handling for the Hoppscotch desktop app.", + "desktop_keyboard": "Keyboard", + "desktop_keyboard_strategy_label": "Match shortcuts by typed letter or physical position", + "desktop_keyboard_strategy_description": "On non-QWERTY layouts, the same letter can come from different physical keys. The default works for most layouts; switch options if shortcuts don't fire as expected on yours.", + "desktop_keyboard_strategy_hybrid": "Smart (recommended)", + "desktop_keyboard_strategy_hybrid_description": "Use the typed letter for Latin characters; fall back to the physical key position for non-Latin layouts (Cyrillic, CJK).", + "desktop_keyboard_strategy_key": "Typed letter", + "desktop_keyboard_strategy_key_description": "Always use the typed letter. Pick this if shortcuts don't work as expected on your layout.", + "desktop_keyboard_strategy_code": "Physical key position", + "desktop_keyboard_strategy_code_description": "Always use the US-QWERTY physical position. Pick this if you have QWERTY muscle memory on a non-Latin layout.", "desktop_updates": "Updates", "disable_encode_mode_tooltip": "Never encode the parameters in the request", "disable_update_checks": "Disable automatic update checks", diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts index 5cf1795bf38..357e2240678 100644 --- a/packages/hoppscotch-common/src/components.d.ts +++ b/packages/hoppscotch-common/src/components.d.ts @@ -201,6 +201,7 @@ declare module 'vue' { HttpExampleResponseTab: typeof import('./components/http/example/ResponseTab.vue')['default'] HttpHeaders: typeof import('./components/http/Headers.vue')['default'] HttpImportCurl: typeof import('./components/http/ImportCurl.vue')['default'] + HttpInheritedScriptsModal: typeof import('./components/http/InheritedScriptsModal.vue')['default'] HttpKeyValue: typeof import('./components/http/KeyValue.vue')['default'] HttpParameters: typeof import('./components/http/Parameters.vue')['default'] HttpPreRequestScript: typeof import('./components/http/PreRequestScript.vue')['default'] @@ -245,7 +246,6 @@ 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'] @@ -257,7 +257,6 @@ declare module 'vue' { IconLucideLayers: typeof import('~icons/lucide/layers')['default'] IconLucideListEnd: typeof import('~icons/lucide/list-end')['default'] IconLucideLoader2: typeof import('~icons/lucide/loader2')['default'] - IconLucideLock: typeof import('~icons/lucide/lock')['default'] IconLucideMinus: typeof import('~icons/lucide/minus')['default'] IconLucidePlusCircle: typeof import('~icons/lucide/plus-circle')['default'] IconLucideRefreshCw: typeof import('~icons/lucide/refresh-cw')['default'] diff --git a/packages/hoppscotch-common/src/components/settings/Desktop.vue b/packages/hoppscotch-common/src/components/settings/Desktop.vue index 659dae2f993..a7384e0b4b0 100644 --- a/packages/hoppscotch-common/src/components/settings/Desktop.vue +++ b/packages/hoppscotch-common/src/components/settings/Desktop.vue @@ -71,14 +71,58 @@

+ + +
+

+ {{ t("settings.desktop_keyboard") }} +

+ +
+

+ {{ t("settings.desktop_keyboard_strategy_label") }} +

+

+ {{ t("settings.desktop_keyboard_strategy_description") }} +

+ +
+
+ +

+ {{ option.description }} +

+
+
+
+