Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_pausewidget.mjs

59.5 KiB, 1 run

created by r2519314175:583, 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_pausewidget.mjs — the PPTW, searched for its properties rather than
2// walked down a happy path.
3//
4// The rule the whole control rests on (dev/NOTES2_PLAN.md §1.1, with the
5// denominator corrected):
6//
7// A leaf is binary. A branch shows green when every ARMED leaf under it plays,
8// red when none does, and amber otherwise. AMBER IS DERIVED AND CAN NEVER BE
9// SET. Pausing a branch pauses all its leaves; resuming it resumes them all.
10//
11// `ARMED` is the word notes2 did not have and the reason it did not need one:
12// when the plan was written every leaf was something that could act on its own,
13// so "every leaf" and "every armed leaf" were the same set. They stopped being
14// the same set once mail folders (which may be manual), a Diamond's own turns
15// and loose chats became leaves, and the light then said "running" about things
16// nobody had automated. Pausing is unchanged: it still writes every leaf,
17// armed or not, which is what keeps "pause Everything" meaning everything.
18//
19// THE CONTROL IS THREE THINGS, IN THE ORDER ITS NAME GIVES. notes2.txt line 3:
20// "Pause/Play/Traffic light widgets (PPTWs) for all spendable functions. Green
21// = all spendable functions active, Amber = some are active, Red = all paused."
22//
23// a PLAY a verb
24// a PAUSE a verb
25// a TRAFFIC LIGHT a coloured disc, LAST, with NOTHING inside it
26//
27// It was briefly one button drawn from `data-state`, which put a green PLAY
28// triangle on a running node — the colour said "running" while the shape said
29// "play" and the only thing pressing it could do was pause. The first rebuild
30// then put that same glyph inside the lamp and led with it, which is the same
31// confusion one place to the right. Both the ORDER and the EMPTINESS of the
32// light are checked below, because both have been got wrong.
33//
34// Both verbs are always present; the inapplicable one is DISABLED, not hidden.
35// On a leaf exactly one is ever live. ON AN AMBER BRANCH BOTH ARE — which is
36// the property one button could not have: a single control had to guess, and
37// `clickWould` guessed resume-all with nothing on screen to say so.
38//
39// A test that pauses one thing and reads the flag back confirms the script. So
40// this enumerates: every subset of the leaf set is built directly, and then
41// EVERY verb of EVERY control on the page is pressed from it, and the result is
42// judged against a rule this file computes for itself from a tree it builds for
43// itself out of the rail's DOM. `DaimondPause._core` is never consulted —
44// checking the module against its own arithmetic would agree with it right up
45// to the case that is wrong.
46//
47// WHAT THIS FILE LOCKS DOWN.
48//
49// A. A verb does what it says, from all 2^n leaf states: pause leaves the node
50// red, play leaves it green, neither ever leaves it amber; the leaves under
51// it come out uniform; the leaves outside it do not move; and the light
52// draws what the module says.
53// A2. WHICH VERB IS OFFERED matches the state — pause alone when running, play
54// alone when paused, BOTH when amber — and the light is not a control.
55// B. A branch agrees with its leaves — for the empty case, the all-paused
56// case, the all-playing case, and every mixed case in between.
57// C. The global control IS the root: pausing it pauses every leaf including
58// the worker pump, every tile shows it, and it holds no state of its own.
59// D. The old worker hold migrates. Seeded on a fresh profile, the pump comes
60// up held, the old key is gone, and the hold is enforced AT THE NETWORK —
61// a resumed agent sends nothing while the leaf is set.
62// E. Pause does not pause reading: a paused Diamond still opens, its crystal
63// still renders, and the workspace still lists.
64// F. The verbs are reachable and named: real buttons, in the tab order,
65// answering Enter and Space, each with an accessible name that says which
66// node it governs and which verb it is — and the light beside them is
67// named, unfocusable and inert.
68//
69// PROVED RED. Two ways. `--unbuilt` neuters the three new pieces before the app
70// boots — no widget is built, no tree is registered, and the pump reads the old
71// key — which is the page as it stood before this phase; every check above must
72// fail there. And the self-test section at the foot breaks each property in the
73// live page one at a time and requires the matching check to notice.
74//
75// node dev/verify_pausewidget.mjs
76// node dev/verify_pausewidget.mjs --unbuilt # must fail, loudly
77//
78// Needs a world: `eval "$(bash dev/world.sh N --up)"`. No gateway.
79
80import fs from 'node:fs';
81import { open, newChat, scratch, mockLog, clearMockLog } from './harness.mjs';
82
83const UNBUILT = process.argv.includes('--unbuilt');
84
85const out = [];
86let bad = 0;
87const check = (ok, what, detail) => {
88 out.push(`${ok ? 'PASS' : 'FAIL'} ${what}${detail != null ? ' — ' + detail : ''}`);
89 if (!ok) bad++;
90 return ok;
91};
92const red = (wentRed, what) => check(wentRed, `[self-test] ${what}`);
93
94/// Click a control through the browser's own hit-testing, or say it was not
95/// there. Guarded because `--unbuilt` is a page with no controls on it at all,
96/// and a throw there would end the run before it could report anything.
97async function clickReal(page, sel) {
98 const el = await page.$(sel);
99 if (!el) return false;
100 await el.click({ force: true }).catch(() => {});
101 return true;
102}
103
104// ── The oracle: a tree and a rule this file owns ────────────────────
105//
106// Built from the rail's own DOM and the mail store, not from anything the
107// module publishes, so "the branch agrees with its leaves" is checked against
108// an independent reading of what exists rather than against the module's view
109// of itself.
110
111/// The module's id escaping, reimplemented. Two lines, and reimplementing them
112/// is the point: a folder called "a/b" must not invent a level of tree.
113const idOf = (...parts) => parts.filter((p) => p != null && p !== '')
114 .map((p) => String(p).replace(/%/g, '%25').replace(/\//g, '%2F')).join('/');
115
116/// Every leaf id at or under a node of OUR tree. A node with no `children`
117/// array is a leaf; one with an empty array is an empty branch.
118function leavesOf(node) {
119 if (!node.children) return [node.id];
120 return node.children.flatMap(leavesOf);
121}
122
123/// Every ARMED leaf id at or under a node: the ones with something set up to
124/// spend WITHOUT BEING ASKED. `armed` is put on our own tree by `expectedTree`,
125/// from the rail's DOM and the mail store, so this stays an independent reading.
126///
127/// An absent `armed` is armed, which is the module's own default and is here for
128/// the same reason: a leaf added later without thinking about it behaves as it
129/// did rather than silently dropping out of every light above it.
130function armedOf(node) {
131 if (!node.children) return node.armed === false ? [] : [node.id];
132 return node.children.flatMap(armedOf);
133}
134
135/// The rule, stated once, in this file.
136///
137/// COUNTED OVER THE ARMED LEAVES, which is what changed. It used to count every
138/// leaf, so a node nobody had paused read green -- and green is read as
139/// "running". The owner read the Email panel exactly that way and said so: it
140/// "shows green when all mailboxes are updated manually", green with nothing
141/// automated at all, and "in the default case, the light should show red, since
142/// there is no automation running". A node with no armed leaf under it is
143/// `idle`: red, and told apart from `pause` in the WORD rather than the colour,
144/// because "there is no automation here" and "the automation here is stopped"
145/// are different things to say to somebody.
146function ruleFor(node, paused) {
147 const leaves = armedOf(node);
148 if (!leaves.length) return 'idle'; // nothing here runs on its own
149 const n = leaves.filter((l) => paused.has(l)).length;
150 return n === 0 ? 'play' : n === leaves.length ? 'pause' : 'mixed';
151}
152
153/// Which of the two verbs a state offers, in DOM order — play, pause, light — so
154/// a mixed branch reads `['play','pause']`. `idle` offers what `pause` offers:
155/// there is nothing running to stop, so play is live and pause is greyed.
156const VERBS = { play: ['pause'], pause: ['play'], idle: ['play'], mixed: ['play', 'pause'] };
157
158/// Is every leaf under this node held?
159const heldAll = (node, paused) => {
160 const ls = leavesOf(node);
161 return ls.length > 0 && ls.every((l) => paused.has(l));
162};
163
164/// The verbs a node offers, which is not always the verbs its LIGHT would offer.
165///
166/// A `stoppable` node arms nothing — so its light is red whether it is held or
167/// not — while holding it still stops something. `root/web` is the only one:
168/// the app answers a page fetch with 423 while that leaf is held, so the pause
169/// verb has work to do on a control the light calls idle. Everything else takes
170/// its verbs from the light, which is the owner's rule.
171const verbsFor = (node, paused) =>
172 node.stoppable ? (heldAll(node, paused) ? ['play'] : ['pause'])
173 : VERBS[ruleFor(node, paused)];
174
175/// FOUR STATES, THREE COLOURS. `idle` and `pause` are both red, so a paint is
176/// compared through the same collapse the widget makes rather than to the raw
177/// state. Written as one function rather than as `!== 'idle'` at each site, so a
178/// fifth state cannot arrive without this file having an opinion about it — and
179/// `data-armed` is separately asserted, so the collapse cannot hide a light that
180/// stopped tracking one of the two.
181const colourOf = (st) => (st === 'idle' ? 'pause' : st);
182
183/// Walk our tree, node by node.
184function* walk(node) {
185 yield node;
186 for (const kid of node.children || []) yield* walk(kid);
187}
188
189/// What is on the rail, read off the rail.
190async function expectedTree(p) {
191 const seen = await p.evaluate(() => {
192 let mail = [];
193 try {
194 const j = JSON.parse(localStorage.getItem('daimond-mail') || '{}');
195 mail = (Array.isArray(j.accounts) ? j.accounts : []).filter((a) => a && a.address)
196 .map((a) => ({
197 address: a.address,
198 folders: Object.keys(a.folders || {}),
199 sel: a.folder || 'INBOX',
200 // SECONDS PER FOLDER, and zero means manual only. `mail.js`'s
201 // `refreshOf` reads the same map; read here off the store rather
202 // than asked of the module, so this stays an oracle.
203 refresh: (a.refresh && typeof a.refresh === 'object') ? a.refresh : {},
204 }));
205 } catch (e) { /* none */ }
206 // Is the worker pump running anything? Off the same store `Workers.load`
207 // reads, not off `Workers.runs`, for the reason `idOf` is reimplemented.
208 let busyWorkers = false;
209 try {
210 const w = JSON.parse(localStorage.getItem('daimond-workers') || '[]');
211 const runs = Array.isArray(w) ? w : ((w && w.runs) || []);
212 busyWorkers = runs.some((r) => r && (r.status === 'running' || r.status === 'queued'));
213 } catch (e) { /* none */ }
214 return {
215 busyWorkers,
216 // Each Diamond with the triggered actions it holds. Phase H made a
217 // Diamond a branch with more than one leaf under it -- `pause.js`
218 // documented that shape long before there were any -- so a file that
219 // assumed one leaf per Diamond read every Diamond with a trigger as
220 // "mixed" where it wanted "pause", and its click sweep expected a leaf
221 // set that was one short.
222 diamonds: [...document.querySelectorAll('.diamond-box')].map((e) => ({
223 id: e.dataset.id,
224 triggers: (() => {
225 try {
226 // The whole record, not merely the id: whether a TA is ARMED is
227 // whether it would actually fire, and that is `on` AND the fields
228 // its kind cannot go without AND an instruction. Judged below by
229 // this file's own copy of the test rather than by asking
230 // `DaimondTriggers.ready`, exactly as `idOf` reimplements the
231 // escaping: a TA switched on with no mailbox is one the trigger
232 // module already refuses, and a light that counted it would be
233 // reporting a thing that cannot happen.
234 return (window.DaimondTriggersOf ? DaimondTriggersOf(e.dataset.id) : [])
235 .map((t) => ({
236 id: t.id,
237 armed: t.on !== false
238 && String(t.instruction || '').trim() !== ''
239 && (t.kind === 'activity' ? !!t.minutes
240 : t.kind === 'mail' ? (!!t.mailbox && !!t.folder)
241 : false),
242 }));
243 } catch (x) { return []; }
244 })(),
245 // EVERY Diamond is in the tree, automated or not. Its TILE may draw no
246 // widget — the `prompted` action that used to give each one something
247 // to hold is gone — but the global control writes through this tree,
248 // and "pause Everything" has to mean everything.
249 })),
250 chats: [...document.querySelectorAll('.chat-box')].map((e) => e.dataset.id),
251 mail,
252 };
253 });
254 return {
255 id: 'root',
256 children: [
257 // A DIAMOND'S OWN TURNS ARE NOT AUTOMATION, so `self` is a leaf the
258 // global control still writes and the light does not count. Its daimon
259 // answers because somebody typed; notes2's "Daimon Prompted" trigger was
260 // removed for exactly that reason, and this is the same fact stated in
261 // the tree.
262 { id: 'root/diamonds', children: seen.diamonds.map((d) => ({
263 id: idOf('root', 'diamonds', d.id),
264 children: [{ id: idOf('root', 'diamonds', d.id, 'self'), armed: false }].concat(
265 (d.triggers || []).map((t) =>
266 ({ id: idOf('root', 'diamonds', d.id, 'triggers', t.id), armed: t.armed }))),
267 })) },
268 { id: 'root/chats', children: seen.chats.map((c) => ({ id: idOf('root', 'chats', c), armed: false })) },
269 { id: 'root/mail', children: seen.mail.map((a) => {
270 const names = a.folders.includes(a.sel) ? a.folders.slice() : a.folders.concat([a.sel]);
271 const scheduled = (n) => typeof a.refresh[n] === 'number' && a.refresh[n] > 0;
272 return { id: idOf('root', 'mail', a.address), children:
273 [{ id: idOf('root', 'mail', a.address, 'self'), armed: names.some(scheduled) }]
274 .concat(names.sort().map((n) =>
275 ({ id: idOf('root', 'mail', a.address, n), armed: scheduled(n) }))) };
276 }) },
277 { id: 'root/workers', armed: seen.busyWorkers },
278 // A leaf because a page fetch spends; without it the enforcement would
279 // fall back to the root. NOT ARMED — it is fetched during a turn somebody
280 // started — and STOPPABLE, because the Web panel does place the widget on
281 // it (`mountPause(web, PAUSE_WEB, …)`, 2026-08-10) and holding it stops a
282 // real spend. The two facts were read as one on 2026-08-28 and the pause
283 // verb on that control could not act for a day; the line above this one
284 // used to say the Web panel was not a placement, and it was that sentence
285 // the arming decision was made against.
286 { id: 'root/web', armed: false, stoppable: true },
287 ],
288 };
289}
290
291// ── The run ─────────────────────────────────────────────────────────
292
293const profile = scratch('pw', 'pptw-' + process.pid);
294const s = await open({ name: 'pptw' + process.pid, profile });
295const closeBrowser = s.close;
296s.close = async () => {
297 await closeBrowser();
298 try { fs.rmSync(profile, { recursive: true, force: true }); } catch (e) { /* gone */ }
299};
300const p = s.page;
301
302// The unbuilt page: no widget is built, no tree is registered, and the pump
303// reads the key this phase migrated away. Installed by rewriting the script as
304// it is served to the page, so it is the OLD code that runs rather than the new
305// code with a flag in it.
306if (UNBUILT) {
307 await p.route('**/js/daimond.js', async (route) => {
308 const res = await route.fetch();
309 let body = await res.text();
310 body = body
311 .replace('function pauseWidget(nodeId, name) {',
312 'function pauseWidget(nodeId, name) { return document.createTextNode(""); /* UNBUILT */ // eslint-disable-line')
313 .replace('DaimondPause.setTree(pauseTree);', '/* UNBUILT: no tree was registered */')
314 .replace('function migrateWorkerHold() {', 'function migrateWorkerHold() { return; /* UNBUILT */')
315 .replace('function workersHeld() {',
316 'function workersHeld() { return localStorage.getItem(WORKERS_PAUSED_KEY) === "1"; /* UNBUILT */');
317 await route.fulfill({ response: res, body, headers: { ...res.headers(), 'content-type': 'text/javascript; charset=utf-8' } });
318 });
319 await p.reload({ waitUntil: 'domcontentloaded' });
320 await p.waitForTimeout(1500);
321 // The reload lands on the unlock screen, because the identity now exists.
322 const gate = await p.$('#id-pass');
323 if (gate && await gate.isVisible()) {
324 await p.fill('#id-pass', 'testpass1234');
325 await p.evaluate(() => document.getElementById('id-primary').click());
326 await p.waitForSelector('#identity-modal', { state: 'hidden', timeout: 15000 }).catch(() => {});
327 }
328 await p.waitForTimeout(2000);
329 const broke = s.errs.filter((e) => !/favicon|401|402|502|Unauthorized|Payment|Bad Gateway/i.test(e));
330 if (broke.length) console.log('unbuilt page errors:', JSON.stringify(broke.slice(0, 4)));
331}
332
333await p.waitForTimeout(800);
334
335// Two Diamonds and two chats, so a section can be genuinely mixed rather than
336// merely half of one thing.
337async function newDiamond(name) {
338 await p.click('#new-diamond-btn', { force: true });
339 await p.waitForSelector('.dlg-input', { timeout: 10000 });
340 await p.fill('.dlg-input', name);
341 await p.click('.dlg-ok', { force: true });
342 await p.waitForTimeout(700);
343}
344await newDiamond('Alpha');
345await newDiamond('Beta');
346
347// GIVE THEM SOMETHING TO GOVERN. A Diamond with no triggered actions has no
348// pause node and no widget — prompting it is the activation, so a control would
349// stand for a decision already made by typing. That is the product rule, and it
350// leaves this file, which is about the WIDGET, with nothing to point at. So each
351// of these two gets a timer, exactly as the Optimiser ships with one.
352await p.evaluate(async () => {
353 const ids = [...document.querySelectorAll('.diamond-box')].map((e) => e.dataset.id);
354 for (const id of ids) {
355 if ((DaimondTriggersOf(id) || []).length) continue; // the Optimiser has one already
356 const ta = DaimondTriggers.blank('activity');
357 ta.id = 'activity-1';
358 ta.instruction = 'Say one useful thing.';
359 await DaimondCore.triggerSet(id, ta);
360 }
361});
362await p.waitForTimeout(700);
363await newChat(s);
364await p.click('#new-session-btn', { force: true });
365await p.waitForTimeout(400);
366{
367 const start = p.locator('.tile-start').first();
368 if (await start.count()) await start.click({ force: true });
369}
370await p.waitForTimeout(900);
371{
372 const drawer = p.locator('#admin-close');
373 if (await drawer.isVisible().catch(() => false)) { await drawer.click({ force: true }); await p.waitForTimeout(300); }
374}
375
376const tree = await expectedTree(p);
377const nodes = [...walk(tree)];
378const leaves = nodes.filter((n) => !n.children).map((n) => n.id);
379const byId = new Map(nodes.map((n) => [n.id, n]));
380
381check(leaves.length >= 5, `the page carries enough leaves to search (${leaves.length}: ${leaves.join(', ')})`,
382 leaves.length < 5 ? JSON.stringify(leaves) : null);
383
384/// Every control on the page, and the node each governs. A control is now the
385/// GROUP — the light and the two verbs — so each is read as its three parts.
386const controls = () => p.evaluate(() => [...document.querySelectorAll('.pptw')].map((g) => {
387 const lamp = g.querySelector('.pptw-lamp');
388 const part = (b) => ({
389 act: b.dataset.act,
390 tag: b.tagName.toLowerCase(),
391 disabled: !!b.disabled,
392 label: b.getAttribute('aria-label'),
393 // Enabled buttons are in the tab order; a DISABLED one is deliberately
394 // not, and must not be counted against it.
395 inTabOrder: b.matches('button:not([disabled]),[tabindex]:not([tabindex="-1"])') || b.disabled,
396 iconHidden: [...b.querySelectorAll('svg')].every((s) => s.getAttribute('aria-hidden') === 'true'),
397 });
398 return {
399 node: g.dataset.pauseNode, name: g.dataset.pauseName,
400 state: g.dataset.state,
401 tag: g.tagName.toLowerCase(),
402 lamp: lamp ? {
403 tag: lamp.tagName.toLowerCase(),
404 role: lamp.getAttribute('role'),
405 label: lamp.getAttribute('aria-label'),
406 // Focusable at all? `tabIndex >= 0` is the browser's own answer, and it
407 // is 0 for a <button> even without the attribute.
408 focusable: lamp.tabIndex >= 0,
409 // EMPTY is the specification. Anything at all inside the lamp -- an
410 // svg, a path, a character -- is a glyph, and a glyph in a light is a
411 // verb worn as a noun.
412 inside: lamp.innerHTML.trim(),
413 kids: lamp.childElementCount,
414 } : null,
415 // The DOM order, which is the reading order and the tab order both.
416 order: [...g.children].map((e) => e.dataset.act || (e.classList.contains('pptw-lamp') ? 'lamp' : '?')),
417 acts: [...g.querySelectorAll('.pptw-act')].map(part),
418 where: g.closest('.diamond-box') ? 'diamond' : g.closest('.chat-box') ? 'chat'
419 : g.closest('#pptw-global-row') ? 'global' : 'other',
420 };
421}));
422
423const ctl = await controls();
424check(ctl.length >= 5, `a control is on each placement (${ctl.length} found)`,
425 JSON.stringify(ctl.map((c) => c.where + ':' + c.node)));
426check(ctl.some((c) => c.where === 'global'), 'one at the top of the rail, above the Diamonds list');
427// One per Diamond ON THE RAIL, whatever else the account holds. It used to be the
428// literal 2, which was the number of Diamonds this file happens to make -- and
429// phase H seeds two more (Daimond Help, Daimond Optimiser), so the constant said
430// four were two. A tile's own light is what is under test; the count of tiles is
431// the app's business.
432const tiles = await p.evaluate(() =>
433 document.querySelectorAll('#diamond-list .diamond-box').length);
434check(ctl.filter((c) => c.where === 'diamond').length === tiles, 'one on each Diamond tile',
435 `${ctl.filter((c) => c.where === 'diamond').length} of ${tiles}`);
436// NONE on a chat tile, at the user's ruling: "ordinary chats do not need a
437// pptw". Same rule as an unautomated Diamond — a widget is a standing offer to
438// hold something that runs on its own, and a chat spends when you type in it and
439// at no other time. A chat is still a LEAF of the tree, so the global control
440// reaches it; what has gone is the drawing, not the governance.
441check(ctl.filter((c) => c.where === 'chat').length === 0,
442 'and NONE on a chat tile — a chat spends only when you type in it',
443 `${ctl.filter((c) => c.where === 'chat').length}`);
444
445// ── F. Reachable and named ──────────────────────────────────────────
446const acts = ctl.flatMap((c) => c.acts);
447check(ctl.length > 0 && ctl.every((c) => c.acts.length === 2),
448 'every control carries exactly two verbs', JSON.stringify(ctl.map((c) => c.acts.length)));
449// THE ORDER IS THE SPECIFICATION, notes2.txt line 3 and the widget's own name.
450// Play, pause, then the traffic light on the right. Got this backwards once --
451// light first, with a glyph in it -- so it is pinned rather than assumed.
452check(ctl.length > 0 && ctl.every((c) => JSON.stringify(c.order) === '["play","pause","lamp"]'),
453 'PLAY, PAUSE, THEN THE LIGHT — in that order, at every placement',
454 JSON.stringify(ctl.map((c) => c.order)));
455check(acts.length > 0 && acts.every((a) => a.tag === 'button'),
456 'every verb is a real <button>', JSON.stringify([...new Set(acts.map((a) => a.tag))]));
457check(acts.length > 0 && acts.every((a) => a.inTabOrder),
458 'every live verb is in the tab order, like the rest of the rail');
459check(acts.length > 0 && acts.every((a) => a.iconHidden),
460 'its glyph is aria-hidden, so it is not read out beside its own label');
461check(acts.length > 0 && acts.every((a) => a.label && /[\p{L}\p{N}]/u.test(a.label)),
462 'every verb has an accessible name that is not bare punctuation',
463 JSON.stringify(acts.map((a) => a.label).slice(0, 3)));
464
465// THE LIGHT IS NOT A CONTROL. This is what makes "amber can never be set" true
466// by construction: there is no press that could set it.
467const lamps = ctl.map((c) => c.lamp);
468check(lamps.length > 0 && lamps.every((l) => l && l.tag !== 'button'),
469 'the light is not a button', JSON.stringify(lamps.map((l) => l && l.tag)));
470check(lamps.length > 0 && lamps.every((l) => l && !l.focusable),
471 'and it is not focusable — nothing about it says it can be pressed',
472 JSON.stringify(lamps.map((l) => l && l.focusable)));
473check(lamps.length > 0 && lamps.every((l) => l && l.role === 'img' && l.label && /[\p{L}\p{N}]/u.test(l.label)),
474 'but it is still announced, and says what it is showing',
475 JSON.stringify(lamps.map((l) => l && l.label).slice(0, 3)));
476// THE LIGHT IS EMPTY. Colour is the whole signal. A glyph inside it is a verb
477// worn as a noun — the exact fault the compact single button had, moved one
478// place to the right — so "nothing inside" is checked as literally as it reads.
479check(lamps.length > 0 && lamps.every((l) => l && l.kids === 0 && l.inside === ''),
480 'AND IT CARRIES NOTHING BUT A COLOUR — no glyph, no symbol, nothing inside it',
481 JSON.stringify(lamps.map((l) => l && l.inside).slice(0, 3)));
482
483// The names have to say WHICH node; five rails of "Pause" are five identical
484// controls. The verb's name says the ACT, the light's says the STATE — never
485// the other way round, which is the whole fault this rebuild answers.
486{
487 const names = ctl.filter((c) => c.where !== 'global').flatMap((c) => c.acts.map((a) => a.label));
488 check(names.length > 0 && new Set(names).size === names.length,
489 'no two verbs on the page announce the same thing', JSON.stringify(names));
490 const alpha = ctl.find((c) => c.name === 'Alpha');
491 const aPause = alpha && alpha.acts.find((a) => a.act === 'pause');
492 const aPlay = alpha && alpha.acts.find((a) => a.act === 'play');
493 check(!!aPause && /Alpha/.test(aPause.label || '') && /Pause/i.test(aPause.label || ''),
494 'the pause verb names the node and says pause', JSON.stringify(aPause && aPause.label));
495 check(!!aPlay && /Alpha/.test(aPlay.label || '') && /Resume|Play/i.test(aPlay.label || ''),
496 'the play verb names the node and says resume', JSON.stringify(aPlay && aPlay.label));
497 check(!!alpha && /Alpha/.test(alpha.lamp.label || '')
498 && /running|paused/i.test(alpha.lamp.label || '')
499 && !/^(Pause|Resume)\b/i.test(alpha.lamp.label || ''),
500 'and the light names the node and its STATE, never an action',
501 JSON.stringify(alpha && alpha.lamp.label));
502}
503
504// ── A. A verb does what it says ─────────────────────────────────────
505//
506// Every subset of the leaves, times every control, times BOTH verbs. The leaves
507// are set DIRECTLY — never through a control — so the starting state is
508// genuinely arbitrary rather than something a press could reach.
509//
510// A disabled button is pressed too, and is expected to do nothing: `click()` on
511// a disabled <button> dispatches no event, and that IS the guard against the
512// state changing under a verb the page said was unavailable.
513const sweep = await p.evaluate(({ leaves, nodeIds }) => {
514 const rows = [];
515 if (!window.DaimondPause) return rows;
516 const grpFor = (n) => document.querySelector(`.pptw[data-pause-node="${n}"]`);
517 const live = nodeIds.filter(grpFor);
518 for (let m = 0; m < (1 << leaves.length); m++) {
519 for (const node of live) {
520 for (const act of ['pause', 'play']) {
521 DaimondPause.set('root', true);
522 for (let i = 0; i < leaves.length; i++) if (m & (1 << i)) DaimondPause.set(leaves[i], false);
523 const before = leaves.map((l) => DaimondPause.isPaused(l));
524 const g = grpFor(node);
525 const b = g.querySelector('.pptw-act.pptw-' + act);
526 const shown = g.dataset.state;
527 const wasDisabled = !!(b && b.disabled);
528 if (b) b.click();
529 rows.push({
530 m, node, act, shown, wasDisabled,
531 // `after` is what the LIGHT shows and `said` is what the module
532 // answers, both read after the press. They are not the same
533 // alphabet: four states, three colours, so `idle` shows as `pause`.
534 // The pair is compared through `colourOf` below rather than
535 // directly, and `armed` is what says which of the two reds it is.
536 after: g.dataset.state,
537 armed: g.dataset.armed,
538 said: DaimondPause.state(node),
539 beforeL: before,
540 afterL: leaves.map((l) => DaimondPause.isPaused(l)),
541 });
542 }
543 }
544 }
545 return rows;
546}, { leaves, nodeIds: nodes.map((n) => n.id) });
547
548check(sweep.length > 0, `the sweep ran (${sweep.length} presses over ${1 << leaves.length} leaf states)`);
549
550{
551 // Every judgement below requires the sweep to have RUN. A page with no
552 // controls on it makes "no press reaches amber" true and meaningless, and a
553 // check that cannot fail is not evidence.
554 const ran = sweep.length > 0;
555 const amber = sweep.filter((r) => r.after === 'mixed' || r.said === 'mixed');
556 check(ran && amber.length === 0,
557 'NO press of either verb, from any of the leaf states, leaves that control amber',
558 amber.length ? `${amber.length} did, e.g. ${JSON.stringify(amber[0])}` : null);
559
560 const disagree = sweep.filter((r) => r.after !== colourOf(r.said));
561 check(ran && disagree.length === 0,
562 'the light always draws what the module says the node is',
563 disagree.length ? JSON.stringify(disagree[0]) : null);
564 // AND THE TWO REDS ARE DISTINGUISHED ON THE ELEMENT, not merely in the module,
565 // or the collapse above would hide a light that had stopped tracking one of
566 // them. `data-armed` is what the accessible name is chosen from.
567 const misarmed = sweep.filter((r) => r.armed !== (r.said === 'idle' ? '0' : '1'));
568 check(ran && misarmed.length === 0,
569 'and the control says WHICH red it is, so the two are never one fact',
570 misarmed.length ? JSON.stringify(misarmed[0]) : null);
571 // AND THE TWO RED STATES ARE TOLD APART IN THE WORD. A colour cannot say
572 // "nothing is set up here"; the accessible name has to, or the two facts are
573 // indistinguishable to everybody and not merely to a screen reader.
574 const words = await p.evaluate(() => {
575 const o = {};
576 for (const st of ['play', 'pause', 'mixed', 'idle']) {
577 o[st] = window.DaimondI18n ? DaimondI18n.t('pause.state_' + st) : '';
578 }
579 return o;
580 });
581 check(words.idle && words.pause && words.idle !== words.pause,
582 'and idle and paused are DIFFERENT WORDS, since they are the same colour',
583 JSON.stringify(words));
584
585 // The leaves under the pressed node come out uniform, and everything else
586 // is left exactly as it was.
587 // A DISABLED VERB IS EXCLUDED HERE AND CHECKED SEPARATELY BELOW. It does
588 // nothing by design, so a node it left non-uniform is the page working. The
589 // property that a disabled verb really changes nothing is `ghostPress`, which
590 // is the stronger statement and is asserted over the same sweep.
591 const smeared = [], bled = [];
592 for (const r of sweep.filter((x) => !x.wasDisabled)) {
593 const under = new Set(leavesOf(byId.get(r.node)));
594 const inSet = leaves.filter((l) => under.has(l));
595 const outSet = leaves.map((l, i) => [l, i]).filter(([l]) => !under.has(l));
596 const vals = inSet.map((l) => r.afterL[leaves.indexOf(l)]);
597 if (new Set(vals).size > 1) smeared.push(r);
598 for (const [, i] of outSet) if (r.afterL[i] !== r.beforeL[i]) { bled.push(r); break; }
599 }
600 check(ran && smeared.length === 0,
601 'a press sets EVERY leaf under the node it was on, to the same thing',
602 smeared.length ? JSON.stringify(smeared[0]) : null);
603 check(ran && bled.length === 0,
604 'and touches no leaf outside it',
605 bled.length ? JSON.stringify(bled[0]) : null);
606
607 // THE VERB, NOT THE STATE, DECIDES. Pause leaves it red and play leaves it
608 // green, from wherever it started — which is what one button could not say.
609 //
610 // EXCEPT ON A NODE WITH NOTHING ARMED, where play cannot make it green because
611 // there is nothing to be running: it releases every leaf under it, exactly as
612 // on any other node, and the light stays `idle`. That is stated here rather
613 // than excluded, because "play sometimes leaves it red" is the kind of licence
614 // that swallows a real regression: the press is still asserted to have RELEASED
615 // its leaves, by `smeared`/`bled` above, which is the property that matters.
616 const wrongWay = sweep.filter((r) => r.said !== 'idle'
617 && r.after !== (r.act === 'pause' ? 'pause' : 'play'));
618 check(ran && wrongWay.length === 0,
619 'pause always ends paused and play always ends playing, from every state',
620 wrongWay.length ? JSON.stringify(wrongWay[0]) : null);
621 const idlePress = sweep.filter((r) => r.said === 'idle');
622 check(ran && idlePress.length > 0 && idlePress.every((r) => r.after === 'pause' && r.armed === '0'),
623 `a node with nothing armed stays red whichever verb is pressed (${idlePress.length} presses)`,
624 JSON.stringify(idlePress.find((r) => !(r.after === 'pause' && r.armed === '0')) || null));
625
626 // A verb the page had greyed out changed nothing — which is the same thing
627 // as saying the disabled attribute is real rather than cosmetic.
628 const ghostPress = sweep.filter((r) => r.wasDisabled
629 && JSON.stringify(r.beforeL) !== JSON.stringify(r.afterL));
630 check(ran && ghostPress.length === 0,
631 'a disabled verb changes nothing when it is pressed',
632 ghostPress.length ? JSON.stringify(ghostPress[0]) : null);
633 const disabledSeen = sweep.filter((r) => r.wasDisabled).length;
634 check(ran && disabledSeen > 0,
635 `a disabled verb was really pressed in the sweep (${disabledSeen} times)`);
636}
637
638// ── A2. Which verb is offered, and the light offers none ────────────
639//
640// The fault this rebuild answers is not arithmetic, it is what the control SAYS
641// is available. Running offers pause. Paused offers play. Amber offers BOTH —
642// with one button a mixed branch had to guess, and it guessed silently.
643{
644 const offered = await p.evaluate(({ leaves, nodeIds }) => {
645 const rows = [];
646 if (!window.DaimondPause) return rows;
647 const grpFor = (n) => document.querySelector(`.pptw[data-pause-node="${n}"]`);
648 const live = nodeIds.filter(grpFor);
649 for (let m = 0; m < (1 << leaves.length); m++) {
650 DaimondPause.set('root', true);
651 for (let i = 0; i < leaves.length; i++) if (m & (1 << i)) DaimondPause.set(leaves[i], false);
652 for (const node of live) {
653 const g = grpFor(node);
654 rows.push({
655 m, node, state: g.dataset.state,
656 live: [...g.querySelectorAll('.pptw-act')].filter((b) => !b.disabled).map((b) => b.dataset.act),
657 });
658 }
659 }
660 return rows;
661 }, { leaves, nodeIds: nodes.map((n) => n.id) });
662
663 // Judged against THIS file's tree and THIS file's rule, not against the state
664 // the widget painted on itself: a light and a verb drawn from one wrong answer
665 // agree with each other. `m` is the leaf pattern that row was taken under.
666 const byId = new Map(nodes.map((n) => [n.id, n]));
667 const pausedAt = (m) => new Set(leaves.filter((l, i) => m & (1 << i)));
668 const wrong = offered.filter((r) =>
669 JSON.stringify(r.live) !== JSON.stringify(verbsFor(byId.get(r.node), pausedAt(r.m))));
670 check(offered.length > 0 && wrong.length === 0,
671 'the verb offered is the one that can be done: pause when running, play when paused',
672 wrong.length ? `${wrong.length} wrong, e.g. ${JSON.stringify(wrong[0])}` : `${offered.length} node-states`);
673 const ambers = offered.filter((r) => r.state === 'mixed');
674 check(ambers.length > 0,
675 `an amber branch was reached in this enumeration (${ambers.length} node-states)`);
676 check(ambers.length > 0 && ambers.every((r) => r.live.length === 2),
677 'AND AN AMBER BRANCH OFFERS BOTH — the thing one button could not do',
678 JSON.stringify(ambers.filter((r) => r.live.length !== 2)[0] || null));
679
680 // The light itself. Pressing it — the way a finger would — must move nothing
681 // at all.
682 //
683 // A DIAMOND TILE's light, which is the hard case: the tile is itself a click
684 // target, so a press that is not swallowed opens the Diamond. Aiming at a
685 // light and navigating instead is the mis-tap this has to rule out, and it is
686 // only visible on a control that sits on something clickable.
687 const inert = await p.evaluate(() => {
688 DaimondPause.set('root', true);
689 const g = document.querySelector('.diamond-box .pptw');
690 if (!g) return null;
691 const lamp = g.querySelector('.pptw-lamp');
692 const before = DaimondPause.pausedIds().join(',');
693 const wasOpen = ((document.getElementById('current-session-name') || {}).textContent || '').trim();
694 lamp.click();
695 lamp.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
696 lamp.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
697 return {
698 before, after: DaimondPause.pausedIds().join(','),
699 cursor: getComputedStyle(lamp).cursor,
700 wasOpen, nowOpen: ((document.getElementById('current-session-name') || {}).textContent || '').trim(),
701 };
702 });
703 check(!!inert && inert.before === inert.after,
704 'and pressing the LIGHT changes nothing — amber cannot be set because nothing sets it',
705 JSON.stringify(inert));
706 check(!!inert && inert.wasOpen === inert.nowOpen,
707 'nor does it open the tile it sits on, which is what a mis-tap would otherwise do',
708 `${JSON.stringify(inert && inert.wasOpen)} -> ${JSON.stringify(inert && inert.nowOpen)}`);
709 check(!!inert && inert.cursor !== 'pointer',
710 'nor does it dress itself as pressable', JSON.stringify(inert && inert.cursor));
711}
712
713// ── B. A branch agrees with its leaves ──────────────────────────────
714//
715// Same enumeration, judged against this file's rule rather than the module's.
716{
717 const asked = await p.evaluate(({ leaves, all }) => {
718 const rows = [];
719 if (!window.DaimondPause) return rows;
720 for (let m = 0; m < (1 << leaves.length); m++) {
721 DaimondPause.set('root', true);
722 for (let i = 0; i < leaves.length; i++) if (m & (1 << i)) DaimondPause.set(leaves[i], false);
723 const said = {};
724 for (const id of all) said[id] = DaimondPause.state(id);
725 const painted = {};
726 for (const b of document.querySelectorAll('.pptw')) painted[b.dataset.pauseNode] = b.dataset.state;
727 rows.push({ m, said, painted });
728 }
729 return rows;
730 }, { leaves, all: nodes.map((n) => n.id) });
731
732 let wrongSaid = null, wrongPainted = null, cases = 0, mixedSeen = 0;
733 for (const row of asked) {
734 const paused = new Set(leaves.filter((l, i) => row.m & (1 << i)));
735 for (const n of nodes) {
736 const want = ruleFor(n, paused);
737 if (want === 'mixed') mixedSeen++;
738 cases++;
739 if (row.said[n.id] !== want && !wrongSaid) {
740 wrongSaid = { node: n.id, want, got: row.said[n.id], paused: [...paused] };
741 }
742 if (row.painted[n.id] !== undefined && row.painted[n.id] !== colourOf(want) && !wrongPainted) {
743 wrongPainted = { node: n.id, want, got: row.painted[n.id], paused: [...paused] };
744 }
745 }
746 }
747 check(asked.length === (1 << leaves.length),
748 `every leaf state was tried (${asked.length} of ${1 << leaves.length})`);
749 check(!wrongSaid, `every node's state matches the rule in all ${cases} node-states`,
750 wrongSaid ? JSON.stringify(wrongSaid) : null);
751 check(ctl.length > 0 && !wrongPainted, 'and every control on screen is painted that colour',
752 wrongPainted ? JSON.stringify(wrongPainted) : `${ctl.length} controls`);
753 check(mixedSeen > 0, `amber really was reached by setting leaves directly (${mixedSeen} node-states)`);
754
755 // The empty branch. `root/mail` has no mailbox on this profile. It used to read
756 // GREEN, on the argument that calling it red would open a new account's rail
757 // red — but green is read as "running", and nothing was. It reads `idle` now:
758 // red, and named in words as nothing being set up, which is not an accusation
759 // and is the honest answer.
760 const empties = nodes.filter((n) => n.children && n.children.length === 0);
761 const emptySaid = await p.evaluate((ids) => {
762 DaimondPause.set('root', false); // everything paused, so the answer here is the RULE
763 const o = {};
764 for (const id of ids) o[id] = DaimondPause.state(id);
765 return o;
766 }, empties.map((n) => n.id));
767 check(empties.length > 0, `there is an empty branch to test (${empties.map((n) => n.id).join(', ')})`);
768 check(empties.every((n) => emptySaid[n.id] === 'idle'),
769 'an empty branch reads IDLE, not green — there is nothing there to be running',
770 JSON.stringify(emptySaid));
771 // And it holds no flag of its own: an empty branch emitted as a LEAF would
772 // take one, and nothing could ever resume it.
773 const stray = await p.evaluate((ids) => DaimondPause.pausedIds().filter((k) => ids.indexOf(k) !== -1),
774 empties.map((n) => n.id));
775 check(stray.length === 0, 'and pausing the root wrote no phantom id for it', JSON.stringify(stray));
776}
777
778// ── C. The global control is the root ───────────────────────────────
779{
780 const g = ctl.find((c) => c.where === 'global');
781 check(!!g && g.node === 'root', 'the global control governs `root`, not a setting of its own',
782 JSON.stringify(g && g.node));
783
784 const paused = await p.evaluate(() => {
785 // Everything explicitly resumed leaf by leaf, NOT through `set('root')`:
786 // with no tree registered that call reaches no leaf, and the section
787 // would then judge a state left over from the sweep above.
788 for (const id of DaimondPause.pausedIds()) DaimondPause.set(id, true);
789 const before = DaimondPause.pausedIds();
790 const b = document.querySelector('#pptw-global .pptw-pause');
791 if (!b) return { before, after: null, tiles: [], keys: [] };
792 b.click();
793 return {
794 before,
795 after: DaimondPause.pausedIds(),
796 tiles: [...document.querySelectorAll('.pptw')].map((x) => x.dataset.state),
797 keys: Object.keys(localStorage).filter((k) => /pause/i.test(k)),
798 };
799 });
800 const want = leaves.slice().sort();
801 check(paused.before.length === 0, 'the tree starts clear for this section',
802 JSON.stringify(paused.before));
803 check(!!paused.after && JSON.stringify(paused.after) === JSON.stringify(want),
804 'pausing it pauses every leaf in the tree, and only those',
805 `got ${JSON.stringify(paused.after)} want ${JSON.stringify(want)}`);
806 check(paused.tiles.length > 0 && paused.tiles.every((x) => x === 'pause'),
807 'and every per-tile control shows it', JSON.stringify(paused.tiles));
808 check(!!paused.after && paused.after.indexOf('root/workers') !== -1,
809 'including the worker pump, which is a leaf of the same tree and not a second flag');
810 check(JSON.stringify(paused.keys) === JSON.stringify(['daimond-pause']),
811 'one store holds the whole tree', JSON.stringify(paused.keys));
812
813 const back = await p.evaluate(() => {
814 const b = document.querySelector('#pptw-global .pptw-play');
815 if (!b) return null;
816 b.click();
817 return {
818 ids: DaimondPause.pausedIds(),
819 tiles: [...document.querySelectorAll('.pptw')].map((x) => x.dataset.state),
820 armed: [...document.querySelectorAll('.pptw')].map((x) => x.dataset.armed),
821 };
822 });
823 // THE PROPERTY IS THE FLAGS, and it is asserted on the flags: not one leaf is
824 // left paused. What the lights then show is a second question with a second
825 // answer, because a control governing nothing armed goes on reading red after
826 // a release that did release everything -- it has nothing to be running. So
827 // the tiles are judged per control, against what that control governs.
828 check(!!back && back.ids.length === 0,
829 'and its play verb resumes everything — not one leaf is left paused',
830 JSON.stringify(back && back.ids));
831 check(!!back && back.tiles.length > 0
832 && back.tiles.every((st, i) => (back.armed[i] === '0' ? st === 'pause' : st === 'play')),
833 'and every control shows it: green where it governs something armed, red where it governs nothing',
834 JSON.stringify(back));
835}
836
837// ── F (continued). Enter and Space ──────────────────────────────────
838//
839// The verbs, one at a time and in both directions, because two buttons means
840// the keyboard has to reach the RIGHT one — a Tab order that skips the live
841// verb is a control a keyboard cannot work.
842{
843 await p.evaluate(() => DaimondPause.set('root', true));
844 const node = await p.evaluate(() => {
845 const g = document.querySelector('.diamond-box .pptw');
846 if (!g) return null;
847 const b = g.querySelector('.pptw-pause');
848 b.focus();
849 return { node: g.dataset.pauseNode, focused: document.activeElement === b, state: g.dataset.state };
850 });
851 check(!!node && node.focused, 'the pause verb takes the focus');
852 if (node) {
853 await p.keyboard.press('Enter');
854 await p.waitForTimeout(200);
855 const afterEnter = await p.evaluate((n) => DaimondPause.state(n), node.node);
856 check(afterEnter === 'pause', 'Enter on it pauses the node', `${node.state} -> ${afterEnter}`);
857 // The pause verb is now disabled, so the focus has to be moved to the
858 // other one by hand — which is exactly what a person does.
859 const onPlay = await p.evaluate(() => {
860 const b = document.querySelector('.diamond-box .pptw .pptw-play');
861 b.focus();
862 return { focused: document.activeElement === b, disabled: b.disabled };
863 });
864 check(onPlay.focused && !onPlay.disabled,
865 'and the play verb beside it is live and takes the focus', JSON.stringify(onPlay));
866 await p.keyboard.press(' ');
867 await p.waitForTimeout(200);
868 const afterSpace = await p.evaluate((n) => DaimondPause.state(n), node.node);
869 check(afterSpace === 'play', 'Space on THAT one resumes it', `-> ${afterSpace}`);
870 }
871 // A real pointer press, through the browser's own hit-testing: the tile
872 // underneath opens on click, and a verb must not open it.
873 const opened = await p.evaluate(() => {
874 for (const id of DaimondPause.pausedIds()) DaimondPause.set(id, true);
875 const box = document.querySelector('.diamond-box');
876 return {
877 name: (document.getElementById('current-session-name') || {}).textContent || '',
878 id: box.dataset.id, ids: DaimondPause.pausedIds(),
879 };
880 });
881 const hit = await clickReal(p, '.diamond-box .pptw .pptw-pause');
882 check(hit, 'there is a pause verb on a Diamond tile for a pointer to hit');
883 await p.waitForTimeout(400);
884 const afterClick = await p.evaluate(() => ({
885 name: (document.getElementById('current-session-name') || {}).textContent || '',
886 ids: DaimondPause.pausedIds(),
887 }));
888 check(hit && opened.ids.length === 0 && afterClick.ids.length > 0,
889 'a pointer press on it pauses the Diamond, from nothing paused',
890 `${JSON.stringify(opened.ids)} -> ${JSON.stringify(afterClick.ids)}`);
891 check(hit && afterClick.name === opened.name,
892 'and does NOT open the tile it sits on', `${JSON.stringify(opened.name)} -> ${JSON.stringify(afterClick.name)}`);
893}
894
895// ── E. Pause does not pause reading ─────────────────────────────────
896{
897 // Everything paused, then the Diamond is opened the way a person opens it.
898 await p.evaluate(() => DaimondPause.set('root', false));
899 await p.waitForTimeout(200);
900 await p.evaluate(() => document.querySelector('.diamond-box').click());
901 await p.waitForTimeout(1200);
902 const reading = await p.evaluate(() => {
903 const cv = document.getElementById('crystal-view');
904 const cb = document.getElementById('crystal-body');
905 return {
906 name: ((document.getElementById('current-session-name') || {}).textContent || '').trim(),
907 crystal: !!(cv && cv.getClientRects().length),
908 // Named for what it means rather than for `innerHTML` being non-empty: a
909 // crystal is drawn by a frame now, and a wrapper element would keep that
910 // measure true while showing nothing at all.
911 body: !!(cb && (cb.textContent.trim().length > 0
912 || cb.querySelector('.crystal-frame, .crystal-fallback, .crystal-empty'))),
913 paused: DaimondPause.pausedIds().length,
914 };
915 });
916 check(reading.paused > 0, 'the tree really is paused for this check', `${reading.paused} leaves`);
917 check(!!reading.name, 'a paused Diamond still opens', JSON.stringify(reading.name));
918 check(reading.crystal, 'and its crystal view is on screen');
919 check(reading.body, 'and the crystal rendered something');
920
921 // And the workspace still lists. Opened through the dock chip a person uses,
922 // with a file put in it — writing a file is not spending, so a paused app
923 // must still do it, and a tree that says "empty" would prove nothing.
924 await p.evaluate(() => {
925 const c = document.querySelector('#panel-tags .ptag[data-panel="work"]');
926 if (c && !document.getElementById('panel-work').getClientRects().length) c.click();
927 });
928 await p.waitForTimeout(1200);
929 await p.click('#panel-work [data-act="new-file"]', { force: true }).catch(() => {});
930 await p.waitForSelector('.dlg-input', { timeout: 8000 }).catch(() => {});
931 await p.fill('.dlg-input', 'held.txt').catch(() => {});
932 await p.click('.dlg-ok', { force: true }).catch(() => {});
933 await p.waitForTimeout(1500);
934 const files = await p.evaluate(() => {
935 const w = document.getElementById('panel-work');
936 const tree = w && w.querySelector('.files-tree');
937 return {
938 open: !!(w && w.getClientRects().length),
939 rows: tree ? tree.children.length : 0,
940 text: tree ? (tree.innerText || '').slice(0, 80) : '',
941 };
942 });
943 check(files.open, 'the workspace panel opens while everything is paused');
944 check(/held\.txt/.test(files.text), 'a file written while paused is there, and listed',
945 `${files.rows} rows: ${JSON.stringify(files.text)}`);
946 await p.evaluate(() => DaimondPause.set('root', true));
947 await p.waitForTimeout(200);
948}
949
950// ── Self-tests: each check shown going red ──────────────────────────
951out.push('');
952out.push('--- self-test: breaking each property in the live page');
953
954// (a) A control that kept its markup and lost its listener still LOOKS operable
955// and answers nothing, which is the regression the sweep exists to catch.
956{
957 const r = await p.evaluate(() => {
958 DaimondPause.set('root', true);
959 const g = document.querySelector('.diamond-box .pptw');
960 if (!g) return { was: 'no control', now: 'no control at all' };
961 const twin = g.cloneNode(true);
962 g.parentNode.replaceChild(twin, g);
963 const was = DaimondPause.state(twin.dataset.pauseNode);
964 twin.querySelector('.pptw-pause').click();
965 const now = DaimondPause.state(twin.dataset.pauseNode);
966 return { was, now };
967 });
968 red(r.was === r.now, 'a verb that lost its listener fails the "pause ends paused" rule');
969 await p.evaluate(() => window.dispatchEvent(new CustomEvent('daimond:pause')));
970 await p.waitForTimeout(200);
971}
972
973// (a2) THE REGRESSION THIS REBUILD ANSWERS, planted: a control that offers the
974// verb it is ALREADY in — a running node offering play. That is one button's
975// whole failure mode, drawn as two.
976{
977 const r = await p.evaluate(() => {
978 DaimondPause.set('root', true);
979 const g = document.querySelector('.diamond-box .pptw');
980 if (!g) return null;
981 const play = g.querySelector('.pptw-play');
982 play.disabled = false; // running, and yet play is on offer
983 const live = [...g.querySelectorAll('.pptw-act')].filter((b) => !b.disabled).map((b) => b.dataset.act);
984 return { state: g.dataset.state, live };
985 });
986 // In DOM order — play, pause, light — so a mixed branch reads ['play','pause'].
987 const want = { play: ['pause'], pause: ['play'], mixed: ['play', 'pause'] };
988 red(!!r && JSON.stringify(r.live) !== JSON.stringify(want[r.state]),
989 'a running node that offers play is caught by the offered-verb rule');
990 await p.evaluate(() => window.dispatchEvent(new CustomEvent('daimond:pause')));
991 await p.waitForTimeout(200);
992}
993
994// (a3) And the other half of it: a light dressed as a button. If the lamp were
995// focusable it would be pressable, and amber would stop being unreachable by
996// construction.
997{
998 const r = await p.evaluate(() => {
999 const lamp = document.querySelector('.diamond-box .pptw .pptw-lamp');
1000 if (!lamp) return null;
1001 lamp.tabIndex = 0;
1002 const bad = lamp.tabIndex >= 0;
1003 lamp.removeAttribute('tabindex');
1004 return { bad, restored: lamp.tabIndex >= 0 };
1005 });
1006 red(!!r && r.bad && !r.restored, 'a focusable light is a light that can be pressed, and is caught');
1007}
1008
1009// (b) A painted state frozen away from the module's answer.
1010{
1011 const r = await p.evaluate(() => {
1012 DaimondPause.set('root', true);
1013 // A DIAMOND tile, not a chat: chat tiles no longer draw a control at all,
1014 // so a self-test aimed at one proves nothing about the sweep. The
1015 // Diamonds here are armed with a timer at the top of this file.
1016 const g = document.querySelector('.diamond-box .pptw');
1017 if (!g) return { said: 'no control', painted: 'no control' };
1018 DaimondPause.set(g.dataset.pauseNode, false); // really paused
1019 g.dataset.state = 'play'; // but drawn green
1020 return { said: DaimondPause.state(g.dataset.pauseNode), painted: g.dataset.state };
1021 });
1022 red(r.said !== r.painted, 'a light whose colour stops tracking the module is caught');
1023 await p.evaluate(() => window.dispatchEvent(new CustomEvent('daimond:pause')));
1024 await p.waitForTimeout(200);
1025 const fixed = await p.evaluate(() => {
1026 const g = document.querySelector('.diamond-box .pptw');
1027 if (!g) return { said: 'no control', painted: 'none' };
1028 return { said: DaimondPause.state(g.dataset.pauseNode), painted: g.dataset.state };
1029 });
1030 red(fixed.said === fixed.painted, 'and it agrees again once the repaint runs');
1031}
1032
1033// (c) A branch click that sets only ONE of its leaves: the "uniform" check has
1034// to notice, because that is exactly how a user-settable amber would arrive.
1035{
1036 const r = await p.evaluate(() => {
1037 DaimondPause.set('root', true);
1038 const [a, b] = [...document.querySelectorAll('.diamond-box .pptw')];
1039 if (!a || !b) return null;
1040 DaimondPause.set(a.dataset.pauseNode, false); // half the Diamonds section
1041 return { section: DaimondPause.state('root/diamonds'), root: DaimondPause.state('root') };
1042 });
1043 red(!!r && r.section === 'mixed' && r.root === 'mixed',
1044 'half a section set by hand really does read amber (so the sweep would catch a click that did it)');
1045}
1046
1047// (d) The rule itself, applied to a tree with a leaf the module does not know
1048// about: the state of an unknown id must not silently answer green.
1049{
1050 const r = await p.evaluate(() => {
1051 DaimondPause.set('root', true);
1052 DaimondPause.seedPaused('root/diamonds/ghost/self');
1053 const st = DaimondPause.state('root/diamonds');
1054 DaimondPause.forget('root/diamonds/ghost');
1055 return { st, after: DaimondPause.state('root/diamonds') };
1056 });
1057 red(r.st === 'play' && r.after === 'play',
1058 'a stale id outside the tree colours nothing, and forget() clears it');
1059}
1060
1061// (e) The global control, bound to a section instead of the root — which is
1062// what "a seventh setting" would look like from the outside. The node is baked
1063// into the handler's closure, not read off the dataset, so a stand-in with its
1064// own listener is the only way to express the wrong wiring; that the DATASET
1065// cannot be edited to change behaviour is itself worth knowing.
1066{
1067 const r = await p.evaluate(() => {
1068 DaimondPause.set('root', true);
1069 const g = document.querySelector('#pptw-global .pptw');
1070 if (!g) return null;
1071 const twin = g.cloneNode(true);
1072 twin.querySelector('.pptw-pause')
1073 .addEventListener('click', function () { DaimondPause.set('root/chats', false); });
1074 g.parentNode.replaceChild(twin, g);
1075 twin.querySelector('.pptw-pause').click();
1076 const ids = DaimondPause.pausedIds();
1077 twin.parentNode.replaceChild(g, twin); // the real control back
1078 DaimondPause.set('root', true);
1079 return ids;
1080 });
1081 red(!!r && r.indexOf('root/workers') === -1,
1082 'a global control wired to a section no longer pauses the worker pump');
1083 await p.evaluate(() => window.dispatchEvent(new CustomEvent('daimond:pause')));
1084 const good = await p.evaluate(() => {
1085 const g = document.querySelector('#pptw-global .pptw');
1086 if (!g) return [];
1087 g.querySelector('.pptw-pause').click();
1088 const ids = DaimondPause.pausedIds();
1089 g.querySelector('.pptw-play').click();
1090 return ids;
1091 });
1092 red(good && good.indexOf('root/workers') !== -1, 'and the real one pauses it again');
1093}
1094
1095await s.close();
1096
1097// ── D. The migration, on its own fresh profile ──────────────────────
1098//
1099// A second browser, because the point is what happens on a FIRST load that
1100// finds the old key: the app must come up held.
1101{
1102 const prof2 = scratch('pw', 'pptwmig-' + process.pid);
1103 const s2 = await open({ name: 'pptwmig' + process.pid, profile: prof2 });
1104 const p2 = s2.page;
1105 // A real Diamond for the agent to belong to: a worker for a Diamond that
1106 // does not exist cannot be scoped, so it would fail on its own account and
1107 // "nothing reached the provider" would be true for the wrong reason.
1108 await p2.click('#new-diamond-btn', { force: true });
1109 await p2.waitForSelector('.dlg-input', { timeout: 10000 });
1110 await p2.fill('.dlg-input', 'Held');
1111 await p2.click('.dlg-ok', { force: true });
1112 await p2.waitForTimeout(900);
1113 const did = await p2.evaluate(() => (document.querySelector('.diamond-box') || {}).dataset.id);
1114 check(!!did, 'the migration profile has a Diamond for the held agent to work for', did);
1115
1116 // Seed the world as a user who paused their workers yesterday: the old key
1117 // set, and no pause tree at all.
1118 await p2.evaluate((d) => {
1119 localStorage.setItem('daimond-workers-paused', '1');
1120 localStorage.removeItem('daimond-pause');
1121 // One paused agent, so the pump's hold has something to hold.
1122 localStorage.setItem('daimond-workers', JSON.stringify([{
1123 id: 'w1', name: 'yesterday', task: 'Say the word ready.', diamondId: d, diamondName: 'Held',
1124 model: 'mock/fast', provider: 'custom', status: 'paused', text: 'half done', tools: [],
1125 promptTokens: 0, completionTokens: 0, cachedTokens: 0, costUsd: 0,
1126 }]));
1127 localStorage.setItem('daimond-agents-revealed', '1');
1128 }, did);
1129 if (UNBUILT) {
1130 await p2.route('**/js/daimond.js', async (route) => {
1131 const res = await route.fetch();
1132 let body = await res.text();
1133 body = body
1134 .replace('function migrateWorkerHold() {', 'function migrateWorkerHold() { return; /* UNBUILT */')
1135 .replace('function workersHeld() {',
1136 'function workersHeld() { return localStorage.getItem(WORKERS_PAUSED_KEY) === "1"; /* UNBUILT */');
1137 await route.fulfill({ response: res, body, headers: { ...res.headers(), 'content-type': 'text/javascript; charset=utf-8' } });
1138 });
1139 }
1140 await p2.reload({ waitUntil: 'domcontentloaded' });
1141 await p2.waitForTimeout(1200);
1142 // The reload lands on the unlock screen; get back in the way a person does.
1143 const gate = await p2.$('#id-pass');
1144 if (gate && await gate.isVisible()) {
1145 await p2.fill('#id-pass', 'testpass1234');
1146 await p2.evaluate(() => document.getElementById('id-primary').click());
1147 await p2.waitForSelector('#identity-modal', { state: 'hidden', timeout: 15000 }).catch(() => {});
1148 }
1149 await p2.waitForTimeout(2000);
1150
1151 const mig = await p2.evaluate(() => ({
1152 ids: window.DaimondPause ? DaimondPause.pausedIds() : null,
1153 oldKey: localStorage.getItem('daimond-workers-paused'),
1154 holding: !!(document.getElementById('agents-ctl') || {}).classList
1155 && document.getElementById('agents-ctl').classList.contains('holding'),
1156 tiles: [...document.querySelectorAll('.acard')].map((c) => c.className),
1157 }));
1158 check(!!mig.ids && mig.ids.indexOf('root/workers') !== -1,
1159 'a hold set under the OLD key comes up as `root/workers` in the tree', JSON.stringify(mig.ids));
1160 check(mig.oldKey === null, 'and the old key is gone, so nothing writes two answers again',
1161 JSON.stringify(mig.oldKey));
1162 check(mig.holding, 'the Agents panel shows the pump held, which is the hold reaching the UI');
1163
1164 // And at the NETWORK: resuming that one agent queues it and sends nothing,
1165 // because the pump reads the leaf. Measured in the mock's log, not in a flag.
1166 clearMockLog();
1167 const before = mockLog().length;
1168 await p2.evaluate(() => {
1169 const b = [...document.querySelectorAll('.abtn.a-play')][0];
1170 if (b) b.click();
1171 });
1172 await p2.waitForTimeout(3000);
1173 const sent = mockLog().length - before;
1174 check(sent === 0, 'resuming an agent while the pump is held sends NOTHING to the provider',
1175 `${sent} requests reached the mock`);
1176
1177 // Release it through the GLOBAL CONTROL -- the button, not the module -- and
1178 // the same agent goes. Measured at the network again, in the other
1179 // direction: the pump reads the tree, so releasing the tree lets it out.
1180 const released = await clickReal(p2, '#pptw-global .pptw-play');
1181 check(released, 'there is a global play verb to release the hold with');
1182 for (let i = 0; i < 40 && mockLog().length - before === 0; i++) await p2.waitForTimeout(300);
1183 const after = mockLog().length - before;
1184 const status = await p2.evaluate(() =>
1185 [...document.querySelectorAll('.acard')].map((c) => c.className).join(' '));
1186 check(after > 0,
1187 'and the global control releases it — the held agent reaches the provider',
1188 `${after} requests, tiles: ${status}`);
1189
1190 await s2.close();
1191 try { fs.rmSync(prof2, { recursive: true, force: true }); } catch (e) { /* gone */ }
1192}
1193
1194console.log(out.join('\n'));
1195const total = out.filter((l) => /^(PASS|FAIL)/.test(l)).length;
1196if (UNBUILT) {
1197 console.log(`\nUNBUILT RUN: ${bad} of ${total} checks failed. `
1198 + (bad > 0 ? 'Good — the checks see the missing control.' : 'BAD — a check that cannot fail is not evidence.'));
1199 process.exit(bad > 0 ? 0 : 1);
1200}
1201console.log(bad === 0 ? `\nALL ${total} CHECKS PASSED` : `\n${bad} of ${total} FAILED`);
1202process.exit(bad === 0 ? 0 : 1);