import { Asset } from "@noya-app/noya-schemas"; import { describe, expect, it, jest } from "bun:test"; import { SerializableRequest, SerializableResponse } from "../rpc/types"; import { createDefaultMessageHandler, isEmbeddedToParentMessage, isParentToEmbeddedMessage, type EmbeddedToParentMessage, type ParentToEmbeddedMessage, } from "../sync/parentFrameMessages"; describe("message type guards", () => { describe("EmbeddedToParentMessage", () => { it("validates multiplayer.ready message", () => { const message: EmbeddedToParentMessage = { type: "multiplayer.ready", }; expect(isEmbeddedToParentMessage(message)).toBe(true); }); it("validates multiplayer.message message", () => { const message: EmbeddedToParentMessage = { type: "multiplayer.message", payload: { type: "patch", id: "test-patch-1", baseHash: "abc123", patches: [{ op: "add", path: ["count"], value: 1 }], }, }; expect(isEmbeddedToParentMessage(message)).toBe(true); }); it("validates application.setMenuItems message", () => { const message: EmbeddedToParentMessage = { type: "application.setMenuItems", payload: { menuItems: [{ title: "File", value: "file" }], position: "left", }, }; expect(isEmbeddedToParentMessage(message)).toBe(true); }); it("validates rpc.request message", () => { const request: SerializableRequest = { id: "test-rpc-1", url: "/api/assets", options: { method: "GET", }, }; const message: EmbeddedToParentMessage = { type: "rpc.request", payload: request, }; expect(isEmbeddedToParentMessage(message)).toBe(true); }); it("validates streaming rpc.request message", () => { const request: SerializableRequest = { id: "test-rpc-1", url: "/api/assets", streaming: true, options: { method: "GET", }, }; const message: EmbeddedToParentMessage = { type: "rpc.request", payload: request, }; expect(isEmbeddedToParentMessage(message)).toBe(true); }); it("rejects invalid messages", () => { const invalidMessages = [ null, undefined, {}, { type: "unknown" }, { type: "multiplayer.message" }, // missing payload { type: "application.setMenuItems", payload: { menuItems: "not-an-array" }, }, { type: "rpc.request" }, // missing payload ]; invalidMessages.forEach((message) => { expect(isEmbeddedToParentMessage(message)).toBe(false); }); }); }); describe("ParentToEmbeddedMessage", () => { it("validates connectedUsers message", () => { const message: ParentToEmbeddedMessage = { type: "connectedUsers", users: [{ connectionId: "1", name: "Test" }], }; expect(isParentToEmbeddedMessage(message)).toBe(true); }); it("validates connection message", () => { const message: ParentToEmbeddedMessage = { type: "connection", state: "connected", }; expect(isParentToEmbeddedMessage(message)).toBe(true); }); it("validates application.selectMenuItem message", () => { const message: ParentToEmbeddedMessage = { type: "application.selectMenuItem", payload: { value: "file" }, }; expect(isParentToEmbeddedMessage(message)).toBe(true); }); it("validates rpc.response message", () => { const assets: Asset[] = [ { id: "test-asset", stableId: "test-asset", width: 100, height: 100, userId: "test-user", url: "https://example.com", createdAt: new Date().toISOString(), size: 1024, contentType: "image/png", }, ]; const response: SerializableResponse = { id: "test-rpc-1", status: 200, statusText: "OK", headers: {}, body: JSON.stringify(assets), }; const message: ParentToEmbeddedMessage = { type: "end", id: "test-rpc-1", response: response, }; expect(isParentToEmbeddedMessage(message)).toBe(true); }); it("validates rpc.chunk message", () => { const message: ParentToEmbeddedMessage = { type: "chunk", id: "test-rpc-1", chunk: "chunk data", }; expect(isParentToEmbeddedMessage(message)).toBe(true); }); it("validates rpc.error message", () => { const message: ParentToEmbeddedMessage = { type: "error", id: "test-rpc-1", error: "Failed to fetch assets", }; expect(isParentToEmbeddedMessage(message)).toBe(true); }); it("rejects invalid messages", () => { const invalidMessages = [ null, undefined, {}, { type: 123 }, { payload: "missing-type" }, { type: "connection", state: "invalid-state" }, { type: "application.selectMenuItem" }, // missing payload { type: "rpc.response" }, // missing payload { type: "rpc.chunk" }, // missing payload { type: "rpc.error" }, // missing payload ]; invalidMessages.forEach((message) => { expect(isParentToEmbeddedMessage(message)).toBe(false); }); }); }); }); describe("message handlers", () => { it("creates default message handler", () => { const handler = createDefaultMessageHandler(); expect(typeof handler.postMessage).toBe("function"); expect(typeof handler.addMessageListener).toBe("function"); }); it("posts messages to parent window", () => { const originalPostMessage = window.parent.postMessage; const mockPostMessage = jest.fn(); window.parent.postMessage = mockPostMessage; const handler = createDefaultMessageHandler(); const message: EmbeddedToParentMessage = { type: "multiplayer.ready" }; handler.postMessage(message, "*"); expect(mockPostMessage).toHaveBeenCalledWith(message, "*"); window.parent.postMessage = originalPostMessage; }); it("adds and removes message listeners", () => { const handler = createDefaultMessageHandler(); const mockListener = jest.fn(); const removeListener = handler.addMessageListener(mockListener); expect(typeof removeListener).toBe("function"); // Simulate a message event const messageEvent = new MessageEvent("message", { data: { type: "connection", state: "connected" }, }); window.dispatchEvent(messageEvent); expect(mockListener).toHaveBeenCalledWith(messageEvent); // Remove listener and verify it's not called removeListener(); window.dispatchEvent(messageEvent); expect(mockListener).toHaveBeenCalledTimes(1); }); it("handles multiple message listeners", () => { const handler = createDefaultMessageHandler(); const mockListener1 = jest.fn(); const mockListener2 = jest.fn(); const removeListener1 = handler.addMessageListener(mockListener1); const removeListener2 = handler.addMessageListener(mockListener2); const messageEvent = new MessageEvent("message", { data: { type: "connection", state: "connected" }, }); window.dispatchEvent(messageEvent); expect(mockListener1).toHaveBeenCalledTimes(1); expect(mockListener2).toHaveBeenCalledTimes(1); removeListener1(); window.dispatchEvent(messageEvent); expect(mockListener1).toHaveBeenCalledTimes(1); expect(mockListener2).toHaveBeenCalledTimes(2); removeListener2(); window.dispatchEvent(messageEvent); expect(mockListener1).toHaveBeenCalledTimes(1); expect(mockListener2).toHaveBeenCalledTimes(2); }); });