266 lines
8.5 KiB
JavaScript
266 lines
8.5 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
PACKAGE_ROOT,
|
|
RENDER_HTML_PATH,
|
|
SNAPSHOT_ORIGIN,
|
|
SnapshotError,
|
|
chromiumLaunchOptions,
|
|
loadJobFromOptions,
|
|
parseSnapshotArgs,
|
|
printRenderResult,
|
|
resolveRenderJobPacket,
|
|
resolveSnapshotRouteFile,
|
|
timestampOutputPath,
|
|
} from "./snapshot.mjs";
|
|
|
|
const ttyStdin = { isTTY: true };
|
|
|
|
function withTempImplicitModel(callback) {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "implicitjs-snapshot-"));
|
|
try {
|
|
const models = path.join(root, "models", "implicit-cad");
|
|
fs.mkdirSync(models, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(models, "orb.implicit.js"),
|
|
"export default {glsl: 'float sdf(vec3 p) { return length(p) - 1.0; }'};\n",
|
|
"utf8"
|
|
);
|
|
return callback({ root, models });
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
test("shortcut job shape is built by the JavaScript snapshot CLI", async () => {
|
|
const options = parseSnapshotArgs([
|
|
"--input",
|
|
"models/implicit-cad/orb.implicit.js",
|
|
"--output",
|
|
"tmp/orb.png",
|
|
"--camera",
|
|
"front",
|
|
"--width",
|
|
"640",
|
|
"--height",
|
|
"480",
|
|
"--params",
|
|
"{\"radius\":24}",
|
|
"--appearance",
|
|
"workbench",
|
|
"--graphics",
|
|
"{\"detail\":2,\"resolutionScale\":3}",
|
|
"--size-profile",
|
|
"simple",
|
|
]);
|
|
|
|
const job = await loadJobFromOptions(options, { stdin: ttyStdin, cwd: process.cwd() });
|
|
|
|
assert.equal(job.input, "models/implicit-cad/orb.implicit.js");
|
|
assert.equal(job.mode, "view");
|
|
assert.equal(job.outputs[0].path, "tmp/orb.png");
|
|
assert.equal(job.outputs[0].camera, "front");
|
|
assert.equal(job.outputs[0].width, 640);
|
|
assert.equal(job.outputs[0].height, 480);
|
|
assert.equal(job.appearance, "workbench");
|
|
assert.deepEqual(job.graphics, { detail: 2, resolutionScale: 3 });
|
|
assert.deepEqual(job.render, { sizeProfile: "simple" });
|
|
assert.deepEqual(job.implicitParameters, { radius: 24 });
|
|
});
|
|
|
|
test("shortcut accepts JSON camera objects", async () => {
|
|
const options = parseSnapshotArgs([
|
|
"--input",
|
|
"models/implicit-cad/orb.implicit.js",
|
|
"--output",
|
|
"tmp/orb.png",
|
|
"--camera",
|
|
"{\"position\":[4,-5,3],\"target\":[0,0,0],\"up\":[0,0,1],\"zoom\":1.4}",
|
|
]);
|
|
|
|
const job = await loadJobFromOptions(options, { stdin: ttyStdin, cwd: process.cwd() });
|
|
|
|
assert.deepEqual(job.outputs[0].camera, {
|
|
position: [4, -5, 3],
|
|
target: [0, 0, 0],
|
|
up: [0, 0, 1],
|
|
zoom: 1.4,
|
|
});
|
|
});
|
|
|
|
test("GIF shortcut defaults to orbit mode", async () => {
|
|
const options = parseSnapshotArgs([
|
|
"--input",
|
|
"models/implicit-cad/orb.implicit.js",
|
|
"--output",
|
|
"tmp/orb.gif",
|
|
]);
|
|
|
|
const job = await loadJobFromOptions(options, { stdin: ttyStdin, cwd: process.cwd() });
|
|
|
|
assert.equal(job.mode, "orbit");
|
|
assert.equal(job.outputs[0].path, "tmp/orb.gif");
|
|
});
|
|
|
|
test("render job derives asset root from input parent", () => withTempImplicitModel(({ root, models }) => {
|
|
const packet = resolveRenderJobPacket({
|
|
input: "models/implicit-cad/orb.implicit.js",
|
|
outputs: [{ path: "tmp/orb.png", camera: "iso" }],
|
|
}, {
|
|
cwd: root,
|
|
timestamp: "20260527T163012Z",
|
|
});
|
|
|
|
const job = packet.jobs[0];
|
|
assert.equal(packet.single, true);
|
|
assert.equal(job.resolved.kind, "implicit");
|
|
assert.equal(job.resolved.rootPath, models);
|
|
assert.match(job.resolved.inputUrl, /^\/__render_asset\/orb\.implicit\.js\?v=/u);
|
|
assert.equal(job.appearance, "workbench");
|
|
assert.deepEqual(job.render, {});
|
|
assert.deepEqual(job.graphics, {});
|
|
assert.equal(job.outputs[0].width, 1600);
|
|
assert.equal(job.outputs[0].height, 1200);
|
|
assert.equal(path.basename(job.outputs[0].path), "orb_20260527T163012Z.png");
|
|
}));
|
|
|
|
test("render job packet supports multi-output review snapshots", () => withTempImplicitModel(({ root }) => {
|
|
const packet = resolveRenderJobPacket({
|
|
input: "models/implicit-cad/orb.implicit.js",
|
|
render: { frameMargin: 1.55 },
|
|
outputs: [
|
|
{ path: "tmp/orb-iso.png", camera: "iso" },
|
|
{ path: "tmp/orb-front.png", camera: "front", width: 900, height: 700 },
|
|
{ path: "tmp/orb-top.png", camera: "top" },
|
|
],
|
|
}, {
|
|
cwd: root,
|
|
timestamp: "20260527T163012Z",
|
|
});
|
|
|
|
const job = packet.jobs[0];
|
|
assert.equal(packet.single, true);
|
|
assert.equal(job.outputs.length, 3);
|
|
assert.equal(job.render.frameMargin, 1.55);
|
|
assert.equal(job.outputs[0].camera, "iso");
|
|
assert.equal(job.outputs[1].camera, "front");
|
|
assert.equal(job.outputs[1].width, 900);
|
|
assert.equal(job.outputs[1].height, 700);
|
|
assert.equal(job.outputs[2].camera, "top");
|
|
assert.deepEqual(
|
|
job.outputs.map((output) => path.basename(output.path)),
|
|
[
|
|
"orb-iso_20260527T163012Z.png",
|
|
"orb-front_20260527T163012Z.png",
|
|
"orb-top_20260527T163012Z.png",
|
|
]
|
|
);
|
|
}));
|
|
|
|
test("render job packet accepts arrays with one shared timestamp", () => withTempImplicitModel(({ root }) => {
|
|
const packet = resolveRenderJobPacket([
|
|
{ input: "models/implicit-cad/orb.implicit.js", outputs: [{ path: "tmp/a.png" }] },
|
|
{ input: "models/implicit-cad/orb.implicit.js", outputs: [{ path: "tmp/b.png" }] },
|
|
], {
|
|
cwd: root,
|
|
timestamp: "20260527T163012Z",
|
|
});
|
|
|
|
assert.equal(packet.single, false);
|
|
assert.equal(packet.jobs.length, 2);
|
|
assert.equal(path.basename(packet.jobs[0].outputs[0].path), "a_20260527T163012Z.png");
|
|
assert.equal(path.basename(packet.jobs[1].outputs[0].path), "b_20260527T163012Z.png");
|
|
assert.equal(packet.jobs[0].resolved.inputUrl, packet.jobs[1].resolved.inputUrl);
|
|
}));
|
|
|
|
test("render job accepts animation mode", () => withTempImplicitModel(({ root }) => {
|
|
const packet = resolveRenderJobPacket({
|
|
input: "models/implicit-cad/orb.implicit.js",
|
|
mode: "animate",
|
|
outputs: [{ path: "tmp/orb.gif" }],
|
|
}, { cwd: root });
|
|
|
|
assert.equal(packet.jobs[0].mode, "animate");
|
|
}));
|
|
|
|
test("non implicit input files are rejected", () => {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "implicitjs-snapshot-"));
|
|
try {
|
|
const models = path.join(root, "models");
|
|
fs.mkdirSync(models, { recursive: true });
|
|
fs.writeFileSync(path.join(models, "part.js"), "export default {};\n", "utf8");
|
|
|
|
assert.throws(
|
|
() => resolveRenderJobPacket({ input: "models/part.js", outputs: [{ path: "tmp/part.png" }] }, { cwd: root }),
|
|
/only \.implicit\.js and \.implicit\.mjs/u
|
|
);
|
|
} finally {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("legacy top-level theme and params fields are rejected", () => withTempImplicitModel(({ root }) => {
|
|
assert.throws(
|
|
() => resolveRenderJobPacket({
|
|
input: "models/implicit-cad/orb.implicit.js",
|
|
theme: "dark",
|
|
outputs: [{ path: "tmp/orb.png" }],
|
|
}, { cwd: root }),
|
|
/use appearance/u
|
|
);
|
|
assert.throws(
|
|
() => resolveRenderJobPacket({
|
|
input: "models/implicit-cad/orb.implicit.js",
|
|
params: {},
|
|
outputs: [{ path: "tmp/orb.png" }],
|
|
}, { cwd: root }),
|
|
/use implicitParameters/u
|
|
);
|
|
}));
|
|
|
|
test("timestamp output path preserves extension", () => {
|
|
assert.equal(
|
|
timestampOutputPath("snapshots/review.png", "20260527T163012Z"),
|
|
"snapshots/review_20260527T163012Z.png"
|
|
);
|
|
});
|
|
|
|
test("snapshot routes are package-owned and self-contained", () => {
|
|
assert.equal(resolveSnapshotRouteFile(`${SNAPSHOT_ORIGIN}/render.html`), RENDER_HTML_PATH);
|
|
assert.equal(
|
|
resolveSnapshotRouteFile(`${SNAPSHOT_ORIGIN}/src/common/implicitHeadlessRenderEntry.js`),
|
|
path.join(PACKAGE_ROOT, "src", "common", "implicitHeadlessRenderEntry.js")
|
|
);
|
|
assert.equal(
|
|
resolveSnapshotRouteFile(`${SNAPSHOT_ORIGIN}/src/common/implicitHeadlessRenderEntry.js?v=test`),
|
|
path.join(PACKAGE_ROOT, "src", "common", "implicitHeadlessRenderEntry.js")
|
|
);
|
|
assert.throws(
|
|
() => resolveSnapshotRouteFile(`${SNAPSHOT_ORIGIN}/__render_asset/orb.implicit.js`),
|
|
SnapshotError
|
|
);
|
|
});
|
|
|
|
test("snapshot renderer does not force Chromium single-process mode", () => {
|
|
assert.notEqual(chromiumLaunchOptions().args?.includes("--single-process"), true);
|
|
});
|
|
|
|
test("render result printing surfaces captured page warnings", () => {
|
|
const lines = [];
|
|
const stdout = { write: (text) => lines.push(text) };
|
|
|
|
printRenderResult({
|
|
ok: true,
|
|
mode: "view",
|
|
outputs: [{ path: "tmp/orb.png" }],
|
|
warnings: ["page console error: THREE.WebGLProgram: Shader Error 0 - VALIDATE_STATUS false"],
|
|
}, { jsonOutput: false, stdout });
|
|
|
|
assert.equal(lines[0], "saved snapshot: tmp/orb.png\n");
|
|
assert.match(lines[1], /^snapshot warning: page console error: THREE\.WebGLProgram/);
|
|
});
|