Oregami
Repositories/oxedyne/daimond

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.
11import fs from 'node:fs';
12import { open, shot, scratch } from './harness.mjs';
13import { IMAP_PORT, SMTP_PORT } from './ports.mjs';
14
15const SENT = process.env.SENT_EML || '/tmp/sent.eml';
16const ok = [], bad = [];
17const 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.
24const s = await open({ name: 'compose', connect: false, profile: scratch('compose-profile') });
25const 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.
33await 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).
45await 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 });
61await p.waitForTimeout(1200);
62
63// ── Sync, so there is something to reply to.
64await p.evaluate(() => window.DaimondMail.sync());
65await p.waitForSelector('.mail-msg', { timeout: 20000 });
66const msgs = await p.$$eval('.mail-msg', els => els.length);
67check('the fixture mailbox syncs', msgs >= 3, msgs + ' messages');
68
69// ── Open one, and answer it.
70await p.click('.mail-msg');
71await p.waitForSelector('#panel-msg .msg-acts', { timeout: 10000 });
72const verbs = await p.$$eval('.msg-act', els => els.map(e => e.textContent));
73check('the message offers the verbs', verbs.includes('Reply') && verbs.includes('Forward'), verbs.join(', '));
74
75await p.click('.msg-act'); // Reply
76await p.waitForSelector('#panel-compose', { state: 'visible', timeout: 10000 });
77const 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}));
85check('reply opens on the stage, beside the daimon', pre.staged && pre.aiToo);
86check('reply addresses the sender', /@/.test(pre.to), pre.to);
87check('reply carries Re:', /^Re:/i.test(pre.subject), pre.subject);
88check('reply quotes what it answers', /^> /m.test(pre.body));
89check('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).
93fs.writeFileSync('/tmp/compose-att.txt', 'the attachment survived the journey\n');
94await p.fill('#compose-subject', 'Re: naïve — a résumé of the façade');
95await p.fill('#compose-text', 'Thanks — the café is fine.\nA line with trailing space \nFrom the top.\n\n> quoted still\n');
96await p.setInputFiles('#compose-file', '/tmp/compose-att.txt');
97await p.waitForSelector('.compose-att', { timeout: 5000 });
98check('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.
101await p.click('#compose-save');
102await p.waitForFunction(() => /Saved to /.test(document.getElementById('compose-note').textContent), { timeout: 10000 });
103const draftPath = await p.$eval('#compose-note', e => e.textContent.replace('Saved to ', ''));
104check('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.
115try { fs.unlinkSync(SENT); } catch (e) { /* nothing sent here before, which is the state wanted */ }
116
117await p.click('#compose-send');
118await p.waitForSelector('.dlg-card', { timeout: 5000 });
119const confirmText = await p.$eval('.dlg-card', e => e.textContent);
120check('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.
129await p.click('.dlg-card .dlg-actions .dlg-ok');
130await p.waitForTimeout(3000);
131
132// ── What actually went on the wire.
133let raw = '';
134for (let i = 0; i < 20 && !raw; i++) {
135 try { raw = fs.readFileSync(SENT, 'binary'); } catch { await p.waitForTimeout(500); }
136}
137check('the provider received a message', !!raw, raw.length + ' bytes');
138if (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.
160const 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});
173check('the sent message is kept as a file', after.some(f => /\/sent\/.*\.eml$/.test(f)),
174 after.filter(f => /sent/.test(f)).join(', '));
175check('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.
179await 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});
190await p.evaluate(() => window.DaimondMail.onOpen());
191await 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.
197const draftRows = await p.$$eval('.mail-draft', els => els.map(e => e.textContent));
198const drafted = draftRows.find(x => /daimon wrote/.test(x)) || '';
199check('a draft the agent wrote is offered to the user', !!drafted,
200 drafted || 'the drafts on offer were: ' + (draftRows.join(' | ') || 'none'));
201
202await p.click('.mail-draft:has-text("daimon wrote")');
203await p.waitForSelector('#panel-compose', { state: 'visible', timeout: 5000 });
204const 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}));
209check('the agent\'s draft opens for the human to send',
210 reopened.to === 'bob@test.local' && /check/.test(reopened.body), JSON.stringify(reopened));
211
212await shot(s, 'compose');
213console.log('\nconsole errors:', s.errs.filter(e => !/favicon|404/.test(e)).slice(0, 5));
214console.log(`\n${ok.length} passed, ${bad.length} failed`);
215if (bad.length) console.log('FAILED:\n ' + bad.join('\n '));
216await s.close();
217process.exit(bad.length ? 1 : 0);