| 1 | // Run: node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/browser-panel.test.tsx |
| 2 | import assert from "node:assert/strict"; |
| 3 | import React, { act } from "react"; |
| 4 | import { JSDOM } from "jsdom"; |
| 5 | |
| 6 | import type { BrowserDownloadView, BrowserLayoutRect, BrowserTabView, DesktopBrowserHost } from "../lib/browserHost"; |
| 7 | import type { ReasonixDesktopHost } from "../lib/desktopHost"; |
| 8 | |
| 9 | const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost", pretendToBeVisual: true }); |
| 10 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, localStorage: dom.window.localStorage, |
| 11 | Event: dom.window.Event, KeyboardEvent: dom.window.KeyboardEvent, InputEvent: dom.window.InputEvent, |
| 12 | MouseEvent: dom.window.MouseEvent, MutationObserver: dom.window.MutationObserver, |
| 13 | Node: dom.window.Node, HTMLElement: dom.window.HTMLElement, HTMLInputElement: dom.window.HTMLInputElement, |
| 14 | requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), |
| 15 | cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), |
| 16 | IS_REACT_ACT_ENVIRONMENT: true }); |
| 17 | class TestResizeObserver { observe() {} unobserve() {} disconnect() {} } |
| 18 | (dom.window as unknown as { ResizeObserver: unknown }).ResizeObserver = TestResizeObserver; |
| 19 | // React's legacy input-event fallback expects these IE hooks when JSDOM does |
| 20 | // not advertise native InputEvent support. |
| 21 | Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} }); |
| 22 | Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} }); |
| 23 | |
| 24 | // Import ReactDOM and the component only after installing the DOM so React |
| 25 | // selects its native input-event path instead of the legacy IE polyfill. |
| 26 | const [{ createRoot }, { BrowserPanel }, { useBrowserPanelStore }, { LocaleProvider }, { ToastProvider }] = await Promise.all([ |
| 27 | import("react-dom/client"), |
| 28 | import("../components/BrowserPanel"), |
| 29 | import("../lib/browserPanelStore"), |
| 30 | import("../lib/i18n"), |
| 31 | import("../lib/toast"), |
| 32 | ]); |
| 33 | |
| 34 | const tab = (overrides: Partial<BrowserTabView>): BrowserTabView => ({ |
| 35 | id: "t1", taskId: "A", url: "https://example.com/", title: "Example", loading: false, canGoBack: false, canGoForward: false, |
| 36 | temporary: false, mode: "agent", epoch: 0, zoom: 1, error: null, ...overrides, |
| 37 | }); |
| 38 | const calls: string[] = []; |
| 39 | const layouts: (BrowserLayoutRect | null)[] = []; |
| 40 | const overlays: boolean[] = []; |
| 41 | let tabsCb: ((tabs: BrowserTabView[]) => void) | null = null; |
| 42 | let downloadCb: ((entry: BrowserDownloadView) => void) | null = null; |
| 43 | let closeError: Error | null = null; |
| 44 | const browser: DesktopBrowserHost = { |
| 45 | list: async () => [], |
| 46 | open: async (url, opts) => { calls.push(`open ${url} ${opts?.taskId}`); return tab({ id: "opened", url, title: "", taskId: opts?.taskId ?? "" }); }, |
| 47 | close: async (id) => { calls.push(`close ${id}`); if (closeError) throw closeError; }, |
| 48 | activate: async (id) => { calls.push(`activate ${id}`); }, |
| 49 | navigate: async (id, target) => { calls.push(`navigate ${id} ${target.action ?? target.url}`); }, |
| 50 | setZoom: async (id, factor) => { calls.push(`zoom ${id} ${factor}`); }, |
| 51 | toggleDevTools: async (id) => { calls.push(`devtools ${id}`); }, |
| 52 | resume: async (id) => { calls.push(`resume ${id}`); }, |
| 53 | takeover: async (id) => { calls.push(`takeover ${id}`); }, |
| 54 | setLayout: (rect) => { layouts.push(rect); }, |
| 55 | setOverlay: (active) => { overlays.push(active); }, |
| 56 | onTabs: (cb) => { tabsCb = cb; return () => { tabsCb = null; }; }, |
| 57 | onDownload: (cb) => { downloadCb = cb; return () => { downloadCb = null; }; }, |
| 58 | }; |
| 59 | const noop = () => {}; |
| 60 | window.reasonixDesktop = { |
| 61 | kind: "electron", |
| 62 | contract: { protocolVersion: 1, digest: "test", commands: [] }, |
| 63 | platform: { os: "darwin", arch: "arm64", versions: {} }, |
| 64 | invoke: async () => undefined, |
| 65 | on: () => noop, |
| 66 | native: { |
| 67 | openExternal: async () => {}, |
| 68 | clipboard: { writeText: async () => true, readText: async () => "" }, |
| 69 | window: { setTheme: noop, setBackgroundColour: noop, getBounds: async () => ({ x: 0, y: 0, width: 0, height: 0, maximised: false }), |
| 70 | isMaximised: async () => false, minimise: noop, toggleMaximise: noop, close: noop }, |
| 71 | getPathForFile: () => "", |
| 72 | onServiceState: () => noop, |
| 73 | }, |
| 74 | browser, |
| 75 | } satisfies ReasonixDesktopHost; |
| 76 | |
| 77 | const root = createRoot(document.getElementById("root")!); |
| 78 | const paint = (taskId: string) => act(async () => root.render( |
| 79 | <LocaleProvider><ToastProvider><BrowserPanel taskId={taskId} /></ToastProvider></LocaleProvider>, |
| 80 | )); |
| 81 | const emitTabs = (tabs: BrowserTabView[]) => act(async () => { tabsCb?.(tabs); }); |
| 82 | const byLabel = (label: string) => [...document.querySelectorAll<HTMLElement>("[aria-label]")].find((el) => el.getAttribute("aria-label") === label); |
| 83 | const setInputValue = (input: HTMLInputElement, value: string) => { |
| 84 | Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, "value")!.set!.call(input, value); |
| 85 | input.dispatchEvent(new dom.window.InputEvent("input", { bubbles: true })); |
| 86 | }; |
| 87 | const key = (target: Element, init: KeyboardEventInit) => target.dispatchEvent(new dom.window.KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init })); |
| 88 | |
| 89 | console.log("\nbrowser panel"); |
| 90 | try { |
| 91 | await paint("A"); |
| 92 | assert.ok(document.querySelector(".browser-panel__empty"), "no tabs renders the compact empty state"); |
| 93 | assert.ok(byLabel("Address"), "the empty state carries the address bar"); |
| 94 | assert.equal(document.querySelector("[data-browser-surface]"), null, "no native surface without a tab"); |
| 95 | assert.deepEqual(overlays, [false], "the overlay gate reports its initial state"); |
| 96 | assert.deepEqual(layouts, [], "no layout is reported without a surface"); |
| 97 | assert.ok(tabsCb, "the panel subscribes to tab updates"); |
| 98 | |
| 99 | const address = byLabel("Address") as HTMLInputElement; |
| 100 | await act(async () => { setInputValue(address, "example.com"); key(address, { key: "Enter" }); }); |
| 101 | assert.equal(calls.at(-2), "open https://example.com user", "Enter in the empty state opens a user tab with https"); |
| 102 | assert.equal(calls.at(-1), "activate opened"); |
| 103 | assert.ok(document.querySelector("[data-browser-surface]"), "an open tab mounts the native surface"); |
| 104 | assert.deepEqual(layouts.at(-1), { x: 0, y: 0, width: 0, height: 0 }, "the surface rect reaches the shell"); |
| 105 | assert.equal(document.querySelectorAll("[role='tab']").length, 1); |
| 106 | |
| 107 | await emitTabs([tab({ id: "t1", loading: true, canGoBack: true }), tab({ id: "other", taskId: "B", title: "Other task" })]); |
| 108 | assert.equal(document.querySelectorAll("[role='tab']").length, 1, "another task's tab is not shown"); |
| 109 | assert.ok(byLabel("Loading"), "a loading tab shows its spinner"); |
| 110 | assert.ok(byLabel("Stop loading"), "loading swaps reload for stop"); |
| 111 | assert.equal(byLabel("Reload"), undefined); |
| 112 | assert.equal((byLabel("Back") as HTMLButtonElement).disabled, false); |
| 113 | assert.equal((byLabel("Forward") as HTMLButtonElement).disabled, true); |
| 114 | await act(async () => byLabel("Stop loading")!.click()); |
| 115 | assert.equal(calls.at(-1), "navigate t1 stop"); |
| 116 | |
| 117 | const layoutCount = layouts.length; |
| 118 | await emitTabs([tab({ id: "t1", error: { code: -105, description: "ERR_NAME_NOT_RESOLVED" } })]); |
| 119 | const alert = document.querySelector("[role='alert']"); |
| 120 | assert.ok(alert?.textContent?.includes("ERR_NAME_NOT_RESOLVED (-105)"), "the Chromium error code and description are shown"); |
| 121 | assert.equal(document.querySelector("[data-browser-surface]"), null, "the error state replaces the native surface"); |
| 122 | assert.equal(layouts.at(-1), null, "hiding the surface releases the native view"); |
| 123 | assert.equal(layouts.length, layoutCount + 1); |
| 124 | await act(async () => [...document.querySelectorAll("button")].find((el) => el.textContent === "Retry")!.click()); |
| 125 | assert.equal(calls.at(-1), "navigate t1 reload", "Retry reloads the tab"); |
| 126 | |
| 127 | const takeOver = [...document.querySelectorAll("button")].find((el) => el.textContent === "Take over"); |
| 128 | assert.ok(takeOver, "agent mode exposes an explicit takeover button"); |
| 129 | await act(async () => takeOver.click()); |
| 130 | assert.equal(calls.at(-1), "takeover t1", "Take over uses the application host operation"); |
| 131 | await emitTabs([tab({ id: "t1", mode: "human", temporary: true, zoom: 1.25, epoch: 1 })]); |
| 132 | assert.equal([...document.querySelectorAll("button")].some((el) => el.textContent === "Take over"), false); |
| 133 | const banner = document.querySelector(".browser-panel__takeover"); |
| 134 | assert.ok(banner?.textContent?.includes("You are controlling this page; the agent is paused"), "human mode shows the take-over banner"); |
| 135 | assert.ok(document.querySelector(".browser-tab__badge")?.textContent === "Temporary", "temporary tabs carry a badge"); |
| 136 | assert.equal(byLabel("Reset zoom (125%)")?.textContent, "125%"); |
| 137 | await act(async () => [...banner!.querySelectorAll("button")].find((el) => el.textContent === "Resume")!.click()); |
| 138 | assert.equal(calls.at(-1), "resume t1", "Resume hands the page back to the agent"); |
| 139 | await emitTabs([tab({ id: "t1", mode: "agent", temporary: true, zoom: 1.25, epoch: 2 })]); |
| 140 | assert.equal(document.querySelector(".browser-panel__takeover"), null, "authoritative Resume clears the takeover banner"); |
| 141 | assert.ok([...document.querySelectorAll("button")].find((el) => el.textContent === "Take over"), "Resume restores the manual takeover entry"); |
| 142 | await act(async () => byLabel("Zoom in")!.click()); |
| 143 | assert.equal(calls.at(-1), "zoom t1 1.5"); |
| 144 | await act(async () => byLabel("Toggle DevTools")!.click()); |
| 145 | assert.equal(calls.at(-1), "devtools t1"); |
| 146 | |
| 147 | await act(async () => { downloadCb?.({ id: "d1", tabId: "t1", url: "https://example.com/a.zip", filename: "a.zip", path: "/tmp/a.zip", state: "progressing", received: 50, total: 100 }); }); |
| 148 | const strip = document.querySelector(".browser-panel__downloads"); |
| 149 | assert.ok(strip?.textContent?.includes("a.zip") && strip.textContent.includes("50%"), "downloads show filename and progress"); |
| 150 | await act(async () => { downloadCb?.({ id: "d1", tabId: "t1", url: "https://example.com/a.zip", filename: "a.zip", path: "/tmp/a.zip", state: "completed", received: 100, total: 100 }); }); |
| 151 | assert.ok(document.querySelector(".browser-panel__downloads")?.textContent?.includes("Completed")); |
| 152 | |
| 153 | byLabel("Back")!.focus(); |
| 154 | await act(async () => { key(byLabel("Back")!, { key: "l", ctrlKey: true }); }); |
| 155 | assert.equal(document.activeElement, byLabel("Address"), "Ctrl+L focuses the address bar while the panel has focus"); |
| 156 | await act(async () => { setInputValue(byLabel("Address") as HTMLInputElement, "docs.example"); byLabel("New tab from address")!.click(); }); |
| 157 | assert.equal(calls.at(-2), "open https://docs.example user", "+ opens a tab from the typed draft"); |
| 158 | |
| 159 | closeError = new Error("tab is busy"); |
| 160 | await act(async () => byLabel("Close Example")!.click()); |
| 161 | assert.ok(document.querySelector(".toast--error")?.textContent?.includes("Browser: tab is busy"), "host failures surface as error toasts"); |
| 162 | |
| 163 | await act(async () => root.unmount()); |
| 164 | assert.equal(layouts.at(-1), null, "unmount releases the layout"); |
| 165 | assert.equal(calls.at(-1), "activate null", "unmount deactivates the native view"); |
| 166 | assert.equal(tabsCb, null, "unmount unsubscribes from the host"); |
| 167 | assert.equal(useBrowserPanelStore.getState().host, null); |
| 168 | console.log("browser panel: empty, loading, error, take-over, downloads, keyboard and toast states passed"); |
| 169 | } finally { |
| 170 | dom.window.close(); |
| 171 | } |
| 172 |