diff --git a/packages/builtin-tools/src/tools/RemoteTriggerTool/__tests__/RemoteTriggerTool.test.ts b/packages/builtin-tools/src/tools/RemoteTriggerTool/__tests__/RemoteTriggerTool.test.ts index f773f57e0..d9cef4798 100644 --- a/packages/builtin-tools/src/tools/RemoteTriggerTool/__tests__/RemoteTriggerTool.test.ts +++ b/packages/builtin-tools/src/tools/RemoteTriggerTool/__tests__/RemoteTriggerTool.test.ts @@ -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[] = [] -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) diff --git a/packages/builtin-tools/src/tools/VaultHttpFetchTool/__tests__/VaultHttpFetchTool.test.ts b/packages/builtin-tools/src/tools/VaultHttpFetchTool/__tests__/VaultHttpFetchTool.test.ts index 220114c8a..7144086c9 100644 --- a/packages/builtin-tools/src/tools/VaultHttpFetchTool/__tests__/VaultHttpFetchTool.test.ts +++ b/packages/builtin-tools/src/tools/VaultHttpFetchTool/__tests__/VaultHttpFetchTool.test.ts @@ -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 diff --git a/packages/builtin-tools/src/tools/WebFetchTool/__tests__/headers.test.ts b/packages/builtin-tools/src/tools/WebFetchTool/__tests__/headers.test.ts index 20755e247..d4db977b2 100644 --- a/packages/builtin-tools/src/tools/WebFetchTool/__tests__/headers.test.ts +++ b/packages/builtin-tools/src/tools/WebFetchTool/__tests__/headers.test.ts @@ -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 -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 () => { diff --git a/packages/builtin-tools/src/tools/WebSearchTool/__tests__/bingAdapter.test.ts b/packages/builtin-tools/src/tools/WebSearchTool/__tests__/bingAdapter.test.ts index 36cc097b5..bf5331a7e 100644 --- a/packages/builtin-tools/src/tools/WebSearchTool/__tests__/bingAdapter.test.ts +++ b/packages/builtin-tools/src/tools/WebSearchTool/__tests__/bingAdapter.test.ts @@ -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 { diff --git a/packages/builtin-tools/src/tools/WebSearchTool/__tests__/braveAdapter.test.ts b/packages/builtin-tools/src/tools/WebSearchTool/__tests__/braveAdapter.test.ts index 083e2f5b9..ef7c5a178 100644 --- a/packages/builtin-tools/src/tools/WebSearchTool/__tests__/braveAdapter.test.ts +++ b/packages/builtin-tools/src/tools/WebSearchTool/__tests__/braveAdapter.test.ts @@ -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 diff --git a/packages/builtin-tools/src/tools/WebSearchTool/__tests__/exaAdapter.test.ts b/packages/builtin-tools/src/tools/WebSearchTool/__tests__/exaAdapter.test.ts index e5502941c..417fae469 100644 --- a/packages/builtin-tools/src/tools/WebSearchTool/__tests__/exaAdapter.test.ts +++ b/packages/builtin-tools/src/tools/WebSearchTool/__tests__/exaAdapter.test.ts @@ -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 { diff --git a/src/commands/agents-platform/__tests__/agentsApi.test.ts b/src/commands/agents-platform/__tests__/agentsApi.test.ts index b58156d88..02ad75bca 100644 --- a/src/commands/agents-platform/__tests__/agentsApi.test.ts +++ b/src/commands/agents-platform/__tests__/agentsApi.test.ts @@ -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() diff --git a/src/commands/memory-stores/__tests__/api.test.ts b/src/commands/memory-stores/__tests__/api.test.ts index bea61b690..f036bbafb 100644 --- a/src/commands/memory-stores/__tests__/api.test.ts +++ b/src/commands/memory-stores/__tests__/api.test.ts @@ -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() diff --git a/src/commands/onboarding/__tests__/onboarding.test.tsx b/src/commands/onboarding/__tests__/onboarding.test.tsx index 5aca5771f..fc8cc0e6d 100644 --- a/src/commands/onboarding/__tests__/onboarding.test.tsx +++ b/src/commands/onboarding/__tests__/onboarding.test.tsx @@ -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; +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'), diff --git a/src/commands/review/__tests__/ultrareviewCommand.test.tsx b/src/commands/review/__tests__/ultrareviewCommand.test.tsx index ca73a4652..fd3938502 100644 --- a/src/commands/review/__tests__/ultrareviewCommand.test.tsx +++ b/src/commands/review/__tests__/ultrareviewCommand.test.tsx @@ -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 & { default?: Record; }; +const _realInkMod = (await import('@anthropic/ink')) as Record; 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', diff --git a/src/commands/schedule/__tests__/api.test.ts b/src/commands/schedule/__tests__/api.test.ts index ee9a12850..fa8d50807 100644 --- a/src/commands/schedule/__tests__/api.test.ts +++ b/src/commands/schedule/__tests__/api.test.ts @@ -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() diff --git a/src/commands/skill-store/__tests__/api.test.ts b/src/commands/skill-store/__tests__/api.test.ts index 1ba13a5d1..883d9b55d 100644 --- a/src/commands/skill-store/__tests__/api.test.ts +++ b/src/commands/skill-store/__tests__/api.test.ts @@ -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() diff --git a/src/commands/skill-store/__tests__/launchSkillStore.test.ts b/src/commands/skill-store/__tests__/launchSkillStore.test.ts index a4c65c9c2..77ead5a51 100644 --- a/src/commands/skill-store/__tests__/launchSkillStore.test.ts +++ b/src/commands/skill-store/__tests__/launchSkillStore.test.ts @@ -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 }) diff --git a/src/commands/vault/__tests__/api.test.ts b/src/commands/vault/__tests__/api.test.ts index 3e2ac0150..6afa5bcb0 100644 --- a/src/commands/vault/__tests__/api.test.ts +++ b/src/commands/vault/__tests__/api.test.ts @@ -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() diff --git a/src/commands/vault/__tests__/launchVault.test.ts b/src/commands/vault/__tests__/launchVault.test.ts index a12a13f8a..d1324e6a9 100644 --- a/src/commands/vault/__tests__/launchVault.test.ts +++ b/src/commands/vault/__tests__/launchVault.test.ts @@ -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() diff --git a/src/services/api/__tests__/ultrareviewPreflight.test.ts b/src/services/api/__tests__/ultrareviewPreflight.test.ts index db4bf73ac..8079ed1f3 100644 --- a/src/services/api/__tests__/ultrareviewPreflight.test.ts +++ b/src/services/api/__tests__/ultrareviewPreflight.test.ts @@ -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 => { 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 { diff --git a/src/services/mcp/__tests__/officialRegistry.test.ts b/src/services/mcp/__tests__/officialRegistry.test.ts index 507cc5758..f6ac3ab73 100644 --- a/src/services/mcp/__tests__/officialRegistry.test.ts +++ b/src/services/mcp/__tests__/officialRegistry.test.ts @@ -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( diff --git a/tests/mocks/axios.ts b/tests/mocks/axios.ts new file mode 100644 index 000000000..92b572315 --- /dev/null +++ b/tests/mocks/axios.ts @@ -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 + const realDefault = ((real.default as + | Record + | undefined) ?? real) as Record + + 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 = { ...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 +}