Files
danyang-match/ui-tests/miniapp-api-client-behavior.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

131 lines
5.4 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.
// 契约:小程序 API 请求层的**响应路径行为**(不是源码结构断言,是真跑)
//
// 由来(2026-09-19):`miniapp/utils/api.js` 的成功回调把 `releaseIdempotencyKey`
// 误写成 `this._releaseIdempotencyKey`(多一个下划线),且该调用位于 `resolve()` **之前**:
// TypeError → resolve 永不执行 → **每一个成功请求**的 Promise 永不 settle
// → 所有页面永远停在 loading(App.onError 刷屏)。
//
// 静态断言(grep / 类型检查)抓不到这类问题有两个原因:
// ① `utils/api.js` 是 .js,`tsc` 不管;② 判据只比对"源码里写了什么"。
// 故此处直接 **require 真实模块 + 桩掉 wx.request**,断言响应路径的真实行为。
//
// 能这么做的前提:`api.js` 只依赖 `../constants` 与 `../env`(纯数据模块),
// `wx` 只在调用期使用 —— 所以可以在 node 里给它一个 `wx` 桩。若将来 api.js
// 在加载期就调用 wx 或引入其他运行时依赖,本契约会立刻报错(这是好事,不是噪音)。
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { resolve } from 'node:path'
const require = createRequire(import.meta.url)
const root = resolve(import.meta.dirname, '..')
// ── wx 桩 ────────────────────────────────────────────────────────
const storage = new Map()
let pendingRequest = null
let lastRequestOptions = null
globalThis.wx = {
getStorageSync: (k) => (storage.has(k) ? storage.get(k) : ''),
setStorageSync: (k, v) => storage.set(k, v),
removeStorageSync: (k) => storage.delete(k),
request: (options) => {
pendingRequest = options
lastRequestOptions = options
},
}
const api = require(resolve(root, 'miniapp/utils/api.js'))
function respond(statusCode, data) {
assert.ok(pendingRequest, '没有待响应的请求 —— 被测代码没走到 wx.request')
const opt = pendingRequest
pendingRequest = null
opt.success({ statusCode, data })
}
/** 断言 Promise 在限定时间内 settle,否则视为「永不 settle」缺陷 */
function withinSettle(promise, ms = 1000) {
return Promise.race([
promise,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error(`Promise 在 ${ms}ms 内未 settle —— 收尾逻辑又跑到了 resolve 之前`)),
ms,
),
),
])
}
// ── 1. 成功路径必须 resolve(回归:曾在此处抛 TypeError 导致永久 pending)──
{
const key = api.idempotencyKey('like', 'u1')
const p = api.post('/likes', { targetUserId: 'u1' }, undefined, key)
respond(200, { success: true, data: { liked: true } })
const data = await withinSettle(p)
assert.deepEqual(data, { liked: true }, '成功响应应把 body.data 交给调用方')
assert.equal(
lastRequestOptions.header['Idempotency-Key'],
key,
'幂等键必须真的写进请求头(否则后端无法识别重复投递)',
)
}
// ── 2. 成功之后必须释放幂等键:同域下一次操作应拿到**新**键 ──
{
const before = api.idempotencyKey('like', 'u1')
const p = api.post('/likes', { targetUserId: 'u1' }, undefined, before)
respond(200, { success: true, data: { liked: true } })
await withinSettle(p)
const after = api.idempotencyKey('like', 'u1')
assert.notEqual(
after,
before,
'成功落地后未释放幂等键 —— 用户的第二次操作会被后端当成重复投递而被幂等丢掉',
)
}
// ── 3. 失败时**刻意不释放**:重试必须复用同一把键 ──
{
const before = api.idempotencyKey('pass', 'u9')
const p = api.post('/passes', { targetUserId: 'u9' }, undefined, before)
respond(200, { success: false, code: 'RATE_LIMITED', message: '太频繁' })
// 注意:本层 reject 的是**普通对象** `{code, message, requestId}` 而非 Error,
// 所以不能用 /正则/ 直接匹配(那只对 Error.message 生效),要传校验函数。
// 代价是丢掉堆栈 —— 属既有契约,此处不改,仅记录。
await assert.rejects(
withinSettle(p),
(err) => {
assert.equal(err.code, 'RATE_LIMITED')
assert.equal(err.message, '太频繁')
return true
},
'业务失败应 reject 并带服务端原文',
)
const after = api.idempotencyKey('pass', 'u9')
assert.equal(after, before, '失败后不应释放幂等键 —— 重试需复用同键,后端才能识别重复投递')
}
// ── 4. 收尾动作自身抛错,绝不能破坏响应路径(本轮结构性教训的固化)──
{
const original = api.releaseIdempotencyKey
const warns = []
const originalWarn = console.warn
console.warn = (...args) => warns.push(args.join(' '))
api.releaseIdempotencyKey = () => {
throw new Error('模拟收尾动作崩溃')
}
try {
const p = api.post('/likes', { targetUserId: 'u2' }, undefined, api.idempotencyKey('like', 'u2'))
respond(200, { success: true, data: { liked: true } })
const data = await withinSettle(p)
assert.deepEqual(data, { liked: true }, '收尾动作抛错不应影响调用方拿到数据')
assert.ok(warns.length > 0, '收尾动作抛错必须留下告警,不能静默吞掉')
} finally {
api.releaseIdempotencyKey = original
console.warn = originalWarn
}
}
console.log('Miniapp API client behavior contracts passed(成功/失败/释放/收尾容错 4 组)')