mirror of
https://github.com/millionco/react-doctor.git
synced 2026-09-14 20:00:24 +08:00
738 lines
25 KiB
TypeScript
738 lines
25 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { tmpdir } from "node:os";
|
|
import * as path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
|
|
import {
|
|
findAgentsWithOutdatedReactDoctorHooks,
|
|
installReactDoctorAgentHooks,
|
|
} from "../src/cli/utils/install-agent-hooks.js";
|
|
import * as fs from "node:fs";
|
|
|
|
interface AgentHooksFixture {
|
|
readonly projectRoot: string;
|
|
readonly cleanup: () => void;
|
|
}
|
|
|
|
interface AgentHookJsonOutput {
|
|
readonly followup_message: string;
|
|
}
|
|
|
|
interface ClaudeAgentHookJsonOutput {
|
|
readonly decision: "block";
|
|
readonly reason: string;
|
|
}
|
|
|
|
interface FakeBinaryOptions {
|
|
readonly exitCode: number;
|
|
readonly output?: string;
|
|
readonly invocationFileName?: string;
|
|
}
|
|
|
|
const setupFixture = (): AgentHooksFixture => {
|
|
const root = fs.mkdtempSync(path.join(tmpdir(), "react-doctor-agent-hooks-"));
|
|
return {
|
|
projectRoot: root,
|
|
cleanup: () => fs.rmSync(root, { recursive: true, force: true }),
|
|
};
|
|
};
|
|
|
|
const readJson = <Value>(filePath: string): Value => JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
|
|
// The agent-hooks suite is POSIX-only (describe.skipIf win32), so the fake
|
|
// binary is a plain `#!/bin/sh` wrapper that execs a node script recording its
|
|
// cwd + argv and emitting the configured output/exit code.
|
|
const writeFakeReactDoctorBinaryAt = (
|
|
binDirectory: string,
|
|
projectRoot: string,
|
|
options: FakeBinaryOptions,
|
|
cwdFileName: string,
|
|
defaultInvocationFileName: string,
|
|
): void => {
|
|
fs.mkdirSync(binDirectory, { recursive: true });
|
|
const output = options.output ?? "fake scan output";
|
|
const invocationFileName = options.invocationFileName ?? defaultInvocationFileName;
|
|
|
|
const scriptPath = path.join(binDirectory, "react-doctor.mjs");
|
|
const scriptContent = [
|
|
"import { writeFileSync } from 'node:fs';",
|
|
"import { join } from 'node:path';",
|
|
"",
|
|
`const projectRoot = ${JSON.stringify(projectRoot)};`,
|
|
`const output = ${JSON.stringify(output)};`,
|
|
`const exitCode = ${options.exitCode};`,
|
|
`const invocationFileName = ${JSON.stringify(invocationFileName)};`,
|
|
`const cwdFileName = ${JSON.stringify(cwdFileName)};`,
|
|
"",
|
|
"try {",
|
|
" writeFileSync(join(projectRoot, '.react-doctor', cwdFileName), process.cwd() + '\\n');",
|
|
" writeFileSync(",
|
|
" join(projectRoot, '.react-doctor', invocationFileName),",
|
|
" process.argv.slice(2).join('\\n') + '\\n'",
|
|
" );",
|
|
" console.log(output);",
|
|
"} catch (error) {}",
|
|
"process.exit(exitCode);",
|
|
].join("\n");
|
|
fs.writeFileSync(scriptPath, scriptContent);
|
|
|
|
const wrapperPath = path.join(binDirectory, "react-doctor");
|
|
fs.writeFileSync(wrapperPath, `#!/bin/sh\nexec node "${scriptPath}" "$@"`);
|
|
fs.chmodSync(wrapperPath, fs.constants.S_IRWXU);
|
|
};
|
|
|
|
const writeFakeReactDoctorBinary = (projectRoot: string, options: FakeBinaryOptions): void =>
|
|
writeFakeReactDoctorBinaryAt(
|
|
path.join(projectRoot, "node_modules/.bin"),
|
|
projectRoot,
|
|
options,
|
|
"agent-hook-cwd.txt",
|
|
"agent-hook-args.txt",
|
|
);
|
|
|
|
const writeFakePathReactDoctorBinary = (
|
|
binDirectory: string,
|
|
projectRoot: string,
|
|
options: FakeBinaryOptions,
|
|
): void =>
|
|
writeFakeReactDoctorBinaryAt(
|
|
binDirectory,
|
|
projectRoot,
|
|
options,
|
|
"path-agent-hook-cwd.txt",
|
|
"path-agent-hook-args.txt",
|
|
);
|
|
|
|
describe.skipIf(process.platform === "win32")("installReactDoctorAgentHooks", () => {
|
|
let fixture: AgentHooksFixture;
|
|
|
|
beforeEach(() => {
|
|
fixture = setupFixture();
|
|
});
|
|
|
|
afterEach(() => {
|
|
fixture.cleanup();
|
|
});
|
|
|
|
it("installs a Claude Code Stop hook without duplicating existing hooks", () => {
|
|
const settingsPath = path.join(fixture.projectRoot, ".claude/settings.json");
|
|
const hookPath = path.join(fixture.projectRoot, ".claude/hooks/react-doctor.mjs");
|
|
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
fs.writeFileSync(
|
|
settingsPath,
|
|
JSON.stringify({
|
|
permissions: { allow: ["Bash(git status)"] },
|
|
hooks: {
|
|
PostToolBatch: [
|
|
{
|
|
hooks: [{ type: "command", command: "echo existing" }],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["claude-code"],
|
|
});
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["claude-code"],
|
|
});
|
|
|
|
const settings = readJson<{
|
|
permissions: { allow: string[] };
|
|
hooks: {
|
|
PostToolBatch: Array<{ hooks: Array<{ command: string }> }>;
|
|
Stop: Array<{ hooks: Array<{ command: string }> }>;
|
|
};
|
|
}>(settingsPath);
|
|
const hookCommands = settings.hooks.Stop.flatMap((group) =>
|
|
group.hooks.map((hook) => hook.command),
|
|
);
|
|
const hookContent = fs.readFileSync(hookPath, "utf8");
|
|
|
|
expect(result.installedAgents).toEqual(["claude-code"]);
|
|
expect(result.files).toContain(settingsPath);
|
|
expect(settings.permissions.allow).toEqual(["Bash(git status)"]);
|
|
expect(settings.hooks.PostToolBatch).toEqual([
|
|
{ hooks: [{ type: "command", command: "echo existing" }] },
|
|
]);
|
|
expect(hookCommands.filter((command) => command.includes("react-doctor.mjs"))).toHaveLength(1);
|
|
expect(hookContent).toContain("CLAUDE_PROJECT_DIR");
|
|
expect(hookContent).toContain(
|
|
"react-doctor --verbose --scope changed --include-untracked --blocking warning",
|
|
);
|
|
// cmd.exe signals a missing command with exit 9009 (not the POSIX 127) —
|
|
// the generated runner loop must fall through on it or every Windows check
|
|
// reports shell noise as scan findings.
|
|
expect(hookContent).toContain("9009");
|
|
expect(hookContent).toContain("maxBuffer");
|
|
});
|
|
|
|
it("replaces a legacy .sh Claude hook instead of stacking a second entry", () => {
|
|
const settingsPath = path.join(fixture.projectRoot, ".claude/settings.json");
|
|
const legacyScriptPath = path.join(fixture.projectRoot, ".claude/hooks/react-doctor.sh");
|
|
fs.mkdirSync(path.dirname(legacyScriptPath), { recursive: true });
|
|
fs.writeFileSync(legacyScriptPath, "#!/bin/sh\nexit 0\n");
|
|
fs.writeFileSync(
|
|
settingsPath,
|
|
JSON.stringify({
|
|
hooks: {
|
|
PostToolBatch: [
|
|
{
|
|
hooks: [
|
|
{
|
|
type: "command",
|
|
command: 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/react-doctor.sh"',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["claude-code"],
|
|
});
|
|
|
|
const settings = readJson<{
|
|
hooks: {
|
|
PostToolBatch: Array<{ hooks: Array<{ command: string }> }>;
|
|
Stop: Array<{ hooks: Array<{ command: string }> }>;
|
|
};
|
|
}>(settingsPath);
|
|
const hookCommands = settings.hooks.Stop.flatMap((group) =>
|
|
group.hooks.map((hook) => hook.command),
|
|
);
|
|
|
|
expect(settings.hooks.PostToolBatch).toEqual([]);
|
|
expect(hookCommands).toHaveLength(1);
|
|
expect(hookCommands[0]).toContain("react-doctor.mjs");
|
|
expect(fs.existsSync(legacyScriptPath)).toBe(false);
|
|
});
|
|
|
|
it("tolerates a parseable hook entry that lacks a command", () => {
|
|
const configPath = path.join(fixture.projectRoot, ".cursor/hooks.json");
|
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
fs.writeFileSync(
|
|
configPath,
|
|
JSON.stringify({
|
|
version: 1,
|
|
hooks: { postToolUse: [{ matcher: "Write" }] },
|
|
}),
|
|
);
|
|
|
|
expect(() => {
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["cursor"],
|
|
});
|
|
}).not.toThrow();
|
|
|
|
const config = readJson<{ hooks: { stop: Array<{ command?: string }> } }>(configPath);
|
|
expect(config.hooks.stop.some((handler) => handler.command?.includes("react-doctor.mjs"))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("replaces a legacy .sh Cursor hook instead of stacking a second entry", () => {
|
|
const configPath = path.join(fixture.projectRoot, ".cursor/hooks.json");
|
|
const legacyScriptPath = path.join(fixture.projectRoot, ".cursor/hooks/react-doctor.sh");
|
|
fs.mkdirSync(path.dirname(legacyScriptPath), { recursive: true });
|
|
fs.writeFileSync(legacyScriptPath, "#!/bin/sh\nexit 0\n");
|
|
fs.writeFileSync(
|
|
configPath,
|
|
JSON.stringify({
|
|
version: 1,
|
|
hooks: {
|
|
postToolUse: [{ command: ".cursor/hooks/react-doctor.sh", matcher: "Write" }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["cursor"],
|
|
});
|
|
|
|
const config = readJson<{
|
|
hooks: {
|
|
postToolUse: Array<{ command: string }>;
|
|
stop: Array<{ command: string }>;
|
|
};
|
|
}>(configPath);
|
|
|
|
expect(config.hooks.postToolUse).toEqual([]);
|
|
expect(config.hooks.stop).toHaveLength(1);
|
|
expect(config.hooks.stop[0].command).toContain("react-doctor.mjs");
|
|
expect(fs.existsSync(legacyScriptPath)).toBe(false);
|
|
});
|
|
|
|
it("preserves user hook groups the legacy strip didn't touch (empty or hook-less)", () => {
|
|
const settingsPath = path.join(fixture.projectRoot, ".claude/settings.json");
|
|
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
fs.writeFileSync(
|
|
settingsPath,
|
|
JSON.stringify({
|
|
hooks: {
|
|
PostToolBatch: [{ matcher: "Bash" }, { matcher: "Write", hooks: [] }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["claude-code"],
|
|
});
|
|
|
|
const settings = readJson<{
|
|
hooks: {
|
|
PostToolBatch: Array<{ matcher?: string; hooks?: Array<{ command: string }> }>;
|
|
Stop: Array<{ hooks: Array<{ command: string }> }>;
|
|
};
|
|
}>(settingsPath);
|
|
|
|
expect(settings.hooks.PostToolBatch).toHaveLength(2);
|
|
expect(settings.hooks.PostToolBatch[0]).toEqual({ matcher: "Bash" });
|
|
expect(settings.hooks.PostToolBatch[1]).toEqual({ matcher: "Write", hooks: [] });
|
|
expect(
|
|
settings.hooks.Stop[0].hooks.some((hook) => hook.command.includes("react-doctor.mjs")),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("detects outdated hooks per agent and tolerates invalid settings JSON", () => {
|
|
expect(findAgentsWithOutdatedReactDoctorHooks(fixture.projectRoot)).toEqual([]);
|
|
|
|
const settingsPath = path.join(fixture.projectRoot, ".claude/settings.json");
|
|
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
fs.writeFileSync(
|
|
settingsPath,
|
|
JSON.stringify({
|
|
hooks: {
|
|
PostToolBatch: [
|
|
{
|
|
hooks: [
|
|
{
|
|
type: "command",
|
|
command: 'sh "$CLAUDE_PROJECT_DIR/.claude/hooks/react-doctor.sh"',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
const configPath = path.join(fixture.projectRoot, ".cursor/hooks.json");
|
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
fs.writeFileSync(
|
|
configPath,
|
|
JSON.stringify({
|
|
version: 1,
|
|
hooks: { postToolUse: [{ command: ".cursor/hooks/react-doctor.sh", matcher: "Write" }] },
|
|
}),
|
|
);
|
|
expect(findAgentsWithOutdatedReactDoctorHooks(fixture.projectRoot)).toEqual([
|
|
"claude-code",
|
|
"cursor",
|
|
]);
|
|
|
|
// A probe must never crash a scan on a user-mangled file.
|
|
fs.writeFileSync(settingsPath, "{ not json");
|
|
expect(findAgentsWithOutdatedReactDoctorHooks(fixture.projectRoot)).toEqual(["cursor"]);
|
|
});
|
|
|
|
it("leaves a user's own wrapper referencing a react-doctor.sh outside our install paths", () => {
|
|
const configPath = path.join(fixture.projectRoot, ".cursor/hooks.json");
|
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
const userWrapperCommand = "bash scripts/hooks/react-doctor.sh --my-flags";
|
|
fs.writeFileSync(
|
|
configPath,
|
|
JSON.stringify({
|
|
version: 1,
|
|
hooks: { postToolUse: [{ command: userWrapperCommand, matcher: "Write" }] },
|
|
}),
|
|
);
|
|
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["cursor"],
|
|
});
|
|
|
|
const config = readJson<{
|
|
hooks: {
|
|
postToolUse: Array<{ command: string }>;
|
|
stop: Array<{ command: string }>;
|
|
};
|
|
}>(configPath);
|
|
expect(config.hooks.postToolUse.map((handler) => handler.command)).toContain(
|
|
userWrapperCommand,
|
|
);
|
|
expect(config.hooks.stop.some((handler) => handler.command.includes("react-doctor.mjs"))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("installs a Cursor stop hook and preserves existing hook config", () => {
|
|
const configPath = path.join(fixture.projectRoot, ".cursor/hooks.json");
|
|
const hookPath = path.join(fixture.projectRoot, ".cursor/hooks/react-doctor.mjs");
|
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
fs.writeFileSync(
|
|
configPath,
|
|
JSON.stringify({
|
|
version: 1,
|
|
hooks: {
|
|
sessionStart: [{ command: ".cursor/hooks/bootstrap.sh" }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["cursor"],
|
|
});
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["cursor"],
|
|
});
|
|
|
|
const config = readJson<{
|
|
version: number;
|
|
hooks: {
|
|
sessionStart: Array<{ command: string }>;
|
|
stop: Array<{ command: string; timeout: number }>;
|
|
};
|
|
}>(configPath);
|
|
|
|
expect(result.installedAgents).toEqual(["cursor"]);
|
|
const hookContent = fs.readFileSync(hookPath, "utf8");
|
|
expect(config.version).toBe(1);
|
|
expect(config.hooks.sessionStart).toEqual([{ command: ".cursor/hooks/bootstrap.sh" }]);
|
|
expect(config.hooks.stop).toHaveLength(1);
|
|
expect(config.hooks.stop[0]).toEqual({
|
|
command: "node .cursor/hooks/react-doctor.mjs",
|
|
timeout: 120,
|
|
});
|
|
expect(fs.existsSync(hookPath)).toBe(true);
|
|
expect(hookContent).toContain("__dirname");
|
|
expect(hookContent).toContain("followup_message");
|
|
});
|
|
|
|
it("runs generated agent hooks from the project root and returns scan context", () => {
|
|
const hookPath = path.join(fixture.projectRoot, ".cursor/hooks/react-doctor.mjs");
|
|
const nestedDirectory = path.join(fixture.projectRoot, "packages/app/src");
|
|
fs.mkdirSync(nestedDirectory, { recursive: true });
|
|
fs.mkdirSync(path.join(fixture.projectRoot, ".react-doctor"), { recursive: true });
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["cursor"],
|
|
});
|
|
writeFakeReactDoctorBinary(fixture.projectRoot, { exitCode: 1 });
|
|
|
|
const output = execFileSync(process.execPath, [hookPath], {
|
|
cwd: nestedDirectory,
|
|
input: JSON.stringify({
|
|
hook_event_name: "stop",
|
|
status: "completed",
|
|
loop_count: 0,
|
|
}),
|
|
encoding: "utf8",
|
|
});
|
|
const parsedOutput: AgentHookJsonOutput = JSON.parse(output);
|
|
|
|
expect(
|
|
fs.realpathSync(
|
|
fs
|
|
.readFileSync(path.join(fixture.projectRoot, ".react-doctor/agent-hook-cwd.txt"), "utf8")
|
|
.trim(),
|
|
),
|
|
).toBe(fs.realpathSync(fixture.projectRoot));
|
|
expect(
|
|
fs.readFileSync(path.join(fixture.projectRoot, ".react-doctor/agent-hook-args.txt"), "utf8"),
|
|
).toContain("--verbose");
|
|
expect(parsedOutput.followup_message).toContain("fake scan output");
|
|
expect(parsedOutput.followup_message).toContain("create GitHub issues");
|
|
});
|
|
|
|
it("uses CLAUDE_PROJECT_DIR when a generated Claude hook runs outside the repo", () => {
|
|
const hookPath = path.join(fixture.projectRoot, ".claude/hooks/react-doctor.mjs");
|
|
const outsideDirectory = path.join(fixture.projectRoot, "..", "outside-cwd");
|
|
fs.mkdirSync(outsideDirectory, { recursive: true });
|
|
fs.mkdirSync(path.join(fixture.projectRoot, ".react-doctor"), { recursive: true });
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["claude-code"],
|
|
});
|
|
writeFakeReactDoctorBinary(fixture.projectRoot, { exitCode: 1 });
|
|
|
|
const output = execFileSync(process.execPath, [hookPath], {
|
|
cwd: outsideDirectory,
|
|
env: {
|
|
...process.env,
|
|
CLAUDE_PROJECT_DIR: fixture.projectRoot,
|
|
},
|
|
input: JSON.stringify({
|
|
hook_event_name: "Stop",
|
|
stop_hook_active: false,
|
|
}),
|
|
encoding: "utf8",
|
|
});
|
|
const parsedOutput: ClaudeAgentHookJsonOutput = JSON.parse(output);
|
|
|
|
expect(
|
|
fs.realpathSync(
|
|
fs
|
|
.readFileSync(path.join(fixture.projectRoot, ".react-doctor/agent-hook-cwd.txt"), "utf8")
|
|
.trim(),
|
|
),
|
|
).toBe(fs.realpathSync(fixture.projectRoot));
|
|
expect(parsedOutput.decision).toBe("block");
|
|
expect(parsedOutput.reason).toContain("fake scan output");
|
|
expect(parsedOutput.reason).toContain("create GitHub issues");
|
|
});
|
|
|
|
it("uses a PATH react-doctor binary when the local binary is missing", () => {
|
|
const hookPath = path.join(fixture.projectRoot, ".cursor/hooks/react-doctor.mjs");
|
|
const binDirectory = path.join(fixture.projectRoot, "fake-bin");
|
|
fs.mkdirSync(path.join(fixture.projectRoot, ".react-doctor"), { recursive: true });
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["cursor"],
|
|
});
|
|
writeFakePathReactDoctorBinary(binDirectory, fixture.projectRoot, {
|
|
exitCode: 1,
|
|
output: "path scan output",
|
|
});
|
|
|
|
const output = execFileSync(process.execPath, [hookPath], {
|
|
cwd: fixture.projectRoot,
|
|
env: {
|
|
...process.env,
|
|
PATH: [
|
|
binDirectory,
|
|
path.dirname(process.execPath),
|
|
"/usr/bin",
|
|
"/bin",
|
|
process.env.PATH ?? "",
|
|
].join(path.delimiter),
|
|
},
|
|
input: JSON.stringify({
|
|
hook_event_name: "stop",
|
|
status: "completed",
|
|
loop_count: 0,
|
|
}),
|
|
encoding: "utf8",
|
|
});
|
|
const parsedOutput: AgentHookJsonOutput = JSON.parse(output);
|
|
|
|
expect(
|
|
fs.readFileSync(
|
|
path.join(fixture.projectRoot, ".react-doctor/path-agent-hook-args.txt"),
|
|
"utf8",
|
|
),
|
|
).toContain("--verbose");
|
|
expect(parsedOutput.followup_message).toContain("path scan output");
|
|
});
|
|
|
|
it("exits quietly when no react-doctor runner is available", () => {
|
|
const hookPath = path.join(fixture.projectRoot, ".cursor/hooks/react-doctor.mjs");
|
|
const invocationPath = path.join(fixture.projectRoot, ".react-doctor/agent-hook-args.txt");
|
|
fs.mkdirSync(path.join(fixture.projectRoot, ".react-doctor"), { recursive: true });
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["cursor"],
|
|
});
|
|
|
|
const output = execFileSync(process.execPath, [hookPath], {
|
|
cwd: fixture.projectRoot,
|
|
env: {
|
|
...process.env,
|
|
PATH: "/usr/bin:/bin",
|
|
},
|
|
input: JSON.stringify({
|
|
hook_event_name: "stop",
|
|
status: "completed",
|
|
loop_count: 0,
|
|
}),
|
|
encoding: "utf8",
|
|
});
|
|
|
|
expect(output).toBe("");
|
|
expect(fs.existsSync(invocationPath)).toBe(false);
|
|
});
|
|
|
|
it("does not re-run a Claude hook continuation", () => {
|
|
const hookPath = path.join(fixture.projectRoot, ".claude/hooks/react-doctor.mjs");
|
|
const invocationPath = path.join(fixture.projectRoot, ".react-doctor/agent-hook-args.txt");
|
|
fs.mkdirSync(path.join(fixture.projectRoot, ".react-doctor"), { recursive: true });
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["claude-code"],
|
|
});
|
|
writeFakeReactDoctorBinary(fixture.projectRoot, { exitCode: 1 });
|
|
|
|
const output = execFileSync(process.execPath, [hookPath], {
|
|
cwd: path.join(fixture.projectRoot, ".claude/hooks"),
|
|
input: JSON.stringify({
|
|
hook_event_name: "Stop",
|
|
stop_hook_active: true,
|
|
}),
|
|
encoding: "utf8",
|
|
});
|
|
|
|
expect(output).toBe("");
|
|
expect(fs.existsSync(invocationPath)).toBe(false);
|
|
});
|
|
|
|
it("returns no context when a generated agent hook scan succeeds", () => {
|
|
const hookPath = path.join(fixture.projectRoot, ".cursor/hooks/react-doctor.mjs");
|
|
fs.mkdirSync(path.join(fixture.projectRoot, ".react-doctor"), { recursive: true });
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["cursor"],
|
|
});
|
|
writeFakeReactDoctorBinary(fixture.projectRoot, { exitCode: 0, output: "clean scan" });
|
|
|
|
const output = execFileSync(process.execPath, [hookPath], {
|
|
cwd: fixture.projectRoot,
|
|
input: JSON.stringify({
|
|
hook_event_name: "stop",
|
|
status: "completed",
|
|
loop_count: 0,
|
|
}),
|
|
encoding: "utf8",
|
|
});
|
|
|
|
expect(output).toBe("");
|
|
expect(
|
|
fs.readFileSync(path.join(fixture.projectRoot, ".react-doctor/agent-hook-args.txt"), "utf8"),
|
|
).toContain("--verbose");
|
|
});
|
|
|
|
it("does not re-run a Cursor hook continuation", () => {
|
|
const hookPath = path.join(fixture.projectRoot, ".cursor/hooks/react-doctor.mjs");
|
|
const invocationPath = path.join(fixture.projectRoot, ".react-doctor/agent-hook-args.txt");
|
|
fs.mkdirSync(path.join(fixture.projectRoot, ".react-doctor"), { recursive: true });
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["cursor"],
|
|
});
|
|
writeFakeReactDoctorBinary(fixture.projectRoot, { exitCode: 1 });
|
|
|
|
const output = execFileSync(process.execPath, [hookPath], {
|
|
cwd: fixture.projectRoot,
|
|
input: JSON.stringify({
|
|
hook_event_name: "stop",
|
|
status: "completed",
|
|
loop_count: 1,
|
|
}),
|
|
encoding: "utf8",
|
|
});
|
|
|
|
expect(output).toBe("");
|
|
expect(fs.existsSync(invocationPath)).toBe(false);
|
|
});
|
|
|
|
it("scans when hook input is malformed instead of failing closed", () => {
|
|
const hookPath = path.join(fixture.projectRoot, ".cursor/hooks/react-doctor.mjs");
|
|
fs.mkdirSync(path.join(fixture.projectRoot, ".react-doctor"), { recursive: true });
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["cursor"],
|
|
});
|
|
writeFakeReactDoctorBinary(fixture.projectRoot, { exitCode: 1 });
|
|
|
|
const output = execFileSync(process.execPath, [hookPath], {
|
|
cwd: fixture.projectRoot,
|
|
input: "{not-json",
|
|
encoding: "utf8",
|
|
});
|
|
const parsedOutput: AgentHookJsonOutput = JSON.parse(output);
|
|
|
|
expect(parsedOutput.followup_message).toContain("fake scan output");
|
|
});
|
|
|
|
it("ignores agents without native hook support", () => {
|
|
const result = installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["codex", "opencode"],
|
|
});
|
|
|
|
expect(result.installedAgents).toEqual([]);
|
|
expect(result.files).toEqual([]);
|
|
expect(fs.existsSync(path.join(fixture.projectRoot, ".cursor/hooks.json"))).toBe(false);
|
|
expect(fs.existsSync(path.join(fixture.projectRoot, ".claude/settings.json"))).toBe(false);
|
|
});
|
|
|
|
it("throws CliInputError when settings file contains malformed JSON", () => {
|
|
const settingsPath = path.join(fixture.projectRoot, ".claude/settings.json");
|
|
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
fs.writeFileSync(settingsPath, '{ "permissions": { "allow": ["Bash(git status)"] }');
|
|
|
|
expect(() => {
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["claude-code"],
|
|
});
|
|
}).toThrow(/Could not parse.*invalid JSON/);
|
|
});
|
|
|
|
it("throws CliInputError when config file contains malformed JSON", () => {
|
|
const configPath = path.join(fixture.projectRoot, ".cursor/hooks.json");
|
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
fs.writeFileSync(configPath, '{ "version": 1, "hooks": {');
|
|
|
|
expect(() => {
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["cursor"],
|
|
});
|
|
}).toThrow(/Could not parse.*invalid JSON/);
|
|
});
|
|
|
|
it("throws CliInputError when .claude directory exists as a file", () => {
|
|
const claudePath = path.join(fixture.projectRoot, ".claude");
|
|
fs.writeFileSync(claudePath, "i am a file not a directory");
|
|
|
|
expect(() => {
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["claude-code"],
|
|
});
|
|
}).toThrow(/Could not create directory.*file exists at this path/);
|
|
});
|
|
|
|
it("throws CliInputError when .cursor/hooks exists as a file", () => {
|
|
const hooksPath = path.join(fixture.projectRoot, ".cursor/hooks");
|
|
fs.mkdirSync(path.dirname(hooksPath), { recursive: true });
|
|
fs.writeFileSync(hooksPath, "i am a file not a directory");
|
|
|
|
expect(() => {
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: fixture.projectRoot,
|
|
agents: ["cursor"],
|
|
});
|
|
}).toThrow(/Could not create directory.*file exists at this path/);
|
|
});
|
|
|
|
it("throws CliInputError when target directory is not writable", () => {
|
|
if (process.getuid?.() === 0) {
|
|
return;
|
|
}
|
|
|
|
const readOnlyRoot = path.join(fixture.projectRoot, "readonly");
|
|
fs.mkdirSync(readOnlyRoot, { mode: 0o555 });
|
|
|
|
expect(() => {
|
|
installReactDoctorAgentHooks({
|
|
projectRoot: readOnlyRoot,
|
|
agents: ["cursor"],
|
|
});
|
|
}).toThrow(/Could not create directory.*permission denied/);
|
|
|
|
fs.chmodSync(readOnlyRoot, 0o755);
|
|
});
|
|
});
|