2026-07-12 06:34:27 +08:00
import { describe , expect , it } from "vitest" ;
import {
runSetup ,
2026-07-18 11:59:16 +08:00
type ClientConfigurationInspection ,
2026-07-12 06:34:27 +08:00
type ClientConfigurationResult ,
type SetupClient ,
type SetupHost ,
2026-07-13 15:13:45 +08:00
type SetupOptions ,
2026-07-13 21:08:33 +00:00
type SetupHopperInstallResult ,
2026-07-18 11:59:16 +08:00
type SetupProgressEvent ,
2026-07-16 00:02:49 +08:00
type SetupProviderEnvironment ,
2026-07-12 06:34:27 +08:00
} from "../src/application/Setup.js" ;
import type { DoctorCheck } from "../src/application/Doctor.js" ;
2026-07-12 23:42:20 +00:00
import type { LinuxDistribution } from "../src/application/LinuxHopper.js" ;
2026-07-12 06:34:27 +08:00
class FakeSetupHost implements SetupHost {
2026-07-12 23:42:20 +00:00
readonly platform : NodeJS.Platform ;
2026-07-13 19:09:27 +00:00
nodeVersion = "25.1.0" ;
2026-07-12 06:34:27 +08:00
version : string | undefined = "14.5" ;
2026-07-13 15:13:45 +08:00
distribution : LinuxDistribution | undefined ;
2026-07-12 06:34:27 +08:00
hopper : string | undefined ;
2026-07-16 00:02:49 +08:00
ghidra : string | undefined ;
javaHome : string | undefined ;
2026-07-12 06:34:27 +08:00
hopperInstallSucceeds = true ;
skill : "installed" | "unchanged" | "failed" = "installed" ;
clients : readonly SetupClient [] = [];
clientResults = new Map < string , ClientConfigurationResult >();
2026-07-18 11:59:16 +08:00
clientInspections = new Map < string , ClientConfigurationInspection >();
2026-07-12 06:34:27 +08:00
hopperInstalls = 0 ;
2026-07-15 06:52:52 +00:00
hopperReplaceRequests : boolean [] = [];
2026-07-12 06:34:27 +08:00
configurations = 0 ;
2026-07-14 20:14:58 +00:00
skillInstalls = 0 ;
2026-07-20 17:11:06 +08:00
doctorCalls = 0 ;
2026-07-15 06:34:25 +00:00
checkedHopperPaths : Array < string | undefined > = [];
2026-07-16 00:02:49 +08:00
configuredProviderEnvironments : SetupProviderEnvironment [] = [];
2026-07-13 19:09:27 +00:00
doctorHealthy : boolean | undefined ;
2026-07-13 20:50:01 +00:00
linuxDemoRuntimeMissing = false ;
unsupportedHopperVersion = false ;
2026-07-12 23:42:20 +00:00
constructor ( platform : NodeJS.Platform = "darwin" ) {
this . platform = platform ;
}
2026-07-12 06:34:27 +08:00
macosVersion = () : Promise < string | undefined > =>
Promise . resolve ( this . version );
2026-07-12 23:42:20 +00:00
linuxDistribution = () : Promise < LinuxDistribution | undefined > =>
Promise . resolve ( this . distribution );
2026-07-12 06:34:27 +08:00
hopperPath = () : Promise < string | undefined > => Promise . resolve ( this . hopper );
2026-07-16 00:02:49 +08:00
providerEnvironment = () : Promise < SetupProviderEnvironment > =>
Promise . resolve ({
...( this . hopper === undefined
? {}
: { HOPPER_LAUNCHER_PATH : this.hopper }),
...( this . ghidra === undefined ? {} : { GHIDRA_INSTALL_DIR : this.ghidra }),
...( this . javaHome === undefined ? {} : { JAVA_HOME : this.javaHome }),
});
2026-07-15 06:52:52 +00:00
installHopper = (
replaceExisting : boolean ,
) : Promise < SetupHopperInstallResult > => {
2026-07-12 06:34:27 +08:00
this . hopperInstalls += 1 ;
2026-07-15 06:52:52 +00:00
this . hopperReplaceRequests . push ( replaceExisting );
2026-07-13 20:50:01 +00:00
this . linuxDemoRuntimeMissing = false ;
2026-07-13 21:08:33 +00:00
if ( this . hopperInstallSucceeds ) {
this . hopper = "/manual/Hopper" ;
return Promise . resolve ({
status : "installed" ,
launcherPath : this.hopper ,
});
}
return Promise . resolve ({
status : "failed" ,
code : "download_failed" ,
remediation : "Download failed." ,
});
2026-07-12 06:34:27 +08:00
};
detectedClients = () : Promise < readonly SetupClient [] > =>
Promise . resolve ( this . clients );
configureClient = (
client : SetupClient ,
2026-07-16 00:02:49 +08:00
providerEnvironment : SetupProviderEnvironment ,
2026-07-12 06:34:27 +08:00
) : Promise < ClientConfigurationResult > => {
this . configurations += 1 ;
2026-07-16 00:02:49 +08:00
this . configuredProviderEnvironments . push ( providerEnvironment );
2026-07-12 06:34:27 +08:00
return Promise . resolve (
2026-07-13 15:13:45 +08:00
this . clientResults . get ( client . name ) ?? { status : "configured" },
2026-07-12 06:34:27 +08:00
);
};
2026-07-15 06:34:25 +00:00
clientNeedsConfigure = (
client : SetupClient ,
2026-07-16 00:02:49 +08:00
providerEnvironment : SetupProviderEnvironment ,
2026-07-15 06:34:25 +00:00
) : Promise < boolean > => {
2026-07-16 00:02:49 +08:00
this . checkedHopperPaths . push ( providerEnvironment . HOPPER_LAUNCHER_PATH );
2026-07-15 06:34:25 +00:00
return Promise . resolve (
2026-07-14 22:00:42 +00:00
this . clientResults . get ( client . name ) ? . status !== "unchanged" ,
);
2026-07-15 06:34:25 +00:00
};
2026-07-18 11:59:16 +08:00
inspectClientConfiguration = (
client : SetupClient ,
) : Promise < ClientConfigurationInspection > =>
Promise . resolve (
this . clientInspections . get ( client . name ) ?? { status : "update" },
);
2026-07-14 20:14:58 +00:00
skillNeedsInstall = () : Promise < boolean > =>
Promise . resolve ( this . skill !== "unchanged" );
installSkill = () : Promise < "installed" | "unchanged" | "failed" > => {
this . skillInstalls += 1 ;
return Promise . resolve ( this . skill );
};
2026-07-12 06:34:27 +08:00
doctor = () : Promise < {
healthy : boolean ;
hopperPath? : string ;
checks : readonly DoctorCheck [];
2026-07-13 20:50:01 +00:00
} > => {
2026-07-20 17:11:06 +08:00
this . doctorCalls += 1 ;
2026-07-13 20:50:01 +00:00
const checks : DoctorCheck [] = [
...( this . linuxDemoRuntimeMissing
? ([
{
name : "hopper-demo-runtime" ,
ok : false ,
classification : "missing_dependency" ,
},
] as const )
: []),
...( this . unsupportedHopperVersion
? ([
{
name : "hopper-version" ,
ok : false ,
classification : "config_drift" ,
detail : this.hopper ?? "" ,
},
] as const )
: []),
];
return Promise . resolve ({
healthy :
this.doctorHealthy ??
( this . hopper !== undefined &&
! this . linuxDemoRuntimeMissing &&
! this . unsupportedHopperVersion ),
2026-07-12 06:34:27 +08:00
...( this . hopper === undefined ? {} : { hopperPath : this.hopper }),
2026-07-13 20:50:01 +00:00
checks ,
2026-07-12 06:34:27 +08:00
});
2026-07-13 20:50:01 +00:00
};
2026-07-12 06:34:27 +08:00
}
2026-07-13 15:13:45 +08:00
const options = ( approved : boolean , installHopper = false ) : SetupOptions => ({
approved ,
installHopper ,
structured : true ,
});
2026-07-12 06:34:27 +08:00
describe ( "setup workflow" , () => {
2026-07-13 15:13:45 +08:00
it ( "returns a complete plan without mutation" , async () => {
2026-07-12 06:34:27 +08:00
const host = new FakeSetupHost ();
2026-07-13 15:13:45 +08:00
host . clients = [{ name : "cursor" , configPath : "/cursor.json" }];
const result = await runSetup ( options ( false ), host );
expect ( result . status ). toBe ( "needs_confirmation" );
expect ( result . plannedActions . map (({ kind }) => kind )). toEqual ([
"install_hopper" ,
"configure_client" ,
"install_skill" ,
2026-07-12 06:34:27 +08:00
]);
2026-07-12 23:42:20 +00:00
expect ( host . hopperInstalls ). toBe ( 0 );
expect ( host . configurations ). toBe ( 0 );
2026-07-13 19:09:27 +00:00
expect ( result . remediation ). toBe (
"Review the setup plan, then rerun interactively or with --yes." ,
);
2026-07-12 23:42:20 +00:00
});
2026-07-13 20:50:01 +00:00
it ( "reports ready after an accepted Linux plan installs healthy Hopper" , async () => {
const host = new FakeSetupHost ( "linux" );
host . distribution = {
id : "ubuntu" ,
versionId : "24.04" ,
packageFamily : "deb" ,
supported : true ,
};
2026-07-13 15:13:45 +08:00
const result = await runSetup (
{ ... options ( false ), structured : false },
host ,
() => Promise . resolve ( true ),
);
2026-07-13 20:50:01 +00:00
expect ( result . status ). toBe ( "ready" );
2026-07-13 15:13:45 +08:00
expect ( result . appliedActions ). toEqual ([
"installed_hopper" ,
"installed_skill" ,
]);
expect ( host . hopperInstalls ). toBe ( 1 );
2026-07-13 20:50:01 +00:00
expect ( result . remediation ). toBeUndefined ();
});
it ( "requires first-run activation after installing Hopper on macOS" , async () => {
const host = new FakeSetupHost ();
const result = await runSetup (
{ ... options ( false ), structured : false },
host ,
() => Promise . resolve ( true ),
2026-07-13 19:09:27 +00:00
);
2026-07-13 20:50:01 +00:00
expect ( result . status ). toBe ( "needs_human" );
expect ( result . remediation ). toContain ( "choose its demo mode" );
2026-07-12 06:34:27 +08:00
});
2026-07-13 15:13:45 +08:00
it ( "declines an interactive plan without mutation" , async () => {
2026-07-12 08:51:35 +08:00
const host = new FakeSetupHost ();
2026-07-13 15:13:45 +08:00
const result = await runSetup (
{ ... options ( false ), structured : false },
host ,
() => Promise . resolve ( false ),
);
expect ( result . status ). toBe ( "planned" );
expect ( result . appliedActions ). toEqual ([]);
2026-07-12 08:51:35 +08:00
expect ( host . hopperInstalls ). toBe ( 0 );
2026-07-20 17:11:06 +08:00
expect ( host . doctorCalls ). toBe ( 1 );
});
it ( "treats an empty interactive selection as cancellation" , async () => {
const host = new FakeSetupHost ();
host . clients = [{ name : "codex" , configPath : "/codex.toml" }];
const result = await runSetup (
{ ... options ( false ), structured : false },
host ,
() => Promise . resolve ({ approved : false , selectedActionIds : [] }),
);
expect ( result . status ). toBe ( "planned" );
expect ( result . plannedActions ). toEqual ([]);
expect ( result . appliedActions ). toEqual ([]);
expect ( host . configurations ). toBe ( 0 );
expect ( host . skillInstalls ). toBe ( 0 );
expect ( host . hopperInstalls ). toBe ( 0 );
expect ( host . doctorCalls ). toBe ( 1 );
2026-07-12 08:51:35 +08:00
});
2026-07-13 15:13:45 +08:00
it ( "requires the Hopper flag for unattended setup" , async () => {
2026-07-12 06:34:27 +08:00
const host = new FakeSetupHost ();
2026-07-13 15:13:45 +08:00
const result = await runSetup ( options ( true ), host );
expect ( result . status ). toBe ( "needs_human" );
2026-07-12 06:34:27 +08:00
expect ( host . hopperInstalls ). toBe ( 0 );
2026-07-13 15:13:45 +08:00
expect ( result . appliedActions ). toEqual ([ "installed_skill" ]);
expect ( result . remediation ). toContain ( "--install-hopper" );
2026-07-13 19:09:27 +00:00
expect ( result . remediation ). toBe (
"Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis." ,
);
2026-07-12 06:34:27 +08:00
});
2026-07-13 15:13:45 +08:00
it ( "installs Hopper when unattended authorization is explicit" , async () => {
2026-07-13 20:50:01 +00:00
const host = new FakeSetupHost ( "linux" );
host . distribution = {
id : "ubuntu" ,
versionId : "24.04" ,
packageFamily : "deb" ,
supported : true ,
};
2026-07-13 15:13:45 +08:00
const result = await runSetup ( options ( true , true ), host );
expect ( result . appliedActions ). toEqual ([
"installed_hopper" ,
"installed_skill" ,
]);
expect ( host . hopperInstalls ). toBe ( 1 );
2026-07-13 20:50:01 +00:00
expect ( result . status ). toBe ( "ready" );
expect ( result . remediation ). toBeUndefined ();
2026-07-13 15:13:45 +08:00
});
2026-07-12 06:34:27 +08:00
2026-07-13 15:13:45 +08:00
it ( "reuses existing Hopper and configures detected clients" , async () => {
2026-07-12 06:34:27 +08:00
const host = new FakeSetupHost ();
2026-07-13 15:13:45 +08:00
host . hopper = "/Applications/Hopper" ;
2026-07-12 06:34:27 +08:00
host . clients = [
{ name : "claude" , configPath : "/claude.json" },
{ name : "cursor" , configPath : "/cursor.json" },
];
2026-07-13 15:13:45 +08:00
const result = await runSetup ( options ( true ), host );
expect ( result . status ). toBe ( "ready" );
expect ( result . appliedActions ). toEqual ([
"configured_claude" ,
"configured_cursor" ,
"installed_skill" ,
]);
expect ( host . hopperInstalls ). toBe ( 0 );
2026-07-12 06:34:27 +08:00
});
2026-07-18 11:59:16 +08:00
it ( "reports append-only lifecycle progress for each selected action" , async () => {
const host = new FakeSetupHost ();
host . hopper = "/Applications/Hopper" ;
host . clients = [
{
name : "codex" ,
displayName : "Codex" ,
configPath : "/codex.toml" ,
},
];
const progress : SetupProgressEvent [] = [];
await runSetup (
{ ... options ( true ), onProgress : ( event ) => progress . push ( event ) },
host ,
);
expect ( progress ). toEqual ([
{ actionId : "configure_client:codex" , label : "Codex" , state : "started" },
{
actionId : "configure_client:codex" ,
label : "Codex" ,
state : "completed" ,
},
{
actionId : "install_skill" ,
label : "REA reverse-engineering skill" ,
state : "started" ,
},
{
actionId : "install_skill" ,
label : "REA reverse-engineering skill" ,
state : "completed" ,
},
]);
});
2026-07-16 00:02:49 +08:00
it ( "plans and propagates BYO Ghidra without installing software" , async () => {
const host = new FakeSetupHost ( "linux" );
host . distribution = {
id : "ubuntu" ,
versionId : "24.04" ,
packageFamily : "deb" ,
supported : true ,
};
host . ghidra = "/opt/ghidra_12.1.2_PUBLIC" ;
host . javaHome = "/usr/lib/jvm/jdk-21" ;
host . doctorHealthy = true ;
host . skill = "unchanged" ;
host . clients = [{ name : "cursor" , configPath : "/cursor.json" }];
const plan = await runSetup ( options ( false ), host );
expect ( plan . plannedActions . map (({ kind }) => kind )). toEqual ([
"configure_client" ,
]);
expect ( plan . plannedActions [ 0 ] ? . detail ). toContain (
"GHIDRA_INSTALL_DIR=/opt/ghidra_12.1.2_PUBLIC" ,
);
expect ( plan . plannedActions [ 0 ] ? . detail ). toContain (
"JAVA_HOME=/usr/lib/jvm/jdk-21" ,
);
expect ( host . hopperInstalls ). toBe ( 0 );
expect ( host . configurations ). toBe ( 0 );
const applied = await runSetup ( options ( true ), host );
expect ( applied . status ). toBe ( "ready" );
expect ( host . hopperInstalls ). toBe ( 0 );
expect ( host . configuredProviderEnvironments ). toContainEqual ({
GHIDRA_INSTALL_DIR : "/opt/ghidra_12.1.2_PUBLIC" ,
JAVA_HOME : "/usr/lib/jvm/jdk-21" ,
});
});
2026-07-15 06:34:25 +00:00
it ( "reinstalls existing Hopper when explicitly requested" , async () => {
const host = new FakeSetupHost ();
host . hopper = "/Applications/Hopper" ;
host . skill = "unchanged" ;
host . clients = [{ name : "codex" , configPath : "/codex.toml" }];
host . clientResults . set ( "codex" , { status : "unchanged" });
const result = await runSetup ( options ( true , true ), host );
expect ( result . plannedActions . map (({ kind }) => kind )). toEqual ([
"install_hopper" ,
"configure_client" ,
]);
expect ( result . appliedActions ). toEqual ([ "installed_hopper" ]);
expect ( host . hopperInstalls ). toBe ( 1 );
2026-07-15 06:52:52 +00:00
expect ( host . hopperReplaceRequests ). toEqual ([ true ]);
2026-07-15 06:34:25 +00:00
expect ( host . configurations ). toBe ( 0 );
expect ( host . checkedHopperPaths ). toEqual ([ "/manual/Hopper" ]);
});
2026-07-14 20:14:58 +00:00
it ( "omits an aligned managed skill from an otherwise empty plan" , async () => {
const host = new FakeSetupHost ();
host . hopper = "/Applications/Hopper" ;
host . skill = "unchanged" ;
const result = await runSetup ( options ( false ), host );
expect ( result . status ). toBe ( "ready" );
expect ( result . plannedActions ). toEqual ([]);
expect ( result . appliedActions ). toEqual ([]);
expect ( host . skillInstalls ). toBe ( 0 );
});
2026-07-13 20:50:01 +00:00
it ( "installs missing Linux demo dependencies for existing Hopper" , async () => {
const host = new FakeSetupHost ( "linux" );
host . distribution = {
id : "ubuntu" ,
versionId : "24.04" ,
packageFamily : "deb" ,
supported : true ,
};
host . hopper = "/opt/hopper/bin/Hopper" ;
host . linuxDemoRuntimeMissing = true ;
const result = await runSetup (
{ ... options ( false ), structured : false },
host ,
() => Promise . resolve ( true ),
);
expect ( result . status ). toBe ( "ready" );
expect ( host . hopperInstalls ). toBe ( 1 );
expect ( result . plannedActions . map (({ kind }) => kind )). toContain (
"install_hopper" ,
);
});
it ( "does not reinstall Hopper for an unsupported configured path" , async () => {
const host = new FakeSetupHost ( "linux" );
host . distribution = {
id : "ubuntu" ,
versionId : "24.04" ,
packageFamily : "deb" ,
supported : true ,
};
host . hopper = "/custom/Hopper" ;
host . unsupportedHopperVersion = true ;
const result = await runSetup (
{ ... options ( false ), structured : false },
host ,
() => Promise . resolve ( true ),
);
expect ( result . status ). toBe ( "needs_human" );
expect ( host . hopperInstalls ). toBe ( 0 );
expect ( result . plannedActions . map (({ kind }) => kind )). not . toContain (
"install_hopper" ,
);
});
2026-07-13 15:13:45 +08:00
it ( "reports a failed Hopper installation without configuring clients" , async () => {
2026-07-12 06:34:27 +08:00
const host = new FakeSetupHost ();
2026-07-13 15:13:45 +08:00
host . hopperInstallSucceeds = false ;
2026-07-12 06:34:27 +08:00
host . clients = [{ name : "cursor" , configPath : "/cursor.json" }];
2026-07-13 15:13:45 +08:00
const result = await runSetup ( options ( true , true ), host );
expect ( result . status ). toBe ( "needs_human" );
2026-07-13 21:08:33 +00:00
expect ( result . code ). toBe ( "download_failed" );
expect ( result . remediation ). toBe ( "Download failed." );
2026-07-13 15:13:45 +08:00
expect ( host . configurations ). toBe ( 0 );
2026-07-12 06:34:27 +08:00
});
2026-07-18 11:59:16 +08:00
it ( "blocks malformed client configuration before Hopper or file mutation" , async () => {
const host = new FakeSetupHost ();
host . clients = [
{ name : "codex" , displayName : "Codex" , configPath : "/codex.toml" },
];
host . clientInspections . set ( "codex" , {
status : "invalid" ,
remediation : "Repair the existing TOML." ,
});
const result = await runSetup ( options ( true , true ), host );
expect ( result . status ). toBe ( "needs_human" );
expect ( result . remediation ). toBe ( "Codex: Repair the existing TOML." );
expect ( host . hopperInstalls ). toBe ( 0 );
expect ( host . configurations ). toBe ( 0 );
expect ( host . skillInstalls ). toBe ( 0 );
});
it ( "blocks malformed configuration before unrelated writes with an existing provider" , async () => {
const host = new FakeSetupHost ();
host . hopper = "/Applications/Hopper" ;
host . clients = [
{
name : "claude_desktop" ,
displayName : "Claude Desktop" ,
configPath : "/claude.json" ,
},
{ name : "cursor" , displayName : "Cursor" , configPath : "/cursor.json" },
];
host . clientInspections . set ( "claude_desktop" , {
status : "invalid" ,
remediation : "Repair the existing JSON." ,
});
const result = await runSetup ( options ( true ), host );
expect ( result . status ). toBe ( "needs_human" );
expect ( result . remediation ). toBe (
"Claude Desktop: Repair the existing JSON." ,
);
expect ( host . configurations ). toBe ( 0 );
expect ( host . skillInstalls ). toBe ( 0 );
});
it ( "plans a client update when Hopper installation changes its desired environment" , async () => {
const host = new FakeSetupHost ();
host . clients = [
{ name : "codex" , displayName : "Codex" , configPath : "/codex.toml" },
];
host . clientInspections . set ( "codex" , { status : "already_current" });
const result = await runSetup ( options ( false ), host );
expect (
result . plannedActions . map (({ id , operation }) => ({
id ,
operation ,
})),
). toContainEqual ({
id : "configure_client:codex" ,
operation : "update" ,
});
expect ( host . configurations ). toBe ( 0 );
});
it ( "executes only actions selected by the interactive adapter" , async () => {
const host = new FakeSetupHost ();
host . hopper = "/Applications/Hopper" ;
host . clients = [
{ name : "codex" , displayName : "Codex" , configPath : "/codex.toml" },
{ name : "cursor" , displayName : "Cursor" , configPath : "/cursor.json" },
];
const result = await runSetup (
{ ... options ( false ), structured : false },
host ,
() =>
Promise . resolve ({
approved : true ,
selectedActionIds : [ "configure_client:codex" ],
}),
);
expect ( result . plannedActions . map (({ id }) => id )). toEqual ([
"configure_client:codex" ,
]);
expect ( result . clients ). toEqual ({ codex : { status : "configured" } });
expect ( host . configurations ). toBe ( 1 );
expect ( host . skillInstalls ). toBe ( 0 );
});
2026-07-13 19:09:27 +00:00
it . each ([
2026-07-14 20:50:07 +00:00
[
"path" ,
"Agent configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun setup." ,
],
2026-07-13 19:09:27 +00:00
[
"backup" ,
2026-07-13 19:17:15 +00:00
"Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup." ,
2026-07-13 19:09:27 +00:00
],
[
"write" ,
2026-07-13 19:17:15 +00:00
"Agent configuration could not be updated. Check file permissions, then rerun setup." ,
2026-07-13 19:09:27 +00:00
],
[
"readback" ,
2026-07-13 19:17:15 +00:00
"Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup." ,
2026-07-13 19:09:27 +00:00
],
] as const )( "explains %s configuration recovery" , async ( reason , message ) => {
const host = new FakeSetupHost ();
host . hopper = "/Applications/Hopper" ;
host . clients = [{ name : "internal_client_key" , configPath : "/agent.json" }];
host . clientResults . set ( "internal_client_key" , { status : "failed" , reason });
const result = await runSetup ( options ( true ), host );
expect ( result . status ). toBe ( "needs_human" );
expect ( result . remediation ). toBe ( message );
expect ( result . remediation ). not . toContain ( "internal_client_key" );
});
2026-07-14 20:24:06 +00:00
it ( "records every detected client outcome after an earlier failure" , async () => {
const host = new FakeSetupHost ();
host . hopper = "/Applications/Hopper" ;
host . clients = [
{ name : "first" , configPath : "/first.json" },
{ name : "second" , configPath : "/second.json" },
{ name : "third" , configPath : "/third.json" },
];
host . clientResults . set ( "first" , { status : "failed" , reason : "write" });
host . clientResults . set ( "second" , { status : "configured" });
host . clientResults . set ( "third" , { status : "failed" , reason : "readback" });
const result = await runSetup ( options ( true ), host );
expect ( host . configurations ). toBe ( 3 );
expect ( result . clients ). toEqual ({
first : { status : "failed" , reason : "write" },
second : { status : "configured" },
third : { status : "failed" , reason : "readback" },
});
expect ( result . appliedActions ). toEqual ([ "configured_second" ]);
expect ( result . status ). toBe ( "needs_human" );
expect ( result . remediation ). toContain ( "could not be updated" );
});
2026-07-13 19:09:27 +00:00
it ( "explains skill installation recovery" , async () => {
const host = new FakeSetupHost ();
host . hopper = "/Applications/Hopper" ;
host . skill = "failed" ;
const result = await runSetup ( options ( true ), host );
expect ( result . status ). toBe ( "needs_human" );
expect ( result . remediation ). toBe (
"REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup." ,
);
});
it ( "delegates remaining unhealthy checks to doctor remediation" , async () => {
const host = new FakeSetupHost ();
host . hopper = "/Applications/Hopper" ;
host . doctorHealthy = false ;
const result = await runSetup ( options ( true ), host );
expect ( result . remediation ). toBe (
"Run rea doctor and apply each reported remediation." ,
);
});
it ( "explains unsupported Node and macOS recovery" , async () => {
const platformHost = new FakeSetupHost ( "win32" );
expect (( await runSetup ( options ( true ), platformHost )). remediation ). toBe (
"REA supports Hopper on macOS and selected 64-bit Linux distributions." ,
);
const nodeHost = new FakeSetupHost ();
nodeHost . nodeVersion = "20.0.0" ;
expect (( await runSetup ( options ( true ), nodeHost )). remediation ). toBe (
"Install Node.js 22.19+ or 24.11+ and rerun setup." ,
);
const macHost = new FakeSetupHost ();
macHost . version = "11.7" ;
expect (( await runSetup ( options ( true ), macHost )). remediation ). toBe (
"Upgrade to macOS 12 or newer." ,
);
});
2026-07-13 15:13:45 +08:00
it ( "rejects unsupported hosts before mutation" , async () => {
const host = new FakeSetupHost ( "linux" );
host . distribution = {
id : "debian" ,
versionId : "13" ,
packageFamily : "deb" ,
supported : false ,
};
const result = await runSetup ( options ( true , true ), host );
expect ( result . status ). toBe ( "needs_human" );
expect ( host . hopperInstalls ). toBe ( 0 );
2026-07-13 19:09:27 +00:00
expect ( result . remediation ). toBe (
2026-07-16 00:02:49 +08:00
"Automated Hopper setup supports Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux; configure an existing supported provider instead." ,
2026-07-13 19:09:27 +00:00
);
2026-07-13 15:13:45 +08:00
});
2026-07-16 00:02:49 +08:00
it ( "configures BYO Ghidra on Linux outside Hopper's installer matrix" , async () => {
const host = new FakeSetupHost ( "linux" );
host . distribution = {
id : "debian" ,
versionId : "13" ,
packageFamily : "deb" ,
supported : false ,
};
host . ghidra = "/opt/ghidra_12.1.2_PUBLIC" ;
host . javaHome = "/usr/lib/jvm/jdk-21" ;
host . doctorHealthy = true ;
host . skill = "unchanged" ;
host . clients = [{ name : "codex" , configPath : "/codex.toml" }];
const result = await runSetup ( options ( true ), host );
expect ( result . status ). toBe ( "ready" );
expect ( host . hopperInstalls ). toBe ( 0 );
expect ( host . configuredProviderEnvironments ). toContainEqual ({
GHIDRA_INSTALL_DIR : "/opt/ghidra_12.1.2_PUBLIC" ,
JAVA_HOME : "/usr/lib/jvm/jdk-21" ,
});
});
2026-07-12 06:34:27 +08:00
});