/** * Docker Compose baseline test — locks in the [E00-S02-T01/T02/T03] `docker * compose up -d` DB + app baseline, the PostgreSQL health gate and the app * health endpoint for the workspace. * * Acceptance criteria covered (each test fails without the committed state): * - "docker compose up -d starts the database" → the committed * `compose.yaml` declares a `db` service backed by a PostgreSQL image * with the credentials the app expects and a published default port, so * `docker compose up -d` creates and starts the database container. When * a Docker daemon + Compose plugin are available (CI/dev machines), the * suite additionally runs the real stack (`docker compose up -d` → * `docker compose ps` → `docker compose down`) and asserts the `db` * container is up (and healthy). * - "docker compose up -d starts the application" → the committed * `compose.yaml` declares an `app` service built from the committed * `apps/server/Dockerfile` (multi-stage: Node 24.19.0 bookworm-slim + * frozen pnpm install → `tsc` build of `@personal-blog/server` → * `node apps/server/dist/index.js`), with a published default port. Since * T03 the real-stack probe asserts the `app` container stays **running** * (the server now serves the health endpoint instead of exiting) and the * health endpoint answers HTTP 200 with a healthy body inside the * container. * - "PostgreSQL health check gates application start" → the committed * `compose.yaml` declares a `healthcheck` on the `db` service that probes * readiness with `pg_isready` against the same credentials the database * was created with (`$$`-escaped so the container env applies), with an * interval/retries so a still-booting database is re-probed rather than * failed instantly. * - "app waits for the database before starting" → the `app` service * depends on `db` with `condition: service_healthy`, so Compose only * starts the application once PostgreSQL reports healthy (the real-stack * probe asserts the `db` container is healthy when Docker reports it). * * Run: `node --test tests/compose-config.test.mjs` * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) */ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, existsSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); /** The committed Compose file and app image definition under test. */ const COMPOSE_PATH = 'compose.yaml'; const DOCKERFILE_PATH = 'apps/server/Dockerfile'; const DOCKERIGNORE_PATH = '.dockerignore'; // --------------------------------------------------------------------------- // Minimal block-YAML parser (no dependencies, lockfile untouched) // --------------------------------------------------------------------------- // // Covers exactly the subset the committed compose.yaml uses: nested block // mappings, block sequences of scalars, plain / single-quoted / double-quoted // scalars and `#` comments. Anything else (flow collections, anchors/aliases, // `|`/`>` block scalars, merge keys) is rejected loudly so the parser can // never silently misread the structure it locks in. /** Drops a `#` comment that is not inside a quoted scalar. */ function stripComment(line) { let quote = null; for (let i = 0; i < line.length; i += 1) { const ch = line[i]; if (quote) { if (ch === quote) quote = null; } else if (ch === "'" || ch === '"') { quote = ch; } else if (ch === '#' && (i === 0 || /\s/.test(line[i - 1]))) { return line.slice(0, i).trimEnd(); } } return line.trimEnd(); } /** Unquotes a plain / single-quoted / double-quoted scalar. */ function scalarValue(raw) { if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1); if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) { return raw.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\'); } return raw; } /** * Parses the supported block-YAML subset into plain JS objects/arrays. * Throws on any construct the committed file does not use. */ function parseYaml(text) { const lines = []; for (const raw of text.split(/\r?\n/)) { const expanded = raw.replace(/\t/g, ' '); if (!expanded.trim() || expanded.trim().startsWith('#')) continue; const indent = expanded.length - expanded.trimStart().length; const content = stripComment(expanded.trimStart()).trim(); if (!content) continue; lines.push({ indent, content }); } let pos = 0; const parseBlock = (indent) => { const node = {}; while (pos < lines.length && lines[pos].indent >= indent) { if (lines[pos].indent > indent) { throw new Error(`unexpected indentation at "${lines[pos].content}"`); } const { content } = lines[pos]; const match = /^([^:#][^:]*):(?:\s+(.*))?$/.exec(content); if (!match) { throw new Error(`expected "key: value", got "${content}"`); } const key = scalarValue(match[1].trim()); const rest = match[2] === undefined ? undefined : match[2].trim(); pos += 1; if (rest === undefined || rest === '') { if (pos < lines.length && lines[pos].indent > indent) { if (lines[pos].content.startsWith('-')) { node[key] = parseSequence(lines[pos].indent); } else { node[key] = parseBlock(lines[pos].indent); } } else { node[key] = null; } } else { node[key] = scalarValue(rest); } } return node; }; const parseSequence = (indent) => { const items = []; while (pos < lines.length && lines[pos].indent >= indent) { if (lines[pos].indent > indent) { throw new Error(`unexpected indentation in sequence at "${lines[pos].content}"`); } const { content } = lines[pos]; if (!content.startsWith('-')) break; const rest = content.slice(1).trim(); if (!rest) throw new Error(`empty sequence item at "-"`); items.push(scalarValue(rest)); pos += 1; } return items; }; if (lines.length === 0) return {}; return parseBlock(0); } // --------------------------------------------------------------------------- // Compose structure assertions (shared by the tests and the mutation probes) // --------------------------------------------------------------------------- /** * Asserts the committed compose.yaml declares a `db` service that * `docker compose up -d` can start: a PostgreSQL image, the credentials the * app expects, and a published default port. */ function assertDbService(compose) { assert.ok(compose.services, 'compose.yaml must declare a top-level "services" map'); const db = compose.services.db; assert.ok(db, 'compose.yaml must declare a "db" service (docker compose up -d starts the database)'); assert.equal( db.image, 'postgres:18-bookworm', 'the "db" service must use the committed PostgreSQL 18 image (postgres:18-bookworm)', ); const env = db.environment ?? {}; assert.equal(env.POSTGRES_DB, '${POSTGRES_DB:-eppp}', 'db must set POSTGRES_DB (with a default)'); assert.equal(env.POSTGRES_USER, '${POSTGRES_USER:-eppp}', 'db must set POSTGRES_USER (with a default)'); assert.ok( typeof env.POSTGRES_PASSWORD === 'string' && env.POSTGRES_PASSWORD.length > 0, 'db must set POSTGRES_PASSWORD (with a default)', ); assert.ok( Array.isArray(db.ports) && db.ports.some((p) => p.endsWith(':5432')), 'db must publish the PostgreSQL port (a mapping ending in ":5432")', ); } /** * Asserts the committed compose.yaml declares an `app` service that * `docker compose up -d` can start: built from the committed Dockerfile, * pointed at the db service, and started only after the database is healthy * (depends_on with condition: service_healthy). */ function assertAppService(compose) { const app = compose.services?.app; assert.ok(app, 'compose.yaml must declare an "app" service (docker compose up -d starts the application)'); assert.equal( app.build?.context, '.', 'the "app" service must build from the repository root context (build.context: ".")', ); assert.equal( app.build?.dockerfile, DOCKERFILE_PATH, `the "app" service must build the committed ${DOCKERFILE_PATH} image`, ); const env = app.environment ?? {}; assert.ok( typeof env.DATABASE_URL === 'string' && /@db:5432\//.test(env.DATABASE_URL), 'app must set DATABASE_URL pointing at the db service host (postgres://…@db:5432/…)', ); assert.ok( Array.isArray(app.ports) && app.ports.some((p) => p.endsWith(':3000')), 'app must publish the application port (a mapping ending in ":3000")', ); assert.equal( app.depends_on?.db?.condition, 'service_healthy', 'app must depend on db with condition: service_healthy so it waits for the database before starting', ); } /** * Asserts the committed compose.yaml gates the app on PostgreSQL health: the * `db` service declares a `pg_isready` healthcheck against the credentials it * was created with, with an interval and retries so a booting database is * re-probed instead of failed instantly. */ function assertDbHealthcheck(compose) { const db = compose.services?.db; assert.ok(db, 'compose.yaml must declare a "db" service'); const hc = db.healthcheck; assert.ok(hc, 'the "db" service must declare a healthcheck (PostgreSQL health check gates application start)'); assert.match( hc.test ?? '', /pg_isready/, 'the db healthcheck must probe readiness with pg_isready', ); assert.ok( (hc.test ?? '').includes('$${POSTGRES_USER}') && (hc.test ?? '').includes('$${POSTGRES_DB}'), 'the db healthcheck must probe the same credentials the database was created with ' + '($${POSTGRES_USER}/$${POSTGRES_DB}, $$-escaped so the container env applies)', ); assert.ok(hc.interval, 'the db healthcheck must declare an interval so readiness is re-probed'); assert.ok( Number(hc.retries) >= 1, 'the db healthcheck must declare retries so a booting database is not failed instantly', ); } // --------------------------------------------------------------------------- // Docker probe helpers (integration tests skip cleanly without Docker) // --------------------------------------------------------------------------- function run(cmd, args, opts = {}) { return spawnSync(cmd, args, { encoding: 'utf8', timeout: 600_000, ...opts, }); } /** True when the `docker` CLI with the Compose plugin is on PATH. */ function dockerComposeAvailable() { try { return run('docker', ['compose', 'version'], { timeout: 15_000 }).status === 0; } catch { return false; } } /** True when a reachable Docker daemon exists. */ function dockerDaemonAvailable() { try { return run('docker', ['info'], { timeout: 15_000 }).status === 0; } catch { return false; } } /** Parses `docker compose ps --format json` (JSON array or one object per line). */ function parsePsJson(stdout) { const text = String(stdout).trim(); if (!text) return []; try { const parsed = JSON.parse(text); return Array.isArray(parsed) ? parsed : [parsed]; } catch { return text .split('\n') .map((line) => line.trim()) .filter(Boolean) .map((line) => JSON.parse(line)); } } /** Tolerant field lookup across compose ps JSON shapes. */ function field(container, ...names) { for (const name of names) { if (container[name] !== undefined) return container[name]; } return undefined; } const DOCKER_COMPOSE = dockerComposeAvailable(); const DOCKER_DAEMON = dockerDaemonAvailable(); // --------------------------------------------------------------------------- // Tests — the committed state that makes `docker compose up -d` work // --------------------------------------------------------------------------- test('compose.yaml exists, parses, and declares exactly the db and app services', () => { assert.ok(existsSync(path.join(REPO_ROOT, COMPOSE_PATH)), `committed ${COMPOSE_PATH} must exist`); const compose = parseYaml(read(COMPOSE_PATH)); assert.deepEqual( Object.keys(compose.services ?? {}).sort(), ['app', 'db'], 'compose.yaml must declare exactly the "db" and "app" services at T01/T02/T03', ); }); test('the database service is defined so "docker compose up -d" starts the database', () => { assertDbService(parseYaml(read(COMPOSE_PATH))); }); test('the application service is defined so "docker compose up -d" starts the application', () => { assertAppService(parseYaml(read(COMPOSE_PATH))); }); test('PostgreSQL health check gates application start (db declares a pg_isready healthcheck)', () => { assertDbHealthcheck(parseYaml(read(COMPOSE_PATH))); }); test('the app waits for the database before starting (depends_on db with condition service_healthy)', () => { const app = parseYaml(read(COMPOSE_PATH)).services?.app; assert.ok(app, 'compose.yaml must declare an "app" service'); assert.equal( app.depends_on?.db?.condition, 'service_healthy', 'app must depend on db with condition: service_healthy so it starts only after PostgreSQL is healthy', ); }); test('the application image is defined by a committed multi-stage Dockerfile', () => { assert.ok(existsSync(path.join(REPO_ROOT, DOCKERFILE_PATH)), `committed ${DOCKERFILE_PATH} must exist`); const dockerfile = read(DOCKERFILE_PATH); assert.match( dockerfile, /FROM node:24\.19\.0-bookworm-slim AS build/, 'the Dockerfile must build on the committed Node 24.19.0 bookworm-slim base (FROM node:24.19.0-bookworm-slim AS build)', ); assert.match( dockerfile, /FROM node:24\.19\.0-bookworm-slim AS runtime/, 'the Dockerfile must ship a Node 24.19.0 bookworm-slim runtime stage (FROM node:24.19.0-bookworm-slim AS runtime)', ); assert.match( dockerfile, /pnpm install --frozen-lockfile/, 'the Dockerfile must install with the frozen lockfile (reproducible builds)', ); assert.match( dockerfile, /pnpm --filter @personal-blog\/server build/, 'the Dockerfile must compile the server package (pnpm --filter @personal-blog/server build)', ); assert.match( dockerfile, /node\b[^\n]*apps\/server\/dist\/index\.js/, 'the Dockerfile runtime stage must run the compiled server entrypoint (node apps/server/dist/index.js)', ); }); test('the build context excludes local artifacts and environment files', () => { assert.ok( existsSync(path.join(REPO_ROOT, DOCKERIGNORE_PATH)), `committed ${DOCKERIGNORE_PATH} must exist so local artifacts stay out of the build context`, ); const patterns = read(DOCKERIGNORE_PATH) .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line && !line.startsWith('#')); for (const required of ['node_modules', 'dist', '.env', '.git']) { assert.ok( patterns.includes(required), `.dockerignore must exclude "${required}" (got: ${patterns.join(', ')})`, ); } }); // --------------------------------------------------------------------------- // Real-stack probes — run the actual acceptance command when Docker is present // --------------------------------------------------------------------------- test('the committed compose.yaml validates against the Compose spec (docker compose config)', { skip: !DOCKER_COMPOSE }, () => { const result = run('docker', ['compose', 'config', '--quiet'], { cwd: REPO_ROOT }); assert.equal( result.status, 0, `"docker compose config" must exit 0 for the committed ${COMPOSE_PATH}:\n` + `${(result.stdout || '')}\n${(result.stderr || '')}`.trim(), ); }); test('docker compose up -d starts the database and application containers', { skip: !DOCKER_COMPOSE || !DOCKER_DAEMON }, (t) => { const up = run('docker', ['compose', 'up', '-d'], { cwd: REPO_ROOT }); assert.equal( up.status, 0, `"docker compose up -d" must exit 0:\n${(up.stdout || '')}\n${(up.stderr || '')}`.trim(), ); try { const ps = run('docker', ['compose', 'ps', '-a', '--format', 'json'], { cwd: REPO_ROOT }); assert.equal(ps.status, 0, `"docker compose ps" must exit 0:\n${(ps.stderr || ps.stdout || '').trim()}`); const containers = parsePsJson(ps.stdout); const db = containers.find((c) => field(c, 'Service', 'service') === 'db'); assert.ok(db, 'docker compose up -d must create the "db" container'); const dbState = String(field(db, 'State', 'state') ?? ''); assert.match( dbState, /running|up/i, `the "db" container must be running after "docker compose up -d" (state: "${dbState}")`, ); const dbHealth = String(field(db, 'Health', 'health') ?? ''); if (dbHealth) { assert.match( dbHealth, /healthy/i, `the "db" container must be healthy before the app starts (health: "${dbHealth}")`, ); } const app = containers.find((c) => field(c, 'Service', 'service') === 'app'); assert.ok(app, 'docker compose up -d must create the "app" container'); const appState = String(field(app, 'State', 'state') ?? ''); assert.match( appState, /running|up/i, `the "app" container must stay running after "docker compose up -d" (state: "${appState}") — since T03 the server serves the health endpoint and must not exit`, ); // T03: the app serves the health endpoint — HTTP smoke test against the // endpoint inside the app container (no host-port dependency), polling // until it answers or times out. let healthOutput = ''; let healthOk = false; for (let attempt = 0; attempt < 30 && !healthOk; attempt += 1) { const probe = run('docker', ['compose', 'exec', '-T', 'app', 'node', '-e', ` fetch('http://127.0.0.1:3000/health') .then(async (res) => { console.log(res.status, await res.text()); process.exit(res.ok ? 0 : 1); }) .catch(() => process.exit(2)); `], { cwd: REPO_ROOT, timeout: 15_000 }); const output = String(probe.stdout ?? '') + String(probe.stderr ?? ''); if (probe.status === 0) { healthOk = true; healthOutput = output; } else if (probe.status === 1) { healthOutput = output; // answered but not 2xx — fail fast break; } else { run(process.execPath, ['-e', 'setTimeout(() => {}, 1000)']); // app still starting — retry } } assert.ok( healthOk, `GET /health must answer 2xx inside the app container once the stack is up (last probe: "${healthOutput.trim()}")`, ); assert.match(healthOutput, /200/, `GET /health must return HTTP 200 (got: "${healthOutput.trim()}")`); assert.match( healthOutput, /"status":"ok"/, `GET /health must report a healthy application (got: "${healthOutput.trim()}")`, ); } finally { run('docker', ['compose', 'down'], { cwd: REPO_ROOT }); } }); // --------------------------------------------------------------------------- // Non-vacuous probes — the assertions above really do fail on violations // --------------------------------------------------------------------------- test('the YAML parser reads the committed structure (non-vacuous parser probe)', () => { const parsed = parseYaml(` services: db: image: postgres:18-bookworm environment: POSTGRES_DB: eppp ports: - "5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U \$\${POSTGRES_USER} -d \$\${POSTGRES_DB}"] interval: 5s retries: 5 app: build: context: . dockerfile: apps/server/Dockerfile depends_on: db: condition: service_healthy `); assert.equal(parsed.services.db.image, 'postgres:18-bookworm'); assert.equal(parsed.services.db.environment.POSTGRES_DB, 'eppp'); assert.deepEqual(parsed.services.db.ports, ['5432:5432']); assert.match(parsed.services.db.healthcheck.test, /pg_isready/); assert.equal(parsed.services.db.healthcheck.retries, '5'); assert.equal(parsed.services.app.build.dockerfile, 'apps/server/Dockerfile'); assert.equal(parsed.services.app.depends_on.db.condition, 'service_healthy'); }); test('removing the db service makes the database criterion fail (mutation probe)', () => { const text = read(COMPOSE_PATH); const withoutDb = text.replace(/^ db:\n(?: .*\n?)*/m, ''); assert.notEqual(withoutDb, text, 'the mutation must actually remove the db service block'); const compose = parseYaml(withoutDb); assert.throws(() => assertDbService(compose), /"db" service/); }); test('removing the app service makes the application criterion fail (mutation probe)', () => { const text = read(COMPOSE_PATH); const withoutApp = text.replace(/^ app:\n(?: .*\n?)*/m, ''); assert.notEqual(withoutApp, text, 'the mutation must actually remove the app service block'); const compose = parseYaml(withoutApp); assert.throws(() => assertAppService(compose), /"app" service/); }); test('removing the db healthcheck makes the health-gate criterion fail (mutation probe)', () => { const text = read(COMPOSE_PATH); const withoutHealthcheck = text.replace(/^ healthcheck:\n(?: .*\n?)*/m, ''); assert.notEqual(withoutHealthcheck, text, 'the mutation must actually remove the db healthcheck block'); const compose = parseYaml(withoutHealthcheck); assert.throws(() => assertDbHealthcheck(compose), /healthcheck/); }); test('reverting depends_on to a plain list breaks the healthy-gate criterion (mutation probe)', () => { const text = read(COMPOSE_PATH); const withoutGate = text.replace( /^ depends_on:\n db:\n condition: service_healthy\n/m, ' depends_on:\n - db\n', ); assert.notEqual(withoutGate, text, 'the mutation must actually replace the healthy-conditioned depends_on'); const compose = parseYaml(withoutGate); assert.throws(() => { const app = compose.services?.app; assert.equal( app?.depends_on?.db?.condition, 'service_healthy', 'app must depend on db with condition: service_healthy', ); }, /service_healthy/); }); test('a Dockerfile without the runtime entrypoint fails the image criterion (mutation probe)', () => { const dockerfile = read(DOCKERFILE_PATH); const withoutCmd = dockerfile.replace(/CMD \[[^\]]*\]/g, ''); assert.notEqual(withoutCmd, dockerfile, 'the mutation must actually remove the CMD'); const asserts = () => { assert.match(withoutCmd, /node\s+apps\/server\/dist\/index\.js/, 'must run the compiled entrypoint'); }; assert.throws(asserts, /compiled entrypoint/); });