Add draft and shared-folder-move coverage (suite now 31 tests). Findings are asserted server-side or pinned with test.fail where the UI is incomplete. Drafts (07): - multiple recipients (committed and typed-but-uncommitted) persist, and the draft reopens via the continue-draft button; - a server-created draft (with $draft) shows the continue-draft button; - a changed sender identity is saved to the draft on the server; - KNOWN BUG (test.fail): reopening a draft resets the From selector to the default identity instead of the one the draft was saved with. Shared-folder moves (08): - shared -> shared (same owner) moves work in both directions (server-verified); - KNOWN LIMITATION (test.fail): cross-account moves (own account <-> shared folder) don't relocate the message — the Move-to submenu offers the target but clicking it is a no-op. Hooks added: composer From select + save-status, viewer edit-draft button, context-menu "Move to" submenu + per-target testids (testId on ContextMenuSubMenu). Helpers: JMAP identities/createDraft/sharing, composer drive + move-via-submenu. README documents the findings.
313 lines
13 KiB
TypeScript
313 lines
13 KiB
TypeScript
/**
|
|
* 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 (`<nextjs-portal>`) 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<void> {
|
|
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<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 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,
|
|
});
|
|
}
|
|
|
|
/** Fill and submit the login form (works for first login and add-account). */
|
|
async function submitCredentials(page: Page, account: TestAccount): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string | null> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
await page.locator('[data-testid="composer-from"]').selectOption({ value: identityId });
|
|
}
|
|
|
|
/** Fill the subject field. */
|
|
export async function setSubject(page: Page, subject: string): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string[]> {
|
|
const to = page.locator('[data-testid="composer-to"]');
|
|
const text = (await to.innerText()).toLowerCase();
|
|
return text.split(/\s+/).filter((t) => t.includes('@'));
|
|
}
|
|
|
|
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. */
|
|
export function folderRow(page: Page, sel: FolderSelector): Locator {
|
|
let s = '[data-testid="folder-row"]';
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Read a folder's unread/total counts. When both are zero the counts element
|
|
* is not rendered, so a missing element is reported as {0,0}.
|
|
*/
|
|
export async function folderCounts(page: Page, sel: FolderSelector): Promise<FolderCounts> {
|
|
const row = folderRow(page, sel).first();
|
|
const counts = row.locator('[data-testid="folder-counts"]');
|
|
if ((await counts.count()) === 0) return { unread: 0, total: 0 };
|
|
const unread = await counts.getAttribute('data-unread');
|
|
const total = await counts.getAttribute('data-total');
|
|
return { unread: Number(unread ?? 0), total: Number(total ?? 0) };
|
|
}
|
|
|
|
/** Poll until a folder's unread count reaches `expected`. */
|
|
export async function expectFolderUnread(page: Page, sel: FolderSelector, expected: number, timeout = 30000): Promise<void> {
|
|
await expect
|
|
.poll(async () => (await folderCounts(page, sel)).unread, { timeout })
|
|
.toBe(expected);
|
|
}
|
|
|
|
/** The JMAP (UI) mailbox id backing a folder row — namespaced for shared folders. */
|
|
export async function folderMailboxId(page: Page, sel: FolderSelector): Promise<string> {
|
|
const id = await folderRow(page, sel).first().getAttribute('data-mailbox-id');
|
|
if (!id) throw new Error(`folder ${JSON.stringify(sel)} has no data-mailbox-id`);
|
|
return id;
|
|
}
|
|
|
|
/**
|
|
* Move an email to `destMailboxId` (a UI mailbox id, e.g. from
|
|
* {@link folderMailboxId}) via the list context menu's "Move to" submenu.
|
|
*/
|
|
export async function moveEmailTo(page: Page, subject: string, destMailboxId: string): Promise<void> {
|
|
const row = emailItem(page, subject).first();
|
|
await row.waitFor({ state: 'visible' });
|
|
const submenu = page.locator('[data-testid="ctx-move-to"]');
|
|
await expect(async () => {
|
|
await row.click({ button: 'right' });
|
|
await submenu.waitFor({ state: 'visible', timeout: 2000 });
|
|
}).toPass({ timeout: 15000 });
|
|
await submenu.hover();
|
|
const target = page.locator(`[data-testid="move-to:${destMailboxId}"]`);
|
|
await target.waitFor({ state: 'visible', timeout: 5000 });
|
|
await target.click();
|
|
}
|
|
|
|
/** Poll until a folder's total count reaches `expected`. */
|
|
export async function expectFolderTotal(page: Page, sel: FolderSelector, expected: number, timeout = 30000): Promise<void> {
|
|
await expect
|
|
.poll(async () => (await folderCounts(page, sel)).total, { timeout })
|
|
.toBe(expected);
|
|
}
|
|
|
|
/** Click a folder row to select it. */
|
|
export async function openFolder(page: Page, sel: FolderSelector): Promise<void> {
|
|
await folderRow(page, sel).first().click();
|
|
}
|
|
|
|
/** 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<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();
|
|
}
|