test(integration): dockerized webmail⇆Stalwart Playwright sync suite
Add an end-to-end integration harness that runs the webmail against a real Stalwart mail server in Docker and drives it with Playwright, focused on the mail/folder synchronisation behaviour (unread/total counters, folder-list sync, account-scoped Unified Mailbox) that the unified-mailbox work touches. - integration/ stack: Stalwart (declarative bootstrap: alice/bob/carol, submission + IMAP listeners, permissive CORS) + webmail (dev mode, so the browser's plaintext cross-origin JMAP calls aren't blocked by the prod CSP). - Helpers: dependency-free SMTP submit client, JMAP client for seeding / inspecting server state, and page helpers (login, add/switch account, locale-independent folder-counter reads). - Specs: login, single-account sync (receive/read/move/delete/folder-create/ burst) and multi-account (per-account isolation + cross-account Unified Inbox aggregation + background-account delivery). 12 tests, all green. - Add focused data-testid hooks to the mail UI (folder rows + counters, email list items, account switcher, composer) for stable selectors. - Exclude examples/ and integration/ from tsconfig/eslint/.dockerignore.
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Minimal JMAP client for test setup/inspection against Stalwart.
|
||||
*
|
||||
* Uses global fetch (Node 18+). Not a full JMAP implementation — just the
|
||||
* pieces the integration tests need: authenticate, read/reset mailboxes,
|
||||
* create folders, and poll for delivery. Assertions on *server* state (via
|
||||
* this client) are kept separate from assertions on *UI* state (via the page),
|
||||
* so a failing test can tell whether the bug is in delivery or in the webmail's
|
||||
* sync.
|
||||
*/
|
||||
import { JMAP_URL } from './config';
|
||||
|
||||
const CORE = 'urn:ietf:params:jmap:core';
|
||||
const MAIL = 'urn:ietf:params:jmap:mail';
|
||||
|
||||
interface JmapMailbox {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string | null;
|
||||
parentId: string | null;
|
||||
totalEmails: number;
|
||||
unreadEmails: number;
|
||||
}
|
||||
|
||||
type MethodCall = [string, Record<string, unknown>, string];
|
||||
|
||||
export class JmapClient {
|
||||
private authHeader: string;
|
||||
private apiUrl: string;
|
||||
accountId = '';
|
||||
|
||||
private constructor(private email: string, password: string) {
|
||||
this.authHeader = 'Basic ' + Buffer.from(`${email}:${password}`).toString('base64');
|
||||
// Stalwart advertises apiUrl on its configured hostname (mail.example.org);
|
||||
// rewrite onto the reachable origin, exactly as the app client does.
|
||||
this.apiUrl = `${JMAP_URL}/jmap/`;
|
||||
}
|
||||
|
||||
static async connect(email: string, password: string): Promise<JmapClient> {
|
||||
const c = new JmapClient(email, password);
|
||||
const res = await fetch(`${JMAP_URL}/jmap/session`, {
|
||||
headers: { Authorization: c.authHeader },
|
||||
});
|
||||
if (!res.ok) throw new Error(`JMAP session failed for ${email}: ${res.status}`);
|
||||
const session = await res.json();
|
||||
const primary = session.primaryAccounts?.[MAIL];
|
||||
if (!primary) throw new Error(`No mail account for ${email} in JMAP session`);
|
||||
c.accountId = primary;
|
||||
return c;
|
||||
}
|
||||
|
||||
async request(methodCalls: MethodCall[]): Promise<any> {
|
||||
const res = await fetch(this.apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: this.authHeader, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ using: [CORE, MAIL], methodCalls }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`JMAP request failed: ${res.status} ${await res.text()}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async mailboxes(): Promise<JmapMailbox[]> {
|
||||
const r = await this.request([['Mailbox/get', { accountId: this.accountId }, '0']]);
|
||||
return r.methodResponses[0][1].list as JmapMailbox[];
|
||||
}
|
||||
|
||||
async mailboxByRole(role: string): Promise<JmapMailbox | undefined> {
|
||||
return (await this.mailboxes()).find((m) => m.role === role);
|
||||
}
|
||||
|
||||
async mailboxByName(name: string): Promise<JmapMailbox | undefined> {
|
||||
return (await this.mailboxes()).find((m) => m.name === name);
|
||||
}
|
||||
|
||||
/** Create a folder (top-level) and return its id. Idempotent by name. */
|
||||
async createMailbox(name: string, parentId: string | null = null): Promise<string> {
|
||||
const existing = await this.mailboxByName(name);
|
||||
if (existing) return existing.id;
|
||||
const r = await this.request([
|
||||
['Mailbox/set', { accountId: this.accountId, create: { new: { name, parentId } } }, '0'],
|
||||
]);
|
||||
const created = r.methodResponses[0][1].created?.new;
|
||||
if (!created) throw new Error(`Mailbox/set create failed: ${JSON.stringify(r.methodResponses[0][1])}`);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async deleteMailboxByName(name: string): Promise<void> {
|
||||
const mb = await this.mailboxByName(name);
|
||||
if (!mb) return;
|
||||
await this.request([
|
||||
['Mailbox/set', { accountId: this.accountId, onDestroyRemoveEmails: true, destroy: [mb.id] }, '0'],
|
||||
]);
|
||||
}
|
||||
|
||||
private async allEmailIds(): Promise<string[]> {
|
||||
const r = await this.request([['Email/query', { accountId: this.accountId, limit: 5000 }, '0']]);
|
||||
return r.methodResponses[0][1].ids as string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a mailbox to a clean slate: destroy every message and delete any
|
||||
* non-system (custom) folder. System folders (Inbox/Sent/Trash/…) are kept.
|
||||
*/
|
||||
async reset(): Promise<void> {
|
||||
const ids = await this.allEmailIds();
|
||||
if (ids.length) {
|
||||
await this.request([['Email/set', { accountId: this.accountId, destroy: ids }, '0']]);
|
||||
}
|
||||
const custom = (await this.mailboxes()).filter((m) => !m.role);
|
||||
if (custom.length) {
|
||||
await this.request([
|
||||
['Mailbox/set', { accountId: this.accountId, onDestroyRemoveEmails: true, destroy: custom.map((m) => m.id) }, '0'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Look up an email id by subject within an optional mailbox. */
|
||||
async findEmailBySubject(subject: string, mailboxId?: string): Promise<any | undefined> {
|
||||
const filter: Record<string, unknown> = { subject };
|
||||
if (mailboxId) filter.inMailbox = mailboxId;
|
||||
const r = await this.request([
|
||||
['Email/query', { accountId: this.accountId, filter }, '0'],
|
||||
['Email/get', {
|
||||
accountId: this.accountId,
|
||||
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||
properties: ['id', 'subject', 'keywords', 'mailboxIds', 'from', 'preview'],
|
||||
}, '1'],
|
||||
]);
|
||||
return r.methodResponses[1][1].list[0];
|
||||
}
|
||||
|
||||
/** Poll until a message with `subject` is delivered (or throw on timeout). */
|
||||
async waitForEmail(subject: string, opts: { mailboxId?: string; timeoutMs?: number } = {}): Promise<any> {
|
||||
const deadline = Date.now() + (opts.timeoutMs ?? 15000);
|
||||
for (;;) {
|
||||
const found = await this.findEmailBySubject(subject, opts.mailboxId);
|
||||
if (found) return found;
|
||||
if (Date.now() > deadline) throw new Error(`Timed out waiting for email "${subject}" (${this.email})`);
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user