From 1478adfacb013580c7839f08bacc9c8f684d2052 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Wed, 26 Aug 2026 20:02:39 -0400 Subject: [PATCH] feat(studio): add studio_inspect, so an agent reads before it writes Everything about one element in one call: resolved styles, text fields, box, data attributes, GSAP animations, and what the element will and will not accept. The point is to prevent a failed write rather than to satisfy curiosity. `can.reasonIfDisabled` is passed through verbatim from Studio's own capabilities, so an agent that reads first should never attempt an edit the element would refuse. Three things it refuses to get wrong: Animations are reported ONLY for the current selection, because that is the only element Studio parses them for. Attributing them to any other element would be reporting the wrong element's motion, which is worse than reporting none. When a handle names something else the field is empty and `animationEditingBlocked` says why. `animationEditingBlocked` also carries the two states where animation editing is off entirely, multiple timelines and an unsupported timeline pattern. Both live on the selection context. Learning them from a read costs one call; learning them from a failed write costs a retry loop. Inspecting a handle does NOT change what is selected. It is a read, and stealing the human's selection would be a side effect they did not ask for. There is a test asserting `applySelection` is never called. Nothing selected and no handle given is a failure, not an empty result. An empty result would assert "this element has nothing", which is a different and false claim. --- .../studio/src/webmcp/StudioAgentTools.tsx | 17 +- .../src/webmcp/tools/frameTools.test.ts | 12 +- .../src/webmcp/tools/inspectTools.test.ts | 197 +++++++++++++++++ .../studio/src/webmcp/tools/inspectTools.ts | 208 ++++++++++++++++++ .../src/webmcp/tools/selectionTools.test.ts | 52 +---- .../src/webmcp/useStudioAgentTools.test.tsx | 13 +- .../studio/src/webmcp/useStudioAgentTools.ts | 21 +- packages/studio/src/webmcp/webmcpTestUtils.ts | 91 ++++++++ 8 files changed, 544 insertions(+), 67 deletions(-) create mode 100644 packages/studio/src/webmcp/tools/inspectTools.test.ts create mode 100644 packages/studio/src/webmcp/tools/inspectTools.ts create mode 100644 packages/studio/src/webmcp/webmcpTestUtils.ts diff --git a/packages/studio/src/webmcp/StudioAgentTools.tsx b/packages/studio/src/webmcp/StudioAgentTools.tsx index e26612c175..4fbf55e689 100644 --- a/packages/studio/src/webmcp/StudioAgentTools.tsx +++ b/packages/studio/src/webmcp/StudioAgentTools.tsx @@ -18,7 +18,12 @@ import type { StudioLookSnapshot } from "./tools/lookTools"; */ export function StudioAgentTools() { const { projectId, activeCompPath, editHistory } = useStudioShellContext(); - const { domEditSelection, selectedGsapAnimations } = useDomEditSelectionContext(); + const { + domEditSelection, + selectedGsapAnimations, + gsapMultipleTimelines, + gsapUnsupportedTimelinePattern, + } = useDomEditSelectionContext(); const { previewIframeRef, buildDomSelectionFromTarget, applyDomSelection } = useDomEditActionsContext(); @@ -71,6 +76,12 @@ export function StudioAgentTools() { } }, wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + getCurrentSelection: () => domEditSelection, + getGsapDiagnostics: () => ({ + animations: selectedGsapAnimations, + multipleTimelines: gsapMultipleTimelines, + unsupportedTimelinePattern: gsapUnsupportedTimelinePattern, + }), }), [ getSnapshot, @@ -79,6 +90,10 @@ export function StudioAgentTools() { applyDomSelection, projectId, activeCompPath, + domEditSelection, + selectedGsapAnimations, + gsapMultipleTimelines, + gsapUnsupportedTimelinePattern, ], ); diff --git a/packages/studio/src/webmcp/tools/frameTools.test.ts b/packages/studio/src/webmcp/tools/frameTools.test.ts index e98d5c2e46..8ac0b5a432 100644 --- a/packages/studio/src/webmcp/tools/frameTools.test.ts +++ b/packages/studio/src/webmcp/tools/frameTools.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { describe, expect, it, vi } from "vitest"; import { studioFrame, type FrameToolDeps, type StudioFrameResult } from "./frameTools"; -import type { ToolFailure, ToolResult } from "../toolResult"; +import { expectFailure, expectOk } from "../webmcpTestUtils"; function frameDeps(overrides: Partial = {}): FrameToolDeps { return { @@ -15,16 +15,6 @@ function frameDeps(overrides: Partial = {}): FrameToolDeps { }; } -function expectOk(result: ToolResult): { ok: true } & T { - if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`); - return result; -} - -function expectFailure(result: ToolResult): ToolFailure { - if (result.ok) throw new Error(`expected failure, got ${JSON.stringify(result)}`); - return result; -} - describe("studioFrame", () => { it("returns a URL for the composition at the playhead", async () => { const result = await studioFrame(frameDeps()); diff --git a/packages/studio/src/webmcp/tools/inspectTools.test.ts b/packages/studio/src/webmcp/tools/inspectTools.test.ts new file mode 100644 index 0000000000..d59034a45f --- /dev/null +++ b/packages/studio/src/webmcp/tools/inspectTools.test.ts @@ -0,0 +1,197 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from "vitest"; +import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser"; +import { studioInspect, type InspectToolDeps, type StudioInspectResult } from "./inspectTools"; +import { + expectFailure, + expectOk, + previewDoc, + previewElement, + selectionFor, +} from "../webmcpTestUtils"; + +function animation(overrides: Partial = {}): GsapAnimation { + return { + id: "anim-1", + targetSelector: "#headline", + method: "from", + position: 0, + properties: { y: -50, opacity: 0 }, + duration: 1, + ease: "power2.out", + ...overrides, + } as GsapAnimation; +} + +function inspectDeps(overrides: Partial = {}): InspectToolDeps { + return { + getPreviewDocument: () => null, + buildSelection: async (element) => selectionFor(element), + applySelection: () => undefined, + requestSeek: () => undefined, + readPlayhead: () => ({ currentTime: 0, duration: 10, isPlaying: false }), + getCurrentSelection: () => null, + getGsapDiagnostics: () => ({ + animations: [], + multipleTimelines: false, + unsupportedTimelinePattern: false, + }), + ...overrides, + }; +} + +describe("studioInspect", () => { + it("returns the resolved styles, not the authored ones", async () => { + const element = previewElement('

