Files
workinf_Blender_Wasm/tools/web/qa/run-web-blender-qa-20261111.mjs
mes123456 d1e6b63d54
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 feature-gap work, docs and tests
- update native web_engine sources and rebuilt wasm artifacts
- add task cards, status notes and tooling scripts
- add web e2e specs, tutorial UI sources and tutorial assets
- sync golden task manifests and audit records

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

1001 lines
57 KiB
JavaScript

#!/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"), "<!doctype html><html><body>QA worker host</body></html>");
}
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; });