Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_terminal.mjs

51.5 KiB, 1 run

created by r2519314175:733, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_terminal.mjs — the terminal draws what it was sent, and sends what was typed.
2//
3// The panel has two boundaries and both of them are testable. On the way in, bytes become a
4// grid of cells and then pixels: what is asserted is not "the model says bold" but that the
5// PIXEL at a known cell is the palette's own colour, read back off the canvas — a canvas
6// renderer that quietly stopped painting would satisfy every DOM assertion ever written.
7// On the way out, a keypress becomes bytes: those are asserted against the xterm sequences
8// by number, because an arrow key that sends something plausible-but-wrong is exactly the
9// bug that makes every REPL feel broken and exactly the bug a loose test misses.
10//
11// It drives dev/termdemo.mjs, which serves the REAL www/js/terminal.js and www/css/
12// terminal.css and feeds them output recorded from real programs on a real pty. The demo
13// server is started by this file on a port of its own, so nothing else need be running:
14// this verifier needs neither dev/serve.mjs nor dev/mockllm.mjs, because the terminal has
15// no model, no gateway and no wasm behind it.
16//
17// node dev/verify_terminal.mjs
18//
19// Eleven of the checks are SELF-TESTS: the property is broken in the live page and the check
20// is required to go red, then restored and required to go green. A check that has only ever
21// been seen passing has not been seen working.
22
23import fs from 'node:fs';
24import os from 'node:os';
25import path from 'node:path';
26import { fileURLToPath, pathToFileURL } from 'node:url';
27import { start, FIXTURES } from './termdemo.mjs';
28
29const PW = process.env.DAIMOND_PW
30 || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
31const { chromium } = await import(pathToFileURL(PW).href);
32const CHROME = process.env.DAIMOND_CHROME
33 || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`;
34const HERE = path.dirname(fileURLToPath(import.meta.url));
35const SHOTS = path.join(os.homedir(), '.cache/daimond/term-shots');
36fs.mkdirSync(SHOTS, { recursive: true });
37
38// DERIVED FROM THE WORLD, because 8779 IS world 2's app port (8777 + 2). This file
39// starts a server of its own, so a fixed default meant this verifier and another
40// agent's world could not both exist -- and on 2026-08-18 the gate died here,
41// EADDRINUSE, because world 2 was up in the main tree. The verifier was blameless
42// and the number was the bug. Add a row to the port register in dev/world.sh
43// before deriving any port.
44const WORLD = Number(process.env.DAIMOND_PORT || 8777) - 8777;
45const PORT = Number(process.env.TERMDEMO_PORT || (8940 + WORLD));
46
47let bad = 0, n = 0;
48const out = [];
49const check = (ok, what) => {
50 n++;
51 out.push(`${ok ? 'PASS' : 'FAIL'} ${what}`);
52 console.log(`${ok ? ' ok ' : ' FAIL '}${what}`);
53 if (!ok) bad++;
54};
55/// A property proved twice: broken, and required to fail; restored, and required to pass.
56const selfTests = [];
57const proved = async (name, breakIt, testIt, fixIt) => {
58 await breakIt();
59 const red = await testIt();
60 await fixIt();
61 const green = await testIt();
62 selfTests.push(name);
63 check(!red && green, `SELF-TEST ${name}: fails when broken, passes when whole `
64 + `(broken=${red ? 'passed — the check is blind' : 'failed, correctly'}, whole=${green ? 'passed' : 'FAILED'})`);
65};
66
67const server = start(PORT);
68const browser = await chromium.launch({ executablePath: CHROME, args: ['--no-sandbox', '--headless=new'] });
69const page = await browser.newPage({ viewport: { width: 1200, height: 800 }, deviceScaleFactor: 1 });
70const errs = [];
71page.on('console', m => { if (m.type() === 'error') errs.push(m.text()); });
72page.on('pageerror', e => errs.push('pageerror: ' + e.message));
73await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'networkidle' });
74await page.waitForTimeout(400);
75
76const reset = () => page.evaluate(() => { window.__term.reset(); window.__clearSent(); window.__shell = false; });
77const write = (s) => page.evaluate(t => { window.__term.write(t); window.__term._paintNow(); }, s);
78const play = (name) => page.evaluate(nm => window.__play(nm, 0), name);
79
80// ── 1. The grid: bytes become cells ─────────────────────────────────
81
82await reset();
83await write('\x1b[1mB\x1b[0m\x1b[2mD\x1b[0m\x1b[3mI\x1b[0m\x1b[4mU\x1b[0m\x1b[7mR\x1b[0m\x1b[9mS\x1b[0m');
84const attrs = await page.evaluate(() => {
85 const s = window.__term.screen, A = DaimondTerminal.ATTR;
86 const c = s.cells;
87 const at = i => c.attr[i];
88 return { bold: !!(at(0) & A.BOLD), dim: !!(at(1) & A.DIM), italic: !!(at(2) & A.ITALIC),
89 under: !!(at(3) & A.UNDER), rev: !!(at(4) & A.REVERSE), strike: !!(at(5) & A.STRIKE),
90 chars: [0, 1, 2, 3, 4, 5].map(i => String.fromCharCode(c.ch[i])).join('') };
91});
92check(attrs.chars === 'BDIURS' && attrs.bold && attrs.dim && attrs.italic
93 && attrs.under && attrs.rev && attrs.strike,
94 `the six attributes each land on their own cell: ${JSON.stringify(attrs)}`);
95
96await reset();
97await write('\x1b[31mA\x1b[38;5;208mB\x1b[38;2;18;52;86mC\x1b[38:2::200:100:50mD\x1b[48;5;21mE');
98const cols = await page.evaluate(() => {
99 const c = window.__term.screen.cells;
100 const un = v => ({ mode: (v >>> 24) & 3, val: v & 0xFFFFFF });
101 return { a: un(c.fg[0]), b: un(c.fg[1]), c: un(c.fg[2]), d: un(c.fg[3]), e: un(c.bg[4]) };
102});
103check(cols.a.mode === 1 && cols.a.val === 1, `a named colour is indexed: ${JSON.stringify(cols.a)} (wanted mode 1, index 1)`);
104check(cols.b.mode === 1 && cols.b.val === 208, `a 256-palette colour keeps its index: ${JSON.stringify(cols.b)}`);
105check(cols.c.mode === 2 && cols.c.val === 0x123456, `24-bit, semicolon form: ${cols.c.val.toString(16)} (wanted 123456)`);
106check(cols.d.mode === 2 && cols.d.val === ((200 << 16) | (100 << 8) | 50),
107 `24-bit, COLON form with the empty colour-space slot: ${cols.d.val.toString(16)} (wanted c86432)`);
108check(cols.e.mode === 1 && cols.e.val === 21, `a background colour lands on the background: ${JSON.stringify(cols.e)}`);
109
110// ── 2. The pixels: cells become the palette's own colours ───────────
111//
112// Read back off the canvas. This is the check that a DOM assertion cannot make and the
113// only one that would notice a renderer that had stopped drawing.
114
115/// The colour actually painted at the middle of cell (x, y), as [r,g,b].
116const pixelAt = (x, y, dx = 0.5, dy = 0.5) => page.evaluate(({ x, y, dx, dy }) => {
117 const cv = document.querySelector('.term-canvas');
118 const c = window.__term.cell();
119 const dpr = cv.width / parseFloat(cv.style.width);
120 const px = Math.floor((x + dx) * c.w * dpr), py = Math.floor((y + dy) * c.h * dpr);
121 const g = cv.getContext('2d').getImageData(px, py, 1, 1).data;
122 return [g[0], g[1], g[2]];
123}, { x, y, dx, dy });
124
125/// What the stylesheet says a colour is, resolved by the browser.
126const cssColour = (name) => page.evaluate(nm => {
127 const v = getComputedStyle(document.querySelector('.term')).getPropertyValue(nm).trim();
128 if (!v) return null; // the variable is not defined at all
129 const d = document.createElement('div');
130 d.style.color = v;
131 document.body.appendChild(d);
132 const got = getComputedStyle(d).color;
133 d.remove();
134 return (got.match(/\d+/g) || []).slice(0, 3).map(Number);
135}, name);
136
137const near = (a, b, tol = 6) => a && b && a.every((v, i) => Math.abs(v - b[i]) <= tol);
138
139await reset();
140// A background block per named colour, so the pixel read is of a filled cell rather than
141// of the antialiased edge of a glyph.
142await write([...Array(16).keys()].map(i => `\x1b[48;5;${i}m \x1b[0m`).join(''));
143let wrongColour = [];
144for (let i = 0; i < 16; i++) {
145 // A background may override its own value; where it does not, the two are the same.
146 const want = (await cssColour(`--term-ansi-bg-${i}`)) || (await cssColour(`--term-ansi-${i}`));
147 const got = await pixelAt(i * 2, 0);
148 if (!near(got, want)) wrongColour.push(`${i}: drew ${got} wanted ${want}`);
149}
150check(wrongColour.length === 0,
151 `all sixteen named backgrounds are painted in the stylesheet's own colours${wrongColour.length ? ': ' + wrongColour.join('; ') : ''}`);
152
153await reset();
154await write('\x1b[48;2;18;52;86m \x1b[48;5;208m \x1b[0m');
155const trueCol = await pixelAt(0, 0), cubeCol = await pixelAt(1, 0);
156check(near(trueCol, [18, 52, 86]), `a 24-bit background is painted exactly: ${trueCol} (wanted 18,52,86)`);
157check(near(cubeCol, [255, 135, 0]), `256-palette 208 is the cube's own orange: ${cubeCol} (wanted 255,135,0)`);
158
159// Reverse video really swaps the two, at the pixel.
160await reset();
161await write('\x1b[31;7mX\x1b[0m');
162const revBg = await pixelAt(0, 0, 0.5, 0.9); // below the glyph, still inside the cell
163const red = await cssColour('--term-ansi-1');
164check(near(revBg, red, 10), `reverse video paints the FOREGROUND colour as the ground: ${revBg} (wanted the red ${red})`);
165
166// ── 3. A whole screen, from a real recording ────────────────────────
167
168await reset();
169await play('colours');
170await page.waitForTimeout(250);
171await page.evaluate(() => window.__term._paintNow());
172await page.screenshot({ path: path.join(SHOTS, 'colours.png') });
173const inked = await page.evaluate(() => {
174 // How much of the canvas is not the ground: a screen that drew nothing, or drew
175 // everything, both look wrong here and neither shows up in the model.
176 const cv = document.querySelector('.term-canvas');
177 const g = cv.getContext('2d').getImageData(0, 0, cv.width, cv.height).data;
178 const bg = [g[0], g[1], g[2]];
179 let on = 0, total = 0;
180 for (let i = 0; i < g.length; i += 4 * 7) {
181 total++;
182 if (Math.abs(g[i] - bg[0]) + Math.abs(g[i + 1] - bg[1]) + Math.abs(g[i + 2] - bg[2]) > 24) on++;
183 }
184 return on / total;
185});
186check(inked > 0.04 && inked < 0.9, `the colour chart puts ink on the screen without flooding it: ${(inked * 100).toFixed(1)}% of sampled pixels differ from the ground`);
187
188await reset();
189await play('top');
190await page.waitForTimeout(250);
191const topDrew = await page.evaluate(() => {
192 const s = window.__term.screen;
193 return { row0: s.lineText(s.absOfRow(0)), row7: s.lineText(s.absOfRow(7)) };
194});
195check(/^top - /.test(topDrew.row0) && /\d+ (jason|root)/.test(topDrew.row7),
196 `a full-screen program's cursor addressing and clears land where it put them: `
197 + `row 0 ${JSON.stringify(topDrew.row0.slice(0, 34))}, row 7 ${JSON.stringify(topDrew.row7.slice(0, 34))}`);
198
199// The alternate screen, entered and left, which is the property that matters: a pager must
200// give back the shell output it covered up.
201await reset();
202await write('the shell was here\r\n');
203const ALT_ENTER = 1253; // where less.bin leaves the alternate screen
204const pager = await page.evaluate(async (n) => {
205 await window.__playTo('less', n);
206 const s = window.__term.screen;
207 const rows = [];
208 for (let y = 0; y < s.rows; y++) rows.push(s.lineText(s.absOfRow(y)));
209 return { alt: s.modes.alt, sb: s.scrollback(), rows };
210}, ALT_ENTER);
211check(pager.alt === true, `a pager moves to the alternate screen: modes.alt=${pager.alt}`);
212// The pager was told a 24-row terminal by script(1) and this panel has more, so the file
213// lands wherever its own cursor addressing put it -- which is the point: it is asserted
214// that the TEXT is on the alternate screen, not that it is on a row this test guessed.
215const pagerLine = pager.rows.find(r => /serve\.mjs/.test(r));
216check(!!pagerLine, `and draws the file there: ${JSON.stringify((pagerLine || '').slice(0, 46))}`);
217check(pager.sb === 0, `the alternate screen adds nothing to the scrollback: ${pager.sb} line(s) — a redraw is not a transcript`);
218const after = await page.evaluate(async (n) => {
219 await window.__playFrom('less', n);
220 const s = window.__term.screen;
221 return { alt: s.modes.alt, row0: s.lineText(s.absOfRow(0)) };
222}, ALT_ENTER);
223check(after.alt === false && /the shell was here/.test(after.row0),
224 `and when it quits the shell's own screen is back, unharmed: modes.alt=${after.alt}, `
225 + `row 0 ${JSON.stringify(after.row0)}`);
226
227await reset();
228await play('bar');
229await page.waitForTimeout(250);
230const bar = await page.evaluate(() => {
231 const s = window.__term.screen;
232 const rows = [];
233 for (let y = 0; y < 4; y++) rows.push(s.lineText(s.absOfRow(y)));
234 return rows;
235});
236check(/100%/.test(bar[0]) && !/\[#*\]\s*\[/.test(bar[0]),
237 `a progress bar redrawn with carriage returns leaves ONE line, not twenty-one: ${JSON.stringify(bar[0])}`);
238
239await reset();
240await play('ask');
241await page.waitForTimeout(200);
242const ask = await page.evaluate(() => {
243 const s = window.__term.screen;
244 return [0, 1, 2, 3].map(y => s.lineText(s.absOfRow(y)));
245});
246check(/\[sudo\] password for/.test(ask[0]) && /Sorry, try again/.test(ask[1]),
247 `a sudo prompt and its refusal land on their own lines: ${JSON.stringify(ask.slice(0, 2))}`);
248
249// ── 4. The damage model ─────────────────────────────────────────────
250
251await reset();
252await write('hello\r\nworld\r\n');
253const dmg = await page.evaluate(() => {
254 const s = window.__term.screen;
255 s.compose(); // clear whatever is outstanding
256 s.write('!'); // one character, one row
257 const d = s.compose();
258 let rows = 0;
259 for (let i = 0; i < d.rows.length; i++) if (d.rows[i]) rows++;
260 return { all: d.all, rows, scrolled: d.scrolled };
261});
262check(!dmg.all && dmg.rows === 1, `one character damages exactly one row: ${JSON.stringify(dmg)}`);
263
264const scrollDmg = await page.evaluate(() => {
265 const s = window.__term.screen;
266 // Fill to the bottom so the next line scrolls.
267 for (let i = 0; i < s.rows + 2; i++) s.write('line ' + i + '\r\n');
268 s.compose();
269 s.write('one more\r\n');
270 const d = s.compose();
271 return { all: d.all, scrolled: d.scrolled };
272});
273check(!scrollDmg.all && scrollDmg.scrolled === 1,
274 `a scroll is reported AS a scroll rather than as a whole-screen repaint: ${JSON.stringify(scrollDmg)}`);
275
276// The blit has to produce the same pixels as a full repaint, or the optimisation is a
277// rendering bug with a stopwatch attached.
278await reset();
279await write('\x1b[H');
280for (let i = 0; i < 40; i++) await write(`\x1b[3${i % 8}mrow ${i} with some text on it\x1b[0m\r\n`);
281const same = await page.evaluate(() => {
282 const cv = document.querySelector('.term-canvas');
283 const grab = () => cv.getContext('2d').getImageData(0, 0, cv.width, cv.height).data;
284 window.__term.write('\x1b[36ma newly scrolled line\x1b[0m\r\n');
285 window.__term._paintFrame(); // the damage path: blit, then the new row
286 const blitted = Array.from(grab());
287 window.__term._paintNow(); // the whole grid, from scratch
288 const full = Array.from(grab());
289 let diff = 0;
290 for (let i = 0; i < full.length; i += 4) {
291 if (Math.abs(full[i] - blitted[i]) > 2 || Math.abs(full[i + 1] - blitted[i + 1]) > 2
292 || Math.abs(full[i + 2] - blitted[i + 2]) > 2) diff++;
293 }
294 return { diff, of: full.length / 4 };
295});
296check(same.diff === 0,
297 `the scroll blit draws the same pixels as a full repaint: ${same.diff} of ${same.of} pixels differ`);
298
299// ── 5. Keys become the right bytes ──────────────────────────────────
300
301const typed = async (key, opts = {}) => {
302 await page.evaluate(() => window.__clearSent());
303 await page.focus('.term-input');
304 await page.keyboard.press(key, opts);
305 await page.waitForTimeout(30);
306 const got = await page.evaluate(() => window.__sentRaw.flat());
307 return got;
308};
309const bytesOf = (s) => Array.from(new TextEncoder().encode(s));
310const keyIs = async (key, want, what) => {
311 const got = await typed(key);
312 const w = bytesOf(want);
313 check(JSON.stringify(got) === JSON.stringify(w),
314 `${what || key} sends ${JSON.stringify(want).replace(/\\u001b/g, 'ESC')} — got `
315 + `[${got.map(b => b.toString(16)).join(' ')}], wanted [${w.map(b => b.toString(16)).join(' ')}]`);
316};
317
318await reset();
319await keyIs('Enter', '\r', 'Enter (CR, not LF — the line discipline makes the newline)');
320await keyIs('Backspace', '\x7f', 'Backspace (DEL, not BS)');
321await keyIs('Control+Backspace', '\x08', 'Ctrl-Backspace (BS: readline\'s delete-word)');
322await keyIs('Tab', '\t');
323await keyIs('Shift+Tab', '\x1b[Z', 'Shift-Tab (CBT)');
324await keyIs('Escape', '\x1b');
325await keyIs('ArrowUp', '\x1b[A');
326await keyIs('ArrowDown', '\x1b[B');
327await keyIs('ArrowRight', '\x1b[C');
328await keyIs('ArrowLeft', '\x1b[D');
329await keyIs('Home', '\x1b[H');
330await keyIs('End', '\x1b[F');
331await keyIs('PageUp', '\x1b[5~');
332await keyIs('PageDown', '\x1b[6~');
333await keyIs('Delete', '\x1b[3~');
334await keyIs('Insert', '\x1b[2~');
335await keyIs('Control+c', '\x03', 'Ctrl-C (ETX — the interrupt, and it must never be the copy)');
336await keyIs('Control+d', '\x04', 'Ctrl-D (EOT)');
337await keyIs('Control+z', '\x1a', 'Ctrl-Z (SUB)');
338await keyIs('Control+l', '\x0c', 'Ctrl-L');
339await keyIs('Control+Space', '\x00', 'Ctrl-Space (NUL)');
340await keyIs('Control+[', '\x1b', 'Ctrl-[');
341await keyIs('Control+\\', '\x1c', 'Ctrl-\\ (QUIT)');
342await keyIs('F1', '\x1bOP');
343await keyIs('F4', '\x1bOS');
344await keyIs('F5', '\x1b[15~');
345await keyIs('F12', '\x1b[24~');
346await keyIs('Control+ArrowRight', '\x1b[1;5C', 'Ctrl-Right (the modifier is a parameter, not a different key)');
347await keyIs('Shift+ArrowLeft', '\x1b[1;2D', 'Shift-Left');
348await keyIs('Alt+ArrowUp', '\x1b[1;3A', 'Alt-Up');
349await keyIs('Alt+b', '\x1bb', 'Alt-b (ESC prefix — readline\'s back-word)');
350await keyIs('a', 'a', 'an ordinary letter');
351
352// Application cursor keys: the same arrow, a different sequence, because that is what a
353// REPL in readline asks for and what vi assumes.
354await write('\x1b[?1h');
355await keyIs('ArrowUp', '\x1bOA', 'ArrowUp in application cursor mode');
356await keyIs('Home', '\x1bOH', 'Home in application cursor mode');
357await keyIs('Control+ArrowUp', '\x1b[1;5A', 'a MODIFIED arrow stays CSI even in application mode');
358await write('\x1b[?1l');
359await keyIs('ArrowUp', '\x1b[A', 'ArrowUp back in normal mode');
360
361// UTF-8, not a code unit.
362await page.evaluate(() => window.__clearSent());
363await page.keyboard.type('é');
364await page.waitForTimeout(30);
365const acc = await page.evaluate(() => window.__sentRaw.flat());
366check(JSON.stringify(acc) === JSON.stringify([0xC3, 0xA9]),
367 `a non-ASCII letter is sent as UTF-8: [${acc.map(b => b.toString(16)).join(' ')}] (wanted c3 a9)`);
368
369// A query the program made must be answered, or it waits for ever.
370await page.evaluate(() => window.__clearSent());
371await write('\x1b[10;5H\x1b[6n');
372await page.waitForTimeout(50);
373const dsr = await page.evaluate(() => window.__sentRaw.flat().map(b => String.fromCharCode(b)).join(''));
374check(dsr === '\x1b[10;5R', `a cursor-position report is answered: ${JSON.stringify(dsr)} (wanted ESC[10;5R)`);
375
376// ── 6. Paste ────────────────────────────────────────────────────────
377
378/// Paste through the REAL clipboard event, not the internal function, so the listener is
379/// covered too.
380const pasteEvent = (text) => page.evaluate(t => {
381 const dt = new DataTransfer();
382 dt.setData('text/plain', t);
383 document.querySelector('.term-input').dispatchEvent(
384 new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
385}, text);
386
387await reset();
388await write('\x1b[?2004h'); // the program asks to be told
389await pasteEvent('one\ntwo\nthree');
390await page.waitForTimeout(60);
391const bracketed = await page.evaluate(() => window.__sentRaw.flat().map(b => String.fromCharCode(b)).join(''));
392check(bracketed === '\x1b[200~one\rtwo\rthree\x1b[201~',
393 `a program that asked for bracketed paste gets it wrapped: ${JSON.stringify(bracketed)}`);
394
395await page.evaluate(() => window.__clearSent());
396await pasteEvent('a\x1b[201~; rm -rf /\nb');
397await page.waitForTimeout(60);
398const spoof = await page.evaluate(() => window.__sentRaw.flat().map(b => String.fromCharCode(b)).join(''));
399check(spoof.indexOf('\x1b[201~') === spoof.length - 6 && spoof.split('\x1b[201~').length === 2,
400 `a paste carrying its own terminator cannot end the bracket early: ${JSON.stringify(spoof)}`);
401
402await reset();
403await write('\x1b[?2004l'); // the program did NOT ask
404await pasteEvent('echo one\necho two\necho three');
405await page.waitForTimeout(80);
406const asked = await page.evaluate(() => ({
407 sent: window.__sentRaw.flat().length,
408 shown: !document.querySelector('.term-paste').hidden,
409 says: (document.querySelector('.term-paste-say') || {}).textContent || '',
410 buttons: [...document.querySelectorAll('.term-paste-btn')].map(b => b.textContent),
411 focused: document.activeElement && document.activeElement.className,
412}));
413check(asked.sent === 0 && asked.shown,
414 `a multi-line paste into a program that did NOT ask sends nothing until the person answers: `
415 + `${asked.sent} byte(s) sent, question ${asked.shown ? 'shown' : 'NOT SHOWN'}`);
416check(/3/.test(asked.says) && asked.buttons.length === 3,
417 `and the question says how many lines and offers three ways out: ${JSON.stringify(asked.says)} `
418 + `${JSON.stringify(asked.buttons)}`);
419check(/term-paste-btn/.test(asked.focused || ''),
420 `focus moves into the question, so it can be answered from the keyboard: ${asked.focused}`);
421await page.click('.term-paste-btn.primary');
422await page.waitForTimeout(60);
423const firstOnly = await page.evaluate(() => window.__sentRaw.flat().map(b => String.fromCharCode(b)).join(''));
424check(firstOnly === 'echo one', `"paste the first line" sends the first line and no newline: ${JSON.stringify(firstOnly)}`);
425
426// BOTH paste keys must reach the BROWSER: preventing either stops the paste event ever
427// firing, and the shortcut would silently do nothing. Plain Ctrl-V used to be prevented and
428// sent \x16 -- readline's quoted-insert -- which is what this check pinned until the owner
429// asked for the key people actually reach for on 2026-08-26. Quoted-insert is the cost, and
430// it is a real one: it is no longer reachable from this terminal.
431await reset();
432const shortcuts = await page.evaluate(async () => {
433 const ta = document.querySelector('.term-input');
434 const fire = (key, shift, ctrl) => {
435 const e = new KeyboardEvent('keydown', { key, shiftKey: shift, ctrlKey: ctrl, bubbles: true, cancelable: true });
436 ta.dispatchEvent(e);
437 return e.defaultPrevented;
438 };
439 return { pasteShortcut: fire('V', true, true), plainPaste: fire('v', false, true) };
440});
441check(shortcuts.pasteShortcut === false && shortcuts.plainPaste === false,
442 `both Ctrl-Shift-V and plain Ctrl-V are left to the browser, so the paste event fires for `
443 + `either: prevented? shift=${shortcuts.pasteShortcut} plain=${shortcuts.plainPaste}`);
444
445// Ctrl-C IS THE INTERRUPT UNLESS SOMETHING IS SELECTED, and the order matters more than
446// either half: a terminal that copied instead of interrupting would be broken exactly when
447// a program is running away, which is the one moment the key is worth having.
448await reset();
449await write('alpha bravo\r\n');
450const ctrlC = await page.evaluate(() => {
451 const t = window.__term, ta = document.querySelector('.term-input');
452 const fire = () => {
453 window.__sentRaw = [];
454 const e = new KeyboardEvent('keydown', { key: 'c', ctrlKey: true, bubbles: true, cancelable: true });
455 ta.dispatchEvent(e);
456 return { prevented: e.defaultPrevented,
457 sent: (window.__sentRaw || []).flat().map(b => String.fromCharCode(b)).join('') };
458 };
459 t.clearSelection();
460 const withNone = fire();
461 t.selectAll();
462 const withSel = fire();
463 t.clearSelection();
464 return { withNone, withSel };
465});
466check(ctrlC.withNone.sent === '\x03',
467 `Ctrl-C with nothing selected still sends the interrupt: ${JSON.stringify(ctrlC.withNone.sent)}`);
468check(ctrlC.withSel.sent === '' && ctrlC.withSel.prevented === true,
469 `Ctrl-C with a selection copies instead, and sends the program nothing: `
470 + `${JSON.stringify(ctrlC.withSel.sent)}`);
471// A copy ANNOUNCES, and the live-region checks further down read every announcement the
472// panel has made. Left behind, "33 lines copied." is counted as the terminal reciting its
473// output and two honest checks go red for a reason that is this check's doing.
474await page.evaluate(() => {
475 document.querySelectorAll('[role="log"] p').forEach(p => p.remove());
476});
477
478await reset();
479await pasteEvent('one line only');
480await page.waitForTimeout(60);
481const oneLine = await page.evaluate(() => ({
482 sent: window.__sentRaw.flat().map(b => String.fromCharCode(b)).join(''),
483 shown: !document.querySelector('.term-paste').hidden,
484}));
485check(oneLine.sent === 'one line only' && !oneLine.shown,
486 `a single-line paste is not worth a question: ${JSON.stringify(oneLine)}`);
487
488// ── 7. Size ─────────────────────────────────────────────────────────
489
490await reset();
491const sized = await page.evaluate(async () => {
492 const stage = document.getElementById('stage');
493 stage.style.width = '700px';
494 stage.style.height = '400px';
495 await new Promise(r => setTimeout(r, 500));
496 const c = window.__term.cell();
497 const box = document.querySelector('.term').getBoundingClientRect();
498 const s = window.__term.size();
499 return { cols: s.cols, rows: s.rows, cw: c.w, ch: c.h, w: box.width, h: box.height,
500 reported: window.__lastResize, attrCols: +document.querySelector('.term').dataset.cols };
501});
502const wantCols = Math.floor((sized.w - 8) / sized.cw);
503const wantRows = Math.floor((sized.h - 8) / sized.ch);
504check(sized.cols === wantCols && sized.rows === wantRows,
505 `the box becomes columns and rows by MEASURING the font: ${sized.w.toFixed(1)}×${sized.h.toFixed(1)}px `
506 + `at ${sized.cw.toFixed(3)}×${sized.ch}px per cell → ${sized.cols}×${sized.rows} (arithmetic says ${wantCols}×${wantRows})`);
507check(Math.abs(sized.cw - 8) > 0.01 && sized.cw > 3 && sized.cw < 40,
508 `the cell width is measured rather than assumed: ${sized.cw.toFixed(4)}px, which is not the 8 a guess would give`);
509check(sized.reported && sized.reported.cols === sized.cols && sized.reported.rows === sized.rows,
510 `and the host is TOLD, which is the only way the kernel ever finds out: `
511 + `${JSON.stringify(sized.reported && { cols: sized.reported.cols, rows: sized.reported.rows })}`);
512
513// Debounced: a drag is many resize events and must not be many SIGWINCHes.
514const debounce = await page.evaluate(async () => {
515 const stage = document.getElementById('stage');
516 let count = 0;
517 const seen = new Set();
518 window.__lastResize = null;
519 const t0 = Date.now();
520 for (let i = 0; i < 12; i++) {
521 stage.style.width = (480 + i * 9) + 'px';
522 await new Promise(r => setTimeout(r, 16));
523 }
524 await new Promise(r => setTimeout(r, 400));
525 return { last: window.__lastResize, ms: Date.now() - t0 };
526});
527const settledSize = await page.evaluate(() => window.__term.size());
528check(!!debounce.last && debounce.last.cols === settledSize.cols,
529 `a drag across twelve widths ends with a report of the FINAL size and not of the ones in between: `
530 + `reported ${JSON.stringify(debounce.last && { cols: debounce.last.cols, rows: debounce.last.rows })}, `
531 + `settled at ${JSON.stringify(settledSize)}`);
532
533// ── 8. Selection and copy ───────────────────────────────────────────
534
535await page.evaluate(() => { const s = document.getElementById('stage'); s.style.width = ''; s.style.height = ''; });
536await page.waitForTimeout(400);
537await reset();
538await write('alpha bravo charlie\r\ndelta echo foxtrot\r\n');
539const selText = await page.evaluate(() => {
540 const t = window.__term, s = t.screen;
541 t.screen.setViewOffset(0);
542 // Two full rows, chosen through the same path a drag takes.
543 const cv = document.querySelector('.term-canvas');
544 const box = cv.getBoundingClientRect();
545 const c = t.cell();
546 const at = (col, row) => ({ clientX: box.left + col * c.w + 1, clientY: box.top + row * c.h + 1,
547 button: 0, bubbles: true, detail: 1 });
548 cv.dispatchEvent(new MouseEvent('mousedown', at(0, 0)));
549 window.dispatchEvent(new MouseEvent('mousemove', at(18, 1)));
550 window.dispatchEvent(new MouseEvent('mouseup', at(18, 1)));
551 return t.selection();
552});
553check(selText === 'alpha bravo charlie\ndelta echo foxtrot',
554 `a drag selects what lies between the two cells: ${JSON.stringify(selText)}`);
555
556// AND A REAL MOUSE MUST REACH THE CANVAS TO DO IT. The check above dispatches a
557// MouseEvent AT the canvas element, which bypasses hit-testing entirely -- so it
558// passed for months while a person dragging with an actual mouse selected nothing,
559// because `.term-input` is stretched over the canvas at `inset: 0` and was the
560// topmost thing under the pointer everywhere on the screen. Reported on 2026-08-26
561// as "dragging doesn't select at all", with 111 checks green.
562//
563// `elementFromPoint` is the question the other check cannot ask: not "does the
564// handler work" but "does the pointer get there". Four corners and the middle,
565// because an overlay that covered only part of the screen would be the same bug
566// with a smaller blast radius.
567const onTop = await page.evaluate(() => {
568 const cv = document.querySelector('.term-canvas');
569 const b = cv.getBoundingClientRect();
570 const spots = [
571 ['middle', b.left + b.width / 2, b.top + b.height / 2],
572 ['top-left', b.left + 4, b.top + 4],
573 ['top-right', b.right - 4, b.top + 4],
574 ['bottom-left', b.left + 4, b.bottom - 4],
575 ['bottom-right', b.right - 4, b.bottom - 4],
576 ];
577 return spots.map(([name, x, y]) => {
578 const el = document.elementFromPoint(x, y);
579 return { name, tag: el ? el.tagName.toLowerCase() : 'none',
580 cls: el ? String(el.className || '') : '', isCanvas: el === cv };
581 });
582});
583const blocked = onTop.filter(s => !s.isCanvas);
584check(blocked.length === 0,
585 blocked.length === 0
586 ? `a real pointer reaches the canvas at all five spots, so a drag can select`
587 : `a real pointer does NOT reach the canvas: ${blocked.map(s =>
588 `${s.name} hits <${s.tag} class="${s.cls}">`).join('; ')}`);
589
590// A line the TERMINAL wrapped is one line, and copying it must not invent a newline that
591// would break the path or the command it holds.
592await reset();
593const wrapJoin = await page.evaluate(() => {
594 const t = window.__term;
595 const cols = t.size().cols;
596 const long = '/home/u/' + 'x'.repeat(cols + 10);
597 t.write(long);
598 t._paintNow();
599 t.selectAll();
600 return { text: t.selection(), cols, long };
601});
602check(wrapJoin.text.indexOf(wrapJoin.long) === 0,
603 `a wrapped line copies back as ONE line: ${JSON.stringify(wrapJoin.text.slice(0, 30))}… `
604 + `(${wrapJoin.text.split('\n').length} line(s) for ${wrapJoin.long.length} characters in ${wrapJoin.cols} columns)`);
605
606// Selection is drawn, not merely recorded.
607await reset();
608await write('selected text here');
609const selPixel = await page.evaluate(() => {
610 const cv = document.querySelector('.term-canvas');
611 const g = () => Array.from(cv.getContext('2d').getImageData(2, 2, 1, 1).data).slice(0, 3);
612 const before = g();
613 window.__term.selectAll();
614 const after = g();
615 window.__term.clearSelection();
616 return { before, after };
617});
618check(JSON.stringify(selPixel.before) !== JSON.stringify(selPixel.after),
619 `and the selection is PAINTED: the ground under the first cell went ${selPixel.before} → ${selPixel.after}`);
620
621// ── 9. What a screen reader is given ────────────────────────────────
622
623const a11y = await page.evaluate(() => {
624 const root = document.querySelector('.term');
625 const cv = root.querySelector('.term-canvas');
626 const input = root.querySelector('.term-input');
627 const mirror = root.querySelector('.term-mirror');
628 const log = root.querySelector('[role="log"]');
629 const cs = getComputedStyle(mirror);
630 return {
631 canvasHidden: cv.getAttribute('aria-hidden'),
632 inputTag: input.tagName,
633 inputLabel: input.getAttribute('aria-label'),
634 describedBy: input.getAttribute('aria-describedby'),
635 hintText: (document.getElementById(input.getAttribute('aria-describedby')) || {}).textContent || '',
636 mirrorRole: mirror.getAttribute('role'),
637 mirrorLabel: mirror.getAttribute('aria-label'),
638 mirrorDisplay: cs.display,
639 mirrorVisibility: cs.visibility,
640 logLive: log && log.getAttribute('aria-live'),
641 };
642});
643check(a11y.canvasHidden === 'true',
644 `the canvas is kept OUT of the accessibility tree: aria-hidden=${a11y.canvasHidden} — a picture of a terminal is nothing to a screen reader, and announcing it as "canvas" is worse than silence`);
645check(a11y.inputTag === 'TEXTAREA' && !!a11y.inputLabel && a11y.inputLabel !== 'term.label',
646 `the focusable control is a real field with a spoken name: <${a11y.inputTag}> "${a11y.inputLabel}"`);
647check(!!a11y.describedBy && a11y.hintText.length > 40,
648 `and it is described, so the shortcuts are discoverable by ear: ${JSON.stringify(a11y.hintText.slice(0, 60))}…`);
649check(a11y.mirrorRole === 'region' && !!a11y.mirrorLabel
650 && a11y.mirrorDisplay !== 'none' && a11y.mirrorVisibility !== 'hidden',
651 `the screen is mirrored as ordinary text a reader can browse: role=${a11y.mirrorRole} `
652 + `label=${JSON.stringify(a11y.mirrorLabel)} display=${a11y.mirrorDisplay} — clipped, NOT display:none, `
653 + `because either of those would take it out of the tree as well`);
654check(a11y.logLive === 'polite', `and there is a polite log for what has just happened: aria-live=${a11y.logLive}`);
655
656await reset();
657await write('the first line\r\nthe second line\r\n');
658await page.waitForTimeout(900); // past the settle
659const mirrored = await page.evaluate(() => ({
660 text: document.querySelector('.term-mirror').textContent,
661 said: [...document.querySelectorAll('[role="log"] p')].map(p => p.textContent),
662}));
663check(/the first line/.test(mirrored.text) && /the second line/.test(mirrored.text),
664 `the mirror holds what the screen holds: ${JSON.stringify(mirrored.text.trim().slice(0, 40))}…`);
665check(mirrored.said.length === 1 && /the first line/.test(mirrored.said[0]),
666 `the log speaks ONCE, after the output settles: ${JSON.stringify(mirrored.said)}`);
667
668// A torrent must not be recited. This is the same reasoning the guide gives for announcing
669// an answer once rather than as it is typed.
670await reset();
671await page.evaluate(() => { window.__flood(2000); });
672await page.waitForTimeout(1200);
673const torrent = await page.evaluate(() => [...document.querySelectorAll('[role="log"] p')].map(p => p.textContent));
674check(torrent.length <= 2 && /2000|\d+ lines/.test(torrent.join(' ')),
675 `two thousand lines produce a summary, not a recital: ${torrent.length} announcement(s), `
676 + `${JSON.stringify((torrent[torrent.length - 1] || '').slice(0, 70))}…`);
677
678// Focus, drawn outside the control against the surface behind it.
679await page.focus('.term-input');
680await page.waitForTimeout(80);
681const ring = await page.evaluate(() => {
682 const cs = getComputedStyle(document.querySelector('.term'));
683 return { style: cs.outlineStyle, width: cs.outlineWidth, offset: cs.outlineOffset, colour: cs.outlineColor };
684});
685check(ring.style !== 'none' && parseFloat(ring.width) >= 2 && parseFloat(ring.offset) > 0,
686 `focus is a ring OUTSIDE the control, standing off from its edge: ${JSON.stringify(ring)}`);
687await page.screenshot({ path: path.join(SHOTS, 'focus.png') });
688
689// ── 10. The palette, on all eleven ──────────────────────────────────
690
691const THEMES = {
692 light: 'dark', mist: 'dark', linen: 'dark', lollypop: 'dark', sage: 'dark',
693 dusk: 'light', dark: 'light', amber: 'light', midnight: 'light', forest: 'light', plum: 'light',
694};
695const lum = ([r, g, b]) => {
696 const f = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
697 return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
698};
699const ratio = (a, b) => { const [x, y] = lum(a) > lum(b) ? [lum(a), lum(b)] : [lum(b), lum(a)]; return (x + 0.05) / (y + 0.05); };
700
701const CHROMATIC = [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14];
702const GREY = [0, 8, 7, 15];
703const lowest = [];
704const axisBad = [];
705for (const [theme, ink] of Object.entries(THEMES)) {
706 const vals = await page.evaluate(({ theme, ink }) => {
707 document.documentElement.setAttribute('data-theme', theme);
708 document.documentElement.setAttribute('data-ink', ink);
709 const el = document.querySelector('.term');
710 const cs = getComputedStyle(el);
711 const resolve = (v) => {
712 const d = document.createElement('div');
713 d.style.color = v; document.body.appendChild(d);
714 const got = getComputedStyle(d).color; d.remove();
715 return (got.match(/\d+/g) || []).slice(0, 3).map(Number);
716 };
717 const ansi = [], ansiBg = [];
718 for (let i = 0; i < 16; i++) {
719 ansi.push(resolve(cs.getPropertyValue('--term-ansi-' + i).trim()));
720 const o = cs.getPropertyValue('--term-ansi-bg-' + i).trim();
721 ansiBg.push(o ? resolve(o) : ansi[i]);
722 }
723 return { ansi, ansiBg, bg: resolve(cs.getPropertyValue('--term-bg').trim()) };
724 }, { theme, ink });
725 let worst = 99, worstAt = -1;
726 for (let i = 0; i < 16; i++) {
727 const r = ratio(vals.ansi[i], vals.bg);
728 if (r < worst) { worst = r; worstAt = i; }
729 }
730 lowest.push({ theme, worst, worstAt });
731 // The BACKGROUND table is where the black-to-white axis has to survive, because a
732 // program filling a status bar with colour 15 means white and a program filling one
733 // with colour 0 means black. The foreground table is deliberately not an axis on a
734 // paper ground -- see terminal.css -- so it is held to readability instead, above.
735 const L = GREY.map(i => lum(vals.ansiBg[i]));
736 const rising = L[0] < L[1] && L[1] < L[2] && L[2] < L[3];
737 const steps = [ratio(vals.ansiBg[0], vals.ansiBg[8]), ratio(vals.ansiBg[8], vals.ansiBg[7]),
738 ratio(vals.ansiBg[7], vals.ansiBg[15])];
739 if (!rising || steps.some(s => s < 1.25)) {
740 axisBad.push(`${theme}: order=${rising ? 'rising' : 'BROKEN'} steps=${steps.map(s => s.toFixed(2)).join(',')}`);
741 }
742}
743const floor = Math.min(...lowest.map(l => l.worst));
744check(floor >= 3.0,
745 `every one of the sixteen named colours clears 3.0 AS LETTERING against every palette's own ground: worst is `
746 + lowest.slice().sort((a, b) => a.worst - b.worst).slice(0, 3)
747 .map(l => `${l.theme} colour ${l.worstAt} at ${l.worst.toFixed(2)}`).join(', '));
748check(axisBad.length === 0,
749 `and AS A GROUND the black-to-white axis stays in order and stepped on all eleven`
750 + `${axisBad.length ? ': ' + axisBad.join('; ') : ''}`);
751
752// A palette change repaints, rather than leaving the last palette's pixels on screen.
753await page.evaluate(() => {
754 document.documentElement.setAttribute('data-theme', 'dark');
755 document.documentElement.setAttribute('data-ink', 'light');
756});
757await page.waitForTimeout(200);
758await reset();
759await write('\x1b[31msome red text\x1b[0m');
760const beforeTheme = await pixelAt(0, 0, 0.1, 0.5);
761await page.evaluate(() => {
762 document.documentElement.setAttribute('data-theme', 'light');
763 document.documentElement.setAttribute('data-ink', 'dark');
764});
765await page.waitForTimeout(300);
766const afterTheme = await pixelAt(0, 0, 0.1, 0.5);
767check(!near(beforeTheme, afterTheme, 20),
768 `the drawn pixels follow a palette change with no redraw asked for: ground went ${beforeTheme} → ${afterTheme}`);
769await page.screenshot({ path: path.join(SHOTS, 'light.png') });
770await page.evaluate(() => {
771 document.documentElement.setAttribute('data-theme', 'dark');
772 document.documentElement.setAttribute('data-ink', 'light');
773});
774await page.waitForTimeout(200);
775
776// ── 11. A phone ─────────────────────────────────────────────────────
777
778await page.setViewportSize({ width: 390, height: 780 });
779await page.evaluate(() => { document.getElementById('stage').style.height = '50vh'; });
780await page.waitForTimeout(500);
781await reset();
782await play('ls');
783await page.waitForTimeout(300);
784const phone = await page.evaluate(() => ({
785 cols: window.__term.size().cols,
786 scrollW: document.documentElement.scrollWidth,
787 clientW: document.documentElement.clientWidth,
788 canvasW: document.querySelector('.term-canvas').getBoundingClientRect().width,
789 termW: document.querySelector('.term').getBoundingClientRect().width,
790}));
791check(phone.scrollW <= phone.clientW,
792 `at phone width the page does not scroll sideways: scrollWidth ${phone.scrollW} vs clientWidth ${phone.clientW}`);
793check(phone.canvasW <= phone.termW && phone.cols >= 20,
794 `and the grid fits the panel rather than the panel fitting the grid: ${phone.cols} columns, `
795 + `canvas ${phone.canvasW.toFixed(1)}px inside a ${phone.termW.toFixed(1)}px panel`);
796await page.screenshot({ path: path.join(SHOTS, 'phone.png') });
797await page.setViewportSize({ width: 1200, height: 800 });
798await page.waitForTimeout(500);
799
800// ── 12. Self-tests: each of these checks is proved on broken code ────
801//
802// Every property below is broken in the live page, the check that guards it is required to
803// go RED, and then it is restored and required to go green. Freezing a check that cannot
804// fail into a suite is worse than having no check, because it reads as cover.
805
806await reset();
807
808// (a) The pixel check would notice a renderer that stopped drawing.
809await proved('the pixel check catches a renderer that stops painting',
810 // The break is at the drawing itself rather than at the entry point: a stubbed
811 // `_paintNow` would be quietly covered by the animation frame the write already
812 // scheduled, and the check would pass over a renderer that had stopped.
813 () => page.evaluate(() => {
814 window.__savedFillRect = CanvasRenderingContext2D.prototype.fillRect;
815 CanvasRenderingContext2D.prototype.fillRect = function () {};
816 }),
817 async () => {
818 await page.evaluate(() => {
819 window.__term.reset();
820 window.__term.write('\x1b[48;5;1m \x1b[0m');
821 window.__term._paintNow();
822 });
823 await page.waitForTimeout(60);
824 const px = await pixelAt(0, 0);
825 const want = await cssColour('--term-ansi-1');
826 return near(px, want);
827 },
828 () => page.evaluate(() => {
829 CanvasRenderingContext2D.prototype.fillRect = window.__savedFillRect;
830 window.__term._paintNow();
831 }));
832
833// (b) The key mapping check would notice an arrow that sent the wrong bytes.
834await proved('the key check catches an arrow sending the wrong bytes',
835 () => page.evaluate(() => {
836 window.__realOnData = window.__sentRaw;
837 window.__breakArrow = true;
838 const ta = document.querySelector('.term-input');
839 window.__evilKey = (e) => {
840 if (e.key === 'ArrowUp' && window.__breakArrow) {
841 e.stopImmediatePropagation();
842 e.preventDefault();
843 window.__sentRaw.push([0x1b, 0x5b, 0x42]); // down, not up
844 }
845 };
846 ta.addEventListener('keydown', window.__evilKey, true);
847 }),
848 async () => {
849 const got = await typed('ArrowUp');
850 return JSON.stringify(got) === JSON.stringify(bytesOf('\x1b[A'));
851 },
852 () => page.evaluate(() => {
853 window.__breakArrow = false;
854 document.querySelector('.term-input').removeEventListener('keydown', window.__evilKey, true);
855 }));
856
857// (c) The bracketed-paste check would notice the wrapper being dropped.
858await proved('the paste check catches bracketed paste being sent bare',
859 // The break is the program's request never arriving, which is the realistic
860 // failure: a parser that dropped DECSET 2004 would look exactly like this.
861 () => page.evaluate(() => {
862 window.__savedWrite = window.__term.write;
863 window.__term.write = function (d) {
864 return window.__savedWrite(typeof d === 'string' ? d.split('\x1b[?2004h').join('') : d);
865 };
866 }),
867 async () => {
868 await reset();
869 await page.evaluate(() => { window.__term.write('\x1b[?2004h'); window.__clearSent(); });
870 await pasteEvent('one\ntwo');
871 await page.waitForTimeout(60);
872 const got = await page.evaluate(() => window.__sentRaw.flat().map(b => String.fromCharCode(b)).join(''));
873 return got === '\x1b[200~one\rtwo\x1b[201~';
874 },
875 () => page.evaluate(() => { window.__term.write = window.__savedWrite; }));
876
877// (d) The multi-line guard would notice the question being skipped.
878await proved('the paste-guard check catches a multi-line paste going straight through',
879 // Broken by making the terminal believe the program asked for bracketed paste when it
880 // did not -- which is the same thing as the guard being skipped, from the outside.
881 () => page.evaluate(() => {
882 window.__savedReset = window.__term.reset;
883 window.__term.reset = function () { window.__savedReset(); window.__term.screen.modes.bracketed = true; };
884 }),
885 async () => {
886 await reset();
887 await page.evaluate(() => { window.__clearSent(); });
888 await pasteEvent('echo a\necho b\necho c');
889 await page.waitForTimeout(80);
890 return await page.evaluate(() => window.__sentRaw.flat().length === 0
891 && !document.querySelector('.term-paste').hidden);
892 },
893 () => page.evaluate(() => { window.__term.reset = window.__savedReset; }));
894await page.evaluate(() => { const p = document.querySelector('.term-paste'); p.hidden = true; p.innerHTML = ''; });
895
896// (e) The accessibility check would notice the mirror being hidden the wrong way.
897await proved('the mirror check catches display:none, which empties the accessibility tree',
898 () => page.evaluate(() => { document.querySelector('.term-mirror').style.display = 'none'; }),
899 () => page.evaluate(() => {
900 const m = document.querySelector('.term-mirror');
901 const cs = getComputedStyle(m);
902 return m.getAttribute('role') === 'region' && !!m.getAttribute('aria-label')
903 && cs.display !== 'none' && cs.visibility !== 'hidden';
904 }),
905 () => page.evaluate(() => { document.querySelector('.term-mirror').style.display = ''; }));
906
907// (f) The focus-ring check would notice the ring moving inside the control.
908await proved('the focus check catches a ring drawn with no offset',
909 () => page.evaluate(() => {
910 window.__ringStyle = document.createElement('style');
911 window.__ringStyle.textContent = '.term:focus-within { outline-offset: 0 !important; }';
912 document.head.appendChild(window.__ringStyle);
913 }),
914 async () => {
915 await page.focus('.term-input');
916 return await page.evaluate(() => {
917 const cs = getComputedStyle(document.querySelector('.term'));
918 return cs.outlineStyle !== 'none' && parseFloat(cs.outlineWidth) >= 2 && parseFloat(cs.outlineOffset) > 0;
919 });
920 },
921 () => page.evaluate(() => { window.__ringStyle.remove(); }));
922
923// (g) The damage check would notice a model that marked everything dirty.
924await proved('the damage check catches a model that repaints the whole grid on every byte',
925 () => page.evaluate(() => {
926 const s = window.__term.screen;
927 window.__savedCompose = s.compose;
928 s.compose = function () { const d = window.__savedCompose.call(s); d.all = true; return d; };
929 }),
930 () => page.evaluate(() => {
931 const s = window.__term.screen;
932 s.compose();
933 s.write('!');
934 const d = s.compose();
935 let rows = 0;
936 for (let i = 0; i < d.rows.length; i++) if (d.rows[i]) rows++;
937 return !d.all && rows === 1;
938 }),
939 () => page.evaluate(() => { window.__term.screen.compose = window.__savedCompose; }));
940
941// (h) The scroll-blit check would notice a blit that drew the wrong pixels.
942await proved('the blit check catches a scroll that shifts by the wrong number of rows',
943 () => page.evaluate(() => {
944 const s = window.__term.screen;
945 window.__savedCompose2 = s.compose;
946 s.compose = function () { const d = window.__savedCompose2.call(s); if (d.scrolled) d.scrolled += 1; return d; };
947 }),
948 () => page.evaluate(() => {
949 const cv = document.querySelector('.term-canvas');
950 const t = window.__term;
951 t.reset();
952 for (let i = 0; i < 40; i++) t.write('\x1b[3' + (i % 8) + 'mrow ' + i + ' with text\x1b[0m\r\n');
953 t._paintNow();
954 const grab = () => cv.getContext('2d').getImageData(0, 0, cv.width, cv.height).data;
955 t.write('\x1b[36ma newly scrolled line\x1b[0m\r\n');
956 t._paintFrame();
957 const a = Array.from(grab());
958 t._paintNow();
959 const b = Array.from(grab());
960 let diff = 0;
961 for (let i = 0; i < b.length; i += 4) if (Math.abs(b[i] - a[i]) > 2) diff++;
962 return diff === 0;
963 }),
964 () => page.evaluate(() => { window.__term.screen.compose = window.__savedCompose2; }));
965
966// (i) The size check would notice a cell width that was assumed rather than measured.
967await proved('the size check catches a grid computed from a guessed cell width',
968 () => page.evaluate(() => {
969 window.__savedCell = window.__term.cell;
970 window.__term.cell = function () { return { w: 8, h: 16 }; };
971 }),
972 () => page.evaluate(() => {
973 const c = window.__term.cell();
974 return Math.abs(c.w - 8) > 0.01 && c.w > 3 && c.w < 40;
975 }),
976 () => page.evaluate(() => { window.__term.cell = window.__savedCell; }));
977
978// (j) The selection check would notice a copy that lost the second row.
979await proved('the selection check catches a copy that stops at the first row',
980 () => page.evaluate(() => {
981 window.__savedSel = window.__term.selection;
982 window.__term.selection = function () { return window.__savedSel().split('\n')[0]; };
983 }),
984 async () => {
985 await reset();
986 await write('alpha bravo charlie\r\ndelta echo foxtrot\r\n');
987 return await page.evaluate(() => {
988 const t = window.__term, cv = document.querySelector('.term-canvas');
989 const box = cv.getBoundingClientRect(), c = t.cell();
990 const at = (col, row) => ({ clientX: box.left + col * c.w + 1, clientY: box.top + row * c.h + 1, button: 0, bubbles: true, detail: 1 });
991 cv.dispatchEvent(new MouseEvent('mousedown', at(0, 0)));
992 window.dispatchEvent(new MouseEvent('mousemove', at(18, 1)));
993 window.dispatchEvent(new MouseEvent('mouseup', at(18, 1)));
994 return t.selection() === 'alpha bravo charlie\ndelta echo foxtrot';
995 });
996 },
997 () => page.evaluate(() => { window.__term.selection = window.__savedSel; }));
998
999// (k) The announcement check would notice a live region reciting every line.
1000await proved('the announcement check catches a log that recites a build',
1001 () => page.evaluate(() => {
1002 const log = document.querySelector('[role="log"]');
1003 window.__spam = setInterval(() => {
1004 const p = document.createElement('p'); p.textContent = 'chatter'; log.appendChild(p);
1005 }, 40);
1006 }),
1007 async () => {
1008 await page.evaluate(() => { document.querySelector('[role="log"]').innerHTML = ''; window.__flood(2000); });
1009 await page.waitForTimeout(1100);
1010 return await page.evaluate(() => document.querySelectorAll('[role="log"] p').length <= 2);
1011 },
1012 () => page.evaluate(() => { clearInterval(window.__spam); document.querySelector('[role="log"]').innerHTML = ''; }));
1013
1014// ── 13. Throughput, reported rather than asserted ───────────────────
1015//
1016// A number that depends on the machine is not a pass/fail, so it is printed. The one thing
1017// asserted is that the load case does not take longer than the frame it is drawn in.
1018
1019const bench = await page.evaluate(() => window.__bench());
1020const notes = Object.entries(bench).map(([k, v]) => ` ${k.padEnd(26)} ${(+v).toFixed(2)}`);
1021check(bench['canvas scroll 200x50'] < 16.7,
1022 `a build log's frame — 200×50, one more line, blitted — fits inside a 60 Hz frame: `
1023 + `${bench['canvas scroll 200x50'].toFixed(2)} ms`);
1024check(bench['canvas full 200x50'] < bench['dom rebuild 200x50'],
1025 `and a full canvas repaint beats the DOM alternative at the same grid: `
1026 + `${bench['canvas full 200x50'].toFixed(2)} ms vs ${bench['dom rebuild 200x50'].toFixed(2)} ms`);
1027
1028// ── 14. Every recording plays, and none of them throws ──────────────
1029
1030for (const [name, what] of FIXTURES) {
1031 await reset();
1032 const before = errs.length;
1033 await play(name);
1034 await page.waitForTimeout(120);
1035 const drew = await page.evaluate(() => {
1036 const s = window.__term.screen;
1037 let ink = 0;
1038 for (let y = 0; y < s.rows; y++) if (s.lineText(s.absOfRow(y)).trim()) ink++;
1039 return ink;
1040 });
1041 // `less` is the one that should leave NOTHING behind: it draws on the alternate
1042 // screen and gives the primary one back exactly as it found it, which on a screen
1043 // this test cleared first means empty.
1044 const restores = name === 'less';
1045 check((restores ? drew === 0 : drew > 0) && errs.length === before,
1046 `${name} — ${what} — plays into ${drew} non-empty row(s)`
1047 + `${restores ? ', which is the alternate screen giving the primary one back untouched' : ''}`
1048 + ` and throws nothing`);
1049}
1050
1051// ── The console ─────────────────────────────────────────────────────
1052
1053const noise = /favicon|404 \(Not Found\)/;
1054const real = errs.filter(e => !noise.test(e));
1055check(real.length === 0, `no console errors${real.length ? ': ' + JSON.stringify(real.slice(0, 3)) : ''}`);
1056
1057await browser.close();
1058server.close();
1059
1060console.log('\n' + out.join('\n'));
1061console.log(`\n Measured on this machine (headless Chromium, software rasterisation, dpr 1):`);
1062console.log(notes.join('\n'));
1063console.log(`\n Screenshots: ${SHOTS}`);
1064console.log(bad === 0 ? `\nALL ${n} CHECKS PASSED (${selfTests.length} of them proved on broken code)`
1065 : `\n${bad} of ${n} FAILED`);
1066process.exit(bad === 0 ? 0 : 1);