The Caddyfile was using the host port (e.g., 9100) as the Caddy listen address, but Docker maps host:9100 → container:80. Caddy inside the container was listening on 9100 while Docker only forwarded to port 80, causing all health checks to fail with "connection reset by peer".
257 lines
8.4 KiB
TypeScript
257 lines
8.4 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 path-based 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(':80 {');
|
|
expect(caddyfile).toContain('handle_path /abc123/*');
|
|
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 /abc123/api/*');
|
|
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-backend:8080');
|
|
expect(caddyfile).toContain('handle_path /abc123/*');
|
|
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-frontend:3000');
|
|
});
|
|
|
|
it('generates multi-preview Caddyfile under single host block', () => {
|
|
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(':80 {');
|
|
expect(caddyfile).toContain('handle_path /abc/*');
|
|
expect(caddyfile).toContain('handle_path /xyz/*');
|
|
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('/abc/api/auth');
|
|
const apiIdx = caddyfile.indexOf('handle_path /abc/api/*');
|
|
const rootIdx = caddyfile.indexOf('handle_path /abc/*');
|
|
|
|
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');
|
|
});
|
|
});
|