diff --git a/CLAUDE.md b/CLAUDE.md index 3414de2a9..a31381bc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -263,6 +263,18 @@ Feature flags control which functionality is enabled at runtime. 代码中统一 被迫 mock 的根源:`log.ts` / `debug.ts` → `bootstrap/state.ts`(模块级 `realpathSync` / `randomUUID` 副作用)。必须 mock 的模块:`log.ts`、`debug.ts`、`bun:bundle`、`settings/settings.js`、`config.ts`、`auth.ts`、第三方网络库。 +**`log.ts` 和 `debug.ts` 使用共享 mock**(`tests/mocks/log.ts` / `tests/mocks/debug.ts`),不要在测试文件中内联 mock 定义。使用方式: + +```ts +import { logMock } from "../../../tests/mocks/log"; +mock.module("src/utils/log.ts", logMock); + +import { debugMock } from "../../../../tests/mocks/debug"; +mock.module("src/utils/debug.ts", debugMock); +``` + +源文件导出变更时只需更新 `tests/mocks/` 下的对应文件,不需要逐个修改测试。 + 不要 mock:纯函数模块(`errors.ts`、`stringUtils.js`)、mock 值与真实实现相同的模块、mock 路径与实际 import 不匹配的模块。 路径规则:统一用 `.ts` 扩展名 + `src/*` 别名路径,禁止双重 mock 同一模块。 diff --git a/packages/builtin-tools/src/tools/AgentTool/__tests__/agentToolUtils.test.ts b/packages/builtin-tools/src/tools/AgentTool/__tests__/agentToolUtils.test.ts index 21d8a70a0..90525baad 100644 --- a/packages/builtin-tools/src/tools/AgentTool/__tests__/agentToolUtils.test.ts +++ b/packages/builtin-tools/src/tools/AgentTool/__tests__/agentToolUtils.test.ts @@ -1,4 +1,5 @@ import { mock, describe, expect, test } from "bun:test"; +import { debugMock } from "../../../../../../tests/mocks/debug"; // ─── Mocks for agentToolUtils.ts dependencies ─── // Only mock modules that are truly unavailable or cause side effects. @@ -87,20 +88,7 @@ mock.module("src/tasks/LocalAgentTask/LocalAgentTask.js", () => ({ updateProgressFromMessage: noop, })); -mock.module("src/utils/debug.ts", () => ({ - getMinDebugLogLevel: () => "warn", - isDebugMode: () => false, - enableDebugLogging: () => false, - getDebugFilter: () => null, - isDebugToStdErr: () => false, - getDebugFilePath: () => null, - setHasFormattedOutput: noop, - getHasFormattedOutput: () => false, - flushDebugLogs: async () => {}, - logForDebugging: noop, - getDebugLogPath: () => "", - logAntError: noop, -})); +mock.module("src/utils/debug.ts", debugMock); mock.module("src/utils/errors.js", () => ({ ClaudeError: class extends Error {}, diff --git a/packages/builtin-tools/src/tools/CtxInspectTool/__tests__/CtxInspectTool.test.ts b/packages/builtin-tools/src/tools/CtxInspectTool/__tests__/CtxInspectTool.test.ts index 36b842e4c..5270b50f0 100644 --- a/packages/builtin-tools/src/tools/CtxInspectTool/__tests__/CtxInspectTool.test.ts +++ b/packages/builtin-tools/src/tools/CtxInspectTool/__tests__/CtxInspectTool.test.ts @@ -1,21 +1,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { logMock } from '../../../../../../tests/mocks/log' -mock.module('src/utils/log.ts', () => ({ - logError: () => {}, - logToFile: () => {}, - getLogDisplayTitle: () => '', - logEvent: () => {}, - logMCPError: () => {}, - logMCPDebug: () => {}, - dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, '-'), - getLogFilePath: () => '/tmp/mock-log', - attachErrorLogSink: () => {}, - getInMemoryErrors: () => [], - loadErrorLogs: async () => [], - getErrorLogByIndex: async () => null, - captureAPIRequest: () => {}, - _resetErrorLogForTesting: () => {}, -})) +mock.module('src/utils/log.ts', logMock) mock.module('src/services/tokenEstimation.ts', () => ({ roughTokenCountEstimation: (text: string) => Math.ceil(text.length / 4), diff --git a/packages/builtin-tools/src/tools/FileEditTool/__tests__/utils.test.ts b/packages/builtin-tools/src/tools/FileEditTool/__tests__/utils.test.ts index 1cfab5fab..eca237141 100644 --- a/packages/builtin-tools/src/tools/FileEditTool/__tests__/utils.test.ts +++ b/packages/builtin-tools/src/tools/FileEditTool/__tests__/utils.test.ts @@ -1,22 +1,8 @@ import { mock, describe, expect, test } from "bun:test"; +import { logMock } from "../../../../../../tests/mocks/log"; // Mock log.ts to cut the heavy dependency chain -mock.module("src/utils/log.ts", () => ({ - logError: () => {}, - logToFile: () => {}, - getLogDisplayTitle: () => "", - logEvent: () => {}, - logMCPError: () => {}, - logMCPDebug: () => {}, - dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, "-"), - getLogFilePath: () => "/tmp/mock-log", - attachErrorLogSink: () => {}, - getInMemoryErrors: () => [], - loadErrorLogs: async () => [], - getErrorLogByIndex: async () => null, - captureAPIRequest: () => {}, - _resetErrorLogForTesting: () => {}, -})); +mock.module("src/utils/log.ts", logMock); const { normalizeQuotes, diff --git a/packages/builtin-tools/src/tools/LSPTool/__tests__/formatters.test.ts b/packages/builtin-tools/src/tools/LSPTool/__tests__/formatters.test.ts index 325900079..035cf88cc 100644 --- a/packages/builtin-tools/src/tools/LSPTool/__tests__/formatters.test.ts +++ b/packages/builtin-tools/src/tools/LSPTool/__tests__/formatters.test.ts @@ -1,9 +1,7 @@ import { mock, describe, expect, test } from "bun:test"; +import { debugMock } from "../../../../../../tests/mocks/debug"; -mock.module("src/utils/debug.ts", () => ({ - logForDebugging: () => {}, - isDebugMode: () => false, -})); +mock.module("src/utils/debug.ts", debugMock); const { formatGoToDefinitionResult, diff --git a/src/services/langfuse/__tests__/langfuse.test.ts b/src/services/langfuse/__tests__/langfuse.test.ts index 8d9d0f535..48afa2372 100644 --- a/src/services/langfuse/__tests__/langfuse.test.ts +++ b/src/services/langfuse/__tests__/langfuse.test.ts @@ -1,4 +1,5 @@ import { mock, describe, test, expect, beforeEach } from 'bun:test' +import { debugMock } from '../../../../tests/mocks/debug' // Mock @langfuse/otel before any imports const mockForceFlush = mock(() => Promise.resolve()) @@ -71,9 +72,7 @@ mock.module('@langfuse/tracing', () => ({ })) // Mock debug logger -mock.module('src/utils/debug.ts', () => ({ - logForDebugging: mock(() => {}), -})) +mock.module('src/utils/debug.ts', debugMock) // Mock user data — resolveLangfuseUserId uses getCoreUserData().email and .deviceId mock.module('src/utils/user.js', () => ({ diff --git a/src/services/mcp/__tests__/officialRegistry.test.ts b/src/services/mcp/__tests__/officialRegistry.test.ts index 12ce50184..bcc9da35d 100644 --- a/src/services/mcp/__tests__/officialRegistry.test.ts +++ b/src/services/mcp/__tests__/officialRegistry.test.ts @@ -1,11 +1,10 @@ import { mock, describe, expect, test, afterEach } from "bun:test"; +import { debugMock } from "../../../../tests/mocks/debug"; mock.module("axios", () => ({ default: { get: async () => ({ data: { servers: [] } }) }, })); -mock.module("src/utils/debug.ts", () => ({ - logForDebugging: () => {}, -})); +mock.module("src/utils/debug.ts", debugMock); const { isOfficialMcpUrl, resetOfficialMcpUrlsForTesting } = await import( "../officialRegistry" diff --git a/src/utils/__tests__/envValidation.test.ts b/src/utils/__tests__/envValidation.test.ts index 984d5cfde..0ef8d18dd 100644 --- a/src/utils/__tests__/envValidation.test.ts +++ b/src/utils/__tests__/envValidation.test.ts @@ -1,20 +1,8 @@ import { mock, describe, expect, test } from "bun:test"; +import { debugMock } from "../../../tests/mocks/debug"; // Mock debug.ts to cut bootstrap/state dependency chain -mock.module("src/utils/debug.ts", () => ({ - logForDebugging: () => {}, - isDebugMode: () => false, - isDebugToStdErr: () => false, - getDebugFilePath: () => null, - getDebugFilter: () => null, - getMinDebugLogLevel: () => "debug", - getDebugLogPath: () => "/tmp/mock-debug.log", - flushDebugLogs: async () => {}, - enableDebugLogging: () => false, - setHasFormattedOutput: () => {}, - getHasFormattedOutput: () => false, - logAntError: () => {}, -})); +mock.module("src/utils/debug.ts", debugMock); const { validateBoundedIntEnvVar } = await import("../envValidation"); diff --git a/src/utils/__tests__/json.test.ts b/src/utils/__tests__/json.test.ts index 42a624b10..1d4b441f9 100644 --- a/src/utils/__tests__/json.test.ts +++ b/src/utils/__tests__/json.test.ts @@ -1,12 +1,8 @@ import { mock, describe, expect, test } from "bun:test"; +import { logMock } from "../../../tests/mocks/log"; // Mock log.ts to cut the heavy dependency chain (log.ts → bootstrap/state.ts → analytics) -mock.module("src/utils/log.ts", () => ({ - logError: () => {}, - logToFile: () => {}, - getLogDisplayTitle: () => "", - logEvent: () => {}, -})); +mock.module("src/utils/log.ts", logMock); const { safeParseJSON, safeParseJSONC, parseJSONL, addItemToJSONCArray } = await import("../json"); diff --git a/src/utils/__tests__/memoize.test.ts b/src/utils/__tests__/memoize.test.ts index 4cdff9968..41e4a3349 100644 --- a/src/utils/__tests__/memoize.test.ts +++ b/src/utils/__tests__/memoize.test.ts @@ -1,12 +1,8 @@ import { mock, describe, expect, test, beforeEach } from "bun:test"; +import { logMock } from "../../../tests/mocks/log"; // Mock log.ts to cut the bootstrap/state dependency chain -mock.module("src/utils/log.ts", () => ({ - logError: () => {}, - logToFile: () => {}, - getLogDisplayTitle: () => "", - logEvent: () => {}, -})); +mock.module("src/utils/log.ts", logMock); const { memoizeWithTTL, memoizeWithTTLAsync, memoizeWithLRU } = await import( "../memoize" diff --git a/src/utils/__tests__/tokens.test.ts b/src/utils/__tests__/tokens.test.ts index 4e7b905c5..cf3eba2bb 100644 --- a/src/utils/__tests__/tokens.test.ts +++ b/src/utils/__tests__/tokens.test.ts @@ -1,22 +1,8 @@ import { mock, describe, expect, test } from "bun:test"; +import { logMock } from "../../../tests/mocks/log"; // Mock heavy dependency chain: tokenEstimation.ts → log.ts → bootstrap/state.ts -mock.module("src/utils/log.ts", () => ({ - logError: () => {}, - logToFile: () => {}, - getLogDisplayTitle: () => "", - logEvent: () => {}, - logMCPError: () => {}, - logMCPDebug: () => {}, - dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, "-"), - getLogFilePath: () => "/tmp/mock-log", - attachErrorLogSink: () => {}, - getInMemoryErrors: () => [], - loadErrorLogs: async () => [], - getErrorLogByIndex: async () => null, - captureAPIRequest: () => {}, - _resetErrorLogForTesting: () => {}, -})); +mock.module("src/utils/log.ts", logMock); // Mock tokenEstimation to avoid pulling in API provider deps mock.module("src/services/tokenEstimation.ts", () => ({ diff --git a/src/utils/permissions/__tests__/PermissionMode.test.ts b/src/utils/permissions/__tests__/PermissionMode.test.ts index d07596d16..0f2d065d9 100644 --- a/src/utils/permissions/__tests__/PermissionMode.test.ts +++ b/src/utils/permissions/__tests__/PermissionMode.test.ts @@ -1,11 +1,7 @@ import { mock, describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { logMock } from "../../../../tests/mocks/log"; -mock.module("src/utils/log.ts", () => ({ - logError: () => {}, - logToFile: () => {}, - getLogDisplayTitle: () => "", - logEvent: () => {}, -})); +mock.module("src/utils/log.ts", logMock); const { isExternalPermissionMode, diff --git a/src/utils/permissions/__tests__/permissions.test.ts b/src/utils/permissions/__tests__/permissions.test.ts index 01b6ace54..d83c2b153 100644 --- a/src/utils/permissions/__tests__/permissions.test.ts +++ b/src/utils/permissions/__tests__/permissions.test.ts @@ -1,24 +1,10 @@ import { mock, describe, expect, test } from 'bun:test' +import { logMock } from '../../../../tests/mocks/log' import { createFileStateCacheWithSizeLimit } from '../../../utils/fileStateCache.js' import { createSubagentContext } from '../../../utils/forkedAgent.js' import { getEmptyToolPermissionContext } from '../../../Tool.js' -mock.module('src/utils/log.ts', () => ({ - logError: () => {}, - logToFile: () => {}, - getLogDisplayTitle: () => '', - logEvent: () => {}, - logMCPError: () => {}, - logMCPDebug: () => {}, - dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, '-'), - getLogFilePath: () => '/tmp/mock-log', - attachErrorLogSink: () => {}, - getInMemoryErrors: () => [], - loadErrorLogs: async () => [], - getErrorLogByIndex: async () => null, - captureAPIRequest: () => {}, - _resetErrorLogForTesting: () => {}, -})) +mock.module('src/utils/log.ts', logMock) const { getDenyRuleForTool, diff --git a/src/utils/shell/__tests__/outputLimits.test.ts b/src/utils/shell/__tests__/outputLimits.test.ts index 7abd319ff..b1418418e 100644 --- a/src/utils/shell/__tests__/outputLimits.test.ts +++ b/src/utils/shell/__tests__/outputLimits.test.ts @@ -1,20 +1,8 @@ import { mock, describe, expect, test, afterEach } from "bun:test"; +import { debugMock } from "../../../../tests/mocks/debug"; // Mock debug.ts to cut the bootstrap/state dependency chain -mock.module("src/utils/debug.ts", () => ({ - logForDebugging: () => {}, - isDebugMode: () => false, - isDebugToStdErr: () => false, - getDebugFilePath: () => null, - getDebugFilter: () => null, - getMinDebugLogLevel: () => "debug", - getDebugLogPath: () => "/tmp/mock-debug.log", - flushDebugLogs: async () => {}, - enableDebugLogging: () => false, - setHasFormattedOutput: () => {}, - getHasFormattedOutput: () => false, - logAntError: () => {}, -})); +mock.module("src/utils/debug.ts", debugMock); const { getMaxOutputLength, diff --git a/tests/mocks/debug.ts b/tests/mocks/debug.ts new file mode 100644 index 000000000..0dc981668 --- /dev/null +++ b/tests/mocks/debug.ts @@ -0,0 +1,25 @@ +/** + * Shared mock for src/utils/debug.ts + * + * Cuts the bootstrap/state.ts dependency chain (module-level realpathSync + randomUUID). + * Must be called via mock.module("src/utils/debug.ts", debugMock) BEFORE any import that + * transitively depends on debug.ts. + * + * Exported as a factory so each call produces a fresh object (mock.module requirement). + */ +export function debugMock() { + return { + getMinDebugLogLevel: () => "debug" as const, + isDebugMode: () => false, + enableDebugLogging: () => false, + getDebugFilter: () => null, + isDebugToStdErr: () => false, + getDebugFilePath: () => null as string | null, + setHasFormattedOutput: () => {}, + getHasFormattedOutput: () => false, + flushDebugLogs: async () => {}, + logForDebugging: () => {}, + getDebugLogPath: () => "/tmp/mock-debug.log", + logAntError: () => {}, + } +} diff --git a/tests/mocks/log.ts b/tests/mocks/log.ts new file mode 100644 index 000000000..6661a0971 --- /dev/null +++ b/tests/mocks/log.ts @@ -0,0 +1,24 @@ +/** + * Shared mock for src/utils/log.ts + * + * Cuts the bootstrap/state.ts dependency chain (module-level realpathSync + randomUUID). + * Must be called via mock.module("src/utils/log.ts", logMock) BEFORE any import that + * transitively depends on log.ts. + * + * Exported as a factory so each call produces a fresh object (mock.module requirement). + */ +export function logMock() { + return { + logError: () => {}, + getLogDisplayTitle: () => "", + dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, "-"), + attachErrorLogSink: () => {}, + getInMemoryErrors: () => [] as Array<{ error: string; timestamp: string }>, + loadErrorLogs: async () => [], + getErrorLogByIndex: async () => null, + logMCPError: () => {}, + logMCPDebug: () => {}, + captureAPIRequest: () => {}, + _resetErrorLogForTesting: () => {}, + } +}