test: add first Playwright regression suite
This commit is contained in:
parent
6c2161f9df
commit
d7bd667ea2
|
|
@ -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.*
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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 未覆盖:记录为测试夹具缺口,不修改业务代码。
|
||||||
|
- 只有在测试断言与设计文档不一致时,才记录为测试设计问题,待确认后修改测试。
|
||||||
|
|
@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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("回车备注");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { default } from "./报告器/中文失败报告器";
|
||||||
|
|
@ -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, "../..");
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue