返回 DeepSeek-Reasonix
browser-panel-store.test.ts
根目录 / desktop / frontend / src / __tests__ / browser-panel-store.test.ts
1 // Run: tsx src/__tests__/browser-panel-store.test.ts
2 import assert from "node:assert/strict";
3
4 import { normalizeAddress, zoomStep } from "../lib/browserAddress";
5 import type { BrowserDownloadView, BrowserTabView, DesktopBrowserHost } from "../lib/browserHost";
6 import { selectActiveTab, selectAddress, USER_TASK_ID, useBrowserPanelStore, waitForBrowserHost } from "../lib/browserPanelStore";
7
8 const tab = (overrides: Partial<BrowserTabView>): BrowserTabView => ({
9 id: "t", taskId: "A", url: "https://example.com/", title: "Example", loading: false, canGoBack: false, canGoForward: false,
10 temporary: false, mode: "agent", epoch: 0, zoom: 1, error: null, ...overrides,
11 });
12 const download = (overrides: Partial<BrowserDownloadView>): BrowserDownloadView => ({
13 id: "d", tabId: "t", url: "https://example.com/file.zip", filename: "file.zip", path: "/tmp/file.zip",
14 state: "progressing", received: 0, total: 100, ...overrides,
15 });
16 const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
17
18 function fakeHost(initial: BrowserTabView[] = []) {
19 const calls: string[] = [];
20 const fail: Partial<Record<"open" | "close" | "navigate" | "takeover", Error>> = {};
21 let tabsCb: ((tabs: BrowserTabView[]) => void) | null = null;
22 let downloadCb: ((entry: BrowserDownloadView) => void) | null = null;
23 const host: DesktopBrowserHost = {
24 list: async () => { calls.push("list"); return initial; },
25 open: async (url, opts) => {
26 calls.push(`open ${url} ${opts?.taskId} ${opts?.temporary}`);
27 if (fail.open) throw fail.open;
28 return tab({ id: `new:${url}`, url, taskId: opts?.taskId ?? "" });
29 },
30 close: async (id) => { calls.push(`close ${id}`); if (fail.close) throw fail.close; },
31 activate: async (id) => { calls.push(`activate ${id}`); },
32 navigate: async (id, target) => { calls.push(`navigate ${id} ${JSON.stringify(target)}`); if (fail.navigate) throw fail.navigate; },
33 setZoom: async (id, factor) => { calls.push(`zoom ${id} ${factor}`); },
34 toggleDevTools: async (id) => { calls.push(`devtools ${id}`); },
35 resume: async (id) => { calls.push(`resume ${id}`); },
36 takeover: async (id) => { calls.push(`takeover ${id}`); if (fail.takeover) throw fail.takeover; },
37 setLayout: () => {},
38 setOverlay: () => {},
39 onTabs: (cb) => { tabsCb = cb; return () => { tabsCb = null; }; },
40 onDownload: (cb) => { downloadCb = cb; return () => { downloadCb = null; }; },
41 };
42 return {
43 host, calls, fail,
44 emitTabs: (tabs: BrowserTabView[]) => tabsCb?.(tabs),
45 emitDownload: (entry: BrowserDownloadView) => downloadCb?.(entry),
46 get subscribed() { return tabsCb !== null; },
47 };
48 }
49
50 const reset = () => useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true);
51 const store = () => useBrowserPanelStore.getState();
52 const notices: string[] = [];
53 const notify = (message: string) => { notices.push(message); };
54
55 console.log("\nbrowser panel store");
56
57 {
58 reset();
59 const fake = fakeHost();
60 const pending = waitForBrowserHost(500);
61 const detach = store().attach(fake.host, notify);
62 assert.equal(await pending, fake.host, "host readiness resolves from the store subscription without animation-frame polling");
63 detach();
64 console.log(" PASS browser host readiness subscription");
65 }
66
67 {
68 reset();
69 const fake = fakeHost();
70 const detach = store().attach(fake.host, notify);
71 await tick();
72 fake.emitTabs([tab({ id: "a1", taskId: "A" }), tab({ id: "b1", taskId: "B" }), tab({ id: "u1", taskId: USER_TASK_ID })]);
73 store().setTaskId("A");
74 store().setVisible(true);
75 assert.deepEqual(store().shown.map((entry) => entry.id), ["a1", "u1"], "current task tabs plus user tabs are shown");
76 assert.equal(store().activeTabId, "u1", "the newest shown tab becomes active");
77 assert.deepEqual(fake.calls.filter((call) => call.startsWith("activate")), ["activate u1"]);
78 store().setTaskId("B");
79 assert.deepEqual(store().shown.map((entry) => entry.id), ["b1", "u1"]);
80 assert.equal(store().activeTabId, "u1", "a still-shown active tab survives a task switch");
81 assert.equal(fake.calls.filter((call) => call.startsWith("activate")).length, 1, "unchanged activation is not resent");
82 store().activate("b1");
83 assert.equal(selectActiveTab(store())?.id, "b1");
84 store().setTaskId("A");
85 assert.equal(store().activeTabId, "u1", "another task's active tab is never shown for this task");
86 store().activate("a1");
87 store().setVisible(false);
88 assert.equal(fake.calls[fake.calls.length - 1], "activate null", "hiding the panel deactivates the native view");
89 store().setVisible(true);
90 assert.equal(fake.calls[fake.calls.length - 1], "activate a1");
91 fake.emitTabs([tab({ id: "u1", taskId: USER_TASK_ID })]);
92 assert.equal(store().activeTabId, "u1", "a closed active tab falls back to a shown tab");
93 detach();
94 assert.equal(fake.subscribed, false, "detach unsubscribes tab updates");
95 assert.equal(fake.calls[fake.calls.length - 1], "activate null", "detach releases the native view");
96 assert.equal(store().host, null);
97 fake.emitTabs([]);
98 assert.equal(store().shown.length, 1, "updates after detach are ignored");
99 console.log(" PASS tab projection per task, activation sync and detach");
100 }
101
102 {
103 reset();
104 const fake = fakeHost([tab({ id: "seed", taskId: "A" })]);
105 store().attach(fake.host, notify);
106 store().setTaskId("A");
107 store().setVisible(true);
108 await tick();
109 assert.deepEqual(store().shown.map((entry) => entry.id), ["seed"], "attach lists existing tabs");
110 assert.equal(selectAddress(store()), "https://example.com/", "address falls back to the active tab url");
111 await store().takeover("seed");
112 assert.equal(fake.calls[fake.calls.length - 1], "takeover seed", "explicit takeover reaches the host for the selected tab");
113 assert.equal(selectActiveTab(store())?.mode, "agent", "ownership waits for the authoritative host update");
114 fake.emitTabs([tab({ id: "seed", mode: "human", epoch: 1 })]);
115 assert.equal(selectActiveTab(store())?.mode, "human");
116 await store().resume("seed");
117 assert.equal(fake.calls[fake.calls.length - 1], "resume seed");
118 fake.emitTabs([tab({ id: "seed", mode: "agent", epoch: 2 })]);
119 assert.equal(selectActiveTab(store())?.mode, "agent");
120 store().setDraft("seed", "localhost:3000/app");
121 assert.equal(selectAddress(store()), "localhost:3000/app");
122 await store().submitAddress();
123 assert.equal(fake.calls[fake.calls.length - 1], 'navigate seed {"url":"http://localhost:3000/app"}', "Enter navigates the active tab");
124 assert.equal(selectAddress(store()), "https://example.com/", "the draft is consumed by navigation");
125 assert.equal(await store().openDraft(), false, "no typed draft means nothing to open");
126 store().setDraft("seed", " Example.org ");
127 assert.equal(await store().openDraft(), true);
128 assert.equal(fake.calls[fake.calls.length - 2], `open https://Example.org ${USER_TASK_ID} false`, "+ opens the draft as a user tab");
129 assert.equal(store().activeTabId, "new:https://Example.org");
130 assert.equal(fake.calls[fake.calls.length - 1], "activate new:https://Example.org");
131 store().clearDraft(store().activeTabId);
132 fake.emitTabs([]);
133 store().setDraft(null, "wiki.example");
134 await store().submitAddress();
135 assert.equal(fake.calls[fake.calls.length - 2], `open https://wiki.example ${USER_TASK_ID} false`, "Enter without a tab opens one");
136 await store().zoom(store().activeTabId!, 1);
137 assert.equal(fake.calls[fake.calls.length - 1], `zoom ${store().activeTabId} 1.1`);
138 await store().close(store().activeTabId!);
139 assert.equal(store().shown.length, 0, "a closed tab leaves the projection immediately");
140 console.log(" PASS address submission, draft lifecycle, open, zoom and close");
141 }
142
143 {
144 reset();
145 notices.length = 0;
146 const fake = fakeHost();
147 store().attach(fake.host, notify);
148 store().setTaskId("A");
149 store().setVisible(true);
150 fake.emitTabs([tab({ id: "a1" })]);
151 fake.fail.navigate = new Error("navigation refused");
152 await store().navigate("a1", { action: "reload" });
153 fake.fail.close = new Error("close refused");
154 await store().close("a1");
155 fake.fail.open = new Error("open refused");
156 await store().open("https://example.net");
157 fake.fail.takeover = new Error("takeover refused");
158 await store().takeover("a1");
159 assert.deepEqual(notices, ["navigation refused", "close refused", "open refused", "takeover refused"], "host failures reach the notifier");
160 assert.equal(store().shown.length, 1, "a failed close keeps the tab");
161 console.log(" PASS host errors are surfaced through the bound notifier");
162 }
163
164 {
165 reset();
166 const fake = fakeHost();
167 store().attach(fake.host, notify);
168 for (let index = 0; index < 55; index += 1) fake.emitDownload(download({ id: `d${index}`, state: "completed" }));
169 assert.equal(store().downloads.length, 50, "downloads keep the last 50 entries");
170 assert.equal(store().downloads[0].id, "d54", "newest download first");
171 fake.emitDownload(download({ id: "d10", received: 40 }));
172 assert.equal(store().downloads[0].id, "d10", "a progress update moves its download to the front");
173 assert.equal(store().downloads.filter((entry) => entry.id === "d10").length, 1, "updates replace instead of duplicating");
174 store().clearDownloads();
175 assert.deepEqual(store().downloads.map((entry) => entry.id), ["d10"], "clearing keeps in-flight downloads");
176 console.log(" PASS download strip bookkeeping");
177 }
178
179 for (const [input, expected] of [
180 ["", null], [" ", null],
181 ["example.com", "https://example.com"], [" Docs.example.org/path?q=1 ", "https://Docs.example.org/path?q=1"],
182 ["http://example.com", "http://example.com"], ["HTTPS://example.com", "HTTPS://example.com"],
183 ["localhost:3000", "http://localhost:3000"], ["127.0.0.1:8080/x", "http://127.0.0.1:8080/x"], ["[::1]:5173", "http://[::1]:5173"],
184 ["localhost.example.com", "https://localhost.example.com"],
185 ["about:blank", "about:blank"], ["file:///tmp/a.html", "file:///tmp/a.html"], ["data:text/plain,hi", "data:text/plain,hi"],
186 ["ftp://host/file", "ftp://host/file"], ["javascript:alert(1)", "https://javascript:alert(1)"],
187 ] as const) {
188 assert.equal(normalizeAddress(input), expected, `normalizeAddress(${JSON.stringify(input)})`);
189 }
190 console.log(" PASS address normalisation");
191
192 assert.equal(zoomStep(1, 1), 1.1);
193 assert.equal(zoomStep(1, -1), 0.9);
194 assert.equal(zoomStep(1.1, 0), 1);
195 assert.equal(zoomStep(5, 1), 5, "zoom in saturates at the largest preset");
196 assert.equal(zoomStep(0.25, -1), 0.25, "zoom out saturates at the smallest preset");
197 assert.equal(zoomStep(1.3, 1), 1.5, "an off-preset factor snaps to the next preset");
198 assert.equal(zoomStep(1.3, -1), 1.25);
199 console.log(" PASS zoom presets");
200 console.log("browser panel store: all checks passed");
201
201 lines TYPESCRIPT