From aba4886ed86f541734b7b9751b4b18a062cd2558 Mon Sep 17 00:00:00 2001 From: Clawdie AI Date: Wed, 15 Apr 2026 05:14:52 +0000 Subject: [PATCH] test: add agent-cli-check and identity-restore tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 27 new tests: detectAgentClis/requireAtLeastOneAgentCli/NoAgentCliError with mocked commandExists, plus identity-restore run() covering all skip conditions (missing .env, no Supabase config, fetch errors) and happy path (canonical + legacy paths, no-overwrite guard, blank file overwrite, header verification, quoted .env values). Co-Authored-By: Claude Sonnet 4.6 --- Build: FAIL | Tests: pass — Tests 1390 passed (1390) --- setup/agent-cli-check.test.ts | 151 +++++++++++++++++++++++ setup/identity-restore.test.ts | 216 +++++++++++++++++++++++++++++++++ 2 files changed, 367 insertions(+) create mode 100644 setup/agent-cli-check.test.ts create mode 100644 setup/identity-restore.test.ts diff --git a/setup/agent-cli-check.test.ts b/setup/agent-cli-check.test.ts new file mode 100644 index 0000000..ff015c2 --- /dev/null +++ b/setup/agent-cli-check.test.ts @@ -0,0 +1,151 @@ +/** + * setup/agent-cli-check.test.ts — agent CLI detection tests. + * + * Tests AGENT_CLIS, detectAgentClis(), NoAgentCliError, and + * requireAtLeastOneAgentCli() with mocked commandExists. + * + * Run with: npx vitest run setup/agent-cli-check.test.ts + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// --------------------------------------------------------------------------- +// Mock commandExists from platform.js +// --------------------------------------------------------------------------- + +const platformMock = vi.hoisted(() => ({ + commandExists: vi.fn<[string], boolean>(() => false), +})); + +vi.mock('./platform.js', () => platformMock); + +import { + AGENT_CLIS, + detectAgentClis, + NoAgentCliError, + requireAtLeastOneAgentCli, +} from './agent-cli-check.js'; + +// --------------------------------------------------------------------------- +// AGENT_CLIS +// --------------------------------------------------------------------------- + +describe('AGENT_CLIS', () => { + it('contains pi, aider, claude, codex, gemini', () => { + const commands = AGENT_CLIS.map((c) => c.command); + expect(commands).toContain('pi'); + expect(commands).toContain('aider'); + expect(commands).toContain('claude'); + expect(commands).toContain('codex'); + expect(commands).toContain('gemini'); + }); + + it('each entry has name and command fields', () => { + for (const cli of AGENT_CLIS) { + expect(typeof cli.name).toBe('string'); + expect(typeof cli.command).toBe('string'); + } + }); +}); + +// --------------------------------------------------------------------------- +// detectAgentClis +// --------------------------------------------------------------------------- + +describe('detectAgentClis', () => { + beforeEach(() => { + platformMock.commandExists.mockReset().mockReturnValue(false); + }); + + it('returns one entry per AGENT_CLIS entry', () => { + const result = detectAgentClis(); + expect(result).toHaveLength(AGENT_CLIS.length); + }); + + it('marks all as not present when commandExists returns false', () => { + const result = detectAgentClis(); + expect(result.every((c) => !c.present)).toBe(true); + }); + + it('marks all as present when commandExists returns true', () => { + platformMock.commandExists.mockReturnValue(true); + const result = detectAgentClis(); + expect(result.every((c) => c.present)).toBe(true); + }); + + it('marks only "pi" as present when only pi is available', () => { + platformMock.commandExists.mockImplementation((cmd) => cmd === 'pi'); + const result = detectAgentClis(); + const piEntry = result.find((c) => c.command === 'pi'); + const aiderEntry = result.find((c) => c.command === 'aider'); + expect(piEntry?.present).toBe(true); + expect(aiderEntry?.present).toBe(false); + }); + + it('passes the command string to commandExists', () => { + detectAgentClis(); + const calledCommands = platformMock.commandExists.mock.calls.map((c) => c[0]); + expect(calledCommands).toContain('pi'); + expect(calledCommands).toContain('aider'); + }); + + it('returned entries include name and command from AGENT_CLIS', () => { + const result = detectAgentClis(); + for (let i = 0; i < AGENT_CLIS.length; i++) { + expect(result[i].name).toBe(AGENT_CLIS[i].name); + expect(result[i].command).toBe(AGENT_CLIS[i].command); + } + }); +}); + +// --------------------------------------------------------------------------- +// NoAgentCliError +// --------------------------------------------------------------------------- + +describe('NoAgentCliError', () => { + it('is an instance of Error', () => { + expect(new NoAgentCliError()).toBeInstanceOf(Error); + }); + + it('has name "NoAgentCliError"', () => { + expect(new NoAgentCliError().name).toBe('NoAgentCliError'); + }); + + it('message mentions agent CLI and install guidance', () => { + const msg = new NoAgentCliError().message; + expect(msg).toContain('No agent CLI found'); + expect(msg.toLowerCase()).toContain('pi'); + expect(msg.toLowerCase()).toContain('aider'); + }); +}); + +// --------------------------------------------------------------------------- +// requireAtLeastOneAgentCli +// --------------------------------------------------------------------------- + +describe('requireAtLeastOneAgentCli', () => { + beforeEach(() => { + platformMock.commandExists.mockReset().mockReturnValue(false); + }); + + it('throws NoAgentCliError when no CLI is present', () => { + expect(() => requireAtLeastOneAgentCli()).toThrow(NoAgentCliError); + }); + + it('does not throw when at least one CLI is present', () => { + platformMock.commandExists.mockImplementation((cmd) => cmd === 'claude'); + expect(() => requireAtLeastOneAgentCli()).not.toThrow(); + }); + + it('returns the full list of CLIs', () => { + platformMock.commandExists.mockReturnValue(true); + const result = requireAtLeastOneAgentCli(); + expect(result).toHaveLength(AGENT_CLIS.length); + }); + + it('returned list includes present=true for available CLIs', () => { + platformMock.commandExists.mockImplementation((cmd) => cmd === 'aider'); + const result = requireAtLeastOneAgentCli(); + const aider = result.find((c) => c.command === 'aider'); + expect(aider?.present).toBe(true); + }); +}); diff --git a/setup/identity-restore.test.ts b/setup/identity-restore.test.ts new file mode 100644 index 0000000..2527a3a --- /dev/null +++ b/setup/identity-restore.test.ts @@ -0,0 +1,216 @@ +/** + * setup/identity-restore.test.ts — identity-restore run() tests. + * + * Tests the skip-conditions (no .env, missing Supabase config, fetch error) + * and the happy path (resolveFile with multi-path candidates, no-overwrite guard). + * + * The private parseEnv() and resolveFile() helpers are covered via run(). + * + * Run with: npx vitest run setup/identity-restore.test.ts + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import path from 'path'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const fsMocks = vi.hoisted(() => ({ + existsSync: vi.fn<[string], boolean>(() => false), + readFileSync: vi.fn<[string, BufferEncoding], string>(() => ''), + writeFileSync: vi.fn<[string, string, string], void>(), +})); + +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + const mocked = { ...actual, ...fsMocks }; + return { ...mocked, default: mocked }; +}); + +vi.mock('../src/logger.js', () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock('./status.js', () => ({ emitStatus: vi.fn() })); + +// Global fetch mock +const fetchMock = vi.fn<[string, RequestInit?], Promise>(); +vi.stubGlobal('fetch', fetchMock); + +import { run } from './identity-restore.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const ENV_WITH_SUPABASE = + 'SUPABASE_URL=https://abc.supabase.co\nSUPABASE_SERVICE_ROLE_KEY=svc-key\n'; + +function makeResponse(data: unknown, ok = true, status = 200): Response { + return { + ok, + status, + text: vi.fn().mockResolvedValue(JSON.stringify(data)), + json: vi.fn().mockResolvedValue(data), + } as unknown as Response; +} + +const ENV_FILE = path.join(process.cwd(), '.env'); + +// --------------------------------------------------------------------------- +// Skip conditions +// --------------------------------------------------------------------------- + +describe('identity-restore.run — skip conditions', () => { + beforeEach(() => { + fsMocks.existsSync.mockReset().mockReturnValue(false); + fsMocks.readFileSync.mockReset().mockReturnValue(''); + fsMocks.writeFileSync.mockReset(); + fetchMock.mockReset(); + }); + + it('skips when .env does not exist', async () => { + fsMocks.existsSync.mockReturnValue(false); + await run([]); + expect(fetchMock).not.toHaveBeenCalled(); + expect(fsMocks.writeFileSync).not.toHaveBeenCalled(); + }); + + it('skips when SUPABASE_URL is not set', async () => { + fsMocks.existsSync.mockReturnValue(true); + fsMocks.readFileSync.mockReturnValue('AGENT_NAME=clawdie\n'); + await run([]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('skips when SUPABASE_SERVICE_ROLE_KEY is not set', async () => { + fsMocks.existsSync.mockReturnValue(true); + fsMocks.readFileSync.mockReturnValue('SUPABASE_URL=https://abc.supabase.co\n'); + await run([]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('skips gracefully when fetch throws', async () => { + fsMocks.existsSync.mockReturnValue(true); + fsMocks.readFileSync.mockReturnValue(ENV_WITH_SUPABASE); + fetchMock.mockRejectedValue(new Error('Network unreachable')); + await expect(run([])).resolves.toBeUndefined(); + expect(fsMocks.writeFileSync).not.toHaveBeenCalled(); + }); + + it('skips gracefully when fetch returns non-ok status', async () => { + fsMocks.existsSync.mockReturnValue(true); + fsMocks.readFileSync.mockReturnValue(ENV_WITH_SUPABASE); + fetchMock.mockResolvedValue(makeResponse('Unauthorized', false, 401)); + await expect(run([])).resolves.toBeUndefined(); + expect(fsMocks.writeFileSync).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Happy path — file writing +// --------------------------------------------------------------------------- + +describe('identity-restore.run — happy path', () => { + beforeEach(() => { + fsMocks.existsSync.mockReset(); + fsMocks.readFileSync.mockReset(); + fsMocks.writeFileSync.mockReset(); + fetchMock.mockReset(); + }); + + it('writes SOUL.md when found under canonical path', async () => { + fsMocks.existsSync.mockImplementation((p: string) => { + return p === ENV_FILE; // .env exists; identity files do not + }); + fsMocks.readFileSync.mockReturnValue(ENV_WITH_SUPABASE); + const rows = [{ file_path: 'SOUL.md', content: '# Soul content' }]; + fetchMock.mockResolvedValue(makeResponse(rows)); + + await run([]); + const written = fsMocks.writeFileSync.mock.calls.find( + (c) => (c[0] as string).endsWith('SOUL.md'), + ); + expect(written).toBeDefined(); + expect(written?.[1]).toBe('# Soul content'); + }); + + it('writes SOUL.md when found under legacy path v2/SOUL.md', async () => { + fsMocks.existsSync.mockImplementation((p: string) => p === ENV_FILE); + fsMocks.readFileSync.mockReturnValue(ENV_WITH_SUPABASE); + const rows = [{ file_path: 'v2/SOUL.md', content: '# Legacy soul' }]; + fetchMock.mockResolvedValue(makeResponse(rows)); + + await run([]); + const written = fsMocks.writeFileSync.mock.calls.find( + (c) => (c[0] as string).endsWith('SOUL.md'), + ); + expect(written).toBeDefined(); + expect(written?.[1]).toBe('# Legacy soul'); + }); + + it('does not overwrite existing non-empty SOUL.md', async () => { + fsMocks.existsSync.mockImplementation(() => true); // everything exists + fsMocks.readFileSync.mockImplementation((p: string) => { + if ((p as string) === ENV_FILE) return ENV_WITH_SUPABASE; + return '# Existing soul content'; // non-empty existing file + }); + const rows = [{ file_path: 'SOUL.md', content: '# New soul' }]; + fetchMock.mockResolvedValue(makeResponse(rows)); + + await run([]); + expect(fsMocks.writeFileSync).not.toHaveBeenCalled(); + }); + + it('overwrites empty (blank) existing identity file', async () => { + fsMocks.existsSync.mockImplementation(() => true); + fsMocks.readFileSync.mockImplementation((p: string) => { + if ((p as string) === ENV_FILE) return ENV_WITH_SUPABASE; + return ' \n'; // blank file + }); + const rows = [{ file_path: 'SOUL.md', content: '# Restored soul' }]; + fetchMock.mockResolvedValue(makeResponse(rows)); + + await run([]); + const written = fsMocks.writeFileSync.mock.calls.find( + (c) => (c[0] as string).endsWith('SOUL.md'), + ); + expect(written).toBeDefined(); + expect(written?.[1]).toBe('# Restored soul'); + }); + + it('writes nothing when Supabase returns empty rows', async () => { + fsMocks.existsSync.mockImplementation((p: string) => p === ENV_FILE); + fsMocks.readFileSync.mockReturnValue(ENV_WITH_SUPABASE); + fetchMock.mockResolvedValue(makeResponse([])); + + await run([]); + expect(fsMocks.writeFileSync).not.toHaveBeenCalled(); + }); + + it('sends Authorization and apikey headers to Supabase', async () => { + fsMocks.existsSync.mockImplementation((p: string) => p === ENV_FILE); + fsMocks.readFileSync.mockReturnValue(ENV_WITH_SUPABASE); + fetchMock.mockResolvedValue(makeResponse([])); + + await run([]); + const [, opts] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = opts.headers as Record; + expect(headers['Authorization']).toBe('Bearer svc-key'); + expect(headers['apikey']).toBe('svc-key'); + }); + + it('parses .env quoted values correctly (strips single quotes)', async () => { + fsMocks.existsSync.mockImplementation((p: string) => p === ENV_FILE); + fsMocks.readFileSync.mockReturnValue( + "SUPABASE_URL='https://quoted.supabase.co'\nSUPABASE_SERVICE_ROLE_KEY='quoted-key'\n", + ); + fetchMock.mockResolvedValue(makeResponse([])); + + await run([]); + // Fetch should have been called — confirming URL was parsed correctly + expect(fetchMock).toHaveBeenCalled(); + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toContain('https://quoted.supabase.co'); + }); +});