@hyperframes/studio 0.7.90 → 0.7.93
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-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
- package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
- package/dist/assets/index-D78KEjgB.css +1 -0
- package/dist/assets/index-Dp1zVb9X.js +428 -0
- package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
- package/dist/index.d.ts +40 -36
- package/dist/index.html +2 -2
- package/dist/index.js +9598 -6798
- package/dist/index.js.map +1 -1
- package/package.json +8 -7
- package/src/App.tsx +14 -14
- package/src/components/DesignPanelPromoteProvider.tsx +1 -1
- package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
- package/src/components/ExternalFileConflictBanner.tsx +255 -0
- package/src/components/StudioRightPanel.tsx +1 -1
- package/src/components/editor/GsapAnimationList.tsx +84 -0
- package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
- package/src/components/editor/GsapAnimationSection.tsx +11 -41
- package/src/components/editor/PropertyPanel.tsx +1 -1
- package/src/components/editor/PropertyPanelFlat.tsx +29 -26
- package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
- package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +37 -2
- package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
- package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
- package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
- package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
- package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
- package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
- package/src/components/editor/propertyPanelTypes.ts +10 -4
- package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
- package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
- package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
- package/src/components/renders/useRenderQueue.ts +16 -3
- package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
- package/src/components/sidebar/AssetCard.tsx +4 -3
- package/src/components/sidebar/AudioRow.tsx +4 -3
- package/src/contexts/DomEditContext.tsx +1 -1
- package/src/contexts/FileManagerContext.tsx +12 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
- package/src/hooks/externalFileReloadBus.test.ts +34 -0
- package/src/hooks/externalFileReloadBus.ts +18 -0
- package/src/hooks/gsapDragCommit.test.ts +39 -1
- package/src/hooks/gsapDragCommit.ts +12 -40
- package/src/hooks/gsapEditOutcome.ts +72 -0
- package/src/hooks/gsapResizeIntercept.test.ts +90 -2
- package/src/hooks/gsapResizeIntercept.ts +44 -16
- package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
- package/src/hooks/gsapRuntimeBridge.ts +93 -28
- package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
- package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
- package/src/hooks/useDomEditSession.test.tsx +1 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
- package/src/hooks/useDomGeometryCommits.ts +20 -0
- package/src/hooks/useEditorSave.test.tsx +131 -0
- package/src/hooks/useEditorSave.ts +113 -39
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
- package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
- package/src/hooks/useFileManager.ts +32 -3
- package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
- package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
- package/src/hooks/useGsapAwareEditing.ts +88 -27
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
- package/src/hooks/useGsapKeyframeOps.ts +20 -9
- package/src/hooks/usePreviewPersistence.test.ts +23 -0
- package/src/hooks/usePreviewPersistence.ts +22 -58
- package/src/hooks/useSdkSession.ts +8 -52
- package/src/hooks/useStudioExternalFileChanges.ts +81 -0
- package/src/hooks/useStudioSdkSessions.ts +2 -7
- package/src/hooks/useStudioSessionStart.ts +15 -0
- package/src/hooks/useStudioTestHooks.test.tsx +11 -7
- package/src/player/components/BeatStrip.test.tsx +374 -0
- package/src/player/components/BeatStrip.tsx +295 -37
- package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
- package/src/player/components/LayerDisclosureRow.tsx +2 -0
- package/src/player/components/Timeline.test.ts +26 -8
- package/src/player/components/Timeline.tsx +87 -88
- package/src/player/components/Timeline.virtualization.test.tsx +54 -11
- package/src/player/components/TimelineCanvas.tsx +14 -71
- package/src/player/components/TimelineClip.test.tsx +17 -2
- package/src/player/components/TimelineClip.tsx +20 -5
- package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
- package/src/player/components/TimelineClipDiamonds.tsx +118 -223
- package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
- package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
- package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
- package/src/player/components/TimelineGestureOverlay.tsx +96 -0
- package/src/player/components/TimelineLanes.test.tsx +64 -7
- package/src/player/components/TimelineLanes.tsx +84 -66
- package/src/player/components/TimelineOverlays.test.ts +161 -0
- package/src/player/components/TimelineOverlays.tsx +104 -11
- package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
- package/src/player/components/TimelinePropertyLanes.tsx +9 -2
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.tsx +16 -0
- package/src/player/components/TimelineTrackRow.tsx +58 -7
- package/src/player/components/timelineCallbacks.ts +1 -1
- package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
- package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
- package/src/player/components/timelineClipDragPreview.test.ts +33 -0
- package/src/player/components/timelineClipDragPreview.ts +18 -12
- package/src/player/components/timelineClipDragTypes.ts +12 -0
- package/src/player/components/timelineDiamondTypes.ts +18 -21
- package/src/player/components/timelineDragDrop.test.tsx +206 -0
- package/src/player/components/timelineDragDrop.ts +152 -30
- package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
- package/src/player/components/timelineKeyboardNavigation.ts +337 -0
- package/src/player/components/timelineLaneProps.ts +3 -1
- package/src/player/components/timelineMarquee.test.ts +73 -25
- package/src/player/components/timelineMarquee.ts +44 -16
- package/src/player/components/timelineNavigationIdentity.ts +38 -0
- package/src/player/components/timelineTestViewport.ts +13 -0
- package/src/player/components/timelineViewModel.ts +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
- package/src/player/components/useTimelineClipDrag.ts +112 -70
- package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
- package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
- package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
- package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
- package/src/player/components/useTimelineKeyboardActor.ts +147 -0
- package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
- package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
- package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
- package/src/player/components/useTimelineLogicalFocus.ts +80 -0
- package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
- package/src/player/components/useTimelineLogicalRows.ts +47 -0
- package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
- package/src/player/components/useTimelineRangeSelection.ts +202 -69
- package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
- package/src/player/components/useTimelineRowVirtualization.ts +26 -29
- package/src/player/components/useTrackGapMenu.test.tsx +64 -0
- package/src/player/components/useTrackGapMenu.ts +32 -9
- package/src/player/store/keyframeSlice.ts +59 -18
- package/src/player/store/liveTime.ts +11 -0
- package/src/player/store/playerStore.test.ts +106 -23
- package/src/player/store/playerStore.ts +43 -40
- package/src/player/store/timelineFocusState.ts +15 -0
- package/src/telemetry/canary.test.ts +325 -0
- package/src/telemetry/canary.ts +268 -0
- package/src/telemetry/client.test.ts +8 -3
- package/src/telemetry/client.ts +13 -43
- package/src/telemetry/config.ts +16 -2
- package/src/telemetry/distinctId.test.ts +35 -0
- package/src/telemetry/distinctId.ts +11 -2
- package/src/telemetry/policy.test.ts +81 -0
- package/src/telemetry/policy.ts +104 -0
- package/src/utils/domEditSaveQueue.test.ts +37 -1
- package/src/utils/domEditSaveQueue.ts +22 -4
- package/src/utils/externalConflictStorage.test.ts +95 -0
- package/src/utils/externalConflictStorage.ts +172 -0
- package/src/utils/studioFileVersion.test.ts +28 -1
- package/src/utils/studioFileVersion.ts +30 -0
- package/src/utils/studioPendingEdits.test.ts +108 -1
- package/src/utils/studioPendingEdits.ts +39 -2
- package/src/utils/studioSaveDiagnostics.ts +5 -0
- package/src/utils/studioTelemetry.test.ts +76 -0
- package/src/utils/studioTelemetry.ts +14 -6
- package/dist/assets/index-BXkzp_OU.css +0 -1
- package/dist/assets/index-Cql15Yur.js +0 -428
- package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
- package/src/player/components/useTimelineRevealClip.ts +0 -180
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
} from "../../utils/studioUiPreferences";
|
|
11
11
|
import { clampTimelineZoomPercent, computePinnedZoomPercent } from "../components/timelineZoom";
|
|
12
12
|
import { createKeyframeSlice, type KeyframeCacheEntry, type KeyframeSlice } from "./keyframeSlice";
|
|
13
|
+
import { createTimelineFocusRequest, type TimelineFocusRequest } from "./timelineFocusState";
|
|
13
14
|
|
|
14
15
|
export type { KeyframeCacheEntry } from "./keyframeSlice";
|
|
16
|
+
export { liveTime } from "./liveTime";
|
|
15
17
|
|
|
16
18
|
export interface TimelineElement {
|
|
17
19
|
id: string;
|
|
@@ -220,15 +222,10 @@ interface PlayerState extends KeyframeSlice {
|
|
|
220
222
|
requestSeek: (time: number) => void;
|
|
221
223
|
clearSeekRequest: () => void;
|
|
222
224
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
* clip observable so a second click re-reveals after the user scrolls away.
|
|
228
|
-
*/
|
|
229
|
-
clipRevealRequest: { elementId: string; nonce: number } | null;
|
|
230
|
-
requestClipReveal: (elementId: string) => void;
|
|
231
|
-
clearClipRevealRequest: () => void;
|
|
225
|
+
timelineFocus: TimelineFocusRequest | null;
|
|
226
|
+
timelineFocusNonce: number;
|
|
227
|
+
requestTimelineFocus: (id: string) => void;
|
|
228
|
+
clearTimelineFocus: (nonce: number) => void;
|
|
232
229
|
|
|
233
230
|
lintFindingsByElement: Map<string, { count: number; messages: string[] }>;
|
|
234
231
|
setLintFindingsByElement: (map: Map<string, { count: number; messages: string[] }>) => void;
|
|
@@ -279,19 +276,6 @@ interface BeatHistoryEntry {
|
|
|
279
276
|
label: string;
|
|
280
277
|
}
|
|
281
278
|
|
|
282
|
-
// Lightweight pub-sub for current time during playback.
|
|
283
|
-
// Bypasses React state so the RAF loop can update the playhead/time display
|
|
284
|
-
// without triggering re-renders on every frame.
|
|
285
|
-
type TimeListener = (time: number) => void;
|
|
286
|
-
const _timeListeners = new Set<TimeListener>();
|
|
287
|
-
export const liveTime = {
|
|
288
|
-
notify: (t: number) => _timeListeners.forEach((cb) => cb(t)),
|
|
289
|
-
subscribe: (cb: TimeListener) => {
|
|
290
|
-
_timeListeners.add(cb);
|
|
291
|
-
return () => _timeListeners.delete(cb);
|
|
292
|
-
},
|
|
293
|
-
};
|
|
294
|
-
|
|
295
279
|
export function createTimelineResetState() {
|
|
296
280
|
return {
|
|
297
281
|
isPlaying: false,
|
|
@@ -313,8 +297,8 @@ export function createTimelineResetState() {
|
|
|
313
297
|
focusedEaseSegment: null,
|
|
314
298
|
selectedElementIds: new Set<string>(),
|
|
315
299
|
requestedSeekTime: null,
|
|
316
|
-
clipRevealRequest: null,
|
|
317
300
|
lintFindingsByElement: new Map<string, { count: number; messages: string[] }>(),
|
|
301
|
+
timelineFocus: null,
|
|
318
302
|
keyframeCache: new Map<string, KeyframeCacheEntry>(),
|
|
319
303
|
gsapAnimations: new Map<string, GsapAnimation[]>(),
|
|
320
304
|
beatAnalysis: null,
|
|
@@ -352,7 +336,10 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
352
336
|
activeTool: "select",
|
|
353
337
|
setActiveTool: (tool) => set({ activeTool: tool }),
|
|
354
338
|
|
|
355
|
-
...createKeyframeSlice(set)
|
|
339
|
+
...createKeyframeSlice(set, () => ({
|
|
340
|
+
timelineProjectId: get().timelineProjectId,
|
|
341
|
+
timelineSessionEpoch: get().timelineSessionEpoch,
|
|
342
|
+
})),
|
|
356
343
|
|
|
357
344
|
activeKeyframePct: null,
|
|
358
345
|
setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
|
|
@@ -384,12 +371,23 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
384
371
|
requestSeek: (time) => set({ requestedSeekTime: time }),
|
|
385
372
|
clearSeekRequest: () => set({ requestedSeekTime: null }),
|
|
386
373
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
374
|
+
timelineFocus: null,
|
|
375
|
+
timelineFocusNonce: 0,
|
|
376
|
+
requestTimelineFocus: (id) =>
|
|
377
|
+
set((s) => {
|
|
378
|
+
const nonce = s.timelineFocusNonce + 1;
|
|
379
|
+
return {
|
|
380
|
+
timelineFocusNonce: nonce,
|
|
381
|
+
timelineFocus: createTimelineFocusRequest(
|
|
382
|
+
id,
|
|
383
|
+
s.timelineProjectId,
|
|
384
|
+
s.timelineSessionEpoch,
|
|
385
|
+
nonce,
|
|
386
|
+
),
|
|
387
|
+
};
|
|
388
|
+
}),
|
|
389
|
+
clearTimelineFocus: (nonce) =>
|
|
390
|
+
set((s) => (s.timelineFocus?.nonce === nonce ? { timelineFocus: null } : s)),
|
|
393
391
|
|
|
394
392
|
lintFindingsByElement: new Map(),
|
|
395
393
|
setLintFindingsByElement: (map) => set({ lintFindingsByElement: map }),
|
|
@@ -541,6 +539,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
541
539
|
selectedElementIds,
|
|
542
540
|
activeKeyframePct: null,
|
|
543
541
|
motionPathArmed: false,
|
|
542
|
+
focusedEaseSegment: null,
|
|
544
543
|
}
|
|
545
544
|
: { selectedElementId: id, selectedElementIds };
|
|
546
545
|
}),
|
|
@@ -550,9 +549,16 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
550
549
|
setSelectionAnchor: (id) =>
|
|
551
550
|
set((s) => {
|
|
552
551
|
if (id != null && s.selectedElementIds.size > 1 && s.selectedElementIds.has(id)) {
|
|
553
|
-
return {
|
|
552
|
+
return {
|
|
553
|
+
selectedElementId: id,
|
|
554
|
+
focusedEaseSegment: id === s.selectedElementId ? s.focusedEaseSegment : null,
|
|
555
|
+
};
|
|
554
556
|
}
|
|
555
|
-
return {
|
|
557
|
+
return {
|
|
558
|
+
selectedElementId: id,
|
|
559
|
+
selectedElementIds: id ? new Set([id]) : new Set<string>(),
|
|
560
|
+
focusedEaseSegment: id === s.selectedElementId ? s.focusedEaseSegment : null,
|
|
561
|
+
};
|
|
556
562
|
}),
|
|
557
563
|
updateElement: (elementId, updates) =>
|
|
558
564
|
set((state) => ({
|
|
@@ -560,9 +566,9 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
560
566
|
(el.key ?? el.id) === elementId ? { ...el, ...updates } : el,
|
|
561
567
|
),
|
|
562
568
|
})),
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
569
|
+
// UI preferences intentionally survive reset. So do timelineSessionEpoch and
|
|
570
|
+
// focusedEaseRequestNonce: the epoch advances only when project identity
|
|
571
|
+
// changes, while a monotonic nonce prevents collisions with stale consumers.
|
|
566
572
|
beginTimelineSession: (projectId) =>
|
|
567
573
|
set((state) => {
|
|
568
574
|
if (state.timelineProjectId === projectId) return state;
|
|
@@ -575,18 +581,15 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
575
581
|
reset: () => set(createTimelineResetState()),
|
|
576
582
|
}));
|
|
577
583
|
|
|
578
|
-
// Bug-bash aid: expose the store so a reproduction can dump live state from the
|
|
579
|
-
// console, e.g. `__playerStore.getState().selectedElementId`. Harmless read
|
|
580
|
-
// handle; no behavioural effect.
|
|
581
|
-
// Only in dev. `import.meta.env` may be undefined in non-Vite bundlers (Next.js
|
|
582
|
-
// Turbopack), so guard the access like the telemetry client does.
|
|
583
584
|
function isDevBuild(): boolean {
|
|
584
585
|
try {
|
|
585
586
|
return import.meta.env.DEV === true;
|
|
586
587
|
} catch {
|
|
588
|
+
// Turbopack and other non-Vite bundlers may not provide import.meta.env.
|
|
587
589
|
return false;
|
|
588
590
|
}
|
|
589
591
|
}
|
|
590
592
|
if (isDevBuild() && typeof window !== "undefined") {
|
|
593
|
+
// Console handle for dumping live Studio state during bug-bash reproduction.
|
|
591
594
|
(window as unknown as { __playerStore?: typeof usePlayerStore }).__playerStore = usePlayerStore;
|
|
592
595
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface TimelineFocusRequest {
|
|
2
|
+
id: string;
|
|
3
|
+
projectId: string | null;
|
|
4
|
+
sessionEpoch: number;
|
|
5
|
+
nonce: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createTimelineFocusRequest(
|
|
9
|
+
id: string,
|
|
10
|
+
projectId: string | null,
|
|
11
|
+
sessionEpoch: number,
|
|
12
|
+
nonce: number,
|
|
13
|
+
): TimelineFocusRequest {
|
|
14
|
+
return { id, projectId, sessionEpoch, nonce };
|
|
15
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
4
|
+
import { evaluateCanary } from "@hyperframes/core/canary";
|
|
5
|
+
|
|
6
|
+
// Pin the registry: real entries move as rollouts ramp, and these tests are
|
|
7
|
+
// about the BINDING (does the browser supply the right three inputs?), not
|
|
8
|
+
// about whichever canaries happen to be live today.
|
|
9
|
+
// The policy reads import.meta.env.DEV, which vitest sets true — without
|
|
10
|
+
// this every case would resolve to telemetry_opt_out. Controlled explicitly
|
|
11
|
+
// so each test states the privacy posture it is exercising.
|
|
12
|
+
const policyState = { allowed: true };
|
|
13
|
+
vi.mock("./policy", () => ({
|
|
14
|
+
browserTelemetryAllowed: () => policyState.allowed,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock("@hyperframes/core/canary-registry", async () => {
|
|
18
|
+
const actual = await vi.importActual<typeof import("@hyperframes/core/canary-registry")>(
|
|
19
|
+
"@hyperframes/core/canary-registry",
|
|
20
|
+
);
|
|
21
|
+
const defs = [
|
|
22
|
+
{
|
|
23
|
+
name: "on-everywhere",
|
|
24
|
+
percentage: 100,
|
|
25
|
+
description: "",
|
|
26
|
+
owner: "t",
|
|
27
|
+
sunsetAfter: "2099-01-01",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: "off-everywhere",
|
|
31
|
+
percentage: 0,
|
|
32
|
+
description: "",
|
|
33
|
+
owner: "t",
|
|
34
|
+
sunsetAfter: "2099-01-01",
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
return { ...actual, CANARIES: defs, findCanary: (n: string) => defs.find((d) => d.name === n) };
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const {
|
|
41
|
+
isCanaryEnabled,
|
|
42
|
+
resolveCanary,
|
|
43
|
+
canaryEventProperties,
|
|
44
|
+
canaryParamName,
|
|
45
|
+
__resetStudioCanaryCacheForTests,
|
|
46
|
+
} = await import("./canary");
|
|
47
|
+
const { resolveStudioDistinctId, __resetStudioDistinctIdForTests } = await import("./distinctId");
|
|
48
|
+
|
|
49
|
+
function setSearch(search: string): void {
|
|
50
|
+
window.history.replaceState({}, "", `/${search}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
policyState.allowed = true;
|
|
55
|
+
localStorage.clear();
|
|
56
|
+
sessionStorage.clear();
|
|
57
|
+
setSearch("");
|
|
58
|
+
delete window.__HF_CLI_DISTINCT_ID;
|
|
59
|
+
delete window.__HF_CLI_BUCKET_SEED;
|
|
60
|
+
Object.defineProperty(navigator, "webdriver", { value: false, configurable: true });
|
|
61
|
+
__resetStudioCanaryCacheForTests();
|
|
62
|
+
__resetStudioDistinctIdForTests();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
setSearch("");
|
|
67
|
+
__resetStudioCanaryCacheForTests();
|
|
68
|
+
__resetStudioDistinctIdForTests();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("studio canary binding", () => {
|
|
72
|
+
it("reads the percentage from the shared registry", () => {
|
|
73
|
+
expect(isCanaryEnabled("on-everywhere")).toBe(true);
|
|
74
|
+
expect(isCanaryEnabled("off-everywhere")).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("an unregistered name is off, not a throw — a typo must not break the editor", () => {
|
|
78
|
+
expect(isCanaryEnabled("nope")).toBe(false);
|
|
79
|
+
expect(resolveCanary("nope").reason).toBe("out_of_cohort");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("derives the query param from the canary name", () => {
|
|
83
|
+
expect(canaryParamName("de-parallel-router")).toBe("hf_canary_de_parallel_router");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("URL override", () => {
|
|
88
|
+
it("turns a canary on and off from the query string", () => {
|
|
89
|
+
setSearch("?hf_canary_off_everywhere=on");
|
|
90
|
+
expect(resolveCanary("off-everywhere")).toMatchObject({ enabled: true, reason: "forced_on" });
|
|
91
|
+
|
|
92
|
+
__resetStudioCanaryCacheForTests();
|
|
93
|
+
setSearch("?hf_canary_on_everywhere=off");
|
|
94
|
+
expect(resolveCanary("on-everywhere")).toMatchObject({ enabled: false, reason: "forced_off" });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("survives losing the query string, so in-app navigation keeps the override", () => {
|
|
98
|
+
setSearch("?hf_canary_off_everywhere=on");
|
|
99
|
+
expect(isCanaryEnabled("off-everywhere")).toBe(true);
|
|
100
|
+
|
|
101
|
+
// Navigate away from the param — a real SPA drops it constantly.
|
|
102
|
+
__resetStudioCanaryCacheForTests();
|
|
103
|
+
setSearch("");
|
|
104
|
+
expect(isCanaryEnabled("off-everywhere")).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("is session-scoped, not persisted to localStorage", () => {
|
|
108
|
+
// A URL-borne override must not silently pin a browser into a cohort
|
|
109
|
+
// forever; closing the tab is the reset.
|
|
110
|
+
setSearch("?hf_canary_off_everywhere=on");
|
|
111
|
+
expect(isCanaryEnabled("off-everywhere")).toBe(true);
|
|
112
|
+
expect(JSON.stringify(localStorage).includes("canary")).toBe(false);
|
|
113
|
+
expect(sessionStorage.length).toBeGreaterThan(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("=reset clears a stored override", () => {
|
|
117
|
+
setSearch("?hf_canary_off_everywhere=on");
|
|
118
|
+
expect(isCanaryEnabled("off-everywhere")).toBe(true);
|
|
119
|
+
|
|
120
|
+
__resetStudioCanaryCacheForTests();
|
|
121
|
+
setSearch("?hf_canary_off_everywhere=reset");
|
|
122
|
+
expect(isCanaryEnabled("off-everywhere")).toBe(false);
|
|
123
|
+
|
|
124
|
+
__resetStudioCanaryCacheForTests();
|
|
125
|
+
setSearch("");
|
|
126
|
+
expect(isCanaryEnabled("off-everywhere")).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("automated browsers", () => {
|
|
131
|
+
it("are excluded from percentage enrolment", () => {
|
|
132
|
+
Object.defineProperty(navigator, "webdriver", { value: true, configurable: true });
|
|
133
|
+
expect(resolveCanary("on-everywhere")).toMatchObject({ enabled: false, reason: "excluded" });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("still honour an explicit override, so a canary can be tested under automation", () => {
|
|
137
|
+
Object.defineProperty(navigator, "webdriver", { value: true, configurable: true });
|
|
138
|
+
setSearch("?hf_canary_on_everywhere=on");
|
|
139
|
+
expect(resolveCanary("on-everywhere")).toMatchObject({ enabled: true, reason: "forced_on" });
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("cohort identity", () => {
|
|
144
|
+
it("buckets on the CLI's bucket seed when injected — the unit that survives config wipes", () => {
|
|
145
|
+
// The CLI buckets on its bucketSeed (inherited across config wipes via
|
|
146
|
+
// the install-state file), so a CLI-launched Studio must bucket on the
|
|
147
|
+
// SAME seed or the two surfaces would split one machine across cohorts.
|
|
148
|
+
const cliId = "db0c1f4a-b95e-4c35-90c6-1a15bd76f717";
|
|
149
|
+
const cliSeed = "5f1c9d2e-0000-4000-8000-aaaaaaaaaaaa";
|
|
150
|
+
window.__HF_CLI_DISTINCT_ID = cliId;
|
|
151
|
+
window.__HF_CLI_BUCKET_SEED = cliSeed;
|
|
152
|
+
__resetStudioDistinctIdForTests();
|
|
153
|
+
__resetStudioCanaryCacheForTests();
|
|
154
|
+
|
|
155
|
+
// Telemetry identity still adopts the DISTINCT id — the seed only buckets.
|
|
156
|
+
expect(resolveStudioDistinctId()).toBe(cliId);
|
|
157
|
+
const viaBinding = resolveCanary("on-everywhere").bucket;
|
|
158
|
+
const bySeed = evaluateCanary({
|
|
159
|
+
feature: "on-everywhere",
|
|
160
|
+
unitId: cliSeed,
|
|
161
|
+
percentage: 100,
|
|
162
|
+
}).bucket;
|
|
163
|
+
expect(viaBinding).toBe(bySeed);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("buckets on the Studio distinct id when no seed is injected (standalone Studio)", () => {
|
|
167
|
+
const cliId = "db0c1f4a-b95e-4c35-90c6-1a15bd76f717";
|
|
168
|
+
window.__HF_CLI_DISTINCT_ID = cliId;
|
|
169
|
+
__resetStudioDistinctIdForTests();
|
|
170
|
+
__resetStudioCanaryCacheForTests();
|
|
171
|
+
|
|
172
|
+
expect(resolveStudioDistinctId()).toBe(cliId);
|
|
173
|
+
const viaBinding = resolveCanary("on-everywhere").bucket;
|
|
174
|
+
const direct = evaluateCanary({
|
|
175
|
+
feature: "on-everywhere",
|
|
176
|
+
unitId: cliId,
|
|
177
|
+
percentage: 100,
|
|
178
|
+
}).bucket;
|
|
179
|
+
expect(viaBinding).toBe(direct);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("memoizes so a decision cannot change mid-session", () => {
|
|
183
|
+
expect(isCanaryEnabled("off-everywhere")).toBe(false);
|
|
184
|
+
// A late override must NOT flip a component that already rendered.
|
|
185
|
+
setSearch("?hf_canary_off_everywhere=on");
|
|
186
|
+
expect(isCanaryEnabled("off-everywhere")).toBe(false);
|
|
187
|
+
__resetStudioCanaryCacheForTests();
|
|
188
|
+
expect(isCanaryEnabled("off-everywhere")).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("telemetry", () => {
|
|
193
|
+
it("emits the same PostHog flag-shaped properties as the CLI", () => {
|
|
194
|
+
expect(canaryEventProperties()).toEqual({
|
|
195
|
+
"$feature/canary-on-everywhere": "true",
|
|
196
|
+
"$feature/canary-off-everywhere": "false",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
__resetStudioCanaryCacheForTests();
|
|
200
|
+
setSearch("?hf_canary_on_everywhere=off");
|
|
201
|
+
expect(canaryEventProperties()["$feature/canary-on-everywhere"]).toBe("false");
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("telemetry opt-out is canary opt-out", () => {
|
|
206
|
+
// The studio opt-out lever, per telemetry/config.ts.
|
|
207
|
+
const OPT_OUT_KEY = "hyperframes-studio:telemetryDisabled";
|
|
208
|
+
|
|
209
|
+
it("does not enrol an opted-out browser profile", () => {
|
|
210
|
+
policyState.allowed = false;
|
|
211
|
+
localStorage.setItem(OPT_OUT_KEY, "1");
|
|
212
|
+
// on-everywhere is at 100% — it would be on for everyone otherwise.
|
|
213
|
+
expect(resolveCanary("on-everywhere")).toEqual({
|
|
214
|
+
enabled: false,
|
|
215
|
+
reason: "telemetry_opt_out",
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("never buckets an opted-out profile — no cohort is assigned at all", () => {
|
|
220
|
+
policyState.allowed = false;
|
|
221
|
+
localStorage.setItem(OPT_OUT_KEY, "1");
|
|
222
|
+
expect(resolveCanary("on-everywhere").bucket).toBeUndefined();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("still honours an explicit URL override", () => {
|
|
226
|
+
policyState.allowed = false;
|
|
227
|
+
localStorage.setItem(OPT_OUT_KEY, "1");
|
|
228
|
+
setSearch("?hf_canary_off_everywhere=on");
|
|
229
|
+
expect(resolveCanary("off-everywhere")).toEqual({ enabled: true, reason: "forced_on" });
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("reports every canary as false when opted out", () => {
|
|
233
|
+
policyState.allowed = false;
|
|
234
|
+
localStorage.setItem(OPT_OUT_KEY, "1");
|
|
235
|
+
expect(canaryEventProperties()).toEqual({
|
|
236
|
+
"$feature/canary-on-everywhere": "false",
|
|
237
|
+
"$feature/canary-off-everywhere": "false",
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("CLI-launched Studio adopts the CLI's decisions", () => {
|
|
243
|
+
const OPT_OUT_KEY = "hyperframes-studio:telemetryDisabled";
|
|
244
|
+
const cohort = (enabled: boolean) => ({ enabled, forced: false });
|
|
245
|
+
const forced = (enabled: boolean) => ({ enabled, forced: true });
|
|
246
|
+
|
|
247
|
+
afterEach(() => {
|
|
248
|
+
delete window.__HF_CLI_CANARY_DECISIONS;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// The divergence this exists for: CLI telemetry off resolves every canary
|
|
252
|
+
// to telemetry_opt_out, but Studio's opt-out is a SEPARATE localStorage
|
|
253
|
+
// flag it cannot see — left to itself it would evaluate and could enrol.
|
|
254
|
+
it("stays off when the CLI opted out, even though Studio's own flag is unset", () => {
|
|
255
|
+
expect(localStorage.getItem(OPT_OUT_KEY)).toBeNull();
|
|
256
|
+
window.__HF_CLI_CANARY_DECISIONS = { "on-everywhere": cohort(false) };
|
|
257
|
+
expect(resolveCanary("on-everywhere").enabled).toBe(false);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// HF_CANARY_* never crosses into the browser, so before this the CLI was
|
|
261
|
+
// forced on and Studio silently guessed from the percentage.
|
|
262
|
+
it("turns on when the CLI forced it on, with no URL param present", () => {
|
|
263
|
+
window.__HF_CLI_CANARY_DECISIONS = { "off-everywhere": forced(true) };
|
|
264
|
+
expect(resolveCanary("off-everywhere").enabled).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("beats a contradicting URL override — one render must not run half-enrolled", () => {
|
|
268
|
+
window.__HF_CLI_CANARY_DECISIONS = { "on-everywhere": forced(false) };
|
|
269
|
+
setSearch("?hf_canary_on_everywhere=on");
|
|
270
|
+
expect(resolveCanary("on-everywhere").enabled).toBe(false);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("beats the seed-derived bucket", () => {
|
|
274
|
+
window.__HF_CLI_BUCKET_SEED = "5f1c9d2e-0000-4000-8000-aaaaaaaaaaaa";
|
|
275
|
+
window.__HF_CLI_CANARY_DECISIONS = { "on-everywhere": cohort(false) };
|
|
276
|
+
expect(resolveCanary("on-everywhere").enabled).toBe(false);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("falls back to local evaluation for a canary the CLI did not publish", () => {
|
|
280
|
+
window.__HF_CLI_CANARY_DECISIONS = { "off-everywhere": cohort(true) };
|
|
281
|
+
expect(resolveCanary("on-everywhere").enabled).toBe(true);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("ignores a malformed entry rather than trusting it", () => {
|
|
285
|
+
window.__HF_CLI_CANARY_DECISIONS = {
|
|
286
|
+
"on-everywhere": { enabled: "false" },
|
|
287
|
+
} as unknown as Record<string, { enabled?: boolean; forced?: boolean }>;
|
|
288
|
+
// Falls through to local evaluation: on-everywhere is at 100%.
|
|
289
|
+
expect(resolveCanary("on-everywhere").enabled).toBe(true);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Miguel's P1: a percentage roll from the CLI must NOT be able to enrol a
|
|
293
|
+
// browser profile that opted out. The two surfaces have independent
|
|
294
|
+
// opt-outs, and CLI telemetry being on says nothing about this profile.
|
|
295
|
+
describe("precedence against Studio's own opt-out", () => {
|
|
296
|
+
beforeEach(() => {
|
|
297
|
+
policyState.allowed = false;
|
|
298
|
+
localStorage.setItem(OPT_OUT_KEY, "1");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("refuses a CLI COHORT enrolment when this profile opted out", () => {
|
|
302
|
+
window.__HF_CLI_CANARY_DECISIONS = { "off-everywhere": cohort(true) };
|
|
303
|
+
expect(resolveCanary("off-everywhere")).toEqual({
|
|
304
|
+
enabled: false,
|
|
305
|
+
reason: "telemetry_opt_out",
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("honours a CLI FORCED enrolment even when this profile opted out", () => {
|
|
310
|
+
// An explicit HF_CANARY_* override is a deliberate operator choice —
|
|
311
|
+
// the documented escalation channel, same as a local URL override.
|
|
312
|
+
window.__HF_CLI_CANARY_DECISIONS = { "off-everywhere": forced(true) };
|
|
313
|
+
expect(resolveCanary("off-everywhere")).toEqual({ enabled: true, reason: "forced_on" });
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("honours a CLI forced-OFF when this profile opted out", () => {
|
|
317
|
+
window.__HF_CLI_CANARY_DECISIONS = { "on-everywhere": forced(false) };
|
|
318
|
+
expect(resolveCanary("on-everywhere")).toEqual({ enabled: false, reason: "forced_off" });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("still refuses cohort enrolment with no CLI decision at all", () => {
|
|
322
|
+
expect(resolveCanary("on-everywhere").reason).toBe("telemetry_opt_out");
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
});
|