#!/usr/bin/env node /** * run-regression.mjs — headless 回归 runner(CI / 本地均可) * * 前置:仓库根目录起 dev-server(node site/dev-server.js) * 依赖:npm i playwright(或全局已装) * 运行:node tools/run-regression.mjs * 产出:tests/report.json(首页「测试通过率」数据源)+ tests/report-junit.xml */ import { writeFileSync } from 'fs'; const BASE = process.env.REG_BASE || 'http://127.0.0.1:3311'; const CONC = 6; const { chromium } = await import('playwright'); const browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }); const data = await (await fetch(`${BASE}/site/data.json`)).json(); const slugs = data.components.map(c => c.slug); const results = []; async function runOne(browser, slug) { const p = await browser.newPage({ viewport: { width: 1200, height: 800 } }); try { await p.goto(`${BASE}/tests/${slug}.html`, { waitUntil: 'load', timeout: 15000 }); /* 断言在 iframe 演示页加载后自动执行;等汇总区出现结果 */ await p.waitForFunction(() => document.getElementById('sum-total')?.textContent !== '总数 0', { timeout: 8000 }) .catch(() => {}); const btn = p.locator('#run-asserts'); if (await btn.count()) { await btn.click(); await p.waitForTimeout(150); } const r = await p.evaluate(() => { const num = s => parseInt(String(s || '0').replace(/\D/g, '')) || 0; const t = id => { const n = document.getElementById(id); return n ? n.textContent : '0'; }; const failures = [...document.querySelectorAll('#result-list .t-row')] .filter(li => li.querySelector('.t-pill')?.classList.contains('fail')) .map(li => li.querySelector('.id')?.textContent || '?') .slice(0, 8); return { total: num(t('sum-total')), pass: num(t('sum-pass')), fail: num(t('sum-fail')), skip: num(t('sum-skip')), failures }; }); results.push({ slug, ...r }); } catch (e) { results.push({ slug, total: 0, pass: 0, fail: 1, skip: 0, failures: ['page-error'] }); } finally { await p.close(); } } for (let i = 0; i < slugs.length; i += CONC) { await Promise.all(slugs.slice(i, i + CONC).map(s => runOne(browser, s))); } await browser.close(); const totalAssert = results.reduce((s, r) => s + r.total, 0); const passAssert = results.reduce((s, r) => s + r.pass, 0); const failAssert = results.reduce((s, r) => s + r.fail, 0); const skipAssert = results.reduce((s, r) => s + r.skip, 0); /* 通过率分母排除 N/A(静态组件无交互面等),与 tests/_collect.html 同口径 */ const denom = passAssert + failAssert; const pagesAllPass = results.filter(r => r.fail === 0 && r.total > 0).length; const report = { generated: new Date().toISOString().slice(0, 19).replace('T', ' '), version: data.meta.version, pages: results.length, pagesAllPass, passRate: denom ? Math.round(passAssert / denom * 1000) / 10 : 0, assertions: { total: totalAssert, pass: passAssert, fail: failAssert, skip: skipAssert, na: totalAssert - denom }, failedPages: results.filter(r => r.fail > 0).map(r => ({ slug: r.slug, fail: r.fail, failures: r.failures })) }; writeFileSync('tests/report.json', JSON.stringify(report, null, 2) + '\n'); /* JUnit XML(供 CI 展示) */ const esc = s => String(s).replace(/&/g, '&').replace(/ ` ` + (r.fail > 0 ? `${r.fail} failed` : '') + ``).join('\n'); writeFileSync('tests/report-junit.xml', `\n\n${cases}\n\n`); console.log(`passRate ${report.passRate}% | pages ${results.length} (all-pass ${pagesAllPass}) | assertions ${passAssert}/${totalAssert}`); console.log('written: tests/report.json, tests/report-junit.xml');