/** * Page-level helpers for driving the Bulwark webmail in integration tests. * * Selectors rely on the data-testid hooks added to the mail UI (sidebar folder * rows + counters, account switcher, composer). Folder counters are read from * the `data-unread` / `data-total` attributes on `[data-testid=folder-counts]` * rather than parsing rendered text, so assertions are locale-independent. */ import { expect, type Page, type Locator } from '@playwright/test'; import type { TestAccount } from './config'; /** * The account switcher renders twice (collapsed nav rail + expanded sidebar); * both carry the same data-testid and state, so always target the first. */ export function accountSwitcher(page: Page): Locator { return page.locator('[data-testid="account-switcher"]').first(); } /** * The Next.js dev-mode overlay (``) sits in the bottom-left * corner and intercepts pointer events over the account switcher. Disable * pointer events on the portal host (light DOM) so it can't swallow clicks. * Registered as an init script so it survives navigations within the test. */ export async function neutralizeDevOverlay(page: Page): Promise { await page.addInitScript(() => { const inject = () => { const s = document.createElement('style'); s.textContent = 'nextjs-portal{pointer-events:none!important}'; document.documentElement.appendChild(s); }; if (document.documentElement) inject(); else document.addEventListener('DOMContentLoaded', inject); }); } /** * 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): Promise { 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 { 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 { await seedSettings(page, { enableUnifiedMailbox: true, enableCrossAllView: true, includeGroupInUnified: true, unifiedCrossAccount: !!opts.crossAccount, }); } /** Fill and submit the login form (works for first login and add-account). */ async function submitCredentials(page: Page, account: TestAccount): Promise { await page.locator('#username').waitFor({ state: 'visible', timeout: 30000 }); await page.fill('#username', account.email); await page.fill('#password', account.password); await page.click('button[type="submit"]'); } /** Log in as `account` from a clean context and wait for the mailbox to load. */ export async function login(page: Page, account: TestAccount): Promise { await neutralizeDevOverlay(page); await page.goto('/'); await submitCredentials(page, account); // Landed in the app once the account switcher (sidebar chrome) is present. await accountSwitcher(page).waitFor({ state: 'visible', timeout: 30000 }); } /** Add a second (or later) account via the account switcher + login form. */ export async function addAccount(page: Page, account: TestAccount): Promise { await accountSwitcher(page).click(); await page.locator('[data-testid="add-account"]').click(); await submitCredentials(page, account); // Wait until the switcher reports the newly added account as active. await expect .poll(async () => activeAccountEmail(page), { timeout: 30000 }) .toBe(account.email); } /** Email of the currently active account, read from the switcher option list. */ export async function activeAccountEmail(page: Page): Promise { const switcher = accountSwitcher(page); const id = await switcher.getAttribute('data-active-account-id'); if (!id) return null; await switcher.click(); const email = await page .locator(`[data-testid="account-option"][data-account-id="${id}"]`) .first() .getAttribute('data-account-email'); // Close the popover again. await page.keyboard.press('Escape'); return email; } /** Switch the active account to the one matching `email`. */ export async function switchAccount(page: Page, email: string): Promise { await accountSwitcher(page).click(); await page.locator(`[data-testid="account-option"][data-account-email="${email}"]`).first().click(); await expect.poll(async () => activeAccountEmail(page), { timeout: 30000 }).toBe(email); } /** * Nudge the app to reconcile mailbox state immediately. * * The JMAP client refetches on `visibilitychange` (tab focus) via * checkForStateChanges(). Dispatching it makes reconciliation deterministic * after an *external* mutation, sidestepping the small window right after * login where a change can land before the SSE push channel has settled. * Mirrors what happens when a real user tabs back to the mailbox. */ export async function forceSync(page: Page): Promise { await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange'))); } // ─── Composer / drafts ──────────────────────────────────────────────────── /** Open the composer via the keyboard shortcut and wait for it to render. */ export async function openComposer(page: Page): Promise { await page.keyboard.press('c'); await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15000 }); } /** Add a recipient to the To field (commits it as a chip with Enter). */ export async function addRecipient(page: Page, email: string): Promise { const input = page.locator('[data-testid="composer-to"] input').first(); await input.click(); await input.fill(email); await input.press('Enter'); } /** Select a sending identity in the From dropdown by its identity id. */ export async function setFrom(page: Page, identityId: string): Promise { await page.locator('[data-testid="composer-from"]').selectOption({ value: identityId }); } /** Fill the subject field. */ export async function setSubject(page: Page, subject: string): Promise { await page.locator('[data-testid="composer-subject"]').fill(subject); } /** Wait until the composer reports the draft as saved. */ export async function waitDraftSaved(page: Page): Promise { await expect(page.locator('[data-testid="composer-save-status"]')).toHaveAttribute('data-status', 'saved', { timeout: 20000, }); } /** Close the composer (draft is auto-saved). */ export async function closeComposer(page: Page): Promise { await page.keyboard.press('Escape'); await page.locator('[data-testid="email-composer"]').waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}); } /** Recipient chips currently shown in the composer's To field. */ export async function composerRecipients(page: Page): Promise { const to = page.locator('[data-testid="composer-to"]'); const text = (await to.innerText()).toLowerCase(); return text.split(/\s+/).filter((t) => t.includes('@')); } /** * The sender addresses the composer's From control offers. * * With more than one identity the control is a to be * rendered. Scrolls it into view first so the change is captured on video. */ export async function selectComposerFrom(page: Page, emailNeedle: string): Promise { const from = page.locator('[data-testid="composer-from"]').first(); await from.scrollIntoViewIfNeeded(); const value = await from.locator('option', { hasText: emailNeedle }).first().getAttribute('value'); if (!value) throw new Error(`No composer From option matching "${emailNeedle}"`); await from.selectOption(value); } /** The display text of the currently selected From sender. */ export async function selectedComposerFrom(page: Page): Promise { const from = page.locator('[data-testid="composer-from"]').first(); return (await from.locator('option:checked').first().textContent())?.trim() ?? ''; } /** Locator for an email row by (exact) subject. */ export function emailItem(page: Page, subject: string): Locator { return page.locator(`[data-testid="email-list-item"][data-subject="${subject}"]`); } /** Poll until an email with `subject` is present in the list. */ export async function expectEmailVisible(page: Page, subject: string, timeout = 20000): Promise { 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 { 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 { 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(); }