Oregami
Repositories/oxedyne/daimond

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.
58import fs from 'node:fs';
59import path from 'node:path';
60import { fileURLToPath } from 'node:url';
61import { open, shot, scratch, errors } from './harness.mjs';
62import { IMAP_PORT, SMTP_PORT } from './ports.mjs';
63
64const HERE = path.dirname(fileURLToPath(import.meta.url));
65const WWW = path.join(HERE, '..', 'www');
66
67const BREAK = (() => {
68 const i = process.argv.indexOf('--break');
69 return i > 0 ? String(process.argv[i + 1] || '') : '';
70})();
71
72const PROFILE = scratch('pw', 'mailrefresh' + (BREAK ? '-' + BREAK : ''));
73fs.rmSync(PROFILE, { recursive: true, force: true });
74
75const ok = [], bad = [];
76const 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.
85const 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
132if (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.
139function 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.
156function 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
168const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-headers': '*' };
169const json = (body, status = 200) => ({
170 status, contentType: 'application/json', headers: CORS, body: JSON.stringify(body),
171});
172
173const BOX_A = 'alice@test.local';
174const 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.
178const syncs = [];
179const lists = [];
180const since = () => syncs.length;
181const 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.
185const 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.
199const SERVER = {
200 [BOX_A]: { INBOX: 3, Sent: 1, Archive: 0 },
201 [BOX_B]: { INBOX: 2, Sent: 0, Archive: 0 },
202};
203
204async 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
254const pauseLeaf = (page, id) => page.evaluate(i => window.DaimondPause.set(i, false), id);
255const playLeaf = (page, id) => page.evaluate(i => window.DaimondPause.set(i, true), id);
256const sleep = (ms) => new Promise(r => setTimeout(r, ms));
257
258const s = await open({ name: 'mailrefresh', profile: PROFILE, signIn: false, connect: false });
259const { page } = s;
260await 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.
264await page.goto(process.env.DAIMOND_APP || 'http://localhost:8777', { waitUntil: 'domcontentloaded' });
265const { signInAs } = await import('./harness.mjs');
266await signInAs(s, 'mailrefresh');
267await page.waitForTimeout(2000);
268
269try {
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
559console.log(`\nsyncs seen: ${syncs.length} folder lists: ${lists.length}`);
560if (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}
565console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`);
566process.exit(bad.length === 0 ? 0 : 1);