Files
aurora-admin/tools/run-regression.mjs
T
aurora-admin 8934ebb440 feat: P2 分发 + P3 CI(含三个实测发现的缺陷修复)
执行 ROADMAP 的 S1-P2(npm 分发)与 S1-P3(CI 回归)。

P3 · CI 回归流水线
- .github/workflows/regression.yml:push/PR 自动跑回归,失败阻断
  含 playwright 浏览器缓存、零依赖自检、服务就绪轮询、报告上传、
  GitHub Step Summary 输出
- tools/run-regression.mjs 重构:
  · 路径基于脚本位置(不再依赖 cwd,CI 更稳)
  · 环境自检:服务未起退出码 2、playwright 缺失退出码 2
  · 断言超时后重试一次(吸取 _collect.html 的偶发教训)
  · 超时与断言失败分开统计(timedOut 字段)
  · 失败时打印明细并 exit 1,全部通过 exit 0

P2 · npm 分发
- package.json:aurora-admin-design,dependencies 为空(零运行时依赖铁律)
  playwright 放 devDependencies(CI 专用)
- tools/build-dist.mjs:零依赖构建脚本,产出 dist/
  tokens/{tokens.css,tokens.json(DTCG),figma.json}
  components/{<slug>.css ×79, index.css 聚合, <slug>.html ×79}
  manifest.json(含 SHA 校验和) + README.md
- .npmignore / .gitignore 更新(dist/ node_modules/ 不入库)
- npm pack 实测:115.9 KB 压缩 / 566 KB 解压 / 167 文件 / 无 site|tests 泄漏

实测发现的三个缺陷(全部修复)
1. package.json 加 "type": "module" 会让 site/dev-server.js(CJS)崩溃
   —— 移除该字段,靠 .mjs 扩展名区分模块系统
2. dist 里 HTML 引用 ../tokens/colors_and_type.css 但产物是 tokens.css
   —— 路径对但文件不存在,产物是坏的。重写全部引用规则
   (含 index.css 的 @import 从 ./tokens/ 改为 ../tokens/)
3. 断言总数不确定:icon-contrast 是条件性推入(依赖渲染时机),
   导致同一页不同次运行总数不同(960 vs 961)
   —— 改为恒定输出一条(无问题=pass,有问题=skip),
   断言数从 960 稳定为 1038,连跑两次完全一致
4. 顺带修 na 与 skip 重复计算(na = total - denom,但 skip 已是同一批)
   —— 统一为 na = skip,算术自洽:pass+fail+skip = total

验证
- runner:passRate 100% | 79 页全通过 | 1003/1003 | N/A 35 | exit 0
- collector:完全同口径(total 1038 / pass 1003 / fail 0 / skip 35)
- dist:79/79 页面浏览器实测正常(令牌解析、无加载失败)
- 连跑两次数字完全一致(可复现)
2026-09-11 23:13:25 +08:00

