diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index f49754158..fc2132aa8 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -113,4 +113,14 @@ export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag( false, ); +// Resolver-parity tripwire (telemetry-only, decoupled from cutover). +// Runs the SDK resolver alongside any edit and emits sdk_resolver_shadow on +// divergence. Default true; disable via VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED=false. +// Soak gate: retire once zero element_not_found divergences over a clean window. +export const STUDIO_SDK_RESOLVER_SHADOW_ENABLED = resolveStudioBooleanEnvFlag( + env, + ["VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED"], + true, +); + export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled"; diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 1fa260c0f..53551ef94 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -6,6 +6,7 @@ import type { PatchTarget } from "../utils/sourcePatcher"; import type { SidebarTab } from "../components/sidebar/LeftSidebar"; import type { Composition } from "@hyperframes/sdk"; import { sdkCutoverPersist, sdkDeletePersist } from "../utils/sdkCutover"; +import { runResolverShadow } from "../utils/sdkResolverShadow"; import { useAskAgentModal } from "./useAskAgentModal"; import { useDomSelection } from "./useDomSelection"; import { usePreviewInteraction } from "./usePreviewInteraction"; @@ -239,8 +240,10 @@ export function useDomEditSession({ buildDomSelectionFromTarget, forceReloadSdkSession, onTrySdkPersist: sdkSession - ? (selection, operations, originalContent, targetPath, options) => - sdkCutoverPersist( + ? (selection, operations, originalContent, targetPath, options) => { + // Resolver shadow runs regardless of the cutover flag — decoupled tripwire. + runResolverShadow(sdkSession, selection.hfId, operations); + return sdkCutoverPersist( selection, operations, originalContent, @@ -254,7 +257,8 @@ export function useDomEditSession({ compositionPath: activeCompPath, }, options, - ) + ); + } : undefined, onTrySdkDelete: sdkSession ? (hfId, originalContent, targetPath) => diff --git a/packages/studio/src/utils/sdkCutover.gate.test.ts b/packages/studio/src/utils/sdkCutover.gate.test.ts index c8592dba8..e5d122cc9 100644 --- a/packages/studio/src/utils/sdkCutover.gate.test.ts +++ b/packages/studio/src/utils/sdkCutover.gate.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it, vi } from "vitest"; // turns these red. (sdkCutover.test.ts mocks the flag TRUE; this is its sibling.) vi.mock("../components/editor/manualEditingAvailability", () => ({ STUDIO_SDK_CUTOVER_ENABLED: false, + STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false, })); vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() })); diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts index 93e16411e..67d9ee4f5 100644 --- a/packages/studio/src/utils/sdkCutover.test.ts +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -14,6 +14,7 @@ import type { MutableRefObject } from "react"; vi.mock("../components/editor/manualEditingAvailability", () => ({ STUDIO_SDK_CUTOVER_ENABLED: true, + STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false, })); vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn(), diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index 03499a01f..b7c108119 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -1,11 +1,13 @@ import type { MutableRefObject } from "react"; -import type { Composition, EditOp, GsapTweenSpec } from "@hyperframes/sdk"; +import type { Composition, GsapTweenSpec } 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 { trackStudioEvent } from "./studioTelemetry"; import { markSelfWrite } from "../hooks/sdkSelfWriteRegistry"; +import { patchOpsToSdkEditOps } from "./sdkOpMapping"; +import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow"; const CUTOVER_OP_TYPES = new Set([ "inline-style", @@ -34,10 +36,6 @@ const RESERVED_CUTOVER_ATTRS = new Set([ "data-hold-fill", ]); -// The attribute name the SDK setAttribute op carries for this patch op (or null -// if the op isn't an attribute). Shared by patchOpsToSdkEditOps and the reserved -// gate so the name they reason about can't drift: a bare `attribute` op is -// force-prefixed `data-`; an `html-attribute` op keeps its raw name. function sdkAttrName(op: PatchOperation): string | null { if (op.type === "attribute") { return op.property.startsWith("data-") ? op.property : `data-${op.property}`; @@ -54,38 +52,6 @@ function mapsToReservedAttr(op: PatchOperation): boolean { return name !== null && RESERVED_CUTOVER_ATTRS.has(name.toLowerCase()); } -/** - * 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 = {}; - 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" || op.type === "html-attribute") { - const name = sdkAttrName(op); - if (name !== null) { - result.push({ type: "setAttribute", target: hfId, name, value: op.value }); - } - } - } - - if (hasStyles) { - result.unshift({ type: "setStyle", target: hfId, styles }); - } - - return result; -} - export function shouldUseSdkCutover( flagEnabled: boolean, hasSession: boolean, @@ -250,6 +216,9 @@ export async function sdkTimingPersist( deps: CutoverDeps, options?: CutoverOptions, ): Promise { + // Resolver tripwire — runs BEFORE the cutover gate (decoupled): records when + // the SDK can't resolve a target the server timing path is addressing. + recordResolverParity(sdkSession, hfId, "setTiming"); // Dark-launch gate: without this, timing cutover runs whenever an SDK session // exists (it always does, for shadow/selection) — flipping the flag OFF would // NOT disable it. Gate here so flag-off routes back to the legacy server path. @@ -286,6 +255,18 @@ export function sdkGsapTweenPersist( deps: CutoverDeps, options?: CutoverOptions, ): Promise { + // Resolver tripwire — runs BEFORE this function's own cutover gate (decoupled). + // add targets an element (element-resolution parity); set/remove target an + // animationId (animation-resolution parity). Done here, not via + // dispatchGsapOpAndPersist's resolverTarget, because the gate below returns + // before that call when cutover is off. + if (op.kind === "add") recordResolverParity(sdkSession, op.target, "addGsapTween"); + else + recordAnimationResolverParity( + sdkSession, + op.animationId, + op.kind === "set" ? "setGsapTween" : "removeGsapTween", + ); // Leading dark-launch gate so flag-off does no SDK touch (getElement) at all — // matches the other three chokepoints' discipline. if (!STUDIO_SDK_CUTOVER_ENABLED) return Promise.resolve(false); @@ -313,7 +294,13 @@ async function dispatchGsapOpAndPersist( deps: CutoverDeps, options: CutoverOptions | undefined, dispatch: (s: Composition) => void, + resolverTarget?: { animationId: string; opLabel: string }, ): Promise { + // Resolver tripwire — runs BEFORE the cutover gate (decoupled): records when + // the SDK can't resolve the animationId the server GSAP path is addressing. + if (resolverTarget) { + recordAnimationResolverParity(sdkSession, resolverTarget.animationId, resolverTarget.opLabel); + } // Dark-launch gate (shared chokepoint for every GSAP-op cutover persist): // flag OFF → return false → caller falls back to the legacy server path. if (!STUDIO_SDK_CUTOVER_ENABLED) return false; @@ -354,8 +341,13 @@ export function sdkGsapKeyframePersist( deps: CutoverDeps, options?: CutoverOptions, ): Promise { - return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => - s.batch(() => s.dispatch({ type: "addGsapKeyframe", animationId, position, value })), + return dispatchGsapOpAndPersist( + targetPath, + sdkSession, + deps, + options, + (s) => s.batch(() => s.dispatch({ type: "addGsapKeyframe", animationId, position, value })), + { animationId, opLabel: "addGsapKeyframe" }, ); } @@ -367,8 +359,13 @@ export function sdkGsapRemoveKeyframePersist( deps: CutoverDeps, options?: CutoverOptions, ): Promise { - return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => - s.dispatch({ type: "removeGsapKeyframe", animationId, percentage }), + return dispatchGsapOpAndPersist( + targetPath, + sdkSession, + deps, + options, + (s) => s.dispatch({ type: "removeGsapKeyframe", animationId, percentage }), + { animationId, opLabel: "removeGsapKeyframe" }, ); } @@ -381,8 +378,13 @@ export function sdkGsapRemovePropertyPersist( deps: CutoverDeps, options?: CutoverOptions, ): Promise { - return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => - s.dispatch({ type: "removeGsapProperty", animationId, property, from }), + return dispatchGsapOpAndPersist( + targetPath, + sdkSession, + deps, + options, + (s) => s.dispatch({ type: "removeGsapProperty", animationId, property, from }), + { animationId, opLabel: "removeGsapProperty" }, ); } @@ -405,8 +407,13 @@ export function sdkGsapRemoveAllKeyframesPersist( deps: CutoverDeps, options?: CutoverOptions, ): Promise { - return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => - s.dispatch({ type: "removeAllKeyframes", animationId }), + return dispatchGsapOpAndPersist( + targetPath, + sdkSession, + deps, + options, + (s) => s.dispatch({ type: "removeAllKeyframes", animationId }), + { animationId, opLabel: "removeAllKeyframes" }, ); } @@ -418,8 +425,13 @@ export function sdkGsapConvertToKeyframesPersist( deps: CutoverDeps, options?: CutoverOptions, ): Promise { - return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => - s.dispatch({ type: "convertToKeyframes", animationId, resolvedFromValues }), + return dispatchGsapOpAndPersist( + targetPath, + sdkSession, + deps, + options, + (s) => s.dispatch({ type: "convertToKeyframes", animationId, resolvedFromValues }), + { animationId, opLabel: "convertToKeyframes" }, ); } @@ -430,6 +442,8 @@ export async function sdkDeletePersist( sdkSession: Composition | null | undefined, deps: CutoverDeps, ): Promise { + // Resolver tripwire — runs BEFORE the cutover gate (decoupled). + recordResolverParity(sdkSession, hfId, "removeElement"); // Dark-launch gate: flag OFF → legacy server delete path. if (!STUDIO_SDK_CUTOVER_ENABLED) return false; if (!sdkSession || !sdkSession.getElement(hfId)) return false; diff --git a/packages/studio/src/utils/sdkOpMapping.ts b/packages/studio/src/utils/sdkOpMapping.ts new file mode 100644 index 000000000..2a2ca974a --- /dev/null +++ b/packages/studio/src/utils/sdkOpMapping.ts @@ -0,0 +1,43 @@ +/** + * Studio PatchOperation[] → SDK EditOp[] mapping. + * + * Lives in its own module so both the cutover path (sdkCutover.ts) and the + * resolver-shadow tripwire (sdkResolverShadow.ts) can use it without a circular + * import between those two. + * + * Multiple inline-style ops are coalesced into a single setStyle (the SDK + * batches style changes naturally). One SDK op is emitted per non-style op. + */ + +import type { EditOp } from "@hyperframes/sdk"; +import type { PatchOperation } from "./sourcePatcher"; + +export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] { + const result: EditOp[] = []; + const styles: Record = {}; + 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; +} diff --git a/packages/studio/src/utils/sdkResolverShadow.test.ts b/packages/studio/src/utils/sdkResolverShadow.test.ts new file mode 100644 index 000000000..399954ec2 --- /dev/null +++ b/packages/studio/src/utils/sdkResolverShadow.test.ts @@ -0,0 +1,366 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { + sdkResolverShadowCheck, + runResolverShadow, + recordResolverParity, + recordAnimationResolverParity, + evaluateSoakGate, + type SdkResolverMismatch, +} from "./sdkResolverShadow"; +import type { PatchOperation } from "./sourcePatcher"; +import { openComposition } from "@hyperframes/sdk"; + +// ─── Telemetry capture ──────────────────────────────────────────────────────── + +const trackedEvents: Array<{ event: string; props: Record }> = []; +vi.mock("./studioTelemetry", () => ({ + trackStudioEvent: (event: string, props: Record) => + trackedEvents.push({ event, props }), +})); +beforeEach(() => { + trackedEvents.length = 0; +}); +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("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 } 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("C10: unmappable op type produces no mismatch (excluded, not flagged)", async () => { + const session = await openComposition(BASE_HTML); + // "unknown-op" is not in MAPPED_OP_TYPES, so it must be silently excluded. + const ops = [{ type: "unknown-op", property: "x", value: "y" }] as unknown as PatchOperation[]; + const mismatches = sdkResolverShadowCheck(session, "hf-box", ops); + expect(mismatches).toHaveLength(0); + }); +}); + +// ─── D. Redaction ───────────────────────────────────────────────────────────── + +describe("D. Redaction", () => { + it("D11: telemetry payload carries kind/hfId/count but NOT raw style value or text", async () => { + mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; + const session = await makePoisonedStyleSession(); + const sensitiveValue = "rgba(255, 0, 0, 0.5)"; + runResolverShadow(session, "hf-box", [ + { type: "inline-style", property: "color", value: sensitiveValue }, + ]); + const ev = lastShadow(); + expect(ev).toBeDefined(); + expect(ev?.mismatchCount).toBe(1); + // The raw sensitive value must NOT appear in the serialized mismatches + const serialized = JSON.stringify(ev?.mismatches ?? ""); + expect(serialized).not.toContain(sensitiveValue); + // But the kind and hfId must be present + expect(serialized).toContain("value_mismatch"); + expect(serialized).toContain("hf-box"); + }); + + it("D11: text-content value is fully redacted (replaced with length marker)", async () => { + mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; + const session = await openComposition(BASE_HTML); + const origDispatch = session.dispatch.bind(session); + // Prevent setText from applying so text value differs + session.dispatch = (op) => { + if (typeof op === "object" && "type" in op && op.type === "setText") return; + origDispatch(op); + }; + const secretText = "confidential user content"; + runResolverShadow(session, "hf-box", [ + { type: "text-content", property: "text", value: secretText }, + ]); + const ev = lastShadow(); + const serialized = JSON.stringify(ev?.mismatches ?? ""); + expect(serialized).not.toContain(secretText); + expect(serialized).toContain("[redacted len="); + }); +}); + +// ─── E. Soak gate ───────────────────────────────────────────────────────────── + +describe("E. Soak gate", () => { + it("E12: zero divergences → parity-proven", () => { + expect(evaluateSoakGate(0)).toBe("parity-proven"); + }); + + it("E12: one divergence → divergence-detected", () => { + expect(evaluateSoakGate(1)).toBe("divergence-detected"); + }); + + it("E12: many divergences → divergence-detected", () => { + expect(evaluateSoakGate(100)).toBe("divergence-detected"); + }); +}); + +// ─── F. recordResolverParity (extended coverage: timing / delete / gsap-add) ── + +describe("F. recordResolverParity", () => { + it("emits element_not_found when the SDK cannot resolve the target", async () => { + mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; + const session = await openComposition(BASE_HTML); + recordResolverParity(session, "hf-missing", "setTiming"); + const ev = lastShadow(); + expect(ev?.mismatchCount).toBe(1); + expect(ev?.opLabel).toBe("setTiming"); + expect(JSON.stringify(ev?.mismatches)).toContain("element_not_found"); + }); + + it("emits nothing when the target resolves (parity)", async () => { + mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; + const session = await openComposition(BASE_HTML); + recordResolverParity(session, "hf-box", "removeElement"); + expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0); + }); + + it("is a no-op (no SDK touch) when the flag is off", async () => { + mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false; + const session = await openComposition(BASE_HTML); + const spy = vi.spyOn(session, "getElement"); + recordResolverParity(session, "hf-missing", "setTiming"); + expect(trackedEvents).toHaveLength(0); + expect(spy).not.toHaveBeenCalled(); + }); + + it("never mutates the session (read-only resolver check)", async () => { + mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; + const session = await openComposition(BASE_HTML); + recordResolverParity(session, "hf-box", "setTiming"); + expect(session.getElement("hf-box")?.inlineStyles.color).toBe("red"); // unchanged + }); +}); + +// ─── G. recordAnimationResolverParity (GSAP animationId ops) ────────────────── + +const GSAP_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); + }); +}); diff --git a/packages/studio/src/utils/sdkResolverShadow.ts b/packages/studio/src/utils/sdkResolverShadow.ts new file mode 100644 index 000000000..cf62d21f0 --- /dev/null +++ b/packages/studio/src/utils/sdkResolverShadow.ts @@ -0,0 +1,313 @@ +/** + * SDK resolver-parity tripwire (telemetry-only). + * + * Checks whether the SDK session resolves the same element id the server + * patch path would target, then optionally verifies value parity after an + * in-memory dispatch. Emits `sdk_resolver_shadow` on any divergence. + * + * Headline signal: `element_not_found` — the resolver divergence class that + * caused the v0.6.110 regression. The writer-parity suite (#1533) cannot see + * this class; this tripwire exists specifically to catch it. + * + * Decoupled from `STUDIO_SDK_CUTOVER_ENABLED`. Gated by its own flag + * `STUDIO_SDK_RESOLVER_SHADOW_ENABLED` (default ON during the soak — collect + * wild telemetry; flip off / remove once resolver parity is proven). + * Telemetry-only — never writes to disk, never affects the user-visible edit. + */ + +import type { Composition, JsonPatchOp } from "@hyperframes/sdk"; +import type { PatchOperation } from "./sourcePatcher"; +import { STUDIO_SDK_RESOLVER_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability"; +import { patchOpsToSdkEditOps } from "./sdkOpMapping"; +import { trackStudioEvent } from "./studioTelemetry"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface SdkResolverMismatch { + kind: "element_not_found" | "value_mismatch" | "dispatch_error" | "animation_not_found"; + hfId?: string; + animationId?: string; + property?: string; + expected?: string | null; + actual?: string | null | undefined; + error?: string; +} + +// ─── Op helpers ─────────────────────────────────────────────────────────────── + +// Drop studio-internal data-hf-* markers the SDK model doesn't represent. +function isShadowableOp(op: PatchOperation): boolean { + const name = + op.type === "attribute" + ? op.property.startsWith("data-") + ? op.property + : `data-${op.property}` + : op.type === "html-attribute" + ? op.property + : null; + return name === null || !name.startsWith("data-hf-"); +} + +const MAPPED_OP_TYPES = new Set(["inline-style", "text-content", "attribute", "html-attribute"]); + +// ─── Read-back helpers ──────────────────────────────────────────────────────── + +function kebabToCamel(prop: string): string { + return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); +} + +function normalizeText(v: string | null | undefined): string | null { + if (v == null) return null; + const t = v.trim(); + return t === "" ? null : t; +} + +type FlatEl = NonNullable>; +type AttrMap = Record; + +function checkStyleOp( + op: PatchOperation, + el: FlatEl, +): { expected: string | null; actual: string | null } { + return { + expected: op.value ?? null, + actual: el.inlineStyles[kebabToCamel(op.property)] ?? el.inlineStyles[op.property] ?? null, + }; +} + +function checkTextOp( + op: PatchOperation, + el: FlatEl, +): { expected: string | null; actual: string | null } { + return { expected: normalizeText(op.value), actual: normalizeText(el.text) }; +} + +function checkAttrOp( + op: PatchOperation, + el: FlatEl, +): { property: string; expected: string | null; actual: string | null } { + const property = + op.type === "attribute" + ? op.property.startsWith("data-") + ? op.property + : `data-${op.property}` + : op.property; + return { + property, + expected: op.value ?? null, + actual: (el.attributes as AttrMap)[property] ?? null, + }; +} + +function checkOpValue(op: PatchOperation, el: FlatEl, hfId: string): SdkResolverMismatch | null { + let property: string; + let expected: string | null; + let actual: string | null; + + if (op.type === "inline-style") { + property = op.property; + ({ expected, actual } = checkStyleOp(op, el)); + } else if (op.type === "text-content") { + property = "text"; + ({ expected, actual } = checkTextOp(op, el)); + } else if (op.type === "attribute" || op.type === "html-attribute") { + ({ property, expected, actual } = checkAttrOp(op, el)); + } else { + return null; + } + + if (actual === expected) return null; + return { kind: "value_mismatch", hfId, property, expected, actual }; +} + +// ─── Core check (pure — testable without flag) ──────────────────────────────── + +/** + * Run the resolver shadow check against an already-open SDK session. + * + * Returns an array of mismatches (empty = parity). The value-parity check + * dispatches the ops into the session to read the result back, then UNDOES + * those mutations via the captured inverse patches before returning — the + * session ends exactly as it started. This is essential: the session is shared + * with the cutover path, and a residual shadow mutation would make the + * subsequent sdkCutoverPersist see before === after and silently fall back to + * the server path. Telemetry-only; the server path stays authoritative on disk. + * + * Exported for unit tests; call `runResolverShadow` at call sites. + */ +export function sdkResolverShadowCheck( + session: Composition, + hfId: string, + ops: PatchOperation[], +): SdkResolverMismatch[] { + if (!session.getElement(hfId)) { + return [{ kind: "element_not_found", hfId }]; + } + + const shadowable = ops.filter(isShadowableOp); + if (shadowable.length === 0) return []; + + // Silently skip op batches containing unmapped types — not a resolver bug. + if (shadowable.some((op) => !MAPPED_OP_TYPES.has(op.type))) return []; + + // Capture the inverse of the shadow dispatch so we can restore the session. + // `batch` fires a single PatchEvent whose `inversePatches` are already in + // reverse-apply order (session.ts reverses inside buildPatchEvent), so + // applyPatches(inverse) undoes the dispatch with no further reordering. If a + // future SDK refactor ever coalesces batch into a composite with no per-op + // inverse, this restore breaks — keep batch emitting inverse patches. + const inverse: JsonPatchOp[] = []; + const stopCapture = session.on("patch", (e) => inverse.push(...e.inversePatches)); + // restore() runs in `finally` so the patch listener is always removed and the + // session is always undone — even if checkOpValue throws between dispatch and + // return. A residual mutation or leaked listener on the shared session is the + // exact cutover-coupling failure mode this module exists to avoid. + try { + try { + const editOps = patchOpsToSdkEditOps(hfId, shadowable); + session.batch(() => { + for (const op of editOps) session.dispatch(op); + }); + } catch (err) { + return [{ kind: "dispatch_error", hfId, error: String(err) }]; + } + + const el = session.getElement(hfId); + if (!el) return [{ kind: "element_not_found", hfId }]; + + return shadowable + .map((op) => checkOpValue(op, el, hfId)) + .filter((m): m is SdkResolverMismatch => m !== null); + } finally { + stopCapture(); + if (inverse.length > 0) session.applyPatches(inverse); + } +} + +// ─── Telemetry ──────────────────────────────────────────────────────────────── + +// Redact all user-content values before telemetry: style values and text both +// carry user data. Keep only the length so we can detect truncation without +// leaking the actual bytes. +function redactValue(value: string | null | undefined): string | null | undefined { + if (value == null) return value; + return `[redacted len=${value.length}]`; +} + +function redactMismatches(mismatches: SdkResolverMismatch[]): SdkResolverMismatch[] { + return mismatches.map((m) => ({ + ...m, + expected: redactValue(m.expected), + actual: redactValue(m.actual), + })); +} + +/** + * Run the resolver shadow and emit `sdk_resolver_shadow` telemetry. + * No-op when `STUDIO_SDK_RESOLVER_SHADOW_ENABLED` is false. + * Never throws — any exception inside the shadow is swallowed. + * + * Side-effect-free on the live session: sdkResolverShadowCheck dispatches into + * the session to read values back, then undoes those mutations before returning + * (see below). The session is shared with the cutover path, so it MUST end the + * call exactly as it started. + */ +export function runResolverShadow( + session: Composition, + hfId: string | null | undefined, + ops: PatchOperation[], +): void { + if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return; + if (!hfId) return; + try { + const mismatches = sdkResolverShadowCheck(session, hfId, ops); + // Emit only on divergence — parity is silent, matching recordResolverParity + // and recordAnimationResolverParity. Otherwise this fires a PostHog event on + // every style/text/attr edit (the editor's chattiest path) at default-ON. + if (mismatches.length === 0) return; + trackStudioEvent("sdk_resolver_shadow", { + hfId, + mismatchCount: mismatches.length, + mismatches: JSON.stringify(redactMismatches(mismatches)), + }); + } catch { + // never propagate from the shadow path + } +} + +/** + * Record element-resolution parity for an element-targeted op WITHOUT + * dispatching. Read-only: emits a single `element_not_found` event when the SDK + * can't resolve a target the server path is addressing. This extends the + * tripwire beyond the DOM-edit path (runResolverShadow) to the other + * element-targeted cutover chokepoints — timing, delete, GSAP-tween add — for + * the headline resolver signal, without the cost/mutation of a value check. + * + * No-op when the shadow flag is off; never throws; never mutates the session. + */ +export function recordResolverParity( + session: Composition | null | undefined, + hfId: string | null | undefined, + opLabel: string, +): void { + if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return; + if (!session || !hfId) return; + try { + if (session.getElement(hfId)) return; // resolves — parity, nothing to record + trackStudioEvent("sdk_resolver_shadow", { + hfId, + opLabel, + mismatchCount: 1, + mismatches: JSON.stringify([ + { kind: "element_not_found", hfId } satisfies SdkResolverMismatch, + ]), + }); + } catch { + // never propagate from the shadow path + } +} + +/** + * Record animation-resolution parity for an animationId-targeted GSAP op WITHOUT + * dispatching. Read-only: emits `animation_not_found` when the SDK can't resolve + * the animationId the server GSAP path is addressing — the GSAP-edit-surface + * analogue of element_not_found. The SDK's resolvable animation ids are the + * located ids attached to elements (buildAnimationIdMap), so a target absent + * from every element's animationIds is a resolver divergence. + * + * No-op when the shadow flag is off; never throws; never mutates the session. + */ +export function recordAnimationResolverParity( + session: Composition | null | undefined, + animationId: string, + opLabel: string, +): void { + if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return; + if (!session || !animationId) return; + try { + const resolves = session.getElements().some((el) => el.animationIds.includes(animationId)); + if (resolves) return; // SDK locates the animation — parity + trackStudioEvent("sdk_resolver_shadow", { + animationId, + opLabel, + mismatchCount: 1, + mismatches: JSON.stringify([ + { kind: "animation_not_found", animationId } satisfies SdkResolverMismatch, + ]), + }); + } catch { + // never propagate from the shadow path + } +} + +// ─── Soak gate ──────────────────────────────────────────────────────────────── + +/** + * Evaluate the soak-gate exit criterion. + * + * A clean soak window has zero `element_not_found` divergences. When that + * condition holds, resolver parity is proven and the flag can be retired. + */ +export function evaluateSoakGate(divergenceCount: number): "parity-proven" | "divergence-detected" { + return divergenceCount === 0 ? "parity-proven" : "divergence-detected"; +}