From acfaac5f142467b7a0fcf000bdf25cae60126bb8 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Thu, 2 Apr 2026 08:50:29 +0800 Subject: [PATCH] =?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:*"); + }); +});