@hyperframes/studio 0.7.89 → 0.7.92
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-DVTnBvSO.js → hyperframes-player-9qOa08Tv.js} +1 -1
- package/dist/assets/{index-Qvx7boWH.js → index-Bw4lNdtO.js} +1 -1
- package/dist/assets/{index-ulQEdWKo.js → index-CDTN2ZVa.js} +1 -1
- package/dist/assets/index-DIntkrQl.js +428 -0
- package/dist/index.d.ts +20 -16
- package/dist/index.html +1 -1
- package/dist/index.js +6495 -5429
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- 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/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/propertyPanelTypes.ts +1 -1
- 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/contexts/DomEditContext.tsx +1 -1
- package/src/hooks/useDomEditSession.test.tsx +1 -1
- package/src/hooks/useDomEditSession.ts +1 -1
- package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
- package/src/hooks/useGsapKeyframeOps.ts +20 -9
- 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/Timeline.test.ts +2 -3
- package/src/player/components/Timeline.tsx +47 -48
- package/src/player/components/TimelineCanvas.tsx +14 -71
- package/src/player/components/TimelineClip.tsx +6 -3
- package/src/player/components/TimelineClipDiamonds.test.tsx +442 -18
- package/src/player/components/TimelineClipDiamonds.tsx +66 -211
- package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
- package/src/player/components/TimelineGestureOverlay.tsx +96 -0
- package/src/player/components/TimelineLanes.test.tsx +0 -1
- package/src/player/components/TimelineLanes.tsx +3 -2
- package/src/player/components/TimelineOverlays.test.ts +161 -0
- package/src/player/components/TimelineOverlays.tsx +104 -11
- 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 +5 -15
- package/src/player/components/timelineDragDrop.test.tsx +206 -0
- package/src/player/components/timelineDragDrop.ts +152 -30
- package/src/player/components/timelineLaneProps.ts +0 -1
- package/src/player/components/timelineMarquee.test.ts +73 -25
- package/src/player/components/timelineMarquee.ts +44 -16
- 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 +4 -4
- 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/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 +11 -4
- 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/playerStore.test.ts +76 -0
- package/src/player/store/playerStore.ts +19 -11
- 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/studioTelemetry.test.ts +76 -0
- package/src/utils/studioTelemetry.ts +14 -6
- package/dist/assets/index-CddKwfQ1.js +0 -428
|
@@ -22,6 +22,34 @@ export interface KeyframeCacheEntry {
|
|
|
22
22
|
easeEach?: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
export interface FocusedEaseSegment {
|
|
26
|
+
animationId: string;
|
|
27
|
+
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
28
|
+
tweenPercentage: number;
|
|
29
|
+
elementId: string;
|
|
30
|
+
projectId: string | null;
|
|
31
|
+
sessionEpoch: number;
|
|
32
|
+
nonce: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type FocusedEaseSegmentTarget = Omit<FocusedEaseSegment, "projectId" | "sessionEpoch" | "nonce">;
|
|
36
|
+
|
|
37
|
+
interface TimelineSessionIdentity {
|
|
38
|
+
timelineProjectId: string | null;
|
|
39
|
+
timelineSessionEpoch: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isFocusedEaseRequestCurrent(
|
|
43
|
+
request: FocusedEaseSegment,
|
|
44
|
+
state: TimelineSessionIdentity & { selectedElementId: string | null },
|
|
45
|
+
): boolean {
|
|
46
|
+
return (
|
|
47
|
+
request.projectId === state.timelineProjectId &&
|
|
48
|
+
request.sessionEpoch === state.timelineSessionEpoch &&
|
|
49
|
+
request.elementId === state.selectedElementId
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
25
53
|
export interface KeyframeSlice {
|
|
26
54
|
/** Selected collapsed (`element:pct`) or expanded (`element:group:animation:clipPct`) diamonds. */
|
|
27
55
|
selectedKeyframes: Set<string>;
|
|
@@ -35,22 +63,14 @@ export interface KeyframeSlice {
|
|
|
35
63
|
/** Union-expand clips (keyframed clips are expanded by default on load). */
|
|
36
64
|
expandClips: (ids: readonly string[]) => void;
|
|
37
65
|
|
|
38
|
-
/**
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
setFocusedEaseSegment: (
|
|
47
|
-
target: {
|
|
48
|
-
animationId: string;
|
|
49
|
-
collidingAnimationTargets?: AnimationKeyframeTarget[];
|
|
50
|
-
tweenPercentage: number;
|
|
51
|
-
elementId: string;
|
|
52
|
-
} | null,
|
|
53
|
-
) => void;
|
|
66
|
+
/**
|
|
67
|
+
* Project/session/element-scoped request. Its nonce is monotonic across store
|
|
68
|
+
* resets so a stale consumer can never collide with a later request.
|
|
69
|
+
*/
|
|
70
|
+
focusedEaseSegment: FocusedEaseSegment | null;
|
|
71
|
+
focusedEaseRequestNonce: number;
|
|
72
|
+
setFocusedEaseSegment: (target: FocusedEaseSegmentTarget) => void;
|
|
73
|
+
clearFocusedEaseSegment: (nonce: number) => void;
|
|
54
74
|
|
|
55
75
|
/** Keyframe data per element id, populated from parsed GSAP animations. */
|
|
56
76
|
keyframeCache: Map<string, KeyframeCacheEntry>;
|
|
@@ -60,7 +80,10 @@ export interface KeyframeSlice {
|
|
|
60
80
|
setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void;
|
|
61
81
|
}
|
|
62
82
|
|
|
63
|
-
export function createKeyframeSlice(
|
|
83
|
+
export function createKeyframeSlice(
|
|
84
|
+
set: StoreApi<KeyframeSlice>["setState"],
|
|
85
|
+
getTimelineSessionIdentity: () => TimelineSessionIdentity,
|
|
86
|
+
): KeyframeSlice {
|
|
64
87
|
return {
|
|
65
88
|
selectedKeyframes: new Set(),
|
|
66
89
|
toggleSelectedKeyframe: (key) =>
|
|
@@ -97,7 +120,25 @@ export function createKeyframeSlice(set: StoreApi<KeyframeSlice>["setState"]): K
|
|
|
97
120
|
}),
|
|
98
121
|
|
|
99
122
|
focusedEaseSegment: null,
|
|
100
|
-
|
|
123
|
+
focusedEaseRequestNonce: 0,
|
|
124
|
+
setFocusedEaseSegment: (target) =>
|
|
125
|
+
set((state) => {
|
|
126
|
+
const nonce = state.focusedEaseRequestNonce + 1;
|
|
127
|
+
const { timelineProjectId, timelineSessionEpoch } = getTimelineSessionIdentity();
|
|
128
|
+
return {
|
|
129
|
+
focusedEaseRequestNonce: nonce,
|
|
130
|
+
focusedEaseSegment: {
|
|
131
|
+
...target,
|
|
132
|
+
projectId: timelineProjectId,
|
|
133
|
+
sessionEpoch: timelineSessionEpoch,
|
|
134
|
+
nonce,
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}),
|
|
138
|
+
clearFocusedEaseSegment: (nonce) =>
|
|
139
|
+
set((state) =>
|
|
140
|
+
state.focusedEaseSegment?.nonce === nonce ? { focusedEaseSegment: null } : state,
|
|
141
|
+
),
|
|
101
142
|
|
|
102
143
|
keyframeCache: new Map(),
|
|
103
144
|
setKeyframeCache: (elementId, data) =>
|
|
@@ -53,6 +53,82 @@ describe("usePlayerStore", () => {
|
|
|
53
53
|
});
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
+
describe("focused ease requests", () => {
|
|
57
|
+
it("stamps the current project session and only lets its nonce clear it", () => {
|
|
58
|
+
const store = usePlayerStore.getState();
|
|
59
|
+
store.beginTimelineSession("project-a");
|
|
60
|
+
store.setSelectedElementId("index.html#hero");
|
|
61
|
+
store.setFocusedEaseSegment({
|
|
62
|
+
elementId: "index.html#hero",
|
|
63
|
+
animationId: "animation-a",
|
|
64
|
+
tweenPercentage: 50,
|
|
65
|
+
});
|
|
66
|
+
const first = usePlayerStore.getState().focusedEaseSegment;
|
|
67
|
+
if (!first) throw new Error("expected focused ease request");
|
|
68
|
+
expect(first.projectId).toBe("project-a");
|
|
69
|
+
expect(first.sessionEpoch).toBeGreaterThan(0);
|
|
70
|
+
expect(first.nonce).toBeGreaterThan(0);
|
|
71
|
+
|
|
72
|
+
store.setFocusedEaseSegment({
|
|
73
|
+
elementId: "index.html#hero",
|
|
74
|
+
animationId: "animation-a",
|
|
75
|
+
tweenPercentage: 75,
|
|
76
|
+
});
|
|
77
|
+
const second = usePlayerStore.getState().focusedEaseSegment;
|
|
78
|
+
if (!second) throw new Error("expected replacement request");
|
|
79
|
+
expect(second.nonce).toBe(first.nonce + 1);
|
|
80
|
+
|
|
81
|
+
store.clearFocusedEaseSegment(first.nonce);
|
|
82
|
+
expect(usePlayerStore.getState().focusedEaseSegment).toBe(second);
|
|
83
|
+
store.clearFocusedEaseSegment(second.nonce);
|
|
84
|
+
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("clears a pending request when the project session changes", () => {
|
|
88
|
+
const store = usePlayerStore.getState();
|
|
89
|
+
store.beginTimelineSession("project-a");
|
|
90
|
+
store.setFocusedEaseSegment({
|
|
91
|
+
elementId: "index.html#hero",
|
|
92
|
+
animationId: "animation-a",
|
|
93
|
+
tweenPercentage: 50,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
store.beginTimelineSession("project-b");
|
|
97
|
+
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("does not revive an old request after selecting away and back", () => {
|
|
101
|
+
const store = usePlayerStore.getState();
|
|
102
|
+
store.setSelectedElementId("index.html#a");
|
|
103
|
+
store.setFocusedEaseSegment({
|
|
104
|
+
elementId: "index.html#a",
|
|
105
|
+
animationId: "animation-a",
|
|
106
|
+
tweenPercentage: 50,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
store.setSelectedElementId("index.html#b");
|
|
110
|
+
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
|
|
111
|
+
store.setSelectedElementId("index.html#a");
|
|
112
|
+
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("invalidates on a genuine selection-anchor change but not a same-anchor echo", () => {
|
|
116
|
+
const store = usePlayerStore.getState();
|
|
117
|
+
store.setSelection(new Set(["index.html#a", "index.html#b"]), "index.html#a");
|
|
118
|
+
store.setFocusedEaseSegment({
|
|
119
|
+
elementId: "index.html#a",
|
|
120
|
+
animationId: "animation-a",
|
|
121
|
+
tweenPercentage: 50,
|
|
122
|
+
});
|
|
123
|
+
const request = usePlayerStore.getState().focusedEaseSegment;
|
|
124
|
+
|
|
125
|
+
store.setSelectionAnchor("index.html#a");
|
|
126
|
+
expect(usePlayerStore.getState().focusedEaseSegment).toBe(request);
|
|
127
|
+
store.setSelectionAnchor("index.html#b");
|
|
128
|
+
expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
56
132
|
describe("setIsPlaying", () => {
|
|
57
133
|
it("sets isPlaying to true", () => {
|
|
58
134
|
usePlayerStore.getState().setIsPlaying(true);
|
|
@@ -352,7 +352,10 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
352
352
|
activeTool: "select",
|
|
353
353
|
setActiveTool: (tool) => set({ activeTool: tool }),
|
|
354
354
|
|
|
355
|
-
...createKeyframeSlice(set)
|
|
355
|
+
...createKeyframeSlice(set, () => ({
|
|
356
|
+
timelineProjectId: get().timelineProjectId,
|
|
357
|
+
timelineSessionEpoch: get().timelineSessionEpoch,
|
|
358
|
+
})),
|
|
356
359
|
|
|
357
360
|
activeKeyframePct: null,
|
|
358
361
|
setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
|
|
@@ -541,6 +544,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
541
544
|
selectedElementIds,
|
|
542
545
|
activeKeyframePct: null,
|
|
543
546
|
motionPathArmed: false,
|
|
547
|
+
focusedEaseSegment: null,
|
|
544
548
|
}
|
|
545
549
|
: { selectedElementId: id, selectedElementIds };
|
|
546
550
|
}),
|
|
@@ -550,9 +554,16 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
550
554
|
setSelectionAnchor: (id) =>
|
|
551
555
|
set((s) => {
|
|
552
556
|
if (id != null && s.selectedElementIds.size > 1 && s.selectedElementIds.has(id)) {
|
|
553
|
-
return {
|
|
557
|
+
return {
|
|
558
|
+
selectedElementId: id,
|
|
559
|
+
focusedEaseSegment: id === s.selectedElementId ? s.focusedEaseSegment : null,
|
|
560
|
+
};
|
|
554
561
|
}
|
|
555
|
-
return {
|
|
562
|
+
return {
|
|
563
|
+
selectedElementId: id,
|
|
564
|
+
selectedElementIds: id ? new Set([id]) : new Set<string>(),
|
|
565
|
+
focusedEaseSegment: id === s.selectedElementId ? s.focusedEaseSegment : null,
|
|
566
|
+
};
|
|
556
567
|
}),
|
|
557
568
|
updateElement: (elementId, updates) =>
|
|
558
569
|
set((state) => ({
|
|
@@ -560,9 +571,9 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
560
571
|
(el.key ?? el.id) === elementId ? { ...el, ...updates } : el,
|
|
561
572
|
),
|
|
562
573
|
})),
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
574
|
+
// UI preferences intentionally survive reset. So do timelineSessionEpoch and
|
|
575
|
+
// focusedEaseRequestNonce: the epoch advances only when project identity
|
|
576
|
+
// changes, while a monotonic nonce prevents collisions with stale consumers.
|
|
566
577
|
beginTimelineSession: (projectId) =>
|
|
567
578
|
set((state) => {
|
|
568
579
|
if (state.timelineProjectId === projectId) return state;
|
|
@@ -575,18 +586,15 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
|
|
575
586
|
reset: () => set(createTimelineResetState()),
|
|
576
587
|
}));
|
|
577
588
|
|
|
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
589
|
function isDevBuild(): boolean {
|
|
584
590
|
try {
|
|
585
591
|
return import.meta.env.DEV === true;
|
|
586
592
|
} catch {
|
|
593
|
+
// Turbopack and other non-Vite bundlers may not provide import.meta.env.
|
|
587
594
|
return false;
|
|
588
595
|
}
|
|
589
596
|
}
|
|
590
597
|
if (isDevBuild() && typeof window !== "undefined") {
|
|
598
|
+
// Console handle for dumping live Studio state during bug-bash reproduction.
|
|
591
599
|
(window as unknown as { __playerStore?: typeof usePlayerStore }).__playerStore = usePlayerStore;
|
|
592
600
|
}
|
|
@@ -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
|
+
});
|