done-done-axi 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/dom.js CHANGED
@@ -3,8 +3,10 @@
3
3
  // (home.dark.390w.png -> home.dark.390w.dom.json), so a matrix cell's record carries its DONE_DONE_CELL suffix:
4
4
  // {"url": "<page url>", "overflow": <true when content is wider than the viewport>, "overflowing": ["<element>", ...],
5
5
  // "console_errors": ["<message>", ...], "failed_requests": [{"url": "<request url>", "status": <HTTP status, 0 when none>}],
6
- // "squashed_text": [{"element": "<element>", "text": "<its text, ~40 chars>", "chars": <width>, "lines": <n>, "words": <n>}]}
7
- // squashed_text is optional, so a record written before it stays valid.
6
+ // "squashed_text": [{"element": "<element>", "text": "<its text, ~40 chars>", "chars": <width>, "lines": <n>, "words": <n>}],
7
+ // "clipped_text": [{"element": "<element>", "text": "<its text>", "by": "<the container that cuts it>"}],
8
+ // "cut_off": [{"element": "<element>", "text": "<its text>", "edges": ["left" | "right" | "top", ...]}]}
9
+ // squashed_text, clipped_text and cut_off are optional, so a record written before them stays valid.
8
10
  import { readFileSync } from 'node:fs';
9
11
  import { basename } from 'node:path';
10
12
  // The measurement the recipe passes to its browser tool's eval, so no agent invents one: every visible element with its
@@ -27,8 +29,48 @@ export const SQUASHED_TEXT = `() => {
27
29
  }
28
30
  return out;
29
31
  }`.replace(/\s*\n\s*/g, ' ');
32
+ // The rest of the look measurement, run in the page by `done-done look` next to SQUASHED_TEXT. A part of an element only
33
+ // shows inside every ancestor that clips it (overflow other than visible; html and body clip as the viewport, so they are
34
+ // the edges, not containers). Text is clipped when part of it shows and part runs past a hidden or clip container on an
35
+ // axis without text-overflow: ellipsis or a line clamp; an element is cut off when part of what shows lies past the
36
+ // left, right or top edge of the viewport (the page is at its top, so nothing above it can be scrolled to). Something
37
+ // entirely outside its container or the viewport is hidden on purpose (a closed drawer, a visually hidden label), not cut;
38
+ // a cut element's children are not listed again.
39
+ // ponytail: the main frame only, and a transform or clip-path that hides part of a box is not seen; measure each frame
40
+ // and clip-path when those show up.
41
+ export const LOOK = `() => {
42
+ const vw = document.documentElement.clientWidth; const vh = document.documentElement.clientHeight; const out = { clipped_text: [], cut_off: [] }; const cut = new Set();
43
+ const name = (e) => e.tagName.toLowerCase() + (e.id ? '#' + e.id : e.classList[0] ? '.' + e.classList[0] : '');
44
+ const own = (e) => [...e.childNodes].filter((n) => n.nodeType === 3 && n.data.trim()).map((n) => n.data).join(' ').replace(/\\s+/g, ' ').trim().slice(0, 40);
45
+ const clips = (e) => { const out = []; for (let a = e; a && a !== document.body && a !== document.documentElement; a = a.parentElement) { const s = getComputedStyle(a); if (s.overflowX !== 'visible' || s.overflowY !== 'visible') { const r = a.getBoundingClientRect(); out.push({ a, s, l: r.left + a.clientLeft, t: r.top + a.clientTop, r: r.left + a.clientLeft + a.clientWidth, b: r.top + a.clientTop + a.clientHeight }); } } return out; };
46
+ const shows = (b) => b.r - b.l > 1 && b.b - b.t > 1;
47
+ for (const e of document.querySelectorAll('body *')) {
48
+ const s = getComputedStyle(e); const rect = e.getBoundingClientRect();
49
+ if (!e.getClientRects().length || s.visibility !== 'visible' || rect.width < 1 || rect.height < 1) continue;
50
+ const boxes = clips(e.parentElement);
51
+ const box = boxes.reduce((v, c) => ({ l: Math.max(v.l, c.l), t: Math.max(v.t, c.t), r: Math.min(v.r, c.r), b: Math.min(v.b, c.b) }), { l: rect.left, t: rect.top, r: rect.right, b: rect.bottom });
52
+ if (!shows(box)) continue;
53
+ const text = own(e);
54
+ const parent = [...cut].some((c) => c.contains(e));
55
+ const edges = [box.l < -1 && 'left', box.r > vw + 1 && 'right', box.t < -1 && 'top'].filter(Boolean);
56
+ if (edges.length && box.r > 0 && box.l < vw && box.b > 0 && box.t < vh && !parent) { cut.add(e); out.cut_off.push({ element: name(e), text, edges }); }
57
+ if (!text) continue;
58
+ const t = { l: Infinity, t: Infinity, r: -Infinity, b: -Infinity };
59
+ for (const n of e.childNodes) if (n.nodeType === 3 && n.data.trim()) { const g = document.createRange(); g.selectNodeContents(n); for (const q of g.getClientRects()) if (q.width) { t.l = Math.min(t.l, q.left); t.t = Math.min(t.t, q.top); t.r = Math.max(t.r, q.right); t.b = Math.max(t.b, q.bottom); } }
60
+ if (t.l === Infinity || t.t >= vh || t.b <= 0) continue;
61
+ for (const c of clips(e)) {
62
+ if (c.r - c.l <= 1 || c.b - c.t <= 1 || !(t.r > c.l && t.l < c.r && t.b > c.t && t.t < c.b) || !['hidden', 'clip'].some((v) => v === c.s.overflowX || v === c.s.overflowY)) continue;
63
+ const x = ['hidden', 'clip'].includes(c.s.overflowX) && c.s.textOverflow !== 'ellipsis' && (t.l < c.l - 1 || t.r > c.r + 1);
64
+ const y = ['hidden', 'clip'].includes(c.s.overflowY) && c.s.webkitLineClamp === 'none' && (t.t < c.t - 1 || t.b > c.b + 1);
65
+ if (x || y) { out.clipped_text.push({ element: name(e), text, by: c.a === e ? 'its own box' : name(c.a) }); break; }
66
+ }
67
+ }
68
+ return { overflow: document.documentElement.scrollWidth > vw, overflowing: [...document.querySelectorAll('body *')].filter((e) => e.getBoundingClientRect().right > vw + 1).slice(0, 10).map(name), ...out };
69
+ }`.replace(/\s*\n\s*/g, ' ');
30
70
  const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
