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 页面浏览器实测正常(令牌解析、无加载失败)
- 连跑两次数字完全一致(可复现)
This commit is contained in:
aurora-admin
2026-09-11 23:13:25 +08:00
parent f0e0d7ad1a
commit 8934ebb440
14 changed files with 796 additions and 62 deletions
+102
View File
@@ -0,0 +1,102 @@
name: Regression
# 每次 push / PR 自动跑回归。
# 目标:让质量不再依赖「人工记得跑」—— 这是 ROADMAP S1-P3 的核心价值。
#
# 说明:
# - playwright 是 devDependency(CI 专用工具),运行时依然零依赖。
# - dev-server 在 Ubuntu 上可正常跑(Node 脚本,跨平台)。
# - 失败时仍上传报告,便于排查。
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
regression:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('package.json') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install dependencies
run: npm ci || npm install
- name: Install playwright chromium
run: npx playwright install --with-deps chromium
- name: Verify zero runtime dependencies
run: |
node -e "
const p = require('./package.json');
const deps = Object.keys(p.dependencies || {});
if (deps.length) {
console.error('FAIL: 运行时依赖必须为空,实际: ' + deps.join(', '));
process.exit(1);
}
console.log('zero-dep OK');
"
- name: Start dev server
run: |
node site/dev-server.js &
# 等服务就绪(最多 15 秒)
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:3311/site/data.json > /dev/null; then
echo "server ready after ${i} attempt(s)"
exit 0
fi
sleep 0.5
done
echo "FAIL: dev-server 未在 15 秒内就绪"
exit 1
- name: Run regression
run: node tools/run-regression.mjs
- name: Upload report
uses: actions/upload-artifact@v4
if: always()
with:
name: regression-report
path: |
tests/report.json
tests/report-junit.xml
retention-days: 30
- name: Publish test summary
if: always()
run: |
if [ -f tests/report.json ]; then
node -e "
const r = require('./tests/report.json');
const a = r.assertions;
console.log('## 回归结果');
console.log('');
console.log('| 项 | 值 |');
console.log('|---|---|');
console.log('| 通过率 | ' + r.passRate + '% |');
console.log('| 断言 | ' + a.pass + '/' + (a.pass + a.fail) + ' |');
console.log('| N/A | ' + (a.na || 0) + ' |');
console.log('| 页面全通过 | ' + r.pagesAllPass + '/' + r.pages + ' |');
console.log('| 超时 | ' + ((r.timedOut || []).length) + ' |');
" >> \$GITHUB_STEP_SUMMARY
fi
+11
View File
@@ -9,3 +9,14 @@
# OS
Thumbs.db
.DS_Store
# 构建产物:由 tools/build-dist.mjs 生成,不入库(发布时用 npm publish / CI 上传)
dist/
# 依赖(仅 CI 用的 devDependencies;运行时零依赖不变)
node_modules/
# npm
npm-debug.log*
.npm/
*.tgz
+52
View File
@@ -0,0 +1,52 @@
# npm 发布排除清单
# 目的:npm 包里只放「能直接用」的产物,不带开发资产。
# 注:package.json 的 "files" 字段已做白名单(dist/ README LICENSE),
# 本文件是双保险 —— 某些工具会绕过 files 字段。
# 开发资产
site/
tests/
tools/
frameworks/
.design_library/
.github/
.playwright-mcp/
.tmp/
# 构建脚本(发布包不需要重新构建)
build-site.ps1
run-tests.ps1
run_tests.py
run-tests.ps1
# 文档与配置(保留 README/LICENSE,其余排除)
CONTRIBUTING.md
TESTING.md
PLAN.md
ROADMAP.md
AGENTS.md
DOCS_GAP_ANALYSIS.md
现状问题分析与改造清单.md
竞品网站检查报告.md
组件*.txt
计划-*.md
sitemap.xml
index.html
# 部署相关
Dockerfile
docker-compose.yml
nginx.conf
start-dsh.*
dsh_launcher.*
# 截图与临时产物
*.png
*.log
.tmp/
*.tgz
# 容器化 / CI
.github/
.gitignore
.npmignore
+48
View File
@@ -0,0 +1,48 @@
{
"name": "aurora-admin-design",
"version": "1.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aurora-admin-design",
"version": "1.4.1",
"license": "MIT",
"devDependencies": {
"playwright": "^1.48.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"name": "aurora-admin-design",
"version": "1.4.1",
"description": "B 端中后台设计系统 · 79 组件 × 5 端实现 · 零运行时依赖",
"license": "MIT",
"main": "dist/tokens/tokens.css",
"style": "dist/components/index.css",
"files": [
"dist/",
"README.md",
"LICENSE"
],
"scripts": {
"build": "node tools/build-dist.mjs",
"regression": "node tools/run-regression.mjs",
"serve": "node site/dev-server.js"
},
"keywords": [
"design-system",
"design-tokens",
"admin",
"dashboard",
"css-variables",
"component-library",
"b2b"
],
"sideEffects": [
"*.css"
],
"engines": {
"node": ">=18"
},
"devDependencies": {
"playwright": "^1.48.0"
},
"dependencies": {},
"repository": {
"type": "git",
"url": "git+https://github.com/YOUR-ACCOUNT/aurora-admin.git"
},
"bugs": {
"url": "https://github.com/YOUR-ACCOUNT/aurora-admin/issues"
},
"homepage": "https://YOUR-ACCOUNT.github.io/aurora-admin/site/"
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -153,7 +153,8 @@ pre#out { background: var(--au-color-card-bg); border: 1px solid var(--au-color-
pages: results.length,
pagesAllPass: results.filter(function (r) { return r.fail === 0 && r.total > 0; }).length,
passRate: denom ? Math.round(passAssert / denom * 1000) / 10 : 0,
assertions: { total: totalAssert, pass: passAssert, fail: failAssert, skip: skipAssert, na: totalAssert - denom },
/* na 与 skip 是同一批,不重复计算 */
assertions: { total: totalAssert, pass: passAssert, fail: failAssert, skip: skipAssert, na: skipAssert },
failureKinds: kinds,
/* 两次重试后仍无响应的页面:属采集环境问题(并发抢占),与断言失败分开统计 */
timedOut: results.filter(function (r) { return (r.failures || []).indexOf('timeout') >= 0; }).map(function (r) { return r.slug; }),
+7 -1
View File
@@ -445,7 +445,13 @@
}
});
add('contrast>=4.5', contrastFailures.length === 0, contrastFailures.length ? '低于 WCAG AA:' + contrastFailures.join(';') : null);
if (iconWarnings.length) list.push({ id: 'matrix:icon-contrast', status: 'skip', ms: 0, err: '图标低于 3:1(非阻塞):' + iconWarnings.join(';') });
/* 图标对比度:恒定输出一条,避免断言总数随渲染时机浮动
(此前仅在检出图标对比度不足时才推入 skip,导致同一页不同次运行的总数不同,
对 CI 是不可接受的 —— 报告数字必须可复现)。
status 语义:无问题 = pass;有问题 = skip(WCAG 1.4.11 非阻塞)。 */
list.push(iconWarnings.length
? { id: 'matrix:icon-contrast', status: 'skip', ms: 0, err: '图标低于 3:1(非阻塞):' + iconWarnings.join(';') }
: { id: 'matrix:icon-contrast', status: 'pass', ms: 0, err: null });
/* token 一致性:用例区内联 hex 颜色。
排除「数据驱动的颜色」——色板选项、轮播卡片背景、图表数据系列等,
+82
View File
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuite name="aurora-admin tests" tests="79" failures="0" time="0">
<testcase name="tests/button" time="0"></testcase>
<testcase name="tests/input" time="0"></testcase>
<testcase name="tests/select" time="0"></testcase>
<testcase name="tests/table" time="0"></testcase>
<testcase name="tests/card" time="0"></testcase>
<testcase name="tests/modal" time="0"></testcase>
<testcase name="tests/tag" time="0"></testcase>
<testcase name="tests/tabs" time="0"></testcase>
<testcase name="tests/steps" time="0"></testcase>
<testcase name="tests/breadcrumb" time="0"></testcase>
<testcase name="tests/tree" time="0"></testcase>
<testcase name="tests/dropdown" time="0"></testcase>
<testcase name="tests/popconfirm" time="0"></testcase>
<testcase name="tests/segmented" time="0"></testcase>
<testcase name="tests/transfer" time="0"></testcase>
<testcase name="tests/rate" time="0"></testcase>
<testcase name="tests/slider" time="0"></testcase>
<testcase name="tests/mention" time="0"></testcase>
<testcase name="tests/autocomplete" time="0"></testcase>
<testcase name="tests/collapse" time="0"></testcase>
<testcase name="tests/calendar" time="0"></testcase>
<testcase name="tests/carousel" time="0"></testcase>
<testcase name="tests/imagepreview" time="0"></testcase>
<testcase name="tests/qrcode" time="0"></testcase>
<testcase name="tests/countdown" time="0"></testcase>
<testcase name="tests/watermark" time="0"></testcase>
<testcase name="tests/cardlist" time="0"></testcase>
<testcase name="tests/shortcutpanel" time="0"></testcase>
<testcase name="tests/bankcardinput" time="0"></testcase>
<testcase name="tests/idcardinput" time="0"></testcase>
<testcase name="tests/plateinput" time="0"></testcase>
<testcase name="tests/bottomsheet" time="0"></testcase>
<testcase name="tests/treetable" time="0"></testcase>
<testcase name="tests/expandabletable" time="0"></testcase>
<testcase name="tests/mergedcelltable" time="0"></testcase>
<testcase name="tests/summaryrowtable" time="0"></testcase>
<testcase name="tests/fixedcolumntable" time="0"></testcase>
<testcase name="tests/timelinelist" time="0"></testcase>
<testcase name="tests/steplist" time="0"></testcase>
<testcase name="tests/chartpanel" time="0"></testcase>
<testcase name="tests/dashboardcard" time="0"></testcase>
<testcase name="tests/enhancedtabnav" time="0"></testcase>
<testcase name="tests/sidemenu" time="0"></testcase>
<testcase name="tests/topmenu" time="0"></testcase>
<testcase name="tests/mixednavigation" time="0"></testcase>
<testcase name="tests/quicknav" time="0"></testcase>
<testcase name="tests/anchornav" time="0"></testcase>
<testcase name="tests/backtotop" time="0"></testcase>
<testcase name="tests/pagetransition" time="0"></testcase>
<testcase name="tests/buttongroup" time="0"></testcase>
<testcase name="tests/radiocard" time="0"></testcase>
<testcase name="tests/checkboxcard" time="0"></testcase>
<testcase name="tests/listpicker" time="0"></testcase>
<testcase name="tests/cascader" time="0"></testcase>
<testcase name="tests/colorpicker" time="0"></testcase>
<testcase name="tests/rangequickpicker" time="0"></testcase>
<testcase name="tests/numberrangeinput" time="0"></testcase>
<testcase name="tests/switchgroup" time="0"></testcase>
<testcase name="tests/dragupload" time="0"></testcase>
<testcase name="tests/signaturepad" time="0"></testcase>
<testcase name="tests/codeinput" time="0"></testcase>
<testcase name="tests/passwordinput" time="0"></testcase>
<testcase name="tests/phoneinput" time="0"></testcase>
<testcase name="tests/messagepro" time="0"></testcase>
<testcase name="tests/notificationpro" time="0"></testcase>
<testcase name="tests/progressvariants" time="0"></testcase>
<testcase name="tests/skeletonpro" time="0"></testcase>
<testcase name="tests/emptypro" time="0"></testcase>
<testcase name="tests/loadingoverlay" time="0"></testcase>
<testcase name="tests/resultvariants" time="0"></testcase>
<testcase name="tests/exceptionvariants" time="0"></testcase>
<testcase name="tests/confirmmodal" time="0"></testcase>
<testcase name="tests/alertmodal" time="0"></testcase>
<testcase name="tests/formmodal" time="0"></testcase>
<testcase name="tests/fullscreenmodal" time="0"></testcase>
<testcase name="tests/themeswitcher" time="0"></testcase>
<testcase name="tests/layoutswitcher" time="0"></testcase>
<testcase name="tests/densityswitcher" time="0"></testcase>
<testcase name="tests/employeecard" time="0"></testcase>
</testsuite>
+7 -9
View File
@@ -1,18 +1,16 @@
{
"generated": "2026-09-11 17:42:00",
"generated": "2026-09-11 15:12:19",
"version": "1.4.1",
"pages": 79,
"pagesAllPass": 79,
"passRate": 100,
"assertions": {
"total": 961,
"pass": 925,
"total": 1038,
"pass": 1003,
"fail": 0,
"skip": 36,
"na": 36
"skip": 35,
"na": 35
},
"failureKinds": {},
"failedPages": [],
"note": "v1.4.1:四轮审计(契约保真/令牌语义/i18n 质量/残留检查)+ 回归偶发根治。八次连跑 100% 零超时。N/A 36 条来自静态组件与单实例组件。此通过率不等于无障碍合规认证。",
"timedOut": []
"timedOut": [],
"failedPages": []
}
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env node
/**
* build-dist.mjs — 产出可分发产物(npm 包 / CDN)
*
* 设计约束:
* - 零运行时依赖:只用 Node 内置能力(fs / path)
* - 不依赖 build-site.ps1(那是 Windows 专用的文档站构建)
* - 产物自包含:dist/ 里的东西拷走就能用
*
* 输入:
* - .design_library/aurora-admin/colors_and_type.css 设计令牌
* - .design_library/aurora-admin/components.css 6 核心组件样式聚合
* - .design_library/aurora-admin/css.json 结构化令牌
* - frameworks/*.css 79 个组件样式
* - frameworks/*.html 79 个演示页
*
* 输出:
* - dist/tokens/tokens.css 设计令牌(可直接 @import)
* - dist/tokens/tokens.json W3C DTCG 格式
* - dist/tokens/figma.json Figma Tokens 格式
* - dist/components/<slug>.css 单个组件样式
* - dist/components/index.css 全部组件样式聚合
* - dist/components/<slug>.html 静态演示页(可独立打开)
* - dist/manifest.json 组件清单 + 版本 + 校验和
* - dist/README.md 用法速查
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'fs';
import { createHash } from 'crypto';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const LIB = join(ROOT, '.design_library', 'aurora-admin');
const SRC = join(ROOT, 'frameworks');
const DIST = join(ROOT, 'dist');
/* ---------- 工具 ---------- */
function ensure(dir) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
function read(p) {
return readFileSync(p, 'utf8');
}
function write(p, content) {
ensure(dirname(p));
// 统一 LF,避免跨平台差异
writeFileSync(p, content.replace(/\r\n/g, '\n'));
}
function sha256(content) {
return createHash('sha256').update(content).digest('hex').slice(0, 16);
}
/* ---------- 1. 读组件清单 ---------- */
const idxPath = join(LIB, 'components', 'index.json');
if (!existsSync(idxPath)) {
console.error('[FATAL] 找不到 components/index.json');
process.exit(1);
}
const idx = JSON.parse(read(idxPath));
const components = idx.components;
const pkg = JSON.parse(read(join(ROOT, 'package.json')));
const version = pkg.version;
console.log(`[build-dist] 版本 ${version} / ${components.length} 个组件`);
/* ---------- 2. 令牌 ---------- */
const tokenCss = read(join(LIB, 'colors_and_type.css'));
// 从令牌文件解析出 name/value/comment(与 build-site.ps1 同一套正则思路)
const tokens = [];
const TOKEN_RE = /--(au-[a-z0-9-]+)\s*:\s*([^;]+);(?:\s*\/\*\s*(.*?)\s*\*\/)?/g;
let m;
while ((m = TOKEN_RE.exec(tokenCss)) !== null) {
tokens.push({ name: m[1], value: m[2].trim(), comment: (m[3] || '').trim() });
}
write(join(DIST, 'tokens', 'tokens.css'), tokenCss);
// W3C DTCG
function groupOf(name) {
if (/color|brand|text|bg|border|success|warning|error|info|mask/.test(name)) return 'color';
if (/font|type/.test(name)) return 'typography';
if (/radius/.test(name)) return 'radius';
if (/space|gap|padding/.test(name)) return 'spacing';
if (/shadow|elevation/.test(name)) return 'shadow';
if (/duration|ease|motion/.test(name)) return 'motion';
if (/size|height|width|icon/.test(name)) return 'size';
return 'misc';
}
const dtcg = {};
for (const t of tokens) {
const g = groupOf(t.name);
dtcg[g] = dtcg[g] || {};
dtcg[g][t.name] = { $value: t.value, $type: g === 'color' ? 'color' : 'dimension', $description: t.comment || undefined };
}
write(
join(DIST, 'tokens', 'tokens.json'),
JSON.stringify(
{ $schema: 'https://schemas.design-tokens.org/draft/dtcg.json', $metadata: { name: 'Aurora Admin', version }, tokens: dtcg },
null,
2
)
);
// Figma Tokens
const figma = {};
for (const t of tokens) {
const g = groupOf(t.name);
figma[t.name] = {
value: t.value,
type: g === 'color' ? 'color' : g === 'size' ? 'sizing' : g === 'spacing' ? 'spacing' : g === 'radius' ? 'borderRadius' : 'other',
description: t.comment || '',
};
}
write(join(DIST, 'tokens', 'figma.json'), JSON.stringify({ global: figma, $themes: [], $metadata: { tokenSetOrder: ['global'] } }, null, 2));
console.log(`[build-dist] 令牌 ${tokens.length} 个 → dist/tokens/`);
/* ---------- 3. 组件样式 ---------- */
const manifest = {
name: pkg.name,
version,
generated: new Date().toISOString().slice(0, 19).replace('T', ' '),
tokens: tokens.length,
components: [],
};
const aggregate = [
'/* Aurora Admin — 全部组件样式聚合',
` * 版本 ${version} · ${components.length} 个组件`,
' * 用法:在令牌之后引入本文件即可获得全部组件样式',
' */',
'',
/* 本文件位于 dist/components/,令牌在 dist/tokens/ —— 故必须是 ../tokens/ */
`@import url('../tokens/tokens.css');`,
'',
].join('\n');
const parts = [aggregate];
for (const c of components) {
const slug = c.slug;
const prefix = c.frameworksPrefix;
// 组件 CSS
const cssPath = join(SRC, `${prefix}.css`);
if (existsSync(cssPath)) {
const css = read(cssPath);
write(join(DIST, 'components', `${slug}.css`), `/* Aurora Admin · ${c.name} */\n${css}`);
parts.push(`/* ---- ${c.name} (${slug}) ---- */`, css, '');
}
// 演示页(重写引用,使其在 dist 下能独立打开)
const htmlPath = join(SRC, `${prefix}.html`);
let hasDemo = false;
if (existsSync(htmlPath)) {
let html = read(htmlPath);
/* dist 的目录结构:
dist/components/<slug>.html
dist/components/<slug>.css
dist/tokens/tokens.css
因此所有引用都要重写。漏掉文件名是最容易犯的错 —— 会产出「路径对但文件不存在」的坏产物。 */
html = html.replace(/\.\.\/\.design_library\/aurora-admin\/colors_and_type\.css/g, '../tokens/tokens.css');
html = html.replace(/\.\.\/\.design_library\/aurora-admin\/components\.css/g, './index.css');
html = html.replace(/\.\.\/\.design_library\/aurora-admin\//g, '../tokens/');
// 组件自身样式:./<Prefix>.css → ./<slug>.css
html = html.replace(new RegExp(`(\\./)?${prefix}\\.css`, 'g'), `./${slug}.css`);
write(join(DIST, 'components', `${slug}.html`), html);
hasDemo = true;
}
manifest.components.push({
slug,
name: c.name,
category: c.category,
tier: c.tier,
css: existsSync(cssPath) ? `components/${slug}.css` : null,
demo: hasDemo ? `components/${slug}.html` : null,
cssHash: existsSync(cssPath) ? sha256(read(cssPath)) : null,
});
}
write(join(DIST, 'components', 'index.css'), parts.join('\n'));
write(join(DIST, 'manifest.json'), JSON.stringify(manifest, null, 2));
const cssCount = readdirSync(join(DIST, 'components')).filter((f) => f.endsWith('.css')).length;
const htmlCount = readdirSync(join(DIST, 'components')).filter((f) => f.endsWith('.html')).length;
console.log(`[build-dist] 组件 ${components.length} 个 → dist/components/(${cssCount} css / ${htmlCount} html)`);
/* ---------- 4. 用法速查 ---------- */
write(
join(DIST, 'README.md'),
`# Aurora Admin Design System — 分发包
版本 ${version} · ${components.length} 个组件 · ${tokens.length} 个设计令牌 · 零运行时依赖
## 最快用法
\`\`\`html
<link rel="stylesheet" href="./tokens/tokens.css">
<link rel="stylesheet" href="./components/index.css">
\`\`\`
## 按需引入
\`\`\`html
<link rel="stylesheet" href="./tokens/tokens.css">
<link rel="stylesheet" href="./components/button.css"> <!-- 只要按钮 -->
\`\`\`
## 改主题
所有色值都引用 \`--au-*\` 令牌,覆盖即可全局换色:
\`\`\`css
:root { --au-color-brand: #722ED1; }
\`\`\`
## 目录
| 路径 | 内容 |
|---|---|
| \`tokens/tokens.css\` | 设计令牌(CSS 变量) |
| \`tokens/tokens.json\` | 同上(W3C DTCG 格式,供工具消费) |
| \`tokens/figma.json\` | Figma Tokens(Tokens Studio 可导入) |
| \`components/index.css\` | 全部组件样式聚合 |
| \`components/<slug>.css\` | 单个组件样式 |
| \`components/<slug>.html\` | 该组件的静态演示页(可直接打开) |
| \`manifest.json\` | 组件清单 + 校验和 |
## 组件清单
${manifest.components.map((c) => `- \`${c.slug}\` — ${c.name}`).join('\n')}
`
);
console.log('[build-dist] 完成 → dist/');
console.log(` components/manifest.json · README.md`);
+189 -48
View File
@@ -3,80 +3,221 @@
* run-regression.mjs — headless 回归 runner(CI / 本地均可)
*
* 前置:仓库根目录起 dev-server(node site/dev-server.js)
* 依赖:npm i playwright(或全局已装)
* 依赖: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 } from 'fs';
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 = 6;
const CONC = Number(process.env.REG_CONC) || 6;
const PAGE_TIMEOUT = 15000;
const ASSERTS_TIMEOUT = 8000;
const RETRY_LIMIT = 2; // 总尝试次数(1 次原始 + 1 次重试)
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 } });
async function checkServer() {
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 });
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) {
results.push({ slug, total: 0, pass: 0, fail: 1, skip: 0, failures: ['page-error'] });
} finally {
await p.close();
console.error(`[FATAL] 无法连接 ${BASE}/site/data.json — ${e.message}`);
console.error(' 请先在仓库根目录运行:node site/dev-server.js');
process.exit(2);
}
}
for (let i = 0; i < slugs.length; i += CONC) {
await Promise.all(slugs.slice(i, i + CONC).map(s => runOne(browser, s)));
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 pagesAllPass = results.filter(r => r.fail === 0 && r.total > 0).length;
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,
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 }))
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 })),
};
writeFileSync('tests/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('tests/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}">\n${cases}\n</testsuite>\n`);
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(`passRate ${report.passRate}% | pages ${results.length} (all-pass ${pagesAllPass}) | assertions ${passAssert}/${totalAssert}`);
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);