From a0c79bd657510561bab389d11b775d13605b5fc4 Mon Sep 17 00:00:00 2001 From: frostebite Date: Thu, 5 Mar 2026 08:20:31 +0000 Subject: [PATCH] test(orchestrator): expand unit tests for enterprise services Add comprehensive tests for CLI provider (cleanupWorkflow, garbageCollect, listWorkflow, watchWorkflow, stderr forwarding, timeout handling), local cache service (saveLfsCache full path and error handling), git hooks service (husky install, failure logging, edge cases), and LFS agent service (empty storagePaths, validate logging). 73 tests across 4 test files. Co-Authored-By: Claude Opus 4.6 --- .../providers/cli/cli-provider.test.ts | 202 ++++++++++++++++++ .../cache/local-cache-service.test.ts | 32 +++ .../services/hooks/git-hooks-service.test.ts | 52 +++++ .../services/lfs/lfs-agent-service.test.ts | 28 +++ 4 files changed, 314 insertions(+) diff --git a/src/model/orchestrator/providers/cli/cli-provider.test.ts b/src/model/orchestrator/providers/cli/cli-provider.test.ts index 033e4ab9..a734a9a6 100644 --- a/src/model/orchestrator/providers/cli/cli-provider.test.ts +++ b/src/model/orchestrator/providers/cli/cli-provider.test.ts @@ -217,6 +217,208 @@ describe('CliProvider', () => { }); }); + describe('cleanupWorkflow', () => { + it('sends cleanup-workflow command and returns result', async () => { + const child = createMockChildProcess(); + mockSpawn.mockReturnValue(child); + + const provider = new CliProvider('/path/to/exe', {} as any); + const promise = provider.cleanupWorkflow({ editorVersion: '2022.3' } as any, 'main', []); + + child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: 'cleaned' }) + '\n')); + child.emit('close', 0); + + const result = await promise; + expect(result).toBe('cleaned'); + + const parsed = JSON.parse(child.stdin.write.mock.calls[0][0]); + expect(parsed.command).toBe('cleanup-workflow'); + expect(parsed.params.branchName).toBe('main'); + }); + }); + + describe('garbageCollect', () => { + it('sends garbage-collect command with correct params', async () => { + const child = createMockChildProcess(); + mockSpawn.mockReturnValue(child); + + const provider = new CliProvider('/path/to/exe', {} as any); + const promise = provider.garbageCollect('filter*', true, 30, false, true); + + child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, output: '3 items removed' }) + '\n')); + child.emit('close', 0); + + const result = await promise; + expect(result).toBe('3 items removed'); + + const parsed = JSON.parse(child.stdin.write.mock.calls[0][0]); + expect(parsed.command).toBe('garbage-collect'); + expect(parsed.params.filter).toBe('filter*'); + expect(parsed.params.previewOnly).toBe(true); + expect(parsed.params.olderThan).toBe(30); + expect(parsed.params.baseDependencies).toBe(true); + }); + + it('returns empty string when response has no output', async () => { + const child = createMockChildProcess(); + mockSpawn.mockReturnValue(child); + + const provider = new CliProvider('/path/to/exe', {} as any); + const promise = provider.garbageCollect('', false, 7, false, false); + + child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true }) + '\n')); + child.emit('close', 0); + + const result = await promise; + expect(result).toBe(''); + }); + }); + + describe('listWorkflow', () => { + it('returns workflow list from response', async () => { + const child = createMockChildProcess(); + mockSpawn.mockReturnValue(child); + + const provider = new CliProvider('/path/to/exe', {} as any); + const promise = provider.listWorkflow(); + + const workflows = [{ Name: 'wf-1' }]; + child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: workflows }) + '\n')); + child.emit('close', 0); + + const result = await promise; + expect(result).toEqual(workflows); + }); + + it('returns empty array when result is missing', async () => { + const child = createMockChildProcess(); + mockSpawn.mockReturnValue(child); + + const provider = new CliProvider('/path/to/exe', {} as any); + const promise = provider.listWorkflow(); + + child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true }) + '\n')); + child.emit('close', 0); + + const result = await promise; + expect(result).toEqual([]); + }); + }); + + describe('watchWorkflow', () => { + it('forwards streaming output and resolves on success', async () => { + const child = createMockChildProcess(); + mockSpawn.mockReturnValue(child); + + const provider = new CliProvider('/path/to/exe', {} as any); + const promise = provider.watchWorkflow(); + + child.stdout.emit('data', Buffer.from('watching...\nstatus: running\n')); + child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, output: 'completed' }) + '\n')); + child.emit('close', 0); + + const result = await promise; + expect(result).toBe('completed'); + + const parsed = JSON.parse(child.stdin.write.mock.calls[0][0]); + expect(parsed.command).toBe('watch-workflow'); + }); + + it('rejects on watch-workflow failure', async () => { + const child = createMockChildProcess(); + mockSpawn.mockReturnValue(child); + + const provider = new CliProvider('/path/to/exe', {} as any); + const promise = provider.watchWorkflow(); + + child.stdout.emit('data', Buffer.from(JSON.stringify({ success: false, error: 'lost connection' }) + '\n')); + child.emit('close', 1); + + await expect(promise).rejects.toThrow('lost connection'); + }); + + it('rejects on spawn error', async () => { + const child = createMockChildProcess(); + mockSpawn.mockReturnValue(child); + + const provider = new CliProvider('/path/to/exe', {} as any); + const promise = provider.watchWorkflow(); + + child.emit('error', new Error('ENOENT')); + + await expect(promise).rejects.toThrow('failed to spawn executable'); + }); + + it('resolves with collected output when exit code 0 and no JSON', async () => { + const child = createMockChildProcess(); + mockSpawn.mockReturnValue(child); + + const provider = new CliProvider('/path/to/exe', {} as any); + const promise = provider.watchWorkflow(); + + child.stdout.emit('data', Buffer.from('line A\nline B\n')); + child.emit('close', 0); + + const result = await promise; + expect(result).toBe('line A\nline B'); + }); + }); + + describe('stderr forwarding', () => { + it('forwards stderr lines to logger during execute', async () => { + const child = createMockChildProcess(); + mockSpawn.mockReturnValue(child); + + const provider = new CliProvider('/path/to/exe', {} as any); + const promise = provider.listResources(); + + child.stderr.emit('data', Buffer.from('warning: something\n')); + child.stdout.emit('data', Buffer.from(JSON.stringify({ success: true, result: [] }) + '\n')); + child.emit('close', 0); + + await promise; + // stderr content included in error message if process fails + // Here it succeeds, so we just verify no rejection + }); + + it('includes stderr in error message when process fails without JSON', async () => { + const child = createMockChildProcess(); + mockSpawn.mockReturnValue(child); + + const provider = new CliProvider('/path/to/exe', {} as any); + const promise = provider.listResources(); + + child.stderr.emit('data', Buffer.from('fatal: segfault\n')); + child.emit('close', 1); + + await expect(promise).rejects.toThrow('fatal: segfault'); + }); + }); + + describe('timeout handling', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('rejects and kills process when command times out', async () => { + const child = createMockChildProcess(); + mockSpawn.mockReturnValue(child); + + const provider = new CliProvider('/path/to/exe', {} as any); + const promise = provider.listResources(); + + // Advance past the 300s default timeout + jest.advanceTimersByTime(301_000); + + await expect(promise).rejects.toThrow('timed out'); + expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + }); + }); + describe('available providers list', () => { it('includes cli in the available providers', () => { const providers = ProviderLoader.getAvailableProviders(); diff --git a/src/model/orchestrator/services/cache/local-cache-service.test.ts b/src/model/orchestrator/services/cache/local-cache-service.test.ts index 94ec9abc..86481eaf 100644 --- a/src/model/orchestrator/services/cache/local-cache-service.test.ts +++ b/src/model/orchestrator/services/cache/local-cache-service.test.ts @@ -207,6 +207,38 @@ describe('LocalCacheService', () => { await LocalCacheService.saveLfsCache('/repo', '/cache', 'key1'); expect(mockFs.mkdirSync).not.toHaveBeenCalled(); }); + + it('should create cache directory and save tar when lfs has content', async () => { + (mockFs.existsSync as jest.Mock).mockReturnValue(true); + (mockFs.readdirSync as jest.Mock).mockImplementation((dirPath: string) => { + if (String(dirPath).includes('lfs') && !String(dirPath).includes('cache')) { + return ['objects', 'tmp']; + } + return []; + }); + (mockFs.statSync as jest.Mock).mockReturnValue({ mtimeMs: Date.now() }); + (mockFs.mkdirSync as jest.Mock).mockReturnValue(undefined); + + const { OrchestratorSystem } = require('../core/orchestrator-system'); + OrchestratorSystem.Run.mockResolvedValue(''); + + await LocalCacheService.saveLfsCache('/repo', '/cache', 'key1'); + expect(mockFs.mkdirSync).toHaveBeenCalledWith(path.join('/cache', 'key1', 'lfs'), { recursive: true }); + expect(OrchestratorSystem.Run).toHaveBeenCalledWith( + expect.stringContaining('tar -cf'), + true, + ); + }); + + it('should handle save errors gracefully', async () => { + (mockFs.existsSync as jest.Mock).mockReturnValue(true); + (mockFs.readdirSync as jest.Mock).mockImplementation(() => { + throw new Error('Disk full'); + }); + + // Should not throw + await LocalCacheService.saveLfsCache('/repo', '/cache', 'key1'); + }); }); describe('garbageCollect', () => { diff --git a/src/model/orchestrator/services/hooks/git-hooks-service.test.ts b/src/model/orchestrator/services/hooks/git-hooks-service.test.ts index 2899cb0b..d3b3e7d2 100644 --- a/src/model/orchestrator/services/hooks/git-hooks-service.test.ts +++ b/src/model/orchestrator/services/hooks/git-hooks-service.test.ts @@ -106,6 +106,18 @@ describe('GitHooksService', () => { expect(OrchestratorSystem.Run).toHaveBeenCalledWith(`cd "/repo" && npx lefthook install`, true); }); + it('should run npx husky install when husky is detected', async () => { + (mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => { + return String(filePath).endsWith('.husky'); + }); + + const { OrchestratorSystem } = require('../core/orchestrator-system'); + + await GitHooksService.installHooks('/repo'); + + expect(OrchestratorSystem.Run).toHaveBeenCalledWith(`cd "/repo" && npx husky install`, true); + }); + it('should log and return when no framework is detected', async () => { (mockFs.existsSync as jest.Mock).mockReturnValue(false); @@ -117,5 +129,45 @@ describe('GitHooksService', () => { expect(OrchestratorSystem.Run).not.toHaveBeenCalled(); expect(OrchestratorLogger.log).toHaveBeenCalledWith(expect.stringContaining('No hook framework detected')); }); + + it('should log warning on installation failure', async () => { + (mockFs.existsSync as jest.Mock).mockImplementation((filePath: string) => { + return String(filePath).includes('lefthook.yml') && !String(filePath).startsWith('.'); + }); + + const { OrchestratorSystem } = require('../core/orchestrator-system'); + const OrchestratorLogger = require('../core/orchestrator-logger').default; + OrchestratorSystem.Run.mockRejectedValue(new Error('npx not found')); + + await GitHooksService.installHooks('/repo'); + + expect(OrchestratorLogger.logWarning).toHaveBeenCalledWith( + expect.stringContaining('Hook installation failed'), + ); + }); + }); + + describe('disableHooks', () => { + it('should log warning on failure to disable hooks', async () => { + (mockFs.mkdirSync as jest.Mock).mockReturnValue(undefined); + + const { OrchestratorSystem } = require('../core/orchestrator-system'); + const OrchestratorLogger = require('../core/orchestrator-logger').default; + OrchestratorSystem.Run.mockRejectedValue(new Error('git config failed')); + + await GitHooksService.disableHooks('/repo'); + + expect(OrchestratorLogger.logWarning).toHaveBeenCalledWith( + expect.stringContaining('Failed to disable hooks'), + ); + }); + }); + + describe('configureSkipList edge cases', () => { + it('should handle single hook in skip list', () => { + const result = GitHooksService.configureSkipList(['commit-msg']); + expect(result.LEFTHOOK_EXCLUDE).toBe('commit-msg'); + expect(result.HUSKY).toBe('0'); + }); }); }); diff --git a/src/model/orchestrator/services/lfs/lfs-agent-service.test.ts b/src/model/orchestrator/services/lfs/lfs-agent-service.test.ts index 2fc495cc..bb8888ff 100644 --- a/src/model/orchestrator/services/lfs/lfs-agent-service.test.ts +++ b/src/model/orchestrator/services/lfs/lfs-agent-service.test.ts @@ -80,6 +80,23 @@ describe('LfsAgentService', () => { }); }); + describe('configure with empty storagePaths', () => { + it('should not set LFS_STORAGE_PATHS when storagePaths is empty', async () => { + (mockFs.existsSync as jest.Mock).mockReturnValue(true); + + const originalValue = process.env.LFS_STORAGE_PATHS; + delete process.env.LFS_STORAGE_PATHS; + + await LfsAgentService.configure('/usr/local/bin/agent', '', [], '/repo'); + + expect(process.env.LFS_STORAGE_PATHS).toBeUndefined(); + + if (originalValue !== undefined) { + process.env.LFS_STORAGE_PATHS = originalValue; + } + }); + }); + describe('validate', () => { it('should return true when agent executable exists', async () => { (mockFs.existsSync as jest.Mock).mockReturnValue(true); @@ -92,5 +109,16 @@ describe('LfsAgentService', () => { const result = await LfsAgentService.validate('/nonexistent/agent'); expect(result).toBe(false); }); + + it('should log warning when agent does not exist', async () => { + (mockFs.existsSync as jest.Mock).mockReturnValue(false); + const OrchestratorLogger = require('../core/orchestrator-logger').default; + + await LfsAgentService.validate('/nonexistent/agent'); + + expect(OrchestratorLogger.logWarning).toHaveBeenCalledWith( + expect.stringContaining('Agent executable not found'), + ); + }); }); });