diff --git a/.github/workflows/README.md b/.github/workflows/README.md index df3319c..b02892e 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -5,6 +5,7 @@ GitHub Actions 工作流目录。 ## ✅ 放什么 - CI 验证 - 构建检查 +- `e2e.yml` 分别运行快速页面 smoke 与真实本地 Worker/D1/R2 业务回归,并上传浏览器和 Worker 证据 - 发布自动化 ## 🚫 不放什么 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 86670eb..8b5c0a2 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -52,3 +52,42 @@ jobs: path: apps/web/test-results if-no-files-found: ignore retention-days: 7 + + business-playwright: + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + + - name: Setup pnpm + uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa + with: + version: 10.18.2 + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 22 + cache: pnpm + + - name: Install workspace dependencies + run: pnpm install --frozen-lockfile + + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + + - name: Run isolated Worker and D1 business E2E + run: pnpm test:e2e:business + + - name: Upload business E2E evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: business-playwright-evidence + path: | + apps/web/playwright-report/business + apps/web/test-results/business + if-no-files-found: ignore + retention-days: 7 diff --git a/CHANGELOG.md b/CHANGELOG.md index ddb78d8..cc5b8e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Preserved canonical notification events independently from chat-platform request bodies so failed Slack, Discord, Feishu, and WeCom deliveries replay with complete mail and extraction content; added bounded response reading, request timeouts, platform acknowledgement validation, and explicit errors for legacy chat records without replay data (D1 migration 0024). +- Brought the local database schema snapshot up to date with inbound Message-ID, notification channels, and retention/idempotency indexes; added empty-database and existing-delivery migration regression coverage. +- Restored background accessibility during the same render commit that closes a modal, and cleaned up API key clipboard feedback timers on unmount so route transitions do not leave the page inert or trigger delayed updates. + +### Added + +- Added owner-scoped notification status and replay controls in Webhook/Telegram, administrator notification aggregates, explainable rule dry-runs, and explicit IANA quiet-hours timezones while preserving UTC for existing rules (D1 migration 0026). +- Added durable inbound notification tasks committed with mail metadata, conditional lease-based claiming, bounded exponential retries with Retry-After, stable event IDs, owner-scoped replay, and expiration/deletion cleanup (D1 migration 0025). Minute-level notification recovery runs independently from hourly mail retention cleanup. +- Added isolated real Worker/D1/R2 business E2E with browser registration and mailbox creation, inbound extraction, API key lifecycle, permissions, quotas, Message-ID deduplication, HTML attachments, webhook replay, and controlled outbound retries; added a separate CI job and staging external-service validation checklist. +- Added a next-development roadmap grounded in the current 0.4.0 implementation, prioritizing chat-channel replay fixes, real Worker/D1 business E2E, recoverable notification delivery, outbound status handling, extraction quality evaluation, and first-use guidance with explicit acceptance criteria. + +### Changed + +- Normalized roadmap and validation-report formatting so the committed documents pass whitespace validation. +- Corrected implementation-versus-verification documentation, backup/restore binding instructions, test coverage descriptions, and unmeasured deployment-time claims on the landing page. +- Rebuilt the landing hero around demonstrating the product instead of describing it: an auto-playing inbox demo (mail arrives → verification code extracted → copied), an interactive extraction playground running the real Worker-side extraction engine client-side on pasted text, open-source/self-host/edge positioning pills, honest CTAs (申请邀请 + 自托管部署 instead of 立即开始 hitting the invite wall), and the rotating headline word now carries the brand accent. +- Landing content honesty: key metrics describe automated tests, separate staging/production environments, Cloudflare edge deployment, and the open-source license; the testimonials section is labeled 场景示例 with workflow scenarios instead of fabricated personas. +- Added a blanket prefers-reduced-motion guard covering all landing-page animations. + ## [0.4.0] - 2026-09-06 ### Added diff --git a/README.md b/README.md index 8037851..1d918c7 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

