- 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>
1001 lines
57 KiB
JavaScript
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; });
|