oxedyne/daimond/dev/verify_mailrefresh.mjs
26.8 KiB, 1 run
created by r2519314175:523, 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_mailrefresh.mjs — folders refresh themselves, say how much they hold, |
| 2 | // and stop dead when they are paused. |
| 3 | // |
| 4 | // Phase G of the notes2 programme: a refresh frequency per folder, a count on |
| 5 | // every folder row, and a gear dialog holding both. Three properties are worth |
| 6 | // a verifier and the rest is decoration: |
| 7 | // |
| 8 | // 1. A PAUSE IS REFUSED WHERE THE REQUEST IS MADE. Counted AT THE NETWORK — |
| 9 | // `page.route` sits between the page and the server, so a request that was |
| 10 | // refused never reaches a counter and one that was merely hidden does. |
| 11 | // Both halves are asked, and the second is the one that matters: paused → |
| 12 | // nothing left the page; resumed → the request DOES leave. A check that |
| 13 | // only ever proves silence passes with the feature entirely absent. |
| 14 | // |
| 15 | // 2. THE SCHEDULE IS HONOURED. A folder on a one-second interval is polled |
| 16 | // about that often; one on an hour is not polled in between. Both, because |
| 17 | // "it polls" and "it does not poll too much" are different failures. |
| 18 | // |
| 19 | // 3. A COUNT SAYS WHEN IT IS FROM. A number that silently means "as at the |
| 20 | // last sync" is a lie the reader will act on, so a folder never fetched |
| 21 | // shows no number at all rather than a nought, and a figure gone stale |
| 22 | // carries its age in the row where a `title` cannot be hovered. |
| 23 | // |
| 24 | // And the one the user asked for by name: the manual refresh still refreshes |
| 25 | // EVERYTHING — every folder of every mailbox, not the selected one. |
| 26 | // |
| 27 | // 4. THE FOLDER ON SCREEN IS THE ONE ON SCREEN. Refreshing everything is |
| 28 | // right; showing everything in turn is not. Each folder's sync ended by |
| 29 | // adopting its own digest as the panel's list, so a walk past Sent, |
| 30 | // Archive and a second mailbox left the reader's INBOX replaced — the |
| 31 | // messages appearing, emptying and reappearing as it went. Asserted by |
| 32 | // SUBJECT, because a list of the right length can still be the wrong |
| 33 | // folder's. |
| 34 | // |
| 35 | // EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a |
| 36 | // deliberately damaged copy of a source file to the real page (through |
| 37 | // `page.route`, so the browser loads it as it loads any other script) and the |
| 38 | // run is expected to FAIL. A break that does not apply cleanly aborts rather |
| 39 | // than passing quietly: a check proved against code that was never broken is |
| 40 | // not proved at all. |
| 41 | // |
| 42 | // node dev/verify_mailrefresh.mjs --break pause # 1's paused half fails |
| 43 | // node dev/verify_mailrefresh.mjs --break nofetch # 1's resumed half fails |
| 44 | // node dev/verify_mailrefresh.mjs --break schedule # 2 fails |
| 45 | // node dev/verify_mailrefresh.mjs --break counts # 3 fails |
| 46 | // node dev/verify_mailrefresh.mjs --break globalone # the manual refresh fails |
| 47 | // node dev/verify_mailrefresh.mjs --break digest # 4 fails: the refresh walk |
| 48 | // # leaves another folder's |
| 49 | // # digest on screen |
| 50 | // node dev/verify_mailrefresh.mjs # and then, clean |
| 51 | // |
| 52 | // eval "$(bash dev/world.sh 8 --up)" |
| 53 | // node dev/verify_mailrefresh.mjs |
| 54 | // |
| 55 | // Needs dev/serve.mjs only. No gateway on :9002 and no IMAP fixture: every mail |
| 56 | // route is stubbed here, and everything below the stub — the scheduler, the |
| 57 | // pause tree, the Maildir on disk, the panel — is the real code. |
| 58 | import fs from 'node:fs'; |
| 59 | import path from 'node:path'; |
| 60 | import { fileURLToPath } from 'node:url'; |
| 61 | import { open, shot, scratch, errors } from './harness.mjs'; |
| 62 | import { IMAP_PORT, SMTP_PORT } from './ports.mjs'; |
| 63 | |
| 64 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 65 | const WWW = path.join(HERE, '..', 'www'); |
| 66 | |
| 67 | const BREAK = (() => { |
| 68 | const i = process.argv.indexOf('--break'); |
| 69 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 70 | })(); |
| 71 | |
| 72 | const PROFILE = scratch('pw', 'mailrefresh' + (BREAK ? '-' + BREAK : '')); |
| 73 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 74 | |
| 75 | const ok = [], bad = []; |
| 76 | const check = (name, pass, detail) => { |
| 77 | (pass ? ok : bad).push(name); |
| 78 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 79 | }; |
| 80 | |
| 81 | // ── The breaks ─────────────────────────────────────────────────────── |
| 82 | // Each is a real edit to a real file, served in place of it. `find` must |
| 83 | // appear exactly once: a break that silently matched nothing would leave the |
| 84 | // suite green against working code and prove the opposite of what it claims. |
| 85 | const BREAKS = { |
| 86 | // No pause enforcement anywhere in the app. Not just mail.js's guard — |
| 87 | // gateway.js refuses the same request at the wire, so breaking one leaves |
| 88 | // the other doing the job and the check would rightly still pass. |
| 89 | pause: [{ |
| 90 | file: 'js/pause.js', |
| 91 | find: '\t\tload();\n\t\treturn !!_paused[nodeId];', |
| 92 | with: '\t\tload();\n\t\treturn false;', |
| 93 | }], |
| 94 | // The fetch never happens at all. The paused halves still pass — which is |
| 95 | // the whole point of asking the resumed half. |
| 96 | nofetch: [{ |
| 97 | file: 'js/mail.js', |
| 98 | find: '\t\tvar a = acct(address);\n\t\tif (!a || state.busy) return;\n\t\tvar name = folder || a.folder', |
| 99 | with: '\t\tvar a = acct(address);\n\t\tif (!a || state.busy || true) return;\n\t\tvar name = folder || a.folder', |
| 100 | }], |
| 101 | // The frequency is stored and nothing ever acts on it. |
| 102 | schedule: [{ |
| 103 | file: 'js/mail.js', |
| 104 | find: '\t\tvar wait = Math.max(floor, Math.min(TICK_MAX, soonest - now));\n\t\ttimer = setTimeout(function () { tick(); }, wait);', |
| 105 | with: '\t\tvar wait = Math.max(floor, Math.min(TICK_MAX, soonest - now));\n\t\tif (wait) return;', |
| 106 | }], |
| 107 | // A bare number: never stale, no as-at, and a nought for a folder nobody |
| 108 | // has ever fetched. This is the lie the check exists to catch. |
| 109 | counts: [{ |
| 110 | file: 'js/mail.js', |
| 111 | find: '\t\tvar f = a && a.folders && a.folders[name];\n\t\tvar last = (f && ms(f.lastSync)) || 0;\n\t\tif (!last) {', |
| 112 | with: '\t\tvar f = a && a.folders && a.folders[name];\n\t\tvar last = (f && ms(f.lastSync)) || 0;\n\t\tif (true) { return { text: String((f && f.count) | 0), when: \'\', stale: false, title: \'messages\' }; }\n\t\tif (!last) {', |
| 113 | }], |
| 114 | // The digest of whichever folder synced last becomes what the panel shows, |
| 115 | // selected or not. This is the flicker: a refresh walks INBOX, Sent and |
| 116 | // Archive in turn, each one replaces the list, and the user watches their |
| 117 | // inbox appear, empty and reappear as the walk goes past. |
| 118 | digest: [{ |
| 119 | file: 'js/mail.js', |
| 120 | find: '\t\tif (address === state.sel && a && name === (a.folder || \'INBOX\')) {\n' |
| 121 | + '\t\t\tstate.msgs = msgs;\n\t\t}', |
| 122 | with: '\t\tstate.msgs = msgs;', |
| 123 | }], |
| 124 | // The old behaviour: the selected mailbox, and nothing else. |
| 125 | globalone: [{ |
| 126 | file: 'js/mail.js', |
| 127 | find: '\t\tvar boxes = state.accounts.map(function (a) { return a.address; });', |
| 128 | with: '\t\tvar boxes = state.accounts.map(function (a) { return a.address; }).slice(0, 1);', |
| 129 | }], |
| 130 | }; |
| 131 | |
| 132 | if (BREAK && !BREAKS[BREAK]) { |
| 133 | console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`); |
| 134 | process.exit(2); |
| 135 | } |
| 136 | |
| 137 | /// `src` with `spec` applied, or a hard stop. Nothing is served that was not |
| 138 | /// verified to differ from what it was given. |
| 139 | function damaged(src, spec) { |
| 140 | const n = src.split(spec.find).length - 1; |
| 141 | if (n !== 1) { |
| 142 | console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, ` |
| 143 | + 'so nothing was broken and the run below would prove nothing.'); |
| 144 | process.exit(2); |
| 145 | } |
| 146 | return src.replace(spec.find, spec.with); |
| 147 | } |
| 148 | |
| 149 | /// The damaged files, ONE BODY PER FILE. |
| 150 | /// |
| 151 | /// Every edit a break names for a file goes into the SAME body, in order, and |
| 152 | /// that one body is what the route serves. A `page.route` per edit spec does not |
| 153 | /// work and does not say so: Playwright hands a request to the LAST route |
| 154 | /// registered for its URL, so a two-edit break shipped only its second edit -- |
| 155 | /// and still went red, for half the reason it claims, with nothing to notice it. |
| 156 | function damagedFiles() { |
| 157 | const byFile = new Map(); |
| 158 | for (const spec of (BREAKS[BREAK] || [])) { |
| 159 | const src = byFile.has(spec.file) ? byFile.get(spec.file) |
| 160 | : fs.readFileSync(path.join(WWW, spec.file), 'utf8'); |
| 161 | byFile.set(spec.file, damaged(src, spec)); |
| 162 | } |
| 163 | return byFile; |
| 164 | } |
| 165 | |
| 166 | // ── The stubbed gateway ────────────────────────────────────────────── |
| 167 | |
| 168 | const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' }; |
| 169 | const json = (body, status = 200) => ({ |
| 170 | status, contentType: 'application/json', headers: CORS, body: JSON.stringify(body), |
| 171 | }); |
| 172 | |
| 173 | const BOX_A = 'alice@test.local'; |
| 174 | const BOX_B = 'bob@test.local'; |
| 175 | |
| 176 | // Every folder poll that LEFT the page, in order. A refusal shows as a list |
| 177 | // that did not grow. |
| 178 | const syncs = []; |
| 179 | const lists = []; |
| 180 | const since = () => syncs.length; |
| 181 | const sincePer = (addr, folder) => |
| 182 | syncs.filter(s => s.address === addr && s.mailbox === folder).length; |
| 183 | |
| 184 | /// A message the fixture serves, as the gateway would: base64 RFC 5322. |
| 185 | const msg = (uid, subject) => ({ |
| 186 | uid, |
| 187 | flags: ['\\Seen'], |
| 188 | raw: Buffer.from( |
| 189 | `From: Someone <someone@example.com>\r\n` |
| 190 | + `Subject: ${subject}\r\n` |
| 191 | + `Date: Thu, 7 Aug 2026 09:00:00 +1000\r\n` |
| 192 | + `Content-Type: text/plain; charset=utf-8\r\n\r\n` |
| 193 | + `${subject} body\r\n`, 'utf8').toString('base64'), |
| 194 | }); |
| 195 | |
| 196 | // How many messages each folder holds on the server. The counts on the rows |
| 197 | // are checked against these, so they are the oracle and not a mirror of what |
| 198 | // the client happened to write. |
| 199 | const SERVER = { |
| 200 | [BOX_A]: { INBOX: 3, Sent: 1, Archive: 0 }, |
| 201 | [BOX_B]: { INBOX: 2, Sent: 0, Archive: 0 }, |
| 202 | }; |
| 203 | |
| 204 | async function stub(page) { |
| 205 | if (BREAK) { |
| 206 | for (const [file, body] of damagedFiles()) { |
| 207 | await page.route('**/' + file, r => r.fulfill({ |
| 208 | status: 200, contentType: 'application/javascript', body, |
| 209 | })); |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | await page.route('**/api/account', r => r.fulfill(json({ ok: true }))); |
| 214 | await page.route('**/api/auth/challenge', r => r.fulfill(json({ ok: true, challenge: 'chal-mr', challenge_id: 'cid-1' }))); |
| 215 | await page.route('**/api/auth/verify', r => r.fulfill(json({ ok: true }))); |
| 216 | await page.route('**/api/balance', r => r.fulfill(json({ ok: true, credits_minor: 5000, currency: 'usd', entries: [] }))); |
| 217 | await page.route('**/api/licence', r => r.fulfill(json({ ok: true, licence: true, held: true, currency: 'usd' }))); |
| 218 | |
| 219 | await page.route('**/api/mail/accounts', r => { |
| 220 | if (r.request().method() !== 'GET') return r.fulfill(json({ ok: true })); |
| 221 | return r.fulfill(json({ ok: true, unlocked: true, max_accounts: 3 })); |
| 222 | }); |
| 223 | |
| 224 | await page.route('**/api/mail/folders', r => { |
| 225 | let b = {}; |
| 226 | try { b = JSON.parse(r.request().postData() || '{}'); } catch (e) { b = {}; } |
| 227 | lists.push(b.address || ''); |
| 228 | return r.fulfill(json({ |
| 229 | ok: true, |
| 230 | folders: [ |
| 231 | { name: 'INBOX' }, |
| 232 | { name: 'Sent', role: 'sent' }, |
| 233 | { name: 'Archive', role: 'archive' }, |
| 234 | ], |
| 235 | })); |
| 236 | }); |
| 237 | |
| 238 | await page.route('**/api/mail/sync', r => { |
| 239 | let b = {}; |
| 240 | try { b = JSON.parse(r.request().postData() || '{}'); } catch (e) { b = {}; } |
| 241 | syncs.push({ address: b.address || '', mailbox: b.mailbox || '', at: Date.now() }); |
| 242 | const n = ((SERVER[b.address] || {})[b.mailbox]) | 0; |
| 243 | const messages = []; |
| 244 | for (let i = 1; i <= n; i++) messages.push(msg(i, `${b.mailbox} message ${i}`)); |
| 245 | return r.fulfill(json({ |
| 246 | ok: true, uid_validity: 42, messages, held_back: 0, limit: 25, credits_minor: 4990, |
| 247 | })); |
| 248 | }); |
| 249 | await page.route('**/api/mail/send', r => r.fulfill(json({ ok: true }))); |
| 250 | } |
| 251 | |
| 252 | // ── Driving ────────────────────────────────────────────────────────── |
| 253 | |
| 254 | const pauseLeaf = (page, id) => page.evaluate(i => window.DaimondPause.set(i, false), id); |
| 255 | const playLeaf = (page, id) => page.evaluate(i => window.DaimondPause.set(i, true), id); |
| 256 | const sleep = (ms) => new Promise(r => setTimeout(r, ms)); |
| 257 | |
| 258 | const s = await open({ name: 'mailrefresh', profile: PROFILE, signIn: false, connect: false }); |
| 259 | const { page } = s; |
| 260 | await stub(page); |
| 261 | |
| 262 | // The stub only takes effect on a load that comes after it, and sign-in reloads |
| 263 | // nothing — so the page is reopened with the routes in place. |
| 264 | await page.goto(process.env.DAIMOND_APP || 'http://localhost:8777', { waitUntil: 'domcontentloaded' }); |
| 265 | const { signInAs } = await import('./harness.mjs'); |
| 266 | await signInAs(s, 'mailrefresh'); |
| 267 | await page.waitForTimeout(2000); |
| 268 | |
| 269 | try { |
| 270 | // Two mailboxes, seeded the way the add-dialog would but pointed nowhere |
| 271 | // real: every route they use is stubbed above. |
| 272 | await page.evaluate(async ([a, b, PORTS]) => { |
| 273 | const pass = await window.DaimondIdentity.wrap('test-app-password'); |
| 274 | const mk = (address) => ({ |
| 275 | address, host: '127.0.0.1', port: PORTS.imap, security: 'plain', |
| 276 | smtpHost: '127.0.0.1', smtpPort: PORTS.smtp, smtpSecurity: 'plain', |
| 277 | user: address, pass, folder: 'INBOX', folders: {}, lastSync: 0, touched: 1, |
| 278 | }); |
| 279 | localStorage.setItem('daimond-mail', JSON.stringify({ accounts: [mk(a), mk(b)], sel: a })); |
| 280 | window.DaimondMail.reload(); |
| 281 | window.DaimondPanels.show('mail'); |
| 282 | window.DaimondMail.onOpen(); |
| 283 | }, [BOX_A, BOX_B, { imap: IMAP_PORT, smtp: SMTP_PORT }]); |
| 284 | await page.waitForTimeout(1500); |
| 285 | |
| 286 | const INBOX_A = `root/mail/${BOX_A}/INBOX`; |
| 287 | const SENT_A = `root/mail/${BOX_A}/Sent`; |
| 288 | const SELF_A = `root/mail/${BOX_A}/self`; |
| 289 | |
| 290 | // ── 1. A paused folder is not fetched ──────────────────────── |
| 291 | await pauseLeaf(page, INBOX_A); |
| 292 | let n = since(); |
| 293 | await page.evaluate(() => window.DaimondMail.sync()); |
| 294 | await page.waitForTimeout(1200); |
| 295 | check('a paused folder is not fetched', syncs.length === n, |
| 296 | `${syncs.length - n} request(s) left the page`); |
| 297 | |
| 298 | await playLeaf(page, INBOX_A); |
| 299 | n = since(); |
| 300 | await page.evaluate(() => window.DaimondMail.sync()); |
| 301 | await page.waitForTimeout(1500); |
| 302 | check('and resuming it fetches — so the silence above was the pause', |
| 303 | syncs.length === n + 1, `${syncs.length - n} request(s)`); |
| 304 | |
| 305 | // ── 2. A paused mailbox stops all its folders ──────────────── |
| 306 | // The `self` leaf is a SIBLING of the folders, not their ancestor, so the |
| 307 | // tree does not derive this and neither does `isPaused` on a folder leaf: |
| 308 | // it is a rule laid over the tree, written in mail.js's `pollStop` and |
| 309 | // again at the wire in gateway.js. What is asserted here is the rule, and |
| 310 | // the folder's own leaf is left PLAYING so that nothing else could explain |
| 311 | // the silence. |
| 312 | await pauseLeaf(page, SELF_A); |
| 313 | const folderState = await page.evaluate((id) => window.DaimondPause.state(id), INBOX_A); |
| 314 | check('the folder\'s own leaf is playing while the mailbox is held', |
| 315 | folderState === 'play', folderState); |
| 316 | |
| 317 | n = since(); |
| 318 | await page.evaluate(() => window.DaimondMail.sync()); |
| 319 | await page.waitForTimeout(1200); |
| 320 | check('a paused mailbox holds a folder that is playing', syncs.length === n, |
| 321 | `${syncs.length - n} request(s)`); |
| 322 | |
| 323 | // And the scheduler obeys the same rule: a frequency set on a folder under |
| 324 | // a held mailbox polls nothing at all. |
| 325 | await page.evaluate(([addr]) => { |
| 326 | window.DaimondMail.setRefresh(addr, 'INBOX', 1); |
| 327 | window.DaimondMail.setRefresh(addr, 'Sent', 1); |
| 328 | }, [BOX_A]); |
| 329 | n = since(); |
| 330 | await sleep(3500); |
| 331 | check('and the schedule under a held mailbox polls nothing', syncs.length === n, |
| 332 | `${syncs.length - n} request(s) in 3.5s with two folders on a 1s interval`); |
| 333 | |
| 334 | await playLeaf(page, SELF_A); |
| 335 | |
| 336 | // ── 3. The schedule is honoured ────────────────────────────── |
| 337 | // Sent is put on an hour AFTER a sync of its own, so its clock starts: |
| 338 | // a folder with a frequency and nothing behind it is due immediately, which |
| 339 | // is what setting one is supposed to mean. |
| 340 | await page.evaluate(([addr]) => { |
| 341 | window.DaimondMail.setRefresh(addr, 'Sent', 0); |
| 342 | window.DaimondMail.setRefresh(addr, 'INBOX', 0); |
| 343 | }, [BOX_A]); |
| 344 | await page.evaluate((addr) => window.DaimondMail.selectFolder('Sent'), BOX_A); |
| 345 | await page.waitForTimeout(1500); |
| 346 | await page.evaluate(() => window.DaimondMail.sync()); |
| 347 | await page.waitForTimeout(1200); |
| 348 | await page.evaluate((addr) => window.DaimondMail.selectFolder('INBOX'), BOX_A); |
| 349 | await page.waitForTimeout(1200); |
| 350 | |
| 351 | const inboxBefore = sincePer(BOX_A, 'INBOX'); |
| 352 | const sentBefore = sincePer(BOX_A, 'Sent'); |
| 353 | await page.evaluate(([addr]) => { |
| 354 | window.DaimondMail.setRefresh(addr, 'INBOX', 1); |
| 355 | window.DaimondMail.setRefresh(addr, 'Sent', 3600); |
| 356 | }, [BOX_A]); |
| 357 | await sleep(5000); |
| 358 | const inboxAfter = sincePer(BOX_A, 'INBOX'); |
| 359 | const sentAfter = sincePer(BOX_A, 'Sent'); |
| 360 | check('a folder on a short interval is polled about that often', |
| 361 | inboxAfter - inboxBefore >= 3, |
| 362 | `${inboxAfter - inboxBefore} poll(s) in 5s at 1s`); |
| 363 | check('and one on a long interval is not polled in between', |
| 364 | sentAfter === sentBefore, |
| 365 | `${sentAfter - sentBefore} poll(s) in 5s at 3600s`); |
| 366 | |
| 367 | // And a setting that says "manual only" means it. |
| 368 | await page.evaluate(([addr]) => { |
| 369 | window.DaimondMail.setRefresh(addr, 'INBOX', 0); |
| 370 | window.DaimondMail.setRefresh(addr, 'Sent', 0); |
| 371 | }, [BOX_A]); |
| 372 | await sleep(500); |
| 373 | n = since(); |
| 374 | await sleep(3000); |
| 375 | check('and "manual only" polls nothing', syncs.length === n, |
| 376 | `${syncs.length - n} request(s) in 3s`); |
| 377 | |
| 378 | // ── 4. Counts are the server's, and a stale one says so ────── |
| 379 | const counts = await page.evaluate((addr) => window.DaimondMail.counts(addr), BOX_A); |
| 380 | check('a folder row carries what the server handed over', |
| 381 | counts.INBOX && counts.INBOX.count === SERVER[BOX_A].INBOX |
| 382 | && counts.Sent && counts.Sent.count === SERVER[BOX_A].Sent, |
| 383 | `INBOX ${counts.INBOX && counts.INBOX.count} (server ${SERVER[BOX_A].INBOX}), ` |
| 384 | + `Sent ${counts.Sent && counts.Sent.count} (server ${SERVER[BOX_A].Sent})`); |
| 385 | check('and the count says when it is from', |
| 386 | !!(counts.INBOX && /as at/i.test(counts.INBOX.says.title)), |
| 387 | counts.INBOX && counts.INBOX.says.title); |
| 388 | check('a folder never fetched shows no number rather than a nought', |
| 389 | !!(counts.Archive && counts.Archive.says.text === '—' |
| 390 | && counts.Archive.lastSync === 0), |
| 391 | counts.Archive && JSON.stringify(counts.Archive.says)); |
| 392 | check('a fresh count is not marked stale', |
| 393 | !!(counts.INBOX && counts.INBOX.says.stale === false), |
| 394 | counts.INBOX && String(counts.INBOX.says.stale)); |
| 395 | |
| 396 | // What the reader actually sees, in the DOM, not in the API behind it. |
| 397 | const rowsFresh = await page.$$eval('#mail-folders .mail-folder', els => els.map(e => ({ |
| 398 | name: e.getAttribute('data-folder'), |
| 399 | count: (e.querySelector('.mail-count') || {}).textContent || '', |
| 400 | stale: !!e.querySelector('.mail-count.stale'), |
| 401 | when: (e.querySelector('.mail-when') || {}).textContent || '', |
| 402 | title: (e.querySelector('.mail-count') || {}).title || '', |
| 403 | }))); |
| 404 | const rowOf = (nm) => rowsFresh.find(r => r.name === nm) || {}; |
| 405 | check('the panel draws the count on the folder row', |
| 406 | rowOf('INBOX').count === String(SERVER[BOX_A].INBOX), |
| 407 | JSON.stringify(rowsFresh)); |
| 408 | check('and a never-fetched folder\'s row says never rather than nought', |
| 409 | rowOf('Archive').count === '—' && /never/i.test(rowOf('Archive').when), |
| 410 | JSON.stringify(rowOf('Archive'))); |
| 411 | |
| 412 | // Wind the last sync back three days and reload: the number is the same and |
| 413 | // the row now says how old it is. |
| 414 | await page.evaluate((addr) => { |
| 415 | const j = JSON.parse(localStorage.getItem('daimond-mail') || '{}'); |
| 416 | const a = j.accounts.find(x => x.address === addr); |
| 417 | a.folders.INBOX.lastSync = Date.now() - 3 * 86400000; |
| 418 | a.folders.INBOX.lastTry = a.folders.INBOX.lastSync; |
| 419 | localStorage.setItem('daimond-mail', JSON.stringify(j)); |
| 420 | window.DaimondMail.reload(); |
| 421 | }, BOX_A); |
| 422 | await page.waitForTimeout(600); |
| 423 | const rowsStale = await page.$$eval('#mail-folders .mail-folder', els => els.map(e => ({ |
| 424 | name: e.getAttribute('data-folder'), |
| 425 | count: (e.querySelector('.mail-count') || {}).textContent || '', |
| 426 | stale: !!e.querySelector('.mail-count.stale'), |
| 427 | when: (e.querySelector('.mail-when') || {}).textContent || '', |
| 428 | }))); |
| 429 | const stale = rowsStale.find(r => r.name === 'INBOX') || {}; |
| 430 | check('a stale count keeps its number and says how old it is', |
| 431 | stale.count === String(SERVER[BOX_A].INBOX) && stale.stale === true |
| 432 | && /3d|day/i.test(stale.when), |
| 433 | JSON.stringify(stale)); |
| 434 | |
| 435 | // ── 5. The manual refresh still refreshes everything ───────── |
| 436 | // The one the user asked for by name. Both mailboxes, every folder each of |
| 437 | // them tracks — pressed as a person presses it, on the button in the panel. |
| 438 | syncs.length = 0; |
| 439 | lists.length = 0; |
| 440 | await page.click('.mail-refresh', { force: true }); |
| 441 | await page.waitForTimeout(6000); |
| 442 | const addrs = [...new Set(syncs.map(x => x.address))].sort(); |
| 443 | const pairs = [...new Set(syncs.map(x => x.address + '/' + x.mailbox))].sort(); |
| 444 | check('the manual refresh reaches every mailbox', |
| 445 | addrs.length === 2 && addrs[0] === BOX_A && addrs[1] === BOX_B, |
| 446 | addrs.join(', ') || 'none'); |
| 447 | check('and it re-lists the folders of every mailbox', |
| 448 | [...new Set(lists)].length === 2, JSON.stringify([...new Set(lists)])); |
| 449 | check('and it refreshes every folder each of them tracks', |
| 450 | pairs.length >= 4, pairs.join(' | ')); |
| 451 | |
| 452 | // ── 6. The list still shows the folder that is OPEN ────────────── |
| 453 | // Reported from the live app: "I manually refresh, they showed up for a |
| 454 | // moment, but then they disappear, then reappear and so on." A refresh walks |
| 455 | // every folder of every mailbox — which is check 5, and correct — and each |
| 456 | // sync ended by adopting ITS digest as the panel's list, so the open INBOX |
| 457 | // was replaced by Sent, then by an empty Archive, then by the other |
| 458 | // mailbox's folders. Whatever synced last was left on screen. |
| 459 | // |
| 460 | // Asserted by SUBJECT, not by count: the fixture names every message after |
| 461 | // the folder it came from, so a list showing three of anything is not |
| 462 | // enough — they must be the INBOX's three. `SERVER` is the oracle. |
| 463 | const subjects = await page.$$eval('.mail-msg .mail-subj', els => els.map(e => e.textContent.trim())); |
| 464 | const openFolder = await page.evaluate(() => window.DaimondMail.folder()).catch(() => ''); |
| 465 | check('THE OPEN FOLDER IS STILL THE ONE ON SCREEN AFTER A REFRESH WALKS THE OTHERS', |
| 466 | subjects.length === SERVER[BOX_A].INBOX |
| 467 | && subjects.every(x => /^INBOX message /.test(x)), |
| 468 | `${openFolder || '?'} → ${JSON.stringify(subjects)}`); |
| 469 | // The counts are the other half of the same rule: a folder nobody is looking |
| 470 | // at still has to report what it holds, or fixing the flicker would cost the |
| 471 | // rows their numbers. |
| 472 | const countsAfter = await page.evaluate(a => window.DaimondMail.counts(a), BOX_A); |
| 473 | check('and an unselected folder still reports its own count', |
| 474 | (countsAfter.Sent || {}).count === SERVER[BOX_A].Sent, |
| 475 | JSON.stringify(countsAfter)); |
| 476 | |
| 477 | // And a held folder is skipped rather than being quietly dropped. |
| 478 | await pauseLeaf(page, SENT_A); |
| 479 | syncs.length = 0; |
| 480 | await page.click('.mail-refresh', { force: true }); |
| 481 | await page.waitForTimeout(6000); |
| 482 | check('a held folder is skipped by the manual refresh, and the rest still go', |
| 483 | !syncs.some(x => x.address === BOX_A && x.mailbox === 'Sent') && syncs.length >= 3, |
| 484 | syncs.map(x => x.address + '/' + x.mailbox).join(' | ')); |
| 485 | const said = await page.$eval('#mail-state', e => e.textContent.trim()).catch(() => ''); |
| 486 | check('and the panel says how many were held rather than only how many went', |
| 487 | /held/i.test(said), said.slice(0, 160)); |
| 488 | await playLeaf(page, SENT_A); |
| 489 | |
| 490 | // ── The gear dialog's body ─────────────────────────────────── |
| 491 | const tiles = await page.evaluate((addr) => { |
| 492 | const body = window.DaimondMail.settingsBody(addr); |
| 493 | document.body.appendChild(body); // so getComputedStyle etc. work |
| 494 | const out = [...body.querySelectorAll('.mail-tile')].map(t => ({ |
| 495 | folder: t.getAttribute('data-folder') || 'self', |
| 496 | name: (t.querySelector('.mail-tile-name') || {}).textContent || '', |
| 497 | every: (t.querySelector('.mail-every') || {}).value, |
| 498 | slots: t.querySelectorAll('.pptw-slot').length, |
| 499 | })); |
| 500 | body.remove(); |
| 501 | return out; |
| 502 | }, BOX_A); |
| 503 | check('the gear dialog carries a tile for the mailbox and one per folder', |
| 504 | tiles.length === 4 && tiles[0].folder === 'self', |
| 505 | JSON.stringify(tiles)); |
| 506 | check('and every tile has a pause slot for its own node', |
| 507 | tiles.every(t => t.slots === 1), JSON.stringify(tiles.map(t => t.slots))); |
| 508 | check('and every folder tile carries a frequency', |
| 509 | tiles.slice(1).every(t => typeof t.every === 'string'), |
| 510 | JSON.stringify(tiles.map(t => t.every))); |
| 511 | |
| 512 | // Setting one through the dialog is the same act as setting one through the |
| 513 | // API, and it survives a reload. |
| 514 | await page.evaluate((addr) => { |
| 515 | const body = window.DaimondMail.settingsBody(addr); |
| 516 | document.body.appendChild(body); |
| 517 | const sel = body.querySelector('.mail-tile[data-folder="Archive"] .mail-every'); |
| 518 | sel.value = '900'; |
| 519 | sel.dispatchEvent(new Event('change')); |
| 520 | body.remove(); |
| 521 | }, BOX_A); |
| 522 | await page.evaluate(() => window.DaimondMail.reload()); |
| 523 | await page.waitForTimeout(400); |
| 524 | const kept = await page.evaluate((addr) => |
| 525 | window.DaimondMail.refreshOf(addr, 'Archive'), BOX_A); |
| 526 | check('a frequency set in the dialog is kept', kept === 900, String(kept)); |
| 527 | |
| 528 | // ── The parcel ─────────────────────────────────────────────── |
| 529 | // The frequency travels, so it has to be a fixed point: sorted keys and no |
| 530 | // stamp that moves on its own. Two collects with nothing in between must be |
| 531 | // byte-identical, and applying a parcel must not change what this device |
| 532 | // would then send. Two devices have pushed at each other over less. |
| 533 | const parcel = await page.evaluate(async () => { |
| 534 | const a = JSON.stringify(window.DaimondMail.exportSync()); |
| 535 | const b = JSON.stringify(window.DaimondMail.exportSync()); |
| 536 | await window.DaimondMail.applySync(JSON.parse(a)); |
| 537 | const c = JSON.stringify(window.DaimondMail.exportSync()); |
| 538 | return { a, b, c }; |
| 539 | }); |
| 540 | check('the mail parcel is stable across two collects', parcel.a === parcel.b); |
| 541 | check('and applying it does not change what would be sent next', |
| 542 | parcel.a === parcel.c, parcel.a.slice(0, 200) + '\n vs \n' + parcel.c.slice(0, 200)); |
| 543 | check('and the frequency is in it, with sorted keys', |
| 544 | /"refresh":\{"Archive":900\}/.test(parcel.a), |
| 545 | (parcel.a.match(/"refresh":\{[^}]*\}/g) || []).join(' | ')); |
| 546 | |
| 547 | // A refusal is a decision, not a fault: an app that logs an error every time |
| 548 | // a held folder is asked to poll has taught its user to ignore the console. |
| 549 | const errs = errors(s).filter(e => |
| 550 | !/Failed to load resource/.test(e) && !/Paused:/.test(e)); |
| 551 | check('nothing was refused by way of an unhandled error', errs.length === 0, |
| 552 | errs.slice(0, 3).join(' | ')); |
| 553 | |
| 554 | await shot(s, 'mailrefresh' + (BREAK ? '-' + BREAK : '')); |
| 555 | } finally { |
| 556 | await s.close(); |
| 557 | } |
| 558 | |
| 559 | console.log(`\nsyncs seen: ${syncs.length} folder lists: ${lists.length}`); |
| 560 | if (BREAK) { |
| 561 | console.log(`\nbreak '${BREAK}': ${bad.length} check(s) failed` |
| 562 | + (bad.length ? ' — ' + bad.join('; ') : ' — NOTHING FAILED, so the checks above prove nothing')); |
| 563 | process.exit(bad.length ? 0 : 1); // a break MUST fail something |
| 564 | } |
| 565 | console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`); |
| 566 | process.exit(bad.length === 0 ? 0 : 1); |