Files
aurora-admin/tools/run-mobile-regression.mjs
T
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

213 lines
8.2 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-mobile-regression.mjs — 移动端 headless 回归 runner
*
* 与 PC 侧 tools/run-regression.mjs 的关系:
* - 同口径(超时重试一次;两次都超时记 timeout,不计入通过率分母;N/A 不进分母)
* - 同断言引擎(tests/_runtime.js),行为动词来自移动端 tests/mobile/_behaviors.js
* - **不同数据源与产物**:组件清单取 site/m/data.mobile.json(不读 PC 的 site/data.json),
* 页面取 tests/mobile/<slug>.html,报告写 tests/mobile-report.json(不覆盖 PC 报告)
*
* 前置:node site/dev-server.js(端口 3311)
* 依赖:playwright(devDependency,CI 专用)
* 退出码:0 全通过 / 1 有失败 / 2 环境问题
*/
import { writeFileSync, existsSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const OUT_DIR = join(ROOT, 'tests');
const BASE = process.env.REG_BASE || 'http://127.0.0.1:3311';
const CONC = Number(process.env.REG_MOBILE_CONC) || 4;
const PAGE_TIMEOUT = 20000;
const ASSERTS_TIMEOUT = 10000;
const RETRY_LIMIT = 2;
const PREFIX = 'tests/mobile';
async function checkServer() {
try {
const r = await fetch(`${BASE}/site/m/data.mobile.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/m/data.mobile.json — ${e.message}`);
console.error(' 请先在仓库根目录运行:node site/dev-server.js');
console.error(' 若文件不存在,先运行:node tools/build-mobile.mjs');
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) {
/* 测试页自身是桌面壳(内部 375×640 设备帧),故用桌面视口打开 */
const p = await browser.newPage({ viewport: { width: 1100, height: 900 } });
try {
await p.goto(`${BASE}/${PREFIX}/${slug}.html`, { waitUntil: 'load', timeout: PAGE_TIMEOUT });
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);
const details = [...document.querySelectorAll('#result-list .t-row')]
.filter((li) => li.querySelector('.t-pill')?.classList.contains('fail'))
.map((li) => ({ id: li.querySelector('.id')?.textContent || '?', err: li.querySelector('.err')?.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,
failureDetails: details,
};
});
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,
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(`[mobile-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[mobile-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);
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', ' '),
library: 'kole-ui-mobile',
kind: 'mobile',
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: skipAssert },
timedOut,
/* 逐页结果(PC 侧报告缺这一块,S5-P17 就是在抱怨「失败落不了盘」):
移动端文档站按组件显示「断言 N 条 · 全通过 / 有失败」直接读这里,不猜。 */
pageResults: results.map((r) => ({
slug: r.slug,
total: r.total,
pass: r.pass,
fail: r.fail,
skip: r.skip,
})),
failedPages: results
.filter((r) => r.fail > 0)
.map((r) => ({ slug: r.slug, fail: r.fail, failures: r.failures, details: r.failureDetails, errorDetail: r.errorDetail })),
};
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(join(OUT_DIR, 'mobile-report.json'), JSON.stringify(report, null, 2) + '\n');
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');
const cases = results
.map(
(r) =>
` <testcase name="tests/mobile/${r.slug}" time="0">` +
(r.fail > 0 ? `<failure message="${esc((r.failures || []).join('; '))}">${r.fail} failed</failure>` : '') +
`</testcase>`
)
.join('\n');
writeFileSync(
join(OUT_DIR, 'mobile-report-junit.xml'),
`<?xml version="1.0" encoding="UTF-8"?>\n<testsuite name="kole-ui mobile 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(', ')})` : '')
);
console.log('written: tests/mobile-report.json, tests/mobile-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(', ')}`);
(p.details || []).slice(0, 4).forEach((d) => console.error(` ${d.id}${d.err ? ' → ' + d.err : ''}`));
});
process.exit(1);
}
if (timedOut.length > 0) {
console.error(`\n[FAIL] ${timedOut.length} 个页面超时未产出结果(重试后仍失败)`);
process.exit(1);
}
console.log('\n[OK] 移动端全部通过');
process.exit(0);