304 lines
12 KiB
JavaScript
304 lines
12 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import { promises as fs } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { once } from "node:events";
|
|
import { spawn } from "node:child_process";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
checkpointRecord,
|
|
defaultIo,
|
|
REQUIRED_SECTIONS,
|
|
} from "../scripts/checkpoint-record.mjs";
|
|
|
|
const scriptPath = fileURLToPath(new URL("../scripts/checkpoint-record.mjs", import.meta.url));
|
|
|
|
function record(title = "Workflow checkpoint", sections = REQUIRED_SECTIONS) {
|
|
return `# ${title}\n\n${sections.map((section) => `## ${section}\n\ncheckpoint data\n`).join("\n")}`;
|
|
}
|
|
|
|
function digest(value) {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
async function temporaryDirectory() {
|
|
return fs.mkdtemp(join(tmpdir(), "checkpoint-record-test-"));
|
|
}
|
|
|
|
async function exists(path) {
|
|
try {
|
|
await fs.access(path);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function faultIo({ write, flush, rename, directoryFlush, cleanup } = {}) {
|
|
return {
|
|
...defaultIo,
|
|
open: async (path, flags, mode) => {
|
|
const handle = await defaultIo.open(path, flags, mode);
|
|
const isTemp = flags === "wx" && path.includes(".tmp-");
|
|
const isDirectory = flags === "r";
|
|
if (!(isTemp && (write || flush)) && !(isDirectory && directoryFlush)) return handle;
|
|
return {
|
|
write: async (...args) => {
|
|
if (isTemp && write) throw new Error("interrupted write");
|
|
return handle.write(...args);
|
|
},
|
|
sync: async () => {
|
|
if ((isTemp && flush) || (isDirectory && directoryFlush)) throw new Error("interrupted flush");
|
|
return handle.sync();
|
|
},
|
|
close: (...args) => handle.close(...args),
|
|
};
|
|
},
|
|
rename: async (...args) => {
|
|
if (rename) throw new Error("rename failed");
|
|
return defaultIo.rename(...args);
|
|
},
|
|
unlink: async (path) => {
|
|
if (cleanup) throw new Error("cleanup failed");
|
|
return defaultIo.unlink(path);
|
|
},
|
|
};
|
|
}
|
|
|
|
async function assertRejectsCode(promise, code) {
|
|
await assert.rejects(promise, (error) => {
|
|
assert.equal(error.code, code);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
test("creates a missing record and parent directory atomically with restrictive modes", async () => {
|
|
const directory = await temporaryDirectory();
|
|
const target = join(directory, "nested", "record.md");
|
|
const input = record();
|
|
|
|
const result = await checkpointRecord({ target, input });
|
|
|
|
assert.equal(result.created, true);
|
|
assert.equal(result.sha256, digest(input));
|
|
assert.equal(await fs.readFile(target, "utf8"), input);
|
|
assert.equal((await fs.stat(target)).mode & 0o777, 0o600);
|
|
assert.equal((await fs.stat(join(directory, "nested"))).mode & 0o777, 0o700);
|
|
assert.equal(await exists(`${target}.lock`), false);
|
|
assert.equal(
|
|
(await fs.readdir(join(directory, "nested"))).filter((name) => name.includes(".tmp-")).length,
|
|
0,
|
|
);
|
|
});
|
|
|
|
test("replaces a record only with its current SHA-256 digest", async () => {
|
|
const directory = await temporaryDirectory();
|
|
const target = join(directory, "record.md");
|
|
const before = record("Before");
|
|
const after = record("After");
|
|
await fs.writeFile(target, before, { mode: 0o600 });
|
|
|
|
const result = await checkpointRecord({ target, input: after, expectedSha256: digest(before) });
|
|
|
|
assert.equal(result.created, false);
|
|
assert.equal(await fs.readFile(target, "utf8"), after);
|
|
assert.equal(result.sha256, digest(after));
|
|
});
|
|
|
|
test("rejects malformed, missing, duplicate, and unknown sections before touching the target", async () => {
|
|
const cases = [
|
|
["missing H1", record().replace(/^# .*\n\n/, "")],
|
|
["missing section", record().replace("## Review\n\ncheckpoint data\n\n", "")],
|
|
["duplicate section", `${record()}\n## Status\n\nagain\n`],
|
|
["unknown section", `${record()}\n## Unrecognized\n\nnope\n`],
|
|
];
|
|
for (const [name, input] of cases) {
|
|
const directory = await temporaryDirectory();
|
|
const target = join(directory, `${name.replaceAll(" ", "-")}.md`);
|
|
await assertRejectsCode(checkpointRecord({ target, input }), "INVALID_RECORD");
|
|
assert.equal(await exists(target), false, `${name} created target`);
|
|
assert.equal(await exists(`${target}.lock`), false, `${name} created lock`);
|
|
}
|
|
});
|
|
|
|
test("requires a digest for replacement and rejects stale digests without changing bytes", async () => {
|
|
const directory = await temporaryDirectory();
|
|
const target = join(directory, "record.md");
|
|
const before = record("Before");
|
|
const after = record("After");
|
|
await fs.writeFile(target, before, { mode: 0o600 });
|
|
|
|
await assertRejectsCode(checkpointRecord({ target, input: after }), "EXPECTED_SHA256_REQUIRED");
|
|
await assertRejectsCode(
|
|
checkpointRecord({ target, input: after, expectedSha256: digest(record("Other")) }),
|
|
"STALE_DIGEST",
|
|
);
|
|
assert.equal(await fs.readFile(target, "utf8"), before);
|
|
assert.equal(await exists(`${target}.lock`), false);
|
|
});
|
|
|
|
test("serializes concurrent updates with an exclusive per-target lock", async () => {
|
|
const directory = await temporaryDirectory();
|
|
const target = join(directory, "record.md");
|
|
const lock = `${target}.lock`;
|
|
const first = record("First");
|
|
const second = record("Second");
|
|
let firstLockOpened;
|
|
const lockOpened = new Promise((resolve) => { firstLockOpened = resolve; });
|
|
let releaseFirstLock;
|
|
const firstLockHeld = new Promise((resolve) => { releaseFirstLock = resolve; });
|
|
let firstOpen = true;
|
|
const io = {
|
|
...defaultIo,
|
|
open: async (path, flags, mode) => {
|
|
const handle = await defaultIo.open(path, flags, mode);
|
|
if (path === lock && flags === "wx" && firstOpen) {
|
|
firstOpen = false;
|
|
firstLockOpened();
|
|
await firstLockHeld;
|
|
}
|
|
return handle;
|
|
},
|
|
};
|
|
|
|
const firstPromise = checkpointRecord({ target, input: first, io });
|
|
await lockOpened;
|
|
const secondPromise = checkpointRecord({ target, input: second, io });
|
|
await assertRejectsCode(secondPromise, "LOCK_EXISTS");
|
|
releaseFirstLock();
|
|
await firstPromise;
|
|
|
|
assert.equal(await fs.readFile(target, "utf8"), first);
|
|
assert.equal(await exists(`${target}.lock`), false);
|
|
});
|
|
|
|
test("fails closed on a pre-existing stale lock and leaves it for recovery", async () => {
|
|
const directory = await temporaryDirectory();
|
|
const target = join(directory, "record.md");
|
|
const lock = `${target}.lock`;
|
|
const input = record();
|
|
await fs.writeFile(lock, "stale lock", { mode: 0o600 });
|
|
|
|
await assertRejectsCode(checkpointRecord({ target, input }), "LOCK_EXISTS");
|
|
assert.equal(await fs.readFile(lock, "utf8"), "stale lock");
|
|
assert.equal(await exists(target), false);
|
|
});
|
|
|
|
test("preserves pre-existing stale temporary files", async () => {
|
|
const directory = await temporaryDirectory();
|
|
const target = join(directory, "record.md");
|
|
const stale = `${target}.tmp-stale-recovery`;
|
|
await fs.writeFile(stale, "recover me", { mode: 0o600 });
|
|
|
|
await checkpointRecord({ target, input: record() });
|
|
|
|
assert.equal(await fs.readFile(stale, "utf8"), "recover me");
|
|
assert.equal(await exists(`${target}.lock`), false);
|
|
});
|
|
|
|
test("cleans its own temporary file after an interrupted write and preserves the prior target", async () => {
|
|
const directory = await temporaryDirectory();
|
|
const target = join(directory, "record.md");
|
|
const before = record("Before");
|
|
await fs.writeFile(target, before, { mode: 0o600 });
|
|
|
|
await assertRejectsCode(
|
|
checkpointRecord({ target, input: record("After"), expectedSha256: digest(before), io: faultIo({ write: true }) }),
|
|
"WRITE_FAILED",
|
|
);
|
|
assert.equal(await fs.readFile(target, "utf8"), before);
|
|
assert.equal(await exists(`${target}.lock`), false);
|
|
assert.deepEqual((await fs.readdir(directory)).filter((name) => name.includes(".tmp-")), []);
|
|
});
|
|
|
|
test("cleans its own temporary file after a flush failure and does not mask the primary error", async () => {
|
|
const directory = await temporaryDirectory();
|
|
const target = join(directory, "record.md");
|
|
const before = record("Before");
|
|
await fs.writeFile(target, before, { mode: 0o600 });
|
|
const cleanupFailingIo = faultIo({ flush: true, cleanup: true });
|
|
|
|
await assertRejectsCode(
|
|
checkpointRecord({ target, input: record("After"), expectedSha256: digest(before), io: cleanupFailingIo }),
|
|
"TEMP_SYNC_FAILED",
|
|
);
|
|
assert.equal(await fs.readFile(target, "utf8"), before);
|
|
// Remove artifacts made intentionally unremovable by this fault adapter.
|
|
for (const name of await fs.readdir(directory)) await fs.unlink(join(directory, name));
|
|
});
|
|
|
|
test("rename failure leaves the previous target byte-for-byte intact", async () => {
|
|
const directory = await temporaryDirectory();
|
|
const target = join(directory, "record.md");
|
|
const before = record("Before");
|
|
await fs.writeFile(target, before, { mode: 0o600 });
|
|
|
|
// The adapter's rename failure is wrapped as an atomic operation error.
|
|
await assert.rejects(
|
|
checkpointRecord({ target, input: record("After"), expectedSha256: digest(before), io: faultIo({ rename: true }) }),
|
|
(error) => {
|
|
assert.equal(error.code, "RENAME_FAILED");
|
|
assert.match(error.message, /rename failed/);
|
|
return true;
|
|
},
|
|
);
|
|
assert.equal(await fs.readFile(target, "utf8"), before);
|
|
assert.equal(await exists(`${target}.lock`), false);
|
|
});
|
|
|
|
test("directory flush failure is reported after rename and leaves no lock or owned temp", async () => {
|
|
const directory = await temporaryDirectory();
|
|
const target = join(directory, "record.md");
|
|
const before = record("Before");
|
|
const after = record("After");
|
|
await fs.writeFile(target, before, { mode: 0o600 });
|
|
|
|
await assertRejectsCode(
|
|
checkpointRecord({ target, input: after, expectedSha256: digest(before), io: faultIo({ directoryFlush: true }) }),
|
|
"DIRECTORY_SYNC_FAILED",
|
|
);
|
|
assert.equal(await fs.readFile(target, "utf8"), after);
|
|
assert.equal(await exists(`${target}.lock`), false);
|
|
assert.deepEqual((await fs.readdir(directory)).filter((name) => name.includes(".tmp-")), []);
|
|
});
|
|
|
|
test("cleanup errors never mask a lock or write failure", async () => {
|
|
const directory = await temporaryDirectory();
|
|
const target = join(directory, "record.md");
|
|
const io = faultIo({ write: true, cleanup: true });
|
|
|
|
await assert.rejects(checkpointRecord({ target, input: record(), io }), /interrupted write/);
|
|
for (const name of await fs.readdir(directory)) await fs.unlink(join(directory, name));
|
|
});
|
|
|
|
test("strict CLI reports usage and operation errors with exit code 2", async () => {
|
|
const missingTarget = spawn(process.execPath, [scriptPath], { stdio: ["ignore", "pipe", "pipe"] });
|
|
const [missingResult] = await once(missingTarget, "close");
|
|
assert.equal(missingResult, 2);
|
|
|
|
const directory = await temporaryDirectory();
|
|
const target = join(directory, "record.md");
|
|
const child = spawn(process.execPath, [scriptPath, target], { stdio: ["pipe", "pipe", "pipe"] });
|
|
child.stdin.end(record());
|
|
const [result] = await once(child, "close");
|
|
assert.equal(result, 0);
|
|
assert.equal(await fs.readFile(target, "utf8"), record());
|
|
|
|
const inputPath = join(directory, "input.md");
|
|
const inputRecord = record("From input file");
|
|
await fs.writeFile(inputPath, inputRecord);
|
|
const inputTarget = join(directory, "input-target.md");
|
|
const inputChild = spawn(process.execPath, [scriptPath, inputTarget, "--input", inputPath], { stdio: ["ignore", "pipe", "pipe"] });
|
|
const [inputResult] = await once(inputChild, "close");
|
|
assert.equal(inputResult, 0);
|
|
assert.equal(await fs.readFile(inputTarget, "utf8"), inputRecord);
|
|
|
|
const bad = spawn(process.execPath, [scriptPath, target, "--expected-sha256", "bad"], { stdio: ["pipe", "pipe", "pipe"] });
|
|
bad.stdin.end(record("Changed"));
|
|
const [badResult] = await once(bad, "close");
|
|
assert.equal(badResult, 2);
|
|
});
|