@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.
- package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-CtTDO63S.js} +1 -1
- package/dist/assets/{index-DfmYkU44.js → index-B_UvTX3E.js} +204 -204
- package/dist/assets/{index-KsfE1bUu.js → index-C47jAC3Q.js} +1 -1
- package/dist/assets/{index-Bf1x1y8H.js → index-DeQPzqwH.js} +1 -1
- package/dist/assets/index-uahwWkgw.css +1 -0
- package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
- package/dist/chunk-OBAG3GWK.js.map +1 -0
- package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
- package/dist/index.d.ts +15 -4
- package/dist/index.html +2 -2
- package/dist/index.js +5954 -4474
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +3 -3
- package/src/components/StudioFeedbackBar.tsx +2 -2
- package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
- package/src/components/editor/CanvasContextMenu.tsx +71 -6
- package/src/components/editor/DomEditOverlay.tsx +5 -2
- package/src/components/editor/LayersPanel.tsx +110 -99
- package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
- package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
- package/src/components/editor/domEditingDom.ts +5 -6
- package/src/components/editor/domEditingElement.ts +15 -32
- package/src/components/editor/layersPanelSort.ts +80 -0
- package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
- package/src/components/editor/useLayerRevealOverride.ts +389 -0
- package/src/components/nle/PreviewOverlays.tsx +27 -2
- package/src/components/nle/TimelinePane.test.ts +3 -0
- package/src/components/nle/TimelinePane.tsx +11 -1
- package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
- package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
- package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
- package/src/components/nle/zLaneGesture.test.ts +176 -0
- package/src/components/nle/zLaneGesture.ts +77 -0
- package/src/hooks/domEditCommitTypes.ts +17 -1
- package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
- package/src/hooks/fetchStubTestUtils.ts +16 -0
- package/src/hooks/timelineEditingHelpers.test.ts +92 -1
- package/src/hooks/timelineEditingHelpers.ts +32 -15
- package/src/hooks/timelineMoveAdapter.ts +2 -1
- package/src/hooks/timelineTimingSync.test.ts +703 -33
- package/src/hooks/timelineTimingSync.ts +282 -97
- package/src/hooks/timelineTrackVisibility.test.ts +3 -1
- package/src/hooks/timelineTrackVisibility.ts +4 -3
- package/src/hooks/useClipboard.ts +19 -17
- package/src/hooks/useDomEditCommits.test.tsx +316 -38
- package/src/hooks/useDomEditCommits.ts +143 -78
- package/src/hooks/useDomEditSession.test.tsx +3 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
- package/src/hooks/useElementLifecycleOps.ts +174 -81
- package/src/hooks/usePreviewPersistence.ts +2 -2
- package/src/hooks/useTimelineEditing.test.tsx +302 -154
- package/src/hooks/useTimelineEditing.ts +97 -93
- package/src/hooks/useTimelineEditingTypes.ts +1 -1
- package/src/hooks/useTimelineGroupEditing.ts +35 -3
- package/src/player/components/Timeline.test.ts +31 -21
- package/src/player/components/Timeline.tsx +62 -91
- package/src/player/components/TimelineCanvas.tsx +36 -5
- package/src/player/components/TimelineLanes.tsx +32 -10
- package/src/player/components/TimelineOverlays.tsx +36 -0
- package/src/player/components/TimelineRuler.tsx +18 -5
- package/src/player/components/TrackGapContextMenu.tsx +116 -0
- package/src/player/components/timelineCallbacks.ts +4 -1
- package/src/player/components/timelineClipDragCommit.test.ts +279 -3
- package/src/player/components/timelineClipDragCommit.ts +173 -71
- package/src/player/components/timelineGapCommit.test.ts +163 -0
- package/src/player/components/timelineGapCommit.ts +103 -0
- package/src/player/components/timelineGaps.test.ts +221 -0
- package/src/player/components/timelineGaps.ts +182 -0
- package/src/player/components/timelineLayout.test.ts +2 -1
- package/src/player/components/timelineLayout.ts +22 -6
- package/src/player/components/timelineMarquee.test.ts +20 -10
- package/src/player/components/timelineMarquee.ts +9 -2
- package/src/player/components/timelineStackingSync.test.ts +42 -1
- package/src/player/components/timelineStackingSync.ts +31 -16
- package/src/player/components/timelineZMirror.test.ts +667 -0
- package/src/player/components/timelineZMirror.ts +393 -0
- package/src/player/components/timelineZoom.test.ts +2 -2
- package/src/player/components/timelineZoom.ts +7 -3
- package/src/player/components/useTimelineClipDrag.ts +1 -1
- package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
- package/src/player/components/useTimelineGapHighlights.ts +110 -0
- package/src/player/components/useTimelinePlayhead.ts +4 -3
- package/src/player/components/useTimelineRangeSelection.ts +6 -4
- package/src/player/components/useTimelineScrollViewport.ts +73 -0
- package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
- package/src/player/components/useTimelineStackingSync.ts +5 -3
- package/src/player/components/useTimelineTrackDerivations.ts +38 -0
- package/src/player/components/useTrackGapMenu.ts +155 -0
- package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
- package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
- package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
- package/src/player/lib/layerOrdering.test.ts +1 -15
- package/src/player/lib/layerOrdering.ts +5 -28
- package/src/player/lib/timelineDOM.test.ts +72 -0
- package/src/player/lib/timelineDOM.ts +3 -0
- package/src/player/lib/timelineElementHelpers.ts +35 -11
- package/src/player/store/playerStore.ts +12 -1
- package/src/telemetry/events.test.ts +10 -0
- package/src/telemetry/events.ts +1 -0
- package/src/utils/domEditSaveQueue.ts +4 -3
- package/src/utils/editHistory.test.ts +82 -0
- package/src/utils/editHistory.ts +14 -2
- package/src/utils/gsapSoftReload.test.ts +54 -115
- package/src/utils/gsapSoftReload.ts +42 -152
- package/src/utils/gsapUndoRestore.test.ts +269 -0
- package/src/utils/gsapUndoRestore.ts +269 -0
- package/src/utils/sourceScopedSelectorIndex.ts +29 -0
- package/dist/assets/index-_pqzyxB1.css +0 -1
- package/dist/chunk-5QSIMBEJ.js.map +0 -1
- /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
|
|
33
|
-
*
|
|
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(
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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("
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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).
|
|
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("
|
|
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).
|
|
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>"], {
|
|
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([], {
|
|
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
|
+
});
|