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
This commit is contained in:
2026-07-12 22:18:24 +02:00
parent c2f56ac8c9
commit 9495023aed
13 changed files with 899 additions and 4 deletions

View File

@@ -0,0 +1,197 @@
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();
});
});