162 lines
5.8 KiB
TypeScript
162 lines
5.8 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { ChildProcess, spawn } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { BuildEngine } from "./buildEngine";
|
|
import {
|
|
commit,
|
|
createRepository,
|
|
loadFixture,
|
|
} from "./testing/repositoryFixture";
|
|
|
|
const waitForExit = (child: ChildProcess): Promise<string> =>
|
|
new Promise((resolve, reject) => {
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout?.on("data", (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
child.stderr?.on("data", (chunk) => {
|
|
stderr += chunk;
|
|
});
|
|
child.on("error", reject);
|
|
child.on("exit", (code) => {
|
|
if (code === 0) resolve(stdout.trim());
|
|
else reject(new Error(`worker exited ${code}: ${stderr}`));
|
|
});
|
|
});
|
|
|
|
const waitFor = async (predicate: () => boolean): Promise<void> => {
|
|
const deadline = Date.now() + 10_000;
|
|
while (!predicate()) {
|
|
if (Date.now() > deadline) throw new Error("Timed out waiting for workers.");
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
};
|
|
|
|
test("concurrent writers converge on one verified immutable release", async () => {
|
|
const root = createRepository();
|
|
const output = fs.mkdtempSync(path.join(os.tmpdir(), "concurrent-release-"));
|
|
const barrier = path.join(output, "barrier");
|
|
const worker = path.resolve("src/backend/testing/concurrentBuildWorker.ts");
|
|
try {
|
|
const children = [1, 2].map(() =>
|
|
spawn(process.execPath, ["--import", "tsx", worker, root, output, barrier], {
|
|
cwd: process.cwd(),
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
}),
|
|
);
|
|
const completions = children.map(waitForExit);
|
|
await waitFor(
|
|
() =>
|
|
fs
|
|
.readdirSync(output)
|
|
.filter((entry) => entry.startsWith("barrier.") && entry.endsWith(".ready"))
|
|
.length === 2,
|
|
);
|
|
fs.writeFileSync(`${barrier}.go`, "");
|
|
const checksums = await Promise.all(completions);
|
|
assert.equal(new Set(checksums).size, 1);
|
|
|
|
const releases = fs.readdirSync(path.join(output, "releases"));
|
|
assert.equal(releases.length, 1);
|
|
new BuildEngine(output).verifyRelease(releases[0]);
|
|
assert.equal(
|
|
fs.readdirSync(output).some((entry) => entry.startsWith(".release-")),
|
|
false,
|
|
);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
fs.rmSync(output, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("partial rendering failure leaves no release or temporary directory", () => {
|
|
const root = createRepository();
|
|
const output = fs.mkdtempSync(path.join(os.tmpdir(), "partial-release-"));
|
|
try {
|
|
const template = path.join(
|
|
root,
|
|
"packages/site-definition/.theme/templates/home.liquid",
|
|
);
|
|
fs.appendFileSync(template, "\n{{ undefined.value }}\n");
|
|
commit(root, "break rendering");
|
|
const fixture = loadFixture(root, "partial-failure");
|
|
try {
|
|
const engine = new BuildEngine(output);
|
|
assert.throws(() => engine.build(fixture.input), /undefined|not defined/i);
|
|
assert.equal(fs.existsSync(engine.buildDirectory(fixture.input.artifactBuildId)), false);
|
|
assert.equal(
|
|
fs.readdirSync(output).some((entry) => entry.startsWith(".release-")),
|
|
false,
|
|
);
|
|
} finally {
|
|
fixture.manager.dispose();
|
|
}
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
fs.rmSync(output, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("staging and current pointers promote only checksum-verified releases", () => {
|
|
const root = createRepository();
|
|
const output = fs.mkdtempSync(path.join(os.tmpdir(), "promotion-release-"));
|
|
const first = loadFixture(root, "first");
|
|
try {
|
|
const engine = new BuildEngine(output);
|
|
const firstResult = engine.build(first.input);
|
|
engine.stage(first.input.artifactBuildId);
|
|
engine.activate(first.input.artifactBuildId);
|
|
|
|
const css = path.join(
|
|
root,
|
|
"packages/site-definition/.theme/assets/styles/theme.css",
|
|
);
|
|
fs.appendFileSync(css, "\n.second-release{display:block}\n");
|
|
commit(root, "second release");
|
|
const second = loadFixture(root, "second");
|
|
try {
|
|
const secondResult = engine.build(second.input);
|
|
engine.stage(second.input.artifactBuildId);
|
|
assert.equal(path.basename(fs.realpathSync(engine.currentDirectory())), first.input.artifactBuildId);
|
|
assert.equal(path.basename(fs.realpathSync(engine.stagingDirectory())), second.input.artifactBuildId);
|
|
|
|
engine.activate(second.input.artifactBuildId);
|
|
assert.equal(path.basename(fs.realpathSync(engine.currentDirectory())), second.input.artifactBuildId);
|
|
assert.equal(path.basename(fs.realpathSync(engine.stagingDirectory())), second.input.artifactBuildId);
|
|
|
|
fs.appendFileSync(path.join(firstResult.outputDirectory, "index.html"), "tamper");
|
|
assert.throws(() => engine.activate(first.input.artifactBuildId), /E_RELEASE_CHECKSUM/);
|
|
assert.equal(path.basename(fs.realpathSync(engine.currentDirectory())), second.input.artifactBuildId);
|
|
assert.ok(fs.existsSync(secondResult.outputDirectory));
|
|
} finally {
|
|
second.manager.dispose();
|
|
}
|
|
} finally {
|
|
first.manager.dispose();
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
fs.rmSync(output, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("repeated identical builds retain identical artifact checksums", () => {
|
|
const root = createRepository();
|
|
const output = fs.mkdtempSync(path.join(os.tmpdir(), "idempotent-release-"));
|
|
const fixture = loadFixture(root, "idempotent");
|
|
try {
|
|
const engine = new BuildEngine(output);
|
|
const checksums = Array.from(
|
|
{ length: 20 },
|
|
() => engine.build(fixture.input).artifactChecksum,
|
|
);
|
|
assert.equal(new Set(checksums).size, 1);
|
|
} finally {
|
|
fixture.manager.dispose();
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
fs.rmSync(output, { recursive: true, force: true });
|
|
}
|
|
});
|