test(integration): All Mail, message actions, and shared-folder sync

Extend the integration suite (now 22 tests) to cover:

- All Mail view (04): single-account merge of Inbox + custom folders with
  Junk excluded, and cross-account aggregation across every logged-in account.
- Message actions from the list context menu (05): mark read/unread, delete
  (→ Trash), mark-as-spam (→ Junk) and not-spam round-trip, verified on both
  the UI counters/row state and the server mailbox the message ends up in.
- Shared/delegated folders (06): a delegated folder (+ Trash/Junk) shared
  alice→carol; the shared folder renders with its counter, and read/unread/
  delete/spam performed there land correctly (server-verified).

Hooks added: data-testid on context-menu delete/spam/read-unread items
(via a testId prop on ContextMenuItem), data-shared on folder rows, and
testId/data-expanded on sidebar section headers to drive the Shared section.

Observations surfaced by the suite (asserted server-side / with a reconcile):
- mark-as-spam doesn't optimistically decrement the *source* counter the way
  delete does; a visibility reconcile settles it.
- shared *destination* counters (shared Trash/Junk) don't refresh live —
  forceSync reconciles the active account only, not shared accounts.
This commit is contained in:
Stefan Hildebrandt
2026-07-11 21:15:20 +02:00
parent b8809c2e69
commit e05fbb2fe9
8 changed files with 529 additions and 15 deletions
+74 -13
View File
@@ -36,21 +36,41 @@ export async function neutralizeDevOverlay(page: Page): Promise<void> {
}
/**
* Enable the cross-account Unified Mailbox before the app boots by seeding the
* persisted settings store. Requires the `unifiedCrossAccountEnabled` admin
* feature gate (provided by integration/webmail-config/policy.json). Must be
* called before {@link login} so the init script is registered before the
* first navigation.
* Seed the persisted settings store before the app boots. Merges over the
* store defaults on rehydrate. Must be called before {@link login} so the init
* script is registered before the first navigation.
*/
export async function seedSettings(page: Page, settings: Record<string, unknown>): Promise<void> {
await page.addInitScript((s) => {
localStorage.setItem('settings-storage', JSON.stringify({ state: s, version: 7 }));
}, settings);
}
/**
* Enable the cross-account Unified Mailbox. Requires the
* `unifiedCrossAccountEnabled` admin feature gate (provided by
* integration/webmail-config/policy.json).
*/
export async function seedUnifiedSettings(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem(
'settings-storage',
JSON.stringify({
state: { enableUnifiedMailbox: true, unifiedCrossAccount: true, includeGroupInUnified: true },
version: 7,
}),
);
await seedSettings(page, {
enableUnifiedMailbox: true,
unifiedCrossAccount: true,
includeGroupInUnified: true,
});
}
/**
* Enable the "All Mail" view. `crossAccount` spans every logged-in account
* (requires the `unifiedCrossAccountEnabled` gate); otherwise it is account-
* bounded (spans the active account's own + shared folders). The "All mail"
* entry itself is gated by `crossAllViewEnabled` (also in policy.json).
*/
export async function seedAllMailSettings(page: Page, opts: { crossAccount?: boolean } = {}): Promise<void> {
await seedSettings(page, {
enableUnifiedMailbox: true,
enableCrossAllView: true,
includeGroupInUnified: true,
unifiedCrossAccount: !!opts.crossAccount,
});
}
@@ -121,6 +141,8 @@ export interface FolderSelector {
role?: string;
name?: string;
mailboxId?: string;
/** true = only shared-account folders, false = only own folders. */
shared?: boolean;
}
/** Locator for a sidebar folder row. */
@@ -129,9 +151,24 @@ export function folderRow(page: Page, sel: FolderSelector): Locator {
if (sel.role) s += `[data-folder-role="${sel.role}"]`;
if (sel.name) s += `[data-folder-name="${sel.name}"]`;
if (sel.mailboxId) s += `[data-mailbox-id="${sel.mailboxId}"]`;
if (sel.shared === true) s += '[data-shared="true"]';
if (sel.shared === false) s += ':not([data-shared="true"])';
return page.locator(s);
}
/**
* Expand the sidebar "Shared" section and the given sharer's shared-account
* group so its folders (data-shared="true") render. Idempotent.
*/
export async function expandSharedFolders(page: Page, sharerEmail: string): Promise<void> {
const section = page.locator('[data-testid="section-shared"]');
await section.waitFor({ state: 'visible', timeout: 30000 });
if ((await section.getAttribute('data-expanded')) !== 'true') await section.click();
const account = page.locator(`[data-testid="section-shared-account"][data-section-name="${sharerEmail}"]`);
await account.waitFor({ state: 'visible', timeout: 30000 });
if ((await account.getAttribute('data-expanded')) !== 'true') await account.click();
}
export interface FolderCounts {
unread: number;
total: number;
@@ -178,3 +215,27 @@ export function emailItem(page: Page, subject: string): Locator {
export async function expectEmailVisible(page: Page, subject: string, timeout = 20000): Promise<void> {
await expect(emailItem(page, subject).first()).toBeVisible({ timeout });
}
/** Assert an email row's unread state (from its `data-unread` attribute). */
export async function expectEmailUnread(page: Page, subject: string, unread: boolean, timeout = 20000): Promise<void> {
await expect(emailItem(page, subject).first()).toHaveAttribute('data-unread', String(unread), { timeout });
}
/**
* Open an email's right-click context menu and click one of its actions.
* `testId` is one of: `ctx-delete`, `ctx-spam`, `ctx-not-spam`,
* `ctx-mark-read`, `ctx-mark-unread`.
*/
export async function emailContextAction(page: Page, subject: string, testId: string): Promise<void> {
const row = emailItem(page, subject).first();
await row.waitFor({ state: 'visible' });
await row.scrollIntoViewIfNeeded();
const item = page.locator(`[data-testid="${testId}"]`);
// Right-click can occasionally land before the list row is interactive;
// retry opening the menu until the action item is actually present.
await expect(async () => {
await row.click({ button: 'right' });
await item.waitFor({ state: 'visible', timeout: 2000 });
}).toPass({ timeout: 15000 });
await item.click();
}