379 lines
22 KiB
JavaScript
379 lines
22 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { createHash } from "node:crypto";
|
|
import { constants } from "node:fs";
|
|
import { copyFile, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
// This promoter intentionally handles only the HOTO v1 -> v2 metadata update.
|
|
// It has no effect unless invoked with the explicit --apply flag.
|
|
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const reviewDir = "artifacts/playtest/rom-deep-review/uv-stretch-review";
|
|
const stageDir = `${reviewDir}/staged`;
|
|
const stageReportPath = `${stageDir}/hoto-surface-quality-v2-metadata-stage-report.json`;
|
|
const visualProofPath = `${reviewDir}/hoto-surface-v2-visual-proof.json`;
|
|
const sourceLayoutProofPath = `${stageDir}/hoto-surface-v2-source-layout-proof.json`;
|
|
const independentLayoutProofPath = `${stageDir}/hoto-v2-root-source-layout-proof.json`;
|
|
const textureQualityProofPath = `${stageDir}/compressed-source-texture-fidelity-policy.json`;
|
|
const policyId = "rom-hoto-source-rock-detail-v2";
|
|
const sourceModel = "model/dungeon/echoes_of_the_sea/act_echoes_of_the_sea.ros";
|
|
const sourceRosSha256 = "a0bd77c09045f8bfa1507484bbb2c2ff0cc72d397df4f769fc4e6d5aab14f8a2";
|
|
const canonicalOldSha256 = "1015a8483096c551b4600f772f5f4cc888b3a7ab93dd58fe199cf113488e0078";
|
|
const canonicalCandidateSha256 = "5f4329e5dce0651ec30861d3140c130ddf646aac920670aaa6ba901682d258b6";
|
|
const optimizedOldSha256 = "f745c41b834647ff660db96665a9c474772162db2382c5a5ffa1a41e6a7e841c";
|
|
const optimizedCandidateSha256 = "b80b60cba6e5a0eb3e13bd7e048f9408f21bf870c0c81fc8de5c195d3a3eaee3";
|
|
const reviewedProofHashes = new Map([
|
|
[stageReportPath, "a896070024f0e03eaef952357e76a3da04b23490da0d12f57fe795eeb253e958"],
|
|
[visualProofPath, "da90fb979266efd65ac4a9dd3e8a2447c9c5cc5200520fed60d5547482eb0d51"],
|
|
[sourceLayoutProofPath, "d508f442ce3e95b6eed1f628cf4bfad250434f37b15b538c55e5c078b1610297"],
|
|
[independentLayoutProofPath, "d508f442ce3e95b6eed1f628cf4bfad250434f37b15b538c55e5c078b1610297"],
|
|
[textureQualityProofPath, "ce7d787e502c6283e6bcd64d8c801dfc6724e084d7ff807a3a67063a833e54ca"],
|
|
]);
|
|
const metadataBaselines = new Map([
|
|
["src/assets/game/dungeons/hoto/import-metadata.json", "1e2525fc5c9320125beeb5f5536d8be258c63b0b1bff0fcf958321f8da1bf362"],
|
|
["src/assets/game/dungeons/hoto/optimization-report.json", "a23ab1161a743b679eceeefe576ca55c00fb7f672ec071aaa30e7b669bfe7c44"],
|
|
["src/assets/game/dungeons/hoto/conversion-report.json", "2abb3bda4b758f05c993f80bf09bc880e7d0d6b78d33c257d6da229713ddf723"],
|
|
["runewaker-export-work/hoto/converted/optimization-report.json", "317ec5195ac2f8e8f6c96860cf92905564c5274deacef5c5ffde28165909dbeb"],
|
|
["runewaker-export-work/hoto/converted/conversion-report.json", "2abb3bda4b758f05c993f80bf09bc880e7d0d6b78d33c257d6da229713ddf723"],
|
|
]);
|
|
const canonicalFile = "runewaker-export-work/hoto/converted/hoto-visual.glb";
|
|
const optimizedFile = "runewaker-export-work/hoto/converted/hoto-visual.optimized.glb";
|
|
const shippingFile = "src/assets/game/dungeons/hoto/hoto-visual.glb";
|
|
const targetMaterialNames = [
|
|
"runewaker_material_96", "runewaker_material_53", "runewaker_material_26", "runewaker_material_18",
|
|
];
|
|
const carrierNames = ["rom-detail-hoto-floor:source-tile-carrier", "rom-detail-hoto-wall:source-tile-carrier"];
|
|
const approvedMetadataKeys = ["runewakerSurfaceDetail", "runewakerSurfaceDetailCarrier"];
|
|
const reviewedScreenshotHashes = new Map([
|
|
[`${reviewDir}/hoto-surface-v2-entry-shipped.png`, "b410b8866b3b0732ebe8888eae7c6dea4f9529c8272d6ff47c85d14292363b0c"],
|
|
[`${reviewDir}/hoto-surface-v2-entry-candidate.png`, "e68f92d523198700265774c6ad07a8bd0e1372fbf45f4dd28e71787b3062bcb2"],
|
|
[`${reviewDir}/hoto-surface-v2-material26-low-density-floor-shipped.png`, "63a1e7832767c952ae927e6a675e8c660a25d6d6de12b94ffb4a54e1339b7224"],
|
|
[`${reviewDir}/hoto-surface-v2-material26-low-density-floor-candidate.png`, "6cc22165911aba6835ab0d23e8bf16b1ef5b0992099bcab177d9d57ca11bb841"],
|
|
]);
|
|
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
const readRepo = (file) => readFile(path.resolve(repo, file));
|
|
const readJson = async (file) => JSON.parse((await readRepo(file)).toString("utf8"));
|
|
const repoPath = (file) => path.resolve(repo, file);
|
|
|
|
function parseGlb(bytes, label) {
|
|
assert.equal(bytes.toString("ascii", 0, 4), "glTF", `${label} is not a GLB.`);
|
|
assert.equal(bytes.readUInt32LE(4), 2, `${label} is not glTF 2.0.`);
|
|
assert.equal(bytes.readUInt32LE(8), bytes.length, `${label} header length is invalid.`);
|
|
const chunks = [];
|
|
for (let offset = 12; offset < bytes.length;) {
|
|
assert.ok(offset + 8 <= bytes.length, `${label} has a truncated chunk header.`);
|
|
const length = bytes.readUInt32LE(offset);
|
|
const type = bytes.readUInt32LE(offset + 4);
|
|
const data = Buffer.from(bytes.subarray(offset + 8, offset + 8 + length));
|
|
assert.equal(data.length, length, `${label} has a truncated chunk.`);
|
|
chunks.push({ type, data });
|
|
offset += 8 + length;
|
|
}
|
|
const jsonChunk = chunks.find((chunk) => chunk.type === 0x4e4f534a);
|
|
assert.ok(jsonChunk, `${label} has no JSON chunk.`);
|
|
return {
|
|
chunks,
|
|
json: JSON.parse(jsonChunk.data.toString("utf8").trim()),
|
|
nonJsonChunks: chunks.filter((chunk) => chunk.type !== 0x4e4f534a),
|
|
};
|
|
}
|
|
|
|
function withoutApprovedMetadata(json) {
|
|
const copy = structuredClone(json);
|
|
for (const material of copy.materials ?? []) {
|
|
if (!material.extras) continue;
|
|
if (targetMaterialNames.includes(material.name)) delete material.extras.runewakerSurfaceDetail;
|
|
if (carrierNames.includes(material.name)) delete material.extras.runewakerSurfaceDetailCarrier;
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
function verifyOnlyReviewedMetadataChanged(source, candidate, label, stageEntry) {
|
|
const before = parseGlb(source, `${label} baseline`);
|
|
const after = parseGlb(candidate, `${label} candidate`);
|
|
assert.deepEqual(withoutApprovedMetadata(after.json), withoutApprovedMetadata(before.json),
|
|
`${label} JSON changed outside the six approved HOTO metadata fields.`);
|
|
const beforeChunks = before.nonJsonChunks.map((chunk) => [chunk.type, digest(chunk.data), chunk.data.length]);
|
|
const afterChunks = after.nonJsonChunks.map((chunk) => [chunk.type, digest(chunk.data), chunk.data.length]);
|
|
assert.deepEqual(afterChunks, beforeChunks, `${label} non-JSON GLB chunks changed.`);
|
|
assert.equal(stageEntry.nonJsonChunksByteIdentical, true);
|
|
assert.deepEqual(stageEntry.nonJsonChunks.map((chunk) => [Number(chunk.type), chunk.sha256, chunk.bytes]), beforeChunks,
|
|
`${label} stage report does not match the byte-identity proof.`);
|
|
const materialByName = new Map((after.json.materials ?? []).map((material) => [material.name, material]));
|
|
return { before, after, materialByName };
|
|
}
|
|
|
|
function expectedCarrierMetadata(variantName, sourceProof) {
|
|
const source = sourceProof;
|
|
return {
|
|
version: 1,
|
|
variantName,
|
|
policyId,
|
|
sourceResourceSha256: sourceRosSha256,
|
|
sourceMaterial: source.nativeMaterial,
|
|
canonicalMaterialName: source.canonicalMaterialName,
|
|
sourceTexturePath: source.sourceTexture,
|
|
sourceTextureName: source.sourceImage,
|
|
sourcePreparedPngSha256: source.sourcePreparedPngSha256,
|
|
};
|
|
}
|
|
|
|
const stageReportBytes = await readRepo(stageReportPath);
|
|
const visualProofBytes = await readRepo(visualProofPath);
|
|
for (const [file, expectedSha] of reviewedProofHashes) {
|
|
assert.equal(digest(await readRepo(file)), expectedSha, `${file} differs from the frozen reviewed proof.`);
|
|
}
|
|
const stageReport = JSON.parse(stageReportBytes.toString("utf8"));
|
|
const visualProof = JSON.parse(visualProofBytes.toString("utf8"));
|
|
assert.equal(stageReport.status, "staged-metadata-only-not-promoted");
|
|
assert.equal(stageReport.policyId, policyId);
|
|
assert.equal(stageReport.source.sourceModel, sourceModel);
|
|
assert.equal(stageReport.source.sourceRosSha256, sourceRosSha256);
|
|
assert.equal(stageReport.freshPolicySmoke.status, "applied");
|
|
assert.equal(stageReport.migrationSmoke.status, "passed");
|
|
assert.equal(stageReport.migrationSmoke.firstApplication, "migrated-v1");
|
|
assert.equal(stageReport.migrationSmoke.secondApplication, "already-applied");
|
|
assert.deepEqual(stageReport.metadataScope.materials, targetMaterialNames);
|
|
assert.deepEqual(stageReport.metadataScope.carriers, carrierNames);
|
|
assert.deepEqual(stageReport.metadataScope.changedFields, approvedMetadataKeys);
|
|
assert.equal(stageReport.metadataScope.decodedGeometryUvTexturesAndAllNonJsonChunksUnchanged, true);
|
|
assert.equal(stageReport.metadataScope.level1CompressorOrGeometryOptimizerRun, false);
|
|
assert.equal(stageReport.historicalVisualEvidence.v1FinalScreenshots, "superseded-and-unavailable");
|
|
|
|
const sourceRosBytes = await readFile(path.resolve(repo, stageReport.source.sourceRos));
|
|
assert.equal(digest(sourceRosBytes), sourceRosSha256, "The pinned HOTO source ROS changed.");
|
|
const recipe = await readJson("scripts/runewaker-pipeline/recipes/hoto.json");
|
|
assert.deepEqual(recipe.visualSurfaceQualityPolicies, [policyId], "HOTO recipe does not select the durable v2 policy.");
|
|
|
|
// Verify every source image identity that the staged surface policy cites.
|
|
const sourcePngs = new Map();
|
|
for (const metadata of Object.values(stageReport.freshPolicySmoke.targetMetadata)) {
|
|
const proof = metadata.sourceProof;
|
|
assert.equal(proof.sourceModelSha256, sourceRosSha256);
|
|
for (const source of [proof, proof.floorDetailSource, proof.wallDetailSource].filter(Boolean)) {
|
|
const imageName = source.sourceTextureName ?? source.sourceImage;
|
|
const sha256 = source.sourcePreparedPngSha256;
|
|
if (!imageName || !sha256) continue;
|
|
const prior = sourcePngs.get(imageName);
|
|
if (prior) assert.equal(prior, sha256);
|
|
else sourcePngs.set(imageName, sha256);
|
|
}
|
|
}
|
|
for (const [imageName, expectedSha] of sourcePngs) {
|
|
const png = await readRepo(`runewaker-export-work/hoto/prepared/textures/${imageName}.png`);
|
|
assert.equal(digest(png), expectedSha, `Prepared source image ${imageName} changed.`);
|
|
}
|
|
|
|
const canonicalSource = await readRepo(canonicalFile);
|
|
const optimizedSource = await readRepo(optimizedFile);
|
|
const shippingSource = await readRepo(shippingFile);
|
|
const canonicalCandidate = await readRepo(stageReport.canonical.candidate);
|
|
const optimizedCandidate = await readRepo(stageReport.optimized.candidate);
|
|
assert.equal(digest(canonicalSource), canonicalOldSha256);
|
|
assert.equal(digest(optimizedSource), optimizedOldSha256);
|
|
assert.equal(digest(shippingSource), optimizedOldSha256);
|
|
assert.equal(digest(canonicalCandidate), canonicalCandidateSha256);
|
|
assert.equal(digest(optimizedCandidate), optimizedCandidateSha256);
|
|
assert.equal(canonicalCandidate.length, stageReport.canonical.candidateBytes);
|
|
assert.equal(optimizedCandidate.length, stageReport.optimized.candidateBytes);
|
|
assert.equal(stageReport.canonical.originalSha256, canonicalOldSha256);
|
|
assert.equal(stageReport.optimized.originalSha256, optimizedOldSha256);
|
|
assert.equal(stageReport.shipping.sha256, optimizedOldSha256);
|
|
assert.equal(stageReport.canonical.candidateSha256, canonicalCandidateSha256);
|
|
assert.equal(stageReport.optimized.candidateSha256, optimizedCandidateSha256);
|
|
|
|
const canonical = verifyOnlyReviewedMetadataChanged(canonicalSource, canonicalCandidate, "canonical HOTO", stageReport.canonical);
|
|
const optimized = verifyOnlyReviewedMetadataChanged(optimizedSource, optimizedCandidate, "optimized HOTO", stageReport.optimized);
|
|
const targetMetadata = stageReport.freshPolicySmoke.targetMetadata;
|
|
for (const name of targetMaterialNames) {
|
|
assert.deepEqual(canonical.materialByName.get(name)?.extras?.runewakerSurfaceDetail, targetMetadata[name],
|
|
`Canonical ${name} metadata differs from the source-pinned policy output.`);
|
|
assert.deepEqual(optimized.materialByName.get(name)?.extras?.runewakerSurfaceDetail, targetMetadata[name],
|
|
`Optimized ${name} metadata differs from the source-pinned policy output.`);
|
|
}
|
|
const carriers = [
|
|
[carrierNames[0], "rom-detail-hoto-floor", targetMetadata.runewaker_material_26.sourceProof.floorDetailSource],
|
|
[carrierNames[1], "rom-detail-hoto-wall", targetMetadata.runewaker_material_18.sourceProof.wallDetailSource],
|
|
];
|
|
for (const [name, variantName, sourceProof] of carriers) {
|
|
const expected = expectedCarrierMetadata(variantName, sourceProof);
|
|
for (const document of [canonical, optimized]) {
|
|
assert.deepEqual(document.materialByName.get(name)?.extras?.runewakerSurfaceDetailCarrier, expected,
|
|
`${name} carrier source provenance changed.`);
|
|
}
|
|
}
|
|
assert.equal(canonical.after.json.images.length, 17, "Canonical candidate changed the source image inventory.");
|
|
assert.equal(optimized.after.json.images.length, 17, "Optimized candidate changed the source image inventory.");
|
|
|
|
const sourceLayoutProof = await readJson(sourceLayoutProofPath);
|
|
const independentLayoutProof = await readJson(independentLayoutProofPath);
|
|
for (const proof of [sourceLayoutProof, independentLayoutProof]) {
|
|
assert.equal(digest(await readRepo(proof.beforeFile)), proof.beforeSha256);
|
|
assert.equal(proof.beforeSha256, canonicalOldSha256);
|
|
assert.equal(proof.afterSha256, canonicalCandidateSha256);
|
|
assert.equal(proof.hierarchyAndTransformsPreserved, true);
|
|
assert.equal(proof.orderedSourceTriangleCornersPreserved, true);
|
|
assert.equal(proof.allSourceAttributesExceptAdaptedUv1Preserved, true);
|
|
assert.equal(proof.originalSourceImagesPreserved, true);
|
|
assert.equal(proof.triangleCount, 189855);
|
|
assert.equal(proof.originalSourceImageCount, 17);
|
|
assert.deepEqual(proof.addedSourceRegionImages, []);
|
|
}
|
|
|
|
const textureQualityProof = await readJson(textureQualityProofPath);
|
|
const historicalCodec = textureQualityProof.results.find((result) => result.id === "hoto");
|
|
assert.ok(historicalCodec, "Historical HOTO codec proof is missing.");
|
|
assert.equal(historicalCodec.glbSha256, optimizedOldSha256);
|
|
assert.equal(historicalCodec.textures.length, 4);
|
|
assert.ok(historicalCodec.textures.every((texture) => texture.maeAlpha === 0));
|
|
assert.ok(historicalCodec.textures.some((texture) => texture.name === "5672f062_echoes_of_the_sea_02" && texture.width === 1024 && texture.height === 1024));
|
|
assert.ok(historicalCodec.textures.some((texture) => texture.name === "b2ababb9_echoes_of_the_sea_03" && texture.width === 1024 && texture.height === 1024));
|
|
assert.deepEqual(optimized.after.nonJsonChunks.map((chunk) => [chunk.type, digest(chunk.data)]),
|
|
parseGlb(optimizedSource, "old optimized HOTO").nonJsonChunks.map((chunk) => [chunk.type, digest(chunk.data)]),
|
|
"The v2 metadata-only candidate no longer carries the historically verified KTX2 bytes.");
|
|
|
|
assert.equal(visualProof.status, "passed");
|
|
assert.equal(visualProof.review?.status, "accepted", "The parent review acceptance is not recorded.");
|
|
assert.deepEqual(visualProof.review.views, ["entry", "material26-low-density-floor"]);
|
|
const visualRows = visualProof.results;
|
|
assert.equal(visualRows.length, 4);
|
|
for (const viewName of visualProof.review.views) {
|
|
const pair = visualRows.filter((row) => row.view === viewName);
|
|
const shipped = pair.find((row) => row.stage === "shipped");
|
|
const candidate = pair.find((row) => row.stage === "candidate");
|
|
assert.ok(shipped && candidate, `Missing the shipped/candidate ${viewName} view pair.`);
|
|
assert.deepEqual(shipped.camera, candidate.camera, `${viewName} camera changed between captures.`);
|
|
assert.deepEqual(shipped.errors, []);
|
|
assert.deepEqual(candidate.errors, []);
|
|
assert.equal(shipped.glbSha256, optimizedOldSha256);
|
|
assert.equal(candidate.glbSha256, optimizedCandidateSha256);
|
|
assert.equal(shipped.screenshot, `${reviewDir}/hoto-surface-v2-${viewName}-shipped.png`);
|
|
assert.equal(candidate.screenshot, `${reviewDir}/hoto-surface-v2-${viewName}-candidate.png`);
|
|
for (const row of [shipped, candidate]) {
|
|
const screenshot = await readRepo(row.screenshot);
|
|
const expectedSha = reviewedScreenshotHashes.get(row.screenshot);
|
|
assert.ok(expectedSha, `No reviewed screenshot hash is pinned for ${row.screenshot}.`);
|
|
assert.equal(row.screenshotSha256, expectedSha, `${row.screenshot} report hash changed.`);
|
|
assert.equal(digest(screenshot), expectedSha, `${row.screenshot} image bytes changed.`);
|
|
assert.ok(screenshot.length > 1000);
|
|
}
|
|
assert.ok(candidate.candidateResponses.some((response) => response.status === 200 && response.bodySha256 === optimizedCandidateSha256));
|
|
assert.ok(candidate.timing?.sampleFrames?.averageFps > 0);
|
|
}
|
|
|
|
const visualSummary = visualRows.map((row) => ({
|
|
view: row.view, stage: row.stage, camera: row.camera, screenshot: row.screenshot,
|
|
screenshotSha256: row.screenshotSha256, glbSha256: row.glbSha256, drawCalls: row.drawCalls, timing: row.timing,
|
|
}));
|
|
const review = {
|
|
status: "reviewed-metadata-only",
|
|
policyId,
|
|
sourceModel,
|
|
sourceRosSha256,
|
|
stageReport: stageReportPath,
|
|
sourceLayoutProof: sourceLayoutProofPath,
|
|
independentSourceLayoutProof: independentLayoutProofPath,
|
|
historicalTextureQualityProof: textureQualityProofPath,
|
|
visualProof: visualProofPath,
|
|
visualReview: visualProof.review,
|
|
historicalVisualEvidence: stageReport.historicalVisualEvidence,
|
|
canonical: { file: canonicalFile, beforeSha256: canonicalOldSha256, candidate: stageReport.canonical.candidate, candidateSha256: canonicalCandidateSha256, candidateBytes: canonicalCandidate.length },
|
|
optimized: { file: optimizedFile, beforeSha256: optimizedOldSha256, candidate: stageReport.optimized.candidate, candidateSha256: optimizedCandidateSha256, candidateBytes: optimizedCandidate.length },
|
|
shipping: { file: shippingFile, beforeSha256: optimizedOldSha256, afterSha256: optimizedCandidateSha256 },
|
|
nonJsonChunksByteIdentical: true,
|
|
metadataOnlyFields: approvedMetadataKeys,
|
|
migrationSmoke: stageReport.migrationSmoke,
|
|
visualRows: visualSummary,
|
|
};
|
|
|
|
const metadataEdits = [];
|
|
for (const [file, expectedSha] of metadataBaselines) {
|
|
const bytes = await readRepo(file);
|
|
assert.equal(digest(bytes), expectedSha, `${file} changed after this promoter was reviewed.`);
|
|
const data = JSON.parse(bytes.toString("utf8"));
|
|
if (file.endsWith("import-metadata.json")) {
|
|
const visualAsset = data.assets?.find((asset) => asset.role === "visual");
|
|
assert.equal(visualAsset?.sha256, optimizedOldSha256);
|
|
assert.equal(visualAsset.size, shippingSource.length);
|
|
visualAsset.sha256 = optimizedCandidateSha256;
|
|
visualAsset.size = optimizedCandidate.length;
|
|
data.continuationReview = { ...data.continuationReview, surfaceQuality: review };
|
|
} else if (file.endsWith("optimization-report.json")) {
|
|
assert.equal(data.source?.checksum, canonicalOldSha256);
|
|
assert.equal(data.optimized?.checksum, optimizedOldSha256);
|
|
data.source = { ...data.source, checksum: canonicalCandidateSha256, size: canonicalCandidate.length };
|
|
data.optimized = { ...data.optimized, checksum: optimizedCandidateSha256, size: optimizedCandidate.length };
|
|
data.supersededSurfaceQualityV1 ??= data.visualUvAdaptations?.surfaceQuality;
|
|
data.visualUvAdaptations ??= {};
|
|
data.visualUvAdaptations.surfaceQuality = review;
|
|
data.surfaceQuality = review;
|
|
// textureQuality remains a historical report for the old optimized SHA;
|
|
// the candidate's KTX2 and all other BIN bytes are proven identical above.
|
|
} else {
|
|
data.continuationIntegrityProof = {
|
|
...data.continuationIntegrityProof,
|
|
surfaceQuality: review,
|
|
};
|
|
}
|
|
metadataEdits.push({ file, expectedSha, bytes: Buffer.from(`${JSON.stringify(data, null, 2)}\n`, "utf8") });
|
|
}
|
|
|
|
const apply = process.argv.includes("--apply");
|
|
const preview = {
|
|
ready: true,
|
|
applied: false,
|
|
dungeonId: "hoto",
|
|
policyId,
|
|
assetCopies: [
|
|
{ file: canonicalFile, sha256: canonicalOldSha256, candidateSha256: canonicalCandidateSha256, bytes: canonicalCandidate.length },
|
|
{ file: optimizedFile, sha256: optimizedOldSha256, candidateSha256: optimizedCandidateSha256, bytes: optimizedCandidate.length },
|
|
{ file: shippingFile, sha256: optimizedOldSha256, candidateSha256: optimizedCandidateSha256, bytes: optimizedCandidate.length },
|
|
],
|
|
metadataFiles: metadataEdits.map(({ file, expectedSha, bytes }) => ({ file, sha256: expectedSha, candidateBytes: bytes.length })),
|
|
review,
|
|
};
|
|
if (!apply) {
|
|
console.log(JSON.stringify(preview, null, 2));
|
|
process.exit(0);
|
|
}
|
|
|
|
const backupRoot = `${reviewDir}/pre-hoto-surface-v2-promotion-backup`;
|
|
const proofPath = `${reviewDir}/hoto-surface-v2-promotion-proof.json`;
|
|
for (const pathToCheck of [backupRoot, proofPath]) {
|
|
await stat(repoPath(pathToCheck)).then(
|
|
() => assert.fail(`Refusing to reuse existing promotion output: ${pathToCheck}`),
|
|
(error) => assert.equal(error.code, "ENOENT", `Unable to verify promotion output path ${pathToCheck}.`),
|
|
);
|
|
}
|
|
await mkdir(repoPath(backupRoot), { recursive: false });
|
|
const protectedFiles = [canonicalFile, optimizedFile, shippingFile, ...metadataEdits.map((edit) => edit.file)];
|
|
for (const file of protectedFiles) {
|
|
const backupPath = repoPath(path.join(backupRoot, file).replaceAll("\\", "/"));
|
|
await mkdir(path.dirname(backupPath), { recursive: true });
|
|
await copyFile(repoPath(file), backupPath, constants.COPYFILE_EXCL);
|
|
}
|
|
|
|
// Stage replacement bytes to exclusive sibling files before swapping any live
|
|
// path. The reviewed backup above is the recovery point if a later rename fails.
|
|
const replacements = [
|
|
{ file: canonicalFile, bytes: canonicalCandidate },
|
|
{ file: optimizedFile, bytes: optimizedCandidate },
|
|
{ file: shippingFile, bytes: optimizedCandidate },
|
|
...metadataEdits,
|
|
];
|
|
const stagedReplacements = [];
|
|
for (let index = 0; index < replacements.length; index += 1) {
|
|
const replacement = replacements[index];
|
|
const temp = `${replacement.file}.surface-v2-${process.pid}-${index}.tmp`;
|
|
await stat(repoPath(temp)).then(
|
|
() => assert.fail(`Refusing to overwrite a staged promotion file: ${temp}`),
|
|
(error) => assert.equal(error.code, "ENOENT", `Unable to verify staged replacement path ${temp}.`),
|
|
);
|
|
if (replacement.bytes) await writeFile(repoPath(temp), replacement.bytes, { flag: "wx" });
|
|
else await copyFile(repoPath(replacement.candidate), repoPath(temp), constants.COPYFILE_EXCL);
|
|
stagedReplacements.push({ file: replacement.file, temp });
|
|
}
|
|
for (const replacement of stagedReplacements) await rename(repoPath(replacement.temp), repoPath(replacement.file));
|
|
await writeFile(repoPath(proofPath), `${JSON.stringify({ ...preview, applied: true, backupRoot: backupRoot, proofPath }, null, 2)}\n`, { flag: "wx" });
|
|
console.log(JSON.stringify({ ...preview, applied: true, backupRoot, proofPath }, null, 2));
|