| 1 | // Attach mode: the plugin drives the computer's one shared Chromium through a |
| 2 | // NUL-framed CDP Unix socket (the cw-cdp-bridge of codewhale-computing). It |
| 3 | // never launches, never closes a tab, never closes the browser. |
| 4 | import { test, after } from "node:test"; |
| 5 | import assert from "node:assert/strict"; |
| 6 | import fs from "node:fs"; |
| 7 | import net from "node:net"; |
| 8 | import os from "node:os"; |
| 9 | import path from "node:path"; |
| 10 | import { createBrowser, connectPipeSocket, attachSocket } from "../src/browser-cdp.mjs"; |
| 11 | // These transports are Unix sockets inside the Linux Sprite; Windows cannot bind the path. |
| 12 | const UNIX_SOCKETS = { skip: process.platform === "win32" && "Unix-socket transport (Sprite/Linux only)" }; |
| 13 | |
| 14 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-attach-")); |
| 15 | after(() => fs.rmSync(dir, { recursive: true, force: true })); |
| 16 | |
| 17 | /** A fake bridge: one client at a time, NUL framing, a tiny CDP browser. */ |
| 18 | function fakeBridge(sock, { tabs = [] } = {}) { |
| 19 | const calls = []; |
| 20 | const targets = [...tabs]; |
| 21 | let client = null; |
| 22 | let n = 0; |
| 23 | const server = net.createServer((s) => { |
| 24 | if (client && !client.destroyed) { s.end('{"error":"cdp_busy"}\0'); return; } |
| 25 | client = s; |
| 26 | let buf = Buffer.alloc(0); |
| 27 | const send = (obj) => s.write(`${JSON.stringify(obj)}\0`); |
| 28 | s.on("data", (chunk) => { |
| 29 | buf = Buffer.concat([buf, chunk]); |
| 30 | let i; |
| 31 | while ((i = buf.indexOf(0)) >= 0) { |
| 32 | const msg = JSON.parse(buf.subarray(0, i).toString()); |
| 33 | buf = buf.subarray(i + 1); |
| 34 | calls.push(msg.method); |
| 35 | const reply = (result) => send({ id: msg.id, result, ...(msg.sessionId ? { sessionId: msg.sessionId } : {}) }); |
| 36 | switch (msg.method) { |
| 37 | case "Browser.getVersion": reply({ product: "Chrome/150.0.7871.100" }); break; |
| 38 | case "Target.getTargets": reply({ targetInfos: targets.map((t) => ({ ...t, type: "page" })) }); break; |
| 39 | case "Target.createTarget": { const t = { targetId: `agent-${++n}`, url: "about:blank", title: "" }; targets.push(t); reply({ targetId: t.targetId }); break; } |
| 40 | case "Target.attachToTarget": reply({ sessionId: `s-${msg.params.targetId}` }); break; |
| 41 | case "Target.getTargetInfo": { const t = targets.find((x) => x.targetId === msg.params.targetId); reply({ targetInfo: { ...t, type: "page" } }); break; } |
| 42 | case "Page.navigate": { |
| 43 | const t = targets.find((x) => `s-${x.targetId}` === msg.sessionId); |
| 44 | t.url = msg.params.url; t.title = "Example"; |
| 45 | reply({ frameId: "f" }); |
| 46 | setTimeout(() => send({ method: "Page.loadEventFired", params: {}, sessionId: msg.sessionId }), 5); |
| 47 | break; |
| 48 | } |
| 49 | default: reply({}); |
| 50 | } |
| 51 | } |
| 52 | }); |
| 53 | s.on("close", () => { if (client === s) client = null; }); |
| 54 | }); |
| 55 | return new Promise((resolve) => server.listen(sock, () => resolve({ server, calls, targets }))); |
| 56 | } |
| 57 | |
| 58 | test("attachSocket reads CODEWHALE_CU_BROWSER_ATTACH", () => { |
| 59 | assert.equal(attachSocket({}), null); |
| 60 | assert.equal(attachSocket({ CODEWHALE_CU_BROWSER_ATTACH: " /run/cw/cdp.sock " }), "/run/cw/cdp.sock"); |
| 61 | }); |
| 62 | |
| 63 | test("attach: opens a visible tab beside the person's, lists their tabs, stop only detaches", UNIX_SOCKETS, async (t) => { |
| 64 | const sock = path.join(dir, "a.sock"); |
| 65 | const bridge = await fakeBridge(sock, { tabs: [{ targetId: "human-1", url: "https://news.example/", title: "News" }] }); |
| 66 | t.after(() => bridge.server.close()); |
| 67 | const launched = []; |
| 68 | const browser = createBrowser({ attach: sock, launch: (x) => launched.push(x), findApp: () => { throw new Error("must not look for an app"); }, recordingsDir: () => dir }); |
| 69 | const started = await browser.start({ url: "https://example.com/" }); |
| 70 | assert.equal(started.attached, true); |
| 71 | assert.equal(started.shared, true); |
| 72 | assert.equal(started.browser, "Chrome/150.0.7871.100"); |
| 73 | assert.equal(started.tab.url, "https://example.com/"); |
| 74 | assert.equal(started.verified, true); |
| 75 | assert.equal(launched.length, 0, "attach mode launches nothing"); |
| 76 | assert.ok(bridge.calls.includes("Target.createTarget"), "a person's tab is never taken implicitly"); |
| 77 | assert.ok(bridge.calls.includes("Target.activateTarget"), "the agent's tab is brought to the front"); |
| 78 | |
| 79 | // A tab the person opens shows up in the agent's view. |
| 80 | bridge.targets.push({ targetId: "human-2", url: "https://mail.example/", title: "Mail" }); |
| 81 | const status = await browser.status(); |
| 82 | assert.deepEqual(status.tabs.map((x) => x.id).sort(), ["agent-1", "human-1", "human-2"]); |
| 83 | assert.equal(status.tabs.find((x) => x.id === "agent-1").agent, true); |
| 84 | |
| 85 | // Moving to a named tab of the person's is explicit. |
| 86 | const moved = await browser.start({ tab: "human-2" }); |
| 87 | assert.equal(moved.switched_tab, true); |
| 88 | assert.equal(moved.activeTab.id, "human-2"); |
| 89 | |
| 90 | const stopped = await browser.stop(); |
| 91 | assert.equal(stopped.detached, true); |
| 92 | assert.equal(stopped.browser_closed, false); |
| 93 | assert.ok(!bridge.calls.includes("Target.closeTarget"), "no tab is closed"); |
| 94 | assert.ok(!bridge.calls.includes("Browser.close"), "the shared browser is never closed"); |
| 95 | assert.ok(bridge.calls.includes("Target.detachFromTarget")); |
| 96 | }); |
| 97 | |
| 98 | test("attach: adopts a lone blank tab instead of stacking a second", UNIX_SOCKETS, async (t) => { |
| 99 | const sock = path.join(dir, "b.sock"); |
| 100 | const bridge = await fakeBridge(sock, { tabs: [{ targetId: "blank", url: "chrome://newtab/", title: "New Tab" }] }); |
| 101 | t.after(() => bridge.server.close()); |
| 102 | const browser = createBrowser({ attach: sock, recordingsDir: () => dir }); |
| 103 | const started = await browser.start({}); |
| 104 | assert.equal(started.tab.id, "blank"); |
| 105 | assert.ok(!bridge.calls.includes("Target.createTarget")); |
| 106 | await browser.close(); |
| 107 | }); |
| 108 | |
| 109 | test("attach: a second controller gets browser_busy; a missing bridge gets browser_unavailable", UNIX_SOCKETS, async (t) => { |
| 110 | const sock = path.join(dir, "c.sock"); |
| 111 | const bridge = await fakeBridge(sock, { tabs: [] }); |
| 112 | t.after(() => bridge.server.close()); |
| 113 | const holder = await connectPipeSocket(sock); |
| 114 | t.after(() => holder.close()); |
| 115 | await new Promise((r) => setTimeout(r, 20)); |
| 116 | const browser = createBrowser({ attach: sock, recordingsDir: () => dir }); |
| 117 | await assert.rejects(browser.start({}), (e) => e.code === "browser_busy"); |
| 118 | const missing = createBrowser({ attach: path.join(dir, "nope.sock"), recordingsDir: () => dir }); |
| 119 | await assert.rejects(missing.start({}), (e) => e.code === "browser_unavailable"); |
| 120 | await assert.rejects(createBrowser({ recordingsDir: () => dir, attach: null }).start({ tab: "x" }), (e) => e.code === "bad_args"); |
| 121 | }); |
| 122 |