#!/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();