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
## 原则(强制)
1. **复用登录态文件,不复用 BrowserContext**
- `tests/setup/auth.setup.ts` 一次性登录,写入 `auth/user.json`(`context.storageState`)。
- `chromium` project:`dependencies: ['setup']` + `use.storageState: AUTH_STATE_PATH`。
- `tests/setup/auth.setup.ts` 为主/辅账号登录,写入 `auth/user.json`、`auth/user-playwright.json`。
- `chromium` project:`dependencies: ['setup']`;`fixtures/index.ts` 的 `storageState` fixture 按 worker 加载对应文件。
- 业务用例通过 Playwright 默认 `context`/`page` fixture 加载该文件,拿到**已登录**上下文。
2. **每个用例独立 context,互不污染**
......
......@@ -9,6 +9,10 @@ HOME_PATH=/spd/home/index
TEST_USER=
TEST_PASS=
# 第二测试账号(可选):与主账号按 worker 轮换登录态,并行时减轻同账号互踢
# TEST_USER_2=playwright
# TEST_PASS_2=
# 设为 1 时每次运行强制重新登录(默认复用 auth/user.json,token 过期或失效才登录)
# FORCE_AUTH_LOGIN=0
# token 剩余有效期 ≥ AUTH_FRESH_REMAINING_MS 时跳过首页探活(默认 20 分钟);设 0 强制探活
......
......@@ -5,7 +5,7 @@
## 快速开始
```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 run typecheck # CI 同样会跑 tsc --noEmit
npm run gate # 未执行通过的用例脚本不能 commit/push,见 docs/git-gate.md
......@@ -21,8 +21,8 @@ Chromium 缓存在项目内 `.playwright-browsers/`(已 gitignore),与 `@p
### 登录态复用(storageState,大幅提速)
- **单独 setup**:`tests/setup/auth.setup.ts` 一次性登录,调用 `context.storageState({ path: auth/user.json })`。
- **业务用例加载文件**:`chromium` project 配置 `storageState: AUTH_STATE_PATH`;每条用例由 Playwright **新建独立 BrowserContext**,再注入该文件中的 cookie / localStorage,直接进入已登录态。
- **单独 setup**:`tests/setup/auth.setup.ts` 为主/辅账号各登录一次,写入 `auth/user.json` 与 `auth/user-playwright.json`。
- **业务用例加载文件**:`fixtures/index.ts` 按 worker 注入对应 `storageState`;每条用例由 Playwright **新建独立 BrowserContext**,再注入 cookie / localStorage,直接进入已登录态。
- **隔离第一原则**:复用的是**登录态文件**,不是同一个 context。禁止全局共享 `BrowserContext`/`Page`;厂商 / 准入 / 医院资料等新模块每条用例各自 `open`,互不污染。未登录反例用局部 `test.use({ storageState: { cookies: [], origins: [] } })`。
- token 未过期可跳过重复登录与探活;过期或被踢则 setup 重新登录。强制刷新:`FORCE_AUTH_LOGIN=1`。Jenkins / `npm run test:ci` 默认强制重新登录,避免 Agent 残留旧会话。
- 存量报表里的 `describeWithReuseOpen`(组内同页加速)**不要**扩到新模块。
......@@ -36,12 +36,12 @@ Chromium 缓存在项目内 `.playwright-browsers/`(已 gitignore),与 `@p
| 套件 | 命令 | 用途 |
|------|------|------|
| `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` | 含全列排序,建议周末 |
| 模块 | `SUITE=center npm run test:ci` | `center` / `dept` / `vendor` / `admit` / `basic` / `surgery` / `warehouse` / `flow` 等 |
| 夜间分片 | `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)。
......@@ -97,7 +97,7 @@ scripts/run-ci.mjs Jenkins / 定时执行入口
**失败诊断**:失败时附件 `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 = {
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 {
return Boolean(credentials.username && credentials.password);
}
export function hasSecondaryCredentials(): boolean {
return Boolean(credentialsSecondary.username && credentialsSecondary.password);
}
......@@ -36,6 +36,11 @@ export const timeouts = {
tableAttached: num('TABLE_ATTACHED_TIMEOUT', 15_000),
/** waitForTableReady 轮询上限(原 45s) */
tableReady: num('TABLE_READY_TIMEOUT', 20_000),
/**
* 查询后尚无行、无空态、也无 spin 时,留给 loading 出现的短窗口(毫秒)。
* 超过即视为空闲,避免空表卡满整段 tableReady。
*/
tableIdleGrace: num('TABLE_IDLE_GRACE_MS', 400),
/** 查询按钮 loading 消失 */
searchLoading: num('SEARCH_LOADING_TIMEOUT', 15_000),
/** 导出接口 waitForResponse */
......@@ -43,6 +48,8 @@ export const timeouts = {
/** auth setup 探活 */
authProbeNav: num('AUTH_PROBE_NAV_TIMEOUT', 12_000),
authProbeMenu: num('AUTH_PROBE_MENU_TIMEOUT', 8_000),
/** auth.setup 写登录态(含锁等待 + 登录跳转) */
authSetup: num('AUTH_SETUP_TIMEOUT', 120_000),
/**
* token 剩余有效期大于该值则跳过服务端探活(默认 20 分钟)。
* 设 AUTH_SKIP_PROBE_IF_FRESH=0 可强制每次探活。
......
......@@ -16,6 +16,9 @@ import {
import { type PageHealthScope } from './page-health.util';
import { logger } from './logger';
/** 列表就绪后的健康断言:进页仅 ui;查询/导出等业务动作完成后用 business。 */
export type TableHealthMode = 'ui' | 'business';
/** 所有 Page Object 的基类:导航、智能等待、通用操作。选择器不写在这里。 */
export class BasePage {
constructor(protected readonly page: Page) {}
......@@ -30,12 +33,41 @@ export class BasePage {
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> {
await assertRuntimeHealthy(
this.pageHealthScope(),
context,
getRuntimeHealthGuard(this.page),
'business',
);
}
......@@ -233,7 +265,7 @@ export class BasePage {
const queryBtn = options.queryButton ?? scope.getByRole('button', { name: /查\s*询/ }).first();
await this.click(queryBtn.first(), '组合查询:点击查询');
await this.waitForTableReady();
await this.waitForTableShellVisible();
return { applied, missing };
}
......@@ -305,8 +337,8 @@ export class BasePage {
await this.waitSettle(300);
}
/** 列表就绪(表头或空态出现)。 */
async waitForTableReady(timeout = timeouts.expect): Promise<void> {
/** 列表壳就绪(表头或空态出现),不含业务接口健康断言。 */
async waitForTableShellVisible(timeout = timeouts.expect): Promise<void> {
await this.page
.locator('.ant-table-thead th, .ant-table-header th, .ant-empty-normal')
.first()
......
......@@ -17,6 +17,7 @@ export type FailureSnapshot = {
pageTitle: string;
frames: string[];
healthIssues: string[];
staticObservations: string[];
closed: boolean;
classification?: FailureClassification;
healSuggestions?: string[];
......@@ -33,6 +34,7 @@ export async function collectFailureSnapshot(
let pageTitle = '';
const frames: string[] = [];
let healthIssues: string[] = [];
let staticObservations: string[] = [];
if (!closed) {
url = page.url();
......@@ -44,7 +46,10 @@ export async function collectFailureSnapshot(
}
}
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(
pageTitle,
frames: frames.slice(0, 8),
healthIssues,
staticObservations,
closed,
classification,
healSuggestions,
......@@ -84,6 +90,13 @@ export function formatFailureMarkdown(snapshot: FailureSnapshot): string {
} else {
lines.push(``, `## 运行时健康问题`, `- 无(或页面已关闭未能采集)`);
}
if (snapshot.staticObservations.length) {
lines.push(
``,
`## 静态资源观测(仅日志,不阻断用例)`,
...snapshot.staticObservations.slice(0, 10).map((item) => `- ${item}`),
);
}
if (snapshot.healSuggestions?.length) {
lines.push(
``,
......
......@@ -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(
raw,
) ||
/接口请求失败:.*\(net::/i.test(health)
/业务接口请求失败:.*\(net::/i.test(health)
) {
return {
cls: 'environment',
......@@ -79,7 +79,11 @@ export function classifyFailure(input: {
};
}
if (/检测到页面\/接口\/系统报错|检测到页面报错或系统异常|业务码=/.test(raw)) {
if (
/检测到业务数据接口错误|检测到页面业务错误提示|检测到页面报错或系统异常|业务码=|业务接口/.test(
raw,
)
) {
return {
cls: 'product',
confidence: 'high',
......
import { expect, type FrameLocator, type Locator, type Page, type Response } from '@playwright/test';
import { logger } from './logger';
import {
collectPageHealthIssues,
type PageHealthScope,
} from './page-health.util';
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 {
if (STATIC_ASSET_PATTERN.test(url)) return false;
/** 前端静态目录(字体、图标、打包资源等)。 */
const STATIC_PATH_PATTERN = /\/(assets|fonts|static|media|img|images|iconfont)\//i;
/** 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;
return (
/\/api\//i.test(url) ||
/\/spd\//i.test(url) ||
/(export|query|list|report|search|async)/i.test(url)
);
if (/\/api\//i.test(url)) return true;
if (BUSINESS_ACTION_PATTERN.test(url)) return true;
if (/\/report\//i.test(url) && BUSINESS_ACTION_PATTERN.test(url)) return true;
return false;
}
function trimUrl(url: string): string {
......@@ -27,8 +56,12 @@ function trimUrl(url: string): string {
async function readBusinessError(response: Response): Promise<string | null> {
const contentType = response.headers()['content-type'] ?? '';
if (!contentType.includes('json')) return null;
const lengthHeader = response.headers()['content-length'];
if (lengthHeader && Number(lengthHeader) > MAX_BUSINESS_ERROR_BODY) return null;
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;
const record = body as Record<string, unknown>;
const code = record.code ?? record.statusCode ?? record.errCode;
......@@ -49,8 +82,14 @@ async function readBusinessError(response: Response): Promise<string | null> {
return null;
}
/**
* UI 回归运行时守卫:阻断业务数据接口异常与页面业务错误提示;
* 字体/图片等视觉资源失败仅日志观测(与 fixtures 中 abort 策略一致)。
*/
export class RuntimeHealthGuard {
private readonly apiIssues: string[] = [];
private readonly businessApiIssues: string[] = [];
private readonly staticObservations: string[] = [];
private readonly allowedIssuePatterns: RegExp[] = [];
......@@ -64,19 +103,42 @@ export class RuntimeHealthGuard {
this.attachListeners(page);
}
private pushIssue(issue: string): void {
if (this.apiIssues.length >= MAX_ISSUES) return;
if (this.apiIssues.includes(issue)) return;
this.apiIssues.push(issue);
/** 业务动作开始前调用:仅统计本动作窗口内的接口问题。 */
beginBusinessActionWindow(): void {
this.businessApiIssues.length = 0;
}
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 {
target.on('requestfailed', (request) => {
if (!isTrackableApiUrl(request.url())) return;
const url = request.url();
const failure = request.failure()?.errorText ?? 'unknown';
// 切换统计维度/重复查询时,浏览器会 abort 上一次 in-flight 请求,非业务失败
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) => {
......@@ -86,21 +148,32 @@ export class RuntimeHealthGuard {
private async handleResponse(response: Response): Promise<void> {
const url = response.url();
if (!isTrackableApiUrl(url)) return;
const resourceType = response.request().resourceType();
const status = response.status();
if (status >= 500) {
this.pushIssue(`接口 HTTP ${status}: ${trimUrl(url)}`);
if (isPassiveVisualResource(url, resourceType)) {
const status = response.status();
if (status >= 400) {
this.observeStaticResource(`资源 HTTP ${status}: ${trimUrl(url)}`);
}
return;
}
if (!isTrackableBusinessApiUrl(url)) return;
const contentType = response.headers()['content-type'] ?? '';
const isJsonResponse = contentType.includes('json');
const status = response.status();
if (status >= 400) {
this.pushIssue(`接口 HTTP ${status}: ${trimUrl(url)}`);
if (isJsonResponse || status >= 500) {
this.pushBusinessIssue(`业务接口 HTTP ${status}: ${trimUrl(url)}`);
}
return;
}
const businessError = await readBusinessError(response);
if (businessError) {
this.pushIssue(`接口业务报错 ${trimUrl(url)}: ${businessError}`);
this.pushBusinessIssue(`业务接口报错 ${trimUrl(url)}: ${businessError}`);
}
}
......@@ -117,11 +190,20 @@ export class RuntimeHealthGuard {
return issues.filter((issue) => !this.isAllowedIssue(issue));
}
/** @deprecated 使用 getBusinessApiIssues */
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 scope: PageHealthScope = { page: this.page, roots };
const uiIssues = await collectPageHealthIssues(scope);
......@@ -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> {
const issues = this.filterUnexpected(await this.collectAllIssues(contextRoots));
expect(
issues,
`${context}:检测到页面/接口/系统报错,用例判为失败(不允许 skip 掩盖)`,
).toEqual([]);
await this.assertHealthy(context, 'business', contextRoots);
}
}
......@@ -174,16 +281,25 @@ export function getRuntimeHealthGuard(page: Page): RuntimeHealthGuard | undefine
return guardByPage.get(page);
}
/** 页面 + 接口 + 系统统一健康断言(所有用例 skip 前/结束时调用)。 */
/**
* UI:仅 Ant 错误提示与系统异常文案(业务动作前置,验证可交互)。
* business:UI + 本动作窗口内业务接口问题(查询/保存等之后)。
*/
export async function assertRuntimeHealthy(
scope: PageHealthScope,
context: string,
guard?: RuntimeHealthGuard,
mode: RuntimeHealthAssertMode = 'business',
): Promise<void> {
const raw = guard ? await guard.collectAllIssues(scope.roots) : await collectPageHealthIssues(scope);
const issues = guard ? guard.filterUnexpected(raw) : raw;
expect(
issues,
`${context}:检测到页面/接口/系统报错,用例判为失败(不允许 skip 掩盖)`,
).toEqual([]);
if (guard) {
await guard.assertHealthy(context, mode, scope.roots);
return;
}
const uiIssues = await collectPageHealthIssues(scope);
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(
.catch(() => undefined);
}
/** 查询按钮 loading:先给一个短窗口出现,再等到消失。 */
/**
* 等查询按钮上的 loading 消失。
* 当前不在 loading 且 appearTimeout 为 0 时立即返回,不再空等图标出现。
*/
export async function waitSearchButtonIdle(
searchBtn: Locator,
timeout = timeouts.searchLoading,
appearTimeout = 0,
): Promise<void> {
const loadingIcon = searchBtn.locator(SEARCH_LOADING_SELECTOR);
await loadingIcon.waitFor({ state: 'visible', timeout: 2_000 }).catch(() => undefined);
const loadingIcon = searchBtn.locator(SEARCH_LOADING_SELECTOR).first();
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);
}
......
......@@ -67,9 +67,24 @@ export const centerReportEntrySubTableHeaders = [
/** 入库汇总查询 · 测试数据(与 testcase CSV 对齐)。 */
export const centerReportEntryData = {
reportPath: '/spd/form/centerReport/report/centerPages/entry',
entryPath: '/spd/form/centerReport/report/centerPages/entry',
iframeSrc: 'centerPages/entry',
productNameSelector: '#form_item_goodsName',
columns: centerReportEntryColumns,
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,
coreTableHeaders: [
'产品编号',
......
import {
detailSheetSummaryMatch,
type CenterReportDrawerField,
} from './center-report-shared.types';
/** 出入库明细查询 · 列表列名与抽屉字段映射(动态取数用)。 */
export const centerReportSheetColumns = {
productCode: '产品编号',
......@@ -120,11 +125,29 @@ function buildSortColumnCases(): Array<{ id: string; column: string }> {
/** 出入库明细查询冒烟/回归数据(与 testcase CSV 对齐)。 */
export const centerReportSheetData = {
reportPath: '/spd/form/centerReport/report/centerPages/sheet',
sheetPath: '/spd/form/centerReport/report/centerPages/sheet',
iframeSrc: 'centerPages/sheet',
productNameSelector: '#form_item_goodsName',
columns: centerReportSheetColumns,
columnAliases: centerReportSheetColumnAliases,
coreTableHeaders: ['产品编号', '产品名称', '批号', '进院供应商', '院区名称'],
summaryKeywords: detailSheetSummaryMatch.metricLabels,
summaryMatch: detailSheetSummaryMatch,
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() 读取,不写死业务数据 */
sampleRowIndex: 0,
/** 反例 mock(仅负向用例使用) */
......
......@@ -155,6 +155,6 @@ HTML 报告链接只有 **能打开 Jenkins** 的人才能看。外网领导打
## Git 上线检查
- `.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/定时流水线手动触发
- 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 { applyCaseMeta } from '../core/case-meta';
......@@ -12,16 +12,36 @@ import {
getRuntimeHealthGuard,
} from '../core/runtime-health.util';
import { failWhen, guardedSpecSkip } from '../pages/spd/helpers/guarded-skip.helper';
import { resolveAuthStorageStatePath } from '../utils/auth-pool.util';
import { buildPageFixtures, type AppPageFixtures } from './page-registry';
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)。
* 每条用例由 Playwright 新建独立 context + page,再注入登录态;此处不持有跨用例 session。
* 业务 fixture:storageState 来自 auth.setup 写入的文件。
* 配置 TEST_USER_2/TEST_PASS_2 时按 parallelIndex 在双账号间轮换,减轻互踢会话。
* 每条用例仍新建独立 context + page;此处不持有跨用例 session。
*/
export const test = base.extend<AppFixtures>({
storageState: async ({}, use, workerInfo) => {
await use(resolveAuthStorageStatePath(workerInfo.parallelIndex));
},
page: async ({ page }, use) => {
await page.context().route('**/*', abortHeavyAssets);
attachRuntimeHealthGuard(page);
page.context().on('page', (popup) => {
attachRuntimeHealthGuard(popup);
......
......@@ -8,7 +8,7 @@ related_jrn: ""
related_tc: "testpoint/基础管理-用户管理-医共体用户管理-测试点分析-01.md"
related_def: ""
create_time: "2026-10-20"
update_time: "2026-10-20"
update_time: "2026-10-22"
author: ""
---
......@@ -88,9 +88,9 @@ author: ""
1. 切换「所属医院」后列表只显示该院区用户,「共 N 条」同步变化。
2. 两页同一用户的关键字段(姓名、登录账号、角色、所属科室)一致,仅多出「所属医院」维度。
3. 姓名 + 所属医院 + 科室 + 角色 + 状态组合筛选精确命中;高级筛选按状态过滤;重置恢复默认医院。
4. 切换每页条数(如 **20 条/页**)后可见行数随所选值变化且「共 N 条」不变;列设置可打开并恢复列显示(自动化 `USER-UNI-018`~`020`)。
5. 行内「授权」弹出用户角色授权弹框,账号与所选行一致;弹框内「添加/移除」可增删角色行;取消不落库(自动化 `USER-UNI-021`~`023`)。
3. 姓名 + 所属医院 + 科室 + 角色组合筛选精确命中;高级筛选按科室/角色过滤(现网扫描无「状态」字段,启用/禁用仅在编辑弹窗);重置恢复默认医院。自动化 `USER-UNI-013` 按科室过滤。
4. 切换每页条数(如 **20 条/页**)后可见行数随所选值变化且「共 N 条」不变;列设置可打开并恢复列显示(自动化 `USER-UNI-015`~`016`)。
5. 行内「授权」弹出用户角色授权弹框,账号与所选行一致;弹框内「添加/移除」可增删角色行;取消不落库(自动化 `USER-UNI-017`~`019`)。
6. 写操作(编辑/授权/重置密码)须先定位当前行,医共体下避免误改其他院区用户。
## 关联
......
......@@ -31,15 +31,6 @@
"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: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: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",
......@@ -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-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: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: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",
......@@ -183,9 +168,6 @@
"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: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-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",
......
......@@ -91,6 +91,7 @@ export class AdmitMaterialListPage extends AdmitSharedPage {
await this.leaveWithoutCommit();
await this.skipWhenDataInsufficient('详情无导出');
}
await this.assertBeforeBusinessAction('详情导出');
await this.click(exp, '导出');
await this.assertPageHealthy('详情导出');
expect(this.page.url()).not.toMatch(/\/login/);
......
......@@ -187,10 +187,11 @@ export class AdmitMaterialPage extends AdmitSharedPage {
async openLog(): Promise<void> {
await this.query();
const row = await this.skipIfNoAction('日志', '列表无日志入口');
await this.assertBeforeBusinessAction('日志');
await this.click(this.rowAction(row, '日志'), '日志');
const layer = this.page.locator('.ant-modal:visible, .ant-drawer:visible, .put-page:visible').last();
await expect(layer, '日志层应打开').toBeVisible();
await this.assertPageHealthy('日志');
await this.assertUiReady('日志');
await this.leaveWithoutCommit();
}
}
......@@ -32,7 +32,7 @@ export class AdmitSharedPage extends BasePage {
await this.goto(this.meta.path);
await this.waitSpinGone();
await this.waitSettle(400);
await this.assertPageHealthy(`打开 ${this.meta.path}`);
await this.assertUiReady(`打开 ${this.meta.path}`);
expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/);
}
......@@ -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.waitSpinGone();
await this.waitSettle(400);
await this.assertPageHealthy(`菜单进入${this.meta.menu}`);
await this.assertUiReady(`菜单进入${this.meta.menu}`);
}
async expectOnPage(): Promise<void> {
......@@ -71,11 +71,13 @@ export class AdmitSharedPage extends BasePage {
}
async query(): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.clickButton('查询');
await this.waitForTableReady();
}
async reset(): Promise<void> {
await this.assertBeforeBusinessAction('重置');
await this.clickButton('重置');
await this.waitForTableReady();
}
......@@ -136,6 +138,7 @@ export class AdmitSharedPage extends BasePage {
}
async clickTab(name: string): Promise<void> {
await this.assertBeforeBusinessAction(`切换${name}`);
await this.click(this.tab(name), `切换${name}`);
await this.waitForTableReady();
}
......@@ -207,10 +210,10 @@ export class AdmitSharedPage extends BasePage {
return match ? Number(match[1]) : 0;
}
async waitForTableReady(): Promise<void> {
async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise<void> {
await this.waitSpinGone();
await this.waitSettle(200);
await this.assertPageHealthy('列表加载');
await this.assertAfterTableWait('列表加载', options?.health ?? 'business');
}
async cellText(row: Locator, header: string): Promise<string> {
......@@ -422,6 +425,7 @@ export class AdmitSharedPage extends BasePage {
}
async openPutFromRow(row: Locator, action: string): Promise<void> {
await this.assertBeforeBusinessAction(action);
await this.click(this.rowAction(row, action), action);
await expect(this.put(), `${action}应打开详情或编辑层`).toBeVisible({ timeout: 15_000 });
await this.waitSpinGone();
......@@ -512,6 +516,7 @@ export class AdmitSharedPage extends BasePage {
}
async clickToolbarThenCancel(name: string): Promise<void> {
await this.assertBeforeBusinessAction(name);
await this.clickButton(name);
const layer = this.page.locator('.put-page:visible, .ant-modal:visible, .ant-drawer:visible').last();
await expect(layer, `${name}应打开操作层`).toBeVisible({ timeout: 10_000 });
......@@ -520,9 +525,10 @@ export class AdmitSharedPage extends BasePage {
async openDetailSection(name: string): Promise<void> {
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.waitSettle(300);
await this.assertPageHealthy(name);
await this.assertUiReady(name);
await expect(this.put(), '详情层仍在').toBeVisible();
}
......@@ -531,6 +537,7 @@ export class AdmitSharedPage extends BasePage {
}
async saveOnPut(): Promise<void> {
await this.assertBeforeBusinessAction('保存');
const save = this.button('保存', this.put()).first();
if (await save.isVisible().catch(() => false)) {
await this.click(save, '保存');
......
......@@ -24,6 +24,7 @@ export class BasicFinanceMonthPage extends BasicHospitalSharedPage {
}
async search(): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.click(this.locators.searchBtn, '查 询');
await this.waitForTableReady();
}
......@@ -33,6 +34,7 @@ export class BasicFinanceMonthPage extends BasicHospitalSharedPage {
await this.click(this.locators.cancelBtn, '取消行内编辑');
}
if (await this.locators.resetBtn.isVisible().catch(() => false)) {
await this.assertBeforeBusinessAction('重置');
await this.click(this.locators.resetBtn, '重 置');
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