返回 DeepSeek-Reasonix
hostCalls.test.ts
根目录 / desktop / electron / src / main / hostCalls.test.ts
1 import assert from "node:assert/strict";
2 import { test } from "node:test";
3 import { renderFailurePage, shellActionFromURL } from "./failurePage.js";
4 import { buildHostCallTable, dispatchHostCall, type HostCallDeps } from "./hostCalls.js";
5 import { RpcError } from "./rpc.js";
6
7 function deps() {
8 const calls: string[] = [];
9 const record = (name: string) => (...args: unknown[]) => {
10 calls.push(`${name}(${args.map((a) => JSON.stringify(a)).join(",")})`);
11 };
12 const table = buildHostCallTable({
13 window: {
14 show: record("show"), hide: record("hide"), maximise: record("maximise"), unmaximise: record("unmaximise"),
15 minimise: record("minimise"), unminimise: record("unminimise"), toggleMaximise: record("toggleMaximise"),
16 center: record("center"), isMaximised: () => true, isMinimised: () => false,
17 setPosition: record("setPosition"), setTitle: record("setTitle"), toggleDevTools: record("devtools"),
18 },
19 dialogs: {
20 openDirectory: async () => ({ path: "/dir" }),
21 openFile: async () => ({ paths: [] }),
22 saveFile: async () => ({ path: "" }),
23 message: async () => ({ button: "OK" }),
24 },
25 tray: { ensure: (labels) => { calls.push(`tray(${labels.openTitle},${labels.quitTitle},${labels.tooltip})`); return { ready: true, reason: "" }; }, destroy: record("trayDestroy") },
26 remote: { open: (input) => { calls.push(`remoteOpen(${input.hostKey})`); return { windowId: "7" }; }, navigate: record("remoteNavigate"), focus: record("remoteFocus"), close: record("remoteClose") },
27 lifecycle: { approve: record("approve"), relaunch: record("relaunch") },
28 openExternal: async (url) => { calls.push(`open(${url})`); },
29 hideApp: record("hideApp"),
30 screens: () => [{ x: 0, y: 0, width: 1, height: 1, scale: 2, primary: true }],
31 } satisfies HostCallDeps);
32 return { table, calls };
33 }
34
35 test("every documented host/* method is dispatched with parsed params", async () => {
36 const { table, calls } = deps();
37 assert.deepEqual(await dispatchHostCall(table, "host/window.show", { reason: "tray" }), {});
38 assert.deepEqual(await dispatchHostCall(table, "host/window.isMaximised", {}), { value: true });
39 assert.deepEqual(await dispatchHostCall(table, "host/window.setPosition", { x: 10.4, y: "bad" }), {});
40 assert.deepEqual(await dispatchHostCall(table, "host/screen.list", {}), { screens: [{ x: 0, y: 0, width: 1, height: 1, scale: 2, primary: true }] });
41 assert.deepEqual(await dispatchHostCall(table, "host/dialog.openDirectory", { title: "t" }), { path: "/dir" });
42 assert.deepEqual(await dispatchHostCall(table, "host/tray.ensure", { openTitle: "打开", quitTitle: "退出" }), { ready: true, reason: "" });
43 assert.deepEqual(await dispatchHostCall(table, "host/remoteWindow.open", { hostKey: "h1", url: "https://x", title: "T" }), { windowId: "7" });
44 assert.deepEqual(await dispatchHostCall(table, "host/app.relaunch", { args: ["--x", 3] }), {});
45 assert.deepEqual(await dispatchHostCall(table, "host/shell.openExternal", { url: "https://e" }), {});
46 assert.deepEqual(await dispatchHostCall(table, "host/app.quit", undefined), {});
47 assert.deepEqual(calls, [
48 'show("tray")', "setPosition(10.4,0)", "tray(打开,退出,Reasonix)", "remoteOpen(h1)", 'relaunch(["--x"])', "open(https://e/)", "approve()",
49 ]);
50 for (const method of ["host/window.hide", "host/window.maximise", "host/window.center", "host/devtools.toggle", "host/tray.destroy", "host/app.hide"]) {
51 assert.deepEqual(await dispatchHostCall(table, method, {}), {});
52 }
53 });
54
55 test("update relaunch preserves the stable launcher path", async () => {
56 const { table, calls } = deps();
57 await dispatchHostCall(table, "host/app.relaunch", { args: ["--after-update"], execPath: "/opt/reasonix/reasonix-launcher" });
58 assert.deepEqual(calls, ['relaunch(["--after-update"],"/opt/reasonix/reasonix-launcher")']);
59 });
60
61 test("host external links reject non-user-facing protocols", async () => {
62 const { table, calls } = deps();
63 for (const url of ["file:///tmp/probe", "javascript:alert(1)", "data:text/plain,probe", "not a url"]) {
64 await assert.rejects(dispatchHostCall(table, "host/shell.openExternal", { url }), /refusing to open/);
65 }
66 assert.deepEqual(calls, []);
67 await dispatchHostCall(table, "host/shell.openExternal", { url: "mailto:test@example.com" });
68 assert.deepEqual(calls, ["open(mailto:test@example.com)"]);
69 });
70
71 test("browser host calls merge into the table when the surface is wired", async () => {
72 const { table, calls } = deps();
73 await assert.rejects(dispatchHostCall(table, "host/browser.tabs.list", {}), (error: unknown) => error instanceof RpcError && error.code === -32601);
74
75 const merged = buildHostCallTable({
76 ...({
77 window: {
78 show: () => {}, hide: () => {}, maximise: () => {}, unmaximise: () => {}, minimise: () => {}, unminimise: () => {},
79 toggleMaximise: () => {}, center: () => {}, isMaximised: () => false, isMinimised: () => false,
80 setPosition: () => {}, setTitle: () => {}, toggleDevTools: () => {},
81 },
82 dialogs: { openDirectory: async () => ({ path: "" }), openFile: async () => ({ paths: [] }), saveFile: async () => ({ path: "" }), message: async () => ({ button: "" }) },
83 tray: { ensure: () => ({ ready: false, reason: "x" }), destroy: () => {} },
84 remote: { open: () => ({ windowId: "" }), navigate: () => {}, focus: () => {}, close: () => {} },
85 lifecycle: { approve: () => {}, relaunch: () => {} },
86 openExternal: async () => {},
87 hideApp: () => {},
88 screens: () => [],
89 browser: { "host/browser.tabs.list": () => ({ tabs: ["tab-1"] }) },
90 } satisfies HostCallDeps),
91 });
92 assert.deepEqual(await dispatchHostCall(merged, "host/browser.tabs.list", {}), { tabs: ["tab-1"] });
93 assert.deepEqual(calls, [], "the plain table was not touched");
94 });
95
96 test("unknown host methods fail with -32601 and never hit Object.prototype", async () => {
97 const { table } = deps();
98 for (const method of ["host/window.explode", "toString", "__proto__", "hasOwnProperty"]) {
99 await assert.rejects(dispatchHostCall(table, method, {}), (error: unknown) => error instanceof RpcError && error.code === -32601);
100 }
101 });
102
103 test("failure page actions ride the reasonix://app/__shell/ prefix and the page escapes text", () => {
104 assert.equal(shellActionFromURL("reasonix://app/__shell/open-logs"), "open-logs");
105 assert.equal(shellActionFromURL("reasonix://app/__shell/restart?x=1"), "restart");
106 assert.equal(shellActionFromURL("reasonix://app/__shell/quit"), "quit");
107 assert.equal(shellActionFromURL("reasonix://app/__shell/rm-rf"), null);
108 assert.equal(shellActionFromURL("reasonix://app/index.html"), null);
109 const html = renderFailurePage({ code: -32003, name: "contract_mismatch", title: "Mixed <install>", detail: "digest \"a\" != 'b'" }, "/logs");
110 assert.match(html, /Mixed &lt;install&gt;/);
111 assert.match(html, /digest &quot;a&quot; != &#39;b&#39;/);
112 assert.match(html, /contract_mismatch \(-32003\)/);
113 assert.doesNotMatch(html, /reasonix:\/\/app\/__shell\/restart/);
114 for (const name of ["build_mismatch", "contract_mismatch"]) {
115 assert.doesNotMatch(renderFailurePage({ code: -32003, name, title: "Mismatch", detail: "Install the complete package" }, "/logs"), /reasonix:\/\/app\/__shell\/restart/);
116 }
117 assert.match(renderFailurePage({ code: -1, name: "service_failed", title: "Failed", detail: "Retry" }, "/logs"), /reasonix:\/\/app\/__shell\/restart/);
118 });
119
119 lines TYPESCRIPT