Add local glTF viewer and visual coverage
Amp-Thread-ID: https://ampcode.com/threads/T-01a02d1c-27fc-7761-9f51-ed97b2dd4e3e Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
@@ -0,0 +1,18 @@
|
||||
# glTF screenshot tests
|
||||
|
||||
`npm run test:handles:screenshots` renders the viewer's empty state and every
|
||||
glTF-Binary model in the pinned Khronos glTF Sample Models revision. The suite
|
||||
uses headed Chromium on a private Xvfb display and SwiftShader WebGPU at a fixed
|
||||
1000×720 viewport. It checks the viewer state, browser diagnostics, and a
|
||||
250×180 downsample against each reviewed reference image.
|
||||
|
||||
The first run downloads roughly 215 MB of GLBs into
|
||||
`~/.cache/yawn/gltf-sample-models`. Set `YAWN_GLTF_FIXTURES` to use another
|
||||
cache directory, or `YAWN_SCREENSHOT_MODELS=Box,BoomBox` to run a subset.
|
||||
Failed full-size captures and visual diffs are written to `output/`.
|
||||
|
||||
To intentionally replace references after reviewing full-size renders, run:
|
||||
|
||||
```sh
|
||||
UPDATE_SCREENSHOTS=1 npm run test:handles:screenshots
|
||||
```
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,391 @@
|
||||
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 = join(directory, "baselines");
|
||||
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(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", "1200");
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||