test: add spread+flag axios mock helper to stop CI mock pollution

Bare `mock.module('axios', () => ({ default: { stubs } }))` is
process-global last-write-wins and drops `axios.create`, `request`,
`isAxiosError`, etc. that real consumers need. In CI's alphabetical
file order, that produces dozens of polluted failures (AgentsPlatformView,
schedule API, memory-stores API, etc.) that don't reproduce on WSL2.

Introduce `tests/mocks/axios.ts` with `setupAxiosMock()` — `require('axios')`
inside the factory, spread real shape, route each verb through a per-suite
`useStubs` flag. beforeAll flips on, afterAll flips off; the spread
fall-through eliminates cross-file leakage.

Refactored 12 axios mockers in tests/, plus the bare `@anthropic/ink` mocks
in ultrareviewCommand and onboarding suites (same pollution pattern broke
AgentsPlatformView's Box/Text rendering).

Verified: 5339/5345 tests pass locally; remaining 6 failures are
pre-existing isolation issues unrelated to this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
unraid
2026-05-09 15:55:58 +08:00
parent 8945f08708
commit 8cd0e90ca6
18 changed files with 418 additions and 131 deletions

View File

@@ -1,17 +1,31 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
mock,
test,
} from 'bun:test'
import { authMock } from '../../../../../../tests/mocks/auth'
import { setupAxiosMock } from '../../../../../../tests/mocks/axios'
let requestStatus = 200
const auditRecords: Record<string, unknown>[] = []
mock.module('axios', () => ({
default: {
request: async () => ({
status: requestStatus,
data: { ok: requestStatus >= 200 && requestStatus < 300 },
}),
},
}))
const axiosHandle = setupAxiosMock()
axiosHandle.stubs.request = async () => ({
status: requestStatus,
data: { ok: requestStatus >= 200 && requestStatus < 300 },
})
beforeAll(() => {
axiosHandle.useStubs = true
})
afterAll(() => {
axiosHandle.useStubs = false
})
mock.module('src/utils/auth.js', authMock)

View File

@@ -1,18 +1,27 @@
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
mock,
test,
} from 'bun:test'
import { setupAxiosMock } from '../../../../../../tests/mocks/axios'
// After this suite finishes, switch our getSecret override off so localVault's
// own store.test.ts (running in the same process) sees the real impl.
// own store.test.ts (running in the same process) sees the real impl. Also
// flip the axios stub flag off so the spread mock falls through to real axios
// for any test file that runs after this one.
afterAll(() => {
useMockForGetSecret = false
getSecretShouldThrow = false
axiosHandle.useStubs = false
})
beforeAll(() => {
axiosHandle.useStubs = true
})
// We mock the LOWER layers (axios + localVault store + http util) rather
@@ -34,9 +43,8 @@ const mockAxiosRequest = mock(
}),
)
mock.module('axios', () => ({
default: { request: mockAxiosRequest },
}))
const axiosHandle = setupAxiosMock()
axiosHandle.stubs.request = mockAxiosRequest
let mockedSecret: string | null = 'XSECRETXX'
let getSecretShouldThrow = false

View File

