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'), + ); + }); }); });