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 type { PhaseRepository } from '../db/repositories/phase-repository.js'; import type { InitiativeRepository } from '../db/repositories/initiative-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(), execInContainer: vi.fn(), composePs: vi.fn(), listPreviewProjects: vi.fn(), getContainerLabels: vi.fn(), ensureDockerNetwork: vi.fn(), removeDockerNetwork: vi.fn(), dockerNetworkExists: 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('./worktree.js', () => ({ createPreviewWorktree: vi.fn().mockResolvedValue(undefined), removePreviewWorktree: vi.fn().mockResolvedValue(undefined), })); vi.mock('simple-git', () => ({ simpleGit: vi.fn(() => ({ fetch: vi.fn().mockResolvedValue(undefined), })), })); // Mock gateway to prevent it from consuming docker-client mock values const mockGatewayInstance = { ensureGateway: vi.fn().mockResolvedValue(9100), updateRoutes: vi.fn().mockResolvedValue(undefined), stopGateway: vi.fn().mockResolvedValue(undefined), isRunning: vi.fn().mockResolvedValue(false), getPort: vi.fn().mockResolvedValue(null), }; vi.mock('./gateway.js', () => { const MockGatewayManager = function() { return mockGatewayInstance; }; return { GatewayManager: MockGatewayManager, generateGatewayCaddyfile: vi.fn().mockReturnValue(''), }; }); vi.mock('node:fs/promises', () => ({ mkdir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), rm: vi.fn().mockResolvedValue(undefined), })); vi.mock('nanoid', () => ({ customAlphabet: vi.fn(() => vi.fn(() => 'abc123test')), })); import { PreviewManager } from './manager.js'; import { isDockerAvailable, composeUp, composeDown, execInContainer, 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 { createPreviewWorktree, removePreviewWorktree } from './worktree.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 mockExecInContainer = vi.mocked(execInContainer); const mockDiscoverConfig = vi.mocked(discoverConfig); const mockAllocatePort = vi.mocked(allocatePort); const mockWaitForHealthy = vi.mocked(waitForHealthy); const mockCreatePreviewWorktree = vi.mocked(createPreviewWorktree); 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(), findProjectsByInitiativeId: vi.fn().mockResolvedValue([project]), } as unknown as ProjectRepository; } function createMockPhaseRepo(): PhaseRepository { return { findById: vi.fn(), findByInitiativeId: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), getNextNumber: vi.fn(), findByNumber: vi.fn(), } as unknown as PhaseRepository; } function createMockInitiativeRepo(): InitiativeRepository { return { findById: vi.fn(), findAll: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), findByStatus: vi.fn(), } as unknown as InitiativeRepository; } 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; let phaseRepo: PhaseRepository; let initiativeRepo: InitiativeRepository; beforeEach(() => { vi.clearAllMocks(); // Reset gateway mock to defaults after clearAllMocks wipes implementations mockGatewayInstance.ensureGateway.mockResolvedValue(9100); mockGatewayInstance.updateRoutes.mockResolvedValue(undefined); mockGatewayInstance.stopGateway.mockResolvedValue(undefined); mockGatewayInstance.isRunning.mockResolvedValue(false); mockGatewayInstance.getPort.mockResolvedValue(null); eventBus = createMockEventBus(); projectRepo = createMockProjectRepo(); phaseRepo = createMockPhaseRepo(); initiativeRepo = createMockInitiativeRepo(); manager = new PreviewManager(projectRepo, eventBus, WORKSPACE_ROOT, phaseRepo, initiativeRepo); }); describe('start', () => { it('completes the full start lifecycle with gateway architecture', async () => { mockIsDockerAvailable.mockResolvedValue(true); mockComposeUp.mockResolvedValue(undefined); mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'healthy' }]); mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG); mockCreatePreviewWorktree.mockResolvedValue(undefined); mockWaitForHealthy.mockResolvedValue([{ name: 'app', healthy: true }]); const result = await manager.start({ initiativeId: 'init-1', projectId: 'proj-1', branch: 'feature-x', }); // Verify returned status uses gateway fields 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.gatewayPort).toBe(9100); expect(result.url).toBe('http://abc123test.localhost:9100'); expect(result.mode).toBe('preview'); expect(result.status).toBe('running'); // Verify worktree was created for preview mode expect(mockCreatePreviewWorktree).toHaveBeenCalledOnce(); // Verify events: building then ready const buildingEvent = eventBus.emitted.find((e) => e.type === 'preview:building'); const readyEvent = eventBus.emitted.find((e) => e.type === 'preview:ready'); expect(buildingEvent).toBeDefined(); expect(readyEvent).toBeDefined(); expect((readyEvent!.payload as Record).url).toBe( 'http://abc123test.localhost:9100', ); }); it('includes phaseId when provided', async () => { mockIsDockerAvailable.mockResolvedValue(true); mockComposeUp.mockResolvedValue(undefined); mockComposePs.mockResolvedValue([]); mockDiscoverConfig.mockResolvedValue(SIMPLE_CONFIG); mockCreatePreviewWorktree.mockResolvedValue(undefined); mockWaitForHealthy.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, phaseRepo, initiativeRepo); 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); mockCreatePreviewWorktree.mockResolvedValue(undefined); 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'); // Events: building, then failed const failedEvent = eventBus.emitted.find((e) => e.type === 'preview:failed'); expect(failedEvent).toBeDefined(); }); it('runs seed commands after health check and before preview:ready', async () => { const seededConfig: PreviewConfig = { version: 1, services: { app: { name: 'app', build: '.', port: 3000, seed: ['npm run db:migrate', 'npm run db:seed'], }, }, }; mockIsDockerAvailable.mockResolvedValue(true); mockComposeUp.mockResolvedValue(undefined); mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'healthy' }]); mockDiscoverConfig.mockResolvedValue(seededConfig); mockCreatePreviewWorktree.mockResolvedValue(undefined); mockWaitForHealthy.mockResolvedValue([{ name: 'app', healthy: true }]); mockExecInContainer.mockResolvedValue({ stdout: '', stderr: '' }); const result = await manager.start({ initiativeId: 'init-1', projectId: 'proj-1', branch: 'feature-x', }); expect(result.status).toBe('running'); expect(mockExecInContainer).toHaveBeenCalledTimes(2); expect(mockExecInContainer).toHaveBeenCalledWith('cw-preview-abc123test', 'app', 'npm run db:migrate'); expect(mockExecInContainer).toHaveBeenCalledWith('cw-preview-abc123test', 'app', 'npm run db:seed'); const readyEvent = eventBus.emitted.find((e) => e.type === 'preview:ready'); expect(readyEvent).toBeDefined(); }); it('emits preview:failed and cleans up when seed command fails', async () => { const seededConfig: PreviewConfig = { version: 1, services: { app: { name: 'app', build: '.', port: 3000, seed: ['npm run db:migrate'], }, }, }; mockIsDockerAvailable.mockResolvedValue(true); mockComposeUp.mockResolvedValue(undefined); mockDiscoverConfig.mockResolvedValue(seededConfig); mockCreatePreviewWorktree.mockResolvedValue(undefined); mockWaitForHealthy.mockResolvedValue([{ name: 'app', healthy: true }]); mockExecInContainer.mockRejectedValue(new Error('migration failed: relation already exists')); mockComposeDown.mockResolvedValue(undefined); await expect( manager.start({ initiativeId: 'init-1', projectId: 'proj-1', branch: 'main', }), ).rejects.toThrow('Preview build failed'); const failedEvent = eventBus.emitted.find((e) => e.type === 'preview:failed'); expect(failedEvent).toBeDefined(); expect(mockComposeDown).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); mockComposeUp.mockResolvedValue(undefined); mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'none' }]); mockDiscoverConfig.mockResolvedValue(noHealthConfig); mockCreatePreviewWorktree.mockResolvedValue(undefined); mockWaitForHealthy.mockResolvedValue([]); const result = await manager.start({ initiativeId: 'init-1', projectId: 'proj-1', branch: 'main', }); expect(result.status).toBe('running'); const readyEvent = eventBus.emitted.find((e) => e.type === 'preview:ready'); expect(readyEvent).toBeDefined(); }); }); describe('stop', () => { it('stops compose, cleans up artifacts, and emits preview:stopped', async () => { mockGetContainerLabels.mockResolvedValue({ [PREVIEW_LABELS.preview]: 'true', [PREVIEW_LABELS.initiativeId]: 'init-1', [PREVIEW_LABELS.mode]: 'preview', [PREVIEW_LABELS.projectId]: 'proj-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 }, ); const stoppedEvent = eventBus.emitted.find((e) => e.type === 'preview:stopped'); expect(stoppedEvent).toBeDefined(); expect((stoppedEvent!.payload as Record).previewId).toBe('abc123test'); expect((stoppedEvent!.payload as Record).initiativeId).toBe('init-1'); }); it('emits empty initiativeId when labels are missing', async () => { mockGetContainerLabels.mockResolvedValue({}); mockComposeDown.mockResolvedValue(undefined); await manager.stop('xyz'); const stoppedEvent = eventBus.emitted.find((e) => e.type === 'preview:stopped'); expect(stoppedEvent).toBeDefined(); expect((stoppedEvent!.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', [PREVIEW_LABELS.mode]: 'preview', }) .mockResolvedValueOnce({ [PREVIEW_LABELS.preview]: 'true', [PREVIEW_LABELS.initiativeId]: 'init-2', [PREVIEW_LABELS.projectId]: 'proj-2', [PREVIEW_LABELS.branch]: 'feat-b', [PREVIEW_LABELS.port]: '9100', [PREVIEW_LABELS.previewId]: 'bbb', [PREVIEW_LABELS.mode]: 'dev', }); 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].gatewayPort).toBe(9100); expect(previews[0].url).toBe('http://aaa.localhost:9100'); expect(previews[0].mode).toBe('preview'); expect(previews[0].services).toHaveLength(1); expect(previews[1].id).toBe('bbb'); expect(previews[1].gatewayPort).toBe(9100); expect(previews[1].mode).toBe('dev'); }); 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]: '9100', [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 gateway project from listing', async () => { mockListPreviewProjects.mockResolvedValue([ { Name: 'cw-preview-gateway', Status: 'running(1)', ConfigFiles: '' }, { Name: 'cw-preview-aaa', Status: 'running(1)', ConfigFiles: '' }, ]); mockGetContainerLabels.mockResolvedValue({ [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]: 'aaa', }); mockComposePs.mockResolvedValue([{ name: 'app', state: 'running', health: 'none' }]); const previews = await manager.list(); // Should only include actual previews, not gateway expect(previews).toHaveLength(1); expect(previews[0].id).toBe('aaa'); }); 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', [PREVIEW_LABELS.mode]: 'preview', }; it('returns running when all services are running', async () => { mockGetContainerLabels.mockResolvedValue(labels); mockComposePs.mockResolvedValue([ { name: 'app', state: 'running', health: 'healthy' }, ]); const status = await manager.getStatus('abc'); expect(status).not.toBeNull(); expect(status!.status).toBe('running'); expect(status!.id).toBe('abc'); expect(status!.gatewayPort).toBe(9100); expect(status!.url).toBe('http://abc.localhost:9100'); expect(status!.mode).toBe('preview'); }); it('returns failed when any service is exited', async () => { mockGetContainerLabels.mockResolvedValue(labels); mockComposePs.mockResolvedValue([ { name: 'app', state: 'exited', 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 and the gateway', async () => { mockListPreviewProjects.mockResolvedValue([ { Name: 'cw-preview-gateway', Status: 'running(1)', ConfigFiles: '' }, { 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(); // Should stop preview projects but not call stop() on gateway directly // (gateway is handled separately via stopGateway) expect(mockComposeDown).toHaveBeenCalledWith('cw-preview-aaa'); expect(mockComposeDown).toHaveBeenCalledWith('cw-preview-bbb'); // Gateway is stopped via the mocked GatewayManager.stopGateway() expect(mockGatewayInstance.stopGateway).toHaveBeenCalled(); }); it('handles empty project list gracefully', async () => { mockListPreviewProjects.mockResolvedValue([]); await manager.stopAll(); // No preview composeDown calls, but gateway stopGateway still called expect(mockComposeDown).not.toHaveBeenCalled(); expect(mockGatewayInstance.stopGateway).toHaveBeenCalled(); }); }); });