@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
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
* Telemetry-only — never writes to disk, never affects the user-visible edit.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { openComposition } from "@hyperframes/sdk";
|
|
18
19
|
import type { Composition, JsonPatchOp } from "@hyperframes/sdk";
|
|
19
20
|
import type { PatchOperation } from "./sourcePatcher";
|
|
20
21
|
import { STUDIO_SDK_RESOLVER_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
21
22
|
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
|
|
22
|
-
import { trackStudioEvent
|
|
23
|
+
import { trackStudioEvent } from "./studioTelemetry";
|
|
23
24
|
|
|
24
25
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
25
26
|
|
|
@@ -234,95 +235,14 @@ export function sdkResolverShadowCheck(
|
|
|
234
235
|
}
|
|
235
236
|
}
|
|
236
237
|
|
|
237
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const attemptCounts: Record<string, number> = {};
|
|
247
|
-
|
|
248
|
-
/**
|
|
249
|
-
* Record that the resolver-shadow tripwire ran for `opLabel`, regardless of
|
|
250
|
-
* outcome (parity or divergence). No flag check of its own — only ever called
|
|
251
|
-
* from inside the three emit functions below, after their own
|
|
252
|
-
* STUDIO_SDK_RESOLVER_SHADOW_ENABLED guard, so it's already flag-gated.
|
|
253
|
-
*/
|
|
254
|
-
export function recordAttempt(opLabel: string): void {
|
|
255
|
-
attemptCounts[opLabel] = (attemptCounts[opLabel] ?? 0) + 1;
|
|
256
|
-
ensureAttemptFlushScheduled();
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Return the accumulated attempt counts since the last flush (or `null` if
|
|
261
|
-
* nothing has been recorded — no point emitting an empty rollup), and reset
|
|
262
|
-
* the counter to empty.
|
|
263
|
-
*/
|
|
264
|
-
export function flushAttemptCounts(): Record<string, number> | null {
|
|
265
|
-
const keys = Object.keys(attemptCounts);
|
|
266
|
-
if (keys.length === 0) return null;
|
|
267
|
-
const snapshot: Record<string, number> = {};
|
|
268
|
-
for (const key of keys) {
|
|
269
|
-
snapshot[key] = attemptCounts[key];
|
|
270
|
-
delete attemptCounts[key];
|
|
271
|
-
}
|
|
272
|
-
return snapshot;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const ATTEMPT_FLUSH_INTERVAL_MS = 5 * 60_000;
|
|
276
|
-
let attemptFlushTimer: ReturnType<typeof setInterval> | null = null;
|
|
277
|
-
let attemptVisibilityHandler: (() => void) | null = null;
|
|
278
|
-
|
|
279
|
-
function flushAndEmitAttempts(): void {
|
|
280
|
-
const counts = flushAttemptCounts();
|
|
281
|
-
if (counts === null) return;
|
|
282
|
-
trackStudioEvent("sdk_resolver_shadow_attempt", { counts: JSON.stringify(counts) });
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// Lazily starts the rollup timer + visibilitychange listener on the FIRST
|
|
286
|
-
// attempt in a session — mirrors studioTelemetry.ts's own lazy flushTimer
|
|
287
|
-
// start, so a session that never exercises the tripwire never runs a
|
|
288
|
-
// background timer.
|
|
289
|
-
function ensureAttemptFlushScheduled(): void {
|
|
290
|
-
if (!attemptFlushTimer) {
|
|
291
|
-
attemptFlushTimer = setInterval(flushAndEmitAttempts, ATTEMPT_FLUSH_INTERVAL_MS);
|
|
292
|
-
}
|
|
293
|
-
if (!attemptVisibilityHandler && typeof document !== "undefined") {
|
|
294
|
-
attemptVisibilityHandler = () => {
|
|
295
|
-
if (document.visibilityState !== "hidden") return;
|
|
296
|
-
flushAndEmitAttempts();
|
|
297
|
-
// studioTelemetry.ts registers its own visibilitychange listener (on
|
|
298
|
-
// window, at module load) that drains its queue via sendBeacon. Listener
|
|
299
|
-
// execution order between that handler and this one (on document,
|
|
300
|
-
// registered lazily) is not something to rely on — whichever runs
|
|
301
|
-
// first could otherwise beacon-flush before or after this rollup lands
|
|
302
|
-
// in the queue. Forcing a beacon flush here makes delivery of this
|
|
303
|
-
// rollup event correct regardless of that order.
|
|
304
|
-
flushViaBeacon();
|
|
305
|
-
};
|
|
306
|
-
document.addEventListener("visibilitychange", attemptVisibilityHandler);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Test-only: clears the lazy timer/listener singleton state so tests can
|
|
312
|
-
* verify the "starts on first attempt" behavior in isolation, without an
|
|
313
|
-
* earlier test's real-timer interval (or visibilitychange listener) silently
|
|
314
|
-
* surviving into a later test. Does NOT touch attemptCounts — only the
|
|
315
|
-
* scheduling state. Not part of the public module contract; only imported
|
|
316
|
-
* from sdkResolverShadow.test.ts.
|
|
317
|
-
*/
|
|
318
|
-
export function __resetAttemptSchedulingForTests(): void {
|
|
319
|
-
if (attemptFlushTimer) clearInterval(attemptFlushTimer);
|
|
320
|
-
attemptFlushTimer = null;
|
|
321
|
-
if (attemptVisibilityHandler && typeof document !== "undefined") {
|
|
322
|
-
document.removeEventListener("visibilitychange", attemptVisibilityHandler);
|
|
323
|
-
}
|
|
324
|
-
attemptVisibilityHandler = null;
|
|
325
|
-
}
|
|
238
|
+
// Attempt counting (the soak-gate denominator) lives in sdkResolverAttempts.ts;
|
|
239
|
+
// re-exported here so existing consumers/tests keep one import surface.
|
|
240
|
+
export {
|
|
241
|
+
recordAttempt,
|
|
242
|
+
flushAttemptCounts,
|
|
243
|
+
__resetAttemptSchedulingForTests,
|
|
244
|
+
} from "./sdkResolverAttempts";
|
|
245
|
+
import { recordAttempt } from "./sdkResolverAttempts";
|
|
326
246
|
|
|
327
247
|
// ─── Telemetry ────────────────────────────────────────────────────────────────
|
|
328
248
|
|
|
@@ -380,15 +300,41 @@ function reportEmptySession(session: Composition, opLabel: string): boolean {
|
|
|
380
300
|
return true;
|
|
381
301
|
}
|
|
382
302
|
|
|
303
|
+
/** Shape shared by every resolver-shadow entry point's optional path pair. */
|
|
304
|
+
export interface ResolverShadowPaths {
|
|
305
|
+
targetPath?: string;
|
|
306
|
+
compositionPath?: string | null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Cross-file edit: the session models ONLY the active composition, so a
|
|
311
|
+
* target living in another file is structurally unresolvable — the cutover
|
|
312
|
+
* gates decline it (wrongCompositionFile) and it can never cut over. Every
|
|
313
|
+
* tripwire entry point skips entirely (no event, no attempt): the op belongs
|
|
314
|
+
* in neither the divergence count nor the attempt denominator of the soak
|
|
315
|
+
* rate. PostHog 0.7.41: one cross-file editing session emitted 479 false
|
|
316
|
+
* element_not_found events before this rule existed. Callers that pass no
|
|
317
|
+
* paths (isolated consumers/tests) run as before.
|
|
318
|
+
*/
|
|
319
|
+
function isCrossFileEdit(paths?: ResolverShadowPaths): boolean {
|
|
320
|
+
return (
|
|
321
|
+
paths?.targetPath !== undefined &&
|
|
322
|
+
paths.compositionPath != null &&
|
|
323
|
+
paths.targetPath !== paths.compositionPath
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
383
327
|
export function runResolverShadow(
|
|
384
328
|
session: Composition,
|
|
385
329
|
hfId: string | null | undefined,
|
|
386
330
|
ops: PatchOperation[],
|
|
387
331
|
sourceContent?: string,
|
|
332
|
+
paths?: ResolverShadowPaths,
|
|
388
333
|
): void {
|
|
389
334
|
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
|
390
335
|
if (!hfId) return;
|
|
391
336
|
try {
|
|
337
|
+
if (isCrossFileEdit(paths)) return;
|
|
392
338
|
if (reportEmptySession(session, "dom-edit")) return;
|
|
393
339
|
recordAttempt("dom-edit");
|
|
394
340
|
const mismatches = sdkResolverShadowCheck(session, hfId, ops, sourceContent);
|
|
@@ -434,15 +380,41 @@ export function runResolverShadow(
|
|
|
434
380
|
*
|
|
435
381
|
* No-op when the shadow flag is off; never throws; never mutates the session.
|
|
436
382
|
*/
|
|
383
|
+
/**
|
|
384
|
+
* Source-truth check for a missed hf-id: read the target file and decide
|
|
385
|
+
* whether the miss is a runtime-generated node (id absent from source —
|
|
386
|
+
* suppress, the SDK cannot model it by design) or a reportable divergence
|
|
387
|
+
* (id present → strict attribute count for the event; read failure → fail
|
|
388
|
+
* open, tagged). Mirrors checkAnimationIdOnDisk's error discipline.
|
|
389
|
+
*/
|
|
390
|
+
async function checkHfIdInSource(
|
|
391
|
+
hfId: string,
|
|
392
|
+
readSource?: () => Promise<string | undefined>,
|
|
393
|
+
): Promise<{ suppress: boolean; strictCount?: number; sourceReadFailed: boolean }> {
|
|
394
|
+
if (!readSource) return { suppress: false, sourceReadFailed: false };
|
|
395
|
+
let source: string | undefined;
|
|
396
|
+
try {
|
|
397
|
+
source = await readSource();
|
|
398
|
+
} catch {
|
|
399
|
+
return { suppress: false, sourceReadFailed: true }; // fail-open
|
|
400
|
+
}
|
|
401
|
+
if (source === undefined) return { suppress: false, sourceReadFailed: false };
|
|
402
|
+
// Loose substring match — biased toward keeping signal (see sourceLooseMatchOnly).
|
|
403
|
+
if (!source.includes(hfId)) return { suppress: true, sourceReadFailed: false };
|
|
404
|
+
return { suppress: false, strictCount: countHfIdInSource(source, hfId), sourceReadFailed: false };
|
|
405
|
+
}
|
|
406
|
+
|
|
437
407
|
export async function recordResolverParity(
|
|
438
408
|
session: Composition | null | undefined,
|
|
439
409
|
hfId: string | null | undefined,
|
|
440
410
|
opLabel: string,
|
|
441
411
|
readSource?: () => Promise<string | undefined>,
|
|
412
|
+
paths?: ResolverShadowPaths,
|
|
442
413
|
): Promise<void> {
|
|
443
414
|
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
|
444
415
|
if (!session || !hfId) return;
|
|
445
416
|
try {
|
|
417
|
+
if (isCrossFileEdit(paths)) return;
|
|
446
418
|
if (reportEmptySession(session, opLabel)) return;
|
|
447
419
|
recordAttempt(opLabel);
|
|
448
420
|
if (resolveSnapshot(session, hfId)) return; // resolves — parity, nothing to record
|
|
@@ -453,19 +425,10 @@ export async function recordResolverParity(
|
|
|
453
425
|
// state this field exists to diagnose.
|
|
454
426
|
const sessionElementCount = session.getElements().length;
|
|
455
427
|
// Cheap check passed above, so the source read only runs on a real divergence.
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
if (
|
|
459
|
-
|
|
460
|
-
source = await readSource();
|
|
461
|
-
} catch {
|
|
462
|
-
source = undefined; // fail-open: a read error must not drop a real divergence
|
|
463
|
-
sourceReadFailed = true;
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
// Runtime-generated node the static parse can't model — suppress (mirrors the dom-edit path).
|
|
467
|
-
if (source !== undefined && !source.includes(hfId)) return;
|
|
468
|
-
const strictCount = source !== undefined ? countHfIdInSource(source, hfId) : undefined;
|
|
428
|
+
// Runtime-generated node the static parse can't model → suppressed inside.
|
|
429
|
+
const verdict = await checkHfIdInSource(hfId, readSource);
|
|
430
|
+
if (verdict.suppress) return;
|
|
431
|
+
const { strictCount, sourceReadFailed } = verdict;
|
|
469
432
|
trackStudioEvent("sdk_resolver_shadow", {
|
|
470
433
|
hfId,
|
|
471
434
|
opLabel,
|
|
@@ -505,24 +468,94 @@ export async function recordResolverParity(
|
|
|
505
468
|
*
|
|
506
469
|
* No-op when the shadow flag is off; never throws; never mutates the session.
|
|
507
470
|
*/
|
|
508
|
-
|
|
471
|
+
/**
|
|
472
|
+
* Disk-truth disambiguation for a missed animationId: the GSAP panel computes
|
|
473
|
+
* animationIds from the CURRENT on-disk script (the server read path re-reads
|
|
474
|
+
* per render), while the session's parsed id space reflects the last session
|
|
475
|
+
* reload. An edit that shifts tween positions shifts every
|
|
476
|
+
* `selector-method-position` id, so a panel op landing before the reload
|
|
477
|
+
* targets an id the session has never seen — a sync gap, not a resolver bug
|
|
478
|
+
* (the 0.7.48 keyframe-op class). `staleSession` = a fresh parse of the file
|
|
479
|
+
* resolves the id (suppress); `diskChecked` = the fresh parse ran and ALSO
|
|
480
|
+
* missed (a trustworthy real divergence); read errors fail open.
|
|
481
|
+
*/
|
|
482
|
+
async function checkAnimationIdOnDisk(
|
|
483
|
+
animationId: string,
|
|
484
|
+
sourcePromise: Promise<string | undefined>,
|
|
485
|
+
): Promise<{ staleSession: boolean; diskChecked: boolean; sourceReadFailed: boolean }> {
|
|
486
|
+
let source: string | undefined;
|
|
487
|
+
try {
|
|
488
|
+
source = await sourcePromise;
|
|
489
|
+
} catch {
|
|
490
|
+
return { staleSession: false, diskChecked: false, sourceReadFailed: true };
|
|
491
|
+
}
|
|
492
|
+
if (source === undefined) {
|
|
493
|
+
return { staleSession: false, diskChecked: false, sourceReadFailed: false };
|
|
494
|
+
}
|
|
495
|
+
// A parse failure (malformed script the user just introduced) means the
|
|
496
|
+
// source can't serve as ground truth — treat it exactly like a read failure
|
|
497
|
+
// (fail open, tagged) instead of letting the outer never-propagate catch
|
|
498
|
+
// swallow the whole divergence event.
|
|
499
|
+
let disk: Composition;
|
|
500
|
+
try {
|
|
501
|
+
disk = await openComposition(source, { history: false });
|
|
502
|
+
} catch {
|
|
503
|
+
return { staleSession: false, diskChecked: false, sourceReadFailed: true };
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
return {
|
|
507
|
+
staleSession: disk.getAllAnimationIds().has(animationId),
|
|
508
|
+
diskChecked: true,
|
|
509
|
+
sourceReadFailed: false,
|
|
510
|
+
};
|
|
511
|
+
} finally {
|
|
512
|
+
disk.dispose();
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
export async function recordAnimationResolverParity(
|
|
509
517
|
session: Composition | null | undefined,
|
|
510
518
|
animationId: string,
|
|
511
519
|
opLabel: string,
|
|
512
|
-
)
|
|
520
|
+
readSource?: () => Promise<string | undefined>,
|
|
521
|
+
paths?: ResolverShadowPaths,
|
|
522
|
+
): Promise<void> {
|
|
513
523
|
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
|
514
524
|
if (!session || !animationId) return;
|
|
515
525
|
try {
|
|
526
|
+
if (isCrossFileEdit(paths)) return;
|
|
516
527
|
recordAttempt(opLabel);
|
|
517
528
|
const elements = session.getElements();
|
|
518
529
|
const resolves =
|
|
519
530
|
elements.some((el) => el.animationIds.includes(animationId)) ||
|
|
520
531
|
session.getAllAnimationIds().has(animationId);
|
|
521
532
|
if (resolves) return; // SDK locates the animation — parity
|
|
533
|
+
// Capture BEFORE any await (fire-and-forget caller mutates right after).
|
|
534
|
+
const sessionElementCount = elements.length;
|
|
535
|
+
let diskChecked = false;
|
|
536
|
+
let sourceReadFailed = false;
|
|
537
|
+
if (readSource) {
|
|
538
|
+
// Start the read SYNCHRONOUSLY, before returning control to the caller:
|
|
539
|
+
// the caller's cutover persist writes the new content to this same file
|
|
540
|
+
// moments later, and a read dispatched after that write would check the
|
|
541
|
+
// POST-edit disk (e.g. a remove op's target legitimately gone → false
|
|
542
|
+
// "genuine divergence"). Dispatching the request here, in the same sync
|
|
543
|
+
// prologue as the miss check, orders it ahead of the caller's write.
|
|
544
|
+
const sourcePromise = (async () => readSource())();
|
|
545
|
+
const verdict = await checkAnimationIdOnDisk(animationId, sourcePromise);
|
|
546
|
+
if (verdict.staleSession) return; // sync gap, not a resolver bug — suppress
|
|
547
|
+
diskChecked = verdict.diskChecked;
|
|
548
|
+
sourceReadFailed = verdict.sourceReadFailed;
|
|
549
|
+
}
|
|
522
550
|
trackStudioEvent("sdk_resolver_shadow", {
|
|
523
551
|
animationId,
|
|
524
552
|
opLabel,
|
|
525
|
-
sessionElementCount
|
|
553
|
+
sessionElementCount,
|
|
554
|
+
// The id missed a fresh parse of the on-disk file too — this is a real
|
|
555
|
+
// resolver divergence, not session staleness. Absent when no reader was
|
|
556
|
+
// wired or the read failed (see sourceReadFailed).
|
|
557
|
+
...(diskChecked ? { diskChecked: true } : {}),
|
|
558
|
+
...(sourceReadFailed ? { sourceReadFailed: true } : {}),
|
|
526
559
|
mismatchCount: 1,
|
|
527
560
|
mismatches: JSON.stringify([
|
|
528
561
|
{ kind: "animation_not_found", animationId } satisfies SdkResolverMismatch,
|
|
@@ -74,11 +74,8 @@ describe("persistSlideshowManifest — op construction", () => {
|
|
|
74
74
|
const deps = makeDeps(writeProjectFile);
|
|
75
75
|
const recordEdit = deps.editHistory.recordEdit as ReturnType<typeof vi.fn>;
|
|
76
76
|
|
|
77
|
-
const mockSession = { serialize: vi.fn().mockReturnValue(originalHtml) };
|
|
78
|
-
|
|
79
77
|
await persistSlideshowManifest({
|
|
80
78
|
manifest: { slides: [{ sceneId: "scene-2" }] },
|
|
81
|
-
sdkSession: mockSession as never,
|
|
82
79
|
originalContent: originalHtml,
|
|
83
80
|
targetPath: "/proj/comp.html",
|
|
84
81
|
deps,
|
|
@@ -96,11 +93,8 @@ describe("persistSlideshowManifest — op construction", () => {
|
|
|
96
93
|
const baseHtml = "<html><head></head><body></body></html>";
|
|
97
94
|
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
98
95
|
const deps = makeDeps(writeProjectFile);
|
|
99
|
-
const mockSession = { serialize: vi.fn().mockReturnValue(baseHtml) };
|
|
100
|
-
|
|
101
96
|
await persistSlideshowManifest({
|
|
102
97
|
manifest: { slides: [{ sceneId: "new-scene" }] },
|
|
103
|
-
sdkSession: mockSession as never,
|
|
104
98
|
originalContent: baseHtml,
|
|
105
99
|
targetPath: "/proj/comp.html",
|
|
106
100
|
deps,
|
|
@@ -122,11 +116,8 @@ describe("persistSlideshowManifest — op construction", () => {
|
|
|
122
116
|
|
|
123
117
|
const writeProjectFile = vi.fn().mockResolvedValue(undefined);
|
|
124
118
|
const deps = makeDeps(writeProjectFile);
|
|
125
|
-
const mockSession = { serialize: vi.fn().mockReturnValue(twoIslandHtml) };
|
|
126
|
-
|
|
127
119
|
await persistSlideshowManifest({
|
|
128
120
|
manifest: { slides: [{ sceneId: "fresh" }] },
|
|
129
|
-
sdkSession: mockSession as never,
|
|
130
121
|
originalContent: twoIslandHtml,
|
|
131
122
|
targetPath: "/proj/comp.html",
|
|
132
123
|
deps,
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* embedded in the composition HTML. Because <script> nodes are not tracked by
|
|
6
6
|
* the SDK element tree (they have no hf-id), we cannot use a `setText` dispatch
|
|
7
7
|
* op. Instead we:
|
|
8
|
-
* 1.
|
|
8
|
+
* 1. Acquire the per-file transaction and read the current on-disk HTML.
|
|
9
9
|
* 2. Replace or insert the island with the new manifest JSON.
|
|
10
10
|
* 3. Write via `persistSdkSerialize` — the same low-level writer used by the
|
|
11
11
|
* other SDK-cutover paths (sdkDeletePersist, sdkTimingPersist, etc.).
|
|
@@ -23,7 +23,6 @@ import {
|
|
|
23
23
|
parseSlideshowManifest,
|
|
24
24
|
slideshowIslandRegex,
|
|
25
25
|
} from "@hyperframes/core/slideshow";
|
|
26
|
-
import type { Composition } from "@hyperframes/sdk";
|
|
27
26
|
import type { CutoverDeps } from "./sdkCutover";
|
|
28
27
|
import { persistSdkSerialize } from "./sdkCutover";
|
|
29
28
|
|
|
@@ -46,8 +45,6 @@ export function buildSlideshowIslandHtml(manifest: SlideshowManifest): string {
|
|
|
46
45
|
|
|
47
46
|
export interface PersistSlideshowArgs {
|
|
48
47
|
manifest: SlideshowManifest;
|
|
49
|
-
/** Live SDK Composition session — used only to read the current serialized HTML. */
|
|
50
|
-
sdkSession: Pick<Composition, "serialize">;
|
|
51
48
|
/** Exact on-disk bytes for the undo-history `before` baseline. */
|
|
52
49
|
originalContent: string;
|
|
53
50
|
targetPath: string;
|
|
@@ -62,7 +59,7 @@ export interface PersistSlideshowArgs {
|
|
|
62
59
|
}
|
|
63
60
|
|
|
64
61
|
export async function persistSlideshowManifest(args: PersistSlideshowArgs): Promise<void> {
|
|
65
|
-
const { manifest,
|
|
62
|
+
const { manifest, originalContent, targetPath, deps, label, coalesceKey } = args;
|
|
66
63
|
|
|
67
64
|
const islandHtml = buildSlideshowIslandHtml(manifest);
|
|
68
65
|
|
|
@@ -77,26 +74,22 @@ export async function persistSlideshowManifest(args: PersistSlideshowArgs): Prom
|
|
|
77
74
|
throw new Error(`refusing to persist invalid slideshow manifest: ${(err as Error).message}`);
|
|
78
75
|
}
|
|
79
76
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
await persistSdkSerialize(after, targetPath, originalContent, deps, {
|
|
99
|
-
label: label ?? "Edit slideshow",
|
|
100
|
-
...(coalesceKey ? { coalesceKey } : {}),
|
|
101
|
-
});
|
|
77
|
+
await persistSdkSerialize(
|
|
78
|
+
(current) => {
|
|
79
|
+
// Strip ALL existing islands (handles the case where two stale islands
|
|
80
|
+
// accumulated) then insert exactly one fresh island.
|
|
81
|
+
const stripped = current.replace(ISLAND_RE, "");
|
|
82
|
+
const bodyClose = stripped.lastIndexOf("</body>");
|
|
83
|
+
return bodyClose !== -1
|
|
84
|
+
? stripped.slice(0, bodyClose) + islandHtml + "\n" + stripped.slice(bodyClose)
|
|
85
|
+
: stripped + "\n" + islandHtml;
|
|
86
|
+
},
|
|
87
|
+
targetPath,
|
|
88
|
+
originalContent,
|
|
89
|
+
deps,
|
|
90
|
+
{
|
|
91
|
+
label: label ?? "Edit slideshow",
|
|
92
|
+
...(coalesceKey ? { coalesceKey } : {}),
|
|
93
|
+
},
|
|
94
|
+
);
|
|
102
95
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { saveProjectFilesWithHistory } from "./studioFileHistory";
|
|
3
|
+
import { serializeStudioFileMutation } from "./studioFileMutationCoordinator";
|
|
3
4
|
|
|
4
5
|
describe("saveProjectFilesWithHistory", () => {
|
|
5
6
|
it("reads before content, writes after content, and records a history entry", async () => {
|
|
@@ -153,4 +154,44 @@ describe("saveProjectFilesWithHistory", () => {
|
|
|
153
154
|
["index.html", "index-before"],
|
|
154
155
|
]);
|
|
155
156
|
});
|
|
157
|
+
|
|
158
|
+
it("reads and writes after an earlier same-file mutation completes", async () => {
|
|
159
|
+
let disk = "before";
|
|
160
|
+
let release!: () => void;
|
|
161
|
+
const blocked = new Promise<void>((resolve) => {
|
|
162
|
+
release = resolve;
|
|
163
|
+
});
|
|
164
|
+
const writeFile = vi.fn(async (_path: string, content: string) => {
|
|
165
|
+
disk = content;
|
|
166
|
+
});
|
|
167
|
+
const priorMutation = serializeStudioFileMutation(writeFile, "index.html", async () => {
|
|
168
|
+
await blocked;
|
|
169
|
+
disk = "sdk-after";
|
|
170
|
+
});
|
|
171
|
+
const readFile = vi.fn(async () => disk);
|
|
172
|
+
const recordEdit = vi.fn();
|
|
173
|
+
|
|
174
|
+
const save = saveProjectFilesWithHistory({
|
|
175
|
+
projectId: "project-1",
|
|
176
|
+
label: "Edit source",
|
|
177
|
+
kind: "source",
|
|
178
|
+
files: { "index.html": "editor-after" },
|
|
179
|
+
readFile,
|
|
180
|
+
writeFile,
|
|
181
|
+
recordEdit,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await Promise.resolve();
|
|
185
|
+
expect(readFile).not.toHaveBeenCalled();
|
|
186
|
+
release();
|
|
187
|
+
await priorMutation;
|
|
188
|
+
await save;
|
|
189
|
+
|
|
190
|
+
expect(disk).toBe("editor-after");
|
|
191
|
+
expect(recordEdit).toHaveBeenCalledWith(
|
|
192
|
+
expect.objectContaining({
|
|
193
|
+
files: { "index.html": { before: "sdk-after", after: "editor-after" } },
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
});
|
|
156
197
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { MutableRefObject } from "react";
|
|
2
2
|
import type { EditHistoryKind } from "./editHistory";
|
|
3
|
+
import { serializeStudioFileMutations } from "./studioFileMutationCoordinator";
|
|
3
4
|
import { createStudioSaveHttpError } from "./studioSaveDiagnostics";
|
|
4
5
|
|
|
5
6
|
export interface RecordEditInput {
|
|
@@ -13,7 +14,7 @@ export interface RecordEditInput {
|
|
|
13
14
|
export interface DomEditCommitBaseParams {
|
|
14
15
|
activeCompPath: string | null;
|
|
15
16
|
showToast: (message: string, tone?: "error" | "info") => void;
|
|
16
|
-
writeProjectFile:
|
|
17
|
+
writeProjectFile: ProjectFileWriter;
|
|
17
18
|
domEditSaveTimestampRef: MutableRefObject<number>;
|
|
18
19
|
editHistory: { recordEdit: (entry: RecordEditInput) => Promise<void> };
|
|
19
20
|
projectIdRef: MutableRefObject<string | null>;
|
|
@@ -21,6 +22,8 @@ export interface DomEditCommitBaseParams {
|
|
|
21
22
|
clearDomSelection: () => void;
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
type ProjectFileWriter = (path: string, content: string, expectedContent?: string) => Promise<void>;
|
|
26
|
+
|
|
24
27
|
interface SaveProjectFilesWithHistoryInput {
|
|
25
28
|
projectId: string;
|
|
26
29
|
label: string;
|
|
@@ -29,7 +32,7 @@ interface SaveProjectFilesWithHistoryInput {
|
|
|
29
32
|
coalesceMs?: number;
|
|
30
33
|
files: Record<string, string>;
|
|
31
34
|
readFile: (path: string) => Promise<string>;
|
|
32
|
-
writeFile:
|
|
35
|
+
writeFile: ProjectFileWriter;
|
|
33
36
|
recordEdit: (entry: RecordEditInput) => Promise<void>;
|
|
34
37
|
}
|
|
35
38
|
|
|
@@ -55,37 +58,39 @@ export async function saveProjectFilesWithHistory({
|
|
|
55
58
|
writeFile,
|
|
56
59
|
recordEdit,
|
|
57
60
|
}: SaveProjectFilesWithHistoryInput): Promise<string[]> {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
return serializeStudioFileMutations(writeFile, Object.keys(files), async () => {
|
|
62
|
+
const snapshots: Record<string, { before: string; after: string }> = {};
|
|
63
|
+
for (const [path, after] of Object.entries(files)) {
|
|
64
|
+
const before = await readFile(path);
|
|
65
|
+
if (before !== after) {
|
|
66
|
+
snapshots[path] = { before, after };
|
|
67
|
+
}
|
|
63
68
|
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const changedPaths = Object.keys(snapshots);
|
|
67
|
-
if (changedPaths.length === 0) return [];
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
for (const path of changedPaths) {
|
|
72
|
-
await writeFile(path, snapshots[path].after);
|
|
73
|
-
writtenPaths.push(path);
|
|
74
|
-
}
|
|
70
|
+
const changedPaths = Object.keys(snapshots);
|
|
71
|
+
if (changedPaths.length === 0) return [];
|
|
75
72
|
|
|
76
|
-
|
|
77
|
-
} catch (error) {
|
|
73
|
+
const writtenPaths: string[] = [];
|
|
78
74
|
try {
|
|
79
|
-
for (const path of
|
|
80
|
-
await writeFile(path, snapshots[path].before);
|
|
75
|
+
for (const path of changedPaths) {
|
|
76
|
+
await writeFile(path, snapshots[path].after, snapshots[path].before);
|
|
77
|
+
writtenPaths.push(path);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await recordEdit({ label, kind, coalesceKey, coalesceMs, files: snapshots });
|
|
81
|
+
} catch (error) {
|
|
82
|
+
try {
|
|
83
|
+
for (const path of writtenPaths.reverse()) {
|
|
84
|
+
await writeFile(path, snapshots[path].before, snapshots[path].after);
|
|
85
|
+
}
|
|
86
|
+
} catch (rollbackError) {
|
|
87
|
+
throw new AggregateError(
|
|
88
|
+
[error, rollbackError],
|
|
89
|
+
"Failed to save project files and rollback did not complete",
|
|
90
|
+
);
|
|
81
91
|
}
|
|
82
|
-
|
|
83
|
-
throw new AggregateError(
|
|
84
|
-
[error, rollbackError],
|
|
85
|
-
"Failed to save project files and rollback did not complete",
|
|
86
|
-
);
|
|
92
|
+
throw error;
|
|
87
93
|
}
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
return changedPaths;
|
|
94
|
+
return changedPaths;
|
|
95
|
+
});
|
|
91
96
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type StudioProjectFileWriter = (path: string, content: string) => Promise<void>;
|
|
2
|
+
|
|
3
|
+
// A writer is bound to one project (see useFileManager), so writer identity plus
|
|
4
|
+
// path is the complete durable-file identity. Sharing this queue across SDK and
|
|
5
|
+
// legacy mutation paths prevents independent read-modify-write transactions from
|
|
6
|
+
// cloning the same stale bytes and overwriting one another.
|
|
7
|
+
const mutationQueues = new WeakMap<StudioProjectFileWriter, Map<string, Promise<unknown>>>();
|
|
8
|
+
|
|
9
|
+
export function serializeStudioFileMutation<T>(
|
|
10
|
+
writer: StudioProjectFileWriter,
|
|
11
|
+
targetPath: string,
|
|
12
|
+
task: () => Promise<T>,
|
|
13
|
+
): Promise<T> {
|
|
14
|
+
let queues = mutationQueues.get(writer);
|
|
15
|
+
if (!queues) {
|
|
16
|
+
queues = new Map();
|
|
17
|
+
mutationQueues.set(writer, queues);
|
|
18
|
+
}
|
|
19
|
+
const prior = queues.get(targetPath) ?? Promise.resolve();
|
|
20
|
+
const next = prior.then(task, task);
|
|
21
|
+
queues.set(targetPath, next);
|
|
22
|
+
void next.then(
|
|
23
|
+
() => {
|
|
24
|
+
if (queues?.get(targetPath) === next) queues.delete(targetPath);
|
|
25
|
+
},
|
|
26
|
+
() => {
|
|
27
|
+
if (queues?.get(targetPath) === next) queues.delete(targetPath);
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
return next;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function serializeStudioFileMutations<T>(
|
|
34
|
+
writer: StudioProjectFileWriter,
|
|
35
|
+
targetPaths: readonly string[],
|
|
36
|
+
task: () => Promise<T>,
|
|
37
|
+
): Promise<T> {
|
|
38
|
+
const paths = [...new Set(targetPaths)].sort();
|
|
39
|
+
const acquire = (index: number): Promise<T> => {
|
|
40
|
+
const path = paths[index];
|
|
41
|
+
if (path === undefined) return task();
|
|
42
|
+
return serializeStudioFileMutation(writer, path, () => acquire(index + 1));
|
|
43
|
+
};
|
|
44
|
+
return acquire(0);
|
|
45
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { studioExpectedFileVersion, studioFileContentVersion } from "./studioFileVersion";
|
|
3
|
+
|
|
4
|
+
describe("studioFileContentVersion", () => {
|
|
5
|
+
it("matches the strong SHA-256 ETag format used by studio-server", async () => {
|
|
6
|
+
await expect(studioFileContentVersion("abc")).resolves.toBe(
|
|
7
|
+
'"sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"',
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("keeps an explicit content precondition authoritative over cached state", async () => {
|
|
12
|
+
const versions = new Map<string, string | null>([
|
|
13
|
+
["stale.html", await studioFileContentVersion("stale")],
|
|
14
|
+
["newer.html", await studioFileContentVersion("newer")],
|
|
15
|
+
["missing.html", null],
|
|
16
|
+
]);
|
|
17
|
+
const expectedVersion = await studioFileContentVersion("expected");
|
|
18
|
+
|
|
19
|
+
expect(await studioExpectedFileVersion(versions, "stale.html", "expected")).toBe(
|
|
20
|
+
expectedVersion,
|
|
21
|
+
);
|
|
22
|
+
expect(await studioExpectedFileVersion(versions, "newer.html", "expected")).toBe(
|
|
23
|
+
expectedVersion,
|
|
24
|
+
);
|
|
25
|
+
expect(await studioExpectedFileVersion(versions, "missing.html", "expected")).toBe(
|
|
26
|
+
expectedVersion,
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("keeps known-missing and untracked files distinct without explicit content", async () => {
|
|
31
|
+
const versions = new Map<string, string | null>([["missing.html", null]]);
|
|
32
|
+
|
|
33
|
+
expect(await studioExpectedFileVersion(versions, "missing.html")).toBeNull();
|
|
34
|
+
expect(await studioExpectedFileVersion(versions, "untracked.html")).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
});
|