import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; import { MailboxStore } from "../src/db.js"; import { buildServer } from "../src/server.js"; import type { FastifyInstance } from "fastify"; const here = dirname(fileURLToPath(import.meta.url)); const CLI = join(here, "..", "dist", "cli.js"); let dir: string; let store: MailboxStore; let app: FastifyInstance; let baseUrl: string; beforeEach(async () => { dir = mkdtempSync(join(tmpdir(), "claude-mailbox-cli-watch-")); store = new MailboxStore(join(dir, "test.db")); app = await buildServer( { port: 0, bind: "127.0.0.1", dbPath: join(dir, "test.db"), hideAfterMinutes: 0, deleteAfterMinutes: 0, sweepIntervalMinutes: 0, }, store, ); await app.listen({ host: "127.0.0.1", port: 0 }); const addr = app.server.address(); if (!addr || typeof addr === "string") throw new Error("no address"); baseUrl = `http://127.0.0.1:${addr.port}`; }); afterEach(async () => { store.rejectAllWaiters(); await app.close(); store.close(); rmSync(dir, { recursive: true, force: true }); }); // Async helper: spawn CLI and collect output without blocking the event loop. // spawnSync cannot be used here because the test process hosts the Fastify server, // and spawnSync blocks the event loop, preventing the server from handling connections. function runCli(args: string[], timeoutMs: number = 8000): Promise<{ status: number | null; stdout: string; stderr: string; }> { return new Promise((resolve) => { const child = spawn(process.execPath, [CLI, ...args], { stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; child.stdout.on("data", (d) => { stdout += d.toString(); }); child.stderr.on("data", (d) => { stderr += d.toString(); }); const timer = setTimeout(() => { child.kill(); resolve({ status: null, stdout, stderr }); }, timeoutMs); child.on("exit", (code) => { clearTimeout(timer); resolve({ status: code, stdout, stderr }); }); }); } describe("claude-mailbox watch CLI", () => { it("exits 0 with a formatted message when one is pending", async () => { store.upsertMailbox("alice"); store.upsertMailbox("bob"); store.send("alice", "bob", "hello watcher"); const r = await runCli(["watch", "--block", "--name", "bob", "--timeout", "2", "--url", baseUrl]); expect(r.status).toBe(0); expect(r.stdout).toContain("[Claude-Mailbox] Mail from alice:"); expect(r.stdout).toContain("hello watcher"); }); it("exits 3 silently on timeout", async () => { store.upsertMailbox("bob"); const r = await runCli(["watch", "--block", "--name", "bob", "--timeout", "1", "--url", baseUrl]); expect(r.status).toBe(3); expect(r.stdout).toBe(""); }); it("exits 0 with rename notice when the mailbox is renamed mid-wait", async () => { store.upsertMailbox("oldname"); const child = spawn( process.execPath, [CLI, "watch", "--block", "--name", "oldname", "--timeout", "3", "--url", baseUrl], { stdio: ["ignore", "pipe", "pipe"] }, ); let stdout = ""; child.stdout.on("data", (d) => { stdout += d.toString(); }); setTimeout(() => store.rename("oldname", "newname"), 300); const code: number = await new Promise((r) => child.on("exit", (c) => r(c ?? 1))); expect(code).toBe(0); expect(stdout).toContain("renamed to 'newname'"); }); it("exits 2 when the daemon is unreachable", async () => { const r = await runCli([ "watch", "--block", "--name", "bob", "--timeout", "1", "--url", "http://127.0.0.1:1", // port 1 = guaranteed connection refused ]); expect(r.status).toBe(2); }); it("exits 1 when --name is missing", async () => { const r = await runCli(["watch", "--block", "--timeout", "1", "--url", baseUrl]); expect(r.status).not.toBe(0); expect(r.stderr).toMatch(/required.*name|name.*required/i); }); });