diff --git a/CLAUDE.md b/CLAUDE.md index 60b54ad..92542a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,8 @@ Multi-agent orchestrator that runs parallel Claude Code agents in git worktrees. Tasks submitted via REST API, monitored via SSE, agents auto-commit and merge to main. ## Architecture -- **src/index.ts** — CLI entry point (Commander.js) +- **src/index.ts** — Server entry point (Commander.js) +- **src/cli.ts** — CLI client commands (submit, list, status, diff, logs, stats, workers, search, cancel, retry, watch) - **src/server.ts** — Hono REST API + SSE - **src/scheduler.ts** — Task queue, priority dispatch, retry logic, stale worker recovery - **src/agent-runner.ts** — Multi-agent CLI spawning (Claude, Codex, generic), cost tracking, code review @@ -29,7 +30,7 @@ node dist/index.js --repo /path/to/repo --workers 5 --port 8080 ``` ```bash -# Run tests (255 tests across 6 suites) +# Run tests (282 tests across 8 suites) node --import tsx --test src/__tests__/*.test.ts ``` @@ -37,7 +38,7 @@ node --import tsx --test src/__tests__/*.test.ts - Always use `.js` extensions in TypeScript imports (ESM) - Run `npx tsc` after changes to verify compilation - Only modify core files — do NOT create standalone utility modules -- Core files: server.ts, scheduler.ts, store.ts, agent-runner.ts, worktree-pool.ts, index.ts, types.ts, logger.ts +- Core files: server.ts, scheduler.ts, store.ts, agent-runner.ts, worktree-pool.ts, index.ts, cli.ts, types.ts, logger.ts - Keep changes minimal and focused — one concern per change - Always `git add -A && git commit` after successful changes @@ -139,7 +140,9 @@ pending → running → success (branch merged to main) - `src/__tests__/scheduler.test.ts` — Submit, cancel, stats, queue position - `src/__tests__/agent-runner.test.ts` — Cost estimation, code review, system prompt, CLI dispatch - `src/__tests__/server.test.ts` — API input validation (prompt, timeout, priority, tags, webhookUrl) -- `src/__tests__/cli.test.ts` — CLI commands, fetch mocking, output formatting, error handling +- `src/__tests__/cli.test.ts` — CLI commands (all 12), fetch mocking, output formatting, error handling +- `src/__tests__/logger.test.ts` — Structured logger: JSON format, level filtering, stream routing +- `src/__tests__/index.test.ts` — Entry point validation: repo, workers, port, system prompt (subprocess) ## Repository - **GitHub**: `agent-next/cc-manager` (private) diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 4d129d4..9907d5f 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -473,6 +473,66 @@ describe("cli", () => { }); }); + // ─── watch ─────────────────────────────────────────────────────────────────── + + describe("watch command", () => { + function mockSSEFetch(chunks: string[]) { + fetchCalls = []; + let chunkIndex = 0; + (globalThis as any).fetch = async (url: string | URL, init?: RequestInit) => { + fetchCalls.push({ url: String(url), init }); + return { + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => { + if (chunkIndex < chunks.length) { + const encoder = new TextEncoder(); + return { done: false, value: encoder.encode(chunks[chunkIndex++]) }; + } + return { done: true, value: undefined }; + }, + }), + }, + }; + }; + } + + it("connects to /api/events", async () => { + mockSSEFetch([]); + await run("watch"); + + assert.ok(fetchCalls[0].url.includes("/api/events")); + }); + + it("parses SSE data lines and logs events", async () => { + const event = JSON.stringify({ type: "task_started", taskId: "t1", status: "running" }); + mockSSEFetch([`data: ${event}\n\n`]); + await run("watch"); + + const output = logs.join("\n"); + assert.ok(output.includes("task_started"), "should log event type"); + assert.ok(output.includes("t1"), "should log task ID"); + }); + + it("skips malformed SSE data", async () => { + mockSSEFetch(["data: not-valid-json\n\n"]); + await run("watch"); + + // Should not throw, just silently skip + assert.ok(true); + }); + + it("prints connection message", async () => { + mockSSEFetch([]); + await run("watch"); + + const output = logs.join("\n"); + assert.ok(output.includes("Watching events"), "should show connection message"); + }); + }); + // ─── api() Content-Type header ────────────────────────────────────────────── describe("api() helper behavior", () => { diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts new file mode 100644 index 0000000..a577c3f --- /dev/null +++ b/src/__tests__/index.test.ts @@ -0,0 +1,122 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +// ============================================================================= +// index.ts entry point tests — subprocess validation of CLI args and startup +// ============================================================================= + +const exec = promisify(execFile); + +async function runServer(...args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { + try { + const { stdout, stderr } = await exec("node", ["--import", "tsx", "src/index.ts", ...args], { + timeout: 5000, + env: { ...process.env, NODE_NO_WARNINGS: "1" }, + }); + return { stdout, stderr, exitCode: 0 }; + } catch (err: any) { + return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", exitCode: err.code ?? 1 }; + } +} + +describe("index.ts entry point", () => { + + // ─── required options ─────────────────────────────────────────────────────── + + describe("required options", () => { + it("exits with error when --repo is missing", async () => { + const result = await runServer(); + assert.notStrictEqual(result.exitCode, 0); + assert.ok(result.stderr.includes("--repo"), "should mention --repo in error"); + }); + }); + + // ─── repo validation ──────────────────────────────────────────────────────── + + describe("repo validation", () => { + it("exits with error for nonexistent repo path", async () => { + const result = await runServer("--repo", "/nonexistent/path/to/repo"); + assert.notStrictEqual(result.exitCode, 0); + assert.ok(result.stderr.includes("not a git repository"), "should report invalid repo"); + }); + + it("exits with error for directory without .git", async () => { + const tmp = mkdtempSync(join(tmpdir(), "cc-test-")); + try { + const result = await runServer("--repo", tmp); + assert.notStrictEqual(result.exitCode, 0); + assert.ok(result.stderr.includes("not a git repository")); + } finally { + rmSync(tmp, { recursive: true }); + } + }); + }); + + // ─── workers validation ───────────────────────────────────────────────────── + + describe("workers validation", () => { + let fakeRepo: string; + + // Create a minimal fake git repo for validation tests that pass repo check + fakeRepo = mkdtempSync(join(tmpdir(), "cc-test-repo-")); + mkdirSync(join(fakeRepo, ".git"), { recursive: true }); + + it("exits with error for --workers 0", async () => { + const result = await runServer("--repo", fakeRepo, "--workers", "0"); + assert.notStrictEqual(result.exitCode, 0); + assert.ok(result.stderr.includes("--workers must be between 1 and 20")); + }); + + it("exits with error for --workers 25", async () => { + const result = await runServer("--repo", fakeRepo, "--workers", "25"); + assert.notStrictEqual(result.exitCode, 0); + assert.ok(result.stderr.includes("--workers must be between 1 and 20")); + }); + + it("exits with error for --workers abc", async () => { + const result = await runServer("--repo", fakeRepo, "--workers", "abc"); + assert.notStrictEqual(result.exitCode, 0); + assert.ok(result.stderr.includes("--workers must be between 1 and 20")); + }); + }); + + // ─── port validation ──────────────────────────────────────────────────────── + + describe("port validation", () => { + let fakeRepo: string; + fakeRepo = mkdtempSync(join(tmpdir(), "cc-test-repo-")); + mkdirSync(join(fakeRepo, ".git"), { recursive: true }); + + it("exits with error for --port 80", async () => { + const result = await runServer("--repo", fakeRepo, "--port", "80"); + assert.notStrictEqual(result.exitCode, 0); + assert.ok(result.stderr.includes("--port must be between 1024 and 65535")); + }); + + it("exits with error for --port 99999", async () => { + const result = await runServer("--repo", fakeRepo, "--port", "99999"); + assert.notStrictEqual(result.exitCode, 0); + assert.ok(result.stderr.includes("--port must be between 1024 and 65535")); + }); + }); + + // ─── system prompt file ───────────────────────────────────────────────────── + + describe("system prompt file", () => { + it("exits with error for nonexistent --system-prompt-file", async () => { + const fakeRepo = mkdtempSync(join(tmpdir(), "cc-test-repo-")); + mkdirSync(join(fakeRepo, ".git"), { recursive: true }); + try { + const result = await runServer("--repo", fakeRepo, "--system-prompt-file", "/nonexistent/file.txt"); + assert.notStrictEqual(result.exitCode, 0); + } finally { + rmSync(fakeRepo, { recursive: true }); + } + }); + }); +}); diff --git a/src/__tests__/logger.test.ts b/src/__tests__/logger.test.ts new file mode 100644 index 0000000..1365a0a --- /dev/null +++ b/src/__tests__/logger.test.ts @@ -0,0 +1,145 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { log, setLogLevel } from "../logger.js"; + +// ============================================================================= +// Logger tests — verify structured JSON output, level filtering, stream routing +// ============================================================================= + +let stdoutWrites: string[] = []; +let stderrWrites: string[] = []; +const originalStdoutWrite = process.stdout.write; +const originalStderrWrite = process.stderr.write; + +function captureStreams() { + stdoutWrites = []; + stderrWrites = []; + process.stdout.write = ((chunk: string) => { + stdoutWrites.push(chunk); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: string) => { + stderrWrites.push(chunk); + return true; + }) as typeof process.stderr.write; +} + +function restoreStreams() { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; +} + +describe("logger", () => { + beforeEach(() => { + setLogLevel("info"); // reset to default + captureStreams(); + }); + + afterEach(() => { + restoreStreams(); + }); + + // ─── JSON format ──────────────────────────────────────────────────────────── + + describe("JSON format", () => { + it("outputs valid JSON with ts, level, and msg fields", () => { + log("info", "hello world"); + + assert.strictEqual(stdoutWrites.length, 1); + const entry = JSON.parse(stdoutWrites[0]); + assert.strictEqual(entry.level, "info"); + assert.strictEqual(entry.msg, "hello world"); + assert.ok(entry.ts, "should have timestamp"); + }); + + it("includes extra data fields", () => { + log("info", "task start", { taskId: "abc", agent: "claude" }); + + const entry = JSON.parse(stdoutWrites[0]); + assert.strictEqual(entry.taskId, "abc"); + assert.strictEqual(entry.agent, "claude"); + }); + + it("produces ISO 8601 timestamp", () => { + log("info", "check ts"); + + const entry = JSON.parse(stdoutWrites[0]); + assert.ok(/^\d{4}-\d{2}-\d{2}T/.test(entry.ts), "should be ISO format"); + }); + + it("appends newline after JSON", () => { + log("info", "newline check"); + + assert.ok(stdoutWrites[0].endsWith("\n")); + }); + }); + + // ─── stream routing ───────────────────────────────────────────────────────── + + describe("stream routing", () => { + it("writes info to stdout", () => { + log("info", "info msg"); + assert.strictEqual(stdoutWrites.length, 1); + assert.strictEqual(stderrWrites.length, 0); + }); + + it("writes warn to stdout", () => { + log("warn", "warn msg"); + assert.strictEqual(stdoutWrites.length, 1); + assert.strictEqual(stderrWrites.length, 0); + }); + + it("writes error to stderr", () => { + log("error", "error msg"); + assert.strictEqual(stderrWrites.length, 1); + assert.strictEqual(stdoutWrites.length, 0); + }); + + it("writes debug to stdout when level is debug", () => { + setLogLevel("debug"); + log("debug", "debug msg"); + assert.strictEqual(stdoutWrites.length, 1); + assert.strictEqual(stderrWrites.length, 0); + }); + }); + + // ─── level filtering ──────────────────────────────────────────────────────── + + describe("level filtering", () => { + it("suppresses debug at default info level", () => { + log("debug", "should not appear"); + assert.strictEqual(stdoutWrites.length, 0); + assert.strictEqual(stderrWrites.length, 0); + }); + + it("emits debug when level set to debug", () => { + setLogLevel("debug"); + log("debug", "visible"); + assert.strictEqual(stdoutWrites.length, 1); + }); + + it("suppresses info when level set to warn", () => { + setLogLevel("warn"); + log("info", "hidden"); + assert.strictEqual(stdoutWrites.length, 0); + }); + + it("emits warn when level set to warn", () => { + setLogLevel("warn"); + log("warn", "visible"); + assert.strictEqual(stdoutWrites.length, 1); + }); + + it("suppresses warn when level set to error", () => { + setLogLevel("error"); + log("warn", "hidden"); + assert.strictEqual(stdoutWrites.length, 0); + }); + + it("emits error at any level", () => { + setLogLevel("error"); + log("error", "always visible"); + assert.strictEqual(stderrWrites.length, 1); + }); + }); +});