diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bffb451..32cf5f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,3 +17,6 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm test + - run: pnpm build + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm test:smoke diff --git a/.gitignore b/.gitignore index 832c017..c280f51 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ # testing /coverage /src/lib/__tests__/fixtures/ +/playwright-report/ +/test-results/ +/blob-report/ # next.js /.next/ diff --git a/package.json b/package.json index 7bd4595..3b518f6 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "start": "next start", "lint": "eslint", "test": "tsx --test 'src/lib/__tests__/*.test.ts'", + "test:smoke": "playwright test", "benchmark": "tsx scripts/benchmark-rendering.ts", "fetch-fixtures": "tsx scripts/fetch-fixtures.ts" }, @@ -22,6 +23,7 @@ "react-dom": "19.2.4" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4", "@types/jsdom": "^28.0.1", "@types/node": "^20", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..8a2ff29 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,33 @@ +import { defineConfig, devices } from "@playwright/test"; + +const port = 3210; +const baseURL = `http://127.0.0.1:${port}`; + +export default defineConfig({ + testDir: "./tests/smoke", + fullyParallel: false, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL, + screenshot: "only-on-failure", + trace: "retain-on-failure", + }, + webServer: { + command: `pnpm dev --hostname 127.0.0.1 --port ${port}`, + env: { + MAIL_BROWSER_SMOKE_TESTS: "1", + }, + reuseExistingServer: false, + timeout: 120_000, + url: `${baseURL}/smoke-tests`, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19cf10b..dc7267a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,10 +16,10 @@ importers: version: 17.0.5 next: specifier: 16.2.0 - version: 16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 16.2.0(@babel/core@7.29.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-auth: specifier: ^5.0.0-beta.30 - version: 5.0.0-beta.30(next@16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + version: 5.0.0-beta.30(next@16.2.0(@babel/core@7.29.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) pino: specifier: ^10.3.1 version: 10.3.1 @@ -30,6 +30,9 @@ importers: specifier: 19.2.4 version: 19.2.4(react@19.2.4) devDependencies: + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 '@tailwindcss/postcss': specifier: ^4 version: 4.2.2 @@ -663,6 +666,11 @@ packages: '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1393,6 +1401,11 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1973,6 +1986,16 @@ packages: resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} hasBin: true + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2867,6 +2890,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@rtsao/scc@1.1.0': {} '@swc/helpers@0.5.15': @@ -3770,6 +3797,9 @@ snapshots: dependencies: is-callable: 1.2.7 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -4189,13 +4219,13 @@ snapshots: natural-compare@1.4.0: {} - next-auth@5.0.0-beta.30(next@16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + next-auth@5.0.0-beta.30(next@16.2.0(@babel/core@7.29.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): dependencies: '@auth/core': 0.41.0 - next: 16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 16.2.0(@babel/core@7.29.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 - next@16.2.0(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@16.2.0(@babel/core@7.29.0)(@playwright/test@1.61.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.2.0 '@swc/helpers': 0.5.15 @@ -4214,6 +4244,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.0 '@next/swc-win32-arm64-msvc': 16.2.0 '@next/swc-win32-x64-msvc': 16.2.0 + '@playwright/test': 1.61.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -4341,6 +4372,14 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 4.0.0 + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss@8.4.31: diff --git a/src/app/smoke-tests/SmokeHarness.tsx b/src/app/smoke-tests/SmokeHarness.tsx new file mode 100644 index 0000000..ff948b2 --- /dev/null +++ b/src/app/smoke-tests/SmokeHarness.tsx @@ -0,0 +1,165 @@ +"use client"; + +import Link from "next/link"; +import AttachmentList from "@/components/AttachmentList"; +import Composer from "@/components/Composer"; +import EmailListPanel from "@/components/EmailListPanel"; +import type { Email, EmailBodyPart } from "@/lib/types"; + +export type SmokePanel = "inbox" | "reply" | "attachments" | "target"; + +const fixtureEmails: Email[] = [ + { + id: "email-maya", + messageId: ["message-maya@example.test"], + threadId: "thread-maya", + mailboxIds: { "mailbox-inbox": true }, + subject: "Quarterly plan", + from: [{ name: "Maya Chen", email: "maya@example.test" }], + to: [{ name: "Phillip Carter", email: "phillip@example.test" }], + cc: null, + replyTo: null, + inReplyTo: null, + receivedAt: "2026-07-24T16:30:00.000Z", + preview: "The revised plan is ready for review.", + bodyValues: {}, + htmlBody: [], + textBody: [], + attachments: [], + hasAttachment: false, + keywords: {}, + size: 1840, + }, + { + id: "email-release", + messageId: ["message-release@example.test"], + threadId: "thread-release", + mailboxIds: { "mailbox-inbox": true }, + subject: "Release notes", + from: [{ name: "Noah Williams", email: "noah@example.test" }], + to: [{ name: "Phillip Carter", email: "phillip@example.test" }], + cc: null, + replyTo: null, + inReplyTo: null, + receivedAt: "2026-07-24T15:00:00.000Z", + preview: "A short summary of what shipped today.", + bodyValues: {}, + htmlBody: [], + textBody: [], + attachments: [], + hasAttachment: false, + keywords: { "$seen": true }, + size: 1220, + }, +]; + +const fixtureAttachments: EmailBodyPart[] = [ + { + blobId: "blob-sheet", + name: "March water bills.xlsx", + size: 24_576, + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + disposition: "attachment", + cid: "historical-file-id", + }, + { + blobId: "blob-inline-logo", + name: "tracking-logo.png", + size: 512, + type: "image/png", + disposition: "inline", + cid: "inline-logo", + }, +]; + +const navItems: Array<{ panel: SmokePanel; label: string }> = [ + { panel: "inbox", label: "Inbox" }, + { panel: "reply", label: "Reply" }, + { panel: "attachments", label: "Attachments" }, +]; + +export default function SmokeHarness({ panel }: { panel: SmokePanel }) { + return ( +
+
+

