Refactor preview deployments to use a single shared Caddy gateway container with subdomain routing (<previewId>.localhost:<port>) instead of one Caddy sidecar and one port per preview. Adds dev/preview modes, git worktree support for branch checkouts, and auto-start on phase:pending_review. - Add GatewayManager for shared Caddy lifecycle + Caddyfile generation - Add git worktree helpers for preview mode branch checkouts - Add dev mode: volume-mount + dev server image instead of build - Remove per-preview Caddy sidecar and port publishing - Use shared cw-preview-net Docker network with container name DNS - Auto-start previews when phase enters pending_review - Delete unused PreviewPanel.tsx - Update all tests (40 pass), docs, events, CLI, tRPC, frontend
578 lines
20 KiB
TypeScript
578 lines
20 KiB
TypeScript
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(),
|
|
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', () => ({
|
|
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 { 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 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<string, unknown>).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<typeof vi.fn>).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('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<string, unknown>).previewId).toBe('abc123test');
|
|
expect((stoppedEvent!.payload as Record<string, unknown>).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<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',
|
|
[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();
|
|
});
|
|
});
|
|
});
|