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 | |
| 23 | import fs from 'node:fs'; |
| 24 | import os from 'node:os'; |
| 25 | import path from 'node:path'; |
| 26 | import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 27 | import { start, FIXTURES } from './termdemo.mjs'; |
| 28 | |
| 29 | const PW = process.env.DAIMOND_PW |
| 30 | || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 31 | const { chromium } = await import(pathToFileURL(PW).href); |
| 32 | const CHROME = process.env.DAIMOND_CHROME |
| 33 | || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`; |
| 34 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 35 | const SHOTS = path.join(os.homedir(), '.cache/daimond/term-shots'); |
| 36 | fs.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. |
| 44 | const WORLD = Number(process.env.DAIMOND_PORT || 8777) - 8777; |
| 45 | const PORT = Number(process.env.TERMDEMO_PORT || (8940 + WORLD)); |
| 46 | |
| 47 | let bad = 0, n = 0; |
| 48 | const out = []; |
| 49 | const 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. |
| 56 | const selfTests = []; |
| 57 | const 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 | |
| 67 | const server = start(PORT); |
| 68 | const browser = await chromium.launch({ executablePath: CHROME, args: ['--no-sandbox', '--headless=new'] }); |
| 69 | const page = await browser.newPage({ viewport: { width: 1200, height: 800 }, deviceScaleFactor: 1 }); |
| 70 | const errs = []; |
| 71 | page.on('console', m => { if (m.type() === 'error') errs.push(m.text()); }); |
| 72 | page.on('pageerror', e => errs.push('pageerror: ' + e.message)); |
| 73 | await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'networkidle' }); |
| 74 | await page.waitForTimeout(400); |
| 75 | |
| 76 | const reset = () => page.evaluate(() => { window.__term.reset(); window.__clearSent(); window.__shell = false; }); |
| 77 | const write = (s) => page.evaluate(t => { window.__term.write(t); window.__term._paintNow(); }, s); |
| 78 | const play = (name) => page.evaluate(nm => window.__play(nm, 0), name); |
| 79 | |
| 80 | // ── 1. The grid: bytes become cells ───────────────────────────────── |
| 81 | |
| 82 | await reset(); |
| 83 | await 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'); |
| 84 | const 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 | }); |
| 92 | check(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 | |
| 96 | await reset(); |
| 97 | await write('\x1b[31mA\x1b[38;5;208mB\x1b[38;2;18;52;86mC\x1b[38:2::200:100:50mD\x1b[48;5;21mE'); |
| 98 | const 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 | }); |
| 103 | check(cols.a.mode === 1 && cols.a.val === 1, `a named colour is indexed: ${JSON.stringify(cols.a)} (wanted mode 1, index 1)`); |
| 104 | check(cols.b.mode === 1 && cols.b.val === 208, `a 256-palette colour keeps its index: ${JSON.stringify(cols.b)}`); |
| 105 | check(cols.c.mode === 2 && cols.c.val === 0x123456, `24-bit, semicolon form: ${cols.c.val.toString(16)} (wanted 123456)`); |
| 106 | check(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)`); |
| 108 | check(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]. |
| 116 | const 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. |
| 126 | const 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 | |
| 137 | const near = (a, b, tol = 6) => a && b && a.every((v, i) => Math.abs(v - b[i]) <= tol); |
| 138 | |
| 139 | await 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. |
| 142 | await write([...Array(16).keys()].map(i => `\x1b[48;5;${i}m \x1b[0m`).join('')); |
| 143 | let wrongColour = []; |
| 144 | for (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 | } |
| 150 | check(wrongColour.length === 0, |
| 151 | `all sixteen named backgrounds are painted in the stylesheet's own colours${wrongColour.length ? ': ' + wrongColour.join('; ') : ''}`); |
| 152 | |
| 153 | await reset(); |
| 154 | await write('\x1b[48;2;18;52;86m \x1b[48;5;208m \x1b[0m'); |
| 155 | const trueCol = await pixelAt(0, 0), cubeCol = await pixelAt(1, 0); |
| 156 | check(near(trueCol, [18, 52, 86]), `a 24-bit background is painted exactly: ${trueCol} (wanted 18,52,86)`); |
| 157 | check(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. |
| 160 | await reset(); |
| 161 | await write('\x1b[31;7mX\x1b[0m'); |
| 162 | const revBg = await pixelAt(0, 0, 0.5, 0.9); // below the glyph, still inside the cell |
| 163 | const red = await cssColour('--term-ansi-1'); |
| 164 | check(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 | |
| 168 | await reset(); |
| 169 | await play('colours'); |
| 170 | await page.waitForTimeout(250); |
| 171 | await page.evaluate(() => window.__term._paintNow()); |
| 172 | await page.screenshot({ path: path.join(SHOTS, 'colours.png') }); |
| 173 | const 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 | }); |
| 186 | check(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 | |
| 188 | await reset(); |
| 189 | await play('top'); |
| 190 | await page.waitForTimeout(250); |
| 191 | const 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 | }); |
| 195 | check(/^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. |
| 201 | await reset(); |
| 202 | await write('the shell was here\r\n'); |
| 203 | const ALT_ENTER = 1253; // where less.bin leaves the alternate screen |
| 204 | const 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); |
| 211 | check(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. |
| 215 | const pagerLine = pager.rows.find(r => /serve\.mjs/.test(r)); |
| 216 | check(!!pagerLine, `and draws the file there: ${JSON.stringify((pagerLine || '').slice(0, 46))}`); |
| 217 | check(pager.sb === 0, `the alternate screen adds nothing to the scrollback: ${pager.sb} line(s) — a redraw is not a transcript`); |
| 218 | const 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); |
| 223 | check(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 | |
| 227 | await reset(); |
| 228 | await play('bar'); |
| 229 | await page.waitForTimeout(250); |
| 230 | const 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 | }); |
| 236 | check(/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 | |
| 239 | await reset(); |
| 240 | await play('ask'); |
| 241 | await page.waitForTimeout(200); |
| 242 | const ask = await page.evaluate(() => { |
| 243 | const s = window.__term.screen; |
| 244 | return [0, 1, 2, 3].map(y => s.lineText(s.absOfRow(y))); |
| 245 | }); |
| 246 | check(/\[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 | |
| 251 | await reset(); |
| 252 | await write('hello\r\nworld\r\n'); |
| 253 | const 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 | }); |
| 262 | check(!dmg.all && dmg.rows === 1, `one character damages exactly one row: ${JSON.stringify(dmg)}`); |
| 263 | |
| 264 | const 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 | }); |
| 273 | check(!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. |
| 278 | await reset(); |
| 279 | await write('\x1b[H'); |
| 280 | for (let i = 0; i < 40; i++) await write(`\x1b[3${i % 8}mrow ${i} with some text on it\x1b[0m\r\n`); |
| 281 | const 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 | }); |
| 296 | check(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 | |
| 301 | const 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 | }; |
| 309 | const bytesOf = (s) => Array.from(new TextEncoder().encode(s)); |
| 310 | const 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 | |
| 318 | await reset(); |
| 319 | await keyIs('Enter', '\r', 'Enter (CR, not LF — the line discipline makes the newline)'); |
| 320 | await keyIs('Backspace', '\x7f', 'Backspace (DEL, not BS)'); |
| 321 | await keyIs('Control+Backspace', '\x08', 'Ctrl-Backspace (BS: readline\'s delete-word)'); |
| 322 | await keyIs('Tab', '\t'); |
| 323 | await keyIs('Shift+Tab', '\x1b[Z', 'Shift-Tab (CBT)'); |
| 324 | await keyIs('Escape', '\x1b'); |
| 325 | await keyIs('ArrowUp', '\x1b[A'); |
| 326 | await keyIs('ArrowDown', '\x1b[B'); |
| 327 | await keyIs('ArrowRight', '\x1b[C'); |
| 328 | await keyIs('ArrowLeft', '\x1b[D'); |
| 329 | await keyIs('Home', '\x1b[H'); |
| 330 | await keyIs('End', '\x1b[F'); |
| 331 | await keyIs('PageUp', '\x1b[5~'); |
| 332 | await keyIs('PageDown', '\x1b[6~'); |
| 333 | await keyIs('Delete', '\x1b[3~'); |
| 334 | await keyIs('Insert', '\x1b[2~'); |
| 335 | await keyIs('Control+c', '\x03', 'Ctrl-C (ETX — the interrupt, and it must never be the copy)'); |
| 336 | await keyIs('Control+d', '\x04', 'Ctrl-D (EOT)'); |
| 337 | await keyIs('Control+z', '\x1a', 'Ctrl-Z (SUB)'); |
| 338 | await keyIs('Control+l', '\x0c', 'Ctrl-L'); |
| 339 | await keyIs('Control+Space', '\x00', 'Ctrl-Space (NUL)'); |
| 340 | await keyIs('Control+[', '\x1b', 'Ctrl-['); |
| 341 | await keyIs('Control+\\', '\x1c', 'Ctrl-\\ (QUIT)'); |
| 342 | await keyIs('F1', '\x1bOP'); |
| 343 | await keyIs('F4', '\x1bOS'); |
| 344 | await keyIs('F5', '\x1b[15~'); |
| 345 | await keyIs('F12', '\x1b[24~'); |
| 346 | await keyIs('Control+ArrowRight', '\x1b[1;5C', 'Ctrl-Right (the modifier is a parameter, not a different key)'); |
| 347 | await keyIs('Shift+ArrowLeft', '\x1b[1;2D', 'Shift-Left'); |
| 348 | await keyIs('Alt+ArrowUp', '\x1b[1;3A', 'Alt-Up'); |
| 349 | await keyIs('Alt+b', '\x1bb', 'Alt-b (ESC prefix — readline\'s back-word)'); |
| 350 | await 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. |
| 354 | await write('\x1b[?1h'); |
| 355 | await keyIs('ArrowUp', '\x1bOA', 'ArrowUp in application cursor mode'); |
| 356 | await keyIs('Home', '\x1bOH', 'Home in application cursor mode'); |
| 357 | await keyIs('Control+ArrowUp', '\x1b[1;5A', 'a MODIFIED arrow stays CSI even in application mode'); |
| 358 | await write('\x1b[?1l'); |
| 359 | await keyIs('ArrowUp', '\x1b[A', 'ArrowUp back in normal mode'); |
| 360 | |
| 361 | // UTF-8, not a code unit. |
| 362 | await page.evaluate(() => window.__clearSent()); |
| 363 | await page.keyboard.type('é'); |
| 364 | await page.waitForTimeout(30); |
| 365 | const acc = await page.evaluate(() => window.__sentRaw.flat()); |
| 366 | check(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. |
| 370 | await page.evaluate(() => window.__clearSent()); |
| 371 | await write('\x1b[10;5H\x1b[6n'); |
| 372 | await page.waitForTimeout(50); |
| 373 | const dsr = await page.evaluate(() => window.__sentRaw.flat().map(b => String.fromCharCode(b)).join('')); |
| 374 | check(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. |
| 380 | const 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 | |
| 387 | await reset(); |
| 388 | await write('\x1b[?2004h'); // the program asks to be told |
| 389 | await pasteEvent('one\ntwo\nthree'); |
| 390 | await page.waitForTimeout(60); |
| 391 | const bracketed = await page.evaluate(() => window.__sentRaw.flat().map(b => String.fromCharCode(b)).join('')); |
| 392 | check(bracketed === '\x1b[200~one\rtwo\rthree\x1b[201~', |
| 393 | `a program that asked for bracketed paste gets it wrapped: ${JSON.stringify(bracketed)}`); |
| 394 | |
| 395 | await page.evaluate(() => window.__clearSent()); |
| 396 | await pasteEvent('a\x1b[201~; rm -rf /\nb'); |
| 397 | await page.waitForTimeout(60); |
| 398 | const spoof = await page.evaluate(() => window.__sentRaw.flat().map(b => String.fromCharCode(b)).join('')); |
| 399 | check(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 | |
| 402 | await reset(); |
| 403 | await write('\x1b[?2004l'); // the program did NOT ask |
| 404 | await pasteEvent('echo one\necho two\necho three'); |
| 405 | await page.waitForTimeout(80); |
| 406 | const 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 | })); |
| 413 | check(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'}`); |
| 416 | check(/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)}`); |
| 419 | check(/term-paste-btn/.test(asked.focused || ''), |
| 420 | `focus moves into the question, so it can be answered from the keyboard: ${asked.focused}`); |
| 421 | await page.click('.term-paste-btn.primary'); |
| 422 | await page.waitForTimeout(60); |
| 423 | const firstOnly = await page.evaluate(() => window.__sentRaw.flat().map(b => String.fromCharCode(b)).join('')); |
| 424 | check(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. |
| 431 | await reset(); |
| 432 | const 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 | }); |
| 441 | check(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. |
| 448 | await reset(); |
| 449 | await write('alpha bravo\r\n'); |
| 450 | const 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 | }); |
| 466 | check(ctrlC.withNone.sent === '\x03', |
| 467 | `Ctrl-C with nothing selected still sends the interrupt: ${JSON.stringify(ctrlC.withNone.sent)}`); |
| 468 | check(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. |
| 474 | await page.evaluate(() => { |
| 475 | document.querySelectorAll('[role="log"] p').forEach(p => p.remove()); |
| 476 | }); |
| 477 | |
| 478 | await reset(); |
| 479 | await pasteEvent('one line only'); |
| 480 | await page.waitForTimeout(60); |
| 481 | const oneLine = await page.evaluate(() => ({ |
| 482 | sent: window.__sentRaw.flat().map(b => String.fromCharCode(b)).join(''), |
| 483 | shown: !document.querySelector('.term-paste').hidden, |
| 484 | })); |
| 485 | check(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 | |
| 490 | await reset(); |
| 491 | const 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 | }); |
| 502 | const wantCols = Math.floor((sized.w - 8) / sized.cw); |
| 503 | const wantRows = Math.floor((sized.h - 8) / sized.ch); |
| 504 | check(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})`); |
| 507 | check(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`); |
| 509 | check(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. |
| 514 | const 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 | }); |
| 527 | const settledSize = await page.evaluate(() => window.__term.size()); |
| 528 | check(!!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 | |
| 535 | await page.evaluate(() => { const s = document.getElementById('stage'); s.style.width = ''; s.style.height = ''; }); |
| 536 | await page.waitForTimeout(400); |
| 537 | await reset(); |
| 538 | await write('alpha bravo charlie\r\ndelta echo foxtrot\r\n'); |
| 539 | const 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 | }); |
| 553 | check(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. |
| 567 | const 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 | }); |
| 583 | const blocked = onTop.filter(s => !s.isCanvas); |
| 584 | check(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. |
| 592 | await reset(); |
| 593 | const 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 | }); |
| 602 | check(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. |
| 607 | await reset(); |
| 608 | await write('selected text here'); |
| 609 | const 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 | }); |
| 618 | check(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 | |
| 623 | const 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 | }); |
| 643 | check(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`); |
| 645 | check(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}"`); |
| 647 | check(!!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))}…`); |
| 649 | check(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`); |
| 654 | check(a11y.logLive === 'polite', `and there is a polite log for what has just happened: aria-live=${a11y.logLive}`); |
| 655 | |
| 656 | await reset(); |
| 657 | await write('the first line\r\nthe second line\r\n'); |
| 658 | await page.waitForTimeout(900); // past the settle |
| 659 | const mirrored = await page.evaluate(() => ({ |
| 660 | text: document.querySelector('.term-mirror').textContent, |
| 661 | said: [...document.querySelectorAll('[role="log"] p')].map(p => p.textContent), |
| 662 | })); |
| 663 | check(/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))}…`); |
| 665 | check(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. |
| 670 | await reset(); |
| 671 | await page.evaluate(() => { window.__flood(2000); }); |
| 672 | await page.waitForTimeout(1200); |
| 673 | const torrent = await page.evaluate(() => [...document.querySelectorAll('[role="log"] p')].map(p => p.textContent)); |
| 674 | check(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. |
| 679 | await page.focus('.term-input'); |
| 680 | await page.waitForTimeout(80); |
| 681 | const 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 | }); |
| 685 | check(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)}`); |
| 687 | await page.screenshot({ path: path.join(SHOTS, 'focus.png') }); |
| 688 | |
| 689 | // ── 10. The palette, on all eleven ────────────────────────────────── |
| 690 | |
| 691 | const 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 | }; |
| 695 | const 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 | }; |
| 699 | const 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 | |
| 701 | const CHROMATIC = [1, 2, 3, 4, 5, 6, 9, 10, 11, 12, 13, 14]; |
| 702 | const GREY = [0, 8, 7, 15]; |
| 703 | const lowest = []; |
| 704 | const axisBad = []; |
| 705 | for (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 | } |
| 743 | const floor = Math.min(...lowest.map(l => l.worst)); |
| 744 | check(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(', ')); |
| 748 | check(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. |
| 753 | await page.evaluate(() => { |
| 754 | document.documentElement.setAttribute('data-theme', 'dark'); |
| 755 | document.documentElement.setAttribute('data-ink', 'light'); |
| 756 | }); |
| 757 | await page.waitForTimeout(200); |
| 758 | await reset(); |
| 759 | await write('\x1b[31msome red text\x1b[0m'); |
| 760 | const beforeTheme = await pixelAt(0, 0, 0.1, 0.5); |
| 761 | await page.evaluate(() => { |
| 762 | document.documentElement.setAttribute('data-theme', 'light'); |
| 763 | document.documentElement.setAttribute('data-ink', 'dark'); |
| 764 | }); |
| 765 | await page.waitForTimeout(300); |
| 766 | const afterTheme = await pixelAt(0, 0, 0.1, 0.5); |
| 767 | check(!near(beforeTheme, afterTheme, 20), |
| 768 | `the drawn pixels follow a palette change with no redraw asked for: ground went ${beforeTheme} → ${afterTheme}`); |
| 769 | await page.screenshot({ path: path.join(SHOTS, 'light.png') }); |
| 770 | await page.evaluate(() => { |
| 771 | document.documentElement.setAttribute('data-theme', 'dark'); |
| 772 | document.documentElement.setAttribute('data-ink', 'light'); |
| 773 | }); |
| 774 | await page.waitForTimeout(200); |
| 775 | |
| 776 | // ── 11. A phone ───────────────────────────────────────────────────── |
| 777 | |
| 778 | await page.setViewportSize({ width: 390, height: 780 }); |
| 779 | await page.evaluate(() => { document.getElementById('stage').style.height = '50vh'; }); |
| 780 | await page.waitForTimeout(500); |
| 781 | await reset(); |
| 782 | await play('ls'); |
| 783 | await page.waitForTimeout(300); |
| 784 | const 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 | })); |
| 791 | check(phone.scrollW <= phone.clientW, |
| 792 | `at phone width the page does not scroll sideways: scrollWidth ${phone.scrollW} vs clientWidth ${phone.clientW}`); |
| 793 | check(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`); |
| 796 | await page.screenshot({ path: path.join(SHOTS, 'phone.png') }); |
| 797 | await page.setViewportSize({ width: 1200, height: 800 }); |
| 798 | await 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 | |
| 806 | await reset(); |
| 807 | |
| 808 | // (a) The pixel check would notice a renderer that stopped drawing. |
| 809 | await 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. |
| 834 | await 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. |
| 858 | await 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. |
| 878 | await 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; })); |
| 894 | await 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. |
| 897 | await 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. |
| 908 | await 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. |
| 924 | await 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. |
| 942 | await 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. |
| 967 | await 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. |
| 979 | await 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. |
| 1000 | await 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 | |
| 1019 | const bench = await page.evaluate(() => window.__bench()); |
| 1020 | const notes = Object.entries(bench).map(([k, v]) => ` ${k.padEnd(26)} ${(+v).toFixed(2)}`); |
| 1021 | check(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`); |
| 1024 | check(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 | |
| 1030 | for (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 | |
| 1053 | const noise = /favicon|404 \(Not Found\)/; |
| 1054 | const real = errs.filter(e => !noise.test(e)); |
| 1055 | check(real.length === 0, `no console errors${real.length ? ': ' + JSON.stringify(real.slice(0, 3)) : ''}`); |
| 1056 | |
| 1057 | await browser.close(); |
| 1058 | server.close(); |
| 1059 | |
| 1060 | console.log('\n' + out.join('\n')); |
| 1061 | console.log(`\n Measured on this machine (headless Chromium, software rasterisation, dpr 1):`); |
| 1062 | console.log(notes.join('\n')); |
| 1063 | console.log(`\n Screenshots: ${SHOTS}`); |
| 1064 | console.log(bad === 0 ? `\nALL ${n} CHECKS PASSED (${selfTests.length} of them proved on broken code)` |
| 1065 | : `\n${bad} of ${n} FAILED`); |
| 1066 | process.exit(bad === 0 ? 0 : 1); |