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:
imfozilbek
2025-12-01 02:54:29 +05:00
parent 2ae1ac13f5
commit f5f904a847
16 changed files with 4153 additions and 0 deletions

View File

@@ -5,6 +5,73 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9.0] - 2025-12-01 - Git & Run Tools
### Added
- **GitStatusTool (0.9.1)**
- `git_status()`: Get current git repository status
- Returns branch name, tracking branch, ahead/behind counts
- Lists staged, modified, untracked, and conflicted files
- Detects detached HEAD state
- 29 unit tests
- **GitDiffTool (0.9.2)**
- `git_diff(path?, staged?)`: Get uncommitted changes
- Returns file-by-file diff summary with insertions/deletions
- Full diff text output
- Optional path filter for specific files/directories
- Staged-only mode (`--cached`)
- Handles binary files
- 25 unit tests
- **GitCommitTool (0.9.3)**
- `git_commit(message, files?)`: Create a git commit
- Requires user confirmation before commit
- Optional file staging before commit
- Returns commit hash, summary, author info
- Validates staged files exist
- 26 unit tests
- **CommandSecurity**
- Security module for shell command validation
- Blacklist: dangerous commands always blocked (rm -rf, sudo, git push --force, etc.)
- Whitelist: safe commands allowed without confirmation (npm, node, git status, etc.)
- Classification: `allowed`, `blocked`, `requires_confirmation`
- Git subcommand awareness (safe read operations vs write operations)
- Extensible via `addToBlacklist()` and `addToWhitelist()`
- 65 unit tests
- **RunCommandTool (0.9.4)**
- `run_command(command, timeout?)`: Execute shell commands
- Security-first design with blacklist/whitelist checks
- Blocked commands rejected immediately
- Unknown commands require user confirmation
- Configurable timeout (default 30s, max 10min)
- Output truncation for large outputs
- Returns stdout, stderr, exit code, duration
- 40 unit tests
- **RunTestsTool (0.9.5)**
- `run_tests(path?, filter?, watch?)`: Run project tests
- Auto-detects test runner: vitest, jest, mocha, npm test
- Detects by config files and package.json dependencies
- Path filtering for specific test files/directories
- Name pattern filtering (`-t` / `--grep`)
- Watch mode support
- Returns pass/fail status, exit code, output
- 48 unit tests
### Changed
- Total tests: 1086 (was 853)
- Coverage: 98.08% lines, 92.21% branches
- Git tools category now fully implemented (3/3 tools)
- Run tools category now fully implemented (2/2 tools)
- All 18 planned tools now implemented
---
## [0.8.0] - 2025-12-01 - Analysis Tools ## [0.8.0] - 2025-12-01 - Analysis Tools
### Added ### Added

View File

