Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_mailfolders.mjs

24.5 KiB, 1 run

created by r2519314175:519, 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_mailfolders.mjs — a mailbox is not an inbox.
2//
3// Until now the client asked the server for INBOX and nothing else, so every
4// other folder a person has — Sent, Archive, the one they filed a project in —
5// was unreachable from Daimond. The gateway grew `POST /api/mail/folders`
6// (a LIST, free, same gates as a sync); this drives the client side of it.
7//
8// Two halves, because one fixture cannot pose both questions:
9//
10// A. THE REAL WIRE. fe2o3's imap_test_server now serves seven folders, five
11// of them carrying an RFC 6154 role, one of them an ordinary nested name
12// with a space in it. Everything here goes through the real route, the
13// real IMAP conversation and the real per-folder Maildir on disk.
14//
15// B. THE GMAIL SHAPE, which the fixture cannot produce: a `\Noselect`
16// container, a folder whose NAME is in another language but whose ROLE is
17// not, and All Mail. The route is stubbed for this half only; everything
18// below the fetch — the shaping, the ordering, the labelling, the picker
19// — is the real code.
20//
21// Needs: the gateway with `dev_insecure` on the mail routes (dev/devgw.sh), the
22// IMAP fixture on 127.0.0.1:1143, and the `email` entitlement plus Pro on the
23// harness's fixed account. run_all's phase 2 provisions all of that.
24import { open, shot, scratch } from './harness.mjs';
25import { IMAP_PORT, SMTP_PORT } from './ports.mjs';
26
27const ok = [], bad = [];
28const check = (name, pass, detail) => {
29 (pass ? ok : bad).push(name + (detail ? ' — ' + detail : ''));
30 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
31};
32
33const s = await open({ name: 'compose', connect: false, profile: scratch('compose-profile') });
34const p = s.page;
35
36// Start from a mailbox with no folders on disk, so what appears was fetched by
37// this run rather than left by the last one.
38//
39// THROUGH `DaimondCloud.opfsRoot()`, NOT `navigator.storage.getDirectory()`. The
40// two are different directories for every account but the primary: the app's
41// files live under `d~<id>/`, and this profile's account is not the primary. So
42// this wipe hit an empty top-level `mail/` and left the real one untouched — for
43// every run this file has ever had.
44//
45// What that cost: `Sent` kept one message from the first run that ever synced it,
46// `selectFolder` correctly declined to fetch a folder that already had mail on
47// screen, and the watermark check below therefore read 0/0 and was blamed on the
48// product. It also meant EVERY other check here was reading whatever the last run
49// left, so a green line proved nothing about this one. Nine generations of the
50// same message were on disk by the time it was measured.
51const wiped = await p.evaluate(async () => {
52 // BOTH copies. Deleting the local one alone is not a wipe: the account holds this
53 // mailbox on the server as well, and the next pull puts the old run's copy
54 // straight back — under its ORIGINAL name, so it reads as a message that arrived
55 // rather than one that was restored. A small message rides inline in the sync
56 // parcel and a large attachment is offloaded to cloud storage, so both have to go.
57 const wipeOnce = async () => {
58 let forgotten = 0;
59 try {
60 Object.keys(DaimondCloud.index() || {}).forEach((path) => {
61 if (path.indexOf('mail/alice@test.local/') === 0) {
62 DaimondCloud.forget(path); forgotten++;
63 }
64 });
65 } catch (e) { /* no cloud on this profile, which is also a clean slate */ }
66 const root = await DaimondCloud.opfsRoot();
67 let mail;
68 try { mail = await root.getDirectoryHandle('mail'); }
69 catch (e) { return { how: 'no mail dir', forgotten }; }
70 try { await mail.removeEntry('alice@test.local', { recursive: true }); }
71 catch (e) { return { how: 'no mailbox yet', forgotten }; }
72 // Prove it: a wipe that silently did nothing is what put this file wrong.
73 for await (const [n] of mail.entries()) if (n === 'alice@test.local') return { how: 'STILL THERE', forgotten };
74 return { how: 'wiped', forgotten };
75 };
76 const held = async () => {
77 try {
78 const root = await DaimondCloud.opfsRoot();
79 await (await root.getDirectoryHandle('mail')).getDirectoryHandle('alice@test.local');
80 return true;
81 } catch (e) { return false; }
82 };
83 // AND THE COPY ON THE SERVER, which is the half that was missing. Every Maildir
84 // message is a workspace file, and workspace files ride in the sync parcel; the
85 // gateway holds the last one this account pushed. So the pull that follows this
86 // wipe finds files the device no longer has and adopts them — `applyFiles` in
87 // daimond.js, "only remote: adopt" — under their ORIGINAL names, carrying the
88 // PREVIOUS run's uidValidity. That is right of the product: a file missing here
89 // and present there is a file this device has not seen yet, and there is no
90 // tombstone to say otherwise. It is only wrong of a fixture that deleted it
91 // behind the app's back.
92 //
93 // A push is what makes the deletion true on both sides: the census it carries is
94 // complete and no longer names the mailbox, so the parcel the gateway keeps stops
95 // holding it and no later pull can put it back. Looped, because a pull already in
96 // flight can restore between the wipe and the push, and because `push` declines
97 // while another is running rather than queueing.
98 //
99 // WITHOUT THIS the run reads the last run's mail: `selectFolder` correctly
100 // declines to fetch a folder that already has mail on screen, so Sent is never
101 // synced, its watermarks stay at zero, and the check below is read as a defect in
102 // the product. Measured on 2026-08-24: one run against a fresh profile passed
103 // 28/28, a second against the same profile failed on exactly that check.
104 // AND THE ENGINE IS QUIET BEFORE AND AFTER, WHICH IS WHAT CLOSES THE RACE.
105 //
106 // Sleeping 1200ms and asking whether the mailbox came back only NARROWS the window: a
107 // pull already in flight when the loop exits lands afterwards, adopts the previous
108 // run's mail back -- correctly, since a file the gateway holds and this device does
109 // not is one this device has not seen -- and every check below then reads it. Measured
110 // on 2026-08-24 over eight consecutive cold runs: six passed, and runs 4 and 5 failed
111 // with two and three uidValidity generations on disk. BOTH failures reported
112 // `wiped (0 forgotten)` with no `came back, wiping again`, so the loop was satisfied
113 // when it exited and the restore arrived after it. A wait cannot see a round it does
114 // not know about, so `DaimondSync.state().quiet` was added (www/js/sync.js) to say
115 // whether one is running OR armed, and this waits for it rather than for a clock.
116 const quiet = async (ms) => {
117 const until = Date.now() + ms;
118 for (;;) {
119 let st;
120 try { st = DaimondSync.state(); } catch (e) { return 'no sync here'; }
121 if (st.quiet) return '';
122 if (Date.now() > until) return 'still ' + (st.busyWith || 'busy');
123 await new Promise((r) => setTimeout(r, 100));
124 }
125 };
126 let last = { how: 'no mail dir', forgotten: 0 }, note = [];
127 // FIRST: nothing may be in flight when the wipe begins, or the wipe races the round
128 // that is already reading the mailbox it is about to delete.
129 const before = await quiet(20000);
130 if (before) note.push('did not go quiet before the wipe (' + before + ')');
131 // THE LOOP MAKES THE RESTORE HAPPEN RATHER THAN WAITING TO SEE IF IT WILL.
132 //
133 // Waiting for quiet is not enough and was measured not to be: 6 of 8 cold runs on
134 // 2026-08-24, with the two failures reporting `wiped (0 forgotten)` and no complaint
135 // from `quiet` at all. A pull can be BEGUN after this device went quiet -- the wake
136 // channel hears a version it has not got and starts one -- so "nothing is running"
137 // is not "nothing is coming", and no amount of waiting makes it so.
138 //
139 // So each turn of the loop deletes, pushes, and then PULLS ON PURPOSE. If the gateway
140 // still holds the mailbox, that pull adopts it back HERE, inside the loop, where the
141 // next turn deletes it again; if it does not, the mailbox stays gone and the loop ends
142 // on a device that has just pulled and still has no mail. That is a fixed point rather
143 // than a quiet moment, and a later pull can restore nothing because there is nothing
144 // at the far end to restore.
145 for (let i = 0; i < 6; i++) {
146 last = await wipeOnce();
147 if (last.how === 'STILL THERE') break;
148 if (!window.DaimondSync || !DaimondSync.push) { note.push('no sync here'); break; }
149 try { await DaimondSync.push(); } catch (e) { note.push('push threw'); }
150 const after = await quiet(20000);
151 if (after) note.push('did not go quiet after the push (' + after + ')');
152 // WHAT THE PUSH ACTUALLY SENT, recorded because it decides whether the far end may
153 // drop the mailbox at all: only a COMPLETE census entitles the receiver to delete by
154 // absence (`collectSync`, `applyFiles`). An incomplete one leaves the gateway holding
155 // what this device just deleted, and no number of pushes will take it away.
156 try {
157 const par = await DaimondSync.parcel();
158 if (par && par.filesComplete !== true) note.push('census INCOMPLETE at push ' + (i + 1));
159 } catch (e) { /* nothing to say about a parcel that could not be built */ }
160 // The restore, forced.
161 try { await DaimondSync.pull(); } catch (e) { note.push('pull threw'); }
162 const pulled = await quiet(20000);
163 if (pulled) note.push('did not go quiet after the pull (' + pulled + ')');
164 if (!await held()) break;
165 note.push('came back, wiping again');
166 }
167 const settled = await quiet(20000);
168 if (settled) note.push('did not settle (' + settled + ')');
169 const back = await held();
170 return last.how + ' (' + last.forgotten + ' forgotten'
171 + (note.length ? '; ' + note.join('; ') : '') + ')'
172 + (back ? ' — AND THE SERVER PUT IT BACK' : '');
173});
174check('the mailbox on disk is cleared before the run, in the root the app uses',
175 /^(wiped|no mail dir|no mailbox yet)/.test(wiped) && !/PUT IT BACK/.test(wiped), wiped);
176
177// Seed the account the way the add-dialog would, but pointed at the fixture:
178// the dialog infers security from the port and offers no plaintext, so a
179// loopback test server can only be reached by seeding the record.
180await p.evaluate(async (PORTS) => {
181 const pass = await window.DaimondIdentity.wrap('test-app-password');
182 localStorage.setItem('daimond-mail', JSON.stringify({
183 accounts: [{
184 address: 'alice@test.local',
185 host: '127.0.0.1', port: PORTS.imap, security: 'plain',
186 smtpHost: '127.0.0.1', smtpPort: PORTS.smtp, smtpSecurity: 'plain',
187 user: 'alice@test.local', pass,
188 folder: 'INBOX', folders: {}, lastSync: 0,
189 }],
190 sel: 'alice@test.local',
191 }));
192 window.DaimondMail.reload();
193 window.DaimondPanels.show('mail');
194 window.DaimondMail.onOpen();
195}, { imap: IMAP_PORT, smtp: SMTP_PORT });
196
197// ── A. The real wire ─────────────────────────────────────────────────
198
199await p.waitForFunction(
200 () => window.DaimondMail.folders('alice@test.local').length > 1,
201 { timeout: 25000 },
202).catch(() => {});
203const got = await p.evaluate(() => window.DaimondMail.folders('alice@test.local'));
204check('the server is asked what folders it has', got.length >= 7,
205 got.length + ': ' + got.map(f => f.name).join(', '));
206
207const byName = Object.fromEntries(got.map(f => [f.name, f]));
208check('the inbox is there', !!byName.INBOX);
209check('a role comes back with it', byName.Sent && byName.Sent.role === 'sent',
210 byName.Sent ? byName.Sent.role : 'no Sent');
211check('every special-use folder is named by its role',
212 ['Sent', 'Drafts', 'Archive', 'Junk', 'Trash']
213 .every(n => byName[n] && byName[n].role),
214 ['Sent', 'Drafts', 'Archive', 'Junk', 'Trash']
215 .map(n => n + '=' + ((byName[n] || {}).role || '-')).join(' '));
216check('an ordinary folder comes back by its own name',
217 !!byName['Projects/Bourke Street'],
218 Object.keys(byName).join(' | '));
219
220// The picker itself: what a person sees and can click.
221//
222// `label` reads `.mail-addr`, the span holding the NAME, and not the row -- see
223// the note at the second reader below. There are two of these blocks in this
224// file and they must agree; fixing only the other one moved the failure from
225// "All Mail is offered" to "an ordinary folder keeps the server's own spelling"
226// and taught nothing.
227const rows = await p.$$eval('#mail-folders .mail-folder', els => els.map(e => ({
228 name: e.getAttribute('data-folder'),
229 role: e.getAttribute('data-role') || '',
230 label: (e.querySelector('.mail-addr') || e).textContent.trim(),
231 on: e.classList.contains('on'),
232 off: e.getAttribute('aria-disabled') === 'true',
233})));
234check('the picker offers every folder', rows.length >= 7,
235 rows.length + ' rows: ' + rows.map(r => r.label).join(', '));
236check('a role-labelled folder reads in the interface language, not the server\'s',
237 rows.some(r => r.name === 'Sent' && r.label === 'Sent')
238 && rows.some(r => r.name === 'Trash' && r.label === 'Bin')
239 && rows.some(r => r.name === 'Junk' && r.label === 'Spam'),
240 rows.map(r => r.name + '→' + r.label).join(', '));
241check('an ordinary folder keeps the server\'s own spelling',
242 rows.some(r => r.name === 'Projects/Bourke Street'
243 && r.label === 'Projects/Bourke Street'));
244check('the inbox is the one selected', rows.filter(r => r.on).length === 1
245 && rows.find(r => r.on).name === 'INBOX',
246 rows.filter(r => r.on).map(r => r.name).join(', ') || 'none');
247check('the inbox is listed first', rows[0] && rows[0].name === 'INBOX',
248 rows[0] ? rows[0].name : 'no rows');
249
250// Sync the inbox, so there is something to tell one folder from another by.
251await p.evaluate(() => window.DaimondMail.sync());
252await p.waitForSelector('.mail-msg', { timeout: 25000 }).catch(() => {});
253const inboxSubjects = await p.$$eval('.mail-msg .mail-subj', els => els.map(e => e.textContent));
254check('the inbox syncs', inboxSubjects.length >= 3, inboxSubjects.join(' | '));
255
256// ── Switching folder ─────────────────────────────────────────────────
257
258const clicked = await p.evaluate(() => {
259 const row = [...document.querySelectorAll('#mail-folders .mail-folder')]
260 .find(e => e.getAttribute('data-folder') === 'Sent');
261 if (!row) return false;
262 row.click();
263 return true;
264});
265check('Sent can be clicked in the picker', clicked,
266 clicked ? '' : 'no row for it — the picker is not drawn');
267await p.waitForFunction(
268 () => window.DaimondMail.folder() === 'Sent'
269 && [...document.querySelectorAll('.mail-msg .mail-subj')]
270 .some(e => /Thursday works/.test(e.textContent)),
271 { timeout: 25000 },
272).catch(() => {});
273const sentSubjects = await p.$$eval('.mail-msg .mail-subj', els => els.map(e => e.textContent));
274check('choosing a folder fetches THAT folder',
275 sentSubjects.some(t => /Thursday works/.test(t))
276 && !sentSubjects.some(t => /Your statement is ready/.test(t)),
277 sentSubjects.join(' | ') || 'empty');
278check('the picker follows the choice',
279 await p.evaluate(() => {
280 const on = document.querySelector('#mail-folders .mail-folder.on');
281 return !!on && on.getAttribute('data-folder') === 'Sent';
282 }));
283
284// Two folders, two places on disk, two watermarks. A single shared `lastUid`
285// would make the second folder's first sync ask for mail newer than a UID that
286// belongs to a different mailbox entirely.
287const disk = await p.evaluate(async () => {
288 const walk = async (dir, path) => {
289 const out = [];
290 for await (const [name, h] of dir.entries()) {
291 if (h.kind === 'directory') out.push(...await walk(h, path + name + '/'));
292 else out.push(path + name);
293 }
294 return out;
295 };
296 const root = await navigator.storage.getDirectory();
297 const mail = await root.getDirectoryHandle('mail');
298 return await walk(mail, 'mail/');
299});
300check('each folder has its own Maildir',
301 disk.some(f => /^mail\/alice@test\.local\/INBOX\/cur\//.test(f))
302 && disk.some(f => /^mail\/alice@test\.local\/Sent\/cur\//.test(f)),
303 disk.filter(f => /cur\//.test(f)).slice(0, 6).join(', '));
304check('each folder has its own digest for the agents',
305 disk.includes('mail/alice@test.local/INBOX/index.md')
306 && disk.includes('mail/alice@test.local/Sent/index.md'),
307 disk.filter(f => /index\.md$/.test(f)).join(', '));
308
309const marks = await p.evaluate(() => {
310 const j = JSON.parse(localStorage.getItem('daimond-mail'));
311 const a = j.accounts[0];
312 return Object.fromEntries(Object.entries(a.folders)
313 .map(([k, v]) => [k, { dir: v.dir, lastUid: v.lastUid, uidValidity: v.uidValidity }]));
314});
315// WHOSE MAIL IS THIS? A Maildir name is `<uid>.<uidValidity>.daimond:2,`, and the
316// fixture mints a fresh `uidValidity` every time it starts — so a file stamped with
317// a generation this run never saw is a file from a previous run.
318//
319// That is not hypothetical. The wipe above empties the directory, and older
320// generations reappeared during the run anyway: the pull that follows adopts every
321// mail file the gateway's parcel still held, which is why the wipe above now clears
322// the server's copy too. Every check above was otherwise reading whatever the last
323// run left, and a green line proved nothing about this one.
324//
325// Checked rather than tolerated, because the failure it caused was invisible: with
326// one stale message already in `Sent`, `selectFolder` correctly declines to fetch a
327// folder that has mail on screen — so Sent was never synced, its watermarks stayed
328// at zero, and the check below was read as a defect in the product for two
329// sessions running.
330// `%3A`, NOT `:`. A colon is not a legal OPFS name, so `src/fsname.rs` escapes it
331// and every message on disk is `<uid>.<uidValidity>.daimond%3A2,`. This read
332// `\.daimond:2,` and therefore matched NOTHING, in every run this file has ever
333// had: it reported "0 uidValidity generations on disk" and passed while the check
334// three lines above was listing two of them. The one guard against a run reading
335// the last run's mail was inert for the whole of its life. Both spellings are
336// accepted because the tolerance in `diskNameIn` means either can be met.
337const gens = new Set(disk.filter(f => /\.daimond(?::|%3A)2,/.test(f))
338 .map(f => (f.split('/').pop() || '').split('.')[1]).filter(Boolean));
339check('the mail on disk is from THIS run, not restored from an earlier one',
340 gens.size <= 1,
341 gens.size + ' uidValidity generations on disk: ' + [...gens].join(', ')
342 + (gens.size > 1 ? ' — cloud residency is putting earlier runs back; every check '
343 + 'in this file above is reading them' : ''));
344
345check('the UID watermarks are kept per folder',
346 marks.INBOX && marks.Sent && marks.INBOX.lastUid > 0 && marks.Sent.lastUid > 0,
347 JSON.stringify(marks));
348
349// A name with a slash and a space in it cannot be a directory. It must still
350// land somewhere, and somewhere that another name cannot collide with.
351await p.evaluate(() => window.DaimondMail.selectFolder('Projects/Bourke Street'));
352// `selectFolder` returns as soon as the choice is made; the mail behind it is a
353// round trip away, so wait for the mail, not for the choice.
354await p.waitForFunction(
355 () => window.DaimondMail.folder() === 'Projects/Bourke Street'
356 && [...document.querySelectorAll('.mail-msg .mail-subj')]
357 .some(e => /The new place/.test(e.textContent)),
358 { timeout: 25000 },
359).catch(() => {});
360const projDir = await p.evaluate(() => window.DaimondMail.folderDir(null, 'Projects/Bourke Street'));
361check('an awkward folder name is flattened onto a path safely',
362 /^mail\/alice@test\.local\/[A-Za-z0-9._-]+$/.test(projDir) && !/[/ ]/.test(projDir.split('/')[2]),
363 projDir);
364const projSubjects = await p.$$eval('.mail-msg .mail-subj', els => els.map(e => e.textContent));
365check('and its mail arrives under it',
366 projSubjects.some(t => /The new place/.test(t)), projSubjects.join(' | ') || 'empty');
367
368await p.evaluate(() => window.DaimondMail.selectFolder('INBOX'));
369await p.waitForTimeout(1200);
370await shot(s, 'mailfolders-dark');
371await p.evaluate(() => document.documentElement.setAttribute('data-theme', 'light'));
372await p.waitForTimeout(400);
373await shot(s, 'mailfolders-light');
374await p.evaluate(() => document.documentElement.setAttribute('data-theme', 'lollypop'));
375await p.waitForTimeout(400);
376await shot(s, 'mailfolders-lollypop');
377await p.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
378
379// ── B. The Gmail shape ───────────────────────────────────────────────
380//
381// Gmail's folder list is the awkward one: `[Gmail]` is a container that holds
382// no mail, the names underneath it are in the account's own language, and All
383// Mail is a copy of everything. Only the fetch is stubbed.
384await p.evaluate(() => {
385 const real = window.fetch;
386 window.fetch = function (url, opts) {
387 if (String(url).indexOf('/api/mail/folders') === 0) {
388 return Promise.resolve(new Response(JSON.stringify({
389 ok: true,
390 folders: [
391 { name: 'INBOX', selectable: true, attrs: ['\\HasNoChildren'], delimiter: '/' },
392 { name: '[Gmail]', selectable: false, attrs: ['\\Noselect', '\\HasChildren'], delimiter: '/' },
393 { name: '[Gmail]/Gesendet', selectable: true, attrs: ['\\Sent'], delimiter: '/', role: 'sent' },
394 { name: '[Gmail]/Entwürfe', selectable: true, attrs: ['\\Drafts'], delimiter: '/', role: 'drafts' },
395 { name: '[Gmail]/Papierkorb', selectable: true, attrs: ['\\Trash'], delimiter: '/', role: 'trash' },
396 { name: '[Gmail]/Alle Nachrichten', selectable: true, attrs: ['\\All'], delimiter: '/', role: 'all' },
397 { name: 'Rechnungen', selectable: true, attrs: ['\\HasNoChildren'], delimiter: '/' },
398 ],
399 }), { status: 200, headers: { 'content-type': 'application/json' } }));
400 }
401 return real.apply(this, arguments);
402 };
403});
404await p.evaluate(() => window.DaimondMail.loadFolders('alice@test.local', true));
405await p.waitForFunction(
406 () => window.DaimondMail.folders('alice@test.local').some(f => f.role === 'all'),
407 { timeout: 15000 },
408).catch(() => {});
409
410// The NAME is `.mail-addr`, not the row. Phase G put a count and an age beside
411// it, so the row's own textContent reads `All mailnever—` and every label check
412// here failed against an app that was drawing the name correctly. Reading the
413// element that holds the thing being asserted is the fix; the count phrase is
414// `verify_mailrefresh`'s to prove, and it does so against broken code.
415const g = await p.$$eval('#mail-folders .mail-folder', els => els.map(e => ({
416 name: e.getAttribute('data-folder'),
417 role: e.getAttribute('data-role') || '',
418 label: (e.querySelector('.mail-addr') || e).textContent.trim(),
419 on: e.classList.contains('on'),
420 off: e.getAttribute('aria-disabled') === 'true',
421})));
422const find = n => g.find(r => r.name === n) || {};
423check('a folder named in another language is known by its role',
424 find('[Gmail]/Gesendet').label === 'Sent'
425 && find('[Gmail]/Papierkorb').label === 'Bin',
426 g.map(r => r.name + '→' + r.label).join(', '));
427check('a container is shown but cannot be chosen', find('[Gmail]').off === true,
428 JSON.stringify(find('[Gmail]')));
429check('every other folder can be chosen',
430 g.filter(r => r.name !== '[Gmail]').every(r => r.off !== true));
431check('All Mail is offered', !!find('[Gmail]/Alle Nachrichten').name
432 && find('[Gmail]/Alle Nachrichten').label === 'All mail',
433 find('[Gmail]/Alle Nachrichten').label);
434check('but All Mail is NOT what the panel opens on',
435 g.filter(r => r.on).length === 1 && g.find(r => r.on).name === 'INBOX',
436 g.filter(r => r.on).map(r => r.name).join(', ') || 'none');
437check('All Mail sorts last, where a duplicate of everything belongs',
438 g[g.length - 1] && g[g.length - 1].name === '[Gmail]/Alle Nachrichten',
439 g.map(r => r.name).join(' > '));
440// The picture of the Gmail shape, taken BEFORE the next check replaces the
441// folder list with one holding nothing but the inbox.
442await shot(s, 'mailfolders-gmail');
443
444check('a folder the server no longer offers stops being the selected one',
445 await p.evaluate(async () => {
446 await window.DaimondMail.selectFolder('Rechnungen');
447 await new Promise(r => setTimeout(r, 800));
448 const before = window.DaimondMail.folder();
449 // The next answer drops it.
450 const real = window.fetch;
451 window.fetch = function (url) {
452 if (String(url).indexOf('/api/mail/folders') === 0) {
453 return Promise.resolve(new Response(JSON.stringify({
454 ok: true, folders: [{ name: 'INBOX', selectable: true, attrs: [] }],
455 }), { status: 200, headers: { 'content-type': 'application/json' } }));
456 }
457 return real.apply(this, arguments);
458 };
459 await window.DaimondMail.loadFolders('alice@test.local', true);
460 await new Promise(r => setTimeout(r, 800));
461 return before === 'Rechnungen' && window.DaimondMail.folder() === 'INBOX';
462 }));
463
464console.log('\nconsole errors:', s.errs.filter(e => !/favicon|404/.test(e)).slice(0, 5));
465console.log(`\n${ok.length} passed, ${bad.length} failed`);
466if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
467await s.close();
468process.exit(bad.length ? 1 : 0);