diff --git a/packages/editor/src/canvas-protocol/canvas-client.test.ts b/packages/editor/src/canvas-protocol/canvas-client.test.ts new file mode 100644 index 0000000..b3101ad --- /dev/null +++ b/packages/editor/src/canvas-protocol/canvas-client.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { PROTOCOL_VERSION } from "@repo/core-runner-protocol"; +import { + createCanvasClient, + type CanvasClientHandlers, +} from "@/canvas-protocol/canvas-client"; +import { ScriptedAgentDouble } from "@/canvas-protocol/scripted-agent.mock"; +import { useEditorStore } from "@/store/editor-store"; + +const ADAPTER_ORIGIN = "https://adapter.veect.test:5199"; +const EVIL_ORIGIN = "https://evil.example.com"; + +function setup(handlers: CanvasClientHandlers = {}) { + const agent = new ScriptedAgentDouble({ origin: ADAPTER_ORIGIN }); + const client = createCanvasClient({ + adapterOrigin: ADAPTER_ORIGIN, + getAgentWindow: () => agent.agentWindow, + editorWindow: agent.editorWindow, + handlers, + }); + return { agent, client }; +} + +describe("createCanvasClient — origin pinning", () => { + it('rejects "*" as the adapter origin — wildcard defeats pinning', () => { + expect(() => + createCanvasClient({ + adapterOrigin: "*", + getAgentWindow: () => null, + }), + ).toThrow(TypeError); + }); + + it("rejects a non-URL origin", () => { + expect(() => + createCanvasClient({ + adapterOrigin: "not-an-origin", + getAgentWindow: () => null, + }), + ).toThrow(TypeError); + }); + + it("rejects an origin with a path — must be a bare origin", () => { + expect(() => + createCanvasClient({ + adapterOrigin: "https://adapter.veect.test:5199/frame", + getAgentWindow: () => null, + }), + ).toThrow(TypeError); + }); + + it("posts every outbound message with the pinned targetOrigin", () => { + const { agent, client } = setup(); + client.start(); + client.renderFrame("button"); + client.requestGeometry(); + expect(agent.posted).toHaveLength(2); + for (const post of agent.posted) { + expect(post.targetOrigin).toBe(ADAPTER_ORIGIN); + } + }); + + it("ignores inbound runtime.ready from a wrong origin", () => { + const onRuntimeReady = vi.fn(); + const { agent, client } = setup({ onRuntimeReady }); + client.start(); + agent.postRuntimeReady({ origin: EVIL_ORIGIN }); + expect(onRuntimeReady).not.toHaveBeenCalled(); + agent.postRuntimeReady(); + expect(onRuntimeReady).toHaveBeenCalledTimes(1); + }); + + it("ignores inbound click.target from a wrong origin", () => { + const onClick = vi.fn(); + const { agent, client } = setup({ onClick }); + client.start(); + agent.postClick("el-1", { origin: EVIL_ORIGIN }); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("ignores events from a different source window on the right origin", () => { + const onClick = vi.fn(); + const { agent, client } = setup({ onClick }); + client.start(); + agent.postClick("el-1", { source: { postMessage: vi.fn() } }); + expect(onClick).not.toHaveBeenCalled(); + }); +}); + +describe("createCanvasClient — inbound message handling", () => { + it("dispatches runtime.ready to onRuntimeReady", () => { + const onRuntimeReady = vi.fn(); + const { agent, client } = setup({ onRuntimeReady }); + client.start(); + agent.postRuntimeReady(); + expect(onRuntimeReady).toHaveBeenCalledTimes(1); + }); + + it("dispatches geometry.report targets to onGeometry", () => { + const onGeometry = vi.fn(); + const { agent, client } = setup({ onGeometry }); + client.start(); + const targets = [ + { nodeId: "el-1", rect: { x: 10, y: 20, width: 300, height: 40 } }, + ]; + agent.postGeometry(targets); + expect(onGeometry).toHaveBeenCalledExactlyOnceWith(targets); + }); + + it("dispatches click.target to onClick, including null for background", () => { + const onClick = vi.fn(); + const { agent, client } = setup({ onClick }); + client.start(); + agent.postClick("el-7"); + agent.postClick(null); + expect(onClick).toHaveBeenNthCalledWith(1, "el-7"); + expect(onClick).toHaveBeenNthCalledWith(2, null); + }); + + it("ignores malformed payloads: garbage, near-misses, and editor-direction messages", () => { + const onRuntimeReady = vi.fn(); + const onClick = vi.fn(); + const { agent, client } = setup({ onRuntimeReady, onClick }); + client.start(); + agent.postRaw("not an envelope"); + agent.postRaw({ type: "runtime.ready" }); // bare message, no envelope + agent.postRaw({ + protocolVersion: "999", + message: { type: "runtime.ready" }, + }); + agent.postRaw({ + protocolVersion: PROTOCOL_VERSION, + message: { type: "render-frame", componentId: "button" }, + }); + expect(onRuntimeReady).not.toHaveBeenCalled(); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("survives handlers being omitted", () => { + const { agent, client } = setup(); + client.start(); + expect(() => { + agent.postRuntimeReady(); + agent.postGeometry([]); + agent.postClick(null); + }).not.toThrow(); + }); +}); + +describe("createCanvasClient — outbound messages", () => { + it("renderFrame posts a valid editor envelope requesting one Element", () => { + const { agent, client } = setup(); + client.start(); + expect(client.renderFrame("button")).toBe(true); + expect(agent.posted[0]?.data).toEqual({ + protocolVersion: PROTOCOL_VERSION, + message: { type: "render-frame", componentId: "button" }, + }); + }); + + it("renderFrame includes prop overrides when given", () => { + const { agent, client } = setup(); + client.renderFrame("button", { label: "Save" }); + expect(agent.posted[0]?.data).toEqual({ + protocolVersion: PROTOCOL_VERSION, + message: { + type: "render-frame", + componentId: "button", + props: { label: "Save" }, + }, + }); + }); + + it("requestGeometry posts a geometry.request envelope", () => { + const { agent, client } = setup(); + client.requestGeometry(); + expect(agent.posted[0]?.data).toEqual({ + protocolVersion: PROTOCOL_VERSION, + message: { type: "geometry.request" }, + }); + }); + + it("returns false without posting when the agent window is unavailable", () => { + const agent = new ScriptedAgentDouble({ origin: ADAPTER_ORIGIN }); + const client = createCanvasClient({ + adapterOrigin: ADAPTER_ORIGIN, + getAgentWindow: () => null, + editorWindow: agent.editorWindow, + }); + expect(client.renderFrame("button")).toBe(false); + expect(client.requestGeometry()).toBe(false); + expect(agent.posted).toHaveLength(0); + }); +}); + +describe("createCanvasClient — lifecycle", () => { + it("start is idempotent — one listener no matter how many calls", () => { + const onRuntimeReady = vi.fn(); + const { agent, client } = setup({ onRuntimeReady }); + client.start(); + client.start(); + expect(agent.listenerCount).toBe(1); + agent.postRuntimeReady(); + expect(onRuntimeReady).toHaveBeenCalledTimes(1); + }); + + it("stop detaches the listener and is idempotent", () => { + const onClick = vi.fn(); + const { agent, client } = setup({ onClick }); + client.start(); + client.stop(); + client.stop(); + expect(agent.listenerCount).toBe(0); + agent.postClick("el-1"); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("does not hear messages before start", () => { + const onRuntimeReady = vi.fn(); + const { agent } = setup({ onRuntimeReady }); + agent.postRuntimeReady(); + expect(onRuntimeReady).not.toHaveBeenCalled(); + }); +}); + +describe("createCanvasClient — default editor window", () => { + it("listens on the global window when editorWindow is not injected", () => { + const onRuntimeReady = vi.fn(); + const client = createCanvasClient({ + adapterOrigin: ADAPTER_ORIGIN, + getAgentWindow: () => null, + handlers: { onRuntimeReady }, + }); + client.start(); + try { + window.dispatchEvent( + new MessageEvent("message", { + data: { + protocolVersion: PROTOCOL_VERSION, + message: { type: "runtime.ready" }, + }, + origin: ADAPTER_ORIGIN, + }), + ); + expect(onRuntimeReady).toHaveBeenCalledTimes(1); + } finally { + client.stop(); + } + }); +}); + +describe("selection round-trip through the store", () => { + beforeEach(() => { + useEditorStore.setState({ registry: [], selectedNodeId: null }); + }); + + it("agent click report drives store selection; background click deselects", () => { + const { agent, client } = setup({ + onClick: (nodeId) => useEditorStore.getState().select(nodeId), + }); + client.start(); + + agent.postClick("el-42"); + expect(useEditorStore.getState().selectedNodeId).toBe("el-42"); + + agent.postClick(null); + expect(useEditorStore.getState().selectedNodeId).toBeNull(); + }); + + it("a wrong-origin click never reaches the store", () => { + const { agent, client } = setup({ + onClick: (nodeId) => useEditorStore.getState().select(nodeId), + }); + client.start(); + agent.postClick("el-42", { origin: EVIL_ORIGIN }); + expect(useEditorStore.getState().selectedNodeId).toBeNull(); + }); +}); diff --git a/packages/editor/src/canvas-protocol/canvas-client.ts b/packages/editor/src/canvas-protocol/canvas-client.ts new file mode 100644 index 0000000..972763a --- /dev/null +++ b/packages/editor/src/canvas-protocol/canvas-client.ts @@ -0,0 +1,170 @@ +import { PROTOCOL_VERSION } from "@repo/core-runner-protocol"; +import { + canvasAgentEnvelopeSchema, + canvasEditorEnvelopeSchema, + type CanvasEditorMessage, + type GeometryTarget, +} from "./messages"; + +/** + * Editor-side canvas-protocol client (ADR-028). + * + * Speaks the postMessage seam with the agent script inside one adapter + * iframe. `targetOrigin` is pinned in BOTH directions: + * + * - outbound: every `postMessage` passes the adapter origin, never `"*"`; + * - inbound: events whose `origin` is not the adapter origin are dropped + * before parsing, as are events from a different source window and + * payloads that fail the strict agent-envelope schema. + */ + +/** The minimal surface of the iframe's content window the client posts to. */ +export type AgentWindowLike = { + postMessage: (message: unknown, targetOrigin: string) => void; +}; + +/** The minimal surface of the editor window the client listens on. */ +export type EditorWindowLike = { + addEventListener: ( + type: "message", + listener: (event: MessageEvent) => void, + ) => void; + removeEventListener: ( + type: "message", + listener: (event: MessageEvent) => void, + ) => void; +}; + +export type CanvasClientHandlers = { + /** Agent runtime is up — safe to send `render-frame` (spec §9). */ + onRuntimeReady?: () => void; + /** Fresh geometry report — reposition editor-side overlays. */ + onGeometry?: (targets: readonly GeometryTarget[]) => void; + /** Click-target resolution; `null` means background (deselect). */ + onClick?: (nodeId: string | null) => void; +}; + +export type CanvasClientOptions = { + /** The adapter's origin — the pinned `targetOrigin` for both directions. */ + adapterOrigin: string; + /** + * Late-bound accessor for the iframe's content window; `null` while the + * frame is not mounted. Late binding lets the client outlive iframe + * remounts. + */ + getAgentWindow: () => AgentWindowLike | null; + /** Defaults to the global `window` — injectable for tests. */ + editorWindow?: EditorWindowLike; + handlers?: CanvasClientHandlers; +}; + +export type CanvasClient = { + /** Attach the message listener. Idempotent. */ + start: () => void; + /** Detach the message listener. Idempotent. */ + stop: () => void; + /** + * Ask the adapter's frame host to render one discovered component + * (schema from `@repo/core-runner-protocol`). Returns `false` when the + * agent window is not available yet. + */ + renderFrame: ( + componentId: string, + props?: Record, + ) => boolean; + /** Ask the agent for a fresh `geometry.report`. */ + requestGeometry: () => boolean; +}; + +/** + * Validates that `origin` is a well-formed, pinnable web origin. `"*"` is + * rejected outright — the wildcard would defeat the ADR-028 pinning + * guarantee on the outbound direction. + */ +function assertPinnableOrigin(origin: string): void { + if (origin === "*") { + throw new TypeError( + 'adapterOrigin must be a concrete origin — "*" defeats targetOrigin pinning (ADR-028)', + ); + } + let parsed: URL; + try { + parsed = new URL(origin); + } catch { + throw new TypeError(`adapterOrigin is not a valid origin: "${origin}"`); + } + if (parsed.origin !== origin) { + throw new TypeError( + `adapterOrigin must be a bare origin (no path/trailing slash): "${origin}"`, + ); + } +} + +export function createCanvasClient(options: CanvasClientOptions): CanvasClient { + const { adapterOrigin, getAgentWindow, handlers = {} } = options; + assertPinnableOrigin(adapterOrigin); + const editorWindow: EditorWindowLike = + options.editorWindow ?? (globalThis.window as unknown as EditorWindowLike); + + let listening = false; + + const onMessage = (event: MessageEvent): void => { + // Inbound pin: only the adapter origin may speak to this client. + if (event.origin !== adapterOrigin) return; + // Source pin: when both sides are known, they must be the same window. + const agentWindow = getAgentWindow(); + if ( + agentWindow !== null && + event.source !== null && + event.source !== agentWindow + ) { + return; + } + const parsed = canvasAgentEnvelopeSchema.safeParse(event.data); + if (!parsed.success) return; + const message = parsed.data.message; + switch (message.type) { + case "runtime.ready": + handlers.onRuntimeReady?.(); + return; + case "geometry.report": + handlers.onGeometry?.(message.targets); + return; + case "click.target": + handlers.onClick?.(message.nodeId); + return; + } + }; + + const send = (message: CanvasEditorMessage): boolean => { + const agentWindow = getAgentWindow(); + if (agentWindow === null) return false; + // Self-check outbound shape, then post with the pinned targetOrigin. + const envelope = canvasEditorEnvelopeSchema.parse({ + protocolVersion: PROTOCOL_VERSION, + message, + }); + agentWindow.postMessage(envelope, adapterOrigin); + return true; + }; + + return { + start: () => { + if (listening) return; + editorWindow.addEventListener("message", onMessage); + listening = true; + }, + stop: () => { + if (!listening) return; + editorWindow.removeEventListener("message", onMessage); + listening = false; + }, + renderFrame: (componentId, props) => + send({ + type: "render-frame", + componentId, + ...(props === undefined ? {} : { props }), + }), + requestGeometry: () => send({ type: "geometry.request" }), + }; +} diff --git a/packages/editor/src/canvas-protocol/messages.test.ts b/packages/editor/src/canvas-protocol/messages.test.ts new file mode 100644 index 0000000..fdf0a3e --- /dev/null +++ b/packages/editor/src/canvas-protocol/messages.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from "vitest"; +import { PROTOCOL_VERSION } from "@repo/core-runner-protocol"; +import { + canvasAgentEnvelopeSchema, + canvasAgentMessageSchema, + canvasEditorEnvelopeSchema, + canvasEditorMessageSchema, + geometryTargetSchema, + rectSchema, +} from "@/canvas-protocol/messages"; + +const agentEnvelope = (message: unknown) => ({ + protocolVersion: PROTOCOL_VERSION, + message, +}); + +describe("canvasAgentMessageSchema", () => { + it("round-trips runtime.ready", () => { + const msg = { type: "runtime.ready" } as const; + expect(canvasAgentMessageSchema.parse(msg)).toEqual(msg); + }); + + it("round-trips a geometry.report with targets", () => { + const msg = { + type: "geometry.report", + targets: [ + { nodeId: "el-1", rect: { x: 0, y: 12.5, width: 320, height: 48 } }, + ], + } as const; + expect(canvasAgentMessageSchema.parse(msg)).toEqual(msg); + }); + + it("round-trips click.target with a node id and with null (background)", () => { + expect( + canvasAgentMessageSchema.parse({ type: "click.target", nodeId: "el-1" }), + ).toEqual({ type: "click.target", nodeId: "el-1" }); + expect( + canvasAgentMessageSchema.parse({ type: "click.target", nodeId: null }), + ).toEqual({ type: "click.target", nodeId: null }); + }); + + it("rejects unknown message types", () => { + expect( + canvasAgentMessageSchema.safeParse({ type: "hover.target" }).success, + ).toBe(false); + }); + + it("rejects editor-direction messages — direction unions do not overlap", () => { + expect( + canvasAgentMessageSchema.safeParse({ + type: "render-frame", + componentId: "button", + }).success, + ).toBe(false); + expect( + canvasEditorMessageSchema.safeParse({ type: "runtime.ready" }).success, + ).toBe(false); + }); + + it("is strict: unknown fields are rejected", () => { + expect( + canvasAgentMessageSchema.safeParse({ type: "runtime.ready", extra: 1 }) + .success, + ).toBe(false); + }); +}); + +describe("rectSchema / geometryTargetSchema", () => { + it("rejects negative dimensions", () => { + expect( + rectSchema.safeParse({ x: 0, y: 0, width: -1, height: 10 }).success, + ).toBe(false); + }); + + it("rejects non-finite coordinates", () => { + expect( + rectSchema.safeParse({ x: Infinity, y: 0, width: 1, height: 1 }).success, + ).toBe(false); + expect( + rectSchema.safeParse({ x: 0, y: NaN, width: 1, height: 1 }).success, + ).toBe(false); + }); + + it("rejects an empty nodeId", () => { + expect( + geometryTargetSchema.safeParse({ + nodeId: "", + rect: { x: 0, y: 0, width: 1, height: 1 }, + }).success, + ).toBe(false); + }); +}); + +describe("canvasEditorMessageSchema", () => { + it("accepts render-frame (shared schema from @repo/core-runner-protocol)", () => { + const msg = { + type: "render-frame", + componentId: "button", + props: { label: "Save" }, + } as const; + expect(canvasEditorMessageSchema.parse(msg)).toEqual(msg); + }); + + it("accepts render-frame without props (default props)", () => { + const msg = { type: "render-frame", componentId: "button" } as const; + expect(canvasEditorMessageSchema.parse(msg)).toEqual(msg); + }); + + it("accepts geometry.request", () => { + expect( + canvasEditorMessageSchema.parse({ type: "geometry.request" }), + ).toEqual({ type: "geometry.request" }); + }); +}); + +describe("envelopes", () => { + it("agent envelope round-trips a valid message", () => { + const envelope = agentEnvelope({ type: "runtime.ready" }); + expect(canvasAgentEnvelopeSchema.parse(envelope)).toEqual(envelope); + }); + + it("rejects a foreign protocol version", () => { + expect( + canvasAgentEnvelopeSchema.safeParse({ + protocolVersion: "999", + message: { type: "runtime.ready" }, + }).success, + ).toBe(false); + }); + + it("rejects unknown envelope fields (no token at this seam — the origin pair is the trust boundary)", () => { + expect( + canvasAgentEnvelopeSchema.safeParse({ + ...agentEnvelope({ type: "runtime.ready" }), + token: "smuggled", + }).success, + ).toBe(false); + }); + + it("editor envelope rejects agent-direction messages", () => { + expect( + canvasEditorEnvelopeSchema.safeParse( + agentEnvelope({ type: "click.target", nodeId: "el-1" }), + ).success, + ).toBe(false); + }); +}); diff --git a/packages/editor/src/canvas-protocol/messages.ts b/packages/editor/src/canvas-protocol/messages.ts new file mode 100644 index 0000000..428caa6 --- /dev/null +++ b/packages/editor/src/canvas-protocol/messages.ts @@ -0,0 +1,145 @@ +import { z } from "zod"; +import { + PROTOCOL_VERSION, + renderFrameMessageSchema, +} from "@repo/core-runner-protocol"; + +/** + * Canvas-protocol message set, editor side (ADR-028). + * + * The canvas protocol is the postMessage seam between the editor and the + * Veect agent script injected into the adapter-served iframe. Chrome + * (selection/hover rings) renders as editor-side overlays from geometry the + * agent reports — nothing Veect-authored renders inside the customer's + * document, and `targetOrigin` is pinned in BOTH directions. + * + * These schemas are local to `@repo/editor` for now: the adapter's agent + * script (walking-skeleton story 05) delivers the other side of this seam, + * and the shapes reconcile into `@repo/core-runner-protocol` when the two + * meet in story 10. They are deliberately small. `render-frame` is already + * shared — it comes verbatim from `@repo/core-runner-protocol`, which is + * why its `type` literal is kebab-case while the agent events use the tech + * spec's dot-namespaced names (`runtime.ready`, spec §9). + * + * There is no auth token at this seam — the pinned origin pair is the trust + * boundary (the workspace-scoped token lives on the runner WS envelope). + * All object schemas are `.strict()` so unknown fields are rejected. + */ + +// --- Geometry ------------------------------------------------------------ + +/** A rectangle in the iframe document's CSS pixel space. */ +export const rectSchema = z + .object({ + x: z.number().finite(), + y: z.number().finite(), + width: z.number().finite().nonnegative(), + height: z.number().finite().nonnegative(), + }) + .strict(); + +export type Rect = z.infer; + +/** One measured node: the agent-assigned node id and its bounding rect. */ +export const geometryTargetSchema = z + .object({ + nodeId: z.string().min(1), + rect: rectSchema, + }) + .strict(); + +export type GeometryTarget = z.infer; + +// --- Agent -> editor ----------------------------------------------------- + +/** + * The injected agent script announces the iframe runtime is up and the + * agent is listening (spec §9: `runtime.ready` queue-drain — the editor + * sends `render-frame` only after this). + */ +export const runtimeReadyMessageSchema = z + .object({ + type: z.literal("runtime.ready"), + }) + .strict(); + +export type RuntimeReadyMessage = z.infer; + +/** + * Geometry report: bounding rects for the rendered nodes, measured by the + * agent post-`fonts.loaded` (ADR-028). The editor renders selection/hover + * overlays from these — it never measures across the origin boundary. + */ +export const geometryReportMessageSchema = z + .object({ + type: z.literal("geometry.report"), + targets: z.array(geometryTargetSchema), + }) + .strict(); + +export type GeometryReportMessage = z.infer; + +/** + * Click-target report: the agent hit-tests clicks inside the iframe and + * reports the resolved node id. `null` means background — deselect. + */ +export const clickTargetMessageSchema = z + .object({ + type: z.literal("click.target"), + nodeId: z.string().min(1).nullable(), + }) + .strict(); + +export type ClickTargetMessage = z.infer; + +export const canvasAgentMessageSchema = z.discriminatedUnion("type", [ + runtimeReadyMessageSchema, + geometryReportMessageSchema, + clickTargetMessageSchema, +]); + +export type CanvasAgentMessage = z.infer; + +// --- Editor -> agent ----------------------------------------------------- + +/** Ask the agent to (re-)measure and send a fresh `geometry.report`. */ +export const geometryRequestMessageSchema = z + .object({ + type: z.literal("geometry.request"), + }) + .strict(); + +export type GeometryRequestMessage = z.infer< + typeof geometryRequestMessageSchema +>; + +export const canvasEditorMessageSchema = z.discriminatedUnion("type", [ + renderFrameMessageSchema, + geometryRequestMessageSchema, +]); + +export type CanvasEditorMessage = z.infer; + +// --- Envelopes (direction-specific) -------------------------------------- + +/** + * Direction-specific envelopes: an agent envelope can never smuggle an + * editor command and vice versa — each side parses only its inbound union. + */ +export const canvasAgentEnvelopeSchema = z + .object({ + protocolVersion: z.literal(PROTOCOL_VERSION), + message: canvasAgentMessageSchema, + }) + .strict(); + +export type CanvasAgentEnvelope = z.infer; + +export const canvasEditorEnvelopeSchema = z + .object({ + protocolVersion: z.literal(PROTOCOL_VERSION), + message: canvasEditorMessageSchema, + }) + .strict(); + +export type CanvasEditorEnvelope = z.infer; diff --git a/packages/editor/src/canvas-protocol/scripted-agent.mock.ts b/packages/editor/src/canvas-protocol/scripted-agent.mock.ts new file mode 100644 index 0000000..40f49a3 --- /dev/null +++ b/packages/editor/src/canvas-protocol/scripted-agent.mock.ts @@ -0,0 +1,97 @@ +import { PROTOCOL_VERSION } from "@repo/core-runner-protocol"; +import type { AgentWindowLike, EditorWindowLike } from "./canvas-client"; +import type { CanvasAgentMessage, GeometryTarget } from "./messages"; + +/** + * Scripted agent double (PRD Testing decisions): stands in for the adapter + * iframe's injected agent script in unit and component tests. + * + * One double = one fake origin pair. It owns: + * - `agentWindow` — records every editor→agent post with its + * `targetOrigin`, so tests can assert outbound pinning; + * - `editorWindow` — a fake message target the client listens on, so the + * double can script agent→editor events with any origin/source + * (including hostile ones) without jsdom `MessageEvent` restrictions. + */ + +export type PostedMessage = { + data: unknown; + targetOrigin: string; +}; + +type PostOverrides = { + /** Origin the event claims to come from. Defaults to the double's origin. */ + origin?: string; + /** Source window of the event. Defaults to the double's agentWindow. */ + source?: unknown; +}; + +export class ScriptedAgentDouble { + readonly origin: string; + /** Every editor→agent post, in order, with the targetOrigin used. */ + readonly posted: PostedMessage[] = []; + readonly agentWindow: AgentWindowLike; + readonly editorWindow: EditorWindowLike; + + private readonly listeners = new Set<(event: MessageEvent) => void>(); + + constructor(options: { origin: string }) { + this.origin = options.origin; + this.agentWindow = { + postMessage: (data: unknown, targetOrigin: string) => { + this.posted.push({ data, targetOrigin }); + }, + }; + this.editorWindow = { + addEventListener: (_type, listener) => { + this.listeners.add(listener); + }, + removeEventListener: (_type, listener) => { + this.listeners.delete(listener); + }, + }; + } + + /** Number of attached message listeners (asserts start/stop symmetry). */ + get listenerCount(): number { + return this.listeners.size; + } + + postRuntimeReady(overrides?: PostOverrides): void { + this.postMessage({ type: "runtime.ready" }, overrides); + } + + postGeometry( + targets: readonly GeometryTarget[], + overrides?: PostOverrides, + ): void { + this.postMessage( + { type: "geometry.report", targets: [...targets] }, + overrides, + ); + } + + postClick(nodeId: string | null, overrides?: PostOverrides): void { + this.postMessage({ type: "click.target", nodeId }, overrides); + } + + /** Script a well-formed agent envelope. */ + postMessage(message: CanvasAgentMessage, overrides?: PostOverrides): void { + this.postRaw({ protocolVersion: PROTOCOL_VERSION, message }, overrides); + } + + /** Script an arbitrary payload — for malformed-message tests. */ + postRaw(data: unknown, overrides?: PostOverrides): void { + const event = { + data, + origin: overrides?.origin ?? this.origin, + source: + overrides && "source" in overrides + ? overrides.source + : this.agentWindow, + } as MessageEvent; + for (const listener of [...this.listeners]) { + listener(event); + } + } +} diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 658c9ed..5ac3cff 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -1,4 +1,35 @@ -// Public contract surface of @repo/editor (types, schemas, manifest). -// UI artifacts (board, frame node, overlay, store hook) live behind -// `@repo/editor/ui` — see src/ui/index.ts. +// Public contract surface of @repo/editor (types, schemas, the canvas +// protocol seam, the manifest). UI artifacts (board, frame node, overlay, +// store hook) live behind `@repo/editor/ui` — see src/ui/index.ts. export { editorManifest, type EditorManifest } from "./feature.manifest"; +export { + rectSchema, + type Rect, + geometryTargetSchema, + type GeometryTarget, + runtimeReadyMessageSchema, + type RuntimeReadyMessage, + geometryReportMessageSchema, + type GeometryReportMessage, + clickTargetMessageSchema, + type ClickTargetMessage, + canvasAgentMessageSchema, + type CanvasAgentMessage, + geometryRequestMessageSchema, + type GeometryRequestMessage, + canvasEditorMessageSchema, + type CanvasEditorMessage, + canvasAgentEnvelopeSchema, + type CanvasAgentEnvelope, + canvasEditorEnvelopeSchema, + type CanvasEditorEnvelope, +} from "./canvas-protocol/messages"; +export { + createCanvasClient, + type CanvasClient, + type CanvasClientHandlers, + type CanvasClientOptions, + type AgentWindowLike, + type EditorWindowLike, +} from "./canvas-protocol/canvas-client"; +export type { RegistryComponent } from "./store/editor-store"; diff --git a/packages/editor/src/store/editor-store.test.ts b/packages/editor/src/store/editor-store.test.ts new file mode 100644 index 0000000..dee9d0c --- /dev/null +++ b/packages/editor/src/store/editor-store.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { useEditorStore, type RegistryComponent } from "@/store/editor-store"; + +beforeEach(() => { + useEditorStore.setState({ registry: [], selectedNodeId: null }); +}); + +describe("useEditorStore", () => { + it("starts with an empty registry and no selection", () => { + expect(useEditorStore.getState().registry).toEqual([]); + expect(useEditorStore.getState().selectedNodeId).toBeNull(); + }); + + it("setRegistry replaces the registry", () => { + const registry: RegistryComponent[] = [ + { id: "button", name: "Button" }, + { id: "card", name: "Card" }, + ]; + useEditorStore.getState().setRegistry(registry); + expect(useEditorStore.getState().registry).toEqual(registry); + + useEditorStore.getState().setRegistry([{ id: "badge", name: "Badge" }]); + expect(useEditorStore.getState().registry).toEqual([ + { id: "badge", name: "Badge" }, + ]); + }); + + it("setRegistry copies the input — later caller mutation does not leak in", () => { + const input: RegistryComponent[] = [{ id: "button", name: "Button" }]; + useEditorStore.getState().setRegistry(input); + input.push({ id: "rogue", name: "Rogue" }); + expect(useEditorStore.getState().registry).toEqual([ + { id: "button", name: "Button" }, + ]); + }); + + it("select sets and clears the selected node id", () => { + useEditorStore.getState().select("el-1"); + expect(useEditorStore.getState().selectedNodeId).toBe("el-1"); + useEditorStore.getState().select(null); + expect(useEditorStore.getState().selectedNodeId).toBeNull(); + }); + + it("selection does not touch the registry and vice versa", () => { + useEditorStore.getState().setRegistry([{ id: "button", name: "Button" }]); + useEditorStore.getState().select("el-1"); + useEditorStore.getState().setRegistry([]); + expect(useEditorStore.getState().selectedNodeId).toBe("el-1"); + }); + + it("holds registry + selection ONLY — no document tree until DesignDoc v1 (ADR-029)", () => { + expect(Object.keys(useEditorStore.getState()).sort()).toEqual([ + "registry", + "select", + "selectedNodeId", + "setRegistry", + ]); + }); +}); diff --git a/packages/editor/src/store/editor-store.ts b/packages/editor/src/store/editor-store.ts new file mode 100644 index 0000000..b92526c --- /dev/null +++ b/packages/editor/src/store/editor-store.ts @@ -0,0 +1,36 @@ +import { create } from "zustand"; + +/** + * The minimal editor store (walking-skeleton story 09): registry + + * selection ONLY. There is deliberately no document tree here — DesignDoc + * v1 and the view-model mapping layer arrive with the + * `design-doc-and-editor-foundation` epic (ADR-029). + * + * Geometry is NOT store state: agent-reported rects are frame-local + * (each frame node keeps its own report), keeping this store honest to + * its two concerns. + */ + +/** One discovered component, as the runner's scan reports it (ADR-027). */ +export type RegistryComponent = { + /** Registry id the frame's `render-frame` request references. */ + id: string; + /** Human-readable component name (e.g. "Button"). */ + name: string; +}; + +export type EditorStoreState = { + /** Discovered components available to place on the board. */ + registry: readonly RegistryComponent[]; + /** Agent-assigned node id of the selected element; `null` = none. */ + selectedNodeId: string | null; + setRegistry: (registry: readonly RegistryComponent[]) => void; + select: (nodeId: string | null) => void; +}; + +export const useEditorStore = create((set) => ({ + registry: [], + selectedNodeId: null, + setRegistry: (registry) => set({ registry: [...registry] }), + select: (nodeId) => set({ selectedNodeId: nodeId }), +})); diff --git a/packages/editor/src/ui/index.ts b/packages/editor/src/ui/index.ts index 1a7fc86..995d33f 100644 --- a/packages/editor/src/ui/index.ts +++ b/packages/editor/src/ui/index.ts @@ -1,5 +1,5 @@ -// Public UI surface of @repo/editor: the React Flow board, iframe frame -// node, selection overlay, and the editor store hook land here as the -// walking-skeleton slices ship. Apps import components/hooks from -// `@repo/editor/ui` and contracts from `@repo/editor`. -export {}; +// Public UI surface of @repo/editor: apps import components/hooks from +// `@repo/editor/ui` and contracts from `@repo/editor`. The React Flow +// board, iframe frame node, and selection overlay land here with the +// board-shell slice. +export { useEditorStore, type EditorStoreState } from "../store/editor-store";