Path-prefix routing (`localhost:9100/<id>/`) broke SPAs because absolute asset paths (`/assets/index.js`) didn't match the `handle_path /<id>/*` route. Subdomain routing (`<id>.localhost:9100/`) resolves this since all paths are relative to the root. Chrome/Firefox resolve *.localhost to 127.0.0.1 natively — no DNS setup needed.
257 lines
8.3 KiB
TypeScript
257 lines
8.3 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: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<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('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<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: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<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 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');
|
|
});
|
|
});
|