@hyperframes/studio 0.8.50 → 0.8.52
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-CxxDxHTW.js → hyperframes-player-I5YRzB02.js} +3 -3
- package/dist/assets/{index-Dt3qBI-9.js → index-BTAfgesM.js} +1 -1
- package/dist/assets/index-CxAu_Okg.css +1 -0
- package/dist/assets/index-DocJlW2v.js +376 -0
- package/dist/assets/{index-DUAIWxkI.js → index-Du5x7KUR.js} +1 -1
- package/dist/index.d.ts +419 -20
- package/dist/index.html +2 -2
- package/dist/index.js +7496 -8288
- package/dist/index.js.map +1 -1
- package/dist/styles/tailwind-preset.js.map +1 -1
- package/package.json +13 -12
- package/src/App.tsx +131 -143
- package/src/captions/components/CaptionAnimationPanel.tsx +4 -4
- package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
- package/src/captions/components/CaptionOverlay.tsx +8 -4
- package/src/captions/components/CaptionOverlayUtils.ts +2 -3
- package/src/captions/components/CaptionPropertyPanel.tsx +3 -3
- package/src/captions/components/CaptionTimeline.tsx +1 -1
- package/src/captions/components/shared.tsx +2 -2
- package/src/components/AskAgentModal.tsx +5 -5
- package/src/components/EditorShell.tsx +8 -7
- package/src/components/ExternalFileConflictBanner.tsx +16 -16
- package/src/components/LintModal.tsx +6 -16
- package/src/components/MediaPreview.tsx +2 -2
- package/src/components/RightPanelTabs.test.tsx +151 -0
- package/src/components/RightPanelTabs.tsx +59 -0
- package/src/components/SaveQueuePausedBanner.tsx +2 -2
- package/src/components/StudioHeader.dom.test.tsx +181 -0
- package/src/components/StudioHeader.tsx +110 -210
- package/src/components/StudioLeftSidebar.tsx +7 -4
- package/src/components/StudioOverlays.tsx +1 -1
- package/src/components/StudioRightPanel.tsx +51 -39
- package/src/components/StudioToast.tsx +2 -2
- package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
- package/src/components/TimelineHistoryButtons.tsx +54 -0
- package/src/components/TimelineToolbar.test.tsx +8 -0
- package/src/components/TimelineToolbar.tsx +8 -12
- package/src/components/dock/Dock.test.tsx +260 -0
- package/src/components/dock/Dock.tsx +281 -0
- package/src/components/dock/dock.css +28 -0
- package/src/components/dock/dockAccessibility.test.ts +166 -0
- package/src/components/dock/dockAccessibility.ts +98 -0
- package/src/components/dock/dockLayout.test.ts +134 -0
- package/src/components/dock/dockLayout.ts +105 -0
- package/src/components/dock/dockLayoutSchema.ts +66 -0
- package/src/components/dock/dockLayoutStore.ts +91 -0
- package/src/components/dock/dockTestHarness.ts +11 -0
- package/src/components/dock/panelRegistry.ts +67 -0
- package/src/components/editor/AnimationCard.tsx +4 -4
- package/src/components/editor/AnimationCardParts.tsx +6 -6
- package/src/components/editor/BlockParamsPanel.tsx +4 -4
- package/src/components/editor/BorderRadiusEditor.tsx +1 -1
- package/src/components/editor/CanvasContextMenu.tsx +1 -1
- package/src/components/editor/ComputedTweenNotice.tsx +1 -1
- package/src/components/editor/DomEditOverlay.tsx +1 -1
- package/src/components/editor/EaseCurveSection.tsx +1 -1
- package/src/components/editor/EaseParamFields.tsx +5 -5
- package/src/components/editor/FileTree.tsx +4 -4
- package/src/components/editor/FileTreeIcons.tsx +1 -1
- package/src/components/editor/FileTreeNodes.tsx +13 -13
- package/src/components/editor/InlineTextToolbar.tsx +1 -1
- package/src/components/editor/InspectorHeaderActions.tsx +3 -3
- package/src/components/editor/KeyframeDiamond.tsx +1 -1
- package/src/components/editor/KeyframeEaseList.tsx +1 -1
- package/src/components/editor/LayersPanel.test.ts +97 -1
- package/src/components/editor/LayersPanel.tsx +7 -5
- package/src/components/editor/MarqueeOverlay.tsx +1 -1
- package/src/components/editor/OffCanvasIndicators.tsx +1 -1
- package/src/components/editor/PreviewGuides.tsx +1 -1
- package/src/components/editor/PromotableControl.tsx +2 -2
- package/src/components/editor/PropertyPanelEmptyState.tsx +7 -7
- package/src/components/editor/PropertyPanelFlatHeader.tsx +2 -2
- package/src/components/editor/SnapToolbar.tsx +2 -2
- package/src/components/editor/TimelineFxPopover.tsx +1 -1
- package/src/components/editor/TopologyLens.test.tsx +28 -0
- package/src/components/editor/TopologyLens.tsx +7 -5
- package/src/components/editor/Transform3DCube.tsx +2 -2
- package/src/components/editor/audioFxRevealTarget.ts +1 -0
- package/src/components/editor/domEditing.test.ts +1 -1
- package/src/components/editor/domEditingAgentPrompt.test.ts +8 -6
- package/src/components/editor/domEditingAgentPrompt.ts +4 -3
- package/src/components/editor/propertyPanelColor.tsx +11 -11
- package/src/components/editor/propertyPanelColorCurveGraph.tsx +1 -1
- package/src/components/editor/propertyPanelColorGradingControls.tsx +7 -9
- package/src/components/editor/propertyPanelColorGradingSection.tsx +6 -6
- package/src/components/editor/propertyPanelColorGradingSlider.tsx +5 -5
- package/src/components/editor/propertyPanelColorWheels.tsx +1 -1
- package/src/components/editor/propertyPanelCommitField.tsx +1 -1
- package/src/components/editor/propertyPanelFill.tsx +4 -4
- package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +3 -3
- package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +7 -7
- package/src/components/editor/propertyPanelFlatEffectsSection.tsx +4 -4
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +3 -3
- package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +1 -1
- package/src/components/editor/propertyPanelFlatPrimitives.tsx +10 -10
- package/src/components/editor/propertyPanelFlatSelectRow.tsx +3 -3
- package/src/components/editor/propertyPanelFlatTextSection.test.tsx +8 -9
- package/src/components/editor/propertyPanelFlatTextSection.tsx +5 -5
- package/src/components/editor/propertyPanelFlatToggle.tsx +1 -1
- package/src/components/editor/propertyPanelFont.tsx +5 -5
- package/src/components/editor/propertyPanelFxCarveModule.tsx +1 -1
- package/src/components/editor/propertyPanelFxControls.test.tsx +1 -1
- package/src/components/editor/propertyPanelFxControls.tsx +6 -6
- package/src/components/editor/propertyPanelFxEqModule.tsx +2 -2
- package/src/components/editor/propertyPanelFxPresetStyle.ts +2 -2
- package/src/components/editor/propertyPanelGradingNumberField.tsx +1 -1
- package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
- package/src/components/editor/propertyPanelPrimitives.tsx +7 -7
- package/src/components/editor/propertyPanelSections.tsx +8 -8
- package/src/components/editor/useColorGradingController.test.ts +38 -0
- package/src/components/editor/useColorGradingController.ts +9 -1
- package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
- package/src/components/editor/useColorGradingPreviews.ts +11 -1
- package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
- package/src/components/editor/usePreviewCompositionRect.ts +21 -0
- package/src/components/feedback/StudioFeedbackCard.tsx +7 -7
- package/src/components/feedback/feedbackTrigger.ts +0 -1
- package/src/components/nle/AssetPreviewOverlay.tsx +6 -2
- package/src/components/nle/CompositionBreadcrumb.tsx +3 -3
- package/src/components/nle/NLEPreview.tsx +3 -3
- package/src/components/nle/PreviewOverlays.tsx +6 -6
- package/src/components/nle/PreviewPane.tsx +3 -3
- package/src/components/nle/TimelinePane.tsx +3 -3
- package/src/components/nle/TimelineResizeDivider.tsx +2 -2
- package/src/components/panels/SlideshowPanel.tsx +2 -2
- package/src/components/panels/SlideshowSubPanels.tsx +20 -17
- package/src/components/panels/VariablesBindElement.tsx +2 -2
- package/src/components/panels/VariablesDeclarationForm.tsx +2 -2
- package/src/components/panels/VariablesOtherCompositions.tsx +1 -1
- package/src/components/panels/VariablesPanel.tsx +3 -3
- package/src/components/panels/VariablesValueControls.tsx +2 -2
- package/src/components/pointerTargetSize.test.tsx +8 -0
- package/src/components/renders/FfmpegRequiredNotice.tsx +5 -5
- package/src/components/renders/RenderQueue.test.tsx +7 -0
- package/src/components/renders/RenderQueue.tsx +7 -7
- package/src/components/renders/RenderQueueItem.tsx +6 -6
- package/src/components/sidebar/AssetCard.tsx +8 -8
- package/src/components/sidebar/AssetContextMenu.tsx +8 -8
- package/src/components/sidebar/AssetsTab.tsx +2 -2
- package/src/components/sidebar/AudioRow.tsx +6 -6
- package/src/components/sidebar/BlocksTab.tsx +6 -6
- package/src/components/sidebar/CompositionsTab.tsx +5 -5
- package/src/components/sidebar/GlobalAssetsView.tsx +1 -1
- package/src/components/sidebar/LeftSidebar.lintBadge.test.tsx +58 -0
- package/src/components/sidebar/LeftSidebar.tsx +19 -5
- package/src/components/sidebar/PromptPreviewModal.tsx +3 -3
- package/src/components/timelineToolbarStyles.ts +5 -0
- package/src/components/ui/Button.test.tsx +252 -0
- package/src/components/ui/Button.tsx +89 -126
- package/src/components/ui/IconButton.tsx +38 -0
- package/src/components/ui/Input.tsx +115 -0
- package/src/components/ui/Menu.test.tsx +374 -0
- package/src/components/ui/Menu.tsx +213 -0
- package/src/components/ui/NumberField.tsx +122 -0
- package/src/components/ui/Popover.tsx +74 -0
- package/src/components/ui/SearchInput.tsx +2 -2
- package/src/components/ui/Select.tsx +100 -0
- package/src/components/ui/Slider.tsx +153 -0
- package/src/components/ui/Tabs.test.tsx +98 -0
- package/src/components/ui/Tabs.tsx +71 -0
- package/src/components/ui/Toggle.tsx +63 -0
- package/src/components/ui/Tooltip.test.tsx +110 -0
- package/src/components/ui/Tooltip.tsx +38 -112
- package/src/components/ui/cn.test.ts +30 -0
- package/src/components/ui/cn.ts +25 -0
- package/src/components/ui/index.ts +29 -2
- package/src/components/ui/mountHost.testHelpers.ts +24 -0
- package/src/components/ui/valueControls.test.tsx +524 -0
- package/src/hooks/useAppHotkeys.ts +21 -80
- package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
- package/src/hooks/useCaptionDetection.ts +3 -1
- package/src/hooks/useEditHistoryActions.test.tsx +90 -0
- package/src/hooks/useEditHistoryActions.ts +106 -0
- package/src/hooks/useLintModal.test.tsx +65 -0
- package/src/hooks/useLintModal.ts +3 -0
- package/src/hooks/useSdkSession.lifecycle.test.tsx +49 -4
- package/src/hooks/useSdkSession.ts +43 -10
- package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
- package/src/hooks/useServerConnection.ts +43 -10
- package/src/hooks/useTimelineAssetDropOps.test.tsx +41 -1
- package/src/hooks/useTimelineAssetDropOps.ts +52 -11
- package/src/index.ts +51 -0
- package/src/main.tsx +9 -3
- package/src/player/components/AudioWaveform.tsx +1 -1
- package/src/player/components/AutomationSelectionMenu.tsx +3 -3
- package/src/player/components/ClipContextMenu.tsx +3 -3
- package/src/player/components/CompositionThumbnail.test.ts +0 -3
- package/src/player/components/CompositionThumbnail.tsx +3 -4
- package/src/player/components/EditModal.tsx +1 -1
- package/src/player/components/ImageThumbnail.tsx +1 -1
- package/src/player/components/KeyframeDiamondContextMenu.tsx +4 -4
- package/src/player/components/LayerDisclosureRow.tsx +2 -2
- package/src/player/components/PlayerControls.tsx +1 -1
- package/src/player/components/ShortcutsPanel.tsx +9 -9
- package/src/player/components/SpeedMenu.tsx +3 -5
- package/src/player/components/Timeline.tsx +2 -1
- package/src/player/components/TimelineCanvas.tsx +21 -14
- package/src/player/components/TimelineClip.tsx +0 -1
- package/src/player/components/TimelineDiamondConnectors.tsx +1 -1
- package/src/player/components/TimelineEmptyState.tsx +5 -5
- package/src/player/components/TimelineFxButton.tsx +6 -6
- package/src/player/components/TimelineGroupHeader.tsx +5 -5
- package/src/player/components/TimelineGroupLaneLabels.tsx +1 -1
- package/src/player/components/TimelineGroupRow.tsx +1 -1
- package/src/player/components/TimelineLanes.test.tsx +32 -0
- package/src/player/components/TimelineLanes.tsx +2 -5
- package/src/player/components/TimelineLayerGroupHeader.tsx +2 -2
- package/src/player/components/TimelineRuler.tsx +1 -1
- package/src/player/components/TimelineShortcutHint.tsx +1 -1
- package/src/player/components/TimelineTrackHeader.tsx +1 -1
- package/src/player/components/TimelineTrackPlainHeader.tsx +3 -3
- package/src/player/components/TimelineTypes.ts +2 -1
- package/src/player/components/TrackGapContextMenu.tsx +1 -1
- package/src/player/components/VideoThumbnail.tsx +4 -2
- package/src/player/components/VolumeControl.tsx +2 -2
- package/src/player/components/timelineCallbacks.ts +8 -8
- package/src/player/components/timelineClipDragPreview.ts +51 -27
- package/src/player/components/timelineClipDragTypes.ts +3 -0
- package/src/player/components/timelineDragDrop.test.tsx +61 -1
- package/src/player/components/timelineDragDrop.ts +57 -15
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/timelineSnapping.test.ts +27 -0
- package/src/player/components/timelineSnapping.ts +14 -1
- package/src/player/components/trackHeaderLabelRows.tsx +4 -4
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +136 -2
- package/src/player/components/useTimelineClipDrag.ts +30 -3
- package/src/player/lib/describeClips.ts +3 -54
- package/src/player/lib/playbackShortcuts.ts +2 -0
- package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
- package/src/player/store/previewIframeStore.test.tsx +29 -0
- package/src/player/store/previewIframeStore.ts +25 -0
- package/src/renderQueueExports.test.tsx +58 -0
- package/src/styles/classCandidates.test.ts +105 -0
- package/src/styles/classCandidates.ts +281 -0
- package/src/styles/hex-baseline.json +84 -0
- package/src/styles/hexRatchet.test.ts +201 -0
- package/src/styles/iconTokens.ts +20 -0
- package/src/styles/palette-reset.css +6 -0
- package/src/styles/studio.css +24 -21
- package/src/styles/styleSources.test.ts +20 -0
- package/src/styles/styleSources.ts +47 -0
- package/src/styles/tailwind-preset.shared.js +3 -0
- package/src/styles/theme.css +243 -0
- package/src/styles/theme.test.ts +183 -0
- package/src/styles/tokenGate.test.ts +109 -0
- package/src/utils/sourcePatcher.ts +1 -1
- package/src/utils/studioHelpers.test.ts +18 -0
- package/src/utils/studioHelpers.ts +11 -1
- package/src/utils/studioUiPreferences.ts +7 -0
- package/src/utils/timelineDropTrackInsert.test.ts +134 -0
- package/src/utils/timelineDropTrackInsert.ts +83 -0
- package/src/utils/typingTarget.ts +4 -0
- package/dist/assets/index-n2ml4tUn.js +0 -442
- package/dist/assets/index-pO6p0H27.css +0 -1
- package/src/components/PanelTabButton.tsx +0 -31
- package/src/components/storyboard/AgentChatMessageButton.test.tsx +0 -47
- package/src/components/storyboard/AgentChatMessageButton.tsx +0 -45
- package/src/components/storyboard/FramePoster.test.tsx +0 -61
- package/src/components/storyboard/FramePoster.tsx +0 -86
- package/src/components/storyboard/StoryboardDirection.tsx +0 -45
- package/src/components/storyboard/StoryboardFrameFocus.tsx +0 -412
- package/src/components/storyboard/StoryboardFrameTile.tsx +0 -117
- package/src/components/storyboard/StoryboardGrid.tsx +0 -55
- package/src/components/storyboard/StoryboardLoaded.tsx +0 -307
- package/src/components/storyboard/StoryboardReviewGuide.tsx +0 -300
- package/src/components/storyboard/StoryboardScriptPanel.tsx +0 -26
- package/src/components/storyboard/StoryboardSourceEditor.tsx +0 -231
- package/src/components/storyboard/StoryboardStatusLegend.tsx +0 -21
- package/src/components/storyboard/StoryboardView.tsx +0 -169
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +0 -187
- package/src/components/storyboard/frameComments.test.ts +0 -95
- package/src/components/storyboard/frameComments.ts +0 -115
- package/src/components/storyboard/frameStatus.ts +0 -36
- package/src/components/storyboard/storyboardReviewStage.test.ts +0 -40
- package/src/components/storyboard/storyboardReviewStage.ts +0 -45
- package/src/components/storyboard/useFrameComments.ts +0 -98
- package/src/contexts/ViewModeContext.tsx +0 -142
- package/src/hooks/useProjectSignaturePoll.test.tsx +0 -135
- package/src/hooks/useProjectSignaturePoll.ts +0 -68
- package/src/hooks/useStoryboard.ts +0 -99
|
@@ -3,6 +3,7 @@ import { act } from "react";
|
|
|
3
3
|
import { createRoot } from "react-dom/client";
|
|
4
4
|
import { expect, it, vi } from "vitest";
|
|
5
5
|
import { useCaptionDetection } from "./useCaptionDetection";
|
|
6
|
+
import { usePreviewIframeStore } from "../player/store/previewIframeStore";
|
|
6
7
|
|
|
7
8
|
Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
|
|
8
9
|
|
|
@@ -52,3 +53,48 @@ it("does not let a foreign window trigger caption activation", () => {
|
|
|
52
53
|
vi.unstubAllGlobals();
|
|
53
54
|
}
|
|
54
55
|
});
|
|
56
|
+
|
|
57
|
+
it("re-runs caption activation on the promoted iframe's document", () => {
|
|
58
|
+
const host = document.createElement("div");
|
|
59
|
+
const a = document.createElement("iframe");
|
|
60
|
+
const b = document.createElement("iframe");
|
|
61
|
+
document.body.append(host, a, b);
|
|
62
|
+
const ref = { current: a };
|
|
63
|
+
const root = createRoot(host);
|
|
64
|
+
const fetchMock = vi.fn(() => new Promise<Response>(() => {}));
|
|
65
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
66
|
+
const params = {
|
|
67
|
+
projectId: "demo",
|
|
68
|
+
activeCompPath: "captions.html",
|
|
69
|
+
compIdToSrc: new Map<string, string>(),
|
|
70
|
+
captionEditMode: false,
|
|
71
|
+
captionHasSelection: false,
|
|
72
|
+
previewIframeRef: ref,
|
|
73
|
+
captionSync: { save: vi.fn(), loadOverrides: vi.fn(async () => {}) },
|
|
74
|
+
setRightCollapsed: vi.fn(),
|
|
75
|
+
};
|
|
76
|
+
function Harness() {
|
|
77
|
+
useCaptionDetection(params);
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
act(() => usePreviewIframeStore.getState().setIframe(a));
|
|
82
|
+
act(() => root.render(<Harness />));
|
|
83
|
+
const group = b.contentDocument!.createElement("div");
|
|
84
|
+
group.className = "caption-group";
|
|
85
|
+
b.contentDocument!.body.append(group);
|
|
86
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
87
|
+
act(() => {
|
|
88
|
+
ref.current = b;
|
|
89
|
+
usePreviewIframeStore.getState().setIframe(b);
|
|
90
|
+
});
|
|
91
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
92
|
+
} finally {
|
|
93
|
+
act(() => root.unmount());
|
|
94
|
+
usePreviewIframeStore.setState({ iframe: null });
|
|
95
|
+
host.remove();
|
|
96
|
+
a.remove();
|
|
97
|
+
b.remove();
|
|
98
|
+
vi.unstubAllGlobals();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useLivePreviewIframe } from "../player/store/previewIframeStore";
|
|
1
2
|
import { buildProjectApiPath } from "../utils/projectRouting";
|
|
2
3
|
import { useEffect, useRef } from "react";
|
|
3
4
|
import { useCaptionStore } from "../captions/store";
|
|
@@ -26,6 +27,7 @@ export function useCaptionDetection({
|
|
|
26
27
|
captionSync,
|
|
27
28
|
setRightCollapsed,
|
|
28
29
|
}: UseCaptionDetectionParams) {
|
|
30
|
+
const livePreviewIframe = useLivePreviewIframe();
|
|
29
31
|
// Switching compositions must drop the previous comp's caption state — a
|
|
30
32
|
// stale model + full-canvas overlay otherwise blocks normal element editing
|
|
31
33
|
// on the new composition (and edit mode could never be exited).
|
|
@@ -145,7 +147,7 @@ export function useCaptionDetection({
|
|
|
145
147
|
return () => {
|
|
146
148
|
window.removeEventListener("message", handleMessage);
|
|
147
149
|
};
|
|
148
|
-
}, [activeCompPath, projectId, compIdToSrc, captionSync, previewIframeRef]);
|
|
150
|
+
}, [activeCompPath, projectId, compIdToSrc, captionSync, previewIframeRef, livePreviewIframe]);
|
|
149
151
|
|
|
150
152
|
// eslint-disable-next-line no-restricted-syntax
|
|
151
153
|
useEffect(() => {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act, createElement } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
7
|
+
import { useEditHistoryActions, type EditHistoryHandle } from "./useEditHistoryActions";
|
|
8
|
+
|
|
9
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
10
|
+
|
|
11
|
+
let root: Root | null = null;
|
|
12
|
+
afterEach(() => act(() => root?.unmount()));
|
|
13
|
+
|
|
14
|
+
function mount(result: { ok: boolean; reason?: string; label?: string; paths?: string[] }) {
|
|
15
|
+
const editHistory = {
|
|
16
|
+
undo: vi.fn<EditHistoryHandle["undo"]>(async (cb) => {
|
|
17
|
+
if (result.ok) await cb.writeFile("index.html", "before");
|
|
18
|
+
return result;
|
|
19
|
+
}),
|
|
20
|
+
redo: vi.fn<EditHistoryHandle["redo"]>(async () => result),
|
|
21
|
+
};
|
|
22
|
+
const deps = {
|
|
23
|
+
editHistory,
|
|
24
|
+
readOptionalProjectFile: vi.fn(async () => ""),
|
|
25
|
+
readProjectFile: vi.fn(async () => ""),
|
|
26
|
+
writeProjectFile: vi.fn(async () => undefined),
|
|
27
|
+
showToast: vi.fn(),
|
|
28
|
+
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
|
|
29
|
+
waitForPendingDomEditSaves: vi.fn(async () => undefined),
|
|
30
|
+
onAfterUndoRedo: vi.fn(),
|
|
31
|
+
activeCompPath: "index.html",
|
|
32
|
+
forceReloadSdkSession: vi.fn(),
|
|
33
|
+
};
|
|
34
|
+
let actions!: ReturnType<typeof useEditHistoryActions>;
|
|
35
|
+
function Probe() {
|
|
36
|
+
actions = useEditHistoryActions(deps);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
root = createRoot(document.createElement("div"));
|
|
40
|
+
act(() => root!.render(createElement(Probe)));
|
|
41
|
+
return { deps, actions };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("useEditHistoryActions", () => {
|
|
45
|
+
it("undo writes through the host writer, resyncs the preview and toasts the label", async () => {
|
|
46
|
+
const { deps, actions } = mount({ ok: true, label: "Move clip", paths: ["index.html"] });
|
|
47
|
+
await act(() => actions.undo());
|
|
48
|
+
expect(deps.waitForPendingDomEditSaves).toHaveBeenCalled();
|
|
49
|
+
expect(deps.writeProjectFile).toHaveBeenCalledWith("index.html", "before");
|
|
50
|
+
expect(deps.onAfterUndoRedo).toHaveBeenCalled();
|
|
51
|
+
expect(deps.forceReloadSdkSession).toHaveBeenCalled();
|
|
52
|
+
expect(deps.syncHistoryPreviewAfterApply).toHaveBeenCalled();
|
|
53
|
+
expect(deps.showToast).toHaveBeenCalledWith("Undid Move clip", "info");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("redo reports the redone label and skips the SDK reload for other files", async () => {
|
|
57
|
+
const { deps, actions } = mount({ ok: true, label: "Split clip", paths: ["other.html"] });
|
|
58
|
+
await act(() => actions.redo());
|
|
59
|
+
expect(deps.forceReloadSdkSession).not.toHaveBeenCalled();
|
|
60
|
+
expect(deps.showToast).toHaveBeenCalledWith("Redid Split clip", "info");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("explains a refused undo when the file changed on disk", async () => {
|
|
64
|
+
const { deps, actions } = mount({ ok: false, reason: "content-mismatch" });
|
|
65
|
+
await act(() => actions.undo());
|
|
66
|
+
expect(deps.showToast).toHaveBeenCalledWith(
|
|
67
|
+
"File changed outside Studio. Undo history was not applied.",
|
|
68
|
+
"info",
|
|
69
|
+
);
|
|
70
|
+
expect(deps.syncHistoryPreviewAfterApply).not.toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("waits for pending saves first and reads the motion file through the optional reader", async () => {
|
|
74
|
+
const { deps, actions } = mount({ ok: true, label: "Move clip", paths: ["index.html"] });
|
|
75
|
+
const order: string[] = [];
|
|
76
|
+
deps.waitForPendingDomEditSaves.mockImplementation(async () => void order.push("wait"));
|
|
77
|
+
deps.editHistory.undo.mockImplementation(async (cb) => {
|
|
78
|
+
order.push("undo");
|
|
79
|
+
await cb.readFile(STUDIO_MOTION_PATH);
|
|
80
|
+
await cb.readFile("index.html");
|
|
81
|
+
await cb.serialize?.(["index.html"], async () => order.push("serialized"));
|
|
82
|
+
return { ok: true };
|
|
83
|
+
});
|
|
84
|
+
await act(() => actions.undo());
|
|
85
|
+
expect(order).toEqual(["wait", "undo", "serialized"]);
|
|
86
|
+
expect(deps.readOptionalProjectFile).toHaveBeenCalledWith(STUDIO_MOTION_PATH);
|
|
87
|
+
expect(deps.readProjectFile).toHaveBeenCalledWith("index.html");
|
|
88
|
+
expect(deps.readProjectFile).not.toHaveBeenCalledWith(STUDIO_MOTION_PATH);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// fallow-ignore-file complexity
|
|
2
|
+
import { useCallback, useMemo } from "react";
|
|
3
|
+
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
|
|
4
|
+
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
5
|
+
|
|
6
|
+
interface HistoryResult {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
reason?: string;
|
|
9
|
+
label?: string;
|
|
10
|
+
paths?: string[];
|
|
11
|
+
/** Per-file restored/previous content, used to soft-apply the preview. */
|
|
12
|
+
files?: Record<string, { previous: string; restored: string }>;
|
|
13
|
+
}
|
|
14
|
+
interface HistoryFileCallbacks {
|
|
15
|
+
readFile: (path: string) => Promise<string>;
|
|
16
|
+
writeFile: (path: string, content: string) => Promise<void>;
|
|
17
|
+
serialize?: <T>(paths: readonly string[], task: () => Promise<T>) => Promise<T>;
|
|
18
|
+
}
|
|
19
|
+
export interface EditHistoryHandle {
|
|
20
|
+
undo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
|
|
21
|
+
redo: (cb: HistoryFileCallbacks) => Promise<HistoryResult>;
|
|
22
|
+
state: {
|
|
23
|
+
undo: ReadonlyArray<{ createdAt: number }>;
|
|
24
|
+
redo: ReadonlyArray<{ createdAt: number }>;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface UseEditHistoryActionsOptions {
|
|
29
|
+
editHistory: Pick<EditHistoryHandle, "undo" | "redo">;
|
|
30
|
+
readOptionalProjectFile: (path: string) => Promise<string>;
|
|
31
|
+
readProjectFile: (path: string) => Promise<string>;
|
|
32
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
33
|
+
showToast: (message: string, tone?: "error" | "info") => void;
|
|
34
|
+
syncHistoryPreviewAfterApply: (restore: Pick<HistoryResult, "paths" | "files">) => Promise<void>;
|
|
35
|
+
waitForPendingDomEditSaves: () => Promise<void>;
|
|
36
|
+
onAfterUndoRedo?: () => void;
|
|
37
|
+
/** Active composition path — decides whether undo/redo must resync the SDK session. */
|
|
38
|
+
activeCompPath?: string | null;
|
|
39
|
+
/** Reloads the SDK session after a revert of the active comp, past the self-write suppress window. */
|
|
40
|
+
forceReloadSdkSession?: () => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Applies one persisted file-history step: the single owner of undo/redo over project files. */
|
|
44
|
+
export function useEditHistoryActions({
|
|
45
|
+
editHistory,
|
|
46
|
+
readOptionalProjectFile,
|
|
47
|
+
readProjectFile,
|
|
48
|
+
writeProjectFile,
|
|
49
|
+
showToast,
|
|
50
|
+
syncHistoryPreviewAfterApply,
|
|
51
|
+
waitForPendingDomEditSaves,
|
|
52
|
+
onAfterUndoRedo,
|
|
53
|
+
activeCompPath,
|
|
54
|
+
forceReloadSdkSession,
|
|
55
|
+
}: UseEditHistoryActionsOptions) {
|
|
56
|
+
const readHistoryFile = useCallback(
|
|
57
|
+
(path: string): Promise<string> =>
|
|
58
|
+
path === STUDIO_MOTION_PATH ? readOptionalProjectFile(path) : readProjectFile(path),
|
|
59
|
+
[readOptionalProjectFile, readProjectFile],
|
|
60
|
+
);
|
|
61
|
+
const serializeHistoryFiles = useCallback(
|
|
62
|
+
<T>(paths: readonly string[], task: () => Promise<T>) =>
|
|
63
|
+
serializeStudioFileMutations(writeProjectFile, paths, task),
|
|
64
|
+
[writeProjectFile],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const apply = useCallback(
|
|
68
|
+
async (direction: "undo" | "redo") => {
|
|
69
|
+
const [noun, verb] = direction === "undo" ? ["Undo", "Undid"] : ["Redo", "Redid"];
|
|
70
|
+
await waitForPendingDomEditSaves();
|
|
71
|
+
const result = await editHistory[direction]({
|
|
72
|
+
readFile: readHistoryFile,
|
|
73
|
+
writeFile: writeProjectFile,
|
|
74
|
+
serialize: serializeHistoryFiles,
|
|
75
|
+
});
|
|
76
|
+
if (!result.ok && result.reason === "content-mismatch") {
|
|
77
|
+
showToast(`File changed outside Studio. ${noun} history was not applied.`, "info");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (result.ok && result.label) {
|
|
81
|
+
onAfterUndoRedo?.();
|
|
82
|
+
if (activeCompPath && result.paths?.includes(activeCompPath)) {
|
|
83
|
+
forceReloadSdkSession?.();
|
|
84
|
+
}
|
|
85
|
+
await syncHistoryPreviewAfterApply({ paths: result.paths, files: result.files });
|
|
86
|
+
showToast(`${verb} ${result.label}`, "info");
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
[
|
|
90
|
+
editHistory,
|
|
91
|
+
readHistoryFile,
|
|
92
|
+
showToast,
|
|
93
|
+
syncHistoryPreviewAfterApply,
|
|
94
|
+
waitForPendingDomEditSaves,
|
|
95
|
+
writeProjectFile,
|
|
96
|
+
serializeHistoryFiles,
|
|
97
|
+
onAfterUndoRedo,
|
|
98
|
+
activeCompPath,
|
|
99
|
+
forceReloadSdkSession,
|
|
100
|
+
],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const undo = useCallback(() => apply("undo"), [apply]);
|
|
104
|
+
const redo = useCallback(() => apply("redo"), [apply]);
|
|
105
|
+
return useMemo(() => ({ undo, redo }), [undo, redo]);
|
|
106
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { useLintModal } from "./useLintModal";
|
|
7
|
+
|
|
8
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
let root: Root | null = null;
|
|
11
|
+
let latest: ReturnType<typeof useLintModal> | null = null;
|
|
12
|
+
|
|
13
|
+
function Probe() {
|
|
14
|
+
latest = useLintModal("demo");
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
if (root) act(() => root?.unmount());
|
|
20
|
+
root = null;
|
|
21
|
+
latest = null;
|
|
22
|
+
vi.unstubAllGlobals();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
async function mountWithLint(response: () => Promise<unknown>) {
|
|
26
|
+
vi.stubGlobal("fetch", vi.fn(response));
|
|
27
|
+
root = createRoot(document.createElement("div"));
|
|
28
|
+
await act(async () => root?.render(<Probe />));
|
|
29
|
+
await act(async () => {
|
|
30
|
+
await latest?.handleLint();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const lintResponse = (findings: Array<{ severity: string; message: string }>) => async () => ({
|
|
35
|
+
json: async () => ({ findings }),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("useLintModal hasLintError", () => {
|
|
39
|
+
it("is true when a finding is an error and false for warnings only", async () => {
|
|
40
|
+
await mountWithLint(lintResponse([{ severity: "error", message: "nested" }]));
|
|
41
|
+
expect(latest?.hasLintError).toBe(true);
|
|
42
|
+
act(() => root?.unmount());
|
|
43
|
+
root = null;
|
|
44
|
+
await mountWithLint(lintResponse([{ severity: "warning", message: "soft" }]));
|
|
45
|
+
expect(latest?.hasLintError).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("is true for a mixed error and warning list", async () => {
|
|
49
|
+
await mountWithLint(
|
|
50
|
+
lintResponse([
|
|
51
|
+
{ severity: "warning", message: "soft" },
|
|
52
|
+
{ severity: "error", message: "nested" },
|
|
53
|
+
]),
|
|
54
|
+
);
|
|
55
|
+
expect(latest?.hasLintError).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("is true when the manual lint itself fails, matching the badge count", async () => {
|
|
59
|
+
await mountWithLint(async () => {
|
|
60
|
+
throw new Error("offline");
|
|
61
|
+
});
|
|
62
|
+
expect(latest?.lintModal).toHaveLength(1);
|
|
63
|
+
expect(latest?.hasLintError).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -96,6 +96,8 @@ export function useLintModal(projectId: string | null, refreshKey?: number) {
|
|
|
96
96
|
|
|
97
97
|
const findingsByElement = useMemo(() => groupFindings((f) => f.elementId), [groupFindings]);
|
|
98
98
|
const findingsByFile = useMemo(() => groupFindings((f) => f.file), [groupFindings]);
|
|
99
|
+
// Reads the list the badge count reads: the manual result when present, else the background one.
|
|
100
|
+
const hasLintError = (lintModal ?? backgroundFindings).some((f) => f.severity === "error");
|
|
99
101
|
|
|
100
102
|
// Sync lint findings directly to the player store — eliminates the
|
|
101
103
|
// mirroring useEffect that was previously in App.tsx.
|
|
@@ -111,5 +113,6 @@ export function useLintModal(projectId: string | null, refreshKey?: number) {
|
|
|
111
113
|
backgroundFindings,
|
|
112
114
|
findingsByElement,
|
|
113
115
|
findingsByFile,
|
|
116
|
+
hasLintError,
|
|
114
117
|
};
|
|
115
118
|
}
|
|
@@ -164,17 +164,62 @@ describe("useSdkSession unavailable telemetry", () => {
|
|
|
164
164
|
|
|
165
165
|
// Every cutover chokepoint silently takes the server path when there is no
|
|
166
166
|
// session, and the shadow never runs either — so a missing session is a total,
|
|
167
|
-
// otherwise-invisible SDK bypass. These
|
|
168
|
-
|
|
167
|
+
// otherwise-invisible SDK bypass. These exits are its only origin.
|
|
168
|
+
//
|
|
169
|
+
// `stage: read` carries a reason because it was the largest remaining class
|
|
170
|
+
// and a single opaque string: 56 users hit it in a 7-day window and never
|
|
171
|
+
// landed one successful SDK edit between them, with no way to tell a genuinely
|
|
172
|
+
// absent file from a request that never reached one.
|
|
173
|
+
it("reports the HTTP status when the read fails", async () => {
|
|
169
174
|
vi.stubGlobal(
|
|
170
175
|
"fetch",
|
|
171
|
-
vi.fn(async () => ({ ok: false, json: async () => ({}) }) as Response),
|
|
176
|
+
vi.fn(async () => ({ ok: false, status: 404, json: async () => ({}) }) as Response),
|
|
172
177
|
);
|
|
173
178
|
const root = createRoot(document.createElement("div"));
|
|
174
179
|
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
175
180
|
await flushAsyncEffects();
|
|
176
181
|
|
|
177
|
-
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
182
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
183
|
+
stage: "read",
|
|
184
|
+
reason: "http_error",
|
|
185
|
+
status: 404,
|
|
186
|
+
});
|
|
187
|
+
await act(async () => root.unmount());
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("separates an unexpected response shape from a failed request", async () => {
|
|
191
|
+
vi.stubGlobal(
|
|
192
|
+
"fetch",
|
|
193
|
+
vi.fn(async () => ({ ok: true, json: async () => ({}) }) as Response),
|
|
194
|
+
);
|
|
195
|
+
const root = createRoot(document.createElement("div"));
|
|
196
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
197
|
+
await flushAsyncEffects();
|
|
198
|
+
|
|
199
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
200
|
+
stage: "read",
|
|
201
|
+
reason: "missing_content",
|
|
202
|
+
});
|
|
203
|
+
await act(async () => root.unmount());
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// `optional=1` answers a file that is not on disk with 200 + an empty string,
|
|
207
|
+
// so this is what "the composition genuinely is not there" looks like on the
|
|
208
|
+
// wire — previously indistinguishable from a broken request. The shim and a
|
|
209
|
+
// real 0-byte file are the same response, hence the name.
|
|
210
|
+
it("separates a file that is not on disk", async () => {
|
|
211
|
+
vi.stubGlobal(
|
|
212
|
+
"fetch",
|
|
213
|
+
vi.fn(async () => ({ ok: true, json: async () => ({ content: "" }) }) as Response),
|
|
214
|
+
);
|
|
215
|
+
const root = createRoot(document.createElement("div"));
|
|
216
|
+
await act(async () => root.render(<Probe projectId="project-a" />));
|
|
217
|
+
await flushAsyncEffects();
|
|
218
|
+
|
|
219
|
+
expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", {
|
|
220
|
+
stage: "read",
|
|
221
|
+
reason: "absent_or_empty",
|
|
222
|
+
});
|
|
178
223
|
await act(async () => root.unmount());
|
|
179
224
|
});
|
|
180
225
|
|
|
@@ -7,25 +7,53 @@ import type { PublishSdkSession } from "../utils/sdkCutover";
|
|
|
7
7
|
import { addExternalFileReloadListener } from "./externalFileReloadBus";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Why an optional project-file read produced no usable content. `stage: "read"`
|
|
11
|
+
* was a single opaque reason covering all of these, which made the largest
|
|
12
|
+
* remaining class of SDK-session failures undiagnosable: 56 users in a 7-day
|
|
13
|
+
* window hit it and, between them, never landed a single successful SDK edit.
|
|
14
|
+
* Knowing which branch fired is the difference between "the file legitimately
|
|
15
|
+
* is not there" and "the request never reached the file".
|
|
16
|
+
*
|
|
17
|
+
* Every reason lives in this union so the full surface is readable from one
|
|
18
|
+
* place — `absent_or_empty` included, even though it is a 2xx.
|
|
19
|
+
*/
|
|
20
|
+
type ProjectFileReadFailure =
|
|
21
|
+
| { ok: false; reason: "unsafe_path" }
|
|
22
|
+
| { ok: false; reason: "http_error"; status: number }
|
|
23
|
+
| { ok: false; reason: "missing_content" }
|
|
24
|
+
| { ok: false; reason: "absent_or_empty" };
|
|
25
|
+
|
|
26
|
+
type ProjectFileReadResult = { ok: true; content: string } | ProjectFileReadFailure;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read a project file's content (optional read — a missing file is not an
|
|
30
|
+
* error). Replaces the removed SDK http adapter's `read()` — the only thing
|
|
31
|
+
* Studio used it for (Studio is the sole writer, so the adapter's write path
|
|
32
|
+
* was dead).
|
|
13
33
|
*/
|
|
14
34
|
async function readProjectFileOptional(
|
|
15
35
|
projectId: string,
|
|
16
36
|
path: string,
|
|
17
|
-
): Promise<
|
|
37
|
+
): Promise<ProjectFileReadResult> {
|
|
18
38
|
// Reject traversal / NUL before building the request URL — `path` is a
|
|
19
39
|
// user-influenced composition path (mirrors the guard in timelineEditingHelpers,
|
|
20
40
|
// and closes the CodeQL client-side-request-forgery flag). encodeURIComponent
|
|
21
41
|
// already confines both values to single segments of this same-origin URL.
|
|
22
|
-
if (path.includes("\0") || path.includes("..")) return
|
|
42
|
+
if (path.includes("\0") || path.includes("..")) return { ok: false, reason: "unsafe_path" };
|
|
23
43
|
const res = await fetch(
|
|
24
44
|
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
|
|
25
45
|
);
|
|
26
|
-
if (!res.ok) return
|
|
46
|
+
if (!res.ok) return { ok: false, reason: "http_error", status: res.status };
|
|
27
47
|
const data = (await res.json()) as { content?: string };
|
|
28
|
-
|
|
48
|
+
// `optional=1` answers a missing file with 200 + `content: ""`, so a
|
|
49
|
+
// non-string here means a response shape we did not expect, not absence.
|
|
50
|
+
if (typeof data.content !== "string") return { ok: false, reason: "missing_content" };
|
|
51
|
+
// An empty body parses into a session with no elements, which declines every
|
|
52
|
+
// edit wholesale — not a session worth opening. The absent-file shim and a
|
|
53
|
+
// genuinely 0-byte file are the same 200 on the wire and cannot be told
|
|
54
|
+
// apart here, hence the name; for a composition it is always the former.
|
|
55
|
+
if (data.content === "") return { ok: false, reason: "absent_or_empty" };
|
|
56
|
+
return { ok: true, content: data.content };
|
|
29
57
|
}
|
|
30
58
|
|
|
31
59
|
/**
|
|
@@ -180,16 +208,21 @@ export function useSdkSession(
|
|
|
180
208
|
};
|
|
181
209
|
|
|
182
210
|
readProjectFileOptional(projectId, activeCompPath)
|
|
183
|
-
.then(async (
|
|
211
|
+
.then(async (read) => {
|
|
184
212
|
if (cancelled) return;
|
|
185
|
-
if (
|
|
213
|
+
if (!read.ok) {
|
|
186
214
|
// No SDK session follows, so EVERY cutover chokepoint below takes the
|
|
187
215
|
// server path and emits nothing — the shadow never runs either. This
|
|
188
216
|
// is the only place a missing session can originate, so a broken read
|
|
189
217
|
// would otherwise be a silent, total SDK bypass.
|
|
190
|
-
trackStudioEvent("sdk_session_unavailable", {
|
|
218
|
+
trackStudioEvent("sdk_session_unavailable", {
|
|
219
|
+
stage: "read",
|
|
220
|
+
reason: read.reason,
|
|
221
|
+
...(read.reason === "http_error" ? { status: read.status } : {}),
|
|
222
|
+
});
|
|
191
223
|
return;
|
|
192
224
|
}
|
|
225
|
+
const content = read.content;
|
|
193
226
|
// No persist queue: Studio's writeProjectFile (via sdkCutover's
|
|
194
227
|
// persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist
|
|
195
228
|
// queue too would double-write the file (queue auto-writes on every
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { useServerConnection } from "./useServerConnection";
|
|
6
|
+
|
|
7
|
+
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
8
|
+
|
|
9
|
+
const captured: { projectId: string | null } = { projectId: null };
|
|
10
|
+
|
|
11
|
+
function Probe() {
|
|
12
|
+
captured.projectId = useServerConnection().projectId;
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function flush(): Promise<void> {
|
|
17
|
+
await act(async () => {
|
|
18
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The hash project id outlives the project it names — a renamed folder, or a
|
|
24
|
+
* bookmark from a project that is gone. It used to be trusted unconditionally,
|
|
25
|
+
* so every later /api/projects/<id>/... request 404'd for the life of the tab,
|
|
26
|
+
* including the composition read that opens the SDK session. Telemetry after
|
|
27
|
+
* the read-reason split: 120 http_error/404 reads across 5 users in 24h.
|
|
28
|
+
*/
|
|
29
|
+
function stubFetch(projectRoute: { status: number } | "reject") {
|
|
30
|
+
vi.stubGlobal(
|
|
31
|
+
"fetch",
|
|
32
|
+
vi.fn(async (url: string) => {
|
|
33
|
+
if (url === "/api/projects") {
|
|
34
|
+
return { ok: true, json: async () => ({ projects: [{ id: "real-project" }] }) } as Response;
|
|
35
|
+
}
|
|
36
|
+
if (projectRoute === "reject") throw new TypeError("Failed to fetch");
|
|
37
|
+
return { ok: projectRoute.status === 200, status: projectRoute.status } as Response;
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function renderWithHash(hash: string) {
|
|
43
|
+
window.location.hash = hash;
|
|
44
|
+
const root = createRoot(document.createElement("div"));
|
|
45
|
+
await act(async () => root.render(<Probe />));
|
|
46
|
+
await flush();
|
|
47
|
+
return root;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("useServerConnection hash project id", () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
captured.projectId = null;
|
|
53
|
+
window.location.hash = "";
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
vi.unstubAllGlobals();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("keeps a hash id the server can resolve", async () => {
|
|
61
|
+
stubFetch({ status: 200 });
|
|
62
|
+
const root = await renderWithHash("#project/stale-or-not");
|
|
63
|
+
|
|
64
|
+
expect(captured.projectId).toBe("stale-or-not");
|
|
65
|
+
await act(async () => root.unmount());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("falls back to the first project when the hash id is gone", async () => {
|
|
69
|
+
stubFetch({ status: 404 });
|
|
70
|
+
const root = await renderWithHash("#project/deleted-project");
|
|
71
|
+
|
|
72
|
+
expect(captured.projectId).toBe("real-project");
|
|
73
|
+
expect(window.location.hash).toContain("real-project");
|
|
74
|
+
await act(async () => root.unmount());
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// The important one: a blip must not rewrite the user's hash out from under a
|
|
78
|
+
// project that is actually fine. Only a definite 404 is "missing".
|
|
79
|
+
it("keeps the hash id when the check itself fails", async () => {
|
|
80
|
+
stubFetch("reject");
|
|
81
|
+
const root = await renderWithHash("#project/unreachable-check");
|
|
82
|
+
|
|
83
|
+
expect(captured.projectId).toBe("unreachable-check");
|
|
84
|
+
await act(async () => root.unmount());
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("keeps the hash id on a non-404 error status", async () => {
|
|
88
|
+
stubFetch({ status: 500 });
|
|
89
|
+
const root = await renderWithHash("#project/server-erroring");
|
|
90
|
+
|
|
91
|
+
expect(captured.projectId).toBe("server-erroring");
|
|
92
|
+
await act(async () => root.unmount());
|
|
93
|
+
});
|
|
94
|
+
});
|