Ship it

', "headline"); + const selection = selectionFor(element); + + const result = await studioInspect(inspectDeps({ getCurrentSelection: () => selection })); + + const ok = expectOk(result); + // The authored value is a clamp(); the resolved one is what actually renders. + expect(ok.styles["font-size"]).toBe("42.7px"); + expect(ok.inlineStyles.color).toBe("red"); + expect(ok.box.width).toBe(880); + }); + + it("reports capabilities and the disabled reason verbatim", async () => { + const element = previewElement('

Ship it

', "headline"); + const locked = selectionFor(element, { + capabilities: { + canSelect: true, + canEditStyles: false, + canCrop: false, + canMove: false, + canResize: false, + canApplyManualOffset: false, + canApplyManualSize: false, + canApplyManualRotation: false, + reasonIfDisabled: "Element is inside a locked composition", + }, + }); + + const result = await studioInspect(inspectDeps({ getCurrentSelection: () => locked })); + + const ok = expectOk(result); + expect(ok.can.editStyles).toBe(false); + expect(ok.can.move).toBe(false); + expect(ok.can.reasonIfDisabled).toBe("Element is inside a locked composition"); + }); + + it("lists the animations on the current selection", async () => { + const element = previewElement('

Ship it

', "headline"); + + const result = await studioInspect( + inspectDeps({ + getCurrentSelection: () => selectionFor(element), + getGsapDiagnostics: () => ({ + animations: [animation()], + multipleTimelines: false, + unsupportedTimelinePattern: false, + }), + }), + ); + + const ok = expectOk(result); + expect(ok.animations).toHaveLength(1); + expect(ok.animations[0]?.animationId).toBe("anim-1"); + expect(ok.animations[0]?.ease).toBe("power2.out"); + expect(ok.animationEditingBlocked).toBeNull(); + }); + + it("says WHY animation editing is unavailable, so a write is not attempted", async () => { + const element = previewElement('

