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
198 lines
6.4 KiB
TypeScript
198 lines
6.4 KiB
TypeScript
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();
|
||
});
|
||
});
|