import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import {
loadHooks,
matchGlob,
runBeforeToolHook,
runAfterToolHook,
runSessionHook,
executeHook,
type HookConfig,
} from "./hooks.js";
// ============================================================================
// matchGlob
// ============================================================================
describe("matchGlob", () => {
it("matches exact name", () => {
expect(matchGlob("bash", "bash")).toBe(true);
});
it("rejects non-matching name", () => {
expect(matchGlob("bash", "read_file")).toBe(false);
});
it("matches wildcard *", () => {
expect(matchGlob("read_*", "read_file")).toBe(true);
expect(matchGlob("read_*", "read_image")).toBe(true);
expect(matchGlob("read_*", "write_file")).toBe(false);
});
it("matches single char ?", () => {
expect(matchGlob("bas?", "bash")).toBe(true);
expect(matchGlob("bas?", "base")).toBe(true);
expect(matchGlob("bas?", "ba")).toBe(false);
});
it("matches * in middle", () => {
expect(matchGlob("run_*_tool", "run_local_tool")).toBe(true);
expect(matchGlob("run_*_tool", "run_tool")).toBe(false);
});
it("matches everything with *", () => {
expect(matchGlob("*", "anything")).toBe(true);
expect(matchGlob("*", "")).toBe(true);
});
it("escapes regex special chars in pattern", () => {
expect(matchGlob("file.txt", "file.txt")).toBe(true);
expect(matchGlob("file.txt", "fileTtxt")).toBe(false);
});
});
// ============================================================================
// loadHooks
// ============================================================================
describe("loadHooks", () => {
let tmpDir: string;
beforeEach(() => {
tmpDir = join(tmpdir(), `hooks-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tmpDir, { recursive: true });
});
afterEach(() => {
if (existsSync(tmpDir)) {
rmSync(tmpDir, { recursive: true, force: true });
}
});
it("returns empty array when no hooks files exist", () => {
const hooks = loadHooks(tmpDir);
expect(hooks).toEqual([]);
});
it("loads hooks from project .whale/hooks.json", () => {
const whaleDir = join(tmpDir, ".whale");
mkdirSync(whaleDir, { recursive: true });
writeFileSync(
join(whaleDir, "hooks.json"),
JSON.stringify([
{ event: "BeforeTool", command: "echo before" },
{ event: "AfterTool", command: "echo after" },
]),
);
const hooks = loadHooks(tmpDir);
expect(hooks).toHaveLength(2);
expect(hooks[0].event).toBe("BeforeTool");
expect(hooks[1].event).toBe("AfterTool");
});
it("skips invalid hooks (missing event or command)", () => {
const whaleDir = join(tmpDir, ".whale");
mkdirSync(whaleDir, { recursive: true });
writeFileSync(
join(whaleDir, "hooks.json"),
JSON.stringify([
{ event: "BeforeTool", command: "echo valid" },
{ event: "BeforeTool" }, // missing command
{ command: "echo no-event" }, // missing event
{ event: "InvalidEvent", command: "echo bad" }, // invalid event
]),
);
const hooks = loadHooks(tmpDir);
expect(hooks).toHaveLength(1);
expect(hooks[0].command).toBe("echo valid");
});
it("skips non-array JSON files", () => {
const whaleDir = join(tmpDir, ".whale");
mkdirSync(whaleDir, { recursive: true });
writeFileSync(
join(whaleDir, "hooks.json"),
JSON.stringify({ hooks: [{ event: "BeforeTool", command: "echo" }] }),
);
const hooks = loadHooks(tmpDir);
expect(hooks).toEqual([]);
});
it("skips malformed JSON files", () => {
const whaleDir = join(tmpDir, ".whale");
mkdirSync(whaleDir, { recursive: true });
writeFileSync(join(whaleDir, "hooks.json"), "not json at all");
const hooks = loadHooks(tmpDir);
expect(hooks).toEqual([]);
});
it("preserves optional fields", () => {
const whaleDir = join(tmpDir, ".whale");
mkdirSync(whaleDir, { recursive: true });
writeFileSync(
join(whaleDir, "hooks.json"),
JSON.stringify([
{
event: "BeforeTool",
command: "echo check",
pattern: "bash",
timeout: 5000,
allowModify: true,
},
]),
);
const hooks = loadHooks(tmpDir);
expect(hooks).toHaveLength(1);
expect(hooks[0].pattern).toBe("bash");
expect(hooks[0].timeout).toBe(5000);
expect(hooks[0].allowModify).toBe(true);
});
});
// ============================================================================
// executeHook
// ============================================================================
describe("executeHook", () => {
it("sends payload on stdin and parses JSON stdout", async () => {
const hook: HookConfig = {
event: "BeforeTool",
command: 'cat | python3 -c "import sys,json; d=json.load(sys.stdin); print(json.dumps({\'allow\': True, \'message\': d[\'tool_name\']}))"',
timeout: 5000,
};
const result = await executeHook(hook, {
event: "BeforeTool",
tool_name: "bash",
tool_input: { command: "ls" },
});
expect(result).not.toBeNull();
expect(result!.allow).toBe(true);
expect(result!.message).toBe("bash");
});
it("returns null for non-JSON stdout", async () => {
const hook: HookConfig = {
event: "BeforeTool",
command: "echo 'just plain text'",
timeout: 5000,
};
const result = await executeHook(hook, { event: "BeforeTool" });
expect(result).toBeNull();
});
it("returns null when hook exits non-zero", async () => {
const hook: HookConfig = {
event: "BeforeTool",
command: "exit 1",
timeout: 5000,
};
const result = await executeHook(hook, { event: "BeforeTool" });
expect(result).toBeNull();
});
it("returns null and kills on timeout", async () => {
const hook: HookConfig = {
event: "BeforeTool",
command: "sleep 30",
timeout: 200,
};
const start = Date.now();
const result = await executeHook(hook, { event: "BeforeTool" });
const elapsed = Date.now() - start;
expect(result).toBeNull();
expect(elapsed).toBeLessThan(2000); // Should kill well before 30s
});
it("returns null for empty stdout", async () => {
const hook: HookConfig = {
event: "BeforeTool",
command: "true",
timeout: 5000,
};
const result = await executeHook(hook, { event: "BeforeTool" });
expect(result).toBeNull();
});
it("parses JSON output with allow=false", async () => {
const hook: HookConfig = {
event: "BeforeTool",
command: 'echo \'{"allow": false, "message": "denied"}\'',
timeout: 5000,
};
const result = await executeHook(hook, { event: "BeforeTool" });
expect(result).not.toBeNull();
expect(result!.allow).toBe(false);
expect(result!.message).toBe("denied");
});
});
// ============================================================================
// runBeforeToolHook
// ============================================================================
describe("runBeforeToolHook", () => {
it("returns allow: true when no hooks match", async () => {
const result = await runBeforeToolHook([], "bash", { command: "ls" });
expect(result.allow).toBe(true);
});
it("returns allow: true when hooks have non-matching patterns", async () => {
const hooks: HookConfig[] = [
{ event: "BeforeTool", command: "echo hi", pattern: "read_*" },
];
const result = await runBeforeToolHook(hooks, "bash", { command: "ls" });
expect(result.allow).toBe(true);
});
it("blocks when hook outputs allow: false", async () => {
const hooks: HookConfig[] = [
{
event: "BeforeTool",
command: 'echo \'{"allow": false, "message": "no bash allowed"}\'',
},
];
const result = await runBeforeToolHook(hooks, "bash", { command: "ls" });
expect(result.allow).toBe(false);
expect(result.message).toBe("no bash allowed");
});
it("allows when hook exits non-zero (warning only)", async () => {
const hooks: HookConfig[] = [
{ event: "BeforeTool", command: "exit 1" },
];
const result = await runBeforeToolHook(hooks, "bash", { command: "ls" });
expect(result.allow).toBe(true);
});
it("returns modified input when allowModify is true", async () => {
const hooks: HookConfig[] = [
{
event: "BeforeTool",
command: 'echo \'{"modified_input": {"command": "ls -la"}}\'',
allowModify: true,
},
];
const result = await runBeforeToolHook(hooks, "bash", { command: "ls" });
expect(result.allow).toBe(true);
expect(result.modifiedInput).toEqual({ command: "ls -la" });
});
it("ignores modified_input when allowModify is false", async () => {
const hooks: HookConfig[] = [
{
event: "BeforeTool",
command: 'echo \'{"modified_input": {"command": "rm -rf /"}}\'',
allowModify: false,
},
];
const result = await runBeforeToolHook(hooks, "bash", { command: "ls" });
expect(result.allow).toBe(true);
expect(result.modifiedInput).toBeUndefined();
});
it("pattern-matches tool names with globs", async () => {
const hooks: HookConfig[] = [
{
event: "BeforeTool",
command: 'echo \'{"allow": false, "message": "blocked"}\'',
pattern: "write_*",
},
];
// Should not match
const r1 = await runBeforeToolHook(hooks, "read_file", {});
expect(r1.allow).toBe(true);
// Should match
const r2 = await runBeforeToolHook(hooks, "write_file", {});
expect(r2.allow).toBe(false);
});
it("skips AfterTool hooks", async () => {
const hooks: HookConfig[] = [
{
event: "AfterTool",
command: 'echo \'{"allow": false}\'',
},
];
const result = await runBeforeToolHook(hooks, "bash", { command: "ls" });
expect(result.allow).toBe(true);
});
});
// ============================================================================
// runAfterToolHook
// ============================================================================
describe("runAfterToolHook", () => {
it("returns empty when no hooks match", async () => {
const result = await runAfterToolHook([], "bash", "output", true);
expect(result).toEqual({});
});
it("returns modified output when allowModify is true", async () => {
const hooks: HookConfig[] = [
{
event: "AfterTool",
command: 'echo \'{"modified_output": "cleaned output"}\'',
allowModify: true,
},
];
const result = await runAfterToolHook(hooks, "bash", "raw output", true);
expect(result.modifiedOutput).toBe("cleaned output");
});
it("ignores modified_output when allowModify is false", async () => {
const hooks: HookConfig[] = [
{
event: "AfterTool",
command: 'echo \'{"modified_output": "hacked output"}\'',
allowModify: false,
},
];
const result = await runAfterToolHook(hooks, "bash", "real output", true);
expect(result.modifiedOutput).toBeUndefined();
});
it("skips BeforeTool hooks", async () => {
const hooks: HookConfig[] = [
{
event: "BeforeTool",
command: 'echo \'{"modified_output": "should not appear"}\'',
allowModify: true,
},
];
const result = await runAfterToolHook(hooks, "bash", "output", true);
expect(result).toEqual({});
});
});
// ============================================================================
// runSessionHook
// ============================================================================
describe("runSessionHook", () => {
it("completes without error when no hooks match", async () => {
await expect(
runSessionHook([], "SessionStart", {}),
).resolves.not.toThrow();
});
it("runs matching session hooks", async () => {
const hooks: HookConfig[] = [
{ event: "SessionStart", command: "true" },
{ event: "SessionEnd", command: "true" },
];
// Should not throw
await runSessionHook(hooks, "SessionStart");
await runSessionHook(hooks, "SessionEnd");
});
it("does not throw when hook fails", async () => {
const hooks: HookConfig[] = [
{ event: "SessionStart", command: "exit 1" },
];
await expect(
runSessionHook(hooks, "SessionStart"),
).resolves.not.toThrow();
});
it("runs multiple session hooks concurrently", async () => {
const hooks: HookConfig[] = [
{ event: "SessionStart", command: "true" },
{ event: "SessionStart", command: "true" },
{ event: "SessionStart", command: "true" },
];
const start = Date.now();
await runSessionHook(hooks, "SessionStart");
const elapsed = Date.now() - start;
// All should run in parallel, so should be fast
expect(elapsed).toBeLessThan(3000);
});
});