GitNexus/gitnexus/test/unit/setup-selection.test.ts
Gergő Magyar 6252aa745f
feat(setup): add CodeBuddy and Qoder coding-agent integrations (#2368)
* feat(setup): add CodeBuddy and Qoder coding-agent integrations

Adds Tencent CodeBuddy and Alibaba Qoder to gitnexus setup/uninstall,
fitted to the editor-targets registry and --coding-agent selection.

- CodeBuddy: MCP entry written into the first existing file of its
  documented priority chain (~/.codebuddy/.mcp.json recommended,
  ~/.codebuddy/mcp.json deprecated, ~/.codebuddy.json legacy) so a
  populated deprecated config is never shadowed; skills to
  ~/.codebuddy/skills/ (https://www.codebuddy.ai/docs/cli/mcp)
- Qoder: MCP entry in ~/.qoder.json, skills to ~/.qoder/skills/
  (https://docs.qoder.com/cli/using-cli, /extensions/skills)
- editor-targets gains optional legacyFiles; uninstall sweeps them
- roster strings updated (CLI help, i18n en/zh-CN, READMEs); en/zh-CN
  setup descriptions were stale (missing Antigravity) and are refreshed

Supersedes and credits PR #1030 by @zykai0302, re-fitted to the
post-#2168 selective-agent architecture with documented config paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(cli): assert stable zh-CN setup-description fragment

* fix(setup): surface non-ENOENT config read/stat failures instead of clobbering

* fix(setup): report corrupt legacy MCP files informationally during uninstall

* test(setup): cover multi-candidate uninstall sweep combinations

* fix(setup): detect CodeBuddy/Qoder installs via existing MCP config files

* fix(setup): skip empty and non-file candidates in the MCP config chain

* docs: add CodeBuddy and Qoder manual MCP configuration sections

* test(ci): run the setup-uninstall round-trip in the cross-platform matrix

* fix(setup): never claim "not configured" when uninstall recorded errors

* refactor(cli): share the isEnoent predicate via editor-targets

* refactor(setup): share chain-file install detection between CodeBuddy and Qoder

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 10:54:50 +01:00

151 lines
6 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
const execFileMock = vi.fn((...args: any[]) => {
const callback = args.at(-1);
if (typeof callback === 'function') callback(null, '', '');
});
vi.mock('child_process', () => ({
execFile: execFileMock,
execFileSync: vi.fn(() => {
throw new Error('not found');
}),
}));
describe('setupCommand coding-agent selection', () => {
let tempHome: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
let originalExitCode: number | string | null | undefined;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
originalExitCode = process.exitCode;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-setup-selection-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
process.exitCode = undefined;
await Promise.all([
fs.mkdir(path.join(tempHome, '.cursor'), { recursive: true }),
fs.mkdir(path.join(tempHome, '.claude'), { recursive: true }),
fs.mkdir(path.join(tempHome, '.gemini', 'antigravity'), { recursive: true }),
fs.mkdir(path.join(tempHome, '.config', 'opencode'), { recursive: true }),
fs.mkdir(path.join(tempHome, '.codex'), { recursive: true }),
]);
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
process.exitCode = originalExitCode;
await fs.rm(tempHome, { recursive: true, force: true });
});
it('explicit -c codebuddy succeeds when only a legacy root config exists (no dot-dir)', async () => {
const legacy = path.join(tempHome, '.codebuddy.json');
await fs.writeFile(
legacy,
JSON.stringify({ mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand({ codingAgent: ['codebuddy'] });
const config = JSON.parse(await fs.readFile(legacy, 'utf-8'));
expect(config.mcpServers.gitnexus).toBeDefined();
expect(config.mcpServers.other).toEqual({ command: 'foo' });
// Explicit selection that configures something must not exit 1.
expect(process.exitCode).not.toBe(1);
});
it('configures only the requested coding agent', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand({ codingAgent: ['opencode'] });
await expect(
fs.access(path.join(tempHome, '.config', 'opencode', 'opencode.json')),
).resolves.toBeUndefined();
await expect(fs.access(path.join(tempHome, '.cursor', 'mcp.json'))).rejects.toThrow();
await expect(fs.access(path.join(tempHome, '.claude.json'))).rejects.toThrow();
await expect(
fs.access(path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json')),
).rejects.toThrow();
await expect(fs.access(path.join(tempHome, '.codex', 'config.toml'))).rejects.toThrow();
});
it('accepts comma-separated and repeated selections without configuring others', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand({ codingAgent: ['cursor,opencode', 'cursor'] });
await expect(fs.access(path.join(tempHome, '.cursor', 'mcp.json'))).resolves.toBeUndefined();
await expect(
fs.access(path.join(tempHome, '.config', 'opencode', 'opencode.json')),
).resolves.toBeUndefined();
await expect(fs.access(path.join(tempHome, '.claude.json'))).rejects.toThrow();
});
it('rejects unknown values before writing configuration', async () => {
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand({ codingAgent: ['opencode,unknown'] });
expect(process.exitCode).toBe(1);
expect(stderr).toHaveBeenCalledWith(
expect.stringContaining(
'Valid values: cursor, claude, antigravity, opencode, codebuddy, qoder, codex',
),
);
await expect(
fs.access(path.join(tempHome, '.config', 'opencode', 'opencode.json')),
).rejects.toThrow();
});
it.each([
['an empty string', ''],
['an empty array', []],
])('rejects %s before writing configuration', async (_label, codingAgent) => {
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand({ codingAgent });
expect(process.exitCode).toBe(1);
expect(stderr).toHaveBeenCalledWith(expect.stringContaining('No coding agents were provided.'));
await expect(fs.access(path.join(tempHome, '.cursor', 'mcp.json'))).rejects.toThrow();
});
it('fails clearly when an explicitly selected agent is not installed', async () => {
await fs.rm(path.join(tempHome, '.codex'), { recursive: true, force: true });
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand({ codingAgent: ['codex'] });
expect(process.exitCode).toBe(1);
expect(stderr).toHaveBeenCalledWith(
'None of the explicitly selected coding agents were configured.\n',
);
expect(vi.mocked(console.log).mock.calls.flat().join('\n')).not.toContain('MCP is ready!');
});
it('preserves the no-flag default of configuring every detected agent', async () => {
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
await expect(fs.access(path.join(tempHome, '.cursor', 'mcp.json'))).resolves.toBeUndefined();
await expect(fs.access(path.join(tempHome, '.claude.json'))).resolves.toBeUndefined();
await expect(
fs.access(path.join(tempHome, '.config', 'opencode', 'opencode.json')),
).resolves.toBeUndefined();
});
});