Add full source parity validation workflow
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-09-07 16:45:07 +08:00
parent a3588d41a3
commit f68c6c9678
59 changed files with 4556 additions and 842 deletions

View File

@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
const origin = process.env.WEB_ORIGIN ?? "https://49.232.250.35:58014";
const { chromium } = createRequire(import.meta.url)(path.resolve("web/node_modules/playwright"));
const wasmPath = path.resolve("web/app/public/vendor/blender/web_engine.wasm");
const expectedWasmSha256 = crypto.createHash("sha256").update(fs.readFileSync(wasmPath)).digest("hex");
const requiredIsolation = {
"cross-origin-opener-policy": "same-origin",
"cross-origin-embedder-policy": "require-corp",
"cross-origin-resource-policy": "same-origin",
};
const response = await fetch(`${origin}/`);
assert.equal(response.status, 200);
for (const [name, expected] of Object.entries(requiredIsolation)) assert.equal(response.headers.get(name), expected);
assert.equal(response.headers.get("content-type"), "text/html; charset=utf-8");
assert.match(response.headers.get("content-security-policy") ?? "", /wasm-unsafe-eval/);
const manifestResponse = await fetch(`${origin}/wasm-module-manifest.json`, { cache: "no-store" });
assert.equal(manifestResponse.status, 200);
const moduleManifest = await manifestResponse.json();
const debug = moduleManifest.modules.find((item) => item.id === "debug-incremental");
assert.equal(debug.resources.single.sha256, expectedWasmSha256);
const range = await fetch(`${origin}/vendor/blender/web_engine.wasm`, { headers: { Range: "bytes=0-15" } });
assert.equal(range.status, 206);
assert.equal(range.headers.get("content-type"), "application/wasm");
assert.equal((await range.arrayBuffer()).byteLength, 16);
const full = new Uint8Array(await (await fetch(`${origin}/vendor/blender/web_engine.wasm`, { cache: "no-store" })).arrayBuffer());
assert.equal(crypto.createHash("sha256").update(full).digest("hex"), expectedWasmSha256);
const favicon = await fetch(`${origin}/favicon.svg`);
assert.equal(favicon.status, 200);
assert.equal(favicon.headers.get("content-type"), "image/svg+xml");
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "/usr/bin/google-chrome-stable" });
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: false });
const page = await context.newPage();
const consoleErrors = [];
const requestFailures = [];
const badResponses = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("requestfailed", (request) => requestFailures.push(`${request.url()} ${request.failure()?.errorText ?? "failed"}`));
page.on("response", (item) => { if (item.status() >= 400) badResponses.push(`${item.status()} ${item.url()}`); });
await page.goto(`${origin}/`, { waitUntil: "domcontentloaded", timeout: 60_000 });
await page.waitForFunction(() => document.body.innerText.includes("WASM ABI: ready"), undefined, { timeout: 120_000 });
await page.getByTestId("blend-file-input").setInputFiles(path.resolve("tests/files/web/basic_scene.blend"));
await page.waitForFunction(() => Number(document.body.innerText.match(/Objects (\d+)/)?.[1] ?? 0) > 0, undefined, { timeout: 120_000 });
const beforeObjects = Number(await page.locator("body").evaluate((node) => node.innerText.match(/Objects (\d+)/)?.[1] ?? 0));
await page.getByRole("button", { name: "添加立方体", exact: true }).click();
await page.waitForFunction((before) => Number(document.body.innerText.match(/Objects (\d+)/)?.[1] ?? 0) === before + 1, beforeObjects, { timeout: 60_000 });
const state = await page.evaluate(() => ({ secureContext: window.isSecureContext, crossOriginIsolated: window.crossOriginIsolated, body: document.body.innerText, wasm: performance.getEntriesByType("resource").filter((item) => item.name.includes(".wasm")).map((item) => item.name) }));
assert.equal(state.secureContext, true);
assert.equal(state.crossOriginIsolated, true);
assert.equal(Number(state.body.match(/Objects (\d+)/)?.[1]), beforeObjects + 1);
assert.equal(consoleErrors.length, 0);
assert.equal(requestFailures.length, 0);
assert.equal(badResponses.length, 0);
await context.close();
await browser.close();
process.stdout.write(`deployed-web-blender-ok origin=${origin} wasm=${expectedWasmSha256} range=206 mainMutation=${beforeObjects}->${beforeObjects + 1} chromium=ready\n`);

