import assert from "node:assert/strict"; import { execFile, spawn } from "node:child_process"; import { createWriteStream } from "node:fs"; import { createServer } from "node:net"; import { access, copyFile, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { basename, dirname, join, resolve } from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; import { after, before, test } from "node:test"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; const execute = promisify(execFile); const directory = dirname(fileURLToPath(import.meta.url)); const root = resolve(directory, "../../../.."); const baselines = resolve( process.env.YAWN_SCREENSHOT_BASELINES ?? join(directory, "baselines"), ); const settleMilliseconds = Number(process.env.YAWN_SCREENSHOT_SETTLE_MS ?? 1200); const output = resolve( process.env.YAWN_SCREENSHOT_OUTPUT ?? join(directory, "output"), ); const revision = "d7a3cc8e51d7c573771ae77a57f16b0662a905c6"; const fixtureDirectory = resolve( process.env.YAWN_GLTF_FIXTURES ?? join(homedir(), ".cache", "yawn", "gltf-sample-models", revision), ); const selected = new Set( (process.env.YAWN_SCREENSHOT_MODELS ?? "") .split(",") .map((name) => name.trim()) .filter(Boolean), ); const update = process.env.UPDATE_SCREENSHOTS === "1"; const session = `yawn-gltf-screenshots-${process.pid}`; const temporary = await mkdtemp(join(tmpdir(), "yawn-gltf-screenshots-")); const imageSize = { width: 250, height: 180 }; const allModels = (await readdir(join(directory, "baselines"))) .filter((name) => name.endsWith(".png") && name !== "initial.png") .map((name) => basename(name, ".png")) .sort() .map((name) => ({ name, source: name === "XmpMetadataRoundedCube" ? "xmp.glb" : `${name}.glb`, path: join(fixtureDirectory, `${name}.glb`), })); const models = allModels.filter( ({ name }) => selected.size === 0 || selected.has(name), ); let serverProcess; let serverOutput = ""; let url = process.env.YAWN_VISUAL_URL?.replace(/\/+$/u, ""); function run(name, args, options = {}) { return execute(name, args, { cwd: root, maxBuffer: 10 * 1024 * 1024, timeout: 120_000, ...options, }); } async function browser(...args) { const { stdout } = await run( process.env.AGENT_BROWSER_BIN ?? "agent-browser", ["--session", session, "--json", ...args], ); const result = JSON.parse(stdout.trim()); if (!result.success) throw new Error(result.error ?? "agent-browser failed"); return result.data; } async function availablePort() { const server = createServer(); await new Promise((accept, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", accept); }); const address = server.address(); assert(address && typeof address !== "string"); await new Promise((accept, reject) => server.close((error) => (error ? reject(error) : accept())), ); return address.port; } async function startServer() { if (url) return; const binary = join(temporary, "yawn-server"); await run("go", ["build", "-o", binary, "./server"]); const port = await availablePort(); serverProcess = spawn(binary, [], { cwd: root, env: { ...process.env, PORT: String(port), YAWN_DATABASE: join(temporary, "yawn.db"), }, stdio: ["ignore", "pipe", "pipe"], }); const record = (chunk) => { serverOutput = `${serverOutput}${chunk}`.slice(-20_000); }; serverProcess.stdout.on("data", record); serverProcess.stderr.on("data", record); url = `http://localhost:${port}`; const deadline = Date.now() + 600_000; while (Date.now() < deadline) { if (serverProcess.exitCode !== null) throw new Error(`Yawn server exited early:\n${serverOutput}`); try { if ((await fetch(`${url}/healthz`)).status === 204) return; } catch { // The package build happens before the server begins listening. } await new Promise((accept) => setTimeout(accept, 250)); } throw new Error(`Yawn server did not become ready:\n${serverOutput}`); } async function validGlb(path) { try { const info = await stat(path); if (info.size < 12) return false; const file = await open(path, "r"); try { const header = Buffer.alloc(12); await file.read(header, 0, header.length, 0); return ( header.toString("ascii", 0, 4) === "glTF" && header.readUInt32LE(4) === 2 && header.readUInt32LE(8) === info.size ); } finally { await file.close(); } } catch { return false; } } async function download(model) { if (await validGlb(model.path)) return; await mkdir(fixtureDirectory, { recursive: true }); const part = `${model.path}.${process.pid}.part`; const source = new URL( `${encodeURIComponent(model.name)}/glTF-Binary/${encodeURIComponent(model.source)}`, `https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/${revision}/2.0/`, ); process.stdout.write(`Downloading ${model.name}…\n`); const response = await fetch(source); if (!response.ok || !response.body) throw new Error(`Could not download ${source}: HTTP ${response.status}`); try { await pipeline(Readable.fromWeb(response.body), createWriteStream(part)); } catch (error) { await rm(part, { force: true }); throw error; } if (!(await validGlb(part))) { await rm(part, { force: true }); throw new Error(`${model.name} did not download as a valid GLB`); } await rename(part, model.path); } async function prepareModels() { let next = 0; await Promise.all( Array.from({ length: Math.min(4, models.length) }, async () => { while (next < models.length) await download(models[next++]); }), ); } async function imageBytes(path) { const raw = join(temporary, `${basename(path)}-${Math.random()}.rgb`); await run("magick", [ path, "-filter", "box", "-resize", `${imageSize.width}x${imageSize.height}!`, "-depth", "8", `rgb:${raw}`, ]); const bytes = await readFile(raw); await rm(raw, { force: true }); return bytes; } async function compareScreenshot(name, actual) { const expected = join(baselines, `${name}.png`); if (update) { await run("magick", [ actual, "-filter", "box", "-resize", `${imageSize.width}x${imageSize.height}!`, expected, ]); return; } await access(expected); const [actualBytes, expectedBytes] = await Promise.all([ imageBytes(actual), imageBytes(expected), ]); assert.equal(actualBytes.length, expectedBytes.length); let absoluteError = 0; let differentPixels = 0; for (let index = 0; index < actualBytes.length; index += 3) { let squaredError = 0; for (let channel = 0; channel < 3; channel++) { const difference = Math.abs( actualBytes[index + channel] - expectedBytes[index + channel], ); absoluteError += difference; squaredError += difference * difference; } if (Math.sqrt(squaredError / 3) / 255 > 0.1) differentPixels++; } const meanError = absoluteError / actualBytes.length / 255; const differentRatio = differentPixels / (actualBytes.length / 3); if (meanError <= 0.01 && differentRatio <= 0.02) return; await mkdir(output, { recursive: true }); const saved = join(output, `${name}-actual.png`); const difference = join(output, `${name}-diff.png`); await copyFile(actual, saved); await run("magick", [ expected, "(", actual, "-filter", "box", "-resize", `${imageSize.width}x${imageSize.height}!`, ")", "-compose", "difference", "-composite", "-auto-level", "-filter", "point", "-resize", "640x460", difference, ]); assert.fail( `${name} differs from its reference: mean error ${(meanError * 100).toFixed(2)}%, ` + `${(differentRatio * 100).toFixed(2)}% changed pixels; see ${saved}`, ); } async function capture(name) { const path = join(temporary, `${name}.png`); await browser("screenshot", path); await compareScreenshot(name, path); } async function diagnostics() { const { errors } = await browser("errors"); const { messages } = await browser("console"); assert.deepEqual(errors, []); assert.deepEqual(messages, []); await browser("errors", "--clear"); await browser("console", "--clear"); } async function saveFailure(name) { try { await mkdir(output, { recursive: true }); await browser("screenshot", join(output, `${name}-failure.png`)); } catch { // Preserve the original test failure if the browser cannot capture. } } before( async () => { assert.equal( allModels.length, 72, "expected all 72 reviewed GLB baselines", ); assert.equal( selected.size === 0 || models.length === selected.size, true, "YAWN_SCREENSHOT_MODELS contains an unknown model", ); await prepareModels(); await startServer(); await browser("--webgpu", "--headed", "open", `${url}/view/gltf`); await browser("set", "viewport", "1000", "720", "1"); }, { timeout: 900_000 }, ); after(async () => { try { await browser("close"); } catch { // The browser may already be gone after an initialization failure. } if (serverProcess && serverProcess.exitCode === null) serverProcess.kill(); await rm(temporary, { force: true, recursive: true }); }); test("viewer starts with the local-file prompt", async () => { const state = await browser( "eval", `({ isolated: crossOriginIsolated, webgpu: Boolean(navigator.gpu), promptHidden: document.querySelector("#drop-prompt").hidden, prompt: document.querySelector("#prompt-message").textContent, renderHidden: document.querySelector("#render-time").hidden })`, ); assert.deepEqual(state.result, { isolated: true, webgpu: true, promptHidden: false, prompt: "Drop a glTF file", renderHidden: true, }); await diagnostics(); await capture("initial"); }); for (const model of models) { test( `renders ${model.name}`, async () => { try { await browser("errors", "--clear"); await browser("console", "--clear"); await browser( "eval", `(() => { window.__yawnScreenshotReady = false; const prompt = document.querySelector("#drop-prompt"); const observer = new MutationObserver(() => { if (prompt.hidden) { window.__yawnScreenshotReady = true; observer.disconnect(); } }); observer.observe(prompt, { attributes: true, attributeFilter: ["hidden"] }); })()`, ); await browser("upload", "#file-picker", model.path); await browser("wait", "--fn", "window.__yawnScreenshotReady === true"); await browser("wait", String(settleMilliseconds)); const state = await browser( "eval", `({ promptHidden: document.querySelector("#drop-prompt").hidden, renderHidden: document.querySelector("#render-time").hidden, render: document.querySelector("#render-value").textContent })`, ); assert.equal(state.result.promptHidden, true); assert.equal(state.result.renderHidden, false); assert.match(state.result.render, /^(?:—|\d+(?:\.\d+)?) ms$/u); await diagnostics(); await capture(model.name); } catch (error) { await saveFailure(model.name); throw error; } }, { timeout: 120_000 }, ); }