@@ -0,0 +1,155 @@
import { type CommitResult, type SimpleGit, simpleGit } from "simple-git"
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
import {
createErrorResult,
createSuccessResult,
type ToolResult,
} from "../../../domain/value-objects/ToolResult.js"
/**
* Author information.
*/
export interface CommitAuthor {
name: string
email: string
}
/**
* Result data from git_commit tool.
*/
export interface GitCommitResult {
/** Commit hash */
hash: string
/** Current branch */
branch: string
/** Commit message */
message: string
/** Number of files changed */
filesChanged: number
/** Number of insertions */
insertions: number
/** Number of deletions */
deletions: number
/** Author information */
author: CommitAuthor | null
}
/**
* Tool for creating git commits.
* Requires confirmation before execution.
*/
export class GitCommitTool implements ITool {
readonly name = "git_commit"
readonly description =
"Create a git commit with the specified message. " +
"Will ask for confirmation. Optionally stage specific files first."
readonly parameters: ToolParameterSchema[] = [
{
name: "message",
type: "string",
description: "Commit message",
required: true,
},
{
name: "files",
type: "array",
description: "Files to stage before commit (optional, defaults to all staged)",
required: false,
},
]
readonly requiresConfirmation = true
readonly category = "git" as const
private readonly gitFactory: (basePath: string) => SimpleGit
constructor(gitFactory?: (basePath: string) => SimpleGit) {
this.gitFactory = gitFactory ?? ((basePath: string) => simpleGit(basePath))
}
validateParams(params: Record<string, unknown>): string | null {
if (params.message === undefined) {
return "Parameter 'message' is required"
}
if (typeof params.message !== "string") {
return "Parameter 'message' must be a string"
}
if (params.message.trim() === "") {
return "Parameter 'message' cannot be empty"
}
if (params.files !== undefined) {
if (!Array.isArray(params.files)) {
return "Parameter 'files' must be an array"
}
for (const file of params.files) {
if (typeof file !== "string") {
return "Parameter 'files' must be an array of strings"
}
}
}
return null
}
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const startTime = Date.now()
const callId = `${this.name}-${String(startTime)}`
const message = params.message as string
const files = params.files as string[] | undefined
try {
const git = this.gitFactory(ctx.projectRoot)
const isRepo = await git.checkIsRepo()
if (!isRepo) {
return createErrorResult(
callId,
"Not a git repository. Initialize with 'git init' first.",
Date.now() - startTime,
)
}
if (files && files.length > 0) {
await git.add(files)
}
const status = await git.status()
if (status.staged.length === 0 && (!files || files.length === 0)) {
return createErrorResult(
callId,
"Nothing to commit. Stage files first with 'git add' or provide 'files' parameter.",
Date.now() - startTime,
)
}
const commitSummary = `Committing ${String(status.staged.length)} file(s): ${message}`
const confirmed = await ctx.requestConfirmation(commitSummary)
if (!confirmed) {
return createErrorResult(callId, "Commit cancelled by user", Date.now() - startTime)
}
const commitResult = await git.commit(message)
const result = this.formatCommitResult(commitResult, message)
return createSuccessResult(callId, result, Date.now() - startTime)
} catch (error) {
const message_ = error instanceof Error ? error.message : String(error)
return createErrorResult(callId, message_, Date.now() - startTime)
}
}
/**
* Format simple-git CommitResult into our result structure.
*/
private formatCommitResult(commit: CommitResult, message: string): GitCommitResult {
return {
hash: commit.commit,
branch: commit.branch,
message,
filesChanged: commit.summary.changes,
insertions: commit.summary.insertions,
deletions: commit.summary.deletions,
author: commit.author ?? null,
}
}
}

View File

@@ -0,0 +1,155 @@
import { simpleGit, type SimpleGit } from "simple-git"
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
import {
createErrorResult,
createSuccessResult,
type ToolResult,
} from "../../../domain/value-objects/ToolResult.js"
/**
* A single file diff entry.
*/
export interface DiffEntry {
/** File path */
file: string
/** Number of insertions */
insertions: number
/** Number of deletions */
deletions: number
/** Whether the file is binary */
binary: boolean
}
/**
* Result data from git_diff tool.
*/
export interface GitDiffResult {
/** Whether showing staged or all changes */
staged: boolean
/** Path filter applied (null if all files) */
pathFilter: string | null
/** Whether there are any changes */
hasChanges: boolean
/** Summary of changes */
summary: {
/** Number of files changed */
filesChanged: number
/** Total insertions */
insertions: number
/** Total deletions */
deletions: number
}
/** List of changed files */
files: DiffEntry[]
/** Full diff text */
diff: string
}
/**
* Tool for getting uncommitted git changes (diff).
* Shows what has changed but not yet committed.
*/
export class GitDiffTool implements ITool {
readonly name = "git_diff"
readonly description =
"Get uncommitted changes (diff). " + "Shows what has changed but not yet committed."
readonly parameters: ToolParameterSchema[] = [
{
name: "path",
type: "string",
description: "Limit diff to specific file or directory",
required: false,
},
{
name: "staged",
type: "boolean",
description: "Show only staged changes (default: false, shows all)",
required: false,
},
]
readonly requiresConfirmation = false
readonly category = "git" as const
private readonly gitFactory: (basePath: string) => SimpleGit
constructor(gitFactory?: (basePath: string) => SimpleGit) {
this.gitFactory = gitFactory ?? ((basePath: string) => simpleGit(basePath))
}
validateParams(params: Record<string, unknown>): string | null {
if (params.path !== undefined && typeof params.path !== "string") {
return "Parameter 'path' must be a string"
}
if (params.staged !== undefined && typeof params.staged !== "boolean") {
return "Parameter 'staged' must be a boolean"
}
return null
}
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const startTime = Date.now()
const callId = `${this.name}-${String(startTime)}`
const pathFilter = (params.path as string) ?? null
const staged = (params.staged as boolean) ?? false
try {
const git = this.gitFactory(ctx.projectRoot)
const isRepo = await git.checkIsRepo()
if (!isRepo) {
return createErrorResult(
callId,
"Not a git repository. Initialize with 'git init' first.",
Date.now() - startTime,
)
}
const diffArgs = this.buildDiffArgs(staged, pathFilter)
const diffSummary = await git.diffSummary(diffArgs)
const diffText = await git.diff(diffArgs)
const files: DiffEntry[] = diffSummary.files.map((f) => ({
file: f.file,
insertions: "insertions" in f ? f.insertions : 0,
deletions: "deletions" in f ? f.deletions : 0,
binary: f.binary,
}))
const result: GitDiffResult = {
staged,
pathFilter,
hasChanges: diffSummary.files.length > 0,
summary: {
filesChanged: diffSummary.files.length,
insertions: diffSummary.insertions,
deletions: diffSummary.deletions,
},
files,
diff: diffText,
}
return createSuccessResult(callId, result, Date.now() - startTime)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return createErrorResult(callId, message, Date.now() - startTime)
}
}
/**
* Build diff arguments array.
*/
private buildDiffArgs(staged: boolean, pathFilter: string | null): string[] {
const args: string[] = []
if (staged) {
args.push("--cached")
}
if (pathFilter) {
args.push("--", pathFilter)
}
return args
}
}

