import { describe, it, expect, beforeEach, vi } from "vitest"; import { act } from "react"; import { render, screen } from "@testing-library/react"; import { postAgentEnvelopeToWindow } from "@/canvas-protocol/scripted-agent.mock"; import { useEditorStore } from "@/store/editor-store"; import { FrameNode, type FrameNodeData } from "./frame-node"; const ADAPTER_ORIGIN = "https://adapter.veect.test:5199"; const EVIL_ORIGIN = "https://evil.example.com"; const data: FrameNodeData = { frame: { name: "Button", componentId: "button", adapterOrigin: ADAPTER_ORIGIN, width: 640, height: 480, }, }; function getIframe(): HTMLIFrameElement { return screen.getByTitle("Button frame") as HTMLIFrameElement; } function spyOnAgentWindow() { const contentWindow = getIframe().contentWindow; if (contentWindow === null) throw new Error("iframe has no contentWindow"); return vi.spyOn(contentWindow, "postMessage"); } function agentSays(message: Parameters[0]) { act(() => postAgentEnvelopeToWindow(message, ADAPTER_ORIGIN)); } beforeEach(() => { useEditorStore.setState({ registry: [], selectedNodeId: null }); }); describe("FrameNode — cold start", () => { it("hosts the adapter origin in a sandboxed iframe with a drag-handle tab", () => { render(); const iframe = getIframe(); expect(iframe).toHaveAttribute("src", ADAPTER_ORIGIN); expect(iframe).toHaveAttribute( "sandbox", "allow-scripts allow-same-origin", ); expect(screen.getByTitle("Drag to move")).toHaveClass("frame-drag-handle"); expect(screen.getByText("Button")).toBeInTheDocument(); }); it('shows the explicit "Starting preview…" skeleton until runtime.ready (ADR-028)', () => { render(); expect(screen.getByText("Starting preview…")).toBeInTheDocument(); }); it("sends nothing before runtime.ready — queue-drain semantics", () => { render(); const post = spyOnAgentWindow(); expect(post).not.toHaveBeenCalled(); }); }); describe("FrameNode — runtime.ready", () => { it("requests a single Element via render-frame (default props) then geometry, pinned to the adapter origin", () => { render(); const post = spyOnAgentWindow(); agentSays({ type: "runtime.ready" }); expect(post).toHaveBeenCalledTimes(2); expect(post).toHaveBeenNthCalledWith( 1, { protocolVersion: "0", message: { type: "render-frame", componentId: "button" }, }, ADAPTER_ORIGIN, ); expect(post).toHaveBeenNthCalledWith( 2, { protocolVersion: "0", message: { type: "geometry.request" }, }, ADAPTER_ORIGIN, ); expect(screen.queryByText("Starting preview…")).not.toBeInTheDocument(); }); it("ignores runtime.ready from a wrong origin — still starting, nothing sent", () => { render(); const post = spyOnAgentWindow(); act(() => postAgentEnvelopeToWindow({ type: "runtime.ready" }, EVIL_ORIGIN), ); expect(post).not.toHaveBeenCalled(); expect(screen.getByText("Starting preview…")).toBeInTheDocument(); }); }); describe("FrameNode — selection round-trip from reported geometry", () => { it("click report selects in the store and the overlay renders at the reported rect", () => { render(); agentSays({ type: "runtime.ready" }); agentSays({ type: "geometry.report", targets: [ { nodeId: "el-1", rect: { x: 16, y: 32, width: 240, height: 56 } }, ], }); expect(screen.queryByTestId("selection-overlay")).not.toBeInTheDocument(); agentSays({ type: "click.target", nodeId: "el-1" }); expect(useEditorStore.getState().selectedNodeId).toBe("el-1"); expect(screen.getByTestId("selection-overlay")).toHaveStyle({ left: "16px", top: "32px", width: "240px", height: "56px", }); }); it("background click deselects and hides the overlay", () => { render(); agentSays({ type: "runtime.ready" }); agentSays({ type: "geometry.report", targets: [ { nodeId: "el-1", rect: { x: 0, y: 0, width: 10, height: 10 } }, ], }); agentSays({ type: "click.target", nodeId: "el-1" }); expect(screen.getByTestId("selection-overlay")).toBeInTheDocument(); agentSays({ type: "click.target", nodeId: null }); expect(useEditorStore.getState().selectedNodeId).toBeNull(); expect(screen.queryByTestId("selection-overlay")).not.toBeInTheDocument(); }); it("no overlay when the selected node has no reported geometry yet", () => { render(); agentSays({ type: "runtime.ready" }); agentSays({ type: "click.target", nodeId: "el-unmeasured" }); expect(useEditorStore.getState().selectedNodeId).toBe("el-unmeasured"); expect(screen.queryByTestId("selection-overlay")).not.toBeInTheDocument(); }); }); describe("FrameNode — drag path stays off the protocol", () => { it("rerendering (as frame drag does) produces zero protocol traffic", () => { const { rerender } = render(); const post = spyOnAgentWindow(); agentSays({ type: "runtime.ready" }); expect(post).toHaveBeenCalledTimes(2); // React Flow drag rerenders the node with fresh props; data values // are unchanged, so the canvas client must not be recreated and no // message may be sent (<16 ms/frame budget, ADR-028). rerender(); rerender(); expect(post).toHaveBeenCalledTimes(2); }); it("stops listening on unmount — late agent messages no longer reach the store", () => { const { unmount } = render(); agentSays({ type: "runtime.ready" }); unmount(); agentSays({ type: "click.target", nodeId: "el-late" }); expect(useEditorStore.getState().selectedNodeId).toBeNull(); }); }); describe("FrameNode — defaults", () => { it("falls back to the 800x600 default viewport", () => { render( , ); expect(screen.getByText(/800 × 600/)).toBeInTheDocument(); }); });