Files
workinf_Blender_Wasm/tools/web/generate-test-case-catalog.mjs
mes123456 a078648297
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions
feat(web): complete PBR shader, asset library and offscreen render chain
- generate a desktop-Blender PBR asset library and seed catalog v2 into OPFS
- add React asset browser, material preview sphere and Web PBR render panel
- extend shader graph/compiler to the finite 14-node set and native properties
- add image colour-space editing, scene-camera still rendering and asset repair
- add focused E2E/unit coverage and implementation status notes

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-11 14:59:43 +08:00

1204 lines
64 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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/<task>/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 <file>]
*/
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/<timestamp>/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(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Web Blender 测试案例目录</title>
<style>
:root {
--bg: #f6f7f9; --panel: #ffffff; --ink: #1b1f24; --muted: #5b6570; --line: #dde2e8;
--accent: #2f6feb; --accent-soft: #e8f0fe; --warn: #b45309; --ok: #1a7f37; --code: #f3f4f6;
--radius: 10px; --shadow: 0 1px 2px rgba(16,24,40,.06), 0 1px 3px rgba(16,24,40,.1);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171a; --panel: #1c2024; --ink: #e6e9ed; --muted: #9aa4af; --line: #2c333a;
--accent: #7aa7ff; --accent-soft: #1d2b45; --warn: #f0b429; --ok: #6fce8b; --code: #121518;
--shadow: 0 1px 2px rgba(0,0,0,.5);
}
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.6 system-ui, -apple-system, "Segoe UI", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
header.top { position: sticky; top: 0; z-index: 20; background: color-mix(in srgb, var(--panel) 94%, transparent); backdrop-filter: blur(8px); border-bottom: 1px solid var(--line); }
.top-inner { max-width: 1240px; margin: 0 auto; padding: 12px 20px; display: flex; flex-wrap: wrap; align-items: center; gap: 12px 20px; }
.brand { font-weight: 700; font-size: 17px; letter-spacing: .2px; }
.brand small { display: block; font-weight: 400; color: var(--muted); font-size: 12px; letter-spacing: 0; }
nav.tabs { display: flex; gap: 4px; flex-wrap: wrap; }
nav.tabs button { border: 1px solid transparent; background: none; color: var(--muted); padding: 6px 12px; border-radius: 999px; cursor: pointer; font: inherit; }
nav.tabs button:hover { background: var(--accent-soft); color: var(--ink); }
nav.tabs button[aria-selected="true"] { background: var(--accent); color: #fff; }
main { max-width: 1240px; margin: 0 auto; padding: 22px 20px 60px; }
h1 { font-size: 22px; margin: 4px 0 6px; }
h2 { font-size: 18px; margin: 0 0 10px; }
h3 { font-size: 15px; margin: 18px 0 8px; }
p { margin: 6px 0 10px; }
.muted { color: var(--muted); }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); padding: 16px; margin: 14px 0; }
.grid { display: grid; gap: 8px 28px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }
.kv { display: flex; justify-content: space-between; gap: 12px; border-bottom: 1px dashed var(--line); padding: 5px 0; }
.kv b { font-variant-numeric: tabular-nums; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { color: var(--muted); font-weight: 600; font-size: 12.5px; white-space: nowrap; }
tr:last-child td { border-bottom: 0; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
code { background: var(--code); padding: 1px 5px; border-radius: 4px; font-size: 12.5px; }
pre { background: var(--code); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; overflow: auto; font-size: 12.5px; line-height: 1.55; max-height: 420px; }
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 12px 0; }
.toolbar input[type="search"], .toolbar select { font: inherit; color: inherit; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; min-width: 180px; }
.toolbar input[type="search"] { flex: 1 1 260px; }
.badge { display: inline-block; font-size: 11.5px; line-height: 1.4; padding: 1px 8px; border-radius: 999px; background: var(--accent-soft); color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent); margin: 0 4px 0 0; white-space: nowrap; }
.badge.warn { background: color-mix(in srgb, var(--warn) 14%, transparent); color: var(--warn); border-color: color-mix(in srgb, var(--warn) 35%, transparent); }
.badge.ok { background: color-mix(in srgb, var(--ok) 14%, transparent); color: var(--ok); border-color: color-mix(in srgb, var(--ok) 35%, transparent); }
.badge.plain { background: var(--code); color: var(--muted); border-color: var(--line); }
.case { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 14px; margin: 10px 0; box-shadow: var(--shadow); }
.case h4 { margin: 0 0 4px; font-size: 14.5px; line-height: 1.45; }
.case .meta { color: var(--muted); font-size: 12.5px; display: flex; flex-wrap: wrap; gap: 4px 14px; }
.case .desc { margin: 6px 0; }
details { margin: 8px 0 0; }
details > summary { cursor: pointer; color: var(--accent); font-size: 12.5px; user-select: none; }
details[open] > summary { margin-bottom: 8px; }
.actions { margin: 6px 0 0; padding-left: 18px; font-size: 12.5px; }
.actions li { margin: 2px 0; }
.chips { display: flex; flex-wrap: wrap; gap: 4px; }
.gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 8px; margin-top: 8px; }
.shot { display: block; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; background: var(--code); color: var(--ink); }
.shot:hover { border-color: var(--accent); text-decoration: none; }
.shot img { display: block; width: 100%; height: 120px; object-fit: contain; background: #0f1114; }
.shot .cap { display: block; padding: 3px 6px; font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.evidence-panel { border-left: 3px solid var(--ok); }
.evidence-panel .ut { border-top: 1px dashed var(--line); padding-top: 8px; margin-top: 8px; }
.shot-link { font-size: 11.5px; color: var(--accent); word-break: break-all; }
.pager { display: flex; gap: 8px; align-items: center; justify-content: center; margin: 14px 0; color: var(--muted); }
.pager button { font: inherit; padding: 5px 12px; border-radius: 8px; border: 1px solid var(--line); background: var(--panel); color: var(--ink); cursor: pointer; }
.pager button:disabled { opacity: .45; cursor: default; }
.empty { text-align: center; color: var(--muted); padding: 34px 10px; }
.note { border-left: 3px solid var(--accent); background: var(--accent-soft); padding: 9px 12px; border-radius: 0 8px 8px 0; font-size: 13px; }
.section-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; justify-content: space-between; }
.count { color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; word-break: break-all; }
.hidden { display: none !important; }
@media (max-width: 640px) { .top-inner { padding: 10px 14px; } main { padding: 16px 14px 50px; } th, td { padding: 6px; } }
@media print { header.top { position: static; } nav.tabs, .toolbar, .pager { display: none; } .case, .panel { break-inside: avoid; } }
</style>
</head>
<body>
<header class="top">
<div class="top-inner">
<div class="brand">Web Blender 测试案例目录<small>Blender 5.2.0 LTS · 桌面到 Web 移植工程</small></div>
<nav class="tabs" id="tabs" aria-label="章节"></nav>
</div>
</header>
<main>
<section id="view-overview" class="view"></section>
<section id="view-e2e" class="view hidden"></section>
<section id="view-unit" class="view hidden"></section>
<section id="view-golden" class="view hidden"></section>
<section id="view-assets" class="view hidden"></section>
</main>
<script id="catalog-data" type="application/json">${json}</script>
<script>
(() => {
"use strict";
const DATA = JSON.parse(document.getElementById("catalog-data").textContent);
const CAT = new Map(DATA.categories.map(c => [c.id, c.name]));
const catName = id => CAT.get(id) || id;
const esc = s => String(s ?? "").replace(/[&<>"']/g, ch => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[ch]));
const fmt = n => Number(n).toLocaleString("zh-CN");
const size = n => n < 1024 ? n + " B" : n < 1048576 ? (n/1024).toFixed(1) + " KiB" : (n/1048576).toFixed(1) + " MiB";
const byId = id => document.getElementById(id);
/* ---------------- tabs ---------------- */
const TABS = [
["overview", "总览"],
["e2e", "E2E 测试 (" + fmt(DATA.counts.e2eCases) + ")"],
["unit", "单元测试 (" + fmt(DATA.counts.unitCases) + ")"],
["golden", "任务验证记录 (" + fmt(DATA.counts.goldenRecords) + ")"],
["assets", "夹具 / 配置 / 命令"],
];
const tabBar = byId("tabs");
TABS.forEach(([id, label], i) => {
const b = document.createElement("button");
b.type = "button"; b.textContent = label; b.dataset.tab = id;
b.setAttribute("aria-selected", String(i === 0));
b.addEventListener("click", () => switchTab(id));
tabBar.appendChild(b);
});
function switchTab(id) {
tabBar.querySelectorAll("button").forEach(b => b.setAttribute("aria-selected", String(b.dataset.tab === id)));
document.querySelectorAll(".view").forEach(v => v.classList.toggle("hidden", v.id !== "view-" + id));
location.hash = id;
window.scrollTo({ top: 0, behavior: "instant" });
}
/* ---------------- overview ---------------- */
const catRows = DATA.categoryStats
.filter(c => c.e2e || c.unit || c.golden)
.map(c => "<tr><td>" + esc(c.name) + "</td><td>" + fmt(c.e2e) + "</td><td>" + fmt(c.unit) + "</td><td>" + fmt(c.golden) + "</td></tr>")
.join("");
byId("view-overview").innerHTML = \`
<h1>测试案例目录</h1>
<p class="muted">自动盘点本仓库中可直接执行的测试用例与任务验证记录。生成于 <span class="mono">\${esc(DATA.generatedAt)}</span>,提交 <span class="mono">\${esc(DATA.commit || "unknown")}</span>。</p>
<div class="panel">
<h2>规模总览</h2>
<div class="grid">
<div class="kv"><span>Playwright 规格文件</span><b>\${fmt(DATA.counts.e2eSpecs)}</b></div>
<div class="kv"><span>E2E 测试用例</span><b>\${fmt(DATA.counts.e2eCases)}</b></div>
<div class="kv"><span>其中跳过 / 待修复</span><b>\${fmt(DATA.counts.e2eSkipped)}</b></div>
<div class="kv"><span>单元测试文件</span><b>\${fmt(DATA.counts.unitFiles)}</b></div>
<div class="kv"><span>单元测试用例</span><b>\${fmt(DATA.counts.unitCases)}</b></div>
<div class="kv"><span>任务验证记录</span><b>\${fmt(DATA.counts.goldenRecords)}</b></div>
<div class="kv"><span>Blender 测试夹具</span><b>\${fmt(DATA.counts.fixtures)}</b></div>
<div class="kv"><span>Playwright 配置</span><b>\${fmt(DATA.counts.configs)}</b></div>
<div class="kv"><span>测试相关 npm 命令</span><b>\${fmt(DATA.counts.scripts)}</b></div>
<div class="kv"><span>带截图的 E2E 用例</span><b>\${fmt(DATA.shots.stats.casesWithShots)}</b></div>
<div class="kv"><span>已索引用例截图</span><b>\${fmt(DATA.shots.stats.caseShots)}</b></div>
<div class="kv"><span>带截图的验证记录</span><b>\${fmt(DATA.shots.stats.tasksWithShots)}</b></div>
<div class="kv"><span>用户测试 UT 截图</span><b>\${fmt(DATA.shots.stats.utShotCount)}</b></div>
</div>
</div>
<div class="panel">
<h2>截图证据</h2>
<p>用例卡片中的“测试截图”来自 Playwright 运行产物目录(目录名按 Playwright 的 <code>规格-标题-哈希</code> 规则与用例逐一匹配);验证记录卡片中的截图仅取任务目录顶层的图片;规格级截图来自 <code>user-testing</code> 的 UT 用例,按 <code>manual/cases.json</code> 映射到被测试的规格文件。</p>
<p class="muted">共索引 \${fmt(DATA.shots.stats.caseShots)} 张用例截图(\${fmt(DATA.shots.stats.casesWithShots)} 个用例)、\${fmt(DATA.shots.stats.taskShots)} 张任务截图(\${fmt(DATA.shots.stats.tasksWithShots)} 条记录)、\${fmt(DATA.shots.stats.utShotCount)} 张 UT 截图(\${fmt(DATA.shots.stats.utCases)} 个 UT 用例,覆盖 \${fmt(DATA.shots.stats.utSpecs)} 个规格文件)。图片以仓库相对路径链接,需在本机仓库内打开本文档;点击缩略图可查看原图。</p>
</div>
<div class="panel">
<h2>测试套件构成</h2>
<table>
<thead><tr><th>套件</th><th>位置</th><th>运行方式</th><th>说明</th></tr></thead>
<tbody>
<tr><td>E2E 浏览器测试</td><td class="mono">web/tests/e2e/*.spec.ts</td><td class="mono">npm run test:e2e</td><td>Chromium + 真实 WASM 引擎,驱动产品自身入口(文件输入、菜单、<code>__nativeWorkflowEngine</code>),校验场景状态、保存读回与撤销。</td></tr>
<tr><td>协议单元测试</td><td class="mono">web/tests/unit/*.test.mjs</td><td class="mono">npm test</td><td>用 <code>node --test</code> 直接校验协议模块、预算、恢复与安全策略,不需要浏览器。</td></tr>
<tr><td>任务验证记录</td><td class="mono">tests/golden/&lt;task&gt;/manifest.json</td><td>按任务卡命令</td><td>每个移植任务的构件哈希、断言、运行时与产物清单;是证据索引,不等同于可执行测试。</td></tr>
<tr><td>蓝图 / 桌面对照</td><td class="mono">tests/files/web/*.blend</td><td>被 E2E 引用</td><td>来自桌面 Blender 测试资产的 .blend 场景,作为真实输入夹具。</td></tr>
</tbody>
</table>
</div>
<div class="panel">
<h2>按功能域统计</h2>
<p class="muted">分类依据文件名与用例标题关键词自动推断,仅用于导航,不是官方模块边界。</p>
<table><thead><tr><th>功能域</th><th>E2E</th><th>单元</th><th>验证记录</th></tr></thead><tbody>\${catRows}</tbody></table>
</div>
<div class="panel">
<h2>范围与口径</h2>
<ul>
<li>本目录统计的是 Web 移植工程自身的测试:<code>web/tests/e2e</code>、<code>web/tests/unit</code>、<code>tests/golden</code>。</li>
<li>未计入 <code>blender-5.2.0/tests</code> 上游 C/C++ 测试,以及各 <code>build_*</code> 目录中的构建期测试。</li>
<li><code>tests/golden</code> / <code>user-testing</code> 下的 trace.zip、视频等大体积文件未嵌入本文档;截图按 Playwright 产物目录名与 UT 元数据匹配后以仓库相对路径链接,原图仍保留在磁盘上。</li>
<li>用例的“操作序列”和“断言”由源码 AST 提取,完整语义以展开后的源码片段和原始 spec 文件为准。</li>
</ul>
<div class="note">判定原则:编译通过、单条用例通过或探针返回 ok 都只是证据素材,不代表模块或产品整体验收通过。</div>
</div>\`;
/* ---------------- generic list rendering ---------------- */
function pager(container, total, state, onChange) {
const pages = Math.max(1, Math.ceil(total / state.pageSize));
if (state.page >= pages) state.page = pages - 1;
const el = document.createElement("div");
el.className = "pager";
el.innerHTML = '<button type="button" ' + (state.page === 0 ? "disabled" : "") + '>上一页</button>' +
'<span>第 ' + (state.page + 1) + ' / ' + pages + ' 页 · 共 ' + fmt(total) + ' 条</span>' +
'<button type="button" ' + (state.page >= pages - 1 ? "disabled" : "") + '>下一页</button>';
const [prev, next] = el.querySelectorAll("button");
prev.addEventListener("click", () => { state.page--; onChange(); });
next.addEventListener("click", () => { state.page++; onChange(); });
container.appendChild(el);
}
function matchText(haystack, q) {
const n = q.trim().toLowerCase();
return !n || haystack.toLowerCase().includes(n);
}
/* ---------------- e2e ---------------- */
const e2eState = { q: "", cat: "", spec: "", page: 0, pageSize: 40 };
const e2eRoot = byId("view-e2e");
e2eRoot.innerHTML = \`
<div class="section-head"><h2>E2E 测试用例</h2><span class="count" id="e2e-count"></span></div>
<div class="toolbar">
<input type="search" id="e2e-q" placeholder="搜索用例标题、规格文件、描述或夹具…" aria-label="搜索 E2E 用例">
<select id="e2e-cat" aria-label="按功能域筛选"><option value="">全部功能域</option></select>
<select id="e2e-spec" aria-label="按规格文件筛选"><option value="">全部规格文件</option></select>
</div>
<div id="e2e-evidence"></div>
<div id="e2e-list"></div>
<div id="e2e-pager"></div>\`;
const e2eCat = byId("e2e-cat");
DATA.categories.forEach(c => { if (DATA.e2e.some(x => x.category === c.id)) e2eCat.insertAdjacentHTML("beforeend", '<option value="' + c.id + '">' + esc(c.name) + " (" + DATA.e2e.filter(x => x.category === c.id).length + ")</option>"); });
const e2eSpec = byId("e2e-spec");
[...new Set(DATA.e2e.map(x => x.spec))].sort().forEach(s => e2eSpec.insertAdjacentHTML("beforeend", '<option value="' + esc(s) + '">' + esc(s) + "</option>"));
function filteredE2E() {
return DATA.e2e.filter(c =>
(!e2eState.cat || c.category === e2eState.cat) &&
(!e2eState.spec || c.spec === e2eState.spec) &&
matchText([c.title, c.describe, c.spec, c.fixtures.join(" "), (c.tags || []).join(" ")].join(" "), e2eState.q));
}
function renderE2E() {
const list = filteredE2E();
byId("e2e-count").textContent = fmt(list.length) + " / " + fmt(DATA.e2e.length) + " 条";
renderSpecEvidence(list);
const box = byId("e2e-list");
const start = e2eState.page * e2eState.pageSize;
const slice = list.slice(start, start + e2eState.pageSize);
box.innerHTML = slice.length ? slice.map(caseCardE2E).join("") : '<div class="empty">没有匹配的用例</div>';
const pg = byId("e2e-pager"); pg.innerHTML = "";
pager(pg, list.length, e2eState, renderE2E);
}
function renderSpecEvidence(list) {
const box = byId("e2e-evidence");
const specs = [...new Set(list.map(c => c.spec))];
if (specs.length !== 1) { box.innerHTML = ""; return; }
const spec = specs[0];
const entries = DATA.shots.specEvidence[spec] || [];
if (!entries.length) { box.innerHTML = ""; return; }
const total = entries.reduce((s, e) => s + e.count, 0);
box.innerHTML = '<div class="panel evidence-panel"><h3>规格级用户测试截图</h3>' +
'<p class="muted">来自 <code>user-testing</code> 套件,按 <code>manual/cases.json</code> 映射到 <code>' + esc(spec) + '</code>,适用于该规格文件下的全部用例。共 ' + fmt(total) + ' 张。</p>' +
entries.map(e => '<div class="ut"><div><b>' + esc(e.id) + '</b> ' + esc(e.title) + (e.module ? " · " + esc(e.module) : "") + ' <span class="badge plain">' + fmt(e.count) + ' 张</span></div>' +
(e.dirUrl ? '<div><a class="shot-link" href="' + encodeURI(e.dirUrl) + '" target="_blank" rel="noopener">打开截图目录:' + esc(e.dir) + '</a></div>' : "") +
'<details class="evidence" data-ut="' + esc(e.id) + '"><summary>展开截图(' + fmt(e.count) + ')</summary><div class="gallery"></div></details></div>').join("") +
"</div>";
}
function caseCardE2E(c) {
const actions = c.actions.slice(0, 30).map(a => '<li><code>' + esc(a.method) + '</code> <span class="muted">L' + a.line + '</span>' + (a.preview ? ' <span class="mono">' + esc(a.preview) + '</span>' : "") + "</li>").join("");
const matchers = Object.entries(c.matchers).sort((a,b) => b[1]-a[1]).map(([k,v]) => '<span class="badge plain">' + esc(k) + " ×" + v + "</span>").join("");
const caseShots = DATA.shots.byCase[c.id] || [];
const specShots = (DATA.shots.specEvidence[c.spec] || []).reduce((s, e) => s + e.count, 0);
return '<article class="case">' +
"<h4>" + esc(c.title) + "</h4>" +
'<div class="meta">' +
'<span class="mono">' + esc(c.spec) + ":" + c.line + "</span>" +
(c.describe ? "<span>分组:" + esc(c.describe) + "</span>" : "") +
'<span class="badge">' + esc(catName(c.category)) + "</span>" +
(c.mode ? '<span class="badge warn">' + esc(c.mode) + "</span>" : "") +
(c.timeout ? '<span class="badge plain">超时 ' + fmt(c.timeout) + " ms</span>" : "") +
'<span class="badge plain">断言 ' + fmt(c.expects) + "</span>" +
(caseShots.length ? '<span class="badge ok">截图 ' + fmt(caseShots.length) + "</span>" : "") +
(specShots ? '<span class="badge ok">规格截图 ' + fmt(specShots) + "</span>" : "") +
(c.tags || []).map(t => '<span class="badge ok">#' + esc(t) + "</span>").join("") +
"</div>" +
(c.fixtures.length ? '<div class="desc"><span class="muted">夹具:</span><span class="chips">' + c.fixtures.map(f => '<code>' + esc(f) + "</code>").join(" ") + "</span></div>" : "") +
(matchers ? '<div class="desc"><span class="muted">断言类型:</span>' + matchers + "</div>" : "") +
(caseShots.length ? '<details class="evidence" data-case="' + esc(c.id) + '"><summary>测试截图(' + fmt(caseShots.length) + ')</summary><div class="gallery"></div></details>' : "") +
(actions ? '<details><summary>操作序列(' + c.actions.length + " 步)</summary><ol class=\\"actions\\">" + actions + "</ol></details>" : "") +
'<details><summary>查看测试源码</summary><pre><code>' + esc(c.snippet) + "</code></pre></details>" +
"</article>";
}
byId("e2e-q").addEventListener("input", e => { e2eState.q = e.target.value; e2eState.page = 0; renderE2E(); });
e2eCat.addEventListener("change", e => { e2eState.cat = e.target.value; e2eState.page = 0; renderE2E(); });
e2eSpec.addEventListener("change", e => { e2eState.spec = e.target.value; e2eState.page = 0; renderE2E(); });
/* ---------------- unit ---------------- */
const unitState = { q: "", cat: "", page: 0, pageSize: 50 };
const unitRoot = byId("view-unit");
unitRoot.innerHTML = \`
<div class="section-head"><h2>协议单元测试</h2><span class="count" id="unit-count"></span></div>
<div class="toolbar">
<input type="search" id="unit-q" placeholder="搜索用例标题或文件…" aria-label="搜索单元测试">
<select id="unit-cat" aria-label="按功能域筛选"><option value="">全部功能域</option></select>
</div>
<div id="unit-list"></div><div id="unit-pager"></div>\`;
const unitCat = byId("unit-cat");
DATA.categories.forEach(c => { if (DATA.unit.some(x => x.category === c.id)) unitCat.insertAdjacentHTML("beforeend", '<option value="' + c.id + '">' + esc(c.name) + " (" + DATA.unit.filter(x => x.category === c.id).length + ")</option>"); });
function filteredUnit() {
return DATA.unit.filter(c => (!unitState.cat || c.category === unitState.cat) && matchText([c.title, c.file].join(" "), unitState.q));
}
function renderUnit() {
const list = filteredUnit();
byId("unit-count").textContent = fmt(list.length) + " / " + fmt(DATA.unit.length) + " 条";
const start = unitState.page * unitState.pageSize;
const slice = list.slice(start, start + unitState.pageSize);
byId("unit-list").innerHTML = slice.length ? slice.map(c =>
'<article class="case"><h4>' + esc(c.title) + "</h4>" +
'<div class="meta"><span class="mono">' + esc(c.file) + ":" + c.line + "</span>" +
'<span class="badge">' + esc(catName(c.category)) + "</span>" +
(c.mode ? '<span class="badge warn">' + esc(c.mode) + "</span>" : "") +
'<span class="badge plain">断言 ' + fmt(c.asserts) + "</span></div>" +
'<details><summary>查看测试源码</summary><pre><code>' + esc(c.snippet) + "</code></pre></details></article>"
).join("") : '<div class="empty">没有匹配的用例</div>';
const pg = byId("unit-pager"); pg.innerHTML = "";
pager(pg, list.length, unitState, renderUnit);
}
byId("unit-q").addEventListener("input", e => { unitState.q = e.target.value; unitState.page = 0; renderUnit(); });
unitCat.addEventListener("change", e => { unitState.cat = e.target.value; unitState.page = 0; renderUnit(); });
/* ---------------- golden ---------------- */
const goldState = { q: "", status: "", op: "", page: 0, pageSize: 50 };
const goldRoot = byId("view-golden");
const statuses = [...new Set(DATA.golden.map(g => g.status))].sort();
const ops = [...new Set(DATA.golden.map(g => g.operation))].sort();
goldRoot.innerHTML = \`
<div class="section-head"><h2>任务验证记录</h2><span class="count" id="gold-count"></span></div>
<p class="muted">来自 <code>tests/golden/&lt;task&gt;/manifest.json</code>。这些记录说明每个移植任务产出了什么、断言了什么、构件哈希是否落盘,属于证据索引;执行方式与结果以对应任务卡和回执为准。</p>
<div class="toolbar">
<input type="search" id="gold-q" placeholder="搜索任务号、操作、运行时或断言键…" aria-label="搜索验证记录">
<select id="gold-status" aria-label="按状态筛选"><option value="">全部状态</option>\${statuses.map(s => '<option value="' + esc(s) + '">' + esc(s) + "</option>").join("")}</select>
<select id="gold-op" aria-label="按操作筛选"><option value="">全部操作</option>\${ops.map(s => '<option value="' + esc(s) + '">' + esc(s) + "</option>").join("")}</select>
</div>
<div id="gold-list"></div><div id="gold-pager"></div>\`;
function filteredGold() {
return DATA.golden.filter(g =>
(!goldState.status || g.status === goldState.status) &&
(!goldState.op || g.operation === goldState.op) &&
matchText([g.task, g.operation, g.runtime, g.status, JSON.stringify(g.assertions), g.nextTask].join(" "), goldState.q));
}
function renderGold() {
const list = filteredGold();
byId("gold-count").textContent = fmt(list.length) + " / " + fmt(DATA.golden.length) + " 条";
const start = goldState.page * goldState.pageSize;
const slice = list.slice(start, start + goldState.pageSize);
byId("gold-list").innerHTML = slice.length ? slice.map(g => {
const asserts = Object.entries(g.assertions).map(([k,v]) => '<span class="badge plain">' + esc(k) + ": " + esc(Array.isArray(v) ? v.join(", ") : v) + "</span>").join("");
const arts = g.artifacts.slice(0, 12).map(a => '<li><code>' + esc(a.key) + "</code> <span class=\\"mono\\">" + esc(a.path) + "</span> " + (a.exists ? '<span class="badge ok">存在</span>' : '<span class="badge warn">缺失</span>') + "</li>").join("");
const taskShots = DATA.shots.byTask[g.task] || [];
return '<article class="case"><h4>' + esc(g.task) + " <span class=\\"muted\\">· " + esc(g.operation) + "</span></h4>" +
'<div class="meta"><span class="badge ' + (g.status === "done" ? "ok" : g.status === "—" ? "plain" : "warn") + '">' + esc(g.status) + "</span>" +
'<span class="badge plain">' + esc(g.runtime) + "</span>" +
(g.parent ? "<span>父任务 " + esc(g.parent) + "</span>" : "") +
(g.nextTask ? "<span>nextTask " + esc(g.nextTask) + "</span>" : "") +
(taskShots.length ? '<span class="badge ok">截图 ' + fmt(taskShots.length) + "</span>" : "") +
'<span class="mono">' + esc(g.manifest) + "</span></div>" +
(asserts ? '<div class="desc">' + asserts + "</div>" : "") +
(taskShots.length ? '<details class="evidence" data-task="' + esc(g.task) + '"><summary>验证截图(' + fmt(taskShots.length) + ')</summary><div class="gallery"></div></details>' : "") +
'<details><summary>构件与产物(' + g.artifacts.length + ")</summary><ul class=\\"actions\\">" + (arts || "<li class=\\"muted\\">无</li>") + "</ul></details>" +
"</article>";
}).join("") : '<div class="empty">没有匹配的记录</div>';
const pg = byId("gold-pager"); pg.innerHTML = "";
pager(pg, list.length, goldState, renderGold);
}
byId("gold-q").addEventListener("input", e => { goldState.q = e.target.value; goldState.page = 0; renderGold(); });
byId("gold-status").addEventListener("change", e => { goldState.status = e.target.value; goldState.page = 0; renderGold(); });
byId("gold-op").addEventListener("change", e => { goldState.op = e.target.value; goldState.page = 0; renderGold(); });
/* ---------------- assets ---------------- */
const assetState = { q: "" };
const assetRoot = byId("view-assets");
assetRoot.innerHTML = \`
<h2>测试夹具、配置与运行命令</h2>
<div class="panel">
<h3>Blender 测试夹具(tests/files/web)</h3>
<div class="toolbar"><input type="search" id="fix-q" placeholder="搜索夹具路径…" aria-label="搜索夹具"></div>
<div id="fix-list"></div>
</div>
<div class="panel">
<h3>Playwright 配置</h3>
<table><thead><tr><th>配置文件</th><th>testDir</th><th>超时</th><th>baseURL / 启动命令</th></tr></thead><tbody>
\${DATA.configs.map(c => "<tr><td class=\\"mono\\">" + esc(c.file) + "</td><td class=\\"mono\\">" + esc(c.testDir || "—") + "</td><td>" + esc(c.timeout || "—") + "</td><td class=\\"mono\\">" + esc(c.baseURL || c.webServer || "—") + "</td></tr>").join("")}
</tbody></table>
</div>
<div class="panel">
<h3>测试相关 npm 命令(web/package.json)</h3>
<div class="toolbar"><input type="search" id="cmd-q" placeholder="搜索命令名或内容…" aria-label="搜索 npm 命令"></div>
<div id="cmd-list"></div>
</div>
<div class="panel">
<h3>user-testing 截图来源</h3>
<p class="muted">各用户测试套件可索引的图片数(不含 trace 资源与视频)。UT 截图按 <code>manual/cases.json</code> 映射到规格文件;未映射的套件(视口、机器人工具等)保留在原目录,未强行归入某个用例。</p>
<table><thead><tr><th>套件</th><th>图片数</th></tr></thead><tbody>
\${DATA.shots.suites.map(s => '<tr><td class="mono">' + esc(s.name) + "</td><td>" + fmt(s.count) + "</td></tr>").join("")}
</tbody></table>
</div>\`;
function renderFixtures() {
const list = DATA.fixtures.filter(f => matchText(f.path, assetState.q));
byId("fix-list").innerHTML = list.length
? '<table><thead><tr><th>夹具</th><th>大小</th><th>被引用用例</th></tr></thead><tbody>' + list.map(f =>
'<tr><td class="mono">' + esc(f.path) + "</td><td>" + size(f.size) + "</td><td>" + (f.refs.length ? '<span class="badge ok">' + f.refs.length + "</span>" : '<span class="muted">—</span>') + "</td></tr>"
).join("") + "</tbody></table>"
: '<div class="empty">没有匹配的夹具</div>';
}
function renderCommands() {
const q = (byId("cmd-q")?.value || "").toLowerCase();
const list = DATA.scripts.filter(s => !q || s.name.toLowerCase().includes(q) || s.command.toLowerCase().includes(q));
byId("cmd-list").innerHTML = list.length
? '<table><thead><tr><th>命令</th><th>内容</th></tr></thead><tbody>' + list.map(s =>
"<tr><td><code>npm run " + esc(s.name) + "</code></td><td class=\\"mono\\">" + esc(s.command) + "</td></tr>"
).join("") + "</tbody></table>"
: '<div class="empty">没有匹配的命令</div>';
}
byId("fix-q").addEventListener("input", e => { assetState.q = e.target.value; renderFixtures(); });
byId("cmd-q").addEventListener("input", renderCommands);
/* ---------------- screenshot galleries (lazy) ---------------- */
function shotCard(s) {
return '<a class="shot" href="' + encodeURI(s.u) + '" target="_blank" rel="noopener">' +
'<img loading="lazy" src="' + encodeURI(s.u) + '" alt="' + esc(s.label) + '">' +
'<span class="cap">' + esc(s.label) + "</span></a>";
}
document.addEventListener("toggle", (e) => {
const d = e.target;
if (!(d instanceof HTMLDetailsElement) || !d.open) return;
const box = d.querySelector(".gallery");
if (!box || box.dataset.loaded) return;
let items = [];
if (d.dataset.case) items = DATA.shots.byCase[d.dataset.case] || [];
else if (d.dataset.task) items = DATA.shots.byTask[d.dataset.task] || [];
else if (d.dataset.ut) items = DATA.shots.utShots[d.dataset.ut] || [];
const shown = items.slice(0, 60);
box.innerHTML = shown.length
? shown.map(shotCard).join("") +
(items.length > 60 ? '<p class="muted">仅显示前 60 张,共 ' + fmt(items.length) + " 张。</p>" : "") +
(items[0].dirUrl ? '<p><a class="shot-link" href="' + encodeURI(items[0].dirUrl) + '" target="_blank" rel="noopener">打开截图目录:' + esc(items[0].dir) + "</a></p>" : "")
: '<div class="empty">无截图</div>';
box.dataset.loaded = "1";
}, true);
/* ---------------- boot ---------------- */
renderE2E();
renderUnit();
renderGold();
renderFixtures();
renderCommands();
const initial = location.hash.replace("#", "");
if (TABS.some(([id]) => id === initial)) switchTab(initial);
})();
</script>
</body>
</html>
`;
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`);