test: add first Playwright regression suite

This commit is contained in:
openclaw 2026-08-01 18:47:36 +08:00
parent 6c2161f9df
commit d7bd667ea2
17 changed files with 690 additions and 6 deletions

3
.gitignore vendored
View File

@ -4,6 +4,9 @@ node_modules/
dist/ dist/
backups/ backups/
backup/ backup/
test-reports/
test-results/
playwright-report/
docs/prototype/ docs/prototype/
.env .env
.env.* .env.*

View File

@ -58,6 +58,14 @@ const displayAmount = computed(() =>
maximumFractionDigits: minorUnits.value, maximumFractionDigits: minorUnits.value,
}).format(calculatedAmount.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 currencySymbol = computed(() => currencies[currency.value].symbol);
const selectedLedgerLabel = computed(() => { const selectedLedgerLabel = computed(() => {
const visibleNames = props.ledgerOptions const visibleNames = props.ledgerOptions
@ -179,6 +187,13 @@ function startNoteEditing() {
noteEditing.value = true; noteEditing.value = true;
} }
function finishNoteEditing(event: KeyboardEvent) {
if (event.isComposing) return;
event.preventDefault();
noteEditing.value = false;
(event.currentTarget as HTMLInputElement).blur();
}
function openLedgerPicker() { function openLedgerPicker() {
confirmCategory(); confirmCategory();
noteEditing.value = false; noteEditing.value = false;
@ -224,13 +239,13 @@ function saveEntry() {
<div v-if="selectorOpen" class="category-focus-scrim"></div> <div v-if="selectorOpen" class="category-focus-scrim"></div>
<div class="drawer-handle"></div> <div class="drawer-handle"></div>
<header class="drawer-header"> <header class="drawer-header" :class="{ 'long-amount': amountNeedsFullRow }">
<div class="amount-block"> <div class="amount-block">
<div class="amount-result"> <div class="amount-result" :class="amountSizeClass">
<button class="amount-prefix" type="button" :aria-label="`切换币种,当前${currencies[currency].name}`" @click="openCurrencyPicker"> <button class="amount-prefix" type="button" :aria-label="`切换币种,当前${currencies[currency].name}`" @click="openCurrencyPicker">
{{ currencySymbol }} {{ currencySymbol }}
</button> </button>
<strong class="amount-value">{{ displayAmount }}</strong> <strong class="amount-value" :class="amountSizeClass">{{ displayAmount }}</strong>
</div> </div>
<span v-if="hasCalculation" class="calculation-line">{{ calculationLabel }}</span> <span v-if="hasCalculation" class="calculation-line">{{ calculationLabel }}</span>
</div> </div>
@ -250,6 +265,7 @@ function saveEntry() {
aria-label="备注" aria-label="备注"
@focus="startNoteEditing" @focus="startNoteEditing"
@blur="noteEditing = false" @blur="noteEditing = false"
@keydown.enter="finishNoteEditing"
/> />
</label> </label>

View File

@ -828,6 +828,20 @@ input:focus-visible {
padding: 7px 14px 5px 18px; 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 { .amount-block {
min-width: 0; min-width: 0;
display: grid; display: grid;
@ -910,15 +924,25 @@ input:focus-visible {
.conversion-notice .spinning { animation: sync-spin .8s linear infinite; } .conversion-notice .spinning { animation: sync-spin .8s linear infinite; }
.amount-value { .amount-value {
min-width: 0;
flex: 1 1 auto;
overflow: hidden; overflow: hidden;
color: #172421; color: #172421;
font-size: clamp(34px, 10vw, 44px);
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
line-height: 1; line-height: 1;
text-overflow: ellipsis;
white-space: nowrap; 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 { .calculation-line {
max-width: min(72vw, 330px); max-width: min(72vw, 330px);
overflow: hidden; overflow: hidden;

66
package-lock.json generated
View File

@ -12,6 +12,9 @@
"services/*", "services/*",
"packages/*" "packages/*"
], ],
"devDependencies": {
"@playwright/test": "^1.62.1"
},
"engines": { "engines": {
"node": ">=22" "node": ">=22"
} }
@ -1015,6 +1018,22 @@
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT" "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": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
@ -2276,6 +2295,53 @@
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT" "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": { "node_modules/postcss": {
"version": "8.5.19", "version": "8.5.19",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",

View File

@ -16,9 +16,14 @@
"user:add": "npm run user:add --workspace @cents/api --", "user:add": "npm run user:add --workspace @cents/api --",
"build": "npm run build --workspaces --if-present", "build": "npm run build --workspaces --if-present",
"typecheck": "npm run typecheck --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": { "engines": {
"node": ">=22" "node": ">=22"
},
"devDependencies": {
"@playwright/test": "^1.62.1"
} }
} }

36
playwright.config.ts Normal file
View File

@ -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,
},
});

View File

@ -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 未覆盖:记录为测试夹具缺口,不修改业务代码。
- 只有在测试断言与设计文档不一致时,才记录为测试设计问题,待确认后修改测试。

View File

@ -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/);
});
});

View File

@ -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();
});
});

View File

@ -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("回车备注");
});
});

View File

@ -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();
});
});

View File

@ -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();
});
});

View File

@ -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();
});
});

View File

@ -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();
});
});

View File

@ -0,0 +1 @@
export { default } from "./报告器/中文失败报告器";

View File

@ -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, "../..");
}

125
tests/e2e/测试夹具.ts Normal file
View File

@ -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<string, unknown>;
export function makeEntry(overrides: Partial<Entry> = {}): 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();
}