mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user