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)`). |
| 62 | import fs from 'node:fs'; |
| 63 | import path from 'node:path'; |
| 64 | import { fileURLToPath } from 'node:url'; |
| 65 | import { open, signInAs, connectMock, shot, scratch, errors, mockLog } from './harness.mjs'; |
| 66 | import { IMAP_PORT, SMTP_PORT } from './ports.mjs'; |
| 67 | |
| 68 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 69 | const WWW = path.join(HERE, '..', 'www'); |
| 70 | |
| 71 | const BREAK = (() => { |
| 72 | const i = process.argv.indexOf('--break'); |
| 73 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 74 | })(); |
| 75 | |
| 76 | const PROFILE = scratch('pw', 'mailtrigger' + (BREAK ? '-' + BREAK : '')); |
| 77 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 78 | |
| 79 | const ok = [], bad = []; |
| 80 | const 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. |
| 89 | const 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 | |
| 93 | const 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 | |
| 126 | if (BREAK && !BREAKS[BREAK]) { |
| 127 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 128 | process.exit(2); |
| 129 | } |
| 130 | |
| 131 | function 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. |
| 148 | function 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 | |
| 160 | const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' }; |
| 161 | const json = (body, status = 200) => ({ |
| 162 | status, contentType: 'application/json', headers: CORS, body: JSON.stringify(body), |
| 163 | }); |
| 164 | |
| 165 | const BOX = 'alice@test.local'; |
| 166 | const 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. |
| 169 | const 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. |
| 178 | function 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. |
| 184 | const 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. |
| 197 | const 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. |
| 206 | function 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 | |
| 226 | async 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 | |
| 258 | const 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. |
| 262 | const SAYS = 'MAIL TRIGGER CHECK ' + Date.now().toString(36); |
| 263 | |
| 264 | const s = await open({ name: 'mailtrigger', profile: PROFILE, signIn: false, connect: false, route: stub }); |
| 265 | const { page } = s; |
| 266 | |
| 267 | /// Everything `daimond:mail-arrived` has announced so far, as the listener in |
| 268 | /// daimond.js sees it. |
| 269 | const 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. |
| 273 | const 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. |
| 278 | const settle = (ms = 6000) => sleep(ms); |
| 279 | |
| 280 | let logBase = 0; |
| 281 | try { |
| 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 | |
| 459 | console.log('\nsyncs: ' + syncs.map(x => `${x.before ? 'before ' + x.before : 'since ' + x.since}` |
| 460 | + `→[${x.gave}]`).join(' ')); |
| 461 | if (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 | } |
| 466 | console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`); |
| 467 | process.exit(bad.length === 0 ? 0 : 1); |