31
71
  const strings = (a) => Array.isArray(a) && a.every((s) => typeof s === 'string');
72
+ // An optional record list: absent, or every item has an element and text and passes `more`.
73
+ const list = (a, more) => a === undefined || (Array.isArray(a) && a.every((q) => isMap(q) && typeof q.element === 'string' && typeof q.text === 'string' && more(q)));
32
74
  const badRegex = (s) => { try {
33
75
  new RegExp(s);
34
76
  return false;
@@ -71,13 +113,23 @@ export function domIssues(dom, capture) {
71
113
  }
72
114
  if (!isMap(r) || typeof r.url !== 'string' || typeof r.overflow !== 'boolean' || !strings(r.overflowing ?? []) || !strings(r.console_errors)
73
115
  || !Array.isArray(r.failed_requests) || !r.failed_requests.every((q) => isMap(q) && typeof q.url === 'string' && typeof q.status === 'number')
74
- || (r.squashed_text !== undefined && !(Array.isArray(r.squashed_text) && r.squashed_text.every((q) => isMap(q) && typeof q.element === 'string' && typeof q.text === 'string' && [q.chars, q.lines, q.words].every((n) => typeof n === 'number')))))
75
- return [`page-check record ${basename(path)} is not {url, overflow, overflowing, console_errors, failed_requests: [{url, status}], squashed_text: [{element, text, chars, lines, words}]}`];
116
+ || !list(r.squashed_text, (q) => [q.chars, q.lines, q.words].every((n) => typeof n === 'number'))
117
+ || !list(r.clipped_text, (q) => typeof q.by === 'string') || !list(r.cut_off, (q) => strings(q.edges)))
118
+ return [`page-check record ${basename(path)} is not {url, overflow, overflowing, console_errors, failed_requests: [{url, status}], squashed_text: [{element, text, chars, lines, words}], clipped_text: [{element, text, by}], cut_off: [{element, text, edges}]}`];
76
119
  const allowed = (list, s) => list.some((re) => new RegExp(re).test(s));
77
120
  return [
78
- ...(dom.fail_on_overflow && r.overflow ? [`${r.url} overflows the viewport horizontally${r.overflowing?.length ? ` (${r.overflowing.join(', ')})` : ''}`] : []),
121
+ ...lookIssues({ ...r, overflow: dom.fail_on_overflow && r.overflow }),
79
122
  ...r.console_errors.filter((m) => !allowed(dom.allow_console, m)).map((m) => `${r.url} console error: ${m}`),
80
123
  ...r.failed_requests.filter((q) => (q.status === 0 || q.status >= 400) && !allowed(dom.allow_requests, q.url)).map((q) => `${r.url} request failed (${q.status || 'no response'}): ${q.url}`),
124
+ ];
125
+ }
126
+ // The look defects a page-check record shows: horizontal overflow, squashed, clipped and cut-off text or elements.
127
+ export function lookIssues(r) {
128
+ const quoted = (t) => (t ? ` "${t}"` : '');
129
+ return [
130
+ ...(r.overflow ? [`${r.url} overflows the viewport horizontally${r.overflowing?.length ? ` (${r.overflowing.join(', ')})` : ''}`] : []),
81
131
  ...(r.squashed_text ?? []).map((q) => `${r.url} squashed text: ${q.element} "${q.text}" is ${q.chars} characters wide (lines ${q.lines}, words ${q.words})`),
132
+ ...(r.clipped_text ?? []).map((q) => `${r.url} clipped text: ${q.element}${quoted(q.text)} is cut by ${q.by}`),
133
+ ...(r.cut_off ?? []).map((q) => `${r.url} cut off: ${q.element}${quoted(q.text)} runs past the ${q.edges.join(' and ')} edge of the viewport`),
82
134
  ];
83
135
  }
