Files
agentic-dev/packages/editor/src/ui/components/frame-node.test.tsx
Danijel Martinek 9495023aed feat(editor): board shell with iframe frame and selection overlay
React Flow board (pan/zoom + frame drag are pure editor-side transforms
— zero canvas-protocol traffic, <16ms budget) hosting one iframe frame
node on the adapter origin. The frame waits for runtime.ready
(queue-drain) behind an explicit 'Starting preview…' skeleton, then
requests a single Element via render-frame (core-runner-protocol
schema) plus geometry; agent-reported geometry stays frame-local and
click reports drive store selection. The selection ring renders as an
editor-side overlay from reported geometry — chrome never inside the
customer's document (ADR-028). Stories + jsdom component tests for
board/frame/overlay; Storybook stories glob extended to
packages/editor (verified via static build).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
2026-07-12 22:18:24 +02:00

198 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<typeof postAgentEnvelopeToWindow>[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(<FrameNode data={data} />);
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(<FrameNode data={data} />);
expect(screen.getByText("Starting preview…")).toBeInTheDocument();
});
it("sends nothing before runtime.ready — queue-drain semantics", () => {
render(<FrameNode data={data} />);
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(<FrameNode data={data} />);
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(<FrameNode data={data} />);
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(<FrameNode data={data} />);
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(<FrameNode data={data} />);
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(<FrameNode data={data} />);
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(<FrameNode data={data} />);
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(<FrameNode data={{ frame: { ...data.frame } }} />);
rerender(<FrameNode data={{ frame: { ...data.frame } }} />);
expect(post).toHaveBeenCalledTimes(2);
});
it("stops listening on unmount — late agent messages no longer reach the store", () => {
const { unmount } = render(<FrameNode data={data} />);
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(
<FrameNode
data={{
frame: {
name: "Card",
componentId: "card",
adapterOrigin: ADAPTER_ORIGIN,
},
}}
/>,
);
expect(screen.getByText(/800 × 600/)).toBeInTheDocument();
});
});