Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 42 additions & 54 deletions src/services/tools/file_edit_replace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,27 @@ const mockToolCallOptions: ToolCallOptions = {
messages: [],
};

// Test helpers
const setupFile = async (filePath: string, content: string): Promise<string> => {
await fs.writeFile(filePath, content);
const stats = await fs.stat(filePath);
return leaseFromStat(stats);
};

const readFile = async (filePath: string): Promise<string> => {
return await fs.readFile(filePath, "utf-8");
};

const executeReplace = async (
tool: ReturnType<typeof createFileEditReplaceTool>,
filePath: string,
edits: FileEditReplaceToolArgs["edits"],
lease: string
): Promise<FileEditReplaceToolResult> => {
const args: FileEditReplaceToolArgs = { file_path: filePath, edits, lease };
return (await tool.execute!(args, mockToolCallOptions)) as FileEditReplaceToolResult;
};

describe("file_edit_replace tool", () => {
let testDir: string;
let testFilePath: string;
Expand All @@ -29,81 +50,48 @@ describe("file_edit_replace tool", () => {
});

it("should apply a single edit successfully", async () => {
// Setup
const initialContent = "Hello world\nThis is a test\nGoodbye world";
await fs.writeFile(testFilePath, initialContent);

// Get lease by reading file stats
const stats = await fs.stat(testFilePath);
const lease = leaseFromStat(stats);

const lease = await setupFile(testFilePath, "Hello world\nThis is a test\nGoodbye world");
const tool = createFileEditReplaceTool({ cwd: testDir });
const args: FileEditReplaceToolArgs = {
file_path: testFilePath,
edits: [
{
old_string: "Hello world",
new_string: "Hello universe",
},
],
lease,
};

// Execute
const result = (await tool.execute!(args, mockToolCallOptions)) as FileEditReplaceToolResult;
const result = await executeReplace(
tool,
testFilePath,
[{ old_string: "Hello world", new_string: "Hello universe" }],
lease
);

// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.edits_applied).toBe(1);
expect(result.lease).toMatch(/^[0-9a-f]{6}$/);
expect(result.lease).not.toBe(lease); // New lease should be different
expect(result.lease).not.toBe(lease);
}

const updatedContent = await fs.readFile(testFilePath, "utf-8");
expect(updatedContent).toBe("Hello universe\nThis is a test\nGoodbye world");
expect(await readFile(testFilePath)).toBe("Hello universe\nThis is a test\nGoodbye world");
});

it("should apply multiple edits sequentially", async () => {
// Setup
const initialContent = "foo bar baz";
await fs.writeFile(testFilePath, initialContent);

const stats = await fs.stat(testFilePath);
const lease = leaseFromStat(stats);

const lease = await setupFile(testFilePath, "foo bar baz");
const tool = createFileEditReplaceTool({ cwd: testDir });
const args: FileEditReplaceToolArgs = {
file_path: testFilePath,
edits: [
{
old_string: "foo",
new_string: "FOO",
},
{
old_string: "bar",
new_string: "BAR",
},
{
old_string: "baz",
new_string: "BAZ",
},
],
lease,
};

// Execute
const result = (await tool.execute!(args, mockToolCallOptions)) as FileEditReplaceToolResult;
const result = await executeReplace(
tool,
testFilePath,
[
{ old_string: "foo", new_string: "FOO" },
{ old_string: "bar", new_string: "BAR" },
{ old_string: "baz", new_string: "BAZ" },
],
lease
);

// Assert
expect(result.success).toBe(true);
if (result.success) {
expect(result.edits_applied).toBe(3);
expect(result.lease).toMatch(/^[0-9a-f]{6}$/);
}

const updatedContent = await fs.readFile(testFilePath, "utf-8");
expect(updatedContent).toBe("FOO BAR BAZ");
expect(await readFile(testFilePath)).toBe("FOO BAR BAZ");
});

it("should rollback if later edit fails (first edit breaks second edit search)", async () => {
Expand Down
160 changes: 69 additions & 91 deletions src/utils/slashCommands/parser.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import { describe, it, expect } from "bun:test";
import { parseCommand, setNestedProperty } from "./parser";

// Test helpers
const expectParse = (input: string, expected: ReturnType<typeof parseCommand>) => {
expect(parseCommand(input)).toEqual(expected);
};

const expectProvidersSet = (input: string, provider: string, keyPath: string[], value: string) => {
expectParse(input, { type: "providers-set", provider, keyPath, value });
};

const expectModelSet = (input: string, modelString: string) => {
expectParse(input, { type: "model-set", modelString });
};

describe("commandParser", () => {
describe("parseCommand", () => {
it("should return null for non-command input", () => {
Expand All @@ -10,157 +23,122 @@ describe("commandParser", () => {
});

it("should parse /clear command", () => {
const result = parseCommand("/clear");
expect(result).toEqual({
type: "clear",
});
expectParse("/clear", { type: "clear" });
});

it("should parse /providers help when no subcommand", () => {
const result = parseCommand("/providers");
expect(result).toEqual({
type: "providers-help",
});
expectParse("/providers", { type: "providers-help" });
});

it("should parse /providers with invalid subcommand", () => {
const result = parseCommand("/providers invalid");
expect(result).toEqual({
expectParse("/providers invalid", {
type: "providers-invalid-subcommand",
subcommand: "invalid",
});
});

it("should parse /providers set with missing args", () => {
expect(parseCommand("/providers set")).toEqual({
type: "providers-missing-args",
subcommand: "set",
argCount: 0,
});
const missingArgsCases = [
{ input: "/providers set", argCount: 0 },
{ input: "/providers set anthropic", argCount: 1 },
{ input: "/providers set anthropic apiKey", argCount: 2 },
];

expect(parseCommand("/providers set anthropic")).toEqual({
type: "providers-missing-args",
subcommand: "set",
argCount: 1,
});

expect(parseCommand("/providers set anthropic apiKey")).toEqual({
type: "providers-missing-args",
subcommand: "set",
argCount: 2,
missingArgsCases.forEach(({ input, argCount }) => {
expectParse(input, {
type: "providers-missing-args",
subcommand: "set",
argCount,
});
});
});

it("should parse /providers set with all arguments", () => {
const result = parseCommand("/providers set anthropic apiKey sk-123");
expect(result).toEqual({
type: "providers-set",
provider: "anthropic",
keyPath: ["apiKey"],
value: "sk-123",
});
expectProvidersSet(
"/providers set anthropic apiKey sk-123",
"anthropic",
["apiKey"],
"sk-123"
);
});

it("should handle quoted arguments", () => {
const result = parseCommand('/providers set anthropic apiKey "my key with spaces"');
expect(result).toEqual({
type: "providers-set",
provider: "anthropic",
keyPath: ["apiKey"],
value: "my key with spaces",
});
expectProvidersSet(
'/providers set anthropic apiKey "my key with spaces"',
"anthropic",
["apiKey"],
"my key with spaces"
);
});

it("should handle multiple spaces in value", () => {
const result = parseCommand("/providers set anthropic apiKey My Anthropic API");
expect(result).toEqual({
type: "providers-set",
provider: "anthropic",
keyPath: ["apiKey"],
value: "My Anthropic API",
});
expectProvidersSet(
"/providers set anthropic apiKey My Anthropic API",
"anthropic",
["apiKey"],
"My Anthropic API"
);
});

it("should handle nested key paths", () => {
const result = parseCommand("/providers set anthropic baseUrl.scheme https");
expect(result).toEqual({
type: "providers-set",
provider: "anthropic",
keyPath: ["baseUrl", "scheme"],
value: "https",
});
expectProvidersSet(
"/providers set anthropic baseUrl.scheme https",
"anthropic",
["baseUrl", "scheme"],
"https"
);
});

it("should parse unknown commands", () => {
expect(parseCommand("/foo")).toEqual({
expectParse("/foo", {
type: "unknown-command",
command: "foo",
subcommand: undefined,
});

expect(parseCommand("/foo bar")).toEqual({
expectParse("/foo bar", {
type: "unknown-command",
command: "foo",
subcommand: "bar",
});
});

it("should handle multiple spaces between arguments", () => {
const result = parseCommand("/providers set anthropic apiKey sk-12345");
expect(result).toEqual({
type: "providers-set",
provider: "anthropic",
keyPath: ["apiKey"],
value: "sk-12345",
});
expectProvidersSet(
"/providers set anthropic apiKey sk-12345",
"anthropic",
["apiKey"],
"sk-12345"
);
});

it("should handle quoted URL values", () => {
const result = parseCommand(
'/providers set anthropic baseUrl "https://api.anthropic.com/v1"'
expectProvidersSet(
'/providers set anthropic baseUrl "https://api.anthropic.com/v1"',
"anthropic",
["baseUrl"],
"https://api.anthropic.com/v1"
);
expect(result).toEqual({
type: "providers-set",
provider: "anthropic",
keyPath: ["baseUrl"],
value: "https://api.anthropic.com/v1",
});
});

it("should parse /model with abbreviation", () => {
const result = parseCommand("/model opus");
expect(result).toEqual({
type: "model-set",
modelString: "anthropic:claude-opus-4-1",
});
expectModelSet("/model opus", "anthropic:claude-opus-4-1");
});

it("should parse /model with full provider:model format", () => {
const result = parseCommand("/model anthropic:claude-sonnet-4-5");
expect(result).toEqual({
type: "model-set",
modelString: "anthropic:claude-sonnet-4-5",
});
expectModelSet("/model anthropic:claude-sonnet-4-5", "anthropic:claude-sonnet-4-5");
});

it("should parse /model help when no args", () => {
const result = parseCommand("/model");
expect(result).toEqual({
type: "model-help",
});
expectParse("/model", { type: "model-help" });
});

it("should handle unknown abbreviation as full model string", () => {
const result = parseCommand("/model custom:model-name");
expect(result).toEqual({
type: "model-set",
modelString: "custom:model-name",
});
expectModelSet("/model custom:model-name", "custom:model-name");
});

it("should reject /model with too many arguments", () => {
const result = parseCommand("/model anthropic claude extra");
expect(result).toEqual({
expectParse("/model anthropic claude extra", {
type: "unknown-command",
command: "model",
subcommand: "claude",
Expand Down
Loading