test(integration): draft handling and shared-folder moves
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.
This commit is contained in:
@@ -137,6 +137,52 @@ 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;
|
||||
@@ -194,6 +240,31 @@ export async function expectFolderUnread(page: Page, sel: FolderSelector, expect
|
||||
.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
|
||||
|
||||
@@ -13,6 +13,7 @@ import { JMAP_URL } from './config';
|
||||
const CORE = 'urn:ietf:params:jmap:core';
|
||||
const MAIL = 'urn:ietf:params:jmap:mail';
|
||||
const PRINCIPALS = 'urn:ietf:params:jmap:principals';
|
||||
const SUBMISSION = 'urn:ietf:params:jmap:submission';
|
||||
|
||||
/** Rights granted on a shared mailbox (JMAP ACL). */
|
||||
export const FULL_MAILBOX_RIGHTS = {
|
||||
@@ -73,6 +74,29 @@ export class JmapClient {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** All sending identities of this account. */
|
||||
async identities(): Promise<Array<{ id: string; name: string; email: string }>> {
|
||||
const r = await this.request([['Identity/get', { accountId: this.accountId }, '0']], [CORE, SUBMISSION]);
|
||||
return r.methodResponses[0][1].list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a second sending identity `name <email>` exists (idempotent by
|
||||
* name). Returns its id. Used to make the composer's From selector appear so
|
||||
* a changed sender can be exercised.
|
||||
*/
|
||||
async ensureIdentity(name: string, email: string): Promise<string> {
|
||||
const existing = (await this.identities()).find((i) => i.name === name);
|
||||
if (existing) return existing.id;
|
||||
const r = await this.request(
|
||||
[['Identity/set', { accountId: this.accountId, create: { alt: { name, email, replyTo: null } } }, '0']],
|
||||
[CORE, SUBMISSION],
|
||||
);
|
||||
const created = r.methodResponses[0][1].created?.alt;
|
||||
if (!created) throw new Error(`Identity/set failed: ${JSON.stringify(r.methodResponses[0][1])}`);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/** Resolve another user's principal id (needed as the key in `shareWith`). */
|
||||
async principalIdByEmail(email: string): Promise<string> {
|
||||
const r = await this.request(
|
||||
@@ -194,6 +218,31 @@ export class JmapClient {
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Create a draft message (with the $draft keyword) in the Drafts folder. */
|
||||
async createDraft(subject: string, toEmail: string): Promise<string> {
|
||||
const drafts = await this.mailboxByRole('drafts');
|
||||
if (!drafts) throw new Error('No Drafts mailbox');
|
||||
const r = await this.request([
|
||||
['Email/set', {
|
||||
accountId: this.accountId,
|
||||
create: {
|
||||
d: {
|
||||
mailboxIds: { [drafts.id]: true },
|
||||
keywords: { $draft: true },
|
||||
from: [{ email: this.email }],
|
||||
to: [{ email: toEmail }],
|
||||
subject,
|
||||
bodyValues: { b: { value: 'server-created draft body' } },
|
||||
textBody: [{ partId: 'b', type: 'text/plain' }],
|
||||
},
|
||||
},
|
||||
}, '0'],
|
||||
]);
|
||||
const created = r.methodResponses[0][1].created?.d;
|
||||
if (!created) throw new Error(`createDraft failed: ${JSON.stringify(r.methodResponses[0][1])}`);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
/** Set or clear the $seen keyword on an email. */
|
||||
async setSeen(emailId: string, seen: boolean): Promise<void> {
|
||||
await this.request([
|
||||
@@ -210,7 +259,7 @@ export class JmapClient {
|
||||
['Email/get', {
|
||||
accountId: this.accountId,
|
||||
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||
properties: ['id', 'subject', 'keywords', 'mailboxIds', 'from', 'preview'],
|
||||
properties: ['id', 'subject', 'keywords', 'mailboxIds', 'from', 'to', 'preview'],
|
||||
}, '1'],
|
||||
]);
|
||||
return r.methodResponses[1][1].list[0];
|
||||
|
||||
Reference in New Issue
Block a user