@hyperframes/studio 0.7.57 → 0.7.58

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.
Files changed (112) hide show
  1. package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-CtTDO63S.js} +1 -1
  2. package/dist/assets/{index-DfmYkU44.js → index-B_UvTX3E.js} +204 -204
  3. package/dist/assets/{index-KsfE1bUu.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-Bf1x1y8H.js → index-DeQPzqwH.js} +1 -1
  5. package/dist/assets/index-uahwWkgw.css +1 -0
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +15 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +5954 -4474
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/StudioFeedbackBar.tsx +2 -2
  16. package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
  17. package/src/components/editor/CanvasContextMenu.tsx +71 -6
  18. package/src/components/editor/DomEditOverlay.tsx +5 -2
  19. package/src/components/editor/LayersPanel.tsx +110 -99
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
  21. package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
  22. package/src/components/editor/domEditingDom.ts +5 -6
  23. package/src/components/editor/domEditingElement.ts +15 -32
  24. package/src/components/editor/layersPanelSort.ts +80 -0
  25. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  26. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  27. package/src/components/nle/PreviewOverlays.tsx +27 -2
  28. package/src/components/nle/TimelinePane.test.ts +3 -0
  29. package/src/components/nle/TimelinePane.tsx +11 -1
  30. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  31. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  32. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  33. package/src/components/nle/zLaneGesture.test.ts +176 -0
  34. package/src/components/nle/zLaneGesture.ts +77 -0
  35. package/src/hooks/domEditCommitTypes.ts +17 -1
  36. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  37. package/src/hooks/fetchStubTestUtils.ts +16 -0
  38. package/src/hooks/timelineEditingHelpers.test.ts +92 -1
  39. package/src/hooks/timelineEditingHelpers.ts +32 -15
  40. package/src/hooks/timelineMoveAdapter.ts +2 -1
  41. package/src/hooks/timelineTimingSync.test.ts +703 -33
  42. package/src/hooks/timelineTimingSync.ts +282 -97
  43. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  44. package/src/hooks/timelineTrackVisibility.ts +4 -3
  45. package/src/hooks/useClipboard.ts +19 -17
  46. package/src/hooks/useDomEditCommits.test.tsx +316 -38
  47. package/src/hooks/useDomEditCommits.ts +143 -78
  48. package/src/hooks/useDomEditSession.test.tsx +3 -1
  49. package/src/hooks/useDomEditSession.ts +1 -1
  50. package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
  51. package/src/hooks/useElementLifecycleOps.ts +174 -81
  52. package/src/hooks/usePreviewPersistence.ts +2 -2
  53. package/src/hooks/useTimelineEditing.test.tsx +302 -154
  54. package/src/hooks/useTimelineEditing.ts +97 -93
  55. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  56. package/src/hooks/useTimelineGroupEditing.ts +35 -3
  57. package/src/player/components/Timeline.test.ts +31 -21
  58. package/src/player/components/Timeline.tsx +62 -91
  59. package/src/player/components/TimelineCanvas.tsx +36 -5
  60. package/src/player/components/TimelineLanes.tsx +32 -10
  61. package/src/player/components/TimelineOverlays.tsx +36 -0
  62. package/src/player/components/TimelineRuler.tsx +18 -5
  63. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  64. package/src/player/components/timelineCallbacks.ts +4 -1
  65. package/src/player/components/timelineClipDragCommit.test.ts +279 -3
  66. package/src/player/components/timelineClipDragCommit.ts +173 -71
  67. package/src/player/components/timelineGapCommit.test.ts +163 -0
  68. package/src/player/components/timelineGapCommit.ts +103 -0
  69. package/src/player/components/timelineGaps.test.ts +221 -0
  70. package/src/player/components/timelineGaps.ts +182 -0
  71. package/src/player/components/timelineLayout.test.ts +2 -1
  72. package/src/player/components/timelineLayout.ts +22 -6
  73. package/src/player/components/timelineMarquee.test.ts +20 -10
  74. package/src/player/components/timelineMarquee.ts +9 -2
  75. package/src/player/components/timelineStackingSync.test.ts +42 -1
  76. package/src/player/components/timelineStackingSync.ts +31 -16
  77. package/src/player/components/timelineZMirror.test.ts +667 -0
  78. package/src/player/components/timelineZMirror.ts +393 -0
  79. package/src/player/components/timelineZoom.test.ts +2 -2
  80. package/src/player/components/timelineZoom.ts +7 -3
  81. package/src/player/components/useTimelineClipDrag.ts +1 -1
  82. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  83. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  84. package/src/player/components/useTimelinePlayhead.ts +4 -3
  85. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  86. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  87. package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
  88. package/src/player/components/useTimelineStackingSync.ts +5 -3
  89. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  90. package/src/player/components/useTrackGapMenu.ts +155 -0
  91. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  92. package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
  93. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  94. package/src/player/lib/layerOrdering.test.ts +1 -15
  95. package/src/player/lib/layerOrdering.ts +5 -28
  96. package/src/player/lib/timelineDOM.test.ts +72 -0
  97. package/src/player/lib/timelineDOM.ts +3 -0
  98. package/src/player/lib/timelineElementHelpers.ts +35 -11
  99. package/src/player/store/playerStore.ts +12 -1
  100. package/src/telemetry/events.test.ts +10 -0
  101. package/src/telemetry/events.ts +1 -0
  102. package/src/utils/domEditSaveQueue.ts +4 -3
  103. package/src/utils/editHistory.test.ts +82 -0
  104. package/src/utils/editHistory.ts +14 -2
  105. package/src/utils/gsapSoftReload.test.ts +54 -115
  106. package/src/utils/gsapSoftReload.ts +42 -152
  107. package/src/utils/gsapUndoRestore.test.ts +269 -0
  108. package/src/utils/gsapUndoRestore.ts +269 -0
  109. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  110. package/dist/assets/index-_pqzyxB1.css +0 -1
  111. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  112. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -1,9 +1,12 @@
