Files
workinf_Blender_Wasm/tools/web/capture-parts-tutorial-screenshots.mjs
mes123456 d1e6b63d54
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
chore: checkpoint web Blender feature-gap work, docs and tests
- update native web_engine sources and rebuilt wasm artifacts
- add task cards, status notes and tooling scripts
- add web e2e specs, tutorial UI sources and tutorial assets
- sync golden task manifests and audit records

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

236 lines
12 KiB
JavaScript

#!/usr/bin/env node
/**
* Capture the illustrated parts tutorial from a headed Chromium browser.
*
* The script drives the real product entry (landing -> tutorial editor), runs
* every step's "演示本步" through the native Main, and writes screenshots plus a
* run report. By default it prefers the production build in web/dist; pass
* --dev to use the Vite dev server while iterating.
*
* Usage:
* node tools/web/capture-parts-tutorial-screenshots.mjs [--dev] [--port 5176]
*/
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const webDir = path.join(repoRoot, "web");
const requireFromWeb = createRequire(path.join(webDir, "package.json"));
const { chromium } = requireFromWeb("playwright");
const args = process.argv.slice(2);
const useDev = args.includes("--dev");
const landingOnly = args.includes("--landing-only");
const portFlag = args.indexOf("--port");
const port = Number.parseInt(portFlag >= 0 ? args[portFlag + 1] : process.env.WEB_TEST_PORT ?? "5176", 10) || 5176;
const baseURL = `http://127.0.0.1:${port}`;
const COURSES = {
parts: {
route: "/?tutorial=parts",
assets: path.join(webDir, "app/public/tutorial/parts"),
report: path.join(repoRoot, "tests/golden/PARTS_TUTORIAL-001"),
frameAll: ["assembly-hierarchy", "materials", "save-export", "summary"],
reopen: "展开零件建模教程",
},
reducer: {
route: "/?tutorial=reducer",
assets: path.join(webDir, "app/public/tutorial/reducer"),
report: path.join(repoRoot, "tests/golden/REDUCER_TUTORIAL-001"),
frameAll: ["shaft-assemblies", "root-hierarchy", "collections", "materials-assign", "save-export", "summary"],
reopen: "展开二级减速器教程",
},
};
const courseFlag = args.indexOf("--course");
const courseId = courseFlag >= 0 ? args[courseFlag + 1] : "parts";
const course = COURSES[courseId];
if (!course) throw new Error(`Unknown tutorial course: ${courseId}`);
const outDir = path.join(course.assets, "steps");
const reportDir = course.report;
fs.mkdirSync(outDir, { recursive: true });
fs.mkdirSync(reportDir, { recursive: true });
function launchServer() {
if (useDev) {
const vite = path.join(webDir, "node_modules/vite/bin/vite.js");
return spawn(process.execPath, [vite, "--config", "app/vite.config.ts", "--host", "127.0.0.1", "--port", String(port), "--strictPort"], {
cwd: webDir, stdio: ["ignore", "pipe", "pipe"], env: process.env,
});
}
const index = path.join(webDir, "dist/index.html");
if (!fs.existsSync(index)) throw new Error("web/dist/index.html is missing; run `npm run build` or pass --dev");
return spawn(process.execPath, [path.join(repoRoot, "tools/web/deployment-http-server.mjs"), "dist"], {
cwd: webDir, stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, BLENDER_WEB_HTTP_PORT: String(port) },
});
}
async function waitForServer(timeoutMs = 60_000) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
try {
const response = await fetch(`${baseURL}/`, { signal: AbortSignal.timeout(2000) });
if (response.ok) return;
} catch { /* retry */ }
await new Promise((resolve) => setTimeout(resolve, 300));
}
throw new Error(`server did not become ready at ${baseURL}`);
}
function chromePath() {
return process.env.CHROME_PATH || "/usr/bin/google-chrome";
}
async function main() {
const server = launchServer();
let browser;
const report = { schemaVersion: 1, baseURL, startedAt: new Date().toISOString(), steps: [], errors: [] };
try {
await waitForServer();
browser = await chromium.launch({
headless: false,
executablePath: chromePath(),
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader", "--enable-unsafe-webgpu", "--enable-dawn-features=allow_unsafe_apis", "--use-webgpu-adapter=swiftshader"],
});
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, acceptDownloads: true });
const page = await context.newPage();
page.on("pageerror", (error) => report.errors.push(`pageerror: ${error.message}`));
page.on("console", (message) => {
if (message.type() !== "error") return;
// The editor's existing inline-style CSP violation is unrelated to the
// tutorial and appears on every page load; keep it out of the step verdict.
if (message.text().includes("Content Security Policy directive") && message.text().includes("style")) return;
report.errors.push(`console: ${message.text()}`);
});
await page.goto(`${baseURL}${course.route}`);
await page.getByRole("heading", { name: /从零制作/ }).waitFor({ timeout: 60_000 });
await page.evaluate(async () => {
const images = [...document.images];
for (const image of images) image.loading = "eager";
await Promise.all(images.map((image) => image.complete ? Promise.resolve() : new Promise((resolve) => { image.addEventListener("load", resolve, { once: true }); image.addEventListener("error", resolve, { once: true }); })));
});
await page.screenshot({ path: path.join(course.assets, "landing.jpg"), fullPage: true, type: "jpeg", quality: 85 });
if (landingOnly) { report.status = "PASS"; return; }
await page.getByRole("button", { name: /开始学习/ }).click();
await page.locator("[data-tutorial-step]").waitFor({ timeout: 90_000 });
await page.locator('.tutorial-loading[data-tutorial-ready="false"]').waitFor({ state: "detached", timeout: 120_000 });
let stepIndex = 0;
for (;;) {
const panel = page.locator("[data-tutorial-step]");
const stepId = await panel.getAttribute("data-tutorial-step");
stepIndex += 1;
const fileName = `${stepId}.jpg`;
const demo = page.getByRole("button", { name: "演示本步" });
let demoState = "none";
if (await demo.count()) {
try {
await demo.click({ timeout: 45_000 });
} catch (error) {
const state = await demo.getAttribute("data-demo-state").catch(() => null);
const engineStatus = await page.locator('[data-testid="engine-status"]').textContent().catch(() => null);
report.errors.push(`${stepId}: demo button not actionable (${state ?? "unknown"}); engine=${engineStatus ?? "unknown"}`);
throw error;
}
demoState = "clicked";
await page.waitForFunction(() => {
if (document.querySelector(".tutorial-error")) return "error";
const rows = [...document.querySelectorAll("[data-check-id]")];
const demo = document.querySelector(".tutorial-demo");
const running = demo?.getAttribute("data-demo-state") === "running";
return !running && rows.length > 0 && rows.every((row) => row.getAttribute("data-ok") === "true") ? "ok" : false;
}, undefined, { timeout: 180_000 }).then((outcome) => {
if (outcome === "error") throw new Error("demo reported an error");
}).catch(async (error) => {
const overlay = (await page.locator(".tutorial-error").allTextContents()).join(" ") || "checks did not pass";
const checks = await page.locator("[data-check-id]").evaluateAll((rows) => rows.map((row) => ({
id: row.getAttribute("data-check-id"),
ok: row.getAttribute("data-ok"),
text: (row.textContent ?? "").trim().slice(0, 200),
})));
const engineStatus = await page.locator('[data-testid="engine-status"]').textContent().catch(() => null);
const actions = await page.evaluate(() => {
const main = document.querySelector("main.blender-app");
const demo = document.querySelector(".tutorial-demo");
return {
save: main?.getAttribute("data-user-action-save-status"), saveAs: main?.getAttribute("data-user-action-save-as-status"),
saveAsError: main?.getAttribute("data-user-action-save-as-error"),
export: main?.getAttribute("data-user-action-export-status"), exportError: main?.getAttribute("data-user-action-export-error"),
demo: demo?.getAttribute("data-demo-state"), demoText: demo?.textContent?.trim(),
};
});
await page.screenshot({ path: path.join(reportDir, `failure-${stepId}.png`) }).catch(() => undefined);
report.errors.push(`${stepId}: ${overlay} :: ${JSON.stringify({ checks, engineStatus, actions })}`);
demoState = "failed";
throw error;
});
} else {
// Guide/summary cards only wait for a ready engine.
await page.waitForFunction(() => !document.querySelector('.tutorial-loading[data-tutorial-ready="false"]'), undefined, { timeout: 120_000 });
}
await page.waitForTimeout(500);
// Capture the clean viewport reference first so the full screenshot below
// already shows the step's reference image instead of a pending request.
const collapse = page.getByRole("button", { name: "收起教程侧栏" });
if (await collapse.count()) {
await collapse.click();
// Frame the step's subject. Steps whose active object is the assembly
// Empty use the whole scene instead of framing an empty point.
const frameAll = course.frameAll.includes(stepId);
await page.getByRole("button", { name: frameAll ? "显示全部" : "框显所选" }).click({ timeout: 5000 }).catch(() => undefined);
await page.waitForTimeout(400);
await page.locator(".viewport-placeholder").screenshot({ path: path.join(outDir, `${stepId}-view.jpg`), type: "jpeg", quality: 85 }).catch(() => undefined);
await page.getByRole("button", { name: course.reopen }).click();
await page.waitForTimeout(600);
}
await page.screenshot({ path: path.join(outDir, fileName), fullPage: false, type: "jpeg", quality: 85 });
report.steps.push({ index: stepIndex, stepId, fileName, demo: demoState });
const next = page.getByRole("button", { name: /下一步|下一节/ });
if (!(await next.count())) break;
try {
await next.click({ timeout: 60_000 });
} catch (error) {
const state = await page.evaluate(() => {
const panel = document.querySelector("[data-tutorial-step]");
const main = document.querySelector("main.blender-app");
const demo = document.querySelector(".tutorial-demo");
return {
step: panel?.getAttribute("data-tutorial-step"),
project: panel?.getAttribute("data-tutorial-project"),
ready: panel?.getAttribute("data-tutorial-ready"),
demo: demo?.getAttribute("data-demo-state"),
save: main?.getAttribute("data-user-action-save-status"),
checks: [...document.querySelectorAll("[data-check-id]")].map((row) => ({ id: row.getAttribute("data-check-id"), ok: row.getAttribute("data-ok") })),
error: document.querySelector(".tutorial-error")?.textContent,
};
}).catch(() => null);
await page.screenshot({ path: path.join(reportDir, `failure-next-${stepId}.png`) }).catch(() => undefined);
report.errors.push(`next-click ${stepId}: ${JSON.stringify(state)}`);
throw error;
}
await page.waitForTimeout(300);
}
await page.screenshot({ path: path.join(outDir, "00-assembly.jpg"), fullPage: false, type: "jpeg", quality: 85 });
report.completedAt = new Date().toISOString();
report.status = report.errors.length ? "FAILED" : "PASS";
} catch (error) {
report.status = "FAILED";
report.errors.push(error instanceof Error ? error.stack ?? error.message : String(error));
} finally {
await browser?.close().catch(() => undefined);
server.kill("SIGTERM");
fs.writeFileSync(path.join(reportDir, `headed-screenshots-${Date.now()}.json`), JSON.stringify(report, null, 2));
const latest = path.join(reportDir, "headed-screenshots-latest.json");
fs.writeFileSync(latest, JSON.stringify(report, null, 2));
console.log(JSON.stringify({ status: report.status, steps: report.steps.length, errors: report.errors.slice(0, 4) }, null, 2));
process.exitCode = report.status === "PASS" ? 0 : 1;
}
}
await main();