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 }}
+
+
+
+
+