Files
danyang-match/scripts/run-ui-contracts.mjs
T
WorkBuddy d707fb1f60 ci(ui): 把 14 个 UI 契约门禁接入 CI,并新增统一执行器
Phase 23 收尾计划 批 1 Task 1.1 —— 此前 ui-tests/*.mjs 只在本地手工逐个执行,没有任何 CI
把关:改动破坏了源码结构契约也不会有任何信号。

- 新增 scripts/run-ui-contracts.mjs:逐个 spawn 子进程运行守卫 → 汇总 → 任一失败即非零退出,
  并打印失败守卫的原始输出便于定位。零 npm 依赖(守卫只用 node 内建模块)。
- 两条防退化设计:
  ① 子进程 cwd 固定为仓库根 —— 守卫里 6 个用相对路径、8 个用 import.meta.dirname 自锚定,
     不固定 cwd 会在 CI 上假红;
  ② 守卫数量下限校验(MIN_GUARDS=14,可用 UI_GUARDS_MIN 覆盖)—— 防止守卫被删除或改名后
     依然报绿(Phase 19 教训:扫描清单本身会漏)。
- ci.yml 新增 ui-contracts job(checkout + setup-node + 跑执行器),与既有 test-shell-guard
  同属「守门」类 job。
- 生产 compose 缺失的既有兜底确认可复用:ui-document-baseline-contracts.mjs 对不在版本控制内的
  infrastructure/docker-compose.prod.yml 已做 existsSync 判定并显式打印跳过原因,CI 上不会假红。

验证:
- 基线 node scripts/run-ui-contracts.mjs → PASS 14/14,退出码 0;
- 负向注入(.workbuddy/tmp 下必然失败的守卫)→ FAIL、退出码 1,失败详情可见;
- 数量下限分支以 UI_GUARDS_MIN=99 自检 → 正确拒绝报绿(退出码 1);
- .github/workflows/ci.yml 经 yaml.safe_load 解析通过,jobs 含 ui-contracts。
2026-09-18 21:30:15 +08:00

95 lines
4.0 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
// UI 契约门禁统一执行器(本地 + CI 共用入口)
//
// 背景:ui-tests/*.mjs 是一组「源码结构断言」守卫(纯 node 内建模块、零 npm 依赖),
// 2026-09-18 之前**只在本地手工逐个执行** —— 没有 CI 把关,改坏了没人知道。
// 本脚本把「逐个跑 + 汇总 + 非零退出」固化下来:
//
// node scripts/run-ui-contracts.mjs # 跑 ui-tests/ 下全部守卫(默认)
// node scripts/run-ui-contracts.mjs <file...> # 只跑指定守卫(排查用)
//
// 三条约定(都对应踩过的坑):
// 1) **子进程 cwd 固定为仓库根**。守卫分两类路径风格:多数用 `import.meta.dirname` 自锚定,
// 但 phase03/04/06/07、tabbar-icons、miniapp-auth 用相对路径 —— 在别处调用会假红。
// 2) **守卫数量下限硬校验**:防止「守卫被删除/改名」后依然报绿(Phase 19 教训:扫描清单本身会漏)。
// 新增守卫要上调 MIN_GUARDS;有意删减则下调,二者都必须显式改这行,不能默默变绿。
// 3) 任一守卫非零退出 → 本脚本非零退出(CI 才会红),并打印该守卫的原始输出便于定位。
import { spawnSync } from 'node:child_process'
import { readdirSync } from 'node:fs'
import { basename, dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const UI_TESTS_DIR = resolve(root, 'ui-tests')
// 2026-09-18 基线:ui-tests/ 下 14 个守卫。新增/删减守卫时同步修改本默认值。
// 可用环境变量 UI_GUARDS_MIN 覆盖 —— 唯一目的是让「数量下限」这条分支本身可被自检
// (`UI_GUARDS_MIN=99 node scripts/run-ui-contracts.mjs` 必须拒绝报绿)。
const MIN_GUARDS = Number(process.env.UI_GUARDS_MIN ?? 14)
const cliArgs = process.argv.slice(2)
const explicit = cliArgs.length > 0
const guards = explicit
? cliArgs.map((p) => resolve(root, p))
: readdirSync(UI_TESTS_DIR)
.filter((f) => f.endsWith('.mjs'))
.sort()
.map((f) => resolve(UI_TESTS_DIR, f))
if (guards.length === 0) {
console.error('ERROR: 未找到任何守卫(ui-tests/*.mjs)')
process.exit(1)
}
if (!explicit && guards.length < MIN_GUARDS) {
console.error(
`ERROR: 只发现 ${guards.length} 个守卫,少于下限 ${MIN_GUARDS} —— 守卫可能被删除或改名,拒绝报绿。`,
)
console.error(' 新增守卫请上调 MIN_GUARDS;确为有意删减请下调(并说明原因)。')
process.exit(1)
}
console.log(`UI 契约门禁(源码结构断言)— ${guards.length} 个守卫\n`)
const pad = String(guards.length).length
const failures = []
const t0 = Date.now()
guards.forEach((file, i) => {
const name = basename(file)
const started = Date.now()
const r = spawnSync(process.execPath, [file], {
cwd: root,
encoding: 'utf8',
env: { ...process.env, NO_COLOR: '1' },
})
const ms = Date.now() - started
const tag = `[${String(i + 1).padStart(pad)}/${guards.length}]`
if (r.status === 0) {
console.log(`${tag} PASS ${name} (${ms}ms)`)
} else {
console.log(`${tag} FAIL ${name} (${ms}ms)`)
failures.push({ name, status: r.status, stdout: r.stdout, stderr: r.stderr })
}
})
if (failures.length > 0) {
console.log(`\n${'='.repeat(64)}\n失败详情(守卫原始输出)\n${'='.repeat(64)}`)
for (const f of failures) {
console.log(`\n--- ${f.name}(exit ${f.status ?? 'null'})---`)
const out = [f.stdout, f.stderr].filter(Boolean).join('\n').trim()
console.log(out || '(无输出 —— 守卫自身异常退出,需人工排查)')
}
}
const secs = ((Date.now() - t0) / 1000).toFixed(1)
console.log('')
if (failures.length === 0) {
console.log(`✅ PASS ${guards.length} / ${guards.length},0 失败,用时 ${secs}s`)
process.exit(0)
}
console.log(`❌ PASS ${guards.length - failures.length} / ${guards.length},${failures.length} 失败,用时 ${secs}s`)
process.exit(1)