#!/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 crypto from "node:crypto"; 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 rendersOnly = args.includes("--renders-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 gpuMode = process.env.GPU_MODE ?? (process.env.DISPLAY ? "hardware" : "swiftshader"); const hardwareArgs = [ "--no-sandbox", "--use-gl=angle", "--use-angle=gl", "--ignore-gpu-blocklist", "--enable-gpu-rasterization", "--enable-zero-copy", "--enable-unsafe-webgpu", ]; const swiftshaderArgs = [ "--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader", "--enable-unsafe-webgpu", "--enable-dawn-features=allow_unsafe_apis", "--use-webgpu-adapter=swiftshader", ]; 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: "展开二级减速器教程", }, pbr: { route: "/?ui=web&tutorial=pbr", assets: path.join(webDir, "app/public/tutorial/pbr"), report: path.join(repoRoot, "tests/golden/PBR_TUTORIAL-001"), frameAll: ["load-studio", "assign-emission", "render-setup", "download-save", "summary"], reopen: "展开 PBR 渲染教程", heading: /从零开始 PBR 渲染/, renderMatrix: { studio: path.join(webDir, "app/public/tutorial/pbr/studio.blend"), rendersDir: path.join(webDir, "app/public/tutorial/pbr/renders"), models: [ { name: "PBR Suzanne", slug: "suzanne" }, { name: "PBR Cube", slug: "cube" }, { name: "PBR Torus", slug: "torus" }, ], materials: [ { name: "PBR Metal Brushed", slug: "metal-brushed" }, { name: "PBR Glass", slug: "glass" }, { name: "PBR Wood", slug: "wood" }, { name: "PBR Emission Cyan", slug: "emission-cyan" }, ], }, }, }; 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"; } function sha256File(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); } async function openAssetLibrary(page) { for (let attempt = 0; attempt < 30; attempt += 1) { await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true }).catch(() => undefined); if (await page.locator(".pbr-asset-library").isVisible().catch(() => false)) return; await page.waitForTimeout(500); } throw new Error("PBR asset library did not become visible"); } function loadDesktopRenderManifest() { const manifestPath = path.join(course.assets, "renders", "manifest.json"); if (!fs.existsSync(manifestPath)) return null; const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); if ((manifest.renders?.length ?? 0) !== 12) return null; return { renders: manifest.renders, renderer: manifest.renderer ?? "DESKTOP_BLENDER_EEVEE" }; } async function captureRenderMatrix(context, report) { const matrix = course.renderMatrix; if (!matrix) return; fs.mkdirSync(matrix.rendersDir, { recursive: true }); const manifest = []; const page = await context.newPage(); page.on("pageerror", (error) => report.errors.push(`render pageerror: ${error.message}`)); try { await page.goto(`${baseURL}/?ui=web`); await page.locator(".blender-app").waitFor({ timeout: 60_000 }); await page.locator('[data-testid="blend-file-input"]').setInputFiles(matrix.studio); await page.locator(".blender-app[data-project-id]").waitFor({ timeout: 60_000 }); await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 60_000 }); await page.waitForFunction(() => /Objects [6-9]/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 60_000 }); await openAssetLibrary(page); await page.waitForFunction(() => document.querySelectorAll(".pbr-asset-card").length >= 20, undefined, { timeout: 60_000 }); for (const [modelIndex, model] of matrix.models.entries()) { const before = Number(/Objects (\d+)/.exec(await page.getByTestId("scene-stats").innerText())?.[1] ?? 0); await page.locator(".pbr-asset-card", { hasText: model.name }).first().dblclick(); await page.waitForFunction((expected) => { const stats = document.querySelector('[data-testid="scene-stats"]'); const count = Number(/Objects (\d+)/.exec(stats?.textContent ?? "")?.[1] ?? 0); return count >= expected; }, before + 1, { timeout: 60_000 }); await page.waitForTimeout(1_000); for (const material of matrix.materials) { await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true }); const viewport = page.locator(".viewport-placeholder"); const viewportBox = await viewport.boundingBox().catch(() => null); if (viewportBox) { await viewport.click({ position: { x: viewportBox.width / 2, y: viewportBox.height / 2 } }); await page.waitForFunction((name) => document.querySelector('[data-testid="scene-stats"]')?.textContent?.includes(`Active ${name}`), model.name, { timeout: 15_000 }).catch(() => undefined); } await page.locator(".pbr-asset-card", { hasText: material.name }).first().dblclick(); await page.waitForFunction((expected) => { const select = document.querySelector('select[aria-label^="材质槽"]'); return select?.selectedOptions?.[0]?.textContent?.trim().startsWith(expected); }, material.name, { timeout: 60_000 }); await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true }); await page.locator('[aria-label="PBR 渲染分辨率"]').selectOption("1024"); await page.locator('[aria-label="PBR 渲染相机"]').selectOption("SCENE"); await page.getByRole("button", { name: "渲染 PBR", exact: true }).click(); await page.waitForFunction(() => { const image = document.querySelector(".pbr-render-result img"); return image instanceof HTMLImageElement && image.naturalWidth > 0; }, undefined, { timeout: 120_000 }); const pngPath = path.join(matrix.rendersDir, `${model.slug}-${material.slug}.png`); const jpgPath = path.join(matrix.rendersDir, `${model.slug}-${material.slug}.jpg`); const downloadPromise = page.waitForEvent("download", { timeout: 60_000 }); await page.locator(".pbr-render-download").click(); const download = await downloadPromise; await download.saveAs(pngPath); const jpegDataUrl = await page.locator(".pbr-render-result img").evaluate(async (image) => { await image.decode(); const canvas = document.createElement("canvas"); canvas.width = image.naturalWidth; canvas.height = image.naturalHeight; canvas.getContext("2d").drawImage(image, 0, 0); return canvas.toDataURL("image/jpeg", 0.9); }); fs.writeFileSync(jpgPath, Buffer.from(jpegDataUrl.split(",")[1], "base64")); const entry = { model: model.name, modelSlug: model.slug, material: material.name, materialSlug: material.slug, png: path.relative(repoRoot, pngPath).split(path.sep).join("/"), jpg: path.relative(repoRoot, jpgPath).split(path.sep).join("/"), width: 1024, height: 1024, bytes: fs.statSync(pngPath).size, sha256: sha256File(pngPath), }; manifest.push(entry); report.renders.push(entry); } if (modelIndex < matrix.models.length - 1) { // Reload instead of deleting: library appends can reset Main's active // object, and a reload releases the native-files Web Lock cleanly. await page.reload(); await page.locator(".blender-app").waitFor({ timeout: 60_000 }); await page.locator('[data-testid="blend-file-input"]').setInputFiles(matrix.studio); await page.locator(".blender-app[data-project-id]").waitFor({ timeout: 60_000 }); await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 60_000 }); await page.waitForFunction(() => /Objects [6-9]/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 60_000 }); await openAssetLibrary(page); await page.waitForFunction(() => document.querySelectorAll(".pbr-asset-card").length >= 20, undefined, { timeout: 60_000 }); } } } catch (error) { report.errors.push(`render matrix: ${error instanceof Error ? error.message : String(error)}`); await page.screenshot({ path: path.join(reportDir, "failure-render-matrix.png") }).catch(() => undefined); } finally { await page.close().catch(() => undefined); } fs.writeFileSync(path.join(matrix.rendersDir, "manifest.json"), `${JSON.stringify({ schemaVersion: 1, renders: manifest }, null, 2)}\n`); } async function main() { const server = launchServer(); let browser; const report = { schemaVersion: 2, course: courseId, baseURL, startedAt: new Date().toISOString(), steps: [], renders: [], errors: [] }; try { await waitForServer(); browser = await chromium.launch({ headless: false, executablePath: chromePath(), args: gpuMode === "hardware" ? hardwareArgs : swiftshaderArgs, }); 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()}`); }); if (rendersOnly) { const desktop = loadDesktopRenderManifest(); if (desktop) { report.renders = desktop.renders; report.renderer = desktop.renderer; } else await captureRenderMatrix(context, report); report.completedAt = new Date().toISOString(); report.status = report.errors.length ? "FAILED" : "PASS"; return; } await page.goto(`${baseURL}${course.route}`); await page.getByRole("heading", { name: course.heading ?? /从零制作/ }).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(async () => { await page.screenshot({ path: path.join(outDir, `${stepId}-view.jpg`), fullPage: false, 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 }); const desktopRenders = loadDesktopRenderManifest(); if (desktopRenders) { report.renders = desktopRenders.renders; report.renderer = desktopRenders.renderer; } else await captureRenderMatrix(context, report); 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();