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:
@@ -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
@@ -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
@@ -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
|
||||
Generated
+48
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-1
@@ -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
@@ -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 颜色。
|
||||
排除「数据驱动的颜色」——色板选项、轮播卡片背景、图表数据系列等,
|
||||
|
||||
@@ -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
@@ -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": []
|
||||
}
|
||||
|
||||
@@ -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
@@ -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, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||
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, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user