oxedyne/daimond/dev/verify_termpanel.mjs
51.0 KiB, 1 run
created by r2519314175:735, 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_termpanel.mjs — the Terminal panel: the joint between the screen and |
| 2 | // the pty, driven in the real app. |
| 3 | // |
| 4 | // Everything under this panel is proved elsewhere. `dev/verify_terminal.mjs` |
| 5 | // proves that bytes become pixels and that a keypress becomes the right bytes; |
| 6 | // `dev/verify_pty.mjs` proves that the relay carries bytes untouched, says so |
| 7 | // when some are missing, and tells a hand that STOPPED from one that was never |
| 8 | // installed. What is proved here is the JOINT: that the panel builds a terminal |
| 9 | // when a person asks for one and not before, that what comes off the wire is |
| 10 | // written to the screen and what is typed goes back as bytes, that the kernel is |
| 11 | // told when the panel changes size, that a hole in the output is shown beside |
| 12 | // the stream, that closing the panel takes the program with it — and, hardest to |
| 13 | // keep true over time, that the page never composes a fence. |
| 14 | // |
| 15 | // ── The two doubles, and why each is honest ───────────────────────── |
| 16 | // |
| 17 | // A machine hand is a program outside the browser and the Rust half of this app |
| 18 | // is compiled into wasm, so neither can be conjured by a test. Two things are |
| 19 | // stood in for, and NOTHING else is: |
| 20 | // |
| 21 | // the LINK. `window.DaimondHand` is replaced by a double that records what was |
| 22 | // sent and replies as a hand would. It supplies no behaviour of handpty.js's: |
| 23 | // every sequence number, every base64 payload and every ending here is put on |
| 24 | // the wire by this file and read back through the real relay. |
| 25 | // |
| 26 | // the RUST ANSWER. `DaimondTerm._setRequestForTest` stands in for the one call |
| 27 | // that reaches Rust for an `open` request. The fence it returns is a FICTION |
| 28 | // and is treated as one: no check in this file asserts anything about that |
| 29 | // fence's contents. What is asserted is that the panel passes the answer |
| 30 | // through unaltered, refuses when there is none, and refuses when the relay |
| 31 | // rejects one — which is the whole of the panel's part in the arrangement. |
| 32 | // |
| 33 | // The static half of the file needs no browser at all: it reads the page's own |
| 34 | // source and holds the two properties a reviewer would otherwise have to take on |
| 35 | // trust — that no fence is composed in JavaScript, and that every string the |
| 36 | // panel says exists in every language the app ships. |
| 37 | // |
| 38 | // ── Proving the checks ────────────────────────────────────────────── |
| 39 | // |
| 40 | // A check that has only ever been seen passing has not been seen working. Every |
| 41 | // property below is put through `proved`: the thing is BROKEN in the live page |
| 42 | // (or in the data), the check is required to go red, the thing is restored, and |
| 43 | // the check is required to go green. Where a property cannot be broken from |
| 44 | // outside — the F6 capture, for one — the proof is a negative control instead: a |
| 45 | // key the panel does not own must NOT do what F6 does, on the same assertion. |
| 46 | // |
| 47 | // node dev/verify_termpanel.mjs |
| 48 | // |
| 49 | // Needs dev/serve.mjs (DAIMOND_PORT, default 8777) and dev/mockllm.mjs |
| 50 | // (DAIMOND_MOCK_PORT, default 9099); both are started here if they are not already up. |
| 51 | // Without the mock provider every model turn fails and the app reads as broken when it |
| 52 | // is only unattended. |
| 53 | |
| 54 | import fs from 'node:fs'; |
| 55 | import net from 'node:net'; |
| 56 | import path from 'node:path'; |
| 57 | import { spawn } from 'node:child_process'; |
| 58 | import { fileURLToPath } from 'node:url'; |
| 59 | |
| 60 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 61 | const ROOT = path.join(HERE, '..'); |
| 62 | |
| 63 | const INDEX = path.join(ROOT, 'www/index.html'); |
| 64 | const APPJS = path.join(ROOT, 'www/js/daimond.js'); |
| 65 | const RELAYJS = path.join(ROOT, 'www/js/handpty.js'); |
| 66 | const I18NDIR = path.join(ROOT, 'www/i18n'); |
| 67 | |
| 68 | const ok = [], bad = []; |
| 69 | const check = (name, pass, detail) => { |
| 70 | (pass ? ok : bad).push(name); |
| 71 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + String(detail).slice(0, 180) : '')); |
| 72 | }; |
| 73 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 74 | |
| 75 | /// A property proved twice: broken, and required to fail; whole, and required to |
| 76 | /// pass. `name` is what is being proved, not what is being done to it. |
| 77 | /// |
| 78 | /// `breakIt` puts the app in the state where the property does NOT hold and |
| 79 | /// `fixIt` puts it in the state where it does; `testIt` only ever LOOKS. That |
| 80 | /// division is the whole value of the thing: a `testIt` that did any of the |
| 81 | /// breaking itself would answer the same in both worlds and prove nothing, which |
| 82 | /// is how the first draft of this file "proved" three checks that were blind. |
| 83 | const provedNames = []; |
| 84 | async function proved(name, breakIt, testIt, fixIt) { |
| 85 | await breakIt(); |
| 86 | let red = false; |
| 87 | try { red = await testIt(); } catch (e) { red = false; } |
| 88 | await fixIt(); |
| 89 | let green = false; |
| 90 | try { green = await testIt(); } catch (e) { green = false; } |
| 91 | provedNames.push(name); |
| 92 | check(`PROVED ${name}: red when broken, green when whole`, |
| 93 | !red && green, |
| 94 | `broken=${red ? 'PASSED — the check is blind' : 'failed, correctly'}, whole=${green ? 'passed' : 'FAILED'}`); |
| 95 | return !red && green; |
| 96 | } |
| 97 | |
| 98 | // ── Phase one: the page's own source ──────────────────────────────── |
| 99 | |
| 100 | const indexSrc = fs.readFileSync(INDEX, 'utf8'); |
| 101 | const appSrc = fs.readFileSync(APPJS, 'utf8'); |
| 102 | const relaySrc = fs.readFileSync(RELAYJS, 'utf8'); |
| 103 | |
| 104 | console.log('\nThe panel, as the page declares it'); |
| 105 | |
| 106 | const asideRe = /<aside[^>]*data-panel="term"[^>]*>/; |
| 107 | const aside = (asideRe.exec(indexSrc) || [''])[0]; |
| 108 | check('index.html declares a stage panel with the id `term`', |
| 109 | /data-panel="term"/.test(aside) && /data-zone="stage"/.test(aside), aside.slice(0, 120)); |
| 110 | check('and it carries a label and the key that translates it', |
| 111 | /data-label="Terminal"/.test(aside) && /data-i18n-label="panel\.term"/.test(aside), aside.slice(0, 160)); |
| 112 | check('and it is NOT given the `term` class, which belongs to what terminal.js builds', |
| 113 | /class="panel termpanel"/.test(aside) && !/class="panel term"/.test(aside), aside.slice(0, 80)); |
| 114 | // The stage's occupants are DECLARED inside <main class="stage">; the dock's are |
| 115 | // declared after it and moved into a column by the engine. A stage panel left |
| 116 | // among the dock's would still work -- the engine seats by `data-zone` and not by |
| 117 | // position -- so what this holds is the file's own convention, which is the only |
| 118 | // thing a reader has to go on when they open the markup looking for the stage. |
| 119 | { |
| 120 | const stageFrom = indexSrc.indexOf('<main class="stage"'); |
| 121 | const stageTo = indexSrc.indexOf('</main>', stageFrom); |
| 122 | const at = indexSrc.search(asideRe); |
| 123 | check('and it is declared inside the stage, where the stage\'s panels are written', |
| 124 | stageFrom !== -1 && at > stageFrom && at < stageTo, `aside@${at}, stage ${stageFrom}..${stageTo}`); |
| 125 | } |
| 126 | // A stage panel is a CARD -- the AI, the page, the document and the graph all |
| 127 | // carry the same border, ground and corner. app.css names most of them in one |
| 128 | // rule and graph.css gives the Graph its own copy; the Terminal's is in the file |
| 129 | // the terminal owns. A panel left off every one of those lists is a bare block |
| 130 | // on the stage's ground, and worse, a block is not a flex column: its head and |
| 131 | // its body would share the WIDTH and nothing inside could be told to fill the |
| 132 | // height, which is the failure that froze the terminal at its minimum grid. |
| 133 | { |
| 134 | const cssSrc = fs.readFileSync(path.join(ROOT, 'www/css/terminal.css'), 'utf8'); |
| 135 | const rule = (/\.panel\.termpanel\s*\{([^}]*)\}/.exec(cssSrc) || [, ''])[1]; |
| 136 | check('the panel is given the stage card: a ground, an edge and a corner', |
| 137 | /background:/.test(rule) && /border:/.test(rule) && /border-radius:/.test(rule), rule.replace(/\s+/g, ' ').trim()); |
| 138 | check('and it is a flex column, or nothing in it can be told to fill the height', |
| 139 | /display:\s*flex/.test(rule) && /flex-direction:\s*column/.test(rule), rule.replace(/\s+/g, ' ').trim()); |
| 140 | check('and it declares no flex of its own, which the engine sets per seat', |
| 141 | !/(^|;)\s*flex:/.test(rule), rule.replace(/\s+/g, ' ').trim()); |
| 142 | } |
| 143 | check('the panel body has a host, a state line, a notices region and a way-out hint', |
| 144 | /id="termp-host"/.test(indexSrc) && /id="termp-state"/.test(indexSrc) |
| 145 | && /id="termp-gaps"/.test(indexSrc) && /id="termp-foot"/.test(indexSrc)); |
| 146 | check('the notices region is a polite live region, so a hole is spoken as well as drawn', |
| 147 | /id="termp-gaps"[^>]*role="log"[^>]*aria-live="polite"/.test(indexSrc) |
| 148 | || /id="termp-gaps"[^>]*aria-live="polite"[^>]*role="log"/.test(indexSrc)); |
| 149 | |
| 150 | check('daimond.js opens the panel through the layout engine\'s own seam', |
| 151 | /id === 'term' && window\.DaimondTerm\) DaimondTerm\.onOpen\(\)/.test(appSrc)); |
| 152 | check('and closes it through the matching one', |
| 153 | /id === 'term' && window\.DaimondTerm\) DaimondTerm\.onClose\(\)/.test(appSrc)); |
| 154 | check('a Diamond change reaches the panel', |
| 155 | /daimond-diamond-changed[\s\S]{0,200}DaimondTerm\.onDiamondChanged\(\)/.test(appSrc)); |
| 156 | check('a panel already open in the saved layout is built at boot too', |
| 157 | /DaimondPanels\.isOpen\('term'\)\) DaimondTerm\.onOpen\(\)/.test(appSrc)); |
| 158 | check('locking the app takes the terminal with it', |
| 159 | /DaimondTerm\.onClose\(\)/.test(appSrc.slice(appSrc.indexOf('function lockApp')))); |
| 160 | |
| 161 | // ── The fence is not composed in the page ─────────────────────────── |
| 162 | // |
| 163 | // The one property in this file worth more than all the others, and the only one |
| 164 | // that can rot silently: a fence composed in JavaScript would work perfectly and |
| 165 | // be wrong. So the panel's own source is read, and anything that looks like the |
| 166 | // four fields of a `FenceSpec` being built is a failure. |
| 167 | |
| 168 | /// The Terminal panel's source, from its banner to the line that publishes it. |
| 169 | function panelSource(src) { |
| 170 | const from = src.indexOf('var DaimondTerm = (function () {'); |
| 171 | const to = src.indexOf('window.DaimondTerm = DaimondTerm;', from); |
| 172 | return (from === -1 || to === -1) ? '' : src.slice(from, to); |
| 173 | } |
| 174 | /// Whether a piece of source builds something shaped like a fence. |
| 175 | /// |
| 176 | /// Two of the four names together, as object keys, in one statement. One alone |
| 177 | /// is a false alarm (`ro` is two letters and appears inside words), and all four |
| 178 | /// are what a real `FenceSpec` has. |
| 179 | function composesAFence(src) { |
| 180 | const stmts = src.split(/[;\n]/); |
| 181 | return stmts.some((line) => { |
| 182 | const keys = ['rw', 'ro', 'deny', 'net'] |
| 183 | .filter((k) => new RegExp(`(^|[^\\w$.])${k}\\s*:`).test(line)); |
| 184 | return keys.length >= 2; |
| 185 | }); |
| 186 | } |
| 187 | const panelSrc = panelSource(appSrc); |
| 188 | check('the Terminal panel is where it says it is in daimond.js', panelSrc.length > 2000, `${panelSrc.length} chars`); |
| 189 | |
| 190 | // The checker is put through the same door as everything else: it is shown a |
| 191 | // source that DOES compose a fence and required to say so, before its silence on |
| 192 | // the real one is worth anything. |
| 193 | provedNames.push('the fence-composition check is not blind'); |
| 194 | check('PROVED the fence-composition check sees a fence when there is one', |
| 195 | composesAFence(panelSrc + '\nvar f = { rw: [root], ro: [], deny: [], net: false };\n')); |
| 196 | check('and the panel composes no fence of its own', !composesAFence(panelSrc)); |
| 197 | check('the panel gets its request from the Rust side, by name', |
| 198 | /Wasm\s*&&\s*Wasm\.pty_request/.test(panelSrc) && /fence_spec|composed on the Rust side/i.test(panelSrc)); |
| 199 | |
| 200 | // ── Every string it says, in every language it says it in ─────────── |
| 201 | |
| 202 | const KEYS = [ |
| 203 | 'panel.term', 'term.notices_label', 'term.start', 'term.restart', 'term.stop', |
| 204 | 'term.leave_hint', 'term.starting', 'term.running', 'term.nothing_running', |
| 205 | 'term.not_paired', 'term.no_composer', 'term.unreadable_request', |
| 206 | 'term.no_relay_script', 'term.no_renderer', 'term.dismiss_notice', |
| 207 | // A plural pair is two keys, and a language that has only one of them says |
| 208 | // the key itself to half its users. |
| 209 | 'term.gaps_count.one', 'term.gaps_count.other', |
| 210 | ]; |
| 211 | const locales = fs.readdirSync(I18NDIR).filter((f) => f.endsWith('.js')); |
| 212 | /// Which of `keys` a locale file does not define. |
| 213 | function missing(src, keys) { |
| 214 | return keys.filter((k) => !new RegExp(`['"]${k.replace('.', '\\.')}['"]\\s*:`).test(src)); |
| 215 | } |
| 216 | const enSrc = fs.readFileSync(path.join(I18NDIR, 'en.js'), 'utf8'); |
| 217 | provedNames.push('the missing-string check is not blind'); |
| 218 | check('PROVED the missing-string check sees a string that is gone', |
| 219 | missing(enSrc.replace(/'term\.no_composer':/, "'term.gone':"), KEYS).join(',') === 'term.no_composer'); |
| 220 | for (const f of locales) { |
| 221 | const src = fs.readFileSync(path.join(I18NDIR, f), 'utf8'); |
| 222 | const gone = missing(src, KEYS); |
| 223 | check(`${f} says every string the panel needs`, gone.length === 0, gone.join(', ')); |
| 224 | } |
| 225 | // The boundary matters: without it `createElement('div')` reads as a call to |
| 226 | // `t('div')`, and the check fails on a string nothing ever asks for. |
| 227 | const asks = [...panelSrc.matchAll(/(?:^|[^\w.$])t\('([\w.]+)'/g)].map((m) => m[1]); |
| 228 | const unknown = asks.filter((k) => !new RegExp(`['"]${k.replace(/\./g, '\\.')}['"]\\s*:`).test(enSrc)); |
| 229 | check('the page asks for no string the English table does not have', |
| 230 | asks.length > 5 && unknown.length === 0, unknown.join(' ') || `${asks.length} keys`); |
| 231 | |
| 232 | // ── Phase two: the panel in the real app ──────────────────────────── |
| 233 | |
| 234 | function listening(port) { |
| 235 | return new Promise((resolve) => { |
| 236 | const s = net.connect(port, '127.0.0.1'); |
| 237 | s.once('connect', () => { s.destroy(); resolve(true); }); |
| 238 | s.once('error', () => resolve(false)); |
| 239 | }); |
| 240 | } |
| 241 | const started = []; |
| 242 | async function serve(name, args, port) { |
| 243 | if (await listening(port)) { console.log(` (${name} already up on ${port})`); return; } |
| 244 | const p = spawn('node', args, { cwd: ROOT, stdio: 'ignore' }); |
| 245 | started.push(p); |
| 246 | for (let i = 0; i < 100; i++) { |
| 247 | if (await listening(port)) { console.log(` (started ${name} on ${port})`); return; } |
| 248 | await sleep(100); |
| 249 | } |
| 250 | throw new Error(`${name} did not come up on ${port}`); |
| 251 | } |
| 252 | // What the children will bind: `serve.mjs` reads DAIMOND_PORT and `mockllm.mjs` |
| 253 | // DAIMOND_MOCK_PORT, so the wait below is asking about the port they chose. |
| 254 | const APP_PORT = Number(process.env.DAIMOND_PORT || 8777); |
| 255 | const MOCK_PORT = Number(process.env.DAIMOND_MOCK_PORT || 9099); |
| 256 | await serve('dev server', ['dev/serve.mjs'], APP_PORT); |
| 257 | await serve('mock provider', ['dev/mockllm.mjs'], MOCK_PORT); |
| 258 | |
| 259 | const { open: openApp, shot } = await import('./harness.mjs'); |
| 260 | |
| 261 | /// The relay's own sentence for a page with no hand relay at all, read out of |
| 262 | /// the file so this test cannot drift into asserting one the product dropped. |
| 263 | const NO_RELAY = (function () { |
| 264 | const hit = /var\s+NO_RELAY\s*=\s*([\s\S]*?);\n/.exec(relaySrc); |
| 265 | try { return hit ? new Function('return ' + hit[1])() : ''; } catch (e) { return ''; } |
| 266 | })(); |
| 267 | check('handpty.js still has the sentence a page with no relay shows', |
| 268 | NO_RELAY.length > 80, NO_RELAY.slice(0, 60)); |
| 269 | |
| 270 | const s = await openApp({ name: 'termpanel' }); |
| 271 | const p = s.page; |
| 272 | |
| 273 | /// The link double: what hand.js is asked to provide, and nothing more. |
| 274 | async function installLink({ caps = ['fence:linux', 'landlock:abi-8'] } = {}) { |
| 275 | await p.evaluate((caps) => { |
| 276 | window.__link = { sent: [], subs: {}, pid: 4242, refuse: null, opens: 0 }; |
| 277 | const fire = (id, msg) => (window.__link.subs[id] || []).slice().forEach((f) => f(msg)); |
| 278 | window.__emit = fire; |
| 279 | window.DaimondHand = { |
| 280 | send(m) { |
| 281 | window.__link.sent.push(m); |
| 282 | if (m.t === 'open') { |
| 283 | window.__link.opens++; |
| 284 | window.__link.lastOpen = m; |
| 285 | setTimeout(() => { |
| 286 | if (window.__link.refuse) fire(m.id, { t: 'refused', reason: window.__link.refuse }); |
| 287 | else fire(m.id, { t: 'opened', pid: window.__link.pid }); |
| 288 | }, 0); |
| 289 | } |
| 290 | if (m.t === 'signal') { |
| 291 | setTimeout(() => fire(m.id, { t: 'closed', exit: 0, killed: m.sig !== 'term' }), 0); |
| 292 | } |
| 293 | return Promise.resolve(); |
| 294 | }, |
| 295 | subscribe(id, fn) { |
| 296 | (window.__link.subs[id] = window.__link.subs[id] || []).push(fn); |
| 297 | return () => { |
| 298 | const a = window.__link.subs[id] || []; |
| 299 | const i = a.indexOf(fn); |
| 300 | if (i >= 0) a.splice(i, 1); |
| 301 | }; |
| 302 | }, |
| 303 | status() { |
| 304 | return Promise.resolve(JSON.stringify({ |
| 305 | paired: true, transport: 'machine', machine: 'double', os: 'linux', |
| 306 | root: '/nowhere/ws', caps: caps, |
| 307 | })); |
| 308 | }, |
| 309 | hasHand: () => true, |
| 310 | }; |
| 311 | }, caps); |
| 312 | } |
| 313 | |
| 314 | /// The Rust answer double. `fence` is a fiction and no check reads it; what it |
| 315 | /// stands in for is the SHAPE of an answer and the fact that one arrived. |
| 316 | async function installComposer({ dropFence = false } = {}) { |
| 317 | await p.evaluate((drop) => { |
| 318 | window.DaimondTerm._setRequestForTest(async (json) => { |
| 319 | window.__asked = JSON.parse(json); |
| 320 | const req = { |
| 321 | t: 'open', id: 'tp-' + (++window.__reqSeq || (window.__reqSeq = 1)), |
| 322 | argv: ['bash', '-i'], cwd: '/nowhere/ws', env: [], |
| 323 | size: { cols: window.__asked.cols, rows: window.__asked.rows }, |
| 324 | fence: { rw: ['/nowhere/ws'], ro: [], deny: [], net: false }, |
| 325 | }; |
| 326 | if (drop) delete req.fence; |
| 327 | return JSON.stringify(req); |
| 328 | }); |
| 329 | }, dropFence); |
| 330 | } |
| 331 | |
| 332 | /// Open the panel and wait for the attempt to settle. |
| 333 | async function openPanel() { |
| 334 | await p.evaluate(() => window.DaimondPanels.show('term')); |
| 335 | await sleep(700); |
| 336 | } |
| 337 | async function closePanel() { |
| 338 | await p.evaluate(() => window.DaimondPanels.hide('term')); |
| 339 | await sleep(300); |
| 340 | } |
| 341 | /// The sentence the panel is showing. |
| 342 | const stateText = () => p.evaluate(() => (document.getElementById('termp-state') || {}).textContent || ''); |
| 343 | /// The session the panel believes it has. |
| 344 | const sessionId = () => p.evaluate(() => window.DaimondTerm.session()); |
| 345 | /// Everything the link has been sent since it was installed. |
| 346 | const sent = () => p.evaluate(() => window.__link.sent.map((m) => JSON.parse(JSON.stringify(m)))); |
| 347 | /// What the screen reader is given: the terminal's own text mirror. |
| 348 | const mirror = () => p.evaluate(() => |
| 349 | ((document.querySelector('#termp-host .term-mirror') || {}).textContent || '').replace(/\s+/g, ' ')); |
| 350 | |
| 351 | try { |
| 352 | console.log('\nThe panel in the app'); |
| 353 | |
| 354 | // 1. Nothing is built until somebody asks. |
| 355 | await proved('the terminal is built on the first open and not at page load', |
| 356 | async () => { await closePanel(); }, |
| 357 | async () => !!(await p.$('#termp-host .term')), |
| 358 | async () => { await installLink(); await installComposer(); await openPanel(); }); |
| 359 | |
| 360 | // 2. With no relay in the page at all, the panel says the relay's own sentence. |
| 361 | // The BROKEN world for this property is a page that has a relay. |
| 362 | await proved('a page with no machine hand shows the relay\'s own refusal, verbatim', |
| 363 | async () => { |
| 364 | await closePanel(); await installLink(); await installComposer(); await openPanel(); |
| 365 | }, |
| 366 | async () => (await stateText()).indexOf(NO_RELAY) === 0, |
| 367 | async () => { |
| 368 | await closePanel(); |
| 369 | await p.evaluate(() => { window.__handWas = window.DaimondHand; delete window.DaimondHand; }); |
| 370 | await openPanel(); |
| 371 | }); |
| 372 | await p.evaluate(() => { window.DaimondHand = window.__handWas; }); |
| 373 | |
| 374 | // 3. Rust refusing means no terminal — and no request on the wire either. |
| 375 | // |
| 376 | // The composer here is the REAL one. `Wasm.pty_request` did not exist when |
| 377 | // this was first written, so "no way to ask Rust" was the reachable world; |
| 378 | // it exists now, and the world worth proving is the one where the app asks |
| 379 | // and is told no. The machine the double describes is a hand that cannot |
| 380 | // fence, which is what `pty_request` refuses on — release gate 1, applied |
| 381 | // to a session. |
| 382 | await proved('when Rust refuses a request, nothing is sent and the app says why', |
| 383 | async () => { |
| 384 | await closePanel(); await installLink(); await installComposer(); await openPanel(); |
| 385 | }, |
| 386 | async () => { |
| 387 | const st = await stateText(); |
| 388 | const opens = await p.evaluate(() => window.__link.opens); |
| 389 | return opens === 0 && /^Refused:/.test(st) && st.length > 40; |
| 390 | }, |
| 391 | async () => { |
| 392 | await closePanel(); |
| 393 | await installLink({ caps: ['fence:none'] }); |
| 394 | await p.evaluate(() => window.DaimondTerm._setRequestForTest(null)); |
| 395 | await openPanel(); |
| 396 | }); |
| 397 | |
| 398 | // 4. A fenceless answer is passed through and refused BY THE RELAY. The panel |
| 399 | // must not repair it, and the sentence the user reads must be the relay's. |
| 400 | await proved('a request with no fence is refused, and the page does not patch one in', |
| 401 | async () => { |
| 402 | await closePanel(); await installLink(); await installComposer(); await openPanel(); |
| 403 | }, |
| 404 | async () => { |
| 405 | const st = await stateText(); |
| 406 | const opens = await p.evaluate(() => window.__link.opens); |
| 407 | return opens === 0 && /fence_spec/.test(st); |
| 408 | }, |
| 409 | async () => { |
| 410 | await closePanel(); |
| 411 | await installLink(); |
| 412 | await installComposer({ dropFence: true }); |
| 413 | await openPanel(); |
| 414 | }); |
| 415 | |
| 416 | // ── A live session ────────────────────────────────────────── |
| 417 | await closePanel(); |
| 418 | await installLink(); |
| 419 | await installComposer(); |
| 420 | await openPanel(); |
| 421 | const sid = await sessionId(); |
| 422 | const grid = await p.evaluate(() => { |
| 423 | const el = document.querySelector('#termp-host .term'); |
| 424 | return el ? { cols: +el.dataset.cols, rows: +el.dataset.rows } : null; |
| 425 | }); |
| 426 | check('the terminal is fitted to the panel it was built into', |
| 427 | !!grid && grid.cols >= 20 && grid.rows >= 4, JSON.stringify(grid)); |
| 428 | check('a session opens through the relay and the panel holds its id', !!sid, String(sid)); |
| 429 | check('the panel says it is running', /running/i.test(await stateText()), await stateText()); |
| 430 | |
| 431 | // ── Where the panel sits, and what fills it ───────────────── |
| 432 | // |
| 433 | // A dock panel and a stage panel are laid out by different rules: the dock |
| 434 | // tiles its occupants into a fixed column, the stage sizes its two seats |
| 435 | // inline. What has to hold either way is that the terminal's own box IS the |
| 436 | // panel's, minus the head and the two lines of furniture -- a grid computed |
| 437 | // from a box smaller than the panel is a screen the program draws wrong for |
| 438 | // as long as it runs. |
| 439 | const seat = await p.evaluate(() => { |
| 440 | const panel = document.getElementById('panel-term'); |
| 441 | const host = document.getElementById('termp-host'); |
| 442 | const term = document.querySelector('#termp-host .term'); |
| 443 | const cs = getComputedStyle(panel); |
| 444 | const h = (e) => (e ? Math.round(e.getBoundingClientRect().height) : 0); |
| 445 | const w = (e) => (e ? Math.round(e.getBoundingClientRect().width) : 0); |
| 446 | return { |
| 447 | declared: panel.dataset.zone, |
| 448 | seated: window.DaimondPanels.zone('term'), |
| 449 | parent: panel.parentElement.id, |
| 450 | display: cs.display, |
| 451 | flow: cs.flexDirection, |
| 452 | panelH: h(panel), hostH: h(host), termH: h(term), |
| 453 | hostW: w(host), termW: w(term), |
| 454 | }; |
| 455 | }); |
| 456 | check('the panel is seated on the stage rather than tiled into the dock', |
| 457 | seat.declared === 'stage' && seat.seated === 'stage' && seat.parent === 'stage', |
| 458 | `declared ${seat.declared}, registered ${seat.seated}, drawn in #${seat.parent}`); |
| 459 | check('and it is a flex column on screen, not a block', |
| 460 | seat.display === 'flex' && seat.flow === 'column', `${seat.display} / ${seat.flow}`); |
| 461 | check('the terminal is exactly its host, and the host has most of the panel', |
| 462 | seat.termH === seat.hostH && seat.termW === seat.hostW |
| 463 | && seat.hostH > seat.panelH * 0.6 && seat.termH > 200, |
| 464 | `term ${seat.termW}x${seat.termH}, host ${seat.hostW}x${seat.hostH}, panel ${seat.panelH} tall`); |
| 465 | |
| 466 | const asked = await p.evaluate(() => window.__asked); |
| 467 | check('the request Rust was asked for carries the panel\'s real grid', |
| 468 | asked && asked.cols === grid.cols && asked.rows === grid.rows, |
| 469 | JSON.stringify(asked)); |
| 470 | check('and the Diamond scope it should be fenced to', |
| 471 | asked && typeof asked.own_dir === 'string' |
| 472 | && Array.isArray(asked.attached) && Array.isArray(asked.read_only), |
| 473 | JSON.stringify(asked)); |
| 474 | |
| 475 | const openMsg = await p.evaluate(() => window.__link.lastOpen); |
| 476 | check('the request reached the wire exactly as Rust composed it', |
| 477 | openMsg && openMsg.fence && openMsg.fence.rw[0] === '/nowhere/ws' && openMsg.cwd === '/nowhere/ws', |
| 478 | JSON.stringify(openMsg && openMsg.fence)); |
| 479 | |
| 480 | // ── The dock keeps its four ───────────────────────────────── |
| 481 | // |
| 482 | // The reason the panel moved. The dock's automatic grid is one column of four |
| 483 | // below 1900px, and Agents, Email, Workspace and Spending fill it exactly; a |
| 484 | // fifth panel there can only arrive by closing one of them. On the stage, |
| 485 | // opening the Terminal costs the dock nothing. |
| 486 | // |
| 487 | // The broken world is the behaviour it used to have: a `show('term')` that |
| 488 | // takes a dock seat with it. That is a faithful break -- it is what the user |
| 489 | // actually saw -- and the check has to go red for it or it is not reading the |
| 490 | // dock at all. |
| 491 | const dockSeats = () => p.evaluate(() => [...document.querySelectorAll('#dock .pcol > .panel')] |
| 492 | .filter((e) => getComputedStyle(e).display !== 'none').map((e) => e.dataset.panel).sort()); |
| 493 | await p.evaluate(() => { |
| 494 | // Agents waits for its first run before it joins the row; the dock is only |
| 495 | // full with it, and full is the case this is about. |
| 496 | try { localStorage.setItem('daimond-agents-revealed', '1'); } catch (e) { /* private mode */ } |
| 497 | document.body.classList.remove('agents-hidden'); |
| 498 | ['agents', 'mail', 'work', 'spend'].forEach((id) => window.DaimondPanels.show(id)); |
| 499 | }); |
| 500 | await sleep(600); |
| 501 | check('the dock holds its four, and its automatic grid has room for exactly those', |
| 502 | (await dockSeats()).length === 4 |
| 503 | && (await p.evaluate(() => window.DaimondPanels.model().dockMax)) === 4, |
| 504 | (await dockSeats()).join(', ')); |
| 505 | |
| 506 | await proved('opening the Terminal costs the dock no seat', |
| 507 | async () => { |
| 508 | await p.evaluate(() => { |
| 509 | window.__realShow = window.DaimondPanels.show; |
| 510 | window.DaimondPanels.show = function (id) { |
| 511 | window.__realShow.call(window.DaimondPanels, id); |
| 512 | // What a fifth panel in a four-seat dock did: something already |
| 513 | // seated has to go, and the user is never asked which. |
| 514 | if (id === 'term') window.DaimondPanels.hide('spend'); |
| 515 | }; |
| 516 | }); |
| 517 | }, |
| 518 | async () => { |
| 519 | await closePanel(); |
| 520 | const before = await dockSeats(); |
| 521 | await openPanel(); |
| 522 | const after = await dockSeats(); |
| 523 | return before.length === 4 && after.join('|') === before.join('|'); |
| 524 | }, |
| 525 | async () => { |
| 526 | await p.evaluate(() => { window.DaimondPanels.show = window.__realShow; }); |
| 527 | await p.evaluate(() => ['agents', 'mail', 'work', 'spend'] |
| 528 | .forEach((id) => window.DaimondPanels.show(id))); |
| 529 | await sleep(400); |
| 530 | await closePanel(); await installLink(); await installComposer(); await openPanel(); |
| 531 | }); |
| 532 | |
| 533 | /// Push output at the session as the hand would, base64 as the wire carries it. |
| 534 | /// |
| 535 | /// The wait is long on purpose: what is read back afterwards is the terminal's |
| 536 | /// TEXT MIRROR, and that is rebuilt when the output settles (600 ms in |
| 537 | /// terminal.js) rather than on every byte — which is the same reason a screen |
| 538 | /// reader is not read a build log one line at a time. |
| 539 | async function output(id, text, seq) { |
| 540 | await p.evaluate(({ id, text, seq }) => { |
| 541 | const u8 = new TextEncoder().encode(text); |
| 542 | let s = ''; |
| 543 | for (const b of u8) s += String.fromCharCode(b); |
| 544 | window.__emit(id, { t: 'output', seq, data: btoa(s) }); |
| 545 | }, { id, text, seq }); |
| 546 | await sleep(900); |
| 547 | } |
| 548 | |
| 549 | // 5. Output reaches the screen — read back through the text a screen reader |
| 550 | // is given, so the accessible path is proved with the drawing one. |
| 551 | await proved('bytes off the wire are written to the screen', |
| 552 | async () => { |
| 553 | await p.evaluate(() => { |
| 554 | window.__realCreate = window.DaimondTerminal.create; |
| 555 | window.DaimondTerminal.create = function (host, opts) { |
| 556 | const h = window.__realCreate(host, opts); |
| 557 | h.write = function () {}; // the joint, cut |
| 558 | return h; |
| 559 | }; |
| 560 | }); |
| 561 | await closePanel(); await installLink(); await installComposer(); await openPanel(); |
| 562 | }, |
| 563 | async () => { |
| 564 | const id = await sessionId(); |
| 565 | if (!id) return false; |
| 566 | await output(id, 'PANEL-OUTPUT-MARK\r\n', 0); |
| 567 | return /PANEL-OUTPUT-MARK/.test(await mirror()); |
| 568 | }, |
| 569 | async () => { |
| 570 | await p.evaluate(() => { window.DaimondTerminal.create = window.__realCreate; }); |
| 571 | await closePanel(); await installLink(); await installComposer(); await openPanel(); |
| 572 | }); |
| 573 | |
| 574 | // 6. A keystroke goes back as the BYTES it is, never as text about them. |
| 575 | await proved('a keystroke reaches the pty as the bytes it is', |
| 576 | async () => { |
| 577 | await p.evaluate(() => { |
| 578 | window.__realInput = window.DaimondPty.input; |
| 579 | window.DaimondPty.input = () => Promise.resolve(); // the joint, cut |
| 580 | }); |
| 581 | }, |
| 582 | async () => { |
| 583 | const before = (await sent()).filter((m) => m.t === 'input').length; |
| 584 | await p.focus('#termp-host .term-input'); |
| 585 | await p.keyboard.type('ls'); |
| 586 | await p.keyboard.press('Control+c'); |
| 587 | await sleep(250); |
| 588 | const ins = (await sent()).filter((m) => m.t === 'input').slice(before); |
| 589 | if (!ins.length) return false; |
| 590 | const joined = ins.map((m) => Buffer.from(m.data, 'base64').toString('binary')).join(''); |
| 591 | return joined.includes('l') && joined.includes('s') && joined.includes('\x03'); |
| 592 | }, |
| 593 | async () => { await p.evaluate(() => { window.DaimondPty.input = window.__realInput; }); }); |
| 594 | |
| 595 | // 7. The kernel is told when the panel changes size. |
| 596 | await proved('a panel resize is carried to the kernel as a new size', |
| 597 | async () => { |
| 598 | await p.evaluate(() => { |
| 599 | window.__realResize = window.DaimondPty.resize; |
| 600 | window.DaimondPty.resize = () => Promise.resolve(); // the joint, cut |
| 601 | }); |
| 602 | }, |
| 603 | async () => { |
| 604 | const before = (await sent()).filter((m) => m.t === 'resize').length; |
| 605 | // A window a person actually resizes. The dock's WIDTH does not follow |
| 606 | // the window, so the height is what moves the grid — and it does, which |
| 607 | // is itself worth knowing: until the panel was made a flex column the |
| 608 | // terminal sat at its minimum grid however tall the panel got, and this |
| 609 | // check is what caught it. |
| 610 | await p.setViewportSize({ width: 1500, height: 640 }); |
| 611 | await sleep(800); |
| 612 | await p.setViewportSize({ width: 1500, height: 950 }); |
| 613 | await sleep(800); |
| 614 | const rs = (await sent()).filter((m) => m.t === 'resize').slice(before); |
| 615 | if (!rs.length) return false; |
| 616 | const el = await p.evaluate(() => { |
| 617 | const t = document.querySelector('#termp-host .term'); |
| 618 | return t ? { cols: +t.dataset.cols, rows: +t.dataset.rows } : null; |
| 619 | }); |
| 620 | const last = rs[rs.length - 1]; |
| 621 | return !!el && last.size.cols === el.cols && last.size.rows === el.rows; |
| 622 | }, |
| 623 | async () => { await p.evaluate(() => { window.DaimondPty.resize = window.__realResize; }); }); |
| 624 | |
| 625 | // 8. A hole is shown BESIDE the stream, and the bytes still arrive. |
| 626 | await proved('a gap in the output is shown beside the stream, and the bytes still go through', |
| 627 | async () => { |
| 628 | await p.evaluate(() => { |
| 629 | window.__gapsEl = document.getElementById('termp-gaps'); |
| 630 | window.__realAppend = window.__gapsEl.appendChild.bind(window.__gapsEl); |
| 631 | window.__gapsEl.appendChild = function () {}; // the notice, suppressed |
| 632 | }); |
| 633 | }, |
| 634 | async () => { |
| 635 | // A FRESH session both times. The relay counts from the first chunk it |
| 636 | // is given, so a second run over an old session would open with a hole |
| 637 | // of its own and the count below would be reading the wrong one. |
| 638 | await closePanel(); await installLink(); await installComposer(); await openPanel(); |
| 639 | const id = await sessionId(); |
| 640 | if (!id) return false; |
| 641 | await output(id, 'BEFORE-THE-HOLE\r\n', 40); |
| 642 | await output(id, 'AFTER-THE-HOLE\r\n', 47); // six chunks never arrived |
| 643 | const notices = await p.evaluate(() => |
| 644 | [...document.querySelectorAll('#termp-gaps .termp-gap')].map((d) => d.textContent)); |
| 645 | const drew = /AFTER-THE-HOLE/.test(await mirror()); |
| 646 | return drew && notices.length === 1 && /missing/i.test(notices[0]); |
| 647 | }, |
| 648 | async () => { await p.evaluate(() => { window.__gapsEl.appendChild = window.__realAppend; }); }); |
| 649 | |
| 650 | check('the gap notice is the relay\'s own sentence, not a paraphrase', |
| 651 | await p.evaluate(() => { |
| 652 | const d = document.querySelector('#termp-gaps .termp-gap'); |
| 653 | return !!d && /chunk\(s\) of output/.test(d.textContent) && /hole in it/.test(d.textContent); |
| 654 | })); |
| 655 | |
| 656 | // A notice lies OVER the screen, so it must be possible to move out of the |
| 657 | // way — and the fact it reports must not go with it. |
| 658 | await proved('a dismissed notice takes the message away and leaves the fact', |
| 659 | async () => { |
| 660 | await p.evaluate(() => { |
| 661 | window.__realX = HTMLButtonElement.prototype.click; |
| 662 | HTMLButtonElement.prototype.click = function () {}; // the dismiss, cut |
| 663 | }); |
| 664 | }, |
| 665 | async () => { |
| 666 | await p.evaluate(() => { |
| 667 | const b = document.querySelector('#termp-gaps .termp-gap-x'); |
| 668 | if (b) b.click(); |
| 669 | }); |
| 670 | await sleep(200); |
| 671 | const gone = await p.evaluate(() => !document.querySelector('#termp-gaps .termp-gap')); |
| 672 | const said = /missing in 1 place/.test(await stateText()); |
| 673 | return gone && said; |
| 674 | }, |
| 675 | async () => { await p.evaluate(() => { HTMLButtonElement.prototype.click = window.__realX; }); }); |
| 676 | |
| 677 | // 9. The way out of a control that swallows keys. |
| 678 | // |
| 679 | // The capture handler cannot be reached from outside to be broken, so the |
| 680 | // proof is a NEGATIVE CONTROL on the same assertion: F7 is a key the panel |
| 681 | // does not own, and pressing it must leave the focus exactly where F6 moves |
| 682 | // it from. A check that goes green for F7 is a check that is not reading the |
| 683 | // keyboard at all. |
| 684 | let escKey = 'F6'; |
| 685 | await proved('F6 moves the keyboard out of the terminal, and a key the panel does not own does not', |
| 686 | async () => { escKey = 'F7'; }, |
| 687 | async () => { |
| 688 | await p.focus('#termp-host .term-input'); |
| 689 | await sleep(100); |
| 690 | await p.keyboard.press(escKey); |
| 691 | await sleep(200); |
| 692 | return await p.evaluate(() => !document.activeElement.classList.contains('term-input') |
| 693 | && document.activeElement.closest('#panel-term') !== null); |
| 694 | }, |
| 695 | async () => { escKey = 'F6'; }); |
| 696 | |
| 697 | check('and F6 was not typed at the program on its way out', |
| 698 | !(await sent()).filter((m) => m.t === 'input') |
| 699 | .some((m) => Buffer.from(m.data, 'base64').toString('binary').includes('\x1b[17~'))); |
| 700 | |
| 701 | // 10. Closing the panel stops the program and destroys the screen. |
| 702 | await proved('closing the panel asks the program to stop and destroys the screen', |
| 703 | async () => { |
| 704 | await p.evaluate(() => { |
| 705 | window.__realClose = window.DaimondPty.close; |
| 706 | window.DaimondPty.close = () => Promise.resolve(); // the joint, cut |
| 707 | }); |
| 708 | }, |
| 709 | async () => { |
| 710 | await closePanel(); await installLink(); await installComposer(); await openPanel(); |
| 711 | const before = (await sent()).filter((m) => m.t === 'signal').length; |
| 712 | await closePanel(); |
| 713 | const sigs = (await sent()).filter((m) => m.t === 'signal').slice(before); |
| 714 | const gone = !(await p.$('#termp-host .term')); |
| 715 | const idle = !(await sessionId()); |
| 716 | return sigs.length === 1 && sigs[0].sig === 'term' && gone && idle; |
| 717 | }, |
| 718 | async () => { await p.evaluate(() => { window.DaimondPty.close = window.__realClose; }); }); |
| 719 | |
| 720 | // 11. A session belongs to ONE Diamond's bounds, so a Diamond change ends it. |
| 721 | await proved('changing the Diamond ends the session it was fenced for', |
| 722 | async () => { |
| 723 | await p.evaluate(() => { |
| 724 | window.__realClose2 = window.DaimondPty.close; |
| 725 | window.DaimondPty.close = () => Promise.resolve(); // the joint, cut |
| 726 | }); |
| 727 | }, |
| 728 | async () => { |
| 729 | await closePanel(); await installLink(); await installComposer(); await openPanel(); |
| 730 | const first = await sessionId(); |
| 731 | if (!first) return false; |
| 732 | const before = (await sent()).filter((m) => m.t === 'signal').length; |
| 733 | await p.click('#new-diamond-btn', { force: true }); |
| 734 | await p.waitForSelector('.dlg-input', { timeout: 10000 }); |
| 735 | await p.fill('.dlg-input', 'Terminal scope ' + Date.now()); |
| 736 | await p.click('.dlg-ok', { force: true }); |
| 737 | await sleep(1500); |
| 738 | const sigs = (await sent()).filter((m) => m.t === 'signal').slice(before); |
| 739 | const second = await sessionId(); |
| 740 | return sigs.length >= 1 && second !== first; |
| 741 | }, |
| 742 | async () => { await p.evaluate(() => { window.DaimondPty.close = window.__realClose2; }); }); |
| 743 | |
| 744 | const scoped = await p.evaluate(() => window.__asked); |
| 745 | check('and the terminal that replaces it is scoped to the Diamond now open', |
| 746 | scoped && /^diamonds\//.test(scoped.own_dir || '') && scoped.cwd === scoped.own_dir, |
| 747 | JSON.stringify(scoped)); |
| 748 | |
| 749 | // 12. An ending is reported where the output it belongs to is. |
| 750 | { |
| 751 | await closePanel(); await installLink(); await installComposer(); await openPanel(); |
| 752 | const id = await sessionId(); |
| 753 | await p.evaluate((i) => window.__emit(i, { t: 'closed', exit: 3, killed: false }), id); |
| 754 | await sleep(1000); // the ending is written into the screen; the mirror settles |
| 755 | check('an exit is reported in the panel and written into the screen', |
| 756 | /status 3/.test(await stateText()) && /status 3/.test(await mirror()), |
| 757 | await stateText()); |
| 758 | check('and the panel no longer believes it has a session', !(await sessionId())); |
| 759 | } |
| 760 | |
| 761 | // 13. The two buttons in the head, pressed the way a person presses them. |
| 762 | { |
| 763 | await closePanel(); await installLink(); await installComposer(); await openPanel(); |
| 764 | const first = await sessionId(); |
| 765 | await p.click('#panel-term [data-act="term-start"]', { force: true }); |
| 766 | await sleep(900); |
| 767 | const second = await sessionId(); |
| 768 | check('Restart replaces the session with a new one', |
| 769 | !!first && !!second && first !== second, `${first} -> ${second}`); |
| 770 | const before = (await sent()).filter((m) => m.t === 'signal').length; |
| 771 | await p.click('#panel-term [data-act="term-stop"]', { force: true }); |
| 772 | await sleep(400); |
| 773 | const sigs = (await sent()).filter((m) => m.t === 'signal').slice(before); |
| 774 | check('Stop asks the program to stop rather than insisting', |
| 775 | sigs.length === 1 && sigs[0].sig === 'term', JSON.stringify(sigs)); |
| 776 | await p.click('#panel-term [data-act="term-stop"]', { force: true }); |
| 777 | await sleep(300); |
| 778 | check('and pressing Stop with nothing running says so instead of failing quietly', |
| 779 | /no program running/i.test(await stateText()), await stateText()); |
| 780 | check('the Start button now offers to start rather than to restart', |
| 781 | /^Start/.test(await p.evaluate(() => |
| 782 | document.querySelector('#panel-term [data-act="term-start"]').getAttribute('aria-label'))), |
| 783 | await p.evaluate(() => document.querySelector('#panel-term [data-act="term-start"]').getAttribute('aria-label'))); |
| 784 | } |
| 785 | |
| 786 | // 14. What a screen reader is given, and what the keyboard can reach. |
| 787 | const a11y = await p.evaluate(() => { |
| 788 | const input = document.querySelector('#termp-host .term-input'); |
| 789 | const head = document.querySelector('#panel-term .chead [role="heading"]'); |
| 790 | const btns = [...document.querySelectorAll('#panel-term .chead button')]; |
| 791 | return { |
| 792 | inputName: input ? input.getAttribute('aria-label') : null, |
| 793 | inputDesc: input ? !!document.getElementById(input.getAttribute('aria-describedby')) : false, |
| 794 | headFocus: head ? head.getAttribute('tabindex') : null, |
| 795 | unnamed: btns.filter((b) => !(b.getAttribute('aria-label') || b.title || '').trim()).length, |
| 796 | named: btns.map((b) => b.getAttribute('aria-label')), |
| 797 | hint: (document.getElementById('termp-foot') || {}).textContent || '', |
| 798 | mirrorRole: (document.querySelector('#termp-host .term-mirror') || {}).getAttribute?.('role'), |
| 799 | }; |
| 800 | }); |
| 801 | check('the terminal control has a name a screen reader can say', !!a11y.inputName, a11y.inputName); |
| 802 | check('and a description that says which keys it answers to', a11y.inputDesc); |
| 803 | check('every button in the panel head has a real name, not the glyph on it', |
| 804 | a11y.unnamed === 0, a11y.named.join(' | ')); |
| 805 | check('the heading can be focused, so F6 has somewhere to land', a11y.headFocus === '-1'); |
| 806 | check('the way out is written on the panel, not left to be guessed', |
| 807 | /F6/.test(a11y.hint), a11y.hint); |
| 808 | check('the screen is also there as text, for a reader who cannot see a canvas', |
| 809 | a11y.mirrorRole === 'region'); |
| 810 | |
| 811 | // 15. Every palette, measured — and two of them looked at. |
| 812 | // |
| 813 | // The head changed with the zone: a dock panel wears the rail's small capitals |
| 814 | // on the page's own ground, a stage card wears the card head the AI and the |
| 815 | // Web panel wear on a card of its own. New ink on a new ground is a new |
| 816 | // contrast question, and it is not one screenshot's to answer -- the app has |
| 817 | // eleven palettes and a colour that reads on two of them can be unreadable on |
| 818 | // a third. So every palette is put on and the RENDERED pixels are measured. |
| 819 | // |
| 820 | // The panel's name and its two sentences are TEXT and are held to 4.5. The |
| 821 | // card's EDGE is not held to a number here, and the reason is worth writing |
| 822 | // down: `--border` is a hairline separator in this app, not a boundary drawn |
| 823 | // to be seen on its own -- it measures 1.23 to 1.29 against the ground on |
| 824 | // every palette, and it does so for the AI panel, the Web panel and the Graph |
| 825 | // exactly as much as for this one. A 3.0 floor asserted here would fail every |
| 826 | // card in the app, which would mean the check was wrong and not the app. What |
| 827 | // is held instead is that the Terminal's card is the SAME card: same ground, |
| 828 | // same edge, on all eleven. That is the property a zone move can actually |
| 829 | // break -- a panel left wearing the dock's transparent ground would be a bare |
| 830 | // block on the stage -- and it is proved below against a page where it does |
| 831 | // not hold. |
| 832 | { |
| 833 | /// WCAG relative luminance, from an `rgb(...)` string the browser gave us. |
| 834 | const lum = (css) => { |
| 835 | const [r, g, b] = (css.match(/[\d.]+/g) || ['0', '0', '0']).slice(0, 3).map(Number); |
| 836 | const f = (c) => { c /= 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; |
| 837 | return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); |
| 838 | }; |
| 839 | const ratio = (a, b) => { |
| 840 | const [x, y] = [lum(a), lum(b)].sort((m, n) => n - m); |
| 841 | return (x + 0.05) / (y + 0.05); |
| 842 | }; |
| 843 | /// What the panel is wearing right now, straight off the rendered styles. |
| 844 | const ink = () => p.evaluate(() => { |
| 845 | const panel = document.getElementById('panel-term'); |
| 846 | const ai = document.getElementById('panel-ai'); |
| 847 | const cs = (e) => getComputedStyle(e); |
| 848 | return { |
| 849 | title: cs(panel.querySelector('.chead .ctitle')).color, |
| 850 | state: cs(document.getElementById('termp-state')).color, |
| 851 | foot: cs(document.getElementById('termp-foot')).color, |
| 852 | card: cs(panel).backgroundColor, |
| 853 | edge: cs(panel).borderTopColor, |
| 854 | aiCard: cs(ai).backgroundColor, |
| 855 | aiEdge: cs(ai).borderTopColor, |
| 856 | }; |
| 857 | }); |
| 858 | /// Whether this panel is wearing the stage card the AI panel is wearing. |
| 859 | const sameCard = (v) => v.card === v.aiCard && v.edge === v.aiEdge; |
| 860 | |
| 861 | const themes = await p.evaluate(() => window.DaimondTheme.list()); |
| 862 | check('the app has the eleven palettes this is measured across', |
| 863 | themes.length === 11, `${themes.length}: ${themes.join(', ')}`); |
| 864 | const shortfalls = [], strangers = [], edges = []; |
| 865 | for (const theme of themes) { |
| 866 | await p.evaluate((th) => { |
| 867 | try { window.DaimondTheme.set(th); } catch (e) { document.documentElement.setAttribute('data-theme', th); } |
| 868 | }, theme); |
| 869 | await sleep(220); |
| 870 | const v = await ink(); |
| 871 | [ |
| 872 | ['the panel\'s name', ratio(v.title, v.card)], |
| 873 | ['the state sentence', ratio(v.state, v.card)], |
| 874 | ['the way-out line', ratio(v.foot, v.card)], |
| 875 | ].forEach(([what, r]) => { |
| 876 | if (r < 4.5) shortfalls.push(`${theme}: ${what} = ${r.toFixed(2)} (floor 4.5)`); |
| 877 | }); |
| 878 | if (!sameCard(v)) strangers.push(`${theme}: card ${v.card} vs ${v.aiCard}, edge ${v.edge} vs ${v.aiEdge}`); |
| 879 | edges.push(ratio(v.edge, v.card)); |
| 880 | } |
| 881 | check('every word the panel says clears 4.5 on every palette', |
| 882 | shortfalls.length === 0, shortfalls.join(' | ') || `${themes.length} palettes measured`); |
| 883 | check('and it wears the same card as the AI panel on every one of them', |
| 884 | strangers.length === 0, |
| 885 | strangers.join(' | ') |
| 886 | + ` (its hairline runs ${Math.min(...edges).toFixed(2)}-${Math.max(...edges).toFixed(2)}` |
| 887 | + ' against its own ground, which is what every card in the app runs at)'); |
| 888 | |
| 889 | // And the card check is put through the same door as everything else: a |
| 890 | // panel painted in the ground behind it is the state the check exists to |
| 891 | // catch, so it has to go red for one. |
| 892 | await proved('the panel is wearing the stage\'s card and not the ground behind it', |
| 893 | async () => { |
| 894 | await p.evaluate(() => { |
| 895 | const el = document.getElementById('panel-term'); |
| 896 | el.style.background = getComputedStyle(document.body).backgroundColor; |
| 897 | el.style.borderColor = getComputedStyle(document.body).backgroundColor; |
| 898 | }); |
| 899 | }, |
| 900 | async () => sameCard(await ink()), |
| 901 | async () => { |
| 902 | await p.evaluate(() => { |
| 903 | const el = document.getElementById('panel-term'); |
| 904 | el.style.background = ''; |
| 905 | el.style.borderColor = ''; |
| 906 | }); |
| 907 | }); |
| 908 | |
| 909 | // And two of them photographed, because a ratio is not a look. |
| 910 | for (const theme of ['dark', 'light']) { |
| 911 | await p.evaluate((th) => { |
| 912 | try { window.DaimondTheme.set(th); } catch (e) { document.documentElement.setAttribute('data-theme', th); } |
| 913 | }, theme); |
| 914 | await sleep(400); |
| 915 | await shot(s, `termpanel-${theme}`); |
| 916 | } |
| 917 | check('a screenshot was taken in a dark and a light palette', true); |
| 918 | } |
| 919 | |
| 920 | // ── The Terminal folder row ───────────────────────────────────── |
| 921 | // |
| 922 | // There is NO pulldown, and there was one twice: a list of somebody's particular |
| 923 | // directories is a guess about which folders a person might want, not a choice. The row |
| 924 | // states the folder a terminal will open in and offers a walk over the machine's own. |
| 925 | { |
| 926 | await installLink({ caps: ['fence:linux', 'landlock:abi-8', 'ws:abc123', 'browse:dirs', |
| 927 | 'terminal-ceiling:/home/u'] }); |
| 928 | await installComposer(); |
| 929 | await p.click('#panel-term [data-act="term-start"]', { force: true }); |
| 930 | await sleep(300); |
| 931 | await p.evaluate(() => { try { DaimondAdmin.home(); } catch (e) { try { DaimondAdmin.status(); } catch (x) {} } }); |
| 932 | await sleep(200); |
| 933 | const row = await p.evaluate(() => { |
| 934 | const sec = document.getElementById('termroot-section'); |
| 935 | const home = document.getElementById('admin-home'); |
| 936 | return { |
| 937 | shown: !!sec && sec.style.display !== 'none', |
| 938 | inHome: !!(sec && home && home.contains(sec)), |
| 939 | // The defect this replaces: any pulldown at all. |
| 940 | selects: sec ? sec.querySelectorAll('select').length : -1, |
| 941 | now: (document.getElementById('termroot-now') || {}).textContent, |
| 942 | browse: !!document.getElementById('termroot-browse'), |
| 943 | }; |
| 944 | }); |
| 945 | check('the Terminal folder row states the folder and offers a walk, with NO pulldown', |
| 946 | row.shown && row.inHome && row.selects === 0 && row.browse === true |
| 947 | && row.now === '/nowhere/ws', |
| 948 | JSON.stringify(row)); |
| 949 | |
| 950 | // A folder chosen through the walk has to REACH the request, or the row is decoration. |
| 951 | await p.evaluate(() => { |
| 952 | const all = JSON.parse(localStorage.getItem('daimond-terminal-root') || '{}'); |
| 953 | all.abc123 = '/home/u/work'; |
| 954 | localStorage.setItem('daimond-terminal-root', JSON.stringify(all)); |
| 955 | }); |
| 956 | await p.click('#panel-term [data-act="term-stop"]', { force: true }); |
| 957 | await sleep(120); |
| 958 | await p.click('#panel-term [data-act="term-start"]', { force: true }); |
| 959 | await sleep(300); |
| 960 | const asked = await p.evaluate(() => (window.__asked || {}).terminal_root); |
| 961 | check('and a folder chosen through the walk reaches the request the wire carries', |
| 962 | asked === '/home/u/work', JSON.stringify(asked)); |
| 963 | |
| 964 | // A folder the machine no longer offers is dropped rather than sent. |
| 965 | await p.evaluate(() => { |
| 966 | const all = JSON.parse(localStorage.getItem('daimond-terminal-root') || '{}'); |
| 967 | all.abc123 = '/etc'; |
| 968 | localStorage.setItem('daimond-terminal-root', JSON.stringify(all)); |
| 969 | }); |
| 970 | await p.click('#panel-term [data-act="term-stop"]', { force: true }); |
| 971 | await sleep(120); |
| 972 | await p.click('#panel-term [data-act="term-start"]', { force: true }); |
| 973 | await sleep(300); |
| 974 | const stray = await p.evaluate(() => (window.__asked || {}).terminal_root); |
| 975 | check('a stored folder outside what the machine offers falls back rather than being sent', |
| 976 | stray === '' || stray === undefined, JSON.stringify(stray)); |
| 977 | |
| 978 | // PINNED at a shell: said, and not offered. |
| 979 | await p.click('#panel-term [data-act="term-stop"]', { force: true }); |
| 980 | await installLink({ caps: ['fence:linux', 'landlock:abi-8', 'ws:abc123', 'browse:dirs', |
| 981 | 'terminal-ceiling:/home/u', 'terminal-root:/home/u'] }); |
| 982 | await p.click('#panel-term [data-act="term-start"]', { force: true }); |
| 983 | await sleep(300); |
| 984 | const pinned = await p.evaluate(() => ({ |
| 985 | said: (document.getElementById('termroot-pinned') || {}).style |
| 986 | ? document.getElementById('termroot-pinned').style.display !== 'none' : null, |
| 987 | now: (document.getElementById('termroot-now') || {}).textContent, |
| 988 | browse: (document.getElementById('termroot-browse') || {}).style |
| 989 | ? document.getElementById('termroot-browse').style.display : null, |
| 990 | })); |
| 991 | // ── The folder the hand may work in at all ───────────────── |
| 992 | // |
| 993 | // Until 2026-08-27 this was changeable only by editing `root.txt` at a shell, so a new |
| 994 | // user picked a folder before knowing what the app did with one and had nowhere to go |
| 995 | // on finding they had picked wrongly. It is a second row over the SAME walk, and what |
| 996 | // is checked is that it exists, states the granted folder, and is a different question |
| 997 | // from the terminal's. |
| 998 | const grantRow = await p.evaluate(() => { |
| 999 | const sec = document.getElementById('grantroot-section'); |
| 1000 | const home = document.getElementById('admin-home'); |
| 1001 | return { |
| 1002 | shown: !!sec && sec.style.display !== 'none', |
| 1003 | inHome: !!(sec && home && home.contains(sec)), |
| 1004 | now: (document.getElementById('grantroot-now') || {}).textContent, |
| 1005 | browse: !!document.getElementById('grantroot-browse'), |
| 1006 | // Its own walk panel, so the folders are drawn under the question they answer. |
| 1007 | ownWalk: !!(sec && sec.querySelector('#grantroot-walk')), |
| 1008 | }; |
| 1009 | }); |
| 1010 | // AND IT MUST APPEAR WHEN THE FOLDER IS WRONG, which is the only time anyone needs it. |
| 1011 | // A hand whose folder disagrees with the page's is not `paired`, and the row was learned |
| 1012 | // after that check -- so it was invisible in exactly the situation it exists for. |
| 1013 | { |
| 1014 | await p.evaluate(() => { |
| 1015 | const s = window.DaimondPty.status; |
| 1016 | window.__savedStatus = s; |
| 1017 | window.DaimondPty.status = async () => JSON.stringify({ |
| 1018 | paired: false, reason: 'the folders disagree', transport: 'machine', |
| 1019 | root: '/nowhere/ws', |
| 1020 | caps: ['fence:linux', 'ws:abc123', 'browse:dirs', 'terminal-ceiling:/home/u'], |
| 1021 | }); |
| 1022 | }); |
| 1023 | await p.click('#panel-term [data-act="term-start"]', { force: true }); |
| 1024 | await sleep(300); |
| 1025 | const whenWrong = await p.evaluate(() => { |
| 1026 | const sec = document.getElementById('grantroot-section'); |
| 1027 | return { shown: !!sec && sec.style.display !== 'none', |
| 1028 | now: (document.getElementById('grantroot-now') || {}).textContent }; |
| 1029 | }); |
| 1030 | check('and it appears even when the hand is NOT paired, which is when it is needed', |
| 1031 | whenWrong.shown === true && whenWrong.now === '/nowhere/ws', |
| 1032 | JSON.stringify(whenWrong)); |
| 1033 | await p.evaluate(() => { window.DaimondPty.status = window.__savedStatus; }); |
| 1034 | await p.click('#panel-term [data-act="term-stop"]', { force: true }); |
| 1035 | await sleep(120); |
| 1036 | await p.click('#panel-term [data-act="term-start"]', { force: true }); |
| 1037 | await sleep(300); |
| 1038 | } |
| 1039 | |
| 1040 | check('the granted folder can be changed from Settings, over the same walk', |
| 1041 | grantRow.shown && grantRow.inHome && grantRow.browse === true |
| 1042 | && grantRow.now === '/nowhere/ws' && grantRow.ownWalk === true, |
| 1043 | JSON.stringify(grantRow)); |
| 1044 | |
| 1045 | check('a folder pinned at a shell is stated and the walk is withdrawn', |
| 1046 | pinned.said === true && pinned.now === '/home/u' && pinned.browse === 'none', |
| 1047 | JSON.stringify(pinned)); |
| 1048 | await p.click('#panel-term [data-act="term-stop"]', { force: true }); |
| 1049 | } |
| 1050 | |
| 1051 | const noise = s.errs.filter((e) => !/favicon|ERR_ABORTED|502|Bad Gateway|net::ERR/i.test(e)); |
| 1052 | check('the app threw nothing while all that happened', noise.length === 0, noise.slice(0, 3).join(' | ')); |
| 1053 | } finally { |
| 1054 | await s.close().catch(() => {}); |
| 1055 | for (const proc of started) { try { proc.kill(); } catch (e) { /* already gone */ } } |
| 1056 | } |
| 1057 | |
| 1058 | console.log(`\n${ok.length} ok, ${bad.length} failed, ${provedNames.length} properties proved against broken code`); |
| 1059 | process.exit(bad.length ? 1 : 0); |