- 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>
1204 lines
64 KiB
JavaScript
1204 lines
64 KiB
JavaScript
#!/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 => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[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/<task>/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/<task>/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`);
|