diff --git a/tests/build-targets.test.mjs b/tests/build-targets.test.mjs new file mode 100644 index 0000000..b2dd338 --- /dev/null +++ b/tests/build-targets.test.mjs @@ -0,0 +1,362 @@ +/** + * 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', + ); +});