@@ -1,5 +1,14 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test'
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
mock,
test,
} from 'bun:test'
import { logMock } from '../../../../../../tests/mocks/log'
import { setupAxiosMock } from '../../../../../../tests/mocks/axios'
type MockAxiosResponse = {
data: ArrayBuffer
@@ -18,17 +27,12 @@ type MockAxiosError = Error & {
let getMock: (url: string) => Promise<MockAxiosResponse>
mock.module('axios', () => {
const axiosMock = {
get: (url: string) => getMock(url),
isAxiosError: (error: unknown): error is MockAxiosError =>
typeof error === 'object' &&
error !== null &&
(error as { isAxiosError?: unknown }).isAxiosError === true,
}
return { default: axiosMock }
})
const axiosHandle = setupAxiosMock()
axiosHandle.stubs.get = (url: string) => getMock(url)
axiosHandle.stubs.isAxiosError = (error: unknown): boolean =>
typeof error === 'object' &&
error !== null &&
(error as { isAxiosError?: unknown }).isAxiosError === true
mock.module('src/services/analytics/index.js', () => ({
logEvent: () => {},
@@ -67,6 +71,14 @@ beforeEach(() => {
})
})
beforeAll(() => {
axiosHandle.useStubs = true
})
afterAll(() => {
axiosHandle.useStubs = false
})
describe('WebFetch response headers', () => {
test('reads redirect Location from AxiosHeaders-style get()', async () => {
getMock = async () => {

View File

@@ -1,4 +1,12 @@
import { describe, expect, mock, test } from 'bun:test'
import { afterAll, describe, expect, mock, test } from 'bun:test'
import { setupAxiosMock } from '../../../../../../tests/mocks/axios'
// Each test below calls `mock.module('axios', ...)` per-test. Re-register a
// spread-real axios mock at end-of-file so the per-test stubs do not leak
// into subsequent test files (mock.module is process-global, last-write-wins).
afterAll(() => {
setupAxiosMock()
})
const _abortMock = () => ({
AbortError: class AbortError extends Error {

View File

@@ -1,4 +1,22 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
mock,
test,
} from 'bun:test'
import { setupAxiosMock } from '../../../../../../tests/mocks/axios'
// Each test below calls `mock.module('axios', ...)` per-test. Without an
// afterAll cleanup, the LAST per-test stub leaks into every test file that
// runs after this one (mock.module is process-global, last-write-wins). The
// spread-real mock registered here at the end re-routes axios to the real
// module, undoing the stub leakage so later suites see real axios.
afterAll(() => {
setupAxiosMock()
})
// Defensive mock: agent.test.ts mocks config.js which can corrupt Bun's
// src/* path alias resolution. Provide AbortError directly so the dynamic

View File

@@ -1,4 +1,12 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'
import { afterAll, afterEach, describe, expect, mock, test } from 'bun:test'
import { setupAxiosMock } from '../../../../../../tests/mocks/axios'
// Each test below calls `mock.module('axios', ...)` per-test. Re-register a
// spread-real axios mock at end-of-file so the per-test stubs do not leak
// into subsequent test files (mock.module is process-global, last-write-wins).
afterAll(() => {
setupAxiosMock()
})
const _abortMock = () => ({
AbortError: class AbortError extends Error {

View File

@@ -1,4 +1,5 @@
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
@@ -9,6 +10,7 @@ import {
} from 'bun:test'
import { debugMock } from '../../../../tests/mocks/debug.js'
import { logMock } from '../../../../tests/mocks/log.js'
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
// Mock side-effect modules first
mock.module('src/utils/log.ts', logMock)
@@ -48,15 +50,11 @@ const axiosIsAxiosError = mock((err: unknown) => {
)
})
mock.module('axios', () => ({
default: {
get: axiosGetMock,
post: axiosPostMock,
delete: axiosDeleteMock,
isAxiosError: axiosIsAxiosError,
},
isAxiosError: axiosIsAxiosError,
}))
const axiosHandle = setupAxiosMock()
axiosHandle.stubs.get = axiosGetMock
axiosHandle.stubs.post = axiosPostMock
axiosHandle.stubs.delete = axiosDeleteMock
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
// Lazy import after mocks are in place
let listAgents: typeof import('../agentsApi.js').listAgents
@@ -65,6 +63,7 @@ let deleteAgent: typeof import('../agentsApi.js').deleteAgent
let runAgent: typeof import('../agentsApi.js').runAgent
beforeAll(async () => {
axiosHandle.useStubs = true
const mod = await import('../agentsApi.js')
listAgents = mod.listAgents
createAgent = mod.createAgent
@@ -72,6 +71,10 @@ beforeAll(async () => {
runAgent = mod.runAgent
})
afterAll(() => {
axiosHandle.useStubs = false
})
beforeEach(() => {
axiosGetMock.mockClear()
axiosPostMock.mockClear()

View File

@@ -11,6 +11,7 @@
*/
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
@@ -21,6 +22,7 @@ import {
} from 'bun:test'
import { debugMock } from '../../../../tests/mocks/debug.js'
import { logMock } from '../../../../tests/mocks/log.js'
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
@@ -60,16 +62,12 @@ const axiosIsAxiosError = mock((err: unknown) => {
)
})
mock.module('axios', () => ({
default: {
get: axiosGetMock,
post: axiosPostMock,
patch: axiosPatchMock,
delete: axiosDeleteMock,
isAxiosError: axiosIsAxiosError,
},
isAxiosError: axiosIsAxiosError,
}))
const axiosHandle = setupAxiosMock()
axiosHandle.stubs.get = axiosGetMock
axiosHandle.stubs.post = axiosPostMock
axiosHandle.stubs.patch = axiosPatchMock
axiosHandle.stubs.delete = axiosDeleteMock
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
// ── Lazy import after mocks ─────────────────────────────────────────────────
let listStores: typeof import('../memoryStoresApi.js').listStores
@@ -85,6 +83,7 @@ let listVersions: typeof import('../memoryStoresApi.js').listVersions
let redactVersion: typeof import('../memoryStoresApi.js').redactVersion
beforeAll(async () => {
axiosHandle.useStubs = true
const mod = await import('../memoryStoresApi.js')
listStores = mod.listStores
getStore = mod.getStore
@@ -99,6 +98,10 @@ beforeAll(async () => {
redactVersion = mod.redactVersion
})
afterAll(() => {
axiosHandle.useStubs = false
})
beforeEach(() => {
axiosGetMock.mockClear()
axiosPostMock.mockClear()

View File

@@ -1,8 +1,18 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import * as React from 'react';
import { logMock } from '../../../../tests/mocks/log';
import { debugMock } from '../../../../tests/mocks/debug';
// Pre-import real ink so we can fall through after this suite. Bun's
// mock.module is process-global / last-write-wins; without delegation the
// stub Box/Pane/Text/useTheme leak into other test files (e.g.
// AgentsPlatformView.test.tsx) that need real ink components.
const _realOnboardingInkMod = (await import('@anthropic/ink')) as Record<string, unknown>;
let _useStubInkForOnboarding = true;
afterAll(() => {
_useStubInkForOnboarding = false;
});
mock.module('bun:bundle', () => ({
feature: (_name: string) => false,
}));
@@ -37,13 +47,20 @@ mock.module('src/utils/config.js', () => ({
}));
// Stub heavy theme + ink imports — the launcher only references them for
// the `theme` subcommand JSX render path.
mock.module('@anthropic/ink', () => ({
Box: ({ children }: { children?: React.ReactNode }) => React.createElement('box', null, children),
Pane: ({ children }: { children?: React.ReactNode }) => React.createElement('pane', null, children),
Text: ({ children }: { children?: React.ReactNode }) => React.createElement('text', null, children),
useTheme: () => ['dark', (_t: string) => undefined],
}));
// the `theme` subcommand JSX render path. Spread real ink so when the flag
// flips off in afterAll, later test files see real components.
mock.module('@anthropic/ink', () => {
if (_useStubInkForOnboarding) {
return {
..._realOnboardingInkMod,
Box: ({ children }: { children?: React.ReactNode }) => React.createElement('box', null, children),
Pane: ({ children }: { children?: React.ReactNode }) => React.createElement('pane', null, children),
Text: ({ children }: { children?: React.ReactNode }) => React.createElement('text', null, children),
useTheme: () => ['dark', (_t: string) => undefined],
};
}
return _realOnboardingInkMod;
});
mock.module('src/components/ThemePicker.js', () => ({
ThemePicker: () => React.createElement('theme-picker'),

View File

@@ -15,17 +15,26 @@
import { afterAll, describe, expect, mock, test } from 'bun:test';
import { debugMock } from '../../../../tests/mocks/debug.js';
import { logMock } from '../../../../tests/mocks/log.js';
import { setupAxiosMock } from '../../../../tests/mocks/axios.js';
// Pre-import the real react module so we can delegate after this suite.
// Bun's mock.module is process-global / last-write-wins; without delegation
// the stub createElement leaks into other test files (e.g.
// SnapshotUpdateDialog.test.tsx) that need real React.createElement.
// Pre-import the real react and ink modules so we can delegate after this
// suite. Bun's mock.module is process-global / last-write-wins; without
// delegation the stub createElement / stub ink components leak into other
// test files (e.g. SnapshotUpdateDialog.test.tsx, AgentsPlatformView.test.tsx)
// that need real React.createElement and real Box/Text components.
const _realReactMod = (await import('react')) as Record<string, unknown> & {
default?: Record<string, unknown>;
};
const _realInkMod = (await import('@anthropic/ink')) as Record<string, unknown>;
let _useStubReactForUltrareview = true;
let _useStubInkForUltrareview = true;
afterAll(() => {
_useStubReactForUltrareview = false;
_useStubInkForUltrareview = false;
// The handle reference exists by the time afterAll runs (TDZ resolves via
// closure). Flip useStubs off so the spread-real fall-through kicks in for
// any test file that runs after this one in the same process.
_ultrareviewAxiosHandle.useStubs = false;
});
// Mock dependency chain before any subject import
@@ -79,14 +88,15 @@ const mockAxiosPost = mock(
}),
);
mock.module('axios', () => {
const axiosMock = {
post: mockAxiosPost,
isAxiosError: (e: unknown) =>
typeof e === 'object' && e !== null && (e as { isAxiosError?: boolean }).isAxiosError === true,
};
return { default: axiosMock, ...axiosMock };
});
// Spread real axios + flag-gate stubs so the per-test mockAxiosPost stops
// leaking into later test files (mock.module is process-global). Default ON
// for this suite; afterAll above flips _useStubReactForUltrareview, but here
// we tie axios cleanup to the helper's own flag — see suite-level afterAll.
const _ultrareviewAxiosHandle = setupAxiosMock();
_ultrareviewAxiosHandle.useStubs = true;
_ultrareviewAxiosHandle.stubs.post = mockAxiosPost;
_ultrareviewAxiosHandle.stubs.isAxiosError = (e: unknown) =>
typeof e === 'object' && e !== null && (e as { isAxiosError?: boolean }).isAxiosError === true;
// Mock detectCurrentRepositoryWithHost
mock.module('src/utils/detectRepository.js', () => ({
@@ -128,11 +138,21 @@ mock.module('react', () => {
};
});
mock.module('@anthropic/ink', () => ({
Box: 'Box',
Dialog: 'Dialog',
Text: 'Text',
}));
// Spread real ink + flag-gate the stub components. Without spread, the bare
// { Box: 'Box', Dialog: 'Dialog', Text: 'Text' } leaks into every later test
// file (e.g. AgentsPlatformView.test.tsx) that imports @anthropic/ink — those
// consumers receive strings instead of real components and rendering breaks.
mock.module('@anthropic/ink', () => {
if (_useStubInkForUltrareview) {
return {
..._realInkMod,
Box: 'Box',
Dialog: 'Dialog',
Text: 'Text',
};
}
return _realInkMod;
});
mock.module('src/components/CustomSelect/select.js', () => ({
Select: 'Select',

View File

@@ -9,6 +9,7 @@
*/
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
@@ -19,6 +20,7 @@ import {
} from 'bun:test'
import { debugMock } from '../../../../tests/mocks/debug.js'
import { logMock } from '../../../../tests/mocks/log.js'
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
@@ -57,15 +59,11 @@ const axiosIsAxiosError = mock((err: unknown) => {
)
})
mock.module('axios', () => ({
default: {
get: axiosGetMock,
post: axiosPostMock,
delete: axiosDeleteMock,
isAxiosError: axiosIsAxiosError,
},
isAxiosError: axiosIsAxiosError,
}))
const axiosHandle = setupAxiosMock()
axiosHandle.stubs.get = axiosGetMock
axiosHandle.stubs.post = axiosPostMock
axiosHandle.stubs.delete = axiosDeleteMock
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
// ── Lazy import after mocks ─────────────────────────────────────────────────
// Use the src/ alias path (same canonical key used in launchSchedule.test.ts mock)
@@ -79,6 +77,7 @@ let deleteTrigger: typeof import('../triggersApi.js').deleteTrigger
let runTrigger: typeof import('../triggersApi.js').runTrigger
beforeAll(async () => {
axiosHandle.useStubs = true
const mod = await import('../triggersApi.js')
listTriggers = mod.listTriggers
getTrigger = mod.getTrigger
@@ -88,6 +87,10 @@ beforeAll(async () => {
runTrigger = mod.runTrigger
})
afterAll(() => {
axiosHandle.useStubs = false
})
beforeEach(() => {
axiosGetMock.mockClear()
axiosPostMock.mockClear()

View File

@@ -14,6 +14,7 @@
*/
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
@@ -24,6 +25,7 @@ import {
} from 'bun:test'
import { debugMock } from '../../../../tests/mocks/debug.js'
import { logMock } from '../../../../tests/mocks/log.js'
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
@@ -62,15 +64,11 @@ const axiosIsAxiosError = mock((err: unknown) => {
)
})
mock.module('axios', () => ({
default: {
get: axiosGetMock,
post: axiosPostMock,
delete: axiosDeleteMock,
isAxiosError: axiosIsAxiosError,
},
isAxiosError: axiosIsAxiosError,
}))
const axiosHandle = setupAxiosMock()
axiosHandle.stubs.get = axiosGetMock
axiosHandle.stubs.post = axiosPostMock
axiosHandle.stubs.delete = axiosDeleteMock
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
// ── Lazy import after mocks ─────────────────────────────────────────────────
let listSkills: typeof import('../skillsApi.js').listSkills
@@ -81,6 +79,7 @@ let createSkill: typeof import('../skillsApi.js').createSkill
let deleteSkill: typeof import('../skillsApi.js').deleteSkill
beforeAll(async () => {
axiosHandle.useStubs = true
const mod = await import('../skillsApi.js')
listSkills = mod.listSkills
getSkill = mod.getSkill
@@ -90,6 +89,10 @@ beforeAll(async () => {
deleteSkill = mod.deleteSkill
})
afterAll(() => {
axiosHandle.useStubs = false
})
beforeEach(() => {
axiosGetMock.mockClear()
axiosPostMock.mockClear()

View File

@@ -20,6 +20,7 @@ import {
} from 'bun:test'
import { debugMock } from '../../../../tests/mocks/debug.js'
import { logMock } from '../../../../tests/mocks/log.js'
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
@@ -73,15 +74,11 @@ const axiosIsAxiosError = mock((err: unknown) => {
)
})
mock.module('axios', () => ({
default: {
get: axiosGetMock,
post: axiosPostMock,
delete: axiosDeleteMock,
isAxiosError: axiosIsAxiosError,
},
isAxiosError: axiosIsAxiosError,
}))
const axiosHandle = setupAxiosMock()
axiosHandle.stubs.get = axiosGetMock
axiosHandle.stubs.post = axiosPostMock
axiosHandle.stubs.delete = axiosDeleteMock
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
// ── fs/promises mock ─────────────────────────────────────────────────────────
// Bun's mock.module is global per-process and last-write-wins. Replacing
@@ -119,6 +116,7 @@ let getClaudeConfigHomeDir: typeof import('../../../utils/envUtils.js').getClaud
let origConfigDir: string | undefined
beforeAll(async () => {
axiosHandle.useStubs = true
const mod = await import('../launchSkillStore.js')
callSkillStore = mod.callSkillStore
const envMod = await import('../../../utils/envUtils.js')
@@ -130,6 +128,7 @@ beforeAll(async () => {
// Flip the stub flag off after this suite so localVault/store and other
// fs-dependent tests in the same process see real readFile/readdir/etc.
afterAll(() => {
axiosHandle.useStubs = false
useSkillStoreFsStubs = false
})

View File

@@ -12,6 +12,7 @@
*/
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
@@ -22,6 +23,7 @@ import {
} from 'bun:test'
import { debugMock } from '../../../../tests/mocks/debug.js'
import { logMock } from '../../../../tests/mocks/log.js'
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
@@ -60,15 +62,11 @@ const axiosIsAxiosError = mock((err: unknown) => {
)
})
mock.module('axios', () => ({
default: {
get: axiosGetMock,
post: axiosPostMock,
delete: axiosDeleteMock,
isAxiosError: axiosIsAxiosError,
},
isAxiosError: axiosIsAxiosError,
}))
const axiosHandle = setupAxiosMock()
axiosHandle.stubs.get = axiosGetMock
axiosHandle.stubs.post = axiosPostMock
axiosHandle.stubs.delete = axiosDeleteMock
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
// ── Lazy import after mocks ─────────────────────────────────────────────────
let listVaults: typeof import('../vaultsApi.js').listVaults
@@ -80,6 +78,7 @@ let addCredential: typeof import('../vaultsApi.js').addCredential
let archiveCredential: typeof import('../vaultsApi.js').archiveCredential
beforeAll(async () => {
axiosHandle.useStubs = true
const mod = await import('../vaultsApi.js')
listVaults = mod.listVaults
createVault = mod.createVault
@@ -90,6 +89,10 @@ beforeAll(async () => {
archiveCredential = mod.archiveCredential
})
afterAll(() => {
axiosHandle.useStubs = false
})
beforeEach(() => {
axiosGetMock.mockClear()
axiosPostMock.mockClear()

View File

@@ -8,6 +8,7 @@
*/
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
@@ -18,6 +19,7 @@ import {
} from 'bun:test'
import { debugMock } from '../../../../tests/mocks/debug.js'
import { logMock } from '../../../../tests/mocks/log.js'
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
@@ -51,24 +53,27 @@ const axiosIsAxiosError = mock((err: unknown) => {
)
})
mock.module('axios', () => ({
default: {
get: axiosGetMock,
post: axiosPostMock,
delete: mock(async () => ({})),
isAxiosError: axiosIsAxiosError,
},
isAxiosError: axiosIsAxiosError,
}))
const axiosDeleteMock = mock(async () => ({}))
const axiosHandle = setupAxiosMock()
axiosHandle.stubs.get = axiosGetMock
axiosHandle.stubs.post = axiosPostMock
axiosHandle.stubs.delete = axiosDeleteMock
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
// ── Lazy import after mocks ─────────────────────────────────────────────────
let callVault: typeof import('../launchVault.js').callVault
beforeAll(async () => {
axiosHandle.useStubs = true
const mod = await import('../launchVault.js')
callVault = mod.callVault
})
afterAll(() => {
axiosHandle.useStubs = false
})
beforeEach(() => {
axiosGetMock.mockClear()
axiosPostMock.mockClear()

View File

@@ -3,9 +3,10 @@
* Verifies all three action enum states (proceed/confirm/blocked),
* network/HTTP error handling, and Zod schema mismatch fallback.
*/
import { describe, expect, mock, test } from 'bun:test'
import { afterAll, beforeAll, describe, expect, mock, test } from 'bun:test'
import { debugMock } from '../../../../tests/mocks/debug.js'
import { logMock } from '../../../../tests/mocks/log.js'
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
// Mock dependency chain before any subject import
mock.module('src/utils/debug.ts', debugMock)
@@ -46,15 +47,19 @@ const mockAxiosPost = mock(async (..._args: any[]): Promise<any> => {
throw new Error('not configured')
})
mock.module('axios', () => {
const axiosMock = {
post: mockAxiosPost,
isAxiosError: (e: unknown) =>
typeof e === 'object' &&
e !== null &&
(e as { isAxiosError?: boolean }).isAxiosError === true,
}
return { default: axiosMock, ...axiosMock }
const axiosHandle = setupAxiosMock()
axiosHandle.stubs.post = mockAxiosPost
axiosHandle.stubs.isAxiosError = (e: unknown) =>
typeof e === 'object' &&
e !== null &&
(e as { isAxiosError?: boolean }).isAxiosError === true
beforeAll(() => {
axiosHandle.useStubs = true
})
afterAll(() => {
axiosHandle.useStubs = false
})
import {

View File

@@ -1,9 +1,26 @@
import { mock, describe, expect, test, afterEach } from 'bun:test'
import {
mock,
describe,
expect,
test,
afterEach,
beforeAll,
afterAll,
} from 'bun:test'
import { debugMock } from '../../../../tests/mocks/debug'
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
const axiosHandle = setupAxiosMock()
axiosHandle.stubs.get = async () => ({ data: { servers: [] } })
beforeAll(() => {
axiosHandle.useStubs = true
})
afterAll(() => {
axiosHandle.useStubs = false
})
mock.module('axios', () => ({
default: { get: async () => ({ data: { servers: [] } }) },
}))
mock.module('src/utils/debug.ts', debugMock)
const { isOfficialMcpUrl, resetOfficialMcpUrlsForTesting } = await import(

141
tests/mocks/axios.ts Normal file
View 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
}