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; // 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; 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; 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; 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; // 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(); 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:80 {'); expect(caddyfile).toContain('handle /* {'); 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(); 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('abc123.localhost:80 {'); 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 separate subdomain blocks for each preview', () => { const previews = new Map(); 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:80 {'); expect(caddyfile).toContain('xyz.localhost:80 {'); 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(); 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 rootIdx = caddyfile.indexOf('handle /* {'); expect(apiAuthIdx).toBeLessThan(apiIdx); expect(apiIdx).toBeLessThan(rootIdx); }); }); 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'); }); });