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