Files
Codewalkers/apps/server/preview/compose-generator.test.ts
Lukas May 143aad58e8 feat: Replace per-preview Caddy sidecars with shared gateway architecture
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
2026-03-05 12:22:29 +01:00

255 lines
8.2 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import yaml from 'js-yaml';
import {
generateComposeFile,
generateLabels,
} from './compose-generator.js';
import { generateGatewayCaddyfile } from './gateway.js';
import type { PreviewConfig } from './types.js';
import { GATEWAY_NETWORK } from './types.js';
import type { GatewayRoute } from './gateway.js';
describe('generateComposeFile', () => {
const baseOpts = {
projectPath: '/workspace/repos/my-project-abc123',
deploymentId: 'test123',
labels: {
'cw.preview': 'true',
'cw.initiative-id': 'init-1',
'cw.port': '9100',
},
mode: 'preview' as const,
};
it('generates valid compose YAML with user services (no Caddy sidecar)', () => {
const config: PreviewConfig = {
version: 1,
services: {
app: {
name: 'app',
build: '.',
port: 3000,
},
},
};
const result = generateComposeFile(config, baseOpts);
const parsed = yaml.load(result) as Record<string, any>;
// Has user service but NOT caddy-proxy
expect(parsed.services.app).toBeDefined();
expect(parsed.services['caddy-proxy']).toBeUndefined();
// External network + internal network
expect(parsed.networks[GATEWAY_NETWORK]).toEqual({ external: true });
expect(parsed.networks.internal).toEqual({ driver: 'bridge' });
// No published ports on user service
expect(parsed.services.app.ports).toBeUndefined();
// Container name set for DNS resolution
expect(parsed.services.app.container_name).toBe('cw-preview-test123-app');
// Labels propagated
expect(parsed.services.app.labels['cw.preview']).toBe('true');
// Public service on both networks
expect(parsed.services.app.networks).toContain(GATEWAY_NETWORK);
expect(parsed.services.app.networks).toContain('internal');
});
it('handles object build config with context path joining', () => {
const config: PreviewConfig = {
version: 1,
services: {
api: {
name: 'api',
build: { context: 'packages/api', dockerfile: 'Dockerfile.prod' },
port: 8080,
},
},
};
const result = generateComposeFile(config, baseOpts);
const parsed = yaml.load(result) as Record<string, any>;
expect(parsed.services.api.build.context).toBe(
'/workspace/repos/my-project-abc123/packages/api',
);
expect(parsed.services.api.build.dockerfile).toBe('Dockerfile.prod');
});
it('handles image-based services', () => {
const config: PreviewConfig = {
version: 1,
services: {
db: {
name: 'db',
image: 'postgres:16',
port: 5432,
internal: true,
env: { POSTGRES_PASSWORD: 'test' },
},
},
};
const result = generateComposeFile(config, baseOpts);
const parsed = yaml.load(result) as Record<string, any>;
expect(parsed.services.db.image).toBe('postgres:16');
expect(parsed.services.db.environment.POSTGRES_PASSWORD).toBe('test');
});
it('internal services only get internal network', () => {
const config: PreviewConfig = {
version: 1,
services: {
db: {
name: 'db',
image: 'postgres:16',
port: 5432,
internal: true,
},
},
};
const result = generateComposeFile(config, baseOpts);
const parsed = yaml.load(result) as Record<string, any>;
expect(parsed.services.db.networks).toEqual(['internal']);
expect(parsed.services.db.networks).not.toContain(GATEWAY_NETWORK);
});
it('dev mode uses image + volumes + command instead of build', () => {
const config: PreviewConfig = {
version: 1,
services: {
frontend: {
name: 'frontend',
build: '.',
port: 3000,
dev: {
image: 'node:20-alpine',
command: 'npm run dev -- --host 0.0.0.0',
workdir: '/app',
},
},
},
};
const devOpts = { ...baseOpts, mode: 'dev' as const };
const result = generateComposeFile(config, devOpts);
const parsed = yaml.load(result) as Record<string, any>;
// Should use dev image, not build
expect(parsed.services.frontend.image).toBe('node:20-alpine');
expect(parsed.services.frontend.build).toBeUndefined();
expect(parsed.services.frontend.command).toBe('npm run dev -- --host 0.0.0.0');
expect(parsed.services.frontend.working_dir).toBe('/app');
// Should have volume mount + node_modules anonymous volume
expect(parsed.services.frontend.volumes).toContain(
`${baseOpts.projectPath}:/app`,
);
expect(parsed.services.frontend.volumes).toContain('/app/node_modules');
});
});
describe('generateGatewayCaddyfile', () => {
it('generates single-preview Caddyfile with subdomain routing', () => {
const previews = new Map<string, GatewayRoute[]>();
previews.set('abc123', [
{ containerName: 'cw-preview-abc123-app', port: 3000, route: '/' },
]);
const caddyfile = generateGatewayCaddyfile(previews, 9100);
expect(caddyfile).toContain('auto_https off');
expect(caddyfile).toContain('abc123.localhost:9100 {');
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-app:3000');
});
it('generates multi-service Caddyfile with handle_path for non-root routes', () => {
const previews = new Map<string, GatewayRoute[]>();
previews.set('abc123', [
{ containerName: 'cw-preview-abc123-frontend', port: 3000, route: '/' },
{ containerName: 'cw-preview-abc123-backend', port: 8080, route: '/api' },
]);
const caddyfile = generateGatewayCaddyfile(previews, 9100);
expect(caddyfile).toContain('handle_path /api/*');
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-backend:8080');
expect(caddyfile).toContain('handle {');
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-frontend:3000');
});
it('generates multi-preview Caddyfile with separate subdomain blocks', () => {
const previews = new Map<string, GatewayRoute[]>();
previews.set('abc', [
{ containerName: 'cw-preview-abc-app', port: 3000, route: '/' },
]);
previews.set('xyz', [
{ containerName: 'cw-preview-xyz-app', port: 5000, route: '/' },
]);
const caddyfile = generateGatewayCaddyfile(previews, 9100);
expect(caddyfile).toContain('abc.localhost:9100 {');
expect(caddyfile).toContain('xyz.localhost:9100 {');
expect(caddyfile).toContain('reverse_proxy cw-preview-abc-app:3000');
expect(caddyfile).toContain('reverse_proxy cw-preview-xyz-app:5000');
});
it('sorts routes by specificity (longer paths first)', () => {
const previews = new Map<string, GatewayRoute[]>();
previews.set('abc', [
{ containerName: 'cw-preview-abc-app', port: 3000, route: '/' },
{ containerName: 'cw-preview-abc-api', port: 8080, route: '/api' },
{ containerName: 'cw-preview-abc-auth', port: 9090, route: '/api/auth' },
]);
const caddyfile = generateGatewayCaddyfile(previews, 9100);
const apiAuthIdx = caddyfile.indexOf('/api/auth');
const apiIdx = caddyfile.indexOf('handle_path /api/*');
const handleIdx = caddyfile.indexOf('handle {');
expect(apiAuthIdx).toBeLessThan(apiIdx);
expect(apiIdx).toBeLessThan(handleIdx);
});
});
describe('generateLabels', () => {
it('generates correct labels', () => {
const labels = generateLabels({
initiativeId: 'init-1',
phaseId: 'phase-1',
projectId: 'proj-1',
branch: 'feature/test',
gatewayPort: 9100,
previewId: 'abc123',
mode: 'preview',
});
expect(labels['cw.preview']).toBe('true');
expect(labels['cw.initiative-id']).toBe('init-1');
expect(labels['cw.phase-id']).toBe('phase-1');
expect(labels['cw.project-id']).toBe('proj-1');
expect(labels['cw.branch']).toBe('feature/test');
expect(labels['cw.port']).toBe('9100');
expect(labels['cw.preview-id']).toBe('abc123');
expect(labels['cw.mode']).toBe('preview');
});
it('omits phaseId label when not provided', () => {
const labels = generateLabels({
initiativeId: 'init-1',
projectId: 'proj-1',
branch: 'main',
gatewayPort: 9100,
previewId: 'abc123',
mode: 'dev',
});
expect(labels['cw.phase-id']).toBeUndefined();
expect(labels['cw.mode']).toBe('dev');
});
});