Commit 29a9f2d0 authored by liguangyu06's avatar liguangyu06
Browse files

Add SPD Playwright UI automation framework for GitLab CI and Jenkins.

Keep credentials, browser cache, and Cursor skills out of the repository so clones can run from .env.example and npm ci.
parents
import './load-env';
export type DbConfig = {
host: string;
port: number;
user: string;
password: string;
database: string;
};
export const db: DbConfig = {
host: process.env.MYSQL_HOST || '127.0.0.1',
port: Number(process.env.MYSQL_PORT || 3306),
user: process.env.MYSQL_USER || '',
password: process.env.MYSQL_PASSWORD || '',
database: process.env.MYSQL_DATABASE || '',
};
export function hasDbCredentials(): boolean {
return Boolean(db.user && db.password);
}
import './load-env';
import { timeouts } from './timeouts.config';
export type EnvName = 'dev' | 'test' | 'prod';
export type EnvConfig = {
name: EnvName;
/** 前端站点,与接口网关可能不同 */
baseURL: string;
loginPath: string;
homePath: string;
/** 导航 / 单测超时(见 config/timeouts.config.ts,可用 TEST_TIMEOUT 覆盖) */
timeout: number;
};
const DEFAULT_BASE = 'http://spdtest.cmic.com.cn:8080';
const ENV_MAP: Record<EnvName, EnvConfig> = {
dev: {
name: 'dev',
baseURL: process.env.BASE_URL || DEFAULT_BASE,
loginPath: process.env.LOGIN_PATH || '/spd/login',
homePath: process.env.HOME_PATH || '/spd/home/index',
timeout: timeouts.test,
},
test: {
name: 'test',
baseURL: process.env.BASE_URL || DEFAULT_BASE,
loginPath: process.env.LOGIN_PATH || '/spd/login',
homePath: process.env.HOME_PATH || '/spd/home/index',
timeout: timeouts.test,
},
prod: {
name: 'prod',
baseURL: process.env.BASE_URL || DEFAULT_BASE,
loginPath: process.env.LOGIN_PATH || '/spd/login',
homePath: process.env.HOME_PATH || '/spd/home/index',
timeout: timeouts.test,
},
};
function resolveEnvName(): EnvName {
const raw = (process.env.TEST_ENV || process.env.ENV || 'test').toLowerCase();
if (raw === 'dev' || raw === 'test' || raw === 'prod') {
return raw;
}
return 'test';
}
export const env = ENV_MAP[resolveEnvName()];
export { loadEnv } from './load-env';
export { env, type EnvConfig, type EnvName } from './env.config';
export { browserUse } from './browser.config';
export { credentials, hasCredentials, type Credentials } from './credentials.config';
export { db, hasDbCredentials, type DbConfig } from './db.config';
http://127.0.0.1:18081/v1
import { existsSync } from 'node:fs';
import path from 'node:path';
/**
* 在读取 env / credentials 之前加载仓库根目录 .env。
* Node 20+ 使用 process.loadEnvFile;文件不存在时静默跳过。
*/
let loaded = false;
export function loadEnv(): void {
if (loaded) {
return;
}
loaded = true;
const envPath = path.resolve(__dirname, '..', '.env');
if (!existsSync(envPath)) {
return;
}
if (typeof process.loadEnvFile !== 'function') {
throw new Error('加载 .env 需要 Node 20+(当前环境不支持 process.loadEnvFile)');
}
process.loadEnvFile(envPath);
}
loadEnv();
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
const PROJECT_ROOT = path.resolve(__dirname, '..');
export const PLAYWRIGHT_BROWSERS_DIR = path.join(PROJECT_ROOT, '.playwright-browsers');
function readBrowsersPathFromDotEnv() {
const envPath = path.join(PROJECT_ROOT, '.env');
if (!existsSync(envPath)) {
return undefined;
}
for (const rawLine of readFileSync(envPath, 'utf8').split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const match = line.match(/^PLAYWRIGHT_BROWSERS_PATH=(.+)$/);
if (!match) {
continue;
}
return match[1].trim().replace(/^['"]|['"]$/g, '');
}
return undefined;
}
/** 测试运行时统一浏览器目录;.env 可覆盖,否则强制项目内路径。 */
export function applyPlaywrightBrowsersPath() {
const fromDotEnv = readBrowsersPathFromDotEnv();
if (fromDotEnv) {
process.env.PLAYWRIGHT_BROWSERS_PATH = path.isAbsolute(fromDotEnv)
? fromDotEnv
: path.resolve(PROJECT_ROOT, fromDotEnv);
return;
}
process.env.PLAYWRIGHT_BROWSERS_PATH = PLAYWRIGHT_BROWSERS_DIR;
}
{
"version": 1,
"items": []
}
/**
* 执行套件目录:Jenkins / npm / 本地共用同一份清单。
* 调试 spec(*.stat-*.spec.ts)默认不进套件,避免与主 spec 重复跑。
*/
export const PAGE_GROUPS = {
center: [
'tests/spd/center-report-sheet.spec.ts',
'tests/spd/center-report-entry.spec.ts',
'tests/spd/center-report-out.spec.ts',
'tests/spd/center-report-psi.spec.ts',
'tests/spd/center-report-lmm-sheet.spec.ts',
'tests/spd/center-report-turnover.spec.ts',
'tests/spd/center-report-lmm-psi.spec.ts',
'tests/spd/center-report-net-entry.spec.ts',
'tests/spd/center-report-net-out.spec.ts',
],
dept: [
'tests/spd/dept-report-sheet.spec.ts',
'tests/spd/dept-report-entry.spec.ts',
'tests/spd/dept-report-out.spec.ts',
'tests/spd/dept-report-psi.spec.ts',
'tests/spd/dept-report-lmm-sheet.spec.ts',
'tests/spd/dept-report-turnover.spec.ts',
],
invoice: [
'tests/spd/invoice-settle.spec.ts',
'tests/spd/invoice-prov-classify.spec.ts',
'tests/spd/invoice-balance-status.spec.ts',
],
trace: [
'tests/spd/trace-direct-buy.spec.ts',
'tests/spd/trace-barcode.spec.ts',
'tests/spd/trace-direct-detail.spec.ts',
'tests/spd/trace-purchase.spec.ts',
'tests/spd/trace-dept-buy.spec.ts',
],
consume: [
'tests/spd/consume-high.spec.ts',
'tests/spd/consume-trend.spec.ts',
'tests/spd/consume-patient.spec.ts',
'tests/spd/consume-multi-charge.spec.ts',
'tests/spd/consume-dept-rank.spec.ts',
],
recon: [
'tests/spd/recon-dept-account.spec.ts',
'tests/spd/recon-dept-net-in.spec.ts',
'tests/spd/recon-dept-net-out.spec.ts',
'tests/spd/recon-out-classify.spec.ts',
'tests/spd/recon-purchase-classify.spec.ts',
'tests/spd/recon-purchase-entry.spec.ts',
'tests/spd/recon-supply.spec.ts',
],
cost: [
'tests/spd/cost-entry-detail.spec.ts',
'tests/spd/cost-settle-entry.spec.ts',
'tests/spd/cost-finance-classify.spec.ts',
'tests/spd/cost-supply-classify.spec.ts',
'tests/spd/cost-finance-psi.spec.ts',
'tests/spd/cost-dept-classify.spec.ts',
'tests/spd/cost-dept-classify-charge.spec.ts',
'tests/spd/cost-dept-net-in.spec.ts',
'tests/spd/cost-entry-exec.spec.ts',
'tests/spd/cost-entry-summary.spec.ts',
'tests/spd/cost-fin-in.spec.ts',
'tests/spd/cost-fin-out.spec.ts',
'tests/spd/cost-product-rank.spec.ts',
'tests/spd/cost-dept-rank.spec.ts',
],
warn: [
'tests/spd/warn-strand.spec.ts',
'tests/spd/warn-detain.spec.ts',
'tests/spd/warn-expdt.spec.ts',
'tests/spd/warn-low.spec.ts',
],
pol: [
'tests/spd/pol-key.spec.ts',
'tests/spd/pol-task.spec.ts',
'tests/spd/pol-rate.spec.ts',
'tests/spd/pol-dept.spec.ts',
'tests/spd/pol-consumer.spec.ts',
'tests/spd/pol-seven.spec.ts',
],
exc: [
'tests/spd/exc-cost.spec.ts',
'tests/spd/exc-low.spec.ts',
'tests/spd/exc-lowni.spec.ts',
],
ana: [
'tests/spd/ana-chg.spec.ts',
'tests/spd/ana-dept.spec.ts',
'tests/spd/ana-prov.spec.ts',
],
ops: ['tests/spd/ops-check.spec.ts'],
reag: ['tests/spd/reag-entry.spec.ts'],
auth: ['tests/spd/login.spec.ts', 'tests/spd/home.spec.ts'],
};
/** @typedef {{ description: string, files: string[], grep?: string, runFull?: boolean, runQuarantine?: boolean, workers?: number, retries?: number }} SuiteDef */
/** 用例级 retries 默认 0;瞬时加载走操作层重试,不要在套件里全局打开。 */
/** @type {Record<string, SuiteDef>} */
export const SUITES = {
smoke: {
description: '冒烟:登录/首页 + 成本/开票/科室出入库 + 预警/政策/异常已打 @smoke 的用例',
files: [
...PAGE_GROUPS.auth,
...PAGE_GROUPS.cost,
...PAGE_GROUPS.invoice,
...PAGE_GROUPS.dept,
...PAGE_GROUPS.warn,
...PAGE_GROUPS.pol,
...PAGE_GROUPS.exc,
],
grep: '@smoke',
workers: 3,
retries: 0,
},
nightly: {
description: '夜间回归:tests/spd 主 spec(排除 @full 与调试 *.stat-*.spec.ts)',
files: ['tests/spd'],
workers: 3,
retries: 0,
},
full: {
description: '完整回归:含 @full 全列排序,建议周末',
files: ['tests/spd'],
runFull: true,
workers: 2,
retries: 0,
},
center: {
description: '中心库出入库报表',
files: PAGE_GROUPS.center,
workers: 2,
retries: 0,
},
dept: {
description: '科室出入库报表',
files: PAGE_GROUPS.dept,
workers: 2,
retries: 0,
},
invoice: {
description: '发票/入账报表',
files: PAGE_GROUPS.invoice,
workers: 2,
retries: 0,
},
trace: {
description: '追溯报表',
files: PAGE_GROUPS.trace,
workers: 2,
retries: 0,
},
consume: {
description: '发放消耗报表',
files: PAGE_GROUPS.consume,
workers: 2,
retries: 0,
},
recon: {
description: '对账报表',
files: PAGE_GROUPS.recon,
workers: 2,
retries: 0,
},
cost: {
description: '结算成本报表',
files: PAGE_GROUPS.cost,
workers: 2,
retries: 0,
},
warn: {
description: '预警报表',
files: PAGE_GROUPS.warn,
workers: 3,
retries: 0,
},
pol: {
description: '政策导向报表',
files: PAGE_GROUPS.pol,
workers: 3,
retries: 0,
},
exc: {
description: '异常分析报表',
files: PAGE_GROUPS.exc,
workers: 3,
retries: 0,
},
'invoice-trace': {
description: '发票 + 追溯',
files: [...PAGE_GROUPS.invoice, ...PAGE_GROUPS.trace],
workers: 2,
retries: 0,
},
'recon-cost': {
description: '对账 + 结算成本',
files: [...PAGE_GROUPS.recon, ...PAGE_GROUPS.cost],
workers: 2,
retries: 0,
},
extended: {
description: '扩展报表:预警/运维/异常/政策/财务分析/试剂',
files: [
...PAGE_GROUPS.warn,
...PAGE_GROUPS.ops,
...PAGE_GROUPS.exc,
...PAGE_GROUPS.pol,
...PAGE_GROUPS.ana,
...PAGE_GROUPS.reag,
],
workers: 2,
retries: 0,
},
quarantine: {
description: '只跑 config/quarantine.json 隔离项,验证是否仍 flaky',
files: ['tests/spd'],
runQuarantine: true,
workers: 2,
retries: 0,
},
};
export const SUITE_NAMES = Object.keys(SUITES);
export function getSuite(name) {
const key = String(name || '').trim().toLowerCase();
const suite = SUITES[key];
if (!suite) {
throw new Error(`未知套件 "${name}"。可用:${SUITE_NAMES.join(', ')}`);
}
return { name: key, ...suite };
}
export function formatSuiteList() {
const width = Math.max(...SUITE_NAMES.map((n) => n.length));
return SUITE_NAMES.map((name) => {
const s = SUITES[name];
const files = s.files.length === 1 && s.files[0] === 'tests/spd' ? 'tests/spd' : `${s.files.length} files`;
const extra = [s.grep, s.runFull ? 'RUN_FULL' : '', s.runQuarantine ? 'QUARANTINE' : '']
.filter(Boolean)
.join(' ');
return ` ${name.padEnd(width)} ${files.padEnd(12)} ${s.description}${extra ? ` (${extra})` : ''}`;
}).join('\n');
}
import './load-env';
/**
* 框架层统一超时(短超时 + 明确失败,避免 120s 慢失败拖垮全量)。
* 可用环境变量覆盖,单位毫秒。
*/
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;
}
export const timeouts = {
/** 单测 / navigationTimeout(原 120s) */
test: num('TEST_TIMEOUT', 60_000),
/** 组合查询 / 首行勾稽(多次查询,避免 60s 误杀) */
comboTest: num('COMBO_TEST_TIMEOUT', 90_000),
/** expect() 默认 */
expect: num('EXPECT_TIMEOUT', 8_000),
/** 点击/填写等 action */
action: num('ACTION_TIMEOUT', 12_000),
/** 底层操作瞬时失败总尝试次数(含首次),断言失败不走此次数 */
actionRetries: num('ACTION_RETRIES', 3),
/** 操作重试间隔基数(毫秒),第 n 次等待 n * 该值 */
actionRetryDelayMs: num('ACTION_RETRY_DELAY_MS', 250),
/** iframe 挂载、查询按钮可见 */
pageReady: num('PAGE_READY_TIMEOUT', 20_000),
/** 表体 attached / spin hidden */
tableAttached: num('TABLE_ATTACHED_TIMEOUT', 15_000),
/** waitForTableReady 轮询上限(原 45s) */
tableReady: num('TABLE_READY_TIMEOUT', 20_000),
/** 查询按钮 loading 消失 */
searchLoading: num('SEARCH_LOADING_TIMEOUT', 15_000),
/** 导出接口 waitForResponse */
exportResponse: num('EXPORT_RESPONSE_TIMEOUT', 30_000),
/** auth setup 探活 */
authProbeNav: num('AUTH_PROBE_NAV_TIMEOUT', 12_000),
authProbeMenu: num('AUTH_PROBE_MENU_TIMEOUT', 8_000),
/**
* token 剩余有效期大于该值则跳过服务端探活(默认 20 分钟)。
* 设 AUTH_SKIP_PROBE_IF_FRESH=0 可强制每次探活。
*/
authFreshRemaining: num('AUTH_FRESH_REMAINING_MS', 20 * 60_000),
} as const;
import { timeouts } from '../config/timeouts.config';
import { logger } from './logger';
export type RetryActionOptions = {
/** 总尝试次数(含首次)。默认 `ACTION_RETRIES`,通常 3。 */
attempts?: number;
delayMs?: number;
label?: string;
onRetry?: (error: unknown, attempt: number) => Promise<void> | void;
};
type ErrorLike = {
name?: string;
message?: string;
matcherResult?: unknown;
constructor?: { name?: string };
};
function errorText(error: unknown): string {
if (error instanceof Error) return `${error.name}: ${error.message}`;
return String(error);
}
function asErrorLike(error: unknown): ErrorLike {
if (error && typeof error === 'object') return error as ErrorLike;
return { message: String(error) };
}
/**
* Playwright `expect` / Node AssertionError / 框架健康检查与 guardedSkip。
* 这类失败代表业务预期未满足,禁止重试以免掩盖真 bug。
*/
export function isAssertionFailure(error: unknown): boolean {
const err = asErrorLike(error);
if (err.matcherResult !== undefined) return true;
if (err.name === 'AssertionError' || err.constructor?.name === 'AssertionError') return true;
const msg = err.message ?? '';
if (
/用例预期未满足|检测到页面\/接口\/系统报错|检测到页面报错或系统异常|应 fail 而非 skip/.test(
msg,
)
) {
return true;
}
return /expect(?:\.poll)?\(/.test(msg) && /\bto(?:Be|Equal|Have|Contain|Match|Throw)/.test(msg);
}
/** 点击被挡、节点卸载、导航超时等页面瞬时问题,适合操作层重试。 */
export function isTransientActionError(error: unknown): boolean {
if (isAssertionFailure(error)) return false;
const err = asErrorLike(error);
if (err.name === 'TimeoutError') return true;
const msg = err.message ?? '';
return /not attached|not visible|not stable|not enabled|intercepts pointer|subtree intercepts|element is hidden|element is not visible|frame was detached|execution context was destroyed|target (?:page )?closed|protocol error|cannot find context|waiting for (?:locator|getBy|selector|navigation|frame)|Navigation timeout|net::ERR_|Timeout \d+ms exceeded/i.test(
msg,
);
}
export function shouldRetryAction(error: unknown): boolean {
return isTransientActionError(error) && !isAssertionFailure(error);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
/**
* 底层操作细粒度重试。只重试瞬时 UI/导航错误;断言与健康检查失败立即抛出。
*/
export async function retryAction<T>(
action: () => Promise<T>,
options: RetryActionOptions = {},
): Promise<T> {
const attempts = Math.max(1, options.attempts ?? timeouts.actionRetries);
const delayMs = options.delayMs ?? timeouts.actionRetryDelayMs;
const label = options.label ?? '操作';
let lastError: unknown;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await action();
} catch (error) {
lastError = error;
const retryable = shouldRetryAction(error);
if (!retryable || attempt === attempts) {
throw error;
}
logger.warn(
`${label} 瞬时失败,重试 ${attempt}/${attempts - 1}${errorText(error).slice(0, 240)}`,
);
await options.onRetry?.(error, attempt);
await sleep(delayMs * attempt);
}
}
throw lastError;
}
import { expect, type Locator, type Page, test } from '@playwright/test';
import { timeouts } from '../config/timeouts.config';
import { retryAction } from './action-retry';
import { runWithLocatorHeal } from './heal-locator';
import {
waitAntSpinGone,
waitSearchButtonIdle,
waitUiSettle,
waitVisible as waitLocatorVisible,
} from './smart-wait';
import {
assertRuntimeHealthy,
getRuntimeHealthGuard,
} from './runtime-health.util';
import { type PageHealthScope } from './page-health.util';
import { logger } from './logger';
/** 所有 Page Object 的基类:导航、智能等待、通用操作。选择器不写在这里。 */
export class BasePage {
constructor(protected readonly page: Page) {}
/** 供 spec 层统一健康检查 / guardedSpecSkip 使用。 */
get browserPage(): Page {
return this.page;
}
/** 子类可覆盖,将报表 iframe 等纳入健康检查。 */
protected pageHealthScope(): PageHealthScope {
return { page: this.page };
}
/** 系统挂掉、接口报错或页面报错时 fail,避免被 skip 掩盖。 */
protected async assertPageHealthy(context: string): Promise<void> {
await assertRuntimeHealthy(
this.pageHealthScope(),
context,
getRuntimeHealthGuard(this.page),
);
}
/** 供 spec / helper 在 skip 前调用:系统异常或页面报错则 fail。 */
async ensureHealthyBeforeSkip(reason: string): Promise<void> {
await this.assertPageHealthy(`skip 前检查:${reason}`);
}
/** 前置/预期未满足时 fail,禁止 skip 掩盖缺陷。 */
protected async guardedSkip(reason: string): Promise<void> {
await this.ensureHealthyBeforeSkip(reason);
expect(false, `用例预期未满足(应 fail 而非 skip):${reason}`).toBeTruthy();
}
/** 场景1(切换至第2页):数据不足时 skip,输出「数据不足:…」。 */
protected async skipWhenDataInsufficient(detail: string): Promise<void> {
const reason = detail.startsWith('数据不足') ? detail : `数据不足:${detail}`;
await this.ensureHealthyBeforeSkip(reason);
test.skip(true, reason);
}
async goto(path = '/'): Promise<void> {
await test.step(`打开页面: ${path}`, async () => {
logger.step(`打开页面: ${path}`);
await retryAction(
async () => {
await this.page.goto(path, { waitUntil: 'domcontentloaded' });
},
{ label: `打开页面: ${path}` },
);
});
}
async waitVisible(locator: Locator, timeout = timeouts.expect): Promise<void> {
await waitLocatorVisible(locator, timeout);
}
async waitSpinGone(root: Page | Locator = this.page, timeout = timeouts.searchLoading): Promise<void> {
await waitAntSpinGone(root, timeout);
}
async waitSearchIdle(searchBtn: Locator, timeout = timeouts.searchLoading): Promise<void> {
await waitSearchButtonIdle(searchBtn, timeout);
}
async waitSettle(fallbackMs = 200): Promise<void> {
await waitUiSettle(this.page, fallbackMs);
}
async click(
locator: Locator,
stepName?: string,
options?: { timeout?: number; fallbacks?: Locator[] },
): Promise<void> {
const timeout = options?.timeout ?? timeouts.action;
const action = async (): Promise<void> => {
if (stepName) {
logger.step(stepName);
}
await runWithLocatorHeal(
locator,
options?.fallbacks,
async (target) => {
await target.waitFor({ state: 'visible', timeout });
await target.scrollIntoViewIfNeeded().catch(() => undefined);
await target.click({ timeout });
},
{
label: stepName ?? 'click',
onRetry: async () => {
await waitAntSpinGone(this.page);
await waitUiSettle(this.page);
},
},
);
};
if (stepName) {
await test.step(stepName, action);
return;
}
await action();
}
async fill(
locator: Locator,
value: string,
stepName?: string,
options?: { fallbacks?: Locator[] },
): Promise<void> {
const action = async (): Promise<void> => {
if (stepName) {
logger.step(stepName);
}
await runWithLocatorHeal(
locator,
options?.fallbacks,
async (target) => {
await target.waitFor({ state: 'visible', timeout: timeouts.action });
await target.fill(value);
},
{
label: stepName ?? 'fill',
onRetry: async () => {
await waitAntSpinGone(this.page);
await waitUiSettle(this.page);
},
},
);
};
if (stepName) {
await test.step(stepName, action);
return;
}
await action();
}
async getText(locator: Locator): Promise<string> {
return retryAction(async () => (await locator.innerText()).trim(), { label: '读取文本' });
}
}
import type { TestInfo } from '@playwright/test';
const CASE_ID_RE = /\b(STAT-[A-Z]+-\d{3,})\b/;
export function extractCaseId(title: string): string | undefined {
return title.match(CASE_ID_RE)?.[1];
}
/** 从用例标题提取 STAT-xxx,写入 annotation,报告/Allure 可检索。 */
export function applyCaseMeta(testInfo: TestInfo): void {
const caseId = extractCaseId(testInfo.title);
if (!caseId) return;
if (testInfo.annotations.some((item) => item.type === 'caseId' && item.description === caseId)) {
return;
}
testInfo.annotations.push({ type: 'caseId', description: caseId });
}
export function caseTitle(id: string, name: string): string {
return `${id} ${name}`;
}
/** 表头常见别名组(同组互为别名,长→短匹配)。 */
const COLUMN_HEADER_ALIAS_GROUPS: readonly (readonly string[])[] = [
['生产厂家', '厂家', '制造商', '生产企业', '生产商'],
['入账日期', '入账时间', '记账日期', '记账时间', '统计日期', '统计时间'],
['财务月份', '入账月份', '会计月份'],
['院区名称', '院区'],
['27位医保编码', '医保编码', '医保码'],
['医保单件名称', '单件名称', '医保名称'],
['数量', '净入数量', '净出数量', '入库数量', '出库数量', '成本数量'],
['HIS编码', 'HIS码', 'His编码'],
['结算单标签', '标签'],
['产品编号', '产品编码', '物资编码', '商品编码'],
['产品名称', '物资名称', '商品名称'],
['规格型号', '规格'],
];
function aliasTokensFor(columnName: string): string[] {
const normalized = columnName.replace(/\s+/g, '').trim();
const out: string[] = [];
for (const group of COLUMN_HEADER_ALIAS_GROUPS) {
const hit = group.some((alias) => {
const a = alias.replace(/\s+/g, '');
return a === normalized || a.includes(normalized) || normalized.includes(a);
});
if (hit) {
for (const alias of group) {
const a = alias.replace(/\s+/g, '').trim();
if (a && !out.includes(a)) out.push(a);
}
}
}
return out;
}
/** 表头可能被截断时的匹配 token(长→短,优先精确)。 */
export function columnHeaderMatchTokens(columnName: string): string[] {
const tokens: string[] = [];
const push = (value: string) => {
const normalized = value.replace(/\s+/g, '').trim();
if (normalized && !tokens.includes(normalized)) {
tokens.push(normalized);
}
};
push(columnName);
for (const alias of aliasTokensFor(columnName)) {
push(alias);
}
if (columnName.includes('-')) {
const head = columnName.split('-')[0]?.trim();
if (head) push(head);
}
if (columnName.length > 7) {
push(columnName.slice(0, 7));
}
// 数字开头长表头再补更短前缀(如 27位医保)
if (/^\d/.test(columnName.replace(/\s+/g, '')) && columnName.replace(/\s+/g, '').length > 4) {
push(columnName.replace(/\s+/g, '').slice(0, 4));
}
return tokens.sort((a, b) => b.length - a.length);
}
export function columnHeaderTextsMatch(cellText: string, columnName: string): boolean {
const normalizedCell = cellText.replace(/\s+/g, '').trim();
if (!normalizedCell) return false;
return columnHeaderMatchTokens(columnName).some((token) => {
if (!token) return false;
if (normalizedCell === token || normalizedCell.includes(token)) return true;
return normalizedCell.length >= 4 && token.includes(normalizedCell);
});
}
export function scoreColumnHeaderMatch(cellText: string, columnName: string): number {
const normalizedCell = cellText.replace(/\s+/g, '').trim();
if (!normalizedCell) return 0;
let best = 0;
for (const token of columnHeaderMatchTokens(columnName)) {
if (!token) continue;
if (normalizedCell === token) {
best = Math.max(best, token.length + 1000);
continue;
}
if (normalizedCell.includes(token)) {
best = Math.max(best, token.length);
continue;
}
if (normalizedCell.length >= 4 && token.includes(normalizedCell)) {
best = Math.max(best, normalizedCell.length);
}
}
return best;
}
/** @deprecated 使用 columnHeaderTextsMatch */
export const columnHeaderTextMatches = columnHeaderTextsMatch;
import type { Page, TestInfo } from '@playwright/test';
import { getRuntimeHealthGuard, type RuntimeHealthGuard } from './runtime-health.util';
import { extractCaseId } from './case-meta';
import {
annotateFailureClass,
classifyFromTestInfo,
formatClassificationMarkdown,
type FailureClassification,
} from './failure-classify';
import { collectHealSuggestions } from './heal-locator';
export type FailureSnapshot = {
caseId: string;
title: string;
url: string;
pageTitle: string;
frames: string[];
healthIssues: string[];
closed: boolean;
classification?: FailureClassification;
healSuggestions?: string[];
};
export async function collectFailureSnapshot(
page: Page,
testInfo: TestInfo,
guard?: RuntimeHealthGuard,
): Promise<FailureSnapshot> {
const closed = page.isClosed();
const healthGuard = guard ?? getRuntimeHealthGuard(page);
let url = '';
let pageTitle = '';
const frames: string[] = [];
let healthIssues: string[] = [];
if (!closed) {
url = page.url();
pageTitle = await page.title().catch(() => '');
for (const frame of page.frames()) {
const frameUrl = frame.url();
if (frameUrl && frameUrl !== url && frameUrl !== 'about:blank') {
frames.push(frameUrl);
}
}
if (healthGuard) {
healthIssues = await healthGuard.collectAllIssues().catch(() => [...healthGuard.getApiIssues()]);
}
}
const classification = classifyFromTestInfo(testInfo, healthIssues);
const healSuggestions = closed ? [] : await collectHealSuggestions(page).catch(() => []);
return {
caseId: extractCaseId(testInfo.title) ?? '',
title: testInfo.title,
url,
pageTitle,
frames: frames.slice(0, 8),
healthIssues,
closed,
classification,
healSuggestions,
};
}
export function formatFailureMarkdown(snapshot: FailureSnapshot): string {
const lines = [
`# 失败诊断`,
``,
`- 用例: ${snapshot.title}`,
`- 编号: ${snapshot.caseId || '(标题无 STAT-)'}`,
`- 页面: ${snapshot.closed ? '已关闭' : snapshot.url || '(无 URL)'}`,
`- 标题: ${snapshot.pageTitle || ''}`,
];
if (snapshot.frames.length) {
lines.push(`- iframe: ${snapshot.frames.join(' | ')}`);
}
if (snapshot.classification) {
lines.push('', formatClassificationMarkdown(snapshot.classification).trimEnd());
}
if (snapshot.healthIssues.length) {
lines.push(``, `## 运行时健康问题`, ...snapshot.healthIssues.map((item) => `- ${item}`));
} else {
lines.push(``, `## 运行时健康问题`, `- 无(或页面已关闭未能采集)`);
}
if (snapshot.healSuggestions?.length) {
lines.push(
``,
`## 自愈定位候选(仅建议,未改 locators)`,
...snapshot.healSuggestions.map((item) => `- ${item}`),
);
}
lines.push('');
return lines.join('\n');
}
/** 失败时挂到 Playwright / Allure,便于 Jenkins HTML 里直接打开。 */
export async function attachFailureDiagnostics(
page: Page,
testInfo: TestInfo,
guard?: RuntimeHealthGuard,
): Promise<void> {
const snapshot = await collectFailureSnapshot(page, testInfo, guard);
const markdown = formatFailureMarkdown(snapshot);
await testInfo.attach('failure-context.md', {
body: Buffer.from(markdown, 'utf8'),
contentType: 'text/markdown',
});
testInfo.annotations.push({ type: 'pageUrl', description: snapshot.url || 'page-closed' });
if (snapshot.caseId) {
testInfo.annotations.push({ type: 'caseId', description: snapshot.caseId });
}
if (snapshot.classification) {
annotateFailureClass(testInfo, snapshot.classification);
}
}
import type { TestInfo } from '@playwright/test';
import { isAssertionFailure, isTransientActionError } from './action-retry';
/** 失败自动分类:产品缺陷 / 脚本 / 环境 / 数据 / 瞬时。 */
export type FailureClass = 'product' | 'script' | 'environment' | 'data' | 'transient';
export type FailureClassification = {
cls: FailureClass;
confidence: 'high' | 'medium' | 'low';
reason: string;
rca: string;
next: string;
};
const CLASS_LABEL: Record<FailureClass, string> = {
product: '产品缺陷',
script: '脚本问题',
environment: '环境/基础设施',
data: '测试数据',
transient: '瞬时 Flake',
};
export function failureClassLabel(cls: FailureClass): string {
return CLASS_LABEL[cls];
}
function textOf(error: unknown): string {
if (!error) return '';
if (error instanceof Error) return `${error.name}\n${error.message}\n${error.stack ?? ''}`;
if (typeof error === 'object') {
const err = error as { message?: string; name?: string; stack?: string };
return `${err.name ?? ''}\n${err.message ?? ''}\n${err.stack ?? ''}`;
}
return String(error);
}
export function classifyFailure(input: {
error?: unknown;
healthIssues?: string[];
title?: string;
}): FailureClassification {
const title = input.title ?? '';
const health = (input.healthIssues ?? []).join('\n');
const raw = `${textOf(input.error)}\n${health}\n${title}`;
if (/数据不足/.test(raw)) {
return {
cls: 'data',
confidence: 'high',
reason: '前置或列表数据不足,无法验证目标业务点',
rca: '测试账号/库内缺少可操作样本,或筛选条件把结果滤空',
next: '补隔离数据或放宽查询条件;不要当成产品功能缺陷提单',
};
}
if (
/net::ERR_|ECONNRESET|ENOTFOUND|ETIMEDOUT|ERR_CONNECTION|ERR_EMPTY_RESPONSE|ERR_INTERNET|browser has been closed|Target closed|Browser closed/i.test(
raw,
) ||
/接口请求失败:.*\(net::/i.test(health)
) {
return {
cls: 'environment',
confidence: 'high',
reason: '网络、站点不可达或浏览器/进程被关闭',
rca: '被测环境、代理、节点资源或会话被踢',
next: '查 BASE_URL 探活、Jenkins 节点与同账号互踢;恢复后再跑,勿改断言',
};
}
if (/\b(502|503|504)\b/.test(raw) && /接口|HTTP|status/i.test(raw)) {
return {
cls: 'environment',
confidence: 'high',
reason: '网关/服务不可用(5xx)',
rca: '后端或网关瞬时不可用',
next: '对照接口状态与发布窗口;稳定后重跑,避免当脚本问题改定位器',
};
}
if (/检测到页面\/接口\/系统报错|检测到页面报错或系统异常|业务码=/.test(raw)) {
return {
cls: 'product',
confidence: 'high',
reason: '运行时健康检查捕获到页面或接口业务错误',
rca: '被测系统返回错误页、失败业务码或接口异常',
next: '用 failure-context 中的接口/文案提单;禁止 skip 或标 flaky 掩盖',
};
}
if (/用例预期未满足|应 fail 而非 skip/.test(raw)) {
return {
cls: 'product',
confidence: 'high',
reason: '业务前置或预期未满足(框架禁止 skip 掩盖)',
rca: '页面状态与用例约定不一致,更可能是功能回归',
next: '核对需求与现场;确认是脚本写错再改 Page,否则提产品缺陷',
};
}
if (/strict mode violation|resolved to \d+ elements|locator\.count/i.test(raw)) {
return {
cls: 'script',
confidence: 'high',
reason: '定位器命中多个或写法不唯一',
rca: '选择器过宽或页面结构变更导致歧义',
next: '收紧 locators(role/name/testid);可参考诊断里的自愈候选',
};
}
if (
/TypeError|ReferenceError|Cannot read propert|is not a function|Unexpected token/i.test(raw)
) {
return {
cls: 'script',
confidence: 'high',
reason: '脚本运行期错误,不是页面断言',
rca: 'Page/fixture 代码缺陷或错误假设',
next: '修脚本;不要重试、不要 quarantine',
};
}
if (isTransientActionError(input.error) && !isAssertionFailure(input.error)) {
const resolved = /locator resolved to/i.test(raw);
return {
cls: resolved ? 'transient' : 'script',
confidence: resolved ? 'medium' : 'medium',
reason: resolved
? '元素曾命中但点击/填写被挡或节点卸载(操作层已重试仍失败)'
: '等待定位器超时且未稳定命中,偏向定位或页面未出齐',
rca: resolved
? '遮罩、Spin、iframe 重挂或动画未结束'
: '定位器失效,或环境过慢导致未渲染',
next: resolved
? '先看是否环境抖动;连续多日再列入 quarantine observe,禁止因断言失败 quarantine'
: '更新定位器或增加页面就绪等待;对照自愈候选',
};
}
if (isAssertionFailure(input.error)) {
return {
cls: 'product',
confidence: 'high',
reason: '断言失败:实际结果与预期不符',
rca: '功能回归,或脚本期望已过期',
next: '先对照产品行为;确认期望过期再改断言。断言失败不重试',
};
}
if (/Timeout|timed out|超时/i.test(raw)) {
return {
cls: 'environment',
confidence: 'low',
reason: '超时但无法归入明确的定位/断言类型',
rca: '站点慢、卡死或等待条件过严',
next: '看 trace / 接口耗时;不要默认加长全局超时',
};
}
return {
cls: 'script',
confidence: 'low',
reason: '未能匹配已知失败模式',
rca: '需要结合 trace、截图与步骤日志人工判断',
next: '打开 failure-context 与 trace,归类后再决定修脚本、提单或隔离',
};
}
export function classifyFromTestInfo(
testInfo: Pick<TestInfo, 'title' | 'error' | 'errors'>,
healthIssues: string[] = [],
): FailureClassification {
const error = testInfo.error ?? testInfo.errors?.[0];
return classifyFailure({ error, healthIssues, title: testInfo.title });
}
export function annotateFailureClass(
testInfo: TestInfo,
item: FailureClassification,
): void {
testInfo.annotations.push({ type: 'failureClass', description: item.cls });
testInfo.annotations.push({ type: 'failureConfidence', description: item.confidence });
testInfo.annotations.push({ type: 'failureReason', description: item.reason });
testInfo.annotations.push({ type: 'rca', description: item.rca });
}
export function formatClassificationMarkdown(item: FailureClassification): string {
return [
`## 失败分类`,
``,
`- 类型: ${failureClassLabel(item.cls)} (\`${item.cls}\`)`,
`- 置信度: ${item.confidence}`,
`- 判定: ${item.reason}`,
`- 根因: ${item.rca}`,
`- 下一步: ${item.next}`,
``,
].join('\n');
}
/** 已知瞬时 flaky 用例标签;不要用来掩盖断言失败。 */
export const FLAKY_TAG = '@flaky';
/**
* flaky 用例额外重跑次数(不含首次)。默认 1,即最多再跑一遍步骤。
* 仅 `testFlaky` 使用;全局 `retries` 保持 0。
*/
export function resolveFlakyRetries(): number {
const raw = process.env.PW_FLAKY_RETRIES;
if (raw === undefined || raw === '') return 1;
const n = Number(raw);
return Number.isFinite(n) && n >= 0 ? n : 1;
}
import { mkdirSync, appendFileSync } from 'node:fs';
import path from 'node:path';
import type { Locator, Page } from '@playwright/test';
import { logger } from './logger';
import { retryAction, type RetryActionOptions } from './action-retry';
export type HealEvent = {
ts: string;
label: string;
used: 'primary' | 'fallback';
fallbackIndex?: number;
suggestion?: string;
};
function healLogPath(): string {
if (process.env.PW_HEAL_LOG) return process.env.PW_HEAL_LOG;
const dir = process.env.PW_FLAKE_DIR || path.join(process.cwd(), 'reports/flake');
return path.join(dir, 'heal.jsonl');
}
export function appendHealEvent(event: HealEvent): void {
try {
const file = healLogPath();
mkdirSync(path.dirname(file), { recursive: true });
appendFileSync(file, `${JSON.stringify(event)}\n`, 'utf8');
} catch {
// 写盘失败不影响用例
}
}
/**
* 主定位失败后尝试 fallback。成功则记自愈事件,不改 locators 源文件。
* 断言失败不会走到这里(由调用方在操作层使用)。
*/
export async function runWithLocatorHeal(
primary: Locator,
fallbacks: Locator[] | undefined,
action: (locator: Locator) => Promise<void>,
options: RetryActionOptions & { label: string },
): Promise<void> {
const { label, ...retry } = options;
try {
await retryAction(() => action(primary), { ...retry, label });
return;
} catch (primaryError) {
if (!fallbacks?.length) throw primaryError;
for (let i = 0; i < fallbacks.length; i += 1) {
try {
await retryAction(() => action(fallbacks[i]), {
...retry,
attempts: 2,
label: `${label} fallback#${i + 1}`,
});
const event: HealEvent = {
ts: new Date().toISOString(),
label,
used: 'fallback',
fallbackIndex: i,
suggestion: `操作「${label}」主定位失败,fallback#${i + 1} 成功,请回 locators 固化`,
};
appendHealEvent(event);
logger.warn(event.suggestion ?? label);
return;
} catch {
// 试下一个
}
}
throw primaryError;
}
}
/** 失败时采集页面上可见的按钮/输入提示,供人工改定位,不自动改文件。 */
export async function collectHealSuggestions(page: Page): Promise<string[]> {
if (page.isClosed()) return [];
const hints: string[] = [];
for (const frame of page.frames().slice(0, 5)) {
const found = await frame
.evaluate(() => {
const out: string[] = [];
const nodes = document.querySelectorAll('button, [role="button"], input, a, .ant-btn');
nodes.forEach((el, index) => {
if (index > 50) return;
const input = el as HTMLInputElement;
const text = (
el.getAttribute('aria-label') ||
el.getAttribute('placeholder') ||
input.placeholder ||
el.textContent ||
''
)
.replace(/\s+/g, ' ')
.trim();
if (!text || text.length > 48) return;
out.push(`${el.tagName.toLowerCase()}: ${text}`);
});
return out;
})
.catch(() => [] as string[]);
hints.push(...found);
}
return [...new Set(hints)].slice(0, 24);
}
export { BasePage } from './base.page';
export {
isAssertionFailure,
isTransientActionError,
retryAction,
shouldRetryAction,
} from './action-retry';
export { FLAKY_TAG, resolveFlakyRetries } from './flaky';
export {
annotateFailureClass,
classifyFailure,
classifyFromTestInfo,
failureClassLabel,
type FailureClass,
type FailureClassification,
} from './failure-classify';
export { applyQuarantine, loadQuarantine, matchQuarantine, QUARANTINE_TAG } from './quarantine';
export { collectHealSuggestions, runWithLocatorHeal } from './heal-locator';
export { logger } from './logger';
export { applyCaseMeta, caseTitle, extractCaseId } from './case-meta';
export { attachFailureDiagnostics, collectFailureSnapshot } from './diagnostics';
export {
waitAntSpinGone,
waitSearchButtonIdle,
waitUiSettle,
waitUntilNotLoading,
waitVisible,
type TableReadyState,
} from './smart-wait';
export { attachRuntimeHealthGuard, getRuntimeHealthGuard } from './runtime-health.util';
const stamp = (): string => new Date().toISOString();
export const logger = {
info(message: string): void {
console.log(`[${stamp()}] [INFO] ${message}`);
},
step(message: string): void {
console.log(`[${stamp()}] [STEP] ${message}`);
},
warn(message: string): void {
console.warn(`[${stamp()}] [WARN] ${message}`);
},
};
import { expect, type FrameLocator, type Locator, type Page } from '@playwright/test';
const ERROR_SELECTORS = [
'.ant-message-error',
'.ant-notification-notice-error',
'.ant-alert-error',
'.ant-result-error',
'.ant-modal-confirm-error',
] as const;
const SYSTEM_FAILURE_PATTERNS = [
/服务器\s*(错误|异常|繁忙)/,
/系统\s*(异常|维护|不可用|错误)/,
/Internal Server Error/i,
/Bad Gateway/i,
/Service Unavailable/i,
/Gateway Time-out/i,
/502\s*Bad Gateway/i,
/503\s*Service Unavailable/i,
/504\s*Gateway Time-out/i,
/网关超时/,
/请求失败/,
/网络异常/,
/连接超时/,
/无法连接/,
/服务暂不可用/,
];
export type PageHealthScope = {
page: Page;
/** 报表 iframe 等额外检查范围 */
roots?: Array<Locator | FrameLocator>;
};
async function collectVisibleErrorMessages(
root: Page | Locator | FrameLocator,
): Promise<string[]> {
const messages: string[] = [];
for (const selector of ERROR_SELECTORS) {
const locator = root.locator(selector);
const count = await locator.count().catch(() => 0);
for (let i = 0; i < count; i += 1) {
const item = locator.nth(i);
if (!(await item.isVisible().catch(() => false))) continue;
const text = (await item.innerText().catch(() => '')).trim();
if (text) messages.push(text);
}
}
return messages;
}
export async function collectPageHealthIssues(scope: PageHealthScope): Promise<string[]> {
const issues: string[] = [];
const roots: Array<Page | Locator | FrameLocator> = [scope.page, ...(scope.roots ?? [])];
for (const root of roots) {
issues.push(...(await collectVisibleErrorMessages(root)));
}
const bodyText = (await scope.page.locator('body').innerText().catch(() => '')).slice(0, 8000);
for (const pattern of SYSTEM_FAILURE_PATTERNS) {
const match = bodyText.match(pattern);
if (match?.[0]) {
issues.push(`系统/服务异常文案:${match[0]}`);
}
}
for (const root of scope.roots ?? []) {
const rootText = (await root.locator('body').innerText().catch(() => '')).slice(0, 8000);
for (const pattern of SYSTEM_FAILURE_PATTERNS) {
const match = rootText.match(pattern);
if (match?.[0]) {
issues.push(`报表区域异常文案:${match[0]}`);
}
}
}
return [...new Set(issues)];
}
/** 页面报错或系统异常时断言失败(不 skip)。 */
export async function assertPageHealthy(scope: PageHealthScope, context: string): Promise<void> {
const issues = await collectPageHealthIssues(scope);
expect(
issues,
`${context}:检测到页面报错或系统异常,用例判为失败(不 skip)`,
).toEqual([]);
}
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