#!/usr/bin/env node /** * Generate a self-contained HTML catalog of every test case in the web Blender * migration project. * * Inputs (read-only): * web/tests/e2e/**.spec.ts Playwright end-to-end cases * web/tests/unit/**.test.mjs node:test unit cases * tests/golden//manifest.json Golden task / reference records * tests/golden/**, user-testing/** Playwright artifacts / UT screenshots * tests/files/web/** Blender fixtures * web/playwright*.config.ts Runner configuration * web/package.json npm test entrypoints * * Screenshot association: * - E2E: artifact directory names are recomputed with Playwright's own * output-directory algorithm (spec + title + sha1-trimmed to 60 chars). * - Golden: images stored directly in the task directory. * - Spec-level: user-testing manual/cases.json maps UT ids to spec files. * * Output: * docs/web/test-case-catalog.html * * Usage: node tools/web/generate-test-case-catalog.mjs [--out ] */ import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { execFileSync } from "node:child_process"; import { pathToFileURL } from "node:url"; const REPO = path.resolve(import.meta.dirname, "../.."); const OUT = (() => { const i = process.argv.indexOf("--out"); return i > 0 && process.argv[i + 1] ? path.resolve(process.argv[i + 1]) : path.join(REPO, "docs/web/test-case-catalog.html"); })(); const ts = (await import(pathToFileURL(path.join(REPO, "web/node_modules/typescript/lib/typescript.js")).href)).default; /* ------------------------------------------------------------------ */ /* Categories */ /* ------------------------------------------------------------------ */ const CATEGORIES = [ { id: "file", name: "文件与保存", keywords: ["file", "save", "open", "blend", "filepath", "archive", "autosave", "version", "transfer", "drop", "import", "export", "gzip", "pack", "reset", "write", "copy", "delivery", "retry", "io-format"] }, { id: "mesh", name: "网格与建模", keywords: ["mesh", "modeling", "topology", "curve", "sculpt", "remesh", "primitive", "edit-mode", "editmode", "uv-", "vertex", "edge", "face", "subdiv", "convex", "boolean", "surface"] }, { id: "object", name: "对象与集合", keywords: ["object", "collection", "hierarchy", "outliner", "parent", "duplicate", "origin", "transform", "instance", "visibility", "rename", "delete", "scene-flow", "scene-object", "scene-multiview"] }, { id: "modifier", name: "修改器与几何节点", keywords: ["modifier", "geometry-node", "node-graph", "convex-hull", "simplify", "lod", "volume", "metaball", "nanovdb", "field", "vdb"] }, { id: "material", name: "材质与着色", keywords: ["material", "shader", "texture", "pbr", "lighting", "light", "color-management", "image", "font", "line-style"] }, { id: "animation", name: "动画与绑定", keywords: ["animation", "action", "nla", "armature", "rig", "pose", "shape-key", "fcurve", "driver", "keyframe"] }, { id: "render", name: "渲染、合成与序列", keywords: ["render", "compositor", "sequencer", "media", "ffmpeg", "movie", "audio", "tracking", "mask", "strip", "viewport-consistency", "codec", "export"] }, { id: "asset", name: "资产与库", keywords: ["asset", "library", "append", "link", "override", "catalog", "preview"] }, { id: "script", name: "脚本与安全沙箱", keywords: ["script", "python", "sandbox", "signature", "trust", "permission", "malicious", "host-call"] }, { id: "engine", name: "引擎与运行时", keywords: ["engine", "backend", "worker", "wasm", "pthread", "thread", "session", "crash", "oom", "device", "network", "storage", "opfs", "indexeddb", "browser", "runtime", "webgpu", "memory", "mutex", "module", "protocol", "receipt", "diagnostic", "console"] }, { id: "ui", name: "界面与交互", keywords: ["ui", "menu", "panel", "toolbar", "layout", "responsive", "accessibility", "keyboard", "keymap", "modal", "tutorial", "palette", "context", "workspace", "properties", "add-menu", "editor"] }, { id: "paint", name: "绘制与笔刷", keywords: ["paint", "brush", "stroke", "grease-pencil", "gpencil", "pixel"] }, { id: "physics", name: "物理与模拟", keywords: ["physics", "simulation", "cache", "fluid", "cloth", "particle", "solver", "collision"] }, { id: "data", name: "数据、RNA 与协议", keywords: ["rna", "datablock", "reader", "capability", "schema", "metadata", "reflection", "manifest"] }, { id: "perf", name: "性能与稳定性", keywords: ["performance", "soak", "interrupt", "quota", "loss", "fault", "stress", "budget", "8k", "4k", "10m", "recovery"] }, { id: "other", name: "其他 / 跨域", keywords: [] }, ]; const PREFIX_CATEGORY = [ [/^b3-/, "file"], [/^b4-/, "ui"], [/^b5-/, "animation"], [/^b6-/, "render"], [/^archive-/, "file"], [/^asset-/, "asset"], [/^script-/, "script"], [/^nanovdb-/, "modifier"], [/^paint-/, "paint"], [/^physics-/, "physics"], [/^simulation-/, "physics"], [/^sequencer-/, "render"], [/^compositor-/, "render"], [/^library-/, "asset"], [/^glb-/, "file"], [/^obj-/, "file"], [/^stl-/, "file"], [/^ply-/, "file"], [/^io-/, "file"], [/^file-/, "file"], [/^ui-/, "ui"], [/^console-/, "engine"], [/^native-/, "engine"], [/^upstream-blendfile/, "file"], [/^viewport-/, "render"], [/^render-/, "render"], [/^shader-/, "material"], [/^geometry-/, "modifier"], [/^curve-/, "mesh"], [/^grease-pencil-/, "paint"], [/^armature-/, "animation"], [/^rig-/, "animation"], [/^nla-/, "animation"], [/^animation-/, "animation"], [/^action-/, "animation"], [/^mesh-/, "mesh"], [/^modeling-/, "mesh"], [/^sculpt-/, "mesh"], [/^modifier-/, "modifier"], [/^object-/, "object"], [/^outliner-/, "object"], [/^hierarchy-/, "object"], [/^material-/, "material"], [/^lighting-/, "material"], [/^light-/, "material"], [/^pbr-/, "material"], [/^script-/, "script"], [/^nanovdb-/, "modifier"], [/^corrective-/, "other"], [/^cross-browser/, "engine"], [/^m10-/, "engine"], ]; function classify(text) { const lower = text.toLowerCase(); for (const [re, id] of PREFIX_CATEGORY) if (re.test(lower)) return id; let best = "other"; let bestScore = 0; for (const cat of CATEGORIES) { let score = 0; for (const kw of cat.keywords) if (lower.includes(kw)) score += kw.length > 4 ? 2 : 1; if (score > bestScore) { bestScore = score; best = cat.id; } } return best; } /* ------------------------------------------------------------------ */ /* TypeScript helpers */ /* ------------------------------------------------------------------ */ const ACTION_METHODS = new Set([ "goto", "reload", "goBack", "setViewportSize", "addInitScript", "exposeFunction", "setInputFiles", "click", "dblclick", "fill", "press", "type", "hover", "focus", "dragTo", "dragAndDrop", "selectOption", "check", "uncheck", "setChecked", "waitForEvent", "waitForFunction", "waitForSelector", "waitForTimeout", "evaluate", "evaluateHandle", "screenshot", "dispatchEvent", "setContent", "route", "keyboard", "mouse", "download", "setDefaultTimeout", "setTimeout", ]); function lineOf(sf, node) { return sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1; } function stringLiteral(node, sf) { if (!node) return null; if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; return null; } function preview(node, sf, max = 90) { const text = node.getText(sf).replace(/\s+/g, " ").trim(); return text.length > max ? `${text.slice(0, max)}…` : text; } function isFunctionNode(node) { return ts.isArrowFunction(node) || ts.isFunctionExpression(node); } function walkTestBody(func, sf, acc) { const visit = (node) => { if (ts.isCallExpression(node)) { const callee = node.expression; if (ts.isPropertyAccessExpression(callee)) { const method = callee.name.text; const left = callee.expression.getText(sf); if (ACTION_METHODS.has(method) && acc.actions.length < 60) { acc.actions.push({ method, line: lineOf(sf, node), preview: node.arguments.length ? preview(node.arguments[0], sf) : "", }); } if (method.startsWith("to") && /^expect\b/.test(left)) { acc.matchers[method] = (acc.matchers[method] || 0) + 1; } } if (ts.isIdentifier(callee) && callee.text === "expect") acc.expects += 1; if (ts.isPropertyAccessExpression(callee) && callee.expression.getText(sf).startsWith("expect") && callee.name.text === "soft") acc.expects += 1; } ts.forEachChild(node, visit); }; visit(func); } function collectFixtureStrings(func, sf) { const seen = new Set(); const out = []; const visit = (node) => { if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { const v = node.text; if (/\.(blend\d?|glb|gltf|obj|stl|ply|exr|hdr|mp4|mkv|wav|png|jpg|json|py)$/i.test(v) && (v.includes("/") || v.includes("."))) { if (!seen.has(v)) { seen.add(v); out.push(v); } } } ts.forEachChild(node, visit); }; visit(func); return out.slice(0, 25); } /* ------------------------------------------------------------------ */ /* Playwright output-directory naming (for screenshot matching) */ /* ------------------------------------------------------------------ */ /* Mirrors playwright/lib/worker/workerProcessEntry.js: * outputDir = trimLongString(sanitizedRelativePath + "-" + * sanitizeForFilePath(titlePath.slice(1).join(" ")), 60) */ function sanitizeForFilePath(s) { return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, "-"); } function trimLongString(s, length = 60) { if (s.length <= length) return s; const hash = crypto.createHash("sha1").update(s).digest("hex"); const middle = `-${hash.slice(0, 5)}-`; const start = Math.floor((length - middle.length) / 2); const end = length - middle.length - start; return s.slice(0, start) + middle + s.slice(-end); } function playwrightOutputBase(specFile, describes, title) { const rel = specFile.replace(/\.spec\.ts$/, "").replace(/[\\/]/g, "-"); return trimLongString(`${rel}-${sanitizeForFilePath([...describes, title].join(" "))}`, 60); } /* ------------------------------------------------------------------ */ /* E2E specs */ /* ------------------------------------------------------------------ */ function parseE2E() { const dir = path.join(REPO, "web/tests/e2e"); const files = fs.readdirSync(dir).filter((f) => f.endsWith(".spec.ts")).sort(); const cases = []; for (const file of files) { const full = path.join(dir, file); const src = fs.readFileSync(full, "utf8"); const sf = ts.createSourceFile(full, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); const visit = (node, describes) => { if (ts.isCallExpression(node)) { const calleeText = node.expression.getText(sf); if (calleeText.startsWith("test.describe")) { const title = stringLiteral(node.arguments[0], sf); const next = title ? [...describes, title] : describes; ts.forEachChild(node, (child) => visit(child, next)); return; } const m = /^test(?:\.(skip|only|fixme|fail))?$/.exec(calleeText); if (m) { const func = node.arguments.find(isFunctionNode); if (func) { const literalTitle = stringLiteral(node.arguments[0], sf); const title = literalTitle ?? preview(node.arguments[0], sf); const acc = { actions: [], matchers: {}, expects: 0 }; walkTestBody(func, sf, acc); const bodyText = func.body ? func.body.getText(sf) : ""; const timeoutMatch = /test\.setTimeout\(\s*([\d_]+)\s*\)/.exec(bodyText); const details = node.arguments.find((a) => ts.isObjectLiteralExpression(a)); let tags = []; if (details) { for (const prop of details.properties) { if (ts.isPropertyAssignment(prop) && prop.name.getText(sf) === "tag") { const text = prop.initializer.getText(sf); tags = [...text.matchAll(/["'`]([^"'`]+)["'`]/g)].map((x) => x[1]); } } } const fixtures = collectFixtureStrings(func, sf); const describe = describes.join(" › "); cases.push({ id: `${file.replace(/\.spec\.ts$/, "")}#${cases.length + 1}`, spec: file, title, describe, line: lineOf(sf, node), timeout: timeoutMatch ? Number(timeoutMatch[1].replace(/_/g, "")) : null, mode: m[1] || null, tags, category: classify(`${file} ${describe} ${title}`), expects: acc.expects, matchers: acc.matchers, actions: acc.actions, fixtures, base: literalTitle ? playwrightOutputBase(file, describes, literalTitle) : null, snippet: bodyText.length > 4500 ? `${bodyText.slice(0, 4500)}\n… (截断)` : bodyText, }); } } } ts.forEachChild(node, (child) => visit(child, describes)); }; visit(sf, []); } return { files, cases }; } /* ------------------------------------------------------------------ */ /* Unit tests */ /* ------------------------------------------------------------------ */ function parseUnit() { const dir = path.join(REPO, "web/tests/unit"); const files = fs.readdirSync(dir).filter((f) => f.endsWith(".test.mjs")).sort(); const cases = []; for (const file of files) { const full = path.join(dir, file); const src = fs.readFileSync(full, "utf8"); const sf = ts.createSourceFile(full, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS); const visit = (node) => { if (ts.isCallExpression(node)) { const calleeText = node.expression.getText(sf); const m = /^(?:test|it)(?:\.(skip|only|todo))?$/.exec(calleeText); if (m) { const func = node.arguments.find(isFunctionNode); if (func) { const title = stringLiteral(node.arguments[0], sf) ?? preview(node.arguments[0], sf); const bodyText = func.body ? func.body.getText(sf) : ""; const asserts = (bodyText.match(/\bassert\.\w+/g) || []).length; cases.push({ id: `${file.replace(/\.test\.mjs$/, "")}#${cases.length + 1}`, file, title, line: lineOf(sf, node), mode: m[1] || null, category: classify(`${file} ${title}`), asserts, snippet: bodyText.length > 3000 ? `${bodyText.slice(0, 3000)}\n… (截断)` : bodyText, }); } } } ts.forEachChild(node, visit); }; visit(sf); } return { files, cases }; } /* ------------------------------------------------------------------ */ /* Golden manifests */ /* ------------------------------------------------------------------ */ function flattenScalars(obj, limit = 40) { const out = {}; if (!obj || typeof obj !== "object") return out; let n = 0; for (const [k, v] of Object.entries(obj)) { if (n >= limit) break; if (v === null || ["string", "number", "boolean"].includes(typeof v)) { out[k] = typeof v === "string" && v.length > 160 ? `${v.slice(0, 160)}…` : v; n += 1; } else if (Array.isArray(v) && v.every((x) => x === null || ["string", "number", "boolean"].includes(typeof x))) { out[k] = v.length > 12 ? [...v.slice(0, 12), `…(+${v.length - 12})`] : v; n += 1; } } return out; } function parseGolden() { const root = path.join(REPO, "tests/golden"); const dirs = fs.readdirSync(root, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort(); const records = []; for (const name of dirs) { const manifestPath = path.join(root, name, "manifest.json"); if (!fs.existsSync(manifestPath)) continue; let m; try { m = JSON.parse(fs.readFileSync(manifestPath, "utf8")); } catch { continue; } const task = m.task || name; const artifacts = []; if (m.artifacts && typeof m.artifacts === "object") { for (const [key, v] of Object.entries(m.artifacts)) { if (v && typeof v === "object" && typeof v.path === "string") { artifacts.push({ key, path: v.path, exists: fs.existsSync(path.join(REPO, v.path)) }); } } } if (m.source && typeof m.source.fixture === "string") { artifacts.push({ key: "fixture", path: m.source.fixture, exists: fs.existsSync(path.join(REPO, m.source.fixture)) }); } const assertions = flattenScalars(m.assertions); const familyMatch = /^(M\d+|WBV2-P\d+|WBV2-[A-Z0-9]+|corrective|QA-[A-Z0-9-]+|[A-Z]+-?\d*)/.exec(task); records.push({ id: task, dir: name, task, parent: m.parentTask || "", operation: m.operation || (m.reference ? "GOLDEN_REFERENCE" : "—"), status: m.status || "—", runtime: m.runtime || "—", family: familyMatch ? familyMatch[1] : "其他", enabling: m.enablingTask === true, parity: m.parityStateChange === true, assertions, assertionCount: Object.keys(assertions).length, thresholds: flattenScalars(m.thresholds, 12), artifacts, nextTask: m.nextTask || "", manifest: `tests/golden/${name}/manifest.json`, category: classify(`${name} ${m.operation || ""} ${m.runtime || ""}`), }); } return records; } /* ------------------------------------------------------------------ */ /* Screenshot evidence */ /* ------------------------------------------------------------------ */ const IMAGE_RE = /\.(png|jpe?g|webp)$/i; const UT_SUITES = [ "user-testing/WEB_Blender_Full_Test_20260916", "user-testing/WEB_Blender_Parity_Round3_20260916", "user-testing/WEB_Blender_Desktop_Parity_20260916", "user-testing/WEB_Blender_Parity_Round2_20260916", ]; const SHOT_LIMIT = 300; const UT_SHOT_LIMIT = 600; function shotType(name) { const failed = /^\d*-?test-failed-(\d+)/.exec(name); if (failed) return { label: `失败截图 ${Number(failed[1])}`, order: 1 }; const finished = /^\d*-?test-finished-(\d+)/.exec(name); if (finished) return { label: `完成截图 ${Number(finished[1])}`, order: 2 }; const trace = /^trace-frame-(\d+)/i.exec(name); if (trace) return { label: `trace 帧 ${Number(trace[1])}`, order: 3 }; return { label: name.replace(IMAGE_RE, ""), order: 0 }; } function collectImageDirs(roots) { const byDir = new Map(); const suiteCounts = new Map(); const walk = (dir, topSuite) => { let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } const direct = []; for (const ent of ents) { const full = path.join(dir, ent.name); if (ent.isDirectory()) { if (ent.name === "node_modules" || ent.name === ".git" || ent.name === "traces") continue; walk(full, topSuite); } else if (IMAGE_RE.test(ent.name)) { let size = 0; try { size = fs.statSync(full).size; } catch { /* ignore */ } direct.push({ abs: full, name: ent.name, size }); if (topSuite) suiteCounts.set(topSuite, (suiteCounts.get(topSuite) || 0) + 1); } } if (direct.length) byDir.set(dir, direct); }; const utRoot = path.join(REPO, "user-testing"); for (const rootRel of roots) { const root = path.join(REPO, rootRel); if (!fs.existsSync(root)) continue; if (rootRel === "user-testing") { for (const ent of fs.readdirSync(root, { withFileTypes: true })) { if (ent.isDirectory()) walk(path.join(root, ent.name), `user-testing/${ent.name}`); } } else { walk(root, null); } } return { byDir, suiteCounts, utRoot }; } function buildScreenshotIndex(e2eCases, goldenRecords) { const docDir = path.dirname(OUT); const toRepo = (abs) => path.relative(REPO, abs).split(path.sep).join("/"); const toUrl = (abs) => path.relative(docDir, abs).split(path.sep).join("/"); const hashCache = new Map(); const fileHash = (abs, size) => { if (hashCache.has(abs)) return hashCache.get(abs); let h = `${size}`; try { h = crypto.createHash("sha1").update(fs.readFileSync(abs)).digest("hex"); } catch { /* keep size */ } hashCache.set(abs, h); return h; }; const makeShot = (abs, name, size) => { const { label, order } = shotType(name); const dir = path.dirname(abs); return { p: toRepo(abs), u: toUrl(abs), label, order, size, dir: toRepo(dir), dirUrl: toUrl(dir) }; }; const { byDir, suiteCounts } = collectImageDirs(["tests/golden", "user-testing", "web/test-results"]); /* --- E2E case screenshots, matched by Playwright output-directory name --- */ const casesByBase = new Map(); for (const c of e2eCases) { if (!c.base) continue; if (!casesByBase.has(c.base)) casesByBase.set(c.base, []); casesByBase.get(c.base).push(c); } const byCase = {}; const seenByCase = new Map(); for (const [dir, files] of byDir) { const base = path.basename(dir); const matched = casesByBase.get(base); if (!matched) continue; for (const c of matched) { if (!seenByCase.has(c.id)) seenByCase.set(c.id, new Set()); const seen = seenByCase.get(c.id); const list = (byCase[c.id] ||= []); for (const f of files) { if (list.length >= SHOT_LIMIT) break; const h = fileHash(f.abs, f.size); if (seen.has(h)) continue; seen.add(h); list.push(makeShot(f.abs, f.name, f.size)); } } } for (const id of Object.keys(byCase)) { byCase[id].sort((a, b) => a.order - b.order || a.label.localeCompare(b.label, "en", { numeric: true })); } /* --- Golden task screenshots: images stored directly in the task dir --- */ const byTask = {}; for (const rec of goldenRecords) { const dir = path.join(REPO, "tests/golden", rec.dir); const files = byDir.get(dir); if (!files || !files.length) continue; const list = []; const seen = new Set(); for (const f of files) { if (list.length >= 30) break; const h = fileHash(f.abs, f.size); if (seen.has(h)) continue; seen.add(h); list.push(makeShot(f.abs, f.name, f.size)); } list.sort((a, b) => a.order - b.order || a.label.localeCompare(b.label, "en", { numeric: true })); byTask[rec.task] = list; } /* --- User-testing UT evidence, mapped to the spec file it exercises --- */ const utMeta = new Map(); for (const suiteRel of UT_SUITES) { const casesPath = path.join(REPO, suiteRel, "manual/cases.json"); if (!fs.existsSync(casesPath)) continue; try { for (const item of JSON.parse(fs.readFileSync(casesPath, "utf8"))) { if (!item || !item.id) continue; const prev = utMeta.get(item.id); utMeta.set(item.id, { ...prev, ...item, file: prev?.file || item.file, title: prev?.title || item.title, module: prev?.module || item.module }); } } catch { /* ignore malformed metadata */ } } const utShots = {}; const utDirs = {}; const utSeen = new Map(); const addUtImages = (utId, dir, recursive) => { const list = (utShots[utId] ||= []); if (!utSeen.has(utId)) utSeen.set(utId, new Set()); const seen = utSeen.get(utId); const push = (abs, relKey) => { let size = 0; try { size = fs.statSync(abs).size; } catch { return; } const key = `${relKey}:${size}`; if (seen.has(key)) return; seen.add(key); if (list.length >= UT_SHOT_LIMIT) return; const shot = makeShot(abs, path.basename(abs), size); const relDir = relKey.split("/").slice(0, -1).join("/"); if (relDir) shot.label = `${relDir} · ${shot.label}`; list.push(shot); }; if (!recursive) { let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const ent of ents) if (!ent.isDirectory() && IMAGE_RE.test(ent.name)) push(path.join(dir, ent.name), ent.name); return; } const walk = (p) => { let ents; try { ents = fs.readdirSync(p, { withFileTypes: true }); } catch { return; } for (const ent of ents) { const full = path.join(p, ent.name); if (ent.isDirectory()) walk(full); else if (IMAGE_RE.test(ent.name)) push(full, path.relative(dir, full).split(path.sep).join("/")); } }; walk(dir); }; for (const suiteRel of UT_SUITES) { const suite = path.join(REPO, suiteRel); // screenshots/UT-xxx/** (canonical manual evidence) const shotRoot = path.join(suite, "screenshots"); if (fs.existsSync(shotRoot)) { for (const ent of fs.readdirSync(shotRoot, { withFileTypes: true })) { if (!ent.isDirectory() || !/^UT-\d+/.test(ent.name)) continue; if (!utDirs[ent.name]) utDirs[ent.name] = toRepo(path.join(shotRoot, ent.name)); addUtImages(ent.name, path.join(shotRoot, ent.name), true); } } // native-tests//UT-xxx/*.png (desktop/parity captures) const nativeRoot = path.join(suite, "native-tests"); if (fs.existsSync(nativeRoot)) { for (const run of fs.readdirSync(nativeRoot, { withFileTypes: true })) { if (!run.isDirectory()) continue; const runDir = path.join(nativeRoot, run.name); for (const ent of fs.readdirSync(runDir, { withFileTypes: true })) { if (!ent.isDirectory() || !/^UT-\d+/.test(ent.name)) continue; if (!utDirs[ent.name]) utDirs[ent.name] = toRepo(path.join(runDir, ent.name)); addUtImages(ent.name, path.join(runDir, ent.name), false); } } } } for (const id of Object.keys(utShots)) { utShots[id].sort((a, b) => a.order - b.order || a.label.localeCompare(b.label, "en", { numeric: true })); } const specEvidence = {}; for (const [utId, shots] of Object.entries(utShots)) { const meta = utMeta.get(utId); if (!meta || !meta.file || !shots.length) continue; const spec = meta.file.replace(/^.*\//, ""); const dirRel = utDirs[utId] || ""; (specEvidence[spec] ||= []).push({ id: utId, title: meta.title || utId, module: meta.module || "", count: shots.length, dir: dirRel, dirUrl: dirRel ? toUrl(path.join(REPO, dirRel)) : "", images: shots, }); } for (const spec of Object.keys(specEvidence)) { specEvidence[spec].sort((a, b) => a.id.localeCompare(b.id, "en", { numeric: true })); } const casesWithShots = Object.values(byCase).filter((l) => l.length).length; const caseShots = Object.values(byCase).reduce((s, l) => s + l.length, 0); const tasksWithShots = Object.keys(byTask).length; const taskShots = Object.values(byTask).reduce((s, l) => s + l.length, 0); const utShotCount = Object.values(utShots).reduce((s, l) => s + l.length, 0); const suites = [...suiteCounts.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count); return { byCase, byTask, utShots, specEvidence, suites, stats: { casesWithShots, caseShots, tasksWithShots, taskShots, utSpecs: Object.keys(specEvidence).length, utCases: Object.keys(utShots).length, utShotCount, }, }; } /* ------------------------------------------------------------------ */ /* Fixtures, configs, scripts */ /* ------------------------------------------------------------------ */ function parseFixtures(cases) { const root = path.join(REPO, "tests/files/web"); const out = []; if (!fs.existsSync(root)) return out; const walk = (dir) => { for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, ent.name); if (ent.isDirectory()) { walk(full); continue; } const rel = path.relative(REPO, full).split(path.sep).join("/"); const st = fs.statSync(full); const refs = cases.filter((c) => c.fixtures.some((f) => f.endsWith(ent.name) || f.includes(ent.name))).map((c) => c.id); out.push({ path: rel, size: st.size, ext: path.extname(ent.name).toLowerCase(), refs, }); } }; walk(root); return out.sort((a, b) => b.size - a.size); } function parseConfigs() { return fs.readdirSync(path.join(REPO, "web")) .filter((f) => /^playwright.*\.config\.ts$/.test(f)) .sort() .map((file) => { const text = fs.readFileSync(path.join(REPO, "web", file), "utf8"); const pick = (re) => { const m = re.exec(text); return m ? m[1] : ""; }; return { file: `web/${file}`, testDir: pick(/testDir:\s*["']([^"']+)["']/), timeout: pick(/timeout:\s*([\d_]+)/), baseURL: (() => { const m = /baseURL:\s*([^,\n]+)/.exec(text); return m ? m[1].trim() : ""; })(), webServer: pick(/command:\s*`([^`]+)`/), ignores: [...text.matchAll(/["']([^"']*\.spec\.ts)["']/g)].map((m) => m[1]).slice(0, 8), }; }); } function parseScripts() { const pkg = JSON.parse(fs.readFileSync(path.join(REPO, "web/package.json"), "utf8")); return Object.entries(pkg.scripts || {}) .filter(([name]) => /^(test|e2e)/.test(name)) .map(([name, command]) => ({ name, command })); } /* ------------------------------------------------------------------ */ /* Assemble */ /* ------------------------------------------------------------------ */ const e2e = parseE2E(); const unit = parseUnit(); const golden = parseGolden(); const fixtures = parseFixtures(e2e.cases); const configs = parseConfigs(); const scripts = parseScripts(); const shots = buildScreenshotIndex(e2e.cases, golden); let commit = ""; try { commit = execFileSync("git", ["rev-parse", "--short", "HEAD"], { cwd: REPO, encoding: "utf8" }).trim(); } catch { /* not a git checkout */ } try { execFileSync("git", ["diff", "--quiet"], { cwd: REPO }); } catch { /* dirty tree */ } const categoryStats = CATEGORIES.map((c) => ({ id: c.id, name: c.name, e2e: e2e.cases.filter((x) => x.category === c.id).length, unit: unit.cases.filter((x) => x.category === c.id).length, golden: golden.filter((x) => x.category === c.id).length, })); const data = { generatedAt: new Date().toISOString(), commit, counts: { e2eSpecs: e2e.files.length, e2eCases: e2e.cases.length, e2eSkipped: e2e.cases.filter((c) => c.mode).length, unitFiles: unit.files.length, unitCases: unit.cases.length, goldenRecords: golden.length, fixtures: fixtures.length, configs: configs.length, scripts: scripts.length, }, categories: CATEGORIES.map(({ id, name }) => ({ id, name })), categoryStats, e2e: e2e.cases, unit: unit.cases, golden, fixtures, configs, scripts, shots, }; /* ------------------------------------------------------------------ */ /* HTML */ /* ------------------------------------------------------------------ */ const json = JSON.stringify(data).replace(/ Web Blender 测试案例目录
Web Blender 测试案例目录Blender 5.2.0 LTS · 桌面到 Web 移植工程
`; fs.mkdirSync(path.dirname(OUT), { recursive: true }); fs.writeFileSync(OUT, html); console.log(`Wrote ${OUT}`); console.log(` E2E: ${data.counts.e2eSpecs} specs / ${data.counts.e2eCases} cases`); console.log(` Unit: ${data.counts.unitFiles} files / ${data.counts.unitCases} cases`); console.log(` Golden: ${data.counts.goldenRecords} records`); console.log(` Fixtures: ${data.counts.fixtures}`); console.log(` Configs: ${data.counts.configs} Scripts: ${data.counts.scripts}`); console.log(` Size: ${(html.length / 1048576).toFixed(2)} MiB`);