mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +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,443 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
GitCommitTool,
|
||||
type GitCommitResult,
|
||||
} from "../../../../../src/infrastructure/tools/git/GitCommitTool.js"
|
||||
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||
import type { SimpleGit, CommitResult, 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,
|
||||
confirmResult: boolean = true,
|
||||
): ToolContext {
|
||||
return {
|
||||
projectRoot: "/test/project",
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(confirmResult),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
function createMockStatusResult(
|
||||
overrides: Partial<StatusResult> = {},
|
||||
): StatusResult {
|
||||
return {
|
||||
not_added: [],
|
||||
conflicted: [],
|
||||
created: [],
|
||||
deleted: [],
|
||||
ignored: [],
|
||||
modified: [],
|
||||
renamed: [],
|
||||
files: [],
|
||||
staged: ["file.ts"],
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
current: "main",
|
||||
tracking: "origin/main",
|
||||
detached: false,
|
||||
isClean: () => false,
|
||||
...overrides,
|
||||
} as StatusResult
|
||||
}
|
||||
|
||||
function createMockCommitResult(
|
||||
overrides: Partial<CommitResult> = {},
|
||||
): CommitResult {
|
||||
return {
|
||||
commit: "abc1234",
|
||||
branch: "main",
|
||||
root: false,
|
||||
author: null,
|
||||
summary: {
|
||||
changes: 1,
|
||||
insertions: 5,
|
||||
deletions: 2,
|
||||
},
|
||||
...overrides,
|
||||
} as CommitResult
|
||||
}
|
||||
|
||||
function createMockGit(options: {
|
||||
isRepo?: boolean
|
||||
status?: StatusResult
|
||||
commitResult?: CommitResult
|
||||
error?: Error
|
||||
addError?: Error
|
||||
}): SimpleGit {
|
||||
const mockGit = {
|
||||
checkIsRepo: vi.fn().mockResolvedValue(options.isRepo ?? true),
|
||||
status: vi.fn().mockResolvedValue(
|
||||
options.status ?? createMockStatusResult(),
|
||||
),
|
||||
add: vi.fn(),
|
||||
commit: vi.fn(),
|
||||
}
|
||||
|
||||
if (options.addError) {
|
||||
mockGit.add.mockRejectedValue(options.addError)
|
||||
} else {
|
||||
mockGit.add.mockResolvedValue(undefined)
|
||||
}
|
||||
|
||||
if (options.error) {
|
||||
mockGit.commit.mockRejectedValue(options.error)
|
||||
} else {
|
||||
mockGit.commit.mockResolvedValue(
|
||||
options.commitResult ?? createMockCommitResult(),
|
||||
)
|
||||
}
|
||||
|
||||
return mockGit as unknown as SimpleGit
|
||||
}
|
||||
|
||||
describe("GitCommitTool", () => {
|
||||
let tool: GitCommitTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new GitCommitTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("git_commit")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("git")
|
||||
})
|
||||
|
||||
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("message")
|
||||
expect(tool.parameters[0].required).toBe(true)
|
||||
expect(tool.parameters[1].name).toBe("files")
|
||||
expect(tool.parameters[1].required).toBe(false)
|
||||
})
|
||||
|
||||
it("should have description", () => {
|
||||
expect(tool.description).toContain("commit")
|
||||
expect(tool.description).toContain("confirmation")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return error for missing message", () => {
|
||||
expect(tool.validateParams({})).toContain("message")
|
||||
expect(tool.validateParams({})).toContain("required")
|
||||
})
|
||||
|
||||
it("should return error for non-string message", () => {
|
||||
expect(tool.validateParams({ message: 123 })).toContain("message")
|
||||
expect(tool.validateParams({ message: 123 })).toContain("string")
|
||||
})
|
||||
|
||||
it("should return error for empty message", () => {
|
||||
expect(tool.validateParams({ message: "" })).toContain("empty")
|
||||
expect(tool.validateParams({ message: " " })).toContain("empty")
|
||||
})
|
||||
|
||||
it("should return null for valid message", () => {
|
||||
expect(tool.validateParams({ message: "fix: bug" })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for valid message with files", () => {
|
||||
expect(
|
||||
tool.validateParams({ message: "fix: bug", files: ["a.ts", "b.ts"] }),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for non-array files", () => {
|
||||
expect(
|
||||
tool.validateParams({ message: "fix: bug", files: "a.ts" }),
|
||||
).toContain("array")
|
||||
})
|
||||
|
||||
it("should return error for non-string in files array", () => {
|
||||
expect(
|
||||
tool.validateParams({ message: "fix: bug", files: [1, 2] }),
|
||||
).toContain("strings")
|
||||
})
|
||||
})
|
||||
|
||||
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 GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Not a git repository")
|
||||
})
|
||||
})
|
||||
|
||||
describe("nothing to commit", () => {
|
||||
it("should return error when no staged files", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({ staged: [] }),
|
||||
})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Nothing to commit")
|
||||
})
|
||||
})
|
||||
|
||||
describe("with staged files", () => {
|
||||
it("should commit successfully", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({ staged: ["file.ts"] }),
|
||||
commitResult: createMockCommitResult({
|
||||
commit: "def5678",
|
||||
branch: "main",
|
||||
summary: { changes: 1, insertions: 10, deletions: 3 },
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "feat: new feature" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitCommitResult
|
||||
expect(data.hash).toBe("def5678")
|
||||
expect(data.branch).toBe("main")
|
||||
expect(data.message).toBe("feat: new feature")
|
||||
expect(data.filesChanged).toBe(1)
|
||||
expect(data.insertions).toBe(10)
|
||||
expect(data.deletions).toBe(3)
|
||||
})
|
||||
|
||||
it("should include author when available", async () => {
|
||||
const mockGit = createMockGit({
|
||||
commitResult: createMockCommitResult({
|
||||
author: {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
},
|
||||
}),
|
||||
})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as GitCommitResult
|
||||
expect(data.author).toEqual({
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("files parameter", () => {
|
||||
it("should stage specified files before commit", async () => {
|
||||
const mockGit = createMockGit({
|
||||
status: createMockStatusResult({ staged: [] }),
|
||||
})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMock.execute(
|
||||
{ message: "test", files: ["a.ts", "b.ts"] },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(mockGit.add).toHaveBeenCalledWith(["a.ts", "b.ts"])
|
||||
})
|
||||
|
||||
it("should not call add when files is empty", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMock.execute(
|
||||
{ message: "test", files: [] },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(mockGit.add).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should handle add errors", async () => {
|
||||
const mockGit = createMockGit({
|
||||
addError: new Error("Failed to add files"),
|
||||
})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test", files: ["nonexistent.ts"] },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Failed to add files")
|
||||
})
|
||||
})
|
||||
|
||||
describe("confirmation", () => {
|
||||
it("should request confirmation before commit", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMock.execute({ message: "test commit" }, ctx)
|
||||
|
||||
expect(ctx.requestConfirmation).toHaveBeenCalled()
|
||||
const confirmMessage = (ctx.requestConfirmation as ReturnType<typeof vi.fn>)
|
||||
.mock.calls[0][0] as string
|
||||
expect(confirmMessage).toContain("Committing")
|
||||
expect(confirmMessage).toContain("test commit")
|
||||
})
|
||||
|
||||
it("should cancel commit when user declines", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext(undefined, false)
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("cancelled")
|
||||
expect(mockGit.commit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should proceed with commit when user confirms", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext(undefined, true)
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockGit.commit).toHaveBeenCalledWith("test commit")
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle git command errors", async () => {
|
||||
const mockGit = createMockGit({
|
||||
error: new Error("Git commit failed"),
|
||||
})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("Git commit failed")
|
||||
})
|
||||
|
||||
it("should handle non-Error exceptions", async () => {
|
||||
const mockGit = {
|
||||
checkIsRepo: vi.fn().mockResolvedValue(true),
|
||||
status: vi.fn().mockResolvedValue(createMockStatusResult()),
|
||||
add: vi.fn(),
|
||||
commit: vi.fn().mockRejectedValue("string error"),
|
||||
} as unknown as SimpleGit
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
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 GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("call id", () => {
|
||||
it("should generate unique call id", async () => {
|
||||
const mockGit = createMockGit({})
|
||||
const toolWithMock = new GitCommitTool(() => mockGit)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ message: "test commit" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.callId).toMatch(/^git_commit-\d+$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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+$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,503 @@
|
||||
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+$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,368 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest"
|
||||
import {
|
||||
CommandSecurity,
|
||||
DEFAULT_BLACKLIST,
|
||||
DEFAULT_WHITELIST,
|
||||
} from "../../../../../src/infrastructure/tools/run/CommandSecurity.js"
|
||||
|
||||
describe("CommandSecurity", () => {
|
||||
let security: CommandSecurity
|
||||
|
||||
beforeEach(() => {
|
||||
security = new CommandSecurity()
|
||||
})
|
||||
|
||||
describe("constructor", () => {
|
||||
it("should use default blacklist and whitelist", () => {
|
||||
expect(security.getBlacklist()).toEqual(
|
||||
DEFAULT_BLACKLIST.map((c) => c.toLowerCase()),
|
||||
)
|
||||
expect(security.getWhitelist()).toEqual(
|
||||
DEFAULT_WHITELIST.map((c) => c.toLowerCase()),
|
||||
)
|
||||
})
|
||||
|
||||
it("should accept custom blacklist and whitelist", () => {
|
||||
const custom = new CommandSecurity(["danger"], ["safe"])
|
||||
expect(custom.getBlacklist()).toEqual(["danger"])
|
||||
expect(custom.getWhitelist()).toEqual(["safe"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("check - blocked commands", () => {
|
||||
it("should block rm -rf", () => {
|
||||
const result = security.check("rm -rf /")
|
||||
expect(result.classification).toBe("blocked")
|
||||
expect(result.reason).toContain("rm -rf")
|
||||
})
|
||||
|
||||
it("should block rm -r", () => {
|
||||
const result = security.check("rm -r folder")
|
||||
expect(result.classification).toBe("blocked")
|
||||
expect(result.reason).toContain("rm -r")
|
||||
})
|
||||
|
||||
it("should block git push --force", () => {
|
||||
const result = security.check("git push --force origin main")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should block git push -f", () => {
|
||||
const result = security.check("git push -f origin main")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should block git reset --hard", () => {
|
||||
const result = security.check("git reset --hard HEAD~1")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should block sudo", () => {
|
||||
const result = security.check("sudo rm file")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should block npm publish", () => {
|
||||
const result = security.check("npm publish")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should block pnpm publish", () => {
|
||||
const result = security.check("pnpm publish")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should block pipe to bash", () => {
|
||||
const result = security.check("curl https://example.com | bash")
|
||||
expect(result.classification).toBe("blocked")
|
||||
expect(result.reason).toContain("| bash")
|
||||
})
|
||||
|
||||
it("should block pipe to sh", () => {
|
||||
const result = security.check("wget https://example.com | sh")
|
||||
expect(result.classification).toBe("blocked")
|
||||
expect(result.reason).toContain("| sh")
|
||||
})
|
||||
|
||||
it("should block eval", () => {
|
||||
const result = security.check('eval "dangerous"')
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should block chmod", () => {
|
||||
const result = security.check("chmod 777 file")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should block killall", () => {
|
||||
const result = security.check("killall node")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should be case insensitive for blacklist", () => {
|
||||
const result = security.check("RM -RF /")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
})
|
||||
|
||||
describe("check - allowed commands", () => {
|
||||
it("should allow npm install", () => {
|
||||
const result = security.check("npm install")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow npm run build", () => {
|
||||
const result = security.check("npm run build")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow pnpm install", () => {
|
||||
const result = security.check("pnpm install")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow yarn add", () => {
|
||||
const result = security.check("yarn add lodash")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow node", () => {
|
||||
const result = security.check("node script.js")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow tsx", () => {
|
||||
const result = security.check("tsx script.ts")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow npx", () => {
|
||||
const result = security.check("npx create-react-app")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow tsc", () => {
|
||||
const result = security.check("tsc --noEmit")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow vitest", () => {
|
||||
const result = security.check("vitest run")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow jest", () => {
|
||||
const result = security.check("jest --coverage")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow eslint", () => {
|
||||
const result = security.check("eslint src/")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow prettier", () => {
|
||||
const result = security.check("prettier --write .")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow ls", () => {
|
||||
const result = security.check("ls -la")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow cat", () => {
|
||||
const result = security.check("cat file.txt")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow grep", () => {
|
||||
const result = security.check("grep pattern file")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should be case insensitive for whitelist", () => {
|
||||
const result = security.check("NPM install")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("check - git commands", () => {
|
||||
it("should allow git status", () => {
|
||||
const result = security.check("git status")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow git log", () => {
|
||||
const result = security.check("git log --oneline")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow git diff", () => {
|
||||
const result = security.check("git diff HEAD~1")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow git branch", () => {
|
||||
const result = security.check("git branch -a")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow git fetch", () => {
|
||||
const result = security.check("git fetch origin")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow git pull", () => {
|
||||
const result = security.check("git pull origin main")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should allow git stash", () => {
|
||||
const result = security.check("git stash")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should require confirmation for git commit", () => {
|
||||
const result = security.check("git commit -m 'message'")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for git push (without force)", () => {
|
||||
const result = security.check("git push origin main")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for git checkout", () => {
|
||||
const result = security.check("git checkout -b new-branch")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for git merge", () => {
|
||||
const result = security.check("git merge feature")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for git rebase", () => {
|
||||
const result = security.check("git rebase main")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for git without subcommand", () => {
|
||||
const result = security.check("git")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
})
|
||||
|
||||
describe("check - requires confirmation", () => {
|
||||
it("should require confirmation for unknown commands", () => {
|
||||
const result = security.check("unknown-command")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
expect(result.reason).toContain("not in the whitelist")
|
||||
})
|
||||
|
||||
it("should require confirmation for curl (without pipe)", () => {
|
||||
const result = security.check("curl https://example.com")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for wget (without pipe)", () => {
|
||||
const result = security.check("wget https://example.com")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for mkdir", () => {
|
||||
const result = security.check("mkdir new-folder")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for touch", () => {
|
||||
const result = security.check("touch new-file.txt")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for cp", () => {
|
||||
const result = security.check("cp file1 file2")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should require confirmation for mv", () => {
|
||||
const result = security.check("mv file1 file2")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
})
|
||||
|
||||
describe("addToBlacklist", () => {
|
||||
it("should add patterns to blacklist", () => {
|
||||
security.addToBlacklist(["danger"])
|
||||
expect(security.getBlacklist()).toContain("danger")
|
||||
})
|
||||
|
||||
it("should not add duplicates", () => {
|
||||
const initialLength = security.getBlacklist().length
|
||||
security.addToBlacklist(["rm -rf"])
|
||||
expect(security.getBlacklist().length).toBe(initialLength)
|
||||
})
|
||||
|
||||
it("should normalize to lowercase", () => {
|
||||
security.addToBlacklist(["DANGER"])
|
||||
expect(security.getBlacklist()).toContain("danger")
|
||||
})
|
||||
})
|
||||
|
||||
describe("addToWhitelist", () => {
|
||||
it("should add commands to whitelist", () => {
|
||||
security.addToWhitelist(["mycommand"])
|
||||
expect(security.getWhitelist()).toContain("mycommand")
|
||||
})
|
||||
|
||||
it("should not add duplicates", () => {
|
||||
const initialLength = security.getWhitelist().length
|
||||
security.addToWhitelist(["npm"])
|
||||
expect(security.getWhitelist().length).toBe(initialLength)
|
||||
})
|
||||
|
||||
it("should normalize to lowercase", () => {
|
||||
security.addToWhitelist(["MYCOMMAND"])
|
||||
expect(security.getWhitelist()).toContain("mycommand")
|
||||
})
|
||||
|
||||
it("should allow newly added commands", () => {
|
||||
security.addToWhitelist(["mycommand"])
|
||||
const result = security.check("mycommand arg1 arg2")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty command", () => {
|
||||
const result = security.check("")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should handle whitespace-only command", () => {
|
||||
const result = security.check(" ")
|
||||
expect(result.classification).toBe("requires_confirmation")
|
||||
})
|
||||
|
||||
it("should handle command with leading/trailing whitespace", () => {
|
||||
const result = security.check(" npm install ")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should handle command with multiple spaces", () => {
|
||||
const result = security.check("npm install lodash")
|
||||
expect(result.classification).toBe("allowed")
|
||||
})
|
||||
|
||||
it("should detect blocked pattern anywhere in command", () => {
|
||||
const result = security.check("echo test && rm -rf /")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
|
||||
it("should detect blocked pattern in subshell", () => {
|
||||
const result = security.check("$(rm -rf /)")
|
||||
expect(result.classification).toBe("blocked")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,505 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
RunCommandTool,
|
||||
type RunCommandResult,
|
||||
} from "../../../../../src/infrastructure/tools/run/RunCommandTool.js"
|
||||
import { CommandSecurity } from "../../../../../src/infrastructure/tools/run/CommandSecurity.js"
|
||||
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||
|
||||
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,
|
||||
confirmResult: boolean = true,
|
||||
): ToolContext {
|
||||
return {
|
||||
projectRoot: "/test/project",
|
||||
storage: storage ?? createMockStorage(),
|
||||
requestConfirmation: vi.fn().mockResolvedValue(confirmResult),
|
||||
onProgress: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
type ExecResult = { stdout: string; stderr: string }
|
||||
type ExecFn = (
|
||||
command: string,
|
||||
options: Record<string, unknown>,
|
||||
) => Promise<ExecResult>
|
||||
|
||||
function createMockExec(options: {
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
error?: Error & { code?: number; stdout?: string; stderr?: string }
|
||||
}): ExecFn {
|
||||
return vi.fn().mockImplementation(() => {
|
||||
if (options.error) {
|
||||
return Promise.reject(options.error)
|
||||
}
|
||||
return Promise.resolve({
|
||||
stdout: options.stdout ?? "",
|
||||
stderr: options.stderr ?? "",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe("RunCommandTool", () => {
|
||||
let tool: RunCommandTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new RunCommandTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("run_command")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("run")
|
||||
})
|
||||
|
||||
it("should not require confirmation (handled internally)", () => {
|
||||
expect(tool.requiresConfirmation).toBe(false)
|
||||
})
|
||||
|
||||
it("should have correct parameters", () => {
|
||||
expect(tool.parameters).toHaveLength(2)
|
||||
expect(tool.parameters[0].name).toBe("command")
|
||||
expect(tool.parameters[0].required).toBe(true)
|
||||
expect(tool.parameters[1].name).toBe("timeout")
|
||||
expect(tool.parameters[1].required).toBe(false)
|
||||
})
|
||||
|
||||
it("should have description", () => {
|
||||
expect(tool.description).toContain("shell command")
|
||||
expect(tool.description).toContain("security")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return error for missing command", () => {
|
||||
expect(tool.validateParams({})).toContain("command")
|
||||
expect(tool.validateParams({})).toContain("required")
|
||||
})
|
||||
|
||||
it("should return error for non-string command", () => {
|
||||
expect(tool.validateParams({ command: 123 })).toContain("string")
|
||||
})
|
||||
|
||||
it("should return error for empty command", () => {
|
||||
expect(tool.validateParams({ command: "" })).toContain("empty")
|
||||
expect(tool.validateParams({ command: " " })).toContain("empty")
|
||||
})
|
||||
|
||||
it("should return null for valid command", () => {
|
||||
expect(tool.validateParams({ command: "ls" })).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for non-number timeout", () => {
|
||||
expect(
|
||||
tool.validateParams({ command: "ls", timeout: "5000" }),
|
||||
).toContain("number")
|
||||
})
|
||||
|
||||
it("should return error for negative timeout", () => {
|
||||
expect(tool.validateParams({ command: "ls", timeout: -1 })).toContain(
|
||||
"positive",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for zero timeout", () => {
|
||||
expect(tool.validateParams({ command: "ls", timeout: 0 })).toContain(
|
||||
"positive",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return error for timeout > 10 minutes", () => {
|
||||
expect(
|
||||
tool.validateParams({ command: "ls", timeout: 600001 }),
|
||||
).toContain("600000")
|
||||
})
|
||||
|
||||
it("should return null for valid timeout", () => {
|
||||
expect(tool.validateParams({ command: "ls", timeout: 5000 })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute - blocked commands", () => {
|
||||
it("should block dangerous commands", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "rm -rf /" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("blocked")
|
||||
expect(execFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should block sudo commands", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "sudo apt-get" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("blocked")
|
||||
})
|
||||
|
||||
it("should block git push --force", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ command: "git push --force" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("blocked")
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute - allowed commands", () => {
|
||||
it("should execute whitelisted commands without confirmation", async () => {
|
||||
const execFn = createMockExec({ stdout: "output" })
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "npm install" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(ctx.requestConfirmation).not.toHaveBeenCalled()
|
||||
expect(execFn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should return stdout and stderr", async () => {
|
||||
const execFn = createMockExec({
|
||||
stdout: "standard output",
|
||||
stderr: "standard error",
|
||||
})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "npm run build" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunCommandResult
|
||||
expect(data.stdout).toBe("standard output")
|
||||
expect(data.stderr).toBe("standard error")
|
||||
expect(data.exitCode).toBe(0)
|
||||
expect(data.success).toBe(true)
|
||||
})
|
||||
|
||||
it("should mark requiredConfirmation as false", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunCommandResult
|
||||
expect(data.requiredConfirmation).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute - requires confirmation", () => {
|
||||
it("should request confirmation for unknown commands", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMock.execute({ command: "unknown-command" }, ctx)
|
||||
|
||||
expect(ctx.requestConfirmation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should execute after confirmation", async () => {
|
||||
const execFn = createMockExec({ stdout: "done" })
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext(undefined, true)
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ command: "custom-script" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunCommandResult
|
||||
expect(data.requiredConfirmation).toBe(true)
|
||||
expect(execFn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should cancel when user declines", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext(undefined, false)
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ command: "custom-script" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("cancelled")
|
||||
expect(execFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should require confirmation for git commit", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMock.execute({ command: "git commit -m 'test'" }, ctx)
|
||||
|
||||
expect(ctx.requestConfirmation).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute - error handling", () => {
|
||||
it("should handle command failure with exit code", async () => {
|
||||
const error = Object.assign(new Error("Command failed"), {
|
||||
code: 1,
|
||||
stdout: "partial output",
|
||||
stderr: "error message",
|
||||
})
|
||||
const execFn = createMockExec({ error })
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "npm test" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunCommandResult
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.exitCode).toBe(1)
|
||||
expect(data.stdout).toBe("partial output")
|
||||
expect(data.stderr).toBe("error message")
|
||||
})
|
||||
|
||||
it("should handle timeout", async () => {
|
||||
const error = new Error("Command timed out")
|
||||
const execFn = createMockExec({ error })
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("timed out")
|
||||
})
|
||||
|
||||
it("should handle ETIMEDOUT", async () => {
|
||||
const error = new Error("ETIMEDOUT")
|
||||
const execFn = createMockExec({ error })
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("timed out")
|
||||
})
|
||||
|
||||
it("should handle generic errors", async () => {
|
||||
const error = new Error("Something went wrong")
|
||||
const execFn = createMockExec({ error })
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Something went wrong")
|
||||
})
|
||||
|
||||
it("should handle non-Error exceptions", async () => {
|
||||
const execFn = vi.fn().mockRejectedValue("string error")
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("string error")
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute - options", () => {
|
||||
it("should use default timeout", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(execFn).toHaveBeenCalledWith(
|
||||
"ls",
|
||||
expect.objectContaining({ timeout: 30000 }),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use custom timeout", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMock.execute({ command: "ls", timeout: 5000 }, ctx)
|
||||
|
||||
expect(execFn).toHaveBeenCalledWith(
|
||||
"ls",
|
||||
expect.objectContaining({ timeout: 5000 }),
|
||||
)
|
||||
})
|
||||
|
||||
it("should execute in project root", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
ctx.projectRoot = "/my/project"
|
||||
|
||||
await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(execFn).toHaveBeenCalledWith(
|
||||
"ls",
|
||||
expect.objectContaining({ cwd: "/my/project" }),
|
||||
)
|
||||
})
|
||||
|
||||
it("should disable colors", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(execFn).toHaveBeenCalledWith(
|
||||
"ls",
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ FORCE_COLOR: "0" }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute - output truncation", () => {
|
||||
it("should truncate very long output", async () => {
|
||||
const longOutput = "x".repeat(200000)
|
||||
const execFn = createMockExec({ stdout: longOutput })
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunCommandResult
|
||||
expect(data.stdout.length).toBeLessThan(longOutput.length)
|
||||
expect(data.stdout).toContain("truncated")
|
||||
})
|
||||
|
||||
it("should not truncate normal output", async () => {
|
||||
const normalOutput = "normal output"
|
||||
const execFn = createMockExec({ stdout: normalOutput })
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunCommandResult
|
||||
expect(data.stdout).toBe(normalOutput)
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute - timing", () => {
|
||||
it("should return execution time", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunCommandResult
|
||||
expect(data.durationMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it("should return execution time ms in result", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.executionTimeMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute - call id", () => {
|
||||
it("should generate unique call id", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMock.execute({ command: "ls" }, ctx)
|
||||
|
||||
expect(result.callId).toMatch(/^run_command-\d+$/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getSecurity", () => {
|
||||
it("should return security instance", () => {
|
||||
const security = new CommandSecurity()
|
||||
const toolWithSecurity = new RunCommandTool(security)
|
||||
|
||||
expect(toolWithSecurity.getSecurity()).toBe(security)
|
||||
})
|
||||
|
||||
it("should allow modifying security", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const toolWithMock = new RunCommandTool(undefined, execFn)
|
||||
const ctx = createMockContext()
|
||||
|
||||
toolWithMock.getSecurity().addToWhitelist(["custom-safe"])
|
||||
|
||||
const result = await toolWithMock.execute(
|
||||
{ command: "custom-safe arg" },
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(ctx.requestConfirmation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,552 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
RunTestsTool,
|
||||
type RunTestsResult,
|
||||
type TestRunner,
|
||||
} from "../../../../../src/infrastructure/tools/run/RunTestsTool.js"
|
||||
import type { ToolContext } from "../../../../../src/domain/services/ITool.js"
|
||||
import type { IStorage } from "../../../../../src/domain/services/IStorage.js"
|
||||
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
type ExecResult = { stdout: string; stderr: string }
|
||||
type ExecFn = (
|
||||
command: string,
|
||||
options: Record<string, unknown>,
|
||||
) => Promise<ExecResult>
|
||||
|
||||
function createMockExec(options: {
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
error?: Error & { code?: number; stdout?: string; stderr?: string }
|
||||
}): ExecFn {
|
||||
return vi.fn().mockImplementation(() => {
|
||||
if (options.error) {
|
||||
return Promise.reject(options.error)
|
||||
}
|
||||
return Promise.resolve({
|
||||
stdout: options.stdout ?? "",
|
||||
stderr: options.stderr ?? "",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createMockFsAccess(existingFiles: string[]): typeof import("fs/promises").access {
|
||||
return vi.fn().mockImplementation((filePath: string) => {
|
||||
for (const file of existingFiles) {
|
||||
if (filePath.endsWith(file)) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
return Promise.reject(new Error("ENOENT"))
|
||||
})
|
||||
}
|
||||
|
||||
function createMockFsReadFile(
|
||||
packageJson?: Record<string, unknown>,
|
||||
): typeof import("fs/promises").readFile {
|
||||
return vi.fn().mockImplementation((filePath: string) => {
|
||||
if (filePath.endsWith("package.json") && packageJson) {
|
||||
return Promise.resolve(JSON.stringify(packageJson))
|
||||
}
|
||||
return Promise.reject(new Error("ENOENT"))
|
||||
})
|
||||
}
|
||||
|
||||
describe("RunTestsTool", () => {
|
||||
let tool: RunTestsTool
|
||||
|
||||
beforeEach(() => {
|
||||
tool = new RunTestsTool()
|
||||
})
|
||||
|
||||
describe("metadata", () => {
|
||||
it("should have correct name", () => {
|
||||
expect(tool.name).toBe("run_tests")
|
||||
})
|
||||
|
||||
it("should have correct category", () => {
|
||||
expect(tool.category).toBe("run")
|
||||
})
|
||||
|
||||
it("should not require confirmation", () => {
|
||||
expect(tool.requiresConfirmation).toBe(false)
|
||||
})
|
||||
|
||||
it("should have correct parameters", () => {
|
||||
expect(tool.parameters).toHaveLength(3)
|
||||
expect(tool.parameters[0].name).toBe("path")
|
||||
expect(tool.parameters[1].name).toBe("filter")
|
||||
expect(tool.parameters[2].name).toBe("watch")
|
||||
})
|
||||
|
||||
it("should have description", () => {
|
||||
expect(tool.description).toContain("test")
|
||||
expect(tool.description).toContain("vitest")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateParams", () => {
|
||||
it("should return null for empty params", () => {
|
||||
expect(tool.validateParams({})).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for valid params", () => {
|
||||
expect(
|
||||
tool.validateParams({ path: "src", filter: "login", watch: true }),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it("should return error for invalid path", () => {
|
||||
expect(tool.validateParams({ path: 123 })).toContain("path")
|
||||
})
|
||||
|
||||
it("should return error for invalid filter", () => {
|
||||
expect(tool.validateParams({ filter: 123 })).toContain("filter")
|
||||
})
|
||||
|
||||
it("should return error for invalid watch", () => {
|
||||
expect(tool.validateParams({ watch: "yes" })).toContain("watch")
|
||||
})
|
||||
})
|
||||
|
||||
describe("detectTestRunner", () => {
|
||||
it("should detect vitest from config file", async () => {
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
|
||||
|
||||
const runner = await toolWithMocks.detectTestRunner("/test/project")
|
||||
|
||||
expect(runner).toBe("vitest")
|
||||
})
|
||||
|
||||
it("should detect vitest from .js config", async () => {
|
||||
const fsAccess = createMockFsAccess(["vitest.config.js"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
|
||||
|
||||
const runner = await toolWithMocks.detectTestRunner("/test/project")
|
||||
|
||||
expect(runner).toBe("vitest")
|
||||
})
|
||||
|
||||
it("should detect vitest from .mts config", async () => {
|
||||
const fsAccess = createMockFsAccess(["vitest.config.mts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
|
||||
|
||||
const runner = await toolWithMocks.detectTestRunner("/test/project")
|
||||
|
||||
expect(runner).toBe("vitest")
|
||||
})
|
||||
|
||||
it("should detect jest from config file", async () => {
|
||||
const fsAccess = createMockFsAccess(["jest.config.js"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
|
||||
|
||||
const runner = await toolWithMocks.detectTestRunner("/test/project")
|
||||
|
||||
expect(runner).toBe("jest")
|
||||
})
|
||||
|
||||
it("should detect vitest from devDependencies", async () => {
|
||||
const fsAccess = createMockFsAccess([])
|
||||
const fsReadFile = createMockFsReadFile({
|
||||
devDependencies: { vitest: "^1.0.0" },
|
||||
})
|
||||
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
|
||||
|
||||
const runner = await toolWithMocks.detectTestRunner("/test/project")
|
||||
|
||||
expect(runner).toBe("vitest")
|
||||
})
|
||||
|
||||
it("should detect jest from devDependencies", async () => {
|
||||
const fsAccess = createMockFsAccess([])
|
||||
const fsReadFile = createMockFsReadFile({
|
||||
devDependencies: { jest: "^29.0.0" },
|
||||
})
|
||||
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
|
||||
|
||||
const runner = await toolWithMocks.detectTestRunner("/test/project")
|
||||
|
||||
expect(runner).toBe("jest")
|
||||
})
|
||||
|
||||
it("should detect mocha from devDependencies", async () => {
|
||||
const fsAccess = createMockFsAccess([])
|
||||
const fsReadFile = createMockFsReadFile({
|
||||
devDependencies: { mocha: "^10.0.0" },
|
||||
})
|
||||
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
|
||||
|
||||
const runner = await toolWithMocks.detectTestRunner("/test/project")
|
||||
|
||||
expect(runner).toBe("mocha")
|
||||
})
|
||||
|
||||
it("should detect npm test script as fallback", async () => {
|
||||
const fsAccess = createMockFsAccess([])
|
||||
const fsReadFile = createMockFsReadFile({
|
||||
scripts: { test: "node test.js" },
|
||||
})
|
||||
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
|
||||
|
||||
const runner = await toolWithMocks.detectTestRunner("/test/project")
|
||||
|
||||
expect(runner).toBe("npm")
|
||||
})
|
||||
|
||||
it("should return null when no runner found", async () => {
|
||||
const fsAccess = createMockFsAccess([])
|
||||
const fsReadFile = createMockFsReadFile({})
|
||||
const toolWithMocks = new RunTestsTool(undefined, fsAccess, fsReadFile)
|
||||
|
||||
const runner = await toolWithMocks.detectTestRunner("/test/project")
|
||||
|
||||
expect(runner).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildCommand", () => {
|
||||
describe("vitest", () => {
|
||||
it("should build basic vitest command", () => {
|
||||
const cmd = tool.buildCommand("vitest")
|
||||
expect(cmd).toBe("npx vitest run")
|
||||
})
|
||||
|
||||
it("should build vitest with path", () => {
|
||||
const cmd = tool.buildCommand("vitest", "src/tests")
|
||||
expect(cmd).toBe("npx vitest run src/tests")
|
||||
})
|
||||
|
||||
it("should build vitest with filter", () => {
|
||||
const cmd = tool.buildCommand("vitest", undefined, "login")
|
||||
expect(cmd).toBe('npx vitest run -t "login"')
|
||||
})
|
||||
|
||||
it("should build vitest with watch", () => {
|
||||
const cmd = tool.buildCommand("vitest", undefined, undefined, true)
|
||||
expect(cmd).toBe("npx vitest")
|
||||
})
|
||||
|
||||
it("should build vitest with all options", () => {
|
||||
const cmd = tool.buildCommand("vitest", "src", "login", true)
|
||||
expect(cmd).toBe('npx vitest src -t "login"')
|
||||
})
|
||||
})
|
||||
|
||||
describe("jest", () => {
|
||||
it("should build basic jest command", () => {
|
||||
const cmd = tool.buildCommand("jest")
|
||||
expect(cmd).toBe("npx jest")
|
||||
})
|
||||
|
||||
it("should build jest with path", () => {
|
||||
const cmd = tool.buildCommand("jest", "src/tests")
|
||||
expect(cmd).toBe("npx jest src/tests")
|
||||
})
|
||||
|
||||
it("should build jest with filter", () => {
|
||||
const cmd = tool.buildCommand("jest", undefined, "login")
|
||||
expect(cmd).toBe('npx jest -t "login"')
|
||||
})
|
||||
|
||||
it("should build jest with watch", () => {
|
||||
const cmd = tool.buildCommand("jest", undefined, undefined, true)
|
||||
expect(cmd).toBe("npx jest --watch")
|
||||
})
|
||||
})
|
||||
|
||||
describe("mocha", () => {
|
||||
it("should build basic mocha command", () => {
|
||||
const cmd = tool.buildCommand("mocha")
|
||||
expect(cmd).toBe("npx mocha")
|
||||
})
|
||||
|
||||
it("should build mocha with path", () => {
|
||||
const cmd = tool.buildCommand("mocha", "test/")
|
||||
expect(cmd).toBe("npx mocha test/")
|
||||
})
|
||||
|
||||
it("should build mocha with filter", () => {
|
||||
const cmd = tool.buildCommand("mocha", undefined, "login")
|
||||
expect(cmd).toBe('npx mocha --grep "login"')
|
||||
})
|
||||
|
||||
it("should build mocha with watch", () => {
|
||||
const cmd = tool.buildCommand("mocha", undefined, undefined, true)
|
||||
expect(cmd).toBe("npx mocha --watch")
|
||||
})
|
||||
})
|
||||
|
||||
describe("npm", () => {
|
||||
it("should build basic npm test command", () => {
|
||||
const cmd = tool.buildCommand("npm")
|
||||
expect(cmd).toBe("npm test")
|
||||
})
|
||||
|
||||
it("should build npm test with path", () => {
|
||||
const cmd = tool.buildCommand("npm", "src/tests")
|
||||
expect(cmd).toBe("npm test -- src/tests")
|
||||
})
|
||||
|
||||
it("should build npm test with filter", () => {
|
||||
const cmd = tool.buildCommand("npm", undefined, "login")
|
||||
expect(cmd).toBe('npm test -- "login"')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("execute", () => {
|
||||
describe("no runner detected", () => {
|
||||
it("should return error when no runner found", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const fsAccess = createMockFsAccess([])
|
||||
const fsReadFile = createMockFsReadFile({})
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("No test runner detected")
|
||||
})
|
||||
})
|
||||
|
||||
describe("successful tests", () => {
|
||||
it("should return success when tests pass", async () => {
|
||||
const execFn = createMockExec({
|
||||
stdout: "All tests passed",
|
||||
stderr: "",
|
||||
})
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunTestsResult
|
||||
expect(data.passed).toBe(true)
|
||||
expect(data.exitCode).toBe(0)
|
||||
expect(data.runner).toBe("vitest")
|
||||
expect(data.stdout).toContain("All tests passed")
|
||||
})
|
||||
|
||||
it("should include command in result", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunTestsResult
|
||||
expect(data.command).toBe("npx vitest run")
|
||||
})
|
||||
|
||||
it("should include duration in result", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunTestsResult
|
||||
expect(data.durationMs).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("failing tests", () => {
|
||||
it("should return success=true but passed=false for test failures", async () => {
|
||||
const error = Object.assign(new Error("Tests failed"), {
|
||||
code: 1,
|
||||
stdout: "1 test failed",
|
||||
stderr: "AssertionError",
|
||||
})
|
||||
const execFn = createMockExec({ error })
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunTestsResult
|
||||
expect(data.passed).toBe(false)
|
||||
expect(data.exitCode).toBe(1)
|
||||
expect(data.stdout).toContain("1 test failed")
|
||||
expect(data.stderr).toContain("AssertionError")
|
||||
})
|
||||
})
|
||||
|
||||
describe("with options", () => {
|
||||
it("should pass path to command", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({ path: "src/tests" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunTestsResult
|
||||
expect(data.command).toContain("src/tests")
|
||||
})
|
||||
|
||||
it("should pass filter to command", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({ filter: "login" }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunTestsResult
|
||||
expect(data.command).toContain('-t "login"')
|
||||
})
|
||||
|
||||
it("should pass watch option", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({ watch: true }, ctx)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const data = result.data as RunTestsResult
|
||||
expect(data.command).toBe("npx vitest")
|
||||
expect(data.command).not.toContain("run")
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle timeout", async () => {
|
||||
const error = new Error("Command timed out")
|
||||
const execFn = createMockExec({ error })
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("timed out")
|
||||
})
|
||||
|
||||
it("should handle generic errors", async () => {
|
||||
const error = new Error("Something went wrong")
|
||||
const execFn = createMockExec({ error })
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("Something went wrong")
|
||||
})
|
||||
})
|
||||
|
||||
describe("exec options", () => {
|
||||
it("should run in project root", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
ctx.projectRoot = "/my/project"
|
||||
|
||||
await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(execFn).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({ cwd: "/my/project" }),
|
||||
)
|
||||
})
|
||||
|
||||
it("should set CI environment variable", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(execFn).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({ CI: "true" }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("call id", () => {
|
||||
it("should generate unique call id", async () => {
|
||||
const execFn = createMockExec({})
|
||||
const fsAccess = createMockFsAccess(["vitest.config.ts"])
|
||||
const fsReadFile = createMockFsReadFile()
|
||||
const toolWithMocks = new RunTestsTool(execFn, fsAccess, fsReadFile)
|
||||
const ctx = createMockContext()
|
||||
|
||||
const result = await toolWithMocks.execute({}, ctx)
|
||||
|
||||
expect(result.callId).toMatch(/^run_tests-\d+$/)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user