diff --git a/packages/ipuaro/CHANGELOG.md b/packages/ipuaro/CHANGELOG.md index c9b1f2d..4dbd1ba 100644 --- a/packages/ipuaro/CHANGELOG.md +++ b/packages/ipuaro/CHANGELOG.md @@ -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/), 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 ### Added diff --git a/packages/ipuaro/src/infrastructure/tools/git/GitCommitTool.ts b/packages/ipuaro/src/infrastructure/tools/git/GitCommitTool.ts new file mode 100644 index 0000000..233bbb9 --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/git/GitCommitTool.ts @@ -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 | 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, ctx: ToolContext): Promise { + 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, + } + } +} diff --git a/packages/ipuaro/src/infrastructure/tools/git/GitDiffTool.ts b/packages/ipuaro/src/infrastructure/tools/git/GitDiffTool.ts new file mode 100644 index 0000000..e1b2b8f --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/git/GitDiffTool.ts @@ -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 | 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, ctx: ToolContext): Promise { + 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 + } +} diff --git a/packages/ipuaro/src/infrastructure/tools/git/GitStatusTool.ts b/packages/ipuaro/src/infrastructure/tools/git/GitStatusTool.ts new file mode 100644 index 0000000..2606d17 --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/git/GitStatusTool.ts @@ -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 | null { + return null + } + + async execute(_params: Record, ctx: ToolContext): Promise { + 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(), + } + } +} diff --git a/packages/ipuaro/src/infrastructure/tools/git/index.ts b/packages/ipuaro/src/infrastructure/tools/git/index.ts new file mode 100644 index 0000000..73fd3d5 --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/git/index.ts @@ -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" diff --git a/packages/ipuaro/src/infrastructure/tools/index.ts b/packages/ipuaro/src/infrastructure/tools/index.ts index 46d1c3c..9601084 100644 --- a/packages/ipuaro/src/infrastructure/tools/index.ts +++ b/packages/ipuaro/src/infrastructure/tools/index.ts @@ -53,3 +53,23 @@ export { type TodoEntry, type TodoType, } 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" diff --git a/packages/ipuaro/src/infrastructure/tools/run/CommandSecurity.ts b/packages/ipuaro/src/infrastructure/tools/run/CommandSecurity.ts new file mode 100644 index 0000000..a11e804 --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/run/CommandSecurity.ts @@ -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] + } +} diff --git a/packages/ipuaro/src/infrastructure/tools/run/RunCommandTool.ts b/packages/ipuaro/src/infrastructure/tools/run/RunCommandTool.ts new file mode 100644 index 0000000..cbc19ba --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/run/RunCommandTool.ts @@ -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 | 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, ctx: ToolContext): Promise { + 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 + } +} diff --git a/packages/ipuaro/src/infrastructure/tools/run/RunTestsTool.ts b/packages/ipuaro/src/infrastructure/tools/run/RunTestsTool.ts new file mode 100644 index 0000000..03070dd --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/run/RunTestsTool.ts @@ -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 | 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, ctx: ToolContext): Promise { + 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 { + 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 + devDependencies?: Record + dependencies?: Record + } + + 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 { + 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)` + } +} diff --git a/packages/ipuaro/src/infrastructure/tools/run/index.ts b/packages/ipuaro/src/infrastructure/tools/run/index.ts new file mode 100644 index 0000000..77e91ec --- /dev/null +++ b/packages/ipuaro/src/infrastructure/tools/run/index.ts @@ -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" diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/git/GitCommitTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/git/GitCommitTool.test.ts new file mode 100644 index 0000000..7116e2a --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/git/GitCommitTool.test.ts @@ -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 { + 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 { + 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) + .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+$/) + }) + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/git/GitDiffTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/git/GitDiffTool.test.ts new file mode 100644 index 0000000..b946b8c --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/git/GitDiffTool.test.ts @@ -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 { + 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+$/) + }) + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/git/GitStatusTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/git/GitStatusTool.test.ts new file mode 100644 index 0000000..1925dee --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/git/GitStatusTool.test.ts @@ -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 { + 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+$/) + }) + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/run/CommandSecurity.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/run/CommandSecurity.test.ts new file mode 100644 index 0000000..e7d6882 --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/run/CommandSecurity.test.ts @@ -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") + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/run/RunCommandTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/run/RunCommandTool.test.ts new file mode 100644 index 0000000..d4a340a --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/run/RunCommandTool.test.ts @@ -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, +) => Promise + +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() + }) + }) +}) diff --git a/packages/ipuaro/tests/unit/infrastructure/tools/run/RunTestsTool.test.ts b/packages/ipuaro/tests/unit/infrastructure/tools/run/RunTestsTool.test.ts new file mode 100644 index 0000000..bdf573a --- /dev/null +++ b/packages/ipuaro/tests/unit/infrastructure/tools/run/RunTestsTool.test.ts @@ -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, +) => Promise + +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, +): 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+$/) + }) + }) + }) +})