+ Mail reliability checks +

+ +
+ +
+ {panel === "inbox" && ( + + )} + + {panel === "reply" && ( + On July 24, 2026, Maya Chen wrote:\n>\n> The revised plan is ready for review." + } + inReplyToId="message-maya@example.test" + replyThreadId="thread-maya" + /> + )} + + {panel === "attachments" && ( +
+

+ Historical message +

+

+ The explicit spreadsheet attachment should remain visible. +

+ +
+ )} + + {panel === "target" && ( +
+

+ Navigation completed. +

+
+ )} +
+
+ ); +} diff --git a/src/app/smoke-tests/page.tsx b/src/app/smoke-tests/page.tsx new file mode 100644 index 0000000..3b9a3c0 --- /dev/null +++ b/src/app/smoke-tests/page.tsx @@ -0,0 +1,27 @@ +import { notFound } from "next/navigation"; +import SmokeHarness, { type SmokePanel } from "./SmokeHarness"; + +interface Props { + searchParams: Promise<{ panel?: string }>; +} + +const smokePanels = new Set([ + "inbox", + "reply", + "attachments", + "target", +]); + +export default async function SmokePage({ searchParams }: Props) { + if (process.env.MAIL_BROWSER_SMOKE_TESTS !== "1") { + notFound(); + } + + const requestedPanel = (await searchParams).panel; + const panel = + requestedPanel && smokePanels.has(requestedPanel as SmokePanel) + ? (requestedPanel as SmokePanel) + : "inbox"; + + return ; +} diff --git a/src/app/smoke-tests/thread/[threadId]/page.tsx b/src/app/smoke-tests/thread/[threadId]/page.tsx new file mode 100644 index 0000000..5211d54 --- /dev/null +++ b/src/app/smoke-tests/thread/[threadId]/page.tsx @@ -0,0 +1,21 @@ +import { notFound } from "next/navigation"; + +interface Props { + params: Promise<{ threadId: string }>; +} + +export default async function SmokeThreadPage({ params }: Props) { + if (process.env.MAIL_BROWSER_SMOKE_TESTS !== "1") { + notFound(); + } + + const { threadId } = await params; + + return ( +
+

+ Opened conversation {threadId} +

+
+ ); +} diff --git a/src/components/EmailListPanel.tsx b/src/components/EmailListPanel.tsx index 59d0a9a..a3c5134 100644 --- a/src/components/EmailListPanel.tsx +++ b/src/components/EmailListPanel.tsx @@ -55,6 +55,7 @@ interface Props { spamReadTotal?: number; spamMailboxId?: string; deferredContent?: ReactNode; + threadHrefPrefix?: string; } type View = "inbox" | "drafts" | "sent" | "spam"; @@ -184,6 +185,7 @@ export default function EmailListPanel({ spamReadTotal: initialSpamReadTotal = 0, spamMailboxId, deferredContent, + threadHrefPrefix = "/thread", }: Props) { const pathname = usePathname(); const router = useRouter(); @@ -870,8 +872,8 @@ export default function EmailListPanel({ if (!confirmNavigation()) return; router.push( view === "spam" - ? `/thread/${activeThread.threadId}?from=spam` - : `/thread/${activeThread.threadId}`, + ? `${threadHrefPrefix}/${activeThread.threadId}?from=spam` + : `${threadHrefPrefix}/${activeThread.threadId}`, ); } else if ( event.key.toLowerCase() === "e" && @@ -908,6 +910,7 @@ export default function EmailListPanel({ selectedThreadId, selectionMode, shortcutHelpOpen, + threadHrefPrefix, view, visibleThreads, ]); @@ -1252,7 +1255,10 @@ export default function EmailListPanel({ !isSearching && visibleThreads.map((thread, idx) => { const { latestEmail, senders } = thread; - const threadHref = view === "spam" ? `/thread/${thread.threadId}?from=spam` : `/thread/${thread.threadId}`; + const threadHref = + view === "spam" + ? `${threadHrefPrefix}/${thread.threadId}?from=spam` + : `${threadHrefPrefix}/${thread.threadId}`; const isRouteSelected = thread.threadId === selectedThreadId || thread.latestEmail.id === selectedEmailId; diff --git a/src/middleware.ts b/src/middleware.ts deleted file mode 100644 index 75d493c..0000000 --- a/src/middleware.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { auth as middleware } from "@/auth"; - -export const config = { - matcher: ["/((?!api/auth|api/health|_next/static|_next/image|favicon\\.ico|login).*)"], -}; diff --git a/src/proxy.ts b/src/proxy.ts new file mode 100644 index 0000000..58ec9b5 --- /dev/null +++ b/src/proxy.ts @@ -0,0 +1,8 @@ +export { auth as proxy } from "@/auth"; + +export const config = { + // The smoke-test harness has its own server-side environment gate. Keeping + // it outside auth lets CI exercise the real client interactions without a + // mailbox account, while normal deployments still return a 404. + matcher: ["/((?!api/auth|api/health|_next/static|_next/image|favicon\\.ico|icon\\.svg|login|smoke-tests).*)"], +}; diff --git a/tests/smoke/mail.spec.ts b/tests/smoke/mail.spec.ts new file mode 100644 index 0000000..066d76c --- /dev/null +++ b/tests/smoke/mail.spec.ts @@ -0,0 +1,111 @@ +import { expect, test } from "@playwright/test"; + +test("serves the themed favicon without an auth redirect", async ({ + request, +}) => { + const response = await request.get("/icon.svg"); + + expect(response.status()).toBe(200); + expect(response.headers()["content-type"]).toContain("image/svg+xml"); +}); + +test("opens a conversation from a desktop click", async ({ page }) => { + await page.goto("/smoke-tests"); + + const conversation = page.locator( + 'a[href="/smoke-tests/thread/thread-maya"]', + ); + await expect(conversation).toContainText("Quarterly plan"); + + await conversation.click(); + await expect(page).toHaveURL("/smoke-tests/thread/thread-maya"); + await expect( + page.getByRole("heading", { + name: "Opened conversation thread-maya", + }), + ).toBeVisible(); +}); + +test("moves through conversations with the keyboard", async ({ page }) => { + await page.goto("/smoke-tests"); + + const announcement = page.locator( + '[aria-live="polite"][aria-atomic="true"]', + ); + + await page.keyboard.press("j"); + await expect(announcement).toHaveText( + "Selected conversation: Quarterly plan", + ); + + await page.keyboard.press("j"); + await expect(announcement).toHaveText( + "Selected conversation: Release notes", + ); + + await page.keyboard.press("k"); + await expect(announcement).toHaveText( + "Selected conversation: Quarterly plan", + ); + + await page.keyboard.press("Enter"); + await expect(page).toHaveURL("/smoke-tests/thread/thread-maya"); +}); + +test("keeps reply history intact while editing Markdown", async ({ page }) => { + await page.goto("/smoke-tests?panel=reply"); + + const subject = page.getByRole("textbox", { name: "Subject" }); + const editor = page.locator("textarea"); + + await expect(subject).toHaveValue("Re: Quarterly plan"); + await expect(editor).toHaveValue(/^Thanks, Maya\.\s*$/); + + await editor.fill("Updated **reply**"); + await page.getByRole("button", { name: "Preview" }).click(); + + const preview = page.frameLocator('iframe[title="Email preview"]'); + await expect(preview.locator("strong")).toHaveText("reply"); + + await page.getByRole("button", { name: "Write" }).click(); + await page.getByRole("button", { name: "Show quoted text" }).click(); + await expect(editor).toHaveValue(/The revised plan is ready for review\./); +}); + +test("protects an edited reply from accidental navigation", async ({ page }) => { + await page.goto("/smoke-tests?panel=reply"); + await page.locator("textarea").fill("A changed reply"); + + let prompt = ""; + page.once("dialog", async (dialog) => { + prompt = dialog.message(); + await dialog.dismiss(); + }); + await page.getByRole("link", { name: "Attachments" }).click(); + + await expect(page).toHaveURL(/panel=reply/); + expect(prompt).toBe("Leave this message? Recent changes may not be saved."); + + page.once("dialog", (dialog) => dialog.accept()); + await page.getByRole("link", { name: "Attachments" }).click(); + await expect(page.getByRole("heading", { name: "Historical message" })).toBeVisible(); +}); + +test("shows historical spreadsheet attachments", async ({ page }) => { + await page.goto("/smoke-tests?panel=attachments"); + + await expect(page.getByText("March water bills.xlsx")).toBeVisible(); + await expect(page.getByText("tracking-logo.png")).toHaveCount(0); + + await page.getByText("March water bills.xlsx").click(); + const dialog = page.getByRole("dialog", { + name: "March water bills.xlsx", + }); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByRole("link", { name: "Download", exact: true }), + ).toHaveAttribute("href", /blobId=blob-sheet/); + + await dialog.getByRole("button", { name: "Close" }).click(); + await expect(dialog).toHaveCount(0); +});