oxedyne/daimond/dev/verify_compose.mjs
11.7 KiB, 1 run
created by r2519314175:303, 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_compose.mjs — drive the compose path end to end, against real servers. |
| 2 | // |
| 3 | // The IMAP fixture (fe2o3's imap_test_server, 127.0.0.1:1143) puts real mail in the |
| 4 | // mailbox; a stand-in submission provider (127.0.0.1:1587) takes what is sent and |
| 5 | // writes it to a file. So the question this answers is not "did the UI do something" |
| 6 | // but "what exactly did Daimond put on the wire" — the headers, the encoding, the |
| 7 | // threading, the envelope. |
| 8 | // |
| 9 | // Needs: the gateway with `dev_insecure` on the mail routes, the fixture, the |
| 10 | // stand-in, and the `email` entitlement granted to the harness's account. |
| 11 | import fs from 'node:fs'; |
| 12 | import { open, shot, scratch } from './harness.mjs'; |
| 13 | import { IMAP_PORT, SMTP_PORT } from './ports.mjs'; |
| 14 | |
| 15 | const SENT = process.env.SENT_EML || '/tmp/sent.eml'; |
| 16 | const ok = [], bad = []; |
| 17 | const check = (name, pass, detail) => { |
| 18 | (pass ? ok : bad).push(name + (detail ? ' — ' + detail : '')); |
| 19 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 20 | }; |
| 21 | |
| 22 | // A fixed profile, so the identity — and therefore the gateway account holding the |
| 23 | // email unlock and the credits — is the same one every run. |
| 24 | const s = await open({ name: 'compose', connect: false, profile: scratch('compose-profile') }); |
| 25 | const p = s.page; |
| 26 | |
| 27 | // ── Clear the drafts this run is about to make claims about. |
| 28 | // |
| 29 | // The profile is fixed, so OPFS persists between runs — and the run below seeds a draft of |
| 30 | // its own (the one the agent writes). Left behind, that draft is still sitting there when the |
| 31 | // NEXT run asserts that sending cleared the drafts folder, and the run fails on the leavings |
| 32 | // of the run before it. The state under test is this run's; start it empty. |
| 33 | await p.evaluate(async () => { |
| 34 | const root = await navigator.storage.getDirectory(); |
| 35 | try { |
| 36 | const mail = await root.getDirectoryHandle('mail'); |
| 37 | const box = await mail.getDirectoryHandle('alice@test.local'); |
| 38 | await box.removeEntry('drafts', { recursive: true }); |
| 39 | } catch (e) { /* no mailbox yet, which is the state we are asking for */ } |
| 40 | }); |
| 41 | |
| 42 | // ── Seed the mailbox the way the add-dialog would, but pointed at the fixture. |
| 43 | // The dialog infers security from the port and offers no plaintext, so a loopback |
| 44 | // test server can only be reached by seeding the record (a known gotcha). |
| 45 | await p.evaluate(async (PORTS) => { |
| 46 | const pass = await window.DaimondIdentity.wrap('test-app-password'); |
| 47 | localStorage.setItem('daimond-mail', JSON.stringify({ |
| 48 | accounts: [{ |
| 49 | address: 'alice@test.local', |
| 50 | host: '127.0.0.1', port: PORTS.imap, security: 'plain', |
| 51 | smtpHost: '127.0.0.1', smtpPort: PORTS.smtp, smtpSecurity: 'plain', |
| 52 | user: 'alice@test.local', pass, |
| 53 | uidValidity: 0, lastUid: 0, lastSync: 0, |
| 54 | }], |
| 55 | sel: 'alice@test.local', |
| 56 | })); |
| 57 | window.DaimondMail.reload(); |
| 58 | window.DaimondPanels.show('mail'); |
| 59 | window.DaimondMail.onOpen(); |
| 60 | }, { imap: IMAP_PORT, smtp: SMTP_PORT }); |
| 61 | await p.waitForTimeout(1200); |
| 62 | |
| 63 | // ── Sync, so there is something to reply to. |
| 64 | await p.evaluate(() => window.DaimondMail.sync()); |
| 65 | await p.waitForSelector('.mail-msg', { timeout: 20000 }); |
| 66 | const msgs = await p.$$eval('.mail-msg', els => els.length); |
| 67 | check('the fixture mailbox syncs', msgs >= 3, msgs + ' messages'); |
| 68 | |
| 69 | // ── Open one, and answer it. |
| 70 | await p.click('.mail-msg'); |
| 71 | await p.waitForSelector('#panel-msg .msg-acts', { timeout: 10000 }); |
| 72 | const verbs = await p.$$eval('.msg-act', els => els.map(e => e.textContent)); |
| 73 | check('the message offers the verbs', verbs.includes('Reply') && verbs.includes('Forward'), verbs.join(', ')); |
| 74 | |
| 75 | await p.click('.msg-act'); // Reply |
| 76 | await p.waitForSelector('#panel-compose', { state: 'visible', timeout: 10000 }); |
| 77 | const pre = await p.evaluate(() => ({ |
| 78 | to: document.getElementById('compose-to').value, |
| 79 | subject: document.getElementById('compose-subject').value, |
| 80 | body: document.getElementById('compose-text').value, |
| 81 | from: document.getElementById('compose-from').value, |
| 82 | staged: !!document.querySelector('#panel-compose').offsetParent, |
| 83 | aiToo: !!document.querySelector('[data-panel="ai"]').offsetParent, |
| 84 | })); |
| 85 | check('reply opens on the stage, beside the daimon', pre.staged && pre.aiToo); |
| 86 | check('reply addresses the sender', /@/.test(pre.to), pre.to); |
| 87 | check('reply carries Re:', /^Re:/i.test(pre.subject), pre.subject); |
| 88 | check('reply quotes what it answers', /^> /m.test(pre.body)); |
| 89 | check('reply is from the mailbox', pre.from === 'alice@test.local', pre.from); |
| 90 | |
| 91 | // ── Write it: a non-ASCII subject and body (encoded-words and quoted-printable), |
| 92 | // and a file (the multipart path). |
| 93 | fs.writeFileSync('/tmp/compose-att.txt', 'the attachment survived the journey\n'); |
| 94 | await p.fill('#compose-subject', 'Re: naïve — a résumé of the façade'); |
| 95 | await p.fill('#compose-text', 'Thanks — the café is fine.\nA line with trailing space \nFrom the top.\n\n> quoted still\n'); |
| 96 | await p.setInputFiles('#compose-file', '/tmp/compose-att.txt'); |
| 97 | await p.waitForSelector('.compose-att', { timeout: 5000 }); |
| 98 | check('an attachment can be added', true, await p.$eval('.compose-att', e => e.textContent)); |
| 99 | |
| 100 | // ── Save it as a draft first: the file is the draft. |
| 101 | await p.click('#compose-save'); |
| 102 | await p.waitForFunction(() => /Saved to /.test(document.getElementById('compose-note').textContent), { timeout: 10000 }); |
| 103 | const draftPath = await p.$eval('#compose-note', e => e.textContent.replace('Saved to ', '')); |
| 104 | check('a draft is a file in the workspace', /^mail\/.*\/drafts\/.*\.eml$/.test(draftPath), draftPath); |
| 105 | |
| 106 | // ── Send it. The confirm is the last thing between a person and an irreversible act. |
| 107 | // |
| 108 | // The stand-in provider APPENDS NOTHING and TRUNCATES NOTHING: it writes SENT once per |
| 109 | // message it accepts, and leaves the file there afterwards. So a run that sends nothing at |
| 110 | // all still finds the file — the one the run before it left — and every claim below about |
| 111 | // "what went on the wire" is then made about somebody else's message. That is exactly what |
| 112 | // happened on the 2026-08-11 gate: thirteen checks passed green against a file eleven hours |
| 113 | // old while the send under test never left the browser. Remove it first, so the only way to |
| 114 | // read one is for THIS run to have put it there. |
| 115 | try { fs.unlinkSync(SENT); } catch (e) { /* nothing sent here before, which is the state wanted */ } |
| 116 | |
| 117 | await p.click('#compose-send'); |
| 118 | await p.waitForSelector('.dlg-card', { timeout: 5000 }); |
| 119 | const confirmText = await p.$eval('.dlg-card', e => e.textContent); |
| 120 | check('sending asks first', /cannot be recalled/i.test(confirmText)); |
| 121 | // The DECISION, named — not "a button that is not Cancel". |
| 122 | // |
| 123 | // Every dialog gained a corner cross on 2026-08-11 (`DaimondCloser.head`), which is a |
| 124 | // `<button>` inside `.dlg-card` and stands EARLIER in the document than the foot. A CSS |
| 125 | // selector list matches in document order, not in the order it is written, so |
| 126 | // `button:not(.dlg-cancel)` began resolving to the cross: the run pressed Close, sent |
| 127 | // nothing, and the failure surfaced two checks later as "the draft was not cleared". |
| 128 | // `.dlg-actions .dlg-ok` is the foot's affirmative and nothing else can be. |
| 129 | await p.click('.dlg-card .dlg-actions .dlg-ok'); |
| 130 | await p.waitForTimeout(3000); |
| 131 | |
| 132 | // ── What actually went on the wire. |
| 133 | let raw = ''; |
| 134 | for (let i = 0; i < 20 && !raw; i++) { |
| 135 | try { raw = fs.readFileSync(SENT, 'binary'); } catch { await p.waitForTimeout(500); } |
| 136 | } |
| 137 | check('the provider received a message', !!raw, raw.length + ' bytes'); |
| 138 | if (raw) { |
| 139 | const head = raw.split('\r\n\r\n')[0]; |
| 140 | check('it is From the mailbox', /^From: alice@test\.local/m.test(head)); |
| 141 | check('it has a Message-ID', /^Message-ID: <.+@test\.local>/m.test(head)); |
| 142 | check('it threads (In-Reply-To)', /^In-Reply-To: <.+>/m.test(head), (head.match(/^In-Reply-To: .*/m) || [''])[0]); |
| 143 | check('it threads (References)', /^References: <.+>/m.test(head)); |
| 144 | check('a non-ASCII subject is encoded',/^Subject: .*=\?utf-8\?B\?/m.test(head), (head.match(/^Subject: .*/m) || [''])[0].slice(0, 60)); |
| 145 | check('it is multipart with the file', /Content-Type: multipart\/mixed/.test(head)); |
| 146 | check('the body is quoted-printable', /Content-Transfer-Encoding: quoted-printable/.test(raw)); |
| 147 | check('a trailing space is encoded', /space=20\r\n/.test(raw) || /space=\r\n/.test(raw)); |
| 148 | check('a "From " line is escaped', /^=46rom the top\./m.test(raw)); |
| 149 | check('the attachment is carried', /filename="compose-att.txt"/.test(raw)); |
| 150 | // The attachment's bytes, decoded back out of the base64 part. |
| 151 | const b64 = (raw.split('filename="compose-att.txt"')[1] || '').split('\r\n\r\n')[1] || ''; |
| 152 | const back = Buffer.from(b64.split('\r\n--')[0].replace(/\s+/g, ''), 'base64').toString(); |
| 153 | check('the attachment survives', /survived the journey/.test(back), JSON.stringify(back.slice(0, 40))); |
| 154 | // The é in the body: quoted-printable of UTF-8 is =C3=A9. |
| 155 | check('a non-ASCII body is encoded', /caf=C3=A9/.test(raw)); |
| 156 | fs.writeFileSync('/tmp/sent-head.txt', head); |
| 157 | } |
| 158 | |
| 159 | // ── The sent copy, and the draft that is now gone. |
| 160 | const after = await p.evaluate(async () => { |
| 161 | const walk = async (dir, path) => { |
| 162 | const out = []; |
| 163 | for await (const [name, h] of dir.entries()) { |
| 164 | if (h.kind === 'directory') out.push(...await walk(h, path + name + '/')); |
| 165 | else out.push(path + name); |
| 166 | } |
| 167 | return out; |
| 168 | }; |
| 169 | const root = await navigator.storage.getDirectory(); |
| 170 | const mail = await root.getDirectoryHandle('mail'); |
| 171 | return await walk(mail, 'mail/'); |
| 172 | }); |
| 173 | check('the sent message is kept as a file', after.some(f => /\/sent\/.*\.eml$/.test(f)), |
| 174 | after.filter(f => /sent/.test(f)).join(', ')); |
| 175 | check('the sent draft is cleared', !after.some(f => /\/drafts\/.*\.eml$/.test(f)), |
| 176 | after.filter(f => /drafts/.test(f)).join(', ') || 'none left'); |
| 177 | |
| 178 | // ── The agent's channel: a draft IT wrote must appear where the user looks. |
| 179 | await p.evaluate(async () => { |
| 180 | const root = await navigator.storage.getDirectory(); |
| 181 | const mail = await root.getDirectoryHandle('mail'); |
| 182 | const box = await mail.getDirectoryHandle('alice@test.local'); |
| 183 | const dir = await box.getDirectoryHandle('drafts', { create: true }); |
| 184 | const fh = await dir.getFileHandle('agent-reply.eml', { create: true }); |
| 185 | const w = await fh.createWritable(); |
| 186 | await w.write('From: alice@test.local\r\nTo: bob@test.local\r\n' |
| 187 | + 'Subject: The draft the daimon wrote\r\n\r\nI have written this for you to check.\r\n'); |
| 188 | await w.close(); |
| 189 | }); |
| 190 | await p.evaluate(() => window.DaimondMail.onOpen()); |
| 191 | await p.waitForSelector('.mail-draft', { timeout: 10000 }); |
| 192 | // The AGENT'S draft, found by what it says — not "the first row in the list". The rows are |
| 193 | // ordered by date and an agent's draft carries no Date header, so it sorts last; taking |
| 194 | // `.mail-draft` unqualified read whichever draft happened to be on top and asserted the |
| 195 | // agent's words about it. All draft rows are listed, so a missing one is still a failure |
| 196 | // here — it just fails saying which one is missing. |
| 197 | const draftRows = await p.$$eval('.mail-draft', els => els.map(e => e.textContent)); |
| 198 | const drafted = draftRows.find(x => /daimon wrote/.test(x)) || ''; |
| 199 | check('a draft the agent wrote is offered to the user', !!drafted, |
| 200 | drafted || 'the drafts on offer were: ' + (draftRows.join(' | ') || 'none')); |
| 201 | |
| 202 | await p.click('.mail-draft:has-text("daimon wrote")'); |
| 203 | await p.waitForSelector('#panel-compose', { state: 'visible', timeout: 5000 }); |
| 204 | const reopened = await p.evaluate(() => ({ |
| 205 | to: document.getElementById('compose-to').value, |
| 206 | subj: document.getElementById('compose-subject').value, |
| 207 | body: document.getElementById('compose-text').value, |
| 208 | })); |
| 209 | check('the agent\'s draft opens for the human to send', |
| 210 | reopened.to === 'bob@test.local' && /check/.test(reopened.body), JSON.stringify(reopened)); |
| 211 | |
| 212 | await shot(s, 'compose'); |
| 213 | console.log('\nconsole errors:', s.errs.filter(e => !/favicon|404/.test(e)).slice(0, 5)); |
| 214 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 215 | if (bad.length) console.log('FAILED:\n ' + bad.join('\n ')); |
| 216 | await s.close(); |
| 217 | process.exit(bad.length ? 1 : 0); |