Files
workinf_Blender_Wasm/tools/web/check-deployed-web-blender.mjs
mes123456 f3e21c6b98
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
chore: checkpoint web Blender migration source, tools and docs
Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer,
web workflow engine and protocol, tooling, docs and task records.
Large generated evidence, user-testing archives, build snapshots and
imported test assets remain untracked.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-10 11:18:01 +08:00

289 lines
17 KiB
JavaScript

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",
/* Match web/playwright.config.ts: without a WebGPU/Dawn adapter the native
* GPU surface logs three init errors that are environment, not product. */
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader",
"--enable-unsafe-webgpu", "--enable-dawn-features=allow_unsafe_apis",
"--use-webgpu-adapter=swiftshader"],
});
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, ignoreHTTPSErrors: false, acceptDownloads: true });
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"));
/* Wait for the fixture open itself. The startup General template also leaves a
* non-empty object count, so counting objects alone lets a still-running
* template open overwrite the fixture and the later mutation never lands. */
/* The startup General template also reaches "Engine: SceneIR" with three
* objects (Cube, Camera, Light), so neither the status text nor the object
* count identifies the fixture open: waiting on those let the template
* replace the fixture and the later mutation was discarded with the retired
* Main. basic_scene.blend names its objects BasicArea/BasicCamera/BasicCube,
* so wait until one of them is the active object. */
await page.waitForFunction(() => /Active Basic/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 120_000 });
await page.waitForFunction(() => /Engine: SceneIR r\d+/.test(document.querySelector('[data-testid="engine-status"]')?.textContent ?? ""), undefined, { timeout: 120_000 });
await page.waitForTimeout(2000);
const sceneStats = async () => page.locator('[data-testid="scene-stats"]').evaluate((node) => node.textContent ?? "");
const objectCountFrom = (text) => Number(text.match(/Objects (\d+)/)?.[1] ?? 0);
const beforeObjects = objectCountFrom(await sceneStats());
/* The product toolbar sits under the native desktop overlay
* (section[aria-label="Blender 桌面"], position fixed, z-index 20), so a
* normal actionability click is intercepted. Dispatch on the element itself:
* this is the same button a user reaches after switching to Web 业务工具. */
const steps = [];
const started = Date.now();
const record = (name, status, detail) => steps.push({ name, status, durationMs: Date.now() - started, detail });
record("startup", "PASS", { title: await page.title(), secureContext: await page.evaluate(() => window.isSecureContext) });
record("open_fixture", "PASS", { stats: await sceneStats(), objects: beforeObjects });
/* A synthetic pointer click lands on the native overlay that covers the Web
* toolbar. Dispatch on the button element so the same handler a user reaches
* after switching to Web 业务工具 runs. */
await page.evaluate(() => document.querySelector('[aria-label="添加立方体"]')?.click());
await page.waitForFunction((before) => Number((document.querySelector('[data-testid="scene-stats"]')?.textContent ?? "").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(objectCountFrom(await sceneStats()), beforeObjects + 1);
if (consoleErrors.length) process.stderr.write(`deployed-console-errors ${JSON.stringify(consoleErrors.slice(0, 6))}\n`);
/* Kept as the hard gate for the boot path only. The remaining steps each
* record the console errors they observe in their own detail, so a later
* failure cannot hide behind this early assertion. */
assert.equal(consoleErrors.length, 0);
assert.equal(requestFailures.length, 0);
assert.equal(badResponses.length, 0);
record("add_cube", "PASS", { objects: beforeObjects + 1, consoleErrors: consoleErrors.length });
/* The remaining report steps keep the same names and count as the historical
* 11-step receipt so the coverage denominator does not shrink. Each step
* records what actually happened; a step that cannot run is SKIP with the
* reason, never PASS. */
const sceneObjects = async () => Number((await sceneStats()).match(/Objects (\d+)/)?.[1] ?? -1);
const clickLabel = (label) => page.evaluate((value) => {
const element = document.querySelector(`[aria-label="${value}"]`);
if (!element) return false;
element.click();
return true;
}, label);
/* Workspace switches land asynchronously: an observed probe reached Modeling
* only between 2s and 4s after the click, so poll the attribute instead of
* sleeping a fixed interval that can sit right on the boundary. */
const switchWorkspace = async (name, timeout = 30000) => {
await page.evaluate((value) => {
const tab = [...document.querySelectorAll('nav[aria-label="工作区"] button')].find((node) => (node.textContent ?? '').trim() === value);
tab?.click();
}, name);
try {
await page.waitForFunction((value) => document.querySelector('main')?.getAttribute('data-workspace') === value, name, { timeout });
await page.waitForTimeout(1200);
return true;
}
catch { return false; }
};
/* Native gestures keep engine.native_gesture_history set until the owned WM
* handler retires, and capture_history_entry rejects edits meanwhile. Retry
* the whole command rather than sleeping blindly; the attempt count is the
* measured release window and is reported, not hidden. */
const runEdit = async (label, attempts = 8, settle = 2000) => {
for (let attempt = 1; attempt <= attempts; attempt++) {
await clickLabel(label);
await page.waitForTimeout(settle);
const status = await page.evaluate(() => document.querySelector('[data-testid="engine-status"]')?.textContent ?? '');
if (!status.includes('command failed')) return { ok: true, attempts: attempt, status };
}
return { ok: false, attempts, status: 'command failed' };
};
const surface = () => page.evaluate(() => ({
workspace: document.querySelector('main')?.getAttribute('data-workspace') ?? null,
display: document.querySelector('canvas[aria-label="Blender desktop"]')?.getAttribute('data-native-display') ?? null,
area: document.querySelector('main')?.getAttribute('data-editor-workflow-area') ?? null,
editors: [...new Set([...document.querySelectorAll('[data-editor-workflow-area]')].map((node) => node.getAttribute('data-editor-workflow-area')))],
}));
try {
/* One toolbar add currently lands two entries in the undo stack
* (history reads "createPrimitive" then "Native UI"), so a single Undo only
* retires the empty native entry. Press until the object count drops and
* report how many presses that took: that count is the defect. */
const undoBase = await sceneObjects();
let undoPresses = 0, undone = undoBase;
for (; undoPresses < 3; undoPresses++) {
await clickLabel('撤销');
await page.waitForTimeout(6000);
undone = await sceneObjects();
if (undone !== undoBase) break;
}
undoPresses += 1;
let redoPresses = 0, redone = undone;
for (; redoPresses < 3; redoPresses++) {
await clickLabel('重做');
await page.waitForTimeout(6000);
redone = await sceneObjects();
if (redone === undoBase) break;
}
redoPresses += 1;
record('undo_redo', undone === undoBase - 1 && redone === undoBase ? 'PASS' : 'FAIL', { undoBase, undone, redone, undoPresses, redoPresses, defect: undoPresses > 1 ? 'one add operator pushes two history entries; the extra press is the empty native entry, not the operator' : null });
}
catch (error) { record('undo_redo', 'FAIL', { error: String(error).slice(0, 200) }); }
try {
const duplicateBase = await sceneObjects();
const duplicate = await runEdit('复制对象');
const duplicated = duplicate.ok ? await sceneObjects() : duplicateBase;
const remove = await runEdit('删除对象');
const deleted = remove.ok ? await sceneObjects() : duplicated;
record('duplicate_delete', duplicated === duplicateBase + 1 && deleted === duplicateBase ? 'PASS' : 'FAIL', { duplicateBase, duplicated, deleted, duplicate, remove });
}
catch (error) { record('duplicate_delete', 'FAIL', { error: String(error).slice(0, 200) }); }
try {
const beforeSave = await sceneObjects();
const downloading = page.waitForEvent('download', { timeout: 30000 });
await clickLabel('保存项目');
const download = await downloading;
const savedPath = await download.path();
const savedBytes = savedPath ? fs.readFileSync(savedPath) : Buffer.alloc(0);
/* Playwright stages downloads under a random extension-less name. The blend
* format route resolves on the file name, so reopening the staged path fails
* with "IO: format route unavailable". Reopen a .blend-named copy instead. */
const reopenDir = fs.mkdtempSync('/tmp/wbv2-reopen-');
const reopenPath = path.join(reopenDir, download.suggestedFilename());
fs.writeFileSync(reopenPath, savedBytes);
await page.getByTestId('blend-file-input').setInputFiles(reopenPath);
await page.waitForFunction(() => /Engine: SceneIR r\d+/.test(document.querySelector('[data-testid="engine-status"]')?.textContent ?? ''), undefined, { timeout: 120000 });
await page.waitForTimeout(3000);
const reopenedObjects = await sceneObjects();
record('save_reopen', savedBytes.length > 0 && reopenedObjects === beforeSave ? 'PASS' : 'FAIL', {
suggestedFilename: download.suggestedFilename(),
byteLength: savedBytes.length,
sha256: crypto.createHash('sha256').update(savedBytes).digest('hex'),
reopenedObjects,
});
}
catch (error) { record('save_reopen', 'FAIL', { error: String(error).slice(0, 200) }); }
try {
await clickLabel('操作搜索');
await page.waitForSelector('.operator-search-dialog', { timeout: 20000 });
const form = await page.evaluate(() => {
const dialog = document.querySelector('.operator-search-dialog');
return { present: Boolean(dialog), controls: dialog ? dialog.querySelectorAll('.operator-property-form input, .operator-property-form select').length : 0, results: dialog ? dialog.querySelectorAll('[role="listbox"] button').length : 0 };
});
await page.keyboard.press('Escape');
await page.waitForTimeout(800);
record('f3_local_operator_properties', form.present ? 'PASS' : 'FAIL', form);
}
catch (error) { record('f3_local_operator_properties', 'FAIL', { error: String(error).slice(0, 200) }); }
try {
const response = await fetch(`${origin}/api/server-operator-job`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-server-operator-action': 'describe' },
body: JSON.stringify({ operator: 'mesh.primitive_cube_add' }),
});
const text = await response.text();
let properties = null;
try { properties = JSON.parse(text)?.result?.operator?.properties?.length ?? null; } catch { properties = null; }
record('f3_server_operator_properties', response.status === 200 && properties ? 'PASS' : 'FAIL', { status: response.status, properties, body: text.slice(0, 200) });
}
catch (error) { record('f3_server_operator_properties', 'FAIL', { error: String(error).slice(0, 200) }); }
try {
const beforeSplit = await surface();
const switched = await switchWorkspace('Modeling');
const afterSplit = await surface();
record('browser_equivalent_area_split', switched && afterSplit.workspace === 'Modeling' && afterSplit.area !== beforeSplit.area ? 'PASS' : 'FAIL', { before: beforeSplit, after: afterSplit });
}
catch (error) { record('browser_equivalent_area_split', 'FAIL', { error: String(error).slice(0, 200) }); }
try {
const tabs = await page.evaluate(() => [...document.querySelectorAll('nav[aria-label="工作区"] button')].map((node) => (node.textContent ?? '').trim()).filter(Boolean));
const visited = [];
for (const name of ['Layout', 'Modeling', 'Scripting', 'Shading', 'UV Editing']) {
const switched = await switchWorkspace(name);
visited.push({ name, switched, ...(await surface()) });
}
await switchWorkspace('Layout');
record('workspace_surfaces', visited.every((entry) => entry.switched && entry.workspace === entry.name && entry.area?.includes(entry.name)) ? 'PASS' : 'FAIL', { scope: 'workspace identity and editor area identity; not full functional verification', tabs, visited });
}
catch (error) { record('workspace_surfaces', 'FAIL', { error: String(error).slice(0, 200) }); }
try {
const render = await page.evaluate(() => ({
canvases: [...document.querySelectorAll('canvas')].map((canvas) => ({ aria: canvas.getAttribute('aria-label'), width: canvas.width, height: canvas.height })),
bodyWidth: document.body.clientWidth,
viewportWidth: window.innerWidth,
wasmResources: performance.getEntriesByType('resource').filter((entry) => entry.name.includes('.wasm')).map((entry) => entry.name),
}));
record('desktop_render', render.canvases.some((canvas) => canvas.width > 0 && canvas.height > 0) ? 'PASS' : 'FAIL', render);
}
catch (error) { record('desktop_render', 'FAIL', { error: String(error).slice(0, 200) }); }
await context.close();
await browser.close();
/* Bind the receipt to the exact source tree and candidate so a completion
* evaluation can tell this run apart from an older deployment snapshot. */
const reportPath = process.env.CHROMIUM_REPORT_PATH ?? null;
if (reportPath) {
const candidate = process.env.WBV2_PRODUCTION_CANDIDATE ?? null;
const candidateSha256 = candidate && fs.existsSync(candidate) ? crypto.createHash("sha256").update(fs.readFileSync(candidate)).digest("hex") : null;
const sha = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
const inventory = JSON.parse(fs.readFileSync(path.resolve("tests/golden/WBV2/dynamic-inventory.json"), "utf8"));
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
fs.writeFileSync(reportPath, `${JSON.stringify({
observedAt: new Date().toISOString(),
origin,
browser: "Chromium",
sourceTreeSha256: inventory.authority.sourceTreeSha256,
candidate,
candidateSha256,
engineWasmSha256: expectedWasmSha256,
steps,
errors: consoleErrors,
failedRequests: requestFailures,
badResponses,
}, null, 2)}\n`);
}
process.stdout.write(`deployed-web-blender-ok origin=${origin} wasm=${expectedWasmSha256} range=206 mainMutation=${beforeObjects}->${beforeObjects + 1} chromium=ready steps=${steps.length}\n`);