mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
chore: 添加 CI 配置、codecov 和测试 mock 基础设施
Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
141
tests/mocks/axios.ts
Normal file
141
tests/mocks/axios.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Shared axios mock helper using the spread+flag pattern.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Usage in a test file:
|
||||
*
|
||||
* import { setupAxiosMock } from '../../../tests/mocks/axios'
|
||||
*
|
||||
* const axiosHandle = setupAxiosMock()
|
||||
* axiosHandle.stubs.get = (url, config) => Promise.resolve({ status: 200, data: {...}, headers: {}, statusText: 'OK', config })
|
||||
* axiosHandle.stubs.post = ...
|
||||
*
|
||||
* beforeAll(() => { axiosHandle.useStubs = true })
|
||||
* afterAll(() => { axiosHandle.useStubs = false })
|
||||
*
|
||||
* If your suite needs an `isAxiosError` predicate that recognises plain
|
||||
* objects with `isAxiosError: true`, set `axiosHandle.stubs.isAxiosError` —
|
||||
* otherwise the real axios's predicate is used.
|
||||
*/
|
||||
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.
|
||||
// biome-ignore lint/suspicious/noExplicitAny: see comment above
|
||||
type AnyFn = (...args: any[]) => unknown
|
||||
|
||||
export type AxiosMethodStubs = {
|
||||
get?: AnyFn
|
||||
post?: AnyFn
|
||||
put?: AnyFn
|
||||
patch?: AnyFn
|
||||
delete?: AnyFn
|
||||
head?: AnyFn
|
||||
options?: AnyFn
|
||||
request?: AnyFn
|
||||
isAxiosError?: (e: unknown) => boolean
|
||||
isCancel?: (e: unknown) => boolean
|
||||
create?: AnyFn
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function setupAxiosMock(): AxiosMockHandle {
|
||||
const handle: AxiosMockHandle = { useStubs: false, stubs: {} }
|
||||
|
||||
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[]) => {
|
||||
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 routedDefault: Record<string, unknown> = { ...realDefault }
|
||||
for (const v of verbs) {
|
||||
routedDefault[v] = route(v)
|
||||
}
|
||||
|
||||
routedDefault.isAxiosError = (e: unknown) => {
|
||||
if (handle.useStubs && handle.stubs.isAxiosError) {
|
||||
return handle.stubs.isAxiosError(e)
|
||||
}
|
||||
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 handle
|
||||
}
|
||||
45
tests/mocks/childProcess.ts
Normal file
45
tests/mocks/childProcess.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Shared mock for `node:child_process`.
|
||||
*
|
||||
* Usage:
|
||||
* import { mock } from 'bun:test'
|
||||
* import { childProcessMock, execFileMock, execFileSyncMock } from 'tests/mocks/childProcess'
|
||||
* mock.module('node:child_process', () => childProcessMock)
|
||||
*
|
||||
* Call `execFileMock.mockImplementation(...)` or `execFileSyncMock.mockImplementation(...)`
|
||||
* before each test that needs specific behavior.
|
||||
*/
|
||||
import { mock } from 'bun:test'
|
||||
|
||||
// execFile: node-style callback (cmd, args, opts?, callback)
|
||||
export const execFileMock = mock(
|
||||
(
|
||||
_cmd: string,
|
||||
_args: string[],
|
||||
_optsOrCb?: unknown,
|
||||
_cb?: (err: Error | null, stdout: string, stderr: string) => void,
|
||||
) => {
|
||||
const cb =
|
||||
typeof _optsOrCb === 'function'
|
||||
? (_optsOrCb as (
|
||||
err: Error | null,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
) => void)
|
||||
: _cb
|
||||
if (cb) cb(null, '', '')
|
||||
return null
|
||||
},
|
||||
)
|
||||
|
||||
// execFileSync: synchronous (returns Buffer)
|
||||
export const execFileSyncMock = mock(
|
||||
(_cmd: string, _args: string[], _opts?: unknown): Buffer => {
|
||||
return Buffer.from('')
|
||||
},
|
||||
)
|
||||
|
||||
export const childProcessMock = {
|
||||
execFile: execFileMock,
|
||||
execFileSync: execFileSyncMock,
|
||||
}
|
||||
91
tests/mocks/state.ts
Normal file
91
tests/mocks/state.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Shared partial mock for src/bootstrap/state.ts
|
||||
*
|
||||
* Covers the most commonly imported exports plus their transitive callers.
|
||||
* Add exports here when new tests need them — never mock exports that don't exist.
|
||||
*
|
||||
* Usage:
|
||||
* import { stateMock } from '../../../tests/mocks/state'
|
||||
* mock.module('src/bootstrap/state.js', stateMock)
|
||||
*/
|
||||
export function stateMock() {
|
||||
const noop = () => {}
|
||||
return {
|
||||
// Session identity
|
||||
getSessionId: () => 'mock-session-id',
|
||||
regenerateSessionId: noop,
|
||||
getParentSessionId: () => undefined,
|
||||
switchSession: noop,
|
||||
onSessionSwitch: () => () => {},
|
||||
|
||||
// CWD / project
|
||||
getOriginalCwd: () => '/mock/cwd',
|
||||
getSessionProjectDir: () => null,
|
||||
getProjectRoot: () => '/mock/project',
|
||||
getCwdState: () => '/mock/cwd',
|
||||
setCwdState: noop,
|
||||
setOriginalCwd: noop,
|
||||
setProjectRoot: noop,
|
||||
|
||||
// Direct-connect
|
||||
getDirectConnectServerUrl: () => undefined,
|
||||
setDirectConnectServerUrl: noop,
|
||||
|
||||
// Duration / cost accumulators
|
||||
addToTotalDurationState: noop,
|
||||
resetTotalDurationStateAndCost_FOR_TESTS_ONLY: noop,
|
||||
addToTotalCostState: noop,
|
||||
getTotalCostUSD: () => 0,
|
||||
getTotalAPIDuration: () => 0,
|
||||
getTotalDuration: () => 0,
|
||||
getTotalAPIDurationWithoutRetries: () => 0,
|
||||
getTotalToolDuration: () => 0,
|
||||
addToToolDuration: noop,
|
||||
|
||||
// Turn stats
|
||||
getTurnHookDurationMs: () => 0,
|
||||
addToTurnHookDuration: noop,
|
||||
resetTurnHookDuration: noop,
|
||||
getTurnHookCount: () => 0,
|
||||
getTurnToolDurationMs: () => 0,
|
||||
resetTurnToolDuration: noop,
|
||||
getTurnToolCount: () => 0,
|
||||
getTurnClassifierDurationMs: () => 0,
|
||||
addToTurnClassifierDuration: noop,
|
||||
resetTurnClassifierDuration: noop,
|
||||
getTurnClassifierCount: () => 0,
|
||||
|
||||
// Stats store
|
||||
getStatsStore: () => ({}),
|
||||
setStatsStore: noop,
|
||||
|
||||
// Interaction time
|
||||
updateLastInteractionTime: noop,
|
||||
flushInteractionTime: noop,
|
||||
|
||||
// Lines changed
|
||||
addToTotalLinesChanged: noop,
|
||||
getTotalLinesAdded: () => 0,
|
||||
getTotalLinesRemoved: () => 0,
|
||||
|
||||
// Token counts
|
||||
getTotalInputTokens: () => 0,
|
||||
getTotalOutputTokens: () => 0,
|
||||
getTotalCacheReadInputTokens: () => 0,
|
||||
getTotalCacheCreationInputTokens: () => 0,
|
||||
getTotalWebSearchRequests: () => 0,
|
||||
getTurnOutputTokens: () => 0,
|
||||
getCurrentTurnTokenBudget: () => null,
|
||||
|
||||
// API request state
|
||||
setLastAPIRequest: noop,
|
||||
getLastAPIRequest: () => null,
|
||||
setLastAPIRequestMessages: noop,
|
||||
getLastAPIRequestMessages: () => [],
|
||||
|
||||
// Various getters (add as needed)
|
||||
getIsNonInteractiveSession: () => false,
|
||||
getSdkAgentProgressSummariesEnabled: () => false,
|
||||
addSlowOperation: noop,
|
||||
}
|
||||
}
|
||||
52
tests/mocks/toolContext.ts
Normal file
52
tests/mocks/toolContext.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Shared minimal ToolUseContext stub for tool unit tests.
|
||||
*
|
||||
* Provides only the fields tools actually access in tests:
|
||||
* - getAppState() returns a context with empty rule arrays for every source
|
||||
* - toolUseId / parentMessageId / assistantMessageId / turnId can be
|
||||
* overridden per test for budget tracking tests
|
||||
*
|
||||
* Usage:
|
||||
* import { mockToolContext } from 'tests/mocks/toolContext'
|
||||
* const ctx = mockToolContext({ toolUseId: 't1' })
|
||||
*
|
||||
* Per memory feedback "Mock dependency not subject" — this exists so each
|
||||
* tool test file does not redefine the same partial stub.
|
||||
*/
|
||||
|
||||
const emptyRules = {
|
||||
user: [],
|
||||
project: [],
|
||||
local: [],
|
||||
session: [],
|
||||
cliArg: [],
|
||||
}
|
||||
|
||||
export interface MockToolContextOptions {
|
||||
toolUseId?: string
|
||||
parentMessageId?: string
|
||||
assistantMessageId?: string
|
||||
turnId?: string
|
||||
/** Override toolPermissionContext fields (e.g. mode, alwaysAllowRules). */
|
||||
permissionOverrides?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function mockToolContext(opts: MockToolContextOptions = {}): never {
|
||||
return {
|
||||
toolUseId: opts.toolUseId,
|
||||
parentMessageId: opts.parentMessageId,
|
||||
assistantMessageId: opts.assistantMessageId,
|
||||
turnId: opts.turnId,
|
||||
getAppState: () => ({
|
||||
toolPermissionContext: {
|
||||
mode: 'default',
|
||||
additionalWorkingDirectories: new Set(),
|
||||
alwaysAllowRules: { ...emptyRules },
|
||||
alwaysDenyRules: { ...emptyRules },
|
||||
alwaysAskRules: { ...emptyRules },
|
||||
isBypassPermissionsModeAvailable: false,
|
||||
...(opts.permissionOverrides ?? {}),
|
||||
},
|
||||
}),
|
||||
} as never
|
||||
}
|
||||
Reference in New Issue
Block a user