Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
From: Frank Team <frank@work.example>
To: alice@example.com, Bob Builder <bob@work.example>, Carol Finance <carol@work.example>
Subject: Team sync recap
Date: Sun, 24 May 2026 09:30:00 +0000
Message-ID: <alice-inbox-10@example.com>
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: 8bit

Thanks all for joining the sync.

Action items are in the shared doc; please update your sections by Friday.
17 changes: 17 additions & 0 deletions e2e/fixtures/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,23 @@ export const manifest: ManifestAccount[] = [
signature: 'unsigned',
attachments: []
},
{
// Multiple recipients incl. alice herself — exercises reader reply-all
// (Cc = other To/Cc recipients, own address excluded).
slug: 'alice-inbox-10-multi-recipient',
box: 'cur',
flags: 'S',
messageId: 'alice-inbox-10@example.com',
from: 'Frank Team',
fromAddr: 'frank@work.example',
to: 'alice@example.com, Bob Builder <bob@work.example>, Carol Finance <carol@work.example>',
subject: 'Team sync recap',
date: utc(24, 9, 30),
bodyText:
'Thanks all for joining the sync.\n\nAction items are in the shared doc; please update your sections by Friday.',
signature: 'unsigned',
attachments: []
},
{
slug: 'alice-inbox-xss-01-script-tag',
box: 'cur',
Expand Down
47 changes: 47 additions & 0 deletions e2e/pages/ComposePage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/** Page object for the compose screen (incl. reply/forward prefill). */
import { expect, type Locator, type Page } from '@playwright/test';

export class ComposePage {
constructor(private readonly page: Page) {}

container(): Locator {
return this.page.getByTestId('compose.container');
}

/** Wait for the compose screen to be visible. */
async waitVisible(): Promise<void> {
await expect(this.container()).toBeVisible();
}

toInput(): Locator {
return this.page.getByTestId('compose.to-input');
}

ccInput(): Locator {
return this.page.getByTestId('compose.cc-input');
}

subjectInput(): Locator {
return this.page.getByTestId('compose.subject-input');
}

body(): Locator {
return this.page.getByTestId('compose.body');
}

async toValue(): Promise<string> {
return (await this.toInput().inputValue()) ?? '';
}

async ccValue(): Promise<string> {
return (await this.ccInput().inputValue()) ?? '';
}

async subjectValue(): Promise<string> {
return (await this.subjectInput().inputValue()) ?? '';
}

async bodyValue(): Promise<string> {
return (await this.body().inputValue()) ?? '';
}
}
19 changes: 19 additions & 0 deletions e2e/pages/MailboxPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,25 @@ export class MailboxPage {
await this.page.keyboard.press('r');
}

/** The currently selected (active) row's 0-based index, or -1 if none. */
async selectedIndex(): Promise<number> {
const active = this.messages().and(this.page.locator('.active')).first();
if ((await active.count()) === 0) return -1;
const idx = await active.getAttribute('data-msg-idx');
return idx == null ? -1 : Number(idx);
}

/** `g g` — jump selection to the top of the list. */
async jumpTop(): Promise<void> {
await this.page.keyboard.press('g');
await this.page.keyboard.press('g');
}

/** `G` — jump selection to the bottom of the list. */
async jumpBottom(): Promise<void> {
await this.page.keyboard.press('Shift+G');
}

// ── Search ─────────────────────────────────────────────────────────────────

/** Open search (via `/` key), type a query, and submit it. */
Expand Down
56 changes: 56 additions & 0 deletions e2e/pages/MessagePage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,62 @@ export class MessagePage {
await this.page.keyboard.press('Escape');
}

// ── Message actions (reply / forward / yank / headers) ──────────────────────
// openspec/changes/hotkeys-improvement/specs/reader-message-actions/spec.md

/** `r` — reply to sender (opens compose prefilled). */
async reply(): Promise<void> {
await this.page.keyboard.press('r');
}

/** `R` (Shift+r) — reply to all. */
async replyAll(): Promise<void> {
await this.page.keyboard.press('Shift+R');
}

/** `F` (Shift+f) — forward (distinct from `f` = hint mode). */
async forward(): Promise<void> {
await this.page.keyboard.press('Shift+F');
}

/** `y` — yank the message body to the clipboard. */
async yankBody(): Promise<void> {
await this.page.keyboard.press('y');
}

/** `Y` (Shift+y) — yank headers + body to the clipboard. */
async yankHeaders(): Promise<void> {
await this.page.keyboard.press('Shift+Y');
}

/** `g h` — toggle the headers popover. */
async toggleHeaders(): Promise<void> {
await this.page.keyboard.press('g');
await this.page.keyboard.press('h');
}

/** `f` — activate vimium-style hint mode (text/simple modes only). */
async activateHints(): Promise<void> {
await this.page.keyboard.press('f');
}

/** The raw-headers popover (toggled by `g h` or the headers button). */
headersPopover(): Locator {
return this.page.getByTestId('headers-popover.container');
}

/** `g f` — open the folder picker from the reader. */
async gotoFolderPicker(): Promise<void> {
await this.page.keyboard.press('g');
await this.page.keyboard.press('f');
}

/** `g a` — open the account picker from the reader. */
async gotoAccountPicker(): Promise<void> {
await this.page.keyboard.press('g');
await this.page.keyboard.press('a');
}

// ── Folder-position counter ─────────────────────────────────────────────────

/** Absolute message index number in the breadcrumb counter. */
Expand Down
84 changes: 41 additions & 43 deletions e2e/specs/hotkeys-list-leader.spec.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,67 @@
/** List-scope `g X` leader sequences resolve folder names case-insensitively. */
/** List-scope g-leader: trimmed to navigation primitives (g f / g a / g g / G). */
import { test, expect } from '../harness/fixtures.ts';
import { AccountsPage } from '../pages/AccountsPage.ts';
import { MailboxPage } from '../pages/MailboxPage.ts';
import { manifest } from '../fixtures/manifest.ts';
import { folderOf, manifest, messagesNewestFirst, PER_PAGE } from '../fixtures/manifest.ts';

const alice = manifest.find((a) => a.address === 'alice@example.com')!;
const archive = folderOf(alice, 'Archive');

async function openAtArchive(page: import('@playwright/test').Page) {
async function openArchive(page: import('@playwright/test').Page): Promise<MailboxPage> {
const accounts = new AccountsPage(page);
await accounts.open();
await accounts.select(alice.address);
const mailbox = new MailboxPage(page);
await mailbox.openFolder('Archive');
await expect(page).toHaveURL(/\/folder\/Archive/);
// Park the cursor away from the rows — list rows set selectedIdx on
// mouseenter, which would otherwise race the keyboard-driven selection.
await page.mouse.move(0, 0);
return mailbox;
}

// openspec/changes/isolate-hotkeys/specs/ui-hotkeys/spec.md: list `g i` -> Inbox
test('g i navigates to Inbox', async ({ page }) => {
await openAtArchive(page);
// openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md: g f opens the folder picker
test('g f opens the folder picker', async ({ page }) => {
await openArchive(page);
await page.keyboard.press('g');
await page.keyboard.press('i');
await expect(page).toHaveURL(/\/folder\/Inbox/);
await page.keyboard.press('f');
await expect(page.getByText('Open a folder')).toBeVisible();
});

// openspec/changes/isolate-hotkeys/specs/ui-hotkeys/spec.md: list `g a` -> Archive
test('g a navigates to Archive', async ({ page }) => {
const accounts = new AccountsPage(page);
await accounts.open();
await accounts.select(alice.address);
const mailbox = new MailboxPage(page);
await mailbox.openFolder('Inbox');
// openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md: g a opens the account picker
test('g a opens the account picker', async ({ page }) => {
await openArchive(page);
await page.keyboard.press('g');
await page.keyboard.press('a');
await expect(page).toHaveURL(/\/folder\/Archive/);
});

// openspec/changes/isolate-hotkeys/specs/ui-hotkeys/spec.md: list `g s` -> Sent
test('g s navigates to Sent', async ({ page }) => {
await openAtArchive(page);
await page.keyboard.press('g');
await page.keyboard.press('s');
await expect(page).toHaveURL(/\/folder\/Sent/);
await expect(page.getByText('Open a maildir')).toBeVisible();
});

// openspec/changes/isolate-hotkeys/specs/ui-hotkeys/spec.md: list `g f` -> Folder picker
test('g f opens the folder picker', async ({ page }) => {
await openAtArchive(page);
await page.keyboard.press('g');
await page.keyboard.press('f');
await expect(page.getByText('Open a folder')).toBeVisible();
// openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md: g g jumps to top of list
test('g g jumps the selection to the top of the list', async ({ page }) => {
const mailbox = await openArchive(page);
// Move the selection to the bottom (keyboard), then g g returns it to 0.
await mailbox.jumpBottom();
await expect.poll(() => mailbox.selectedIndex()).toBeGreaterThan(0);
await mailbox.jumpTop();
await expect.poll(() => mailbox.selectedIndex()).toBe(0);
});

// openspec/changes/isolate-hotkeys/specs/ui-hotkeys/spec.md: list `g A` -> Account picker
test('g A opens the account picker', async ({ page }) => {
await openAtArchive(page);
await page.keyboard.press('g');
await page.keyboard.press('Shift+A');
await expect(page.getByText('Open a maildir')).toBeVisible();
// openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md: G jumps to bottom of list
test('G jumps the selection to the bottom of the page', async ({ page }) => {
const mailbox = await openArchive(page);
// Archive has more messages than a page; G selects the last rendered row.
const onPage = Math.min(messagesNewestFirst(archive).length, PER_PAGE);
await mailbox.jumpBottom();
await expect.poll(() => mailbox.selectedIndex()).toBe(onPage - 1);
});

// openspec/changes/isolate-hotkeys/specs/ui-hotkeys/spec.md: list `g d` -> Drafts (silent no-op if absent)
test('g d does nothing when the account has no Drafts folder', async ({ page }) => {
await openAtArchive(page);
await page.keyboard.press('g');
await page.keyboard.press('d');
// alice has no Drafts folder in the corpus — URL stays on Archive.
await expect(page).toHaveURL(/\/folder\/Archive/);
// openspec/changes/hotkeys-improvement/specs/ui-hotkeys/spec.md: removed follow-ups no longer navigate
test('removed leaders g i / g s / g d are no-ops', async ({ page }) => {
await openArchive(page);
for (const key of ['i', 's', 'd']) {
await page.keyboard.press('g');
await page.keyboard.press(key);
// No folder navigation occurs — URL stays on Archive.
await expect(page).toHaveURL(/\/folder\/Archive/);
}
});
Loading
Loading