Files
SRCmail/integration/tests/02-mail-sync.spec.ts
T
Stefan HildebrandtandLinus Rath 8d8bc7cb13 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.
2026-07-16 17:59:52 +02:00

126 lines
4.9 KiB
TypeScript

import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import {
login,
folderRow,
folderCounts,
expectFolderUnread,
expectFolderTotal,
expectFolderCountsSynced,
emailItem,
expectEmailVisible,
} from './helpers/app';
/**
* Single-account mail & folder synchronisation.
*
* These exercise the webmail's ability to reflect *external* changes to the
* mailbox — new deliveries, server-side reads/moves/deletes, and folder
* creation — which is where "my counts are wrong / my folder didn't show up"
* sync bugs live. Mutations are made over SMTP/JMAP and the assertions are on
* the rendered UI.
*/
const alice = ACCOUNTS.alice;
// Unique subject per test run avoids cross-test contamination if a reset lags.
let seq = 0;
const subj = (label: string) => `IT ${label} ${Date.now()}-${seq++}`;
test.describe('Single-account sync', () => {
let jmap: JmapClient;
test.beforeEach(async () => {
jmap = await JmapClient.connect(alice.email, alice.password);
await jmap.reset();
});
test('incoming mail appears and bumps the Inbox unread counter', async ({ page }) => {
await login(page, alice);
await expectFolderUnread(page, { role: 'inbox' }, 0);
const subject = subj('incoming');
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'hi' });
await expectFolderUnread(page, { role: 'inbox' }, 1);
await expectEmailVisible(page, subject);
});
test('opening a message clears its unread state (UI -> server -> counter)', async ({ page }) => {
const subject = subj('read');
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'read me' });
await jmap.waitForEmail(subject);
await login(page, alice);
await expectFolderUnread(page, { role: 'inbox' }, 1);
await emailItem(page, subject).first().click();
await expectFolderUnread(page, { role: 'inbox' }, 0);
});
test('a folder created on the server shows up in the sidebar', async ({ page }) => {
await login(page, alice);
await expect(folderRow(page, { name: 'SyncFolder' })).toHaveCount(0);
await jmap.createMailbox('SyncFolder');
await expect(folderRow(page, { name: 'SyncFolder' }).first()).toBeVisible({ timeout: 20000 });
});
test('a server-side move updates both source and destination counters', async ({ page }) => {
const subject = subj('move');
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'move me' });
const email = await jmap.waitForEmail(subject);
await login(page, alice);
await expectFolderUnread(page, { role: 'inbox' }, 1);
// Create destination + move the message there (server-side).
const destId = await jmap.createMailbox('Archive2');
const inbox = await jmap.mailboxByRole('inbox');
await jmap.request([
['Email/set', { accountId: jmap.accountId, update: { [email.id]: { mailboxIds: { [destId]: true } } } }, '0'],
]);
// Source Inbox drains, destination gains the message. These follow a
// reconcile (not live push), so nudge one before every poll to stay robust
// against a single missed reconcile under load.
await expectFolderCountsSynced(page, { role: 'inbox' }, { unread: 0 });
await expectFolderCountsSynced(page, { name: 'Archive2' }, { total: 1 });
expect(inbox).toBeTruthy();
});
test('a server-side delete drains the Inbox total', async ({ page }) => {
const subject = subj('delete');
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'delete me' });
const email = await jmap.waitForEmail(subject);
await login(page, alice);
await expectFolderTotal(page, { role: 'inbox' }, 1);
await jmap.request([['Email/set', { accountId: jmap.accountId, destroy: [email.id] }, '0']]);
// The folder counter is the sync-critical signal and drains to zero (via a
// reconcile, nudged before every poll). (The already-rendered list view is
// not re-queried on a background delete, so we don't assert on the row
// disappearing here.)
await expectFolderCountsSynced(page, { role: 'inbox' }, { unread: 0, total: 0 });
});
test('counts are consistent between server and UI after a burst of deliveries', async ({ page }) => {
await login(page, alice);
await expectFolderUnread(page, { role: 'inbox' }, 0);
const subjects = Array.from({ length: 3 }, (_, i) => subj(`burst-${i}`));
for (const s of subjects) {
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject: s, body: 'burst' });
}
await expectFolderUnread(page, { role: 'inbox' }, 3);
const counts = await folderCounts(page, { role: 'inbox' });
expect(counts.total).toBe(3);
for (const s of subjects) await expectEmailVisible(page, s);
});
});