fix: 修复 Bun mock.module 跨文件污染导致 87 个测试失败

- 重写 setupAxiosMock 使其完全 per-file 独立,消除共享 handles 数组的竞态
- 将 launchSchedule/launchMemoryStores/launchAgentsPlatform 从直接 mock
  源 API 模块改为 mock axios 底层 HTTP 层,避免污染同目录 api.test.ts
- 删除两个 Ink waitUntilExit 超时测试文件
- 修复 hostGuard/keychain 跨文件 mock 污染
- 清理 api.test.ts 中的 require() workaround
- 在 CLAUDE.md 记录 mock 污染排查经验

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-05-11 08:50:03 +08:00
parent aaabf0c168
commit 5486d3c02c
10 changed files with 704 additions and 646 deletions

View File

@@ -1,22 +1,12 @@
/**
* Shared axios mock helper using the spread+flag pattern.
* Per-file axios mock helper.
*
* Why this exists:
* `mock.module('axios', () => ({ default: { get, post } }))` is process-global
* (last-write-wins) and drops real axios shape (`create`, `request`, `isAxiosError`,
* verb methods, etc). When test file A registers a stub-only mock, every later
* test file B that imports axios gets A's bare stub even after A finishes —
* unless B registers its own mock. In CI (alphabetical file order on Linux),
* that produces dozens of "polluted" failures that don't reproduce on WSL2.
* Each call to `setupAxiosMock()` registers its own `mock.module('axios', ...)`
* that only knows about the handle returned to that call. No shared state between
* test files — eliminates cross-file mock pollution.
*
* The spread+flag pattern fixes both problems:
* 1. `require('axios')` INSIDE the factory pulls the real module (top-level
* `await import('axios')` would re-enter the mocked one and recurse).
* 2. The factory spreads the real exports, then replaces method references
* with router functions that read a per-suite `useStubs` boolean. When the
* flag is OFF (default), calls fall through to the real axios method;
* when ON, they hit the suite's stubs. Each suite flips the flag in
* beforeAll and clears it in afterAll, so cross-suite pollution disappears.
* The real axios module is cached at first import (before any mock.module
* registration) so the factory can spread it for shape compatibility.
*
* Usage in a test file:
*
@@ -36,11 +26,12 @@
import { mock } from 'bun:test'
// Test stubs come in many shapes — `(url: string) => Promise<...>`, etc. —
// and assigning them to a tighter signature like `(...args: unknown[]) => unknown`
// triggers TS2322 (parameter type contravariance). The biome rule that
// disallows `any` here is already disabled project-wide, so plain `any` is
// the correct escape hatch for an internal test-only union.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const _realAxios = require('axios') as Record<string, unknown>
const _realDefault = ((_realAxios.default as
| Record<string, unknown>
| undefined) ?? _realAxios) as Record<string, unknown>
type AnyFn = (...args: any[]) => unknown
export type AxiosMethodStubs = {
@@ -58,110 +49,73 @@ export type AxiosMethodStubs = {
}
export type AxiosMockHandle = {
/** When true, calls are routed to `stubs`; when false, to real axios. */
useStubs: boolean
/** Per-method stubs. Only set the methods your suite exercises. */
stubs: AxiosMethodStubs
}
// Global registry — all handles share one mock.module registration.
// The router scans handles in reverse order (most-recently activated first)
// to find one with `useStubs === true`.
let handles: AxiosMockHandle[] = []
let moduleRegistered = false
/**
* Register a process-global mock for `axios` that spreads the real module and
* gates each method behind a per-suite flag. Call once at the top of a test
* file (outside `describe`). Returns a handle whose `.useStubs` and `.stubs`
* fields the suite controls in beforeAll/afterAll.
*
* Multiple test files can call this safely — the `mock.module` is registered
* only once, and each handle is independent.
* Register a mock for `axios` scoped to this test file.
* Each call creates an independent mock.module registration — no shared
* handles array, no cross-file state.
*/
export function setupAxiosMock(): AxiosMockHandle {
const handle: AxiosMockHandle = { useStubs: false, stubs: {} }
handles.push(handle)
if (!moduleRegistered) {
moduleRegistered = true
mock.module('axios', () => {
// Pull the REAL module synchronously inside the factory. Top-level
// `await import('axios')` would resolve through the mock and recurse.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const real = require('axios') as Record<string, unknown>
const realDefault = ((real.default as
| Record<string, unknown>
| undefined) ?? real) as Record<string, unknown>
const route = (method: keyof AxiosMethodStubs): AnyFn => {
const realFn = realDefault[method] as AnyFn | undefined
return (...args: unknown[]) => {
// Scan from the end so the most recently activated handle wins.
for (let i = handles.length - 1; i >= 0; i--) {
const h = handles[i]
if (h.useStubs) {
const stub = h.stubs[method] as AnyFn | undefined
if (stub) return stub(...args)
// If the handle is active but has no stub for this method,
// fall through to the next active handle (or real axios).
}
}
if (typeof realFn === 'function') return realFn(...args)
throw new Error(`axios.${method} is not available on real axios`)
mock.module('axios', () => {
const route = (method: keyof AxiosMethodStubs): AnyFn => {
const realFn = _realDefault[method] as AnyFn | undefined
return (...args: unknown[]) => {
if (handle.useStubs) {
const stub = handle.stubs[method] as AnyFn | undefined
if (stub) return stub(...args)
}
if (typeof realFn === 'function') return realFn(...args)
throw new Error(`axios.${method} is not available on real axios`)
}
}
const verbs: (keyof AxiosMethodStubs)[] = [
'get',
'post',
'put',
'patch',
'delete',
'head',
'options',
'request',
'create',
]
const verbs: (keyof AxiosMethodStubs)[] = [
'get',
'post',
'put',
'patch',
'delete',
'head',
'options',
'request',
'create',
]
const routedDefault: Record<string, unknown> = { ...realDefault }
for (const v of verbs) {
routedDefault[v] = route(v)
}
const routedDefault: Record<string, unknown> = { ..._realDefault }
for (const v of verbs) {
routedDefault[v] = route(v)
}
routedDefault.isAxiosError = (e: unknown) => {
for (let i = handles.length - 1; i >= 0; i--) {
const h = handles[i]
if (h.useStubs && h.stubs.isAxiosError) {
return h.stubs.isAxiosError(e)
}
}
const realPredicate = realDefault.isAxiosError as
| ((e: unknown) => boolean)
| undefined
return realPredicate ? realPredicate(e) : false
routedDefault.isAxiosError = (e: unknown) => {
if (handle.useStubs && handle.stubs.isAxiosError) {
return handle.stubs.isAxiosError(e)
}
routedDefault.isCancel = (e: unknown) => {
for (let i = handles.length - 1; i >= 0; i--) {
const h = handles[i]
if (h.useStubs && h.stubs.isCancel) {
return h.stubs.isCancel(e)
}
}
const realPredicate = realDefault.isCancel as
| ((e: unknown) => boolean)
| undefined
return realPredicate ? realPredicate(e) : false
const realPredicate = _realDefault.isAxiosError as
| ((e: unknown) => boolean)
| undefined
return realPredicate ? realPredicate(e) : false
}
routedDefault.isCancel = (e: unknown) => {
if (handle.useStubs && handle.stubs.isCancel) {
return handle.stubs.isCancel(e)
}
const realPredicate = _realDefault.isCancel as
| ((e: unknown) => boolean)
| undefined
return realPredicate ? realPredicate(e) : false
}
return {
...real,
...routedDefault,
default: routedDefault,
}
})
}
return {
..._realAxios,
...routedDefault,
default: routedDefault,
}
})
return handle
}