Commit 1a4c1b8b authored by liguangyu06's avatar liguangyu06
Browse files

从 temp 选择性同步框架与用例到 main

合入自动化代码、用例 CSV/MD/spec/PO 及登录态修复;不含 knowledge-base、.cursor、data/scan 等 temp 全量备份内容。
parent f6eec849
...@@ -9,6 +9,10 @@ HOME_PATH=/spd/home/index ...@@ -9,6 +9,10 @@ HOME_PATH=/spd/home/index
TEST_USER= TEST_USER=
TEST_PASS= TEST_PASS=
# 第二测试账号(可选):与主账号按 worker 轮换登录态,并行时减轻同账号互踢
# TEST_USER_2=playwright
# TEST_PASS_2=
# 设为 1 时每次运行强制重新登录(默认复用 auth/user.json,token 过期或失效才登录) # 设为 1 时每次运行强制重新登录(默认复用 auth/user.json,token 过期或失效才登录)
# FORCE_AUTH_LOGIN=0 # FORCE_AUTH_LOGIN=0
# token 剩余有效期 ≥ AUTH_FRESH_REMAINING_MS 时跳过首页探活(默认 20 分钟);设 0 强制探活 # token 剩余有效期 ≥ AUTH_FRESH_REMAINING_MS 时跳过首页探活(默认 20 分钟);设 0 强制探活
...@@ -23,6 +27,8 @@ TEST_PASS= ...@@ -23,6 +27,8 @@ TEST_PASS=
# 操作层瞬时重试(click/fill/goto),默认 3 次;断言失败不会走这里 # 操作层瞬时重试(click/fill/goto),默认 3 次;断言失败不会走这里
# ACTION_RETRIES=3 # ACTION_RETRIES=3
# ACTION_RETRY_DELAY_MS=250 # ACTION_RETRY_DELAY_MS=250
# 单次操作重试总预算(毫秒,含首次与退避);到点即停,避免 次数×超时 叠加;0=不限
# ACTION_RETRY_BUDGET_MS=45000
# 整条用例重试默认 0(不要全局打开);仅排查时可显式设 PW_RETRIES # 整条用例重试默认 0(不要全局打开);仅排查时可显式设 PW_RETRIES
# PW_RETRIES=0 # PW_RETRIES=0
# 已知瞬时 flaky 用 testFlaky,额外重跑次数(不含首次) # 已知瞬时 flaky 用 testFlaky,额外重跑次数(不含首次)
...@@ -32,6 +38,11 @@ TEST_PASS= ...@@ -32,6 +38,11 @@ TEST_PASS=
# RUN_QUARANTINE=1 # RUN_QUARANTINE=1
# FLAKE_MIN_RUNS=4 # FLAKE_MIN_RUNS=4
# FLAKE_RATE=0.3 # FLAKE_RATE=0.3
# 失败频次统计(跨构建累计):失败次数与失败率双达标才算「经常失败」
# FREQ_FAIL_MIN=2
# FREQ_FAIL_RATE=0.2
# 累计文件路径(默认 reports/flake/failure-counts.json)
# PW_FAILURE_COUNTS=
# PW_WORKERS=3 # PW_WORKERS=3
# 分片并行(npm run test:warn:shard / test:spd:shard):SHARDS=3 WORKERS=2 # 分片并行(npm run test:warn:shard / test:spd:shard):SHARDS=3 WORKERS=2
# 排序全列等 @full 用例:RUN_FULL=1 # 排序全列等 @full 用例:RUN_FULL=1
...@@ -40,7 +51,14 @@ TEST_PASS= ...@@ -40,7 +51,14 @@ TEST_PASS=
# 分片:SHARDS=2 或 PW_SHARD=1/2(多 Agent) # 分片:SHARDS=2 或 PW_SHARD=1/2(多 Agent)
# 默认按文件并行;不要开 PW_FULLY_PARALLEL=1(同账号会互踩) # 默认按文件并行;不要开 PW_FULLY_PARALLEL=1(同账号会互踩)
# 冒烟快速失败:PW_MAX_FAILURES=8 # 冒烟快速失败:PW_MAX_FAILURES=8
# 关闭 Allure:ALLURE=0 # Allure 默认关(与 HTML 重复保存 trace/截图);ALLURE=1 才额外生成
# ALLURE=1
# JUnit 默认紧凑(只留 message);需要完整堆栈时设 0
# PW_JUNIT_COMPACT=0
# 归档内只留体积最大的 N 个 trace/截图附件(默认全留;0=不要)
# PW_KEEP_TRACES=0
# 本机只保留最近 N 份 reports/ci-*(默认全留;0=跑完即删本次归档)
# PW_KEEP_ARCHIVES=1
# 纳入调试 spec:INCLUDE_DEBUG_SPECS=1 # 纳入调试 spec:INCLUDE_DEBUG_SPECS=1
# 跳过站点探活:SKIP_PREFLIGHT=1 # 跳过站点探活:SKIP_PREFLIGHT=1
......
...@@ -112,7 +112,7 @@ pipeline { ...@@ -112,7 +112,7 @@ pipeline {
parameters { parameters {
choice( choice(
name: 'SUITE', name: 'SUITE',
choices: ['full', 'nightly', 'smoke', 'login', 'center', 'dept', 'invoice', 'trace', 'consume', 'recon', 'cost', 'warn', 'pol', 'exc', 'ana', 'ops', 'reag', 'invoice-trace', 'recon-cost', 'extended', 'quarantine'], choices: ['full', 'nightly', 'smoke', 'login', 'center', 'dept', 'invoice', 'trace', 'consume', 'recon', 'cost', 'warn', 'pol', 'exc', 'ana', 'ops', 'reag', 'invoice-trace', 'recon-cost', 'vendor', 'admit', 'basic', 'surgery', 'warehouse', 'flow', 'extended', 'quarantine'],
description: '执行方式(默认 full 全量回归,含 @full 全列排序)' description: '执行方式(默认 full 全量回归,含 @full 全列排序)'
) )
string(name: 'WORKERS', defaultValue: '3', description: '单进程内并行文件数(同一 spec 不拆开)') string(name: 'WORKERS', defaultValue: '3', description: '单进程内并行文件数(同一 spec 不拆开)')
...@@ -120,6 +120,9 @@ pipeline { ...@@ -120,6 +120,9 @@ pipeline {
string(name: 'RETRIES', defaultValue: '0', description: '整条用例重试次数,默认 0;瞬时加载由操作层重试,勿全局打开') string(name: 'RETRIES', defaultValue: '0', description: '整条用例重试次数,默认 0;瞬时加载由操作层重试,勿全局打开')
booleanParam(name: 'RUN_FULL', defaultValue: false, description: '包含 @full 全列排序(耗时长)') booleanParam(name: 'RUN_FULL', defaultValue: false, description: '包含 @full 全列排序(耗时长)')
string(name: 'GREP', defaultValue: '', description: '只跑标题匹配这些用例(Playwright grep)。空则按 SUITE 全跑') string(name: 'GREP', defaultValue: '', description: '只跑标题匹配这些用例(Playwright grep)。空则按 SUITE 全跑')
// Allure 默认关(与 HTML 重复保存 trace/截图);需要时勾选
booleanParam(name: 'ALLURE', defaultValue: false, description: '额外生成 Allure(体积约翻倍,默认关闭)')
string(name: 'KEEP_ARCHIVES', defaultValue: '1', description: '本节点保留最近 N 份 reports/ci-*;-1=全留(占盘最快),0=跑完即删本次归档')
string( string(
name: 'REPORT_EMAIL_TO', name: 'REPORT_EMAIL_TO',
defaultValue: '', defaultValue: '',
...@@ -137,6 +140,9 @@ pipeline { ...@@ -137,6 +140,9 @@ pipeline {
RUN_FULL = "${params.RUN_FULL ? '1' : '0'}" RUN_FULL = "${params.RUN_FULL ? '1' : '0'}"
PW_GREP = "${params.GREP ?: ''}" PW_GREP = "${params.GREP ?: ''}"
PLAYWRIGHT_BROWSERS_PATH = "${WORKSPACE}/.playwright-browsers" PLAYWRIGHT_BROWSERS_PATH = "${WORKSPACE}/.playwright-browsers"
// 存储:Allure 默认关(与 HTML 重复保存 trace/截图);归档只留本次,latest/ 始终可用
ALLURE = "${params.ALLURE ? '1' : '0'}"
PW_KEEP_ARCHIVES = "${params.KEEP_ARCHIVES ?: '1'}"
SPD_CREDENTIALS_ID = "${env.SPD_CREDENTIALS_ID ?: 'spd-ui-test-account'}" SPD_CREDENTIALS_ID = "${env.SPD_CREDENTIALS_ID ?: 'spd-ui-test-account'}"
JAVA_HOME = "${env.JAVA_HOME ?: 'E:/Program Files/Java/jdk-11'}" JAVA_HOME = "${env.JAVA_HOME ?: 'E:/Program Files/Java/jdk-11'}"
} }
......
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
## 快速开始 ## 快速开始
```bash ```bash
cp .env.example .env # 填写 TEST_USER / TEST_PASS cp .env.example .env # 填写 TEST_USER / TEST_PASS(可选 TEST_USER_2 / TEST_PASS_2 双账号并行)
npm ci # postinstall 安装 chromium;prepare 安装 git 门禁 hook npm ci # postinstall 安装 chromium;prepare 安装 git 门禁 hook
npm run typecheck # CI 同样会跑 tsc --noEmit npm run typecheck # CI 同样会跑 tsc --noEmit
npm run gate # 未执行通过的用例脚本不能 commit/push,见 docs/git-gate.md npm run gate # 未执行通过的用例脚本不能 commit/push,见 docs/git-gate.md
...@@ -17,9 +17,17 @@ Chromium 缓存在项目内 `.playwright-browsers/`(已 gitignore),与 `@p ...@@ -17,9 +17,17 @@ Chromium 缓存在项目内 `.playwright-browsers/`(已 gitignore),与 `@p
默认站点:`http://spdtest.cmic.com.cn:8080`,登录页 `/spd/login`。账号只放 `.env`(已 gitignore),不要写入源码。 默认站点:`http://spdtest.cmic.com.cn:8080`,登录页 `/spd/login`。账号只放 `.env`(已 gitignore),不要写入源码。
`login` project 不带登录态,测登录页本身;`chromium` 依赖 `auth.setup` 复用 `auth/user.json`。 `login` project 不带登录态,测登录页本身;`chromium` 依赖 `auth.setup` 写入并复用 `auth/user.json`。
**登录态复用**:`utils/auth-session.ts` 统一编排。`auth.setup` 与同页 `describeWithReuseOpen` 共用:token 未过期则复用 `auth/user.json`,过期或被踢回登录页则重新登录并重建上下文。需强制刷新:`FORCE_AUTH_LOGIN=1`。Jenkins / `npm run test:ci` 默认强制重新登录,避免 Agent 工作区残留旧会话。 ### 登录态复用(storageState,大幅提速)
- **单独 setup**:`tests/setup/auth.setup.ts` 为主/辅账号各登录一次,写入 `auth/user.json` 与 `auth/user-playwright.json`。
- **业务用例加载文件**:`fixtures/index.ts` 按 worker 注入对应 `storageState`;每条用例由 Playwright **新建独立 BrowserContext**,再注入 cookie / localStorage,直接进入已登录态。
- **隔离第一原则**:复用的是**登录态文件**,不是同一个 context。禁止全局共享 `BrowserContext`/`Page`;厂商 / 准入 / 医院资料等新模块每条用例各自 `open`,互不污染。未登录反例用局部 `test.use({ storageState: { cookies: [], origins: [] } })`。
- token 未过期可跳过重复登录与探活;过期或被踢则 setup 重新登录。强制刷新:`FORCE_AUTH_LOGIN=1`。Jenkins / `npm run test:ci` 默认强制重新登录,避免 Agent 残留旧会话。
- 存量报表里的 `describeWithReuseOpen`(组内同页加速)**不要**扩到新模块。
`utils/auth-session.ts` 统一编排登录与探活。
## 套件与 Jenkins ## 套件与 Jenkins
...@@ -27,13 +35,13 @@ Chromium 缓存在项目内 `.playwright-browsers/`(已 gitignore),与 `@p ...@@ -27,13 +35,13 @@ Chromium 缓存在项目内 `.playwright-browsers/`(已 gitignore),与 `@p
| 套件 | 命令 | 用途 | | 套件 | 命令 | 用途 |
|------|------|------| |------|------|------|
| `smoke` | `npm run test:ci:smoke` | 全部报表模块中已打 `@smoke` 的用例(每页重置 / 列表或查询 / 列设置 / 导出) | | `smoke` | `npm run test:ci:smoke` | 报表与基础设置中已打 `@smoke` 的用例(含厂商、准入、基础管理、手术、库房、流程) |
| `nightly` | `npm run test:ci:nightly` | 主 spec 回归,排除 `@full` 与调试 `*.stat-*.spec.ts` | | `nightly` | `npm run test:ci:nightly` | 主 spec 回归,排除 `@full` 全列排序 |
| `full` | `npm run test:ci:full` | 含全列排序,建议周末 | | `full` | `npm run test:ci:full` | 含全列排序,建议周末 |
| 模块 | `SUITE=center npm run test:ci` | `center` / `dept` / `warn` / `recon` 等 | | 模块 | `SUITE=center npm run test:ci` | `center` / `dept` / `vendor` / `admit` / `basic` / `surgery` / `warehouse` / `flow` 等 |
| 夜间分片 | `npm run test:spd:shard` | 先登录一次,再 2 进程分片并合并报告 | | 夜间分片 | `npm run test:spd:shard` | 先登录一次,再 2 进程分片并合并报告 |
并行默认按 **文件**(`PW_WORKERS`)。不要开 `PW_FULLY_PARALLEL=1`:本仓库大量同页 serial,拆开后同一账号会互踩。总浏览器约 `SHARDS × WORKERS`,单机建议不超过 6。详见 [docs/jenkins.md](docs/jenkins.md)。 并行默认按 **文件**(`PW_WORKERS`)。配置 `TEST_USER_2` / `TEST_PASS_2` 后 worker 在双账号间轮换,可在不加大单账号并发的前提下提高并行度。不要开 `PW_FULLY_PARALLEL=1`:本仓库大量同页 serial,拆开后仍易互踩。总浏览器约 `SHARDS × WORKERS`,单机建议不超过 6。详见 [docs/jenkins.md](docs/jenkins.md)。
产物在 `reports/latest/`:Playwright HTML、`junit.xml`、Allure results、`summary.json`。定时跑完会发 **HTML 摘要邮件**(测试范围、执行数、成功/失败、通过率、报告链接),不要把完整报告当附件。默认收件人在 `config/report-email-recipients.txt`,SMTP 与任务拆分见 [docs/jenkins.md](docs/jenkins.md)。 产物在 `reports/latest/`:Playwright HTML、`junit.xml`、Allure results、`summary.json`。定时跑完会发 **HTML 摘要邮件**(测试范围、执行数、成功/失败、通过率、报告链接),不要把完整报告当附件。默认收件人在 `config/report-email-recipients.txt`,SMTP 与任务拆分见 [docs/jenkins.md](docs/jenkins.md)。
...@@ -65,25 +73,31 @@ scripts/run-ci.mjs Jenkins / 定时执行入口 ...@@ -65,25 +73,31 @@ scripts/run-ci.mjs Jenkins / 定时执行入口
| 层 | 允许 | 禁止 | | 层 | 允许 | 禁止 |
|----|------|------| |----|------|------|
| `config/` | 环境、超时、套件 | 业务操作 | | `config/` | 环境、超时、套件 | 业务操作 |
| `data/` | 路径、列名、样本字段、mock 值 | 选择器、click/fill | | `data/` | 路径、列名、样本字段、mock 值 | 选择器、click/fill、密码 |
| `locators/` | `getByRole` / `getByPlaceholder` / `getByTestId` | click、fill、断言 | | `locators/` | `getByRole` / `getByPlaceholder` / `getByTestId` | click、fill、断言 |
| `core/` | 等待、操作重试、诊断、健康检查、BasePage | 具体报表选择器 | | `core/` | 等待、操作重试、诊断、健康检查、BasePage | 具体报表选择器 |
| `pages/` | 业务操作、调用 core 等待 | 用例流程编排、裸选择器 | | `pages/` | 业务操作、取数/快照(如 `snapshotList`) | 用例主断言、流程编排、裸密码 |
| `tests/` | 调用 Page 方法 + 断言业务结果 | `page.locator(...)`、访问 `locators` | | `tests/` | 调用 Page 方法 + **`expect` 断言业务结果** | `page.locator(...)`(反例探测除外)、访问 `locators` |
厂商管理样板:`tests/spd/vendor-query.spec.ts` + `tests/spd/helpers/vendor-assert.helper.ts`(断言在用例;PO 无 `expect`)。
**智能等待**:查询按钮 loading、Ant Spin、表体有行/空态,统一走 `core/smart-wait.ts`。动画沉降用 `waitUiSettle`(双 rAF),不要在 Page 里写 `waitForTimeout`。 **智能等待**:查询按钮 loading、Ant Spin、表体有行/空态,统一走 `core/smart-wait.ts`。动画沉降用 `waitUiSettle`(双 rAF),不要在 Page 里写 `waitForTimeout`。
**重试策略**: **重试策略**:
- **操作层**(默认开):`BasePage` 的 `goto` / `click` / `fill` / `getText` 对点击被挡、节点卸载、导航超时等瞬时失败重试(`ACTION_RETRIES`,默认 3 次)。`expect`、健康检查、`guardedSkip` **不重试**。 - **操作层**(默认开):`BasePage` 的 `goto` / `click` / `fill` / `getText` 对点击被挡、节点卸载、导航超时等瞬时失败重试(`ACTION_RETRIES`,默认 3 次)。`expect`、健康检查、`guardedSkip` **不重试**。
- **写操作安全**(默认防重复触发):导出、提交、删除、打印等 `{ operation: 'write' }` 的点击**只尝试 1 次**、且不试 fallback;仅当业务确认幂等时才加 `idempotent: true`。
- **超时总预算**:`retryAction` 的 `deadlineMs`(默认 `ACTION_RETRY_BUDGET_MS=45_000`)限制单次操作总耗时,避免 `次数 × 单次超时` 叠成几十秒慢失败。
- **用例层**(默认关):全局 `retries=0`,Jenkins / GitHub 也不自动打开。已知瞬时 flaky 用 `testFlaky`(带 `@flaky`),断言失败仍立即失败。不要设 `PW_RETRIES` 掩盖断言失败。 - **用例层**(默认关):全局 `retries=0`,Jenkins / GitHub 也不自动打开。已知瞬时 flaky 用 `testFlaky`(带 `@flaky`),断言失败仍立即失败。不要设 `PW_RETRIES` 掩盖断言失败。
**Flake 治理**:失败自动分为产品/脚本/环境/数据/瞬时,写入 `failure-context.md`。CI 累计 `reports/flake/history.jsonl`,`npm run flake:report` 看趋势与隔离候选。已知瞬时问题进 `config/quarantine.json`,主门禁默认隔离;`npm run test:quarantine` 单独验证。细则见 [docs/flake.md](docs/flake.md)。 **Flake 治理**:失败自动分为产品/脚本/环境/数据/瞬时,写入 `failure-context.md`。CI 累计 `reports/flake/history.jsonl`,`npm run flake:report` 看趋势与隔离候选;`npm run quarantine:sync`(预览)/ `quarantine:apply`(落库)把候选写入 `config/quarantine.json`——只隔离环境/瞬时/脚本类,**不自动隔离产品缺陷**。主门禁默认隔离;`npm run test:quarantine` 单独验证。细则见 [docs/flake.md](docs/flake.md)。
**失败频次标记**:跨构建按 `STAT-` 编号累计每个用例的 `失败/执行`,失败次数与失败率双达标即标为「经常失败」。`npm run failures:report` 查榜单(`reports/flake/frequent-failures.md`),CI 邮件摘要自动附该小节;用例报告里带 `frequentFailure` / `failureRuns` / `failureRate` annotation,可直接筛选。
**数据驱动**:每个报表一份 `data/{page}.data.ts`(`CenterReportPageData`),Page 构造时注入;用例标题带 `STAT-xxx`,报告里自动带 `caseId`。 **数据驱动**:每个报表一份 `data/{page}.data.ts`(`CenterReportPageData`),Page 构造时注入;用例标题带 `STAT-xxx`,报告里自动带 `caseId`。
**失败诊断**:失败时附件 `failure-context.md`(URL、iframe、接口/页面报错),HTML / Allure 可直接打开。通过用例仍做运行时健康收尾,避免接口 5xx 被当成通过。 **失败诊断**:失败时附件 `failure-context.md`(URL、iframe、接口/页面报错),HTML / Allure 可直接打开。通过用例仍做运行时健康收尾,避免接口 5xx 被当成通过。
`seed.spec.ts` 默认不执行。需要时:`npm run test:seed`。CI 默认不跑 `*.stat-*.spec.ts` 调试用例;本地按文件跑不受影响,强制纳入:`INCLUDE_DEBUG_SPECS=1`。 `seed.spec.ts` 默认不执行。需要时:`npm run test:seed`。单条用例本地单步用 `PWDEBUG=1 npx playwright test <spec> --project=chromium`。
## 新增页面清单 ## 新增页面清单
......
...@@ -14,6 +14,16 @@ export const credentials: Credentials = { ...@@ -14,6 +14,16 @@ export const credentials: Credentials = {
password: process.env.TEST_PASS ?? '', password: process.env.TEST_PASS ?? '',
}; };
/** 第二测试账号,与主账号并行跑用例时按 worker 分配登录态。 */
export const credentialsSecondary: Credentials = {
username: process.env.TEST_USER_2 ?? '',
password: process.env.TEST_PASS_2 ?? '',
};
export function hasCredentials(): boolean { export function hasCredentials(): boolean {
return Boolean(credentials.username && credentials.password); return Boolean(credentials.username && credentials.password);
} }
export function hasSecondaryCredentials(): boolean {
return Boolean(credentialsSecondary.username && credentialsSecondary.password);
}
...@@ -94,6 +94,45 @@ export const PAGE_GROUPS = { ...@@ -94,6 +94,45 @@ export const PAGE_GROUPS = {
ops: ['tests/spd/ops-check.spec.ts'], ops: ['tests/spd/ops-check.spec.ts'],
reag: ['tests/spd/reag-entry.spec.ts'], reag: ['tests/spd/reag-entry.spec.ts'],
auth: ['tests/spd/login.spec.ts', 'tests/spd/home.spec.ts'], auth: ['tests/spd/login.spec.ts', 'tests/spd/home.spec.ts'],
vendor: [
'tests/spd/vendor-query.spec.ts',
'tests/spd/vendor-qual.spec.ts',
'tests/spd/vendor-audit.spec.ts',
'tests/spd/vendor-cert-diff.spec.ts',
'tests/spd/vendor-eval-scheme.spec.ts',
'tests/spd/vendor-eval-indicator.spec.ts',
'tests/spd/vendor-holiday.spec.ts',
],
admit: [
'tests/spd/admit-material.spec.ts',
'tests/spd/admit-material-list.spec.ts',
'tests/spd/admit-product-apply.spec.ts',
'tests/spd/admit-product-list.spec.ts',
'tests/spd/admit-supply.spec.ts',
'tests/spd/admit-task.spec.ts',
],
basic: [
'tests/spd/basic-user.spec.ts',
'tests/spd/basic-user-union.spec.ts',
'tests/spd/basic-role.spec.ts',
'tests/spd/basic-branch.spec.ts',
'tests/spd/basic-notice.spec.ts',
'tests/spd/basic-hospital-info.spec.ts',
'tests/spd/basic-finance-month.spec.ts',
],
surgery: [
'tests/spd/surgery-base-pkg.spec.ts',
'tests/spd/surgery-oper-type.spec.ts',
'tests/spd/surgery-std-pkg.spec.ts',
],
warehouse: [
'tests/spd/warehouse-zone.spec.ts',
'tests/spd/warehouse-probe.spec.ts',
],
flow: [
'tests/spd/flow-def.spec.ts',
'tests/spd/flow-inst.spec.ts',
],
}; };
/** @typedef {{ description: string, files: string[], grep?: string, runFull?: boolean, runQuarantine?: boolean, workers?: number, retries?: number }} SuiteDef */ /** @typedef {{ description: string, files: string[], grep?: string, runFull?: boolean, runQuarantine?: boolean, workers?: number, retries?: number }} SuiteDef */
...@@ -109,7 +148,7 @@ export const SUITES = { ...@@ -109,7 +148,7 @@ export const SUITES = {
retries: 0, retries: 0,
}, },
smoke: { smoke: {
description: '冒烟:全部报表模块中已打 @smoke 的用例(登录/首页、成本、开票、科室、中心库、消耗、追溯、对账、预警、政策、异常、统计分析、运维、试剂)', description: '冒烟:报表与基础设置中已打 @smoke 的用例(登录/首页、报表、厂商、准入、基础管理、手术、库房、流程)',
files: [ files: [
...PAGE_GROUPS.auth, ...PAGE_GROUPS.auth,
...PAGE_GROUPS.cost, ...PAGE_GROUPS.cost,
...@@ -125,6 +164,12 @@ export const SUITES = { ...@@ -125,6 +164,12 @@ export const SUITES = {
...PAGE_GROUPS.ana, ...PAGE_GROUPS.ana,
...PAGE_GROUPS.ops, ...PAGE_GROUPS.ops,
...PAGE_GROUPS.reag, ...PAGE_GROUPS.reag,
...PAGE_GROUPS.vendor,
...PAGE_GROUPS.admit,
...PAGE_GROUPS.basic,
...PAGE_GROUPS.surgery,
...PAGE_GROUPS.warehouse,
...PAGE_GROUPS.flow,
], ],
grep: '@smoke', grep: '@smoke',
workers: 3, workers: 3,
...@@ -215,6 +260,42 @@ export const SUITES = { ...@@ -215,6 +260,42 @@ export const SUITES = {
workers: 2, workers: 2,
retries: 0, retries: 0,
}, },
vendor: {
description: '厂商管理',
files: PAGE_GROUPS.vendor,
workers: 1,
retries: 0,
},
admit: {
description: '准入管理',
files: PAGE_GROUPS.admit,
workers: 1,
retries: 0,
},
basic: {
description: '基础管理(用户/角色/院区等)',
files: PAGE_GROUPS.basic,
workers: 1,
retries: 0,
},
surgery: {
description: '手术包管理',
files: PAGE_GROUPS.surgery,
workers: 1,
retries: 0,
},
warehouse: {
description: '库区与探针',
files: PAGE_GROUPS.warehouse,
workers: 1,
retries: 0,
},
flow: {
description: '流程定义与实例',
files: PAGE_GROUPS.flow,
workers: 1,
retries: 0,
},
extended: { extended: {
description: '扩展报表:预警/运维/异常/政策/财务分析/试剂', description: '扩展报表:预警/运维/异常/政策/财务分析/试剂',
files: [ files: [
......
...@@ -24,12 +24,23 @@ export const timeouts = { ...@@ -24,12 +24,23 @@ export const timeouts = {
actionRetries: num('ACTION_RETRIES', 3), actionRetries: num('ACTION_RETRIES', 3),
/** 操作重试间隔基数(毫秒),第 n 次等待 n * 该值 */ /** 操作重试间隔基数(毫秒),第 n 次等待 n * 该值 */
actionRetryDelayMs: num('ACTION_RETRY_DELAY_MS', 250), actionRetryDelayMs: num('ACTION_RETRY_DELAY_MS', 250),
/**
* 单次操作重试的总时间预算(毫秒,含首次尝试与退避)。
* 到点即停,避免 次数 × 单次超时 叠加成几十秒慢失败;0 表示不限。
* 默认 45_000:约为 action(12s) × 3 的 1.25 倍上限,慢页面下也能及时收敛。
*/
actionRetryBudgetMs: num('ACTION_RETRY_BUDGET_MS', 45_000),
/** iframe 挂载、查询按钮可见 */ /** iframe 挂载、查询按钮可见 */
pageReady: num('PAGE_READY_TIMEOUT', 20_000), pageReady: num('PAGE_READY_TIMEOUT', 20_000),
/** 表体 attached / spin hidden */ /** 表体 attached / spin hidden */
tableAttached: num('TABLE_ATTACHED_TIMEOUT', 15_000), tableAttached: num('TABLE_ATTACHED_TIMEOUT', 15_000),
/** waitForTableReady 轮询上限(原 45s) */ /** waitForTableReady 轮询上限(原 45s) */
tableReady: num('TABLE_READY_TIMEOUT', 20_000), tableReady: num('TABLE_READY_TIMEOUT', 20_000),
/**
* 查询后尚无行、无空态、也无 spin 时,留给 loading 出现的短窗口(毫秒)。
* 超过即视为空闲,避免空表卡满整段 tableReady。
*/
tableIdleGrace: num('TABLE_IDLE_GRACE_MS', 400),
/** 查询按钮 loading 消失 */ /** 查询按钮 loading 消失 */
searchLoading: num('SEARCH_LOADING_TIMEOUT', 15_000), searchLoading: num('SEARCH_LOADING_TIMEOUT', 15_000),
/** 导出接口 waitForResponse */ /** 导出接口 waitForResponse */
...@@ -37,6 +48,8 @@ export const timeouts = { ...@@ -37,6 +48,8 @@ export const timeouts = {
/** auth setup 探活 */ /** auth setup 探活 */
authProbeNav: num('AUTH_PROBE_NAV_TIMEOUT', 12_000), authProbeNav: num('AUTH_PROBE_NAV_TIMEOUT', 12_000),
authProbeMenu: num('AUTH_PROBE_MENU_TIMEOUT', 8_000), authProbeMenu: num('AUTH_PROBE_MENU_TIMEOUT', 8_000),
/** auth.setup 写登录态(含锁等待 + 登录跳转) */
authSetup: num('AUTH_SETUP_TIMEOUT', 120_000),
/** /**
* token 剩余有效期大于该值则跳过服务端探活(默认 20 分钟)。 * token 剩余有效期大于该值则跳过服务端探活(默认 20 分钟)。
* 设 AUTH_SKIP_PROBE_IF_FRESH=0 可强制每次探活。 * 设 AUTH_SKIP_PROBE_IF_FRESH=0 可强制每次探活。
......
import { timeouts } from '../config/timeouts.config'; import { timeouts } from '../config/timeouts.config';
import { logger } from './logger'; import { logger } from './logger';
/**
* 操作语义:
* - `read`(默认):查询、导航、读取、打开面板等可安全重复执行的操作,瞬时失败可重试。
* - `write`:导出、提交、删除、审批等会产生副作用的操作。超时后无法确认服务端是否已执行,
* 盲目重试会重复触发,因此默认只尝试 1 次(除非调用方显式声明 `idempotent: true`)。
*/
export type RetryOperation = 'read' | 'write';
export type RetryActionOptions = { export type RetryActionOptions = {
/** 总尝试次数(含首次)。默认 `ACTION_RETRIES`,通常 3。 */ /** 总尝试次数(含首次)。默认 `ACTION_RETRIES`,通常 3;写操作默认 1。 */
attempts?: number; attempts?: number;
delayMs?: number; delayMs?: number;
label?: string; label?: string;
onRetry?: (error: unknown, attempt: number) => Promise<void> | void; onRetry?: (error: unknown, attempt: number) => Promise<void> | void;
/** 操作语义,默认 `read`。写操作默认不重试,防重复触发。 */
operation?: RetryOperation;
/**
* 写操作是否幂等。仅当业务确认「重复执行安全」(如按固定单据号覆盖式保存)时设 true,
* 才会按 `attempts` 重试;否则即使设了更大 attempts 也只尝试 1 次。
*/
idempotent?: boolean;
/**
* 本次重试的总时间预算(毫秒,含首次尝试与退避等待)。
* 到点即停并抛出最后一次错误,避免 `次数 × 单次超时` 叠加成几十秒慢失败。
* 默认 `ACTION_RETRY_BUDGET_MS`;未设且时间为 0 表示不限。
*/
deadlineMs?: number;
}; };
type ErrorLike = { type ErrorLike = {
...@@ -68,30 +89,55 @@ function sleep(ms: number): Promise<void> { ...@@ -68,30 +89,55 @@ function sleep(ms: number): Promise<void> {
/** /**
* 底层操作细粒度重试。只重试瞬时 UI/导航错误;断言与健康检查失败立即抛出。 * 底层操作细粒度重试。只重试瞬时 UI/导航错误;断言与健康检查失败立即抛出。
*
* 安全边界:
* - 写操作(`operation: 'write'`)默认只尝试 1 次,避免副作用被重复触发;
* - 设了 `deadlineMs` 时,退避等待不会让总耗时突破预算。
*/ */
export async function retryAction<T>( export async function retryAction<T>(
action: () => Promise<T>, action: () => Promise<T>,
options: RetryActionOptions = {}, options: RetryActionOptions = {},
): Promise<T> { ): Promise<T> {
const attempts = Math.max(1, options.attempts ?? timeouts.actionRetries); const operation = options.operation ?? 'read';
const writesAreRisky = operation === 'write' && !options.idempotent;
const requestedAttempts = Math.max(1, options.attempts ?? timeouts.actionRetries);
// 非幂等写操作:无论传了多少次,都只执行一次。
const attempts = writesAreRisky ? 1 : requestedAttempts;
const delayMs = options.delayMs ?? timeouts.actionRetryDelayMs; const delayMs = options.delayMs ?? timeouts.actionRetryDelayMs;
const label = options.label ?? '操作'; const label = options.label ?? '操作';
const deadlineMs = Math.max(0, options.deadlineMs ?? timeouts.actionRetryBudgetMs);
const startedAt = Date.now();
const budgetExhausted = (): boolean => deadlineMs > 0 && Date.now() - startedAt >= deadlineMs;
let lastError: unknown; let lastError: unknown;
if (writesAreRisky && requestedAttempts > 1) {
logger.warn(
`${label} 属写操作,已禁用重试(防重复触发);如确认幂等请传 idempotent: true`,
);
}
for (let attempt = 1; attempt <= attempts; attempt += 1) { for (let attempt = 1; attempt <= attempts; attempt += 1) {
try { try {
return await action(); return await action();
} catch (error) { } catch (error) {
lastError = error; lastError = error;
const retryable = shouldRetryAction(error); const retryable = shouldRetryAction(error);
if (!retryable || attempt === attempts) { const isLast = attempt === attempts;
if (!retryable || isLast) {
throw error;
}
if (budgetExhausted()) {
logger.warn(`${label} 重试预算 ${deadlineMs}ms 已用完,放弃重试:${errorText(error).slice(0, 200)}`);
throw error; throw error;
} }
logger.warn( logger.warn(
`${label} 瞬时失败,重试 ${attempt}/${attempts - 1}:${errorText(error).slice(0, 240)}`, `${label} 瞬时失败,重试 ${attempt}/${attempts - 1}:${errorText(error).slice(0, 240)}`,
); );
await options.onRetry?.(error, attempt); await options.onRetry?.(error, attempt);
await sleep(delayMs * attempt); // 退避等待不越界:预算不足时按剩余时间收敛,至少不等待。
const backoff = delayMs * attempt;
const remaining = deadlineMs > 0 ? deadlineMs - (Date.now() - startedAt) : backoff;
await sleep(Math.max(0, Math.min(backoff, remaining)));
} }
} }
......
import { expect, type Locator, type Page, test } from '@playwright/test'; import { expect, type Locator, type Page, test } from '@playwright/test';
import { timeouts } from '../config/timeouts.config'; import { timeouts } from '../config/timeouts.config';
import { retryAction } from './action-retry'; import { retryAction, type RetryOperation } from './action-retry';
import { runWithLocatorHeal } from './heal-locator'; import { runWithLocatorHeal } from './heal-locator';
import { import {
waitAntSpinGone, waitAntSpinGone,
...@@ -16,6 +16,9 @@ import { ...@@ -16,6 +16,9 @@ import {
import { type PageHealthScope } from './page-health.util'; import { type PageHealthScope } from './page-health.util';
import { logger } from './logger'; import { logger } from './logger';
/** 列表就绪后的健康断言:进页仅 ui;查询/导出等业务动作完成后用 business。 */
export type TableHealthMode = 'ui' | 'business';
/** 所有 Page Object 的基类:导航、智能等待、通用操作。选择器不写在这里。 */ /** 所有 Page Object 的基类:导航、智能等待、通用操作。选择器不写在这里。 */
export class BasePage { export class BasePage {
constructor(protected readonly page: Page) {} constructor(protected readonly page: Page) {}
...@@ -30,12 +33,41 @@ export class BasePage { ...@@ -30,12 +33,41 @@ export class BasePage {
return { page: this.page }; return { page: this.page };
} }
/** 系统挂掉、接口报错或页面报错时 fail,避免被 skip 掩盖。 */ /** 进页/菜单导航后:只校验页面可交互(无业务错误提示),不校验静态资源与被动加载接口。 */
protected async assertUiReady(context: string): Promise<void> {
await assertRuntimeHealthy(
this.pageHealthScope(),
context,
getRuntimeHealthGuard(this.page),
'ui',
);
}
/**
* 业务动作(查询/保存/导出等)执行前:清空接口窗口并确认当前可交互。
* 框架聚焦 UI 交互与布局,不承担字体/图片等资源完整性检查。
*/
protected async assertBeforeBusinessAction(actionLabel: string): Promise<void> {
getRuntimeHealthGuard(this.page)?.beginBusinessActionWindow();
await this.assertUiReady(`${actionLabel}前`);
}
/** 等表/壳就绪后:按场景选择是否校验业务接口。 */
protected async assertAfterTableWait(
context: string,
mode: TableHealthMode = 'business',
): Promise<void> {
if (mode === 'ui') await this.assertUiReady(context);
else await this.assertPageHealthy(context);
}
/** 业务动作完成后:UI + 本动作窗口内业务数据接口。 */
protected async assertPageHealthy(context: string): Promise<void> { protected async assertPageHealthy(context: string): Promise<void> {
await assertRuntimeHealthy( await assertRuntimeHealthy(
this.pageHealthScope(), this.pageHealthScope(),
context, context,
getRuntimeHealthGuard(this.page), getRuntimeHealthGuard(this.page),
'business',
); );
} }
...@@ -88,7 +120,7 @@ export class BasePage { ...@@ -88,7 +120,7 @@ export class BasePage {
async click( async click(
locator: Locator, locator: Locator,
stepName?: string, stepName?: string,
options?: { timeout?: number; fallbacks?: Locator[] }, options?: { timeout?: number; fallbacks?: Locator[]; operation?: RetryOperation; idempotent?: boolean },
): Promise<void> { ): Promise<void> {
const timeout = options?.timeout ?? timeouts.action; const timeout = options?.timeout ?? timeouts.action;
const action = async (): Promise<void> => { const action = async (): Promise<void> => {
...@@ -114,6 +146,9 @@ export class BasePage { ...@@ -114,6 +146,9 @@ export class BasePage {
}, },
{ {
label: stepName ?? 'click', label: stepName ?? 'click',
// 导出/提交/删除类写操作默认不重试,防副作用重复触发
operation: options?.operation ?? 'read',
idempotent: options?.idempotent,
onRetry: async () => { onRetry: async () => {
await waitAntSpinGone(this.page); await waitAntSpinGone(this.page);
await waitUiSettle(this.page); await waitUiSettle(this.page);
...@@ -132,7 +167,7 @@ export class BasePage { ...@@ -132,7 +167,7 @@ export class BasePage {
locator: Locator, locator: Locator,
value: string, value: string,
stepName?: string, stepName?: string,
options?: { fallbacks?: Locator[] }, options?: { fallbacks?: Locator[]; operation?: RetryOperation; idempotent?: boolean },
): Promise<void> { ): Promise<void> {
const action = async (): Promise<void> => { const action = async (): Promise<void> => {
if (stepName) { if (stepName) {
...@@ -147,6 +182,8 @@ export class BasePage { ...@@ -147,6 +182,8 @@ export class BasePage {
}, },
{ {
label: stepName ?? 'fill', label: stepName ?? 'fill',
operation: options?.operation ?? 'read',
idempotent: options?.idempotent,
onRetry: async () => { onRetry: async () => {
await waitAntSpinGone(this.page); await waitAntSpinGone(this.page);
await waitUiSettle(this.page); await waitUiSettle(this.page);
...@@ -164,4 +201,164 @@ export class BasePage { ...@@ -164,4 +201,164 @@ export class BasePage {
async getText(locator: Locator): Promise<string> { async getText(locator: Locator): Promise<string> {
return retryAction(async () => (await locator.innerText()).trim(), { label: '读取文本' }); return retryAction(async () => (await locator.innerText()).trim(), { label: '读取文本' });
} }
/** 搜索条作用域:有 iframe 用首个 iframe,否则用主文档。 */
async queryScope(): Promise<FilterScope> {
const frames = this.page.locator('iframe');
if ((await frames.count().catch(() => 0)) > 0) return this.page.frameLocator('iframe').first();
return this.page;
}
/**
* 通用多条件筛选:按字段名定位搜索条,一次性填多个条件后查询。
* 用于「组合查询」类用例;单字段用例仍可用各页原有专用方法。
*/
/**
* 通用多条件筛选:按字段名定位搜索条,一次性填多个条件后查询。
* 用于「组合查询」类用例;单字段用例仍可用各页原有专用方法。
*/
async applyFilterCombination(
fields: FilterField[],
options: {
/** 搜索条根节点;iframe 页传入 frameLocator */
scope?: FilterScope;
/** 触发查询的按钮;默认按「查 询」文本找 */
queryButton?: Locator;
/** 字段不可见时:true=跳过并记录,false=直接失败 */
skipMissing?: boolean;
} = {},
): Promise<{ applied: string[]; missing: string[] }> {
const scope = options.scope ?? this.page;
const applied: string[] = [];
const missing: string[] = [];
// 筛选区默认可能折叠(如差异对比页),先展开再填,否则后半截字段不可见
await this.expandFiltersIfCollapsed(scope);
for (const field of fields) {
const item = await this.resolveFilterItem(scope, field.label);
if (!item) {
missing.push(field.label);
if (options.skipMissing === false) {
throw new Error(`筛选字段「${field.label}」不可见,组合查询无法执行`);
}
continue;
}
if (field.kind === 'select') {
await this.pickFirstOption(scope, item, field.label);
} else if (field.kind === 'dateRange') {
await this.fillDateRange(scope, item, field.label);
} else {
const input = item.locator('input').first();
await this.fill(input, field.value ?? '', `组合查询:${field.label}`, {
fallbacks: [scope.getByPlaceholder(new RegExp(field.label)).first()],
});
}
applied.push(field.label);
}
logger.step(`组合查询:已填 ${applied.length}/${fields.length} 项,未找到 ${missing.join('、') || '无'}`);
expect(
applied.length,
`组合查询至少要有 2 个条件生效(已生效:${applied.join('、') || '无'})`,
).toBeGreaterThanOrEqual(Math.min(2, fields.length));
const queryBtn = options.queryButton ?? scope.getByRole('button', { name: /查\s*询/ }).first();
await this.click(queryBtn.first(), '组合查询:点击查询');
await this.waitForTableShellVisible();
return { applied, missing };
}
/** 搜索条「展开」:折叠时点开,保证所有筛选字段可填(如差异对比页默认只露 3 项)。 */
protected async expandFiltersIfCollapsed(scope: FilterScope): Promise<void> {
const expand = scope.getByRole('button', { name: /^展\s*开$/ }).first();
const expandByClass = scope.locator('button._btn.collapse').filter({ hasText: '展开' }).first();
const target = (await expand.isVisible().catch(() => false)) ? expand : expandByClass;
if (await target.isVisible().catch(() => false)) {
await this.click(target, '组合查询:展开筛选区');
await this.waitSettle(600);
}
}
/**
* 搜索条中的单个字段:按 label[for] 精确定位到同级控件容器。
* antd 的 label for / 控件 id 一一对应,比 hasText 更可靠(避免「物料名称」误命中含同名文字的外层)。
*/
protected async resolveFilterItem(scope: FilterScope, label: string): Promise<Locator | null> {
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const exactLabel = new RegExp(`^\\s*${escaped}\\s*$`);
const labels = scope.locator('.ant-form-item label').filter({ hasText: exactLabel });
const count = await labels.count().catch(() => 0);
for (let i = 0; i < count; i += 1) {
const lab = labels.nth(i);
const id = await lab.getAttribute('for').catch(() => null);
if (!id) continue;
const item = scope.locator('.ant-form-item').filter({ has: scope.locator(`#${CSS_ESCAPE(id)}`) }).first();
if (await item.isVisible().catch(() => false)) return item;
}
// 退化:label 所在表单项(仅当 label 文本完全相等时)
const fallback = scope.locator('.ant-form-item').filter({ has: scope.locator('label', { hasText: exactLabel }) }).first();
if (await fallback.isVisible().catch(() => false)) return fallback;
return null;
}
/**
* 打开下拉并取第一个可选项。浮层挂在**当前 scope 的 document.body**——
* iframe 页面的浮层在 iframe 内,主页面查不到,因此 scope 必须传对。
*/
protected async pickFirstOption(scope: FilterScope, item: Locator, label: string): Promise<string> {
const dropdown = scope.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)').last();
await this.click(item.locator('.ant-select-selector').first(), `组合查询:展开「${label}」`);
const option = dropdown.locator('.ant-select-item-option:not(.ant-select-item-option-disabled)').first();
await option.waitFor({ state: 'visible', timeout: timeouts.expect });
const text = ((await option.innerText().catch(() => '')) ?? '').replace(/\s/g, '');
await this.click(option, `组合查询:选择「${label}」`);
await this.waitSettle(300);
return text;
}
/**
* 日期区间:antd range picker 输入框是 readonly,必须走浮层「今天」选择,
* 起止同一天既保证区间合法,又不依赖业务日期数据。浮层同样跟随 scope。
*/
protected async fillDateRange(scope: FilterScope, item: Locator, label: string): Promise<void> {
await this.click(item.locator('input').first(), `组合查询:打开「${label}」`);
const panel = scope.locator('.ant-picker-dropdown:not(.ant-picker-dropdown-hidden)').last();
const today = panel.getByText(/今\s*天/).first();
const todayVisible = await today.isVisible().catch(() => false);
if (todayVisible) {
await this.click(today, `组合查询:「${label}」选择今天`);
await this.click(today, `组合查询:「${label}」结束选择今天`).catch(() => undefined);
} else {
// 无「今天」快捷项时退化为键盘回车,避免写死日期格式
await item.locator('input').first().press('Enter').catch(() => undefined);
}
await this.page.keyboard.press('Escape').catch(() => undefined);
await this.waitSettle(300);
}
/** 列表壳就绪(表头或空态出现),不含业务接口健康断言。 */
async waitForTableShellVisible(timeout = timeouts.expect): Promise<void> {
await this.page
.locator('.ant-table-thead th, .ant-table-header th, .ant-empty-normal')
.first()
.waitFor({ state: 'visible', timeout })
.catch(() => undefined);
await this.waitSettle(400);
}
} }
/** 搜索条作用域:Page、Locator 或 FrameLocator 均可。 */
export type FilterScope = Pick<Page, 'locator' | 'getByRole' | 'getByPlaceholder'>;
/** CSS id 选择器转义(antd 的 id 形如 table_search_form_keyword,安全,但统一走转义)。 */
const CSS_ESCAPE = (id: string) => id.replace(/([^\w-])/g, '\\$1');
/** 组合查询的单个筛选条件。 */
export type FilterField = {
/** 搜索条上的字段名,需与页面 label 文本一致 */
label: string;
kind: 'text' | 'select' | 'dateRange';
/** kind=text 时的输入值;其余可省略 */
value?: string;
};
...@@ -17,6 +17,7 @@ export type FailureSnapshot = { ...@@ -17,6 +17,7 @@ export type FailureSnapshot = {
pageTitle: string; pageTitle: string;
frames: string[]; frames: string[];
healthIssues: string[]; healthIssues: string[];
staticObservations: string[];
closed: boolean; closed: boolean;
classification?: FailureClassification; classification?: FailureClassification;
healSuggestions?: string[]; healSuggestions?: string[];
...@@ -33,6 +34,7 @@ export async function collectFailureSnapshot( ...@@ -33,6 +34,7 @@ export async function collectFailureSnapshot(
let pageTitle = ''; let pageTitle = '';
const frames: string[] = []; const frames: string[] = [];
let healthIssues: string[] = []; let healthIssues: string[] = [];
let staticObservations: string[] = [];
if (!closed) { if (!closed) {
url = page.url(); url = page.url();
...@@ -44,7 +46,10 @@ export async function collectFailureSnapshot( ...@@ -44,7 +46,10 @@ export async function collectFailureSnapshot(
} }
} }
if (healthGuard) { if (healthGuard) {
healthIssues = await healthGuard.collectAllIssues().catch(() => [...healthGuard.getApiIssues()]); healthIssues = await healthGuard
.collectAllIssues()
.catch(() => [...healthGuard.getBusinessApiIssues()]);
staticObservations = [...healthGuard.getStaticObservations()];
} }
} }
...@@ -58,6 +63,7 @@ export async function collectFailureSnapshot( ...@@ -58,6 +63,7 @@ export async function collectFailureSnapshot(
pageTitle, pageTitle,
frames: frames.slice(0, 8), frames: frames.slice(0, 8),
healthIssues, healthIssues,
staticObservations,
closed, closed,
classification, classification,
healSuggestions, healSuggestions,
...@@ -84,6 +90,13 @@ export function formatFailureMarkdown(snapshot: FailureSnapshot): string { ...@@ -84,6 +90,13 @@ export function formatFailureMarkdown(snapshot: FailureSnapshot): string {
} else { } else {
lines.push(``, `## 运行时健康问题`, `- 无(或页面已关闭未能采集)`); lines.push(``, `## 运行时健康问题`, `- 无(或页面已关闭未能采集)`);
} }
if (snapshot.staticObservations.length) {
lines.push(
``,
`## 静态资源观测(仅日志,不阻断用例)`,
...snapshot.staticObservations.slice(0, 10).map((item) => `- ${item}`),
);
}
if (snapshot.healSuggestions?.length) { if (snapshot.healSuggestions?.length) {
lines.push( lines.push(
``, ``,
......
...@@ -58,7 +58,7 @@ export function classifyFailure(input: { ...@@ -58,7 +58,7 @@ export function classifyFailure(input: {
/net::ERR_|ECONNRESET|ENOTFOUND|ETIMEDOUT|ERR_CONNECTION|ERR_EMPTY_RESPONSE|ERR_INTERNET|browser has been closed|Target closed|Browser closed/i.test( /net::ERR_|ECONNRESET|ENOTFOUND|ETIMEDOUT|ERR_CONNECTION|ERR_EMPTY_RESPONSE|ERR_INTERNET|browser has been closed|Target closed|Browser closed/i.test(
raw, raw,
) || ) ||
/接口请求失败:.*\(net::/i.test(health) /业务接口请求失败:.*\(net::/i.test(health)
) { ) {
return { return {
cls: 'environment', cls: 'environment',
...@@ -79,7 +79,11 @@ export function classifyFailure(input: { ...@@ -79,7 +79,11 @@ export function classifyFailure(input: {
}; };
} }
if (/检测到页面\/接口\/系统报错|检测到页面报错或系统异常|业务码=/.test(raw)) { if (
/检测到业务数据接口错误|检测到页面业务错误提示|检测到页面报错或系统异常|业务码=|业务接口/.test(
raw,
)
) {
return { return {
cls: 'product', cls: 'product',
confidence: 'high', confidence: 'high',
......
/**
* 用例失败频次统计:跨构建累计每个用例(按 STAT- 编号聚合)的执行/失败次数,
* 用于标记「经常执行失败」的用例,便于优先修脚本或提单。
*
* 存储:reports/flake/failure-counts.json(累计文件,非单次 history,重启不清零)。
* 标记:失败/成功时写入 Playwright annotation(failureRuns / failureRate / frequentFailure),
* HTML / Allure / JUnit 均可检索;同时可写 markdown 榜单。
*
* 仅统计「真实执行」的用例:skip 不增加失败,但会增加执行次数;断言与瞬时失败都计入失败。
*/
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import type { TestInfo } from '@playwright/test';
import { extractCaseId } from './case-meta';
import { logger } from './logger';
export type CaseFailureStat = {
/** STAT-XXX-NNN */
id: string;
/** 最近一次看到的原标题(去掉编号前缀后可能变化) */
title: string;
/** 最近一次所在 spec(相对路径) */
file: string;
/** 累计执行次数(passed + failed + timedOut,不含 skipped) */
runs: number;
/** 累计通过 */
passed: number;
/** 累计失败(failed + timedOut) */
failed: number;
/** 首次失败时间 */
firstFailedAt?: string;
/** 最近一次失败时间 */
lastFailedAt?: string;
/** 最近一次失败分类 */
lastClass?: string;
/** 连续失败次数(一旦通过则归零) */
consecutiveFails: number;
};
export type FailureStore = {
version: number;
updatedAt: string;
/** 判定「经常失败」的阈值(失败次数下限) */
frequentMinFails: number;
/** 判定「经常失败」的阈值(失败率下限) */
frequentRate: number;
items: Record<string, CaseFailureStat>;
};
const DEFAULT_MIN_FAILS = 2;
const DEFAULT_RATE = 0.2;
function num(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined || raw === '') return fallback;
const n = Number(raw);
return Number.isFinite(n) && n >= 0 ? n : fallback;
}
function storePath(): string {
return process.env.PW_FAILURE_COUNTS || path.join(process.cwd(), 'reports/flake/failure-counts.json');
}
export function loadFailureStore(): FailureStore {
const empty: FailureStore = {
version: 1,
updatedAt: new Date().toISOString(),
frequentMinFails: num('FREQ_FAIL_MIN', DEFAULT_MIN_FAILS),
frequentRate: num('FREQ_FAIL_RATE', DEFAULT_RATE),
items: {},
};
try {
const parsed = JSON.parse(readFileSync(storePath(), 'utf8')) as Partial<FailureStore>;
return {
...empty,
...parsed,
// 阈值以当前环境变量为准,便于调参
frequentMinFails: num('FREQ_FAIL_MIN', parsed.frequentMinFails ?? DEFAULT_MIN_FAILS),
frequentRate: num('FREQ_FAIL_RATE', parsed.frequentRate ?? DEFAULT_RATE),
items: parsed.items ?? {},
};
} catch {
return empty;
}
}
export function saveFailureStore(store: FailureStore): void {
const file = storePath();
store.updatedAt = new Date().toISOString();
try {
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, `${JSON.stringify(store, null, 2)}\n`, 'utf8');
} catch (error) {
logger.warn(`失败频次统计写入失败:${String(error)}`);
}
}
/**
* 多 worker / 多分片安全落盘:先读回磁盘上最新统计,把本次进程的增量合并进去再写,
* 避免各进程用「启动时的旧快照」互相覆盖。
*
* 合并策略:同一用例取两边的较大值。正常情况下一条用例只会落在某一个 worker/分片,
* 因此不存在同用例并发累加;取 max 既能保留先写进程的增量,也能在磁盘更新时不回退。
*/
export function mergeAndSaveFailureStore(local: FailureStore): void {
const file = storePath();
let onDisk: FailureStore;
try {
onDisk = JSON.parse(readFileSync(file, 'utf8')) as FailureStore;
} catch {
onDisk = { ...local, items: {} };
}
const merged: FailureStore = {
version: onDisk.version ?? local.version,
updatedAt: new Date().toISOString(),
frequentMinFails: local.frequentMinFails,
frequentRate: local.frequentRate,
items: { ...(onDisk.items ?? {}) },
};
for (const [id, stat] of Object.entries(local.items)) {
const prev = merged.items[id];
if (!prev) {
merged.items[id] = stat;
continue;
}
merged.items[id] = {
...prev,
title: stat.title || prev.title,
file: stat.file || prev.file,
// runs/passed/failed 为跨 worker 累加量,用「本地-基线」增量不可知,
// 这里以两边的最大值 + 增量差值近似,避免重复计数导致虚高。
runs: Math.max(prev.runs, stat.runs),
passed: Math.max(prev.passed, stat.passed),
failed: Math.max(prev.failed, stat.failed),
consecutiveFails: stat.consecutiveFails,
firstFailedAt: prev.firstFailedAt ?? stat.firstFailedAt,
lastFailedAt: laterIso(prev.lastFailedAt, stat.lastFailedAt),
lastClass: stat.lastClass ?? prev.lastClass,
};
}
try {
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
} catch (error) {
logger.warn(`失败频次统计写入失败:${String(error)}`);
}
}
function laterIso(a?: string, b?: string): string | undefined {
if (!a) return b;
if (!b) return a;
return a > b ? a : b;
}
export function failureRateOf(stat: Pick<CaseFailureStat, 'runs' | 'failed'>): number {
return stat.runs > 0 ? stat.failed / stat.runs : 0;
}
/** 是否「经常失败」:失败次数达标且失败率达标。 */
export function isFrequentFailure(stat: CaseFailureStat, store: FailureStore): boolean {
return stat.failed >= store.frequentMinFails && failureRateOf(stat) >= store.frequentRate;
}
/**
* 记录一次用例结果并更新累计统计(就地修改 store)。
* @returns 更新后的该用例统计(无编号则返回 undefined)。
*/
export function recordOutcome(
store: FailureStore,
input: {
title: string;
file: string;
status: TestInfo['status'];
failureClass?: string;
},
): CaseFailureStat | undefined {
const id = extractCaseId(input.title);
if (!id) return undefined;
const now = new Date().toISOString();
const stat: CaseFailureStat =
store.items[id] ??
{
id,
title: input.title,
file: input.file,
runs: 0,
passed: 0,
failed: 0,
consecutiveFails: 0,
};
// 跳过不计入执行;其余都算一次执行
if (input.status !== 'skipped') {
stat.runs += 1;
}
if (input.status === 'passed') {
stat.passed += 1;
stat.consecutiveFails = 0;
} else if (input.status === 'failed' || input.status === 'timedOut') {
stat.failed += 1;
stat.consecutiveFails += 1;
stat.lastFailedAt = now;
stat.firstFailedAt ??= now;
if (input.failureClass) stat.lastClass = input.failureClass;
}
// 标题/文件可能微调,始终记录最新
stat.title = input.title;
stat.file = input.file;
store.items[id] = stat;
return stat;
}
/** 把频次统计写进 annotation,报告与 JUnit 里可检索。 */
export function annotateFailureFrequency(testInfo: TestInfo, stat: CaseFailureStat, store: FailureStore): void {
const rate = failureRateOf(stat);
const frequent = isFrequentFailure(stat, store);
const push = (type: string, description: string): void => {
if (testInfo.annotations.some((item) => item.type === type && item.description === description)) return;
testInfo.annotations.push({ type, description });
};
push('failureRuns', `${stat.failed}/${stat.runs}`);
push('failureRate', rate.toFixed(2));
if (stat.consecutiveFails >= 2) push('consecutiveFails', String(stat.consecutiveFails));
if (frequent) push('frequentFailure', `经常失败:${stat.failed}/${stat.runs} (${(rate * 100).toFixed(0)}%)`);
}
export type FrequentFailureRow = CaseFailureStat & { rate: number };
/** 列出经常失败的用例,按失败次数、失败率排序。 */
export function listFrequentFailures(store: FailureStore): FrequentFailureRow[] {
return Object.values(store.items)
.filter((stat) => isFrequentFailure(stat, store))
.map((stat) => ({ ...stat, rate: failureRateOf(stat) }))
.sort((a, b) => b.failed - a.failed || b.rate - a.rate);
}
/** 生成 markdown 榜单,写入 reports/flake/frequent-failures.md。 */
export function formatFrequentFailuresMarkdown(store: FailureStore): string {
const rows = listFrequentFailures(store);
const lines = [
'# 经常失败的用例',
'',
`- 生成: ${new Date().toISOString()}`,
`- 判定: 失败 ≥ ${store.frequentMinFails} 次且失败率 ≥ ${store.frequentRate}`,
`- 累计用例数: ${Object.keys(store.items).length}`,
`- 达标条目: ${rows.length}`,
'',
'| 编号 | 失败/执行 | 失败率 | 连续失败 | 最近分类 | 最近失败时间 | 用例 |',
'|------|-----------|--------|----------|----------|--------------|------|',
...(rows.length
? rows.map(
(r) =>
`| ${r.id} | ${r.failed}/${r.runs} | ${(r.rate * 100).toFixed(0)}% | ${r.consecutiveFails} | ${
r.lastClass ?? '-'
} | ${r.lastFailedAt?.slice(0, 19).replace('T', ' ') ?? '-'} | ${r.title} |`,
)
: ['| - | - | - | - | - | - | 暂无(需多次构建累计) |']),
'',
];
return lines.join('\n');
}
export function writeFrequentFailuresReport(store: FailureStore = loadFailureStore()): string {
const file = path.join(path.dirname(storePath()), 'frequent-failures.md');
try {
mkdirSync(path.dirname(file), { recursive: true });
writeFileSync(file, formatFrequentFailuresMarkdown(store), 'utf8');
} catch (error) {
logger.warn(`经常失败榜单写入失败:${String(error)}`);
}
return file;
}
/**
* afterEach 调用:按当前用例结果**预估**统计并打 annotation(只读,不落盘)。
* 落盘由 reporter(主进程,覆盖更全)负责;这里预估是为了让 annotation 反映本次结果,
* 供 HTML/Allure/JUnit 即时筛选,不依赖 reporter 的执行时机。
*/
export function annotateFailureFrequencyForTest(
testInfo: TestInfo,
): CaseFailureStat | undefined {
const id = extractCaseId(testInfo.title);
if (!id) return undefined;
const store = loadFailureStore();
// 在内存副本上叠加本次结果,仅用于标注,不写盘,避免与 reporter 重复计数。
const projected = recordOutcome(store, {
title: testInfo.title,
file: testInfo.file,
status: testInfo.status,
failureClass: testInfo.annotations.find((item) => item.type === 'failureClass')?.description,
});
if (!projected) return undefined;
annotateFailureFrequency(testInfo, projected, store);
return projected;
}
/** 刷新 markdown 榜单(CI 收尾调用,或 reporter onEnd 调用)。 */
export function refreshFrequentFailuresReport(): string {
return writeFrequentFailuresReport(loadFailureStore());
}
...@@ -41,11 +41,13 @@ export async function runWithLocatorHeal( ...@@ -41,11 +41,13 @@ export async function runWithLocatorHeal(
options: RetryActionOptions & { label: string }, options: RetryActionOptions & { label: string },
): Promise<void> { ): Promise<void> {
const { label, ...retry } = options; const { label, ...retry } = options;
// 非幂等写操作:主定位失败后可能已部分触发副作用,不再尝试 fallback,避免重复触发。
const writeRisk = retry.operation === 'write' && !retry.idempotent;
try { try {
await retryAction(() => action(primary), { ...retry, label }); await retryAction(() => action(primary), { ...retry, label });
return; return;
} catch (primaryError) { } catch (primaryError) {
if (!fallbacks?.length) throw primaryError; if (!fallbacks?.length || writeRisk) throw primaryError;
for (let i = 0; i < fallbacks.length; i += 1) { for (let i = 0; i < fallbacks.length; i += 1) {
try { try {
await retryAction(() => action(fallbacks[i]), { await retryAction(() => action(fallbacks[i]), {
......
...@@ -4,6 +4,8 @@ export { ...@@ -4,6 +4,8 @@ export {
isTransientActionError, isTransientActionError,
retryAction, retryAction,
shouldRetryAction, shouldRetryAction,
type RetryActionOptions,
type RetryOperation,
} from './action-retry'; } from './action-retry';
export { FLAKY_TAG, resolveFlakyRetries } from './flaky'; export { FLAKY_TAG, resolveFlakyRetries } from './flaky';
export { export {
...@@ -15,6 +17,21 @@ export { ...@@ -15,6 +17,21 @@ export {
type FailureClassification, type FailureClassification,
} from './failure-classify'; } from './failure-classify';
export { applyQuarantine, loadQuarantine, matchQuarantine, QUARANTINE_TAG } from './quarantine'; export { applyQuarantine, loadQuarantine, matchQuarantine, QUARANTINE_TAG } from './quarantine';
export {
annotateFailureFrequency,
annotateFailureFrequencyForTest,
failureRateOf,
formatFrequentFailuresMarkdown,
isFrequentFailure,
listFrequentFailures,
loadFailureStore,
mergeAndSaveFailureStore,
recordOutcome,
refreshFrequentFailuresReport,
type CaseFailureStat,
type FailureStore,
type FrequentFailureRow,
} from './failure-tracker';
export { collectHealSuggestions, runWithLocatorHeal } from './heal-locator'; export { collectHealSuggestions, runWithLocatorHeal } from './heal-locator';
export { logger } from './logger'; export { logger } from './logger';
export { applyCaseMeta, caseTitle, extractCaseId } from './case-meta'; export { applyCaseMeta, caseTitle, extractCaseId } from './case-meta';
......
...@@ -24,6 +24,8 @@ export type QuarantineItem = { ...@@ -24,6 +24,8 @@ export type QuarantineItem = {
since: string; since: string;
ticket?: string; ticket?: string;
category?: FailureClass; category?: FailureClass;
/** 由 scripts/sync-quarantine.mjs 自动写入(区别于人工维护项)。 */
auto?: boolean;
}; };
export type QuarantineFile = { export type QuarantineFile = {
......
import { expect, type FrameLocator, type Locator, type Page, type Response } from '@playwright/test'; import { expect, type FrameLocator, type Locator, type Page, type Response } from '@playwright/test';
import { logger } from './logger';
import { import {
collectPageHealthIssues, collectPageHealthIssues,
type PageHealthScope, type PageHealthScope,
} from './page-health.util'; } from './page-health.util';
const MAX_ISSUES = 30; const MAX_ISSUES = 30;
const MAX_STATIC_OBSERVATIONS = 50;
/** 只解析小型业务包络;大列表响应跳过 JSON.parse。 */
const MAX_BUSINESS_ERROR_BODY = 64 * 1024;
const STATIC_ASSET_PATTERN = /\.(js|css|png|jpe?g|gif|svg|webp|woff2?|ico|map)(\?|$)/i; /** 纯视觉/静态资源后缀(不参与阻断,仅观测日志)。 */
const STATIC_ASSET_PATTERN =
/\.(js|css|png|jpe?g|gif|svg|webp|woff2?|ttf|otf|eot|ico|map)(\?|$)/i;
/** 需要监控的业务接口(排除静态资源)。 */ /** 前端静态目录(字体、图标、打包资源等)。 */
function isTrackableApiUrl(url: string): boolean { const STATIC_PATH_PATTERN = /\/(assets|fonts|static|media|img|images|iconfont)\//i;
if (STATIC_ASSET_PATTERN.test(url)) return false;
/** Playwright resourceType:框架 route 会 abort 的纯视觉资源,失败只记日志。 */
const PASSIVE_VISUAL_RESOURCE_TYPES = new Set(['image', 'media', 'font']);
const BUSINESS_ACTION_PATTERN =
/(export|query|list|search|async|save|update|delete|add|remove)/i;
export type RuntimeHealthAssertMode = 'ui' | 'business';
function isStaticResourceUrl(url: string): boolean {
if (STATIC_ASSET_PATTERN.test(url)) return true;
if (STATIC_PATH_PATTERN.test(url)) return true;
return false;
}
function isPassiveVisualResource(url: string, resourceType?: string): boolean {
if (resourceType && PASSIVE_VISUAL_RESOURCE_TYPES.has(resourceType)) return true;
return isStaticResourceUrl(url);
}
/** 业务数据源接口(XHR/JSON);静态壳与视觉资源不纳入。 */
function isTrackableBusinessApiUrl(url: string): boolean {
if (isStaticResourceUrl(url)) return false;
if (url.startsWith('data:') || url.startsWith('blob:')) return false; if (url.startsWith('data:') || url.startsWith('blob:')) return false;
return (
/\/api\//i.test(url) || if (/\/api\//i.test(url)) return true;
/\/spd\//i.test(url) || if (BUSINESS_ACTION_PATTERN.test(url)) return true;
/(export|query|list|report|search|async)/i.test(url) if (/\/report\//i.test(url) && BUSINESS_ACTION_PATTERN.test(url)) return true;
);
return false;
} }
function trimUrl(url: string): string { function trimUrl(url: string): string {
...@@ -27,8 +56,12 @@ function trimUrl(url: string): string { ...@@ -27,8 +56,12 @@ function trimUrl(url: string): string {
async function readBusinessError(response: Response): Promise<string | null> { async function readBusinessError(response: Response): Promise<string | null> {
const contentType = response.headers()['content-type'] ?? ''; const contentType = response.headers()['content-type'] ?? '';
if (!contentType.includes('json')) return null; if (!contentType.includes('json')) return null;
const lengthHeader = response.headers()['content-length'];
if (lengthHeader && Number(lengthHeader) > MAX_BUSINESS_ERROR_BODY) return null;
try { try {
const body = await response.json(); const raw = await response.body();
if (raw.byteLength > MAX_BUSINESS_ERROR_BODY) return null;
const body = JSON.parse(raw.toString('utf8')) as unknown;
if (!body || typeof body !== 'object') return null; if (!body || typeof body !== 'object') return null;
const record = body as Record<string, unknown>; const record = body as Record<string, unknown>;
const code = record.code ?? record.statusCode ?? record.errCode; const code = record.code ?? record.statusCode ?? record.errCode;
...@@ -49,8 +82,14 @@ async function readBusinessError(response: Response): Promise<string | null> { ...@@ -49,8 +82,14 @@ async function readBusinessError(response: Response): Promise<string | null> {
return null; return null;
} }
/**
* UI 回归运行时守卫:阻断业务数据接口异常与页面业务错误提示;
* 字体/图片等视觉资源失败仅日志观测(与 fixtures 中 abort 策略一致)。
*/
export class RuntimeHealthGuard { export class RuntimeHealthGuard {
private readonly apiIssues: string[] = []; private readonly businessApiIssues: string[] = [];
private readonly staticObservations: string[] = [];
private readonly allowedIssuePatterns: RegExp[] = []; private readonly allowedIssuePatterns: RegExp[] = [];
...@@ -64,19 +103,42 @@ export class RuntimeHealthGuard { ...@@ -64,19 +103,42 @@ export class RuntimeHealthGuard {
this.attachListeners(page); this.attachListeners(page);
} }
private pushIssue(issue: string): void { /** 业务动作开始前调用:仅统计本动作窗口内的接口问题。 */
if (this.apiIssues.length >= MAX_ISSUES) return; beginBusinessActionWindow(): void {
if (this.apiIssues.includes(issue)) return; this.businessApiIssues.length = 0;
this.apiIssues.push(issue); }
private pushBusinessIssue(issue: string): void {
if (this.businessApiIssues.length >= MAX_ISSUES) return;
if (this.businessApiIssues.includes(issue)) return;
this.businessApiIssues.push(issue);
}
private observeStaticResource(message: string): void {
if (this.staticObservations.includes(message)) return;
if (this.staticObservations.length >= MAX_STATIC_OBSERVATIONS) return;
this.staticObservations.push(message);
logger.warn(`[health:static] ${message}`);
} }
private attachListeners(target: Page): void { private attachListeners(target: Page): void {
target.on('requestfailed', (request) => { target.on('requestfailed', (request) => {
if (!isTrackableApiUrl(request.url())) return; const url = request.url();
const failure = request.failure()?.errorText ?? 'unknown'; const failure = request.failure()?.errorText ?? 'unknown';
// 切换统计维度/重复查询时,浏览器会 abort 上一次 in-flight 请求,非业务失败
if (/ERR_ABORTED/i.test(failure)) return; if (/ERR_ABORTED/i.test(failure)) return;
this.pushIssue(`接口请求失败: ${request.method()} ${trimUrl(request.url())} (${failure})`);
const resourceType = request.resourceType();
if (isPassiveVisualResource(url, resourceType)) {
this.observeStaticResource(
`资源加载失败(${resourceType}): ${request.method()} ${trimUrl(url)} (${failure})`,
);
return;
}
if (!isTrackableBusinessApiUrl(url)) return;
this.pushBusinessIssue(
`业务接口请求失败: ${request.method()} ${trimUrl(url)} (${failure})`,
);
}); });
target.on('response', (response) => { target.on('response', (response) => {
...@@ -86,21 +148,32 @@ export class RuntimeHealthGuard { ...@@ -86,21 +148,32 @@ export class RuntimeHealthGuard {
private async handleResponse(response: Response): Promise<void> { private async handleResponse(response: Response): Promise<void> {
const url = response.url(); const url = response.url();
if (!isTrackableApiUrl(url)) return; const resourceType = response.request().resourceType();
const status = response.status(); if (isPassiveVisualResource(url, resourceType)) {
if (status >= 500) { const status = response.status();
this.pushIssue(`接口 HTTP ${status}: ${trimUrl(url)}`); if (status >= 400) {
this.observeStaticResource(`资源 HTTP ${status}: ${trimUrl(url)}`);
}
return; return;
} }
if (!isTrackableBusinessApiUrl(url)) return;
const contentType = response.headers()['content-type'] ?? '';
const isJsonResponse = contentType.includes('json');
const status = response.status();
if (status >= 400) { if (status >= 400) {
this.pushIssue(`接口 HTTP ${status}: ${trimUrl(url)}`); if (isJsonResponse || status >= 500) {
this.pushBusinessIssue(`业务接口 HTTP ${status}: ${trimUrl(url)}`);
}
return; return;
} }
const businessError = await readBusinessError(response); const businessError = await readBusinessError(response);
if (businessError) { if (businessError) {
this.pushIssue(`接口业务报错 ${trimUrl(url)}: ${businessError}`); this.pushBusinessIssue(`业务接口报错 ${trimUrl(url)}: ${businessError}`);
} }
} }
...@@ -117,11 +190,20 @@ export class RuntimeHealthGuard { ...@@ -117,11 +190,20 @@ export class RuntimeHealthGuard {
return issues.filter((issue) => !this.isAllowedIssue(issue)); return issues.filter((issue) => !this.isAllowedIssue(issue));
} }
/** @deprecated 使用 getBusinessApiIssues */
getApiIssues(): readonly string[] { getApiIssues(): readonly string[] {
return this.apiIssues; return this.businessApiIssues;
} }
async collectAllIssues(contextRoots?: Array<Locator | FrameLocator>): Promise<string[]> { getBusinessApiIssues(): readonly string[] {
return this.businessApiIssues;
}
getStaticObservations(): readonly string[] {
return this.staticObservations;
}
private async collectUiIssues(contextRoots?: Array<Locator | FrameLocator>): Promise<string[]> {
const roots = contextRoots ?? [...this.extraRoots]; const roots = contextRoots ?? [...this.extraRoots];
const scope: PageHealthScope = { page: this.page, roots }; const scope: PageHealthScope = { page: this.page, roots };
const uiIssues = await collectPageHealthIssues(scope); const uiIssues = await collectPageHealthIssues(scope);
...@@ -145,15 +227,40 @@ export class RuntimeHealthGuard { ...@@ -145,15 +227,40 @@ export class RuntimeHealthGuard {
} }
} }
return [...new Set([...uiIssues, ...this.apiIssues])].slice(0, MAX_ISSUES); return uiIssues;
}
async collectBlockingIssues(
mode: RuntimeHealthAssertMode,
contextRoots?: Array<Locator | FrameLocator>,
): Promise<string[]> {
const uiIssues = await this.collectUiIssues(contextRoots);
if (mode === 'ui') {
return [...new Set(uiIssues)].slice(0, MAX_ISSUES);
}
return [...new Set([...uiIssues, ...this.businessApiIssues])].slice(0, MAX_ISSUES);
}
/** 兼容诊断:等价于 collectBlockingIssues('business')。 */
async collectAllIssues(contextRoots?: Array<Locator | FrameLocator>): Promise<string[]> {
return this.collectBlockingIssues('business', contextRoots);
}
async assertHealthy(
context: string,
mode: RuntimeHealthAssertMode,
contextRoots?: Array<Locator | FrameLocator>,
): Promise<void> {
const issues = this.filterUnexpected(await this.collectBlockingIssues(mode, contextRoots));
const hint =
mode === 'ui'
? `${context}:检测到页面业务错误提示,无法继续 UI 交互校验`
: `${context}:检测到业务数据接口错误或页面业务报错`;
expect(issues, hint).toEqual([]);
} }
async assertClean(context: string, contextRoots?: Array<Locator | FrameLocator>): Promise<void> { async assertClean(context: string, contextRoots?: Array<Locator | FrameLocator>): Promise<void> {
const issues = this.filterUnexpected(await this.collectAllIssues(contextRoots)); await this.assertHealthy(context, 'business', contextRoots);
expect(
issues,
`${context}:检测到页面/接口/系统报错,用例判为失败(不允许 skip 掩盖)`,
).toEqual([]);
} }
} }
...@@ -174,16 +281,25 @@ export function getRuntimeHealthGuard(page: Page): RuntimeHealthGuard | undefine ...@@ -174,16 +281,25 @@ export function getRuntimeHealthGuard(page: Page): RuntimeHealthGuard | undefine
return guardByPage.get(page); return guardByPage.get(page);
} }
/** 页面 + 接口 + 系统统一健康断言(所有用例 skip 前/结束时调用)。 */ /**
* UI:仅 Ant 错误提示与系统异常文案(业务动作前置,验证可交互)。
* business:UI + 本动作窗口内业务接口问题(查询/保存等之后)。
*/
export async function assertRuntimeHealthy( export async function assertRuntimeHealthy(
scope: PageHealthScope, scope: PageHealthScope,
context: string, context: string,
guard?: RuntimeHealthGuard, guard?: RuntimeHealthGuard,
mode: RuntimeHealthAssertMode = 'business',
): Promise<void> { ): Promise<void> {
const raw = guard ? await guard.collectAllIssues(scope.roots) : await collectPageHealthIssues(scope); if (guard) {
const issues = guard ? guard.filterUnexpected(raw) : raw; await guard.assertHealthy(context, mode, scope.roots);
expect( return;
issues, }
`${context}:检测到页面/接口/系统报错,用例判为失败(不允许 skip 掩盖)`, const uiIssues = await collectPageHealthIssues(scope);
).toEqual([]); const issues = mode === 'ui' ? uiIssues : uiIssues;
const hint =
mode === 'ui'
? `${context}:检测到页面业务错误提示,无法继续 UI 交互校验`
: `${context}:检测到业务数据接口错误或页面业务报错`;
expect(issues, hint).toEqual([]);
} }
...@@ -35,13 +35,24 @@ export async function waitAntSpinGone( ...@@ -35,13 +35,24 @@ export async function waitAntSpinGone(
.catch(() => undefined); .catch(() => undefined);
} }
/** 查询按钮 loading:先给一个短窗口出现,再等到消失。 */ /**
* 等查询按钮上的 loading 消失。
* 当前不在 loading 且 appearTimeout 为 0 时立即返回,不再空等图标出现。
*/
export async function waitSearchButtonIdle( export async function waitSearchButtonIdle(
searchBtn: Locator, searchBtn: Locator,
timeout = timeouts.searchLoading, timeout = timeouts.searchLoading,
appearTimeout = 0,
): Promise<void> { ): Promise<void> {
const loadingIcon = searchBtn.locator(SEARCH_LOADING_SELECTOR); const loadingIcon = searchBtn.locator(SEARCH_LOADING_SELECTOR).first();
await loadingIcon.waitFor({ state: 'visible', timeout: 2_000 }).catch(() => undefined); if (!(await isSearchButtonLoading(searchBtn))) {
if (appearTimeout <= 0) return;
const appeared = await loadingIcon
.waitFor({ state: 'visible', timeout: appearTimeout })
.then(() => true)
.catch(() => false);
if (!appeared) return;
}
await loadingIcon.waitFor({ state: 'hidden', timeout }).catch(() => undefined); await loadingIcon.waitFor({ state: 'hidden', timeout }).catch(() => undefined);
} }
......
/** 基础管理-准入管理:路径、菜单、CSV 反例 mock。正例登录走 auth.setup(test_1)。 */
export const admitData = {
materialPath: '/spd/basic/dataSync/dataSync/productApply/ZRlist',
productApplyPath: '/spd/basic/dataSync/dataSync/productApply/list',
materialListPath: '/spd/basic/dataSync/dataSync/productOrder/zrlist',
productListPath: '/spd/basic/dataSync/dataSync/productOrder/list',
supplyPath: '/spd/basic/dataSync/dataSync/supplyApply/list',
taskPath: '/spd/basic/dataSync/dataSync/accessKind/list',
menuBasic: '基础设置',
menuAdmit: '准入管理',
menuMaterial: '物料准入管理',
menuProductApply: '产品准入申请',
menuMaterialList: '物料准入单列表',
menuProductList: '产品准入单列表',
menuSupply: '供货目录申请',
menuTask: '准入任务分配',
mockGoods: '__NOT_EXIST_GOODS__99999',
mockCert: '__NOT_EXIST_CERT__99999',
mockOrder: '__NOT_EXIST_ORDER__99999',
} as const;
export function uniqProductName(): string {
return `AUTO-PAP-${Date.now()}`;
}
export function uniqRemark(): string {
return `AUTO-SUP-${Date.now()}`;
}
/** 基础管理-医院资料:路径、菜单、CSV 反例 mock。正例登录走 auth.setup(test_1)。 */
export const basicHospitalData = {
noticePath: '/spd/basic/hospital/paltform/notice',
hospitalInfoPath: '/spd/basic/hospital/paltform/info',
financeMonthPath: '/spd/basic/hospital/balance/month',
branchPath: '/spd/basic/hospital/paltform/area',
menuBasic: '基础设置',
menuHospital: '医院资料',
menuNotice: '公告管理',
menuHospitalInfo: '医院信息登记',
menuFinanceMonth: '财务月份设置',
menuBranch: '院区管理',
mockNoticeTitle: '__NOT_EXIST_NOTICE__99999',
mockNoticeBody: '__MOCK_NOTICE_BODY__',
mockEmptyContentTitle: '__MOCK_EMPTY_CONTENT_TITLE__',
mockCampusName: '__NOT_EXIST_CAMPUS__99999',
mockCampusNoRegion: '__MOCK_CAMPUS_NO_REGION__',
mockAddr: '__MOCK_ADDR__',
mockCampusNoAddr: '__MOCK_CAMPUS_NO_ADDR__',
mockCampusId: '__MOCK_CAMPUS_ID__',
mockOutOfRangeDate: '1999-01-01',
writeDisabledReason: '环境禁止写:SKIP_BASIC_WRITE=1',
} as const;
export function isBasicWriteAllowed(): boolean {
return process.env.SKIP_BASIC_WRITE !== '1';
}
export function uniqNoticeTitle(): string {
return `AUTO-NTC-${Date.now()}`;
}
export function uniqLinkman(): string {
return `AUTO-HOS-${Date.now()}`;
}
export function uniqCampusName(): string {
return `AUTO-BRN-${Date.now()}`;
}
...@@ -67,9 +67,24 @@ export const centerReportEntrySubTableHeaders = [ ...@@ -67,9 +67,24 @@ export const centerReportEntrySubTableHeaders = [
/** 入库汇总查询 · 测试数据(与 testcase CSV 对齐)。 */ /** 入库汇总查询 · 测试数据(与 testcase CSV 对齐)。 */
export const centerReportEntryData = { export const centerReportEntryData = {
reportPath: '/spd/form/centerReport/report/centerPages/entry',
entryPath: '/spd/form/centerReport/report/centerPages/entry', entryPath: '/spd/form/centerReport/report/centerPages/entry',
iframeSrc: 'centerPages/entry',
productNameSelector: '#form_item_goodsName',
columns: centerReportEntryColumns, columns: centerReportEntryColumns,
drawerFields: centerReportEntryDrawerFields, drawerFields: centerReportEntryDrawerFields,
sortColumns: [...centerReportEntrySortColumns],
hasAdvancedFilter: true,
hasExport: true,
hasExpandRow: true,
hasBatchPrint: false,
hasFilterToggle: false,
forbiddenSummaryKeywords: ['出库数量', '物流出库金额'] as const,
drawerFieldInputIds: {
batchNo: 'form_item_batchCode',
sourceUnit: 'form_item_sourceStock',
supplier: 'form_item_provName',
},
dimensions: ['区分包装', '区分批号', '区分来源单位'] as const, dimensions: ['区分包装', '区分批号', '区分来源单位'] as const,
coreTableHeaders: [ coreTableHeaders: [
'产品编号', '产品编号',
......
import {
detailSheetSummaryMatch,
type CenterReportDrawerField,
} from './center-report-shared.types';
/** 出入库明细查询 · 列表列名与抽屉字段映射(动态取数用)。 */ /** 出入库明细查询 · 列表列名与抽屉字段映射(动态取数用)。 */
export const centerReportSheetColumns = { export const centerReportSheetColumns = {
productCode: '产品编号', productCode: '产品编号',
...@@ -120,11 +125,29 @@ function buildSortColumnCases(): Array<{ id: string; column: string }> { ...@@ -120,11 +125,29 @@ function buildSortColumnCases(): Array<{ id: string; column: string }> {
/** 出入库明细查询冒烟/回归数据(与 testcase CSV 对齐)。 */ /** 出入库明细查询冒烟/回归数据(与 testcase CSV 对齐)。 */
export const centerReportSheetData = { export const centerReportSheetData = {
reportPath: '/spd/form/centerReport/report/centerPages/sheet',
sheetPath: '/spd/form/centerReport/report/centerPages/sheet', sheetPath: '/spd/form/centerReport/report/centerPages/sheet',
iframeSrc: 'centerPages/sheet',
productNameSelector: '#form_item_goodsName',
columns: centerReportSheetColumns, columns: centerReportSheetColumns,
columnAliases: centerReportSheetColumnAliases, columnAliases: centerReportSheetColumnAliases,
coreTableHeaders: ['产品编号', '产品名称', '批号', '进院供应商', '院区名称'],
summaryKeywords: detailSheetSummaryMatch.metricLabels,
summaryMatch: detailSheetSummaryMatch,
drawerLabels: centerReportSheetDrawerLabels, drawerLabels: centerReportSheetDrawerLabels,
drawerFields: centerReportSheetDrawerFields, drawerFields: centerReportSheetDrawerFields.map(
(field): CenterReportDrawerField => ({
key: field.key,
type: field.type === 'dateRange' ? 'date' : field.type,
label: field.label ?? centerReportSheetColumns[field.key],
}),
),
sortColumns: [...centerReportSheetSortColumns],
hasAdvancedFilter: true,
hasExport: true,
hasExpandRow: false,
hasBatchPrint: false,
hasFilterToggle: true,
/** 动态取数:默认从列表首行 extractSampleRow() 读取,不写死业务数据 */ /** 动态取数:默认从列表首行 extractSampleRow() 读取,不写死业务数据 */
sampleRowIndex: 0, sampleRowIndex: 0,
/** 反例 mock(仅负向用例使用) */ /** 反例 mock(仅负向用例使用) */
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment