Files
millionco__react-doctor/packages/react-doctor/tests/install-agent-hooks.test.ts

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);
});
});