#!/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');