Files
ClaudeMailbox/node/tests/hook.test.ts
Mika Kuns 48b6ba6452 feat(plugin): SessionStart hook discovers and announces active peers
session-announce now calls /v1/list with the session's X-Mailbox header,
which both registers the session with the daemon and returns all known
mailboxes in one round-trip. The output appends an "Active peers" block
listing mailboxes seen within the last hour (configurable via
--peer-window-minutes), capped at 10 entries by default. Self is
filtered out; the list is sorted most-recent-first.

So when the user says "I started a second session, coordinate with it",
Claude already has the peer's mailbox name in context — no manual
list_mailboxes call needed.

The peer-formatting logic is extracted into formatActivePeerList for
unit testing; CLI tests now pin --url to an unreachable port to keep
assertions stable on machines that have a real daemon running.
2026-05-19 11:59:31 +02:00

366 lines
12 KiB
TypeScript

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 {
applyInstall,
applyUninstall,
buildHookCommand,
deriveSessionName,
formatActivePeerList,
formatMessagesForHook,
parseHookStdin,
readSettings,
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<string, unknown> = {};
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<string, unknown> = {};
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<string, unknown> = {
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<string, unknown>).PostToolUse).toBeDefined();
});
it("adds a new empty-matcher group when only non-empty matchers exist", () => {
const s: Record<string, unknown> = {
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<string, unknown> = {};
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<string, unknown> = {
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<string, unknown> = {
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<string, unknown> = {};
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 / deriveSessionName", () => {
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");
});
it("derives anonymous name when no base", () => {
expect(deriveSessionName("abc12345-de67-89f0-1234-567890abcdef")).toBe("claude-abc12345");
});
it("prepends base prefix when given", () => {
expect(deriveSessionName("abc12345-de67-89f0-1234-567890abcdef", "backend")).toBe(
"backend-abc12345",
);
});
it("treats whitespace-only base as no base", () => {
expect(deriveSessionName("abc12345-de67-89f0-1234-567890abcdef", " ")).toBe(
"claude-abc12345",
);
});
it("derives different names for different sessions with the same base", () => {
const a = deriveSessionName("aaaa1111-de67-89f0-1234-567890abcdef", "shared");
const b = deriveSessionName("bbbb2222-de67-89f0-1234-567890abcdef", "shared");
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 });
}
});
});