View File

@@ -0,0 +1,129 @@
import { simpleGit, type SimpleGit, type StatusResult } from "simple-git"
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
import {
createErrorResult,
createSuccessResult,
type ToolResult,
} from "../../../domain/value-objects/ToolResult.js"
/**
* File status entry in git status.
*/
export interface FileStatusEntry {
/** Relative file path */
path: string
/** Working directory status (modified, deleted, etc.) */
workingDir: string
/** Index/staging status */
index: string
}
/**
* Result data from git_status tool.
*/
export interface GitStatusResult {
/** Current branch name */
branch: string
/** Tracking branch (e.g., origin/main) */
tracking: string | null
/** Number of commits ahead of tracking */
ahead: number
/** Number of commits behind tracking */
behind: number
/** Files staged for commit */
staged: FileStatusEntry[]
/** Modified files not staged */
modified: FileStatusEntry[]
/** Untracked files */
untracked: string[]
/** Files with merge conflicts */
conflicted: string[]
/** Whether working directory is clean */
isClean: boolean
}
/**
* Tool for getting git repository status.
* Returns branch info, staged/modified/untracked files.
*/
export class GitStatusTool implements ITool {
readonly name = "git_status"
readonly description =
"Get current git repository status. " +
"Returns branch name, staged files, modified files, and untracked files."
readonly parameters: ToolParameterSchema[] = []
readonly requiresConfirmation = false
readonly category = "git" as const
private readonly gitFactory: (basePath: string) => SimpleGit
constructor(gitFactory?: (basePath: string) => SimpleGit) {
this.gitFactory = gitFactory ?? ((basePath: string) => simpleGit(basePath))
}
validateParams(_params: Record<string, unknown>): string | null {
return null
}
async execute(_params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const startTime = Date.now()
const callId = `${this.name}-${String(startTime)}`
try {
const git = this.gitFactory(ctx.projectRoot)
const isRepo = await git.checkIsRepo()
if (!isRepo) {
return createErrorResult(
callId,
"Not a git repository. Initialize with 'git init' first.",
Date.now() - startTime,
)
}
const status = await git.status()
const result = this.formatStatus(status)
return createSuccessResult(callId, result, Date.now() - startTime)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return createErrorResult(callId, message, Date.now() - startTime)
}
}
/**
* Format simple-git StatusResult into our result structure.
*/
private formatStatus(status: StatusResult): GitStatusResult {
const staged: FileStatusEntry[] = []
const modified: FileStatusEntry[] = []
for (const file of status.files) {
const entry: FileStatusEntry = {
path: file.path,
workingDir: file.working_dir,
index: file.index,
}
if (file.index !== " " && file.index !== "?") {
staged.push(entry)
}
if (file.working_dir !== " " && file.working_dir !== "?") {
modified.push(entry)
}
}
return {
branch: status.current ?? "HEAD (detached)",
tracking: status.tracking ?? null,
ahead: status.ahead,
behind: status.behind,
staged,
modified,
untracked: status.not_added,
conflicted: status.conflicted,
isClean: status.isClean(),
}
}
}

