@hyperframes/studio 0.8.18 → 0.8.20

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.18",
3
+ "version": "0.8.20",
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.18",
52
- "@hyperframes/parsers": "0.8.18",
53
- "@hyperframes/player": "0.8.18",
54
- "@hyperframes/sdk": "0.8.18",
55
- "@hyperframes/studio-server": "0.8.18"
51
+ "@hyperframes/core": "0.8.20",
52
+ "@hyperframes/sdk": "0.8.20",
53
+ "@hyperframes/player": "0.8.20",
54
+ "@hyperframes/studio-server": "0.8.20",
55
+ "@hyperframes/parsers": "0.8.20"
56
56
  },
57
57
  "devDependencies": {
58
58
  "@types/react": "19",
@@ -69,7 +69,7 @@
69
69
  "vite": "^6.4.2",
70
70
  "vitest": "^3.2.4",
71
71
  "zustand": "^5.0.0",
72
- "@hyperframes/producer": "0.8.18"
72
+ "@hyperframes/producer": "0.8.20"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "react": "19",
package/src/App.tsx CHANGED
@@ -84,7 +84,7 @@ export function StudioApp() {
84
84
  const activeCompPathRef = useRef(activeCompPath);
85
85
  activeCompPathRef.current = activeCompPath;
86
86
  const leftSidebarRef = useRef<LeftSidebarHandle>(null);
87
- const renderQueue = useRenderQueue(projectId);
87
+ const renderQueue = useRenderQueue(projectId, activeCompPathRef);
88
88
  const captionEditMode = useCaptionStore((s) => s.isEditMode);
89
89
  const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0);
90
90
  const captionSync = useCaptionSync(projectId);
@@ -12,13 +12,8 @@ import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore";
12
12
  * without giving anything a second reader.
13
13
  */
14
14
  export const RenderQueuePanel = memo(function RenderQueuePanel() {
15
- const {
16
- projectId,
17
- activeCompPath,
18
- compositionDimensions,
19
- waitForPendingDomEditSaves,
20
- renderQueue,
21
- } = useStudioShellContext();
15
+ const { projectId, compositionDimensions, waitForPendingDomEditSaves, renderQueue } =
16
+ useStudioShellContext();
22
17
 
23
18
  return (
24
19
  <RenderQueue
@@ -36,14 +31,12 @@ export const RenderQueuePanel = memo(function RenderQueuePanel() {
36
31
  onRecheckFfmpeg={renderQueue.recheckFfmpeg}
37
32
  onStartRender={async (format, quality, resolution, fps) => {
38
33
  await waitForPendingDomEditSaves();
39
- const composition =
40
- activeCompPath && activeCompPath !== "index.html" ? activeCompPath : undefined;
34
+ // No `composition`: startRender targets the active one by default.
41
35
  await renderQueue.startRender({
42
36
  fps,
43
37
  quality,
44
38
  format,
45
39
  resolution,
46
- composition,
47
40
  // Render what the user is previewing: active variable overrides
48
41
  // from the Variables panel ride along (undefined = defaults).
49
42
  variables: usePreviewVariablesStore.getState().values ?? undefined,
@@ -49,13 +49,39 @@ export interface MountedQueue {
49
49
  unmount: () => void;
50
50
  }
51
51
 
52
+ /**
53
+ * Mounts the hook, starts one render, and returns the body of the POST it
54
+ * made — the only place Studio states what to render and who to attribute it
55
+ * to, so it is what the tests around it assert on. The caller keeps the
56
+ * returned queue to unmount it.
57
+ */
58
+ export async function startRenderAndReadBody(
59
+ useRenderQueueHook: UseRenderQueue,
60
+ {
61
+ activeCompPath = null,
62
+ opts,
63
+ }: { activeCompPath?: string | null; opts?: Parameters<RenderQueueApi["startRender"]>[0] } = {},
64
+ ): Promise<{ body: Record<string, unknown>; queue: MountedQueue }> {
65
+ const fetchMock = stubRenderFetch();
66
+ const queue = mountRenderQueue(useRenderQueueHook, "demo", activeCompPath);
67
+ await act(async () => {
68
+ await queue.api().startRender(opts);
69
+ });
70
+ const [post] = renderPosts(fetchMock) as [undefined | [string, RequestInit]];
71
+ const body = post?.[1]?.body;
72
+ if (body === undefined || body === null) throw new Error("hook made no POST with a body");
73
+ return { body: JSON.parse(String(body)) as Record<string, unknown>, queue };
74
+ }
75
+
52
76
  export function mountRenderQueue(
53
77
  useRenderQueueHook: UseRenderQueue,
54
78
  projectId = "demo",
79
+ activeCompPath: string | null = null,
55
80
  ): MountedQueue {
56
81
  let current: RenderQueueApi | null = null;
82
+ const activeCompPathRef = { current: activeCompPath };
57
83
  function Harness(): null {
58
- current = useRenderQueueHook(projectId);
84
+ current = useRenderQueueHook(projectId, activeCompPathRef);
59
85
  return null;
60
86
  }
61
87
  const host = document.createElement("div");
@@ -30,7 +30,11 @@ export interface StartRenderOptions {
30
30
  format?: "mp4" | "webm" | "mov";
31
31
  /** `"auto"` (default) renders at the composition's authored dimensions. */
32
32
  resolution?: ResolutionPreset | "auto";
33
- /** Render a specific composition file instead of index.html. */
33
+ /**
34
+ * Render a specific composition file. Omit it to render the composition the
35
+ * user currently has open — only the sidebar's per-composition Render button
36
+ * names one, because it renders a card the user is not looking at.
37
+ */
34
38
  composition?: string;
35
39
  /**
36
40
  * Composition-variable overrides ({variableId: value}), forwarded to the
@@ -66,7 +70,13 @@ function writeHiddenIds(projectId: string, ids: Set<string>): void {
66
70
  }
67
71
  }
68
72
 
69
- export function useRenderQueue(projectId: string | null) {
73
+ export function useRenderQueue(
74
+ projectId: string | null,
75
+ // A ref, not the value: the render target has to be read at click time, and
76
+ // threading the value through would rebuild every callback below on each
77
+ // composition switch.
78
+ activeCompPathRef: { current: string | null },
79
+ ) {
70
80
  const [jobs, setJobs] = useState<RenderJob[]>([]);
71
81
  // History fetch failure — distinguished from "no renders yet" so the panel
72
82
  // never shows a false empty state.
@@ -185,7 +195,13 @@ export function useRenderQueue(projectId: string | null) {
185
195
  const quality = opts.quality ?? "standard";
186
196
  const format = opts.format ?? "mp4";
187
197
  const resolution = opts.resolution;
188
- const composition = opts.composition;
198
+ // Which composition a render targets belongs here, with the same
199
+ // argument the FFmpeg gate above makes: Studio starts renders from three
200
+ // controls, and a default living in one of them leaves the others
201
+ // exporting a file the user is not looking at. The header's Export
202
+ // passed no options at all, so every render it started went to
203
+ // index.html no matter which composition was selected (#3549).
204
+ const composition = opts.composition ?? activeCompPathRef.current ?? undefined;
189
205
 
190
206
  trackStudioRenderStart({
191
207
  fps,
@@ -344,7 +360,7 @@ export function useRenderQueue(projectId: string | null) {
344
360
 
345
361
  return jobId;
346
362
  },
347
- [projectId, closeActiveEventSource, addSessionJob, ffmpeg, ffmpegMissing],
363
+ [projectId, activeCompPathRef, closeActiveEventSource, addSessionJob, ffmpeg, ffmpegMissing],
348
364
  );
349
365
 
350
366
  // Cancel an in-flight render. The job row stays (as "cancelled") so the
@@ -0,0 +1,55 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ // The render POST is the only place Studio says WHICH file to render. When it
4
+ // says nothing the server falls back to index.html, so a caller that forgets
5
+ // the field does not fail — it silently exports the wrong video (#3549). The
6
+ // default therefore lives in startRender, which every control routes through.
7
+
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ import { startRenderAndReadBody, type MountedQueue } from "./renderQueueTestHarness";
10
+
11
+ vi.mock("../../telemetry/policy", () => ({ browserTelemetryAllowed: () => false }));
12
+ vi.mock("../../telemetry/config", () => ({ getAnonymousId: () => "unused" }));
13
+ vi.mock("../../telemetry/events", () => ({ trackStudioRenderStart: vi.fn() }));
14
+
15
+ const { useRenderQueue } = await import("./useRenderQueue");
16
+
17
+ let queue: MountedQueue | null = null;
18
+
19
+ /** Body of the render POST, started with `opts` while `activeCompPath` is open. */
20
+ async function renderBody(
21
+ activeCompPath: string | null,
22
+ opts?: Parameters<ReturnType<typeof useRenderQueue>["startRender"]>[0],
23
+ ): Promise<Record<string, unknown>> {
24
+ const started = await startRenderAndReadBody(useRenderQueue, { activeCompPath, opts });
25
+ queue = started.queue;
26
+ return started.body;
27
+ }
28
+
29
+ afterEach(() => {
30
+ queue?.unmount();
31
+ queue = null;
32
+ document.body.innerHTML = "";
33
+ vi.unstubAllGlobals();
34
+ });
35
+
36
+ describe("render target composition", () => {
37
+ it("renders the composition the user has selected when the caller names none", async () => {
38
+ // The header's Export button: no options at all.
39
+ const body = await renderBody("parts/part-1.html", undefined);
40
+ expect(body["composition"]).toBe("parts/part-1.html");
41
+ });
42
+
43
+ it("keeps the caller's composition when one is named", async () => {
44
+ // The sidebar's per-composition Render button renders a card the user is
45
+ // not looking at, so its argument must win over the active composition.
46
+ const body = await renderBody("parts/part-1.html", { composition: "parts/part-4.html" });
47
+ expect(body["composition"]).toBe("parts/part-4.html");
48
+ });
49
+
50
+ it("omits the composition when nothing is selected", async () => {
51
+ // Master view. The server's index.html fallback is the right answer here.
52
+ const body = await renderBody(null, { format: "mp4" });
53
+ expect(body["composition"]).toBeUndefined();
54
+ });
55
+ });
@@ -7,9 +7,8 @@
7
7
  // install id rather than the user's, which is worse than attributing it
8
8
  // correctly.
9
9
 
10
- import { act } from "react";
11
10
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
12
- import { mountRenderQueue, renderPosts, stubRenderFetch } from "./renderQueueTestHarness";
11
+ import { startRenderAndReadBody, type MountedQueue } from "./renderQueueTestHarness";
13
12
 
14
13
  const policyState = { allowed: true };
15
14
  const mintCalls = vi.fn(() => "browser-user-123");
@@ -26,20 +25,15 @@ vi.mock("../../telemetry/events", () => ({
26
25
 
27
26
  const { useRenderQueue } = await import("./useRenderQueue");
28
27
 
29
- let queue: ReturnType<typeof mountRenderQueue> | null = null;
28
+ let queue: MountedQueue | null = null;
30
29
 
31
30
  /** Body of the POST the hook makes when a render is started. */
32
31
  async function startRenderBody(): Promise<Record<string, unknown>> {
33
- const fetchMock = stubRenderFetch();
34
- queue = mountRenderQueue(useRenderQueue);
35
- await act(async () => {
36
- await queue?.api().startRender({ fps: 30, quality: "standard", format: "mp4" });
32
+ const started = await startRenderAndReadBody(useRenderQueue, {
33
+ opts: { fps: 30, quality: "standard", format: "mp4" },
37
34
  });
38
-
39
- const [post] = renderPosts(fetchMock) as [undefined | [string, RequestInit]];
40
- const body = post?.[1]?.body;
41
- if (body === undefined || body === null) throw new Error("hook made no POST with a body");
42
- return JSON.parse(String(body)) as Record<string, unknown>;
35
+ queue = started.queue;
36
+ return started.body;
43
37
  }
44
38
 
45
39
  beforeEach(() => {
@@ -2,8 +2,15 @@
2
2
  import { act } from "react";
3
3
  import { createRoot } from "react-dom/client";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
- import { useEditorSave, type EditorSaveHandle } from "./useEditorSave";
5
+
6
+ const trackStudioSaveFailure = vi.hoisted(() => vi.fn());
7
+ vi.mock("../utils/studioSaveDiagnostics", async (importOriginal) => ({
8
+ ...(await importOriginal<typeof import("../utils/studioSaveDiagnostics")>()),
9
+ trackStudioSaveFailure,
10
+ }));
11
+
6
12
  import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
13
+ import { useEditorSave, type EditorSaveHandle } from "./useEditorSave";
7
14
 
8
15
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
16
 
@@ -11,6 +18,7 @@ type WriteProjectFile = (path: string, content: string, expectedContent?: string
11
18
 
12
19
  async function mountEditorSave(writeProjectFile: WriteProjectFile) {
13
20
  const captured: { handle: EditorSaveHandle | null } = { handle: null };
21
+ const showToast = vi.fn();
14
22
 
15
23
  function Probe() {
16
24
  captured.handle = useEditorSave({
@@ -21,7 +29,7 @@ async function mountEditorSave(writeProjectFile: WriteProjectFile) {
21
29
  recordEdit: vi.fn(async () => undefined),
22
30
  domEditSaveTimestampRef: { current: 0 },
23
31
  setRefreshKey: vi.fn(),
24
- showToast: vi.fn(),
32
+ showToast,
25
33
  });
26
34
  return null;
27
35
  }
@@ -32,12 +40,14 @@ async function mountEditorSave(writeProjectFile: WriteProjectFile) {
32
40
 
33
41
  return {
34
42
  handle: captured.handle,
43
+ showToast,
35
44
  unmount: () => act(async () => root.unmount()),
36
45
  };
37
46
  }
38
47
 
39
48
  describe("useEditorSave pending work", () => {
40
49
  beforeEach(() => {
50
+ trackStudioSaveFailure.mockClear();
41
51
  vi.stubGlobal(
42
52
  "requestAnimationFrame",
43
53
  vi.fn(() => 41),
@@ -45,7 +55,10 @@ describe("useEditorSave pending work", () => {
45
55
  vi.stubGlobal("cancelAnimationFrame", vi.fn());
46
56
  });
47
57
 
48
- afterEach(() => vi.unstubAllGlobals());
58
+ afterEach(() => {
59
+ vi.restoreAllMocks();
60
+ vi.unstubAllGlobals();
61
+ });
49
62
 
50
63
  it("exposes and flushes the latest rAF-buffered source candidate", async () => {
51
64
  const writeProjectFile = vi.fn(async () => undefined);
@@ -111,7 +124,72 @@ describe("useEditorSave pending work", () => {
111
124
  status: "conflict",
112
125
  error: conflict,
113
126
  });
127
+ expect(trackStudioSaveFailure).toHaveBeenCalledWith({
128
+ source: "code_editor",
129
+ error: conflict,
130
+ filePath: "index.html",
131
+ });
132
+
133
+ await mounted.unmount();
134
+ });
135
+
136
+ it("emits one identical failure per five-second burst", async () => {
137
+ vi.spyOn(Date, "now").mockReturnValue(1_000);
138
+ const error = new Error("Load failed");
139
+ const mounted = await mountEditorSave(async () => {
140
+ throw error;
141
+ });
142
+
143
+ act(() => mounted.handle.handleContentChange("first candidate"));
144
+ await mounted.handle.flushPendingSave();
145
+ vi.spyOn(Date, "now").mockReturnValue(2_000);
146
+ act(() => mounted.handle.handleContentChange("second candidate"));
147
+ await mounted.handle.flushPendingSave();
148
+
149
+ expect(trackStudioSaveFailure).toHaveBeenCalledOnce();
150
+ expect(mounted.showToast).toHaveBeenCalledOnce();
151
+ await mounted.unmount();
152
+ });
153
+
154
+ it("emits a changed failure immediately and repeats after the burst window", async () => {
155
+ const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
156
+ const writeProjectFile = vi
157
+ .fn<WriteProjectFile>()
158
+ .mockRejectedValueOnce(new Error("Load failed"))
159
+ .mockRejectedValueOnce(new Error("Failed to fetch"))
160
+ .mockRejectedValueOnce(new Error("Failed to fetch"));
161
+ const mounted = await mountEditorSave(writeProjectFile);
162
+
163
+ act(() => mounted.handle.handleContentChange("first candidate"));
164
+ await mounted.handle.flushPendingSave();
165
+ now.mockReturnValue(2_000);
166
+ act(() => mounted.handle.handleContentChange("second candidate"));
167
+ await mounted.handle.flushPendingSave();
168
+ now.mockReturnValue(8_000);
169
+ act(() => mounted.handle.handleContentChange("third candidate"));
170
+ await mounted.handle.flushPendingSave();
171
+
172
+ expect(trackStudioSaveFailure).toHaveBeenCalledTimes(3);
173
+ await mounted.unmount();
174
+ });
175
+
176
+ it("emits the same failure again after a successful save", async () => {
177
+ vi.spyOn(Date, "now").mockReturnValue(1_000);
178
+ const writeProjectFile = vi
179
+ .fn<WriteProjectFile>()
180
+ .mockRejectedValueOnce(new Error("Load failed"))
181
+ .mockResolvedValueOnce(undefined)
182
+ .mockRejectedValueOnce(new Error("Load failed"));
183
+ const mounted = await mountEditorSave(writeProjectFile);
184
+
185
+ act(() => mounted.handle.handleContentChange("first candidate"));
186
+ await mounted.handle.flushPendingSave();
187
+ act(() => mounted.handle.handleContentChange("successful candidate"));
188
+ await mounted.handle.flushPendingSave();
189
+ act(() => mounted.handle.handleContentChange("third candidate"));
190
+ await mounted.handle.flushPendingSave();
114
191
 
192
+ expect(trackStudioSaveFailure).toHaveBeenCalledTimes(2);
115
193
  await mounted.unmount();
116
194
  });
117
195
 
@@ -1,12 +1,15 @@
1
1
  import { useCallback, useRef } from "react";
2
2
  import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
3
3
  import type { EditHistoryKind } from "../utils/editHistory";
4
- import { trackStudioEvent } from "../utils/studioTelemetry";
5
4
  import {
6
5
  StudioFileConflictError,
6
+ buildStudioSaveFailureProperties,
7
+ trackStudioSaveFailure,
7
8
  type StudioSaveDrainResult,
8
9
  } from "../utils/studioSaveDiagnostics";
9
10
 
11
+ const FAILURE_BURST_MS = 5_000;
12
+
10
13
  interface RecordEditInput {
11
14
  label: string;
12
15
  kind: EditHistoryKind;
@@ -58,19 +61,40 @@ export function useEditorSave({
58
61
  const refreshRafRef = useRef<number | null>(null);
59
62
  // One error toast per burst of failures — every keystroke retries the save,
60
63
  // and error toasts persist until dismissed, so don't stack duplicates.
61
- const lastFailureToastAtRef = useRef(0);
64
+ const lastFailureToastAtRef = useRef<number | null>(null);
65
+ const lastFailureReportRef = useRef<{ fingerprint: string; emittedAt: number } | null>(null);
62
66
  const pendingCandidateRef = useRef<EditorSaveCandidate | null>(null);
63
67
  const inFlightRef = useRef<Promise<EditorSaveDrainResult> | null>(null);
64
68
  const inFlightCandidateRef = useRef<EditorSaveCandidate | null>(null);
65
69
 
66
70
  const reportFailure = useCallback(
67
71
  (path: string, error: unknown) => {
68
- trackStudioEvent("save_failure", {
72
+ const now = Date.now();
73
+ const properties = buildStudioSaveFailureProperties({
69
74
  source: "code_editor",
70
- error_message: error instanceof Error ? error.message : "unknown",
75
+ error,
76
+ filePath: path,
71
77
  });
72
- const now = Date.now();
73
- if (now - lastFailureToastAtRef.current > 5000) {
78
+ const errorName = error instanceof Error ? error.name : typeof error;
79
+ const fingerprint = JSON.stringify([
80
+ path,
81
+ errorName,
82
+ properties.error_message,
83
+ properties.status_code,
84
+ ]);
85
+ const previous = lastFailureReportRef.current;
86
+ if (
87
+ previous === null ||
88
+ previous.fingerprint !== fingerprint ||
89
+ now - previous.emittedAt >= FAILURE_BURST_MS
90
+ ) {
91
+ trackStudioSaveFailure({ source: "code_editor", error, filePath: path });
92
+ lastFailureReportRef.current = { fingerprint, emittedAt: now };
93
+ }
94
+ if (
95
+ lastFailureToastAtRef.current === null ||
96
+ now - lastFailureToastAtRef.current >= FAILURE_BURST_MS
97
+ ) {
74
98
  lastFailureToastAtRef.current = now;
75
99
  showToast(
76
100
  `Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
@@ -95,6 +119,7 @@ export function useEditorSave({
95
119
  })
96
120
  .then<EditorSaveDrainResult>(() => {
97
121
  if (pendingCandidateRef.current === candidate) pendingCandidateRef.current = null;
122
+ lastFailureReportRef.current = null;
98
123
  if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
99
124
  refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
100
125
  return { status: "clean" };
@@ -1,32 +1,49 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import React, { act } from "react";
4
- import { describe, expect, it, vi } from "vitest";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
6
6
  import { mountReactHarness } from "./domSelectionTestHarness";
7
7
  import { GsapEditBlockedError } from "./gsapEditOutcome";
8
8
 
9
9
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
10
 
11
- const trackStudioSaveFailure = vi.hoisted(() => vi.fn());
12
- vi.mock("../utils/studioSaveDiagnostics", () => ({ trackStudioSaveFailure }));
11
+ const { trackStudioEditBlocked, trackStudioSaveFailure } = vi.hoisted(() => ({
12
+ trackStudioEditBlocked: vi.fn(),
13
+ trackStudioSaveFailure: vi.fn(),
14
+ }));
15
+ vi.mock("../utils/studioSaveDiagnostics", () => ({
16
+ trackStudioEditBlocked,
17
+ trackStudioSaveFailure,
18
+ }));
13
19
 
14
20
  import { useGsapInteractionFailureTelemetry } from "./useGsapInteractionFailureTelemetry";
15
21
 
22
+ const selection = {
23
+ id: "clip",
24
+ selector: "#clip",
25
+ element: document.createElement("div"),
26
+ } as unknown as DomEditSelection;
27
+
28
+ function mountFailureTelemetry(showToast: ReturnType<typeof vi.fn>) {
29
+ let report!: ReturnType<typeof useGsapInteractionFailureTelemetry>;
30
+ function Harness() {
31
+ report = useGsapInteractionFailureTelemetry("index.html", showToast);
32
+ return null;
33
+ }
34
+ const root = mountReactHarness(<Harness />);
35
+ return { report, root };
36
+ }
37
+
16
38
  describe("useGsapInteractionFailureTelemetry", () => {
17
- it("surfaces the blocked reason instead of a generic save failure", () => {
39
+ beforeEach(() => {
40
+ trackStudioEditBlocked.mockClear();
41
+ trackStudioSaveFailure.mockClear();
42
+ });
43
+
44
+ it("tracks an expected edit block separately from save failures", () => {
18
45
  const showToast = vi.fn();
19
- const selection = {
20
- id: "clip",
21
- selector: "#clip",
22
- element: document.createElement("div"),
23
- } as unknown as DomEditSelection;
24
- let report!: ReturnType<typeof useGsapInteractionFailureTelemetry>;
25
- function Harness() {
26
- report = useGsapInteractionFailureTelemetry("index.html", showToast);
27
- return null;
28
- }
29
- const root = mountReactHarness(<Harness />);
46
+ const { report, root } = mountFailureTelemetry(showToast);
30
47
 
31
48
  act(() => report(new GsapEditBlockedError("unroll-required"), selection, "drag", "Move"));
32
49
 
@@ -34,9 +51,24 @@ describe("useGsapInteractionFailureTelemetry", () => {
34
51
  "This motion comes from a helper or loop. Choose Unroll to edit it explicitly.",
35
52
  "error",
36
53
  );
37
- expect(trackStudioSaveFailure).toHaveBeenCalledWith(
54
+ expect(trackStudioEditBlocked).toHaveBeenCalledWith(
38
55
  expect.objectContaining({ source: "gsap_commit", mutationType: "drag", targetId: "clip" }),
39
56
  );
57
+ expect(trackStudioSaveFailure).not.toHaveBeenCalled();
58
+ act(() => root.unmount());
59
+ });
60
+
61
+ it("keeps unexpected GSAP persistence errors in save_failure", () => {
62
+ const showToast = vi.fn();
63
+ const { report, root } = mountFailureTelemetry(showToast);
64
+ const error = new Error("network dropped");
65
+
66
+ act(() => report(error, selection, "drag", "Move"));
67
+
68
+ expect(trackStudioSaveFailure).toHaveBeenCalledWith(
69
+ expect.objectContaining({ source: "gsap_commit", error, mutationType: "drag" }),
70
+ );
71
+ expect(trackStudioEditBlocked).not.toHaveBeenCalled();
40
72
  act(() => root.unmount());
41
73
  });
42
74
  });
@@ -1,6 +1,6 @@
1
1
  import { useCallback } from "react";
2
2
  import type { DomEditSelection } from "../components/editor/domEditing";
3
- import { trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
3
+ import { trackStudioEditBlocked, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
4
4
  import { isGsapEditBlockedError } from "./gsapEditOutcome";
5
5
 
6
6
  export function useGsapInteractionFailureTelemetry(
@@ -9,7 +9,10 @@ export function useGsapInteractionFailureTelemetry(
9
9
  ) {
10
10
  return useCallback(
11
11
  (error: unknown, selection: DomEditSelection | null, mutationType: string, label: string) => {
12
- trackStudioSaveFailure({
12
+ const report = isGsapEditBlockedError(error)
13
+ ? trackStudioEditBlocked
14
+ : trackStudioSaveFailure;
15
+ report({
13
16
  source: "gsap_commit",
14
17
  error,
15
18
  filePath: selection?.sourceFile ?? activeCompPath ?? "index.html",
@@ -1,12 +1,16 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import {
3
3
  consumeStudioWriteToken,
4
+ createStudioWriteToken,
4
5
  markStudioWriteToken,
5
6
  resetStudioWriteTokens,
6
7
  studioExpectedFileVersion,
7
8
  studioFileContentVersion,
9
+ studioWriteHeaders,
8
10
  } from "./studioFileVersion";
9
11
 
12
+ afterEach(() => vi.unstubAllGlobals());
13
+
10
14
  describe("studioFileContentVersion", () => {
11
15
  it("matches the strong SHA-256 ETag format used by studio-server", async () => {
12
16
  await expect(studioFileContentVersion("abc")).resolves.toBe(
@@ -42,6 +46,38 @@ describe("studioFileContentVersion", () => {
42
46
  });
43
47
 
44
48
  describe("studio write-token echo identity", () => {
49
+ it("prefers the platform randomUUID implementation", () => {
50
+ const randomUUID = vi.fn(() => "11111111-2222-4333-8444-555555555555");
51
+ vi.stubGlobal("crypto", { randomUUID });
52
+ resetStudioWriteTokens();
53
+
54
+ expect(studioWriteHeaders()).toEqual({
55
+ "X-Hyperframes-Write-Token": "11111111-2222-4333-8444-555555555555",
56
+ });
57
+ expect(randomUUID).toHaveBeenCalledOnce();
58
+ expect(consumeStudioWriteToken("11111111-2222-4333-8444-555555555555")).toBe(true);
59
+ });
60
+
61
+ it("creates an RFC 4122 UUID-v4 token from getRandomValues when randomUUID is unavailable", () => {
62
+ const source = Uint8Array.from({ length: 16 }, (_, index) => index);
63
+ vi.stubGlobal("crypto", {
64
+ getRandomValues: vi.fn((target: Uint8Array) => {
65
+ target.set(source);
66
+ return target;
67
+ }),
68
+ });
69
+
70
+ expect(createStudioWriteToken()).toBe("00010203-0405-4607-8809-0a0b0c0d0e0f");
71
+ });
72
+
73
+ it("fails explicitly when Web Crypto cannot provide secure random bytes", () => {
74
+ vi.stubGlobal("crypto", {});
75
+
76
+ expect(() => createStudioWriteToken()).toThrow(
77
+ "Web Crypto getRandomValues is required for Studio write identity",
78
+ );
79
+ });
80
+
45
81
  it("suppresses exactly one matching API write receipt without hiding path-only external writes", () => {
46
82
  resetStudioWriteTokens();
47
83
  markStudioWriteToken("studio-write-1");
@@ -48,8 +48,18 @@ export async function studioExpectedFileVersion(
48
48
  return versions.get(path);
49
49
  }
50
50
 
51
- function createStudioWriteToken(): string {
52
- return globalThis.crypto.randomUUID();
51
+ export function createStudioWriteToken(): string {
52
+ const webCrypto = globalThis.crypto;
53
+ if (typeof webCrypto?.randomUUID === "function") return webCrypto.randomUUID();
54
+ if (typeof webCrypto?.getRandomValues !== "function") {
55
+ throw new Error("Web Crypto getRandomValues is required for Studio write identity");
56
+ }
57
+
58
+ const bytes = webCrypto.getRandomValues(new Uint8Array(16));
59
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
60
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
61
+ const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
62
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
53
63
  }
54
64
 
55
65
  /**