diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5669d51..9a108a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,6 +143,24 @@ jobs: - name: 禁止 skip/pass 空壳测试 run: python3 scripts/check-no-empty-tests.py tests + ui-contracts: + name: UI Contracts (source-structure gates) + runs-on: ubuntu-latest + # Phase 23 遗留(收尾计划 批 1 Task 1.1):14 个 UI 契约门禁此前**只在本地手工执行**, + # 没有 CI 把关 —— 即「改动破坏了源码结构契约也不会被发现」。 + # 守卫是纯 node 内建模块、零 npm 依赖,故无需装依赖;统一执行器负责逐个跑 + 汇总 + 非零退出, + # 并校验守卫数量下限(防止守卫被删除/改名后仍然报绿)。 + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: 运行 UI 契约门禁 + run: node scripts/run-ui-contracts.mjs + e2e: name: E2E (live stack) runs-on: ubuntu-latest diff --git a/scripts/run-ui-contracts.mjs b/scripts/run-ui-contracts.mjs new file mode 100644 index 0000000..a51ed74 --- /dev/null +++ b/scripts/run-ui-contracts.mjs @@ -0,0 +1,94 @@ +#!/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 # 只跑指定守卫(排查用) +// +// 三条约定(都对应踩过的坑): +// 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)