package/dist/judge.js CHANGED
@@ -1,9 +1,8 @@
1
- import { spawnSync } from 'node:child_process';
2
1
  import { createHash } from 'node:crypto';
3
- import { accessSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
2
+ import { accessSync, constants, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
4
3
  import { tmpdir } from 'node:os';
5
4
  import { basename, delimiter, isAbsolute, join } from 'node:path';
6
- import { agentRun, askAgent, claudeBin, notStartable } from './agents.js';
5
+ import { agentRun, askAgent, notStartable } from './agents.js';
7
6
  import { home, KEY_ROUTES, parseDuration, UsageError } from './config.js';
8
7
  import { downscaleToPng } from './media.js';
9
8
  const MAX_SIDE = 1280; // long edge sent to the model, in pixels
@@ -79,21 +78,6 @@ export function onPath(name) {
79
78
  }
80
79
  return '';
81
80
  }
82
- // Is claude signed in to this sign-in folder? Reads only the binary's non-secret auth status, as the kit does.
83
- // It runs in a scratch folder with a scratch HOME, so nothing else of the user's is read.
84
- export function claudeLoggedIn(bin, configDir) {
85
- const scratch = mkdtempSync(join(tmpdir(), 'done-done-judge-'));
86
- try {
87
- const r = spawnSync(bin, ['--safe-mode', '--setting-sources', '', 'auth', 'status'], { cwd: scratch, encoding: 'utf8', timeout: 30_000, env: { HOME: scratch, PATH: process.env.PATH, CLAUDE_CONFIG_DIR: configDir } });
88
- return r.status === 0 && JSON.parse(r.stdout).loggedIn === true;
89
- }
90
- catch {
91
- return false;
92
- }
93
- finally {
94
- rmSync(scratch, { recursive: true, force: true });
95
- }
96
- }
97
81
  const KIT = '@byokit/decide@0.8.0';
98
82
  export const quote = (a) => (/^[\w@%+=:,./-]+$/.test(a) ? a : `'${a.replaceAll("'", `'\\''`)}'`);
99
83
  // The kit is an optional peer dependency, so a plain install stays small; it loads only when a capture needs judging.
@@ -102,7 +86,7 @@ async function kit() {
102
86
  if (major < 22 || (major === 22 && minor < 18))
103
87
  throw new UsageError(`judge: the judge needs Node 22.18 or later (this is ${process.versions.node})`, 'Install Node 22.18 or later, then re-run this command');
104
88
  try {
105
- return { ...(await import('@byokit/decide/claude-code')), ...(await import('@byokit/decide')) };
89
+ return await import('@byokit/decide');
106
90
  }
107
91
  catch (e) {
108
92
  if (e.code !== 'ERR_MODULE_NOT_FOUND')
@@ -114,7 +98,7 @@ async function kit() {
114
98
  }
115
99
  }
116
100
  // The backend for one judge choice. It checks everything that needs no model call, so doctor can use it too.
117
- // record: where the answerer writes its agent call record (the claude-code route starts no agent of ours);
101
+ // record: where each agent call writes its call record (the claude-code and answerer routes both run a real agent call);
118
102
  // questions: what each request asks, so the answerer can ask its agent for that answer shape; attempts: asks per request
119
103
  // when the answer is malformed.
120
104
  export async function backend(cfg, choice, record = () => '', questions = { relevance: QUESTION }, attempts = 1) {
@@ -126,57 +110,35 @@ export async function backend(cfg, choice, record = () => '', questions = { rele
126
110
  throw new UsageError(`judge: ${choice.route} needs your own API key in ${name}, which is not set`, `Export ${name} with your key, then re-run this command`);
127
111
  const { openai, resolve } = await kit();
128
112
  const ask = openai({ model: choice.model, key, supportsImages: true, maxRetries: 0 }).ask;
129
- return { ask, resolve, lastError: () => '', signIn: '', signedIn: () => true, detail: `${name} is set and the judge kit is present` };
113
+ return { ask, resolve, lastError: () => '', detail: `${name} is set and the judge kit is present` };
130
114
  }
131
- const timeoutMs = parseDuration(cfg.agent_timeout);
132
115
  const override = cfg.agent_path_override[choice.agent];
133
- if (choice.route !== 'claude-code') {
134
- const run = agentRun(cfg, choice.agent, choice.model);
135
- const bin = run.path;
136
- if (!bin)
137
- throw new UsageError(`judge: ${notStartable(choice.agent, override)}`, `Install ${choice.agent}, or ask the user for another judge and save it: done-done init --judge <agent> --judge-model <model>`);
138
- const { answerer, resolve } = await kit();
139
- let lastError = '';
140
- // The kit hides a failed call's message from the verdict, so the real cause is kept here for the result.
141
- let requests = 0;
142
- const ask = answerer({
143
- name: choice.route,
144
- leaves: true,
145
- supportsImages: true,
146
- ask: async (prompt, _signal, images) => {
147
- lastError = '';
148
- requests++;
149
- const attached = images.map((img) => ({ name: `${img.id}.${img.mime.split('/')[1]}`, bytes: Buffer.from(img.dataUrl.slice(img.dataUrl.indexOf(',') + 1), 'base64') }));
150
- // A tool-less call runs in its own scratch folder, so the CLI sees the attached capture and nothing else.
151
- const a = await askAgent({ ...run, prompt, schema: answerSchema(questions), cwd: tmpdir(), tools: 'none', attempts, record: record(requests), images: attached });
152
- lastError = a.ok ? '' : a.error ?? 'no answer';
153
- if (!a.ok)
154
- throw new Error(lastError);
155
- return JSON.stringify(a.answer);
156
- },
157
- }).ask;
158
- return { ask, resolve, lastError: () => lastError, signIn: '', signedIn: () => true, detail: `${choice.agent} at ${bin} and the judge kit are present; it judges with every tool off` };
159
- }
160
- const bin = claudeBin(cfg);
161
- const dir = cfg.judge.claude_code.config_dir;
162
- const signIn = `CLAUDE_CONFIG_DIR=${dir} ${bin || 'claude'} auth login`;
163
- const where = '~/.done-done/config.yaml';
116
+ const run = agentRun(cfg, choice.agent, choice.model);
117
+ const bin = run.path;
118
+ const claude = choice.agent === 'claude';
164
119
  if (!bin)
165
- throw new UsageError(`judge: ${notStartable('claude', override)}`, override ? `Fix agent_path_override.claude in ${where}, or install Claude Code` : 'Install Claude Code, or set agent_path_override.claude to its absolute path');
166
- if (!isAbsolute(dir))
167
- throw new UsageError(`judge: judge.claude_code.config_dir must be an absolute path (got ${dir})`, `Fix judge.claude_code.config_dir in ${where}`);
168
- if (!existsSync(dir))
169
- throw new UsageError(`judge: no Claude sign-in at ${dir}`, `Sign in once with your Claude plan: mkdir -p ${dir} && ${signIn}`);
170
- const { claudeCode, resolve } = await kit();
171
- // The real folder, so a link to ~/.claude is refused here as a config error, not later per capture.
172
- const real = realpathSync(dir);
173
- try {
174
- const ask = claudeCode({ bin, configDir: real, model: choice.model, timeoutMs }).ask;
175
- return { ask, resolve, lastError: () => '', signIn, signedIn: () => claudeLoggedIn(bin, real), detail: `claude binary at ${bin}, a sign-in folder and the judge kit are present (login is checked only when a capture is judged)` };
176
- }
177
- catch (e) {
178
- throw new UsageError(`judge: ${e.message} (claude ${bin}, judge.claude_code.config_dir ${dir})`, 'Set judge.claude_code.config_dir to a folder of its own, not your ~/.claude');
179
- }
120
+ throw new UsageError(`judge: ${notStartable(choice.agent, override)}`, claude ? (override ? 'Fix agent_path_override.claude in ~/.done-done/config.yaml, or install Claude Code' : 'Install Claude Code, or set agent_path_override.claude to its absolute path') : `Install ${choice.agent}, or ask the user for another judge and save it: done-done init --judge <agent> --judge-model <model>`);
121
+ const { answerer, resolve } = await kit();
122
+ let lastError = '';
123
+ // The kit hides a failed call's message from the verdict, so the real cause is kept here for the result.
124
+ let requests = 0;
125
+ const ask = answerer({
126
+ name: choice.route,
127
+ leaves: true,
128
+ supportsImages: true,
129
+ ask: async (prompt, _signal, images) => {
130
+ lastError = '';
131
+ requests++;
132
+ const attached = images.map((img) => ({ name: `${img.id}.${img.mime.split('/')[1]}`, bytes: Buffer.from(img.dataUrl.slice(img.dataUrl.indexOf(',') + 1), 'base64') }));
133
+ // A tool-less call runs in its own scratch folder, so the CLI sees the attached capture and nothing else.
134
+ const a = await askAgent({ ...run, prompt, schema: answerSchema(questions), cwd: tmpdir(), tools: 'none', safeMode: true, attempts, record: record(requests), images: attached });
135
+ lastError = a.ok ? '' : a.error ?? 'no answer';
136
+ if (!a.ok)
137
+ throw new Error(lastError);
138
+ return JSON.stringify(a.answer);
139
+ },
140
+ }).ask;
141
+ return { ask, resolve, lastError: () => lastError, detail: claude ? `claude binary at ${bin} and the judge kit are present; it judges with your own claude sign-in` : `${choice.agent} at ${bin} and the judge kit are present; it judges with every tool off` };
180
142
  }
181
143
  // The image to send: the original when it already fits, else a downscaled PNG; a reason when neither works.
182
144
  export function prepare(c) {
@@ -189,7 +151,7 @@ export function prepare(c) {
189
151
  // One request about the named images: each question's answer after the floors, or why the judge did not answer.
190
152
  // An answer the kit cannot read (all-zero probabilities, an option left out) would abstain on every question as if the
191
153
  // judge were unsure, so it is asked once more, and one that still cannot be read is never kept.
192
- // A signed-out or non-subscription claude-code sign-in is a usage error, since no later request can do better.
154
+ // A signed-out claude is a usage error, since no later request can do better.
193
155
  export async function askJudge(cfg, be, choice, state, questions, images) {
194
156
  try {
195
157
  const ask = () => be.ask(state, questions, AbortSignal.timeout(parseDuration(cfg.agent_timeout)), Object.entries(images).map(([id, i]) => ({ id, mime: i.mime, dataUrl: `data:${i.mime};base64,${i.bytes.toString('base64')}` })));
@@ -203,12 +165,10 @@ export async function askJudge(cfg, be, choice, state, questions, images) {
203
165
  return Object.fromEntries(Object.entries(questions).map(([k, q]) => [k, be.resolve(q, raw[k])]));
204
166
  }
205
167
  catch (e) {
206
- const code = e.code;
207
168
  const why = be.lastError() || e.message;
208
- // The kit reports a signed-out binary, a bad model id and a used-up plan all as 'process', so ask the binary which.
209
- if (choice.route === 'claude-code' && (code === 'subscription_required' || (code === 'process' && !be.signedIn())))
210
- throw new UsageError(`judge: ${why} (sign-in ${cfg.judge.claude_code.config_dir})`, `Sign in once with your Claude plan: ${be.signIn}`);
211
- const hint = code === 'process' ? ` (check judge.model ${choice.model} and your plan's usage limit)` : Object.hasOwn(KEY_ROUTES, choice.route) ? ` (check judge.model ${choice.model} and the key in ${KEY_ROUTES[choice.route]})` : '';
169
+ if (choice.route === 'claude-code' && /not logged in/i.test(why))
170
+ throw new UsageError(`judge: claude is not signed in (${why})`, 'sign in with: claude auth login, then re-run this command');
171
+ const hint = choice.route === 'claude-code' ? ` (check judge.model ${choice.model} and your plan's usage limit)` : Object.hasOwn(KEY_ROUTES, choice.route) ? ` (check judge.model ${choice.model} and the key in ${KEY_ROUTES[choice.route]})` : '';
212
172
  return `the judge did not answer: ${why}${hint}`;
213
173
  }
214
174
  }
package/dist/look.js ADDED
@@ -0,0 +1,116 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { UsageError } from './config.js';
6
+ import { domRecord, LOOK, SQUASHED_TEXT } from './dom.js';
7
+ import { findBin } from './agents.js';
8
+ // done-done look: opens one page in headless Chrome at a phone viewport over the DevTools protocol (Node's own
9
+ // WebSocket, no browser library), writes the screenshot and its page-check record next to it, and returns the record.
10
+ // Chrome runs without its sandbox so it starts in containers and CI: point look at your own app, not untrusted sites.
11
+ const BROWSERS = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'];
12
+ const WAIT = 30_000;
13
+ export async function look(url, capture, width, height) {
14
+ const bin = process.env.DONE_DONE_CHROME ? findBin(process.env.DONE_DONE_CHROME) : BROWSERS.map(findBin).find(Boolean);
15
+ if (!bin)
16
+ throw new UsageError(`look needs Chrome or Chromium: ${process.env.DONE_DONE_CHROME ? `DONE_DONE_CHROME=${process.env.DONE_DONE_CHROME} is not an executable` : `none of ${BROWSERS.slice(0, 4).join(', ')} is on PATH`}`, 'Install Chrome or Chromium, or set DONE_DONE_CHROME to its binary');
17
+ const profile = mkdtempSync(join(tmpdir(), 'done-done-look-'));
18
+ const chrome = spawn(bin, ['--headless', '--no-sandbox', '--disable-gpu', '--hide-scrollbars', '--no-first-run', '--no-default-browser-check', `--user-data-dir=${profile}`, '--remote-debugging-port=0', `--window-size=${width},${height}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] });
19
+ let ws;
20
+ try {
21
+ // Chrome prints its DevTools address on stderr once it listens; it exiting first is the real cause, so show its words.
22
+ let said = '';
23
+ const port = await within(WAIT, 'Chrome did not start', (done, fail) => {
24
+ chrome.stderr.on('data', (d) => {
25
+ said += d;
26
+ const m = /DevTools listening on ws:\/\/[^:]+:(\d+)\//.exec(said);
27
+ if (m)
28
+ done(m[1]);
29
+ });
30
+ chrome.on('error', (e) => fail(new Error(`Chrome (${bin}) did not start: ${e.message}`)));
31
+ chrome.on('exit', (code) => fail(new Error(`Chrome (${bin}) exited ${code} before it listened: ${said.trim().split('\n').slice(-3).join(' ') || 'no output'}`)));
32
+ });
33
+ const pages = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json());
34
+ const target = pages.find((p) => p.type === 'page');
35
+ if (!target)
36
+ throw new Error('Chrome opened no page to drive');
37
+ const sock = new WebSocket(target.webSocketDebuggerUrl);
38
+ ws = sock;
39
+ await within(WAIT, 'Chrome DevTools did not connect', (done, fail) => {
40
+ sock.onopen = () => done();
41
+ sock.onerror = () => fail(new Error('Chrome DevTools connection failed'));
42
+ });
43
+ let id = 0;
44
+ const waiting = new Map();
45
+ const on = new Map();
46
+ let failLoad;
47
+ const died = (why) => { for (const w of waiting.values())
48
+ w.fail(new Error(why)); waiting.clear(); failLoad?.(new Error(why)); };
49
+ sock.onclose = () => died(`the Chrome DevTools connection closed before ${url} was captured`);
50
+ chrome.on('exit', (code) => died(`Chrome (${bin}) exited ${code} before ${url} was captured`));
51
+ sock.onmessage = (m) => {
52
+ const msg = JSON.parse(String(m.data));
53
+ const w = waiting.get(msg.id);
54
+ if (w) {
55
+ waiting.delete(msg.id);
56
+ if (msg.error)
57
+ w.fail(new Error(`Chrome DevTools: ${msg.error.message}`));
58
+ else
59
+ w.done(msg.result);
60
+ }
61
+ else
62
+ on.get(msg.method)?.(msg.params);
63
+ };
64
+ const send = (method, params = {}) => new Promise((done, fail) => {
65
+ waiting.set(++id, { done, fail });
66
+ sock.send(JSON.stringify({ id, method, params }));
67
+ });
68
+ const evaluate = async (expression) => {
69
+ const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
70
+ if (r.exceptionDetails)
71
+ throw new Error(`the look measurement threw in ${url}: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`);
72
+ return r.result.value;
73
+ };
74
+ // What the page logged and fetched, for the record's console_errors and failed_requests.
75
+ const consoleErrors = [];
76
+ const failed = [];
77
+ const requests = new Map();
78
+ on.set('Runtime.consoleAPICalled', (p) => { if (p.type === 'error')
79
+ consoleErrors.push(p.args.map((a) => a.value ?? a.description ?? '').join(' ')); });
80
+ on.set('Runtime.exceptionThrown', (p) => consoleErrors.push(p.exceptionDetails.exception?.description ?? p.exceptionDetails.text));
81
+ on.set('Network.requestWillBeSent', (p) => requests.set(p.requestId, p.request.url));
82
+ on.set('Network.responseReceived', (p) => { if (p.response.status >= 400)
83
+ failed.push({ url: p.response.url, status: p.response.status }); });
84
+ on.set('Network.loadingFailed', (p) => { if (!p.canceled)
85
+ failed.push({ url: requests.get(p.requestId) ?? '', status: 0 }); });
86
+ for (const d of ['Page', 'Runtime', 'Network'])
87
+ await send(`${d}.enable`);
88
+ await send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: true });
89
+ const loaded = within(WAIT, `${url} did not finish loading in ${WAIT / 1000}s`, (done, fail) => { failLoad = fail; on.set('Page.loadEventFired', () => done()); });
90
+ loaded.catch(() => { }); // a navigation error below is the cause to report, not this wait
91
+ const nav = await send('Page.navigate', { url });
92
+ if (nav.errorText)
93
+ throw new Error(`${url} did not load: ${nav.errorText}`);
94
+ await loaded;
95
+ // Web fonts change text widths, so measure once they are in and two frames have painted.
96
+ await evaluate('document.fonts.ready.then(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))))');
97
+ const record = { url, ...(await evaluate(`(${LOOK})()`)), console_errors: consoleErrors, failed_requests: failed, squashed_text: await evaluate(`(${SQUASHED_TEXT})()`) };
98
+ const shot = await send('Page.captureScreenshot', { format: 'png' });
99
+ writeFileSync(capture, Buffer.from(shot.data, 'base64'));
100
+ writeFileSync(domRecord(capture), `${JSON.stringify(record, null, 2)}\n`);
101
+ return record;
102
+ }
103
+ finally {
104
+ ws?.close();
105
+ chrome.kill('SIGKILL');
106
+ await new Promise((r) => (chrome.exitCode === null && chrome.signalCode === null ? chrome.once('exit', r) : r(null)));
107
+ rmSync(profile, { recursive: true, force: true });
108
+ }
109
+ }
110
+ // A promise that fails with `why` after `ms`; its timer alone never keeps the process running.
111
+ function within(ms, why, run) {
112
+ return new Promise((done, fail) => {
113
+ const timer = setTimeout(() => fail(new Error(why)), ms).unref();
114
+ run((v) => (clearTimeout(timer), done(v)), (e) => (clearTimeout(timer), fail(e)));
115
+ });
116
+ }