/** * Multi-arch build targets test — locks in the [E00-S02-T07] amd64/arm64 * build targets for the workspace server application image. * * Acceptance criteria covered (each test fails without the committed state): * - "amd64 is a build target" → the committed `compose.yaml` `app` service's * `build.platforms` list (Compose Build spec `platforms`) declares * `linux/amd64`, so `docker compose build` targets amd64. The mutation * probes below prove the assertion is non-vacuous (removing the amd64 * entry, or dropping/emptying the whole list, breaks the criterion). * - "arm64 is a build target" → the same `build.platforms` list declares * `linux/arm64`, so `docker compose build` targets arm64. Removing the * arm64 entry (or replacing it with a non-arm64 platform) breaks the * criterion. * - buildability: every stage of the committed `apps/server/Dockerfile` * builds on the official multi-arch `node:24.19.0-bookworm-slim` base * (which publishes linux/amd64 and linux/arm64 manifests), so the declared * targets are actually realizable; and, on machines with Docker, two * gated probes confirm it end to end: * - `docker compose build --print` emits the equivalent bake (buildx) * config whose app target lists both platforms — a real * `docker compose build` produces a multi-arch image; * - `docker buildx imagetools inspect node:24.19.0-bookworm-slim` * reports both `linux/amd64` and `linux/arm64` in the base image's * manifest list. * * Run: `node --test tests/build-targets.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'; // --------------------------------------------------------------------------- // Minimal block-YAML parser (the same subset the committed compose.yaml uses; // this is the repo's own parser from tests/compose-config.test.mjs) // --------------------------------------------------------------------------- /** 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: nested * block mappings, scalar values and sequences of scalars. 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); } // --------------------------------------------------------------------------- // Criterion assertions // --------------------------------------------------------------------------- /** * Asserts the committed compose.yaml declares both acceptance criteria: the * `app` service's build config lists `linux/amd64` and `linux/arm64` in * `build.platforms`. Fails fast on a missing app service / build config or on * a missing, empty or partial platforms list; the mutation probes below prove * the assertions are non-vacuous. */ function assertBuildTargets(compose) { const services = compose.services; assert.ok(services, 'compose.yaml must declare a top-level "services" map'); const app = services.app; assert.ok(app, 'compose.yaml must declare an "app" service (the image `docker compose build` produces)'); const build = app.build; assert.ok(build, 'the "app" service must declare a build config (build.context / build.dockerfile)'); const platforms = build.platforms; assert.ok( Array.isArray(platforms) && platforms.length > 0, 'the "app" build config must declare a non-empty "platforms" list (Compose Build spec `build.platforms`)', ); assert.ok( platforms.includes('linux/amd64'), 'amd64 is a build target: "build.platforms" must include "linux/amd64"', ); assert.ok( platforms.includes('linux/arm64'), 'arm64 is a build target: "build.platforms" must include "linux/arm64"', ); } /** * Asserts every stage of the committed Dockerfile builds on the official * multi-arch `node:24.19.0-bookworm-slim` base, which publishes linux/amd64 * and linux/arm64 manifests — so the platforms declared in compose.yaml are * actually buildable. A single-arch or private base image cannot satisfy the * criteria. */ function assertMultiArchBase(dockerfile) { const froms = [...dockerfile.matchAll(/^FROM\s+(\S+)(?:\s+AS\s+\S+)?\s*$/gm)].map((m) => m[1]); assert.ok( froms.length >= 2, 'the Dockerfile must declare at least a build and a runtime stage (two FROM lines)', ); for (const image of froms) { assert.equal( image, 'node:24.19.0-bookworm-slim', `every Dockerfile stage must use the official multi-arch "node:24.19.0-bookworm-slim" base ` + `(amd64/arm64 build targets need a base image that publishes both manifests; got "${image}")`, ); } } // --------------------------------------------------------------------------- // 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; } } /** True when the buildx plugin (multi-platform builds) is available. */ function dockerBuildxAvailable() { try { return run('docker', ['buildx', 'version'], { timeout: 15_000 }).status === 0; } catch { return false; } } const DOCKER_COMPOSE = dockerComposeAvailable(); const DOCKER_DAEMON = dockerDaemonAvailable(); const DOCKER_BUILDX = dockerBuildxAvailable(); // --------------------------------------------------------------------------- // Criterion tests // --------------------------------------------------------------------------- test('compose.yaml declares amd64 and arm64 as build targets (app service build.platforms)', () => { assert.ok(existsSync(path.join(REPO_ROOT, COMPOSE_PATH)), `committed ${COMPOSE_PATH} must exist`); assertBuildTargets(parseYaml(read(COMPOSE_PATH))); }); test('the app image is built from a multi-arch base image (every stage FROM node:24.19.0-bookworm-slim)', () => { assert.ok(existsSync(path.join(REPO_ROOT, DOCKERFILE_PATH)), `committed ${DOCKERFILE_PATH} must exist`); assertMultiArchBase(read(DOCKERFILE_PATH)); }); test('docker compose build targets both platforms (bake config probe)', { skip: !DOCKER_COMPOSE || !DOCKER_DAEMON }, () => { // `docker compose build --print` emits the equivalent bake (buildx) config // without building: its app target must list both platforms, i.e. a real // `docker compose build` produces a multi-arch image. Requires the Compose // plugin and a daemon (project resolution talks to the daemon). const printed = run('docker', ['compose', 'build', '--print'], { cwd: REPO_ROOT, timeout: 60_000 }); assert.equal( printed.status, 0, `"docker compose build --print" must exit 0:\n${(printed.stdout || '')}\n${(printed.stderr || '')}`.trim(), ); const out = String(printed.stdout || ''); const jsonStart = out.indexOf('{'); const jsonEnd = out.lastIndexOf('}'); assert.ok( jsonStart !== -1 && jsonEnd > jsonStart, `"docker compose build --print" must emit a JSON bake config (got: "${out.slice(0, 200)}")`, ); const bake = JSON.parse(out.slice(jsonStart, jsonEnd + 1)); const targets = Object.values(bake.target || {}); const appTarget = targets.find((t) => Array.isArray(t.platforms)); assert.ok(appTarget, 'the bake config must contain a target with a platforms list'); assert.ok( appTarget.platforms.includes('linux/amd64'), `the bake target must list linux/amd64 (got: ${JSON.stringify(appTarget.platforms)})`, ); assert.ok( appTarget.platforms.includes('linux/arm64'), `the bake target must list linux/arm64 (got: ${JSON.stringify(appTarget.platforms)})`, ); }); test('the base image publishes amd64 and arm64 manifests (buildx imagetools probe)', { skip: !DOCKER_BUILDX }, () => { // The declared build targets are only realizable if the base image ships // both architectures. Requires the buildx plugin and registry access. const inspect = run('docker', ['buildx', 'imagetools', 'inspect', 'node:24.19.0-bookworm-slim'], { timeout: 120_000, }); assert.equal( inspect.status, 0, `"docker buildx imagetools inspect" must exit 0:\n${(inspect.stdout || '')}\n${(inspect.stderr || '')}`.trim(), ); const out = String(inspect.stdout || ''); assert.match(out, /linux\/amd64/, 'the base image manifest list must include linux/amd64'); assert.match(out, /linux\/arm64/, 'the base image manifest list must include linux/arm64'); }); // --------------------------------------------------------------------------- // Non-vacuous probes — the assertions above really do fail on violations // --------------------------------------------------------------------------- test('removing the amd64 target makes the amd64 criterion fail (mutation probe)', () => { const text = read(COMPOSE_PATH); const withoutAmd64 = text.replace(/\n\s*- linux\/amd64\n/, '\n'); assert.notEqual(withoutAmd64, text, 'the mutation must actually remove the linux/amd64 entry'); assert.throws(() => assertBuildTargets(parseYaml(withoutAmd64)), /linux\/amd64/); }); test('removing the arm64 target makes the arm64 criterion fail (mutation probe)', () => { const text = read(COMPOSE_PATH); const withoutArm64 = text.replace(/\n\s*- linux\/arm64\n/, '\n'); assert.notEqual(withoutArm64, text, 'the mutation must actually remove the linux/arm64 entry'); assert.throws(() => assertBuildTargets(parseYaml(withoutArm64)), /linux\/arm64/); }); test('replacing the arm64 target with a non-arm64 platform fails (mutation probe)', () => { const text = read(COMPOSE_PATH); const wrongArch = text.replace(/\n(\s*)- linux\/arm64\n/, '\n$1- linux/386\n'); assert.notEqual(wrongArch, text, 'the mutation must actually replace the linux/arm64 entry'); assert.throws(() => assertBuildTargets(parseYaml(wrongArch)), /linux\/arm64/); }); test('dropping the platforms list entirely fails the criteria (mutation probe)', () => { const text = read(COMPOSE_PATH); const withoutPlatforms = text.replace(/\n\s*platforms:\n(?:\s*- [^\n]+\n)+/, '\n'); assert.notEqual(withoutPlatforms, text, 'the mutation must actually remove the platforms list'); assert.throws(() => assertBuildTargets(parseYaml(withoutPlatforms)), /platforms/); }); test('an empty platforms list fails the criteria (mutation probe)', () => { const text = read(COMPOSE_PATH); const empty = text.replace(/\n\s*platforms:\n(?:\s*- [^\n]+\n)+/, '\n platforms:\n'); assert.notEqual(empty, text, 'the mutation must actually empty the platforms list'); assert.throws(() => assertBuildTargets(parseYaml(empty)), /platforms/); }); test('a platforms list on the db service (which has no build config) cannot satisfy the criteria (mutation probe)', () => { const text = read(COMPOSE_PATH); const dbOnly = text .replace(/\n\s*platforms:\n(?:\s*- [^\n]+\n)+/, '\n') .replace( ' image: postgres:18-bookworm\n', ' image: postgres:18-bookworm\n platforms:\n - linux/amd64\n - linux/arm64\n', ); assert.notEqual(dbOnly, text, 'the mutation must actually move the platforms list onto db'); assert.throws(() => assertBuildTargets(parseYaml(dbOnly)), /platforms/); }); test('the YAML parser reads the committed build.platforms structure (non-vacuous parser probe)', () => { const compose = parseYaml(read(COMPOSE_PATH)); assert.deepEqual( compose.services.app.build.platforms, ['linux/amd64', 'linux/arm64'], 'the committed compose.yaml must declare exactly the two build targets', ); });