import { describe, it, expect } from "vitest"; import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { execFileSync } from "node:child_process"; import { applyInstall, applyUninstall, buildHookCommand, deriveProjectName, deriveSessionName, formatActivePeerList, formatMessagesForHook, parseHookStdin, readSettings, sanitizeProjectName, shortSessionId, writeSettings, type PeerEntry, } from "../src/hook.js"; describe("formatMessagesForHook", () => { it("returns empty string for empty inbox", () => { expect(formatMessagesForHook("bob", [])).toBe(""); }); it("formats a single message", () => { const out = formatMessagesForHook("bob", [ { id: 1, from: "alice", body: "hi bob", sentAt: "2026-05-19T10:00:00.000Z" }, ]); expect(out).toContain("1 new mailbox message"); expect(out).toContain("[#1] from alice (2026-05-19T10:00:00.000Z):"); expect(out).toContain(" hi bob"); }); it("formats multiple messages with plural header", () => { const out = formatMessagesForHook("bob", [ { id: 1, from: "alice", body: "one", sentAt: "2026-05-19T10:00:00.000Z" }, { id: 2, from: "carol", body: "two", sentAt: "2026-05-19T10:01:00.000Z" }, ]); expect(out).toContain("2 new mailbox messages"); expect(out).toContain("[#1] from alice"); expect(out).toContain("[#2] from carol"); }); it("preserves multi-line bodies with indentation", () => { const out = formatMessagesForHook("bob", [ { id: 1, from: "alice", body: "line1\nline2", sentAt: "2026-05-19T10:00:00.000Z" }, ]); expect(out).toContain(" line1"); expect(out).toContain(" line2"); }); }); describe("buildHookCommand", () => { it("builds a basic command", () => { expect(buildHookCommand("alice")).toBe("claude-mailbox check --name alice --hook"); }); it("appends --url when provided", () => { expect(buildHookCommand("alice", "http://127.0.0.1:9000")).toBe( "claude-mailbox check --name alice --hook --url http://127.0.0.1:9000", ); }); it("quotes names with spaces", () => { const cmd = buildHookCommand("my mailbox"); expect(cmd).toContain('--name "my mailbox"'); }); }); describe("applyInstall", () => { it("creates hooks structure from empty settings", () => { const s: Record = {}; const r = applyInstall(s, "claude-mailbox check --name bob --hook"); expect(r).toEqual({ changed: true, reason: "added" }); expect(s).toMatchObject({ hooks: { UserPromptSubmit: [ { matcher: "", hooks: [{ type: "command", command: "claude-mailbox check --name bob --hook" }], }, ], }, }); }); it("is idempotent — does not duplicate the same command", () => { const s: Record = {}; applyInstall(s, "claude-mailbox check --name bob --hook"); const r = applyInstall(s, "claude-mailbox check --name bob --hook"); expect(r).toEqual({ changed: false, reason: "already-present" }); const groups = (s.hooks as { UserPromptSubmit: { hooks: unknown[] }[] }).UserPromptSubmit; expect(groups[0]!.hooks).toHaveLength(1); }); it("preserves existing unrelated hooks", () => { const s: Record = { hooks: { UserPromptSubmit: [ { matcher: "", hooks: [{ type: "command", command: "echo something-else" }], }, ], PostToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "logger" }] }], }, }; applyInstall(s, "claude-mailbox check --name bob --hook"); const hooks = s.hooks as { UserPromptSubmit: { hooks: { command: string }[] }[] }; expect(hooks.UserPromptSubmit[0]!.hooks).toHaveLength(2); expect(hooks.UserPromptSubmit[0]!.hooks[0]!.command).toBe("echo something-else"); expect(hooks.UserPromptSubmit[0]!.hooks[1]!.command).toBe( "claude-mailbox check --name bob --hook", ); expect((s.hooks as Record).PostToolUse).toBeDefined(); }); it("adds a new empty-matcher group when only non-empty matchers exist", () => { const s: Record = { hooks: { UserPromptSubmit: [ { matcher: "Bash", hooks: [{ type: "command", command: "echo bash-only" }] }, ], }, }; applyInstall(s, "claude-mailbox check --name bob --hook"); const groups = (s.hooks as { UserPromptSubmit: { matcher?: string }[] }).UserPromptSubmit; expect(groups).toHaveLength(2); expect(groups[1]!.matcher).toBe(""); }); }); describe("applyUninstall", () => { it("removes the hook and cleans up empty structures", () => { const s: Record = {}; applyInstall(s, "claude-mailbox check --name bob --hook"); const r = applyUninstall(s); expect(r).toEqual({ changed: true, reason: "removed" }); expect(s.hooks).toBeUndefined(); }); it("preserves unrelated hooks in the same group", () => { const s: Record = { hooks: { UserPromptSubmit: [ { matcher: "", hooks: [ { type: "command", command: "echo something-else" }, { type: "command", command: "claude-mailbox check --name bob --hook" }, ], }, ], }, }; applyUninstall(s); const hooks = s.hooks as { UserPromptSubmit: { hooks: { command: string }[] }[] }; expect(hooks.UserPromptSubmit[0]!.hooks).toHaveLength(1); expect(hooks.UserPromptSubmit[0]!.hooks[0]!.command).toBe("echo something-else"); }); it("returns not-present when there is nothing to remove", () => { const s: Record = { hooks: { UserPromptSubmit: [{ matcher: "", hooks: [{ type: "command", command: "x" }] }] }, }; const r = applyUninstall(s); expect(r).toEqual({ changed: false, reason: "not-present" }); }); it("removes hooks installed with --url arg", () => { const s: Record = {}; applyInstall(s, "claude-mailbox check --name bob --hook --url http://x"); applyUninstall(s); expect(s.hooks).toBeUndefined(); }); }); describe("parseHookStdin", () => { it("returns null for empty or whitespace input", () => { expect(parseHookStdin(null)).toBeNull(); expect(parseHookStdin("")).toBeNull(); expect(parseHookStdin(" \n ")).toBeNull(); }); it("returns null for non-JSON input", () => { expect(parseHookStdin("not json")).toBeNull(); expect(parseHookStdin("{")).toBeNull(); }); it("returns null for JSON primitives (only objects allowed)", () => { expect(parseHookStdin("42")).toBeNull(); expect(parseHookStdin("\"foo\"")).toBeNull(); expect(parseHookStdin("null")).toBeNull(); }); it("parses a hook payload", () => { const out = parseHookStdin( JSON.stringify({ session_id: "abc12345-de67-89f0-1234-567890abcdef", hook_event_name: "UserPromptSubmit", prompt: "hi", }), ); expect(out?.session_id).toBe("abc12345-de67-89f0-1234-567890abcdef"); expect(out?.hook_event_name).toBe("UserPromptSubmit"); }); }); describe("shortSessionId", () => { it("takes first 8 hex chars from a UUID", () => { expect(shortSessionId("abc12345-de67-89f0-1234-567890abcdef")).toBe("abc12345"); }); it("normalizes case and ignores hyphens", () => { expect(shortSessionId("ABC12345-DE67-89F0-1234-567890ABCDEF")).toBe("abc12345"); }); it("falls back to a sanitized prefix for non-hex ids", () => { expect(shortSessionId("session-Test123")).toBe("sessiont"); }); }); describe("sanitizeProjectName", () => { it("lowercases and replaces non-alnum with dashes", () => { expect(sanitizeProjectName("My Project!")).toBe("my-project"); }); it("collapses runs of separators", () => { expect(sanitizeProjectName("foo __ bar")).toBe("foo-bar"); }); it("trims leading/trailing dashes", () => { expect(sanitizeProjectName("--foo--")).toBe("foo"); }); it("returns empty for purely non-alnum input", () => { expect(sanitizeProjectName("---")).toBe(""); expect(sanitizeProjectName("")).toBe(""); expect(sanitizeProjectName(null)).toBe(""); expect(sanitizeProjectName(undefined)).toBe(""); }); it("caps long names", () => { const out = sanitizeProjectName("a".repeat(120)); expect(out.length).toBeLessThanOrEqual(40); }); }); describe("deriveProjectName", () => { it("uses cwd basename when not in a git repo", () => { // tmpdir is virtually never inside a git repo; basename is platform-dependent. const got = deriveProjectName(tmpdir()); expect(got).toMatch(/^[a-z0-9-]+$/); }); it("falls back to 'claude' when cwd is empty", () => { expect(deriveProjectName("")).toBe("claude"); expect(deriveProjectName(null)).toBe("claude"); expect(deriveProjectName(undefined)).toBe("claude"); }); it("uses git toplevel basename when called from inside a repo", () => { // The test harness itself runs inside the claude-mailbox checkout. let inRepo = false; try { execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: "pipe" }); inRepo = true; } catch { inRepo = false; } if (!inRepo) return; // CI without git in PATH — skip. const got = deriveProjectName(process.cwd()); // Anywhere in the repo, we should resolve to the repo's basename — sanitized. expect(got).toMatch(/^[a-z0-9-]+$/); expect(got.length).toBeGreaterThan(0); }); }); describe("deriveSessionName", () => { it("composes -", () => { const got = deriveSessionName("abc12345-de67-89f0-1234-567890abcdef", ""); expect(got).toBe("claude-abc12345"); }); it("derives different names for different sessions in the same project", () => { const a = deriveSessionName("aaaa1111-de67-89f0-1234-567890abcdef", ""); const b = deriveSessionName("bbbb2222-de67-89f0-1234-567890abcdef", ""); expect(a).not.toBe(b); }); }); describe("formatActivePeerList", () => { const NOW = new Date("2026-05-19T12:00:00.000Z").getTime(); const peer = (name: string, isoOffsetMinutes: number): PeerEntry => ({ name, lastSeenAt: new Date(NOW - isoOffsetMinutes * 60_000).toISOString(), }); it("excludes self from the list", () => { const out = formatActivePeerList( [peer("self", 1), peer("alice", 1)], "self", { windowMinutes: 60, maxPeers: 10, now: NOW }, ); const joined = out.join("\n"); expect(joined).not.toContain("self"); expect(joined).toContain("alice"); }); it("filters out peers older than the window", () => { const out = formatActivePeerList( [peer("recent", 5), peer("stale", 120)], "self", { windowMinutes: 60, maxPeers: 10, now: NOW }, ); const joined = out.join("\n"); expect(joined).toContain("recent"); expect(joined).not.toContain("stale"); expect(out[0]).toContain("1 of 2 total"); }); it("returns a no-peers message when nothing is active", () => { const out = formatActivePeerList( [peer("ancient", 9999)], "self", { windowMinutes: 60, maxPeers: 10, now: NOW }, ); expect(out).toHaveLength(1); expect(out[0]).toMatch(/No other mailboxes seen within the last 60 minutes/); expect(out[0]).toContain("1 total registered"); }); it("caps at maxPeers and sorts most-recent first", () => { const out = formatActivePeerList( [peer("p1", 30), peer("p2", 20), peer("p3", 10)], "self", { windowMinutes: 60, maxPeers: 2, now: NOW }, ); const joined = out.join("\n"); expect(joined).toContain("p3"); expect(joined).toContain("p2"); expect(joined).not.toContain("p1"); expect(out[0]).toContain("2 of 3 total"); expect(joined.indexOf("p3")).toBeLessThan(joined.indexOf("p2")); }); it("ignores peers with invalid lastSeenAt", () => { const out = formatActivePeerList( [{ name: "garbage", lastSeenAt: "not-a-date" }, peer("ok", 5)], "self", { windowMinutes: 60, maxPeers: 10, now: NOW }, ); const joined = out.join("\n"); expect(joined).toContain("ok"); expect(joined).not.toContain("garbage"); }); }); describe("readSettings / writeSettings roundtrip", () => { it("survives an install → write → read cycle", () => { const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-")); try { const path = join(dir, "settings.json"); const s = readSettings(path); expect(s).toEqual({}); applyInstall(s, "claude-mailbox check --name bob --hook"); writeSettings(path, s); const reloaded = readSettings(path); expect(reloaded.hooks?.UserPromptSubmit?.[0]?.hooks[0]?.command).toBe( "claude-mailbox check --name bob --hook", ); } finally { rmSync(dir, { recursive: true, force: true }); } }); it("creates parent .claude directory when missing", () => { const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-")); try { const path = join(dir, "nested", ".claude", "settings.json"); writeSettings(path, { hooks: {} }); expect(readFileSync(path, "utf8")).toContain('"hooks"'); } finally { rmSync(dir, { recursive: true, force: true }); } }); it("preserves non-hook settings keys", () => { const dir = mkdtempSync(join(tmpdir(), "claude-mailbox-hook-")); try { const path = join(dir, "settings.json"); mkdirSync(dir, { recursive: true }); writeFileSync( path, JSON.stringify({ model: "sonnet", permissions: { allow: ["Bash"] } }, null, 2), ); const s = readSettings(path); applyInstall(s, "claude-mailbox check --name bob --hook"); writeSettings(path, s); const reloaded = readSettings(path) as { model?: string; permissions?: { allow?: string[] }; hooks?: unknown; }; expect(reloaded.model).toBe("sonnet"); expect(reloaded.permissions?.allow).toEqual(["Bash"]); expect(reloaded.hooks).toBeDefined(); } finally { rmSync(dir, { recursive: true, force: true }); } }); });