@hyperframes/studio 0.6.106 → 0.6.107
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-koqvg-_0.js → index-CVHV-S-B.js} +141 -141
- package/dist/assets/{index-CRCE5Dz_.js → index-P52mbTRb.js} +1 -1
- package/dist/assets/{index-CBS_7p5W.js → index-zIK7PWvk.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +5 -5
- package/src/hooks/gsapScriptCommitTypes.ts +11 -0
- package/src/hooks/serializeByKey.test.ts +83 -0
- package/src/hooks/serializeByKey.ts +33 -0
- package/src/hooks/useGsapAnimationOps.ts +5 -1
- package/src/hooks/useGsapKeyframeOps.ts +21 -1
- package/src/hooks/useGsapScriptCommits.ts +33 -5
- package/src/utils/sdkShadow.test.ts +187 -1
- package/src/utils/sdkShadow.ts +67 -10
- package/src/utils/sdkShadowGsapFidelity.ts +46 -18
- package/src/utils/sdkShadowGsapKeyframe.test.ts +265 -0
- package/src/utils/sdkShadowGsapKeyframe.ts +257 -0
- package/src/utils/sdkShadowNumeric.ts +11 -0
package/src/utils/sdkShadow.ts
CHANGED
|
@@ -12,6 +12,7 @@ import type { Composition } from "@hyperframes/sdk";
|
|
|
12
12
|
import type { EditOp, GsapTweenSpec } from "@hyperframes/sdk";
|
|
13
13
|
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
14
14
|
import { trackStudioEvent } from "./studioTelemetry";
|
|
15
|
+
import { relEqual } from "./sdkShadowNumeric";
|
|
15
16
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
16
17
|
import type { PatchOperation } from "./sourcePatcher";
|
|
17
18
|
|
|
@@ -39,6 +40,16 @@ function isShadowableOp(op: PatchOperation): boolean {
|
|
|
39
40
|
return true;
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// PatchOperation types patchOpsToSdkEditOps knows how to map. Used by
|
|
44
|
+
// runShadowDispatch to flag any unmapped type as visible telemetry rather than
|
|
45
|
+
// silently dropping it (see the unmapped_type guard there).
|
|
46
|
+
const MAPPED_PATCH_OP_TYPES: ReadonlySet<string> = new Set([
|
|
47
|
+
"inline-style",
|
|
48
|
+
"text-content",
|
|
49
|
+
"attribute",
|
|
50
|
+
"html-attribute",
|
|
51
|
+
]);
|
|
52
|
+
|
|
42
53
|
export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
|
|
43
54
|
const result: EditOp[] = [];
|
|
44
55
|
const styles: Record<string, string | null> = {};
|
|
@@ -121,13 +132,29 @@ function kebabToCamel(prop: string): string {
|
|
|
121
132
|
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
122
133
|
}
|
|
123
134
|
|
|
135
|
+
// Text parity: the SDK snapshot.text is trimmed, so trim the op value too.
|
|
136
|
+
// An empty string and absent text (null) are treated as equivalent (collapsed
|
|
137
|
+
// to null) so "" vs null does not flag — both mean "no text content".
|
|
138
|
+
function normalizeText(value: string | null | undefined): string | null {
|
|
139
|
+
if (value == null) return null;
|
|
140
|
+
const trimmed = value.trim();
|
|
141
|
+
return trimmed === "" ? null : trimmed;
|
|
142
|
+
}
|
|
143
|
+
|
|
124
144
|
const OP_FIELD_RESOLVERS: Record<string, OpFieldResolver> = {
|
|
125
145
|
"inline-style": (op, flat) => ({
|
|
126
146
|
property: op.property,
|
|
127
147
|
expected: op.value,
|
|
128
148
|
actual: flat.styles[kebabToCamel(op.property)] ?? flat.styles[op.property] ?? null,
|
|
129
149
|
}),
|
|
130
|
-
|
|
150
|
+
// snapshot.text is already TRIMMED; trim the expected op value to match, so
|
|
151
|
+
// trailing-whitespace differences don't flag. Empty-vs-absent ("" vs null) is
|
|
152
|
+
// collapsed in checkOpParity. A genuinely different text value still flags.
|
|
153
|
+
"text-content": (op, flat) => ({
|
|
154
|
+
property: "text",
|
|
155
|
+
expected: normalizeText(op.value),
|
|
156
|
+
actual: normalizeText(flat.text),
|
|
157
|
+
}),
|
|
131
158
|
attribute: (op, flat) => ({
|
|
132
159
|
property: attrName(op.property),
|
|
133
160
|
expected: op.value ?? null,
|
|
@@ -244,6 +271,23 @@ export function runShadowDispatch(
|
|
|
244
271
|
});
|
|
245
272
|
return;
|
|
246
273
|
}
|
|
274
|
+
// Defensive: patchOpsToSdkEditOps silently drops PatchOperation types it
|
|
275
|
+
// doesn't map. PatchOperation.type is a closed union today, but emit a visible
|
|
276
|
+
// unmapped_type event if a future type ever slips through, so the gap surfaces
|
|
277
|
+
// in telemetry instead of vanishing.
|
|
278
|
+
// Map to the type string before find, so a future unmapped type is read as a
|
|
279
|
+
// plain string (no object cast; find on the closed union narrows to never).
|
|
280
|
+
const unmappedType = ops.map((op) => op.type).find((t) => !MAPPED_PATCH_OP_TYPES.has(t));
|
|
281
|
+
if (unmappedType !== undefined) {
|
|
282
|
+
trackStudioEvent("sdk_shadow_dispatch", {
|
|
283
|
+
op: "property",
|
|
284
|
+
dispatched: false,
|
|
285
|
+
reason: "unmapped_type",
|
|
286
|
+
type: unmappedType,
|
|
287
|
+
mismatchCount: 0,
|
|
288
|
+
});
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
247
291
|
const result = sdkShadowDispatch(session, hfId, ops);
|
|
248
292
|
trackStudioEvent("sdk_shadow_dispatch", {
|
|
249
293
|
op: "property",
|
|
@@ -345,6 +389,20 @@ export interface ShadowTiming {
|
|
|
345
389
|
trackIndex?: number;
|
|
346
390
|
}
|
|
347
391
|
|
|
392
|
+
// start/duration tolerate float-precision drift (SDK computes them
|
|
393
|
+
// arithmetically, server stores a rounded literal) via the shared relative
|
|
394
|
+
// epsilon; trackIndex (integer track slot) is compared exactly.
|
|
395
|
+
function timingFieldEqual(
|
|
396
|
+
key: keyof ShadowTiming,
|
|
397
|
+
actual: number | null | undefined,
|
|
398
|
+
expected: number,
|
|
399
|
+
): boolean {
|
|
400
|
+
if (typeof actual === "number" && key !== "trackIndex") {
|
|
401
|
+
return relEqual(actual, expected);
|
|
402
|
+
}
|
|
403
|
+
return actual === expected;
|
|
404
|
+
}
|
|
405
|
+
|
|
348
406
|
/** Shadow a timing edit. Parity: snapshot start/duration/trackIndex match. */
|
|
349
407
|
export function runShadowTiming(
|
|
350
408
|
session: Composition,
|
|
@@ -373,15 +431,14 @@ export function runShadowTiming(
|
|
|
373
431
|
];
|
|
374
432
|
for (const [key, actual] of fields) {
|
|
375
433
|
const expected = timing[key];
|
|
376
|
-
if (expected
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
}
|
|
434
|
+
if (expected === undefined || timingFieldEqual(key, actual, expected)) continue;
|
|
435
|
+
mismatches.push({
|
|
436
|
+
kind: "value_mismatch",
|
|
437
|
+
hfId,
|
|
438
|
+
property: key,
|
|
439
|
+
expected: String(expected),
|
|
440
|
+
actual: actual == null ? null : String(actual),
|
|
441
|
+
});
|
|
385
442
|
}
|
|
386
443
|
return mismatches;
|
|
387
444
|
});
|
|
@@ -16,6 +16,7 @@ import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
|
|
|
16
16
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
17
17
|
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
18
18
|
import { trackStudioEvent } from "./studioTelemetry";
|
|
19
|
+
import { relEqual } from "./sdkShadowNumeric";
|
|
19
20
|
import type { SdkShadowMismatch, ShadowGsapOp } from "./sdkShadow";
|
|
20
21
|
|
|
21
22
|
// Marker set must match document.ts extractGsapScript so both pick the same
|
|
@@ -24,7 +25,7 @@ function isGsapScriptBody(body: string): boolean {
|
|
|
24
25
|
return body.includes("gsap") || body.includes("__timelines") || body.includes("ScrollTrigger");
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
function extractGsapScript(html: string): string | null {
|
|
28
|
+
export function extractGsapScript(html: string): string | null {
|
|
28
29
|
// Close tag is `</script[^>]*>` (not just `</script>`) — HTML5 ignores junk
|
|
29
30
|
// before the `>`, e.g. `</script >` or `</script foo>` (CodeQL js/bad-tag-filter).
|
|
30
31
|
const scripts = html.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi);
|
|
@@ -73,17 +74,17 @@ function animByKey(
|
|
|
73
74
|
// number-vs-string forms. Compare canonically — sort keys, coerce numeric
|
|
74
75
|
// strings — so only real value drift registers, not formatting differences.
|
|
75
76
|
|
|
77
|
+
// Coerce string operands to numbers, then compare with the shared relative
|
|
78
|
+
// epsilon (relEqual) so float-formatting noise (3.1 vs 3.0999999999999996)
|
|
79
|
+
// isn't flagged as drift while a real 2 vs 1 still is.
|
|
76
80
|
function numericEqual(a: unknown, b: unknown): boolean {
|
|
77
81
|
if (a === b) return true;
|
|
78
82
|
const na = typeof a === "string" ? Number(a) : a;
|
|
79
83
|
const nb = typeof b === "string" ? Number(b) : b;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
!Number.isNaN(nb) &&
|
|
85
|
-
na === nb
|
|
86
|
-
);
|
|
84
|
+
if (typeof na !== "number" || typeof nb !== "number" || Number.isNaN(na) || Number.isNaN(nb)) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return relEqual(na, nb);
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
function canonicalProps(obj: Record<string, unknown> | undefined): string {
|
|
@@ -187,27 +188,54 @@ export function resolveGsapFidelityArgs(
|
|
|
187
188
|
return { before, op: shadowGsapOp, serverScript };
|
|
188
189
|
}
|
|
189
190
|
|
|
190
|
-
// Resolve a CSS selector to a canonical element
|
|
191
|
-
//
|
|
192
|
-
// ([data-hf-id="X"] vs .X)
|
|
193
|
-
//
|
|
191
|
+
// Resolve a CSS selector to a canonical element key using the pre-op document,
|
|
192
|
+
// so tweens that target the same element via different selectors
|
|
193
|
+
// ([data-hf-id="X"] vs .X vs #X) collapse to one key in the fidelity diff.
|
|
194
|
+
//
|
|
195
|
+
// The SDK writer emits [data-hf-id="X"] while the server may emit a class/id
|
|
196
|
+
// selector for the SAME element. Keying both forms to the same node prevents a
|
|
197
|
+
// false present/absent mismatch. Resolution order, for whatever element the
|
|
198
|
+
// selector matches:
|
|
199
|
+
// 1. data-hf-id present → "hfid:<id>" (the common, stable case)
|
|
200
|
+
// 2. no data-hf-id → "node:<n>" (per-document node index; identical
|
|
201
|
+
// regardless of which selector form found the node, so .x and [data-hf-id]
|
|
202
|
+
// pointing at the same attribute-less node still collapse)
|
|
203
|
+
// 3. selector resolves to no node / parse error / no DOM → the raw selector
|
|
204
|
+
// (last resort; only diverges when the two writers genuinely target
|
|
205
|
+
// different — or unresolvable — nodes, which is real drift to surface)
|
|
206
|
+
// The "hfid:"/"node:" prefixes are namespaced so a canonical key can never
|
|
207
|
+
// collide with a raw-selector fallback.
|
|
194
208
|
//
|
|
195
209
|
// ponytail: first-match heuristic — querySelector returns the FIRST match, so an
|
|
196
|
-
// ambiguous selector (e.g. .x shared by two elements) may map to a different
|
|
197
|
-
// than the SDK side's [data-hf-id] target and still flag present/absent.
|
|
198
|
-
// for studio templates (one tween per
|
|
199
|
-
// uniqueness check if ambiguous selectors appear.
|
|
200
|
-
function makeSelectorResolver(html: string): (sel: string) => string {
|
|
210
|
+
// ambiguous selector (e.g. .x shared by two elements) may map to a different
|
|
211
|
+
// node than the SDK side's [data-hf-id] target and still flag present/absent.
|
|
212
|
+
// Safe for studio templates (one tween per element); upgrade to querySelectorAll
|
|
213
|
+
// + uniqueness check if ambiguous selectors appear.
|
|
214
|
+
export function makeSelectorResolver(html: string): (sel: string) => string {
|
|
201
215
|
let doc: Document | null = null;
|
|
202
216
|
try {
|
|
203
217
|
doc = new DOMParser().parseFromString(html, "text/html");
|
|
204
218
|
} catch {
|
|
205
219
|
doc = null;
|
|
206
220
|
}
|
|
221
|
+
// Stable per-node index so an attribute-less element keys identically no
|
|
222
|
+
// matter which selector form (class vs id vs [data-hf-id]) resolved it.
|
|
223
|
+
const nodeKeys = new WeakMap<Element, string>();
|
|
224
|
+
let nextNode = 0;
|
|
225
|
+
const keyForNode = (el: Element): string => {
|
|
226
|
+
const hfId = el.getAttribute("data-hf-id");
|
|
227
|
+
if (hfId != null && hfId !== "") return `hfid:${hfId}`;
|
|
228
|
+
const existing = nodeKeys.get(el);
|
|
229
|
+
if (existing != null) return existing;
|
|
230
|
+
const key = `node:${nextNode++}`;
|
|
231
|
+
nodeKeys.set(el, key);
|
|
232
|
+
return key;
|
|
233
|
+
};
|
|
207
234
|
return (sel) => {
|
|
208
235
|
if (!doc) return sel;
|
|
209
236
|
try {
|
|
210
|
-
|
|
237
|
+
const el = doc.querySelector(sel);
|
|
238
|
+
return el ? keyForNode(el) : sel;
|
|
211
239
|
} catch {
|
|
212
240
|
return sel;
|
|
213
241
|
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
import { openComposition } from "@hyperframes/sdk";
|
|
3
|
+
import {
|
|
4
|
+
resolveKeyframeIndexByPercentage,
|
|
5
|
+
keyframeOpToEditOp,
|
|
6
|
+
gsapKeyframeFidelityMismatches,
|
|
7
|
+
runShadowGsapKeyframeFidelity,
|
|
8
|
+
type ShadowKeyframeOp,
|
|
9
|
+
} from "./sdkShadowGsapKeyframe";
|
|
10
|
+
import { runShadowDispatch } from "./sdkShadow";
|
|
11
|
+
import type { PatchOperation } from "./sourcePatcher";
|
|
12
|
+
|
|
13
|
+
// Capture sdk_shadow_dispatch telemetry.
|
|
14
|
+
const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
|
|
15
|
+
vi.mock("./studioTelemetry", () => ({
|
|
16
|
+
trackStudioEvent: (event: string, props: Record<string, unknown>) =>
|
|
17
|
+
trackedEvents.push({ event, props }),
|
|
18
|
+
}));
|
|
19
|
+
// STUDIO_SDK_SHADOW_ENABLED defaults true (no env override in test), so the
|
|
20
|
+
// runners are active here without mocking the availability module.
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
trackedEvents.length = 0;
|
|
24
|
+
});
|
|
25
|
+
const lastShadow = () =>
|
|
26
|
+
trackedEvents.filter((e) => e.event === "sdk_shadow_dispatch").at(-1)?.props;
|
|
27
|
+
|
|
28
|
+
const ANIM_ID = "#hero-to-0-position";
|
|
29
|
+
|
|
30
|
+
function gsapHtml(scriptBody: string): string {
|
|
31
|
+
return /* html */ `<!DOCTYPE html><html><body>
|
|
32
|
+
<div data-hf-id="hf-hero" id="hero" class="clip">x</div>
|
|
33
|
+
<script>
|
|
34
|
+
${scriptBody}
|
|
35
|
+
window.__timelines = [tl];
|
|
36
|
+
</script>
|
|
37
|
+
</body></html>`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const KF_SCRIPT = `
|
|
41
|
+
const tl = gsap.timeline({ paused: true });
|
|
42
|
+
tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200 } }, duration: 5 }, 0);`;
|
|
43
|
+
|
|
44
|
+
// A script body string (not full HTML) for the index-resolution helpers.
|
|
45
|
+
const KF_SCRIPT_BODY = KF_SCRIPT;
|
|
46
|
+
const DUP_SCRIPT_BODY = `
|
|
47
|
+
const tl = gsap.timeline({ paused: true });
|
|
48
|
+
tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "50%": { x: 150 }, "100%": { x: 200 } }, duration: 5 }, 0);`;
|
|
49
|
+
|
|
50
|
+
describe("resolveKeyframeIndexByPercentage", () => {
|
|
51
|
+
it("resolves a unique percentage to its 0-based index", () => {
|
|
52
|
+
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 50)).toEqual({
|
|
53
|
+
keyframeIndex: 1,
|
|
54
|
+
});
|
|
55
|
+
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 100)).toEqual({
|
|
56
|
+
keyframeIndex: 2,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("matches within ~0.001 tolerance", () => {
|
|
61
|
+
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 50.0005).keyframeIndex).toBe(
|
|
62
|
+
1,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns null with not_found when no percentage matches", () => {
|
|
67
|
+
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 33)).toEqual({
|
|
68
|
+
keyframeIndex: null,
|
|
69
|
+
reason: "not_found",
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns null with no_keyframes for an unknown animation", () => {
|
|
74
|
+
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, "#nope-to-0", 50)).toEqual({
|
|
75
|
+
keyframeIndex: null,
|
|
76
|
+
reason: "no_keyframes",
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns null with no_keyframes when script is empty", () => {
|
|
81
|
+
expect(resolveKeyframeIndexByPercentage(null, ANIM_ID, 50).reason).toBe("no_keyframes");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("no-ops on ambiguity (duplicate-percentage keyframes — PR #1498 landmine)", () => {
|
|
85
|
+
expect(resolveKeyframeIndexByPercentage(DUP_SCRIPT_BODY, ANIM_ID, 50)).toEqual({
|
|
86
|
+
keyframeIndex: null,
|
|
87
|
+
reason: "ambiguous",
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Regression: a from/fromTo tween's id may normalize to "-to-" on write, so a
|
|
92
|
+
// "-from-"/"-fromTo-" animationId must fall back to the converted id (matching
|
|
93
|
+
// the writer's locateAnimationWithFallback) — else the keyframe diff goes blind.
|
|
94
|
+
it("falls back from a -from- id to the -to- tween", () => {
|
|
95
|
+
const fromId = ANIM_ID.replace("-to-", "-from-");
|
|
96
|
+
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, fromId, 50)).toEqual({
|
|
97
|
+
keyframeIndex: 1,
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("keyframeOpToEditOp", () => {
|
|
103
|
+
it("maps add → addGsapKeyframe with position = percentage", () => {
|
|
104
|
+
const op: ShadowKeyframeOp = {
|
|
105
|
+
kind: "add",
|
|
106
|
+
animationId: ANIM_ID,
|
|
107
|
+
percentage: 25,
|
|
108
|
+
properties: { x: 50 },
|
|
109
|
+
};
|
|
110
|
+
expect(keyframeOpToEditOp(op, KF_SCRIPT_BODY)).toEqual({
|
|
111
|
+
op: { type: "addGsapKeyframe", animationId: ANIM_ID, position: 25, value: { x: 50 } },
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("maps remove → removeGsapKeyframe with resolved index", () => {
|
|
116
|
+
const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
|
|
117
|
+
expect(keyframeOpToEditOp(op, KF_SCRIPT_BODY)).toEqual({
|
|
118
|
+
op: { type: "removeGsapKeyframe", animationId: ANIM_ID, keyframeIndex: 1 },
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("returns null op + reason when remove percentage is ambiguous", () => {
|
|
123
|
+
const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
|
|
124
|
+
expect(keyframeOpToEditOp(op, DUP_SCRIPT_BODY)).toEqual({ op: null, reason: "ambiguous" });
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("gsapKeyframeFidelityMismatches", () => {
|
|
129
|
+
it("reports no mismatches when keyframe arrays match", () => {
|
|
130
|
+
expect(gsapKeyframeFidelityMismatches(KF_SCRIPT_BODY, KF_SCRIPT_BODY, ANIM_ID)).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("reports a keyframes mismatch when arrays diverge", () => {
|
|
134
|
+
const other = `
|
|
135
|
+
const tl = gsap.timeline({ paused: true });
|
|
136
|
+
tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 999 }, "100%": { x: 200 } }, duration: 5 }, 0);`;
|
|
137
|
+
const mismatches = gsapKeyframeFidelityMismatches(KF_SCRIPT_BODY, other, ANIM_ID);
|
|
138
|
+
expect(mismatches.some((m) => m.property === "keyframes")).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("runShadowGsapKeyframeFidelity (add)", () => {
|
|
143
|
+
it("emits gsap_keyframe with a keyframes mismatch when SDK adds but server didn't", async () => {
|
|
144
|
+
const beforeHtml = gsapHtml(KF_SCRIPT);
|
|
145
|
+
// server script unchanged (server "failed" to add the 25% keyframe) → drift
|
|
146
|
+
const session = await openComposition(beforeHtml);
|
|
147
|
+
const serverScript = session
|
|
148
|
+
.serialize()
|
|
149
|
+
.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1];
|
|
150
|
+
expect(serverScript).toBeTruthy();
|
|
151
|
+
const op: ShadowKeyframeOp = {
|
|
152
|
+
kind: "add",
|
|
153
|
+
animationId: ANIM_ID,
|
|
154
|
+
percentage: 25,
|
|
155
|
+
properties: { x: 50 },
|
|
156
|
+
};
|
|
157
|
+
await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript);
|
|
158
|
+
const props = lastShadow();
|
|
159
|
+
expect(props?.op).toBe("gsap_keyframe");
|
|
160
|
+
expect(props?.dispatched).toBe(true);
|
|
161
|
+
expect(props?.mismatchCount).toBe(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("emits dispatched:true mismatchCount:0 when SDK and server agree", async () => {
|
|
165
|
+
const beforeHtml = gsapHtml(KF_SCRIPT);
|
|
166
|
+
// Build the server's resulting script by applying the same op via the SDK.
|
|
167
|
+
const serverSession = await openComposition(beforeHtml);
|
|
168
|
+
serverSession.batch(() =>
|
|
169
|
+
serverSession.dispatch({
|
|
170
|
+
type: "addGsapKeyframe",
|
|
171
|
+
animationId: ANIM_ID,
|
|
172
|
+
position: 25,
|
|
173
|
+
value: { x: 50 },
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
const serverScript = serverSession
|
|
177
|
+
.serialize()
|
|
178
|
+
.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1];
|
|
179
|
+
const op: ShadowKeyframeOp = {
|
|
180
|
+
kind: "add",
|
|
181
|
+
animationId: ANIM_ID,
|
|
182
|
+
percentage: 25,
|
|
183
|
+
properties: { x: 50 },
|
|
184
|
+
};
|
|
185
|
+
await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript);
|
|
186
|
+
const props = lastShadow();
|
|
187
|
+
expect(props?.op).toBe("gsap_keyframe");
|
|
188
|
+
expect(props?.dispatched).toBe(true);
|
|
189
|
+
expect(props?.mismatchCount).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("runShadowGsapKeyframeFidelity (remove)", () => {
|
|
194
|
+
it("no-ops with reason when remove percentage is ambiguous", async () => {
|
|
195
|
+
const beforeHtml = gsapHtml(DUP_SCRIPT_BODY);
|
|
196
|
+
const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
|
|
197
|
+
await runShadowGsapKeyframeFidelity(beforeHtml, op, "non-empty-server-script gsap");
|
|
198
|
+
const props = lastShadow();
|
|
199
|
+
expect(props?.op).toBe("gsap_keyframe");
|
|
200
|
+
expect(props?.dispatched).toBe(false);
|
|
201
|
+
expect(props?.reason).toBe("ambiguous");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("dispatches a resolved remove and diffs", async () => {
|
|
205
|
+
const beforeHtml = gsapHtml(KF_SCRIPT);
|
|
206
|
+
const serverSession = await openComposition(beforeHtml);
|
|
207
|
+
serverSession.batch(() =>
|
|
208
|
+
serverSession.dispatch({
|
|
209
|
+
type: "removeGsapKeyframe",
|
|
210
|
+
animationId: ANIM_ID,
|
|
211
|
+
keyframeIndex: 1,
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
const serverScript = serverSession
|
|
215
|
+
.serialize()
|
|
216
|
+
.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1];
|
|
217
|
+
const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
|
|
218
|
+
await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript);
|
|
219
|
+
const props = lastShadow();
|
|
220
|
+
expect(props?.op).toBe("gsap_keyframe");
|
|
221
|
+
expect(props?.dispatched).toBe(true);
|
|
222
|
+
expect(props?.mismatchCount).toBe(0);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("runShadowGsapKeyframeFidelity (guards)", () => {
|
|
227
|
+
it("skips when there is no server script", async () => {
|
|
228
|
+
const op: ShadowKeyframeOp = {
|
|
229
|
+
kind: "add",
|
|
230
|
+
animationId: ANIM_ID,
|
|
231
|
+
percentage: 25,
|
|
232
|
+
properties: { x: 50 },
|
|
233
|
+
};
|
|
234
|
+
await runShadowGsapKeyframeFidelity(gsapHtml(KF_SCRIPT), op, null);
|
|
235
|
+
expect(lastShadow()).toBeUndefined();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe("runShadowDispatch unmapped-type guard", () => {
|
|
240
|
+
const ELEMENT_HTML = /* html */ `<!DOCTYPE html><html><body>
|
|
241
|
+
<div data-hf-id="hf-box" style="color: red;">Hi</div>
|
|
242
|
+
</body></html>`;
|
|
243
|
+
|
|
244
|
+
it("emits unmapped_type when a PatchOperation type isn't mapped", async () => {
|
|
245
|
+
const session = await openComposition(ELEMENT_HTML);
|
|
246
|
+
// PatchOperation.type is a closed union today; cast to exercise the defensive
|
|
247
|
+
// guard for a future unmapped type.
|
|
248
|
+
const ops = [{ type: "future-op", property: "x", value: "1" } as unknown as PatchOperation];
|
|
249
|
+
runShadowDispatch(session, { hfId: "hf-box" } as never, ops);
|
|
250
|
+
const props = lastShadow();
|
|
251
|
+
expect(props?.op).toBe("property");
|
|
252
|
+
expect(props?.dispatched).toBe(false);
|
|
253
|
+
expect(props?.reason).toBe("unmapped_type");
|
|
254
|
+
expect(props?.type).toBe("future-op");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("dispatches normally for known PatchOperation types", async () => {
|
|
258
|
+
const session = await openComposition(ELEMENT_HTML);
|
|
259
|
+
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
|
|
260
|
+
runShadowDispatch(session, { hfId: "hf-box" } as never, ops);
|
|
261
|
+
const props = lastShadow();
|
|
262
|
+
expect(props?.dispatched).toBe(true);
|
|
263
|
+
expect(props?.reason).toBeUndefined();
|
|
264
|
+
});
|
|
265
|
+
});
|