@hyperframes/studio 0.6.110 → 0.6.111

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 (58) hide show
  1. package/dist/assets/index-B7S86vK1.js +370 -0
  2. package/dist/assets/index-DP8pPIk2.css +1 -0
  3. package/dist/assets/{index-CfiyaR7G.js → index-_IV-vm9l.js} +1 -1
  4. package/dist/assets/{index-CXy_mWnP.js → index-x0c2-zQN.js} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +8 -5
  7. package/src/App.tsx +133 -141
  8. package/src/components/StudioHeader.tsx +44 -0
  9. package/src/components/StudioOverlays.tsx +70 -0
  10. package/src/components/editor/SourceEditor.tsx +19 -16
  11. package/src/components/editor/manualEditingAvailability.ts +25 -7
  12. package/src/components/storyboard/FramePoster.tsx +56 -0
  13. package/src/components/storyboard/StoryboardDirection.tsx +45 -0
  14. package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
  15. package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
  16. package/src/components/storyboard/StoryboardGrid.tsx +33 -0
  17. package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
  18. package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
  19. package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
  20. package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
  21. package/src/components/storyboard/StoryboardView.tsx +78 -0
  22. package/src/components/storyboard/frameStatus.ts +36 -0
  23. package/src/contexts/ViewModeContext.tsx +98 -0
  24. package/src/hooks/gsapScriptCommitTypes.ts +4 -7
  25. package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
  26. package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
  27. package/src/hooks/timelineEditingHelpers.ts +2 -0
  28. package/src/hooks/useAppHotkeys.ts +20 -0
  29. package/src/hooks/useDomEditCommits.ts +43 -14
  30. package/src/hooks/useDomEditSession.test.ts +41 -0
  31. package/src/hooks/useDomEditSession.ts +38 -6
  32. package/src/hooks/useDomGeometryCommits.ts +5 -9
  33. package/src/hooks/useElementLifecycleOps.ts +30 -0
  34. package/src/hooks/useGsapAnimationOps.ts +78 -51
  35. package/src/hooks/useGsapKeyframeOps.ts +127 -43
  36. package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
  37. package/src/hooks/useGsapPropertyDebounce.ts +201 -23
  38. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
  39. package/src/hooks/useGsapScriptCommits.ts +95 -40
  40. package/src/hooks/useSdkSession.test.ts +12 -0
  41. package/src/hooks/useSdkSession.ts +91 -25
  42. package/src/hooks/useStoryboard.ts +80 -0
  43. package/src/hooks/useTimelineEditing.ts +163 -68
  44. package/src/utils/gsapSoftReload.ts +14 -0
  45. package/src/utils/sdkCutover.gate.test.ts +61 -0
  46. package/src/utils/sdkCutover.test.ts +839 -0
  47. package/src/utils/sdkCutover.ts +465 -0
  48. package/src/utils/sdkOpMapping.ts +43 -0
  49. package/src/utils/sdkResolverShadow.test.ts +366 -0
  50. package/src/utils/sdkResolverShadow.ts +313 -0
  51. package/dist/assets/index-1C8oFiPi.css +0 -1
  52. package/dist/assets/index-D-3sGz65.js +0 -296
  53. package/src/utils/sdkShadow.test.ts +0 -606
  54. package/src/utils/sdkShadow.ts +0 -517
  55. package/src/utils/sdkShadowGsapFidelity.ts +0 -296
  56. package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
  57. package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
  58. package/src/utils/sdkShadowNumeric.ts +0 -11
