mirror of
https://github.com/hoppscotch/hoppscotch.git
synced 2026-09-19 01:35:47 +08:00
chore: merge hoppscotch/main into hoppscotch/next
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hoppscotch-backend",
|
||||
"version": "2026.4.0",
|
||||
"version": "2026.4.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -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:", () => {
|
||||
|
||||
+46
-2
@@ -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});"
|
||||
}
|
||||
|
||||
+38
@@ -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": ""
|
||||
}
|
||||
+114
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
@@ -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 (KeyA–KeyZ)
|
||||
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 (Digit0–Digit9)
|
||||
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 (Numpad0–Numpad9), 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,7 +1,7 @@
|
||||
{
|
||||
"name": "hoppscotch-desktop",
|
||||
"private": true,
|
||||
"version": "26.4.0",
|
||||
"version": "26.4.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+1
-1
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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:*",
|
||||
|
||||
Generated
+6
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user