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>
289 lines
17 KiB
JavaScript
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`);
|