mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +00:00
Merge branch 'claude-code-best:main' into main
This commit is contained in:
201
src/__tests__/Tool.test.ts
Normal file
201
src/__tests__/Tool.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
buildTool,
|
||||
toolMatchesName,
|
||||
findToolByName,
|
||||
getEmptyToolPermissionContext,
|
||||
filterToolProgressMessages,
|
||||
} from "../Tool";
|
||||
|
||||
// Minimal tool definition for testing buildTool
|
||||
function makeMinimalToolDef(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: "TestTool",
|
||||
inputSchema: { type: "object" as const } as any,
|
||||
maxResultSizeChars: 10000,
|
||||
call: async () => ({ data: "ok" }),
|
||||
description: async () => "A test tool",
|
||||
prompt: async () => "test prompt",
|
||||
mapToolResultToToolResultBlockParam: (content: unknown, toolUseID: string) => ({
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: toolUseID,
|
||||
content: String(content),
|
||||
}),
|
||||
renderToolUseMessage: () => null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildTool", () => {
|
||||
test("fills in default isEnabled as true", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.isEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
test("fills in default isConcurrencySafe as false", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.isConcurrencySafe({})).toBe(false);
|
||||
});
|
||||
|
||||
test("fills in default isReadOnly as false", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.isReadOnly({})).toBe(false);
|
||||
});
|
||||
|
||||
test("fills in default isDestructive as false", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.isDestructive!({})).toBe(false);
|
||||
});
|
||||
|
||||
test("fills in default checkPermissions as allow", async () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
const input = { foo: "bar" };
|
||||
const result = await tool.checkPermissions(input, {} as any);
|
||||
expect(result).toEqual({ behavior: "allow", updatedInput: input });
|
||||
});
|
||||
|
||||
test("fills in default userFacingName from tool name", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.userFacingName(undefined)).toBe("TestTool");
|
||||
});
|
||||
|
||||
test("fills in default toAutoClassifierInput as empty string", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.toAutoClassifierInput({})).toBe("");
|
||||
});
|
||||
|
||||
test("preserves explicitly provided methods", () => {
|
||||
const tool = buildTool(
|
||||
makeMinimalToolDef({
|
||||
isEnabled: () => false,
|
||||
isConcurrencySafe: () => true,
|
||||
isReadOnly: () => true,
|
||||
})
|
||||
);
|
||||
expect(tool.isEnabled()).toBe(false);
|
||||
expect(tool.isConcurrencySafe({})).toBe(true);
|
||||
expect(tool.isReadOnly({})).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves all non-defaultable properties", () => {
|
||||
const tool = buildTool(makeMinimalToolDef());
|
||||
expect(tool.name).toBe("TestTool");
|
||||
expect(tool.maxResultSizeChars).toBe(10000);
|
||||
expect(typeof tool.call).toBe("function");
|
||||
expect(typeof tool.description).toBe("function");
|
||||
expect(typeof tool.prompt).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolMatchesName", () => {
|
||||
test("returns true for exact name match", () => {
|
||||
expect(toolMatchesName({ name: "Bash" }, "Bash")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for non-matching name", () => {
|
||||
expect(toolMatchesName({ name: "Bash" }, "Read")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true when name matches an alias", () => {
|
||||
expect(
|
||||
toolMatchesName({ name: "Bash", aliases: ["BashTool", "Shell"] }, "BashTool")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when aliases is undefined", () => {
|
||||
expect(toolMatchesName({ name: "Bash" }, "BashTool")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when aliases is empty", () => {
|
||||
expect(
|
||||
toolMatchesName({ name: "Bash", aliases: [] }, "BashTool")
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findToolByName", () => {
|
||||
const mockTools = [
|
||||
buildTool(makeMinimalToolDef({ name: "Bash" })),
|
||||
buildTool(makeMinimalToolDef({ name: "Read", aliases: ["FileRead"] })),
|
||||
buildTool(makeMinimalToolDef({ name: "Edit" })),
|
||||
];
|
||||
|
||||
test("finds tool by primary name", () => {
|
||||
const tool = findToolByName(mockTools, "Bash");
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool!.name).toBe("Bash");
|
||||
});
|
||||
|
||||
test("finds tool by alias", () => {
|
||||
const tool = findToolByName(mockTools, "FileRead");
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool!.name).toBe("Read");
|
||||
});
|
||||
|
||||
test("returns undefined when no match", () => {
|
||||
expect(findToolByName(mockTools, "NonExistent")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns first match when duplicates exist", () => {
|
||||
const dupeTools = [
|
||||
buildTool(makeMinimalToolDef({ name: "Bash", maxResultSizeChars: 100 })),
|
||||
buildTool(makeMinimalToolDef({ name: "Bash", maxResultSizeChars: 200 })),
|
||||
];
|
||||
const tool = findToolByName(dupeTools, "Bash");
|
||||
expect(tool!.maxResultSizeChars).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEmptyToolPermissionContext", () => {
|
||||
test("returns default permission mode", () => {
|
||||
const ctx = getEmptyToolPermissionContext();
|
||||
expect(ctx.mode).toBe("default");
|
||||
});
|
||||
|
||||
test("returns empty maps and arrays", () => {
|
||||
const ctx = getEmptyToolPermissionContext();
|
||||
expect(ctx.additionalWorkingDirectories.size).toBe(0);
|
||||
expect(ctx.alwaysAllowRules).toEqual({});
|
||||
expect(ctx.alwaysDenyRules).toEqual({});
|
||||
expect(ctx.alwaysAskRules).toEqual({});
|
||||
});
|
||||
|
||||
test("returns isBypassPermissionsModeAvailable as false", () => {
|
||||
const ctx = getEmptyToolPermissionContext();
|
||||
expect(ctx.isBypassPermissionsModeAvailable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterToolProgressMessages", () => {
|
||||
test("filters out hook_progress messages", () => {
|
||||
const messages = [
|
||||
{ data: { type: "hook_progress", hookName: "pre" } },
|
||||
{ data: { type: "tool_progress", toolName: "Bash" } },
|
||||
] as any[];
|
||||
const result = filterToolProgressMessages(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
expect((result[0]!.data as any).type).toBe("tool_progress");
|
||||
});
|
||||
|
||||
test("keeps tool progress messages", () => {
|
||||
const messages = [
|
||||
{ data: { type: "tool_progress", toolName: "Bash" } },
|
||||
{ data: { type: "tool_progress", toolName: "Read" } },
|
||||
] as any[];
|
||||
const result = filterToolProgressMessages(messages);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("returns empty array for empty input", () => {
|
||||
expect(filterToolProgressMessages([])).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles messages without type field", () => {
|
||||
const messages = [
|
||||
{ data: { toolName: "Bash" } },
|
||||
{ data: { type: "hook_progress" } },
|
||||
] as any[];
|
||||
const result = filterToolProgressMessages(messages);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
82
src/__tests__/tools.test.ts
Normal file
82
src/__tests__/tools.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseToolPreset, filterToolsByDenyRules } from "../tools";
|
||||
import { getEmptyToolPermissionContext } from "../Tool";
|
||||
|
||||
describe("parseToolPreset", () => {
|
||||
test('returns "default" for "default" input', () => {
|
||||
expect(parseToolPreset("default")).toBe("default");
|
||||
});
|
||||
|
||||
test('returns "default" for "Default" input (case-insensitive)', () => {
|
||||
expect(parseToolPreset("Default")).toBe("default");
|
||||
});
|
||||
|
||||
test("returns null for unknown preset", () => {
|
||||
expect(parseToolPreset("unknown")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(parseToolPreset("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for random string", () => {
|
||||
expect(parseToolPreset("custom-preset")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── filterToolsByDenyRules ─────────────────────────────────────────────
|
||||
|
||||
describe("filterToolsByDenyRules", () => {
|
||||
const mockTools = [
|
||||
{ name: "Bash", mcpInfo: undefined },
|
||||
{ name: "Read", mcpInfo: undefined },
|
||||
{ name: "Write", mcpInfo: undefined },
|
||||
{ name: "mcp__server__tool", mcpInfo: { serverName: "server", toolName: "tool" } },
|
||||
];
|
||||
|
||||
test("returns all tools when no deny rules", () => {
|
||||
const ctx = getEmptyToolPermissionContext();
|
||||
const result = filterToolsByDenyRules(mockTools, ctx);
|
||||
expect(result).toHaveLength(4);
|
||||
});
|
||||
|
||||
test("filters out denied tool by name", () => {
|
||||
const ctx = {
|
||||
...getEmptyToolPermissionContext(),
|
||||
alwaysDenyRules: {
|
||||
localSettings: ["Bash"],
|
||||
},
|
||||
};
|
||||
const result = filterToolsByDenyRules(mockTools, ctx as any);
|
||||
expect(result.find((t) => t.name === "Bash")).toBeUndefined();
|
||||
expect(result).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("filters out multiple denied tools", () => {
|
||||
const ctx = {
|
||||
...getEmptyToolPermissionContext(),
|
||||
alwaysDenyRules: {
|
||||
localSettings: ["Bash", "Write"],
|
||||
},
|
||||
};
|
||||
const result = filterToolsByDenyRules(mockTools, ctx as any);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t) => t.name)).toEqual(["Read", "mcp__server__tool"]);
|
||||
});
|
||||
|
||||
test("returns empty array when all tools denied", () => {
|
||||
const ctx = {
|
||||
...getEmptyToolPermissionContext(),
|
||||
alwaysDenyRules: {
|
||||
localSettings: mockTools.map((t) => t.name),
|
||||
},
|
||||
};
|
||||
const result = filterToolsByDenyRules(mockTools, ctx as any);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("handles empty tools array", () => {
|
||||
const ctx = getEmptyToolPermissionContext();
|
||||
expect(filterToolsByDenyRules([], ctx)).toEqual([]);
|
||||
});
|
||||
});
|
||||
164
src/tools/FileEditTool/__tests__/utils.test.ts
Normal file
164
src/tools/FileEditTool/__tests__/utils.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
// Mock log.ts to cut the heavy dependency chain
|
||||
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: () => {},
|
||||
}));
|
||||
|
||||
const {
|
||||
normalizeQuotes,
|
||||
stripTrailingWhitespace,
|
||||
findActualString,
|
||||
preserveQuoteStyle,
|
||||
applyEditToFile,
|
||||
LEFT_SINGLE_CURLY_QUOTE,
|
||||
RIGHT_SINGLE_CURLY_QUOTE,
|
||||
LEFT_DOUBLE_CURLY_QUOTE,
|
||||
RIGHT_DOUBLE_CURLY_QUOTE,
|
||||
} = await import("../utils");
|
||||
|
||||
// ─── normalizeQuotes ────────────────────────────────────────────────────
|
||||
|
||||
describe("normalizeQuotes", () => {
|
||||
test("converts left single curly to straight", () => {
|
||||
expect(normalizeQuotes(`${LEFT_SINGLE_CURLY_QUOTE}hello`)).toBe("'hello");
|
||||
});
|
||||
|
||||
test("converts right single curly to straight", () => {
|
||||
expect(normalizeQuotes(`hello${RIGHT_SINGLE_CURLY_QUOTE}`)).toBe("hello'");
|
||||
});
|
||||
|
||||
test("converts left double curly to straight", () => {
|
||||
expect(normalizeQuotes(`${LEFT_DOUBLE_CURLY_QUOTE}hello`)).toBe('"hello');
|
||||
});
|
||||
|
||||
test("converts right double curly to straight", () => {
|
||||
expect(normalizeQuotes(`hello${RIGHT_DOUBLE_CURLY_QUOTE}`)).toBe('hello"');
|
||||
});
|
||||
|
||||
test("leaves straight quotes unchanged", () => {
|
||||
expect(normalizeQuotes("'hello' \"world\"")).toBe("'hello' \"world\"");
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(normalizeQuotes("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── stripTrailingWhitespace ────────────────────────────────────────────
|
||||
|
||||
describe("stripTrailingWhitespace", () => {
|
||||
test("strips trailing spaces from lines", () => {
|
||||
expect(stripTrailingWhitespace("hello \nworld ")).toBe("hello\nworld");
|
||||
});
|
||||
|
||||
test("strips trailing tabs", () => {
|
||||
expect(stripTrailingWhitespace("hello\t\nworld\t")).toBe("hello\nworld");
|
||||
});
|
||||
|
||||
test("preserves leading whitespace", () => {
|
||||
expect(stripTrailingWhitespace(" hello \n world ")).toBe(
|
||||
" hello\n world"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(stripTrailingWhitespace("")).toBe("");
|
||||
});
|
||||
|
||||
test("handles CRLF line endings", () => {
|
||||
expect(stripTrailingWhitespace("hello \r\nworld ")).toBe(
|
||||
"hello\r\nworld"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles no trailing whitespace", () => {
|
||||
expect(stripTrailingWhitespace("hello\nworld")).toBe("hello\nworld");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── findActualString ───────────────────────────────────────────────────
|
||||
|
||||
describe("findActualString", () => {
|
||||
test("finds exact match", () => {
|
||||
expect(findActualString("hello world", "hello")).toBe("hello");
|
||||
});
|
||||
|
||||
test("finds match with curly quotes normalized", () => {
|
||||
const fileContent = `${LEFT_DOUBLE_CURLY_QUOTE}hello${RIGHT_DOUBLE_CURLY_QUOTE}`;
|
||||
const result = findActualString(fileContent, '"hello"');
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when not found", () => {
|
||||
expect(findActualString("hello world", "xyz")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty search in non-empty content", () => {
|
||||
// Empty string is always found at index 0 via includes()
|
||||
const result = findActualString("hello", "");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── preserveQuoteStyle ─────────────────────────────────────────────────
|
||||
|
||||
describe("preserveQuoteStyle", () => {
|
||||
test("returns newString unchanged when no normalization happened", () => {
|
||||
expect(preserveQuoteStyle("hello", "hello", "world")).toBe("world");
|
||||
});
|
||||
|
||||
test("converts straight double quotes to curly in replacement", () => {
|
||||
const oldString = '"hello"';
|
||||
const actualOldString = `${LEFT_DOUBLE_CURLY_QUOTE}hello${RIGHT_DOUBLE_CURLY_QUOTE}`;
|
||||
const newString = '"world"';
|
||||
const result = preserveQuoteStyle(oldString, actualOldString, newString);
|
||||
expect(result).toContain(LEFT_DOUBLE_CURLY_QUOTE);
|
||||
expect(result).toContain(RIGHT_DOUBLE_CURLY_QUOTE);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── applyEditToFile ────────────────────────────────────────────────────
|
||||
|
||||
describe("applyEditToFile", () => {
|
||||
test("replaces first occurrence by default", () => {
|
||||
expect(applyEditToFile("foo bar foo", "foo", "baz")).toBe("baz bar foo");
|
||||
});
|
||||
|
||||
test("replaces all occurrences with replaceAll=true", () => {
|
||||
expect(applyEditToFile("foo bar foo", "foo", "baz", true)).toBe(
|
||||
"baz bar baz"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles deletion (empty newString) with trailing newline", () => {
|
||||
const result = applyEditToFile("line1\nline2\nline3\n", "line2", "");
|
||||
expect(result).toBe("line1\nline3\n");
|
||||
});
|
||||
|
||||
test("handles deletion without trailing newline", () => {
|
||||
const result = applyEditToFile("foobar", "foo", "");
|
||||
expect(result).toBe("bar");
|
||||
});
|
||||
|
||||
test("handles no match (returns original)", () => {
|
||||
expect(applyEditToFile("hello world", "xyz", "abc")).toBe("hello world");
|
||||
});
|
||||
|
||||
test("handles empty original content with insertion", () => {
|
||||
expect(applyEditToFile("", "", "new content")).toBe("new content");
|
||||
});
|
||||
});
|
||||
134
src/tools/shared/__tests__/gitOperationTracking.test.ts
Normal file
134
src/tools/shared/__tests__/gitOperationTracking.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseGitCommitId, detectGitOperation } from "../gitOperationTracking";
|
||||
|
||||
describe("parseGitCommitId", () => {
|
||||
test("extracts commit hash from git commit output", () => {
|
||||
expect(parseGitCommitId("[main abc1234] fix: some message")).toBe("abc1234");
|
||||
});
|
||||
|
||||
test("extracts hash from root commit output", () => {
|
||||
expect(
|
||||
parseGitCommitId("[main (root-commit) abc1234] initial commit")
|
||||
).toBe("abc1234");
|
||||
});
|
||||
|
||||
test("returns undefined for non-commit output", () => {
|
||||
expect(parseGitCommitId("nothing to commit")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("handles various branch name formats", () => {
|
||||
expect(parseGitCommitId("[feature/foo abc1234] message")).toBe("abc1234");
|
||||
expect(parseGitCommitId("[fix/bar-baz abc1234] message")).toBe("abc1234");
|
||||
expect(parseGitCommitId("[v1.0.0 abc1234] message")).toBe("abc1234");
|
||||
});
|
||||
|
||||
test("returns undefined for empty string", () => {
|
||||
expect(parseGitCommitId("")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectGitOperation", () => {
|
||||
test("detects git commit operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"git commit -m 'fix bug'",
|
||||
"[main abc1234] fix bug"
|
||||
);
|
||||
expect(result.commit).toBeDefined();
|
||||
expect(result.commit!.sha).toBe("abc123");
|
||||
expect(result.commit!.kind).toBe("committed");
|
||||
});
|
||||
|
||||
test("detects git commit --amend operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"git commit --amend -m 'updated'",
|
||||
"[main def5678] updated"
|
||||
);
|
||||
expect(result.commit).toBeDefined();
|
||||
expect(result.commit!.kind).toBe("amended");
|
||||
});
|
||||
|
||||
test("detects git cherry-pick operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"git cherry-pick abc1234",
|
||||
"[main def5678] cherry picked commit"
|
||||
);
|
||||
expect(result.commit).toBeDefined();
|
||||
expect(result.commit!.kind).toBe("cherry-picked");
|
||||
});
|
||||
|
||||
test("detects git push operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"git push origin main",
|
||||
" abc1234..def5678 main -> main"
|
||||
);
|
||||
expect(result.push).toBeDefined();
|
||||
expect(result.push!.branch).toBe("main");
|
||||
});
|
||||
|
||||
test("detects git merge operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"git merge feature-branch",
|
||||
"Merge made by the 'ort' strategy."
|
||||
);
|
||||
expect(result.branch).toBeDefined();
|
||||
expect(result.branch!.action).toBe("merged");
|
||||
expect(result.branch!.ref).toBe("feature-branch");
|
||||
});
|
||||
|
||||
test("detects git rebase operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"git rebase main",
|
||||
"Successfully rebased and updated refs/heads/feature."
|
||||
);
|
||||
expect(result.branch).toBeDefined();
|
||||
expect(result.branch!.action).toBe("rebased");
|
||||
expect(result.branch!.ref).toBe("main");
|
||||
});
|
||||
|
||||
test("returns null for non-git commands", () => {
|
||||
const result = detectGitOperation("ls -la", "total 100\ndrwxr-xr-x");
|
||||
expect(result.commit).toBeUndefined();
|
||||
expect(result.push).toBeUndefined();
|
||||
expect(result.branch).toBeUndefined();
|
||||
expect(result.pr).toBeUndefined();
|
||||
});
|
||||
|
||||
test("detects gh pr create operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"gh pr create --title 'fix' --body 'desc'",
|
||||
"https://github.com/owner/repo/pull/42"
|
||||
);
|
||||
expect(result.pr).toBeDefined();
|
||||
expect(result.pr!.number).toBe(42);
|
||||
expect(result.pr!.action).toBe("created");
|
||||
});
|
||||
|
||||
test("detects gh pr merge operation", () => {
|
||||
const result = detectGitOperation(
|
||||
"gh pr merge 42",
|
||||
"✓ Merged pull request owner/repo#42"
|
||||
);
|
||||
expect(result.pr).toBeDefined();
|
||||
expect(result.pr!.number).toBe(42);
|
||||
expect(result.pr!.action).toBe("merged");
|
||||
});
|
||||
|
||||
test("handles git commit with -c options", () => {
|
||||
const result = detectGitOperation(
|
||||
"git -c commit.gpgsign=false commit -m 'msg'",
|
||||
"[main aaa1111] msg"
|
||||
);
|
||||
expect(result.commit).toBeDefined();
|
||||
expect(result.commit!.sha).toBe("aaa111");
|
||||
});
|
||||
|
||||
test("detects fast-forward merge", () => {
|
||||
const result = detectGitOperation(
|
||||
"git merge develop",
|
||||
"Fast-forward\n file.txt | 1 +\n 1 file changed"
|
||||
);
|
||||
expect(result.branch).toBeDefined();
|
||||
expect(result.branch!.action).toBe("merged");
|
||||
expect(result.branch!.ref).toBe("develop");
|
||||
});
|
||||
});
|
||||
123
src/utils/__tests__/claudemd.test.ts
Normal file
123
src/utils/__tests__/claudemd.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
stripHtmlComments,
|
||||
isMemoryFilePath,
|
||||
getLargeMemoryFiles,
|
||||
MAX_MEMORY_CHARACTER_COUNT,
|
||||
type MemoryFileInfo,
|
||||
} from "../claudemd";
|
||||
|
||||
function mockMemoryFile(overrides: Partial<MemoryFileInfo> = {}): MemoryFileInfo {
|
||||
return {
|
||||
path: "/project/CLAUDE.md",
|
||||
type: "Project",
|
||||
content: "test content",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("stripHtmlComments", () => {
|
||||
test("strips block-level HTML comments (own line)", () => {
|
||||
// CommonMark type-2 HTML blocks: comment must start at beginning of line
|
||||
const result = stripHtmlComments("text\n<!-- block comment -->\nmore");
|
||||
expect(result.content).not.toContain("block comment");
|
||||
expect(result.stripped).toBe(true);
|
||||
});
|
||||
|
||||
test("returns stripped: false when no comments", () => {
|
||||
const result = stripHtmlComments("no comments here");
|
||||
expect(result.stripped).toBe(false);
|
||||
expect(result.content).toBe("no comments here");
|
||||
});
|
||||
|
||||
test("returns stripped: true when block comments exist", () => {
|
||||
const result = stripHtmlComments("hello\n<!-- world -->\nend");
|
||||
expect(result.stripped).toBe(true);
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
const result = stripHtmlComments("");
|
||||
expect(result.content).toBe("");
|
||||
expect(result.stripped).toBe(false);
|
||||
});
|
||||
|
||||
test("handles multiple block comments", () => {
|
||||
const result = stripHtmlComments(
|
||||
"a\n<!-- c1 -->\nb\n<!-- c2 -->\nc"
|
||||
);
|
||||
expect(result.content).not.toContain("c1");
|
||||
expect(result.content).not.toContain("c2");
|
||||
expect(result.stripped).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves code block content", () => {
|
||||
const input = "text\n```html\n<!-- not stripped -->\n```\nmore";
|
||||
const result = stripHtmlComments(input);
|
||||
expect(result.content).toContain("<!-- not stripped -->");
|
||||
});
|
||||
|
||||
test("preserves inline comments within paragraphs", () => {
|
||||
// Inline comments are NOT stripped (CommonMark paragraph semantics)
|
||||
const result = stripHtmlComments("text <!-- inline --> more");
|
||||
expect(result.content).toContain("<!-- inline -->");
|
||||
expect(result.stripped).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMemoryFilePath", () => {
|
||||
test("returns true for CLAUDE.md path", () => {
|
||||
expect(isMemoryFilePath("/project/CLAUDE.md")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for CLAUDE.local.md path", () => {
|
||||
expect(isMemoryFilePath("/project/CLAUDE.local.md")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for .claude/rules/ path", () => {
|
||||
expect(isMemoryFilePath("/project/.claude/rules/foo.md")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for regular file", () => {
|
||||
expect(isMemoryFilePath("/project/src/main.ts")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for unrelated .md file", () => {
|
||||
expect(isMemoryFilePath("/project/README.md")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for .claude directory non-rules file", () => {
|
||||
expect(isMemoryFilePath("/project/.claude/settings.json")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLargeMemoryFiles", () => {
|
||||
test("returns files exceeding threshold", () => {
|
||||
const largeContent = "x".repeat(MAX_MEMORY_CHARACTER_COUNT + 1);
|
||||
const files = [
|
||||
mockMemoryFile({ content: "small" }),
|
||||
mockMemoryFile({ content: largeContent, path: "/big.md" }),
|
||||
];
|
||||
const result = getLargeMemoryFiles(files);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].path).toBe("/big.md");
|
||||
});
|
||||
|
||||
test("returns empty array when all files are small", () => {
|
||||
const files = [
|
||||
mockMemoryFile({ content: "small" }),
|
||||
mockMemoryFile({ content: "also small" }),
|
||||
];
|
||||
expect(getLargeMemoryFiles(files)).toEqual([]);
|
||||
});
|
||||
|
||||
test("correctly identifies threshold boundary", () => {
|
||||
const atThreshold = "x".repeat(MAX_MEMORY_CHARACTER_COUNT);
|
||||
const overThreshold = "x".repeat(MAX_MEMORY_CHARACTER_COUNT + 1);
|
||||
const files = [
|
||||
mockMemoryFile({ content: atThreshold }),
|
||||
mockMemoryFile({ content: overThreshold }),
|
||||
];
|
||||
const result = getLargeMemoryFiles(files);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
253
src/utils/__tests__/cron.test.ts
Normal file
253
src/utils/__tests__/cron.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseCronExpression, computeNextCronRun, cronToHuman } from "../cron";
|
||||
|
||||
describe("parseCronExpression", () => {
|
||||
describe("valid expressions", () => {
|
||||
test("parses wildcard fields", () => {
|
||||
const result = parseCronExpression("* * * * *");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toHaveLength(60);
|
||||
expect(result!.hour).toHaveLength(24);
|
||||
expect(result!.dayOfMonth).toHaveLength(31);
|
||||
expect(result!.month).toHaveLength(12);
|
||||
expect(result!.dayOfWeek).toHaveLength(7);
|
||||
});
|
||||
|
||||
test("parses specific values", () => {
|
||||
const result = parseCronExpression("30 14 1 6 3");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toEqual([30]);
|
||||
expect(result!.hour).toEqual([14]);
|
||||
expect(result!.dayOfMonth).toEqual([1]);
|
||||
expect(result!.month).toEqual([6]);
|
||||
expect(result!.dayOfWeek).toEqual([3]);
|
||||
});
|
||||
|
||||
test("parses step syntax", () => {
|
||||
const result = parseCronExpression("*/5 * * * *");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toEqual([0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]);
|
||||
});
|
||||
|
||||
test("parses range syntax", () => {
|
||||
const result = parseCronExpression("1-5 * * * *");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
test("parses range with step", () => {
|
||||
const result = parseCronExpression("1-10/3 * * * *");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toEqual([1, 4, 7, 10]);
|
||||
});
|
||||
|
||||
test("parses comma-separated list", () => {
|
||||
const result = parseCronExpression("1,15,30 * * * *");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toEqual([1, 15, 30]);
|
||||
});
|
||||
|
||||
test("parses day-of-week 7 as Sunday alias", () => {
|
||||
const result = parseCronExpression("0 0 * * 7");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.dayOfWeek).toEqual([0]);
|
||||
});
|
||||
|
||||
test("parses range with day-of-week 7", () => {
|
||||
const result = parseCronExpression("0 0 * * 5-7");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.dayOfWeek).toEqual([0, 5, 6]);
|
||||
});
|
||||
|
||||
test("parses complex combined expression", () => {
|
||||
const result = parseCronExpression("0,30 9-17 * * 1-5");
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.minute).toEqual([0, 30]);
|
||||
expect(result!.hour).toEqual([9, 10, 11, 12, 13, 14, 15, 16, 17]);
|
||||
expect(result!.dayOfWeek).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalid expressions", () => {
|
||||
test("returns null for wrong field count", () => {
|
||||
expect(parseCronExpression("* * *")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for out-of-range values", () => {
|
||||
expect(parseCronExpression("60 * * * *")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for invalid step", () => {
|
||||
expect(parseCronExpression("*/0 * * * *")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for reversed range", () => {
|
||||
expect(parseCronExpression("10-5 * * * *")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(parseCronExpression("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for non-numeric tokens", () => {
|
||||
expect(parseCronExpression("abc * * * *")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("field range validation", () => {
|
||||
test("minute: 0-59", () => {
|
||||
expect(parseCronExpression("0 * * * *")).not.toBeNull();
|
||||
expect(parseCronExpression("59 * * * *")).not.toBeNull();
|
||||
expect(parseCronExpression("60 * * * *")).toBeNull();
|
||||
});
|
||||
|
||||
test("hour: 0-23", () => {
|
||||
expect(parseCronExpression("* 0 * * *")).not.toBeNull();
|
||||
expect(parseCronExpression("* 23 * * *")).not.toBeNull();
|
||||
expect(parseCronExpression("* 24 * * *")).toBeNull();
|
||||
});
|
||||
|
||||
test("dayOfMonth: 1-31", () => {
|
||||
expect(parseCronExpression("* * 1 * *")).not.toBeNull();
|
||||
expect(parseCronExpression("* * 31 * *")).not.toBeNull();
|
||||
expect(parseCronExpression("* * 0 * *")).toBeNull();
|
||||
expect(parseCronExpression("* * 32 * *")).toBeNull();
|
||||
});
|
||||
|
||||
test("month: 1-12", () => {
|
||||
expect(parseCronExpression("* * * 1 *")).not.toBeNull();
|
||||
expect(parseCronExpression("* * * 12 *")).not.toBeNull();
|
||||
expect(parseCronExpression("* * * 0 *")).toBeNull();
|
||||
expect(parseCronExpression("* * * 13 *")).toBeNull();
|
||||
});
|
||||
|
||||
test("dayOfWeek: 0-6 (plus 7 alias)", () => {
|
||||
expect(parseCronExpression("* * * * 0")).not.toBeNull();
|
||||
expect(parseCronExpression("* * * * 6")).not.toBeNull();
|
||||
expect(parseCronExpression("* * * * 7")).not.toBeNull(); // alias for 0
|
||||
expect(parseCronExpression("* * * * 8")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeNextCronRun", () => {
|
||||
test("finds next minute", () => {
|
||||
const fields = parseCronExpression("31 14 * * *")!;
|
||||
const from = new Date(2026, 0, 15, 14, 30, 45); // 14:30:45
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getHours()).toBe(14);
|
||||
expect(next!.getMinutes()).toBe(31);
|
||||
});
|
||||
|
||||
test("finds next hour", () => {
|
||||
const fields = parseCronExpression("0 15 * * *")!;
|
||||
const from = new Date(2026, 0, 15, 14, 30);
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getHours()).toBe(15);
|
||||
expect(next!.getMinutes()).toBe(0);
|
||||
});
|
||||
|
||||
test("rolls to next day", () => {
|
||||
const fields = parseCronExpression("0 10 * * *")!;
|
||||
const from = new Date(2026, 0, 15, 14, 30);
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getDate()).toBe(16);
|
||||
expect(next!.getHours()).toBe(10);
|
||||
});
|
||||
|
||||
test("is strictly after from date", () => {
|
||||
const fields = parseCronExpression("30 14 * * *")!;
|
||||
const from = new Date(2026, 0, 15, 14, 30, 0); // exactly on cron time
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getTime()).toBeGreaterThan(from.getTime());
|
||||
});
|
||||
|
||||
test("every 5 minutes from arbitrary time", () => {
|
||||
const fields = parseCronExpression("*/5 * * * *")!;
|
||||
const from = new Date(2026, 0, 15, 14, 32);
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getMinutes()).toBe(35);
|
||||
});
|
||||
|
||||
test("every minute", () => {
|
||||
const fields = parseCronExpression("* * * * *")!;
|
||||
const from = new Date(2026, 0, 15, 14, 32, 45);
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getMinutes()).toBe(33);
|
||||
});
|
||||
|
||||
test("handles step across midnight", () => {
|
||||
const fields = parseCronExpression("0 0 * * *")!;
|
||||
const from = new Date(2026, 0, 15, 23, 59);
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
expect(next!.getHours()).toBe(0);
|
||||
expect(next!.getDate()).toBe(16);
|
||||
});
|
||||
|
||||
test("OR semantics when both dom and dow constrained", () => {
|
||||
// dom=15, dow=3(Wed) - matches 15th OR Wednesday
|
||||
const fields = parseCronExpression("0 0 15 * 3")!;
|
||||
const from = new Date(2026, 0, 12, 0, 0); // Monday Jan 12
|
||||
const next = computeNextCronRun(fields, from);
|
||||
expect(next).not.toBeNull();
|
||||
// Should match the first of either: next Wednesday(Jan 14) or 15th(Jan 15)
|
||||
const dayOfWeek = next!.getDay();
|
||||
const dayOfMonth = next!.getDate();
|
||||
expect(dayOfWeek === 3 || dayOfMonth === 15).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cronToHuman", () => {
|
||||
test("every N minutes", () => {
|
||||
expect(cronToHuman("*/5 * * * *")).toBe("Every 5 minutes");
|
||||
});
|
||||
|
||||
test("every minute", () => {
|
||||
expect(cronToHuman("*/1 * * * *")).toBe("Every minute");
|
||||
});
|
||||
|
||||
test("every hour at :00", () => {
|
||||
expect(cronToHuman("0 * * * *")).toBe("Every hour");
|
||||
});
|
||||
|
||||
test("every hour at :30", () => {
|
||||
expect(cronToHuman("30 * * * *")).toBe("Every hour at :30");
|
||||
});
|
||||
|
||||
test("every N hours", () => {
|
||||
expect(cronToHuman("0 */2 * * *")).toBe("Every 2 hours");
|
||||
});
|
||||
|
||||
test("daily at specific time", () => {
|
||||
const result = cronToHuman("30 9 * * *");
|
||||
expect(result).toContain("Every day at");
|
||||
expect(result).toContain("9:30");
|
||||
});
|
||||
|
||||
test("specific day of week", () => {
|
||||
const result = cronToHuman("0 9 * * 3");
|
||||
expect(result).toContain("Wednesday");
|
||||
expect(result).toContain("9:00");
|
||||
});
|
||||
|
||||
test("weekdays", () => {
|
||||
const result = cronToHuman("0 9 * * 1-5");
|
||||
expect(result).toContain("Weekdays");
|
||||
expect(result).toContain("9:00");
|
||||
});
|
||||
|
||||
test("returns raw cron for complex patterns", () => {
|
||||
expect(cronToHuman("0,30 9-17 * * 1-5")).toBe("0,30 9-17 * * 1-5");
|
||||
});
|
||||
|
||||
test("returns raw cron for wrong field count", () => {
|
||||
expect(cronToHuman("* * *")).toBe("* * *");
|
||||
});
|
||||
});
|
||||
77
src/utils/__tests__/diff.test.ts
Normal file
77
src/utils/__tests__/diff.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { adjustHunkLineNumbers, getPatchFromContents } from "../diff";
|
||||
|
||||
describe("adjustHunkLineNumbers", () => {
|
||||
test("shifts hunk line numbers by offset", () => {
|
||||
const hunks = [
|
||||
{ oldStart: 1, oldLines: 3, newStart: 1, newLines: 4, lines: [" a", "-b", "+c", "+d", " e"] },
|
||||
] as any[];
|
||||
const result = adjustHunkLineNumbers(hunks, 10);
|
||||
expect(result[0].oldStart).toBe(11);
|
||||
expect(result[0].newStart).toBe(11);
|
||||
});
|
||||
|
||||
test("returns original hunks for zero offset", () => {
|
||||
const hunks = [
|
||||
{ oldStart: 5, oldLines: 2, newStart: 5, newLines: 2, lines: [] },
|
||||
] as any[];
|
||||
const result = adjustHunkLineNumbers(hunks, 0);
|
||||
expect(result).toBe(hunks); // same reference
|
||||
});
|
||||
|
||||
test("handles negative offset", () => {
|
||||
const hunks = [
|
||||
{ oldStart: 10, oldLines: 2, newStart: 10, newLines: 2, lines: [] },
|
||||
] as any[];
|
||||
const result = adjustHunkLineNumbers(hunks, -5);
|
||||
expect(result[0].oldStart).toBe(5);
|
||||
expect(result[0].newStart).toBe(5);
|
||||
});
|
||||
|
||||
test("handles empty hunks array", () => {
|
||||
expect(adjustHunkLineNumbers([], 10)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPatchFromContents", () => {
|
||||
test("returns hunks for different content", () => {
|
||||
const hunks = getPatchFromContents({
|
||||
filePath: "test.txt",
|
||||
oldContent: "hello\nworld",
|
||||
newContent: "hello\nplanet",
|
||||
});
|
||||
expect(hunks.length).toBeGreaterThan(0);
|
||||
expect(hunks[0].lines.some((l: string) => l.startsWith("-"))).toBe(true);
|
||||
expect(hunks[0].lines.some((l: string) => l.startsWith("+"))).toBe(true);
|
||||
});
|
||||
|
||||
test("returns empty hunks for identical content", () => {
|
||||
const hunks = getPatchFromContents({
|
||||
filePath: "test.txt",
|
||||
oldContent: "same content",
|
||||
newContent: "same content",
|
||||
});
|
||||
expect(hunks).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles content with ampersands", () => {
|
||||
const hunks = getPatchFromContents({
|
||||
filePath: "test.txt",
|
||||
oldContent: "a & b",
|
||||
newContent: "a & c",
|
||||
});
|
||||
expect(hunks.length).toBeGreaterThan(0);
|
||||
// Verify ampersands are unescaped in the output
|
||||
const allLines = hunks.flatMap((h: any) => h.lines);
|
||||
expect(allLines.some((l: string) => l.includes("&"))).toBe(true);
|
||||
});
|
||||
|
||||
test("handles empty old content (new file)", () => {
|
||||
const hunks = getPatchFromContents({
|
||||
filePath: "test.txt",
|
||||
oldContent: "",
|
||||
newContent: "new content",
|
||||
});
|
||||
expect(hunks.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
95
src/utils/__tests__/file.test.ts
Normal file
95
src/utils/__tests__/file.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
convertLeadingTabsToSpaces,
|
||||
addLineNumbers,
|
||||
stripLineNumberPrefix,
|
||||
pathsEqual,
|
||||
normalizePathForComparison,
|
||||
} from "../file";
|
||||
|
||||
describe("convertLeadingTabsToSpaces", () => {
|
||||
test("converts leading tabs to 2 spaces each", () => {
|
||||
expect(convertLeadingTabsToSpaces("\t\thello")).toBe(" hello");
|
||||
});
|
||||
|
||||
test("only converts leading tabs", () => {
|
||||
expect(convertLeadingTabsToSpaces("\thello\tworld")).toBe(" hello\tworld");
|
||||
});
|
||||
|
||||
test("returns unchanged if no tabs", () => {
|
||||
expect(convertLeadingTabsToSpaces("no tabs")).toBe("no tabs");
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(convertLeadingTabsToSpaces("")).toBe("");
|
||||
});
|
||||
|
||||
test("handles multiline content", () => {
|
||||
const input = "\tline1\n\t\tline2\nline3";
|
||||
const expected = " line1\n line2\nline3";
|
||||
expect(convertLeadingTabsToSpaces(input)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addLineNumbers", () => {
|
||||
test("adds line numbers starting from 1", () => {
|
||||
const result = addLineNumbers({ content: "a\nb\nc", startLine: 1 });
|
||||
expect(result).toContain("1");
|
||||
expect(result).toContain("a");
|
||||
expect(result).toContain("b");
|
||||
expect(result).toContain("c");
|
||||
});
|
||||
|
||||
test("returns empty string for empty content", () => {
|
||||
expect(addLineNumbers({ content: "", startLine: 1 })).toBe("");
|
||||
});
|
||||
|
||||
test("respects startLine offset", () => {
|
||||
const result = addLineNumbers({ content: "hello", startLine: 10 });
|
||||
expect(result).toContain("10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripLineNumberPrefix", () => {
|
||||
test("strips arrow-separated prefix", () => {
|
||||
expect(stripLineNumberPrefix(" 1→content")).toBe("content");
|
||||
});
|
||||
|
||||
test("strips tab-separated prefix", () => {
|
||||
expect(stripLineNumberPrefix("1\tcontent")).toBe("content");
|
||||
});
|
||||
|
||||
test("returns line unchanged if no prefix", () => {
|
||||
expect(stripLineNumberPrefix("no prefix")).toBe("no prefix");
|
||||
});
|
||||
|
||||
test("handles large line numbers", () => {
|
||||
expect(stripLineNumberPrefix("123456→content")).toBe("content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizePathForComparison", () => {
|
||||
test("normalizes redundant separators", () => {
|
||||
const result = normalizePathForComparison("/a//b/c");
|
||||
expect(result).toBe("/a/b/c");
|
||||
});
|
||||
|
||||
test("resolves dot segments", () => {
|
||||
const result = normalizePathForComparison("/a/./b/../c");
|
||||
expect(result).toBe("/a/c");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pathsEqual", () => {
|
||||
test("returns true for identical paths", () => {
|
||||
expect(pathsEqual("/a/b/c", "/a/b/c")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for equivalent paths with dot segments", () => {
|
||||
expect(pathsEqual("/a/./b", "/a/b")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for different paths", () => {
|
||||
expect(pathsEqual("/a/b", "/a/c")).toBe(false);
|
||||
});
|
||||
});
|
||||
133
src/utils/__tests__/format.test.ts
Normal file
133
src/utils/__tests__/format.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
formatFileSize,
|
||||
formatSecondsShort,
|
||||
formatDuration,
|
||||
formatNumber,
|
||||
formatTokens,
|
||||
formatRelativeTime,
|
||||
} from "../format";
|
||||
|
||||
describe("formatFileSize", () => {
|
||||
test("formats bytes", () => {
|
||||
expect(formatFileSize(500)).toBe("500 bytes");
|
||||
});
|
||||
|
||||
test("formats kilobytes", () => {
|
||||
expect(formatFileSize(1536)).toBe("1.5KB");
|
||||
});
|
||||
|
||||
test("formats megabytes", () => {
|
||||
expect(formatFileSize(1.5 * 1024 * 1024)).toBe("1.5MB");
|
||||
});
|
||||
|
||||
test("formats gigabytes", () => {
|
||||
expect(formatFileSize(2 * 1024 * 1024 * 1024)).toBe("2GB");
|
||||
});
|
||||
|
||||
test("removes trailing .0", () => {
|
||||
expect(formatFileSize(1024)).toBe("1KB");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatSecondsShort", () => {
|
||||
test("formats milliseconds to seconds", () => {
|
||||
expect(formatSecondsShort(1234)).toBe("1.2s");
|
||||
});
|
||||
|
||||
test("formats zero", () => {
|
||||
expect(formatSecondsShort(0)).toBe("0.0s");
|
||||
});
|
||||
|
||||
test("formats sub-second", () => {
|
||||
expect(formatSecondsShort(500)).toBe("0.5s");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDuration", () => {
|
||||
test("formats 0 as 0s", () => {
|
||||
expect(formatDuration(0)).toBe("0s");
|
||||
});
|
||||
|
||||
test("formats seconds", () => {
|
||||
expect(formatDuration(5000)).toBe("5s");
|
||||
});
|
||||
|
||||
test("formats minutes and seconds", () => {
|
||||
expect(formatDuration(125000)).toBe("2m 5s");
|
||||
});
|
||||
|
||||
test("formats hours", () => {
|
||||
expect(formatDuration(3661000)).toBe("1h 1m 1s");
|
||||
});
|
||||
|
||||
test("formats days", () => {
|
||||
expect(formatDuration(90000000)).toBe("1d 1h 0m");
|
||||
});
|
||||
|
||||
test("hideTrailingZeros removes zero components", () => {
|
||||
expect(formatDuration(3600000, { hideTrailingZeros: true })).toBe("1h");
|
||||
expect(formatDuration(60000, { hideTrailingZeros: true })).toBe("1m");
|
||||
});
|
||||
|
||||
test("mostSignificantOnly returns largest unit", () => {
|
||||
expect(formatDuration(90000000, { mostSignificantOnly: true })).toBe("1d");
|
||||
expect(formatDuration(3661000, { mostSignificantOnly: true })).toBe("1h");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatNumber", () => {
|
||||
test("formats small numbers as-is", () => {
|
||||
expect(formatNumber(900)).toBe("900");
|
||||
});
|
||||
|
||||
test("formats thousands with k suffix", () => {
|
||||
const result = formatNumber(1321);
|
||||
expect(result).toContain("k");
|
||||
});
|
||||
|
||||
test("formats millions", () => {
|
||||
const result = formatNumber(1500000);
|
||||
expect(result).toContain("m");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTokens", () => {
|
||||
test("removes .0 from formatted number", () => {
|
||||
const result = formatTokens(1000);
|
||||
expect(result).not.toContain(".0");
|
||||
});
|
||||
|
||||
test("formats small numbers", () => {
|
||||
expect(formatTokens(500)).toBe("500");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatRelativeTime", () => {
|
||||
const now = new Date("2026-01-15T12:00:00Z");
|
||||
|
||||
test("formats seconds ago", () => {
|
||||
const date = new Date("2026-01-15T11:59:30Z");
|
||||
const result = formatRelativeTime(date, { now });
|
||||
expect(result).toContain("30");
|
||||
expect(result).toContain("ago");
|
||||
});
|
||||
|
||||
test("formats minutes ago", () => {
|
||||
const date = new Date("2026-01-15T11:55:00Z");
|
||||
const result = formatRelativeTime(date, { now });
|
||||
expect(result).toContain("5");
|
||||
expect(result).toContain("ago");
|
||||
});
|
||||
|
||||
test("formats future time", () => {
|
||||
const date = new Date("2026-01-15T13:00:00Z");
|
||||
const result = formatRelativeTime(date, { now });
|
||||
expect(result).toContain("in");
|
||||
});
|
||||
|
||||
test("handles zero difference", () => {
|
||||
const result = formatRelativeTime(now, { now });
|
||||
expect(result).toContain("0");
|
||||
});
|
||||
});
|
||||
164
src/utils/__tests__/frontmatterParser.test.ts
Normal file
164
src/utils/__tests__/frontmatterParser.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
parseFrontmatter,
|
||||
splitPathInFrontmatter,
|
||||
parsePositiveIntFromFrontmatter,
|
||||
parseBooleanFrontmatter,
|
||||
parseShellFrontmatter,
|
||||
} from "../frontmatterParser";
|
||||
|
||||
describe("parseFrontmatter", () => {
|
||||
test("parses valid frontmatter", () => {
|
||||
const md = `---
|
||||
description: A test
|
||||
type: user
|
||||
---
|
||||
Content here`;
|
||||
const result = parseFrontmatter(md);
|
||||
expect(result.frontmatter.description).toBe("A test");
|
||||
expect(result.frontmatter.type).toBe("user");
|
||||
expect(result.content).toBe("Content here");
|
||||
});
|
||||
|
||||
test("returns empty frontmatter when none exists", () => {
|
||||
const md = "Just content, no frontmatter";
|
||||
const result = parseFrontmatter(md);
|
||||
expect(result.frontmatter).toEqual({});
|
||||
expect(result.content).toBe(md);
|
||||
});
|
||||
|
||||
test("handles empty frontmatter block", () => {
|
||||
const md = `---
|
||||
---
|
||||
Content`;
|
||||
const result = parseFrontmatter(md);
|
||||
expect(result.frontmatter).toEqual({});
|
||||
expect(result.content).toBe("Content");
|
||||
});
|
||||
|
||||
test("handles frontmatter with list values", () => {
|
||||
const md = `---
|
||||
allowed-tools:
|
||||
- Bash
|
||||
- Read
|
||||
---
|
||||
Content`;
|
||||
const result = parseFrontmatter(md);
|
||||
expect(result.frontmatter["allowed-tools"]).toEqual(["Bash", "Read"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitPathInFrontmatter", () => {
|
||||
test("splits comma-separated paths", () => {
|
||||
expect(splitPathInFrontmatter("a, b, c")).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("expands brace patterns", () => {
|
||||
expect(splitPathInFrontmatter("src/*.{ts,tsx}")).toEqual([
|
||||
"src/*.ts",
|
||||
"src/*.tsx",
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles nested brace expansion", () => {
|
||||
expect(splitPathInFrontmatter("{a,b}/{c,d}")).toEqual([
|
||||
"a/c", "a/d", "b/c", "b/d",
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles array input", () => {
|
||||
expect(splitPathInFrontmatter(["a", "b"])).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
test("returns empty array for non-string", () => {
|
||||
expect(splitPathInFrontmatter(123 as any)).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves braces in comma-separated list", () => {
|
||||
expect(splitPathInFrontmatter("a, src/*.{ts,tsx}")).toEqual([
|
||||
"a",
|
||||
"src/*.ts",
|
||||
"src/*.tsx",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parsePositiveIntFromFrontmatter", () => {
|
||||
test("returns number for positive integer", () => {
|
||||
expect(parsePositiveIntFromFrontmatter(5)).toBe(5);
|
||||
});
|
||||
|
||||
test("parses string number", () => {
|
||||
expect(parsePositiveIntFromFrontmatter("10")).toBe(10);
|
||||
});
|
||||
|
||||
test("returns undefined for zero", () => {
|
||||
expect(parsePositiveIntFromFrontmatter(0)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for negative number", () => {
|
||||
expect(parsePositiveIntFromFrontmatter(-1)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for float", () => {
|
||||
expect(parsePositiveIntFromFrontmatter(1.5)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for null/undefined", () => {
|
||||
expect(parsePositiveIntFromFrontmatter(null)).toBeUndefined();
|
||||
expect(parsePositiveIntFromFrontmatter(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for non-numeric string", () => {
|
||||
expect(parsePositiveIntFromFrontmatter("abc")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBooleanFrontmatter", () => {
|
||||
test("returns true for boolean true", () => {
|
||||
expect(parseBooleanFrontmatter(true)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for string 'true'", () => {
|
||||
expect(parseBooleanFrontmatter("true")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for boolean false", () => {
|
||||
expect(parseBooleanFrontmatter(false)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for string 'false'", () => {
|
||||
expect(parseBooleanFrontmatter("false")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for null/undefined", () => {
|
||||
expect(parseBooleanFrontmatter(null)).toBe(false);
|
||||
expect(parseBooleanFrontmatter(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseShellFrontmatter", () => {
|
||||
test("returns bash for 'bash'", () => {
|
||||
expect(parseShellFrontmatter("bash", "test")).toBe("bash");
|
||||
});
|
||||
|
||||
test("returns powershell for 'powershell'", () => {
|
||||
expect(parseShellFrontmatter("powershell", "test")).toBe("powershell");
|
||||
});
|
||||
|
||||
test("returns undefined for null", () => {
|
||||
expect(parseShellFrontmatter(null, "test")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined for unrecognized value", () => {
|
||||
expect(parseShellFrontmatter("zsh", "test")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("is case insensitive", () => {
|
||||
expect(parseShellFrontmatter("BASH", "test")).toBe("bash");
|
||||
});
|
||||
|
||||
test("returns undefined for empty string", () => {
|
||||
expect(parseShellFrontmatter("", "test")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
124
src/utils/__tests__/git.test.ts
Normal file
124
src/utils/__tests__/git.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { normalizeGitRemoteUrl } from "../git";
|
||||
|
||||
describe("normalizeGitRemoteUrl", () => {
|
||||
describe("SSH format (git@host:owner/repo)", () => {
|
||||
test("normalizes basic SSH URL", () => {
|
||||
expect(normalizeGitRemoteUrl("git@github.com:owner/repo.git")).toBe(
|
||||
"github.com/owner/repo"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles SSH URL without .git suffix", () => {
|
||||
expect(normalizeGitRemoteUrl("git@github.com:owner/repo")).toBe(
|
||||
"github.com/owner/repo"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles nested paths", () => {
|
||||
expect(normalizeGitRemoteUrl("git@gitlab.com:group/sub/repo.git")).toBe(
|
||||
"gitlab.com/group/sub/repo"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTTPS format", () => {
|
||||
test("normalizes basic HTTPS URL", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl("https://github.com/owner/repo.git")
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
|
||||
test("handles HTTPS without .git suffix", () => {
|
||||
expect(normalizeGitRemoteUrl("https://github.com/owner/repo")).toBe(
|
||||
"github.com/owner/repo"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles HTTP URL", () => {
|
||||
expect(normalizeGitRemoteUrl("http://github.com/owner/repo.git")).toBe(
|
||||
"github.com/owner/repo"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles HTTPS with auth", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl("https://user@github.com/owner/repo.git")
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ssh:// format", () => {
|
||||
test("normalizes ssh:// URL", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl("ssh://git@github.com/owner/repo")
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
|
||||
test("handles ssh:// with .git suffix", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl("ssh://git@github.com/owner/repo.git")
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CCR proxy URLs", () => {
|
||||
test("handles legacy proxy format (assumes github.com)", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl(
|
||||
"http://local_proxy@127.0.0.1:16583/git/owner/repo"
|
||||
)
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
|
||||
test("handles GHE proxy format (host in path)", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl(
|
||||
"http://local_proxy@127.0.0.1:16583/git/ghe.company.com/owner/repo"
|
||||
)
|
||||
).toBe("ghe.company.com/owner/repo");
|
||||
});
|
||||
|
||||
test("handles localhost proxy", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl(
|
||||
"http://proxy@localhost:8080/git/owner/repo"
|
||||
)
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("case normalization", () => {
|
||||
test("converts to lowercase", () => {
|
||||
expect(normalizeGitRemoteUrl("git@GitHub.COM:Owner/Repo.git")).toBe(
|
||||
"github.com/owner/repo"
|
||||
);
|
||||
});
|
||||
|
||||
test("converts HTTPS to lowercase", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl("https://GitHub.COM/Owner/Repo.git")
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("returns null for empty string", () => {
|
||||
expect(normalizeGitRemoteUrl("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for whitespace only", () => {
|
||||
expect(normalizeGitRemoteUrl(" ")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for unrecognized format", () => {
|
||||
expect(normalizeGitRemoteUrl("not-a-url")).toBeNull();
|
||||
});
|
||||
|
||||
test("trims whitespace before parsing", () => {
|
||||
expect(
|
||||
normalizeGitRemoteUrl(" git@github.com:owner/repo.git ")
|
||||
).toBe("github.com/owner/repo");
|
||||
});
|
||||
});
|
||||
});
|
||||
40
src/utils/__tests__/glob.test.ts
Normal file
40
src/utils/__tests__/glob.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { extractGlobBaseDirectory } from "../glob";
|
||||
|
||||
describe("extractGlobBaseDirectory", () => {
|
||||
test("extracts base dir from glob with *", () => {
|
||||
const result = extractGlobBaseDirectory("src/utils/*.ts");
|
||||
expect(result.baseDir).toBe("src/utils");
|
||||
expect(result.relativePattern).toBe("*.ts");
|
||||
});
|
||||
|
||||
test("extracts base dir from glob with **", () => {
|
||||
const result = extractGlobBaseDirectory("src/**/*.ts");
|
||||
expect(result.baseDir).toBe("src");
|
||||
expect(result.relativePattern).toBe("**/*.ts");
|
||||
});
|
||||
|
||||
test("returns dirname for literal path", () => {
|
||||
const result = extractGlobBaseDirectory("src/utils/file.ts");
|
||||
expect(result.baseDir).toBe("src/utils");
|
||||
expect(result.relativePattern).toBe("file.ts");
|
||||
});
|
||||
|
||||
test("handles glob starting with pattern", () => {
|
||||
const result = extractGlobBaseDirectory("*.ts");
|
||||
expect(result.baseDir).toBe("");
|
||||
expect(result.relativePattern).toBe("*.ts");
|
||||
});
|
||||
|
||||
test("handles braces pattern", () => {
|
||||
const result = extractGlobBaseDirectory("src/{a,b}/*.ts");
|
||||
expect(result.baseDir).toBe("src");
|
||||
expect(result.relativePattern).toBe("{a,b}/*.ts");
|
||||
});
|
||||
|
||||
test("handles question mark pattern", () => {
|
||||
const result = extractGlobBaseDirectory("src/?.ts");
|
||||
expect(result.baseDir).toBe("src");
|
||||
expect(result.relativePattern).toBe("?.ts");
|
||||
});
|
||||
});
|
||||
57
src/utils/__tests__/hash.test.ts
Normal file
57
src/utils/__tests__/hash.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { djb2Hash, hashContent, hashPair } from "../hash";
|
||||
|
||||
describe("djb2Hash", () => {
|
||||
test("returns a number", () => {
|
||||
expect(typeof djb2Hash("hello")).toBe("number");
|
||||
});
|
||||
|
||||
test("returns 0 for empty string", () => {
|
||||
expect(djb2Hash("")).toBe(0);
|
||||
});
|
||||
|
||||
test("is deterministic", () => {
|
||||
expect(djb2Hash("test")).toBe(djb2Hash("test"));
|
||||
});
|
||||
|
||||
test("different strings produce different hashes", () => {
|
||||
expect(djb2Hash("abc")).not.toBe(djb2Hash("def"));
|
||||
});
|
||||
|
||||
test("returns 32-bit integer", () => {
|
||||
const hash = djb2Hash("some long string to hash");
|
||||
expect(hash).toBe(hash | 0); // bitwise OR with 0 preserves 32-bit int
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashContent", () => {
|
||||
test("returns a string", () => {
|
||||
expect(typeof hashContent("hello")).toBe("string");
|
||||
});
|
||||
|
||||
test("is deterministic", () => {
|
||||
expect(hashContent("test")).toBe(hashContent("test"));
|
||||
});
|
||||
|
||||
test("different strings produce different hashes", () => {
|
||||
expect(hashContent("abc")).not.toBe(hashContent("def"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashPair", () => {
|
||||
test("returns a string", () => {
|
||||
expect(typeof hashPair("a", "b")).toBe("string");
|
||||
});
|
||||
|
||||
test("is deterministic", () => {
|
||||
expect(hashPair("a", "b")).toBe(hashPair("a", "b"));
|
||||
});
|
||||
|
||||
test("order matters", () => {
|
||||
expect(hashPair("a", "b")).not.toBe(hashPair("b", "a"));
|
||||
});
|
||||
|
||||
test("disambiguates different splits", () => {
|
||||
expect(hashPair("ts", "code")).not.toBe(hashPair("tsc", "ode"));
|
||||
});
|
||||
});
|
||||
153
src/utils/__tests__/json.test.ts
Normal file
153
src/utils/__tests__/json.test.ts
Normal file
@@ -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"]);
|
||||
});
|
||||
});
|
||||
473
src/utils/__tests__/messages.test.ts
Normal file
473
src/utils/__tests__/messages.test.ts
Normal file
@@ -0,0 +1,473 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
deriveShortMessageId,
|
||||
INTERRUPT_MESSAGE,
|
||||
INTERRUPT_MESSAGE_FOR_TOOL_USE,
|
||||
CANCEL_MESSAGE,
|
||||
REJECT_MESSAGE,
|
||||
NO_RESPONSE_REQUESTED,
|
||||
SYNTHETIC_MESSAGES,
|
||||
isSyntheticMessage,
|
||||
getLastAssistantMessage,
|
||||
hasToolCallsInLastAssistantTurn,
|
||||
createAssistantMessage,
|
||||
createAssistantAPIErrorMessage,
|
||||
createUserMessage,
|
||||
createUserInterruptionMessage,
|
||||
prepareUserContent,
|
||||
createToolResultStopMessage,
|
||||
extractTag,
|
||||
isNotEmptyMessage,
|
||||
deriveUUID,
|
||||
normalizeMessages,
|
||||
isClassifierDenial,
|
||||
buildYoloRejectionMessage,
|
||||
buildClassifierUnavailableMessage,
|
||||
AUTO_REJECT_MESSAGE,
|
||||
DONT_ASK_REJECT_MESSAGE,
|
||||
SYNTHETIC_MODEL,
|
||||
} from "../messages";
|
||||
import type { Message, AssistantMessage, UserMessage } from "../../types/message";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
function makeAssistantMsg(
|
||||
contentBlocks: Array<{ type: string; text?: string; [key: string]: any }>
|
||||
): AssistantMessage {
|
||||
return createAssistantMessage({
|
||||
content: contentBlocks as any,
|
||||
});
|
||||
}
|
||||
|
||||
function makeUserMsg(text: string): UserMessage {
|
||||
return createUserMessage({ content: text });
|
||||
}
|
||||
|
||||
// ─── deriveShortMessageId ───────────────────────────────────────────────
|
||||
|
||||
describe("deriveShortMessageId", () => {
|
||||
test("returns 6-char string", () => {
|
||||
const id = deriveShortMessageId("550e8400-e29b-41d4-a716-446655440000");
|
||||
expect(id).toHaveLength(6);
|
||||
});
|
||||
|
||||
test("is deterministic for same input", () => {
|
||||
const uuid = "a0b1c2d3-e4f5-6789-abcd-ef0123456789";
|
||||
expect(deriveShortMessageId(uuid)).toBe(deriveShortMessageId(uuid));
|
||||
});
|
||||
|
||||
test("produces different IDs for different UUIDs", () => {
|
||||
const id1 = deriveShortMessageId("00000000-0000-0000-0000-000000000001");
|
||||
const id2 = deriveShortMessageId("ffffffff-ffff-ffff-ffff-ffffffffffff");
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("message constants", () => {
|
||||
test("SYNTHETIC_MESSAGES contains expected messages", () => {
|
||||
expect(SYNTHETIC_MESSAGES.has(INTERRUPT_MESSAGE)).toBe(true);
|
||||
expect(SYNTHETIC_MESSAGES.has(INTERRUPT_MESSAGE_FOR_TOOL_USE)).toBe(true);
|
||||
expect(SYNTHETIC_MESSAGES.has(CANCEL_MESSAGE)).toBe(true);
|
||||
expect(SYNTHETIC_MESSAGES.has(REJECT_MESSAGE)).toBe(true);
|
||||
expect(SYNTHETIC_MESSAGES.has(NO_RESPONSE_REQUESTED)).toBe(true);
|
||||
});
|
||||
|
||||
test("SYNTHETIC_MODEL is <synthetic>", () => {
|
||||
expect(SYNTHETIC_MODEL).toBe("<synthetic>");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Message factories ──────────────────────────────────────────────────
|
||||
|
||||
describe("createAssistantMessage", () => {
|
||||
test("creates assistant message with string content", () => {
|
||||
const msg = createAssistantMessage({ content: "hello" });
|
||||
expect(msg.type).toBe("assistant");
|
||||
expect(msg.message.role).toBe("assistant");
|
||||
expect(msg.message.content).toHaveLength(1);
|
||||
expect((msg.message.content[0] as any).text).toBe("hello");
|
||||
});
|
||||
|
||||
test("creates assistant message with content blocks", () => {
|
||||
const blocks = [{ type: "text" as const, text: "hello" }];
|
||||
const msg = createAssistantMessage({ content: blocks as any });
|
||||
expect(msg.type).toBe("assistant");
|
||||
expect(msg.message.content).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("generates unique uuid per call", () => {
|
||||
const msg1 = createAssistantMessage({ content: "a" });
|
||||
const msg2 = createAssistantMessage({ content: "b" });
|
||||
expect(msg1.uuid).not.toBe(msg2.uuid);
|
||||
});
|
||||
|
||||
test("has isApiErrorMessage false", () => {
|
||||
const msg = createAssistantMessage({ content: "test" });
|
||||
expect(msg.isApiErrorMessage).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAssistantAPIErrorMessage", () => {
|
||||
test("sets isApiErrorMessage to true", () => {
|
||||
const msg = createAssistantAPIErrorMessage({ content: "error" });
|
||||
expect(msg.isApiErrorMessage).toBe(true);
|
||||
});
|
||||
|
||||
test("includes error details", () => {
|
||||
const msg = createAssistantAPIErrorMessage({
|
||||
content: "fail",
|
||||
errorDetails: "rate limited",
|
||||
});
|
||||
expect(msg.errorDetails).toBe("rate limited");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createUserMessage", () => {
|
||||
test("creates user message with string content", () => {
|
||||
const msg = createUserMessage({ content: "hello" });
|
||||
expect(msg.type).toBe("user");
|
||||
expect(msg.message.role).toBe("user");
|
||||
expect(msg.message.content).toBe("hello");
|
||||
});
|
||||
|
||||
test("generates unique uuid", () => {
|
||||
const msg1 = createUserMessage({ content: "a" });
|
||||
const msg2 = createUserMessage({ content: "b" });
|
||||
expect(msg1.uuid).not.toBe(msg2.uuid);
|
||||
});
|
||||
|
||||
test("uses provided uuid when given", () => {
|
||||
const msg = createUserMessage({
|
||||
content: "test",
|
||||
uuid: "custom-uuid-1234-5678-abcd-ef0123456789",
|
||||
});
|
||||
expect(msg.uuid).toBe("custom-uuid-1234-5678-abcd-ef0123456789");
|
||||
});
|
||||
|
||||
test("sets isMeta flag", () => {
|
||||
const msg = createUserMessage({ content: "test", isMeta: true });
|
||||
expect(msg.isMeta).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createUserInterruptionMessage", () => {
|
||||
test("creates interrupt message without tool use", () => {
|
||||
const msg = createUserInterruptionMessage({});
|
||||
expect(msg.type).toBe("user");
|
||||
expect((msg.message.content as any)[0].text).toBe(INTERRUPT_MESSAGE);
|
||||
});
|
||||
|
||||
test("creates interrupt message with tool use", () => {
|
||||
const msg = createUserInterruptionMessage({ toolUse: true });
|
||||
expect((msg.message.content as any)[0].text).toBe(
|
||||
INTERRUPT_MESSAGE_FOR_TOOL_USE
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareUserContent", () => {
|
||||
test("returns string when no preceding blocks", () => {
|
||||
const result = prepareUserContent({
|
||||
inputString: "hello",
|
||||
precedingInputBlocks: [],
|
||||
});
|
||||
expect(result).toBe("hello");
|
||||
});
|
||||
|
||||
test("returns array when preceding blocks exist", () => {
|
||||
const blocks = [{ type: "image" as const, source: {} } as any];
|
||||
const result = prepareUserContent({
|
||||
inputString: "describe this",
|
||||
precedingInputBlocks: blocks,
|
||||
});
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect((result as any[]).length).toBe(2);
|
||||
expect((result as any[])[1].text).toBe("describe this");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createToolResultStopMessage", () => {
|
||||
test("creates tool result with error flag", () => {
|
||||
const result = createToolResultStopMessage("tool-123");
|
||||
expect(result.type).toBe("tool_result");
|
||||
expect(result.is_error).toBe(true);
|
||||
expect(result.tool_use_id).toBe("tool-123");
|
||||
expect(result.content).toBe(CANCEL_MESSAGE);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isSyntheticMessage ─────────────────────────────────────────────────
|
||||
|
||||
describe("isSyntheticMessage", () => {
|
||||
test("identifies interrupt message as synthetic", () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: { content: [{ type: "text", text: INTERRUPT_MESSAGE }] },
|
||||
};
|
||||
expect(isSyntheticMessage(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("identifies cancel message as synthetic", () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: { content: [{ type: "text", text: CANCEL_MESSAGE }] },
|
||||
};
|
||||
expect(isSyntheticMessage(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for normal user message", () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: { content: [{ type: "text", text: "hello" }] },
|
||||
};
|
||||
expect(isSyntheticMessage(msg)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for progress message", () => {
|
||||
const msg: any = {
|
||||
type: "progress",
|
||||
message: { content: [{ type: "text", text: INTERRUPT_MESSAGE }] },
|
||||
};
|
||||
expect(isSyntheticMessage(msg)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for string content", () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: { content: INTERRUPT_MESSAGE },
|
||||
};
|
||||
expect(isSyntheticMessage(msg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getLastAssistantMessage ────────────────────────────────────────────
|
||||
|
||||
describe("getLastAssistantMessage", () => {
|
||||
test("returns last assistant message", () => {
|
||||
const a1 = makeAssistantMsg([{ type: "text", text: "first" }]);
|
||||
const u = makeUserMsg("mid");
|
||||
const a2 = makeAssistantMsg([{ type: "text", text: "last" }]);
|
||||
const result = getLastAssistantMessage([a1, u, a2]);
|
||||
expect(result).toBe(a2);
|
||||
});
|
||||
|
||||
test("returns undefined for empty array", () => {
|
||||
expect(getLastAssistantMessage([])).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns undefined when no assistant messages", () => {
|
||||
const u = makeUserMsg("hello");
|
||||
expect(getLastAssistantMessage([u])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── hasToolCallsInLastAssistantTurn ────────────────────────────────────
|
||||
|
||||
describe("hasToolCallsInLastAssistantTurn", () => {
|
||||
test("returns true when last assistant has tool_use", () => {
|
||||
const msg = makeAssistantMsg([
|
||||
{ type: "text", text: "let me check" },
|
||||
{ type: "tool_use", id: "t1", name: "Bash", input: {} },
|
||||
]);
|
||||
expect(hasToolCallsInLastAssistantTurn([msg])).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when last assistant has only text", () => {
|
||||
const msg = makeAssistantMsg([{ type: "text", text: "done" }]);
|
||||
expect(hasToolCallsInLastAssistantTurn([msg])).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for empty messages", () => {
|
||||
expect(hasToolCallsInLastAssistantTurn([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── extractTag ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("extractTag", () => {
|
||||
test("extracts simple tag content", () => {
|
||||
expect(extractTag("<foo>bar</foo>", "foo")).toBe("bar");
|
||||
});
|
||||
|
||||
test("extracts tag with attributes", () => {
|
||||
expect(extractTag('<foo class="a">bar</foo>', "foo")).toBe("bar");
|
||||
});
|
||||
|
||||
test("handles multiline content", () => {
|
||||
expect(extractTag("<foo>\nline1\nline2\n</foo>", "foo")).toBe(
|
||||
"\nline1\nline2\n"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null for missing tag", () => {
|
||||
expect(extractTag("<foo>bar</foo>", "baz")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty html", () => {
|
||||
expect(extractTag("", "foo")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty tagName", () => {
|
||||
expect(extractTag("<foo>bar</foo>", "")).toBeNull();
|
||||
});
|
||||
|
||||
test("is case-insensitive", () => {
|
||||
expect(extractTag("<FOO>bar</FOO>", "foo")).toBe("bar");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isNotEmptyMessage ──────────────────────────────────────────────────
|
||||
|
||||
describe("isNotEmptyMessage", () => {
|
||||
test("returns true for message with text content", () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: { content: "hello" },
|
||||
};
|
||||
expect(isNotEmptyMessage(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for empty string content", () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: { content: " " },
|
||||
};
|
||||
expect(isNotEmptyMessage(msg)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for empty content array", () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: { content: [] },
|
||||
};
|
||||
expect(isNotEmptyMessage(msg)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for progress message", () => {
|
||||
const msg: any = {
|
||||
type: "progress",
|
||||
message: { content: [] },
|
||||
};
|
||||
expect(isNotEmptyMessage(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for multi-block content", () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "a" },
|
||||
{ type: "text", text: "b" },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(isNotEmptyMessage(msg)).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for non-text block", () => {
|
||||
const msg: any = {
|
||||
type: "user",
|
||||
message: {
|
||||
content: [{ type: "tool_use", id: "t1", name: "Bash", input: {} }],
|
||||
},
|
||||
};
|
||||
expect(isNotEmptyMessage(msg)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deriveUUID ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("deriveUUID", () => {
|
||||
test("produces deterministic output", () => {
|
||||
const parent = "550e8400-e29b-41d4-a716-446655440000" as any;
|
||||
expect(deriveUUID(parent, 0)).toBe(deriveUUID(parent, 0));
|
||||
});
|
||||
|
||||
test("produces different output for different indices", () => {
|
||||
const parent = "550e8400-e29b-41d4-a716-446655440000" as any;
|
||||
expect(deriveUUID(parent, 0)).not.toBe(deriveUUID(parent, 1));
|
||||
});
|
||||
|
||||
test("preserves UUID-like length", () => {
|
||||
const parent = "550e8400-e29b-41d4-a716-446655440000" as any;
|
||||
const derived = deriveUUID(parent, 5);
|
||||
expect(derived.length).toBe(parent.length);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── isClassifierDenial ─────────────────────────────────────────────────
|
||||
|
||||
describe("isClassifierDenial", () => {
|
||||
test("returns true for classifier denial prefix", () => {
|
||||
expect(
|
||||
isClassifierDenial(
|
||||
"Permission for this action has been denied. Reason: unsafe"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for normal content", () => {
|
||||
expect(isClassifierDenial("hello world")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Message builder functions ──────────────────────────────────────────
|
||||
|
||||
describe("AUTO_REJECT_MESSAGE", () => {
|
||||
test("includes tool name", () => {
|
||||
const msg = AUTO_REJECT_MESSAGE("Bash");
|
||||
expect(msg).toContain("Bash");
|
||||
expect(msg).toContain("denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DONT_ASK_REJECT_MESSAGE", () => {
|
||||
test("includes tool name and dont ask mode", () => {
|
||||
const msg = DONT_ASK_REJECT_MESSAGE("Write");
|
||||
expect(msg).toContain("Write");
|
||||
expect(msg).toContain("don't ask mode");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildYoloRejectionMessage", () => {
|
||||
test("includes reason", () => {
|
||||
const msg = buildYoloRejectionMessage("potentially destructive");
|
||||
expect(msg).toContain("potentially destructive");
|
||||
expect(msg).toContain("denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildClassifierUnavailableMessage", () => {
|
||||
test("includes tool name and model", () => {
|
||||
const msg = buildClassifierUnavailableMessage("Bash", "classifier-v1");
|
||||
expect(msg).toContain("Bash");
|
||||
expect(msg).toContain("classifier-v1");
|
||||
expect(msg).toContain("unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── normalizeMessages ──────────────────────────────────────────────────
|
||||
|
||||
describe("normalizeMessages", () => {
|
||||
test("splits multi-block assistant message into individual messages", () => {
|
||||
const msg = makeAssistantMsg([
|
||||
{ type: "text", text: "first" },
|
||||
{ type: "text", text: "second" },
|
||||
]);
|
||||
const normalized = normalizeMessages([msg]);
|
||||
expect(normalized.length).toBe(2);
|
||||
});
|
||||
|
||||
test("handles empty array", () => {
|
||||
const result = normalizeMessages([] as AssistantMessage[]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves single-block message", () => {
|
||||
const msg = makeAssistantMsg([{ type: "text", text: "hello" }]);
|
||||
const normalized = normalizeMessages([msg]);
|
||||
expect(normalized.length).toBe(1);
|
||||
});
|
||||
});
|
||||
72
src/utils/__tests__/path.test.ts
Normal file
72
src/utils/__tests__/path.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
98
src/utils/__tests__/semver.test.ts
Normal file
98
src/utils/__tests__/semver.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { gt, gte, lt, lte, satisfies, order } from "../semver";
|
||||
|
||||
describe("gt", () => {
|
||||
test("returns true when a > b", () => {
|
||||
expect(gt("2.0.0", "1.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when a < b", () => {
|
||||
expect(gt("1.0.0", "2.0.0")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when equal", () => {
|
||||
expect(gt("1.0.0", "1.0.0")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gte", () => {
|
||||
test("returns true when a > b", () => {
|
||||
expect(gte("2.0.0", "1.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true when equal", () => {
|
||||
expect(gte("1.0.0", "1.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when a < b", () => {
|
||||
expect(gte("1.0.0", "2.0.0")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lt", () => {
|
||||
test("returns true when a < b", () => {
|
||||
expect(lt("1.0.0", "2.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when a > b", () => {
|
||||
expect(lt("2.0.0", "1.0.0")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when equal", () => {
|
||||
expect(lt("1.0.0", "1.0.0")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lte", () => {
|
||||
test("returns true when a < b", () => {
|
||||
expect(lte("1.0.0", "2.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true when equal", () => {
|
||||
expect(lte("1.0.0", "1.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false when a > b", () => {
|
||||
expect(lte("2.0.0", "1.0.0")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("satisfies", () => {
|
||||
test("matches exact version", () => {
|
||||
expect(satisfies("1.2.3", "1.2.3")).toBe(true);
|
||||
});
|
||||
|
||||
test("matches range", () => {
|
||||
expect(satisfies("1.2.3", ">=1.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not match out-of-range version", () => {
|
||||
expect(satisfies("0.9.0", ">=1.0.0")).toBe(false);
|
||||
});
|
||||
|
||||
test("matches caret range", () => {
|
||||
expect(satisfies("1.2.3", "^1.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("does not match major bump in caret", () => {
|
||||
expect(satisfies("2.0.0", "^1.0.0")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("order", () => {
|
||||
test("returns 1 when a > b", () => {
|
||||
expect(order("2.0.0", "1.0.0")).toBe(1);
|
||||
});
|
||||
|
||||
test("returns -1 when a < b", () => {
|
||||
expect(order("1.0.0", "2.0.0")).toBe(-1);
|
||||
});
|
||||
|
||||
test("returns 0 when equal", () => {
|
||||
expect(order("1.0.0", "1.0.0")).toBe(0);
|
||||
});
|
||||
|
||||
test("compares patch versions", () => {
|
||||
expect(order("1.0.1", "1.0.0")).toBe(1);
|
||||
});
|
||||
});
|
||||
195
src/utils/__tests__/stringUtils.test.ts
Normal file
195
src/utils/__tests__/stringUtils.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
escapeRegExp,
|
||||
capitalize,
|
||||
plural,
|
||||
firstLineOf,
|
||||
countCharInString,
|
||||
normalizeFullWidthDigits,
|
||||
normalizeFullWidthSpace,
|
||||
safeJoinLines,
|
||||
EndTruncatingAccumulator,
|
||||
truncateToLines,
|
||||
} from "../stringUtils";
|
||||
|
||||
describe("escapeRegExp", () => {
|
||||
test("escapes special regex chars", () => {
|
||||
expect(escapeRegExp("a.b*c?d")).toBe("a\\.b\\*c\\?d");
|
||||
});
|
||||
|
||||
test("escapes brackets and parens", () => {
|
||||
expect(escapeRegExp("[foo](bar)")).toBe("\\[foo\\]\\(bar\\)");
|
||||
});
|
||||
|
||||
test("escapes all special chars", () => {
|
||||
expect(escapeRegExp("^${}()|[]\\.*+?")).toBe(
|
||||
"\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\\\.\\*\\+\\?"
|
||||
);
|
||||
});
|
||||
|
||||
test("returns normal string unchanged", () => {
|
||||
expect(escapeRegExp("hello")).toBe("hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("capitalize", () => {
|
||||
test("uppercases first char", () => {
|
||||
expect(capitalize("hello")).toBe("Hello");
|
||||
});
|
||||
|
||||
test("does NOT lowercase rest", () => {
|
||||
expect(capitalize("fooBar")).toBe("FooBar");
|
||||
});
|
||||
|
||||
test("handles single char", () => {
|
||||
expect(capitalize("a")).toBe("A");
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(capitalize("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plural", () => {
|
||||
test("returns singular for 1", () => {
|
||||
expect(plural(1, "file")).toBe("file");
|
||||
});
|
||||
|
||||
test("returns plural for 0", () => {
|
||||
expect(plural(0, "file")).toBe("files");
|
||||
});
|
||||
|
||||
test("returns plural for many", () => {
|
||||
expect(plural(3, "file")).toBe("files");
|
||||
});
|
||||
|
||||
test("uses custom plural form", () => {
|
||||
expect(plural(2, "entry", "entries")).toBe("entries");
|
||||
});
|
||||
});
|
||||
|
||||
describe("firstLineOf", () => {
|
||||
test("returns first line of multiline string", () => {
|
||||
expect(firstLineOf("line1\nline2\nline3")).toBe("line1");
|
||||
});
|
||||
|
||||
test("returns whole string if no newline", () => {
|
||||
expect(firstLineOf("single line")).toBe("single line");
|
||||
});
|
||||
|
||||
test("returns empty string for leading newline", () => {
|
||||
expect(firstLineOf("\nline2")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("countCharInString", () => {
|
||||
test("counts occurrences of a character", () => {
|
||||
expect(countCharInString("hello world", "l")).toBe(3);
|
||||
});
|
||||
|
||||
test("returns 0 for no match", () => {
|
||||
expect(countCharInString("hello", "z")).toBe(0);
|
||||
});
|
||||
|
||||
test("counts from start offset", () => {
|
||||
expect(countCharInString("aabaa", "a", 2)).toBe(2);
|
||||
});
|
||||
|
||||
test("returns 0 for empty string", () => {
|
||||
expect(countCharInString("", "a")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeFullWidthDigits", () => {
|
||||
test("converts full-width digits to half-width", () => {
|
||||
expect(normalizeFullWidthDigits("0123456789")).toBe("0123456789");
|
||||
});
|
||||
|
||||
test("leaves half-width digits unchanged", () => {
|
||||
expect(normalizeFullWidthDigits("0123")).toBe("0123");
|
||||
});
|
||||
|
||||
test("handles mixed content", () => {
|
||||
expect(normalizeFullWidthDigits("test123")).toBe("test123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeFullWidthSpace", () => {
|
||||
test("converts full-width space to half-width", () => {
|
||||
expect(normalizeFullWidthSpace("a\u3000b")).toBe("a b");
|
||||
});
|
||||
|
||||
test("leaves normal spaces unchanged", () => {
|
||||
expect(normalizeFullWidthSpace("a b")).toBe("a b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeJoinLines", () => {
|
||||
test("joins lines with delimiter", () => {
|
||||
expect(safeJoinLines(["a", "b", "c"], ",")).toBe("a,b,c");
|
||||
});
|
||||
|
||||
test("truncates when exceeding maxSize", () => {
|
||||
const result = safeJoinLines(["hello", "world", "foo"], ",", 12);
|
||||
expect(result.length).toBeLessThanOrEqual(12 + "...[truncated]".length);
|
||||
expect(result).toContain("...[truncated]");
|
||||
});
|
||||
|
||||
test("returns empty string for empty input", () => {
|
||||
expect(safeJoinLines([])).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EndTruncatingAccumulator", () => {
|
||||
test("accumulates text", () => {
|
||||
const acc = new EndTruncatingAccumulator(100);
|
||||
acc.append("hello ");
|
||||
acc.append("world");
|
||||
expect(acc.toString()).toBe("hello world");
|
||||
});
|
||||
|
||||
test("truncates when exceeding maxSize", () => {
|
||||
const acc = new EndTruncatingAccumulator(10);
|
||||
acc.append("12345678901234567890");
|
||||
expect(acc.truncated).toBe(true);
|
||||
expect(acc.length).toBe(10);
|
||||
});
|
||||
|
||||
test("reports total bytes received", () => {
|
||||
const acc = new EndTruncatingAccumulator(5);
|
||||
acc.append("1234567890");
|
||||
expect(acc.totalBytes).toBe(10);
|
||||
});
|
||||
|
||||
test("clear resets state", () => {
|
||||
const acc = new EndTruncatingAccumulator(100);
|
||||
acc.append("hello");
|
||||
acc.clear();
|
||||
expect(acc.toString()).toBe("");
|
||||
expect(acc.length).toBe(0);
|
||||
expect(acc.truncated).toBe(false);
|
||||
});
|
||||
|
||||
test("stops accepting data once truncated and full", () => {
|
||||
const acc = new EndTruncatingAccumulator(5);
|
||||
acc.append("12345");
|
||||
acc.append("67890");
|
||||
expect(acc.length).toBe(5);
|
||||
acc.append("more");
|
||||
expect(acc.length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateToLines", () => {
|
||||
test("returns text unchanged if within limit", () => {
|
||||
expect(truncateToLines("a\nb\nc", 5)).toBe("a\nb\nc");
|
||||
});
|
||||
|
||||
test("truncates text exceeding limit", () => {
|
||||
expect(truncateToLines("a\nb\nc\nd\ne", 3)).toBe("a\nb\nc…");
|
||||
});
|
||||
|
||||
test("handles single line", () => {
|
||||
expect(truncateToLines("hello", 1)).toBe("hello");
|
||||
});
|
||||
});
|
||||
88
src/utils/__tests__/systemPrompt.test.ts
Normal file
88
src/utils/__tests__/systemPrompt.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { buildEffectiveSystemPrompt } from "../systemPrompt";
|
||||
|
||||
const defaultPrompt = ["You are a helpful assistant.", "Follow instructions."];
|
||||
|
||||
function buildPrompt(overrides: Record<string, unknown> = {}) {
|
||||
return buildEffectiveSystemPrompt({
|
||||
mainThreadAgentDefinition: undefined,
|
||||
toolUseContext: { options: {} as any },
|
||||
customSystemPrompt: undefined,
|
||||
defaultSystemPrompt: defaultPrompt,
|
||||
appendSystemPrompt: undefined,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("buildEffectiveSystemPrompt", () => {
|
||||
test("returns default system prompt when no overrides", () => {
|
||||
const result = buildPrompt();
|
||||
expect(Array.from(result)).toEqual(defaultPrompt);
|
||||
});
|
||||
|
||||
test("overrideSystemPrompt replaces everything", () => {
|
||||
const result = buildPrompt({ overrideSystemPrompt: "override" });
|
||||
expect(Array.from(result)).toEqual(["override"]);
|
||||
});
|
||||
|
||||
test("customSystemPrompt replaces default", () => {
|
||||
const result = buildPrompt({ customSystemPrompt: "custom" });
|
||||
expect(Array.from(result)).toEqual(["custom"]);
|
||||
});
|
||||
|
||||
test("appendSystemPrompt is appended after main prompt", () => {
|
||||
const result = buildPrompt({ appendSystemPrompt: "appended" });
|
||||
expect(Array.from(result)).toEqual([...defaultPrompt, "appended"]);
|
||||
});
|
||||
|
||||
test("agent definition replaces default prompt", () => {
|
||||
const agentDef = {
|
||||
getSystemPrompt: () => "agent prompt",
|
||||
agentType: "custom",
|
||||
} as any;
|
||||
const result = buildPrompt({ mainThreadAgentDefinition: agentDef });
|
||||
expect(Array.from(result)).toEqual(["agent prompt"]);
|
||||
});
|
||||
|
||||
test("agent definition with append combines both", () => {
|
||||
const agentDef = {
|
||||
getSystemPrompt: () => "agent prompt",
|
||||
agentType: "custom",
|
||||
} as any;
|
||||
const result = buildPrompt({
|
||||
mainThreadAgentDefinition: agentDef,
|
||||
appendSystemPrompt: "extra",
|
||||
});
|
||||
expect(Array.from(result)).toEqual(["agent prompt", "extra"]);
|
||||
});
|
||||
|
||||
test("override takes precedence over agent and custom", () => {
|
||||
const agentDef = {
|
||||
getSystemPrompt: () => "agent prompt",
|
||||
agentType: "custom",
|
||||
} as any;
|
||||
const result = buildPrompt({
|
||||
mainThreadAgentDefinition: agentDef,
|
||||
customSystemPrompt: "custom",
|
||||
appendSystemPrompt: "extra",
|
||||
overrideSystemPrompt: "override",
|
||||
});
|
||||
expect(Array.from(result)).toEqual(["override"]);
|
||||
});
|
||||
|
||||
test("returns array of strings", () => {
|
||||
const result = buildPrompt();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
for (const item of result) {
|
||||
expect(typeof item).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
test("custom + append combines both", () => {
|
||||
const result = buildPrompt({
|
||||
customSystemPrompt: "custom",
|
||||
appendSystemPrompt: "extra",
|
||||
});
|
||||
expect(Array.from(result)).toEqual(["custom", "extra"]);
|
||||
});
|
||||
});
|
||||
296
src/utils/__tests__/tokens.test.ts
Normal file
296
src/utils/__tests__/tokens.test.ts
Normal file
@@ -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 },
|
||||
"<synthetic>"
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
146
src/utils/__tests__/truncate.test.ts
Normal file
146
src/utils/__tests__/truncate.test.ts
Normal file
@@ -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"]);
|
||||
});
|
||||
});
|
||||
34
src/utils/__tests__/uuid.test.ts
Normal file
34
src/utils/__tests__/uuid.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { validateUuid } from "../uuid";
|
||||
|
||||
describe("validateUuid", () => {
|
||||
test("validates correct UUID", () => {
|
||||
const result = validateUuid("550e8400-e29b-41d4-a716-446655440000");
|
||||
expect(result).toBe("550e8400-e29b-41d4-a716-446655440000");
|
||||
});
|
||||
|
||||
test("validates uppercase UUID", () => {
|
||||
const result = validateUuid("550E8400-E29B-41D4-A716-446655440000");
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for non-string", () => {
|
||||
expect(validateUuid(123)).toBeNull();
|
||||
expect(validateUuid(null)).toBeNull();
|
||||
expect(validateUuid(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for invalid UUID format", () => {
|
||||
expect(validateUuid("not-a-uuid")).toBeNull();
|
||||
expect(validateUuid("550e8400-e29b-41d4-a716")).toBeNull();
|
||||
expect(validateUuid("550e8400e29b41d4a716446655440000")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for empty string", () => {
|
||||
expect(validateUuid("")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for UUID with invalid chars", () => {
|
||||
expect(validateUuid("550e8400-e29b-41d4-a716-44665544000g")).toBeNull();
|
||||
});
|
||||
});
|
||||
42
src/utils/__tests__/xml.test.ts
Normal file
42
src/utils/__tests__/xml.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { escapeXml, escapeXmlAttr } from "../xml";
|
||||
|
||||
describe("escapeXml", () => {
|
||||
test("escapes ampersand", () => {
|
||||
expect(escapeXml("a & b")).toBe("a & b");
|
||||
});
|
||||
|
||||
test("escapes less-than", () => {
|
||||
expect(escapeXml("<div>")).toBe("<div>");
|
||||
});
|
||||
|
||||
test("escapes greater-than", () => {
|
||||
expect(escapeXml("a > b")).toBe("a > b");
|
||||
});
|
||||
|
||||
test("escapes multiple special chars", () => {
|
||||
expect(escapeXml("<a & b>")).toBe("<a & b>");
|
||||
});
|
||||
|
||||
test("returns empty string unchanged", () => {
|
||||
expect(escapeXml("")).toBe("");
|
||||
});
|
||||
|
||||
test("returns normal text unchanged", () => {
|
||||
expect(escapeXml("hello world")).toBe("hello world");
|
||||
});
|
||||
});
|
||||
|
||||
describe("escapeXmlAttr", () => {
|
||||
test("escapes double quotes", () => {
|
||||
expect(escapeXmlAttr('say "hello"')).toBe("say "hello"");
|
||||
});
|
||||
|
||||
test("escapes single quotes", () => {
|
||||
expect(escapeXmlAttr("it's")).toBe("it's");
|
||||
});
|
||||
|
||||
test("escapes all special chars", () => {
|
||||
expect(escapeXmlAttr('<a & "b">')).toBe("<a & "b">");
|
||||
});
|
||||
});
|
||||
70
src/utils/model/__tests__/aliases.test.ts
Normal file
70
src/utils/model/__tests__/aliases.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { isModelAlias, isModelFamilyAlias } from "../aliases";
|
||||
|
||||
describe("isModelAlias", () => {
|
||||
test('returns true for "sonnet"', () => {
|
||||
expect(isModelAlias("sonnet")).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for "opus"', () => {
|
||||
expect(isModelAlias("opus")).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for "haiku"', () => {
|
||||
expect(isModelAlias("haiku")).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for "best"', () => {
|
||||
expect(isModelAlias("best")).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for "sonnet[1m]"', () => {
|
||||
expect(isModelAlias("sonnet[1m]")).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for "opus[1m]"', () => {
|
||||
expect(isModelAlias("opus[1m]")).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for "opusplan"', () => {
|
||||
expect(isModelAlias("opusplan")).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for full model ID", () => {
|
||||
expect(isModelAlias("claude-sonnet-4-6-20250514")).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for unknown string", () => {
|
||||
expect(isModelAlias("gpt-4")).toBe(false);
|
||||
});
|
||||
|
||||
test("is case-sensitive", () => {
|
||||
expect(isModelAlias("Sonnet")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isModelFamilyAlias", () => {
|
||||
test('returns true for "sonnet"', () => {
|
||||
expect(isModelFamilyAlias("sonnet")).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for "opus"', () => {
|
||||
expect(isModelFamilyAlias("opus")).toBe(true);
|
||||
});
|
||||
|
||||
test('returns true for "haiku"', () => {
|
||||
expect(isModelFamilyAlias("haiku")).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for "best"', () => {
|
||||
expect(isModelFamilyAlias("best")).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for "opusplan"', () => {
|
||||
expect(isModelFamilyAlias("opusplan")).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for "sonnet[1m]"', () => {
|
||||
expect(isModelFamilyAlias("sonnet[1m]")).toBe(false);
|
||||
});
|
||||
});
|
||||
92
src/utils/model/__tests__/model.test.ts
Normal file
92
src/utils/model/__tests__/model.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { firstPartyNameToCanonical } from "../model";
|
||||
|
||||
describe("firstPartyNameToCanonical", () => {
|
||||
test("maps opus-4-6 full name to canonical", () => {
|
||||
expect(firstPartyNameToCanonical("claude-opus-4-6-20250514")).toBe(
|
||||
"claude-opus-4-6"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps sonnet-4-6 full name", () => {
|
||||
expect(firstPartyNameToCanonical("claude-sonnet-4-6-20250514")).toBe(
|
||||
"claude-sonnet-4-6"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps haiku-4-5", () => {
|
||||
expect(firstPartyNameToCanonical("claude-haiku-4-5-20251001")).toBe(
|
||||
"claude-haiku-4-5"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps 3P provider format", () => {
|
||||
expect(
|
||||
firstPartyNameToCanonical("us.anthropic.claude-opus-4-6-v1:0")
|
||||
).toBe("claude-opus-4-6");
|
||||
});
|
||||
|
||||
test("maps claude-3-7-sonnet", () => {
|
||||
expect(firstPartyNameToCanonical("claude-3-7-sonnet-20250219")).toBe(
|
||||
"claude-3-7-sonnet"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps claude-3-5-sonnet", () => {
|
||||
expect(firstPartyNameToCanonical("claude-3-5-sonnet-20241022")).toBe(
|
||||
"claude-3-5-sonnet"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps claude-3-5-haiku", () => {
|
||||
expect(firstPartyNameToCanonical("claude-3-5-haiku-20241022")).toBe(
|
||||
"claude-3-5-haiku"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps claude-3-opus", () => {
|
||||
expect(firstPartyNameToCanonical("claude-3-opus-20240229")).toBe(
|
||||
"claude-3-opus"
|
||||
);
|
||||
});
|
||||
|
||||
test("is case insensitive", () => {
|
||||
expect(firstPartyNameToCanonical("Claude-Opus-4-6-20250514")).toBe(
|
||||
"claude-opus-4-6"
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to input for unknown model", () => {
|
||||
expect(firstPartyNameToCanonical("unknown-model")).toBe("unknown-model");
|
||||
});
|
||||
|
||||
test("differentiates opus-4 vs opus-4-5 vs opus-4-6", () => {
|
||||
expect(firstPartyNameToCanonical("claude-opus-4-20240101")).toBe(
|
||||
"claude-opus-4"
|
||||
);
|
||||
expect(firstPartyNameToCanonical("claude-opus-4-5-20240101")).toBe(
|
||||
"claude-opus-4-5"
|
||||
);
|
||||
expect(firstPartyNameToCanonical("claude-opus-4-6-20240101")).toBe(
|
||||
"claude-opus-4-6"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps opus-4-1", () => {
|
||||
expect(firstPartyNameToCanonical("claude-opus-4-1-20240101")).toBe(
|
||||
"claude-opus-4-1"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps sonnet-4-5", () => {
|
||||
expect(firstPartyNameToCanonical("claude-sonnet-4-5-20240101")).toBe(
|
||||
"claude-sonnet-4-5"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps sonnet-4", () => {
|
||||
expect(firstPartyNameToCanonical("claude-sonnet-4-20240101")).toBe(
|
||||
"claude-sonnet-4"
|
||||
);
|
||||
});
|
||||
});
|
||||
84
src/utils/model/__tests__/providers.test.ts
Normal file
84
src/utils/model/__tests__/providers.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||
import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from "../providers";
|
||||
|
||||
describe("getAPIProvider", () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK;
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX;
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY;
|
||||
});
|
||||
|
||||
test('returns "firstParty" by default', () => {
|
||||
delete process.env.CLAUDE_CODE_USE_BEDROCK;
|
||||
delete process.env.CLAUDE_CODE_USE_VERTEX;
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY;
|
||||
expect(getAPIProvider()).toBe("firstParty");
|
||||
});
|
||||
|
||||
test('returns "bedrock" when CLAUDE_CODE_USE_BEDROCK is set', () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
expect(getAPIProvider()).toBe("bedrock");
|
||||
});
|
||||
|
||||
test('returns "vertex" when CLAUDE_CODE_USE_VERTEX is set', () => {
|
||||
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
||||
expect(getAPIProvider()).toBe("vertex");
|
||||
});
|
||||
|
||||
test('returns "foundry" when CLAUDE_CODE_USE_FOUNDRY is set', () => {
|
||||
process.env.CLAUDE_CODE_USE_FOUNDRY = "1";
|
||||
expect(getAPIProvider()).toBe("foundry");
|
||||
});
|
||||
|
||||
test("bedrock takes precedence over vertex", () => {
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
process.env.CLAUDE_CODE_USE_VERTEX = "1";
|
||||
expect(getAPIProvider()).toBe("bedrock");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFirstPartyAnthropicBaseUrl", () => {
|
||||
const originalBaseUrl = process.env.ANTHROPIC_BASE_URL;
|
||||
const originalUserType = process.env.USER_TYPE;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalBaseUrl !== undefined) {
|
||||
process.env.ANTHROPIC_BASE_URL = originalBaseUrl;
|
||||
} else {
|
||||
delete process.env.ANTHROPIC_BASE_URL;
|
||||
}
|
||||
if (originalUserType !== undefined) {
|
||||
process.env.USER_TYPE = originalUserType;
|
||||
} else {
|
||||
delete process.env.USER_TYPE;
|
||||
}
|
||||
});
|
||||
|
||||
test("returns true when ANTHROPIC_BASE_URL is not set", () => {
|
||||
delete process.env.ANTHROPIC_BASE_URL;
|
||||
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
|
||||
});
|
||||
|
||||
test("returns true for api.anthropic.com", () => {
|
||||
process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
||||
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for custom URL", () => {
|
||||
process.env.ANTHROPIC_BASE_URL = "https://my-proxy.com";
|
||||
expect(isFirstPartyAnthropicBaseUrl()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false for invalid URL", () => {
|
||||
process.env.ANTHROPIC_BASE_URL = "not-a-url";
|
||||
expect(isFirstPartyAnthropicBaseUrl()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for staging URL when USER_TYPE is ant", () => {
|
||||
process.env.ANTHROPIC_BASE_URL = "https://api-staging.anthropic.com";
|
||||
process.env.USER_TYPE = "ant";
|
||||
expect(isFirstPartyAnthropicBaseUrl()).toBe(true);
|
||||
});
|
||||
});
|
||||
152
src/utils/permissions/__tests__/permissionRuleParser.test.ts
Normal file
152
src/utils/permissions/__tests__/permissionRuleParser.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
escapeRuleContent,
|
||||
unescapeRuleContent,
|
||||
permissionRuleValueFromString,
|
||||
permissionRuleValueToString,
|
||||
normalizeLegacyToolName,
|
||||
} from "../permissionRuleParser";
|
||||
|
||||
describe("escapeRuleContent", () => {
|
||||
test("escapes backslashes first", () => {
|
||||
expect(escapeRuleContent("a\\b")).toBe("a\\\\b");
|
||||
});
|
||||
|
||||
test("escapes opening parentheses", () => {
|
||||
expect(escapeRuleContent("fn(x)")).toBe("fn\\(x\\)");
|
||||
});
|
||||
|
||||
test("escapes backslash before parens correctly", () => {
|
||||
expect(escapeRuleContent('echo "test\\nvalue"')).toBe(
|
||||
'echo "test\\\\nvalue"'
|
||||
);
|
||||
});
|
||||
|
||||
test("returns unchanged string with no special chars", () => {
|
||||
expect(escapeRuleContent("npm install")).toBe("npm install");
|
||||
});
|
||||
|
||||
test("handles empty string", () => {
|
||||
expect(escapeRuleContent("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unescapeRuleContent", () => {
|
||||
test("unescapes parentheses", () => {
|
||||
expect(unescapeRuleContent("fn\\(x\\)")).toBe("fn(x)");
|
||||
});
|
||||
|
||||
test("unescapes backslashes", () => {
|
||||
expect(unescapeRuleContent("a\\\\b")).toBe("a\\b");
|
||||
});
|
||||
|
||||
test("roundtrips with escapeRuleContent", () => {
|
||||
const original = 'python -c "print(1)"';
|
||||
expect(unescapeRuleContent(escapeRuleContent(original))).toBe(original);
|
||||
});
|
||||
|
||||
test("handles content with backslash-paren combo", () => {
|
||||
const original = 'echo "test\\nvalue"';
|
||||
expect(unescapeRuleContent(escapeRuleContent(original))).toBe(original);
|
||||
});
|
||||
|
||||
test("returns unchanged string with no escapes", () => {
|
||||
expect(unescapeRuleContent("npm install")).toBe("npm install");
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissionRuleValueFromString", () => {
|
||||
test("parses tool name only", () => {
|
||||
expect(permissionRuleValueFromString("Bash")).toEqual({
|
||||
toolName: "Bash",
|
||||
});
|
||||
});
|
||||
|
||||
test("parses tool name with content", () => {
|
||||
expect(permissionRuleValueFromString("Bash(npm install)")).toEqual({
|
||||
toolName: "Bash",
|
||||
ruleContent: "npm install",
|
||||
});
|
||||
});
|
||||
|
||||
test("handles escaped parens in content", () => {
|
||||
const result = permissionRuleValueFromString(
|
||||
'Bash(python -c "print\\(1\\)")'
|
||||
);
|
||||
expect(result.toolName).toBe("Bash");
|
||||
expect(result.ruleContent).toBe('python -c "print(1)"');
|
||||
});
|
||||
|
||||
test("treats empty content as tool-wide rule", () => {
|
||||
expect(permissionRuleValueFromString("Bash()")).toEqual({
|
||||
toolName: "Bash",
|
||||
});
|
||||
});
|
||||
|
||||
test("treats wildcard content as tool-wide rule", () => {
|
||||
expect(permissionRuleValueFromString("Bash(*)")).toEqual({
|
||||
toolName: "Bash",
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes legacy tool names", () => {
|
||||
const result = permissionRuleValueFromString("Task");
|
||||
expect(result.toolName).toBe("Agent");
|
||||
});
|
||||
|
||||
test("handles MCP-style tool names", () => {
|
||||
expect(permissionRuleValueFromString("mcp__server__tool")).toEqual({
|
||||
toolName: "mcp__server__tool",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissionRuleValueToString", () => {
|
||||
test("formats tool name only", () => {
|
||||
expect(permissionRuleValueToString({ toolName: "Bash" })).toBe("Bash");
|
||||
});
|
||||
|
||||
test("formats tool name with content", () => {
|
||||
expect(
|
||||
permissionRuleValueToString({
|
||||
toolName: "Bash",
|
||||
ruleContent: "npm install",
|
||||
})
|
||||
).toBe("Bash(npm install)");
|
||||
});
|
||||
|
||||
test("escapes parens in content", () => {
|
||||
expect(
|
||||
permissionRuleValueToString({
|
||||
toolName: "Bash",
|
||||
ruleContent: 'python -c "print(1)"',
|
||||
})
|
||||
).toBe('Bash(python -c "print\\(1\\)")');
|
||||
});
|
||||
|
||||
test("roundtrips with permissionRuleValueFromString", () => {
|
||||
const original = { toolName: "Bash", ruleContent: "npm install" };
|
||||
const str = permissionRuleValueToString(original);
|
||||
const parsed = permissionRuleValueFromString(str);
|
||||
expect(parsed).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeLegacyToolName", () => {
|
||||
test("maps Task to Agent", () => {
|
||||
expect(normalizeLegacyToolName("Task")).toBe("Agent");
|
||||
});
|
||||
|
||||
test("maps KillShell to TaskStop", () => {
|
||||
expect(normalizeLegacyToolName("KillShell")).toBe("TaskStop");
|
||||
});
|
||||
|
||||
test("returns unknown name as-is", () => {
|
||||
expect(normalizeLegacyToolName("UnknownTool")).toBe("UnknownTool");
|
||||
});
|
||||
|
||||
test("preserves current canonical names", () => {
|
||||
expect(normalizeLegacyToolName("Bash")).toBe("Bash");
|
||||
expect(normalizeLegacyToolName("Agent")).toBe("Agent");
|
||||
});
|
||||
});
|
||||
165
src/utils/permissions/__tests__/permissions.test.ts
Normal file
165
src/utils/permissions/__tests__/permissions.test.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
// Mock log.ts to cut the heavy dependency chain
|
||||
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 slowOperations to avoid bun:bundle
|
||||
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 {
|
||||
getDenyRuleForTool,
|
||||
getAskRuleForTool,
|
||||
getDenyRuleForAgent,
|
||||
filterDeniedAgents,
|
||||
} = await import("../permissions");
|
||||
|
||||
import { getEmptyToolPermissionContext } from "../../../Tool";
|
||||
|
||||
// ─── Helper ─────────────────────────────────────────────────────────────
|
||||
|
||||
function makeContext(opts: {
|
||||
denyRules?: string[];
|
||||
askRules?: string[];
|
||||
}) {
|
||||
const ctx = getEmptyToolPermissionContext();
|
||||
const deny: Record<string, string[]> = {};
|
||||
const ask: Record<string, string[]> = {};
|
||||
|
||||
// alwaysDenyRules stores raw rule strings — getDenyRules() calls
|
||||
// permissionRuleValueFromString internally
|
||||
if (opts.denyRules?.length) {
|
||||
deny["localSettings"] = opts.denyRules;
|
||||
}
|
||||
if (opts.askRules?.length) {
|
||||
ask["localSettings"] = opts.askRules;
|
||||
}
|
||||
|
||||
return {
|
||||
...ctx,
|
||||
alwaysDenyRules: deny,
|
||||
alwaysAskRules: ask,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeTool(name: string, mcpInfo?: { serverName: string; toolName: string }) {
|
||||
return { name, mcpInfo };
|
||||
}
|
||||
|
||||
// ─── getDenyRuleForTool ─────────────────────────────────────────────────
|
||||
|
||||
describe("getDenyRuleForTool", () => {
|
||||
test("returns null when no deny rules", () => {
|
||||
const ctx = makeContext({});
|
||||
expect(getDenyRuleForTool(ctx, makeTool("Bash"))).toBeNull();
|
||||
});
|
||||
|
||||
test("returns matching deny rule for tool", () => {
|
||||
const ctx = makeContext({ denyRules: ["Bash"] });
|
||||
const result = getDenyRuleForTool(ctx, makeTool("Bash"));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.ruleValue.toolName).toBe("Bash");
|
||||
});
|
||||
|
||||
test("returns null for non-matching tool", () => {
|
||||
const ctx = makeContext({ denyRules: ["Bash"] });
|
||||
expect(getDenyRuleForTool(ctx, makeTool("Read"))).toBeNull();
|
||||
});
|
||||
|
||||
test("rule with content does not match whole-tool deny", () => {
|
||||
// getDenyRuleForTool uses toolMatchesRule which requires ruleContent === undefined
|
||||
// Rules like "Bash(rm -rf)" only match specific invocations, not the entire tool
|
||||
const ctx = makeContext({ denyRules: ["Bash(rm -rf)"] });
|
||||
const result = getDenyRuleForTool(ctx, makeTool("Bash"));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getAskRuleForTool ──────────────────────────────────────────────────
|
||||
|
||||
describe("getAskRuleForTool", () => {
|
||||
test("returns null when no ask rules", () => {
|
||||
const ctx = makeContext({});
|
||||
expect(getAskRuleForTool(ctx, makeTool("Bash"))).toBeNull();
|
||||
});
|
||||
|
||||
test("returns matching ask rule", () => {
|
||||
const ctx = makeContext({ askRules: ["Write"] });
|
||||
const result = getAskRuleForTool(ctx, makeTool("Write"));
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for non-matching tool", () => {
|
||||
const ctx = makeContext({ askRules: ["Write"] });
|
||||
expect(getAskRuleForTool(ctx, makeTool("Bash"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getDenyRuleForAgent ────────────────────────────────────────────────
|
||||
|
||||
describe("getDenyRuleForAgent", () => {
|
||||
test("returns null when no deny rules", () => {
|
||||
const ctx = makeContext({});
|
||||
expect(getDenyRuleForAgent(ctx, "Agent", "Explore")).toBeNull();
|
||||
});
|
||||
|
||||
test("returns matching deny rule for agent type", () => {
|
||||
const ctx = makeContext({ denyRules: ["Agent(Explore)"] });
|
||||
const result = getDenyRuleForAgent(ctx, "Agent", "Explore");
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
test("returns null for non-matching agent type", () => {
|
||||
const ctx = makeContext({ denyRules: ["Agent(Explore)"] });
|
||||
expect(getDenyRuleForAgent(ctx, "Agent", "Research")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── filterDeniedAgents ─────────────────────────────────────────────────
|
||||
|
||||
describe("filterDeniedAgents", () => {
|
||||
test("returns all agents when no deny rules", () => {
|
||||
const ctx = makeContext({});
|
||||
const agents = [{ agentType: "Explore" }, { agentType: "Research" }];
|
||||
expect(filterDeniedAgents(agents, ctx, "Agent")).toEqual(agents);
|
||||
});
|
||||
|
||||
test("filters out denied agent type", () => {
|
||||
const ctx = makeContext({ denyRules: ["Agent(Explore)"] });
|
||||
const agents = [{ agentType: "Explore" }, { agentType: "Research" }];
|
||||
const result = filterDeniedAgents(agents, ctx, "Agent");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]!.agentType).toBe("Research");
|
||||
});
|
||||
|
||||
test("returns empty array when all agents denied", () => {
|
||||
const ctx = makeContext({
|
||||
denyRules: ["Agent(Explore)", "Agent(Research)"],
|
||||
});
|
||||
const agents = [{ agentType: "Explore" }, { agentType: "Research" }];
|
||||
expect(filterDeniedAgents(agents, ctx, "Agent")).toEqual([]);
|
||||
});
|
||||
});
|
||||
476
src/utils/settings/__tests__/config.test.ts
Normal file
476
src/utils/settings/__tests__/config.test.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
SettingsSchema,
|
||||
EnvironmentVariablesSchema,
|
||||
PermissionsSchema,
|
||||
AllowedMcpServerEntrySchema,
|
||||
DeniedMcpServerEntrySchema,
|
||||
isMcpServerNameEntry,
|
||||
isMcpServerCommandEntry,
|
||||
isMcpServerUrlEntry,
|
||||
CUSTOMIZATION_SURFACES,
|
||||
} from "../types";
|
||||
import {
|
||||
SETTING_SOURCES,
|
||||
SOURCES,
|
||||
CLAUDE_CODE_SETTINGS_SCHEMA_URL,
|
||||
getSettingSourceName,
|
||||
getSourceDisplayName,
|
||||
getSettingSourceDisplayNameLowercase,
|
||||
getSettingSourceDisplayNameCapitalized,
|
||||
parseSettingSourcesFlag,
|
||||
} from "../constants";
|
||||
import {
|
||||
formatZodError,
|
||||
filterInvalidPermissionRules,
|
||||
validateSettingsFileContent,
|
||||
} from "../validation";
|
||||
|
||||
// ─── Settings Schema Validation ──────────────────────────────────────────
|
||||
|
||||
describe("SettingsSchema", () => {
|
||||
test("accepts empty object", () => {
|
||||
const result = SettingsSchema().safeParse({});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts model string", () => {
|
||||
const result = SettingsSchema().safeParse({ model: "sonnet" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts permissions block with allow rules", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
permissions: { allow: ["Bash(npm install)"] },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts permissions block with deny rules", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
permissions: { deny: ["Bash(rm -rf *)"] },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts env variables", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
env: { FOO: "bar", DEBUG: "1" },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts hooks configuration", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{
|
||||
matcher: "Bash",
|
||||
hooks: [{ type: "command", command: "echo test" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts attribution settings", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
attribution: {
|
||||
commit: "Generated by AI",
|
||||
pr: "AI-generated PR",
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts worktree settings", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
worktree: {
|
||||
symlinkDirectories: ["node_modules", ".cache"],
|
||||
sparsePaths: ["src/"],
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts $schema field", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
$schema: CLAUDE_CODE_SETTINGS_SCHEMA_URL,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("passes through unknown keys (passthrough mode)", () => {
|
||||
const result = SettingsSchema().safeParse({ unknownKey: "value" });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect((result.data as any).unknownKey).toBe("value");
|
||||
}
|
||||
});
|
||||
|
||||
test("coerces env var numbers to strings", () => {
|
||||
const result = EnvironmentVariablesSchema().safeParse({ PORT: 3000 });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.PORT).toBe("3000");
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts boolean settings", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
includeCoAuthoredBy: true,
|
||||
respectGitignore: false,
|
||||
disableAllHooks: true,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts cleanupPeriodDays", () => {
|
||||
const result = SettingsSchema().safeParse({ cleanupPeriodDays: 30 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects negative cleanupPeriodDays", () => {
|
||||
const result = SettingsSchema().safeParse({ cleanupPeriodDays: -1 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts statusLine configuration", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
statusLine: { type: "command", command: "echo status" },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts sshConfigs", () => {
|
||||
const result = SettingsSchema().safeParse({
|
||||
sshConfigs: [
|
||||
{
|
||||
id: "dev-server",
|
||||
name: "Development Server",
|
||||
sshHost: "dev.example.com",
|
||||
sshPort: 22,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Permissions Schema ─────────────────────────────────────────────────
|
||||
|
||||
describe("PermissionsSchema", () => {
|
||||
test("accepts defaultMode", () => {
|
||||
const result = PermissionsSchema().safeParse({
|
||||
defaultMode: "acceptEdits",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts additionalDirectories", () => {
|
||||
const result = PermissionsSchema().safeParse({
|
||||
additionalDirectories: ["/tmp/extra"],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts disableBypassPermissionsMode", () => {
|
||||
const result = PermissionsSchema().safeParse({
|
||||
disableBypassPermissionsMode: "disable",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── AllowedMcpServerEntrySchema ────────────────────────────────────────
|
||||
|
||||
describe("AllowedMcpServerEntrySchema", () => {
|
||||
test("accepts serverName entry", () => {
|
||||
const result = AllowedMcpServerEntrySchema().safeParse({
|
||||
serverName: "my-server",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts serverCommand entry", () => {
|
||||
const result = AllowedMcpServerEntrySchema().safeParse({
|
||||
serverCommand: ["npx", "mcp-server"],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts serverUrl entry", () => {
|
||||
const result = AllowedMcpServerEntrySchema().safeParse({
|
||||
serverUrl: "https://*.example.com/*",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects entry with no fields", () => {
|
||||
const result = AllowedMcpServerEntrySchema().safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects entry with multiple fields", () => {
|
||||
const result = AllowedMcpServerEntrySchema().safeParse({
|
||||
serverName: "my-server",
|
||||
serverUrl: "https://example.com",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects invalid serverName characters", () => {
|
||||
const result = AllowedMcpServerEntrySchema().safeParse({
|
||||
serverName: "my server with spaces",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects empty serverCommand array", () => {
|
||||
const result = AllowedMcpServerEntrySchema().safeParse({
|
||||
serverCommand: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Type guards ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("MCP server entry type guards", () => {
|
||||
test("isMcpServerNameEntry identifies name entry", () => {
|
||||
expect(isMcpServerNameEntry({ serverName: "test" })).toBe(true);
|
||||
});
|
||||
|
||||
test("isMcpServerNameEntry rejects non-name entry", () => {
|
||||
expect(isMcpServerNameEntry({ serverUrl: "https://example.com" })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("isMcpServerCommandEntry identifies command entry", () => {
|
||||
expect(isMcpServerCommandEntry({ serverCommand: ["npx", "srv"] })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("isMcpServerCommandEntry rejects non-command entry", () => {
|
||||
expect(isMcpServerCommandEntry({ serverName: "test" })).toBe(false);
|
||||
});
|
||||
|
||||
test("isMcpServerUrlEntry identifies url entry", () => {
|
||||
expect(
|
||||
isMcpServerUrlEntry({ serverUrl: "https://example.com" })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("isMcpServerUrlEntry rejects non-url entry", () => {
|
||||
expect(isMcpServerUrlEntry({ serverName: "test" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Constants ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("SETTING_SOURCES", () => {
|
||||
test("contains all five sources in order", () => {
|
||||
expect(SETTING_SOURCES).toEqual([
|
||||
"userSettings",
|
||||
"projectSettings",
|
||||
"localSettings",
|
||||
"flagSettings",
|
||||
"policySettings",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SOURCES (editable)", () => {
|
||||
test("contains three editable sources", () => {
|
||||
expect(SOURCES).toEqual([
|
||||
"localSettings",
|
||||
"projectSettings",
|
||||
"userSettings",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CUSTOMIZATION_SURFACES", () => {
|
||||
test("contains expected surfaces", () => {
|
||||
expect(CUSTOMIZATION_SURFACES).toEqual([
|
||||
"skills",
|
||||
"agents",
|
||||
"hooks",
|
||||
"mcp",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSettingSourceName", () => {
|
||||
test("maps userSettings to user", () => {
|
||||
expect(getSettingSourceName("userSettings")).toBe("user");
|
||||
});
|
||||
|
||||
test("maps projectSettings to project", () => {
|
||||
expect(getSettingSourceName("projectSettings")).toBe("project");
|
||||
});
|
||||
|
||||
test("maps localSettings to project, gitignored", () => {
|
||||
expect(getSettingSourceName("localSettings")).toBe("project, gitignored");
|
||||
});
|
||||
|
||||
test("maps flagSettings to cli flag", () => {
|
||||
expect(getSettingSourceName("flagSettings")).toBe("cli flag");
|
||||
});
|
||||
|
||||
test("maps policySettings to managed", () => {
|
||||
expect(getSettingSourceName("policySettings")).toBe("managed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSourceDisplayName", () => {
|
||||
test("maps userSettings to User", () => {
|
||||
expect(getSourceDisplayName("userSettings")).toBe("User");
|
||||
});
|
||||
|
||||
test("maps plugin to Plugin", () => {
|
||||
expect(getSourceDisplayName("plugin")).toBe("Plugin");
|
||||
});
|
||||
|
||||
test("maps built-in to Built-in", () => {
|
||||
expect(getSourceDisplayName("built-in")).toBe("Built-in");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSettingSourceDisplayNameLowercase", () => {
|
||||
test("maps policySettings correctly", () => {
|
||||
expect(getSettingSourceDisplayNameLowercase("policySettings")).toBe(
|
||||
"enterprise managed settings"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps cliArg correctly", () => {
|
||||
expect(getSettingSourceDisplayNameLowercase("cliArg")).toBe("CLI argument");
|
||||
});
|
||||
|
||||
test("maps session correctly", () => {
|
||||
expect(getSettingSourceDisplayNameLowercase("session")).toBe(
|
||||
"current session"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSettingSourceDisplayNameCapitalized", () => {
|
||||
test("maps userSettings correctly", () => {
|
||||
expect(getSettingSourceDisplayNameCapitalized("userSettings")).toBe(
|
||||
"User settings"
|
||||
);
|
||||
});
|
||||
|
||||
test("maps command correctly", () => {
|
||||
expect(getSettingSourceDisplayNameCapitalized("command")).toBe(
|
||||
"Command configuration"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSettingSourcesFlag", () => {
|
||||
test("parses comma-separated sources", () => {
|
||||
expect(parseSettingSourcesFlag("user,project,local")).toEqual([
|
||||
"userSettings",
|
||||
"projectSettings",
|
||||
"localSettings",
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses single source", () => {
|
||||
expect(parseSettingSourcesFlag("user")).toEqual(["userSettings"]);
|
||||
});
|
||||
|
||||
test("returns empty array for empty string", () => {
|
||||
expect(parseSettingSourcesFlag("")).toEqual([]);
|
||||
});
|
||||
|
||||
test("trims whitespace", () => {
|
||||
expect(parseSettingSourcesFlag("user , project")).toEqual([
|
||||
"userSettings",
|
||||
"projectSettings",
|
||||
]);
|
||||
});
|
||||
|
||||
test("throws for invalid source name", () => {
|
||||
expect(() => parseSettingSourcesFlag("invalid")).toThrow(
|
||||
"Invalid setting source"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Validation ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("filterInvalidPermissionRules", () => {
|
||||
test("returns empty for non-object input", () => {
|
||||
expect(filterInvalidPermissionRules(null, "test.json")).toEqual([]);
|
||||
expect(filterInvalidPermissionRules("string", "test.json")).toEqual([]);
|
||||
});
|
||||
|
||||
test("returns empty when no permissions", () => {
|
||||
expect(filterInvalidPermissionRules({}, "test.json")).toEqual([]);
|
||||
});
|
||||
|
||||
test("filters non-string rules and returns warnings", () => {
|
||||
const data = { permissions: { allow: ["Bash", 123, "Read"] } };
|
||||
const warnings = filterInvalidPermissionRules(data, "test.json");
|
||||
expect(warnings.length).toBe(1);
|
||||
expect(warnings[0]!.path).toBe("permissions.allow");
|
||||
expect((data.permissions as any).allow).toEqual(["Bash", "Read"]);
|
||||
});
|
||||
|
||||
test("preserves valid rules", () => {
|
||||
const data = {
|
||||
permissions: { allow: ["Bash(npm install)", "Read", "Write"] },
|
||||
};
|
||||
const warnings = filterInvalidPermissionRules(data, "test.json");
|
||||
expect(warnings).toEqual([]);
|
||||
expect((data.permissions as any).allow).toEqual([
|
||||
"Bash(npm install)",
|
||||
"Read",
|
||||
"Write",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateSettingsFileContent", () => {
|
||||
test("accepts valid JSON settings", () => {
|
||||
const result = validateSettingsFileContent('{"model": "sonnet"}');
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
test("accepts empty object", () => {
|
||||
const result = validateSettingsFileContent("{}");
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects invalid JSON", () => {
|
||||
const result = validateSettingsFileContent("not json");
|
||||
expect(result.isValid).toBe(false);
|
||||
if (!result.isValid) {
|
||||
expect(result.error).toContain("Invalid JSON");
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unknown keys in strict mode", () => {
|
||||
const result = validateSettingsFileContent('{"unknownField": true}');
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatZodError", () => {
|
||||
test("formats invalid type error", () => {
|
||||
const result = SettingsSchema().safeParse({ model: 123 });
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const errors = formatZodError(result.error, "settings.json");
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
expect(errors[0]!.file).toBe("settings.json");
|
||||
expect(errors[0]!.path).toContain("model");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user