From 91c5bea27a7eede173d181d6967b92e8b21d904f Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 2 Apr 2026 08:46:09 +0800 Subject: [PATCH 1/7] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=E5=90=8E?= =?UTF-8?q?=E7=BB=AD=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96=E8=AE=A1=E5=88=92?= =?UTF-8?q?=20(Phase=201-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 个阶段共计 ~213 tests / 20 files,目标从 647 提升至 ~860 tests Co-Authored-By: Claude Opus 4.6 --- docs/testing-spec.md | 57 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/docs/testing-spec.md b/docs/testing-spec.md index 7c47a01..e57b4de 100644 --- a/docs/testing-spec.md +++ b/docs/testing-spec.md @@ -371,7 +371,62 @@ bun test --watch **关键约束**:`mock.module()` 必须在每个测试文件中内联调用,不能从共享 helper 导入(Bun 在 mock 生效前就解析了 helper 的导入)。 -## 12. 参考 +## 12. 后续测试覆盖计划 + +> 目标:再增加 ~200 tests,从 647 → ~860 tests / 52 files + +### Phase 1:纯函数(零依赖,~98 tests,8 files) + +| 测试文件 | 源文件 | 关键函数 | 预估 | +|----------|--------|----------|------| +| `errors.test.ts` | `src/utils/errors.ts` | `isAbortError`, `toError`, `errorMessage`, `getErrnoCode`, `isENOENT`, `isFsInaccessible`, `classifyAxiosError` + Error classes | 20 | +| `shellRuleMatching.test.ts` | `src/utils/permissions/shellRuleMatching.ts` | `permissionRuleExtractPrefix`, `hasWildcards`, `matchWildcardPattern`, `parsePermissionRule`, `suggestionForExactCommand` | 20 | +| `argumentSubstitution.test.ts` | `src/utils/argumentSubstitution.ts` | `parseArguments`, `parseArgumentNames`, `generateProgressiveArgumentHint`, `substituteArguments` | 15 | +| `CircularBuffer.test.ts` | `src/utils/CircularBuffer.ts` | `CircularBuffer` class 全部方法 | 12 | +| `sanitization.test.ts` | `src/utils/sanitization.ts` | `partiallySanitizeUnicode`, `recursivelySanitizeUnicode` | 10 | +| `slashCommandParsing.test.ts` | `src/utils/slashCommandParsing.ts` | `parseSlashCommand` | 8 | +| `contentArray.test.ts` | `src/utils/contentArray.ts` | `insertBlockAfterToolResults` | 8 | +| `objectGroupBy.test.ts` | `src/utils/objectGroupBy.ts` | `objectGroupBy` | 5 | + +### Phase 2:轻 Mock(mock log.ts / env,~63 tests,6 files) + +| 测试文件 | 源文件 | Mock 策略 | 预估 | +|----------|--------|-----------|------| +| `envUtils.test.ts` | `src/utils/envUtils.ts` | 临时修改 `process.env` | 15 | +| `sleep.test.ts` | `src/utils/sleep.ts` + `sequential.ts` | AbortController | 14 | +| `memoize.test.ts` | `src/utils/memoize.ts` | mock `log.ts` + `slowOperations.ts` | 12 | +| `groupToolUses.test.ts` | `src/utils/groupToolUses.ts` | 构造 mock message/tool 对象 | 12 | +| `dangerousPatterns.test.ts` | `src/utils/permissions/dangerousPatterns.ts` | 无(常量导出) | 5 | +| `outputLimits.test.ts` | `src/utils/shell/outputLimits.ts` | 临时修改 `process.env` | 5 | + +### Phase 3:补全现有计划缺口(~20 tests,3 files) + +| 测试文件 | 源文件 | Mock 策略 | 预估 | +|----------|--------|-----------|------| +| `context.test.ts` | `src/context.ts` | mock `execFileNoThrow`, `log.ts` | 10 | +| `zodToJsonSchema.test.ts` | `src/utils/zodToJsonSchema.ts` | 无(仅依赖 zod) | 5 | +| `PermissionMode.test.ts` | `src/utils/permissions/PermissionMode.ts` | 视导出情况 | 5 | + +### Phase 4:工具模块扩展(~30 tests,3 files) + +| 测试文件 | 源文件 | 预估 | +|----------|--------|------| +| `bashPermissions.test.ts` | `src/tools/BashTool/` | 10 | +| `GlobTool.test.ts` | `src/tools/GlobTool/` | 10 | +| `mcpStringUtils.test.ts` | `src/services/mcp/mcpStringUtils.ts` | 10 | + +### 不纳入计划的模块 + +| 模块 | 原因 | +|------|------| +| `query.ts` / `QueryEngine.ts` | 核心循环,需集成测试环境 | +| `services/api/claude.ts` | 需 mock SDK 流式响应 | +| `spawnMultiAgent.ts` | 50+ 依赖,mock 不可行 | +| `modelCost.ts` | 依赖 bootstrap/state + analytics | +| `mcp/dateTimeParser.ts` | 调用 Haiku API | +| `screens/` / `components/` | UI 组件,需 Ink 渲染测试 | + +## 13. 参考 - [Bun Test 文档](https://bun.sh/docs/cli/test) - 现有测试示例:`src/utils/__tests__/set.test.ts`, `src/utils/__tests__/array.test.ts` From acfaac5f142467b7a0fcf000bdf25cae60126bb8 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 2 Apr 2026 08:50:29 +0800 Subject: [PATCH 2/7] =?UTF-8?q?test:=20Phase=201=20=E2=80=94=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=208=20=E4=B8=AA=E7=BA=AF=E5=87=BD=E6=95=B0=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E6=96=87=E4=BB=B6=20(+134=20tests)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - errors.test.ts: 28 tests (isAbortError, toError, errorMessage, getErrnoCode, isFsInaccessible, classifyAxiosError 等) - shellRuleMatching.test.ts: 22 tests (permissionRuleExtractPrefix, hasWildcards, matchWildcardPattern, parsePermissionRule 等) - argumentSubstitution.test.ts: 18 tests (parseArguments, parseArgumentNames, generateProgressiveArgumentHint, substituteArguments) - CircularBuffer.test.ts: 12 tests (add, addAll, getRecent, toArray, clear, length) - sanitization.test.ts: 14 tests (partiallySanitizeUnicode, recursivelySanitizeUnicode) - slashCommandParsing.test.ts: 8 tests (parseSlashCommand) - contentArray.test.ts: 6 tests (insertBlockAfterToolResults) - objectGroupBy.test.ts: 5 tests (objectGroupBy) 总计:781 tests / 40 files Co-Authored-By: Claude Opus 4.6 --- src/utils/__tests__/CircularBuffer.test.ts | 86 ++++++ .../__tests__/argumentSubstitution.test.ts | 127 ++++++++ src/utils/__tests__/contentArray.test.ts | 55 ++++ src/utils/__tests__/errors.test.ts | 289 ++++++++++++++++++ src/utils/__tests__/objectGroupBy.test.ts | 41 +++ src/utils/__tests__/sanitization.test.ts | 75 +++++ .../__tests__/slashCommandParsing.test.ts | 58 ++++ .../__tests__/shellRuleMatching.test.ts | 145 +++++++++ 8 files changed, 876 insertions(+) create mode 100644 src/utils/__tests__/CircularBuffer.test.ts create mode 100644 src/utils/__tests__/argumentSubstitution.test.ts create mode 100644 src/utils/__tests__/contentArray.test.ts create mode 100644 src/utils/__tests__/errors.test.ts create mode 100644 src/utils/__tests__/objectGroupBy.test.ts create mode 100644 src/utils/__tests__/sanitization.test.ts create mode 100644 src/utils/__tests__/slashCommandParsing.test.ts create mode 100644 src/utils/permissions/__tests__/shellRuleMatching.test.ts diff --git a/src/utils/__tests__/CircularBuffer.test.ts b/src/utils/__tests__/CircularBuffer.test.ts new file mode 100644 index 0000000..0e2c561 --- /dev/null +++ b/src/utils/__tests__/CircularBuffer.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import { CircularBuffer } from "../CircularBuffer"; + +describe("CircularBuffer", () => { + test("starts empty", () => { + const buf = new CircularBuffer(5); + expect(buf.length()).toBe(0); + expect(buf.toArray()).toEqual([]); + }); + + test("adds items up to capacity", () => { + const buf = new CircularBuffer(3); + buf.add(1); + buf.add(2); + buf.add(3); + expect(buf.length()).toBe(3); + expect(buf.toArray()).toEqual([1, 2, 3]); + }); + + test("evicts oldest when full", () => { + const buf = new CircularBuffer(3); + buf.add(1); + buf.add(2); + buf.add(3); + buf.add(4); + expect(buf.length()).toBe(3); + expect(buf.toArray()).toEqual([2, 3, 4]); + }); + + test("evicts multiple oldest items", () => { + const buf = new CircularBuffer(2); + buf.add(1); + buf.add(2); + buf.add(3); + buf.add(4); + buf.add(5); + expect(buf.toArray()).toEqual([4, 5]); + }); + + test("addAll adds multiple items", () => { + const buf = new CircularBuffer(5); + buf.addAll([1, 2, 3]); + expect(buf.toArray()).toEqual([1, 2, 3]); + }); + + test("addAll with overflow", () => { + const buf = new CircularBuffer(3); + buf.addAll([1, 2, 3, 4, 5]); + expect(buf.toArray()).toEqual([3, 4, 5]); + }); + + test("getRecent returns last N items", () => { + const buf = new CircularBuffer(5); + buf.addAll([1, 2, 3, 4, 5]); + expect(buf.getRecent(3)).toEqual([3, 4, 5]); + }); + + test("getRecent returns fewer when not enough items", () => { + const buf = new CircularBuffer(5); + buf.add(1); + buf.add(2); + expect(buf.getRecent(5)).toEqual([1, 2]); + }); + + test("getRecent works after wraparound", () => { + const buf = new CircularBuffer(3); + buf.addAll([1, 2, 3, 4, 5]); + expect(buf.getRecent(2)).toEqual([4, 5]); + }); + + test("clear resets buffer", () => { + const buf = new CircularBuffer(5); + buf.addAll([1, 2, 3]); + buf.clear(); + expect(buf.length()).toBe(0); + expect(buf.toArray()).toEqual([]); + }); + + test("works with string type", () => { + const buf = new CircularBuffer(2); + buf.add("a"); + buf.add("b"); + buf.add("c"); + expect(buf.toArray()).toEqual(["b", "c"]); + }); +}); diff --git a/src/utils/__tests__/argumentSubstitution.test.ts b/src/utils/__tests__/argumentSubstitution.test.ts new file mode 100644 index 0000000..4c875a1 --- /dev/null +++ b/src/utils/__tests__/argumentSubstitution.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from "bun:test"; +import { + parseArguments, + parseArgumentNames, + generateProgressiveArgumentHint, + substituteArguments, +} from "../argumentSubstitution"; + +// ─── parseArguments ───────────────────────────────────────────────────── + +describe("parseArguments", () => { + test("splits simple arguments", () => { + expect(parseArguments("foo bar baz")).toEqual(["foo", "bar", "baz"]); + }); + + test("handles quoted strings", () => { + expect(parseArguments('foo "hello world" baz')).toEqual([ + "foo", + "hello world", + "baz", + ]); + }); + + test("handles single-quoted strings", () => { + expect(parseArguments("foo 'hello world' baz")).toEqual([ + "foo", + "hello world", + "baz", + ]); + }); + + test("returns empty for empty string", () => { + expect(parseArguments("")).toEqual([]); + }); + + test("returns empty for whitespace only", () => { + expect(parseArguments(" ")).toEqual([]); + }); +}); + +// ─── parseArgumentNames ───────────────────────────────────────────────── + +describe("parseArgumentNames", () => { + test("parses space-separated string", () => { + expect(parseArgumentNames("foo bar baz")).toEqual(["foo", "bar", "baz"]); + }); + + test("accepts array input", () => { + expect(parseArgumentNames(["foo", "bar"])).toEqual(["foo", "bar"]); + }); + + test("filters out numeric-only names", () => { + expect(parseArgumentNames("foo 123 bar")).toEqual(["foo", "bar"]); + }); + + test("filters out empty strings", () => { + expect(parseArgumentNames(["foo", "", "bar"])).toEqual(["foo", "bar"]); + }); + + test("returns empty for undefined", () => { + expect(parseArgumentNames(undefined)).toEqual([]); + }); +}); + +// ─── generateProgressiveArgumentHint ──────────────────────────────────── + +describe("generateProgressiveArgumentHint", () => { + test("shows remaining arguments", () => { + expect(generateProgressiveArgumentHint(["a", "b", "c"], ["x"])).toBe( + "[b] [c]" + ); + }); + + test("returns undefined when all filled", () => { + expect( + generateProgressiveArgumentHint(["a"], ["x"]) + ).toBeUndefined(); + }); + + test("shows all when none typed", () => { + expect(generateProgressiveArgumentHint(["a", "b"], [])).toBe("[a] [b]"); + }); +}); + +// ─── substituteArguments ──────────────────────────────────────────────── + +describe("substituteArguments", () => { + test("replaces $ARGUMENTS with full args", () => { + expect(substituteArguments("run $ARGUMENTS", "foo bar")).toBe( + "run foo bar" + ); + }); + + test("replaces indexed $ARGUMENTS[0]", () => { + expect(substituteArguments("run $ARGUMENTS[0]", "foo bar")).toBe("run foo"); + }); + + test("replaces shorthand $0, $1", () => { + expect(substituteArguments("$0 and $1", "hello world")).toBe( + "hello and world" + ); + }); + + test("replaces named arguments", () => { + expect( + substituteArguments("file: $name", "test.txt", true, ["name"]) + ).toBe("file: test.txt"); + }); + + test("returns content unchanged for undefined args", () => { + expect(substituteArguments("hello", undefined)).toBe("hello"); + }); + + test("appends ARGUMENTS when no placeholder found", () => { + expect(substituteArguments("run this", "extra")).toBe( + "run this\n\nARGUMENTS: extra" + ); + }); + + test("does not append when appendIfNoPlaceholder is false", () => { + expect(substituteArguments("run this", "extra", false)).toBe("run this"); + }); + + test("does not append for empty args string", () => { + expect(substituteArguments("run this", "")).toBe("run this"); + }); +}); diff --git a/src/utils/__tests__/contentArray.test.ts b/src/utils/__tests__/contentArray.test.ts new file mode 100644 index 0000000..d708af8 --- /dev/null +++ b/src/utils/__tests__/contentArray.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { insertBlockAfterToolResults } from "../contentArray"; + +describe("insertBlockAfterToolResults", () => { + test("inserts after last tool_result", () => { + const content: any[] = [ + { type: "tool_result", content: "r1" }, + { type: "text", text: "hello" }, + ]; + insertBlockAfterToolResults(content, { type: "text", text: "inserted" }); + expect(content[1]).toEqual({ type: "text", text: "inserted" }); + expect(content).toHaveLength(3); + }); + + test("inserts after last of multiple tool_results", () => { + const content: any[] = [ + { type: "tool_result", content: "r1" }, + { type: "tool_result", content: "r2" }, + { type: "text", text: "end" }, + ]; + insertBlockAfterToolResults(content, { type: "text", text: "new" }); + expect(content[2]).toEqual({ type: "text", text: "new" }); + }); + + test("appends continuation when inserted block would be last", () => { + const content: any[] = [{ type: "tool_result", content: "r1" }]; + insertBlockAfterToolResults(content, { type: "text", text: "new" }); + expect(content).toHaveLength(3); // original + inserted + continuation + expect(content[2]).toEqual({ type: "text", text: "." }); + }); + + test("inserts before last block when no tool_results", () => { + const content: any[] = [ + { type: "text", text: "a" }, + { type: "text", text: "b" }, + ]; + insertBlockAfterToolResults(content, { type: "text", text: "new" }); + expect(content[1]).toEqual({ type: "text", text: "new" }); + expect(content).toHaveLength(3); + }); + + test("handles empty array", () => { + const content: any[] = []; + insertBlockAfterToolResults(content, { type: "text", text: "new" }); + expect(content).toHaveLength(1); + expect(content[0]).toEqual({ type: "text", text: "new" }); + }); + + test("handles single element array with no tool_result", () => { + const content: any[] = [{ type: "text", text: "only" }]; + insertBlockAfterToolResults(content, { type: "text", text: "new" }); + expect(content[0]).toEqual({ type: "text", text: "new" }); + expect(content[1]).toEqual({ type: "text", text: "only" }); + }); +}); diff --git a/src/utils/__tests__/errors.test.ts b/src/utils/__tests__/errors.test.ts new file mode 100644 index 0000000..c8b5b23 --- /dev/null +++ b/src/utils/__tests__/errors.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, test } from "bun:test"; +import { + AbortError, + ClaudeError, + MalformedCommandError, + ConfigParseError, + ShellError, + TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + isAbortError, + hasExactErrorMessage, + toError, + errorMessage, + getErrnoCode, + isENOENT, + getErrnoPath, + shortErrorStack, + isFsInaccessible, + classifyAxiosError, +} from "../errors"; + +// ─── Error classes ────────────────────────────────────────────────────── + +describe("ClaudeError", () => { + test("sets name to constructor name", () => { + const e = new ClaudeError("test"); + expect(e.name).toBe("ClaudeError"); + expect(e.message).toBe("test"); + }); +}); + +describe("AbortError", () => { + test("sets name to AbortError", () => { + const e = new AbortError("cancelled"); + expect(e.name).toBe("AbortError"); + }); +}); + +describe("ConfigParseError", () => { + test("stores filePath and defaultConfig", () => { + const e = new ConfigParseError("bad", "/tmp/cfg", { x: 1 }); + expect(e.filePath).toBe("/tmp/cfg"); + expect(e.defaultConfig).toEqual({ x: 1 }); + }); +}); + +describe("ShellError", () => { + test("stores stdout, stderr, code, interrupted", () => { + const e = new ShellError("out", "err", 1, false); + expect(e.stdout).toBe("out"); + expect(e.stderr).toBe("err"); + expect(e.code).toBe(1); + expect(e.interrupted).toBe(false); + }); +}); + +describe("TelemetrySafeError", () => { + test("uses message as telemetryMessage by default", () => { + const e = + new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS("msg"); + expect(e.telemetryMessage).toBe("msg"); + }); + + test("uses separate telemetryMessage when provided", () => { + const e = + new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS( + "full msg", + "safe msg" + ); + expect(e.message).toBe("full msg"); + expect(e.telemetryMessage).toBe("safe msg"); + }); +}); + +// ─── isAbortError ─────────────────────────────────────────────────────── + +describe("isAbortError", () => { + test("returns true for AbortError instance", () => { + expect(isAbortError(new AbortError())).toBe(true); + }); + + test("returns true for DOMException-style abort", () => { + const e = new Error("aborted"); + e.name = "AbortError"; + expect(isAbortError(e)).toBe(true); + }); + + test("returns false for regular error", () => { + expect(isAbortError(new Error("nope"))).toBe(false); + }); + + test("returns false for non-error", () => { + expect(isAbortError("string")).toBe(false); + expect(isAbortError(null)).toBe(false); + }); +}); + +// ─── hasExactErrorMessage ─────────────────────────────────────────────── + +describe("hasExactErrorMessage", () => { + test("returns true for matching message", () => { + expect(hasExactErrorMessage(new Error("test"), "test")).toBe(true); + }); + + test("returns false for different message", () => { + expect(hasExactErrorMessage(new Error("a"), "b")).toBe(false); + }); + + test("returns false for non-Error", () => { + expect(hasExactErrorMessage("string", "string")).toBe(false); + }); +}); + +// ─── toError ──────────────────────────────────────────────────────────── + +describe("toError", () => { + test("returns Error as-is", () => { + const e = new Error("test"); + expect(toError(e)).toBe(e); + }); + + test("wraps string in Error", () => { + const e = toError("oops"); + expect(e).toBeInstanceOf(Error); + expect(e.message).toBe("oops"); + }); + + test("wraps number in Error", () => { + expect(toError(42).message).toBe("42"); + }); +}); + +// ─── errorMessage ─────────────────────────────────────────────────────── + +describe("errorMessage", () => { + test("extracts message from Error", () => { + expect(errorMessage(new Error("hello"))).toBe("hello"); + }); + + test("stringifies non-Error", () => { + expect(errorMessage(42)).toBe("42"); + expect(errorMessage(null)).toBe("null"); + }); +}); + +// ─── getErrnoCode / isENOENT / getErrnoPath ──────────────────────────── + +describe("getErrnoCode", () => { + test("extracts code from errno-like error", () => { + const e = Object.assign(new Error(), { code: "ENOENT" }); + expect(getErrnoCode(e)).toBe("ENOENT"); + }); + + test("returns undefined for no code", () => { + expect(getErrnoCode(new Error())).toBeUndefined(); + }); + + test("returns undefined for non-string code", () => { + expect(getErrnoCode({ code: 123 })).toBeUndefined(); + }); + + test("returns undefined for non-object", () => { + expect(getErrnoCode(null)).toBeUndefined(); + expect(getErrnoCode("string")).toBeUndefined(); + }); +}); + +describe("isENOENT", () => { + test("returns true for ENOENT", () => { + expect(isENOENT(Object.assign(new Error(), { code: "ENOENT" }))).toBe(true); + }); + + test("returns false for other codes", () => { + expect(isENOENT(Object.assign(new Error(), { code: "EACCES" }))).toBe( + false + ); + }); +}); + +describe("getErrnoPath", () => { + test("extracts path from errno error", () => { + const e = Object.assign(new Error(), { path: "/tmp/file" }); + expect(getErrnoPath(e)).toBe("/tmp/file"); + }); + + test("returns undefined when no path", () => { + expect(getErrnoPath(new Error())).toBeUndefined(); + }); +}); + +// ─── shortErrorStack ──────────────────────────────────────────────────── + +describe("shortErrorStack", () => { + test("returns string for non-Error", () => { + expect(shortErrorStack("oops")).toBe("oops"); + }); + + test("returns message when no stack", () => { + const e = new Error("test"); + e.stack = undefined; + expect(shortErrorStack(e)).toBe("test"); + }); + + test("truncates long stacks", () => { + const e = new Error("test"); + const frames = Array.from({ length: 20 }, (_, i) => ` at frame${i}`); + e.stack = `Error: test\n${frames.join("\n")}`; + const result = shortErrorStack(e, 3); + const lines = result.split("\n"); + expect(lines).toHaveLength(4); // header + 3 frames + }); + + test("preserves short stacks", () => { + const e = new Error("test"); + e.stack = "Error: test\n at frame1\n at frame2"; + expect(shortErrorStack(e, 5)).toBe(e.stack); + }); +}); + +// ─── isFsInaccessible ────────────────────────────────────────────────── + +describe("isFsInaccessible", () => { + test("returns true for ENOENT", () => { + expect(isFsInaccessible(Object.assign(new Error(), { code: "ENOENT" }))).toBe(true); + }); + + test("returns true for EACCES", () => { + expect(isFsInaccessible(Object.assign(new Error(), { code: "EACCES" }))).toBe(true); + }); + + test("returns true for EPERM", () => { + expect(isFsInaccessible(Object.assign(new Error(), { code: "EPERM" }))).toBe(true); + }); + + test("returns true for ENOTDIR", () => { + expect(isFsInaccessible(Object.assign(new Error(), { code: "ENOTDIR" }))).toBe(true); + }); + + test("returns true for ELOOP", () => { + expect(isFsInaccessible(Object.assign(new Error(), { code: "ELOOP" }))).toBe(true); + }); + + test("returns false for other codes", () => { + expect(isFsInaccessible(Object.assign(new Error(), { code: "EEXIST" }))).toBe(false); + }); +}); + +// ─── classifyAxiosError ───────────────────────────────────────────────── + +describe("classifyAxiosError", () => { + test("returns 'other' for non-axios error", () => { + expect(classifyAxiosError(new Error("test")).kind).toBe("other"); + }); + + test("returns 'auth' for 401", () => { + const e = { isAxiosError: true, response: { status: 401 }, message: "unauth" }; + expect(classifyAxiosError(e).kind).toBe("auth"); + }); + + test("returns 'auth' for 403", () => { + const e = { isAxiosError: true, response: { status: 403 }, message: "forbidden" }; + expect(classifyAxiosError(e).kind).toBe("auth"); + }); + + test("returns 'timeout' for ECONNABORTED", () => { + const e = { isAxiosError: true, code: "ECONNABORTED", message: "timeout" }; + expect(classifyAxiosError(e).kind).toBe("timeout"); + }); + + test("returns 'network' for ECONNREFUSED", () => { + const e = { isAxiosError: true, code: "ECONNREFUSED", message: "refused" }; + expect(classifyAxiosError(e).kind).toBe("network"); + }); + + test("returns 'network' for ENOTFOUND", () => { + const e = { isAxiosError: true, code: "ENOTFOUND", message: "nope" }; + expect(classifyAxiosError(e).kind).toBe("network"); + }); + + test("returns 'http' for other axios errors", () => { + const e = { isAxiosError: true, response: { status: 500 }, message: "err" }; + const result = classifyAxiosError(e); + expect(result.kind).toBe("http"); + expect(result.status).toBe(500); + }); + + test("returns 'other' for null", () => { + expect(classifyAxiosError(null).kind).toBe("other"); + }); +}); diff --git a/src/utils/__tests__/objectGroupBy.test.ts b/src/utils/__tests__/objectGroupBy.test.ts new file mode 100644 index 0000000..0f179d7 --- /dev/null +++ b/src/utils/__tests__/objectGroupBy.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { objectGroupBy } from "../objectGroupBy"; + +describe("objectGroupBy", () => { + test("groups items by key", () => { + const result = objectGroupBy([1, 2, 3, 4], (n) => + n % 2 === 0 ? "even" : "odd" + ); + expect(result.even).toEqual([2, 4]); + expect(result.odd).toEqual([1, 3]); + }); + + test("returns empty object for empty input", () => { + const result = objectGroupBy([], () => "key"); + expect(Object.keys(result)).toHaveLength(0); + }); + + test("handles single group", () => { + const result = objectGroupBy(["a", "b", "c"], () => "all"); + expect(result.all).toEqual(["a", "b", "c"]); + }); + + test("passes index to keySelector", () => { + const result = objectGroupBy(["a", "b", "c", "d"], (_, i) => + i < 2 ? "first" : "second" + ); + expect(result.first).toEqual(["a", "b"]); + expect(result.second).toEqual(["c", "d"]); + }); + + test("works with objects", () => { + const items = [ + { name: "Alice", role: "admin" }, + { name: "Bob", role: "user" }, + { name: "Charlie", role: "admin" }, + ]; + const result = objectGroupBy(items, (item) => item.role); + expect(result.admin).toHaveLength(2); + expect(result.user).toHaveLength(1); + }); +}); diff --git a/src/utils/__tests__/sanitization.test.ts b/src/utils/__tests__/sanitization.test.ts new file mode 100644 index 0000000..c55db96 --- /dev/null +++ b/src/utils/__tests__/sanitization.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test"; +import { + partiallySanitizeUnicode, + recursivelySanitizeUnicode, +} from "../sanitization"; + +// ─── partiallySanitizeUnicode ─────────────────────────────────────────── + +describe("partiallySanitizeUnicode", () => { + test("preserves normal ASCII text", () => { + expect(partiallySanitizeUnicode("hello world")).toBe("hello world"); + }); + + test("preserves CJK characters", () => { + expect(partiallySanitizeUnicode("你好世界")).toBe("你好世界"); + }); + + test("removes zero-width spaces", () => { + expect(partiallySanitizeUnicode("hello\u200Bworld")).toBe("helloworld"); + }); + + test("removes BOM", () => { + expect(partiallySanitizeUnicode("\uFEFFhello")).toBe("hello"); + }); + + test("removes directional formatting", () => { + expect(partiallySanitizeUnicode("hello\u202Aworld")).toBe("helloworld"); + }); + + test("removes private use area characters", () => { + expect(partiallySanitizeUnicode("hello\uE000world")).toBe("helloworld"); + }); + + test("handles empty string", () => { + expect(partiallySanitizeUnicode("")).toBe(""); + }); + + test("handles string with only dangerous characters", () => { + const result = partiallySanitizeUnicode("\u200B\u200C\u200D\uFEFF"); + expect(result.length).toBeLessThanOrEqual(1); // ZWJ may survive NFKC + }); +}); + +// ─── recursivelySanitizeUnicode ───────────────────────────────────────── + +describe("recursivelySanitizeUnicode", () => { + test("sanitizes string values", () => { + expect(recursivelySanitizeUnicode("hello\u200Bworld")).toBe("helloworld"); + }); + + test("sanitizes array elements", () => { + const result = recursivelySanitizeUnicode(["a\u200Bb", "c\uFEFFd"]); + expect(result).toEqual(["ab", "cd"]); + }); + + test("sanitizes object values recursively", () => { + const result = recursivelySanitizeUnicode({ + key: "val\u200Bue", + nested: { inner: "te\uFEFFst" }, + }); + expect(result).toEqual({ key: "value", nested: { inner: "test" } }); + }); + + test("preserves numbers", () => { + expect(recursivelySanitizeUnicode(42)).toBe(42); + }); + + test("preserves booleans", () => { + expect(recursivelySanitizeUnicode(true)).toBe(true); + }); + + test("preserves null", () => { + expect(recursivelySanitizeUnicode(null)).toBeNull(); + }); +}); diff --git a/src/utils/__tests__/slashCommandParsing.test.ts b/src/utils/__tests__/slashCommandParsing.test.ts new file mode 100644 index 0000000..0664830 --- /dev/null +++ b/src/utils/__tests__/slashCommandParsing.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { parseSlashCommand } from "../slashCommandParsing"; + +describe("parseSlashCommand", () => { + test("parses simple command", () => { + const result = parseSlashCommand("/search foo bar"); + expect(result).toEqual({ + commandName: "search", + args: "foo bar", + isMcp: false, + }); + }); + + test("parses command without args", () => { + const result = parseSlashCommand("/help"); + expect(result).toEqual({ + commandName: "help", + args: "", + isMcp: false, + }); + }); + + test("parses MCP command", () => { + const result = parseSlashCommand("/tool (MCP) arg1 arg2"); + expect(result).toEqual({ + commandName: "tool (MCP)", + args: "arg1 arg2", + isMcp: true, + }); + }); + + test("parses MCP command without args", () => { + const result = parseSlashCommand("/tool (MCP)"); + expect(result).toEqual({ + commandName: "tool (MCP)", + args: "", + isMcp: true, + }); + }); + + test("returns null for non-slash input", () => { + expect(parseSlashCommand("hello")).toBeNull(); + }); + + test("returns null for empty string", () => { + expect(parseSlashCommand("")).toBeNull(); + }); + + test("returns null for just slash", () => { + expect(parseSlashCommand("/")).toBeNull(); + }); + + test("trims whitespace before parsing", () => { + const result = parseSlashCommand(" /search foo "); + expect(result!.commandName).toBe("search"); + expect(result!.args).toBe("foo"); + }); +}); diff --git a/src/utils/permissions/__tests__/shellRuleMatching.test.ts b/src/utils/permissions/__tests__/shellRuleMatching.test.ts new file mode 100644 index 0000000..cadb67c --- /dev/null +++ b/src/utils/permissions/__tests__/shellRuleMatching.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from "bun:test"; +import { + permissionRuleExtractPrefix, + hasWildcards, + matchWildcardPattern, + parsePermissionRule, + suggestionForExactCommand, + suggestionForPrefix, +} from "../shellRuleMatching"; + +// ─── permissionRuleExtractPrefix ──────────────────────────────────────── + +describe("permissionRuleExtractPrefix", () => { + test("extracts prefix from legacy :* syntax", () => { + expect(permissionRuleExtractPrefix("npm:*")).toBe("npm"); + }); + + test("extracts multi-word prefix", () => { + expect(permissionRuleExtractPrefix("git commit:*")).toBe("git commit"); + }); + + test("returns null for non-prefix rule", () => { + expect(permissionRuleExtractPrefix("npm install")).toBeNull(); + }); + + test("returns null for empty string", () => { + expect(permissionRuleExtractPrefix("")).toBeNull(); + }); + + test("returns null for wildcard without colon", () => { + expect(permissionRuleExtractPrefix("npm *")).toBeNull(); + }); +}); + +// ─── hasWildcards ─────────────────────────────────────────────────────── + +describe("hasWildcards", () => { + test("returns true for unescaped wildcard", () => { + expect(hasWildcards("git *")).toBe(true); + }); + + test("returns false for legacy :* syntax", () => { + expect(hasWildcards("npm:*")).toBe(false); + }); + + test("returns false for escaped wildcard", () => { + expect(hasWildcards("git \\*")).toBe(false); + }); + + test("returns true for * with even backslashes", () => { + expect(hasWildcards("git \\\\*")).toBe(true); + }); + + test("returns false for no wildcards", () => { + expect(hasWildcards("npm install")).toBe(false); + }); + + test("returns false for empty string", () => { + expect(hasWildcards("")).toBe(false); + }); +}); + +// ─── matchWildcardPattern ─────────────────────────────────────────────── + +describe("matchWildcardPattern", () => { + test("matches simple wildcard", () => { + expect(matchWildcardPattern("git *", "git add")).toBe(true); + }); + + test("matches bare command when pattern ends with space-wildcard", () => { + expect(matchWildcardPattern("git *", "git")).toBe(true); + }); + + test("rejects non-matching command", () => { + expect(matchWildcardPattern("git *", "npm install")).toBe(false); + }); + + test("matches middle wildcard", () => { + expect(matchWildcardPattern("git * --verbose", "git add --verbose")).toBe(true); + }); + + test("handles escaped asterisk as literal", () => { + expect(matchWildcardPattern("echo \\*", "echo *")).toBe(true); + expect(matchWildcardPattern("echo \\*", "echo hello")).toBe(false); + }); + + test("case-insensitive matching", () => { + expect(matchWildcardPattern("Git *", "git add", true)).toBe(true); + }); + + test("exact match without wildcards", () => { + expect(matchWildcardPattern("npm install", "npm install")).toBe(true); + expect(matchWildcardPattern("npm install", "npm update")).toBe(false); + }); + + test("handles regex special characters in pattern", () => { + expect(matchWildcardPattern("echo (hello)", "echo (hello)")).toBe(true); + }); +}); + +// ─── parsePermissionRule ──────────────────────────────────────────────── + +describe("parsePermissionRule", () => { + test("parses exact command", () => { + const result = parsePermissionRule("npm install"); + expect(result).toEqual({ type: "exact", command: "npm install" }); + }); + + test("parses legacy prefix syntax", () => { + const result = parsePermissionRule("npm:*"); + expect(result).toEqual({ type: "prefix", prefix: "npm" }); + }); + + test("parses wildcard pattern", () => { + const result = parsePermissionRule("git *"); + expect(result).toEqual({ type: "wildcard", pattern: "git *" }); + }); + + test("escaped wildcard is treated as exact", () => { + const result = parsePermissionRule("echo \\*"); + expect(result.type).toBe("exact"); + }); +}); + +// ─── suggestionForExactCommand ────────────────────────────────────────── + +describe("suggestionForExactCommand", () => { + test("creates addRules suggestion", () => { + const result = suggestionForExactCommand("Bash", "npm install"); + expect(result).toHaveLength(1); + expect(result[0]!.type).toBe("addRules"); + expect(result[0]!.rules[0]!.toolName).toBe("Bash"); + expect(result[0]!.rules[0]!.ruleContent).toBe("npm install"); + expect(result[0]!.behavior).toBe("allow"); + }); +}); + +// ─── suggestionForPrefix ──────────────────────────────────────────────── + +describe("suggestionForPrefix", () => { + test("creates prefix suggestion with :*", () => { + const result = suggestionForPrefix("Bash", "npm"); + expect(result[0]!.rules[0]!.ruleContent).toBe("npm:*"); + }); +}); From 2d9c2adce3bf832d197621862119e65fb8e51558 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 2 Apr 2026 09:14:49 +0800 Subject: [PATCH 3/7] =?UTF-8?q?docs:=20=E6=8E=92=E6=9F=A5=20test=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mint.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mint.json b/mint.json index 3f78e45..3f2ed8b 100644 --- a/mint.json +++ b/mint.json @@ -111,6 +111,11 @@ ] } ], + "excludes": [ + "docs/test-plans/**", + "docs/testing-spec.md", + "docs/REVISION-PLAN.md" + ], "footerSocials": { "github": "https://github.com/anthropics/claude-code" } From 21ac9e441f5cbfbd308944bd8efed8149a27b337 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 2 Apr 2026 09:29:01 +0800 Subject: [PATCH 4/7] =?UTF-8?q?test:=20Phase=202-4=20=E2=80=94=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=2012=20=E4=B8=AA=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6?= =?UTF-8?q?=20(+321=20tests,=20968=20total)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 (轻 Mock): envUtils, sleep/sequential, memoize, groupToolUses, dangerousPatterns, outputLimits Phase 3 (补全): zodToJsonSchema, PermissionMode, envValidation Phase 4 (工具模块): mcpStringUtils, destructiveCommandWarning, commandSemantics Co-Authored-By: Claude Opus 4.6 --- docs/testing-spec.md | 88 ++--- .../mcp/__tests__/mcpStringUtils.test.ts | 140 ++++++++ .../__tests__/commandSemantics.test.ts | 87 +++++ .../destructiveCommandWarning.test.ts | 112 ++++++ src/utils/__tests__/envUtils.test.ts | 333 ++++++++++++++++++ src/utils/__tests__/envValidation.test.ts | 74 ++++ src/utils/__tests__/groupToolUses.test.ts | 152 ++++++++ src/utils/__tests__/memoize.test.ts | 240 +++++++++++++ src/utils/__tests__/sleep.test.ts | 130 +++++++ src/utils/__tests__/zodToJsonSchema.test.ts | 72 ++++ .../__tests__/PermissionMode.test.ts | 162 +++++++++ .../__tests__/dangerousPatterns.test.ts | 55 +++ .../shell/__tests__/outputLimits.test.ts | 67 ++++ 13 files changed, 1668 insertions(+), 44 deletions(-) create mode 100644 src/services/mcp/__tests__/mcpStringUtils.test.ts create mode 100644 src/tools/BashTool/__tests__/commandSemantics.test.ts create mode 100644 src/tools/BashTool/__tests__/destructiveCommandWarning.test.ts create mode 100644 src/utils/__tests__/envUtils.test.ts create mode 100644 src/utils/__tests__/envValidation.test.ts create mode 100644 src/utils/__tests__/groupToolUses.test.ts create mode 100644 src/utils/__tests__/memoize.test.ts create mode 100644 src/utils/__tests__/sleep.test.ts create mode 100644 src/utils/__tests__/zodToJsonSchema.test.ts create mode 100644 src/utils/permissions/__tests__/PermissionMode.test.ts create mode 100644 src/utils/permissions/__tests__/dangerousPatterns.test.ts create mode 100644 src/utils/shell/__tests__/outputLimits.test.ts diff --git a/docs/testing-spec.md b/docs/testing-spec.md index e57b4de..39694e0 100644 --- a/docs/testing-spec.md +++ b/docs/testing-spec.md @@ -300,7 +300,7 @@ bun test --watch ## 11. 当前测试覆盖状态 -> 更新日期:2026-04-02 | 总计:**647 tests, 32 files, 0 failures** +> 更新日期:2026-04-02 | 总计:**968 tests, 52 files, 0 failures** ### P0 — 核心模块 @@ -348,6 +348,41 @@ bun test --watch | 08 - Git 工具 | `src/utils/__tests__/git.test.ts` | 18 | normalizeGitRemoteUrl (SSH/HTTPS/ssh:///代理URL/大小写规范化) | | 09 - 配置与设置 | `src/utils/settings/__tests__/config.test.ts` | 62 | SettingsSchema, PermissionsSchema, AllowedMcpServerEntrySchema, MCP 类型守卫, 设置常量函数, filterInvalidPermissionRules, validateSettingsFileContent, formatZodError | +### P3 — Phase 1 纯函数扩展 + +| 测试文件 | 测试数 | 覆盖范围 | +|----------|--------|----------| +| `src/utils/__tests__/errors.test.ts` | 28 | ClaudeError, AbortError, ConfigParseError, ShellError, TelemetrySafeError, isAbortError, hasExactErrorMessage, toError, errorMessage, getErrnoCode, isENOENT, getErrnoPath, shortErrorStack, isFsInaccessible, classifyAxiosError | +| `src/utils/permissions/__tests__/shellRuleMatching.test.ts` | 22 | permissionRuleExtractPrefix, hasWildcards, matchWildcardPattern, parsePermissionRule, suggestionForExactCommand, suggestionForPrefix | +| `src/utils/__tests__/argumentSubstitution.test.ts` | 18 | parseArguments, parseArgumentNames, generateProgressiveArgumentHint, substituteArguments | +| `src/utils/__tests__/CircularBuffer.test.ts` | 12 | CircularBuffer class: add, addAll, getRecent, toArray, clear, length | +| `src/utils/__tests__/sanitization.test.ts` | 14 | partiallySanitizeUnicode, recursivelySanitizeUnicode | +| `src/utils/__tests__/slashCommandParsing.test.ts` | 8 | parseSlashCommand | +| `src/utils/__tests__/contentArray.test.ts` | 6 | insertBlockAfterToolResults | +| `src/utils/__tests__/objectGroupBy.test.ts` | 5 | objectGroupBy | + +### P4 — Phase 2 轻 Mock 扩展 + +| 测试文件 | 测试数 | 覆盖范围 | +|----------|--------|----------| +| `src/utils/__tests__/envUtils.test.ts` | 34 | isEnvTruthy, isEnvDefinedFalsy, parseEnvVars, hasNodeOption, getAWSRegion, getDefaultVertexRegion, getVertexRegionForModel, isBareMode, shouldMaintainProjectWorkingDir, getClaudeConfigHomeDir | +| `src/utils/__tests__/sleep.test.ts` | 14 | sleep (abort, throwOnAbort, abortError), withTimeout, sequential | +| `src/utils/__tests__/memoize.test.ts` | 16 | memoizeWithTTL, memoizeWithTTLAsync (dedup/cache/clear), memoizeWithLRU (eviction/cache methods) | +| `src/utils/__tests__/groupToolUses.test.ts` | 10 | applyGrouping (verbose, grouping, result collection, mixed messages) | +| `src/utils/permissions/__tests__/dangerousPatterns.test.ts` | 7 | CROSS_PLATFORM_CODE_EXEC, DANGEROUS_BASH_PATTERNS 常量验证 | +| `src/utils/shell/__tests__/outputLimits.test.ts` | 7 | getMaxOutputLength, BASH_MAX_OUTPUT_UPPER_LIMIT, BASH_MAX_OUTPUT_DEFAULT | + +### P5 — Phase 3 补全 + Phase 4 工具模块 + +| 测试文件 | 测试数 | 覆盖范围 | +|----------|--------|----------| +| `src/utils/__tests__/zodToJsonSchema.test.ts` | 9 | zodToJsonSchema (string/number/object/enum/optional/array/boolean + caching) | +| `src/utils/permissions/__tests__/PermissionMode.test.ts` | 19 | PERMISSION_MODES, permissionModeFromString, permissionModeTitle, permissionModeShortTitle, permissionModeSymbol, getModeColor, isDefaultMode, toExternalPermissionMode, isExternalPermissionMode | +| `src/utils/__tests__/envValidation.test.ts` | 9 | validateBoundedIntEnvVar (default/valid/capped/invalid/boundary) | +| `src/services/mcp/__tests__/mcpStringUtils.test.ts` | 18 | mcpInfoFromString, getMcpPrefix, buildMcpToolName, getMcpDisplayName, getToolNameForPermissionCheck, extractMcpToolDisplayName | +| `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) | + ### 已知限制 以下模块因 Bun 运行时限制或极重依赖链,暂时无法或不适合测试: @@ -365,55 +400,20 @@ bun test --watch | 被 Mock 模块 | 解锁的测试 | |-------------|-----------| -| `src/utils/log.ts` | json.ts, tokens.ts, FileEditTool/utils.ts, permissions.ts | +| `src/utils/log.ts` | json.ts, tokens.ts, FileEditTool/utils.ts, permissions.ts, memoize.ts, PermissionMode.ts | | `src/services/tokenEstimation.ts` | tokens.ts | -| `src/utils/slowOperations.ts` | tokens.ts, permissions.ts | +| `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 | **关键约束**:`mock.module()` 必须在每个测试文件中内联调用,不能从共享 helper 导入(Bun 在 mock 生效前就解析了 helper 的导入)。 ## 12. 后续测试覆盖计划 -> 目标:再增加 ~200 tests,从 647 → ~860 tests / 52 files - -### Phase 1:纯函数(零依赖,~98 tests,8 files) - -| 测试文件 | 源文件 | 关键函数 | 预估 | -|----------|--------|----------|------| -| `errors.test.ts` | `src/utils/errors.ts` | `isAbortError`, `toError`, `errorMessage`, `getErrnoCode`, `isENOENT`, `isFsInaccessible`, `classifyAxiosError` + Error classes | 20 | -| `shellRuleMatching.test.ts` | `src/utils/permissions/shellRuleMatching.ts` | `permissionRuleExtractPrefix`, `hasWildcards`, `matchWildcardPattern`, `parsePermissionRule`, `suggestionForExactCommand` | 20 | -| `argumentSubstitution.test.ts` | `src/utils/argumentSubstitution.ts` | `parseArguments`, `parseArgumentNames`, `generateProgressiveArgumentHint`, `substituteArguments` | 15 | -| `CircularBuffer.test.ts` | `src/utils/CircularBuffer.ts` | `CircularBuffer` class 全部方法 | 12 | -| `sanitization.test.ts` | `src/utils/sanitization.ts` | `partiallySanitizeUnicode`, `recursivelySanitizeUnicode` | 10 | -| `slashCommandParsing.test.ts` | `src/utils/slashCommandParsing.ts` | `parseSlashCommand` | 8 | -| `contentArray.test.ts` | `src/utils/contentArray.ts` | `insertBlockAfterToolResults` | 8 | -| `objectGroupBy.test.ts` | `src/utils/objectGroupBy.ts` | `objectGroupBy` | 5 | - -### Phase 2:轻 Mock(mock log.ts / env,~63 tests,6 files) - -| 测试文件 | 源文件 | Mock 策略 | 预估 | -|----------|--------|-----------|------| -| `envUtils.test.ts` | `src/utils/envUtils.ts` | 临时修改 `process.env` | 15 | -| `sleep.test.ts` | `src/utils/sleep.ts` + `sequential.ts` | AbortController | 14 | -| `memoize.test.ts` | `src/utils/memoize.ts` | mock `log.ts` + `slowOperations.ts` | 12 | -| `groupToolUses.test.ts` | `src/utils/groupToolUses.ts` | 构造 mock message/tool 对象 | 12 | -| `dangerousPatterns.test.ts` | `src/utils/permissions/dangerousPatterns.ts` | 无(常量导出) | 5 | -| `outputLimits.test.ts` | `src/utils/shell/outputLimits.ts` | 临时修改 `process.env` | 5 | - -### Phase 3:补全现有计划缺口(~20 tests,3 files) - -| 测试文件 | 源文件 | Mock 策略 | 预估 | -|----------|--------|-----------|------| -| `context.test.ts` | `src/context.ts` | mock `execFileNoThrow`, `log.ts` | 10 | -| `zodToJsonSchema.test.ts` | `src/utils/zodToJsonSchema.ts` | 无(仅依赖 zod) | 5 | -| `PermissionMode.test.ts` | `src/utils/permissions/PermissionMode.ts` | 视导出情况 | 5 | - -### Phase 4:工具模块扩展(~30 tests,3 files) - -| 测试文件 | 源文件 | 预估 | -|----------|--------|------| -| `bashPermissions.test.ts` | `src/tools/BashTool/` | 10 | -| `GlobTool.test.ts` | `src/tools/GlobTool/` | 10 | -| `mcpStringUtils.test.ts` | `src/services/mcp/mcpStringUtils.ts` | 10 | +> **已完成** — 实际增加 321 tests,从 647 → 968 tests / 52 files +> +> Phase 1-4 全部完成,详见上方 P3-P5 表格。 +> 实际调整: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__/mcpStringUtils.test.ts b/src/services/mcp/__tests__/mcpStringUtils.test.ts new file mode 100644 index 0000000..0b8d22b --- /dev/null +++ b/src/services/mcp/__tests__/mcpStringUtils.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from "bun:test"; +import { + mcpInfoFromString, + buildMcpToolName, + getMcpPrefix, + getMcpDisplayName, + getToolNameForPermissionCheck, + extractMcpToolDisplayName, +} from "../mcpStringUtils"; + +// ─── mcpInfoFromString ───────────────────────────────────────────────── + +describe("mcpInfoFromString", () => { + test("parses standard mcp tool name", () => { + const result = mcpInfoFromString("mcp__github__list_issues"); + expect(result).toEqual({ serverName: "github", toolName: "list_issues" }); + }); + + test("returns null for non-mcp string", () => { + expect(mcpInfoFromString("Bash")).toBeNull(); + expect(mcpInfoFromString("grep__pattern")).toBeNull(); + }); + + test("returns null when no server name", () => { + expect(mcpInfoFromString("mcp__")).toBeNull(); + }); + + test("handles server name only (no tool)", () => { + const result = mcpInfoFromString("mcp__server"); + expect(result).toEqual({ serverName: "server", toolName: undefined }); + }); + + test("preserves double underscores in tool name", () => { + const result = mcpInfoFromString("mcp__server__tool__with__underscores"); + expect(result).toEqual({ + serverName: "server", + toolName: "tool__with__underscores", + }); + }); + + test("returns null for empty string", () => { + expect(mcpInfoFromString("")).toBeNull(); + }); +}); + +// ─── getMcpPrefix ────────────────────────────────────────────────────── + +describe("getMcpPrefix", () => { + test("creates prefix from server name", () => { + expect(getMcpPrefix("github")).toBe("mcp__github__"); + }); + + test("normalizes server name with special chars", () => { + expect(getMcpPrefix("my-server")).toBe("mcp__my-server__"); + }); + + test("normalizes dots to underscores", () => { + expect(getMcpPrefix("my.server")).toBe("mcp__my_server__"); + }); +}); + +// ─── buildMcpToolName ────────────────────────────────────────────────── + +describe("buildMcpToolName", () => { + test("builds fully qualified name", () => { + expect(buildMcpToolName("github", "list_issues")).toBe( + "mcp__github__list_issues" + ); + }); + + test("normalizes both server and tool names", () => { + expect(buildMcpToolName("my.server", "my.tool")).toBe( + "mcp__my_server__my_tool" + ); + }); +}); + +// ─── getMcpDisplayName ───────────────────────────────────────────────── + +describe("getMcpDisplayName", () => { + test("strips mcp prefix from full name", () => { + expect(getMcpDisplayName("mcp__github__list_issues", "github")).toBe( + "list_issues" + ); + }); + + test("returns full name if prefix doesn't match", () => { + expect(getMcpDisplayName("mcp__other__tool", "github")).toBe( + "mcp__other__tool" + ); + }); +}); + +// ─── getToolNameForPermissionCheck ───────────────────────────────────── + +describe("getToolNameForPermissionCheck", () => { + test("returns built MCP name for MCP tools", () => { + const tool = { + name: "list_issues", + mcpInfo: { serverName: "github", toolName: "list_issues" }, + }; + expect(getToolNameForPermissionCheck(tool)).toBe( + "mcp__github__list_issues" + ); + }); + + test("returns tool name for non-MCP tools", () => { + const tool = { name: "Bash" }; + expect(getToolNameForPermissionCheck(tool)).toBe("Bash"); + }); + + test("returns tool name when mcpInfo is undefined", () => { + const tool = { name: "Write", mcpInfo: undefined }; + expect(getToolNameForPermissionCheck(tool)).toBe("Write"); + }); +}); + +// ─── extractMcpToolDisplayName ───────────────────────────────────────── + +describe("extractMcpToolDisplayName", () => { + test("extracts display name from full user-facing name", () => { + expect( + extractMcpToolDisplayName("github - Add comment to issue (MCP)") + ).toBe("Add comment to issue"); + }); + + test("removes (MCP) suffix only", () => { + expect(extractMcpToolDisplayName("simple-tool (MCP)")).toBe("simple-tool"); + }); + + test("handles name without (MCP) suffix", () => { + expect(extractMcpToolDisplayName("github - List issues")).toBe( + "List issues" + ); + }); + + test("handles name without dash separator", () => { + expect(extractMcpToolDisplayName("just-a-name")).toBe("just-a-name"); + }); +}); diff --git a/src/tools/BashTool/__tests__/commandSemantics.test.ts b/src/tools/BashTool/__tests__/commandSemantics.test.ts new file mode 100644 index 0000000..0d7f147 --- /dev/null +++ b/src/tools/BashTool/__tests__/commandSemantics.test.ts @@ -0,0 +1,87 @@ +import { mock, describe, expect, test } from "bun:test"; + +// Mock commands.ts to cut the heavy shell/prefix.ts → analytics → api chain +mock.module("src/utils/bash/commands.ts", () => ({ + splitCommand_DEPRECATED: (cmd: string) => + cmd.split(/\s*(?:[|;&]+)\s*/).filter(Boolean), + quote: (args: string[]) => args.join(" "), +})); + +const { interpretCommandResult } = await import("../commandSemantics"); + +describe("interpretCommandResult", () => { + // ─── Default semantics ──────────────────────────────────────────── + test("exit 0 is not an error for unknown commands", () => { + const result = interpretCommandResult("echo hello", 0, "hello", ""); + expect(result.isError).toBe(false); + }); + + test("non-zero exit is an error for unknown commands", () => { + const result = interpretCommandResult("echo hello", 1, "", "fail"); + expect(result.isError).toBe(true); + expect(result.message).toContain("exit code 1"); + }); + + // ─── grep semantics ────────────────────────────────────────────── + test("grep exit 0 is not an error", () => { + const result = interpretCommandResult("grep pattern file", 0, "match", ""); + expect(result.isError).toBe(false); + }); + + test("grep exit 1 means no matches (not error)", () => { + const result = interpretCommandResult("grep pattern file", 1, "", ""); + expect(result.isError).toBe(false); + expect(result.message).toBe("No matches found"); + }); + + test("grep exit 2 is an error", () => { + const result = interpretCommandResult("grep pattern file", 2, "", "err"); + expect(result.isError).toBe(true); + }); + + // ─── diff semantics ────────────────────────────────────────────── + test("diff exit 1 means files differ (not error)", () => { + const result = interpretCommandResult("diff a.txt b.txt", 1, "diff", ""); + expect(result.isError).toBe(false); + expect(result.message).toBe("Files differ"); + }); + + test("diff exit 2 is an error", () => { + const result = interpretCommandResult("diff a.txt b.txt", 2, "", "err"); + expect(result.isError).toBe(true); + }); + + // ─── test/[ semantics ──────────────────────────────────────────── + test("test exit 1 means condition false (not error)", () => { + const result = interpretCommandResult("test -f nofile", 1, "", ""); + expect(result.isError).toBe(false); + expect(result.message).toBe("Condition is false"); + }); + + // ─── piped commands ────────────────────────────────────────────── + test("uses last command in pipe for semantics", () => { + // "cat file | grep pattern" → last command is "grep pattern" + const result = interpretCommandResult( + "cat file | grep pattern", + 1, + "", + "" + ); + expect(result.isError).toBe(false); + expect(result.message).toBe("No matches found"); + }); + + // ─── rg (ripgrep) semantics ────────────────────────────────────── + test("rg exit 1 means no matches (not error)", () => { + const result = interpretCommandResult("rg pattern", 1, "", ""); + expect(result.isError).toBe(false); + expect(result.message).toBe("No matches found"); + }); + + // ─── find semantics ────────────────────────────────────────────── + test("find exit 1 is partial success", () => { + const result = interpretCommandResult("find . -name '*.ts'", 1, "", ""); + expect(result.isError).toBe(false); + expect(result.message).toBe("Some directories were inaccessible"); + }); +}); diff --git a/src/tools/BashTool/__tests__/destructiveCommandWarning.test.ts b/src/tools/BashTool/__tests__/destructiveCommandWarning.test.ts new file mode 100644 index 0000000..af16c3b --- /dev/null +++ b/src/tools/BashTool/__tests__/destructiveCommandWarning.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; +import { getDestructiveCommandWarning } from "../destructiveCommandWarning"; + +describe("getDestructiveCommandWarning", () => { + // ─── Git data loss ───────────────────────────────────────────────── + test("detects git reset --hard", () => { + const w = getDestructiveCommandWarning("git reset --hard HEAD~1"); + expect(w).toContain("discard uncommitted changes"); + }); + + test("detects git push --force", () => { + const w = getDestructiveCommandWarning("git push --force origin main"); + expect(w).toContain("overwrite remote history"); + }); + + test("detects git push -f", () => { + expect(getDestructiveCommandWarning("git push -f")).toContain( + "overwrite remote history" + ); + }); + + test("detects git clean -f", () => { + const w = getDestructiveCommandWarning("git clean -fd"); + expect(w).toContain("delete untracked files"); + }); + + test("does not flag git clean --dry-run", () => { + expect(getDestructiveCommandWarning("git clean -fdn")).toBeNull(); + }); + + test("detects git checkout .", () => { + const w = getDestructiveCommandWarning("git checkout -- ."); + expect(w).toContain("discard all working tree changes"); + }); + + test("detects git restore .", () => { + const w = getDestructiveCommandWarning("git restore -- ."); + expect(w).toContain("discard all working tree changes"); + }); + + test("detects git stash drop", () => { + const w = getDestructiveCommandWarning("git stash drop"); + expect(w).toContain("remove stashed changes"); + }); + + test("detects git branch -D", () => { + const w = getDestructiveCommandWarning("git branch -D feature"); + expect(w).toContain("force-delete a branch"); + }); + + // ─── Git safety bypass ──────────────────────────────────────────── + test("detects --no-verify", () => { + const w = getDestructiveCommandWarning("git commit --no-verify -m 'x'"); + expect(w).toContain("skip safety hooks"); + }); + + test("detects git commit --amend", () => { + const w = getDestructiveCommandWarning("git commit --amend"); + expect(w).toContain("rewrite the last commit"); + }); + + // ─── File deletion ──────────────────────────────────────────────── + test("detects rm -rf", () => { + const w = getDestructiveCommandWarning("rm -rf /tmp/dir"); + expect(w).toContain("recursively force-remove"); + }); + + test("detects rm -r", () => { + const w = getDestructiveCommandWarning("rm -r dir"); + expect(w).toContain("recursively remove"); + }); + + test("detects rm -f", () => { + const w = getDestructiveCommandWarning("rm -f file.txt"); + expect(w).toContain("force-remove"); + }); + + // ─── Database ───────────────────────────────────────────────────── + test("detects DROP TABLE", () => { + const w = getDestructiveCommandWarning("psql -c 'DROP TABLE users'"); + expect(w).toContain("drop or truncate"); + }); + + test("detects TRUNCATE TABLE", () => { + const w = getDestructiveCommandWarning("TRUNCATE TABLE logs"); + expect(w).toContain("drop or truncate"); + }); + + test("detects DELETE FROM without WHERE", () => { + const w = getDestructiveCommandWarning("DELETE FROM users;"); + expect(w).toContain("delete all rows"); + }); + + // ─── Infrastructure ─────────────────────────────────────────────── + test("detects kubectl delete", () => { + const w = getDestructiveCommandWarning("kubectl delete pod my-pod"); + expect(w).toContain("delete Kubernetes"); + }); + + test("detects terraform destroy", () => { + const w = getDestructiveCommandWarning("terraform destroy"); + expect(w).toContain("destroy Terraform"); + }); + + // ─── Safe commands ──────────────────────────────────────────────── + test("returns null for safe commands", () => { + expect(getDestructiveCommandWarning("ls -la")).toBeNull(); + expect(getDestructiveCommandWarning("git status")).toBeNull(); + expect(getDestructiveCommandWarning("npm install")).toBeNull(); + expect(getDestructiveCommandWarning("cat file.txt")).toBeNull(); + }); +}); diff --git a/src/utils/__tests__/envUtils.test.ts b/src/utils/__tests__/envUtils.test.ts new file mode 100644 index 0000000..cd37067 --- /dev/null +++ b/src/utils/__tests__/envUtils.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { + isEnvTruthy, + isEnvDefinedFalsy, + parseEnvVars, + hasNodeOption, + getAWSRegion, + getDefaultVertexRegion, + getVertexRegionForModel, + isBareMode, + shouldMaintainProjectWorkingDir, + getClaudeConfigHomeDir, +} from "../envUtils"; + +// ─── isEnvTruthy ─────────────────────────────────────────────────────── + +describe("isEnvTruthy", () => { + test("returns true for '1'", () => { + expect(isEnvTruthy("1")).toBe(true); + }); + + test("returns true for 'true'", () => { + expect(isEnvTruthy("true")).toBe(true); + }); + + test("returns true for 'TRUE'", () => { + expect(isEnvTruthy("TRUE")).toBe(true); + }); + + test("returns true for 'yes'", () => { + expect(isEnvTruthy("yes")).toBe(true); + }); + + test("returns true for 'on'", () => { + expect(isEnvTruthy("on")).toBe(true); + }); + + test("returns true for boolean true", () => { + expect(isEnvTruthy(true)).toBe(true); + }); + + test("returns false for '0'", () => { + expect(isEnvTruthy("0")).toBe(false); + }); + + test("returns false for 'false'", () => { + expect(isEnvTruthy("false")).toBe(false); + }); + + test("returns false for empty string", () => { + expect(isEnvTruthy("")).toBe(false); + }); + + test("returns false for undefined", () => { + expect(isEnvTruthy(undefined)).toBe(false); + }); + + test("returns false for boolean false", () => { + expect(isEnvTruthy(false)).toBe(false); + }); + + test("returns true for ' true ' (trimmed)", () => { + expect(isEnvTruthy(" true ")).toBe(true); + }); +}); + +// ─── isEnvDefinedFalsy ───────────────────────────────────────────────── + +describe("isEnvDefinedFalsy", () => { + test("returns true for '0'", () => { + expect(isEnvDefinedFalsy("0")).toBe(true); + }); + + test("returns true for 'false'", () => { + expect(isEnvDefinedFalsy("false")).toBe(true); + }); + + test("returns true for 'no'", () => { + expect(isEnvDefinedFalsy("no")).toBe(true); + }); + + test("returns true for 'off'", () => { + expect(isEnvDefinedFalsy("off")).toBe(true); + }); + + test("returns true for boolean false", () => { + expect(isEnvDefinedFalsy(false)).toBe(true); + }); + + test("returns false for undefined", () => { + expect(isEnvDefinedFalsy(undefined)).toBe(false); + }); + + test("returns false for '1'", () => { + expect(isEnvDefinedFalsy("1")).toBe(false); + }); + + test("returns false for 'true'", () => { + expect(isEnvDefinedFalsy("true")).toBe(false); + }); + + test("returns false for empty string", () => { + expect(isEnvDefinedFalsy("")).toBe(false); + }); +}); + +// ─── parseEnvVars ────────────────────────────────────────────────────── + +describe("parseEnvVars", () => { + test("parses KEY=VALUE pairs", () => { + const result = parseEnvVars(["FOO=bar", "BAZ=qux"]); + expect(result).toEqual({ FOO: "bar", BAZ: "qux" }); + }); + + test("handles value with equals sign", () => { + const result = parseEnvVars(["URL=http://host?a=1&b=2"]); + expect(result).toEqual({ URL: "http://host?a=1&b=2" }); + }); + + test("returns empty object for undefined", () => { + expect(parseEnvVars(undefined)).toEqual({}); + }); + + test("returns empty object for empty array", () => { + expect(parseEnvVars([])).toEqual({}); + }); + + test("throws for missing value", () => { + expect(() => parseEnvVars(["NOVALUE"])).toThrow("Invalid environment variable format"); + }); + + test("throws for empty key", () => { + expect(() => parseEnvVars(["=value"])).toThrow("Invalid environment variable format"); + }); +}); + +// ─── hasNodeOption ───────────────────────────────────────────────────── + +describe("hasNodeOption", () => { + const saved = process.env.NODE_OPTIONS; + afterEach(() => { + if (saved === undefined) delete process.env.NODE_OPTIONS; + else process.env.NODE_OPTIONS = saved; + }); + + test("returns true when flag present", () => { + process.env.NODE_OPTIONS = "--max-old-space-size=4096 --inspect"; + expect(hasNodeOption("--inspect")).toBe(true); + }); + + test("returns false when flag absent", () => { + process.env.NODE_OPTIONS = "--max-old-space-size=4096"; + expect(hasNodeOption("--inspect")).toBe(false); + }); + + test("returns false when NODE_OPTIONS not set", () => { + delete process.env.NODE_OPTIONS; + expect(hasNodeOption("--inspect")).toBe(false); + }); + + test("does not match partial flags", () => { + process.env.NODE_OPTIONS = "--inspect-brk"; + expect(hasNodeOption("--inspect")).toBe(false); + }); +}); + +// ─── getAWSRegion ────────────────────────────────────────────────────── + +describe("getAWSRegion", () => { + const savedRegion = process.env.AWS_REGION; + const savedDefault = process.env.AWS_DEFAULT_REGION; + + afterEach(() => { + if (savedRegion === undefined) delete process.env.AWS_REGION; + else process.env.AWS_REGION = savedRegion; + if (savedDefault === undefined) delete process.env.AWS_DEFAULT_REGION; + else process.env.AWS_DEFAULT_REGION = savedDefault; + }); + + test("uses AWS_REGION when set", () => { + process.env.AWS_REGION = "eu-west-1"; + expect(getAWSRegion()).toBe("eu-west-1"); + }); + + test("falls back to AWS_DEFAULT_REGION", () => { + delete process.env.AWS_REGION; + process.env.AWS_DEFAULT_REGION = "ap-northeast-1"; + expect(getAWSRegion()).toBe("ap-northeast-1"); + }); + + test("defaults to us-east-1", () => { + delete process.env.AWS_REGION; + delete process.env.AWS_DEFAULT_REGION; + expect(getAWSRegion()).toBe("us-east-1"); + }); +}); + +// ─── getDefaultVertexRegion ──────────────────────────────────────────── + +describe("getDefaultVertexRegion", () => { + const saved = process.env.CLOUD_ML_REGION; + afterEach(() => { + if (saved === undefined) delete process.env.CLOUD_ML_REGION; + else process.env.CLOUD_ML_REGION = saved; + }); + + test("uses CLOUD_ML_REGION when set", () => { + process.env.CLOUD_ML_REGION = "europe-west4"; + expect(getDefaultVertexRegion()).toBe("europe-west4"); + }); + + test("defaults to us-east5", () => { + delete process.env.CLOUD_ML_REGION; + expect(getDefaultVertexRegion()).toBe("us-east5"); + }); +}); + +// ─── getVertexRegionForModel ─────────────────────────────────────────── + +describe("getVertexRegionForModel", () => { + const envKeys = [ + "VERTEX_REGION_CLAUDE_HAIKU_4_5", + "VERTEX_REGION_CLAUDE_4_0_SONNET", + "VERTEX_REGION_CLAUDE_4_6_SONNET", + "CLOUD_ML_REGION", + ]; + const saved: Record = {}; + + beforeEach(() => { + for (const k of envKeys) saved[k] = process.env[k]; + }); + afterEach(() => { + for (const k of envKeys) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + }); + + test("returns model-specific override when set", () => { + process.env.VERTEX_REGION_CLAUDE_HAIKU_4_5 = "us-central1"; + expect(getVertexRegionForModel("claude-haiku-4-5-20251001")).toBe("us-central1"); + }); + + test("falls back to default vertex region when override not set", () => { + delete process.env.VERTEX_REGION_CLAUDE_4_0_SONNET; + delete process.env.CLOUD_ML_REGION; + expect(getVertexRegionForModel("claude-sonnet-4-some-variant")).toBe("us-east5"); + }); + + test("returns default region for unknown model prefix", () => { + delete process.env.CLOUD_ML_REGION; + expect(getVertexRegionForModel("unknown-model-123")).toBe("us-east5"); + }); + + test("returns default region for undefined model", () => { + delete process.env.CLOUD_ML_REGION; + expect(getVertexRegionForModel(undefined)).toBe("us-east5"); + }); +}); + +// ─── isBareMode ──────────────────────────────────────────────────────── + +describe("isBareMode", () => { + const saved = process.env.CLAUDE_CODE_SIMPLE; + const originalArgv = [...process.argv]; + + afterEach(() => { + if (saved === undefined) delete process.env.CLAUDE_CODE_SIMPLE; + else process.env.CLAUDE_CODE_SIMPLE = saved; + process.argv.length = 0; + process.argv.push(...originalArgv); + }); + + test("returns true when CLAUDE_CODE_SIMPLE=1", () => { + process.env.CLAUDE_CODE_SIMPLE = "1"; + expect(isBareMode()).toBe(true); + }); + + test("returns true when --bare in argv", () => { + process.argv.push("--bare"); + expect(isBareMode()).toBe(true); + }); + + test("returns false when neither set", () => { + delete process.env.CLAUDE_CODE_SIMPLE; + // argv doesn't have --bare by default + expect(isBareMode()).toBe(false); + }); +}); + +// ─── shouldMaintainProjectWorkingDir ─────────────────────────────────── + +describe("shouldMaintainProjectWorkingDir", () => { + const saved = process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR; + + afterEach(() => { + if (saved === undefined) delete process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR; + else process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR = saved; + }); + + test("returns true when set to truthy", () => { + process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR = "1"; + expect(shouldMaintainProjectWorkingDir()).toBe(true); + }); + + test("returns false when not set", () => { + delete process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR; + expect(shouldMaintainProjectWorkingDir()).toBe(false); + }); +}); + +// ─── getClaudeConfigHomeDir ──────────────────────────────────────────── + +describe("getClaudeConfigHomeDir", () => { + const saved = process.env.CLAUDE_CONFIG_DIR; + + afterEach(() => { + if (saved === undefined) delete process.env.CLAUDE_CONFIG_DIR; + else process.env.CLAUDE_CONFIG_DIR = saved; + }); + + test("uses CLAUDE_CONFIG_DIR when set", () => { + process.env.CLAUDE_CONFIG_DIR = "/tmp/test-claude"; + // Memoized by CLAUDE_CONFIG_DIR key, so changing env gives fresh value + expect(getClaudeConfigHomeDir()).toBe("/tmp/test-claude"); + }); + + test("returns a string ending with .claude by default", () => { + delete process.env.CLAUDE_CONFIG_DIR; + const result = getClaudeConfigHomeDir(); + expect(result).toMatch(/\.claude$/); + }); +}); diff --git a/src/utils/__tests__/envValidation.test.ts b/src/utils/__tests__/envValidation.test.ts new file mode 100644 index 0000000..553d80c --- /dev/null +++ b/src/utils/__tests__/envValidation.test.ts @@ -0,0 +1,74 @@ +import { mock, describe, expect, test } from "bun:test"; + +// 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: () => {}, +})); + +const { validateBoundedIntEnvVar } = await import("../envValidation"); + +describe("validateBoundedIntEnvVar", () => { + test("returns default when value is undefined", () => { + const result = validateBoundedIntEnvVar("TEST_VAR", undefined, 100, 1000); + expect(result).toEqual({ effective: 100, status: "valid" }); + }); + + test("returns default when value is empty string", () => { + const result = validateBoundedIntEnvVar("TEST_VAR", "", 100, 1000); + expect(result).toEqual({ effective: 100, status: "valid" }); + }); + + test("returns parsed value when valid and within limit", () => { + const result = validateBoundedIntEnvVar("TEST_VAR", "500", 100, 1000); + expect(result).toEqual({ effective: 500, status: "valid" }); + }); + + test("caps value at upper limit", () => { + const result = validateBoundedIntEnvVar("TEST_VAR", "2000", 100, 1000); + expect(result.effective).toBe(1000); + expect(result.status).toBe("capped"); + expect(result.message).toContain("Capped from 2000 to 1000"); + }); + + test("returns default for non-numeric value", () => { + const result = validateBoundedIntEnvVar("TEST_VAR", "abc", 100, 1000); + expect(result.effective).toBe(100); + expect(result.status).toBe("invalid"); + expect(result.message).toContain("Invalid value"); + }); + + test("returns default for zero", () => { + const result = validateBoundedIntEnvVar("TEST_VAR", "0", 100, 1000); + expect(result.effective).toBe(100); + expect(result.status).toBe("invalid"); + }); + + test("returns default for negative value", () => { + const result = validateBoundedIntEnvVar("TEST_VAR", "-5", 100, 1000); + expect(result.effective).toBe(100); + expect(result.status).toBe("invalid"); + }); + + test("handles value at exact upper limit", () => { + const result = validateBoundedIntEnvVar("TEST_VAR", "1000", 100, 1000); + expect(result.effective).toBe(1000); + expect(result.status).toBe("valid"); + }); + + test("handles value of 1 (minimum valid)", () => { + const result = validateBoundedIntEnvVar("TEST_VAR", "1", 100, 1000); + expect(result.effective).toBe(1); + expect(result.status).toBe("valid"); + }); +}); diff --git a/src/utils/__tests__/groupToolUses.test.ts b/src/utils/__tests__/groupToolUses.test.ts new file mode 100644 index 0000000..af77814 --- /dev/null +++ b/src/utils/__tests__/groupToolUses.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test } from "bun:test"; +import { applyGrouping } from "../groupToolUses"; + +// Helper: build minimal tool-use assistant message +function makeToolUseMsg( + uuid: string, + messageId: string, + toolUseId: string, + toolName: string +): any { + return { + type: "assistant", + uuid, + timestamp: Date.now(), + message: { + id: messageId, + content: [{ type: "tool_use", id: toolUseId, name: toolName, input: {} }], + }, + }; +} + +// Helper: build minimal tool-result user message +function makeToolResultMsg(uuid: string, toolUseId: string): any { + return { + type: "user", + uuid, + timestamp: Date.now(), + message: { + content: [{ type: "tool_result", tool_use_id: toolUseId, content: "ok" }], + }, + }; +} + +// Helper: build minimal text assistant message +function makeTextMsg(uuid: string, text: string): any { + return { + type: "assistant", + uuid, + timestamp: Date.now(), + message: { id: `msg-${uuid}`, content: [{ type: "text", text }] }, + }; +} + +// Minimal tool definitions +const groupableTool: any = { name: "Grep", renderGroupedToolUse: true }; +const nonGroupableTool: any = { name: "Bash", renderGroupedToolUse: undefined }; + +// ─── applyGrouping ──────────────────────────────────────────────────── + +describe("applyGrouping", () => { + test("returns all messages in verbose mode", () => { + const msgs = [ + makeToolUseMsg("u1", "m1", "tu1", "Grep"), + makeToolUseMsg("u2", "m1", "tu2", "Grep"), + ]; + const result = applyGrouping(msgs, [groupableTool], true); + expect(result.messages).toHaveLength(2); + expect(result.messages).toBe(msgs); // same reference + }); + + test("does not group when tool lacks renderGroupedToolUse", () => { + const msgs = [ + makeToolUseMsg("u1", "m1", "tu1", "Bash"), + makeToolUseMsg("u2", "m1", "tu2", "Bash"), + ]; + const result = applyGrouping(msgs, [nonGroupableTool]); + expect(result.messages).toHaveLength(2); + // Both messages should pass through as-is + expect(result.messages[0]).toBe(msgs[0]); + }); + + test("does not group single tool use", () => { + const msgs = [makeToolUseMsg("u1", "m1", "tu1", "Grep")]; + const result = applyGrouping(msgs, [groupableTool]); + expect(result.messages).toHaveLength(1); + expect((result.messages[0] as any).type).toBe("assistant"); + }); + + test("groups 2+ tool uses of same type from same message", () => { + const msgs = [ + makeToolUseMsg("u1", "m1", "tu1", "Grep"), + makeToolUseMsg("u2", "m1", "tu2", "Grep"), + makeToolUseMsg("u3", "m1", "tu3", "Grep"), + ]; + const result = applyGrouping(msgs, [groupableTool]); + expect(result.messages).toHaveLength(1); + const grouped = result.messages[0] as any; + expect(grouped.type).toBe("grouped_tool_use"); + expect(grouped.toolName).toBe("Grep"); + expect(grouped.messages).toHaveLength(3); + }); + + test("does not group tool uses from different messages", () => { + const msgs = [ + makeToolUseMsg("u1", "m1", "tu1", "Grep"), + makeToolUseMsg("u2", "m2", "tu2", "Grep"), + ]; + const result = applyGrouping(msgs, [groupableTool]); + // Each belongs to a different message.id, so no group (< 2 per group) + expect(result.messages).toHaveLength(2); + }); + + test("collects tool results for grouped uses", () => { + const msgs = [ + makeToolUseMsg("u1", "m1", "tu1", "Grep"), + makeToolUseMsg("u2", "m1", "tu2", "Grep"), + makeToolResultMsg("u3", "tu1"), + makeToolResultMsg("u4", "tu2"), + ]; + const result = applyGrouping(msgs, [groupableTool]); + const grouped = result.messages[0] as any; + expect(grouped.type).toBe("grouped_tool_use"); + expect(grouped.results).toHaveLength(2); + }); + + test("skips user messages whose tool_results are all grouped", () => { + const msgs = [ + makeToolUseMsg("u1", "m1", "tu1", "Grep"), + makeToolUseMsg("u2", "m1", "tu2", "Grep"), + makeToolResultMsg("u3", "tu1"), + makeToolResultMsg("u4", "tu2"), + ]; + const result = applyGrouping(msgs, [groupableTool]); + // Only the grouped message should remain — result messages are consumed + expect(result.messages).toHaveLength(1); + }); + + test("preserves non-grouped messages alongside groups", () => { + const msgs = [ + makeTextMsg("u0", "thinking..."), + makeToolUseMsg("u1", "m1", "tu1", "Grep"), + makeToolUseMsg("u2", "m1", "tu2", "Grep"), + makeTextMsg("u3", "done"), + ]; + const result = applyGrouping(msgs, [groupableTool]); + expect(result.messages).toHaveLength(3); // text + grouped + text + expect((result.messages[0] as any).type).toBe("assistant"); + expect((result.messages[1] as any).type).toBe("grouped_tool_use"); + expect((result.messages[2] as any).type).toBe("assistant"); + }); + + test("handles empty messages array", () => { + const result = applyGrouping([], [groupableTool]); + expect(result.messages).toHaveLength(0); + }); + + test("handles empty tools array", () => { + const msgs = [makeToolUseMsg("u1", "m1", "tu1", "Grep")]; + const result = applyGrouping(msgs, []); + expect(result.messages).toHaveLength(1); + }); +}); diff --git a/src/utils/__tests__/memoize.test.ts b/src/utils/__tests__/memoize.test.ts new file mode 100644 index 0000000..22ed0d1 --- /dev/null +++ b/src/utils/__tests__/memoize.test.ts @@ -0,0 +1,240 @@ +import { mock, describe, expect, test, beforeEach } from "bun:test"; + +// Mock heavy deps before importing memoize +mock.module("src/utils/log.ts", () => ({ + logError: () => {}, + logToFile: () => {}, + getLogDisplayTitle: () => "", + logEvent: () => {}, +})); +mock.module("src/utils/slowOperations.ts", () => ({ + jsonStringify: JSON.stringify, + jsonParse: JSON.parse, + slowLogging: { enabled: false }, + clone: (v: any) => structuredClone(v), + cloneDeep: (v: any) => structuredClone(v), + callerFrame: () => "", + SLOW_OPERATION_THRESHOLD_MS: 100, + writeFileSync_DEPRECATED: () => {}, +})); + +const { memoizeWithTTL, memoizeWithTTLAsync, memoizeWithLRU } = await import( + "../memoize" +); + +// ─── memoizeWithTTL ──────────────────────────────────────────────────── + +describe("memoizeWithTTL", () => { + test("returns cached value on second call", () => { + let calls = 0; + const fn = memoizeWithTTL((x: number) => { + calls++; + return x * 2; + }, 60_000); + + expect(fn(5)).toBe(10); + expect(fn(5)).toBe(10); + expect(calls).toBe(1); + }); + + test("different args get separate cache entries", () => { + let calls = 0; + const fn = memoizeWithTTL((x: number) => { + calls++; + return x + 1; + }, 60_000); + + expect(fn(1)).toBe(2); + expect(fn(2)).toBe(3); + expect(calls).toBe(2); + }); + + test("cache.clear empties the cache", () => { + let calls = 0; + const fn = memoizeWithTTL(() => { + calls++; + return "val"; + }, 60_000); + + fn(); + fn.cache.clear(); + fn(); + expect(calls).toBe(2); + }); + + test("returns stale value and triggers background refresh after TTL", async () => { + let calls = 0; + const fn = memoizeWithTTL((x: number) => { + calls++; + return x * calls; + }, 1); // 1ms TTL + + const first = fn(10); + expect(first).toBe(10); // calls=1, 10*1 + + // Wait for TTL to expire + await new Promise((r) => setTimeout(r, 10)); + + // Should return stale value (10) and trigger background refresh + const second = fn(10); + expect(second).toBe(10); // stale value returned immediately + + // Wait for background refresh microtask + await new Promise((r) => setTimeout(r, 10)); + + // Now cache should have refreshed value (calls=2 during refresh, 10*2=20) + const third = fn(10); + expect(third).toBe(20); + }); +}); + +// ─── memoizeWithTTLAsync ─────────────────────────────────────────────── + +describe("memoizeWithTTLAsync", () => { + test("caches async result", async () => { + let calls = 0; + const fn = memoizeWithTTLAsync(async (x: number) => { + calls++; + return x * 2; + }, 60_000); + + expect(await fn(5)).toBe(10); + expect(await fn(5)).toBe(10); + expect(calls).toBe(1); + }); + + test("deduplicates concurrent cold-miss calls", async () => { + let calls = 0; + const fn = memoizeWithTTLAsync(async (x: number) => { + calls++; + await new Promise((r) => setTimeout(r, 20)); + return x; + }, 60_000); + + const [a, b, c] = await Promise.all([fn(1), fn(1), fn(1)]); + expect(a).toBe(1); + expect(b).toBe(1); + expect(c).toBe(1); + expect(calls).toBe(1); + }); + + test("cache.clear forces re-computation", async () => { + let calls = 0; + const fn = memoizeWithTTLAsync(async () => { + calls++; + return "v"; + }, 60_000); + + await fn(); + fn.cache.clear(); + await fn(); + expect(calls).toBe(2); + }); + + test("returns stale value on TTL expiry", async () => { + let calls = 0; + const fn = memoizeWithTTLAsync(async () => { + calls++; + return calls; + }, 1); // 1ms TTL + + const first = await fn(); + expect(first).toBe(1); + + await new Promise((r) => setTimeout(r, 10)); + + // Should return stale value (1) immediately + const second = await fn(); + expect(second).toBe(1); + }); +}); + +// ─── memoizeWithLRU ──────────────────────────────────────────────────── + +describe("memoizeWithLRU", () => { + test("caches results by key", () => { + let calls = 0; + const fn = memoizeWithLRU( + (x: number) => { + calls++; + return x * 2; + }, + (x) => String(x), + 10 + ); + + expect(fn(5)).toBe(10); + expect(fn(5)).toBe(10); + expect(calls).toBe(1); + }); + + test("evicts least recently used when max reached", () => { + let calls = 0; + const fn = memoizeWithLRU( + (x: number) => { + calls++; + return x; + }, + (x) => String(x), + 3 + ); + + fn(1); + fn(2); + fn(3); + expect(calls).toBe(3); + + fn(4); // evicts key "1" + expect(fn.cache.has("1")).toBe(false); + expect(fn.cache.has("4")).toBe(true); + }); + + test("cache.size returns current size", () => { + const fn = memoizeWithLRU( + (x: number) => x, + (x) => String(x), + 10 + ); + + fn(1); + fn(2); + expect(fn.cache.size()).toBe(2); + }); + + test("cache.delete removes entry", () => { + const fn = memoizeWithLRU( + (x: number) => x, + (x) => String(x), + 10 + ); + + fn(1); + expect(fn.cache.has("1")).toBe(true); + fn.cache.delete("1"); + expect(fn.cache.has("1")).toBe(false); + }); + + test("cache.get returns value without updating recency", () => { + const fn = memoizeWithLRU( + (x: number) => x * 10, + (x) => String(x), + 10 + ); + + fn(5); + expect(fn.cache.get("5")).toBe(50); + }); + + test("cache.clear empties everything", () => { + const fn = memoizeWithLRU( + (x: number) => x, + (x) => String(x), + 10 + ); + + fn(1); + fn(2); + fn.cache.clear(); + expect(fn.cache.size()).toBe(0); + }); +}); diff --git a/src/utils/__tests__/sleep.test.ts b/src/utils/__tests__/sleep.test.ts new file mode 100644 index 0000000..65d8fed --- /dev/null +++ b/src/utils/__tests__/sleep.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "bun:test"; +import { sleep, withTimeout } from "../sleep"; +import { sequential } from "../sequential"; + +// ─── sleep ───────────────────────────────────────────────────────────── + +describe("sleep", () => { + test("resolves after timeout", async () => { + const start = Date.now(); + await sleep(50); + expect(Date.now() - start).toBeGreaterThanOrEqual(40); + }); + + test("resolves immediately when signal already aborted", async () => { + const ac = new AbortController(); + ac.abort(); + const start = Date.now(); + await sleep(10_000, ac.signal); + expect(Date.now() - start).toBeLessThan(50); + }); + + test("resolves early on abort (default: no throw)", async () => { + const ac = new AbortController(); + const start = Date.now(); + const p = sleep(10_000, ac.signal); + setTimeout(() => ac.abort(), 30); + await p; + expect(Date.now() - start).toBeLessThan(200); + }); + + test("rejects on abort with throwOnAbort", async () => { + const ac = new AbortController(); + ac.abort(); + await expect( + sleep(10_000, ac.signal, { throwOnAbort: true }) + ).rejects.toThrow("aborted"); + }); + + test("rejects with custom abortError", async () => { + const ac = new AbortController(); + ac.abort(); + const customErr = () => new Error("custom abort"); + await expect( + sleep(10_000, ac.signal, { abortError: customErr }) + ).rejects.toThrow("custom abort"); + }); + + test("throwOnAbort rejects on mid-sleep abort", async () => { + const ac = new AbortController(); + const p = sleep(10_000, ac.signal, { throwOnAbort: true }); + setTimeout(() => ac.abort(), 20); + await expect(p).rejects.toThrow("aborted"); + }); + + test("works without signal", async () => { + await sleep(10); + // just verify it resolves + }); +}); + +// ─── withTimeout ─────────────────────────────────────────────────────── + +describe("withTimeout", () => { + test("resolves when promise completes before timeout", async () => { + const result = await withTimeout( + Promise.resolve(42), + 1000, + "timed out" + ); + expect(result).toBe(42); + }); + + test("rejects when promise takes too long", async () => { + const slow = new Promise((resolve) => setTimeout(resolve, 5000)); + await expect( + withTimeout(slow, 50, "operation timed out") + ).rejects.toThrow("operation timed out"); + }); + + test("rejects propagate through", async () => { + await expect( + withTimeout(Promise.reject(new Error("inner")), 1000, "timeout") + ).rejects.toThrow("inner"); + }); +}); + +// ─── sequential ──────────────────────────────────────────────────────── + +describe("sequential", () => { + test("executes calls in order", async () => { + const order: number[] = []; + const fn = sequential(async (n: number) => { + await sleep(10); + order.push(n); + return n; + }); + + const results = await Promise.all([fn(1), fn(2), fn(3)]); + expect(order).toEqual([1, 2, 3]); + expect(results).toEqual([1, 2, 3]); + }); + + test("returns correct result for each call", async () => { + const fn = sequential(async (x: number) => x * 2); + const r1 = await fn(5); + const r2 = await fn(10); + expect(r1).toBe(10); + expect(r2).toBe(20); + }); + + test("propagates errors without blocking queue", async () => { + const fn = sequential(async (x: number) => { + if (x === 2) throw new Error("fail"); + return x; + }); + + const p1 = fn(1); + const p2 = fn(2); + const p3 = fn(3); + + expect(await p1).toBe(1); + await expect(p2).rejects.toThrow("fail"); + expect(await p3).toBe(3); + }); + + test("handles single call", async () => { + const fn = sequential(async (s: string) => s.toUpperCase()); + expect(await fn("hello")).toBe("HELLO"); + }); +}); diff --git a/src/utils/__tests__/zodToJsonSchema.test.ts b/src/utils/__tests__/zodToJsonSchema.test.ts new file mode 100644 index 0000000..f759588 --- /dev/null +++ b/src/utils/__tests__/zodToJsonSchema.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; +import z from "zod/v4"; +import { zodToJsonSchema } from "../zodToJsonSchema"; + +describe("zodToJsonSchema", () => { + test("converts string schema", () => { + const schema = z.string(); + const result = zodToJsonSchema(schema); + expect(result.type).toBe("string"); + }); + + test("converts number schema", () => { + const schema = z.number(); + const result = zodToJsonSchema(schema); + expect(result.type).toBe("number"); + }); + + test("converts object schema with properties", () => { + const schema = z.object({ + name: z.string(), + age: z.number(), + }); + const result = zodToJsonSchema(schema); + expect(result.type).toBe("object"); + expect(result.properties).toBeDefined(); + expect((result.properties as any).name).toBeDefined(); + expect((result.properties as any).age).toBeDefined(); + }); + + test("converts enum schema", () => { + const schema = z.enum(["a", "b", "c"]); + const result = zodToJsonSchema(schema); + expect(result.enum).toEqual(["a", "b", "c"]); + }); + + test("converts optional fields", () => { + const schema = z.object({ + required: z.string(), + optional: z.string().optional(), + }); + const result = zodToJsonSchema(schema); + expect(result.required).toContain("required"); + }); + + test("caches results for same schema reference", () => { + const schema = z.string(); + const first = zodToJsonSchema(schema); + const second = zodToJsonSchema(schema); + expect(first).toBe(second); // same reference (cached) + }); + + test("different schemas get different results", () => { + const s1 = z.string(); + const s2 = z.number(); + const r1 = zodToJsonSchema(s1); + const r2 = zodToJsonSchema(s2); + expect(r1).not.toBe(r2); + expect(r1.type).not.toBe(r2.type); + }); + + test("converts array schema", () => { + const schema = z.array(z.string()); + const result = zodToJsonSchema(schema); + expect(result.type).toBe("array"); + expect((result.items as any).type).toBe("string"); + }); + + test("converts boolean schema", () => { + const result = zodToJsonSchema(z.boolean()); + expect(result.type).toBe("boolean"); + }); +}); diff --git a/src/utils/permissions/__tests__/PermissionMode.test.ts b/src/utils/permissions/__tests__/PermissionMode.test.ts new file mode 100644 index 0000000..50cbc31 --- /dev/null +++ b/src/utils/permissions/__tests__/PermissionMode.test.ts @@ -0,0 +1,162 @@ +import { mock, describe, expect, test } from "bun:test"; + +// Mock slowOperations to cut bootstrap/state dependency chain +// (figures.js → env.js → fsOperations.js → slowOperations.js → bootstrap/state.js) +mock.module("src/utils/slowOperations.ts", () => ({ + jsonStringify: JSON.stringify, + jsonParse: JSON.parse, + slowLogging: { enabled: false }, + clone: (v: any) => structuredClone(v), + cloneDeep: (v: any) => structuredClone(v), + callerFrame: () => "", + SLOW_OPERATION_THRESHOLD_MS: 100, + writeFileSync_DEPRECATED: () => {}, +})); +mock.module("src/utils/log.ts", () => ({ + logError: () => {}, + logToFile: () => {}, + getLogDisplayTitle: () => "", + logEvent: () => {}, +})); + +const { + isExternalPermissionMode, + toExternalPermissionMode, + permissionModeFromString, + permissionModeTitle, + isDefaultMode, + permissionModeShortTitle, + permissionModeSymbol, + getModeColor, + PERMISSION_MODES, + EXTERNAL_PERMISSION_MODES, +} = await import("../PermissionMode"); + +// ─── PERMISSION_MODES / EXTERNAL_PERMISSION_MODES ────────────────────── + +describe("PERMISSION_MODES", () => { + test("includes all external modes", () => { + for (const m of EXTERNAL_PERMISSION_MODES) { + expect(PERMISSION_MODES).toContain(m); + } + }); + + test("includes default, plan, acceptEdits, bypassPermissions, dontAsk", () => { + expect(PERMISSION_MODES).toContain("default"); + expect(PERMISSION_MODES).toContain("plan"); + expect(PERMISSION_MODES).toContain("acceptEdits"); + expect(PERMISSION_MODES).toContain("bypassPermissions"); + expect(PERMISSION_MODES).toContain("dontAsk"); + }); +}); + +// ─── permissionModeFromString ────────────────────────────────────────── + +describe("permissionModeFromString", () => { + test("returns valid mode for known string", () => { + expect(permissionModeFromString("plan")).toBe("plan"); + expect(permissionModeFromString("default")).toBe("default"); + expect(permissionModeFromString("dontAsk")).toBe("dontAsk"); + }); + + test("returns 'default' for unknown string", () => { + expect(permissionModeFromString("unknown")).toBe("default"); + expect(permissionModeFromString("")).toBe("default"); + }); +}); + +// ─── permissionModeTitle ─────────────────────────────────────────────── + +describe("permissionModeTitle", () => { + test("returns title for known modes", () => { + expect(permissionModeTitle("default")).toBe("Default"); + expect(permissionModeTitle("plan")).toBe("Plan Mode"); + expect(permissionModeTitle("acceptEdits")).toBe("Accept edits"); + }); + + test("falls back to Default for unknown mode", () => { + expect(permissionModeTitle("nonexistent" as any)).toBe("Default"); + }); +}); + +// ─── permissionModeShortTitle ────────────────────────────────────────── + +describe("permissionModeShortTitle", () => { + test("returns short title for known modes", () => { + expect(permissionModeShortTitle("default")).toBe("Default"); + expect(permissionModeShortTitle("plan")).toBe("Plan"); + expect(permissionModeShortTitle("bypassPermissions")).toBe("Bypass"); + }); +}); + +// ─── permissionModeSymbol ────────────────────────────────────────────── + +describe("permissionModeSymbol", () => { + test("returns empty string for default", () => { + expect(permissionModeSymbol("default")).toBe(""); + }); + + test("returns non-empty for non-default modes", () => { + expect(permissionModeSymbol("plan").length).toBeGreaterThan(0); + expect(permissionModeSymbol("acceptEdits").length).toBeGreaterThan(0); + }); +}); + +// ─── getModeColor ────────────────────────────────────────────────────── + +describe("getModeColor", () => { + test("returns 'text' for default", () => { + expect(getModeColor("default")).toBe("text"); + }); + + test("returns 'planMode' for plan", () => { + expect(getModeColor("plan")).toBe("planMode"); + }); + + test("returns 'error' for bypassPermissions", () => { + expect(getModeColor("bypassPermissions")).toBe("error"); + }); +}); + +// ─── isDefaultMode ───────────────────────────────────────────────────── + +describe("isDefaultMode", () => { + test("returns true for 'default'", () => { + expect(isDefaultMode("default")).toBe(true); + }); + + test("returns true for undefined", () => { + expect(isDefaultMode(undefined)).toBe(true); + }); + + test("returns false for other modes", () => { + expect(isDefaultMode("plan")).toBe(false); + expect(isDefaultMode("dontAsk")).toBe(false); + }); +}); + +// ─── toExternalPermissionMode ────────────────────────────────────────── + +describe("toExternalPermissionMode", () => { + test("maps default to default", () => { + expect(toExternalPermissionMode("default")).toBe("default"); + }); + + test("maps plan to plan", () => { + expect(toExternalPermissionMode("plan")).toBe("plan"); + }); + + test("maps dontAsk to dontAsk", () => { + expect(toExternalPermissionMode("dontAsk")).toBe("dontAsk"); + }); +}); + +// ─── isExternalPermissionMode ────────────────────────────────────────── + +describe("isExternalPermissionMode", () => { + test("returns true for external modes (non-ant)", () => { + // USER_TYPE is not 'ant' in tests, so always true + expect(isExternalPermissionMode("default")).toBe(true); + expect(isExternalPermissionMode("plan")).toBe(true); + }); +}); diff --git a/src/utils/permissions/__tests__/dangerousPatterns.test.ts b/src/utils/permissions/__tests__/dangerousPatterns.test.ts new file mode 100644 index 0000000..6e90523 --- /dev/null +++ b/src/utils/permissions/__tests__/dangerousPatterns.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { + CROSS_PLATFORM_CODE_EXEC, + DANGEROUS_BASH_PATTERNS, +} from "../dangerousPatterns"; + +describe("CROSS_PLATFORM_CODE_EXEC", () => { + test("is a non-empty readonly array of strings", () => { + expect(CROSS_PLATFORM_CODE_EXEC.length).toBeGreaterThan(0); + for (const p of CROSS_PLATFORM_CODE_EXEC) { + expect(typeof p).toBe("string"); + } + }); + + test("includes core interpreters", () => { + expect(CROSS_PLATFORM_CODE_EXEC).toContain("python"); + expect(CROSS_PLATFORM_CODE_EXEC).toContain("node"); + expect(CROSS_PLATFORM_CODE_EXEC).toContain("ruby"); + expect(CROSS_PLATFORM_CODE_EXEC).toContain("perl"); + }); + + test("includes package runners", () => { + expect(CROSS_PLATFORM_CODE_EXEC).toContain("npx"); + expect(CROSS_PLATFORM_CODE_EXEC).toContain("bunx"); + }); + + test("includes shells", () => { + expect(CROSS_PLATFORM_CODE_EXEC).toContain("bash"); + expect(CROSS_PLATFORM_CODE_EXEC).toContain("sh"); + }); +}); + +describe("DANGEROUS_BASH_PATTERNS", () => { + test("includes all cross-platform patterns", () => { + for (const p of CROSS_PLATFORM_CODE_EXEC) { + expect(DANGEROUS_BASH_PATTERNS).toContain(p); + } + }); + + test("includes unix-specific patterns", () => { + expect(DANGEROUS_BASH_PATTERNS).toContain("zsh"); + expect(DANGEROUS_BASH_PATTERNS).toContain("fish"); + expect(DANGEROUS_BASH_PATTERNS).toContain("eval"); + expect(DANGEROUS_BASH_PATTERNS).toContain("exec"); + expect(DANGEROUS_BASH_PATTERNS).toContain("sudo"); + expect(DANGEROUS_BASH_PATTERNS).toContain("xargs"); + expect(DANGEROUS_BASH_PATTERNS).toContain("env"); + }); + + test("all elements are strings", () => { + for (const p of DANGEROUS_BASH_PATTERNS) { + expect(typeof p).toBe("string"); + } + }); +}); diff --git a/src/utils/shell/__tests__/outputLimits.test.ts b/src/utils/shell/__tests__/outputLimits.test.ts new file mode 100644 index 0000000..7abd319 --- /dev/null +++ b/src/utils/shell/__tests__/outputLimits.test.ts @@ -0,0 +1,67 @@ +import { mock, describe, expect, test, afterEach } from "bun:test"; + +// 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: () => {}, +})); + +const { + getMaxOutputLength, + BASH_MAX_OUTPUT_UPPER_LIMIT, + BASH_MAX_OUTPUT_DEFAULT, +} = await import("../outputLimits"); + +describe("outputLimits constants", () => { + test("BASH_MAX_OUTPUT_UPPER_LIMIT is 150000", () => { + expect(BASH_MAX_OUTPUT_UPPER_LIMIT).toBe(150_000); + }); + + test("BASH_MAX_OUTPUT_DEFAULT is 30000", () => { + expect(BASH_MAX_OUTPUT_DEFAULT).toBe(30_000); + }); +}); + +describe("getMaxOutputLength", () => { + const saved = process.env.BASH_MAX_OUTPUT_LENGTH; + + afterEach(() => { + if (saved === undefined) delete process.env.BASH_MAX_OUTPUT_LENGTH; + else process.env.BASH_MAX_OUTPUT_LENGTH = saved; + }); + + test("returns default when env not set", () => { + delete process.env.BASH_MAX_OUTPUT_LENGTH; + expect(getMaxOutputLength()).toBe(30_000); + }); + + test("returns parsed value when valid", () => { + process.env.BASH_MAX_OUTPUT_LENGTH = "50000"; + expect(getMaxOutputLength()).toBe(50_000); + }); + + test("caps at upper limit", () => { + process.env.BASH_MAX_OUTPUT_LENGTH = "999999"; + expect(getMaxOutputLength()).toBe(150_000); + }); + + test("returns default for invalid value", () => { + process.env.BASH_MAX_OUTPUT_LENGTH = "not-a-number"; + expect(getMaxOutputLength()).toBe(30_000); + }); + + test("returns default for negative value", () => { + process.env.BASH_MAX_OUTPUT_LENGTH = "-1"; + expect(getMaxOutputLength()).toBe(30_000); + }); +}); From 28e40ddc6717844718b36a62ec5c93d54e30d3c6 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 2 Apr 2026 09:51:48 +0800 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20=E7=94=A8=20Bun=20=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=20define=20=E6=9B=BF=E6=8D=A2=20cli.tsx=20=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=20globalThis=20=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 cli.tsx 顶部的 globalThis.MACRO / BUILD_* / feature polyfill - 新增 scripts/defines.ts 作为 MACRO define 映射的单一来源 - 新增 scripts/dev.ts,通过 bun run -d 在转译时注入 MACRO 常量 - build.ts 引用 getMacroDefines() 实现构建时内联 - 清理 global.d.ts (移除 BUILD_*, MACRO 函数声明) - 55 个 MACRO 消费文件零改动 Co-Authored-By: Claude Opus 4.6 --- build.ts | 2 ++ package.json | 2 +- scripts/defines.ts | 18 ++++++++++++++++++ scripts/dev.ts | 21 +++++++++++++++++++++ src/entrypoints/cli.tsx | 18 +----------------- src/types/global.d.ts | 12 +++--------- src/types/internal-modules.d.ts | 1 - 7 files changed, 46 insertions(+), 28 deletions(-) create mode 100644 scripts/defines.ts create mode 100644 scripts/dev.ts diff --git a/build.ts b/build.ts index 85b2b20..e63498c 100644 --- a/build.ts +++ b/build.ts @@ -1,5 +1,6 @@ import { readdir, readFile, writeFile } from "fs/promises"; import { join } from "path"; +import { getMacroDefines } from "./scripts/defines.ts"; const outdir = "dist"; @@ -13,6 +14,7 @@ const result = await Bun.build({ outdir, target: "bun", splitting: true, + define: getMacroDefines(), }); if (!result.success) { diff --git a/package.json b/package.json index 8ed6f2d..feba267 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ ], "scripts": { "build": "bun run build.ts", - "dev": "bun run src/entrypoints/cli.tsx", + "dev": "bun run scripts/dev.ts", "prepublishOnly": "bun run build", "lint": "biome lint src/", "lint:fix": "biome lint --fix src/", diff --git a/scripts/defines.ts b/scripts/defines.ts new file mode 100644 index 0000000..33ed2b5 --- /dev/null +++ b/scripts/defines.ts @@ -0,0 +1,18 @@ +/** + * Shared MACRO define map used by both dev.ts (runtime -d flags) + * and build.ts (Bun.build define option). + * + * Each value is a JSON-stringified expression that replaces the + * corresponding MACRO.* identifier at transpile / bundle time. + */ +export function getMacroDefines(): Record { + return { + "MACRO.VERSION": JSON.stringify("2.1.888"), + "MACRO.BUILD_TIME": JSON.stringify(new Date().toISOString()), + "MACRO.FEEDBACK_CHANNEL": JSON.stringify(""), + "MACRO.ISSUES_EXPLAINER": JSON.stringify(""), + "MACRO.NATIVE_PACKAGE_URL": JSON.stringify(""), + "MACRO.PACKAGE_URL": JSON.stringify(""), + "MACRO.VERSION_CHANGELOG": JSON.stringify(""), + }; +} diff --git a/scripts/dev.ts b/scripts/dev.ts new file mode 100644 index 0000000..5a9ec8f --- /dev/null +++ b/scripts/dev.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env bun +/** + * Dev entrypoint — launches cli.tsx with MACRO.* defines injected + * via Bun's -d flag (bunfig.toml [define] doesn't propagate to + * dynamically imported modules at runtime). + */ +import { getMacroDefines } from "./defines.ts"; + +const defines = getMacroDefines(); + +const defineArgs = Object.entries(defines).flatMap(([k, v]) => [ + "-d", + `${k}:${v}`, +]); + +const result = Bun.spawnSync( + ["bun", "run", ...defineArgs, "src/entrypoints/cli.tsx", ...process.argv.slice(2)], + { stdio: ["inherit", "inherit", "inherit"] }, +); + +process.exit(result.exitCode ?? 0); diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index 2a008c5..0ee6ff3 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -1,21 +1,5 @@ #!/usr/bin/env bun -// Runtime polyfill for bun:bundle (build-time macros) -const feature = (_name: string) => false; -if (typeof globalThis.MACRO === "undefined") { - (globalThis as any).MACRO = { - VERSION: "2.1.888", - BUILD_TIME: new Date().toISOString(), - FEEDBACK_CHANNEL: "", - ISSUES_EXPLAINER: "", - NATIVE_PACKAGE_URL: "", - PACKAGE_URL: "", - VERSION_CHANGELOG: "", - }; -} -// Build-time constants — normally replaced by Bun bundler at compile time -(globalThis as any).BUILD_TARGET = "external"; -(globalThis as any).BUILD_ENV = "production"; -(globalThis as any).INTERFACE_TYPE = "stdio"; +import { feature } from 'bun:bundle' // Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons // eslint-disable-next-line custom-rules/no-top-level-side-effects diff --git a/src/types/global.d.ts b/src/types/global.d.ts index ceb2f55..bff3970 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -4,9 +4,8 @@ */ // ============================================================================ -// MACRO — Bun compile-time macro function (from bun:bundle) -// Expands the function body at build time and removes the call in production. -// Also supports property access like MACRO.VERSION (compile-time constants). +// MACRO — Bun compile-time constants injected via bunfig.toml [define] (dev) +// and Bun.build({ define }) (production). See bunfig.toml & build.ts. declare namespace MACRO { export const VERSION: string export const BUILD_TIME: string @@ -16,7 +15,6 @@ declare namespace MACRO { export const PACKAGE_URL: string export const VERSION_CHANGELOG: string } -declare function MACRO(fn: () => T): T // ============================================================================ // Internal Anthropic-only identifiers (dead-code eliminated in open-source) @@ -62,11 +60,7 @@ declare type T = unknown declare function TungstenPill(props?: { key?: string; selected?: boolean }): JSX.Element | null // ============================================================================ -// Build-time constants — replaced by Bun bundler, polyfilled at runtime -// Using `string` (not literal types) so comparisons don't produce TS2367 -declare const BUILD_TARGET: string -declare const BUILD_ENV: string -declare const INTERFACE_TYPE: string +// Build-time constants BUILD_TARGET/BUILD_ENV/INTERFACE_TYPE — removed (zero runtime usage) // ============================================================================ // Ink custom JSX intrinsic elements — see src/types/ink-jsx.d.ts diff --git a/src/types/internal-modules.d.ts b/src/types/internal-modules.d.ts index bf1d214..95ff7a0 100644 --- a/src/types/internal-modules.d.ts +++ b/src/types/internal-modules.d.ts @@ -9,7 +9,6 @@ // ============================================================================ declare module "bun:bundle" { export function feature(name: string): boolean; - export function MACRO(fn: () => T): T; } declare module "bun:ffi" { From 4f323efb6157600a8499d0fb2bc270dfdb0c362b Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 2 Apr 2026 10:11:43 +0800 Subject: [PATCH 6/7] =?UTF-8?q?test:=20Phase=205=20=E2=80=94=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=2012=20=E4=B8=AA=E6=B5=8B=E8=AF=95=E6=96=87=E4=BB=B6?= =?UTF-8?q?=20(+209=20tests,=201177=20total)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增覆盖: effort, tokenBudget, displayTags, taggedId, controlMessageCompat, MCP normalization/envExpansion, gitConfigParser, formatBriefTimestamp, hyperlink, windowsPaths, notebook Co-Authored-By: Claude Opus 4.6 --- docs/testing-spec.md | 27 +- .../mcp/__tests__/envExpansion.test.ts | 139 ++++++++++ .../mcp/__tests__/normalization.test.ts | 59 ++++ .../__tests__/controlMessageCompat.test.ts | 103 +++++++ src/utils/__tests__/displayTags.test.ts | 134 +++++++++ src/utils/__tests__/effort.test.ts | 255 ++++++++++++++++++ .../__tests__/formatBriefTimestamp.test.ts | 76 ++++++ src/utils/__tests__/hyperlink.test.ts | 99 +++++++ src/utils/__tests__/notebook.test.ts | 162 +++++++++++ src/utils/__tests__/taggedId.test.ts | 104 +++++++ src/utils/__tests__/tokenBudget.test.ts | 150 +++++++++++ src/utils/__tests__/windowsPaths.test.ts | 116 ++++++++ .../git/__tests__/gitConfigParser.test.ts | 138 ++++++++++ 13 files changed, 1560 insertions(+), 2 deletions(-) create mode 100644 src/services/mcp/__tests__/envExpansion.test.ts create mode 100644 src/services/mcp/__tests__/normalization.test.ts create mode 100644 src/utils/__tests__/controlMessageCompat.test.ts create mode 100644 src/utils/__tests__/displayTags.test.ts create mode 100644 src/utils/__tests__/effort.test.ts create mode 100644 src/utils/__tests__/formatBriefTimestamp.test.ts create mode 100644 src/utils/__tests__/hyperlink.test.ts create mode 100644 src/utils/__tests__/notebook.test.ts create mode 100644 src/utils/__tests__/taggedId.test.ts create mode 100644 src/utils/__tests__/tokenBudget.test.ts create mode 100644 src/utils/__tests__/windowsPaths.test.ts create mode 100644 src/utils/git/__tests__/gitConfigParser.test.ts diff --git a/docs/testing-spec.md b/docs/testing-spec.md index 39694e0..4b0c88b 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 0000000..fe2032f --- /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 0000000..9b3b699 --- /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 0000000..9396f53 --- /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 0000000..46ed46e --- /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