ucode-agent 1.2.0 → 1.4.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/README.md +73 -17
- package/package.json +3 -2
- package/skills/build-app/SKILL.md +4 -2
- package/skills/ui-ux/SKILL.md +5 -1
- package/src/core/context.js +151 -0
- package/src/core/loop.js +550 -40
- package/src/core/provider.js +58 -15
- package/src/core/updater.js +95 -0
- package/src/tools/browser.js +258 -0
- package/src/tools/files.js +173 -16
- package/src/tools/index.js +472 -394
- package/src/tools/shell.js +149 -1
- package/src/ui/plain.js +330 -325
- package/src/ui/screen.js +27 -7
- package/src/ui/theme.js +18 -0
package/src/core/provider.js
CHANGED
|
@@ -58,7 +58,7 @@ export const MODELS = {
|
|
|
58
58
|
name: 'Nemotron 3 Ultra',
|
|
59
59
|
context: 1_000_000,
|
|
60
60
|
star: true,
|
|
61
|
-
note: 'deepest reasoning, 1M context —
|
|
61
|
+
note: 'deepest reasoning, 1M context — slowest to answer',
|
|
62
62
|
},
|
|
63
63
|
'nvidia/nemotron-3.5-lightning:free': {
|
|
64
64
|
name: 'Nemotron 3.5 Lightning',
|
|
@@ -79,18 +79,43 @@ export const MODELS = {
|
|
|
79
79
|
name: 'North Mini Code',
|
|
80
80
|
context: 256_000,
|
|
81
81
|
star: true,
|
|
82
|
-
note: '
|
|
82
|
+
note: 'the default — built for code and interface work, quick to answer',
|
|
83
83
|
},
|
|
84
84
|
};
|
|
85
85
|
|
|
86
86
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* to Lightning or North Mini Code when the wait stops being worth it.
|
|
87
|
+
* North Mini Code is the default: it is built for code and interface work,
|
|
88
|
+
* which is what ucode is mostly asked to do, and it answers far sooner than
|
|
89
|
+
* the big reasoning models. /model moves to Ultra when a problem needs the
|
|
90
|
+
* million-token window and the long think more than it needs the speed.
|
|
92
91
|
*/
|
|
93
|
-
export const DEFAULT_MODEL = '
|
|
92
|
+
export const DEFAULT_MODEL = 'cohere/north-mini-code:free';
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Where to go when a model is busy, in order of preference. Each is served by
|
|
96
|
+
* a different upstream, so a rate limit on one rarely means a limit on the
|
|
97
|
+
* next — which is what lets a long build keep going instead of stopping at
|
|
98
|
+
* the first "too many requests".
|
|
99
|
+
*/
|
|
100
|
+
export const FALLBACKS = [
|
|
101
|
+
'cohere/north-mini-code:free',
|
|
102
|
+
'nvidia/nemotron-3.5-lightning:free',
|
|
103
|
+
'nvidia/nemotron-3-super-120b-a12b:free',
|
|
104
|
+
'nvidia/nemotron-3-ultra-550b-a55b:free',
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
/** The next model to try after `id`, skipping any already tried this round. */
|
|
108
|
+
export function fallbackFor(id, tried = new Set()) {
|
|
109
|
+
const start = Math.max(0, FALLBACKS.indexOf(id));
|
|
110
|
+
for (let i = 1; i <= FALLBACKS.length; i++) {
|
|
111
|
+
const next = FALLBACKS[(start + i) % FALLBACKS.length];
|
|
112
|
+
if (next !== id && !tried.has(next)) return next;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Seconds to wait on successive rate limits that come with no retry-after. */
|
|
118
|
+
const RATE_LIMIT_BACKOFF = [5, 10, 20];
|
|
94
119
|
|
|
95
120
|
let current = process.env.UCODE_MODEL || DEFAULT_MODEL;
|
|
96
121
|
let client = null;
|
|
@@ -578,12 +603,15 @@ export async function ask(messages, tools = [], opts = {}) {
|
|
|
578
603
|
problem = explain(err, id);
|
|
579
604
|
|
|
580
605
|
// A per-minute limit is a wait, not a failure. Sit it out rather than
|
|
581
|
-
// making the user retype their message.
|
|
582
|
-
|
|
606
|
+
// making the user retype their message. Free endpoints often refuse
|
|
607
|
+
// without saying how long to wait, so when there is no retry-after the
|
|
608
|
+
// pauses grow on their own — 5s, 10s, 20s — and only then does the
|
|
609
|
+
// error go up to the loop, which moves to another model.
|
|
610
|
+
const told = problem.detail?.retryAfter;
|
|
611
|
+
const wait = Number.isFinite(told) && told > 0 && told <= 90 ? told : RATE_LIMIT_BACKOFF[attempt - 1];
|
|
583
612
|
if (
|
|
584
613
|
problem.kind === 'rate_limit' && !problem.detail?.daily &&
|
|
585
|
-
|
|
586
|
-
attempt < attempts && !opts.signal?.aborted
|
|
614
|
+
wait && attempt < attempts && printed === 0 && !opts.signal?.aborted
|
|
587
615
|
) {
|
|
588
616
|
const until = Date.now() + wait * 1000;
|
|
589
617
|
while (Date.now() < until && !opts.signal?.aborted) {
|
|
@@ -630,6 +658,8 @@ async function streamed(request, opts, id) {
|
|
|
630
658
|
let finishReason = 'stop';
|
|
631
659
|
let usage = null;
|
|
632
660
|
const partial = new Map();
|
|
661
|
+
const handed = new Set();
|
|
662
|
+
let highest = -1;
|
|
633
663
|
|
|
634
664
|
for await (const chunk of stream) {
|
|
635
665
|
if (opts.signal?.aborted) break;
|
|
@@ -656,6 +686,20 @@ async function streamed(request, opts, id) {
|
|
|
656
686
|
// A tool call's name and arguments arrive across several chunks, keyed by
|
|
657
687
|
// index, so they are stitched back together here.
|
|
658
688
|
for (const call of delta.tool_calls ?? []) {
|
|
689
|
+
// Calls arrive one after another, so the first chunk of call N means
|
|
690
|
+
// every call before it is complete. Those are handed over at once, and
|
|
691
|
+
// the caller can start running them while the rest are still being
|
|
692
|
+
// written — the reply streaming and the tools working overlap.
|
|
693
|
+
if (opts.onToolCall && call.index > highest) {
|
|
694
|
+
for (const [index, slot] of partial) {
|
|
695
|
+
if (index < call.index && !handed.has(index)) {
|
|
696
|
+
handed.add(index);
|
|
697
|
+
opts.onToolCall(readCall({ id: slot.id || `call_${index}`, name: slot.name, raw: slot.args }));
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
highest = call.index;
|
|
701
|
+
}
|
|
702
|
+
|
|
659
703
|
const slot = partial.get(call.index) ?? { id: '', name: '', args: '' };
|
|
660
704
|
if (call.id) slot.id = call.id;
|
|
661
705
|
if (call.function?.name) slot.name += call.function.name;
|
|
@@ -665,9 +709,8 @@ async function streamed(request, opts, id) {
|
|
|
665
709
|
}
|
|
666
710
|
|
|
667
711
|
const toolCalls = [];
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
toolCalls.push(readCall({ id: slot.id || `call_${n++}`, name: slot.name, raw: slot.args }));
|
|
712
|
+
for (const [index, slot] of partial) {
|
|
713
|
+
toolCalls.push(readCall({ id: slot.id || `call_${index}`, name: slot.name, raw: slot.args }));
|
|
671
714
|
}
|
|
672
715
|
|
|
673
716
|
return {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* updater.js — staying current without anyone running npm by hand.
|
|
3
|
+
*
|
|
4
|
+
* On every launch ucode asks the registry, in the background, whether a newer
|
|
5
|
+
* version exists. If one does, it installs it globally, detached, while you
|
|
6
|
+
* work. The version you are running carries on untouched; the next launch is
|
|
7
|
+
* the new one. Nothing about starting ucode waits on any of this.
|
|
8
|
+
*
|
|
9
|
+
* It stays out of the way in three cases: a development checkout (updating
|
|
10
|
+
* would overwrite the `npm link` that points at your working copy), when
|
|
11
|
+
* UCODE_NO_UPDATE is set, and when another ucode is already updating.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { existsSync, promises as fs } from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { VERSION } from './version.js';
|
|
20
|
+
|
|
21
|
+
const PACKAGE = 'ucode-agent';
|
|
22
|
+
const PACKAGE_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
23
|
+
const HOME = path.join(os.homedir(), '.ucode');
|
|
24
|
+
const LOCK = path.join(HOME, 'update.lock');
|
|
25
|
+
const LOG = path.join(HOME, 'update.log');
|
|
26
|
+
const LOCK_TTL = 10 * 60_000;
|
|
27
|
+
|
|
28
|
+
/** "1.10.0" > "1.9.3" — numeric, part by part. */
|
|
29
|
+
export function newer(a, b) {
|
|
30
|
+
const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
|
|
31
|
+
const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
|
|
32
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
33
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isDevCheckout() {
|
|
39
|
+
return existsSync(path.join(PACKAGE_ROOT, '.git'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function latestVersion() {
|
|
43
|
+
const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`, {
|
|
44
|
+
signal: AbortSignal.timeout(5_000),
|
|
45
|
+
headers: { accept: 'application/json' },
|
|
46
|
+
});
|
|
47
|
+
if (!res.ok) return null;
|
|
48
|
+
return (await res.json())?.version ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function takeLock() {
|
|
52
|
+
try {
|
|
53
|
+
const stat = await fs.stat(LOCK);
|
|
54
|
+
if (Date.now() - stat.mtimeMs < LOCK_TTL) return false; // someone else is on it
|
|
55
|
+
} catch { /* no lock — good */ }
|
|
56
|
+
await fs.mkdir(HOME, { recursive: true });
|
|
57
|
+
await fs.writeFile(LOCK, String(process.pid));
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check, and install if there is something newer.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} o
|
|
65
|
+
* @param {(v: string) => void} [o.onUpdated] called when the install finishes
|
|
66
|
+
*/
|
|
67
|
+
export async function autoUpdate({ onUpdated } = {}) {
|
|
68
|
+
try {
|
|
69
|
+
if (process.env.UCODE_NO_UPDATE || isDevCheckout() || !VERSION) return;
|
|
70
|
+
const latest = await latestVersion();
|
|
71
|
+
if (!latest || !newer(latest, VERSION)) return;
|
|
72
|
+
if (!(await takeLock())) return;
|
|
73
|
+
|
|
74
|
+
const log = await fs.open(LOG, 'w');
|
|
75
|
+
const child = spawn(`npm install -g ${PACKAGE}@${latest} --no-audit --no-fund`, {
|
|
76
|
+
shell: true,
|
|
77
|
+
detached: true,
|
|
78
|
+
windowsHide: true,
|
|
79
|
+
stdio: ['ignore', log.fd, log.fd],
|
|
80
|
+
});
|
|
81
|
+
await log.close();
|
|
82
|
+
child.unref();
|
|
83
|
+
|
|
84
|
+
child.on('exit', async (code) => {
|
|
85
|
+
await fs.rm(LOCK, { force: true }).catch(() => {});
|
|
86
|
+
if (code === 0) onUpdated?.(latest);
|
|
87
|
+
});
|
|
88
|
+
child.on('error', async () => {
|
|
89
|
+
await fs.rm(LOCK, { force: true }).catch(() => {});
|
|
90
|
+
});
|
|
91
|
+
} catch {
|
|
92
|
+
// An update check must never be the reason ucode misbehaves. Offline,
|
|
93
|
+
// registry down, no permission to install globally: all silently skipped.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
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 path from 'node:path';
|
|
18
|
+
import { ToolFailure } from '../core/failure.js';
|
|
19
|
+
import { ask } from '../core/provider.js';
|
|
20
|
+
import { getRoot, result } from './shared.js';
|
|
21
|
+
|
|
22
|
+
const VISION_MODEL = 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free';
|
|
23
|
+
const WIDTHS = [
|
|
24
|
+
{ name: 'phone', width: 375, height: 812 },
|
|
25
|
+
{ name: 'desktop', width: 1440, height: 900 },
|
|
26
|
+
];
|
|
27
|
+
const LOCAL = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i;
|
|
28
|
+
|
|
29
|
+
let browserPromise = null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One browser for the whole session, started on first use. The installed
|
|
33
|
+
* Edge or Chrome is tried first; Playwright's own Chromium only if it happens
|
|
34
|
+
* to be installed.
|
|
35
|
+
*/
|
|
36
|
+
async function browser() {
|
|
37
|
+
if (browserPromise) return browserPromise;
|
|
38
|
+
browserPromise = (async () => {
|
|
39
|
+
let chromium;
|
|
40
|
+
try {
|
|
41
|
+
({ chromium } = await import('playwright-core'));
|
|
42
|
+
} catch (err) {
|
|
43
|
+
throw new ToolFailure({
|
|
44
|
+
kind: 'no_playwright',
|
|
45
|
+
attempted: 'starting a browser',
|
|
46
|
+
failed: `playwright-core could not be loaded: ${err.message}`,
|
|
47
|
+
fix: 'Reinstall ucode (npm install -g ucode-agent). Carry on without looking at the app, and say so.',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const tried = [];
|
|
51
|
+
for (const channel of ['msedge', 'chrome', undefined]) {
|
|
52
|
+
try {
|
|
53
|
+
return await chromium.launch({ channel, headless: true });
|
|
54
|
+
} catch (err) {
|
|
55
|
+
tried.push(`${channel ?? 'bundled chromium'}: ${String(err.message).split('\n')[0]}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
throw new ToolFailure({
|
|
59
|
+
kind: 'no_browser',
|
|
60
|
+
attempted: 'starting a browser',
|
|
61
|
+
failed: `No browser could be started. Tried ${tried.join('; ')}.`,
|
|
62
|
+
fix: 'Install Google Chrome or Microsoft Edge. Carry on without looking at the app, and say so.',
|
|
63
|
+
});
|
|
64
|
+
})();
|
|
65
|
+
browserPromise.catch(() => { browserPromise = null; });
|
|
66
|
+
return browserPromise;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Close the shared browser, if one was started. Called when ucode exits. */
|
|
70
|
+
export async function closeBrowser() {
|
|
71
|
+
if (!browserPromise) return;
|
|
72
|
+
try { await (await browserPromise).close(); } catch { /* already gone */ }
|
|
73
|
+
browserPromise = null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Layout and accessibility checks run inside the page. */
|
|
77
|
+
function inspect() {
|
|
78
|
+
const vw = window.innerWidth;
|
|
79
|
+
const describeEl = (el) => {
|
|
80
|
+
const id = el.id ? `#${el.id}` : '';
|
|
81
|
+
const cls = typeof el.className === 'string' && el.className.trim()
|
|
82
|
+
? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` : '';
|
|
83
|
+
const text = (el.innerText || el.getAttribute('aria-label') || '').trim().replace(/\s+/g, ' ').slice(0, 40);
|
|
84
|
+
return `<${el.tagName.toLowerCase()}${id}${cls}>${text ? ` "${text}"` : ''}`;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const overflow = document.documentElement.scrollWidth - vw;
|
|
88
|
+
const wide = [];
|
|
89
|
+
if (overflow > 1) {
|
|
90
|
+
for (const el of document.querySelectorAll('body *')) {
|
|
91
|
+
const r = el.getBoundingClientRect();
|
|
92
|
+
if (r.width > 0 && r.right > vw + 1 && getComputedStyle(el).position !== 'fixed') {
|
|
93
|
+
wide.push(`${describeEl(el)} reaches ${Math.round(r.right)}px`);
|
|
94
|
+
if (wide.length >= 5) break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const broken = [...document.images].filter((i) => i.complete && i.naturalWidth === 0).map((i) => i.src.slice(0, 80));
|
|
100
|
+
const noAlt = [...document.images].filter((i) => !i.hasAttribute('alt')).length;
|
|
101
|
+
const unnamed = [...document.querySelectorAll('button, a[href], [role="button"]')]
|
|
102
|
+
.filter((el) => !(el.innerText || '').trim() && !el.getAttribute('aria-label') && !el.getAttribute('title')
|
|
103
|
+
&& !el.querySelector('[aria-label], title, img[alt]:not([alt=""])'))
|
|
104
|
+
.slice(0, 5).map(describeEl);
|
|
105
|
+
const inputsNoLabel = [...document.querySelectorAll('input:not([type="hidden"]), textarea, select')]
|
|
106
|
+
.filter((el) => !(el.id && document.querySelector(`label[for="${el.id}"]`)) && !el.closest('label')
|
|
107
|
+
&& !el.getAttribute('aria-label') && !el.getAttribute('aria-labelledby'))
|
|
108
|
+
.length;
|
|
109
|
+
const tiny = vw < 600
|
|
110
|
+
? [...document.querySelectorAll('button, a[href], [role="button"], input, select')]
|
|
111
|
+
.filter((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && (r.height < 32 || r.width < 32); })
|
|
112
|
+
.length
|
|
113
|
+
: 0;
|
|
114
|
+
const smallText = [...document.querySelectorAll('p, li, span, a, button, label, td')]
|
|
115
|
+
.filter((el) => el.childElementCount === 0 && (el.innerText || '').trim() && parseFloat(getComputedStyle(el).fontSize) < 12)
|
|
116
|
+
.length;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
title: document.title,
|
|
120
|
+
overflow: overflow > 1 ? Math.round(overflow) : 0,
|
|
121
|
+
wide, broken, noAlt, unnamed, inputsNoLabel, tiny, smallText,
|
|
122
|
+
empty: !(document.body.innerText || '').trim(),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const safeName = (p) => (p === '/' ? 'home' : p.replace(/^\/+|\/+$/g, '').replace(/[^\w-]+/g, '_')) || 'page';
|
|
127
|
+
|
|
128
|
+
async function review(shots) {
|
|
129
|
+
const request = [
|
|
130
|
+
{
|
|
131
|
+
role: 'system',
|
|
132
|
+
content:
|
|
133
|
+
'You are a senior product designer reviewing screenshots of a web app, one at a phone width ' +
|
|
134
|
+
'and one at desktop width. List the concrete visual problems a user would notice, most ' +
|
|
135
|
+
'important first: broken or cramped layout, overflow, misalignment, weak hierarchy (is the ' +
|
|
136
|
+
'most important thing the most prominent?), inconsistent spacing, low contrast, default-looking ' +
|
|
137
|
+
'components, awkward empty states, text that is too small. For each: where it is, what is wrong, ' +
|
|
138
|
+
'and the specific fix. At most 8 points, one or two lines each. If it genuinely looks polished, ' +
|
|
139
|
+
'say so in one line and name the one thing that would improve it most. No preamble.',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
role: 'user',
|
|
143
|
+
content: shots.map((s) => `${s.label}`).join(' and ') + '.',
|
|
144
|
+
images: shots.map((s) => s.dataUrl),
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
const reply = await ask(request, [], { model: VISION_MODEL, temperature: 0.2, maxOutputTokens: 900 });
|
|
148
|
+
return reply.text.trim();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function lookAtApp({ url, paths = ['/'], review: wantReview = true }) {
|
|
152
|
+
const base = String(url ?? '').trim().replace(/\/+$/, '');
|
|
153
|
+
if (!LOCAL.test(`${base}/`)) {
|
|
154
|
+
throw new ToolFailure({
|
|
155
|
+
kind: 'bad_args',
|
|
156
|
+
attempted: 'looking at the app',
|
|
157
|
+
failed: `"${url}" is not a local address. This only opens apps running on this machine.`,
|
|
158
|
+
fix: 'Pass the URL the dev server reported, e.g. http://localhost:3000',
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const pages = (Array.isArray(paths) && paths.length ? paths : ['/'])
|
|
163
|
+
.map((p) => `/${String(p).trim().replace(/^\/+/, '')}`)
|
|
164
|
+
.slice(0, 4);
|
|
165
|
+
|
|
166
|
+
const shotsDir = path.join(getRoot(), '.ucode', 'screenshots');
|
|
167
|
+
await fs.mkdir(shotsDir, { recursive: true });
|
|
168
|
+
|
|
169
|
+
const b = await browser();
|
|
170
|
+
const sections = [];
|
|
171
|
+
const toReview = [];
|
|
172
|
+
let problems = 0;
|
|
173
|
+
|
|
174
|
+
for (const pagePath of pages) {
|
|
175
|
+
for (const size of WIDTHS) {
|
|
176
|
+
const context = await b.newContext({ viewport: { width: size.width, height: size.height }, deviceScaleFactor: 1 });
|
|
177
|
+
const page = await context.newPage();
|
|
178
|
+
const errors = [];
|
|
179
|
+
const failed = [];
|
|
180
|
+
page.on('console', (m) => {
|
|
181
|
+
if (m.type() === 'error' && !/devtools|download the react/i.test(m.text())) errors.push(m.text().slice(0, 200));
|
|
182
|
+
});
|
|
183
|
+
page.on('pageerror', (e) => errors.push(`uncaught: ${String(e.message).slice(0, 200)}`));
|
|
184
|
+
page.on('requestfailed', (r) => failed.push(`${r.method()} ${r.url().slice(0, 100)} — ${r.failure()?.errorText ?? 'failed'}`));
|
|
185
|
+
page.on('response', (r) => { if (r.status() >= 400) failed.push(`${r.status()} ${r.url().slice(0, 100)}`); });
|
|
186
|
+
|
|
187
|
+
const target = `${base}${pagePath}`;
|
|
188
|
+
let loadError = null;
|
|
189
|
+
try {
|
|
190
|
+
// A dev server compiles a page on its first request, which can take a
|
|
191
|
+
// while; networkidle then waits for the page's own data to arrive.
|
|
192
|
+
await page.goto(target, { waitUntil: 'networkidle', timeout: 60_000 });
|
|
193
|
+
} catch (err) {
|
|
194
|
+
try { await page.goto(target, { waitUntil: 'load', timeout: 30_000 }); }
|
|
195
|
+
catch (err2) { loadError = String(err2.message).split('\n')[0]; }
|
|
196
|
+
}
|
|
197
|
+
await page.waitForTimeout(600); // let entrance animations settle
|
|
198
|
+
|
|
199
|
+
const file = path.join(shotsDir, `${safeName(pagePath)}-${size.name}.jpg`);
|
|
200
|
+
let facts = null;
|
|
201
|
+
if (!loadError) {
|
|
202
|
+
facts = await page.evaluate(inspect).catch((err) => ({ error: err.message }));
|
|
203
|
+
const buffer = await page.screenshot({ type: 'jpeg', quality: 70, fullPage: false });
|
|
204
|
+
await fs.writeFile(file, buffer);
|
|
205
|
+
toReview.push({ label: `${pagePath} at ${size.width}px (${size.name})`, dataUrl: `data:image/jpeg;base64,${buffer.toString('base64')}` });
|
|
206
|
+
}
|
|
207
|
+
await context.close();
|
|
208
|
+
|
|
209
|
+
const lines = [`### ${pagePath} at ${size.width}px (${size.name})`];
|
|
210
|
+
if (loadError) {
|
|
211
|
+
lines.push(`Could not load: ${loadError}`);
|
|
212
|
+
problems++;
|
|
213
|
+
} else {
|
|
214
|
+
lines.push(`Screenshot: ${path.relative(getRoot(), file).split(path.sep).join('/')}`);
|
|
215
|
+
if (facts?.empty) { lines.push('- The page rendered no visible text at all.'); problems++; }
|
|
216
|
+
if (facts?.overflow) {
|
|
217
|
+
lines.push(`- Content is ${facts.overflow}px wider than the screen, so it scrolls sideways:`, ...facts.wide.map((w) => ` - ${w}`));
|
|
218
|
+
problems++;
|
|
219
|
+
}
|
|
220
|
+
if (facts?.broken?.length) { lines.push(`- Broken images: ${facts.broken.join(', ')}`); problems++; }
|
|
221
|
+
if (facts?.unnamed?.length) { lines.push(`- Buttons or links with no accessible name: ${facts.unnamed.join(', ')}`); problems++; }
|
|
222
|
+
if (facts?.inputsNoLabel) { lines.push(`- ${facts.inputsNoLabel} form field(s) without a label.`); problems++; }
|
|
223
|
+
if (facts?.noAlt) lines.push(`- ${facts.noAlt} image(s) without alt text.`);
|
|
224
|
+
if (facts?.tiny) lines.push(`- ${facts.tiny} tap target(s) smaller than 32px on a phone.`);
|
|
225
|
+
if (facts?.smallText) lines.push(`- ${facts.smallText} text element(s) under 12px.`);
|
|
226
|
+
}
|
|
227
|
+
if (errors.length) { lines.push('- Console errors:', ...[...new Set(errors)].slice(0, 6).map((e) => ` - ${e}`)); problems++; }
|
|
228
|
+
if (failed.length) { lines.push('- Failed requests:', ...[...new Set(failed)].slice(0, 6).map((f) => ` - ${f}`)); problems++; }
|
|
229
|
+
if (lines.length === 2 && !loadError) lines.push('- No errors, no overflow, nothing unlabeled.');
|
|
230
|
+
sections.push(lines.join('\n'));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let critique = '';
|
|
235
|
+
if (wantReview !== false && toReview.length) {
|
|
236
|
+
try {
|
|
237
|
+
critique = await review(toReview.slice(0, 4));
|
|
238
|
+
} catch (err) {
|
|
239
|
+
critique = `(The visual review could not run: ${err.failed ?? err.message}. The checks above still apply.)`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const body = [
|
|
244
|
+
...sections,
|
|
245
|
+
critique ? `## Visual review\n${critique}` : '',
|
|
246
|
+
'',
|
|
247
|
+
problems
|
|
248
|
+
? 'Fix the problems above, then look again to confirm.'
|
|
249
|
+
: 'The automatic checks found nothing. Weigh the visual review, fix what is worth fixing.',
|
|
250
|
+
].filter(Boolean).join('\n\n');
|
|
251
|
+
|
|
252
|
+
return result(
|
|
253
|
+
body,
|
|
254
|
+
problems
|
|
255
|
+
? `${problems} problem${problems === 1 ? '' : 's'} found · screenshots in .ucode/screenshots`
|
|
256
|
+
: 'no errors · screenshots in .ucode/screenshots'
|
|
257
|
+
);
|
|
258
|
+
}
|