Checkpoint web parity through Chromium input tasks
This commit is contained in:
163
web/tests/unit/library-archive-recovery.test.mjs
Normal file
163
web/tests/unit/library-archive-recovery.test.mjs
Normal file
@@ -0,0 +1,163 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "library-archive-recovery-unit-"));
|
||||
for (const sourceName of ["archive-extraction-transaction.ts", "archive-extraction-recovery.ts"]) {
|
||||
const sourcePath = path.join(root, "web/protocol", sourceName);
|
||||
const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName: sourcePath,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(transpiled.diagnostics, []);
|
||||
fs.writeFileSync(
|
||||
path.join(temporary, sourceName.replace(".ts", ".mjs")),
|
||||
transpiled.outputText.replaceAll('from "./archive-extraction-transaction"', 'from "./archive-extraction-transaction.mjs"'),
|
||||
);
|
||||
}
|
||||
const extraction = await import(pathToFileURL(path.join(temporary, "archive-extraction-recovery.mjs")));
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M12-04I/manifest.json"), "utf8"));
|
||||
const fileSha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(path.join(root, file))).digest("hex");
|
||||
const bytesSha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
class FaultingDirectoryStorage {
|
||||
constructor(directory, committed) {
|
||||
this.directory = directory;
|
||||
this.committed = { ...committed };
|
||||
this.fault = null;
|
||||
this.partialBytes = 0;
|
||||
fs.mkdirSync(path.join(directory, "committed"), { recursive: true });
|
||||
fs.writeFileSync(path.join(directory, "committed", "project.blend"), Buffer.from("old-project"));
|
||||
}
|
||||
|
||||
async readCommitted() { return { ...this.committed }; }
|
||||
|
||||
async createStaging(transactionId) {
|
||||
fs.mkdirSync(path.join(this.directory, "staging", transactionId), { recursive: true });
|
||||
}
|
||||
|
||||
async writeStaging(transactionId, entryPath, bytes) {
|
||||
const target = path.join(this.directory, "staging", transactionId, entryPath);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
if (this.fault) {
|
||||
const partial = bytes.subarray(0, Math.max(1, Math.floor(bytes.byteLength / 2)));
|
||||
fs.writeFileSync(target, partial);
|
||||
this.partialBytes += partial.byteLength;
|
||||
const fault = this.fault;
|
||||
this.fault = null;
|
||||
throw fault;
|
||||
}
|
||||
fs.writeFileSync(target, bytes);
|
||||
}
|
||||
|
||||
async countStagingEntries(transactionId) {
|
||||
const staging = path.join(this.directory, "staging", transactionId);
|
||||
if (!fs.existsSync(staging)) return 0;
|
||||
const visit = (directory) => fs.readdirSync(directory, { withFileTypes: true }).reduce(
|
||||
(count, entry) => count + (entry.isDirectory() ? visit(path.join(directory, entry.name)) : 1), 0,
|
||||
);
|
||||
return visit(staging);
|
||||
}
|
||||
|
||||
async discardStaging(transactionId) {
|
||||
const count = await this.countStagingEntries(transactionId);
|
||||
fs.rmSync(path.join(this.directory, "staging", transactionId), { recursive: true, force: true });
|
||||
return count;
|
||||
}
|
||||
|
||||
async commitStaging(transactionId, expected, candidate) {
|
||||
assert.deepEqual(this.committed, expected);
|
||||
const source = path.join(this.directory, "staging", transactionId, "project.blend");
|
||||
const target = path.join(this.directory, "committed", "project.blend");
|
||||
fs.renameSync(source, target);
|
||||
await this.discardStaging(transactionId);
|
||||
this.committed = { ...candidate };
|
||||
return { ...this.committed };
|
||||
}
|
||||
}
|
||||
|
||||
const oldBytes = Buffer.from("old-project");
|
||||
const largeBytes = Buffer.alloc(4096, 0x51);
|
||||
const smallBytes = Buffer.from("small-project");
|
||||
const committed = { projectId: "project:m12-04i", revision: 9, sha256: bytesSha256(oldBytes) };
|
||||
|
||||
function request(transactionId, base, bytes) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
transactionId,
|
||||
archiveId: `archive:${transactionId}`,
|
||||
committed: base,
|
||||
candidate: { projectId: base.projectId, revision: base.revision + 1, sha256: bytesSha256(bytes) },
|
||||
entries: [{ path: "project.blend", uncompressedBytes: bytes.byteLength, sha256: bytesSha256(bytes) }],
|
||||
};
|
||||
}
|
||||
|
||||
function createStorage(name) {
|
||||
const directory = path.join(temporary, name);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
return new FaultingDirectoryStorage(directory, committed);
|
||||
}
|
||||
|
||||
async function faultThenRecover(name, fault, expectedCode) {
|
||||
const storage = createStorage(name);
|
||||
storage.fault = fault;
|
||||
const failedRequest = request(`transaction:${name}:large`, committed, largeBytes);
|
||||
await assert.rejects(
|
||||
extraction.runArchiveExtractionTransaction(failedRequest, storage, async () => largeBytes, new AbortController().signal),
|
||||
{ code: expectedCode },
|
||||
);
|
||||
assert.ok(storage.partialBytes > 0);
|
||||
assert.equal(await storage.countStagingEntries(failedRequest.transactionId), 0);
|
||||
assert.deepEqual(await storage.readCommitted(), committed);
|
||||
assert.equal(fs.readFileSync(path.join(storage.directory, "committed", "project.blend"), "utf8"), "old-project");
|
||||
|
||||
const recoveryRequest = request(`transaction:${name}:small`, committed, smallBytes);
|
||||
const receipt = await extraction.runArchiveExtractionTransaction(
|
||||
recoveryRequest,
|
||||
storage,
|
||||
async () => smallBytes,
|
||||
new AbortController().signal,
|
||||
);
|
||||
assert.deepEqual(receipt, {
|
||||
status: "COMMITTED",
|
||||
transactionId: recoveryRequest.transactionId,
|
||||
committed: recoveryRequest.candidate,
|
||||
stagingEntriesAfter: 0,
|
||||
});
|
||||
assert.equal(await storage.countStagingEntries(recoveryRequest.transactionId), 0);
|
||||
assert.equal(fs.readFileSync(path.join(storage.directory, "committed", "project.blend"), "utf8"), "small-project");
|
||||
}
|
||||
|
||||
test("M12-04I binds quota/OOM cleanup and recovery evidence", () => {
|
||||
assert.equal(manifest.task, "M12-04I");
|
||||
assert.equal(manifest.parentTask, "M12-04H");
|
||||
assert.equal(manifest.nextTask, "M12-04J");
|
||||
assert.deepEqual(manifest.assertions.faultCodes, ["STORAGE_QUOTA", "WASM_OUT_OF_MEMORY"]);
|
||||
for (const artifact of Object.values(manifest.artifacts)) assert.equal(fileSha256(artifact.path), artifact.sha256, artifact.path);
|
||||
});
|
||||
|
||||
test("M12-04I releases partial staging after quota and accepts a small archive in the same storage", async () => {
|
||||
await faultThenRecover("quota", new DOMException("quota exhausted", "QuotaExceededError"), "STORAGE_QUOTA");
|
||||
});
|
||||
|
||||
test("M12-04I releases partial staging after OOM and accepts a small archive in the same storage", async () => {
|
||||
await faultThenRecover("oom", Object.assign(new Error("deterministic allocation failure"), { code: "WASM_OUT_OF_MEMORY" }), "WASM_OUT_OF_MEMORY");
|
||||
});
|
||||
|
||||
test("M12-04I maps only declared resource faults", () => {
|
||||
assert.equal(extraction.archiveExtractionResourceFaultCode(new DOMException("full", "QuotaExceededError")), "STORAGE_QUOTA");
|
||||
assert.equal(extraction.archiveExtractionResourceFaultCode(Object.assign(new Error("fault"), { code: "STORAGE_QUOTA" })), "STORAGE_QUOTA");
|
||||
assert.equal(extraction.archiveExtractionResourceFaultCode(Object.assign(new Error("fault"), { code: "WASM_OUT_OF_MEMORY" })), "WASM_OUT_OF_MEMORY");
|
||||
assert.equal(extraction.archiveExtractionResourceFaultCode(Object.assign(new Error("fault"), { name: "OutOfMemoryError" })), "WASM_OUT_OF_MEMORY");
|
||||
assert.equal(extraction.archiveExtractionResourceFaultCode(new RangeError("array length is invalid")), undefined);
|
||||
assert.equal(extraction.archiveExtractionResourceFaultCode(new Error("ordinary IO failure")), undefined);
|
||||
});
|
||||
|
||||
test.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
Reference in New Issue
Block a user