返回 DeepSeek-Reasonix
browser-panel.test.tsx
根目录 / desktop / frontend / src / __tests__ / browser-panel.test.tsx
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
172 lines Plain Text