mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
feat(ipuaro): add git and run tools (v0.9.0)
Git tools: - GitStatusTool: repository status (branch, staged, modified, untracked) - GitDiffTool: uncommitted changes with diff output - GitCommitTool: create commits with confirmation Run tools: - CommandSecurity: blacklist/whitelist shell command validation - RunCommandTool: execute shell commands with security checks - RunTestsTool: auto-detect and run vitest/jest/mocha/npm test All 18 planned tools now implemented. Tests: 1086 (+233), Coverage: 98.08%
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
GitDiffTool,
|
||||
type GitDiffResult,
|
||||
} from "../../../../../src/infrastructure/tools/git/GitDiffTool.js"
|
||||
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||
import type { SimpleGit, DiffResult } from "simple-git"
|
||||
|
||||
function createMockStorage(): IStorage {
|
||||
return {
|
||||
getFile: vi.fn(),
|
||||
setFile: vi.fn(),
|
||||
deleteFile: vi.fn(),
|
||||
getAllFiles: vi.fn().mockResolvedValue(new Map()),
|
||||
getFileCount: vi.fn().mockResolvedValue(0),
|
||||
getAST: vi.fn(),
|
||||
setAST: vi.fn(),
|
||||
deleteAST: vi.fn(),
|
||||
getAllASTs: vi.fn().mockResolvedValue(new Map()),
|
||||
getMeta: vi.fn(),
|
||||
setMeta: vi.fn(),
|
||||
deleteMeta: vi.fn(),
|
||||
getAllMetas: vi.fn().mockResolvedValue(new Map()),
|
||||
getSymbolIndex: vi.fn().mockResolvedValue(new Map()),
|
||||
setSymbolIndex: vi.fn(),
|
||||
getDepsGraph: vi.fn().mockResolvedValue({ imports: new Map(), importedBy: new Map() }),
|
||||
setDepsGraph: vi.fn(),
|
||||
getProjectConfig: vi.fn(),
|
||||
setProjectConfig: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
clear: vi.fn(),
|
||||
} as unknown as IStorage
|
||||
}
|
||||
|
||||
function createMockContext(storage?: IStorage): ToolContext {
|
||||
return {
|
||||
projectRoot: "/test/project",
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(true),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
function createMockDiffSummary(overrides: Partial<DiffResult> = {}): DiffResult {
|
||||
return {
|
||||
changed: 0,
|
||||
deletions: 0,
|
||||
insertions: 0,
|
||||
files: [],
|
||||
...overrides,
|
||||
} as DiffResult
|
||||
}
|
||||
|
||||
function createMockGit(options: {
|
||||
isRepo?: boolean
|
||||
diffSummary?: DiffResult
|
||||
diff?: string
|
||||
error?: Error
|
||||
}): SimpleGit {
|
||||
const mockGit = {
|
||||
checkIsRepo: vi.fn().mockResolvedValue(options.isRepo ?? true),
|
||||
diffSummary: vi.fn(),
|
||||
diff: vi.fn(),
|
||||
}
|
||||
|
||||
if (options.error) {
|
||||
mockGit.diffSummary.mockRejectedValue(options.error)
|
||||
} else {
|
||||
mockGit.diffSummary.mockResolvedValue(
|
||||
options.diffSummary ?? createMockDiffSummary(),
|
||||
)
|
||||
mockGit.diff.mockResolvedValue(options.diff ?? "")
|
||||
}
|
||||
|
||||
return mockGit as unknown as SimpleGit
|
||||
}
|
||||
|
||||
describe("GitDiffTool", () => {
|
||||
let tool: GitDiffTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new GitDiffTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("git_diff")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("git")
|
||||
})
|
||||
|
||||
it("should not require confirmation", () => {
|
||||
expect(tool.requiresConfirmation).toBe(false)
|
||||
})
|
||||
|
||||
it("should have correct parameters", () => {
|
||||
expect(tool.parameters).toHaveLength(2)
|
||||
expect(tool.parameters[0].name).toBe("path")
|
||||
expect(tool.parameters[0].required).toBe(false)
|
||||
expect(tool.parameters[1].name).toBe("staged")
|
||||
expect(tool.parameters[1].required).toBe(false)
|
||||
})
|
||||
|
||||
it("should have description", () => {
|
||||
expect(tool.description).toContain("diff")
|
||||
expect(tool.description).toContain("changes")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return null for empty params", () => {
|
||||
expect(tool.validateParams({})).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for valid path", () => {
|
||||
expect(tool.validateParams({ path: "src" })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for valid staged", () => {
|
||||
expect(tool.validateParams({ staged: true })).toBeNull()
|
||||
expect(tool.validateParams({ staged: false })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for invalid path type", () => {
|
||||
expect(tool.validateParams({ path: 123 })).toContain("path")
|
||||
expect(tool.validateParams({ path: 123 })).toContain("string")
|
||||
})
|
||||
|
||||
it("should return error for invalid staged type", () => {
|
||||
expect(tool.validateParams({ staged: "yes" })).toContain("staged")
|
||||
expect(tool.validateParams({ staged: "yes" })).toContain("boolean")
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute", () => {
|
||||
describe("not a git repository", () => {
|
||||
it("should return error when not in a git repo", async () => {
|
||||
const mockGit = createMockGit({ isRepo: false })
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Not a git repository")
|
||||
})
|
||||
})
|
||||
|
||||
describe("no changes", () => {
|
||||
it("should return empty diff for clean repo", async () => {
|
||||
const mockGit = createMockGit({
|
||||
diffSummary: createMockDiffSummary({ files: [] }),
|
||||
diff: "",
|
||||
})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitDiffResult
|
||||
expect(data.hasChanges).toBe(false)
|
||||
expect(data.files).toHaveLength(0)
|
||||
expect(data.diff).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("with changes", () => {
|
||||
it("should return diff for modified files", async () => {
|
||||
const mockGit = createMockGit({
|
||||
diffSummary: createMockDiffSummary({
|
||||
files: [
|
||||
{ file: "src/index.ts", insertions: 5, deletions: 2, binary: false },
|
||||
],
|
||||
insertions: 5,
|
||||
deletions: 2,
|
||||
}),
|
||||
diff: "diff --git a/src/index.ts",
|
||||
})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitDiffResult
|
||||
expect(data.hasChanges).toBe(true)
|
||||
expect(data.files).toHaveLength(1)
|
||||
expect(data.files[0].file).toBe("src/index.ts")
|
||||
expect(data.files[0].insertions).toBe(5)
|
||||
expect(data.files[0].deletions).toBe(2)
|
||||
})
|
||||
|
||||
it("should return multiple files", async () => {
|
||||
const mockGit = createMockGit({
|
||||
diffSummary: createMockDiffSummary({
|
||||
files: [
|
||||
{ file: "a.ts", insertions: 1, deletions: 0, binary: false },
|
||||
{ file: "b.ts", insertions: 2, deletions: 1, binary: false },
|
||||
{ file: "c.ts", insertions: 0, deletions: 5, binary: false },
|
||||
],
|
||||
insertions: 3,
|
||||
deletions: 6,
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitDiffResult
|
||||
expect(data.files).toHaveLength(3)
|
||||
expect(data.summary.filesChanged).toBe(3)
|
||||
expect(data.summary.insertions).toBe(3)
|
||||
expect(data.summary.deletions).toBe(6)
|
||||
})
|
||||
|
||||
it("should handle binary files", async () => {
|
||||
const mockGit = createMockGit({
|
||||
diffSummary: createMockDiffSummary({
|
||||
files: [
|
||||
{ file: "image.png", insertions: 0, deletions: 0, binary: true },
|
||||
],
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitDiffResult
|
||||
expect(data.files[0].binary).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("staged parameter", () => {
|
||||
it("should default to false (unstaged)", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitDiffResult
|
||||
expect(data.staged).toBe(false)
|
||||
expect(mockGit.diffSummary).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it("should pass --cached for staged=true", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ staged: true }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitDiffResult
|
||||
expect(data.staged).toBe(true)
|
||||
expect(mockGit.diffSummary).toHaveBeenCalledWith(["--cached"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("path parameter", () => {
|
||||
it("should filter by path", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ path: "src" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitDiffResult
|
||||
expect(data.pathFilter).toBe("src")
|
||||
expect(mockGit.diffSummary).toHaveBeenCalledWith(["--", "src"])
|
||||
})
|
||||
|
||||
it("should combine staged and path", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ staged: true, path: "src/index.ts" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockGit.diffSummary).toHaveBeenCalledWith([
|
||||
"--cached",
|
||||
"--",
|
||||
"src/index.ts",
|
||||
])
|
||||
})
|
||||
|
||||
it("should return null pathFilter when not provided", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitDiffResult
|
||||
expect(data.pathFilter).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("diff text", () => {
|
||||
it("should include full diff text", async () => {
|
||||
const diffText = `diff --git a/src/index.ts b/src/index.ts
|
||||
index abc123..def456 100644
|
||||
--- a/src/index.ts
|
||||
+++ b/src/index.ts
|
||||
@@ -1,3 +1,4 @@
|
||||
+import { foo } from "./foo"
|
||||
export function main() {
|
||||
console.log("hello")
|
||||
}`
|
||||
const mockGit = createMockGit({
|
||||
diffSummary: createMockDiffSummary({
|
||||
files: [
|
||||
{ file: "src/index.ts", insertions: 1, deletions: 0, binary: false },
|
||||
],
|
||||
}),
|
||||
diff: diffText,
|
||||
})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitDiffResult
|
||||
expect(data.diff).toBe(diffText)
|
||||
expect(data.diff).toContain("diff --git")
|
||||
expect(data.diff).toContain("import { foo }")
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle git command errors", async () => {
|
||||
const mockGit = createMockGit({
|
||||
error: new Error("Git command failed"),
|
||||
})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Git command failed")
|
||||
})
|
||||
|
||||
it("should handle non-Error exceptions", async () => {
|
||||
const mockGit = {
|
||||
checkIsRepo: vi.fn().mockResolvedValue(true),
|
||||
diffSummary: vi.fn().mockRejectedValue("string error"),
|
||||
} as unknown as SimpleGit
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("string error")
|
||||
})
|
||||
})
|
||||
|
||||
describe("timing", () => {
|
||||
it("should return timing information", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("call id", () => {
|
||||
it("should generate unique call id", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitDiffTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({}, ctx)
|
||||
|
||||
expect(result.callId).toMatch(/^git_diff-\d+$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user