Oregami
Repositories/oxedyne/daimond

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.
87import fs from 'node:fs';
88import path from 'node:path';
89import { fileURLToPath } from 'node:url';
90import { open, shot, scratch, mockLog, signInAs } from './harness.mjs';
91
92const HERE = path.dirname(fileURLToPath(import.meta.url));
93const WWW = path.join(HERE, '..', 'www');
94
95const BREAK = (() => {
96 const i = process.argv.indexOf('--break');
97 return i > 0 ? String(process.argv[i + 1] || '') : '';
98})();
99
100const PROFILE = scratch('pw', 'pending' + (BREAK ? '-' + BREAK : ''));
101fs.rmSync(PROFILE, { recursive: true, force: true });
102
103const ok = [], bad = [];
104const 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.
112const 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
179if (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.
186function 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.
203function 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
213const 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
224const HOST = 'shop.test';
225const 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.
228const CARD = 'x'.repeat(400) + 'card-4111-2222-3333-4444' + 'x'.repeat(400);
229
230const s = await open({ name: 'pending', profile: PROFILE, signIn: true, connect: true,
231 route: routeBreaks });
232const { page: p } = s;
233await p.waitForFunction(() => !!window.DaimondCore && !!window.__daimondEgressAllowed
234 && !!window.DaimondPendingView, null, { timeout: 20000 }).catch(() => {});
235
236const 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.
243async 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.
265async 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.
286const 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
293const answerOf = (slot) => p.evaluate((k) => window[k], slot);
294const drv = () => p.evaluate(() => ({
295 clicks: window.__drv.clicks.slice(), types: window.__drv.types.slice() }));
296const resetDrv = () => p.evaluate(() => { window.__drv.clicks = []; window.__drv.types = []; });
297const dialogs = () => p.evaluate(() => document.querySelectorAll('.modal.dlg').length);
298const 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.
302async 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.
314const 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.
324const 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
332let failures = 0;
333try {
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
614console.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 '));
618process.exit(failures === 0 ? 0 : 1);