mirror of
https://github.com/millionco/react-doctor.git
synced 2026-09-14 20:00:24 +08:00
1123 lines
42 KiB
TypeScript
1123 lines
42 KiB
TypeScript
import { execFileSync } from "node:child_process";
|
|
import { tmpdir } from "node:os";
|
|
import * as path from "node:path";
|
|
import type { SkillAgentType } from "agent-install";
|
|
import type { Answers, PromptObject } from "prompts";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
|
|
import * as fs from "node:fs";
|
|
|
|
// Keeps the suite hermetic: the real detection spawns `gh` / `git` against
|
|
// the temp fixture, and a cold `gh.exe` on Windows CI has blown the 30s test
|
|
// timeout. Returning null exercises the same `main` fallback the fixtures
|
|
// (which have no git remote) would land on anyway.
|
|
vi.mock("../src/cli/utils/detect-default-branch.js", () => ({
|
|
detectDefaultBranch: async () => null,
|
|
}));
|
|
import {
|
|
CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES,
|
|
CODING_AGENT_ENVIRONMENT_VARIABLES,
|
|
} from "../src/cli/utils/is-ci-environment.js";
|
|
import { NON_INTERACTIVE_ENVIRONMENT_VARIABLES } from "../src/cli/utils/is-non-interactive-environment.js";
|
|
import { runInstallReactDoctor } from "../src/cli/utils/install-react-doctor.js";
|
|
import type { InstallReactDoctorDependencyRunnerInput } from "../src/cli/utils/install-react-doctor.js";
|
|
import { recordActionUpgradeDecision } from "../src/cli/utils/action-upgrade-prompt.js";
|
|
import { CONFIG_DIR_ENV_VAR } from "../src/cli/utils/cli-state-store.js";
|
|
import { hasHandledCiPrompt, recordCiPromptDecision } from "../src/cli/utils/ci-prompt-decision.js";
|
|
import { readInstallAgents } from "../src/cli/utils/install-agents-preference.js";
|
|
import { setSpinnerSilent } from "../src/cli/utils/spinner.js";
|
|
import { silenceConsoleForTest } from "./helpers/silence-console.js";
|
|
|
|
interface InstallReactDoctorFixture {
|
|
projectRoot: string;
|
|
sourceDir: string;
|
|
cleanup: () => void;
|
|
}
|
|
|
|
const setupFixture = (): InstallReactDoctorFixture => {
|
|
const root = fs.mkdtempSync(path.join(tmpdir(), "react-doctor-install-"));
|
|
const projectRoot = path.join(root, "project");
|
|
const sourceDir = path.join(root, "source");
|
|
fs.mkdirSync(projectRoot, { recursive: true });
|
|
fs.mkdirSync(sourceDir, { recursive: true });
|
|
return {
|
|
projectRoot,
|
|
sourceDir,
|
|
cleanup: () => fs.rmSync(root, { recursive: true, force: true }),
|
|
};
|
|
};
|
|
|
|
const writeValidSkill = (sourceDir: string): void => {
|
|
fs.writeFileSync(
|
|
path.join(sourceDir, "SKILL.md"),
|
|
"---\nname: react-doctor\ndescription: Test skill for install fixtures\n---\n# react-doctor\n",
|
|
);
|
|
};
|
|
|
|
const writePackageJson = (projectRoot: string, value: Record<string, unknown>): void => {
|
|
fs.writeFileSync(path.join(projectRoot, "package.json"), `${JSON.stringify(value, null, 2)}\n`);
|
|
};
|
|
|
|
const writeExistingWorkflow = (projectRoot: string, actionRef: string): string => {
|
|
const workflowPath = path.join(projectRoot, ".github", "workflows", "react-doctor.yml");
|
|
fs.mkdirSync(path.dirname(workflowPath), { recursive: true });
|
|
fs.writeFileSync(
|
|
workflowPath,
|
|
[
|
|
"name: React Doctor",
|
|
"on:",
|
|
" pull_request:",
|
|
"jobs:",
|
|
" react-doctor:",
|
|
" runs-on: ubuntu-latest",
|
|
" steps:",
|
|
" - uses: actions/checkout@v5",
|
|
` - uses: ${actionRef}`,
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
return workflowPath;
|
|
};
|
|
|
|
const readFixturePackageJson = (projectRoot: string): Record<string, unknown> =>
|
|
JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"));
|
|
|
|
let dependencyInstallCalls: InstallReactDoctorDependencyRunnerInput[] = [];
|
|
|
|
const installDependencyForTest: NonNullable<
|
|
Parameters<typeof runInstallReactDoctor>[0]
|
|
>["installDependencyRunner"] = (input) => {
|
|
dependencyInstallCalls.push(input);
|
|
const packageJson = readFixturePackageJson(input.cwd);
|
|
writePackageJson(input.cwd, {
|
|
...packageJson,
|
|
devDependencies: {
|
|
...(typeof packageJson.devDependencies === "object" &&
|
|
packageJson.devDependencies !== null &&
|
|
!Array.isArray(packageJson.devDependencies)
|
|
? packageJson.devDependencies
|
|
: {}),
|
|
"react-doctor": "latest",
|
|
},
|
|
});
|
|
};
|
|
|
|
const runInstallReactDoctorForTest = (
|
|
options: NonNullable<Parameters<typeof runInstallReactDoctor>[0]>,
|
|
) =>
|
|
runInstallReactDoctor({
|
|
installDependencyRunner: installDependencyForTest,
|
|
...options,
|
|
});
|
|
|
|
interface RunInteractiveInstallReactDoctorForTestOptions {
|
|
readonly sourceDir: string;
|
|
readonly projectRoot: string;
|
|
readonly gitHookPath: string;
|
|
readonly setupOptions: readonly string[];
|
|
readonly promptQuestions?: unknown[];
|
|
readonly detectedAgents?: readonly SkillAgentType[];
|
|
readonly lastSelectedAgents?: readonly SkillAgentType[];
|
|
}
|
|
|
|
const runInteractiveInstallReactDoctorForTest = async (
|
|
options: RunInteractiveInstallReactDoctorForTestOptions,
|
|
): Promise<void> => {
|
|
const originalIsTty = process.stdin.isTTY;
|
|
const savedEnvironmentValues = new Map<string, string | undefined>();
|
|
const interactivePromptEnvironmentVariables = new Set([
|
|
...NON_INTERACTIVE_ENVIRONMENT_VARIABLES,
|
|
...CODING_AGENT_ENVIRONMENT_VARIABLES,
|
|
...CODING_AGENT_ENVIRONMENT_VALUE_VARIABLES,
|
|
]);
|
|
const prompt: NonNullable<Parameters<typeof runInstallReactDoctor>[0]>["prompt"] = async <
|
|
PromptName extends string = string,
|
|
>(
|
|
question: PromptObject<PromptName> | PromptObject<PromptName>[],
|
|
) => {
|
|
options.promptQuestions?.push(question);
|
|
const promptQuestion = Array.isArray(question) ? question[0] : question;
|
|
const answers: Answers<PromptName> = Object.create(null);
|
|
const questionName = promptQuestion?.name;
|
|
if (typeof questionName !== "string") return answers;
|
|
if (questionName === "agents") {
|
|
answers[questionName] = ["cursor"];
|
|
} else if (questionName === "ciChoice") {
|
|
answers[questionName] = options.setupOptions.includes("workflow") ? "ci-yes" : "ci-no";
|
|
} else if (questionName === "upgradeChoice") {
|
|
answers[questionName] = options.setupOptions.includes("workflow-upgrade")
|
|
? "upgrade-yes"
|
|
: "upgrade-no";
|
|
} else {
|
|
answers[questionName] = options.setupOptions;
|
|
}
|
|
return answers;
|
|
};
|
|
|
|
try {
|
|
for (const environmentVariable of interactivePromptEnvironmentVariables) {
|
|
savedEnvironmentValues.set(environmentVariable, process.env[environmentVariable]);
|
|
delete process.env[environmentVariable];
|
|
}
|
|
Object.defineProperty(process.stdin, "isTTY", {
|
|
configurable: true,
|
|
value: true,
|
|
});
|
|
|
|
await runInstallReactDoctorForTest({
|
|
sourceDir: options.sourceDir,
|
|
projectRoot: options.projectRoot,
|
|
detectedAgents: [...(options.detectedAgents ?? ["cursor"])],
|
|
lastSelectedAgents:
|
|
options.lastSelectedAgents === undefined ? undefined : [...options.lastSelectedAgents],
|
|
gitHookPath: options.gitHookPath,
|
|
prompt,
|
|
});
|
|
} finally {
|
|
Object.defineProperty(process.stdin, "isTTY", {
|
|
configurable: true,
|
|
value: originalIsTty,
|
|
});
|
|
for (const environmentVariable of interactivePromptEnvironmentVariables) {
|
|
const savedEnvironmentValue = savedEnvironmentValues.get(environmentVariable);
|
|
if (savedEnvironmentValue === undefined) {
|
|
delete process.env[environmentVariable];
|
|
} else {
|
|
process.env[environmentVariable] = savedEnvironmentValue;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
describe("runInstallReactDoctor", () => {
|
|
let fixture: InstallReactDoctorFixture;
|
|
let originalExitCode: number | string | null | undefined;
|
|
let originalCi: string | undefined;
|
|
let originalConfigDir: string | undefined;
|
|
let configDir: string;
|
|
let restoreConsole: () => void;
|
|
|
|
beforeEach(() => {
|
|
fixture = setupFixture();
|
|
originalExitCode = process.exitCode;
|
|
originalCi = process.env.CI;
|
|
// Isolate the per-user CLI state (onboarding / prompt decisions) into a
|
|
// throwaway dir so the interactive install flow — which records decisions
|
|
// without threading a `cwd` — never reads or writes the real user config,
|
|
// and parallel test runs can't pollute each other's persisted state.
|
|
originalConfigDir = process.env[CONFIG_DIR_ENV_VAR];
|
|
configDir = fs.mkdtempSync(path.join(tmpdir(), "react-doctor-install-config-"));
|
|
process.env[CONFIG_DIR_ENV_VAR] = configDir;
|
|
dependencyInstallCalls = [];
|
|
process.exitCode = 0;
|
|
restoreConsole = silenceConsoleForTest();
|
|
setSpinnerSilent(true);
|
|
});
|
|
|
|
afterEach(() => {
|
|
fixture.cleanup();
|
|
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;
|
|
}
|
|
process.exitCode = originalExitCode;
|
|
if (originalCi === undefined) {
|
|
delete process.env.CI;
|
|
} else {
|
|
process.env.CI = originalCi;
|
|
}
|
|
restoreConsole();
|
|
setSpinnerSilent(false);
|
|
});
|
|
|
|
it("exits with code 1 when the bundled SKILL.md is missing", async () => {
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor"],
|
|
});
|
|
expect(process.exitCode).toBe(1);
|
|
expect(readFixturePackageJson(fixture.projectRoot).scripts).toEqual({});
|
|
expect(readFixturePackageJson(fixture.projectRoot)).not.toHaveProperty("devDependencies");
|
|
expect(dependencyInstallCalls).toEqual([]);
|
|
});
|
|
|
|
it("exits with code 1 when no agents are detected", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: [],
|
|
});
|
|
expect(process.exitCode).toBe(1);
|
|
expect(fs.existsSync(path.join(fixture.projectRoot, ".agents"))).toBe(false);
|
|
expect(readFixturePackageJson(fixture.projectRoot).scripts).toEqual({});
|
|
expect(readFixturePackageJson(fixture.projectRoot)).not.toHaveProperty("devDependencies");
|
|
expect(dependencyInstallCalls).toEqual([]);
|
|
});
|
|
|
|
it("throws when SKILL.md exists but has no parseable frontmatter (H1 silent-success regression guard)", async () => {
|
|
// HACK: agent-install's discoverSkills returns an empty array for
|
|
// SKILL.md without `name:` / `description:` frontmatter. Before the
|
|
// fix, our wrapper only checked `failed.length > 0` and reported
|
|
// success even though zero files were written.
|
|
fs.writeFileSync(
|
|
path.join(fixture.sourceDir, "SKILL.md"),
|
|
"# Just a heading, no frontmatter\nNothing here.\n",
|
|
);
|
|
await expect(
|
|
runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor"],
|
|
}),
|
|
).rejects.toThrow(/Could not parse SKILL\.md/);
|
|
});
|
|
|
|
it("throws when frontmatter is missing the required `description` field (H1 regression guard)", async () => {
|
|
fs.writeFileSync(
|
|
path.join(fixture.sourceDir, "SKILL.md"),
|
|
"---\nname: react-doctor\n---\n# react-doctor\n",
|
|
);
|
|
await expect(
|
|
runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor"],
|
|
}),
|
|
).rejects.toThrow(/Could not parse SKILL\.md/);
|
|
});
|
|
|
|
it("--dry-run writes nothing, even with valid SKILL.md and detected agents", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
dryRun: true,
|
|
agentHooks: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor", "claude-code"],
|
|
});
|
|
expect(fs.existsSync(path.join(fixture.projectRoot, ".agents"))).toBe(false);
|
|
expect(fs.existsSync(path.join(fixture.projectRoot, ".claude"))).toBe(false);
|
|
expect(fs.existsSync(path.join(fixture.projectRoot, ".cursor"))).toBe(false);
|
|
expect(fs.existsSync(path.join(fixture.projectRoot, ".factory"))).toBe(false);
|
|
expect(readFixturePackageJson(fixture.projectRoot).scripts).toEqual({});
|
|
});
|
|
|
|
it("adds doctor package setup when package.json exists and setup is missing", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, {
|
|
scripts: {
|
|
test: "vite-plus test",
|
|
},
|
|
});
|
|
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor"],
|
|
gitHookPath: null,
|
|
});
|
|
|
|
expect(readFixturePackageJson(fixture.projectRoot).scripts).toEqual({
|
|
test: "vite-plus test",
|
|
doctor: "npx react-doctor@latest",
|
|
});
|
|
expect(readFixturePackageJson(fixture.projectRoot).devDependencies).toEqual({
|
|
"react-doctor": "latest",
|
|
});
|
|
});
|
|
|
|
it("installs react-doctor with the detected package manager", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, {
|
|
packageManager: "pnpm@10.29.1",
|
|
scripts: {},
|
|
});
|
|
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor"],
|
|
gitHookPath: null,
|
|
});
|
|
|
|
expect(dependencyInstallCalls).toEqual([
|
|
{
|
|
command: "pnpm",
|
|
args: ["add", "--save-dev", "react-doctor@latest"],
|
|
cwd: fixture.projectRoot,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("does not fail setup when the package manager install command fails", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, {
|
|
scripts: {},
|
|
});
|
|
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor"],
|
|
gitHookPath: null,
|
|
installDependencyRunner: (input) => {
|
|
dependencyInstallCalls.push(input);
|
|
throw new Error("npm install failed");
|
|
},
|
|
});
|
|
|
|
expect(process.exitCode).toBe(0);
|
|
expect(dependencyInstallCalls).toEqual([
|
|
{
|
|
command: "npm",
|
|
args: ["install", "--save-dev", "react-doctor@latest"],
|
|
cwd: fixture.projectRoot,
|
|
},
|
|
]);
|
|
expect(readFixturePackageJson(fixture.projectRoot).scripts).toEqual({
|
|
doctor: "npx react-doctor@latest",
|
|
});
|
|
expect(readFixturePackageJson(fixture.projectRoot)).not.toHaveProperty("devDependencies");
|
|
});
|
|
|
|
it("does not fail setup when a supply-chain trust policy blocks the install", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor"],
|
|
gitHookPath: null,
|
|
installDependencyRunner: (input) => {
|
|
dependencyInstallCalls.push(input);
|
|
throw Object.assign(new Error("pnpm add failed"), {
|
|
stderr: "ERR_PNPM_TRUST_DOWNGRADE High-risk trust downgrade for effect@4.0.0-beta.102",
|
|
});
|
|
},
|
|
});
|
|
|
|
expect(process.exitCode).toBe(0);
|
|
expect(readFixturePackageJson(fixture.projectRoot).scripts).toEqual({
|
|
doctor: "npx react-doctor@latest",
|
|
});
|
|
expect(readFixturePackageJson(fixture.projectRoot)).not.toHaveProperty("devDependencies");
|
|
});
|
|
|
|
it("detects the package manager from an ancestor package.json", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, {
|
|
packageManager: "pnpm@10.29.1",
|
|
workspaces: ["apps/*"],
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(fixture.projectRoot, "pnpm-workspace.yaml"),
|
|
"packages:\n - apps/*\n",
|
|
);
|
|
const appDirectory = path.join(fixture.projectRoot, "apps", "web");
|
|
fs.mkdirSync(appDirectory, { recursive: true });
|
|
writePackageJson(appDirectory, {
|
|
scripts: {},
|
|
});
|
|
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: appDirectory,
|
|
detectedAgents: ["cursor"],
|
|
gitHookPath: null,
|
|
});
|
|
|
|
expect(dependencyInstallCalls).toEqual([
|
|
{
|
|
command: "pnpm",
|
|
args: ["add", "--save-dev", "-w", "react-doctor@latest"],
|
|
cwd: appDirectory,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("does not mutate package setup when skill parsing fails", async () => {
|
|
fs.writeFileSync(path.join(fixture.sourceDir, "SKILL.md"), "# Invalid skill\n");
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
|
|
await expect(
|
|
runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor"],
|
|
}),
|
|
).rejects.toThrow(/Could not parse SKILL\.md/);
|
|
|
|
expect(readFixturePackageJson(fixture.projectRoot).scripts).toEqual({});
|
|
expect(readFixturePackageJson(fixture.projectRoot)).not.toHaveProperty("devDependencies");
|
|
expect(dependencyInstallCalls).toEqual([]);
|
|
});
|
|
|
|
it("installs package setup and skills at the nearest package root from a nested directory", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
const nestedDirectory = path.join(fixture.projectRoot, "src", "components");
|
|
fs.mkdirSync(nestedDirectory, { recursive: true });
|
|
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: nestedDirectory,
|
|
detectedAgents: ["cursor"],
|
|
gitHookPath: null,
|
|
});
|
|
|
|
expect(readFixturePackageJson(fixture.projectRoot).scripts).toEqual({
|
|
doctor: "npx react-doctor@latest",
|
|
});
|
|
expect(
|
|
fs.existsSync(path.join(fixture.projectRoot, ".agents/skills/react-doctor/SKILL.md")),
|
|
).toBe(true);
|
|
expect(fs.existsSync(path.join(nestedDirectory, ".agents"))).toBe(false);
|
|
});
|
|
|
|
it("does not overwrite an existing doctor package script", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, {
|
|
scripts: {
|
|
doctor: "pnpm react-doctor --verbose",
|
|
},
|
|
});
|
|
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor"],
|
|
gitHookPath: null,
|
|
});
|
|
|
|
expect(readFixturePackageJson(fixture.projectRoot).scripts).toEqual({
|
|
doctor: "pnpm react-doctor --verbose",
|
|
});
|
|
expect(readFixturePackageJson(fixture.projectRoot).devDependencies).toEqual({
|
|
"react-doctor": "latest",
|
|
});
|
|
});
|
|
|
|
it("adds a react-doctor package script when doctor is already taken", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, {
|
|
scripts: {
|
|
doctor: "vitest --run",
|
|
},
|
|
});
|
|
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor"],
|
|
gitHookPath: null,
|
|
});
|
|
|
|
expect(readFixturePackageJson(fixture.projectRoot).scripts).toEqual({
|
|
doctor: "vitest --run",
|
|
"react-doctor": "npx react-doctor@latest",
|
|
});
|
|
expect(readFixturePackageJson(fixture.projectRoot).devDependencies).toEqual({
|
|
"react-doctor": "latest",
|
|
});
|
|
});
|
|
|
|
it("does not overwrite an existing react-doctor dependency", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, {
|
|
devDependencies: {
|
|
"react-doctor": "^1.2.3",
|
|
},
|
|
scripts: {},
|
|
});
|
|
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor"],
|
|
gitHookPath: null,
|
|
});
|
|
|
|
expect(readFixturePackageJson(fixture.projectRoot).scripts).toEqual({
|
|
doctor: "npx react-doctor@latest",
|
|
});
|
|
expect(readFixturePackageJson(fixture.projectRoot).devDependencies).toEqual({
|
|
"react-doctor": "^1.2.3",
|
|
});
|
|
});
|
|
|
|
it("does not crash setup when package.json is invalid", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
fs.writeFileSync(path.join(fixture.projectRoot, "package.json"), "{ invalid json");
|
|
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor"],
|
|
gitHookPath: null,
|
|
});
|
|
|
|
expect(
|
|
fs.existsSync(path.join(fixture.projectRoot, ".agents/skills/react-doctor/SKILL.md")),
|
|
).toBe(true);
|
|
expect(fs.readFileSync(path.join(fixture.projectRoot, "package.json"), "utf8")).toBe(
|
|
"{ invalid json",
|
|
);
|
|
});
|
|
|
|
it("installs the skill into the universal .agents/skills directory for a universal agent", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor"],
|
|
});
|
|
expect(
|
|
fs.existsSync(path.join(fixture.projectRoot, ".agents/skills/react-doctor/SKILL.md")),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("installs the skill into a vendor-specific directory for a non-universal agent", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["claude-code"],
|
|
});
|
|
expect(
|
|
fs.existsSync(path.join(fixture.projectRoot, ".claude/skills/react-doctor/SKILL.md")),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("installs the skill into .factory/skills for the droid agent (upstream agent-install@0.0.3)", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["droid"],
|
|
});
|
|
expect(
|
|
fs.existsSync(path.join(fixture.projectRoot, ".factory/skills/react-doctor/SKILL.md")),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("--yes installs a non-blocking pre-commit hook when a git hook target is detected", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
const hookPath = path.join(fixture.projectRoot, ".git/hooks/pre-commit");
|
|
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor"],
|
|
gitHookPath: hookPath,
|
|
});
|
|
|
|
expect(
|
|
fs.existsSync(path.join(fixture.projectRoot, ".agents/skills/react-doctor/SKILL.md")),
|
|
).toBe(true);
|
|
expect(fs.readFileSync(hookPath, "utf8")).toContain("react-doctor --staged --blocking warning");
|
|
expect(fs.existsSync(path.join(fixture.projectRoot, ".react-doctor/hooks/pre-commit"))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("--agent-hooks installs native hooks for selected supported agents", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
agentHooks: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor", "claude-code", "codex"],
|
|
gitHookPath: null,
|
|
});
|
|
|
|
expect(
|
|
fs.existsSync(path.join(fixture.projectRoot, ".agents/skills/react-doctor/SKILL.md")),
|
|
).toBe(true);
|
|
expect(
|
|
fs.existsSync(path.join(fixture.projectRoot, ".claude/skills/react-doctor/SKILL.md")),
|
|
).toBe(true);
|
|
expect(
|
|
fs.readFileSync(path.join(fixture.projectRoot, ".claude/settings.json"), "utf8"),
|
|
).toContain('"Stop"');
|
|
expect(fs.readFileSync(path.join(fixture.projectRoot, ".cursor/hooks.json"), "utf8")).toContain(
|
|
'"stop"',
|
|
);
|
|
expect(fs.existsSync(path.join(fixture.projectRoot, ".codex/hooks.json"))).toBe(false);
|
|
});
|
|
|
|
it("prompts once for optional setup and installs only selected options", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
const promptQuestions: unknown[] = [];
|
|
const hookPath = path.join(fixture.projectRoot, ".git/hooks/pre-commit");
|
|
const workflowPath = path.join(fixture.projectRoot, ".github/workflows/react-doctor.yml");
|
|
|
|
await runInteractiveInstallReactDoctorForTest({
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
gitHookPath: hookPath,
|
|
setupOptions: ["workflow"],
|
|
promptQuestions,
|
|
});
|
|
|
|
expect(promptQuestions).toHaveLength(3);
|
|
// CI is asked first, as its own dedicated question (the shared pitch).
|
|
expect(promptQuestions[0]).toEqual(
|
|
expect.objectContaining({ type: "select", name: "ciChoice" }),
|
|
);
|
|
expect(promptQuestions[2]).toEqual(
|
|
expect.objectContaining({
|
|
type: "multiselect",
|
|
name: "setupOptions",
|
|
message: "Select additional React Doctor setup:",
|
|
}),
|
|
);
|
|
expect(promptQuestions[2]).toEqual(
|
|
expect.objectContaining({
|
|
choices: expect.arrayContaining([
|
|
expect.objectContaining({ value: "skip" }),
|
|
expect.objectContaining({ value: "git-hook" }),
|
|
expect.objectContaining({ value: "agent-hooks" }),
|
|
]),
|
|
}),
|
|
);
|
|
expect(fs.existsSync(hookPath)).toBe(false);
|
|
expect(fs.existsSync(path.join(fixture.projectRoot, ".cursor/hooks.json"))).toBe(false);
|
|
const workflowContent = fs.readFileSync(workflowPath, "utf8");
|
|
expect(workflowContent).toContain("name: React Doctor");
|
|
expect(workflowContent).toContain("pull-requests: write");
|
|
expect(workflowContent).toContain("issues: write");
|
|
expect(workflowContent).toContain("statuses: write");
|
|
expect(workflowContent).toContain("actions/checkout@v5");
|
|
expect(workflowContent).toContain("millionco/react-doctor@v2");
|
|
expect(workflowContent).toContain("Advisory by default");
|
|
expect(workflowContent).toContain("# blocking: error");
|
|
// Advisory scaffold: the React Doctor step's gate stays commented (`# with:`),
|
|
// so its `uses:` line is never directly followed by an active `with:` block.
|
|
// (The checkout step carries its own `with: fetch-depth: 0`, which is fine.)
|
|
expect(workflowContent).not.toContain("react-doctor@v2\n with:");
|
|
expect(workflowContent).not.toContain("github-token");
|
|
expect(workflowContent).not.toContain("diff: main");
|
|
});
|
|
|
|
const findAgentChoiceSelection = (promptQuestions: unknown[]): Map<string, boolean> => {
|
|
const agentQuestion = promptQuestions.find(
|
|
(question): question is { choices: Array<{ value: string; selected?: boolean }> } =>
|
|
typeof question === "object" &&
|
|
question !== null &&
|
|
"name" in question &&
|
|
(question as { name?: unknown }).name === "agents",
|
|
);
|
|
expect(agentQuestion).toBeDefined();
|
|
return new Map(
|
|
agentQuestion!.choices.map((choice) => [choice.value, Boolean(choice.selected)]),
|
|
);
|
|
};
|
|
|
|
it("pre-selects only the curated default agents, leaving merely-installed ones opt-in", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
const promptQuestions: unknown[] = [];
|
|
|
|
await runInteractiveInstallReactDoctorForTest({
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
gitHookPath: path.join(fixture.projectRoot, ".git/hooks/pre-commit"),
|
|
setupOptions: [],
|
|
promptQuestions,
|
|
// A machine with four AI tools installed, no remembered selection yet.
|
|
detectedAgents: ["claude-code", "cursor", "goose", "crush"],
|
|
});
|
|
|
|
const selectionByAgent = findAgentChoiceSelection(promptQuestions);
|
|
// Popular agents in the curated default set → pre-checked.
|
|
expect(selectionByAgent.get("claude-code")).toBe(true);
|
|
expect(selectionByAgent.get("cursor")).toBe(true);
|
|
// Merely installed somewhere in $HOME → shown, but not pre-checked, so a
|
|
// single Enter never copies the skill into their project-local dirs.
|
|
expect(selectionByAgent.get("goose")).toBe(false);
|
|
expect(selectionByAgent.get("crush")).toBe(false);
|
|
});
|
|
|
|
it("pre-selects the user's remembered selection over the curated defaults", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
const promptQuestions: unknown[] = [];
|
|
|
|
await runInteractiveInstallReactDoctorForTest({
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
gitHookPath: path.join(fixture.projectRoot, ".git/hooks/pre-commit"),
|
|
setupOptions: [],
|
|
promptQuestions,
|
|
detectedAgents: ["claude-code", "cursor", "goose"],
|
|
// Last time the user deliberately picked Goose; that wins over defaults.
|
|
lastSelectedAgents: ["goose"],
|
|
});
|
|
|
|
const selectionByAgent = findAgentChoiceSelection(promptQuestions);
|
|
expect(selectionByAgent.get("goose")).toBe(true);
|
|
expect(selectionByAgent.get("claude-code")).toBe(false);
|
|
expect(selectionByAgent.get("cursor")).toBe(false);
|
|
});
|
|
|
|
it("remembers the interactive selection so the next install defaults to it", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
expect(readInstallAgents()).toEqual([]);
|
|
|
|
// The interactive harness's prompt mock answers the agent question with
|
|
// ["cursor"], so that pick should be persisted globally for next time.
|
|
await runInteractiveInstallReactDoctorForTest({
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
gitHookPath: path.join(fixture.projectRoot, ".git/hooks/pre-commit"),
|
|
setupOptions: [],
|
|
detectedAgents: ["claude-code", "cursor"],
|
|
});
|
|
|
|
expect(readInstallAgents()).toEqual(["cursor"]);
|
|
});
|
|
|
|
it("skips optional setup when only the skip option is selected", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
const hookPath = path.join(fixture.projectRoot, ".git/hooks/pre-commit");
|
|
const workflowPath = path.join(fixture.projectRoot, ".github/workflows/react-doctor.yml");
|
|
|
|
await runInteractiveInstallReactDoctorForTest({
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
gitHookPath: hookPath,
|
|
setupOptions: ["skip"],
|
|
});
|
|
|
|
expect(fs.existsSync(hookPath)).toBe(false);
|
|
expect(fs.existsSync(path.join(fixture.projectRoot, ".cursor/hooks.json"))).toBe(false);
|
|
expect(fs.existsSync(workflowPath)).toBe(false);
|
|
});
|
|
|
|
it("honors selected setup actions when skip is selected with another option", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
const hookPath = path.join(fixture.projectRoot, ".git/hooks/pre-commit");
|
|
const workflowPath = path.join(fixture.projectRoot, ".github/workflows/react-doctor.yml");
|
|
|
|
await runInteractiveInstallReactDoctorForTest({
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
gitHookPath: hookPath,
|
|
setupOptions: ["skip", "workflow"],
|
|
});
|
|
|
|
expect(fs.existsSync(hookPath)).toBe(false);
|
|
expect(fs.existsSync(path.join(fixture.projectRoot, ".cursor/hooks.json"))).toBe(false);
|
|
expect(fs.readFileSync(workflowPath, "utf8")).toContain("millionco/react-doctor@v2");
|
|
});
|
|
|
|
it("--yes does not install native agent hooks unless --agent-hooks is set", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor", "claude-code"],
|
|
gitHookPath: null,
|
|
});
|
|
|
|
expect(
|
|
fs.existsSync(path.join(fixture.projectRoot, ".agents/skills/react-doctor/SKILL.md")),
|
|
).toBe(true);
|
|
expect(
|
|
fs.existsSync(path.join(fixture.projectRoot, ".claude/skills/react-doctor/SKILL.md")),
|
|
).toBe(true);
|
|
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("--yes installs Git and agent hooks in CI using real git detection", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
process.env.CI = "1";
|
|
execFileSync("git", ["init"], {
|
|
cwd: fixture.projectRoot,
|
|
stdio: "ignore",
|
|
});
|
|
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
agentHooks: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor", "claude-code"],
|
|
});
|
|
|
|
expect(
|
|
fs.readFileSync(path.join(fixture.projectRoot, ".git/hooks/pre-commit"), "utf8"),
|
|
).toContain("react-doctor --staged --blocking warning");
|
|
expect(fs.existsSync(path.join(fixture.projectRoot, ".react-doctor/hooks/pre-commit"))).toBe(
|
|
false,
|
|
);
|
|
expect(fs.readFileSync(path.join(fixture.projectRoot, ".cursor/hooks.json"), "utf8")).toContain(
|
|
'"stop"',
|
|
);
|
|
expect(
|
|
fs.readFileSync(path.join(fixture.projectRoot, ".claude/settings.json"), "utf8"),
|
|
).toContain('"Stop"');
|
|
});
|
|
|
|
it("--yes upgrades an existing @v1 workflow to @v2 in place", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
const workflowPath = writeExistingWorkflow(fixture.projectRoot, "millionco/react-doctor@v1");
|
|
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor"],
|
|
gitHookPath: null,
|
|
});
|
|
|
|
const workflowContent = fs.readFileSync(workflowPath, "utf8");
|
|
expect(workflowContent).toContain("millionco/react-doctor@v2");
|
|
expect(workflowContent).not.toContain("millionco/react-doctor@v1");
|
|
});
|
|
|
|
it("--yes leaves an exactly-pinned workflow untouched", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
const workflowPath = writeExistingWorkflow(
|
|
fixture.projectRoot,
|
|
"millionco/react-doctor@v1.2.3",
|
|
);
|
|
const originalContent = fs.readFileSync(workflowPath, "utf8");
|
|
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor"],
|
|
gitHookPath: null,
|
|
});
|
|
|
|
expect(fs.readFileSync(workflowPath, "utf8")).toBe(originalContent);
|
|
});
|
|
|
|
it("--yes leaves an already-@v2 workflow untouched", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
const workflowPath = writeExistingWorkflow(fixture.projectRoot, "millionco/react-doctor@v2");
|
|
const originalContent = fs.readFileSync(workflowPath, "utf8");
|
|
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor"],
|
|
gitHookPath: null,
|
|
});
|
|
|
|
expect(fs.readFileSync(workflowPath, "utf8")).toBe(originalContent);
|
|
});
|
|
|
|
it("interactively upgrades an existing @v1 workflow when the offer is accepted", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
const hookPath = path.join(fixture.projectRoot, ".git/hooks/pre-commit");
|
|
const workflowPath = writeExistingWorkflow(fixture.projectRoot, "millionco/react-doctor@v1");
|
|
const promptQuestions: unknown[] = [];
|
|
|
|
await runInteractiveInstallReactDoctorForTest({
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
gitHookPath: hookPath,
|
|
setupOptions: ["workflow-upgrade"],
|
|
promptQuestions,
|
|
});
|
|
|
|
// The upgrade prompt replaces the "add" prompt when a workflow exists.
|
|
expect(promptQuestions[0]).toEqual(
|
|
expect.objectContaining({ type: "select", name: "upgradeChoice" }),
|
|
);
|
|
expect(fs.readFileSync(workflowPath, "utf8")).toContain("millionco/react-doctor@v2");
|
|
});
|
|
|
|
it("interactively keeps an existing @v1 workflow when the offer is declined", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
const hookPath = path.join(fixture.projectRoot, ".git/hooks/pre-commit");
|
|
const workflowPath = writeExistingWorkflow(fixture.projectRoot, "millionco/react-doctor@v1");
|
|
|
|
await runInteractiveInstallReactDoctorForTest({
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
gitHookPath: hookPath,
|
|
setupOptions: [],
|
|
});
|
|
|
|
expect(fs.readFileSync(workflowPath, "utf8")).toContain("millionco/react-doctor@v1");
|
|
expect(fs.readFileSync(workflowPath, "utf8")).not.toContain("millionco/react-doctor@v2");
|
|
});
|
|
|
|
it("interactively skips the @v1 upgrade offer once the decision is already persisted", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
const hookPath = path.join(fixture.projectRoot, ".git/hooks/pre-commit");
|
|
const workflowPath = writeExistingWorkflow(fixture.projectRoot, "millionco/react-doctor@v1");
|
|
recordActionUpgradeDecision(fixture.projectRoot, "declined");
|
|
const promptQuestions: unknown[] = [];
|
|
|
|
await runInteractiveInstallReactDoctorForTest({
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
gitHookPath: hookPath,
|
|
// Would normally accept the bump — but the persisted decline suppresses
|
|
// the offer entirely, so the prompt is never shown.
|
|
setupOptions: ["workflow-upgrade"],
|
|
promptQuestions,
|
|
});
|
|
|
|
expect(promptQuestions).not.toContainEqual(expect.objectContaining({ name: "upgradeChoice" }));
|
|
expect(fs.readFileSync(workflowPath, "utf8")).toContain("millionco/react-doctor@v1");
|
|
expect(fs.readFileSync(workflowPath, "utf8")).not.toContain("millionco/react-doctor@v2");
|
|
});
|
|
|
|
it("--yes does not re-apply an already-declined @v1 upgrade", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
const workflowPath = writeExistingWorkflow(fixture.projectRoot, "millionco/react-doctor@v1");
|
|
recordActionUpgradeDecision(fixture.projectRoot, "declined");
|
|
|
|
await runInstallReactDoctorForTest({
|
|
yes: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor"],
|
|
gitHookPath: null,
|
|
});
|
|
|
|
expect(fs.readFileSync(workflowPath, "utf8")).toContain("millionco/react-doctor@v1");
|
|
expect(fs.readFileSync(workflowPath, "utf8")).not.toContain("millionco/react-doctor@v2");
|
|
});
|
|
|
|
it("interactively persists a declined CI offer so the scan handoff won't re-ask", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
const hookPath = path.join(fixture.projectRoot, ".git/hooks/pre-commit");
|
|
const workflowPath = path.join(fixture.projectRoot, ".github/workflows/react-doctor.yml");
|
|
expect(hasHandledCiPrompt(fixture.projectRoot)).toBe(false);
|
|
|
|
await runInteractiveInstallReactDoctorForTest({
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
gitHookPath: hookPath,
|
|
// No "workflow" → the CI offer is declined (ci-no).
|
|
setupOptions: [],
|
|
});
|
|
|
|
expect(fs.existsSync(workflowPath)).toBe(false);
|
|
// The decline is persisted, so the once-per-repo pitch stays answered.
|
|
expect(hasHandledCiPrompt(fixture.projectRoot)).toBe(true);
|
|
});
|
|
|
|
it("persists an accepted CI offer so the scan handoff won't re-ask", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
const hookPath = path.join(fixture.projectRoot, ".git/hooks/pre-commit");
|
|
const workflowPath = path.join(fixture.projectRoot, ".github/workflows/react-doctor.yml");
|
|
expect(hasHandledCiPrompt(fixture.projectRoot)).toBe(false);
|
|
|
|
await runInteractiveInstallReactDoctorForTest({
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
gitHookPath: hookPath,
|
|
// "workflow" → the CI offer is accepted (ci-yes).
|
|
setupOptions: ["workflow"],
|
|
});
|
|
|
|
expect(fs.existsSync(workflowPath)).toBe(true);
|
|
// The accept is persisted at answer time, so an accept that never wrote the
|
|
// workflow (early exit / failure) still won't re-pitch on the next scan.
|
|
expect(hasHandledCiPrompt(fixture.projectRoot)).toBe(true);
|
|
});
|
|
|
|
it("interactively skips the CI offer once the decision is already persisted", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
writePackageJson(fixture.projectRoot, { scripts: {} });
|
|
const hookPath = path.join(fixture.projectRoot, ".git/hooks/pre-commit");
|
|
const workflowPath = path.join(fixture.projectRoot, ".github/workflows/react-doctor.yml");
|
|
recordCiPromptDecision(fixture.projectRoot, "declined");
|
|
const promptQuestions: unknown[] = [];
|
|
|
|
await runInteractiveInstallReactDoctorForTest({
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
gitHookPath: hookPath,
|
|
// Would normally add the workflow — but the persisted decline (e.g. from a
|
|
// prior scan handoff) suppresses the CI question entirely.
|
|
setupOptions: ["workflow"],
|
|
promptQuestions,
|
|
});
|
|
|
|
expect(promptQuestions).not.toContainEqual(expect.objectContaining({ name: "ciChoice" }));
|
|
expect(fs.existsSync(workflowPath)).toBe(false);
|
|
});
|
|
|
|
it("CI skips prompts without --yes but does not install the optional Git hook", async () => {
|
|
writeValidSkill(fixture.sourceDir);
|
|
process.env.CI = "1";
|
|
execFileSync("git", ["init"], {
|
|
cwd: fixture.projectRoot,
|
|
stdio: "ignore",
|
|
});
|
|
|
|
await runInstallReactDoctorForTest({
|
|
agentHooks: true,
|
|
sourceDir: fixture.sourceDir,
|
|
projectRoot: fixture.projectRoot,
|
|
detectedAgents: ["cursor"],
|
|
});
|
|
|
|
expect(
|
|
fs.existsSync(path.join(fixture.projectRoot, ".agents/skills/react-doctor/SKILL.md")),
|
|
).toBe(true);
|
|
expect(fs.readFileSync(path.join(fixture.projectRoot, ".cursor/hooks.json"), "utf8")).toContain(
|
|
'"stop"',
|
|
);
|
|
expect(fs.existsSync(path.join(fixture.projectRoot, ".git/hooks/pre-commit"))).toBe(false);
|
|
expect(fs.existsSync(path.join(fixture.projectRoot, ".react-doctor/hooks/pre-commit"))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
});
|