Files
aurora-admin f1fbfc2ddb
Regression / regression (push) Canceled after 0s
feat(品牌标识): 几何 K 图标(favicon/顶栏标记/theme-color) + 并行会话成果入库
## 品牌标识(本次会话)

起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」,
那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」,
移动端站已是「K」;移动端文档站则完全没有 favicon)。

- 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边;
  描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」
- 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark);
  顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28))
- 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg)
- 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留)
- 移动端 7 个模板补 favicon(此前计数 0)

验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/
theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致;
两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。

## 并行会话成果(本次一并入库)

- 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具
  + IconPreview 预览页 + ICON-SPEC.md 冻结规格
- 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁
- PC 组件:103 个大后台组件 / 组件11 批次
- uni-app:PC 端试点 + 移动端端实现 + 真实编译验证

## 工程

- .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库)
- CHANGELOG 补品牌标识条目
- ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
2026-09-21 10:05:48 +08:00

295 lines
13 KiB
JavaScript
Raw Permalink 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 的口径保持一致。
* - **采集失败**(页面/帧根本没跑出结果,`no-result` / `timeout` / `page-error`)与
* **断言失败**分开计数:前者不进 passRate 分母,单列在 `collectionFailures` 里。
* 判据修改的理由与反例见下方 `isCollectionFailure` 的注释(S6-P30)。
* - 非满分时**保留上一次报告**为 `tests/report-prev.json`(S5-P17),
* 使「1016/1017」这类偶发失败事后可回放(此前每次运行都覆盖,无法定位)。
*/
import { writeFileSync, readFileSync, 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 rows = [...document.querySelectorAll('#result-list .t-row')]
.filter((li) => li.querySelector('.t-pill')?.classList.contains('fail'));
const failures = rows.map((li) => li.querySelector('.id')?.textContent || '?').slice(0, 8);
/* 失败详情(节点名 + 实测比值)由 tests/_runtime.js 放在 window.__koleResult.failureDetails。
只存断言 ID 的话,偶发失败(如对比度采到过渡中间帧)事后无法回溯 —— 2026-09-20 那次
0.56% 的偶发就是靠手工压测才定位的,因此这里把 err 一并落盘。 */
const detail = (window.__koleResult && window.__koleResult.failureDetails) || null;
const errText = detail
? detail.map((d) => d.id + (d.err ? ' → ' + d.err : '')).join(' ; ')
: '';
return {
total: num(t('sum-total')),
pass: num(t('sum-pass')),
fail: num(t('sum-fail')),
skip: num(t('sum-skip')),
failures,
errorDetail: errText || undefined,
};
});
// 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);
}
if (r.total === 0) {
await p.close();
return { slug, total: 0, pass: 0, fail: 1, skip: 0, failures: ['no-result'] };
}
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),
};
}
}
/* 采集失败 = 页面/帧根本没跑出结果,而不是断言判负。
*
* 判据修改(S6-P30,附理由 + 反例):
* 理由:`no-result` / `page-error` 表示「这一次没采集到」,与「组件真的坏了」是两类事实。
* 把前者计入 passRate 分母,会让**采集环境问题**表现为「回归失败」——
* 实测:跨批次整轮连跑 62 次中有 2 次非 100%,且报告未落盘无法回放判断类别。
* 反例(本判据必须能拦住真缺陷):`no-result` **只在两次尝试都拿不到结果**时才产生,
* 单次抖动已被重试吸收;所以「组件坏了导致页面跑不出结果」这一情形——
* 若两次都坏——仍会以 collectionFailures 单独列出并让退出码为 1(见文件尾),
* 即**不会被静默吞掉**。只有连续两次都发生的采集失败才会走到这里,
* 它已不是"偶发",因此额外要求「同一轮里 collectionFailures 非空即退出码非 0」。
* 口径对齐:`tests/_collect.html` 的 timedOut 早已如此单列,runner 此前只在 timeout 上对齐。 */
function isCollectionFailure(r) {
return (r.failures || []).some((f) => f === 'no-result' || f === 'timeout' || f === 'page-error');
}
/* ---------- 主流程 ---------- */
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(静态组件无交互面等);② 采集失败(根本没跑出结果,见 isCollectionFailure) */
const collectionFailures = results.filter(isCollectionFailure);
const collectionSlugs = new Set(collectionFailures.map((r) => r.slug));
const assertResults = results.filter((r) => !collectionSlugs.has(r.slug));
const denom =
assertResults.reduce((s, r) => s + r.pass, 0) + assertResults.reduce((s, r) => s + r.fail, 0);
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,
/* 采集失败单列(与断言失败分开;不计入 passRate 分母,但会让退出码非 0,见文件尾) */
collectionFailures: collectionFailures.map((r) => ({
slug: r.slug,
kind: (r.failures || [])[0],
errorDetail: r.errorDetail,
})),
failedPages: results
/* 只列真断言失败:采集失败(no-result / timeout / page-error)另立 collectionFailures,
否则「button: no-result」会被读成「button 的断言坏了」——两类事实不应混在一张表里 */
.filter((r) => r.fail > 0 && !isCollectionFailure(r))
.map((r) => ({ slug: r.slug, fail: r.fail, failures: r.failures, errorDetail: r.errorDetail })),
};
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
/* S5-P17:非满分时先把**上一次**报告留档为 report-prev.json,再覆盖本次结果。
动机(实测):2026-09-19 连跑 27 次中出现 2 次 1016/1017,但因每次运行都覆盖 report.json,
失败那次的现场无法回放 —— 只能证明"发生过",无法回答"哪条断言、期望/实际分别是什么"。
现在任何一次非满分(含采集失败)都会保留前一份,事后可直接 diff 两份报告定位。 */
const REPORT_PATH = join(OUT_DIR, 'report.json');
const isPerfect = failAssert === 0 && collectionFailures.length === 0;
if (!isPerfect && existsSync(REPORT_PATH)) {
try {
writeFileSync(join(OUT_DIR, 'report-prev.json'), readFileSync(REPORT_PATH, 'utf8'));
console.log('kept: tests/report-prev.json(本次非满分,已保留上一次报告供回放)');
} catch (e) {
console.warn(`[warn] 未能保留上一次报告:${e.message}`);
}
}
writeFileSync(REPORT_PATH, 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.errorDetail ? ' — ' + r.errorDetail : '')).slice(0, 500)
)}">${r.fail} failed</failure>`
: '') +
`</testcase>`
)
.join('\n');
writeFileSync(
join(OUT_DIR, 'report-junit.xml'),
`<?xml version="1.0" encoding="UTF-8"?>\n<testsuite name="kole-ui 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}` : '') +
(collectionFailures.length
? ` | 采集失败 ${collectionFailures.length} (${collectionFailures
.slice(0, 5)
.map((r) => r.slug + ':' + (r.failures || [])[0])
.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);
}
/* 采集失败同样让退出码为 1(不静默)——它已通过重试,两次都失败就不是抖动。
与 failAssert 分开报的原因见 isCollectionFailure 的注释(S6-P30)。 */
if (collectionFailures.length > 0) {
console.error(`\n[FAIL] ${collectionFailures.length} 个页面两次尝试后仍未产出结果(采集失败,非断言失败):`);
collectionFailures.slice(0, 10).forEach((r) => {
console.error(` ${r.slug}: ${(r.failures || []).join(', ')}${r.errorDetail ? ' — ' + r.errorDetail : ''}`);
});
console.error(' 这一类不计入 passRate 分母,但退出码仍为 1;请重跑或排查该页。');
process.exit(1);
}
console.log('\n[OK] 全部通过');
process.exit(0);