Files
danyang-match/ui-tests/miniapp-self-method-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

151 lines
6.5 KiB
JavaScript
Raw Permalink 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.
// 契约:小程序内 `this._私有方法(...)` 调用必须在同文件有定义
//
// 由来(2026-09-19):运行期崩溃
// TypeError: _this._releaseIdempotencyKey is not a function
// 定位:miniapp/utils/api.js 里定义了 `releaseIdempotencyKey()`,
// 但成功回调写成了 `this._releaseIdempotencyKey(...)`(**多一个下划线**)。
//
// 为什么危害远大于「一个方法名写错」:
// 该调用位于 `wx.request` 的 success 回调内、**在 `resolve()` 之前**。
// 抛错 → `resolve` 永不执行 → 该 Promise **永不 settle** → 页面永远停在 loading。
// 即:一个字符的笔误,把「所有成功请求」变成「所有页面卡死」。
//
// 为什么既有门禁全都抓不到:
// - `miniapp tsc --noEmit` 只检查 .ts;`utils/api.js` 是 **.js**,不在类型检查范围内;
// - UI 契约门禁是「源码内容断言」(grep 关键字 / 数条目),不做符号解析。
// 又是一处**覆盖盲区**(与 WXML 结构、`pass` 空壳同族)。
//
// 判据范围(**刻意收窄,宁少不误**):
// 只检查**下划线前缀**的私有方法调用 `this._x(` / `self._x(` / `that._x(` / `_this._x(`,
// 要求同文件内存在其定义(方法简写 `_x(`、类字段 `_x =`、对象属性 `_x:`、prototype 赋值)。
// 不做通用「方法是否存在」检查 —— 那会把 `this.setData` / `this.selectComponent`
// 这类运行时注入的宿主方法全部误报,守卫一旦有假阳性就会被绕过(本项目已有先例)。
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { extname, join, relative, resolve } from 'node:path'
const root = resolve(import.meta.dirname, '..')
// SCAN_ROOT 仅用于负向注入自证(指向临时目录),默认 miniapp/
const scanRoot = resolve(root, process.env.MINIAPP_SCAN_ROOT ?? 'miniapp')
const SKIP_DIRS = new Set(['node_modules', 'miniprogram_npm', 'dist', 'build', '.git'])
function walk(dir, out = []) {
let entries
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return out
}
for (const e of entries) {
const p = join(dir, e.name)
if (e.isDirectory()) {
if (!SKIP_DIRS.has(e.name)) walk(p, out)
} else if (['.js', '.ts'].includes(extname(e.name)) && !e.name.endsWith('.d.ts')) {
out.push(p)
}
}
return out
}
const RECEIVERS = ['this', 'self', 'that', '_this']
/**
* 收集同文件内定义的私有成员名。
*
* 定义形态(必须都覆盖,否则产生假阳性):
* 1. 方法简写 / 函数声明:`_x() {}`、`function _x()`
* 2. 类字段 / 对象属性:`_x: ...`、`_x = ...`
* 3. 实例属性赋值:`this._x = ...`(**首版漏了这条** —— `_unsub` 就属于这种,
* 它由 `this._unsub = matchStore.bindToPage(...)` 定义、在 `onUnload` 里调用,
* 首版判据把它误报成「未定义」。注意正则前缀必须允许 `this.` 这种带点的形式)
* 4. prototype 赋值:`.prototype._x =`
*
* 例外:赋值为 `undefined` / `null` 不算定义(后面调用照样会抛)。
*/
function collectDefinitions(src, names) {
const defined = new Set()
for (const n of names) {
const esc = n.replace(/[$]/g, '\\$')
const patterns = [
new RegExp(`(^|[^\\w$.])${esc}\\s*\\(`, 'm'), // 方法简写 _x(
// 注意 lookahead 里必须再吃一次 `\s*`:写成 `=\s*(?!undefined|null)`
// 会因 `\s*` 回溯到零宽而在「= null」上误判成功(实测踩到)。
new RegExp(`(^|[^\\w$.])${esc}\\s*[:=]\\s*(?!\\s*(?:undefined|null)\\b)`, 'm'),
new RegExp(`\\b(?:this|self|that|_this)\\.${esc}\\s*=\\s*(?!\\s*(?:undefined|null)\\b)`, 'm'),
new RegExp(`\\.prototype\\.${esc}\\s*=`, 'm'), // prototype 赋值
]
if (patterns.some((re) => re.test(src))) defined.add(n)
}
return defined
}
const files = walk(scanRoot)
assert.ok(files.length > 0, `未在 ${scanRoot} 下找到任何 .js/.ts —— 扫描清单可能失配,拒绝报绿`)
const violations = []
for (const file of files) {
const src = readFileSync(file, 'utf8')
// 1) 找出所有 `接收者._私有名(` 调用
const callRe = new RegExp(`\\b(${RECEIVERS.join('|')})\\.(_[A-Za-z_$][\\w$]*)\\s*\\(`, 'g')
const calls = new Map() // name -> firstLine
let m
while ((m = callRe.exec(src))) {
const name = m[2]
if (!calls.has(name)) calls.set(name, src.slice(0, m.index).split('\n').length)
}
if (calls.size === 0) continue
// 2) 同文件内必须能找到定义
const defined = collectDefinitions(src, [...calls.keys()])
for (const [name, line] of calls) {
if (!defined.has(name)) {
violations.push(
`${relative(root, file).replace(/\\/g, '/')}:${line} 调用了 ${RECEIVERS[0]}.${name}() 但同文件无该定义`,
)
}
}
}
// 3) 负向自检:确认判据本身有牙(一个「只调用、无定义」的样例必须被判红)
{
const sample = 'class T {\n go() {\n return this._missingHelper(1)\n }\n}\n'
const callRe = new RegExp(`\\b(${RECEIVERS.join('|')})\\.(_[A-Za-z_$][\\w$]*)\\s*\\(`, 'g')
const found = [...sample.matchAll(callRe)].map((x) => x[2])
assert.ok(found.includes('_missingHelper'), '自检失败:判据未能识别 this._x( 调用')
assert.deepEqual(
[...collectDefinitions(sample, ['_missingHelper'])],
[],
'自检失败:判据把未定义的方法误判为已定义',
)
}
// 4) 反向自检:**假阳性**防线。
// `this._unsub = expr` 这种「运行时赋值定义」必须被认成有定义,
// 否则守卫会把合法代码报成缺陷(首版即如此,误报 3 个页面)。
{
const assignDefined = 'Page({\n onLoad() {\n this._unsub = store.bind(this)\n },\n onUnload() {\n if (this._unsub) this._unsub()\n },\n})\n'
assert.deepEqual(
[...collectDefinitions(assignDefined, ['_unsub'])],
['_unsub'],
'自检失败:`this._x = 值` 形式未被认成定义(会导致假阳性)',
)
const assignNull = 'Page({\n onLoad() {\n this._late = null\n },\n go() {\n this._late()\n },\n})\n'
assert.deepEqual(
[...collectDefinitions(assignNull, ['_late'])],
[],
'自检失败:赋值为 null/undefined 不应被认成可用定义',
)
}
assert.deepEqual(
violations,
[],
`以下私有方法调用在同文件内找不到定义(会抛 TypeError;若位于 resolve 之前将导致 Promise 永不 settle):\n${violations.join('\n')}`,
)
console.log(`Miniapp self-method contracts passed(扫描 ${files.length} 个文件)`)