import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { EventBus, DomainEvent } from '../events/types.js'; import type { ProjectRepository } from '../db/repositories/project-repository.js'; import { PREVIEW_LABELS, COMPOSE_PROJECT_PREFIX } from './types.js'; // Mock all external dependencies before imports vi.mock('./docker-client.js', () => ({ isDockerAvailable: vi.fn(), composeUp: vi.fn(), composeDown: vi.fn(), composePs: vi.fn(), listPreviewProjects: vi.fn(), getContainerLabels: vi.fn(), getPreviewPorts: vi.fn(), })); vi.mock('./config-reader.js', () => ({ discoverConfig: vi.fn(), })); vi.mock('./port-allocator.js', () => ({ allocatePort: vi.fn(), })); vi.mock('./health-checker.js', () => ({ waitForHealthy: vi.fn(), })); vi.mock('node:fs/promises', () => ({ mkdir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), rm: vi.fn().mockResolvedValue(undefined), })); vi.mock('nanoid', () => ({ nanoid: vi.fn(() => 'abc123test'), })); import { PreviewManager } from './manager.js'; import { isDockerAvailable, composeUp, composeDown, composePs, listPreviewProjects, getContainerLabels, } from './docker-client.js'; import { discoverConfig } from './config-reader.js'; import { allocatePort } from './port-allocator.js'; import { waitForHealthy } from './health-checker.js'; import { mkdir, writeFile, rm } from 'node:fs/promises'; import type { PreviewConfig } from './types.js'; const mockIsDockerAvailable = vi.mocked(isDockerAvailable); const mockComposeUp = vi.mocked(composeUp); const mockComposeDown = vi.mocked(composeDown); const mockComposePs = vi.mocked(composePs); const mockListPreviewProjects = vi.mocked(listPreviewProjects); const mockGetContainerLabels = vi.mocked(getContainerLabels); const mockDiscoverConfig = vi.mocked(discoverConfig); const mockAllocatePort = vi.mocked(allocatePort); const mockWaitForHealthy = vi.mocked(waitForHealthy); const mockMkdir = vi.mocked(mkdir); const mockWriteFile = vi.mocked(writeFile); const mockRm = vi.mocked(rm); // Collect emitted events function createMockEventBus(): EventBus & { emitted: DomainEvent[] } { const emitted: DomainEvent[] = []; return { emitted, emit: vi.fn((event: DomainEvent) => { emitted.push(event); }), on: vi.fn(), off: vi.fn(), once: vi.fn(), }; } function createMockProjectRepo(project = { id: 'proj-1', name: 'test-project', url: 'https://github.com/test/repo.git', defaultBranch: 'main', createdAt: new Date(), updatedAt: new Date(), }): ProjectRepository { return { findById: vi.fn().mockResolvedValue(project), findByName: vi.fn(), findByUrl: vi.fn(), findAll: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), setInitiativeProjects: vi.fn(), getInitiativeProjects: vi.fn(), } as unknown as ProjectRepository; } const WORKSPACE_ROOT = '/tmp/test-workspace'; const SIMPLE_CONFIG: PreviewConfig = { version: 1, services: { app: { name: 'app', build: '.', port: 3000, healthcheck: { path: '/health' }, }, }, }; describe('PreviewManager', () => { let manager: PreviewManager; let eventBus: EventBus & { emitted: DomainEvent[] }; let projectRepo: ProjectRepository; beforeEach(() => { vi.clearAllMocks(); eventBus = createMockEventBus(); projectRepo = createMockProjectRepo(); manager = new PreviewManager(projectRepo, eventBus, WORKSPACE_ROOT); }); describe('start', () => { it('completes the full start lifecycle for a healthy service', async () => { mockIsDockerAvailable.mockResolvedValue(true); mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG); mockAllocatePort.mockResolvedValue(9100); mockComposeUp.mockResolvedValue(undefined); mockWaitForHealthy.mockResolvedValue([{ name: 'app', healthy: true }]); mockComposePs.mockResolvedValue([ { name: 'app', state: 'running', health: 'healthy' }, ]); const result = await manager.start({ initiativeId: 'init-1', projectId: 'proj-1', branch: 'feature-x', }); // Verify returned status expect(result.id).toBe('abc123test'); expect(result.projectName).toBe('cw-preview-abc123test'); expect(result.initiativeId).toBe('init-1'); expect(result.projectId).toBe('proj-1'); expect(result.branch).toBe('feature-x'); expect(result.port).toBe(9100); expect(result.status).toBe('running'); expect(result.services).toHaveLength(1); // Verify Docker was called expect(mockIsDockerAvailable).toHaveBeenCalledOnce(); expect(mockComposeUp).toHaveBeenCalledWith( expect.stringContaining('.cw-previews/abc123test/docker-compose.yml'), 'cw-preview-abc123test', ); // Verify compose artifacts were written expect(mockMkdir).toHaveBeenCalledWith( expect.stringContaining('.cw-previews/abc123test'), { recursive: true }, ); expect(mockWriteFile).toHaveBeenCalledTimes(2); // compose + Caddyfile // Verify events: building then ready expect(eventBus.emitted).toHaveLength(2); expect(eventBus.emitted[0].type).toBe('preview:building'); expect(eventBus.emitted[0].payload).toEqual( expect.objectContaining({ previewId: 'abc123test', initiativeId: 'init-1', branch: 'feature-x', port: 9100, }), ); expect(eventBus.emitted[1].type).toBe('preview:ready'); expect(eventBus.emitted[1].payload).toEqual( expect.objectContaining({ previewId: 'abc123test', url: 'http://localhost:9100', }), ); }); it('includes phaseId when provided', async () => { mockIsDockerAvailable.mockResolvedValue(true); mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG); mockAllocatePort.mockResolvedValue(9100); mockComposeUp.mockResolvedValue(undefined); mockWaitForHealthy.mockResolvedValue([]); mockComposePs.mockResolvedValue([]); const result = await manager.start({ initiativeId: 'init-1', phaseId: 'phase-1', projectId: 'proj-1', branch: 'feature-x', }); expect(result.phaseId).toBe('phase-1'); }); it('throws when Docker is not available', async () => { mockIsDockerAvailable.mockResolvedValue(false); await expect( manager.start({ initiativeId: 'init-1', projectId: 'proj-1', branch: 'main', }), ).rejects.toThrow('Docker is not available'); // No events should be emitted expect(eventBus.emitted).toHaveLength(0); }); it('throws when project is not found', async () => { mockIsDockerAvailable.mockResolvedValue(true); projectRepo = createMockProjectRepo(); (projectRepo.findById as ReturnType).mockResolvedValue(null); manager = new PreviewManager(projectRepo, eventBus, WORKSPACE_ROOT); await expect( manager.start({ initiativeId: 'init-1', projectId: 'nonexistent', branch: 'main', }), ).rejects.toThrow("Project 'nonexistent' not found"); }); it('emits preview:failed and cleans up when compose up fails', async () => { mockIsDockerAvailable.mockResolvedValue(true); mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG); mockAllocatePort.mockResolvedValue(9100); mockComposeUp.mockRejectedValue(new Error('build error: Dockerfile not found')); mockComposeDown.mockResolvedValue(undefined); await expect( manager.start({ initiativeId: 'init-1', projectId: 'proj-1', branch: 'main', }), ).rejects.toThrow('Preview build failed: build error: Dockerfile not found'); // Events: building, then failed expect(eventBus.emitted).toHaveLength(2); expect(eventBus.emitted[0].type).toBe('preview:building'); expect(eventBus.emitted[1].type).toBe('preview:failed'); expect((eventBus.emitted[1].payload as Record).error).toBe( 'build error: Dockerfile not found', ); // Cleanup was attempted expect(mockComposeDown).toHaveBeenCalledWith('cw-preview-abc123test'); expect(mockRm).toHaveBeenCalledWith( expect.stringContaining('.cw-previews/abc123test'), { recursive: true, force: true }, ); }); it('emits preview:failed and cleans up when health checks fail', async () => { mockIsDockerAvailable.mockResolvedValue(true); mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG); mockAllocatePort.mockResolvedValue(9101); mockComposeUp.mockResolvedValue(undefined); mockWaitForHealthy.mockResolvedValue([ { name: 'app', healthy: false, error: 'health check timed out' }, ]); mockComposeDown.mockResolvedValue(undefined); await expect( manager.start({ initiativeId: 'init-1', projectId: 'proj-1', branch: 'main', }), ).rejects.toThrow('Preview health checks failed for services: app'); // Events: building, then failed expect(eventBus.emitted).toHaveLength(2); expect(eventBus.emitted[1].type).toBe('preview:failed'); expect((eventBus.emitted[1].payload as Record).error).toBe( 'Health checks failed for: app', ); // Cleanup expect(mockComposeDown).toHaveBeenCalled(); expect(mockRm).toHaveBeenCalled(); }); it('succeeds when no healthcheck endpoints are configured', async () => { const noHealthConfig: PreviewConfig = { version: 1, services: { app: { name: 'app', build: '.', port: 3000 } }, }; mockIsDockerAvailable.mockResolvedValue(true); mockDiscoverConfig.mockResolvedValue(noHealthConfig); mockAllocatePort.mockResolvedValue(9100); mockComposeUp.mockResolvedValue(undefined); mockWaitForHealthy.mockResolvedValue([]); // no health endpoints → empty results mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'none' }]); const result = await manager.start({ initiativeId: 'init-1', projectId: 'proj-1', branch: 'main', }); expect(result.status).toBe('running'); // Should succeed — empty health results means allHealthy is vacuously true expect(eventBus.emitted[1].type).toBe('preview:ready'); }); }); describe('stop', () => { it('stops compose, cleans up artifacts, and emits preview:stopped', async () => { mockGetContainerLabels.mockResolvedValue({ [PREVIEW_LABELS.preview]: 'true', [PREVIEW_LABELS.initiativeId]: 'init-1', }); mockComposeDown.mockResolvedValue(undefined); await manager.stop('abc123test'); expect(mockComposeDown).toHaveBeenCalledWith('cw-preview-abc123test'); expect(mockRm).toHaveBeenCalledWith( `${WORKSPACE_ROOT}/.cw-previews/abc123test`, { recursive: true, force: true }, ); expect(eventBus.emitted).toHaveLength(1); expect(eventBus.emitted[0].type).toBe('preview:stopped'); expect(eventBus.emitted[0].payload).toEqual( expect.objectContaining({ previewId: 'abc123test', initiativeId: 'init-1', }), ); }); it('emits empty initiativeId when labels are missing', async () => { mockGetContainerLabels.mockResolvedValue({}); mockComposeDown.mockResolvedValue(undefined); await manager.stop('xyz'); expect(eventBus.emitted).toHaveLength(1); expect((eventBus.emitted[0].payload as Record).initiativeId).toBe(''); }); }); describe('list', () => { it('returns all active previews reconstructed from Docker state', async () => { mockListPreviewProjects.mockResolvedValue([ { Name: 'cw-preview-aaa', Status: 'running(2)', ConfigFiles: '/tmp/compose.yml' }, { Name: 'cw-preview-bbb', Status: 'running(1)', ConfigFiles: '/tmp/compose2.yml' }, ]); mockGetContainerLabels .mockResolvedValueOnce({ [PREVIEW_LABELS.preview]: 'true', [PREVIEW_LABELS.initiativeId]: 'init-1', [PREVIEW_LABELS.projectId]: 'proj-1', [PREVIEW_LABELS.branch]: 'feat-a', [PREVIEW_LABELS.port]: '9100', [PREVIEW_LABELS.previewId]: 'aaa', }) .mockResolvedValueOnce({ [PREVIEW_LABELS.preview]: 'true', [PREVIEW_LABELS.initiativeId]: 'init-2', [PREVIEW_LABELS.projectId]: 'proj-2', [PREVIEW_LABELS.branch]: 'feat-b', [PREVIEW_LABELS.port]: '9101', [PREVIEW_LABELS.previewId]: 'bbb', }); mockComposePs .mockResolvedValueOnce([{ name: 'app', state: 'running', health: 'healthy' }]) .mockResolvedValueOnce([{ name: 'api', state: 'running', health: 'none' }]); const previews = await manager.list(); expect(previews).toHaveLength(2); expect(previews[0].id).toBe('aaa'); expect(previews[0].port).toBe(9100); expect(previews[0].services).toHaveLength(1); expect(previews[1].id).toBe('bbb'); expect(previews[1].port).toBe(9101); }); it('filters by initiativeId when provided', async () => { mockListPreviewProjects.mockResolvedValue([ { Name: 'cw-preview-aaa', Status: 'running(2)', ConfigFiles: '' }, { Name: 'cw-preview-bbb', Status: 'running(1)', ConfigFiles: '' }, ]); mockGetContainerLabels .mockResolvedValueOnce({ [PREVIEW_LABELS.preview]: 'true', [PREVIEW_LABELS.initiativeId]: 'init-1', [PREVIEW_LABELS.projectId]: 'proj-1', [PREVIEW_LABELS.branch]: 'feat-a', [PREVIEW_LABELS.port]: '9100', [PREVIEW_LABELS.previewId]: 'aaa', }) .mockResolvedValueOnce({ [PREVIEW_LABELS.preview]: 'true', [PREVIEW_LABELS.initiativeId]: 'init-2', [PREVIEW_LABELS.projectId]: 'proj-2', [PREVIEW_LABELS.branch]: 'feat-b', [PREVIEW_LABELS.port]: '9101', [PREVIEW_LABELS.previewId]: 'bbb', }); mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'none' }]); const previews = await manager.list('init-1'); expect(previews).toHaveLength(1); expect(previews[0].initiativeId).toBe('init-1'); }); it('skips projects without cw.preview label', async () => { mockListPreviewProjects.mockResolvedValue([ { Name: 'cw-preview-orphan', Status: 'running(1)', ConfigFiles: '' }, ]); mockGetContainerLabels.mockResolvedValue({}); // no cw.preview label const previews = await manager.list(); expect(previews).toHaveLength(0); }); it('skips projects with incomplete labels', async () => { mockListPreviewProjects.mockResolvedValue([ { Name: 'cw-preview-partial', Status: 'running(1)', ConfigFiles: '' }, ]); mockGetContainerLabels.mockResolvedValue({ [PREVIEW_LABELS.preview]: 'true', // Missing required: initiativeId, projectId, branch }); const previews = await manager.list(); expect(previews).toHaveLength(0); }); }); describe('getStatus', () => { const labels = { [PREVIEW_LABELS.preview]: 'true', [PREVIEW_LABELS.initiativeId]: 'init-1', [PREVIEW_LABELS.projectId]: 'proj-1', [PREVIEW_LABELS.branch]: 'main', [PREVIEW_LABELS.port]: '9100', [PREVIEW_LABELS.previewId]: 'abc', }; it('returns running when all services are running', async () => { mockGetContainerLabels.mockResolvedValue(labels); mockComposePs.mockResolvedValue([ { name: 'app', state: 'running', health: 'healthy' }, { name: 'caddy-proxy', state: 'running', health: 'none' }, ]); const status = await manager.getStatus('abc'); expect(status).not.toBeNull(); expect(status!.status).toBe('running'); expect(status!.id).toBe('abc'); expect(status!.port).toBe(9100); }); it('returns failed when any service is exited', async () => { mockGetContainerLabels.mockResolvedValue(labels); mockComposePs.mockResolvedValue([ { name: 'app', state: 'exited', health: 'none' }, { name: 'caddy-proxy', state: 'running', health: 'none' }, ]); const status = await manager.getStatus('abc'); expect(status!.status).toBe('failed'); }); it('returns stopped when no services exist', async () => { mockGetContainerLabels.mockResolvedValue(labels); mockComposePs.mockResolvedValue([]); const status = await manager.getStatus('abc'); expect(status!.status).toBe('stopped'); }); it('returns building when services are in other states', async () => { mockGetContainerLabels.mockResolvedValue(labels); mockComposePs.mockResolvedValue([ { name: 'app', state: 'created', health: 'starting' }, ]); const status = await manager.getStatus('abc'); expect(status!.status).toBe('building'); }); it('returns null when preview is not found', async () => { mockGetContainerLabels.mockResolvedValue({}); // no cw.preview label const status = await manager.getStatus('nonexistent'); expect(status).toBeNull(); }); }); describe('stopAll', () => { it('stops all preview projects', async () => { mockListPreviewProjects.mockResolvedValue([ { Name: 'cw-preview-aaa', Status: 'running(2)', ConfigFiles: '' }, { Name: 'cw-preview-bbb', Status: 'running(1)', ConfigFiles: '' }, ]); // stop() calls getContainerLabels then composeDown mockGetContainerLabels.mockResolvedValue({ [PREVIEW_LABELS.initiativeId]: 'init-1', }); mockComposeDown.mockResolvedValue(undefined); await manager.stopAll(); expect(mockComposeDown).toHaveBeenCalledTimes(2); expect(mockComposeDown).toHaveBeenCalledWith('cw-preview-aaa'); expect(mockComposeDown).toHaveBeenCalledWith('cw-preview-bbb'); expect(eventBus.emitted.filter((e) => e.type === 'preview:stopped')).toHaveLength(2); }); it('continues stopping other previews when one fails', async () => { mockListPreviewProjects.mockResolvedValue([ { Name: 'cw-preview-fail', Status: 'running(1)', ConfigFiles: '' }, { Name: 'cw-preview-ok', Status: 'running(1)', ConfigFiles: '' }, ]); mockGetContainerLabels.mockResolvedValue({ [PREVIEW_LABELS.initiativeId]: 'init-1', }); // First stop fails, second succeeds mockComposeDown .mockRejectedValueOnce(new Error('docker daemon not responding')) .mockResolvedValueOnce(undefined); // Should not throw — errors are caught per-project await manager.stopAll(); // Second preview still stopped successfully expect(mockComposeDown).toHaveBeenCalledTimes(2); }); it('handles empty project list gracefully', async () => { mockListPreviewProjects.mockResolvedValue([]); await manager.stopAll(); expect(mockComposeDown).not.toHaveBeenCalled(); expect(eventBus.emitted).toHaveLength(0); }); }); });