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