/** * 全量按钮测试: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; 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 { 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 { 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}`); } }); } });