@hyperframes/studio 0.7.58 → 0.7.59
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-Bm07FLkl.js} +1 -1
- package/dist/assets/{index-C47jAC3Q.js → index-B995FG46.js} +1 -1
- package/dist/assets/index-CrkAdJkb.js +426 -0
- package/dist/assets/index-Dj5p8U_A.css +1 -0
- package/dist/assets/{index-DeQPzqwH.js → index-FvzmPhfG.js} +1 -1
- package/dist/index.d.ts +12 -3
- package/dist/index.html +2 -2
- package/dist/index.js +13514 -8528
- 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 +3 -3
- package/src/components/DesignPanelPromoteProvider.tsx +15 -2
- package/src/components/StudioRightPanel.tsx +29 -51
- 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 +101 -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/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/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.ts +29 -175
- package/src/hooks/useDomEditCommitsHelpers.ts +158 -0
- package/src/hooks/useDomEditSession.ts +17 -5
- package/src/hooks/useDomEditTextCommits.ts +14 -9
- package/src/hooks/useDomEditWiring.ts +10 -9
- package/src/hooks/useElementLifecycleOps.ts +8 -3
- package/src/hooks/useFileManager.projectOwnership.test.tsx +98 -0
- package/src/hooks/useFileManager.ts +82 -60
- 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/usePersistentEditHistory.projectOwnership.test.tsx +60 -0
- package/src/hooks/usePersistentEditHistory.test.ts +49 -1
- package/src/hooks/usePersistentEditHistory.ts +71 -31
- 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/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 +16 -10
- package/src/hooks/useTimelineEditingTypes.ts +3 -0
- package/src/hooks/useTimelineGroupEditing.ts +16 -2
- 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/sdkCutover.gate.test.ts +7 -7
- package/src/utils/sdkCutover.test.ts +490 -57
- package/src/utils/sdkCutover.ts +181 -219
- package/src/utils/sdkEditTransaction.ts +292 -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 +30 -27
- package/src/utils/studioFileMutationCoordinator.ts +45 -0
- package/src/utils/studioHelpers.test.ts +45 -0
- package/src/utils/studioHelpers.ts +45 -14
- package/src/utils/studioSaveDiagnostics.ts +17 -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,7 +217,7 @@ 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",
|
|
@@ -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,21 +834,23 @@ 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
855
|
expect(deps.writeProjectFile).toHaveBeenCalledWith("/comp.html", "<html>after</html>");
|
|
423
856
|
});
|
|
@@ -448,7 +881,7 @@ describe("sdkDeletePersist", () => {
|
|
|
448
881
|
throw new Error("remove failed");
|
|
449
882
|
});
|
|
450
883
|
const result = await sdkDeletePersist("hf-abc", "before", "/comp.html", session, deps);
|
|
451
|
-
expect(result).toBe(
|
|
884
|
+
expect(result.status).toBe("failed");
|
|
452
885
|
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
453
886
|
expect(deps.reloadPreview).not.toHaveBeenCalled();
|
|
454
887
|
});
|
|
@@ -461,6 +894,7 @@ describe("sdkTimingPersist", () => {
|
|
|
461
894
|
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
462
895
|
reloadPreview: vi.fn(),
|
|
463
896
|
domEditSaveTimestampRef: makeRef(0),
|
|
897
|
+
...candidateTestDeps(),
|
|
464
898
|
});
|
|
465
899
|
|
|
466
900
|
const makeSession = (hasEl = true) =>
|
|
@@ -475,16 +909,16 @@ describe("sdkTimingPersist", () => {
|
|
|
475
909
|
}) as unknown as Parameters<typeof sdkTimingPersist>[3];
|
|
476
910
|
|
|
477
911
|
it("returns false when session is null", async () => {
|
|
478
|
-
expect(
|
|
479
|
-
|
|
480
|
-
);
|
|
912
|
+
expect(
|
|
913
|
+
(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, null, makeDeps())).status,
|
|
914
|
+
).toBe("declined");
|
|
481
915
|
});
|
|
482
916
|
|
|
483
917
|
it("returns false when element not found in session", async () => {
|
|
484
918
|
const session = makeSession(false);
|
|
485
|
-
expect(
|
|
486
|
-
|
|
487
|
-
);
|
|
919
|
+
expect(
|
|
920
|
+
(await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, makeDeps())).status,
|
|
921
|
+
).toBe("declined");
|
|
488
922
|
});
|
|
489
923
|
|
|
490
924
|
it("calls setTiming with provided update and writes serialized content", async () => {
|
|
@@ -497,7 +931,7 @@ describe("sdkTimingPersist", () => {
|
|
|
497
931
|
session,
|
|
498
932
|
deps,
|
|
499
933
|
);
|
|
500
|
-
expect(result).toBe(
|
|
934
|
+
expect(result.status).toBe("committed");
|
|
501
935
|
expect(session!.setTiming).toHaveBeenCalledWith("hf-clip", {
|
|
502
936
|
start: 2,
|
|
503
937
|
duration: 5,
|
|
@@ -524,7 +958,7 @@ describe("sdkTimingPersist", () => {
|
|
|
524
958
|
throw new Error("timing error");
|
|
525
959
|
});
|
|
526
960
|
const result = await sdkTimingPersist("hf-clip", "/comp.html", { start: 1 }, session, deps);
|
|
527
|
-
expect(result).toBe(
|
|
961
|
+
expect(result.status).toBe("failed");
|
|
528
962
|
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
529
963
|
});
|
|
530
964
|
|
|
@@ -539,6 +973,7 @@ describe("sdkTimingPersist", () => {
|
|
|
539
973
|
const session = makeSession(true);
|
|
540
974
|
await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
|
|
541
975
|
expect(deps.readProjectFile).toHaveBeenCalledWith("/comp.html");
|
|
976
|
+
expect(deps.readProjectFile).toHaveBeenCalledOnce();
|
|
542
977
|
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
|
|
543
978
|
expect.objectContaining({
|
|
544
979
|
files: {
|
|
@@ -548,18 +983,16 @@ describe("sdkTimingPersist", () => {
|
|
|
548
983
|
);
|
|
549
984
|
});
|
|
550
985
|
|
|
551
|
-
it("
|
|
986
|
+
it("fails without writing when the authoritative reader throws", async () => {
|
|
552
987
|
const deps = {
|
|
553
988
|
...makeDeps(),
|
|
554
989
|
readProjectFile: vi.fn().mockRejectedValue(new Error("read failed")),
|
|
555
990
|
};
|
|
556
991
|
const session = makeSession(true);
|
|
557
|
-
await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
|
|
558
|
-
expect(
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
}),
|
|
562
|
-
);
|
|
992
|
+
const result = await sdkTimingPersist("hf-clip", "/comp.html", { start: 3 }, session, deps);
|
|
993
|
+
expect(result).toMatchObject({ status: "failed", error: new Error("read failed") });
|
|
994
|
+
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
995
|
+
expect(deps.editHistory.recordEdit).not.toHaveBeenCalled();
|
|
563
996
|
});
|
|
564
997
|
});
|
|
565
998
|
|
|
@@ -583,6 +1016,7 @@ describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => {
|
|
|
583
1016
|
reloadPreview: vi.fn(),
|
|
584
1017
|
domEditSaveTimestampRef: makeRef(0),
|
|
585
1018
|
readProjectFile: vi.fn().mockResolvedValue("<html>on-disk gsap bytes</html>"),
|
|
1019
|
+
...candidateTestDeps(),
|
|
586
1020
|
};
|
|
587
1021
|
const session = makeSession();
|
|
588
1022
|
await sdkGsapTweenPersist(
|
|
@@ -591,6 +1025,7 @@ describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => {
|
|
|
591
1025
|
session,
|
|
592
1026
|
deps,
|
|
593
1027
|
);
|
|
1028
|
+
expect(deps.readProjectFile).toHaveBeenCalledOnce();
|
|
594
1029
|
expect(deps.editHistory.recordEdit).toHaveBeenCalledWith(
|
|
595
1030
|
expect.objectContaining({
|
|
596
1031
|
files: {
|
|
@@ -604,16 +1039,18 @@ describe("sdkGsapTweenPersist — undo baseline (finding #12)", () => {
|
|
|
604
1039
|
describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => {
|
|
605
1040
|
const makeRef = <T>(val: T): MutableRefObject<T> => ({ current: val });
|
|
606
1041
|
|
|
607
|
-
it("routes the read-modify-write through the
|
|
1042
|
+
it("routes the read-modify-write through the shared file coordinator", async () => {
|
|
608
1043
|
const order: string[] = [];
|
|
609
1044
|
let writeResolve: (() => void) | null = null;
|
|
1045
|
+
let writeCall = 0;
|
|
610
1046
|
const deps = {
|
|
611
1047
|
editHistory: { recordEdit: vi.fn().mockResolvedValue(undefined) },
|
|
612
1048
|
// First write blocks until we release it, so without serialization the
|
|
613
1049
|
// second op's serialize()/dispatch would interleave ahead of it.
|
|
614
1050
|
writeProjectFile: vi.fn().mockImplementation((_p: string, content: string) => {
|
|
1051
|
+
writeCall++;
|
|
615
1052
|
order.push(`write-start:${content}`);
|
|
616
|
-
if (
|
|
1053
|
+
if (writeCall === 1) {
|
|
617
1054
|
return new Promise<void>((res) => {
|
|
618
1055
|
writeResolve = () => {
|
|
619
1056
|
order.push(`write-done:${content}`);
|
|
@@ -626,16 +1063,7 @@ describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => {
|
|
|
626
1063
|
}),
|
|
627
1064
|
reloadPreview: vi.fn(),
|
|
628
1065
|
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
|
-
})(),
|
|
1066
|
+
...candidateTestDeps(),
|
|
639
1067
|
};
|
|
640
1068
|
|
|
641
1069
|
let serializeCall = 0;
|
|
@@ -662,15 +1090,17 @@ describe("sdkGsapTweenPersist — per-file serialization (finding #8)", () => {
|
|
|
662
1090
|
session,
|
|
663
1091
|
deps,
|
|
664
1092
|
);
|
|
665
|
-
// Let the first op reach its
|
|
666
|
-
await
|
|
667
|
-
await Promise.resolve();
|
|
1093
|
+
// Let the first op finish candidate construction and reach its blocked write.
|
|
1094
|
+
await vi.waitFor(() => expect(writeResolve).not.toBeNull());
|
|
668
1095
|
writeResolve?.();
|
|
669
1096
|
await Promise.all([p1, p2]);
|
|
670
1097
|
|
|
671
1098
|
// The second op's write must NOT start before the first op's write completes.
|
|
672
|
-
const firstWriteDone = order.
|
|
673
|
-
const
|
|
1099
|
+
const firstWriteDone = order.findIndex((entry) => entry.startsWith("write-done:"));
|
|
1100
|
+
const writeStarts = order
|
|
1101
|
+
.map((entry, index) => (entry.startsWith("write-start:") ? index : -1))
|
|
1102
|
+
.filter((index) => index >= 0);
|
|
1103
|
+
const secondWriteStart = writeStarts[1] ?? -1;
|
|
674
1104
|
expect(firstWriteDone).toBeGreaterThanOrEqual(0);
|
|
675
1105
|
expect(secondWriteStart).toBeGreaterThan(firstWriteDone);
|
|
676
1106
|
});
|
|
@@ -683,6 +1113,7 @@ describe("sdkGsapTweenPersist", () => {
|
|
|
683
1113
|
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
684
1114
|
reloadPreview: vi.fn(),
|
|
685
1115
|
domEditSaveTimestampRef: makeRef(0),
|
|
1116
|
+
...candidateTestDeps(),
|
|
686
1117
|
});
|
|
687
1118
|
|
|
688
1119
|
const makeSession = (opts?: { addGsapTween?: string; hasEl?: boolean }) =>
|
|
@@ -706,7 +1137,7 @@ describe("sdkGsapTweenPersist", () => {
|
|
|
706
1137
|
null,
|
|
707
1138
|
makeDeps(),
|
|
708
1139
|
),
|
|
709
|
-
).
|
|
1140
|
+
).toMatchObject({ status: "declined" });
|
|
710
1141
|
});
|
|
711
1142
|
|
|
712
1143
|
it("calls addGsapTween and writes for kind=add", async () => {
|
|
@@ -722,7 +1153,7 @@ describe("sdkGsapTweenPersist", () => {
|
|
|
722
1153
|
session,
|
|
723
1154
|
deps,
|
|
724
1155
|
);
|
|
725
|
-
expect(result).toBe(
|
|
1156
|
+
expect(result.status).toBe("committed");
|
|
726
1157
|
expect(session!.addGsapTween).toHaveBeenCalledWith(
|
|
727
1158
|
"hf-box",
|
|
728
1159
|
expect.objectContaining({ method: "to" }),
|
|
@@ -739,7 +1170,7 @@ describe("sdkGsapTweenPersist", () => {
|
|
|
739
1170
|
session,
|
|
740
1171
|
deps,
|
|
741
1172
|
);
|
|
742
|
-
expect(result).toBe(
|
|
1173
|
+
expect(result.status).toBe("declined");
|
|
743
1174
|
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
744
1175
|
});
|
|
745
1176
|
|
|
@@ -752,7 +1183,7 @@ describe("sdkGsapTweenPersist", () => {
|
|
|
752
1183
|
session,
|
|
753
1184
|
deps,
|
|
754
1185
|
);
|
|
755
|
-
expect(result).toBe(
|
|
1186
|
+
expect(result.status).toBe("committed");
|
|
756
1187
|
expect(session!.setGsapTween).toHaveBeenCalledWith("tw-1", { ease: "power3.in" });
|
|
757
1188
|
expect(deps.reloadPreview).toHaveBeenCalled();
|
|
758
1189
|
});
|
|
@@ -766,7 +1197,7 @@ describe("sdkGsapTweenPersist", () => {
|
|
|
766
1197
|
session,
|
|
767
1198
|
deps,
|
|
768
1199
|
);
|
|
769
|
-
expect(result).toBe(
|
|
1200
|
+
expect(result.status).toBe("committed");
|
|
770
1201
|
expect(session!.removeGsapTween).toHaveBeenCalledWith("tw-1");
|
|
771
1202
|
});
|
|
772
1203
|
|
|
@@ -782,7 +1213,7 @@ describe("sdkGsapTweenPersist", () => {
|
|
|
782
1213
|
session,
|
|
783
1214
|
deps,
|
|
784
1215
|
);
|
|
785
|
-
expect(result).toBe(
|
|
1216
|
+
expect(result.status).toBe("failed");
|
|
786
1217
|
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
787
1218
|
});
|
|
788
1219
|
});
|
|
@@ -794,6 +1225,7 @@ describe("sdkGsapKeyframePersist", () => {
|
|
|
794
1225
|
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
795
1226
|
reloadPreview: vi.fn(),
|
|
796
1227
|
domEditSaveTimestampRef: makeRef(0),
|
|
1228
|
+
...candidateTestDeps(),
|
|
797
1229
|
});
|
|
798
1230
|
|
|
799
1231
|
const makeSession = () =>
|
|
@@ -809,7 +1241,7 @@ describe("sdkGsapKeyframePersist", () => {
|
|
|
809
1241
|
it("returns false when session is null", async () => {
|
|
810
1242
|
expect(
|
|
811
1243
|
await sdkGsapKeyframePersist("/comp.html", "tw-1", 50, { opacity: 0.5 }, null, makeDeps()),
|
|
812
|
-
).
|
|
1244
|
+
).toMatchObject({ status: "declined" });
|
|
813
1245
|
});
|
|
814
1246
|
|
|
815
1247
|
it("dispatches addGsapKeyframe and writes serialized content", async () => {
|
|
@@ -823,7 +1255,7 @@ describe("sdkGsapKeyframePersist", () => {
|
|
|
823
1255
|
session,
|
|
824
1256
|
deps,
|
|
825
1257
|
);
|
|
826
|
-
expect(result).toBe(
|
|
1258
|
+
expect(result.status).toBe("committed");
|
|
827
1259
|
expect(session!.dispatch).toHaveBeenCalledWith({
|
|
828
1260
|
type: "addGsapKeyframe",
|
|
829
1261
|
animationId: "tw-1",
|
|
@@ -848,7 +1280,7 @@ describe("sdkGsapKeyframePersist", () => {
|
|
|
848
1280
|
session,
|
|
849
1281
|
deps,
|
|
850
1282
|
);
|
|
851
|
-
expect(result).toBe(
|
|
1283
|
+
expect(result.status).toBe("failed");
|
|
852
1284
|
expect(deps.writeProjectFile).not.toHaveBeenCalled();
|
|
853
1285
|
});
|
|
854
1286
|
});
|
|
@@ -860,6 +1292,7 @@ describe("sdkCutoverPersist — GSAP script preservation (integration)", () => {
|
|
|
860
1292
|
writeProjectFile: vi.fn().mockResolvedValue(undefined),
|
|
861
1293
|
reloadPreview: vi.fn(),
|
|
862
1294
|
domEditSaveTimestampRef: makeRef(0),
|
|
1295
|
+
...candidateTestDeps(),
|
|
863
1296
|
});
|
|
864
1297
|
|
|
865
1298
|
it("preserves GSAP <script> block and data-position-mode through setStyle dispatch", async () => {
|
|
@@ -880,7 +1313,7 @@ gsap.timeline().to('[data-hf-id="hf-layer"]', { duration: 1, x: 100 });
|
|
|
880
1313
|
comp,
|
|
881
1314
|
deps,
|
|
882
1315
|
);
|
|
883
|
-
expect(result).toBe(
|
|
1316
|
+
expect(result.status).toBe("committed");
|
|
884
1317
|
const written = (deps.writeProjectFile as ReturnType<typeof vi.fn>).mock
|
|
885
1318
|
.calls[0]?.[1] as string;
|
|
886
1319
|
expect(written).toContain("data-hf-gsap");
|