@hyperframes/studio 0.8.45 → 0.8.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.8.45",
3
+ "version": "0.8.47",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,11 +48,11 @@
48
48
  "gsap": "^3.13.0",
49
49
  "marked": "^14.1.4",
50
50
  "mediabunny": "^1.45.3",
51
- "@hyperframes/core": "0.8.45",
52
- "@hyperframes/player": "0.8.45",
53
- "@hyperframes/sdk": "0.8.45",
54
- "@hyperframes/parsers": "0.8.45",
55
- "@hyperframes/studio-server": "0.8.45"
51
+ "@hyperframes/core": "0.8.47",
52
+ "@hyperframes/sdk": "0.8.47",
53
+ "@hyperframes/parsers": "0.8.47",
54
+ "@hyperframes/studio-server": "0.8.47",
55
+ "@hyperframes/player": "0.8.47"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/react": "19",
@@ -69,7 +69,7 @@
69
69
  "vite": "^6.4.2",
70
70
  "vitest": "^4.1.11",
71
71
  "zustand": "^5.0.0",
72
- "@hyperframes/producer": "0.8.45"
72
+ "@hyperframes/producer": "0.8.47"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "react": "19",
@@ -1,5 +1,6 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { resolveStudioBooleanEnvFlag } from "./manualEditingAvailability";
3
+ import { STUDIO_SDK_OPERATION_FAMILIES } from "../../utils/sdkCutoverPolicy";
3
4
 
4
5
  async function loadAvailabilityWithEnv(env: Record<string, string | undefined>) {
5
6
  vi.resetModules();
@@ -88,4 +89,22 @@ describe("manual editing availability", () => {
88
89
  });
89
90
  expect(off.STUDIO_FLAT_INSPECTOR_ENABLED).toBe(false);
90
91
  });
92
+
93
+ it("defaults SDK cutover on for every family", async () => {
94
+ const all = await loadAvailabilityWithEnv({});
95
+ expect(all.STUDIO_SDK_CUTOVER_ENABLED).toBe(true);
96
+ expect(all.STUDIO_SDK_CUTOVER_FAMILIES).toEqual(new Set(STUDIO_SDK_OPERATION_FAMILIES));
97
+ });
98
+
99
+ it("honors the SDK cutover kill switches", async () => {
100
+ const off = await loadAvailabilityWithEnv({ VITE_STUDIO_SDK_CUTOVER_ENABLED: "false" });
101
+ expect(off.STUDIO_SDK_CUTOVER_ENABLED).toBe(false);
102
+ expect(off.STUDIO_SDK_CUTOVER_FAMILIES.size).toBe(0);
103
+
104
+ const subset = await loadAvailabilityWithEnv({
105
+ VITE_STUDIO_SDK_CUTOVER_FAMILIES: "dom,timing",
106
+ });
107
+ expect(subset.STUDIO_SDK_CUTOVER_ENABLED).toBe(true);
108
+ expect(subset.STUDIO_SDK_CUTOVER_FAMILIES).toEqual(new Set(["dom", "timing"]));
109
+ });
91
110
  });
@@ -38,16 +38,20 @@ const runtimeEnv =
38
38
  : {};
39
39
  const env = { ...(import.meta.env ?? {}), ...runtimeEnv } as StudioFeatureFlagEnv;
40
40
 
41
- // Stage 7 Step 3c: SDK cutover — routes inline-style ops through SDK dispatch
42
- // instead of the server patch-element API. Default false; enable via
43
- // VITE_STUDIO_SDK_CUTOVER_ENABLED=true. Requires SDK session to be open.
41
+ // Stage 7 SDK cutover — routes Studio edits through SDK dispatch instead of the
42
+ // server patch/mutation routes. Default TRUE as of the flip release (spec:
43
+ // 2026-09-17-studio-sdk-cutover-flip-design.md). Kill switch:
44
+ // VITE_STUDIO_SDK_CUTOVER_ENABLED=false (works at runtime via __HF_STUDIO_ENV__).
44
45
  export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag(
45
46
  env,
46
47
  ["VITE_STUDIO_SDK_CUTOVER_ENABLED"],
47
- false,
48
+ true,
48
49
  );
49
50
 
