test: Add PreviewManager integration tests

21 tests covering the full preview lifecycle: start (happy path, phaseId,
Docker unavailable, project not found, compose failure, health check failure,
no healthchecks), stop, list (with filter, missing labels), getStatus
(running/failed/stopped/building/not found), and stopAll (including partial
failure resilience).
This commit is contained in:
Lukas May
2026-02-10 14:02:43 +01:00
parent 9902069d8d
commit f8c5dce588

567
src/preview/manager.test.ts Normal file
View File

@@ -0,0 +1,567 @@
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(),
};
}
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<typeof vi.fn>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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);
});
});
});