Ship it

', "headline"); + const base = { + getCurrentSelection: () => selectionFor(element), + }; + + const multiple = await studioInspect( + inspectDeps({ + ...base, + getGsapDiagnostics: () => ({ + animations: [], + multipleTimelines: true, + unsupportedTimelinePattern: false, + }), + }), + ); + const unsupported = await studioInspect( + inspectDeps({ + ...base, + getGsapDiagnostics: () => ({ + animations: [], + multipleTimelines: false, + unsupportedTimelinePattern: true, + }), + }), + ); + + expect(expectOk(multiple).animationEditingBlocked).toMatch( + /multiple GSAP timelines/, + ); + expect(expectOk(unsupported).animationEditingBlocked).toMatch( + /not editable/, + ); + }); + + it("does not attribute the selection's animations to a different element", async () => { + // Studio only parses animations for the CURRENT selection. Reporting them + // against another element would report the wrong element's motion. + const headline = previewElement('

A

B

', "headline"); + const doc = headline.ownerDocument; + + const result = await studioInspect( + inspectDeps({ + getPreviewDocument: () => doc, + getCurrentSelection: () => selectionFor(headline), + getGsapDiagnostics: () => ({ + animations: [animation()], + multipleTimelines: false, + unsupportedTimelinePattern: false, + }), + }), + { handle: "dom:body" }, + ); + + const ok = expectOk(result); + expect(ok.isCurrentSelection).toBe(false); + expect(ok.animations).toEqual([]); + expect(ok.animationEditingBlocked).toMatch(/only readable for the current selection/); + }); + + it("inspects a handle without changing what is selected", async () => { + const doc = previewDoc('

A

