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`);