/** * Integration Docker Validation Tests * * Validates the RuFlo Docker-based deployment stack without running Docker. * Checks docker-compose.yml, nginx.conf, MCP bridge source, CLI Dockerfile, * and CLI build/init/doctor commands for correctness. * * 40+ test cases covering: * - Docker Compose config parsing and service definitions * - Nginx reverse proxy configuration * - MCP bridge endpoint and CORS validation * - CLI Dockerfile multi-stage build * - CLI build, init, and doctor commands * - Generated file validity */ import { describe, it, expect, beforeAll } from 'vitest'; import { readFileSync, existsSync, statSync } from 'fs'; import { execSync } from 'child_process'; import { resolve, join } from 'path'; // --------------------------------------------------------------------------- // Paths // --------------------------------------------------------------------------- const ROOT = resolve(__dirname, '..', '..', '..', '..'); // /workspaces/claude-flow const CLI_DIR = resolve(__dirname, '..'); // v3/@claude-flow/cli const RUFLO_DIR = join(ROOT, 'ruflo'); const COMPOSE_PATH = join(RUFLO_DIR, 'docker-compose.yml'); const NGINX_CONF_PATH = join(RUFLO_DIR, 'src', 'nginx', 'nginx.conf'); const NGINX_DOCKERFILE = join(RUFLO_DIR, 'src', 'nginx', 'Dockerfile'); const MCP_BRIDGE_INDEX = join(RUFLO_DIR, 'src', 'mcp-bridge', 'index.js'); const MCP_BRIDGE_DOCKERFILE = join(RUFLO_DIR, 'src', 'mcp-bridge', 'Dockerfile'); const CLI_DOCKERFILE = join(CLI_DIR, 'docker', 'Dockerfile'); const ENV_EXAMPLE = join(RUFLO_DIR, '.env.example'); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function readFile(p: string): string { return readFileSync(p, 'utf-8'); } /** Minimal YAML parser: extracts top-level keys under `services:` */ function parseComposeServices(yaml: string): string[] { const lines = yaml.split('\n'); const services: string[] = []; let inServices = false; for (const line of lines) { if (/^services:/.test(line)) { inServices = true; continue; } if (inServices && /^[a-z]/.test(line)) break; // next top-level key if (inServices && /^ [a-z][\w-]*:/.test(line)) { services.push(line.trim().replace(':', '')); } } return services; } /** Extract all EXPOSE directives from a Dockerfile */ function extractExposePort(content: string): number[] { return [...content.matchAll(/^EXPOSE\s+(\d+)/gm)].map(m => parseInt(m[1], 10)); } // --------------------------------------------------------------------------- // 1. Docker Compose Config Validation // --------------------------------------------------------------------------- describe('Docker Compose Configuration', () => { let composeContent: string; beforeAll(() => { composeContent = readFile(COMPOSE_PATH); }); it('docker-compose.yml exists', () => { expect(existsSync(COMPOSE_PATH)).toBe(true); }); it('defines exactly 4 services (mongodb, mcp-bridge, nginx, chat-ui)', () => { const services = parseComposeServices(composeContent); expect(services).toEqual( expect.arrayContaining(['mongodb', 'mcp-bridge', 'nginx', 'chat-ui']), ); expect(services).toHaveLength(4); }); it('mongodb uses mongo:7 image', () => { expect(composeContent).toMatch(/image:\s*mongo:7/); }); it('mongodb exposes port 27017', () => { expect(composeContent).toMatch(/"27017:27017"/); }); it('mongodb has a named volume for data persistence', () => { expect(composeContent).toMatch(/mongo-data:\/data\/db/); expect(composeContent).toMatch(/^volumes:\s*\n\s+mongo-data:/m); }); it('mcp-bridge builds from correct context', () => { expect(composeContent).toMatch(/context:\s*\.\/src\/ruvocal\/mcp-bridge/); }); it('mcp-bridge publishes port 3001', () => { expect(composeContent).toMatch(/"3001:3001"/); }); it('mcp-bridge has a healthcheck', () => { // The healthcheck should reference /health const mcpSection = composeContent.split('mcp-bridge:')[1]?.split(/\n [a-z]/)[0] ?? ''; expect(composeContent).toContain('healthcheck'); expect(composeContent).toContain('/health'); }); it('nginx depends on chat-ui and mcp-bridge', () => { // Extract nginx depends_on block const nginxBlock = composeContent.split('nginx:')[1]?.split(/\n [a-z]/)[0] ?? ''; expect(nginxBlock).toContain('chat-ui'); expect(nginxBlock).toContain('mcp-bridge'); }); it('nginx publishes port 3000', () => { expect(composeContent).toMatch(/"3000:3000"/); }); it('nginx has a healthcheck', () => { // The nginx healthcheck should use wget expect(composeContent).toContain('wget'); expect(composeContent).toContain('http://localhost:3000'); }); it('chat-ui depends on mongodb and mcp-bridge', () => { const chatBlock = composeContent.split('chat-ui:')[1] ?? ''; const depsBlock = chatBlock.split('depends_on:')[1]?.split(/\n [a-z]/)[0] ?? ''; expect(depsBlock).toContain('mongodb'); expect(depsBlock).toContain('mcp-bridge'); }); it('chat-ui exposes port 3000 internally only', () => { const chatBlock = composeContent.split('chat-ui:')[1]?.split(/\n [a-z]/)[0] ?? ''; // Should use "expose" not "ports" since nginx fronts it expect(chatBlock).toContain('expose'); }); it('chat-ui injects DOTENV_LOCAL with required env vars', () => { expect(composeContent).toContain('DOTENV_LOCAL'); expect(composeContent).toContain('MONGODB_URL=mongodb://mongodb:27017'); expect(composeContent).toContain('PUBLIC_APP_NAME'); expect(composeContent).toContain('OPENAI_BASE_URL=http://mcp-bridge:3001'); }); it('all services use restart: unless-stopped', () => { const matches = composeContent.match(/restart:\s*unless-stopped/g); expect(matches).not.toBeNull(); expect(matches!.length).toBeGreaterThanOrEqual(4); }); it('MCP tool group env vars are defined for mcp-bridge', () => { const groups = [ 'MCP_GROUP_INTELLIGENCE', 'MCP_GROUP_AGENTS', 'MCP_GROUP_MEMORY', 'MCP_GROUP_DEVTOOLS', 'MCP_GROUP_SECURITY', 'MCP_GROUP_BROWSER', 'MCP_GROUP_NEURAL', 'MCP_GROUP_AGENTIC_FLOW', 'MCP_GROUP_CLAUDE_CODE', 'MCP_GROUP_GEMINI', 'MCP_GROUP_CODEX', ]; for (const g of groups) { expect(composeContent).toContain(g); } }); }); // --------------------------------------------------------------------------- // 2. Nginx Config Validation // --------------------------------------------------------------------------- describe('Nginx Configuration', () => { let nginxContent: string; beforeAll(() => { nginxContent = readFile(NGINX_CONF_PATH); }); it('nginx.conf exists', () => { expect(existsSync(NGINX_CONF_PATH)).toBe(true); }); it('listens on port 3000', () => { expect(nginxContent).toMatch(/listen\s+3000/); }); it('gzip is disabled (required for sub_filter)', () => { expect(nginxContent).toMatch(/gzip\s+off/); }); it('sets Access-Control-Allow-Origin header', () => { expect(nginxContent).toContain('Access-Control-Allow-Origin'); }); it('sets Access-Control-Allow-Methods header', () => { expect(nginxContent).toContain('Access-Control-Allow-Methods'); expect(nginxContent).toContain('GET'); expect(nginxContent).toContain('POST'); expect(nginxContent).toContain('OPTIONS'); }); it('sets Access-Control-Allow-Headers header', () => { expect(nginxContent).toContain('Access-Control-Allow-Headers'); expect(nginxContent).toContain('Content-Type'); expect(nginxContent).toContain('Authorization'); }); it('handles OPTIONS preflight requests with 204', () => { expect(nginxContent).toMatch(/if\s*\(\$request_method\s*=\s*OPTIONS\)/); expect(nginxContent).toContain('return 204'); }); it('proxies root location to chat-ui:3000', () => { expect(nginxContent).toMatch(/proxy_pass\s+http:\/\/chat-ui:3000/); }); it('sets WebSocket upgrade headers', () => { expect(nginxContent).toContain('Upgrade'); expect(nginxContent).toContain('"upgrade"'); expect(nginxContent).toContain('$http_upgrade'); }); it('disables upstream gzip via Accept-Encoding header', () => { expect(nginxContent).toContain('proxy_set_header Accept-Encoding ""'); }); it('injects RuFlo welcome.js script via sub_filter', () => { expect(nginxContent).toMatch(/sub_filter\s+'<\/head>'\s+'