test: lock in amd64/arm64 build target criteria (E00-S02-T07)
CI / Frozen lockfile install (pull_request) Successful in 46s

This commit is contained in:
bot-implementer
2026-08-29 01:13:30 +00:00
parent 5bbcfa9b6d
commit f663fd23eb
+362
View File
@@ -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',
);
});