@hyperframes/studio 0.7.58 → 0.7.60
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-CtTDO63S.js → hyperframes-player-3XTTaVNf.js} +1 -1
- package/dist/assets/{index-C47jAC3Q.js → index-D6etaey-.js} +1 -1
- package/dist/assets/index-DXbu6IPT.css +1 -0
- package/dist/assets/{index-DeQPzqwH.js → index-Dh_WhagG.js} +1 -1
- package/dist/assets/index-cH6NfVV_.js +426 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.html +2 -2
- package/dist/index.js +13755 -8627
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.d.ts +5 -0
- package/dist/styles/tailwind-preset.js +8 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +7 -7
- package/src/App.tsx +4 -4
- package/src/components/DesignPanelPromoteProvider.tsx +15 -2
- package/src/components/StudioRightPanel.tsx +42 -53
- package/src/components/editor/AnimationCard.test.tsx +134 -0
- package/src/components/editor/AnimationCard.tsx +19 -4
- package/src/components/editor/ArcPathControls.tsx +1 -0
- package/src/components/editor/GestureRecordControl.tsx +7 -1
- package/src/components/editor/GsapAnimationSection.tsx +109 -14
- package/src/components/editor/InspectorHeaderActions.tsx +42 -4
- package/src/components/editor/PropertyPanel.test.tsx +948 -0
- package/src/components/editor/PropertyPanel.tsx +144 -148
- package/src/components/editor/PropertyPanelEmptyState.test.tsx +74 -0
- package/src/components/editor/PropertyPanelEmptyState.tsx +179 -1
- package/src/components/editor/PropertyPanelFlat.tsx +580 -0
- package/src/components/editor/PropertyPanelFlatFooter.test.tsx +83 -0
- package/src/components/editor/PropertyPanelFlatFooter.tsx +73 -0
- package/src/components/editor/PropertyPanelFlatHeader.test.tsx +79 -0
- package/src/components/editor/PropertyPanelFlatHeader.tsx +112 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +27 -0
- package/src/components/editor/gsapLivePreview.ts +34 -0
- package/src/components/editor/manualEditingAvailability.test.ts +10 -0
- package/src/components/editor/manualEditingAvailability.ts +9 -0
- package/src/components/editor/propertyPanel3dTransform.tsx +5 -0
- package/src/components/editor/propertyPanelColor.test.tsx +28 -0
- package/src/components/editor/propertyPanelColor.tsx +31 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +11 -2
- package/src/components/editor/propertyPanelColorGradingSection.tsx +45 -365
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +19 -5
- package/src/components/editor/propertyPanelFill.tsx +22 -5
- package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +622 -0
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +570 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.test.tsx +293 -0
- package/src/components/editor/propertyPanelFlatLayoutSection.tsx +383 -0
- package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +102 -0
- package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +436 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +286 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +272 -0
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +315 -0
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +1174 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +563 -0
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +107 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.test.ts +23 -0
- package/src/components/editor/propertyPanelFlatStyleHelpers.ts +27 -0
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +598 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +516 -0
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +465 -0
- package/src/components/editor/propertyPanelFlatTextSection.tsx +407 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.test.ts +71 -0
- package/src/components/editor/propertyPanelFlatTimingDerivation.ts +59 -0
- package/src/components/editor/propertyPanelFlatToggle.test.tsx +63 -0
- package/src/components/editor/propertyPanelFlatToggle.tsx +53 -0
- package/src/components/editor/propertyPanelFont.test.tsx +30 -0
- package/src/components/editor/propertyPanelFont.tsx +136 -95
- package/src/components/editor/propertyPanelHelpers.ts +73 -0
- package/src/components/editor/propertyPanelInputCoverage.test.tsx +587 -0
- package/src/components/editor/propertyPanelMediaSection.tsx +10 -0
- package/src/components/editor/propertyPanelPrimitives.tsx +60 -29
- package/src/components/editor/propertyPanelSections.test.tsx +161 -0
- package/src/components/editor/propertyPanelSections.tsx +154 -90
- package/src/components/editor/propertyPanelStyleSections.tsx +12 -1
- package/src/components/editor/propertyPanelTimingSection.tsx +1 -1
- package/src/components/editor/propertyPanelTypes.ts +14 -1
- package/src/components/editor/propertyPanelValueTier.test.ts +32 -0
- package/src/components/editor/propertyPanelValueTier.ts +28 -0
- package/src/components/editor/useColorGradingController.test.ts +418 -0
- package/src/components/editor/useColorGradingController.ts +596 -0
- package/src/components/panels/VariablesPanel.tsx +4 -0
- package/src/components/sidebar/CompositionsTab.test.ts +28 -2
- package/src/components/sidebar/CompositionsTab.tsx +3 -1
- package/src/components/storyboard/FramePoster.tsx +24 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +4 -0
- package/src/components/storyboard/StoryboardFrameTile.tsx +30 -1
- package/src/components/storyboard/StoryboardGrid.tsx +23 -1
- package/src/components/storyboard/StoryboardLoaded.tsx +58 -2
- package/src/components/storyboard/StoryboardView.tsx +9 -2
- package/src/components/storyboard/frameComments.test.ts +95 -0
- package/src/components/storyboard/frameComments.ts +115 -0
- package/src/components/storyboard/useFrameComments.ts +82 -0
- package/src/contexts/DesignPanelInputContext.test.tsx +81 -0
- package/src/contexts/DesignPanelInputContext.tsx +48 -0
- package/src/contexts/DomEditContext.tsx +4 -0
- package/src/contexts/FileManagerContext.tsx +3 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -0
- package/src/contexts/VariablePromoteContext.test.tsx +67 -0
- package/src/contexts/VariablePromoteContext.tsx +10 -5
- package/src/hooks/domEditCommitRunner.ts +11 -0
- package/src/hooks/gsapScriptCommitTypes.ts +6 -4
- package/src/hooks/timelineEditingHelpers.ts +2 -2
- package/src/hooks/timelineTrackVisibility.test.ts +52 -0
- package/src/hooks/timelineTrackVisibility.ts +27 -10
- package/src/hooks/useAppHotkeys.ts +9 -0
- package/src/hooks/useDomEditAttributeCommits.ts +123 -1
- package/src/hooks/useDomEditCommits.test.tsx +41 -1
- package/src/hooks/useDomEditCommits.ts +31 -177
- package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
- package/src/hooks/useDomEditSession.ts +18 -6
- package/src/hooks/useDomEditTextCommits.ts +14 -9
- package/src/hooks/useDomEditWiring.ts +10 -9
- package/src/hooks/useEditorSave.ts +1 -1
- package/src/hooks/useElementLifecycleOps.ts +8 -3
- package/src/hooks/useFileManager.projectOwnership.test.tsx +109 -0
- package/src/hooks/useFileManager.ts +151 -65
- package/src/hooks/useGsapAnimationOps.ts +7 -6
- package/src/hooks/useGsapKeyframeOps.ts +6 -5
- package/src/hooks/useGsapPropertyDebounce.ts +43 -28
- package/src/hooks/useGsapPropertyDebounceFlush.test.ts +35 -0
- package/src/hooks/useGsapScriptCommits.test.tsx +48 -2
- package/src/hooks/useGsapScriptCommits.ts +150 -50
- package/src/hooks/useGsapSelectionHandlers.test.tsx +116 -0
- package/src/hooks/useGsapSelectionHandlers.ts +94 -31
- package/src/hooks/useInspectorSplitResize.ts +51 -0
- package/src/hooks/usePanelLayout.test.ts +118 -0
- package/src/hooks/usePanelLayout.ts +24 -1
- package/src/hooks/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
- package/src/hooks/usePersistentEditHistory.test.ts +49 -1
- package/src/hooks/usePersistentEditHistory.ts +71 -31
- package/src/hooks/usePreviewPersistence.ts +4 -1
- package/src/hooks/useProjectCompositionVariables.ts +10 -10
- package/src/hooks/useProjectSignaturePoll.test.tsx +135 -0
- package/src/hooks/useProjectSignaturePoll.ts +68 -0
- package/src/hooks/useRazorSplit.history.test.tsx +12 -4
- package/src/hooks/useRazorSplit.test.tsx +88 -0
- package/src/hooks/useRazorSplit.testHelpers.ts +3 -2
- package/src/hooks/useRazorSplit.ts +50 -16
- package/src/hooks/useSdkSession.lifecycle.test.tsx +137 -0
- package/src/hooks/useSdkSession.ts +155 -14
- package/src/hooks/useSlideshowPersist.ts +7 -1
- package/src/hooks/useStoryboard.ts +23 -4
- package/src/hooks/useTimelineEditing.test.tsx +4 -1
- package/src/hooks/useTimelineEditing.ts +18 -10
- package/src/hooks/useTimelineEditingTypes.ts +5 -1
- package/src/hooks/useTimelineGroupEditing.ts +17 -3
- package/src/hooks/useVariablesPersist.ts +9 -7
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/styles/studio.css +22 -0
- package/src/styles/tailwind-preset.shared.js +7 -0
- package/src/utils/designInputTracking.test.ts +97 -0
- package/src/utils/designInputTracking.ts +80 -0
- package/src/utils/domEditSaveQueue.test.ts +19 -0
- package/src/utils/domEditSaveQueue.ts +2 -1
- package/src/utils/sdkCutover.gate.test.ts +7 -7
- package/src/utils/sdkCutover.test.ts +511 -62
- package/src/utils/sdkCutover.ts +181 -219
- package/src/utils/sdkEditTransaction.ts +293 -0
- package/src/utils/sdkResolverAttempts.ts +94 -0
- package/src/utils/sdkResolverShadow.test.ts +148 -0
- package/src/utils/sdkResolverShadow.ts +139 -106
- package/src/utils/setSlideshowManifest.test.ts +0 -9
- package/src/utils/setSlideshowManifest.ts +20 -27
- package/src/utils/studioFileHistory.test.ts +41 -0
- package/src/utils/studioFileHistory.ts +34 -29
- package/src/utils/studioFileMutationCoordinator.ts +45 -0
- package/src/utils/studioFileVersion.test.ts +36 -0
- package/src/utils/studioFileVersion.ts +23 -0
- package/src/utils/studioHelpers.test.ts +45 -0
- package/src/utils/studioHelpers.ts +45 -14
- package/src/utils/studioSaveDiagnostics.test.ts +18 -0
- package/src/utils/studioSaveDiagnostics.ts +38 -0
- package/dist/assets/index-B_UvTX3E.js +0 -423
- package/dist/assets/index-uahwWkgw.css +0 -1
|
@@ -6,6 +6,9 @@ import {
|
|
|
6
6
|
sdkTimingPersist,
|
|
7
7
|
sdkGsapTweenPersist,
|
|
8
8
|
sdkGsapKeyframePersist,
|
|
9
|
+
cutoverCommittedOrThrow,
|
|
10
|
+
persistSdkCandidateMutation,
|
|
11
|
+
persistSdkSerialize,
|
|
9
12
|
} from "./sdkCutover";
|
|
10
13
|
import { openComposition } from "@hyperframes/sdk";
|
|
11
14
|
import { createMemoryAdapter } from "@hyperframes/sdk/adapters/memory";
|
|
@@ -44,6 +47,14 @@ const htmlAttrOp = (property: string, value: string): PatchOperation => ({
|
|
|
44
47
|
value,
|
|
45
48
|
});
|
|
46
49
|
|
|
50
|
+
const candidateTestDeps = () => ({
|
|
51
|
+
publishSession: vi.fn(),
|
|
52
|
+
createCandidateSession: async (
|
|
53
|
+
_serialized: string,
|
|
54
|
+
live: Parameters<typeof sdkCutoverPersist>[4],
|
|
55
|
+
) => live!,
|
|
56
|
+
});
|
|
57
|
+
|
|
47
58
|
describe("shouldUseSdkCutover", () => {
|
|
48
59
|
it("returns false when flag disabled", () => {
|
|
49
60
|
expect(shouldUseSdkCutover(false, true, "hf-abc", [styleOp("color", "red")])).toBe(false);
|
|
@@ -148,6 +159,7 @@ describe("sdkCutoverPersist", () => {
|
|
|
148
159
|
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
149
160
|
reloadPreview: vi.fn(),
|
|
150
161
|
domEditSaveTimestampRef: makeRef(0),
|
|
162
|
+
...candidateTestDeps(),
|
|
151
163
|
...overrides,
|
|
152
164
|
});
|
|
153
165
|
|
|
@@ -175,7 +187,7 @@ describe("sdkCutoverPersist", () => {
|
|
|
175
187
|
null,
|
|
176
188
|
deps,
|
|
177
189
|
);
|
|
178
|
-
expect(result).toBe(
|
|
190
|
+
expect(result.status).toBe("declined");
|
|
179
191
|
});
|
|
180
192
|
|
|
181
193
|
it("returns false when element not found in session", async () => {
|
|
@@ -190,7 +202,7 @@ describe("sdkCutoverPersist", () => {
|
|
|
190
202
|
session,
|
|
191
203
|
deps,
|
|
192
204
|
);
|
|
193
|
-
expect(result).toBe(
|
|
205
|
+
expect(result.status).toBe("declined");
|
|
194
206
|
});
|
|
195
207
|
|
|
196
208
|
it("dispatches setStyle for inline-style ops", async () => {
|
|
@@ -205,13 +217,13 @@ describe("sdkCutoverPersist", () => {
|
|
|
205
217
|
session,
|
|
206
218
|
deps,
|
|
207
219
|
);
|
|
208
|
-
expect(result).toBe(
|
|
220
|
+
expect(result.status).toBe("committed");
|
|
209
221
|
expect(session!.dispatch).toHaveBeenCalledWith({
|
|
210
222
|
type: "setStyle",
|
|
211
223
|
target: "hf-abc",
|
|
212
224
|
styles: { color: "red", opacity: "0.5" },
|
|
213
225
|
});
|
|
214
|
-
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html></html>");
|
|
226
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html></html>", "before");
|
|
215
227
|
expect(deps.reloadPreview).toHaveBeenCalled();
|
|
216
228
|
});
|
|
217
229
|
|
|
@@ -227,7 +239,7 @@ describe("sdkCutoverPersist", () => {
|
|
|
227
239
|
session,
|
|
228
240
|
deps,
|
|
229
241
|
);
|
|
230
|
-
expect(result).toBe(
|
|
242
|
+
expect(result.status).toBe("committed");
|
|
231
243
|
expect(session!.dispatch).toHaveBeenCalledWith({
|
|
232
244
|
type: "setText",
|
|
233
245
|
target: "hf-abc",
|
|
@@ -251,7 +263,7 @@ describe("sdkCutoverPersist", () => {
|
|
|
251
263
|
session,
|
|
252
264
|
deps,
|
|
253
265
|
);
|
|
254
|
-
expect(result).toBe(
|
|
266
|
+
expect(result.status).toBe("declined");
|
|
255
267
|
expect(session!.dispatch).not.toHaveBeenCalled();
|
|
256
268
|
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
257
269
|
});
|
|
@@ -268,7 +280,7 @@ describe("sdkCutoverPersist", () => {
|
|
|
268
280
|
session,
|
|
269
281
|
deps,
|
|
270
282
|
);
|
|
271
|
-
expect(result).toBe(
|
|
283
|
+
expect(result.status).toBe("committed");
|
|
272
284
|
expect(session!.dispatch).toHaveBeenCalledWith({
|
|
273
285
|
type: "setAttribute",
|
|
274
286
|
target: "hf-abc",
|
|
@@ -289,7 +301,7 @@ describe("sdkCutoverPersist", () => {
|
|
|
289
301
|
session,
|
|
290
302
|
deps,
|
|
291
303
|
);
|
|
292
|
-
expect(result).toBe(
|
|
304
|
+
expect(result.status).toBe("committed");
|
|
293
305
|
expect(session!.dispatch).toHaveBeenCalledWith({
|
|
294
306
|
type: "setAttribute",
|
|
295
307
|
target: "hf-abc",
|
|
@@ -337,7 +349,7 @@ describe("sdkCutoverPersist", () => {
|
|
|
337
349
|
session,
|
|
338
350
|
deps,
|
|
339
351
|
);
|
|
340
|
-
expect(result).toBe(
|
|
352
|
+
expect(result.status).toBe("failed");
|
|
341
353
|
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
342
354
|
});
|
|
343
355
|
|
|
@@ -376,12 +388,430 @@ describe("sdkCutoverPersist", () => {
|
|
|
376
388
|
session,
|
|
377
389
|
deps,
|
|
378
390
|
);
|
|
379
|
-
expect(result).toBe(
|
|
391
|
+
expect(result.status).toBe("failed");
|
|
380
392
|
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
381
393
|
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
382
394
|
});
|
|
383
395
|
});
|
|
384
396
|
|
|
397
|
+
describe("transactional SDK candidate publication", () => {
|
|
398
|
+
const html = `<!DOCTYPE html><html data-composition-variables='[]'><body>
|
|
399
|
+
<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box" data-start="0" data-duration="1"></div>
|
|
400
|
+
<script>var tl = gsap.timeline({ paused: true });
|
|
401
|
+
tl.to('[data-hf-id="hf-box"]', { duration: 1, x: 100 }, 0);
|
|
402
|
+
window.__timelines = { main: tl };</script></div>
|
|
403
|
+
</body></html>`;
|
|
404
|
+
|
|
405
|
+
it.each([
|
|
406
|
+
[
|
|
407
|
+
"style",
|
|
408
|
+
(session: Awaited<ReturnType<typeof openComposition>>) =>
|
|
409
|
+
session.setStyle("hf-box", { color: "red" }),
|
|
410
|
+
],
|
|
411
|
+
[
|
|
412
|
+
"timing",
|
|
413
|
+
(session: Awaited<ReturnType<typeof openComposition>>) =>
|
|
414
|
+
session.setTiming("hf-box", { start: 2 }),
|
|
415
|
+
],
|
|
416
|
+
[
|
|
417
|
+
"delete",
|
|
418
|
+
(session: Awaited<ReturnType<typeof openComposition>>) => session.removeElement("hf-box"),
|
|
419
|
+
],
|
|
420
|
+
[
|
|
421
|
+
"variables",
|
|
422
|
+
(session: Awaited<ReturnType<typeof openComposition>>) =>
|
|
423
|
+
session.declareVariable({ id: "title", type: "string", label: "Title", default: "Hello" }),
|
|
424
|
+
],
|
|
425
|
+
[
|
|
426
|
+
"grouping/structure",
|
|
427
|
+
(session: Awaited<ReturnType<typeof openComposition>>) =>
|
|
428
|
+
session.addElement(null, 0, '<div data-hf-group="group-1"></div>'),
|
|
429
|
+
],
|
|
430
|
+
[
|
|
431
|
+
"GSAP",
|
|
432
|
+
(session: Awaited<ReturnType<typeof openComposition>>) => {
|
|
433
|
+
const animationId = session.getElement("hf-box")?.animationIds[0];
|
|
434
|
+
if (!animationId) throw new Error("missing fixture animation");
|
|
435
|
+
session.setGsapTween(animationId, { ease: "power2.in" });
|
|
436
|
+
},
|
|
437
|
+
],
|
|
438
|
+
])(
|
|
439
|
+
"restores disk and keeps the live session unchanged when %s history fails",
|
|
440
|
+
async (_name, mutate) => {
|
|
441
|
+
const live = await openComposition(html, { history: false });
|
|
442
|
+
const liveBefore = live.serialize();
|
|
443
|
+
let disk = html;
|
|
444
|
+
const publishSession = vi.fn();
|
|
445
|
+
const result = await persistSdkCandidateMutation(
|
|
446
|
+
live,
|
|
447
|
+
"/comp.html",
|
|
448
|
+
html,
|
|
449
|
+
{
|
|
450
|
+
editHistory: { recordEdit: vi.fn().mockRejectedValue(new Error("history failed")) },
|
|
451
|
+
writeProjectFile: vi.fn(async (_path: string, content: string) => {
|
|
452
|
+
disk = content;
|
|
453
|
+
}),
|
|
454
|
+
reloadPreview: vi.fn(),
|
|
455
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
456
|
+
publishSession,
|
|
457
|
+
},
|
|
458
|
+
mutate,
|
|
459
|
+
{ label: `Edit ${_name}` },
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
expect(result.status).toBe("failed");
|
|
463
|
+
expect(disk).toBe(html);
|
|
464
|
+
expect(live.serialize()).toBe(liveBefore);
|
|
465
|
+
expect(publishSession).not.toHaveBeenCalled();
|
|
466
|
+
expect(() => cutoverCommittedOrThrow(result)).toThrow("history failed");
|
|
467
|
+
live.dispose();
|
|
468
|
+
},
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
it("disposes a mutated real candidate and leaves the live session unpublished when the write fails", async () => {
|
|
472
|
+
const live = await openComposition(html, { history: false });
|
|
473
|
+
const liveBefore = live.serialize();
|
|
474
|
+
let candidate: Awaited<ReturnType<typeof openComposition>> | undefined;
|
|
475
|
+
let disposeCandidate: ReturnType<typeof vi.spyOn> | undefined;
|
|
476
|
+
const publishSession = vi.fn().mockReturnValue("published");
|
|
477
|
+
const writeError = new Error("write failed");
|
|
478
|
+
const writeProjectFile = vi.fn().mockRejectedValue(writeError);
|
|
479
|
+
const result = await persistSdkCandidateMutation(
|
|
480
|
+
live,
|
|
481
|
+
"/comp.html",
|
|
482
|
+
html,
|
|
483
|
+
{
|
|
484
|
+
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
|
485
|
+
writeProjectFile,
|
|
486
|
+
reloadPreview: vi.fn(),
|
|
487
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
488
|
+
createCandidateSession: async (source) => {
|
|
489
|
+
candidate = await openComposition(source, { history: false });
|
|
490
|
+
disposeCandidate = vi.spyOn(candidate, "dispose");
|
|
491
|
+
return candidate;
|
|
492
|
+
},
|
|
493
|
+
publishSession,
|
|
494
|
+
},
|
|
495
|
+
(next) => next.setStyle("hf-box", { color: "red" }),
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
expect(result).toMatchObject({ status: "failed", error: writeError });
|
|
499
|
+
expect(writeProjectFile).toHaveBeenCalledOnce();
|
|
500
|
+
expect(writeProjectFile.mock.calls[0]?.[0]).toBe("/comp.html");
|
|
501
|
+
expect(writeProjectFile.mock.calls[0]?.[1]).toContain("color: red");
|
|
502
|
+
expect(live.serialize()).toBe(liveBefore);
|
|
503
|
+
expect(publishSession).not.toHaveBeenCalled();
|
|
504
|
+
expect(disposeCandidate).toHaveBeenCalledOnce();
|
|
505
|
+
live.dispose();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it("publishes the candidate only after write and history commit", async () => {
|
|
509
|
+
const live = await openComposition(html, { history: false });
|
|
510
|
+
const liveBefore = live.serialize();
|
|
511
|
+
const order: string[] = [];
|
|
512
|
+
let published: Awaited<ReturnType<typeof openComposition>> | undefined;
|
|
513
|
+
const result = await persistSdkCandidateMutation(
|
|
514
|
+
live,
|
|
515
|
+
"/comp.html",
|
|
516
|
+
html,
|
|
517
|
+
{
|
|
518
|
+
editHistory: {
|
|
519
|
+
recordEdit: vi.fn(async () => {
|
|
520
|
+
order.push("history");
|
|
521
|
+
}),
|
|
522
|
+
},
|
|
523
|
+
writeProjectFile: vi.fn(async () => {
|
|
524
|
+
order.push("write");
|
|
525
|
+
}),
|
|
526
|
+
reloadPreview: vi.fn(() => order.push("refresh")),
|
|
527
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
528
|
+
publishSession: ({ candidate }) => {
|
|
529
|
+
order.push("publish");
|
|
530
|
+
published = candidate;
|
|
531
|
+
return "published";
|
|
532
|
+
},
|
|
533
|
+
},
|
|
534
|
+
(candidate) => candidate.setStyle("hf-box", { color: "red" }),
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
expect(result.status).toBe("committed");
|
|
538
|
+
expect(order).toEqual(["write", "history", "publish", "refresh"]);
|
|
539
|
+
expect(live.serialize()).toBe(liveBefore);
|
|
540
|
+
expect(published?.serialize()).toContain("color: red");
|
|
541
|
+
live.dispose();
|
|
542
|
+
published?.dispose();
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("keeps the durable commit authoritative when a publisher throws after publication", async () => {
|
|
546
|
+
const live = await openComposition(html, { history: false });
|
|
547
|
+
let disk = html;
|
|
548
|
+
let published: Awaited<ReturnType<typeof openComposition>> | undefined;
|
|
549
|
+
const recordEdit = vi.fn().mockResolvedValue(undefined);
|
|
550
|
+
const writeProjectFile = vi.fn(async (_path: string, content: string) => {
|
|
551
|
+
disk = content;
|
|
552
|
+
});
|
|
553
|
+
const result = await persistSdkCandidateMutation(
|
|
554
|
+
live,
|
|
555
|
+
"/comp.html",
|
|
556
|
+
html,
|
|
557
|
+
{
|
|
558
|
+
editHistory: { recordEdit },
|
|
559
|
+
writeProjectFile,
|
|
560
|
+
reloadPreview: vi.fn(),
|
|
561
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
562
|
+
publishSession: ({ candidate }) => {
|
|
563
|
+
published = candidate;
|
|
564
|
+
throw new Error("cleanup after publish failed");
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
(candidate) => candidate.setStyle("hf-box", { color: "red" }),
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
expect(result.status).toBe("committed");
|
|
571
|
+
expect(disk).toContain("color: red");
|
|
572
|
+
expect(writeProjectFile).toHaveBeenCalledTimes(1);
|
|
573
|
+
expect(recordEdit).toHaveBeenCalledTimes(1);
|
|
574
|
+
expect(published?.serialize()).toContain("color: red");
|
|
575
|
+
live.dispose();
|
|
576
|
+
published?.dispose();
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it("serializes overlapping SDK edits and rebases each candidate on the latest disk bytes", async () => {
|
|
580
|
+
const live = await openComposition(html, { history: false });
|
|
581
|
+
let disk = html;
|
|
582
|
+
const published: Array<Awaited<ReturnType<typeof openComposition>>> = [];
|
|
583
|
+
const writeProjectFile = vi.fn(async (_path: string, content: string) => {
|
|
584
|
+
// Yield inside the write to make overlap deterministic.
|
|
585
|
+
await Promise.resolve();
|
|
586
|
+
disk = content;
|
|
587
|
+
});
|
|
588
|
+
const deps = {
|
|
589
|
+
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
|
590
|
+
writeProjectFile,
|
|
591
|
+
readProjectFile: vi.fn(async () => disk),
|
|
592
|
+
reloadPreview: vi.fn(),
|
|
593
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
594
|
+
publishSession: ({ candidate }) => {
|
|
595
|
+
published.push(candidate);
|
|
596
|
+
return "published";
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const [first, second] = await Promise.all([
|
|
601
|
+
persistSdkCandidateMutation(live, "/comp.html", html, deps, (candidate) =>
|
|
602
|
+
candidate.setStyle("hf-box", { color: "red" }),
|
|
603
|
+
),
|
|
604
|
+
persistSdkCandidateMutation(live, "/comp.html", html, deps, (candidate) =>
|
|
605
|
+
candidate.setStyle("hf-box", { backgroundColor: "blue" }),
|
|
606
|
+
),
|
|
607
|
+
]);
|
|
608
|
+
|
|
609
|
+
expect(first.status).toBe("committed");
|
|
610
|
+
expect(second.status).toBe("committed");
|
|
611
|
+
expect(disk).toContain("color: red");
|
|
612
|
+
expect(disk).toContain("background-color: blue");
|
|
613
|
+
expect(writeProjectFile).toHaveBeenCalledTimes(2);
|
|
614
|
+
live.dispose();
|
|
615
|
+
for (const candidate of published) candidate.dispose();
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
it("fails instead of cloning stale bytes when the authoritative queued read rejects", async () => {
|
|
619
|
+
const live = await openComposition(html, { history: false });
|
|
620
|
+
let disk = html;
|
|
621
|
+
let readCount = 0;
|
|
622
|
+
const writeProjectFile = vi.fn(async (_path: string, content: string) => {
|
|
623
|
+
disk = content;
|
|
624
|
+
});
|
|
625
|
+
const deps = {
|
|
626
|
+
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
|
627
|
+
writeProjectFile,
|
|
628
|
+
readProjectFile: vi.fn(async () => {
|
|
629
|
+
readCount++;
|
|
630
|
+
if (readCount === 1) return disk;
|
|
631
|
+
throw new Error("transient read failure");
|
|
632
|
+
}),
|
|
633
|
+
reloadPreview: vi.fn(),
|
|
634
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
635
|
+
publishSession: vi.fn().mockReturnValue("published"),
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
const first = await persistSdkCandidateMutation(live, "/comp.html", html, deps, (candidate) =>
|
|
639
|
+
candidate.setStyle("hf-box", { color: "red" }),
|
|
640
|
+
);
|
|
641
|
+
const second = await persistSdkCandidateMutation(live, "/comp.html", html, deps, (candidate) =>
|
|
642
|
+
candidate.setStyle("hf-box", { backgroundColor: "blue" }),
|
|
643
|
+
);
|
|
644
|
+
|
|
645
|
+
expect(first.status).toBe("committed");
|
|
646
|
+
expect(second).toMatchObject({ status: "failed", error: new Error("transient read failure") });
|
|
647
|
+
expect(disk).toContain("color: red");
|
|
648
|
+
expect(disk).not.toContain("background-color: blue");
|
|
649
|
+
expect(writeProjectFile).toHaveBeenCalledTimes(1);
|
|
650
|
+
live.dispose();
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("does not publish a delayed candidate after the active composition switches", async () => {
|
|
654
|
+
const liveA = await openComposition(html, { history: false });
|
|
655
|
+
const liveB = await openComposition(html.replace("hf-box", "hf-other"), { history: false });
|
|
656
|
+
const candidateA = await openComposition(html, { history: false });
|
|
657
|
+
const disposeCandidate = vi.spyOn(candidateA, "dispose");
|
|
658
|
+
let activePath = "/a.html";
|
|
659
|
+
let currentSession = liveA;
|
|
660
|
+
let releaseHistory: (() => void) | undefined;
|
|
661
|
+
const historyStarted = new Promise<void>((resolve) => {
|
|
662
|
+
releaseHistory = resolve;
|
|
663
|
+
});
|
|
664
|
+
let notifyHistoryStarted: (() => void) | undefined;
|
|
665
|
+
const didStartHistory = new Promise<void>((resolve) => {
|
|
666
|
+
notifyHistoryStarted = resolve;
|
|
667
|
+
});
|
|
668
|
+
const published: Array<Awaited<ReturnType<typeof openComposition>>> = [];
|
|
669
|
+
const refresh = vi.fn();
|
|
670
|
+
const pending = persistSdkCandidateMutation(
|
|
671
|
+
liveA,
|
|
672
|
+
"/a.html",
|
|
673
|
+
html,
|
|
674
|
+
{
|
|
675
|
+
editHistory: {
|
|
676
|
+
recordEdit: vi.fn(async () => {
|
|
677
|
+
notifyHistoryStarted?.();
|
|
678
|
+
await historyStarted;
|
|
679
|
+
}),
|
|
680
|
+
},
|
|
681
|
+
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
682
|
+
readProjectFile: vi.fn().mockResolvedValue(html),
|
|
683
|
+
reloadPreview: vi.fn(),
|
|
684
|
+
refresh,
|
|
685
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
686
|
+
createCandidateSession: vi.fn().mockResolvedValue(candidateA),
|
|
687
|
+
publishSession: ({ candidate, expectedSession, targetPath }) => {
|
|
688
|
+
if (activePath !== targetPath || currentSession !== expectedSession) {
|
|
689
|
+
return "rejected-inactive-target";
|
|
690
|
+
}
|
|
691
|
+
currentSession = candidate;
|
|
692
|
+
published.push(candidate);
|
|
693
|
+
return "published";
|
|
694
|
+
},
|
|
695
|
+
},
|
|
696
|
+
(candidate) => candidate.setStyle("hf-box", { color: "red" }),
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
await didStartHistory;
|
|
700
|
+
activePath = "/b.html";
|
|
701
|
+
currentSession = liveB;
|
|
702
|
+
releaseHistory?.();
|
|
703
|
+
|
|
704
|
+
expect((await pending).status).toBe("committed");
|
|
705
|
+
expect(currentSession).toBe(liveB);
|
|
706
|
+
expect(published).toHaveLength(0);
|
|
707
|
+
expect(disposeCandidate).toHaveBeenCalledOnce();
|
|
708
|
+
expect(refresh).not.toHaveBeenCalled();
|
|
709
|
+
liveA.dispose();
|
|
710
|
+
liveB.dispose();
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
describe("persistSdkSerialize — shared per-file transaction boundary", () => {
|
|
715
|
+
const html = `<!DOCTYPE html><html data-composition-variables='[]'><body>
|
|
716
|
+
<div data-hf-id="hf-stage" data-hf-root><div data-hf-id="hf-box" data-start="0" data-duration="1"></div></div>
|
|
717
|
+
</body></html>`;
|
|
718
|
+
|
|
719
|
+
const makeDeps = (disk: { current: string }) => ({
|
|
720
|
+
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
|
721
|
+
writeProjectFile: vi.fn(async (_path: string, content: string) => {
|
|
722
|
+
disk.current = content;
|
|
723
|
+
}),
|
|
724
|
+
readProjectFile: vi.fn(async () => disk.current),
|
|
725
|
+
reloadPreview: vi.fn(),
|
|
726
|
+
domEditSaveTimestampRef: { current: 0 },
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
it("rebases overlapping whole-file transforms on the latest committed bytes", async () => {
|
|
730
|
+
const disk = { current: "<html><body></body></html>" };
|
|
731
|
+
let releaseFirstWrite: (() => void) | undefined;
|
|
732
|
+
const firstWriteStarted = new Promise<void>((resolve) => {
|
|
733
|
+
releaseFirstWrite = resolve;
|
|
734
|
+
});
|
|
735
|
+
let notifyFirstWriteStarted: (() => void) | undefined;
|
|
736
|
+
const didStartFirstWrite = new Promise<void>((resolve) => {
|
|
737
|
+
notifyFirstWriteStarted = resolve;
|
|
738
|
+
});
|
|
739
|
+
const deps = makeDeps(disk);
|
|
740
|
+
deps.writeProjectFile.mockImplementationOnce(async (_path: string, content: string) => {
|
|
741
|
+
notifyFirstWriteStarted?.();
|
|
742
|
+
await firstWriteStarted;
|
|
743
|
+
disk.current = content;
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
const first = persistSdkSerialize(
|
|
747
|
+
(before) => before.replace("</body>", "<div>A</div></body>"),
|
|
748
|
+
"/comp.html",
|
|
749
|
+
disk.current,
|
|
750
|
+
deps,
|
|
751
|
+
);
|
|
752
|
+
await didStartFirstWrite;
|
|
753
|
+
const second = persistSdkSerialize(
|
|
754
|
+
(before) => before.replace("</body>", "<div>B</div></body>"),
|
|
755
|
+
"/comp.html",
|
|
756
|
+
disk.current,
|
|
757
|
+
deps,
|
|
758
|
+
);
|
|
759
|
+
releaseFirstWrite?.();
|
|
760
|
+
await Promise.all([first, second]);
|
|
761
|
+
|
|
762
|
+
expect(disk.current).toContain("<div>A</div>");
|
|
763
|
+
expect(disk.current).toContain("<div>B</div>");
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it("shares the same queue with candidate mutations", async () => {
|
|
767
|
+
const disk = { current: html };
|
|
768
|
+
const live = await openComposition(html, { history: false });
|
|
769
|
+
const published: Array<Awaited<ReturnType<typeof openComposition>>> = [];
|
|
770
|
+
const deps = {
|
|
771
|
+
...makeDeps(disk),
|
|
772
|
+
publishSession: ({
|
|
773
|
+
candidate,
|
|
774
|
+
}: {
|
|
775
|
+
candidate: Awaited<ReturnType<typeof openComposition>>;
|
|
776
|
+
}) => {
|
|
777
|
+
published.push(candidate);
|
|
778
|
+
return "published";
|
|
779
|
+
},
|
|
780
|
+
};
|
|
781
|
+
let releaseCandidateWrite: (() => void) | undefined;
|
|
782
|
+
const candidateWriteGate = new Promise<void>((resolve) => {
|
|
783
|
+
releaseCandidateWrite = resolve;
|
|
784
|
+
});
|
|
785
|
+
let notifyCandidateWriteStarted: (() => void) | undefined;
|
|
786
|
+
const candidateWriteStarted = new Promise<void>((resolve) => {
|
|
787
|
+
notifyCandidateWriteStarted = resolve;
|
|
788
|
+
});
|
|
789
|
+
deps.writeProjectFile.mockImplementationOnce(async (_path: string, content: string) => {
|
|
790
|
+
notifyCandidateWriteStarted?.();
|
|
791
|
+
await candidateWriteGate;
|
|
792
|
+
disk.current = content;
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
const candidateEdit = persistSdkCandidateMutation(live, "/comp.html", html, deps, (candidate) =>
|
|
796
|
+
candidate.setStyle("hf-box", { color: "red" }),
|
|
797
|
+
);
|
|
798
|
+
await candidateWriteStarted;
|
|
799
|
+
const islandEdit = persistSdkSerialize(
|
|
800
|
+
(before) => before.replace("</body>", "<script>island</script></body>"),
|
|
801
|
+
"/comp.html",
|
|
802
|
+
html,
|
|
803
|
+
deps,
|
|
804
|
+
);
|
|
805
|
+
releaseCandidateWrite?.();
|
|
806
|
+
await Promise.all([candidateEdit, islandEdit]);
|
|
807
|
+
|
|
808
|
+
expect(disk.current).toContain("color: red");
|
|
809
|
+
expect(disk.current).toContain("<script>island</script>");
|
|
810
|
+
live.dispose();
|
|
811
|
+
for (const candidate of published) candidate.dispose();
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
|
|
385
815
|
describe("sdkDeletePersist", () => {
|
|
386
816
|
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
|
387
817
|
const makeDeps = () => ({
|
|
@@ -389,6 +819,7 @@ describe("sdkDeletePersist", () => {
|
|
|
389
819
|
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
390
820
|
reloadPreview: vi.fn(),
|
|
391
821
|
domEditSaveTimestampRef: makeRef(0),
|
|
822
|
+
...candidateTestDeps(),
|
|
392
823
|
});
|
|
393
824
|
|
|
394
825
|
const makeSession = (hasEl = true) =>
|
|
@@ -403,23 +834,29 @@ describe("sdkDeletePersist", () => {
|
|
|
403
834
|
}) as unknown as Parameters<typeof sdkDeletePersist>[3];
|
|
404
835
|
|
|
405
836
|
it("returns false when session is null", async () => {
|
|
406
|
-
expect(
|
|
837
|
+
expect(
|
|
838
|
+
(await sdkDeletePersist("hf-abc", "before", "/comp.html", null, makeDeps())).status,
|
|
839
|
+
).toBe("declined");
|
|
407
840
|
});
|
|
408
841
|
|
|
409
842
|
it("returns false when element not found in session", async () => {
|
|
410
843
|
const session = makeSession(false);
|
|
411
|
-
expect(
|
|
412
|
-
|
|
413
|
-
);
|
|
844
|
+
expect(
|
|
845
|
+
(await sdkDeletePersist("hf-abc", "before", "/comp.html", session, makeDeps())).status,
|
|
846
|
+
).toBe("declined");
|
|
414
847
|
});
|
|
415
848
|
|
|
416
849
|
it("calls removeElement and writes serialized content", async () => {
|
|
417
850
|
const deps = makeDeps();
|
|
418
851
|
const session = makeSession(true);
|
|
419
852
|
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
|
|
420
|
-
expect(result).toBe(
|
|
853
|
+
expect(result.status).toBe("committed");
|
|
421
854
|
expect(session!.removeElement).toHaveBeenCalledWith("hf-abc");
|
|
422
|
-
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
855
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
856
|
+
"/comp.html",
|
|
857
|
+
"<html>after</html>",
|
|
858
|
+
"before",
|
|
859
|
+
);
|
|
423
860
|
});
|
|
424
861
|
|
|
425
862
|
it("records edit history with before/after diff", async () => {
|
|
@@ -448,7 +885,7 @@ describe("sdkDeletePersist", () => {
|
|
|
448
885
|
throw new Error("remove failed");
|
|
449
886
|
});
|
|
450
887
|
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
|
|
451
|
-
expect(result).toBe(
|
|
888
|
+
expect(result.status).toBe("failed");
|
|
452
889
|
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
453
890
|
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
454
891
|
});
|
|
@@ -461,6 +898,7 @@ describe("sdkTimingPersist", () => {
|
|
|
461
898
|
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
462
899
|
reloadPreview: vi.fn(),
|
|
463
900
|
domEditSaveTimestampRef: makeRef(0),
|
|
901
|
+
...candidateTestDeps(),
|
|
464
902
|
});
|
|
465
903
|
|
|
466
904
|
const makeSession = (hasEl = true) =>
|
|
@@ -475,16 +913,16 @@ describe("sdkTimingPersist", () => {
|
|
|
475
913
|
}) as unknown as Parameters<typeof sdkTimingPersist>[3];
|
|
476
914
|
|
|
477
915
|
it("returns false when session is null", async () => {
|
|
478
|
-
expect(
|
|
479
|
-
|
|
480
|
-
);
|
|
916
|
+
expect(
|
|
917
|
+
(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, null, makeDeps())).status,
|
|
918
|
+
).toBe("declined");
|
|
481
919
|
});
|
|
482
920
|
|
|
483
921
|
it("returns false when element not found in session", async () => {
|
|
484
922
|
const session = makeSession(false);
|
|
485
|
-
expect(
|
|
486
|
-
|
|
487
|
-
);
|
|
923
|
+
expect(
|
|
924
|
+
(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, makeDeps())).status,
|
|
925
|
+
).toBe("declined");
|
|
488
926
|
});
|
|
489
927
|
|
|
490
928
|
it("calls setTiming with provided update and writes serialized content", async () => {
|
|
@@ -497,13 +935,17 @@ describe("sdkTimingPersist", () => {
|
|
|
497
935
|
session,
|
|
498
936
|
deps,
|
|
499
937
|
);
|
|
500
|
-
expect(result).toBe(
|
|
938
|
+
expect(result.status).toBe("committed");
|
|
501
939
|
expect(session!.setTiming).toHaveBeenCalledWith("hf-clip", {
|
|
502
940
|
start: 2,
|
|
503
941
|
duration: 5,
|
|
504
942
|
trackIndex: 1,
|
|
505
943
|
});
|
|
506
|
-
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
944
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
945
|
+
"/comp.html",
|
|
946
|
+
"<html>after</html>",
|
|
947
|
+
"<html>before</html>",
|
|
948
|
+
);
|
|
507
949
|
});
|
|
508
950
|
|
|
509
951
|
it("captures before-state before setTiming dispatch", async () => {
|
|
@@ -524,7 +966,7 @@ describe("sdkTimingPersist", () => {
|
|
|
524
966
|
throw new Error("timing error");
|
|
525
967
|
});
|
|
526
968
|
const result = await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, deps);
|
|
527
|
-
expect(result).toBe(
|
|
969
|
+
expect(result.status).toBe("failed");
|
|
528
970
|
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
529
971
|
});
|
|
530
972
|
|
|
@@ -539,6 +981,7 @@ describe("sdkTimingPersist", () => {
|
|
|
539
981
|
const session = makeSession(true);
|
|
540
982
|
await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
|
|
541
983
|
expect(deps.readProjectFile).toHaveBeenCalledWith("/comp.html");
|
|
984
|
+
expect(deps.readProjectFile).toHaveBeenCalledOnce();
|
|
542
985
|
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
|
|
543
986
|
expect.objectContaining({
|
|
544
987
|
files: {
|
|
@@ -548,18 +991,16 @@ describe("sdkTimingPersist", () => {
|
|
|
548
991
|
);
|
|
549
992
|
});
|
|
550
993
|
|
|
551
|
-
it("
|
|
994
|
+
it("fails without writing when the authoritative reader throws", async () => {
|
|
552
995
|
const deps = {
|
|
553
996
|
...makeDeps(),
|
|
554
997
|
readProjectFile: vi.fn().mockRejectedValue(new Error("read failed")),
|
|
555
998
|
};
|
|
556
999
|
const session = makeSession(true);
|
|
557
|
-
await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
|
|
558
|
-
expect(
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
}),
|
|
562
|
-
);
|
|
1000
|
+
const result = await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
|
|
1001
|
+
expect(result).toMatchObject({ status: "failed", error: new Error("read failed") });
|
|
1002
|
+
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
1003
|
+
expect(deps.editHistory.recordEdit).not.toHaveBeenCalled();
|
|
563
1004
|
});
|
|
564
1005
|
});
|
|
565
1006
|
|
|
@@ -583,6 +1024,7 @@ describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => {
|
|
|
583
1024
|
reloadPreview: vi.fn(),
|
|
584
1025
|
domEditSaveTimestampRef: makeRef(0),
|
|
585
1026
|
readProjectFile: vi.fn().mockResolvedValue("<html>on-disk gsap bytes</html>"),
|
|
1027
|
+
...candidateTestDeps(),
|
|
586
1028
|
};
|
|
587
1029
|
const session = makeSession();
|
|
588
1030
|
await sdkGsapTweenPersist(
|
|
@@ -591,6 +1033,7 @@ describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => {
|
|
|
591
1033
|
session,
|
|
592
1034
|
deps,
|
|
593
1035
|
);
|
|
1036
|
+
expect(deps.readProjectFile).toHaveBeenCalledOnce();
|
|
594
1037
|
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
|
|
595
1038
|
expect.objectContaining({
|
|
596
1039
|
files: {
|
|
@@ -604,16 +1047,18 @@ describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => {
|
|
|
604
1047
|
describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => {
|
|
605
1048
|
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
|
606
1049
|
|
|
607
|
-
it("routes the read-modify-write through the
|
|
1050
|
+
it("routes the read-modify-write through the shared file coordinator", async () => {
|
|
608
1051
|
const order: string[] = [];
|
|
609
1052
|
let writeResolve: (() => void) | null = null;
|
|
1053
|
+
let writeCall = 0;
|
|
610
1054
|
const deps = {
|
|
611
1055
|
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
|
612
1056
|
// First write blocks until we release it, so without serialization the
|
|
613
1057
|
// second op's serialize()/dispatch would interleave ahead of it.
|
|
614
1058
|
writeProjectFile: vi.fn().mockImplementation((_p: string, content: string) => {
|
|
1059
|
+
writeCall++;
|
|
615
1060
|
order.push(`write-start:${content}`);
|
|
616
|
-
if (
|
|
1061
|
+
if (writeCall === 1) {
|
|
617
1062
|
return new Promise<void>((res) => {
|
|
618
1063
|
writeResolve = () => {
|
|
619
1064
|
order.push(`write-done:${content}`);
|
|
@@ -626,16 +1071,7 @@ describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => {
|
|
|
626
1071
|
}),
|
|
627
1072
|
reloadPreview: vi.fn(),
|
|
628
1073
|
domEditSaveTimestampRef: makeRef(0),
|
|
629
|
-
|
|
630
|
-
serialize: (() => {
|
|
631
|
-
const inFlight = new Map<string, Promise<unknown>>();
|
|
632
|
-
return <T>(key: string, task: () => Promise<T>): Promise<T> => {
|
|
633
|
-
const prior = inFlight.get(key) ?? Promise.resolve();
|
|
634
|
-
const next = prior.then(task, task);
|
|
635
|
-
inFlight.set(key, next);
|
|
636
|
-
return next as Promise<T>;
|
|
637
|
-
};
|
|
638
|
-
})(),
|
|
1074
|
+
...candidateTestDeps(),
|
|
639
1075
|
};
|
|
640
1076
|
|
|
641
1077
|
let serializeCall = 0;
|
|
@@ -662,15 +1098,17 @@ describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => {
|
|
|
662
1098
|
session,
|
|
663
1099
|
deps,
|
|
664
1100
|
);
|
|
665
|
-
// Let the first op reach its
|
|
666
|
-
await
|
|
667
|
-
await Promise.resolve();
|
|
1101
|
+
// Let the first op finish candidate construction and reach its blocked write.
|
|
1102
|
+
await vi.waitFor(() => expect(writeResolve).not.toBeNull());
|
|
668
1103
|
writeResolve?.();
|
|
669
1104
|
await Promise.all([p1, p2]);
|
|
670
1105
|
|
|
671
1106
|
// The second op's write must NOT start before the first op's write completes.
|
|
672
|
-
const firstWriteDone = order.
|
|
673
|
-
const
|
|
1107
|
+
const firstWriteDone = order.findIndex((entry) => entry.startsWith("write-done:"));
|
|
1108
|
+
const writeStarts = order
|
|
1109
|
+
.map((entry, index) => (entry.startsWith("write-start:") ? index : -1))
|
|
1110
|
+
.filter((index) => index >= 0);
|
|
1111
|
+
const secondWriteStart = writeStarts[1] ?? -1;
|
|
674
1112
|
expect(firstWriteDone).toBeGreaterThanOrEqual(0);
|
|
675
1113
|
expect(secondWriteStart).toBeGreaterThan(firstWriteDone);
|
|
676
1114
|
});
|
|
@@ -683,6 +1121,7 @@ describe("sdkGsapTweenPersist", () => {
|
|
|
683
1121
|
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
684
1122
|
reloadPreview: vi.fn(),
|
|
685
1123
|
domEditSaveTimestampRef: makeRef(0),
|
|
1124
|
+
...candidateTestDeps(),
|
|
686
1125
|
});
|
|
687
1126
|
|
|
688
1127
|
const makeSession = (opts?: { addGsapTween?: string; hasEl?: boolean }) =>
|
|
@@ -706,7 +1145,7 @@ describe("sdkGsapTweenPersist", () => {
|
|
|
706
1145
|
null,
|
|
707
1146
|
makeDeps(),
|
|
708
1147
|
),
|
|
709
|
-
).
|
|
1148
|
+
).toMatchObject({ status: "declined" });
|
|
710
1149
|
});
|
|
711
1150
|
|
|
712
1151
|
it("calls addGsapTween and writes for kind=add", async () => {
|
|
@@ -722,12 +1161,16 @@ describe("sdkGsapTweenPersist", () => {
|
|
|
722
1161
|
session,
|
|
723
1162
|
deps,
|
|
724
1163
|
);
|
|
725
|
-
expect(result).toBe(
|
|
1164
|
+
expect(result.status).toBe("committed");
|
|
726
1165
|
expect(session!.addGsapTween).toHaveBeenCalledWith(
|
|
727
1166
|
"hf-box",
|
|
728
1167
|
expect.objectContaining({ method: "to" }),
|
|
729
1168
|
);
|
|
730
|
-
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
1169
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
1170
|
+
"/comp.html",
|
|
1171
|
+
"<html>after</html>",
|
|
1172
|
+
"<html>before</html>",
|
|
1173
|
+
);
|
|
731
1174
|
});
|
|
732
1175
|
|
|
733
1176
|
it("returns false for kind=add when element not found", async () => {
|
|
@@ -739,7 +1182,7 @@ describe("sdkGsapTweenPersist", () => {
|
|
|
739
1182
|
session,
|
|
740
1183
|
deps,
|
|
741
1184
|
);
|
|
742
|
-
expect(result).toBe(
|
|
1185
|
+
expect(result.status).toBe("declined");
|
|
743
1186
|
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
744
1187
|
});
|
|
745
1188
|
|
|
@@ -752,7 +1195,7 @@ describe("sdkGsapTweenPersist", () => {
|
|
|
752
1195
|
session,
|
|
753
1196
|
deps,
|
|
754
1197
|
);
|
|
755
|
-
expect(result).toBe(
|
|
1198
|
+
expect(result.status).toBe("committed");
|
|
756
1199
|
expect(session!.setGsapTween).toHaveBeenCalledWith("tw-1", { ease: "power3.in" });
|
|
757
1200
|
expect(deps.reloadPreview).toHaveBeenCalled();
|
|
758
1201
|
});
|
|
@@ -766,7 +1209,7 @@ describe("sdkGsapTweenPersist", () => {
|
|
|
766
1209
|
session,
|
|
767
1210
|
deps,
|
|
768
1211
|
);
|
|
769
|
-
expect(result).toBe(
|
|
1212
|
+
expect(result.status).toBe("committed");
|
|
770
1213
|
expect(session!.removeGsapTween).toHaveBeenCalledWith("tw-1");
|
|
771
1214
|
});
|
|
772
1215
|
|
|
@@ -782,7 +1225,7 @@ describe("sdkGsapTweenPersist", () => {
|
|
|
782
1225
|
session,
|
|
783
1226
|
deps,
|
|
784
1227
|
);
|
|
785
|
-
expect(result).toBe(
|
|
1228
|
+
expect(result.status).toBe("failed");
|
|
786
1229
|
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
787
1230
|
});
|
|
788
1231
|
});
|
|
@@ -794,6 +1237,7 @@ describe("sdkGsapKeyframePersist", () => {
|
|
|
794
1237
|
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
795
1238
|
reloadPreview: vi.fn(),
|
|
796
1239
|
domEditSaveTimestampRef: makeRef(0),
|
|
1240
|
+
...candidateTestDeps(),
|
|
797
1241
|
});
|
|
798
1242
|
|
|
799
1243
|
const makeSession = () =>
|
|
@@ -809,7 +1253,7 @@ describe("sdkGsapKeyframePersist", () => {
|
|
|
809
1253
|
it("returns false when session is null", async () => {
|
|
810
1254
|
expect(
|
|
811
1255
|
await sdkGsapKeyframePersist("/comp.html", "tw-1", 50, { opacity: 0.5 }, null, makeDeps()),
|
|
812
|
-
).
|
|
1256
|
+
).toMatchObject({ status: "declined" });
|
|
813
1257
|
});
|
|
814
1258
|
|
|
815
1259
|
it("dispatches addGsapKeyframe and writes serialized content", async () => {
|
|
@@ -823,14 +1267,18 @@ describe("sdkGsapKeyframePersist", () => {
|
|
|
823
1267
|
session,
|
|
824
1268
|
deps,
|
|
825
1269
|
);
|
|
826
|
-
expect(result).toBe(
|
|
1270
|
+
expect(result.status).toBe("committed");
|
|
827
1271
|
expect(session!.dispatch).toHaveBeenCalledWith({
|
|
828
1272
|
type: "addGsapKeyframe",
|
|
829
1273
|
animationId: "tw-1",
|
|
830
1274
|
position: 50,
|
|
831
1275
|
value: { opacity: 0.5 },
|
|
832
1276
|
});
|
|
833
|
-
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
1277
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith(
|
|
1278
|
+
"/comp.html",
|
|
1279
|
+
"<html>after</html>",
|
|
1280
|
+
"<html>before</html>",
|
|
1281
|
+
);
|
|
834
1282
|
expect(deps.reloadPreview).toHaveBeenCalled();
|
|
835
1283
|
});
|
|
836
1284
|
|
|
@@ -848,7 +1296,7 @@ describe("sdkGsapKeyframePersist", () => {
|
|
|
848
1296
|
session,
|
|
849
1297
|
deps,
|
|
850
1298
|
);
|
|
851
|
-
expect(result).toBe(
|
|
1299
|
+
expect(result.status).toBe("failed");
|
|
852
1300
|
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
853
1301
|
});
|
|
854
1302
|
});
|
|
@@ -860,6 +1308,7 @@ describe("sdkCutoverPersist — GSAP script preservation (integration)", () => {
|
|
|
860
1308
|
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
861
1309
|
reloadPreview: vi.fn(),
|
|
862
1310
|
domEditSaveTimestampRef: makeRef(0),
|
|
1311
|
+
...candidateTestDeps(),
|
|
863
1312
|
});
|
|
864
1313
|
|
|
865
1314
|
it("preserves GSAP <script> block and data-position-mode through setStyle dispatch", async () => {
|
|
@@ -880,7 +1329,7 @@ gsap.timeline().to('[data-hf-id="hf-layer"]', { duration: 1, x: 100 });
|
|
|
880
1329
|
comp,
|
|
881
1330
|
deps,
|
|
882
1331
|
);
|
|
883
|
-
expect(result).toBe(
|
|
1332
|
+
expect(result.status).toBe("committed");
|
|
884
1333
|
const written = (deps.writeProjectFile as ReturnType<typeof vi.fn>).mock
|
|
885
1334
|
.calls[0]?.[1] as string;
|
|
886
1335
|
expect(written).toContain("data-hf-gsap");
|