1
1
  // @vitest-environment happy-dom
2
2
  import { afterEach, describe, expect, it, vi } from "vitest";
3
3
  import { usePlayerStore } from "../player/store/playerStore";
4
+ import { jsonResponse, requestUrl } from "./fetchStubTestUtils";
5
+ import type { TimelineElement } from "../player/store/playerStore";
4
6
  import {
5
7
  captureDurationRollback,
6
8
  finishClipTimingFallback,
9
+ finishGroupTimingGsapFallback,
7
10
  readFileContent,
8
11
  shiftGsapPositions,
9
12
  } from "./timelineTimingSync";
@@ -14,38 +17,38 @@ afterEach(() => {
14
17
  vi.unstubAllGlobals();
15
18
  });
16
19
 
17
- function jsonResponse(body: unknown): Response {
18
- return new Response(JSON.stringify(body), {
19
- status: 200,
20
- headers: { "content-type": "application/json" },
21
- });
22
- }
23
-
24
- function requestUrl(input: Parameters<typeof fetch>[0]): string {
25
- if (typeof input === "string") return input;
26
- if (input instanceof URL) return input.toString();
27
- return input.url;
28
- }
29
-
30
20
  /**
31
21
  * Stub fetch: `/files/` reads return contents from the queue (repeating the
32
- * last entry), the GSAP-mutation endpoint answers with `gsapBody` (a thrown
33
- * Error rejects the call with a non-ok response).
22
+ * last entry), the GSAP-mutation endpoint owns the first/last contents as its
23
+ * atomic before/after pair, and rollback succeeds conditionally.
34
24
  */
