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,127 +0,0 @@
/**
* Tests for AgentsPlatformView.tsx
* Covers all 5 modes: list (empty), list (with agents), created, deleted, ran, error
*/
import { describe, expect, mock, test } from 'bun:test';
import * as React from 'react';
import { renderToString } from '../../../utils/staticRender.js';
// Mock cron utility before importing AgentsPlatformView
mock.module('src/utils/cron.js', () => ({
cronToHuman: (expr: string) => `HumanCron(${expr})`,
parseCronExpression: () => null,
computeNextCronRun: () => null,
}));
const { AgentsPlatformView } = await import('../AgentsPlatformView.js');
const sampleAgent = {
id: 'agt_abc123',
cron_expr: '0 9 * * 1',
prompt: 'Run standup report',
status: 'active' as const,
timezone: 'UTC',
next_run: '2026-05-05T09:00:00.000Z',
};
describe('AgentsPlatformView list mode', () => {
test('empty list shows placeholder message', async () => {
const out = await renderToString(<AgentsPlatformView mode="list" agents={[]} />);
expect(out).toContain('No scheduled agents');
});
test('non-empty list shows agent count', async () => {
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
expect(out).toContain('Scheduled Agents (1)');
});
test('non-empty list shows agent id', async () => {
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
expect(out).toContain('agt_abc123');
});
test('non-empty list shows agent status', async () => {
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
expect(out).toContain('active');
});
test('non-empty list shows human-readable schedule', async () => {
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
expect(out).toContain('HumanCron(0 9 * * 1)');
});
test('list shows agent prompt', async () => {
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
expect(out).toContain('Run standup report');
});
test('list shows next run date', async () => {
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
// next_run is formatted via toLocaleString — just check it's rendered
expect(out).toContain('Next run');
});
test('list with null next_run shows em dash', async () => {
const agentNoNextRun = { ...sampleAgent, next_run: null };
const out = await renderToString(<AgentsPlatformView mode="list" agents={[agentNoNextRun]} />);
expect(out).toContain('—');
});
test('multiple agents rendered', async () => {
const agent2 = { ...sampleAgent, id: 'agt_xyz', cron_expr: '0 10 * * 2' };
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent, agent2]} />);
expect(out).toContain('Scheduled Agents (2)');
expect(out).toContain('agt_abc123');
expect(out).toContain('agt_xyz');
});
});
describe('AgentsPlatformView created mode', () => {
test('shows Agent created', async () => {
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
expect(out).toContain('Agent created');
});
test('shows agent id', async () => {
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
expect(out).toContain('agt_abc123');
});
test('shows schedule', async () => {
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
expect(out).toContain('HumanCron(0 9 * * 1)');
});
test('shows prompt', async () => {
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
expect(out).toContain('Run standup report');
});
});
describe('AgentsPlatformView deleted mode', () => {
test('shows deleted confirmation with id', async () => {
const out = await renderToString(<AgentsPlatformView mode="deleted" id="agt_abc123" />);
expect(out).toContain('agt_abc123');
expect(out).toContain('deleted');
});
});
describe('AgentsPlatformView ran mode', () => {
test('shows triggered with agent id', async () => {
const out = await renderToString(<AgentsPlatformView mode="ran" id="agt_abc123" runId="run_xyz" />);
expect(out).toContain('agt_abc123');
expect(out).toContain('triggered');
});
test('shows run id', async () => {
const out = await renderToString(<AgentsPlatformView mode="ran" id="agt_abc123" runId="run_xyz" />);
expect(out).toContain('run_xyz');
});
});
describe('AgentsPlatformView error mode', () => {
test('shows error message', async () => {
const out = await renderToString(<AgentsPlatformView mode="error" message="Network failure" />);
expect(out).toContain('Network failure');
});
});

View File

