mirror of
https://github.com/millionco/react-doctor.git
synced 2026-09-14 20:00:24 +08:00
211 lines
8.2 KiB
TypeScript
211 lines
8.2 KiB
TypeScript
import { tmpdir } from "node:os";
|
|
import * as path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
|
import * as fs from "node:fs";
|
|
import { clearConfigCache } from "@react-doctor/core";
|
|
import { runProjectMigrations } from "../src/cli/utils/cli-migrations.js";
|
|
import { CONFIG_DIR_ENV_VAR } from "../src/cli/utils/cli-state-store.js";
|
|
|
|
// Integration tests for the real registered migrations (not the framework with
|
|
// a fake one): each should apply exactly once, print its summary, and be
|
|
// recorded so it never re-runs. Config state is isolated to a temp dir.
|
|
describe("runProjectMigrations", () => {
|
|
let projectRoot: string;
|
|
let configDir: string;
|
|
let originalConfigDir: string | undefined;
|
|
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(() => {
|
|
projectRoot = fs.mkdtempSync(path.join(tmpdir(), "react-doctor-migrations-"));
|
|
configDir = fs.mkdtempSync(path.join(tmpdir(), "react-doctor-migrations-config-"));
|
|
originalConfigDir = process.env[CONFIG_DIR_ENV_VAR];
|
|
process.env[CONFIG_DIR_ENV_VAR] = configDir;
|
|
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(() => {
|
|
logSpy.mockRestore();
|
|
fs.rmSync(projectRoot, { recursive: true, force: true });
|
|
fs.rmSync(configDir, { recursive: true, force: true });
|
|
if (originalConfigDir === undefined) {
|
|
delete process.env[CONFIG_DIR_ENV_VAR];
|
|
} else {
|
|
process.env[CONFIG_DIR_ENV_VAR] = originalConfigDir;
|
|
}
|
|
clearConfigCache();
|
|
});
|
|
|
|
const writeLegacyConfig = (): void => {
|
|
fs.writeFileSync(
|
|
path.join(projectRoot, "react-doctor.config.json"),
|
|
JSON.stringify({ $schema: "https://react.doctor/schema/config.json", lint: true }),
|
|
);
|
|
};
|
|
|
|
const capturedOutput = (): string => logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
|
|
|
it("renames the legacy config, prints the summary, and records the migration", async () => {
|
|
writeLegacyConfig();
|
|
|
|
const report = await runProjectMigrations(projectRoot);
|
|
|
|
expect(report).toContainEqual({ id: "config-json-to-ts", ran: true, applied: true });
|
|
expect(fs.existsSync(path.join(projectRoot, "react-doctor.config.json"))).toBe(false);
|
|
expect(fs.existsSync(path.join(projectRoot, "doctor.config.ts"))).toBe(true);
|
|
expect(capturedOutput()).toContain("Migrated react-doctor.config.json → doctor.config.ts");
|
|
});
|
|
|
|
it("is a no-op with no legacy config: stays pending, prints nothing", async () => {
|
|
const report = await runProjectMigrations(projectRoot);
|
|
|
|
expect(report).toContainEqual({ id: "config-json-to-ts", ran: true, applied: false });
|
|
expect(logSpy.mock.calls.length).toBe(0);
|
|
});
|
|
|
|
it("does not run again once applied (recorded / idempotent)", async () => {
|
|
writeLegacyConfig();
|
|
await runProjectMigrations(projectRoot);
|
|
logSpy.mockClear();
|
|
|
|
// The legacy file is gone AND the migration is recorded; the second pass
|
|
// skips `run` entirely rather than re-detecting.
|
|
const second = await runProjectMigrations(projectRoot);
|
|
|
|
expect(second).toContainEqual({ id: "config-json-to-ts", ran: false, applied: true });
|
|
expect(logSpy.mock.calls.length).toBe(0);
|
|
});
|
|
|
|
describe("agent-hooks-sh-to-mjs", () => {
|
|
const writeLegacyClaudeHook = (): void => {
|
|
const settingsPath = path.join(projectRoot, ".claude/settings.json");
|
|
const legacyScriptPath = path.join(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"',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
};
|
|
|
|
it("upgrades a legacy Claude shell hook, prints the summary, and records the migration", async () => {
|
|
writeLegacyClaudeHook();
|
|
|
|
const report = await runProjectMigrations(projectRoot);
|
|
|
|
expect(report).toContainEqual({ id: "agent-hooks-sh-to-mjs", ran: true, applied: true });
|
|
const settings: { hooks: { Stop: Array<{ hooks: Array<{ command: string }> }> } } =
|
|
JSON.parse(fs.readFileSync(path.join(projectRoot, ".claude/settings.json"), "utf8"));
|
|
const hookCommands = settings.hooks.Stop.flatMap((group) =>
|
|
group.hooks.map((hook) => hook.command),
|
|
);
|
|
expect(hookCommands).toHaveLength(1);
|
|
expect(hookCommands[0]).toContain("react-doctor.mjs");
|
|
expect(fs.existsSync(path.join(projectRoot, ".claude/hooks/react-doctor.sh"))).toBe(false);
|
|
expect(fs.existsSync(path.join(projectRoot, ".claude/hooks/react-doctor.mjs"))).toBe(true);
|
|
expect(capturedOutput()).toContain("Moved React Doctor agent hooks to end-of-turn checks");
|
|
});
|
|
|
|
it("stays pending with no legacy hooks and doesn't touch agent settings", async () => {
|
|
const report = await runProjectMigrations(projectRoot);
|
|
|
|
expect(report).toContainEqual({ id: "agent-hooks-sh-to-mjs", ran: true, applied: false });
|
|
expect(fs.existsSync(path.join(projectRoot, ".claude"))).toBe(false);
|
|
expect(fs.existsSync(path.join(projectRoot, ".cursor"))).toBe(false);
|
|
});
|
|
|
|
it("does not run again once applied (recorded)", async () => {
|
|
writeLegacyClaudeHook();
|
|
await runProjectMigrations(projectRoot);
|
|
logSpy.mockClear();
|
|
|
|
const second = await runProjectMigrations(projectRoot);
|
|
|
|
expect(second).toContainEqual({ id: "agent-hooks-sh-to-mjs", ran: false, applied: true });
|
|
expect(logSpy.mock.calls.length).toBe(0);
|
|
});
|
|
|
|
it("moves installed Node hooks from per-tool events to Stop events", async () => {
|
|
const settingsPath = path.join(projectRoot, ".claude/settings.json");
|
|
const configPath = path.join(projectRoot, ".cursor/hooks.json");
|
|
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
fs.writeFileSync(
|
|
settingsPath,
|
|
JSON.stringify({
|
|
hooks: {
|
|
PostToolBatch: [
|
|
{
|
|
hooks: [
|
|
{
|
|
type: "command",
|
|
command: 'node "$CLAUDE_PROJECT_DIR/.claude/hooks/react-doctor.mjs"',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
fs.writeFileSync(
|
|
configPath,
|
|
JSON.stringify({
|
|
version: 1,
|
|
hooks: {
|
|
postToolUse: [
|
|
{
|
|
command: "node .cursor/hooks/react-doctor.mjs",
|
|
matcher: "Write|Edit|MultiEdit|ApplyPatch",
|
|
timeout: 120,
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const report = await runProjectMigrations(projectRoot);
|
|
const settings: { hooks: { PostToolBatch: unknown[]; Stop: unknown[] } } = JSON.parse(
|
|
fs.readFileSync(settingsPath, "utf8"),
|
|
);
|
|
const config: { hooks: { postToolUse: unknown[]; stop: unknown[] } } = JSON.parse(
|
|
fs.readFileSync(configPath, "utf8"),
|
|
);
|
|
|
|
expect(report).toContainEqual({ id: "agent-hooks-sh-to-mjs", ran: true, applied: true });
|
|
expect(settings.hooks.PostToolBatch).toEqual([]);
|
|
expect(settings.hooks.Stop).toHaveLength(1);
|
|
expect(config.hooks.postToolUse).toEqual([]);
|
|
expect(config.hooks.stop).toHaveLength(1);
|
|
});
|
|
|
|
it("ignores a user's own wrapper outside our install paths (anchored detection)", async () => {
|
|
const configPath = path.join(projectRoot, ".cursor/hooks.json");
|
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
const userConfig = JSON.stringify({
|
|
version: 1,
|
|
hooks: {
|
|
postToolUse: [{ command: "bash scripts/hooks/react-doctor.sh", matcher: "Write" }],
|
|
},
|
|
});
|
|
fs.writeFileSync(configPath, userConfig);
|
|
|
|
const report = await runProjectMigrations(projectRoot);
|
|
|
|
expect(report).toContainEqual({ id: "agent-hooks-sh-to-mjs", ran: true, applied: false });
|
|
expect(fs.readFileSync(configPath, "utf8")).toBe(userConfig);
|
|
});
|
|
});
|
|
});
|