35
- function stubFetch(fileContents: string[], gsapBody: unknown | Error) {
36
- let readIndex = 0;
25
+ function stubFetch(
26
+ fileContents: string[],
27
+ gsapBody: unknown | Error,
28
+ supportsOwnedMutations = true,
29
+ gsapStatus = 200,
30
+ ) {
37
31
  const fetchMock = vi.fn(async (input: Parameters<typeof fetch>[0]): Promise<Response> => {
38
32
  const url = requestUrl(input);
33
+ if (url.includes("/gsap-mutation-capabilities")) {
34
+ return supportsOwnedMutations
35
+ ? jsonResponse({ atomicOwnershipPairs: true })
36
+ : new Response(JSON.stringify({ error: "not found" }), { status: 404 });
37
+ }
39
38
  if (url.includes("/files/")) {
40
- const content = fileContents[Math.min(readIndex, fileContents.length - 1)];
41
- readIndex += 1;
42
- return jsonResponse({ content });
39
+ return jsonResponse({ content: fileContents.at(-1) });
40
+ }
41
+ if (url.includes("/gsap-mutation-rollback/")) {
42
+ return jsonResponse({ ok: true, restored: true, conflict: false });
43
43
  }
44
44
  if (url.includes("/gsap-mutations/")) {
45
45
  if (gsapBody instanceof Error) {
46
46
  return new Response(JSON.stringify({ error: gsapBody.message }), { status: 500 });
47
47
  }
48
- return jsonResponse(gsapBody);
48
+ return new Response(JSON.stringify(gsapBody), {
49
+ status: gsapStatus,
50
+ headers: { "content-type": "application/json" },
51
+ });
49
52
  }
50
53
  throw new Error(`Unexpected fetch: ${url}`);
51
54
  });
@@ -70,12 +73,13 @@ function clipFallbackInput(overrides: {
70
73
  }
71
74
 
72
75
  describe("finishClipTimingFallback failure domains", () => {
73
- it("still syncs the preview when the history-fold step fails after a successful mutation", async () => {
74
- // Mutation succeeds (server rewrite already on disk), but recordEdit (the
75
- // fold step) throws. The preview MUST still be synced — otherwise stale
76
- // GSAP positions stay on screen. iframe=null makes the sync observable as
77
- // one reloadPreview() call.
78
- stubFetch(["<before>", "<after>"], { mutated: true, scriptText: "tl.to()" });
76
+ it("rolls back and skips preview sync when history recording fails", async () => {
77
+ stubFetch(["<before>", "<before>", "<after>"], {
78
+ mutated: true,
79
+ scriptText: "tl.to()",
80
+ before: "<before>",
81
+ after: "<after>",
82
+ });
79
83
  const reloadPreview = vi.fn();
80
84
  const foldError = new Error("history fold failed");
81
85
  const recordEdit = vi.fn(async () => {
@@ -86,12 +90,18 @@ describe("finishClipTimingFallback failure domains", () => {
86
90
  await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
87
91
 
88
92
  expect(recordEdit).toHaveBeenCalledTimes(1);
89
- expect(reloadPreview).toHaveBeenCalledTimes(1);
90
- // The fold error is surfaced, not swallowed silently.
93
+ expect(reloadPreview).not.toHaveBeenCalled();
91
94
  expect(consoleError).toHaveBeenCalledWith(expect.stringContaining("GSAP"), foldError);
95
+ const rollback = vi
96
+ .mocked(fetch)
97
+ .mock.calls.find(([input]) => requestUrl(input).includes("/gsap-mutation-rollback/"));
98
+ expect(JSON.parse(String(rollback?.[1]?.body))).toEqual({
99
+ expected: "<after>",
100
+ restore: "<before>",
101
+ });
92
102
  });
93
103
 
94
- it("skips the preview sync when the MUTATION itself fails", async () => {
104
+ it("reloads when the mutation endpoint fails after dispatch", async () => {
95
105
  stubFetch(["<before>"], new Error("mutation blew up"));
96
106
  const reloadPreview = vi.fn();
97
107
  const recordEdit = vi.fn(async () => {});
@@ -100,12 +110,77 @@ describe("finishClipTimingFallback failure domains", () => {
100
110
  await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
101
111
 
102
112
  expect(recordEdit).not.toHaveBeenCalled();
103
- expect(reloadPreview).not.toHaveBeenCalled();
113
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
104
114
  expect(consoleError).toHaveBeenCalledTimes(1);
105
115
  });
106
116
 
117
+ it("reloads when a successful response omits the exact ownership pair", async () => {
118
+ stubFetch(["<possible-write>"], {});
119
+ const reloadPreview = vi.fn();
120
+ const recordEdit = vi.fn(async () => {});
121
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
122
+
123
+ await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
124
+
125
+ expect(recordEdit).not.toHaveBeenCalled();
126
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
127
+ expect(consoleError).toHaveBeenCalledWith(
128
+ expect.stringContaining("GSAP"),
129
+ expect.objectContaining({ message: "Invalid owned GSAP mutation response" }),
130
+ );
131
+ });
132
+
133
+ it("reloads when the mutation transport fails after dispatch", async () => {
134
+ const transportError = new TypeError("connection reset");
135
+ vi.stubGlobal(
136
+ "fetch",
137
+ vi.fn(async (input: Parameters<typeof fetch>[0]) => {
138
+ const url = requestUrl(input);
139
+ if (url.includes("/gsap-mutation-capabilities")) {
140
+ return jsonResponse({ atomicOwnershipPairs: true });
141
+ }
142
+ if (url.includes("/gsap-mutations/")) throw transportError;
143
+ throw new Error(`Unexpected fetch: ${url}`);
144
+ }),
145
+ );
146
+ const reloadPreview = vi.fn();
147
+ const recordEdit = vi.fn(async () => {});
148
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
149
+
150
+ await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
151
+
152
+ expect(recordEdit).not.toHaveBeenCalled();
153
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
154
+ expect(consoleError).toHaveBeenCalledWith(
155
+ expect.stringContaining("GSAP"),
156
+ expect.objectContaining({ cause: transportError }),
157
+ );
158
+ });
159
+
160
+ it("reloads the preview when the mutation endpoint reports an ownership conflict", async () => {
161
+ stubFetch(
162
+ ["<successor>"],
163
+ { error: "file changed during GSAP mutation", conflict: true },
164
+ true,
165
+ 409,
166
+ );
167
+ const reloadPreview = vi.fn();
168
+ const recordEdit = vi.fn(async () => {});
169
+ vi.spyOn(console, "error").mockImplementation(() => {});
170
+
171
+ await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
172
+
173
+ expect(recordEdit).not.toHaveBeenCalled();
174
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
175
+ });
176
+
107
177
  it("records the fold and syncs on the happy path", async () => {
108
- stubFetch(["<before>", "<after>"], { mutated: true, scriptText: "tl.to()" });
178
+ stubFetch(["<before>", "<before>", "<after>"], {
179
+ mutated: true,
180
+ scriptText: "tl.to()",
181
+ before: "<before>",
182
+ after: "<after>",
183
+ });
109
184
  const reloadPreview = vi.fn();
110
185
  const recordEdit = vi.fn(async () => {});
111
186
 
@@ -114,6 +189,51 @@ describe("finishClipTimingFallback failure domains", () => {
114
189
  expect(recordEdit).toHaveBeenCalledTimes(1);
115
190
  expect(reloadPreview).toHaveBeenCalledTimes(1);
116
191
  });
192
+
193
+ it("preflights ownership support before sending a mutation", async () => {
194
+ const fetchMock = stubFetch(["<before>"], { mutated: true, scriptText: null }, false);
195
+ const reloadPreview = vi.fn();
196
+ const recordEdit = vi.fn(async () => {});
197
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
198
+
199
+ await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
200
+
201
+ expect(
202
+ fetchMock.mock.calls.some(([input]) => requestUrl(input).includes("/gsap-mutations/")),
203
+ ).toBe(false);
204
+ expect(recordEdit).not.toHaveBeenCalled();
205
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
206
+ expect(consoleError).toHaveBeenCalledWith(
207
+ expect.stringContaining("GSAP"),
208
+ expect.objectContaining({ message: "Server does not support owned GSAP mutations" }),
209
+ );
210
+ });
211
+
212
+ it("reloads and surfaces a server that violates its advertised ownership contract", async () => {
213
+ const fetchMock = stubFetch(["<after>"], { mutated: true, scriptText: null });
214
+ const reloadPreview = vi.fn();
215
+ const recordEdit = vi.fn(async () => {});
216
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
217
+
218
+ await finishClipTimingFallback(clipFallbackInput({ reloadPreview, recordEdit }));
219
+
220
+ expect(
221
+ fetchMock.mock.calls.some(([input]) => requestUrl(input).includes("/gsap-mutations/")),
222
+ ).toBe(true);
223
+ expect(
224
+ fetchMock.mock.calls.some(([input]) =>
225
+ requestUrl(input).includes("/gsap-mutation-rollback/"),
226
+ ),
227
+ ).toBe(false);
228
+ expect(recordEdit).not.toHaveBeenCalled();
229
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
230
+ expect(consoleError).toHaveBeenCalledWith(
231
+ expect.stringContaining("GSAP"),
232
+ expect.objectContaining({
233
+ message: "Invalid owned GSAP mutation response",
234
+ }),
235
+ );
236
+ });
117
237
  });
118
238
 
119
239
  describe("captureDurationRollback", () => {
@@ -141,10 +261,560 @@ describe("fetch URL encoding (user-influenced segments)", () => {
141
261
  });
142
262
 
143
263
  it("URI-encodes the projectId in GSAP mutation calls", async () => {
144
- const fetchMock = stubFetch([], { mutated: false, scriptText: null });
264
+ const fetchMock = stubFetch([], {
265
+ mutated: false,
266
+ scriptText: null,
267
+ before: "<html>",
268
+ after: "<html>",
269
+ });
145
270
  await shiftGsapPositions("p one", "scenes/intro.html", "clip", 1);
146
271
  expect(requestUrl(fetchMock.mock.calls[0]![0])).toBe(
147
272
  "/api/projects/p%20one/gsap-mutations/scenes%2Fintro.html",
148
273
  );
149
274
  });
150
275
  });
276
+
277
+ /**
278
+ * Live-preview iframe stub: inline GSAP script elements plus the runtime hooks
279
+ * the timing rebind needs. `appendedScripts` records any script a sync path
280
+ * executes — the rebind-only path must record NONE — and `scriptEls` lets tests
281
+ * assert the original script elements were left untouched in the document.
282
+ */
283
+ const LIVE_SCRIPT =
284
+ 'window.__timelines = window.__timelines || {}; window.__timelines["root"] = tl;';
285
+ const LIVE_CAPTION_SCRIPT =
286
+ 'window.__timelines = window.__timelines || {}; window.__timelines["captions"] = capTl;';
287
+
288
+ function buildLivePreviewIframe(liveScripts: string[] = [LIVE_SCRIPT]) {
289
+ const scriptEls = liveScripts.map((text) => {
290
+ const el = document.createElement("script");
291
+ el.textContent = text;
292
+ return el;
293
+ });
294
+ const container = document.createElement("div");
295
+ for (const el of scriptEls) container.appendChild(el);
296
+
297
+ const contentWindow = {
298
+ gsap: { timeline: vi.fn(), set: vi.fn() },
299
+ __hfForceTimelineRebind: vi.fn() as unknown,
300
+ __timelines: { root: { kill: vi.fn() } } as Record<string, unknown>,
301
+ __player: { getTime: () => 0, seek: vi.fn() },
302
+ __hfStudioManualEditsApply: vi.fn(),
303
+ };
304
+
305
+ const appendedScripts: string[] = [];
306
+ const realAppendChild = container.appendChild.bind(container);
307
+ container.appendChild = <T extends Node>(node: T): T => {
308
+ const result = realAppendChild(node);
309
+ if (node instanceof HTMLScriptElement) appendedScripts.push(node.textContent ?? "");
310
+ return result;
311
+ };
312
+
313
+ const contentDocument = {
314
+ querySelectorAll: (sel: string) => (sel === "script:not([src])" ? scriptEls : []),
315
+ createElement: (tag: string) => document.createElement(tag),
316
+ body: container,
317
+ head: document.createElement("div"),
318
+ };
319
+
320
+ return {
321
+ iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement,
322
+ contentWindow,
323
+ container,
324
+ scriptEls,
325
+ appendedScripts,
326
+ };
327
+ }
328
+
329
+ describe("nothing-to-rewrite timing edits rebind in place (no script re-execution)", () => {
330
+ it("single clip without a domId: rebinds + seeks, executes NO script, no full reload", async () => {
331
+ // A selector-addressed clip (e.g. a .sub caption) has no domId, so there is
332
+ // no GSAP mutation to run — the timing attributes are already live-patched
333
+ // and __timelines is still valid, so the runtime only needs to re-derive
334
+ // the clip windows (rebind) and re-seek. Re-running init-style scripts
335
+ // (three.js setups etc.) is exactly the unsafe case this must avoid.
336
+ const { iframe, contentWindow, container, scriptEls, appendedScripts } =
337
+ buildLivePreviewIframe();
338
+ const reloadPreview = vi.fn();
339
+
340
+ await finishClipTimingFallback({
341
+ ...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }),
342
+ iframe,
343
+ domId: undefined,
344
+ });
345
+
346
+ expect(reloadPreview).not.toHaveBeenCalled();
347
+ expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
348
+ expect(contentWindow.__player.seek).toHaveBeenCalledTimes(1);
349
+ expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalledTimes(1);
350
+ // NO script executed, the original script element untouched in place.
351
+ expect(appendedScripts).toHaveLength(0);
352
+ expect(container.contains(scriptEls[0]!)).toBe(true);
353
+ expect(scriptEls[0]!.textContent).toBe(LIVE_SCRIPT);
354
+ });
355
+
356
+ it("multi-script document (e.g. three.js + captions): rebind-only, both scripts untouched", async () => {
357
+ // Real compositions commonly hold heavy inline scripts (main timeline,
358
+ // three.js setup, captions). None of them may run twice — the rebind path
359
+ // must not create, remove, or re-execute any of them.
360
+ const { iframe, contentWindow, container, scriptEls, appendedScripts } = buildLivePreviewIframe(
361
+ [LIVE_SCRIPT, LIVE_CAPTION_SCRIPT],
362
+ );
363
+ const rootKill = vi.fn();
364
+ const captionsKill = vi.fn();
365
+ contentWindow.__timelines = { root: { kill: rootKill }, captions: { kill: captionsKill } };
366
+ const reloadPreview = vi.fn();
367
+
368
+ await finishClipTimingFallback({
369
+ ...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }),
370
+ iframe,
371
+ domId: undefined,
372
+ });
373
+
374
+ expect(reloadPreview).not.toHaveBeenCalled();
375
+ expect(appendedScripts).toHaveLength(0);
376
+ expect(container.contains(scriptEls[0]!)).toBe(true);
377
+ expect(container.contains(scriptEls[1]!)).toBe(true);
378
+ // The still-valid timelines are NOT killed — nothing re-registers them.
379
+ expect(rootKill).not.toHaveBeenCalled();
380
+ expect(captionsKill).not.toHaveBeenCalled();
381
+ expect(contentWindow.__timelines.root).toBeDefined();
382
+ expect(contentWindow.__timelines.captions).toBeDefined();
383
+ // One finalization: one seek, one rebind.
384
+ expect(contentWindow.__player.seek).toHaveBeenCalledTimes(1);
385
+ expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
386
+ });
387
+
388
+ it("server-confirmed no-op with an unchanged script rebinds without executing any script", async () => {
389
+ stubFetch([], {
390
+ mutated: false,
391
+ scriptText: LIVE_SCRIPT,
392
+ before: "<same>",
393
+ after: "<same>",
394
+ });
395
+ const { iframe, contentWindow, container, scriptEls, appendedScripts } = buildLivePreviewIframe(
396
+ [LIVE_SCRIPT, LIVE_CAPTION_SCRIPT],
397
+ );
398
+ const reloadPreview = vi.fn();
399
+
400
+ await finishClipTimingFallback({
401
+ ...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }),
402
+ iframe,
403
+ });
404
+
405
+ expect(reloadPreview).not.toHaveBeenCalled();
406
+ expect(appendedScripts).toHaveLength(0);
407
+ expect(scriptEls.every((script) => container.contains(script))).toBe(true);
408
+ expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
409
+ });
410
+
411
+ it("comp with ZERO GSAP scripts also rebinds in place (previously full-reloaded)", async () => {
412
+ // The rebind hook is installed by the runtime unconditionally — it does not
413
+ // depend on GSAP — so a script-less comp gets the flashless path too.
414
+ const { iframe, contentWindow, appendedScripts } = buildLivePreviewIframe([]);
415
+ const reloadPreview = vi.fn();
416
+
417
+ await finishClipTimingFallback({
418
+ ...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }),
419
+ iframe,
420
+ domId: undefined,
421
+ });
422
+
423
+ expect(reloadPreview).not.toHaveBeenCalled();
424
+ expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
425
+ expect(contentWindow.__player.seek).toHaveBeenCalledTimes(1);
426
+ expect(appendedScripts).toHaveLength(0);
427
+ });
428
+
429
+ it("full-reloads when the runtime rebind hook is unavailable", async () => {
430
+ const { iframe, contentWindow, appendedScripts } = buildLivePreviewIframe();
431
+ contentWindow.__hfForceTimelineRebind = undefined;
432
+ const reloadPreview = vi.fn();
433
+
434
+ await finishClipTimingFallback({
435
+ ...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }),
436
+ iframe,
437
+ domId: undefined,
438
+ });
439
+
440
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
441
+ expect(appendedScripts).toHaveLength(0);
442
+ });
443
+
444
+ it("still full-reloads when the server MUTATED the file but returned no script", async () => {
445
+ // mutated:true with scriptText:null (older server) means the live script is
446
+ // now STALE relative to disk — a rebind against it would show wrong
447
+ // positions.
448
+ stubFetch(["<before>", "<before>", "<after>"], {
449
+ mutated: true,
450
+ scriptText: null,
451
+ before: "<before>",
452
+ after: "<after>",
453
+ });
454
+ const { iframe, contentWindow, appendedScripts } = buildLivePreviewIframe();
455
+ const reloadPreview = vi.fn();
456
+
457
+ await finishClipTimingFallback({
458
+ ...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }),
459
+ iframe,
460
+ });
461
+
462
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
463
+ expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled();
464
+ expect(appendedScripts).toHaveLength(0);
465
+ });
466
+
467
+ it("mutated WITH a rewritten script keeps the script-swap soft path (not rebind-only)", async () => {
468
+ // A genuine rewrite must re-run the REWRITTEN script — the rebind-only
469
+ // shortcut is reserved for the no-op case where every script is unchanged.
470
+ stubFetch(["<before>", "<before>", "<after>"], {
471
+ mutated: true,
472
+ scriptText: 'window.__timelines["root"] = tl2;',
473
+ before: "<before>",
474
+ after: "<after>",
475
+ });
476
+ const { iframe, contentWindow, appendedScripts } = buildLivePreviewIframe();
477
+ const reloadPreview = vi.fn();
478
+
479
+ await finishClipTimingFallback({
480
+ ...clipFallbackInput({ reloadPreview, recordEdit: vi.fn(async () => {}) }),
481
+ iframe,
482
+ });
483
+
484
+ expect(reloadPreview).not.toHaveBeenCalled();
485
+ expect(appendedScripts).toHaveLength(1);
486
+ expect(appendedScripts[0]).toContain('__timelines["root"] = tl2;');
487
+ expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
488
+ });
489
+
490
+ it("group batch where every change had nothing to rewrite (gap close over no-domId clips) rebinds in place", async () => {
491
+ stubFetch(["<html>"], { mutated: false, scriptText: null });
492
+ const { iframe, contentWindow, container, scriptEls, appendedScripts } =
493
+ buildLivePreviewIframe();
494
+ const reloadPreview = vi.fn();
495
+ const element = { sourceFile: "index.html" } as TimelineElement;
496
+
497
+ await finishGroupTimingGsapFallback({
498
+ projectId: "p1",
499
+ iframe,
500
+ reloadPreview,
501
+ label: "Move timeline clips",
502
+ errorLabel: "Failed to shift GSAP positions",
503
+ recordEdit: vi.fn(async () => {}) as never,
504
+ activeCompPath: "index.html",
505
+ changes: [{ element }, { element }],
506
+ resolveChangePath: () => "index.html",
507
+ // No domId → nothing to rewrite for ANY change (the gap-close blink path).
508
+ mutateChange: () => null,
509
+ });
510
+
511
+ expect(reloadPreview).not.toHaveBeenCalled();
512
+ expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
513
+ expect(contentWindow.__player.seek).toHaveBeenCalledTimes(1);
514
+ // No script executed, the live script element untouched.
515
+ expect(appendedScripts).toHaveLength(0);
516
+ expect(container.contains(scriptEls[0]!)).toBe(true);
517
+ });
518
+
519
+ it("group batch touching ANOTHER file still full-reloads even when nothing was rewritten", async () => {
520
+ stubFetch(["<html>"], { mutated: false, scriptText: null });
521
+ const { iframe, contentWindow, appendedScripts } = buildLivePreviewIframe();
522
+ const reloadPreview = vi.fn();
523
+
524
+ await finishGroupTimingGsapFallback({
525
+ projectId: "p1",
526
+ iframe,
527
+ reloadPreview,
528
+ label: "Move timeline clips",
529
+ errorLabel: "Failed to shift GSAP positions",
530
+ recordEdit: vi.fn(async () => {}) as never,
531
+ activeCompPath: "index.html",
532
+ changes: [
533
+ { element: { sourceFile: "index.html" } as TimelineElement },
534
+ { element: { sourceFile: "scenes/intro.html" } as TimelineElement },
535
+ ],
536
+ resolveChangePath: (el) => el.sourceFile ?? "index.html",
537
+ mutateChange: () => null,
538
+ });
539
+
540
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
541
+ expect(contentWindow.__hfForceTimelineRebind).not.toHaveBeenCalled();
542
+ expect(appendedScripts).toHaveLength(0);
543
+ });
544
+ });
545
+
546
+ function installOwnedFileServer(
547
+ contents: Map<string, string>,
548
+ options: {
549
+ failReadAt?: number;
550
+ failRollback?: boolean;
551
+ beforeRollback?: (path: string) => void;
552
+ } = {},
553
+ ) {
554
+ let reads = 0;
555
+ const rollbacks: Array<{ path: string; expected: string; restore: string; conflict: boolean }> =
556
+ [];
557
+ vi.stubGlobal(
558
+ "fetch",
559
+ // One in-memory server fixture owns capability, file, and CAS rollback
560
+ // routes so each transaction test observes a coherent content map.
561
+ // fallow-ignore-next-line complexity
562
+ vi.fn(async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
563
+ const url = requestUrl(input);
564
+ if (url.includes("/gsap-mutation-capabilities")) {
565
+ return jsonResponse({ atomicOwnershipPairs: true });
566
+ }
567
+ if (url.includes("/files/")) {
568
+ reads += 1;
569
+ if (reads === options.failReadAt) throw new Error("verification read failed");
570
+ const path = decodeURIComponent(url.split("/files/")[1] ?? "");
571
+ return jsonResponse({ content: contents.get(path) ?? "" });
572
+ }
573
+ if (url.includes("/gsap-mutation-rollback/")) {
574
+ const path = decodeURIComponent(url.split("/gsap-mutation-rollback/")[1] ?? "");
575
+ const body = JSON.parse(String(init?.body)) as { expected: string; restore: string };
576
+ if (options.failRollback) {
577
+ return new Response(JSON.stringify({ error: "rollback unavailable" }), { status: 503 });
578
+ }
579
+ options.beforeRollback?.(path);
580
+ const conflict = contents.get(path) !== body.expected;
581
+ if (!conflict) contents.set(path, body.restore);
582
+ rollbacks.push({ path, ...body, conflict });
583
+ return jsonResponse({ ok: true, restored: !conflict, conflict });
584
+ }
585
+ throw new Error(`Unexpected fetch: ${url}`);
586
+ }),
587
+ );
588
+ return { rollbacks, readCount: () => reads };
589
+ }
590
+
591
+ function ownedMutation(contents: Map<string, string>, path: string, before: string, after: string) {
592
+ contents.set(path, after);
593
+ return { mutated: true, scriptText: null, before, after };
594
+ }
595
+
596
+ describe("foldGsapMutationIntoHistory — owned GSAP transaction", () => {
597
+ const element = { sourceFile: "index.html" } as TimelineElement;
598
+
599
+ it("reverse-rolls back every successful same-file step after a late failure", async () => {
600
+ const contents = new Map<string, string>([["index.html", "ORIGINAL"]]);
601
+ const server = installOwnedFileServer(contents);
602
+ let clipIndex = 0;
603
+
604
+ await finishGroupTimingGsapFallback({
605
+ projectId: "p1",
606
+ iframe: buildLivePreviewIframe().iframe,
607
+ reloadPreview: vi.fn(),
608
+ label: "Move timeline clips",
609
+ errorLabel: "Failed to shift GSAP positions",
610
+ recordEdit: vi.fn(async () => {}) as never,
611
+ activeCompPath: "index.html",
612
+ changes: [{ element }, { element }, { element }],
613
+ resolveChangePath: () => "index.html",
614
+ mutateChange: async () => {
615
+ clipIndex += 1;
616
+ if (clipIndex === 1) return ownedMutation(contents, "index.html", "ORIGINAL", "STEP-1");
617
+ if (clipIndex === 2) return ownedMutation(contents, "index.html", "STEP-1", "STEP-2");
618
+ throw new Error("late failure");
619
+ },
620
+ });
621
+
622
+ expect(server.rollbacks).toEqual([
623
+ { path: "index.html", expected: "STEP-2", restore: "STEP-1", conflict: false },
624
+ { path: "index.html", expected: "STEP-1", restore: "ORIGINAL", conflict: false },
625
+ ]);
626
+ expect(contents.get("index.html")).toBe("ORIGINAL");
627
+ });
628
+
629
+ it("uses the endpoint's atomic before when a foreign write precedes mutation", async () => {
630
+ const contents = new Map<string, string>([["index.html", "FOREIGN"]]);
631
+ const server = installOwnedFileServer(contents);
632
+ let clipIndex = 0;
633
+
634
+ await finishGroupTimingGsapFallback({
635
+ projectId: "p1",
636
+ iframe: buildLivePreviewIframe().iframe,
637
+ reloadPreview: vi.fn(),
638
+ label: "Move timeline clips",
639
+ errorLabel: "Failed to shift GSAP positions",
640
+ recordEdit: vi.fn(async () => {}) as never,
641
+ activeCompPath: "index.html",
642
+ changes: [{ element }, { element }],
643
+ resolveChangePath: () => "index.html",
644
+ mutateChange: async () => {
645
+ clipIndex += 1;
646
+ if (clipIndex === 1) {
647
+ return ownedMutation(contents, "index.html", "FOREIGN", "FOREIGN+OWNED");
648
+ }
649
+ throw new Error("late failure");
650
+ },
651
+ });
652
+
653
+ expect(server.readCount()).toBe(0);
654
+ expect(server.rollbacks[0]).toEqual({
655
+ path: "index.html",
656
+ expected: "FOREIGN+OWNED",
657
+ restore: "FOREIGN",
658
+ conflict: false,
659
+ });
660
+ expect(contents.get("index.html")).toBe("FOREIGN");
661
+ });
662
+
663
+ it("server CAS preserves a successor that arrives as rollback starts", async () => {
664
+ const contents = new Map<string, string>([["index.html", "ORIGINAL"]]);
665
+ const server = installOwnedFileServer(contents, {
666
+ beforeRollback: () => contents.set("index.html", "SUCCESSOR"),
667
+ });
668
+ let clipIndex = 0;
669
+
670
+ const reloadPreview = vi.fn();
671
+ await finishGroupTimingGsapFallback({
672
+ projectId: "p1",
673
+ iframe: buildLivePreviewIframe().iframe,
674
+ reloadPreview,
675
+ label: "Move timeline clips",
676
+ errorLabel: "Failed to shift GSAP positions",
677
+ recordEdit: vi.fn(async () => {}) as never,
678
+ activeCompPath: "index.html",
679
+ changes: [{ element }, { element }],
680
+ resolveChangePath: () => "index.html",
681
+ mutateChange: async () => {
682
+ clipIndex += 1;
683
+ if (clipIndex === 1) return ownedMutation(contents, "index.html", "ORIGINAL", "OWNED");
684
+ throw new Error("late failure");
685
+ },
686
+ });
687
+
688
+ expect(server.rollbacks[0]?.conflict).toBe(true);
689
+ expect(contents.get("index.html")).toBe("SUCCESSOR");
690
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
691
+ });
692
+
693
+ it("reloads when a failed rollback request leaves owned bytes without history", async () => {
694
+ const contents = new Map<string, string>([["index.html", "ORIGINAL"]]);
695
+ installOwnedFileServer(contents, { failRollback: true });
696
+ const reloadPreview = vi.fn();
697
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
698
+ let clipIndex = 0;
699
+
700
+ await finishGroupTimingGsapFallback({
701
+ projectId: "p1",
702
+ iframe: buildLivePreviewIframe().iframe,
703
+ reloadPreview,
704
+ label: "Move timeline clips",
705
+ errorLabel: "Failed to shift GSAP positions",
706
+ recordEdit: vi.fn(async () => {}) as never,
707
+ activeCompPath: "index.html",
708
+ changes: [{ element }, { element }],
709
+ resolveChangePath: () => "index.html",
710
+ mutateChange: async () => {
711
+ clipIndex += 1;
712
+ if (clipIndex === 1) return ownedMutation(contents, "index.html", "ORIGINAL", "OWNED");
713
+ throw new Error("late failure");
714
+ },
715
+ });
716
+
717
+ expect(contents.get("index.html")).toBe("OWNED");
718
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
719
+ expect(consoleError).toHaveBeenCalledWith(
720
+ expect.stringContaining("GSAP"),
721
+ expect.objectContaining({ message: "Failed to restore index.html" }),
722
+ );
723
+ });
724
+
725
+ it("records first owned before and last owned after without a client snapshot", async () => {
726
+ const contents = new Map<string, string>([["index.html", "FOREIGN"]]);
727
+ const server = installOwnedFileServer(contents);
728
+ const recordEdit = vi.fn(async () => {});
729
+
730
+ await finishGroupTimingGsapFallback({
731
+ projectId: "p1",
732
+ iframe: buildLivePreviewIframe().iframe,
733
+ reloadPreview: vi.fn(),
734
+ label: "Move timeline clips",
735
+ errorLabel: "Failed to shift GSAP positions",
736
+ recordEdit: recordEdit as never,
737
+ activeCompPath: "index.html",
738
+ changes: [{ element }, { element }],
739
+ resolveChangePath: () => "index.html",
740
+ mutateChange: async (_change, _path) => {
741
+ const before = contents.get("index.html")!;
742
+ const after = before === "FOREIGN" ? "FOREIGN+ONE" : "FOREIGN+ONE+TWO";
743
+ return ownedMutation(contents, "index.html", before, after);
744
+ },
745
+ });
746
+
747
+ expect(server.readCount()).toBe(1); // final ownership verification only
748
+ expect(recordEdit).toHaveBeenCalledWith(
749
+ expect.objectContaining({
750
+ files: {
751
+ "index.html": { before: "FOREIGN", after: "FOREIGN+ONE+TWO" },
752
+ },
753
+ }),
754
+ );
755
+ });
756
+
757
+ it("rejects a discontinuous same-file chain without claiming foreign bytes", async () => {
758
+ const contents = new Map<string, string>([["index.html", "ORIGINAL"]]);
759
+ const server = installOwnedFileServer(contents);
760
+ const recordEdit = vi.fn(async () => {});
761
+ let clipIndex = 0;
762
+
763
+ const reloadPreview = vi.fn();
764
+ await finishGroupTimingGsapFallback({
765
+ projectId: "p1",
766
+ iframe: buildLivePreviewIframe().iframe,
767
+ reloadPreview,
768
+ label: "Move timeline clips",
769
+ errorLabel: "Failed to shift GSAP positions",
770
+ recordEdit: recordEdit as never,
771
+ activeCompPath: "index.html",
772
+ changes: [{ element }, { element }],
773
+ resolveChangePath: () => "index.html",
774
+ mutateChange: async () => {
775
+ clipIndex += 1;
776
+ if (clipIndex === 1) return ownedMutation(contents, "index.html", "ORIGINAL", "STEP-1");
777
+ return ownedMutation(contents, "index.html", "FOREIGN", "FOREIGN+STEP-2");
778
+ },
779
+ });
780
+
781
+ expect(recordEdit).not.toHaveBeenCalled();
782
+ expect(server.rollbacks).toEqual([
783
+ {
784
+ path: "index.html",
785
+ expected: "FOREIGN+STEP-2",
786
+ restore: "FOREIGN",
787
+ conflict: false,
788
+ },
789
+ { path: "index.html", expected: "STEP-1", restore: "ORIGINAL", conflict: true },
790
+ ]);
791
+ expect(contents.get("index.html")).toBe("FOREIGN");
792
+ expect(reloadPreview).toHaveBeenCalledTimes(1);
793
+ });
794
+
795
+ it("rolls back the owned output when the history verification reread fails", async () => {
796
+ const contents = new Map<string, string>([["index.html", "ORIGINAL"]]);
797
+ const server = installOwnedFileServer(contents, { failReadAt: 1 });
798
+
799
+ await finishGroupTimingGsapFallback({
800
+ projectId: "p1",
801
+ iframe: buildLivePreviewIframe().iframe,
802
+ reloadPreview: vi.fn(),
803
+ label: "Move timeline clips",
804
+ errorLabel: "Failed to shift GSAP positions",
805
+ recordEdit: vi.fn(async () => {}) as never,
806
+ activeCompPath: "index.html",
807
+ changes: [{ element }],
808
+ resolveChangePath: () => "index.html",
809
+ mutateChange: async () => ownedMutation(contents, "index.html", "ORIGINAL", "OWNED"),
810
+ });
811
+
812
+ expect(server.rollbacks[0]).toEqual({
813
+ path: "index.html",
814
+ expected: "OWNED",
815
+ restore: "ORIGINAL",
816
+ conflict: false,
817
+ });
818
+ expect(contents.get("index.html")).toBe("ORIGINAL");
819
+ });
820
+ });