Files
ClaudeMailbox/node/tests/cli-watch.test.ts
Mika Kuns 9f8c1d9e9d test(cli): fix flaky rename watch test with deterministic waiter polling
The rename test relied on a fixed 300ms setTimeout to fire after the CLI
subprocess had registered its waiter — adequate in isolation but flaky
under full-suite load on Windows (CLI spawn + first HTTP request can
exceed 300ms). Add a tiny public MailboxStore.waiterCount(name) helper
so the test can poll until the waiter is actually registered before
triggering the rename. Also tighten the missing-name assertion from
not-zero to the contract-exact exit code 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:34:47 +02:00

130 lines
4.4 KiB
TypeScript

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", "5", "--url", baseUrl],
{ stdio: ["ignore", "pipe", "pipe"] },
);
let stdout = "";
child.stdout.on("data", (d) => { stdout += d.toString(); });
// Wait for the CLI subprocess to register its waiter before renaming.
// A fixed delay is flaky under full-suite load on Windows.
const start = Date.now();
while (store.waiterCount("oldname") === 0) {
if (Date.now() - start > 4000) throw new Error("CLI never registered a waiter");
await new Promise((r) => setTimeout(r, 25));
}
store.rename("oldname", "newname");
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).toBe(1);
expect(r.stderr).toMatch(/required.*name|name.*required/i);
});
});