gitoriaLog in with ident

ident

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit5fdbb6b25fdbb6b2ident mission 009: report — scratch folder notemre5fdbb6b2/tests/cdp.mjs

32.0 KB

  1. // tests/browser/cdp.mjs — a tiny raw-CDP browser driver. ZERO npm dependencies:
  2. // node's native WebSocket speaks the Chrome DevTools Protocol directly, so the
  3. // real-browser tier needs nothing installed beyond `google-chrome` itself.
  4. //
  5. // Two connection modes (mission 073 gate 1):
  6. // • default — launch a LOCAL `google-chrome --headless=new` on a free debug port.
  7. // This is what the suites use: hermetic, no external dependency.
  8. // • HL_CDP_URL=http://host:port — attach to an ALREADY-RUNNING Chrome instead
  9. // (e.g. the creator's notebook at http://lisbeth.tailfd3886.ts.net:9223).
  10. // Nothing is launched or killed in that mode; only the tabs we create are closed.
  11. //
  12. // Everything a browser test needs and nothing else: navigate, wait-for-selector,
  13. // evaluate, REAL mouse/keyboard dispatch (Input domain, not synthetic .click()),
  14. // full console + uncaught-exception capture, and teardown that leaves no process.
  15. import { spawn } from 'node:child_process';
  16. import { mkdtempSync, rmSync } from 'node:fs';
  17. import { tmpdir } from 'node:os';
  18. import { join } from 'node:path';
  19. import { createConnection } from 'node:net';
  20. // One definition of "is this port actually usable", shared with the fixture boot
  21. // path — the whole of mission 285 is that the tier had two different answers to
  22. // that question and neither was the one the kernel gives.
  23. import { portBindable, whoHolds } from './ports.mjs';
  24. const CHROME_CANDIDATES = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'];
  25. export function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
  26. async function portOpen(host, port) {
  27. return new Promise(resolve => {
  28. const sock = createConnection({ host, port });
  29. const done = (ok) => { try { sock.destroy(); } catch {} resolve(ok); };
  30. sock.setTimeout(400);
  31. sock.on('connect', () => done(true));
  32. sock.on('error', () => done(false));
  33. sock.on('timeout', () => done(false));
  34. });
  35. }
  36. /**
  37. * Wait until `http://host:port/json/version` answers, or throw.
  38. *
  39. * `giveUp` (mission 285) is asked on every turn for a reason to stop early: waiting
  40. * out a full timeout for something that has already announced it will never arrive
  41. * is how a port collision came to look like a twenty-second test failure.
  42. */
  43. async function waitForEndpoint(base, timeoutMs, label, giveUp = () => null) {
  44. const deadline = Date.now() + timeoutMs;
  45. let lastErr = 'no attempt';
  46. while (Date.now() < deadline) {
  47. const stop = giveUp();
  48. if (stop) throw new Error(stop);
  49. try {
  50. const res = await fetch(base + '/json/version', { signal: AbortSignal.timeout(2000) });
  51. if (res.ok) return await res.json();
  52. lastErr = 'HTTP ' + res.status;
  53. } catch (e) { lastErr = String(e && e.message || e); }
  54. await sleep(100);
  55. }
  56. throw new Error(`${label} not reachable at ${base} after ${timeoutMs}ms (${lastErr})`);
  57. }
  58. // --- the protocol connection ------------------------------------------------
  59. class Conn {
  60. constructor(ws) {
  61. this.ws = ws;
  62. this.nextId = 1;
  63. this.pending = new Map();
  64. this.listeners = [];
  65. ws.addEventListener('message', (ev) => {
  66. let msg;
  67. try { msg = JSON.parse(typeof ev.data === 'string' ? ev.data : String(ev.data)); } catch { return; }
  68. if (msg.id !== undefined && this.pending.has(msg.id)) {
  69. const { resolve, reject } = this.pending.get(msg.id);
  70. this.pending.delete(msg.id);
  71. if (msg.error) reject(new Error(`${msg.error.message} (CDP ${msg.error.code})`));
  72. else resolve(msg.result);
  73. return;
  74. }
  75. for (const fn of this.listeners) fn(msg);
  76. });
  77. ws.addEventListener('close', () => {
  78. for (const { reject } of this.pending.values()) reject(new Error('CDP connection closed'));
  79. this.pending.clear();
  80. });
  81. }
  82. onEvent(fn) { this.listeners.push(fn); return () => { this.listeners = this.listeners.filter(f => f !== fn); }; }
  83. send(method, params = {}, sessionId) {
  84. const id = this.nextId++;
  85. const payload = { id, method, params };
  86. if (sessionId) payload.sessionId = sessionId;
  87. return new Promise((resolve, reject) => {
  88. this.pending.set(id, { resolve, reject });
  89. const t = setTimeout(() => {
  90. if (this.pending.delete(id)) reject(new Error(`CDP timeout: ${method}`));
  91. }, 20000);
  92. const clear = () => clearTimeout(t);
  93. const orig = this.pending.get(id);
  94. this.pending.set(id, {
  95. resolve: (v) => { clear(); orig.resolve(v); },
  96. reject: (e) => { clear(); orig.reject(e); },
  97. });
  98. try { this.ws.send(JSON.stringify(payload)); } catch (e) { this.pending.delete(id); clear(); reject(e); }
  99. });
  100. }
  101. close() { try { this.ws.close(); } catch {} }
  102. }
  103. async function connectWs(url) {
  104. const ws = new WebSocket(url);
  105. await new Promise((resolve, reject) => {
  106. const to = setTimeout(() => reject(new Error('WebSocket open timeout: ' + url)), 10000);
  107. ws.addEventListener('open', () => { clearTimeout(to); resolve(); }, { once: true });
  108. ws.addEventListener('error', (e) => { clearTimeout(to); reject(new Error('WebSocket error: ' + url)); }, { once: true });
  109. });
  110. return new Conn(ws);
  111. }
  112. // --- Page -------------------------------------------------------------------
  113. /**
  114. * One tab. Owns its own console/exception log from the moment it is created,
  115. * so nothing that happens during load can be missed.
  116. */
  117. export class Page {
  118. constructor(browser, targetId, sessionId) {
  119. this.browser = browser;
  120. this.conn = browser.conn;
  121. this.targetId = targetId;
  122. this.sessionId = sessionId;
  123. /** every console message AND uncaught exception, in arrival order */
  124. this.messages = [];
  125. this.closed = false;
  126. }
  127. send(method, params) { return this.conn.send(method, params, this.sessionId); }
  128. /** console messages whose level is error/warning (what "console clean" means) */
  129. problems() { return this.messages.filter(m => m.level === 'error' || m.level === 'warning'); }
  130. /** Wait until a captured console message matches — the hydration/apply signals. */
  131. async waitForConsoleMatch(re, { timeout = 10000 } = {}) {
  132. const deadline = Date.now() + timeout;
  133. while (Date.now() < deadline) {
  134. const hit = this.messages.find(m => re.test(m.text));
  135. if (hit) return hit;
  136. await sleep(40);
  137. }
  138. throw new Error(`waitForConsoleMatch timed out (${re}). Console was:\n${this.dumpConsole()}`);
  139. }
  140. /** one-line rendering of everything captured — for failure details */
  141. dumpConsole() { return this.messages.map(m => `[${m.level}] ${m.text}`).join('\n') || '(no console output)'; }
  142. /**
  143. * Run `source` in EVERY document this tab loads, BEFORE any page script.
  144. * The only way to observe something the app sets up on load — mission 080 uses
  145. * it to wrap `EventSource` and capture the raw SSE frames, so the assertion
  146. * "this session was told nothing" is made against the wire, not the DOM.
  147. */
  148. addInitScript(source) { return this.send('Page.addScriptToEvaluateOnNewDocument', { source }); }
  149. /**
  150. * Capture every SSE frame this page receives into `window.__hlSSE`. Must be
  151. * called BEFORE goto(); the wrapper subclasses the real EventSource, so the
  152. * app's own `onmessage` is untouched and the transport is not simulated.
  153. */
  154. captureSse() {
  155. return this.addInitScript(`(() => {
  156. window.__hlSSE = [];
  157. const Native = window.EventSource;
  158. if (!Native) return;
  159. window.EventSource = class extends Native {
  160. constructor(...a) {
  161. super(...a);
  162. this.addEventListener('message', (e) => {
  163. try { window.__hlSSE.push(JSON.parse(e.data)); } catch {}
  164. });
  165. }
  166. };
  167. })()`);
  168. }
  169. /** The SSE frames captured so far (see captureSse), oldest first. */
  170. sseFrames() { return this.evaluate('window.__hlSSE || []'); }
  171. /**
  172. * Mission 090: capture every WebSocket frame this page RECEIVES (`__hlWs`) and
  173. * every one it SENDS (`__hlWsOut`). Must be called BEFORE goto(); the wrapper
  174. * subclasses the real WebSocket, so the app's own listeners are untouched and
  175. * the transport is the real one — "this session was told exactly these bytes"
  176. * is then an assertion about the wire, not about the DOM.
  177. */
  178. captureWs() {
  179. return this.addInitScript(`(() => {
  180. window.__hlWs = [];
  181. window.__hlWsOut = [];
  182. // when the LAST frame arrived, on the page's own clock — the ordering
  183. // assertion "the DOM moved because of the frame" is made against this
  184. window.__hlWsAt = 0;
  185. // ...and when the first STATE PATCH arrived. A patch is an ARRAY frame (the
  186. // protocol's onPatches); control frames — ack, pong, build — are objects,
  187. // and since mission 133 an ack lands right AFTER the patch it belongs to,
  188. // so __hlWsAt alone would put "the last frame" after the DOM had already
  189. // moved for the patch.
  190. window.__hlWsPatchAt = 0;
  191. const Native = window.WebSocket;
  192. if (!Native) return;
  193. window.WebSocket = class extends Native {
  194. constructor(...a) {
  195. super(...a);
  196. this.addEventListener('message', (e) => {
  197. window.__hlWs.push(String(e.data));
  198. window.__hlWsAt = performance.now();
  199. if (!window.__hlWsPatchAt) {
  200. try {
  201. if (Array.isArray(JSON.parse(String(e.data)))) window.__hlWsPatchAt = window.__hlWsAt;
  202. } catch {}
  203. }
  204. });
  205. }
  206. send(d) { window.__hlWsOut.push(String(d)); return super.send(d); }
  207. };
  208. })()`);
  209. }
  210. /** Frames this page RECEIVED / SENT since load (see captureWs), oldest first. */
  211. wsFrames() { return this.evaluate('window.__hlWs || []'); }
  212. wsSent() { return this.evaluate('window.__hlWsOut || []'); }
  213. resetWs() { return this.evaluate('window.__hlWs = []'); }
  214. /**
  215. * Mission 093: the cookies the BROWSER holds for `urls`, as the browser sees
  216. * them — including the httpOnly ones, which is the whole point. `document.cookie`
  217. * cannot see an httpOnly cookie, so "the session id is not reachable from page
  218. * script" and "the session id exists" can only be asserted together from here.
  219. */
  220. async cookies(urls) {
  221. await this.send('Network.enable');
  222. const { cookies } = await this.send('Network.getCookies', urls ? { urls } : {});
  223. return cookies || [];
  224. }
  225. /** Cut THIS tab off the network, or let it back on (CDP emulation). */
  226. async setOffline(offline) {
  227. await this.send('Network.enable');
  228. await this.send('Network.emulateNetworkConditions', {
  229. offline, latency: 0, downloadThroughput: -1, uploadThroughput: -1,
  230. });
  231. }
  232. /**
  233. * Mission 081: serve an APP SHELL PRECACHE from the harness — the part mission
  234. * 082's generated service worker will play (D29/§10.4: the `offline` target
  235. * precaches the shell so a revisit boots with the network down).
  236. *
  237. * 081 owns the STATE layer — "with the network down the state comes from
  238. * IndexedDB" — and that claim is only observable once a document can load at
  239. * all. `snapshot` is a url → {status, contentType, body} map the test captured
  240. * over HTTP while it was still online; those URLs are fulfilled from it, and
  241. * EVERY OTHER request (`?__data=1`, `/emit`, `/events` — all the data
  242. * endpoints) is passed on to the network, which is exactly where it dies. So
  243. * the shell is precached and the data is genuinely unreachable, which is the
  244. * situation §10.2 describes.
  245. *
  246. * Chrome's own HTTP cache cannot stand in for this: a response replayed
  247. * through `Fetch.continueResponse` is not stored in the disk cache (measured),
  248. * and `Page.reload` deliberately revalidates, so it fails offline either way.
  249. */
  250. async servePrecache(snapshot) {
  251. this._precache = snapshot;
  252. await this.send('Fetch.enable', { patterns: [{ urlPattern: '*' }] });
  253. }
  254. /** Stop answering from the precache and let every request reach the network. */
  255. async stopPrecache() {
  256. this._precache = null;
  257. await this.send('Fetch.enable', { patterns: [{ urlPattern: '*/favicon.ico' }] });
  258. }
  259. /**
  260. * Mission 087 (gate 2): capture EVERY request the page makes, from the browser
  261. * itself. `Network.requestWillBeSent` fires for navigations, subresources,
  262. * `fetch`/XHR and beacons alike, so "this interaction cost zero network
  263. * requests" is asserted against the wire and cannot be talked around.
  264. * The favicon request the browser makes on its own is excluded by URL — it is
  265. * the browser's, not the app's (the Fetch handler above already answers it).
  266. */
  267. async captureNetwork() {
  268. this.requests = [];
  269. this._netOn = true;
  270. await this.send('Network.enable');
  271. return this;
  272. }
  273. /** Every request URL captured since the last `resetNetwork()`, oldest first. */
  274. networkUrls() { return (this.requests || []).map(r => r.url); }
  275. /** The same requests as `{ url, method, headers }` — mission 090 asserts all three. */
  276. networkRequests() { return (this.requests || []).slice(); }
  277. /** Forget everything captured so far — the "since this moment" baseline. */
  278. resetNetwork() { this.requests = []; return this; }
  279. _record(level, text, extra) { this.messages.push({ level, text, ...extra }); }
  280. async _init() {
  281. this.conn.onEvent((msg) => {
  282. if (msg.sessionId !== this.sessionId) return;
  283. const p = msg.params || {};
  284. if (msg.method === 'Runtime.consoleAPICalled') {
  285. const level = p.type === 'error' ? 'error' : p.type === 'warning' || p.type === 'warn' ? 'warning' : p.type === 'debug' ? 'debug' : 'info';
  286. this._record(level, (p.args || []).map(describeArg).join(' '), { source: 'console.' + p.type });
  287. } else if (msg.method === 'Runtime.exceptionThrown') {
  288. const d = p.exceptionDetails || {};
  289. const text = (d.exception && (d.exception.description || d.exception.value)) || d.text || 'uncaught exception';
  290. this._record('error', String(text), { source: 'exception' });
  291. } else if (msg.method === 'Log.entryAdded') {
  292. const e = p.entry || {};
  293. const level = e.level === 'error' ? 'error' : e.level === 'warning' ? 'warning' : 'info';
  294. this._record(level, `${e.source}: ${e.text}${e.url ? ' @ ' + e.url : ''}`, { source: 'log.' + e.source });
  295. } else if (msg.method === 'Page.loadEventFired') {
  296. this._loaded = true;
  297. } else if (msg.method === 'Network.requestWillBeSent' && this._netOn) {
  298. const url = (p.request && p.request.url) || '';
  299. if (!/\/favicon\.ico$/.test(url)) {
  300. this.requests.push({
  301. url,
  302. method: (p.request && p.request.method) || 'GET',
  303. headers: (p.request && p.request.headers) || {},
  304. });
  305. }
  306. } else if (msg.method === 'Fetch.requestPaused' && this._precache
  307. && this._precache[(p.request && p.request.url) || '']) {
  308. // the harness precache (servePrecache) answers this one, network untouched
  309. const hit = this._precache[p.request.url];
  310. this.send('Fetch.fulfillRequest', {
  311. requestId: p.requestId, responseCode: hit.status || 200,
  312. responseHeaders: [{ name: 'Content-Type', value: hit.contentType || 'text/html; charset=utf-8' }],
  313. body: Buffer.from(hit.body, 'utf-8').toString('base64'),
  314. }).catch(() => {});
  315. } else if (msg.method === 'Fetch.requestPaused' && this._precache
  316. && !/\/favicon\.ico$/.test((p.request && p.request.url) || '')) {
  317. // not precached: let it go to the network — which is where it must fail
  318. this.send('Fetch.continueRequest', { requestId: p.requestId }).catch(() => {});
  319. } else if (msg.method === 'Fetch.requestPaused') {
  320. // The browser fetches /favicon.ico on every navigation all by itself.
  321. // A fixture server serves no favicon, so that 404 would show up as a
  322. // console error that NO page code caused. Answer it here (empty 200)
  323. // instead of filtering it out later: then "zero console errors" means
  324. // exactly what it says — nothing is excused after the fact.
  325. this.send('Fetch.fulfillRequest', {
  326. requestId: p.requestId, responseCode: 200,
  327. responseHeaders: [{ name: 'Content-Type', value: 'image/x-icon' }], body: '',
  328. }).catch(() => {});
  329. }
  330. });
  331. await this.send('Runtime.enable');
  332. await this.send('Log.enable');
  333. await this.send('Page.enable');
  334. await this.send('DOM.enable');
  335. await this.send('Fetch.enable', { patterns: [{ urlPattern: '*/favicon.ico' }] });
  336. }
  337. /** Navigate and wait for the load event (plus a settled DOM). */
  338. async goto(url, { timeout = 15000 } = {}) {
  339. this._loaded = false;
  340. await this.send('Page.navigate', { url });
  341. const deadline = Date.now() + timeout;
  342. while (!this._loaded && Date.now() < deadline) await sleep(25);
  343. if (!this._loaded) throw new Error(`navigate: load event never fired for ${url}`);
  344. return this;
  345. }
  346. /** Evaluate an expression (or a function body via a plain string) in the page. */
  347. async evaluate(expression, { awaitPromise = true } = {}) {
  348. const src = typeof expression === 'function' ? `(${expression.toString()})()` : expression;
  349. const res = await this.send('Runtime.evaluate', {
  350. expression: src, returnByValue: true, awaitPromise, userGesture: true,
  351. });
  352. if (res.exceptionDetails) {
  353. const d = res.exceptionDetails;
  354. const text = (d.exception && (d.exception.description || d.exception.value)) || d.text;
  355. throw new Error('evaluate threw: ' + text);
  356. }
  357. return res.result && res.result.value;
  358. }
  359. /** Poll a JS expression until it is truthy. Returns its value. */
  360. async waitFor(expression, { timeout = 10000, interval = 50, label } = {}) {
  361. const deadline = Date.now() + timeout;
  362. let last;
  363. while (Date.now() < deadline) {
  364. try { last = await this.evaluate(expression); } catch (e) { last = undefined; }
  365. if (last) return last;
  366. await sleep(interval);
  367. }
  368. throw new Error(`waitFor timed out after ${timeout}ms: ${label || String(expression).slice(0, 120)}`);
  369. }
  370. waitForSelector(selector, opts = {}) {
  371. return this.waitFor(`!!document.querySelector(${JSON.stringify(selector)})`, { ...opts, label: `selector ${selector}` });
  372. }
  373. text(selector) {
  374. return this.evaluate(`(document.querySelector(${JSON.stringify(selector)}) || {}).textContent`);
  375. }
  376. html(selector = 'body') {
  377. return this.evaluate(`(document.querySelector(${JSON.stringify(selector)}) || {}).innerHTML`);
  378. }
  379. /** Element centre in viewport coordinates, or null when it isn't laid out. */
  380. async box(selector) {
  381. return this.evaluate(`(() => {
  382. const el = document.querySelector(${JSON.stringify(selector)});
  383. if (!el) return null;
  384. // a real click lands at viewport coordinates: an element below the fold is
  385. // scrolled into view first, as a user would have to (2026-09-12: a grown
  386. // list pushed a heading off-screen and the press hit nothing)
  387. el.scrollIntoView({ block: 'center', inline: 'nearest' });
  388. const r = el.getBoundingClientRect();
  389. if (r.width === 0 && r.height === 0) return null;
  390. return { x: r.left + r.width / 2, y: r.top + r.height / 2, w: r.width, h: r.height };
  391. })()`);
  392. }
  393. /**
  394. * A REAL click: an Input-domain mouse press/release at the element's centre.
  395. * Not `el.click()` — this goes through Chrome's real hit-testing and event path,
  396. * so an overlay, a zero-size element or a detached node makes it fail here.
  397. */
  398. async click(selector, { timeout = 10000 } = {}) {
  399. await this.waitForSelector(selector, { timeout });
  400. const box = await this.box(selector);
  401. if (!box) throw new Error(`click: ${selector} has no layout box (not visible)`);
  402. const base = { x: Math.round(box.x), y: Math.round(box.y), button: 'left', clickCount: 1, buttons: 1 };
  403. await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...base, buttons: 0 });
  404. await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...base });
  405. await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...base, buttons: 0 });
  406. return this;
  407. }
  408. /**
  409. * A REAL DRAG (mission 127): press over `fromSel`, walk the pointer to
  410. * `toSel` in `steps` intermediate moves, then release — all through the Input
  411. * domain, so Chrome does its own hit-testing and produces the genuine
  412. * pointerdown / pointermove / pointerenter / pointerup sequence. Nothing here
  413. * is synthesised in page script.
  414. *
  415. * `hold` is awaited while the button is DOWN over the destination, which is the
  416. * only moment a mid-drag assertion (a "dragging: X" label) can be made.
  417. * The destination box is measured AFTER the press, because a drag may already
  418. * have moved the layout by then.
  419. */
  420. async drag(fromSel, toSel, { steps = 8, hold = null, onStep = null, timeout = 10000 } = {}) {
  421. await this.waitForSelector(fromSel, { timeout });
  422. await this.waitForSelector(toSel, { timeout });
  423. const from = await this.box(fromSel);
  424. if (!from) throw new Error(`drag: ${fromSel} has no layout box (not visible)`);
  425. const at = (x, y, extra) => this.send('Input.dispatchMouseEvent', {
  426. x: Math.round(x), y: Math.round(y), button: 'left', clickCount: 1, ...extra,
  427. });
  428. await at(from.x, from.y, { type: 'mouseMoved', buttons: 0 });
  429. await at(from.x, from.y, { type: 'mousePressed', buttons: 1 });
  430. const to = await this.box(toSel);
  431. if (!to) throw new Error(`drag: ${toSel} has no layout box (not visible)`);
  432. for (let i = 1; i <= steps; i++) {
  433. const t = i / steps;
  434. const x = from.x + (to.x - from.x) * t;
  435. const y = from.y + (to.y - from.y) * t;
  436. await at(x, y, { type: 'mouseMoved', buttons: 1 });
  437. // MID-DRAG SAMPLING (mission 134): the only place from which "the row
  438. // follows the pointer" can be measured is BETWEEN two moves, while the
  439. // button is still down — `hold` runs once, at the end, and so can never
  440. // show that the row tracked anything.
  441. if (onStep) await onStep(i, x, y);
  442. }
  443. if (hold) await hold();
  444. await at(to.x, to.y, { type: 'mouseReleased', buttons: 0 });
  445. return this;
  446. }
  447. /** Focus an element (real DOM focus, so keystrokes land in it). */
  448. async focus(selector) {
  449. await this.waitForSelector(selector);
  450. const ok = await this.evaluate(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.focus(); return document.activeElement === el; })()`);
  451. if (!ok) throw new Error(`focus: ${selector} did not take focus`);
  452. return this;
  453. }
  454. /**
  455. * REAL typing: per-character rawKeyDown/char/keyUp through the Input domain.
  456. * The page sees genuine keydown/keypress/input/keyup — an `onInput` binding that
  457. * only listens for synthetic events would fail here.
  458. */
  459. async type(selector, textToType, { clear = false } = {}) {
  460. await this.focus(selector);
  461. if (clear) {
  462. await this.evaluate(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); })()`);
  463. }
  464. for (const ch of textToType) {
  465. await this.send('Input.dispatchKeyEvent', { type: 'keyDown', text: ch, unmodifiedText: ch, key: ch });
  466. await this.send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch });
  467. }
  468. return this;
  469. }
  470. async close() {
  471. if (this.closed) return;
  472. this.closed = true;
  473. try { await this.conn.send('Target.closeTarget', { targetId: this.targetId }); } catch {}
  474. }
  475. }
  476. function describeArg(a) {
  477. if (!a) return '';
  478. if ('value' in a) return typeof a.value === 'object' ? JSON.stringify(a.value) : String(a.value);
  479. if (a.description) return a.description;
  480. if (a.unserializableValue) return a.unserializableValue;
  481. return a.type || '';
  482. }
  483. // --- Browser ----------------------------------------------------------------
  484. export class Browser {
  485. constructor(conn, { proc = null, userDataDir = null, endpoint }) {
  486. this.conn = conn;
  487. this.proc = proc;
  488. this.userDataDir = userDataDir;
  489. this.endpoint = endpoint;
  490. this.pages = [];
  491. }
  492. /** Whether this driver launched the browser (and therefore must kill it). */
  493. get owned() { return this.proc !== null; }
  494. async newPage() {
  495. const { targetId } = await this.conn.send('Target.createTarget', { url: 'about:blank' });
  496. const { sessionId } = await this.conn.send('Target.attachToTarget', { targetId, flatten: true });
  497. const page = new Page(this, targetId, sessionId);
  498. await page._init();
  499. this.pages.push(page);
  500. return page;
  501. }
  502. /**
  503. * Teardown. Closes every tab we opened; when WE launched the browser it is also
  504. * killed by PID and waited for, and its profile dir removed. Resolves only once
  505. * the process is actually gone — the run script asserts on that.
  506. */
  507. async close() {
  508. for (const p of this.pages) { try { await p.close(); } catch {} }
  509. this.pages = [];
  510. if (!this.owned) { this.conn.close(); return; }
  511. try { await this.conn.send('Browser.close'); } catch {}
  512. this.conn.close();
  513. const pid = this.proc.pid;
  514. const gone = await waitForExit(this.proc, 5000);
  515. if (!gone) {
  516. try { process.kill(pid, 'SIGKILL'); } catch {}
  517. await waitForExit(this.proc, 3000);
  518. }
  519. if (this.userDataDir) { try { rmSync(this.userDataDir, { recursive: true, force: true }); } catch {} }
  520. const rec = launchedBrowsers.find(r => r.browser === this);
  521. if (rec) rec.closed = true;
  522. }
  523. }
  524. function waitForExit(proc, timeoutMs) {
  525. if (proc.exitCode !== null || proc.signalCode !== null) return Promise.resolve(true);
  526. return new Promise(resolve => {
  527. const t = setTimeout(() => resolve(false), timeoutMs);
  528. proc.once('exit', () => { clearTimeout(t); resolve(true); });
  529. });
  530. }
  531. /**
  532. * MISSION 285 — A PORT IS FREE WHEN IT CAN BE BOUND, NOT WHEN NOTHING ANSWERS.
  533. *
  534. * This used to ask `portOpen('127.0.0.1', p)`: connect, and if it is refused call
  535. * the port free. Chrome then binds it — and when that bind loses, Chrome does NOT
  536. * fail. It falls back to IPv6 and says so on stderr, which nothing read:
  537. *
  538. * ERROR:net/socket/socket_posix.cc:175] bind() failed: Address already in use (98)
  539. * DevTools listening on ws://[::1]:53502/devtools/browser/06fcaa25-…
  540. *
  541. * `waitForEndpoint` only ever polls `http://127.0.0.1:PORT`, so it then spent its
  542. * whole 20 000 ms waiting for an endpoint that was up the entire time at a place it
  543. * never looked, and the test that happened to be launching a peer browser took the
  544. * blame. Measured twice in one tier run (25-sessions and 27-login, both 20 4xx ms,
  545. * both on :53502) and reproduced on demand by putting one listener on 127.0.0.1:P
  546. * before the launch — same bind error, same ::1 fallback, IPv4 never reachable.
  547. *
  548. * So: bind-test the candidate (see `portBindable` in ports.mjs for why a bind is
  549. * the honest question), and check what Chrome actually did afterwards.
  550. */
  551. async function findFreePort(from, to) {
  552. for (let p = from; p <= to; p++) if (await portBindable(p)) return p;
  553. throw new Error(`no bindable port in ${from}-${to}`);
  554. }
  555. /** Where Chrome says it is listening — the only address that is not a guess. */
  556. function announcedEndpoint(stderr) {
  557. const m = /DevTools listening on ws:\/\/(\[[^\]]+\]|[^:/]+):(\d+)\//.exec(stderr);
  558. return m ? { host: m[1], port: Number(m[2]) } : null;
  559. }
  560. /**
  561. * Launch (or attach to) a browser.
  562. * HL_CDP_URL set → attach, never spawn, never kill.
  563. * otherwise → spawn local headless Chrome on a free port in PORT_BASE+200..+209
  564. * (8800-8809 by default; `HL_PORT_BASE` moves the block so two
  565. * worktrees can run the tier at once).
  566. * Optional HL_CHROME overrides the binary; HL_HEADFUL=1 shows the window.
  567. *
  568. * THE OFFSET HAS NOW MOVED TWICE FOR THE SAME REASON. It was +40 until the tier's
  569. * fixture ports grew past forty and the last test was handed the debug port
  570. * itself; it became +100, and on 2026-09-06 the tier's 101st fixture port was
  571. * handed 8700 — the running Chrome's debug endpoint — and that test could not
  572. * start at all ("PORT COLLISION, not a server fault: :8700 could not be bound").
  573. * +200 buys the next hundred. See run.mjs's PORT_BASE comment.
  574. */
  575. const DEBUG_PORT_BASE = (Number(process.env.HL_PORT_BASE) || 8600) + 200;
  576. /** The debug block this process may spawn into — the runner audits ALL of it. */
  577. export const DEBUG_PORT_RANGE = [DEBUG_PORT_BASE, DEBUG_PORT_BASE + 9];
  578. /**
  579. * EVERY browser this process launched, whoever asked for it.
  580. *
  581. * The teardown audit used to know only about the ONE browser run.mjs launched,
  582. * so a peer browser started inside a test (25-sessions and 45-server-face-emit
  583. * each launch a second one for a second session) could survive while the audit
  584. * reported "no orphan chrome/servers" — measured 2026-08-23 with a deliberate
  585. * leak, which the audit passed clean. `launchBrowser` is the one choke point
  586. * every browser goes through, so the registry belongs here rather than in the
  587. * runner, and it catches a leak no matter who caused it.
  588. */
  589. export const launchedBrowsers = [];
  590. export async function launchBrowser({ debugPortRange = [DEBUG_PORT_BASE, DEBUG_PORT_BASE + 9] } = {}) {
  591. const attach = process.env.HL_CDP_URL;
  592. if (attach) {
  593. const base = attach.replace(/\/$/, '');
  594. const info = await waitForEndpoint(base, 8000, 'HL_CDP_URL browser');
  595. const conn = await connectWs(info.webSocketDebuggerUrl);
  596. return new Browser(conn, { endpoint: base });
  597. }
  598. const bin = process.env.HL_CHROME || (await firstOnPath(CHROME_CANDIDATES));
  599. if (!bin) throw new Error(`no Chrome binary found (tried ${CHROME_CANDIDATES.join(', ')}); set HL_CHROME or HL_CDP_URL`);
  600. const port = await findFreePort(debugPortRange[0], debugPortRange[1]);
  601. const userDataDir = mkdtempSync(join(tmpdir(), 'hl-browser-tier-'));
  602. const args = [
  603. process.env.HL_HEADFUL ? '--headless=false' : '--headless=new',
  604. `--remote-debugging-port=${port}`,
  605. `--user-data-dir=${userDataDir}`,
  606. '--no-first-run', '--no-default-browser-check', '--disable-extensions',
  607. // WPAD proxy auto-detection stalls the FIRST http navigation for ~20s on this
  608. // machine (measured: data: URL instant, first http nav 20s, later navs instant).
  609. // Going straight-to-direct removes the stall — localhost fixtures need no proxy.
  610. '--no-proxy-server', "--proxy-server=direct://", '--proxy-bypass-list=*',
  611. '--password-store=basic', '--use-mock-keychain',
  612. '--disable-background-networking', '--disable-component-update',
  613. '--disable-default-apps', '--disable-sync', '--metrics-recording-only',
  614. '--mute-audio', '--disable-gpu', '--disable-dev-shm-usage',
  615. '--window-size=1280,900', 'about:blank',
  616. ].filter(a => a !== '--headless=false');
  617. const proc = spawn(bin, args, { stdio: ['ignore', 'ignore', 'pipe'] });
  618. const stderr = [];
  619. proc.stderr.on('data', d => { if (stderr.length < 50) stderr.push(String(d)); });
  620. const base = `http://127.0.0.1:${port}`;
  621. const die = (msg) => {
  622. try { process.kill(proc.pid, 'SIGKILL'); } catch {}
  623. rmSync(userDataDir, { recursive: true, force: true });
  624. throw new Error(msg);
  625. };
  626. let info;
  627. try {
  628. // MISSION 285 — stop the moment Chrome says it went somewhere else, instead of
  629. // polling an address it has already told us it is not on. A launch that lost the
  630. // port is a PORT COLLISION and must read as one; twenty silent seconds followed
  631. // by "not reachable" reads as a broken browser or a broken test.
  632. info = await waitForEndpoint(base, 20000, 'headless Chrome', () => {
  633. const said = announcedEndpoint(stderr.join(''));
  634. if (said && said.host !== '127.0.0.1') {
  635. 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(' | ')}`;
  636. }
  637. return null;
  638. });
  639. } catch (e) {
  640. die(e.message + '\nchrome stderr:\n' + stderr.join(''));
  641. }
  642. const conn = await connectWs(info.webSocketDebuggerUrl);
  643. const browser = new Browser(conn, { proc, userDataDir, endpoint: base });
  644. launchedBrowsers.push({ pid: proc.pid, port, userDataDir, closed: false, browser });
  645. return browser;
  646. }
  647. async function firstOnPath(names) {
  648. const { execFile } = await import('node:child_process');
  649. for (const n of names) {
  650. const found = await new Promise(res => execFile('which', [n], (err, out) => res(err ? null : String(out).trim())));
  651. if (found) return found;
  652. }
  653. return null;
  654. }

Branches

Latest commits

  • 5fdbb6b2ident mission 009: report — scratch folder notemre
  • 74235815ident mission 009 (4/4): docs (README files map + same-output test, STATUS, LOG), report, tests/letcount.py + realdata-baseline/comparemre
  • fe183516ident mission 009 (3/4): let only where reassigned — 293 never-reassigned lets are plain declarations; kept: reassigned, loop bodies, names of a file member, a name declared twice in one function; same outputmre
  • d2e7f91bident mission 009 (2/4): one lib file per topic (login, accounts, identities, apps, invites, selector, notify + helpers, util), function routes as thin wrappers in lib/api.hl, project.hl = the map; same outputmre
  • 91017164ident mission 009 (1/4): file moves — the root .hl files into lib/ (api.hl → lib/api-helpers.hl), styles.hl → components/styles.hl; imports adjusted, no other changemre
  • f8bdcbc2ident: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); all gates greenmre
  • ff78726cident: Hybriel master 190aa11d (fc838894 GC correctness, #127, #126 closure scopes); gates all greenmre
  • a3a7d21aident: Hybriel master 8efba065 (#126 GC by bytes, #48 lambda params copy); session-writing lambdas take &sessionmre
  • 98226b41antcolony#40: mission references point to the moved missionsmre
  • ff805b9aantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
  • 51a7bcdfident: Hybriel master 73267707 (#122); /code uses the new page() signature; pending address passed as parameter; once-checksmre
  • 836f644fident#24: installable app (manifest, service worker, data-free offline /start), own iconmre
  • 8bebbbf2deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
  • cc063ea2deploy.sh: never send .git or .gitignore to Byrodinmre
  • 81b15b7bState of 2026-09-27, before the move to gitoriamre