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>
102 lines
4.4 KiB
TypeScript
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).");
|
|
});
|
|
});
|