Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion packages/studio/src/utils/sdkShadow.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import {
patchOpsToSdkEditOps,
runShadowDelete,
Expand All @@ -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<string, unknown> }> = [];
Expand Down Expand Up @@ -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;`;
Expand Down Expand Up @@ -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 = `<div data-hf-id="hf-9flp" class="caption-layer" id="intro-layer"></div>`;
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 = `<div data-hf-id="hf-9flp" class="caption-layer"></div>`;
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 = `<div class="caption-layer" id="intro-layer"></div>`;
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", () => {
Expand Down
66 changes: 49 additions & 17 deletions packages/studio/src/utils/sdkShadowGsapFidelity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | undefined): string {
Expand Down Expand Up @@ -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:<id>" (the common, stable case)
// 2. no data-hf-id → "node:<n>" (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<Element, string>();
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;
}
Expand Down
Loading