255 lines
8.4 KiB
TypeScript
255 lines
8.4 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
|
|
import { spawn } from "@lydell/node-pty";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
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, detected targets, modality labels, and durable key help", async () => {
|
|
const result = await runJourney([step("Choose a setup", "\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("Detected Codex");
|
|
expect(result.output).toContain("Codex · MCP · create");
|
|
expect(result.output).toContain("Hopper deep-analysis provider (provider)");
|
|
expect(result.output).toContain("REA reverse-engineering skill (skill)");
|
|
expect(result.output).toContain(
|
|
"Keys: ↑/↓ navigate · Space toggle · Enter confirm · Ctrl-C cancel",
|
|
);
|
|
expect(result.decision.approved).toBe(false);
|
|
});
|
|
|
|
it("offers recommended, custom, and no-thanks intent before choosing targets", async () => {
|
|
const result = await runJourney([
|
|
step("Choose a setup", "\u001b[B\u001b[B\r"),
|
|
]);
|
|
|
|
expect(result.output).toContain("Set up all available capabilities");
|
|
expect(result.output).toContain("recommended · 3 changes");
|
|
expect(result.output).toContain("Customize setup");
|
|
expect(result.output).toContain("No thanks");
|
|
expect(result.output).not.toContain("Ready to review");
|
|
expect(result.decision).toMatchObject({ approved: false });
|
|
});
|
|
|
|
it("lets custom mode reach the modality-labelled target picker and cancel safely", async () => {
|
|
const result = await runJourney([
|
|
step("Choose a setup", "\u001b[B\r"),
|
|
step("Choose what REA should configure", "\u0003"),
|
|
]);
|
|
|
|
expect(result.output).toContain("Choose what REA should configure");
|
|
expect(result.output).toContain("Codex (MCP)");
|
|
expect(result.output).toContain("Hopper deep-analysis provider (provider)");
|
|
expect(result.output).toContain("Setup cancelled. No changes were made.");
|
|
expect(result.decision.approved).toBe(false);
|
|
});
|
|
|
|
it("keeps the recommended path subject to a default-No exact preflight", async () => {
|
|
const result = await runJourney([
|
|
step("Choose a setup", "\r"),
|
|
step("Apply these 3 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("No, cancel");
|
|
expect(result.output).toContain("Setup cancelled. No changes were made.");
|
|
expect(result.decision).toEqual({
|
|
approved: false,
|
|
selectedActionIds: actions.map(({ id }) => id),
|
|
});
|
|
});
|
|
|
|
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: "Use REA to explain how a feature works in /path/to/app."',
|
|
);
|
|
});
|
|
});
|
|
|
|
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[],
|
|
): Promise<JourneyResult> => {
|
|
const isolatedHome = await mkdtemp(join(tmpdir(), "rea-cli-setup-test-"));
|
|
const script = [
|
|
'import { confirmInteractiveSetup } from "./dist/cliSetup.js";',
|
|
`const actions = ${JSON.stringify(actions)};`,
|
|
"const decision = await confirmInteractiveSetup(actions, false);",
|
|
`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"));
|
|
});
|
|
}
|
|
});
|
|
};
|