50
- /** Explicit per-operation-family canary selection; the master flag alone enables nothing. */
51
+ /**
52
+ * Per-family selection. Unset = every family (the master alone enables all).
53
+ * Set `VITE_STUDIO_SDK_CUTOVER_FAMILIES=dom,timing` to RESTRICT to a subset.
54
+ */
51
55
  export const STUDIO_SDK_CUTOVER_FAMILIES = resolveEnabledSdkFamilies(
52
56
  env,
53
57
  STUDIO_SDK_CUTOVER_ENABLED,
@@ -12,6 +12,17 @@ vi.mock("@hyperframes/sdk", () => ({
12
12
  import type { Composition } from "@hyperframes/sdk";
13
13
  import { useSdkSession, type SdkSessionHandle } from "./useSdkSession";
14
14
 
15
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() }));
16
+
17
+ import { trackStudioEvent } from "../utils/studioTelemetry";
18
+
19
+ const trackMock = vi.mocked(trackStudioEvent);
20
+
21
+ function Probe({ projectId }: { projectId: string }) {
22
+ useSdkSession(projectId, "index.html");
23
+ return null;
24
+ }
25
+
15
26
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
16
27
 
17
28
  function fakeSession(): Composition {
@@ -135,3 +146,68 @@ describe("useSdkSession ownership", () => {
135
146
  expect(published.dispose).toHaveBeenCalledOnce();
136
147
  });
137
148
  });
