mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): s7.5 — delete shadow scaffolding; keep cutover flag (dark launch) (#1462)
Removes the SDK shadow telemetry: STUDIO_SDK_SHADOW_ENABLED, sdkShadow.ts + sdkShadowGsapFidelity/GsapKeyframe/Numeric and their tests, the runShadow* call-sites across the GSAP/timeline hooks, and the onDomEditPersisted shadow callback in useDomEditSession. Moves patchOpsToSdkEditOps into sdkCutover.ts. KEEPS STUDIO_SDK_CUTOVER_ENABLED as a dark-launch kill-switch — default false, enable per-environment via VITE_STUDIO_SDK_CUTOVER_ENABLED=true. shouldUseSdkCutover stays flag-gated. The stack can merge with zero behavior change; cutover is validated by flipping the flag, not by removing it. Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
co-authored by
Miguel Ángel
parent
0ca1a8a9d1
commit
ca1a8a6879
@@ -1,10 +1,9 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import type { Composition, EditOp } from "@hyperframes/sdk";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
import type { EditHistoryKind } from "./editHistory";
|
||||
import type { PatchOperation } from "./sourcePatcher";
|
||||
import { STUDIO_SDK_CUTOVER_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
import { patchOpsToSdkEditOps } from "./sdkShadow";
|
||||
import { trackStudioEvent } from "./studioTelemetry";
|
||||
|
||||
const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
|
||||
@@ -14,6 +13,42 @@ const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
|
||||
"html-attribute",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Map Studio PatchOperations for a given hf-id to SDK EditOps.
|
||||
*
|
||||
* Multiple inline-style ops are coalesced into a single setStyle (SDK batches
|
||||
* style changes naturally). One SDK op is emitted per non-style op.
|
||||
*/
|
||||
function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
|
||||
const result: EditOp[] = [];
|
||||
const styles: Record<string, string | null> = {};
|
||||
let hasStyles = false;
|
||||
|
||||
for (const op of ops) {
|
||||
if (op.type === "inline-style") {
|
||||
styles[op.property] = op.value;
|
||||
hasStyles = true;
|
||||
} else if (op.type === "text-content") {
|
||||
result.push({ type: "setText", target: hfId, value: op.value ?? "" });
|
||||
} else if (op.type === "attribute") {
|
||||
result.push({
|
||||
type: "setAttribute",
|
||||
target: hfId,
|
||||
name: op.property.startsWith("data-") ? op.property : `data-${op.property}`,
|
||||
value: op.value,
|
||||
});
|
||||
} else if (op.type === "html-attribute") {
|
||||
result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value });
|
||||
}
|
||||
}
|
||||
|
||||
if (hasStyles) {
|
||||
result.unshift({ type: "setStyle", target: hfId, styles });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function shouldUseSdkCutover(
|
||||
flagEnabled: boolean,
|
||||
hasSession: boolean,
|
||||
|
||||
@@ -1,606 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
patchOpsToSdkEditOps,
|
||||
runShadowDelete,
|
||||
runShadowTiming,
|
||||
runShadowGsapTween,
|
||||
runShadowGsapFidelity,
|
||||
gsapFidelityMismatches,
|
||||
resolveGsapFidelityArgs,
|
||||
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> }> = [];
|
||||
vi.mock("./studioTelemetry", () => ({
|
||||
trackStudioEvent: (event: string, props: Record<string, unknown>) =>
|
||||
trackedEvents.push({ event, props }),
|
||||
}));
|
||||
beforeEach(() => {
|
||||
trackedEvents.length = 0;
|
||||
});
|
||||
const lastShadow = () =>
|
||||
trackedEvents.filter((e) => e.event === "sdk_shadow_dispatch").at(-1)?.props;
|
||||
|
||||
const BASE_HTML = /* html */ `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-hf-id="hf-box" style="color: red; width: 100px;" data-name="box">Hello</div>
|
||||
</body></html>`;
|
||||
|
||||
describe("patchOpsToSdkEditOps", () => {
|
||||
it("maps inline-style ops to a single setStyle EditOp", () => {
|
||||
const ops: PatchOperation[] = [
|
||||
{ type: "inline-style", property: "color", value: "#00f" },
|
||||
{ type: "inline-style", property: "opacity", value: "0.5" },
|
||||
];
|
||||
const result = patchOpsToSdkEditOps("hf-box", ops);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: "setStyle",
|
||||
target: "hf-box",
|
||||
styles: { color: "#00f", opacity: "0.5" },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps text-content op to setText EditOp", () => {
|
||||
const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "World" }];
|
||||
const result = patchOpsToSdkEditOps("hf-box", ops);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({ type: "setText", target: "hf-box", value: "World" });
|
||||
});
|
||||
|
||||
it("maps attribute op to setAttribute with data- prefix", () => {
|
||||
const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }];
|
||||
const result = patchOpsToSdkEditOps("hf-box", ops);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: "setAttribute",
|
||||
target: "hf-box",
|
||||
name: "data-name",
|
||||
value: "hero",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps html-attribute op to setAttribute without prefix", () => {
|
||||
const ops: PatchOperation[] = [
|
||||
{ type: "html-attribute", property: "contenteditable", value: "true" },
|
||||
];
|
||||
const result = patchOpsToSdkEditOps("hf-box", ops);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual({
|
||||
type: "setAttribute",
|
||||
target: "hf-box",
|
||||
name: "contenteditable",
|
||||
value: "true",
|
||||
});
|
||||
});
|
||||
|
||||
it("handles null value for attribute removal", () => {
|
||||
const ops: PatchOperation[] = [{ type: "html-attribute", property: "hidden", value: null }];
|
||||
const result = patchOpsToSdkEditOps("hf-box", ops);
|
||||
expect(result[0]).toEqual({
|
||||
type: "setAttribute",
|
||||
target: "hf-box",
|
||||
name: "hidden",
|
||||
value: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty array for unknown op types", () => {
|
||||
const ops = [{ type: "unknown-op", property: "x", value: "y" }] as unknown as PatchOperation[];
|
||||
expect(patchOpsToSdkEditOps("hf-box", ops)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sdkShadowDispatch (integration)", () => {
|
||||
it("applies ops and returns no mismatches when SDK matches expected values", async () => {
|
||||
const { sdkShadowDispatch } = await import("./sdkShadow");
|
||||
const session = await openComposition(BASE_HTML);
|
||||
|
||||
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
|
||||
const result = sdkShadowDispatch(session, "hf-box", ops);
|
||||
|
||||
expect(result.dispatched).toBe(true);
|
||||
expect(result.mismatches).toHaveLength(0);
|
||||
expect(session.getElement("hf-box")?.inlineStyles.color).toBe("#00f");
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("does NOT false-mismatch a hyphenated style property (kebab op vs camelCase snapshot)", async () => {
|
||||
const { sdkShadowDispatch } = await import("./sdkShadow");
|
||||
const session = await openComposition(BASE_HTML);
|
||||
|
||||
const ops: PatchOperation[] = [
|
||||
{ type: "inline-style", property: "background-color", value: "rgb(255, 79, 88)" },
|
||||
];
|
||||
const result = sdkShadowDispatch(session, "hf-box", ops);
|
||||
|
||||
expect(result.dispatched).toBe(true);
|
||||
expect(result.mismatches).toHaveLength(0); // was 1 before the kebab→camel read-back fix
|
||||
expect(session.getElement("hf-box")?.inlineStyles.backgroundColor).toBe("rgb(255, 79, 88)");
|
||||
});
|
||||
|
||||
it("returns dispatched:false when hfId not found in session", async () => {
|
||||
const { sdkShadowDispatch } = await import("./sdkShadow");
|
||||
const session = await openComposition(BASE_HTML);
|
||||
|
||||
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
|
||||
const result = sdkShadowDispatch(session, "hf-missing", ops);
|
||||
|
||||
expect(result.dispatched).toBe(false);
|
||||
expect(result.mismatches).toHaveLength(1);
|
||||
expect(result.mismatches[0]).toMatchObject<SdkShadowMismatch>({
|
||||
kind: "element_not_found",
|
||||
hfId: "hf-missing",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies text op and reads back via session.getElement", async () => {
|
||||
const { sdkShadowDispatch } = await import("./sdkShadow");
|
||||
const session = await openComposition(BASE_HTML);
|
||||
|
||||
const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "Updated" }];
|
||||
sdkShadowDispatch(session, "hf-box", ops);
|
||||
|
||||
expect(session.getElement("hf-box")?.text).toBe("Updated");
|
||||
});
|
||||
|
||||
// Fix 2: text parity normalization. snapshot.text is trimmed by the SDK, so a
|
||||
// trailing-whitespace-only difference between the op value and the snapshot must
|
||||
// not flag.
|
||||
it("does NOT false-mismatch trailing-whitespace-only text difference", async () => {
|
||||
const { sdkShadowDispatch } = await import("./sdkShadow");
|
||||
const session = await openComposition(BASE_HTML);
|
||||
|
||||
const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "World " }];
|
||||
const result = sdkShadowDispatch(session, "hf-box", ops);
|
||||
|
||||
expect(result.dispatched).toBe(true);
|
||||
expect(result.mismatches).toHaveLength(0); // trimmed both sides
|
||||
});
|
||||
|
||||
// Empty-string op value vs an absent (null) snapshot text must collapse to equal
|
||||
// — both mean "no text content".
|
||||
it("treats empty-string text op and null snapshot text as equal", async () => {
|
||||
const { sdkShadowDispatch } = await import("./sdkShadow");
|
||||
const EMPTY_HTML = /* html */ `<!DOCTYPE html>
|
||||
<html><body><img data-hf-id="hf-img" src="x.png" /></body></html>`;
|
||||
const session = await openComposition(EMPTY_HTML);
|
||||
|
||||
const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "" }];
|
||||
const result = sdkShadowDispatch(session, "hf-img", ops);
|
||||
|
||||
expect(result.dispatched).toBe(true);
|
||||
expect(result.mismatches).toHaveLength(0); // "" vs null → both null
|
||||
});
|
||||
|
||||
// Fix 3 verdict (REAL DIVERGENCE, not a readback artifact): the inline-style
|
||||
// read-back already reads only the AUTHORED style attribute (getElementStyles →
|
||||
// parseStyleAttr), never computed styles. The transform-origin divergence
|
||||
// (expected null actual "center center") was a genuine SDK bug — setStyle
|
||||
// removal of a HYPHENATED property silently no-opped because setElementStyles
|
||||
// deleted the kebab key while the style map is keyed camelCase. Now FIXED in
|
||||
// the SDK (model.ts setElementStyles normalizes the key via toCamel), so the
|
||||
// shadow sees parity: removal applies and there is no mismatch.
|
||||
it("reports clean removal of a hyphenated style (SDK setStyle kebab/camel fix)", async () => {
|
||||
const { sdkShadowDispatch } = await import("./sdkShadow");
|
||||
const TO_HTML = /* html */ `<!DOCTYPE html>
|
||||
<html><body><div data-hf-id="hf-box" style="transform-origin: center center">x</div></body></html>`;
|
||||
const session = await openComposition(TO_HTML);
|
||||
|
||||
const ops: PatchOperation[] = [
|
||||
{ type: "inline-style", property: "transform-origin", value: null },
|
||||
];
|
||||
const result = sdkShadowDispatch(session, "hf-box", ops);
|
||||
|
||||
// The SDK now removes the hyphenated property, so the shadow read-back agrees.
|
||||
expect(result.dispatched).toBe(true);
|
||||
expect(result.mismatches).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("applies attribute op and reads back via session.getElement", async () => {
|
||||
const { sdkShadowDispatch } = await import("./sdkShadow");
|
||||
const session = await openComposition(BASE_HTML);
|
||||
|
||||
const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }];
|
||||
sdkShadowDispatch(session, "hf-box", ops);
|
||||
|
||||
expect(session.getElement("hf-box")?.attributes["data-name"]).toBe("hero");
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("does NOT false-mismatch studio-internal data-hf-* marker attributes", async () => {
|
||||
const { sdkShadowDispatch } = await import("./sdkShadow");
|
||||
const session = await openComposition(BASE_HTML);
|
||||
|
||||
// path-offset drags emit these already-data-prefixed, SDK-excluded markers.
|
||||
const ops: PatchOperation[] = [
|
||||
{ type: "attribute", property: "data-hf-studio-path-offset", value: "true" },
|
||||
];
|
||||
const result = sdkShadowDispatch(session, "hf-box", ops);
|
||||
|
||||
expect(result.dispatched).toBe(true);
|
||||
expect(result.mismatches).toHaveLength(0); // filtered, not double-prefixed + flagged
|
||||
});
|
||||
|
||||
it("returns dispatch_error when dispatch throws — does not propagate", async () => {
|
||||
const { sdkShadowDispatch } = await import("./sdkShadow");
|
||||
const session = await openComposition(BASE_HTML);
|
||||
// Poison dispatch so it throws on any call
|
||||
session.dispatch = () => {
|
||||
throw new Error("sdk internal error");
|
||||
};
|
||||
|
||||
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "red" }];
|
||||
let result: ReturnType<typeof sdkShadowDispatch> | undefined;
|
||||
expect(() => {
|
||||
result = sdkShadowDispatch(session, "hf-box", ops);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(result!.dispatched).toBe(false);
|
||||
expect(result!.mismatches).toHaveLength(1);
|
||||
expect(result!.mismatches[0]).toMatchObject<SdkShadowMismatch>({
|
||||
kind: "dispatch_error",
|
||||
hfId: "hf-box",
|
||||
error: expect.stringContaining("sdk internal error"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const TIMING_HTML = /* html */ `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<div data-hf-id="hf-clip" data-start="0" data-duration="1" data-track="0">clip</div>
|
||||
</body></html>`;
|
||||
|
||||
const GSAP_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
||||
<div data-hf-id="hf-box" style="opacity:0"></div>
|
||||
<script>var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;</script>
|
||||
</div>`;
|
||||
|
||||
const NO_TIMELINE_HTML = `<div data-hf-id="hf-stage" data-hf-root>
|
||||
<div data-hf-id="hf-box"></div>
|
||||
<script>gsap.defaults({ ease: "power1.out" });
|
||||
window.__timelines = {};</script>
|
||||
</div>`;
|
||||
|
||||
describe("runShadowDelete", () => {
|
||||
it("removes the element from the SDK session and reports parity", async () => {
|
||||
const session = await openComposition(BASE_HTML);
|
||||
runShadowDelete(session, "hf-box");
|
||||
expect(session.getElement("hf-box")).toBeNull();
|
||||
expect(lastShadow()).toMatchObject({ op: "delete", dispatched: true, mismatchCount: 0 });
|
||||
});
|
||||
|
||||
it("reports no_hf_id when selection has no hf-id", async () => {
|
||||
const session = await openComposition(BASE_HTML);
|
||||
runShadowDelete(session, null);
|
||||
expect(lastShadow()).toMatchObject({ op: "delete", dispatched: false, reason: "no_hf_id" });
|
||||
});
|
||||
|
||||
it("reports cannot_dispatch when the element is not addressable", async () => {
|
||||
const session = await openComposition(BASE_HTML);
|
||||
runShadowDelete(session, "hf-missing");
|
||||
expect(lastShadow()).toMatchObject({
|
||||
op: "delete",
|
||||
dispatched: false,
|
||||
reason: "cannot_dispatch",
|
||||
});
|
||||
});
|
||||
|
||||
// Fix 4 verdict (REAL SDK id-resolution divergence, NOT a readback bug): when a
|
||||
// bare hf-id collides between a sub-composition element (scopedId
|
||||
// "hf-host/hf-dup") and a top-level sibling (scopedId "hf-dup"), removeElement
|
||||
// resolves the bare id via resolveScoped → querySelector (document-order-first,
|
||||
// removes the INNER instance), but getElement prefers the canonical top-level
|
||||
// match (scopedId === id) which SURVIVES. The shadow then correctly reports
|
||||
// expected "removed" / actual "present". The readback here is correct (it checks
|
||||
// the same id it dispatched); the fix belongs in the SDK's id resolution
|
||||
// (resolveScoped vs getElement agreement), not in this file.
|
||||
const DUP_ID_HTML = /* html */ `<!DOCTYPE html><html><body>
|
||||
<div data-hf-id="hf-root" data-hf-root>
|
||||
<div data-hf-id="hf-host" data-composition-file="sub.html">
|
||||
<div data-hf-id="hf-dup">inner</div>
|
||||
</div>
|
||||
<div data-hf-id="hf-dup">outer</div>
|
||||
</div>
|
||||
</body></html>`;
|
||||
|
||||
it("reports clean delete for a duplicate bare id (SDK resolves removeElement/getElement to the same instance)", async () => {
|
||||
const session = await openComposition(DUP_ID_HTML);
|
||||
runShadowDelete(session, "hf-dup");
|
||||
// SDK fix (agree removeElement/getElement on duplicate bare ids): both now
|
||||
// resolve a bare id to the canonical (top-level) instance, so removeElement
|
||||
// drops exactly the element the readback checks → no mismatch. (Previously
|
||||
// removeElement dropped the inner instance while the top-level survived,
|
||||
// which this shadow correctly flagged; that divergence is now fixed.)
|
||||
expect(lastShadow()).toMatchObject({ op: "delete", dispatched: true, mismatchCount: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runShadowTiming", () => {
|
||||
it("applies timing and reports parity against the snapshot", async () => {
|
||||
const session = await openComposition(TIMING_HTML);
|
||||
runShadowTiming(session, "hf-clip", { start: 2, duration: 3, trackIndex: 1 });
|
||||
const el = session.getElement("hf-clip");
|
||||
expect(el?.start).toBe(2);
|
||||
expect(el?.duration).toBe(3);
|
||||
expect(el?.trackIndex).toBe(1);
|
||||
expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
|
||||
});
|
||||
|
||||
// Fix 1: float-precision tolerance. The SDK computes durations arithmetically
|
||||
// (returning e.g. 3.0999999999999996); the server stores the rounded literal
|
||||
// (3.1). A relative epsilon must treat these as equal, while a real difference
|
||||
// still flags. A fake session returns the imprecise value on read-back.
|
||||
type FakeTiming = { start?: number; duration?: number; trackIndex?: number };
|
||||
function fakeTimingSession(readback: FakeTiming) {
|
||||
return {
|
||||
can: () => ({ ok: true }),
|
||||
batch: (fn: () => void) => fn(),
|
||||
dispatch: () => {},
|
||||
getElement: () => readback,
|
||||
} as unknown as Parameters<typeof runShadowTiming>[0];
|
||||
}
|
||||
|
||||
it("does NOT flag float-precision duration drift (3.1 vs 3.0999999999999996)", () => {
|
||||
const session = fakeTimingSession({ duration: 3.0999999999999996 });
|
||||
runShadowTiming(session, "hf-clip", { duration: 3.1 });
|
||||
expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
|
||||
});
|
||||
|
||||
it("does NOT flag float-precision start drift (21.36 vs 21.360000000000014)", () => {
|
||||
const session = fakeTimingSession({ start: 21.360000000000014 });
|
||||
runShadowTiming(session, "hf-clip", { start: 21.36 });
|
||||
expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
|
||||
});
|
||||
|
||||
it("STILL flags a real duration difference (3.1 vs 3.5)", () => {
|
||||
const session = fakeTimingSession({ duration: 3.5 });
|
||||
runShadowTiming(session, "hf-clip", { duration: 3.1 });
|
||||
expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runShadowGsapTween", () => {
|
||||
it("add reports success and the new tween lands on the target's animationIds", async () => {
|
||||
const session = await openComposition(GSAP_HTML);
|
||||
const before = session.getElement("hf-box")?.animationIds.length ?? 0;
|
||||
runShadowGsapTween(session, {
|
||||
kind: "add",
|
||||
target: "hf-box",
|
||||
tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
|
||||
});
|
||||
expect(session.getElement("hf-box")!.animationIds.length).toBe(before + 1);
|
||||
expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 });
|
||||
});
|
||||
|
||||
it("remove drops the tween from animationIds and reports parity", async () => {
|
||||
const session = await openComposition(GSAP_HTML);
|
||||
const animationId = session.getElement("hf-box")?.animationIds[0];
|
||||
expect(animationId).toBeDefined();
|
||||
runShadowGsapTween(session, { kind: "remove", animationId: animationId! });
|
||||
expect(session.getElement("hf-box")?.animationIds ?? []).not.toContain(animationId);
|
||||
expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 });
|
||||
});
|
||||
|
||||
it("reports cannot_dispatch (E_NO_GSAP_TIMELINE) when the script has no timeline", async () => {
|
||||
const session = await openComposition(NO_TIMELINE_HTML);
|
||||
runShadowGsapTween(session, {
|
||||
kind: "add",
|
||||
target: "hf-box",
|
||||
tween: { method: "to", properties: { x: 100 } },
|
||||
});
|
||||
expect(lastShadow()).toMatchObject({
|
||||
op: "gsap",
|
||||
dispatched: false,
|
||||
reason: "cannot_dispatch",
|
||||
code: "E_NO_GSAP_TIMELINE",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const SCRIPT_A = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
|
||||
window.__timelines["t"] = tl;`;
|
||||
|
||||
describe("gsapFidelityMismatches", () => {
|
||||
it("returns no mismatches for identical scripts", () => {
|
||||
expect(gsapFidelityMismatches(SCRIPT_A, SCRIPT_A)).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags a per-field value drift (duration)", () => {
|
||||
const drifted = SCRIPT_A.replace("duration: 0.5", "duration: 0.9");
|
||||
const mismatches = gsapFidelityMismatches(drifted, SCRIPT_A);
|
||||
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;`;
|
||||
const mismatches = gsapFidelityMismatches(empty, SCRIPT_A);
|
||||
expect(mismatches.some((m) => m.property === "tween")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT flag property key-order differences (canonical compare)", () => {
|
||||
const ab = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { x: 10, y: 20, duration: 0.5 }, 0);
|
||||
window.__timelines["t"] = tl;`;
|
||||
const ba = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { y: 20, x: 10, duration: 0.5 }, 0);
|
||||
window.__timelines["t"] = tl;`;
|
||||
expect(gsapFidelityMismatches(ab, ba)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does NOT flag number-vs-string-equivalent property values", () => {
|
||||
const numeric = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0);
|
||||
window.__timelines["t"] = tl;`;
|
||||
const stringy = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: "1", duration: 0.5 }, 0);
|
||||
window.__timelines["t"] = tl;`;
|
||||
expect(gsapFidelityMismatches(numeric, stringy)).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches the same element across different selector forms when a resolver is given", () => {
|
||||
// SDK writes [data-hf-id="hf-x"], server writes .x — same element, same tween.
|
||||
const sdk = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to("[data-hf-id=\\"hf-x\\"]", { x: 200, duration: 0.8 }, 0.5);
|
||||
window.__timelines["t"] = tl;`;
|
||||
const server = `var tl = gsap.timeline({ paused: true });
|
||||
tl.to(".x", { x: 200, duration: 0.8 }, 0.5);
|
||||
window.__timelines["t"] = tl;`;
|
||||
const resolve = (sel: string) => (/hf-x|\.x/.test(sel) ? "hf-x" : sel);
|
||||
// Without a resolver: selector-form divergence → present/absent mismatch.
|
||||
expect(gsapFidelityMismatches(sdk, server).length).toBeGreaterThan(0);
|
||||
// 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", () => {
|
||||
const BEFORE_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
|
||||
<div data-hf-id="hf-box" style="opacity:0"></div>
|
||||
<script>var tl = gsap.timeline({ paused: true });
|
||||
window.__timelines["t"] = tl;</script>
|
||||
</div>`;
|
||||
|
||||
it("reports zero mismatches when the SDK output matches the server script", async () => {
|
||||
// Produce the "server" script by applying the same op via the SDK, so a
|
||||
// faithful SDK writer must reproduce it exactly.
|
||||
const ref = await openComposition(BEFORE_HTML);
|
||||
const op = {
|
||||
kind: "add",
|
||||
target: "hf-box",
|
||||
tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
|
||||
} as const;
|
||||
ref.addGsapTween(op.target, op.tween);
|
||||
const serverScript =
|
||||
ref.serialize().match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? "";
|
||||
|
||||
await runShadowGsapFidelity(BEFORE_HTML, op, serverScript);
|
||||
expect(lastShadow()).toMatchObject({ op: "gsap_fidelity", dispatched: true, mismatchCount: 0 });
|
||||
});
|
||||
|
||||
it("reports mismatches when the server script diverges", async () => {
|
||||
const op = {
|
||||
kind: "add",
|
||||
target: "hf-box",
|
||||
tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
|
||||
} as const;
|
||||
const ref = await openComposition(BEFORE_HTML);
|
||||
ref.addGsapTween(op.target, op.tween);
|
||||
const serverScript = (
|
||||
ref.serialize().match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? ""
|
||||
).replace("100", "999");
|
||||
|
||||
await runShadowGsapFidelity(BEFORE_HTML, op, serverScript);
|
||||
const ev = lastShadow();
|
||||
expect(ev).toMatchObject({ op: "gsap_fidelity", dispatched: true });
|
||||
expect(ev?.mismatchCount as number).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveGsapFidelityArgs (chokepoint wiring)", () => {
|
||||
const op: ShadowGsapOp = { kind: "remove", animationId: "a-1" };
|
||||
const session = {} as object;
|
||||
|
||||
it("returns narrowed args when session, op, before, and serverScript are all present", () => {
|
||||
expect(resolveGsapFidelityArgs(session, op, "<html>before</html>", "tl.to(...)")).toEqual({
|
||||
before: "<html>before</html>",
|
||||
op,
|
||||
serverScript: "tl.to(...)",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when no session (shadow not wired)", () => {
|
||||
expect(resolveGsapFidelityArgs(null, op, "before", "script")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no shadowGsapOp (non-meta edit, e.g. property/keyframe)", () => {
|
||||
expect(resolveGsapFidelityArgs(session, undefined, "before", "script")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when serverScript is null (composition has no GSAP script)", () => {
|
||||
expect(resolveGsapFidelityArgs(session, op, "before", null)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when before is null", () => {
|
||||
expect(resolveGsapFidelityArgs(session, op, null, "script")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,517 +0,0 @@
|
||||
/**
|
||||
* SDK shadow dispatch utilities for Stage 7 Step 3b.
|
||||
*
|
||||
* Shadow mode keeps the server patch path authoritative while also dispatching
|
||||
* the equivalent op to the SDK session, then compares the result to detect
|
||||
* addressing gaps (blocker E: no-hf-id elements) and serialization drift
|
||||
* (blocker B: linkedom whole-doc serialize). Results are reported as structured
|
||||
* mismatches for telemetry — no user-visible change.
|
||||
*/
|
||||
|
||||
import type { Composition } from "@hyperframes/sdk";
|
||||
import type { EditOp, GsapTweenSpec } from "@hyperframes/sdk";
|
||||
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
import { trackStudioEvent } from "./studioTelemetry";
|
||||
import { relEqual } from "./sdkShadowNumeric";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import type { PatchOperation } from "./sourcePatcher";
|
||||
|
||||
// ─── Op mapping ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Map Studio PatchOperations for a given hf-id to SDK EditOps.
|
||||
*
|
||||
* Multiple inline-style ops are coalesced into a single setStyle (SDK batches
|
||||
* style changes naturally). One SDK op is emitted per non-style op.
|
||||
*/
|
||||
// "attribute" PatchOperations carry the data- attribute NAME. Studio passes
|
||||
// some already prefixed (e.g. "data-hf-studio-path-offset") and some bare
|
||||
// (e.g. "name"); prefix only when needed, never double-prefix.
|
||||
function attrName(property: string): string {
|
||||
return property.startsWith("data-") ? property : `data-${property}`;
|
||||
}
|
||||
|
||||
// The SDK element model excludes data-hf-* attributes (document.ts skips them),
|
||||
// so shadowing studio-internal markers (data-hf-studio-path-offset, etc.) can
|
||||
// never match — drop those ops from the shadow instead of false-mismatching.
|
||||
function isShadowableOp(op: PatchOperation): boolean {
|
||||
if (op.type === "attribute") return !attrName(op.property).startsWith("data-hf-");
|
||||
if (op.type === "html-attribute") return !op.property.startsWith("data-hf-");
|
||||
return true;
|
||||
}
|
||||
|
||||
// PatchOperation types patchOpsToSdkEditOps knows how to map. Used by
|
||||
// runShadowDispatch to flag any unmapped type as visible telemetry rather than
|
||||
// silently dropping it (see the unmapped_type guard there).
|
||||
const MAPPED_PATCH_OP_TYPES: ReadonlySet<string> = new Set([
|
||||
"inline-style",
|
||||
"text-content",
|
||||
"attribute",
|
||||
"html-attribute",
|
||||
]);
|
||||
|
||||
export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
|
||||
const result: EditOp[] = [];
|
||||
const styles: Record<string, string | null> = {};
|
||||
let hasStyles = false;
|
||||
|
||||
for (const op of ops) {
|
||||
if (op.type === "inline-style") {
|
||||
styles[op.property] = op.value;
|
||||
hasStyles = true;
|
||||
} else if (op.type === "text-content") {
|
||||
result.push({ type: "setText", target: hfId, value: op.value ?? "" });
|
||||
} else if (op.type === "attribute") {
|
||||
result.push({
|
||||
type: "setAttribute",
|
||||
target: hfId,
|
||||
name: attrName(op.property),
|
||||
value: op.value,
|
||||
});
|
||||
} else if (op.type === "html-attribute") {
|
||||
result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value });
|
||||
}
|
||||
// unknown op types produce no SDK op
|
||||
}
|
||||
|
||||
if (hasStyles) {
|
||||
result.unshift({ type: "setStyle", target: hfId, styles });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Shadow result types ──────────────────────────────────────────────────────
|
||||
|
||||
export interface SdkShadowMismatch {
|
||||
kind: "element_not_found" | "value_mismatch" | "dispatch_error";
|
||||
hfId: string;
|
||||
property?: string;
|
||||
expected?: string | null;
|
||||
actual?: string | null | undefined;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SdkShadowResult {
|
||||
/** False if the element was not found in the SDK session. */
|
||||
dispatched: boolean;
|
||||
mismatches: SdkShadowMismatch[];
|
||||
}
|
||||
|
||||
// ─── Shadow dispatch ──────────────────────────────────────────────────────────
|
||||
|
||||
type ElementSnapshot = ReturnType<Composition["getElement"]>;
|
||||
type OpFields = {
|
||||
property: string;
|
||||
expected: string | null | undefined;
|
||||
actual: string | null | undefined;
|
||||
};
|
||||
|
||||
type FlatSnapshot = {
|
||||
styles: Record<string, string | null>;
|
||||
attrs: Record<string, string | null>;
|
||||
text: string | null;
|
||||
};
|
||||
|
||||
function flattenSnapshot(snap: ElementSnapshot): FlatSnapshot {
|
||||
return {
|
||||
styles: snap?.inlineStyles ?? {},
|
||||
attrs: Object.fromEntries(
|
||||
Object.entries(snap?.attributes ?? {}).map(([k, v]) => [k, v ?? null]),
|
||||
),
|
||||
text: snap?.text ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
type OpFieldResolver = (op: PatchOperation, flat: FlatSnapshot) => OpFields;
|
||||
|
||||
// Snapshot inlineStyles are camelCase (CSSStyleDeclaration convention); PatchOperation
|
||||
// style properties are kebab-case ("background-color"). Convert for read-back, else
|
||||
// every hyphenated property false-mismatches against a null actual.
|
||||
function kebabToCamel(prop: string): string {
|
||||
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
||||
}
|
||||
|
||||
// Text parity: the SDK snapshot.text is trimmed, so trim the op value too.
|
||||
// An empty string and absent text (null) are treated as equivalent (collapsed
|
||||
// to null) so "" vs null does not flag — both mean "no text content".
|
||||
function normalizeText(value: string | null | undefined): string | null {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? null : trimmed;
|
||||
}
|
||||
|
||||
const OP_FIELD_RESOLVERS: Record<string, OpFieldResolver> = {
|
||||
"inline-style": (op, flat) => ({
|
||||
property: op.property,
|
||||
expected: op.value,
|
||||
actual: flat.styles[kebabToCamel(op.property)] ?? flat.styles[op.property] ?? null,
|
||||
}),
|
||||
// snapshot.text is already TRIMMED; trim the expected op value to match, so
|
||||
// trailing-whitespace differences don't flag. Empty-vs-absent ("" vs null) is
|
||||
// collapsed in checkOpParity. A genuinely different text value still flags.
|
||||
"text-content": (op, flat) => ({
|
||||
property: "text",
|
||||
expected: normalizeText(op.value),
|
||||
actual: normalizeText(flat.text),
|
||||
}),
|
||||
attribute: (op, flat) => ({
|
||||
property: attrName(op.property),
|
||||
expected: op.value ?? null,
|
||||
actual: flat.attrs[attrName(op.property)] ?? null,
|
||||
}),
|
||||
"html-attribute": (op, flat) => ({
|
||||
property: op.property,
|
||||
expected: op.value ?? null,
|
||||
actual: flat.attrs[op.property] ?? null,
|
||||
}),
|
||||
};
|
||||
|
||||
function resolveOpFields(op: PatchOperation, flat: FlatSnapshot): OpFields | null {
|
||||
return OP_FIELD_RESOLVERS[op.type]?.(op, flat) ?? null;
|
||||
}
|
||||
|
||||
function checkOpParity(
|
||||
op: PatchOperation,
|
||||
flat: FlatSnapshot,
|
||||
hfId: string,
|
||||
): SdkShadowMismatch | null {
|
||||
const fields = resolveOpFields(op, flat);
|
||||
if (!fields || fields.actual === fields.expected) return null;
|
||||
return { kind: "value_mismatch", hfId, ...fields };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch PatchOperations to the SDK session and return a parity report.
|
||||
*
|
||||
* If the element is not found by hfId, returns dispatched:false with a
|
||||
* element_not_found mismatch (signals blocker E — element has no hf-id or
|
||||
* SDK can't address it).
|
||||
*
|
||||
* On success, verifies that the SDK element snapshot reflects the applied
|
||||
* values. Value mismatches indicate serialization or normalization drift.
|
||||
*
|
||||
* **persist:error drift risk**: the HTTP adapter fires persist:error on
|
||||
* network failure but the SDK session is already mutated at that point. If
|
||||
* the server file was not updated (e.g. 503), subsequent shadow parity
|
||||
* comparisons here will see a diverged SDK session and produce false
|
||||
* positives. Before flipping STUDIO_SDK_DISPATCH_ENABLED, verify the shadow
|
||||
* window is clear of persist:error events.
|
||||
*/
|
||||
|
||||
export function sdkShadowDispatch(
|
||||
session: Composition,
|
||||
hfId: string,
|
||||
ops: PatchOperation[],
|
||||
): SdkShadowResult {
|
||||
if (!session.getElement(hfId)) {
|
||||
return { dispatched: false, mismatches: [{ kind: "element_not_found", hfId }] };
|
||||
}
|
||||
// Drop studio-internal markers the SDK model can't represent (data-hf-*), so
|
||||
// canvas-drag/path-offset edits don't false-mismatch on bookkeeping attrs.
|
||||
const shadowable = ops.filter(isShadowableOp);
|
||||
try {
|
||||
const sdkOps = patchOpsToSdkEditOps(hfId, shadowable);
|
||||
session.batch(() => {
|
||||
for (const op of sdkOps) session.dispatch(op);
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
dispatched: false,
|
||||
mismatches: [{ kind: "dispatch_error", hfId, error: String(err) }],
|
||||
};
|
||||
}
|
||||
const flat = flattenSnapshot(session.getElement(hfId));
|
||||
const mismatches = shadowable
|
||||
.map((op) => checkOpParity(op, flat, hfId))
|
||||
.filter((m): m is SdkShadowMismatch => m !== null);
|
||||
return { dispatched: true, mismatches };
|
||||
}
|
||||
|
||||
// ─── Telemetry reporting ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Shadow-dispatch ops to the SDK session and emit sdk_shadow_dispatch telemetry.
|
||||
* Despite the telemetry focus, this function does mutate the SDK session — it
|
||||
* is not read-only. No-op when STUDIO_SDK_SHADOW_ENABLED is false.
|
||||
*/
|
||||
// Property-path mismatches carry user content (inline-style values, edited
|
||||
// text) in expected/actual. Scrub before telemetry: fully redact text-content
|
||||
// values, length-cap the rest. The in-memory parity result keeps raw values.
|
||||
function redactValueForTelemetry(
|
||||
property: string | undefined,
|
||||
value: string | null | undefined,
|
||||
): string | null | undefined {
|
||||
if (value == null) return value;
|
||||
if (property === "text") return `[redacted len=${value.length}]`;
|
||||
return value.length > 64 ? `${value.slice(0, 64)}…` : value;
|
||||
}
|
||||
|
||||
function redactMismatchesForTelemetry(mismatches: SdkShadowMismatch[]): SdkShadowMismatch[] {
|
||||
return mismatches.map((m) => ({
|
||||
...m,
|
||||
expected: redactValueForTelemetry(m.property, m.expected),
|
||||
actual: redactValueForTelemetry(m.property, m.actual),
|
||||
}));
|
||||
}
|
||||
|
||||
export function runShadowDispatch(
|
||||
session: Composition,
|
||||
selection: DomEditSelection,
|
||||
ops: PatchOperation[],
|
||||
): void {
|
||||
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
||||
const hfId = selection.hfId;
|
||||
if (!hfId) {
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: "property",
|
||||
dispatched: false,
|
||||
reason: "no_hf_id",
|
||||
mismatchCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Defensive: patchOpsToSdkEditOps silently drops PatchOperation types it
|
||||
// doesn't map. PatchOperation.type is a closed union today, but emit a visible
|
||||
// unmapped_type event if a future type ever slips through, so the gap surfaces
|
||||
// in telemetry instead of vanishing.
|
||||
// Map to the type string before find, so a future unmapped type is read as a
|
||||
// plain string (no object cast; find on the closed union narrows to never).
|
||||
const unmappedType = ops.map((op) => op.type).find((t) => !MAPPED_PATCH_OP_TYPES.has(t));
|
||||
if (unmappedType !== undefined) {
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: "property",
|
||||
dispatched: false,
|
||||
reason: "unmapped_type",
|
||||
type: unmappedType,
|
||||
mismatchCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = sdkShadowDispatch(session, hfId, ops);
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: "property",
|
||||
dispatched: result.dispatched,
|
||||
mismatchCount: result.mismatches.length,
|
||||
mismatches: JSON.stringify(redactMismatchesForTelemetry(result.mismatches)),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Shadow for non-PatchOperation ops (delete / timing / GSAP) ───────────────
|
||||
//
|
||||
// These ops never flow through persistDomEditOperations, so the property-path
|
||||
// shadow above never sees them. Each runner keeps the server authoritative and
|
||||
// only observes the SDK: can() pre-checks addressing/validity (pure, no
|
||||
// mutation — works even for GSAP, which has no element-snapshot value), then a
|
||||
// dispatch into the live session with a snapshot-based parity check.
|
||||
//
|
||||
// Parity coverage by op:
|
||||
// delete → getElement(id) === null (full)
|
||||
// timing → snapshot.start/duration/trackIndex (full)
|
||||
// gsap → tween id present/absent in animationIds (existence only — the
|
||||
// tween's property values are script-level, not in the snapshot)
|
||||
|
||||
/**
|
||||
* can()-gated shadow dispatch. Emits sdk_shadow_dispatch tagged with `opLabel`.
|
||||
* Mutates the SDK session (not read-only); server stays authoritative.
|
||||
* No-op when STUDIO_SDK_SHADOW_ENABLED is false.
|
||||
*/
|
||||
function runShadowEditOp(
|
||||
session: Composition,
|
||||
op: EditOp,
|
||||
opLabel: string,
|
||||
dispatchAndCheck: () => SdkShadowMismatch[],
|
||||
): void {
|
||||
const verdict = session.can(op);
|
||||
if (!verdict.ok) {
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: opLabel,
|
||||
dispatched: false,
|
||||
reason: "cannot_dispatch",
|
||||
code: verdict.code,
|
||||
mismatchCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
let mismatches: SdkShadowMismatch[];
|
||||
try {
|
||||
mismatches = dispatchAndCheck();
|
||||
} catch (err) {
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: opLabel,
|
||||
dispatched: false,
|
||||
reason: "dispatch_error",
|
||||
error: String(err),
|
||||
mismatchCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: opLabel,
|
||||
dispatched: true,
|
||||
mismatchCount: mismatches.length,
|
||||
mismatches: JSON.stringify(mismatches),
|
||||
});
|
||||
}
|
||||
|
||||
/** Shadow an element delete. Parity: the element is gone from the SDK session. */
|
||||
export function runShadowDelete(session: Composition, hfId: string | null | undefined): void {
|
||||
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
||||
if (!hfId) {
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: "delete",
|
||||
dispatched: false,
|
||||
reason: "no_hf_id",
|
||||
mismatchCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const op: EditOp = { type: "removeElement", target: hfId };
|
||||
runShadowEditOp(session, op, "delete", () => {
|
||||
session.batch(() => session.dispatch(op));
|
||||
return session.getElement(hfId)
|
||||
? [
|
||||
{
|
||||
kind: "value_mismatch",
|
||||
hfId,
|
||||
property: "exists",
|
||||
expected: "removed",
|
||||
actual: "present",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
export interface ShadowTiming {
|
||||
start?: number;
|
||||
duration?: number;
|
||||
trackIndex?: number;
|
||||
}
|
||||
|
||||
// start/duration tolerate float-precision drift (SDK computes them
|
||||
// arithmetically, server stores a rounded literal) via the shared relative
|
||||
// epsilon; trackIndex (integer track slot) is compared exactly.
|
||||
function timingFieldEqual(
|
||||
key: keyof ShadowTiming,
|
||||
actual: number | null | undefined,
|
||||
expected: number,
|
||||
): boolean {
|
||||
if (typeof actual === "number" && key !== "trackIndex") {
|
||||
return relEqual(actual, expected);
|
||||
}
|
||||
return actual === expected;
|
||||
}
|
||||
|
||||
/** Shadow a timing edit. Parity: snapshot start/duration/trackIndex match. */
|
||||
export function runShadowTiming(
|
||||
session: Composition,
|
||||
hfId: string | null | undefined,
|
||||
timing: ShadowTiming,
|
||||
): void {
|
||||
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
||||
if (!hfId) {
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: "timing",
|
||||
dispatched: false,
|
||||
reason: "no_hf_id",
|
||||
mismatchCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const op: EditOp = { type: "setTiming", target: hfId, ...timing };
|
||||
runShadowEditOp(session, op, "timing", () => {
|
||||
session.batch(() => session.dispatch(op));
|
||||
const el = session.getElement(hfId);
|
||||
const mismatches: SdkShadowMismatch[] = [];
|
||||
const fields: Array<[keyof ShadowTiming, number | null | undefined]> = [
|
||||
["start", el?.start],
|
||||
["duration", el?.duration],
|
||||
["trackIndex", el?.trackIndex],
|
||||
];
|
||||
for (const [key, actual] of fields) {
|
||||
const expected = timing[key];
|
||||
if (expected === undefined || timingFieldEqual(key, actual, expected)) continue;
|
||||
mismatches.push({
|
||||
kind: "value_mismatch",
|
||||
hfId,
|
||||
property: key,
|
||||
expected: String(expected),
|
||||
actual: actual == null ? null : String(actual),
|
||||
});
|
||||
}
|
||||
return mismatches;
|
||||
});
|
||||
}
|
||||
|
||||
export type ShadowGsapOp =
|
||||
| { kind: "add"; target: string; tween: GsapTweenSpec }
|
||||
| { kind: "set"; animationId: string; properties: Partial<GsapTweenSpec> }
|
||||
| { kind: "remove"; animationId: string };
|
||||
|
||||
/**
|
||||
* Shadow a GSAP tween mutation (add / set / remove). The server's animationId
|
||||
* shares the SDK's id-space (both derive `targetSelector-method-position` from
|
||||
* the same acorn parser — see sdk assignStableIds), so it is dispatchable as-is.
|
||||
*
|
||||
* Parity via the now-populated ElementSnapshot.animationIds:
|
||||
* add → the returned tween id is present on the target element
|
||||
* remove → the id is gone from every element
|
||||
* set → existence only (the SDK exposes no per-tween property reader; value
|
||||
* fidelity would need serialize()-script round-trip diffing).
|
||||
*/
|
||||
export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp): void {
|
||||
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
||||
const op: EditOp =
|
||||
gsapOp.kind === "add"
|
||||
? { type: "addGsapTween", target: gsapOp.target, tween: gsapOp.tween }
|
||||
: gsapOp.kind === "set"
|
||||
? { type: "setGsapTween", animationId: gsapOp.animationId, properties: gsapOp.properties }
|
||||
: { type: "removeGsapTween", animationId: gsapOp.animationId };
|
||||
// fallow-ignore-next-line complexity
|
||||
runShadowEditOp(session, op, "gsap", () => {
|
||||
let newId: string | undefined;
|
||||
session.batch(() => {
|
||||
if (gsapOp.kind === "add") newId = session.addGsapTween(gsapOp.target, gsapOp.tween);
|
||||
else session.dispatch(op);
|
||||
});
|
||||
if (gsapOp.kind === "add") {
|
||||
const onTarget = session.getElement(gsapOp.target)?.animationIds ?? [];
|
||||
if (!newId || !onTarget.includes(newId)) {
|
||||
return [
|
||||
{
|
||||
kind: "value_mismatch",
|
||||
hfId: gsapOp.target,
|
||||
property: "animationIds",
|
||||
expected: newId ?? "non-empty",
|
||||
actual: onTarget.join(",") || null,
|
||||
},
|
||||
];
|
||||
}
|
||||
} else if (gsapOp.kind === "remove") {
|
||||
const stillPresent = session
|
||||
.getElements()
|
||||
.some((el) => el.animationIds.includes(gsapOp.animationId));
|
||||
if (stillPresent) {
|
||||
return [
|
||||
{
|
||||
kind: "value_mismatch",
|
||||
hfId: gsapOp.animationId,
|
||||
property: "animationIds",
|
||||
expected: "removed",
|
||||
actual: "present",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
// GSAP value-fidelity diff lives in its own module to keep this file under the
|
||||
// 600-line studio cap; re-exported here so the shadow surface stays in one place.
|
||||
export {
|
||||
gsapFidelityMismatches,
|
||||
resolveGsapFidelityArgs,
|
||||
runShadowGsapFidelity,
|
||||
} from "./sdkShadowGsapFidelity";
|
||||
@@ -1,296 +0,0 @@
|
||||
/**
|
||||
* GSAP value-fidelity shadow (serialize round-trip diff). Split out of
|
||||
* sdkShadow.ts to keep that file under the 600-line studio cap.
|
||||
*
|
||||
* Existence parity (sdkShadow.ts) confirms a tween was created/removed, but not
|
||||
* that its VALUES (duration / ease / position / properties) match the server.
|
||||
* The SDK exposes no per-tween property reader, so we compare the two writers'
|
||||
* output: apply the same op to a fresh SDK doc opened from the server's pre-op
|
||||
* file, then structurally diff the SDK's GSAP script against the server's
|
||||
* resulting script. Both are re-parsed, so formatting/whitespace differences
|
||||
* never produce false positives — only real value drift does.
|
||||
*/
|
||||
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
import { trackStudioEvent } from "./studioTelemetry";
|
||||
import { relEqual } from "./sdkShadowNumeric";
|
||||
import type { SdkShadowMismatch, ShadowGsapOp } from "./sdkShadow";
|
||||
|
||||
// Marker set must match document.ts extractGsapScript so both pick the same
|
||||
// <script> from any given composition.
|
||||
function isGsapScriptBody(body: string): boolean {
|
||||
return body.includes("gsap") || body.includes("__timelines") || body.includes("ScrollTrigger");
|
||||
}
|
||||
|
||||
export function extractGsapScript(html: string): string | null {
|
||||
// Close tag is `</script[^>]*>` (not just `</script>`) — HTML5 ignores junk
|
||||
// before the `>`, e.g. `</script >` or `</script foo>` (CodeQL js/bad-tag-filter).
|
||||
const scripts = html.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi);
|
||||
if (!scripts) return null;
|
||||
for (const block of scripts) {
|
||||
const body = block.replace(/^<script\b[^>]*>/i, "").replace(/<\/script[^>]*>$/i, "");
|
||||
if (isGsapScriptBody(body)) return body;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function posKey(position: unknown): string {
|
||||
if (typeof position === "number") return String(position);
|
||||
const n = Number(position);
|
||||
return Number.isNaN(n) ? String(position) : String(n);
|
||||
}
|
||||
|
||||
// Key a tween by its RESOLVED target element (not raw selector) + method +
|
||||
// position. The SDK writer emits [data-hf-id="X"] selectors while the server
|
||||
// emits class/other selectors for the SAME element; keying by resolved element
|
||||
// matches them so the diff compares values instead of flagging present/absent.
|
||||
//
|
||||
// ponytail: one-tween-per-(element, method, position) assumption — coincident
|
||||
// tweens (same element+method+position, different props) collapse, last wins,
|
||||
// so the diff under-reports them. Props can't go in the key (a matched pair
|
||||
// must share a key for the field-diff to run; raw props would split real value
|
||||
// drift into present/absent). Not seen in studio-emitted templates; add a
|
||||
// property-NAME hash to the key if coincident tweens show up in the wild.
|
||||
function tweenKey(anim: GsapAnimation, resolveSelector?: (sel: string) => string): string {
|
||||
const sel = resolveSelector ? resolveSelector(anim.targetSelector) : anim.targetSelector;
|
||||
return `${sel}|${anim.method}|${posKey(anim.position)}`;
|
||||
}
|
||||
|
||||
function animByKey(
|
||||
script: string,
|
||||
resolveSelector?: (sel: string) => string,
|
||||
): Map<string, GsapAnimation> {
|
||||
const map = new Map<string, GsapAnimation>();
|
||||
const parsed = parseGsapScriptAcorn(script);
|
||||
for (const anim of parsed.animations) map.set(tweenKey(anim, resolveSelector), anim);
|
||||
return map;
|
||||
}
|
||||
|
||||
// The server (addAnimationToScript) and SDK (gsapWriterAcorn) are DIFFERENT
|
||||
// writers, so the same tween can serialize with different property key order or
|
||||
// number-vs-string forms. Compare canonically — sort keys, coerce numeric
|
||||
// strings — so only real value drift registers, not formatting differences.
|
||||
|
||||
// Coerce string operands to numbers, then compare with the shared relative
|
||||
// epsilon (relEqual) so float-formatting noise (3.1 vs 3.0999999999999996)
|
||||
// isn't flagged as drift while a real 2 vs 1 still is.
|
||||
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;
|
||||
if (typeof na !== "number" || typeof nb !== "number" || Number.isNaN(na) || Number.isNaN(nb)) {
|
||||
return false;
|
||||
}
|
||||
return relEqual(na, nb);
|
||||
}
|
||||
|
||||
function canonicalProps(obj: Record<string, unknown> | undefined): string {
|
||||
if (!obj) return "{}";
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(obj).sort()) {
|
||||
const v = obj[key];
|
||||
// normalize "0.5" → 0.5 so a number/string writer difference isn't drift
|
||||
out[key] = typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : v;
|
||||
}
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Structurally diff two GSAP scripts. Tweens are matched by resolved target
|
||||
* element + method + position (see tweenKey), so the SDK's [data-hf-id]
|
||||
* selectors and the server's class selectors for the same element don't
|
||||
* false-flag present/absent. Reports a tween present in one but not the other,
|
||||
* and per-field value drift (duration, ease, properties, fromProperties).
|
||||
* Comparison is canonical so writer formatting differences don't register.
|
||||
*
|
||||
* Pass resolveSelector (selector → canonical element id) to enable the
|
||||
* element-based matching; without it, matching falls back to raw selector.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
export function gsapFidelityMismatches(
|
||||
sdkScript: string,
|
||||
serverScript: string,
|
||||
resolveSelector?: (sel: string) => string,
|
||||
): SdkShadowMismatch[] {
|
||||
const sdk = animByKey(sdkScript, resolveSelector);
|
||||
const server = animByKey(serverScript, resolveSelector);
|
||||
const mismatches: SdkShadowMismatch[] = [];
|
||||
const keys = new Set([...sdk.keys(), ...server.keys()]);
|
||||
for (const key of keys) {
|
||||
const a = sdk.get(key);
|
||||
const b = server.get(key);
|
||||
if (!a || !b) {
|
||||
mismatches.push({
|
||||
kind: "value_mismatch",
|
||||
hfId: key,
|
||||
property: "tween",
|
||||
expected: b ? "present" : "absent",
|
||||
actual: a ? "present" : "absent",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// method + position are part of the key (already equal); compare values.
|
||||
const fields: Array<[string, unknown, unknown, boolean]> = [
|
||||
["duration", a.duration, b.duration, numericEqual(a.duration, b.duration)],
|
||||
["ease", a.ease, b.ease, a.ease === b.ease],
|
||||
[
|
||||
"properties",
|
||||
a.properties,
|
||||
b.properties,
|
||||
canonicalProps(a.properties) === canonicalProps(b.properties),
|
||||
],
|
||||
[
|
||||
"fromProperties",
|
||||
a.fromProperties,
|
||||
b.fromProperties,
|
||||
canonicalProps(a.fromProperties) === canonicalProps(b.fromProperties),
|
||||
],
|
||||
];
|
||||
for (const [property, av, bv, equal] of fields) {
|
||||
if (!equal) {
|
||||
mismatches.push({
|
||||
kind: "value_mismatch",
|
||||
hfId: key,
|
||||
property,
|
||||
expected: bv == null ? null : JSON.stringify(bv),
|
||||
actual: av == null ? null : JSON.stringify(av),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
export interface GsapFidelityArgs {
|
||||
before: string;
|
||||
op: ShadowGsapOp;
|
||||
serverScript: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wiring gate for the commitMutation chokepoint: return the narrowed fidelity
|
||||
* args only when there is a live session, a typed shadow op, and both the
|
||||
* pre-op file and the server's resulting script to diff against (scriptText is
|
||||
* null when the composition has no GSAP script). Returns null otherwise. Pure +
|
||||
* narrowing so the wiring decision is unit-testable without rendering the hook
|
||||
* and the caller needs no non-null assertions.
|
||||
*/
|
||||
export function resolveGsapFidelityArgs(
|
||||
sdkSession: unknown,
|
||||
shadowGsapOp: ShadowGsapOp | undefined,
|
||||
before: string | null | undefined,
|
||||
serverScript: string | null | undefined,
|
||||
): GsapFidelityArgs | null {
|
||||
if (!sdkSession || !shadowGsapOp || before == null || serverScript == null) return null;
|
||||
return { before, op: shadowGsapOp, serverScript };
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
const el = doc.querySelector(sel);
|
||||
return el ? keyForNode(el) : sel;
|
||||
} catch {
|
||||
return sel;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shadow GSAP value fidelity: open a fresh SDK doc from the server's pre-op
|
||||
* file, apply the same tween op, serialize, and diff the SDK's GSAP script
|
||||
* against the server's resulting script. Emits sdk_shadow_dispatch op:
|
||||
* "gsap_fidelity". Async, fire-and-forget; server stays authoritative.
|
||||
*/
|
||||
export async function runShadowGsapFidelity(
|
||||
beforeHtml: string,
|
||||
gsapOp: ShadowGsapOp,
|
||||
serverScript: string,
|
||||
): Promise<void> {
|
||||
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
||||
// No server script to diff against → skip the (costly) openComposition.
|
||||
if (!serverScript || !beforeHtml) return;
|
||||
try {
|
||||
const session = await openComposition(beforeHtml);
|
||||
session.batch(() => {
|
||||
if (gsapOp.kind === "add") session.addGsapTween(gsapOp.target, gsapOp.tween);
|
||||
else if (gsapOp.kind === "set") session.setGsapTween(gsapOp.animationId, gsapOp.properties);
|
||||
else session.removeGsapTween(gsapOp.animationId);
|
||||
});
|
||||
const sdkScript = extractGsapScript(session.serialize());
|
||||
if (sdkScript == null) {
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: "gsap_fidelity",
|
||||
dispatched: false,
|
||||
reason: "no_sdk_script",
|
||||
mismatchCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const mismatches = gsapFidelityMismatches(
|
||||
sdkScript,
|
||||
serverScript,
|
||||
makeSelectorResolver(beforeHtml),
|
||||
);
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: "gsap_fidelity",
|
||||
dispatched: true,
|
||||
mismatchCount: mismatches.length,
|
||||
mismatches: JSON.stringify(mismatches),
|
||||
});
|
||||
} catch (err) {
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: "gsap_fidelity",
|
||||
dispatched: false,
|
||||
reason: "fidelity_error",
|
||||
error: String(err),
|
||||
mismatchCount: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
import {
|
||||
resolveKeyframeIndexByPercentage,
|
||||
keyframeOpToEditOp,
|
||||
gsapKeyframeFidelityMismatches,
|
||||
runShadowGsapKeyframeFidelity,
|
||||
type ShadowKeyframeOp,
|
||||
} from "./sdkShadowGsapKeyframe";
|
||||
import { runShadowDispatch } from "./sdkShadow";
|
||||
import type { PatchOperation } from "./sourcePatcher";
|
||||
|
||||
// Capture sdk_shadow_dispatch telemetry.
|
||||
const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
|
||||
vi.mock("./studioTelemetry", () => ({
|
||||
trackStudioEvent: (event: string, props: Record<string, unknown>) =>
|
||||
trackedEvents.push({ event, props }),
|
||||
}));
|
||||
// STUDIO_SDK_SHADOW_ENABLED defaults true (no env override in test), so the
|
||||
// runners are active here without mocking the availability module.
|
||||
|
||||
beforeEach(() => {
|
||||
trackedEvents.length = 0;
|
||||
});
|
||||
const lastShadow = () =>
|
||||
trackedEvents.filter((e) => e.event === "sdk_shadow_dispatch").at(-1)?.props;
|
||||
|
||||
const ANIM_ID = "#hero-to-0-position";
|
||||
|
||||
function gsapHtml(scriptBody: string): string {
|
||||
return /* html */ `<!DOCTYPE html><html><body>
|
||||
<div data-hf-id="hf-hero" id="hero" class="clip">x</div>
|
||||
<script>
|
||||
${scriptBody}
|
||||
window.__timelines = [tl];
|
||||
</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
const KF_SCRIPT = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200 } }, duration: 5 }, 0);`;
|
||||
|
||||
// A script body string (not full HTML) for the index-resolution helpers.
|
||||
const KF_SCRIPT_BODY = KF_SCRIPT;
|
||||
const DUP_SCRIPT_BODY = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "50%": { x: 150 }, "100%": { x: 200 } }, duration: 5 }, 0);`;
|
||||
|
||||
describe("resolveKeyframeIndexByPercentage", () => {
|
||||
it("resolves a unique percentage to its 0-based index", () => {
|
||||
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 50)).toEqual({
|
||||
keyframeIndex: 1,
|
||||
});
|
||||
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 100)).toEqual({
|
||||
keyframeIndex: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("matches within ~0.001 tolerance", () => {
|
||||
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 50.0005).keyframeIndex).toBe(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null with not_found when no percentage matches", () => {
|
||||
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 33)).toEqual({
|
||||
keyframeIndex: null,
|
||||
reason: "not_found",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null with no_keyframes for an unknown animation", () => {
|
||||
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, "#nope-to-0", 50)).toEqual({
|
||||
keyframeIndex: null,
|
||||
reason: "no_keyframes",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null with no_keyframes when script is empty", () => {
|
||||
expect(resolveKeyframeIndexByPercentage(null, ANIM_ID, 50).reason).toBe("no_keyframes");
|
||||
});
|
||||
|
||||
it("no-ops on ambiguity (duplicate-percentage keyframes — PR #1498 landmine)", () => {
|
||||
expect(resolveKeyframeIndexByPercentage(DUP_SCRIPT_BODY, ANIM_ID, 50)).toEqual({
|
||||
keyframeIndex: null,
|
||||
reason: "ambiguous",
|
||||
});
|
||||
});
|
||||
|
||||
// Regression: a from/fromTo tween's id may normalize to "-to-" on write, so a
|
||||
// "-from-"/"-fromTo-" animationId must fall back to the converted id (matching
|
||||
// the writer's locateAnimationWithFallback) — else the keyframe diff goes blind.
|
||||
it("falls back from a -from- id to the -to- tween", () => {
|
||||
const fromId = ANIM_ID.replace("-to-", "-from-");
|
||||
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, fromId, 50)).toEqual({
|
||||
keyframeIndex: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyframeOpToEditOp", () => {
|
||||
it("maps add → addGsapKeyframe with position = percentage", () => {
|
||||
const op: ShadowKeyframeOp = {
|
||||
kind: "add",
|
||||
animationId: ANIM_ID,
|
||||
percentage: 25,
|
||||
properties: { x: 50 },
|
||||
};
|
||||
expect(keyframeOpToEditOp(op, KF_SCRIPT_BODY)).toEqual({
|
||||
op: { type: "addGsapKeyframe", animationId: ANIM_ID, position: 25, value: { x: 50 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps remove → removeGsapKeyframe with resolved index", () => {
|
||||
const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
|
||||
expect(keyframeOpToEditOp(op, KF_SCRIPT_BODY)).toEqual({
|
||||
op: { type: "removeGsapKeyframe", animationId: ANIM_ID, keyframeIndex: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null op + reason when remove percentage is ambiguous", () => {
|
||||
const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
|
||||
expect(keyframeOpToEditOp(op, DUP_SCRIPT_BODY)).toEqual({ op: null, reason: "ambiguous" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("gsapKeyframeFidelityMismatches", () => {
|
||||
it("reports no mismatches when keyframe arrays match", () => {
|
||||
expect(gsapKeyframeFidelityMismatches(KF_SCRIPT_BODY, KF_SCRIPT_BODY, ANIM_ID)).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports a keyframes mismatch when arrays diverge", () => {
|
||||
const other = `
|
||||
const tl = gsap.timeline({ paused: true });
|
||||
tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 999 }, "100%": { x: 200 } }, duration: 5 }, 0);`;
|
||||
const mismatches = gsapKeyframeFidelityMismatches(KF_SCRIPT_BODY, other, ANIM_ID);
|
||||
expect(mismatches.some((m) => m.property === "keyframes")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runShadowGsapKeyframeFidelity (add)", () => {
|
||||
it("emits gsap_keyframe with a keyframes mismatch when SDK adds but server didn't", async () => {
|
||||
const beforeHtml = gsapHtml(KF_SCRIPT);
|
||||
// server script unchanged (server "failed" to add the 25% keyframe) → drift
|
||||
const session = await openComposition(beforeHtml);
|
||||
const serverScript = session
|
||||
.serialize()
|
||||
.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1];
|
||||
expect(serverScript).toBeTruthy();
|
||||
const op: ShadowKeyframeOp = {
|
||||
kind: "add",
|
||||
animationId: ANIM_ID,
|
||||
percentage: 25,
|
||||
properties: { x: 50 },
|
||||
};
|
||||
await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript);
|
||||
const props = lastShadow();
|
||||
expect(props?.op).toBe("gsap_keyframe");
|
||||
expect(props?.dispatched).toBe(true);
|
||||
expect(props?.mismatchCount).toBe(1);
|
||||
});
|
||||
|
||||
it("emits dispatched:true mismatchCount:0 when SDK and server agree", async () => {
|
||||
const beforeHtml = gsapHtml(KF_SCRIPT);
|
||||
// Build the server's resulting script by applying the same op via the SDK.
|
||||
const serverSession = await openComposition(beforeHtml);
|
||||
serverSession.batch(() =>
|
||||
serverSession.dispatch({
|
||||
type: "addGsapKeyframe",
|
||||
animationId: ANIM_ID,
|
||||
position: 25,
|
||||
value: { x: 50 },
|
||||
}),
|
||||
);
|
||||
const serverScript = serverSession
|
||||
.serialize()
|
||||
.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1];
|
||||
const op: ShadowKeyframeOp = {
|
||||
kind: "add",
|
||||
animationId: ANIM_ID,
|
||||
percentage: 25,
|
||||
properties: { x: 50 },
|
||||
};
|
||||
await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript);
|
||||
const props = lastShadow();
|
||||
expect(props?.op).toBe("gsap_keyframe");
|
||||
expect(props?.dispatched).toBe(true);
|
||||
expect(props?.mismatchCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runShadowGsapKeyframeFidelity (remove)", () => {
|
||||
it("no-ops with reason when remove percentage is ambiguous", async () => {
|
||||
const beforeHtml = gsapHtml(DUP_SCRIPT_BODY);
|
||||
const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
|
||||
await runShadowGsapKeyframeFidelity(beforeHtml, op, "non-empty-server-script gsap");
|
||||
const props = lastShadow();
|
||||
expect(props?.op).toBe("gsap_keyframe");
|
||||
expect(props?.dispatched).toBe(false);
|
||||
expect(props?.reason).toBe("ambiguous");
|
||||
});
|
||||
|
||||
it("dispatches a resolved remove and diffs", async () => {
|
||||
const beforeHtml = gsapHtml(KF_SCRIPT);
|
||||
const serverSession = await openComposition(beforeHtml);
|
||||
serverSession.batch(() =>
|
||||
serverSession.dispatch({
|
||||
type: "removeGsapKeyframe",
|
||||
animationId: ANIM_ID,
|
||||
keyframeIndex: 1,
|
||||
}),
|
||||
);
|
||||
const serverScript = serverSession
|
||||
.serialize()
|
||||
.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1];
|
||||
const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
|
||||
await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript);
|
||||
const props = lastShadow();
|
||||
expect(props?.op).toBe("gsap_keyframe");
|
||||
expect(props?.dispatched).toBe(true);
|
||||
expect(props?.mismatchCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runShadowGsapKeyframeFidelity (guards)", () => {
|
||||
it("skips when there is no server script", async () => {
|
||||
const op: ShadowKeyframeOp = {
|
||||
kind: "add",
|
||||
animationId: ANIM_ID,
|
||||
percentage: 25,
|
||||
properties: { x: 50 },
|
||||
};
|
||||
await runShadowGsapKeyframeFidelity(gsapHtml(KF_SCRIPT), op, null);
|
||||
expect(lastShadow()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runShadowDispatch unmapped-type guard", () => {
|
||||
const ELEMENT_HTML = /* html */ `<!DOCTYPE html><html><body>
|
||||
<div data-hf-id="hf-box" style="color: red;">Hi</div>
|
||||
</body></html>`;
|
||||
|
||||
it("emits unmapped_type when a PatchOperation type isn't mapped", async () => {
|
||||
const session = await openComposition(ELEMENT_HTML);
|
||||
// PatchOperation.type is a closed union today; cast to exercise the defensive
|
||||
// guard for a future unmapped type.
|
||||
const ops = [{ type: "future-op", property: "x", value: "1" } as unknown as PatchOperation];
|
||||
runShadowDispatch(session, { hfId: "hf-box" } as never, ops);
|
||||
const props = lastShadow();
|
||||
expect(props?.op).toBe("property");
|
||||
expect(props?.dispatched).toBe(false);
|
||||
expect(props?.reason).toBe("unmapped_type");
|
||||
expect(props?.type).toBe("future-op");
|
||||
});
|
||||
|
||||
it("dispatches normally for known PatchOperation types", async () => {
|
||||
const session = await openComposition(ELEMENT_HTML);
|
||||
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
|
||||
runShadowDispatch(session, { hfId: "hf-box" } as never, ops);
|
||||
const props = lastShadow();
|
||||
expect(props?.dispatched).toBe(true);
|
||||
expect(props?.reason).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,257 +0,0 @@
|
||||
/**
|
||||
* GSAP keyframe-op shadow (serialize round-trip diff). New module for the Stage 7
|
||||
* shadow-parity push — kept out of sdkShadow.ts / sdkShadowGsapFidelity.ts so the
|
||||
* shared files stay untouched (only additive imports) and the studio 600-line cap
|
||||
* holds.
|
||||
*
|
||||
* Unlike tweens, the SDK exposes NO keyframe reader on ElementSnapshot, so there
|
||||
* is no existence-parity path here. Instead we compare the two writers' output:
|
||||
* open a fresh SDK doc from the server's pre-op file, dispatch the equivalent
|
||||
* keyframe op, serialize, and diff the SDK's GSAP script against the server's
|
||||
* resulting script.
|
||||
*
|
||||
* gsapFidelityMismatches (reused) matches tweens by resolved target element +
|
||||
* method + position and diffs tween-level fields — but it does NOT look inside a
|
||||
* tween's `keyframes` array. Keyframe drift therefore needs a dedicated diff,
|
||||
* layered on top of the reused tween-level diff, matched by the GSAP animation id.
|
||||
*
|
||||
* SDK mapping (main, pre PR #1498 percentage-variant):
|
||||
* add → addGsapKeyframe{animationId, position: percentage, value: properties}
|
||||
* remove → removeGsapKeyframe{animationId, keyframeIndex} — the studio op is
|
||||
* percentage-based, so we resolve percentage → index against the pre-op
|
||||
* script (KF_PERCENT_TOLERANCE, aligned with the writer ~0.001) and
|
||||
* no-op on ambiguity (duplicate-percentage keyframes can't be told
|
||||
* apart by percentage — landmine from PR #1498).
|
||||
*/
|
||||
|
||||
import { openComposition } from "@hyperframes/sdk";
|
||||
import type { EditOp } from "@hyperframes/sdk";
|
||||
import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
|
||||
import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
|
||||
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
|
||||
import { trackStudioEvent } from "./studioTelemetry";
|
||||
import type { SdkShadowMismatch } from "./sdkShadow";
|
||||
import {
|
||||
extractGsapScript,
|
||||
gsapFidelityMismatches,
|
||||
makeSelectorResolver,
|
||||
} from "./sdkShadowGsapFidelity";
|
||||
|
||||
// Match the GSAP writer's percentage equality tolerance so a remove resolves to
|
||||
// the same keyframe the server would pick (writer rounds to ~3 decimals).
|
||||
const KF_PERCENT_TOLERANCE = 0.001;
|
||||
|
||||
export type ShadowKeyframeOp =
|
||||
| {
|
||||
kind: "add";
|
||||
animationId: string;
|
||||
percentage: number;
|
||||
properties: Record<string, number | string>;
|
||||
}
|
||||
| { kind: "remove"; animationId: string; percentage: number };
|
||||
|
||||
// ─── percentage → SDK op mapping ──────────────────────────────────────────────
|
||||
|
||||
function findAnimationKeyframes(
|
||||
script: string,
|
||||
animationId: string,
|
||||
): GsapPercentageKeyframe[] | null {
|
||||
const parsed = parseGsapScriptAcorn(script);
|
||||
// Match the writer's locateAnimationWithFallback (gsapParser.ts): a from/fromTo
|
||||
// tween's derived id may be normalized to "-to-" on write, so fall back to the
|
||||
// converted id when the exact one isn't found — otherwise the keyframe diff
|
||||
// goes blind (both scripts resolve null → falsely "clean") on converted tweens.
|
||||
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
|
||||
const anim =
|
||||
parsed.animations.find((a) => a.id === animationId) ??
|
||||
parsed.animations.find((a) => a.id === convertedId);
|
||||
return anim?.keyframes?.keyframes ?? null;
|
||||
}
|
||||
|
||||
export interface KeyframeRemoveResolution {
|
||||
/** Resolved 0-based index, or null when it can't be safely resolved. */
|
||||
keyframeIndex: number | null;
|
||||
/** Why no index — for telemetry when keyframeIndex is null. */
|
||||
reason?: "no_keyframes" | "not_found" | "ambiguous";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a percentage-based remove to a keyframe index against the pre-op
|
||||
* script. Returns null index (with a reason) when there are no keyframes, the
|
||||
* percentage matches none, or — per the PR #1498 landmine — more than one
|
||||
* keyframe shares the percentage (can't be disambiguated by percentage alone).
|
||||
* Pure + exported so the mapping is unit-testable without an SDK session.
|
||||
*/
|
||||
export function resolveKeyframeIndexByPercentage(
|
||||
script: string | null | undefined,
|
||||
animationId: string,
|
||||
percentage: number,
|
||||
): KeyframeRemoveResolution {
|
||||
if (!script) return { keyframeIndex: null, reason: "no_keyframes" };
|
||||
const kfs = findAnimationKeyframes(script, animationId);
|
||||
if (!kfs || kfs.length === 0) return { keyframeIndex: null, reason: "no_keyframes" };
|
||||
const matches: number[] = [];
|
||||
for (let i = 0; i < kfs.length; i++) {
|
||||
if (Math.abs(kfs[i]?.percentage - percentage) <= KF_PERCENT_TOLERANCE) matches.push(i);
|
||||
}
|
||||
if (matches.length === 0) return { keyframeIndex: null, reason: "not_found" };
|
||||
if (matches.length > 1) return { keyframeIndex: null, reason: "ambiguous" };
|
||||
return { keyframeIndex: matches[0] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a studio keyframe op to the SDK EditOp. For a remove this needs the pre-op
|
||||
* script to resolve percentage → index; returns null (with a reason) when the
|
||||
* index can't be safely resolved so the caller can emit a no-op-with-reason
|
||||
* event instead of dispatching the wrong keyframe.
|
||||
*/
|
||||
export function keyframeOpToEditOp(
|
||||
op: ShadowKeyframeOp,
|
||||
beforeScript: string | null | undefined,
|
||||
): { op: EditOp } | { op: null; reason: string } {
|
||||
if (op.kind === "add") {
|
||||
return {
|
||||
op: {
|
||||
type: "addGsapKeyframe",
|
||||
animationId: op.animationId,
|
||||
position: op.percentage,
|
||||
value: op.properties,
|
||||
},
|
||||
};
|
||||
}
|
||||
const resolved = resolveKeyframeIndexByPercentage(beforeScript, op.animationId, op.percentage);
|
||||
if (resolved.keyframeIndex === null) {
|
||||
return { op: null, reason: resolved.reason ?? "unresolved" };
|
||||
}
|
||||
return {
|
||||
op: {
|
||||
type: "removeGsapKeyframe",
|
||||
animationId: op.animationId,
|
||||
keyframeIndex: resolved.keyframeIndex,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Keyframe-aware fidelity diff ─────────────────────────────────────────────
|
||||
|
||||
function canonicalKeyframe(kf: GsapPercentageKeyframe): string {
|
||||
const props: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(kf.properties).sort()) {
|
||||
const v = kf.properties[key];
|
||||
props[key] =
|
||||
typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : v;
|
||||
}
|
||||
return JSON.stringify({ pct: Math.round(kf.percentage * 1000) / 1000, ease: kf.ease, props });
|
||||
}
|
||||
|
||||
function canonicalKeyframes(kfs: GsapPercentageKeyframe[] | null): string {
|
||||
if (!kfs) return "[]";
|
||||
return JSON.stringify(
|
||||
[...kfs].sort((a, b) => a.percentage - b.percentage).map(canonicalKeyframe),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff two GSAP scripts for a keyframe op: the reused tween-level diff PLUS a
|
||||
* keyframe-array comparison for the targeted animation (which the tween-level
|
||||
* diff doesn't inspect). Reports a `keyframes` value_mismatch when the SDK and
|
||||
* server keyframe arrays diverge canonically.
|
||||
*/
|
||||
export function gsapKeyframeFidelityMismatches(
|
||||
sdkScript: string,
|
||||
serverScript: string,
|
||||
animationId: string,
|
||||
resolveSelector?: (sel: string) => string,
|
||||
): SdkShadowMismatch[] {
|
||||
const mismatches = gsapFidelityMismatches(sdkScript, serverScript, resolveSelector);
|
||||
const sdkKfs = findAnimationKeyframes(sdkScript, animationId);
|
||||
const serverKfs = findAnimationKeyframes(serverScript, animationId);
|
||||
const sdkCanon = canonicalKeyframes(sdkKfs);
|
||||
const serverCanon = canonicalKeyframes(serverKfs);
|
||||
if (sdkCanon !== serverCanon) {
|
||||
mismatches.push({
|
||||
kind: "value_mismatch",
|
||||
hfId: animationId,
|
||||
property: "keyframes",
|
||||
expected: serverCanon,
|
||||
actual: sdkCanon,
|
||||
});
|
||||
}
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
// ─── Telemetry runner ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Shadow a GSAP keyframe op: open a fresh SDK doc from the server's pre-op file,
|
||||
* apply the equivalent keyframe op, serialize, and diff against the server's
|
||||
* resulting script. Emits sdk_shadow_dispatch op: "gsap_keyframe". Async,
|
||||
* fire-and-forget; server stays authoritative. No-op when shadow is disabled.
|
||||
*/
|
||||
export async function runShadowGsapKeyframeFidelity(
|
||||
beforeHtml: string | null | undefined,
|
||||
op: ShadowKeyframeOp,
|
||||
serverScript: string | null | undefined,
|
||||
): Promise<void> {
|
||||
if (!STUDIO_SDK_SHADOW_ENABLED) return;
|
||||
// No server script to diff against → skip the (costly) openComposition.
|
||||
if (!serverScript || !beforeHtml) return;
|
||||
const beforeScript = extractGsapScript(beforeHtml);
|
||||
const mapped = keyframeOpToEditOp(op, beforeScript);
|
||||
if (mapped.op === null) {
|
||||
// Ambiguous / not-found percentage: don't dispatch the wrong keyframe.
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: "gsap_keyframe",
|
||||
dispatched: false,
|
||||
reason: mapped.reason,
|
||||
mismatchCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const editOp = mapped.op;
|
||||
try {
|
||||
const session = await openComposition(beforeHtml);
|
||||
const verdict = session.can(editOp);
|
||||
if (!verdict.ok) {
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: "gsap_keyframe",
|
||||
dispatched: false,
|
||||
reason: "cannot_dispatch",
|
||||
code: verdict.code,
|
||||
mismatchCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
session.batch(() => session.dispatch(editOp));
|
||||
const sdkScript = extractGsapScript(session.serialize());
|
||||
if (sdkScript == null) {
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: "gsap_keyframe",
|
||||
dispatched: false,
|
||||
reason: "no_sdk_script",
|
||||
mismatchCount: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const mismatches = gsapKeyframeFidelityMismatches(
|
||||
sdkScript,
|
||||
serverScript,
|
||||
op.animationId,
|
||||
makeSelectorResolver(beforeHtml),
|
||||
);
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: "gsap_keyframe",
|
||||
dispatched: true,
|
||||
mismatchCount: mismatches.length,
|
||||
mismatches: JSON.stringify(mismatches),
|
||||
});
|
||||
} catch (err) {
|
||||
trackStudioEvent("sdk_shadow_dispatch", {
|
||||
op: "gsap_keyframe",
|
||||
dispatched: false,
|
||||
reason: "fidelity_error",
|
||||
error: String(err),
|
||||
mismatchCount: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Relative-epsilon numeric equality shared by the shadow diffs (timing parity +
|
||||
* GSAP value fidelity). Both writers round-trip durations/positions through JS
|
||||
* number formatting, so a value like 3.1 can read back as 3.0999999999999996.
|
||||
* Treat values within 1e-6 * max(1, |a|, |b|) as equal — tight enough that a
|
||||
* real 2 vs 1 (or 0.5 vs 0.49) still flags, loose enough to absorb float noise.
|
||||
*/
|
||||
export function relEqual(a: number, b: number): boolean {
|
||||
if (a === b) return true;
|
||||
return Math.abs(a - b) <= 1e-6 * Math.max(1, Math.abs(a), Math.abs(b));
|
||||
}
|
||||
Reference in New Issue
Block a user