Commit 2d26844c authored by liguangyu06's avatar liguangyu06
Browse files

同步基础管理/报表用例与登录态稳定性修复

修复 auth 写入锁 stale PID 导致 setup 空等超时;主辅账号 setup 串行并延长超时,login 依赖 setup。同步用户/厂商/准入等 CSV·MD·spec·PO,移除 stat 调试 spec,补充离线全量脚本与 gate-stamp 目录哈希修复。
parent 45178f79
...@@ -8,8 +8,8 @@ alwaysApply: true ...@@ -8,8 +8,8 @@ alwaysApply: true
## 原则(强制) ## 原则(强制)
1. **复用登录态文件,不复用 BrowserContext** 1. **复用登录态文件,不复用 BrowserContext**
- `tests/setup/auth.setup.ts` 一次性登录,写入 `auth/user.json`(`context.storageState`)。 - `tests/setup/auth.setup.ts` 为主/辅账号登录,写入 `auth/user.json`、`auth/user-playwright.json`。
- `chromium` project:`dependencies: ['setup']` + `use.storageState: AUTH_STATE_PATH`。 - `chromium` project:`dependencies: ['setup']`;`fixtures/index.ts` 的 `storageState` fixture 按 worker 加载对应文件。
- 业务用例通过 Playwright 默认 `context`/`page` fixture 加载该文件,拿到**已登录**上下文。 - 业务用例通过 Playwright 默认 `context`/`page` fixture 加载该文件,拿到**已登录**上下文。
2. **每个用例独立 context,互不污染** 2. **每个用例独立 context,互不污染**
......
...@@ -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 强制探活
......
...@@ -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
...@@ -21,8 +21,8 @@ Chromium 缓存在项目内 `.playwright-browsers/`(已 gitignore),与 `@p ...@@ -21,8 +21,8 @@ Chromium 缓存在项目内 `.playwright-browsers/`(已 gitignore),与 `@p
### 登录态复用(storageState,大幅提速) ### 登录态复用(storageState,大幅提速)
- **单独 setup**:`tests/setup/auth.setup.ts` 一次性登录,调用 `context.storageState({ path: auth/user.json })`。 - **单独 setup**:`tests/setup/auth.setup.ts` 为主/辅账号各登录一次,写入 `auth/user.json` 与 `auth/user-playwright.json`。
- **业务用例加载文件**:`chromium` project 配置 `storageState: AUTH_STATE_PATH`;每条用例由 Playwright **新建独立 BrowserContext**,再注入该文件中的 cookie / localStorage,直接进入已登录态。 - **业务用例加载文件**:`fixtures/index.ts` 按 worker 注入对应 `storageState`;每条用例由 Playwright **新建独立 BrowserContext**,再注入 cookie / localStorage,直接进入已登录态。
- **隔离第一原则**:复用的是**登录态文件**,不是同一个 context。禁止全局共享 `BrowserContext`/`Page`;厂商 / 准入 / 医院资料等新模块每条用例各自 `open`,互不污染。未登录反例用局部 `test.use({ storageState: { cookies: [], origins: [] } })`。 - **隔离第一原则**:复用的是**登录态文件**,不是同一个 context。禁止全局共享 `BrowserContext`/`Page`;厂商 / 准入 / 医院资料等新模块每条用例各自 `open`,互不污染。未登录反例用局部 `test.use({ storageState: { cookies: [], origins: [] } })`。
- token 未过期可跳过重复登录与探活;过期或被踢则 setup 重新登录。强制刷新:`FORCE_AUTH_LOGIN=1`。Jenkins / `npm run test:ci` 默认强制重新登录,避免 Agent 残留旧会话。 - token 未过期可跳过重复登录与探活;过期或被踢则 setup 重新登录。强制刷新:`FORCE_AUTH_LOGIN=1`。Jenkins / `npm run test:ci` 默认强制重新登录,避免 Agent 残留旧会话。
- 存量报表里的 `describeWithReuseOpen`(组内同页加速)**不要**扩到新模块。 - 存量报表里的 `describeWithReuseOpen`(组内同页加速)**不要**扩到新模块。
...@@ -36,12 +36,12 @@ Chromium 缓存在项目内 `.playwright-browsers/`(已 gitignore),与 `@p ...@@ -36,12 +36,12 @@ 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` / `vendor` / `admit` / `basic` / `surgery` / `warehouse` / `flow` 等 | | 模块 | `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)。
...@@ -97,7 +97,7 @@ scripts/run-ci.mjs Jenkins / 定时执行入口 ...@@ -97,7 +97,7 @@ scripts/run-ci.mjs Jenkins / 定时执行入口
**失败诊断**:失败时附件 `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);
}
...@@ -36,6 +36,11 @@ export const timeouts = { ...@@ -36,6 +36,11 @@ export const timeouts = {
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 */
...@@ -43,6 +48,8 @@ export const timeouts = { ...@@ -43,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 可强制每次探活。
......
...@@ -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',
); );
} }
...@@ -233,7 +265,7 @@ export class BasePage { ...@@ -233,7 +265,7 @@ export class BasePage {
const queryBtn = options.queryButton ?? scope.getByRole('button', { name: /查\s*询/ }).first(); const queryBtn = options.queryButton ?? scope.getByRole('button', { name: /查\s*询/ }).first();
await this.click(queryBtn.first(), '组合查询:点击查询'); await this.click(queryBtn.first(), '组合查询:点击查询');
await this.waitForTableReady(); await this.waitForTableShellVisible();
return { applied, missing }; return { applied, missing };
} }
...@@ -305,8 +337,8 @@ export class BasePage { ...@@ -305,8 +337,8 @@ export class BasePage {
await this.waitSettle(300); await this.waitSettle(300);
} }
/** 列表就绪(表头或空态出现)。 */ /** 列表壳就绪(表头或空态出现),不含业务接口健康断言。 */
async waitForTableReady(timeout = timeouts.expect): Promise<void> { async waitForTableShellVisible(timeout = timeouts.expect): Promise<void> {
await this.page await this.page
.locator('.ant-table-thead th, .ant-table-header th, .ant-empty-normal') .locator('.ant-table-thead th, .ant-table-header th, .ant-empty-normal')
.first() .first()
......
...@@ -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',
......
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);
} }
......
...@@ -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(仅负向用例使用) */
......
...@@ -155,6 +155,6 @@ HTML 报告链接只有 **能打开 Jenkins** 的人才能看。外网领导打 ...@@ -155,6 +155,6 @@ HTML 报告链接只有 **能打开 Jenkins** 的人才能看。外网领导打
## Git 上线检查 ## Git 上线检查
- `.env`、`auth/*.json`、`.playwright-browsers/`、`reports/*`(除 README)已在 `.gitignore` - `.env`、`auth/*.json`、`.playwright-browsers/`、`reports/*`(除 README)已在 `.gitignore`
- 推送后 Jenkins 用凭据注入 `TEST_USER` / `TEST_PASS` - 推送后 Jenkins 用凭据注入 `TEST_USER` / `TEST_PASS`;可选在任务环境变量增加 `TEST_USER_2` / `TEST_PASS_2`,worker 按序号在双账号间轮换
- GitLab:根目录 `.gitlab-ci.yml` 默认只跑 `typecheck`;冒烟需配置 Variables `TEST_USER` / `TEST_PASS`,在 Web/定时流水线手动触发 - GitLab:根目录 `.gitlab-ci.yml` 默认只跑 `typecheck`;冒烟需配置 Variables `TEST_USER` / `TEST_PASS`,在 Web/定时流水线手动触发
- GitHub Actions 默认跑 `smoke`,完整回归留给 Jenkins 夜间任务 - GitHub Actions 默认跑 `smoke`,完整回归留给 Jenkins 夜间任务
import { test as base, expect, type TestDetails, type TestInfo } from '@playwright/test'; import { test as base, expect, type Route, type TestDetails, type TestInfo } from '@playwright/test';
import { retryAction } from '../core/action-retry'; import { retryAction } from '../core/action-retry';
import { applyCaseMeta } from '../core/case-meta'; import { applyCaseMeta } from '../core/case-meta';
...@@ -12,16 +12,36 @@ import { ...@@ -12,16 +12,36 @@ import {
getRuntimeHealthGuard, getRuntimeHealthGuard,
} from '../core/runtime-health.util'; } from '../core/runtime-health.util';
import { failWhen, guardedSpecSkip } from '../pages/spd/helpers/guarded-skip.helper'; import { failWhen, guardedSpecSkip } from '../pages/spd/helpers/guarded-skip.helper';
import { resolveAuthStorageStatePath } from '../utils/auth-pool.util';
import { buildPageFixtures, type AppPageFixtures } from './page-registry'; import { buildPageFixtures, type AppPageFixtures } from './page-registry';
type AppFixtures = AppPageFixtures; type AppFixtures = AppPageFixtures;
const BLOCKED_RESOURCE_TYPES = new Set(['image', 'media', 'font']);
const ANALYTICS_URL =
/(?:google-analytics|googletagmanager|hm\.baidu\.com|cnzz\.com|hotjar|sentry\.io|umeng\.com|growingio|sensorsdata|clarity\.ms)/i;
/** 拦截图片、字体、音视频和统计脚本。不拦截 document / xhr / fetch,报表查询仍走网络。 */
async function abortHeavyAssets(route: Route): Promise<void> {
const request = route.request();
if (BLOCKED_RESOURCE_TYPES.has(request.resourceType()) || ANALYTICS_URL.test(request.url())) {
await route.abort();
return;
}
await route.continue();
}
/** /**
* 业务 fixture:依赖 chromium.project 的 storageState(auth/user.json)。 * 业务 fixture:storageState 来自 auth.setup 写入的文件。
* 每条用例由 Playwright 新建独立 context + page,再注入登录态;此处不持有跨用例 session。 * 配置 TEST_USER_2/TEST_PASS_2 时按 parallelIndex 在双账号间轮换,减轻互踢会话。
* 每条用例仍新建独立 context + page;此处不持有跨用例 session。
*/ */
export const test = base.extend<AppFixtures>({ export const test = base.extend<AppFixtures>({
storageState: async ({}, use, workerInfo) => {
await use(resolveAuthStorageStatePath(workerInfo.parallelIndex));
},
page: async ({ page }, use) => { page: async ({ page }, use) => {
await page.context().route('**/*', abortHeavyAssets);
attachRuntimeHealthGuard(page); attachRuntimeHealthGuard(page);
page.context().on('page', (popup) => { page.context().on('page', (popup) => {
attachRuntimeHealthGuard(popup); attachRuntimeHealthGuard(popup);
......
...@@ -8,7 +8,7 @@ related_jrn: "" ...@@ -8,7 +8,7 @@ related_jrn: ""
related_tc: "testpoint/基础管理-用户管理-医共体用户管理-测试点分析-01.md" related_tc: "testpoint/基础管理-用户管理-医共体用户管理-测试点分析-01.md"
related_def: "" related_def: ""
create_time: "2026-10-20" create_time: "2026-10-20"
update_time: "2026-10-20" update_time: "2026-10-22"
author: "" author: ""
--- ---
...@@ -88,9 +88,9 @@ author: "" ...@@ -88,9 +88,9 @@ author: ""
1. 切换「所属医院」后列表只显示该院区用户,「共 N 条」同步变化。 1. 切换「所属医院」后列表只显示该院区用户,「共 N 条」同步变化。
2. 两页同一用户的关键字段(姓名、登录账号、角色、所属科室)一致,仅多出「所属医院」维度。 2. 两页同一用户的关键字段(姓名、登录账号、角色、所属科室)一致,仅多出「所属医院」维度。
3. 姓名 + 所属医院 + 科室 + 角色 + 状态组合筛选精确命中;高级筛选按状态过滤;重置恢复默认医院。 3. 姓名 + 所属医院 + 科室 + 角色组合筛选精确命中;高级筛选按科室/角色过滤(现网扫描无「状态」字段,启用/禁用仅在编辑弹窗);重置恢复默认医院。自动化 `USER-UNI-013` 按科室过滤。
4. 切换每页条数(如 **20 条/页**)后可见行数随所选值变化且「共 N 条」不变;列设置可打开并恢复列显示(自动化 `USER-UNI-018`~`020`)。 4. 切换每页条数(如 **20 条/页**)后可见行数随所选值变化且「共 N 条」不变;列设置可打开并恢复列显示(自动化 `USER-UNI-015`~`016`)。
5. 行内「授权」弹出用户角色授权弹框,账号与所选行一致;弹框内「添加/移除」可增删角色行;取消不落库(自动化 `USER-UNI-021`~`023`)。 5. 行内「授权」弹出用户角色授权弹框,账号与所选行一致;弹框内「添加/移除」可增删角色行;取消不落库(自动化 `USER-UNI-017`~`019`)。
6. 写操作(编辑/授权/重置密码)须先定位当前行,医共体下避免误改其他院区用户。 6. 写操作(编辑/授权/重置密码)须先定位当前行,医共体下避免误改其他院区用户。
## 关联 ## 关联
......
...@@ -31,15 +31,6 @@ ...@@ -31,15 +31,6 @@
"test:quarantine": "node scripts/run-ci.mjs --suite=quarantine", "test:quarantine": "node scripts/run-ci.mjs --suite=quarantine",
"test:smoke": "playwright test tests/spd/login.spec.ts tests/spd/home.spec.ts --grep @smoke", "test:smoke": "playwright test tests/spd/login.spec.ts tests/spd/home.spec.ts --grep @smoke",
"test:smoke:allure": "ALLURE=1 npm run test:smoke && npm run allure:generate", "test:smoke:allure": "ALLURE=1 npm run test:smoke && npm run allure:generate",
"test:stat-iod-002": "SLOW_MO=500 playwright test tests/spd/center-report-sheet.stat-iod-002.spec.ts --headed --project=chromium --workers=1",
"test:stat-iod-003": "SLOW_MO=500 playwright test tests/spd/center-report-sheet.stat-iod-003.spec.ts --headed --project=chromium --workers=1",
"test:stat-iod-004": "SLOW_MO=500 playwright test tests/spd/center-report-sheet.stat-iod-004.spec.ts --headed --project=chromium --workers=1",
"test:stat-iod-006": "SLOW_MO=500 playwright test tests/spd/center-report-sheet.stat-iod-006.spec.ts --headed --project=chromium --workers=1",
"test:stat-iod-043": "SLOW_MO=500 playwright test tests/spd/center-report-sheet.stat-iod-043.spec.ts --headed --project=chromium --workers=1",
"test:stat-iod-044": "SLOW_MO=500 playwright test tests/spd/center-report-sheet.stat-iod-044.spec.ts --headed --project=chromium --workers=1",
"test:stat-ies-027": "SLOW_MO=500 PWDEBUG=console playwright test tests/spd/center-report-entry.stat-ies-027.spec.ts --headed --project=chromium --workers=1",
"test:stat-oos-004": "SLOW_MO=500 playwright test tests/spd/center-report-out.stat-oos-004.spec.ts --headed --project=chromium --workers=1",
"test:stat-oos-006": "SLOW_MO=500 playwright test tests/spd/center-report-out.stat-oos-006.spec.ts --headed --project=chromium --workers=1",
"test:center-report": "playwright test tests/spd/center-report-sheet.spec.ts --project=chromium", "test:center-report": "playwright test tests/spd/center-report-sheet.spec.ts --project=chromium",
"test:center-report:all": "playwright test tests/spd/center-report-sheet.spec.ts --project=chromium --workers=3", "test:center-report:all": "playwright test tests/spd/center-report-sheet.spec.ts --project=chromium --workers=3",
"test:center-report-entry": "playwright test tests/spd/center-report-entry.spec.ts --project=chromium", "test:center-report-entry": "playwright test tests/spd/center-report-entry.spec.ts --project=chromium",
...@@ -52,12 +43,6 @@ ...@@ -52,12 +43,6 @@
"test:center-report-lmm-sheet:all": "playwright test tests/spd/center-report-lmm-sheet.spec.ts --project=chromium", "test:center-report-lmm-sheet:all": "playwright test tests/spd/center-report-lmm-sheet.spec.ts --project=chromium",
"test:center-report-turnover": "playwright test tests/spd/center-report-turnover.spec.ts --project=chromium", "test:center-report-turnover": "playwright test tests/spd/center-report-turnover.spec.ts --project=chromium",
"test:center-report-turnover:all": "playwright test tests/spd/center-report-turnover.spec.ts --project=chromium", "test:center-report-turnover:all": "playwright test tests/spd/center-report-turnover.spec.ts --project=chromium",
"test:stat-ptr-002": "SLOW_MO=500 playwright test tests/spd/center-report-turnover.stat-ptr-002.spec.ts --headed --project=chromium --workers=1",
"test:stat-lms-005": "SLOW_MO=500 playwright test tests/spd/center-report-lmm-sheet.stat-lms-005.spec.ts --headed --project=chromium --workers=1",
"test:stat-nos-007": "SLOW_MO=500 PWDEBUG=console playwright test tests/spd/center-report-net-out.stat-nos-007.spec.ts --headed --project=chromium --workers=1",
"test:stat-nes-007": "SLOW_MO=500 PWDEBUG=console playwright test tests/spd/center-report-net-entry.stat-nes-007.spec.ts --headed --project=chromium --workers=1",
"test:stat-psi-004": "SLOW_MO=500 PWDEBUG=console playwright test tests/spd/center-report-psi.stat-psi-004.spec.ts --headed --project=chromium --workers=1",
"test:stat-lmp-004": "SLOW_MO=500 PWDEBUG=console playwright test tests/spd/center-report-lmm-psi.stat-lmp-004.spec.ts --headed --project=chromium --workers=1",
"test:center-report-lmm-psi": "playwright test tests/spd/center-report-lmm-psi.spec.ts --project=chromium", "test:center-report-lmm-psi": "playwright test tests/spd/center-report-lmm-psi.spec.ts --project=chromium",
"test:center-report-lmm-psi:all": "playwright test tests/spd/center-report-lmm-psi.spec.ts --project=chromium", "test:center-report-lmm-psi:all": "playwright test tests/spd/center-report-lmm-psi.spec.ts --project=chromium",
"test:center-report-net-entry": "playwright test tests/spd/center-report-net-entry.spec.ts --project=chromium", "test:center-report-net-entry": "playwright test tests/spd/center-report-net-entry.spec.ts --project=chromium",
...@@ -183,9 +168,6 @@ ...@@ -183,9 +168,6 @@
"test:invoice-balance-status": "playwright test tests/spd/invoice-balance-status.spec.ts --project=chromium", "test:invoice-balance-status": "playwright test tests/spd/invoice-balance-status.spec.ts --project=chromium",
"test:invoice-balance-status:all": "playwright test tests/spd/invoice-balance-status.spec.ts --project=chromium --workers=1", "test:invoice-balance-status:all": "playwright test tests/spd/invoice-balance-status.spec.ts --project=chromium --workers=1",
"test:trace-direct-buy": "playwright test tests/spd/trace-direct-buy.spec.ts --project=chromium", "test:trace-direct-buy": "playwright test tests/spd/trace-direct-buy.spec.ts --project=chromium",
"test:stat-tby-004": "SLOW_MO=500 playwright test tests/spd/trace-direct-buy.stat-tby-004.spec.ts --headed --project=chromium --workers=1",
"test:stat-tbc-004": "SLOW_MO=500 playwright test tests/spd/trace-barcode.stat-tbc-004.spec.ts --headed --project=chromium --workers=1",
"test:stat-tdb-004": "SLOW_MO=500 playwright test tests/spd/trace-dept-buy.stat-tdb-004.spec.ts --headed --project=chromium --workers=1",
"test:trace-direct-buy:all": "playwright test tests/spd/trace-direct-buy.spec.ts --project=chromium --workers=1", "test:trace-direct-buy:all": "playwright test tests/spd/trace-direct-buy.spec.ts --project=chromium --workers=1",
"test:trace-barcode": "playwright test tests/spd/trace-barcode.spec.ts --project=chromium", "test:trace-barcode": "playwright test tests/spd/trace-barcode.spec.ts --project=chromium",
"test:trace-barcode:all": "playwright test tests/spd/trace-barcode.spec.ts --project=chromium --workers=1", "test:trace-barcode:all": "playwright test tests/spd/trace-barcode.spec.ts --project=chromium --workers=1",
......
...@@ -91,6 +91,7 @@ export class AdmitMaterialListPage extends AdmitSharedPage { ...@@ -91,6 +91,7 @@ export class AdmitMaterialListPage extends AdmitSharedPage {
await this.leaveWithoutCommit(); await this.leaveWithoutCommit();
await this.skipWhenDataInsufficient('详情无导出'); await this.skipWhenDataInsufficient('详情无导出');
} }
await this.assertBeforeBusinessAction('详情导出');
await this.click(exp, '导出'); await this.click(exp, '导出');
await this.assertPageHealthy('详情导出'); await this.assertPageHealthy('详情导出');
expect(this.page.url()).not.toMatch(/\/login/); expect(this.page.url()).not.toMatch(/\/login/);
......
...@@ -187,10 +187,11 @@ export class AdmitMaterialPage extends AdmitSharedPage { ...@@ -187,10 +187,11 @@ export class AdmitMaterialPage extends AdmitSharedPage {
async openLog(): Promise<void> { async openLog(): Promise<void> {
await this.query(); await this.query();
const row = await this.skipIfNoAction('日志', '列表无日志入口'); const row = await this.skipIfNoAction('日志', '列表无日志入口');
await this.assertBeforeBusinessAction('日志');
await this.click(this.rowAction(row, '日志'), '日志'); await this.click(this.rowAction(row, '日志'), '日志');
const layer = this.page.locator('.ant-modal:visible, .ant-drawer:visible, .put-page:visible').last(); const layer = this.page.locator('.ant-modal:visible, .ant-drawer:visible, .put-page:visible').last();
await expect(layer, '日志层应打开').toBeVisible(); await expect(layer, '日志层应打开').toBeVisible();
await this.assertPageHealthy('日志'); await this.assertUiReady('日志');
await this.leaveWithoutCommit(); await this.leaveWithoutCommit();
} }
} }
...@@ -32,7 +32,7 @@ export class AdmitSharedPage extends BasePage { ...@@ -32,7 +32,7 @@ export class AdmitSharedPage extends BasePage {
await this.goto(this.meta.path); await this.goto(this.meta.path);
await this.waitSpinGone(); await this.waitSpinGone();
await this.waitSettle(400); await this.waitSettle(400);
await this.assertPageHealthy(`打开 ${this.meta.path}`); await this.assertUiReady(`打开 ${this.meta.path}`);
expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/); expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/);
} }
...@@ -50,7 +50,7 @@ export class AdmitSharedPage extends BasePage { ...@@ -50,7 +50,7 @@ export class AdmitSharedPage extends BasePage {
await this.page.waitForURL((url) => url.pathname.includes(this.meta.pathPart), { timeout: 20_000 }); await this.page.waitForURL((url) => url.pathname.includes(this.meta.pathPart), { timeout: 20_000 });
await this.waitSpinGone(); await this.waitSpinGone();
await this.waitSettle(400); await this.waitSettle(400);
await this.assertPageHealthy(`菜单进入${this.meta.menu}`); await this.assertUiReady(`菜单进入${this.meta.menu}`);
} }
async expectOnPage(): Promise<void> { async expectOnPage(): Promise<void> {
...@@ -71,11 +71,13 @@ export class AdmitSharedPage extends BasePage { ...@@ -71,11 +71,13 @@ export class AdmitSharedPage extends BasePage {
} }
async query(): Promise<void> { async query(): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.clickButton('查询'); await this.clickButton('查询');
await this.waitForTableReady(); await this.waitForTableReady();
} }
async reset(): Promise<void> { async reset(): Promise<void> {
await this.assertBeforeBusinessAction('重置');
await this.clickButton('重置'); await this.clickButton('重置');
await this.waitForTableReady(); await this.waitForTableReady();
} }
...@@ -136,6 +138,7 @@ export class AdmitSharedPage extends BasePage { ...@@ -136,6 +138,7 @@ export class AdmitSharedPage extends BasePage {
} }
async clickTab(name: string): Promise<void> { async clickTab(name: string): Promise<void> {
await this.assertBeforeBusinessAction(`切换${name}`);
await this.click(this.tab(name), `切换${name}`); await this.click(this.tab(name), `切换${name}`);
await this.waitForTableReady(); await this.waitForTableReady();
} }
...@@ -207,10 +210,10 @@ export class AdmitSharedPage extends BasePage { ...@@ -207,10 +210,10 @@ export class AdmitSharedPage extends BasePage {
return match ? Number(match[1]) : 0; return match ? Number(match[1]) : 0;
} }
async waitForTableReady(): Promise<void> { async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise<void> {
await this.waitSpinGone(); await this.waitSpinGone();
await this.waitSettle(200); await this.waitSettle(200);
await this.assertPageHealthy('列表加载'); await this.assertAfterTableWait('列表加载', options?.health ?? 'business');
} }
async cellText(row: Locator, header: string): Promise<string> { async cellText(row: Locator, header: string): Promise<string> {
...@@ -422,6 +425,7 @@ export class AdmitSharedPage extends BasePage { ...@@ -422,6 +425,7 @@ export class AdmitSharedPage extends BasePage {
} }
async openPutFromRow(row: Locator, action: string): Promise<void> { async openPutFromRow(row: Locator, action: string): Promise<void> {
await this.assertBeforeBusinessAction(action);
await this.click(this.rowAction(row, action), action); await this.click(this.rowAction(row, action), action);
await expect(this.put(), `${action}应打开详情或编辑层`).toBeVisible({ timeout: 15_000 }); await expect(this.put(), `${action}应打开详情或编辑层`).toBeVisible({ timeout: 15_000 });
await this.waitSpinGone(); await this.waitSpinGone();
...@@ -512,6 +516,7 @@ export class AdmitSharedPage extends BasePage { ...@@ -512,6 +516,7 @@ export class AdmitSharedPage extends BasePage {
} }
async clickToolbarThenCancel(name: string): Promise<void> { async clickToolbarThenCancel(name: string): Promise<void> {
await this.assertBeforeBusinessAction(name);
await this.clickButton(name); await this.clickButton(name);
const layer = this.page.locator('.put-page:visible, .ant-modal:visible, .ant-drawer:visible').last(); const layer = this.page.locator('.put-page:visible, .ant-modal:visible, .ant-drawer:visible').last();
await expect(layer, `${name}应打开操作层`).toBeVisible({ timeout: 10_000 }); await expect(layer, `${name}应打开操作层`).toBeVisible({ timeout: 10_000 });
...@@ -520,9 +525,10 @@ export class AdmitSharedPage extends BasePage { ...@@ -520,9 +525,10 @@ export class AdmitSharedPage extends BasePage {
async openDetailSection(name: string): Promise<void> { async openDetailSection(name: string): Promise<void> {
const target = this.put().getByRole('button', { name: new RegExp(name) }).or(this.put().getByText(name)).first(); const target = this.put().getByRole('button', { name: new RegExp(name) }).or(this.put().getByText(name)).first();
await this.assertBeforeBusinessAction(name);
await this.click(target, name); await this.click(target, name);
await this.waitSettle(300); await this.waitSettle(300);
await this.assertPageHealthy(name); await this.assertUiReady(name);
await expect(this.put(), '详情层仍在').toBeVisible(); await expect(this.put(), '详情层仍在').toBeVisible();
} }
...@@ -531,6 +537,7 @@ export class AdmitSharedPage extends BasePage { ...@@ -531,6 +537,7 @@ export class AdmitSharedPage extends BasePage {
} }
async saveOnPut(): Promise<void> { async saveOnPut(): Promise<void> {
await this.assertBeforeBusinessAction('保存');
const save = this.button('保存', this.put()).first(); const save = this.button('保存', this.put()).first();
if (await save.isVisible().catch(() => false)) { if (await save.isVisible().catch(() => false)) {
await this.click(save, '保存'); await this.click(save, '保存');
......
...@@ -24,6 +24,7 @@ export class BasicFinanceMonthPage extends BasicHospitalSharedPage { ...@@ -24,6 +24,7 @@ export class BasicFinanceMonthPage extends BasicHospitalSharedPage {
} }
async search(): Promise<void> { async search(): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.click(this.locators.searchBtn, '查 询'); await this.click(this.locators.searchBtn, '查 询');
await this.waitForTableReady(); await this.waitForTableReady();
} }
...@@ -33,6 +34,7 @@ export class BasicFinanceMonthPage extends BasicHospitalSharedPage { ...@@ -33,6 +34,7 @@ export class BasicFinanceMonthPage extends BasicHospitalSharedPage {
await this.click(this.locators.cancelBtn, '取消行内编辑'); await this.click(this.locators.cancelBtn, '取消行内编辑');
} }
if (await this.locators.resetBtn.isVisible().catch(() => false)) { if (await this.locators.resetBtn.isVisible().catch(() => false)) {
await this.assertBeforeBusinessAction('重置');
await this.click(this.locators.resetBtn, '重 置'); await this.click(this.locators.resetBtn, '重 置');
await this.waitForTableReady(); await this.waitForTableReady();
} }
......
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