@hyperframes/studio 0.8.19 → 0.8.21

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.
Files changed (45) hide show
  1. package/dist/assets/hyperframes-player-CIXNA_bj.js +460 -0
  2. package/dist/assets/{index-Dd0fsIIL.js → index-44gfDvkh.js} +1 -1
  3. package/dist/assets/{index-DxccOrkZ.js → index-9VfE7bZe.js} +1 -1
  4. package/dist/assets/{index-Bjd2hioj.js → index-CgGY8dAv.js} +164 -164
  5. package/dist/index.d.ts +28 -0
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1206 -234
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +2 -0
  11. package/src/contexts/StudioContext.tsx +10 -0
  12. package/src/hooks/useEditorSave.test.tsx +81 -3
  13. package/src/hooks/useEditorSave.ts +31 -6
  14. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +48 -16
  15. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -2
  16. package/src/hooks/useStudioContextValue.ts +9 -0
  17. package/src/player/hooks/timelineSyncHydration.test.ts +96 -0
  18. package/src/player/hooks/timelineSyncHydration.ts +47 -1
  19. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  20. package/src/player/hooks/useExpandedTimelineElements.ts +52 -29
  21. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -0
  22. package/src/player/store/playerStore.ts +17 -2
  23. package/src/player/store/timelineElement.ts +21 -0
  24. package/src/utils/studioFileVersion.test.ts +37 -1
  25. package/src/utils/studioFileVersion.ts +12 -2
  26. package/src/utils/studioSaveDiagnostics.test.ts +29 -0
  27. package/src/utils/studioSaveDiagnostics.ts +4 -0
  28. package/src/webmcp/StudioAgentTools.tsx +105 -10
  29. package/src/webmcp/toolResult.ts +1 -1
  30. package/src/webmcp/tools/animationTools.test.ts +244 -0
  31. package/src/webmcp/tools/animationTools.ts +276 -0
  32. package/src/webmcp/tools/contentTools.test.ts +275 -0
  33. package/src/webmcp/tools/contentTools.ts +217 -0
  34. package/src/webmcp/tools/frameTools.test.ts +136 -0
  35. package/src/webmcp/tools/frameTools.ts +133 -0
  36. package/src/webmcp/tools/inspectTools.test.ts +197 -0
  37. package/src/webmcp/tools/inspectTools.ts +208 -0
  38. package/src/webmcp/tools/selectionTools.test.ts +164 -0
  39. package/src/webmcp/tools/selectionTools.ts +154 -0
  40. package/src/webmcp/tools/transformTools.test.ts +179 -0
  41. package/src/webmcp/tools/transformTools.ts +205 -0
  42. package/src/webmcp/useStudioAgentTools.test.tsx +71 -22
  43. package/src/webmcp/useStudioAgentTools.ts +195 -1
  44. package/src/webmcp/webmcpTestUtils.ts +91 -0
  45. package/dist/assets/hyperframes-player-BA-QO9CQ.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(deps: StudioAgentToolsDeps) {
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={deps} />);
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(["studio_look"]);
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(1);
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(1);
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(1);
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
- getSnapshot: () => {
205
- throw new TypeError("handler signature moved");
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
+ }