mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-27 23:06:54 +05:00
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%
504 lines
19 KiB
TypeScript
504 lines
19 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest"
|
|
import {
|
|
GitStatusTool,
|
|
type GitStatusResult,
|
|
} from "../../../../../src/infrastructure/tools/git/GitStatusTool.js"
|
|
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
|
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
|
import type { SimpleGit, StatusResult } 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 createMockStatusResult(overrides: Partial<StatusResult> = {}): StatusResult {
|
|
return {
|
|
not_added: [],
|
|
conflicted: [],
|
|
created: [],
|
|
deleted: [],
|
|
ignored: [],
|
|
modified: [],
|
|
renamed: [],
|
|
files: [],
|
|
staged: [],
|
|
ahead: 0,
|
|
behind: 0,
|
|
current: "main",
|
|
tracking: "origin/main",
|
|
detached: false,
|
|
isClean: () => true,
|
|
...overrides,
|
|
} as StatusResult
|
|
}
|
|
|
|
function createMockGit(options: {
|
|
isRepo?: boolean
|
|
status?: StatusResult
|
|
error?: Error
|
|
}): SimpleGit {
|
|
const mockGit = {
|
|
checkIsRepo: vi.fn().mockResolvedValue(options.isRepo ?? true),
|
|
status: vi.fn(),
|
|
}
|
|
|
|
if (options.error) {
|
|
mockGit.status.mockRejectedValue(options.error)
|
|
} else {
|
|
mockGit.status.mockResolvedValue(options.status ?? createMockStatusResult())
|
|
}
|
|
|
|
return mockGit as unknown as SimpleGit
|
|
}
|
|
|
|
describe("GitStatusTool", () => {
|
|
let tool: GitStatusTool
|
|
|
|
beforeEach(() => {
|
|
tool = new GitStatusTool()
|
|
})
|
|
|
|
describe("metadata", () => {
|
|
it("should have correct name", () => {
|
|
expect(tool.name).toBe("git_status")
|
|
})
|
|
|
|
it("should have correct category", () => {
|
|
expect(tool.category).toBe("git")
|
|
})
|
|
|
|
it("should not require confirmation", () => {
|
|
expect(tool.requiresConfirmation).toBe(false)
|
|
})
|
|
|
|
it("should have no parameters", () => {
|
|
expect(tool.parameters).toHaveLength(0)
|
|
})
|
|
|
|
it("should have description", () => {
|
|
expect(tool.description).toContain("git")
|
|
expect(tool.description).toContain("status")
|
|
})
|
|
})
|
|
|
|
describe("validateParams", () => {
|
|
it("should return null for empty params", () => {
|
|
expect(tool.validateParams({})).toBeNull()
|
|
})
|
|
|
|
it("should return null for any params (no required)", () => {
|
|
expect(tool.validateParams({ foo: "bar" })).toBeNull()
|
|
})
|
|
})
|
|
|
|
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 GitStatusTool(() => mockGit)
|
|
const ctx = createMockContext()
|
|
|
|
const result = await toolWithMock.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(false)
|
|
expect(result.error).toContain("Not a git repository")
|
|
})
|
|
})
|
|
|
|
describe("clean repository", () => {
|
|
it("should return clean status", async () => {
|
|
const mockGit = createMockGit({
|
|
status: createMockStatusResult({
|
|
current: "main",
|
|
tracking: "origin/main",
|
|
ahead: 0,
|
|
behind: 0,
|
|
isClean: () => true,
|
|
}),
|
|
})
|
|
const toolWithMock = new GitStatusTool(() => mockGit)
|
|
const ctx = createMockContext()
|
|
|
|
const result = await toolWithMock.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GitStatusResult
|
|
expect(data.branch).toBe("main")
|
|
expect(data.tracking).toBe("origin/main")
|
|
expect(data.isClean).toBe(true)
|
|
expect(data.staged).toHaveLength(0)
|
|
expect(data.modified).toHaveLength(0)
|
|
expect(data.untracked).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe("branch information", () => {
|
|
it("should return current branch name", async () => {
|
|
const mockGit = createMockGit({
|
|
status: createMockStatusResult({ current: "feature/test" }),
|
|
})
|
|
const toolWithMock = new GitStatusTool(() => mockGit)
|
|
const ctx = createMockContext()
|
|
|
|
const result = await toolWithMock.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GitStatusResult
|
|
expect(data.branch).toBe("feature/test")
|
|
})
|
|
|
|
it("should handle detached HEAD", async () => {
|
|
const mockGit = createMockGit({
|
|
status: createMockStatusResult({ current: null }),
|
|
})
|
|
const toolWithMock = new GitStatusTool(() => mockGit)
|
|
const ctx = createMockContext()
|
|
|
|
const result = await toolWithMock.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GitStatusResult
|
|
expect(data.branch).toBe("HEAD (detached)")
|
|
})
|
|
|
|
it("should return tracking branch when available", async () => {
|
|
const mockGit = createMockGit({
|
|
status: createMockStatusResult({ tracking: "origin/develop" }),
|
|
})
|
|
const toolWithMock = new GitStatusTool(() => mockGit)
|
|
const ctx = createMockContext()
|
|
|
|
const result = await toolWithMock.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GitStatusResult
|
|
expect(data.tracking).toBe("origin/develop")
|
|
})
|
|
|
|
it("should handle no tracking branch", async () => {
|
|
const mockGit = createMockGit({
|
|
status: createMockStatusResult({ tracking: null }),
|
|
})
|
|
const toolWithMock = new GitStatusTool(() => mockGit)
|
|
const ctx = createMockContext()
|
|
|
|
const result = await toolWithMock.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GitStatusResult
|
|
expect(data.tracking).toBeNull()
|
|
})
|
|
|
|
it("should return ahead/behind counts", async () => {
|
|
const mockGit = createMockGit({
|
|
status: createMockStatusResult({ ahead: 3, behind: 1 }),
|
|
})
|
|
const toolWithMock = new GitStatusTool(() => mockGit)
|
|
const ctx = createMockContext()
|
|
|
|
const result = await toolWithMock.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GitStatusResult
|
|
expect(data.ahead).toBe(3)
|
|
expect(data.behind).toBe(1)
|
|
})
|
|
})
|
|
|
|
describe("staged files", () => {
|
|
it("should return staged files (new file)", async () => {
|
|
const mockGit = createMockGit({
|
|
status: createMockStatusResult({
|
|
files: [{ path: "new.ts", index: "A", working_dir: " " }],
|
|
isClean: () => false,
|
|
}),
|
|
})
|
|
const toolWithMock = new GitStatusTool(() => mockGit)
|
|
const ctx = createMockContext()
|
|
|
|
const result = await toolWithMock.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GitStatusResult
|
|
expect(data.staged).toHaveLength(1)
|
|
expect(data.staged[0].path).toBe("new.ts")
|
|
expect(data.staged[0].index).toBe("A")
|
|
})
|
|
|
|
it("should return staged files (modified)", async () => {
|
|
const mockGit = createMockGit({
|
|
status: createMockStatusResult({
|
|
files: [{ path: "src/index.ts", index: "M", working_dir: " " }],
|
|
isClean: () => false,
|
|
}),
|
|
})
|
|
const toolWithMock = new GitStatusTool(() => mockGit)
|
|
const ctx = createMockContext()
|
|
|
|
const result = await toolWithMock.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GitStatusResult
|
|
expect(data.staged).toHaveLength(1)
|
|
expect(data.staged[0].path).toBe("src/index.ts")
|
|
expect(data.staged[0].index).toBe("M")
|
|
})
|
|
|
|
it("should return staged files (deleted)", async () => {
|
|
const mockGit = createMockGit({
|
|
status: createMockStatusResult({
|
|
files: [{ path: "old.ts", index: "D", working_dir: " " }],
|
|
isClean: () => false,
|
|
}),
|
|
})
|
|
const toolWithMock = new GitStatusTool(() => mockGit)
|
|
const ctx = createMockContext()
|
|
|
|
const result = await toolWithMock.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GitStatusResult
|
|
expect(data.staged).toHaveLength(1)
|
|
expect(data.staged[0].index).toBe("D")
|
|
})
|
|
|
|
it("should return multiple staged files", async () => {
|
|
const mockGit = createMockGit({
|
|
status: createMockStatusResult({
|
|
files: [
|
|
{ path: "a.ts", index: "A", working_dir: " " },
|
|
{ path: "b.ts", index: "M", working_dir: " " },
|
|
{ path: "c.ts", index: "D", working_dir: " " },
|
|
],
|
|
isClean: () => false,
|
|
}),
|
|
})
|
|
const toolWithMock = new GitStatusTool(() => mockGit)
|
|
const ctx = createMockContext()
|
|
|
|
const result = await toolWithMock.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GitStatusResult
|
|
expect(data.staged).toHaveLength(3)
|
|
})
|
|
})
|
|
|
|
describe("modified files", () => {
|
|
it("should return modified unstaged files", async () => {
|
|
const mockGit = createMockGit({
|
|
status: createMockStatusResult({
|
|
files: [{ path: "src/app.ts", index: " ", working_dir: "M" }],
|
|
isClean: () => false,
|
|
}),
|
|
})
|
|
const toolWithMock = new GitStatusTool(() => mockGit)
|
|
const ctx = createMockContext()
|
|
|
|
const result = await toolWithMock.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GitStatusResult
|
|
expect(data.modified).toHaveLength(1)
|
|
expect(data.modified[0].path).toBe("src/app.ts")
|
|
expect(data.modified[0].workingDir).toBe("M")
|
|
})
|
|
|
|
it("should return deleted unstaged files", async () => {
|
|
const mockGit = createMockGit({
|
|
status: createMockStatusResult({
|
|
files: [{ path: "deleted.ts", index: " ", working_dir: "D" }],
|
|
isClean: () => false,
|
|
}),
|
|
})
|
|
const toolWithMock = new GitStatusTool(() => mockGit)
|
|
const ctx = createMockContext()
|
|
|
|
const result = await toolWithMock.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GitStatusResult
|
|
expect(data.modified).toHaveLength(1)
|
|
expect(data.modified[0].workingDir).toBe("D")
|
|
})
|
|
})
|
|
|
|
describe("untracked files", () => {
|
|
it("should return untracked files", async () => {
|
|
const mockGit = createMockGit({
|
|
status: createMockStatusResult({
|
|
not_added: ["new-file.ts", "another.js"],
|
|
isClean: () => false,
|
|
}),
|
|
})
|
|
const toolWithMock = new GitStatusTool(() => mockGit)
|
|
const ctx = createMockContext()
|
|
|
|
const result = await toolWithMock.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GitStatusResult
|
|
expect(data.untracked).toContain("new-file.ts")
|
|
expect(data.untracked).toContain("another.js")
|
|
})
|
|
})
|
|
|
|
describe("conflicted files", () => {
|
|
it("should return conflicted files", async () => {
|
|
const mockGit = createMockGit({
|
|
status: createMockStatusResult({
|
|
conflicted: ["conflict.ts"],
|
|
isClean: () => false,
|
|
}),
|
|
})
|
|
const toolWithMock = new GitStatusTool(() => mockGit)
|
|
const ctx = createMockContext()
|
|
|
|
const result = await toolWithMock.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GitStatusResult
|
|
expect(data.conflicted).toContain("conflict.ts")
|
|
})
|
|
})
|
|
|
|
describe("mixed status", () => {
|
|
it("should correctly categorize files with both staged and unstaged changes", async () => {
|
|
const mockGit = createMockGit({
|
|
status: createMockStatusResult({
|
|
files: [{ path: "both.ts", index: "M", working_dir: "M" }],
|
|
isClean: () => false,
|
|
}),
|
|
})
|
|
const toolWithMock = new GitStatusTool(() => mockGit)
|
|
const ctx = createMockContext()
|
|
|
|
const result = await toolWithMock.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GitStatusResult
|
|
expect(data.staged).toHaveLength(1)
|
|
expect(data.modified).toHaveLength(1)
|
|
expect(data.staged[0].path).toBe("both.ts")
|
|
expect(data.modified[0].path).toBe("both.ts")
|
|
})
|
|
|
|
it("should not include untracked in staged/modified", async () => {
|
|
const mockGit = createMockGit({
|
|
status: createMockStatusResult({
|
|
files: [{ path: "new.ts", index: "?", working_dir: "?" }],
|
|
not_added: ["new.ts"],
|
|
isClean: () => false,
|
|
}),
|
|
})
|
|
const toolWithMock = new GitStatusTool(() => mockGit)
|
|
const ctx = createMockContext()
|
|
|
|
const result = await toolWithMock.execute({}, ctx)
|
|
|
|
expect(result.success).toBe(true)
|
|
const data = result.data as GitStatusResult
|
|
expect(data.staged).toHaveLength(0)
|
|
expect(data.modified).toHaveLength(0)
|
|
expect(data.untracked).toContain("new.ts")
|
|
})
|
|
})
|
|
|
|
describe("error handling", () => {
|
|
it("should handle git command errors", async () => {
|
|
const mockGit = createMockGit({
|
|
error: new Error("Git command failed"),
|
|
})
|
|
const toolWithMock = new GitStatusTool(() => 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),
|
|
status: vi.fn().mockRejectedValue("string error"),
|
|
} as unknown as SimpleGit
|
|
const toolWithMock = new GitStatusTool(() => 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 GitStatusTool(() => mockGit)
|
|
const ctx = createMockContext()
|
|
|
|
const result = await toolWithMock.execute({}, ctx)
|
|
|
|
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
|
})
|
|
|
|
it("should include timing on error", async () => {
|
|
const mockGit = createMockGit({ error: new Error("fail") })
|
|
const toolWithMock = new GitStatusTool(() => 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 GitStatusTool(() => mockGit)
|
|
const ctx = createMockContext()
|
|
|
|
const result = await toolWithMock.execute({}, ctx)
|
|
|
|
expect(result.callId).toMatch(/^git_status-\d+$/)
|
|
})
|
|
})
|
|
})
|
|
})
|