'); + const applySelection = vi.fn(); + + const result = await studioInspect( + inspectDeps({ getPreviewDocument: () => doc, applySelection }), + { handle: "dom:headline" }, + ); + + expect(result.ok).toBe(true); + // Inspecting is a read. It must not steal the human's selection. + expect(applySelection).not.toHaveBeenCalled(); + }); + + it("fails rather than returning an empty result when nothing is selected", async () => { + const result = expectFailure(await studioInspect(inspectDeps())); + + // An empty result would assert "this element has nothing", a different and + // false claim from "you did not say which element". + expect(result.kind).toBe("invalid"); + expect(result.reason).toMatch(/nothing is selected/); + expect(result.hint).toMatch(/studio_select/); + }); + + it("reports an unknown handle distinctly from an unmounted preview", async () => { + const notMounted = expectFailure(await studioInspect(inspectDeps(), { handle: "dom:x" })); + expect(notMounted.kind).toBe("blocked"); + + const doc = previewDoc('

A

'); + const unknown = expectFailure( + await studioInspect(inspectDeps({ getPreviewDocument: () => doc }), { handle: "dom:x" }), + ); + expect(unknown.kind).toBe("invalid"); + expect(unknown.reason).not.toBe(notMounted.reason); + }); +}); diff --git a/packages/studio/src/webmcp/tools/inspectTools.ts b/packages/studio/src/webmcp/tools/inspectTools.ts new file mode 100644 index 0000000000..f31b9fc834 --- /dev/null +++ b/packages/studio/src/webmcp/tools/inspectTools.ts @@ -0,0 +1,208 @@ +/** + * `studio_inspect`: everything about one element, in one call. + * + * The point is to prevent a failed write. Every field here either tells the + * agent what it can change (`can`, with `reasonIfDisabled` verbatim) or what it + * would be changing (the resolved styles, the text fields, the animations). + * An agent that reads this first should never attempt an edit the element will + * refuse. + * + * The GSAP diagnostics are here for the same reason: `multipleTimelines` and + * `unsupportedTimelinePattern` are states where animation editing is off, and + * learning that from a read is cheaper than learning it from a failed write. + */ + +import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser"; +import type { DomEditSelection } from "../../components/editor/domEditingTypes"; +import { mintElementHandle, patchTargetAddress, resolveElementHandle } from "../handles"; +import { toolFailure, toolOk, type ToolResult } from "../toolResult"; +import type { SelectionToolDeps } from "./selectionTools"; + +export interface InspectToolDeps extends SelectionToolDeps { + /** What the human currently has selected, used when no handle is given. */ + getCurrentSelection: () => DomEditSelection | null; + getGsapDiagnostics: () => { + animations: readonly GsapAnimation[]; + multipleTimelines: boolean; + unsupportedTimelinePattern: boolean; + }; +} + +interface InspectAnimation { + animationId: string; + method: string; + target: string; + position: number | string; + duration: number | null; + ease: string | null; + properties: Record; + hasKeyframes: boolean; + hasArcPath: boolean; +} + +interface InspectTextField { + key: string; + label: string; + value: string; + tagName: string; +} + +export interface StudioInspectResult { + handle: string | null; + label: string; + tagName: string; + sourceFile: string; + box: { x: number; y: number; width: number; height: number }; + text: string | null; + textFields: InspectTextField[]; + /** The styles Studio itself surfaces, resolved, not as authored. */ + styles: Record; + inlineStyles: Record; + dataAttributes: Record; + can: { + editStyles: boolean; + move: boolean; + resize: boolean; + rotate: boolean; + crop: boolean; + editText: boolean; + reasonIfDisabled: string | null; + }; + animations: InspectAnimation[]; + /** Present only when animation editing is unavailable, with the reason. */ + animationEditingBlocked: string | null; + /** True when this element is the one the human currently has selected. */ + isCurrentSelection: boolean; +} + +export interface StudioInspectInput { + /** Omit to inspect the current selection. */ + handle?: string; +} + +function describeAnimation(animation: GsapAnimation): InspectAnimation { + return { + animationId: animation.id, + method: animation.method, + target: animation.targetSelector, + position: animation.position, + duration: animation.duration ?? null, + ease: animation.ease ?? null, + properties: animation.properties, + hasKeyframes: animation.keyframes !== undefined, + hasArcPath: animation.arcPath !== undefined, + }; +} + +function describe( + selection: DomEditSelection, + deps: InspectToolDeps, + isCurrentSelection: boolean, +): ToolResult { + const { capabilities } = selection; + const gsap = deps.getGsapDiagnostics(); + + // Only the CURRENT selection's animations are parsed by Studio. Reporting + // them for some other element would be reporting the wrong element's motion, + // which is worse than reporting none. + const animations = isCurrentSelection ? gsap.animations.map(describeAnimation) : []; + + let animationEditingBlocked: string | null = null; + if (!isCurrentSelection) { + animationEditingBlocked = "animations are only readable for the current selection"; + } else if (gsap.multipleTimelines) { + animationEditingBlocked = "this composition has multiple GSAP timelines"; + } else if (gsap.unsupportedTimelinePattern) { + animationEditingBlocked = "this composition's timeline pattern is not editable by Studio"; + } + + return toolOk({ + handle: mintElementHandle(patchTargetAddress(selection)), + label: selection.label, + tagName: selection.tagName, + sourceFile: selection.sourceFile, + box: selection.boundingBox, + text: selection.textContent, + textFields: selection.textFields.map((field) => ({ + key: field.key, + label: field.label, + value: field.value, + tagName: field.tagName, + })), + styles: selection.computedStyles, + inlineStyles: selection.inlineStyles, + dataAttributes: selection.dataAttributes, + can: { + editStyles: capabilities.canEditStyles, + move: capabilities.canMove || capabilities.canApplyManualOffset, + resize: capabilities.canResize || capabilities.canApplyManualSize, + rotate: capabilities.canApplyManualRotation, + crop: capabilities.canCrop, + editText: selection.textFields.length > 0, + reasonIfDisabled: capabilities.reasonIfDisabled ?? null, + }, + animations, + animationEditingBlocked, + isCurrentSelection, + }); +} + +export async function studioInspect( + deps: InspectToolDeps, + input: StudioInspectInput = {}, +): Promise> { + const current = deps.getCurrentSelection(); + + if (!input.handle) { + // An empty result here would assert "this element has nothing", which is a + // different and false claim from "you did not tell me which element". + if (!current) { + return toolFailure( + "invalid", + "nothing is selected and no handle was given", + "Pass a handle from studio_look, or call studio_select first.", + ); + } + return describe(current, deps, true); + } + + const doc = deps.getPreviewDocument(); + if (!doc) return toolFailure("blocked", "the preview is not mounted yet"); + + const element = resolveElementHandle(doc, input.handle); + if (!element) { + return toolFailure( + "invalid", + `no element matches handle ${input.handle}`, + "Call studio_look for current handles.", + ); + } + + const selection = await deps.buildSelection(element); + if (!selection) { + return toolFailure("blocked", `${input.handle} resolved to an element Studio cannot inspect`); + } + + return describe(selection, deps, current?.element === element); +} + +export const STUDIO_INSPECT_INPUT_SCHEMA = { + type: "object", + properties: { + handle: { + type: "string", + description: "An element handle from studio_look. Omit to inspect the current selection.", + }, + }, + additionalProperties: false, +} as const; + +export const STUDIO_INSPECT_DESCRIPTION = [ + "Everything about one element: its resolved styles, its text fields, its box,", + "its GSAP animations, and crucially what it will and will not accept.", + "Read this BEFORE editing. `can` tells you which edits are possible and", + "`can.reasonIfDisabled` says why one is not, so you can avoid a write that would be refused.", + "Animations are only readable for the CURRENT selection; `animationEditingBlocked` says when", + "and why animation editing is unavailable.", + "Returns `ok: true`, or `ok: false` with `kind`, `reason` and a `hint`.", +].join(" "); diff --git a/packages/studio/src/webmcp/tools/selectionTools.test.ts b/packages/studio/src/webmcp/tools/selectionTools.test.ts index c6ec5fdd2a..07168529ae 100644 --- a/packages/studio/src/webmcp/tools/selectionTools.test.ts +++ b/packages/studio/src/webmcp/tools/selectionTools.test.ts @@ -1,6 +1,5 @@ // @vitest-environment jsdom import { describe, expect, it, vi } from "vitest"; -import type { DomEditSelection } from "../../components/editor/domEditingTypes"; import { studioSeek, studioSelect, @@ -8,46 +7,7 @@ import { type StudioSeekResult, type StudioSelectResult, } from "./selectionTools"; -import type { ToolFailure, ToolResult } from "../toolResult"; - -function previewDoc(html: string): Document { - const iframe = document.createElement("iframe"); - document.body.append(iframe); - const doc = iframe.contentDocument; - if (!doc) throw new Error("expected iframe document"); - doc.body.innerHTML = html; - return doc; -} - -function selectionFor(element: HTMLElement): DomEditSelection { - return { - id: element.id || undefined, - hfId: element.getAttribute("data-hf-id") ?? undefined, - element, - label: "Headline", - tagName: element.tagName.toLowerCase(), - sourceFile: "index.html", - compositionPath: "index.html", - isCompositionHost: false, - isInsideLockedComposition: false, - boundingBox: { x: 40, y: 12, width: 880, height: 96 }, - textContent: element.textContent, - dataAttributes: {}, - inlineStyles: {}, - computedStyles: {}, - textFields: [], - capabilities: { - canSelect: true, - canEditStyles: true, - canCrop: true, - canMove: true, - canResize: true, - canApplyManualOffset: true, - canApplyManualSize: true, - canApplyManualRotation: true, - }, - }; -} +import { expectFailure, expectOk, previewDoc, selectionFor } from "../webmcpTestUtils"; function selectionDeps(overrides: Partial = {}): SelectionToolDeps { return { @@ -60,16 +20,6 @@ function selectionDeps(overrides: Partial = {}): SelectionToo }; } -function expectFailure(result: ToolResult): ToolFailure { - if (result.ok) throw new Error(`expected failure, got ${JSON.stringify(result)}`); - return result; -} - -function expectOk(result: ToolResult): { ok: true } & T { - if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`); - return result; -} - describe("studioSelect", () => { it("applies the selection a click would produce and reports it back", async () => { const doc = previewDoc('

Ship it

'); diff --git a/packages/studio/src/webmcp/useStudioAgentTools.test.tsx b/packages/studio/src/webmcp/useStudioAgentTools.test.tsx index d9c7dd4081..3a9a3ee988 100644 --- a/packages/studio/src/webmcp/useStudioAgentTools.test.tsx +++ b/packages/studio/src/webmcp/useStudioAgentTools.test.tsx @@ -42,6 +42,12 @@ function deps(overrides: Partial = {}): StudioAgentToolsDe getCompositionPath: () => "index.html", probeFrame: async () => ({ ok: true, status: 200 }), wait: async () => undefined, + getCurrentSelection: () => null, + getGsapDiagnostics: () => ({ + animations: [], + multipleTimelines: false, + unsupportedTimelinePattern: false, + }), ...overrides, }; } @@ -107,6 +113,7 @@ describe("useStudioAgentTools", () => { "studio_select", "studio_seek", "studio_frame", + "studio_inspect", ]); expect(trackEvent).toHaveBeenCalledWith("webmcp.native_present"); }); @@ -121,14 +128,14 @@ describe("useStudioAgentTools", () => { await act(async () => { harness = mountTools(deps({ getSnapshot: () => snapshot() })); }); - expect(registerTool).toHaveBeenCalledTimes(4); + expect(registerTool).toHaveBeenCalledTimes(5); await act(async () => { harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 5 }) })); harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 6 }) })); }); - expect(registerTool).toHaveBeenCalledTimes(4); + expect(registerTool).toHaveBeenCalledTimes(5); }); it("executes against the LATEST deps, not the ones present at registration", async () => { @@ -201,7 +208,7 @@ describe("useStudioAgentTools", () => { mountTools(deps({ getSnapshot: () => snapshot() })); }); - expect(registerTool).toHaveBeenCalledTimes(4); + expect(registerTool).toHaveBeenCalledTimes(5); }); it("reports a non-abort registration failure through production telemetry", async () => { diff --git a/packages/studio/src/webmcp/useStudioAgentTools.ts b/packages/studio/src/webmcp/useStudioAgentTools.ts index 9af5b8cc61..c5e1a444eb 100644 --- a/packages/studio/src/webmcp/useStudioAgentTools.ts +++ b/packages/studio/src/webmcp/useStudioAgentTools.ts @@ -33,6 +33,14 @@ import { type StudioFrameInput, type StudioFrameResult, } from "./tools/frameTools"; +import { + studioInspect, + STUDIO_INSPECT_DESCRIPTION, + STUDIO_INSPECT_INPUT_SCHEMA, + type InspectToolDeps, + type StudioInspectInput, + type StudioInspectResult, +} from "./tools/inspectTools"; const log = makeStudioDebugLogger("webmcp"); @@ -46,7 +54,7 @@ function reportRegistration(report: ToolRegistrationReport, native: boolean): vo } } -export interface StudioAgentToolsDeps extends SelectionToolDeps, FrameToolDeps { +export interface StudioAgentToolsDeps extends SelectionToolDeps, FrameToolDeps, InspectToolDeps { /** Read Studio's current state. Called per tool invocation, never cached. */ getSnapshot: () => StudioLookSnapshot; } @@ -107,6 +115,17 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }): execute: (input): Promise> => runToolBody("studio_frame", () => studioFrame(depsRef.current, input as StudioFrameInput)), }, + { + name: "studio_inspect", + title: "Inspect one element", + description: STUDIO_INSPECT_DESCRIPTION, + inputSchema: STUDIO_INSPECT_INPUT_SCHEMA, + annotations: { readOnlyHint: true, untrustedContentHint: true }, + execute: (input): Promise> => + runToolBody("studio_inspect", () => + studioInspect(depsRef.current, input as StudioInspectInput), + ), + }, ]; } diff --git a/packages/studio/src/webmcp/webmcpTestUtils.ts b/packages/studio/src/webmcp/webmcpTestUtils.ts new file mode 100644 index 0000000000..8f4f5e96b0 --- /dev/null +++ b/packages/studio/src/webmcp/webmcpTestUtils.ts @@ -0,0 +1,91 @@ +/** + * Shared fixtures for the WebMCP tool tests. + * + * Not a `.test` file so vitest does not collect it as a suite. Mirrors the + * existing `hooks/domSelectionTestHarness.ts` convention. + */ + +import { expect } from "vitest"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import type { ToolFailure, ToolResult } from "./toolResult"; + +/** + * An element inside a real iframe, which is where Studio's chrome expects to + * find preview elements. The separate realm matters: a preview element is not + * an instance of Studio's own `HTMLElement`. + */ +export function previewDoc(html: string): Document { + const iframe = document.createElement("iframe"); + document.body.append(iframe); + const doc = iframe.contentDocument; + if (!doc) throw new Error("expected iframe document"); + doc.body.innerHTML = html; + return doc; +} + +export function previewElement(html: string, id: string): HTMLElement { + const doc = previewDoc(html); + const element = doc.getElementById(id); + const HTMLElementCtor = doc.defaultView?.HTMLElement; + if (!HTMLElementCtor || !(element instanceof HTMLElementCtor)) { + throw new Error(`expected preview element #${id}`); + } + return element; +} + +export function selectionFor( + element: HTMLElement, + overrides: Partial = {}, +): DomEditSelection { + return { + id: element.id || undefined, + hfId: element.getAttribute("data-hf-id") ?? undefined, + element, + label: "Headline", + tagName: element.tagName.toLowerCase(), + sourceFile: "index.html", + compositionPath: "index.html", + isCompositionHost: false, + isInsideLockedComposition: false, + boundingBox: { x: 40, y: 12, width: 880, height: 96 }, + textContent: element.textContent, + dataAttributes: { "data-role": "title" }, + inlineStyles: { color: "red" }, + computedStyles: { "font-size": "42.7px", color: "rgb(255, 0, 0)" }, + textFields: [ + { + key: "self", + label: "Text", + value: element.textContent ?? "", + tagName: element.tagName.toLowerCase(), + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self", + }, + ], + capabilities: { + canSelect: true, + canEditStyles: true, + canCrop: true, + canMove: true, + canResize: true, + canApplyManualOffset: true, + canApplyManualSize: true, + canApplyManualRotation: true, + }, + ...overrides, + }; +} + +export function expectOk(result: ToolResult): { ok: true } & T { + expect(result.ok, `expected ok, got ${JSON.stringify(result)}`).toBe(true); + if (!result.ok) throw new Error("unreachable"); + return result; +} + +export function expectFailure(result: ToolResult): ToolFailure { + expect(result.ok, `expected failure, got ${JSON.stringify(result)}`).toBe(false); + if (result.ok) throw new Error("unreachable"); + return result; +}