Commit 6a181bea authored by liguangyu06's avatar liguangyu06
Browse files

将基础管理等未入库改动暂存到 temp 分支,便于远程备份。

新模块用例尚未具备通关凭证,本次按临时备份跳过本地门禁。
parent f6eec849
......@@ -23,6 +23,8 @@ TEST_PASS=
# 操作层瞬时重试(click/fill/goto),默认 3 次;断言失败不会走这里
# ACTION_RETRIES=3
# ACTION_RETRY_DELAY_MS=250
# 单次操作重试总预算(毫秒,含首次与退避);到点即停,避免 次数×超时 叠加;0=不限
# ACTION_RETRY_BUDGET_MS=45000
# 整条用例重试默认 0(不要全局打开);仅排查时可显式设 PW_RETRIES
# PW_RETRIES=0
# 已知瞬时 flaky 用 testFlaky,额外重跑次数(不含首次)
......@@ -32,6 +34,11 @@ TEST_PASS=
# RUN_QUARANTINE=1
# FLAKE_MIN_RUNS=4
# FLAKE_RATE=0.3
# 失败频次统计(跨构建累计):失败次数与失败率双达标才算「经常失败」
# FREQ_FAIL_MIN=2
# FREQ_FAIL_RATE=0.2
# 累计文件路径(默认 reports/flake/failure-counts.json)
# PW_FAILURE_COUNTS=
# PW_WORKERS=3
# 分片并行(npm run test:warn:shard / test:spd:shard):SHARDS=3 WORKERS=2
# 排序全列等 @full 用例:RUN_FULL=1
......@@ -40,7 +47,14 @@ TEST_PASS=
# 分片:SHARDS=2 或 PW_SHARD=1/2(多 Agent)
# 默认按文件并行;不要开 PW_FULLY_PARALLEL=1(同账号会互踩)
# 冒烟快速失败: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
# 跳过站点探活:SKIP_PREFLIGHT=1
......
......@@ -120,6 +120,9 @@ pipeline {
string(name: 'RETRIES', defaultValue: '0', description: '整条用例重试次数,默认 0;瞬时加载由操作层重试,勿全局打开')
booleanParam(name: 'RUN_FULL', defaultValue: false, description: '包含 @full 全列排序(耗时长)')
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(
name: 'REPORT_EMAIL_TO',
defaultValue: '',
......@@ -137,6 +140,9 @@ pipeline {
RUN_FULL = "${params.RUN_FULL ? '1' : '0'}"
PW_GREP = "${params.GREP ?: ''}"
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'}"
JAVA_HOME = "${env.JAVA_HOME ?: 'E:/Program Files/Java/jdk-11'}"
}
......
......@@ -17,9 +17,17 @@ Chromium 缓存在项目内 `.playwright-browsers/`(已 gitignore),与 `@p
默认站点:`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` 一次性登录,调用 `context.storageState({ path: auth/user.json })`。
- **业务用例加载文件**:`chromium` project 配置 `storageState: AUTH_STATE_PATH`;每条用例由 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
......@@ -65,19 +73,25 @@ scripts/run-ci.mjs Jenkins / 定时执行入口
| 层 | 允许 | 禁止 |
|----|------|------|
| `config/` | 环境、超时、套件 | 业务操作 |
| `data/` | 路径、列名、样本字段、mock 值 | 选择器、click/fill |
| `data/` | 路径、列名、样本字段、mock 值 | 选择器、click/fill、密码 |
| `locators/` | `getByRole` / `getByPlaceholder` / `getByTestId` | click、fill、断言 |
| `core/` | 等待、操作重试、诊断、健康检查、BasePage | 具体报表选择器 |
| `pages/` | 业务操作、调用 core 等待 | 用例流程编排、裸选择器 |
| `tests/` | 调用 Page 方法 + 断言业务结果 | `page.locator(...)`、访问 `locators` |
| `pages/` | 业务操作、取数/快照(如 `snapshotList`) | 用例主断言、流程编排、裸密码 |
| `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`。
**重试策略**:
- **操作层**(默认开):`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` 掩盖断言失败。
**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`。
......
......@@ -24,6 +24,12 @@ export const timeouts = {
actionRetries: num('ACTION_RETRIES', 3),
/** 操作重试间隔基数(毫秒),第 n 次等待 n * 该值 */
actionRetryDelayMs: num('ACTION_RETRY_DELAY_MS', 250),
/**
* 单次操作重试的总时间预算(毫秒,含首次尝试与退避)。
* 到点即停,避免 次数 × 单次超时 叠加成几十秒慢失败;0 表示不限。
* 默认 45_000:约为 action(12s) × 3 的 1.25 倍上限,慢页面下也能及时收敛。
*/
actionRetryBudgetMs: num('ACTION_RETRY_BUDGET_MS', 45_000),
/** iframe 挂载、查询按钮可见 */
pageReady: num('PAGE_READY_TIMEOUT', 20_000),
/** 表体 attached / spin hidden */
......
import { timeouts } from '../config/timeouts.config';
import { logger } from './logger';
/**
* 操作语义:
* - `read`(默认):查询、导航、读取、打开面板等可安全重复执行的操作,瞬时失败可重试。
* - `write`:导出、提交、删除、审批等会产生副作用的操作。超时后无法确认服务端是否已执行,
* 盲目重试会重复触发,因此默认只尝试 1 次(除非调用方显式声明 `idempotent: true`)。
*/
export type RetryOperation = 'read' | 'write';
export type RetryActionOptions = {
/** 总尝试次数(含首次)。默认 `ACTION_RETRIES`,通常 3。 */
/** 总尝试次数(含首次)。默认 `ACTION_RETRIES`,通常 3;写操作默认 1。 */
attempts?: number;
delayMs?: number;
label?: string;
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 = {
......@@ -68,30 +89,55 @@ function sleep(ms: number): Promise<void> {
/**
* 底层操作细粒度重试。只重试瞬时 UI/导航错误;断言与健康检查失败立即抛出。
*
* 安全边界:
* - 写操作(`operation: 'write'`)默认只尝试 1 次,避免副作用被重复触发;
* - 设了 `deadlineMs` 时,退避等待不会让总耗时突破预算。
*/
export async function retryAction<T>(
action: () => Promise<T>,
options: RetryActionOptions = {},
): 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 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;
if (writesAreRisky && requestedAttempts > 1) {
logger.warn(
`${label} 属写操作,已禁用重试(防重复触发);如确认幂等请传 idempotent: true`,
);
}
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await action();
} catch (error) {
lastError = 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;
}
logger.warn(
`${label} 瞬时失败,重试 ${attempt}/${attempts - 1}:${errorText(error).slice(0, 240)}`,
);
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 { timeouts } from '../config/timeouts.config';
import { retryAction } from './action-retry';
import { retryAction, type RetryOperation } from './action-retry';
import { runWithLocatorHeal } from './heal-locator';
import {
waitAntSpinGone,
......@@ -88,7 +88,7 @@ export class BasePage {
async click(
locator: Locator,
stepName?: string,
options?: { timeout?: number; fallbacks?: Locator[] },
options?: { timeout?: number; fallbacks?: Locator[]; operation?: RetryOperation; idempotent?: boolean },
): Promise<void> {
const timeout = options?.timeout ?? timeouts.action;
const action = async (): Promise<void> => {
......@@ -114,6 +114,9 @@ export class BasePage {
},
{
label: stepName ?? 'click',
// 导出/提交/删除类写操作默认不重试,防副作用重复触发
operation: options?.operation ?? 'read',
idempotent: options?.idempotent,
onRetry: async () => {
await waitAntSpinGone(this.page);
await waitUiSettle(this.page);
......@@ -132,7 +135,7 @@ export class BasePage {
locator: Locator,
value: string,
stepName?: string,
options?: { fallbacks?: Locator[] },
options?: { fallbacks?: Locator[]; operation?: RetryOperation; idempotent?: boolean },
): Promise<void> {
const action = async (): Promise<void> => {
if (stepName) {
......@@ -147,6 +150,8 @@ export class BasePage {
},
{
label: stepName ?? 'fill',
operation: options?.operation ?? 'read',
idempotent: options?.idempotent,
onRetry: async () => {
await waitAntSpinGone(this.page);
await waitUiSettle(this.page);
......@@ -164,4 +169,164 @@ export class BasePage {
async getText(locator: Locator): Promise<string> {
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.waitForTableReady();
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 waitForTableReady(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;
};
/**
* 用例失败频次统计:跨构建累计每个用例(按 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(
options: RetryActionOptions & { label: string },
): Promise<void> {
const { label, ...retry } = options;
// 非幂等写操作:主定位失败后可能已部分触发副作用,不再尝试 fallback,避免重复触发。
const writeRisk = retry.operation === 'write' && !retry.idempotent;
try {
await retryAction(() => action(primary), { ...retry, label });
return;
} catch (primaryError) {
if (!fallbacks?.length) throw primaryError;
if (!fallbacks?.length || writeRisk) throw primaryError;
for (let i = 0; i < fallbacks.length; i += 1) {
try {
await retryAction(() => action(fallbacks[i]), {
......
......@@ -4,6 +4,8 @@ export {
isTransientActionError,
retryAction,
shouldRetryAction,
type RetryActionOptions,
type RetryOperation,
} from './action-retry';
export { FLAKY_TAG, resolveFlakyRetries } from './flaky';
export {
......@@ -15,6 +17,21 @@ export {
type FailureClassification,
} from './failure-classify';
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 { logger } from './logger';
export { applyCaseMeta, caseTitle, extractCaseId } from './case-meta';
......
......@@ -24,6 +24,8 @@ export type QuarantineItem = {
since: string;
ticket?: string;
category?: FailureClass;
/** 由 scripts/sync-quarantine.mjs 自动写入(区别于人工维护项)。 */
auto?: boolean;
};
export type QuarantineFile = {
......
/** 基础管理-准入管理:路径、菜单、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()}`;
}
/** 基础管理-流程设置:路径、菜单、CSV 反例 mock。正例登录走 auth.setup(test_1)。 */
export const flowData = {
defPath: '/spd/basic/flowSet/workflow/flowSet',
instPath: '/spd/basic/flowSet/workflow/flowCase',
menuBasic: '基础设置',
menuGroup: '流程设置',
menuDef: '审批流程设置',
menuInst: '审批流实例',
mockFlow: '__NOT_EXIST_FLOW__99999',
prefixType: {
PLAN: '采购计划',
DB: '二级库调拨',
GC: '产品信息变更',
BUY: '请领单',
} as const,
} as const;
/**
* 基础管理-手术套包设置:路径、菜单、URL 片段与 CSV 反例 mock。
* 正例登录走 auth.setup 写入的 storageState,用例内不重复登录、不写明文凭据。
* 严格对齐 testcase/基础管理-手术套包设置-*-UI自动化测试用例-01.csv。
*/
export const surgeryData = {
basePkgPath: '/spd/basic/operationSetting/baseMaintenance',
operTypePath: '/spd/basic/operationSetting/typeMaintenance',
stdPkgPath: '/spd/basic/operationSetting/standardMaintenance',
basePkgPathPart: '/spd/basic/operationSetting/baseMaintenance',
operTypePathPart: '/spd/basic/operationSetting/typeMaintenance',
stdPkgPathPart: '/spd/basic/operationSetting/standardMaintenance',
menuBasic: '基础设置',
menuSurgery: '手术套包设置',
menuBasePkg: '基础包维护',
menuOperType: '术种维护',
menuStdPkg: '标准套包维护',
// CSV 反例 mock(不依赖真实业务数据)
mockBaseKeyword: '__NOT_EXIST_BASE__99999',
mockOperKeyword: '__NOT_EXIST_OPER__99999',
mockStdKeyword: '__NOT_EXIST_STD__99999',
/** 术种维护状态 Tab(CSV SURG-TYPE-011/024/025) */
statusAll: '全部',
statusDisabled: '停用',
statusUnclassified: '未建分类',
} as const;
/** 唯一名称/编码,避免用例间数据污染(CSV 前置 ${uniqPkgName} 等)。 */
export function uniqStamp(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
export function uniqBasePkgName(): string {
return uniqStamp('AUTO-BASE');
}
export function uniqBasePkgCode(): string {
return uniqStamp('AB');
}
export function uniqOperName(): string {
return uniqStamp('AUTO-OPER');
}
export function uniqStdPkgName(): string {
return uniqStamp('AUTO-STD');
}
export function uniqCategoryName(): string {
return uniqStamp('AUTO-CATE');
}
/** 基础管理-用户管理:路径、菜单、CSV 反例 mock。正例登录走 auth.setup(test_1)。 */
export const userData = {
userPath: '/spd/basic/user/platform/person',
unionPath: '/spd/basic/user/platform/personCom',
rolePath: '/spd/basic/user/platform/role',
menuBasic: '基础设置',
menuUserGroup: '用户管理',
menuUser: '用户管理',
menuUnion: '医共体用户管理',
menuRole: '角色管理',
positiveUser: 'test_1',
mockUser: '__NOT_EXIST_USER__99999',
mockRole: '__NOT_EXIST_ROLE__99999',
} as const;
export function uniqLogin(): string {
return `auto.usr.${Date.now()}`;
}
/** 基础管理-厂商管理:路径、菜单、CSV 反例 mock。正例登录走 auth.setup(test_1)。 */
export const vendorData = {
queryPath: '/spd/basic/mfrs/paltform/mfrsName/factory',
qualPath: '/spd/basic/mfrs/basic/qcManage',
authFirstPath: '/spd/basic/mfrs/paltform/mfrsName/authAudit',
authFinalPath: '/spd/basic/mfrs/paltform/mfrsName/authAuditFinal',
certFirstPath: '/spd/basic/mfrs/paltform/mfrsName/regCertAudit',
certFinalPath: '/spd/basic/mfrs/paltform/mfrsName/regCertAuditFinal',
certDiffPath: '/spd/basic/mfrs/scjs/yc/certCompare',
evalSchemePath: '/spd/basic/mfrs/paltform/mfrsName/evaluationPlan',
evalIndicatorPath: '/spd/basic/mfrs/paltform/mfrsName/indicators',
holidayPath: '/spd/basic/mfrs/basic/prov/holiday',
menuBasic: '基础设置',
menuVendor: '厂商管理',
menuQuery: '厂商查询',
menuQual: '供应商资质管理',
mockMfrsName: '__NOT_EXIST_MFRS__99999',
mockSupplier: '__NOT_EXIST_SUPPLIER__99999',
mockOrder: '__NOT_EXIST_ORDER__99999',
menuAuthFirst: '授权变更初审',
menuAuthFinal: '授权变更终审',
menuCertFirst: '注册证变更初审',
menuCertFinal: '注册证变更终审',
menuCertDiff: '注册证差异对比',
menuEvalScheme: '评价方案维护',
menuEvalIndicator: '评价指标维护',
menuHoliday: '供应商节假日管理',
} as const;
/** 基础管理-库房管理:路径、菜单、CSV 反例 mock。正例登录走 auth.setup(test_1)。 */
export const warehouseData = {
zonePath: '/spd/basic/storehouse/platform/storehouse/area',
probePath: '/spd/basic/storehouse/storehouse/probe',
menuBasic: '基础设置',
menuGroup: '库房管理',
menuZone: '库区设置',
menuProbe: '探针绑定',
mockZone: '__NOT_EXIST_ZONE__99999',
mockProbe: '__NOT_EXIST_PROBE__99999',
} as const;
......@@ -27,8 +27,9 @@
1. 跑 CI,报告写入 `reports/flake/history.jsonl`(gitignore)
2. `npm run flake:report` → `reports/flake/latest.md`
3. 同一用例**有过有挂**才算 flake;失败率 ≥ 0.3 且有效次数 ≥ 4 列为隔离候选
4. 只把 **transient / environment** 候选写入 `config/quarantine.json`:
3. **`npm run quarantine:sync`** → 把候选合并进 `config/quarantine.json`(预览);确认后 `npm run quarantine:apply` 落库
4. 同一用例**有过有挂**才算 flake;失败率 ≥ 0.3 且有效次数 ≥ 4 列为隔离候选
5. 只把 **transient / environment / script / data** 候选写入 `config/quarantine.json`:
```json
{
......@@ -41,12 +42,13 @@
}
```
- **自动同步(`scripts/sync-quarantine.mjs`)**:只隔离 `environment / transient / script / data`;`product`(断言/业务缺陷)**绝不自动隔离**;标题无 `STAT-` 编号的候选跳过;已有人工条目(带 ticket/owner)**保留且不被覆盖**;自动项带 `auto: true`,可重复执行(幂等)。
- `quarantine`:Jenkins / `CI=1` 默认 skip;本地仍跑
- `observe`:照常执行,只打标
- `npm run test:quarantine`:只跑清单项,验证是否还抖
- `QUARANTINE_POLICY=never`:主门禁也不 skip
出清单条件:根因已记、有 owner、不是断言失败。修稳后删条目。
出清单条件:根因已记、有 owner、不是断言失败。修稳后删条目(自动项重新同步会自动回收)。
## 自愈定位
......@@ -56,12 +58,70 @@
| 手段 | 默认 | 断言失败 |
|------|------|----------|
| `ACTION_RETRIES`(操作层) | 3 | 不重试 |
| `ACTION_RETRIES`(操作层,读操作) | 3 | 不重试 |
| 写操作(`{ operation: 'write' }`) | **1(不重试)** | 不重试 |
| `ACTION_RETRY_BUDGET_MS`(单次操作总预算) | 45_000 | — |
| `PW_RETRIES`(整条用例) | 0 | 会重试,不要开 |
| `testFlaky` / `PW_FLAKY_RETRIES` | 按需 1 | 不重试 |
### 写操作幂等安全
导出、提交、删除、审批、打印等**会产生副作用**的点击,重试前无法确认服务端是否已执行,盲目重试会重复触发。
因此 `BasePage.click` / `fill` 支持 `{ operation: 'write' }`:默认只尝试 1 次,且主定位失败后**不再尝试 fallback**。
```ts
// 查询/打开面板等读操作:默认走 3 次重试
await this.click(queryBtn, '点击查询');
// 导出/打印/提交等写操作:只点一次,不重试、不 fallback
await this.click(exportBtn, '点击导出', { operation: 'write' });
// 业务确认重复执行安全(如按单据号覆盖式保存)才显式声明幂等
await this.click(saveBtn, '保存', { operation: 'write', idempotent: true });
```
### 超时总预算
`retryAction` 的 `deadlineMs`(默认 `ACTION_RETRY_BUDGET_MS=45_000`)限制**单次操作**的总耗时,含首次尝试与退避等待;到点即停并抛出最后一次错误。
避免 `attempts × 单次超时` 在慢页面下叠成几十秒慢失败。
```ts
await retryAction(() => action(), { label: '慢接口', deadlineMs: 30_000 });
```
## 失败频次统计(经常失败的用例)
目标:回答「**哪些用例经常挂**」,而不是只看单次红绿。区别于 flake(有过有挂),此处**只累计失败**,跨构建持久化。
原理:
1. 每条用例 `afterEach` 按 `STAT-` 编号累加 `runs/passed/failed/consecutiveFails`,写入 `reports/flake/failure-counts.json`(跨构建累计,重启不清零)。
2. 失败次数 ≥ `FREQ_FAIL_MIN`(默认 2)**且** 失败率 ≥ `FREQ_FAIL_RATE`(默认 0.2)判定为「经常失败」。
3. 给用例追加 annotation,报告可检索、可筛:
| annotation | 含义 |
|------------|------|
| `failureRuns` | `失败/执行`,如 `4/5` |
| `failureRate` | 失败率,如 `0.80` |
| `consecutiveFails` | 连续失败次数(通过即归零) |
| `frequentFailure` | 达标标记(经常失败) |
4. 榜单与命令:
```bash
npm run failures:report # 表格:经常失败的用例 top30
node scripts/failure-report.mjs --json # 机器可读,供看板/流水线消费
node scripts/failure-report.mjs --min-fails=3 --rate=0.3 --top=50
```
- 累计与落盘在 worker 的 `afterEach`(`mergeAndSaveFailureStore` 多 worker 合并安全);`reporters/failure-trend.reporter.ts` 在主进程 `onEnd` 刷新榜单并在日志打印命中项。
- Jenkins 邮件摘要(`scripts/ci-email-summary.mjs`)自动追加「经常失败的用例」小节。
- 编号以外的用例(无 `STAT-`)不参与统计。
## 产物
- `reports/latest/flake/latest.md` — 趋势与候选
- `reports/latest/flake/run.json` — 当次分类计数
- `reports/latest/flake/history.jsonl` — 当次明细(并追加到 `reports/flake/history.jsonl`)
- `reports/flake/failure-counts.json` — 跨构建失败频次累计(经常失败判定依据)
- `reports/flake/frequent-failures.md` — 经常失败用例榜单(每次运行刷新)
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