feat: add render graph driven renderer architecture

Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab

Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-07-27 02:53:44 +00:00
co-authored by heaust
parent 8a8369706b
commit d4e8634f67
290 changed files with 48804 additions and 1995 deletions
+88
View File
@@ -0,0 +1,88 @@
/** Test-only headless authority assembled from the example's public node definitions. */
import { compileFxNodeComposition, composeNode, composeSocket, setTheme } from "@lib/composition/index.js";
import type { FxNodeCompositionData } from "@lib/composition/index.js";
import { bindFxNodeHeadless } from "@lib/headless-runtime.js";
import {
APPLICATION_HEADER_STYLES,
APPLICATION_ID,
APPLICATION_RESOURCES,
APPLICATION_VERSION,
applicationCompatibility,
} from "../examples/blender/nodes/application.js";
import { exampleTheme } from "../examples/shared/theme.js";
import {
anySocket,
floatSocket,
vectorSocket,
colorSocket,
shaderSocket,
geometrySocket,
} from "../examples/blender/nodes/socket-types.js";
import { frameNode } from "../examples/blender/nodes/common/frame.js";
import { rerouteNode } from "../examples/blender/nodes/common/reroute.js";
import { groupInputNode } from "../examples/blender/nodes/common/group-input.js";
import { groupOutputNode } from "../examples/blender/nodes/common/group-output.js";
import { valueNode } from "../examples/blender/nodes/shader/value.js";
import { colorNode } from "../examples/blender/nodes/shader/color.js";
import { mathNode } from "../examples/blender/nodes/shader/math.js";
import { vectorMathNode } from "../examples/blender/nodes/shader/vector-math.js";
import { mixNode } from "../examples/blender/nodes/shader/mix.js";
import { colorRampNode } from "../examples/blender/nodes/shader/color-ramp.js";
import { textureCoordinateNode } from "../examples/blender/nodes/shader/texture-coordinate.js";
import { noiseTextureNode } from "../examples/blender/nodes/shader/noise-texture.js";
import { imageTextureNode } from "../examples/blender/nodes/shader/image-texture.js";
import { principledBsdfNode } from "../examples/blender/nodes/shader/principled-bsdf.js";
import { materialOutputNode } from "../examples/blender/nodes/shader/material-output.js";
import { positionNode } from "../examples/blender/nodes/geometry/position.js";
import { meshCubeNode } from "../examples/blender/nodes/geometry/mesh-cube.js";
import { setPositionNode } from "../examples/blender/nodes/geometry/set-position.js";
import { transformGeometryNode } from "../examples/blender/nodes/geometry/transform-geometry.js";
import { joinGeometryNode } from "../examples/blender/nodes/geometry/join-geometry.js";
import { imageNode } from "../examples/blender/nodes/compositor/image.js";
import { colorBalanceNode } from "../examples/shared/nodes/color-balance.js";
const seed = {
schemaVersion: 2,
id: APPLICATION_ID,
version: APPLICATION_VERSION,
nodeStyles: APPLICATION_HEADER_STYLES,
resources: APPLICATION_RESOURCES,
compatibility: applicationCompatibility,
socketTypes: {},
nodes: {},
} as const;
const themed = setTheme(seed, exampleTheme);
const socket1 = composeSocket(themed, ...anySocket);
const socket2 = composeSocket(socket1, ...floatSocket);
const socket3 = composeSocket(socket2, ...vectorSocket);
const socket4 = composeSocket(socket3, ...colorSocket);
const socket5 = composeSocket(socket4, ...shaderSocket);
const socket6 = composeSocket(socket5, ...geometrySocket);
const node1 = composeNode(socket6, ...frameNode);
const node2 = composeNode(node1, ...rerouteNode);
const node3 = composeNode(node2, ...groupInputNode);
const node4 = composeNode(node3, ...groupOutputNode);
const node5 = composeNode(node4, ...valueNode);
const node6 = composeNode(node5, ...colorNode);
const node7 = composeNode(node6, ...mathNode);
const node8 = composeNode(node7, ...vectorMathNode);
const node9 = composeNode(node8, ...mixNode);
const firstBranch = composeNode(node9, ...colorRampNode);
const node11 = composeNode(socket6, ...textureCoordinateNode);
const node12 = composeNode(node11, ...noiseTextureNode);
const node13 = composeNode(node12, ...imageTextureNode);
const node14 = composeNode(node13, ...principledBsdfNode);
const node15 = composeNode(node14, ...materialOutputNode);
const secondBranch = composeNode(node15, ...positionNode);
const node17 = composeNode(socket6, ...meshCubeNode);
const node18 = composeNode(node17, ...setPositionNode);
const node19 = composeNode(node18, ...transformGeometryNode);
const node20 = composeNode(node19, ...joinGeometryNode);
const node21 = composeNode(node20, ...imageNode);
const thirdBranch = composeNode(node21, ...colorBalanceNode);
const source: FxNodeCompositionData = {
...firstBranch,
nodes: { ...firstBranch.nodes, ...secondBranch.nodes, ...thirdBranch.nodes },
};
export const APPLICATION_COMPILED = compileFxNodeComposition(source);
export const APPLICATION_HEADLESS = bindFxNodeHeadless(APPLICATION_COMPILED);
+82
View File
@@ -0,0 +1,82 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
planAtlasCompaction,
planAtlasUpsert,
removeAtlasItem,
type AtlasLayout,
} from "@lib/worker/atlas-allocator.js";
const add = (layout: AtlasLayout | undefined, id: string, width: number, height: number) => {
const plan = planAtlasUpsert(layout, { id, width, height });
assert.equal(plan.ok, true);
return plan.ok ? plan.layout : undefined!;
};
const overlaps = (a: { x: number; y: number; width: number; height: number }, b: typeof a) =>
a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
function valid(layout: AtlasLayout): void {
const regions = [...layout.regions.values()],
free = [...layout.free];
regions.forEach((region, index) => {
assert(region.x >= 0 && region.y >= 0);
assert(region.x + region.width <= layout.width);
assert(region.y + region.height <= layout.height);
regions.slice(index + 1).forEach((other) => assert.equal(overlaps(region, other), false));
free.forEach((other) => assert.equal(overlaps(region, other), false));
});
free.forEach((region, index) => {
assert(region.x >= 0 && region.y >= 0);
assert(region.x + region.width <= layout.width);
assert(region.y + region.height <= layout.height);
free.slice(index + 1).forEach((other) => assert.equal(overlaps(region, other), false));
});
const covered = [...regions, ...free].reduce((sum, region) => sum + region.width * region.height, 0);
assert.equal(covered, layout.width * layout.height);
}
test("atlas allocation is deterministic, bounded, and preserves shrink capacity", () => {
const first = add(undefined, "a", 100, 80);
assert.deepEqual(
{ width: first.width, height: first.height, region: first.regions.get("a") },
{
width: 256,
height: 256,
region: { x: 0, y: 0, width: 100, height: 80 },
},
);
const second = add(first, "b", 90, 70),
replay = add(add(undefined, "a", 100, 80), "b", 90, 70);
assert.deepEqual([...second.regions], [...replay.regions]);
const shrunk = add(second, "a", 40, 30);
assert.deepEqual(shrunk.regions.get("a"), second.regions.get("a"));
assert.deepEqual(shrunk.items.get("a"), { width: 40, height: 30 });
valid(shrunk);
});
test("atlas relocation, removal, growth, and capacity failure remain atomic", () => {
let layout = add(undefined, "a", 200, 200);
layout = add(layout, "b", 200, 200);
const before = [...layout.regions];
const oversized = planAtlasUpsert(layout, { id: "bad", width: 8193, height: 1 });
assert.deepEqual(oversized, { ok: false, code: "atlas.dimension" });
assert.deepEqual([...layout.regions], before);
const grown = add(layout, "a", 500, 300);
assert(grown.width * grown.height >= layout.width * layout.height);
valid(grown);
const removed = removeAtlasItem(grown, "b")!;
assert.equal(removed.items.has("b"), false);
valid(removed);
assert.equal(removeAtlasItem(removed, "a"), undefined);
});
test("atlas compaction is deferred unless occupancy and savings justify it", () => {
let layout = add(undefined, "large", 1000, 1000);
layout = add(layout, "small", 100, 100);
layout = removeAtlasItem(layout, "large")!;
const compact = planAtlasCompaction(layout);
assert(compact?.ok);
if (compact?.ok) {
assert(compact.layout.width * compact.layout.height <= (layout.width * layout.height) / 2);
valid(compact.layout);
}
});
+43
View File
@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
import ts from "typescript";
test("browser client has no DOM lifecycle ownership", () => {
const path = new URL("../src/browser/client.ts", import.meta.url),
text = readFileSync(path, "utf8"),
source = ts.createSourceFile(path.pathname, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
const forbidden = new Set([
"document",
"window",
"ResizeObserver",
"MutationObserver",
"isConnected",
"addEventListener",
"removeEventListener",
"createElement",
"getBoundingClientRect",
"focus",
"setPointerCapture",
"releasePointerCapture",
"hasPointerCapture",
"tabIndex",
"touchAction",
"clientWidth",
"clientHeight",
]),
found = new Set<string>();
const visit = (node: ts.Node): void => {
if (ts.isIdentifier(node) && forbidden.has(node.text)) found.add(node.text);
ts.forEachChild(node, visit);
};
visit(source);
assert.deepEqual([...found], []);
assert(!text.includes('from "./add-node-menu.js"'));
assert(!/\b(?:this\.)?canvas\.(?:width|height)\s*=/.test(text));
assert(!existsSync(new URL("../src/browser/add-node-menu.ts", import.meta.url)));
const publicIndex = readFileSync(new URL("../src/index.ts", import.meta.url), "utf8");
assert(publicIndex.includes("FxNodeResourceOpenRequest"));
assert(!publicIndex.includes("FxNodeResourceOpenTarget"));
assert(!publicIndex.includes("FxNodeResourceHitRegion"));
});
+141
View File
@@ -0,0 +1,141 @@
import { expect, test } from "@playwright/test";
test("right-click DOM menu searches and adds through one worker gesture", async ({ page }) => {
await page.goto("/test/browser/index.html");
await page.evaluate(() => window.ready);
await page.evaluate(() => {
const events: {
mutations: Array<{ version: number; cause: string; mutations: readonly unknown[] }>;
snapshots: number[];
requests: Array<{ frozen: boolean; revision: number; position: { x: number; y: number } }>;
} = { mutations: [], snapshots: [], requests: [] };
(window as typeof window & { menuEvents: typeof events }).menuEvents = events;
window.root.onMutations((event) => events.mutations.push(event));
window.root.onSnapshots((event) => events.snapshots.push(event.version));
window.view.onHostRequests((request) => {
if (request.kind === "add-node-menu")
events.requests.push({
frozen: Object.isFrozen(request) && Object.isFrozen(request.viewPosition),
revision: request.compositionRevision,
position: { ...request.viewPosition },
});
});
});
const canvas = page.locator("#primary");
await canvas.click({ button: "right", position: { x: 40, y: 50 } });
const dialog = page.getByRole("dialog", { name: "Add node" });
await expect(dialog).toBeVisible();
expect(
await dialog.getByRole("group").evaluateAll((groups) =>
groups.map((group) => ({
name: group.getAttribute("aria-label"),
options: [...group.querySelectorAll('[role="option"]')].map((item) => item.textContent?.trim()),
})),
),
).toEqual([
{ name: "Common", options: ["Frame", "Reroute", "Group Input", "Group Output"] },
{
name: "Shader",
options: [
"Value",
"Color",
"Math",
"Vector Math",
"Mix",
"Color Ramp",
"Texture Coordinate",
"Noise Texture",
"Image Texture",
"Principled BSDF",
"Material Output",
],
},
{ name: "Geometry", options: ["Position", "Mesh Cube", "Set Position", "Transform Geometry", "Join Geometry"] },
{ name: "Compositor", options: ["Image", "Color Balance"] },
]);
const search = page.getByRole("combobox", { name: "Search nodes" });
await search.fill(" ShAdEr ");
await expect(dialog.getByRole("option")).toHaveText([
"Value",
"Color",
"Math",
"Vector Math",
"Mix",
"Color Ramp",
"Texture Coordinate",
"Noise Texture",
"Image Texture",
"Principled BSDF",
"Material Output",
]);
await search.fill("noise-texture");
await expect(dialog.getByRole("option")).toHaveText(["Noise Texture"]);
await search.press("Enter");
await expect(dialog).toHaveCount(0);
await expect
.poll(() => page.evaluate(() => window.root.getState().then((snapshot) => snapshot.nodes.length)))
.toBe(1);
const result = await page.evaluate(async () => ({
snapshot: await window.root.getState(),
events: (
window as typeof window & {
menuEvents: {
mutations: Array<{ version: number; cause: string; mutations: readonly unknown[] }>;
snapshots: number[];
requests: Array<{ frozen: boolean; revision: number; position: { x: number; y: number } }>;
};
}
).menuEvents,
}));
expect(result.snapshot.nodes[0]?.typeId).toBe("fxnode.shader.noise-texture");
expect(result.snapshot.nodes[0]?.position).toEqual({ x: -120, y: 40 });
expect(result.events.mutations).toHaveLength(1);
expect(result.events.mutations[0]).toMatchObject({ version: 2, cause: "api" });
expect(result.events.mutations[0]?.mutations).toHaveLength(1);
expect(result.events.snapshots).toEqual([2]);
expect(result.events.requests).toEqual([{ frozen: true, revision: 31, position: { x: 40, y: 50 } }]);
const rootAdded = await page.evaluate(async () => {
const beforeSelection = window.view.getHostSnapshot().selection.nodeCount;
await window.root.dispatch({ type: "node.add", nodeType: "fxnode.shader.value", position: { x: -120, y: 40 } });
const snapshot = await window.root.getState();
return {
id: snapshot.nodes.find((node) => node.typeId === "fxnode.shader.value")!.id,
beforeSelection,
afterSelection: window.view.getHostSnapshot().selection.nodeCount,
};
});
expect(rootAdded.afterSelection).toBe(rootAdded.beforeSelection);
await canvas.press("Delete");
expect((await page.evaluate(() => window.root.getState())).nodes.map((node) => node.id)).toEqual([rootAdded.id]);
await windowUndo(page);
await windowUndo(page);
await windowUndo(page);
expect((await page.evaluate(() => window.root.getState())).nodes).toHaveLength(0);
await canvas.click({ button: "right", position: { x: 80, y: 80 } });
await expect(dialog).toBeVisible();
await page.evaluate(async () => {
window.fxnodeHost.destroy();
await window.view.detach();
window.root.destroy();
});
await expect(dialog).toHaveCount(0);
});
test("right-click on a node and Ctrl-RMB never open the add menu", async ({ page }) => {
await page.goto("/examples/blender/");
await page.evaluate(() => window.fxnodeExample.ready);
const canvas = page.locator("#graph"),
dialog = page.getByRole("dialog", { name: "Add node" });
await canvas.click({ button: "right", position: { x: 320, y: 160 } });
await expect(dialog).toHaveCount(0);
await canvas.hover({ position: { x: 30, y: 30 } });
await page.keyboard.down("Control");
await page.mouse.down({ button: "right" });
await page.mouse.up({ button: "right" });
await page.keyboard.up("Control");
await expect(dialog).toHaveCount(0);
});
async function windowUndo(page: import("@playwright/test").Page): Promise<void> {
await page.evaluate(() => window.root.undo());
}
+10
View File
@@ -0,0 +1,10 @@
import { expect, test } from "@playwright/test";
test("@visual all supported catalog", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await page.goto("/examples/blender/all-supported/");
await page.waitForFunction(() => Boolean(window.fxnodeExample));
await page.evaluate(() => window.fxnodeExample.ready);
await page.locator("canvas").press("Home");
await page.evaluate(() => window.fxnodeExample.view!.whenRendered());
await expect(page.locator("canvas")).toHaveScreenshot("all-supported.png", { animations: "disabled" });
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

@@ -0,0 +1,129 @@
import { expect, test } from "@playwright/test";
const expectedNodeTypes = [
"fxnode.common.frame",
"fxnode.common.reroute",
"fxnode.common.group-input",
"fxnode.common.group-output",
"fxnode.shader.value",
"fxnode.shader.color",
"fxnode.shader.math",
"fxnode.shader.vector-math",
"fxnode.shader.mix",
"fxnode.shader.color-ramp",
"fxnode.shader.texture-coordinate",
"fxnode.shader.noise-texture",
"fxnode.shader.image-texture",
"fxnode.shader.principled-bsdf",
"fxnode.shader.material-output",
"fxnode.geometry.position",
"fxnode.geometry.mesh-cube",
"fxnode.geometry.set-position",
"fxnode.geometry.transform-geometry",
"fxnode.geometry.join-geometry",
"fxnode.compositor.image",
"fxnode.compositor.color-balance",
] as const;
test("example installs every startup node through the live worker API", async ({ page }) => {
await page.addInitScript(() => {
const sent: unknown[] = [],
received: unknown[] = [];
(
window as unknown as { applicationStartupMessages: { sent: unknown[]; received: unknown[] } }
).applicationStartupMessages = { sent, received };
const NativeWorker = Worker;
class StartupWorker extends NativeWorker {
constructor(url: string | URL, options?: WorkerOptions) {
super(url, options);
super.addEventListener("message", (event) => received.push(structuredClone(event.data)));
}
override postMessage(message: unknown, options?: StructuredSerializeOptions | Transferable[]) {
sent.push(structuredClone(message));
super.postMessage(message, options as StructuredSerializeOptions);
}
}
Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: StartupWorker });
});
await page.goto("/examples/blender/");
await page.evaluate(() => window.fxnodeExample.ready);
const result = await page.evaluate(async () => {
const messages = (window as unknown as { applicationStartupMessages: { sent: any[]; received: any[] } })
.applicationStartupMessages,
init = messages.sent.find((message) => message.type === "init"),
updates = messages.sent.filter((message) => message.type === "composition.update"),
stateSet = messages.sent.find((message) => message.type === "state.set"),
api = window.fxnodeExample.root!;
const updateIds = new Set(updates.map((message) => message.id)),
compositionReceipts = messages.received
.filter((message) => message.type === "response" && updateIds.has(message.id))
.map((message) => message.value),
stateSetIndex = messages.sent.indexOf(stateSet),
lastUpdateIndex = Math.max(...updates.map((message) => messages.sent.indexOf(message))),
snapshot = await api.getState(),
undo = await api.undo({ expectedVersion: 1 }),
empty = await api.getState(),
redo = await api.redo({ expectedVersion: 2 }),
restored = await api.getState();
return {
initKeys: Object.keys(init).sort(),
updates: updates.map((message) => ({
kind: message.update.kind,
id: message.update.id,
expected: message.expected,
})),
compositionReceipts,
stateSet,
stateSetIndex,
lastUpdateIndex,
snapshot,
undo,
empty,
redo,
restored,
saved: await api.save(),
initialLayout: await (await fetch("/examples/blender/initialLayout.json")).json(),
};
});
expect(result.initKeys).toEqual([
"applicationId",
"applicationVersion",
"historyLimit",
"id",
"protocol",
"resources",
"type",
]);
expect(result.updates).toHaveLength(31);
expect(result.updates.slice(0, 9).map((update) => update.kind)).toEqual([
"theme.set",
"header-styles.set",
...Array(6).fill("socket.compose"),
"compatibility.set",
]);
expect(result.updates.slice(9).map((update) => update.kind)).toEqual(Array(22).fill("node.compose"));
expect(result.updates.slice(9).map((update) => update.id)).toEqual(expectedNodeTypes);
expect(result.updates.map((update) => update.expected)).toEqual(Array(31).fill({ kind: "current" }));
expect(result.compositionReceipts).toHaveLength(31);
expect(
result.compositionReceipts.every((receipt) => receipt.graphVersion === 0 && receipt.graphChanged === false),
).toBe(true);
expect(result.stateSetIndex).toBeGreaterThan(result.lastUpdateIndex);
expect(result.stateSet.expected).toEqual({ kind: "current" });
expect(result.stateSet.state.schemaVersion).toBeUndefined();
expect(result.stateSet.state.version).toBeUndefined();
expect(result.stateSet.state.nodes.every((node: any) => node.known === true)).toBe(true);
expect(result.snapshot.version).toBe(1);
expect(result.snapshot.nodes.length).toBe(result.initialLayout.nodes.length);
expect(result.snapshot.nodes.every((node: any) => node.known)).toBe(true);
expect(result.undo).toEqual({ status: "committed", version: 2 });
expect(result.empty).toMatchObject({ version: 2, nodes: [], links: [] });
expect(result.redo).toEqual({ status: "committed", version: 3 });
expect(result.restored.nodes).toEqual(result.snapshot.nodes);
expect(result.restored.links).toEqual(result.snapshot.links);
expect(result.saved).toEqual({
schemaVersion: 2,
...result.initialLayout,
nodes: result.initialLayout.nodes.map(({ known: _known, ...node }: any) => node),
});
});
+261
View File
@@ -0,0 +1,261 @@
import { expect, test } from "@playwright/test";
test("canvas-free authority owns independent attachable views", async ({ page }) => {
await page.goto("/test/browser/client-runtime.html");
const result = await page.evaluate(async () => {
type Wire = Record<string, unknown> & { type: string; id?: string; viewId?: string };
class FakeWorker {
static readonly instances: FakeWorker[] = [];
readonly posted: Wire[] = [];
onmessage: ((event: MessageEvent<unknown>) => void) | null = null;
onerror: ((event: ErrorEvent) => void) | null = null;
onmessageerror: ((event: MessageEvent<unknown>) => void) | null = null;
terminated = false;
holdViewports = false;
readonly heldViewportIds: string[] = [];
constructor() {
FakeWorker.instances.push(this);
}
postMessage(message: Wire): void {
this.posted.push(message);
if (!message.id) return;
if (message.type === "init") this.respond(message.id);
else if (message.type === "view.attach") {
// Exercise the valid event-before-acknowledgement ordering.
this.emit({
protocol: 3,
type: "view.selection.host",
viewId: message.viewId,
projection: { nodeCount: 0, linkCount: 0, canRemove: false, mute: { enabled: false } },
});
this.respond(message.id);
} else if (message.type === "view.detach") this.respond(message.id);
else if (message.type === "command") this.respond(message.id, { status: "committed", version: 1 });
else if (message.type === "view.viewport") {
if (this.holdViewports) this.heldViewportIds.push(message.id);
else this.respond(message.id);
} else if (message.type.startsWith("view.")) this.respond(message.id, { status: "noop", version: 0 });
}
terminate(): void {
this.terminated = true;
}
emit(data: unknown): void {
queueMicrotask(() => this.onmessage?.(new MessageEvent("message", { data })));
}
settleViewport(ok: boolean): void {
const id = this.heldViewportIds.shift()!;
this.emit({
protocol: 3,
type: "response",
id,
ok,
...(ok ? {} : { error: { code: "viewport.test", message: "Test rejection" } }),
});
}
private respond(id: string, value?: unknown): void {
this.emit({
protocol: 3,
type: "response",
id,
ok: true,
...(value === undefined ? {} : { value }),
});
}
}
const NativeWorker = window.Worker;
Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: FakeWorker });
try {
const { createFxNode, FxNodeViewDetachedError } = (await import(
"/src/index.ts" as string
)) as typeof import("@lib/index.js");
const api = await createFxNode({ applicationId: "client-test", applicationVersion: 1, resources: {} });
const worker = FakeWorker.instances[0]!;
const rootMessages = worker.posted.map((message) => message.type);
const firstCanvas = document.createElement("canvas"),
secondCanvas = document.createElement("canvas"),
viewport = { width: 320, height: 180, dpr: 1 };
const first = await api.attachView({ canvas: firstCanvas, viewport });
const second = await api.attachView({
canvas: secondCanvas,
viewport,
initialCamera: { center: { x: 2_000, y: 0 }, zoom: 0.75 },
});
const pointerLaneCount = worker.posted.filter(
(message) =>
message.type === "view.attach" &&
typeof SharedArrayBuffer === "function" &&
message.pointerLane instanceof SharedArrayBuffer,
).length;
let duplicateCode = "";
try {
await api.attachView({ canvas: firstCanvas, viewport });
} catch (error) {
duplicateCode = (error as { code?: string }).code ?? "";
}
worker.emit({
protocol: 3,
type: "view.selection.host",
viewId: first.id,
projection: {
nodeCount: 1,
linkCount: 0,
canRemove: true,
mute: { enabled: true, state: "all-unmuted" },
},
});
await new Promise((resolve) => setTimeout(resolve));
const selections = [first.getHostSnapshot().selection.nodeCount, second.getHostSnapshot().selection.nodeCount];
const modifiers = { alt: false, control: false, meta: false, shift: false };
const viewportCount = () => worker.posted.filter((message) => message.type === "view.viewport").length;
const beforeNoop = viewportCount();
await first.setViewport(viewport);
const noopDidNotPost = viewportCount() === beforeNoop;
worker.holdViewports = true;
const resizeA = first.setViewport({ width: 321, height: 180, dpr: 1 });
await new Promise((resolve) => setTimeout(resolve));
const resizeB = first.setViewport({ width: 322, height: 180, dpr: 1 });
const resizeC = first.setViewport({ width: 323, height: 180, dpr: 1 });
const resizeMessagesBeforeA = viewportCount() - beforeNoop;
const resourceRequests = [0, 0];
first.onHostRequests((request) => {
if (request.kind === "resource-open") resourceRequests[0] = resourceRequests[0]! + 1;
});
second.onHostRequests((request) => {
if (request.kind === "resource-open") resourceRequests[1] = resourceRequests[1]! + 1;
});
first.feedInput({
kind: "pointer",
phase: "down",
pointerId: 7,
pointerType: "mouse",
position: { x: 12, y: 14 },
button: 0,
buttons: 1,
modifiers,
});
const inputDuringResize = [...worker.posted]
.reverse()
.find((message) => message.type === "view.input" && message.viewId === first.id)!;
const resizeAWire = worker.posted.find(
(message) => message.type === "view.viewport" && message.viewId === first.id,
)!;
worker.settleViewport(true);
await resizeA;
await new Promise((resolve) => setTimeout(resolve));
const coalescedWire = [...worker.posted]
.reverse()
.find((message) => message.type === "view.viewport" && message.viewId === first.id)!;
worker.settleViewport(false);
const coalescedSettlements = await Promise.allSettled([resizeB, resizeC]);
worker.holdViewports = false;
const resourceOpenRequestId = [...worker.posted]
.reverse()
.find((message) => message.type === "view.input" && message.viewId === first.id)
?.resourceOpenRequestId as string;
first.feedInput({
kind: "pointer",
phase: "up",
pointerId: 7,
pointerType: "mouse",
position: { x: 12, y: 14 },
button: 0,
buttons: 0,
modifiers,
});
worker.emit({
protocol: 3,
type: "view.resource.open",
viewId: first.id,
requestId: resourceOpenRequestId,
authorization: { viewId: first.id, token: "node:parameter:image", graphVersion: 0, compositionRevision: 0 },
resource: {
id: "image",
kind: "image",
title: "Image",
openTitle: "Open image",
accept: ["image/png"],
maxBytes: 1024,
maxWidth: 64,
maxHeight: 64,
maxPixels: 4096,
},
});
await new Promise((resolve) => setTimeout(resolve));
await first.setViewport({ width: 324, height: 180, dpr: 1 });
for (const view of [first, second])
view.feedInput({
kind: "pointer",
phase: "move",
pointerId: 1,
pointerType: "mouse",
position: { x: 10, y: 10 },
button: -1,
buttons: 0,
modifiers,
});
const beforeDispatch = worker.posted.length;
await api.dispatch({ type: "undo" });
const dispatchTrace = worker.posted.slice(beforeDispatch).map((message) => message.type);
const detachA = first.detach(),
detachB = first.detach(),
stableDetachPromise = detachA === detachB;
let detachedImmediately = false;
try {
first.getHostSnapshot();
} catch (error) {
detachedImmediately = error instanceof FxNodeViewDetachedError;
}
await detachA;
const replacement = await api.attachView({ canvas: firstCanvas, viewport });
await replacement.detach();
await second.detach();
api.destroy();
return {
rootMessages,
idsDiffer: first.id !== second.id,
duplicateCode,
selections,
resourceRequests,
dispatchTrace,
pointerLaneCount,
noopDidNotPost,
resizeMessagesBeforeA,
coalescedWidth: (coalescedWire.viewport as { width: number }).width,
coalescedSettlements: coalescedSettlements.map((item) => item.status),
resizeHostGeneration: resizeAWire.hostGeneration,
inputHostGeneration: inputDuringResize.hostGeneration,
stableDetachPromise,
detachedImmediately,
terminated: worker.terminated,
};
} finally {
Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: NativeWorker });
}
});
expect(result.rootMessages).toEqual(["init"]);
expect(result.idsDiffer).toBe(true);
expect(result.duplicateCode).toBe("canvas.in-use");
expect(result.selections).toEqual([1, 0]);
// The coalesced resize is posted after the pointer request and intentionally clears that pre-existing request.
expect(result.resourceRequests).toEqual([0, 0]);
expect(result.dispatchTrace.at(-1)).toBe("command");
expect(result.dispatchTrace.filter((type) => type === "view.pointer.flush")).toHaveLength(result.pointerLaneCount);
expect(result.noopDidNotPost).toBe(true);
expect(result.resizeMessagesBeforeA).toBe(1);
expect(result.coalescedWidth).toBe(323);
expect(result.coalescedSettlements).toEqual(["rejected", "rejected"]);
expect(result.inputHostGeneration).toBeGreaterThan(result.resizeHostGeneration as number);
expect(result.stableDetachPromise).toBe(true);
expect(result.detachedImmediately).toBe(true);
expect(result.terminated).toBe(true);
});
+8
View File
@@ -0,0 +1,8 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>FxNode client runtime test</title>
</head>
<body></body>
</html>
+277
View File
@@ -0,0 +1,277 @@
import { expect, test, type Page } from "@playwright/test";
type Point = Readonly<{ x: number; y: number }>;
type ControlPage = Page & { readonly __controlPageBrand?: never };
// Canvas is 1200x640 and the zoom-1 origin is its center (600,320).
// Layout rows are 24px high below a 24px header. Scalar numeric fields span
// nearly the full row; compound and non-numeric controls retain split layout.
const CONTROL_COORDS = Object.freeze({
value: { x: 196, y: 147 },
mathOperation: { x: 473, y: 147 },
mathClamp: { x: 473, y: 171 },
mathA: { x: 473, y: 195 },
mathB: { x: 473, y: 219 },
vectorOperation: { x: 853, y: 147 },
vectorAX: { x: 792, y: 171 },
vectorAY: { x: 853, y: 171 },
colorSwatch: { x: 1003, y: 147 },
groupString: { x: 623, y: 456 },
} satisfies Record<string, Point>);
const open = async (page: Page): Promise<ControlPage> => {
await page.goto("/examples/blender/control-test/");
await page.evaluate(() => window.controlTest.ready);
expect(await page.locator('input[type="file"]').count()).toBe(0);
await page.evaluate(() => {
window.controlEvents = { mutations: [], snapshots: [] };
window.controlTest.root!.onMutations((event) => window.controlEvents.mutations.push(event.version));
window.controlTest.root!.onSnapshots((event) => window.controlEvents.snapshots.push(event.version));
});
return page;
};
const state = (page: ControlPage) =>
page.evaluate(async () => ({
snapshot: await window.controlTest.root!.getState(),
layout: await window.controlTest.root!.save(),
events: window.controlEvents,
}));
const value = (layout: Awaited<ReturnType<typeof state>>["layout"], id: string, parameter: string) =>
layout.nodes.find((node) => node.id === id)!.parameters[parameter]!;
const socketValue = (layout: Awaited<ReturnType<typeof state>>["layout"], id: string, socket: string) =>
layout.nodes.find((node) => node.id === id)!.sockets.find((item) => item.id === socket)!.defaultValue!;
const invariant = (
before: Awaited<ReturnType<typeof state>>,
after: Awaited<ReturnType<typeof state>>,
commits: number,
) => {
expect(after.snapshot.version - before.snapshot.version).toBe(commits);
expect(after.events.mutations.slice(before.events.mutations.length)).toEqual(
after.events.snapshots.slice(before.events.snapshots.length),
);
expect(after.events.mutations.length - before.events.mutations.length).toBe(commits);
};
async function scrub(
page: ControlPage,
point: Point,
dx: number,
modifiers: { shift?: boolean; control?: boolean } = {},
) {
const canvas = page.locator("#controls"),
box = await canvas.boundingBox();
if (!box) throw new Error("Canvas bounds missing");
if (modifiers.shift) await page.keyboard.down("Shift");
if (modifiers.control) await page.keyboard.down("Control");
await page.mouse.move(box.x + point.x, box.y + point.y);
await page.mouse.down();
await page.mouse.move(box.x + point.x + dx / 2, box.y + point.y, { steps: 3 });
await page.mouse.move(box.x + point.x + dx, box.y + point.y, { steps: 3 });
return async () => {
await page.mouse.up();
if (modifiers.control) await page.keyboard.up("Control");
if (modifiers.shift) await page.keyboard.up("Shift");
};
}
test("number scrub is transient, atomic, cancellable, precise and snapping", async ({ page }) => {
const p = await open(page),
initial = await state(p),
release = await scrub(p, CONTROL_COORDS.value, 30);
const preview = await state(p);
expect(preview.snapshot.version).toBe(initial.snapshot.version);
expect(preview.events).toEqual(initial.events);
await release();
const normal = await state(p);
invariant(initial, normal, 1);
expect(value(normal.layout, "value", "value")).toEqual({ kind: "number", value: 3 });
const cancelStart = await state(p),
cancel = await scrub(p, CONTROL_COORDS.value, 30);
await p.locator("#controls").press("Escape");
await cancel();
invariant(cancelStart, await state(p), 0);
const shiftStart = await state(p),
shiftedRelease = await scrub(p, CONTROL_COORDS.value, 30, { shift: true });
await shiftedRelease();
const shifted = await state(p);
invariant(shiftStart, shifted, 1);
expect((value(shifted.layout, "value", "value") as { value: number }).value - 3).toBeLessThan(3);
const ctrlStart = await state(p),
ctrlRelease = await scrub(p, CONTROL_COORDS.value, 37, { control: true });
await ctrlRelease();
const snapped = await state(p);
invariant(ctrlStart, snapped, 1);
expect(Number.isInteger((value(snapped.layout, "value", "value") as { value: number }).value)).toBe(true);
});
test("numeric fields support typed assignment, cancellation, clamping and step arrows", async ({ page }) => {
const p = await open(page),
canvas = p.locator("#controls"),
a = await state(p);
await canvas.click({ position: CONTROL_COORDS.value });
await p.keyboard.type("0.375");
await p.keyboard.press("Enter");
const b = await state(p);
invariant(a, b, 1);
expect(value(b.layout, "value", "value")).toEqual({ kind: "number", value: 0.375 });
await canvas.click({ position: CONTROL_COORDS.value });
await p.keyboard.type("999");
await p.keyboard.press("Enter");
const clamped = await state(p);
invariant(b, clamped, 1);
expect(value(clamped.layout, "value", "value")).toEqual({ kind: "number", value: 999 });
await canvas.click({ position: CONTROL_COORDS.value });
await p.keyboard.type("bad");
await p.keyboard.press("Enter");
invariant(clamped, await state(p), 0);
await p.keyboard.press("Escape");
await canvas.click({ position: { x: 235, y: 147 } });
const stepped = await state(p);
invariant(clamped, stepped, 1);
expect(value(stepped.layout, "value", "value")).toEqual({ kind: "number", value: 1000 });
await canvas.click({ position: { x: 115, y: 147 } });
const decremented = await state(p);
invariant(stepped, decremented, 1);
expect(value(decremented.layout, "value", "value")).toEqual({ kind: "number", value: 999 });
});
test("vector component scrubs commit only the selected component", async ({ page }) => {
const p = await open(page),
a = await state(p),
r1 = await scrub(p, CONTROL_COORDS.vectorAY, 20);
await r1();
const b = await state(p);
invariant(a, b, 1);
expect(socketValue(b.layout, "vector", "vector:a")).toEqual({ kind: "vector", value: [0, 2, 0] });
});
test("color picker keeps RGBA, HSV and hex previews transient until confirmation", async ({ page }) => {
const p = await open(page),
canvas = p.locator("#controls"),
before = await state(p);
await canvas.click({ position: CONTROL_COORDS.colorSwatch });
const box = await canvas.boundingBox();
if (!box) throw new Error("Canvas bounds missing");
await page.mouse.move(box.x + 805, box.y + 245);
await page.mouse.down();
await page.mouse.move(box.x + 850, box.y + 245, { steps: 4 });
await page.mouse.up();
invariant(before, await state(p), 0);
for (const [field, text] of [
[{ x: 742, y: 378 }, "0.25"],
[{ x: 752, y: 408 }, "120"],
[{ x: 830, y: 439 }, "#336699CC"],
] as const) {
await canvas.click({ position: field });
await page.keyboard.type(text);
await page.keyboard.press("Enter");
invariant(before, await state(p), 0);
}
await canvas.click({ position: { x: 723, y: 163 } });
const committed = await state(p);
invariant(before, committed, 1);
expect(value(committed.layout, "color", "color")).toEqual({ kind: "color", value: [0.2, 0.4, 0.6, 0.8] });
await canvas.click({ position: CONTROL_COORDS.colorSwatch });
await canvas.click({ position: { x: 830, y: 439 } });
await page.keyboard.type("#FF0000");
await canvas.click({ position: { x: 20, y: 20 } });
const outside = await state(p);
invariant(committed, outside, 1);
expect(value(outside.layout, "color", "color")).toEqual({ kind: "color", value: [1, 0, 0, 1] });
await canvas.click({ position: CONTROL_COORDS.colorSwatch });
await page.mouse.move(box.x + 805, box.y + 245);
await page.mouse.down();
await page.mouse.move(box.x + 825, box.y + 220);
await page.mouse.up();
await canvas.press("Escape");
invariant(outside, await state(p), 0);
});
test("enum and boolean controls emit one mutation/snapshot pair", async ({ page }) => {
const p = await open(page),
canvas = p.locator("#controls"),
a = await state(p);
await canvas.click({ position: CONTROL_COORDS.mathOperation });
const b = await state(p);
invariant(a, b, 1);
expect(value(b.layout, "math", "operation")).toEqual({ kind: "string", value: "subtract" });
await canvas.press("ArrowDown");
const c = await state(p);
invariant(b, c, 1);
expect(value(c.layout, "math", "operation")).toEqual({ kind: "string", value: "multiply" });
await canvas.click({ position: CONTROL_COORDS.mathClamp });
const d = await state(p);
invariant(c, d, 1);
expect(value(d.layout, "math", "clamp")).toEqual({ kind: "boolean", value: true });
});
test("string editing commits on Enter and cancels on Escape or blur", async ({ page }) => {
const p = await open(page),
canvas = p.locator("#controls"),
a = await state(p);
await canvas.click({ position: CONTROL_COORDS.groupString });
await p.keyboard.type(" Name");
await p.keyboard.press("Enter");
const b = await state(p);
invariant(a, b, 1);
expect(value(b.layout, "group", "interfaceName")).toEqual({ kind: "string", value: "Socket Name" });
await canvas.click({ position: CONTROL_COORDS.groupString });
await p.keyboard.type(" bad");
await p.keyboard.press("Escape");
invariant(b, await state(p), 0);
await canvas.click({ position: CONTROL_COORDS.groupString });
await p.keyboard.type(" worse");
await p.locator("body").evaluate((body) => (body as HTMLElement).focus());
await canvas.evaluate((element) => (element as HTMLCanvasElement).blur());
invariant(b, await state(p), 0);
});
test("Backspace resets defaults, undo restores, and linked socket is inert", async ({ page }) => {
const p = await open(page),
canvas = p.locator("#controls"),
a = await state(p),
r = await scrub(p, CONTROL_COORDS.value, 30);
await r();
const changed = await state(p);
invariant(a, changed, 1);
await canvas.hover({ position: CONTROL_COORDS.value });
await canvas.press("Backspace");
const reset = await state(p);
invariant(changed, reset, 1);
expect(value(reset.layout, "value", "value")).toEqual({ kind: "number", value: 0 });
await p.evaluate(() => window.controlTest.root!.undo());
const undone = await state(p);
invariant(reset, undone, 1);
expect(value(undone.layout, "value", "value")).toEqual({ kind: "number", value: 3 });
await canvas.hover({ position: CONTROL_COORDS.mathA });
await canvas.press("Backspace");
invariant(undone, await state(p), 0);
});
test("muting a link reveals its default control and unmuting hides it", async ({ page }) => {
const p = await open(page),
canvas = p.locator("#controls"),
a = await state(p);
await p.evaluate(() => {
const id = window.controlTest.root!.save().then((layout) => layout.links[0]!.id);
return id.then((link) => window.controlTest.root!.dispatch({ type: "link.mute", id: link, value: true }));
});
await p.evaluate(() => window.controlTest.view!.whenRendered());
const muted = await state(p);
invariant(a, muted, 1);
expect(muted.layout.links[0]!.muted).toBe(true);
const r = await scrub(p, CONTROL_COORDS.mathA, 20);
await r();
const edited = await state(p);
invariant(muted, edited, 1);
expect(socketValue(edited.layout, "math", "math:a")).toEqual({ kind: "number", value: 2 });
await p.evaluate(() => {
const id = window.controlTest.root!.save().then((layout) => layout.links[0]!.id);
return id.then((link) => window.controlTest.root!.dispatch({ type: "link.mute", id: link, value: false }));
});
await p.evaluate(() => window.controlTest.view!.whenRendered());
const live = await state(p);
invariant(edited, live, 1);
await canvas.click({ position: CONTROL_COORDS.mathA });
invariant(live, await state(p), 0);
expect((await state(p)).layout.links[0]!.muted).toBe(false);
});
+10
View File
@@ -0,0 +1,10 @@
import { expect, test } from "@playwright/test";
for (const example of ["minimal", "color-balance", "live-composition", "multi-view"])
test(`${example} documentation image`, async ({ page }) => {
await page.goto(`/examples/${example}/`);
await page.evaluate(
(name) => (name === "multi-view" ? window.fxnodeMultiView.ready : window.fxnodeStandalone.ready),
example,
);
await expect(page).toHaveScreenshot(`${example}.png`, { animations: "disabled", fullPage: true });
});
+193
View File
@@ -0,0 +1,193 @@
import { expect, test, type Page } from "@playwright/test";
const examples = [
{ path: "minimal", nodeId: "value", typeId: "example.minimal.value" },
{ path: "color-balance", nodeId: "color-balance", typeId: "fxnode.compositor.color-balance" },
{ path: "live-composition", nodeId: "live-node", typeId: "example.live.parameter" },
] as const;
function capturePageErrors(page: Page): Error[] {
const errors: Error[] = [];
page.on("pageerror", (error) => errors.push(error));
return errors;
}
test("gallery links every standalone application and loads its images", async ({ page }) => {
await page.goto("/examples/");
await expect(page.locator(".gallery a")).toHaveCount(5);
expect(
await page.locator(".gallery a").evaluateAll((links) => links.map((link) => link.getAttribute("href"))),
).toEqual(["./minimal/", "./color-balance/", "./live-composition/", "./multi-view/", "./blender/"]);
await expect(page.locator(".gallery img")).toHaveCount(4);
expect(
await page
.locator(".gallery img")
.evaluateAll((images) =>
images.every((image) => image instanceof HTMLImageElement && image.complete && image.naturalWidth > 0),
),
).toBe(true);
});
test("multi-view keeps view input and selection local while graph changes fan out", async ({ page }) => {
await page.addInitScript(() => {
const original = EventTarget.prototype.addEventListener;
(window as unknown as { canvasListeners: Record<string, string[]> }).canvasListeners = {};
EventTarget.prototype.addEventListener = function (type, listener, options) {
if (this instanceof HTMLCanvasElement) {
const events = (window as unknown as { canvasListeners: Record<string, string[]> }).canvasListeners;
(events[this.id] ??= []).push(type);
}
return original.call(this, type, listener, options);
};
});
await page.goto("/examples/multi-view/");
await page.evaluate(() => window.fxnodeMultiView.ready);
expect(
await page.evaluate(() => (window as unknown as { canvasListeners: Record<string, string[]> }).canvasListeners),
).toEqual({
"view-a": [
"pointerdown",
"pointerdown",
"pointermove",
"pointerup",
"pointercancel",
"mousedown",
"wheel",
"keydown",
"keyup",
"focus",
"blur",
"contextmenu",
"lostpointercapture",
],
"view-b": [
"pointerdown",
"pointerdown",
"pointermove",
"pointerup",
"pointercancel",
"mousedown",
"wheel",
"keydown",
"keyup",
"focus",
"blur",
"contextmenu",
"lostpointercapture",
],
});
const baseline = await page.evaluate(async () => ({
renders: [...window.fxnodeMultiView.renderCounts],
ids: (await window.fxnodeMultiView.root!.getState()).nodes.map((node) => node.id),
}));
await page.getByRole("button", { name: "Add" }).click();
await expect
.poll(() => page.evaluate(() => window.fxnodeMultiView.root!.getState().then((state) => state.nodes.length)))
.toBe(baseline.ids.length + 1);
await page.locator("#view-b").dispatchEvent("pointerdown", {
pointerId: 2,
pointerType: "mouse",
clientX: 900,
clientY: 300,
button: 0,
buttons: 1,
});
await expect(page.locator("article").nth(1)).toHaveClass(/active/);
await page.getByRole("button", { name: "Add" }).click();
await expect
.poll(() => page.evaluate(() => window.fxnodeMultiView.root!.getState().then((state) => state.nodes.length)))
.toBe(baseline.ids.length + 2);
await expect
.poll(() =>
page.evaluate(
(counts) => window.fxnodeMultiView.renderCounts.every((value, i) => value > counts[i]!),
baseline.renders,
),
)
.toBe(true);
let result = await page.evaluate(async (baselineIds) => {
const state = await window.fxnodeMultiView.root!.getState();
const added = state.nodes.filter((node) => !baselineIds.includes(node.id));
return {
positions: added.map((node) => node.position),
selections: window.fxnodeMultiView.views.map((view) => view.getHostSnapshot().selection.nodeCount),
};
}, baseline.ids);
expect(result.positions.sort((a, b) => a.x - b.x)).toEqual([
{ x: 480, y: -550 },
{ x: 2080, y: -400 },
]);
expect(result.selections).toEqual([1, 1]);
await page.getByRole("button", { name: "Mute" }).click();
await expect(page.getByRole("button", { name: "Unmute" })).toHaveAttribute("aria-pressed", "true");
await page.getByRole("button", { name: "Delete" }).click();
await expect
.poll(() => page.evaluate(() => window.fxnodeMultiView.root!.getState().then((state) => state.nodes.length)))
.toBe(baseline.ids.length + 1);
result = await page.evaluate(
async (baselineIds) => ({
positions: (await window.fxnodeMultiView.root!.getState()).nodes
.filter((node) => !baselineIds.includes(node.id))
.map((node) => node.position),
selections: window.fxnodeMultiView.views.map((view) => view.getHostSnapshot().selection.nodeCount),
}),
baseline.ids,
);
expect(result.positions).toEqual([{ x: 480, y: -550 }]);
expect(result.selections).toEqual([1, 0]);
await page.evaluate(() => Promise.all([window.fxnodeMultiView.cleanup(), window.fxnodeMultiView.cleanup()]));
expect(
await page.evaluate(() => ({ root: window.fxnodeMultiView.root, views: window.fxnodeMultiView.views.length })),
).toEqual({ root: null, views: 0 });
});
for (const example of examples) {
test(`${example.path} renders its known node and cleans up on pagehide`, async ({ page }) => {
const errors = capturePageErrors(page);
await page.goto(`/examples/${example.path}/`);
await page.evaluate(() => window.fxnodeStandalone.ready);
const result = await page.evaluate(async ({ nodeId }) => {
const api = window.fxnodeStandalone.root;
if (!api) return null;
const node = (await api.getState()).nodes.find((candidate) => candidate.id === nodeId);
const canvas = document.querySelector<HTMLCanvasElement>("canvas")!;
const pixels = canvas.getContext("2d")!.getImageData(0, 0, canvas.width, canvas.height).data;
return {
node: node && { id: node.id, typeId: node.typeId, known: node.known },
nonEmpty: pixels.some((channel) => channel !== 0),
};
}, example);
expect(result).not.toBeNull();
expect(result?.node).toEqual({ id: example.nodeId, typeId: example.typeId, known: true });
expect(result?.nonEmpty).toBe(true);
await page.evaluate(() => window.dispatchEvent(new PageTransitionEvent("pagehide")));
expect(await page.evaluate(() => window.fxnodeStandalone.root)).toBeNull();
expect(errors).toEqual([]);
});
}
test("live composition migrates the real node and records the graph/history transition", async ({ page }) => {
const errors = capturePageErrors(page);
await page.goto("/examples/live-composition/");
await page.evaluate(() => window.fxnodeStandalone.ready);
const beforeVersion = await page.evaluate(() => window.fxnodeStandalone.graphVersion);
expect(beforeVersion).toBeDefined();
await page.getByRole("button", { name: "Compose version 2" }).click();
await expect(page.locator("#status")).toContainText("Version 2 committed");
const result = await page.evaluate(async () => ({
receipt: window.fxnodeStandalone.lastCompositionReceipt,
state: await window.fxnodeStandalone.root!.getState(),
}));
expect(result.receipt?.status).toBe("committed");
expect(result.receipt?.graphChanged).toBe(true);
expect(result.receipt?.historyReset).toBe(true);
expect(result.receipt?.graphVersion).toBe(beforeVersion! + 1);
const node = result.state.nodes.find((candidate) => candidate.id === "live-node");
expect(node?.typeId).toBe("example.live.parameter");
expect(node?.typeVersion).toBe(2);
expect(node?.parameters["detail"]).toEqual({ kind: "number", value: 0.5 });
expect(errors).toEqual([]);
});
+27
View File
@@ -0,0 +1,27 @@
import { createApplicationFxNode } from "../../examples/blender/application-browser.js";
import { prepareFxNodeBrowserHost } from "../../examples/shared/browser-host.js";
const initialLayout = { graphId: "browser", catalogVersion: 4, nodes: [], links: [], metadata: {} };
window.ready = (async () => {
const primary = document.querySelector<HTMLCanvasElement>("#primary");
const addNodeMenuTemplate = document.querySelector<HTMLTemplateElement>("#add-node-menu-template");
if (!primary || !addNodeMenuTemplate) throw new Error("Primary canvas or add-node menu template missing");
const host = prepareFxNodeBrowserHost({ canvas: primary, addNodeMenuTemplate });
let root: Awaited<ReturnType<typeof createApplicationFxNode>> | undefined,
view: Awaited<ReturnType<NonNullable<typeof root>["attachView"]>> | undefined;
try {
root = await createApplicationFxNode();
await root.setState(initialLayout);
view = await root.attachView({ canvas: primary, viewport: host.initialViewport });
host.attach(root, view);
window.root = root;
window.view = view;
window.fxnodeHost = host;
await view.whenRendered();
return true;
} catch (error) {
host.destroy();
await view?.detach();
root?.destroy();
throw error;
}
})();
+450
View File
@@ -0,0 +1,450 @@
import { test, expect } from "@playwright/test";
test("host snapshots are stable immutable projections with isolated subscriptions", async ({ page }) => {
await page.goto("/test/browser/index.html");
await page.evaluate(() => window.ready);
const result = await page.evaluate(async () => {
const api = window.root,
view = window.view,
first = view.getHostSnapshot(),
same = first === view.getHostSnapshot(),
frozen = Object.isFrozen(first) && Object.isFrozen(first.selection) && Object.isFrozen(first.selection.mute);
let good = 0,
bad = 0;
const offBad = view.subscribeHost(() => {
bad++;
throw new Error("subscriber");
}),
offGood = view.subscribeHost(() => good++);
await api.composeNode("phase-one", {
version: 1,
title: "Phase One",
behavior: "standard",
style: "shader",
parameters: {},
sockets: {},
ui: [],
muteBypass: [],
migrations: [],
});
const projected = view.getHostSnapshot();
offBad();
offBad();
offGood();
await api.removeNode("phase-one");
const detached = structuredClone(projected);
api.destroy();
let readable = true,
terminal = "";
try {
view.getHostSnapshot();
} catch (e) {
readable = false;
terminal = (e as Error).name;
}
return {
same,
frozen,
good,
bad,
firstRevision: first.compositionRevision,
revision: projected.compositionRevision,
detachedKeys: Object.keys(detached).sort(),
readable,
terminal,
};
});
expect(result).toEqual({
same: true,
frozen: true,
good: 1,
bad: 1,
firstRevision: 31,
revision: 32,
detachedKeys: ["colorPickerOpen", "compositionRevision", "selection"],
readable: false,
terminal: "FxNodeDestroyedError",
});
});
test("getState is detached and setState is atomic, ordered, and undoable", async ({ page }) => {
await page.goto("/examples/blender/");
await page.evaluate(() => window.fxnodeExample.ready);
const result = await page.evaluate(async () => {
const api = window.fxnodeExample.root!,
view = window.fxnodeExample.view!,
original = await api.getState(),
detached = structuredClone(original);
(detached.nodes as unknown[]).push({});
const events: string[] = [];
api.onMutations((event) => events.push(`mutation:${event.version}`));
api.onSnapshots((event) => events.push(`snapshot:${event.version}`));
const noop = await api.setState(original),
target = { ...original, graphId: "replacement" as typeof original.graphId },
changed = await api.setState(target, { expectedVersion: original.version }),
after = await api.getState(),
undo = await api.undo(),
undone = await api.getState();
api.destroy();
return {
originalVersion: original.version,
originalNodes: original.nodes.length,
detachedNodes: detached.nodes.length,
noop,
changed,
afterGraphId: after.graphId,
undo,
undoneGraphId: undone.graphId,
events,
};
});
const v = result.originalVersion;
expect(result.detachedNodes).toBe(result.originalNodes + 1);
expect(result.noop).toEqual({ status: "noop", version: v });
expect(result.changed).toEqual({ status: "committed", version: v + 1 });
expect(result.afterGraphId).toBe("replacement");
expect(result.undo).toEqual({ status: "committed", version: v + 2 });
expect(result.undoneGraphId).not.toBe("replacement");
expect(result.events).toEqual([`mutation:${v + 1}`, `snapshot:${v + 1}`, `mutation:${v + 2}`, `snapshot:${v + 2}`]);
});
test("explicit node and selection actions preserve worker authority and structured failures", async ({ page }) => {
await page.goto("/test/browser/index.html");
await page.evaluate(() => window.ready);
const result = await page.evaluate(async () => {
const api = window.root,
view = window.view,
initial = await api.getState();
let notifications = 0,
selection = JSON.stringify(view.getHostSnapshot().selection);
const off = view.subscribeHost(() => {
const next = JSON.stringify(view.getHostSnapshot().selection);
if (next !== selection) {
selection = next;
notifications++;
}
});
let stale = "",
unknown = "",
duplicate = "";
try {
await view.removeSelected({ expectedVersion: initial.version + 1 });
} catch (error) {
stale = (error as Error & { code?: string }).code ?? "";
}
try {
await view.addNode({ typeId: "missing", viewPosition: { x: 40, y: 40 }, nodeId: "unknown" });
} catch (error) {
unknown = (error as Error & { code?: string }).code ?? "";
}
const added = await view.addNode({
typeId: "fxnode.shader.value",
viewPosition: { x: 40, y: 40 },
nodeId: "supplied-id",
}),
selected = structuredClone(view.getHostSnapshot().selection);
try {
await view.addNode({ typeId: "fxnode.shader.value", viewPosition: { x: 80, y: 80 }, nodeId: "supplied-id" });
} catch (error) {
duplicate = (error as Error & { code?: string }).code ?? "";
}
const muted = await view.setSelectedMuted(true),
mutedSelection = structuredClone(view.getHostSnapshot().selection),
afterMute = await api.getState();
const removed = await view.removeSelected(),
afterRemove = await api.getState(),
empty = structuredClone(view.getHostSnapshot().selection);
off();
return {
stale,
unknown,
duplicate,
added,
selected,
muted,
mutedSelection,
node: afterMute.nodes.find((node) => node.id === "supplied-id"),
removed,
remaining: afterRemove.nodes.length,
empty,
notifications,
};
});
expect(result.stale).toBe("version.stale");
expect(result.unknown).toBe("node.type-unknown");
expect(result.duplicate).toBe("node.duplicate");
expect(result.added).toEqual({ status: "committed", version: 2 });
expect(result.selected).toEqual({
nodeCount: 1,
linkCount: 0,
canRemove: true,
mute: { enabled: true, state: "all-unmuted" },
});
expect(result.muted).toEqual({ status: "committed", version: 3 });
expect(result.node?.muted).toBe(true);
expect(result.mutedSelection.mute).toEqual({ enabled: true, state: "all-muted" });
expect(result.removed).toEqual({ status: "committed", version: 4 });
expect(result.remaining).toBe(0);
expect(result.empty).toEqual({ nodeCount: 0, linkCount: 0, canRemove: false, mute: { enabled: false } });
expect(result.notifications).toBe(3);
});
test("dedicated worker renders, commits FIFO, fans out and destroys", async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(HTMLCanvasElement.prototype, "transferControlToOffscreen", {
value: () => {
throw new Error("sabotaged");
},
});
Object.defineProperty(window, "OffscreenCanvas", { value: undefined });
});
await page.goto("/test/browser/index.html");
await page.evaluate(() => window.ready);
const result = await page.evaluate(async () => {
const api = window.root;
const trace: Array<
{ kind: "mutation"; baseVersion: number; version: number; cause: string } | { kind: "snapshot"; version: number }
> = [];
api.onMutations((event) =>
trace.push({ kind: "mutation", baseVersion: event.baseVersion, version: event.version, cause: event.cause }),
);
api.onSnapshots((event) => trace.push({ kind: "snapshot", version: event.version }));
const [a, b] = await Promise.all([
api.dispatch({ type: "node.add", nodeType: "fxnode.shader.value", position: { x: 0, y: 0 } }),
api.dispatch({ type: "node.add", nodeType: "fxnode.shader.value", position: { x: 1, y: 1 } }),
]);
const beforeStale = { trace: structuredClone(trace), snapshot: await api.getState() };
let staleError: { name: string; message: string; code?: string } | undefined;
try {
await api.dispatch({ type: "undo" }, { expectedVersion: 0 });
} catch (error) {
const value = error as Error & { code?: string };
staleError = { name: value.name, message: value.message, ...(value.code ? { code: value.code } : {}) };
}
const afterStale = { trace: structuredClone(trace), snapshot: await api.getState() };
const undo = await api.undo();
const redo = await api.redo();
const beforeQueries = trace.length;
const snap = await api.getState();
const saved = await api.save();
const queriesSilent = trace.length === beforeQueries;
const mirror = document.querySelector<HTMLCanvasElement>("#mirror");
const copy = document.querySelector<HTMLCanvasElement>("#copy");
const primary = document.querySelector<HTMLCanvasElement>("#primary");
if (!mirror || !copy || !primary) throw new Error("Canvas missing");
mirror.getContext("2d")!.drawImage(primary, 0, 0);
copy.getContext("2d")!.drawImage(primary, 0, 0);
const pixel = (canvas: HTMLCanvasElement) => Array.from(canvas.getContext("2d")!.getImageData(2, 2, 1, 1).data);
const pixels = [pixel(primary), pixel(mirror), pixel(copy)];
api.destroy();
let destroyed = false;
try {
await api.getState();
} catch {
destroyed = true;
}
return {
a,
b,
undo,
redo,
trace,
beforeStale,
afterStale,
staleError,
queriesSilent,
pixels,
catalog: saved.catalogVersion,
nodes: snap.nodes.length,
destroyed,
};
});
expect(result.a).toEqual({ status: "committed", version: 2 });
expect(result.b).toEqual({ status: "committed", version: 3 });
expect(result.beforeStale.trace).toEqual([
{ kind: "mutation", baseVersion: 1, version: 2, cause: "api" },
{ kind: "snapshot", version: 2 },
{ kind: "mutation", baseVersion: 2, version: 3, cause: "api" },
{ kind: "snapshot", version: 3 },
]);
expect(result.beforeStale.snapshot.version).toBe(3);
expect(result.staleError).toEqual({
name: "FxNodeWorkerError",
message: "Expected version does not match",
code: "version.stale",
});
expect(result.afterStale).toEqual(result.beforeStale);
expect(result.undo).toEqual({ status: "committed", version: 4 });
expect(result.redo).toEqual({ status: "committed", version: 5 });
expect(result.trace).toEqual([
...result.beforeStale.trace,
{ kind: "mutation", baseVersion: 3, version: 4, cause: "undo" },
{ kind: "snapshot", version: 4 },
{ kind: "mutation", baseVersion: 4, version: 5, cause: "redo" },
{ kind: "snapshot", version: 5 },
]);
expect(result.queriesSilent).toBe(true);
expect(result.catalog).toBe(4);
expect(result.nodes).toBe(2);
expect(result.destroyed).toBe(true);
expect(result.pixels[0]).toEqual(result.pixels[1]);
expect(result.pixels[0]).toEqual(result.pixels[2]);
expect(result.pixels[0]?.[3]).toBe(255);
});
test("steady-size frame presentation does not reallocate canvas backing stores", async ({ page }) => {
await page.addInitScript(() => {
const counts = { width: 0, height: 0 };
(window as unknown as { canvasAssignments: typeof counts }).canvasAssignments = counts;
for (const key of ["width", "height"] as const) {
const descriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, key)!;
Object.defineProperty(HTMLCanvasElement.prototype, key, {
...descriptor,
set(value: number) {
counts[key]++;
descriptor.set!.call(this, value);
},
});
}
});
await page.goto("/test/browser/index.html");
await page.evaluate(() => window.ready);
await page.evaluate(async () => {
const counts = (window as unknown as { canvasAssignments: { width: number; height: number } }).canvasAssignments;
counts.width = counts.height = 0;
for (let index = 0; index < 8; index++)
await window.root.dispatch({
type: "node.add",
nodeType: "fxnode.shader.value",
position: { x: index * 10, y: index * 10 },
});
await window.view.whenRendered();
});
expect(
await page.evaluate(
() => (window as unknown as { canvasAssignments: { width: number; height: number } }).canvasAssignments,
),
).toEqual({ width: 0, height: 0 });
});
test("structured command errors with paths reject only their RPC", async ({ page }) => {
await page.goto("/test/browser/index.html");
await page.evaluate(() => window.ready);
const result = await page.evaluate(async () => {
let error: { name?: string; code?: string; path?: string } = {};
try {
await window.root.dispatch({
type: "link.add",
link: {
id: "stale",
fromNodeId: "missing-a",
fromSocketId: "missing-a:out",
toNodeId: "missing-b",
toSocketId: "missing-b:in",
muted: false,
extensions: {},
},
} as any);
} catch (value) {
const e = value as typeof error;
error = {
...(e.name === undefined ? {} : { name: e.name }),
...(e.code === undefined ? {} : { code: e.code }),
...(e.path === undefined ? {} : { path: e.path }),
};
}
const snapshot = await window.root.getState();
window.root.destroy();
return { error, version: snapshot.version };
});
expect(result).toEqual({
error: { name: "FxNodeWorkerError", code: "link.endpoint", path: "/links/stale" },
version: 1,
});
});
test("malformed and incoherent state receipts terminate the client", async ({ browser }) => {
const installCorruptor = () => {
const NativeWorker = Worker;
let corruptType = "";
class CorruptingWorker extends NativeWorker {
private requestId = "";
constructor(url: string | URL, options?: WorkerOptions) {
super(url, options);
super.addEventListener("message", (event) => {
const message = event.data;
if (!this.requestId || message?.type !== "response" || message.id !== this.requestId) return;
event.stopImmediatePropagation();
const type = corruptType,
id = this.requestId;
this.requestId = "";
queueMicrotask(() =>
this.dispatchEvent(
new MessageEvent("message", {
data: {
protocol: 2,
type: "response",
id,
ok: true,
value: type === "state.set" ? null : { status: "noop", version: 2 },
},
}),
),
);
});
}
override postMessage(message: unknown, options?: StructuredSerializeOptions | Transferable[]) {
if ((message as { type?: string }).type === corruptType) this.requestId = (message as { id: string }).id;
super.postMessage(message, options as StructuredSerializeOptions);
}
}
Object.defineProperty(window, "corruptReceiptType", { set: (value: string) => (corruptType = value) });
Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: CorruptingWorker });
};
const statePage = await browser.newPage();
await statePage.addInitScript(installCorruptor);
await statePage.goto("/test/browser/index.html");
await statePage.evaluate(() => window.ready);
const malformed = await statePage.evaluate(async () => {
const state = await window.root.getState();
(window as unknown as { corruptReceiptType: string }).corruptReceiptType = "state.set";
let first = "",
terminal = "";
try {
await window.root.setState(state, { expectedVersion: state.version });
} catch (error) {
first = (error as Error).name;
}
try {
await window.root.getState();
} catch (error) {
terminal = (error as Error).name;
}
return { first, terminal };
});
await statePage.close();
const loadPage = await browser.newPage();
await loadPage.addInitScript(installCorruptor);
await loadPage.goto("/test/browser/index.html");
await loadPage.evaluate(() => window.ready);
const incoherent = await loadPage.evaluate(async () => {
const state = await window.root.getState(),
data = await window.root.getSaveData();
(window as unknown as { corruptReceiptType: string }).corruptReceiptType = "load";
let first = "",
terminal = "";
try {
await window.root.load(data, { expectedVersion: state.version });
} catch (error) {
first = (error as Error).name;
}
try {
await window.root.getState();
} catch (error) {
terminal = (error as Error).name;
}
return { first, terminal };
});
await loadPage.close();
expect(malformed).toEqual({ first: "FxNodeProtocolError", terminal: "FxNodeProtocolError" });
expect(incoherent).toEqual({ first: "FxNodeProtocolError", terminal: "FxNodeProtocolError" });
});
+28
View File
@@ -0,0 +1,28 @@
import type { FxNode, FxNodeView } from "@lib/index.js";
import type { PreparedFxNodeBrowserHost } from "../../examples/shared/browser-host.js";
declare global {
interface Window {
root: FxNode;
view: FxNodeView;
fxnodeHost: PreparedFxNodeBrowserHost;
ready: Promise<boolean>;
fxnodeExample: FxNodeExampleHandle;
parityExample: { root: FxNode; view: FxNodeView };
controlTest: { root: FxNode | null; view: FxNodeView | null; ready: Promise<void> };
linkToolsTest: { root: FxNode | null; view: FxNodeView | null; ready: Promise<void> };
controlEvents: { mutations: number[]; snapshots: number[] };
}
interface FxNodeExampleHandle {
root: FxNode | null;
view: FxNodeView | null;
ready: Promise<void>;
readonly rendered: Promise<void>;
}
interface FxNodeEvidenceCounters {
mutations: number;
snapshots: number;
}
}
export {};
+315
View File
@@ -0,0 +1,315 @@
<!doctype html>
<script type="importmap">
{ "imports": { "@lib/": "/src/" } }
</script>
<canvas id="primary" tabindex="0" style="width: 320px; height: 180px"></canvas><canvas id="mirror"></canvas
><canvas id="copy"></canvas>
<template id="add-node-menu-template">
<div data-fxnode-add-menu style="position: fixed; z-index: 2147483647; visibility: hidden">
<style>
[data-fxnode-add-menu] * {
box-sizing: border-box;
}
[data-fxnode-add-menu] [hidden] {
display: none !important;
}
.fxnode-add-dialog {
width: 286px;
max-height: min(520px, calc(100vh - 16px));
display: flex;
flex-direction: column;
padding: 8px;
background: #25282d;
color: #e5e5e5;
border: 1px solid #111216;
border-radius: 7px;
box-shadow: 0 12px 32px #000a;
font:
12px/1.3 system-ui,
sans-serif;
}
.fxnode-add-title {
font-weight: 650;
margin: 1px 2px 7px;
}
.fxnode-add-search {
width: 100%;
border: 1px solid #111216;
border-radius: 4px;
background: #181a1e;
color: #fff;
padding: 7px 8px;
outline: none;
}
.fxnode-add-search:focus {
border-color: #ed5700;
}
.fxnode-add-results {
overflow: auto;
margin-top: 7px;
min-height: 40px;
}
.fxnode-add-group {
margin: 4px 0 7px;
}
.fxnode-add-heading {
padding: 3px 7px;
color: #a5a8ad;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
}
.fxnode-add-option {
display: block;
width: 100%;
padding: 6px 8px;
border: 0;
border-radius: 4px;
background: transparent;
color: #e5e5e5;
text-align: left;
font: inherit;
cursor: default;
}
.fxnode-add-option:hover,
.fxnode-add-option.active {
background: #ed5700;
color: #fff;
}
.fxnode-add-empty {
padding: 14px 8px;
color: #a5a8ad;
text-align: center;
}
</style>
<section class="fxnode-add-dialog" role="dialog" aria-label="Add node">
<div class="fxnode-add-title">Add Node</div>
<input
data-fxnode-menu-search
class="fxnode-add-search"
type="search"
role="combobox"
aria-label="Search nodes"
aria-autocomplete="list"
aria-controls="fxnode-node-results"
autocomplete="off"
placeholder="Search…"
/>
<div data-fxnode-menu-results class="fxnode-add-results" id="fxnode-node-results" role="listbox">
<section data-fxnode-menu-group class="fxnode-add-group" role="group" aria-label="Common">
<div data-fxnode-menu-heading class="fxnode-add-heading">Common</div>
<button
data-fxnode-menu-option
data-type-id="fxnode.common.frame"
class="fxnode-add-option"
type="button"
role="option"
>
Frame
</button>
<button
data-fxnode-menu-option
data-type-id="fxnode.common.reroute"
class="fxnode-add-option"
type="button"
role="option"
>
Reroute
</button>
<button
data-fxnode-menu-option
data-type-id="fxnode.common.group-input"
class="fxnode-add-option"
type="button"
role="option"
>
Group Input
</button>
<button
data-fxnode-menu-option
data-type-id="fxnode.common.group-output"
class="fxnode-add-option"
type="button"
role="option"
>
Group Output
</button>
</section>
<section data-fxnode-menu-group class="fxnode-add-group" role="group" aria-label="Shader">
<div data-fxnode-menu-heading class="fxnode-add-heading">Shader</div>
<button
data-fxnode-menu-option
data-type-id="fxnode.shader.value"
class="fxnode-add-option"
type="button"
role="option"
>
Value
</button>
<button
data-fxnode-menu-option
data-type-id="fxnode.shader.color"
class="fxnode-add-option"
type="button"
role="option"
>
Color
</button>
<button
data-fxnode-menu-option
data-type-id="fxnode.shader.math"
class="fxnode-add-option"
type="button"
role="option"
>
Math
</button>
<button
data-fxnode-menu-option
data-type-id="fxnode.shader.vector-math"
class="fxnode-add-option"
type="button"
role="option"
>
Vector Math
</button>
<button
data-fxnode-menu-option
data-type-id="fxnode.shader.mix"
class="fxnode-add-option"
type="button"
role="option"
>
Mix
</button>
<button
data-fxnode-menu-option
data-type-id="fxnode.shader.color-ramp"
class="fxnode-add-option"
type="button"
role="option"
>
Color Ramp
</button>
<button
data-fxnode-menu-option
data-type-id="fxnode.shader.texture-coordinate"
class="fxnode-add-option"
type="button"
role="option"
>
Texture Coordinate
</button>
<button
data-fxnode-menu-option
data-type-id="fxnode.shader.noise-texture"
class="fxnode-add-option"
type="button"
role="option"
>
Noise Texture
</button>
<button
data-fxnode-menu-option
data-type-id="fxnode.shader.image-texture"
class="fxnode-add-option"
type="button"
role="option"
>
Image Texture
</button>
<button
data-fxnode-menu-option
data-type-id="fxnode.shader.principled-bsdf"
class="fxnode-add-option"
type="button"
role="option"
>
Principled BSDF
</button>
<button
data-fxnode-menu-option
data-type-id="fxnode.shader.material-output"
class="fxnode-add-option"
type="button"
role="option"
>
Material Output
</button>
</section>
<section data-fxnode-menu-group class="fxnode-add-group" role="group" aria-label="Geometry">
<div data-fxnode-menu-heading class="fxnode-add-heading">Geometry</div>
<button
data-fxnode-menu-option
data-type-id="fxnode.geometry.position"
class="fxnode-add-option"
type="button"
role="option"
>
Position
</button>
<button
data-fxnode-menu-option
data-type-id="fxnode.geometry.mesh-cube"
class="fxnode-add-option"
type="button"
role="option"
>
Mesh Cube
</button>
<button
data-fxnode-menu-option
data-type-id="fxnode.geometry.set-position"
class="fxnode-add-option"
type="button"
role="option"
>
Set Position
</button>
<button
data-fxnode-menu-option
data-type-id="fxnode.geometry.transform-geometry"
class="fxnode-add-option"
type="button"
role="option"
>
Transform Geometry
</button>
<button
data-fxnode-menu-option
data-type-id="fxnode.geometry.join-geometry"
class="fxnode-add-option"
type="button"
role="option"
>
Join Geometry
</button>
</section>
<section data-fxnode-menu-group class="fxnode-add-group" role="group" aria-label="Compositor">
<div data-fxnode-menu-heading class="fxnode-add-heading">Compositor</div>
<button
data-fxnode-menu-option
data-type-id="fxnode.compositor.image"
class="fxnode-add-option"
type="button"
role="option"
>
Image
</button>
<button
data-fxnode-menu-option
data-type-id="fxnode.compositor.color-balance"
class="fxnode-add-option"
type="button"
role="option"
>
Color Balance
</button>
</section>
<div data-fxnode-menu-empty class="fxnode-add-empty" role="status" hidden>No nodes found</div>
</div>
</section>
</div>
</template>
<script type="module" src="./fixture.ts"></script>
+507
View File
@@ -0,0 +1,507 @@
import { expect, test } from "@playwright/test";
test("direct input preserves modifiers, SAB ordering, and right-button menu rules", async ({ page }) => {
await page.addInitScript(() => {
const Native = Worker;
const messages: unknown[] = [];
let lane: SharedArrayBuffer | undefined;
class Spy extends Native {
override postMessage(message: unknown, options?: Transferable[] | StructuredSerializeOptions) {
const value = message as { type?: string; pointerLane?: SharedArrayBuffer };
if (value.type === "view.attach") lane = value.pointerLane;
if (["view.input", "view.pointer.flush", "view.viewport", "view.render"].includes(value.type ?? ""))
messages.push(message);
super.postMessage(message, options as StructuredSerializeOptions);
}
}
Object.defineProperties(window, { phase1Messages: { value: messages }, phase1Lane: { get: () => lane } });
Object.defineProperty(window, "Worker", { value: Spy });
});
await page.goto("/examples/blender/");
await page.evaluate(() => window.fxnodeExample.ready);
const result = await page.evaluate(async () => {
const api = window.fxnodeExample.root!,
view = window.fxnodeExample.view!,
w = window as unknown as { phase1Messages: any[]; phase1Lane?: SharedArrayBuffer };
w.phase1Messages.length = 0;
const mods0 = { alt: false, control: false, meta: false, shift: false },
mods15 = { alt: true, control: true, meta: true, shift: true };
const before = api.getState();
view.feedInput({
kind: "pointer",
phase: "down",
pointerId: 9,
pointerType: "mouse",
position: { x: 380, y: 160 },
button: 0,
buttons: 1,
modifiers: mods0,
});
view.feedInput({
kind: "pointer",
phase: "move",
pointerId: 9,
pointerType: "mouse",
position: { x: 410, y: 180 },
button: 0,
buttons: 1,
modifiers: mods15,
});
view.feedInput({
kind: "pointer",
phase: "up",
pointerId: 9,
pointerType: "mouse",
position: { x: 410, y: 180 },
button: 0,
buttons: 0,
modifiers: mods0,
});
await view.whenRendered();
const after = await api.getState();
const invalidBefore = w.phase1Messages.length,
laneBefore = w.phase1Lane ? Array.from(new Int32Array(w.phase1Lane)) : [];
let invalid = "";
try {
view.feedInput({ kind: "wheel", position: { x: 0, y: 0 }, delta: { x: NaN, y: 0 }, modifiers: mods0 });
} catch (e) {
invalid = (e as Error).name;
}
const invalidAfter = w.phase1Messages.length,
laneAfter = w.phase1Lane ? Array.from(new Int32Array(w.phase1Lane)) : [];
w.phase1Messages.length = 0;
view.feedInput({
kind: "pointer",
phase: "move",
pointerId: 10,
pointerType: "mouse",
position: { x: -3, y: -4 },
button: 0,
buttons: 0,
modifiers: mods15,
});
await view.setViewport({ width: 301, height: 179, dpr: 1 });
const ordered = structuredClone(w.phase1Messages);
w.phase1Messages.length = 0;
const rendered = view.whenRendered();
await rendered;
const renderMessage = w.phase1Messages.find((x) => x.type === "view.render");
w.phase1Messages.length = 0;
view.feedInput({
kind: "pointer",
phase: "down",
pointerId: 11,
pointerType: "mouse",
position: { x: 1, y: 1 },
button: 2,
buttons: 3,
modifiers: mods0,
});
const rmb = structuredClone(w.phase1Messages.at(-1));
return {
before: await before,
after,
invalid,
invalidBefore,
invalidAfter,
laneBefore,
laneAfter,
ordered,
renderedWithDedicatedMessage: !!renderMessage,
rmb,
};
});
expect(result.after.nodes.find((n) => n.id === "math")?.position).toEqual({ x: -270, y: 150 });
const inputs = result.ordered.length;
expect(result.invalid).toBe("TypeError");
expect(result.invalidAfter).toBe(result.invalidBefore);
expect(result.laneAfter).toEqual(result.laneBefore);
expect(result.ordered.map((x: any) => x.type)).toEqual(["view.viewport"]);
expect(result.ordered[0].pointerFence.before.event.modifiers).toBe(15);
expect(result.ordered[0].viewport).toEqual({ width: 301, height: 179, dpr: 1 });
expect(result.renderedWithDedicatedMessage).toBe(true);
const sentMasks = (result as any).rmb.event.modifiers;
expect(sentMasks).toBe(0);
expect((result as any).rmb.nodeMenuRequestId).toBeUndefined();
expect(inputs).toBe(1);
});
test("direct input invalidates a pending add-menu request", async ({ page }) => {
await page.goto("/examples/blender/");
await page.evaluate(() => window.fxnodeExample.ready);
await page.evaluate(() => {
const api = window.fxnodeExample.root!,
view = window.fxnodeExample.view!,
modifiers = { alt: false, control: false, meta: false, shift: false };
view.feedInput({
kind: "pointer",
phase: "down",
pointerId: 31,
pointerType: "mouse",
position: { x: 20, y: 20 },
button: 2,
buttons: 2,
modifiers,
});
view.feedInput({ kind: "wheel", position: { x: 20, y: 20 }, delta: { x: 0, y: 1 }, modifiers });
});
await page.waitForTimeout(100);
expect(await page.locator("[data-fxnode-add-menu]").count()).toBe(0);
});
test("synchronous input and viewport postMessage failures throw", async ({ page }) => {
await page.addInitScript(() => {
const Native = Worker;
let fail = "";
class Spy extends Native {
override postMessage(message: unknown, options?: Transferable[] | StructuredSerializeOptions) {
if ((message as any)?.type === fail) throw new DOMException("blocked", "DataCloneError");
super.postMessage(message, options as StructuredSerializeOptions);
}
}
Object.defineProperty(window, "failWorkerPost", { set: (v: string) => (fail = v) });
Object.defineProperty(window, "Worker", { value: Spy });
});
await page.goto("/test/browser/index.html");
await page.evaluate(() => window.ready);
expect(
await page.evaluate(() => {
(window as any).failWorkerPost = "view.input";
try {
window.view.feedInput({ kind: "focus", phase: "focus" });
return "none";
} catch (e) {
return (e as Error).name;
}
}),
).toBe("FxNodeProtocolError");
await page.reload();
await page.evaluate(() => window.ready);
expect(
await page.evaluate(async () => {
(window as any).failWorkerPost = "view.viewport";
try {
await window.view.setViewport({ width: 10, height: 10, dpr: 1 });
return "none";
} catch (e) {
return (e as Error).name;
}
}),
).toBe("FxNodeProtocolError");
});
test("dedicated actions and resource transfers embed transactional SAB fences", async ({ page }) => {
await page.addInitScript(() => {
const Native = Worker,
messages: unknown[] = [],
resourceTransfers: boolean[] = [];
let lane: SharedArrayBuffer | undefined,
fail = "";
class Spy extends Native {
override postMessage(message: unknown, options?: Transferable[] | StructuredSerializeOptions) {
const value = message as { type?: string; pointerLane?: SharedArrayBuffer; resource?: { bytes?: ArrayBuffer } };
if (value.type === "view.attach") lane = value.pointerLane;
if (
["view.node.add", "view.selection.mute", "view.selection.remove", "view.resource.set"].includes(
value.type ?? "",
)
)
messages.push(structuredClone(message));
if (value.type === "view.resource.set")
resourceTransfers.push(Array.isArray(options) && options[0] === value.resource?.bytes);
if (value.type === fail) throw new DOMException("blocked", "DataCloneError");
super.postMessage(message, options as StructuredSerializeOptions);
}
}
Object.defineProperties(window, {
actionMessages: { value: messages },
actionLane: { get: () => lane },
resourceTransfers: { value: resourceTransfers },
failActionPost: { set: (value: string) => (fail = value) },
});
Object.defineProperty(window, "Worker", { value: Spy });
});
await page.goto("/test/browser/index.html");
await page.evaluate(() => window.ready);
const result = await page.evaluate(async () => {
const api = window.root,
view = window.view,
w = window as any,
modifiers = { alt: false, control: false, meta: false, shift: false },
move = (x: number) =>
view.feedInput({
kind: "pointer",
phase: "move",
pointerId: 7,
pointerType: "mouse",
position: { x, y: x + 1 },
button: 0,
buttons: 0,
modifiers,
});
move(1);
await view.addNode({ typeId: "fxnode.shader.value", viewPosition: { x: 40, y: 40 }, nodeId: "fenced" });
move(2);
await view.setSelectedMuted(true);
move(3);
await view.removeSelected();
move(4);
const transferred = new ArrayBuffer(1);
let stale = "";
try {
await view.provideResource(
{ token: "missing", viewId: view.id, graphVersion: 4, compositionRevision: 30 },
{ name: "x.png", mime: "image/png", bytes: transferred },
);
} catch (error) {
stale = (error as any).code;
}
const laneBefore = Array.from(new Int32Array(w.actionLane));
move(5);
const beforeFailure = Array.from(new Int32Array(w.actionLane)),
failedBuffer = new ArrayBuffer(1);
w.failActionPost = "view.resource.set";
let failure = "";
try {
await view.provideResource(
{ token: "missing", viewId: view.id, graphVersion: 4, compositionRevision: 30 },
{ name: "x.png", mime: "image/png", bytes: failedBuffer },
);
} catch (error) {
failure = (error as Error).name;
}
const sent = structuredClone(w.actionMessages),
afterFailure = Array.from(new Int32Array(w.actionLane));
return {
sent,
laneBefore,
beforeFailure,
afterFailure,
failure,
stale,
detached: transferred.byteLength,
failedBytes: failedBuffer.byteLength,
transfers: w.resourceTransfers,
};
});
expect(result.sent.map((message: any) => message.type)).toEqual([
"view.node.add",
"view.selection.mute",
"view.selection.remove",
"view.resource.set",
"view.resource.set",
]);
const generations = result.sent.map((message: any) => message.pointerFence.generation),
base = generations[0];
expect(generations).toEqual([base, base + 1, base + 2, base + 3, base + 4]);
expect(result.sent.map((message: any) => message.pointerFence.before.event.position.x)).toEqual([1, 2, 3, 4, 5]);
expect(result.laneBefore[1]).toBe(base + 3);
expect(result.beforeFailure[1]).toBe(base + 3);
expect(result.afterFailure[1]).toBe(base + 3);
expect(result.failure).toBe("FxNodeProtocolError");
expect(result.stale).toBe("resource.stale");
expect(result.detached).toBe(0);
expect(result.failedBytes).toBe(1);
expect(result.transfers).toEqual([true, true]);
});
test("selection publication does not wait for frame consumption", async ({ page }) => {
await page.addInitScript(() => {
const Native = Worker;
let block = false;
class Spy extends Native {
override postMessage(message: unknown, options?: Transferable[] | StructuredSerializeOptions) {
if ((message as any)?.type === "view.frame.consumed" && block) return;
super.postMessage(message, options as StructuredSerializeOptions);
}
}
Object.defineProperty(window, "blockFrameConsumption", { set: (value: boolean) => (block = value) });
Object.defineProperty(window, "Worker", { value: Spy });
});
await page.goto("/test/browser/index.html");
await page.evaluate(() => window.ready);
const result = await page.evaluate(async () => {
(window as any).blockFrameConsumption = true;
const api = window.root,
view = window.view,
before = view.getHostSnapshot();
const receipt = await view.addNode({
typeId: "fxnode.shader.value",
viewPosition: { x: 40, y: 40 },
nodeId: "blocked-frame",
}),
after = view.getHostSnapshot();
return { receipt, changed: before !== after, selection: structuredClone(after.selection) };
});
expect(result).toEqual({
receipt: { status: "committed", version: 2 },
changed: true,
selection: { nodeCount: 1, linkCount: 0, canRemove: true, mute: { enabled: true, state: "all-unmuted" } },
});
});
test("worker gestures stay transient and commit exactly one paired event", async ({ page }) => {
await page.addInitScript(() => {
const NativeWorker = window.Worker;
let moves = 0;
class TrackedWorker extends NativeWorker {
override postMessage(message: unknown, transfer: Transferable[]): void;
override postMessage(message: unknown, options?: StructuredSerializeOptions): void;
override postMessage(message: unknown, transferOrOptions?: Transferable[] | StructuredSerializeOptions): void {
if (
typeof message === "object" &&
message !== null &&
(message as { type?: unknown }).type === "view.input" &&
(message as { event?: { kind?: unknown; phase?: unknown } }).event?.kind === "pointer" &&
(message as { event?: { phase?: unknown } }).event?.phase === "move"
)
moves++;
if (Array.isArray(transferOrOptions)) super.postMessage(message, transferOrOptions);
else super.postMessage(message, transferOrOptions);
}
}
Object.defineProperty(window, "Worker", { value: TrackedWorker });
Object.defineProperty(window, "pointerMoveMessages", { get: () => moves });
});
await page.goto("/examples/blender/");
await page.evaluate(() => window.fxnodeExample.ready);
expect(await page.evaluate(() => crossOriginIsolated)).toBe(true);
await page.evaluate(() => {
const h = window.fxnodeExample;
const w = window as typeof window & { gestureEvents: { m: number; s: number } };
w.gestureEvents = { m: 0, s: 0 };
h.root!.onMutations(() => w.gestureEvents.m++);
h.root!.onSnapshots(() => w.gestureEvents.s++);
});
const canvas = page.locator("#graph"),
snapshot = () => page.evaluate(() => window.fxnodeExample.root!.getState());
const original = await snapshot();
// Math header is deterministic: world (-300,170), view origin (600,320).
await canvas.click({ position: { x: 380, y: 160 } });
expect((await snapshot()).version).toBe(original.version);
expect(
await page.evaluate(() => (window as typeof window & { gestureEvents: { m: number; s: number } }).gestureEvents),
).toEqual({ m: 0, s: 0 });
const bounds = await canvas.boundingBox();
if (!bounds) throw new Error("canvas bounds missing");
await canvas.hover({ position: { x: 380, y: 160 } });
await page.mouse.down();
await page.mouse.move(bounds.x + 410, bounds.y + 180, { steps: 4 });
expect((await snapshot()).nodes.find((n) => n.id === "math")?.position).toEqual({ x: -300, y: 170 });
await page.mouse.up();
expect((await snapshot()).nodes.find((n) => n.id === "math")?.position).toEqual({ x: -270, y: 150 });
expect(
await page.evaluate(() => (window as typeof window & { gestureEvents: { m: number; s: number } }).gestureEvents),
).toEqual({ m: 1, s: 1 });
expect(
await page.evaluate(() => (window as typeof window & { pointerMoveMessages: number }).pointerMoveMessages),
).toBe(0);
// G and ordinary drag cancel without mutation; RMB is suppressed by the canvas.
await canvas.press("g");
await page.mouse.move(bounds.x + 390, bounds.y + 210);
await canvas.press("Escape");
expect((await snapshot()).version).toBe(original.version + 1);
await canvas.hover({ position: { x: 390, y: 180 } });
await page.mouse.down();
await page.mouse.move(bounds.x + 430, bounds.y + 220);
await canvas.press("Escape");
await page.mouse.up();
await canvas.click({ position: { x: 30, y: 30 }, button: "right" });
expect((await snapshot()).version).toBe(original.version + 1);
// Box, MMB, wheel and Home are view/selection-only.
await canvas.hover({ position: { x: 20, y: 20 } });
await page.mouse.down();
await page.mouse.move(bounds.x + 250, bounds.y + 300);
await page.mouse.up();
await canvas.hover({ position: { x: 30, y: 30 } });
await page.mouse.down({ button: "middle" });
await page.mouse.move(bounds.x + 40, bounds.y + 40);
await page.mouse.up({ button: "middle" });
await page.mouse.wheel(0, 30);
await canvas.press("Home");
expect((await snapshot()).version).toBe(original.version + 1);
});
test("non-isolated hosts retain the ordered pointer message fallback", async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(window, "crossOriginIsolated", { value: false });
const NativeWorker = window.Worker;
let moves = 0;
class TrackedWorker extends NativeWorker {
override postMessage(message: unknown, transfer: Transferable[]): void;
override postMessage(message: unknown, options?: StructuredSerializeOptions): void;
override postMessage(message: unknown, transferOrOptions?: Transferable[] | StructuredSerializeOptions): void {
if (
typeof message === "object" &&
message !== null &&
(message as { type?: unknown }).type === "view.input" &&
(message as { event?: { kind?: unknown; phase?: unknown } }).event?.kind === "pointer" &&
(message as { event?: { phase?: unknown } }).event?.phase === "move"
)
moves++;
if (Array.isArray(transferOrOptions)) super.postMessage(message, transferOrOptions);
else super.postMessage(message, transferOrOptions);
}
}
Object.defineProperty(window, "Worker", { value: TrackedWorker });
Object.defineProperty(window, "pointerMoveMessages", { get: () => moves });
});
await page.goto("/examples/blender/");
await page.evaluate(() => window.fxnodeExample.ready);
expect(await page.evaluate(() => crossOriginIsolated)).toBe(false);
const canvas = page.locator("#graph"),
bounds = await canvas.boundingBox();
if (!bounds) throw new Error("canvas bounds missing");
await canvas.hover({ position: { x: 380, y: 160 } });
await page.mouse.down();
await page.mouse.move(bounds.x + 410, bounds.y + 180, { steps: 4 });
await page.mouse.up();
expect(
(await page.evaluate(() => window.fxnodeExample.root!.getState())).nodes.find((node) => node.id === "math")
?.position,
).toEqual({ x: -270, y: 150 });
expect(
await page.evaluate(() => (window as typeof window & { pointerMoveMessages: number }).pointerMoveMessages),
).toBeGreaterThan(0);
});
test("wheel input visibly zooms around the pointer without changing graph state", async ({ page }) => {
await page.addInitScript(() => {
const NativeWorker = window.Worker;
let wheels = 0;
class TrackedWorker extends NativeWorker {
override postMessage(message: unknown, transfer: Transferable[]): void;
override postMessage(message: unknown, options?: StructuredSerializeOptions): void;
override postMessage(message: unknown, transferOrOptions?: Transferable[] | StructuredSerializeOptions): void {
if (
typeof message === "object" &&
message !== null &&
(message as { type?: unknown }).type === "view.input" &&
(message as { event?: { kind?: unknown } }).event?.kind === "wheel"
)
wheels++;
if (Array.isArray(transferOrOptions)) super.postMessage(message, transferOrOptions);
else super.postMessage(message, transferOrOptions);
}
}
Object.defineProperty(window, "Worker", { value: TrackedWorker });
Object.defineProperty(window, "wheelMessages", { get: () => wheels });
});
await page.goto("/examples/blender/");
await page.evaluate(() => window.fxnodeExample.ready);
const canvas = page.locator("#graph"),
before = await canvas.screenshot(),
version = (await page.evaluate(() => window.fxnodeExample.root!.getState())).version;
await canvas.hover({ position: { x: 400, y: 240 } });
await page.mouse.wheel(0, -240);
await expect
.poll(() => page.evaluate(() => (window as typeof window & { wheelMessages: number }).wheelMessages))
.toBe(1);
await page.evaluate(() => window.fxnodeExample.view!.whenRendered());
const zoomed = await canvas.screenshot();
expect(zoomed.equals(before)).toBe(false);
expect((await page.evaluate(() => window.fxnodeExample.root!.getState())).version).toBe(version);
await page.mouse.wheel(0, 240);
await page.evaluate(() => window.fxnodeExample.view!.whenRendered());
expect((await canvas.screenshot()).equals(zoomed)).toBe(false);
});
+222
View File
@@ -0,0 +1,222 @@
import { expect, test, type Page } from "@playwright/test";
const MAP = {
knifeX: 600,
parallelTop: 90,
parallelBottom: 420,
chainKnifeX: 430,
chainTop: 390,
chainBottom: 485,
mathAHeader: { x: 850, y: 90 },
transformHeader: { x: 440, y: 560 },
noiseHeader: { x: 850, y: 560 },
} as const;
async function open(page: Page) {
await page.goto("/examples/blender/link-tools-test/");
await page.evaluate(() => window.linkToolsTest.ready);
await page.evaluate(() => {
const events = { mutations: [] as number[], snapshots: [] as number[] };
Object.assign(window, { linkToolEvents: events });
window.linkToolsTest.root!.onMutations((e) => events.mutations.push(e.version));
window.linkToolsTest.root!.onSnapshots((e) => events.snapshots.push(e.version));
});
return page.locator("#link-tools");
}
async function publicState(page: Page) {
return page.evaluate(async () => ({
save: await window.linkToolsTest.root!.save(),
snapshot: await window.linkToolsTest.root!.getState(),
events: structuredClone(
(window as typeof window & { linkToolEvents: { mutations: number[]; snapshots: number[] } }).linkToolEvents,
),
}));
}
async function knife(
page: Page,
canvas: ReturnType<Page["locator"]>,
from: { x: number; y: number },
to: { x: number; y: number },
alt = false,
release = true,
) {
await canvas.hover({ position: from });
await page.keyboard.down("Control");
if (alt) await page.keyboard.down("Alt");
await page.mouse.down({ button: "right" });
const box = await canvas.boundingBox();
if (!box) throw new Error("canvas bounds missing");
await page.mouse.move(box.x + to.x, box.y + to.y, { steps: 12 });
if (release) await page.mouse.up({ button: "right" });
if (alt) await page.keyboard.up("Alt");
await page.keyboard.up("Control");
}
test("Ctrl-RMB knife is transient then removes every crossed link atomically", async ({ page }) => {
const canvas = await open(page),
before = await publicState(page);
await knife(
page,
canvas,
{ x: MAP.knifeX, y: MAP.parallelTop },
{ x: MAP.knifeX, y: MAP.parallelBottom },
false,
false,
);
const during = await publicState(page);
expect(during).toEqual(before);
// The actual canvas must contain the white knife/highlight, not merely worker state.
expect(
await page.evaluate(
({ x, top, bottom }) => {
const c = document.querySelector<HTMLCanvasElement>("#link-tools")!,
d = c.getContext("2d")!.getImageData(x - 3, top, 7, bottom - top).data;
let n = 0;
for (let i = 0; i < d.length; i += 4) if (d[i]! > 220 && d[i + 1]! > 220 && d[i + 2]! > 220) n++;
return n;
},
{ x: MAP.knifeX, top: MAP.parallelTop, bottom: MAP.parallelBottom },
),
).toBeGreaterThan(150);
await page.mouse.up({ button: "right" });
await page.keyboard.up("Control");
const removed = await publicState(page);
expect(removed.save.links.map((l) => l.id).sort()).toEqual(["chain-in", "chain-out"]);
expect(removed.snapshot.version).toBe(before.snapshot.version + 1);
expect(removed.events).toEqual({
mutations: [before.snapshot.version + 1],
snapshots: [before.snapshot.version + 1],
});
await page.keyboard.press("Control+z");
const undone = await publicState(page);
expect(undone.save.links).toEqual(before.save.links);
expect(undone.snapshot.version).toBe(before.snapshot.version + 2);
});
test("Escape and pointer cancellation discard knife and context menu stays suppressed", async ({ page }) => {
const canvas = await open(page);
await page.evaluate(() => {
(window as any).unblockedMenus = 0;
document.addEventListener("contextmenu", (event) => {
if (!event.defaultPrevented) (window as any).unblockedMenus++;
});
});
await knife(page, canvas, { x: 600, y: 55 }, { x: 600, y: 390 }, false, false);
await page.keyboard.press("Escape");
await page.mouse.up({ button: "right" });
await page.keyboard.up("Control");
expect((await publicState(page)).events).toEqual({ mutations: [], snapshots: [] });
await knife(page, canvas, { x: 600, y: 55 }, { x: 600, y: 390 }, false, false);
await canvas.dispatchEvent("pointercancel", {
pointerId: 1,
pointerType: "mouse",
button: 2,
buttons: 0,
clientX: 600,
clientY: 390,
});
await page.mouse.up({ button: "right" });
await page.keyboard.up("Control");
expect((await publicState(page)).save.links).toHaveLength(5);
expect(await page.evaluate(() => (window as any).unblockedMenus)).toBe(0);
});
test("Ctrl-Alt-RMB mutes a link, restores its default editor, and undo is atomic", async ({ page }) => {
const canvas = await open(page),
before = await publicState(page);
await knife(page, canvas, { x: 600, y: 100 }, { x: 600, y: 205 }, true);
const muted = await publicState(page),
link = muted.save.links.find((l) => l.id === "parallel-a");
expect(link?.muted).toBe(true);
expect(muted.events).toEqual({ mutations: [before.snapshot.version + 1], snapshots: [before.snapshot.version + 1] });
// Red pixels on the formerly grey link and a clickable default-value row prove rendering/hit behavior.
expect(
await page.evaluate(() => {
const c = document.querySelector<HTMLCanvasElement>("#link-tools")!,
d = c.getContext("2d")!.getImageData(500, 50, 200, 130).data;
let n = 0;
for (let i = 0; i < d.length; i += 4) if (d[i]! > 150 && d[i + 1]! < 110) n++;
return n;
}),
).toBeGreaterThan(5);
await canvas.click({ position: { x: 850, y: 125 } });
expect((await publicState(page)).snapshot.version).toBe(before.snapshot.version + 1);
await knife(page, canvas, { x: 600, y: 100 }, { x: 600, y: 205 }, true);
expect((await publicState(page)).save.links.find((l) => l.id === "parallel-a")?.muted).toBe(false);
await page.keyboard.press("Control+z");
expect((await publicState(page)).save.links.find((l) => l.id === "parallel-a")?.muted).toBe(true);
});
test("reroute effective mute is red without changing downstream authored flags", async ({ page }) => {
const canvas = await open(page);
await knife(page, canvas, { x: MAP.chainKnifeX, y: MAP.chainTop }, { x: MAP.chainKnifeX, y: MAP.chainBottom }, true);
const state = await publicState(page);
expect(state.save.links.find((l) => l.id === "chain-in")?.muted).toBe(true);
expect(state.save.links.find((l) => l.id === "chain-out")?.muted).toBe(false);
await page.evaluate(() => window.linkToolsTest.view!.whenRendered());
expect(
await page.evaluate(() => {
const c = document.querySelector<HTMLCanvasElement>("#link-tools")!,
d = c.getContext("2d")!.getImageData(590, 420, 250, 100).data;
let n = 0;
for (let i = 0; i < d.length; i += 4) if (d[i]! > 150 && d[i + 1]! < 110) n++;
return n;
}),
).toBeGreaterThan(5);
});
test("M mutes operators with bypasses and visibly grays generators", async ({ page }) => {
const canvas = await open(page);
for (const [id, point] of [
["math-a", MAP.mathAHeader],
["transform", MAP.transformHeader],
] as const) {
await canvas.click({ position: point });
const prior = await publicState(page),
before = prior.snapshot.version;
await canvas.press("m");
const muted = await publicState(page);
expect(muted.save.nodes.find((n) => n.id === id)?.muted).toBe(true);
expect(muted.snapshot.version).toBe(before + 1);
expect(muted.events.mutations).toHaveLength(prior.events.mutations.length + 1);
expect(muted.events.snapshots).toHaveLength(prior.events.snapshots.length + 1);
await page.evaluate(() => window.linkToolsTest.view!.whenRendered());
expect(
await page.evaluate(({ x, y }) => {
const c = document.querySelector<HTMLCanvasElement>("#link-tools")!,
d = c.getContext("2d")!.getImageData(x - 100, y, 200, 100).data;
let n = 0;
for (let i = 0; i < d.length; i += 4) if (d[i]! > 150 && d[i + 1]! < 110) n++;
return n;
}, point),
).toBeGreaterThan(5);
await page.keyboard.press("Control+z");
expect((await publicState(page)).save.nodes.find((n) => n.id === id)?.muted).toBe(false);
}
await canvas.click({ position: MAP.noiseHeader });
const before = await publicState(page),
beforeLight = await page.evaluate(() => {
const c = document.querySelector<HTMLCanvasElement>("#link-tools")!,
d = c.getContext("2d")!.getImageData(780, 550, 165, 85).data;
let n = 0;
for (let i = 0; i < d.length; i += 4) n += d[i]! + d[i + 1]! + d[i + 2]!;
return n;
});
await canvas.press("m");
await page.evaluate(() => window.linkToolsTest.view!.whenRendered());
const muted = await publicState(page),
afterLight = await page.evaluate(() => {
const c = document.querySelector<HTMLCanvasElement>("#link-tools")!,
d = c.getContext("2d")!.getImageData(780, 550, 165, 85).data;
let n = 0;
for (let i = 0; i < d.length; i += 4) n += d[i]! + d[i + 1]! + d[i + 2]!;
return n;
});
expect(muted.save.nodes.find((n) => n.id === "noise")?.muted).toBe(true);
expect(muted.snapshot.version).toBe(before.snapshot.version + 1);
expect(afterLight).toBeLessThan(beforeLight * 0.8);
await page.keyboard.press("Control+z");
expect((await publicState(page)).save.nodes.find((n) => n.id === "noise")?.muted).toBe(false);
});
+490
View File
@@ -0,0 +1,490 @@
import { expect, test } from "@playwright/test";
import type { GraphState } from "@lib/core/types.js";
test("browser host disconnect lifecycle is opt-in and preserves same-task reparenting", async ({ page }) => {
await page.goto("/test/browser/index.html");
const result = await page.evaluate(async () => {
const { prepareFxNodeBrowserHost } = await import("../../examples/shared/browser-host.js");
const makeApi = () => {
let detaches = 0;
const api = {
feedInput() {},
setViewport() {},
detach() {
detaches++;
return Promise.resolve();
},
getHostSnapshot: () => ({ colorPickerOpen: false }),
onHostRequests: () => () => {},
onCompositionChanges: () => () => {},
onMutations: () => () => {},
} as any;
return { api, count: () => detaches };
};
const explicitCanvas = document.createElement("canvas"),
automaticCanvas = document.createElement("canvas"),
replacementParent = document.createElement("div");
document.body.append(explicitCanvas, automaticCanvas, replacementParent);
const explicitApi = makeApi(),
automaticApi = makeApi(),
explicitHost = prepareFxNodeBrowserHost({ canvas: explicitCanvas }),
automaticHost = prepareFxNodeBrowserHost({
canvas: automaticCanvas,
lifecycle: "detach-on-disconnect",
});
explicitHost.attach(explicitApi.api, explicitApi.api);
automaticHost.attach(automaticApi.api, automaticApi.api);
explicitCanvas.remove();
replacementParent.append(automaticCanvas);
await new Promise((resolve) => setTimeout(resolve));
const afterReparent = automaticApi.count();
automaticCanvas.remove();
await new Promise((resolve) => setTimeout(resolve));
const resultValue = {
explicit: explicitApi.count(),
afterReparent,
afterRemoval: automaticApi.count(),
};
explicitHost.destroy();
automaticHost.destroy();
replacementParent.remove();
return resultValue;
});
expect(result).toEqual({ explicit: 0, afterReparent: 0, afterRemoval: 1 });
});
test("core lifecycle owns no DOM state and the host adapter cleans up its policy", async ({ page }) => {
test.setTimeout(45_000);
await page.goto("/test/browser/index.html");
await page.evaluate(() => window.ready);
const result = await page.evaluate(async () => {
window.fxnodeHost.destroy();
window.root.destroy();
const { createFxNode } = await import("@lib/index.js");
const { prepareFxNodeBrowserHost } = await import("../../examples/shared/browser-host.js");
const { APPLICATION_ID, APPLICATION_RESOURCES, APPLICATION_VERSION, APPLICATION_HEADER_STYLES } = await import(
"../../examples/blender/nodes/application.js"
);
const { exampleTheme } = await import("../../examples/shared/theme.js");
const { anySocket, floatSocket } = await import("../../examples/blender/nodes/socket-types.js");
const { valueNode } = await import("../../examples/blender/nodes/shader/value.js");
const { applicationCompatibility } = await import("../../examples/blender/nodes/application.js");
const layout = { schemaVersion: 1, graphId: "lifecycle", catalogVersion: 7, nodes: [], links: [], metadata: {} };
const applicationOptions = {
applicationId: APPLICATION_ID,
applicationVersion: APPLICATION_VERSION,
resources: APPLICATION_RESOURCES,
};
const state = {
graphId: layout.graphId as GraphState["graphId"],
catalogVersion: APPLICATION_VERSION,
nodes: [],
links: [],
metadata: {},
};
const canvas = document.querySelector<HTMLCanvasElement>("#primary")!;
canvas.setAttribute("tabindex", "7");
canvas.style.touchAction = "pan-x";
const width = canvas.width,
height = canvas.height;
const installValue = async (api: Awaited<ReturnType<typeof createFxNode>>) => {
await api.setTheme(exampleTheme);
await api.setHeaderStyles(APPLICATION_HEADER_STYLES);
await api.composeSocket(...anySocket);
await api.composeSocket(...floatSocket);
await api.setCompatibility(applicationCompatibility);
await api.composeNode(...valueNode);
};
let later = 0;
for (let index = 0; index < 20; index++) {
const api = await createFxNode(applicationOptions);
if (index === 0) await installValue(api);
await api.setState(state, { expectedVersion: 0 });
if (index === 0) {
api.onMutations(() => {
throw new Error("intentional subscriber failure");
});
api.onMutations(() => later++);
await api.dispatch({ type: "node.add", nodeType: "fxnode.shader.value", position: { x: 0, y: 0 } });
}
const view = await api.attachView({ canvas, viewport: { width: 1200, height: 640, dpr: 1 } });
const barrier = view.whenRendered();
await view.detach();
api.destroy();
await barrier.catch((error) => error);
}
const host = prepareFxNodeBrowserHost({ canvas }),
api = await createFxNode(applicationOptions),
view = await api.attachView({ canvas, viewport: host.initialViewport });
await api.setState(state, { expectedVersion: 0 });
host.attach(api, view);
host.destroy();
api.destroy();
return {
later,
tabIndex: canvas.getAttribute("tabindex"),
touchAction: canvas.style.touchAction,
widthUnchanged: canvas.width === width,
heightUnchanged: canvas.height === height,
};
});
expect(result).toEqual({
later: 1,
tabIndex: "7",
touchAction: "pan-x",
widthUnchanged: true,
heightUnchanged: true,
});
});
test("bad load retains structured issues and returned values are detached", async ({ page }) => {
await page.goto("/test/browser/index.html");
await page.evaluate(() => window.ready);
const result = await page.evaluate(async () => {
let error: { code?: string; issues?: readonly unknown[] } = {};
try {
await window.root.load({ nope: true });
} catch (value) {
error = value as typeof error;
}
const saved = await window.root.save();
(saved.nodes as unknown as unknown[]).push({});
const snapshot = await window.root.getState();
(snapshot.nodes as unknown as unknown[]).push({});
const nextSaved = await window.root.save();
const nextSnapshot = await window.root.getState();
window.fxnodeHost.destroy();
window.root.destroy();
return {
code: error.code,
issues: error.issues?.length ?? 0,
saved: nextSaved.nodes.length,
snapshot: nextSnapshot.nodes.length,
};
});
expect(result.code).toBeTruthy();
expect(result.issues).toBeGreaterThan(0);
expect(result.saved).toBe(0);
expect(result.snapshot).toBe(0);
});
test("direct client never touches DOM lifecycle APIs", async ({ page }) => {
await page.goto("/test/browser/index.html");
await page.evaluate(() => window.ready);
const result = await page.evaluate(async () => {
window.fxnodeHost.destroy();
window.root.destroy();
const { createFxNode } = await import("@lib/index.js"),
{ APPLICATION_ID, APPLICATION_RESOURCES, APPLICATION_VERSION } = await import(
"../../examples/blender/nodes/application.js"
),
canvas = document.querySelector<HTMLCanvasElement>("#primary")!,
NativeObserver = ResizeObserver;
const before = {
tabindex: canvas.getAttribute("tabindex"),
touchAction: canvas.style.touchAction,
width: canvas.width,
height: canvas.height,
children: document.body.childElementCount,
};
class ThrowingObserver {
constructor() {
throw new Error("ResizeObserver constructed");
}
}
Object.defineProperty(window, "ResizeObserver", { configurable: true, writable: true, value: ThrowingObserver });
const original = {
add: canvas.addEventListener,
rect: canvas.getBoundingClientRect,
focus: canvas.focus,
capture: canvas.setPointerCapture,
};
canvas.addEventListener = (() => {
throw new Error("listener registered");
}) as typeof canvas.addEventListener;
canvas.getBoundingClientRect = (() => {
throw new Error("layout read");
}) as typeof canvas.getBoundingClientRect;
canvas.focus = (() => {
throw new Error("focused");
}) as typeof canvas.focus;
canvas.setPointerCapture = (() => {
throw new Error("captured");
}) as typeof canvas.setPointerCapture;
try {
const version = APPLICATION_VERSION,
api = await createFxNode({
applicationId: APPLICATION_ID,
applicationVersion: APPLICATION_VERSION,
resources: APPLICATION_RESOURCES,
});
await api.setState(
{
graphId: "direct-dom-boundary" as GraphState["graphId"],
catalogVersion: version,
nodes: [],
links: [],
metadata: {},
},
{ expectedVersion: 0 },
);
api.destroy();
} finally {
canvas.addEventListener = original.add;
canvas.getBoundingClientRect = original.rect;
canvas.focus = original.focus;
canvas.setPointerCapture = original.capture;
Object.defineProperty(window, "ResizeObserver", { configurable: true, writable: true, value: NativeObserver });
}
return {
before,
after: {
tabindex: canvas.getAttribute("tabindex"),
touchAction: canvas.style.touchAction,
width: canvas.width,
height: canvas.height,
children: document.body.childElementCount,
},
};
});
expect(result.after).toEqual(result.before);
});
test("browser host is exclusive and preserves application DOM changes on cleanup", async ({ page }) => {
await page.goto("/test/browser/index.html");
await page.evaluate(() => window.ready);
const result = await page.evaluate(async () => {
window.fxnodeHost.destroy();
window.root.destroy();
const { createFxNode } = await import("@lib/index.js"),
{ prepareFxNodeBrowserHost } = await import("../../examples/shared/browser-host.js"),
{ APPLICATION_ID, APPLICATION_RESOURCES, APPLICATION_VERSION } = await import(
"../../examples/blender/nodes/application.js"
),
canvas = document.querySelector<HTMLCanvasElement>("#primary")!;
canvas.removeAttribute("tabindex");
canvas.style.touchAction = "";
const host = prepareFxNodeBrowserHost({ canvas });
let duplicate = "";
try {
prepareFxNodeBrowserHost({ canvas });
} catch (error) {
duplicate = (error as Error).message;
}
const version = APPLICATION_VERSION,
api = await createFxNode({
applicationId: APPLICATION_ID,
applicationVersion: APPLICATION_VERSION,
resources: APPLICATION_RESOURCES,
});
await api.setState(
{
graphId: "host-lifecycle" as GraphState["graphId"],
catalogVersion: version,
nodes: [],
links: [],
metadata: {},
},
{ expectedVersion: 0 },
);
const view = await api.attachView({ canvas, viewport: host.initialViewport });
host.attach(api, view);
canvas.setAttribute("tabindex", "9");
canvas.style.touchAction = "pan-y";
host.destroy();
api.destroy();
const replacement = prepareFxNodeBrowserHost({ canvas });
replacement.destroy();
return { duplicate, tabindex: canvas.getAttribute("tabindex"), touchAction: canvas.style.touchAction };
});
expect(result).toEqual({
duplicate: "Canvas already has an active FxNode browser host",
tabindex: "9",
touchAction: "pan-y",
});
});
test("worker resource requests close delayed add-menu UI without intercepting input", async ({ page }) => {
await page.goto("/test/browser/index.html");
await page.evaluate(() => window.ready);
const result = await page.evaluate(async () => {
const { prepareFxNodeBrowserHost } = await import("../../examples/shared/browser-host.js"),
canvas = document.createElement("canvas");
canvas.style.cssText = "width:200px;height:120px";
document.body.append(canvas);
let request: ((value: any) => void) | undefined,
pickers = 0,
forwarded = 0;
const target = Object.freeze({
kind: "resource-open" as const,
authorization: Object.freeze({ token: "resource", graphVersion: 0, compositionRevision: 0 }),
resource: Object.freeze({
id: "image",
kind: "image" as const,
title: "Image",
openTitle: "Open",
accept: Object.freeze(["image/png"]),
maxBytes: 100,
maxWidth: 10,
maxHeight: 10,
maxPixels: 100,
}),
}),
snapshot = Object.freeze({
compositionRevision: 0,
colorPickerOpen: false,
selection: Object.freeze({
nodeCount: 0,
linkCount: 0,
canRemove: false,
mute: Object.freeze({ enabled: false as const }),
}),
});
const api = {
feedInput(value: any) {
if (value.kind === "pointer" && value.phase === "down" && value.button === 0) forwarded++;
},
setViewport() {},
getHostSnapshot: () => snapshot,
onHostRequests(callback: (value: any) => void) {
request = callback;
return () => {};
},
onCompositionChanges() {
return () => {};
},
onMutations() {
return () => {};
},
addNode() {
return Promise.resolve({ status: "noop", version: 0 });
},
} as any,
host = prepareFxNodeBrowserHost({
canvas,
activateResourcePicker: () => {
pickers++;
},
});
host.attach(api, api);
const rect = canvas.getBoundingClientRect(),
fire = (pointerId: number, button: number, x: number) => {
canvas.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
pointerId,
pointerType: "mouse",
button,
buttons: button === 2 ? 2 : 1,
clientX: rect.left + x,
clientY: rect.top + 10,
}),
);
};
fire(81, 2, 80);
fire(82, 0, 10);
request?.(target);
request?.({
kind: "add-node-menu",
viewPosition: { x: 80, y: 10 },
compositionRevision: 0,
});
const menus = document.querySelectorAll("[data-fxnode-add-menu]").length;
host.destroy();
canvas.remove();
return { pickers, menus, forwarded };
});
expect(result).toEqual({ pickers: 1, menus: 0, forwarded: 1 });
});
test("default resource picker ignores obsolete asynchronous reads", async ({ page }) => {
await page.goto("/test/browser/index.html");
await page.evaluate(() => window.ready);
const result = await page.evaluate(async () => {
const { prepareFxNodeBrowserHost } = await import("../../examples/shared/browser-host.js"),
canvas = document.createElement("canvas");
canvas.style.cssText = "width:100px;height:100px";
document.body.append(canvas);
const target = Object.freeze({
kind: "resource-open" as const,
authorization: Object.freeze({ token: "resource", graphVersion: 0, compositionRevision: 0 }),
resource: Object.freeze({
id: "image",
kind: "image" as const,
title: "Image",
openTitle: "Open",
accept: Object.freeze(["image/png"]),
maxBytes: 100,
maxWidth: 10,
maxHeight: 10,
maxPixels: 100,
}),
}),
snapshot = Object.freeze({
compositionRevision: 0,
colorPickerOpen: false,
selection: Object.freeze({
nodeCount: 0,
linkCount: 0,
canRemove: false,
mute: Object.freeze({ enabled: false as const }),
}),
});
let resolveA!: (value: ArrayBuffer) => void, resolveB!: (value: ArrayBuffer) => void;
const a = new File([new Uint8Array([1])], "a.png", { type: "image/png" }),
b = new File([new Uint8Array([2])], "b.png", { type: "image/png" });
Object.defineProperty(a, "arrayBuffer", {
value: () => new Promise<ArrayBuffer>((resolve) => (resolveA = resolve)),
});
Object.defineProperty(b, "arrayBuffer", {
value: () => new Promise<ArrayBuffer>((resolve) => (resolveB = resolve)),
});
let request: ((value: any) => void) | undefined;
const provided: string[] = [],
api = {
feedInput() {},
setViewport() {},
getHostSnapshot: () => snapshot,
onHostRequests(callback: (value: any) => void) {
request = callback;
return () => {};
},
onCompositionChanges() {
return () => {};
},
onMutations() {
return () => {};
},
addNode() {
return Promise.resolve({ status: "noop", version: 0 });
},
provideResource(_authorization: unknown, data: { name: string }) {
provided.push(data.name);
return Promise.resolve({ status: "committed", version: provided.length });
},
} as any,
host = prepareFxNodeBrowserHost({ canvas });
host.attach(api, api);
const choose = (file: File) => {
request?.(target);
const input = document.querySelector<HTMLInputElement>("[data-fxnode-resource-file]")!;
Object.defineProperty(input, "files", {
configurable: true,
value: { 0: file, length: 1, item: (index: number) => (index === 0 ? file : null) },
});
input.dispatchEvent(new Event("change"));
};
choose(a);
choose(b);
resolveB(new Uint8Array([2]).buffer);
await Promise.resolve();
await Promise.resolve();
resolveA(new Uint8Array([1]).buffer);
await Promise.resolve();
await Promise.resolve();
host.destroy();
canvas.remove();
return provided;
});
expect(result).toEqual(["b.png"]);
});
+177
View File
@@ -0,0 +1,177 @@
import { expect, test } from "@playwright/test";
test("M muting is one paired gesture for bypass nodes and generators", async ({ page }) => {
await page.goto("/examples/blender/");
await page.evaluate(() => window.fxnodeExample.ready);
const canvas = page.locator("#graph");
await page.evaluate(() => {
const api = window.fxnodeExample.root!,
view = window.fxnodeExample.view!;
const state = { mutations: [] as number[], snapshots: [] as number[] };
(window as typeof window & { muteEvents: typeof state }).muteEvents = state;
// Subscriber exceptions are deliberately isolated from subsequent listeners.
api.onMutations(() => {
throw new Error("intentional test subscriber");
});
api.onMutations((event) => state.mutations.push(event.version));
api.onSnapshots((event) => state.snapshots.push(event.version));
});
const baseline = (await page.evaluate(() => window.fxnodeExample.root!.getState())).version;
// Fixture coordinates use the documented 1200x640 viewport and world origin
// (600,320). Header centers are stable layout data, not worker introspection.
await canvas.click({ position: { x: 380, y: 160 } }); // Math (-300,170), width 160
await canvas.press("m");
let snapshot = await page.evaluate(() => window.fxnodeExample.root!.getState());
expect(snapshot.nodes.find((node) => node.id === "math")?.muted).toBe(true);
expect(
await page.evaluate(
() => (window as typeof window & { muteEvents: { mutations: number[]; snapshots: number[] } }).muteEvents,
),
).toEqual({ mutations: [baseline + 1], snapshots: [baseline + 1] });
await canvas.press("m");
snapshot = await page.evaluate(() => window.fxnodeExample.root!.getState());
expect(snapshot.nodes.find((node) => node.id === "math")?.muted).toBe(false);
expect(
await page.evaluate(
() => (window as typeof window & { muteEvents: { mutations: number[]; snapshots: number[] } }).muteEvents,
),
).toEqual({ mutations: [baseline + 1, baseline + 2], snapshots: [baseline + 1, baseline + 2] });
await canvas.click({ position: { x: 580, y: 140 } }); // Noise generator (-100,190)
await canvas.press("m");
snapshot = await page.evaluate(() => window.fxnodeExample.root!.getState());
expect(snapshot.nodes.find((node) => node.id === "noise")?.muted).toBe(true);
expect(snapshot.version).toBe(baseline + 3);
expect(
await page.evaluate(
() => (window as typeof window & { muteEvents: { mutations: number[]; snapshots: number[] } }).muteEvents,
),
).toEqual({
mutations: [baseline + 1, baseline + 2, baseline + 3],
snapshots: [baseline + 1, baseline + 2, baseline + 3],
});
await canvas.press("Control+z");
snapshot = await page.evaluate(() => window.fxnodeExample.root!.getState());
expect(snapshot.nodes.find((node) => node.id === "noise")?.muted).toBe(false);
expect(snapshot.nodes.find((node) => node.id === "math")?.muted).toBe(false);
});
test("equivalent selection summaries retain identity and API mute is one-step undoable", async ({ page }) => {
await page.goto("/examples/blender/");
await page.evaluate(() => window.fxnodeExample.ready);
const canvas = page.locator("#graph");
expect(
await page.evaluate(async () => {
const api = window.fxnodeExample.root!,
view = window.fxnodeExample.view!;
await view.addNode({ typeId: "fxnode.shader.value", viewPosition: { x: 900, y: 500 }, nodeId: "summary-a" });
const first = view.getHostSnapshot().selection;
await view.addNode({ typeId: "fxnode.shader.value", viewPosition: { x: 1100, y: 500 }, nodeId: "summary-b" });
return view.getHostSnapshot().selection === first;
}),
).toBe(true);
await page.evaluate(() =>
window.fxnodeExample.root!.dispatch({ type: "node.mute", id: "summary-b" as never, value: true }),
);
expect((await page.evaluate(() => window.fxnodeExample.view!.getHostSnapshot())).selection.mute).toEqual({
enabled: true,
state: "all-muted",
});
const result = await page.evaluate(async () => {
const api = window.fxnodeExample.root!,
view = window.fxnodeExample.view!,
order: string[] = [];
api.onMutations((event) => order.push(`mutation:${event.version}`));
api.onSnapshots((event) => order.push(`snapshot:${event.version}`));
const receipt = await view.setSelectedMuted(false);
order.push(`receipt:${receipt.version}`);
const unmuted = structuredClone(view.getHostSnapshot().selection.mute),
undo = await api.undo(),
muted = structuredClone(view.getHostSnapshot().selection.mute),
redo = await api.redo(),
again = structuredClone(view.getHostSnapshot().selection.mute);
return { receipt, unmuted, undo, muted, redo, again, order };
});
expect(result.unmuted).toEqual({ enabled: true, state: "all-unmuted" });
expect(result.muted).toEqual({ enabled: true, state: "all-muted" });
expect(result.again).toEqual({ enabled: true, state: "all-unmuted" });
expect(result.receipt.status).toBe("committed");
expect(result.undo.status).toBe("committed");
expect(result.redo.status).toBe("committed");
expect(result.order).toEqual([
`mutation:${result.receipt.version}`,
`snapshot:${result.receipt.version}`,
`receipt:${result.receipt.version}`,
`mutation:${result.undo.version}`,
`snapshot:${result.undo.version}`,
`mutation:${result.redo.version}`,
`snapshot:${result.redo.version}`,
]);
});
test("collapse chevron animates for click, H, API, and interrupted reversal then idles", async ({ page }) => {
await page.addInitScript(() => {
let frames = 0;
const original = CanvasRenderingContext2D.prototype.drawImage;
Object.defineProperty(CanvasRenderingContext2D.prototype, "drawImage", {
value: function (this: CanvasRenderingContext2D, ...args: unknown[]) {
frames++;
return Reflect.apply(original, this, args);
},
});
Object.defineProperty(window, "frameDraws", { get: () => frames });
});
await page.goto("/examples/blender/");
await page.evaluate(() => window.fxnodeExample.ready);
const canvas = page.locator("#graph");
await canvas.click({ position: { x: 380, y: 160 } });
const chevron = () =>
page.evaluate(() =>
Array.from(
document.querySelector<HTMLCanvasElement>("#graph")!.getContext("2d")!.getImageData(304, 154, 16, 16).data,
),
);
const expanded = await chevron(),
before = await page.evaluate(() => (window as typeof window & { frameDraws: number }).frameDraws);
await canvas.click({ position: { x: 312, y: 162 } });
expect(
(await page.evaluate(() => window.fxnodeExample.root!.getState())).nodes.find((node) => node.id === "math")
?.collapsed,
).toBe(true);
await page.waitForTimeout(180);
const collapsed = await chevron(),
animated = await page.evaluate(() => (window as typeof window & { frameDraws: number }).frameDraws);
expect(collapsed).not.toEqual(expanded);
expect(animated - before).toBeGreaterThan(1);
await canvas.press("h");
await page.waitForTimeout(180);
expect(
(await page.evaluate(() => window.fxnodeExample.root!.getState())).nodes.find((node) => node.id === "math")
?.collapsed,
).toBe(false);
expect(await chevron()).toEqual(expanded);
await page.evaluate(async () => {
const api = window.fxnodeExample.root!,
view = window.fxnodeExample.view!,
id = (await api.getState()).nodes.find((node) => node.id === "math")!.id;
return api.dispatch({ type: "node.collapse", id, value: true });
});
await page.waitForTimeout(30);
await page.evaluate(async () => {
const api = window.fxnodeExample.root!,
view = window.fxnodeExample.view!,
id = (await api.getState()).nodes.find((node) => node.id === "math")!.id;
return api.dispatch({ type: "node.collapse", id, value: false });
});
await page.waitForTimeout(180);
expect(
(await page.evaluate(() => window.fxnodeExample.root!.getState())).nodes.find((node) => node.id === "math")
?.collapsed,
).toBe(false);
expect(await chevron()).toEqual(expanded);
const idle = await page.evaluate(() => (window as typeof window & { frameDraws: number }).frameDraws);
await page.waitForTimeout(180);
expect(await page.evaluate(() => (window as typeof window & { frameDraws: number }).frameDraws)).toBe(idle);
});
+51
View File
@@ -0,0 +1,51 @@
import { expect, test, type Locator } from "@playwright/test";
async function stable(canvas: Locator): Promise<void> {
const first = await canvas.screenshot();
await canvas.page().evaluate(() => window.parityExample.view.whenRendered());
expect((await canvas.screenshot()).equals(first)).toBe(true);
}
test("@visual structural parity baseline and focused controls", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 });
await page.goto("/examples/blender/parity/");
await page.waitForFunction(() => Boolean(window.parityExample));
const canvas = page.locator("canvas");
await canvas.press("Home");
await page.evaluate(() => window.parityExample.view.whenRendered());
await stable(canvas);
const regions = await canvas.evaluate((element) => {
const context = (element as HTMLCanvasElement).getContext("2d")!;
const regions = [
{ x: 40, y: 110, w: 260, h: 290 },
{ x: 1025, y: 110, w: 370, h: 330 },
{ x: 650, y: 615, w: 470, h: 240 },
];
return regions.map((region) => {
const data = context.getImageData(region.x, region.y, region.w, region.h).data;
let body = 0,
controls = 0;
for (let i = 0; i < data.length; i += 4) {
if (data[i] === 53 && data[i + 1] === 56 && data[i + 2] === 62) body++;
if (data[i] === 36 && data[i + 1] === 39 && data[i + 2] === 43) controls++;
}
return { body, controls };
});
});
for (const region of regions) expect(region.body).toBeGreaterThan(1000);
expect(regions.reduce((sum, region) => sum + region.controls, 0)).toBeGreaterThan(500);
await expect(canvas).toHaveScreenshot("parity-structural.png", { animations: "disabled" });
// Crops deliberately cover the widget structures rather than Blender pixels:
// image selectors/projection, grading mode rows, and ramp/noise conditional rows.
const imageGrading = await page.screenshot({
clip: { x: 90, y: 500, width: 1260, height: 420 },
animations: "disabled",
});
expect(imageGrading).toMatchSnapshot("parity-image-grading.png");
const rampNoise = await page.screenshot({
clip: { x: 380, y: 55, width: 1060, height: 450 },
animations: "disabled",
});
expect(rampNoise).toMatchSnapshot("parity-ramp-noise.png");
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+7
View File
@@ -0,0 +1,7 @@
<!doctype html>
<html>
<body>
<canvas id="presentation" width="500" height="300"></canvas>
<script type="module" src="./presentation-render.ts"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
import { expect, test } from "@playwright/test";
test("renderer paints composition-resolved background, header, socket, and link colors", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/test/browser/presentation-render.html");
await page.waitForTimeout(500);
expect(errors).toEqual([]);
const result = await page.evaluate(
() =>
(
window as unknown as {
presentationResult: {
background: number[];
header: number[];
socket: number[];
linkContainsCustomColor: boolean;
};
}
).presentationResult,
);
expect(result).toEqual({
background: [1, 2, 3, 255],
header: [118, 84, 171, 255],
socket: [161, 178, 195, 255],
linkContainsCustomColor: true,
});
});
+130
View File
@@ -0,0 +1,130 @@
import { compileFxNodeComposition } from "@lib/composition/index.js";
import { bindFxNodeHeadless } from "@lib/headless-runtime.js";
import { layoutGraph } from "@lib/layout/layout-graph.js";
import { worldToView } from "@lib/layout/geometry.js";
import { renderCanvas } from "@lib/render/canvas-renderer.js";
import { linkId } from "@lib/core/types.js";
const theme = {
background: "#010203",
grid: "#010203",
frame: "#070809",
frameHeader: "#0a0b0c",
body: "#0d0e0f",
control: "#101112",
controlFill: "#131415",
controlEditing: "#161718",
textSelection: "#191a1b",
outline: "#1c1d1e",
text: "#1f2021",
muted: "#222324",
shadow: "#00000000",
nodeSelected: "#28292a",
nodeActive: "#2b2c2d",
unknownHeader: "#2e2f30",
unknownSocket: "#313233",
linkMuted: "#343536",
knifeMuted: "#373839",
emphasis: "#3a3b3c",
focus: "#3d3e3f",
editOutline: "#404142",
resize: "#434445",
muteOverlay: "#464748",
boxSelectionFill: "#494a4b",
checkerLight: "#4c4d4e",
checkerDark: "#4f5051",
widgetBorder: "#525354",
rampBorder: "#555657",
resourceBackground: "#58595a",
} as const;
const base = () =>
({
version: 1,
behavior: "standard",
style: "custom",
parameters: {},
muteBypass: [],
migrations: [],
}) as const;
const composition = {
schemaVersion: 2,
id: "browser-render-proof",
version: 1,
compatibility: { wildcardInputTypes: [] },
theme,
socketTypes: { signal: { title: "Signal", color: "#a1b2c3", acceptsFrom: ["signal"] } },
nodeStyles: { custom: { header: "#7654ab" } },
resources: {},
nodes: {
source: {
...base(),
title: "Source",
sockets: {
out: {
title: "Out",
direction: "output",
type: "signal",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
},
},
ui: [{ kind: "socket", socket: "out" }],
},
target: {
...base(),
title: "Target",
sockets: {
in: {
title: "In",
direction: "input",
type: "signal",
maxIncomingLinks: 1,
visible: true,
value: null,
showValue: false,
},
},
ui: [{ kind: "socket", socket: "in" }],
},
},
} as const;
const compiled = compileFxNodeComposition(composition),
runtime = bindFxNodeHeadless(compiled);
const source = runtime.materializeNode("source", "source", { x: -200, y: 80 }),
target = runtime.materializeNode("target", "target", { x: 100, y: 80 });
const link = {
id: linkId("wire"),
fromNodeId: source.id,
fromSocketId: source.sockets[0]!.id,
toNodeId: target.id,
toSocketId: target.sockets[0]!.id,
muted: false,
extensions: {},
};
const graph = { ...runtime.emptyDocument("render"), nodes: { source, target }, links: { wire: link } };
const transform = { center: { x: 0, y: 0 }, zoom: 1, viewport: { x: 500, y: 300 }, dpr: 1 },
layout = layoutGraph(compiled, graph, transform);
const canvas = document.querySelector<HTMLCanvasElement>("#presentation")!,
context = canvas.getContext("2d")!;
renderCanvas(context as unknown as OffscreenCanvasRenderingContext2D, layout, compiled.theme);
const pixel = (x: number, y: number) => Array.from(context.getImageData(Math.round(x), Math.round(y), 1, 1).data);
const contains = (x: number, y: number, rgba: readonly number[]) => {
const data = context.getImageData(Math.round(x) - 3, Math.round(y) - 3, 7, 7).data;
for (let i = 0; i < data.length; i += 4) if (rgba.every((v, j) => data[i + j] === v)) return true;
return false;
};
const node = layout.nodes.get(source.id)!,
socket = layout.sockets.get(source.sockets[0]!.id)!,
wire = layout.links.get("wire" as never)!,
header = worldToView({ x: node.bounds.x + 60, y: node.bounds.y - 12 }, transform),
socketPoint = worldToView(socket.anchor, transform),
middle = worldToView(wire.points[Math.floor(wire.points.length / 2)]!, transform);
const result = {
background: pixel(3, 3),
header: pixel(header.x, header.y),
socket: pixel(socketPoint.x, socketPoint.y),
linkContainsCustomColor: contains(middle.x, middle.y, [161, 178, 195, 255]),
};
(window as unknown as { presentationResult: typeof result }).presentationResult = result;
+274
View File
@@ -0,0 +1,274 @@
import { expect, test, type Page } from "@playwright/test";
import type { FxNode } from "@lib/index.js";
import type { GraphLayoutV2, GraphSnapshot } from "@lib/core/types.js";
import type { ColorRamp } from "@lib/widgets/color-ramp.js";
type Point = Readonly<{ x: number; y: number }>;
type Events = { mutations: number[]; snapshots: number[] };
type State = { snapshot: GraphSnapshot; layout: GraphLayoutV2; events: Events };
type RampWindow = Window &
typeof globalThis & {
rampTest: { root: FxNode | null; view: import("@lib/index.js").FxNodeView | null; ready: Promise<void> };
rampEvents: Events;
};
// The fixture ramp's compound world origin is frame (-300,250) + child (40,-40),
// hence (-260,210), width 320. At zoom 1 the canvas origin is (600,320).
// Layout's documented ramp bounds start at node.x+10 and node.y-header-2 rows-3:
// screen origin (350,185), width 300; rows are toolbar +0, menus +22,
// gradient +46, handles +74, and details +104.
const P = Object.freeze({
add: { x: 368, y: 195 },
remove: { x: 404, y: 195 },
flip: { x: 479, y: 195 },
distribute: { x: 593, y: 195 },
mode: { x: 395, y: 217 },
interpolation: { x: 501, y: 217 },
hue: { x: 614, y: 217 },
gradient: { x: 500, y: 242 },
firstHandle: { x: 386, y: 271 },
selector: { x: 387, y: 299 },
position: { x: 483, y: 299 },
swatch: { x: 380, y: 321 },
r: { x: 440, y: 321 },
g: { x: 500, y: 321 },
b: { x: 560, y: 321 },
a: { x: 620, y: 321 },
} satisfies Record<string, Point>);
async function open(page: Page) {
await page.goto("/examples/blender/ramp-test/");
await page.evaluate(() => (window as unknown as RampWindow).rampTest.ready);
await page.evaluate(() => {
const w = window as unknown as RampWindow;
w.rampEvents = { mutations: [], snapshots: [] };
w.rampTest.root!.onMutations((e) => w.rampEvents.mutations.push(e.version));
w.rampTest.root!.onSnapshots((e) => w.rampEvents.snapshots.push(e.version));
});
return page.locator("#ramp");
}
const state = (page: Page): Promise<State> =>
page.evaluate(async () => {
const w = window as unknown as RampWindow;
return {
snapshot: await w.rampTest.root!.getState(),
layout: await w.rampTest.root!.save(),
events: structuredClone(w.rampEvents),
};
});
const ramp = (s: State): ColorRamp =>
(s.layout.nodes.find((n) => n.id === "ramp")!.parameters.ramp as { readonly kind: "json"; readonly value: unknown })
.value as ColorRamp;
function pairs(before: State, after: State, count: number) {
const m = after.events.mutations.slice(before.events.mutations.length),
s = after.events.snapshots.slice(before.events.snapshots.length);
expect(m).toEqual(s);
expect(m).toHaveLength(count);
expect(after.snapshot.version - before.snapshot.version).toBe(count);
}
async function drag(page: Page, from: Point, dx: number, shift = false) {
const box = await page.locator("#ramp").boundingBox();
if (!box) throw new Error("Canvas missing");
if (shift) await page.keyboard.down("Shift");
await page.mouse.move(box.x + from.x, box.y + from.y);
await page.mouse.down();
await page.mouse.move(box.x + from.x + dx, box.y + from.y, { steps: 5 });
return async () => {
await page.mouse.up();
if (shift) await page.keyboard.up("Shift");
};
}
const undo = (page: Page) => page.evaluate(() => (window as unknown as RampWindow).rampTest.root!.undo());
test("handle drag previews pixels, commits once, and Escape/RMB cancel", async ({ page }) => {
const canvas = await open(page),
a = await state(page),
pixels = await canvas.screenshot(),
release = await drag(page, P.firstHandle, 45);
await expect.poll(async () => (await canvas.screenshot()).equals(pixels)).toBe(false);
const preview = await state(page);
expect(preview).toEqual(a);
await release();
const b = await state(page);
pairs(a, b, 1);
expect(ramp(b).stops.find((s) => s.id === "red")!.position).toBeGreaterThan(0.2);
await page.evaluate(() => (window as unknown as RampWindow).rampTest.view!.whenRendered());
const stopped = await canvas.screenshot();
await page.mouse.move(700, 400);
expect(await canvas.screenshot()).toEqual(stopped);
const cancel = await drag(page, { ...P.firstHandle, x: 386 + 45 }, 30);
await canvas.press("Escape");
await cancel();
pairs(b, await state(page), 0);
const cancelRmb = await drag(page, { ...P.firstHandle, x: 386 + 45 }, -25);
await page.mouse.down({ button: "right" });
await page.mouse.up({ button: "right" });
await cancelRmb();
pairs(b, await state(page), 0);
});
test("plain gradient click inserts one transiently-active stop and undo removes it", async ({ page }) => {
const canvas = await open(page),
a = await state(page);
await canvas.click({ position: P.gradient });
const b = await state(page);
pairs(a, b, 1);
expect(ramp(b).stops).toHaveLength(4);
expect(ramp(b).stops.filter((s) => !ramp(a).stops.some((old) => old.id === s.id))).toHaveLength(1);
expect(JSON.stringify(b.layout)).not.toContain("activeRamp");
expect(JSON.stringify(b.snapshot)).not.toContain("activeRamp");
await undo(page);
const c = await state(page);
pairs(b, c, 1);
expect(ramp(c)).toEqual(ramp(a));
});
test("transparent gradient checker is clipped to its exact bounds", async ({ page }) => {
const canvas = await open(page);
const pixels = await canvas.evaluate((element) => {
const context = (element as HTMLCanvasElement).getContext("2d")!;
return {
x: Array.from(context.getImageData(643, 240, 1, 1).data),
y: Array.from(context.getImageData(500, 260, 1, 1).data),
};
});
for (const pixel of [pixels.x, pixels.y]) {
expect(pixel[3]).toBe(255);
expect(pixel.slice(0, 3)).not.toEqual([119, 119, 119]);
expect(pixel.slice(0, 3)).not.toEqual([170, 170, 170]);
}
});
test("toolbar add/remove commits atomically and enforces two stops; shortcuts stay scoped", async ({ page }) => {
const canvas = await open(page),
a = await state(page);
await canvas.click({ position: P.firstHandle });
await canvas.click({ position: P.add });
const b = await state(page);
pairs(a, b, 1);
expect(ramp(b).stops).toHaveLength(4);
await canvas.click({ position: P.remove });
const c = await state(page);
pairs(b, c, 1);
expect(ramp(c).stops).toHaveLength(3);
await canvas.press("Delete");
const d = await state(page);
pairs(c, d, 1);
expect(ramp(d).stops).toHaveLength(2);
await canvas.press("Delete");
pairs(d, await state(page), 0);
await canvas.press("g");
await canvas.press("m");
expect((await state(page)).layout.nodes).toHaveLength(2);
});
test("mode, interpolation, and hue controls cycle legal values and survive save/load", async ({ page }) => {
const canvas = await open(page);
let a = await state(page);
const controls: readonly [Point, "colorMode" | "interpolation" | "hueInterpolation", readonly string[]][] = [
[P.mode, "colorMode", ["rgb", "hsv", "hsl"]],
[P.interpolation, "interpolation", ["linear", "ease", "constant", "cardinal", "b-spline"]],
[P.hue, "hueInterpolation", ["near", "far", "clockwise", "counter-clockwise"]],
];
for (const [point, key, legal] of controls) {
await canvas.click({ position: point });
const b = await state(page);
pairs(a, b, 1);
expect(legal).toContain(ramp(b)[key]);
a = b;
}
const saved = a.layout,
persisted = ramp(a);
await canvas.click({ position: P.mode });
const changed = await state(page);
await page.evaluate(
(layout: unknown) => (window as unknown as RampWindow).rampTest.root!.load(layout),
saved as unknown,
);
const loaded = await state(page);
pairs(changed, loaded, 1);
expect(ramp(loaded)).toEqual(persisted);
});
test("position scrub alters only the active stop position", async ({ page }) => {
await open(page);
let a = await state(page);
const finishPos = await drag(page, P.position, 10, true);
await finishPos();
let b = await state(page);
pairs(a, b, 1);
expect(ramp(b).stops[0]!.position).toBeCloseTo(ramp(a).stops[0]!.position + 0.01);
expect(ramp(b).stops[0]!.color).toEqual(ramp(a).stops[0]!.color);
});
test("active stop swatch opens an Oklch picker with transient preview and atomic commit", async ({ page }) => {
const canvas = await open(page),
before = await state(page);
await canvas.click({ position: P.swatch });
const box = await canvas.boundingBox();
if (!box) throw new Error("Canvas missing");
await page.mouse.move(box.x + 756, box.y + 409);
await page.mouse.down();
await page.mouse.move(box.x + 816, box.y + 409, { steps: 4 });
pairs(before, await state(page), 0);
await page.mouse.up();
pairs(before, await state(page), 0);
await canvas.click({ position: { x: 676, y: 322 } });
const committed = await state(page);
pairs(before, committed, 1);
expect(ramp(committed).stops[0]!.color).not.toEqual(ramp(before).stops[0]!.color);
await canvas.click({ position: P.swatch });
await page.mouse.move(box.x + 756, box.y + 409);
await page.mouse.down();
await page.mouse.move(box.x + 776, box.y + 372);
await page.mouse.up();
await canvas.press("Escape");
pairs(committed, await state(page), 0);
});
test("Backspace resets ramp or active color, undo restores, and Escape is inert", async ({ page }) => {
const canvas = await open(page),
a = await state(page);
await canvas.click({ position: P.position });
await canvas.press("Backspace");
const reset = await state(page);
pairs(a, reset, 1);
expect(ramp(reset).stops.map((s) => s.position)).toEqual([0, 1]);
await undo(page);
const restored = await state(page);
pairs(reset, restored, 1);
expect(ramp(restored)).toEqual(ramp(a));
await canvas.hover({ position: P.swatch });
await canvas.press("Backspace");
const black = await state(page);
pairs(restored, black, 1);
expect(ramp(black).stops[0]!.color).toEqual([0, 0, 0, 1]);
await canvas.press("Escape");
pairs(black, await state(page), 0);
});
test("flip and distribute are one-commit, one-step-undo tools", async ({ page }) => {
const canvas = await open(page);
let a = await state(page);
await canvas.click({ position: P.flip });
let b = await state(page);
pairs(a, b, 1);
expect(ramp(b).stops.map((s) => s.position)).toEqual([
expect.closeTo(0.1),
expect.closeTo(0.65),
expect.closeTo(0.9),
]);
await undo(page);
let c = await state(page);
pairs(b, c, 1);
expect(ramp(c)).toEqual(ramp(a));
a = c;
await canvas.click({ position: P.distribute });
b = await state(page);
pairs(a, b, 1);
expect(ramp(b).stops.map((s) => s.position)).toEqual([0.1, 0.5, 0.9]);
await undo(page);
c = await state(page);
pairs(b, c, 1);
expect(ramp(c)).toEqual(ramp(a));
});
+299
View File
@@ -0,0 +1,299 @@
import { expect, test, type Page } from "@playwright/test";
type Point = Readonly<{ x: number; y: number }>;
type Saved = Awaited<ReturnType<typeof saved>>;
// The parity page is deliberately used instead of a test-only worker hook. At
// 1280x1200, zoom one maps world (0,0) to canvas (640,600). These are centers
// of public canvas controls derived from the descriptor layout's 24px rows.
const P = Object.freeze({
image: {
resource: { x: 260, y: 525 },
interpolation: { x: 312, y: 552 },
projection: { x: 312, y: 576 },
flatExtension: { x: 312, y: 600 },
boxBlend: { x: 312, y: 600 },
boxExtension: { x: 312, y: 624 },
},
compositor: {
resource: { x: 420, y: 965 },
source: { x: 464, y: 992 },
frames: { x: 464, y: 1016 },
start: { x: 464, y: 1040 },
offset: { x: 464, y: 1064 },
cyclic: { x: 464, y: 1088 },
refresh: { x: 464, y: 1112 },
},
noise: { dimensions: { x: 653, y: 456 }, type: { x: 653, y: 480 } },
master: {
mode: { x: 961, y: 896 },
scalar: { x: 729, y: 1048 },
liftWheel: { x: 729, y: 990 },
whiteTemperature: { x: 961, y: 944 },
eye: { x: 860, y: 1016 },
},
});
async function open(page: Page) {
await page.setViewportSize({ width: 1280, height: 1200 });
await page.goto("/examples/blender/parity/");
await page.waitForFunction(() => !!window.parityExample);
await page.evaluate(() => {
const w = window as typeof window & { requestedEvents: { m: number[]; s: number[] } };
w.requestedEvents = { m: [], s: [] };
window.parityExample!.root.onMutations((e) => w.requestedEvents.m.push(e.version));
window.parityExample!.root.onMutations(() => {
throw new Error("intentional requested-node subscriber failure");
});
window.parityExample!.root.onSnapshots((e) => w.requestedEvents.s.push(e.version));
});
return page.locator("#graph");
}
async function saved(page: Page) {
return page.evaluate(async () => ({
layout: await window.parityExample!.root.save(),
snapshot: await window.parityExample!.root.getState(),
events: (window as typeof window & { requestedEvents: { m: number[]; s: number[] } }).requestedEvents,
}));
}
const node = (state: Saved, id: string) => state.layout.nodes.find((n) => n.id === id)!;
const parameter = (state: Saved, id: string, key: string) => node(state, id).parameters[key]!;
function paired(a: Saved, b: Saved, count = 1) {
expect(b.snapshot.version - a.snapshot.version).toBe(count);
expect(b.events.m.slice(a.events.m.length)).toEqual(b.events.s.slice(a.events.s.length));
expect(b.events.m.length - a.events.m.length).toBe(count);
}
async function pixels(page: Page, x: number, y: number, w: number, h: number) {
return page.locator("#graph").evaluate(
(c, r) => {
const d = (c as HTMLCanvasElement).getContext("2d")!.getImageData(r.x, r.y, r.w, r.h).data;
let hash = 2166136261;
for (const v of d) hash = Math.imul(hash ^ v, 16777619);
return hash >>> 0;
},
{ x, y, w, h },
);
}
async function scrub(page: Page, p: Point, dx = 25) {
const box = await page.locator("#graph").boundingBox();
if (!box) throw Error("canvas bounds missing");
await page.mouse.move(box.x + p.x, box.y + p.y);
await page.mouse.down();
await page.mouse.move(box.x + p.x + dx, box.y + p.y, { steps: 4 });
await page.mouse.up();
}
const PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAIAAAACAQMAAABIeJ9nAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAABlBMVEUzZpn////xDcYdAAAAAWJLR0QB/wIt3gAAAAd0SU1FB+oHFQw4FzO6KaEAAAAMSURBVAjXY2BgYAAAAAQAASc0JwoAAAAldEVYdGRhdGU6Y3JlYXRlADIwMjYtMDctMjFUMTI6NTY6MjMrMDA6MDC80CO3AAAAJXRFWHRkYXRlOm1vZGlmeQAyMDI2LTA3LTIxVDEyOjU2OjIzKzAwOjAwzY2bCwAAAABJRU5ErkJggg==",
"base64",
);
async function chooseImage(page: Page, canvas: ReturnType<Page["locator"]>, position: Point, name: string) {
const pending = page.waitForEvent("filechooser");
await canvas.click({ position });
const chooser = await pending;
await chooser.setFiles({ name, mimeType: "image/png", buffer: PNG });
}
test("Image Texture conditional blend and persisted texture modes", async ({ page }) => {
const canvas = await open(page);
let a = await saved(page);
const emptyPreview = await pixels(page, 130, 448, 260, 62);
await chooseImage(page, canvas, P.image.resource, "texture.png");
await expect
.poll(async () => (parameter(await saved(page), "image-texture", "image") as { value: string }).value)
.toContain("texture.png");
await page.evaluate(() => window.parityExample!.view.whenRendered());
const uploaded = await saved(page);
paired(a, uploaded);
expect(await pixels(page, 130, 448, 260, 62)).not.toBe(emptyPreview);
a = uploaded;
const flatPixels = await pixels(page, 110, 410, 300, 300);
expect(node(a, "image-texture").sockets.map((s) => [s.key, s.direction, s.dataType])).toEqual([
["vector", "input", "vector"],
["color", "output", "color"],
["alpha", "output", "float"],
]);
await canvas.click({ position: P.image.projection });
await page.evaluate(() => window.parityExample!.view.whenRendered());
const box = await saved(page);
paired(a, box);
expect(parameter(box, "image-texture", "projection")).toEqual({ kind: "string", value: "Box" });
expect(await pixels(page, 110, 410, 300, 300)).not.toBe(flatPixels);
await scrub(page, P.image.boxBlend);
const blended = await saved(page);
paired(box, blended);
expect((parameter(blended, "image-texture", "blend") as { value: number }).value).toBeGreaterThan(0);
await canvas.hover({ position: P.image.boxBlend });
await canvas.press("Backspace");
const reset = await saved(page);
paired(blended, reset);
expect(parameter(reset, "image-texture", "blend")).toEqual({ kind: "number", value: 0 });
await scrub(page, P.image.boxBlend, 15);
await canvas.click({ position: P.image.projection });
const sphere = await saved(page);
expect(parameter(sphere, "image-texture", "projection")).toEqual({ kind: "string", value: "Sphere" });
expect((parameter(sphere, "image-texture", "blend") as { value: number }).value).toBeGreaterThan(0);
// The old Blend coordinate is now Extension: hit behavior proves that no
// Blend control remains there, while the saved Blend value stays authored.
await canvas.click({ position: P.image.boxBlend });
let hidden = await saved(page);
expect(parameter(hidden, "image-texture", "extension")).toEqual({ kind: "string", value: "Extend" });
expect((parameter(hidden, "image-texture", "blend") as { value: number }).value).toBeGreaterThan(0);
await canvas.click({ position: P.image.interpolation });
const changed = await saved(page);
expect(parameter(changed, "image-texture", "interpolation")).toEqual({ kind: "string", value: "Closest" });
expect(parameter(changed, "image-texture", "extension")).toEqual({ kind: "string", value: "Extend" });
await page.evaluate(async () => {
const x = await window.parityExample!.root.save();
await window.parityExample!.root.load(x);
});
const loaded = await saved(page);
expect(node(loaded, "image-texture").parameters).toEqual(node(changed, "image-texture").parameters);
});
test("Compositor Image movie rows, resource editing and output contract", async ({ page }) => {
const canvas = await open(page),
a = await saved(page);
const n = node(a, "compositor-image");
expect(n.sockets.map((s) => [s.key, s.direction, s.dataType])).toEqual([
["image", "output", "color"],
["alpha", "output", "float"],
["z", "output", "float"],
]);
expect(Object.keys(n.parameters)).not.toEqual(expect.arrayContaining(["projection", "interpolation", "vector"]));
await chooseImage(page, canvas, P.compositor.resource, "plate.png");
await expect
.poll(async () => (parameter(await saved(page), "compositor-image", "image") as { value: string }).value)
.toContain("plate.png");
let b = await saved(page);
paired(a, b);
await canvas.click({ position: P.compositor.source });
b = await saved(page);
expect(parameter(b, "compositor-image", "source")).toEqual({ kind: "string", value: "Movie" });
await scrub(page, P.compositor.frames);
await scrub(page, P.compositor.start);
await scrub(page, P.compositor.offset);
await canvas.click({ position: P.compositor.cyclic });
await canvas.click({ position: P.compositor.refresh });
const edited = await saved(page);
expect((parameter(edited, "compositor-image", "frames") as { value: number }).value).toBeGreaterThan(1);
await canvas.hover({ position: { x: 10, y: 10 } });
await canvas.hover({ position: P.compositor.frames });
await page.keyboard.press("Backspace");
expect(parameter(await saved(page), "compositor-image", "frames")).toEqual({ kind: "number", value: 1 });
// Movie -> Sequence retains the same rows; cycling through Multilayer,
// Generated and File hides them without deleting their authored values.
await canvas.click({ position: P.compositor.source });
expect(parameter(await saved(page), "compositor-image", "source")).toEqual({ kind: "string", value: "Sequence" });
for (let i = 0; i < 3; i++) {
await canvas.click({ position: P.compositor.source });
await page.evaluate(() => window.parityExample!.view.whenRendered());
}
const file = await saved(page);
expect(parameter(file, "compositor-image", "source")).toEqual({ kind: "string", value: "File" });
expect(parameter(file, "compositor-image", "offset")).toEqual(parameter(edited, "compositor-image", "offset"));
const v = file.snapshot.version;
await canvas.click({ position: P.compositor.frames });
expect((await saved(page)).snapshot.version).toBe(v);
});
test("Noise dimensions/types use conditional hit rows and preserve hidden values", async ({ page }) => {
const canvas = await open(page);
let prior = await saved(page),
hash = await pixels(page, 410, 410, 220, 380);
// 3D -> 4D -> 1D, then 1D -> 2D -> 3D -> 4D (actual enum events).
await canvas.click({ position: P.noise.dimensions });
await canvas.click({ position: P.noise.dimensions });
let one = await saved(page);
paired(prior, one, 2);
expect(parameter(one, "noise-3d", "dimensions")).toEqual({ kind: "string", value: "1d" });
expect(await pixels(page, 410, 410, 220, 380)).not.toBe(hash);
// W is the first socket row in 1D (y=528).
await scrub(page, { x: 653, y: 528 });
const wEdited = await saved(page);
expect(
(node(wEdited, "noise-3d").sockets.find((s) => s.key === "w")!.defaultValue as { value: number }).value,
).toBeGreaterThan(0);
for (let i = 0; i < 3; i++) await canvas.click({ position: P.noise.dimensions });
let four = await saved(page);
expect(parameter(four, "noise-3d", "dimensions")).toEqual({ kind: "string", value: "4d" });
await canvas.click({ position: P.noise.type });
await canvas.click({ position: P.noise.type });
let hybrid = await saved(page);
expect(parameter(hybrid, "noise-3d", "noiseType")).toEqual({ kind: "string", value: "hybrid-multifractal" });
// In 4D hybrid: vector,w,scale,detail,roughness,lacunarity,offset,gain.
await scrub(page, { x: 653, y: 648 });
await scrub(page, { x: 653, y: 672 });
const conditional = await saved(page);
for (const key of ["offset", "gain"])
expect(
(node(conditional, "noise-3d").sockets.find((s) => s.key === key)!.defaultValue as { value: number }).value,
).toBeGreaterThan(0);
await canvas.click({ position: P.noise.type });
expect(parameter(await saved(page), "noise-3d", "noiseType")).toEqual({
kind: "string",
value: "ridged-multifractal",
});
await canvas.click({ position: P.noise.type });
const hetero = await saved(page);
expect(parameter(hetero, "noise-3d", "noiseType")).toEqual({ kind: "string", value: "hetero-terrain" });
expect(node(hetero, "noise-3d").sockets.find((s) => s.key === "gain")!.defaultValue).toEqual(
node(conditional, "noise-3d").sockets.find((s) => s.key === "gain")!.defaultValue,
);
expect(parameter(hetero, "noise-3d", "normalize")).toEqual({ kind: "boolean", value: false });
await canvas.hover({ position: P.noise.type });
await canvas.press("Backspace");
await page.evaluate(() => window.parityExample!.view.whenRendered());
await canvas.hover({ position: { x: 10, y: 10 } });
await canvas.hover({ position: P.noise.dimensions });
await canvas.press("Backspace");
const reset = await saved(page);
expect(parameter(reset, "noise-3d", "noiseType")).toEqual({ kind: "string", value: "fbm" });
expect(parameter(reset, "noise-3d", "dimensions")).toEqual({ kind: "string", value: "3d" });
});
test("Master Color Grading modes edit, persist, load and undo", async ({ page }) => {
const canvas = await open(page),
initial = await saved(page);
expect(node(initial, "master").label).toBe("Master Color Grading");
const lgg = await pixels(page, 650, 850, 420, 350);
await scrub(page, P.master.scalar);
const box = await canvas.boundingBox();
if (!box) throw Error("canvas bounds missing");
await page.mouse.move(box.x + P.master.liftWheel.x, box.y + P.master.liftWheel.y);
await page.mouse.down();
await page.mouse.move(box.x + P.master.liftWheel.x + 25, box.y + P.master.liftWheel.y, { steps: 4 });
paired(initial, await saved(page), 1);
await page.mouse.up();
const lift = await saved(page);
paired(initial, lift, 2);
expect((parameter(lift, "master", "lift") as { value: number }).value).toBeGreaterThan(0);
expect(parameter(lift, "master", "liftColor")).not.toEqual(parameter(initial, "master", "liftColor"));
await canvas.click({ position: P.master.mode });
let ops = await saved(page);
expect(parameter(ops, "master", "mode")).toEqual({ kind: "string", value: "Offset/Power/Slope" });
expect(await pixels(page, 650, 850, 420, 350)).not.toBe(lgg);
await scrub(page, P.master.scalar);
ops = await saved(page);
expect((parameter(ops, "master", "offset") as { value: number }).value).toBeGreaterThan(0);
expect(parameter(ops, "master", "lift")).toEqual(parameter(lift, "master", "lift"));
await canvas.click({ position: P.master.mode });
let white = await saved(page);
expect(parameter(white, "master", "mode")).toEqual({ kind: "string", value: "White Point" });
await scrub(page, P.master.whiteTemperature);
white = await saved(page);
expect((parameter(white, "master", "inputTemperature") as { value: number }).value).toBeGreaterThan(6500);
const beforeEye = white.snapshot.version;
await canvas.click({ position: P.master.eye });
expect((await saved(page)).snapshot.version).toBe(beforeEye);
const persisted = await page.evaluate(async () => {
const x = await window.parityExample!.root.save();
await window.parityExample!.root.load(x);
return window.parityExample!.root.save();
});
expect(persisted.nodes.find((n) => n.id === "master")!.label).toBe("Master Color Grading");
expect(persisted.nodes.find((n) => n.id === "master")!.parameters).toEqual(node(white, "master").parameters);
await canvas.click({ position: P.master.mode });
expect(parameter(await saved(page), "master", "mode")).toEqual({ kind: "string", value: "Lift/Gamma/Gain" });
await page.evaluate(() => window.parityExample!.root.undo());
expect(parameter(await saved(page), "master", "mode")).toEqual({ kind: "string", value: "White Point" });
});
+9
View File
@@ -0,0 +1,9 @@
bddbb2638889d825407600a55ddd427a04fa26cf624a718dde13bb3429e8b3aa test/browser/all-supported.spec.ts-snapshots/all-supported-linux.png
66021d791bacd650a5c0d983d0a2c5ba377aba23c598dd022de7fc18f9ad7f68 test/browser/parity.visual.spec.ts-snapshots/parity-image-grading-linux.png
6d24bc2f6783a4bbad5d416b72442c02e0b05260700fb06d6aaaa1e61aff53dc test/browser/parity.visual.spec.ts-snapshots/parity-ramp-noise-linux.png
acd64dbf5847551a9403517be7c7f94ca6c7691fc14c641b82b687ce7b16a919 test/browser/parity.visual.spec.ts-snapshots/parity-structural-linux.png
b22233380b3509b79769cc1dd9dca809360e097c337c258638871867f4442673 test/browser/visual.spec.ts-snapshots/color-picker-linux.png
db27823fafd21ef361dfa12e13d9424972e111d13babfc4fee53614bfdde37e4 test/browser/visual.spec.ts-snapshots/numeric-editing-linux.png
83a60b97d9dd423e614d52e0427e3a17515f92ec94a467cd9bd3fe1723157fc5 test/browser/visual.spec.ts-snapshots/numeric-fields-linux.png
5e578055b7ade004c9252ca785491dd39211b92ceb68d6eb8a37421e1c2b30fd test/browser/visual.spec.ts-snapshots/phase-4-example-linux.png
0ba342da7237709e47ee607726fc131f5dcdbb84650aed5d6ac3e2d1ced8b727 test/browser/visual.spec.ts-snapshots/zoomed-out-lod-linux.png
+82
View File
@@ -0,0 +1,82 @@
import { expect, test } from "@playwright/test";
test("@visual deterministic example canvas", async ({ page }) => {
await page.goto("/examples/blender/");
await page.evaluate(() => window.fxnodeExample.ready);
await page.evaluate(() => window.fxnodeExample.rendered);
const canvas = page.locator("#graph");
await expect(canvas).toHaveAttribute("width", "1200");
await expect(canvas).toHaveAttribute("height", "640");
const evidence = await canvas.evaluate((element) => {
const context = (element as HTMLCanvasElement).getContext("2d");
if (!context) throw new Error("Canvas context missing");
const data = context.getImageData(0, 0, 1200, 640).data;
let background = 0,
nodePixels = 0,
linkPixels = 0;
for (let index = 0; index < data.length; index += 4) {
const r = data[index],
g = data[index + 1],
b = data[index + 2],
a = data[index + 3];
if (r === 29 && g === 31 && b === 35 && a === 255) background++;
if (r === 53 && g === 56 && b === 62 && a === 255) nodePixels++;
if ((r === 168 && g === 168 && b === 168) || (r === 98 && g === 179 && b === 79)) linkPixels++;
}
return { background, nodePixels, linkPixels };
});
expect(evidence.background).toBeGreaterThan(300_000);
expect(evidence.nodePixels).toBeGreaterThan(20_000);
expect(evidence.linkPixels).toBeGreaterThan(100);
const first = await canvas.screenshot();
const second = await canvas.screenshot();
expect(second.equals(first)).toBe(true);
await expect(canvas).toHaveScreenshot("phase-4-example.png", { animations: "disabled" });
await canvas.click({ position: { x: 380, y: 160 } });
await canvas.click({ position: { x: 560, y: 140 }, modifiers: ["Shift"] });
await page.evaluate(() => window.fxnodeExample.view!.whenRendered());
const selection = await canvas.evaluate((element) => {
const context = (element as HTMLCanvasElement).getContext("2d")!;
const pixels = context.getImageData(0, 0, 1200, 640).data;
let selected = 0,
expanded = 0;
for (let y = 150; y < 260; y++)
for (let x = 295; x < 485; x++) {
const index = (y * 1200 + x) * 4;
if (pixels[index] === 237 && pixels[index + 1] === 87 && pixels[index + 2] === 0 && pixels[index + 3] === 255) {
selected++;
if (x < 299) expanded++;
}
}
const socketIndex = (234 * 1200 + 300) * 4;
return { selected, expanded, socket: Array.from(pixels.slice(socketIndex, socketIndex + 4)) };
});
expect(selection.selected).toBeGreaterThan(100);
expect(selection.expanded).toBe(0);
expect(selection.socket).toEqual([168, 168, 168, 255]);
});
test("@visual distant zoom keeps text inside scaled nodes", async ({ page }) => {
await page.goto("/examples/blender/");
await page.evaluate(() => window.fxnodeExample.ready);
const canvas = page.locator("#graph");
await canvas.hover({ position: { x: 600, y: 320 } });
await page.mouse.wheel(0, 900);
await page.evaluate(() => window.fxnodeExample.view!.whenRendered());
await expect(canvas).toHaveScreenshot("zoomed-out-lod.png", { animations: "disabled" });
});
test("@visual Blender-style numeric fields and text editing", async ({ page }) => {
await page.goto("/examples/blender/control-test/");
await page.evaluate(() => window.controlTest.ready);
const canvas = page.locator("#controls");
await expect(canvas).toHaveScreenshot("numeric-fields.png", { animations: "disabled" });
await canvas.click({ position: { x: 196, y: 147 } });
await expect(canvas).toHaveScreenshot("numeric-editing.png", { animations: "disabled" });
await canvas.press("Escape");
await canvas.click({ position: { x: 1003, y: 147 } });
await expect(canvas).toHaveScreenshot("color-picker.png", { animations: "disabled" });
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html>
<head>
<script type="importmap">
{ "imports": { "@lib/": "/src/" } }
</script>
</head>
<body>
<canvas id="application-a" width="360" height="220" style="width: 360px; height: 220px"></canvas>
<canvas id="application-b" width="360" height="220" style="width: 360px; height: 220px"></canvas>
<template id="add-node-menu-template">
<div data-fxnode-add-menu style="position: fixed; visibility: hidden">
<section role="dialog" aria-label="Add node">
<input data-fxnode-menu-search type="search" role="combobox" aria-label="Search nodes" />
<div data-fxnode-menu-results role="listbox">
<section data-fxnode-menu-group role="group">
<h2 data-fxnode-menu-heading>Test</h2>
<button data-fxnode-menu-option data-type-id="alpha-node" type="button" role="option">Alpha Node</button>
</section>
<div data-fxnode-menu-empty role="status" hidden>No nodes found</div>
</div>
</section>
</div>
</template>
<script type="module" src="./worker-composition.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+372
View File
@@ -0,0 +1,372 @@
import { createFxNode, type FxNode, type FxNodeCompositionData, type FxNodeView } from "@lib/index.js";
import { prepareFxNodeBrowserHost, type PreparedFxNodeBrowserHost } from "../../examples/shared/browser-host.js";
import type { FxNodeResourceOpenRequest } from "@lib/index.js";
import type { GraphState } from "@lib/core/types.js";
const color = "#202124" as const;
const theme = {
background: color,
grid: color,
frame: color,
frameHeader: color,
body: "#35383e",
control: color,
controlFill: color,
controlEditing: color,
textSelection: color,
outline: "#101114",
text: "#eeeeee",
muted: "#999999",
shadow: "#000000",
nodeSelected: "#ed5700",
nodeActive: "#ed5700",
unknownHeader: "#555555",
unknownSocket: "#777777",
linkMuted: "#d94b4b",
knifeMuted: "#e85b5b",
emphasis: "#ffffff",
focus: "#f5a623",
editOutline: "#666a70",
resize: "#8b8e95",
muteOverlay: "#14141459",
boxSelectionFill: "#f5a6231f",
checkerLight: "#aaaaaa",
checkerDark: "#777777",
widgetBorder: "#111216",
rampBorder: "#111111",
resourceBackground: "#202228",
} as const;
const composition = (
id: string,
version: number,
nodeType: string,
title: string,
header: `#${string}`,
maxBytes: number,
maxWidth: number,
accept: string,
) =>
({
schemaVersion: 2,
id,
version,
compatibility: { wildcardInputTypes: [] },
theme,
socketTypes: { signal: { title: "Signal", color: header, acceptsFrom: ["signal"] } },
nodeStyles: { application: { header } },
resources: {
image: {
kind: "image",
title: "Image",
openTitle: "Open",
accept: [accept],
referencePrefix: `${id}-image:`,
maxBytes,
maxWidth,
maxHeight: 4,
maxPixels: maxWidth * 4,
},
strictImage: {
kind: "image",
title: "Strict Image",
openTitle: "Open",
accept: [accept],
referencePrefix: `${id}-image:`,
maxBytes,
maxWidth: 1,
maxHeight: 1,
maxPixels: 1,
},
},
nodes: {
[nodeType]: {
version: 1,
title,
behavior: "standard",
style: "application",
parameters: { image: { type: "string", default: { kind: "string", value: "" } } },
sockets: {
output: {
title: "Output",
direction: "output",
type: "signal",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
},
},
ui: [
{ kind: "resource", resource: "image", parameter: "image" },
{ kind: "socket", socket: "output" },
],
muteBypass: [],
migrations: [],
},
[`${nodeType}-strict`]: {
version: 1,
title: `${title} Strict`,
behavior: "standard",
style: "application",
parameters: { image: { type: "string", default: { kind: "string", value: "" } } },
sockets: {
output: {
title: "Output",
direction: "output",
type: "signal",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
},
},
ui: [
{ kind: "resource", resource: "strictImage", parameter: "image" },
{ kind: "socket", socket: "output" },
],
muteBypass: [],
migrations: [],
},
},
}) as unknown as FxNodeCompositionData;
export const compositionA = composition(
"application-a",
11,
"alpha-node",
"Alpha Node",
"#9b59b6",
512,
1,
"image/png",
);
export const compositionB = composition(
"application-b",
22,
"beta-node",
"Beta Node",
"#2e86de",
2048,
4,
"image/webp",
);
export const migrationComposition = {
...compositionA,
id: "worker-migration-custom",
version: 73,
nodes: {
"odd-migrator": {
...compositionA.nodes["alpha-node"]!,
version: 2,
parameters: { level: { type: "number", default: { kind: "number", value: 5 } } },
sockets: {
source: {
title: "Source",
direction: "output",
type: "signal",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
},
sink: {
title: "Sink",
direction: "input",
type: "signal",
maxIncomingLinks: 4,
visible: true,
value: null,
showValue: false,
},
},
ui: [
{ kind: "parameter", parameter: "level" },
{ kind: "socket", socket: "source" },
{ kind: "socket", socket: "sink" },
],
migrations: [
{
fromVersion: 1,
toVersion: 2,
steps: [
{ kind: "rename-parameter", from: "oldLevel", to: "level" },
{ kind: "rename-socket", from: "oldSource", to: "source" },
{ kind: "rename-socket", from: "oldSink", to: "sink" },
],
},
],
},
},
} as unknown as FxNodeCompositionData;
const layout = (id: string, version: number) => ({
schemaVersion: 2 as const,
graphId: id,
catalogVersion: version,
nodes: [],
links: [],
metadata: {},
});
const migrationLayout = {
schemaVersion: 2 as const,
graphId: "worker-historical",
catalogVersion: 1,
nodes: [
{
id: "left",
typeId: "odd-migrator",
typeVersion: 1,
position: { x: 0, y: 0 },
size: { x: 180, y: 190 },
label: "",
parameters: { oldLevel: { kind: "number", value: 9 } },
sockets: [
{
id: "left:oldSource",
key: "oldSource",
label: "Source",
direction: "output",
dataType: "signal",
accepts: [],
maxIncomingLinks: 0,
visible: true,
},
{
id: "left:oldSink",
key: "oldSink",
label: "Sink",
direction: "input",
dataType: "signal",
accepts: ["signal"],
maxIncomingLinks: 4,
visible: true,
},
],
muted: false,
collapsed: false,
extensions: {},
},
{
id: "right",
typeId: "odd-migrator",
typeVersion: 1,
position: { x: 300, y: 0 },
size: { x: 180, y: 190 },
label: "",
parameters: { oldLevel: { kind: "number", value: 3 } },
sockets: [
{
id: "right:oldSource",
key: "oldSource",
label: "Source",
direction: "output",
dataType: "signal",
accepts: [],
maxIncomingLinks: 0,
visible: true,
},
{
id: "right:oldSink",
key: "oldSink",
label: "Sink",
direction: "input",
dataType: "signal",
accepts: ["signal"],
maxIncomingLinks: 4,
visible: true,
},
],
muted: false,
collapsed: false,
extensions: {},
},
],
links: [
{
id: "historical-link",
fromNodeId: "left",
fromSocketId: "left:oldSource",
toNodeId: "right",
toSocketId: "right:oldSink",
muted: false,
extensions: { kept: true },
},
],
metadata: { fixture: "migration" },
};
const hosts = new WeakMap<FxNode, PreparedFxNodeBrowserHost>(),
views = new WeakMap<FxNode, FxNodeView>();
const create = async <C extends FxNodeCompositionData>(
canvas: HTMLCanvasElement,
value: C,
initialLayout: unknown = layout(value.id, value.version),
activateResourcePicker?: (request: FxNodeResourceOpenRequest) => void,
) => {
const host = prepareFxNodeBrowserHost({
canvas,
addNodeMenuTemplate: document.querySelector<HTMLTemplateElement>("#add-node-menu-template")!,
...(activateResourcePicker ? { activateResourcePicker } : {}),
});
let api: FxNode | undefined;
const { id, version, resources } = value;
try {
api = await createFxNode({
applicationId: id,
applicationVersion: version,
resources,
});
await api.loadComposition(value as never, { expectedRevision: 0 });
if (initialLayout === migrationLayout) await api.load(initialLayout);
const view = await api.attachView({ canvas, viewport: host.initialViewport });
host.attach(api, view);
hosts.set(api, host);
views.set(api, view);
return api;
} catch (error) {
host.destroy();
api?.destroy();
throw error;
}
};
const destroy = (api: FxNode) => {
hosts.get(api)?.destroy();
hosts.delete(api);
views.delete(api);
api.destroy();
};
type Harness = {
readonly compositionA: typeof compositionA;
readonly compositionB: typeof compositionB;
createA(activateResourcePicker?: (request: FxNodeResourceOpenRequest) => void): Promise<FxNode>;
createB(activateResourcePicker?: (request: FxNodeResourceOpenRequest) => void): Promise<FxNode>;
createMigration(): Promise<FxNode>;
createRaw(canvas: HTMLCanvasElement, value: FxNodeCompositionData): Promise<FxNode>;
view(api: FxNode): FxNodeView;
destroy(api: FxNode): void;
};
(window as unknown as { workerCompositionTest: Harness }).workerCompositionTest = {
compositionA,
compositionB,
createA: (activate) =>
create(document.querySelector<HTMLCanvasElement>("#application-a")!, compositionA, undefined, activate),
createB: (activate) =>
create(document.querySelector<HTMLCanvasElement>("#application-b")!, compositionB, undefined, activate),
createMigration: () =>
create(document.querySelector<HTMLCanvasElement>("#application-a")!, migrationComposition, migrationLayout),
createRaw: async (canvas, value) => {
const api = await create(canvas, compositionA);
try {
await api.loadComposition(value, { expectedRevision: 1 });
return api;
} catch (error) {
destroy(api);
throw error;
}
},
view: (api) => {
const view = views.get(api);
if (!view) throw new Error("FxNode test view is unavailable");
return view;
},
destroy,
};
+156
View File
@@ -0,0 +1,156 @@
import { expect, test } from "@playwright/test";
test("real worker supports a browser root with no attached views", async ({ page }) => {
await page.goto("/test/browser/client-runtime.html");
const result = await page.evaluate(async () => {
const { createFxNode } = (await import("/src/index.ts" as string)) as typeof import("@lib/index.js");
const { minimalStyles, numberSocket, valueNode } = (await import(
"../../examples/minimal/definition.js"
)) as typeof import("../../examples/minimal/definition.js");
const root = await createFxNode({ applicationId: "worker-headless", applicationVersion: 1, resources: {} });
await root.setHeaderStyles(minimalStyles);
await root.composeSocket(...numberSocket);
await root.composeNode(...valueNode);
const added = await root.dispatch({
type: "node.add",
nodeId: "headless-node" as never,
nodeType: valueNode[0],
position: { x: 12, y: 34 },
});
const state = await root.getState(),
saved = await root.getSaveData(),
undone = await root.undo(),
empty = await root.getState();
root.destroy();
return {
addedVersion: added.version,
undoneVersion: undone.version,
node: state.nodes[0],
journalLength: saved.commands.length,
emptyCount: empty.nodes.length,
};
});
expect(result.addedVersion).toBe(1);
expect(result.undoneVersion).toBe(2);
expect(result.node).toMatchObject({ id: "headless-node", position: { x: 12, y: 34 } });
expect(result.journalLength).toBe(1);
expect(result.emptyCount).toBe(0);
});
test("real worker shares graph history while views keep camera and selection", async ({ page }) => {
await page.goto("/test/browser/client-runtime.html");
const result = await page.evaluate(async () => {
const { createFxNode } = (await import("/src/index.ts" as string)) as typeof import("@lib/index.js");
const { minimalStyles, numberSocket, valueNode } = (await import(
"../../examples/minimal/definition.js"
)) as typeof import("../../examples/minimal/definition.js");
const api = await createFxNode({ applicationId: "worker-multiview", applicationVersion: 1, resources: {} });
await api.setHeaderStyles(minimalStyles);
await api.composeSocket(...numberSocket);
await api.composeNode(...valueNode);
const makeCanvas = () => {
const canvas = document.createElement("canvas"),
context = canvas.getContext("2d")!;
canvas.width = 400;
canvas.height = 200;
let frames = 0;
const drawImage = context.drawImage.bind(context);
context.drawImage = ((...args: Parameters<CanvasRenderingContext2D["drawImage"]>) => {
frames++;
Reflect.apply(drawImage, context, args);
}) as CanvasRenderingContext2D["drawImage"];
return { canvas, frames: () => frames };
};
const firstCanvas = makeCanvas(),
secondCanvas = makeCanvas(),
first = await api.attachView({
canvas: firstCanvas.canvas,
viewport: { width: 400, height: 200, dpr: 1 },
initialCamera: { center: { x: 1_000, y: -200 }, zoom: 2 },
}),
second = await api.attachView({
canvas: secondCanvas.canvas,
viewport: { width: 400, height: 200, dpr: 1 },
});
await Promise.all([first.whenRendered(), second.whenRendered()]);
const beforeRootFrames = [firstCanvas.frames(), secondCanvas.frames()];
const rootAdd = await api.dispatch({
type: "node.add",
nodeId: "root-node" as never,
nodeType: valueNode[0],
position: { x: 0, y: 0 },
});
const deadline = performance.now() + 2_000;
while (
performance.now() < deadline &&
(firstCanvas.frames() <= beforeRootFrames[0]! || secondCanvas.frames() <= beforeRootFrames[1]!)
)
await new Promise((resolve) => setTimeout(resolve, 16));
const rootSelections = [first.getHostSnapshot().selection.nodeCount, second.getHostSnapshot().selection.nodeCount];
const firstAdd = await first.addNode({
typeId: valueNode[0],
nodeId: "first-node",
viewPosition: { x: 300, y: 50 },
});
const afterFirstSelection = [
first.getHostSnapshot().selection.nodeCount,
second.getHostSnapshot().selection.nodeCount,
];
const secondAdd = await second.addNode({
typeId: valueNode[0],
nodeId: "second-node",
viewPosition: { x: 200, y: 100 },
});
const afterSecondSelection = [
first.getHostSnapshot().selection.nodeCount,
second.getHostSnapshot().selection.nodeCount,
];
const mute = await first.setSelectedMuted(true),
remove = await second.removeSelected(),
afterActions = await api.getState(),
undo = await api.undo(),
afterUndo = await api.getState(),
redo = await api.redo(),
afterRedo = await api.getState();
const firstNode = afterActions.nodes.find((node) => node.id === "first-node");
await first.detach();
await second.detach();
api.destroy();
return {
rootSelections,
afterFirstSelection,
afterSecondSelection,
rootRenderedBoth: firstCanvas.frames() > beforeRootFrames[0]! && secondCanvas.frames() > beforeRootFrames[1]!,
firstPosition: firstNode?.position,
firstMuted: firstNode?.muted,
secondRemoved: !afterActions.nodes.some((node) => node.id === "second-node"),
undoRestoredSecond: afterUndo.nodes.some((node) => node.id === "second-node"),
redoRemovedSecond: !afterRedo.nodes.some((node) => node.id === "second-node"),
versions: [
rootAdd.version,
firstAdd.version,
secondAdd.version,
mute.version,
remove.version,
undo.version,
redo.version,
],
};
});
expect(result.rootSelections).toEqual([0, 0]);
expect(result.afterFirstSelection).toEqual([1, 0]);
expect(result.afterSecondSelection).toEqual([1, 1]);
expect(result.rootRenderedBoth).toBe(true);
expect(result.firstPosition).toEqual({ x: 1_050, y: -175 });
expect(result.firstMuted).toBe(true);
expect(result.secondRemoved).toBe(true);
expect(result.undoRestoredSecond).toBe(true);
expect(result.redoRemovedSecond).toBe(true);
expect(result.versions).toEqual([1, 2, 3, 4, 5, 6, 7]);
});
+116
View File
@@ -0,0 +1,116 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
addRampMidpoint,
addRampStop,
distributeColorRamp,
flipColorRamp,
isColorRamp,
migrateColorRamp,
moveRampStop,
removeRampStop,
sampleColorRamp,
setRampColor,
type ColorRamp,
} from "@lib/widgets/color-ramp.js";
const DEFAULT_COLOR_RAMP: ColorRamp = {
colorMode: "rgb",
interpolation: "linear",
hueInterpolation: "near",
stops: [
{ id: "stop-0", position: 0, color: [0, 0, 0, 1] },
{ id: "stop-1", position: 1, color: [1, 1, 1, 1] },
],
};
import { layoutGraph as genericLayoutGraph } from "@lib/layout/layout-graph.js";
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js";
const layoutGraph = (
document: Parameters<typeof genericLayoutGraph>[1],
transform: Parameters<typeof genericLayoutGraph>[2],
) => genericLayoutGraph(APPLICATION_COMPILED, document, transform);
const { materializeNode } = APPLICATION_HEADLESS;
test("Color Ramp migration, validation and stable pure operations", () => {
const migrated = migrateColorRamp([
{ position: 0, color: [0, 0, 0, 1] },
{ position: 0.5, color: [1, 0, 0, 1] },
{ position: 1, color: [1, 1, 1, 1] },
])!;
assert.deepEqual(
migrated.stops.map((s) => s.id),
["stop-0", "stop-1", "stop-2"],
);
assert.equal(isColorRamp(migrated), true);
const added = addRampStop(migrated, 0.25, "worker-1");
assert.equal(added.stops.find((s) => s.id === "worker-1")?.color[0], 0.5);
assert.equal(addRampMidpoint(migrated, "stop-1", "worker-2").stops.find((s) => s.id === "worker-2")?.position, 0.25);
assert.deepEqual(
moveRampStop(migrated, "stop-0", 0.75).stops.map((s) => s.id),
["stop-1", "stop-0", "stop-2"],
);
assert.equal(removeRampStop(DEFAULT_COLOR_RAMP, "stop-0"), DEFAULT_COLOR_RAMP);
assert.deepEqual(
distributeColorRamp(migrated).stops.map((s) => s.position),
[0, 0.5, 1],
);
assert.deepEqual(
flipColorRamp(migrated).stops.map((s) => s.position),
[0, 0.5, 1],
);
assert.deepEqual(sampleColorRamp({ ...DEFAULT_COLOR_RAMP, interpolation: "constant" }, 0.5), [0, 0, 0, 1]);
});
test("Color Ramp public operations preserve validity for empty IDs and non-finite numbers", () => {
for (const position of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
assert.equal(addRampStop(DEFAULT_COLOR_RAMP, position, "new"), DEFAULT_COLOR_RAMP);
assert.equal(moveRampStop(DEFAULT_COLOR_RAMP, "stop-0", position), DEFAULT_COLOR_RAMP);
assert.deepEqual(sampleColorRamp(DEFAULT_COLOR_RAMP, position), DEFAULT_COLOR_RAMP.stops[0]!.color);
}
assert.equal(addRampStop(DEFAULT_COLOR_RAMP, 0.5, ""), DEFAULT_COLOR_RAMP);
for (let channel = 0; channel < 4; channel++) {
const color = [0, 0, 0, 1] as number[];
color[channel] = Number.NaN;
assert.equal(
setRampColor(DEFAULT_COLOR_RAMP, "stop-0", color as [number, number, number, number]),
DEFAULT_COLOR_RAMP,
);
}
assert.equal(isColorRamp(DEFAULT_COLOR_RAMP), true);
});
test("Noise Texture exhaustive Blender 4.5 visibility matrix and immediate height", () => {
const dimensions = ["1d", "2d", "3d", "4d"],
types = ["fbm", "multifractal", "hybrid-multifractal", "ridged-multifractal", "hetero-terrain"];
for (const dimension of dimensions)
for (const noiseType of types) {
const base = materializeNode("noise", "fxnode.shader.noise-texture");
const node = {
...base,
parameters: {
...base.parameters,
dimensions: { kind: "string" as const, value: dimension },
noiseType: { kind: "string" as const, value: noiseType },
},
};
const document = {
schemaVersion: 2 as const,
graphId: "g" as never,
catalogVersion: 2,
nodes: { noise: node },
links: {},
metadata: {},
};
const layout = layoutGraph(document, { center: { x: 0, y: 0 }, zoom: 1, viewport: { x: 800, y: 600 }, dpr: 1 });
const keys = new Set([...layout.sockets.values()].map((s) => s.id.split(":").at(-1)));
assert.equal(keys.has("vector"), dimension !== "1d");
assert.equal(keys.has("w"), dimension === "1d" || dimension === "4d");
assert.equal(
keys.has("offset"),
["hybrid-multifractal", "ridged-multifractal", "hetero-terrain"].includes(noiseType),
);
assert.equal(keys.has("gain"), ["hybrid-multifractal", "ridged-multifractal"].includes(noiseType));
const hasNormalize = layout.controls.has("noise:parameter:normalize");
assert.equal(hasNormalize, noiseType === "fbm");
assert.ok(layout.nodes.get("noise" as never)!.bounds.height > 0);
}
});
+327
View File
@@ -0,0 +1,327 @@
import assert from "node:assert/strict";
import test from "node:test";
import { validCommand, validFxNodeReplayCommand } from "@lib/commands/validate.js";
import { commandId, linkId, nodeId, socketId } from "@lib/core/types.js";
import { saveCompositionCompatibility } from "@lib/composition/save-compatibility.js";
import { createFxNodeHeadless } from "@lib/headless-runtime.js";
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js";
import type { FxNodeCompositionData } from "@lib/composition/index.js";
const composition = {
...APPLICATION_COMPILED.source,
id: "command-log-test",
version: 7,
nodes: { value: APPLICATION_COMPILED.source.nodes["fxnode.shader.value"]! },
};
const runtime = createFxNodeHeadless(composition);
const save = (commands: readonly unknown[], baseline: unknown = runtime.save(runtime.emptyDocument())) => ({
kind: "fxnode.command-log",
schemaVersion: 2,
composition,
baseline,
commands,
});
test("command validators are total and replay is forward-only", () => {
const hostile = new Proxy(
{},
{
ownKeys() {
throw new Error("hostile");
},
},
);
assert.doesNotThrow(() => validCommand(hostile));
assert.equal(validCommand(hostile), false);
assert.equal(validCommand({ type: "undo" }), true);
assert.equal(validFxNodeReplayCommand({ type: "undo" }), false);
assert.equal(
validFxNodeReplayCommand({ type: "batch", commands: [{ type: "node.move", id: "n", position: { x: 1, y: 2 } }] }),
true,
);
});
test("replay commits atomically once, preserves bounded staged history, and accepts empty logs", () => {
const original = runtime.createEngine(runtime.emptyDocument(), 1),
commands = [
{ type: "node.add", nodeId: nodeId("n"), nodeType: "value", position: { x: 0, y: 0 } } as const,
{ type: "node.move", id: nodeId("n"), position: { x: 2, y: 3 } } as const,
];
const result = runtime.replaySaveData(original, save(commands), 0, commandId("restore"));
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.status, "committed");
if (result.status !== "committed") return;
assert.equal(Object.isFrozen(result.saveData), true);
assert.notEqual(result.saveData, save(commands));
assert.equal(result.state.version, 1);
assert.deepEqual(result.state.document.nodes.n!.position, { x: 2, y: 3 });
assert.equal(result.state.undo.length, 1);
assert.equal(result.state.redo.length, 0);
assert.equal(result.mutationEnvelope.cause, "load");
assert.equal(result.mutationEnvelope.mutations.length, 1);
assert.equal(result.mutationEnvelope.mutations[0]?.kind, "document.replaced");
const empty = runtime.replaySaveData(result.state, save([]));
assert.equal(empty.ok, true);
if (empty.ok) {
assert.equal(empty.state.version, 2);
assert.equal(empty.state.undo.length, 0);
}
});
test("replay rejection, noop, composition mismatch, and stale source inspection are atomic", () => {
const state = runtime.createEngine(runtime.emptyDocument());
for (const data of [
save([{ type: "node.remove", id: "missing" }]),
save([{ type: "batch", commands: [] }]),
{ ...save([]), composition: { ...composition, id: "other" } },
]) {
const result = runtime.replaySaveData(state, data);
assert.equal(result.ok, false);
if (!result.ok) assert.equal(result.state, state);
}
const noop = runtime.replaySaveData(state, save([{ type: "batch", commands: [] }]));
assert.equal(noop.ok, false);
if (!noop.ok) {
assert.equal(noop.issues[0]?.code, "replay.noop");
assert.equal(noop.issues[0]?.path, "/commands/0");
}
let inspected = false;
const hostile = new Proxy(
{},
{
ownKeys() {
inspected = true;
throw new Error("inspected");
},
},
);
const stale = runtime.replaySaveData(state, hostile, 1);
assert.equal(stale.ok, false);
assert.equal(inspected, false);
if (!stale.ok) {
assert.equal(stale.state, state);
assert.equal(stale.issues[0]?.code, "version.stale");
}
const unchanged = runtime.replaySaveData(state, save([]));
assert.equal(unchanged.ok, true);
if (unchanged.ok) {
assert.equal(unchanged.status, "noop");
assert.equal(unchanged.state.version, state.version);
assert.equal(unchanged.state.document, state.document);
}
});
test("persisted commands reject malformed durable values and links atomically", () => {
const state = runtime.createEngine(runtime.emptyDocument());
const link = {
id: "l",
fromNodeId: "a",
fromSocketId: "a:o",
toNodeId: "b",
toSocketId: "b:i",
muted: false,
extensions: {},
};
const malformed = [
{ type: "link.add", link: { ...link, muted: 0 } },
{ type: "link.add", link: { ...link, extensions: { bad: undefined } } },
{ type: "node.parameter", id: "n", key: "value", value: { kind: "number", value: "1" } },
{ type: "node.socket-default", id: "n", socketId: "n:o", value: { kind: "vector", value: [1, 2] } },
{ type: "node.add", nodeId: "n", nodeType: "not-bound", position: { x: 0, y: 0 } },
];
for (const command of malformed) {
const result = runtime.replaySaveData(state, save([command]));
assert.equal(result.ok, false);
if (!result.ok) {
assert.equal(result.state, state);
assert.match(result.issues[0]?.path ?? "", /^\/commands\/0/);
}
}
});
test("command and baseline admissions have independent budgets and require V2", () => {
const state = runtime.createEngine(runtime.emptyDocument()),
baseline = runtime.save(runtime.emptyDocument());
const large = { ...baseline, metadata: { values: Array.from({ length: 100_001 }, () => 0) } };
assert.equal(runtime.replaySaveData(state, save([], large)).ok, true);
const commands = Array.from({ length: 1_001 }, () => ({ type: "node.remove", id: "n" }));
const over = runtime.replaySaveData(state, save(commands, baseline));
assert.equal(over.ok, false);
if (!over.ok) assert.equal(over.state, state);
const v1 = runtime.replaySaveData(state, save([], { ...baseline, schemaVersion: 1 }));
assert.equal(v1.ok, false);
if (!v1.ok) {
assert.equal(v1.state, state);
assert.equal(v1.issues[0]?.code, "baseline.schema");
}
});
test("save schema v1 and future versions have distinct structured errors", () => {
const state = runtime.createEngine(runtime.emptyDocument());
for (const [schemaVersion, code] of [
[1, "save.schema.unsupported"],
[3, "save.schema.future"],
] as const) {
const result = runtime.replaySaveData(state, { ...save([]), schemaVersion });
assert.equal(result.ok, false);
if (!result.ok) {
assert.equal(result.issues[0]?.code, code);
assert.equal(result.issues[0]?.path, "/schemaVersion");
assert.equal(result.state, state);
}
}
});
test("save decode alone normalizes embedded schema-1 composition menus", () => {
const state = runtime.createEngine(runtime.emptyDocument());
const legacyComposition = {
...composition,
schemaVersion: 1,
menuGroups: { legacy: { title: "Legacy", order: 0 } },
nodes: Object.fromEntries(
Object.entries(composition.nodes).map(([id, node]) => [
id,
{ ...node, menu: { kind: "entry", group: "legacy", order: 0, keywords: [] } },
]),
),
};
const result = runtime.replaySaveData(state, { ...save([]), composition: legacyComposition });
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.saveData.composition.schemaVersion, 2);
assert.equal("menuGroups" in result.saveData.composition, false);
assert.equal("menu" in result.saveData.composition.nodes.value!, false);
assert.equal(validateLegacyRaw(legacyComposition), false);
for (const invalid of [
{ ...legacyComposition, menuGroups: undefined },
{ ...legacyComposition, nodes: { ...legacyComposition.nodes, value: composition.nodes.value } },
{
...legacyComposition,
nodes: {
...legacyComposition.nodes,
value: { ...legacyComposition.nodes.value, menu: { kind: "entry", group: "missing", order: 0, keywords: [] } },
},
},
{ ...legacyComposition, unexpected: true },
]) {
const rejected = runtime.replaySaveData(state, { ...save([]), composition: invalid });
assert.equal(rejected.ok, false);
}
});
const validateLegacyRaw = (value: unknown) => {
try {
createFxNodeHeadless(value as FxNodeCompositionData);
return true;
} catch {
return false;
}
};
test("composition compatibility accepts true supersets and rejects changed replay semantics", () => {
const extra = APPLICATION_COMPILED.source.nodes["fxnode.shader.color"]!,
presentation = {
...composition,
version: 8,
theme: { ...composition.theme, background: "#123456" as const },
nodes: { ...composition.nodes, extra },
};
const reordered = {
...presentation,
nodes: {
...presentation.nodes,
value: { ...presentation.nodes.value!, ui: [...presentation.nodes.value!.ui].reverse() },
},
};
const baseline = runtime.save(runtime.emptyDocument());
assert.deepEqual(saveCompositionCompatibility(composition, reordered, baseline), []);
const supersetRuntime = createFxNodeHeadless(reordered),
accepted = supersetRuntime.replaySaveData(supersetRuntime.createEngine(supersetRuntime.emptyDocument()), save([]));
assert.equal(accepted.ok, true);
const changed = {
...presentation,
nodes: {
...presentation.nodes,
value: {
...presentation.nodes.value!,
parameters: {
value: { ...presentation.nodes.value!.parameters.value!, default: { kind: "number" as const, value: 42 } },
},
},
},
};
const semantic = saveCompositionCompatibility(composition, changed as FxNodeCompositionData, baseline);
assert.equal(semantic[0]?.code, "composition.incompatible");
assert.equal(semantic[1]?.code, "composition.node-semantic");
assert.equal(semantic[1]?.path, "/composition/nodes/value");
const missing = { ...presentation, nodes: { extra } };
const absent = saveCompositionCompatibility(composition, missing, baseline);
assert.equal(absent[0]?.code, "composition.incompatible");
assert.equal(absent[1]?.code, "composition.definition-missing");
const forged = {
...save([{ type: "node.add", nodeId: "x", nodeType: "extra", position: { x: 0, y: 0 } }]),
composition,
};
const rejected = supersetRuntime.replaySaveData(
supersetRuntime.createEngine(supersetRuntime.emptyDocument()),
forged,
);
assert.equal(rejected.ok, false);
if (!rejected.ok) assert.equal(rejected.issues[0]?.code, "command.invalid");
});
test("compatibility guards opaque promotion and caps structured issues", () => {
const extra = APPLICATION_COMPILED.source.nodes["fxnode.shader.color"]!,
current = { ...composition, nodes: { ...composition.nodes, opaque: extra } };
const opaque = { ...runtime.save(runtime.emptyDocument()), nodes: [{ id: "o", typeId: "opaque" }] as never };
const promoted = saveCompositionCompatibility(composition, current, opaque);
assert.equal(promoted[0]?.code, "composition.incompatible");
assert.equal(promoted[1]?.code, "composition.opaque-promotion");
assert.equal(promoted[1]?.path, "/baseline/nodes/0/typeId");
const many = Object.fromEntries(
Array.from({ length: 150 }, (_, i) => [`style-${i}`, { header: "#000000" }]),
) as unknown as typeof composition.nodeStyles,
withMany = { ...composition, nodeStyles: many };
const capped = saveCompositionCompatibility(
withMany,
{ ...composition, nodeStyles: {} },
runtime.save(runtime.emptyDocument()),
);
assert.equal(capped.length, 100);
});
test("trusted journal validation rejects executable commands outside the durable schema", () => {
const full = createFxNodeHeadless(APPLICATION_COMPILED.source),
from = full.materializeNode("from", "fxnode.shader.value"),
to = full.materializeNode("to", "fxnode.shader.math"),
baseline = full.save({ ...full.emptyDocument(), nodes: { from, to } }),
link = {
id: linkId("l"),
fromNodeId: nodeId("from"),
fromSocketId: socketId("from:value"),
toNodeId: nodeId("to"),
toSocketId: socketId("to:a"),
muted: false,
extensions: {},
};
assert.equal(full.validateReplayJournal(baseline, [{ type: "link.add", link }]), true);
assert.equal(
full.validateReplayJournal(baseline, [{ type: "link.add", link: { ...link, transient: "not durable" } as never }]),
false,
);
});
test("replay rejects a command whose resulting aggregate exceeds persistence closure", () => {
const node = runtime.materializeNode("n", "value"),
baseline = runtime.save({ ...runtime.emptyDocument(), nodes: { n: node } });
const baselineText = "b".repeat(8_388_350),
commandText = "c".repeat(1_048_554);
const result = runtime.replaySaveData(
runtime.createEngine(runtime.emptyDocument()),
save([{ type: "node.label", id: "n", label: commandText }], { ...baseline, metadata: { text: baselineText } }),
);
assert.equal(result.ok, false);
if (!result.ok) assert.equal(result.state.document.nodes.n, undefined);
});
+578
View File
@@ -0,0 +1,578 @@
import assert from "node:assert/strict";
import test from "node:test";
import type {
createFxNode as CreateFxNode,
FxNode,
FxNodeView,
FxNodeViewOptions,
FxNodeCompositionSeed,
NodeParameterId,
NodeSocketId,
NodeStyleId,
NodeTypeId,
ResourceId,
SocketTypeId,
Themed,
RemovedNode,
RemovedSocket,
} from "@lib/index.js";
import { compileFxNodeComposition } from "@lib/composition/compile.js";
import {
composeNode,
composeSocket,
removeNode,
removeSocket,
setHeaderStyles,
setTheme,
} from "@lib/composition/compose.js";
import { APPLICATION_ID, APPLICATION_RESOURCES, APPLICATION_VERSION } from "../examples/blender/nodes/application.js";
import type {
createApplicationFxNode as CreateApplicationFxNode,
ApplicationFxNode,
} from "../examples/blender/application-browser.js";
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
type Expect<T extends true> = T;
const theme = {
background: "#000",
grid: "#000",
frame: "#000",
frameHeader: "#000",
body: "#000",
control: "#000",
controlFill: "#000",
controlEditing: "#000",
textSelection: "#000",
outline: "#000",
text: "#000",
muted: "#000",
shadow: "#000",
nodeSelected: "#000",
nodeActive: "#000",
unknownHeader: "#000",
unknownSocket: "#000",
linkMuted: "#000",
knifeMuted: "#000",
emphasis: "#000",
focus: "#000",
editOutline: "#000",
resize: "#000",
muteOverlay: "#000",
boxSelectionFill: "#000",
checkerLight: "#000",
checkerDark: "#000",
widgetBorder: "#000",
rampBorder: "#000",
resourceBackground: "#000",
} as const;
const valid = {
schemaVersion: 2,
id: "types",
version: 1,
compatibility: { wildcardInputTypes: [] },
theme,
socketTypes: { number: { title: "Number", color: "#abc", acceptsFrom: ["number"] } },
nodeStyles: { basic: { header: "#abc" } },
resources: {
preview: {
kind: "image",
title: "Preview",
openTitle: "Open",
accept: ["image/png"],
referencePrefix: "test-image:",
maxBytes: 100_000_000,
maxWidth: 10_000,
maxHeight: 3,
maxPixels: 100_000_000,
},
},
nodes: {
math: {
version: 2,
title: "Math",
behavior: "standard",
style: "basic",
parameters: {
amount: { type: "number", default: { kind: "number", value: 1 }, precision: 2 },
ramp: { type: "json", codec: "color-ramp/v1", default: { kind: "json", value: null } },
},
sockets: {
input: {
title: "In",
direction: "input",
type: "number",
maxIncomingLinks: 1,
visible: true,
value: null,
showValue: false,
},
output: {
title: "Out",
direction: "output",
type: "number",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
},
},
ui: [
{ kind: "parameter", parameter: "amount", visibleWhen: { all: [{ parameter: "amount", equals: 1 }] } },
{ kind: "widget", widget: "color-ramp", parameter: "ramp" },
{ kind: "resource", resource: "preview", parameter: "amount" },
],
muteBypass: [["input", "output"]],
migrations: [
{
fromVersion: 1,
toVersion: 2,
steps: [
{ kind: "materialize-missing", target: "parameter", key: "amount" },
{ kind: "rename-socket", from: "old", to: "input" },
],
},
],
},
other: {
version: 1,
title: "Other",
behavior: "standard",
style: "basic",
parameters: { label: { type: "string", default: { kind: "string", value: "" } } },
sockets: {
result: {
title: "Result",
direction: "output",
type: "number",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
},
},
ui: [
{ kind: "parameter", parameter: "label" },
{ kind: "socket", socket: "result" },
],
muteBypass: [],
migrations: [],
},
},
} as const;
type _N = Expect<Equal<NodeTypeId<typeof valid>, "math" | "other">>;
type _T = Expect<Equal<SocketTypeId<typeof valid>, "number">>;
type _S = Expect<Equal<NodeStyleId<typeof valid>, "basic">>;
type _R = Expect<Equal<ResourceId<typeof valid>, "preview">>;
type _P = Expect<Equal<NodeParameterId<typeof valid, "math">, "amount" | "ramp">>;
type _K = Expect<Equal<NodeSocketId<typeof valid, "math">, "input" | "output">>;
type _OP = Expect<Equal<NodeParameterId<typeof valid, "other">, "label">>;
type _OK = Expect<Equal<NodeSocketId<typeof valid, "other">, "result">>;
if (false) {
const createFxNode = null as unknown as typeof CreateFxNode;
const compiled = compileFxNodeComposition(valid);
const restyled = setHeaderStyles(valid, { alternate: { header: "#123456" } });
type _HeaderStyle = Expect<Equal<NodeStyleId<typeof restyled>, "alternate">>;
void (null as _HeaderStyle | null);
const typeId = compiled.nodes.get("math")?.typeId;
type _CN = Expect<Equal<Exclude<typeof typeId, undefined>, "math" | "other">>;
void (null as _CN | null);
const requestedBytes = compiled.source.resources.preview.maxBytes;
type _RequestedBytes = Expect<Equal<typeof requestedBytes, 100_000_000>>;
void (null as _RequestedBytes | null);
const effectiveBytes = compiled.resources.get("preview")!.maxBytes;
type _EffectiveBytes = Expect<Equal<typeof effectiveBytes, number>>;
void (null as _EffectiveBytes | null);
// @ts-expect-error exact compiled node keys
compiled.nodes.get("unknown");
const canvas = null as unknown as HTMLCanvasElement;
const viewport = { width: 1, height: 1, dpr: 1 };
type _ApplicationApi = Expect<Equal<Awaited<ReturnType<typeof CreateApplicationFxNode>>, ApplicationFxNode>>;
void (null as _ApplicationApi | null);
const options = {
applicationId: APPLICATION_ID,
applicationVersion: APPLICATION_VERSION,
resources: APPLICATION_RESOURCES,
};
const nodeless = createFxNode(options);
void nodeless;
// @ts-expect-error application identity, version, and resources are mandatory
void createFxNode({});
// @ts-expect-error constructor composition was removed
void createFxNode({ ...options, composition: valid });
// @ts-expect-error constructor graph layout was removed
void createFxNode({ ...options, layout: {} });
// @ts-expect-error canvases belong to attached views, not root construction
void createFxNode({ ...options, canvas });
// @ts-expect-error viewports belong to attached views, not root construction
void createFxNode({ ...options, viewport });
const apiPromise = createFxNode(options);
type _Api = Expect<Equal<Awaited<typeof apiPromise>, FxNode>>;
void (null as _Api | null);
void apiPromise.then((api) => {
const viewOptions: FxNodeViewOptions = {
canvas,
viewport,
initialCamera: { center: { x: 0, y: 0 }, zoom: 1 },
};
api.attachView(viewOptions).then((view) => {
type _View = Expect<Equal<typeof view, FxNodeView>>;
void (null as _View | null);
view.feedInput({ kind: "focus", phase: "focus" });
view.setViewport(viewport);
view.getHostSnapshot().selection.nodeCount;
const unsubscribeHost = view.subscribeHost(() => view.getHostSnapshot());
const unsubscribeRequests = view.onHostRequests((request) => void request);
void view.addNode({ typeId: "math", viewPosition: { x: 0, y: 0 } });
void view.removeSelected();
void view.setSelectedMuted(true);
void view.provideResource(
{ viewId: view.id, token: "token", graphVersion: 0, compositionRevision: 0 },
{ name: "image.png", mime: "image/png", bytes: new ArrayBuffer(1) },
);
unsubscribeHost();
unsubscribeRequests();
void view.whenRendered();
void view.detach();
view.id.length;
// @ts-expect-error a view ID cannot be replaced by consumers
view.id = "replacement";
// @ts-expect-error graph authority remains on the root
view.dispatch({ type: "undo" });
// @ts-expect-error graph snapshots remain on the root
view.getState();
// @ts-expect-error composition authority remains on the root
view.setTheme(theme);
// @ts-expect-error composition authority remains on the root
view.composeNode("other", valid.nodes.other);
});
// @ts-expect-error canvas is required
api.attachView({ viewport });
// @ts-expect-error viewport is required
api.attachView({ canvas });
// @ts-expect-error view IDs are internal
api.attachView({ canvas, viewport, viewId: "caller-owned" });
// @ts-expect-error view-only operation
api.feedInput({ kind: "focus", phase: "focus" });
// @ts-expect-error old copy presentation API was removed
api.copyTo(canvas);
// @ts-expect-error old mirror presentation API was removed
api.addMirror(canvas);
// @ts-expect-error old mirror presentation API was removed
api.removeMirror(canvas);
// @ts-expect-error rendering barriers belong to views
api.whenRendered();
api.setCompatibility({ wildcardInputTypes: ["any"] });
api.dispatch({ type: "node.add", nodeType: "math", position: { x: 0, y: 0 } });
api.getState().then((snapshot) => {
const known = snapshot.nodes.find((node) => node.known);
if (known) {
type _Known = Expect<Equal<typeof known.typeId, string>>;
void (null as _Known | null);
}
});
api.onSnapshots((event) => {
const known = event.snapshot.nodes.find((node) => node.known);
if (known) {
type _Known = Expect<Equal<typeof known.typeId, string>>;
void (null as _Known | null);
}
});
api.onMutations((event) => {
for (const mutation of event.mutations)
if (mutation.kind === "node.set" && mutation.after?.known) {
type _Known = Expect<Equal<typeof mutation.after.typeId, string>>;
void (null as _Known | null);
}
});
// @ts-expect-error live composition validates definition-local parameter references
void api.composeNode("bad-live-node", { ...valid.nodes.other, ui: [{ kind: "parameter", parameter: "missing" }] });
void api
.composeSocket("dynamic", { title: "Dynamic", color: "#abcdef", acceptsFrom: ["dynamic"] } as const)
.then(async (receipt) => {
await api.composeNode("dynamic-node", {
version: 1,
title: "Dynamic Node",
behavior: "standard",
style: "basic",
parameters: {},
sockets: {
out: {
title: "Out",
direction: "output",
type: "dynamic",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
},
},
ui: [{ kind: "socket", socket: "out" }],
muteBypass: [],
migrations: [],
});
api.dispatch({ type: "node.add", nodeType: "dynamic-node", position: { x: 0, y: 0 } });
await api.removeNode("dynamic-node");
void receipt.revision;
});
api.onCompositionChanges((event) => {
event.change.kind;
event.revision;
event.graphVersion;
// @ts-expect-error composition events never expose definitions
event.change.definition;
});
});
}
test("public composition definition preserves identity", () => assert.equal(valid.id, "types"));
const seed = {
schemaVersion: 2,
id: "composed-types",
version: 1,
compatibility: { wildcardInputTypes: [] },
nodeStyles: { basic: { header: "#abc" } },
resources: {},
socketTypes: {},
nodes: {},
} as const satisfies FxNodeCompositionSeed;
const themed = setTheme(seed, theme);
const scalar = composeSocket(themed, "scalar", { title: "Scalar", color: "#abc", acceptsFrom: ["scalar"] });
const vector = composeSocket(scalar, "vector", { title: "Vector", color: "#def", acceptsFrom: ["vector", "scalar"] });
const composed = composeNode(vector, "constant", {
version: 1,
title: "Constant",
behavior: "standard",
style: "basic",
parameters: { value: { type: "number", default: { kind: "number", value: 1 } } },
sockets: {
out: {
title: "Out",
direction: "output",
type: "scalar",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
},
},
ui: [
{ kind: "parameter", parameter: "value" },
{ kind: "socket", socket: "out" },
],
muteBypass: [],
migrations: [],
});
const overridden = composeNode(composed, "constant", {
...composed.nodes.constant,
title: "Replacement",
sockets: {
result: {
title: "Result",
direction: "output",
type: "vector",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
},
},
ui: [
{ kind: "parameter", parameter: "value" },
{ kind: "socket", socket: "result" },
],
muteBypass: [],
});
const noNode = removeNode(overridden, "constant"),
noVector = removeSocket(vector, "vector");
type _ComposedSockets = Expect<Equal<SocketTypeId<typeof vector>, "scalar" | "vector">>;
type _ComposedNodes = Expect<Equal<NodeTypeId<typeof composed>, "constant">>;
type _OverriddenSocket = Expect<Equal<NodeSocketId<typeof overridden, "constant">, "result">>;
type _RemovedNode = Expect<Equal<NodeTypeId<typeof noNode>, never>>;
type _RemovedSocket = Expect<Equal<SocketTypeId<typeof noVector>, "scalar">>;
type _ThemedExport = Expect<Equal<typeof themed, Themed<typeof seed, typeof theme>>>;
type _RemovedNodeExport = Expect<Equal<typeof noNode, RemovedNode<typeof overridden, "constant">>>;
type _RemovedSocketExport = Expect<Equal<typeof noVector, RemovedSocket<typeof vector, "vector">>>;
void (null as
| _ComposedSockets
| _ComposedNodes
| _OverriddenSocket
| _RemovedNode
| _RemovedSocket
| _ThemedExport
| _RemovedNodeExport
| _RemovedSocketExport
| null);
if (false) {
// @ts-expect-error acceptsFrom may only reference itself or a composed socket type
composeSocket(scalar, "bad", { title: "Bad", color: "#000", acceptsFrom: ["missing"] });
// @ts-expect-error removeNode only accepts a currently composed node ID
removeNode(composed, "missing");
// @ts-expect-error removeSocket only accepts a currently composed socket ID
removeSocket(vector, "missing");
// @ts-expect-error composeNode validates style references against the current composition
composeNode(vector, "bad", { ...composed.nodes.constant, style: "missing" });
composeNode(vector, "bad-size", {
...composed.nodes.constant,
// @ts-expect-error node dimensions are calculated internally
defaultSize: { x: 1, y: 1 },
});
compileFxNodeComposition(composed);
// Every representable reference and finite schema discriminator is checked below.
compileFxNodeComposition({
...valid,
socketTypes: {
number: {
...valid.socketTypes.number,
// @ts-expect-error unknown acceptsFrom socket type
acceptsFrom: ["missing"],
},
},
});
compileFxNodeComposition({
...valid,
nodes: {
math: {
...valid.nodes.math,
// @ts-expect-error unknown node socket type
sockets: { ...valid.nodes.math.sockets, input: { ...valid.nodes.math.sockets.input, type: "missing" } },
},
},
});
compileFxNodeComposition({
...valid,
nodes: {
math: {
...valid.nodes.math,
// @ts-expect-error unknown style
style: "missing",
},
},
});
compileFxNodeComposition({
...valid,
nodes: {
math: {
...valid.nodes.math,
// @ts-expect-error unknown resource
ui: [{ kind: "resource", resource: "missing", parameter: "amount" }],
},
},
});
compileFxNodeComposition({
...valid,
nodes: {
math: {
...valid.nodes.math,
// @ts-expect-error unknown UI parameter
ui: [{ kind: "parameter", parameter: "missing" }],
},
},
});
compileFxNodeComposition({
...valid,
nodes: {
math: {
...valid.nodes.math,
// @ts-expect-error unknown UI socket
ui: [{ kind: "socket", socket: "missing" }],
},
},
});
compileFxNodeComposition({
...valid,
nodes: {
math: {
...valid.nodes.math,
// @ts-expect-error unknown nested visibility parameter
ui: [{ kind: "parameter", parameter: "amount", visibleWhen: { any: [{ parameter: "missing", equals: 1 }] } }],
},
},
});
compileFxNodeComposition({
...valid,
nodes: {
math: {
...valid.nodes.math,
// @ts-expect-error unknown grading scalar/color references
ui: [
{
kind: "widget",
widget: "grading-wheels",
bindings: [
{ title: "A", scalar: "missing", color: "amount" },
{ title: "B", scalar: "amount", color: "amount" },
{ title: "C", scalar: "amount", color: "amount" },
],
},
],
},
},
});
compileFxNodeComposition({
...valid,
nodes: {
math: {
...valid.nodes.math,
// @ts-expect-error unknown mute bypass socket
muteBypass: [["missing", "output"]],
},
},
});
compileFxNodeComposition({
...valid,
nodes: {
math: {
...valid.nodes.math,
// @ts-expect-error unknown migration current-side parameter
migrations: [
{ fromVersion: 1, toVersion: 2, steps: [{ kind: "rename-parameter", from: "old", to: "missing" }] },
],
},
},
});
compileFxNodeComposition({
...valid,
nodes: {
math: {
...valid.nodes.math,
// @ts-expect-error unknown migration current-side socket
migrations: [
{ fromVersion: 1, toVersion: 2, steps: [{ kind: "materialize-missing", target: "socket", key: "missing" }] },
],
},
},
});
compileFxNodeComposition({
...valid,
nodes: {
math: {
...valid.nodes.math,
// @ts-expect-error unsupported widget
ui: [{ kind: "widget", widget: "dial", parameter: "amount" }],
},
},
});
compileFxNodeComposition({
...valid,
nodes: {
math: {
...valid.nodes.math,
migrations: [
{
fromVersion: 1,
toVersion: 2,
steps: [
{
kind: "migrate-parameter",
parameter: "amount",
// @ts-expect-error unsupported migration codec
codec: "unknown",
},
],
},
],
},
},
});
}
+627
View File
@@ -0,0 +1,627 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
compileFxNodeComposition,
composeNode,
composeSocket,
FXNODE_COMPOSITION_LIMITS,
FxNodeCompositionError,
removeNode,
removeSocket,
setTheme,
validateFxNodeComposition,
type FxNodeCompositionSeed,
} from "@lib/composition/index.js";
const theme = {
background: "#112233",
grid: "#112233",
frame: "#112233",
frameHeader: "#112233",
body: "#112233",
control: "#112233",
controlFill: "#112233",
controlEditing: "#112233",
textSelection: "#112233",
outline: "#112233",
text: "#112233",
muted: "#112233",
shadow: "#112233",
nodeSelected: "#112233",
nodeActive: "#112233",
unknownHeader: "#112233",
unknownSocket: "#112233",
linkMuted: "#112233",
knifeMuted: "#112233",
emphasis: "#112233",
focus: "#112233",
editOutline: "#112233",
resize: "#112233",
muteOverlay: "#112233",
boxSelectionFill: "#112233",
checkerLight: "#112233",
checkerDark: "#112233",
widgetBorder: "#112233",
rampBorder: "#112233",
resourceBackground: "#112233",
} as const;
const socket = (
direction: "input" | "output",
type: "scalar" | "wide",
value: null | { type: "number"; default: { kind: "number"; value: number } } = null,
) => ({
title: direction,
direction,
type,
maxIncomingLinks: direction === "input" ? 1 : 0,
visible: true,
value: direction === "input" ? value : null,
showValue: direction === "input" && value !== null,
});
const valid = () =>
({
schemaVersion: 2,
id: "studio",
version: 4,
compatibility: { wildcardInputTypes: [] },
theme,
socketTypes: {
scalar: { title: "Scalar", color: "#abcdef", acceptsFrom: ["scalar"] },
wide: { title: "Wide", color: "#fedcba", acceptsFrom: ["wide", "scalar"] },
},
nodeStyles: { grade: { header: "#123456" }, utility: { header: "#654321" } },
resources: {
image: {
kind: "image",
title: "Image",
openTitle: "Open",
accept: ["image/png", "image/jpeg"],
referencePrefix: "test-image:",
maxBytes: 1024,
maxWidth: 64,
maxHeight: 64,
maxPixels: 4096,
},
},
nodes: {
grade: {
version: 3,
title: "Grade",
behavior: "standard",
style: "grade",
parameters: {
exposure: {
type: "number",
default: { kind: "number", value: 0 },
minimum: -10,
maximum: 10,
step: 0.1,
precision: 2,
},
mode: { type: "string", default: { kind: "string", value: "film" }, enum: ["film", "raw"] },
file: { type: "string", default: { kind: "string", value: "" } },
tint: { type: "color", default: { kind: "color", value: [1, 1, 1, 1] }, minimum: 0, maximum: 1 },
ramp: {
type: "json",
codec: "color-ramp/v1",
default: {
kind: "json",
value: {
colorMode: "rgb",
interpolation: "linear",
hueInterpolation: "near",
stops: [
{ id: "a", position: 0, color: [0, 0, 0, 1] },
{ id: "b", position: 1, color: [1, 1, 1, 1] },
],
},
},
},
lift: { type: "number", default: { kind: "number", value: 0 } },
gamma: { type: "number", default: { kind: "number", value: 1 } },
gain: { type: "number", default: { kind: "number", value: 1 } },
liftColor: { type: "color", default: { kind: "color", value: [0, 0, 0, 1] } },
gammaColor: { type: "color", default: { kind: "color", value: [0.5, 0.5, 0.5, 1] } },
gainColor: { type: "color", default: { kind: "color", value: [1, 1, 1, 1] } },
},
sockets: {
input: socket("input", "wide", { type: "number", default: { kind: "number", value: 0 } }),
output: socket("output", "scalar"),
secret: socket("input", "scalar"),
},
ui: [
{
kind: "text",
variant: "header",
title: "Color",
visibleWhen: {
all: [
{ parameter: "mode", equals: "film" },
{
any: [
{ parameter: "exposure", in: [0, 1] },
{ parameter: "mode", equals: "raw" },
],
},
],
},
},
{ kind: "parameter", parameter: "exposure" },
{ kind: "parameter", parameter: "mode" },
{ kind: "resource", resource: "image", parameter: "file" },
{ kind: "parameter", parameter: "tint" },
{ kind: "widget", widget: "color-ramp", parameter: "ramp" },
{
kind: "widget",
widget: "grading-wheels",
bindings: [
{ title: "Lift", scalar: "lift", color: "liftColor" },
{ title: "Gamma", scalar: "gamma", color: "gammaColor" },
{ title: "Gain", scalar: "gain", color: "gainColor" },
],
},
{ kind: "socket", socket: "input" },
{ kind: "socket", socket: "output" },
{ kind: "hidden", target: "socket", socket: "secret" },
],
muteBypass: [["input", "output"]],
migrations: [
{
fromVersion: 1,
toVersion: 3,
steps: [
{ kind: "materialize-missing", target: "parameter", key: "exposure" },
{ kind: "migrate-parameter", parameter: "ramp", codec: "color-ramp/legacy-stops" },
{ kind: "rename-socket", from: "source", to: "input" },
],
},
],
},
constant: {
version: 1,
title: "Constant",
behavior: "standard",
style: "utility",
parameters: { value: { type: "number", default: { kind: "number", value: 1 }, integer: true } },
sockets: { out: socket("output", "scalar") },
ui: [
{ kind: "parameter", parameter: "value" },
{ kind: "socket", socket: "out" },
],
muteBypass: [],
migrations: [],
},
internal: {
version: 1,
title: "Internal",
behavior: "reroute",
style: "utility",
parameters: {},
sockets: {},
ui: [],
muteBypass: [],
migrations: [],
},
},
}) as const;
const clone = () => structuredClone(valid());
function issue(value: unknown, code: string, path: string) {
const r = validateFxNodeComposition(value);
assert.equal(r.ok, false);
if (!r.ok)
assert(
r.issues.some((x) => x.code === code && x.path === path),
`${code} ${path}\n${JSON.stringify(r.issues)}`,
);
}
test("valid fixture compiles without menu projection", () => {
const v = valid();
assert.equal(validateFxNodeComposition(v).ok, true);
const c = compileFxNodeComposition(v);
assert.equal("menuEntries" in c, false);
});
test("composition authoring helpers are immutable, replaceable, removable and clone-safe", () => {
const seed = {
schemaVersion: 2,
id: "helpers",
version: 1,
compatibility: { wildcardInputTypes: [] },
nodeStyles: { utility: { header: "#654321" } },
resources: {},
socketTypes: {},
nodes: {},
} as const satisfies FxNodeCompositionSeed;
const themed = setTheme(seed, theme);
assert.equal("theme" in seed, false);
assert.notEqual(themed, seed);
const rethemed = setTheme(themed, { ...theme, grid: "#abcdef" });
assert.equal(themed.theme.grid, "#112233");
assert.equal(rethemed.theme.grid, "#abcdef");
const sockets = composeSocket(themed, "scalar", { title: "Scalar", color: "#abcdef", acceptsFrom: ["scalar"] });
assert.deepEqual(seed.socketTypes, {});
assert.notEqual(sockets.socketTypes, seed.socketTypes);
const definition = {
version: 1,
title: "Constant",
behavior: "standard",
style: "utility",
parameters: {},
sockets: {
out: {
title: "Out",
direction: "output",
type: "scalar",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
},
},
ui: [{ kind: "socket", socket: "out" }],
muteBypass: [],
migrations: [],
} as const;
const nodes = composeNode(sockets, "constant", definition),
replacement = composeNode(nodes, "constant", { ...definition, title: "Replacement" });
assert.equal(nodes.nodes.constant.title, "Constant");
assert.equal(replacement.nodes.constant.title, "Replacement");
assert.deepEqual(sockets.nodes, {});
assert.doesNotThrow(() => structuredClone(replacement));
assert.equal(validateFxNodeComposition(replacement).ok, true);
const withoutNode = removeNode(replacement, "constant");
assert.equal("constant" in withoutNode.nodes, false);
assert.equal("constant" in replacement.nodes, true);
const dangling = removeSocket(replacement, "scalar"),
checked = validateFxNodeComposition(dangling);
assert.equal(checked.ok, false);
if (!checked.ok) assert(checked.issues.some((issue) => issue.code === "reference.socketType"));
const withSize: any = structuredClone(replacement);
withSize.nodes.constant.defaultSize = { x: 1, y: 1 };
issue(withSize, "shape.unknown", "/nodes/constant/defaultSize");
});
test("compilations are independent immutable clones and expose readonly facades", () => {
const a: any = clone(),
b: any = clone();
a.id = "same";
b.id = "same";
const ca = compileFxNodeComposition(a),
cb = compileFxNodeComposition(b);
assert.notEqual(ca.source, cb.source);
a.nodes.grade.parameters.exposure.default.value = 9;
assert.equal(ca.source.nodes.grade.parameters.exposure.default.value, 0);
assert.equal(ca.nodes.get("grade")?.typeId, "grade");
assert(Object.isFrozen(ca));
assert(Object.isFrozen(ca.source));
assert(Object.isFrozen(ca.source.nodes.grade.parameters));
for (const facade of [ca.nodes, ca.socketTypes, ca.styles, ca.resources])
for (const method of ["set", "delete", "clear"]) assert.equal(method in facade, false);
let leaked: unknown = "unset";
const size = ca.nodes.size;
ca.nodes.forEach(function () {
leaked = arguments[2];
});
assert.equal(leaked, undefined);
assert.equal(ca.nodes.size, size);
});
test("compiled image policies preserve requested source limits and expose effective ceilings", () => {
const source: any = clone();
source.resources.image = {
...source.resources.image,
maxBytes: 100_000_000,
maxWidth: 10_000,
maxHeight: 3,
maxPixels: 100_000_000,
};
const compiled = compileFxNodeComposition(source);
assert.deepEqual(
{
maxBytes: compiled.source.resources.image.maxBytes,
maxWidth: compiled.source.resources.image.maxWidth,
maxHeight: compiled.source.resources.image.maxHeight,
maxPixels: compiled.source.resources.image.maxPixels,
},
{ maxBytes: 100_000_000, maxWidth: 10_000, maxHeight: 3, maxPixels: 100_000_000 },
);
const effective = compiled.resources.get("image")!;
assert.deepEqual(
{
maxBytes: effective.maxBytes,
maxWidth: effective.maxWidth,
maxHeight: effective.maxHeight,
maxPixels: effective.maxPixels,
},
{ maxBytes: 33_554_432, maxWidth: 8192, maxHeight: 3, maxPixels: 24_576 },
);
});
type Mutator = (x: any) => void;
const cases: [string, string, Mutator][] = [
["reference.style", "/nodes/grade/style", (x) => (x.nodes.grade.style = "no")],
["reference.socketType", "/socketTypes/wide/acceptsFrom/0", (x) => (x.socketTypes.wide.acceptsFrom[0] = "no")],
["reference.socketType", "/nodes/grade/sockets/input/type", (x) => (x.nodes.grade.sockets.input.type = "no")],
["reference.resource", "/nodes/grade/ui/3/resource", (x) => (x.nodes.grade.ui[3].resource = "no")],
["reference.parameter", "/nodes/grade/ui/1/parameter", (x) => (x.nodes.grade.ui[1].parameter = "no")],
["reference.socket", "/nodes/grade/ui/7/socket", (x) => (x.nodes.grade.ui[7].socket = "no")],
[
"reference.parameter",
"/nodes/grade/ui/0/visibleWhen/all/0/parameter",
(x) => (x.nodes.grade.ui[0].visibleWhen.all[0].parameter = "no"),
],
[
"schema.bounds",
"/nodes/grade/parameters/exposure/minimum",
(x) => (x.nodes.grade.parameters.exposure.minimum = 20),
],
[
"schema.integer",
"/nodes/constant/parameters/value/integer",
(x) => (x.nodes.constant.parameters.value.integer = "yes"),
],
[
"schema.precision",
"/nodes/grade/parameters/exposure/precision",
(x) => (x.nodes.grade.parameters.exposure.precision = 21),
],
[
"value.duplicate",
"/nodes/grade/parameters/mode/enum",
(x) => (x.nodes.grade.parameters.mode.enum = ["film", "film"]),
],
[
"schema.enum",
"/nodes/grade/parameters/mode/default/value",
(x) => (x.nodes.grade.parameters.mode.default.value = "no"),
],
[
"schema.bounds",
"/nodes/grade/parameters/tint/default/value",
(x) => (x.nodes.grade.parameters.tint.default.value[0] = 2),
],
[
"schema.bounds",
"/nodes/grade/parameters/exposure/default/value",
(x) => (x.nodes.grade.parameters.exposure.default.value = 20),
],
[
"socket.links",
"/nodes/grade/sockets/input/maxIncomingLinks",
(x) => (x.nodes.grade.sockets.input.maxIncomingLinks = 0),
],
["socket.output", "/nodes/grade/sockets/output", (x) => (x.nodes.grade.sockets.output.showValue = true)],
[
"socket.value",
"/nodes/grade/sockets/input/value",
(x) => {
x.nodes.grade.sockets.input.value = null;
x.nodes.grade.sockets.input.showValue = true;
},
],
["ui.widget", "/nodes/grade/ui/5/widget", (x) => (x.nodes.grade.ui[5].widget = "dial")],
["ui.widget", "/nodes/grade/ui/5/parameter", (x) => (x.nodes.grade.ui[5].parameter = "exposure")],
["ui.widget", "/nodes/grade/ui/6/bindings/0/scalar", (x) => (x.nodes.grade.ui[6].bindings[0].scalar = "liftColor")],
["ui.widget", "/nodes/grade/ui/6/bindings/0/color", (x) => (x.nodes.grade.ui[6].bindings[0].color = "lift")],
["ui.resource", "/nodes/grade/ui/3/parameter", (x) => (x.nodes.grade.ui[3].parameter = "exposure")],
["ui.placement", "/nodes/grade/parameters/exposure", (x) => x.nodes.grade.ui.splice(1, 1)],
["ui.placement", "/nodes/grade/ui/2", (x) => (x.nodes.grade.ui[2] = { kind: "parameter", parameter: "exposure" })],
["socket.bypass", "/nodes/grade/muteBypass/0", (x) => (x.nodes.grade.muteBypass = [["output", "input"]])],
[
"socket.compatibility",
"/nodes/grade/muteBypass/0",
(x) => {
x.nodes.grade.sockets.input.type = "scalar";
x.nodes.grade.sockets.output.type = "wide";
},
],
["value.duplicate", "/nodes/grade/muteBypass/1", (x) => x.nodes.grade.muteBypass.push(["input", "output"])],
["limit.resource", "/resources/image/maxWidth", (x) => (x.resources.image.maxWidth = 0)],
["shape.string", "/resources/image/referencePrefix", (x) => (x.resources.image.referencePrefix = "bad")],
["migration.edge", "/nodes/grade/migrations/0", (x) => (x.nodes.grade.migrations[0].toVersion = 4)],
[
"migration.outgoing",
"/nodes/grade/migrations/1/fromVersion",
(x) => x.nodes.grade.migrations.push({ fromVersion: 1, toVersion: 2, steps: [] }),
],
[
"reference.migrationTarget",
"/nodes/grade/migrations/0/steps/0/key",
(x) => (x.nodes.grade.migrations[0].steps[0].key = "no"),
],
[
"migration.codec",
"/nodes/grade/migrations/0/steps/1/codec",
(x) => (x.nodes.grade.migrations[0].steps[1].codec = "no"),
],
[
"migration.rename",
"/nodes/grade/migrations/0/steps/3/from",
(x) => x.nodes.grade.migrations[0].steps.push({ kind: "rename-socket", from: "source", to: "output" }),
],
["shape.literal", "/nodes/grade/behavior", (x) => (x.nodes.grade.behavior = ["standard"])],
[
"socket.direction",
"/nodes/grade/sockets/input/direction",
(x) => (x.nodes.grade.sockets.input.direction = ["input"]),
],
["ui.text", "/nodes/grade/ui/0/variant", (x) => (x.nodes.grade.ui[0].variant = ["header"])],
["shape.string", "/nodes/grade/ui/1/parameter", (x) => (x.nodes.grade.ui[1].parameter = 0)],
["shape.string", "/nodes/grade/ui/3/resource", (x) => (x.nodes.grade.ui[3].resource = 0)],
["ui.target", "/nodes/grade/ui/9/target", (x) => (x.nodes.grade.ui[9].target = "bogus")],
["ui.visibility", "/nodes/grade/ui/0/visibleWhen/all", (x) => (x.nodes.grade.ui[0].visibleWhen = { all: [] })],
["ui.visibility", "/nodes/grade/ui/0/visibleWhen/any", (x) => (x.nodes.grade.ui[0].visibleWhen = { any: [] })],
[
"migration.codec",
"/nodes/grade/migrations/0/steps/1/parameter",
(x) => (x.nodes.grade.migrations[0].steps[1].parameter = "exposure"),
],
];
test("semantic invalid cases report precise codes and paths", () => {
for (const [code, path, mutate] of cases) {
const x: any = clone();
mutate(x);
issue(x, code, path);
}
});
test("hostile structures fail without executing code or throwing", () => {
for (const array of [false, true]) {
let calls = 0;
const x: any = clone();
if (array)
Object.defineProperty(x.nodes.grade.ui, 0, {
get() {
calls++;
return {};
},
enumerable: true,
});
else
Object.defineProperty(x, "id", {
get() {
calls++;
return "x";
},
enumerable: true,
});
issue(x, "data.inspect", array ? "/nodes/grade/ui/0" : "/id");
assert.equal(calls, 0);
}
const hostile: [unknown, string, string][] = [
[Object.assign(clone(), { [Symbol("x")]: 1 }), "data.symbol", ""],
[Object.assign(clone(), { id: new (class X {})() }), "data.type", "/id"],
[Object.assign(clone(), { id: new Date() }), "data.type", "/id"],
[Object.assign(clone(), { id: new Map() }), "data.type", "/id"],
[Object.assign(clone(), { id: new ArrayBuffer(1) }), "data.type", "/id"],
[Object.assign(clone(), { id: 1n }), "data.type", "/id"],
[Object.assign(clone(), { id: undefined }), "data.type", "/id"],
];
for (const [x, c, p] of hostile) issue(x, c, p);
const sparse: any = clone();
sparse.nodes.grade.ui.length++;
issue(sparse, "data.array", "/nodes/grade/ui/10");
const keyed: any = [];
Object.defineProperty(keyed, "x".repeat(FXNODE_COMPOSITION_LIMITS.maxStringCodeUnits + 1), {
value: null,
enumerable: true,
});
issue(keyed, "limit.strings", "");
const shared: any = clone();
shared.extra = shared.theme;
issue(shared, "data.identity", "/extra");
const cycle: any = clone();
cycle.extra = cycle;
issue(cycle, "data.identity", "/extra");
const revoked = Proxy.revocable({}, {});
revoked.revoke();
for (const proxy of [
new Proxy(
{},
{
ownKeys() {
throw Error("no");
},
},
),
revoked.proxy,
])
assert.doesNotThrow(() => issue(proxy, "data.inspect", ""));
const np = Object.assign(Object.create(null), clone());
const r = validateFxNodeComposition(np);
assert.equal(r.ok, true);
if (r.ok) assert.equal(r.value.id, "studio");
});
test("collection, visibility, and issue caps", () => {
const limits: [string, string, (x: any) => void][] = [
[
"limit.collection",
"/nodes",
(x) => {
const n = x.nodes.internal;
x.nodes = {};
for (let i = 0; i <= FXNODE_COMPOSITION_LIMITS.maxNodes; i++) x.nodes[`n${i}`] = structuredClone(n);
},
],
[
"limit.collection",
"/socketTypes",
(x) => {
for (let i = 0; i <= FXNODE_COMPOSITION_LIMITS.maxSocketTypes; i++)
x.socketTypes[`s${i}`] = { title: "S", color: "#abcdef", acceptsFrom: [] };
},
],
[
"limit.ui",
"/nodes/grade/ui",
(x) => {
x.nodes.grade.ui = Array.from({ length: 257 }, () => ({ kind: "text", variant: "header", title: "x" }));
},
],
[
"limit.enum",
"/nodes/grade/parameters/mode/enum",
(x) => (x.nodes.grade.parameters.mode.enum = Array.from({ length: 257 }, (_, i) => `v${i}`)),
],
[
"limit.migrations",
"/nodes/grade/migrations",
(x) =>
(x.nodes.grade.migrations = Array.from({ length: 65 }, (_, i) => ({
fromVersion: i + 1,
toVersion: i + 2,
steps: [],
}))),
],
[
"limit.migrations",
"/nodes/grade/migrations/0/steps",
(x) =>
(x.nodes.grade.migrations[0].steps = Array.from({ length: 129 }, () => ({
kind: "materialize-missing",
target: "parameter",
key: "exposure",
}))),
],
[
"limit.visibility",
"/nodes/grade/ui/0/visibleWhen/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0/all/0",
(x) => {
let v: any = { parameter: "mode", equals: "film" };
for (let i = 0; i < 17; i++) v = { all: [v] };
x.nodes.grade.ui[0].visibleWhen = v;
},
],
];
for (const [c, p, m] of limits) {
const x: any = clone();
m(x);
issue(x, c, p);
}
for (const target of ["top", "node"]) {
const x: any = clone();
const o = target === "top" ? x : x.nodes.grade;
for (let i = 0; i < 110; i++) o[`unknown${i}`] = i;
const r = validateFxNodeComposition(x);
assert.equal(r.ok, false);
if (!r.ok) {
assert.equal(r.issues.length, FXNODE_COMPOSITION_LIMITS.maxIssues);
assert(r.issues.every((i) => i.code === "shape.unknown"));
}
}
});
test("compile error carries frozen issues", () => {
try {
compileFxNodeComposition({ ...valid(), schemaVersion: 1 } as unknown as ReturnType<typeof valid>);
assert.fail("expected error");
} catch (e) {
assert(e instanceof FxNodeCompositionError);
assert(Object.isFrozen(e.issues));
assert(Object.isFrozen(e.issues[0]));
assert(e.issues.some((i) => i.path === "/schemaVersion"));
}
});
+71
View File
@@ -0,0 +1,71 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
clampNumber,
cycleEnum,
numericStep,
scrubValue,
setNumericComponent,
snapNumber,
} from "@lib/worker/control-edit.js";
import type { LayoutControl } from "@lib/layout/types.js";
const numberControl: LayoutControl = {
id: "number",
nodeId: "node" as LayoutControl["nodeId"],
source: "parameter",
key: "value",
label: "Value",
kind: "number",
value: { kind: "number", value: 0 },
schema: { type: "number", default: { kind: "number", value: 0 }, minimum: -1, maximum: 1, step: 0.25 },
linked: false,
bounds: { x: 0, y: 0, width: 10, height: 10 },
subfields: [],
numericFields: [],
};
test("control edit helpers clamp, snap and apply fine scrub", () => {
assert.equal(clampNumber(2, numberControl.schema), 1);
assert.equal(snapNumber(0.38, numberControl.schema), 0.5);
assert.deepEqual(scrubValue(numberControl, { kind: "number", value: 0 }, 0, 5, true, false), {
kind: "number",
value: 0.05,
});
assert.deepEqual(scrubValue(numberControl, { kind: "number", value: 0 }, 0, 3.8, false, true), {
kind: "number",
value: 0.5,
});
assert.deepEqual(setNumericComponent(numberControl, { kind: "number", value: 0 }, 0, 2), {
kind: "number",
value: 1,
});
assert.equal(numericStep(numberControl, false), 0.25);
assert.equal(numericStep(numberControl, true), 0.025);
});
test("vector/color edits preserve other components and enum cycling wraps", () => {
const vector = {
...numberControl,
kind: "vector" as const,
schema: { type: "vector" as const, default: { kind: "vector" as const, value: [0, 0, 0] as const } },
};
assert.deepEqual(scrubValue(vector, { kind: "vector", value: [1, 2, 3] }, 1, 10, false, false), {
kind: "vector",
value: [1, 3, 3],
});
assert.deepEqual(setNumericComponent(vector, { kind: "vector", value: [1, 2, 3] }, 1, 4), {
kind: "vector",
value: [1, 4, 3],
});
const color = {
...numberControl,
kind: "color" as const,
schema: { type: "color" as const, default: { kind: "color" as const, value: [0, 0, 0, 1] as const } },
};
assert.deepEqual(scrubValue(color, { kind: "color", value: [0, 0.5, 0, 1] }, 1, 10, false, false), {
kind: "color",
value: [0, 1, 0, 1],
});
assert.equal(cycleEnum(["a", "b"], "b", 1), "a");
});
+397
View File
@@ -0,0 +1,397 @@
import test from "node:test";
import assert from "node:assert/strict";
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js";
import { commandId, linkId, nodeId, socketId } from "@lib/core/types.js";
import { reduceMutations } from "@lib/engine/reducer.js";
import { minimumNodeSize } from "@lib/layout/node-dimensions.js";
import { validCommand } from "@lib/commands/validate.js";
import { planSelectionMute, planSelectionRemoval } from "@lib/worker/selection-actions.js";
const {
createEngine,
decodeGraphDocument,
emptyDocument,
materializeNode,
save,
serializeGraphDocument,
getState,
transition,
} = APPLICATION_HEADLESS;
const BUILTIN_DESCRIPTORS = Object.freeze([...APPLICATION_COMPILED.nodes.values()]);
const CATALOG_NODE_IDS = [...APPLICATION_COMPILED.nodes.keys()];
import type { EngineState, Command, CommandRequest } from "@lib/headless.js";
type ApplicationEngineState = EngineState<typeof APPLICATION_COMPILED.source>;
let sequence = 0;
function run(state: ApplicationEngineState, command: Command) {
const request: CommandRequest = {
commandId: commandId(`command-${++sequence}`),
expectedVersion: state.version,
source: "api",
command,
};
return transition(state, request);
}
function committed(state: ApplicationEngineState, command: Command): ApplicationEngineState {
const result = run(state, command);
assert.equal(result.status, "committed");
if (result.status !== "committed") throw new Error("expected commit");
return result.state;
}
test("catalog has exact, frozen coverage and every built-in materializes generic initial state", () => {
const expectedTypeIds = [
"fxnode.common.frame",
"fxnode.common.reroute",
"fxnode.common.group-input",
"fxnode.common.group-output",
"fxnode.shader.value",
"fxnode.shader.color",
"fxnode.shader.math",
"fxnode.shader.vector-math",
"fxnode.shader.mix",
"fxnode.shader.color-ramp",
"fxnode.shader.texture-coordinate",
"fxnode.shader.noise-texture",
"fxnode.shader.image-texture",
"fxnode.shader.principled-bsdf",
"fxnode.shader.material-output",
"fxnode.geometry.position",
"fxnode.geometry.mesh-cube",
"fxnode.geometry.set-position",
"fxnode.geometry.transform-geometry",
"fxnode.geometry.join-geometry",
"fxnode.compositor.image",
"fxnode.compositor.color-balance",
] as const;
assert.deepEqual(
BUILTIN_DESCRIPTORS.map((item) => item.typeId),
expectedTypeIds,
);
assert.deepEqual(CATALOG_NODE_IDS, expectedTypeIds);
assert.equal(BUILTIN_DESCRIPTORS.length, 22);
assert.ok(BUILTIN_DESCRIPTORS.every(Object.isFrozen));
assert.ok(Object.isFrozen(BUILTIN_DESCRIPTORS));
assert.ok(Object.isFrozen(BUILTIN_DESCRIPTORS[0]!.sockets));
assert.ok(Object.isFrozen(BUILTIN_DESCRIPTORS[4]!.parameters));
for (const [index, typeId] of expectedTypeIds.entries()) {
const node = materializeNode(`catalog-${index}`, typeId, { x: index, y: -index });
assert.equal(node.typeId, typeId);
assert.deepEqual(node.position, { x: index, y: -index });
assert.equal(
node.size.y,
typeId === "fxnode.common.reroute" ? 10 : Math.max(100, minimumNodeSize(BUILTIN_DESCRIPTORS[index]!, node).y),
);
assert.equal(node.known, true);
assert.equal(node.muted, false);
assert.equal(node.collapsed, false);
assert.equal(node.parentId, undefined);
assert.deepEqual(Object.keys(node.extensions), []);
assert.ok(Object.isFrozen(node));
assert.ok(Object.isFrozen(node.parameters));
assert.ok(Object.isFrozen(node.sockets));
}
});
test("commit envelopes carry versions, command id, cause and explicit null", () => {
const state = createEngine(emptyDocument());
const id = commandId("add-1");
const result = transition(state, {
commandId: id,
expectedVersion: 0,
source: "gesture",
command: { type: "node.add", nodeId: nodeId("n"), nodeType: "fxnode.shader.value", position: { x: 0, y: 0 } },
});
assert.equal(result.status, "committed");
if (result.status !== "committed") return;
assert.deepEqual(
{
base: result.mutationEnvelope.baseVersion,
version: result.mutationEnvelope.version,
cause: result.mutationEnvelope.cause,
},
{ base: 0, version: 1, cause: "gesture" },
);
assert.equal(result.mutationEnvelope.commandId, id);
assert.equal(result.mutationEnvelope.mutations[0]?.before, null);
assert.equal(result.snapshotEnvelope.version, 1);
});
test("stale, duplicate and missing commands reject with exact state identity", () => {
let state = createEngine(emptyDocument());
state = committed(state, {
type: "node.add",
nodeId: nodeId("n"),
nodeType: "fxnode.shader.value",
position: { x: 0, y: 0 },
});
for (const command of [
{ type: "node.add", nodeId: nodeId("n"), nodeType: "fxnode.shader.value", position: { x: 0, y: 0 } },
{ type: "node.remove", id: nodeId("missing") },
] as const) {
const result = run(state, command);
assert.equal(result.status, "rejected");
assert.equal(result.state, state);
}
const stale = transition(state, {
commandId: commandId("stale"),
expectedVersion: 0,
source: "api",
command: { type: "undo" },
});
assert.equal(stale.status, "rejected");
assert.equal(stale.state, state);
});
test("same-value editing is a no-op and untouched records are shared", () => {
let state = createEngine(emptyDocument());
state = committed(state, {
type: "node.add",
nodeId: nodeId("a"),
nodeType: "fxnode.shader.value",
position: { x: 0, y: 0 },
});
state = committed(state, {
type: "node.add",
nodeId: nodeId("b"),
nodeType: "fxnode.shader.value",
position: { x: 0, y: 0 },
});
const noop = run(state, { type: "node.move", id: nodeId("a"), position: { x: 0, y: 0 } });
assert.equal(noop.status, "noop");
assert.equal(noop.state, state);
const before = state.document.nodes.b;
state = committed(state, { type: "node.move", id: nodeId("a"), position: { x: 5, y: 6 } });
assert.equal(state.document.nodes.b, before);
assert.ok(Object.isFrozen(state.document.nodes.a?.parameters));
});
test("undo and redo restore remove cascades and frame children", () => {
let state = createEngine(emptyDocument());
state = committed(state, {
type: "node.add",
nodeId: nodeId("frame"),
nodeType: "fxnode.common.frame",
position: { x: 0, y: 0 },
});
state = committed(state, {
type: "node.add",
nodeId: nodeId("child"),
nodeType: "fxnode.shader.value",
position: { x: 0, y: 0 },
parentId: nodeId("frame"),
});
const beforeRemoval = state,
removal = run(state, { type: "node.remove", id: nodeId("frame") });
assert.equal(removal.status, "committed");
if (removal.status !== "committed") return;
state = removal.state;
assert.equal(state.document.nodes.child?.parentId, undefined);
assert.equal(Object.hasOwn(state.document.nodes.child!, "parentId"), false);
assert.deepEqual(reduceMutations(beforeRemoval.document, removal.mutationEnvelope.mutations), state.document);
const unparented = save(state.document),
savedChild = unparented.nodes.find((node) => node.id === "child");
assert.equal(Object.hasOwn(savedChild!, "parentId"), false);
assert.equal(decodeGraphDocument(unparented).ok, true);
state = committed(state, { type: "undo" });
assert.equal(state.document.nodes.child?.parentId, "frame");
state = committed(state, { type: "redo" });
assert.equal(state.document.nodes.frame, undefined);
});
test("history limits 0, 1 and 2 bound undo and new edits clear redo", () => {
for (const limit of [0, 1, 2]) {
let state = createEngine(emptyDocument(), limit);
state = committed(state, {
type: "node.add",
nodeId: nodeId(`n${limit}`),
nodeType: "fxnode.shader.value",
position: { x: 0, y: 0 },
});
assert.equal(state.undo.length, limit === 0 ? 0 : 1);
}
assert.throws(() => createEngine(emptyDocument(), 1.5), RangeError);
});
test("frame cycles reject atomically", () => {
let state = createEngine(emptyDocument());
state = committed(state, {
type: "node.add",
nodeId: nodeId("a"),
nodeType: "fxnode.common.frame",
position: { x: 0, y: 0 },
});
state = committed(state, {
type: "node.add",
nodeId: nodeId("b"),
nodeType: "fxnode.common.frame",
position: { x: 0, y: 0 },
parentId: nodeId("a"),
});
const result = run(state, { type: "node.parent", id: nodeId("a"), parentId: nodeId("b") });
assert.equal(result.status, "rejected");
assert.equal(result.state, state);
});
test("save, decode and save is canonical; snapshot arrays sort by id", () => {
let state = createEngine(emptyDocument("canonical"));
state = committed(state, {
type: "node.add",
nodeId: nodeId("z"),
nodeType: "fxnode.shader.value",
position: { x: 0, y: 0 },
});
state = committed(state, {
type: "node.add",
nodeId: nodeId("a"),
nodeType: "fxnode.shader.value",
position: { x: 0, y: 0 },
});
const layout = save(state.document);
assert.deepEqual(
layout.nodes.map((item) => item.id),
["a", "z"],
);
const decoded = decodeGraphDocument(structuredClone(layout));
assert.equal(decoded.ok, true);
if (decoded.ok) assert.equal(serializeGraphDocument(decoded.value), serializeGraphDocument(state.document));
assert.deepEqual(
getState(state).nodes.map((item) => item.id),
["a", "z"],
);
assert.equal("known" in layout.nodes[0]!, false);
});
test("transient fields are rejected even through extension escape hatches", () => {
const raw = { ...save(emptyDocument()), extensions: { selection: [] } };
assert.equal(decodeGraphDocument(raw).ok, false);
const nested = { ...save(emptyDocument()), metadata: { extension: { history: [] } } };
assert.equal(decodeGraphDocument(nested).ok, false);
});
test("bound persistence normalizes v1 and rejects future document schemas", () => {
const v1 = { schemaVersion: 1, graphId: "old", catalogVersion: 1, nodes: [], links: [], metadata: {} };
const migrated = decodeGraphDocument(v1);
assert.equal(migrated.ok, true);
if (migrated.ok) assert.equal(migrated.value.catalogVersion, emptyDocument().catalogVersion);
const future = decodeGraphDocument({ ...v1, schemaVersion: 3 });
assert.equal(future.ok, false);
if (!future.ok) assert.equal(future.issues.at(-1)?.code, "schema.future");
});
test("parameter reset resolves descriptor default and is one-step undoable", () => {
let state = createEngine(emptyDocument());
state = committed(state, {
type: "node.add",
nodeId: nodeId("v"),
nodeType: "fxnode.shader.value",
position: { x: 0, y: 0 },
});
state = committed(state, {
type: "node.parameter",
id: nodeId("v"),
key: "value",
value: { kind: "number", value: 9 },
});
state = committed(state, { type: "node.parameter-reset", id: nodeId("v"), key: "value" });
assert.deepEqual(state.document.nodes.v?.parameters.value, { kind: "number", value: 0 });
state = committed(state, { type: "undo" });
assert.deepEqual(state.document.nodes.v?.parameters.value, { kind: "number", value: 9 });
});
test("batch is atomic, increments once, and undoes as one entry", () => {
let state = createEngine(emptyDocument());
state = committed(state, {
type: "node.add",
nodeId: nodeId("a"),
nodeType: "fxnode.shader.value",
position: { x: 0, y: 0 },
});
state = committed(state, {
type: "node.add",
nodeId: nodeId("b"),
nodeType: "fxnode.shader.value",
position: { x: 0, y: 0 },
});
const before = state,
result = run(state, {
type: "batch",
commands: [
{ type: "node.move", id: nodeId("a"), position: { x: 3, y: 4 } },
{ type: "node.move", id: nodeId("b"), position: { x: 5, y: 6 } },
],
});
assert.equal(result.status, "committed");
if (result.status !== "committed") return;
assert.equal(result.state.version, before.version + 1);
assert.equal(result.state.undo.length, before.undo.length + 1);
state = committed(result.state, { type: "undo" });
assert.deepEqual(state.document.nodes.a?.position, { x: 0, y: 0 });
assert.deepEqual(state.document.nodes.b?.position, { x: 0, y: 0 });
const rejected = run(state, {
type: "batch",
commands: [
{ type: "node.move", id: nodeId("a"), position: { x: 9, y: 9 } },
{ type: "node.remove", id: nodeId("missing") },
],
});
assert.equal(rejected.status, "rejected");
assert.equal(rejected.state, state);
});
test("selection planners are complete, deterministic, and enforce the shared atomic limit", () => {
const a = materializeNode("a", "fxnode.shader.value", { x: 0, y: 0 })!,
b = materializeNode("b", "fxnode.shader.value", { x: 1, y: 1 })!;
const incident = {
id: linkId("incident"),
fromNodeId: nodeId("a"),
fromSocketId: socketId("a:out"),
toNodeId: nodeId("b"),
toSocketId: socketId("b:in"),
muted: false,
extensions: {},
},
standalone = { ...incident, id: linkId("standalone"), fromNodeId: nodeId("b") };
const document = { ...emptyDocument(), nodes: { a, b }, links: { incident, standalone } };
const removal = planSelectionRemoval(
document,
new Set([nodeId("a")]),
new Set([linkId("incident"), linkId("standalone")]),
)!;
assert.deepEqual(removal, {
type: "batch",
commands: [
{ type: "link.remove", id: linkId("standalone") },
{ type: "node.remove", id: nodeId("a") },
],
});
const mixed = { ...document, nodes: { a: { ...a, muted: true }, b } };
assert.deepEqual(
planSelectionMute(mixed, new Set([nodeId("b"), nodeId("a")]), () => true),
{ type: "batch", commands: [{ type: "node.mute", id: nodeId("b"), value: true }] },
);
const nodes = Object.fromEntries(
Array.from({ length: 257 }, (_, index) => {
const id = `n-${String(index).padStart(3, "0")}`;
return [id, materializeNode(id, "fxnode.shader.value", { x: index, y: 0 })!];
}),
);
const overflow = planSelectionRemoval(
{ ...emptyDocument(), nodes },
new Set(Object.keys(nodes).map(nodeId)),
new Set(),
)!;
assert.equal(overflow.type, "batch");
if (overflow.type !== "batch") return;
assert.equal(overflow.commands.length, 257);
assert.equal(validCommand(overflow), false);
const overflowState = createEngine({ ...emptyDocument(), nodes }),
rejected = run(overflowState, overflow);
assert.equal(rejected.status, "rejected");
assert.equal(rejected.state, overflowState);
assert.equal(overflowState.undo.length, 0);
assert.deepEqual(
planSelectionMute(mixed, new Set([nodeId("b"), nodeId("a")]), (id) => id === nodeId("a"), false),
{ type: "batch", commands: [{ type: "node.mute", id: nodeId("a"), value: false }] },
);
});
+494
View File
@@ -0,0 +1,494 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createFxNodeHeadless } from "@lib/headless-runtime.js";
import { commandId, nodeId, type GraphState } from "@lib/core/types.js";
import { rebindBoundEngineAuthority } from "@lib/composition/bound-engine.js";
import type { FxNodeCompositionData } from "@lib/composition/types.js";
import { canonicalJsonEqual } from "@lib/core/json.js";
import { admitStructuredData } from "@lib/core/json.js";
import { PERSISTENCE_LIMITS } from "@lib/composition/bound-document.js";
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js";
const composition = {
...APPLICATION_COMPILED.source,
id: "runtime-test",
version: 91,
nodes: {
value: APPLICATION_COMPILED.source.nodes["fxnode.shader.value"]!,
frame: APPLICATION_COMPILED.source.nodes["fxnode.common.frame"]!,
},
};
const runtime = createFxNodeHeadless(composition);
const request = (version: number, command: Parameters<typeof runtime.transition>[1]["command"]) => ({
commandId: commandId(`c${version}`),
expectedVersion: version,
source: "api" as const,
command,
});
test("bound runtime materializes, edits, resets, undoes, redoes, saves and loads", () => {
assert.equal(runtime.emptyDocument().catalogVersion, 91);
const materialized = runtime.materializeNode("direct", "value");
assert.equal(materialized.typeId, "value");
let state = runtime.createEngine(runtime.emptyDocument());
let result = runtime.transition(
state,
request(0, { type: "node.add", nodeId: nodeId("n"), nodeType: "value", position: { x: 1, y: 2 } }),
);
assert.equal(result.status, "committed");
if (result.status !== "committed") return;
state = result.state;
const key = Object.keys(state.document.nodes.n!.parameters)[0]!;
result = runtime.transition(
state,
request(1, { type: "node.parameter", id: nodeId("n"), key, value: { kind: "number", value: 4 } }),
);
assert.equal(result.status, "committed");
if (result.status !== "committed") return;
state = result.state;
result = runtime.transition(state, request(2, { type: "node.parameter-reset", id: nodeId("n"), key }));
assert.equal(result.status, "committed");
if (result.status !== "committed") return;
state = result.state;
result = runtime.transition(state, request(3, { type: "undo" }));
assert.equal(result.status, "committed");
if (result.status !== "committed") return;
result = runtime.transition(result.state, request(4, { type: "redo" }));
assert.equal(result.status, "committed");
if (result.status !== "committed") return;
const saved = runtime.save(result.state.document),
decoded = runtime.decodeGraphDocument(saved);
assert.equal(decoded.ok, true);
const loaded = runtime.load(result.state, saved);
assert.equal(loaded.ok, true);
assert.equal(
runtime.serializeGraphDocument(result.state.document),
runtime.serializeGraphDocument(decoded.ok ? decoded.value : result.state.document),
);
});
test("bound runtime rejects unknown add and invalid initial engine", () => {
const state = runtime.createEngine(runtime.emptyDocument());
const bad = { type: "node.add", nodeId: nodeId("x"), nodeType: "missing", position: { x: 0, y: 0 } } as never;
const result = runtime.transition(state, request(0, bad));
assert.equal(result.status, "rejected");
if (result.status === "rejected") assert.equal(result.error.code, "node.type-unknown");
assert.throws(() =>
runtime.createEngine({
...runtime.emptyDocument(),
nodes: { x: { ...runtime.materializeNode("x", "value"), size: { x: 0, y: 1 } } },
}),
);
assert.throws(() => runtime.createEngine({ ...runtime.emptyDocument(), catalogVersion: 999 }));
assert.throws(() =>
runtime.createEngine({
...runtime.emptyDocument(),
links: {
wrong: {
id: "actual",
fromNodeId: "a",
fromSocketId: "a:out",
toNodeId: "b",
toSocketId: "b:in",
muted: false,
extensions: {},
},
},
} as never),
);
});
test("graph state decoding is exact for current composition and ignores only observational version", () => {
const document = {
...runtime.emptyDocument("state-source"),
nodes: { n: runtime.materializeNode("n", "value", { x: 4, y: 5 }) },
},
state = runtime.createEngine(document),
snapshot = runtime.getState(state),
input = structuredClone(snapshot),
nested = input.nodes[0]!;
const decoded = runtime.decodeGraphState(input);
assert.equal(decoded.ok, true);
assert.deepEqual(input, snapshot);
assert.equal(Object.isFrozen(input), false);
assert.equal(Object.isFrozen(nested), false);
if (!decoded.ok) return;
assert.equal(decoded.value.nodes.n?.known, true);
assert.ok(Object.isFrozen(decoded.value));
const spoofed = runtime.decodeGraphState({ ...snapshot, version: Number.MAX_SAFE_INTEGER });
assert.equal(spoofed.ok, true);
const missingKnown = { ...snapshot, nodes: snapshot.nodes.map(({ known: _, ...node }) => node) },
wrongKnown = { ...snapshot, nodes: snapshot.nodes.map((node) => ({ ...node, known: false })) },
foreign = { ...snapshot, catalogVersion: 999 };
for (const value of [missingKnown, wrongKnown, foreign]) {
const result = runtime.decodeGraphState(value);
assert.equal(result.ok, false);
if (!result.ok) assert.equal(result.issues[0]?.code, "state.inexact");
}
const malformed = runtime.decodeGraphState({ ...snapshot, extra: true });
assert.equal(malformed.ok, false);
if (!malformed.ok) assert.equal(malformed.issues[0]?.code, "state.shape");
const failed = structuredClone(wrongKnown),
failedNested = failed.nodes[0]!;
runtime.decodeGraphState(failed);
assert.equal(Object.isFrozen(failed), false);
assert.equal(Object.isFrozen(failedNested), false);
assert.deepEqual(failed, wrongKnown);
const cycle: any = {};
cycle.self = cycle;
const sparse: any = structuredClone(snapshot);
sparse.nodes.length = 2;
const nonfinite: any = structuredClone(snapshot);
nonfinite.nodes[0].position.x = Infinity;
const overLimit: any = { ...snapshot, nodes: Array(100_001).fill(null) };
for (const value of [cycle, sparse, nonfinite, overLimit])
assert.doesNotThrow(() => assert.equal(runtime.decodeGraphState(value).ok, false));
});
test("maximum admitted durable layout round-trips through getState and state decoding", () => {
const base = runtime.save({
...runtime.emptyDocument("state-limit"),
nodes: { n: runtime.materializeNode("n", "value") },
}),
metrics = admitStructuredData(base, PERSISTENCE_LIMITS);
assert.equal(metrics.ok, true);
if (!metrics.ok) return;
const paddingLength = PERSISTENCE_LIMITS.maxValues - metrics.metrics.values - 1,
layout = { ...base, metadata: { padding: Array(paddingLength).fill(null) } } as typeof base,
admitted = admitStructuredData(layout, PERSISTENCE_LIMITS);
assert.equal(admitted.ok, true);
if (!admitted.ok) return;
assert.equal(admitted.metrics.values, PERSISTENCE_LIMITS.maxValues);
const document = runtime.decodeGraphDocument(layout);
assert.equal(document.ok, true);
if (!document.ok) return;
const state = runtime.getState(runtime.createEngine(document.value)),
decoded = runtime.decodeGraphState(state);
assert.equal(decoded.ok, true);
if (decoded.ok) assert.equal((decoded.value.metadata.padding as readonly unknown[]).length, paddingLength);
});
test("state replacement commits once and is one-step undoable and redoable", () => {
const targetDocument = {
...runtime.emptyDocument("replacement"),
nodes: { n: runtime.materializeNode("n", "value", { x: 7, y: 8 }) },
},
target = runtime.getState(runtime.createEngine(targetDocument));
let state = runtime.createEngine(runtime.emptyDocument("before"), 2);
const replaced = runtime.replaceState(state, { commandId: commandId("replace"), expectedVersion: 0, target });
assert.equal(replaced.status, "committed");
if (replaced.status !== "committed") return;
assert.equal(replaced.state.version, 1);
assert.equal(replaced.state.document.graphId, "replacement");
assert.equal(replaced.state.undo.length, 1);
assert.equal(replaced.mutationEnvelope.cause, "api");
assert.deepEqual(
replaced.mutationEnvelope.mutations.map((m) => m.kind),
["document.replaced"],
);
state = replaced.state;
const undo = runtime.transition(state, request(1, { type: "undo" }));
assert.equal(undo.status, "committed");
if (undo.status !== "committed") return;
assert.equal(undo.state.version, 2);
assert.equal(undo.state.document.graphId, "before");
assert.equal(undo.state.redo.length, 1);
const redo = runtime.transition(undo.state, request(2, { type: "redo" }));
assert.equal(redo.status, "committed");
if (redo.status !== "committed") return;
assert.equal(redo.state.version, 3);
assert.equal(redo.state.document.graphId, "replacement");
const noop = runtime.replaceState(redo.state, {
commandId: commandId("noop"),
expectedVersion: 3,
target: runtime.getState(redo.state),
});
assert.equal(noop.status, "noop");
assert.equal(noop.state, redo.state);
const withoutHistory = runtime.createEngine(runtime.emptyDocument("zero"), 0),
zero = runtime.replaceState(withoutHistory, { commandId: commandId("zero"), expectedVersion: 0, target });
assert.equal(zero.status, "committed");
if (zero.status === "committed") assert.equal(zero.state.undo.length, 0);
});
test("state replacement checks staleness before inspecting hostile input and noops before overflow", () => {
const state = runtime.createEngine(runtime.emptyDocument());
let inspections = 0;
const hostile = Object.defineProperty({}, "graphId", {
enumerable: true,
get() {
inspections++;
throw new Error("must not inspect");
},
}) as GraphState<typeof composition>;
const stale = runtime.replaceState(state, {
commandId: commandId("stale-state"),
expectedVersion: 1,
target: hostile,
});
assert.equal(stale.status, "rejected");
assert.equal(stale.state, state);
assert.equal(inspections, 0);
if (stale.status === "rejected") assert.equal(stale.error.code, "version.stale");
const current = runtime.replaceState(state, {
commandId: commandId("hostile-state"),
expectedVersion: 0,
target: hostile,
});
assert.equal(current.status, "rejected");
assert.equal(inspections, 0);
if (current.status === "rejected") assert.equal(current.error.code, "data.inspect");
const maximum = { ...state, version: Number.MAX_SAFE_INTEGER } as typeof state,
target = runtime.getState(state),
noop = runtime.replaceState(maximum, {
commandId: commandId("maximum-noop"),
expectedVersion: Number.MAX_SAFE_INTEGER,
target,
});
assert.equal(noop.status, "noop");
assert.equal(noop.state, maximum);
const changed = { ...target, graphId: "changed" as typeof target.graphId },
overflow = runtime.replaceState(maximum, {
commandId: commandId("maximum-change"),
expectedVersion: Number.MAX_SAFE_INTEGER,
target: changed,
});
assert.equal(overflow.status, "rejected");
assert.equal(overflow.state, maximum);
if (overflow.status === "rejected") assert.equal(overflow.error.code, "version.overflow");
});
test("state replacement preserves bounded prior history, clears redo, and ignores spoofed target version", () => {
const target = runtime.getState(
runtime.createEngine({
...runtime.emptyDocument("target"),
nodes: { target: runtime.materializeNode("target", "value") },
}),
);
let state = runtime.createEngine(runtime.emptyDocument("history"), 2),
first = runtime.transition(
state,
request(0, { type: "node.add", nodeId: nodeId("a"), nodeType: "value", position: { x: 0, y: 0 } }),
);
assert.equal(first.status, "committed");
if (first.status !== "committed") return;
state = first.state;
const second = runtime.transition(
state,
request(1, { type: "node.add", nodeId: nodeId("b"), nodeType: "value", position: { x: 0, y: 0 } }),
);
assert.equal(second.status, "committed");
if (second.status !== "committed") return;
const undone = runtime.transition(second.state, request(2, { type: "undo" }));
assert.equal(undone.status, "committed");
if (undone.status !== "committed") return;
assert.equal(undone.state.redo.length, 1);
const replaced = runtime.replaceState(undone.state, {
commandId: commandId("history-replace"),
expectedVersion: 3,
target: { ...target, version: 999 } as GraphState<typeof composition>,
});
assert.equal(replaced.status, "committed");
if (replaced.status !== "committed") return;
assert.equal(replaced.state.version, 4);
assert.equal(replaced.state.redo.length, 0);
assert.equal(replaced.state.undo.length, 2);
assert.deepEqual(
replaced.state.undo.at(-1)?.forward.map((m) => m.kind),
["document.replaced"],
);
assert.deepEqual(
replaced.state.undo.at(-1)?.inverse.map((m) => m.kind),
["document.replaced"],
);
const undoIdentity = replaced.state.undo,
redoIdentity = replaced.state.redo,
entryIdentity = replaced.state.undo[0],
noop = runtime.replaceState(replaced.state, {
commandId: commandId("history-noop"),
expectedVersion: 4,
target: runtime.getState(replaced.state),
});
assert.equal(noop.status, "noop");
assert.equal(noop.state.undo, undoIdentity);
assert.equal(noop.state.redo, redoIdentity);
assert.equal(noop.state.undo[0], entryIdentity);
const limited = runtime.createEngine(runtime.emptyDocument("limited"), 1),
added = runtime.transition(
limited,
request(0, { type: "node.add", nodeId: nodeId("old"), nodeType: "value", position: { x: 0, y: 0 } }),
);
assert.equal(added.status, "committed");
if (added.status !== "committed") return;
const one = runtime.replaceState(added.state, {
commandId: commandId("limited-replace"),
expectedVersion: 1,
target,
});
assert.equal(one.status, "committed");
if (one.status === "committed") {
assert.equal(one.state.undo.length, 1);
assert.deepEqual(
one.state.undo[0]?.forward.map((m) => m.kind),
["document.replaced"],
);
}
});
test("composition runtimes isolate overlapping IDs; unknown versions roundtrip read-only", () => {
const other = createFxNodeHeadless({
...composition,
id: "other",
version: 92,
nodes: { ...composition.nodes, value: { ...composition.nodes.value, title: "Other" } },
});
assert.equal(other.materializeNode("a", "value").label, "Other");
const raw = runtime.save({ ...runtime.emptyDocument(), nodes: { a: runtime.materializeNode("a", "value") } });
const unknownVersion = { ...raw, nodes: raw.nodes.map((n) => ({ ...n, typeVersion: n.typeVersion + 1 })) };
const decoded = runtime.decodeGraphDocument(unknownVersion);
assert.equal(decoded.ok, true);
if (!decoded.ok) return;
assert.equal(decoded.value.nodes.a!.known, false);
assert.equal(runtime.parseGraphDocument(runtime.serializeGraphDocument(decoded.value)).ok, true);
let state = runtime.createEngine(decoded.value);
const key = Object.keys(state.document.nodes.a!.parameters)[0]!;
const edit = runtime.transition(state, request(0, { type: "node.parameter-reset", id: nodeId("a"), key }));
assert.equal(edit.status, "rejected");
const malformed = { ...raw, nodes: raw.nodes.map((n) => ({ ...n, parameters: {} })) };
assert.equal(runtime.decodeGraphDocument(malformed).ok, false);
});
test("foreign catalog versions normalize and future node payloads remain opaque", () => {
const raw = runtime.save({ ...runtime.emptyDocument(), nodes: { a: runtime.materializeNode("a", "value") } });
const foreign = { ...raw, catalogVersion: 999 };
const normalized = runtime.decodeGraphDocument(foreign);
assert.equal(normalized.ok, true);
if (!normalized.ok) return;
assert.equal(normalized.value.catalogVersion, 91);
assert.equal(runtime.save(normalized.value).catalogVersion, 91);
const loaded = runtime.load(runtime.createEngine(runtime.emptyDocument()), foreign);
assert.equal(loaded.ok, true);
if (loaded.ok) assert.equal(loaded.snapshotEnvelope.snapshot.catalogVersion, 91);
const future = {
...raw,
nodes: raw.nodes.map((n) => ({
...n,
typeId: "future.node",
typeVersion: 12,
parameters: { future: { history: [] } },
extensions: { session: { selection: [1] } },
futureField: { hovered: true },
sockets: [
{
...n.sockets[0]!,
dataType: "future-socket",
accepts: ["future-source"],
defaultValue: null,
metadata: { runtimeVersion: 7 },
},
],
})),
};
const decoded = runtime.decodeGraphDocument(future);
assert.equal(decoded.ok, true);
if (!decoded.ok) return;
assert.equal(decoded.value.nodes.a!.known, false);
assert.equal(JSON.stringify(runtime.save(decoded.value)), JSON.stringify(future));
});
test("authority rebind preserves graph version for presentation changes and resets history", () => {
const source: FxNodeCompositionData = composition,
candidateSource: FxNodeCompositionData = { ...source, theme: { ...source.theme, background: "#123456" } },
current = createFxNodeHeadless(source),
candidate = createFxNodeHeadless(candidateSource);
let state = current.createEngine(current.emptyDocument(), 7),
result = current.transition(state, {
commandId: commandId("add"),
expectedVersion: 0,
source: "api",
command: { type: "node.add", nodeId: nodeId("n"), nodeType: "value", position: { x: 0, y: 0 } },
});
assert.equal(result.status, "committed");
if (result.status !== "committed") return;
state = result.state;
assert.equal(state.undo.length, 1);
const rebound = rebindBoundEngineAuthority(state, current, candidate, { commandId: commandId("rebind") });
assert.equal(rebound.ok, true);
if (!rebound.ok) return;
assert.equal(rebound.graphChanged, false);
assert.equal(rebound.state.version, state.version);
assert.equal(rebound.state.historyLimit, 7);
assert.equal(rebound.state.undo.length, 0);
assert.equal(rebound.state.redo.length, 0);
assert.equal(rebound.state.document.nodes.n!.known, true);
assert.equal(canonicalJsonEqual({ b: 1, a: 2 }, { a: 2, b: 1 }), true);
const left = Object.create(null) as Record<string, unknown>,
right = Object.create(null) as Record<string, unknown>;
left.__proto__ = { value: 1 };
right.__proto__ = { value: 2 };
assert.equal(canonicalJsonEqual(left, right), false);
});
test("authority rebind promotes opaque nodes and explicitly removed definitions remain opaque", () => {
const full: FxNodeCompositionData = composition,
withoutFrame: FxNodeCompositionData = { ...full, nodes: { value: full.nodes.value! } },
old = createFxNodeHeadless(withoutFrame),
candidate = createFxNodeHeadless(full);
const authored = candidate.save({
...candidate.emptyDocument(),
nodes: { f: candidate.materializeNode("f", "frame") },
}),
opaque = old.decodeGraphDocument(authored);
assert.equal(opaque.ok, true);
if (!opaque.ok) return;
assert.equal(opaque.value.nodes.f!.known, false);
const promoted = rebindBoundEngineAuthority(old.createEngine(opaque.value), old, candidate, {
commandId: commandId("promote"),
});
assert.equal(promoted.ok, true);
if (!promoted.ok || !promoted.graphChanged) return;
assert.equal(promoted.state.version, 1);
assert.equal(promoted.state.document.nodes.f!.known, true);
assert.equal(promoted.mutationEnvelope.cause, "composition");
assert.equal(promoted.mutationEnvelope.mutations[0]?.kind, "document.replaced");
assert.deepEqual(old.save(opaque.value), candidate.save(promoted.state.document));
const current = createFxNodeHeadless(full),
removed = createFxNodeHeadless(withoutFrame),
known = current.createEngine({ ...current.emptyDocument(), nodes: { f: current.materializeNode("f", "frame") } });
const rejected = rebindBoundEngineAuthority(known, current, removed, { commandId: commandId("reject") });
assert.equal(rejected.ok, false);
if (rejected.ok) return;
assert.equal(rejected.state, known);
assert.equal(rejected.issues[0]?.code, "composition.node-demotion");
const accepted = rebindBoundEngineAuthority(known, current, removed, {
commandId: commandId("remove"),
removedNodeTypes: new Set(["frame"]),
});
assert.equal(accepted.ok, true);
if (!accepted.ok) return;
assert.equal(accepted.graphChanged, true);
assert.equal(accepted.state.document.nodes.f!.known, false);
assert.equal(accepted.state.document.nodes.f!.id, "f");
});
test("graph-changing authority rebind rejects version overflow atomically", () => {
const full: FxNodeCompositionData = composition,
removedSource: FxNodeCompositionData = { ...full, nodes: { value: full.nodes.value! } },
current = createFxNodeHeadless(full),
removed = createFxNodeHeadless(removedSource);
const normal = current.createEngine({
...current.emptyDocument(),
nodes: { f: current.materializeNode("f", "frame") },
}),
state = { ...normal, version: Number.MAX_SAFE_INTEGER, undo: [{ forward: [], inverse: [] }] } as typeof normal;
const result = rebindBoundEngineAuthority(state, current, removed, {
commandId: commandId("overflow"),
removedNodeTypes: new Set(["frame"]),
});
assert.equal(result.ok, false);
if (result.ok) return;
assert.equal(result.state, state);
assert.equal(result.issues[0]?.code, "version.overflow");
assert.equal(result.state.undo.length, 1);
});
+36
View File
@@ -0,0 +1,36 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile, readdir } from "node:fs/promises";
import { extname, join } from "node:path";
test("browser entry does not import the engine runtime", async () => {
const source = await readFile(new URL("../src/index.ts", import.meta.url), "utf8");
assert.doesNotMatch(source, /headless|engine\/engine|core\/document/);
assert.match(source, /browser\/client/);
});
test("examples server explicitly loads the repository Vite config", async () => {
const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")) as {
scripts?: { example?: string; examples?: string };
};
assert.equal(packageJson.scripts?.example, "npm run examples");
assert.match(packageJson.scripts?.examples ?? "", /vite\s+--config\s+vite\.config\.ts\s+examples(?:\s|$)/);
});
test("standalone examples import library types and values only through the public entrypoint", async () => {
const root = new URL("../examples/", import.meta.url);
const directories = ["shared", "minimal", "color-balance", "live-composition"];
const files: string[] = [];
async function collect(directory: string): Promise<void> {
for (const entry of await readdir(new URL(directory, root), { withFileTypes: true })) {
const relative = join(directory, entry.name);
if (entry.isDirectory()) await collect(`${relative}/`);
else if ([".ts", ".tsx", ".js", ".mjs"].includes(extname(entry.name))) files.push(relative);
}
}
await Promise.all(directories.map((directory) => collect(`${directory}/`)));
for (const file of files) {
const source = await readFile(new URL(file, root), "utf8");
const imports = source.matchAll(/(?:from\s*|import\s*)["'](@lib\/[^"']+)["']/g);
for (const match of imports) assert.equal(match[1], "@lib/index.js", `${file} bypasses the public entrypoint`);
}
});
+65
View File
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import { appendKnifePoint, crossedLinks, MAX_KNIFE_POINTS, segmentsIntersect } from "@lib/worker/knife-path.js";
import { effectivelyMutedLinks } from "@lib/layout/link-mute.js";
import { layoutGraph as genericLayoutGraph } from "@lib/layout/layout-graph.js";
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js";
const layoutGraph = (
document: Parameters<typeof genericLayoutGraph>[1],
transform: Parameters<typeof genericLayoutGraph>[2],
) => genericLayoutGraph(APPLICATION_COMPILED, document, transform);
import { worldToView } from "@lib/layout/geometry.js";
const { emptyDocument, materializeNode } = APPLICATION_HEADLESS;
test("knife intersection handles directions, dedupes links, candidates, mute and point cap", () => {
assert.equal(segmentsIntersect({ x: 0, y: 0 }, { x: 10, y: 10 }, { x: 0, y: 10 }, { x: 10, y: 0 }), true);
assert.equal(segmentsIntersect({ x: 10, y: 10 }, { x: 0, y: 0 }, { x: 0, y: 10 }, { x: 10, y: 0 }), true);
assert.equal(segmentsIntersect({ x: 0, y: 0 }, { x: 2, y: 0 }, { x: 3, y: 0 }, { x: 5, y: 0 }), false);
let points: readonly { x: number; y: number }[] = [];
for (let i = 0; i < 300; i++) points = appendKnifePoint(points, { x: i * 3, y: 0 });
assert.equal(points.length, MAX_KNIFE_POINTS);
const source = materializeNode("source", "fxnode.shader.value", { x: -200, y: 50 }),
target = materializeNode("target", "fxnode.shader.math", { x: 100, y: 50 });
const base = emptyDocument();
const document = {
...base,
nodes: { source, target },
links: {
wire: {
id: "wire",
fromNodeId: "source",
fromSocketId: source.sockets[0]!.id,
toNodeId: "target",
toSocketId: target.sockets[0]!.id,
muted: false,
},
},
} as never;
const layout = layoutGraph(document, { center: { x: 0, y: 0 }, zoom: 1, viewport: { x: 1200, y: 640 }, dpr: 1 });
const link = [...layout.links.values()].find((item) => item.visible && !item.muted)!;
const middle = link.points[Math.floor(link.points.length / 2)]!;
const view = worldToView(middle, layout.transform);
const found = crossedLinks(layout, [
{ x: view.x, y: view.y - 100 },
{ x: view.x, y: view.y + 100 },
]);
assert.equal(found.has(link.id), true);
assert.equal(found.size, 1);
});
test("reroute effective mute propagates down chains and branches without changing authored flags", () => {
const doc = {
nodes: {
a: { id: "a", typeId: "x", known: false, sockets: [] },
r: { id: "r", typeId: "fxnode.common.reroute", known: true, sockets: [] },
b: { id: "b", typeId: "x", known: false, sockets: [] },
},
links: {
one: { id: "one", fromNodeId: "a", toNodeId: "r", muted: true },
two: { id: "two", fromNodeId: "r", toNodeId: "b", muted: false },
three: { id: "three", fromNodeId: "r", toNodeId: "a", muted: false },
},
} as never;
assert.deepEqual([...effectivelyMutedLinks(APPLICATION_COMPILED, doc)].sort(), ["one", "three", "two"]);
assert.equal((doc as any).links.two.muted, false);
});
+807
View File
@@ -0,0 +1,807 @@
import test from "node:test";
import assert from "node:assert/strict";
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js";
import { commandId, nodeId } from "@lib/core/types.js";
import { layoutGraph as genericLayoutGraph } from "@lib/layout/layout-graph.js";
import { viewToWorld, worldToView } from "@lib/layout/geometry.js";
const layoutGraph = (document: any, transform: Parameters<typeof genericLayoutGraph>[2]) =>
genericLayoutGraph(APPLICATION_COMPILED, document as never, transform);
const { createEngine, emptyDocument, materializeNode, transition, socketsCompatible } = APPLICATION_HEADLESS;
const BUILTIN_DESCRIPTORS = [...APPLICATION_COMPILED.nodes.values()];
import type { GraphDocument } from "@lib/headless.js";
import { applyNodeOrder } from "@lib/layout/layout-graph.js";
import {
boxNodes,
clampResize,
compatibleTargets,
frameDropCandidate,
groupRoots,
hitRamp,
hitTest,
planLink,
zoomAt,
} from "@lib/worker/interaction.js";
const transform = { center: { x: 0, y: 0 }, zoom: 2, viewport: { x: 800, y: 600 }, dpr: 1 };
test("world/view transforms round trip with +Y up", () => {
const point = { x: 17, y: -23 };
assert.deepEqual(viewToWorld(worldToView(point, transform), transform), point);
assert.ok(worldToView({ x: 0, y: 1 }, transform).y < worldToView({ x: 0, y: 0 }, transform).y);
});
test("all catalog types lay out deterministically", () => {
let state = createEngine(emptyDocument());
for (const [index, descriptor] of BUILTIN_DESCRIPTORS.entries()) {
const result = transition(state, {
commandId: commandId(String(index)),
expectedVersion: state.version,
source: "api",
command: {
type: "node.add",
nodeId: nodeId(`n${index}`),
nodeType: descriptor.typeId,
position: { x: index * 10, y: 0 },
},
});
assert.notEqual(result.status, "rejected");
if (result.status === "committed") state = result.state;
}
const a = layoutGraph(state.document, transform),
b = layoutGraph(state.document, transform);
assert.equal(a.nodes.size, BUILTIN_DESCRIPTORS.length);
assert.deepEqual([...a.drawOrder], [...b.drawOrder]);
const expected = [
["fxnode.common.frame", "frame", "common", [], [100, 100], [300, 100]],
["fxnode.common.reroute", "reroute", "common", ["socket:input:input:1"], [10, 10], [10, 10]],
[
"fxnode.shader.texture-coordinate",
"node",
"shader",
["socket:output:generated:1", "socket:output:normal:1", "socket:output:uv:1", "socket:output:object:1"],
[192, 124],
[192, 124],
],
[
"fxnode.shader.noise-texture",
"node",
"texture",
[
"control:enum:dimensions:1",
"control:enum:noiseType:1",
"control:boolean:normalize:1",
"socket:input:vector:1",
"socket:input:scale:1",
"socket:input:detail:1",
"socket:input:roughness:1",
"socket:input:lacunarity:1",
"socket:input:distortion:1",
"socket:output:factor:1",
"socket:output:color:1",
],
[286, 292],
[286, 292],
],
[
"fxnode.shader.image-texture",
"node",
"input",
[
"control:resource:image:4",
"control:enum:interpolation:1",
"control:enum:projection:1",
"control:enum:extension:1",
"control:enum:colorSpace:1",
"control:enum:alphaMode:1",
"socket:input:vector:1",
"socket:output:color:1",
"socket:output:alpha:1",
],
[257, 316],
[257, 316],
],
[
"fxnode.shader.principled-bsdf",
"node",
"shader",
[
"socket:input:base-color:1",
"socket:input:metallic:1",
"socket:input:roughness:1",
"socket:input:ior:1",
"socket:input:alpha:1",
"socket:input:normal:1",
"socket:output:bsdf:1",
],
[208, 196],
[208, 196],
],
[
"fxnode.shader.material-output",
"node",
"output",
["socket:input:surface:1", "socket:input:volume:1", "socket:input:displacement:1"],
[340, 100],
[340, 100],
],
["fxnode.geometry.position", "node", "geometry", ["socket:output:position:1"], [175, 52], [175, 100]],
[
"fxnode.geometry.mesh-cube",
"node",
"geometry",
[
"socket:input:size:1",
"socket:input:vertices-x:1",
"socket:input:vertices-y:1",
"socket:input:vertices-z:1",
"socket:output:mesh:1",
],
[340, 148],
[340, 148],
],
[
"fxnode.geometry.set-position",
"node",
"geometry",
["socket:input:geometry:1", "socket:input:position:1", "socket:input:offset:1", "socket:output:result:1"],
[340, 124],
[340, 124],
],
[
"fxnode.geometry.transform-geometry",
"node",
"geometry",
[
"socket:input:geometry:1",
"socket:input:translation:1",
"socket:input:rotation:1",
"socket:input:scale:1",
"socket:output:result:1",
],
[340, 148],
[340, 148],
],
[
"fxnode.geometry.join-geometry",
"node",
"geometry",
["socket:input:geometry:1", "socket:output:result:1"],
[175, 76],
[175, 100],
],
[
"fxnode.common.group-input",
"node",
"input",
["control:string:interfaceName:1", "socket:output:output:1"],
[257, 76],
[257, 100],
],
[
"fxnode.compositor.image",
"node",
"compositorInput",
[
"control:resource:image:4",
"control:enum:source:1",
"socket:output:image:1",
"socket:output:alpha:1",
"socket:output:z:1",
],
[176, 220],
[176, 220],
],
[
"fxnode.compositor.color-balance",
"node",
"compositorColor",
[
"control:enum:mode:1",
"grading-wheels:Lift:number:lift:color:liftColor,Gamma:number:gamma:color:gammaColor,Gain:number:gain:color:gainColor:7",
"socket:input:image:1",
"socket:input:factor:1",
"socket:output:result:1",
],
[400, 292],
[400, 292],
],
[
"fxnode.common.group-output",
"node",
"output",
["control:string:interfaceName:1", "socket:input:input:1"],
[257, 76],
[257, 100],
],
[
"fxnode.shader.value",
"node",
"shader",
["control:number:value:1", "socket:output:value:1"],
[151, 76],
[151, 100],
],
[
"fxnode.shader.color",
"node",
"shader",
["control:color:color:1", "socket:output:color:1"],
[151, 76],
[151, 100],
],
[
"fxnode.shader.math",
"node",
"converter",
[
"control:enum:operation:1",
"control:boolean:clamp:1",
"socket:input:a:1",
"socket:input:b:1",
"socket:output:value:1",
],
[192, 148],
[192, 148],
],
[
"fxnode.shader.vector-math",
"node",
"converter",
[
"control:enum:operation:1",
"socket:input:a:1",
"socket:input:b:1",
"socket:output:vector:1",
"socket:output:value:1",
],
[340, 148],
[340, 148],
],
[
"fxnode.shader.mix",
"node",
"shader",
[
"control:enum:blend:1",
"control:boolean:clamp:1",
"socket:input:factor:1",
"socket:input:a:1",
"socket:input:b:1",
"socket:output:result:1",
],
[151, 172],
[151, 172],
],
[
"fxnode.shader.color-ramp",
"node",
"shader",
["socket:output:color:1", "socket:output:alpha:1", "control:color-ramp:ramp:8", "socket:input:factor:1"],
[320, 292],
[320, 292],
],
] as Array<[string, string, string, string[], [number, number], [number, number]]>;
const layoutByType = new Map([...a.nodes.values()].map((node) => [node.typeId, node]));
const aggregate = expected.map(([typeId]) => {
const node = layoutByType.get(typeId)!;
const sourceNode = state.document.nodes[node.id]!;
return {
typeId: node.typeId,
kind: node.kind,
category: node.styleId,
rows: node.rows.map((row) => {
if (row.kind === "control") {
const control = a.controls.get(row.controlId)!;
return `control:${control.kind}:${control.key}:${row.units}`;
}
if (row.kind === "socket") {
const socket = sourceNode.sockets.find((item) => item.id === row.socketId)!;
return `socket:${socket.direction}:${socket.key}:${row.units}`;
}
if (row.kind === "grading-wheels") {
const wheels = row.wheels.map((wheel) => {
const scalar = a.controls.get(wheel.scalarControlId)!;
const color = a.controls.get(wheel.colorControlId)!;
return `${wheel.label}:${scalar.kind}:${scalar.key}:${color.kind}:${color.key}`;
});
return `grading-wheels:${wheels.join(",")}:${row.units}`;
}
return `${row.kind}:${row.label}:${row.units}`;
}),
minimum: node.minimumSize,
effective: { x: node.bounds.width, y: node.bounds.height },
};
});
const expectedLayout = expected.map(
([typeId, kind, category, rows, [minimumX, minimumY], [effectiveX, effectiveY]]) => ({
typeId,
kind,
category,
rows,
minimum: { x: minimumX, y: minimumY },
effective: { x: effectiveX, y: effectiveY },
}),
);
assert.deepEqual(aggregate, expectedLayout);
});
test("socket compatibility and exact theme palettes are stable", () => {
const types = ["float", "vector", "color", "shader", "geometry", "any"] as const;
const matrix = types.map((output) =>
types.map((input) =>
socketsCompatible(
{ direction: "output", dataType: output },
{ direction: "input", dataType: input, accepts: input === "any" ? ["any"] : [input, "any"] },
),
),
);
assert.deepEqual(matrix, [
[true, false, false, false, false, true],
[false, true, false, false, false, true],
[false, false, true, false, false, true],
[false, false, false, true, false, true],
[false, false, false, false, true, true],
[true, true, true, true, true, true],
]);
assert.equal(
socketsCompatible(
{ direction: "input", dataType: "float" },
{ direction: "input", dataType: "float", accepts: ["float"] },
),
false,
);
assert.equal(
socketsCompatible(
{ direction: "output", dataType: "float" },
{ direction: "output", dataType: "float", accepts: [] },
),
false,
);
assert.deepEqual(Object.fromEntries([...APPLICATION_COMPILED.styles].map(([id, style]) => [id, style.header])), {
input: "#8b3f72",
converter: "#4f5964",
texture: "#a36b34",
shader: "#3b7551",
output: "#963d3d",
geometry: "#2c7a75",
common: "#555b64",
compositorInput: "#8b3f72",
compositorColor: "#4f5964",
});
assert.deepEqual(Object.fromEntries([...APPLICATION_COMPILED.socketTypes].map(([id, type]) => [id, type.color])), {
float: "#a8a8a8",
vector: "#6476dc",
color: "#d7ca63",
shader: "#62b34f",
geometry: "#00bfa5",
any: "#999999",
});
});
test("mute bypass map and endpoint anchors are exact", () => {
const expected = [
["fxnode.common.reroute", [["input", "output"]]],
["fxnode.shader.math", [["a", "value"]]],
["fxnode.shader.vector-math", [["a", "vector"]]],
["fxnode.shader.mix", [["a", "result"]]],
["fxnode.geometry.set-position", [["geometry", "result"]]],
["fxnode.geometry.transform-geometry", [["geometry", "result"]]],
["fxnode.compositor.color-balance", [["image", "result"]]],
] as const;
assert.deepEqual(
BUILTIN_DESCRIPTORS.filter((item) => item.muteBypass.length).map((item) => [item.typeId, item.muteBypass]),
expected,
);
for (const [typeId, pairs] of expected) {
const node = { ...materializeNode("muted", typeId, { x: 0, y: 0 }), muted: true };
const document: GraphDocument = { ...emptyDocument("mute"), nodes: { muted: node } };
const layout = layoutGraph(document, transform),
placed = layout.nodes.get(nodeId("muted"))!;
assert.equal(placed.bypasses.length, pairs.length);
for (const [index, [from, to]] of pairs.entries()) {
const fromSocket = node.sockets.find((socket) => socket.key === from)!;
const toSocket = node.sockets.find((socket) => socket.key === to)!;
assert.deepEqual(placed.bypasses[index], {
from: layout.sockets.get(fromSocket.id)!.anchor,
to: layout.sockets.get(toSocket.id)!.anchor,
});
}
}
});
test("expanded, collapsed, reroute and links have pinned geometry", () => {
const value = materializeNode("value", "fxnode.shader.value", { x: -100, y: 20 });
const math = materializeNode("math", "fxnode.shader.math", { x: 100, y: 20 });
const link = {
id: "link",
fromNodeId: "value",
fromSocketId: "value:value",
toNodeId: "math",
toSocketId: "math:a",
extensions: {},
};
const raw = {
schemaVersion: 1,
graphId: "layout",
catalogVersion: 1,
nodes: { value, math },
links: { link },
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0];
const snapshot = layoutGraph(raw, transform);
assert.equal(snapshot.links.values().next().value?.points.length, 13);
const valueLayout = snapshot.nodes.get(nodeId("value"))!;
assert.equal(valueLayout.bounds.width, valueLayout.minimumSize.x);
assert.ok(valueLayout.bounds.width >= 140);
assert.equal(snapshot.sockets.size, 4);
assert.equal((snapshot.controls.get("value:parameter:value")?.value as { value: number }).value, 0);
assert.equal((snapshot.controls.get("math:parameter:operation")?.value as { value: string }).value, "add");
const mathLayout = snapshot.nodes.get(nodeId("math"))!;
assert.ok(mathLayout.bounds.height >= 126);
assert.equal(mathLayout.collapseHitRect.x, mathLayout.bounds.x);
assert.equal(mathLayout.collapseHitRect.width, 14);
assert.deepEqual(
hitTest(snapshot, worldToView({ x: mathLayout.bounds.x + 10, y: mathLayout.bounds.y - 12 }, snapshot.transform)),
{ kind: "collapse", id: nodeId("math") },
);
assert.deepEqual(
hitTest(snapshot, worldToView({ x: mathLayout.bounds.x + 22, y: mathLayout.bounds.y - 12 }, snapshot.transform)),
{ kind: "node", id: nodeId("math") },
);
assert.equal(snapshot.controls.get("math:socket:math:a")?.linked, true, "linked inputs hide controls");
});
test("frames have labelled fitted bounds around parent-local children", () => {
const frame = {
...materializeNode("frame", "fxnode.common.frame", { x: -200, y: 200 }),
label: "Surface Controls",
size: { x: 100, y: 100 },
};
const child = { ...materializeNode("child", "fxnode.shader.value", { x: 30, y: -50 }), parentId: "frame" };
const raw = {
schemaVersion: 1,
graphId: "frame-layout",
catalogVersion: 1,
nodes: { frame, child },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0];
const snapshot = layoutGraph(raw, transform),
frameLayout = snapshot.nodes.get(nodeId("frame"))!,
childLayout = snapshot.nodes.get(nodeId("child"))!;
assert.equal(frameLayout.label, "Surface Controls");
assert.ok(frameLayout.bounds.x <= childLayout.bounds.x - 30);
assert.ok(frameLayout.bounds.x + frameLayout.bounds.width >= childLayout.bounds.x + childLayout.bounds.width + 30);
assert.ok(frameLayout.bounds.y >= childLayout.bounds.y + 30);
assert.ok(frameLayout.bounds.y - frameLayout.bounds.height <= childLayout.bounds.y - childLayout.bounds.height - 30);
});
test("socket compatibility does not treat accepts any as wildcard", () => {
const output = { direction: "output" as const, dataType: "float" as const };
assert.equal(socketsCompatible(output, { direction: "input", dataType: "vector", accepts: ["any"] }), false);
assert.equal(socketsCompatible(output, { direction: "input", dataType: "any", accepts: [] }), true);
});
test("wheel zoom preserves world point and group roots omit selected descendants", () => {
const cursor = { x: 123, y: 234 },
before = viewToWorld(cursor, transform),
next = zoomAt(transform, cursor, 120),
after = viewToWorld(cursor, { ...transform, ...next });
assert.ok(Math.abs(before.x - after.x) < 1e-9);
assert.ok(Math.abs(before.y - after.y) < 1e-9);
const frame = { ...materializeNode("f", "fxnode.common.frame", { x: 0, y: 0 }) },
child = { ...materializeNode("c", "fxnode.shader.value", { x: 10, y: -10 }), parentId: nodeId("f") };
const doc = {
schemaVersion: 1,
graphId: "g",
catalogVersion: 1,
nodes: { f: frame, c: child },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0];
assert.deepEqual(groupRoots(new Set([nodeId("f"), nodeId("c")]), layoutGraph(doc, transform)), [nodeId("f")]);
});
test("transient node order controls overlapping paint and hit order", () => {
const a = materializeNode("a", "fxnode.shader.value", { x: 0, y: 0 }),
b = materializeNode("b", "fxnode.shader.value", { x: 0, y: 0 }),
doc = {
schemaVersion: 1,
graphId: "z",
catalogVersion: 1,
nodes: { a, b },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0];
const original = layoutGraph(doc, { ...transform, zoom: 1 }),
raised = applyNodeOrder(original, [nodeId("b"), nodeId("a")]),
point = worldToView({ x: 40, y: -20 }, raised.transform);
assert.deepEqual(raised.drawOrder.slice(-2), [nodeId("b"), nodeId("a")]);
assert.deepEqual(hitTest(raised, point), { kind: "node", id: nodeId("a") });
const shifted = { ...b, position: { x: 30, y: 0 } },
overlap = applyNodeOrder(layoutGraph({ ...doc, nodes: { a, b: shifted } }, { ...transform, zoom: 1 }), [
nodeId("a"),
nodeId("b"),
]);
assert.deepEqual(hitTest(overlap, worldToView({ x: 35, y: -36 }, overlap.transform)), {
kind: "node",
id: nodeId("b"),
});
});
test("reroute core starts links while its outer halo selects the node", () => {
const reroute = materializeNode("r", "fxnode.common.reroute", { x: 0, y: 0 }),
doc = {
schemaVersion: 1,
graphId: "reroute",
catalogVersion: 1,
nodes: { r: reroute },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0],
layout = layoutGraph(doc, { ...transform, zoom: 1 }),
node = layout.nodes.get(nodeId("r"))!,
center = worldToView({ x: node.bounds.x + 5, y: node.bounds.y - 5 }, layout.transform);
assert.equal(hitTest(layout, center, "output").kind, "socket");
assert.deepEqual(hitTest(layout, { x: center.x + 11, y: center.y }), { kind: "node", id: nodeId("r") });
});
test("interaction helpers hit sampled links, box nodes, plan replacement and clamp resize", () => {
const value = materializeNode("v", "fxnode.shader.value", { x: -100, y: 50 }),
math = materializeNode("m", "fxnode.shader.math", { x: 100, y: 50 });
const old = { id: "old", fromNodeId: "v", fromSocketId: "v:value", toNodeId: "m", toSocketId: "m:a", extensions: {} };
const doc = {
schemaVersion: 1,
graphId: "i",
catalogVersion: 1,
nodes: { v: value, m: math },
links: { old },
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0],
layout = layoutGraph(doc, { ...transform, zoom: 1 });
const link = layout.links.values().next().value!,
mid = worldToView(link.points[6]!, layout.transform);
assert.deepEqual(hitTest(layout, mid), { kind: "link", id: "old" });
const v = layout.nodes.get(nodeId("v"))!;
assert.deepEqual(
boxNodes(
layout,
worldToView({ x: v.bounds.x - 1, y: v.bounds.y + 1 }, layout.transform),
worldToView({ x: v.bounds.x + v.bounds.width + 1, y: v.bounds.y - v.bounds.height - 1 }, layout.transform),
),
[nodeId("v")],
);
assert.equal(
compatibleTargets(layout, "v:value" as never).some((s) => s.id === "m:a"),
true,
);
assert.equal(planLink(layout, "v:value" as never, "m:a" as never)?.type, "link.replace");
assert.deepEqual(clampResize(layout, nodeId("m"), { x: 10000, y: 10000 }), {
x: 700,
y: layout.nodes.get(nodeId("m"))!.minimumSize.y,
});
});
test("frame drop picks containing frame and rejects self", () => {
const frame = materializeNode("f", "fxnode.common.frame", { x: -200, y: 200 }),
node = materializeNode("n", "fxnode.shader.value", { x: 0, y: 0 });
const doc = {
schemaVersion: 1,
graphId: "d",
catalogVersion: 1,
nodes: { f: frame, n: node },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0],
layout = layoutGraph(doc, transform);
assert.equal(frameDropCandidate(layout, nodeId("n"), { x: -100, y: 100 }), nodeId("f"));
assert.equal(frameDropCandidate(layout, nodeId("f"), { x: -100, y: 100 }), undefined);
});
test("Color Ramp authoritative bounds resolve every interaction region", () => {
const node = materializeNode("r", "fxnode.shader.color-ramp", { x: 0, y: 0 }),
doc = {
schemaVersion: 1,
graphId: "r",
catalogVersion: 1,
nodes: { r: node },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0],
layout = layoutGraph(doc, { ...transform, zoom: 1 }),
control = layout.controls.get("r:parameter:ramp")!,
b = control.rampBounds!,
center = (r: typeof b.toolbar) => ({ x: r.x + r.width / 2, y: r.y - r.height / 2 });
assert.equal(hitRamp(control, { x: b.toolbar.x + 1, y: b.toolbar.y - 10 })?.target, "add");
assert.equal(hitRamp(control, { x: b.toolbar.x + b.toolbar.width * 0.18, y: b.toolbar.y - 10 })?.target, "remove");
assert.equal(hitRamp(control, { x: b.toolbar.x + b.toolbar.width * 0.4, y: b.toolbar.y - 10 })?.target, "flip");
assert.equal(hitRamp(control, { x: b.toolbar.x + b.toolbar.width * 0.8, y: b.toolbar.y - 10 })?.target, "distribute");
assert.equal(hitRamp(control, center(b.mode))?.target, "mode");
assert.equal(hitRamp(control, center(b.interpolation))?.target, "interpolation");
assert.equal(hitRamp(control, center(b.hue))?.target, "hue");
assert.equal(hitRamp(control, center(b.gradient))?.target, "gradient");
assert.equal(hitRamp(control, center(b.selector))?.target, "selector");
assert.equal(hitRamp(control, center(b.position))?.target, "position");
for (const x of [0.05, 0.5, 0.95])
assert.equal(hitRamp(control, { x: b.color.x + b.color.width * x, y: b.color.y - 10 })?.target, "swatch");
});
test("Color Balance owns three disjoint Blender-style grading wheels", () => {
const node = materializeNode("balance", "fxnode.compositor.color-balance", { x: 0, y: 0 }),
doc = {
schemaVersion: 1,
graphId: "balance",
catalogVersion: 1,
nodes: { balance: node },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0],
layout = layoutGraph(doc, { ...transform, zoom: 1 }),
placed = layout.nodes.get(nodeId("balance"))!,
row = placed.rows.find((item) => item.kind === "grading-wheels");
assert.ok(row && row.kind === "grading-wheels");
assert.deepEqual(
row.wheels.map((wheel) => wheel.label),
["Lift", "Gamma", "Gain"],
);
assert.ok(placed.minimumSize.x >= 400);
assert.equal(row.units, 7);
for (const wheel of row.wheels) {
const color = layout.controls.get(wheel.colorControlId)!,
scalar = layout.controls.get(wheel.scalarControlId)!,
bounds = color.colorWheelBounds!;
assert.equal(bounds.plane.width, bounds.plane.height);
assert.ok(bounds.lightness.x >= bounds.plane.x + bounds.plane.width);
for (const [region, rect] of Object.entries(bounds) as ["plane" | "lightness", typeof bounds.plane][])
assert.deepEqual(
hitTest(layout, worldToView({ x: rect.x + rect.width / 2, y: rect.y - rect.height / 2 }, layout.transform)),
{ kind: "color-wheel", id: color.id, region },
);
assert.equal(
hitTest(
layout,
worldToView(
{ x: scalar.bounds.x + scalar.bounds.width / 2, y: scalar.bounds.y - scalar.bounds.height / 2 },
layout.transform,
),
).kind,
"control",
);
}
for (let i = 1; i < row.wheels.length; i++) {
const prior = layout.controls.get(row.wheels[i - 1]!.colorControlId)!.bounds,
next = layout.controls.get(row.wheels[i]!.colorControlId)!.bounds;
assert.ok(next.x >= prior.x + prior.width);
}
const collapsed = layoutGraph(
{ ...doc, nodes: { balance: { ...node, collapsed: true } } },
{ ...transform, zoom: 1 },
).nodes.get(nodeId("balance"))!;
assert.equal(collapsed.bounds.width, placed.bounds.width);
assert.equal(collapsed.bounds.height, 24);
});
test("compound and component controls have bounded, disjoint layout cells", () => {
const types = [
["r", "fxnode.shader.color-ramp"],
["i", "fxnode.shader.image-texture"],
["g", "fxnode.compositor.color-balance"],
] as const;
const nodes = Object.fromEntries(
types.map(([id, type], index) => [id, materializeNode(id, type, { x: index * 500, y: 0 })]),
);
const doc = {
schemaVersion: 1,
graphId: "cells",
catalogVersion: 1,
nodes,
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0],
layout = layoutGraph(doc, transform);
const inside = (
outer: { x: number; y: number; width: number; height: number },
inner: { x: number; y: number; width: number; height: number },
) =>
inner.x >= outer.x &&
inner.x + inner.width <= outer.x + outer.width &&
inner.y <= outer.y &&
inner.y - inner.height >= outer.y - outer.height;
for (const control of layout.controls.values()) {
const node = layout.nodes.get(control.nodeId)!;
assert.ok(inside(node.bounds, control.bounds), `${control.id} is inside its node`);
for (let index = 1; index < control.subfields.length; index++)
assert.ok(
control.subfields[index]!.bounds.x -
(control.subfields[index - 1]!.bounds.x + control.subfields[index - 1]!.bounds.width) >=
2,
"component gutter",
);
}
const ramp = layout.controls.get("r:parameter:ramp")!,
rampRow = layout.nodes.get(nodeId("r"))!.rows.find((row) => row.kind === "control")!;
for (const row of layout.nodes.get(nodeId("r"))!.rows.filter((row) => row.kind === "socket"))
assert.ok(
row.bounds.y - row.bounds.height >= rampRow.bounds.y || rampRow.bounds.y - rampRow.bounds.height >= row.bounds.y,
"ramp and sockets disjoint",
);
const ordinary = [layout.controls.get("i:parameter:interpolation")!, layout.controls.get("i:parameter:projection")!],
factor = layout.controls.get("g:socket:g:factor")!;
assert.ok([...ordinary, factor].every((control) => control.bounds.height === 18));
assert.ok(
ordinary.every(
(control) =>
Math.abs(
(control.bounds.x - layout.nodes.get(control.nodeId)!.bounds.x) /
layout.nodes.get(control.nodeId)!.bounds.width -
0.42,
) < 1e-9,
),
);
assert.equal(factor.bounds.x - layout.nodes.get(factor.nodeId)!.bounds.x, 12);
});
test("numeric fields expose Blender-style range fill and step geometry", () => {
const principled = materializeNode("p", "fxnode.shader.principled-bsdf", { x: 0, y: 0 }),
valueNode = materializeNode("v", "fxnode.shader.value", { x: 500, y: 0 }),
doc = {
schemaVersion: 1,
graphId: "numeric-fields",
catalogVersion: 1,
nodes: { p: principled, v: valueNode },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0],
layout = layoutGraph(doc, transform),
roughness = layout.controls.get("p:socket:p:roughness")!.numericFields[0]!,
unbounded = layout.controls.get("v:parameter:value")!.numericFields[0]!;
assert.deepEqual(roughness.range, { minimum: 0, maximum: 1 });
assert.equal(unbounded.range, undefined);
assert.equal(roughness.decrement.width, 7);
assert.equal(roughness.increment.width, 7);
assert.ok(roughness.value.x >= roughness.decrement.x + roughness.decrement.width);
assert.ok(roughness.increment.x >= roughness.value.x + roughness.value.width);
});
test("color controls are compact swatches rather than RGBA fields", () => {
const colorNode = materializeNode("c", "fxnode.shader.color", { x: 0, y: 0 }),
principled = materializeNode("p", "fxnode.shader.principled-bsdf", { x: 300, y: 0 }),
doc = {
schemaVersion: 1,
graphId: "swatches",
catalogVersion: 1,
nodes: { c: colorNode, p: principled },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0],
layout = layoutGraph(doc, transform),
parameter = layout.controls.get("c:parameter:color")!,
socket = layout.controls.get("p:socket:p:base-color")!;
for (const control of [parameter, socket]) {
assert.equal(control.kind, "color");
assert.equal(control.subfields.length, 0);
assert.equal(control.numericFields.length, 0);
assert.equal(
hitTest(
layout,
worldToView(
{ x: control.bounds.x + control.bounds.width / 2, y: control.bounds.y - control.bounds.height / 2 },
layout.transform,
),
).kind,
"control",
);
}
assert.equal(layout.nodes.get(nodeId("c"))!.minimumSize.x, 151);
assert.equal(layout.nodes.get(nodeId("p"))!.bounds.width, 208);
});
test("resource previews and open buttons are authoritative worker hit targets", () => {
const image = materializeNode("i", "fxnode.shader.image-texture", { x: 0, y: 0 }),
document = {
schemaVersion: 1,
graphId: "resource-hits",
catalogVersion: 1,
nodes: { i: image },
links: {},
metadata: {},
} as unknown as Parameters<typeof layoutGraph>[0],
layout = layoutGraph(document, transform),
control = layout.controls.get("i:parameter:image")!,
bounds = control.resourceBounds!,
center = (rect: typeof bounds.preview) =>
worldToView({ x: rect.x + rect.width / 2, y: rect.y - rect.height / 2 }, layout.transform);
assert.equal(control.kind, "resource");
assert.deepEqual(hitTest(layout, center(bounds.preview)), { kind: "resource", id: control.id });
assert.deepEqual(hitTest(layout, center(bounds.open)), { kind: "resource", id: control.id });
});
+22
View File
@@ -0,0 +1,22 @@
import test from "node:test";
import assert from "node:assert/strict";
import { isInSrgbGamut, mapOklchToSrgb, maxSrgbChroma, oklabToOklch, srgbToOklab } from "@lib/color/oklab.js";
test("Oklab conversion matches reference primaries", () => {
const red = srgbToOklab([1, 0, 0]);
assert.ok(Math.abs(red.l - 0.627955) < 1e-5);
assert.ok(Math.abs(red.a - 0.224863) < 1e-5);
assert.ok(Math.abs(red.b - 0.125846) < 1e-5);
const white = srgbToOklab([1, 1, 1]);
assert.ok(Math.abs(white.l - 1) < 1e-6);
});
test("Oklch gamut mapping preserves lightness and hue by reducing chroma", () => {
const requested = { l: 0.65, c: 0.6, h: 1.2 },
limit = maxSrgbChroma(requested.l, requested.h);
assert.ok(limit < requested.c);
assert.equal(isInSrgbGamut({ ...requested, c: limit }), true);
const rgb = mapOklchToSrgb(requested);
assert.ok(rgb.every((value) => value >= 0 && value <= 1));
const mapped = oklabToOklch(srgbToOklab(rgb));
assert.ok(Math.abs(mapped.l - requested.l) < 2e-5);
assert.ok(Math.abs(mapped.h - requested.h) < 2e-4);
});
+536
View File
@@ -0,0 +1,536 @@
import assert from "node:assert/strict";
import test from "node:test";
import { PERSISTENCE_LIMITS } from "@lib/composition/bound-document.js";
import { createFxNodeHeadless } from "@lib/headless-runtime.js";
const numberValue = (value: number) => ({ kind: "number" as const, value });
const rampDefault = {
kind: "json" as const,
value: {
colorMode: "rgb",
interpolation: "linear",
hueInterpolation: "near",
stops: [
{ id: "a", position: 0, color: [0, 0, 0, 1] },
{ id: "b", position: 1, color: [1, 1, 1, 1] },
],
},
};
const node = (migrations: readonly any[], version = 3) => ({
version,
title: "Arbitrary Migrating Thing",
behavior: "standard" as const,
style: "common",
parameters: {
gain: { type: "number" as const, default: numberValue(7) },
bonus: { type: "number" as const, default: numberValue(11) },
spectrum: { type: "json" as const, codec: "color-ramp/v1" as const, default: rampDefault },
},
sockets: {
source: {
title: "Source",
direction: "output" as const,
type: "float",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
},
sink: {
title: "Sink",
direction: "input" as const,
type: "float",
maxIncomingLinks: 8,
visible: true,
value: null,
showValue: false,
},
},
ui: [
{ kind: "parameter" as const, parameter: "gain" },
{ kind: "parameter" as const, parameter: "bonus" },
{ kind: "widget" as const, widget: "color-ramp" as const, parameter: "spectrum" },
{ kind: "socket" as const, socket: "source" },
{ kind: "socket" as const, socket: "sink" },
],
muteBypass: [],
migrations,
});
const edges = [
{
fromVersion: 1,
toVersion: 2,
steps: [
{ kind: "rename-parameter", from: "strength", to: "gain" },
{ kind: "rename-socket", from: "send", to: "source" },
{ kind: "rename-socket", from: "receive", to: "sink" },
],
},
{
fromVersion: 2,
toVersion: 3,
steps: [
{ kind: "materialize-missing", target: "parameter", key: "bonus" },
{ kind: "migrate-parameter", parameter: "spectrum", codec: "color-ramp/legacy-stops" },
],
},
] as const;
const color = "#000000" as const;
const theme = Object.fromEntries(
[
"background",
"grid",
"frame",
"frameHeader",
"body",
"control",
"controlFill",
"controlEditing",
"textSelection",
"outline",
"text",
"muted",
"shadow",
"nodeSelected",
"nodeActive",
"unknownHeader",
"unknownSocket",
"linkMuted",
"knifeMuted",
"emphasis",
"focus",
"editOutline",
"resize",
"muteOverlay",
"boxSelectionFill",
"checkerLight",
"checkerDark",
"widgetBorder",
"rampBorder",
"resourceBackground",
].map((key) => [key, color]),
);
const make = (migrations: readonly any[] = edges, version = 3) =>
createFxNodeHeadless({
schemaVersion: 2,
id: "peculiar-migration-suite",
version: 47,
compatibility: { wildcardInputTypes: [] },
theme,
socketTypes: { float: { title: "Float", color, acceptsFrom: ["float"] } },
nodeStyles: { common: { header: color } },
resources: {},
nodes: { "acme.odd-unit": node(migrations, version) },
} as any);
const current = (runtime = make(), id = "n") =>
runtime.save({ ...runtime.emptyDocument(), nodes: { [id]: runtime.materializeNode(id, "acme.odd-unit") } } as any);
const historical = (runtime = make(), id = "n") => {
const raw = structuredClone(current(runtime, id));
const n: any = raw.nodes[0];
n.typeVersion = 1;
n.parameters.strength = numberValue(23);
delete n.parameters.gain;
delete n.parameters.bonus;
n.parameters.spectrum = [
{ position: 0, color: [0, 0, 0, 1] },
{ position: 1, color: [1, 0, 0, 1] },
];
for (const s of n.sockets) {
if (s.key === "source") {
s.key = "send";
s.id = `${id}:send`;
} else {
s.key = "receive";
s.id = `${id}:receive`;
}
}
return raw;
};
const decode = (runtime: ReturnType<typeof make>, raw: unknown) => {
const result = runtime.decodeGraphDocument(raw);
assert.equal(result.ok, true, result.ok ? undefined : JSON.stringify(result.issues));
return result.ok ? result.value : runtime.emptyDocument();
};
test("custom parameter rename and missing default materialize", () => {
const r = make(),
d = decode(r, historical(r)),
n: any = d.nodes.n;
assert.deepEqual(n.parameters.gain, numberValue(23));
assert.deepEqual(n.parameters.bonus, numberValue(11));
assert.equal("strength" in n.parameters, false);
});
test("socket rename rewrites output/input endpoints and preserves link identity and extensions", () => {
const r = make(),
raw: any = historical(r);
raw.nodes.push(structuredClone(raw.nodes[0]));
raw.nodes[1].id = "m";
for (const s of raw.nodes[1].sockets) s.id = s.id.replace(/^n:/, "m:");
raw.links = [
{
id: "out",
fromNodeId: "n",
fromSocketId: "n:send",
toNodeId: "m",
toSocketId: "m:receive",
muted: false,
extensions: { custom: { x: 1 } },
},
{
id: "in",
fromNodeId: "m",
fromSocketId: "m:send",
toNodeId: "n",
toSocketId: "n:receive",
muted: true,
extensions: { tag: "kept" },
},
];
const saved = r.save(decode(r, raw));
assert.deepEqual(
saved.links.map((x) => [x.id, x.fromSocketId, x.toSocketId, x.extensions]),
[
["in", "m:source", "n:sink", { tag: "kept" }],
["out", "n:source", "m:sink", { custom: { x: 1 } }],
],
);
});
test("graph state decoding never performs persistence migrations, materialization, rewrites, or reordering", () => {
const r = make(),
raw: any = historical(r);
raw.nodes.push(structuredClone(raw.nodes[0]));
raw.nodes[1].id = "m";
for (const s of raw.nodes[1].sockets) s.id = s.id.replace(/^n:/, "m:");
raw.links = [
{
id: "out",
fromNodeId: "n",
fromSocketId: "n:send",
toNodeId: "m",
toSocketId: "m:receive",
muted: false,
extensions: {},
},
{
id: "in",
fromNodeId: "m",
fromSocketId: "m:send",
toNodeId: "n",
toSocketId: "n:receive",
muted: false,
extensions: {},
},
];
assert.equal(r.decodeGraphDocument(raw).ok, true);
const state = {
graphId: raw.graphId,
catalogVersion: raw.catalogVersion,
nodes: raw.nodes.map((node: any) => ({ ...node, known: true })),
links: raw.links,
metadata: raw.metadata,
},
migrating = r.decodeGraphState(state);
assert.equal(migrating.ok, false);
if (!migrating.ok) assert.equal(migrating.issues[0]?.code, "state.inexact");
const currentState = r.getState(r.createEngine(decode(r, current(r)))),
reordered = { ...currentState, nodes: [...currentState.nodes].reverse() };
assert.equal(r.decodeGraphState(reordered).ok, currentState.nodes.length < 2);
const promoted = { ...currentState, nodes: currentState.nodes.map((node: any) => ({ ...node, known: false })) },
promotion = r.decodeGraphState(promoted);
assert.equal(promotion.ok, false);
if (!promotion.ok) assert.equal(promotion.issues[0]?.code, "state.inexact");
const currentTwo = decode(r, { ...current(r), nodes: [...current(r, "a").nodes, ...current(r, "b").nodes] } as any),
twoState = r.getState(r.createEngine(currentTwo)),
reorderedNodes = { ...twoState, nodes: [...twoState.nodes].reverse() };
assert.equal(r.decodeGraphState(reorderedNodes).ok, false);
const currentLinks = decode(
r,
r.save(
decode(r, {
...raw,
nodes: raw.nodes.map((node: any) => ({
...node,
typeVersion: 3,
parameters: { gain: numberValue(7), bonus: numberValue(11), spectrum: structuredClone(rampDefault) },
sockets: node.sockets.map((socket: any) => ({
...socket,
key: socket.key === "send" ? "source" : "sink",
id: socket.id.replace(/:(send|receive)$/, (_: string, key: string) =>
key === "send" ? ":source" : ":sink",
),
})),
})),
links: raw.links.map((link: any) => ({
...link,
fromSocketId: link.fromSocketId.replace(":send", ":source"),
toSocketId: link.toSocketId.replace(":receive", ":sink"),
})),
} as any),
),
),
linkState = r.getState(r.createEngine(currentLinks)),
reorderedLinks = { ...linkState, links: [...linkState.links].reverse() };
assert.equal(r.decodeGraphState(reorderedLinks).ok, false);
});
test("legacy color-ramp codec works on arbitrary node and parameter", () => {
const r = make(),
n: any = decode(r, historical(r)).nodes.n;
assert.equal(n.parameters.spectrum.kind, "json");
assert.deepEqual(
n.parameters.spectrum.value.stops.map((x: any) => x.id),
["stop-0", "stop-1"],
);
});
test("direct and multi-edge migration paths execute", () => {
const direct = make([
{ fromVersion: 2, toVersion: 3, steps: [{ kind: "materialize-missing", target: "parameter", key: "bonus" }] },
]);
const raw: any = structuredClone(current(direct));
raw.nodes[0].typeVersion = 2;
delete raw.nodes[0].parameters.bonus;
assert.equal((decode(direct, raw).nodes.n as any).known, true);
assert.equal((decode(make(), historical()).nodes.n as any).typeVersion, 3);
});
test("catalogVersion mismatch is irrelevant and output is normalized", () => {
const r = make(),
raw = historical(r) as any;
raw.catalogVersion = 999;
const d = decode(r, raw);
assert.equal(d.catalogVersion, 47);
assert.equal(r.save(d).catalogVersion, 47);
});
test("missing route variants and unknown types preserve original save payload", () => {
const cases: any[] = [];
const noEdge: any = historical(make([]));
cases.push([make([]), noEdge]);
const gap: any = historical(make([edges[0]]));
cases.push([make([edges[0]]), gap]);
const future: any = structuredClone(current());
future.nodes[0].typeVersion = 99;
cases.push([make(), future]);
const absent: any = structuredClone(current());
absent.nodes[0].typeId = "vendor.absent";
cases.push([make(), absent]);
for (const [r, raw] of cases) {
const d = decode(r, raw);
assert.equal((Object.values(d.nodes)[0] as any).known, false);
assert.deepEqual(r.save(d).nodes[0], raw.nodes[0]);
}
});
test("failed migration steps and obsolete final fields remain original unknown", () => {
const variants: any[] = [];
const malformed: any = historical();
malformed.nodes[0].parameters.spectrum = [{ bad: true }];
variants.push(malformed);
const missing: any = historical();
delete missing.nodes[0].parameters.strength;
variants.push(missing);
const parameterCollision: any = historical();
parameterCollision.nodes[0].parameters.gain = numberValue(4);
variants.push(parameterCollision);
const socketCollision: any = historical();
socketCollision.nodes[0].sockets.push({
...structuredClone(socketCollision.nodes[0].sockets[0]),
id: "n:source",
key: "source",
});
variants.push(socketCollision);
const obsolete: any = historical();
obsolete.nodes[0].parameters.obsolete = numberValue(1);
variants.push(obsolete);
for (const raw of variants) {
const r = make(),
d = decode(r, raw);
assert.equal((d.nodes.n as any).known, false);
assert.deepEqual(r.save(d).nodes[0], raw.nodes[0]);
}
});
test("malformed current-version payload hard fails catalog.invalid", () => {
const r = make(),
raw: any = structuredClone(current(r));
delete raw.nodes[0].parameters.gain;
const d = r.decodeGraphDocument(raw);
assert.equal(d.ok, false);
if (!d.ok) assert.ok(d.issues.some((x) => x.code === "catalog.invalid"));
});
test("current known nodes reject extra durable fields at every exactness level", () => {
const variants = [
(n: any) => {
n.obsolete = true;
},
(n: any) => {
n.parameters.gain.obsolete = true;
},
(n: any) => {
n.sockets[0].obsolete = true;
},
(n: any) => {
n.position.obsolete = true;
},
];
for (const mutate of variants) {
const r = make(),
raw: any = structuredClone(current(r));
mutate(raw.nodes[0]);
const decoded = r.decodeGraphDocument(raw);
assert.equal(decoded.ok, false);
if (!decoded.ok) assert.ok(decoded.issues.some((x) => x.code === "catalog.invalid"));
}
});
test("historical extras abort migration and preserve the original unknown payload", () => {
const variants = [
(n: any) => {
n.obsolete = true;
},
(n: any) => {
n.parameters.strength.obsolete = true;
},
(n: any) => {
n.sockets[0].obsolete = true;
},
];
for (const mutate of variants) {
const r = make(),
raw: any = historical(r);
mutate(raw.nodes[0]);
const decoded = decode(r, raw);
assert.equal((decoded.nodes.n as any).known, false);
assert.deepEqual(r.save(decoded).nodes[0], raw.nodes[0]);
}
});
test("persisted known is rejected rather than silently stripped from opaque nodes", () => {
const r = make(),
raw: any = structuredClone(current(r));
raw.nodes[0].typeId = "vendor.opaque";
raw.nodes[0].known = false;
const decoded = r.decodeGraphDocument(raw);
assert.equal(decoded.ok, false);
if (!decoded.ok) assert.ok(decoded.issues.some((x) => x.code === "decode.node"));
});
test("materialized defaults are independent clones", () => {
const r = make(),
a: any = r.materializeNode("a", "acme.odd-unit"),
b: any = r.materializeNode("b", "acme.odd-unit");
assert.notEqual(a.parameters.spectrum, b.parameters.spectrum);
assert.notEqual(a.parameters.spectrum.value, b.parameters.spectrum.value);
});
test("materialized socket compatibility arrays are independently clone-safe", () => {
const r = make(),
a: any = r.materializeNode("a", "acme.odd-unit"),
b: any = r.materializeNode("b", "acme.odd-unit"),
raw = r.save({ ...r.emptyDocument(), nodes: { a, b } } as any);
assert.notEqual(a.sockets[1].accepts, b.sockets[1].accepts);
assert.equal(r.decodeGraphDocument(raw).ok, true);
});
test("link destination collision causes no partial node or link migration", () => {
const r = make(),
raw: any = historical(r);
raw.links = [
{
id: "collision",
fromNodeId: "n",
fromSocketId: "n:send",
toNodeId: "n",
toSocketId: "n:sink",
muted: false,
extensions: { x: 1 },
},
];
const copy = structuredClone(raw),
d = r.decodeGraphDocument(raw);
assert.equal(d.ok, false);
assert.deepEqual(raw, copy);
});
test("opaque unknown custom socket fields roundtrip", () => {
const r = make(),
raw: any = structuredClone(current(r));
raw.nodes[0].typeId = "opaque.widget";
raw.nodes[0].sockets[0] = {
...raw.nodes[0].sockets[0],
dataType: "quark",
accepts: ["boson"],
defaultValue: null,
metadata: { private: { v: 1 } },
extensions: { socketExtra: true },
};
assert.deepEqual(r.save(decode(r, raw)), raw);
});
test("caller input is unchanged after successful and failed decode", () => {
const r = make();
for (const raw of [
historical(r),
(() => {
const x: any = structuredClone(current(r));
delete x.nodes[0].parameters.gain;
return x;
})(),
]) {
const copy = structuredClone(raw);
r.decodeGraphDocument(raw);
assert.deepEqual(raw, copy);
}
});
test("decode-save-decode stabilizes", () => {
const r = make(),
first = r.save(decode(r, historical(r))),
second = r.save(decode(r, first));
assert.deepEqual(second, first);
});
test("getter, cycle, sparse and nonfinite inputs error without throwing or executing getter", () => {
const r = make();
let calls = 0;
const getter: any = {
get schemaVersion() {
calls++;
throw Error("no");
},
};
const cycle: any = {};
cycle.self = cycle;
const sparse: any = structuredClone(current(r));
sparse.nodes.length = 2;
const hugeSparse = new Array(4_000_000_000);
const nonfinite: any = structuredClone(current(r));
nonfinite.nodes[0].position.x = Infinity;
for (const value of [getter, cycle, sparse, hugeSparse, nonfinite])
assert.doesNotThrow(() => assert.equal(r.decodeGraphDocument(value).ok, false));
const huge = r.decodeGraphDocument(hugeSparse);
assert.equal(huge.ok, false);
if (!huge.ok) assert.equal(huge.issues[0]?.code, "limit.values");
assert.equal(calls, 0);
});
test("node and link collection limits reject over-limit and admit at-limit", () => {
const r = make(),
base: any = { schemaVersion: 2, graphId: "limits", catalogVersion: 1, nodes: [], links: [], metadata: {} };
base.nodes = Array(PERSISTENCE_LIMITS.maxNodes + 1).fill(null);
let d = r.decodeGraphDocument(base);
assert.equal(d.ok, false);
if (!d.ok) assert.equal(d.issues[0]?.path, "/nodes");
base.nodes = [];
base.links = Array(PERSISTENCE_LIMITS.maxLinks + 1).fill(null);
d = r.decodeGraphDocument(base);
assert.equal(d.ok, false);
if (!d.ok) assert.equal(d.issues[0]?.path, "/links");
base.links = [];
assert.equal(r.decodeGraphDocument(base).ok, true);
});
test("bound runtime failed load returns the identical state object with version and history", () => {
const r = make();
let state: any = r.createEngine(r.emptyDocument());
const request: any = {
commandId: "x",
expectedVersion: 0,
source: "api",
command: { type: "node.add", nodeId: "n", nodeType: "acme.odd-unit", position: { x: 0, y: 0 } },
};
const added = r.transition(state, request);
assert.equal(added.status, "committed");
if (added.status === "committed") state = added.state;
const bad: any = structuredClone(current(r));
delete bad.nodes[0].parameters.gain;
const result = r.load(state, bad);
assert.equal(result.ok, false);
assert.equal(result.state, state);
assert.equal(result.state.version, 1);
assert.equal(result.state.undo, state.undo);
});
+42
View File
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
advancePointerLaneFence,
createPointerLane,
pointerLaneFence,
publishPointerMove,
readPointerMove,
type PointerMoveWire,
} from "@lib/browser/pointer-lane.js";
const move = (x: number, y: number): PointerMoveWire => ({
kind: "pointer",
phase: "move",
pointerId: 7,
pointerType: "pen",
position: { x, y },
button: -1,
buttons: 1,
modifiers: 8,
});
test("shared pointer lane publishes exact latest coordinates and fences", () => {
const lane = createPointerLane();
assert.equal(pointerLaneFence(lane), 0);
const generation = 0x1_0000_0007;
const sequence = publishPointerMove(lane, move(-12.25, 987.125), generation);
assert.equal(sequence, 1);
assert.deepEqual(readPointerMove(lane), { sequence: 1, hostGeneration: generation, event: move(-12.25, 987.125) });
assert.equal(advancePointerLaneFence(lane), 1);
assert.equal(pointerLaneFence(lane), 1);
assert.equal(publishPointerMove(lane, { ...move(4, 5), pointerType: "unknown" }, generation + 1), undefined);
assert.deepEqual(readPointerMove(lane), { sequence: 1, hostGeneration: generation, event: move(-12.25, 987.125) });
});
test("shared pointer lane rejects an in-progress publication", () => {
const lane = createPointerLane();
publishPointerMove(lane, move(1, 2), 3);
const words = new Int32Array(lane);
Atomics.store(words, 0, Atomics.load(words, 0) | 1);
assert.equal(readPointerMove(lane), undefined);
});
+304
View File
@@ -0,0 +1,304 @@
import assert from "node:assert/strict";
import test from "node:test";
import { compileFxNodeComposition } from "@lib/composition/index.js";
import { bindFxNodeHeadless } from "@lib/headless-runtime.js";
import { layoutGraph } from "@lib/layout/layout-graph.js";
import { effectivelyMutedLinks } from "@lib/layout/link-mute.js";
import { IndexedLayoutStore } from "@lib/layout/indexed-layout-store.js";
import { layoutSocketsCompatible } from "@lib/layout/types.js";
import { compatibleTargets } from "@lib/worker/interaction.js";
const theme = {
background: "#010203",
grid: "#040506",
frame: "#070809",
frameHeader: "#0a0b0c",
body: "#0d0e0f",
control: "#101112",
controlFill: "#131415",
controlEditing: "#161718",
textSelection: "#191a1b",
outline: "#1c1d1e",
text: "#1f2021",
muted: "#222324",
shadow: "#252627",
nodeSelected: "#28292a",
nodeActive: "#2b2c2d",
unknownHeader: "#2e2f30",
unknownSocket: "#313233",
linkMuted: "#343536",
knifeMuted: "#373839",
emphasis: "#3a3b3c",
focus: "#3d3e3f",
editOutline: "#404142",
resize: "#434445",
muteOverlay: "#464748",
boxSelectionFill: "#494a4b",
checkerLight: "#4c4d4e",
checkerDark: "#4f5051",
widgetBorder: "#525354",
rampBorder: "#555657",
resourceBackground: "#58595a",
} as const;
const ramp = () => ({
kind: "json" as const,
value: {
colorMode: "rgb",
interpolation: "linear",
hueInterpolation: "near",
stops: [
{ id: "dark", position: 0, color: [0, 0, 0, 1] },
{ id: "light", position: 1, color: [1, 1, 1, 1] },
],
},
});
const input = <const T extends string>(type: T, title = "Input", visible = true) => ({
title,
direction: "input" as const,
type,
maxIncomingLinks: 1,
visible,
value: null,
showValue: false,
});
const output = <const T extends string>(type: T, title = "Output") => ({
title,
direction: "output" as const,
type,
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
});
const base = (behavior: "standard" | "frame" | "reroute", style: "violet" | "frameStyle" = "violet") => ({
version: 1,
title: "Node",
behavior,
style,
parameters: {},
sockets: {},
ui: [],
muteBypass: [],
migrations: [],
});
const compositionSource = {
schemaVersion: 2,
id: "adversarial-presentation",
version: 73,
compatibility: { wildcardInputTypes: ["universal-destination"] },
theme,
socketTypes: {
"source-signal": { title: "Signal", color: "#a1b2c3", acceptsFrom: ["source-signal"] },
"universal-destination": { title: "Everything", color: "#d4e5f6", acceptsFrom: [] },
},
nodeStyles: { violet: { header: "#7654ab" }, frameStyle: { header: "#abcdef" } },
resources: {
photo: {
kind: "image",
title: "Default image title",
openTitle: "Default open",
accept: ["image/png"],
referencePrefix: "test-image:",
maxBytes: 1024,
maxWidth: 64,
maxHeight: 64,
maxPixels: 4096,
},
},
nodes: {
"fxnode.common.frame": { ...base("standard"), title: "Frame-looking ordinary" },
"fxnode.common.reroute": { ...base("standard"), title: "Reroute-looking ordinary" },
"totally-unrelated-container": { ...base("frame", "frameStyle"), title: "Actual frame" },
"odd-junction": {
...base("reroute"),
title: "Actual reroute",
sockets: { in: input("source-signal"), out: output("source-signal") },
ui: [
{ kind: "socket", socket: "in" },
{ kind: "socket", socket: "out" },
],
muteBypass: [["in", "out"]],
},
source: { ...base("standard"), sockets: { out: output("source-signal") }, ui: [{ kind: "socket", socket: "out" }] },
destination: {
...base("standard"),
parameters: { mode: { type: "string", default: { kind: "string", value: "show" }, enum: ["show", "hide"] } },
sockets: {
wild: input("universal-destination", "Schema label"),
hidden: input("source-signal", "Hidden socket"),
conditional: input("source-signal", "Conditional socket"),
},
ui: [
{ kind: "parameter", parameter: "mode" },
{ kind: "socket", socket: "wild", title: "Overridden socket title" },
{ kind: "hidden", target: "socket", socket: "hidden" },
{ kind: "socket", socket: "conditional", visibleWhen: { parameter: "mode", equals: "show" } },
],
},
controls: {
...base("standard"),
parameters: {
ramp: { type: "json", codec: "color-ramp/v1", default: ramp() },
ordinaryRampJson: { type: "json", codec: "color-ramp/v1", default: ramp() },
imageRef: { type: "string", default: { kind: "string", value: "" } },
lift: { type: "number", default: { kind: "number", value: 0 } },
gamma: { type: "number", default: { kind: "number", value: 1 } },
gain: { type: "number", default: { kind: "number", value: 1 } },
liftColor: { type: "color", default: { kind: "color", value: [0, 0, 0, 1] } },
gammaColor: { type: "color", default: { kind: "color", value: [0.5, 0.5, 0.5, 1] } },
gainColor: { type: "color", default: { kind: "color", value: [1, 1, 1, 1] } },
},
sockets: {},
ui: [
{ kind: "widget", widget: "color-ramp", parameter: "ramp" },
{ kind: "parameter", parameter: "ordinaryRampJson", title: "Plain JSON" },
{ kind: "resource", resource: "photo", parameter: "imageRef", title: "Plate", openTitle: "Choose plate" },
{
kind: "widget",
widget: "grading-wheels",
bindings: [
{ title: "Shadows", scalar: "lift", color: "liftColor" },
{ title: "Midtones", scalar: "gamma", color: "gammaColor" },
{ title: "Highlights", scalar: "gain", color: "gainColor" },
],
},
],
},
},
} as const;
const compiled = compileFxNodeComposition(structuredClone(compositionSource));
const runtime = bindFxNodeHeadless(compiled);
const transform = { center: { x: 200, y: 0 }, zoom: 1, viewport: { x: 1600, y: 1000 }, dpr: 1 };
const link = (
id: string,
fromNodeId: string,
fromSocketId: string,
toNodeId: string,
toSocketId: string,
muted = false,
) => ({ id, fromNodeId, fromSocketId, toNodeId, toSocketId, muted, extensions: {} });
test("custom behavior metadata, UI placement, widgets, resources, and colors are authoritative", () => {
const lookFrame = runtime.materializeNode("look-frame", "fxnode.common.frame", { x: 0, y: 0 });
const lookReroute = runtime.materializeNode("look-reroute", "fxnode.common.reroute", { x: 220, y: 0 });
const frame = runtime.materializeNode("container", "totally-unrelated-container", { x: 0, y: 300 });
const child = runtime.materializeNode("child", "source", { x: 40, y: -40 }, "container");
const junction = runtime.materializeNode("junction", "odd-junction", { x: 400, y: 100 });
const controls = runtime.materializeNode("arbitrary", "controls", { x: 600, y: 300 });
const destination = runtime.materializeNode("dest", "destination", { x: 1100, y: 200 });
const document = {
...runtime.emptyDocument("custom"),
nodes: {
"look-frame": lookFrame,
"look-reroute": lookReroute,
container: frame,
child,
junction,
arbitrary: controls,
dest: destination,
},
links: {},
} as any;
const layout = layoutGraph(compiled, document, transform);
assert.equal(layout.nodes.get("look-frame" as never)?.kind, "node");
assert.equal(layout.nodes.get("look-reroute" as never)?.kind, "node");
assert.equal(layout.nodes.get("container" as never)?.kind, "frame");
assert.equal(layout.nodes.get("junction" as never)?.kind, "reroute");
assert.equal(layout.drawOrder[0], "container");
const f = layout.nodes.get("container" as never)!,
c = layout.nodes.get("child" as never)!;
assert.ok(f.bounds.width > 100 && f.bounds.x <= c.bounds.x - 30, "custom frame fits its child and paints behind it");
assert.equal(layout.sockets.has("dest:hidden" as never), false);
assert.equal(layout.sockets.get("dest:wild" as never)?.label, "Overridden socket title");
assert.equal(layout.sockets.has("dest:conditional" as never), true);
const hiddenMode = {
...destination,
parameters: { ...destination.parameters, mode: { kind: "string", value: "hide" } },
};
const conditional = layoutGraph(compiled, { ...document, nodes: { ...document.nodes, dest: hiddenMode } }, transform);
assert.equal(conditional.sockets.has("dest:conditional" as never), false);
assert.equal(
conditional.nodes.get("dest" as never)?.rows.some((r: any) => r.socketId === "dest:conditional"),
false,
);
const explicit = layout.controls.get("arbitrary:parameter:ramp")!,
plain = layout.controls.get("arbitrary:parameter:ordinaryRampJson")!,
resource = layout.controls.get("arbitrary:parameter:imageRef")!;
assert.equal(explicit.kind, "color-ramp");
assert.ok(explicit.rampBounds);
assert.equal(plain.kind, "readonly-json");
assert.equal(plain.rampBounds, undefined);
assert.equal(resource.kind, "resource");
assert.ok(resource.resourceBounds);
assert.equal(resource.label, "Plate");
assert.equal(resource.openTitle, "Choose plate");
const wheels = layout.nodes.get("arbitrary" as never)?.rows.find((r) => r.kind === "grading-wheels");
assert.ok(wheels?.kind === "grading-wheels");
assert.deepEqual(
wheels.wheels.map((w) => w.label),
["Shadows", "Midtones", "Highlights"],
);
assert.ok(wheels.wheels.every((w) => layout.controls.get(w.colorControlId)?.colorWheelBounds));
assert.equal(layout.nodes.get("arbitrary" as never)?.headerColor, "#7654ab");
assert.equal(layout.sockets.get("junction:out" as never)?.color, "#a1b2c3");
const unknown = {
...junction,
known: false,
typeId: "odd-junction",
id: "opaque",
sockets: junction.sockets.map((s) => ({ ...s, id: `opaque:${s.key}` })),
};
const opaqueLayout = layoutGraph(compiled, { ...document, nodes: { opaque: unknown }, links: {} }, transform);
assert.equal(opaqueLayout.nodes.get("opaque" as never)?.kind, "node");
assert.equal(opaqueLayout.nodes.get("opaque" as never)?.headerColor, theme.unknownHeader);
assert.ok([...opaqueLayout.sockets.values()].every((s) => s.color === theme.unknownSocket));
});
test("custom wildcard compatibility agrees across layout, interaction, and bound runtime", () => {
const source = runtime.materializeNode("s", "source"),
destination = runtime.materializeNode("d", "destination", { x: 300, y: 0 }),
document = { ...runtime.emptyDocument("compat"), nodes: { s: source, d: destination }, links: {} } as any,
layout = layoutGraph(compiled, document, transform),
from = layout.sockets.get("s:out" as never)!,
to = layout.sockets.get("d:wild" as never)!;
assert.equal(to.dataType, "universal-destination");
assert.equal(to.wildcardInput, true);
assert.equal(layoutSocketsCompatible(from, to), true);
assert.equal(
compatibleTargets(layout, from.id).some((x) => x.id === to.id),
true,
);
assert.equal(runtime.socketsCompatible(source.sockets[0]!, destination.sockets[0]!), true);
});
test("custom reroute mute propagation excludes opaque lookalikes and survives indexed rebuild authority", () => {
const source = runtime.materializeNode("s", "source"),
reroute = runtime.materializeNode("r", "odd-junction"),
destination = runtime.materializeNode("d", "destination"),
unknown = {
...reroute,
known: false,
id: "u",
typeId: "odd-junction",
sockets: reroute.sockets.map((s) => ({ ...s, id: `u:${s.key}` })),
};
const links = {
a: link("a", "s", "s:out", "r", "r:in", true),
b: link("b", "r", "r:out", "d", "d:wild"),
c: link("c", "s", "s:out", "u", "u:in", true),
d: link("d", "u", "u:out", "d", "d:wild"),
};
const document = {
...runtime.emptyDocument("mute"),
nodes: { s: source, r: reroute, d: destination, u: unknown },
links,
} as any,
muted = effectivelyMutedLinks(compiled, document);
assert.deepEqual([...muted].sort(), ["a", "b", "c"]);
const store = new IndexedLayoutStore(compiled, document),
authority = store.compiled;
store.rebuild({ ...document, links: {} });
assert.equal(store.compiled, authority);
assert.equal(store.compiled, compiled);
assert.equal(store.scene.nodes.get("r" as never)?.kind, "reroute");
});
+879
View File
@@ -0,0 +1,879 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
PROTOCOL_VERSION,
validCommandReceipt,
validCompositionReceipt,
validRequest,
validWorkerMessage,
} from "@lib/browser/protocol.js";
import {
decodeFxNodeActionOptions,
decodeFxNodeAddNodeParams,
decodeFxNodeCamera,
decodeFxNodeInput,
decodeFxNodeResourceAuthorization,
decodeFxNodeResourceData,
decodeFxNodeViewport,
} from "@lib/browser/host-decode.js";
import { fxNodeDevicePixels } from "@lib/browser/view-limits.js";
const viewport = { width: 1, height: 1, dpr: 1 };
const viewId = "internal-view";
test("protocol v2 messages are rejected", () => {
assert.equal(validRequest({ protocol: 2, type: "state.get", id: "request" }), false);
assert.equal(
validWorkerMessage({ protocol: 2, type: "view.node-menu.result", viewId, requestId: "request", open: false }),
false,
);
});
test("old unprefixed view message names are rejected", () => {
for (const type of [
"viewport",
"input",
"node.add",
"selection.remove",
"selection.mute",
"resource.set",
"pointer.flush",
"frame.consumed",
])
assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type, viewId }), false);
for (const type of ["frame", "selection.host", "node-menu.result", "resource.open"])
assert.equal(validWorkerMessage({ protocol: PROTOCOL_VERSION, type, viewId }), false);
});
test("view protocol is internally scoped and camera decoding is hostile-safe", () => {
const attach = {
protocol: PROTOCOL_VERSION,
type: "view.attach",
id: "request",
viewId: "internal-view",
viewport,
camera: { center: { x: 3, y: -4 }, zoom: 2 },
};
assert.equal(validRequest(attach), true);
assert.equal(validRequest({ ...attach, viewId: undefined }), false);
assert.equal(validRequest({ ...attach, camera: undefined }), false);
assert.equal(validRequest({ ...attach, callerViewId: "public" }), false);
for (const id of [undefined, "", 1, "x".repeat(513)]) assert.equal(validRequest({ ...attach, viewId: id }), false);
assert.equal(validRequest({ ...attach, extra: true }), false);
assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type: "view.detach", id: "request", viewId }), true);
assert.equal(
validRequest({ protocol: PROTOCOL_VERSION, type: "view.detach", id: "request", viewId, extra: true }),
false,
);
assert.deepEqual(decodeFxNodeCamera(attach.camera), attach.camera);
const frozen = Object.freeze({ center: Object.freeze({ x: 3, y: -4 }), zoom: 0.1 });
assert.deepEqual(decodeFxNodeCamera(frozen), frozen);
assert.deepEqual(decodeFxNodeCamera({ center: { x: 0, y: 0 }, zoom: 4 }), {
center: { x: 0, y: 0 },
zoom: 4,
});
for (const value of [NaN, Infinity, -Infinity]) {
assert.throws(() => decodeFxNodeCamera({ center: { x: value, y: 0 }, zoom: 1 }), TypeError);
assert.throws(() => decodeFxNodeCamera({ center: { x: 0, y: 0 }, zoom: value }), TypeError);
}
assert.throws(
() => decodeFxNodeCamera({ center: { x: 0, y: 0 }, zoom: 1, extra: true } as never),
new TypeError("Invalid FxNode camera"),
);
assert.throws(
() => decodeFxNodeCamera({ center: { x: 0, y: 0 }, zoom: 1, [Symbol()]: true } as never),
new TypeError("Invalid FxNode camera"),
);
let cameraReads = 0;
const accessorCamera = Object.defineProperty({ center: { x: 0, y: 0 } }, "zoom", {
enumerable: true,
get() {
cameraReads++;
return 1;
},
});
assert.throws(() => decodeFxNodeCamera(accessorCamera as never), new TypeError("Invalid FxNode camera"));
assert.equal(cameraReads, 0);
const center = { x: 7, y: 8 };
const detachedCamera = decodeFxNodeCamera({ center, zoom: 1 });
center.x = 99;
assert.deepEqual(detachedCamera, { center: { x: 7, y: 8 }, zoom: 1 });
for (const zoom of [0.099, 4.001])
assert.throws(() => decodeFxNodeCamera({ center: { x: 0, y: 0 }, zoom }), RangeError);
const nested = Proxy.revocable({ x: 0, y: 0 }, {});
nested.revoke();
assert.throws(() => decodeFxNodeCamera({ center: nested.proxy, zoom: 1 }), new TypeError("Invalid FxNode camera"));
const revoked = Proxy.revocable({}, {});
revoked.revoke();
assert.throws(() => decodeFxNodeCamera(revoked.proxy as never), new TypeError("Invalid FxNode camera"));
});
test("view.frame worker messages are exact and view IDs are bounded", () => {
const previous = globalThis.ImageBitmap;
class TestImageBitmap {
readonly width = 1;
readonly height = 1;
}
Object.defineProperty(globalThis, "ImageBitmap", { configurable: true, value: TestImageBitmap });
try {
const frame = {
protocol: PROTOCOL_VERSION,
type: "view.frame",
viewId,
bitmap: new TestImageBitmap(),
renderId: 0,
frameId: 0,
hostGeneration: 0,
surfaceGeneration: 0,
deviceWidth: 1,
deviceHeight: 1,
host: { colorPickerOpen: false },
};
assert.equal(validWorkerMessage(frame), true);
for (const candidate of [
{ ...frame, bitmap: {} },
{ ...frame, frameId: -1 },
{ ...frame, renderId: 0.5 },
{ ...frame, hostGeneration: Number.MAX_SAFE_INTEGER + 1 },
{ ...frame, surfaceGeneration: -1 },
{ ...frame, deviceWidth: 0 },
{ ...frame, deviceHeight: 8193 },
{ ...frame, host: { colorPickerOpen: false, extra: true } },
{ ...frame, extra: true },
])
assert.equal(validWorkerMessage(candidate), false);
for (const invalidViewId of [undefined, "", 1, "x".repeat(513)]) {
const candidate = { ...frame, viewId: invalidViewId };
if (invalidViewId === undefined) delete (candidate as { viewId?: unknown }).viewId;
assert.equal(validWorkerMessage(candidate), false);
}
} finally {
if (previous === undefined) delete (globalThis as { ImageBitmap?: unknown }).ImageBitmap;
else Object.defineProperty(globalThis, "ImageBitmap", { configurable: true, value: previous });
}
});
test("input and viewport decoder tables cover every variant and boundary", () => {
const m0 = { alt: false, control: false, meta: false, shift: false },
m15 = { alt: true, control: true, meta: true, shift: true };
const cases = [
[
{ kind: "focus", phase: "focus" },
{ kind: "focus", phase: "focus" },
],
[
{ kind: "outside-pointer", button: -1 },
{ kind: "outside-pointer", button: -1 },
],
[
{
kind: "pointer",
phase: "move",
pointerId: 1,
pointerType: "pen",
position: { x: -2, y: -3 },
button: -1,
buttons: 0,
modifiers: m0,
},
{
kind: "pointer",
phase: "move",
pointerId: 1,
pointerType: "pen",
position: { x: -2, y: -3 },
button: -1,
buttons: 0,
modifiers: 0,
},
],
[
{ kind: "wheel", position: { x: -1, y: -2 }, delta: { x: -3, y: 4 }, modifiers: m15 },
{ kind: "wheel", position: { x: -1, y: -2 }, delta: { x: -3, y: 4 }, modifiers: 15 },
],
[
{ kind: "key", phase: "up", key: "A", code: "KeyA", repeat: false, modifiers: m15 },
{ kind: "key", phase: "up", key: "A", code: "KeyA", repeat: false, modifiers: 15 },
],
] as const;
for (const [input, expected] of cases) assert.deepEqual(decodeFxNodeInput(input as never), expected);
for (const value of [
{ width: 0, height: 8192, dpr: Number.MIN_VALUE },
{ width: 8192, height: 0, dpr: 1 },
{ width: 4096, height: 4096, dpr: 1 },
])
assert.deepEqual(decodeFxNodeViewport(value), value);
assert.deepEqual(decodeFxNodeViewport({ width: 0, height: 0, dpr: 4 }), { width: 0, height: 0, dpr: 4 });
assert.deepEqual(fxNodeDevicePixels(0, 0, 1), { width: 1, height: 1 });
assert.deepEqual(fxNodeDevicePixels(0.49, 0.5, 1), { width: 1, height: 1 });
assert.deepEqual(fxNodeDevicePixels(1.5, 2.5, 1), { width: 2, height: 3 });
assert.equal(fxNodeDevicePixels(4097, 4096, 1), undefined);
for (const value of [
{ width: -1, height: 1, dpr: 1 },
{ width: 8193, height: 0, dpr: 1 },
{ width: 4097, height: 4096, dpr: 1 },
{ width: 1, height: 1, dpr: 0 },
])
assert.throws(() => decodeFxNodeViewport(value), RangeError);
});
test("protocol tables reject malformed pointer fences", () => {
const base = { protocol: PROTOCOL_VERSION, type: "view.pointer.flush", viewId },
move = {
kind: "pointer",
phase: "move",
pointerId: 1,
pointerType: "mouse",
position: { x: 0, y: 0 },
button: 0,
buttons: 0,
modifiers: 0,
};
assert.equal(
validRequest({ ...base, pointerFence: { generation: 0, before: { sequence: 1, hostGeneration: 2, event: move } } }),
true,
);
for (const pointerFence of [
{},
{ generation: 0.5 },
{ generation: 0, extra: true },
{ generation: 0, before: {} },
{ generation: 0, before: { sequence: 1, event: move } },
{ generation: 0, before: { sequence: 1, hostGeneration: -1, event: move } },
{ generation: 0, before: { sequence: 1, hostGeneration: 0, event: { ...move, phase: "down" } } },
{ generation: 0, before: { sequence: 1, hostGeneration: 0, event: { ...move, position: { x: Infinity, y: 0 } } } },
])
assert.equal(validRequest({ ...base, pointerFence }), false);
});
test("host generations are required, exact, and nonnegative", () => {
const event = { kind: "focus", phase: "focus" },
input = { protocol: PROTOCOL_VERSION, type: "view.input", viewId, event, hostGeneration: 0 },
viewportRequest = {
protocol: PROTOCOL_VERSION,
type: "view.viewport",
id: "resize",
viewId,
viewport,
expectedSurfaceGeneration: 1,
hostGeneration: 0,
};
for (const value of [input, viewportRequest]) assert.equal(validRequest(value), true);
for (const generation of [undefined, -1, 0.5]) {
const candidate = { ...input, ...(generation === undefined ? {} : { hostGeneration: generation }) };
if (generation === undefined) delete (candidate as { hostGeneration?: unknown }).hostGeneration;
assert.equal(validRequest(candidate), false);
}
assert.equal(validRequest({ ...input, extra: true }), false);
assert.equal(validRequest({ ...viewportRequest, hostGeneration: -1 }), false);
assert.equal(validRequest({ ...viewportRequest, pointerFence: { generation: 1 } }), true);
assert.equal(validRequest({ ...viewportRequest, pointerFence: { generation: 0.5 } }), false);
for (const expectedSurfaceGeneration of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1])
assert.equal(validRequest({ ...viewportRequest, expectedSurfaceGeneration }), false);
const render = { protocol: PROTOCOL_VERSION, type: "view.render", viewId, renderId: 1, hostGeneration: 0 };
assert.equal(validRequest(render), true);
for (const renderId of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1])
assert.equal(validRequest({ ...render, renderId }), false);
const consumed = { protocol: PROTOCOL_VERSION, type: "view.frame.consumed", viewId, frameId: 0 };
assert.equal(validRequest(consumed), true);
assert.equal(validRequest({ ...consumed, extra: true }), false);
for (const frameId of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1])
assert.equal(validRequest({ ...consumed, frameId }), false);
});
test("resource open correlation is exact on input and worker messages", () => {
const event = {
kind: "pointer",
phase: "down",
pointerId: 1,
pointerType: "mouse",
position: { x: 2, y: 3 },
button: 0,
buttons: 1,
modifiers: 0,
},
input = {
protocol: PROTOCOL_VERSION,
type: "view.input",
viewId,
event,
hostGeneration: 0,
resourceOpenRequestId: "open-1",
},
resource = {
id: "image",
kind: "image",
title: "Image",
openTitle: "Open image",
accept: ["image/png"],
maxBytes: 1024,
maxWidth: 4,
maxHeight: 4,
maxPixels: 16,
},
open = {
protocol: PROTOCOL_VERSION,
type: "view.resource.open",
viewId,
requestId: "open-1",
authorization: { viewId, token: "node:parameter:image", graphVersion: 2, compositionRevision: 3 },
resource,
};
assert.equal(validRequest(input), true);
assert.equal(validWorkerMessage(open), true);
assert.equal(validWorkerMessage({ ...open, authorization: { ...open.authorization, viewId: "other" } }), true);
assert.equal(validWorkerMessage({ ...open, authorization: { ...open.authorization, extra: true } }), false);
assert.equal(validWorkerMessage({ ...open, authorization: { ...open.authorization, viewId: "" } }), false);
for (const requestId of ["", "x".repeat(513), 1]) {
assert.equal(validRequest({ ...input, resourceOpenRequestId: requestId }), false);
assert.equal(validWorkerMessage({ ...open, requestId }), false);
}
assert.equal(validRequest({ ...input, extra: true }), false);
assert.equal(validWorkerMessage({ ...open, bounds: { x: 0, y: 0, width: 1, height: 1 } }), false);
assert.equal(validWorkerMessage({ ...open, authorization: { ...open.authorization, extra: true } }), false);
assert.equal(validWorkerMessage({ ...open, resource: { ...resource, accept: ["image/png"], extra: true } }), false);
});
test("public host values decode once without getters and detach nested input", () => {
let reads = 0;
const getter = Object.defineProperty({ kind: "focus" }, "phase", {
enumerable: true,
get() {
reads++;
return "focus";
},
});
assert.throws(() => decodeFxNodeInput(getter as never), new TypeError("Invalid FxNode input"));
assert.equal(reads, 0);
const position = { x: 1, y: 2 },
mods = { alt: true, control: false, meta: true, shift: false },
input = {
kind: "pointer",
phase: "down",
pointerId: 1,
pointerType: "mouse",
position,
button: 0,
buttons: 1,
modifiers: mods,
} as const,
wire = decodeFxNodeInput(input);
position.x = 9;
mods.control = true;
assert.deepEqual(wire, {
kind: "pointer",
phase: "down",
pointerId: 1,
pointerType: "mouse",
position: { x: 1, y: 2 },
button: 0,
buttons: 1,
modifiers: 5,
});
assert.throws(
() => decodeFxNodeInput({ ...input, [Symbol()]: true } as never),
new TypeError("Invalid FxNode input"),
);
assert.throws(
() => decodeFxNodeViewport({ width: Infinity, height: 1, dpr: 1 }),
new TypeError("Invalid FxNode viewport"),
);
assert.throws(
() => decodeFxNodeViewport({ width: 8192, height: 8192, dpr: 1 }),
new RangeError("FxNode viewport is outside supported bounds"),
);
});
test("public action values reject accessors, symbols, and hostile proxies", () => {
assert.deepEqual(decodeFxNodeActionOptions({}), { kind: "current" });
let reads = 0;
const options = Object.defineProperty({}, "expectedVersion", {
enumerable: true,
get() {
reads++;
return 1;
},
});
const params = Object.defineProperty({ typeId: "test", viewPosition: { x: 1, y: 2 } }, "nodeId", {
enumerable: true,
get() {
reads++;
return "node";
},
});
assert.throws(() => decodeFxNodeActionOptions(options), new TypeError("Invalid action options"));
assert.throws(() => decodeFxNodeAddNodeParams(params), new TypeError("Invalid add-node parameters"));
assert.equal(reads, 0);
assert.throws(
() => decodeFxNodeAddNodeParams({ typeId: "test", viewPosition: { x: 1, y: 2 }, [Symbol()]: true }),
new TypeError("Invalid add-node parameters"),
);
const revoked = Proxy.revocable({}, {});
revoked.revoke();
assert.throws(() => decodeFxNodeActionOptions(revoked.proxy), new TypeError("Invalid action options"));
assert.throws(() => decodeFxNodeAddNodeParams(revoked.proxy), new TypeError("Invalid add-node parameters"));
});
test("protocol validators are total and bound request and node type ids", () => {
const throwing = new Proxy(
{},
{
get() {
throw new Error("hostile");
},
ownKeys() {
throw new Error("hostile");
},
},
);
const target = {};
const revoked = Proxy.revocable(target, {});
revoked.revoke();
for (const value of [throwing, revoked.proxy]) {
assert.doesNotThrow(() => validRequest(value));
assert.equal(validRequest(value), false);
assert.doesNotThrow(() => validWorkerMessage(value));
assert.equal(validWorkerMessage(value), false);
}
assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type: "state.get", id: "x".repeat(513) }), false);
assert.equal(
validRequest({
protocol: PROTOCOL_VERSION,
type: "node.add-at-view",
id: "r",
nodeId: "n",
nodeType: "x".repeat(129),
viewPosition: { x: 0, y: 0 },
}),
false,
);
// The live command wire format retains its historical 512-code-unit node type bound.
assert.equal(
validRequest({
protocol: PROTOCOL_VERSION,
type: "command",
id: "r",
expected: { kind: "current" },
command: { type: "node.add", nodeId: "n", nodeType: "x".repeat(129), position: { x: 0, y: 0 } },
}),
true,
);
assert.equal(
validRequest({
protocol: PROTOCOL_VERSION,
type: "command",
id: "r",
expected: { kind: "current" },
command: { type: "node.add", nodeId: "n", nodeType: "x".repeat(513), position: { x: 0, y: 0 } },
}),
false,
);
assert.equal(
validRequest({
protocol: PROTOCOL_VERSION,
type: "command",
id: "r",
expected: { kind: "current" },
command: Object.create({ type: "undo" }),
}),
false,
);
assert.equal(
validRequest({ protocol: PROTOCOL_VERSION, type: "load", id: "r", data: {}, expected: { kind: "current" } }),
true,
);
assert.equal(
validRequest({ protocol: PROTOCOL_VERSION, type: "load", id: "r", layout: {}, expected: { kind: "current" } }),
false,
);
assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type: "save.data", id: "r" }), true);
assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type: "save.data", id: "r", extra: true }), false);
assert.equal(validRequest(Object.create({ protocol: PROTOCOL_VERSION, type: "state.get", id: "r" })), false);
assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type: "snapshot", id: "r" }), false);
assert.equal(validRequest({ protocol: PROTOCOL_VERSION, type: "state.get", id: "r" }), true);
let inspected = 0;
const opaque = Object.defineProperty({}, "nodes", {
enumerable: true,
get() {
inspected++;
throw new Error("payload inspected");
},
});
assert.equal(
validRequest({
protocol: PROTOCOL_VERSION,
type: "state.set",
id: "r",
state: opaque,
expected: { kind: "current" },
}),
true,
);
assert.equal(inspected, 0);
assert.equal(
validRequest({
protocol: PROTOCOL_VERSION,
type: "state.set",
id: "r",
state: {},
expected: { kind: "current" },
extra: true,
}),
false,
);
assert.equal(
validRequest({
protocol: PROTOCOL_VERSION,
type: "view.resource.set",
viewId,
id: "r",
authorization: { viewId, token: "t", graphVersion: 2, compositionRevision: 0 },
resource: { name: "x.png", mime: "image/png", bytes: new ArrayBuffer(1) },
expected: { kind: "current" },
}),
true,
);
assert.equal(
validRequest(
Object.assign(Object.create({ event: { kind: "focus", phase: "focus" } }), {
protocol: PROTOCOL_VERSION,
type: "view.input",
viewId,
}),
),
false,
);
assert.equal(
validRequest({
protocol: PROTOCOL_VERSION,
type: "view.pointer.flush",
viewId,
pointerFence: Object.create({ generation: 0 }),
}),
false,
);
});
test("action requests, selection projections, menu results, and receipts are exact", () => {
const current = { kind: "current" as const };
assert.equal(
validRequest({
protocol: PROTOCOL_VERSION,
type: "view.node.add",
viewId,
id: "r",
nodeId: "n",
typeId: "t",
viewPosition: { x: 1, y: 2 },
expected: current,
}),
true,
);
assert.equal(
validRequest({ protocol: PROTOCOL_VERSION, type: "view.selection.remove", viewId, id: "r", expected: current }),
true,
);
assert.equal(
validRequest({
protocol: PROTOCOL_VERSION,
type: "view.selection.remove",
viewId,
id: "r",
expected: current,
value: false,
}),
false,
);
assert.equal(
validRequest({
protocol: PROTOCOL_VERSION,
type: "view.selection.mute",
viewId,
id: "r",
expected: current,
value: false,
}),
true,
);
assert.equal(
validRequest({ protocol: PROTOCOL_VERSION, type: "view.selection.mute", viewId, id: "r", expected: current }),
false,
);
const selection = (nodeCount: number, linkCount: number, canRemove: boolean) => ({
protocol: PROTOCOL_VERSION,
type: "view.selection.host",
viewId,
projection: { nodeCount, linkCount, canRemove, mute: { enabled: false } },
});
assert.equal(validWorkerMessage(selection(0, 0, false)), true);
assert.equal(validWorkerMessage(selection(1, 0, true)), true);
assert.equal(validWorkerMessage(selection(0, 0, true)), false);
assert.equal(validWorkerMessage(selection(1, 0, false)), false);
assert.equal(
validWorkerMessage({
protocol: PROTOCOL_VERSION,
type: "view.node-menu.result",
viewId,
requestId: "r",
open: false,
}),
true,
);
assert.equal(
validWorkerMessage({
protocol: PROTOCOL_VERSION,
type: "view.node-menu.result",
viewId,
requestId: "r",
open: true,
compositionRevision: 0,
viewPosition: { x: 1, y: 2 },
}),
true,
);
assert.equal(
validWorkerMessage({
protocol: PROTOCOL_VERSION,
type: "view.node-menu.result",
viewId,
requestId: "r",
open: false,
viewPosition: { x: 1, y: 2 },
}),
false,
);
assert.equal(validCommandReceipt({ status: "noop", version: 0 }), true);
assert.equal(validCommandReceipt({ status: "committed", version: 1 }), true);
assert.equal(validCommandReceipt({ status: "noop", version: 0, extra: true }), false);
});
test("resource DTO and wire decoding is exact, bounded, and hostile-safe", () => {
const authorization = { viewId, token: "control", graphVersion: 2, compositionRevision: 3 },
bytes = new ArrayBuffer(4),
resource = { name: "pixel.png", mime: "image/png", bytes };
assert.deepEqual(decodeFxNodeResourceAuthorization(authorization), authorization);
assert.throws(
() => decodeFxNodeResourceAuthorization({ ...authorization, viewId: "" }),
new TypeError("Invalid resource authorization"),
);
assert.throws(
() => decodeFxNodeResourceAuthorization({ ...authorization, extra: true }),
new TypeError("Invalid resource authorization"),
);
assert.equal(decodeFxNodeResourceData(resource).bytes, bytes);
let reads = 0;
const accessor = Object.defineProperty({ token: "control", graphVersion: 2 }, "compositionRevision", {
enumerable: true,
get() {
reads++;
return 3;
},
});
assert.throws(() => decodeFxNodeResourceAuthorization(accessor), new TypeError("Invalid resource authorization"));
assert.equal(reads, 0);
assert.throws(
() => decodeFxNodeResourceData({ ...resource, [Symbol()]: true }),
new TypeError("Invalid resource data"),
);
assert.throws(
() => decodeFxNodeResourceData({ ...resource, bytes: new ArrayBuffer(0) }),
new RangeError("Resource data is outside supported bounds"),
);
const request = {
protocol: PROTOCOL_VERSION,
type: "view.resource.set",
viewId,
id: "r",
authorization,
resource,
expected: { kind: "exact", version: 2 },
pointerFence: { generation: 1 },
};
assert.equal(validRequest(request), true);
assert.equal(validRequest({ ...request, authorization: { ...authorization, extra: true } }), false);
assert.equal(validRequest({ ...request, resource: { ...resource, name: "bad\nname" } }), false);
assert.equal(validRequest({ ...request, expected: { kind: "exact", version: -1 } }), false);
const nested = Proxy.revocable({ x: 1, y: 2 }, {});
nested.revoke();
assert.throws(
() => decodeFxNodeAddNodeParams({ typeId: "test", viewPosition: nested.proxy }),
new TypeError("Invalid add-node parameters"),
);
const bytesProxy = Proxy.revocable(new ArrayBuffer(4), {});
bytesProxy.revoke();
assert.throws(
() => decodeFxNodeResourceData({ ...resource, bytes: bytesProxy.proxy }),
new TypeError("Invalid resource data"),
);
});
test("malformed composition init remains recognizable protocol", () => {
const init = {
protocol: PROTOCOL_VERSION,
type: "init",
id: "init",
applicationId: "app",
applicationVersion: 1,
resources: {},
historyLimit: 0,
};
assert.equal(validRequest(init), true);
for (const presentation of [
{ viewport },
{ camera: { center: { x: 0, y: 0 }, zoom: 1 } },
{ pointerLane: new SharedArrayBuffer(32) },
])
assert.equal(validRequest({ ...init, ...presentation }), false);
assert.equal(
validRequest({
protocol: PROTOCOL_VERSION,
type: "init",
id: "init",
composition: {},
layout: null,
historyLimit: 0,
}),
false,
);
assert.equal(
validWorkerMessage({
protocol: PROTOCOL_VERSION,
type: "response",
id: "init",
ok: false,
error: {
code: "composition.invalid",
message: "Invalid composition",
issues: [{ code: "composition.schema", path: "/", message: "invalid" }],
},
}),
true,
);
assert.equal(
validWorkerMessage({
protocol: PROTOCOL_VERSION,
type: "response",
id: "init",
ok: false,
error: { code: "link.endpoint", message: "Missing endpoint", path: "/links/stale" },
}),
true,
);
assert.equal(
validWorkerMessage({
protocol: PROTOCOL_VERSION,
type: "response",
id: "init",
ok: false,
error: { code: "link.endpoint", message: "Missing endpoint", path: "/" + "x".repeat(513) },
}),
false,
);
assert.equal(
validWorkerMessage(Object.create({ protocol: PROTOCOL_VERSION, type: "response", id: "init", ok: true })),
false,
);
assert.equal(
validWorkerMessage({
protocol: PROTOCOL_VERSION,
type: "response",
id: "init",
ok: false,
error: Object.create({ code: "worker.error", message: "inherited" }),
}),
false,
);
});
test("live composition protocol validates updates, revisions, receipts and events", () => {
const request = (update: unknown, expected: unknown = { kind: "current" }) => ({
protocol: PROTOCOL_VERSION,
type: "composition.update",
id: "request",
expected,
update,
});
for (const update of [
{ kind: "composition.load", composition: {} },
{ kind: "theme.set", theme: {} },
{ kind: "compatibility.set", compatibility: { wildcardInputTypes: [] } },
{ kind: "socket.compose", id: "signal", definition: {} },
{ kind: "socket.remove", id: "signal" },
{ kind: "node.compose", id: "source", definition: {} },
{ kind: "node.remove", id: "source" },
])
assert.equal(validRequest(request(update)), true);
assert.equal(validRequest(request({ kind: "composition.load", composition: {}, extra: true })), false);
for (const expected of [
{ kind: "exact", revision: -1 },
{ kind: "exact", revision: 0.5 },
{ kind: "exact", revision: Number.MAX_SAFE_INTEGER + 1 },
])
assert.equal(validRequest(request({ kind: "node.remove", id: "source" }, expected)), false);
for (const id of ["", "x".repeat(129), "bad\nvalue", "__proto__", "prototype", "constructor"])
assert.equal(validRequest(request({ kind: "node.remove", id })), false);
assert.equal(validRequest({ ...request({ kind: "node.remove", id: "source" }), extra: true }), false);
assert.equal(validRequest(request({ kind: "node.remove", id: "source", extra: true })), false);
assert.equal(
validCompositionReceipt({
status: "committed",
revision: 1,
graphVersion: 0,
graphChanged: false,
historyReset: true,
}),
true,
);
assert.equal(
validCompositionReceipt({ status: "noop", revision: 0, graphVersion: 0, graphChanged: false, historyReset: false }),
true,
);
assert.equal(
validCompositionReceipt(
Object.create({ status: "noop", revision: 0, graphVersion: 0, graphChanged: false, historyReset: false }),
),
false,
);
assert.equal(
validCompositionReceipt({ status: "noop", revision: 0, graphVersion: 0, graphChanged: true, historyReset: false }),
false,
);
assert.equal(
validCompositionReceipt({
status: "committed",
revision: 1,
graphVersion: 0,
graphChanged: false,
historyReset: false,
}),
false,
);
assert.equal(
validWorkerMessage({
protocol: PROTOCOL_VERSION,
type: "composition.event",
envelope: {
baseRevision: 0,
revision: 1,
change: { kind: "node.compose", id: "source" },
baseGraphVersion: 2,
graphVersion: 3,
graphChanged: true,
historyReset: true,
},
}),
true,
);
assert.equal(
validWorkerMessage({
protocol: PROTOCOL_VERSION,
type: "composition.event",
envelope: {
baseRevision: 0,
revision: 2,
change: { kind: "theme.set" },
baseGraphVersion: 2,
graphVersion: 2,
graphChanged: false,
historyReset: true,
},
}),
false,
);
});
+41
View File
@@ -0,0 +1,41 @@
/** Compile-only mirror of README composition snippets; check:readme guards their key syntax. */
import type { FxNode, FxNodeDefinition, FxNodeSocketTypeDefinition, FxNodeStyleDefinition } from "@lib/index.js";
import { createFxNodeHeadless } from "@lib/headless.js";
import { minimalStyles, numberSocket, valueNode } from "../examples/minimal/definition.js";
import { colorBalanceNode } from "../examples/shared/nodes/color-balance.js";
import { exampleTheme } from "../examples/shared/theme.js";
const floatSocket = [
"float",
{ title: "Float", color: "#a8a8a8", acceptsFrom: ["float"] },
] as const satisfies readonly [string, FxNodeSocketTypeDefinition];
const styles = { compositorColor: { header: "#8c5cc4" } } as const satisfies Readonly<
Record<string, FxNodeStyleDefinition>
>;
export async function installColorBalance(api: FxNode) {
await api.setTheme(exampleTheme);
await api.setHeaderStyles(styles);
await api.composeSocket(...floatSocket);
await api.composeNode(...colorBalanceNode);
}
export const gradingWheelsRow = {
kind: "widget",
widget: "grading-wheels",
bindings: [
{ title: "Lift", scalar: "lift", color: "liftColor" },
{ title: "Gamma", scalar: "gamma", color: "gammaColor" },
{ title: "Gain", scalar: "gain", color: "gainColor" },
],
visibleWhen: { parameter: "mode", equals: "Lift/Gamma/Gain" },
} satisfies FxNodeDefinition["ui"][number];
export const readmeHeadless = createFxNodeHeadless({
schemaVersion: 2,
id: "fxnode.example.minimal",
version: 1,
compatibility: { wildcardInputTypes: [] },
theme: exampleTheme,
socketTypes: { [numberSocket[0]]: numberSocket[1] },
nodeStyles: minimalStyles,
resources: {},
nodes: { [valueNode[0]]: valueNode[1] },
} as const);
+83
View File
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DirtyReason, RenderScheduler, requestViewInvalidations } from "@lib/worker/render-scheduler.js";
test("atlas cleanup invalidations request worker viewport repaints", () => {
const requests: Array<readonly [number, DirtyReason]> = [];
const views = new Map([
["a", { scheduler: { request: (id: number, reason: DirtyReason) => requests.push([id, reason]) } }],
]);
requestViewInvalidations(["missing", "a"], views);
assert.deepEqual(requests, [[0, DirtyReason.Viewport]]);
});
test("continuous RAF polls while idle and preserves one-frame backpressure", () => {
const callbacks: Array<() => void> = [];
const draws: Array<readonly [number, number, number]> = [];
let polls = 0;
const scheduler = new RenderScheduler(
(...args) => draws.push(args),
(callback) => callbacks.push(callback),
);
const tick = () => {
const callback = callbacks.shift();
assert.ok(callback);
callback();
};
scheduler.start(() => polls++);
assert.equal(callbacks.length, 1);
tick();
assert.equal(polls, 1);
assert.equal(draws.length, 0);
assert.equal(callbacks.length, 1);
scheduler.request(4, DirtyReason.Preview);
tick();
assert.deepEqual(draws, [[1, 4, DirtyReason.Preview]]);
scheduler.request(5, DirtyReason.Scene);
tick();
assert.equal(draws.length, 1);
scheduler.consumed(99);
tick();
assert.equal(draws.length, 1);
scheduler.consumed(1);
tick();
assert.deepEqual(draws[1], [2, 5, DirtyReason.Scene]);
assert.equal(scheduler.metrics.staleAcks, 1);
assert.equal(scheduler.metrics.maxInFlight, 1);
scheduler.stop();
tick();
assert.equal(callbacks.length, 0);
});
test("requests coalesce and only a matching defer retries the same reasons", () => {
const callbacks: Array<() => void> = [],
draws: Array<readonly [number, number, number]> = [];
const scheduler = new RenderScheduler(
(...args) => draws.push(args),
(callback) => callbacks.push(callback),
);
const tick = () => callbacks.shift()!();
scheduler.start();
scheduler.request(2, DirtyReason.Scene);
scheduler.request(7, DirtyReason.Selection);
tick();
assert.deepEqual(draws, [[1, 7, DirtyReason.Scene | DirtyReason.Selection]]);
scheduler.defer(99, DirtyReason.Camera);
tick();
assert.equal(draws.length, 1, "a stale defer must not clear the in-flight frame");
scheduler.defer(1, DirtyReason.Scene | DirtyReason.Selection);
tick();
assert.deepEqual(draws[1], [2, 7, DirtyReason.Scene | DirtyReason.Selection]);
scheduler.consumed(1);
tick();
assert.equal(draws.length, 2, "a stale ack must not consume the retried frame");
scheduler.consumed(2);
tick();
assert.equal(draws.length, 2);
scheduler.stop();
});
+311
View File
@@ -0,0 +1,311 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ViewAtlasError, ViewAtlasManager, type ViewAtlasPlatform } from "@lib/worker/view-atlas.js";
class FakeBitmap {
width = 1;
height = 1;
closed = false;
close() {
this.closed = true;
}
}
class FakeCanvas extends EventTarget {
context = {} as OffscreenCanvasRenderingContext2D;
constructor(
public width: number,
public height: number,
) {
super();
}
getContext() {
return this.context;
}
}
function platform(options: { failCrop?: boolean; deferred?: { resolve: (bitmap: FakeBitmap) => void } } = {}) {
const canvases: FakeCanvas[] = [],
bitmaps: FakeBitmap[] = [];
const value: ViewAtlasPlatform = {
createCanvas(width, height) {
const canvas = new FakeCanvas(width, height);
canvases.push(canvas);
return canvas as unknown as OffscreenCanvas;
},
async createBitmap() {
if (options.failCrop) throw new Error("crop");
if (options.deferred)
return new Promise((resolve) => (options.deferred!.resolve = resolve)) as Promise<ImageBitmap>;
const bitmap = new FakeBitmap();
bitmaps.push(bitmap);
return bitmap as unknown as ImageBitmap;
},
};
return { value, canvases, bitmaps };
}
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
test("atlas manager is lazy, serialized, and releases its only canvas after last detach", async () => {
const fake = platform(),
atlas = new ViewAtlasManager(fake.value);
assert.equal(atlas.surface(), undefined);
const [a, b] = await Promise.all([
atlas.attach("a", { width: 100, height: 80 }),
atlas.attach("b", { width: 90, height: 70 }),
]);
assert.equal(fake.canvases.length, 1);
assert.equal(a.slot.viewId, "a");
assert.equal(b.slot.viewId, "b");
assert.deepEqual(await atlas.detach("a"), { invalidatedViewIds: [] });
assert(atlas.surface());
assert.deepEqual(await atlas.detach("b"), { invalidatedViewIds: [] });
assert.equal(atlas.surface(), undefined);
await atlas.attach("c", { width: 20, height: 20 });
assert.equal(fake.canvases.length, 2);
});
test("detach reports only survivors invalidated by successful compaction", async () => {
const fake = platform(),
atlas = new ViewAtlasManager(fake.value);
await atlas.attach("large", { width: 1000, height: 1000 });
await atlas.attach("small", { width: 100, height: 100 });
assert.deepEqual(await atlas.detach("large"), { invalidatedViewIds: ["small"] });
assert(atlas.slot("small"));
});
test("failed detach compaction with a successful rollback invalidates every survivor", async () => {
const fake = platform(),
atlas = new ViewAtlasManager(fake.value);
await atlas.attach("large", { width: 1000, height: 1000 });
await atlas.attach("small-a", { width: 100, height: 100 });
await atlas.attach("small-b", { width: 90, height: 90 });
let probes = 0;
fake.value.createBitmap = async () => {
if (probes++ === 0) throw new Error("compact probe failed");
return new FakeBitmap() as unknown as ImageBitmap;
};
assert.deepEqual(await atlas.detach("large"), {
invalidatedViewIds: ["small-a", "small-b"],
});
assert(atlas.slot("small-a"));
assert(atlas.slot("small-b"));
});
test("failed surface transition with successful rollback stales all previous slots", async () => {
const fake = platform(),
atlas = new ViewAtlasManager(fake.value);
await atlas.attach("a", { width: 100, height: 100 });
const generation = atlas.surface()!.atlasGeneration,
slotGeneration = atlas.slot("a")!.slotGeneration;
let probes = 0;
fake.value.createBitmap = async () => {
if (probes++ === 0) throw new Error("new surface probe failed");
return new FakeBitmap() as unknown as ImageBitmap;
};
await assert.rejects(atlas.attach("b", { width: 1000, height: 1000 }), (error: unknown) => {
assert(error instanceof ViewAtlasError);
assert.equal(error.code, "atlas.crop");
return true;
});
assert.equal(atlas.slot("b"), undefined);
assert(atlas.slot("a")!.slotGeneration > slotGeneration);
assert(atlas.surface()!.atlasGeneration > generation);
});
test("failed first probe leaves the manager empty and retryable", async () => {
const bad = platform({ failCrop: true }),
atlas = new ViewAtlasManager(bad.value);
await assert.rejects(atlas.attach("a", { width: 10, height: 10 }), (error: unknown) => {
assert(error instanceof ViewAtlasError);
assert.equal(error.code, "atlas.crop");
return true;
});
assert.equal(atlas.surface(), undefined);
bad.value.createBitmap = async () => new FakeBitmap() as unknown as ImageBitmap;
await atlas.attach("a", { width: 10, height: 10 });
assert(atlas.surface());
});
test("dispose during a pending probe cannot resurrect atlas state", async () => {
const deferred = { resolve: (_bitmap: FakeBitmap) => {} },
fake = platform({ deferred }),
atlas = new ViewAtlasManager(fake.value),
attached = atlas.attach("a", { width: 10, height: 10 });
await new Promise((resolve) => setTimeout(resolve, 0));
atlas.dispose();
const bitmap = new FakeBitmap();
deferred.resolve(bitmap);
await assert.rejects(attached, { name: "ViewAtlasError" });
assert.equal(bitmap.closed, true);
assert.equal(atlas.surface(), undefined);
});
test("context loss during initial probe cannot expose the lost candidate", async () => {
const deferred = { resolve: (_bitmap: FakeBitmap) => {} },
fake = platform({ deferred }),
atlas = new ViewAtlasManager(fake.value),
attached = atlas.attach("a", { width: 10, height: 10 });
await tick();
fake.canvases[0]!.dispatchEvent(new Event("contextlost", { cancelable: true }));
deferred.resolve(new FakeBitmap());
await assert.rejects(attached, (error: unknown) => {
assert(error instanceof ViewAtlasError);
assert.equal(error.code, "atlas.context-lost");
return true;
});
assert.equal(atlas.surface(), undefined);
});
test("restoration events from a discarded candidate cannot affect its replacement", async () => {
const deferred = { resolve: (_bitmap: FakeBitmap) => {} },
fake = platform({ deferred }),
fatals: ViewAtlasError[] = [],
atlas = new ViewAtlasManager(fake.value, (error) => fatals.push(error)),
first = atlas.attach("a", { width: 10, height: 10 });
await tick();
const discarded = fake.canvases[0]!;
discarded.dispatchEvent(new Event("contextlost", { cancelable: true }));
discarded.dispatchEvent(new Event("contextrestored"));
deferred.resolve(new FakeBitmap());
await assert.rejects(first, { name: "ViewAtlasError" });
fake.value.createBitmap = async () => new FakeBitmap() as unknown as ImageBitmap;
await atlas.attach("b", { width: 10, height: 10 });
const generation = atlas.surface()!.atlasGeneration;
discarded.dispatchEvent(new Event("contextrestored"));
await tick();
assert.equal(atlas.surface()!.atlasGeneration, generation);
assert.deepEqual(fatals, []);
});
test("dispose during an existing resize does not enter rollback or republish state", async () => {
const fake = platform(),
atlas = new ViewAtlasManager(fake.value);
await atlas.attach("a", { width: 10, height: 10 });
let resolve = (_bitmap: FakeBitmap) => {};
fake.value.createBitmap = () => new Promise((done) => (resolve = done)) as Promise<ImageBitmap>;
const resized = atlas.resize("a", { width: 400, height: 400 });
await tick();
atlas.dispose();
resolve(new FakeBitmap());
await assert.rejects(resized, { name: "ViewAtlasError" });
assert.equal(atlas.surface(), undefined);
});
test("context loss hides the surface and restoration reprobes before exposing it", async () => {
const fake = platform(),
fatals: ViewAtlasError[] = [],
atlas = new ViewAtlasManager(fake.value, (error) => fatals.push(error));
await atlas.attach("a", { width: 10, height: 10 });
const before = atlas.surface()!;
fake.canvases[0]!.dispatchEvent(new Event("contextlost", { cancelable: true }));
assert.equal(atlas.surface(), undefined);
fake.canvases[0]!.dispatchEvent(new Event("contextrestored"));
for (let index = 0; index < 20 && !atlas.surface(); index++) await new Promise((resolve) => setTimeout(resolve, 0));
const restored = atlas.surface()!;
assert(restored);
assert(restored.atlasGeneration > before.atlasGeneration);
assert.deepEqual(fatals, []);
await atlas.detach("a");
await atlas.attach("b", { width: 10, height: 10 });
assert(atlas.surface());
});
test("a stale restoration cannot expose or poison a newer context loss", async () => {
const fake = platform(),
fatals: ViewAtlasError[] = [],
atlas = new ViewAtlasManager(fake.value, (error) => fatals.push(error));
await atlas.attach("a", { width: 10, height: 10 });
const canvas = fake.canvases[0]!;
let rejectRestore = (_error: Error) => {};
fake.value.createBitmap = () => new Promise((_resolve, reject) => (rejectRestore = reject));
canvas.dispatchEvent(new Event("contextlost", { cancelable: true }));
canvas.dispatchEvent(new Event("contextrestored"));
await tick();
canvas.dispatchEvent(new Event("contextlost", { cancelable: true }));
rejectRestore(new Error("stale restoration"));
await tick();
assert.equal(atlas.surface(), undefined);
assert.deepEqual(fatals, []);
fake.value.createBitmap = async () => new FakeBitmap() as unknown as ImageBitmap;
canvas.dispatchEvent(new Event("contextrestored"));
for (let index = 0; index < 20 && !atlas.surface(); index++) await tick();
assert(atlas.surface());
assert.deepEqual(fatals, []);
});
test("renderAndCrop clips, clears, paints, and crops a slot without yielding", async () => {
const calls: unknown[][] = [],
context = new Proxy(
{},
{
get:
(_target, property) =>
(...args: unknown[]) =>
calls.push([property, ...args]),
},
) as OffscreenCanvasRenderingContext2D,
canvases: FakeCanvas[] = [];
const crops: number[][] = [];
const atlas = new ViewAtlasManager({
createCanvas: (width, height) => {
const canvas = new FakeCanvas(width, height);
canvas.context = context;
canvases.push(canvas);
return canvas as unknown as OffscreenCanvas;
},
async createBitmap(_canvas, x, y, width, height) {
crops.push([x, y, width, height]);
return Object.assign(new FakeBitmap(), { width, height }) as unknown as ImageBitmap;
},
});
await atlas.attach("a", { width: 12, height: 8 });
calls.length = crops.length = 0;
const bitmap = await atlas.renderAndCrop("a", { width: 12, height: 8 }, (target) => {
calls.push(["paint", target.deviceX, target.deviceY, target.deviceWidth, target.deviceHeight]);
});
assert(bitmap);
assert.deepEqual(crops, [[0, 0, 12, 8]]);
assert.deepEqual(calls.slice(0, 6), [
["save"],
["setTransform", 1, 0, 0, 1, 0, 0],
["beginPath"],
["rect", 0, 0, 12, 8],
["clip"],
["clearRect", 0, 0, 12, 8],
]);
assert.deepEqual(calls.at(-2), ["paint", 0, 0, 12, 8]);
assert.deepEqual(calls.at(-1), ["restore"]);
});
test("detach waits for a delayed render crop and only then retires its slot", async () => {
const fake = platform(),
atlas = new ViewAtlasManager(fake.value);
await atlas.attach("a", { width: 10, height: 10 });
Object.assign(fake.canvases[0]!.context, {
save() {},
setTransform() {},
beginPath() {},
rect() {},
clip() {},
clearRect() {},
restore() {},
});
let resolve = (_bitmap: FakeBitmap) => {},
cropStarted = false;
fake.value.createBitmap = () =>
new Promise((done) => {
cropStarted = true;
resolve = done;
}) as Promise<ImageBitmap>;
const rendered = atlas.renderAndCrop("a", { width: 10, height: 10 }, () => {}),
detached = atlas.detach("a");
for (let index = 0; index < 10 && !cropStarted; index++) await tick();
assert.equal(cropStarted, true);
assert(atlas.slot("a"), "detach must wait behind the crop");
const bitmap = Object.assign(new FakeBitmap(), { width: 10, height: 10 });
resolve(bitmap);
assert.equal(await rendered, bitmap, "the crop ticket remains valid until it is returned");
assert.equal(bitmap.closed, false);
assert.deepEqual(await detached, { invalidatedViewIds: [] });
assert.equal(atlas.slot("a"), undefined);
});
+70
View File
@@ -0,0 +1,70 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { FxNodeSaveData } from "@lib/commands/types.js";
import { createFxNodeHeadless } from "@lib/headless-runtime.js";
import { nodeId } from "@lib/core/types.js";
import { advanceJournal, checkpointJournal, importJournal, journalSaveData } from "@lib/worker/journal.js";
import { APPLICATION_COMPILED, APPLICATION_HEADLESS } from "./application.js";
const composition = {
...APPLICATION_COMPILED.source,
id: "journal",
version: 1,
nodes: { value: APPLICATION_COMPILED.source.nodes["fxnode.shader.value"]! },
},
runtime = createFxNodeHeadless(composition),
baseline = runtime.save(runtime.emptyDocument()),
authority = composition,
valid = () => true;
const add = { type: "node.add", nodeId: nodeId("n"), nodeType: "value", position: { x: 0, y: 0 } } as const,
move = { type: "node.move", id: nodeId("n"), position: { x: 1, y: 2 } } as const;
test("worker journal folds forward undo redo and clears redo on a new forward", () => {
const empty = checkpointJournal(baseline),
one = advanceJournal(empty, add, baseline, valid),
two = advanceJournal(one, move, baseline, valid);
assert.deepEqual(journalSaveData(two, authority).commands, [add, move]);
const undone = advanceJournal(two, { type: "undo" }, baseline, valid);
assert.deepEqual(journalSaveData(undone, authority).commands, [add]);
assert.equal(undone.redo.length, 1);
const redone = advanceJournal(undone, { type: "redo" }, baseline, valid);
assert.deepEqual(journalSaveData(redone, authority).commands, [add, move]);
const replaced = advanceJournal(undone, { ...move, position: { x: 3, y: 4 } }, baseline, valid);
assert.equal(replaced.redo.length, 0);
assert.deepEqual(journalSaveData(replaced, authority).commands.at(-1), { ...move, position: { x: 3, y: 4 } });
assert.equal(Object.isFrozen(replaced), true);
assert.equal(Object.isFrozen(journalSaveData(replaced, authority)), true);
});
test("worker journal checkpoints on strict replay failure and imported journals append", () => {
const one = advanceJournal(checkpointJournal(baseline), add, baseline, valid),
saved = journalSaveData(one, authority),
imported = importJournal(saved as unknown as FxNodeSaveData<typeof composition>),
appended = advanceJournal(imported, move, baseline, valid);
assert.deepEqual(journalSaveData(appended, authority).commands, [add, move]);
const checkpointed = advanceJournal(one, move, baseline, () => false);
assert.equal(checkpointed.applied.length, 0);
assert.equal(JSON.stringify(checkpointed.baseline), JSON.stringify(baseline));
const undone = advanceJournal(checkpointed, { type: "undo" }, { ...baseline, metadata: { after: "undo" } }, valid);
assert.deepEqual(undone.baseline.metadata, { after: "undo" });
assert.equal(undone.applied.length, 0);
const oversized = advanceJournal(
one,
{ type: "node.label", id: nodeId("n"), label: "x".repeat(1_048_577) },
{ ...baseline, metadata: { after: "large command" } },
valid,
);
assert.deepEqual(oversized.baseline.metadata, { after: "large command" });
assert.equal(oversized.applied.length, 0);
});
test("strict callback receives only baseline and applied commands", () => {
let received: readonly unknown[] = [];
const next = advanceJournal(checkpointJournal(baseline), add, baseline, (candidate, commands) => {
received = [candidate, commands];
return true;
});
assert.equal(received[0], next.baseline);
assert.deepEqual(received[1], [add]);
assert.equal("composition" in (received[0] as object), false);
});