149
+
150
+ describe("useSdkSession unavailable telemetry", () => {
151
+ beforeEach(() => {
152
+ openComposition.mockReset();
153
+ trackMock.mockClear();
154
+ class FakeEventSource {
155
+ addEventListener(): void {}
156
+ close(): void {}
157
+ }
158
+ vi.stubGlobal("EventSource", FakeEventSource);
159
+ });
160
+
161
+ afterEach(() => {
162
+ vi.unstubAllGlobals();
163
+ });
164
+
165
+ // Every cutover chokepoint silently takes the server path when there is no
166
+ // session, and the shadow never runs either — so a missing session is a total,
167
+ // otherwise-invisible SDK bypass. These three exits are its only origin.
168
+ it("reports an unreadable composition", async () => {
169
+ vi.stubGlobal(
170
+ "fetch",
171
+ vi.fn(async () => ({ ok: false, json: async () => ({}) }) as Response),
172
+ );
173
+ const root = createRoot(document.createElement("div"));
174
+ await act(async () => root.render(<Probe projectId="project-a" />));
175
+ await flushAsyncEffects();
176
+
177
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", { stage: "read" });
178
+ await act(async () => root.unmount());
179
+ });
180
+
181
+ it("reports a parse failure with its message", async () => {
182
+ vi.stubGlobal(
183
+ "fetch",
184
+ vi.fn(async () => response("PROJECT_A")),
185
+ );
186
+ openComposition.mockRejectedValue(new Error("unparseable composition"));
187
+
188
+ const root = createRoot(document.createElement("div"));
189
+ await act(async () => root.render(<Probe projectId="project-a" />));
190
+ await flushAsyncEffects();
191
+
192
+ expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
193
+ stage: "open",
194
+ error: "unparseable composition",
195
+ });
196
+ await act(async () => root.unmount());
197
+ });
198
+
199
+ it("stays silent on the happy path", async () => {
200
+ vi.stubGlobal(
201
+ "fetch",
202
+ vi.fn(async () => response("PROJECT_A")),
203
+ );
204
+ openComposition.mockResolvedValue(fakeSession());
205
+
206
+ const root = createRoot(document.createElement("div"));
207
+ await act(async () => root.render(<Probe projectId="project-a" />));
208
+ await flushAsyncEffects();
209
+
210
+ expect(trackMock).not.toHaveBeenCalledWith("sdk_session_unavailable", expect.anything());
211
+ await act(async () => root.unmount());
212
+ });
213
+ });
@@ -181,7 +181,15 @@ export function useSdkSession(
181
181
 
182
182
  readProjectFileOptional(projectId, activeCompPath)
183
183
  .then(async (content) => {
184
- if (cancelled || typeof content !== "string") return;
184
+ if (cancelled) return;
185
+ if (typeof content !== "string") {
186
+ // No SDK session follows, so EVERY cutover chokepoint below takes the
187
+ // server path and emits nothing — the shadow never runs either. This
188
+ // is the only place a missing session can originate, so a broken read
189
+ // would otherwise be a silent, total SDK bypass.
190
+ trackStudioEvent("sdk_session_unavailable", { stage: "read" });
191
+ return;
192
+ }
185
193
  // No persist queue: Studio's writeProjectFile (via sdkCutover's
186
194
  // persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
187
195
  // queue too would double-write the file (queue auto-writes on every
@@ -206,6 +214,7 @@ export function useSdkSession(
206
214
  )
207
215
  ) {
208
216
  disposeSdkSession(comp);
217
+ trackStudioEvent("sdk_session_unavailable", { stage: "ownership" });
209
218
  return;
210
219
  }
211
220
  const displaced = ownedSessionRef.current;
@@ -215,8 +224,17 @@ export function useSdkSession(
215
224
  setOwnedSession(installed);
216
225
  if (displaced && displaced.session !== comp) disposeSdkSession(displaced.session);
217
226
  })
218
- .catch(() => {
219
- if (!cancelled && generationRef.current === generation) setOwnedSession(null);
227
+ .catch((error: unknown) => {
228
+ if (!cancelled && generationRef.current === generation) {
229
+ setOwnedSession(null);
230
+ // openComposition threw (unparseable composition, OOM) — same total
231
+ // bypass as the read failure above, but this one is a real defect
232
+ // rather than a missing file. Carry the message; it is the only clue.
233
+ trackStudioEvent("sdk_session_unavailable", {
234
+ stage: "open",
235
+ error: error instanceof Error ? error.message : String(error),
236
+ });
237
+ }
220
238
  });
221
239
 
222
240
  return () => {
@@ -45,6 +45,7 @@ import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../li
45
45
  import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
46
46
  import { createPreviewMessageHandler } from "./previewMessageRouter";
47
47
  import { timelineElementsChanged } from "./timelinePlayerSync";
48
+ import { safeContentDocument } from "./timelineSyncHydration";
48
49
 
49
50
  export function useTimelinePlayer() {
50
51
  const iframeRef = useRef<HTMLIFrameElement | null>(null);
@@ -60,7 +61,7 @@ export function useTimelinePlayer() {
60
61
  const staticSeekAdapterRef = useRef<StaticSeekCacheEntry | null>(null);
61
62
  const staticSeekWarnedRef = useRef(false);
62
63
 
63
- const { setIsPlaying, setCurrentTime, setDuration, setTimelineReady, setElements } =
64
+ const { setIsPlaying, setCurrentTime, setDuration, requestTimelineReady, setElements } =
64
65
  usePlayerStore.getState();
65
66
 
66
67
  // The fixture lease belongs at this shared synchronization boundary so every
@@ -101,7 +102,11 @@ export function useTimelinePlayer() {
101
102
  setDuration(nextDuration ?? 0);
102
103
  }
103
104
  if (!state.timelineReady) {
104
- setTimelineReady(true);
105
+ // Same gate as initializeAdapter's own readiness wait: this is the
106
+ // message-based fallback for compositions that report their duration
107
+ // via clip manifest rather than the direct adapter, and it must not
108
+ // enable Play any earlier than that path does.
109
+ requestTimelineReady(safeContentDocument(iframeRef.current));
105
110
  }
106
111
 
107
112
  // Asynchronously enrich media elements still missing sourceDuration
@@ -120,7 +125,7 @@ export function useTimelinePlayer() {
120
125
  },
121
126
  );
122
127
  },
123
- [setElements, setTimelineReady, setDuration],
128
+ [setElements, requestTimelineReady, setDuration],
124
129
  );
125
130
 
126
131
  // Pre-existing dispatcher complexity — surfaced by this PR's line shifts, not new logic.
@@ -425,7 +430,7 @@ export function useTimelinePlayer() {
425
430
  syncTimelineElements,
426
431
  setDuration,
427
432
  setCurrentTime,
428
- setTimelineReady,
433
+ requestTimelineReady,
429
434
  setIsPlaying,
430
435
  attachIframeShortcutListeners,
431
436
  applyPreviewAudioState,
@@ -42,7 +42,7 @@ interface UseTimelineSyncCallbacksParams {
42
42
  syncTimelineElements: (elements: TimelineElement[], nextDuration?: number) => void;
43
43
  setDuration: (v: number) => void;
44
44
  setCurrentTime: (v: number) => void;
45
- setTimelineReady: (v: boolean) => void;
45
+ requestTimelineReady: (doc: Document | null) => void;
46
46
  setIsPlaying: (v: boolean) => void;
47
47
  attachIframeShortcutListeners: () => void;
48
48
  applyPreviewAudioState: () => void;
@@ -106,7 +106,7 @@ export function useTimelineSyncCallbacks({
106
106
  syncTimelineElements,
107
107
  setDuration,
108
108
  setCurrentTime,
109
- setTimelineReady,
109
+ requestTimelineReady,
110
110
  setIsPlaying,
111
111
  attachIframeShortcutListeners,
112
112
  applyPreviewAudioState,
@@ -204,7 +204,10 @@ export function useTimelineSyncCallbacks({
204
204
  syncAdapterDuration(adapter, setDuration);
205
205
  setCurrentTime(startTime);
206
206
  if (!isRefreshingRef.current) {
207
- setTimelineReady(true);
207
+ // Enables Play from actual play-readiness, not just a known duration —
208
+ // a click before this resolves used to start the timeline with media,
209
+ // images or fonts still loading and never recover.
210
+ requestTimelineReady(safeContentDocument(iframeRef.current));
208
211
  }
209
212
  isRefreshingRef.current = false;
210
213
  setIsPlaying(false);
@@ -223,7 +226,7 @@ export function useTimelineSyncCallbacks({
223
226
  getAdapter,
224
227
  setDuration,
225
228
  setCurrentTime,
226
- setTimelineReady,
229
+ requestTimelineReady,
227
230
  setIsPlaying,
228
231
  processTimelineMessage,
229
232
  enrichMissingCompositions,
@@ -1,3 +1,4 @@
1
+ // @vitest-environment happy-dom
1
2
  import { describe, it, expect, beforeEach, vi } from "vitest";
2
3
  import { usePlayerStore, liveTime, type TimelineElement } from "./playerStore";
3
4
 
@@ -322,6 +323,47 @@ describe("usePlayerStore", () => {
322
323
  });
323
324
  });
324
325
 
326
+ describe("requestTimelineReady", () => {
327
+ function stalledVideoDoc(): { doc: Document; video: HTMLVideoElement } {
328
+ const doc = document.implementation.createHTMLDocument("composition");
329
+ const video = doc.createElement("video");
330
+ Object.defineProperty(video, "readyState", { value: 0, configurable: true });
331
+ doc.body.appendChild(video);
332
+ return { doc, video };
333
+ }
334
+
335
+ it("resolves timelineReady once the doc's media settles", async () => {
336
+ const { doc, video } = stalledVideoDoc();
337
+ usePlayerStore.getState().requestTimelineReady(doc);
338
+ expect(usePlayerStore.getState().timelineReady).toBe(false);
339
+
340
+ video.dispatchEvent(new Event("canplay"));
341
+ await new Promise((resolve) => setTimeout(resolve, 0));
342
+
343
+ expect(usePlayerStore.getState().timelineReady).toBe(true);
344
+ });
345
+
346
+ it("a stale wait from a project switched away from never marks the new project ready", async () => {
347
+ // beginTimelineSession no-ops when projectId is unchanged, so pin a
348
+ // known starting project first rather than relying on reset()'s
349
+ // leftover timelineProjectId from whatever test ran before this one.
350
+ usePlayerStore.getState().beginTimelineSession("readiness-test-project-a");
351
+ const { doc: docA, video: videoA } = stalledVideoDoc();
352
+ usePlayerStore.getState().requestTimelineReady(docA);
353
+
354
+ // Switches projects mid-wait — beginTimelineSession resets timelineReady
355
+ // to false for project B, whose own media has not been checked at all.
356
+ usePlayerStore.getState().beginTimelineSession("readiness-test-project-b");
357
+ expect(usePlayerStore.getState().timelineReady).toBe(false);
358
+
359
+ // A's wait settles late; it must not resolve into B's readiness.
360
+ videoA.dispatchEvent(new Event("canplay"));
361
+ await new Promise((resolve) => setTimeout(resolve, 0));
362
+
363
+ expect(usePlayerStore.getState().timelineReady).toBe(false);
364
+ });
365
+ });
366
+
325
367
  describe("setElements", () => {
326
368
  it("sets the elements array", () => {
327
369
  const elements: TimelineElement[] = [
@@ -19,6 +19,7 @@ import {
19
19
  import { createEditingModeSlice, type EditingModeSlice } from "./editingModeSlice";
20
20
  import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
21
21
  import { createThumbnailSlice, type ThumbnailSlice } from "./thumbnailSlice";
22
+ import { createPlaybackReadinessSlice, resetPlaybackReadinessState } from "./readinessSlice";
22
23
 
23
24
  export type { KeyframeCacheEntry } from "./keyframeSlice";
24
25
  export { liveTime } from "./liveTime";
@@ -54,12 +55,15 @@ function resolveElementSelection(
54
55
  };
55
56
  }
56
57
 
57
- interface PlayerState
58
- extends KeyframeSlice, AutomationSelectionSlice, ThumbnailSlice, EditingModeSlice {
58
+ type PlayerStoreSlices = KeyframeSlice &
59
+ AutomationSelectionSlice &
60
+ ThumbnailSlice &
61
+ EditingModeSlice &
62
+ ReturnType<typeof createPlaybackReadinessSlice>;
63
+ interface PlayerState extends PlayerStoreSlices {
59
64
  isPlaying: boolean;
60
65
  currentTime: number;
61
66
  duration: number;
62
- timelineReady: boolean;
63
67
  /** Increments exactly once when the Studio switches to a different project. */
64
68
  timelineSessionEpoch: number;
65
69
  /** Project owning the current timeline session; null outside a project-scoped reset. */
@@ -129,7 +133,6 @@ interface PlayerState
129
133
  setAudioMuted: (muted: boolean) => void;
130
134
  setAudioVolume: (volume: number) => void;
131
135
  setLoopEnabled: (enabled: boolean) => void;
132
- setTimelineReady: (ready: boolean) => void;
133
136
  setBeatDragging: (dragging: boolean) => void;
134
137
  setElements: (elements: TimelineElement[]) => void;
135
138
  setSelectedElementId: (id: string | null, options?: SelectElementOptions) => void;
@@ -260,7 +263,7 @@ export function createTimelineResetState() {
260
263
  isPlaying: false,
261
264
  currentTime: 0,
262
265
  duration: 0,
263
- timelineReady: false,
266
+ ...resetPlaybackReadinessState(),
264
267
  beatDragging: false,
265
268
  elements: [],
266
269
  selectedElementId: null,
@@ -304,7 +307,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
304
307
  isPlaying: false,
305
308
  currentTime: 0,
306
309
  duration: 0,
307
- timelineReady: false,
308
310
  timelineSessionEpoch: 0,
309
311
  timelineProjectId: null,
310
312
  beatDragging: false,
@@ -333,6 +335,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
333
335
 
334
336
  ...createAutomationSelectionSlice(set),
335
337
  ...createEditingModeSlice(set),
338
+ ...createPlaybackReadinessSlice(set),
336
339
 
337
340
  activeKeyframePct: null,
338
341
  setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
@@ -521,7 +524,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
521
524
  bumpZEditVersion: () => set((state) => ({ zEditVersion: state.zEditVersion + 1 })),
522
525
  setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
523
526
  setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }),
524
- setTimelineReady: (ready) => set({ timelineReady: ready }),
525
527
  setBeatDragging: (dragging) => set({ beatDragging: dragging }),
526
528
  setElements: (elements) => set({ elements }),
527
529
  // A genuine single selection: always collapse the set to just this element. User
@@ -0,0 +1,42 @@
1
+ /** Gates `timelineReady` on the composition's declared readiness inputs
2
+ * (media, for now) instead of just a known duration. The generation counter
3
+ * is module-scope, not store state: it guards an in-flight settlement, not
4
+ * something a component reads, so bumping it shouldn't trigger a render. */
5
+ import type { StoreApi } from "zustand";
6
+ import { settleCompositionReadiness } from "@hyperframes/core/composition-readiness";
7
+
8
+ export interface PlaybackReadinessSlice {
9
+ timelineReady: boolean;
10
+ setTimelineReady: (ready: boolean) => void;
11
+ /** Sets timelineReady once doc's readiness inputs settle, or immediately
12
+ * if doc is null. A wait a later call supersedes never wins the race. */
13
+ requestTimelineReady: (doc: Document | null) => void;
14
+ }
15
+
16
+ let timelineReadyGeneration = 0;
17
+
18
+ /** For a full timeline reset: bumps the generation so any requestTimelineReady
19
+ * wait in flight can never resolve into what replaced it. */
20
+ export function resetPlaybackReadinessState(): Pick<PlaybackReadinessSlice, "timelineReady"> {
21
+ timelineReadyGeneration++;
22
+ return { timelineReady: false };
23
+ }
24
+
25
+ export function createPlaybackReadinessSlice(
26
+ set: StoreApi<PlaybackReadinessSlice>["setState"],
27
+ ): PlaybackReadinessSlice {
28
+ return {
29
+ timelineReady: false,
30
+ setTimelineReady: (ready) => {
31
+ timelineReadyGeneration++;
32
+ set({ timelineReady: ready });
33
+ },
34
+ requestTimelineReady: (doc) => {
35
+ const generation = ++timelineReadyGeneration;
36
+ if (!doc) return set({ timelineReady: true });
37
+ settleCompositionReadiness(doc, () => {
38
+ if (generation === timelineReadyGeneration) set({ timelineReady: true });
39
+ });
40
+ },
41
+ };
42
+ }
@@ -1,11 +1,12 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
 
3
- // Dark-launch contract: with STUDIO_SDK_CUTOVER_ENABLED=false, EVERY cutover
3
+ // Kill-switch contract: with VITE_STUDIO_SDK_CUTOVER_ENABLED=false, EVERY cutover
4
4
  // persist chokepoint must explicitly decline so the caller takes the legacy server
5
5
  // path — even when a valid SDK session exists (one always does, for
6
- // shadow/selection). This is the contract the prod flag-flip rests on; a future
7
- // refactor of the gate guards that silently re-enables cutover on flag-off
8
- // turns these red. (sdkCutover.test.ts mocks the flag TRUE; this is its sibling.)
6
+ // shadow/selection). Default is ON (manualEditingAvailability.test.ts pins that);
7
+ // this file pins that the env override still turns it fully off. A future
8
+ // refactor of the gate guards that ignores the flag turns these red.
9
+ // (sdkCutover.test.ts mocks the flag TRUE; this is its sibling.)
9
10
  vi.mock("../components/editor/manualEditingAvailability", () => ({
10
11
  STUDIO_SDK_CUTOVER_ENABLED: false,
11
12
  STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
@@ -23,6 +23,8 @@ vi.mock("./studioTelemetry", () => ({
23
23
  trackStudioEvent: vi.fn(),
24
24
  }));
25
25
 
26
+ import { trackStudioEvent } from "./studioTelemetry";
27
+
26
28
  const styleOp = (property: string, value: string): PatchOperation => ({
27
29
  type: "inline-style",
28
30
  property,
@@ -202,6 +204,46 @@ describe("sdkCutoverPersist", () => {
202
204
  expect(result.status).toBe("declined");
203
205
  });
204
206
 
207
+ it("tags target_not_found with resolverDisagreement when dispatch could have resolved it", async () => {
208
+ // getElement is canonical-only for a bare id; resolveSnapshot mirrors what
209
+ // dispatch resolves (bare ids anywhere). An element present under a scoped
210
+ // id is dispatchable, so getElement refusing it is a resolver disagreement
211
+ // — and one the shadow event stays silent about.
212
+ const deps = makeDeps();
213
+ const session = makeSession(false);
214
+ (session as unknown as { getElements: () => unknown[] }).getElements = () => [
215
+ { id: "hf-abc", scopedId: "host/hf-abc" },
216
+ ];
217
+ const sel = { hfId: "hf-abc" } as never;
218
+
219
+ await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/path.html", session, deps);
220
+
221
+ expect(trackStudioEvent).toHaveBeenCalledWith(
222
+ "sdk_cutover_declined",
223
+ expect.objectContaining({ reason: "target_not_found", resolverDisagreement: true }),
224
+ );
225
+ });
226
+
227
+ it("does not tag resolverDisagreement when the element is genuinely absent", async () => {
228
+ const deps = makeDeps();
229
+ const session = makeSession(false);
230
+ (session as unknown as { getElements: () => unknown[] }).getElements = () => [
231
+ { id: "hf-other", scopedId: "hf-other" },
232
+ ];
233
+ const sel = { hfId: "hf-abc" } as never;
234
+ vi.mocked(trackStudioEvent).mockClear();
235
+
236
+ await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/path.html", session, deps);
237
+
238
+ expect(trackStudioEvent).toHaveBeenLastCalledWith(
239
+ "sdk_cutover_declined",
240
+ expect.objectContaining({ reason: "target_not_found" }),
241
+ );
242
+ expect(vi.mocked(trackStudioEvent).mock.lastCall?.[1]).not.toHaveProperty(
243
+ "resolverDisagreement",
244
+ );
245
+ });
246
+
205
247
  it("dispatches setStyle for inline-style ops", async () => {
206
248
  const deps = makeDeps();
207
249
  const session = makeSession(true);