@hyperframes/studio 0.6.110 → 0.6.111
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/index-B7S86vK1.js +370 -0
- package/dist/assets/index-DP8pPIk2.css +1 -0
- package/dist/assets/{index-CfiyaR7G.js → index-_IV-vm9l.js} +1 -1
- package/dist/assets/{index-CXy_mWnP.js → index-x0c2-zQN.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -5
- package/src/App.tsx +133 -141
- package/src/components/StudioHeader.tsx +44 -0
- package/src/components/StudioOverlays.tsx +70 -0
- package/src/components/editor/SourceEditor.tsx +19 -16
- package/src/components/editor/manualEditingAvailability.ts +25 -7
- package/src/components/storyboard/FramePoster.tsx +56 -0
- package/src/components/storyboard/StoryboardDirection.tsx +45 -0
- package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
- package/src/components/storyboard/StoryboardGrid.tsx +33 -0
- package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
- package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
- package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
- package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
- package/src/components/storyboard/StoryboardView.tsx +78 -0
- package/src/components/storyboard/frameStatus.ts +36 -0
- package/src/contexts/ViewModeContext.tsx +98 -0
- package/src/hooks/gsapScriptCommitTypes.ts +4 -7
- package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
- package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -0
- package/src/hooks/useAppHotkeys.ts +20 -0
- package/src/hooks/useDomEditCommits.ts +43 -14
- package/src/hooks/useDomEditSession.test.ts +41 -0
- package/src/hooks/useDomEditSession.ts +38 -6
- package/src/hooks/useDomGeometryCommits.ts +5 -9
- package/src/hooks/useElementLifecycleOps.ts +30 -0
- package/src/hooks/useGsapAnimationOps.ts +78 -51
- package/src/hooks/useGsapKeyframeOps.ts +127 -43
- package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
- package/src/hooks/useGsapPropertyDebounce.ts +201 -23
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
- package/src/hooks/useGsapScriptCommits.ts +95 -40
- package/src/hooks/useSdkSession.test.ts +12 -0
- package/src/hooks/useSdkSession.ts +91 -25
- package/src/hooks/useStoryboard.ts +80 -0
- package/src/hooks/useTimelineEditing.ts +163 -68
- package/src/utils/gsapSoftReload.ts +14 -0
- package/src/utils/sdkCutover.gate.test.ts +61 -0
- package/src/utils/sdkCutover.test.ts +839 -0
- package/src/utils/sdkCutover.ts +465 -0
- package/src/utils/sdkOpMapping.ts +43 -0
- package/src/utils/sdkResolverShadow.test.ts +366 -0
- package/src/utils/sdkResolverShadow.ts +313 -0
- package/dist/assets/index-1C8oFiPi.css +0 -1
- package/dist/assets/index-D-3sGz65.js +0 -296
- package/src/utils/sdkShadow.test.ts +0 -606
- package/src/utils/sdkShadow.ts +0 -517
- package/src/utils/sdkShadowGsapFidelity.ts +0 -296
- package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
- package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
- package/src/utils/sdkShadowNumeric.ts +0 -11
|
@@ -0,0 +1,839 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
shouldUseSdkCutover,
|
|
4
|
+
sdkCutoverPersist,
|
|
5
|
+
sdkDeletePersist,
|
|
6
|
+
sdkTimingPersist,
|
|
7
|
+
sdkGsapTweenPersist,
|
|
8
|
+
sdkGsapKeyframePersist,
|
|
9
|
+
} from "./sdkCutover";
|
|
10
|
+
import { openComposition } from "@hyperframes/sdk";
|
|
11
|
+
import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
|
|
12
|
+
import type { PatchOperation } from "./sourcePatcher";
|
|
13
|
+
import type { MutableRefObject } from "react";
|
|
14
|
+
|
|
15
|
+
vi.mock("../components/editor/manualEditingAvailability", () => ({
|
|
16
|
+
STUDIO_SDK_CUTOVER_ENABLED: true,
|
|
17
|
+
STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false,
|
|
18
|
+
}));
|
|
19
|
+
vi.mock("./studioTelemetry", () => ({
|
|
20
|
+
trackStudioEvent: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const styleOp = (property: string, value: string): PatchOperation => ({
|
|
24
|
+
type: "inline-style",
|
|
25
|
+
property,
|
|
26
|
+
value,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const textOp = (value: string): PatchOperation => ({
|
|
30
|
+
type: "text-content",
|
|
31
|
+
property: "text",
|
|
32
|
+
value,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const attrOp = (property: string, value: string): PatchOperation => ({
|
|
36
|
+
type: "attribute",
|
|
37
|
+
property,
|
|
38
|
+
value,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const htmlAttrOp = (property: string, value: string): PatchOperation => ({
|
|
42
|
+
type: "html-attribute",
|
|
43
|
+
property,
|
|
44
|
+
value,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("shouldUseSdkCutover", () => {
|
|
48
|
+
it("returns false when flag disabled", () => {
|
|
49
|
+
expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns false when no session", () => {
|
|
53
|
+
expect(shouldUseSdkCutover(true, false, "hf-abc", [styleOp("color", "red")])).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns false when no hfId", () => {
|
|
57
|
+
expect(shouldUseSdkCutover(true, true, null, [styleOp("color", "red")])).toBe(false);
|
|
58
|
+
expect(shouldUseSdkCutover(true, true, undefined, [styleOp("color", "red")])).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns false when ops empty", () => {
|
|
62
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [])).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns true for inline-style ops", () => {
|
|
66
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [styleOp("color", "red")])).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns true for text-content ops", () => {
|
|
70
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [textOp("hello")])).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns true for attribute ops", () => {
|
|
74
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("data-x", "10")])).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns true for html-attribute ops", () => {
|
|
78
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("class", "foo")])).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("returns false for an attribute op that maps to a reserved data-* name", () => {
|
|
82
|
+
// {type:'attribute', property:'end'} → 'data-end', which the SDK's
|
|
83
|
+
// validateSetAttribute rejects. Decline the batch so it takes the server
|
|
84
|
+
// path cleanly instead of throwing inside dispatch and falling back per op.
|
|
85
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("end", "2")])).toBe(false);
|
|
86
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("data-start", "1")])).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("declines a case-variant reserved attribute (SDK lowercases before checking)", () => {
|
|
90
|
+
// attribute op "END" → "data-END" → lower → "data-end" (reserved).
|
|
91
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [attrOp("END", "2")])).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("declines an html-attribute op whose raw name is reserved", () => {
|
|
95
|
+
// html-attribute ops aren't data-prefixed, so a raw reserved name must still
|
|
96
|
+
// be caught (the SDK throws on it just the same).
|
|
97
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("data-end", "3")])).toBe(false);
|
|
98
|
+
expect(shouldUseSdkCutover(true, true, "hf-abc", [htmlAttrOp("DATA-START", "1")])).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("returns true when ops mix all supported types", () => {
|
|
102
|
+
expect(
|
|
103
|
+
shouldUseSdkCutover(true, true, "hf-abc", [
|
|
104
|
+
styleOp("color", "red"),
|
|
105
|
+
textOp("hello"),
|
|
106
|
+
attrOp("x", "1"),
|
|
107
|
+
htmlAttrOp("class", "foo"),
|
|
108
|
+
]),
|
|
109
|
+
).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("sdkCutoverPersist", () => {
|
|
114
|
+
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
|
115
|
+
|
|
116
|
+
const makeDeps = (overrides: Partial<Parameters<typeof sdkCutoverPersist>[5]> = {}) => ({
|
|
117
|
+
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
|
118
|
+
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
119
|
+
reloadPreview: vi.fn(),
|
|
120
|
+
domEditSaveTimestampRef: makeRef(0),
|
|
121
|
+
...overrides,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const makeSession = (hasEl = true) =>
|
|
125
|
+
({
|
|
126
|
+
getElement: vi.fn().mockReturnValue(hasEl ? { inlineStyles: {} } : null),
|
|
127
|
+
dispatch: vi.fn(),
|
|
128
|
+
// Distinct before/after so the no-op guard (after === before → fall back)
|
|
129
|
+
// treats this as a real change; "after" matches the write assertions.
|
|
130
|
+
serialize: vi
|
|
131
|
+
.fn()
|
|
132
|
+
.mockReturnValueOnce("<html>before</html>")
|
|
133
|
+
.mockReturnValue("<html></html>"),
|
|
134
|
+
batch: vi.fn((fn: () => void) => fn()),
|
|
135
|
+
}) as unknown as Parameters<typeof sdkCutoverPersist>[4];
|
|
136
|
+
|
|
137
|
+
it("returns false when session is null", async () => {
|
|
138
|
+
const deps = makeDeps();
|
|
139
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
140
|
+
const result = await sdkCutoverPersist(
|
|
141
|
+
sel,
|
|
142
|
+
[styleOp("color", "red")],
|
|
143
|
+
"before",
|
|
144
|
+
"/path.html",
|
|
145
|
+
null,
|
|
146
|
+
deps,
|
|
147
|
+
);
|
|
148
|
+
expect(result).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns false when element not found in session", async () => {
|
|
152
|
+
const deps = makeDeps();
|
|
153
|
+
const session = makeSession(false);
|
|
154
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
155
|
+
const result = await sdkCutoverPersist(
|
|
156
|
+
sel,
|
|
157
|
+
[styleOp("color", "red")],
|
|
158
|
+
"before",
|
|
159
|
+
"/path.html",
|
|
160
|
+
session,
|
|
161
|
+
deps,
|
|
162
|
+
);
|
|
163
|
+
expect(result).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("dispatches setStyle for inline-style ops", async () => {
|
|
167
|
+
const deps = makeDeps();
|
|
168
|
+
const session = makeSession(true);
|
|
169
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
170
|
+
const result = await sdkCutoverPersist(
|
|
171
|
+
sel,
|
|
172
|
+
[styleOp("color", "red"), styleOp("opacity", "0.5")],
|
|
173
|
+
"before",
|
|
174
|
+
"/comp.html",
|
|
175
|
+
session,
|
|
176
|
+
deps,
|
|
177
|
+
);
|
|
178
|
+
expect(result).toBe(true);
|
|
179
|
+
expect(session!.dispatch).toHaveBeenCalledWith({
|
|
180
|
+
type: "setStyle",
|
|
181
|
+
target: "hf-abc",
|
|
182
|
+
styles: { color: "red", opacity: "0.5" },
|
|
183
|
+
});
|
|
184
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html></html>");
|
|
185
|
+
expect(deps.reloadPreview).toHaveBeenCalled();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("dispatches setText for text-content op", async () => {
|
|
189
|
+
const deps = makeDeps();
|
|
190
|
+
const session = makeSession(true);
|
|
191
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
192
|
+
const result = await sdkCutoverPersist(
|
|
193
|
+
sel,
|
|
194
|
+
[textOp("Hello world")],
|
|
195
|
+
"before",
|
|
196
|
+
"/comp.html",
|
|
197
|
+
session,
|
|
198
|
+
deps,
|
|
199
|
+
);
|
|
200
|
+
expect(result).toBe(true);
|
|
201
|
+
expect(session!.dispatch).toHaveBeenCalledWith({
|
|
202
|
+
type: "setText",
|
|
203
|
+
target: "hf-abc",
|
|
204
|
+
value: "Hello world",
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("dispatches setAttribute for attribute op with data- prefix", async () => {
|
|
209
|
+
const deps = makeDeps();
|
|
210
|
+
const session = makeSession(true);
|
|
211
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
212
|
+
const result = await sdkCutoverPersist(
|
|
213
|
+
sel,
|
|
214
|
+
[attrOp("x", "42")],
|
|
215
|
+
"before",
|
|
216
|
+
"/comp.html",
|
|
217
|
+
session,
|
|
218
|
+
deps,
|
|
219
|
+
);
|
|
220
|
+
expect(result).toBe(true);
|
|
221
|
+
expect(session!.dispatch).toHaveBeenCalledWith({
|
|
222
|
+
type: "setAttribute",
|
|
223
|
+
target: "hf-abc",
|
|
224
|
+
name: "data-x",
|
|
225
|
+
value: "42",
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("dispatches setAttribute for html-attribute op", async () => {
|
|
230
|
+
const deps = makeDeps();
|
|
231
|
+
const session = makeSession(true);
|
|
232
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
233
|
+
const result = await sdkCutoverPersist(
|
|
234
|
+
sel,
|
|
235
|
+
[htmlAttrOp("class", "foo bar")],
|
|
236
|
+
"before",
|
|
237
|
+
"/comp.html",
|
|
238
|
+
session,
|
|
239
|
+
deps,
|
|
240
|
+
);
|
|
241
|
+
expect(result).toBe(true);
|
|
242
|
+
expect(session!.dispatch).toHaveBeenCalledWith({
|
|
243
|
+
type: "setAttribute",
|
|
244
|
+
target: "hf-abc",
|
|
245
|
+
name: "class",
|
|
246
|
+
value: "foo bar",
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("passes caller label to recordEdit", async () => {
|
|
251
|
+
const deps = makeDeps();
|
|
252
|
+
const session = makeSession(true);
|
|
253
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
254
|
+
await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/comp.html", session, deps, {
|
|
255
|
+
label: "Resize layer box",
|
|
256
|
+
});
|
|
257
|
+
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
|
|
258
|
+
expect.objectContaining({ label: "Resize layer box" }),
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("passes caller coalesceKey to recordEdit", async () => {
|
|
263
|
+
const deps = makeDeps();
|
|
264
|
+
const session = makeSession(true);
|
|
265
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
266
|
+
await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/comp.html", session, deps, {
|
|
267
|
+
coalesceKey: "my-key",
|
|
268
|
+
});
|
|
269
|
+
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
|
|
270
|
+
expect.objectContaining({ coalesceKey: "my-key" }),
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("returns false and does not throw on dispatch error", async () => {
|
|
275
|
+
const deps = makeDeps();
|
|
276
|
+
const session = makeSession(true);
|
|
277
|
+
(session!.dispatch as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
|
278
|
+
throw new Error("dispatch failed");
|
|
279
|
+
});
|
|
280
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
281
|
+
const result = await sdkCutoverPersist(
|
|
282
|
+
sel,
|
|
283
|
+
[styleOp("color", "red")],
|
|
284
|
+
"before",
|
|
285
|
+
"/comp.html",
|
|
286
|
+
session,
|
|
287
|
+
deps,
|
|
288
|
+
);
|
|
289
|
+
expect(result).toBe(false);
|
|
290
|
+
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("wraps all dispatches in session.batch() for atomic rollback", async () => {
|
|
294
|
+
const deps = makeDeps();
|
|
295
|
+
const session = makeSession(true);
|
|
296
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
297
|
+
await sdkCutoverPersist(
|
|
298
|
+
sel,
|
|
299
|
+
[styleOp("color", "red"), styleOp("opacity", "0.5")],
|
|
300
|
+
"before",
|
|
301
|
+
"/comp.html",
|
|
302
|
+
session,
|
|
303
|
+
deps,
|
|
304
|
+
);
|
|
305
|
+
expect(
|
|
306
|
+
(session as unknown as { batch: ReturnType<typeof vi.fn> }).batch,
|
|
307
|
+
).toHaveBeenCalledOnce();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("returns false when second dispatch throws (batch prevents partial mutation)", async () => {
|
|
311
|
+
// inline-style ops coalesce into one setStyle dispatch; use style+text to produce two dispatches.
|
|
312
|
+
const deps = makeDeps();
|
|
313
|
+
const session = makeSession(true);
|
|
314
|
+
let callCount = 0;
|
|
315
|
+
(session!.dispatch as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
|
316
|
+
callCount++;
|
|
317
|
+
if (callCount === 2) throw new Error("2nd op failed");
|
|
318
|
+
});
|
|
319
|
+
const sel = { hfId: "hf-abc" } as never;
|
|
320
|
+
const result = await sdkCutoverPersist(
|
|
321
|
+
sel,
|
|
322
|
+
[styleOp("color", "red"), textOp("hello")],
|
|
323
|
+
"before",
|
|
324
|
+
"/comp.html",
|
|
325
|
+
session,
|
|
326
|
+
deps,
|
|
327
|
+
);
|
|
328
|
+
expect(result).toBe(false);
|
|
329
|
+
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
330
|
+
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
describe("sdkDeletePersist", () => {
|
|
335
|
+
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
|
336
|
+
const makeDeps = () => ({
|
|
337
|
+
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
|
338
|
+
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
339
|
+
reloadPreview: vi.fn(),
|
|
340
|
+
domEditSaveTimestampRef: makeRef(0),
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const makeSession = (hasEl = true) =>
|
|
344
|
+
({
|
|
345
|
+
getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-abc" } : null),
|
|
346
|
+
removeElement: vi.fn(),
|
|
347
|
+
serialize: vi
|
|
348
|
+
.fn()
|
|
349
|
+
.mockReturnValueOnce("<html>before-snap</html>")
|
|
350
|
+
.mockReturnValue("<html>after</html>"),
|
|
351
|
+
batch: vi.fn((fn: () => void) => fn()),
|
|
352
|
+
}) as unknown as Parameters<typeof sdkDeletePersist>[3];
|
|
353
|
+
|
|
354
|
+
it("returns false when session is null", async () => {
|
|
355
|
+
expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", null, makeDeps())).toBe(false);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("returns false when element not found in session", async () => {
|
|
359
|
+
const session = makeSession(false);
|
|
360
|
+
expect(await sdkDeletePersist("hf-abc", "before", "/comp.html", session, makeDeps())).toBe(
|
|
361
|
+
false,
|
|
362
|
+
);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("calls removeElement and writes serialized content", async () => {
|
|
366
|
+
const deps = makeDeps();
|
|
367
|
+
const session = makeSession(true);
|
|
368
|
+
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
|
|
369
|
+
expect(result).toBe(true);
|
|
370
|
+
expect(session!.removeElement).toHaveBeenCalledWith("hf-abc");
|
|
371
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("records edit history with before/after diff", async () => {
|
|
375
|
+
const deps = makeDeps();
|
|
376
|
+
const session = makeSession(true);
|
|
377
|
+
await sdkDeletePersist("hf-abc", "before-content", "/comp.html", session, deps);
|
|
378
|
+
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
|
|
379
|
+
expect.objectContaining({
|
|
380
|
+
label: "Delete element",
|
|
381
|
+
files: { "/comp.html": { before: "before-content", after: "<html>after</html>" } },
|
|
382
|
+
}),
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("calls reloadPreview on success", async () => {
|
|
387
|
+
const deps = makeDeps();
|
|
388
|
+
const session = makeSession(true);
|
|
389
|
+
await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
|
|
390
|
+
expect(deps.reloadPreview).toHaveBeenCalled();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("returns false and does not write on removeElement error", async () => {
|
|
394
|
+
const deps = makeDeps();
|
|
395
|
+
const session = makeSession(true);
|
|
396
|
+
(session!.removeElement as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
|
397
|
+
throw new Error("remove failed");
|
|
398
|
+
});
|
|
399
|
+
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
|
|
400
|
+
expect(result).toBe(false);
|
|
401
|
+
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
402
|
+
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
describe("sdkTimingPersist", () => {
|
|
407
|
+
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
|
408
|
+
const makeDeps = () => ({
|
|
409
|
+
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
|
410
|
+
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
411
|
+
reloadPreview: vi.fn(),
|
|
412
|
+
domEditSaveTimestampRef: makeRef(0),
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const makeSession = (hasEl = true) =>
|
|
416
|
+
({
|
|
417
|
+
getElement: vi.fn().mockReturnValue(hasEl ? { id: "hf-clip" } : null),
|
|
418
|
+
setTiming: vi.fn(),
|
|
419
|
+
serialize: vi
|
|
420
|
+
.fn()
|
|
421
|
+
.mockReturnValueOnce("<html>before</html>")
|
|
422
|
+
.mockReturnValue("<html>after</html>"),
|
|
423
|
+
batch: vi.fn((fn: () => void) => fn()),
|
|
424
|
+
}) as unknown as Parameters<typeof sdkTimingPersist>[3];
|
|
425
|
+
|
|
426
|
+
it("returns false when session is null", async () => {
|
|
427
|
+
expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, null, makeDeps())).toBe(
|
|
428
|
+
false,
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("returns false when element not found in session", async () => {
|
|
433
|
+
const session = makeSession(false);
|
|
434
|
+
expect(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, makeDeps())).toBe(
|
|
435
|
+
false,
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("calls setTiming with provided update and writes serialized content", async () => {
|
|
440
|
+
const deps = makeDeps();
|
|
441
|
+
const session = makeSession(true);
|
|
442
|
+
const result = await sdkTimingPersist(
|
|
443
|
+
"hf-clip",
|
|
444
|
+
"/comp.html",
|
|
445
|
+
{ start: 2, duration: 5, trackIndex: 1 },
|
|
446
|
+
session,
|
|
447
|
+
deps,
|
|
448
|
+
);
|
|
449
|
+
expect(result).toBe(true);
|
|
450
|
+
expect(session!.setTiming).toHaveBeenCalledWith("hf-clip", {
|
|
451
|
+
start: 2,
|
|
452
|
+
duration: 5,
|
|
453
|
+
trackIndex: 1,
|
|
454
|
+
});
|
|
455
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("captures before-state before setTiming dispatch", async () => {
|
|
459
|
+
const deps = makeDeps();
|
|
460
|
+
const session = makeSession(true);
|
|
461
|
+
await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
|
|
462
|
+
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
|
|
463
|
+
expect.objectContaining({
|
|
464
|
+
files: { "/comp.html": { before: "<html>before</html>", after: "<html>after</html>" } },
|
|
465
|
+
}),
|
|
466
|
+
);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("returns false and does not write on setTiming error", async () => {
|
|
470
|
+
const deps = makeDeps();
|
|
471
|
+
const session = makeSession(true);
|
|
472
|
+
(session!.setTiming as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
|
473
|
+
throw new Error("timing error");
|
|
474
|
+
});
|
|
475
|
+
const result = await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, deps);
|
|
476
|
+
expect(result).toBe(false);
|
|
477
|
+
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// Finding #12: undo baseline must be the EXACT on-disk bytes (matching the
|
|
481
|
+
// style/delete paths), not a normalized SDK serialize() re-emit — otherwise
|
|
482
|
+
// undoing a timing edit reformats the whole file.
|
|
483
|
+
it("records the on-disk content (not serialize()) as the undo before when a reader is provided", async () => {
|
|
484
|
+
const deps = {
|
|
485
|
+
...makeDeps(),
|
|
486
|
+
readProjectFile: vi.fn().mockResolvedValue("<html>EXACT ON-DISK BYTES</html>"),
|
|
487
|
+
};
|
|
488
|
+
const session = makeSession(true);
|
|
489
|
+
await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
|
|
490
|
+
expect(deps.readProjectFile).toHaveBeenCalledWith("/comp.html");
|
|
491
|
+
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
|
|
492
|
+
expect.objectContaining({
|
|
493
|
+
files: {
|
|
494
|
+
"/comp.html": { before: "<html>EXACT ON-DISK BYTES</html>", after: "<html>after</html>" },
|
|
495
|
+
},
|
|
496
|
+
}),
|
|
497
|
+
);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it("falls back to serialize() before when the reader throws", async () => {
|
|
501
|
+
const deps = {
|
|
502
|
+
...makeDeps(),
|
|
503
|
+
readProjectFile: vi.fn().mockRejectedValue(new Error("read failed")),
|
|
504
|
+
};
|
|
505
|
+
const session = makeSession(true);
|
|
506
|
+
await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
|
|
507
|
+
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
|
|
508
|
+
expect.objectContaining({
|
|
509
|
+
files: { "/comp.html": { before: "<html>before</html>", after: "<html>after</html>" } },
|
|
510
|
+
}),
|
|
511
|
+
);
|
|
512
|
+
});
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => {
|
|
516
|
+
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
|
517
|
+
const makeSession = () =>
|
|
518
|
+
({
|
|
519
|
+
getElement: vi.fn().mockReturnValue({ id: "hf-box" }),
|
|
520
|
+
setGsapTween: vi.fn(),
|
|
521
|
+
serialize: vi
|
|
522
|
+
.fn()
|
|
523
|
+
.mockReturnValueOnce("<html>serialized-before</html>")
|
|
524
|
+
.mockReturnValue("<html>after</html>"),
|
|
525
|
+
batch: vi.fn((fn: () => void) => fn()),
|
|
526
|
+
}) as unknown as Parameters<typeof sdkGsapTweenPersist>[2];
|
|
527
|
+
|
|
528
|
+
it("records the on-disk content as the undo before, not serialize()", async () => {
|
|
529
|
+
const deps = {
|
|
530
|
+
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
|
531
|
+
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
532
|
+
reloadPreview: vi.fn(),
|
|
533
|
+
domEditSaveTimestampRef: makeRef(0),
|
|
534
|
+
readProjectFile: vi.fn().mockResolvedValue("<html>on-disk gsap bytes</html>"),
|
|
535
|
+
};
|
|
536
|
+
const session = makeSession();
|
|
537
|
+
await sdkGsapTweenPersist(
|
|
538
|
+
"/comp.html",
|
|
539
|
+
{ kind: "set", animationId: "tw-1", properties: { ease: "power3.in" } },
|
|
540
|
+
session,
|
|
541
|
+
deps,
|
|
542
|
+
);
|
|
543
|
+
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
|
|
544
|
+
expect.objectContaining({
|
|
545
|
+
files: {
|
|
546
|
+
"/comp.html": { before: "<html>on-disk gsap bytes</html>", after: "<html>after</html>" },
|
|
547
|
+
},
|
|
548
|
+
}),
|
|
549
|
+
);
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => {
|
|
554
|
+
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
|
555
|
+
|
|
556
|
+
it("routes the read-modify-write through the keyed serializer so same-file flushes can't interleave", async () => {
|
|
557
|
+
const order: string[] = [];
|
|
558
|
+
let writeResolve: (() => void) | null = null;
|
|
559
|
+
const deps = {
|
|
560
|
+
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
|
561
|
+
// First write blocks until we release it, so without serialization the
|
|
562
|
+
// second op's serialize()/dispatch would interleave ahead of it.
|
|
563
|
+
writeProjectFile: vi.fn().mockImplementation((_p: string, content: string) => {
|
|
564
|
+
order.push(`write-start:${content}`);
|
|
565
|
+
if (content === "<html>after-1</html>") {
|
|
566
|
+
return new Promise<void>((res) => {
|
|
567
|
+
writeResolve = () => {
|
|
568
|
+
order.push(`write-done:${content}`);
|
|
569
|
+
res();
|
|
570
|
+
};
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
order.push(`write-done:${content}`);
|
|
574
|
+
return Promise.resolve();
|
|
575
|
+
}),
|
|
576
|
+
reloadPreview: vi.fn(),
|
|
577
|
+
domEditSaveTimestampRef: makeRef(0),
|
|
578
|
+
// A real per-key serializer: tasks under the same key run strictly in order.
|
|
579
|
+
serialize: (() => {
|
|
580
|
+
const inFlight = new Map<string, Promise<unknown>>();
|
|
581
|
+
return <T>(key: string, task: () => Promise<T>): Promise<T> => {
|
|
582
|
+
const prior = inFlight.get(key) ?? Promise.resolve();
|
|
583
|
+
const next = prior.then(task, task);
|
|
584
|
+
inFlight.set(key, next);
|
|
585
|
+
return next as Promise<T>;
|
|
586
|
+
};
|
|
587
|
+
})(),
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
let serializeCall = 0;
|
|
591
|
+
const session = {
|
|
592
|
+
getElement: vi.fn().mockReturnValue({ id: "hf-box" }),
|
|
593
|
+
setGsapTween: vi.fn(() => order.push("dispatch")),
|
|
594
|
+
serialize: vi.fn(() => {
|
|
595
|
+
serializeCall++;
|
|
596
|
+
// before-1, after-1, before-2, after-2
|
|
597
|
+
return `<html>${serializeCall % 2 === 1 ? "before" : "after"}-${Math.ceil(serializeCall / 2)}</html>`;
|
|
598
|
+
}),
|
|
599
|
+
batch: vi.fn((fn: () => void) => fn()),
|
|
600
|
+
} as unknown as Parameters<typeof sdkGsapTweenPersist>[2];
|
|
601
|
+
|
|
602
|
+
const p1 = sdkGsapTweenPersist(
|
|
603
|
+
"/comp.html",
|
|
604
|
+
{ kind: "set", animationId: "tw-1", properties: { ease: "a" } },
|
|
605
|
+
session,
|
|
606
|
+
deps,
|
|
607
|
+
);
|
|
608
|
+
const p2 = sdkGsapTweenPersist(
|
|
609
|
+
"/comp.html",
|
|
610
|
+
{ kind: "set", animationId: "tw-1", properties: { ease: "b" } },
|
|
611
|
+
session,
|
|
612
|
+
deps,
|
|
613
|
+
);
|
|
614
|
+
// Let the first op reach its (blocked) write before releasing it.
|
|
615
|
+
await Promise.resolve();
|
|
616
|
+
await Promise.resolve();
|
|
617
|
+
writeResolve?.();
|
|
618
|
+
await Promise.all([p1, p2]);
|
|
619
|
+
|
|
620
|
+
// The second op's write must NOT start before the first op's write completes.
|
|
621
|
+
const firstWriteDone = order.indexOf("write-done:<html>after-1</html>");
|
|
622
|
+
const secondWriteStart = order.indexOf("write-start:<html>after-2</html>");
|
|
623
|
+
expect(firstWriteDone).toBeGreaterThanOrEqual(0);
|
|
624
|
+
expect(secondWriteStart).toBeGreaterThan(firstWriteDone);
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
describe("sdkGsapTweenPersist", () => {
|
|
629
|
+
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
|
630
|
+
const makeDeps = () => ({
|
|
631
|
+
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
|
632
|
+
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
633
|
+
reloadPreview: vi.fn(),
|
|
634
|
+
domEditSaveTimestampRef: makeRef(0),
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
const makeSession = (opts?: { addGsapTween?: string; hasEl?: boolean }) =>
|
|
638
|
+
({
|
|
639
|
+
getElement: vi.fn().mockReturnValue(opts?.hasEl !== false ? { id: "hf-box" } : null),
|
|
640
|
+
addGsapTween: vi.fn().mockReturnValue(opts?.addGsapTween ?? "tw-1"),
|
|
641
|
+
setGsapTween: vi.fn(),
|
|
642
|
+
removeGsapTween: vi.fn(),
|
|
643
|
+
serialize: vi
|
|
644
|
+
.fn()
|
|
645
|
+
.mockReturnValueOnce("<html>before</html>")
|
|
646
|
+
.mockReturnValue("<html>after</html>"),
|
|
647
|
+
batch: vi.fn((fn: () => void) => fn()),
|
|
648
|
+
}) as unknown as Parameters<typeof sdkGsapTweenPersist>[2];
|
|
649
|
+
|
|
650
|
+
it("returns false when session is null", async () => {
|
|
651
|
+
expect(
|
|
652
|
+
await sdkGsapTweenPersist(
|
|
653
|
+
"/comp.html",
|
|
654
|
+
{ kind: "remove", animationId: "tw-1" },
|
|
655
|
+
null,
|
|
656
|
+
makeDeps(),
|
|
657
|
+
),
|
|
658
|
+
).toBe(false);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("calls addGsapTween and writes for kind=add", async () => {
|
|
662
|
+
const deps = makeDeps();
|
|
663
|
+
const session = makeSession();
|
|
664
|
+
const result = await sdkGsapTweenPersist(
|
|
665
|
+
"/comp.html",
|
|
666
|
+
{
|
|
667
|
+
kind: "add",
|
|
668
|
+
target: "hf-box",
|
|
669
|
+
spec: { method: "to", duration: 1, properties: { opacity: 1 } },
|
|
670
|
+
},
|
|
671
|
+
session,
|
|
672
|
+
deps,
|
|
673
|
+
);
|
|
674
|
+
expect(result).toBe(true);
|
|
675
|
+
expect(session!.addGsapTween).toHaveBeenCalledWith(
|
|
676
|
+
"hf-box",
|
|
677
|
+
expect.objectContaining({ method: "to" }),
|
|
678
|
+
);
|
|
679
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it("returns false for kind=add when element not found", async () => {
|
|
683
|
+
const deps = makeDeps();
|
|
684
|
+
const session = makeSession({ hasEl: false });
|
|
685
|
+
const result = await sdkGsapTweenPersist(
|
|
686
|
+
"/comp.html",
|
|
687
|
+
{ kind: "add", target: "hf-box", spec: { method: "to", properties: { x: 100 } } },
|
|
688
|
+
session,
|
|
689
|
+
deps,
|
|
690
|
+
);
|
|
691
|
+
expect(result).toBe(false);
|
|
692
|
+
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("calls setGsapTween and writes for kind=set", async () => {
|
|
696
|
+
const deps = makeDeps();
|
|
697
|
+
const session = makeSession();
|
|
698
|
+
const result = await sdkGsapTweenPersist(
|
|
699
|
+
"/comp.html",
|
|
700
|
+
{ kind: "set", animationId: "tw-1", properties: { ease: "power3.in" } },
|
|
701
|
+
session,
|
|
702
|
+
deps,
|
|
703
|
+
);
|
|
704
|
+
expect(result).toBe(true);
|
|
705
|
+
expect(session!.setGsapTween).toHaveBeenCalledWith("tw-1", { ease: "power3.in" });
|
|
706
|
+
expect(deps.reloadPreview).toHaveBeenCalled();
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it("calls removeGsapTween for kind=remove", async () => {
|
|
710
|
+
const deps = makeDeps();
|
|
711
|
+
const session = makeSession();
|
|
712
|
+
const result = await sdkGsapTweenPersist(
|
|
713
|
+
"/comp.html",
|
|
714
|
+
{ kind: "remove", animationId: "tw-1" },
|
|
715
|
+
session,
|
|
716
|
+
deps,
|
|
717
|
+
);
|
|
718
|
+
expect(result).toBe(true);
|
|
719
|
+
expect(session!.removeGsapTween).toHaveBeenCalledWith("tw-1");
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it("returns false and does not write on SDK error", async () => {
|
|
723
|
+
const deps = makeDeps();
|
|
724
|
+
const session = makeSession();
|
|
725
|
+
(session!.removeGsapTween as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
|
726
|
+
throw new Error("gsap error");
|
|
727
|
+
});
|
|
728
|
+
const result = await sdkGsapTweenPersist(
|
|
729
|
+
"/comp.html",
|
|
730
|
+
{ kind: "remove", animationId: "tw-1" },
|
|
731
|
+
session,
|
|
732
|
+
deps,
|
|
733
|
+
);
|
|
734
|
+
expect(result).toBe(false);
|
|
735
|
+
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
describe("sdkGsapKeyframePersist", () => {
|
|
740
|
+
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
|
741
|
+
const makeDeps = () => ({
|
|
742
|
+
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
|
743
|
+
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
744
|
+
reloadPreview: vi.fn(),
|
|
745
|
+
domEditSaveTimestampRef: makeRef(0),
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
const makeSession = () =>
|
|
749
|
+
({
|
|
750
|
+
dispatch: vi.fn(),
|
|
751
|
+
serialize: vi
|
|
752
|
+
.fn()
|
|
753
|
+
.mockReturnValueOnce("<html>before</html>")
|
|
754
|
+
.mockReturnValue("<html>after</html>"),
|
|
755
|
+
batch: vi.fn((fn: () => void) => fn()),
|
|
756
|
+
}) as unknown as Parameters<typeof sdkGsapKeyframePersist>[4];
|
|
757
|
+
|
|
758
|
+
it("returns false when session is null", async () => {
|
|
759
|
+
expect(
|
|
760
|
+
await sdkGsapKeyframePersist("/comp.html", "tw-1", 50, { opacity: 0.5 }, null, makeDeps()),
|
|
761
|
+
).toBe(false);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it("dispatches addGsapKeyframe and writes serialized content", async () => {
|
|
765
|
+
const deps = makeDeps();
|
|
766
|
+
const session = makeSession();
|
|
767
|
+
const result = await sdkGsapKeyframePersist(
|
|
768
|
+
"/comp.html",
|
|
769
|
+
"tw-1",
|
|
770
|
+
50,
|
|
771
|
+
{ opacity: 0.5 },
|
|
772
|
+
session,
|
|
773
|
+
deps,
|
|
774
|
+
);
|
|
775
|
+
expect(result).toBe(true);
|
|
776
|
+
expect(session!.dispatch).toHaveBeenCalledWith({
|
|
777
|
+
type: "addGsapKeyframe",
|
|
778
|
+
animationId: "tw-1",
|
|
779
|
+
position: 50,
|
|
780
|
+
value: { opacity: 0.5 },
|
|
781
|
+
});
|
|
782
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
|
|
783
|
+
expect(deps.reloadPreview).toHaveBeenCalled();
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
it("returns false and does not write on dispatch error", async () => {
|
|
787
|
+
const deps = makeDeps();
|
|
788
|
+
const session = makeSession();
|
|
789
|
+
(session!.dispatch as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
|
790
|
+
throw new Error("dispatch failed");
|
|
791
|
+
});
|
|
792
|
+
const result = await sdkGsapKeyframePersist(
|
|
793
|
+
"/comp.html",
|
|
794
|
+
"tw-1",
|
|
795
|
+
25,
|
|
796
|
+
{ x: 100 },
|
|
797
|
+
session,
|
|
798
|
+
deps,
|
|
799
|
+
);
|
|
800
|
+
expect(result).toBe(false);
|
|
801
|
+
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
describe("sdkCutoverPersist — GSAP script preservation (integration)", () => {
|
|
806
|
+
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
|
807
|
+
const makeDeps = () => ({
|
|
808
|
+
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
|
809
|
+
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
810
|
+
reloadPreview: vi.fn(),
|
|
811
|
+
domEditSaveTimestampRef: makeRef(0),
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it("preserves GSAP <script> block and data-position-mode through setStyle dispatch", async () => {
|
|
815
|
+
const html = `<!DOCTYPE html><html><head></head><body>
|
|
816
|
+
<div data-hf-id="hf-layer" style="color: blue; opacity: 1"></div>
|
|
817
|
+
<script data-hf-gsap data-position-mode="relative">
|
|
818
|
+
gsap.timeline().to('[data-hf-id="hf-layer"]', { duration: 1, x: 100 });
|
|
819
|
+
</script>
|
|
820
|
+
</body></html>`;
|
|
821
|
+
const comp = await openComposition(html, { persist: createMemoryAdapter() });
|
|
822
|
+
const deps = makeDeps();
|
|
823
|
+
const sel = { hfId: "hf-layer" } as never;
|
|
824
|
+
const result = await sdkCutoverPersist(
|
|
825
|
+
sel,
|
|
826
|
+
[{ type: "inline-style", property: "color", value: "red" }],
|
|
827
|
+
html,
|
|
828
|
+
"/comp.html",
|
|
829
|
+
comp,
|
|
830
|
+
deps,
|
|
831
|
+
);
|
|
832
|
+
expect(result).toBe(true);
|
|
833
|
+
const written = (deps.writeProjectFile as ReturnType<typeof vi.fn>).mock
|
|
834
|
+
.calls[0]?.[1] as string;
|
|
835
|
+
expect(written).toContain("data-hf-gsap");
|
|
836
|
+
expect(written).toContain('data-position-mode="relative"');
|
|
837
|
+
expect(written).toContain("gsap.timeline()");
|
|
838
|
+
});
|
|
839
|
+
});
|