#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { fileURLToPath } from "node:url"; import { chromium } from "../../../web/node_modules/playwright/index.mjs"; import { createDeploymentHttpServer, listenDeploymentHttpServer } from "../deployment-http-server.mjs"; const HERE = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(HERE, "../../.."); const DIST = path.join(ROOT, "web/dist"); const EVIDENCE = path.join(ROOT, "tests/golden/QA-WEB-BLENDER-20261111"); const CHROME = process.env.CHROME_PATH || "/usr/bin/google-chrome"; const GLB = path.join(ROOT, "tests/files/web/m12_glb_desktop_v1/pbr.glb"); const PBR_BLEND = path.join(ROOT, "tests/files/web/m12_glb_main_v1/pbr.blend"); const WORKER_SOURCE = fs.readFileSync(path.join(HERE, "opfs-sync-worker.js"), "utf8"); fs.mkdirSync(EVIDENCE, { recursive: true }); const results = { schemaVersion: 1, startedAt: new Date().toISOString(), cases: {}, environment: {} }; function writeEvidence(name, value) { const file = path.join(EVIDENCE, name); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, typeof value === "string" ? value : `${JSON.stringify(value, null, 2)}\n`); return file; } function sha256(buffer) { return crypto.createHash("sha256").update(buffer).digest("hex"); } function prepareQaAssets() { const directory = path.join(DIST, "__qa__"); if (fs.existsSync(directory)) throw new Error(`QA asset directory already exists: ${directory}`); fs.mkdirSync(directory, { recursive: true }); fs.writeFileSync(path.join(directory, "opfs-sync-worker.js"), WORKER_SOURCE); fs.writeFileSync(path.join(directory, "blank.html"), "QA worker host"); } function cleanupQaAssets() { fs.rmSync(path.join(DIST, "__qa__"), { recursive: true, force: true }); } async function launchContext(browser) { const context = await browser.newContext({ viewport: { width: 1600, height: 1000 }, acceptDownloads: true }); await context.addInitScript(() => { const NativeWorker = window.Worker; window.__qaRequests = []; window.__qaResponses = []; window.Worker = class extends NativeWorker { constructor(url, options) { super(url, options); if (String(url).includes("web-engine.worker")) window.__nativeWorkflowEngine = this; const post = this.postMessage.bind(this); this.postMessage = (message, ...rest) => { try { window.__qaRequests.push(JSON.parse(JSON.stringify(message))); } catch {} return post(message, ...rest); }; this.addEventListener("message", (event) => { try { window.__qaResponses.push(JSON.parse(JSON.stringify(event.data))); } catch {} }); } }; }); return context; } async function openApp(page, origin, query = "?ui=web") { await page.goto(`${origin}/${query}`, { waitUntil: "domcontentloaded" }); await page.locator('[data-testid="blend-file-input"]').waitFor({ state: "attached", timeout: 60_000 }); await page.waitForTimeout(2_000); } async function waitEngine(page) { await page.locator(".blender-app").waitFor({ state: "attached", timeout: 60_000 }); await page.getByTestId("engine-status").filter({ hasText: /SceneIR/ }).first().waitFor({ timeout: 90_000 }); } async function nativeRequest(page, command, timeout = 30_000) { const requestId = `qa-${Date.now()}-${Math.random().toString(16).slice(2)}`; return page.evaluate(({ requestId, command, timeout }) => new Promise((resolve, reject) => { const worker = window.__nativeWorkflowEngine; if (!worker) { reject(new Error("native workflow worker unavailable")); return; } const timer = setTimeout(() => { worker.removeEventListener("message", listener); reject(new Error("native request timeout")); }, timeout); const listener = (event) => { if (event.data?.requestId !== requestId) return; clearTimeout(timer); worker.removeEventListener("message", listener); resolve(event.data); }; worker.addEventListener("message", listener); worker.postMessage({ requestId, command }); }), { requestId, command, timeout }); } async function snapshot(page) { const response = await nativeRequest(page, { type: "snapshot" }, 60_000); if (!response.ok) throw new Error(`snapshot failed: ${JSON.stringify(response.error ?? response)}`); return response.result.snapshot; } async function saveProject(page, filePath) { const downloadPromise = page.waitForEvent("download", { timeout: 60_000 }); await page.getByRole("button", { name: "保存项目", exact: true }).click(); const download = await downloadPromise; await download.saveAs(filePath); return { filePath, bytes: fs.statSync(filePath).size, sha256: sha256(fs.readFileSync(filePath)) }; } async function opfsTree(page) { return page.evaluate(async () => { const root = await navigator.storage.getDirectory(); const walk = async (directory, prefix) => { const output = []; for await (const [name, handle] of directory.entries()) { const relative = prefix ? `${prefix}/${name}` : name; if (handle.kind === "directory") { output.push({ path: relative, kind: "directory" }); output.push(...await walk(handle, relative)); continue; } const file = await handle.getFile(); const item = { path: relative, kind: "file", size: file.size }; if (file.size <= 8 * 1024 * 1024) { const data = await file.arrayBuffer(); const digest = await crypto.subtle.digest("SHA-256", data); item.sha256 = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); if (file.size <= 512 * 1024 && (name.endsWith(".json") || name.endsWith(".meta.json"))) { item.text = new TextDecoder().decode(data).slice(0, 20000); } } output.push(item); } return output; }; return walk(root, ""); }); } async function indexedDbAudit(page) { return page.evaluate(async () => { const databases = await indexedDB.databases(); const output = []; for (const entry of databases) { if (!entry.name) continue; const db = await new Promise((resolve, reject) => { const request = indexedDB.open(entry.name); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); }); const stores = []; for (const storeName of db.objectStoreNames) { const count = await new Promise((resolve) => { try { const transaction = db.transaction(storeName, "readonly"); const request = transaction.objectStore(storeName).count(); request.onsuccess = () => resolve(request.result); request.onerror = () => resolve(-1); } catch { resolve(-1); } }); stores.push({ name: storeName, count }); } const localKeys = Object.keys(localStorage); db.close(); output.push({ name: entry.name, version: entry.version, stores, localKeys }); } return { databases: output, localStorageKeys: Object.keys(localStorage).sort() }; }); } async function mainWorkerProbe(page) { return page.evaluate(async () => { const root = await navigator.storage.getDirectory(); const directory = await root.getDirectoryHandle("qa", { create: true }); const file = await directory.getFileHandle("main-thread-probe.bin", { create: true }); const method = file.createSyncAccessHandle; const result = { methodType: typeof method, call: "not-called" }; if (typeof method === "function") { try { const handle = await method.call(file); handle.close(); result.call = "resolved"; } catch (error) { result.call = error?.name ?? "Error"; result.message = error?.message ?? String(error); } } return result; }); } async function workerRequest(page, payload) { return page.evaluate((payload) => new Promise((resolve, reject) => { const worker = new Worker("/__qa__/opfs-sync-worker.js"); const timer = setTimeout(() => { worker.terminate(); reject(new Error("qa worker timeout")); }, 20_000); worker.onmessage = (event) => { if (event.data?.requestId !== payload.requestId) return; clearTimeout(timer); window.__qaSyncWorkers ||= {}; window.__qaSyncWorkers[payload.id ?? payload.requestId] = { worker, handleId: payload.id ?? payload.requestId }; resolve(event.data); }; worker.onerror = (error) => { clearTimeout(timer); worker.terminate(); reject(new Error(error.message)); }; worker.postMessage(payload); }), payload); } async function closeWorkerHandle(page, id) { return page.evaluate((id) => new Promise((resolve) => { const entry = window.__qaSyncWorkers?.[id]; if (!entry) { resolve({ ok: false, name: "QA_WORKER_MISSING" }); return; } const timer = setTimeout(() => { entry.worker.terminate(); resolve({ ok: false, name: "QA_CLOSE_TIMEOUT" }); }, 10_000); entry.worker.onmessage = (event) => { if (event.data?.requestId !== `close-${id}`) return; clearTimeout(timer); entry.worker.terminate(); delete window.__qaSyncWorkers[id]; resolve(event.data); }; entry.worker.postMessage({ op: "close", requestId: `close-${id}`, id }); }), id); } async function runCase5(context, origin) { const started = Date.now(); const page = await context.newPage(); const network = []; page.on("request", (request) => { const url = request.url(); if (!url.includes("/node_modules/") && !url.includes("/src/")) network.push({ phase: "request", url, resourceType: request.resourceType() }); }); const record = { steps: [], evidence: {} }; const step = (name, value) => { record.steps.push({ name, at: new Date().toISOString(), value }); console.log("[case5]", name, JSON.stringify(value).slice(0, 300)); }; try { await openApp(page, origin); await page.locator('[data-testid="blend-file-input"]').setInputFiles(GLB); await page.waitForTimeout(8_000); await waitEngine(page).catch(() => {}); record.projectId = await page.locator(".blender-app").getAttribute("data-project-id").catch(() => null); step("import-glb", { projectId: record.projectId, status: await page.getByTestId("engine-status").innerText().catch(() => "") }); const dryRun = path.join(EVIDENCE, "case5-imported-model.json"); const snapshotBefore = await snapshot(page); writeEvidence("case5-scene-snapshot.json", snapshotBefore); step("snapshot", { nodes: snapshotBefore.nodes.length, meshes: snapshotBefore.meshes.length, materials: snapshotBefore.materials.length }); const saved = await saveProject(page, dryRun); step("save-project", { bytes: saved.bytes, sha256: saved.sha256 }); const tree = await opfsTree(page); record.evidence.opfsTree = "case5-opfs-tree.json"; writeEvidence("case5-opfs-tree.json", { tree, projectId: record.projectId, saved }); const projectFiles = tree.filter((item) => item.kind === "file" && item.path.startsWith(`projects/${record.projectId}/`)); step("opfs-project-files", projectFiles.map((item) => ({ path: item.path, size: item.size, sha256: item.sha256 }))); const jsonFiles = projectFiles.filter((item) => item.path.endsWith(".json")); step("opfs-json-files", jsonFiles.map((item) => ({ path: item.path, size: item.size }))); step("opfs-has-model-json", jsonFiles.some((item) => /model|mesh|material/i.test(item.path))); const mainThread = await mainWorkerProbe(page); step("main-thread-sync-handle", mainThread); const workerCapability = await workerRequest(page, { op: "capability", requestId: "qa-worker-capability" }); step("worker-sync-capability", workerCapability); const workerReadWrite = await workerRequest(page, { op: "acquire", requestId: "qa-worker-rw", id: "qa-worker-rw", path: "qa/rw.bin", mode: "readwrite", write: [1, 2, 3, 4] }); step("worker-sync-readwrite", workerReadWrite); if (workerReadWrite.ok) await closeWorkerHandle(page, "qa-worker-rw"); const matrix = []; const rows = [ { name: "readwrite+readwrite", aMode: "readwrite", bMode: "readwrite" }, { name: "read-only+read-only", aMode: "read-only", bMode: "read-only" }, { name: "readwrite+read-only", aMode: "readwrite", bMode: "read-only" }, { name: "read-only+readwrite", aMode: "read-only", bMode: "readwrite" }, { name: "readwrite-unsafe+readwrite-unsafe", aMode: "readwrite-unsafe", bMode: "readwrite-unsafe" }, ]; for (const [index, row] of rows.entries()) { const pageA = await context.newPage(); const pageB = await context.newPage(); await pageA.goto(`${origin}/__qa__/blank.html`, { waitUntil: "domcontentloaded" }); await pageB.goto(`${origin}/__qa__/blank.html`, { waitUntil: "domcontentloaded" }); const filePath = `qa/port-${index}.bin`; const a = await workerRequest(pageA, { op: "acquire", requestId: `qa-a-${index}`, id: `qa-a-${index}`, path: filePath, mode: row.aMode, ...(row.aMode === "read-only" ? {} : { write: [1] }) }); const b = await workerRequest(pageB, { op: "acquire", requestId: `qa-b-${index}`, id: `qa-b-${index}`, path: filePath, mode: row.bMode }); matrix.push({ ...row, a, b }); if (a.ok) await closeWorkerHandle(pageA, `qa-a-${index}`); if (b.ok) await closeWorkerHandle(pageB, `qa-b-${index}`); await pageA.close(); await pageB.close(); } record.evidence.syncModeMatrix = "case5-sync-mode-matrix.json"; writeEvidence("case5-sync-mode-matrix.json", matrix); step("sync-mode-matrix", matrix.map((row) => ({ name: row.name, a: row.a.ok ?? false, bOk: row.b.ok ?? false, bError: row.b.name }))); const cdp = await context.newCDPSession(page); const originURL = new URL(origin).origin; const quotaBefore = await cdp.send("Storage.getUsageAndQuota", { origin: originURL }).catch((error) => ({ error: error.message })); let quotaResult = null; try { const usage = quotaBefore.usage ?? 0; await cdp.send("Storage.overrideQuotaForOrigin", { origin: originURL, quotaSize: usage + 96 * 1024 }); quotaResult = await page.evaluate(async () => { const root = await navigator.storage.getDirectory(); const dir = await root.getDirectoryHandle("qa", { create: true }); const handle = await dir.getFileHandle("quota-probe.bin", { create: true }); const writer = await handle.createWritable(); try { await writer.write(new Uint8Array(128 * 1024)); await writer.close(); return { ok: true }; } catch (error) { return { ok: false, name: error?.name, message: error?.message }; } }); } catch (error) { quotaResult = { ok: false, setupError: error.message }; } await cdp.send("Storage.overrideQuotaForOrigin", { origin: originURL, quotaSize: quotaBefore.quota ?? 0 }).catch(() => {}); step("quota", { before: quotaBefore, write: quotaResult }); record.evidence.quota = "case5-quota.json"; writeEvidence("case5-quota.json", { before: quotaBefore, write: quotaResult }); const persist = await page.evaluate(async () => ({ persist: await navigator.storage.persist().catch((error) => `error:${error.name}`), persisted: await navigator.storage.persisted().catch((error) => `error:${error.name}`), estimate: await navigator.storage.estimate().catch((error) => ({ error: error.name })), permission: await navigator.permissions.query({ name: "persistent-storage" }).then((status) => status.state).catch((error) => `error:${error.name}`), })); step("persist-estimate", persist); record.evidence.persist = "case5-persist.json"; writeEvidence("case5-persist.json", persist); const audit = await indexedDbAudit(page); record.evidence.indexedDbAudit = "case5-indexeddb-audit.json"; writeEvidence("case5-indexeddb-audit.json", audit); step("indexeddb-audit", audit.databases.map((db) => ({ name: db.name, stores: db.stores.map((store) => `${store.name}:${store.count}`) }))); const hasMaterialDelete = await page.getByRole("button", { name: /删除材质|Delete Material/ }).count(); step("material-delete-entry", { count: hasMaterialDelete }); const cleanup = page.getByTestId("cleanup-project-assets"); if (await cleanup.count()) { await cleanup.click(); await page.waitForTimeout(1_500); } const treeAfterCleanup = await opfsTree(page); writeEvidence("case5-opfs-tree-after-cleanup.json", treeAfterCleanup); const firstLoad = Date.now(); await page.close(); const reopen = await context.newPage(); const reopenRequests = []; reopen.on("request", (request) => { const url = request.url(); if (/\.(glb|gltf|blend|png|jpe?g)(\?|$)/i.test(url)) reopenRequests.push(url); }); const reopenStart = Date.now(); await reopen.goto(`${origin}/?ui=web`, { waitUntil: "domcontentloaded" }); await reopen.waitForTimeout(3_000); let recovery = null; await reopen.locator("main.blender-app").waitFor({ state: "attached", timeout: 60_000 }).catch(() => {}); await reopen.waitForFunction(() => document.querySelector("main.blender-app")?.getAttribute("aria-busy") === "false", { timeout: 60_000 }).catch(() => {}); const recent = reopen.getByTestId("recent-projects"); if (await recent.count()) { const values = await recent.locator("option").evaluateAll((options) => options.map((option) => ({ value: option.value, text: option.textContent }))); const match = values.find((option) => option.value === record.projectId) ?? values.find((option) => option.text.includes(record.projectId ?? "___")); if (match) { await recent.selectOption(match.value); await reopen.waitForTimeout(12_000); recovery = await reopen.getByTestId("engine-status").innerText().catch(() => ""); } } step("reopen", { elapsedMs: Date.now() - reopenStart, recovery, networkModelRequests: reopenRequests.slice(0, 20) }); record.evidence.reopen = "case5-reopen-network.json"; writeEvidence("case5-reopen-network.json", { elapsedMs: Date.now() - reopenStart, recovery, requests: reopenRequests, network }); await reopen.screenshot({ path: path.join(EVIDENCE, "case5-reopen.png"), fullPage: false }); record.verdict = "MISSING_OR_PARTIAL"; record.actualSummary = { opfsProjectFiles: projectFiles.length, modelMaterialJson: jsonFiles.some((item) => /model|mesh|material/i.test(item.path)), mainThreadSyncMethod: mainThread.methodType, workerSyncReadWrite: workerReadWrite.ok === true, syncModeMatrix: matrix.map((row) => ({ name: row.name, aOk: row.a.ok === true, bOk: row.b.ok === true, bError: row.b.name })), quotaCaught: quotaResult?.ok === false && quotaResult?.name === "QuotaExceededError", persist: persist.persist, indexedDbDatabases: audit.databases.map((db) => db.name), materialDeleteEntry: hasMaterialDelete, reopenModelNetworkRequests: reopenRequests.length, }; } catch (error) { record.error = { name: error?.name, message: error?.message, stack: String(error?.stack).slice(0, 4000) }; await page.screenshot({ path: path.join(EVIDENCE, "case5-failure.png"), fullPage: false }).catch(() => {}); } finally { await page.close().catch(() => {}); record.durationMs = Date.now() - started; results.cases.case5 = record; writeEvidence("case5-runtime.json", record); } } async function runCase3(context, origin) { const started = Date.now(); const page = await context.newPage(); const record = { steps: [], evidence: {} }; const step = (name, value) => { record.steps.push({ name, at: new Date().toISOString(), value }); console.log("[case3]", name, JSON.stringify(value).slice(0, 400)); }; try { await openApp(page, origin); await page.locator('[data-testid="blend-file-input"]').setInputFiles(PBR_BLEND); await page.waitForTimeout(7_000); await waitEngine(page); await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true }); await page.waitForTimeout(2_500); await page.locator(".blender-shader-canvas").waitFor({ state: "visible", timeout: 30_000 }); const snapshotBefore = await snapshot(page); writeEvidence("case3-scene-before.json", snapshotBefore); const material = snapshotBefore.materials[0]; step("material", { id: material.id, name: material.name, nodes: material.nodes?.length, links: material.links?.length }); const world = snapshotBefore.worlds?.[0]; step("world", { environmentImageId: world?.environmentImageId ?? null, environmentStrength: world?.environmentStrength ?? null, environmentRotation: world?.environmentRotation ?? null }); const canvas = page.locator("canvas.viewport-canvas").first(); const canvasData = await canvas.evaluate((element) => ({ ...element.dataset })).catch(() => ({})); step("viewport-datasets", { toneMapping: canvasData.toneMapping, viewTransform: canvasData.viewTransform, iblStatus: canvasData.iblStatus, textureLoaded: canvasData.textureLoaded }); await page.screenshot({ path: path.join(EVIDENCE, "case3-shading-before.png"), fullPage: false }); await canvas.screenshot({ path: path.join(EVIDENCE, "case3-viewport-before.png") }).catch(() => {}); const previewSelectors = [ '[data-testid*="material-preview"]', '[aria-label*="材质预览"]', '[aria-label*="Material Preview"]', '.material-preview', '.preview-ball', '[data-editor="Material Preview"]', ]; const previewCounts = {}; for (const selector of previewSelectors) previewCounts[selector] = await page.locator(selector).count(); const workflowAreas = (snapshotBefore.editorWorkflow?.workspaces ?? []).flatMap((workspace) => workspace.areas.map((area) => ({ workspace: workspace.name, editor: area.editor, kind: area.space?.kind }))); step("material-preview-probe", { previewCounts, workflowAreas }); const socketNames = ["Base Color", "Metallic", "Roughness", "IOR", "Alpha", "Normal", "Subsurface Weight", "Subsurface Radius", "Specular IOR Level", "Coat Weight", "Coat Roughness", "Sheen Weight", "Sheen Roughness", "Emission Color", "Emission Strength"]; const primary = new Set(["Base Color", "Metallic", "Roughness", "IOR", "Alpha", "Normal", "Emission Color", "Emission Strength", "Transmission Weight", "Coat Weight", "Coat Roughness", "Sheen Weight", "Specular IOR Level"]); const properties = new Set(["Base Color", "Metallic", "Roughness", "IOR", "Specular IOR Level", "Coat Weight", "Coat Roughness", "Emission Strength"]); const render = new Set(["Base Color", "Metallic", "Roughness", "IOR", "Alpha", "Normal", "Specular IOR Level", "Coat Weight", "Coat Roughness", "Emission Color", "Emission Strength"]); const principled = (material.nodes ?? []).find((node) => node.type === "PRINCIPLED"); const socketTable = socketNames.map((name) => { const socket = (principled?.sockets ?? []).find((candidate) => candidate.name === name || candidate.id === name); return { socket: name, nodeSocket: Boolean(socket), socketId: socket?.id ?? null, dataType: socket?.dataType ?? null, defaultVisible: primary.has(name), propertiesControl: properties.has(name), renderPath: render.has(name), classification: !socket ? "缺失" : !render.has(name) ? "节点存在/渲染缺失" : properties.has(name) ? "存在且可连接+滑块" : "存在且可连接", }; }); writeEvidence("case3-principled-sockets.json", socketTable); step("principled-sockets", { total: socketTable.length, nodeSockets: socketTable.filter((row) => row.nodeSocket).length, render: socketTable.filter((row) => row.renderPath).length, properties: socketTable.filter((row) => row.propertiesControl).length, table: socketTable }); const principledNode = page.locator('.blender-shader-node[aria-label="Principled BSDF"]').first(); const beforeRoughness = principledNode.locator('input[aria-label="Roughness"]').first(); const baseInputs = principledNode.locator('input[aria-label^="Base Color "]'); step("base-color-input-count", await baseInputs.count()); if (await baseInputs.count() === 4) { await baseInputs.nth(0).fill("1"); await baseInputs.nth(0).press("Enter"); await baseInputs.nth(1).fill("0"); await baseInputs.nth(1).press("Enter"); await baseInputs.nth(2).fill("0"); await baseInputs.nth(2).press("Enter"); await baseInputs.nth(3).fill("1"); await baseInputs.nth(3).press("Enter"); await page.waitForTimeout(1_200); await baseInputs.nth(0).fill("0"); await baseInputs.nth(0).press("Enter"); await baseInputs.nth(1).fill("0"); await baseInputs.nth(1).press("Enter"); await baseInputs.nth(2).fill("1"); await baseInputs.nth(2).press("Enter"); await baseInputs.nth(3).fill("1"); await baseInputs.nth(3).press("Enter"); } if (await beforeRoughness.count()) { await beforeRoughness.fill("0.8"); await beforeRoughness.press("Enter"); } await page.waitForTimeout(2_000); const snapshotAfter = await snapshot(page); writeEvidence("case3-scene-after.json", snapshotAfter); const materialAfter = snapshotAfter.materials[0]; const principledAfter = (materialAfter.nodes ?? []).find((node) => node.type === "PRINCIPLED"); const roughnessAfter = (principledAfter?.sockets ?? []).find((socket) => socket.name === "Roughness" || socket.id === "Roughness"); step("edit-result", { revisionBefore: snapshotBefore.revision, revisionAfter: snapshotAfter.revision, baseColor: materialAfter.baseColor, roughness: roughnessAfter?.value }); await page.screenshot({ path: path.join(EVIDENCE, "case3-shading-after.png"), fullPage: false }); await canvas.screenshot({ path: path.join(EVIDENCE, "case3-viewport-after.png") }).catch(() => {}); const frameTiming = await page.evaluate(() => new Promise((resolve) => { const start = performance.now(); requestAnimationFrame(() => requestAnimationFrame(() => resolve({ twoFramesMs: performance.now() - start, now: performance.now() }))); })); step("frame-timing", frameTiming); record.verdict = "MISSING_PREVIEW"; record.actualSummary = { materialPreviewSelectors: previewCounts, materialPreviewAreas: workflowAreas.filter((area) => /preview|material/i.test(`${area.editor} ${area.kind}`)), materialId: material.id, environmentImageId: world?.environmentImageId ?? null, revisionBefore: snapshotBefore.revision, revisionAfter: snapshotAfter.revision, roughnessAfter: roughnessAfter?.value ?? null, socketCounts: { total: socketTable.length, nodeSockets: socketTable.filter((row) => row.nodeSocket).length, render: socketTable.filter((row) => row.renderPath).length, properties: socketTable.filter((row) => row.propertiesControl).length }, note: "No Web material preview ball; viewport-only evidence recorded.", }; } catch (error) { record.error = { name: error?.name, message: error?.message, stack: String(error?.stack).slice(0, 4000) }; await page.screenshot({ path: path.join(EVIDENCE, "case3-failure.png"), fullPage: false }).catch(() => {}); } finally { await page.close().catch(() => {}); record.durationMs = Date.now() - started; results.cases.case3 = record; writeEvidence("case3-runtime.json", record); } } async function applyCommand(page, payload, timeout = 60_000) { const response = await nativeRequest(page, { type: "applyCommand", payload }, timeout); if (!response.ok) throw new Error(`command ${payload.type} failed: ${JSON.stringify(response.error ?? response)}`); return response.result?.snapshot ?? null; } function nodeByName(snapshot, name) { return (snapshot.nodes ?? []).find((node) => node.name === name); } function matrixTranslation(matrix) { return matrix && matrix.length >= 16 ? [matrix[12], matrix[13], matrix[14]] : null; } function near(a, b, tolerance = 1e-5) { return Math.abs(a - b) <= tolerance; } function nearVector(a, b, tolerance = 1e-5) { return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((value, index) => near(value, b[index], tolerance)); } async function runCase1(context, origin) { const started = Date.now(); const page = await context.newPage(); const record = { steps: [], evidence: {}, method: "command-level (product UI parent menu exists; command path reused for deterministic matrices)" }; const step = (name, value) => { record.steps.push({ name, at: new Date().toISOString(), value }); console.log("[case1]", name, JSON.stringify(value).slice(0, 500)); }; try { await openApp(page, origin); await page.locator('[data-testid="blend-file-input"]').setInputFiles(path.join(ROOT, "tests/files/web/basic_scene.blend")); await page.waitForTimeout(6_000); await waitEngine(page); for (const [name, location] of [["QA_Base", [0, 0, 0]], ["QA_Upper", [0, 0, 1]], ["QA_Forearm", [0, 0, 2]]]) { await applyCommand(page, { type: "createPrimitive", primitive: "CUBE", name, location }); await page.waitForTimeout(350); } await page.waitForTimeout(700); let scene = await snapshot(page); const base = nodeByName(scene, "QA_Base"), upper = nodeByName(scene, "QA_Upper"), forearm = nodeByName(scene, "QA_Forearm"); step("created", { base: base?.id, upper: upper?.id, forearm: forearm?.id, revisions: scene.revision }); if (!base || !upper || !forearm) throw new Error("created joint objects missing"); await applyCommand(page, { type: "setParents", objectIds: [upper.id], parentId: base.id, keepTransform: false }); await applyCommand(page, { type: "setParents", objectIds: [forearm.id], parentId: upper.id, keepTransform: false }); await page.waitForTimeout(800); scene = await snapshot(page); const beforeUpper = nodeByName(scene, "QA_Upper"), beforeForearm = nodeByName(scene, "QA_Forearm"); step("parented", { upperParent: beforeUpper?.parentId, forearmParent: beforeForearm?.parentId, forearmLocal: beforeForearm?.localMatrix, forearmWorld: beforeForearm?.worldMatrix }); const parentTranslation = matrixTranslation(beforeUpper?.worldMatrix); const localBefore = beforeForearm?.localMatrix?.slice(0, 16); const worldBefore = matrixTranslation(beforeForearm?.worldMatrix); await applyCommand(page, { type: "setObjectTransform", objectId: base.id, translation: [1, 0, 0], rotationEuler: [0, 0, 0], scale: [1, 1, 1] }); await page.waitForTimeout(900); scene = await snapshot(page); const movedBase = nodeByName(scene, "QA_Base"), movedUpper = nodeByName(scene, "QA_Upper"), movedForearm = nodeByName(scene, "QA_Forearm"); const worldAfter = matrixTranslation(movedForearm?.worldMatrix), localAfter = movedForearm?.localMatrix?.slice(0, 16); step("parent-move-result", { baseWorld: matrixTranslation(movedBase?.worldMatrix), upperWorld: matrixTranslation(movedUpper?.worldMatrix), forearmWorld: worldAfter, forearmLocal: localAfter, localUnchanged: nearVector(localBefore, localAfter), worldDelta: worldAfter && worldBefore ? worldAfter.map((value, index) => value - worldBefore[index]) : null }); await applyCommand(page, { type: "setObjectTransform", objectId: base.id, translation: [1, 0, 0], rotationEuler: [Math.PI / 2, 0, 0], scale: [1, 1, 1] }); await page.waitForTimeout(900); scene = await snapshot(page); const rotatedForearm = nodeByName(scene, "QA_Forearm"); const rotatedWorld = matrixTranslation(rotatedForearm?.worldMatrix); step("parent-rotation-result", { forearmWorld: rotatedWorld, forearmLocal: rotatedForearm?.localMatrix }); const inheritRotation = await page.evaluate(() => ({ objectRnaExists: typeof window.__qaObjectInheritRotation, uiLabels: [...document.querySelectorAll("label,button,select")].map((element) => element.textContent ?? "").filter((text) => /inherit rotation|继承旋转/i.test(text)).slice(0, 5), })).catch(() => ({})); step("inherit-rotation-probe", inheritRotation); record.evidence.sceneBefore = "case1-parented.json"; writeEvidence("case1-parented.json", { beforeUpper, beforeForearm }); record.evidence.sceneAfter = "case1-moved.json"; writeEvidence("case1-moved.json", { movedBase, movedUpper, movedForearm, rotatedForearm }); record.verdict = "OBJECT_PARENT_PASS_BONE_PARTIAL"; record.actualSummary = { objectParenting: Boolean(beforeForearm?.parentId === beforeUpper?.id), localUnchanged: nearVector(localBefore, localAfter), worldDelta: worldAfter && worldBefore ? worldAfter.map((value, index) => value - worldBefore[index]) : null, forearmWorldAfterRotation: rotatedWorld, inheritRotationUi: inheritRotation, boneScope: "Armature/bone parenting and inherit rotation are not rendered in three-adapter; separate gap.", }; } catch (error) { record.error = { name: error?.name, message: error?.message, stack: String(error?.stack).slice(0, 4000) }; await page.screenshot({ path: path.join(EVIDENCE, "case1-failure.png"), fullPage: false }).catch(() => {}); } finally { await page.close().catch(() => {}); record.durationMs = Date.now() - started; results.cases.case1 = record; writeEvidence("case1-runtime.json", record); } } function meshSummary(snapshot, objectName) { const node = nodeByName(snapshot, objectName) ?? (snapshot.nodes ?? []).find((candidate) => candidate.type === "MESH"); const mesh = (snapshot.meshes ?? []).find((candidate) => candidate.id === node?.dataId); return { object: node ? { id: node.id, name: node.name, dataId: node.dataId } : null, mesh: mesh ? { id: mesh.id, triangleCount: mesh.triangleCount, faceCount: mesh.faceCount, vertexCount: mesh.vertexCount, attributes: mesh.attributes, uvLayers: mesh.uvLayers, colorAttributes: mesh.colorAttributes, materialSlotIds: mesh.materialSlotIds, modifierStack: mesh.modifierStack, } : null, }; } async function runCase2And6(context, origin) { const started = Date.now(); const page = await context.newPage(); const record = { steps: [], evidence: {} }; const step = (id, name, value) => { record.steps.push({ id, name, at: new Date().toISOString(), value }); console.log(`[${id}]`, name, JSON.stringify(value).slice(0, 600)); }; try { await openApp(page, origin); await page.locator('[data-testid="blend-file-input"]').setInputFiles(path.join(ROOT, "tests/files/web/mesh_simplify_high.blend")); await page.waitForTimeout(7_000); await waitEngine(page); let scene = await snapshot(page); const before = meshSummary(scene, "SimplifyHighSphere"); step("case2", "before", before); writeEvidence("case2-before.json", before); const treeItem = page.getByRole("treeitem", { name: /SimplifyHighSphere/ }).first(); if (await treeItem.count()) await treeItem.click().catch(() => {}); await page.waitForTimeout(800); const previewButton = page.getByRole("button", { name: "预览 Decimate", exact: true }); const applyButton = page.getByRole("button", { name: "应用 Decimate", exact: true }); step("case2", "buttons", { preview: await previewButton.count(), apply: await applyButton.count() }); if (await previewButton.count()) { await previewButton.click(); await page.waitForTimeout(3_000); step("case2", "preview-count", await page.getByTestId("simplify-triangle-counts").innerText().catch(() => "unavailable")); await page.screenshot({ path: path.join(EVIDENCE, "case2-preview.png"), fullPage: false }); const cancel = page.getByRole("button", { name: "取消 Decimate 预览", exact: true }); if (await cancel.count()) await cancel.click(); await page.waitForTimeout(1_000); } if (await applyButton.count()) { await applyButton.click(); await page.waitForTimeout(5_000); scene = await snapshot(page); const after = meshSummary(scene, "SimplifyHighSphere"); step("case2", "after-apply", after); step("case2", "triangle-count-text", await page.getByTestId("simplify-triangle-counts").innerText().catch(() => "unavailable")); writeEvidence("case2-after.json", after); record.case2 = { before, after, verdict: "PARTIAL_OR_PASS_ATTRIBUTES" }; } else { record.case2 = { before, verdict: "UI_ENTRY_MISSING" }; } } catch (error) { record.error = { name: error?.name, message: error?.message, stack: String(error?.stack).slice(0, 4000) }; await page.screenshot({ path: path.join(EVIDENCE, "case2-failure.png"), fullPage: false }).catch(() => {}); } finally { await page.close().catch(() => {}); record.durationMs = Date.now() - started; results.cases.case2 = record; writeEvidence("case2-6-runtime.json", record); } } async function runCase6(context, origin) { const started = Date.now(); const page = await context.newPage(); const record = { steps: [], evidence: {} }; const step = (name, value) => { record.steps.push({ name, at: new Date().toISOString(), value }); console.log("[case6]", name, JSON.stringify(value).slice(0, 600)); }; try { await openApp(page, origin); await page.locator('[data-testid="blend-file-input"]').setInputFiles(path.join(ROOT, "tests/files/web/modifier_stack_scene.blend")); await page.waitForTimeout(6_000); await waitEngine(page); let scene = await snapshot(page); const stackBefore = meshSummary(scene, "StackTarget"); step("before", stackBefore); const treeStack = page.getByRole("treeitem", { name: /StackTarget/ }).first(); if (await treeStack.count()) await treeStack.click().catch(() => {}); await page.waitForTimeout(600); const meshId = stackBefore.mesh?.id; if (!meshId) throw new Error("StackTarget mesh unavailable"); for (const [type, name] of [["DECIMATE", "QA Decimate"], ["ARRAY", "QA Array"]]) { await applyCommand(page, { type: "addModifier", meshId, modifierType: type, name }); await page.waitForTimeout(600); } scene = await snapshot(page); const stackAdded = meshSummary(scene, "StackTarget"); step("added", stackAdded); const decimateModifier = (stackAdded.mesh?.modifierStack ?? []).find((modifier) => modifier.name === "QA Decimate"); if (!decimateModifier?.uuid) throw new Error("QA Decimate modifier uuid unavailable"); await applyCommand(page, { type: "setModifierParameters", meshId, modifierUuid: decimateModifier.uuid, parameters: { ratio: 0.5 } }); await page.waitForTimeout(600); scene = await snapshot(page); const stackRatio50 = meshSummary(scene, "StackTarget"); step("ratio-50", stackRatio50); await applyCommand(page, { type: "setModifierParameters", meshId, modifierUuid: decimateModifier.uuid, parameters: { ratio: 0.3 } }); await page.waitForTimeout(600); scene = await snapshot(page); const stackRatio30 = meshSummary(scene, "StackTarget"); step("ratio-30", stackRatio30); await applyCommand(page, { type: "removeModifier", meshId, modifierUuid: decimateModifier.uuid }); await page.waitForTimeout(600); scene = await snapshot(page); const stackRemoved = meshSummary(scene, "StackTarget"); step("removed-decimate", stackRemoved); writeEvidence("case6-stack.json", { stackBefore, stackAdded, stackRatio50, stackRatio30, stackRemoved }); record.verdict = "STACK_METADATA_ONLY"; record.actualSummary = { beforeTriangleCount: stackBefore.mesh?.triangleCount, addedTriangleCount: stackAdded.mesh?.triangleCount, removedTriangleCount: stackRemoved.mesh?.triangleCount, beforeStack: stackBefore.mesh?.modifierStack, addedStack: stackAdded.mesh?.modifierStack, ratio50Stack: stackRatio50.mesh?.modifierStack, ratio30Stack: stackRatio30.mesh?.modifierStack, removedStack: stackRemoved.mesh?.modifierStack, }; } catch (error) { record.error = { name: error?.name, message: error?.message, stack: String(error?.stack).slice(0, 4000) }; await page.screenshot({ path: path.join(EVIDENCE, "case6-failure.png"), fullPage: false }).catch(() => {}); } finally { await page.close().catch(() => {}); record.durationMs = Date.now() - started; results.cases.case6 = record; writeEvidence("case6-runtime.json", record); } } async function runCase2Extra(context, origin) { const started = Date.now(); const record = { steps: [], evidence: {} }; const step = (name, value) => { record.steps.push({ name, at: new Date().toISOString(), value }); console.log("[case2extra]", name, JSON.stringify(value).slice(0, 600)); }; const page = await context.newPage(); try { await openApp(page, origin); await page.locator('[data-testid="blend-file-input"]').setInputFiles(path.join(ROOT, "blender-5.2.0/tests/files/io_tests/blend_geometry/cubes_with_textures.blend")); await page.waitForTimeout(7_000); await waitEngine(page); let scene = await snapshot(page); const normalMaterial = (scene.materials ?? []).find((material) => (material.textureBindings ?? []).some((binding) => binding.usage === "NORMAL")); const targetNode = normalMaterial ? (scene.nodes ?? []).find((node) => (scene.meshes ?? []).find((mesh) => mesh.id === node.dataId)?.materialSlotIds?.includes(normalMaterial.id)) : null; step("normal-before", { material: normalMaterial?.id, bindings: normalMaterial?.textureBindings, object: targetNode?.name, mesh: meshSummary(scene, targetNode?.name) }); if (targetNode) { const item = page.getByRole("treeitem", { name: new RegExp(targetNode.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) }).first(); if (await item.count()) await item.click().catch(() => {}); await page.waitForTimeout(700); const apply = page.getByRole("button", { name: "应用 Decimate", exact: true }); if (await apply.count()) { await apply.click(); await page.waitForTimeout(4_000); } scene = await snapshot(page); const afterMaterial = (scene.materials ?? []).find((material) => material.id === normalMaterial.id); step("normal-after", { material: afterMaterial?.id, bindings: afterMaterial?.textureBindings, object: meshSummary(scene, targetNode.name) }); } writeEvidence("case2-normal-map.json", record); const colorPage = await context.newPage(); await openApp(colorPage, origin); await colorPage.locator('[data-testid="blend-file-input"]').setInputFiles(path.join(ROOT, "blender-5.2.0/tests/files/io_tests/blend_geometry/cubes_vertex_colors.blend")); await colorPage.waitForTimeout(7_000); await waitEngine(colorPage); let colorScene = await snapshot(colorPage); const colorMesh = (colorScene.meshes ?? []).find((mesh) => (mesh.attributes ?? []).some((attribute) => /color/i.test(attribute.name) || /COLOR/.test(attribute.dataType))); const colorNode = colorMesh ? (colorScene.nodes ?? []).find((node) => node.dataId === colorMesh.id) : null; step("vertex-color-before", { mesh: colorMesh?.id, attributes: colorMesh?.attributes, object: colorNode?.name }); if (colorNode) { const item = colorPage.getByRole("treeitem", { name: new RegExp(colorNode.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) }).first(); if (await item.count()) await item.click().catch(() => {}); await colorPage.waitForTimeout(700); const apply = colorPage.getByRole("button", { name: "应用 Decimate", exact: true }); if (await apply.count()) { await apply.click(); await colorPage.waitForTimeout(4_000); } colorScene = await snapshot(colorPage); const afterMesh = (colorScene.meshes ?? []).find((mesh) => mesh.id === colorMesh.id); step("vertex-color-after", { mesh: afterMesh?.id, attributes: afterMesh?.attributes, object: meshSummary(colorScene, colorNode.name) }); } await colorPage.close(); writeEvidence("case2-vertex-color.json", record); record.verdict = "PARTIAL_ATTRIBUTE_CHECK"; } catch (error) { record.error = { name: error?.name, message: error?.message, stack: String(error?.stack).slice(0, 4000) }; } finally { await page.close().catch(() => {}); record.durationMs = Date.now() - started; results.cases.case2extra = record; writeEvidence("case2-extra-runtime.json", record); } } async function runCase2Vertex(context, origin) { const started = Date.now(); const record = { steps: [], evidence: {} }; const step = (name, value) => { record.steps.push({ name, at: new Date().toISOString(), value }); console.log("[case2vertex]", name, JSON.stringify(value).slice(0, 600)); }; const page = await context.newPage(); try { await openApp(page, origin); await page.locator('[data-testid="blend-file-input"]').setInputFiles(path.join(ROOT, "blender-5.2.0/tests/files/io_tests/blend_geometry/cubes_vertex_colors.blend")); await page.waitForTimeout(7_000); await waitEngine(page); let scene = await snapshot(page); const colorMesh = (scene.meshes ?? []).find((mesh) => (mesh.attributes ?? []).some((attribute) => /color/i.test(attribute.name) || /COLOR/.test(attribute.dataType))); const colorNode = colorMesh ? (scene.nodes ?? []).find((node) => node.dataId === colorMesh.id) : null; step("vertex-color-before", { mesh: colorMesh?.id, attributes: colorMesh?.attributes, object: colorNode?.name, summary: meshSummary(scene, colorNode?.name) }); if (colorNode) { const item = page.getByRole("treeitem", { name: new RegExp(colorNode.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) }).first(); if (await item.count()) await item.click().catch(() => {}); await page.waitForTimeout(700); const preview = page.getByRole("button", { name: "预览 Decimate", exact: true }); const previewEnabled = (await preview.count()) > 0 && await preview.isEnabled(); if (previewEnabled) { await preview.click(); await page.waitForTimeout(5_000); } const apply = page.getByRole("button", { name: "应用 Decimate", exact: true }); const applyEnabled = (await apply.count()) > 0 && await apply.isEnabled(); if (applyEnabled) { await apply.click(); await page.waitForTimeout(4_000); } const uiDisabled = !previewEnabled && !applyEnabled; let commandResult = null; if (uiDisabled) { commandResult = await applyCommand(page, { type: "decimateMesh", meshId: colorMesh.id, profile: { schemaVersion: 1, sourceMeshRevision: scene.revision, attributePolicy: "PRESERVE", mode: "COLLAPSE", ratio: 0.5, triangulate: true, useSymmetry: false, delimit: ["UV"], vertexGroup: null, vertexGroupFactor: 1, vertexGroupInvert: false } }); } scene = await snapshot(page); const afterMesh = (scene.meshes ?? []).find((mesh) => mesh.id === colorMesh.id); step("vertex-color-after", { uiDisabled, commandResult: commandResult?.response ?? commandResult, mesh: afterMesh?.id, attributes: afterMesh?.attributes, summary: meshSummary(scene, colorNode.name) }); } record.verdict = "VERTEX_COLOR_CHECK"; } catch (error) { record.error = { name: error?.name, message: error?.message, stack: String(error?.stack).slice(0, 4000) }; } finally { await page.close().catch(() => {}); record.durationMs = Date.now() - started; results.cases.case2vertex = record; writeEvidence("case2-vertex-color.json", record); } } async function runCase3Visual(context, origin) { const started = Date.now(); const page = await context.newPage(); const record = { steps: [], evidence: {} }; const step = (name, value) => { record.steps.push({ name, at: new Date().toISOString(), value }); console.log("[case3visual]", name, JSON.stringify(value).slice(0, 500)); }; try { await openApp(page, origin); await page.locator('[data-testid="blend-file-input"]').setInputFiles(PBR_BLEND); await page.waitForTimeout(7_000); await waitEngine(page); await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true }); await page.waitForTimeout(2_500); await page.locator(".blender-shader-canvas").waitFor({ state: "visible", timeout: 30_000 }); const canvas = page.locator("canvas.viewport-canvas").first(); let scene = await snapshot(page); await page.waitForTimeout(2_000); const beforeMaterial = scene.materials[0]; step("deselected-before", { activeObjectId: scene.activeObjectId, selectedObjectIds: scene.selectedObjectIds, baseColor: beforeMaterial?.baseColor, roughness: beforeMaterial?.roughness, revision: scene.revision }); await canvas.screenshot({ path: path.join(EVIDENCE, "case3-visual-before.png") }); const principled = page.locator('.blender-shader-node[aria-label="Principled BSDF"]').first(); const baseInputs = principled.locator('input[aria-label^="Base Color "]'); if (await baseInputs.count() === 4) { await baseInputs.nth(0).fill("0"); await baseInputs.nth(0).press("Enter"); await baseInputs.nth(1).fill("1"); await baseInputs.nth(1).press("Enter"); await baseInputs.nth(2).fill("0"); await baseInputs.nth(2).press("Enter"); await baseInputs.nth(3).fill("1"); await baseInputs.nth(3).press("Enter"); } const roughness = principled.locator('input[aria-label="Roughness"]').first(); if (await roughness.count()) { await roughness.fill("0.1"); await roughness.press("Enter"); } for (const [label, waitMs] of [["2s", 2_000], ["6s", 4_000], ["12s", 6_000]]) { await page.waitForTimeout(waitMs); scene = await snapshot(page); const afterMaterial = scene.materials[0]; step(`after-${label}`, { activeObjectId: scene.activeObjectId, selectedObjectIds: scene.selectedObjectIds, baseColor: afterMaterial?.baseColor, roughness: afterMaterial?.roughness, revision: scene.revision }); await canvas.screenshot({ path: path.join(EVIDENCE, `case3-visual-after-${label}.png`) }); } await canvas.screenshot({ path: path.join(EVIDENCE, "case3-visual-after.png") }); const createdScene = await applyCommand(page, { type: "createPrimitive", primitive: "CUBE", name: "QA Visual Selector", location: [3, 0, 0] }); await page.waitForTimeout(2_500); const finalScene = await snapshot(page); step("selection-moved-to-new-cube", { activeObjectId: finalScene.activeObjectId, selectedObjectIds: finalScene.selectedObjectIds, baseColor: finalScene.materials.find((material) => material.id === "material:M12 PBR Material")?.baseColor, revision: finalScene.revision, createdActive: createdScene?.activeObjectId }); await canvas.screenshot({ path: path.join(EVIDENCE, "case3-visual-unselected.png") }); record.verdict = "VIEWPORT_VISUAL_UPDATE_CHECK"; } catch (error) { record.error = { name: error?.name, message: error?.message, stack: String(error?.stack).slice(0, 4000) }; await page.screenshot({ path: path.join(EVIDENCE, "case3-visual-failure.png"), fullPage: false }).catch(() => {}); } finally { await page.close().catch(() => {}); record.durationMs = Date.now() - started; results.cases.case3visual = record; writeEvidence("case3-visual-runtime.json", record); } } async function runCase3Atomic(context, origin) { const started = Date.now(); const page = await context.newPage(); const record = { steps: [], evidence: {} }; const step = (name, value) => { record.steps.push({ name, at: new Date().toISOString(), value }); console.log("[case3atomic]", name, JSON.stringify(value).slice(0, 500)); }; try { await openApp(page, origin); await page.locator('[data-testid="blend-file-input"]').setInputFiles(PBR_BLEND); await page.waitForTimeout(7_000); await waitEngine(page); const canvas = page.locator("canvas.viewport-canvas").first(); let scene = await snapshot(page); const material = scene.materials.find((candidate) => candidate.id === "material:M12 PBR Material"); const principled = (material?.nodes ?? []).find((node) => node.type === "PRINCIPLED"); step("loaded", { revision: scene.revision, baseColor: material?.baseColor, nodeId: principled?.id }); const deselected = await applyCommand(page, { type: "setObjectSelection", objectId: "object:M12 PBR Fixture", selected: false, active: false }); await page.waitForTimeout(1_500); step("deselected", { revision: deselected?.revision, selectedObjectIds: deselected?.selectedObjectIds, activeObjectId: deselected?.activeObjectId }); await canvas.screenshot({ path: path.join(EVIDENCE, "case3-atomic-before.png") }); scene = await snapshot(page); const edited = await applyCommand(page, { type: "editShaderNode", materialId: "material:M12 PBR Material", edit: { action: "SET_SOCKET", nodeId: principled.id, socketId: "Base Color", direction: "INPUT", value: [0, 1, 0, 1] }, baseRevision: scene.revision }); step("atomic-edit", { revision: edited?.revision, baseColor: edited?.materials?.find((candidate) => candidate.id === "material:M12 PBR Material")?.baseColor, selectedObjectIds: edited?.selectedObjectIds, activeObjectId: edited?.activeObjectId }); await page.waitForTimeout(2_500); await canvas.screenshot({ path: path.join(EVIDENCE, "case3-atomic-after-2s.png") }); await page.waitForTimeout(4_000); await canvas.screenshot({ path: path.join(EVIDENCE, "case3-atomic-after-6s.png") }); const finalScene = await snapshot(page); step("final", { revision: finalScene.revision, baseColor: finalScene.materials.find((candidate) => candidate.id === "material:M12 PBR Material")?.baseColor, selectedObjectIds: finalScene.selectedObjectIds, activeObjectId: finalScene.activeObjectId }); record.verdict = "ATOMIC_SHADER_RENDER_CHECK"; } catch (error) { record.error = { name: error?.name, message: error?.message, stack: String(error?.stack).slice(0, 4000) }; await page.screenshot({ path: path.join(EVIDENCE, "case3-atomic-failure.png"), fullPage: false }).catch(() => {}); } finally { await page.close().catch(() => {}); record.durationMs = Date.now() - started; results.cases.case3atomic = record; writeEvidence("case3-atomic-runtime.json", record); } } async function runCase4(context, origin) { const started = Date.now(); const page = await context.newPage(); const record = { steps: [], evidence: {} }; const step = (name, value) => { record.steps.push({ name, at: new Date().toISOString(), value }); console.log("[case4]", name, JSON.stringify(value).slice(0, 500)); }; try { await openApp(page, origin); await page.locator('[data-testid="blend-file-input"]').setInputFiles(path.join(ROOT, "tests/files/web/basic_scene.blend")); await page.waitForTimeout(6_000); await waitEngine(page); const mark = page.getByRole("button", { name: /Mark Asset/ }); const catalog = page.getByRole("button", { name: /Catalog|资产目录/ }); const assetSelectors = ["[data-testid*='asset-browser']", "[data-testid*='asset-grid']", "[data-testid*='asset-thumbnail']", ".asset-browser", ".asset-grid", "[draggable='true'][data-asset-id]"]; const counts = {}; for (const selector of assetSelectors) counts[selector] = await page.locator(selector).count(); const dragSources = await page.locator("[draggable='true']").evaluateAll((nodes) => nodes.map((node) => ({ tag: node.tagName, className: node.className, aria: node.getAttribute("aria-label") })).slice(0, 20)); const deleteMaterial = await page.getByRole("button", { name: /删除材质|Delete Material/ }).count(); step("asset-ui-probe", { markAsset: await mark.count(), catalog: await catalog.count(), counts, dragSources, deleteMaterial }); await page.screenshot({ path: path.join(EVIDENCE, "case4-asset-ui.png"), fullPage: false }); record.verdict = "MISSING_ASSET_BROWSER_AND_DND"; record.actualSummary = { markAsset: await mark.count(), catalog: await catalog.count(), counts, dragSources, deleteMaterial }; } catch (error) { record.error = { name: error?.name, message: error?.message, stack: String(error?.stack).slice(0, 4000) }; } finally { await page.close().catch(() => {}); record.durationMs = Date.now() - started; results.cases.case4 = record; writeEvidence("case4-runtime.json", record); } } async function main() { if (!fs.existsSync(DIST)) throw new Error(`missing production build: ${DIST}`); prepareQaAssets(); const server = createDeploymentHttpServer({ root: DIST }); const origin = await listenDeploymentHttpServer(server, { host: "127.0.0.1", port: 0 }); const browser = await chromium.launch({ headless: true, executablePath: CHROME, 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 launchContext(browser); results.environment = { chrome: (await browser.version()), origin, distIndexSha256: sha256(fs.readFileSync(path.join(DIST, "index.html"))), webEngineWasmSha256: sha256(fs.readFileSync(path.join(DIST, "vendor/blender/web_engine.wasm"))), headless: true, viewport: { width: 1600, height: 1000 }, }; const onlyArg = process.argv.find((argument) => argument.startsWith("--only=")); const only = onlyArg ? new Set(onlyArg.slice("--only=".length).split(",")) : null; const should = (id) => !only || only.has(id); try { if (should("case1")) await runCase1(context, origin); if (should("case3")) await runCase3(context, origin); if (should("case5")) await runCase5(context, origin); if (should("case2")) await runCase2And6(context, origin); if (should("case6")) await runCase6(context, origin); if (should("case2extra")) await runCase2Extra(context, origin); if (should("case2vertex")) await runCase2Vertex(context, origin); if (should("case3visual")) await runCase3Visual(context, origin); if (should("case3atomic")) await runCase3Atomic(context, origin); if (should("case4")) await runCase4(context, origin); } finally { results.finishedAt = new Date().toISOString(); writeEvidence("summary-case5.json", results); await context.close(); await browser.close(); await new Promise((resolve) => server.close(resolve)); cleanupQaAssets(); } } main().catch((error) => { console.error(error); process.exitCode = 1; });