agent-orchestrator/packages/plugins/agent-claude-code/src/hook-script.integration.tes...

307 lines
10 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { execSync } from "node:child_process";
import { mkdtempSync, writeFileSync, readFileSync, rmSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { METADATA_UPDATER_SCRIPT } from "./index.js";
// ---------------------------------------------------------------------------
// Integration tests for the metadata-updater.sh hook script.
// These execute the actual bash script with various inputs and verify that
// session metadata files are updated correctly.
// ---------------------------------------------------------------------------
let testDir: string;
let hookScriptPath: string;
beforeAll(() => {
testDir = mkdtempSync(join(tmpdir(), "ao-hook-test-"));
hookScriptPath = join(testDir, "metadata-updater.sh");
writeFileSync(hookScriptPath, METADATA_UPDATER_SCRIPT, { mode: 0o755 });
});
afterAll(() => {
rmSync(testDir, { recursive: true, force: true });
});
/**
* Run the hook script with given parameters and return the script's
* stdout plus the updated metadata file contents.
*/
function runHook(opts: {
command: string;
toolName?: string;
output?: string;
exitCode?: number;
metadataContent?: string;
}): { stdout: string; metadata: string } {
const sessionId = `test-session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const sessionsDir = join(testDir, "sessions");
mkdirSync(sessionsDir, { recursive: true });
const metadataFile = join(sessionsDir, sessionId);
writeFileSync(metadataFile, opts.metadataContent ?? "status=spawning\n");
const input = JSON.stringify({
tool_name: opts.toolName ?? "Bash",
tool_input: { command: opts.command },
tool_response: opts.output ?? "",
exit_code: opts.exitCode ?? 0,
});
let stdout: string;
try {
stdout = execSync(`bash "${hookScriptPath}"`, {
input,
env: {
...process.env,
AO_SESSION: sessionId,
AO_DATA_DIR: sessionsDir,
HOME: testDir,
},
encoding: "utf-8",
timeout: 5000,
});
} catch (err: unknown) {
const e = err as { stdout?: string };
stdout = e.stdout ?? "";
}
let metadata: string;
try {
metadata = readFileSync(metadataFile, "utf-8");
} catch {
metadata = "";
}
return { stdout, metadata };
}
// =========================================================================
// gh pr create detection
// =========================================================================
describe("hook script: gh pr create", () => {
const prUrl = "https://github.com/owner/repo/pull/42";
it("detects plain gh pr create", () => {
const { metadata } = runHook({
command: 'gh pr create --title "fix" --body "test" --base master',
output: `Creating pull request...\n${prUrl}\n`,
});
expect(metadata).toContain(`pr=${prUrl}`);
expect(metadata).toContain("status=pr_open");
});
it("detects gh pr create with cd && prefix", () => {
const { metadata } = runHook({
command: `cd ~/.worktrees/mercury/cleanup && gh pr create --title "fix" --base master`,
output: `${prUrl}`,
});
expect(metadata).toContain(`pr=${prUrl}`);
expect(metadata).toContain("status=pr_open");
});
it("detects gh pr create with cd ; prefix", () => {
const { metadata } = runHook({
command: `cd /some/path ; gh pr create --title "test" --body "body"`,
output: prUrl,
});
expect(metadata).toContain(`pr=${prUrl}`);
expect(metadata).toContain("status=pr_open");
});
it("detects gh pr create with multiple chained cd prefixes", () => {
const { metadata } = runHook({
command: `cd /tmp && cd ~/.worktrees/mercury && gh pr create --title "fix" --base master`,
output: prUrl,
});
expect(metadata).toContain(`pr=${prUrl}`);
expect(metadata).toContain("status=pr_open");
});
it("does NOT update metadata when PR URL is missing from output", () => {
const { metadata } = runHook({
command: 'gh pr create --title "fix"',
output: "Error: something went wrong",
});
expect(metadata).not.toContain("pr=");
expect(metadata).toContain("status=spawning");
});
});
// =========================================================================
// git checkout -b / git switch -c detection
// =========================================================================
describe("hook script: git checkout -b / git switch -c", () => {
it("detects plain git checkout -b", () => {
const { metadata } = runHook({
command: "git checkout -b feat/my-feature",
});
expect(metadata).toContain("branch=feat/my-feature");
});
it("detects plain git switch -c", () => {
const { metadata } = runHook({
command: "git switch -c fix/bug-123",
});
expect(metadata).toContain("branch=fix/bug-123");
});
it("detects git checkout -b with cd && prefix", () => {
const { metadata } = runHook({
command: "cd /some/project && git checkout -b feat/new-feature",
});
expect(metadata).toContain("branch=feat/new-feature");
});
it("detects git switch -c with cd && prefix", () => {
const { metadata } = runHook({
command: "cd ~/.worktrees/project && git switch -c fix/issue-456",
});
expect(metadata).toContain("branch=fix/issue-456");
});
it("detects git checkout -b with cd ; prefix", () => {
const { metadata } = runHook({
command: "cd /project ; git checkout -b feat/semicolon-test",
});
expect(metadata).toContain("branch=feat/semicolon-test");
});
it("detects git checkout -b with multiple cd prefixes", () => {
const { metadata } = runHook({
command: "cd /tmp && cd /project && git checkout -b feat/chained",
});
expect(metadata).toContain("branch=feat/chained");
});
});
// =========================================================================
// gh pr merge detection
// =========================================================================
describe("hook script: gh pr merge", () => {
it("detects plain gh pr merge", () => {
const { metadata } = runHook({
command: "gh pr merge 123 --squash",
});
expect(metadata).toContain("status=merged");
});
it("detects gh pr merge with cd && prefix", () => {
const { metadata } = runHook({
command: "cd ~/.worktrees/project && gh pr merge 42 --squash",
});
expect(metadata).toContain("status=merged");
});
it("detects gh pr merge with cd ; prefix", () => {
const { metadata } = runHook({
command: "cd /project ; gh pr merge --rebase",
});
expect(metadata).toContain("status=merged");
});
});
// =========================================================================
// git checkout <existing-branch> detection (feature branches)
// =========================================================================
describe("hook script: git checkout existing branch", () => {
it("detects plain git checkout of a feature branch", () => {
const { metadata } = runHook({
command: "git checkout feat/existing-branch",
});
expect(metadata).toContain("branch=feat/existing-branch");
});
it("detects git checkout of a feature branch with cd prefix", () => {
const { metadata } = runHook({
command: "cd /project && git checkout feat/existing-branch",
});
expect(metadata).toContain("branch=feat/existing-branch");
});
});
// =========================================================================
// Non-matching commands (should NOT modify metadata)
// =========================================================================
describe("hook script: non-matching commands", () => {
it("ignores plain ls command", () => {
const { metadata } = runHook({
command: "ls -la",
});
expect(metadata).toBe("status=spawning\n");
});
it("ignores non-Bash tool calls", () => {
const { metadata } = runHook({
command: 'gh pr create --title "test"',
toolName: "Read",
output: "https://github.com/owner/repo/pull/1",
});
expect(metadata).toBe("status=spawning\n");
});
it("ignores commands with non-zero exit code", () => {
const { metadata } = runHook({
command: 'gh pr create --title "test"',
exitCode: 1,
output: "https://github.com/owner/repo/pull/1",
});
expect(metadata).toBe("status=spawning\n");
});
it("ignores cd-only commands (no chained git/gh)", () => {
const { metadata } = runHook({
command: "cd /some/directory",
});
expect(metadata).toBe("status=spawning\n");
});
it("ignores git status", () => {
const { metadata } = runHook({
command: "cd /project && git status",
});
expect(metadata).toBe("status=spawning\n");
});
});
// =========================================================================
// Metadata file update mechanics
// =========================================================================
describe("hook script: metadata file updates", () => {
it("updates an existing key in the metadata file", () => {
const { metadata } = runHook({
command: "gh pr merge 10 --squash",
metadataContent: "status=pr_open\nbranch=feat/test\n",
});
expect(metadata).toContain("status=merged");
expect(metadata).toContain("branch=feat/test");
expect(metadata).not.toContain("status=pr_open");
});
it("appends a new key to the metadata file", () => {
const { metadata } = runHook({
command: 'gh pr create --title "test"',
output: "https://github.com/owner/repo/pull/99",
metadataContent: "branch=feat/test\n",
});
expect(metadata).toContain("branch=feat/test");
expect(metadata).toContain("pr=https://github.com/owner/repo/pull/99");
expect(metadata).toContain("status=pr_open");
});
it("returns systemMessage JSON on successful detection", () => {
const { stdout } = runHook({
command: "gh pr merge 1 --squash",
});
expect(stdout).toContain("systemMessage");
expect(stdout).toContain("merged");
});
it("returns empty JSON for non-matching commands", () => {
const { stdout } = runHook({
command: "echo hello",
});
expect(stdout.trim()).toBe("{}");
});
});