Files
danyang-match/ui-tests/miniapp-api-client-behavior.mjs
WorkBuddy 0da93008d0 test(miniapp): 行为契约的桩改为忠实模拟 wx 回调包装(异常不外逸,转 App.onError)
原桩直接同步调用 `opt.success(...)`,被测代码在回调内抛错时异常会**外逸到测试进程**,
于是「请求永不 settle」这一真实后果反而测不出来 —— 报出来的是注入的异常本身,
而不是「Promise 永久 pending」。

真实小程序里 `wx.request` 的回调是被 wx 包装的:回调内抛错**不会**打断请求链,
而是被捕获后走 `App.onError`(本轮控制台实测 `[App onError] MiniProgramError`),
后果是 `resolve` 永不执行。桩必须保真这一点,否则判据会在错误的地方变红。

改动:
- `respond()` 捕获回调内异常并记录,不外逸;
- 成功路径新增断言:成功回调内**不得**抛出任何异常(附明确失败信息)。

负向自证(改前已实测):在 `resolve(body.data)` 之前注入一行抛错 →
本契约输出 `Promise 在 1000ms 内未 settle —— 收尾逻辑又跑到了 resolve 之前` 并退出 1;
移除注入后 `git diff --exit-code` 为空(逐字节还原),全量门禁 **18/18 PASS**。
2026-09-19 11:43:13 +08:00

149 lines
6.3 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.
// 契约:小程序 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'))
/**
* 模拟 wx 的回调包装:回调内抛出的异常**不会外逸**,而是被 wx 捕获后走 `App.onError`
* (本机控制台实测即如此:`[App onError] MiniProgramError ...`)。
* 保真这一点很关键 —— 否则桩会把异常直接抛给测试进程,
* 于是"请求永不 settle"这一真实后果反而测不出来。
*/
const callbackErrors = []
function respond(statusCode, data) {
assert.ok(pendingRequest, '没有待响应的请求 —— 被测代码没走到 wx.request')
const opt = pendingRequest
pendingRequest = null
try {
opt.success({ statusCode, data })
} catch (error) {
callbackErrors.push(error)
}
}
/** 断言 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,
'幂等键必须真的写进请求头(否则后端无法识别重复投递)',
)
// 成功回调内**不得**抛出任何异常:一旦抛出,wx 会捕获后转 App.onError,
// 而 `resolve` 已经不会执行 → 该请求的 Promise 永久 pending(页面卡 loading)。
assert.deepEqual(
callbackErrors.map((e) => `${e && e.name}: ${e && e.message}`),
[],
'成功回调内抛出了异常 —— 在真实小程序里会表现为该请求永不 settle(页面卡 loading)',
)
}
// ── 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 组)')