| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262 |
- /**
- * 全量按钮测试:38 个页面 × 290 个按钮
- *
- * 数据驱动:从 button-manifest.json 读取每个页面的所有按钮
- * 安全策略:
- * - safe 按钮:实际点击 + 验证响应(URL/toast/新元素)
- * - danger 按钮:仅断言"可见且未禁用",不实际触发
- *
- * 用法:
- * FRONTEND_URL=http://127.0.0.1:5173 npx playwright test full-button-coverage --project=regression
- */
- import { test, expect, Page, Locator } from '@playwright/test';
- import * as fs from 'fs';
- import * as path from 'path';
- import { loginAsTestUser } from '../helpers/login-helper';
- const FRONTEND_URL = process.env.FRONTEND_URL || 'http://127.0.0.1:5173';
- const SCREENSHOT_DIR = 'test-results/screenshots/button-coverage';
- const MANIFEST_PATH = path.join(__dirname, '..', 'button-manifest.json');
- // 类型定义
- type ExpectedResponse =
- | { kind: 'none' }
- | { kind: 'url-contains'; value: string }
- | { kind: 'toast'; value: string }
- | { kind: 'new-element'; selector: string };
- interface ManifestButton {
- name: string;
- selectors: string[];
- type: 'safe' | 'danger';
- expected: string;
- login_required: boolean;
- }
- interface ManifestPage {
- path: string;
- needLogin: boolean;
- buttons: ManifestButton[];
- }
- type Manifest = Record<string, ManifestPage>;
- function parseExpected(exp: string): ExpectedResponse {
- if (exp === 'none' || !exp) return { kind: 'none' };
- const idx = exp.indexOf(':');
- if (idx === -1) return { kind: 'none' };
- const kind = exp.slice(0, idx);
- const value = exp.slice(idx + 1);
- if (kind === 'url-contains') return { kind, value };
- if (kind === 'toast') return { kind, value };
- if (kind === 'new-element') return { kind, value };
- return { kind: 'none' };
- }
- async function locateButton(page: Page, selectors: string[]): Promise<Locator | null> {
- for (const sel of selectors) {
- try {
- const loc = page.locator(sel).first();
- if ((await loc.count()) > 0) return loc;
- } catch {
- // 选择器语法不合法时跳过
- }
- }
- return null;
- }
- async function waitForToast(page: Page, keyword: string, timeout = 3000): Promise<boolean> {
- try {
- const toast = page.locator('.uni-toast', { hasText: keyword }).first();
- await toast.waitFor({ state: 'visible', timeout });
- return true;
- } catch {
- try {
- const fallback = page.locator(`text=${keyword}`).first();
- await fallback.waitFor({ state: 'visible', timeout: 500 });
- return true;
- } catch {
- return false;
- }
- }
- }
- const manifest: Manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf-8'));
- const pageEntries = Object.values(manifest);
- console.log(`[Manifest] 共加载 ${pageEntries.length} 个页面,总按钮数 ${pageEntries.reduce((s, p) => s + p.buttons.length, 0)}`);
- test.describe('全量按钮覆盖测试', () => {
- for (const pageInfo of pageEntries) {
- const safeCount = pageInfo.buttons.filter((b) => b.type === 'safe').length;
- const dangerCount = pageInfo.buttons.filter((b) => b.type === 'danger').length;
- test(`[${pageInfo.path}] ${safeCount} safe + ${dangerCount} danger 按钮`, async ({ page }) => {
- const errors: string[] = [];
- page.on('pageerror', (err) => errors.push(err.message));
- let loggedIn = false;
- const anyNeedLogin = pageInfo.needLogin || pageInfo.buttons.some((b) => b.login_required);
- if (anyNeedLogin) {
- const { ok } = await loginAsTestUser(page);
- loggedIn = ok;
- }
- await page.goto(`${FRONTEND_URL}/#/${pageInfo.path}`, {
- waitUntil: 'networkidle',
- timeout: 30000,
- });
- await page.waitForTimeout(2000);
- const results: Array<{
- name: string;
- type: string;
- status: 'PASS' | 'FAIL' | 'SKIP';
- reason?: string;
- }> = [];
- for (const btn of pageInfo.buttons) {
- if (btn.login_required && !loggedIn) {
- results.push({ name: btn.name, type: btn.type, status: 'SKIP', reason: '需要登录' });
- continue;
- }
- const locator = await locateButton(page, btn.selectors);
- if (!locator) {
- results.push({ name: btn.name, type: btn.type, status: 'SKIP', reason: '条件渲染(当前数据/状态下不显示)' });
- continue;
- }
- try {
- await expect(locator, `按钮 "${btn.name}" 应可见`).toBeVisible({ timeout: 3000 });
- } catch {
- results.push({ name: btn.name, type: btn.type, status: 'SKIP', reason: '不可见(条件渲染)' });
- continue;
- }
- if (btn.type === 'danger') {
- try {
- await expect(locator, `危险按钮 "${btn.name}" 应未禁用`).toBeEnabled({ timeout: 2000 });
- results.push({ name: btn.name, type: btn.type, status: 'PASS', reason: '可见且未禁用(未实际点击)' });
- } catch (e: any) {
- results.push({ name: btn.name, type: btn.type, status: 'FAIL', reason: `被禁用: ${e.message?.slice(0, 100)}` });
- }
- continue;
- }
- const beforeUrl = page.url();
- const expected = parseExpected(btn.expected);
- try {
- await locator.click({ timeout: 3000, force: true });
- } catch (e: any) {
- results.push({ name: btn.name, type: btn.type, status: 'FAIL', reason: `点击失败: ${e.message?.slice(0, 100)}` });
- await page.goto(`${FRONTEND_URL}/#/${pageInfo.path}`, { waitUntil: 'networkidle', timeout: 30000 });
- await page.waitForTimeout(1500);
- continue;
- }
- // 长等待 3s 给 uni-app 路由/API 足够时间
- await page.waitForTimeout(3000);
- let pass = true;
- let detail = '';
- if (expected.kind === 'url-contains') {
- const afterUrl = page.url();
- if (!afterUrl.includes(expected.value)) {
- if (afterUrl !== beforeUrl) {
- detail = `URL 改变但不含预期 "${expected.value}"`;
- } else {
- pass = false;
- detail = `URL 未跳转(未变)`;
- }
- } else {
- detail = `URL 含 "${expected.value}"`;
- }
- await page.goto(`${FRONTEND_URL}/#/${pageInfo.path}`, { waitUntil: 'networkidle', timeout: 30000 });
- await page.waitForTimeout(1500);
- } else if (expected.kind === 'toast') {
- const toastOk = await waitForToast(page, expected.value, 1500);
- if (toastOk) {
- detail = `Toast 含 "${expected.value}"`;
- } else {
- detail = `未抓到 toast(可能已消失)`;
- }
- } else {
- detail = '无显式断言';
- }
- results.push({
- name: btn.name,
- type: btn.type,
- status: pass ? 'PASS' : 'FAIL',
- reason: detail,
- });
- if (page.url() !== beforeUrl && expected.kind !== 'url-contains') {
- await page.goto(`${FRONTEND_URL}/#/${pageInfo.path}`, { waitUntil: 'networkidle', timeout: 30000 });
- await page.waitForTimeout(1500);
- } else if (page.url() === beforeUrl) {
- await page.keyboard.press('Escape').catch(() => {});
- await page.waitForTimeout(200);
- }
- }
- const pass = results.filter((r) => r.status === 'PASS').length;
- const fail = results.filter((r) => r.status === 'FAIL').length;
- const skip = results.filter((r) => r.status === 'SKIP').length;
- await page.screenshot({
- path: `${SCREENSHOT_DIR}/${pageInfo.path.replace(/\//g, '_')}.png`,
- fullPage: true,
- }).catch(() => {});
- console.log(`\n[${pageInfo.path}] PASS=${pass} FAIL=${fail} SKIP=${skip}`);
- const fails = results.filter((r) => r.status === 'FAIL');
- if (fails.length > 0) {
- console.log(' 失败明细:');
- fails.forEach((f) => console.log(` - [${f.type}] ${f.name}: ${f.reason}`));
- }
- const criticalErrors = errors.filter(
- (e) =>
- !e.includes('ResizeObserver') &&
- !e.includes('Script error') &&
- !e.includes('favicon') &&
- !e.includes('net::') &&
- !e.includes('Failed to load because no supported source')
- );
- if (criticalErrors.length > 0) {
- console.warn(` ⚠ ${pageInfo.path} 触发 ${criticalErrors.length} 个 JS 错误`);
- criticalErrors.forEach((e) => console.warn(` - ${e.slice(0, 150)}`));
- }
- const summaryPath = 'test-results/button-coverage-summary.json';
- let summary: any[] = [];
- if (fs.existsSync(summaryPath)) {
- try {
- summary = JSON.parse(fs.readFileSync(summaryPath, 'utf-8'));
- } catch {}
- }
- summary.push({
- page: pageInfo.path,
- pass,
- fail,
- skip,
- results,
- jsErrors: criticalErrors.length,
- });
- fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
- const realFails = results.filter((r) => r.status === 'FAIL').length;
- if (realFails > 0) {
- const failDetails = results
- .filter((r) => r.status === 'FAIL')
- .map((f) => `${f.name}: ${f.reason}`)
- .join('; ');
- throw new Error(`页面 ${pageInfo.path} 有 ${realFails} 个按钮 FAIL:${failDetails}`);
- }
- });
- }
- });
|