Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_mailtrigger.mjs

21.2 KiB, 1 run

created by r2519314175:529, 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_mailtrigger.mjs — the mail-arrival trigger fires on mail that arrived,
2// and on nothing else.
3//
4// A triggered action is a Diamond spending WITHOUT BEING ASKED, so the occasion
5// that fires one is not a detail — it is the whole safety of the feature. The
6// mail arm of it had never been driven by any verifier in this tree (219 of
7// them; `grep -rl mail-arrived dev/` found only the Pending panel, which
8// dispatches the event ITSELF and never asks mail.js to). It shipped announcing
9// an arrival on any sync that returned messages, which is three different lies:
10//
11// * A "FETCH OLDER" BACKFILL. `syncOne(address, older, …)` had `older` in
12// scope at the dispatch and did not test it, so a user pressing "the next 25
13// older" woke every Diamond armed on that folder and paid for a turn about
14// mail they had had for months.
15// * A `uidValidity` REBUILD. The mailbox generation changing re-fetches the
16// folder from uid 0 (mail.js), and the whole mailbox came back looking new.
17// A generation change RENUMBERS every uid, so the new numbers are commonly
18// far above the old watermark and no high-water test can catch it — the
19// rebuild has to say so itself. That is a bill the size of the mailbox.
20// * THE FIRST FETCH OF A FOLDER. Adding an account with a trigger already
21// armed announced everything already in it.
22//
23// The properties, each asserted in BOTH directions — because a check that only
24// ever proves silence passes with the feature entirely absent:
25//
26// 1. A GENUINE ARRIVAL FIRES IT, ONCE, NAMING THE MESSAGES THAT ARRIVED. The
27// event's uids are the new ones and not the folder's, and a turn really
28// reaches the model carrying the action's instruction. Measured at the
29// MOCK PROVIDER'S LOG, which is where the money is: a check on the event
30// alone would pass with the whole trigger chain unwired.
31// 2. A FETCH OLDER FIRES NOTHING. Pressed on the real button in the panel.
32// 3. A uidValidity REBUILD FIRES NOTHING, though every uid it returns is above
33// the old watermark.
34// 4. A FIRST FETCH FIRES NOTHING.
35// 5. AND THE TRIGGER IS STILL ARMED AFTERWARDS: mail arriving after a rebuild
36// fires it again. Hiding the feature would satisfy 2, 3 and 4 and be worse
37// than the defect.
38//
39// EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a
40// damaged `www/js/mail.js` to the real page through `page.route`; the run is
41// expected to fail, and an anchor that does not appear exactly once aborts.
42//
43// node dev/verify_mailtrigger.mjs --break shipped # the code as it shipped: 1-5 all fail
44// node dev/verify_mailtrigger.mjs --break older # a backfill counts as arrival: 2 fails
45// node dev/verify_mailtrigger.mjs --break rebuild # a rebuild counts as arrival: 3 fails
46// node dev/verify_mailtrigger.mjs --break baseline # a first fetch counts as arrival: 4 fails
47// node dev/verify_mailtrigger.mjs # and then, clean
48//
49// eval "$(bash dev/world.sh 20 --up)"
50// node dev/verify_mailtrigger.mjs
51//
52// NO IMAP FIXTURE AND NO GATEWAY. Every mail route is stubbed here, exactly as
53// `dev/verify_mailrefresh.mjs` does, and everything below the stub is the real
54// code: the sync, the Maildir on disk, the panel, the event, the trigger engine,
55// the daimon and the wire to the model. The one thing the stub cannot prove is
56// that a REAL IMAP server's `since_uid` / `before_uid` / `uid_validity`
57// behaviour matches this fixture's; `dev/verify_mailsync.mjs` and
58// `dev/verify_mailfolders.mjs` own that against the fixture on :1143.
59//
60// The fixture's `since_uid` is EXCLUSIVE, matching the shipped gateway
61// (gateway/src/handlers/mail.rs:589, `u.retain(|x| *x > since_uid)`).
62import fs from 'node:fs';
63import path from 'node:path';
64import { fileURLToPath } from 'node:url';
65import { open, signInAs, connectMock, shot, scratch, errors, mockLog } from './harness.mjs';
66import { IMAP_PORT, SMTP_PORT } from './ports.mjs';
67
68const HERE = path.dirname(fileURLToPath(import.meta.url));
69const WWW = path.join(HERE, '..', 'www');
70
71const BREAK = (() => {
72 const i = process.argv.indexOf('--break');
73 return i > 0 ? String(process.argv[i + 1] || '') : '';
74})();
75
76const PROFILE = scratch('pw', 'mailtrigger' + (BREAK ? '-' + BREAK : ''));
77fs.rmSync(PROFILE, { recursive: true, force: true });
78
79const ok = [], bad = [];
80const check = (name, pass, detail) => {
81 (pass ? ok : bad).push(name);
82 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
83};
84
85// ── The breaks ───────────────────────────────────────────────────────
86//
87// The guard reads `(older || rebuilt || !known)`; each break removes one term,
88// and `shipped` puts back the line the app actually carried.
89const GUARD = '\t\t\tvar fresh = (older || rebuilt || !known)\n'
90 + '\t\t\t\t? []\n'
91 + '\t\t\t\t: msgs.filter(function (m) { return m.uid > mark; });';
92
93const BREAKS = {
94 shipped: [{
95 file: 'js/mail.js',
96 find: GUARD,
97 with: '\t\t\tvar fresh = msgs;',
98 }],
99 // BOTH fences that cover a backfill, because a backfill has two: the `older`
100 // term, and the high-water filter (a message BELOW what is held cannot be
101 // above the mark, so a well-behaved server is caught twice). Removing one
102 // alone fails nothing, which is the point of keeping both — and a break that
103 // removed one alone would report a passing run as proof of a check that the
104 // other fence had quietly carried.
105 older: [{
106 file: 'js/mail.js',
107 find: GUARD,
108 with: '\t\t\tvar fresh = (rebuilt || !known) ? [] : msgs;',
109 }],
110 rebuild: [{
111 file: 'js/mail.js',
112 find: GUARD,
113 with: '\t\t\tvar fresh = (older || !known)\n'
114 + '\t\t\t\t? []\n'
115 + '\t\t\t\t: msgs.filter(function (m) { return m.uid > mark; });',
116 }],
117 baseline: [{
118 file: 'js/mail.js',
119 find: GUARD,
120 with: '\t\t\tvar fresh = (older || rebuilt)\n'
121 + '\t\t\t\t? []\n'
122 + '\t\t\t\t: msgs.filter(function (m) { return m.uid > mark; });',
123 }],
124};
125
126if (BREAK && !BREAKS[BREAK]) {
127 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
128 process.exit(2);
129}
130
131function damaged(src, spec) {
132 const n = src.split(spec.find).length - 1;
133 if (n !== 1) {
134 console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, `
135 + 'so nothing was broken and the run below would prove nothing.');
136 process.exit(2);
137 }
138 return src.replace(spec.find, spec.with);
139}
140
141/// The damaged files, ONE BODY PER FILE.
142///
143/// Every edit a break names for a file goes into the SAME body, in order, and
144/// that one body is what the route serves. A `page.route` per edit spec does not
145/// work and does not say so: Playwright hands a request to the LAST route
146/// registered for its URL, so a two-edit break shipped only its second edit --
147/// and still went red, for half the reason it claims, with nothing to notice it.
148function damagedFiles() {
149 const byFile = new Map();
150 for (const spec of (BREAKS[BREAK] || [])) {
151 const src = byFile.has(spec.file) ? byFile.get(spec.file)
152 : fs.readFileSync(path.join(WWW, spec.file), 'utf8');
153 byFile.set(spec.file, damaged(src, spec));
154 }
155 return byFile;
156}
157
158// ── The stubbed gateway, and the mailbox behind it ───────────────────
159
160const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' };
161const json = (body, status = 200) => ({
162 status, contentType: 'application/json', headers: CORS, body: JSON.stringify(body),
163});
164
165const BOX = 'alice@test.local';
166const LIMIT = 2; // messages per batch, so there is a backlog to reach into
167
168/// The server's INBOX: what it holds, and under which generation.
169const server = {
170 validity: 42,
171 msgs: [1, 2, 3, 4, 5, 6].map(uid => ({ uid, subject: 'old ' + uid })),
172};
173
174/// A generation change, which is what `uidValidity` means: every UID now names a
175/// DIFFERENT message, so the server renumbers. The new numbers here are far
176/// ABOVE the old watermark, which is the case a high-water test cannot catch and
177/// the reason the rebuild has to announce itself.
178function renumber(base) {
179 server.validity += 1;
180 server.msgs = server.msgs.map((m, i) => ({ uid: base + i, subject: m.subject }));
181}
182
183/// A message on the wire, as the gateway serves it: base64 RFC 5322.
184const wire = (m) => ({
185 uid: m.uid,
186 flags: ['\\Seen'],
187 raw: Buffer.from(
188 'From: Someone <someone@example.com>\r\n'
189 + `Subject: ${m.subject}\r\n`
190 + 'Date: Thu, 7 Aug 2026 09:00:00 +1000\r\n'
191 + 'Content-Type: text/plain; charset=utf-8\r\n\r\n'
192 + `${m.subject} body\r\n`, 'utf8').toString('base64'),
193});
194
195/// Every sync request that LEFT the page, so a check can say which fetch it is
196/// talking about rather than counting events and hoping.
197const syncs = [];
198
199/// What the fixture answers, by the rules the real gateway follows.
200///
201/// `before_uid` reaches BACKWARDS and returns the highest batch below it;
202/// `since_uid` walks forwards and is EXCLUSIVE. The one departure is the
203/// rebuild's re-fetch — the request mail.js sends carries `since_uid: 0` AND
204/// `before_uid: 0` together, and is answered with the WHOLE folder, because
205/// "the whole mailbox comes back" is the case under test.
206function answer(b) {
207 const all = server.msgs.slice().sort((x, y) => x.uid - y.uid);
208 const since = b.since_uid | 0;
209 const before = b.before_uid | 0;
210 const rebuild = since === 0 && b.before_uid === 0;
211 let out;
212 if (rebuild) out = all;
213 else if (before > 0) out = all.filter(m => m.uid < before).slice(-LIMIT);
214 else out = all.filter(m => m.uid > since).slice(-LIMIT);
215 return {
216 ok: true,
217 uid_validity: server.validity,
218 messages: out.map(wire),
219 // What the cap left behind, so the panel offers the "older" button at all.
220 held_back: all.length - out.length,
221 limit: LIMIT,
222 credits_minor: 4990,
223 };
224}
225
226async function stub(page) {
227 if (BREAK) {
228 for (const [file, body] of damagedFiles()) {
229 await page.route('**/' + file, r => r.fulfill({
230 status: 200, contentType: 'application/javascript', body,
231 }));
232 }
233 }
234 await page.route('**/api/account', r => r.fulfill(json({ ok: true })));
235 await page.route('**/api/auth/challenge', r => r.fulfill(json({ ok: true, challenge: 'chal-mt', challenge_id: 'cid-1' })));
236 await page.route('**/api/auth/verify', r => r.fulfill(json({ ok: true })));
237 await page.route('**/api/balance', r => r.fulfill(json({ ok: true, credits_minor: 5000, currency: 'usd', entries: [] })));
238 await page.route('**/api/licence', r => r.fulfill(json({ ok: true, licence: true, currency: 'usd' })));
239 await page.route('**/api/mail/accounts', r => r.request().method() === 'GET'
240 ? r.fulfill(json({ ok: true, unlocked: true, max_accounts: 3 }))
241 : r.fulfill(json({ ok: true })));
242 await page.route('**/api/mail/folders', r => r.fulfill(json({ ok: true, folders: [{ name: 'INBOX' }] })));
243 await page.route('**/api/mail/send', r => r.fulfill(json({ ok: true })));
244 await page.route('**/api/mail/sync', r => {
245 let b = {};
246 try { b = JSON.parse(r.request().postData() || '{}'); } catch (e) { b = {}; }
247 const j = answer(b);
248 syncs.push({
249 mailbox: b.mailbox, since: b.since_uid, before: b.before_uid,
250 gave: j.messages.map(m => m.uid),
251 });
252 return r.fulfill(json(j));
253 });
254}
255
256// ── Driving ──────────────────────────────────────────────────────────
257
258const sleep = (ms) => new Promise(r => setTimeout(r, ms));
259
260// Unique to this run, so a turn found in the mock's log cannot be an earlier
261// run's: the log belongs to the WORLD, not to the run.
262const SAYS = 'MAIL TRIGGER CHECK ' + Date.now().toString(36);
263
264const s = await open({ name: 'mailtrigger', profile: PROFILE, signIn: false, connect: false, route: stub });
265const { page } = s;
266
267/// Everything `daimond:mail-arrived` has announced so far, as the listener in
268/// daimond.js sees it.
269const arrivals = () => page.evaluate(() => window.__arrivals || []);
270
271/// Turns that reached the model carrying this run's instruction. THE MONEY: a
272/// trigger that fires is a turn nobody asked for.
273const turns = (from) => mockLog().slice(from).filter(r => JSON.stringify(r).includes(SAYS)).length;
274
275/// Wait for a sync to finish and any turn it caused to land. Generous, and the
276/// same wait on every step: a check that gave the firing step longer than the
277/// silent ones would be measuring the clock.
278const settle = (ms = 6000) => sleep(ms);
279
280let logBase = 0;
281try {
282 if (BREAK) console.log(` .. running with --break ${BREAK}`);
283 await signInAs(s, 'mailtrigger');
284 await connectMock(s);
285
286 // The recorder goes on BEFORE anything can sync, so a missed announcement is
287 // a missed announcement and not a late listener.
288 await page.evaluate(() => {
289 window.__arrivals = [];
290 window.addEventListener('daimond:mail-arrived', (e) => window.__arrivals.push(e.detail));
291 });
292
293 // A Diamond to be woken. `Daimond Help` is seeded with no actions and no hold,
294 // so anything that fires here is the action this file armed.
295 await page.evaluate(() => DaimondDiamond.seedDefaults());
296 await page.waitForFunction(() =>
297 [...document.querySelectorAll('#diamond-list .session-box-name')]
298 .some(n => /Daimond Help/.test(n.textContent)), null, { timeout: 20000 });
299 const helpId = await page.evaluate(() => {
300 const b = [...document.querySelectorAll('#diamond-list .diamond-box')]
301 .find(x => /Daimond Help/.test(x.textContent));
302 return b ? b.dataset.id : '';
303 });
304 check('a Diamond is there to be woken', !!helpId, helpId || '(none)');
305
306 // Armed on THIS mailbox and THIS folder, and its pause leaf left playing —
307 // said out loud rather than assumed, because a held TA is dropped before
308 // anything else is asked and every check below would then pass for the wrong
309 // reason.
310 await page.evaluate(async (a) => {
311 const T = window.DaimondTriggers;
312 const ta = T.blank('mail');
313 ta.id = 'mailwatch';
314 ta.mailbox = a.box;
315 ta.folder = 'INBOX';
316 ta.instruction = a.says;
317 await DaimondCore.triggerSet(a.id, ta);
318 DaimondPause.set(T.node(a.id, ta.id), true);
319 }, { id: helpId, box: BOX, says: SAYS });
320 const armed = await page.evaluate((a) => {
321 const T = window.DaimondTriggers;
322 const ta = (window.DaimondTriggersOf(a.id) || []).find(x => x.id === 'mailwatch');
323 return { ready: !!ta && T.ready(ta), allowed: !!ta && T.allowed(a.id, ta) };
324 }, { id: helpId });
325 check('the mail trigger is armed and not held', armed.ready && armed.allowed,
326 JSON.stringify(armed));
327
328 // It is the Diamond ON SCREEN, because a trigger deliberately refuses to move
329 // the centre out from under somebody: with another Diamond up, every firing
330 // below would be refused and the checks would prove nothing.
331 await page.evaluate((id) => {
332 document.querySelector(`#diamond-list .diamond-box[data-id="${id}"]`).click();
333 }, helpId);
334 await page.waitForTimeout(900);
335
336 // The mailbox, seeded the way the add-dialog would but pointed nowhere real.
337 await page.evaluate(async ([box, PORTS]) => {
338 const pass = await window.DaimondIdentity.wrap('test-app-password');
339 localStorage.setItem('daimond-mail', JSON.stringify({
340 accounts: [{
341 address: box, host: '127.0.0.1', port: PORTS.imap, security: 'plain',
342 smtpHost: '127.0.0.1', smtpPort: PORTS.smtp, smtpSecurity: 'plain',
343 user: box, pass, folder: 'INBOX', folders: {}, lastSync: 0, touched: 1,
344 }],
345 sel: box,
346 }));
347 window.DaimondMail.reload();
348 window.DaimondPanels.show('mail');
349 window.DaimondMail.onOpen();
350 }, [BOX, { imap: IMAP_PORT, smtp: SMTP_PORT }]);
351 await page.waitForTimeout(1500);
352
353 logBase = mockLog().length;
354
355 // Each step is measured against the one before it, not against a running
356 // total: "this fetch announced nothing" is the property, and a cumulative
357 // count would let one step's failure redden every step after it and hide
358 // which line is responsible.
359 let seenArr = 0, seenTurns = 0;
360 /// What the step just driven announced, and what it cost.
361 async function step() {
362 const all = await arrivals();
363 const t = turns(logBase);
364 const out = { fresh: all.slice(seenArr), turns: t - seenTurns, fetched: syncs[syncs.length - 1] };
365 seenArr = all.length;
366 seenTurns = t;
367 return out;
368 }
369
370 // ══ 4. The first fetch of a folder is a baseline ══════════════════
371 await page.evaluate(() => window.DaimondMail.sync());
372 await settle();
373 const first = await step();
374 check('THE FIRST FETCH OF A FOLDER ANNOUNCES NOTHING — the messages were already '
375 + 'there and none of them arrived',
376 first.fresh.length === 0 && !!first.fetched && first.fetched.gave.length > 0,
377 `fetched ${JSON.stringify(first.fetched && first.fetched.gave)}, `
378 + `announced ${JSON.stringify(first.fresh)}`);
379 check('and no turn was bought for them', first.turns === 0, `${first.turns} turn(s)`);
380
381 // ══ 1. A genuine arrival ══════════════════════════════════════════
382 server.msgs.push({ uid: 7, subject: 'new A' }, { uid: 8, subject: 'new B' });
383 await page.evaluate(() => window.DaimondMail.sync());
384 await settle();
385 const arrived = await step();
386 check('MAIL THAT ACTUALLY ARRIVED IS ANNOUNCED, ONCE', arrived.fresh.length === 1,
387 JSON.stringify(arrived.fresh));
388 // Meaning, not arity: the event names the two messages that arrived and not
389 // the six the folder holds, and it names the mailbox and folder they came to.
390 check('and it names THOSE messages — uids 7 and 8, not the folder',
391 arrived.fresh.length === 1
392 && JSON.stringify((arrived.fresh[0].uids || []).slice().sort((a, b) => a - b)) === '[7,8]'
393 && arrived.fresh[0].count === 2
394 && arrived.fresh[0].mailbox === BOX && arrived.fresh[0].folder === 'INBOX',
395 JSON.stringify(arrived.fresh[0] || {}));
396 check('AND A TURN REALLY REACHED THE MODEL carrying the action\'s instruction — the '
397 + 'chain is wired end to end, so the silences below are refusals and not a dead wire',
398 arrived.turns === 1, `${arrived.turns} turn(s) on the wire`);
399
400 // ══ 2. A fetch older ══════════════════════════════════════════════
401 // Pressed on the real button in the panel, which is how a person reaches it.
402 const pressed = await page.evaluate(() => {
403 const b = document.querySelector('.mail-older');
404 if (!b) return false;
405 b.click();
406 return true;
407 });
408 check('the panel offers "fetch older", so this is the control the user presses',
409 pressed, pressed ? '' : 'no .mail-older button was drawn');
410 await settle();
411 const older = await step();
412 check('A FETCH OLDER ANNOUNCES NOTHING, though it returned messages',
413 older.fresh.length === 0 && !!older.fetched && older.fetched.before > 0
414 && older.fetched.gave.length > 0,
415 `the backfill returned ${JSON.stringify(older.fetched && older.fetched.gave)}, `
416 + `announced ${JSON.stringify(older.fresh)}`);
417 check('and it bought no turn — pressing the button was the asking',
418 older.turns === 0, `${older.turns} turn(s)`);
419
420 // ══ 3. A uidValidity rebuild ══════════════════════════════════════
421 renumber(101);
422 await page.evaluate(() => window.DaimondMail.sync());
423 await settle();
424 const rebuilt = await step();
425 check('A uidValidity REBUILD ANNOUNCES NOTHING, though the whole folder came back '
426 + 'and every uid in it is above the old watermark',
427 rebuilt.fresh.length === 0 && !!rebuilt.fetched && rebuilt.fetched.gave.length >= 6
428 && Math.min(...rebuilt.fetched.gave) > 8,
429 `the rebuild returned ${JSON.stringify(rebuilt.fetched && rebuilt.fetched.gave)}, `
430 + `announced ${JSON.stringify(rebuilt.fresh)}`);
431 check('and it bought no turn — a rebuild is not a delivery',
432 rebuilt.turns === 0, `${rebuilt.turns} turn(s)`);
433
434 // ══ 5. Still armed ════════════════════════════════════════════════
435 server.msgs.push({ uid: 109, subject: 'new C' });
436 await page.evaluate(() => window.DaimondMail.sync());
437 await settle();
438 const again = await step();
439 check('AND THE TRIGGER IS STILL ARMED: mail arriving after all that fires it again, '
440 + 'naming the one message that came',
441 again.fresh.length === 1 && JSON.stringify(again.fresh[0].uids || []) === '[109]',
442 JSON.stringify(again.fresh));
443 check('and that one did buy a turn — the guards refuse occasions, not the feature',
444 again.turns === 1, `${again.turns} turn(s)`);
445
446 // A refusal is a decision, not a fault.
447 const errs = errors(s).filter(e => !/Failed to load resource/.test(e) && !/Paused:/.test(e));
448 check('nothing was refused by way of an unhandled error', errs.length === 0,
449 errs.slice(0, 3).join(' | '));
450
451 await shot(s, 'mailtrigger' + (BREAK ? '-' + BREAK : ''));
452} catch (e) {
453 check('the run finished', false, String((e && e.message) || e));
454 try { await shot(s, 'mailtrigger-threw'); } catch { /* nothing to show */ }
455} finally {
456 await s.close();
457}
458
459console.log('\nsyncs: ' + syncs.map(x => `${x.before ? 'before ' + x.before : 'since ' + x.since}`
460 + `→[${x.gave}]`).join(' '));
461if (BREAK) {
462 console.log(`\nbreak '${BREAK}': ${bad.length} check(s) failed`
463 + (bad.length ? ' — ' + bad.join('; ') : ' — NOTHING FAILED, so the checks above prove nothing'));
464 process.exit(bad.length ? 0 : 1);
465}
466console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`);
467process.exit(bad.length === 0 ? 0 : 1);