@@ -1,606 +0,0 @@
1
- import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
- import {
3
- patchOpsToSdkEditOps,
4
- runShadowDelete,
5
- runShadowTiming,
6
- runShadowGsapTween,
7
- runShadowGsapFidelity,
8
- gsapFidelityMismatches,
9
- resolveGsapFidelityArgs,
10
- SdkShadowMismatch,
11
- } from "./sdkShadow";
12
- import type { ShadowGsapOp } from "./sdkShadow";
13
- import { makeSelectorResolver } from "./sdkShadowGsapFidelity";
14
- import type { PatchOperation } from "./sourcePatcher";
15
- import { openComposition } from "@hyperframes/sdk";
16
- import { Window } from "happy-dom";
17
-
18
- // Capture sdk_shadow_dispatch telemetry for the non-PatchOperation runners.
19
- const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
20
- vi.mock("./studioTelemetry", () => ({
21
- trackStudioEvent: (event: string, props: Record<string, unknown>) =>
22
- trackedEvents.push({ event, props }),
23
- }));
24
- beforeEach(() => {
25
- trackedEvents.length = 0;
26
- });
27
- const lastShadow = () =>
28
- trackedEvents.filter((e) => e.event === "sdk_shadow_dispatch").at(-1)?.props;
29
-
30
- const BASE_HTML = /* html */ `<!DOCTYPE html>
31
- <html><body>
32
- <div data-hf-id="hf-box" style="color: red; width: 100px;" data-name="box">Hello</div>
33
- </body></html>`;
34
-
35
- describe("patchOpsToSdkEditOps", () => {
36
- it("maps inline-style ops to a single setStyle EditOp", () => {
37
- const ops: PatchOperation[] = [
38
- { type: "inline-style", property: "color", value: "#00f" },
39
- { type: "inline-style", property: "opacity", value: "0.5" },
40
- ];
41
- const result = patchOpsToSdkEditOps("hf-box", ops);
42
- expect(result).toHaveLength(1);
43
- expect(result[0]).toEqual({
44
- type: "setStyle",
45
- target: "hf-box",
46
- styles: { color: "#00f", opacity: "0.5" },
47
- });
48
- });
49
-
50
- it("maps text-content op to setText EditOp", () => {
51
- const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "World" }];
52
- const result = patchOpsToSdkEditOps("hf-box", ops);
53
- expect(result).toHaveLength(1);
54
- expect(result[0]).toEqual({ type: "setText", target: "hf-box", value: "World" });
55
- });
56
-
57
- it("maps attribute op to setAttribute with data- prefix", () => {
58
- const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }];
59
- const result = patchOpsToSdkEditOps("hf-box", ops);
60
- expect(result).toHaveLength(1);
61
- expect(result[0]).toEqual({
62
- type: "setAttribute",
63
- target: "hf-box",
64
- name: "data-name",
65
- value: "hero",
66
- });
67
- });
68
-
69
- it("maps html-attribute op to setAttribute without prefix", () => {
70
- const ops: PatchOperation[] = [
71
- { type: "html-attribute", property: "contenteditable", value: "true" },
72
- ];
73
- const result = patchOpsToSdkEditOps("hf-box", ops);
74
- expect(result).toHaveLength(1);
75
- expect(result[0]).toEqual({
76
- type: "setAttribute",
77
- target: "hf-box",
78
- name: "contenteditable",
79
- value: "true",
80
- });
81
- });
82
-
83
- it("handles null value for attribute removal", () => {
84
- const ops: PatchOperation[] = [{ type: "html-attribute", property: "hidden", value: null }];
85
- const result = patchOpsToSdkEditOps("hf-box", ops);
86
- expect(result[0]).toEqual({
87
- type: "setAttribute",
88
- target: "hf-box",
89
- name: "hidden",
90
- value: null,
91
- });
92
- });
93
-
94
- it("returns empty array for unknown op types", () => {
95
- const ops = [{ type: "unknown-op", property: "x", value: "y" }] as unknown as PatchOperation[];
96
- expect(patchOpsToSdkEditOps("hf-box", ops)).toHaveLength(0);
97
- });
98
- });
99
-
100
- describe("sdkShadowDispatch (integration)", () => {
101
- it("applies ops and returns no mismatches when SDK matches expected values", async () => {
102
- const { sdkShadowDispatch } = await import("./sdkShadow");
103
- const session = await openComposition(BASE_HTML);
104
-
105
- const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
106
- const result = sdkShadowDispatch(session, "hf-box", ops);
107
-
108
- expect(result.dispatched).toBe(true);
109
- expect(result.mismatches).toHaveLength(0);
110
- expect(session.getElement("hf-box")?.inlineStyles.color).toBe("#00f");
111
- });
112
-
113
- // fallow-ignore-next-line code-duplication
114
- it("does NOT false-mismatch a hyphenated style property (kebab op vs camelCase snapshot)", async () => {
115
- const { sdkShadowDispatch } = await import("./sdkShadow");
116
- const session = await openComposition(BASE_HTML);
117
-
118
- const ops: PatchOperation[] = [
119
- { type: "inline-style", property: "background-color", value: "rgb(255, 79, 88)" },
120
- ];
121
- const result = sdkShadowDispatch(session, "hf-box", ops);
122
-
123
- expect(result.dispatched).toBe(true);
124
- expect(result.mismatches).toHaveLength(0); // was 1 before the kebab→camel read-back fix
125
- expect(session.getElement("hf-box")?.inlineStyles.backgroundColor).toBe("rgb(255, 79, 88)");
126
- });
127
-
128
- it("returns dispatched:false when hfId not found in session", async () => {
129
- const { sdkShadowDispatch } = await import("./sdkShadow");
130
- const session = await openComposition(BASE_HTML);
131
-
132
- const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
133
- const result = sdkShadowDispatch(session, "hf-missing", ops);
134
-
135
- expect(result.dispatched).toBe(false);
136
- expect(result.mismatches).toHaveLength(1);
137
- expect(result.mismatches[0]).toMatchObject<SdkShadowMismatch>({
138
- kind: "element_not_found",
139
- hfId: "hf-missing",
140
- });
141
- });
142
-
143
- it("applies text op and reads back via session.getElement", async () => {
144
- const { sdkShadowDispatch } = await import("./sdkShadow");
145
- const session = await openComposition(BASE_HTML);
146
-
147
- const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "Updated" }];
148
- sdkShadowDispatch(session, "hf-box", ops);
149
-
150
- expect(session.getElement("hf-box")?.text).toBe("Updated");
151
- });
152
-
153
- // Fix 2: text parity normalization. snapshot.text is trimmed by the SDK, so a
154
- // trailing-whitespace-only difference between the op value and the snapshot must
155
- // not flag.
156
- it("does NOT false-mismatch trailing-whitespace-only text difference", async () => {
157
- const { sdkShadowDispatch } = await import("./sdkShadow");
158
- const session = await openComposition(BASE_HTML);
159
-
160
- const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "World " }];
161
- const result = sdkShadowDispatch(session, "hf-box", ops);
162
-
163
- expect(result.dispatched).toBe(true);
164
- expect(result.mismatches).toHaveLength(0); // trimmed both sides
165
- });
166
-
167
- // Empty-string op value vs an absent (null) snapshot text must collapse to equal
168
- // — both mean "no text content".
169
- it("treats empty-string text op and null snapshot text as equal", async () => {
170
- const { sdkShadowDispatch } = await import("./sdkShadow");
171
- const EMPTY_HTML = /* html */ `<!DOCTYPE html>
172
- <html><body><img data-hf-id="hf-img" src="x.png" /></body></html>`;
173
- const session = await openComposition(EMPTY_HTML);
174
-
175
- const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "" }];
176
- const result = sdkShadowDispatch(session, "hf-img", ops);
177
-
178
- expect(result.dispatched).toBe(true);
179
- expect(result.mismatches).toHaveLength(0); // "" vs null → both null
180
- });
181
-
182
- // Fix 3 verdict (REAL DIVERGENCE, not a readback artifact): the inline-style
183
- // read-back already reads only the AUTHORED style attribute (getElementStyles →
184
- // parseStyleAttr), never computed styles. The transform-origin divergence
185
- // (expected null actual "center center") was a genuine SDK bug — setStyle
186
- // removal of a HYPHENATED property silently no-opped because setElementStyles
187
- // deleted the kebab key while the style map is keyed camelCase. Now FIXED in
188
- // the SDK (model.ts setElementStyles normalizes the key via toCamel), so the
189
- // shadow sees parity: removal applies and there is no mismatch.
190
- it("reports clean removal of a hyphenated style (SDK setStyle kebab/camel fix)", async () => {
191
- const { sdkShadowDispatch } = await import("./sdkShadow");
192
- const TO_HTML = /* html */ `<!DOCTYPE html>
193
- <html><body><div data-hf-id="hf-box" style="transform-origin: center center">x</div></body></html>`;
194
- const session = await openComposition(TO_HTML);
195
-
196
- const ops: PatchOperation[] = [
197
- { type: "inline-style", property: "transform-origin", value: null },
198
- ];
199
- const result = sdkShadowDispatch(session, "hf-box", ops);
200
-
201
- // The SDK now removes the hyphenated property, so the shadow read-back agrees.
202
- expect(result.dispatched).toBe(true);
203
- expect(result.mismatches).toHaveLength(0);
204
- });
205
-
206
- it("applies attribute op and reads back via session.getElement", async () => {
207
- const { sdkShadowDispatch } = await import("./sdkShadow");
208
- const session = await openComposition(BASE_HTML);
209
-
210
- const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }];
211
- sdkShadowDispatch(session, "hf-box", ops);
212
-
213
- expect(session.getElement("hf-box")?.attributes["data-name"]).toBe("hero");
214
- });
215
-
216
- // fallow-ignore-next-line code-duplication
217
- it("does NOT false-mismatch studio-internal data-hf-* marker attributes", async () => {
218
- const { sdkShadowDispatch } = await import("./sdkShadow");
219
- const session = await openComposition(BASE_HTML);
220
-
221
- // path-offset drags emit these already-data-prefixed, SDK-excluded markers.
222
- const ops: PatchOperation[] = [
223
- { type: "attribute", property: "data-hf-studio-path-offset", value: "true" },
224
- ];
225
- const result = sdkShadowDispatch(session, "hf-box", ops);
226
-
227
- expect(result.dispatched).toBe(true);
228
- expect(result.mismatches).toHaveLength(0); // filtered, not double-prefixed + flagged
229
- });
230
-
231
- it("returns dispatch_error when dispatch throws — does not propagate", async () => {
232
- const { sdkShadowDispatch } = await import("./sdkShadow");
233
- const session = await openComposition(BASE_HTML);
234
- // Poison dispatch so it throws on any call
235
- session.dispatch = () => {
236
- throw new Error("sdk internal error");
237
- };
238
-
239
- const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "red" }];
240
- let result: ReturnType<typeof sdkShadowDispatch> | undefined;
241
- expect(() => {
242
- result = sdkShadowDispatch(session, "hf-box", ops);
243
- }).not.toThrow();
244
-
245
- expect(result!.dispatched).toBe(false);
246
- expect(result!.mismatches).toHaveLength(1);
247
- expect(result!.mismatches[0]).toMatchObject<SdkShadowMismatch>({
248
- kind: "dispatch_error",
249
- hfId: "hf-box",
250
- error: expect.stringContaining("sdk internal error"),
251
- });
252
- });
253
- });
254
-
255
- const TIMING_HTML = /* html */ `<!DOCTYPE html>
256
- <html><body>
257
- <div data-hf-id="hf-clip" data-start="0" data-duration="1" data-track="0">clip</div>
258
- </body></html>`;
259
-
260
- const GSAP_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
261
- <div data-hf-id="hf-box" style="opacity:0"></div>
262
- <script>var tl = gsap.timeline({ paused: true });
263
- tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
264
- window.__timelines["t"] = tl;</script>
265
- </div>`;
266
-
267
- const NO_TIMELINE_HTML = `<div data-hf-id="hf-stage" data-hf-root>
268
- <div data-hf-id="hf-box"></div>
269
- <script>gsap.defaults({ ease: "power1.out" });
270
- window.__timelines = {};</script>
271
- </div>`;
272
-
273
- describe("runShadowDelete", () => {
274
- it("removes the element from the SDK session and reports parity", async () => {
275
- const session = await openComposition(BASE_HTML);
276
- runShadowDelete(session, "hf-box");
277
- expect(session.getElement("hf-box")).toBeNull();
278
- expect(lastShadow()).toMatchObject({ op: "delete", dispatched: true, mismatchCount: 0 });
279
- });
280
-
281
- it("reports no_hf_id when selection has no hf-id", async () => {
282
- const session = await openComposition(BASE_HTML);
283
- runShadowDelete(session, null);
284
- expect(lastShadow()).toMatchObject({ op: "delete", dispatched: false, reason: "no_hf_id" });
285
- });
286
-
287
- it("reports cannot_dispatch when the element is not addressable", async () => {
288
- const session = await openComposition(BASE_HTML);
289
- runShadowDelete(session, "hf-missing");
290
- expect(lastShadow()).toMatchObject({
291
- op: "delete",
292
- dispatched: false,
293
- reason: "cannot_dispatch",
294
- });
295
- });
296
-
297
- // Fix 4 verdict (REAL SDK id-resolution divergence, NOT a readback bug): when a
298
- // bare hf-id collides between a sub-composition element (scopedId
299
- // "hf-host/hf-dup") and a top-level sibling (scopedId "hf-dup"), removeElement
300
- // resolves the bare id via resolveScoped → querySelector (document-order-first,
301
- // removes the INNER instance), but getElement prefers the canonical top-level
302
- // match (scopedId === id) which SURVIVES. The shadow then correctly reports
303
- // expected "removed" / actual "present". The readback here is correct (it checks
304
- // the same id it dispatched); the fix belongs in the SDK's id resolution
305
- // (resolveScoped vs getElement agreement), not in this file.
306
- const DUP_ID_HTML = /* html */ `<!DOCTYPE html><html><body>
307
- <div data-hf-id="hf-root" data-hf-root>
308
- <div data-hf-id="hf-host" data-composition-file="sub.html">
309
- <div data-hf-id="hf-dup">inner</div>
310
- </div>
311
- <div data-hf-id="hf-dup">outer</div>
312
- </div>
313
- </body></html>`;
314
-
315
- it("reports clean delete for a duplicate bare id (SDK resolves removeElement/getElement to the same instance)", async () => {
316
- const session = await openComposition(DUP_ID_HTML);
317
- runShadowDelete(session, "hf-dup");
318
- // SDK fix (agree removeElement/getElement on duplicate bare ids): both now
319
- // resolve a bare id to the canonical (top-level) instance, so removeElement
320
- // drops exactly the element the readback checks → no mismatch. (Previously
321
- // removeElement dropped the inner instance while the top-level survived,
322
- // which this shadow correctly flagged; that divergence is now fixed.)
323
- expect(lastShadow()).toMatchObject({ op: "delete", dispatched: true, mismatchCount: 0 });
324
- });
325
- });
326
-
327
- describe("runShadowTiming", () => {
328
- it("applies timing and reports parity against the snapshot", async () => {
329
- const session = await openComposition(TIMING_HTML);
330
- runShadowTiming(session, "hf-clip", { start: 2, duration: 3, trackIndex: 1 });
331
- const el = session.getElement("hf-clip");
332
- expect(el?.start).toBe(2);
333
- expect(el?.duration).toBe(3);
334
- expect(el?.trackIndex).toBe(1);
335
- expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
336
- });
337
-
338
- // Fix 1: float-precision tolerance. The SDK computes durations arithmetically
339
- // (returning e.g. 3.0999999999999996); the server stores the rounded literal
340
- // (3.1). A relative epsilon must treat these as equal, while a real difference
341
- // still flags. A fake session returns the imprecise value on read-back.
342
- type FakeTiming = { start?: number; duration?: number; trackIndex?: number };
343
- function fakeTimingSession(readback: FakeTiming) {
344
- return {
345
- can: () => ({ ok: true }),
346
- batch: (fn: () => void) => fn(),
347
- dispatch: () => {},
348
- getElement: () => readback,
349
- } as unknown as Parameters<typeof runShadowTiming>[0];
350
- }
351
-
352
- it("does NOT flag float-precision duration drift (3.1 vs 3.0999999999999996)", () => {
353
- const session = fakeTimingSession({ duration: 3.0999999999999996 });
354
- runShadowTiming(session, "hf-clip", { duration: 3.1 });
355
- expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
356
- });
357
-
358
- it("does NOT flag float-precision start drift (21.36 vs 21.360000000000014)", () => {
359
- const session = fakeTimingSession({ start: 21.360000000000014 });
360
- runShadowTiming(session, "hf-clip", { start: 21.36 });
361
- expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
362
- });
363
-
364
- it("STILL flags a real duration difference (3.1 vs 3.5)", () => {
365
- const session = fakeTimingSession({ duration: 3.5 });
366
- runShadowTiming(session, "hf-clip", { duration: 3.1 });
367
- expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 1 });
368
- });
369
- });
370
-
371
- describe("runShadowGsapTween", () => {
372
- it("add reports success and the new tween lands on the target's animationIds", async () => {
373
- const session = await openComposition(GSAP_HTML);
374
- const before = session.getElement("hf-box")?.animationIds.length ?? 0;
375
- runShadowGsapTween(session, {
376
- kind: "add",
377
- target: "hf-box",
378
- tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
379
- });
380
- expect(session.getElement("hf-box")!.animationIds.length).toBe(before + 1);
381
- expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 });
382
- });
383
-
384
- it("remove drops the tween from animationIds and reports parity", async () => {
385
- const session = await openComposition(GSAP_HTML);
386
- const animationId = session.getElement("hf-box")?.animationIds[0];
387
- expect(animationId).toBeDefined();
388
- runShadowGsapTween(session, { kind: "remove", animationId: animationId! });
389
- expect(session.getElement("hf-box")?.animationIds ?? []).not.toContain(animationId);
390
- expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 });
391
- });
392
-
393
- it("reports cannot_dispatch (E_NO_GSAP_TIMELINE) when the script has no timeline", async () => {
394
- const session = await openComposition(NO_TIMELINE_HTML);
395
- runShadowGsapTween(session, {
396
- kind: "add",
397
- target: "hf-box",
398
- tween: { method: "to", properties: { x: 100 } },
399
- });
400
- expect(lastShadow()).toMatchObject({
401
- op: "gsap",
402
- dispatched: false,
403
- reason: "cannot_dispatch",
404
- code: "E_NO_GSAP_TIMELINE",
405
- });
406
- });
407
- });
408
-
409
- const SCRIPT_A = `var tl = gsap.timeline({ paused: true });
410
- tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
411
- window.__timelines["t"] = tl;`;
412
-
413
- describe("gsapFidelityMismatches", () => {
414
- it("returns no mismatches for identical scripts", () => {
415
- expect(gsapFidelityMismatches(SCRIPT_A, SCRIPT_A)).toEqual([]);
416
- });
417
-
418
- it("flags a per-field value drift (duration)", () => {
419
- const drifted = SCRIPT_A.replace("duration: 0.5", "duration: 0.9");
420
- const mismatches = gsapFidelityMismatches(drifted, SCRIPT_A);
421
- expect(mismatches.some((m) => m.property === "duration")).toBe(true);
422
- });
423
-
424
- it("does NOT flag sub-ULP float-formatting noise in duration", () => {
425
- // 3.1 vs 3.0999999999999996 is the same value after writer round-trips;
426
- // relative-epsilon compare must treat it as equal, not drift.
427
- const sdk = `var tl = gsap.timeline({ paused: true });
428
- tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 3.1 }, 0);
429
- window.__timelines["t"] = tl;`;
430
- const server = `var tl = gsap.timeline({ paused: true });
431
- tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 3.0999999999999996 }, 0);
432
- window.__timelines["t"] = tl;`;
433
- expect(gsapFidelityMismatches(sdk, server)).toEqual([]);
434
- });
435
-
436
- it("STILL flags a real integer duration drift (2 vs 1) past the epsilon", () => {
437
- const sdk = `var tl = gsap.timeline({ paused: true });
438
- tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 1 }, 0);
439
- window.__timelines["t"] = tl;`;
440
- const server = `var tl = gsap.timeline({ paused: true });
441
- tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 2 }, 0);
442
- window.__timelines["t"] = tl;`;
443
- const mismatches = gsapFidelityMismatches(sdk, server);
444
- expect(mismatches.some((m) => m.property === "duration")).toBe(true);
445
- });
446
-
447
- it("flags a tween present in one script but not the other", () => {
448
- const empty = `var tl = gsap.timeline({ paused: true });
449
- window.__timelines["t"] = tl;`;
450
- const mismatches = gsapFidelityMismatches(empty, SCRIPT_A);
451
- expect(mismatches.some((m) => m.property === "tween")).toBe(true);
452
- });
453
-
454
- it("does NOT flag property key-order differences (canonical compare)", () => {
455
- const ab = `var tl = gsap.timeline({ paused: true });
456
- tl.to("[data-hf-id=\\"hf-box\\"]", { x: 10, y: 20, duration: 0.5 }, 0);
457
- window.__timelines["t"] = tl;`;
458
- const ba = `var tl = gsap.timeline({ paused: true });
459
- tl.to("[data-hf-id=\\"hf-box\\"]", { y: 20, x: 10, duration: 0.5 }, 0);
460
- window.__timelines["t"] = tl;`;
461
- expect(gsapFidelityMismatches(ab, ba)).toEqual([]);
462
- });
463
-
464
- it("does NOT flag number-vs-string-equivalent property values", () => {
465
- const numeric = `var tl = gsap.timeline({ paused: true });
466
- tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0);
467
- window.__timelines["t"] = tl;`;
468
- const stringy = `var tl = gsap.timeline({ paused: true });
469
- tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: "1", duration: 0.5 }, 0);
470
- window.__timelines["t"] = tl;`;
471
- expect(gsapFidelityMismatches(numeric, stringy)).toEqual([]);
472
- });
473
-
474
- it("matches the same element across different selector forms when a resolver is given", () => {
475
- // SDK writes [data-hf-id="hf-x"], server writes .x — same element, same tween.
476
- const sdk = `var tl = gsap.timeline({ paused: true });
477
- tl.to("[data-hf-id=\\"hf-x\\"]", { x: 200, duration: 0.8 }, 0.5);
478
- window.__timelines["t"] = tl;`;
479
- const server = `var tl = gsap.timeline({ paused: true });
480
- tl.to(".x", { x: 200, duration: 0.8 }, 0.5);
481
- window.__timelines["t"] = tl;`;
482
- const resolve = (sel: string) => (/hf-x|\.x/.test(sel) ? "hf-x" : sel);
483
- // Without a resolver: selector-form divergence → present/absent mismatch.
484
- expect(gsapFidelityMismatches(sdk, server).length).toBeGreaterThan(0);
485
- // With a resolver: matched by element → no mismatch.
486
- expect(gsapFidelityMismatches(sdk, server, resolve)).toEqual([]);
487
- });
488
-
489
- // Drive makeSelectorResolver against a real DOM (happy-dom shims the
490
- // browser-only DOMParser the resolver depends on; the studio test env is node).
491
- describe("makeSelectorResolver unifies selector forms (real DOM)", () => {
492
- const origDomParser = (globalThis as { DOMParser?: unknown }).DOMParser;
493
- beforeEach(() => {
494
- (globalThis as { DOMParser?: unknown }).DOMParser = new Window().DOMParser;
495
- });
496
- afterEach(() => {
497
- (globalThis as { DOMParser?: unknown }).DOMParser = origDomParser;
498
- });
499
-
500
- it("collapses #id / .class / [data-hf-id] for the SAME element to one key", () => {
501
- // Element carries all three forms; the server may emit #id or .class while
502
- // the SDK emits [data-hf-id]. All must resolve to the same canonical key.
503
- const html = `<div data-hf-id="hf-9flp" class="caption-layer" id="intro-layer"></div>`;
504
- const resolve = makeSelectorResolver(html);
505
- const viaHfId = resolve('[data-hf-id="hf-9flp"]');
506
- expect(resolve(".caption-layer")).toBe(viaHfId);
507
- expect(resolve("#intro-layer")).toBe(viaHfId);
508
- });
509
-
510
- it("unifies SDK [data-hf-id] and server .class tweens in the fidelity diff", () => {
511
- const html = `<div data-hf-id="hf-9flp" class="caption-layer"></div>`;
512
- const resolve = makeSelectorResolver(html);
513
- const sdkScript = `var tl = gsap.timeline({ paused: true });
514
- tl.from("[data-hf-id=\\"hf-9flp\\"]", { opacity: 0, duration: 1 }, 0);
515
- window.__timelines["t"] = tl;`;
516
- const serverScript = `var tl = gsap.timeline({ paused: true });
517
- tl.from(".caption-layer", { opacity: 0, duration: 1 }, 0);
518
- window.__timelines["t"] = tl;`;
519
- // Without unification these flag present/absent; the resolver collapses them.
520
- expect(gsapFidelityMismatches(sdkScript, serverScript).length).toBeGreaterThan(0);
521
- expect(gsapFidelityMismatches(sdkScript, serverScript, resolve)).toEqual([]);
522
- });
523
-
524
- it("collapses different selector forms for an element WITHOUT a data-hf-id", () => {
525
- // No hf-id present: the resolver must still key both forms to the same node
526
- // (not leave .class vs #id as distinct raw-selector keys).
527
- const html = `<div class="caption-layer" id="intro-layer"></div>`;
528
- const resolve = makeSelectorResolver(html);
529
- expect(resolve(".caption-layer")).toBe(resolve("#intro-layer"));
530
- // And it is NOT the raw selector fallback.
531
- expect(resolve(".caption-layer")).not.toBe(".caption-layer");
532
- });
533
- });
534
- });
535
-
536
- describe("runShadowGsapFidelity", () => {
537
- const BEFORE_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
538
- <div data-hf-id="hf-box" style="opacity:0"></div>
539
- <script>var tl = gsap.timeline({ paused: true });
540
- window.__timelines["t"] = tl;</script>
541
- </div>`;
542
-
543
- it("reports zero mismatches when the SDK output matches the server script", async () => {
544
- // Produce the "server" script by applying the same op via the SDK, so a
545
- // faithful SDK writer must reproduce it exactly.
546
- const ref = await openComposition(BEFORE_HTML);
547
- const op = {
548
- kind: "add",
549
- target: "hf-box",
550
- tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
551
- } as const;
552
- ref.addGsapTween(op.target, op.tween);
553
- const serverScript =
554
- ref.serialize().match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? "";
555
-
556
- await runShadowGsapFidelity(BEFORE_HTML, op, serverScript);
557
- expect(lastShadow()).toMatchObject({ op: "gsap_fidelity", dispatched: true, mismatchCount: 0 });
558
- });
559
-
560
- it("reports mismatches when the server script diverges", async () => {
561
- const op = {
562
- kind: "add",
563
- target: "hf-box",
564
- tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
565
- } as const;
566
- const ref = await openComposition(BEFORE_HTML);
567
- ref.addGsapTween(op.target, op.tween);
568
- const serverScript = (
569
- ref.serialize().match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? ""
570
- ).replace("100", "999");
571
-
572
- await runShadowGsapFidelity(BEFORE_HTML, op, serverScript);
573
- const ev = lastShadow();
574
- expect(ev).toMatchObject({ op: "gsap_fidelity", dispatched: true });
575
- expect(ev?.mismatchCount as number).toBeGreaterThan(0);
576
- });
577
- });
578
-
579
- describe("resolveGsapFidelityArgs (chokepoint wiring)", () => {
580
- const op: ShadowGsapOp = { kind: "remove", animationId: "a-1" };
581
- const session = {} as object;
582
-
583
- it("returns narrowed args when session, op, before, and serverScript are all present", () => {
584
- expect(resolveGsapFidelityArgs(session, op, "<html>before</html>", "tl.to(...)")).toEqual({
585
- before: "<html>before</html>",
586
- op,
587
- serverScript: "tl.to(...)",
588
- });
589
- });
590
-
591
- it("returns null when no session (shadow not wired)", () => {
592
- expect(resolveGsapFidelityArgs(null, op, "before", "script")).toBeNull();
593
- });
594
-
595
- it("returns null when no shadowGsapOp (non-meta edit, e.g. property/keyframe)", () => {
596
- expect(resolveGsapFidelityArgs(session, undefined, "before", "script")).toBeNull();
597
- });
598
-
599
- it("returns null when serverScript is null (composition has no GSAP script)", () => {
600
- expect(resolveGsapFidelityArgs(session, op, "before", null)).toBeNull();
601
- });
602
-
603
- it("returns null when before is null", () => {
604
- expect(resolveGsapFidelityArgs(session, op, null, "script")).toBeNull();
605
- });
606
- });