| 1 | // Browser control over CDP, driven by a scripted fake WebSocket: the command |
| 2 | // sequence, receipts, refusals and file output are all verified without a real |
| 3 | // browser. The live path gets its own smoke on a real Chrome. |
| 4 | import { test } from "node:test"; |
| 5 | import assert from "node:assert/strict"; |
| 6 | import fs from "node:fs"; |
| 7 | import os from "node:os"; |
| 8 | import path from "node:path"; |
| 9 | import { createBrowser, checkBrowserUrl, findBrowserApp } from "../src/browser-cdp.mjs"; |
| 10 | |
| 11 | class FakeWs { |
| 12 | constructor(script) { this.listeners = {}; this.sent = []; this.script = script; this.closed = false; } |
| 13 | addEventListener(type, fn) { (this.listeners[type] ??= []).push(fn); } |
| 14 | emit(type, event = {}) { for (const fn of this.listeners[type] ?? []) fn(event); } |
| 15 | close() { if (!this.closed) { this.closed = true; queueMicrotask(() => this.emit("close", {})); } } |
| 16 | send(raw) { |
| 17 | const msg = JSON.parse(raw); |
| 18 | this.sent.push(msg); |
| 19 | queueMicrotask(() => { |
| 20 | const respond = this.script[msg.method]; |
| 21 | const result = typeof respond === "function" ? respond(msg.params) : respond; |
| 22 | if (result === undefined) return; // fire-and-forget (Input domain) |
| 23 | this.emit("message", { data: JSON.stringify({ id: msg.id, ...(msg.sessionId ? { sessionId: msg.sessionId } : {}), result }) }); |
| 24 | }); |
| 25 | } |
| 26 | event(method, params, sessionId) { this.emit("message", { data: JSON.stringify({ method, params, sessionId }) }); } |
| 27 | sentOf(method) { return this.sent.filter((m) => m.method === method); } |
| 28 | } |
| 29 | |
| 30 | const pageState = () => ({ url: "about:blank", tabs: ["T1"] }); |
| 31 | function defaultScript(page) { |
| 32 | return { |
| 33 | "Target.createTarget": () => { const id = `T${page.tabs.length + 1}`; page.tabs.push(id); return { targetId: id }; }, |
| 34 | "Target.attachToTarget": { sessionId: "S1" }, |
| 35 | "Page.enable": {}, |
| 36 | "Page.navigate": (p) => { page.url = p.url; return { frameId: "F1" }; }, |
| 37 | "Target.getTargetInfo": () => ({ targetInfo: { url: page.url, title: `t:${page.url}` } }), |
| 38 | "Target.getTargets": () => ({ targetInfos: page.tabs.map((id) => ({ type: "page", targetId: id, url: page.url, title: `t:${page.url}` })) }), |
| 39 | "Target.closeTarget": (p) => { page.tabs = page.tabs.filter((id) => id !== p.targetId); return {}; }, |
| 40 | "Browser.close": (p, m) => (m === undefined ? {} : {}), |
| 41 | "Page.getLayoutMetrics": { cssLayoutViewport: { clientWidth: 800, clientHeight: 600 }, cssVisualViewport: { scale: 2 } }, |
| 42 | "Page.captureScreenshot": { data: Buffer.from("abc").toString("base64") }, |
| 43 | "DOM.enable": {}, |
| 44 | "DOM.getDocument": { root: { nodeId: 1 } }, |
| 45 | "DOM.querySelector": { nodeId: 7 }, |
| 46 | "DOM.scrollIntoViewIfNeeded": {}, |
| 47 | "DOM.getBoxModel": { model: { content: [10, 20, 30, 20, 30, 40, 10, 40] } }, |
| 48 | "DOM.focus": {}, |
| 49 | "Input.insertText": {}, |
| 50 | "Input.dispatchKeyEvent": {}, |
| 51 | "Input.dispatchMouseEvent": {}, |
| 52 | }; |
| 53 | } |
| 54 | |
| 55 | function harness(t, { script, connectFailures = 0, stateDir } = {}) { |
| 56 | const dir = stateDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "cu-browser-")); |
| 57 | const recDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-browser-rec-")); |
| 58 | t.after(() => { fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(recDir, { recursive: true, force: true }); }); |
| 59 | const prior = process.env.CODEWHALE_CU_STATE_DIR; |
| 60 | process.env.CODEWHALE_CU_STATE_DIR = dir; |
| 61 | t.after(() => { if (prior === undefined) delete process.env.CODEWHALE_CU_STATE_DIR; else process.env.CODEWHALE_CU_STATE_DIR = prior; }); |
| 62 | const page = pageState(); |
| 63 | const sockets = []; |
| 64 | const launches = []; |
| 65 | let connects = 0; |
| 66 | const browser = createBrowser({ |
| 67 | findApp: () => "/Applications/Google Chrome.app", |
| 68 | launch: async ({ profileDir }) => { |
| 69 | launches.push(profileDir); |
| 70 | fs.mkdirSync(profileDir, { recursive: true }); |
| 71 | fs.writeFileSync(path.join(profileDir, "DevToolsActivePort"), "4242\n/devtools/browser/test\n"); |
| 72 | }, |
| 73 | connect: async (url) => { |
| 74 | connects += 1; |
| 75 | if (connects <= connectFailures) throw Object.assign(new Error("connection refused"), { code: "browser_unavailable" }); |
| 76 | const ws = new FakeWs(script ?? defaultScript(page)); |
| 77 | sockets.push({ url, ws }); |
| 78 | return ws; |
| 79 | }, |
| 80 | recordingsDir: () => recDir, |
| 81 | }); |
| 82 | return { browser, sockets, launches, recDir, dir, page }; |
| 83 | } |
| 84 | |
| 85 | test("browser start launches the self-owned profile and binds one tab per session", async (t) => { |
| 86 | const { browser, sockets, launches, page } = harness(t); |
| 87 | const start = await browser.start({}); |
| 88 | assert.equal(start.running, true); |
| 89 | assert.equal(start.launched, true); |
| 90 | assert.equal(start.reused, false); |
| 91 | assert.equal(start.browser, "/Applications/Google Chrome.app"); |
| 92 | assert.equal(start.tab.id, "T1"); |
| 93 | assert.match(start.note, /self-owned profile/); |
| 94 | assert.equal(launches.length, 1); |
| 95 | assert.match(sockets[0].url, /^ws:\/\/127\.0\.0\.1:4242\/devtools\/browser\/test$/); |
| 96 | const ws = sockets[0].ws; |
| 97 | assert.equal(ws.sentOf("Target.createTarget").length, 0, "adopts the launch's blank tab"); |
| 98 | assert.equal(ws.sentOf("Target.attachToTarget")[0].params.targetId, "T1"); |
| 99 | assert.equal(ws.sentOf("Target.attachToTarget")[0].params.flatten, true); |
| 100 | page.url = "about:blank"; |
| 101 | |
| 102 | const status = await browser.status(); |
| 103 | assert.equal(status.running, true); |
| 104 | assert.equal(status.tabs.length, 1); |
| 105 | assert.equal(status.activeTab.id, "T1"); |
| 106 | }); |
| 107 | |
| 108 | test("browser start gives a busy instance a new tab instead of stealing one", async (t) => { |
| 109 | const h = harness(t); |
| 110 | h.page.tabs = ["T1", "T2"]; |
| 111 | const start = await h.browser.start({}); |
| 112 | assert.equal(start.tab.id, "T3"); |
| 113 | assert.deepEqual(h.page.tabs, ["T1", "T2", "T3"]); |
| 114 | assert.equal(h.sockets[0].ws.sentOf("Target.createTarget").length, 1); |
| 115 | }); |
| 116 | |
| 117 | test("browser navigate waits for load and reports url+title from the target, not from us", async (t) => { |
| 118 | const { browser, sockets } = harness(t); |
| 119 | await browser.start({}); |
| 120 | const ws = sockets[0].ws; |
| 121 | const nav = browser.navigate({ url: "https://example.com" }); |
| 122 | await new Promise((r) => setTimeout(r, 5)); |
| 123 | ws.event("Page.loadEventFired", {}, "S1"); |
| 124 | const receipt = await nav; |
| 125 | assert.equal(receipt.verified, true); |
| 126 | assert.equal(receipt.url, "https://example.com/"); |
| 127 | assert.equal(receipt.title, "t:https://example.com/"); |
| 128 | assert.equal(ws.sentOf("Page.navigate")[0].params.url, "https://example.com/"); |
| 129 | assert.equal(ws.sentOf("Page.navigate")[0].sessionId, "S1"); |
| 130 | }); |
| 131 | |
| 132 | test("browser navigate reports a load timeout honestly instead of claiming success", async (t) => { |
| 133 | const priorTimeout = process.env.CODEWHALE_CU_BROWSER_LOAD_TIMEOUT_MS; |
| 134 | process.env.CODEWHALE_CU_BROWSER_LOAD_TIMEOUT_MS = "120"; |
| 135 | t.after(() => { if (priorTimeout === undefined) delete process.env.CODEWHALE_CU_BROWSER_LOAD_TIMEOUT_MS; else process.env.CODEWHALE_CU_BROWSER_LOAD_TIMEOUT_MS = priorTimeout; }); |
| 136 | const { browser } = harness(t); |
| 137 | await browser.start({}); |
| 138 | const receipt = await browser.navigate({ url: "https://slow.test" }); |
| 139 | assert.equal(receipt.action_sent, true); |
| 140 | assert.equal(receipt.verified, false); |
| 141 | assert.match(receipt.note, /did not report load completion/); |
| 142 | }); |
| 143 | |
| 144 | test("browser click resolves a selector through the DOM domain and clicks its box center", async (t) => { |
| 145 | const { browser, sockets } = harness(t); |
| 146 | await browser.start({}); |
| 147 | const ws = sockets[0].ws; |
| 148 | const receipt = await browser.click({ selector: "#go" }); |
| 149 | assert.deepEqual(receipt.point, { x: 20, y: 30 }); |
| 150 | assert.equal(receipt.selector, "#go"); |
| 151 | assert.equal(receipt.pointer_moved, false); |
| 152 | assert.ok(ws.sentOf("DOM.querySelector").some((m) => m.params.selector === "#go" && m.params.nodeId === 1)); |
| 153 | const clicks = ws.sentOf("Input.dispatchMouseEvent").map((m) => m.params); |
| 154 | assert.deepEqual(clicks.map((c) => c.type), ["mouseMoved", "mousePressed", "mouseReleased"]); |
| 155 | assert.ok(clicks.every((c) => c.x === 20 && c.y === 30)); |
| 156 | }); |
| 157 | |
| 158 | test("browser click reports selector_not_found and bad_target for an out-of-viewport point", async (t) => { |
| 159 | const script = { ...defaultScript(pageState()), "DOM.querySelector": { nodeId: 0 } }; |
| 160 | const { browser } = harness(t, { script }); |
| 161 | await browser.start({}); |
| 162 | await assert.rejects(browser.click({ selector: "#missing" }), (e) => e.code === "selector_not_found" && /#missing/.test(e.message)); |
| 163 | await assert.rejects(browser.click({ point: { x: 900, y: 5 } }), (e) => e.code === "bad_target" && /800x600/.test(e.message)); |
| 164 | await assert.rejects(browser.click({}), (e) => e.code === "bad_args"); |
| 165 | }); |
| 166 | |
| 167 | test("browser type focuses an optional selector, inserts text, and can press Enter", async (t) => { |
| 168 | const { browser, sockets } = harness(t); |
| 169 | await browser.start({}); |
| 170 | const ws = sockets[0].ws; |
| 171 | const receipt = await browser.type({ text: "hi", selector: "#q", enter: true }); |
| 172 | assert.equal(receipt.chars, 2); |
| 173 | assert.equal(receipt.selector, "#q"); |
| 174 | assert.equal(receipt.entered, true); |
| 175 | assert.equal(ws.sentOf("DOM.focus")[0].params.nodeId, 7); |
| 176 | assert.equal(ws.sentOf("Input.insertText")[0].params.text, "hi"); |
| 177 | const keys = ws.sentOf("Input.dispatchKeyEvent").map((m) => m.params); |
| 178 | assert.deepEqual(keys.map((k) => k.type), ["keyDown", "keyUp"]); |
| 179 | assert.ok(keys.every((k) => k.key === "Enter" && k.windowsVirtualKeyCode === 13)); |
| 180 | await assert.rejects(browser.type({}), (e) => e.code === "bad_args"); |
| 181 | }); |
| 182 | |
| 183 | test("browser screenshot writes a real file and names the viewport space", async (t) => { |
| 184 | const { browser, recDir } = harness(t); |
| 185 | await browser.start({}); |
| 186 | const receipt = await browser.screenshot({}); |
| 187 | assert.ok(fs.existsSync(receipt.file), receipt.file); |
| 188 | assert.equal(fs.readFileSync(receipt.file, "utf8"), "abc"); |
| 189 | assert.equal(receipt.bytes, 3); |
| 190 | assert.deepEqual(receipt.viewport, { w: 800, h: 600 }); |
| 191 | assert.equal(receipt.scale, 2); |
| 192 | assert.equal(receipt.space, "page-viewport"); |
| 193 | assert.match(receipt.file, new RegExp(recDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); |
| 194 | }); |
| 195 | |
| 196 | test("browser stop closes this session's tab and the browser only when no tabs remain", async (t) => { |
| 197 | const { browser, sockets } = harness(t); |
| 198 | await browser.start({}); |
| 199 | const ws = sockets[0].ws; |
| 200 | const stop = await browser.stop(); |
| 201 | assert.equal(stop.closed, true); |
| 202 | assert.equal(stop.browser_closed, true); |
| 203 | assert.equal(ws.sentOf("Target.closeTarget")[0].params.targetId, "T1"); |
| 204 | assert.equal(ws.sentOf("Browser.close").length, 1); |
| 205 | assert.equal((await browser.status()).running, false); |
| 206 | }); |
| 207 | |
| 208 | test("browser stop leaves the shared browser up while other sessions' tabs remain", async (t) => { |
| 209 | const script = { ...defaultScript(pageState()), "Target.getTargets": { targetInfos: [{ type: "page", targetId: "T2", url: "https://other.test/", title: "other" }] } }; |
| 210 | const { browser, sockets } = harness(t, { script }); |
| 211 | await browser.start({}); |
| 212 | const stop = await browser.stop(); |
| 213 | assert.equal(stop.browser_closed, false); |
| 214 | assert.match(stop.note, /other sessions remain open/); |
| 215 | assert.equal(sockets[0].ws.sentOf("Browser.close").length, 0); |
| 216 | }); |
| 217 | |
| 218 | test("browser reuses a live instance and replaces a stale port file", async (t) => { |
| 219 | const shared = fs.mkdtempSync(path.join(os.tmpdir(), "cu-browser-shared-")); |
| 220 | t.after(() => fs.rmSync(shared, { recursive: true, force: true })); |
| 221 | // A stale file must not shadow a fresh launch: the first connect attempt fails. |
| 222 | const first = harness(t, { stateDir: shared, connectFailures: 1 }); |
| 223 | const profile = path.join(shared, "browser", "profile"); |
| 224 | fs.mkdirSync(profile, { recursive: true }); |
| 225 | fs.writeFileSync(path.join(profile, "DevToolsActivePort"), "9999\n/devtools/browser/stale\n"); |
| 226 | const start = await first.browser.start({}); |
| 227 | assert.equal(start.launched, true); |
| 228 | assert.equal(first.launches.length, 1); |
| 229 | // A second session (fresh bridge) sees the live file and reuses the instance. |
| 230 | const second = harness(t, { stateDir: shared }); |
| 231 | const reused = await second.browser.start({}); |
| 232 | assert.equal(reused.reused, true); |
| 233 | assert.equal(reused.launched, false); |
| 234 | assert.equal(second.launches.length, 0); |
| 235 | }); |
| 236 | |
| 237 | test("browser refuses before start, on bad urls, and on old runtimes", async (t) => { |
| 238 | const { browser } = harness(t); |
| 239 | await assert.rejects(browser.navigate({ url: "https://x.test" }), (e) => e.code === "browser_not_running"); |
| 240 | assert.equal((await browser.status()).running, false); |
| 241 | await assert.rejects(browser.start({ url: "ftp://x.test" }), (e) => e.code === "bad_args"); |
| 242 | await assert.rejects(browser.start({ url: "javascript:alert(1)" }), (e) => e.code === "bad_args"); |
| 243 | const prior = globalThis.WebSocket; |
| 244 | globalThis.WebSocket = undefined; |
| 245 | t.after(() => { globalThis.WebSocket = prior; }); |
| 246 | await assert.rejects(browser.start({}), (e) => e.code === "unsupported_runtime"); |
| 247 | }); |
| 248 | |
| 249 | test("checkBrowserUrl allows http(s) and about:blank only; findBrowserApp honors overrides", () => { |
| 250 | assert.equal(checkBrowserUrl("https://a.test/x"), "https://a.test/x"); |
| 251 | assert.equal(checkBrowserUrl("http://127.0.0.1:8080/"), "http://127.0.0.1:8080/"); |
| 252 | assert.equal(checkBrowserUrl("about:blank"), "about:blank"); |
| 253 | assert.throws(() => checkBrowserUrl("file:///etc/passwd"), /only http/); |
| 254 | assert.throws(() => checkBrowserUrl("example.com"), /not a URL/); |
| 255 | assert.throws(() => checkBrowserUrl(""), /need a url/); |
| 256 | assert.equal(findBrowserApp("darwin", {}, (p) => p === "/Applications/Google Chrome.app"), "/Applications/Google Chrome.app"); |
| 257 | assert.equal(findBrowserApp("darwin", {}, () => false), null); |
| 258 | assert.equal(findBrowserApp("darwin", { CODEWHALE_CU_BROWSER_APP: "/custom/Chromium.app" }, () => false), "/custom/Chromium.app"); |
| 259 | }); |
| 260 | |
| 261 | test('findBrowserApp uses actual Windows vendor folders and executable names', () => { |
| 262 | for (const [root, relative] of [ |
| 263 | ['ProgramFiles', 'Google\\Chrome\\Application\\chrome.exe'], |
| 264 | ['ProgramFiles(x86)', 'Microsoft\\Edge\\Application\\msedge.exe'], |
| 265 | ['LOCALAPPDATA', 'BraveSoftware\\Brave-Browser\\Application\\brave.exe'], |
| 266 | ['LOCALAPPDATA', 'Chromium\\Application\\chrome.exe'], |
| 267 | ]) { |
| 268 | const expected = `C:\\fixture\\${relative}`; |
| 269 | assert.equal(findBrowserApp('win32', { [root]: 'C:\\fixture' }, candidate => candidate === expected), expected); |
| 270 | } |
| 271 | }); |
| 272 |