@hyperframes/studio 0.8.20 → 0.8.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/hyperframes-player-DmtkkaPN.js +460 -0
- package/dist/assets/{index-CBSKXKAr.js → index-BIf0zQNt.js} +164 -164
- package/dist/assets/{index-Cehosfnu.js → index-DXR3nsh2.js} +1 -1
- package/dist/assets/{index-BL1zYTW0.js → index-DivnGS5G.js} +1 -1
- package/dist/index.d.ts +28 -0
- package/dist/index.html +1 -1
- package/dist/index.js +1170 -227
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -0
- package/src/contexts/StudioContext.tsx +10 -0
- package/src/hooks/useStudioContextValue.ts +9 -0
- package/src/player/hooks/timelineSyncHydration.test.ts +96 -0
- package/src/player/hooks/timelineSyncHydration.ts +47 -1
- package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
- package/src/player/hooks/useExpandedTimelineElements.ts +52 -29
- package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -0
- package/src/player/store/playerStore.ts +17 -2
- package/src/player/store/timelineElement.ts +21 -0
- package/src/webmcp/StudioAgentTools.tsx +105 -10
- package/src/webmcp/toolResult.ts +1 -1
- package/src/webmcp/tools/animationTools.test.ts +244 -0
- package/src/webmcp/tools/animationTools.ts +276 -0
- package/src/webmcp/tools/contentTools.test.ts +275 -0
- package/src/webmcp/tools/contentTools.ts +217 -0
- package/src/webmcp/tools/frameTools.test.ts +136 -0
- package/src/webmcp/tools/frameTools.ts +133 -0
- package/src/webmcp/tools/inspectTools.test.ts +197 -0
- package/src/webmcp/tools/inspectTools.ts +208 -0
- package/src/webmcp/tools/selectionTools.test.ts +164 -0
- package/src/webmcp/tools/selectionTools.ts +154 -0
- package/src/webmcp/tools/transformTools.test.ts +179 -0
- package/src/webmcp/tools/transformTools.ts +205 -0
- package/src/webmcp/useStudioAgentTools.test.tsx +71 -22
- package/src/webmcp/useStudioAgentTools.ts +195 -1
- package/src/webmcp/webmcpTestUtils.ts +91 -0
- package/dist/assets/hyperframes-player-BEKxuimO.js +0 -459
|
@@ -29,6 +29,40 @@ function snapshot(overrides: Partial<StudioLookSnapshot> = {}): StudioLookSnapsh
|
|
|
29
29
|
};
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/** Full deps with inert defaults; override only what the test is about. */
|
|
33
|
+
function deps(overrides: Partial<StudioAgentToolsDeps> = {}): StudioAgentToolsDeps {
|
|
34
|
+
return {
|
|
35
|
+
getSnapshot: () => snapshot(),
|
|
36
|
+
getPreviewDocument: () => null,
|
|
37
|
+
buildSelection: async () => null,
|
|
38
|
+
applySelection: () => undefined,
|
|
39
|
+
requestSeek: () => undefined,
|
|
40
|
+
readPlayhead: () => ({ currentTime: 0, duration: 10, isPlaying: false }),
|
|
41
|
+
getProjectId: () => "demo",
|
|
42
|
+
getCompositionPath: () => "index.html",
|
|
43
|
+
probeFrame: async () => ({ ok: true, status: 200 }),
|
|
44
|
+
wait: async () => undefined,
|
|
45
|
+
getCurrentSelection: () => null,
|
|
46
|
+
getWriteBlockedReason: () => null,
|
|
47
|
+
setText: async () => ({ ok: true }),
|
|
48
|
+
setStyle: async () => ({ ok: true }),
|
|
49
|
+
readBox: () => ({ x: 0, y: 0, width: 100, height: 50 }),
|
|
50
|
+
moveTo: async () => undefined,
|
|
51
|
+
resizeTo: async () => undefined,
|
|
52
|
+
rotateTo: async () => undefined,
|
|
53
|
+
addAnimation: () => undefined,
|
|
54
|
+
updateAnimation: async () => true,
|
|
55
|
+
addKeyframe: async () => undefined,
|
|
56
|
+
deleteAnimation: () => undefined,
|
|
57
|
+
getGsapDiagnostics: () => ({
|
|
58
|
+
animations: [],
|
|
59
|
+
multipleTimelines: false,
|
|
60
|
+
unsupportedTimelinePattern: false,
|
|
61
|
+
}),
|
|
62
|
+
...overrides,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
32
66
|
/** Install a fake `document.modelContext` and report what got registered. */
|
|
33
67
|
function installModelContext() {
|
|
34
68
|
const registered: ModelContextTool[] = [];
|
|
@@ -50,12 +84,12 @@ function removeModelContext() {
|
|
|
50
84
|
Reflect.deleteProperty(document, "modelContext");
|
|
51
85
|
}
|
|
52
86
|
|
|
53
|
-
function mountTools(
|
|
87
|
+
function mountTools(initial: StudioAgentToolsDeps) {
|
|
54
88
|
function Probe({ current }: { current: StudioAgentToolsDeps }) {
|
|
55
89
|
useStudioAgentTools(current);
|
|
56
90
|
return null;
|
|
57
91
|
}
|
|
58
|
-
const root = mountReactHarness(<Probe current={
|
|
92
|
+
const root = mountReactHarness(<Probe current={initial} />);
|
|
59
93
|
cleanup = () => act(() => root.unmount());
|
|
60
94
|
return {
|
|
61
95
|
rerenderWith(next: StudioAgentToolsDeps) {
|
|
@@ -82,10 +116,23 @@ describe("useStudioAgentTools", () => {
|
|
|
82
116
|
const { registered } = installModelContext();
|
|
83
117
|
|
|
84
118
|
await act(async () => {
|
|
85
|
-
mountTools({ getSnapshot: () => snapshot() });
|
|
119
|
+
mountTools(deps({ getSnapshot: () => snapshot() }));
|
|
86
120
|
});
|
|
87
121
|
|
|
88
|
-
expect(registered.map((tool) => tool.name)).toEqual([
|
|
122
|
+
expect(registered.map((tool) => tool.name)).toEqual([
|
|
123
|
+
"studio_look",
|
|
124
|
+
"studio_select",
|
|
125
|
+
"studio_seek",
|
|
126
|
+
"studio_frame",
|
|
127
|
+
"studio_inspect",
|
|
128
|
+
"studio_set_text",
|
|
129
|
+
"studio_set_style",
|
|
130
|
+
"studio_transform",
|
|
131
|
+
"studio_add_animation",
|
|
132
|
+
"studio_update_animation",
|
|
133
|
+
"studio_add_keyframe",
|
|
134
|
+
"studio_delete_animation",
|
|
135
|
+
]);
|
|
89
136
|
expect(trackEvent).toHaveBeenCalledWith("webmcp.native_present");
|
|
90
137
|
});
|
|
91
138
|
|
|
@@ -97,16 +144,16 @@ describe("useStudioAgentTools", () => {
|
|
|
97
144
|
|
|
98
145
|
let harness: ReturnType<typeof mountTools> | null = null;
|
|
99
146
|
await act(async () => {
|
|
100
|
-
harness = mountTools({ getSnapshot: () => snapshot() });
|
|
147
|
+
harness = mountTools(deps({ getSnapshot: () => snapshot() }));
|
|
101
148
|
});
|
|
102
|
-
expect(registerTool).toHaveBeenCalledTimes(
|
|
149
|
+
expect(registerTool).toHaveBeenCalledTimes(12);
|
|
103
150
|
|
|
104
151
|
await act(async () => {
|
|
105
|
-
harness?.rerenderWith({ getSnapshot: () => snapshot({ currentTime: 5 }) });
|
|
106
|
-
harness?.rerenderWith({ getSnapshot: () => snapshot({ currentTime: 6 }) });
|
|
152
|
+
harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 5 }) }));
|
|
153
|
+
harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 6 }) }));
|
|
107
154
|
});
|
|
108
155
|
|
|
109
|
-
expect(registerTool).toHaveBeenCalledTimes(
|
|
156
|
+
expect(registerTool).toHaveBeenCalledTimes(12);
|
|
110
157
|
});
|
|
111
158
|
|
|
112
159
|
it("executes against the LATEST deps, not the ones present at registration", async () => {
|
|
@@ -116,11 +163,11 @@ describe("useStudioAgentTools", () => {
|
|
|
116
163
|
|
|
117
164
|
let harness: ReturnType<typeof mountTools> | null = null;
|
|
118
165
|
await act(async () => {
|
|
119
|
-
harness = mountTools({ getSnapshot: () => snapshot({ currentTime: 1 }) });
|
|
166
|
+
harness = mountTools(deps({ getSnapshot: () => snapshot({ currentTime: 1 }) }));
|
|
120
167
|
});
|
|
121
168
|
|
|
122
169
|
await act(async () => {
|
|
123
|
-
harness?.rerenderWith({ getSnapshot: () => snapshot({ currentTime: 42 }) });
|
|
170
|
+
harness?.rerenderWith(deps({ getSnapshot: () => snapshot({ currentTime: 42 }) }));
|
|
124
171
|
});
|
|
125
172
|
|
|
126
173
|
const look = registered[0];
|
|
@@ -138,7 +185,7 @@ describe("useStudioAgentTools", () => {
|
|
|
138
185
|
const { registerTool } = installModelContext();
|
|
139
186
|
|
|
140
187
|
await act(async () => {
|
|
141
|
-
mountTools({ getSnapshot: () => snapshot() });
|
|
188
|
+
mountTools(deps({ getSnapshot: () => snapshot() }));
|
|
142
189
|
});
|
|
143
190
|
const signal = registerTool.mock.calls[0]?.[1]?.signal;
|
|
144
191
|
expect(signal?.aborted).toBe(false);
|
|
@@ -153,7 +200,7 @@ describe("useStudioAgentTools", () => {
|
|
|
153
200
|
removeModelContext();
|
|
154
201
|
|
|
155
202
|
await act(async () => {
|
|
156
|
-
mountTools({ getSnapshot: () => snapshot() });
|
|
203
|
+
mountTools(deps({ getSnapshot: () => snapshot() }));
|
|
157
204
|
});
|
|
158
205
|
|
|
159
206
|
// The assertion is that mounting did not throw; a browser without the
|
|
@@ -166,7 +213,7 @@ describe("useStudioAgentTools", () => {
|
|
|
166
213
|
const { registerTool } = installModelContext();
|
|
167
214
|
|
|
168
215
|
await act(async () => {
|
|
169
|
-
mountTools({ getSnapshot: () => snapshot() });
|
|
216
|
+
mountTools(deps({ getSnapshot: () => snapshot() }));
|
|
170
217
|
});
|
|
171
218
|
|
|
172
219
|
expect(registerTool).not.toHaveBeenCalled();
|
|
@@ -176,10 +223,10 @@ describe("useStudioAgentTools", () => {
|
|
|
176
223
|
const { registerTool } = installModelContext();
|
|
177
224
|
|
|
178
225
|
await act(async () => {
|
|
179
|
-
mountTools({ getSnapshot: () => snapshot() });
|
|
226
|
+
mountTools(deps({ getSnapshot: () => snapshot() }));
|
|
180
227
|
});
|
|
181
228
|
|
|
182
|
-
expect(registerTool).toHaveBeenCalledTimes(
|
|
229
|
+
expect(registerTool).toHaveBeenCalledTimes(12);
|
|
183
230
|
});
|
|
184
231
|
|
|
185
232
|
it("reports a non-abort registration failure through production telemetry", async () => {
|
|
@@ -187,7 +234,7 @@ describe("useStudioAgentTools", () => {
|
|
|
187
234
|
registerTool.mockRejectedValue(new DOMException("blocked", "NotAllowedError"));
|
|
188
235
|
|
|
189
236
|
await act(async () => {
|
|
190
|
-
mountTools({ getSnapshot: () => snapshot() });
|
|
237
|
+
mountTools(deps({ getSnapshot: () => snapshot() }));
|
|
191
238
|
});
|
|
192
239
|
|
|
193
240
|
expect(trackEvent).toHaveBeenCalledWith("webmcp_registration_failed", {
|
|
@@ -200,11 +247,13 @@ describe("useStudioAgentTools", () => {
|
|
|
200
247
|
const { registered } = installModelContext();
|
|
201
248
|
|
|
202
249
|
await act(async () => {
|
|
203
|
-
mountTools(
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
250
|
+
mountTools(
|
|
251
|
+
deps({
|
|
252
|
+
getSnapshot: () => {
|
|
253
|
+
throw new TypeError("handler signature moved");
|
|
254
|
+
},
|
|
255
|
+
}),
|
|
256
|
+
);
|
|
208
257
|
});
|
|
209
258
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
210
259
|
|
|
@@ -14,6 +14,71 @@ import {
|
|
|
14
14
|
type StudioLookInput,
|
|
15
15
|
type StudioLookSnapshot,
|
|
16
16
|
} from "./tools/lookTools";
|
|
17
|
+
import {
|
|
18
|
+
studioSeek,
|
|
19
|
+
studioSelect,
|
|
20
|
+
STUDIO_SEEK_DESCRIPTION,
|
|
21
|
+
STUDIO_SEEK_INPUT_SCHEMA,
|
|
22
|
+
STUDIO_SELECT_DESCRIPTION,
|
|
23
|
+
STUDIO_SELECT_INPUT_SCHEMA,
|
|
24
|
+
type SelectionToolDeps,
|
|
25
|
+
type StudioSeekResult,
|
|
26
|
+
type StudioSelectResult,
|
|
27
|
+
} from "./tools/selectionTools";
|
|
28
|
+
import {
|
|
29
|
+
studioFrame,
|
|
30
|
+
STUDIO_FRAME_DESCRIPTION,
|
|
31
|
+
STUDIO_FRAME_INPUT_SCHEMA,
|
|
32
|
+
type FrameToolDeps,
|
|
33
|
+
type StudioFrameInput,
|
|
34
|
+
type StudioFrameResult,
|
|
35
|
+
} from "./tools/frameTools";
|
|
36
|
+
import {
|
|
37
|
+
studioInspect,
|
|
38
|
+
STUDIO_INSPECT_DESCRIPTION,
|
|
39
|
+
STUDIO_INSPECT_INPUT_SCHEMA,
|
|
40
|
+
type InspectToolDeps,
|
|
41
|
+
type StudioInspectInput,
|
|
42
|
+
type StudioInspectResult,
|
|
43
|
+
} from "./tools/inspectTools";
|
|
44
|
+
import {
|
|
45
|
+
studioSetStyle,
|
|
46
|
+
studioSetText,
|
|
47
|
+
STUDIO_SET_STYLE_DESCRIPTION,
|
|
48
|
+
STUDIO_SET_STYLE_INPUT_SCHEMA,
|
|
49
|
+
STUDIO_SET_TEXT_DESCRIPTION,
|
|
50
|
+
STUDIO_SET_TEXT_INPUT_SCHEMA,
|
|
51
|
+
type ContentToolDeps,
|
|
52
|
+
type StudioSetStyleResult,
|
|
53
|
+
type StudioSetTextResult,
|
|
54
|
+
} from "./tools/contentTools";
|
|
55
|
+
import {
|
|
56
|
+
studioTransform,
|
|
57
|
+
STUDIO_TRANSFORM_DESCRIPTION,
|
|
58
|
+
STUDIO_TRANSFORM_INPUT_SCHEMA,
|
|
59
|
+
type StudioTransformInput,
|
|
60
|
+
type StudioTransformResult,
|
|
61
|
+
type TransformToolDeps,
|
|
62
|
+
} from "./tools/transformTools";
|
|
63
|
+
import {
|
|
64
|
+
studioAddAnimation,
|
|
65
|
+
studioAddKeyframe,
|
|
66
|
+
studioDeleteAnimation,
|
|
67
|
+
studioUpdateAnimation,
|
|
68
|
+
STUDIO_ADD_ANIMATION_DESCRIPTION,
|
|
69
|
+
STUDIO_ADD_ANIMATION_INPUT_SCHEMA,
|
|
70
|
+
STUDIO_ADD_KEYFRAME_DESCRIPTION,
|
|
71
|
+
STUDIO_ADD_KEYFRAME_INPUT_SCHEMA,
|
|
72
|
+
STUDIO_DELETE_ANIMATION_DESCRIPTION,
|
|
73
|
+
STUDIO_DELETE_ANIMATION_INPUT_SCHEMA,
|
|
74
|
+
STUDIO_UPDATE_ANIMATION_DESCRIPTION,
|
|
75
|
+
STUDIO_UPDATE_ANIMATION_INPUT_SCHEMA,
|
|
76
|
+
type AnimationToolDeps,
|
|
77
|
+
type StudioAddAnimationResult,
|
|
78
|
+
type StudioAddKeyframeResult,
|
|
79
|
+
type StudioDeleteAnimationResult,
|
|
80
|
+
type StudioUpdateAnimationResult,
|
|
81
|
+
} from "./tools/animationTools";
|
|
17
82
|
|
|
18
83
|
const log = makeStudioDebugLogger("webmcp");
|
|
19
84
|
|
|
@@ -27,7 +92,14 @@ function reportRegistration(report: ToolRegistrationReport, native: boolean): vo
|
|
|
27
92
|
}
|
|
28
93
|
}
|
|
29
94
|
|
|
30
|
-
export interface StudioAgentToolsDeps
|
|
95
|
+
export interface StudioAgentToolsDeps
|
|
96
|
+
extends
|
|
97
|
+
SelectionToolDeps,
|
|
98
|
+
FrameToolDeps,
|
|
99
|
+
InspectToolDeps,
|
|
100
|
+
ContentToolDeps,
|
|
101
|
+
TransformToolDeps,
|
|
102
|
+
AnimationToolDeps {
|
|
31
103
|
/** Read Studio's current state. Called per tool invocation, never cached. */
|
|
32
104
|
getSnapshot: () => StudioLookSnapshot;
|
|
33
105
|
}
|
|
@@ -57,9 +129,131 @@ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }):
|
|
|
57
129
|
buildStudioLook(depsRef.current.getSnapshot(), input as StudioLookInput),
|
|
58
130
|
),
|
|
59
131
|
},
|
|
132
|
+
{
|
|
133
|
+
name: "studio_select",
|
|
134
|
+
title: "Select an element",
|
|
135
|
+
description: STUDIO_SELECT_DESCRIPTION,
|
|
136
|
+
inputSchema: STUDIO_SELECT_INPUT_SCHEMA,
|
|
137
|
+
annotations: { readOnlyHint: false, untrustedContentHint: true },
|
|
138
|
+
execute: (input): Promise<ToolResult<StudioSelectResult>> =>
|
|
139
|
+
runToolBody("studio_select", () =>
|
|
140
|
+
studioSelect(depsRef.current, readStringInput(input, "handle")),
|
|
141
|
+
),
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: "studio_seek",
|
|
145
|
+
title: "Move the playhead",
|
|
146
|
+
description: STUDIO_SEEK_DESCRIPTION,
|
|
147
|
+
inputSchema: STUDIO_SEEK_INPUT_SCHEMA,
|
|
148
|
+
annotations: { readOnlyHint: false },
|
|
149
|
+
execute: (input): Promise<ToolResult<StudioSeekResult>> =>
|
|
150
|
+
runToolBody("studio_seek", async () =>
|
|
151
|
+
studioSeek(depsRef.current, readNumberInput(input, "time")),
|
|
152
|
+
),
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "studio_frame",
|
|
156
|
+
title: "See the composition",
|
|
157
|
+
description: STUDIO_FRAME_DESCRIPTION,
|
|
158
|
+
inputSchema: STUDIO_FRAME_INPUT_SCHEMA,
|
|
159
|
+
annotations: { readOnlyHint: true, untrustedContentHint: true },
|
|
160
|
+
execute: (input): Promise<ToolResult<StudioFrameResult>> =>
|
|
161
|
+
runToolBody("studio_frame", () => studioFrame(depsRef.current, input as StudioFrameInput)),
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: "studio_inspect",
|
|
165
|
+
title: "Inspect one element",
|
|
166
|
+
description: STUDIO_INSPECT_DESCRIPTION,
|
|
167
|
+
inputSchema: STUDIO_INSPECT_INPUT_SCHEMA,
|
|
168
|
+
annotations: { readOnlyHint: true, untrustedContentHint: true },
|
|
169
|
+
execute: (input): Promise<ToolResult<StudioInspectResult>> =>
|
|
170
|
+
runToolBody("studio_inspect", () =>
|
|
171
|
+
studioInspect(depsRef.current, input as StudioInspectInput),
|
|
172
|
+
),
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "studio_set_text",
|
|
176
|
+
title: "Set an element's text",
|
|
177
|
+
description: STUDIO_SET_TEXT_DESCRIPTION,
|
|
178
|
+
inputSchema: STUDIO_SET_TEXT_INPUT_SCHEMA,
|
|
179
|
+
annotations: { readOnlyHint: false, untrustedContentHint: true },
|
|
180
|
+
execute: (input): Promise<ToolResult<StudioSetTextResult>> =>
|
|
181
|
+
runToolBody("studio_set_text", () => studioSetText(depsRef.current, input)),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: "studio_set_style",
|
|
185
|
+
title: "Set an element's styles",
|
|
186
|
+
description: STUDIO_SET_STYLE_DESCRIPTION,
|
|
187
|
+
inputSchema: STUDIO_SET_STYLE_INPUT_SCHEMA,
|
|
188
|
+
annotations: { readOnlyHint: false },
|
|
189
|
+
execute: (input): Promise<ToolResult<StudioSetStyleResult>> =>
|
|
190
|
+
runToolBody("studio_set_style", () => studioSetStyle(depsRef.current, input)),
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
name: "studio_transform",
|
|
194
|
+
title: "Move, resize or rotate",
|
|
195
|
+
description: STUDIO_TRANSFORM_DESCRIPTION,
|
|
196
|
+
inputSchema: STUDIO_TRANSFORM_INPUT_SCHEMA,
|
|
197
|
+
annotations: { readOnlyHint: false },
|
|
198
|
+
execute: (input): Promise<ToolResult<StudioTransformResult>> =>
|
|
199
|
+
runToolBody("studio_transform", () =>
|
|
200
|
+
studioTransform(depsRef.current, input as StudioTransformInput),
|
|
201
|
+
),
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: "studio_add_animation",
|
|
205
|
+
title: "Add an animation",
|
|
206
|
+
description: STUDIO_ADD_ANIMATION_DESCRIPTION,
|
|
207
|
+
inputSchema: STUDIO_ADD_ANIMATION_INPUT_SCHEMA,
|
|
208
|
+
annotations: { readOnlyHint: false },
|
|
209
|
+
execute: (input): Promise<ToolResult<StudioAddAnimationResult>> =>
|
|
210
|
+
runToolBody("studio_add_animation", () => studioAddAnimation(depsRef.current, input)),
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
name: "studio_update_animation",
|
|
214
|
+
title: "Change an animation",
|
|
215
|
+
description: STUDIO_UPDATE_ANIMATION_DESCRIPTION,
|
|
216
|
+
inputSchema: STUDIO_UPDATE_ANIMATION_INPUT_SCHEMA,
|
|
217
|
+
annotations: { readOnlyHint: false },
|
|
218
|
+
execute: (input): Promise<ToolResult<StudioUpdateAnimationResult>> =>
|
|
219
|
+
runToolBody("studio_update_animation", () => studioUpdateAnimation(depsRef.current, input)),
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
name: "studio_add_keyframe",
|
|
223
|
+
title: "Add a keyframe",
|
|
224
|
+
description: STUDIO_ADD_KEYFRAME_DESCRIPTION,
|
|
225
|
+
inputSchema: STUDIO_ADD_KEYFRAME_INPUT_SCHEMA,
|
|
226
|
+
annotations: { readOnlyHint: false },
|
|
227
|
+
execute: (input): Promise<ToolResult<StudioAddKeyframeResult>> =>
|
|
228
|
+
runToolBody("studio_add_keyframe", () => studioAddKeyframe(depsRef.current, input)),
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
name: "studio_delete_animation",
|
|
232
|
+
title: "Remove an animation",
|
|
233
|
+
description: STUDIO_DELETE_ANIMATION_DESCRIPTION,
|
|
234
|
+
inputSchema: STUDIO_DELETE_ANIMATION_INPUT_SCHEMA,
|
|
235
|
+
annotations: { readOnlyHint: false },
|
|
236
|
+
execute: (input): Promise<ToolResult<StudioDeleteAnimationResult>> =>
|
|
237
|
+
runToolBody("studio_delete_animation", () => studioDeleteAnimation(depsRef.current, input)),
|
|
238
|
+
},
|
|
60
239
|
];
|
|
61
240
|
}
|
|
62
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Nothing in the platform validates the input object against `inputSchema`, so
|
|
244
|
+
* a tool receives whatever the agent sent. These read a field without asserting
|
|
245
|
+
* its type; the tools themselves reject what they cannot use.
|
|
246
|
+
*/
|
|
247
|
+
function readStringInput(input: object, key: string): string {
|
|
248
|
+
const value = Reflect.get(input, key);
|
|
249
|
+
return typeof value === "string" ? value : "";
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function readNumberInput(input: object, key: string): number {
|
|
253
|
+
const value = Reflect.get(input, key);
|
|
254
|
+
return typeof value === "number" ? value : Number.NaN;
|
|
255
|
+
}
|
|
256
|
+
|
|
63
257
|
/**
|
|
64
258
|
* Register Studio's tools with the browser, exactly once per mount.
|
|
65
259
|
*
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fixtures for the WebMCP tool tests.
|
|
3
|
+
*
|
|
4
|
+
* Not a `.test` file so vitest does not collect it as a suite. Mirrors the
|
|
5
|
+
* existing `hooks/domSelectionTestHarness.ts` convention.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { expect } from "vitest";
|
|
9
|
+
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
10
|
+
import type { ToolFailure, ToolResult } from "./toolResult";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* An element inside a real iframe, which is where Studio's chrome expects to
|
|
14
|
+
* find preview elements. The separate realm matters: a preview element is not
|
|
15
|
+
* an instance of Studio's own `HTMLElement`.
|
|
16
|
+
*/
|
|
17
|
+
export function previewDoc(html: string): Document {
|
|
18
|
+
const iframe = document.createElement("iframe");
|
|
19
|
+
document.body.append(iframe);
|
|
20
|
+
const doc = iframe.contentDocument;
|
|
21
|
+
if (!doc) throw new Error("expected iframe document");
|
|
22
|
+
doc.body.innerHTML = html;
|
|
23
|
+
return doc;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function previewElement(html: string, id: string): HTMLElement {
|
|
27
|
+
const doc = previewDoc(html);
|
|
28
|
+
const element = doc.getElementById(id);
|
|
29
|
+
const HTMLElementCtor = doc.defaultView?.HTMLElement;
|
|
30
|
+
if (!HTMLElementCtor || !(element instanceof HTMLElementCtor)) {
|
|
31
|
+
throw new Error(`expected preview element #${id}`);
|
|
32
|
+
}
|
|
33
|
+
return element;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function selectionFor(
|
|
37
|
+
element: HTMLElement,
|
|
38
|
+
overrides: Partial<DomEditSelection> = {},
|
|
39
|
+
): DomEditSelection {
|
|
40
|
+
return {
|
|
41
|
+
id: element.id || undefined,
|
|
42
|
+
hfId: element.getAttribute("data-hf-id") ?? undefined,
|
|
43
|
+
element,
|
|
44
|
+
label: "Headline",
|
|
45
|
+
tagName: element.tagName.toLowerCase(),
|
|
46
|
+
sourceFile: "index.html",
|
|
47
|
+
compositionPath: "index.html",
|
|
48
|
+
isCompositionHost: false,
|
|
49
|
+
isInsideLockedComposition: false,
|
|
50
|
+
boundingBox: { x: 40, y: 12, width: 880, height: 96 },
|
|
51
|
+
textContent: element.textContent,
|
|
52
|
+
dataAttributes: { "data-role": "title" },
|
|
53
|
+
inlineStyles: { color: "red" },
|
|
54
|
+
computedStyles: { "font-size": "42.7px", color: "rgb(255, 0, 0)" },
|
|
55
|
+
textFields: [
|
|
56
|
+
{
|
|
57
|
+
key: "self",
|
|
58
|
+
label: "Text",
|
|
59
|
+
value: element.textContent ?? "",
|
|
60
|
+
tagName: element.tagName.toLowerCase(),
|
|
61
|
+
attributes: [],
|
|
62
|
+
inlineStyles: {},
|
|
63
|
+
computedStyles: {},
|
|
64
|
+
source: "self",
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
capabilities: {
|
|
68
|
+
canSelect: true,
|
|
69
|
+
canEditStyles: true,
|
|
70
|
+
canCrop: true,
|
|
71
|
+
canMove: true,
|
|
72
|
+
canResize: true,
|
|
73
|
+
canApplyManualOffset: true,
|
|
74
|
+
canApplyManualSize: true,
|
|
75
|
+
canApplyManualRotation: true,
|
|
76
|
+
},
|
|
77
|
+
...overrides,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function expectOk<T>(result: ToolResult<T>): { ok: true } & T {
|
|
82
|
+
expect(result.ok, `expected ok, got ${JSON.stringify(result)}`).toBe(true);
|
|
83
|
+
if (!result.ok) throw new Error("unreachable");
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function expectFailure(result: ToolResult<unknown>): ToolFailure {
|
|
88
|
+
expect(result.ok, `expected failure, got ${JSON.stringify(result)}`).toBe(false);
|
|
89
|
+
if (result.ok) throw new Error("unreachable");
|
|
90
|
+
return result;
|
|
91
|
+
}
|