224 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* run-regression.mjs — headless 回归 runner(CI / 本地均可)
*
* 前置:仓库根目录起 dev-server(node site/dev-server.js)
* 依赖:npm i -D playwright(CI 专用;运行时零依赖不变)
* 运行:node tools/run-regression.mjs
* 产出:tests/report.json(首页「测试通过率」数据源)+ tests/report-junit.xml
*
* 退出码:
* 0 — 全部通过(fail === 0)
* 1 — 有断言失败或页面异常
* 2 — 环境问题(服务未起 / playwright 缺失)
*
* 设计说明:
* - 断言在测试页的 iframe 演示页加载后自动执行,故需等汇总区出现结果。
* - 并发下 iframe 可能抢不到主线程而超时,因此**超时后重试一次**;
* 两次都超时记为 `timeout`(与断言失败分开,不计入通过率分母)。
* 这与 tests/_collect.html 的口径保持一致。
*/
import { writeFileSync, existsSync, mkdirSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..'); // 仓库根目录(与 cwd 无关,CI 里更稳)
const OUT_DIR = join(ROOT, 'tests');
const BASE = process.env.REG_BASE || 'http://127.0.0.1:3311';
const CONC = Number(process.env.REG_CONC) || 6;
const PAGE_TIMEOUT = 15000;
const ASSERTS_TIMEOUT = 8000;
const RETRY_LIMIT = 2; // 总尝试次数(1 次原始 + 1 次重试)
/* ---------- 环境自检 ---------- */
async function checkServer() {
try {
const r = await fetch(`${BASE}/site/data.json`, { signal: AbortSignal.timeout(5000) });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return await r.json();
} catch (e) {
console.error(`[FATAL] 无法连接 ${BASE}/site/data.json — ${e.message}`);
console.error(' 请先在仓库根目录运行:node site/dev-server.js');
process.exit(2);
}
}
let chromium;
try {
({ chromium } = await import('playwright'));
} catch {
console.error('[FATAL] 未安装 playwright。CI 环境请先运行:npm i -D playwright && npx playwright install --with-deps chromium');
process.exit(2);
}
/* ---------- 单页执行(含重试) ---------- */
async function runOne(browser, slug, attempt = 1) {
const p = await browser.newPage({ viewport: { width: 1200, height: 800 } });
try {
await p.goto(`${BASE}/tests/${slug}.html`, { waitUntil: 'load', timeout: PAGE_TIMEOUT });
/* 断言在 iframe 演示页就绪后自动跑;等汇总区不再是初始值 */
await p
.waitForFunction(() => {
const el = document.getElementById('sum-total');
return el && el.textContent !== '总数 0';
}, { timeout: ASSERTS_TIMEOUT })
.catch(() => {});
/* 兜底:若自动跑未触发,手动点一次 */
const btn = p.locator('#run-asserts');
if (await btn.count()) {
await btn.click().catch(() => {});
await p.waitForTimeout(200);
}
const r = await p.evaluate(() => {
const num = (s) => parseInt(String(s || '0').replace(/\D/g, ''), 10) || 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,
};
});
// total === 0 说明断言未执行(页面或帧有问题)—— 判为需要重试
if (r.total === 0 && attempt < RETRY_LIMIT) {
await p.close();
await new Promise((res) => setTimeout(res, 300));
return runOne(browser, slug, attempt + 1);
}
await p.close();
return { slug, ...r };
} catch (e) {
await p.close().catch(() => {});
if (attempt < RETRY_LIMIT) {
await new Promise((res) => setTimeout(res, 300));
return runOne(browser, slug, attempt + 1);
}
// 两次都失败:区分「超时」与「页面异常」
const isTimeout = /Timeout|timeout/.test(String(e.message || e));
return {
slug,
total: 0,
pass: 0,
fail: isTimeout ? 0 : 1, // 超时不计入 fail(见文件头说明)
skip: 0,
failures: [isTimeout ? 'timeout' : 'page-error'],
errorDetail: String(e.message || e).slice(0, 200),
};
}
}
/* ---------- 主流程 ---------- */
const data = await checkServer();
const slugs = data.components.map((c) => c.slug);
console.log(`[regression] ${slugs.length} 个组件 / 并发 ${CONC} / 目标 ${BASE}`);
const browser = await chromium.launch();
const results = [];
for (let i = 0; i < slugs.length; i += CONC) {
const batch = slugs.slice(i, i + CONC);
const batchResults = await Promise.all(batch.map((s) => runOne(browser, s)));
results.push(...batchResults);
process.stdout.write(`\r[regression] ${Math.min(i + CONC, slugs.length)}/${slugs.length}`);
}
console.log('');
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 timedOut = results.filter((r) => (r.failures || []).includes('timeout')).map((r) => r.slug);
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,
/* na 与 skip 是同一批(静态组件无交互面等),不重复计算 */
assertions: { total: totalAssert, pass: passAssert, fail: failAssert, skip: skipAssert, na: skipAssert },
timedOut,
failedPages: results
.filter((r) => r.fail > 0)
.map((r) => ({ slug: r.slug, fail: r.fail, failures: r.failures, errorDetail: r.errorDetail })),
};
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(join(OUT_DIR, 'report.json'), JSON.stringify(report, null, 2) + '\n');
/* ---------- JUnit XML(供 CI 展示) ---------- */
const esc = (s) =>
String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
const cases = results
.map(
(r) =>
` <testcase name="tests/${r.slug}" time="0">` +
(r.fail > 0
? `<failure message="${esc((r.failures || []).join('; '))}">${r.fail} failed</failure>`
: '') +
`</testcase>`
)
.join('\n');
writeFileSync(
join(OUT_DIR, 'report-junit.xml'),
`<?xml version="1.0" encoding="UTF-8"?>\n<testsuite name="aurora-admin tests" tests="${results.length}" failures="${results.filter((r) => r.fail > 0).length}" time="0">\n${cases}\n</testsuite>\n`
);
/* ---------- 输出与退出码 ---------- */
console.log(
`passRate ${report.passRate}% | pages ${results.length} (all-pass ${pagesAllPass}) | assertions ${passAssert}/${denom}` +
(skipAssert ? ` | N/A ${totalAssert - denom}` : '') +
(timedOut.length ? ` | timeout ${timedOut.length} (${timedOut.slice(0, 5).join(', ')})` : '')
);
if (timedOut.length) {
console.warn(`[warn] ${timedOut.length} 个页面两次尝试均超时(采集环境问题,非断言失败):${timedOut.join(', ')}`);
console.warn(' 可在安静环境重跑一次确认;若持续超时需排查该页性能。');
}
console.log('written: tests/report.json, tests/report-junit.xml');
if (failAssert > 0) {
console.error(`\n[FAIL] ${failAssert} 条断言失败:`);
report.failedPages.slice(0, 10).forEach((p) => {
console.error(` ${p.slug}: ${(p.failures || []).join(', ')}`);
if (p.errorDetail) console.error(` ${p.errorDetail}`);
});
process.exit(1);
}
if (timedOut.length > 0) {
console.error(`\n[FAIL] ${timedOut.length} 个页面超时未产出结果(重试后仍失败)`);
process.exit(1);
}
console.log('\n[OK] 全部通过');
process.exit(0);