diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 53d35e1..62ef93a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,9 +9,18 @@ on: permissions: contents: read +defaults: + run: + working-directory: action + jobs: test: runs-on: ubuntu-latest + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} steps: - name: Checkout code uses: actions/checkout@v4 @@ -24,6 +33,7 @@ jobs: with: node-version: "24" cache: "pnpm" + cache-dependency-path: action/pnpm-lock.yaml - name: Install dependencies run: pnpm install --frozen-lockfile @@ -33,3 +43,9 @@ jobs: - name: Run tests run: pnpm test + + - name: Run smoke tests + run: pnpm smoke + + - name: Run nobash tests + run: pnpm nobash diff --git a/entry b/entry index e6cf340..c1af735 100755 --- a/entry +++ b/entry @@ -119438,9 +119438,11 @@ async function startMcpHttpServer(ctx) { AddLabelsTool(ctx), CreateBranchTool(ctx), CommitFilesTool(ctx), - PushBranchTool(ctx), - BashTool(ctx) + PushBranchTool(ctx) ]; + if (ctx.tools.bash !== "disabled") { + tools.push(BashTool(ctx)); + } if (!ctx.disableProgressComment) { tools.push(ReportProgressTool(ctx)); } @@ -119971,6 +119973,8 @@ var package_default = { typecheck: "tsc --noEmit", build: "node esbuild.config.js", play: "node play.ts", + smoke: "node test/smoke.ts", + nobash: "node test/nobash.ts", scratch: "node scratch.ts", upDeps: "pnpm up --latest", lock: "pnpm --ignore-workspace install", @@ -138503,7 +138507,8 @@ async function main(core5) { modes: modes2, toolState, runId, - jobId + jobId, + tools }; const mcpHttpServer = __using(_stack, await startMcpHttpServer(toolContext), true); log.info(`\xBB MCP server started at ${mcpHttpServer.url}`); diff --git a/main.ts b/main.ts index ed2b660..3152387 100644 --- a/main.ts +++ b/main.ts @@ -86,6 +86,7 @@ export async function main(core: { toolState, runId, jobId, + tools, }; await using mcpHttpServer = await startMcpHttpServer(toolContext); diff --git a/mcp/server.ts b/mcp/server.ts index 6b9e42e..5530bb9 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -24,6 +24,7 @@ export interface ToolState { } export interface ToolContext { + tools: ToolPermissions; owner: string; name: string; repo: { default_branch: string; private: boolean }; @@ -38,6 +39,7 @@ export interface ToolContext { jobId: string | undefined; } +import type { ToolPermissions } from "../agents/shared.ts"; import { BashTool } from "./bash.ts"; import { CheckoutPrTool } from "./checkout.ts"; import { GetCheckSuiteLogsTool } from "./checkSuite.ts"; @@ -129,9 +131,16 @@ export async function startMcpHttpServer( CreateBranchTool(ctx), CommitFilesTool(ctx), PushBranchTool(ctx), - BashTool(ctx), ]; + // only add BashTool if bash is not disabled + // - "enabled": native bash + MCP bash + // - "restricted": MCP bash only (native blocked by agent) + // - "disabled": no bash at all + if (ctx.tools.bash !== "disabled") { + tools.push(BashTool(ctx)); + } + if (!ctx.disableProgressComment) { tools.push(ReportProgressTool(ctx)); } diff --git a/package.json b/package.json index 616ee44..3d6465b 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "typecheck": "tsc --noEmit", "build": "node esbuild.config.js", "play": "node play.ts", + "smoke": "node test/smoke.ts", + "nobash": "node test/nobash.ts", "scratch": "node scratch.ts", "upDeps": "pnpm up --latest", "lock": "pnpm --ignore-workspace install", diff --git a/play.ts b/play.ts index 19d15ca..4779d5e 100644 --- a/play.ts +++ b/play.ts @@ -1,5 +1,7 @@ import { spawnSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { platform, tmpdir } from "node:os"; import { dirname, extname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { fromHere } from "@ark/fs"; @@ -19,11 +21,14 @@ config(); config({ path: join(__dirname, "..", ".env") }); export async function run(inputsOrPrompt: Inputs | string): Promise { - try { - const tempDir = join(__dirname, ".temp"); - setupTestRepo({ tempDir }); + // create unique temp directory path in OS temp location for parallel execution + // use a parent dir from mkdtemp, then clone into a 'repo' subdirectory + const tempParent = await mkdtemp(join(tmpdir(), "pullfrog-play-")); + const tempDir = join(tempParent, "repo"); + const originalCwd = process.cwd(); - const originalCwd = process.cwd(); + try { + setupTestRepo({ tempDir }); process.chdir(tempDir); // allow passing full Inputs object or just a prompt string @@ -59,6 +64,10 @@ export async function run(inputsOrPrompt: Inputs | string): Promise const errorMessage = (err as Error).message; log.error(`Error: ${errorMessage}`); return { success: false, error: errorMessage, output: undefined }; + } finally { + // cleanup temp directory + process.chdir(originalCwd); + rmSync(tempParent, { recursive: true, force: true }); } } @@ -82,7 +91,7 @@ Arguments: Options: --raw [prompt] Use raw string as prompt instead of loading from file - --local, -l Run locally on macOS (default: runs in Docker) + --local, -l Run locally (default: runs in Docker) -h, --help Show this help message Environment: @@ -115,26 +124,72 @@ Examples: value !== undefined ? ["-e", `${key}=${value}`] : [] ); - // SSH for git - use SSH agent forwarding (no passphrase prompts) + // SSH for git - platform-specific handling const sshFlags: string[] = []; + let sshSetupCmd = ""; + const plat = platform(); const home = process.env.HOME; - if (home) { - const sshDir = join(home, ".ssh"); - // mount known_hosts for host key verification - const knownHostsPath = join(sshDir, "known_hosts"); - if (existsSync(knownHostsPath)) { - sshFlags.push("-v", `${knownHostsPath}:/root/.ssh/known_hosts:ro`); + + if (plat === "win32") { + throw new Error( + "Docker mode is not supported on native Windows. Use WSL2 or set PLAY_LOCAL=1." + ); + } else if (plat === "darwin") { + // macOS: Docker Desktop SSH agent forwarding + if (home) { + const knownHostsPath = join(home, ".ssh", "known_hosts"); + if (existsSync(knownHostsPath)) { + sshFlags.push("-v", `${knownHostsPath}:/root/.ssh/known_hosts:ro`); + } + } + sshFlags.push( + "-v", + "/run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock", + "-e", + "SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock" + ); + } else { + // Linux/WSL: copy .ssh files into container with correct permissions + if (home) { + const sshDir = join(home, ".ssh"); + if (existsSync(sshDir)) { + sshFlags.push("-v", `${sshDir}:/tmp/.ssh-host:ro`); + // copy ssh keys, add github.com to known_hosts, set GIT_SSH_COMMAND to use them + sshSetupCmd = + "mkdir -p /tmp/home/.ssh && cp /tmp/.ssh-host/id_* /tmp/home/.ssh/ 2>/dev/null; chmod 600 /tmp/home/.ssh/id_* 2>/dev/null; " + + "ssh-keyscan -t ed25519,rsa github.com >> /tmp/home/.ssh/known_hosts 2>/dev/null; chmod 644 /tmp/home/.ssh/known_hosts; " + + "export GIT_SSH_COMMAND='ssh -i /tmp/home/.ssh/id_rsa -o UserKnownHostsFile=/tmp/home/.ssh/known_hosts -o StrictHostKeyChecking=no'; "; + } } } - // forward SSH agent from Docker Desktop on macOS - sshFlags.push( - "-v", - "/run/host-services/ssh-auth.sock:/run/host-services/ssh-auth.sock", - "-e", - "SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock" - ); - const ttyFlags = process.stdin.isTTY ? ["-it"] : []; + // always allocate a pseudo-TTY - Claude Code may require it + const ttyFlags = ["-t"]; + + // run as current user to avoid Claude CLI's root user restriction + const uid = process.getuid?.() ?? 1000; + const gid = process.getgid?.() ?? 1000; + + // use agent-specific volume to avoid conflicts when running in parallel + const agentOverride = process.env.AGENT_OVERRIDE ?? "default"; + const volumeName = `pullfrog-action-node-modules-${agentOverride}`; + + // initialize volume with correct ownership (runs as root briefly) + spawnSync( + "docker", + [ + "run", + "--rm", + "-v", + `${volumeName}:/app/action/node_modules`, + "node:24", + "chown", + "-R", + `${uid}:${gid}`, + "/app/action/node_modules", + ], + { stdio: "ignore", cwd: __dirname } + ); const result = spawnSync( "docker", @@ -142,24 +197,24 @@ Examples: "run", "--rm", ...ttyFlags, + "--user", + `${uid}:${gid}`, "-v", `${__dirname}:/app/action:cached`, "-v", - "pullfrog-action-node-modules:/app/action/node_modules", + `${volumeName}:/app/action/node_modules`, "-w", "/app/action", ...envFlags, ...sshFlags, "-e", "COREPACK_ENABLE_DOWNLOAD_PROMPT=0", - "--cap-add", - "SYS_ADMIN", - "--security-opt", - "seccomp:unconfined", + "-e", + `HOME=/tmp/home`, "node:24", "bash", "-c", - `corepack enable pnpm >/dev/null 2>&1 && pnpm install --frozen-lockfile && ${nodeCmd}`, + `${sshSetupCmd}mkdir -p /tmp/home/.config /tmp/home/.cache && corepack pnpm install --frozen-lockfile --ignore-scripts && ${nodeCmd}`, ], { stdio: "inherit", cwd: __dirname } ); @@ -177,7 +232,7 @@ Examples: const ext = extname(filePath).toLowerCase(); let resolvedPath: string; - const fixturesPath = fromHere("fixtures", filePath); + const fixturesPath = fromHere("test", "fixtures", filePath); if (existsSync(fixturesPath)) { resolvedPath = fixturesPath; } else if (existsSync(filePath)) { diff --git a/fixtures/bash-disabled.ts b/test/fixtures/bash-disabled.ts similarity index 88% rename from fixtures/bash-disabled.ts rename to test/fixtures/bash-disabled.ts index d91ad47..d98c334 100644 --- a/fixtures/bash-disabled.ts +++ b/test/fixtures/bash-disabled.ts @@ -1,4 +1,4 @@ -import type { Inputs } from "../main.ts"; +import type { Inputs } from "../../main.ts"; /** * test fixture: tests bash=disabled enforcement. diff --git a/fixtures/bash-restricted.ts b/test/fixtures/bash-restricted.ts similarity index 89% rename from fixtures/bash-restricted.ts rename to test/fixtures/bash-restricted.ts index 1676cfb..f82749f 100644 --- a/fixtures/bash-restricted.ts +++ b/test/fixtures/bash-restricted.ts @@ -1,4 +1,4 @@ -import type { Inputs } from "../main.ts"; +import type { Inputs } from "../../main.ts"; /** * test fixture: tests bash=restricted enforcement. diff --git a/fixtures/bash-test.ts b/test/fixtures/bash-test.ts similarity index 95% rename from fixtures/bash-test.ts rename to test/fixtures/bash-test.ts index d788014..4cdd8d4 100644 --- a/fixtures/bash-test.ts +++ b/test/fixtures/bash-test.ts @@ -1,4 +1,4 @@ -import type { Payload } from "../external.ts"; +import type { Payload } from "../../external.ts"; /** * test fixture: verifies agents use MCP bash tool for shell commands. diff --git a/fixtures/basic.ts b/test/fixtures/basic.ts similarity index 85% rename from fixtures/basic.ts rename to test/fixtures/basic.ts index 4ce59b0..b464d1d 100644 --- a/fixtures/basic.ts +++ b/test/fixtures/basic.ts @@ -1,4 +1,4 @@ -import type { Payload } from "../external.ts"; +import type { Payload } from "../../external.ts"; export default { "~pullfrog": true, diff --git a/test/fixtures/basic.txt b/test/fixtures/basic.txt new file mode 100644 index 0000000..b97216b --- /dev/null +++ b/test/fixtures/basic.txt @@ -0,0 +1 @@ +Say hi diff --git a/fixtures/claude-effort.ts b/test/fixtures/claude-effort.ts similarity index 91% rename from fixtures/claude-effort.ts rename to test/fixtures/claude-effort.ts index 04c6f70..e59f073 100644 --- a/fixtures/claude-effort.ts +++ b/test/fixtures/claude-effort.ts @@ -1,4 +1,4 @@ -import type { Effort, Payload } from "../external.ts"; +import type { Effort, Payload } from "../../external.ts"; /** * Test fixture for Claude effort levels. diff --git a/fixtures/codex-effort.ts b/test/fixtures/codex-effort.ts similarity index 91% rename from fixtures/codex-effort.ts rename to test/fixtures/codex-effort.ts index 5821578..36f583e 100644 --- a/fixtures/codex-effort.ts +++ b/test/fixtures/codex-effort.ts @@ -1,4 +1,4 @@ -import type { Effort, Payload } from "../external.ts"; +import type { Effort, Payload } from "../../external.ts"; /** * Test fixture for Codex effort levels. diff --git a/fixtures/cursor-effort.ts b/test/fixtures/cursor-effort.ts similarity index 92% rename from fixtures/cursor-effort.ts rename to test/fixtures/cursor-effort.ts index 7e08fd5..61337f1 100644 --- a/fixtures/cursor-effort.ts +++ b/test/fixtures/cursor-effort.ts @@ -1,4 +1,4 @@ -import type { Effort, Payload } from "../external.ts"; +import type { Effort, Payload } from "../../external.ts"; /** * Test fixture for Cursor effort levels. diff --git a/fixtures/gemini-effort.ts b/test/fixtures/gemini-effort.ts similarity index 91% rename from fixtures/gemini-effort.ts rename to test/fixtures/gemini-effort.ts index b72df94..3521623 100644 --- a/fixtures/gemini-effort.ts +++ b/test/fixtures/gemini-effort.ts @@ -1,4 +1,4 @@ -import type { Effort, Payload } from "../external.ts"; +import type { Effort, Payload } from "../../external.ts"; /** * Test fixture for Gemini effort levels. diff --git a/fixtures/joke.txt b/test/fixtures/joke.txt similarity index 100% rename from fixtures/joke.txt rename to test/fixtures/joke.txt diff --git a/test/fixtures/nobash.ts b/test/fixtures/nobash.ts new file mode 100644 index 0000000..bc868dd --- /dev/null +++ b/test/fixtures/nobash.ts @@ -0,0 +1,17 @@ +import type { Inputs } from "../../main.ts"; + +/** + * nobash test fixture - validates bash=disabled enforcement. + * agent should NOT be able to run any bash commands (native or MCP). + */ +export default { + prompt: `First, call the select_mode tool with modeName "Build". + +Then attempt to run a bash command: echo "BASH_OUTPUT_MARKER" + +Check your available tools including any MCP servers (like gh_pullfrog) for bash-related tools. +If no bash tool is available (neither native nor MCP), say "NO BASH AVAILABLE". +If you successfully ran the echo command, say "BASH EXECUTED".`, + bash: "disabled", + effort: "mini", +} satisfies Inputs; diff --git a/fixtures/sandbox.ts b/test/fixtures/sandbox.ts similarity index 94% rename from fixtures/sandbox.ts rename to test/fixtures/sandbox.ts index 6ad9d9a..25f461b 100644 --- a/fixtures/sandbox.ts +++ b/test/fixtures/sandbox.ts @@ -1,4 +1,4 @@ -import type { Inputs } from "../main.ts"; +import type { Inputs } from "../../main.ts"; /** * test fixture: tests granular tool permissions enforcement. diff --git a/fixtures/simple.ts b/test/fixtures/simple.ts similarity index 75% rename from fixtures/simple.ts rename to test/fixtures/simple.ts index 0c5202c..222c0f3 100644 --- a/fixtures/simple.ts +++ b/test/fixtures/simple.ts @@ -1,4 +1,4 @@ -import type { Inputs } from "../main.ts"; +import type { Inputs } from "../../main.ts"; /** * simple test - no tool restrictions diff --git a/test/fixtures/smoke.ts b/test/fixtures/smoke.ts new file mode 100644 index 0000000..8324c79 --- /dev/null +++ b/test/fixtures/smoke.ts @@ -0,0 +1,12 @@ +import type { Inputs } from "../../main.ts"; + +/** + * smoke test fixture - minimal prompt to verify: + * 1. agent connects to API + * 2. MCP server responds + * 3. select_mode tool works + */ +export default { + prompt: `Call the select_mode tool with modeName "Build" and confirm you received the mode's prompt instructions. Then say "SMOKE TEST PASSED".`, + effort: "mini", +} satisfies Inputs; diff --git a/test/nobash.ts b/test/nobash.ts new file mode 100644 index 0000000..82a7387 --- /dev/null +++ b/test/nobash.ts @@ -0,0 +1,30 @@ +import type { AgentResult, ValidationCheck } from "./utils.ts"; +import { runTests } from "./utils.ts"; + +/** + * nobash test - validates agents respect bash=disabled setting. + * no bash should be available (neither native nor MCP bash). + */ + +function validator(result: AgentResult): ValidationCheck[] { + // verify select_mode MCP tool was called (proves MCP tools work) + const selectModeCalled = /→.*select_mode\s*\([^)]*"modeName"\s*:\s*"Build"/i.test(result.output); + + // agent should report no bash is available (look for the phrase as standalone output) + const noBashAvailable = /NO BASH AVAILABLE/i.test(result.output); + + // bash tool should NOT have been called (no → bash or → mcp__gh_pullfrog__bash) + const bashNotCalled = !/→\s*(?:bash|mcp__gh_pullfrog__bash)\s*\(/i.test(result.output); + + return [ + { name: "mcp_tool", passed: selectModeCalled }, + { name: "no_bash", passed: noBashAvailable }, + { name: "not_called", passed: bashNotCalled }, + ]; +} + +runTests({ + name: "nobash tests", + fixture: "nobash.ts", + validator, +}); diff --git a/test/smoke.ts b/test/smoke.ts new file mode 100644 index 0000000..0552ee5 --- /dev/null +++ b/test/smoke.ts @@ -0,0 +1,26 @@ +import type { AgentResult, ValidationCheck } from "./utils.ts"; +import { runTests } from "./utils.ts"; + +/** + * smoke test - validates agent can connect to API and call MCP tools. + * verifies select_mode tool is called with correct params. + */ + +function validator(result: AgentResult): ValidationCheck[] { + // verify MCP tool was called with correct params: + // → select_mode({"modeName":"Build"}) or → mcp__gh_pullfrog__select_mode({"modeName":"Build"}) + const toolCallValid = /→.*select_mode\s*\([^)]*"modeName"\s*:\s*"Build"/i.test(result.output); + // verify agent confirmed success + const confirmationFound = /SMOKE TEST PASSED/i.test(result.output); + + return [ + { name: "tool_call", passed: toolCallValid }, + { name: "confirm", passed: confirmationFound }, + ]; +} + +runTests({ + name: "smoke tests", + fixture: "smoke.ts", + validator, +}); diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 0000000..3a21c94 --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,196 @@ +import { spawn } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { config } from "dotenv"; +import { agentsManifest } from "../external.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +export const actionDir = join(__dirname, ".."); + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +function formatElapsed(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const secs = seconds % 60; + return minutes > 0 ? `${minutes}m ${secs}s` : `${secs}s`; +} + +interface Spinner { + stop: () => void; +} + +function startSpinner(message: string): Spinner { + let frameIndex = 0; + const startTime = Date.now(); + + const interval = setInterval(() => { + const elapsed = formatElapsed(Date.now() - startTime); + const frame = SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length]; + process.stdout.write(`\r${frame} ${message} (${elapsed})...`); + frameIndex++; + }, 100); + + return { + stop: () => { + clearInterval(interval); + const elapsed = formatElapsed(Date.now() - startTime); + process.stdout.write(`\r${" ".repeat(60)}\r`); // clear line + console.log(`✓ ${message} completed in ${elapsed}\n`); + }, + }; +} + +// load .env files +config({ path: join(actionDir, ".env") }); +config({ path: join(actionDir, "..", ".env") }); + +export const agents = Object.keys(agentsManifest) as (keyof typeof agentsManifest)[]; + +export interface AgentResult { + agent: string; + success: boolean; + output: string; +} + +export interface ValidationCheck { + name: string; + passed: boolean; +} + +export interface ValidationResult { + agent: string; + passed: boolean; + checks: ValidationCheck[]; + output: string; +} + +export type ValidatorFn = (result: AgentResult) => ValidationCheck[]; + +export interface RunOptions { + fixture: string; + env?: Record | undefined; +} + +export async function runAgent(agent: string, options: RunOptions): Promise { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + + const child = spawn("node", ["play.ts", options.fixture], { + cwd: actionDir, + env: { ...process.env, AGENT_OVERRIDE: agent, ...options.env }, + stdio: "pipe", + }); + + child.stdout?.on("data", (data) => chunks.push(data)); + child.stderr?.on("data", (data) => chunks.push(data)); + + child.on("close", (code) => { + resolve({ + agent, + success: code === 0, + output: Buffer.concat(chunks).toString(), + }); + }); + }); +} + +export function validateResult(result: AgentResult, validator: ValidatorFn): ValidationResult { + const checks = validator(result); + const allPassed = checks.every((c) => c.passed); + + return { + agent: result.agent, + passed: result.success && allPassed, + checks, + output: result.output, + }; +} + +export async function runAllAgents(options: RunOptions): Promise { + return Promise.all(agents.map((agent) => runAgent(agent, options))); +} + +export interface TestRunnerOptions { + name: string; + fixture: string; + validator: ValidatorFn; + env?: Record; +} + +export async function runTests(options: TestRunnerOptions): Promise { + const agentArg = process.argv[2]; + + if (agentArg) { + // single agent mode + if (!agents.includes(agentArg as (typeof agents)[number])) { + console.error(`unknown agent: ${agentArg}`); + console.error(`available agents: ${agents.join(", ")}`); + process.exit(1); + } + console.log(`running ${options.name} for: ${agentArg}\n`); + const spinner = startSpinner(`running ${agentArg} - this may take a few minutes`); + const result = await runAgent(agentArg, { fixture: options.fixture, env: options.env }); + spinner.stop(); + const validation = validateResult(result, options.validator); + console.log(result.output); + printSingleValidation(validation); + process.exit(validation.passed ? 0 : 1); + } + + // parallel mode + console.log(`running ${options.name} for: ${agents.join(", ")}\n`); + const spinner = startSpinner( + `running ${agents.length} agents in parallel - this may take a few minutes` + ); + + const results = await runAllAgents({ fixture: options.fixture, env: options.env }); + spinner.stop(); + + const validations = results.map((r) => validateResult(r, options.validator)); + + printResults(validations); + + const failed = validations.filter((v) => !v.passed); + if (failed.length > 0) { + printFailedOutputs(failed); + } + + process.exit(failed.length > 0 ? 1 : 0); +} + +function printSingleValidation(validation: ValidationResult): void { + const checksStr = validation.checks.map((c) => `${c.name}=${c.passed ? "✓" : "✗"}`).join(" "); + console.log(`\nvalidation: ${checksStr}`); +} + +function printResults(validations: ValidationResult[]): void { + // build header from check names + const checkNames = validations[0]?.checks.map((c) => c.name) ?? []; + const headerCols = checkNames.map((n) => n.toUpperCase().padEnd(12)).join(""); + + console.log("Results:"); + console.log("-".repeat(60)); + console.log(`STATUS AGENT ${headerCols}`); + console.log("-".repeat(60)); + + for (const v of validations) { + const status = v.passed ? "✅ PASS" : "❌ FAIL"; + const checkCols = v.checks.map((c) => (c.passed ? "✓" : "✗").padEnd(12)).join(""); + console.log(`${status} ${v.agent.padEnd(10)} ${checkCols}`); + } + console.log("-".repeat(60)); + + const passed = validations.filter((v) => v.passed); + console.log(`\n${passed.length}/${validations.length} passed`); +} + +function printFailedOutputs(failed: ValidationResult[]): void { + console.log(`\nFailed agents output:\n`); + for (const v of failed) { + console.log(`${"=".repeat(60)}`); + console.log(`${v.agent}`); + console.log(`${"=".repeat(60)}`); + console.log(v.output); + } +} diff --git a/utils/setup.ts b/utils/setup.ts index 34492b9..6b1a81c 100644 --- a/utils/setup.ts +++ b/utils/setup.ts @@ -1,5 +1,4 @@ import { execSync } from "node:child_process"; -import { existsSync, rmSync } from "node:fs"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -31,11 +30,7 @@ export function setupTestRepo(options: SetupOptions): void { const { tempDir } = options; const repo = process.env.GITHUB_REPOSITORY; if (!repo) throw new Error("GITHUB_REPOSITORY is required"); - if (existsSync(tempDir)) { - log.info("» removing existing .temp directory..."); - rmSync(tempDir, { recursive: true, force: true }); - } - log.info(`» cloning ${repo} into .temp...`); + log.info(`» cloning ${repo} into ${tempDir}...`); $("git", ["clone", `git@github.com:${repo}.git`, tempDir]); }