From d7bd667ea2eab05bea33a6d6fe1b9c67bd1ca482 Mon Sep 17 00:00:00 2001 From: openclaw Date: Sat, 1 Aug 2026 18:47:36 +0800 Subject: [PATCH] test: add first Playwright regression suite --- .gitignore | 3 + apps/web/src/components/QuickEntryDrawer.vue | 22 +++- apps/web/src/styles.css | 28 ++++- package-lock.json | 66 ++++++++++ package.json | 7 +- playwright.config.ts | 36 ++++++ skills/cents-playwright-regression/SKILL.md | 66 ++++++++++ tests/e2e/01-账号/01-登录与退出.spec.ts | 33 +++++ tests/e2e/02-账本/01-账本列表与新建.spec.ts | 23 ++++ tests/e2e/03-快速记账/01-新增账目.spec.ts | 68 ++++++++++ tests/e2e/04-流水/01-流水编辑删除.spec.ts | 37 ++++++ tests/e2e/05-离线/01-离线记账与同步.spec.ts | 47 +++++++ tests/e2e/06-多币种/01-外币异步换算.spec.ts | 39 ++++++ tests/e2e/07-报销/01-报销流程.spec.ts | 32 +++++ tests/e2e/failed-reporter.ts | 1 + tests/e2e/报告器/中文失败报告器.ts | 63 ++++++++++ tests/e2e/测试夹具.ts | 125 +++++++++++++++++++ 17 files changed, 690 insertions(+), 6 deletions(-) create mode 100644 playwright.config.ts create mode 100644 skills/cents-playwright-regression/SKILL.md create mode 100644 tests/e2e/01-账号/01-登录与退出.spec.ts create mode 100644 tests/e2e/02-账本/01-账本列表与新建.spec.ts create mode 100644 tests/e2e/03-快速记账/01-新增账目.spec.ts create mode 100644 tests/e2e/04-流水/01-流水编辑删除.spec.ts create mode 100644 tests/e2e/05-离线/01-离线记账与同步.spec.ts create mode 100644 tests/e2e/06-多币种/01-外币异步换算.spec.ts create mode 100644 tests/e2e/07-报销/01-报销流程.spec.ts create mode 100644 tests/e2e/failed-reporter.ts create mode 100644 tests/e2e/报告器/中文失败报告器.ts create mode 100644 tests/e2e/测试夹具.ts diff --git a/.gitignore b/.gitignore index 7d68e31..7f91610 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ node_modules/ dist/ backups/ backup/ +test-reports/ +test-results/ +playwright-report/ docs/prototype/ .env .env.* diff --git a/apps/web/src/components/QuickEntryDrawer.vue b/apps/web/src/components/QuickEntryDrawer.vue index beb4ee1..7041cf1 100644 --- a/apps/web/src/components/QuickEntryDrawer.vue +++ b/apps/web/src/components/QuickEntryDrawer.vue @@ -58,6 +58,14 @@ const displayAmount = computed(() => maximumFractionDigits: minorUnits.value, }).format(calculatedAmount.value), ); +const amountSizeClass = computed(() => { + const length = displayAmount.value.length; + if (length <= 8) return "amount-normal"; + if (length <= 10) return "amount-compact"; + if (length <= 13) return "amount-small"; + return "amount-tiny"; +}); +const amountNeedsFullRow = computed(() => displayAmount.value.length > 10); const currencySymbol = computed(() => currencies[currency.value].symbol); const selectedLedgerLabel = computed(() => { const visibleNames = props.ledgerOptions @@ -179,6 +187,13 @@ function startNoteEditing() { noteEditing.value = true; } +function finishNoteEditing(event: KeyboardEvent) { + if (event.isComposing) return; + event.preventDefault(); + noteEditing.value = false; + (event.currentTarget as HTMLInputElement).blur(); +} + function openLedgerPicker() { confirmCategory(); noteEditing.value = false; @@ -224,13 +239,13 @@ function saveEntry() {
-
+
-
+
- {{ displayAmount }} + {{ displayAmount }}
{{ calculationLabel }}
@@ -250,6 +265,7 @@ function saveEntry() { aria-label="备注" @focus="startNoteEditing" @blur="noteEditing = false" + @keydown.enter="finishNoteEditing" /> diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 2218e0b..612aec0 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -828,6 +828,20 @@ input:focus-visible { padding: 7px 14px 5px 18px; } +.drawer-header.long-amount { + flex-wrap: wrap; + row-gap: 2px; +} + +.drawer-header.long-amount .amount-block, +.drawer-header.long-amount .amount-result { + width: 100%; +} + +.drawer-header.long-amount .quick-ledger-target { + margin-left: auto; +} + .amount-block { min-width: 0; display: grid; @@ -910,15 +924,25 @@ input:focus-visible { .conversion-notice .spinning { animation: sync-spin .8s linear infinite; } .amount-value { + min-width: 0; + flex: 1 1 auto; overflow: hidden; color: #172421; - font-size: clamp(34px, 10vw, 44px); font-variant-numeric: tabular-nums; line-height: 1; - text-overflow: ellipsis; white-space: nowrap; } +.amount-normal { font-size: 44px; } +.amount-compact { font-size: 38px; } +.amount-small { font-size: 32px; } +.amount-tiny { font-size: 27px; } + +.amount-result.amount-normal .amount-value { font-size: 44px; } +.amount-result.amount-compact .amount-value { font-size: 38px; } +.amount-result.amount-small .amount-value { font-size: 32px; } +.amount-result.amount-tiny .amount-value { font-size: 27px; } + .calculation-line { max-width: min(72vw, 330px); overflow: hidden; diff --git a/package-lock.json b/package-lock.json index 6f71e58..3815f41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,9 @@ "services/*", "packages/*" ], + "devDependencies": { + "@playwright/test": "^1.62.1" + }, "engines": { "node": ">=22" } @@ -1015,6 +1018,22 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/@playwright/test": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", @@ -2276,6 +2295,53 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.19", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", diff --git a/package.json b/package.json index 960dab9..483ca1e 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,14 @@ "user:add": "npm run user:add --workspace @cents/api --", "build": "npm run build --workspaces --if-present", "typecheck": "npm run typecheck --workspaces --if-present", - "lint": "npm run lint --workspaces --if-present" + "lint": "npm run lint --workspaces --if-present", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui" }, "engines": { "node": ">=22" + }, + "devDependencies": { + "@playwright/test": "^1.62.1" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..5a000c4 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, devices } from "@playwright/test"; + +const runId = process.env.TEST_RUN_ID ?? (() => { + const now = new Date(); + const pad = (value: number) => String(value).padStart(2, "0"); + return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; +})(); + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: false, + timeout: 30_000, + expect: { timeout: 5_000 }, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: [["list"], ["./tests/e2e/failed-reporter.ts", { runId }]], + outputDir: `test-reports/${runId}/test-results`, + use: { + baseURL: "http://127.0.0.1:4173", + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "off", + locale: "zh-CN", + timezoneId: "Asia/Shanghai", + }, + projects: [ + { name: "手机端", use: { ...devices["Pixel 5"] } }, + { name: "桌面端", use: { ...devices["Desktop Chrome"] } }, + ], + webServer: { + command: "npm run dev --workspace @cents/web -- --host 127.0.0.1 --port 4173", + url: "http://127.0.0.1:4173", + reuseExistingServer: !process.env.CI, + timeout: 30_000, + }, +}); diff --git a/skills/cents-playwright-regression/SKILL.md b/skills/cents-playwright-regression/SKILL.md new file mode 100644 index 0000000..c80602d --- /dev/null +++ b/skills/cents-playwright-regression/SKILL.md @@ -0,0 +1,66 @@ +--- +name: cents-playwright-regression +description: 运行和维护 Cents 的中文 Playwright 回归测试,按功能分类执行测试,并将每轮失败案例、截图和 trace 归档到被 gitignore 的测试报告目录。 +--- + +# Cents Playwright 回归测试 + +## 适用场景 + +用户要求执行 Cents 的端到端回归测试、增加第一批功能案例、检查离线/同步/多币种/报销流程,或分析上一轮测试失败时使用本 skill。 + +## 目录约定 + +- 测试目录:`tests/e2e/` +- 功能目录和测试文件使用中文,并以 `01-`、`02-` 编号。 +- 每轮报告目录:`test-reports/YYYYMMDD-HHmmss/` +- 总结:`SUMMARY.md` +- 失败归档:`failures/`,保持测试案例原本目录结构。 +- 失败目录中保存 `失败说明.md`、截图和 Playwright trace。 +- 测试报告、trace、截图不得提交 Git。 + +## 执行流程 + +1. 阅读 `docs/测试策略.md` 以及涉及功能的设计文档。 +2. 检查工作区状态,不覆盖用户已有改动。 +3. 默认执行手机端和桌面端两个项目: + + ```bash + TEST_RUN_ID="$(date +%Y%m%d-%H%M%S)" npm run test:e2e + ``` + +4. 失败时不要修改业务实现代码。先阅读测试输出、`失败说明.md`、截图和 trace。 +5. 如需交互检查,使用: + + ```bash + npx playwright show-trace test-reports/<轮次>/failures/<案例>/trace.zip + ``` + +6. 在 `SUMMARY.md` 中记录通过数、失败数、环境、失败案例和判断;失败原因只描述证据,不擅自推断为业务 bug。 + +## 测试原则 + +- 测试真实用户路径和可观察行为,不测试 Vue 内部状态或实现函数。 +- 主要使用角色、可访问名称、表单标签和 URL 定位;只有轮盘、同步图标、图表等难以稳定定位的元素才使用 `data-testid`。 +- 不使用固定长等待。使用 `expect`、网络响应、可见状态或 URL 变化等待。 +- 每个测试使用独立 BrowserContext 和独立测试数据。 +- API 和汇率服务可以使用 Playwright route mock;不得连接生产数据库或修改生产数据。 +- 离线案例必须分别覆盖真正断网、API 502/失败和请求超时。 +- 业务代码失败时只记录失败,不为了让测试通过而放宽断言或修改实现。 + +## 第一批功能范围 + +- `01-账号`:登录、错误密码、刷新保会话、退出。 +- `02-账本`:个人/共享账本显示、新增账本、默认币种。 +- `03-快速记账`:金额、备注、个人账本自动关联、多账本、外币。 +- `04-流水`:列表、详情、编辑、删除确认。 +- `05-离线`:离线打开、离线记账、待同步标记、恢复同步。 +- `06-多币种`:异步换算、失败降级、显示模式切换。 +- `07-报销`:部分报销、超额拒绝、统计排除报销到账。 + +## 失败判断 + +- 断言失败:记录为产品行为失败。 +- 启动失败、浏览器缺失、端口占用:记录为测试环境失败。 +- API mock 未覆盖:记录为测试夹具缺口,不修改业务代码。 +- 只有在测试断言与设计文档不一致时,才记录为测试设计问题,待确认后修改测试。 diff --git a/tests/e2e/01-账号/01-登录与退出.spec.ts b/tests/e2e/01-账号/01-登录与退出.spec.ts new file mode 100644 index 0000000..b582774 --- /dev/null +++ b/tests/e2e/01-账号/01-登录与退出.spec.ts @@ -0,0 +1,33 @@ +import { test, expect } from "@playwright/test"; +import { mockApi, 登录, 用户 } from "../测试夹具"; + +test.describe("账号:登录与退出", () => { + test("01-错误密码显示统一错误且不进入应用", async ({ page }) => { + await mockApi(page); + await page.goto("/login"); + await page.getByLabel("姓名").fill(用户.主用户.name); + await page.locator('input[autocomplete="current-password"]').fill("wrong-password"); + await page.getByRole("button", { name: "登录" }).click(); + await expect(page.getByRole("alert")).toHaveText("姓名或密码错误"); + await expect(page).toHaveURL(/\/login$/); + }); + + test("02-正确登录后刷新仍保持会话", async ({ page }) => { + await mockApi(page); + await 登录(page); + await expect(page.getByText("个人账本")).toBeVisible(); + await page.reload(); + await expect(page).toHaveURL(/\/$/); + await expect(page.getByLabel("账本流水")).toBeVisible(); + }); + + test("03-退出登录后受保护页面回到登录页", async ({ page }) => { + await mockApi(page); + await 登录(page); + await page.getByRole("link", { name: /我的/ }).click(); + await page.getByRole("button", { name: "退出登录" }).click(); + await expect(page).toHaveURL(/\/login/); + await page.goto("/stats"); + await expect(page).toHaveURL(/\/login/); + }); +}); diff --git a/tests/e2e/02-账本/01-账本列表与新建.spec.ts b/tests/e2e/02-账本/01-账本列表与新建.spec.ts new file mode 100644 index 0000000..4be00dd --- /dev/null +++ b/tests/e2e/02-账本/01-账本列表与新建.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from "@playwright/test"; +import { mockApi, 登录 } from "../测试夹具"; + +test.describe("账本:列表与新建", () => { + test("01-显示个人账本和共享账本,并标出当前账本", async ({ page }) => { + await mockApi(page); + await 登录(page); + await page.getByRole("link", { name: /账本/ }).click(); + await expect(page.getByRole("region", { name: "全部账本" })).toContainText("个人账本"); + await expect(page.getByRole("region", { name: "全部账本" })).toContainText("家庭账本"); + }); + + test("02-新增账本后进入新账本并保留默认币种", async ({ page }) => { + await mockApi(page); + await 登录(page); + await page.getByRole("link", { name: /账本/ }).click(); + await page.getByRole("button", { name: "新增账本" }).click(); + await page.getByLabel("账本名称").fill("旅行计划"); + await page.getByRole("button", { name: "创建账本" }).click(); + await expect(page).toHaveURL(/\/$/); + await expect(page.getByText("旅行计划")).toBeVisible(); + }); +}); diff --git a/tests/e2e/03-快速记账/01-新增账目.spec.ts b/tests/e2e/03-快速记账/01-新增账目.spec.ts new file mode 100644 index 0000000..1e509da --- /dev/null +++ b/tests/e2e/03-快速记账/01-新增账目.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from "@playwright/test"; +import { mockApi, 登录 } from "../测试夹具"; + +test.describe("快速记账:新增账目", () => { + test("01-输入金额和备注后可以完成一笔支出", async ({ page }) => { + await mockApi(page); + await 登录(page); + await page.getByRole("button", { name: "记一笔" }).click(); + await expect(page.getByRole("region", { name: "快速记账" })).toBeVisible(); + await page.getByRole("button", { name: "1", exact: true }).click(); + await page.getByRole("button", { name: "2", exact: true }).click(); + await page.getByLabel("备注").fill("测试午餐"); + await page.getByLabel("备注").press("Tab"); + await page.getByRole("button", { name: "完成记账" }).click(); + await expect(page.getByText("已记入账本")).toBeVisible(); + await expect(page.getByText("测试午餐")).toBeVisible(); + }); + + test("02-个人账本自动选中且不能取消,其他账本可以多选", async ({ page }) => { + await mockApi(page); + await 登录(page); + await page.getByRole("button", { name: "记一笔" }).click(); + await page.getByRole("button", { name: /记入/ }).click(); + const personal = page.getByRole("button", { name: /个人账本自动记入/ }); + await expect(personal).toBeDisabled(); + await expect(personal).toContainText("自动记入"); + await page.getByRole("button", { name: /家庭账本/ }).click(); + await expect(page.getByRole("button", { name: /完成/ }).last()).toBeVisible(); + }); + + test("03-切换币种后保持金额数字且可以确认", async ({ page }) => { + await mockApi(page); + await 登录(page); + await page.getByRole("button", { name: "记一笔" }).click(); + await page.getByRole("button", { name: /切换币种/ }).click(); + await expect(page.getByRole("dialog", { name: "选择币种" })).toBeVisible(); + await page.getByRole("button", { name: /美元/ }).click(); + await expect(page.getByRole("button", { name: /切换币种,当前美元/ })).toBeVisible(); + await page.getByRole("button", { name: "8", exact: true }).click(); + await page.getByRole("button", { name: "完成记账" }).click(); + await expect(page.getByText("已记入账本")).toBeVisible(); + }); + + test("04-大金额完整显示且不使用省略号", async ({ page }) => { + await mockApi(page); + await 登录(page); + await page.getByRole("button", { name: "记一笔" }).click(); + for (let index = 0; index < 9; index += 1) { + await page.getByRole("button", { name: "9", exact: true }).click(); + } + const amount = page.locator(".amount-value"); + await expect(amount).toContainText("999,999,999"); + await expect(amount).not.toContainText("..."); + await expect(page.locator(".drawer-header.long-amount")).toBeVisible(); + }); + + test("05-备注输入回车后恢复数字键盘并保留备注", async ({ page }) => { + await mockApi(page); + await 登录(page); + await page.getByRole("button", { name: "记一笔" }).click(); + const drawer = page.getByRole("region", { name: "快速记账" }); + const note = drawer.getByLabel("备注"); + await note.fill("回车备注"); + await note.press("Enter"); + await expect(drawer.getByRole("button", { name: "完成记账" })).toBeVisible(); + await expect(note).toHaveValue("回车备注"); + }); +}); diff --git a/tests/e2e/04-流水/01-流水编辑删除.spec.ts b/tests/e2e/04-流水/01-流水编辑删除.spec.ts new file mode 100644 index 0000000..c93666b --- /dev/null +++ b/tests/e2e/04-流水/01-流水编辑删除.spec.ts @@ -0,0 +1,37 @@ +import { test, expect } from "@playwright/test"; +import { makeEntry, mockApi, 登录 } from "../测试夹具"; + +test.describe("流水:查看、编辑和删除", () => { + test("01-流水列表显示账目并可进入详情", async ({ page }) => { + await mockApi(page, { initialEntries: [makeEntry()] }); + await 登录(page); + await expect(page.getByText("午餐")).toBeVisible(); + await page.getByText("午餐").click(); + await expect(page).toHaveURL(/\/entries\/entry-lunch$/); + await expect(page.getByText("条目明细")).toBeVisible(); + }); + + test("02-修改备注后保存,刷新后仍保留", async ({ page }) => { + await mockApi(page, { initialEntries: [makeEntry()] }); + await 登录(page); + await page.getByText("午餐").click(); + await page.getByLabel("备注").fill("修改后的午餐"); + await page.getByRole("button", { name: "保存修改" }).click(); + await expect(page.getByRole("status")).toContainText("修改已保存"); + await page.reload(); + await expect(page.getByLabel("备注")).toHaveValue("修改后的午餐"); + }); + + test("03-删除时要求确认从所有关联账本删除", async ({ page }) => { + await mockApi(page, { initialEntries: [makeEntry()] }); + await 登录(page); + await page.getByText("午餐").click(); + page.once("dialog", (dialog) => { + expect(dialog.message()).toContain("关联的全部账本"); + void dialog.accept(); + }); + await page.getByRole("button", { name: "从所有关联账本删除" }).click(); + await expect(page).toHaveURL(/\/$/); + await expect(page.getByText("午餐")).not.toBeVisible(); + }); +}); diff --git a/tests/e2e/05-离线/01-离线记账与同步.spec.ts b/tests/e2e/05-离线/01-离线记账与同步.spec.ts new file mode 100644 index 0000000..b5c4c10 --- /dev/null +++ b/tests/e2e/05-离线/01-离线记账与同步.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from "@playwright/test"; +import { mockApi, 登录 } from "../测试夹具"; + +test.describe("离线:记账与恢复同步", () => { + test("01-服务 API 不可用时显示底部离线横幅且不遮挡页面", async ({ page }) => { + const state = await mockApi(page); + await 登录(page); + state.apiAvailable = false; + await page.reload(); + await expect(page.getByRole("status", { name: "服务暂不可用,点击重连" })).toBeVisible(); + await expect(page.getByRole("navigation", { name: "主导航" })).toBeVisible(); + const banner = page.getByRole("status", { name: "服务暂不可用,点击重连" }); + const nav = page.getByRole("navigation", { name: "主导航" }); + expect((await banner.boundingBox())!.y).toBeLessThan((await nav.boundingBox())!.y); + }); + + test("02-离线时仍可立即新增账目并标记待同步", async ({ page }) => { + const state = await mockApi(page); + await 登录(page); + state.apiAvailable = false; + await page.context().setOffline(true); + await page.getByRole("button", { name: "记一笔" }).click(); + await page.getByRole("button", { name: "9", exact: true }).click(); + await page.getByLabel("备注").fill("离线午餐"); + await page.getByLabel("备注").press("Tab"); + await page.getByRole("button", { name: "完成记账" }).click(); + await expect(page.getByText("离线午餐")).toBeVisible(); + await expect(page.getByText(/条账目未同步云端/)).toBeVisible(); + await expect(page.getByRole("img", { name: "未同步云端" })).toBeVisible(); + }); + + test("03-恢复网络后手动同步并清除待同步标识", async ({ page }) => { + const state = await mockApi(page); + await 登录(page); + state.apiAvailable = false; + await page.context().setOffline(true); + await page.getByRole("button", { name: "记一笔" }).click(); + await page.getByRole("button", { name: "3", exact: true }).click(); + await page.getByLabel("备注").press("Tab"); + await page.getByRole("button", { name: "完成记账" }).click(); + await page.context().setOffline(false); + await expect(page.getByRole("button", { name: /立即同步/ })).toBeVisible(); + state.apiAvailable = true; + await page.getByRole("button", { name: /立即同步/ }).click(); + await expect(page.getByText(/未同步云端/)).not.toBeVisible(); + }); +}); diff --git a/tests/e2e/06-多币种/01-外币异步换算.spec.ts b/tests/e2e/06-多币种/01-外币异步换算.spec.ts new file mode 100644 index 0000000..7d11bf3 --- /dev/null +++ b/tests/e2e/06-多币种/01-外币异步换算.spec.ts @@ -0,0 +1,39 @@ +import { test, expect } from "@playwright/test"; +import { makeEntry, mockApi, 登录 } from "../测试夹具"; + +test.describe("多币种:外币记账与异步换算", () => { + test("01-外币账目在汇率返回前也可以完成记账", async ({ page }) => { + const state = await mockApi(page); + await 登录(page); + await page.getByRole("button", { name: "记一笔" }).click(); + await page.getByRole("button", { name: /切换币种/ }).click(); + await page.getByRole("button", { name: /美元/ }).click(); + await page.getByRole("button", { name: "5", exact: true }).click(); + const started = Date.now(); + await page.getByRole("button", { name: "完成记账" }).click(); + await expect(page.getByText("已记入账本")).toBeVisible(); + expect(Date.now() - started).toBeLessThan(1500); + expect(state.requestPaths.some((path) => path.includes("exchange-rates"))).toBeTruthy(); + }); + + test("02-汇率失败时保留原币账目并显示待换算状态", async ({ page }) => { + const state = await mockApi(page, { initialEntries: [makeEntry({ id: "entry-usd", currency: "USD", amount: 1000, baseAmount: null, exchangeRate: null, exchangeRateEffectiveDate: null, conversionStatus: "pending" })] }); + await 登录(page); + await page.route("**/api/exchange-rates/**", (route) => route.abort("failed")); + await page.reload(); + await expect(page.getByRole("status")).toContainText("等待换算"); + await expect(page.getByText("立即换算")).toBeVisible(); + expect(state.entries).toHaveLength(1); + }); + + test("03-已换算账目切换显示模式不会重复出现等待换算提示", async ({ page }) => { + await mockApi(page, { initialEntries: [makeEntry({ id: "entry-usd", currency: "USD", amount: 1000, baseAmount: 720, exchangeRate: "7.2", exchangeRateEffectiveDate: "2026-08-01", conversionStatus: "exact" })] }); + await 登录(page); + await page.getByRole("button", { name: "更多" }).click(); + await page.getByRole("menuitem", { name: "设置" }).click(); + await page.getByLabel("流水金额显示").selectOption("original"); + await page.getByRole("button", { name: "保存设置" }).click(); + await page.goBack(); + await expect(page.getByText("等待换算")).not.toBeVisible(); + }); +}); diff --git a/tests/e2e/07-报销/01-报销流程.spec.ts b/tests/e2e/07-报销/01-报销流程.spec.ts new file mode 100644 index 0000000..91150e8 --- /dev/null +++ b/tests/e2e/07-报销/01-报销流程.spec.ts @@ -0,0 +1,32 @@ +import { test, expect } from "@playwright/test"; +import { makeEntry, mockApi, 登录 } from "../测试夹具"; + +test.describe("报销:从支出详情记录", () => { + test("01-支出详情可以记录部分报销并保留原支出", async ({ page }) => { + await mockApi(page, { initialEntries: [makeEntry({ amount: 3200 })] }); + await 登录(page); + await page.getByText("午餐").click(); + await page.getByRole("button", { name: "记录报销" }).click(); + await page.getByLabel("本次报销金额").fill("20"); + await page.getByRole("button", { name: "确认报销" }).click(); + await expect(page.getByText(/已报销/)).toBeVisible(); + await expect(page.getByText("净支出")).toBeVisible(); + }); + + test("02-报销金额不能超过原支出的剩余金额", async ({ page }) => { + await mockApi(page, { initialEntries: [makeEntry({ amount: 3200 })] }); + await 登录(page); + await page.getByText("午餐").click(); + await page.getByRole("button", { name: "记录报销" }).click(); + await page.getByLabel("本次报销金额").fill("3201"); + await page.getByRole("button", { name: "确认报销" }).click(); + await expect(page.locator("form.reimbursement-sheet").getByText(/不超过剩余可报销金额/)).toBeVisible(); + }); + + test("03-报销到账不出现在普通收入分类统计中", async ({ page }) => { + await mockApi(page, { initialEntries: [makeEntry(), makeEntry({ id: "reimbursement", type: "income", amount: 1000, categoryId: "restaurant", reimbursementOfEntryId: "entry-lunch", note: "午餐报销" })] }); + await 登录(page); + await page.goto("/stats"); + await expect(page.getByText("报销到账")).not.toBeVisible(); + }); +}); diff --git a/tests/e2e/failed-reporter.ts b/tests/e2e/failed-reporter.ts new file mode 100644 index 0000000..6b1476d --- /dev/null +++ b/tests/e2e/failed-reporter.ts @@ -0,0 +1 @@ +export { default } from "./报告器/中文失败报告器"; diff --git a/tests/e2e/报告器/中文失败报告器.ts b/tests/e2e/报告器/中文失败报告器.ts new file mode 100644 index 0000000..0098373 --- /dev/null +++ b/tests/e2e/报告器/中文失败报告器.ts @@ -0,0 +1,63 @@ +import type { FullConfig, FullResult, Reporter, TestCase, TestResult } from "@playwright/test/reporter"; +import fs from "node:fs"; +import path from "node:path"; + +type Options = { runId: string }; + +function safeName(value: string) { + return value.replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, "-").slice(0, 100); +} + +function relativeTestPath(test: TestCase) { + const file = test.location.file.replace(/\\/g, "/"); + const marker = "/tests/e2e/"; + const index = file.lastIndexOf(marker); + return index >= 0 ? file.slice(index + marker.length) : path.basename(file); +} + +export default class ChineseFailureReporter implements Reporter { + private readonly options: Options; + private config!: FullConfig; + private failed = 0; + + constructor(options: Options) { + this.options = options; + } + + onConfigure(config: FullConfig) { + this.config = config; + fs.mkdirSync(path.join(projectRoot(config), "test-reports", this.options.runId, "failures"), { recursive: true }); + } + + onBegin(config: FullConfig) { + this.config = config; + fs.mkdirSync(path.join(projectRoot(config), "test-reports", this.options.runId, "failures"), { recursive: true }); + } + + onTestEnd(test: TestCase, result: TestResult) { + if (result.status === "passed" || result.status === "skipped") return; + this.failed += 1; + const testFile = relativeTestPath(test).replace(/\.ts$/, ""); + const target = path.join(projectRoot(this.config), "test-reports", this.options.runId, "failures", testFile, safeName(test.title)); + fs.mkdirSync(target, { recursive: true }); + const attachments: string[] = []; + for (const attachment of result.attachments) { + if (!attachment.path) continue; + const targetPath = path.join(target, `${safeName(attachment.name)}${path.extname(attachment.path)}`); + fs.copyFileSync(attachment.path, targetPath); + attachments.push(path.relative(projectRoot(this.config), targetPath)); + } + const errors = result.errors.map((error) => error.message ?? error.stack ?? "未知错误").join("\n\n"); + fs.writeFileSync(path.join(target, "失败说明.md"), `# ${test.title}\n\n- 文件:\`${relativeTestPath(test)}\`\n- 项目:${test.parent.project()?.name ?? "未知"}\n- 重试次数:${result.retry}\n- 状态:${result.status}\n\n## 失败原因\n\n${errors || "未捕获到错误文本,请查看 trace。"}\n\n## 附件\n\n${attachments.length ? attachments.map((item) => `- \`${item}\``).join("\n") : "无"}\n`, "utf8"); + } + + onEnd(result: FullResult) { + const root = path.join(projectRoot(this.config), "test-reports", this.options.runId); + fs.mkdirSync(root, { recursive: true }); + fs.writeFileSync(path.join(root, "SUMMARY.md"), `# 第 ${this.options.runId} 轮测试\n\n- 总体状态:${result.status}\n- 失败测试数:${this.failed}\n- 失败详情:${this.failed ? "见 failures/ 目录" : "无"}\n`, "utf8"); + } +} + +function projectRoot(config: FullConfig) { + return path.resolve(config.rootDir, "../.."); +} diff --git a/tests/e2e/测试夹具.ts b/tests/e2e/测试夹具.ts new file mode 100644 index 0000000..8ecdbc8 --- /dev/null +++ b/tests/e2e/测试夹具.ts @@ -0,0 +1,125 @@ +import { expect, type Page } from "@playwright/test"; + +export const 用户 = { + 主用户: { id: "user-primary", name: "测试一", password: "test12345" }, + 成员: { id: "user-member", name: "测试二", password: "test12345" }, +}; + +export const 账本 = { + 个人: { id: "ledger-personal", name: "个人账本", role: "owner", isPersonal: true }, + 家庭: { id: "ledger-family", name: "家庭账本", role: "owner", isPersonal: false }, +}; + +type Entry = Record; + +export function makeEntry(overrides: Partial = {}): Entry { + return { + id: "entry-lunch", + ownerId: 用户.主用户.id, + ledgerIds: [账本.个人.id, 账本.家庭.id], + type: "expense", + amount: 3200, + currency: "CNY", + baseCurrency: "CNY", + baseAmount: 3200, + exchangeRate: "1", + exchangeRateDate: "2026-08-01", + exchangeRateEffectiveDate: "2026-08-01", + exchangeRateSource: "system", + conversionStatus: "exact", + reimbursementOfEntryId: null, + categoryId: "restaurant", + note: "午餐", + occurredAt: "2026-08-01T12:00:00.000Z", + createdBy: 用户.主用户.id, + updatedBy: 用户.主用户.id, + createdAt: "2026-08-01T12:00:00.000Z", + updatedAt: "2026-08-01T12:00:00.000Z", + deletedAt: null, + version: 1, + ...overrides, + }; +} + +export type MockOptions = { + initialEntries?: Entry[]; + currentUser?: typeof 用户.主用户 | typeof 用户.成员 | null; + memberRole?: "owner" | "member"; + apiAvailable?: boolean; +}; + +export async function mockApi(page: Page, options: MockOptions = {}) { + const state = { + currentUser: options.currentUser ?? null, + entries: [...(options.initialEntries ?? [])], + ledgers: [ + { ...账本.个人, defaultCurrency: "CNY", theme: "jade", icon: "wallet", archivedAt: null }, + { ...账本.家庭, defaultCurrency: "CNY", theme: "coral", icon: "users", archivedAt: null, role: options.memberRole ?? "owner" }, + ], + apiAvailable: options.apiAvailable ?? true, + requestPaths: [] as string[], + }; + + await page.route("**/api/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + state.requestPaths.push(`${request.method()} ${url.pathname}`); + if (!state.apiAvailable) { + await route.abort("failed"); + return; + } + + const json = async (body: unknown, status = 200) => route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); + if (url.pathname === "/api/auth/session") return json({ user: state.currentUser }); + if (url.pathname === "/api/auth/login" && request.method() === "POST") { + const body = request.postDataJSON() as { name?: string; password?: string }; + const account = Object.values(用户).find((item) => item.name === body.name && item.password === body.password); + if (!account) return json({ error: "姓名或密码错误" }, 401); + state.currentUser = account; + return json({ user: account }); + } + if (url.pathname === "/api/auth/logout") { + state.currentUser = null; + return json({ ok: true }); + } + if (url.pathname === "/api/ledgers") { + if (request.method() === "POST") { + const body = request.postDataJSON() as { name: string; defaultCurrency?: string; theme?: string; icon?: string }; + const created = { id: `ledger-${state.entries.length + 10}`, name: body.name, role: "owner", isPersonal: false, defaultCurrency: body.defaultCurrency ?? "CNY", theme: body.theme ?? "jade", icon: body.icon ?? "wallet", archivedAt: null }; + state.ledgers.push(created); + return json({ ledger: created }, 201); + } + return json({ ledgers: state.ledgers }); + } + if (/^\/api\/ledgers\/[^/]+\/members$/.test(url.pathname)) return json({ members: [{ id: 用户.主用户.id, name: 用户.主用户.name, role: options.memberRole ?? "owner", joinedAt: "2026-08-01" }] }); + if (url.pathname.startsWith("/api/ledgers/") && request.method() === "PATCH") return json({ ledger: {} }); + if (url.pathname === "/api/sync/pull") return json({ entries: state.entries, cursor: "cursor-1", hasMore: false }); + if (url.pathname === "/api/sync/push" && request.method() === "POST") { + const body = request.postDataJSON() as { operations?: Array<{ id: string; action: string; payload: Entry }> }; + for (const operation of body.operations ?? []) { + if (operation.action === "delete") continue; + const index = state.entries.findIndex((entry) => entry.id === operation.payload.id); + if (index >= 0) state.entries[index] = operation.payload; + else state.entries.push(operation.payload); + } + return json({ acceptedOperationIds: (body.operations ?? []).map((operation) => operation.id) }); + } + if (/^\/api\/exchange-rates\//.test(url.pathname)) { + const [, , currency, date] = url.pathname.split("/"); + return json({ rate: { currency, requestedDate: date, effectiveDate: date, cnyPerUnit: currency === "USD" ? "7.2" : "1", status: "exact" } }); + } + if (url.pathname === "/api/me") return json({ user: state.currentUser }); + return json({ error: "未模拟的测试 API" }, 404); + }); + + return state; +} + +export async function 登录(page: Page, user = 用户.主用户) { + await page.goto("/login"); + await page.getByLabel("姓名").fill(user.name); + await page.locator('input[autocomplete="current-password"]').fill(user.password); + await page.getByRole("button", { name: "登录" }).click(); + await expect(page).toHaveURL(/\/$/); + await expect(page.getByLabel("账本流水")).toBeVisible(); +}