oxedyne/daimond/dev/verify_pending.mjs
30.0 KiB, 1 run
created by r2519314175:587, 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_pending.mjs — the Pending panel is where a worker's question waits. |
| 2 | // |
| 3 | // The panel was built for notes2 ("a new Dock panel, say 'Pending' with tiles |
| 4 | // for each action that must be approved by the user"), it drew, it sorted, it |
| 5 | // had three answers — and NOTHING IN THE SHIPPED APP EVER RAISED A TILE. Every |
| 6 | // caller of `Pending.add` was in `dev/`. A user had never seen one. |
| 7 | // |
| 8 | // What it was missing is the asynchronous half of consent. The gate in |
| 9 | // `egressAllowed` is a DIALOG, and a dispatched worker acts precisely when the |
| 10 | // user is looking at something else — so a worker's question went up behind a |
| 11 | // tab nobody had in front of them, the worker sat on it holding a slot in the |
| 12 | // pool, and the work died when somebody eventually pressed Escape on a dialog |
| 13 | // with no context. `parkConsent` raises a `consent` tile instead and the worker |
| 14 | // WAITS ON THE TILE: the engine is awaiting the promise that ✓ and ✕ resolve. |
| 15 | // |
| 16 | // The properties, each chosen because it would be invisible if it were wrong: |
| 17 | // |
| 18 | // 1. THE TILE SAYS WHAT WAS ASKED. Not "a tile appeared" — the headline names |
| 19 | // the destination, and the detail quotes the text that would be sent, in |
| 20 | // full, because that is the thing being authorised. |
| 21 | // 2. NOTHING HAPPENS WHILE IT WAITS. The driver has seen nothing and the tool |
| 22 | // call has not returned. A panel that raises a tile and lets the act |
| 23 | // through anyway is worse than no panel. |
| 24 | // 3. ✓ RESUMES THAT ACT. The click reaches the driver with the ref that was |
| 25 | // parked, at the moment the tick is pressed — the tool call carried on from |
| 26 | // where it stopped, rather than a fresh one being made. |
| 27 | // 4. ✕ REFUSES IT AND THE MODEL IS TOLD. The tool call returns the refusal |
| 28 | // naming the destination, and nothing reached the page. A tile that is |
| 29 | // answered and leaves the turn hanging is the same defect one layer up. |
| 30 | // 5. THE OTHER CLAUSE. `someoneCanAnswer` has two: a dialog already on screen, |
| 31 | // and a document that is not on screen. Both are load-bearing, so both are |
| 32 | // driven — 1–4 through the first, 5 through the second. |
| 33 | // 6. THE USER'S OWN TURN IS NOT DIVERTED. Same conditions, a supervised turn: |
| 34 | // it still gets the dialog. A gate that moves everybody's question off the |
| 35 | // screen they are looking at is a worse answer than the one being replaced. |
| 36 | // 7. A TILE THAT OUTLIVED ITS PAGE DOES NOT LIE. What it holds is a promise, |
| 37 | // and a promise does not survive a reload. After one, the same tile says |
| 38 | // the agent has gone and its tick is disabled — asserted against the SAME |
| 39 | // tile before the reload, where the tick is live, so "disabled" cannot be |
| 40 | // a constant. |
| 41 | // |
| 42 | // And the trigger nothing has ever driven: |
| 43 | // |
| 44 | // 8. MAIL ARRIVING IN A WATCHED FOLDER REACHES THE DAIMON. The `mail` kind has |
| 45 | // shipped since phase H with no verifier at all — `verify_triggers` drives |
| 46 | // the activity clock and never dispatches `daimond:mail-arrived`. |
| 47 | // 9. AND MAIL IN ANOTHER FOLDER DOES NOT. Driven FIRST, before anything has |
| 48 | // fired, so it cannot pass because a turn was already running. |
| 49 | // |
| 50 | // EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a |
| 51 | // deliberately damaged copy of a source file to the real page (through |
| 52 | // `page.route`, so the browser loads it as it loads any other script) and the |
| 53 | // run is expected to FAIL. A break whose anchor does not appear exactly once |
| 54 | // aborts rather than passing quietly. |
| 55 | // |
| 56 | // node dev/verify_pending.mjs --break nopark # 1–5 fail: the old dialog, |
| 57 | // # put to an empty room |
| 58 | // node dev/verify_pending.mjs --break optimistic # 2 fails: a tile is raised |
| 59 | // # and the act goes through |
| 60 | // node dev/verify_pending.mjs --break deaf # 3 fails: ✓ answers nobody |
| 61 | // node dev/verify_pending.mjs --break leak # 4 fails: ✕ takes the tile |
| 62 | // # away and leaves the turn |
| 63 | // # waiting for ever |
| 64 | // node dev/verify_pending.mjs --break blindtab # 5 fails: only the dialog |
| 65 | // # clause is consulted |
| 66 | // node dev/verify_pending.mjs --break alwayspark # 6 fails: the user's own |
| 67 | // # question is moved off the |
| 68 | // # screen they are watching |
| 69 | // node dev/verify_pending.mjs --break livelie # 7 fails: a dead tile still |
| 70 | // # offers to do the thing |
| 71 | // node dev/verify_pending.mjs --break mute # 8 fails: mail never fires |
| 72 | // node dev/verify_pending.mjs --break wideopen # 9 fails: any folder fires |
| 73 | // node dev/verify_pending.mjs # and then, clean |
| 74 | // |
| 75 | // eval "$(bash dev/world.sh 5 --up)" |
| 76 | // node dev/verify_pending.mjs |
| 77 | // |
| 78 | // Needs dev/serve.mjs and dev/mockllm.mjs (dev/world.sh N --up gives both). No |
| 79 | // gateway on :9002, no IMAP: the Web panel's DRIVER is stubbed and nothing else |
| 80 | // is, and the mail half dispatches the arrival event the mail panel dispatches. |
| 81 | // |
| 82 | // ONE BROWSER SIGNAL IS STUBBED, and only for check 5: headless Chromium reports |
| 83 | // every page `visible`, measured — a second tab does not hide the first, and |
| 84 | // `bringToFront` does not either — so `document.visibilityState` is redefined |
| 85 | // for the length of that check. The code under test is untouched; what is faked |
| 86 | // is the browser's report of something this environment cannot produce. |
| 87 | import fs from 'node:fs'; |
| 88 | import path from 'node:path'; |
| 89 | import { fileURLToPath } from 'node:url'; |
| 90 | import { open, shot, scratch, mockLog, signInAs } from './harness.mjs'; |
| 91 | |
| 92 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 93 | const WWW = path.join(HERE, '..', 'www'); |
| 94 | |
| 95 | const BREAK = (() => { |
| 96 | const i = process.argv.indexOf('--break'); |
| 97 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 98 | })(); |
| 99 | |
| 100 | const PROFILE = scratch('pw', 'pending' + (BREAK ? '-' + BREAK : '')); |
| 101 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 102 | |
| 103 | const ok = [], bad = []; |
| 104 | const check = (name, pass, detail) => { |
| 105 | (pass ? ok : bad).push(name); |
| 106 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 107 | }; |
| 108 | |
| 109 | // ── The breaks ─────────────────────────────────────────────────────── |
| 110 | // Each is a real edit to a real file, served in place of it, and each is how |
| 111 | // that piece behaved before the fix or how it could plausibly have been written. |
| 112 | const BREAKS = { |
| 113 | // The gate as it was: a worker's question is always a dialog, whether or not |
| 114 | // there is anybody to answer it. |
| 115 | nopark: [{ |
| 116 | file: 'js/daimond.js', |
| 117 | find: '\t\tif (req.alone && !someoneCanAnswer()) return await parkConsent(req, host);', |
| 118 | with: '\t\tif (false && req.alone && !someoneCanAnswer()) return await parkConsent(req, host);', |
| 119 | }], |
| 120 | // A tile is raised and the act goes through regardless — the shape somebody |
| 121 | // reaches for when the panel is treated as a notification rather than a gate. |
| 122 | optimistic: [{ |
| 123 | file: 'js/daimond.js', |
| 124 | find: '\t\tif (!id) return Promise.resolve(\'deny\');\n' |
| 125 | + '\t\treturn new Promise(function (resolve) { _parked[id] = resolve; });', |
| 126 | with: '\t\tif (!id) return Promise.resolve(\'deny\');\n' |
| 127 | + '\t\treturn Promise.resolve(\'allow\');', |
| 128 | }], |
| 129 | // The register is never consulted, so every answer is given to nobody: the |
| 130 | // tile goes away and the turn waits for ever. |
| 131 | deaf: [{ |
| 132 | file: 'js/daimond.js', |
| 133 | find: '\t\tvar go = _parked[id];\n\t\tif (!go) return false;', |
| 134 | with: '\t\tvar go = _parked[id];\n\t\tif (!go || true) return false;', |
| 135 | }], |
| 136 | // Removing a tile does not refuse what was parked on it. This is the leak the |
| 137 | // deny-on-drop rule exists to close, and it is silent: the panel looks right. |
| 138 | leak: [{ |
| 139 | file: 'js/daimond.js', |
| 140 | find: '\t\tdrop: function (id) {\n\t\t\tsettleConsent(id, \'deny\');', |
| 141 | with: '\t\tdrop: function (id) {', |
| 142 | }], |
| 143 | // Only the dialog clause is consulted, so a question raised into a page |
| 144 | // nobody is looking at still goes on a dialog nobody will see. |
| 145 | blindtab: [{ |
| 146 | file: 'js/daimond.js', |
| 147 | find: '\t\t\tif (document.visibilityState === \'hidden\') return false;', |
| 148 | with: '\t\t\tif (false) return false;', |
| 149 | }], |
| 150 | // Everybody's question is parked, the user's own included. |
| 151 | alwayspark: [{ |
| 152 | file: 'js/daimond.js', |
| 153 | find: '\t\tif (req.alone && !someoneCanAnswer()) return await parkConsent(req, host);', |
| 154 | with: '\t\tif (!someoneCanAnswer()) return await parkConsent(req, host);', |
| 155 | }], |
| 156 | // A tile survives the reload still claiming to be live, so its tick offers a |
| 157 | // permission there is nothing left to grant. |
| 158 | livelie: [{ |
| 159 | file: 'js/daimond.js', |
| 160 | find: '\t\t\tthis.items.forEach(function (it) {\n' |
| 161 | + '\t\t\t\tif (it && it.kind === \'consent\') it.expired = true;\n' |
| 162 | + '\t\t\t});', |
| 163 | with: '', |
| 164 | }], |
| 165 | // Mail never matches, so a watched folder fires nothing. |
| 166 | mute: [{ |
| 167 | file: 'js/triggers.js', |
| 168 | find: '\t\t\t\treturn t.mailbox === occasion.mailbox && t.folder === occasion.folder;', |
| 169 | with: '\t\t\t\treturn false;', |
| 170 | }], |
| 171 | // Mail always matches, so every folder of every mailbox fires every mail TA. |
| 172 | wideopen: [{ |
| 173 | file: 'js/triggers.js', |
| 174 | find: '\t\t\t\treturn t.mailbox === occasion.mailbox && t.folder === occasion.folder;', |
| 175 | with: '\t\t\t\treturn true;', |
| 176 | }], |
| 177 | }; |
| 178 | |
| 179 | if (BREAK && !BREAKS[BREAK]) { |
| 180 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 181 | process.exit(2); |
| 182 | } |
| 183 | |
| 184 | /// `src` with `spec` applied, or a hard stop. Nothing is served that was not |
| 185 | /// verified to differ from what it was given. |
| 186 | function damaged(src, spec) { |
| 187 | const n = src.split(spec.find).length - 1; |
| 188 | if (n !== 1) { |
| 189 | console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, ` |
| 190 | + 'so nothing was broken and the run below would prove nothing.'); |
| 191 | process.exit(2); |
| 192 | } |
| 193 | return src.replace(spec.find, spec.with); |
| 194 | } |
| 195 | |
| 196 | /// The damaged files, ONE BODY PER FILE. |
| 197 | /// |
| 198 | /// Every edit a break names for a file goes into the SAME body, in order, and |
| 199 | /// that one body is what the route serves. A `page.route` per edit spec does not |
| 200 | /// work and does not say so: Playwright hands a request to the LAST route |
| 201 | /// registered for its URL, so a two-edit break shipped only its second edit -- |
| 202 | /// and still went red, for half the reason it claims, with nothing to notice it. |
| 203 | function damagedFiles() { |
| 204 | const byFile = new Map(); |
| 205 | for (const spec of (BREAKS[BREAK] || [])) { |
| 206 | const src = byFile.has(spec.file) ? byFile.get(spec.file) |
| 207 | : fs.readFileSync(path.join(WWW, spec.file), 'utf8'); |
| 208 | byFile.set(spec.file, damaged(src, spec)); |
| 209 | } |
| 210 | return byFile; |
| 211 | } |
| 212 | |
| 213 | const routeBreaks = async (page) => { |
| 214 | if (!BREAK) return; |
| 215 | for (const [file, body] of damagedFiles()) { |
| 216 | await page.route('**/' + file, r => r.fulfill({ |
| 217 | status: 200, contentType: 'application/javascript', body, |
| 218 | })); |
| 219 | } |
| 220 | }; |
| 221 | |
| 222 | // ── Driving ────────────────────────────────────────────────────────── |
| 223 | |
| 224 | const HOST = 'shop.test'; |
| 225 | const PAGE = 'https://shop.test/cart'; |
| 226 | // Well past the 300 characters every other body on a consent screen is cut to, |
| 227 | // so "the whole of it is quoted" is a claim with something to fail on. |
| 228 | const CARD = 'x'.repeat(400) + 'card-4111-2222-3333-4444' + 'x'.repeat(400); |
| 229 | |
| 230 | const s = await open({ name: 'pending', profile: PROFILE, signIn: true, connect: true, |
| 231 | route: routeBreaks }); |
| 232 | const { page: p } = s; |
| 233 | await p.waitForFunction(() => !!window.DaimondCore && !!window.__daimondEgressAllowed |
| 234 | && !!window.DaimondPendingView, null, { timeout: 20000 }).catch(() => {}); |
| 235 | |
| 236 | const sleep = (ms) => new Promise(r => setTimeout(r, ms)); |
| 237 | |
| 238 | /// The Web panel's driver, stubbed, and only the driver. Everything below it — |
| 239 | /// the Rust gate, the payload, the real `__daimondEgressAllowed` bridge, the |
| 240 | /// real dialog, the real panel — is the shipped code. It RECORDS what reached |
| 241 | /// it, which is how "nothing happened" is asked at the page rather than at the |
| 242 | /// model's reply. |
| 243 | async function stubDriver() { |
| 244 | await p.evaluate((url) => { |
| 245 | window.__drv = { clicks: [], types: [] }; |
| 246 | window.DaimondWeb = { |
| 247 | status: async () => ({ driver: 'stub', url, open: true }), |
| 248 | open: async (u) => ({ ok: true, url: u }), |
| 249 | fetch: async () => 'stub page', |
| 250 | snapshot: async () => ({ nodes: [] }), |
| 251 | read: async () => 'stub page', |
| 252 | click: async (ref) => { window.__drv.clicks.push(ref); return { ok: true }; }, |
| 253 | type: async (ref, text, submit) => { |
| 254 | window.__drv.types.push({ ref, text, submit }); return { ok: true }; |
| 255 | }, |
| 256 | scroll: async () => ({ ok: true }), |
| 257 | close: async () => ({ ok: true }), |
| 258 | }; |
| 259 | }, PAGE); |
| 260 | } |
| 261 | |
| 262 | /// Build one agent and hold it on `window`, exactly as `Workers.start` builds a |
| 263 | /// worker: `set_unsupervised` is the same call the app makes at dispatch, so a |
| 264 | /// build without it fails here rather than silently testing nothing. |
| 265 | async function mint(key, { alone = false, tainted = false } = {}) { |
| 266 | return await p.evaluate(async ({ key, alone, tainted }) => { |
| 267 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 268 | const app = new mod.DaimondApp('http://127.0.0.1/v1/chat/completions', '', 'none', 256, '', true); |
| 269 | let marked = true; |
| 270 | if (alone) { |
| 271 | if (typeof app.set_unsupervised !== 'function') return { hasSetter: false }; |
| 272 | app.set_unsupervised(); |
| 273 | marked = app.is_unsupervised() === true; |
| 274 | } |
| 275 | if (tainted) { |
| 276 | if (typeof app.set_tainted !== 'function') return { hasTainter: false, hasSetter: true }; |
| 277 | app.set_tainted(); |
| 278 | } |
| 279 | window[key] = app; |
| 280 | return { hasSetter: true, hasTainter: true, marked }; |
| 281 | }, { key, alone, tainted }); |
| 282 | } |
| 283 | |
| 284 | /// Start a tool call WITHOUT waiting for it, so the panel can be looked at while |
| 285 | /// the turn is still held open. `slot` is where its answer lands. |
| 286 | const fire = (agent, slot, tool, args) => p.evaluate(({ agent, slot, tool, args }) => { |
| 287 | window[slot] = { done: false, text: '' }; |
| 288 | window[agent].run_tool(tool, JSON.stringify(args)) |
| 289 | .then((v) => { window[slot] = { done: true, text: String(v) }; }) |
| 290 | .catch((e) => { window[slot] = { done: true, text: 'THREW ' + (e && e.message || e) }; }); |
| 291 | }, { agent, slot, tool, args }); |
| 292 | |
| 293 | const answerOf = (slot) => p.evaluate((k) => window[k], slot); |
| 294 | const drv = () => p.evaluate(() => ({ |
| 295 | clicks: window.__drv.clicks.slice(), types: window.__drv.types.slice() })); |
| 296 | const resetDrv = () => p.evaluate(() => { window.__drv.clicks = []; window.__drv.types = []; }); |
| 297 | const dialogs = () => p.evaluate(() => document.querySelectorAll('.modal.dlg').length); |
| 298 | const tiles = () => p.evaluate(() => window.DaimondPendingView.items()); |
| 299 | |
| 300 | /// Wait for something, time-boxed, so a break that HANGS reports as a failed |
| 301 | /// check rather than as a run that never finished. |
| 302 | async function until(fn, ms = 10000) { |
| 303 | const end = Date.now() + ms; |
| 304 | for (;;) { |
| 305 | try { if (await fn()) return true; } catch (e) { /* the page is mid-navigation */ } |
| 306 | if (Date.now() > end) return false; |
| 307 | await sleep(150); |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | /// Answer whatever dialog is up, by class rather than by reading a label: the |
| 312 | /// heading carries a closer as well, and "the first button that is not Cancel" |
| 313 | /// picks it — which dismisses. |
| 314 | const answerDialog = (yes) => p.evaluate((y) => { |
| 315 | const d = document.querySelector('.modal.dlg'); |
| 316 | if (!d) return false; |
| 317 | const pick = y ? d.querySelector('.dlg-ok') : (d.querySelector('.dlg-cancel') || d.querySelector('.dlg-ok')); |
| 318 | if (!pick) return false; |
| 319 | pick.click(); |
| 320 | return true; |
| 321 | }, yes); |
| 322 | |
| 323 | /// Press one of a tile's three answers, on the tile with THIS id. |
| 324 | const answerTile = (id, cls) => p.evaluate(({ id, cls }) => { |
| 325 | const b = document.querySelector('#pending-list .pend-card[data-id="' + id + '"] .' + cls); |
| 326 | if (!b) return 'no such button'; |
| 327 | if (b.disabled) return 'disabled'; |
| 328 | b.click(); |
| 329 | return 'clicked'; |
| 330 | }, { id, cls }); |
| 331 | |
| 332 | let failures = 0; |
| 333 | try { |
| 334 | await stubDriver(); |
| 335 | const rung = await p.evaluate(async () => { |
| 336 | const mod = await import('../pkg/oxedyne_daimond.js'); |
| 337 | mod.set_permission_mode('guarded'); |
| 338 | return mod.permission_mode(); |
| 339 | }); |
| 340 | check('the default rung is the one under test', rung === 'guarded', rung); |
| 341 | |
| 342 | const wA = await mint('__wA', { alone: true }); |
| 343 | const wB = await mint('__wB', { alone: true }); |
| 344 | const me = await mint('__me', { tainted: true }); |
| 345 | check('a worker can be marked as acting alone', !!(wA.hasSetter && wA.marked && wB.marked), |
| 346 | wA.hasSetter ? 'marked' : 'this build has no set_unsupervised'); |
| 347 | check('and a turn can be marked as having read a stranger\'s words', |
| 348 | me.hasTainter !== false, 'set_tainted'); |
| 349 | |
| 350 | await p.evaluate(() => DaimondPanels.show('pending')); |
| 351 | await sleep(400); |
| 352 | const emptyText = await p.evaluate(() => |
| 353 | (document.getElementById('pending-list') || {}).textContent || ''); |
| 354 | check('Pending starts with nothing on it', /nothing waiting/i.test(emptyText), |
| 355 | emptyText.trim().slice(0, 60)); |
| 356 | |
| 357 | // ══ 1–4: a dialog is already on screen, so the second worker cannot be asked |
| 358 | // |
| 359 | // The condition is made the way it happens: TWO workers ask at once. The |
| 360 | // first gets the dialog; the second arrives at a door that is already |
| 361 | // occupied. Nothing is stubbed to produce it. |
| 362 | await resetDrv(); |
| 363 | await fire('__wA', '__rA', 'web_click', { ref: 41 }); |
| 364 | const gotDialog = await until(async () => (await dialogs()) > 0); |
| 365 | check('a worker\'s question reaches the screen when there is a screen to reach', |
| 366 | gotDialog, gotDialog ? '' : 'no dialog: nothing put the first worker\'s click to anybody'); |
| 367 | |
| 368 | await fire('__wB', '__rB', 'web_type', { ref: 42, text: CARD, submit: true }); |
| 369 | const parked = await until(async () => |
| 370 | (await tiles()).some((i) => i.kind === 'consent')); |
| 371 | const items1 = await tiles(); |
| 372 | const tile1 = items1.find((i) => i.kind === 'consent'); |
| 373 | check('a second worker, arriving at a door that is already occupied, lands on Pending', |
| 374 | parked && !!tile1, tile1 ? tile1.headline : 'no consent tile was raised'); |
| 375 | |
| 376 | check('the tile names the destination and what would happen there', |
| 377 | !!tile1 && /shop\.test/.test(tile1.headline) && /send text/i.test(tile1.headline), |
| 378 | tile1 ? tile1.headline : ''); |
| 379 | check('and quotes the whole of what would be sent, not the first 300 characters', |
| 380 | !!tile1 && tile1.detail.includes(CARD), |
| 381 | tile1 ? ('carries ' + tile1.detail.length + ' characters of a ' |
| 382 | + CARD.length + '-character payload') : ''); |
| 383 | check('and says why it is here rather than on a dialog', |
| 384 | !!tile1 && /not in front of you/i.test(tile1.detail), |
| 385 | tile1 ? tile1.detail.slice(-160) : ''); |
| 386 | check('and it is raised high, because something is stopped until it is answered', |
| 387 | !!tile1 && tile1.priority === 'high', tile1 ? tile1.priority : ''); |
| 388 | |
| 389 | const drawn1 = await p.evaluate((id) => { |
| 390 | const card = document.querySelector('#pending-list .pend-card[data-id="' + id + '"]'); |
| 391 | if (!card) return null; |
| 392 | const go = card.querySelector('.pend-go'); |
| 393 | return { |
| 394 | line: (card.querySelector('.pend-line') || {}).textContent || '', |
| 395 | note: (card.querySelector('.pend-consent') || {}).textContent || '', |
| 396 | goDisabled: !!(go && go.disabled), |
| 397 | }; |
| 398 | }, tile1 ? tile1.id : ''); |
| 399 | check('the tile is actually on the panel, saying the agent is waiting on it', |
| 400 | !!drawn1 && drawn1.line === (tile1 || {}).headline && /still waiting/i.test(drawn1.note), |
| 401 | drawn1 ? drawn1.note.trim() : 'the record exists but nothing was drawn'); |
| 402 | check('and its tick is live, because there is something on the other end of it', |
| 403 | !!drawn1 && drawn1.goDisabled === false, drawn1 ? String(drawn1.goDisabled) : ''); |
| 404 | |
| 405 | // 2. Nothing has happened, and the turn has not been answered either way. |
| 406 | const beforeTick = await drv(); |
| 407 | const restingB = await answerOf('__rB'); |
| 408 | check('nothing reaches the page while the tile waits', |
| 409 | beforeTick.types.length === 0, 'the driver saw ' + beforeTick.types.length + ' type(s)'); |
| 410 | check('and the worker\'s turn is held open rather than refused', |
| 411 | restingB.done === false, restingB.done ? ('it returned: ' + restingB.text.slice(0, 90)) : 'waiting'); |
| 412 | |
| 413 | // The first worker's dialog is answered no, so it is out of the way and its |
| 414 | // refusal cannot be mistaken for the parked one's. |
| 415 | await answerDialog(false); |
| 416 | await until(async () => (await answerOf('__rA')).done); |
| 417 | |
| 418 | // 3. The tick resumes THAT act. |
| 419 | // The id, or an empty one. A break that raises no tile must leave every check |
| 420 | // below it reporting red rather than throwing the run out at the first |
| 421 | // missing record: a red run has to say the whole of what it broke. |
| 422 | const pressed = await answerTile(tile1 ? tile1.id : '', 'pend-go'); |
| 423 | const resumed = await until(async () => (await answerOf('__rB')).done); |
| 424 | const afterTick = await drv(); |
| 425 | const resultB = await answerOf('__rB'); |
| 426 | check('pressing ✓ answers the worker that was waiting', pressed === 'clicked' && resumed, |
| 427 | pressed === 'clicked' ? (resumed ? '' : 'the turn never resumed') : pressed); |
| 428 | check('and the act it was holding is the act that happens: this ref, this text', |
| 429 | afterTick.types.length === 1 && afterTick.types[0].ref === 42 |
| 430 | && afterTick.types[0].text === CARD, |
| 431 | JSON.stringify(afterTick.types.map((x) => ({ ref: x.ref, len: (x.text || '').length })))); |
| 432 | check('and the model is not handed a refusal for something that went through', |
| 433 | !/did not reach/i.test(resultB.text || ''), (resultB.text || '').slice(0, 90)); |
| 434 | const leftAfterGo = await tiles(); |
| 435 | check('and the tile goes, because the question has been answered', |
| 436 | !!tile1 && !leftAfterGo.some((i) => i.id === tile1.id), leftAfterGo.length + ' left'); |
| 437 | |
| 438 | // 4. ✕ refuses it, and the refusal reaches the model. |
| 439 | await resetDrv(); |
| 440 | await fire('__wA', '__rC', 'web_click', { ref: 43 }); |
| 441 | await until(async () => (await dialogs()) > 0); |
| 442 | await fire('__wB', '__rD', 'web_click', { ref: 44 }); |
| 443 | await until(async () => (await tiles()).some((i) => i.kind === 'consent')); |
| 444 | const tile2 = (await tiles()).find((i) => i.kind === 'consent'); |
| 445 | check('a click a worker cannot ask about lands on Pending too', |
| 446 | !!tile2 && /click something on shop\.test/i.test(tile2.headline), |
| 447 | tile2 ? tile2.headline : 'no tile'); |
| 448 | await answerDialog(false); |
| 449 | await until(async () => (await answerOf('__rC')).done); |
| 450 | await resetDrv(); |
| 451 | const dropped = await answerTile(tile2 ? tile2.id : '', 'pend-no'); |
| 452 | const toldNo = await until(async () => (await answerOf('__rD')).done); |
| 453 | const resultD = await answerOf('__rD'); |
| 454 | const afterNo = await drv(); |
| 455 | check('pressing ✕ tells the waiting worker no, rather than leaving it hanging', |
| 456 | dropped === 'clicked' && toldNo, |
| 457 | toldNo ? '' : 'the turn was never answered: the tile went and the worker waits for ever'); |
| 458 | check('the refusal names the destination it did not reach', |
| 459 | /did not reach/i.test(resultD.text || '') && new RegExp(HOST).test(resultD.text || ''), |
| 460 | (resultD.text || '').slice(0, 120)); |
| 461 | check('and nothing reached the page', afterNo.clicks.length === 0, |
| 462 | 'the driver saw ' + afterNo.clicks.length + ' click(s)'); |
| 463 | |
| 464 | // ══ 5: the other clause — a document that is not on screen |
| 465 | // |
| 466 | // `document.visibilityState` is redefined for this check and put back after |
| 467 | // it. Headless Chromium reports every page visible whatever is done to it |
| 468 | // (measured: a second tab does not hide the first, nor does bringToFront), |
| 469 | // so the browser's report is the one thing here that has to be supplied. |
| 470 | await resetDrv(); |
| 471 | await p.evaluate(() => { |
| 472 | // An own property over the prototype's getter, and configurable, so |
| 473 | // deleting it below puts the real one back with nothing to restore. |
| 474 | Object.defineProperty(document, 'visibilityState', |
| 475 | { get: () => 'hidden', configurable: true }); |
| 476 | }); |
| 477 | const hidden = await p.evaluate(() => document.visibilityState === 'hidden'); |
| 478 | check('the document can be made to report that it is not on screen', hidden, |
| 479 | hidden ? '' : 'the stub did not take, so check 5 below proves nothing'); |
| 480 | const beforeHidden = (await tiles()).length; |
| 481 | await fire('__wA', '__rE', 'web_click', { ref: 45 }); |
| 482 | const parkedHidden = await until(async () => (await tiles()).length > beforeHidden); |
| 483 | const tile3 = (await tiles()).find((i) => i.kind === 'consent'); |
| 484 | const dlgHidden = await dialogs(); |
| 485 | check('a worker asking into a page nobody is looking at lands on Pending, with no dialog', |
| 486 | parkedHidden && !!tile3 && dlgHidden === 0, |
| 487 | parkedHidden ? (dlgHidden + ' dialog(s) raised') : 'no tile: it went to a dialog nobody would see'); |
| 488 | |
| 489 | // 6. The user's own turn is not diverted. Same conditions exactly. |
| 490 | const beforeMine = (await tiles()).length; |
| 491 | await fire('__me', '__rF', 'web_click', { ref: 46 }); |
| 492 | const mineAsked = await until(async () => (await dialogs()) > 0, 6000); |
| 493 | const afterMine = (await tiles()).length; |
| 494 | check('the user\'s own turn is still asked on the screen they are looking at', |
| 495 | mineAsked && afterMine === beforeMine, |
| 496 | mineAsked ? (afterMine > beforeMine ? 'it was parked as well' : '') |
| 497 | : 'no dialog: their own question was moved off the screen'); |
| 498 | await answerDialog(false); |
| 499 | await until(async () => (await answerOf('__rF')).done); |
| 500 | await p.evaluate(() => { delete document.visibilityState; }); |
| 501 | check('and the document reports normally again', |
| 502 | await p.evaluate(() => document.visibilityState === 'visible'), ''); |
| 503 | |
| 504 | await shot(s, 'pending-consent'); |
| 505 | |
| 506 | // ══ 7: what a reload leaves behind |
| 507 | // |
| 508 | // The tile from check 5 is still parked and is deliberately left unanswered. |
| 509 | // A reload takes its promise with it, and the tile has to say so. |
| 510 | const keptId = tile3 ? tile3.id : ''; |
| 511 | const keptHead = tile3 ? tile3.headline : ''; |
| 512 | await p.reload({ waitUntil: 'domcontentloaded' }); |
| 513 | // A reload lands on the passphrase gate, and nothing behind it -- the panels, |
| 514 | // the rail, `Pending.load` -- runs until it is answered. Signing in again is |
| 515 | // what a person returning to the tab does. |
| 516 | await signInAs(s, 'pending'); |
| 517 | await p.waitForFunction(() => !!window.DaimondPendingView, null, { timeout: 20000 }).catch(() => {}); |
| 518 | await sleep(1500); |
| 519 | await p.evaluate(() => DaimondPanels.show('pending')); |
| 520 | await until(async () => (await tiles()).some((i) => i.id === keptId), 8000); |
| 521 | const after = await tiles(); |
| 522 | const kept = after.find((i) => i.id === keptId); |
| 523 | check('a question that was never answered is still on the panel after a reload', |
| 524 | !!kept && kept.headline === keptHead, kept ? kept.headline : 'it vanished with the page'); |
| 525 | const drawn2 = await p.evaluate((id) => { |
| 526 | const card = document.querySelector('#pending-list .pend-card[data-id="' + id + '"]'); |
| 527 | if (!card) return null; |
| 528 | const go = card.querySelector('.pend-go'); |
| 529 | return { |
| 530 | note: (card.querySelector('.pend-consent') || {}).textContent || '', |
| 531 | goDisabled: !!(go && go.disabled), |
| 532 | dimmed: go ? (getComputedStyle(go).opacity) : '', |
| 533 | }; |
| 534 | }, keptId); |
| 535 | check('and it says the agent that asked has gone, rather than looking live', |
| 536 | !!drawn2 && /has gone/i.test(drawn2.note), drawn2 ? drawn2.note.trim() : 'no tile drawn'); |
| 537 | check('and its tick is dead — the same tick that was live before the reload', |
| 538 | !!drawn2 && drawn2.goDisabled === true, drawn2 ? ('disabled=' + drawn2.goDisabled) : ''); |
| 539 | check('and it is dimmed, so it does not read as a button that simply did nothing', |
| 540 | !!drawn2 && parseFloat(drawn2.dimmed) < 0.9, drawn2 ? ('opacity ' + drawn2.dimmed) : ''); |
| 541 | const pressDead = await answerTile(keptId, 'pend-go'); |
| 542 | check('and it cannot be pressed', pressDead === 'disabled', pressDead); |
| 543 | await answerTile(keptId, 'pend-no'); |
| 544 | await sleep(300); |
| 545 | |
| 546 | // ══ 8–9: the mail-arrival trigger, which nothing has ever driven |
| 547 | // |
| 548 | // `mail.js` announces an arrival on `daimond:mail-arrived` and never calls the |
| 549 | // trigger machinery itself, so the event IS the seam. Dispatched here exactly |
| 550 | // as the mail panel dispatches it. |
| 551 | const BOX = 'alice@test.local'; |
| 552 | const SAYS = 'MAIL TRIGGER CHECK ' + Date.now().toString(36); |
| 553 | const help = await p.evaluate(() => { |
| 554 | const box = [...document.querySelectorAll('#diamond-list .diamond-box')] |
| 555 | .find((b) => /Help/.test(b.textContent || '')); |
| 556 | return box ? { id: box.dataset.id } : null; |
| 557 | }); |
| 558 | if (!help) { |
| 559 | check('a Diamond to hang a mail trigger on', false, 'the rail has no Daimond Help'); |
| 560 | } else { |
| 561 | const taId = await p.evaluate(async (a) => { |
| 562 | const T = window.DaimondTriggers; |
| 563 | const ta = T.blank('mail'); |
| 564 | ta.id = 'mail-' + Date.now().toString(36); |
| 565 | ta.mailbox = a.box; |
| 566 | ta.folder = 'INBOX'; |
| 567 | ta.instruction = a.says; |
| 568 | await DaimondCore.triggerSet(a.id, ta); |
| 569 | // Said out loud rather than assumed: an unheld leaf reads as playing, |
| 570 | // and this is a check about the folder, not about the tree. |
| 571 | DaimondPause.set(T.node(a.id, ta.id), true); |
| 572 | return ta.id; |
| 573 | }, { id: help.id, box: BOX, says: SAYS }); |
| 574 | // On screen: a trigger deliberately does not move the centre out from |
| 575 | // under somebody, so a Diamond that is not selected is refused. |
| 576 | await p.evaluate((id) => { |
| 577 | document.querySelector(`#diamond-list .diamond-box[data-id="${id}"]`).click(); |
| 578 | }, help.id); |
| 579 | await sleep(800); |
| 580 | |
| 581 | const said = (from) => mockLog().slice(from).some((r) => JSON.stringify(r).includes(SAYS)); |
| 582 | |
| 583 | // The NEGATIVE first, before anything has fired: a refusal that came from |
| 584 | // a turn already running would look exactly like a refusal from the |
| 585 | // folder not matching. |
| 586 | const seen0 = mockLog().length; |
| 587 | await p.evaluate((box) => window.dispatchEvent(new CustomEvent('daimond:mail-arrived', |
| 588 | { detail: { mailbox: box, folder: 'Archive' } })), BOX); |
| 589 | await sleep(2500); |
| 590 | check('mail landing in a folder nobody is watching reaches nobody', |
| 591 | !said(seen0), said(seen0) ? 'the daimon was steered anyway' : 'silent'); |
| 592 | |
| 593 | const seen1 = mockLog().length; |
| 594 | await p.evaluate((box) => window.dispatchEvent(new CustomEvent('daimond:mail-arrived', |
| 595 | { detail: { mailbox: box, folder: 'INBOX' } })), BOX); |
| 596 | const reached = await until(() => Promise.resolve(said(seen1)), 20000); |
| 597 | check('mail landing in the watched folder sends that TA\'s instruction to the daimon', |
| 598 | reached, reached ? '' : 'the instruction never reached the wire'); |
| 599 | check('a mail trigger is a leaf of the pause tree, like every other', |
| 600 | await p.evaluate((a) => !!window.DaimondPause |
| 601 | && typeof window.DaimondPause.isPaused(window.DaimondTriggers.node(a.id, a.ta)) === 'boolean', |
| 602 | { id: help.id, ta: taId }), ''); |
| 603 | } |
| 604 | |
| 605 | await shot(s, 'pending-final'); |
| 606 | } catch (e) { |
| 607 | check('the run finished', false, String(e && e.message || e)); |
| 608 | try { await shot(s, 'threw'); } catch (e2) { /* the page may be gone */ } |
| 609 | } finally { |
| 610 | failures = bad.length; |
| 611 | await s.close(); |
| 612 | } |
| 613 | |
| 614 | console.log(failures === 0 |
| 615 | ? `\nverify_pending: all ${ok.length} checks pass.` |
| 616 | : `\nverify_pending: ${failures} of ${ok.length + failures} failed:\n ` |
| 617 | + bad.join('\n ')); |
| 618 | process.exit(failures === 0 ? 0 : 1); |