- Version + Version License Node pnpm @@ -21,6 +21,18 @@ `WeMail` 是一个基于 pnpm monorepo 的 disposable email 服务,提供临时邮箱、邮件收取、发件控制、API Key、公告、Webhook、Telegram 通知与管理员后台能力。 +项目版本以根 [`package.json`](package.json) 为准;切版时使用 `pnpm version:sync` 同步 workspace、共享版本常量与 OpenAPI,并更新本页徽章。 + +## 能力与验证范围 + +| 状态 | 说明 | 当前范围 | +| --- | --- | --- | +| 已实现 | 源码中存在接口、业务逻辑或界面 | 收件、提取、通知规则、聊天渠道、发件、权限与管理员设置 | +| 已联调 | 指定环境中的组件协同有可复现测试结果 | `pnpm test:e2e:business` 连接真实本地 Worker/D1/R2 与浏览器,外部供应商为受控适配器;快速 smoke 保留模拟 API,范围见[测试策略](docs/testing-strategy.md) | +| 已上线验收 | 指定 commit、环境与真实服务有验收记录 | 需要按[发布手册](docs/deploy-runbook.md)保存真实 Email Routing、通知平台、Resend 与权限验证证据;本页不代表所有外部链路均已验收 | + +`GET /api/users/commercial` 当前返回部署级用量、套餐展示和一个 `default` 组织汇总;独立团队、成员关系与共享邮箱授权仍需后续开发。系统设置中的历史「运维中心」「成熟度总览」「可靠性后台」面板已移除,`/system/operations` 转到系统设置;相关管理员 API 保留。后续交付顺序见[开发计划](docs/plans/2026-10-03-next-development-roadmap.md)。 + ## 仓库结构 ```text @@ -100,6 +112,7 @@ pnpm test:shared pnpm test:worker:integration pnpm test:web:integration pnpm test:e2e +pnpm test:e2e:business ``` 首次运行 E2E 前安装浏览器: @@ -331,6 +344,8 @@ curl -i "$PRODUCTION_API_BASE_URL/api/system/health" 只在 GitHub Actions 不可用或明确需要手动操作时使用。 +Actions 的绑定注入不会在本地自动发生。手动部署前按[备份恢复手册的私有配置步骤](docs/backup-restore-runbook.md#22-准备私有-wrangler-配置)准备 `.wrangler/wrangler.recovery.toml`,填入目标环境的真实 D1/KV ID,并核对路由、R2 bucket、Cookie 与 CORS。公开 `wrangler.toml` 继续保留占位值。构建前按目标环境设置 `VITE_API_BASE_URL`;只有已配置同域 `/api/*` Worker 路由时才可省略。 + staging: ```bash @@ -340,8 +355,8 @@ pnpm typecheck pnpm lint pnpm build cd apps/worker -pnpm exec wrangler d1 migrations apply wemail-staging --env staging --remote -pnpm exec wrangler deploy --env staging +pnpm exec wrangler d1 migrations apply DB --env staging --remote --config .wrangler/wrangler.recovery.toml +pnpm exec wrangler deploy --env staging --config .wrangler/wrangler.recovery.toml cd ../.. pnpm exec wrangler pages deploy apps/web/dist --project-name= --branch=staging ``` @@ -355,8 +370,8 @@ pnpm typecheck pnpm lint pnpm build cd apps/worker -pnpm exec wrangler d1 migrations apply wemail-production --env production --remote -pnpm exec wrangler deploy --env production +pnpm exec wrangler d1 migrations apply DB --env production --remote --config .wrangler/wrangler.recovery.toml +pnpm exec wrangler deploy --env production --config .wrangler/wrangler.recovery.toml cd ../.. pnpm exec wrangler pages deploy apps/web/dist --project-name= ``` @@ -395,4 +410,4 @@ D1 migration 如果已经写入生产,不能盲目回滚代码后假设数据 ## 致谢 -![认可linux.do](https://ld.xh.do/ld-badge.svg) \ No newline at end of file +![认可linux.do](https://ld.xh.do/ld-badge.svg) diff --git a/apps/web/e2e-business/README.md b/apps/web/e2e-business/README.md new file mode 100644 index 0000000..97bb395 --- /dev/null +++ b/apps/web/e2e-business/README.md @@ -0,0 +1,7 @@ +# 真实本地业务回归 + +运行 `pnpm test:e2e:business`,无需手动启动服务。测试实际连接生产路由、D1 本地数据库、R2 本地附件存储与浏览器页面,没有 `page.route()` API 替身。外部供应商使用 Worker 测试入口里的受控适配器。 + +`BUSINESS_TEST_TOKEN` 由 runner 临时生成;邀请码、用户、邮箱和消息都在隔离数据库中。测试结束时删除临时配置与存储,日志/失败截图/trace 保存在已忽略的 `apps/web/test-results/business` 中。 + +Browser 插件与其 browser skill 未在当前会话中提供,本批次按前端测试技能使用项目既有 Playwright。验证目标为“真实邀请码注册 → 建邮箱 → 收件与多值提取 → API Key 与权限”,同时断言无页面运行时异常并保存收件页截图。 diff --git a/apps/web/e2e-business/mail-workflow.spec.ts b/apps/web/e2e-business/mail-workflow.spec.ts new file mode 100644 index 0000000..4d30b3c --- /dev/null +++ b/apps/web/e2e-business/mail-workflow.spec.ts @@ -0,0 +1,238 @@ +import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; + +const apiUrl = process.env.BUSINESS_API_URL ?? "http://127.0.0.1:8789"; +const testHeaders = { authorization: `Bearer ${process.env.BUSINESS_TEST_TOKEN}` }; +const password = "business-password-123"; + +async function driver(request: APIRequestContext, path: string, data: unknown) { + const response = await request.post(`${apiUrl}/__test/${path}`, { headers: testHeaders, data }); + expect(response.ok(), await response.text()).toBe(true); + return response.json(); +} + +async function register(request: APIRequestContext, index: number) { + const email = `business-member-${index}@example.com`; + const response = await request.post(`${apiUrl}/api/auth/register`, { + data: { email, password, name: `Member ${index}`, inviteCode: `BUSINESS-${index}` } + }); + expect(response.status(), await response.text()).toBe(201); + return email; +} + +async function createMailbox(request: APIRequestContext, label: string) { + const response = await request.post(`${apiUrl}/api/accounts`, { data: { label, domain: "example.com" } }); + expect(response.status(), await response.text()).toBe(201); + return (await response.json()).mailbox as { id: string; address: string }; +} + +async function receive(request: APIRequestContext, mailbox: { address: string }, id: string, body = "验证码 654321,10 分钟内有效。Verify your login: https://example.net/verify/token") { + return driver(request, "inbound", { + to: mailbox.address, + raw: ["From: Security ", `To: ${mailbox.address}`, `Message-ID: <${id}@example.net>`, "Subject: Business verification", "Content-Type: text/plain; charset=utf-8", "", body].join("\r\n") + }); +} + +async function createKey(request: APIRequestContext, scopes: string[]) { + const response = await request.post(`${apiUrl}/api/api-keys`, { data: { label: "Business integration", scopes } }); + expect(response.status(), await response.text()).toBe(201); + return (await response.json()).key as { id: string; secret: string }; +} + +async function registerInBrowser(page: Page) { + await page.goto("/register?next=/accounts/list"); + await page.getByRole("textbox", { name: "用户名", exact: true }).fill("Business Member"); + await page.getByRole("textbox", { name: "邮箱", exact: true }).fill("business-member-1@example.com"); + await page.locator("#register-password").fill(password); + await page.getByRole("textbox", { name: "邀请码", exact: true }).fill("BUSINESS-1"); + await page.getByRole("button", { name: "立即注册", exact: true }).click(); + await expect(page).toHaveURL(/\/accounts\/list$/); +} + +test("registers a member, creates a mailbox in the browser, reads real inbound mail and revokes an API key", async ({ page, playwright }, testInfo) => { + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + let expectedAuthRejections = 0; + page.on("pageerror", (error) => pageErrors.push(error.message)); + page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); }); + page.on("response", (response) => { if (response.status() === 401 && new URL(response.url()).pathname === "/api/auth/session") expectedAuthRejections += 1; }); + await registerInBrowser(page); + await expect(page.getByRole("button", { name: "新建账号", exact: true })).toBeVisible(); + await page.context().clearCookies(); + await page.goto("/login?next=/accounts/list"); + await page.getByRole("textbox", { name: "邮箱", exact: true }).fill("business-member-1@example.com"); + await page.locator("#login-password").fill(password); + await page.getByRole("button", { name: "立即登录", exact: true }).click(); + await expect(page).toHaveURL(/\/accounts\/list$/); + await expect(page.getByRole("button", { name: "新建账号", exact: true })).toBeVisible(); + const sessionResponse = await page.evaluate(async (url) => { + const response = await fetch(`${url}/api/auth/session`, { credentials: "include" }); + return { status: response.status, body: await response.json() }; + }, apiUrl); + expect(sessionResponse.status).toBe(200); + const session = sessionResponse.body; + expect(session.user.role).toBe("member"); + await page.getByRole("button", { name: "新建账号", exact: true }).click(); + const dialog = page.getByRole("dialog", { name: "创建新账号" }); + await dialog.getByLabel("账号标签", { exact: true }).fill("Business Inbox"); + await dialog.getByRole("combobox", { name: "邮箱域名", exact: true }).click(); + await page.getByRole("option", { name: "example.com", exact: true }).click(); + const creation = page.waitForResponse((response) => response.url() === `${apiUrl}/api/accounts` && response.request().method() === "POST"); + await dialog.getByRole("button", { name: "创建账号", exact: true }).click(); + const createdResponse = await creation; + expect(createdResponse.status()).toBe(201); + const mailbox = (await createdResponse.json()).mailbox; + await expect(dialog).not.toBeVisible(); + await expect(page.getByRole("row").filter({ hasText: mailbox.address })).toBeVisible(); + const browserApi = await playwright.request.newContext({ storageState: await page.context().storageState() }); + const mailboxes = await (await browserApi.get(`${apiUrl}/api/accounts`)).json(); + expect(mailboxes.mailboxes.map((item: { id: string }) => item.id)).toContain(mailbox.id); + const received = await receive(page.request, mailbox, "browser-first"); + await page.goto("/mail/list"); + await expect(page).toHaveTitle(/邮件列表.*WeMail/); + await expect(page.getByText("Business verification", { exact: true }).first()).toBeVisible(); + await page.getByText("Business verification", { exact: true }).first().click(); + await expect(page.getByRole("region", { name: "邮件识别结果" }).or(page.locator('[aria-label="邮件识别结果"]'))).toContainText("654321"); + await expect(page.locator('[aria-label="其他提取结果"]')).toContainText("https://example.net/verify/token"); + await expect(page.locator('[aria-label="阅读与提取详情"]')).toContainText("10 分钟内有效"); + const screenshot = testInfo.outputPath("inbox.png"); + await page.screenshot({ path: screenshot, fullPage: false }); + await testInfo.attach("real-inbox", { path: screenshot, contentType: "image/png" }); + expect(pageErrors).toEqual([]); + const authConsoleErrors = consoleErrors.filter((message) => message.includes("401 (Unauthorized)")); + expect(authConsoleErrors.length).toBeLessThanOrEqual(expectedAuthRejections); + expect(consoleErrors.filter((message) => !message.includes("401 (Unauthorized)"))).toEqual([]); + await testInfo.attach("browser-console", { body: JSON.stringify({ consoleErrors, pageErrors, expectedAuthRejections }), contentType: "application/json" }); + const key = await createKey(browserApi, ["mail:read"]); + expect(key.secret).toBeTruthy(); + const keyClient = await playwright.request.newContext({ extraHTTPHeaders: { authorization: `Bearer ${key.secret}` } }); + try { + const list = await keyClient.get(`${apiUrl}/api/mail/messages?accountId=${mailbox.id}`); + expect(list.status()).toBe(200); + expect((await list.json()).messages[0].id).toBe(received.message.id); + expect((await browserApi.delete(`${apiUrl}/api/api-keys/${key.id}`)).status()).toBe(200); + expect((await keyClient.get(`${apiUrl}/api/mail/messages?accountId=${mailbox.id}`)).status()).toBe(401); + } finally { await keyClient.dispose(); await browserApi.dispose(); } +}); + +test("rejects unauthenticated test drivers and invalid invites", async ({ request }) => { + const driverResponse = await request.post(`${apiUrl}/__test/inbound`, { data: { to: "nobody@example.com", raw: "" } }); + expect(driverResponse.status()).toBe(403); + const registration = await request.post(`${apiUrl}/api/auth/register`, { data: { email: "invalid-invite@example.com", password, name: "Invalid invite", inviteCode: "INVALID-BUSINESS-INVITE" } }); + expect(registration.status()).toBe(403); + expect(registration.headers()["set-cookie"]).toBeUndefined(); +}); + +test("enforces ownership, API scopes and per-user call quotas in D1", async ({ request, playwright }) => { + const email = await register(request, 2); + const mailbox = await createMailbox(request, "Permission inbox"); + const inbound = await receive(request, mailbox, "permissions"); + const other = await playwright.request.newContext(); + const scoped = await playwright.request.newContext(); + try { + await register(other, 3); + expect((await other.get(`${apiUrl}/api/mail/messages/${inbound.message.id}`)).status()).toBe(404); + const key = await createKey(request, ["mail:read"]); + const headers = { authorization: `Bearer ${key.secret}` }; + expect((await scoped.post(`${apiUrl}/api/mail/send`, { headers, data: { mailboxId: mailbox.id, toAddress: "recipient@example.net", subject: "Forbidden", bodyText: "No send scope" } })).status()).toBe(403); + expect((await scoped.get(`${apiUrl}/api/notification/deliveries`, { headers })).status()).toBe(403); + await driver(request, "quota", { email, apiDailyLimit: 1 }); + expect((await scoped.get(`${apiUrl}/api/mail/messages?accountId=${mailbox.id}`, { headers })).status()).toBe(200); + expect((await scoped.get(`${apiUrl}/api/mail/messages?accountId=${mailbox.id}`, { headers })).status()).toBe(429); + } finally { await other.dispose(); await scoped.dispose(); } +}); + +test("deduplicates Message-ID, preserves distinct mail and stores HTML attachments in R2", async ({ request }) => { + await register(request, 4); + const mailbox = await createMailbox(request, "MIME inbox"); + const first = await receive(request, mailbox, "duplicate"); + const duplicate = await receive(request, mailbox, "duplicate"); + expect(duplicate.message.id).toBe(first.message.id); + await receive(request, mailbox, "distinct"); + const mime = [ + "From: Security ", `To: ${mailbox.address}`, "Message-ID: ", "Subject: HTML attachment", + 'MIME-Version: 1.0', 'Content-Type: multipart/mixed; boundary="business-boundary"', "", "--business-boundary", + 'Content-Type: text/html; charset="utf-8"', "", '

