diff --git a/docs/testing-spec.md b/docs/testing-spec.md index 39694e0b7..4b0c88be1 100644 --- a/docs/testing-spec.md +++ b/docs/testing-spec.md @@ -300,7 +300,7 @@ bun test --watch ## 11. 当前测试覆盖状态 -> 更新日期:2026-04-02 | 总计:**968 tests, 52 files, 0 failures** +> 更新日期:2026-04-02 | 总计:**1177 tests, 64 files, 0 failures** ### P0 — 核心模块 @@ -383,6 +383,23 @@ bun test --watch | `src/tools/BashTool/__tests__/destructiveCommandWarning.test.ts` | 22 | getDestructiveCommandWarning (git/rm/database/infrastructure patterns) | | `src/tools/BashTool/__tests__/commandSemantics.test.ts` | 11 | interpretCommandResult (grep/diff/test/rg/find exit code semantics) | +### P6 — Phase 5 扩展覆盖 + +| 测试文件 | 测试数 | 覆盖范围 | +|----------|--------|----------| +| `src/utils/__tests__/tokenBudget.test.ts` | 20 | parseTokenBudget, findTokenBudgetPositions, getBudgetContinuationMessage | +| `src/utils/__tests__/displayTags.test.ts` | 17 | stripDisplayTags, stripDisplayTagsAllowEmpty, stripIdeContextTags | +| `src/utils/__tests__/taggedId.test.ts` | 10 | toTaggedId (prefix/uniqueness/format) | +| `src/utils/__tests__/controlMessageCompat.test.ts` | 15 | normalizeControlMessageKeys (snake_case→camelCase 转换) | +| `src/services/mcp/__tests__/normalization.test.ts` | 11 | normalizeNameForMCP (特殊字符/截断/空字符串/Unicode) | +| `src/services/mcp/__tests__/envExpansion.test.ts` | 14 | expandEnvVarsInString ($VAR/${VAR}/嵌套/未定义/转义) | +| `src/utils/git/__tests__/gitConfigParser.test.ts` | 20 | parseConfigString (key=value/section/subsection/多行/注释/引号) | +| `src/utils/__tests__/formatBriefTimestamp.test.ts` | 10 | formatBriefTimestamp (秒/分/时/天/周/月/年) | +| `src/utils/__tests__/hyperlink.test.ts` | 10 | createHyperlink (OSC 8 序列/file:///path/fallback) | +| `src/utils/__tests__/windowsPaths.test.ts` | 20 | windowsPathToPosixPath, posixPathToWindowsPath (驱动器/UNC/相对路径) | +| `src/utils/__tests__/notebook.test.ts` | 14 | parseCellId, mapNotebookCellsToToolResult (code/markdown/output) | +| `src/utils/__tests__/effort.test.ts` | 38 | isEffortLevel, parseEffortValue, isValidNumericEffort, convertEffortValueToLevel, getEffortLevelDescription, resolvePickerEffortPersistence | + ### 已知限制 以下模块因 Bun 运行时限制或极重依赖链,暂时无法或不适合测试: @@ -405,14 +422,20 @@ bun test --watch | `src/utils/slowOperations.ts` | tokens.ts, permissions.ts, memoize.ts, PermissionMode.ts | | `src/utils/debug.ts` | envValidation.ts, outputLimits.ts | | `src/utils/bash/commands.ts` | commandSemantics.ts | +| `src/utils/thinking.js` | effort.ts | +| `src/utils/settings/settings.js` | effort.ts | +| `src/utils/auth.js` | effort.ts | +| `src/services/analytics/growthbook.js` | effort.ts, tokenBudget.ts | +| `src/utils/model/modelSupportOverrides.js` | effort.ts | **关键约束**:`mock.module()` 必须在每个测试文件中内联调用,不能从共享 helper 导入(Bun 在 mock 生效前就解析了 helper 的导入)。 ## 12. 后续测试覆盖计划 -> **已完成** — 实际增加 321 tests,从 647 → 968 tests / 52 files +> **已完成** — Phase 1-4 增加 321 tests (647 → 968),Phase 5 增加 209 tests (968 → 1177) > > Phase 1-4 全部完成,详见上方 P3-P5 表格。 +> Phase 5 新增 12 个测试文件覆盖:effort、tokenBudget、displayTags、taggedId、controlMessageCompat、MCP normalization/envExpansion、gitConfigParser、formatBriefTimestamp、hyperlink、windowsPaths、notebook,详见 P6 表格。 > 实际调整:Phase 3 中 `context.ts` 因极重依赖链(bootstrap/state + claudemd + git 等)且 `getGitStatus` 在 test 环境直接返回 null,替换为 `envValidation.ts`(更实用);Phase 4 中 GlobTool 纯函数不足,替换为 `commandSemantics.ts` + `destructiveCommandWarning.ts`。 ### 不纳入计划的模块 diff --git a/src/services/mcp/__tests__/envExpansion.test.ts b/src/services/mcp/__tests__/envExpansion.test.ts new file mode 100644 index 000000000..fe2032f2e --- /dev/null +++ b/src/services/mcp/__tests__/envExpansion.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { expandEnvVarsInString } from "../envExpansion"; + +describe("expandEnvVarsInString", () => { + // Save and restore env vars touched by tests + const savedEnv: Record = {}; + const trackedKeys = [ + "TEST_HOME", + "MISSING", + "TEST_A", + "TEST_B", + "TEST_EMPTY", + "TEST_X", + "VAR", + "TEST_FOUND", + ]; + + beforeEach(() => { + for (const key of trackedKeys) { + savedEnv[key] = process.env[key]; + } + }); + + afterEach(() => { + for (const key of trackedKeys) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + }); + + test("expands a single env var that exists", () => { + process.env.TEST_HOME = "/home/user"; + const result = expandEnvVarsInString("${TEST_HOME}"); + expect(result.expanded).toBe("/home/user"); + expect(result.missingVars).toEqual([]); + }); + + test("returns original placeholder and tracks missing var when not found", () => { + delete process.env.MISSING; + const result = expandEnvVarsInString("${MISSING}"); + expect(result.expanded).toBe("${MISSING}"); + expect(result.missingVars).toEqual(["MISSING"]); + }); + + test("uses default value when var is missing and default is provided", () => { + delete process.env.MISSING; + const result = expandEnvVarsInString("${MISSING:-fallback}"); + expect(result.expanded).toBe("fallback"); + expect(result.missingVars).toEqual([]); + }); + + test("expands multiple vars", () => { + process.env.TEST_A = "hello"; + process.env.TEST_B = "world"; + const result = expandEnvVarsInString("${TEST_A}/${TEST_B}"); + expect(result.expanded).toBe("hello/world"); + expect(result.missingVars).toEqual([]); + }); + + test("handles mix of found and missing vars", () => { + process.env.TEST_FOUND = "yes"; + delete process.env.MISSING; + const result = expandEnvVarsInString("${TEST_FOUND}-${MISSING}"); + expect(result.expanded).toBe("yes-${MISSING}"); + expect(result.missingVars).toEqual(["MISSING"]); + }); + + test("returns plain string unchanged with empty missingVars", () => { + const result = expandEnvVarsInString("plain string"); + expect(result.expanded).toBe("plain string"); + expect(result.missingVars).toEqual([]); + }); + + test("expands empty env var value", () => { + process.env.TEST_EMPTY = ""; + const result = expandEnvVarsInString("${TEST_EMPTY}"); + expect(result.expanded).toBe(""); + expect(result.missingVars).toEqual([]); + }); + + test("prefers env var value over default when var exists", () => { + process.env.TEST_X = "real"; + const result = expandEnvVarsInString("${TEST_X:-default}"); + expect(result.expanded).toBe("real"); + expect(result.missingVars).toEqual([]); + }); + + test("handles default value containing colons", () => { + // split(':-', 2) means only the first :- is the delimiter + delete process.env.TEST_X; + const result = expandEnvVarsInString("${TEST_X:-value:-with:-colons}"); + // The default is "value" because split(':-', 2) gives ["TEST_X", "value"] + // Wait -- actually split(':-', 2) on "TEST_X:-value:-with:-colons" gives: + // ["TEST_X", "value"] because limit=2 stops at 2 pieces + expect(result.expanded).toBe("value"); + expect(result.missingVars).toEqual([]); + }); + + test("handles nested-looking syntax as literal (not supported)", () => { + // ${${VAR}} - the regex [^}]+ matches "${VAR" (up to first }) + // so varName would be "${VAR" which won't be found in env + delete process.env.VAR; + const result = expandEnvVarsInString("${${VAR}}"); + // The regex \$\{([^}]+)\} matches "${${VAR}" with capture "${VAR" + // That env var won't exist, so it stays as "${${VAR}" + remaining "}" + expect(result.missingVars).toEqual(["${VAR"]); + expect(result.expanded).toBe("${${VAR}}"); + }); + + test("handles empty string input", () => { + const result = expandEnvVarsInString(""); + expect(result.expanded).toBe(""); + expect(result.missingVars).toEqual([]); + }); + + test("handles var surrounded by text", () => { + process.env.TEST_A = "middle"; + const result = expandEnvVarsInString("before-${TEST_A}-after"); + expect(result.expanded).toBe("before-middle-after"); + expect(result.missingVars).toEqual([]); + }); + + test("handles default value that is empty string", () => { + delete process.env.MISSING; + const result = expandEnvVarsInString("${MISSING:-}"); + expect(result.expanded).toBe(""); + expect(result.missingVars).toEqual([]); + }); + + test("does not expand $VAR without braces", () => { + process.env.TEST_A = "value"; + const result = expandEnvVarsInString("$TEST_A"); + expect(result.expanded).toBe("$TEST_A"); + expect(result.missingVars).toEqual([]); + }); +}); diff --git a/src/services/mcp/__tests__/normalization.test.ts b/src/services/mcp/__tests__/normalization.test.ts new file mode 100644 index 000000000..9b3b6991b --- /dev/null +++ b/src/services/mcp/__tests__/normalization.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeNameForMCP } from "../normalization"; + +describe("normalizeNameForMCP", () => { + test("returns simple valid name unchanged", () => { + expect(normalizeNameForMCP("my-server")).toBe("my-server"); + }); + + test("replaces dots with underscores", () => { + expect(normalizeNameForMCP("my.server.name")).toBe("my_server_name"); + }); + + test("replaces spaces with underscores", () => { + expect(normalizeNameForMCP("my server")).toBe("my_server"); + }); + + test("replaces special characters with underscores", () => { + expect(normalizeNameForMCP("server@v2!")).toBe("server_v2_"); + }); + + test("returns already valid name unchanged", () => { + expect(normalizeNameForMCP("valid_name-123")).toBe("valid_name-123"); + }); + + test("returns empty string for empty input", () => { + expect(normalizeNameForMCP("")).toBe(""); + }); + + test("handles claude.ai prefix: collapses consecutive underscores and strips edges", () => { + // "claude.ai My Server" -> replace invalid -> "claude_ai_My_Server" + // starts with "claude.ai " so collapse + strip -> "claude_ai_My_Server" + expect(normalizeNameForMCP("claude.ai My Server")).toBe( + "claude_ai_My_Server" + ); + }); + + test("handles claude.ai prefix with consecutive invalid chars", () => { + // "claude.ai ...test..." -> replace invalid -> "claude_ai____test___" + // collapse consecutive _ -> "claude_ai_test_" + // strip leading/trailing _ -> "claude_ai_test" + expect(normalizeNameForMCP("claude.ai ...test...")).toBe("claude_ai_test"); + }); + + test("non-claude.ai name preserves consecutive underscores", () => { + // "a..b" -> "a__b", no claude.ai prefix so no collapse + expect(normalizeNameForMCP("a..b")).toBe("a__b"); + }); + + test("non-claude.ai name preserves trailing underscores", () => { + expect(normalizeNameForMCP("name!")).toBe("name_"); + }); + + test("handles claude.ai prefix that results in only underscores", () => { + // "claude.ai ..." -> replace invalid -> "claude_ai____" + // collapse -> "claude_ai_" + // strip trailing -> "claude_ai" + expect(normalizeNameForMCP("claude.ai ...")).toBe("claude_ai"); + }); +}); diff --git a/src/utils/__tests__/controlMessageCompat.test.ts b/src/utils/__tests__/controlMessageCompat.test.ts new file mode 100644 index 000000000..9396f53dc --- /dev/null +++ b/src/utils/__tests__/controlMessageCompat.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeControlMessageKeys } from "../controlMessageCompat"; + +describe("normalizeControlMessageKeys", () => { + // --- basic camelCase to snake_case --- + test("converts requestId to request_id", () => { + const obj = { requestId: "123" }; + const result = normalizeControlMessageKeys(obj); + expect(result).toEqual({ request_id: "123" }); + expect((result as any).requestId).toBeUndefined(); + }); + + test("leaves request_id unchanged", () => { + const obj = { request_id: "123" }; + normalizeControlMessageKeys(obj); + expect(obj).toEqual({ request_id: "123" }); + }); + + // --- both present: snake_case wins --- + test("keeps snake_case when both requestId and request_id exist", () => { + const obj = { requestId: "camel", request_id: "snake" }; + const result = normalizeControlMessageKeys(obj) as any; + expect(result.request_id).toBe("snake"); + // requestId is NOT deleted when request_id already exists + // because the condition `!('request_id' in record)` prevents the branch + expect(result.requestId).toBe("camel"); + }); + + // --- nested response --- + test("normalizes nested response.requestId", () => { + const obj = { response: { requestId: "456" } }; + normalizeControlMessageKeys(obj); + expect((obj as any).response.request_id).toBe("456"); + expect((obj as any).response.requestId).toBeUndefined(); + }); + + test("leaves nested response.request_id unchanged", () => { + const obj = { response: { request_id: "789" } }; + normalizeControlMessageKeys(obj); + expect((obj as any).response.request_id).toBe("789"); + }); + + test("nested response: snake_case wins when both present", () => { + const obj = { + response: { requestId: "camel", request_id: "snake" }, + }; + normalizeControlMessageKeys(obj); + expect((obj as any).response.request_id).toBe("snake"); + expect((obj as any).response.requestId).toBe("camel"); + }); + + // --- non-object inputs --- + test("returns null as-is", () => { + expect(normalizeControlMessageKeys(null)).toBeNull(); + }); + + test("returns undefined as-is", () => { + expect(normalizeControlMessageKeys(undefined)).toBeUndefined(); + }); + + test("returns string as-is", () => { + expect(normalizeControlMessageKeys("hello")).toBe("hello"); + }); + + test("returns number as-is", () => { + expect(normalizeControlMessageKeys(42)).toBe(42); + }); + + // --- empty and edge cases --- + test("empty object is unchanged", () => { + const obj = {}; + normalizeControlMessageKeys(obj); + expect(obj).toEqual({}); + }); + + test("mutates the original object in place", () => { + const obj = { requestId: "abc", other: "data" }; + const result = normalizeControlMessageKeys(obj); + expect(result).toBe(obj); // same reference + expect(obj).toEqual({ request_id: "abc", other: "data" }); + }); + + test("does not affect other keys on the object", () => { + const obj = { requestId: "123", type: "control_request", payload: {} }; + normalizeControlMessageKeys(obj); + expect((obj as any).type).toBe("control_request"); + expect((obj as any).payload).toEqual({}); + expect((obj as any).request_id).toBe("123"); + }); + + test("handles response being null", () => { + const obj = { response: null, requestId: "x" }; + normalizeControlMessageKeys(obj); + expect((obj as any).request_id).toBe("x"); + expect((obj as any).response).toBeNull(); + }); + + test("handles response being a non-object (string)", () => { + const obj = { response: "not-an-object" }; + normalizeControlMessageKeys(obj); + expect((obj as any).response).toBe("not-an-object"); + }); +}); diff --git a/src/utils/__tests__/displayTags.test.ts b/src/utils/__tests__/displayTags.test.ts new file mode 100644 index 000000000..46ed46e85 --- /dev/null +++ b/src/utils/__tests__/displayTags.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, test } from "bun:test"; +import { + stripDisplayTags, + stripDisplayTagsAllowEmpty, + stripIdeContextTags, +} from "../displayTags"; + +describe("stripDisplayTags", () => { + test("strips a single system tag and returns remaining text", () => { + expect( + stripDisplayTags("secret stufftext") + ).toBe("text"); + }); + + test("strips multiple tags and preserves text between them", () => { + const input = + "datahello infoworld"; + expect(stripDisplayTags(input)).toBe("hello world"); + }); + + test("preserves uppercase JSX component names", () => { + expect(stripDisplayTags("fix the