chore: merge hoppscotch/main into hoppscotch/next

This commit is contained in:
James George
2026-05-14 13:53:34 +05:30
59 changed files with 1566 additions and 365 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hoppscotch-backend",
"version": "2026.4.0",
"version": "2026.4.1",
"description": "",
"author": "",
"private": true,
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@hoppscotch/cli",
"version": "0.31.0",
"version": "0.31.2",
"description": "A CLI to run Hoppscotch test scripts in CI environments.",
"homepage": "https://hoppscotch.io",
"type": "module",
@@ -52,6 +52,7 @@
"lodash-es": "4.18.1",
"papaparse": "5.5.3",
"qs": "6.15.1",
"semver": "7.7.4",
"tough-cookie": "6.0.1",
"verzod": "0.4.0",
"xmlbuilder2": "4.0.3",
@@ -66,7 +67,6 @@
"@types/qs": "6.15.0",
"fp-ts": "2.16.11",
"prettier": "3.8.3",
"semver": "7.7.4",
"tsup": "8.5.1",
"typescript": "5.9.3",
"vitest": "4.1.5"
@@ -540,7 +540,7 @@ describe("hopp test [options] <file_path_or_id>", { timeout: 100000 }, () => {
fs.unlinkSync(junitPath);
}, 600000); // 600 second (10 minute) timeout
test("Inherited collection-level scripts run in order across both sandboxes", async () => {
test("Inherited collection-level scripts run in order on the experimental sandbox (default)", async () => {
const args = `test ${getTestJsonFilePath(
"collection-level-scripts-coll.json",
"collection"
@@ -549,11 +549,35 @@ describe("hopp test [options] <file_path_or_id>", { timeout: 100000 }, () => {
const defaultResult = await runCLIWithNetworkRetry(args);
if (defaultResult === null) return;
expect(defaultResult.error).toBeNull();
});
const legacyResult = await runCLIWithNetworkRetry(`${args} --legacy-sandbox`);
// 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);
if (legacyResult === null) return;
expect(legacyResult.error).toBeNull();
});
test("Surfaces a SyntaxError when the same import binding appears in multiple scripts in a request's cascade", async () => {
const args = `test ${getTestJsonFilePath(
"collection-level-scripts-duplicate-import-coll.json",
"collection"
)}`;
const { error, stderr } = await runCLI(args);
expect(error).not.toBeNull();
expect(stderr).toContain("PRE_REQUEST_SCRIPT_ERROR");
expect(stderr).toContain(
"'dup' is imported from different sources across scripts in this request's chain"
);
});
});
describe("Test `hopp test <file_path_or_id> --env <file_path_or_id>` command:", () => {
@@ -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});"
}
@@ -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": ""
}
@@ -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});"
}
@@ -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;");
});
});
});
@@ -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;
@@ -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
@@ -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
@@ -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
);
+1 -1
View File
@@ -18,7 +18,7 @@ import { HoppEnvs } from "../types/request";
import { ExpectResult, TestMetrics, TestRunnerRes } from "../types/response";
import { getDurationInSeconds } from "./getters";
import { createHoppFetchHook } from "./hopp-fetch";
import { combineScriptsWithIIFE, filterValidScripts } from "./scripting";
import { combineScriptsWithIIFE, filterValidScripts } from "@hoppscotch/js-sandbox/scripting";
/**
* Executes test script and runs testDescriptorParser to generate test-report using
+10 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -2
View File
@@ -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']
@@ -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"
@@ -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"
@@ -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,
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -71,14 +71,58 @@
</p>
</div>
</section>
<!-- Keyboard layout strategy. Three radios, each with a one-line
description so the user can pick without trial and error.
Selection writes to `keyboardLayoutStrategy` through the
desktop settings composable, which mirrors it into the
keyboard-strategy holder so the next keypress respects the
change. -->
<section>
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.desktop_keyboard") }}
</h4>
<div class="mt-4">
<p class="text-secondaryLight">
{{ t("settings.desktop_keyboard_strategy_label") }}
</p>
<p class="mt-1 text-xs text-secondaryLight">
{{ t("settings.desktop_keyboard_strategy_description") }}
</p>
<div class="mt-4 space-y-4">
<div v-for="option in keyboardStrategyOptions" :key="option.value">
<HoppSmartRadio
:value="option.value"
:label="option.label"
:selected="
desktopSettings.settings.keyboardLayoutStrategy ===
option.value
"
class="!px-0 hover:bg-transparent"
@change="setKeyboardStrategy(option.value)"
/>
<p class="ml-8 mt-1 text-xs text-secondaryLight">
{{ option.description }}
</p>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch, type Component } from "vue"
import { HoppButtonSecondary, HoppSmartToggle } from "@hoppscotch/ui"
import {
HoppButtonSecondary,
HoppSmartRadio,
HoppSmartToggle,
} from "@hoppscotch/ui"
import { useI18n } from "~/composables/i18n"
import type { DesktopSettings } from "~/platform/desktop-settings"
import IconLucideDownload from "~icons/lucide/download"
import IconLucideRefreshCw from "~icons/lucide/refresh-cw"
@@ -268,6 +312,36 @@ async function toggleDisableUpdateChecks(): Promise<void> {
!desktopSettings.settings.disableUpdateChecks
)
}
// Keyboard layout strategy radios. Order is recommended-first so users
// without a preference get the smart default. Labels and descriptions
// are i18n keys, rebuilt as a `computed` so a locale change updates
// the rendered text.
type KeyboardStrategy = DesktopSettings["keyboardLayoutStrategy"]
const keyboardStrategyOptions = computed<
Array<{ value: KeyboardStrategy; label: string; description: string }>
>(() => [
{
value: "hybrid",
label: t("settings.desktop_keyboard_strategy_hybrid"),
description: t("settings.desktop_keyboard_strategy_hybrid_description"),
},
{
value: "key",
label: t("settings.desktop_keyboard_strategy_key"),
description: t("settings.desktop_keyboard_strategy_key_description"),
},
{
value: "code",
label: t("settings.desktop_keyboard_strategy_code"),
description: t("settings.desktop_keyboard_strategy_code_description"),
},
])
async function setKeyboardStrategy(value: KeyboardStrategy): Promise<void> {
await desktopSettings.update("keyboardLayoutStrategy", value)
}
</script>
<style scoped>
@@ -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,
@@ -16,6 +16,7 @@ import {
parseDesktopSettings,
type DesktopSettings,
} from "~/platform/desktop-settings"
import { setKeyboardLayoutStrategy } from "~/helpers/keyboard-strategy"
import { Log } from "~/kernel/log"
const LOG_TAG = "useDesktopSettings"
@@ -74,6 +75,7 @@ async function loadInitial(): Promise<void> {
)
const raw = E.isRight(result) ? result.right : undefined
Object.assign(settings, parseDesktopSettings(raw))
setKeyboardLayoutStrategy(settings.keyboardLayoutStrategy)
loaded.value = true
// Subscribe to external writes (for example the Tauri shell's portable
@@ -88,6 +90,7 @@ async function loadInitial(): Promise<void> {
emitter.on("change", ({ value }: { value?: unknown }) => {
if (value !== undefined) {
Object.assign(settings, parseDesktopSettings(value))
setKeyboardLayoutStrategy(settings.keyboardLayoutStrategy)
}
})
} catch (err) {
@@ -161,6 +164,15 @@ export function useDesktopSettings(): {
const previous = settings[key]
settings[key] = value
// Mirror the change into the keyboard-strategy holder eagerly so the
// next keypress respects the new strategy without waiting for the
// store-watch callback to round-trip. The watch fires later with the
// same value, and the redundant write is cheap.
if (key === "keyboardLayoutStrategy") {
setKeyboardLayoutStrategy(
value as DesktopSettings["keyboardLayoutStrategy"]
)
}
try {
await persist()
} catch (err) {
@@ -168,6 +180,11 @@ export function useDesktopSettings(): {
// actually in the store. Without this, a failed persist leaves the
// settings object holding a value the next app start will not find.
settings[key] = previous
if (key === "keyboardLayoutStrategy") {
setKeyboardLayoutStrategy(
previous as DesktopSettings["keyboardLayoutStrategy"]
)
}
throw err
}
}
@@ -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 {
@@ -0,0 +1,198 @@
import { describe, expect, test } from "vitest"
import { resolvePressedKey } from "../keybindings"
// Fixture builder to keep individual cases readable. Layout name in the
// describe block is the conceptual layout; `key` and `code` are what the
// browser actually emits when the user presses a given physical key on
// that layout.
const ev = (key: string, code: string) => ({ key, code })
describe("resolvePressedKey: letter dispatch", () => {
describe("strategy: 'key' (typed letter)", () => {
test("US-QWERTY Q resolves to q", () => {
expect(resolvePressedKey(ev("q", "KeyQ"), "key")).toBe("q")
})
test("AZERTY 'A' keycap (physical Q position) resolves to a", () => {
// On AZERTY the physical KeyQ position types "a"; the user pressing
// the keycap labelled A expects Ctrl+A behaviour.
expect(resolvePressedKey(ev("a", "KeyQ"), "key")).toBe("a")
})
test("QWERTZ 'Z' keycap (physical Y position) resolves to z", () => {
expect(resolvePressedKey(ev("z", "KeyY"), "key")).toBe("z")
})
test("Cyrillic Q (typed 'й') falls through to non-letter handling", () => {
// 'й' is not a-z so the letter branch returns null and the rest
// of the resolver doesn't recognise it either.
expect(resolvePressedKey(ev("й", "KeyQ"), "key")).toBeNull()
})
test("Mac Option+Q (typed 'œ') falls through", () => {
expect(resolvePressedKey(ev("œ", "KeyQ"), "key")).toBeNull()
})
})
describe("strategy: 'code' (physical position)", () => {
test("US-QWERTY Q resolves to q", () => {
expect(resolvePressedKey(ev("q", "KeyQ"), "code")).toBe("q")
})
test("AZERTY 'A' keycap (physical Q position) resolves to q", () => {
// Pure physical strategy; whatever the user typed is ignored.
expect(resolvePressedKey(ev("a", "KeyQ"), "code")).toBe("q")
})
test("Cyrillic physical Q (typed 'й') resolves to q", () => {
expect(resolvePressedKey(ev("й", "KeyQ"), "code")).toBe("q")
})
test("Mac Option+Q (typed 'œ') resolves to q", () => {
expect(resolvePressedKey(ev("œ", "KeyQ"), "code")).toBe("q")
})
})
describe("strategy: 'hybrid' (key first, code fallback)", () => {
test("US-QWERTY Q resolves to q", () => {
expect(resolvePressedKey(ev("q", "KeyQ"), "hybrid")).toBe("q")
})
test("AZERTY 'A' keycap (physical Q position) resolves to a", () => {
// Latin glyph available, so use it.
expect(resolvePressedKey(ev("a", "KeyQ"), "hybrid")).toBe("a")
})
test("Cyrillic physical Q (typed 'й') falls back to code → q", () => {
// Non-Latin glyph, fall back to physical position.
expect(resolvePressedKey(ev("й", "KeyQ"), "hybrid")).toBe("q")
})
test("Mac Option+Q (typed 'œ') falls back to code → q", () => {
// 'œ' is not in [a-z] so hybrid falls through to code.
expect(resolvePressedKey(ev("œ", "KeyQ"), "hybrid")).toBe("q")
})
test("Dvorak 'Q' keycap typing 'q' resolves to q", () => {
// Dvorak users with Dvorak software remapping see the typed
// letter match the keycap; hybrid uses event.key directly.
expect(resolvePressedKey(ev("q", "KeyX"), "hybrid")).toBe("q")
})
})
})
describe("resolvePressedKey: digit dispatch", () => {
test("'key' uses event.key for digits", () => {
expect(resolvePressedKey(ev("1", "Digit1"), "key")).toBe("1")
})
test("'code' uses event.code for digits", () => {
expect(resolvePressedKey(ev("1", "Digit1"), "code")).toBe("1")
})
test("AZERTY digit row (typed '&', code Digit1) resolves to 1 under hybrid", () => {
expect(resolvePressedKey(ev("&", "Digit1"), "hybrid")).toBe("1")
})
test("AZERTY digit row resolves to 1 under code", () => {
expect(resolvePressedKey(ev("&", "Digit1"), "code")).toBe("1")
})
test("AZERTY digit row falls through under key (no Latin digit typed)", () => {
expect(resolvePressedKey(ev("&", "Digit1"), "key")).toBeNull()
})
})
describe("resolvePressedKey: layout-stable keys", () => {
// These keys produce the same event.key regardless of layout, so
// every strategy resolves them identically.
const strategies = ["key", "code", "hybrid"] as const
for (const strategy of strategies) {
describe(`strategy: '${strategy}'`, () => {
test("ArrowUp resolves to up", () => {
expect(resolvePressedKey(ev("ArrowUp", "ArrowUp"), strategy)).toBe("up")
})
test("Tab resolves to tab", () => {
expect(resolvePressedKey(ev("Tab", "Tab"), strategy)).toBe("tab")
})
test("Enter resolves to enter", () => {
expect(resolvePressedKey(ev("Enter", "Enter"), strategy)).toBe("enter")
})
test("Shift+/ (typed '?') maps to /", () => {
expect(resolvePressedKey(ev("?", "Slash"), strategy)).toBe("/")
})
test("[ resolves to [", () => {
expect(resolvePressedKey(ev("[", "BracketLeft"), strategy)).toBe("[")
})
test("Cyrillic physical [ key (typed 'х') falls back to [", () => {
// On Russian Cyrillic the physical KeyBracketLeft position
// types "х"; the resolver falls back to the bracket code so
// the shortcut still fires.
expect(resolvePressedKey(ev("х", "BracketLeft"), strategy)).toBe("[")
})
test("Cyrillic physical ] key (typed 'ъ') falls back to ]", () => {
expect(resolvePressedKey(ev("ъ", "BracketRight"), strategy)).toBe("]")
})
})
}
})
describe("resolvePressedKey: synthetic-event fallback", () => {
// Synthetic events (programmatic dispatch, certain older environments)
// can arrive without `event.code` set. The resolver falls back to
// `event.key` for ASCII letters under every strategy so the shortcut
// still resolves rather than being silently dropped.
test("'code' strategy falls back to event.key when code is empty", () => {
expect(resolvePressedKey(ev("a", ""), "code")).toBe("a")
})
test("'key' strategy resolves Latin letter without needing code", () => {
expect(resolvePressedKey(ev("a", ""), "key")).toBe("a")
})
test("'hybrid' strategy resolves Latin letter without needing code", () => {
expect(resolvePressedKey(ev("a", ""), "hybrid")).toBe("a")
})
})
describe("resolvePressedKey: edge cases", () => {
test("empty event returns null", () => {
expect(resolvePressedKey(ev("", ""), "hybrid")).toBeNull()
})
test("uppercase letter is normalised to lowercase under 'key'", () => {
expect(resolvePressedKey(ev("Q", "KeyQ"), "key")).toBe("q")
})
test("numpad branch resolves under 'code' strategy when NumLock is on", () => {
// The digit branch normally picks up key="1" before the numpad
// branch sees the event. Strategy "code" suppresses that path
// (event.code "Numpad1" isn't "Digit1"), so resolution falls to
// the numpad branch, which gates on NumLock.
expect(
resolvePressedKey(
{ key: "1", code: "Numpad1", getModifierState: () => true },
"code"
)
).toBe("1")
})
test("numpad digit returns null without NumLock when key is navigational", () => {
// Without NumLock, browsers send the navigation function ("End"
// for Numpad1) in event.key, so neither the digit nor the numpad
// branch can resolve.
expect(
resolvePressedKey(
{ key: "End", code: "Numpad1", getModifierState: () => false },
"hybrid"
)
).toBeNull()
})
})
@@ -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<string, string>
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)
})
})
@@ -1,5 +1,9 @@
import { onBeforeUnmount, onMounted } from "vue"
import { HoppActionWithOptionalArgs, invokeAction } from "./actions"
import {
getKeyboardLayoutStrategy,
type KeyboardLayoutStrategy,
} from "./keyboard-strategy"
import { isAppleDevice } from "./platformutils"
import {
isCodeMirrorEditor,
@@ -175,6 +179,18 @@ function handleKeyDown(ev: KeyboardEvent) {
// Do not check keybinds if the mode is disabled
if (!keybindingsEnabled) return
// Skip during IME composition (CJK input). Modern browsers report
// `isComposing`. Older ones use the sentinel `keyCode === 229`.
// Either way the keystroke belongs to a composition, not a shortcut.
if (ev.isComposing || ev.keyCode === 229) return
// Skip when AltGr is the modifier. Browsers report AltGr as Ctrl+Alt
// on Windows, so QWERTZ users typing `[` via AltGr+8 would otherwise
// match Ctrl+Alt+[ (the MRU tab shortcut) and steal the keystroke.
// `getModifierState("AltGraph")` is true only for AltGr, not for
// genuine Ctrl+Alt presses.
if (ev.getModifierState("AltGraph")) return
const binding = generateKeybindingString(ev)
if (!binding) return
@@ -307,29 +323,61 @@ function generateKeybindingString(ev: KeyboardEvent): ShortcutKey | null {
}
function getPressedKey(ev: KeyboardEvent): Key | null {
return resolvePressedKey(ev, getKeyboardLayoutStrategy())
}
// Minimal subset of `KeyboardEvent` so unit tests can construct fixtures
// without a JSDOM event. `getModifierState` is optional because the only
// call site (numpad detection) tolerates its absence.
export type KeyboardEventLike = Pick<KeyboardEvent, "key" | "code"> & {
getModifierState?: KeyboardEvent["getModifierState"]
}
/**
* Resolves a keyboard event into the registered shortcut key, dispatching
* letter and digit lookups through the active layout strategy.
*
* Strategies only affect the letter and digit branches because those are
* the keys whose `event.key` and `event.code` diverge across layouts.
* Arrow keys, Tab, Enter, brackets, and the "?" → "/" mapping are layout
* stable, so the same checks apply regardless of strategy.
*
* `"key"` uses `event.key` (the typed character), which suits AZERTY,
* QWERTZ, and Dvorak users whose keycap labels match the shortcut they
* want to fire. `"code"` uses `event.code` (the physical key position),
* which suits Cyrillic and CJK users with US-QWERTY muscle memory.
* `"hybrid"` prefers `event.key` when it produces a Latin glyph and
* falls back to `event.code` otherwise, covering both populations.
*
* Both `getPressedKey` (the in-page handler entry) and the capture-phase
* listener in `selfhost-web/main.ts` call this with the active strategy
* from `getKeyboardLayoutStrategy`.
*/
export function resolvePressedKey(
ev: KeyboardEventLike,
strategy: KeyboardLayoutStrategy
): Key | null {
const key = (ev.key ?? "").toLowerCase()
const code = ev.code ?? ""
// Use event.code for letters and digits so shortcuts work regardless of
// the active keyboard layout (Cyrillic, CJK, Dvorak, etc). event.key
// returns the character produced by the layout, event.code returns the
// physical key position.
//
// TODO: Several component-level keydown handlers still use event.key
// (spotlight, EnvInput, SchemaSearch, AI modals). Those need the
// same migration but are lower priority since they only check
// arrow/Enter/Escape which are layout-stable.
// Letter keys (KeyAKeyZ)
if (code.startsWith("Key") && code.length === 4) {
return code[3].toLowerCase() as Key
}
// ev.code can be empty in synthetic events or older environments. Fall back
// to ev.key for ASCII letters so shortcuts don't silently stop working.
// This reintroduces layout-dependence for that edge case, but that's better
// than dropping the shortcut entirely.
if (!code && key.length === 1 && key >= "a" && key <= "z") return key as Key
// Letters
const letterFromKey =
key.length === 1 && key >= "a" && key <= "z" ? (key as Key) : null
const letterFromCode =
code.startsWith("Key") && code.length === 4
? (code[3].toLowerCase() as Key)
: null
// The "code" branch falls back to event.key when event.code is empty
// (synthetic events, certain older environments) so a Latin-letter
// shortcut still resolves rather than silently dropping. Matches the
// pre-strategy resolver's contract.
const letter =
strategy === "key"
? letterFromKey
: strategy === "code"
? (letterFromCode ?? (!code ? letterFromKey : null))
: (letterFromKey ?? letterFromCode)
if (letter) return letter
// Arrow keys (ArrowUp → up, etc)
if (key.startsWith("arrow")) {
@@ -343,16 +391,31 @@ function getPressedKey(ev: KeyboardEvent): Key | null {
// Shift+/ produces "?" on most layouts but the shortcut is registered as "/"
if (key === "?") return "/"
// Punctuation and special keys checked before digit codes because some
// layouts produce these characters from physical digit keys (e.g. AZERTY
// produces [ via AltGr+5 which has code "Digit5").
// Punctuation checked before digit codes because some layouts produce
// these characters from physical digit keys (e.g. AZERTY produces [
// via AltGr+5 which has code "Digit5").
if (key === "/" || key === "." || key === "enter") return key
if (key === "[" || key === "]") return key
// Digit keys (Digit0Digit9)
if (code.startsWith("Digit") && code.length === 6) {
return code[5] as Key
}
// Bracket fallback for non-Latin layouts where the physical bracket
// keys don't type [/] (e.g. Russian Cyrillic where KeyBracketLeft
// types "х"). The shortcut is registered as ctrl-alt-[ so users
// pressing the keycap labelled [ still fire it regardless of layout.
if (code === "BracketLeft") return "["
if (code === "BracketRight") return "]"
// Digits
const digitFromKey =
key.length === 1 && key >= "0" && key <= "9" ? (key as Key) : null
const digitFromCode =
code.startsWith("Digit") && code.length === 6 ? (code[5] as Key) : null
const digit =
strategy === "key"
? digitFromKey
: strategy === "code"
? digitFromCode
: (digitFromKey ?? digitFromCode)
if (digit) return digit
// Numpad digits (Numpad0Numpad9), only when NumLock is on.
// When NumLock is off the physical keys act as navigation (Home, End, etc)
@@ -360,7 +423,7 @@ function getPressedKey(ev: KeyboardEvent): Key | null {
if (
code.startsWith("Numpad") &&
code.length === 7 &&
ev.getModifierState("NumLock")
ev.getModifierState?.("NumLock")
) {
return code.slice(6) as Key
}
@@ -0,0 +1,30 @@
import type { DesktopSettings } from "~/platform/desktop-settings"
/**
* Web-safe holder for the keyboard layout strategy.
*
* The desktop settings composable is the only writer: it calls
* `setKeyboardLayoutStrategy` after the initial settings load and from
* its store-watch callback when the user changes the radio. Keeping
* the holder out of the composable lets `keybindings.ts` read the
* strategy without pulling in Tauri-only imports, so the same
* `getPressedKey` works on web builds where Tauri isn't available.
*
* The default `"hybrid"` matches the schema default, so a keypress
* before the composable finishes loading still resolves through the
* recommended strategy.
*/
export type KeyboardLayoutStrategy = DesktopSettings["keyboardLayoutStrategy"]
let currentStrategy: KeyboardLayoutStrategy = "hybrid"
export function getKeyboardLayoutStrategy(): KeyboardLayoutStrategy {
return currentStrategy
}
export function setKeyboardLayoutStrategy(
strategy: KeyboardLayoutStrategy
): void {
currentStrategy = strategy
}
@@ -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
}
@@ -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"
@@ -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"
@@ -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"
@@ -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,
@@ -55,6 +55,14 @@ export const DESKTOP_SETTINGS_SCHEMA = z.object({
// Display and UX. User-facing zoom control is future scope.
zoomLevel: z.number().positive().default(1.0),
// Keyboard shortcut dispatch strategy. The hybrid strategy prefers
// event.key when it produces a Latin glyph (so AZERTY's "A" key fires
// Ctrl+A regardless of physical position) and falls back to event.code
// for non-Latin layouts (Cyrillic, CJK). The `key` and `code` strategies
// are escape valves for users on layouts where the hybrid heuristic
// guesses wrong.
keyboardLayoutStrategy: z.enum(["key", "code", "hybrid"]).default("hybrid"),
})
export type DesktopSettings = z.infer<typeof DESKTOP_SETTINGS_SCHEMA>
@@ -18,7 +18,7 @@ export const VENDORED_INSTANCE_CONFIG: Instance = {
kind: "vendored" as const,
serverUrl: "app://hoppscotch",
displayName: "Hoppscotch Desktop",
version: "26.4.0",
version: "26.4.1",
lastUsed: new Date().toISOString(),
bundleName: "Hoppscotch",
}
@@ -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
@@ -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"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "hoppscotch-desktop",
"private": true,
"version": "26.4.0",
"version": "26.4.1",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -2324,7 +2324,7 @@ dependencies = [
[[package]]
name = "hoppscotch-desktop"
version = "26.4.0"
version = "26.4.1"
dependencies = [
"axum",
"dirs 6.0.0",
@@ -1,6 +1,6 @@
[package]
name = "hoppscotch-desktop"
version = "26.4.0"
version = "26.4.1"
description = "Desktop App for hoppscotch.io"
authors = ["CuriousCorrelation"]
edition = "2021"
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hoppscotch",
"version": "26.4.0",
"version": "26.4.1",
"identifier": "io.hoppscotch.desktop",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hoppscotch",
"version": "26.4.0",
"version": "26.4.1",
"identifier": "io.hoppscotch.desktop",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Hoppscotch",
"version": "26.4.0",
"version": "26.4.1",
"identifier": "io.hoppscotch.desktop",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -299,7 +299,7 @@ const loadVendored = async () => {
const vendoredInstance: VendoredInstance = {
type: "vendored",
displayName: "Hoppscotch",
version: "26.4.0",
version: "26.4.1",
}
const connectionState: ConnectionState = {
+7 -1
View File
@@ -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",
+1
View File
@@ -0,0 +1 @@
export * from "./dist/scripting"
@@ -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
)
})
@@ -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<string, TestResult> => {
// 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<TestResponse>(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,
@@ -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"
@@ -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<ExportNamedDeclaration["source"]>
})
| 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<string, Set<string>>()
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
)
@@ -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<E.Either<string, SandboxTestResult>> => {
// 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<TestResponse>(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,
@@ -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"],
@@ -1,7 +1,7 @@
{
"name": "@hoppscotch/selfhost-web",
"private": true,
"version": "2026.4.0",
"version": "2026.4.1",
"type": "module",
"scripts": {
"dev:vite": "vite",
+50 -96
View File
@@ -2,6 +2,9 @@ import { nextTick, ref, watch } from "vue"
import { emit, listen } from "@tauri-apps/api/event"
import { createHoppApp } from "@hoppscotch/common"
import { useSettingStatic } from "@hoppscotch/common/composables/settings"
import { useDesktopSettings } from "@hoppscotch/common/composables/desktop-settings"
import { resolvePressedKey } from "@hoppscotch/common/helpers/keybindings"
import { getKeyboardLayoutStrategy } from "@hoppscotch/common/helpers/keyboard-strategy"
import { getKernelMode } from "@hoppscotch/kernel"
import { def as stdBackendDef } from "@hoppscotch/common/platform/std/backend"
@@ -78,6 +81,15 @@ const headerPaddingLeft = ref("0px")
const headerPaddingTop = ref("0px")
function setupDesktopUI() {
// Hydrate the keyboard-layout-strategy holder at desktop bootstrap.
// The composable's first call triggers loadInitial, which reads the
// persisted strategy from tauri-plugin-store and writes it into the
// shared holder. Without this call the holder stays at its module-
// level default ("hybrid") until Desktop.vue mounts, so a persisted
// "key" or "code" choice would be dormant on every restart until the
// user opened the settings page.
useDesktopSettings()
headerPaddingTop.value = "0px"
headerPaddingLeft.value = "80px"
@@ -282,130 +294,72 @@ async function initApp() {
return
}
// Skip during IME composition (CJK input). Modern browsers report
// `isComposing`. Older ones use the sentinel `keyCode === 229`.
if (e.isComposing || e.keyCode === 229) return
// Skip when AltGr is the modifier. Browsers report AltGr as
// Ctrl+Alt on Windows, so QWERTZ users typing `[` via AltGr+8
// would otherwise match Ctrl+Alt+[ and get hijacked into the
// MRU tab shortcut. `getModifierState("AltGraph")` is true only
// for AltGr, not for genuine Ctrl+Alt.
if (e.getModifierState("AltGraph")) return
const isCtrlOrCmd = e.ctrlKey || e.metaKey
if (!isCtrlOrCmd) return
// Resolve the pressed key through the active layout strategy so
// AZERTY's "A" keycap (physical KeyQ position) fires Ctrl+A,
// not Ctrl+Q. The in-page handler uses the same resolver, so
// routing the capture phase through it keeps both paths
// consistent and lets the user's strategy choice take effect
// everywhere, including for the desktop-shell shortcuts the
// capture phase pre-empts.
const key = resolvePressedKey(e, getKeyboardLayoutStrategy())
if (!key) return
let shortcutEvent: string | null = null
if (isCtrlOrCmd && !e.shiftKey && !e.altKey && e.code === "KeyQ") {
if (!e.shiftKey && !e.altKey && key === "q") {
// Ctrl/Cmd + Q - Quit Application
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
shortcutEvent = "ctrl-q"
} else if (
isCtrlOrCmd &&
!e.shiftKey &&
!e.altKey &&
e.code === "KeyT"
) {
} else if (!e.shiftKey && !e.altKey && key === "t") {
// Ctrl/Cmd + T - New Tab
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
shortcutEvent = "ctrl-t"
} else if (
isCtrlOrCmd &&
!e.shiftKey &&
!e.altKey &&
e.code === "KeyW"
) {
} else if (!e.shiftKey && !e.altKey && key === "w") {
// Ctrl/Cmd + W - Close Tab
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
shortcutEvent = "ctrl-w"
} else if (
isCtrlOrCmd &&
e.shiftKey &&
!e.altKey &&
e.code === "KeyT"
) {
} else if (e.shiftKey && !e.altKey && key === "t") {
// Ctrl/Cmd + Shift + T - Reopen Tab
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
shortcutEvent = "ctrl-shift-t"
} else if (
isCtrlOrCmd &&
!e.shiftKey &&
e.altKey &&
e.key === "ArrowRight"
) {
} else if (!e.shiftKey && e.altKey && key === "right") {
// Ctrl/Cmd + Alt + Right - Next Tab
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
shortcutEvent = "ctrl-alt-right"
} else if (
isCtrlOrCmd &&
!e.shiftKey &&
e.altKey &&
e.key === "ArrowLeft"
) {
} else if (!e.shiftKey && e.altKey && key === "left") {
// Ctrl/Cmd + Alt + Left - Previous Tab
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
shortcutEvent = "ctrl-alt-left"
} else if (
isCtrlOrCmd &&
!e.shiftKey &&
e.altKey &&
(e.code === "Digit9" ||
(e.code === "Numpad9" && e.getModifierState("NumLock")))
) {
} else if (!e.shiftKey && e.altKey && key === "9") {
// Ctrl/Cmd + Alt + 9 - First Tab
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
shortcutEvent = "ctrl-alt-9"
} else if (
isCtrlOrCmd &&
!e.shiftKey &&
e.altKey &&
(e.code === "Digit0" ||
(e.code === "Numpad0" && e.getModifierState("NumLock")))
) {
} else if (!e.shiftKey && e.altKey && key === "0") {
// Ctrl/Cmd + Alt + 0 - Last Tab
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
shortcutEvent = "ctrl-alt-0"
} else if (
isCtrlOrCmd &&
!e.shiftKey &&
e.altKey &&
e.code === "KeyU"
) {
} else if (!e.shiftKey && e.altKey && key === "u") {
// Ctrl/Cmd + Alt + U - Focus URL Bar
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
shortcutEvent = "ctrl-alt-u"
} else if (
isCtrlOrCmd &&
!e.shiftKey &&
e.altKey &&
e.code === "BracketRight"
) {
} else if (!e.shiftKey && e.altKey && key === "]") {
// Ctrl/Cmd + Alt + ] - MRU Tab Switch
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
shortcutEvent = "ctrl-alt-]"
} else if (
isCtrlOrCmd &&
!e.shiftKey &&
e.altKey &&
e.code === "BracketLeft"
) {
} else if (!e.shiftKey && e.altKey && key === "[") {
// Ctrl/Cmd + Alt + [ - MRU Tab Switch (Reverse)
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
shortcutEvent = "ctrl-alt-["
}
if (shortcutEvent) {
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
setTimeout(() => {
emit("hoppscotch_desktop_shortcut", shortcutEvent).catch(
(error) => {
@@ -3,7 +3,7 @@ package bundle
import "time"
const (
Version = "2026.4.0"
Version = "2026.4.1"
DefaultMaxSize = 50 * 1024 * 1024
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "hoppscotch-sh-admin",
"private": true,
"version": "2026.4.0",
"version": "2026.4.1",
"type": "module",
"scripts": {
"dev": "pnpm exec npm-run-all -p -l dev:*",
+6 -3
View File
@@ -447,6 +447,9 @@ importers:
qs:
specifier: 6.15.1
version: 6.15.1
semver:
specifier: 7.7.4
version: 7.7.4
tough-cookie:
specifier: 6.0.1
version: 6.0.1
@@ -484,9 +487,6 @@ importers:
prettier:
specifier: 3.8.3
version: 3.8.3
semver:
specifier: 7.7.4
version: 7.7.4
tsup:
specifier: 8.5.1
version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(typescript@5.9.3)(yaml@2.8.3)
@@ -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