Complete V1 RC deployment capability gates
This commit is contained in:
63
tools/web/check-deployment-contract.mjs
Normal file
63
tools/web/check-deployment-contract.mjs
Normal file
@@ -0,0 +1,63 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const contractPath = path.join(root, "docs/web/deployment-contract.json");
|
||||
const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"));
|
||||
const guide = fs.readFileSync(path.join(root, "docs/web/DEPLOYMENT.md"), "utf8");
|
||||
const vite = fs.readFileSync(path.join(root, "web/app/vite.config.ts"), "utf8");
|
||||
const releaseCreator = fs.readFileSync(path.join(root, "tools/web/create-offline-release.mjs"), "utf8");
|
||||
|
||||
assert.equal(contract.schemaVersion, 1);
|
||||
assert.equal(contract.product, "Web Blender Modeler V1");
|
||||
assert.equal(contract.browserScope, "Chromium");
|
||||
assert.deepEqual(contract.methods, ["GET", "HEAD"]);
|
||||
assert.equal(contract.transport.production, "https");
|
||||
assert.equal(contract.transport.localDevelopment, "http://127.0.0.1");
|
||||
assert.equal(contract.transport.fileProtocolSupported, false);
|
||||
assert.equal(contract.transport.sameOriginRuntimeAssets, true);
|
||||
|
||||
assert.deepEqual(contract.responseHeaders.allResponses, {
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp",
|
||||
"Cross-Origin-Resource-Policy": "same-origin",
|
||||
});
|
||||
const routes = Object.fromEntries(contract.responseHeaders.routes.map((route) => [route.id, route]));
|
||||
assert.deepEqual(routes["entry-document"], { id: "entry-document", patterns: ["/", "/index.html"], cacheControl: "no-cache" });
|
||||
assert.deepEqual(routes["runtime-manifest"], { id: "runtime-manifest", patterns: ["/engine-manifest.json"], cacheControl: "no-cache" });
|
||||
assert.deepEqual(routes["content-hashed-assets"], { id: "content-hashed-assets", patterns: ["/assets/*"], cacheControl: "public, max-age=31536000, immutable" });
|
||||
assert.deepEqual(routes["stable-engine-assets"], { id: "stable-engine-assets", patterns: ["/vendor/blender/*"], cacheControl: "no-cache" });
|
||||
assert.deepEqual(routes.fallback, { id: "fallback", patterns: ["*"], cacheControl: "no-cache" });
|
||||
|
||||
for (const [extension, mime] of Object.entries({
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".wasm": "application/wasm",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".wav": "audio/wav",
|
||||
".blend": "application/octet-stream",
|
||||
".nvdb": "application/x-nanovdb",
|
||||
})) assert.equal(contract.mimeTypes[extension], mime, `${extension} MIME drifted`);
|
||||
|
||||
assert.deepEqual(contract.rangeRequests.extensions, [".wasm", ".blend", ".nvdb"]);
|
||||
assert.equal(contract.rangeRequests.unit, "bytes");
|
||||
assert.equal(contract.rangeRequests.singleRangeOnly, true);
|
||||
assert.equal(contract.rangeRequests.etag, "strong-sha256");
|
||||
assert.equal(contract.rangeRequests.satisfiedStatus, 206);
|
||||
assert.equal(contract.rangeRequests.unsatisfiedStatus, 416);
|
||||
assert.deepEqual(contract.rangeRequests.requiredSatisfiedHeaders, ["Accept-Ranges", "Content-Range", "Content-Length", "ETag"]);
|
||||
assert.equal(contract.rangeRequests.ifRangeMismatch, "return-full-200");
|
||||
|
||||
for (const value of Object.values(contract.responseHeaders.allResponses)) assert.ok(guide.includes(`\`${value}\``));
|
||||
for (const status of ["206", "416", "200"]) assert.ok(guide.includes(`\`${status}\``));
|
||||
assert.match(vite, /deployment-contract\.json/);
|
||||
assert.match(vite, /responseHeaders\.allResponses/);
|
||||
assert.match(releaseCreator, /deployment-contract\.json/);
|
||||
assert.match(releaseCreator, /DEPLOYMENT\.md/);
|
||||
assert.doesNotMatch(releaseCreator, /Serve app\/ from any local static HTTP server/);
|
||||
|
||||
process.stdout.write(`deployment-contract-ok headers=3 routes=${contract.responseHeaders.routes.length} mime=${Object.keys(contract.mimeTypes).length} rangeExtensions=${contract.rangeRequests.extensions.length}\n`);
|
||||
@@ -5,7 +5,7 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
native_dir="${repo_root}/build_web/native"
|
||||
mkdir -p "${native_dir}"
|
||||
|
||||
c++ -std=c++17 -O2 \
|
||||
c++ -std=c++20 -O2 \
|
||||
-I"${repo_root}/blender-5.2.0/source/blender/web_engine" \
|
||||
-I"${repo_root}/blender-5.2.0/extern/json/include" \
|
||||
"${repo_root}/blender-5.2.0/source/blender/web_engine/web_engine_api.cpp" \
|
||||
|
||||
@@ -48,6 +48,7 @@ for (const target of [100_000, 1_000_000]) {
|
||||
const engine = await factory({ wasmBinary });
|
||||
const source = grid(target);
|
||||
const pointers = [];
|
||||
let report;
|
||||
try {
|
||||
const positions = alloc(engine, source.positions); pointers.push(positions);
|
||||
const indices = alloc(engine, source.indices); pointers.push(indices);
|
||||
@@ -71,11 +72,15 @@ for (const target of [100_000, 1_000_000]) {
|
||||
const heapBytes = engine.HEAPU8.byteLength;
|
||||
assert.ok(elapsedMs <= limits[target], `${target} triangle gate exceeded ${limits[target]}ms: ${elapsedMs.toFixed(1)}ms`);
|
||||
assert.ok(heapBytes <= limits.heapBytes, `${target} triangle gate exceeded WASM heap limit: ${heapBytes}`);
|
||||
results.push({ target, ratio, outputTriangles, elapsedMs: Math.round(elapsedMs), heapBytes });
|
||||
report = { target, ratio, inputTriangles: source.triangleCount, outputTriangles, elapsedMs: Math.round(elapsedMs), peakHeapBytes: heapBytes };
|
||||
}
|
||||
finally {
|
||||
for (const pointer of pointers.reverse()) if (pointer) engine._free(pointer);
|
||||
}
|
||||
const recoveryPointer = engine._malloc(16);
|
||||
assert.ok(recoveryPointer > 0, `${target} triangle gate did not recover a small allocation`);
|
||||
engine._free(recoveryPointer);
|
||||
results.push({ ...report, releasedPointerCount: pointers.length, recoveryAllocation: true });
|
||||
}
|
||||
|
||||
process.stdout.write(`release-performance-ok ${JSON.stringify(results)}\n`);
|
||||
|
||||
15
tools/web/check-v1-acceptance-coverage.mjs
Normal file
15
tools/web/check-v1-acceptance-coverage.mjs
Normal file
@@ -0,0 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { buildAcceptancePlan, readAcceptanceInputs } from "./v1-acceptance-lib.mjs";
|
||||
|
||||
const { ledger, packageManifest } = readAcceptanceInputs();
|
||||
const plan = buildAcceptancePlan(ledger, packageManifest);
|
||||
|
||||
assert.equal(plan.entries.some((entry) => entry.token === "web:test:nonmesh-usd-blender-roundtrip"), true);
|
||||
assert.equal(plan.entries.some((entry) => entry.token === "web:test:v1-user-loop"), true);
|
||||
assert.equal(plan.entries.some((entry) => entry.token === "web:release:offline"), true);
|
||||
assert.ok(plan.e2eCount > 0);
|
||||
assert.ok(plan.reusedCount > 0);
|
||||
|
||||
process.stdout.write(
|
||||
`v1-acceptance-coverage-ok families=${plan.familyCount} declarations=${plan.declarationCount} unique=${plan.uniqueCount} scripts=${plan.scriptCount} e2e=${plan.e2eCount} reused=${plan.reusedCount}\n`,
|
||||
);
|
||||
38
tools/web/check-v1-acceptance-evidence.mjs
Normal file
38
tools/web/check-v1-acceptance-evidence.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import { evidencePath, expectedEvidenceIdentity, sha256 } from "./v1-acceptance-lib.mjs";
|
||||
|
||||
const { ledgerSha256, packageSha256, plan, toolSourceSha256 } = expectedEvidenceIdentity();
|
||||
assert.ok(fs.existsSync(evidencePath), "V1 acceptance evidence is missing; run release:v1-acceptance");
|
||||
const manifest = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
|
||||
|
||||
assert.equal(manifest.schemaVersion, 1);
|
||||
assert.equal(manifest.sourceSha256, ledgerSha256, "V1 acceptance evidence is stale relative to parity ledger");
|
||||
assert.equal(manifest.packageSha256, packageSha256, "V1 acceptance evidence is stale relative to web package scripts");
|
||||
assert.deepEqual(manifest.toolSourceSha256, toolSourceSha256, "V1 acceptance evidence is stale relative to acceptance tools");
|
||||
assert.ok(Number.isFinite(Date.parse(manifest.generatedAt)), "V1 acceptance generatedAt is invalid");
|
||||
assert.equal(manifest.summary.families, plan.familyCount);
|
||||
assert.equal(manifest.summary.declarations, plan.declarationCount);
|
||||
assert.equal(manifest.summary.unique, plan.uniqueCount);
|
||||
assert.equal(manifest.summary.passed, plan.uniqueCount);
|
||||
assert.equal(manifest.summary.failed, 0);
|
||||
assert.equal(manifest.environment.blender.includes("USD=True"), true, "acceptance Blender must provide USD");
|
||||
assert.match(manifest.environment.blenderExecutableSha256, /^[a-f0-9]{64}$/, "acceptance Blender executable hash is missing");
|
||||
assert.match(manifest.environment.blenderArchiveSha256, /^[a-f0-9]{64}$/, "acceptance Blender archive hash is missing");
|
||||
assert.equal(manifest.results.length, plan.uniqueCount);
|
||||
assert.deepEqual(manifest.results.map((result) => result.token), plan.entries.map((entry) => entry.token));
|
||||
|
||||
for (const [index, result] of manifest.results.entries()) {
|
||||
const expected = plan.entries[index];
|
||||
assert.deepEqual(result.familyIds, expected.familyIds, `${result.token}: family mapping drifted`);
|
||||
assert.equal(result.command, expected.command, `${result.token}: command drifted`);
|
||||
assert.equal(result.exitCode, 0, `${result.token}: command failed`);
|
||||
assert.ok(result.durationMs >= 0, `${result.token}: duration is invalid`);
|
||||
assert.match(result.outputSha256, /^[a-f0-9]{64}$/, `${result.token}: output hash is missing`);
|
||||
assert.equal(result.outputSha256, sha256(result.output), `${result.token}: stored output does not match its hash`);
|
||||
assert.ok(result.output.trim().length > 0, `${result.token}: output is empty`);
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`v1-acceptance-evidence-ok families=${plan.familyCount} declarations=${plan.declarationCount} unique=${plan.uniqueCount} sha256=${sha256(fs.readFileSync(evidencePath))}\n`,
|
||||
);
|
||||
21
tools/web/check-v1-acceptance-release-binding.mjs
Normal file
21
tools/web/check-v1-acceptance-release-binding.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { evidencePath, root, sha256 } from "./v1-acceptance-lib.mjs";
|
||||
|
||||
assert.ok(fs.existsSync(evidencePath), "V1 acceptance evidence is missing");
|
||||
const acceptanceSha256 = sha256(fs.readFileSync(evidencePath));
|
||||
const releaseEvidence = JSON.parse(fs.readFileSync(path.join(root, "docs/status/release-evidence.json"), "utf8"));
|
||||
const userLoopRecord = releaseEvidence.evidence?.records?.find((record) => record.id === "v1-user-loop");
|
||||
|
||||
assert.ok(userLoopRecord, "release evidence has no v1-user-loop record");
|
||||
assert.equal(userLoopRecord.exitCode, 0, "release v1-user-loop record failed");
|
||||
assert.ok(userLoopRecord.output?.trim().length > 0, "release v1-user-loop output is empty");
|
||||
assert.ok(
|
||||
userLoopRecord.artifactSha256?.includes(acceptanceSha256),
|
||||
"current V1 acceptance evidence is not bound to release evidence",
|
||||
);
|
||||
|
||||
process.stdout.write(
|
||||
`v1-acceptance-release-binding-ok acceptanceSha256=${acceptanceSha256} releaseGeneratedAt=${releaseEvidence.generatedAt}\n`,
|
||||
);
|
||||
@@ -47,8 +47,8 @@ if (process.argv.includes("--record-v1-user-loop")) {
|
||||
const record = executeEvidenceCommand(
|
||||
"v1-user-loop",
|
||||
[],
|
||||
"npm --prefix web run test:v1-user-loop",
|
||||
["web/tests/e2e/v1-user-loop.spec.ts", "web/app/src/vendor/blender/web_engine.wasm"],
|
||||
"npm --prefix web run test:v1-user-loop && npm --prefix web run test:v1-acceptance-evidence",
|
||||
["web/tests/e2e/v1-user-loop.spec.ts", "web/app/src/vendor/blender/web_engine.wasm", "docs/status/v1-acceptance-evidence.json"],
|
||||
);
|
||||
current.evidence.records = [...current.evidence.records.filter((item) => item.id !== record.id), record];
|
||||
const manifest = {
|
||||
@@ -173,7 +173,7 @@ function run(id, fields, command, artifacts = []) {
|
||||
|
||||
run("sbom", ["provenance.license", "provenance.sbom"], "npm --prefix web run release:sbom", ["docs/web/sbom.spdx.json", "docs/web/third-party-notices.json", "web/package-lock.json"]);
|
||||
run("vdb-boundary", [], "npm --prefix web run test:vdb-availability && npm --prefix web run test:vdb-native && npm --prefix web run test:vdb", ["web/protocol/volume-vdb.ts", "web/app/src/volume/nanovdb-stream.ts", "tools/vdb/vdb_to_nanovdb.cc", "docs/status/vdb-native-evidence.json", "docs/VDB_NANOVDB_WEBGPU_IMPLEMENTATION_PLAN.md"]);
|
||||
run("v1-user-loop", [], "npm --prefix web run test:v1-user-loop", ["web/tests/e2e/v1-user-loop.spec.ts", "web/app/src/vendor/blender/web_engine.wasm"]);
|
||||
run("v1-user-loop", [], "npm --prefix web run test:v1-user-loop && npm --prefix web run test:v1-acceptance-evidence", ["web/tests/e2e/v1-user-loop.spec.ts", "web/app/src/vendor/blender/web_engine.wasm", "docs/status/v1-acceptance-evidence.json"]);
|
||||
run("chromium", ["browser.chromium", "runtime.offline", "runtime.workerRestart", "runtime.opfsRecovery"], "npm --prefix web run test:e2e -- --workers=1 && npm --prefix web run test:browser", ["web/app/src/vendor/blender/web_engine.wasm"]);
|
||||
run("geometry-1m", ["performance.geometry1M"], "npm --prefix web run test:release-performance", ["web/app/src/vendor/blender/web_engine.wasm"]);
|
||||
run("geometry-10m", ["performance.geometry10M"], "npm --prefix web run test:geometry-10m-performance", ["web/protocol/geometry-stream.ts", "web/app/src/performance/large-geometry.ts", "web/app/src/workers/geometry-stream.worker.ts", "web/tests/e2e/geometry-10m-performance.spec.ts"]);
|
||||
|
||||
@@ -15,12 +15,16 @@ fs.cpSync(path.join(root, "web/dist"), path.join(bundle, "app"), { recursive: tr
|
||||
fs.copyFileSync(path.join(root, "blender-5.2.0/COPYING"), path.join(bundle, "COPYING"));
|
||||
fs.copyFileSync(path.join(root, "docs/web/third-party-notices.json"), path.join(bundle, "third-party-notices.json"));
|
||||
fs.copyFileSync(path.join(root, "docs/web/sbom.spdx.json"), path.join(bundle, "sbom.spdx.json"));
|
||||
fs.copyFileSync(path.join(root, "docs/web/deployment-contract.json"), path.join(bundle, "deployment-contract.json"));
|
||||
fs.copyFileSync(path.join(root, "docs/web/DEPLOYMENT.md"), path.join(bundle, "DEPLOYMENT.md"));
|
||||
fs.copyFileSync(path.join(root, "blender-5.2.0/extern/opensubdiv-source/LICENSE.txt"), path.join(bundle, "LICENSE-OpenSubdiv.txt"));
|
||||
fs.copyFileSync(path.join(root, "blender-5.2.0/extern/gmp-source/COPYING.LESSERv3"), path.join(bundle, "LICENSE-GMP-LGPLv3.txt"));
|
||||
fs.writeFileSync(path.join(bundle, "README.txt"), [
|
||||
"Blender Web offline release",
|
||||
"Web Blender Modeler V1 offline release",
|
||||
"",
|
||||
"Serve app/ from any local static HTTP server. The application has no runtime CDN dependency.",
|
||||
"Serve app/ from a static HTTP server that implements deployment-contract.json.",
|
||||
"See DEPLOYMENT.md for the required isolation, cache, MIME, ETag and byte-range behavior.",
|
||||
"The application has no runtime CDN dependency.",
|
||||
"Opening index.html directly is unsupported because browsers restrict module Workers and WASM under file://.",
|
||||
"See SOURCE_OFFER.txt and third-party-notices.json for licensing and corresponding source.",
|
||||
"",
|
||||
|
||||
103
tools/web/run-v1-acceptance.mjs
Normal file
103
tools/web/run-v1-acceptance.mjs
Normal file
@@ -0,0 +1,103 @@
|
||||
import fs from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
evidencePath,
|
||||
expectedEvidenceIdentity,
|
||||
root,
|
||||
sha256,
|
||||
} from "./v1-acceptance-lib.mjs";
|
||||
|
||||
const { ledgerSha256, packageSha256, plan, toolSourceSha256 } = expectedEvidenceIdentity();
|
||||
const basePort = Number.parseInt(process.env.WEB_ACCEPTANCE_PORT ?? "5400", 10);
|
||||
if (!Number.isInteger(basePort) || basePort < 1024 || basePort > 65000 - plan.entries.length) {
|
||||
throw new Error(`WEB_ACCEPTANCE_PORT must leave ${plan.entries.length} sequential ports available`);
|
||||
}
|
||||
|
||||
function capture(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
return `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
||||
}
|
||||
|
||||
const blenderBin = process.env.BLENDER_BIN ?? "build_blender_5.2.0/bin/blender";
|
||||
const blenderArchiveSha256 = process.env.BLENDER_ARCHIVE_SHA256 ?? "";
|
||||
if (blenderArchiveSha256 && !/^[a-f0-9]{64}$/.test(blenderArchiveSha256)) {
|
||||
throw new Error("BLENDER_ARCHIVE_SHA256 must be a lowercase SHA-256");
|
||||
}
|
||||
const environment = {
|
||||
node: process.version,
|
||||
npm: capture("npm", ["--version"]).split(/\r?\n/)[0],
|
||||
chromium: capture(process.env.CHROME_PATH ?? "/home/mes123456/.local/bin/google-chrome", ["--version"]).split(/\r?\n/)[0],
|
||||
blenderBin: process.env.BLENDER_BIN ? "BLENDER_BIN" : blenderBin,
|
||||
blenderExecutableSha256: fs.existsSync(blenderBin) ? sha256(fs.readFileSync(blenderBin)) : "unavailable",
|
||||
blenderArchiveSha256: blenderArchiveSha256 || "unavailable",
|
||||
blender: capture(blenderBin, [
|
||||
"--background",
|
||||
"--factory-startup",
|
||||
"--python-expr",
|
||||
"import bpy; print('ACCEPTANCE_BLENDER=' + bpy.app.version_string + ';USD=' + str(bpy.app.build_options.usd))",
|
||||
]).split(/\r?\n/).filter((line) => line.includes("ACCEPTANCE_BLENDER=")).at(-1) ?? "unavailable",
|
||||
};
|
||||
|
||||
const results = [];
|
||||
for (const [index, entry] of plan.entries.entries()) {
|
||||
const started = Date.now();
|
||||
process.stdout.write(`[${index + 1}/${plan.entries.length}] ${entry.token}\n`);
|
||||
const result = spawnSync("npm", ["--prefix", "web", "run", entry.script, ...entry.args], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
FORCE_COLOR: "0",
|
||||
NO_COLOR: "1",
|
||||
WEB_TEST_PORT: String(basePort + index),
|
||||
},
|
||||
maxBuffer: 32 * 1024 * 1024,
|
||||
});
|
||||
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
||||
const storedOutput = output.slice(-4096) || `${entry.token} produced no output`;
|
||||
const exitCode = result.status ?? 1;
|
||||
results.push({
|
||||
token: entry.token,
|
||||
familyIds: entry.familyIds,
|
||||
command: entry.command,
|
||||
exitCode,
|
||||
durationMs: Date.now() - started,
|
||||
outputSha256: sha256(storedOutput),
|
||||
output: storedOutput,
|
||||
});
|
||||
process.stdout.write(` exit=${exitCode} durationMs=${results.at(-1).durationMs}\n`);
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
source: "docs/status/parity-ledger.json",
|
||||
sourceSha256: ledgerSha256,
|
||||
package: "web/package.json",
|
||||
packageSha256,
|
||||
toolSourceSha256,
|
||||
generatedAt: new Date().toISOString(),
|
||||
environment,
|
||||
summary: {
|
||||
families: plan.familyCount,
|
||||
declarations: plan.declarationCount,
|
||||
unique: plan.uniqueCount,
|
||||
passed: results.filter((result) => result.exitCode === 0).length,
|
||||
failed: results.filter((result) => result.exitCode !== 0).length,
|
||||
},
|
||||
results,
|
||||
};
|
||||
fs.writeFileSync(evidencePath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
|
||||
const failed = results.filter((result) => result.exitCode !== 0);
|
||||
process.stdout.write(
|
||||
`v1-acceptance-run-complete passed=${manifest.summary.passed} failed=${manifest.summary.failed} evidence=${evidencePath}\n`,
|
||||
);
|
||||
if (failed.length > 0) {
|
||||
process.stderr.write(`failed acceptance: ${failed.map((result) => result.token).join(", ")}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
107
tools/web/v1-acceptance-lib.mjs
Normal file
107
tools/web/v1-acceptance-lib.mjs
Normal file
@@ -0,0 +1,107 @@
|
||||
import assert from "node:assert/strict";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
export const ledgerPath = path.join(root, "docs/status/parity-ledger.json");
|
||||
export const packagePath = path.join(root, "web/package.json");
|
||||
export const evidencePath = path.join(root, "docs/status/v1-acceptance-evidence.json");
|
||||
export const toolRelativePaths = [
|
||||
"tools/web/v1-acceptance-lib.mjs",
|
||||
"tools/web/check-v1-acceptance-coverage.mjs",
|
||||
"tools/web/run-v1-acceptance.mjs",
|
||||
"tools/web/check-v1-acceptance-evidence.mjs",
|
||||
];
|
||||
|
||||
export function sha256(value) {
|
||||
return crypto.createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
export function readAcceptanceInputs() {
|
||||
const ledgerBytes = fs.readFileSync(ledgerPath);
|
||||
const packageBytes = fs.readFileSync(packagePath);
|
||||
return {
|
||||
ledgerBytes,
|
||||
packageBytes,
|
||||
ledger: JSON.parse(ledgerBytes),
|
||||
packageManifest: JSON.parse(packageBytes),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAcceptance(token, scripts) {
|
||||
assert.equal(typeof token, "string", "acceptance token must be a string");
|
||||
if (token.startsWith("web:e2e:")) {
|
||||
const pattern = token.slice("web:e2e:".length);
|
||||
assert.ok(pattern.trim().length > 0, `${token}: E2E pattern must not be empty`);
|
||||
assert.equal(typeof scripts["test:e2e"], "string", `${token}: web test:e2e script is missing`);
|
||||
return {
|
||||
token,
|
||||
kind: "e2e",
|
||||
script: "test:e2e",
|
||||
args: ["--", "--workers=1", "-g", pattern],
|
||||
command: `npm --prefix web run test:e2e -- --workers=1 -g ${JSON.stringify(pattern)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const match = /^web:(test|release):(.+)$/.exec(token);
|
||||
assert.ok(match, `${token}: unsupported acceptance namespace`);
|
||||
const script = `${match[1]}:${match[2]}`;
|
||||
assert.equal(typeof scripts[script], "string", `${token}: web package script ${script} is missing`);
|
||||
return {
|
||||
token,
|
||||
kind: "script",
|
||||
script,
|
||||
args: [],
|
||||
command: `npm --prefix web run ${script}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAcceptancePlan(ledger, packageManifest) {
|
||||
assert.equal(ledger.schemaVersion, 2, "unsupported parity ledger schema");
|
||||
assert.ok(Array.isArray(ledger.families), "parity ledger families must be an array");
|
||||
assert.equal(ledger.families.length, 12, "V1 acceptance expects N-015 through N-026");
|
||||
assert.ok(packageManifest.scripts && typeof packageManifest.scripts === "object", "web package scripts are missing");
|
||||
|
||||
const byToken = new Map();
|
||||
let declarationCount = 0;
|
||||
for (const family of ledger.families) {
|
||||
assert.match(family.id, /^N-0(?:1[5-9]|2[0-6])$/, `${family.id}: unexpected family id`);
|
||||
assert.ok(Array.isArray(family.acceptance) && family.acceptance.length > 0, `${family.id}: acceptance is empty`);
|
||||
assert.equal(new Set(family.acceptance).size, family.acceptance.length, `${family.id}: duplicate acceptance token`);
|
||||
for (const token of family.acceptance) {
|
||||
declarationCount += 1;
|
||||
const existing = byToken.get(token);
|
||||
if (existing) {
|
||||
existing.familyIds.push(family.id);
|
||||
continue;
|
||||
}
|
||||
byToken.set(token, { ...resolveAcceptance(token, packageManifest.scripts), familyIds: [family.id] });
|
||||
}
|
||||
}
|
||||
|
||||
const entries = [...byToken.values()];
|
||||
return {
|
||||
familyCount: ledger.families.length,
|
||||
declarationCount,
|
||||
uniqueCount: entries.length,
|
||||
scriptCount: entries.filter((entry) => entry.kind === "script").length,
|
||||
e2eCount: entries.filter((entry) => entry.kind === "e2e").length,
|
||||
reusedCount: entries.filter((entry) => entry.familyIds.length > 1).length,
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
export function expectedEvidenceIdentity() {
|
||||
const inputs = readAcceptanceInputs();
|
||||
return {
|
||||
...inputs,
|
||||
plan: buildAcceptancePlan(inputs.ledger, inputs.packageManifest),
|
||||
ledgerSha256: sha256(inputs.ledgerBytes),
|
||||
packageSha256: sha256(inputs.packageBytes),
|
||||
toolSourceSha256: Object.fromEntries(
|
||||
toolRelativePaths.map((relativePath) => [relativePath, sha256(fs.readFileSync(path.join(root, relativePath)))]),
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user