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. |
| 24 | import { open, shot, scratch } from './harness.mjs'; |
| 25 | import { IMAP_PORT, SMTP_PORT } from './ports.mjs'; |
| 26 | |
| 27 | const ok = [], bad = []; |
| 28 | const check = (name, pass, detail) => { |
| 29 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 30 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 31 | }; |
| 32 | |
| 33 | const s = await open({ name: 'compose', connect: false, profile: scratch('compose-profile') }); |
| 34 | const 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. |
| 51 | const 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 | }); |
| 174 | check('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. |
| 180 | await 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 | |
| 199 | await p.waitForFunction( |
| 200 | () => window.DaimondMail.folders('alice@test.local').length > 1, |
| 201 | { timeout: 25000 }, |
| 202 | ).catch(() => {}); |
| 203 | const got = await p.evaluate(() => window.DaimondMail.folders('alice@test.local')); |
| 204 | check('the server is asked what folders it has', got.length >= 7, |
| 205 | got.length + ': ' + got.map(f => f.name).join(', ')); |
| 206 | |
| 207 | const byName = Object.fromEntries(got.map(f => [f.name, f])); |
| 208 | check('the inbox is there', !!byName.INBOX); |
| 209 | check('a role comes back with it', byName.Sent && byName.Sent.role === 'sent', |
| 210 | byName.Sent ? byName.Sent.role : 'no Sent'); |
| 211 | check('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(' ')); |
| 216 | check('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. |
| 227 | const 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 | }))); |
| 234 | check('the picker offers every folder', rows.length >= 7, |
| 235 | rows.length + ' rows: ' + rows.map(r => r.label).join(', ')); |
| 236 | check('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(', ')); |
| 241 | check('an ordinary folder keeps the server\'s own spelling', |
| 242 | rows.some(r => r.name === 'Projects/Bourke Street' |
| 243 | && r.label === 'Projects/Bourke Street')); |
| 244 | check('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'); |
| 247 | check('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. |
| 251 | await p.evaluate(() => window.DaimondMail.sync()); |
| 252 | await p.waitForSelector('.mail-msg', { timeout: 25000 }).catch(() => {}); |
| 253 | const inboxSubjects = await p.$$eval('.mail-msg .mail-subj', els => els.map(e => e.textContent)); |
| 254 | check('the inbox syncs', inboxSubjects.length >= 3, inboxSubjects.join(' | ')); |
| 255 | |
| 256 | // ── Switching folder ───────────────────────────────────────────────── |
| 257 | |
| 258 | const 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 | }); |
| 265 | check('Sent can be clicked in the picker', clicked, |
| 266 | clicked ? '' : 'no row for it — the picker is not drawn'); |
| 267 | await 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(() => {}); |
| 273 | const sentSubjects = await p.$$eval('.mail-msg .mail-subj', els => els.map(e => e.textContent)); |
| 274 | check('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'); |
| 278 | check('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. |
| 287 | const 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 | }); |
| 300 | check('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(', ')); |
| 304 | check('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 | |
| 309 | const 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. |
| 337 | const gens = new Set(disk.filter(f => /\.daimond(?::|%3A)2,/.test(f)) |
| 338 | .map(f => (f.split('/').pop() || '').split('.')[1]).filter(Boolean)); |
| 339 | check('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 | |
| 345 | check('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. |
| 351 | await 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. |
| 354 | await 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(() => {}); |
| 360 | const projDir = await p.evaluate(() => window.DaimondMail.folderDir(null, 'Projects/Bourke Street')); |
| 361 | check('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); |
| 364 | const projSubjects = await p.$$eval('.mail-msg .mail-subj', els => els.map(e => e.textContent)); |
| 365 | check('and its mail arrives under it', |
| 366 | projSubjects.some(t => /The new place/.test(t)), projSubjects.join(' | ') || 'empty'); |
| 367 | |
| 368 | await p.evaluate(() => window.DaimondMail.selectFolder('INBOX')); |
| 369 | await p.waitForTimeout(1200); |
| 370 | await shot(s, 'mailfolders-dark'); |
| 371 | await p.evaluate(() => document.documentElement.setAttribute('data-theme', 'light')); |
| 372 | await p.waitForTimeout(400); |
| 373 | await shot(s, 'mailfolders-light'); |
| 374 | await p.evaluate(() => document.documentElement.setAttribute('data-theme', 'lollypop')); |
| 375 | await p.waitForTimeout(400); |
| 376 | await shot(s, 'mailfolders-lollypop'); |
| 377 | await 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. |
| 384 | await 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 | }); |
| 404 | await p.evaluate(() => window.DaimondMail.loadFolders('alice@test.local', true)); |
| 405 | await 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. |
| 415 | const 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 | }))); |
| 422 | const find = n => g.find(r => r.name === n) || {}; |
| 423 | check('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(', ')); |
| 427 | check('a container is shown but cannot be chosen', find('[Gmail]').off === true, |
| 428 | JSON.stringify(find('[Gmail]'))); |
| 429 | check('every other folder can be chosen', |
| 430 | g.filter(r => r.name !== '[Gmail]').every(r => r.off !== true)); |
| 431 | check('All Mail is offered', !!find('[Gmail]/Alle Nachrichten').name |
| 432 | && find('[Gmail]/Alle Nachrichten').label === 'All mail', |
| 433 | find('[Gmail]/Alle Nachrichten').label); |
| 434 | check('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'); |
| 437 | check('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. |
| 442 | await shot(s, 'mailfolders-gmail'); |
| 443 | |
| 444 | check('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 | |
| 464 | console.log('\nconsole errors:', s.errs.filter(e => !/favicon|404/.test(e)).slice(0, 5)); |
| 465 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 466 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 467 | await s.close(); |
| 468 | process.exit(bad.length ? 1 : 0); |