test: Phase 1 — 添加 8 个纯函数测试文件 (+134 tests)
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
91c5bea27a
commit
acfaac5f14
86
src/utils/__tests__/CircularBuffer.test.ts
Normal file
86
src/utils/__tests__/CircularBuffer.test.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { CircularBuffer } from "../CircularBuffer";
|
||||
|
||||
describe("CircularBuffer", () => {
|
||||
test("starts empty", () => {
|
||||
const buf = new CircularBuffer<number>(5);
|
||||
expect(buf.length()).toBe(0);
|
||||
expect(buf.toArray()).toEqual([]);
|
||||
});
|
||||
|
||||
test("adds items up to capacity", () => {
|
||||
const buf = new CircularBuffer<number>(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<number>(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<number>(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<number>(5);
|
||||
buf.addAll([1, 2, 3]);
|
||||
expect(buf.toArray()).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test("addAll with overflow", () => {
|
||||
const buf = new CircularBuffer<number>(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<number>(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<number>(5);
|
||||
buf.add(1);
|
||||
buf.add(2);
|
||||
expect(buf.getRecent(5)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test("getRecent works after wraparound", () => {
|
||||
const buf = new CircularBuffer<number>(3);
|
||||
buf.addAll([1, 2, 3, 4, 5]);
|
||||
expect(buf.getRecent(2)).toEqual([4, 5]);
|
||||
});
|
||||
|
||||
test("clear resets buffer", () => {
|
||||
const buf = new CircularBuffer<number>(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<string>(2);
|
||||
buf.add("a");
|
||||
buf.add("b");
|
||||
buf.add("c");
|
||||
expect(buf.toArray()).toEqual(["b", "c"]);
|
||||
});
|
||||
});
|
||||
127
src/utils/__tests__/argumentSubstitution.test.ts
Normal file
127
src/utils/__tests__/argumentSubstitution.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
55
src/utils/__tests__/contentArray.test.ts
Normal file
55
src/utils/__tests__/contentArray.test.ts
Normal file
@ -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" });
|
||||
});
|
||||
});
|
||||
289
src/utils/__tests__/errors.test.ts
Normal file
289
src/utils/__tests__/errors.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
41
src/utils/__tests__/objectGroupBy.test.ts
Normal file
41
src/utils/__tests__/objectGroupBy.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
75
src/utils/__tests__/sanitization.test.ts
Normal file
75
src/utils/__tests__/sanitization.test.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
58
src/utils/__tests__/slashCommandParsing.test.ts
Normal file
58
src/utils/__tests__/slashCommandParsing.test.ts
Normal file
@ -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");
|
||||
});
|
||||
});
|
||||
145
src/utils/permissions/__tests__/shellRuleMatching.test.ts
Normal file
145
src/utils/permissions/__tests__/shellRuleMatching.test.ts
Normal file
@ -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:*");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user