Your verification code is 482914.

', "--business-boundary", + 'Content-Type: text/plain; name="note.txt"', 'Content-Disposition: attachment; filename="note.txt"', 'Content-Transfer-Encoding: base64', "", "YnVzaW5lc3MtYXR0YWNobWVudA==", "--business-boundary--" + ].join("\r\n"); + const { message } = await driver(request, "inbound", { to: mailbox.address, raw: mime }); + const detail = await (await request.get(`${apiUrl}/api/mail/messages/${message.id}`)).json(); + expect(detail.message.bodyText).toContain("482914"); + expect(detail.message.attachments).toHaveLength(1); + const attachment = await request.get(`${apiUrl}/api/mail/messages/${message.id}/attachments/${detail.message.attachments[0].id}`); + expect(attachment.status()).toBe(200); + expect(await attachment.text()).toBe("business-attachment"); + const messages = await (await request.get(`${apiUrl}/api/mail/messages?accountId=${mailbox.id}`)).json(); + expect(messages.total).toBe(3); +}); + +test("shows notification failures, replays from the UI and explains rule matches", async ({ request, page }) => { + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + page.on("pageerror", (error) => pageErrors.push(error.message)); + page.on("console", (message) => { if (message.type() === "error" && !message.text().includes("401 (Unauthorized)")) consoleErrors.push(message.text()); }); + await register(request, 5); + const mailbox = await createMailbox(request, "Notifications inbox"); + const endpoint = await request.post(`${apiUrl}/api/webhook/endpoints`, { data: { name: "Business webhook", url: "https://hooks.business.test/inbound", events: ["message.extracted"], enabled: true } }); + expect(endpoint.status()).toBe(201); + await driver(request, "providers", { webhookFailures: 1 }); + await receive(request, mailbox, "webhook-recovery"); + const deliveries = await (await request.get(`${apiUrl}/api/webhook/deliveries?status=failed`)).json(); + expect(deliveries.deliveries).toHaveLength(1); + expect(deliveries.deliveries[0].payload.data.extraction.value).toBe("654321"); + const tasks = await (await request.get(`${apiUrl}/api/notification/deliveries`)).json(); + expect(tasks.summary.counts.retrying).toBe(1); + const rule = await request.post(`${apiUrl}/api/notification/rules`, { data: { name: "Verification only", target: "webhook", eventTypes: ["message.extracted"], keyword: "verification", quietHoursTimezone: "Asia/Shanghai" } }); + expect(rule.status()).toBe(201); + await page.goto("/login?next=/webhook"); + await page.getByRole("textbox", { name: "邮箱", exact: true }).fill("business-member-5@example.com"); + await page.locator("#login-password").fill(password); + await page.getByRole("button", { name: "立即登录", exact: true }).click(); + await expect(page).toHaveURL(/\/webhook$/); + const panel = page.getByRole("region", { name: "通知状态", exact: true }); + await expect(panel.getByText("HTTP 503: Controlled webhook outage", { exact: true })).toBeVisible(); + await panel.getByRole("button", { name: `重试通知 ${tasks.deliveries[0].id}` }).click(); + await expect(panel.getByText("投递成功 · 本轮已尝试 1 次", { exact: true })).toBeVisible(); + const tester = page.getByRole("region", { name: "通知规则试运行" }); + await tester.getByRole("textbox", { name: "测试邮件内容" }).fill("Other mail"); + await tester.getByRole("button", { name: "测试通知规则" }).click(); + await expect(tester.getByText("此事件未通过通知规则", { exact: true })).toBeVisible(); + await expect(tester.getByText(/事件未匹配、关键词未匹配/)).toBeVisible(); + await panel.screenshot({ path: "/tmp/wemail-notification-status-desktop.png" }); + await page.setViewportSize({ width: 375, height: 812 }); + await expect(panel.getByText("投递成功 · 本轮已尝试 1 次", { exact: true })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + await panel.screenshot({ path: "/tmp/wemail-notification-status-mobile.png" }); + expect(pageErrors).toEqual([]); + expect(consoleErrors).toEqual([]); + const provider = await request.get(`${apiUrl}/__test/providers`, { headers: testHeaders }); + const attempts = (await provider.json()).requests; + expect(attempts).toHaveLength(2); + expect(JSON.parse(attempts[1].body).data.extraction.value).toBe("654321"); +}); + +test("recovers a failed notification automatically when its scheduled retry becomes due", async ({ request }) => { + await register(request, 7); + const mailbox = await createMailbox(request, "Automatic recovery"); + expect((await request.post(`${apiUrl}/api/webhook/endpoints`, { data: { name: "Recovery webhook", url: "https://hooks.business.test/recovery", events: ["message.extracted"], enabled: true } })).status()).toBe(201); + await driver(request, "providers", { webhookFailures: 1 }); + await receive(request, mailbox, "automatic-recovery"); + const before = await (await request.get(`${apiUrl}/api/notification/deliveries`)).json(); + expect(before.deliveries[0].status).toBe("retrying"); + await driver(request, "dispatch", { now: before.deliveries[0].nextAttemptAt }); + const after = await (await request.get(`${apiUrl}/api/notification/deliveries`)).json(); + expect(after.deliveries[0]).toMatchObject({ eventId: before.deliveries[0].eventId, status: "succeeded", attempts: 2 }); +}); + +test("sends with the real outbound route and enforces D1 quota after provider retry", async ({ request }) => { + const email = await register(request, 6); + const mailbox = await createMailbox(request, "Outbound inbox"); + await driver(request, "providers", { resendFailures: 1 }); + const payload = { mailboxId: mailbox.id, toAddress: "recipient@example.net", subject: "Business outbound", bodyText: "A controlled outbound test" }; + const response = await request.post(`${apiUrl}/api/mail/send`, { data: payload }); + expect(response.status(), await response.text()).toBe(200); + const outbound = await (await request.get(`${apiUrl}/api/mail/outbound?accountId=${mailbox.id}`)).json(); + expect(outbound.messages[0].subject).toBe(payload.subject); + expect(outbound.messages[0].status).toBe("sent"); + await driver(request, "quota", { email, dailyLimit: 0 }); + expect((await request.post(`${apiUrl}/api/mail/send`, { data: payload })).status()).toBe(403); +}); diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md index 0b38dc7..c333bcf 100644 --- a/apps/web/e2e/README.md +++ b/apps/web/e2e/README.md @@ -1,6 +1,6 @@ # 🧪 e2e -前端端到端测试目录。 +前端浏览器 smoke 与设计系统结构测试目录。 ## ✅ 放什么 - Playwright 场景测试 @@ -12,17 +12,24 @@ ## 📍 当前状态 -当前目录先提供骨架,后续可逐步补充: -- 登录 / 注册流程 -- mailbox 创建 -- inbox 读取 -- admin 基本操作 -- `/design-system` 结构与视觉回归脚手架 +`smoke.spec.ts` 已覆盖首页、登录跳转与返回目标、成员工作台、邮件/账号设置、旧路由跳转、管理员用户与 dashboard 页面、公告查看与发布入口。`design-system.spec.ts` 覆盖公开 `/design-system` 的分组组件结构。 + +当前配置只启动 Vite preview,业务 API 使用 `page.route()` 模拟。连接真实本地 Worker/D1/R2 的业务 E2E 位于 `../e2e-business`,使用独立 runner 与数据准备:`pnpm test:e2e:business`;外部供应商为受控适配器,真实 staging 外部链路的验证范围见[测试策略](../../../docs/testing-strategy.md)与[验收清单](../../../docs/staging-validation.md)。 + +在仓库根目录运行: + +```bash +pnpm test:e2e:install +pnpm --dir apps/web exec vite build +pnpm test:e2e +``` + +Playwright 配置启动 `http://127.0.0.1:4173` 的 preview,复用已有服务器;运行前确认该端口是当前构建产物。CI 会先构建 Web,再执行测试并上传报告与 raw test results。 ## 📏 约定 - 端到端测试优先验证“用户路径”,不是内部实现细节 -- 当前阶段允许通过 `page.route()` 做 API mock,先保证前端壳层和交互路径稳定 -- 更接近真实后端联调的 e2e 可在后续独立 staging 环境中补充 +- 模拟 API 的页面回归和真实业务验证分别声明边界,不把 mock 响应当作后端或外部服务验收证据 +- 新增真实业务 E2E 后同步配置、运行命令、数据隔离与清理说明 - `design-system.spec.ts` 默认只跑结构断言;截图基线需在预览页示例稳定后,通过 `PW_UPDATE_DESIGN_SYSTEM_SNAPSHOTS=1` 显式开启 - `/design-system` 现在是公开页面;相关 e2e 不要求先登录或挂后台壳层 diff --git a/apps/web/playwright.business.config.ts b/apps/web/playwright.business.config.ts new file mode 100644 index 0000000..5d5459e --- /dev/null +++ b/apps/web/playwright.business.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e-business", + workers: 1, + retries: 0, + timeout: 30_000, + outputDir: "test-results/business", + reporter: [["list"], ["html", { outputFolder: "playwright-report/business", open: "never" }]], + use: { + baseURL: process.env.BUSINESS_WEB_URL ?? "http://127.0.0.1:4187", + headless: true, + trace: "retain-on-failure", + screenshot: "only-on-failure" + } +}); diff --git a/apps/web/src/features/landing/HeroShowcase.tsx b/apps/web/src/features/landing/HeroShowcase.tsx new file mode 100644 index 0000000..91a57be --- /dev/null +++ b/apps/web/src/features/landing/HeroShowcase.tsx @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useState } from "react"; +import { Check, Copy, Inbox, KeyRound, Link2, Timer } from "lucide-react"; + +import { buildMessageExtraction, type ExtractionResult } from "@wemail/shared"; + +// --------------------------------------------------------------------------- +// Hero showcase: the landing page's job is to demonstrate the product, not +// describe it. The demo card auto-plays the core loop (mail arrives → code +// extracted → copied); the playground lets visitors paste any email text and +// watch the real extraction engine (the same pure function the Worker runs) +// work client-side. +// --------------------------------------------------------------------------- + +const DEMO_STEP_DURATIONS = [700, 1000, 1200, 1500, 1700]; +const DEMO_FINAL_STEP = DEMO_STEP_DURATIONS.length - 1; + +function useDemoStep(reducedMotion: boolean) { + const [step, setStep] = useState(0); + + useEffect(() => { + if (reducedMotion) { + setStep(DEMO_FINAL_STEP); + return; + } + + const advance = (current: number) => { + const next = current >= DEMO_FINAL_STEP ? 0 : current + 1; + setStep(next); + window.setTimeout(() => advance(next), DEMO_STEP_DURATIONS[next]); + }; + + const timer = window.setTimeout(() => advance(0), DEMO_STEP_DURATIONS[0]); + return () => window.clearTimeout(timer); + }, [reducedMotion]); + + return reducedMotion ? DEMO_FINAL_STEP : step; +} + +export function HeroDemoCard({ reducedMotion }: { reducedMotion: boolean }) { + const step = useDemoStep(reducedMotion); + + return ( +
+
+ + ops@wemail.dev + + + +
+ +
+ {step === 0 ? ( +

等待新邮件…

+ ) : ( +
+
+ GitHub + 刚刚 +
+

[WeMail] Please verify your device

+

Your verification code is 482914. It expires in 10 minutes.

+ + {step >= 2 ? ( +
+ + + + +
+ ) : null} +
+ )} +
+ +
+ + +
+
+ ); +} + +const PLAYGROUND_SAMPLE = [ + "您的验证码为 836-592,10 分钟内有效。", + "点击确认注册:https://app.example.com/verify?token=abc123", + "不希望再收到?退订:https://example.com/unsubscribe" +].join("\n"); + +const chipKindByType: Record = { + auth_code: "code", + auth_link: "link", + service_link: "link", + subscription_link: "link", + other_link: "link" +}; + +// The shared engine labels are English ("Verification code"); the landing is +// Chinese-first, so chips display a localized label. +const chipLabelByType: Record = { + auth_code: "验证码", + auth_link: "验证链接", + service_link: "服务链接", + subscription_link: "退订链接", + other_link: "有用链接" +}; + +function PlaygroundChip({ item }: { item: ExtractionResult }) { + const kind = chipKindByType[item.type] ?? "link"; + const isCode = kind === "code"; + const displayValue = isCode ? item.value : item.value.replace(/^https?:\/\//, ""); + + return ( + + {isCode ? ( + + ); +} + +export function HeroPlayground() { + const [text, setText] = useState(PLAYGROUND_SAMPLE); + const envelope = useMemo(() => buildMessageExtraction({ subject: "示例邮件", text }), [text]); + + return ( +
+
+

试一试

+

粘贴任意邮件文本,看提取引擎工作

+

+ 这就是 Worker 收信时运行的同一个提取函数——验证码、链接分类、有效期,全部在你的浏览器里实时计算。 +

+
+
+ +