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