View File

@@ -0,0 +1,6 @@
// Git tools exports
export { GitStatusTool, type GitStatusResult, type FileStatusEntry } from "./GitStatusTool.js"
export { GitDiffTool, type GitDiffResult, type DiffEntry } from "./GitDiffTool.js"
export { GitCommitTool, type GitCommitResult, type CommitAuthor } from "./GitCommitTool.js"

View File

@@ -53,3 +53,23 @@ export {
type TodoEntry, type TodoEntry,
type TodoType, type TodoType,
} from "./analysis/GetTodosTool.js" } from "./analysis/GetTodosTool.js"
// Git tools
export { GitStatusTool, type GitStatusResult, type FileStatusEntry } from "./git/GitStatusTool.js"
export { GitDiffTool, type GitDiffResult, type DiffEntry } from "./git/GitDiffTool.js"
export { GitCommitTool, type GitCommitResult, type CommitAuthor } from "./git/GitCommitTool.js"
// Run tools
export {
CommandSecurity,
DEFAULT_BLACKLIST,
DEFAULT_WHITELIST,
type CommandClassification,
type SecurityCheckResult,
} from "./run/CommandSecurity.js"
export { RunCommandTool, type RunCommandResult } from "./run/RunCommandTool.js"
export { RunTestsTool, type RunTestsResult, type TestRunner } from "./run/RunTestsTool.js"

View File

