返回 DeepSeek-Reasonix
surfaceManager.test.ts
根目录 / desktop / electron / src / main / browser / surfaceManager.test.ts
1 import assert from "node:assert/strict";
2 import { test } from "node:test";
3 import type { BrowserTabView } from "../../shared/ipc.js";
4 import { FakeGuestView, FakeViewFactory, silentLog } from "./fakeGuestViews.js";
5 import { BrowserSurfaceManager, normaliseBrowserURL, validateLayout, type BrowserTab } from "./surfaceManager.js";
6
7 function setup(options: { now?: () => number } = {}) {
8 const factory = new FakeViewFactory();
9 const takeovers: Array<{ tabId: string; epoch: number; reason: string }> = [];
10 const crashes: string[] = [];
11 const broadcasts: BrowserTabView[][] = [];
12 const manager = new BrowserSurfaceManager({
13 views: factory,
14 contentSize: () => ({ width: 1000, height: 700 }),
15 onTakeover: (tab: BrowserTab, reason: string) => takeovers.push({ tabId: tab.id, epoch: tab.epoch, reason }),
16 onCrash: (_tab, reason) => crashes.push(reason),
17 log: silentLog,
18 now: options.now,
19 openWaitMs: 10,
20 });
21 manager.subscribe((tabs) => broadcasts.push(tabs));
22 const viewOf = (tab: BrowserTab) => tab.view as FakeGuestView;
23 return { factory, manager, takeovers, crashes, broadcasts, viewOf };
24 }
25
26 test("logical tab limit never evicts a live page and viewport changes invalidate observations", async () => {
27 const { manager } = setup();
28 for (let n = 0; n < 32; n++) await manager.open("https://example.test", { taskId: "task", temporary: false });
29 await assert.rejects(manager.open("https://example.test", { taskId: "task", temporary: false }), /limit/);
30 assert.equal(manager.all().length, 32);
31 const tab = manager.all()[0], epoch = tab.epoch;
32 tab.view.setViewport = () => {};
33 manager.setViewport(tab.id, { width: 393, height: 852, scale: "fit" });
34 assert.ok(tab.epoch > epoch);
35 assert.throws(() => manager.setViewport(tab.id, { width: 1, height: 852, scale: "fit" }), /viewport/);
36 manager.destroyAll();
37 });
38
39 test("URLs are normalised to http(s) and bare hosts get https", () => {
40 assert.equal(normaliseBrowserURL("example.com"), "https://example.com/");
41 assert.equal(normaliseBrowserURL(" http://a.b/c?d=1 "), "http://a.b/c?d=1");
42 assert.throws(() => normaliseBrowserURL("file:///etc/passwd"), /only http\(s\)/);
43 assert.throws(() => normaliseBrowserURL("javascript:alert(1)"), /only http\(s\)/);
44 assert.throws(() => normaliseBrowserURL(""), /empty/);
45 });
46
47 test("layout rects are rounded, clamped to the window and never negative", () => {
48 assert.deepEqual(validateLayout({ x: 10.4, y: -5, width: 2000, height: 300.6 }, { width: 1000, height: 700 }), { x: 10, y: 0, width: 990, height: 301 });
49 assert.deepEqual(validateLayout({ x: 1200, y: 0, width: 100, height: 100 }, { width: 1000, height: 700 }), { x: 1200, y: 0, width: 0, height: 100 });
50 assert.throws(() => validateLayout({ x: Number.NaN, y: 0, width: 1, height: 1 }, null), /finite/);
51 });
52
53 test("open, activate, layout and overlay drive visibility of exactly one view", async () => {
54 const { manager, viewOf } = setup();
55 const first = await manager.open("example.com", { taskId: "task-1", temporary: false });
56 const second = await manager.open("https://b.test", { taskId: "task-1", temporary: true });
57 assert.equal(first.partition, "persist:browser");
58 assert.equal(second.partition, `temp:${second.id}`);
59 assert.equal(viewOf(first).page.calls[0], "load:https://example.com/");
60 assert.equal(manager.activeTabId, null, "opens do not select a native view without the application renderer");
61 manager.activate(second.id);
62 assert.equal(viewOf(second).visible, false, "no layout yet: nothing is visible");
63
64 manager.setLayout({ x: 300, y: 100, width: 600, height: 500 });
65 assert.equal(viewOf(second).visible, true);
66 assert.deepEqual(viewOf(second).bounds, { x: 300, y: 100, width: 600, height: 500 });
67 assert.equal(viewOf(first).visible, false);
68
69 manager.activate(first.id);
70 assert.equal(viewOf(first).visible, true);
71 assert.equal(viewOf(second).visible, false);
72
73 manager.setOverlay(true);
74 assert.equal(viewOf(first).visible, false);
75 manager.setOverlay(false);
76 assert.equal(viewOf(first).visible, true);
77
78 manager.setLayout(null);
79 assert.equal(viewOf(first).visible, false);
80 manager.setLayout({ x: 0, y: 0, width: 100, height: 100 });
81 manager.activate(null);
82 assert.equal(viewOf(first).visible, false);
83 assert.throws(() => manager.activate("tab-99"), /unknown browser tab/);
84 });
85
86 test("navigation bumps the epoch, records the URL and clears load errors", async () => {
87 const { manager, viewOf, broadcasts } = setup();
88 const tab = await manager.open("https://a.test", { taskId: "t", temporary: false });
89 manager.activate(tab.id);
90 const events = viewOf(tab).fire();
91 const before = broadcasts.length;
92 events.onStartLoading();
93 assert.equal(tab.loading, true);
94 events.onFailLoad(-105, "ERR_NAME_NOT_RESOLVED", "https://a.test/");
95 assert.deepEqual(tab.error, { code: -105, description: "ERR_NAME_NOT_RESOLVED" });
96 const epoch = tab.epoch;
97 events.onNavigate("https://a.test/next", false);
98 assert.equal(tab.epoch, epoch + 1);
99 assert.equal(tab.lastURL, "https://a.test/next");
100 assert.equal(tab.error, null);
101 events.onNavigate("https://a.test/next#x", true);
102 assert.equal(tab.epoch, epoch + 2);
103 events.onStopLoading();
104 assert.equal(tab.loading, false);
105 assert.ok(broadcasts.length >= before + 5, "every change is broadcast");
106 const view = manager.list()[0];
107 assert.equal(view.mode, "agent");
108 assert.equal(view.taskId, "t");
109 assert.equal(view.active, true);
110 });
111
112 test("take-over flips to human mode, bumps the epoch and reports to Go; resume hands back", async () => {
113 let now = 1000;
114 const { manager, takeovers } = setup({ now: () => now });
115 const tab = await manager.open("https://a.test", { taskId: "t", temporary: false });
116 const epoch = tab.epoch;
117 assert.equal(manager.takeoverFromSender(999, "mousedown"), false, "unknown senders are ignored");
118 assert.equal(manager.takeoverFromSender(tab.view.page.id, "mousedown"), true);
119 assert.equal(tab.mode, "human");
120 assert.equal(tab.epoch, epoch + 1);
121 assert.deepEqual(takeovers, [{ tabId: tab.id, epoch: epoch + 1, reason: "user mousedown" }]);
122
123 manager.resume(tab.id);
124 assert.equal(tab.mode, "agent");
125 assert.equal(tab.epoch, epoch + 2);
126 manager.resume(tab.id);
127 assert.equal(tab.epoch, epoch + 2, "resume is idempotent");
128
129 manager.markAgentInput(tab);
130 now += 100;
131 assert.equal(manager.takeoverFromSender(tab.view.page.id, "keydown"), false, "the agent's own echoed input is not a take-over");
132 assert.equal(tab.mode, "agent");
133 now += 1000;
134 assert.equal(manager.takeoverFromSender(tab.view.page.id, "keydown"), true);
135 assert.equal(tab.mode, "human");
136 });
137
138 test("a crashed website view reloads its last URL in human mode and emits crash", async () => {
139 const { manager, viewOf, crashes } = setup();
140 const tab = await manager.open("https://a.test", { taskId: "t", temporary: false });
141 const events = viewOf(tab).fire();
142 events.onNavigate("https://a.test/page", false);
143 const epoch = tab.epoch;
144 const partition = tab.partition;
145 events.onRenderProcessGone("crashed");
146 assert.equal(tab.mode, "human");
147 assert.equal(tab.epoch, epoch + 1);
148 // Promoted from prototypes/electron-browser: the reload keeps the persistent
149 // partition, so website logins survive a renderer crash.
150 assert.equal(tab.partition, partition, "recovery preserves the login partition");
151 assert.deepEqual(crashes, ["crashed"]);
152 assert.equal(viewOf(tab).page.calls.at(-1), "load:https://a.test/page");
153 for (let i = 0; i < 5; i++) events.onRenderProcessGone("oom");
154 assert.equal(viewOf(tab).page.calls.filter((call) => call === "load:https://a.test/page").length, 3, "reloads are capped");
155 });
156
157 test("popups retain task and partition without stealing the application selection", async () => {
158 const { manager, viewOf, factory } = setup();
159 const tab = await manager.open("https://a.test", { taskId: "t", temporary: true });
160 manager.activate(tab.id);
161 const adopt = viewOf(tab).fire().onPopup("https://login.test/oauth", "new-window");
162 assert.ok(adopt);
163 const child = factory.create(tab.partition) as FakeGuestView;
164 adopt(child);
165 const tabs = manager.all();
166 assert.equal(tabs.length, 2);
167 assert.equal(tabs[1].taskId, "t");
168 assert.equal(tabs[1].partition, tab.partition);
169 assert.equal(tabs[1].temporary, true);
170 assert.equal(manager.activeTabId, tab.id);
171 assert.ok(child.events, "the popup view is bound to its own tab record");
172 manager.close(tab.id);
173 assert.equal(viewOf(tab).fire().onPopup("https://x", "new-window"), null, "a closed tab cannot spawn popups");
174 });
175
176 test("close and destroyAll tear views down and leave replacement selection to the renderer", async () => {
177 const { manager, viewOf } = setup();
178 const a = await manager.open("https://a.test", { taskId: "t", temporary: false });
179 const b = await manager.open("https://b.test", { taskId: "t", temporary: false });
180 manager.activate(b.id);
181 manager.setLayout({ x: 0, y: 0, width: 10, height: 10 });
182 manager.close(b.id);
183 assert.equal(viewOf(b).destroyed, true);
184 assert.equal(manager.activeTabId, null);
185 assert.equal(viewOf(a).visible, false);
186 manager.activate(a.id);
187 assert.equal(viewOf(a).visible, true);
188 assert.equal(b.mode, "human", "a closed tab fails every pending act as taken over");
189 viewOf(a).fire().onDestroyed();
190 assert.equal(manager.all().length, 0, "a page closing itself drops the record");
191 const c = await manager.open("https://c.test", { taskId: "t", temporary: false });
192 manager.destroyAll();
193 assert.equal(viewOf(c).destroyed, true);
194 assert.equal(manager.all().length, 0);
195 assert.equal(c.mode, "human");
196 });
197
198 test("background opens never replace the current task's page or revive a detached renderer", async () => {
199 const { manager, viewOf, takeovers } = setup();
200 const a = await manager.open("https://a.test", { taskId: "A", temporary: false });
201 manager.activate(a.id);
202 manager.setLayout({ x: 400, y: 100, width: 500, height: 500 });
203 const b = await manager.open("https://b.test", { taskId: "B", temporary: false });
204 assert.equal(manager.activeTabId, a.id);
205 assert.equal(viewOf(a).visible, true);
206 assert.equal(viewOf(b).visible, false);
207 const epoch = a.epoch;
208 manager.pauseForRendererLoss("app renderer crashed");
209 assert.equal(manager.activeTabId, null);
210 assert.equal(viewOf(a).visible, false);
211 assert.equal(a.mode, "human");
212 assert.ok(a.epoch > epoch, "pending acts lose their document epoch");
213 assert.equal(takeovers.length, 2);
214 manager.activate(a.id);
215 assert.equal(viewOf(a).visible, false, "selection cannot restore the lost layout");
216 manager.setLayout({ x: 400, y: 100, width: 500, height: 500 });
217 assert.equal(viewOf(a).visible, true);
218 assert.equal(a.mode, "human", "remount never resumes actions automatically");
219 });
220
221 test("navigate, zoom and devtools act on the page", async () => {
222 const { manager, viewOf } = setup();
223 const tab = await manager.open("https://a.test", { taskId: "t", temporary: false });
224 const page = viewOf(tab).page;
225 page.back = true;
226 await manager.navigate(tab.id, { action: "back" });
227 await manager.navigate(tab.id, { action: "forward" });
228 await manager.navigate(tab.id, { action: "reload" });
229 await manager.navigate(tab.id, { action: "stop" });
230 await manager.navigate(tab.id, { url: "b.test/x" });
231 assert.deepEqual(page.calls.slice(1), ["back", "reload", "stop", "load:https://b.test/x"]);
232 await assert.rejects(manager.navigate(tab.id, {}), /needs a url/);
233 manager.setZoom(tab.id, 9);
234 assert.equal(tab.zoom, 5);
235 assert.equal(page.zoom, 5);
236 assert.throws(() => manager.setZoom(tab.id, Number.NaN), /finite/);
237 manager.toggleDevTools(tab.id);
238 assert.equal(page.devtools, true);
239 manager.toggleDevTools(tab.id);
240 assert.equal(page.devtools, false);
241 });
242
242 lines TYPESCRIPT