Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion packages/studio/src/webmcp/StudioAgentTools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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,
Expand All @@ -79,6 +90,10 @@ export function StudioAgentTools() {
applyDomSelection,
projectId,
activeCompPath,
domEditSelection,
selectedGsapAnimations,
gsapMultipleTimelines,
gsapUnsupportedTimelinePattern,
],
);

Expand Down
12 changes: 1 addition & 11 deletions packages/studio/src/webmcp/tools/frameTools.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): FrameToolDeps {
return {
Expand All @@ -15,16 +15,6 @@ function frameDeps(overrides: Partial<FrameToolDeps> = {}): FrameToolDeps {
};
}

function expectOk<T>(result: ToolResult<T>): { ok: true } & T {
if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
return result;
}

function expectFailure(result: ToolResult<unknown>): 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());
Expand Down
197 changes: 197 additions & 0 deletions packages/studio/src/webmcp/tools/inspectTools.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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> = {}): 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('<h1 id="headline">Ship it</h1>', "headline");
const selection = selectionFor(element);

const result = await studioInspect(inspectDeps({ getCurrentSelection: () => selection }));

const ok = expectOk<StudioInspectResult>(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('<h1 id="headline">Ship it</h1>', "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<StudioInspectResult>(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('<h1 id="headline">Ship it</h1>', "headline");

const result = await studioInspect(
inspectDeps({
getCurrentSelection: () => selectionFor(element),
getGsapDiagnostics: () => ({
animations: [animation()],
multipleTimelines: false,
unsupportedTimelinePattern: false,
}),
}),
);

const ok = expectOk<StudioInspectResult>(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('<h1 id="headline">Ship it</h1>', "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<StudioInspectResult>(multiple).animationEditingBlocked).toMatch(
/multiple GSAP timelines/,
);
expect(expectOk<StudioInspectResult>(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('<h1 id="headline">A</h1><p id="body">B</p>', "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<StudioInspectResult>(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('<h1 id="headline">A</h1>');
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('<h1 id="headline">A</h1>');
const unknown = expectFailure(
await studioInspect(inspectDeps({ getPreviewDocument: () => doc }), { handle: "dom:x" }),
);
expect(unknown.kind).toBe("invalid");
expect(unknown.reason).not.toBe(notMounted.reason);
});
});
Loading
Loading