diff --git a/packages/hoppscotch-backend/package.json b/packages/hoppscotch-backend/package.json index 59cb099bddf..2ca42a23d89 100644 --- a/packages/hoppscotch-backend/package.json +++ b/packages/hoppscotch-backend/package.json @@ -1,6 +1,6 @@ { "name": "hoppscotch-backend", - "version": "2026.4.0", + "version": "2026.4.1", "description": "", "author": "", "private": true, diff --git a/packages/hoppscotch-cli/package.json b/packages/hoppscotch-cli/package.json index c16380b0298..03fd3b70343 100644 --- a/packages/hoppscotch-cli/package.json +++ b/packages/hoppscotch-cli/package.json @@ -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", 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/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/package.json b/packages/hoppscotch-common/package.json index 6abc4db9b5a..0c836225fce 100644 --- a/packages/hoppscotch-common/package.json +++ b/packages/hoppscotch-common/package.json @@ -1,7 +1,7 @@ { "name": "@hoppscotch/common", "private": true, - "version": "2026.4.0", + "version": "2026.4.1", "scripts": { "dev": "pnpm exec npm-run-all -p -l dev:*", "test": "vitest --run", 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/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/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 }} +

+
+
+
+