Files
danyang-match/scripts/run-ui-contracts.mjs
T
WorkBuddy b67a56479e fix(miniapp): 修 P0 请求层崩溃(一个下划线致所有请求卡死)+ 组件 WXSS 违规选择器,再补 3 项门禁
═══ 缺陷 1(P0,运行期):utils/api.js 成功回调调用不存在的方法 ═══

控制台:
  TypeError: _this._releaseIdempotencyKey is not a function

根因:类中定义的是 `releaseIdempotencyKey()`,成功回调写成了
`this._releaseIdempotencyKey(...)`(**多一个下划线**)。

**为什么一个字符的笔误是 P0**:该调用位于 `wx.request` 的 success 回调内、
且在 `resolve()` **之前**。抛错 → `resolve` 永不执行 → 该 Promise **永不 settle**
→ 所有页面永远停在 loading,同时 App.onError 刷屏。
即「一次成功请求」本应推进流程,实际把前端整成不可用。

修复不只是改名,而是纠正结构:
- **先 `resolve(body.data)`,再执行收尾动作** —— 收尾逻辑不允许挡在响应路径上;
- 释放动作自带 try/catch + console.warn:释放失败最多是"下次操作没拿到新键",
  不得升级为业务失败。
- 语义未变(勿改):成功→释放(下次同域操作是新的一次);失败→刻意不释放
  (重试复用同一把键,后端才能识别重复投递)。

═══ 缺陷 2(编译期警告):组件 wxss 用了标签选择器 ═══

  Some selectors are not allowed in component wxss ... (components/recommend-card/index.wxss:1:1092)

实测违规 1 处(23 个组件样式文件全扫):`.compact-like image` —— 组件 wxss 只允许类选择器。
改为 `.compact-like__icon` 并**同步在 WXML 补上类名**
(只改 wxss 会让样式静默落空,是另一种"假成功")。

═══ 门禁:15 → 18(既有的全是源码内容断言,抓不到上述两类)═══

- 第15 `wxml-structure-contracts`(上一提交新增,本轮 MIN_GUARDS 基线顺带对齐)
- 第16 `miniapp-self-method-contracts`:扫描 `this._x(` / `self._x(` 等私有调用,
  要求同文件有定义(方法简写 / 类字段 / `this._x = 值` / prototype 四种形态)。
  **判据先自证假阳性**:首版正则不允许 `.` 前缀,把 `this._unsub = store.bind(this)`
  形式误报为未定义(3 个页面);修正并加反向自检样例固化了该坑。
- 第17 `wxss-component-selector-contracts`:组件 wxss 禁标签/ID/属性选择器。
  **作用域刻意只限 miniapp/components/**(page 的 wxss 允许标签选择器,套上去必有误报)。
- 第18 `miniapp-api-client-behavior`:**唯一的行为断言** —— 桩掉 `wx.request` 真跑 api.js,
  验证成功 resolve / 失败 retain 键 / 成功 release 键 / 收尾抛错不影响响应路径 4 组。
  含 1s settle 超时,直接编码"永不 settle"这一失败模式。
- 新增 `scripts/lib/wxss-selectors.mjs` + `scripts/check-wxss-component-selectors.mjs`(CLI)。
- `MIN_GUARDS` 15 → 18;执行器措辞改为「源码结构与关键行为断言」。

═══ 验证 ═══

- `node scripts/run-ui-contracts.mjs` → **PASS 18 / 18**,退出 0;
- 负向注入:未定义私有方法 / 标签+ID+属性选择器 / 多余闭合标签 → 均精准变红,
  且**合法样例(`this._x = 值`、纯类选择器、属性内含 `>`)不被误报**;
  行为守卫的负向自证见下一提交/记录;`UI_GUARDS_MIN=99` 拒绝报绿。
- 文档口径同步:AGENTS.md、STATUS.md(4 处当前口径 + 新增 1.1.0-rc.18 行)、ci.yml 注释。
- **未验证**:微信开发者工具真实编译与真机渲染(本机无该环境)。
2026-09-19 11:40:38 +08:00

103 lines
4.7 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/ 下 18 个守卫
// 14 → 15:新增 wxml-structure-contracts(补「WXML 能否编译」这一层 ——
// 此前 14 绿时小程序仍有两个页面编译失败);
// 15 → 17:新增 miniapp-self-method-contracts(`this._x()` 无定义 → 运行期 TypeError,
// 且该调用位于 resolve 之前时会让所有请求的 Promise 永不 settle)、
// wxss-component-selector-contracts(组件 wxss 用了标签/ID/属性选择器);
// 17 → 18:新增 miniapp-api-client-behavior(**唯一一组行为断言**:桩掉 wx.request,
// 真跑 api.js,验证成功/失败/幂等键释放/收尾容错 4 组行为)。
// 新增/删减守卫时同步修改本默认值。
// 可用环境变量 UI_GUARDS_MIN 覆盖 —— 唯一目的是让「数量下限」这条分支本身可被自检
// (`UI_GUARDS_MIN=99 node scripts/run-ui-contracts.mjs` 必须拒绝报绿)。
const MIN_GUARDS = Number(process.env.UI_GUARDS_MIN ?? 18)
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)