mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
feat(ipuaro): add edit tools (v0.6.0)
Add file editing capabilities: - EditLinesTool: replace lines with hash conflict detection - CreateFileTool: create files with directory auto-creation - DeleteFileTool: delete files from filesystem and storage Total: 664 tests, 97.77% coverage
This commit is contained in:
@@ -301,6 +301,66 @@ describe("ASTParser", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("import string formats", () => {
|
||||
it("should handle single-quoted imports", () => {
|
||||
const code = `import { foo } from './module'`
|
||||
const ast = parser.parse(code, "ts")
|
||||
|
||||
expect(ast.imports).toHaveLength(1)
|
||||
expect(ast.imports[0].from).toBe("./module")
|
||||
})
|
||||
|
||||
it("should handle double-quoted imports", () => {
|
||||
const code = `import { bar } from "./other"`
|
||||
const ast = parser.parse(code, "ts")
|
||||
|
||||
expect(ast.imports).toHaveLength(1)
|
||||
expect(ast.imports[0].from).toBe("./other")
|
||||
})
|
||||
})
|
||||
|
||||
describe("parameter types", () => {
|
||||
it("should handle simple identifier parameters", () => {
|
||||
const code = `const fn = (x) => x * 2`
|
||||
const ast = parser.parse(code, "ts")
|
||||
|
||||
expect(ast.functions.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("should handle optional parameters with defaults", () => {
|
||||
const code = `function greet(name: string = "World"): string { return name }`
|
||||
const ast = parser.parse(code, "ts")
|
||||
|
||||
expect(ast.functions).toHaveLength(1)
|
||||
const fn = ast.functions[0]
|
||||
expect(fn.params.some((p) => p.hasDefault)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle arrow function with untyped params", () => {
|
||||
const code = `const add = (a, b) => a + b`
|
||||
const ast = parser.parse(code, "ts")
|
||||
|
||||
expect(ast.functions.length).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("should handle multiple parameter types", () => {
|
||||
const code = `
|
||||
function mix(
|
||||
required: string,
|
||||
optional?: number,
|
||||
withDefault: boolean = true
|
||||
) {}
|
||||
`
|
||||
const ast = parser.parse(code, "ts")
|
||||
|
||||
expect(ast.functions).toHaveLength(1)
|
||||
const fn = ast.functions[0]
|
||||
expect(fn.params).toHaveLength(3)
|
||||
expect(fn.params.some((p) => p.optional)).toBe(true)
|
||||
expect(fn.params.some((p) => p.hasDefault)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("complex file", () => {
|
||||
it("should parse complex TypeScript file", () => {
|
||||
const code = `
|
||||
|
||||
@@ -212,6 +212,32 @@ describe("FileScanner", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("empty file handling", () => {
|
||||
it("should consider empty files as text files", async () => {
|
||||
const emptyFile = path.join(FIXTURES_DIR, "empty-file.ts")
|
||||
await fs.writeFile(emptyFile, "")
|
||||
|
||||
try {
|
||||
const isText = await FileScanner.isTextFile(emptyFile)
|
||||
expect(isText).toBe(true)
|
||||
} finally {
|
||||
await fs.unlink(emptyFile)
|
||||
}
|
||||
})
|
||||
|
||||
it("should read empty file content", async () => {
|
||||
const emptyFile = path.join(FIXTURES_DIR, "empty-content.ts")
|
||||
await fs.writeFile(emptyFile, "")
|
||||
|
||||
try {
|
||||
const content = await FileScanner.readFileContent(emptyFile)
|
||||
expect(content).toBe("")
|
||||
} finally {
|
||||
await fs.unlink(emptyFile)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("empty directory handling", () => {
|
||||
let emptyDir: string
|
||||
|
||||
|
||||
@@ -605,4 +605,44 @@ export type ServiceResult<T> = { success: true; data: T } | { success: false; er
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("jsx to tsx resolution", () => {
|
||||
it("should resolve .jsx imports to .tsx files", () => {
|
||||
const mainCode = `import { Button } from "./Button.jsx"`
|
||||
const buttonCode = `export function Button() { return null }`
|
||||
|
||||
const asts = new Map<string, FileAST>([
|
||||
["/project/src/main.ts", parser.parse(mainCode, "ts")],
|
||||
["/project/src/Button.tsx", parser.parse(buttonCode, "tsx")],
|
||||
])
|
||||
|
||||
const graph = builder.buildDepsGraph(asts)
|
||||
|
||||
expect(graph.imports.get("/project/src/main.ts")).toContain("/project/src/Button.tsx")
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty deps graph for circular dependencies", () => {
|
||||
const graph = {
|
||||
imports: new Map<string, string[]>(),
|
||||
importedBy: new Map<string, string[]>(),
|
||||
}
|
||||
|
||||
const cycles = builder.findCircularDependencies(graph)
|
||||
expect(cycles).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle single file with no imports", () => {
|
||||
const code = `export const x = 1`
|
||||
const asts = new Map<string, FileAST>([
|
||||
["/project/src/single.ts", parser.parse(code, "ts")],
|
||||
])
|
||||
|
||||
const graph = builder.buildDepsGraph(asts)
|
||||
const cycles = builder.findCircularDependencies(graph)
|
||||
|
||||
expect(cycles).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -544,6 +544,44 @@ const b = 2`
|
||||
})
|
||||
})
|
||||
|
||||
describe("dependency resolution with different extensions", () => {
|
||||
it("should resolve imports from index files", () => {
|
||||
const content = `import { utils } from "./utils/index"`
|
||||
const ast = parser.parse(content, "ts")
|
||||
const allASTs = new Map<string, FileAST>()
|
||||
allASTs.set("/project/src/main.ts", ast)
|
||||
allASTs.set("/project/src/utils/index.ts", createEmptyFileAST())
|
||||
|
||||
const meta = analyzer.analyze("/project/src/main.ts", ast, content, allASTs)
|
||||
|
||||
expect(meta.dependencies).toContain("/project/src/utils/index.ts")
|
||||
})
|
||||
|
||||
it("should convert .js extension to .ts when resolving", () => {
|
||||
const content = `import { helper } from "./helper.js"`
|
||||
const ast = parser.parse(content, "ts")
|
||||
const allASTs = new Map<string, FileAST>()
|
||||
allASTs.set("/project/src/main.ts", ast)
|
||||
allASTs.set("/project/src/helper.ts", createEmptyFileAST())
|
||||
|
||||
const meta = analyzer.analyze("/project/src/main.ts", ast, content, allASTs)
|
||||
|
||||
expect(meta.dependencies).toContain("/project/src/helper.ts")
|
||||
})
|
||||
|
||||
it("should convert .jsx extension to .tsx when resolving", () => {
|
||||
const content = `import { Button } from "./Button.jsx"`
|
||||
const ast = parser.parse(content, "ts")
|
||||
const allASTs = new Map<string, FileAST>()
|
||||
allASTs.set("/project/src/App.tsx", ast)
|
||||
allASTs.set("/project/src/Button.tsx", createEmptyFileAST())
|
||||
|
||||
const meta = analyzer.analyze("/project/src/App.tsx", ast, content, allASTs)
|
||||
|
||||
expect(meta.dependencies).toContain("/project/src/Button.tsx")
|
||||
})
|
||||
})
|
||||
|
||||
describe("analyze", () => {
|
||||
it("should produce complete FileMeta", () => {
|
||||
const content = `import { helper } from "./helper"
|
||||
|
||||
@@ -94,12 +94,70 @@ describe("Watchdog", () => {
|
||||
it("should return empty array when not watching", () => {
|
||||
expect(watchdog.getWatchedPaths()).toEqual([])
|
||||
})
|
||||
|
||||
it("should return paths when watching", async () => {
|
||||
const testFile = path.join(tempDir, "exists.ts")
|
||||
await fs.writeFile(testFile, "const x = 1")
|
||||
|
||||
watchdog.start(tempDir)
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
|
||||
const paths = watchdog.getWatchedPaths()
|
||||
expect(Array.isArray(paths)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("flushAll", () => {
|
||||
it("should not throw when no pending changes", () => {
|
||||
expect(() => watchdog.flushAll()).not.toThrow()
|
||||
})
|
||||
|
||||
it("should flush all pending changes", async () => {
|
||||
const events: FileChangeEvent[] = []
|
||||
watchdog.onFileChange((event) => events.push(event))
|
||||
watchdog.start(tempDir)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
const testFile = path.join(tempDir, "flush-test.ts")
|
||||
await fs.writeFile(testFile, "const x = 1")
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
watchdog.flushAll()
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
})
|
||||
})
|
||||
|
||||
describe("ignore patterns", () => {
|
||||
it("should handle glob patterns with wildcards", async () => {
|
||||
const customWatchdog = new Watchdog({
|
||||
debounceMs: 50,
|
||||
ignorePatterns: ["*.log", "**/*.tmp"],
|
||||
})
|
||||
|
||||
customWatchdog.start(tempDir)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
expect(customWatchdog.isWatching()).toBe(true)
|
||||
|
||||
await customWatchdog.stop()
|
||||
})
|
||||
|
||||
it("should handle simple directory patterns", async () => {
|
||||
const customWatchdog = new Watchdog({
|
||||
debounceMs: 50,
|
||||
ignorePatterns: ["node_modules", "dist"],
|
||||
})
|
||||
|
||||
customWatchdog.start(tempDir)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
expect(customWatchdog.isWatching()).toBe(true)
|
||||
|
||||
await customWatchdog.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe("file change detection", () => {
|
||||
|
||||
@@ -301,4 +301,188 @@ describe("OllamaClient", () => {
|
||||
expect(() => client.abort()).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("message conversion", () => {
|
||||
it("should convert system messages", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const messages = [
|
||||
{
|
||||
role: "system" as const,
|
||||
content: "You are a helpful assistant",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
await client.chat(messages)
|
||||
|
||||
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "system",
|
||||
content: "You are a helpful assistant",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should convert tool result messages", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const messages = [
|
||||
{
|
||||
role: "tool" as const,
|
||||
content: '{"result": "success"}',
|
||||
timestamp: Date.now(),
|
||||
toolResults: [
|
||||
{ callId: "call_1", success: true, data: "success", executionTimeMs: 10 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
await client.chat(messages)
|
||||
|
||||
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "tool",
|
||||
content: '{"result": "success"}',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should convert assistant messages with tool calls", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const messages = [
|
||||
{
|
||||
role: "assistant" as const,
|
||||
content: "I will read the file",
|
||||
timestamp: Date.now(),
|
||||
toolCalls: [{ id: "call_1", name: "get_lines", params: { path: "test.ts" } }],
|
||||
},
|
||||
]
|
||||
|
||||
await client.chat(messages)
|
||||
|
||||
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
content: "I will read the file",
|
||||
tool_calls: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
function: expect.objectContaining({
|
||||
name: "get_lines",
|
||||
arguments: { path: "test.ts" },
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("response handling", () => {
|
||||
it("should estimate tokens when eval_count is undefined", async () => {
|
||||
mockOllamaInstance.chat.mockResolvedValue({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Hello world response",
|
||||
tool_calls: undefined,
|
||||
},
|
||||
eval_count: undefined,
|
||||
done_reason: "stop",
|
||||
})
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const response = await client.chat([createUserMessage("Hello")])
|
||||
|
||||
expect(response.tokens).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should return length stop reason", async () => {
|
||||
mockOllamaInstance.chat.mockResolvedValue({
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Truncated...",
|
||||
tool_calls: undefined,
|
||||
},
|
||||
eval_count: 100,
|
||||
done_reason: "length",
|
||||
})
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const response = await client.chat([createUserMessage("Hello")])
|
||||
|
||||
expect(response.stopReason).toBe("length")
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool parameter conversion", () => {
|
||||
it("should include enum values when present", async () => {
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
const messages = [createUserMessage("Get status")]
|
||||
const tools = [
|
||||
{
|
||||
name: "get_status",
|
||||
description: "Get status",
|
||||
parameters: [
|
||||
{
|
||||
name: "type",
|
||||
type: "string" as const,
|
||||
description: "Status type",
|
||||
required: true,
|
||||
enum: ["active", "inactive", "pending"],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
await client.chat(messages, tools)
|
||||
|
||||
expect(mockOllamaInstance.chat).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
function: expect.objectContaining({
|
||||
parameters: expect.objectContaining({
|
||||
properties: expect.objectContaining({
|
||||
type: expect.objectContaining({
|
||||
enum: ["active", "inactive", "pending"],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle ECONNREFUSED errors", async () => {
|
||||
mockOllamaInstance.chat.mockRejectedValue(new Error("ECONNREFUSED"))
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
await expect(client.chat([createUserMessage("Hello")])).rejects.toThrow(
|
||||
/Cannot connect to Ollama/,
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle generic errors with context", async () => {
|
||||
mockOllamaInstance.pull.mockRejectedValue(new Error("Unknown error"))
|
||||
|
||||
const client = new OllamaClient(defaultConfig)
|
||||
|
||||
await expect(client.pullModel("test")).rejects.toThrow(/Failed to pull model/)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -249,6 +249,445 @@ describe("prompts", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildFileContext - edge cases", () => {
|
||||
it("should handle empty imports", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
const context = buildFileContext("empty.ts", ast)
|
||||
|
||||
expect(context).toContain("## empty.ts")
|
||||
expect(context).not.toContain("### Imports")
|
||||
})
|
||||
|
||||
it("should handle empty exports", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [{ name: "x", from: "./x", line: 1, type: "internal", isDefault: false }],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
const context = buildFileContext("no-exports.ts", ast)
|
||||
|
||||
expect(context).toContain("### Imports")
|
||||
expect(context).not.toContain("### Exports")
|
||||
})
|
||||
|
||||
it("should handle empty functions", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [
|
||||
{
|
||||
name: "MyClass",
|
||||
lineStart: 1,
|
||||
lineEnd: 10,
|
||||
methods: [],
|
||||
properties: [],
|
||||
implements: [],
|
||||
isExported: false,
|
||||
isAbstract: false,
|
||||
},
|
||||
],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
const context = buildFileContext("no-functions.ts", ast)
|
||||
|
||||
expect(context).not.toContain("### Functions")
|
||||
expect(context).toContain("### Classes")
|
||||
})
|
||||
|
||||
it("should handle empty classes", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [
|
||||
{
|
||||
name: "test",
|
||||
lineStart: 1,
|
||||
lineEnd: 5,
|
||||
params: [],
|
||||
isAsync: false,
|
||||
isExported: false,
|
||||
},
|
||||
],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
const context = buildFileContext("no-classes.ts", ast)
|
||||
|
||||
expect(context).toContain("### Functions")
|
||||
expect(context).not.toContain("### Classes")
|
||||
})
|
||||
|
||||
it("should handle class without extends", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [
|
||||
{
|
||||
name: "Standalone",
|
||||
lineStart: 1,
|
||||
lineEnd: 10,
|
||||
methods: [],
|
||||
properties: [],
|
||||
implements: ["IFoo"],
|
||||
isExported: false,
|
||||
isAbstract: false,
|
||||
},
|
||||
],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
const context = buildFileContext("standalone.ts", ast)
|
||||
|
||||
expect(context).toContain("Standalone implements IFoo")
|
||||
expect(context).not.toContain("extends")
|
||||
})
|
||||
|
||||
it("should handle class without implements", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [
|
||||
{
|
||||
name: "Child",
|
||||
lineStart: 1,
|
||||
lineEnd: 10,
|
||||
methods: [],
|
||||
properties: [],
|
||||
extends: "Parent",
|
||||
implements: [],
|
||||
isExported: false,
|
||||
isAbstract: false,
|
||||
},
|
||||
],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
const context = buildFileContext("child.ts", ast)
|
||||
|
||||
expect(context).toContain("Child extends Parent")
|
||||
expect(context).not.toContain("implements")
|
||||
})
|
||||
|
||||
it("should handle method with private visibility", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [
|
||||
{
|
||||
name: "WithPrivate",
|
||||
lineStart: 1,
|
||||
lineEnd: 20,
|
||||
methods: [
|
||||
{
|
||||
name: "secretMethod",
|
||||
lineStart: 5,
|
||||
lineEnd: 10,
|
||||
params: [],
|
||||
isAsync: false,
|
||||
visibility: "private",
|
||||
isStatic: false,
|
||||
},
|
||||
],
|
||||
properties: [],
|
||||
implements: [],
|
||||
isExported: false,
|
||||
isAbstract: false,
|
||||
},
|
||||
],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
const context = buildFileContext("private.ts", ast)
|
||||
|
||||
expect(context).toContain("private secretMethod()")
|
||||
})
|
||||
|
||||
it("should handle non-async function", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [
|
||||
{
|
||||
name: "syncFn",
|
||||
lineStart: 1,
|
||||
lineEnd: 5,
|
||||
params: [{ name: "x", optional: false, hasDefault: false }],
|
||||
isAsync: false,
|
||||
isExported: false,
|
||||
},
|
||||
],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
const context = buildFileContext("sync.ts", ast)
|
||||
|
||||
expect(context).toContain("syncFn(x)")
|
||||
expect(context).not.toContain("async syncFn")
|
||||
})
|
||||
|
||||
it("should handle export without default", () => {
|
||||
const ast: FileAST = {
|
||||
imports: [],
|
||||
exports: [{ name: "foo", line: 1, isDefault: false, kind: "variable" }],
|
||||
functions: [],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
}
|
||||
|
||||
const context = buildFileContext("named-export.ts", ast)
|
||||
|
||||
expect(context).toContain("variable foo")
|
||||
expect(context).not.toContain("(default)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildInitialContext - edge cases", () => {
|
||||
it("should handle nested directory names", () => {
|
||||
const structure: ProjectStructure = {
|
||||
name: "test",
|
||||
rootPath: "/test",
|
||||
files: [],
|
||||
directories: ["src/components/ui"],
|
||||
}
|
||||
const asts = new Map<string, FileAST>()
|
||||
|
||||
const context = buildInitialContext(structure, asts)
|
||||
|
||||
expect(context).toContain("ui/")
|
||||
})
|
||||
|
||||
it("should handle file with only interfaces", () => {
|
||||
const structure: ProjectStructure = {
|
||||
name: "test",
|
||||
rootPath: "/test",
|
||||
files: ["types.ts"],
|
||||
directories: [],
|
||||
}
|
||||
const asts = new Map<string, FileAST>([
|
||||
[
|
||||
"types.ts",
|
||||
{
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [],
|
||||
interfaces: [{ name: "IFoo", lineStart: 1, lineEnd: 5, isExported: true }],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const context = buildInitialContext(structure, asts)
|
||||
|
||||
expect(context).toContain("interface: IFoo")
|
||||
})
|
||||
|
||||
it("should handle file with only type aliases", () => {
|
||||
const structure: ProjectStructure = {
|
||||
name: "test",
|
||||
rootPath: "/test",
|
||||
files: ["types.ts"],
|
||||
directories: [],
|
||||
}
|
||||
const asts = new Map<string, FileAST>([
|
||||
[
|
||||
"types.ts",
|
||||
{
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [
|
||||
{ name: "MyType", lineStart: 1, lineEnd: 1, isExported: true },
|
||||
],
|
||||
parseError: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const context = buildInitialContext(structure, asts)
|
||||
|
||||
expect(context).toContain("type: MyType")
|
||||
})
|
||||
|
||||
it("should handle file with no AST content", () => {
|
||||
const structure: ProjectStructure = {
|
||||
name: "test",
|
||||
rootPath: "/test",
|
||||
files: ["empty.ts"],
|
||||
directories: [],
|
||||
}
|
||||
const asts = new Map<string, FileAST>([
|
||||
[
|
||||
"empty.ts",
|
||||
{
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const context = buildInitialContext(structure, asts)
|
||||
|
||||
expect(context).toContain("- empty.ts")
|
||||
})
|
||||
|
||||
it("should handle meta with only hub flag", () => {
|
||||
const structure: ProjectStructure = {
|
||||
name: "test",
|
||||
rootPath: "/test",
|
||||
files: ["hub.ts"],
|
||||
directories: [],
|
||||
}
|
||||
const asts = new Map<string, FileAST>([
|
||||
[
|
||||
"hub.ts",
|
||||
{
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"hub.ts",
|
||||
{
|
||||
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 },
|
||||
dependencies: [],
|
||||
dependents: [],
|
||||
isHub: true,
|
||||
isEntryPoint: false,
|
||||
fileType: "source",
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const context = buildInitialContext(structure, asts, metas)
|
||||
|
||||
expect(context).toContain("(hub)")
|
||||
expect(context).not.toContain("entry")
|
||||
expect(context).not.toContain("complex")
|
||||
})
|
||||
|
||||
it("should handle meta with no flags", () => {
|
||||
const structure: ProjectStructure = {
|
||||
name: "test",
|
||||
rootPath: "/test",
|
||||
files: ["normal.ts"],
|
||||
directories: [],
|
||||
}
|
||||
const asts = new Map<string, FileAST>([
|
||||
[
|
||||
"normal.ts",
|
||||
{
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
const metas = new Map<string, FileMeta>([
|
||||
[
|
||||
"normal.ts",
|
||||
{
|
||||
complexity: { loc: 10, nesting: 1, cyclomaticComplexity: 1, score: 10 },
|
||||
dependencies: [],
|
||||
dependents: [],
|
||||
isHub: false,
|
||||
isEntryPoint: false,
|
||||
fileType: "source",
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const context = buildInitialContext(structure, asts, metas)
|
||||
|
||||
expect(context).toContain("- normal.ts")
|
||||
expect(context).not.toContain("(hub")
|
||||
expect(context).not.toContain("entry")
|
||||
expect(context).not.toContain("complex")
|
||||
})
|
||||
|
||||
it("should skip files not in AST map", () => {
|
||||
const structure: ProjectStructure = {
|
||||
name: "test",
|
||||
rootPath: "/test",
|
||||
files: ["exists.ts", "missing.ts"],
|
||||
directories: [],
|
||||
}
|
||||
const asts = new Map<string, FileAST>([
|
||||
[
|
||||
"exists.ts",
|
||||
{
|
||||
imports: [],
|
||||
exports: [],
|
||||
functions: [],
|
||||
classes: [],
|
||||
interfaces: [],
|
||||
typeAliases: [],
|
||||
parseError: false,
|
||||
},
|
||||
],
|
||||
])
|
||||
|
||||
const context = buildInitialContext(structure, asts)
|
||||
|
||||
expect(context).toContain("exists.ts")
|
||||
expect(context).not.toContain("missing.ts")
|
||||
})
|
||||
})
|
||||
|
||||
describe("truncateContext", () => {
|
||||
it("should return original context if within limit", () => {
|
||||
const context = "Short context"
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { promises as fs } from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import * as os from "node:os"
|
||||
import {
|
||||
CreateFileTool,
|
||||
type CreateFileResult,
|
||||
} from "../../../../../src/infrastructure/tools/edit/CreateFileTool.js"
|
||||
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||
import { hashLines } from "../../../../../src/shared/utils/hash.js"
|
||||
|
||||
function createMockStorage(): IStorage {
|
||||
return {
|
||||
getFile: vi.fn().mockResolvedValue(null),
|
||||
setFile: vi.fn().mockResolvedValue(undefined),
|
||||
deleteFile: vi.fn(),
|
||||
getAllFiles: vi.fn(),
|
||||
getFileCount: vi.fn(),
|
||||
getAST: vi.fn(),
|
||||
setAST: vi.fn(),
|
||||
deleteAST: vi.fn(),
|
||||
getAllASTs: vi.fn(),
|
||||
getMeta: vi.fn(),
|
||||
setMeta: vi.fn(),
|
||||
deleteMeta: vi.fn(),
|
||||
getAllMetas: vi.fn(),
|
||||
getSymbolIndex: vi.fn(),
|
||||
setSymbolIndex: vi.fn(),
|
||||
getDepsGraph: vi.fn(),
|
||||
setDepsGraph: vi.fn(),
|
||||
getProjectConfig: vi.fn(),
|
||||
setProjectConfig: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(
|
||||
storage?: IStorage,
|
||||
confirmResult = true,
|
||||
projectRoot = "/test/project",
|
||||
): ToolContext {
|
||||
return {
|
||||
projectRoot,
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(confirmResult),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("CreateFileTool", () => {
|
||||
let tool: CreateFileTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new CreateFileTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("create_file")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("edit")
|
||||
})
|
||||
|
||||
it("should require confirmation", () => {
|
||||
expect(tool.requiresConfirmation).toBe(true)
|
||||
})
|
||||
|
||||
it("should have correct parameters", () => {
|
||||
expect(tool.parameters).toHaveLength(2)
|
||||
expect(tool.parameters[0].name).toBe("path")
|
||||
expect(tool.parameters[0].required).toBe(true)
|
||||
expect(tool.parameters[1].name).toBe("content")
|
||||
expect(tool.parameters[1].required).toBe(true)
|
||||
})
|
||||
|
||||
it("should have description mentioning confirmation", () => {
|
||||
expect(tool.description).toContain("confirmation")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return null for valid params", () => {
|
||||
expect(
|
||||
tool.validateParams({ path: "src/new-file.ts", content: "const x = 1" }),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for missing path", () => {
|
||||
expect(tool.validateParams({ content: "x" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for empty path", () => {
|
||||
expect(tool.validateParams({ path: "", content: "x" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
expect(tool.validateParams({ path: " ", content: "x" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for non-string path", () => {
|
||||
expect(tool.validateParams({ path: 123, content: "x" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for missing content", () => {
|
||||
expect(tool.validateParams({ path: "test.ts" })).toBe(
|
||||
"Parameter 'content' is required and must be a string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for non-string content", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", content: 123 })).toBe(
|
||||
"Parameter 'content' is required and must be a string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should allow empty content string", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", content: "" })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute", () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "create-file-test-"))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("should create new file with content", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const content = "line 1\nline 2\nline 3"
|
||||
const result = await tool.execute({ path: "new-file.ts", content }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as CreateFileResult
|
||||
expect(data.path).toBe("new-file.ts")
|
||||
expect(data.lines).toBe(3)
|
||||
|
||||
const filePath = path.join(tempDir, "new-file.ts")
|
||||
const fileContent = await fs.readFile(filePath, "utf-8")
|
||||
expect(fileContent).toBe(content)
|
||||
})
|
||||
|
||||
it("should create directories if they do not exist", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "deep/nested/dir/file.ts", content: "test" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
const filePath = path.join(tempDir, "deep/nested/dir/file.ts")
|
||||
const fileContent = await fs.readFile(filePath, "utf-8")
|
||||
expect(fileContent).toBe("test")
|
||||
})
|
||||
|
||||
it("should call requestConfirmation with diff info", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
await tool.execute({ path: "new-file.ts", content: "line 1\nline 2" }, ctx)
|
||||
|
||||
expect(ctx.requestConfirmation).toHaveBeenCalledWith(
|
||||
"Create new file: new-file.ts (2 lines)",
|
||||
{
|
||||
filePath: "new-file.ts",
|
||||
oldLines: [],
|
||||
newLines: ["line 1", "line 2"],
|
||||
startLine: 1,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it("should cancel creation when confirmation rejected", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, false, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "new-file.ts", content: "test" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("File creation cancelled by user")
|
||||
|
||||
const filePath = path.join(tempDir, "new-file.ts")
|
||||
await expect(fs.access(filePath)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it("should update storage after creation", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
await tool.execute({ path: "new-file.ts", content: "line 1\nline 2" }, ctx)
|
||||
|
||||
expect(storage.setFile).toHaveBeenCalledWith(
|
||||
"new-file.ts",
|
||||
expect.objectContaining({
|
||||
lines: ["line 1", "line 2"],
|
||||
hash: hashLines(["line 1", "line 2"]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for path outside project root", async () => {
|
||||
const ctx = createMockContext(undefined, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "../outside/file.ts", content: "test" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Path must be within project root")
|
||||
})
|
||||
|
||||
it("should return error if file already exists", async () => {
|
||||
const existingFile = path.join(tempDir, "existing.ts")
|
||||
await fs.writeFile(existingFile, "original content", "utf-8")
|
||||
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "existing.ts", content: "new content" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("File already exists: existing.ts")
|
||||
|
||||
const content = await fs.readFile(existingFile, "utf-8")
|
||||
expect(content).toBe("original content")
|
||||
})
|
||||
|
||||
it("should handle empty content", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "empty.ts", content: "" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as CreateFileResult
|
||||
expect(data.lines).toBe(1)
|
||||
|
||||
const filePath = path.join(tempDir, "empty.ts")
|
||||
const fileContent = await fs.readFile(filePath, "utf-8")
|
||||
expect(fileContent).toBe("")
|
||||
})
|
||||
|
||||
it("should handle single line content", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "single.ts", content: "export const x = 1" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as CreateFileResult
|
||||
expect(data.lines).toBe(1)
|
||||
})
|
||||
|
||||
it("should return correct file size", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const content = "hello world"
|
||||
const result = await tool.execute({ path: "file.ts", content }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as CreateFileResult
|
||||
expect(data.size).toBe(Buffer.byteLength(content, "utf-8"))
|
||||
})
|
||||
|
||||
it("should include callId in result", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "new.ts", content: "test" }, ctx)
|
||||
|
||||
expect(result.callId).toMatch(/^create_file-\d+$/)
|
||||
})
|
||||
|
||||
it("should include executionTimeMs in result", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "new.ts", content: "test" }, ctx)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("should handle multi-line content correctly", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const content = "import { x } from './x'\n\nexport function foo() {\n return x\n}\n"
|
||||
const result = await tool.execute({ path: "foo.ts", content }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as CreateFileResult
|
||||
expect(data.lines).toBe(6)
|
||||
|
||||
const filePath = path.join(tempDir, "foo.ts")
|
||||
const fileContent = await fs.readFile(filePath, "utf-8")
|
||||
expect(fileContent).toBe(content)
|
||||
})
|
||||
|
||||
it("should handle special characters in content", async () => {
|
||||
const storage = createMockStorage()
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const content = "const emoji = '🚀'\nconst quote = \"hello 'world'\""
|
||||
const result = await tool.execute({ path: "special.ts", content }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
|
||||
const filePath = path.join(tempDir, "special.ts")
|
||||
const fileContent = await fs.readFile(filePath, "utf-8")
|
||||
expect(fileContent).toBe(content)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,274 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { promises as fs } from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import * as os from "node:os"
|
||||
import {
|
||||
DeleteFileTool,
|
||||
type DeleteFileResult,
|
||||
} from "../../../../../src/infrastructure/tools/edit/DeleteFileTool.js"
|
||||
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||
|
||||
function createMockStorage(fileData: { lines: string[] } | null = null): IStorage {
|
||||
return {
|
||||
getFile: vi.fn().mockResolvedValue(fileData),
|
||||
setFile: vi.fn().mockResolvedValue(undefined),
|
||||
deleteFile: vi.fn().mockResolvedValue(undefined),
|
||||
getAllFiles: vi.fn(),
|
||||
getFileCount: vi.fn(),
|
||||
getAST: vi.fn(),
|
||||
setAST: vi.fn(),
|
||||
deleteAST: vi.fn().mockResolvedValue(undefined),
|
||||
getAllASTs: vi.fn(),
|
||||
getMeta: vi.fn(),
|
||||
setMeta: vi.fn(),
|
||||
deleteMeta: vi.fn().mockResolvedValue(undefined),
|
||||
getAllMetas: vi.fn(),
|
||||
getSymbolIndex: vi.fn(),
|
||||
setSymbolIndex: vi.fn(),
|
||||
getDepsGraph: vi.fn(),
|
||||
setDepsGraph: vi.fn(),
|
||||
getProjectConfig: vi.fn(),
|
||||
setProjectConfig: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(
|
||||
storage?: IStorage,
|
||||
confirmResult = true,
|
||||
projectRoot = "/test/project",
|
||||
): ToolContext {
|
||||
return {
|
||||
projectRoot,
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(confirmResult),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("DeleteFileTool", () => {
|
||||
let tool: DeleteFileTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new DeleteFileTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("delete_file")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("edit")
|
||||
})
|
||||
|
||||
it("should require confirmation", () => {
|
||||
expect(tool.requiresConfirmation).toBe(true)
|
||||
})
|
||||
|
||||
it("should have correct parameters", () => {
|
||||
expect(tool.parameters).toHaveLength(1)
|
||||
expect(tool.parameters[0].name).toBe("path")
|
||||
expect(tool.parameters[0].required).toBe(true)
|
||||
})
|
||||
|
||||
it("should have description mentioning confirmation", () => {
|
||||
expect(tool.description).toContain("confirmation")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return null for valid params", () => {
|
||||
expect(tool.validateParams({ path: "src/file.ts" })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for missing path", () => {
|
||||
expect(tool.validateParams({})).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for empty path", () => {
|
||||
expect(tool.validateParams({ path: "" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
expect(tool.validateParams({ path: " " })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for non-string path", () => {
|
||||
expect(tool.validateParams({ path: 123 })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute", () => {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "delete-file-test-"))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("should delete existing file", async () => {
|
||||
const testFile = path.join(tempDir, "to-delete.ts")
|
||||
await fs.writeFile(testFile, "content to delete", "utf-8")
|
||||
|
||||
const storage = createMockStorage({ lines: ["content to delete"] })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "to-delete.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as DeleteFileResult
|
||||
expect(data.path).toBe("to-delete.ts")
|
||||
expect(data.deleted).toBe(true)
|
||||
|
||||
await expect(fs.access(testFile)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it("should delete file from storage", async () => {
|
||||
const testFile = path.join(tempDir, "to-delete.ts")
|
||||
await fs.writeFile(testFile, "content", "utf-8")
|
||||
|
||||
const storage = createMockStorage({ lines: ["content"] })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
await tool.execute({ path: "to-delete.ts" }, ctx)
|
||||
|
||||
expect(storage.deleteFile).toHaveBeenCalledWith("to-delete.ts")
|
||||
expect(storage.deleteAST).toHaveBeenCalledWith("to-delete.ts")
|
||||
expect(storage.deleteMeta).toHaveBeenCalledWith("to-delete.ts")
|
||||
})
|
||||
|
||||
it("should call requestConfirmation with diff info", async () => {
|
||||
const testFile = path.join(tempDir, "to-delete.ts")
|
||||
await fs.writeFile(testFile, "line 1\nline 2", "utf-8")
|
||||
|
||||
const storage = createMockStorage({ lines: ["line 1", "line 2"] })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
await tool.execute({ path: "to-delete.ts" }, ctx)
|
||||
|
||||
expect(ctx.requestConfirmation).toHaveBeenCalledWith("Delete file: to-delete.ts", {
|
||||
filePath: "to-delete.ts",
|
||||
oldLines: ["line 1", "line 2"],
|
||||
newLines: [],
|
||||
startLine: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it("should cancel deletion when confirmation rejected", async () => {
|
||||
const testFile = path.join(tempDir, "keep.ts")
|
||||
await fs.writeFile(testFile, "keep this", "utf-8")
|
||||
|
||||
const storage = createMockStorage({ lines: ["keep this"] })
|
||||
const ctx = createMockContext(storage, false, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "keep.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("File deletion cancelled by user")
|
||||
|
||||
const content = await fs.readFile(testFile, "utf-8")
|
||||
expect(content).toBe("keep this")
|
||||
})
|
||||
|
||||
it("should return error for path outside project root", async () => {
|
||||
const ctx = createMockContext(undefined, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "../outside/file.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Path must be within project root")
|
||||
})
|
||||
|
||||
it("should return error if file does not exist", async () => {
|
||||
const storage = createMockStorage(null)
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "nonexistent.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("File not found: nonexistent.ts")
|
||||
})
|
||||
|
||||
it("should read content from filesystem if not in storage", async () => {
|
||||
const testFile = path.join(tempDir, "not-indexed.ts")
|
||||
await fs.writeFile(testFile, "filesystem content\nline 2", "utf-8")
|
||||
|
||||
const storage = createMockStorage(null)
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
await tool.execute({ path: "not-indexed.ts" }, ctx)
|
||||
|
||||
expect(ctx.requestConfirmation).toHaveBeenCalledWith(
|
||||
"Delete file: not-indexed.ts",
|
||||
expect.objectContaining({
|
||||
oldLines: ["filesystem content", "line 2"],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should include callId in result", async () => {
|
||||
const testFile = path.join(tempDir, "file.ts")
|
||||
await fs.writeFile(testFile, "x", "utf-8")
|
||||
|
||||
const storage = createMockStorage({ lines: ["x"] })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "file.ts" }, ctx)
|
||||
|
||||
expect(result.callId).toMatch(/^delete_file-\d+$/)
|
||||
})
|
||||
|
||||
it("should include executionTimeMs in result", async () => {
|
||||
const testFile = path.join(tempDir, "file.ts")
|
||||
await fs.writeFile(testFile, "x", "utf-8")
|
||||
|
||||
const storage = createMockStorage({ lines: ["x"] })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "file.ts" }, ctx)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("should not delete directories", async () => {
|
||||
const dirPath = path.join(tempDir, "some-dir")
|
||||
await fs.mkdir(dirPath)
|
||||
|
||||
const storage = createMockStorage(null)
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "some-dir" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("File not found: some-dir")
|
||||
})
|
||||
|
||||
it("should handle nested file paths", async () => {
|
||||
const nestedDir = path.join(tempDir, "a/b/c")
|
||||
await fs.mkdir(nestedDir, { recursive: true })
|
||||
const testFile = path.join(nestedDir, "file.ts")
|
||||
await fs.writeFile(testFile, "nested", "utf-8")
|
||||
|
||||
const storage = createMockStorage({ lines: ["nested"] })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute({ path: "a/b/c/file.ts" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
await expect(fs.access(testFile)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,493 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { promises as fs } from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import * as os from "node:os"
|
||||
import {
|
||||
EditLinesTool,
|
||||
type EditLinesResult,
|
||||
} from "../../../../../src/infrastructure/tools/edit/EditLinesTool.js"
|
||||
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||
import { hashLines } from "../../../../../src/shared/utils/hash.js"
|
||||
|
||||
function createMockStorage(fileData: { lines: string[]; hash: string } | null = null): IStorage {
|
||||
return {
|
||||
getFile: vi.fn().mockResolvedValue(fileData),
|
||||
setFile: vi.fn().mockResolvedValue(undefined),
|
||||
deleteFile: vi.fn(),
|
||||
getAllFiles: vi.fn(),
|
||||
getFileCount: vi.fn(),
|
||||
getAST: vi.fn(),
|
||||
setAST: vi.fn(),
|
||||
deleteAST: vi.fn(),
|
||||
getAllASTs: vi.fn(),
|
||||
getMeta: vi.fn(),
|
||||
setMeta: vi.fn(),
|
||||
deleteMeta: vi.fn(),
|
||||
getAllMetas: vi.fn(),
|
||||
getSymbolIndex: vi.fn(),
|
||||
setSymbolIndex: vi.fn(),
|
||||
getDepsGraph: vi.fn(),
|
||||
setDepsGraph: vi.fn(),
|
||||
getProjectConfig: vi.fn(),
|
||||
setProjectConfig: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(
|
||||
storage?: IStorage,
|
||||
confirmResult = true,
|
||||
projectRoot = "/test/project",
|
||||
): ToolContext {
|
||||
return {
|
||||
projectRoot,
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(confirmResult),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
describe("EditLinesTool", () => {
|
||||
let tool: EditLinesTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new EditLinesTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("edit_lines")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("edit")
|
||||
})
|
||||
|
||||
it("should require confirmation", () => {
|
||||
expect(tool.requiresConfirmation).toBe(true)
|
||||
})
|
||||
|
||||
it("should have correct parameters", () => {
|
||||
expect(tool.parameters).toHaveLength(4)
|
||||
expect(tool.parameters[0].name).toBe("path")
|
||||
expect(tool.parameters[0].required).toBe(true)
|
||||
expect(tool.parameters[1].name).toBe("start")
|
||||
expect(tool.parameters[1].required).toBe(true)
|
||||
expect(tool.parameters[2].name).toBe("end")
|
||||
expect(tool.parameters[2].required).toBe(true)
|
||||
expect(tool.parameters[3].name).toBe("content")
|
||||
expect(tool.parameters[3].required).toBe(true)
|
||||
})
|
||||
|
||||
it("should have description mentioning confirmation", () => {
|
||||
expect(tool.description).toContain("confirmation")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return null for valid params", () => {
|
||||
expect(
|
||||
tool.validateParams({
|
||||
path: "src/index.ts",
|
||||
start: 1,
|
||||
end: 5,
|
||||
content: "new content",
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for missing path", () => {
|
||||
expect(tool.validateParams({ start: 1, end: 5, content: "x" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for empty path", () => {
|
||||
expect(tool.validateParams({ path: "", start: 1, end: 5, content: "x" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
expect(tool.validateParams({ path: " ", start: 1, end: 5, content: "x" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for non-string path", () => {
|
||||
expect(tool.validateParams({ path: 123, start: 1, end: 5, content: "x" })).toBe(
|
||||
"Parameter 'path' is required and must be a non-empty string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for missing start", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", end: 5, content: "x" })).toBe(
|
||||
"Parameter 'start' is required and must be an integer",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for non-integer start", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", start: 1.5, end: 5, content: "x" })).toBe(
|
||||
"Parameter 'start' is required and must be an integer",
|
||||
)
|
||||
expect(tool.validateParams({ path: "test.ts", start: "1", end: 5, content: "x" })).toBe(
|
||||
"Parameter 'start' is required and must be an integer",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for start < 1", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", start: 0, end: 5, content: "x" })).toBe(
|
||||
"Parameter 'start' must be >= 1",
|
||||
)
|
||||
expect(tool.validateParams({ path: "test.ts", start: -1, end: 5, content: "x" })).toBe(
|
||||
"Parameter 'start' must be >= 1",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for missing end", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", start: 1, content: "x" })).toBe(
|
||||
"Parameter 'end' is required and must be an integer",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for non-integer end", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", start: 1, end: 5.5, content: "x" })).toBe(
|
||||
"Parameter 'end' is required and must be an integer",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for end < 1", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", start: 1, end: 0, content: "x" })).toBe(
|
||||
"Parameter 'end' must be >= 1",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for start > end", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", start: 10, end: 5, content: "x" })).toBe(
|
||||
"Parameter 'start' must be <= 'end'",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for missing content", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", start: 1, end: 5 })).toBe(
|
||||
"Parameter 'content' is required and must be a string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for non-string content", () => {
|
||||
expect(tool.validateParams({ path: "test.ts", start: 1, end: 5, content: 123 })).toBe(
|
||||
"Parameter 'content' is required and must be a string",
|
||||
)
|
||||
})
|
||||
|
||||
it("should allow empty content string", () => {
|
||||
expect(
|
||||
tool.validateParams({ path: "test.ts", start: 1, end: 5, content: "" }),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute", () => {
|
||||
let tempDir: string
|
||||
let testFilePath: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "edit-lines-test-"))
|
||||
testFilePath = path.join(tempDir, "test.ts")
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("should replace lines with new content", async () => {
|
||||
const originalLines = ["line 1", "line 2", "line 3", "line 4", "line 5"]
|
||||
const originalContent = originalLines.join("\n")
|
||||
await fs.writeFile(testFilePath, originalContent, "utf-8")
|
||||
|
||||
const lines = [...originalLines]
|
||||
const hash = hashLines(lines)
|
||||
const storage = createMockStorage({ lines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 2, end: 4, content: "new line A\nnew line B" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as EditLinesResult
|
||||
expect(data.path).toBe("test.ts")
|
||||
expect(data.startLine).toBe(2)
|
||||
expect(data.endLine).toBe(4)
|
||||
expect(data.linesReplaced).toBe(3)
|
||||
expect(data.linesInserted).toBe(2)
|
||||
expect(data.totalLines).toBe(4)
|
||||
|
||||
const newContent = await fs.readFile(testFilePath, "utf-8")
|
||||
expect(newContent).toBe("line 1\nnew line A\nnew line B\nline 5")
|
||||
})
|
||||
|
||||
it("should call requestConfirmation with diff info", async () => {
|
||||
const originalLines = ["line 1", "line 2", "line 3"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
await tool.execute({ path: "test.ts", start: 2, end: 2, content: "replaced" }, ctx)
|
||||
|
||||
expect(ctx.requestConfirmation).toHaveBeenCalledWith("Replace lines 2-2 in test.ts", {
|
||||
filePath: "test.ts",
|
||||
oldLines: ["line 2"],
|
||||
newLines: ["replaced"],
|
||||
startLine: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it("should cancel edit when confirmation rejected", async () => {
|
||||
const originalLines = ["line 1", "line 2", "line 3"]
|
||||
const originalContent = originalLines.join("\n")
|
||||
await fs.writeFile(testFilePath, originalContent, "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, false, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 1, end: 1, content: "changed" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Edit cancelled by user")
|
||||
|
||||
const content = await fs.readFile(testFilePath, "utf-8")
|
||||
expect(content).toBe(originalContent)
|
||||
})
|
||||
|
||||
it("should update storage after edit", async () => {
|
||||
const originalLines = ["line 1", "line 2"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
await tool.execute({ path: "test.ts", start: 1, end: 1, content: "changed" }, ctx)
|
||||
|
||||
expect(storage.setFile).toHaveBeenCalledWith(
|
||||
"test.ts",
|
||||
expect.objectContaining({
|
||||
lines: ["changed", "line 2"],
|
||||
hash: hashLines(["changed", "line 2"]),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for path outside project root", async () => {
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "../outside/file.ts", start: 1, end: 1, content: "x" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Path must be within project root")
|
||||
})
|
||||
|
||||
it("should return error when start exceeds file length", async () => {
|
||||
const originalLines = ["line 1", "line 2"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 10, end: 15, content: "x" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Start line 10 exceeds file length (2 lines)")
|
||||
})
|
||||
|
||||
it("should adjust end to file length if it exceeds", async () => {
|
||||
const originalLines = ["line 1", "line 2", "line 3"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 2, end: 100, content: "new" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as EditLinesResult
|
||||
expect(data.endLine).toBe(3)
|
||||
expect(data.linesReplaced).toBe(2)
|
||||
})
|
||||
|
||||
it("should detect hash conflict", async () => {
|
||||
const originalLines = ["line 1", "line 2"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const oldHash = hashLines(["old content"])
|
||||
const storage = createMockStorage({ lines: originalLines, hash: oldHash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 1, end: 1, content: "new" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe(
|
||||
"File has been modified externally. Please refresh the file before editing.",
|
||||
)
|
||||
})
|
||||
|
||||
it("should allow edit when file not in storage", async () => {
|
||||
const originalLines = ["line 1", "line 2"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const storage = createMockStorage(null)
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 1, end: 1, content: "new" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle single line replacement", async () => {
|
||||
const originalLines = ["line 1", "line 2", "line 3"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 2, end: 2, content: "replaced line 2" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const content = await fs.readFile(testFilePath, "utf-8")
|
||||
expect(content).toBe("line 1\nreplaced line 2\nline 3")
|
||||
})
|
||||
|
||||
it("should handle replacing all lines", async () => {
|
||||
const originalLines = ["line 1", "line 2"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 1, end: 2, content: "completely\nnew\nfile" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const content = await fs.readFile(testFilePath, "utf-8")
|
||||
expect(content).toBe("completely\nnew\nfile")
|
||||
})
|
||||
|
||||
it("should handle inserting more lines than replaced", async () => {
|
||||
const originalLines = ["line 1", "line 2"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 1, end: 1, content: "a\nb\nc\nd" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as EditLinesResult
|
||||
expect(data.linesReplaced).toBe(1)
|
||||
expect(data.linesInserted).toBe(4)
|
||||
expect(data.totalLines).toBe(5)
|
||||
})
|
||||
|
||||
it("should handle deleting lines (empty content)", async () => {
|
||||
const originalLines = ["line 1", "line 2", "line 3"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 2, end: 2, content: "" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as EditLinesResult
|
||||
expect(data.linesReplaced).toBe(1)
|
||||
expect(data.linesInserted).toBe(1)
|
||||
expect(data.totalLines).toBe(3)
|
||||
})
|
||||
|
||||
it("should include callId in result", async () => {
|
||||
const originalLines = ["line 1"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 1, end: 1, content: "new" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.callId).toMatch(/^edit_lines-\d+$/)
|
||||
})
|
||||
|
||||
it("should include executionTimeMs in result", async () => {
|
||||
const originalLines = ["line 1"]
|
||||
await fs.writeFile(testFilePath, originalLines.join("\n"), "utf-8")
|
||||
|
||||
const hash = hashLines(originalLines)
|
||||
const storage = createMockStorage({ lines: originalLines, hash })
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "test.ts", start: 1, end: 1, content: "new" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("should return error when file not found", async () => {
|
||||
const storage = createMockStorage(null)
|
||||
const ctx = createMockContext(storage, true, tempDir)
|
||||
|
||||
const result = await tool.execute(
|
||||
{ path: "nonexistent.ts", start: 1, end: 1, content: "x" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("ENOENT")
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user