// @vitest-environment happy-dom import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { sdkResolverShadowCheck, runResolverShadow, recordResolverParity, recordAnimationResolverParity, evaluateSoakGate, recordAttempt, flushAttemptCounts, __resetAttemptSchedulingForTests, type SdkResolverMismatch, } from "./sdkResolverShadow"; import type { PatchOperation } from "./sourcePatcher"; import { openComposition } from "@hyperframes/sdk"; // ─── Telemetry capture ──────────────────────────────────────────────────────── const trackedEvents: Array<{ event: string; props: Record }> = []; const flushViaBeacon = vi.fn(); vi.mock("./studioTelemetry", () => ({ trackStudioEvent: (event: string, props: Record) => trackedEvents.push({ event, props }), flushViaBeacon: () => flushViaBeacon(), })); beforeEach(() => { trackedEvents.length = 0; flushViaBeacon.mockClear(); }); const lastShadow = () => trackedEvents.filter((e) => e.event === "sdk_resolver_shadow").at(-1)?.props; // ─── Flag mock ──────────────────────────────────────────────────────────────── // manualEditingAvailability reads env at module load time, so we mock the // module to control flag values per test group. // Default false in tests so shadow is opt-in per test (real default is true). const mockFlags = { STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false }; vi.mock("../components/editor/manualEditingAvailability", () => ({ get STUDIO_SDK_RESOLVER_SHADOW_ENABLED() { return mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED; }, get STUDIO_SDK_CUTOVER_ENABLED() { return false; }, })); // ─── Fixtures ───────────────────────────────────────────────────────────────── const BASE_HTML = /* html */ `
Hello
`; // Prevents setStyle from applying so the read-back value differs from expected. // Used in C9 and D11 to simulate a silent SDK value-dispatch bug. async function makePoisonedStyleSession() { const session = await openComposition(BASE_HTML); const origDispatch = session.dispatch.bind(session); session.dispatch = (op) => { if (typeof op === "object" && "type" in op && op.type === "setStyle") return; origDispatch(op); }; return session; } // ─── A. Flag gating ─────────────────────────────────────────────────────────── describe("A. Flag gating", () => { it("A1: flag off → no telemetry, SDK path not touched", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false; const session = await openComposition(BASE_HTML); const spy = vi.spyOn(session, "getElement"); runResolverShadow(session, "hf-box", [ { type: "inline-style", property: "color", value: "blue" }, ]); expect(trackedEvents).toHaveLength(0); expect(spy).not.toHaveBeenCalled(); }); it("A2: flag on + divergence → emits exactly one telemetry event", async () => { // runResolverShadow emits only on divergence, so force one (poisoned dispatch // → value_mismatch). A parity edit is silent (see B-parity-silent). mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; const session = await makePoisonedStyleSession(); runResolverShadow(session, "hf-box", [ { type: "inline-style", property: "color", value: "blue" }, ]); expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1); }); it("A2b: flag on + parity → emits nothing (divergence-only)", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; const session = await openComposition(BASE_HTML); runResolverShadow(session, "hf-box", [ { type: "inline-style", property: "color", value: "blue" }, ]); expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0); }); it("A2c: empty session → ONE tagged session_empty event per session, no attempt", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; flushAttemptCounts(); // drain counts left by earlier tests const session = await openComposition(""); const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }]; runResolverShadow(session, "hf-anything", ops); runResolverShadow(session, "hf-other", ops); // repeat edits do not re-emit const events = trackedEvents.filter((e) => e.event === "sdk_resolver_shadow"); // The modeling gap stays VISIBLE (silence would blind the tripwire to the // exact class that exposed the template-comp bug) but is distinguishable // and rate-limited to once per session instance. expect(events).toHaveLength(1); expect(events[0]?.props.sessionEmpty).toBe(true); expect(JSON.stringify(events[0]?.props.mismatches)).toContain("session_empty"); expect(flushAttemptCounts()).toBeNull(); // can't cut over → not in the denominator }); it("A3: shadow depends ONLY on shadow flag, not on STUDIO_SDK_CUTOVER_ENABLED", async () => { // The mock always returns STUDIO_SDK_CUTOVER_ENABLED=false. Use a divergence // (poisoned session) so the flag-on case emits; flag-off must stay silent. mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false; const session = await makePoisonedStyleSession(); runResolverShadow(session, "hf-box", [{ type: "inline-style", property: "color", value: "x" }]); expect(trackedEvents).toHaveLength(0); // cutover off, shadow off → no event mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; runResolverShadow(session, "hf-box", [{ type: "inline-style", property: "color", value: "x" }]); expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1); // shadow on regardless }); it("A4: null/undefined hfId is a safe no-op (no event, no throw)", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; const session = await openComposition(BASE_HTML); const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }]; expect(() => runResolverShadow(session, null, ops)).not.toThrow(); expect(() => runResolverShadow(session, undefined, ops)).not.toThrow(); expect(trackedEvents).toHaveLength(0); }); }); // ─── B. Telemetry-only (no side effects on real write) ──────────────────────── describe("B. Telemetry-only / no side effects", () => { it("B4: no disk write — shadow never calls writeProjectFile", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; const writeProjectFile = vi.fn(); const session = await openComposition(BASE_HTML); runResolverShadow(session, "hf-box", [ { type: "inline-style", property: "color", value: "blue" }, ]); // writeProjectFile is a deps-level function not in scope here; verify by // checking sdkResolverShadowCheck itself never touches it — it's not passed // in at all, so any call would be a TypeError at runtime. expect(writeProjectFile).not.toHaveBeenCalled(); }); it("B5: the LIVE session is restored after the check (cutover before===after stays correct)", async () => { // The session is shared with the cutover path. The shadow dispatches into it // to read values back, then MUST undo those mutations — otherwise the edit is // pre-applied and the following sdkCutoverPersist sees before === after and // silently falls back to the server path. const session = await openComposition(BASE_HTML); expect(session.getElement("hf-box")?.inlineStyles.color).toBe("red"); const mismatches = sdkResolverShadowCheck(session, "hf-box", [ { type: "inline-style", property: "color", value: "blue" }, ]); expect(mismatches).toHaveLength(0); // SDK applied blue == expected → parity // …but the session is back to its pre-check state, NOT left on "blue". expect(session.getElement("hf-box")?.inlineStyles.color).toBe("red"); }); it("B5b: a real cutover-style serialize diff survives a preceding shadow run", async () => { // End-to-end of the bug: shadow runs, THEN a cutover-style before/dispatch/ // after still produces a diff (proving shadow left no residue). const session = await openComposition(BASE_HTML); sdkResolverShadowCheck(session, "hf-box", [ { type: "inline-style", property: "color", value: "blue" }, ]); const before = session.serialize(); session.dispatch({ type: "setStyle", target: "hf-box", styles: { color: "blue" } }); const after = session.serialize(); expect(after).not.toBe(before); // cutover would write, not fall back }); it("B6: exception inside shadow never propagates to caller", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; const session = await openComposition(BASE_HTML); session.dispatch = () => { throw new Error("sdk exploded"); }; const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }]; expect(() => runResolverShadow(session, "hf-box", ops)).not.toThrow(); // A dispatch_error mismatch is still emitted (via telemetry) const ev = lastShadow(); expect(ev).toBeDefined(); expect(ev?.mismatchCount).toBe(1); }); }); // ─── C. Resolver-parity detection ──────────────────────────────────────────── describe("C. Resolver-parity detection", () => { it("C7: match → mismatchCount 0", async () => { const session = await openComposition(BASE_HTML); const mismatches = sdkResolverShadowCheck(session, "hf-box", [ { type: "inline-style", property: "color", value: "blue" }, ]); expect(mismatches).toHaveLength(0); }); it("C8: element_not_found fires when SDK resolver returns null (v0.6.110 class)", () => { // Simulate the regression: SDK session cannot resolve the hfId the server // would address (e.g. scoped-id mismatch, resolver bug). const session = { getElement: () => null, getElements: () => [] } as unknown as Parameters< typeof sdkResolverShadowCheck >[0]; const mismatches = sdkResolverShadowCheck( session as unknown as Parameters[0], "hf-box", [{ type: "inline-style", property: "color", value: "red" }], ); expect(mismatches).toHaveLength(1); expect(mismatches[0]).toMatchObject({ kind: "element_not_found", hfId: "hf-box", }); }); it("C8 inverse: no element_not_found when SDK resolves (server also resolves)", async () => { const session = await openComposition(BASE_HTML); const mismatches = sdkResolverShadowCheck(session, "hf-box", [ { type: "inline-style", property: "color", value: "blue" }, ]); expect(mismatches.some((m) => m.kind === "element_not_found")).toBe(false); }); it("C9: value_mismatch when dispatch yields different value than expected", async () => { const session = await makePoisonedStyleSession(); const mismatches = sdkResolverShadowCheck(session, "hf-box", [ { type: "inline-style", property: "color", value: "blue" }, ]); expect(mismatches).toHaveLength(1); expect(mismatches[0]).toMatchObject({ kind: "value_mismatch", hfId: "hf-box", property: "color", expected: "blue", }); }); it("C8 runtime-node filter: hfId absent from source → suppressed (not a resolver bug)", () => { // The studio resolved a live-DOM element to an hf-id that the SDK session // doesn't contain AND that never appears in the on-disk source — it's a // node a composition `; const GSAP_UNMATCHED_SELECTOR_HTML = /* html */ `
Hello
`; describe("G. recordAnimationResolverParity", () => { it("emits animation_not_found when the SDK cannot resolve the animationId", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; const session = await openComposition(GSAP_HTML); recordAnimationResolverParity(session, "no-such-anim", "setGsapTween"); const ev = lastShadow(); expect(ev?.mismatchCount).toBe(1); expect(ev?.opLabel).toBe("setGsapTween"); expect(JSON.stringify(ev?.mismatches)).toContain("animation_not_found"); }); it("emits nothing when the animationId resolves to a located animation", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; const session = await openComposition(GSAP_HTML); const realId = session.getElements().flatMap((e) => [...e.animationIds])[0] ?? ""; expect(realId).not.toBe(""); // fixture has a tween on hf-box recordAnimationResolverParity(session, realId, "removeGsapTween"); expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0); }); it("is a no-op when the flag is off", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false; const session = await openComposition(GSAP_HTML); recordAnimationResolverParity(session, "no-such-anim", "setGsapTween"); expect(trackedEvents).toHaveLength(0); }); it("emits nothing when the animationId only resolves via getAllAnimationIds (no live DOM match) — repro of the v0.7.31 false-positive", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; const session = await openComposition(GSAP_UNMATCHED_SELECTOR_HTML); const unmatchedId = [...session.getAllAnimationIds()][0] ?? ""; expect(unmatchedId).not.toBe(""); // Confirms the bug this fixes: the id is NOT attached to any element. expect(session.getElements().some((el) => el.animationIds.includes(unmatchedId))).toBe(false); recordAnimationResolverParity(session, unmatchedId, "removeAllKeyframes"); expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0); }); }); // ─── H. Inlined sub-composition: bare leaf id resolves (regression) ─────────── // PostHog showed ~445 false `element_not_found` events, all on a bare leaf id // (hf-0ytc / #subscribe-btn) inside an inlined sub-composition. The studio reads // the bare data-hf-id off the DOM and the cutover dispatch resolves it via // resolveScoped (which locates the leaf inside the host subtree). But the shadow // resolved via Composition.getElement, which is canonical-only for a bare id and // returns null for a scoped element — so it flagged a divergence the real // dispatch path would not hit. The shadow now mirrors dispatch via resolveSnapshot. describe("H. inlined sub-composition leaf", () => { // host carries data-composition-file → new scope; leaf's scopedId is // "hf-host/hf-leaf" but its raw data-hf-id (what the studio reads) is bare. const INLINED_HTML = /* html */ `
Subscribe
`; it("getElement(bareLeaf) is null (canonical-only) — the trap the shadow used to hit", async () => { const session = await openComposition(INLINED_HTML); expect(session.getElement("hf-leaf")).toBeNull(); expect(session.getElement("hf-host/hf-leaf")).not.toBeNull(); }); it("recordResolverParity emits NOTHING for a bare leaf inside a sub-comp", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; const session = await openComposition(INLINED_HTML); await recordResolverParity(session, "hf-leaf", "setTiming"); expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0); }); it("sdkResolverShadowCheck does not flag element_not_found for a bare leaf in a sub-comp", async () => { const session = await openComposition(INLINED_HTML); const mismatches = sdkResolverShadowCheck(session, "hf-leaf", [ { type: "inline-style", property: "color", value: "blue" }, ]); expect(mismatches.some((m) => m.kind === "element_not_found")).toBe(false); }); }); // ─── I. Attempt counter (denominator for the soak gate) ─────────────────────── describe("I. recordAttempt / flushAttemptCounts", () => { beforeEach(() => { // Drain any counts left over from a prior test so each test starts clean. flushAttemptCounts(); }); it("flushAttemptCounts returns null when nothing has been recorded", () => { expect(flushAttemptCounts()).toBeNull(); }); it("increments the counter for a given op label", () => { recordAttempt("setTiming"); expect(flushAttemptCounts()).toEqual({ setTiming: 1 }); }); it("accumulates multiple calls with the same label", () => { recordAttempt("setTiming"); recordAttempt("setTiming"); recordAttempt("setTiming"); expect(flushAttemptCounts()).toEqual({ setTiming: 3 }); }); it("tracks different labels independently", () => { recordAttempt("setTiming"); recordAttempt("addGsapTween"); recordAttempt("setTiming"); expect(flushAttemptCounts()).toEqual({ setTiming: 2, addGsapTween: 1 }); }); it("resets to empty after a flush", () => { recordAttempt("setTiming"); flushAttemptCounts(); expect(flushAttemptCounts()).toBeNull(); }); }); describe("I. attempt counting inside the three emit functions", () => { beforeEach(() => { flushAttemptCounts(); }); it("runResolverShadow counts an attempt on the parity (silent) path", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; const session = await openComposition(BASE_HTML); runResolverShadow(session, "hf-box", [ { type: "inline-style", property: "color", value: "blue" }, ]); expect(flushAttemptCounts()).toEqual({ "dom-edit": 1 }); expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0); }); it("runResolverShadow counts an attempt on the divergence (emits) path too", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; const session = await openComposition(BASE_HTML); runResolverShadow(session, "hf-missing", [ { type: "inline-style", property: "color", value: "blue" }, ]); expect(flushAttemptCounts()).toEqual({ "dom-edit": 1 }); expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1); }); it("recordResolverParity counts an attempt on the parity (silent) path", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; const session = await openComposition(BASE_HTML); await recordResolverParity(session, "hf-box", "setTiming"); expect(flushAttemptCounts()).toEqual({ setTiming: 1 }); }); it("recordResolverParity counts an attempt on the divergence (emits) path too", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; const session = await openComposition(BASE_HTML); await recordResolverParity(session, "hf-missing", "setTiming"); expect(flushAttemptCounts()).toEqual({ setTiming: 1 }); }); it("recordAnimationResolverParity counts an attempt on the parity (silent) path", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; const session = await openComposition(GSAP_HTML); const realId = session.getElements().flatMap((e) => [...e.animationIds])[0] ?? ""; expect(realId).not.toBe(""); // fixture has a tween on hf-box, see block G above recordAnimationResolverParity(session, realId, "removeGsapTween"); expect(flushAttemptCounts()).toEqual({ removeGsapTween: 1 }); expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0); }); it("recordAnimationResolverParity counts an attempt on the divergence (emits) path too", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; const session = await openComposition(GSAP_HTML); recordAnimationResolverParity(session, "no-such-anim", "setGsapTween"); expect(flushAttemptCounts()).toEqual({ setGsapTween: 1 }); expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1); }); it("counts accumulate across multiple different chokepoints in one rollup", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; const session = await openComposition(BASE_HTML); await recordResolverParity(session, "hf-box", "setTiming"); await recordResolverParity(session, "hf-box", "setTiming"); recordAnimationResolverParity(session, "no-such-anim", "setGsapTween"); expect(flushAttemptCounts()).toEqual({ setTiming: 2, setGsapTween: 1 }); }); it("does not count an attempt when the flag is off", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false; const session = await openComposition(BASE_HTML); runResolverShadow(session, "hf-box", [ { type: "inline-style", property: "color", value: "blue" }, ]); await recordResolverParity(session, "hf-box", "setTiming"); recordAnimationResolverParity(session, "no-such-anim", "setGsapTween"); expect(flushAttemptCounts()).toBeNull(); }); }); describe("I. production rollup wiring", () => { beforeEach(() => { flushAttemptCounts(); __resetAttemptSchedulingForTests(); vi.useFakeTimers({ toFake: ["setTimeout", "setInterval", "clearInterval", "clearTimeout"] }); }); afterEach(() => { __resetAttemptSchedulingForTests(); vi.useRealTimers(); Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true }); }); it("does not emit a rollup event when nothing was recorded", () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; vi.advanceTimersByTime(5 * 60_000); expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow_attempt")).toHaveLength(0); }); it("emits a sdk_resolver_shadow_attempt rollup event every 5 minutes after the first attempt", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; const session = await openComposition(BASE_HTML); await recordResolverParity(session, "hf-box", "setTiming"); vi.advanceTimersByTime(5 * 60_000); const rollups = trackedEvents.filter((e) => e.event === "sdk_resolver_shadow_attempt"); expect(rollups).toHaveLength(1); expect(rollups[0].props.counts).toBe(JSON.stringify({ setTiming: 1 })); }); it("flushes a rollup and forces a beacon delivery on visibilitychange -> hidden", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; const session = await openComposition(BASE_HTML); await recordResolverParity(session, "hf-box", "setTiming"); Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true }); document.dispatchEvent(new Event("visibilitychange")); const rollups = trackedEvents.filter((e) => e.event === "sdk_resolver_shadow_attempt"); expect(rollups).toHaveLength(1); expect(rollups[0].props.counts).toBe(JSON.stringify({ setTiming: 1 })); // Delivery must not depend on studioTelemetry's own visibilitychange listener // winning a race — this module forces its own beacon flush. expect(flushViaBeacon).toHaveBeenCalledTimes(1); }); it("does not register a duplicate visibilitychange listener after a scheduling reset", async () => { mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; const session = await openComposition(BASE_HTML); await recordResolverParity(session, "hf-box", "setTiming"); __resetAttemptSchedulingForTests(); await recordResolverParity(session, "hf-box", "setTiming"); // re-arms scheduling, incl. listener Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true }); document.dispatchEvent(new Event("visibilitychange")); // If the reset had leaked the old listener, this would fire twice. expect(flushViaBeacon).toHaveBeenCalledTimes(1); }); });