installer-targets.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. /**
  2. * Multi-target installer tests.
  3. *
  4. * Each `AgentTarget` is exercised against the same contract:
  5. * - `install` writes the expected files
  6. * - re-running `install` is byte-identical (idempotent)
  7. * - sibling MCP servers / unrelated config is preserved
  8. * - `uninstall` reverses `install`
  9. * - `printConfig` returns parseable, non-empty content
  10. *
  11. * For agent-config destinations we redirect HOME to a tmpdir via
  12. * `os.homedir` spying, and CWD via `process.chdir` — same pattern as
  13. * the legacy `installer.test.ts`. No real `~/.claude/` etc. ever
  14. * touched.
  15. */
  16. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  17. import * as fs from 'fs';
  18. import * as path from 'path';
  19. import * as os from 'os';
  20. import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry';
  21. import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml';
  22. function mkTmpDir(label: string): string {
  23. return fs.mkdtempSync(path.join(os.tmpdir(), `cg-targets-${label}-`));
  24. }
  25. // `os.homedir` is non-configurable on Node, so we redirect it via the
  26. // `$HOME` (POSIX) / `$USERPROFILE` (Windows) env vars that
  27. // `os.homedir()` reads first. Same trick the rest of the suite uses
  28. // when it needs a mock home.
  29. function setHome(dir: string): { restore: () => void } {
  30. const prev = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE };
  31. process.env.HOME = dir;
  32. process.env.USERPROFILE = dir;
  33. return {
  34. restore() {
  35. if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME;
  36. if (prev.USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = prev.USERPROFILE;
  37. },
  38. };
  39. }
  40. describe('Installer targets — contract', () => {
  41. let tmpHome: string;
  42. let tmpCwd: string;
  43. let origCwd: string;
  44. let homeRestore: { restore: () => void };
  45. beforeEach(() => {
  46. tmpHome = mkTmpDir('home');
  47. tmpCwd = mkTmpDir('cwd');
  48. origCwd = process.cwd();
  49. process.chdir(tmpCwd);
  50. homeRestore = setHome(tmpHome);
  51. });
  52. afterEach(() => {
  53. homeRestore.restore();
  54. process.chdir(origCwd);
  55. fs.rmSync(tmpHome, { recursive: true, force: true });
  56. fs.rmSync(tmpCwd, { recursive: true, force: true });
  57. });
  58. for (const target of ALL_TARGETS) {
  59. describe(target.id, () => {
  60. const supportedLocations = (['global', 'local'] as const).filter((l) =>
  61. target.supportsLocation(l),
  62. );
  63. for (const location of supportedLocations) {
  64. describe(`location=${location}`, () => {
  65. it('install writes files; detect.alreadyConfigured becomes true', () => {
  66. expect(target.detect(location).alreadyConfigured).toBe(false);
  67. const result = target.install(location, { autoAllow: true });
  68. expect(result.files.length).toBeGreaterThan(0);
  69. for (const file of result.files) {
  70. if (file.action !== 'unchanged') {
  71. expect(fs.existsSync(file.path)).toBe(true);
  72. }
  73. }
  74. expect(target.detect(location).alreadyConfigured).toBe(true);
  75. });
  76. it('re-running install is idempotent (no actions other than unchanged)', () => {
  77. target.install(location, { autoAllow: true });
  78. const second = target.install(location, { autoAllow: true });
  79. for (const file of second.files) {
  80. expect(file.action).toBe('unchanged');
  81. }
  82. });
  83. it('install preserves a pre-existing sibling MCP server (where applicable)', () => {
  84. // Plant a sibling entry in the same JSON config, install,
  85. // and verify the sibling survives. Skip for Codex (TOML)
  86. // and any target with no JSON config — they get covered
  87. // by their own dedicated tests below.
  88. const paths = target.describePaths(location);
  89. const jsonPath = paths.find((p) => p.endsWith('.json'));
  90. if (!jsonPath) return;
  91. // Seed pre-existing config.
  92. fs.mkdirSync(path.dirname(jsonPath), { recursive: true });
  93. const seed: Record<string, any> = { mcpServers: { other: { command: 'x' } } };
  94. // opencode uses `mcp` not `mcpServers`. Match its shape too.
  95. if (target.id === 'opencode') {
  96. delete seed.mcpServers;
  97. seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } };
  98. }
  99. fs.writeFileSync(jsonPath, JSON.stringify(seed, null, 2) + '\n');
  100. target.install(location, { autoAllow: true });
  101. const after = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
  102. if (target.id === 'opencode') {
  103. expect(after.mcp.other).toBeDefined();
  104. expect(after.mcp.codegraph).toBeDefined();
  105. } else {
  106. expect(after.mcpServers.other).toBeDefined();
  107. expect(after.mcpServers.codegraph).toBeDefined();
  108. }
  109. });
  110. it('uninstall reverses install (alreadyConfigured returns to false)', () => {
  111. target.install(location, { autoAllow: true });
  112. expect(target.detect(location).alreadyConfigured).toBe(true);
  113. target.uninstall(location);
  114. expect(target.detect(location).alreadyConfigured).toBe(false);
  115. });
  116. it('printConfig returns non-empty output without writing anything', () => {
  117. const before = listAllFiles(tmpHome).concat(listAllFiles(tmpCwd));
  118. const out = target.printConfig(location);
  119. expect(out.length).toBeGreaterThan(0);
  120. const after = listAllFiles(tmpHome).concat(listAllFiles(tmpCwd));
  121. expect(after.sort()).toEqual(before.sort());
  122. });
  123. });
  124. }
  125. });
  126. }
  127. });
  128. describe('Installer targets — partial-state idempotency', () => {
  129. let tmpHome: string;
  130. let tmpCwd: string;
  131. let origCwd: string;
  132. let homeRestore: { restore: () => void };
  133. beforeEach(() => {
  134. tmpHome = mkTmpDir('home');
  135. tmpCwd = mkTmpDir('cwd');
  136. origCwd = process.cwd();
  137. process.chdir(tmpCwd);
  138. homeRestore = setHome(tmpHome);
  139. });
  140. afterEach(() => {
  141. homeRestore.restore();
  142. process.chdir(origCwd);
  143. fs.rmSync(tmpHome, { recursive: true, force: true });
  144. fs.rmSync(tmpCwd, { recursive: true, force: true });
  145. });
  146. it('codex: install after only config.toml exists — second pass is fully unchanged', () => {
  147. const codex = getTarget('codex')!;
  148. // First install creates both files.
  149. codex.install('global', { autoAllow: false });
  150. // Delete the AGENTS.md to simulate partial state (user wiped one file).
  151. const agentsMd = path.join(tmpHome, '.codex', 'AGENTS.md');
  152. expect(fs.existsSync(agentsMd)).toBe(true);
  153. fs.unlinkSync(agentsMd);
  154. // Reinstall — TOML stays unchanged, AGENTS.md is recreated.
  155. const second = codex.install('global', { autoAllow: false });
  156. const tomlEntry = second.files.find((f) => f.path.endsWith('config.toml'))!;
  157. const mdEntry = second.files.find((f) => f.path.endsWith('AGENTS.md'))!;
  158. expect(tomlEntry.action).toBe('unchanged');
  159. expect(mdEntry.action).toBe('created');
  160. // Third install — both unchanged (full idempotency restored).
  161. const third = codex.install('global', { autoAllow: false });
  162. for (const f of third.files) expect(f.action).toBe('unchanged');
  163. });
  164. it('codex: user-added key inside [mcp_servers.codegraph] survives idempotent re-install', () => {
  165. const codex = getTarget('codex')!;
  166. codex.install('global', { autoAllow: false });
  167. const tomlPath = path.join(tmpHome, '.codex', 'config.toml');
  168. const original = fs.readFileSync(tomlPath, 'utf-8');
  169. // User edits the block to add a custom key.
  170. const edited = original.replace(
  171. 'args = ["serve", "--mcp"]',
  172. 'args = ["serve", "--mcp"]\nenabled = true',
  173. );
  174. fs.writeFileSync(tomlPath, edited);
  175. // Re-install: our serializer doesn't know `enabled = true`, so
  176. // the block no longer matches the canonical form — we'll
  177. // overwrite it. This is the documented contract: we own the
  178. // codegraph block exclusively.
  179. const second = codex.install('global', { autoAllow: false });
  180. const tomlEntry = second.files.find((f) => f.path.endsWith('config.toml'))!;
  181. expect(tomlEntry.action).toBe('updated');
  182. const after = fs.readFileSync(tomlPath, 'utf-8');
  183. expect(after).not.toContain('enabled = true');
  184. });
  185. });
  186. describe('Installer targets — registry', () => {
  187. it('getTarget returns the right target for each id', () => {
  188. expect(getTarget('claude')?.id).toBe('claude');
  189. expect(getTarget('cursor')?.id).toBe('cursor');
  190. expect(getTarget('codex')?.id).toBe('codex');
  191. expect(getTarget('opencode')?.id).toBe('opencode');
  192. expect(getTarget('not-a-real-target')).toBeUndefined();
  193. });
  194. it('resolveTargetFlag handles auto/all/none/csv', () => {
  195. expect(resolveTargetFlag('none', 'global')).toEqual([]);
  196. expect(resolveTargetFlag('all', 'global').length).toBe(ALL_TARGETS.length);
  197. const csv = resolveTargetFlag('claude,cursor', 'global');
  198. expect(csv.map((t) => t.id)).toEqual(['claude', 'cursor']);
  199. });
  200. it('resolveTargetFlag throws on unknown id', () => {
  201. expect(() => resolveTargetFlag('claude,bogus', 'global')).toThrow(/Unknown --target/);
  202. });
  203. });
  204. describe('Installer targets — TOML serializer (Codex backbone)', () => {
  205. it('builds a [mcp_servers.codegraph] block with command + args', () => {
  206. const block = buildTomlTable('mcp_servers.codegraph', {
  207. command: 'codegraph',
  208. args: ['serve', '--mcp'],
  209. });
  210. expect(block).toContain('[mcp_servers.codegraph]');
  211. expect(block).toContain('command = "codegraph"');
  212. expect(block).toContain('args = ["serve", "--mcp"]');
  213. });
  214. it('upsert inserts into empty content', () => {
  215. const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] });
  216. const { content, action } = upsertTomlTable('', 'mcp_servers.codegraph', block);
  217. expect(action).toBe('inserted');
  218. expect(content.startsWith('[mcp_servers.codegraph]')).toBe(true);
  219. });
  220. it('upsert is idempotent — second call returns unchanged', () => {
  221. const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] });
  222. const first = upsertTomlTable('', 'mcp_servers.codegraph', block);
  223. const second = upsertTomlTable(first.content, 'mcp_servers.codegraph', block);
  224. expect(second.action).toBe('unchanged');
  225. expect(second.content).toBe(first.content);
  226. });
  227. it('upsert replaces an existing block in place, preserving sibling tables', () => {
  228. const existing = [
  229. '[other_table]',
  230. 'foo = "bar"',
  231. '',
  232. '[mcp_servers.codegraph]',
  233. 'command = "old-codegraph"',
  234. 'args = ["old"]',
  235. '',
  236. '[zzz]',
  237. 'baz = "qux"',
  238. '',
  239. ].join('\n');
  240. const newBlock = buildTomlTable('mcp_servers.codegraph', {
  241. command: 'codegraph',
  242. args: ['serve', '--mcp'],
  243. });
  244. const { content, action } = upsertTomlTable(existing, 'mcp_servers.codegraph', newBlock);
  245. expect(action).toBe('replaced');
  246. expect(content).toContain('[other_table]');
  247. expect(content).toContain('foo = "bar"');
  248. expect(content).toContain('[zzz]');
  249. expect(content).toContain('baz = "qux"');
  250. expect(content).toContain('command = "codegraph"');
  251. expect(content).not.toContain('old-codegraph');
  252. });
  253. it('removeTomlTable strips the block and preserves siblings', () => {
  254. const existing = [
  255. '[other_table]',
  256. 'foo = "bar"',
  257. '',
  258. '[mcp_servers.codegraph]',
  259. 'command = "codegraph"',
  260. 'args = ["serve"]',
  261. ].join('\n');
  262. const { content, action } = removeTomlTable(existing, 'mcp_servers.codegraph');
  263. expect(action).toBe('removed');
  264. expect(content).toContain('[other_table]');
  265. expect(content).toContain('foo = "bar"');
  266. expect(content).not.toContain('mcp_servers.codegraph');
  267. });
  268. it('removeTomlTable on missing table returns not-found, no content change', () => {
  269. const existing = '[other]\nfoo = "bar"\n';
  270. const { content, action } = removeTomlTable(existing, 'mcp_servers.codegraph');
  271. expect(action).toBe('not-found');
  272. expect(content).toBe(existing);
  273. });
  274. it('upsert preserves an array-of-tables sibling [[foo]]', () => {
  275. const existing = [
  276. '[[foo]]',
  277. 'name = "a"',
  278. '',
  279. '[[foo]]',
  280. 'name = "b"',
  281. '',
  282. ].join('\n');
  283. const block = buildTomlTable('mcp_servers.codegraph', { command: 'codegraph', args: ['serve'] });
  284. const { content } = upsertTomlTable(existing, 'mcp_servers.codegraph', block);
  285. expect(content.match(/\[\[foo\]\]/g)?.length).toBe(2);
  286. expect(content).toContain('[mcp_servers.codegraph]');
  287. });
  288. });
  289. function listAllFiles(dir: string): string[] {
  290. if (!fs.existsSync(dir)) return [];
  291. const out: string[] = [];
  292. for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
  293. const full = path.join(dir, entry.name);
  294. if (entry.isDirectory()) out.push(...listAllFiles(full));
  295. else out.push(full);
  296. }
  297. return out;
  298. }