import { expect, test } from "@playwright/test"; test("real worker migrates historical custom graph atomically and keeps init state private", async ({ page }) => { await page.goto("/test/browser/worker-composition.html"); const result = await page.evaluate(async () => { const h = (window as any).workerCompositionTest, original = Worker.prototype.postMessage; let initHasValue = false; Worker.prototype.postMessage = function (this: Worker, message: any, transfer?: Transferable[]) { if (message?.type === "init") { const id = message.id; this.addEventListener("message", (e: any) => { if (e.data?.type === "response" && e.data.id === id) initHasValue = "value" in e.data; }); } return Reflect.apply(original, this, [message, transfer ?? []]); } as typeof Worker.prototype.postMessage; const api = await h.createMigration(); Worker.prototype.postMessage = original; const initial = await api.getState(), saved = await api.save(); const edit = await api.dispatch({ type: "node.parameter", id: "left", key: "level", value: { kind: "number", value: 12 }, }); const edited = await api.getState(); let mutations = 0, snapshots = 0; api.onMutations(() => mutations++); api.onSnapshots(() => snapshots++); const malformed = structuredClone(saved); delete malformed.nodes[0].parameters.level; let code = ""; try { await api.load(malformed); } catch (e) { code = (e as any).code ?? ""; } await new Promise((resolve) => setTimeout(resolve, 30)); const after = await api.getState(); api.destroy(); return { initHasValue, initial, saved, edit, edited, code, mutations, snapshots, after }; }); expect(result.initHasValue).toBe(false); expect(result.initial.catalogVersion).toBe(73); expect(result.saved.catalogVersion).toBe(73); expect(result.saved.nodes.every((n: any) => n.typeVersion === 2 && n.parameters.level)).toBe(true); expect(result.saved.links[0]).toMatchObject({ id: "historical-link", fromSocketId: "left:source", toSocketId: "right:sink", extensions: { kept: true }, }); expect(result.edit).toEqual({ status: "committed", version: 2 }); expect(result.edited.nodes.find((n: any) => n.id === "left").parameters.level.value).toBe(12); expect(result.code).toBe("catalog.invalid"); expect(result.mutations).toBe(0); expect(result.snapshots).toBe(0); expect(result.after).toEqual(result.edited); }); test("command-log API is worker-authoritative, side-effect-free to query, and atomically replayable", async ({ page, }) => { await page.goto("/test/browser/worker-composition.html"); const result = await page.evaluate(async () => { const api = await (window as any).workerCompositionTest.createA(), mutations: any[] = [], snapshots: any[] = []; api.onMutations((event: any) => mutations.push(event)); api.onSnapshots((event: any) => snapshots.push(event)); const initial = await api.getState(), empty = await api.getSaveData(), afterQuery = await api.getState(), queryEvents = { mutations: mutations.length, snapshots: snapshots.length }; await api.dispatch({ type: "node.add", nodeId: "logged", nodeType: "alpha-node", position: { x: 12, y: 34 } }); const populated = await api.getSaveData(), eventsAfterAdd = { mutations: mutations.length, snapshots: snapshots.length }; const noop = await api.load(populated), afterNoop = await api.getState(), eventsAfterNoop = { mutations: mutations.length, snapshots: snapshots.length }; const undo = await api.undo(), afterUndo = await api.getState(), undoneSave = await api.getSaveData(); const changed = await api.load(populated), afterChanged = await api.getState(), eventsAfterChanged = { mutations: mutations.length, snapshots: snapshots.length }; const malformed = structuredClone(populated); malformed.commands.push({ type: "node.remove", id: "missing" }); let malformedCode = ""; try { await api.load(malformed); } catch (error) { malformedCode = (error as any).code ?? ""; } const afterMalformed = await api.getState(), afterMalformedSave = await api.getSaveData(), eventsAfterMalformed = { mutations: mutations.length, snapshots: snapshots.length }; const legacy = await api.save(), legacyResult = await api.load(legacy), legacySaveData = await api.getSaveData(); api.destroy(); return { initial, empty, afterQuery, queryEvents, populated, eventsAfterAdd, noop, afterNoop, eventsAfterNoop, undo, afterUndo, undoneSave, changed, afterChanged, eventsAfterChanged, malformedCode, afterMalformed, afterMalformedSave, eventsAfterMalformed, legacy, legacyResult, legacySaveData, }; }); expect(result.empty).toMatchObject({ kind: "fxnode.command-log", schemaVersion: 2, composition: { id: "application-a", version: 11 }, commands: [], }); expect(result.afterQuery).toEqual(result.initial); expect(result.queryEvents).toEqual({ mutations: 0, snapshots: 0 }); expect(result.populated.commands).toEqual([ { type: "node.add", nodeId: "logged", nodeType: "alpha-node", position: { x: 12, y: 34 } }, ]); expect(result.eventsAfterAdd).toEqual({ mutations: 1, snapshots: 1 }); expect(result.noop).toEqual({ status: "noop", version: 1 }); expect(result.afterNoop.nodes).toHaveLength(1); expect(result.eventsAfterNoop).toEqual(result.eventsAfterAdd); expect(result.undo).toEqual({ status: "committed", version: 2 }); expect(result.afterUndo.nodes).toHaveLength(0); expect(result.undoneSave.commands).toEqual([]); expect(result.changed).toEqual({ status: "committed", version: 3 }); expect(result.afterChanged.nodes).toEqual(result.afterNoop.nodes); expect(result.eventsAfterChanged).toEqual({ mutations: 3, snapshots: 3 }); expect(result.malformedCode).toBe("node.missing"); expect(result.afterMalformed).toEqual(result.afterChanged); expect(result.afterMalformedSave).toEqual(result.populated); expect(result.eventsAfterMalformed).toEqual(result.eventsAfterChanged); expect(result.legacyResult).toEqual({ status: "committed", version: 4 }); expect(result.legacySaveData.commands).toEqual([]); expect(result.legacySaveData.baseline).toEqual(result.legacy); }); test("save data carries effective composition and reports incompatible current authority", async ({ page }) => { await page.goto("/test/browser/worker-composition.html"); const result = await page.evaluate(async () => { const h = (window as any).workerCompositionTest, api = await h.createA(), saved = await api.getSaveData(), definition = { ...h.compositionA.nodes["alpha-node"], title: "Dynamic" }; const added = await api.composeNode("dynamic-node", definition), loaded = await api.load(saved), underSuperset = await api.getSaveData(); const removed = await api.removeNode("alpha-node", { expectedRevision: added.revision }), before = await api.getState(); let error: any = {}; try { await api.load(saved); } catch (value) { const e = value as any; error = { code: e.code, path: e.path, message: e.message, issues: e.issues }; } const after = await api.getState(), stillCurrent = await api.getSaveData(); api.destroy(); return { saved, loaded, underSuperset, before, error, after, stillCurrent }; }); expect(result.saved.composition.nodes["alpha-node"]).toBeTruthy(); expect(result.saved.composition.nodes["dynamic-node"]).toBeUndefined(); expect(result.loaded.status).toBe("noop"); expect(result.underSuperset.composition.nodes["dynamic-node"]).toBeTruthy(); expect(result.error).toMatchObject({ code: "composition.incompatible", path: "/composition", message: expect.stringContaining("semantic superset"), issues: [ { code: "composition.incompatible" }, { code: "composition.definition-missing", path: "/composition/nodes/alpha-node" }, ], }); expect(result.after).toEqual(result.before); expect(result.stillCurrent.composition.nodes["alpha-node"]).toBeUndefined(); expect(result.stillCurrent.composition.nodes["dynamic-node"]).toBeTruthy(); }); test("dedicated workers bind disjoint application compositions and plain init sources", async ({ page }) => { await page.goto("/test/browser/worker-composition.html"); const initialized = await page.evaluate(async () => { const harness = ( window as unknown as { workerCompositionTest: { createA: () => Promise; createB: () => Promise; view: (api: any) => any; }; } ).workerCompositionTest; const original = Worker.prototype.postMessage, initMessages: Array<{ id: string; applicationId: unknown; applicationVersion: unknown; resources: unknown }> = [], initResponses: Array<{ id: string; hasValue: boolean }> = [], initOrder: string[] = []; Worker.prototype.postMessage = function (this: Worker, message: unknown, transfer?: Transferable[]) { const value = message as { type?: string; id?: string; applicationId?: unknown; applicationVersion?: unknown; resources?: unknown; }; if (value?.type === "init" && typeof value.id === "string") { initMessages.push({ id: value.id, applicationId: value.applicationId, applicationVersion: value.applicationVersion, resources: value.resources, }); const id = value.id; this.addEventListener( "message", (event: MessageEvent) => { const response = event.data as any; if (response?.type === "response" && response.id === id && response.ok) { initResponses.push({ id, hasValue: "value" in response }); initOrder.push(`${id}:response`); } }, { once: false }, ); } return Reflect.apply(original, this, [message, transfer ?? []]); } as typeof Worker.prototype.postMessage; try { const [a, b] = await Promise.all([harness.createA(), harness.createB()]); await Promise.all([harness.view(a).whenRendered(), harness.view(b).whenRendered()]); const addA = await a.dispatch({ type: "node.add", nodeType: "alpha-node", position: { x: 0, y: 0 } }), addB = await b.dispatch({ type: "node.add", nodeType: "beta-node", position: { x: 0, y: 0 } }); let crossA = "", crossB = ""; try { await a.dispatch({ type: "node.add", nodeType: "beta-node", position: { x: 0, y: 0 } }); } catch (error) { crossA = (error as { code?: string }).code ?? ""; } try { await b.dispatch({ type: "node.add", nodeType: "alpha-node", position: { x: 0, y: 0 } }); } catch (error) { crossB = (error as { code?: string }).code ?? ""; } const beforeDestroy = { a: await a.getState(), b: await b.getState(), saveA: await a.save(), saveB: await b.save(), }; a.destroy(); const afterDestroy = await b.getState(); const wire = initMessages.map((item) => { return { id: item.id, plain: Object.getPrototypeOf(item.resources as object) === Object.prototype, cloneable: !!structuredClone(item), applicationId: item.applicationId, applicationVersion: item.applicationVersion, }; }); b.destroy(); return { addA, addB, crossA, crossB, beforeDestroy, afterDestroy, wire, responses: initResponses, initOrder, }; } finally { Worker.prototype.postMessage = original; } }); expect(initialized.addA).toEqual({ status: "committed", version: 1 }); expect(initialized.addB).toEqual({ status: "committed", version: 1 }); expect(initialized.crossA).toBe("node.type-unknown"); expect(initialized.crossB).toBe("node.type-unknown"); expect(initialized.beforeDestroy.a.nodes.map((node: any) => node.typeId)).toEqual(["alpha-node"]); expect(initialized.beforeDestroy.b.nodes.map((node: any) => node.typeId)).toEqual(["beta-node"]); expect(initialized.beforeDestroy.saveA.catalogVersion).toBe(11); expect(initialized.beforeDestroy.saveB.catalogVersion).toBe(22); expect(initialized.afterDestroy.nodes.map((node: any) => node.typeId)).toEqual(["beta-node"]); expect(initialized.wire).toHaveLength(2); expect(initialized.wire.every((item) => item.plain && item.cloneable)).toBe(true); expect( initialized.wire.map(({ applicationId, applicationVersion }) => ({ applicationId, applicationVersion })), ).toEqual([ { applicationId: "application-a", applicationVersion: 11 }, { applicationId: "application-b", applicationVersion: 22 }, ]); expect(initialized.responses).toHaveLength(2); expect(initialized.responses.every((item) => !item.hasValue)).toBe(true); expect(initialized.initOrder.every((value: string) => value.endsWith(":response"))).toBe(true); const a = page.locator("#application-a"), b = page.locator("#application-b"); // Recreate editors solely to verify the DOM menu remains the host's static HTML. await page.reload(); await page.evaluate(async () => { const h = (window as any).workerCompositionTest; const [a, b] = await Promise.all([h.createA(), h.createB()]); (window as any).menuApis = [a, b]; }); await a.click({ button: "right", position: { x: 20, y: 20 } }); await expect(page.getByRole("option")).toHaveText(["Alpha Node"]); await page.keyboard.press("Escape"); await b.click({ button: "right", position: { x: 20, y: 20 } }); await expect(page.getByRole("option")).toHaveText(["Alpha Node"]); await page.evaluate(() => (window as any).menuApis.forEach((api: any) => api.destroy())); }); test("value inherited from Object.prototype is never mistaken for worker response state", async ({ page }) => { await page.goto("/test/browser/worker-composition.html"); const result = await page.evaluate(async () => { const h = (window as any).workerCompositionTest; Object.defineProperty(Object.prototype, "value", { configurable: true, value: { leaked: true } }); try { const api = await h.createA(), snapshot = await api.getState(); api.destroy(); return { created: true, version: snapshot.version }; } finally { delete (Object.prototype as any).value; } }); expect(result).toEqual({ created: true, version: 0 }); }); test("live composition instance API keeps one worker-authoritative revisioned handle", async ({ page }) => { await page.goto("/test/browser/worker-composition.html"); const result = await page.evaluate(async () => { const h = (window as any).workerCompositionTest, NativeWorker = Worker, workers: Worker[] = []; class TrackingWorker extends NativeWorker { constructor(url: string | URL, options?: WorkerOptions) { super(url, options); workers.push(this); } } Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: TrackingWorker }); const api = await h.createA(), compositionEvents: any[] = [], mutations: any[] = [], snapshots: any[] = []; api.onCompositionChanges((event: any) => compositionEvents.push(event)); api.onMutations((event: any) => mutations.push(event)); api.onSnapshots((event: any) => snapshots.push(event)); const definition = { ...h.compositionA.nodes["alpha-node"], title: "Dynamic Node" }; const composed = await api.composeNode("dynamic-node", definition, { expectedRevision: 1 }), sameObject = true; const added = await api.dispatch({ type: "node.add", nodeId: "dynamic", nodeType: "dynamic-node", position: { x: 0, y: 0 }, }); const removed = await api.removeNode("dynamic-node", { expectedRevision: 2 }), afterRemoval = await api.getState(), undo = await api.undo(); const themeA = { ...h.compositionA.theme, background: "#111111" }, themeB = { ...h.compositionA.theme, background: "#222222" }; const concurrent = await Promise.allSettled([ api.setTheme(themeA, { expectedRevision: 3 }), api.setTheme(themeB, { expectedRevision: 3 }), ]); const raw = new Promise((resolve) => { const id = "malformed-live-update", worker = workers[0]!; const listener = (event: MessageEvent) => { if (event.data?.type === "response" && event.data.id === id) { worker.removeEventListener("message", listener); resolve(event.data); } }; worker.addEventListener("message", listener); worker.postMessage({ protocol: 3, type: "composition.update", id, expected: { kind: "exact", revision: -1 }, update: { kind: "node.remove", id: "dynamic-node" }, }); }); const malformed = await raw, stillAlive = await api.getState(); api.destroy(); Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: NativeWorker }); return { sameObject, composed, added, removed, afterRemoval, undo, concurrent: concurrent.map((item) => item.status === "fulfilled" ? { status: "fulfilled", value: item.value } : { status: "rejected", code: (item.reason as any).code }, ), compositionEvents, mutations, snapshots, malformed, stillAlive, }; }); expect(result.sameObject).toBe(true); expect(result.composed).toEqual({ status: "committed", revision: 2, graphVersion: 0, graphChanged: false, historyReset: true, }); expect(result.added).toEqual({ status: "committed", version: 1 }); expect(result.removed).toEqual({ status: "committed", revision: 3, graphVersion: 2, graphChanged: true, historyReset: true, }); expect(result.afterRemoval.nodes).toHaveLength(1); expect(result.afterRemoval.nodes[0]).toMatchObject({ id: "dynamic", typeId: "dynamic-node", known: false }); expect(result.undo).toEqual({ status: "noop", version: 2 }); expect(result.concurrent.filter((item: any) => item.status === "fulfilled")).toHaveLength(1); expect(result.concurrent.filter((item: any) => item.status === "rejected")).toEqual([ { status: "rejected", code: "composition.revision.stale" }, ]); expect(result.compositionEvents.map((event: any) => event.revision)).toEqual([2, 3, 4]); expect(result.mutations).toHaveLength(2); expect(result.mutations[1]).toMatchObject({ cause: "composition", baseVersion: 1, version: 2, mutations: [{ kind: "document.replaced" }], }); expect(result.snapshots).toHaveLength(2); expect(result.snapshots[0].snapshot.nodes[0]).toMatchObject({ typeId: "dynamic-node", known: true }); expect(result.malformed).toMatchObject({ ok: false, error: { code: "composition.request.invalid" } }); expect(result.stillAlive.version).toBe(2); }); test("canonical live-composition noops preserve revision, history, and events", async ({ page }) => { await page.goto("/test/browser/worker-composition.html"); const result = await page.evaluate(async () => { const h = (window as any).workerCompositionTest, api = await h.createA(), events: any[] = []; api.onCompositionChanges((event: any) => events.push(event)); await api.dispatch({ type: "node.add", nodeId: "alpha", nodeType: "alpha-node", position: { x: 0, y: 0 } }); const noop = await api.setTheme(structuredClone(h.compositionA.theme), { expectedRevision: 1 }); const undo = await api.undo(), snapshot = await api.getState(); api.destroy(); return { noop, undo, events, snapshot }; }); expect(result.noop).toEqual({ status: "noop", revision: 1, graphVersion: 1, graphChanged: false, historyReset: false, }); expect(result.events).toEqual([]); expect(result.undo).toEqual({ status: "committed", version: 2 }); expect(result.snapshot.nodes).toHaveLength(0); }); test("loadComposition atomically replaces authority, checkpoints persistence, and handles prototype-named definitions", async ({ page, }) => { await page.goto("/test/browser/worker-composition.html"); const result = await page.evaluate(async () => { const h = (window as any).workerCompositionTest, api = await h.createA(), compositionEvents: any[] = [], mutations: any[] = [], snapshots: any[] = []; api.onCompositionChanges((event: any) => compositionEvents.push(event)); api.onMutations((event: any) => mutations.push(event)); api.onSnapshots((event: any) => snapshots.push(event)); const definition = { ...h.compositionA.nodes["alpha-node"], title: "Prototype Name" }; const composed = await api.composeNode("toString", definition); await api.dispatch({ type: "node.add", nodeId: "alpha", nodeType: "alpha-node", position: { x: 0, y: 0 } }); await api.dispatch({ type: "node.add", nodeId: "prototype", nodeType: "toString", position: { x: 200, y: 0 } }); const withDefinitions = await api.getSaveData(), withoutDefinitions = { ...withDefinitions.composition, nodes: {} }; const replaced = await api.loadComposition(withoutDefinitions, { expectedRevision: composed.revision }), demoted = await api.getState(), checkpoint = await api.getSaveData(); const replay = await api.load(checkpoint), noop = await api.loadComposition(structuredClone(withoutDefinitions), { expectedRevision: replaced.revision }), undo = await api.undo(); const presentation = { ...withoutDefinitions, theme: { ...withoutDefinitions.theme, background: "#303134" } }, presented = await api.loadComposition(presentation, { expectedRevision: replaced.revision }); const beforeRejected = { snapshot: await api.getState(), save: await api.getSaveData(), eventCount: compositionEvents.length, mutationCount: mutations.length, snapshotCount: snapshots.length, }; const errors: any[] = []; try { await api.loadComposition(withDefinitions.composition, { expectedRevision: replaced.revision }); } catch (error) { errors.push({ code: (error as any).code }); } try { await api.loadComposition({ ...presentation, schemaVersion: 1 } as any, { expectedRevision: presented.revision }); } catch (error) { errors.push({ code: (error as any).code }); } const afterRejected = { snapshot: await api.getState(), save: await api.getSaveData(), eventCount: compositionEvents.length, mutationCount: mutations.length, snapshotCount: snapshots.length, }; const promotedComposition = { ...presentation, nodes: { toString: definition } }, promoted = await api.loadComposition(promotedComposition, { expectedRevision: presented.revision }), promotedSnapshot = await api.getState(); api.destroy(); return { composed, replaced, demoted, checkpoint, replay, noop, undo, presented, beforeRejected, errors, afterRejected, promoted, promotedSnapshot, compositionEvents, mutations, snapshots, }; }); expect(result.replaced).toEqual({ status: "committed", revision: 3, graphVersion: 3, graphChanged: true, historyReset: true, }); expect(result.demoted.nodes).toHaveLength(2); expect(result.demoted.nodes.every((node: any) => node.known === false)).toBe(true); expect(result.demoted.nodes.map((node: any) => node.typeId).sort()).toEqual(["alpha-node", "toString"]); expect(result.checkpoint.composition.nodes).toEqual({}); expect(result.checkpoint.commands).toEqual([]); expect(result.checkpoint.baseline.nodes).toHaveLength(2); expect(result.replay).toEqual({ status: "noop", version: 3 }); expect(result.noop).toEqual({ status: "noop", revision: 3, graphVersion: 3, graphChanged: false, historyReset: false, }); expect(result.undo).toEqual({ status: "noop", version: 3 }); expect(result.presented).toEqual({ status: "committed", revision: 4, graphVersion: 3, graphChanged: false, historyReset: true, }); expect(result.errors).toEqual([{ code: "composition.revision.stale" }, { code: "composition.invalid" }]); expect(result.afterRejected).toEqual(result.beforeRejected); expect(result.promoted).toEqual({ status: "committed", revision: 5, graphVersion: 4, graphChanged: true, historyReset: true, }); expect(result.promotedSnapshot.nodes.find((node: any) => node.id === "prototype")).toMatchObject({ typeId: "toString", known: true, }); expect(result.promotedSnapshot.nodes.find((node: any) => node.id === "alpha")).toMatchObject({ typeId: "alpha-node", known: false, }); expect(result.compositionEvents.map((event: any) => event.change.kind)).toEqual([ "node.compose", "composition.load", "composition.load", "composition.load", ]); expect(result.mutations).toHaveLength(4); expect(result.snapshots).toHaveLength(4); }); test("live node composition does not alter the host-owned DOM add menu", async ({ page }) => { await page.goto("/test/browser/worker-composition.html"); const canvas = page.locator("#application-a"); await page.evaluate(async () => { const h = (window as any).workerCompositionTest, api = await h.createA(), definition = { ...h.compositionA.nodes["alpha-node"], title: "Dynamic Node", }; const update = await api.composeNode("dynamic-node", definition); (window as any).dynamicMenu = { api, revision: update.revision }; }); await canvas.click({ button: "right", position: { x: 20, y: 20 } }); await expect(page.getByRole("option")).toHaveText(["Alpha Node"]); await page.keyboard.press("Escape"); await page.evaluate(async () => { const h = (window as any).dynamicMenu; await h.api.removeNode("dynamic-node", { expectedRevision: h.revision }); }); await canvas.click({ button: "right", position: { x: 340, y: 200 } }); await expect(page.getByRole("option")).toHaveText(["Alpha Node"]); await page.keyboard.press("Escape"); await page.evaluate(() => (window as any).dynamicMenu.api.destroy()); }); test("worker independently rejects a composition corrupted only on the init wire", async ({ page }) => { await page.goto("/test/browser/worker-composition.html"); const result = await page.evaluate(async () => { const harness = (window as any).workerCompositionTest, canvas = document.querySelector("#application-a")!, original = Worker.prototype.postMessage; Worker.prototype.postMessage = function (this: Worker, message: unknown, transfer?: Transferable[]) { const value = message as { type?: string }; return Reflect.apply(original, this, [ value?.type === "init" ? { ...(message as object), resources: { malformed: true } } : message, transfer ?? [], ]); } as typeof Worker.prototype.postMessage; let error: { name?: string; code?: string; issues?: readonly unknown[]; message?: string } = {}; try { await harness.createA(); } catch (value) { const e = value as typeof error; error = { ...(e.name === undefined ? {} : { name: e.name }), ...(e.code === undefined ? {} : { code: e.code }), ...(e.issues === undefined ? {} : { issues: e.issues }), ...(e.message === undefined ? {} : { message: e.message }), }; } finally { Worker.prototype.postMessage = original; } return { error, tabindex: canvas.getAttribute("tabindex"), touchAction: canvas.style.touchAction, inputs: document.querySelectorAll('input[type="file"]').length, }; }); expect(result.error.name).toBe("FxNodeWorkerError"); expect(result.error.code).toBe("composition.invalid"); expect(result.error.issues?.length).toBeGreaterThan(0); expect(result.error.issues?.length).toBeLessThanOrEqual(100); expect(result).toMatchObject({ tabindex: null, touchAction: "", inputs: 0 }); }); test("overlapping resource tokens resolve isolated worker byte and dimension policies", async ({ page }) => { await page.goto("/test/browser/worker-composition.html"); const result = await page.evaluate(async () => { const harness = (window as any).workerCompositionTest, openedA: any[] = [], openedB: any[] = []; const [a, b] = await Promise.all([ harness.createA((request: any) => openedA.push(request)), harness.createB((request: any) => openedB.push(request)), ]), viewA = harness.view(a), viewB = harness.view(b); await Promise.all([ a.dispatch({ type: "node.add", nodeId: "shared", nodeType: "alpha-node", position: { x: -60, y: 30 } }), b.dispatch({ type: "node.add", nodeId: "shared", nodeType: "beta-node", position: { x: -60, y: 30 } }), ]); await Promise.all([ a.dispatch({ type: "node.move", id: "shared", position: { x: -59, y: 30 } }), b.dispatch({ type: "node.move", id: "shared", position: { x: -59, y: 30 } }), ]); await Promise.all([viewA.whenRendered(), viewB.whenRendered()]); const click = async (canvas: HTMLCanvasElement, pointerId: number, opened: any[]) => { const rect = canvas.getBoundingClientRect(), before = opened.length, init = { bubbles: true, pointerId, pointerType: "mouse", button: 0, clientX: rect.left + 200, clientY: rect.top + 115, }; canvas.dispatchEvent(new PointerEvent("pointerdown", { ...init, buttons: 1 })); for (let i = 0; i < 50 && opened.length === before; i++) await new Promise((resolve) => setTimeout(resolve, 10)); canvas.dispatchEvent(new PointerEvent("pointerup", { ...init, buttons: 0 })); }; await Promise.all([ click(document.querySelector("#application-a")!, 41, openedA), click(document.querySelector("#application-b")!, 42, openedB), ]); const initial = [openedA[0], openedB[0]], accepted = initial.map((item) => item.resource.accept.join(",")), resourceIds = initial.map((item) => [item.resource.id]); const frozen = initial.every( (item) => Object.isFrozen(item) && Object.isFrozen(item.authorization) && Object.isFrozen(item.resource) && Object.isFrozen(item.resource.accept), ); const pointerUpDidNotDuplicate = openedA.length === 1; const attempt = async (promise: Promise) => { try { return { ok: true, value: await promise }; } catch (error) { return { ok: false, code: (error as { code?: string }).code }; } }; const image = document.createElement("canvas"); image.width = 2; image.height = 2; image.getContext("2d")!.fillRect(0, 0, 2, 2); const raw = await (await fetch(image.toDataURL("image/png"))).arrayBuffer(); const padded = new Uint8Array(600); padded.set(new Uint8Array(raw)); const dimensions = await Promise.all([ attempt( viewA.provideResource(initial[0].authorization, { name: "pixel.png", mime: "image/png", bytes: raw.slice(0), }), ), attempt( viewB.provideResource(initial[1].authorization, { name: "pixel.png", mime: "image/png", bytes: raw.slice(0), }), ), ]); await Promise.all([viewA.whenRendered(), viewB.whenRendered()]); await Promise.all([ click(document.querySelector("#application-a")!, 43, openedA), click(document.querySelector("#application-b")!, 44, openedB), ]); const next = [openedA.at(-1), openedB.at(-1)]; const bytes = await Promise.all([ attempt( viewA.provideResource(next[0].authorization, { name: "pixel.png", mime: "image/png", bytes: padded.buffer.slice(0), }), ), attempt( viewB.provideResource(next[1].authorization, { name: "pixel.png", mime: "image/png", bytes: padded.buffer.slice(0), }), ), ]); await Promise.all([viewA.whenRendered(), viewB.whenRendered()]); image.width = 4; image.height = 4; image.getContext("2d")!.fillRect(0, 0, 4, 4); await click(document.querySelector("#application-b")!, 45, openedB); const rebindingBytes = await (await fetch(image.toDataURL("image/png"))).arrayBuffer(), rebinding = attempt( viewB.provideResource(openedB.at(-1).authorization, { name: "rebind.png", mime: "image/png", bytes: rebindingBytes, }), ); await b.dispatch({ type: "node.remove", id: "shared" }); await b.dispatch({ type: "node.add", nodeId: "shared", nodeType: "beta-node", position: { x: -60, y: 30 } }); const rebound = await rebinding; const snapshots = await Promise.all([a.getState(), b.getState()]); a.destroy(); b.destroy(); return { accepted, resourceIds, frozen, pointerUpDidNotDuplicate, rawBytes: raw.byteLength, dimensions, bytes, rebound, values: snapshots.map((snapshot) => (snapshot.nodes[0] as any).parameters.image.value), types: snapshots.map((snapshot) => snapshot.nodes[0]?.typeId), }; }); expect(result.accepted).toEqual(["image/png", "image/webp"]); expect(result.resourceIds).toEqual([["image"], ["image"]]); expect(result.frozen).toBe(true); expect(result.pointerUpDidNotDuplicate).toBe(true); expect(result.rawBytes).toBeLessThanOrEqual(512); expect(result.dimensions[0]).toEqual({ ok: false, code: "resource.dimensions" }); expect(result.dimensions[1]).toMatchObject({ ok: true, value: { status: "committed", version: 3 } }); expect(result.bytes[0]).toEqual({ ok: false, code: "resource.bytes" }); expect(result.bytes[1]).toMatchObject({ ok: true, value: { status: "committed", version: 4 } }); expect(result.rebound).toEqual({ ok: false, code: "resource.stale" }); expect(result.values).toEqual(["", ""]); expect(result.types).toEqual(["alpha-node", "beta-node"]); }); test("authoritative resource hits follow paint order and block click-through", async ({ page }) => { await page.goto("/test/browser/worker-composition.html"); const result = await page.evaluate(async () => { const h = (window as any).workerCompositionTest, opened: any[] = [], api = await h.createA((target: any) => opened.push(target)), view = h.view(api), canvas = document.querySelector("#application-a")!; await view.addNode({ typeId: "alpha-node", nodeId: "bottom", viewPosition: { x: 120, y: 80 } }); await view.addNode({ typeId: "alpha-node", nodeId: "top", viewPosition: { x: 120, y: 80 } }); await view.whenRendered(); const rect = canvas.getBoundingClientRect(), click = async (pointerId: number) => { const before = opened.length; const init = { bubbles: true, pointerId, pointerType: "mouse", button: 0, clientX: rect.left + 200, clientY: rect.top + 115, }; canvas.dispatchEvent(new PointerEvent("pointerdown", { ...init, buttons: 1 })); for (let i = 0; i < 50 && opened.length === before; i++) await new Promise((resolve) => setTimeout(resolve, 10)); canvas.dispatchEvent(new PointerEvent("pointerup", { ...init, buttons: 0 })); }; await click(71); const topToken = opened[0]?.authorization.token; const blocker = { ...h.compositionA.nodes["alpha-node"], title: "Blocker", parameters: {}, ui: [ { kind: "socket", socket: "output" }, ...Array.from({ length: 7 }, (_, index) => ({ kind: "text", variant: "section", title: `Row ${index}` })), ], }; await api.composeNode("blocker", blocker); await view.addNode({ typeId: "blocker", nodeId: "cover", viewPosition: { x: 120, y: 80 } }); await view.whenRendered(); await click(72); const blocked = opened.length === 1; await api.dispatch({ type: "node.remove", id: "cover" }); await view.whenRendered(); h.destroy(api); return { topToken, blocked }; }); expect(result).toEqual({ topToken: expect.stringMatching(/^top:/), blocked: true }); }); test("delayed resource requests survive pointer-up but not newer host intent", async ({ page }) => { await page.goto("/test/browser/worker-composition.html"); const result = await page.evaluate(async () => { const harness = (window as any).workerCompositionTest, NativeWorker = Worker; class DelayedWorker extends NativeWorker { static hold = false; static held: { worker: DelayedWorker; event: MessageEvent } | undefined; private receiver: ((this: Worker, event: MessageEvent) => any) | null = null; constructor(url: string | URL, options?: WorkerOptions) { super(url, options); this.addEventListener("message", (event) => { if (DelayedWorker.hold && (event.data as { type?: unknown })?.type === "view.resource.open") { DelayedWorker.hold = false; DelayedWorker.held = { worker: this, event }; return; } this.receiver?.call(this, event); }); } override set onmessage(value: ((this: Worker, event: MessageEvent) => any) | null) { this.receiver = value; } override get onmessage() { return this.receiver; } static release() { const held = DelayedWorker.held; DelayedWorker.held = undefined; held?.worker.receiver?.call(held.worker, held.event); } } Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: DelayedWorker }); let api: any; try { const opened: any[] = []; api = await harness.createA((request: any) => opened.push(request)); const view = harness.view(api); await view.addNode({ typeId: "alpha-node", nodeId: "resource", viewPosition: { x: 120, y: 80 } }); await view.whenRendered(); const canvas = document.querySelector("#application-a")!, rect = canvas.getBoundingClientRect(), click = (pointerId: number) => { const init = { bubbles: true, pointerId, pointerType: "mouse", button: 0, clientX: rect.left + 200, clientY: rect.top + 115, }; canvas.dispatchEvent(new PointerEvent("pointerdown", { ...init, buttons: 1 })); canvas.dispatchEvent(new PointerEvent("pointerup", { ...init, buttons: 0 })); }, waitFor = async (predicate: () => boolean) => { for (let index = 0; index < 100; index++) { if (predicate()) return true; await new Promise(requestAnimationFrame); } return false; }; DelayedWorker.hold = true; click(91); const heldThroughPointerUp = await waitFor(() => !!DelayedWorker.held); DelayedWorker.release(); const deliveredAfterPointerUp = await waitFor(() => opened.length === 1); DelayedWorker.hold = true; click(92); const heldBeforeNewIntent = await waitFor(() => !!DelayedWorker.held); view.feedInput({ kind: "wheel", position: { x: 200, y: 115 }, delta: { x: 0, y: -20 }, modifiers: { alt: false, control: false, meta: false, shift: false }, }); DelayedWorker.release(); await new Promise(requestAnimationFrame); await new Promise(requestAnimationFrame); return { heldThroughPointerUp, deliveredAfterPointerUp, heldBeforeNewIntent, staleIgnored: opened.length === 1, }; } finally { if (api) harness.destroy(api); Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: NativeWorker }); } }); expect(result).toEqual({ heldThroughPointerUp: true, deliveredAfterPointerUp: true, heldBeforeNewIntent: true, staleIgnored: true, }); }); test("an ordinary graph commit invalidates delayed add-menu authorization", async ({ page }) => { await page.goto("/test/browser/worker-composition.html"); const result = await page.evaluate(async () => { const harness = (window as any).workerCompositionTest, NativeWorker = Worker; class DelayedMenuWorker extends NativeWorker { static held: { worker: DelayedMenuWorker; event: MessageEvent } | undefined; private receiver: ((this: Worker, event: MessageEvent) => any) | null = null; constructor(url: string | URL, options?: WorkerOptions) { super(url, options); this.addEventListener("message", (event) => { if (!DelayedMenuWorker.held && (event.data as { type?: unknown })?.type === "view.node-menu.result") { DelayedMenuWorker.held = { worker: this, event }; return; } this.receiver?.call(this, event); }); } override set onmessage(value: ((this: Worker, event: MessageEvent) => any) | null) { this.receiver = value; } override get onmessage() { return this.receiver; } static release() { const held = DelayedMenuWorker.held; DelayedMenuWorker.held = undefined; held?.worker.receiver?.call(held.worker, held.event); } } Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: DelayedMenuWorker }); let api: any; try { api = await harness.createA(); const view = harness.view(api); let opened = 0; view.onHostRequests((request: { kind: string }) => { if (request.kind === "add-node-menu") opened++; }); const canvas = document.querySelector("#application-a")!, rect = canvas.getBoundingClientRect(); canvas.dispatchEvent( new PointerEvent("pointerdown", { bubbles: true, pointerId: 101, pointerType: "mouse", button: 2, buttons: 2, clientX: rect.left + 20, clientY: rect.top + 20, }), ); for (let index = 0; index < 100 && !DelayedMenuWorker.held; index++) await new Promise(requestAnimationFrame); const held = !!DelayedMenuWorker.held; await view.addNode({ typeId: "alpha-node", nodeId: "new-node", viewPosition: { x: 300, y: 180 } }); DelayedMenuWorker.release(); await new Promise(requestAnimationFrame); await new Promise(requestAnimationFrame); return { held, opened, menuOpen: !!document.querySelector("[data-fxnode-add-menu]") }; } finally { if (api) harness.destroy(api); Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: NativeWorker }); } }); expect(result).toEqual({ held: true, opened: 0, menuOpen: false }); }); test("invalid bootstrap authority rejects before constructing a worker", async ({ page }) => { await page.goto("/test/browser/worker-composition.html"); const result = await page.evaluate(async () => { const { createFxNode } = await import("@lib/index.js"), canvas = document.createElement("canvas"), NativeWorker = Worker; let constructed = 0; class TrackingWorker extends NativeWorker { constructor(url: string | URL, options?: WorkerOptions) { constructed++; super(url, options); } } Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: TrackingWorker }); const errors: string[] = []; try { for (const authority of [ { applicationId: "", applicationVersion: 1, resources: {} }, { applicationId: "valid", applicationVersion: 0, resources: {} }, { applicationId: "valid", applicationVersion: 1, resources: { broken: { kind: "image" } } }, ]) try { await createFxNode({ applicationId: authority.applicationId, applicationVersion: authority.applicationVersion, resources: authority.resources as never, }); } catch (error) { errors.push((error as Error).name); } return { constructed, errors }; } finally { Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: NativeWorker }); } }); expect(result).toEqual({ constructed: 0, errors: ["FxNodeCompositionError", "FxNodeCompositionError", "FxNodeCompositionError"], }); }); test("startup attachment failure terminates the worker and invalid composition has no side effects", async ({ page, }) => { await page.goto("/test/browser/worker-composition.html"); const result = await page.evaluate(async () => { const harness = (window as any).workerCompositionTest, canvas = document.querySelector("#application-a")!, NativeWorker = Worker, NativeObserver = ResizeObserver; let constructed = 0, terminated = 0; class TrackingWorker extends NativeWorker { constructor(url: string | URL, options?: WorkerOptions) { super(url, options); constructed++; } override terminate() { terminated++; super.terminate(); } } class ThrowingObserver { constructor(_callback: ResizeObserverCallback) {} observe() { throw new Error("observe exploded"); } disconnect() {} unobserve() {} } Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: TrackingWorker }); Object.defineProperty(window, "ResizeObserver", { configurable: true, writable: true, value: ThrowingObserver }); canvas.setAttribute("tabindex", "7"); canvas.style.touchAction = "pan-x"; let attachMessage = ""; try { await harness.createA(); } catch (error) { attachMessage = (error as Error).message; } const attached = { constructed, terminated, tabindex: canvas.getAttribute("tabindex"), touchAction: canvas.style.touchAction, inputs: document.querySelectorAll('input[type="file"]').length, }; Object.defineProperty(window, "ResizeObserver", { configurable: true, writable: true, value: NativeObserver }); let invalidMessage = ""; try { await harness.createRaw(canvas, { malformed: true }); } catch (error) { invalidMessage = (error as Error).message; } const invalid = { constructed, terminated, tabindex: canvas.getAttribute("tabindex"), touchAction: canvas.style.touchAction, inputs: document.querySelectorAll('input[type="file"]').length, }; Object.defineProperty(window, "Worker", { configurable: true, writable: true, value: NativeWorker }); return { attachMessage, attached, invalidMessage, invalid }; }); expect(result.attachMessage).toBe("observe exploded"); expect(result.attached).toEqual({ constructed: 1, terminated: 1, tabindex: "7", touchAction: "pan-x", inputs: 0 }); expect(result.invalidMessage).toContain("Invalid fxnode composition"); expect(result.invalid).toEqual({ constructed: 2, terminated: 2, tabindex: "7", touchAction: "pan-x", inputs: 0 }); });