Files
rea/tests/cliSetup.test.ts
T
morluto fec5c3ce7f fix(cli): retain bundled skill and bias setup wizard toward apply
Fix #399: when agent integration is selected but all agents are deselected,
the bundled skill was silently dropped. The skill is now always included
when agent integration is selected, regardless of client selection.

Fix #398: the setup wizard defaulted to cancellation at every step despite
explicit setup intent. The capability picker now pre-selects agent
integration, the agent picker pre-selects all detected agents, the final
confirmation defaults to Yes, and accessible prompts default to Yes for
pre-selected items. Agent option hints now describe the agent instead of
the file operation.

Update tests/cliSetup.test.ts to cover the new skill-retention (#399) and
default-toward-apply (#398) behavior, including accessible-mode opt-out.

Update docs/installation.md to describe the new pre-selection and
Yes-default confirmation.
2026-07-22 17:01:05 +00:00

396 lines
13 KiB
TypeScript

import { execFile } from "node:child_process";
import { rm } from "node:fs/promises";
import { promisify } from "node:util";
import { spawn } from "@lydell/node-pty";
import { describe, expect, it } from "vitest";
import { createTestTempDirectory } from "./fixtures/temporaryDirectory.js";
const execute = promisify(execFile);
const decisionMarker = "REA_SETUP_DECISION:";
const actions = [
{
id: "configure_client:codex",
kind: "configure_client",
label: "Codex",
target: "/isolated/.codex/config.toml",
detail: "Add the REA MCP registration for Codex.",
external: false,
operation: "create",
},
{
id: "install_skill",
kind: "install_skill",
label: "REA reverse-engineering skill",
target: "/isolated/.agents/skills/reverse-engineering/SKILL.md",
detail: "Install the bundled reverse-engineering skill.",
external: false,
operation: "install",
},
{
id: "install_hopper",
kind: "install_hopper",
label: "Hopper deep-analysis provider",
target: "/isolated/Applications/Hopper Disassembler.app",
detail: "Install the verified Hopper package.",
external: true,
operation: "install",
},
] as const;
describe("interactive setup journey", () => {
it("leads with value, a compact detected-target summary, and durable key help", async () => {
const result = await runJourney([
step("What should REA set up?", "\u0003"),
]);
expect(result.output).toContain(
"Understand local apps and binaries from your coding agent.",
);
expect(result.output).toContain(
"Trace how a feature works with local evidence.",
);
expect(result.output).toContain("Found 1 supported agent");
expect(result.output).toContain("Codex");
expect(result.output).toContain("Hopper deep-analysis provider (provider)");
expect(result.output).toContain(
"Agent integration (MCP + guided workflow)",
);
expect(result.output).toContain(
"Keys: ↑/↓ navigate · Space toggle · Enter confirm · Ctrl-C cancel",
);
expect(result.decision.approved).toBe(false);
});
it("allows deselecting the pre-selected agent integration to opt out", async () => {
const result = await runJourney([step("What should REA set up?", " \r")]);
expect(result.output).toContain(
"Agent integration (MCP + guided workflow)",
);
expect(result.output).not.toContain(
"REA reverse-engineering skill (skill)",
);
expect(result.output).toContain("Hopper deep-analysis provider (provider)");
expect(result.output).not.toContain("Ready to review");
expect(result.output).toContain("Nothing selected. No changes were made.");
expect(result.decision).toEqual({
approved: false,
selectedActionIds: [],
});
});
it("pre-selects agent integration and proceeds to the agent picker on Enter", async () => {
const result = await runJourney([
step("What should REA set up?", "\r"),
step("Which agents should use REA?", "\u0003"),
]);
expect(result.output).toContain("Codex (detected)");
expect(result.output).toContain("Setup cancelled. No changes were made.");
expect(result.decision.approved).toBe(false);
});
it("offers one bundled repair when MCP is aligned but the skill is missing", async () => {
const result = await runJourney(
[step("What should REA set up?", "\r"), step("Apply this change?", "\r")],
false,
actions.filter(({ kind }) => kind !== "configure_client"),
);
expect(result.output).toContain("No agent integrations need configuration");
expect(result.output).toContain(
"Agent integration (MCP + guided workflow)",
);
expect(result.output).not.toContain(
"REA reverse-engineering skill (skill)",
);
expect(result.decision).toEqual({
approved: true,
selectedActionIds: ["install_skill"],
});
});
it("pre-selects all detected agents in the agent picker", async () => {
const result = await runJourney(
[
step("What should REA set up?", "\r"),
step("Which agents should use REA?", "\u0003"),
],
false,
[
actions[0],
{
...actions[0],
id: "configure_client:cursor",
label: "Cursor",
target: "/isolated/.cursor/mcp.json",
},
...actions.slice(1),
],
);
expect(result.output).toContain("Codex (detected)");
expect(result.output).toContain("Cursor (detected)");
expect(result.decision.approved).toBe(false);
});
it("retains the bundled skill even when all agents are deselected (#399)", async () => {
const result = await runJourney([
step("What should REA set up?", "\r"),
step("Which agents should use REA?", " \r"),
step("Apply this change?", "\r"),
]);
expect(result.output).toContain("Ready to review");
expect(result.output).toContain("INSTALL REA reverse-engineering skill");
expect(result.output).not.toContain("CREATE Codex");
expect(result.decision).toEqual({
approved: true,
selectedActionIds: ["install_skill"],
});
});
it("defaults the final confirmation to Yes, apply", async () => {
const result = await runJourney([
step("What should REA set up?", "\r"),
step("Which agents should use REA?", "\r"),
step("Apply these 2 changes?", "\r"),
]);
expect(result.output).toContain("Ready to review");
expect(result.output).toContain("CREATE Codex");
expect(result.output).toContain("/isolated/.codex/config.toml");
expect(result.output).toContain("INSTALL REA reverse-engineering skill");
expect(result.output).not.toContain(
"INSTALL Hopper deep-analysis provider",
);
expect(result.output).toContain("Yes, apply");
expect(result.decision).toEqual({
approved: true,
selectedActionIds: ["configure_client:codex", "install_skill"],
});
});
it("can still be cancelled at the final confirmation", async () => {
const result = await runJourney([
step("What should REA set up?", "\r"),
step("Which agents should use REA?", "\r"),
step("Apply these 2 changes?", "\u001b[D\r"),
]);
expect(result.output).toContain("Ready to review");
expect(result.output).toContain("Setup cancelled. No changes were made.");
expect(result.decision).toEqual({
approved: false,
selectedActionIds: ["configure_client:codex", "install_skill"],
});
});
it("repairs an aligned agent integration through the bundled option", async () => {
const result = await runJourney(
[step("What should REA set up?", "\r"), step("Apply this change?", "\r")],
false,
actions.filter(({ kind }) => kind !== "configure_client"),
);
expect(result.output).toContain("INSTALL REA reverse-engineering skill");
expect(result.output).toContain(
"Agent integration (MCP + guided workflow)",
);
expect(result.output).not.toContain("CREATE Codex");
expect(result.output).not.toContain(
"INSTALL Hopper deep-analysis provider",
);
expect(result.decision).toEqual({
approved: true,
selectedActionIds: ["install_skill"],
});
});
it("defaults accessible agent integration to Yes and others to No", async () => {
const result = await runJourney(
[
step("Set up Agent integration (MCP + guided workflow)?", "\r"),
step("Set up Hopper deep-analysis provider (provider)?", "\r"),
step("Apply this change?", "\r"),
],
true,
actions.filter(({ kind }) => kind !== "configure_client"),
);
expect(result.output).toContain("Ready to review");
expect(result.output).toContain("INSTALL REA reverse-engineering skill");
expect(result.decision).toEqual({
approved: true,
selectedActionIds: ["install_skill"],
});
});
it("allows accessible opt-out of the pre-selected agent integration", async () => {
const result = await runJourney(
[
step("Set up Agent integration (MCP + guided workflow)?", "\u001b[B\r"),
step("Set up Hopper deep-analysis provider (provider)?", "\r"),
],
true,
);
expect(result.output).toContain("Nothing selected. No changes were made.");
expect(result.decision.selectedActionIds).toEqual([]);
});
it("frames completion around the capabilities that are now ready", async () => {
const { stderr } = await execute(
process.execPath,
[
"--input-type=module",
"--eval",
[
'import { renderInteractiveSetupResult } from "./dist/cliSetup.js";',
"renderInteractiveSetupResult({",
' status: "ready",',
" plannedActions: [],",
" appliedActions: [],",
' clients: { codex: { status: "configured" } },',
" doctor: {",
" healthy: true,",
' hopperPath: "/Applications/Hopper",',
" checks: [],",
' providerInspections: [{ id: "ghidra", available: true }],',
' identity: { skill: { state: "aligned" }, registrations: [{ client: "codex", state: "aligned" }] },',
" },",
"});",
].join("\n"),
],
{
cwd: process.cwd(),
env: { ...process.env, NO_COLOR: "1" },
},
);
expect(stderr).toContain("What you can do now");
expect(stderr).toContain("Deep analysis: Hopper and Ghidra");
expect(stderr).toContain("Agent access: Codex");
expect(stderr).toContain("Guided reverse-engineering workflows: installed");
expect(stderr).toContain("CLI: rea analyze /path/to/app");
expect(stderr).toContain(
'Try in Codex: "Explain how a feature works in /path/to/app and show the evidence."',
);
});
});
interface JourneyStep {
readonly prompt: string;
readonly input: string;
}
interface JourneyResult {
readonly output: string;
readonly decision: {
readonly approved: boolean;
readonly selectedActionIds: readonly string[];
};
}
const step = (prompt: string, input: string): JourneyStep => ({
prompt,
input,
});
const runJourney = async (
steps: readonly JourneyStep[],
accessible = false,
journeyActions: readonly object[] = actions,
): Promise<JourneyResult> => {
const isolatedHome = await createTestTempDirectory("rea-cli-setup-test-");
const script = [
'import { confirmInteractiveSetup } from "./dist/cliSetup.js";',
`const actions = ${JSON.stringify(journeyActions)};`,
`const decision = await confirmInteractiveSetup(actions, ${JSON.stringify(accessible)});`,
`process.stdout.write(${JSON.stringify(`\n${decisionMarker}`)} + JSON.stringify(decision) + "\\n");`,
].join("\n");
return new Promise<JourneyResult>((resolvePromise, reject) => {
let output = "";
let nextStep = 0;
let settled = false;
const terminal = spawn(
process.execPath,
["--input-type=module", "--eval", script],
{
cwd: process.cwd(),
env: {
...process.env,
HOME: isolatedHome,
NO_COLOR: "1",
TERM: "xterm-256color",
},
name: "xterm-256color",
cols: 120,
rows: 40,
},
);
const timeout = setTimeout(() => {
terminal.kill();
finish(new Error(`Timed out after output:\n${output}`));
}, 10_000);
terminal.onData((data) => {
output += data;
const pending = steps[nextStep];
if (pending !== undefined && output.includes(pending.prompt)) {
nextStep += 1;
terminal.write(pending.input);
}
});
terminal.onExit(({ exitCode }) => {
if (exitCode !== 0) {
finish(
new Error(`Setup journey exited ${String(exitCode)}:\n${output}`),
);
return;
}
if (nextStep !== steps.length) {
finish(new Error(`Setup journey missed an interaction:\n${output}`));
return;
}
const markerIndex = output.lastIndexOf(decisionMarker);
if (markerIndex === -1) {
finish(new Error(`Setup journey returned no decision:\n${output}`));
return;
}
const serialized = output
.slice(markerIndex + decisionMarker.length)
.split(/\r?\n/u, 1)[0];
if (serialized === undefined) {
finish(
new Error(`Setup journey returned an empty decision:\n${output}`),
);
return;
}
try {
finish(undefined, {
output,
decision: JSON.parse(serialized) as JourneyResult["decision"],
});
} catch (cause: unknown) {
finish(cause instanceof Error ? cause : new Error(String(cause)));
}
});
function finish(error?: Error, result?: JourneyResult): void {
if (settled) return;
settled = true;
clearTimeout(timeout);
void rm(isolatedHome, { recursive: true, force: true }).finally(() => {
if (error !== undefined) reject(error);
else if (result !== undefined) resolvePromise(result);
else reject(new Error("Setup journey completed without a result"));
});
}
});
};