fix(studio): kill false-positive shadow GSAP fidelity mismatches (#1507)

## What
Kills two false-positive classes in the SDK shadow GSAP value-fidelity diff (`sdkShadowGsapFidelity.ts`).

1. **Float precision** — `numericEqual` compared exactly, so SDK-computed `3.0999999999999996` vs server `3.1` flagged as drift. Now a relative epsilon (`abs(a-b) <= 1e-6 * max(1,|a|,|b|)`); real `2` vs `1` still flags.
2. **Selector-form divergence** — `[data-hf-id="X"]` (SDK writer) vs `.class`/`#id` (server writer) for the same element produced phantom `present`/`absent` pairs. `makeSelectorResolver` now keys tweens by resolved element (incl. nodes with no `data-hf-id`), unifying the forms.

## Why
Surfaced by production SDK-shadow parity telemetry — `gsap_fidelity` was the noisiest real-traffic op; both are diff-harness artifacts, not SDK drift.

## Tests
Epsilon (clean + real-drift) + selector-unification for `#id`/`.class`/`[data-hf-id]`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Vance Ingalls
2026-06-16 12:19:16 -07:00
committed by GitHub
parent c0ac03cab2
commit c096ff3afa
2 changed files with 121 additions and 18 deletions
+72 -1
View File
@@ -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<string, unknown> }> = [];
@@ -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 = `<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", () => {
@@ -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 {
@@ -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;
}