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 | |
| 80 | import fs from 'node:fs'; |
| 81 | import { open, newChat, scratch, mockLog, clearMockLog } from './harness.mjs'; |
| 82 | |
| 83 | const UNBUILT = process.argv.includes('--unbuilt'); |
| 84 | |
| 85 | const out = []; |
| 86 | let bad = 0; |
| 87 | const check = (ok, what, detail) => { |
| 88 | out.push(`${ok ? 'PASS' : 'FAIL'} ${what}${detail != null ? ' — ' + detail : ''}`); |
| 89 | if (!ok) bad++; |
| 90 | return ok; |
| 91 | }; |
| 92 | const 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. |
| 97 | async 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. |
| 113 | const 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. |
| 118 | function 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. |
| 130 | function 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. |
| 146 | function 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. |
| 156 | const VERBS = { play: ['pause'], pause: ['play'], idle: ['play'], mixed: ['play', 'pause'] }; |
| 157 | |
| 158 | /// Is every leaf under this node held? |
| 159 | const 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. |
| 171 | const 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. |
| 181 | const colourOf = (st) => (st === 'idle' ? 'pause' : st); |
| 182 | |
| 183 | /// Walk our tree, node by node. |
| 184 | function* 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. |
| 190 | async 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 | |
| 293 | const profile = scratch('pw', 'pptw-' + process.pid); |
| 294 | const s = await open({ name: 'pptw' + process.pid, profile }); |
| 295 | const closeBrowser = s.close; |
| 296 | s.close = async () => { |
| 297 | await closeBrowser(); |
| 298 | try { fs.rmSync(profile, { recursive: true, force: true }); } catch (e) { /* gone */ } |
| 299 | }; |
| 300 | const 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. |
| 306 | if (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 | |
| 333 | await 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. |
| 337 | async 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 | } |
| 344 | await newDiamond('Alpha'); |
| 345 | await 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. |
| 352 | await 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 | }); |
| 362 | await p.waitForTimeout(700); |
| 363 | await newChat(s); |
| 364 | await p.click('#new-session-btn', { force: true }); |
| 365 | await p.waitForTimeout(400); |
| 366 | { |
| 367 | const start = p.locator('.tile-start').first(); |
| 368 | if (await start.count()) await start.click({ force: true }); |
| 369 | } |
| 370 | await 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 | |
| 376 | const tree = await expectedTree(p); |
| 377 | const nodes = [...walk(tree)]; |
| 378 | const leaves = nodes.filter((n) => !n.children).map((n) => n.id); |
| 379 | const byId = new Map(nodes.map((n) => [n.id, n])); |
| 380 | |
| 381 | check(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. |
| 386 | const 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 | |
| 423 | const ctl = await controls(); |
| 424 | check(ctl.length >= 5, `a control is on each placement (${ctl.length} found)`, |
| 425 | JSON.stringify(ctl.map((c) => c.where + ':' + c.node))); |
| 426 | check(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. |
| 432 | const tiles = await p.evaluate(() => |
| 433 | document.querySelectorAll('#diamond-list .diamond-box').length); |
| 434 | check(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. |
| 441 | check(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 ────────────────────────────────────────── |
| 446 | const acts = ctl.flatMap((c) => c.acts); |
| 447 | check(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. |
| 452 | check(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))); |
| 455 | check(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))])); |
| 457 | check(acts.length > 0 && acts.every((a) => a.inTabOrder), |
| 458 | 'every live verb is in the tab order, like the rest of the rail'); |
| 459 | check(acts.length > 0 && acts.every((a) => a.iconHidden), |
| 460 | 'its glyph is aria-hidden, so it is not read out beside its own label'); |
| 461 | check(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. |
| 467 | const lamps = ctl.map((c) => c.lamp); |
| 468 | check(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))); |
| 470 | check(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))); |
| 473 | check(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. |
| 479 | check(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. |
| 513 | const 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 | |
| 548 | check(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 ────────────────────────── |
| 951 | out.push(''); |
| 952 | out.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 | |
| 1095 | await 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 | |
| 1194 | console.log(out.join('\n')); |
| 1195 | const total = out.filter((l) => /^(PASS|FAIL)/.test(l)).length; |
| 1196 | if (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 | } |
| 1201 | console.log(bad === 0 ? `\nALL ${total} CHECKS PASSED` : `\n${bad} of ${total} FAILED`); |
| 1202 | process.exit(bad === 0 ? 0 : 1); |