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/README.md +10 -7
- package/dist/agents.js +133 -23
- package/dist/cli.js +165 -47
- package/dist/config.js +20 -18
- package/dist/dom.js +57 -5
- package/dist/judge.js +34 -74
- package/dist/look.js +116 -0
- package/docs/reference.md +62 -23
- package/docs/releasing.md +12 -0
- package/package.json +1 -1
- package/skills/done-done/SKILL.md +10 -0
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
|
-
//
|
|
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
|
-
||
|
|
75
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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: () => '',
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
+
}
|