@@ -0,0 +1,257 @@
/**
* Command security classification.
*/
export type CommandClassification = "allowed" | "blocked" | "requires_confirmation"
/**
* Result of command security check.
*/
export interface SecurityCheckResult {
/** Classification of the command */
classification: CommandClassification
/** Reason for the classification */
reason: string
}
/**
* Dangerous commands that are always blocked.
* These commands can cause data loss or security issues.
*/
export const DEFAULT_BLACKLIST: string[] = [
// Destructive file operations
"rm -rf",
"rm -r",
"rm -fr",
"rmdir",
// Dangerous git operations
"git push --force",
"git push -f",
"git reset --hard",
"git clean -fd",
"git clean -f",
// Publishing/deployment
"npm publish",
"yarn publish",
"pnpm publish",
// System commands
"sudo",
"su ",
"chmod",
"chown",
// Network/download commands that could be dangerous
"| sh",
"| bash",
// Environment manipulation
"export ",
"unset ",
// Process control
"kill -9",
"killall",
"pkill",
// Disk operations (require exact command start)
"mkfs",
"fdisk",
// Other dangerous
":(){ :|:& };:",
"eval ",
]
/**
* Safe commands that don't require confirmation.
* Matched by first word (command name).
*/
export const DEFAULT_WHITELIST: string[] = [
// Package managers
"npm",
"pnpm",
"yarn",
"npx",
"bun",
// Node.js
"node",
"tsx",
"ts-node",
// Git (read operations)
"git",
// Build tools
"tsc",
"tsup",
"esbuild",
"vite",
"webpack",
"rollup",
// Testing
"vitest",
"jest",
"mocha",
"playwright",
"cypress",
// Linting/formatting
"eslint",
"prettier",
"biome",
// Utilities
"echo",
"cat",
"ls",
"pwd",
"which",
"head",
"tail",
"grep",
"find",
"wc",
"sort",
"diff",
]
/**
* Git subcommands that are safe and don't need confirmation.
*/
const SAFE_GIT_SUBCOMMANDS: string[] = [
"status",
"log",
"diff",
"show",
"branch",
"remote",
"fetch",
"pull",
"stash",
"tag",
"blame",
"ls-files",
"ls-tree",
"rev-parse",
"describe",
]
/**
* Command security checker.
* Determines if a command is safe to execute, blocked, or requires confirmation.
*/
export class CommandSecurity {
private readonly blacklist: string[]
private readonly whitelist: string[]
constructor(blacklist: string[] = DEFAULT_BLACKLIST, whitelist: string[] = DEFAULT_WHITELIST) {
this.blacklist = blacklist.map((cmd) => cmd.toLowerCase())
this.whitelist = whitelist.map((cmd) => cmd.toLowerCase())
}
/**
* Check if a command is safe to execute.
*/
check(command: string): SecurityCheckResult {
const normalized = command.trim().toLowerCase()
const blacklistMatch = this.isBlacklisted(normalized)
if (blacklistMatch) {
return {
classification: "blocked",
reason: `Command contains blocked pattern: '${blacklistMatch}'`,
}
}
if (this.isWhitelisted(normalized)) {
return {
classification: "allowed",
reason: "Command is in the whitelist",
}
}
return {
classification: "requires_confirmation",
reason: "Command is not in the whitelist and requires user confirmation",
}
}
/**
* Check if command matches any blacklist pattern.
* Returns the matched pattern or null.
*/
private isBlacklisted(command: string): string | null {
for (const pattern of this.blacklist) {
if (command.includes(pattern)) {
return pattern
}
}
return null
}
/**
* Check if command's first word is in the whitelist.
*/
private isWhitelisted(command: string): boolean {
const firstWord = this.getFirstWord(command)
if (!this.whitelist.includes(firstWord)) {
return false
}
if (firstWord === "git") {
return this.isGitCommandSafe(command)
}
return true
}
/**
* Check if git command is safe (read-only operations).
*/
private isGitCommandSafe(command: string): boolean {
const parts = command.split(/\s+/)
if (parts.length < 2) {
return false
}
const subcommand = parts[1]
return SAFE_GIT_SUBCOMMANDS.includes(subcommand)
}
/**
* Get first word from command.
*/
private getFirstWord(command: string): string {
const match = /^(\S+)/.exec(command)
return match ? match[1] : ""
}
/**
* Add patterns to the blacklist.
*/
addToBlacklist(patterns: string[]): void {
for (const pattern of patterns) {
const normalized = pattern.toLowerCase()
if (!this.blacklist.includes(normalized)) {
this.blacklist.push(normalized)
}
}
}
/**
* Add commands to the whitelist.
*/
addToWhitelist(commands: string[]): void {
for (const cmd of commands) {
const normalized = cmd.toLowerCase()
if (!this.whitelist.includes(normalized)) {
this.whitelist.push(normalized)
}
}
}
/**
* Get current blacklist.
*/
getBlacklist(): string[] {
return [...this.blacklist]
}
/**
* Get current whitelist.
*/
getWhitelist(): string[] {
return [...this.whitelist]
}
}

View File

