Oregami
Repositories/oxedyne/daimond

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
54import fs from 'node:fs';
55import net from 'node:net';
56import path from 'node:path';
57import { spawn } from 'node:child_process';
58import { fileURLToPath } from 'node:url';
59
60const HERE = path.dirname(fileURLToPath(import.meta.url));
61const ROOT = path.join(HERE, '..');
62
63const INDEX = path.join(ROOT, 'www/index.html');
64const APPJS = path.join(ROOT, 'www/js/daimond.js');
65const RELAYJS = path.join(ROOT, 'www/js/handpty.js');
66const I18NDIR = path.join(ROOT, 'www/i18n');
67
68const ok = [], bad = [];
69const check = (name, pass, detail) => {
70 (pass ? ok : bad).push(name);
71 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + String(detail).slice(0, 180) : ''));
72};
73const 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.
83const provedNames = [];
84async 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
100const indexSrc = fs.readFileSync(INDEX, 'utf8');
101const appSrc = fs.readFileSync(APPJS, 'utf8');
102const relaySrc = fs.readFileSync(RELAYJS, 'utf8');
103
104console.log('\nThe panel, as the page declares it');
105
106const asideRe = /<aside[^>]*data-panel="term"[^>]*>/;
107const aside = (asideRe.exec(indexSrc) || [''])[0];
108check('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));
110check('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));
112check('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}
143check('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));
146check('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
150check('daimond.js opens the panel through the layout engine\'s own seam',
151 /id === 'term' && window\.DaimondTerm\) DaimondTerm\.onOpen\(\)/.test(appSrc));
152check('and closes it through the matching one',
153 /id === 'term' && window\.DaimondTerm\) DaimondTerm\.onClose\(\)/.test(appSrc));
154check('a Diamond change reaches the panel',
155 /daimond-diamond-changed[\s\S]{0,200}DaimondTerm\.onDiamondChanged\(\)/.test(appSrc));
156check('a panel already open in the saved layout is built at boot too',
157 /DaimondPanels\.isOpen\('term'\)\) DaimondTerm\.onOpen\(\)/.test(appSrc));
158check('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.
169function 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.
179function 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}
187const panelSrc = panelSource(appSrc);
188check('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.
193provedNames.push('the fence-composition check is not blind');
194check('PROVED the fence-composition check sees a fence when there is one',
195 composesAFence(panelSrc + '\nvar f = { rw: [root], ro: [], deny: [], net: false };\n'));
196check('and the panel composes no fence of its own', !composesAFence(panelSrc));
197check('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
202const 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];
211const locales = fs.readdirSync(I18NDIR).filter((f) => f.endsWith('.js'));
212/// Which of `keys` a locale file does not define.
213function missing(src, keys) {
214 return keys.filter((k) => !new RegExp(`['"]${k.replace('.', '\\.')}['"]\\s*:`).test(src));
215}
216const enSrc = fs.readFileSync(path.join(I18NDIR, 'en.js'), 'utf8');
217provedNames.push('the missing-string check is not blind');
218check('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');
220for (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.
227const asks = [...panelSrc.matchAll(/(?:^|[^\w.$])t\('([\w.]+)'/g)].map((m) => m[1]);
228const unknown = asks.filter((k) => !new RegExp(`['"]${k.replace(/\./g, '\\.')}['"]\\s*:`).test(enSrc));
229check('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
234function 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}
241const started = [];
242async 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.
254const APP_PORT = Number(process.env.DAIMOND_PORT || 8777);
255const MOCK_PORT = Number(process.env.DAIMOND_MOCK_PORT || 9099);
256await serve('dev server', ['dev/serve.mjs'], APP_PORT);
257await serve('mock provider', ['dev/mockllm.mjs'], MOCK_PORT);
258
259const { 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.
263const 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})();
267check('handpty.js still has the sentence a page with no relay shows',
268 NO_RELAY.length > 80, NO_RELAY.slice(0, 60));
269
270const s = await openApp({ name: 'termpanel' });
271const p = s.page;
272
273/// The link double: what hand.js is asked to provide, and nothing more.
274async 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.
316async 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.
333async function openPanel() {
334 await p.evaluate(() => window.DaimondPanels.show('term'));
335 await sleep(700);
336}
337async function closePanel() {
338 await p.evaluate(() => window.DaimondPanels.hide('term'));
339 await sleep(300);
340}
341/// The sentence the panel is showing.
342const stateText = () => p.evaluate(() => (document.getElementById('termp-state') || {}).textContent || '');
343/// The session the panel believes it has.
344const sessionId = () => p.evaluate(() => window.DaimondTerm.session());
345/// Everything the link has been sent since it was installed.
346const 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.
348const mirror = () => p.evaluate(() =>
349 ((document.querySelector('#termp-host .term-mirror') || {}).textContent || '').replace(/\s+/g, ' '));
350
351try {
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
1058console.log(`\n${ok.length} ok, ${bad.length} failed, ${provedNames.length} properties proved against broken code`);
1059process.exit(bad.length ? 1 : 0);