From a201daf0979b52d067759d5b2a1964901b68d473 Mon Sep 17 00:00:00 2001 From: Greg von Nessi Date: Tue, 17 Feb 2026 00:03:56 +0000 Subject: [PATCH 1/2] Add CLI command handler tests for hook, ingest, and archive Add 35 new tests across 3 files: - hook.test.ts: session-start, pre-compact, session-end, claudemd-generator dispatch, arg handling, error paths, JSON output format - ingest.test.ts: ingest and batch-ingest arg parsing and error paths - archive.test.ts: export/import flag parsing, encryption password handling, dry-run, merge, project filtering, output formatting --- test/cli/commands/archive.test.ts | 286 ++++++++++++++++++++++++++++++ test/cli/commands/hook.test.ts | 185 +++++++++++++++++++ test/cli/commands/ingest.test.ts | 79 +++++++++ 3 files changed, 550 insertions(+) create mode 100644 test/cli/commands/archive.test.ts create mode 100644 test/cli/commands/hook.test.ts create mode 100644 test/cli/commands/ingest.test.ts diff --git a/test/cli/commands/archive.test.ts b/test/cli/commands/archive.test.ts new file mode 100644 index 0000000..8db5c24 --- /dev/null +++ b/test/cli/commands/archive.test.ts @@ -0,0 +1,286 @@ +/** + * Tests for the export and import CLI command handlers. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock dependencies before importing the commands +vi.mock('../../../src/storage/archive.js', () => ({ + exportArchive: vi.fn(), + importArchive: vi.fn(), +})); + +vi.mock('../../../src/cli/utils.js', () => ({ + promptPassword: vi.fn(), + isEncryptedArchive: vi.fn(), +})); + +import { exportCommand, importCommand } from '../../../src/cli/commands/archive.js'; +import { exportArchive, importArchive } from '../../../src/storage/archive.js'; +import { promptPassword, isEncryptedArchive } from '../../../src/cli/utils.js'; + +const mockExportArchive = vi.mocked(exportArchive); +const mockImportArchive = vi.mocked(importArchive); +const mockPromptPassword = vi.mocked(promptPassword); +const mockIsEncryptedArchive = vi.mocked(isEncryptedArchive); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); +}); + +const sampleExportResult = { + chunkCount: 100, + edgeCount: 50, + clusterCount: 10, + vectorCount: 100, + fileSize: 2048, + compressed: true, + encrypted: true, +}; + +const sampleImportResult = { + chunkCount: 100, + edgeCount: 50, + clusterCount: 10, + vectorCount: 100, + dryRun: false, +}; + +describe('exportCommand', () => { + it('has correct name and usage', () => { + expect(exportCommand.name).toBe('export'); + expect(exportCommand.usage).toContain('--output'); + expect(exportCommand.usage).toContain('--no-encrypt'); + }); + + it('exports unencrypted with --no-encrypt flag', async () => { + mockExportArchive.mockResolvedValue(sampleExportResult); + + await exportCommand.handler(['--no-encrypt', '--output', '/tmp/backup.causantic']); + + expect(mockExportArchive).toHaveBeenCalledWith({ + outputPath: '/tmp/backup.causantic', + password: undefined, + projects: undefined, + redactPaths: false, + redactCode: false, + noVectors: false, + }); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('100 chunks')); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('50 edges')); + }); + + it('uses default output path when --output is not specified', async () => { + mockExportArchive.mockResolvedValue(sampleExportResult); + + await exportCommand.handler(['--no-encrypt']); + + expect(mockExportArchive).toHaveBeenCalledWith( + expect.objectContaining({ outputPath: 'causantic-backup.causantic' }), + ); + }); + + it('passes --projects flag correctly', async () => { + mockExportArchive.mockResolvedValue(sampleExportResult); + + await exportCommand.handler(['--no-encrypt', '--projects', 'proj-a,proj-b']); + + expect(mockExportArchive).toHaveBeenCalledWith( + expect.objectContaining({ projects: ['proj-a', 'proj-b'] }), + ); + }); + + it('passes --redact-paths and --redact-code flags', async () => { + mockExportArchive.mockResolvedValue(sampleExportResult); + + await exportCommand.handler(['--no-encrypt', '--redact-paths', '--redact-code']); + + expect(mockExportArchive).toHaveBeenCalledWith( + expect.objectContaining({ redactPaths: true, redactCode: true }), + ); + }); + + it('passes --no-vectors flag', async () => { + mockExportArchive.mockResolvedValue(sampleExportResult); + + await exportCommand.handler(['--no-encrypt', '--no-vectors']); + + expect(mockExportArchive).toHaveBeenCalledWith( + expect.objectContaining({ noVectors: true }), + ); + }); + + it('uses password from environment variable', async () => { + const originalEnv = process.env.CAUSANTIC_EXPORT_PASSWORD; + process.env.CAUSANTIC_EXPORT_PASSWORD = 'env-password'; + + try { + mockExportArchive.mockResolvedValue(sampleExportResult); + + await exportCommand.handler(['--output', '/tmp/backup.causantic']); + + expect(mockExportArchive).toHaveBeenCalledWith( + expect.objectContaining({ password: 'env-password' }), + ); + expect(mockPromptPassword).not.toHaveBeenCalled(); + } finally { + if (originalEnv === undefined) { + delete process.env.CAUSANTIC_EXPORT_PASSWORD; + } else { + process.env.CAUSANTIC_EXPORT_PASSWORD = originalEnv; + } + } + }); + + it('exits with code 2 when no password and not TTY', async () => { + const originalEnv = process.env.CAUSANTIC_EXPORT_PASSWORD; + delete process.env.CAUSANTIC_EXPORT_PASSWORD; + const originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + try { + await exportCommand.handler([]); + + expect(console.error).toHaveBeenCalledWith( + 'Error: No password provided for encrypted export.', + ); + expect(process.exit).toHaveBeenCalledWith(2); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + if (originalEnv !== undefined) { + process.env.CAUSANTIC_EXPORT_PASSWORD = originalEnv; + } + } + }); + + it('shows compressed and encrypted in output', async () => { + mockExportArchive.mockResolvedValue({ + ...sampleExportResult, + compressed: true, + encrypted: true, + }); + + await exportCommand.handler(['--no-encrypt']); + + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('compressed')); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('encrypted')); + }); +}); + +describe('importCommand', () => { + it('has correct name and usage', () => { + expect(importCommand.name).toBe('import'); + expect(importCommand.usage).toContain('--merge'); + expect(importCommand.usage).toContain('--dry-run'); + }); + + it('imports unencrypted archive', async () => { + mockIsEncryptedArchive.mockResolvedValue(false); + mockImportArchive.mockResolvedValue(sampleImportResult); + + await importCommand.handler(['/tmp/backup.causantic']); + + expect(mockIsEncryptedArchive).toHaveBeenCalledWith('/tmp/backup.causantic'); + expect(mockImportArchive).toHaveBeenCalledWith({ + inputPath: '/tmp/backup.causantic', + password: undefined, + merge: false, + dryRun: false, + }); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Imported:')); + }); + + it('passes --merge flag', async () => { + mockIsEncryptedArchive.mockResolvedValue(false); + mockImportArchive.mockResolvedValue(sampleImportResult); + + await importCommand.handler(['/tmp/backup.causantic', '--merge']); + + expect(mockImportArchive).toHaveBeenCalledWith(expect.objectContaining({ merge: true })); + }); + + it('passes --dry-run flag', async () => { + mockIsEncryptedArchive.mockResolvedValue(false); + mockImportArchive.mockResolvedValue({ ...sampleImportResult, dryRun: true }); + + await importCommand.handler(['/tmp/backup.causantic', '--dry-run']); + + expect(mockImportArchive).toHaveBeenCalledWith(expect.objectContaining({ dryRun: true })); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('Dry run')); + }); + + it('exits with code 2 when no file path provided', async () => { + await importCommand.handler([]); + + expect(console.error).toHaveBeenCalledWith('Error: File path required'); + expect(process.exit).toHaveBeenCalledWith(2); + }); + + it('uses password from environment for encrypted archive', async () => { + const originalEnv = process.env.CAUSANTIC_EXPORT_PASSWORD; + process.env.CAUSANTIC_EXPORT_PASSWORD = 'env-password'; + + try { + mockIsEncryptedArchive.mockResolvedValue(true); + mockImportArchive.mockResolvedValue(sampleImportResult); + + await importCommand.handler(['/tmp/backup.causantic']); + + expect(mockImportArchive).toHaveBeenCalledWith( + expect.objectContaining({ password: 'env-password' }), + ); + } finally { + if (originalEnv === undefined) { + delete process.env.CAUSANTIC_EXPORT_PASSWORD; + } else { + process.env.CAUSANTIC_EXPORT_PASSWORD = originalEnv; + } + } + }); + + it('exits with code 2 when encrypted and no password and not TTY', async () => { + const originalEnv = process.env.CAUSANTIC_EXPORT_PASSWORD; + delete process.env.CAUSANTIC_EXPORT_PASSWORD; + const originalIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + try { + mockIsEncryptedArchive.mockResolvedValue(true); + + await importCommand.handler(['/tmp/backup.causantic']); + + expect(console.error).toHaveBeenCalledWith( + 'Error: Archive is encrypted. Set CAUSANTIC_EXPORT_PASSWORD environment variable.', + ); + expect(process.exit).toHaveBeenCalledWith(2); + } finally { + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + if (originalEnv !== undefined) { + process.env.CAUSANTIC_EXPORT_PASSWORD = originalEnv; + } + } + }); + + it('finds file path when flags come before it', async () => { + mockIsEncryptedArchive.mockResolvedValue(false); + mockImportArchive.mockResolvedValue(sampleImportResult); + + await importCommand.handler(['--merge', '/tmp/backup.causantic']); + + expect(mockImportArchive).toHaveBeenCalledWith( + expect.objectContaining({ + inputPath: '/tmp/backup.causantic', + merge: true, + }), + ); + }); +}); diff --git a/test/cli/commands/hook.test.ts b/test/cli/commands/hook.test.ts new file mode 100644 index 0000000..b9f1ee0 --- /dev/null +++ b/test/cli/commands/hook.test.ts @@ -0,0 +1,185 @@ +/** + * Tests for the hook CLI command handler. + * + * Note: readStdin() returns {} in test context because process.stdin.isTTY is truthy. + * We test hook dispatch via args instead of stdin input. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock all hook handlers before importing the command +vi.mock('../../../src/hooks/session-start.js', () => ({ + handleSessionStart: vi.fn(), +})); + +vi.mock('../../../src/hooks/pre-compact.js', () => ({ + handlePreCompact: vi.fn(), +})); + +vi.mock('../../../src/hooks/session-end.js', () => ({ + handleSessionEnd: vi.fn(), +})); + +vi.mock('../../../src/hooks/claudemd-generator.js', () => ({ + updateClaudeMd: vi.fn(), +})); + +import { hookCommand } from '../../../src/cli/commands/hook.js'; +import { handleSessionStart } from '../../../src/hooks/session-start.js'; +import { handlePreCompact } from '../../../src/hooks/pre-compact.js'; +import { handleSessionEnd } from '../../../src/hooks/session-end.js'; +import { updateClaudeMd } from '../../../src/hooks/claudemd-generator.js'; + +const mockHandleSessionStart = vi.mocked(handleSessionStart); +const mockHandlePreCompact = vi.mocked(handlePreCompact); +const mockHandleSessionEnd = vi.mocked(handleSessionEnd); +const mockUpdateClaudeMd = vi.mocked(updateClaudeMd); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); +}); + +describe('hookCommand', () => { + it('has correct name and usage', () => { + expect(hookCommand.name).toBe('hook'); + expect(hookCommand.usage).toContain('session-start'); + expect(hookCommand.usage).toContain('pre-compact'); + expect(hookCommand.usage).toContain('session-end'); + expect(hookCommand.usage).toContain('claudemd-generator'); + }); + + describe('session-start', () => { + it('calls handleSessionStart with basename of arg path', async () => { + mockHandleSessionStart.mockResolvedValue({ + summary: 'Test summary', + recentChunks: [], + predictions: [], + }); + + await hookCommand.handler(['session-start', '/projects/my-app']); + + expect(mockHandleSessionStart).toHaveBeenCalledWith('my-app', {}); + }); + + it('outputs structured JSON for Claude Code hook system', async () => { + mockHandleSessionStart.mockResolvedValue({ + summary: 'Memory context here', + recentChunks: [], + predictions: [], + }); + + await hookCommand.handler(['session-start', '/projects/my-app']); + + const logCalls = (console.log as ReturnType).mock.calls; + const jsonCall = logCalls.find((c: string[]) => { + try { + JSON.parse(c[0]); + return true; + } catch { + return false; + } + }); + expect(jsonCall).toBeDefined(); + const output = JSON.parse(jsonCall![0]); + expect(output.hookSpecificOutput.hookEventName).toBe('SessionStart'); + expect(output.hookSpecificOutput.additionalContext).toContain('Memory context here'); + }); + + it('falls back to process.cwd basename when no arg provided', async () => { + mockHandleSessionStart.mockResolvedValue({ + summary: '', + recentChunks: [], + predictions: [], + }); + + await hookCommand.handler(['session-start']); + + // Should use basename of cwd as project slug + expect(mockHandleSessionStart).toHaveBeenCalledWith(expect.any(String), {}); + }); + }); + + describe('pre-compact', () => { + it('calls handlePreCompact with arg path', async () => { + mockHandlePreCompact.mockResolvedValue(undefined); + + await hookCommand.handler(['pre-compact', '/tmp/session.jsonl']); + + expect(mockHandlePreCompact).toHaveBeenCalledWith('/tmp/session.jsonl', { + project: expect.any(String), + sessionId: undefined, + }); + expect(console.log).toHaveBeenCalledWith('Pre-compact hook executed.'); + }); + + it('exits with code 2 when no path provided', async () => { + await hookCommand.handler(['pre-compact']); + + expect(console.error).toHaveBeenCalledWith( + 'Error: No transcript_path in stdin and no path argument provided.', + ); + expect(process.exit).toHaveBeenCalledWith(2); + }); + }); + + describe('session-end', () => { + it('calls handleSessionEnd with arg path', async () => { + mockHandleSessionEnd.mockResolvedValue(undefined); + + await hookCommand.handler(['session-end', '/tmp/session.jsonl']); + + expect(mockHandleSessionEnd).toHaveBeenCalledWith('/tmp/session.jsonl', { + project: expect.any(String), + sessionId: undefined, + }); + expect(console.log).toHaveBeenCalledWith('Session-end hook executed.'); + }); + + it('exits with code 2 when no path provided', async () => { + await hookCommand.handler(['session-end']); + + expect(console.error).toHaveBeenCalledWith( + 'Error: No transcript_path in stdin and no path argument provided.', + ); + expect(process.exit).toHaveBeenCalledWith(2); + }); + }); + + describe('claudemd-generator', () => { + it('calls updateClaudeMd with arg path', async () => { + mockUpdateClaudeMd.mockResolvedValue(undefined); + + await hookCommand.handler(['claudemd-generator', '/projects/my-app']); + + expect(mockUpdateClaudeMd).toHaveBeenCalledWith('/projects/my-app', {}); + expect(console.log).toHaveBeenCalledWith('CLAUDE.md updated.'); + }); + + it('falls back to process.cwd when no arg provided', async () => { + mockUpdateClaudeMd.mockResolvedValue(undefined); + + await hookCommand.handler(['claudemd-generator']); + + expect(mockUpdateClaudeMd).toHaveBeenCalledWith(expect.any(String), {}); + }); + }); + + describe('unknown hook', () => { + it('prints error and exits with code 2', async () => { + await hookCommand.handler(['nonexistent']); + + expect(console.error).toHaveBeenCalledWith('Error: Unknown hook'); + expect(process.exit).toHaveBeenCalledWith(2); + }); + + it('handles no hook name provided', async () => { + await hookCommand.handler([]); + + expect(console.error).toHaveBeenCalledWith('Error: Unknown hook'); + expect(process.exit).toHaveBeenCalledWith(2); + }); + }); +}); diff --git a/test/cli/commands/ingest.test.ts b/test/cli/commands/ingest.test.ts new file mode 100644 index 0000000..1d8bba8 --- /dev/null +++ b/test/cli/commands/ingest.test.ts @@ -0,0 +1,79 @@ +/** + * Tests for the ingest and batch-ingest CLI command handlers. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock dependencies before importing the commands +vi.mock('../../../src/ingest/ingest-session.js', () => ({ + ingestSession: vi.fn(), +})); + +vi.mock('../../../src/ingest/batch-ingest.js', () => ({ + batchIngestDirectory: vi.fn(), +})); + +import { ingestCommand, batchIngestCommand } from '../../../src/cli/commands/ingest.js'; +import { ingestSession } from '../../../src/ingest/ingest-session.js'; +import { batchIngestDirectory } from '../../../src/ingest/batch-ingest.js'; + +const mockIngestSession = vi.mocked(ingestSession); +const mockBatchIngestDirectory = vi.mocked(batchIngestDirectory); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, 'log').mockImplementation(() => {}); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); +}); + +describe('ingestCommand', () => { + it('has correct name and usage', () => { + expect(ingestCommand.name).toBe('ingest'); + expect(ingestCommand.usage).toContain('path'); + }); + + it('calls ingestSession with the provided path', async () => { + mockIngestSession.mockResolvedValue(undefined as never); + + await ingestCommand.handler(['/tmp/session.jsonl']); + + expect(mockIngestSession).toHaveBeenCalledWith('/tmp/session.jsonl'); + expect(console.log).toHaveBeenCalledWith('Ingestion complete.'); + }); + + it('exits with code 2 when no path provided', async () => { + await ingestCommand.handler([]); + + expect(console.error).toHaveBeenCalledWith('Error: Path required'); + expect(process.exit).toHaveBeenCalledWith(2); + }); +}); + +describe('batchIngestCommand', () => { + it('has correct name and usage', () => { + expect(batchIngestCommand.name).toBe('batch-ingest'); + expect(batchIngestCommand.usage).toContain('directory'); + }); + + it('calls batchIngestDirectory with the provided directory', async () => { + mockBatchIngestDirectory.mockResolvedValue({ + successCount: 5, + failureCount: 0, + skippedCount: 0, + sessions: [], + } as never); + + await batchIngestCommand.handler(['/tmp/sessions']); + + expect(mockBatchIngestDirectory).toHaveBeenCalledWith('/tmp/sessions', {}); + expect(console.log).toHaveBeenCalledWith('Batch ingestion complete: 5 sessions processed.'); + }); + + it('exits with code 2 when no directory provided', async () => { + await batchIngestCommand.handler([]); + + expect(console.error).toHaveBeenCalledWith('Error: Directory required'); + expect(process.exit).toHaveBeenCalledWith(2); + }); +}); From d855fea13f8238adc4ff0a8c3bddf570c6c397f4 Mon Sep 17 00:00:00 2001 From: Greg von Nessi Date: Tue, 17 Feb 2026 00:08:22 +0000 Subject: [PATCH 2/2] Format archive.test.ts with Prettier --- test/cli/commands/archive.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/cli/commands/archive.test.ts b/test/cli/commands/archive.test.ts index 8db5c24..6cc1b04 100644 --- a/test/cli/commands/archive.test.ts +++ b/test/cli/commands/archive.test.ts @@ -108,9 +108,7 @@ describe('exportCommand', () => { await exportCommand.handler(['--no-encrypt', '--no-vectors']); - expect(mockExportArchive).toHaveBeenCalledWith( - expect.objectContaining({ noVectors: true }), - ); + expect(mockExportArchive).toHaveBeenCalledWith(expect.objectContaining({ noVectors: true })); }); it('uses password from environment variable', async () => {