diff --git a/packages/studio/src/utils/sdkShadow.test.ts b/packages/studio/src/utils/sdkShadow.test.ts index a36405cd6..95743e78d 100644 --- a/packages/studio/src/utils/sdkShadow.test.ts +++ b/packages/studio/src/utils/sdkShadow.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { patchOpsToSdkEditOps, runShadowDelete, @@ -10,8 +10,10 @@ import { SdkShadowMismatch, } from "./sdkShadow"; import type { ShadowGsapOp } from "./sdkShadow"; +import { makeSelectorResolver } from "./sdkShadowGsapFidelity"; import type { PatchOperation } from "./sourcePatcher"; import { openComposition } from "@hyperframes/sdk"; +import { Window } from "happy-dom"; // Capture sdk_shadow_dispatch telemetry for the non-PatchOperation runners. const trackedEvents: Array<{ event: string; props: Record }> = []; @@ -304,6 +306,29 @@ describe("gsapFidelityMismatches", () => { expect(mismatches.some((m) => m.property === "duration")).toBe(true); }); + it("does NOT flag sub-ULP float-formatting noise in duration", () => { + // 3.1 vs 3.0999999999999996 is the same value after writer round-trips; + // relative-epsilon compare must treat it as equal, not drift. + const sdk = `var tl = gsap.timeline({ paused: true }); +tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 3.1 }, 0); +window.__timelines["t"] = tl;`; + const server = `var tl = gsap.timeline({ paused: true }); +tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 3.0999999999999996 }, 0); +window.__timelines["t"] = tl;`; + expect(gsapFidelityMismatches(sdk, server)).toEqual([]); + }); + + it("STILL flags a real integer duration drift (2 vs 1) past the epsilon", () => { + const sdk = `var tl = gsap.timeline({ paused: true }); +tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 1 }, 0); +window.__timelines["t"] = tl;`; + const server = `var tl = gsap.timeline({ paused: true }); +tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 2 }, 0); +window.__timelines["t"] = tl;`; + const mismatches = gsapFidelityMismatches(sdk, server); + expect(mismatches.some((m) => m.property === "duration")).toBe(true); + }); + it("flags a tween present in one script but not the other", () => { const empty = `var tl = gsap.timeline({ paused: true }); window.__timelines["t"] = tl;`; @@ -345,6 +370,52 @@ window.__timelines["t"] = tl;`; // With a resolver: matched by element → no mismatch. expect(gsapFidelityMismatches(sdk, server, resolve)).toEqual([]); }); + + // Drive makeSelectorResolver against a real DOM (happy-dom shims the + // browser-only DOMParser the resolver depends on; the studio test env is node). + describe("makeSelectorResolver unifies selector forms (real DOM)", () => { + const origDomParser = (globalThis as { DOMParser?: unknown }).DOMParser; + beforeEach(() => { + (globalThis as { DOMParser?: unknown }).DOMParser = new Window().DOMParser; + }); + afterEach(() => { + (globalThis as { DOMParser?: unknown }).DOMParser = origDomParser; + }); + + it("collapses #id / .class / [data-hf-id] for the SAME element to one key", () => { + // Element carries all three forms; the server may emit #id or .class while + // the SDK emits [data-hf-id]. All must resolve to the same canonical key. + const html = `
`; + const resolve = makeSelectorResolver(html); + const viaHfId = resolve('[data-hf-id="hf-9flp"]'); + expect(resolve(".caption-layer")).toBe(viaHfId); + expect(resolve("#intro-layer")).toBe(viaHfId); + }); + + it("unifies SDK [data-hf-id] and server .class tweens in the fidelity diff", () => { + const html = `
`; + const resolve = makeSelectorResolver(html); + const sdkScript = `var tl = gsap.timeline({ paused: true }); +tl.from("[data-hf-id=\\"hf-9flp\\"]", { opacity: 0, duration: 1 }, 0); +window.__timelines["t"] = tl;`; + const serverScript = `var tl = gsap.timeline({ paused: true }); +tl.from(".caption-layer", { opacity: 0, duration: 1 }, 0); +window.__timelines["t"] = tl;`; + // Without unification these flag present/absent; the resolver collapses them. + expect(gsapFidelityMismatches(sdkScript, serverScript).length).toBeGreaterThan(0); + expect(gsapFidelityMismatches(sdkScript, serverScript, resolve)).toEqual([]); + }); + + it("collapses different selector forms for an element WITHOUT a data-hf-id", () => { + // No hf-id present: the resolver must still key both forms to the same node + // (not leave .class vs #id as distinct raw-selector keys). + const html = `
`; + const resolve = makeSelectorResolver(html); + expect(resolve(".caption-layer")).toBe(resolve("#intro-layer")); + // And it is NOT the raw selector fallback. + expect(resolve(".caption-layer")).not.toBe(".caption-layer"); + }); + }); }); describe("runShadowGsapFidelity", () => { diff --git a/packages/studio/src/utils/sdkShadowGsapFidelity.ts b/packages/studio/src/utils/sdkShadowGsapFidelity.ts index 6287fa733..cad5f08e0 100644 --- a/packages/studio/src/utils/sdkShadowGsapFidelity.ts +++ b/packages/studio/src/utils/sdkShadowGsapFidelity.ts @@ -73,17 +73,22 @@ function animByKey( // number-vs-string forms. Compare canonically — sort keys, coerce numeric // strings — so only real value drift registers, not formatting differences. +// Relative-epsilon compare: the two writers round-trip durations through JS +// number formatting, so a value like 3.1 can come back as 3.0999999999999996. +// An exact `===` flags that sub-ULP delta as drift. Treat values as equal when +// they're within 1e-6 * max(1, |a|, |b|) of each other — tight enough that a +// real 2 vs 1 (or 0.5 vs 0.49) drift still flags, loose enough to absorb +// float-formatting noise. function numericEqual(a: unknown, b: unknown): boolean { if (a === b) return true; const na = typeof a === "string" ? Number(a) : a; const nb = typeof b === "string" ? Number(b) : b; - return ( - typeof na === "number" && - typeof nb === "number" && - !Number.isNaN(na) && - !Number.isNaN(nb) && - na === nb - ); + if (typeof na !== "number" || typeof nb !== "number" || Number.isNaN(na) || Number.isNaN(nb)) { + return false; + } + if (na === nb) return true; + const tolerance = 1e-6 * Math.max(1, Math.abs(na), Math.abs(nb)); + return Math.abs(na - nb) <= tolerance; } function canonicalProps(obj: Record | undefined): string { @@ -187,27 +192,54 @@ export function resolveGsapFidelityArgs( return { before, op: shadowGsapOp, serverScript }; } -// Resolve a CSS selector to a canonical element id (data-hf-id) using the pre-op -// document, so tweens that target the same element via different selectors -// ([data-hf-id="X"] vs .X) match in the fidelity diff. Falls back to the raw -// selector when it can't resolve (DOMParser unavailable, no match, bad selector). +// Resolve a CSS selector to a canonical element key using the pre-op document, +// so tweens that target the same element via different selectors +// ([data-hf-id="X"] vs .X vs #X) collapse to one key in the fidelity diff. +// +// The SDK writer emits [data-hf-id="X"] while the server may emit a class/id +// selector for the SAME element. Keying both forms to the same node prevents a +// false present/absent mismatch. Resolution order, for whatever element the +// selector matches: +// 1. data-hf-id present → "hfid:" (the common, stable case) +// 2. no data-hf-id → "node:" (per-document node index; identical +// regardless of which selector form found the node, so .x and [data-hf-id] +// pointing at the same attribute-less node still collapse) +// 3. selector resolves to no node / parse error / no DOM → the raw selector +// (last resort; only diverges when the two writers genuinely target +// different — or unresolvable — nodes, which is real drift to surface) +// The "hfid:"/"node:" prefixes are namespaced so a canonical key can never +// collide with a raw-selector fallback. // // ponytail: first-match heuristic — querySelector returns the FIRST match, so an -// ambiguous selector (e.g. .x shared by two elements) may map to a different id -// than the SDK side's [data-hf-id] target and still flag present/absent. Safe -// for studio templates (one tween per data-hf-id); upgrade to querySelectorAll + -// uniqueness check if ambiguous selectors appear. -function makeSelectorResolver(html: string): (sel: string) => string { +// ambiguous selector (e.g. .x shared by two elements) may map to a different +// node than the SDK side's [data-hf-id] target and still flag present/absent. +// Safe for studio templates (one tween per element); upgrade to querySelectorAll +// + uniqueness check if ambiguous selectors appear. +export function makeSelectorResolver(html: string): (sel: string) => string { let doc: Document | null = null; try { doc = new DOMParser().parseFromString(html, "text/html"); } catch { doc = null; } + // Stable per-node index so an attribute-less element keys identically no + // matter which selector form (class vs id vs [data-hf-id]) resolved it. + const nodeKeys = new WeakMap(); + let nextNode = 0; + const keyForNode = (el: Element): string => { + const hfId = el.getAttribute("data-hf-id"); + if (hfId != null && hfId !== "") return `hfid:${hfId}`; + const existing = nodeKeys.get(el); + if (existing != null) return existing; + const key = `node:${nextNode++}`; + nodeKeys.set(el, key); + return key; + }; return (sel) => { if (!doc) return sel; try { - return doc.querySelector(sel)?.getAttribute("data-hf-id") ?? sel; + const el = doc.querySelector(sel); + return el ? keyForNode(el) : sel; } catch { return sel; }