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
+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]);
});