@@ -1,6 +1,24 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
/**
* Tests for launchAgentsPlatform.tsx
*
* Strategy per feedback_mock_dependency_not_subject:
* - DO NOT mock agentsApi.ts itself (would pollute api.test.ts)
* - Mock axios (the underlying HTTP layer) to control API responses
* - Let real agentsApi functions run real code paths
*/
import {
afterAll,
beforeAll,
beforeEach,
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.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
@@ -9,42 +27,40 @@ mock.module('bun:bundle', () => ({
}))
// ── Analytics mock ──────────────────────────────────────────────────────────
const realAnalytics = await import('src/services/analytics/index.js')
const logEventMock = mock(() => {})
mock.module('src/services/analytics/index.js', () => ({
...realAnalytics,
logEvent: logEventMock,
logEventAsync: mock(() => Promise.resolve()),
_resetForTesting: mock(() => {}),
attachAnalyticsSink: mock(() => {}),
stripProtoFields: mock((v: unknown) => v),
}))
// ── agentsApi mock ──────────────────────────────────────────────────────────
const listMock = mock(async () => [
{
id: 'agt_1',
cron_expr: '0 9 * * 1',
prompt: 'hello world',
status: 'active',
timezone: 'UTC',
next_run: null,
},
])
const createMock = mock(async (cron: string, prompt: string) => ({
id: 'agt_new',
cron_expr: cron,
prompt,
status: 'active',
timezone: 'UTC',
next_run: null,
// ── Auth / OAuth mocks ──────────────────────────────────────────────────────
const realAuth = await import('src/utils/auth.js')
mock.module('src/utils/auth.js', () => ({
...realAuth,
getClaudeAIOAuthTokens: () => ({ accessToken: 'test-token-ap' }),
}))
const deleteMock = mock(async () => undefined)
const runMock = mock(async () => ({ run_id: 'run_123' }))
mock.module('src/commands/agents-platform/agentsApi.js', () => ({
listAgents: listMock,
createAgent: createMock,
deleteAgent: deleteMock,
runAgent: runMock,
mock.module('src/services/oauth/client.js', () => ({
getOrganizationUUID: async () => 'org-uuid-ap',
}))
mock.module('src/constants/oauth.js', () => ({
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
}))
const realTeleportApi = await import('src/utils/teleport/api.js')
mock.module('src/utils/teleport/api.js', () => ({
...realTeleportApi,
getOAuthHeaders: (token: string) => ({ Authorization: `Bearer ${token}` }),
prepareWorkspaceApiRequest: async () => ({
apiKey: 'test-workspace-key-ap',
}),
prepareApiRequest: async () => ({
apiKey: 'test-api-key-ap',
}),
}))
mock.module('src/services/auth/hostGuard.ts', () => ({
assertSubscriptionBaseUrl: () => {},
assertWorkspaceHost: () => {},
assertNoAnthropicEnvForOpenAI: () => {},
}))
// ── cron mock ───────────────────────────────────────────────────────────────
@@ -57,19 +73,42 @@ mock.module('src/utils/cron.js', () => ({
computeNextCronRun: () => null,
}))
// ── Axios mock ──────────────────────────────────────────────────────────────
const axiosGetMock = mock(async () => ({}))
const axiosPostMock = mock(async () => ({}))
const axiosDeleteMock = mock(async () => ({}))
const axiosIsAxiosError = mock((err: unknown) => {
return (
typeof err === 'object' &&
err !== null &&
'isAxiosError' in err &&
(err as { isAxiosError: boolean }).isAxiosError === true
)
})
const axiosHandle = setupAxiosMock()
axiosHandle.stubs.get = axiosGetMock
axiosHandle.stubs.post = axiosPostMock
axiosHandle.stubs.delete = axiosDeleteMock
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
let callAgentsPlatform: typeof import('../launchAgentsPlatform.js').callAgentsPlatform
beforeAll(async () => {
axiosHandle.useStubs = true
const mod = await import('../launchAgentsPlatform.js')
callAgentsPlatform = mod.callAgentsPlatform
})
afterAll(() => {
axiosHandle.useStubs = false
})
beforeEach(() => {
logEventMock.mockClear()
listMock.mockClear()
createMock.mockClear()
deleteMock.mockClear()
runMock.mockClear()
axiosGetMock.mockClear()
axiosPostMock.mockClear()
axiosDeleteMock.mockClear()
})
function makeContext() {
@@ -79,8 +118,23 @@ function makeContext() {
describe('callAgentsPlatform', () => {
test('list (empty args) calls listAgents and returns element', async () => {
const onDone = mock(() => {})
axiosGetMock.mockResolvedValueOnce({
data: {
data: [
{
id: 'agt_1',
cron_expr: '0 9 * * 1',
prompt: 'hello world',
status: 'active',
timezone: 'UTC',
next_run: null,
},
],
},
status: 200,
})
const result = await callAgentsPlatform(onDone, makeContext(), '')
expect(listMock).toHaveBeenCalledTimes(1)
expect(axiosGetMock).toHaveBeenCalledTimes(1)
expect(onDone).toHaveBeenCalledTimes(1)
expect(result).not.toBeNull()
expect(logEventMock).toHaveBeenCalledWith(
@@ -91,21 +145,43 @@ describe('callAgentsPlatform', () => {
test('list sub-command calls listAgents', async () => {
const onDone = mock(() => {})
axiosGetMock.mockResolvedValueOnce({
data: { data: [] },
status: 200,
})
await callAgentsPlatform(onDone, makeContext(), 'list')
expect(listMock).toHaveBeenCalledTimes(1)
expect(axiosGetMock).toHaveBeenCalledTimes(1)
})
test('create with valid cron calls createAgent', async () => {
const onDone = mock(() => {})
axiosPostMock.mockResolvedValueOnce({
data: {
id: 'agt_new',
cron_expr: '0 9 * * 1',
prompt: 'Run standup',
status: 'active',
timezone: 'UTC',
next_run: null,
},
status: 201,
})
const result = await callAgentsPlatform(
onDone,
makeContext(),
'create 0 9 * * 1 Run standup',
)
expect(createMock).toHaveBeenCalledTimes(1)
const [cron, prompt] = createMock.mock.calls[0] as [string, string]
expect(cron).toBe('0 9 * * 1')
expect(prompt).toBe('Run standup')
expect(axiosPostMock).toHaveBeenCalledTimes(1)
const callArgs = axiosPostMock.mock.calls[0] as unknown as [
string,
unknown,
unknown,
]
const url = callArgs[0]
const body = callArgs[1] as Record<string, unknown>
expect(url).toContain('/v1/agents')
expect(body.cron_expr).toBe('0 9 * * 1')
expect(body.prompt).toBe('Run standup')
expect(result).not.toBeNull()
expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_create',
@@ -122,7 +198,7 @@ describe('callAgentsPlatform', () => {
'create INVALID INVALID * * * my prompt',
)
// cron = 'INVALID INVALID * * *', mock returns null → no API call
expect(createMock).not.toHaveBeenCalled()
expect(axiosPostMock).not.toHaveBeenCalled()
expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_failed',
expect.anything(),
@@ -131,12 +207,18 @@ describe('callAgentsPlatform', () => {
test('delete with id calls deleteAgent', async () => {
const onDone = mock(() => {})
axiosDeleteMock.mockResolvedValueOnce({ data: {}, status: 204 })
const result = await callAgentsPlatform(
onDone,
makeContext(),
'delete agt_abc',
)
expect(deleteMock).toHaveBeenCalledWith('agt_abc')
expect(axiosDeleteMock).toHaveBeenCalledTimes(1)
const callArgs = axiosDeleteMock.mock.calls[0] as unknown as [
string,
unknown,
]
expect(callArgs[0]).toContain('agt_abc')
expect(result).not.toBeNull()
expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_delete',
@@ -146,12 +228,23 @@ describe('callAgentsPlatform', () => {
test('run with id calls runAgent', async () => {
const onDone = mock(() => {})
axiosPostMock.mockResolvedValueOnce({
data: { run_id: 'run_123' },
status: 200,
})
const result = await callAgentsPlatform(
onDone,
makeContext(),
'run agt_xyz',
)
expect(runMock).toHaveBeenCalledWith('agt_xyz')
expect(axiosPostMock).toHaveBeenCalledTimes(1)
const callArgs = axiosPostMock.mock.calls[0] as unknown as [
string,
unknown,
unknown,
]
expect(callArgs[0]).toContain('agt_xyz')
expect(callArgs[0]).toContain('/run')
expect(result).not.toBeNull()
expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_run',
@@ -167,11 +260,11 @@ describe('callAgentsPlatform', () => {
'tengu_agents_platform_failed',
expect.anything(),
)
expect(listMock).not.toHaveBeenCalled()
expect(axiosGetMock).not.toHaveBeenCalled()
})
test('listAgents API error → error view returned', async () => {
listMock.mockRejectedValueOnce(new Error('network error'))
axiosGetMock.mockRejectedValueOnce(new Error('network error'))
const onDone = mock(() => {})
const result = await callAgentsPlatform(onDone, makeContext(), 'list')
expect(result).not.toBeNull()
@@ -183,6 +276,10 @@ describe('callAgentsPlatform', () => {
test('started event fires on every call', async () => {
const onDone = mock(() => {})
axiosGetMock.mockResolvedValueOnce({
data: { data: [] },
status: 200,
})
await callAgentsPlatform(onDone, makeContext(), '')
expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_started',
@@ -190,10 +287,10 @@ describe('callAgentsPlatform', () => {
)
})
// ── Error-path branches (lines 77-86, 100-109, 128-136) ──────────────────
// ── Error-path branches ──────────────────────────────────────────────────
test('createAgent API error → error view returned', async () => {
createMock.mockRejectedValueOnce(new Error('subscription required'))
axiosPostMock.mockRejectedValueOnce(new Error('subscription required'))
const onDone = mock(() => {})
const result = await callAgentsPlatform(
onDone,
@@ -212,7 +309,7 @@ describe('callAgentsPlatform', () => {
})
test('deleteAgent API error → error view returned', async () => {
deleteMock.mockRejectedValueOnce(new Error('not found'))
axiosDeleteMock.mockRejectedValueOnce(new Error('not found'))
const onDone = mock(() => {})
const result = await callAgentsPlatform(
onDone,
@@ -231,7 +328,7 @@ describe('callAgentsPlatform', () => {
})
test('runAgent API error → error view returned', async () => {
runMock.mockRejectedValueOnce(new Error('run failed'))
axiosPostMock.mockRejectedValueOnce(new Error('run failed'))
const onDone = mock(() => {})
const result = await callAgentsPlatform(
onDone,
@@ -253,7 +350,7 @@ describe('callAgentsPlatform', () => {
const onDone = mock(() => {})
// Only 4 cron fields — parseArgs returns invalid
await callAgentsPlatform(onDone, makeContext(), 'create 0 9 * *')
expect(createMock).not.toHaveBeenCalled()
expect(axiosPostMock).not.toHaveBeenCalled()
expect(logEventMock).toHaveBeenCalledWith(
'tengu_agents_platform_failed',
expect.anything(),