Files
locode/src/hooks/runner.test.ts
T
kimandClaude Sonnet 5 2a35f40c73 Add plugin/hook/skill support, images, @mentions, dashboard, and stability fixes
Builds out locode's Claude Code plugin parity: MCP servers, slash commands,
sub-agents, hooks (12 lifecycle events), and skills, all loadable from a
local path or git URL. Also adds image support (read_file, /import), @-mention
file autocomplete, a /dashboard stats view, /export with an editable filename
prompt, an expanded git tool (reset/stash/merge/rebase/delete_branch), and a
redesigned status bar.

On top of that, a review pass found and fixed 10 correctness/stability bugs:
argument-injection in git reset/merge/rebase (a ref like "--hard" was parsed
as a flag), tool-call image results interleaving with native tool_call_id
messages and breaking OpenAI-compatible message ordering, backgrounded bash
jobs still being silently killed by their original timeout with the kill
masked as a clean exit, a SubagentStart hook's block being ignored, the
sub-agent timeout clock starting before the hook it should exclude, a
template-expansion bug that could re-substitute $1..$9 placeholders,
unbounded background-job output buffers, a missing directory-target fallback
in /export, and image size caps checked after reading the whole file instead
of before.

Session saves and exports now go through a shared atomic
write-then-rename helper so a crash can't leave a truncated file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 18:17:45 +09:00

102 lines
4.4 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { runHooksForEvent } from "./runner.js";
import type { HooksConfig } from "./types.js";
const loadMergedHooks = await vi.hoisted(async () => {
return { loadMergedHooks: vi.fn() };
});
vi.mock("./config.js", async () => {
return { loadMergedHooks: loadMergedHooks.loadMergedHooks };
});
const execa = await vi.hoisted(async () => {
return { execa: vi.fn() };
});
vi.mock("execa", async () => {
return { execa: execa.execa };
});
describe("runHooksForEvent", () => {
const ctx = { sessionId: "s1", cwd: "/repo" };
it("returns empty when no hooks are configured", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({});
const result = await runHooksForEvent("SessionStart", ctx, {});
expect(result).toEqual({ blocked: false, warnings: [] });
});
it("blocks on exit code 2", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
UserPromptSubmit: [{ hooks: [{ type: "command", command: "exit 2" }] }],
});
execa.execa.mockResolvedValueOnce({ exitCode: 2, stdout: "", stderr: "policy violation", timedOut: false });
const result = await runHooksForEvent("UserPromptSubmit", ctx, { prompt: "hi" });
expect(result.blocked).toBe(true);
expect(result.reason).toBe("policy violation");
});
it("warns on non-zero, non-2 exits", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
SessionStart: [{ hooks: [{ type: "command", command: "exit 1" }] }],
});
execa.execa.mockResolvedValueOnce({ exitCode: 1, stdout: "", stderr: "boom", timedOut: false });
const result = await runHooksForEvent("SessionStart", ctx, {});
expect(result.warnings).toContain("boom");
});
it("collects stdout as additional context on exit 0", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
SessionStart: [{ hooks: [{ type: "command", command: "echo hello" }] }],
});
execa.execa.mockResolvedValueOnce({ exitCode: 0, stdout: "hello", stderr: "", timedOut: false });
const result = await runHooksForEvent("SessionStart", ctx, {});
expect(result.additionalContext).toBe("hello");
});
it("parses JSON output when outputSchema is json", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
SessionStart: [{ hooks: [{ type: "command", command: "echo '{\"foo\":1}'", outputSchema: "json" }] }],
});
execa.execa.mockResolvedValueOnce({ exitCode: 0, stdout: '{"foo":1}', stderr: "", timedOut: false });
const result = await runHooksForEvent("SessionStart", ctx, {});
expect(result.jsonContext).toEqual([{ foo: 1 }]);
expect(result.additionalContext).toBeUndefined();
});
it("warns when JSON output fails to parse", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
SessionStart: [{ hooks: [{ type: "command", command: "echo 'not json'", outputSchema: "json" }] }],
});
execa.execa.mockResolvedValueOnce({ exitCode: 0, stdout: "not json", stderr: "", timedOut: false });
const result = await runHooksForEvent("SessionStart", ctx, {});
expect(result.warnings.some((w) => w.includes("JSON output could not be parsed"))).toBe(true);
});
it("filters tool-scoped hooks by matcher", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
PreToolUse: [{ matcher: "bash,write_file", hooks: [{ type: "command", command: "echo ok" }] }],
});
execa.execa.mockResolvedValueOnce({ exitCode: 0, stdout: "ok", stderr: "", timedOut: false });
const result = await runHooksForEvent("PreToolUse", ctx, { tool_name: "bash", tool_input: {} }, "bash");
expect(result.additionalContext).toBe("ok");
});
it("skips tool-scoped hooks when the matcher doesn't match", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
PreToolUse: [{ matcher: "bash", hooks: [{ type: "command", command: "echo ok" }] }],
});
const result = await runHooksForEvent("PreToolUse", ctx, { tool_name: "read_file", tool_input: {} }, "read_file");
expect(result.additionalContext).toBeUndefined();
});
it("warns for unsupported prompt hooks", async () => {
loadMergedHooks.loadMergedHooks.mockReturnValueOnce({
SessionStart: [{ hooks: [{ type: "prompt", message: "ok?" } as any] }],
});
const result = await runHooksForEvent("SessionStart", ctx, {});
expect(result.warnings).toContain("1 prompt hook(s) skipped (not yet implemented).");
});
});