ucode-agent 1.44.0 → 1.45.0
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/package.json +2 -1
- package/src/core/csscheck.js +77 -0
- package/src/core/jslogic.js +69 -0
- package/src/core/loop.js +71 -7
- package/src/tools/browser.js +546 -496
- package/src/tools/index.js +13 -10
- package/src/tools/scaffold.js +80 -65
- package/src/tools/shared.js +389 -343
- package/src/ui/plain.js +353 -353
- package/src/ui/screen.js +1699 -1692
- package/src/ui/theme.js +814 -776
package/src/tools/browser.js
CHANGED
|
@@ -1,496 +1,546 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* browser.js — looking at the app the way a person would.
|
|
3
|
-
*
|
|
4
|
-
* A build that passes and a page that works are different claims. This opens
|
|
5
|
-
* the running app in a real browser at a phone width and a desktop width, and
|
|
6
|
-
* reports what a person would run into: errors in the console, requests that
|
|
7
|
-
* failed, a layout that spills off the side of a phone, broken images,
|
|
8
|
-
* controls with no name. It saves a screenshot of each, and has the one model
|
|
9
|
-
* in the set that can see — Nemotron Nano Omni — review them as a designer
|
|
10
|
-
* would. The model building the app then has something concrete to fix.
|
|
11
|
-
*
|
|
12
|
-
* It drives the browser already on the machine (Edge or Chrome) through
|
|
13
|
-
* playwright-core, so there is no separate 150 MB browser download.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { promises as fs } from 'node:fs';
|
|
17
|
-
import fsSync from 'node:fs';
|
|
18
|
-
import path from 'node:path';
|
|
19
|
-
import { ToolFailure } from '../core/failure.js';
|
|
20
|
-
import { ask } from '../core/provider.js';
|
|
21
|
-
import { getRoot, result } from './shared.js';
|
|
22
|
-
|
|
23
|
-
const VISION_MODEL = 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free';
|
|
24
|
-
const WIDTHS = [
|
|
25
|
-
{ name: 'phone', width: 375, height: 812 },
|
|
26
|
-
{ name: 'desktop', width: 1440, height: 900 },
|
|
27
|
-
];
|
|
28
|
-
const LOCAL = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i;
|
|
29
|
-
const MAX_SHOT_HEIGHT = 3000;
|
|
30
|
-
|
|
31
|
-
let browserPromise = null;
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* One browser for the whole session, started on first use. The installed
|
|
35
|
-
* Edge or Chrome is tried first; Playwright's own Chromium only if it happens
|
|
36
|
-
* to be installed.
|
|
37
|
-
*/
|
|
38
|
-
async function browser() {
|
|
39
|
-
if (browserPromise) return browserPromise;
|
|
40
|
-
browserPromise = (async () => {
|
|
41
|
-
let chromium;
|
|
42
|
-
try {
|
|
43
|
-
({ chromium } = await import('playwright-core'));
|
|
44
|
-
} catch (err) {
|
|
45
|
-
throw new ToolFailure({
|
|
46
|
-
kind: 'no_playwright',
|
|
47
|
-
attempted: 'starting a browser',
|
|
48
|
-
failed: `playwright-core could not be loaded: ${err.message}`,
|
|
49
|
-
fix: 'Reinstall ucode (npm install -g ucode-agent). Carry on without looking at the app, and say so.',
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
const tried = [];
|
|
53
|
-
for (const channel of ['msedge', 'chrome', undefined]) {
|
|
54
|
-
try {
|
|
55
|
-
return await chromium.launch({ channel, headless: true });
|
|
56
|
-
} catch (err) {
|
|
57
|
-
tried.push(`${channel ?? 'bundled chromium'}: ${String(err.message).split('\n')[0]}`);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
throw new ToolFailure({
|
|
61
|
-
kind: 'no_browser',
|
|
62
|
-
attempted: 'starting a browser',
|
|
63
|
-
failed: `No browser could be started. Tried ${tried.join('; ')}.`,
|
|
64
|
-
fix: 'Install Google Chrome or Microsoft Edge. Carry on without looking at the app, and say so.',
|
|
65
|
-
});
|
|
66
|
-
})();
|
|
67
|
-
browserPromise.catch(() => { browserPromise = null; });
|
|
68
|
-
return browserPromise;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Close the shared browser, if one was started. Called when ucode exits. */
|
|
72
|
-
export async function closeBrowser() {
|
|
73
|
-
if (!browserPromise) return;
|
|
74
|
-
try { await (await browserPromise).close(); } catch { /* already gone */ }
|
|
75
|
-
browserPromise = null;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Layout and accessibility checks run inside the page. */
|
|
79
|
-
function inspect() {
|
|
80
|
-
const vw = window.innerWidth;
|
|
81
|
-
const describeEl = (el) => {
|
|
82
|
-
const id = el.id ? `#${el.id}` : '';
|
|
83
|
-
const cls = typeof el.className === 'string' && el.className.trim()
|
|
84
|
-
? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` : '';
|
|
85
|
-
const text = (el.innerText || el.getAttribute('aria-label') || '').trim().replace(/\s+/g, ' ').slice(0, 40);
|
|
86
|
-
return `<${el.tagName.toLowerCase()}${id}${cls}>${text ? ` "${text}"` : ''}`;
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const overflow = document.documentElement.scrollWidth - vw;
|
|
90
|
-
const wide = [];
|
|
91
|
-
if (overflow > 1) {
|
|
92
|
-
for (const el of document.querySelectorAll('body *')) {
|
|
93
|
-
const r = el.getBoundingClientRect();
|
|
94
|
-
if (r.width > 0 && r.right > vw + 1 && getComputedStyle(el).position !== 'fixed') {
|
|
95
|
-
wide.push(`${describeEl(el)} reaches ${Math.round(r.right)}px`);
|
|
96
|
-
if (wide.length >= 5) break;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const broken = [...document.images].filter((i) => i.complete && i.naturalWidth === 0).map((i) => i.src.slice(0, 80));
|
|
102
|
-
const noAlt = [...document.images].filter((i) => !i.hasAttribute('alt')).length;
|
|
103
|
-
const unnamed = [...document.querySelectorAll('button, a[href], [role="button"]')]
|
|
104
|
-
.filter((el) => !(el.innerText || '').trim() && !el.getAttribute('aria-label') && !el.getAttribute('title')
|
|
105
|
-
&& !el.querySelector('[aria-label], title, img[alt]:not([alt=""])'))
|
|
106
|
-
.slice(0, 5).map(describeEl);
|
|
107
|
-
const inputsNoLabel = [...document.querySelectorAll('input:not([type="hidden"]), textarea, select')]
|
|
108
|
-
.filter((el) => !(el.id && document.querySelector(`label[for="${el.id}"]`)) && !el.closest('label')
|
|
109
|
-
&& !el.getAttribute('aria-label') && !el.getAttribute('aria-labelledby'))
|
|
110
|
-
.length;
|
|
111
|
-
const tiny = vw < 600
|
|
112
|
-
? [...document.querySelectorAll('button, a[href], [role="button"], input, select')]
|
|
113
|
-
.filter((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && (r.height < 32 || r.width < 32); })
|
|
114
|
-
.length
|
|
115
|
-
: 0;
|
|
116
|
-
const smallText = [...document.querySelectorAll('p, li, span, a, button, label, td')]
|
|
117
|
-
.filter((el) => el.childElementCount === 0 && (el.innerText || '').trim() && parseFloat(getComputedStyle(el).fontSize) < 12)
|
|
118
|
-
.length;
|
|
119
|
-
|
|
120
|
-
return {
|
|
121
|
-
title: document.title,
|
|
122
|
-
overflow: overflow > 1 ? Math.round(overflow) : 0,
|
|
123
|
-
wide, broken, noAlt, unnamed, inputsNoLabel, tiny, smallText,
|
|
124
|
-
empty: !(document.body.innerText || '').trim(),
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const safeName = (p) => (p === '/' ? 'home' : p.replace(/^\/+|\/+$/g, '').replace(/[^\w-]+/g, '_')) || 'page';
|
|
129
|
-
|
|
130
|
-
async function review(shots) {
|
|
131
|
-
const request = [
|
|
132
|
-
{
|
|
133
|
-
role: 'system',
|
|
134
|
-
content:
|
|
135
|
-
'You are a senior product designer reviewing screenshots of a web app, one at a phone width ' +
|
|
136
|
-
'and one at desktop width. Each screenshot is the whole page, top to bottom, so anything not ' +
|
|
137
|
-
'in it is genuinely not there. List the concrete visual problems a user would notice, most ' +
|
|
138
|
-
'important first: broken or cramped layout, overflow, misalignment, weak hierarchy (is the ' +
|
|
139
|
-
'most important thing the most prominent?), inconsistent spacing, low contrast, default-looking ' +
|
|
140
|
-
'components, awkward empty states, text that is too small. For each: where it is, what is wrong, ' +
|
|
141
|
-
'and the specific fix. At most 8 points, one or two lines each. If it genuinely looks polished, ' +
|
|
142
|
-
'say so in one line and name the one thing that would improve it most. No preamble.',
|
|
143
|
-
},
|
|
144
|
-
{
|
|
145
|
-
role: 'user',
|
|
146
|
-
content: shots.map((s) => `${s.label}`).join(' and ') + '.',
|
|
147
|
-
images: shots.map((s) => s.dataUrl),
|
|
148
|
-
},
|
|
149
|
-
];
|
|
150
|
-
const reply = await ask(request, [], {
|
|
151
|
-
model: VISION_MODEL,
|
|
152
|
-
temperature: 0.2,
|
|
153
|
-
// A reasoning model spends its budget thinking before it writes; 900
|
|
154
|
-
// tokens came back as an empty review. Keep the thinking short, and leave
|
|
155
|
-
// room for the answer.
|
|
156
|
-
maxOutputTokens: 4000,
|
|
157
|
-
reasoning: { effort: 'low' },
|
|
158
|
-
// The free vision model is often busy. One try, and a hard cap: a review
|
|
159
|
-
// that cannot run is skipped, never waited on.
|
|
160
|
-
attempts: 1,
|
|
161
|
-
signal: AbortSignal.timeout(REVIEW_BUDGET_MS),
|
|
162
|
-
});
|
|
163
|
-
const text = reply.text.trim();
|
|
164
|
-
if (!text) throw new Error('the vision model returned an empty review');
|
|
165
|
-
return text;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const REVIEW_BUDGET_MS = 60_000;
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* The designer's review is the slow part — a reasoning model looking at
|
|
172
|
-
* screenshots, most of a minute — so each app gets one per turn: a look after
|
|
173
|
-
* the fixes only re-runs the fast checks. A review that failed (busy model,
|
|
174
|
-
* empty reply) gets one more try on the next look, then is let go.
|
|
175
|
-
*/
|
|
176
|
-
const reviews = new Map(); // base URL -> { done, tries }
|
|
177
|
-
|
|
178
|
-
/** A new request from the user: the apps may be reviewed afresh. */
|
|
179
|
-
export function forgetReviews() {
|
|
180
|
-
reviews.clear();
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Text typed into the app while checking it. Distinctive enough to recognise
|
|
186
|
-
* in a screenshot, and obviously not something a user wrote.
|
|
187
|
-
*/
|
|
188
|
-
const PROBE_TEXT = 'ucode check';
|
|
189
|
-
|
|
190
|
-
/** Did anything at all happen on the page? */
|
|
191
|
-
const moved = (a, b) => a.nodes !== b.nodes || a.text !== b.text || a.stored !== b.stored;
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Use the app, rather than only looking at it.
|
|
195
|
-
*
|
|
196
|
-
* Everything else here is an inspection: overflow, labels, broken images,
|
|
197
|
-
* console errors on load. All of it passes on an app whose Add button does
|
|
198
|
-
* nothing, because a page with dead JavaScript still renders, still has good
|
|
199
|
-
* contrast and still has no console errors — it simply does not work. Nothing
|
|
200
|
-
* in the harness ever pressed anything, so the model was never told, and never
|
|
201
|
-
* fixed it.
|
|
202
|
-
*
|
|
203
|
-
* So: type into the first text field, press Enter, and if that changed nothing,
|
|
204
|
-
* click the first button. Then look at whether the page has more nodes, more
|
|
205
|
-
* text, or more in localStorage than it did. Any of those moving means the core
|
|
206
|
-
* loop is wired up. None of them moving, on a page that has controls to press,
|
|
207
|
-
* means it is not.
|
|
208
|
-
*
|
|
209
|
-
* Real keyboard and mouse input through the driver, never synthetic DOM events:
|
|
210
|
-
* an implicit form submit does not fire for a dispatched event, which would
|
|
211
|
-
* report a working form as dead.
|
|
212
|
-
*
|
|
213
|
-
* A page with nothing to press — a landing page, a chart, a page of prose — is
|
|
214
|
-
* not exercised and not judged. Returning null there is the difference between
|
|
215
|
-
* a check and a false accusation.
|
|
216
|
-
*/
|
|
217
|
-
async function useTheApp(page) {
|
|
218
|
-
const snapshot = () => page.evaluate(() => ({
|
|
219
|
-
nodes: document.body.querySelectorAll('*').length,
|
|
220
|
-
text: document.body.innerText.replace(/\s+/g, ' ').trim().length,
|
|
221
|
-
stored: (() => { try { return JSON.stringify(localStorage).length; } catch { return 0; } })(),
|
|
222
|
-
}));
|
|
223
|
-
|
|
224
|
-
const before = await snapshot().catch(() => null);
|
|
225
|
-
if (!before) return null;
|
|
226
|
-
const tried = [];
|
|
227
|
-
|
|
228
|
-
// A field and the Enter key: the core loop of most one-page apps.
|
|
229
|
-
const field = page.locator('input[type="text"], input[type="search"], input:not([type]), textarea').first();
|
|
230
|
-
if (await field.count().catch(() => 0)) {
|
|
231
|
-
const ok = await field.fill(PROBE_TEXT, { timeout: 2_000 }).then(() => true).catch(() => false);
|
|
232
|
-
if (ok) {
|
|
233
|
-
await field.press('Enter', { timeout: 2_000 }).catch(() => {});
|
|
234
|
-
await page.waitForTimeout(300);
|
|
235
|
-
tried.push('typed into the first field and pressed Enter');
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
let after = await snapshot().catch(() => before);
|
|
240
|
-
if (tried.length && moved(before, after)) return { tried, worked: true };
|
|
241
|
-
|
|
242
|
-
// Nothing moved, so try the other half of the same pattern — the button that
|
|
243
|
-
// submits, before any other. Taking simply the first button in the document
|
|
244
|
-
// finds the theme toggle in the header, clicks it, and reports a working app
|
|
245
|
-
// as dead because switching to dark mode adds no elements.
|
|
246
|
-
const candidates = [
|
|
247
|
-
page.locator('form button[type="submit"], form input[type="submit"], button[type="submit"], input[type="submit"]'),
|
|
248
|
-
page.locator('form button:not([disabled])'),
|
|
249
|
-
page.locator('button:not([disabled]), [role="button"]'),
|
|
250
|
-
];
|
|
251
|
-
|
|
252
|
-
const pressed = new Set();
|
|
253
|
-
for (const group of candidates) {
|
|
254
|
-
const count = Math.min(await group.count().catch(() => 0), 3);
|
|
255
|
-
for (let i = 0; i < count; i++) {
|
|
256
|
-
const button = group.nth(i);
|
|
257
|
-
const label = (await button.innerText().catch(() => '') || '').trim().replace(/\s+/g, ' ').slice(0, 24);
|
|
258
|
-
if (pressed.has(label || `#${i}`)) continue;
|
|
259
|
-
pressed.add(label || `#${i}`);
|
|
260
|
-
|
|
261
|
-
const ok = await button.click({ timeout: 2_000 }).then(() => true).catch(() => false);
|
|
262
|
-
if (!ok) continue;
|
|
263
|
-
await page.waitForTimeout(300);
|
|
264
|
-
tried.push(`clicked ${label ? `"${label}"` : 'a button'}`);
|
|
265
|
-
|
|
266
|
-
after = await snapshot().catch(() => after);
|
|
267
|
-
if (moved(before, after)) return { tried, worked: true };
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (!tried.length) return null;
|
|
272
|
-
if (!moved(before, after)) return { tried, worked: false };
|
|
273
|
-
|
|
274
|
-
// It worked. Did any of it last?
|
|
275
|
-
//
|
|
276
|
-
// An app that writes to localStorage and never reads it back looks perfect
|
|
277
|
-
// for as long as you stay on the page, and loses everything the moment
|
|
278
|
-
// anyone refreshes. Only asked when the app actually stored something —
|
|
279
|
-
// otherwise no persistence was intended, and reporting its absence would be
|
|
280
|
-
// inventing a requirement nobody asked for.
|
|
281
|
-
if (after.stored > before.stored) {
|
|
282
|
-
try {
|
|
283
|
-
await page.reload({ waitUntil: 'load', timeout: 20_000 });
|
|
284
|
-
await page.waitForTimeout(400);
|
|
285
|
-
const reloaded = await snapshot();
|
|
286
|
-
if (reloaded.text <= before.text) return { tried, worked: true, lost: true };
|
|
287
|
-
} catch { /* a reload that will not happen is not evidence of anything */ }
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
return { tried, worked: true };
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
/**
|
|
294
|
-
* Serve a folder over http just long enough to look at it.
|
|
295
|
-
*
|
|
296
|
-
* The default starter has no dev server — three files that open straight from
|
|
297
|
-
* disk — so there was nothing for the checker to point at, and the most
|
|
298
|
-
* thorough thing ucode runs could not run on the apps it makes most often. A
|
|
299
|
-
* static server on an ephemeral port costs nothing and closes again the
|
|
300
|
-
* moment the look is done.
|
|
301
|
-
*/
|
|
302
|
-
export async function withStaticServer(dir, fn) {
|
|
303
|
-
const http = await import('node:http');
|
|
304
|
-
const root = path.resolve(dir);
|
|
305
|
-
const types = {
|
|
306
|
-
'.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript',
|
|
307
|
-
'.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
|
|
308
|
-
};
|
|
309
|
-
|
|
310
|
-
const server = http.createServer((req, res) => {
|
|
311
|
-
const rel = (req.url === '/' ? '/index.html' : req.url).split('?')[0];
|
|
312
|
-
const file = path.join(root, decodeURIComponent(rel));
|
|
313
|
-
if (!file.startsWith(root)) { res.writeHead(403); res.end(); return; }
|
|
314
|
-
fsSync.readFile(file, (err, buf) => {
|
|
315
|
-
if (err) { res.writeHead(404); res.end('not found'); return; }
|
|
316
|
-
res.writeHead(200, { 'content-type': types[path.extname(file).toLowerCase()] ?? 'text/plain' });
|
|
317
|
-
res.end(buf);
|
|
318
|
-
});
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
322
|
-
try {
|
|
323
|
-
return await fn(`http://127.0.0.1:${server.address().port}`);
|
|
324
|
-
} finally {
|
|
325
|
-
server.close();
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
export async function lookAtApp({ url, paths = ['/'] }) {
|
|
330
|
-
const base = String(url ?? '').trim().replace(/\/+$/, '');
|
|
331
|
-
if (!LOCAL.test(`${base}/`)) {
|
|
332
|
-
throw new ToolFailure({
|
|
333
|
-
kind: 'bad_args',
|
|
334
|
-
attempted: 'looking at the app',
|
|
335
|
-
failed: `"${url}" is not a local address. This only opens apps running on this machine.`,
|
|
336
|
-
fix: 'Pass the URL the dev server reported, e.g. http://localhost:3000',
|
|
337
|
-
});
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
const pages = (Array.isArray(paths) && paths.length ? paths : ['/'])
|
|
341
|
-
.map((p) => `/${String(p).trim().replace(/^\/+/, '')}`)
|
|
342
|
-
.slice(0, 4);
|
|
343
|
-
|
|
344
|
-
const shotsDir = path.join(getRoot(), '.ucode', 'screenshots');
|
|
345
|
-
await fs.mkdir(shotsDir, { recursive: true });
|
|
346
|
-
|
|
347
|
-
const b = await browser();
|
|
348
|
-
|
|
349
|
-
// Every page at every width opens at once, each in its own context: the
|
|
350
|
-
// wait is for the slowest one, not the sum of them all.
|
|
351
|
-
const checks = await Promise.all(pages.flatMap((pagePath) => WIDTHS.map(async (size) => {
|
|
352
|
-
let
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
const
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
});
|
|
361
|
-
page.on('
|
|
362
|
-
page.on('
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
let
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
// '
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
await page.
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
1
|
+
/**
|
|
2
|
+
* browser.js — looking at the app the way a person would.
|
|
3
|
+
*
|
|
4
|
+
* A build that passes and a page that works are different claims. This opens
|
|
5
|
+
* the running app in a real browser at a phone width and a desktop width, and
|
|
6
|
+
* reports what a person would run into: errors in the console, requests that
|
|
7
|
+
* failed, a layout that spills off the side of a phone, broken images,
|
|
8
|
+
* controls with no name. It saves a screenshot of each, and has the one model
|
|
9
|
+
* in the set that can see — Nemotron Nano Omni — review them as a designer
|
|
10
|
+
* would. The model building the app then has something concrete to fix.
|
|
11
|
+
*
|
|
12
|
+
* It drives the browser already on the machine (Edge or Chrome) through
|
|
13
|
+
* playwright-core, so there is no separate 150 MB browser download.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { promises as fs } from 'node:fs';
|
|
17
|
+
import fsSync from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { ToolFailure } from '../core/failure.js';
|
|
20
|
+
import { ask } from '../core/provider.js';
|
|
21
|
+
import { getRoot, result } from './shared.js';
|
|
22
|
+
|
|
23
|
+
const VISION_MODEL = 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free';
|
|
24
|
+
const WIDTHS = [
|
|
25
|
+
{ name: 'phone', width: 375, height: 812 },
|
|
26
|
+
{ name: 'desktop', width: 1440, height: 900 },
|
|
27
|
+
];
|
|
28
|
+
const LOCAL = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i;
|
|
29
|
+
const MAX_SHOT_HEIGHT = 3000;
|
|
30
|
+
|
|
31
|
+
let browserPromise = null;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One browser for the whole session, started on first use. The installed
|
|
35
|
+
* Edge or Chrome is tried first; Playwright's own Chromium only if it happens
|
|
36
|
+
* to be installed.
|
|
37
|
+
*/
|
|
38
|
+
async function browser() {
|
|
39
|
+
if (browserPromise) return browserPromise;
|
|
40
|
+
browserPromise = (async () => {
|
|
41
|
+
let chromium;
|
|
42
|
+
try {
|
|
43
|
+
({ chromium } = await import('playwright-core'));
|
|
44
|
+
} catch (err) {
|
|
45
|
+
throw new ToolFailure({
|
|
46
|
+
kind: 'no_playwright',
|
|
47
|
+
attempted: 'starting a browser',
|
|
48
|
+
failed: `playwright-core could not be loaded: ${err.message}`,
|
|
49
|
+
fix: 'Reinstall ucode (npm install -g ucode-agent). Carry on without looking at the app, and say so.',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const tried = [];
|
|
53
|
+
for (const channel of ['msedge', 'chrome', undefined]) {
|
|
54
|
+
try {
|
|
55
|
+
return await chromium.launch({ channel, headless: true });
|
|
56
|
+
} catch (err) {
|
|
57
|
+
tried.push(`${channel ?? 'bundled chromium'}: ${String(err.message).split('\n')[0]}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
throw new ToolFailure({
|
|
61
|
+
kind: 'no_browser',
|
|
62
|
+
attempted: 'starting a browser',
|
|
63
|
+
failed: `No browser could be started. Tried ${tried.join('; ')}.`,
|
|
64
|
+
fix: 'Install Google Chrome or Microsoft Edge. Carry on without looking at the app, and say so.',
|
|
65
|
+
});
|
|
66
|
+
})();
|
|
67
|
+
browserPromise.catch(() => { browserPromise = null; });
|
|
68
|
+
return browserPromise;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Close the shared browser, if one was started. Called when ucode exits. */
|
|
72
|
+
export async function closeBrowser() {
|
|
73
|
+
if (!browserPromise) return;
|
|
74
|
+
try { await (await browserPromise).close(); } catch { /* already gone */ }
|
|
75
|
+
browserPromise = null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Layout and accessibility checks run inside the page. */
|
|
79
|
+
function inspect() {
|
|
80
|
+
const vw = window.innerWidth;
|
|
81
|
+
const describeEl = (el) => {
|
|
82
|
+
const id = el.id ? `#${el.id}` : '';
|
|
83
|
+
const cls = typeof el.className === 'string' && el.className.trim()
|
|
84
|
+
? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` : '';
|
|
85
|
+
const text = (el.innerText || el.getAttribute('aria-label') || '').trim().replace(/\s+/g, ' ').slice(0, 40);
|
|
86
|
+
return `<${el.tagName.toLowerCase()}${id}${cls}>${text ? ` "${text}"` : ''}`;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const overflow = document.documentElement.scrollWidth - vw;
|
|
90
|
+
const wide = [];
|
|
91
|
+
if (overflow > 1) {
|
|
92
|
+
for (const el of document.querySelectorAll('body *')) {
|
|
93
|
+
const r = el.getBoundingClientRect();
|
|
94
|
+
if (r.width > 0 && r.right > vw + 1 && getComputedStyle(el).position !== 'fixed') {
|
|
95
|
+
wide.push(`${describeEl(el)} reaches ${Math.round(r.right)}px`);
|
|
96
|
+
if (wide.length >= 5) break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const broken = [...document.images].filter((i) => i.complete && i.naturalWidth === 0).map((i) => i.src.slice(0, 80));
|
|
102
|
+
const noAlt = [...document.images].filter((i) => !i.hasAttribute('alt')).length;
|
|
103
|
+
const unnamed = [...document.querySelectorAll('button, a[href], [role="button"]')]
|
|
104
|
+
.filter((el) => !(el.innerText || '').trim() && !el.getAttribute('aria-label') && !el.getAttribute('title')
|
|
105
|
+
&& !el.querySelector('[aria-label], title, img[alt]:not([alt=""])'))
|
|
106
|
+
.slice(0, 5).map(describeEl);
|
|
107
|
+
const inputsNoLabel = [...document.querySelectorAll('input:not([type="hidden"]), textarea, select')]
|
|
108
|
+
.filter((el) => !(el.id && document.querySelector(`label[for="${el.id}"]`)) && !el.closest('label')
|
|
109
|
+
&& !el.getAttribute('aria-label') && !el.getAttribute('aria-labelledby'))
|
|
110
|
+
.length;
|
|
111
|
+
const tiny = vw < 600
|
|
112
|
+
? [...document.querySelectorAll('button, a[href], [role="button"], input, select')]
|
|
113
|
+
.filter((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && (r.height < 32 || r.width < 32); })
|
|
114
|
+
.length
|
|
115
|
+
: 0;
|
|
116
|
+
const smallText = [...document.querySelectorAll('p, li, span, a, button, label, td')]
|
|
117
|
+
.filter((el) => el.childElementCount === 0 && (el.innerText || '').trim() && parseFloat(getComputedStyle(el).fontSize) < 12)
|
|
118
|
+
.length;
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
title: document.title,
|
|
122
|
+
overflow: overflow > 1 ? Math.round(overflow) : 0,
|
|
123
|
+
wide, broken, noAlt, unnamed, inputsNoLabel, tiny, smallText,
|
|
124
|
+
empty: !(document.body.innerText || '').trim(),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const safeName = (p) => (p === '/' ? 'home' : p.replace(/^\/+|\/+$/g, '').replace(/[^\w-]+/g, '_')) || 'page';
|
|
129
|
+
|
|
130
|
+
async function review(shots) {
|
|
131
|
+
const request = [
|
|
132
|
+
{
|
|
133
|
+
role: 'system',
|
|
134
|
+
content:
|
|
135
|
+
'You are a senior product designer reviewing screenshots of a web app, one at a phone width ' +
|
|
136
|
+
'and one at desktop width. Each screenshot is the whole page, top to bottom, so anything not ' +
|
|
137
|
+
'in it is genuinely not there. List the concrete visual problems a user would notice, most ' +
|
|
138
|
+
'important first: broken or cramped layout, overflow, misalignment, weak hierarchy (is the ' +
|
|
139
|
+
'most important thing the most prominent?), inconsistent spacing, low contrast, default-looking ' +
|
|
140
|
+
'components, awkward empty states, text that is too small. For each: where it is, what is wrong, ' +
|
|
141
|
+
'and the specific fix. At most 8 points, one or two lines each. If it genuinely looks polished, ' +
|
|
142
|
+
'say so in one line and name the one thing that would improve it most. No preamble.',
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
role: 'user',
|
|
146
|
+
content: shots.map((s) => `${s.label}`).join(' and ') + '.',
|
|
147
|
+
images: shots.map((s) => s.dataUrl),
|
|
148
|
+
},
|
|
149
|
+
];
|
|
150
|
+
const reply = await ask(request, [], {
|
|
151
|
+
model: VISION_MODEL,
|
|
152
|
+
temperature: 0.2,
|
|
153
|
+
// A reasoning model spends its budget thinking before it writes; 900
|
|
154
|
+
// tokens came back as an empty review. Keep the thinking short, and leave
|
|
155
|
+
// room for the answer.
|
|
156
|
+
maxOutputTokens: 4000,
|
|
157
|
+
reasoning: { effort: 'low' },
|
|
158
|
+
// The free vision model is often busy. One try, and a hard cap: a review
|
|
159
|
+
// that cannot run is skipped, never waited on.
|
|
160
|
+
attempts: 1,
|
|
161
|
+
signal: AbortSignal.timeout(REVIEW_BUDGET_MS),
|
|
162
|
+
});
|
|
163
|
+
const text = reply.text.trim();
|
|
164
|
+
if (!text) throw new Error('the vision model returned an empty review');
|
|
165
|
+
return text;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const REVIEW_BUDGET_MS = 60_000;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The designer's review is the slow part — a reasoning model looking at
|
|
172
|
+
* screenshots, most of a minute — so each app gets one per turn: a look after
|
|
173
|
+
* the fixes only re-runs the fast checks. A review that failed (busy model,
|
|
174
|
+
* empty reply) gets one more try on the next look, then is let go.
|
|
175
|
+
*/
|
|
176
|
+
const reviews = new Map(); // base URL -> { done, tries }
|
|
177
|
+
|
|
178
|
+
/** A new request from the user: the apps may be reviewed afresh. */
|
|
179
|
+
export function forgetReviews() {
|
|
180
|
+
reviews.clear();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Text typed into the app while checking it. Distinctive enough to recognise
|
|
186
|
+
* in a screenshot, and obviously not something a user wrote.
|
|
187
|
+
*/
|
|
188
|
+
const PROBE_TEXT = 'ucode check';
|
|
189
|
+
|
|
190
|
+
/** Did anything at all happen on the page? */
|
|
191
|
+
const moved = (a, b) => a.nodes !== b.nodes || a.text !== b.text || a.stored !== b.stored;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Use the app, rather than only looking at it.
|
|
195
|
+
*
|
|
196
|
+
* Everything else here is an inspection: overflow, labels, broken images,
|
|
197
|
+
* console errors on load. All of it passes on an app whose Add button does
|
|
198
|
+
* nothing, because a page with dead JavaScript still renders, still has good
|
|
199
|
+
* contrast and still has no console errors — it simply does not work. Nothing
|
|
200
|
+
* in the harness ever pressed anything, so the model was never told, and never
|
|
201
|
+
* fixed it.
|
|
202
|
+
*
|
|
203
|
+
* So: type into the first text field, press Enter, and if that changed nothing,
|
|
204
|
+
* click the first button. Then look at whether the page has more nodes, more
|
|
205
|
+
* text, or more in localStorage than it did. Any of those moving means the core
|
|
206
|
+
* loop is wired up. None of them moving, on a page that has controls to press,
|
|
207
|
+
* means it is not.
|
|
208
|
+
*
|
|
209
|
+
* Real keyboard and mouse input through the driver, never synthetic DOM events:
|
|
210
|
+
* an implicit form submit does not fire for a dispatched event, which would
|
|
211
|
+
* report a working form as dead.
|
|
212
|
+
*
|
|
213
|
+
* A page with nothing to press — a landing page, a chart, a page of prose — is
|
|
214
|
+
* not exercised and not judged. Returning null there is the difference between
|
|
215
|
+
* a check and a false accusation.
|
|
216
|
+
*/
|
|
217
|
+
async function useTheApp(page) {
|
|
218
|
+
const snapshot = () => page.evaluate(() => ({
|
|
219
|
+
nodes: document.body.querySelectorAll('*').length,
|
|
220
|
+
text: document.body.innerText.replace(/\s+/g, ' ').trim().length,
|
|
221
|
+
stored: (() => { try { return JSON.stringify(localStorage).length; } catch { return 0; } })(),
|
|
222
|
+
}));
|
|
223
|
+
|
|
224
|
+
const before = await snapshot().catch(() => null);
|
|
225
|
+
if (!before) return null;
|
|
226
|
+
const tried = [];
|
|
227
|
+
|
|
228
|
+
// A field and the Enter key: the core loop of most one-page apps.
|
|
229
|
+
const field = page.locator('input[type="text"], input[type="search"], input:not([type]), textarea').first();
|
|
230
|
+
if (await field.count().catch(() => 0)) {
|
|
231
|
+
const ok = await field.fill(PROBE_TEXT, { timeout: 2_000 }).then(() => true).catch(() => false);
|
|
232
|
+
if (ok) {
|
|
233
|
+
await field.press('Enter', { timeout: 2_000 }).catch(() => {});
|
|
234
|
+
await page.waitForTimeout(300);
|
|
235
|
+
tried.push('typed into the first field and pressed Enter');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let after = await snapshot().catch(() => before);
|
|
240
|
+
if (tried.length && moved(before, after)) return { tried, worked: true };
|
|
241
|
+
|
|
242
|
+
// Nothing moved, so try the other half of the same pattern — the button that
|
|
243
|
+
// submits, before any other. Taking simply the first button in the document
|
|
244
|
+
// finds the theme toggle in the header, clicks it, and reports a working app
|
|
245
|
+
// as dead because switching to dark mode adds no elements.
|
|
246
|
+
const candidates = [
|
|
247
|
+
page.locator('form button[type="submit"], form input[type="submit"], button[type="submit"], input[type="submit"]'),
|
|
248
|
+
page.locator('form button:not([disabled])'),
|
|
249
|
+
page.locator('button:not([disabled]), [role="button"]'),
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
const pressed = new Set();
|
|
253
|
+
for (const group of candidates) {
|
|
254
|
+
const count = Math.min(await group.count().catch(() => 0), 3);
|
|
255
|
+
for (let i = 0; i < count; i++) {
|
|
256
|
+
const button = group.nth(i);
|
|
257
|
+
const label = (await button.innerText().catch(() => '') || '').trim().replace(/\s+/g, ' ').slice(0, 24);
|
|
258
|
+
if (pressed.has(label || `#${i}`)) continue;
|
|
259
|
+
pressed.add(label || `#${i}`);
|
|
260
|
+
|
|
261
|
+
const ok = await button.click({ timeout: 2_000 }).then(() => true).catch(() => false);
|
|
262
|
+
if (!ok) continue;
|
|
263
|
+
await page.waitForTimeout(300);
|
|
264
|
+
tried.push(`clicked ${label ? `"${label}"` : 'a button'}`);
|
|
265
|
+
|
|
266
|
+
after = await snapshot().catch(() => after);
|
|
267
|
+
if (moved(before, after)) return { tried, worked: true };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!tried.length) return null;
|
|
272
|
+
if (!moved(before, after)) return { tried, worked: false };
|
|
273
|
+
|
|
274
|
+
// It worked. Did any of it last?
|
|
275
|
+
//
|
|
276
|
+
// An app that writes to localStorage and never reads it back looks perfect
|
|
277
|
+
// for as long as you stay on the page, and loses everything the moment
|
|
278
|
+
// anyone refreshes. Only asked when the app actually stored something —
|
|
279
|
+
// otherwise no persistence was intended, and reporting its absence would be
|
|
280
|
+
// inventing a requirement nobody asked for.
|
|
281
|
+
if (after.stored > before.stored) {
|
|
282
|
+
try {
|
|
283
|
+
await page.reload({ waitUntil: 'load', timeout: 20_000 });
|
|
284
|
+
await page.waitForTimeout(400);
|
|
285
|
+
const reloaded = await snapshot();
|
|
286
|
+
if (reloaded.text <= before.text) return { tried, worked: true, lost: true };
|
|
287
|
+
} catch { /* a reload that will not happen is not evidence of anything */ }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return { tried, worked: true };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Serve a folder over http just long enough to look at it.
|
|
295
|
+
*
|
|
296
|
+
* The default starter has no dev server — three files that open straight from
|
|
297
|
+
* disk — so there was nothing for the checker to point at, and the most
|
|
298
|
+
* thorough thing ucode runs could not run on the apps it makes most often. A
|
|
299
|
+
* static server on an ephemeral port costs nothing and closes again the
|
|
300
|
+
* moment the look is done.
|
|
301
|
+
*/
|
|
302
|
+
export async function withStaticServer(dir, fn) {
|
|
303
|
+
const http = await import('node:http');
|
|
304
|
+
const root = path.resolve(dir);
|
|
305
|
+
const types = {
|
|
306
|
+
'.html': 'text/html', '.css': 'text/css', '.js': 'text/javascript',
|
|
307
|
+
'.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const server = http.createServer((req, res) => {
|
|
311
|
+
const rel = (req.url === '/' ? '/index.html' : req.url).split('?')[0];
|
|
312
|
+
const file = path.join(root, decodeURIComponent(rel));
|
|
313
|
+
if (!file.startsWith(root)) { res.writeHead(403); res.end(); return; }
|
|
314
|
+
fsSync.readFile(file, (err, buf) => {
|
|
315
|
+
if (err) { res.writeHead(404); res.end('not found'); return; }
|
|
316
|
+
res.writeHead(200, { 'content-type': types[path.extname(file).toLowerCase()] ?? 'text/plain' });
|
|
317
|
+
res.end(buf);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
322
|
+
try {
|
|
323
|
+
return await fn(`http://127.0.0.1:${server.address().port}`);
|
|
324
|
+
} finally {
|
|
325
|
+
server.close();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export async function lookAtApp({ url, paths = ['/'] }) {
|
|
330
|
+
const base = String(url ?? '').trim().replace(/\/+$/, '');
|
|
331
|
+
if (!LOCAL.test(`${base}/`)) {
|
|
332
|
+
throw new ToolFailure({
|
|
333
|
+
kind: 'bad_args',
|
|
334
|
+
attempted: 'looking at the app',
|
|
335
|
+
failed: `"${url}" is not a local address. This only opens apps running on this machine.`,
|
|
336
|
+
fix: 'Pass the URL the dev server reported, e.g. http://localhost:3000',
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const pages = (Array.isArray(paths) && paths.length ? paths : ['/'])
|
|
341
|
+
.map((p) => `/${String(p).trim().replace(/^\/+/, '')}`)
|
|
342
|
+
.slice(0, 4);
|
|
343
|
+
|
|
344
|
+
const shotsDir = path.join(getRoot(), '.ucode', 'screenshots');
|
|
345
|
+
await fs.mkdir(shotsDir, { recursive: true });
|
|
346
|
+
|
|
347
|
+
const b = await browser();
|
|
348
|
+
|
|
349
|
+
// Every page at every width opens at once, each in its own context: the
|
|
350
|
+
// wait is for the slowest one, not the sum of them all.
|
|
351
|
+
const checks = await Promise.all(pages.flatMap((pagePath) => WIDTHS.map(async (size) => {
|
|
352
|
+
let shot = null;
|
|
353
|
+
const context = await b.newContext({ viewport: { width: size.width, height: size.height }, deviceScaleFactor: 1 });
|
|
354
|
+
const page = await context.newPage();
|
|
355
|
+
const errors = [];
|
|
356
|
+
const failed = [];
|
|
357
|
+
page.on('console', (m) => {
|
|
358
|
+
if (m.type() === 'error' && !/devtools|download the react/i.test(m.text())) errors.push(m.text().slice(0, 200));
|
|
359
|
+
});
|
|
360
|
+
page.on('pageerror', (e) => errors.push(`uncaught: ${String(e.message).slice(0, 200)}`));
|
|
361
|
+
page.on('requestfailed', (r) => failed.push(`${r.method()} ${r.url().slice(0, 100)} — ${r.failure()?.errorText ?? 'failed'}`));
|
|
362
|
+
page.on('response', (r) => { if (r.status() >= 400) failed.push(`${r.status()} ${r.url().slice(0, 100)}`); });
|
|
363
|
+
|
|
364
|
+
const target = `${base}${pagePath}`;
|
|
365
|
+
let loadError = null;
|
|
366
|
+
let used = null;
|
|
367
|
+
try {
|
|
368
|
+
// 'load', not 'networkidle': a dev server holds a hot-reload
|
|
369
|
+
// connection open and polls, so the network may never go quiet and
|
|
370
|
+
// 'networkidle' would wait out its whole timeout on every page, at
|
|
371
|
+
// every width. 'load' covers the first-request compile; the page's own
|
|
372
|
+
// data then gets a short, bounded chance to settle.
|
|
373
|
+
await page.goto(target, { waitUntil: 'load', timeout: 90_000 });
|
|
374
|
+
await page.waitForLoadState('networkidle', { timeout: 1_500 }).catch(() => {});
|
|
375
|
+
} catch (err) {
|
|
376
|
+
loadError = String(err.message).split('\n')[0];
|
|
377
|
+
}
|
|
378
|
+
await page.waitForTimeout(400); // let entrance animations settle
|
|
379
|
+
|
|
380
|
+
const file = path.join(shotsDir, `${safeName(pagePath)}-${size.name}.jpg`);
|
|
381
|
+
let facts = null;
|
|
382
|
+
try {
|
|
383
|
+
if (!loadError) {
|
|
384
|
+
facts = await page.evaluate(inspect).catch((err) => ({ error: err.message }));
|
|
385
|
+
// The whole page, so the reviewer never reports as missing what is
|
|
386
|
+
// only below the fold — capped, so an endless feed stays one image.
|
|
387
|
+
const tall = await page.evaluate(() => document.documentElement.scrollHeight).catch(() => 0);
|
|
388
|
+
const buffer = await page.screenshot({
|
|
389
|
+
type: 'jpeg',
|
|
390
|
+
quality: 70,
|
|
391
|
+
fullPage: true,
|
|
392
|
+
...(tall > MAX_SHOT_HEIGHT ? { clip: { x: 0, y: 0, width: size.width, height: MAX_SHOT_HEIGHT } } : {}),
|
|
393
|
+
});
|
|
394
|
+
await fs.writeFile(file, buffer);
|
|
395
|
+
shot = { label: `${pagePath} at ${size.width}px (${size.name})`, dataUrl: `data:image/jpeg;base64,${buffer.toString('base64')}` };
|
|
396
|
+
|
|
397
|
+
// Once per look, not once per width: pressing the same button four
|
|
398
|
+
// times says nothing the first press did not, and costs four seconds.
|
|
399
|
+
if (size.name === 'desktop' && pagePath === pages[0]) {
|
|
400
|
+
used = await useTheApp(page).catch(() => null);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
} catch (err) {
|
|
404
|
+
loadError = `the page broke while being checked: ${String(err.message).split('\n')[0]}`;
|
|
405
|
+
} finally {
|
|
406
|
+
await context.close().catch(() => {});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const rel = loadError ? null : path.relative(getRoot(), file).split(path.sep).join('/');
|
|
410
|
+
return { pagePath, size, facts, errors, failed, loadError, shot, used, rel };
|
|
411
|
+
})));
|
|
412
|
+
|
|
413
|
+
// One section per page, not one per page and width.
|
|
414
|
+
//
|
|
415
|
+
// Almost everything these checks find is a fact about the page and comes
|
|
416
|
+
// back identical at every width: an image that is broken at 375px is broken
|
|
417
|
+
// at 1440px, a console error fires in both, an unlabelled field is
|
|
418
|
+
// unlabelled twice. Printed per width, every one of those lines appeared
|
|
419
|
+
// twice over — and counted twice, so one broken image read as two problems
|
|
420
|
+
// and a clean page still produced two near-identical paragraphs to read.
|
|
421
|
+
//
|
|
422
|
+
// What genuinely changes with the viewport is the layout: overflow, tap
|
|
423
|
+
// targets, type size. Only those are still named by width.
|
|
424
|
+
const uniq = (xs) => [...new Set(xs)];
|
|
425
|
+
const sections = [];
|
|
426
|
+
const toReview = [];
|
|
427
|
+
let problems = 0;
|
|
428
|
+
let broken = false;
|
|
429
|
+
|
|
430
|
+
for (const pagePath of pages) {
|
|
431
|
+
const shots = checks.filter((c) => c.pagePath === pagePath);
|
|
432
|
+
const lines = [`### ${pagePath}`];
|
|
433
|
+
const loaded = shots.filter((s) => !s.loadError);
|
|
434
|
+
|
|
435
|
+
if (!loaded.length) {
|
|
436
|
+
lines.push(`Could not load: ${shots[0]?.loadError ?? 'no response'}`);
|
|
437
|
+
problems++;
|
|
438
|
+
broken = true;
|
|
439
|
+
sections.push(lines.join('\n'));
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// The desktop render is where the page-wide facts are read from, and the
|
|
444
|
+
// only one the core loop was exercised on.
|
|
445
|
+
const main = loaded.find((s) => s.size.name === 'desktop') ?? loaded[0];
|
|
446
|
+
const facts = main.facts ?? {};
|
|
447
|
+
const errors = uniq(loaded.flatMap((s) => s.errors));
|
|
448
|
+
const failed = uniq(loaded.flatMap((s) => s.failed));
|
|
449
|
+
const used = loaded.find((s) => s.used)?.used ?? null;
|
|
450
|
+
|
|
451
|
+
lines.push(`Screenshots: ${loaded.map((s) => `${s.rel} (${s.size.name})`).join(', ')}`);
|
|
452
|
+
for (const s of loaded) if (s.shot) toReview.push(s.shot);
|
|
453
|
+
|
|
454
|
+
if (facts.empty) { lines.push('- The page rendered no visible text at all.'); problems++; }
|
|
455
|
+
if (facts.broken?.length) { lines.push(`- Broken images: ${facts.broken.join(', ')}`); problems++; }
|
|
456
|
+
if (facts.unnamed?.length) { lines.push(`- Buttons or links with no accessible name: ${facts.unnamed.join(', ')}`); problems++; }
|
|
457
|
+
if (facts.inputsNoLabel) { lines.push(`- ${facts.inputsNoLabel} form field(s) without a label.`); problems++; }
|
|
458
|
+
if (facts.noAlt) lines.push(`- ${facts.noAlt} image(s) without alt text.`);
|
|
459
|
+
|
|
460
|
+
// A width that failed on its own — the phone render timed out, the desktop
|
|
461
|
+
// one came back — is still a failure, and grouping by page must not let it
|
|
462
|
+
// disappear behind the width that worked.
|
|
463
|
+
for (const s of shots.filter((c) => c.loadError)) {
|
|
464
|
+
lines.push(`- At ${s.size.width}px (${s.size.name}) it could not load: ${s.loadError}`);
|
|
465
|
+
problems++;
|
|
466
|
+
broken = true;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
for (const s of loaded) {
|
|
470
|
+
const f = s.facts ?? {};
|
|
471
|
+
const at = `At ${s.size.width}px (${s.size.name})`;
|
|
472
|
+
if (f.overflow) {
|
|
473
|
+
lines.push(`- ${at}: content is ${f.overflow}px wider than the screen, so it scrolls sideways:`,
|
|
474
|
+
...f.wide.map((w) => ` - ${w}`));
|
|
475
|
+
problems++;
|
|
476
|
+
}
|
|
477
|
+
if (f.tiny) lines.push(`- ${at}: ${f.tiny} tap target(s) smaller than 32px.`);
|
|
478
|
+
if (f.smallText) lines.push(`- ${at}: ${f.smallText} text element(s) under 12px.`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (used && !used.worked) {
|
|
482
|
+
lines.push(
|
|
483
|
+
`- NOTHING HAPPENS WHEN YOU USE IT. I ${used.tried.join(', then ')} — and the page`,
|
|
484
|
+
' gained no elements, changed no text and stored nothing. The markup and the styling',
|
|
485
|
+
' are there; the behaviour is not wired to them. Find the listener that was never',
|
|
486
|
+
' attached, or the handler that throws before it does anything, and fix that first:',
|
|
487
|
+
' everything else on this page is decoration until it works.',
|
|
488
|
+
);
|
|
489
|
+
problems++;
|
|
490
|
+
} else if (used?.lost) {
|
|
491
|
+
lines.push(
|
|
492
|
+
`- It works until you refresh. I ${used.tried.join(', then ')}, the page`,
|
|
493
|
+
' responded, and it wrote to localStorage — but after a reload it was back to',
|
|
494
|
+
' empty. Something is being saved and never read back at start-up. Load the',
|
|
495
|
+
' stored state when the page boots, and check it survives a refresh.',
|
|
496
|
+
);
|
|
497
|
+
problems++;
|
|
498
|
+
} else if (used) {
|
|
499
|
+
lines.push(`- Core loop works: I ${used.tried.join(', then ')}, the page responded, and it survived a reload.`);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (errors.length) { lines.push('- Console errors:', ...errors.slice(0, 6).map((e) => ` - ${e}`)); problems++; }
|
|
503
|
+
if (failed.length) { lines.push('- Failed requests:', ...failed.slice(0, 6).map((f) => ` - ${f}`)); problems++; }
|
|
504
|
+
if (lines.length === 2) lines.push('- No errors, no overflow, nothing unlabeled.');
|
|
505
|
+
|
|
506
|
+
// A dead core loop counts as broken: a screenshot of an app that does not
|
|
507
|
+
// work is not worth a paragraph on its typography.
|
|
508
|
+
if (errors.length || facts.empty || (used && !used.worked)) broken = true;
|
|
509
|
+
sections.push(lines.join('\n'));
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// A page that crashed or threw is fixed first; reviewing a screenshot of an
|
|
513
|
+
// error overlay is a minute spent on nothing.
|
|
514
|
+
let critique = '';
|
|
515
|
+
const state = reviews.get(base) ?? { done: false, tries: 0 };
|
|
516
|
+
if (!broken && !state.done && state.tries < 2 && toReview.length) {
|
|
517
|
+
state.tries++;
|
|
518
|
+
reviews.set(base, state);
|
|
519
|
+
try {
|
|
520
|
+
critique = await review(toReview.slice(0, 4));
|
|
521
|
+
state.done = true;
|
|
522
|
+
} catch (err) {
|
|
523
|
+
const why = String(err.failed ?? err.message).replace(/[.\s]+$/, '');
|
|
524
|
+
critique = `(The visual review could not run: ${why}. The checks above still apply.)`;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const body = [
|
|
529
|
+
...sections,
|
|
530
|
+
critique ? `## Visual review\n${critique}` : '',
|
|
531
|
+
'',
|
|
532
|
+
problems
|
|
533
|
+
? 'Fix the problems above, then look again to confirm.'
|
|
534
|
+
: state.done && critique
|
|
535
|
+
? 'The automatic checks found nothing. Weigh the visual review, fix what is worth fixing - ' +
|
|
536
|
+
'the next look re-runs only the fast checks.'
|
|
537
|
+
: 'The automatic checks found nothing.',
|
|
538
|
+
].filter(Boolean).join('\n\n');
|
|
539
|
+
|
|
540
|
+
return result(
|
|
541
|
+
body,
|
|
542
|
+
problems
|
|
543
|
+
? `${problems} problem${problems === 1 ? '' : 's'} found · screenshots in .ucode/screenshots`
|
|
544
|
+
: 'no errors · screenshots in .ucode/screenshots'
|
|
545
|
+
);
|
|
546
|
+
}
|