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("#primary")!; canvas.setAttribute("tabindex", "7"); canvas.style.touchAction = "pan-x"; const width = canvas.width, height = canvas.height; const installValue = async (api: Awaited>) => { 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("#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("#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((resolve) => (resolveA = resolve)), }); Object.defineProperty(b, "arrayBuffer", { value: () => new Promise((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("[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"]); });