- 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>
236 lines
12 KiB
JavaScript
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();
|