@@ -0,0 +1,227 @@
import { exec } from "node:child_process"
import { promisify } from "node:util"
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
import {
createErrorResult,
createSuccessResult,
type ToolResult,
} from "../../../domain/value-objects/ToolResult.js"
import { CommandSecurity } from "./CommandSecurity.js"
const execAsync = promisify(exec)
/**
* Result data from run_command tool.
*/
export interface RunCommandResult {
/** The command that was executed */
command: string
/** Exit code (0 = success) */
exitCode: number
/** Standard output */
stdout: string
/** Standard error output */
stderr: string
/** Whether command was successful (exit code 0) */
success: boolean
/** Execution time in milliseconds */
durationMs: number
/** Whether user confirmation was required */
requiredConfirmation: boolean
}
/**
* Default command timeout in milliseconds.
*/
const DEFAULT_TIMEOUT = 30000
/**
* Maximum output size in characters.
*/
const MAX_OUTPUT_SIZE = 100000
/**
* Tool for executing shell commands.
* Commands are checked against blacklist/whitelist for security.
*/
export class RunCommandTool implements ITool {
readonly name = "run_command"
readonly description =
"Execute a shell command in the project directory. " +
"Commands are checked against blacklist/whitelist for security. " +
"Unknown commands require user confirmation."
readonly parameters: ToolParameterSchema[] = [
{
name: "command",
type: "string",
description: "Shell command to execute",
required: true,
},
{
name: "timeout",
type: "number",
description: "Timeout in milliseconds (default: 30000)",
required: false,
},
]
readonly requiresConfirmation = false
readonly category = "run" as const
private readonly security: CommandSecurity
private readonly execFn: typeof execAsync
constructor(security?: CommandSecurity, execFn?: typeof execAsync) {
this.security = security ?? new CommandSecurity()
this.execFn = execFn ?? execAsync
}
validateParams(params: Record<string, unknown>): string | null {
if (params.command === undefined) {
return "Parameter 'command' is required"
}
if (typeof params.command !== "string") {
return "Parameter 'command' must be a string"
}
if (params.command.trim() === "") {
return "Parameter 'command' cannot be empty"
}
if (params.timeout !== undefined) {
if (typeof params.timeout !== "number") {
return "Parameter 'timeout' must be a number"
}
if (params.timeout <= 0) {
return "Parameter 'timeout' must be positive"
}
if (params.timeout > 600000) {
return "Parameter 'timeout' cannot exceed 600000ms (10 minutes)"
}
}
return null
}
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const startTime = Date.now()
const callId = `${this.name}-${String(startTime)}`
const command = params.command as string
const timeout = (params.timeout as number) ?? DEFAULT_TIMEOUT
const securityCheck = this.security.check(command)
if (securityCheck.classification === "blocked") {
return createErrorResult(
callId,
`Command blocked for security: ${securityCheck.reason}`,
Date.now() - startTime,
)
}
let requiredConfirmation = false
if (securityCheck.classification === "requires_confirmation") {
requiredConfirmation = true
const confirmed = await ctx.requestConfirmation(
`Execute command: ${command}\n\nReason: ${securityCheck.reason}`,
)
if (!confirmed) {
return createErrorResult(
callId,
"Command execution cancelled by user",
Date.now() - startTime,
)
}
}
try {
const execStartTime = Date.now()
const { stdout, stderr } = await this.execFn(command, {
cwd: ctx.projectRoot,
timeout,
maxBuffer: MAX_OUTPUT_SIZE,
env: { ...process.env, FORCE_COLOR: "0" },
})
const durationMs = Date.now() - execStartTime
const result: RunCommandResult = {
command,
exitCode: 0,
stdout: this.truncateOutput(stdout),
stderr: this.truncateOutput(stderr),
success: true,
durationMs,
requiredConfirmation,
}
return createSuccessResult(callId, result, Date.now() - startTime)
} catch (error) {
return this.handleExecError(callId, command, error, requiredConfirmation, startTime)
}
}
/**
* Handle exec errors and return appropriate result.
*/
private handleExecError(
callId: string,
command: string,
error: unknown,
requiredConfirmation: boolean,
startTime: number,
): ToolResult {
if (this.isExecError(error)) {
const result: RunCommandResult = {
command,
exitCode: error.code ?? 1,
stdout: this.truncateOutput(error.stdout ?? ""),
stderr: this.truncateOutput(error.stderr ?? error.message),
success: false,
durationMs: Date.now() - startTime,
requiredConfirmation,
}
return createSuccessResult(callId, result, Date.now() - startTime)
}
if (error instanceof Error) {
if (error.message.includes("ETIMEDOUT") || error.message.includes("timed out")) {
return createErrorResult(
callId,
`Command timed out: ${command}`,
Date.now() - startTime,
)
}
return createErrorResult(callId, error.message, Date.now() - startTime)
}
return createErrorResult(callId, String(error), Date.now() - startTime)
}
/**
* Type guard for exec error.
*/
private isExecError(
error: unknown,
): error is Error & { code?: number; stdout?: string; stderr?: string } {
return error instanceof Error && "code" in error
}
/**
* Truncate output if too large.
*/
private truncateOutput(output: string): string {
if (output.length <= MAX_OUTPUT_SIZE) {
return output
}
return `${output.slice(0, MAX_OUTPUT_SIZE)}\n... (output truncated)`
}
/**
* Get the security checker instance.
*/
getSecurity(): CommandSecurity {
return this.security
}
}

View File

