@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.
- package/dist/assets/index-B7S86vK1.js +370 -0
- package/dist/assets/index-DP8pPIk2.css +1 -0
- package/dist/assets/{index-CfiyaR7G.js → index-_IV-vm9l.js} +1 -1
- package/dist/assets/{index-CXy_mWnP.js → index-x0c2-zQN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -5
- package/src/App.tsx +133 -141
- package/src/components/StudioHeader.tsx +44 -0
- package/src/components/StudioOverlays.tsx +70 -0
- package/src/components/editor/SourceEditor.tsx +19 -16
- package/src/components/editor/manualEditingAvailability.ts +25 -7
- package/src/components/storyboard/FramePoster.tsx +56 -0
- package/src/components/storyboard/StoryboardDirection.tsx +45 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
- package/src/components/storyboard/StoryboardGrid.tsx +33 -0
- package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
- package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
- package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
- package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
- package/src/components/storyboard/StoryboardView.tsx +78 -0
- package/src/components/storyboard/frameStatus.ts +36 -0
- package/src/contexts/ViewModeContext.tsx +98 -0
- package/src/hooks/gsapScriptCommitTypes.ts +4 -7
- package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
- package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -0
- package/src/hooks/useAppHotkeys.ts +20 -0
- package/src/hooks/useDomEditCommits.ts +43 -14
- package/src/hooks/useDomEditSession.test.ts +41 -0
- package/src/hooks/useDomEditSession.ts +38 -6
- package/src/hooks/useDomGeometryCommits.ts +5 -9
- package/src/hooks/useElementLifecycleOps.ts +30 -0
- package/src/hooks/useGsapAnimationOps.ts +78 -51
- package/src/hooks/useGsapKeyframeOps.ts +127 -43
- package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
- package/src/hooks/useGsapPropertyDebounce.ts +201 -23
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
- package/src/hooks/useGsapScriptCommits.ts +95 -40
- package/src/hooks/useSdkSession.test.ts +12 -0
- package/src/hooks/useSdkSession.ts +91 -25
- package/src/hooks/useStoryboard.ts +80 -0
- package/src/hooks/useTimelineEditing.ts +163 -68
- package/src/utils/gsapSoftReload.ts +14 -0
- package/src/utils/sdkCutover.gate.test.ts +61 -0
- package/src/utils/sdkCutover.test.ts +839 -0
- package/src/utils/sdkCutover.ts +465 -0
- package/src/utils/sdkOpMapping.ts +43 -0
- package/src/utils/sdkResolverShadow.test.ts +366 -0
- package/src/utils/sdkResolverShadow.ts +313 -0
- package/dist/assets/index-1C8oFiPi.css +0 -1
- package/dist/assets/index-D-3sGz65.js +0 -296
- package/src/utils/sdkShadow.test.ts +0 -606
- package/src/utils/sdkShadow.ts +0 -517
- package/src/utils/sdkShadowGsapFidelity.ts +0 -296
- package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
- package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
- 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
|
-
});
|