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:
197
packages/editor/src/ui/components/frame-node.test.tsx
Normal file
197
packages/editor/src/ui/components/frame-node.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user