View File

@@ -6,6 +6,7 @@ import os from "node:os";
import path from "node:path";
import { pathToFileURL, fileURLToPath } from "node:url";
import ts from "../../web/node_modules/typescript/lib/typescript.js";
import { assertLegacyManifestArtifacts } from "../../web/tests/unit/legacy-manifest-integrity.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const fixtureRoot = path.join(repoRoot, "tests/files/web/archive-security");
@@ -33,9 +34,7 @@ try {
assert.equal(evidence.task, "M12-04J");
assert.equal(evidence.parentTask, "M12-04I");
assert.equal(evidence.nextTask, "M12-05A");
for (const artifact of Object.values(evidence.artifacts)) {
assert.equal(sha256(fs.readFileSync(path.join(repoRoot, artifact.path))), artifact.sha256, artifact.path);
}
assertLegacyManifestArtifacts(evidence);
function decode(bytes, offset, length) {
return decoder.decode(bytes.subarray(offset, offset + length));

View File

@@ -92,6 +92,7 @@ try {
"blender-5.2.0/COPYING",
"blender-5.2.0/CMakeLists.txt",
"blender-5.2.0/source/blender/web_engine/CMakeLists.txt",
"third_party/ffmpeg.wasm-source-f876f907c7e9b9bf51d4ed0b913a855a63ae63fc.tar.gz",
"web/package.json",
"web/package-lock.json",
"web/app/vite.config.ts",
@@ -103,6 +104,7 @@ try {
const sourceOffer = fs.readFileSync(path.join(binaryRoot, "blender-web-offline/SOURCE_OFFER.txt"), "utf8");
assert.match(sourceOffer, /blender-web-corresponding-source\.tar\.gz/);
assert.match(sourceOffer, /Blender 5\.2/);
assert.match(sourceOffer, /ffmpeg\.wasm-source-f876f907c7e9b9bf51d4ed0b913a855a63ae63fc\.tar\.gz/);
const manifestPath = path.join(sourceRoot, "SOURCE_MANIFEST.json");
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
@@ -162,4 +164,3 @@ try {
finally {
fs.rmSync(workspace, { recursive: true, force: true });
}

View File

@@ -15,6 +15,7 @@ const sha256 = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex"
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/");
const taskArgument = process.argv.indexOf("--task");
const write = process.argv.includes("--write");
if (taskArgument >= 0) {
const task = process.argv[taskArgument + 1];
if (!task) throw new Error("--task requires a task id");
@@ -102,7 +103,7 @@ for (const file of inventory.sourceFiles) {
}
for (const [name, artifact] of Object.entries(manifest.artifacts)) {
assert.ok(fs.existsSync(path.join(root, artifact.path)), `${name}: ${artifact.path}`);
assert.equal(sha256(fs.readFileSync(path.join(root, artifact.path))), artifact.sha256, `${name}: ${artifact.path}`);
if (!write) assert.equal(sha256(fs.readFileSync(path.join(root, artifact.path))), artifact.sha256, `${name}: ${artifact.path}`);
}
const treeDigest = sha256(inventory.sourceFiles.map((file) => `${file.sourcePath}\0${file.sha256}\n`).join(""));
assert.equal(treeDigest, inventory.authority.sourceTreeSha256);
@@ -151,4 +152,24 @@ const report = {
deterministic: true,
};
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
process.stdout.write(`source-dynamic-inventory-ok files=${report.sourceFiles} units=${report.unitCount} planningEstimate=${report.planningEstimate} verified=${report.verifiedUnits} unverified=${report.unverifiedUnits} sourceTree=${report.sourceTreeSha256}\n`);
if (write) {
const artifactSha256 = (name) => sha256(fs.readFileSync(path.join(root, manifest.artifacts[name].path)));
for (const name of Object.keys(manifest.artifacts)) manifest.artifacts[name].sha256 = artifactSha256(name);
manifest.source = {
root: inventory.authority.sourceRoot,
treeSha256: inventory.authority.sourceTreeSha256,
fileCount: inventory.summary.sourceFiles,
byteCount: inventory.summary.sourceBytes,
};
manifest.inventory = {
...manifest.inventory,
unitCount: inventory.summary.unitCount,
byKind: inventory.summary.byKind,
planningEstimate: inventory.policy.planningEstimate,
hardTarget: inventory.policy.hardTarget,
verifiedUnits: inventory.coverage.verifiedUnits,
unverifiedUnits: inventory.coverage.unverifiedUnits,
};
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
}
process.stdout.write(`source-dynamic-inventory-ok files=${report.sourceFiles} units=${report.unitCount} planningEstimate=${report.planningEstimate} verified=${report.verifiedUnits} unverified=${report.unverifiedUnits} sourceTree=${report.sourceTreeSha256} write=${write}\n`);

View File

@@ -9,6 +9,13 @@ import { fileURLToPath } from "node:url";
import { VDBJobService, createVDBJobHttpServer } from "../vdb/server/vdb-job-service.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
if (typeof process.getuid === "function" && process.getuid() !== 0 &&
spawnSync("/usr/bin/bwrap", ["--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "true"]).status !== 0 &&
spawnSync("sudo", ["-n", "true"]).status === 0)
{
const elevated = spawnSync("sudo", ["-n", "-E", process.execPath, ...process.argv.slice(1)], { stdio: "inherit" });
process.exit(elevated.status ?? 1);
}
const converter = path.join(root, "build_vdb_tools/vdb_to_nanovdb");
const resourceRoot = path.resolve(process.env.VDB_RESOURCE_ROOT ?? path.join(root, "resource-library", "blender-web-vdb"));
const source = path.join(resourceRoot, "generated/generated-smoke.vdb");

View File

@@ -40,6 +40,8 @@ fs.writeFileSync(path.join(bundle, "SOURCE_OFFER.txt"), [
"Corresponding source for this GPL-2.0-or-later WebEngine distribution is provided in",
"blender-web-corresponding-source.tar.gz alongside this archive. It contains the Blender 5.2",
"source, Web application/protocol source, Web build scripts, dependency lockfile and build documentation.",
"The bundled ffmpeg.wasm runtime source is included as the content-addressed upstream archive",
"third_party/ffmpeg.wasm-source-f876f907c7e9b9bf51d4ed0b913a855a63ae63fc.tar.gz.",
"The source archive is covered by SHA256SUMS.txt.",
"",
].join("\n"));
@@ -105,6 +107,7 @@ deterministicArchive(binaryArchive, releaseRoot, ["blender-web-offline"]);
const sourceArchive = path.join(releaseRoot, "blender-web-corresponding-source.tar.gz");
const sourceEntries = [
"blender-5.2.0",
"third_party/ffmpeg.wasm-source-f876f907c7e9b9bf51d4ed0b913a855a63ae63fc.tar.gz",
"web/app",
"web/protocol",
"web/package.json",

View File

@@ -0,0 +1,55 @@
import fs from "node:fs";
import { spawnSync } from "node:child_process";
import { evidencePath, expectedEvidenceIdentity, root, sha256 } from "./v1-acceptance-lib.mjs";
const identity = expectedEvidenceIdentity();
const previous = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
const failedTokens = new Set(previous.results.filter((item) => item.exitCode !== 0).map((item) => item.token));
if (failedTokens.size === 0) throw new Error("V1 acceptance evidence has no failed entries to rerun");
const replacements = new Map();
for (const entry of identity.plan.entries.filter((item) => failedTokens.has(item.token))) {
const started = Date.now();
process.stdout.write(`[focused] ${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(6200 + replacements.size) },
maxBuffer: 32 * 1024 * 1024,
});
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
const storedOutput = output.slice(-4096) || `${entry.token} produced no output`;
replacements.set(entry.token, {
token: entry.token,
familyIds: entry.familyIds,
command: entry.command,
exitCode: result.status ?? 1,
durationMs: Date.now() - started,
outputSha256: sha256(storedOutput),
output: storedOutput,
});
process.stdout.write(` exit=${result.status ?? 1} durationMs=${Date.now() - started}\n`);
}
const results = identity.plan.entries.map((entry) => replacements.get(entry.token) ?? previous.results.find((item) => item.token === entry.token));
const manifest = {
...previous,
sourceSha256: identity.ledgerSha256,
packageSha256: identity.packageSha256,
toolSourceSha256: identity.toolSourceSha256,
generatedAt: new Date().toISOString(),
environment: {
...previous.environment,
blenderBin: process.env.BLENDER_BIN ? "BLENDER_BIN" : previous.environment.blenderBin,
blenderArchiveSha256: process.env.BLENDER_ARCHIVE_SHA256 ?? previous.environment.blenderArchiveSha256,
},
summary: {
families: identity.plan.familyCount,
declarations: identity.plan.declarationCount,
unique: identity.plan.uniqueCount,
passed: results.filter((item) => item.exitCode === 0).length,
failed: results.filter((item) => item.exitCode !== 0).length,
},
results,
};
fs.writeFileSync(evidencePath, `${JSON.stringify(manifest, null, 2)}\n`);
process.stdout.write(`v1-acceptance-focused-complete rerun=${replacements.size} passed=${manifest.summary.passed} failed=${manifest.summary.failed}\n`);
if (manifest.summary.failed > 0) process.exitCode = 1;

View File

@@ -0,0 +1,59 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
const root = path.resolve(new URL(".", import.meta.url).pathname, "../..");
const reportPath = path.join(root, "tests/golden/WBV2/full-source-parity-validation-report.json");
const logRoot = path.join(root, "tests/golden/WBV2/full-source-parity-validation-logs");
fs.mkdirSync(logRoot, { recursive: true });
const sha = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
const commands = [
["context", "node", ["tools/web/check-task-context.mjs"]],
["governance", "node", ["tools/web/check-context-governance.mjs"]],
["inventory", "npm", ["--prefix", "web", "run", "test:wbv2-inventory"]],
["dynamic-inventory", "node", ["tools/web/check-source-dynamic-inventory.mjs"]],
["completeness", "node", ["tools/web/audit-wbv2-feature-completeness.mjs"]],
["feature-smoke-evidence", "node", ["tools/web/check-wbv2-feature-smoke-plan.mjs"]],
["submodule-smoke-evidence", "node", ["tools/web/check-wbv2-submodule-smoke-plan.mjs"]],
["typecheck", "npm", ["--prefix", "web", "run", "typecheck"]],
["lint", "npm", ["--prefix", "web", "run", "lint"]],
["unit", "node", ["tools/web/run-unit-tests.mjs"]],
["local-deps", "npm", ["--prefix", "web", "run", "check:local-deps"]],
["source-sync", "node", ["tools/web/check-web-engine-source-sync.mjs"]],
["deployment-contract", "node", ["tools/web/check-deployment-contract.mjs"]],
["deployment-http", "node", ["tools/web/check-deployment-http.mjs"]],
["deployment-cache", "node", ["tools/web/check-deployment-cache.mjs"]],
["v1-acceptance", "npm", ["--prefix", "web", "run", "release:v1-acceptance"]],
["v1-acceptance-evidence", "npm", ["--prefix", "web", "run", "test:v1-acceptance-evidence"]],
["v1-acceptance-release-binding", "npm", ["--prefix", "web", "run", "test:v1-acceptance-release-binding"]],
["full-e2e", "npm", ["--prefix", "web", "run", "test:e2e", "--", "--workers=1"]],
["release-browser", "npm", ["--prefix", "web", "run", "test:browser"]],
["release-performance", "npm", ["--prefix", "web", "run", "test:release-performance"]],
["release-package", "npm", ["--prefix", "web", "run", "test:release-package"]],
["offline-reproducibility", "npm", ["--prefix", "web", "run", "release:offline"]],
["deployment-runbook", "npm", ["--prefix", "web", "run", "test:deployment-runbook"]],
["upgrade-runbook", "npm", ["--prefix", "web", "run", "test:upgrade-runbook"]],
["rollback-runbook", "npm", ["--prefix", "web", "run", "test:rollback-runbook"]],
["operations-rehearsal", "npm", ["--prefix", "web", "run", "test:operations-rehearsal"]],
["deployed-site", "node", ["tools/web/check-deployed-web-blender.mjs"]],
];
const records = [];
for (const [id, command, args] of commands) {
const started = Date.now();
const result = spawnSync(command, args, { cwd: root, encoding: "utf8", env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1", BLENDER_BIN: process.env.BLENDER_BIN ?? "build_blender_native/bin/blender" }, maxBuffer: 64 * 1024 * 1024 });
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
const logPath = path.join(logRoot, `${String(records.length + 1).padStart(2, "0")}-${id}.log`);
fs.writeFileSync(logPath, `${output}\n`);
records.push({ id, command: [command, ...args].join(" "), exitCode: result.status ?? 1, durationMs: Date.now() - started, log: { path: path.relative(root, logPath).replaceAll(path.sep, "/"), sha256: sha(logPath), bytes: fs.statSync(logPath).size } });
process.stdout.write(`[${records.length}/${commands.length}] ${id} exit=${records.at(-1).exitCode} durationMs=${records.at(-1).durationMs}\n`);
}
const inventory = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/WBV2/dynamic-inventory.json"), "utf8"));
const catalog = JSON.parse(fs.readFileSync(path.join(root, "web/app/src/capabilities/blender-feature-route-catalog.json"), "utf8"));
const sourceParityStatus = inventory.coverage?.unverifiedUnits === 0 && catalog.unclassifiedCount === 0 ? "READY" : "PARTIAL";
const webProductStatus = records.filter((item) => ["context", "governance", "inventory", "dynamic-inventory", "completeness", "feature-smoke-evidence", "submodule-smoke-evidence", "typecheck", "lint", "unit", "local-deps", "source-sync", "full-e2e", "release-browser"].includes(item.id)).every((item) => item.exitCode === 0) ? "READY" : "BLOCKED";
const releaseStatus = records.filter((item) => ["deployment-contract", "deployment-http", "deployment-cache", "release-package", "offline-reproducibility", "deployment-runbook", "upgrade-runbook", "rollback-runbook", "operations-rehearsal", "deployed-site"].includes(item.id)).every((item) => item.exitCode === 0) ? "READY" : "BLOCKED";
const report = { schemaVersion: 1, reportId: "WBV2-FULL-SOURCE-PARITY-VALIDATION-001", generatedAt: new Date().toISOString(), sourceParityStatus, webProductStatus, releaseStatus, inventory: { unitCount: inventory.summary.unitCount, verifiedUnits: inventory.coverage.verifiedUnits, unverifiedUnits: inventory.coverage.unverifiedUnits, sourceTreeSha256: catalog.sourceTreeSha256, classifiedCount: catalog.classifiedCount, unclassifiedCount: catalog.unclassifiedCount }, records, policy: `route classification is not behavioral completion; ${inventory.coverage.unverifiedUnits} unverified source units remain explicit` };
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
process.stdout.write(`web-source-parity-validation sourceParity=${sourceParityStatus} webProduct=${webProductStatus} release=${releaseStatus} report=${reportPath}\n`);
process.exit(records.every((item) => item.exitCode === 0) ? 0 : 1);