@@ -0,0 +1,353 @@
import { exec } from "node:child_process"
import { promisify } from "node:util"
import * as path from "node:path"
import * as fs from "node:fs/promises"
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
import {
createErrorResult,
createSuccessResult,
type ToolResult,
} from "../../../domain/value-objects/ToolResult.js"
const execAsync = promisify(exec)
/**
* Supported test runners.
*/
export type TestRunner = "vitest" | "jest" | "mocha" | "npm"
/**
* Result data from run_tests tool.
*/
export interface RunTestsResult {
/** Test runner that was used */
runner: TestRunner
/** Command that was executed */
command: string
/** Whether all tests passed */
passed: boolean
/** Exit code */
exitCode: number
/** Standard output */
stdout: string
/** Standard error output */
stderr: string
/** Execution time in milliseconds */
durationMs: number
}
/**
* Default test timeout in milliseconds (5 minutes).
*/
const DEFAULT_TIMEOUT = 300000
/**
* Maximum output size in characters.
*/
const MAX_OUTPUT_SIZE = 200000
/**
* Tool for running project tests.
* Auto-detects test runner (vitest, jest, mocha, npm test).
*/
export class RunTestsTool implements ITool {
readonly name = "run_tests"
readonly description =
"Run the project's test suite. Auto-detects test runner (vitest, jest, npm test). " +
"Returns test results summary."
readonly parameters: ToolParameterSchema[] = [
{
name: "path",
type: "string",
description: "Run tests for specific file or directory",
required: false,
},
{
name: "filter",
type: "string",
description: "Filter tests by name pattern",
required: false,
},
{
name: "watch",
type: "boolean",
description: "Run in watch mode (default: false)",
required: false,
},
]
readonly requiresConfirmation = false
readonly category = "run" as const
private readonly execFn: typeof execAsync
private readonly fsAccess: typeof fs.access
private readonly fsReadFile: typeof fs.readFile
constructor(
execFn?: typeof execAsync,
fsAccess?: typeof fs.access,
fsReadFile?: typeof fs.readFile,
) {
this.execFn = execFn ?? execAsync
this.fsAccess = fsAccess ?? fs.access
this.fsReadFile = fsReadFile ?? fs.readFile
}
validateParams(params: Record<string, unknown>): string | null {
if (params.path !== undefined && typeof params.path !== "string") {
return "Parameter 'path' must be a string"
}
if (params.filter !== undefined && typeof params.filter !== "string") {
return "Parameter 'filter' must be a string"
}
if (params.watch !== undefined && typeof params.watch !== "boolean") {
return "Parameter 'watch' must be a boolean"
}
return null
}
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
const startTime = Date.now()
const callId = `${this.name}-${String(startTime)}`
const testPath = params.path as string | undefined
const filter = params.filter as string | undefined
const watch = (params.watch as boolean) ?? false
try {
const runner = await this.detectTestRunner(ctx.projectRoot)
if (!runner) {
return createErrorResult(
callId,
"No test runner detected. Ensure vitest, jest, or mocha is installed, or 'test' script exists in package.json.",
Date.now() - startTime,
)
}
const command = this.buildCommand(runner, testPath, filter, watch)
const execStartTime = Date.now()
try {
const { stdout, stderr } = await this.execFn(command, {
cwd: ctx.projectRoot,
timeout: DEFAULT_TIMEOUT,
maxBuffer: MAX_OUTPUT_SIZE,
env: { ...process.env, FORCE_COLOR: "0", CI: "true" },
})
const durationMs = Date.now() - execStartTime
const result: RunTestsResult = {
runner,
command,
passed: true,
exitCode: 0,
stdout: this.truncateOutput(stdout),
stderr: this.truncateOutput(stderr),
durationMs,
}
return createSuccessResult(callId, result, Date.now() - startTime)
} catch (error) {
return this.handleExecError(
callId,
runner,
command,
error,
execStartTime,
startTime,
)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return createErrorResult(callId, message, Date.now() - startTime)
}
}
/**
* Detect which test runner is available in the project.
*/
async detectTestRunner(projectRoot: string): Promise<TestRunner | null> {
if (await this.hasFile(projectRoot, "vitest.config.ts")) {
return "vitest"
}
if (await this.hasFile(projectRoot, "vitest.config.js")) {
return "vitest"
}
if (await this.hasFile(projectRoot, "vitest.config.mts")) {
return "vitest"
}
if (await this.hasFile(projectRoot, "jest.config.js")) {
return "jest"
}
if (await this.hasFile(projectRoot, "jest.config.ts")) {
return "jest"
}
if (await this.hasFile(projectRoot, "jest.config.json")) {
return "jest"
}
const packageJsonPath = path.join(projectRoot, "package.json")
try {
const content = await this.fsReadFile(packageJsonPath, "utf-8")
const pkg = JSON.parse(content) as {
scripts?: Record<string, string>
devDependencies?: Record<string, string>
dependencies?: Record<string, string>
}
if (pkg.devDependencies?.vitest || pkg.dependencies?.vitest) {
return "vitest"
}
if (pkg.devDependencies?.jest || pkg.dependencies?.jest) {
return "jest"
}
if (pkg.devDependencies?.mocha || pkg.dependencies?.mocha) {
return "mocha"
}
if (pkg.scripts?.test) {
return "npm"
}
} catch {
// package.json doesn't exist or is invalid
}
return null
}
/**
* Build the test command based on runner and options.
*/
buildCommand(runner: TestRunner, testPath?: string, filter?: string, watch?: boolean): string {
const parts: string[] = []
switch (runner) {
case "vitest":
parts.push("npx vitest")
if (!watch) {
parts.push("run")
}
if (testPath) {
parts.push(testPath)
}
if (filter) {
parts.push("-t", `"${filter}"`)
}
break
case "jest":
parts.push("npx jest")
if (testPath) {
parts.push(testPath)
}
if (filter) {
parts.push("-t", `"${filter}"`)
}
if (watch) {
parts.push("--watch")
}
break
case "mocha":
parts.push("npx mocha")
if (testPath) {
parts.push(testPath)
}
if (filter) {
parts.push("--grep", `"${filter}"`)
}
if (watch) {
parts.push("--watch")
}
break
case "npm":
parts.push("npm test")
if (testPath || filter) {
parts.push("--")
if (testPath) {
parts.push(testPath)
}
if (filter) {
parts.push(`"${filter}"`)
}
}
break
}
return parts.join(" ")
}
/**
* Check if a file exists.
*/
private async hasFile(projectRoot: string, filename: string): Promise<boolean> {
try {
await this.fsAccess(path.join(projectRoot, filename))
return true
} catch {
return false
}
}
/**
* Handle exec errors and return appropriate result.
*/
private handleExecError(
callId: string,
runner: TestRunner,
command: string,
error: unknown,
execStartTime: number,
startTime: number,
): ToolResult {
const durationMs = Date.now() - execStartTime
if (this.isExecError(error)) {
const result: RunTestsResult = {
runner,
command,
passed: false,
exitCode: error.code ?? 1,
stdout: this.truncateOutput(error.stdout ?? ""),
stderr: this.truncateOutput(error.stderr ?? error.message),
durationMs,
}
return createSuccessResult(callId, result, Date.now() - startTime)
}
if (error instanceof Error) {
if (error.message.includes("ETIMEDOUT") || error.message.includes("timed out")) {
return createErrorResult(
callId,
`Tests timed out after ${String(DEFAULT_TIMEOUT / 1000)} seconds`,
Date.now() - startTime,
)
}
return createErrorResult(callId, error.message, Date.now() - startTime)
}
return createErrorResult(callId, String(error), Date.now() - startTime)
}
/**
* Type guard for exec error.
*/
private isExecError(
error: unknown,
): error is Error & { code?: number; stdout?: string; stderr?: string } {
return error instanceof Error && "code" in error
}
/**
* Truncate output if too large.
*/
private truncateOutput(output: string): string {
if (output.length <= MAX_OUTPUT_SIZE) {
return output
}
return `${output.slice(0, MAX_OUTPUT_SIZE)}\n... (output truncated)`
}
}

View File

@@ -0,0 +1,12 @@
// Run tools exports
export {
CommandSecurity,
DEFAULT_BLACKLIST,
DEFAULT_WHITELIST,
type CommandClassification,
type SecurityCheckResult,
} from "./CommandSecurity.js"
export { RunCommandTool, type RunCommandResult } from "./RunCommandTool.js"
export { RunTestsTool, type RunTestsResult, type TestRunner } from "./RunTestsTool.js"

View File

@@ -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+$/)
})
})
})
})

View File

@@ -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+$/)
})
})
})
})

View File

@@ -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+$/)
})
})
})
})

View File

@@ -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")
})
})
})

View File

@@ -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()
})
})
})

View File

@@ -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+$/)
})
})
})
})