2026-07-13 19:09:27 +00:00
import {
copyFile ,
lstat ,
mkdtemp ,
mkdir ,
readFile ,
rm ,
writeFile ,
} from "node:fs/promises" ;
2026-07-12 23:42:20 +00:00
import { tmpdir } from "node:os" ;
import { dirname , join } from "node:path" ;
import { afterEach , describe , expect , it } from "vitest" ;
import {
configureTomlClient ,
detectClients ,
} from "../src/application/Setup.js" ;
import {
runUninstall ,
systemUninstallHost ,
2026-07-13 19:09:27 +00:00
type UninstallFileSystem ,
2026-07-12 23:42:20 +00:00
} from "../src/application/Uninstall.js" ;
const roots : string [] = [];
afterEach ( async () => {
await Promise . all (
roots . splice ( 0 ). map (( root ) => rm ( root , { recursive : true , force : true })),
);
});
describe ( "agent lifecycle" , () => {
it ( "detects every supported client and skips absent clients" , async () => {
const home = await mkdtemp ( join ( tmpdir (), "rea-detect-" ));
roots . push ( home );
for ( const marker of [
".claude" ,
"Library/Application Support/Claude" ,
".codex" ,
".cursor" ,
".gemini" ,
".codeium/windsurf" ,
".devin" ,
])
await mkdir ( join ( home , marker ), { recursive : true });
const detected = await detectClients ( home );
expect ( detected . map (({ name }) => name )). toEqual ([
"claude_code" ,
"claude_desktop" ,
"codex" ,
"cursor" ,
"gemini_cli" ,
"windsurf" ,
"devin" ,
]);
expect (
detected . find (({ name }) => name === "claude_code" ) ? . configPath ,
). toBe ( join ( home , ".claude.json" ));
expect ( detected . find (({ name }) => name === "devin" ) ? . format ). toBe (
"unsupported" ,
);
const emptyHome = await mkdtemp ( join ( tmpdir (), "rea-empty-" ));
roots . push ( emptyHome );
expect ( await detectClients ( emptyHome )). toEqual ([]);
});
it ( "preserves unrelated Codex TOML and supports an installed command" , async () => {
const home = await mkdtemp ( join ( tmpdir (), "rea-toml-" ));
roots . push ( home );
const configPath = join ( home , ".codex/config.toml" );
await mkdir ( dirname ( configPath ), { recursive : true });
await writeFile (
configPath ,
'model = "gpt-5"\n[mcp_servers.other]\ncommand = "other"\n' ,
);
expect (
await configureTomlClient (
{ name : "codex" , configPath , format : "toml" },
"/Hopper Path" ,
[ "rea" , "mcp" ],
),
). toMatchObject ({ status : "configured" });
const configured = await readFile ( configPath , "utf8" );
expect ( configured ). toContain ( 'model = "gpt-5"' );
expect ( configured ). toContain ( "[mcp_servers.rea]" );
expect ( configured ). toContain ( 'command = "rea"' );
expect (
await configureTomlClient (
{ name : "codex" , configPath , format : "toml" },
"/Hopper Path" ,
[ "rea" , "mcp" ],
),
). toEqual ({ status : "unchanged" });
});
it ( "uninstalls only owned entries and refuses purge symlinks" , async () => {
const home = await mkdtemp ( join ( tmpdir (), "rea-uninstall-" ));
roots . push ( home );
const cursor = join ( home , ".cursor/mcp.json" );
const skill = join ( home , ".agents/skills/rea-analysis/SKILL.md" );
await mkdir ( dirname ( cursor ), { recursive : true });
await mkdir ( dirname ( skill ), { recursive : true });
await writeFile (
cursor ,
JSON . stringify ({
mcpServers : {
rea : { command : "/isolated prefix/bin/rea" , args : [ "mcp" ] },
other : { command : "other" },
},
}),
);
await writeFile ( skill , "managed" );
await mkdir ( join ( home , ".rea" ), { recursive : true });
await import ( "node:fs/promises" ). then (({ symlink }) =>
symlink ( home , join ( home , ".rea/cache" )),
);
const first = await runUninstall ( true , systemUninstallHost ( home ));
expect ( first . status ). toBe ( "complete" );
expect ( JSON . parse ( await readFile ( cursor , "utf8" ))). toEqual ({
mcpServers : { other : { command : "other" } },
});
expect ( first . items ). toContainEqual (
2026-07-13 19:09:27 +00:00
expect . objectContaining ({
name : "cache" ,
status : "retained" ,
detail :
"REA did not remove this item because its managed path is a symbolic link. Verify the link target before removing it manually." ,
}),
2026-07-12 23:42:20 +00:00
);
expect (( await runUninstall ( true , systemUninstallHost ( home ))). status ). toBe (
"complete" ,
);
});
it ( "fails closed on malformed client configuration" , async () => {
const home = await mkdtemp ( join ( tmpdir (), "rea-uninstall-bad-" ));
roots . push ( home );
const config = join ( home , ".cursor/mcp.json" );
await mkdir ( dirname ( config ), { recursive : true });
await writeFile ( config , "not-json" );
2026-07-13 19:09:27 +00:00
const result = await runUninstall ( false , systemUninstallHost ( home ));
expect ( result . status ). toBe ( "failed" );
expect ( result . items ). toContainEqual (
expect . objectContaining ({
name : "cursor" ,
status : "failed" ,
detail :
"Configuration is not valid JSON and was not changed. Repair it, then rerun uninstall." ,
}),
2026-07-12 23:42:20 +00:00
);
expect ( await readFile ( config , "utf8" )). toBe ( "not-json" );
});
2026-07-13 19:09:27 +00:00
it . each ([
[
"read" ,
"Configuration could not be read. Check file permissions, then rerun uninstall." ,
],
[
"backup" ,
"Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall." ,
],
[
"update" ,
"Configuration could not be updated. The original was restored and its `.rea.backup` was retained. Repair the configuration or restore the backup, then rerun uninstall." ,
],
[
"restore" ,
"Configuration could not be updated or restored. Restore its `.rea.backup` manually, then rerun uninstall." ,
],
] as const )(
"reports an actionable client %s failure" ,
async ( failure , detail ) => {
const { home , config } = await uninstallFixture ();
let writes = 0 ;
const fileSystem : UninstallFileSystem = {
... testFileSystem ,
readText : ( path ) =>
failure === "read" && path === config
? Promise . reject ( new Error ( "SECRET read failure" ))
: readFile ( path , "utf8" ),
copy : ( source , destination ) =>
failure === "backup"
? Promise . reject ( new Error ( "SECRET backup failure" ))
: copyFile ( source , destination ),
writeText : async ( path , contents ) => {
writes += 1 ;
if (( failure === "update" && writes === 1 ) || failure === "restore" )
throw new Error ( "SECRET write failure" );
await writeFile ( path , contents );
},
};
const result = await runUninstall (
false ,
systemUninstallHost ( home , fileSystem ),
);
expect ( result . status ). toBe ( "failed" );
expect ( result . items ). toContainEqual (
expect . objectContaining ({ name : "cursor" , status : "failed" , detail }),
);
expect ( JSON . stringify ( result )). not . toContain ( "SECRET" );
},
);
it ( "reports a managed-path removal failure" , async () => {
const home = await mkdtemp ( join ( tmpdir (), "rea-uninstall-remove-" ));
roots . push ( home );
const skillRoot = join ( home , ".agents/skills/rea-analysis" );
await mkdir ( skillRoot , { recursive : true });
const result = await runUninstall (
false ,
systemUninstallHost ( home , {
... testFileSystem ,
remove : () => Promise . reject ( new Error ( "SECRET removal failure" )),
}),
);
expect ( result . items ). toContainEqual ({
name : "skill" ,
status : "failed" ,
detail :
"This item could not be removed. Check file permissions, then rerun uninstall." ,
});
expect ( JSON . stringify ( result )). not . toContain ( "SECRET" );
});
2026-07-12 23:42:20 +00:00
});
2026-07-13 19:09:27 +00:00
const testFileSystem : UninstallFileSystem = {
readText : ( path ) => readFile ( path , "utf8" ),
copy : ( source , destination ) => copyFile ( source , destination ),
writeText : ( path , contents ) => writeFile ( path , contents ),
stat : ( path ) => lstat ( path ),
remove : ( path ) => rm ( path , { recursive : true }),
};
const uninstallFixture = async () : Promise < {
readonly home : string ;
readonly config : string ;
} > => {
const home = await mkdtemp ( join ( tmpdir (), "rea-uninstall-failure-" ));
roots . push ( home );
const config = join ( home , ".cursor/mcp.json" );
await mkdir ( dirname ( config ), { recursive : true });
await writeFile (
config ,
JSON . stringify ({ mcpServers : { rea : { command : "rea" , args : [ "mcp" ] } } }),
);
return { home , config };
};