From cbfdae0303cc8935d3d05aecbe9a41da7134dcfe Mon Sep 17 00:00:00 2001 From: Dmytro Date: Fri, 20 Mar 2026 15:28:42 +0100 Subject: [PATCH] test(cli): cover full Codex setup flow --- .../test/integration/setup-skills.test.ts | 38 +++++++ gitnexus/test/unit/setup-codex.test.ts | 103 ++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 gitnexus/test/unit/setup-codex.test.ts diff --git a/gitnexus/test/integration/setup-skills.test.ts b/gitnexus/test/integration/setup-skills.test.ts index f6f35f76f..f6e3801e5 100644 --- a/gitnexus/test/integration/setup-skills.test.ts +++ b/gitnexus/test/integration/setup-skills.test.ts @@ -9,6 +9,7 @@ describe('setupCommand skills integration', () => { let tempHome: string; const originalHome = process.env.HOME; const originalUserProfile = process.env.USERPROFILE; + const originalPath = process.env.PATH; const testId = `${Date.now()}-${process.pid}`; const flatSkillName = `test-flat-skill-${testId}`; const dirSkillName = `test-dir-skill-${testId}`; @@ -47,6 +48,7 @@ describe('setupCommand skills integration', () => { await fs.rm(path.join(packageSkillsRoot, dirSkillName), { recursive: true, force: true }); process.env.HOME = originalHome; process.env.USERPROFILE = originalUserProfile; + process.env.PATH = originalPath; await fs.rm(tempHome, { recursive: true, force: true }); }); @@ -85,4 +87,40 @@ describe('setupCommand skills integration', () => { ); expect(nestedInstalled).toContain('Directory Nested File'); }); + + it('falls back to Codex config.toml and installs skills into ~/.agents/skills when codex CLI is unavailable', async () => { + await fs.mkdir(path.join(tempHome, '.codex'), { recursive: true }); + process.env.PATH = ''; + + await setupCommand(); + + const codexConfig = await fs.readFile( + path.join(tempHome, '.codex', 'config.toml'), + 'utf-8', + ); + expect(codexConfig).toContain('[mcp_servers.gitnexus]'); + expect(codexConfig).toContain('gitnexus@latest'); + + const codexSkill = await fs.readFile( + path.join(tempHome, '.agents', 'skills', 'gitnexus-cli', 'SKILL.md'), + 'utf-8', + ); + expect(codexSkill).toContain('GitNexus CLI Commands'); + }); + + it('does not duplicate the Codex MCP section on repeated fallback setup runs', async () => { + await fs.mkdir(path.join(tempHome, '.codex'), { recursive: true }); + process.env.PATH = ''; + + await setupCommand(); + await setupCommand(); + + const codexConfig = await fs.readFile( + path.join(tempHome, '.codex', 'config.toml'), + 'utf-8', + ); + const sectionMatches = codexConfig.match(/\[mcp_servers\.gitnexus\]/g) ?? []; + + expect(sectionMatches).toHaveLength(1); + }); }); diff --git a/gitnexus/test/unit/setup-codex.test.ts b/gitnexus/test/unit/setup-codex.test.ts new file mode 100644 index 000000000..9a6650861 --- /dev/null +++ b/gitnexus/test/unit/setup-codex.test.ts @@ -0,0 +1,103 @@ +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, +})); + +describe('setupCommand codex execution', () => { + let tempHome: string; + let originalHome: string | undefined; + let originalUserProfile: string | undefined; + let platformDescriptor: PropertyDescriptor | undefined; + + const setPlatform = (value: NodeJS.Platform) => { + Object.defineProperty(process, 'platform', { + value, + configurable: true, + }); + }; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + + originalHome = process.env.HOME; + originalUserProfile = process.env.USERPROFILE; + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-codex-setup-')); + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + + await fs.mkdir(path.join(tempHome, '.codex'), { recursive: true }); + + platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + setPlatform('win32'); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + + if (platformDescriptor) { + Object.defineProperty(process, 'platform', platformDescriptor); + } + + process.env.HOME = originalHome; + process.env.USERPROFILE = originalUserProfile; + await fs.rm(tempHome, { recursive: true, force: true }); + }); + + it('invokes codex mcp add with shell enabled on Windows', async () => { + const { setupCommand } = await import('../../src/cli/setup.js'); + + await setupCommand(); + + expect(execFileMock).toHaveBeenCalledWith( + 'codex', + ['mcp', 'add', 'gitnexus', '--', 'cmd', '/c', 'npx', '-y', 'gitnexus@latest', 'mcp'], + { shell: true }, + expect.any(Function), + ); + }); + + it('invokes codex mcp add without shell on non-Windows and does not write fallback config', async () => { + setPlatform('darwin'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + + await setupCommand(); + + expect(execFileMock).toHaveBeenCalledWith( + 'codex', + ['mcp', 'add', 'gitnexus', '--', 'npx', '-y', 'gitnexus@latest', 'mcp'], + { shell: false }, + expect.any(Function), + ); + + await expect( + fs.access(path.join(tempHome, '.codex', 'config.toml')), + ).rejects.toThrow(); + }); + + it('skips Codex setup entirely when ~/.codex is missing', async () => { + await fs.rm(path.join(tempHome, '.codex'), { recursive: true, force: true }); + + const { setupCommand } = await import('../../src/cli/setup.js'); + + await setupCommand(); + + expect(execFileMock).not.toHaveBeenCalled(); + await expect( + fs.access(path.join(tempHome, '.agents', 'skills')), + ).rejects.toThrow(); + }); +});