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>
@@ -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());
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
|
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),
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>FxNode client runtime test</title>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
})();
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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 {};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 138 KiB |
@@ -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>
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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
|
||||
@@ -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" });
|
||||
});
|
||||
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 41 KiB |
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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]);
|
||||
});
|
||||