ident
All repositories: gitoria
32.0 KB
// tests/browser/cdp.mjs — a tiny raw-CDP browser driver. ZERO npm dependencies:// node's native WebSocket speaks the Chrome DevTools Protocol directly, so the// real-browser tier needs nothing installed beyond `google-chrome` itself.//// Two connection modes (mission 073 gate 1):// • default — launch a LOCAL `google-chrome --headless=new` on a free debug port.// This is what the suites use: hermetic, no external dependency.// • HL_CDP_URL=http://host:port — attach to an ALREADY-RUNNING Chrome instead// (e.g. the creator's notebook at http://lisbeth.tailfd3886.ts.net:9223).// Nothing is launched or killed in that mode; only the tabs we create are closed.//// Everything a browser test needs and nothing else: navigate, wait-for-selector,// evaluate, REAL mouse/keyboard dispatch (Input domain, not synthetic .click()),// full console + uncaught-exception capture, and teardown that leaves no process.import { spawn } from 'node:child_process';import { mkdtempSync, rmSync } from 'node:fs';import { tmpdir } from 'node:os';import { join } from 'node:path';import { createConnection } from 'node:net';// One definition of "is this port actually usable", shared with the fixture boot// path — the whole of mission 285 is that the tier had two different answers to// that question and neither was the one the kernel gives.import { portBindable, whoHolds } from './ports.mjs';const CHROME_CANDIDATES = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'];export function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }async function portOpen(host, port) {return new Promise(resolve => {const sock = createConnection({ host, port });const done = (ok) => { try { sock.destroy(); } catch {} resolve(ok); };sock.setTimeout(400);sock.on('connect', () => done(true));sock.on('error', () => done(false));sock.on('timeout', () => done(false));});}/*** Wait until `http://host:port/json/version` answers, or throw.** `giveUp` (mission 285) is asked on every turn for a reason to stop early: waiting* out a full timeout for something that has already announced it will never arrive* is how a port collision came to look like a twenty-second test failure.*/async function waitForEndpoint(base, timeoutMs, label, giveUp = () => null) {const deadline = Date.now() + timeoutMs;let lastErr = 'no attempt';while (Date.now() < deadline) {const stop = giveUp();if (stop) throw new Error(stop);try {const res = await fetch(base + '/json/version', { signal: AbortSignal.timeout(2000) });if (res.ok) return await res.json();lastErr = 'HTTP ' + res.status;} catch (e) { lastErr = String(e && e.message || e); }await sleep(100);}throw new Error(`${label} not reachable at ${base} after ${timeoutMs}ms (${lastErr})`);}// --- the protocol connection ------------------------------------------------class Conn {constructor(ws) {this.ws = ws;this.nextId = 1;this.pending = new Map();this.listeners = [];ws.addEventListener('message', (ev) => {let msg;try { msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data)); } catch { return; }if (msg.id !== undefined && this.pending.has(msg.id)) {const { resolve, reject } = this.pending.get(msg.id);this.pending.delete(msg.id);if (msg.error) reject(new Error(`${msg.error.message} (CDP ${msg.error.code})`));else resolve(msg.result);return;}for (const fn of this.listeners) fn(msg);});ws.addEventListener('close', () => {for (const { reject } of this.pending.values()) reject(new Error('CDP connection closed'));this.pending.clear();});}onEvent(fn) { this.listeners.push(fn); return () => { this.listeners = this.listeners.filter(f => f !== fn); }; }send(method, params = {}, sessionId) {const id = this.nextId++;const payload = { id, method, params };if (sessionId) payload.sessionId = sessionId;return new Promise((resolve, reject) => {this.pending.set(id, { resolve, reject });const t = setTimeout(() => {if (this.pending.delete(id)) reject(new Error(`CDP timeout: ${method}`));}, 20000);const clear = () => clearTimeout(t);const orig = this.pending.get(id);this.pending.set(id, {resolve: (v) => { clear(); orig.resolve(v); },reject: (e) => { clear(); orig.reject(e); },});try { this.ws.send(JSON.stringify(payload)); } catch (e) { this.pending.delete(id); clear(); reject(e); }});}close() { try { this.ws.close(); } catch {} }}async function connectWs(url) {const ws = new WebSocket(url);await new Promise((resolve, reject) => {const to = setTimeout(() => reject(new Error('WebSocket open timeout: ' + url)), 10000);ws.addEventListener('open', () => { clearTimeout(to); resolve(); }, { once: true });ws.addEventListener('error', (e) => { clearTimeout(to); reject(new Error('WebSocket error: ' + url)); }, { once: true });});return new Conn(ws);}// --- Page -------------------------------------------------------------------/*** One tab. Owns its own console/exception log from the moment it is created,* so nothing that happens during load can be missed.*/export class Page {constructor(browser, targetId, sessionId) {this.browser = browser;this.conn = browser.conn;this.targetId = targetId;this.sessionId = sessionId;/** every console message AND uncaught exception, in arrival order */this.messages = [];this.closed = false;}send(method, params) { return this.conn.send(method, params, this.sessionId); }/** console messages whose level is error/warning (what "console clean" means) */problems() { return this.messages.filter(m => m.level === 'error' || m.level === 'warning'); }/** Wait until a captured console message matches — the hydration/apply signals. */async waitForConsoleMatch(re, { timeout = 10000 } = {}) {const deadline = Date.now() + timeout;while (Date.now() < deadline) {const hit = this.messages.find(m => re.test(m.text));if (hit) return hit;await sleep(40);}throw new Error(`waitForConsoleMatch timed out (${re}). Console was:\n${this.dumpConsole()}`);}/** one-line rendering of everything captured — for failure details */dumpConsole() { return this.messages.map(m => `[${m.level}] ${m.text}`).join('\n') || '(no console output)'; }/*** Run `source` in EVERY document this tab loads, BEFORE any page script.* The only way to observe something the app sets up on load — mission 080 uses* it to wrap `EventSource` and capture the raw SSE frames, so the assertion* "this session was told nothing" is made against the wire, not the DOM.*/addInitScript(source) { return this.send('Page.addScriptToEvaluateOnNewDocument', { source }); }/*** Capture every SSE frame this page receives into `window.__hlSSE`. Must be* called BEFORE goto(); the wrapper subclasses the real EventSource, so the* app's own `onmessage` is untouched and the transport is not simulated.*/captureSse() {return this.addInitScript(`(() => {window.__hlSSE = [];const Native = window.EventSource;if (!Native) return;window.EventSource = class extends Native {constructor(...a) {super(...a);this.addEventListener('message', (e) => {try { window.__hlSSE.push(JSON.parse(e.data)); } catch {}});}};})()`);}/** The SSE frames captured so far (see captureSse), oldest first. */sseFrames() { return this.evaluate('window.__hlSSE || []'); }/*** Mission 090: capture every WebSocket frame this page RECEIVES (`__hlWs`) and* every one it SENDS (`__hlWsOut`). Must be called BEFORE goto(); the wrapper* subclasses the real WebSocket, so the app's own listeners are untouched and* the transport is the real one — "this session was told exactly these bytes"* is then an assertion about the wire, not about the DOM.*/captureWs() {return this.addInitScript(`(() => {window.__hlWs = [];window.__hlWsOut = [];// when the LAST frame arrived, on the page's own clock — the ordering// assertion "the DOM moved because of the frame" is made against thiswindow.__hlWsAt = 0;// ...and when the first STATE PATCH arrived. A patch is an ARRAY frame (the// protocol's onPatches); control frames — ack, pong, build — are objects,// and since mission 133 an ack lands right AFTER the patch it belongs to,// so __hlWsAt alone would put "the last frame" after the DOM had already// moved for the patch.window.__hlWsPatchAt = 0;const Native = window.WebSocket;if (!Native) return;window.WebSocket = class extends Native {constructor(...a) {super(...a);this.addEventListener('message', (e) => {window.__hlWs.push(String(e.data));window.__hlWsAt = performance.now();if (!window.__hlWsPatchAt) {try {if (Array.isArray(JSON.parse(String(e.data)))) window.__hlWsPatchAt = window.__hlWsAt;} catch {}}});}send(d) { window.__hlWsOut.push(String(d)); return super.send(d); }};})()`);}/** Frames this page RECEIVED / SENT since load (see captureWs), oldest first. */wsFrames() { return this.evaluate('window.__hlWs || []'); }wsSent() { return this.evaluate('window.__hlWsOut || []'); }resetWs() { return this.evaluate('window.__hlWs = []'); }/*** Mission 093: the cookies the BROWSER holds for `urls`, as the browser sees* them — including the httpOnly ones, which is the whole point. `document.cookie`* cannot see an httpOnly cookie, so "the session id is not reachable from page* script" and "the session id exists" can only be asserted together from here.*/async cookies(urls) {await this.send('Network.enable');const { cookies } = await this.send('Network.getCookies', urls ? { urls } : {});return cookies || [];}/** Cut THIS tab off the network, or let it back on (CDP emulation). */async setOffline(offline) {await this.send('Network.enable');await this.send('Network.emulateNetworkConditions', {offline, latency: 0, downloadThroughput: -1, uploadThroughput: -1,});}/*** Mission 081: serve an APP SHELL PRECACHE from the harness — the part mission* 082's generated service worker will play (D29/§10.4: the `offline` target* precaches the shell so a revisit boots with the network down).** 081 owns the STATE layer — "with the network down the state comes from* IndexedDB" — and that claim is only observable once a document can load at* all. `snapshot` is a url → {status, contentType, body} map the test captured* over HTTP while it was still online; those URLs are fulfilled from it, and* EVERY OTHER request (`?__data=1`, `/emit`, `/events` — all the data* endpoints) is passed on to the network, which is exactly where it dies. So* the shell is precached and the data is genuinely unreachable, which is the* situation §10.2 describes.** Chrome's own HTTP cache cannot stand in for this: a response replayed* through `Fetch.continueResponse` is not stored in the disk cache (measured),* and `Page.reload` deliberately revalidates, so it fails offline either way.*/async servePrecache(snapshot) {this._precache = snapshot;await this.send('Fetch.enable', { patterns: [{ urlPattern: '*' }] });}/** Stop answering from the precache and let every request reach the network. */async stopPrecache() {this._precache = null;await this.send('Fetch.enable', { patterns: [{ urlPattern: '*/favicon.ico' }] });}/*** Mission 087 (gate 2): capture EVERY request the page makes, from the browser* itself. `Network.requestWillBeSent` fires for navigations, subresources,* `fetch`/XHR and beacons alike, so "this interaction cost zero network* requests" is asserted against the wire and cannot be talked around.* The favicon request the browser makes on its own is excluded by URL — it is* the browser's, not the app's (the Fetch handler above already answers it).*/async captureNetwork() {this.requests = [];this._netOn = true;await this.send('Network.enable');return this;}/** Every request URL captured since the last `resetNetwork()`, oldest first. */networkUrls() { return (this.requests || []).map(r => r.url); }/** The same requests as `{ url, method, headers }` — mission 090 asserts all three. */networkRequests() { return (this.requests || []).slice(); }/** Forget everything captured so far — the "since this moment" baseline. */resetNetwork() { this.requests = []; return this; }_record(level, text, extra) { this.messages.push({ level, text, ...extra }); }async _init() {this.conn.onEvent((msg) => {if (msg.sessionId !== this.sessionId) return;const p = msg.params || {};if (msg.method === 'Runtime.consoleAPICalled') {const level = p.type === 'error' ? 'error' : p.type === 'warning' || p.type === 'warn' ? 'warning' : p.type === 'debug' ? 'debug' : 'info';this._record(level, (p.args || []).map(describeArg).join(' '), { source: 'console.' + p.type });} else if (msg.method === 'Runtime.exceptionThrown') {const d = p.exceptionDetails || {};const text = (d.exception && (d.exception.description || d.exception.value)) || d.text || 'uncaught exception';this._record('error', String(text), { source: 'exception' });} else if (msg.method === 'Log.entryAdded') {const e = p.entry || {};const level = e.level === 'error' ? 'error' : e.level === 'warning' ? 'warning' : 'info';this._record(level, `${e.source}: ${e.text}${e.url ? ' @ ' + e.url : ''}`, { source: 'log.' + e.source });} else if (msg.method === 'Page.loadEventFired') {this._loaded = true;} else if (msg.method === 'Network.requestWillBeSent' && this._netOn) {const url = (p.request && p.request.url) || '';if (!/\/favicon\.ico$/.test(url)) {this.requests.push({url,method: (p.request && p.request.method) || 'GET',headers: (p.request && p.request.headers) || {},});}} else if (msg.method === 'Fetch.requestPaused' && this._precache&& this._precache[(p.request && p.request.url) || '']) {// the harness precache (servePrecache) answers this one, network untouchedconst hit = this._precache[p.request.url];this.send('Fetch.fulfillRequest', {requestId: p.requestId, responseCode: hit.status || 200,responseHeaders: [{ name: 'Content-Type', value: hit.contentType || 'text/html; charset=utf-8' }],body: Buffer.from(hit.body, 'utf-8').toString('base64'),}).catch(() => {});} else if (msg.method === 'Fetch.requestPaused' && this._precache&& !/\/favicon\.ico$/.test((p.request && p.request.url) || '')) {// not precached: let it go to the network — which is where it must failthis.send('Fetch.continueRequest', { requestId: p.requestId }).catch(() => {});} else if (msg.method === 'Fetch.requestPaused') {// The browser fetches /favicon.ico on every navigation all by itself.// A fixture server serves no favicon, so that 404 would show up as a// console error that NO page code caused. Answer it here (empty 200)// instead of filtering it out later: then "zero console errors" means// exactly what it says — nothing is excused after the fact.this.send('Fetch.fulfillRequest', {requestId: p.requestId, responseCode: 200,responseHeaders: [{ name: 'Content-Type', value: 'image/x-icon' }], body: '',}).catch(() => {});}});await this.send('Runtime.enable');await this.send('Log.enable');await this.send('Page.enable');await this.send('DOM.enable');await this.send('Fetch.enable', { patterns: [{ urlPattern: '*/favicon.ico' }] });}/** Navigate and wait for the load event (plus a settled DOM). */async goto(url, { timeout = 15000 } = {}) {this._loaded = false;await this.send('Page.navigate', { url });const deadline = Date.now() + timeout;while (!this._loaded && Date.now() < deadline) await sleep(25);if (!this._loaded) throw new Error(`navigate: load event never fired for ${url}`);return this;}/** Evaluate an expression (or a function body via a plain string) in the page. */async evaluate(expression, { awaitPromise = true } = {}) {const src = typeof expression === 'function' ? `(${expression.toString()})()` : expression;const res = await this.send('Runtime.evaluate', {expression: src, returnByValue: true, awaitPromise, userGesture: true,});if (res.exceptionDetails) {const d = res.exceptionDetails;const text = (d.exception && (d.exception.description || d.exception.value)) || d.text;throw new Error('evaluate threw: ' + text);}return res.result && res.result.value;}/** Poll a JS expression until it is truthy. Returns its value. */async waitFor(expression, { timeout = 10000, interval = 50, label } = {}) {const deadline = Date.now() + timeout;let last;while (Date.now() < deadline) {try { last = await this.evaluate(expression); } catch (e) { last = undefined; }if (last) return last;await sleep(interval);}throw new Error(`waitFor timed out after ${timeout}ms: ${label || String(expression).slice(0, 120)}`);}waitForSelector(selector, opts = {}) {return this.waitFor(`!!document.querySelector(${JSON.stringify(selector)})`, { ...opts, label: `selector ${selector}` });}text(selector) {return this.evaluate(`(document.querySelector(${JSON.stringify(selector)}) || {}).textContent`);}html(selector = 'body') {return this.evaluate(`(document.querySelector(${JSON.stringify(selector)}) || {}).innerHTML`);}/** Element centre in viewport coordinates, or null when it isn't laid out. */async box(selector) {return this.evaluate(`(() => {const el = document.querySelector(${JSON.stringify(selector)});if (!el) return null;// a real click lands at viewport coordinates: an element below the fold is// scrolled into view first, as a user would have to (2026-09-12: a grown// list pushed a heading off-screen and the press hit nothing)el.scrollIntoView({ block: 'center', inline: 'nearest' });const r = el.getBoundingClientRect();if (r.width === 0 && r.height === 0) return null;return { x: r.left + r.width / 2, y: r.top + r.height / 2, w: r.width, h: r.height };})()`);}/*** A REAL click: an Input-domain mouse press/release at the element's centre.* Not `el.click()` — this goes through Chrome's real hit-testing and event path,* so an overlay, a zero-size element or a detached node makes it fail here.*/async click(selector, { timeout = 10000 } = {}) {await this.waitForSelector(selector, { timeout });const box = await this.box(selector);if (!box) throw new Error(`click: ${selector} has no layout box (not visible)`);const base = { x: Math.round(box.x), y: Math.round(box.y), button: 'left', clickCount: 1, buttons: 1 };await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...base, buttons: 0 });await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...base });await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...base, buttons: 0 });return this;}/*** A REAL DRAG (mission 127): press over `fromSel`, walk the pointer to* `toSel` in `steps` intermediate moves, then release — all through the Input* domain, so Chrome does its own hit-testing and produces the genuine* pointerdown / pointermove / pointerenter / pointerup sequence. Nothing here* is synthesised in page script.** `hold` is awaited while the button is DOWN over the destination, which is the* only moment a mid-drag assertion (a "dragging: X" label) can be made.* The destination box is measured AFTER the press, because a drag may already* have moved the layout by then.*/async drag(fromSel, toSel, { steps = 8, hold = null, onStep = null, timeout = 10000 } = {}) {await this.waitForSelector(fromSel, { timeout });await this.waitForSelector(toSel, { timeout });const from = await this.box(fromSel);if (!from) throw new Error(`drag: ${fromSel} has no layout box (not visible)`);const at = (x, y, extra) => this.send('Input.dispatchMouseEvent', {x: Math.round(x), y: Math.round(y), button: 'left', clickCount: 1, ...extra,});await at(from.x, from.y, { type: 'mouseMoved', buttons: 0 });await at(from.x, from.y, { type: 'mousePressed', buttons: 1 });const to = await this.box(toSel);if (!to) throw new Error(`drag: ${toSel} has no layout box (not visible)`);for (let i = 1; i <= steps; i++) {const t = i / steps;const x = from.x + (to.x - from.x) * t;const y = from.y + (to.y - from.y) * t;await at(x, y, { type: 'mouseMoved', buttons: 1 });// MID-DRAG SAMPLING (mission 134): the only place from which "the row// follows the pointer" can be measured is BETWEEN two moves, while the// button is still down — `hold` runs once, at the end, and so can never// show that the row tracked anything.if (onStep) await onStep(i, x, y);}if (hold) await hold();await at(to.x, to.y, { type: 'mouseReleased', buttons: 0 });return this;}/** Focus an element (real DOM focus, so keystrokes land in it). */async focus(selector) {await this.waitForSelector(selector);const ok = await this.evaluate(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.focus(); return document.activeElement === el; })()`);if (!ok) throw new Error(`focus: ${selector} did not take focus`);return this;}/*** REAL typing: per-character rawKeyDown/char/keyUp through the Input domain.* The page sees genuine keydown/keypress/input/keyup — an `onInput` binding that* only listens for synthetic events would fail here.*/async type(selector, textToType, { clear = false } = {}) {await this.focus(selector);if (clear) {await this.evaluate(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); })()`);}for (const ch of textToType) {await this.send('Input.dispatchKeyEvent', { type: 'keyDown', text: ch, unmodifiedText: ch, key: ch });await this.send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch });}return this;}async close() {if (this.closed) return;this.closed = true;try { await this.conn.send('Target.closeTarget', { targetId: this.targetId }); } catch {}}}function describeArg(a) {if (!a) return '';if ('value' in a) return typeof a.value === 'object' ? JSON.stringify(a.value) : String(a.value);if (a.description) return a.description;if (a.unserializableValue) return a.unserializableValue;return a.type || '';}// --- Browser ----------------------------------------------------------------export class Browser {constructor(conn, { proc = null, userDataDir = null, endpoint }) {this.conn = conn;this.proc = proc;this.userDataDir = userDataDir;this.endpoint = endpoint;this.pages = [];}/** Whether this driver launched the browser (and therefore must kill it). */get owned() { return this.proc !== null; }async newPage() {const { targetId } = await this.conn.send('Target.createTarget', { url: 'about:blank' });const { sessionId } = await this.conn.send('Target.attachToTarget', { targetId, flatten: true });const page = new Page(this, targetId, sessionId);await page._init();this.pages.push(page);return page;}/*** Teardown. Closes every tab we opened; when WE launched the browser it is also* killed by PID and waited for, and its profile dir removed. Resolves only once* the process is actually gone — the run script asserts on that.*/async close() {for (const p of this.pages) { try { await p.close(); } catch {} }this.pages = [];if (!this.owned) { this.conn.close(); return; }try { await this.conn.send('Browser.close'); } catch {}this.conn.close();const pid = this.proc.pid;const gone = await waitForExit(this.proc, 5000);if (!gone) {try { process.kill(pid, 'SIGKILL'); } catch {}await waitForExit(this.proc, 3000);}if (this.userDataDir) { try { rmSync(this.userDataDir, { recursive: true, force: true }); } catch {} }const rec = launchedBrowsers.find(r => r.browser === this);if (rec) rec.closed = true;}}function waitForExit(proc, timeoutMs) {if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve(true);return new Promise(resolve => {const t = setTimeout(() => resolve(false), timeoutMs);proc.once('exit', () => { clearTimeout(t); resolve(true); });});}/*** MISSION 285 — A PORT IS FREE WHEN IT CAN BE BOUND, NOT WHEN NOTHING ANSWERS.** This used to ask `portOpen('127.0.0.1', p)`: connect, and if it is refused call* the port free. Chrome then binds it — and when that bind loses, Chrome does NOT* fail. It falls back to IPv6 and says so on stderr, which nothing read:** ERROR:net/socket/socket_posix.cc:175] bind() failed: Address already in use (98)* DevTools listening on ws://[::1]:53502/devtools/browser/06fcaa25-…** `waitForEndpoint` only ever polls `http://127.0.0.1:PORT`, so it then spent its* whole 20 000 ms waiting for an endpoint that was up the entire time at a place it* never looked, and the test that happened to be launching a peer browser took the* blame. Measured twice in one tier run (25-sessions and 27-login, both 20 4xx ms,* both on :53502) and reproduced on demand by putting one listener on 127.0.0.1:P* before the launch — same bind error, same ::1 fallback, IPv4 never reachable.** So: bind-test the candidate (see `portBindable` in ports.mjs for why a bind is* the honest question), and check what Chrome actually did afterwards.*/async function findFreePort(from, to) {for (let p = from; p <= to; p++) if (await portBindable(p)) return p;throw new Error(`no bindable port in ${from}-${to}`);}/** Where Chrome says it is listening — the only address that is not a guess. */function announcedEndpoint(stderr) {const m = /DevTools listening on ws:\/\/(\[[^\]]+\]|[^:/]+):(\d+)\//.exec(stderr);return m ? { host: m[1], port: Number(m[2]) } : null;}/*** Launch (or attach to) a browser.* HL_CDP_URL set → attach, never spawn, never kill.* otherwise → spawn local headless Chrome on a free port in PORT_BASE+200..+209* (8800-8809 by default; `HL_PORT_BASE` moves the block so two* worktrees can run the tier at once).* Optional HL_CHROME overrides the binary; HL_HEADFUL=1 shows the window.** THE OFFSET HAS NOW MOVED TWICE FOR THE SAME REASON. It was +40 until the tier's* fixture ports grew past forty and the last test was handed the debug port* itself; it became +100, and on 2026-09-06 the tier's 101st fixture port was* handed 8700 — the running Chrome's debug endpoint — and that test could not* start at all ("PORT COLLISION, not a server fault: :8700 could not be bound").* +200 buys the next hundred. See run.mjs's PORT_BASE comment.*/const DEBUG_PORT_BASE = (Number(process.env.HL_PORT_BASE) || 8600) + 200;/** The debug block this process may spawn into — the runner audits ALL of it. */export const DEBUG_PORT_RANGE = [DEBUG_PORT_BASE, DEBUG_PORT_BASE + 9];/*** EVERY browser this process launched, whoever asked for it.** The teardown audit used to know only about the ONE browser run.mjs launched,* so a peer browser started inside a test (25-sessions and 45-server-face-emit* each launch a second one for a second session) could survive while the audit* reported "no orphan chrome/servers" — measured 2026-08-23 with a deliberate* leak, which the audit passed clean. `launchBrowser` is the one choke point* every browser goes through, so the registry belongs here rather than in the* runner, and it catches a leak no matter who caused it.*/export const launchedBrowsers = [];export async function launchBrowser({ debugPortRange = [DEBUG_PORT_BASE, DEBUG_PORT_BASE + 9] } = {}) {const attach = process.env.HL_CDP_URL;if (attach) {const base = attach.replace(/\/$/, '');const info = await waitForEndpoint(base, 8000, 'HL_CDP_URL browser');const conn = await connectWs(info.webSocketDebuggerUrl);return new Browser(conn, { endpoint: base });}const bin = process.env.HL_CHROME || (await firstOnPath(CHROME_CANDIDATES));if (!bin) throw new Error(`no Chrome binary found (tried ${CHROME_CANDIDATES.join(', ')}); set HL_CHROME or HL_CDP_URL`);const port = await findFreePort(debugPortRange[0], debugPortRange[1]);const userDataDir = mkdtempSync(join(tmpdir(), 'hl-browser-tier-'));const args = [process.env.HL_HEADFUL ? '--headless=false' : '--headless=new',`--remote-debugging-port=${port}`,`--user-data-dir=${userDataDir}`,'--no-first-run', '--no-default-browser-check', '--disable-extensions',// WPAD proxy auto-detection stalls the FIRST http navigation for ~20s on this// machine (measured: data: URL instant, first http nav 20s, later navs instant).// Going straight-to-direct removes the stall — localhost fixtures need no proxy.'--no-proxy-server', "--proxy-server=direct://", '--proxy-bypass-list=*','--password-store=basic', '--use-mock-keychain','--disable-background-networking', '--disable-component-update','--disable-default-apps', '--disable-sync', '--metrics-recording-only','--mute-audio', '--disable-gpu', '--disable-dev-shm-usage','--window-size=1280,900', 'about:blank',].filter(a => a !== '--headless=false');const proc = spawn(bin, args, { stdio: ['ignore', 'ignore', 'pipe'] });const stderr = [];proc.stderr.on('data', d => { if (stderr.length < 50) stderr.push(String(d)); });const base = `http://127.0.0.1:${port}`;const die = (msg) => {try { process.kill(proc.pid, 'SIGKILL'); } catch {}rmSync(userDataDir, { recursive: true, force: true });throw new Error(msg);};let info;try {// MISSION 285 — stop the moment Chrome says it went somewhere else, instead of// polling an address it has already told us it is not on. A launch that lost the// port is a PORT COLLISION and must read as one; twenty silent seconds followed// by "not reachable" reads as a broken browser or a broken test.info = await waitForEndpoint(base, 20000, 'headless Chrome', () => {const said = announcedEndpoint(stderr.join(''));if (said && said.host !== '127.0.0.1') {return `PORT COLLISION, not a browser fault: Chrome could not bind 127.0.0.1:${port} and listened on ${said.host}:${said.port} instead, so nothing was ever going to answer at ${base}.\n sockets: ${whoHolds(port)}\n chrome said: ${stderr.join('').split('\n').filter(l => /bind\(\) failed|DevTools listening/.test(l)).join(' | ')}`;}return null;});} catch (e) {die(e.message + '\nchrome stderr:\n' + stderr.join(''));}const conn = await connectWs(info.webSocketDebuggerUrl);const browser = new Browser(conn, { proc, userDataDir, endpoint: base });launchedBrowsers.push({ pid: proc.pid, port, userDataDir, closed: false, browser });return browser;}async function firstOnPath(names) {const { execFile } = await import('node:child_process');for (const n of names) {const found = await new Promise(res => execFile('which', [n], (err, out) => res(err ? null : String(out).trim())));if (found) return found;}return null;}
Branches
- mainmain branch