#!/usr/bin/env node import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const assets = path.join(repoRoot, "web/app/public/tutorial/pbr"); const stepsDir = path.join(assets, "steps"); const rendersDir = path.join(assets, "renders"); const reportDir = path.join(repoRoot, "tests/golden/PBR_TUTORIAL-001"); const stepIds = [ "intro", "workspace-shading", "load-studio", "add-suzanne", "add-cube", "add-torus", "assign-metal", "assign-glass", "assign-wood", "assign-emission", "render-setup", "download-save", "summary", ]; const models = ["suzanne", "cube", "torus"]; const materials = ["metal-brushed", "glass", "wood", "emission-cyan"]; const errors = []; function requireFile(file, minimum = 1) { if (!fs.existsSync(file)) { errors.push(`missing: ${path.relative(repoRoot, file)}`); return; } if (fs.statSync(file).size < minimum) errors.push(`empty: ${path.relative(repoRoot, file)}`); } function pngSize(file) { const bytes = fs.readFileSync(file); if (bytes.length < 24 || bytes.toString("ascii", 1, 4) !== "PNG") throw new Error(`not a PNG: ${file}`); return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }; } function sha256(file) { return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); } for (const stepId of stepIds) { requireFile(path.join(stepsDir, `${stepId}.jpg`), 1024); requireFile(path.join(stepsDir, `${stepId}-view.jpg`), 1024); } requireFile(path.join(assets, "landing.jpg"), 1024); requireFile(path.join(stepsDir, "00-assembly.jpg"), 1024); for (const model of models) { for (const material of materials) { const png = path.join(rendersDir, `${model}-${material}.png`); const jpg = path.join(rendersDir, `${model}-${material}.jpg`); requireFile(png, 1024); requireFile(jpg, 1024); if (fs.existsSync(png)) { try { const { width, height } = pngSize(png); if (width !== 1024 || height !== 1024) errors.push(`${path.basename(png)} is ${width}x${height}, expected 1024x1024`); } catch (error) { errors.push(error instanceof Error ? error.message : String(error)); } } } } const studioBlend = path.join(assets, "studio.blend"); const studioReport = path.join(reportDir, "studio-build.json"); requireFile(studioBlend, 1024); requireFile(studioReport); if (fs.existsSync(studioBlend) && fs.existsSync(studioReport)) { const report = JSON.parse(fs.readFileSync(studioReport, "utf8")); if (report.blend?.sha256 !== sha256(studioBlend)) errors.push("studio-build.json SHA-256 does not match studio.blend"); } const latestReport = path.join(reportDir, "headed-screenshots-latest.json"); requireFile(latestReport); if (fs.existsSync(latestReport)) { const report = JSON.parse(fs.readFileSync(latestReport, "utf8")); if (report.status !== "PASS") errors.push(`headed report status is ${report.status}`); if (report.course !== "pbr") errors.push("headed report course is not pbr"); if ((report.steps?.length ?? 0) !== stepIds.length) errors.push(`headed report has ${report.steps?.length ?? 0} steps`); if ((report.renders?.length ?? 0) !== 12) errors.push(`headed report has ${report.renders?.length ?? 0} renders`); if ((report.errors?.length ?? 0) !== 0) errors.push(`headed report has ${report.errors.length} errors`); } const renderManifest = path.join(rendersDir, "manifest.json"); requireFile(renderManifest); if (fs.existsSync(renderManifest)) { const manifest = JSON.parse(fs.readFileSync(renderManifest, "utf8")); if ((manifest.renders?.length ?? 0) !== 12) errors.push(`render manifest has ${manifest.renders?.length ?? 0} entries`); for (const entry of manifest.renders ?? []) { if (!fs.existsSync(path.join(repoRoot, entry.png))) errors.push(`render manifest missing ${entry.png}`); if (!fs.existsSync(path.join(repoRoot, entry.jpg))) errors.push(`render manifest missing ${entry.jpg}`); } } if (errors.length) { console.error(JSON.stringify({ status: "FAIL", errors }, null, 2)); process.exit(1); } console.log(JSON.stringify({ status: "PASS", steps: stepIds.length, renders: 12 }, null, 2));