feat: Add seed command support to preview deployments

Run project-specific initialization commands (DB migrations, fixture
loading, etc.) automatically after containers are healthy, before the
preview is marked ready. Configured via per-service `seed` arrays in
.cw-preview.yml.
This commit is contained in:
Lukas May
2026-03-05 12:39:02 +01:00
parent 3913aa2e28
commit 714262fb83
8 changed files with 158 additions and 2 deletions

View File

@@ -10,6 +10,7 @@ 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(),
@@ -74,6 +75,7 @@ import {
isDockerAvailable,
composeUp,
composeDown,
execInContainer,
composePs,
listPreviewProjects,
getContainerLabels,
@@ -91,6 +93,7 @@ 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);
@@ -302,6 +305,76 @@ describe('PreviewManager', () => {
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,