From 43af260322765b2bf6038961c6d341fc3668cf0a Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Wed, 1 Apr 2026 23:56:37 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0=20json/truncate/path?= =?UTF-8?q?/tokens=20=E6=A8=A1=E5=9D=97=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - json.test.ts: 27 tests (safeParseJSON, safeParseJSONC, parseJSONL, addItemToJSONCArray) - truncate.test.ts: 24 tests (truncateToWidth, truncateStartToWidth, truncatePathMiddle, truncate, wrapText) - path.test.ts: 15 tests (containsPathTraversal, normalizePathForConfigKey) - tokens.test.ts: 22 tests (getTokenCountFromUsage, getTokenUsage, tokenCountFromLastAPIResponse, etc.) 使用 mock.module() 切断 log.ts/tokenEstimation.ts/slowOperations.ts 重依赖链 Co-Authored-By: Claude Opus 4.6 --- src/utils/__tests__/json.test.ts | 153 ++++++++++++++ src/utils/__tests__/path.test.ts | 72 +++++++ src/utils/__tests__/tokens.test.ts | 296 +++++++++++++++++++++++++++ src/utils/__tests__/truncate.test.ts | 146 +++++++++++++ 4 files changed, 667 insertions(+) create mode 100644 src/utils/__tests__/json.test.ts create mode 100644 src/utils/__tests__/path.test.ts create mode 100644 src/utils/__tests__/tokens.test.ts create mode 100644 src/utils/__tests__/truncate.test.ts diff --git a/src/utils/__tests__/json.test.ts b/src/utils/__tests__/json.test.ts new file mode 100644 index 0000000..42a624b --- /dev/null +++ b/src/utils/__tests__/json.test.ts @@ -0,0 +1,153 @@ +import { mock, describe, expect, test } from "bun:test"; + +// Mock log.ts to cut the heavy dependency chain (log.ts → bootstrap/state.ts → analytics) +mock.module("src/utils/log.ts", () => ({ + logError: () => {}, + logToFile: () => {}, + getLogDisplayTitle: () => "", + logEvent: () => {}, +})); + +const { safeParseJSON, safeParseJSONC, parseJSONL, addItemToJSONCArray } = + await import("../json"); + +// ─── safeParseJSON ────────────────────────────────────────────────────── + +describe("safeParseJSON", () => { + test("parses valid object", () => { + expect(safeParseJSON('{"a":1}')).toEqual({ a: 1 }); + }); + + test("parses valid array", () => { + expect(safeParseJSON("[1,2,3]")).toEqual([1, 2, 3]); + }); + + test("parses string value", () => { + expect(safeParseJSON('"hello"')).toBe("hello"); + }); + + test("parses number value", () => { + expect(safeParseJSON("42")).toBe(42); + }); + + test("parses boolean value", () => { + expect(safeParseJSON("true")).toBe(true); + }); + + test("parses null value", () => { + expect(safeParseJSON("null")).toBeNull(); + }); + + test("returns null for invalid JSON", () => { + expect(safeParseJSON("{bad}")).toBeNull(); + }); + + test("returns null for empty string", () => { + expect(safeParseJSON("")).toBeNull(); + }); + + test("returns null for undefined input", () => { + expect(safeParseJSON(undefined as any)).toBeNull(); + }); + + test("returns null for null input", () => { + expect(safeParseJSON(null as any)).toBeNull(); + }); + + test("handles JSON with BOM", () => { + expect(safeParseJSON('\uFEFF{"a":1}')).toEqual({ a: 1 }); + }); + + test("parses nested objects", () => { + const input = '{"a":{"b":{"c":1}}}'; + expect(safeParseJSON(input)).toEqual({ a: { b: { c: 1 } } }); + }); +}); + +// ─── safeParseJSONC ───────────────────────────────────────────────────── + +describe("safeParseJSONC", () => { + test("parses standard JSON", () => { + expect(safeParseJSONC('{"a":1}')).toEqual({ a: 1 }); + }); + + test("parses JSON with single-line comments", () => { + expect(safeParseJSONC('{\n// comment\n"a":1\n}')).toEqual({ a: 1 }); + }); + + test("parses JSON with block comments", () => { + expect(safeParseJSONC('{\n/* comment */\n"a":1\n}')).toEqual({ a: 1 }); + }); + + test("parses JSON with trailing commas", () => { + expect(safeParseJSONC('{"a":1,}')).toEqual({ a: 1 }); + }); + + test("returns null for null input", () => { + expect(safeParseJSONC(null as any)).toBeNull(); + }); + + test("returns null for empty string", () => { + expect(safeParseJSONC("")).toBeNull(); + }); +}); + +// ─── parseJSONL ───────────────────────────────────────────────────────── + +describe("parseJSONL", () => { + test("parses multiple lines", () => { + const result = parseJSONL('{"a":1}\n{"b":2}'); + expect(result).toEqual([{ a: 1 }, { b: 2 }]); + }); + + test("returns empty array for empty string", () => { + expect(parseJSONL("")).toEqual([]); + }); + + test("parses single line", () => { + expect(parseJSONL('{"a":1}')).toEqual([{ a: 1 }]); + }); + + test("accepts Buffer input", () => { + const buf = Buffer.from('{"x":1}\n{"y":2}'); + const result = parseJSONL(buf as any); + expect(result).toEqual([{ x: 1 }, { y: 2 }]); + }); + + // NOTE: Skipping malformed-line test — Bun.JSONL.parseChunk hangs + // indefinitely in its error-recovery loop when encountering bad lines. +}); + +// ─── addItemToJSONCArray ──────────────────────────────────────────────── + +describe("addItemToJSONCArray", () => { + test("appends to existing array", () => { + const result = addItemToJSONCArray('["a","b"]', "c"); + const parsed = JSON.parse(result); + expect(parsed).toEqual(["a", "b", "c"]); + }); + + test("appends to empty array", () => { + const result = addItemToJSONCArray("[]", "item"); + const parsed = JSON.parse(result); + expect(parsed).toEqual(["item"]); + }); + + test("creates array from empty content", () => { + const result = addItemToJSONCArray("", "first"); + const parsed = JSON.parse(result); + expect(parsed).toEqual(["first"]); + }); + + test("handles object item", () => { + const result = addItemToJSONCArray("[]", { key: "val" }); + const parsed = JSON.parse(result); + expect(parsed).toEqual([{ key: "val" }]); + }); + + test("wraps item in new array for non-array content", () => { + const result = addItemToJSONCArray('{"a":1}', "item"); + const parsed = JSON.parse(result); + expect(parsed).toEqual(["item"]); + }); +}); diff --git a/src/utils/__tests__/path.test.ts b/src/utils/__tests__/path.test.ts new file mode 100644 index 0000000..85151b7 --- /dev/null +++ b/src/utils/__tests__/path.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; +import { containsPathTraversal, normalizePathForConfigKey } from "../path"; + +// ─── containsPathTraversal ────────────────────────────────────────────── + +describe("containsPathTraversal", () => { + test("detects ../ at start", () => { + expect(containsPathTraversal("../foo")).toBe(true); + }); + + test("detects ../ in middle", () => { + expect(containsPathTraversal("foo/../bar")).toBe(true); + }); + + test("detects .. at end", () => { + expect(containsPathTraversal("foo/..")).toBe(true); + }); + + test("detects standalone ..", () => { + expect(containsPathTraversal("..")).toBe(true); + }); + + test("detects backslash traversal", () => { + expect(containsPathTraversal("foo\\..\\bar")).toBe(true); + }); + + test("returns false for normal path", () => { + expect(containsPathTraversal("foo/bar/baz")).toBe(false); + }); + + test("returns false for single dot", () => { + expect(containsPathTraversal("./foo")).toBe(false); + }); + + test("returns false for ... in filename", () => { + expect(containsPathTraversal("foo/...bar")).toBe(false); + }); + + test("returns false for empty string", () => { + expect(containsPathTraversal("")).toBe(false); + }); + + test("returns false for dotdot in filename without separator", () => { + expect(containsPathTraversal("foo..bar")).toBe(false); + }); +}); + +// ─── normalizePathForConfigKey ────────────────────────────────────────── + +describe("normalizePathForConfigKey", () => { + test("normalizes forward slashes (no change on POSIX)", () => { + expect(normalizePathForConfigKey("foo/bar/baz")).toBe("foo/bar/baz"); + }); + + test("resolves dot segments", () => { + expect(normalizePathForConfigKey("foo/./bar")).toBe("foo/bar"); + }); + + test("resolves double-dot segments", () => { + expect(normalizePathForConfigKey("foo/bar/../baz")).toBe("foo/baz"); + }); + + test("handles absolute path", () => { + const result = normalizePathForConfigKey("/Users/test/project"); + expect(result).toBe("/Users/test/project"); + }); + + test("converts backslashes to forward slashes", () => { + const result = normalizePathForConfigKey("foo\\bar\\baz"); + expect(result).toBe("foo/bar/baz"); + }); +}); diff --git a/src/utils/__tests__/tokens.test.ts b/src/utils/__tests__/tokens.test.ts new file mode 100644 index 0000000..9c4bcfb --- /dev/null +++ b/src/utils/__tests__/tokens.test.ts @@ -0,0 +1,296 @@ +import { mock, describe, expect, test } from "bun:test"; + +// Mock heavy dependency chain: tokenEstimation.ts → log.ts → bootstrap/state.ts +mock.module("src/utils/log.ts", () => ({ + logError: () => {}, + logToFile: () => {}, + getLogDisplayTitle: () => "", + logEvent: () => {}, + logMCPError: () => {}, + logMCPDebug: () => {}, + dateToFilename: (d: Date) => d.toISOString().replace(/[:.]/g, "-"), + getLogFilePath: () => "/tmp/mock-log", + attachErrorLogSink: () => {}, + getInMemoryErrors: () => [], + loadErrorLogs: async () => [], + getErrorLogByIndex: async () => null, + captureAPIRequest: () => {}, + _resetErrorLogForTesting: () => {}, +})); + +// Mock tokenEstimation to avoid pulling in API provider deps +mock.module("src/services/tokenEstimation.ts", () => ({ + roughTokenCountEstimation: (text: string) => Math.ceil(text.length / 4), + roughTokenCountEstimationForMessages: (msgs: any[]) => msgs.length * 100, + roughTokenCountEstimationForMessage: () => 100, + roughTokenCountEstimationForFileType: () => 100, + bytesPerTokenForFileType: () => 4, + countTokensWithAPI: async () => 0, + countMessagesTokensWithAPI: async () => 0, + countTokensViaHaikuFallback: async () => 0, +})); + +// Mock slowOperations to avoid bun:bundle import +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 { + getTokenCountFromUsage, + getTokenUsage, + tokenCountFromLastAPIResponse, + messageTokenCountFromLastAPIResponse, + getCurrentUsage, + doesMostRecentAssistantMessageExceed200k, + getAssistantMessageContentLength, +} = await import("../tokens"); + +// ─── Helpers ──────────────────────────────────────────────────────────── + +function makeAssistantMessage( + content: any[], + usage?: any, + model?: string, + id?: string +) { + return { + type: "assistant" as const, + uuid: `test-${Math.random()}`, + message: { + id: id ?? `msg_${Math.random()}`, + role: "assistant" as const, + content, + model: model ?? "claude-sonnet-4-20250514", + usage: usage ?? { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 10, + cache_read_input_tokens: 5, + }, + }, + isApiErrorMessage: false, + }; +} + +function makeUserMessage(text: string) { + return { + type: "user" as const, + uuid: `test-${Math.random()}`, + message: { role: "user" as const, content: text }, + }; +} + +// ─── getTokenCountFromUsage ───────────────────────────────────────────── + +describe("getTokenCountFromUsage", () => { + test("sums all token fields", () => { + const usage = { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 20, + cache_read_input_tokens: 10, + }; + expect(getTokenCountFromUsage(usage)).toBe(180); + }); + + test("handles missing cache fields", () => { + const usage = { + input_tokens: 100, + output_tokens: 50, + }; + expect(getTokenCountFromUsage(usage)).toBe(150); + }); + + test("handles zero values", () => { + const usage = { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }; + expect(getTokenCountFromUsage(usage)).toBe(0); + }); +}); + +// ─── getTokenUsage ────────────────────────────────────────────────────── + +describe("getTokenUsage", () => { + test("returns usage for valid assistant message", () => { + const msg = makeAssistantMessage([{ type: "text", text: "hello" }]); + const usage = getTokenUsage(msg as any); + expect(usage).toBeDefined(); + expect(usage!.input_tokens).toBe(100); + }); + + test("returns undefined for user message", () => { + const msg = makeUserMessage("hello"); + expect(getTokenUsage(msg as any)).toBeUndefined(); + }); + + test("returns undefined for synthetic model", () => { + const msg = makeAssistantMessage( + [{ type: "text", text: "hello" }], + { input_tokens: 10, output_tokens: 5 }, + "" + ); + expect(getTokenUsage(msg as any)).toBeUndefined(); + }); +}); + +// ─── tokenCountFromLastAPIResponse ────────────────────────────────────── + +describe("tokenCountFromLastAPIResponse", () => { + test("returns token count from last assistant message", () => { + const msgs = [ + makeAssistantMessage([{ type: "text", text: "hi" }], { + input_tokens: 200, + output_tokens: 100, + cache_creation_input_tokens: 50, + cache_read_input_tokens: 25, + }), + ]; + expect(tokenCountFromLastAPIResponse(msgs as any)).toBe(375); + }); + + test("returns 0 for empty messages", () => { + expect(tokenCountFromLastAPIResponse([])).toBe(0); + }); + + test("skips user messages to find last assistant", () => { + const msgs = [ + makeAssistantMessage([{ type: "text", text: "hi" }], { + input_tokens: 100, + output_tokens: 50, + }), + makeUserMessage("reply"), + ]; + expect(tokenCountFromLastAPIResponse(msgs as any)).toBe(150); + }); +}); + +// ─── messageTokenCountFromLastAPIResponse ─────────────────────────────── + +describe("messageTokenCountFromLastAPIResponse", () => { + test("returns output_tokens from last assistant", () => { + const msgs = [ + makeAssistantMessage([{ type: "text", text: "hi" }], { + input_tokens: 200, + output_tokens: 75, + }), + ]; + expect(messageTokenCountFromLastAPIResponse(msgs as any)).toBe(75); + }); + + test("returns 0 for empty messages", () => { + expect(messageTokenCountFromLastAPIResponse([])).toBe(0); + }); +}); + +// ─── getCurrentUsage ──────────────────────────────────────────────────── + +describe("getCurrentUsage", () => { + test("returns usage object from last assistant", () => { + const msgs = [ + makeAssistantMessage([{ type: "text", text: "hi" }], { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 10, + cache_read_input_tokens: 5, + }), + ]; + const usage = getCurrentUsage(msgs as any); + expect(usage).toEqual({ + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 10, + cache_read_input_tokens: 5, + }); + }); + + test("returns null for empty messages", () => { + expect(getCurrentUsage([])).toBeNull(); + }); + + test("defaults cache fields to 0", () => { + const msgs = [ + makeAssistantMessage([{ type: "text", text: "hi" }], { + input_tokens: 100, + output_tokens: 50, + }), + ]; + const usage = getCurrentUsage(msgs as any); + expect(usage!.cache_creation_input_tokens).toBe(0); + expect(usage!.cache_read_input_tokens).toBe(0); + }); +}); + +// ─── doesMostRecentAssistantMessageExceed200k ─────────────────────────── + +describe("doesMostRecentAssistantMessageExceed200k", () => { + test("returns false when under 200k", () => { + const msgs = [ + makeAssistantMessage([{ type: "text", text: "hi" }], { + input_tokens: 1000, + output_tokens: 500, + }), + ]; + expect(doesMostRecentAssistantMessageExceed200k(msgs as any)).toBe(false); + }); + + test("returns true when over 200k", () => { + const msgs = [ + makeAssistantMessage([{ type: "text", text: "hi" }], { + input_tokens: 190000, + output_tokens: 15000, + }), + ]; + expect(doesMostRecentAssistantMessageExceed200k(msgs as any)).toBe(true); + }); + + test("returns false for empty messages", () => { + expect(doesMostRecentAssistantMessageExceed200k([])).toBe(false); + }); +}); + +// ─── getAssistantMessageContentLength ─────────────────────────────────── + +describe("getAssistantMessageContentLength", () => { + test("counts text content length", () => { + const msg = makeAssistantMessage([{ type: "text", text: "hello" }]); + expect(getAssistantMessageContentLength(msg as any)).toBe(5); + }); + + test("counts multiple blocks", () => { + const msg = makeAssistantMessage([ + { type: "text", text: "hello" }, + { type: "text", text: "world" }, + ]); + expect(getAssistantMessageContentLength(msg as any)).toBe(10); + }); + + test("counts thinking content", () => { + const msg = makeAssistantMessage([ + { type: "thinking", thinking: "let me think" }, + ]); + expect(getAssistantMessageContentLength(msg as any)).toBe(12); + }); + + test("returns 0 for empty content", () => { + const msg = makeAssistantMessage([]); + expect(getAssistantMessageContentLength(msg as any)).toBe(0); + }); + + test("counts tool_use input", () => { + const msg = makeAssistantMessage([ + { type: "tool_use", id: "t1", name: "Bash", input: { command: "ls" } }, + ]); + expect(getAssistantMessageContentLength(msg as any)).toBeGreaterThan(0); + }); +}); diff --git a/src/utils/__tests__/truncate.test.ts b/src/utils/__tests__/truncate.test.ts new file mode 100644 index 0000000..bb6a779 --- /dev/null +++ b/src/utils/__tests__/truncate.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "bun:test"; +import { + truncatePathMiddle, + truncateToWidth, + truncateStartToWidth, + truncateToWidthNoEllipsis, + truncate, + wrapText, +} from "../truncate"; + +// ─── truncateToWidth ──────────────────────────────────────────────────── + +describe("truncateToWidth", () => { + test("returns original when within limit", () => { + expect(truncateToWidth("hello", 10)).toBe("hello"); + }); + + test("truncates long string with ellipsis", () => { + const result = truncateToWidth("hello world", 8); + expect(result.endsWith("…")).toBe(true); + expect(result.length).toBeLessThanOrEqual(9); // 8 visible + ellipsis char + }); + + test("returns ellipsis for maxWidth 1", () => { + expect(truncateToWidth("hello", 1)).toBe("…"); + }); + + test("handles empty string", () => { + expect(truncateToWidth("", 10)).toBe(""); + }); +}); + +// ─── truncateStartToWidth ─────────────────────────────────────────────── + +describe("truncateStartToWidth", () => { + test("returns original when within limit", () => { + expect(truncateStartToWidth("hello", 10)).toBe("hello"); + }); + + test("truncates from start with ellipsis prefix", () => { + const result = truncateStartToWidth("hello world", 8); + expect(result.startsWith("…")).toBe(true); + }); + + test("returns ellipsis for maxWidth 1", () => { + expect(truncateStartToWidth("hello", 1)).toBe("…"); + }); +}); + +// ─── truncateToWidthNoEllipsis ────────────────────────────────────────── + +describe("truncateToWidthNoEllipsis", () => { + test("returns original when within limit", () => { + expect(truncateToWidthNoEllipsis("hello", 10)).toBe("hello"); + }); + + test("truncates without ellipsis", () => { + const result = truncateToWidthNoEllipsis("hello world", 5); + expect(result).toBe("hello"); + expect(result.includes("…")).toBe(false); + }); + + test("returns empty for maxWidth 0", () => { + expect(truncateToWidthNoEllipsis("hello", 0)).toBe(""); + }); +}); + +// ─── truncatePathMiddle ───────────────────────────────────────────────── + +describe("truncatePathMiddle", () => { + test("returns original when path fits", () => { + expect(truncatePathMiddle("src/index.ts", 50)).toBe("src/index.ts"); + }); + + test("truncates middle of long path", () => { + const path = "src/components/deeply/nested/folder/MyComponent.tsx"; + const result = truncatePathMiddle(path, 30); + expect(result).toContain("…"); + expect(result.endsWith("MyComponent.tsx")).toBe(true); + }); + + test("returns ellipsis for maxLength 0", () => { + expect(truncatePathMiddle("src/index.ts", 0)).toBe("…"); + }); + + test("handles path without slashes", () => { + const result = truncatePathMiddle("verylongfilename.ts", 10); + expect(result).toContain("…"); + }); + + test("handles short maxLength < 5", () => { + const result = truncatePathMiddle("src/components/foo.ts", 4); + expect(result).toContain("…"); + }); +}); + +// ─── truncate ─────────────────────────────────────────────────────────── + +describe("truncate", () => { + test("returns original when within limit", () => { + expect(truncate("hello", 10)).toBe("hello"); + }); + + test("truncates long string", () => { + const result = truncate("hello world foo bar", 10); + expect(result).toContain("…"); + }); + + test("truncates at newline in singleLine mode", () => { + const result = truncate("first line\nsecond line", 50, true); + expect(result).toBe("first line…"); + }); + + test("does not truncate at newline when singleLine is false", () => { + const result = truncate("first\nsecond", 50, false); + expect(result).toBe("first\nsecond"); + }); + + test("truncates singleLine when first line exceeds maxWidth", () => { + const result = truncate("a very long first line\nsecond", 10, true); + expect(result).toContain("…"); + expect(result).not.toContain("\n"); + }); +}); + +// ─── wrapText ─────────────────────────────────────────────────────────── + +describe("wrapText", () => { + test("wraps text at specified width", () => { + const result = wrapText("hello world", 6); + expect(result.length).toBeGreaterThan(1); + }); + + test("returns single line when text fits", () => { + expect(wrapText("hello", 10)).toEqual(["hello"]); + }); + + test("handles empty string", () => { + expect(wrapText("", 10)).toEqual([]); + }); + + test("wraps each character on width 1", () => { + const result = wrapText("abc", 1); + expect(result).toEqual(["a", "b", "c"]); + }); +});