Files
danyang-match/scripts/run-ui-contracts.mjs
T
WorkBuddy d036c9b986 fix(miniapp): 修复 2 个页面 WXML 结构缺陷,并补第 15 项门禁(WXML 结构守卫)
现象:微信开发者工具报
  [ WXML 文件编译错误] ./pages/service-history/service-history.wxml
  get tag end without start, near `</`

真实缺陷(实测 2 处,非 1 处):
- pages/service-history/service-history.wxml 多 1 个 </view>
- pages/service-program/service-program.wxml  多 1 个 </view>
(均为单行压缩写法,随更早提交入库;全仓另外 84 个 WXML 结构自洽)

根因不是这两个文件写错,而是**门禁覆盖盲区**:
既有 14 项 UI 契约全是「源码内容 / 清单断言」,没有任何一条会解析 WXML,
所以「14/14 全绿」与「小程序能不能编译」之间没有因果关系。

修复:
1. 删掉两处多余闭合标签 → 86 个 WXML 结构全部自洽。
2. 新增 scripts/lib/wxml-structure.mjs:按**引号状态机**逐字符扫描标签。
   不能用懒惰正则 —— 属性值里可以合法出现 `>`(`subtitle="{{unreadTotal > 0 ? ...}}"`),
   正则会在引号内的 `>` 处提前收尾,把自闭合标签的 `/` 读掉,报出假阳性。
   (首版实现即踩此坑,曾把合法的 messages.wxml 误判为坏文件;已复核排除。)
3. 新增 scripts/check-wxml-structure.mjs(CLI,退出码 0/1/2)。
4. 新增 ui-tests/wxml-structure-contracts.mjs:全量扫描 + **3 组内置自检样例**
   (正向样例故意在属性值内放 `>`,把「假阳性」这个坑钉进门禁)。
5. scripts/run-ui-contracts.mjs:MIN_GUARDS 14 → 15(守卫被删/改名仍会拒绝报绿)。
6. 文档口径同步:AGENTS.md、STATUS.md(4 处当前口径 + 新增 rc.17 版本行);
   ci.yml ui-contracts job 注释 14 → 15。历史时点行保留原值。

负向验证(三项,全部通过):
- 注入「多余闭合 / 未闭合」→ 退出 1,且只报坏文件、合法的 `>` 属性文件未被误报;
- UI_GUARDS_MIN=99 → 拒绝报绿(数量下限分支可自检);
- CLI 直扫坏目录 → 退出 1。

验证:node scripts/run-ui-contracts.mjs → PASS 15 / 15,0 失败,退出码 0。
未跑:微信开发者工具真实编译(本机无该环境),结论仅由结构判据支撑。
2026-09-19 11:25:05 +08:00

97 lines
4.1 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-19 基线:ui-tests/ 下 15 个守卫(14 → 15:新增 wxml-structure-contracts,
// 补「WXML 能否编译」这一层 —— 此前 14 绿时小程序仍有两个页面编译失败)。
// 新增/删减守卫时同步修改本默认值。
// 可用环境变量 UI_GUARDS_MIN 覆盖 —— 唯一目的是让「数量下限」这条分支本身可被自检
// (`UI_GUARDS_MIN=99 node scripts/run-ui-contracts.mjs` 必须拒绝报绿)。
const MIN_GUARDS = Number(process.env.UI_GUARDS_MIN ?? 15)
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)