@hyperframes/studio 0.7.106 → 0.7.108
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-CvVcx_CV.js → hyperframes-player-QqavTWRK.js} +1 -1
- package/dist/assets/{index-CRzCCuPH.js → index-BQjXA1pz.js} +1 -1
- package/dist/assets/{index-BbYg_isc.js → index-BhHHMprP.js} +1 -1
- package/dist/assets/index-DNkh9mbV.css +1 -0
- package/dist/assets/index-GqONLcOl.js +428 -0
- package/dist/{chunk-OBAG3GWK.js → chunk-AZYHQC6V.js} +6 -7
- package/dist/chunk-AZYHQC6V.js.map +1 -0
- package/dist/{domEditingLayers-AT7G6F4L.js → domEditingLayers-7AMZ7GFI.js} +4 -2
- package/dist/index.d.ts +86 -3
- package/dist/index.html +2 -2
- package/dist/index.js +24325 -17831
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/EditorShell.tsx +0 -2
- package/src/components/StudioErrorBoundary.test.tsx +97 -0
- package/src/components/StudioErrorBoundary.tsx +7 -0
- package/src/components/StudioOverlays.tsx +15 -13
- package/src/components/StudioRightPanel.tsx +18 -1
- package/src/components/TimelineToolbar.test.tsx +46 -0
- package/src/components/TimelineToolbar.tsx +17 -0
- package/src/components/editor/DomEditOverlay.tsx +36 -18
- package/src/components/editor/DomEditSelectionChrome.test.tsx +85 -0
- package/src/components/editor/DomEditSelectionChrome.tsx +29 -3
- package/src/components/editor/InlineTextToolbar.test.tsx +296 -0
- package/src/components/editor/InlineTextToolbar.tsx +281 -0
- package/src/components/editor/OffCanvasIndicators.tsx +38 -0
- package/src/components/editor/PropertyPanel.test.tsx +5 -1
- package/src/components/editor/PropertyPanel.tsx +7 -23
- package/src/components/editor/PropertyPanelFlat.tsx +25 -0
- package/src/components/editor/audioFxSummary.test.ts +75 -0
- package/src/components/editor/audioFxSummary.ts +36 -0
- package/src/components/editor/audioFxTelemetry.test.ts +129 -0
- package/src/components/editor/audioFxTelemetry.ts +230 -0
- package/src/components/editor/domEditInlineText.test.ts +86 -0
- package/src/components/editor/domEditInlineText.ts +99 -0
- package/src/components/editor/domEditInlineTextElement.test.ts +59 -0
- package/src/components/editor/domEditing.ts +1 -0
- package/src/components/editor/domEditingLayers.ts +7 -6
- package/src/components/editor/inlineTextStyleRange.test.ts +743 -0
- package/src/components/editor/inlineTextStyleRange.ts +593 -0
- package/src/components/editor/inlineTextStyleRead.ts +47 -0
- package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +1830 -0
- package/src/components/editor/propertyPanelAudioFxGroup.tsx +299 -0
- package/src/components/editor/propertyPanelAudioFxGroupUtils.ts +21 -0
- package/src/components/editor/propertyPanelAutomation.test.ts +60 -0
- package/src/components/editor/propertyPanelAutomation.ts +97 -0
- package/src/components/editor/propertyPanelFlatMediaSection.tsx +37 -9
- package/src/components/editor/propertyPanelFlatProps.ts +1 -0
- package/src/components/editor/propertyPanelFxAddMenu.tsx +209 -0
- package/src/components/editor/propertyPanelFxBandRuler.tsx +72 -0
- package/src/components/editor/propertyPanelFxCarveModule.tsx +382 -0
- package/src/components/editor/propertyPanelFxControls.test.tsx +115 -0
- package/src/components/editor/propertyPanelFxControls.tsx +356 -0
- package/src/components/editor/propertyPanelFxEqModule.tsx +196 -0
- package/src/components/editor/propertyPanelFxFamily.ts +83 -0
- package/src/components/editor/propertyPanelFxNodeOpenBody.tsx +269 -0
- package/src/components/editor/propertyPanelFxNodeParams.tsx +112 -0
- package/src/components/editor/propertyPanelFxNodeRow.tsx +339 -0
- package/src/components/editor/propertyPanelFxPresetMenu.tsx +159 -0
- package/src/components/editor/propertyPanelFxPresetRun.tsx +215 -0
- package/src/components/editor/propertyPanelFxPresetStyle.test.ts +142 -0
- package/src/components/editor/propertyPanelFxPresetStyle.ts +216 -0
- package/src/components/editor/propertyPanelFxRackChain.tsx +202 -0
- package/src/components/editor/propertyPanelFxSection.test.tsx +1747 -0
- package/src/components/editor/propertyPanelFxSection.tsx +550 -0
- package/src/components/editor/propertyPanelFxSectionTypes.ts +91 -0
- package/src/components/editor/propertyPanelTypes.ts +5 -0
- package/src/components/editor/useDomEditNudge.ts +2 -2
- package/src/components/editor/useFxAudition.ts +95 -0
- package/src/components/editor/useFxCarve.ts +0 -0
- package/src/components/editor/useFxChainObserved.ts +83 -0
- package/src/components/editor/useFxLevelling.ts +235 -0
- package/src/components/editor/useInlineTextEditing.tsx +119 -0
- package/src/components/editor/useVolumeAutomation.test.tsx +122 -0
- package/src/components/editor/useVolumeAutomation.ts +54 -0
- package/src/components/feedback/CrashFeedbackPrompt.tsx +27 -0
- package/src/components/feedback/StudioFeedbackCard.tsx +373 -0
- package/src/components/feedback/feedbackTrigger.test.ts +168 -0
- package/src/components/feedback/feedbackTrigger.ts +269 -0
- package/src/components/feedback/projectProvenance.test.ts +120 -0
- package/src/components/feedback/projectProvenance.ts +83 -0
- package/src/components/renders/useRenderQueue.ts +62 -4
- package/src/components/storyboard/StoryboardFrameFocus.tsx +2 -2
- package/src/components/storyboard/StoryboardViewModeGuard.test.tsx +19 -2
- package/src/contexts/DomEditContext.tsx +15 -0
- package/src/hooks/domEditPersistFailure.ts +0 -7
- package/src/hooks/useAppHotkeys.test.ts +198 -0
- package/src/hooks/useAppHotkeys.ts +31 -4
- package/src/hooks/useAutomationSelectionKeyboard.test.tsx +429 -0
- package/src/hooks/useAutomationSelectionKeyboard.ts +371 -0
- package/src/hooks/useDomEditAttributeCommits.ts +90 -10
- package/src/hooks/useDomEditCommits.test.tsx +141 -21
- package/src/hooks/useDomEditCommits.ts +4 -0
- package/src/hooks/useDomEditSession.ts +4 -0
- package/src/hooks/useDomEditTextCommits.ts +112 -11
- package/src/hooks/useElementLifecycleOps.ts +3 -0
- package/src/hooks/useFileTree.ts +8 -3
- package/src/hooks/useInlineTextEdit.test.tsx +555 -0
- package/src/hooks/useInlineTextEdit.ts +320 -0
- package/src/hooks/useLivePlayheadTime.ts +49 -0
- package/src/hooks/usePreviewPersistence.ts +7 -0
- package/src/hooks/useTimelineEditing.ts +3 -0
- package/src/player/components/AutomationSelectionMenu.tsx +76 -0
- package/src/player/components/AutomationValueInput.tsx +51 -0
- package/src/player/components/LayerDisclosureRow.tsx +5 -4
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/TimelineAutomationLane.test.tsx +1720 -0
- package/src/player/components/TimelineAutomationLane.tsx +674 -0
- package/src/player/components/TimelineAutomationLaneSlot.test.tsx +193 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -5
- package/src/player/components/TimelineLanes.tsx +142 -154
- package/src/player/components/TimelineTrackHeader.test.tsx +212 -1
- package/src/player/components/TimelineTrackHeader.tsx +166 -6
- package/src/player/components/TimelineTrackRow.tsx +8 -1
- package/src/player/components/automationClipboard.test.ts +94 -0
- package/src/player/components/automationClipboard.ts +136 -0
- package/src/player/components/automationGestureKeys.test.ts +49 -0
- package/src/player/components/automationGestureKeys.ts +49 -0
- package/src/player/components/automationLaneData.test.ts +270 -0
- package/src/player/components/automationLaneData.ts +258 -0
- package/src/player/components/automationLaneDragMath.ts +194 -0
- package/src/player/components/automationLaneGeometry.test.ts +239 -0
- package/src/player/components/automationLaneGeometry.ts +287 -0
- package/src/player/components/automationLaneHeight.ts +15 -0
- package/src/player/components/automationLanePointer.ts +20 -0
- package/src/player/components/automationLaneSelection.test.ts +183 -0
- package/src/player/components/automationLaneSelection.ts +135 -0
- package/src/player/components/automationShapes.test.ts +82 -0
- package/src/player/components/automationShapes.ts +70 -0
- package/src/player/components/automationSimplify.test.ts +48 -0
- package/src/player/components/automationSimplify.ts +51 -0
- package/src/player/components/timelineClipGestureHandlers.ts +223 -0
- package/src/player/components/timelineLaneProps.ts +19 -0
- package/src/player/components/timelineLayout.ts +11 -5
- package/src/player/components/useAutomationEdgeStretch.test.ts +27 -0
- package/src/player/components/useAutomationEdgeStretch.ts +269 -0
- package/src/player/components/useAutomationLaneGestures.ts +537 -0
- package/src/player/components/useAutomationLanes.test.tsx +112 -0
- package/src/player/components/useAutomationLanes.ts +170 -0
- package/src/player/components/useAutomationRangeDrag.ts +114 -0
- package/src/player/components/useTimelineTrackLayout.test.ts +107 -1
- package/src/player/components/useTimelineTrackLayout.ts +83 -7
- package/src/player/hooks/useExpandedTimelineElements.ts +7 -0
- package/src/player/hooks/useTimelinePlayer.ts +13 -1
- package/src/player/lib/automationStoreSync.test.ts +83 -0
- package/src/player/lib/automationStoreSync.ts +56 -0
- package/src/player/lib/playbackShortcuts.ts +5 -4
- package/src/player/lib/timelineDOM.test.ts +33 -0
- package/src/player/lib/timelineDOM.ts +12 -0
- package/src/player/store/automationSelectionSlice.test.ts +30 -0
- package/src/player/store/automationSelectionSlice.ts +54 -0
- package/src/player/store/playerStore.test.ts +15 -0
- package/src/player/store/playerStore.ts +54 -65
- package/src/player/store/timelineElement.ts +78 -0
- package/src/styles/studio.css +89 -0
- package/src/telemetry/agentRuntime.test.ts +45 -0
- package/src/telemetry/agentRuntime.ts +62 -0
- package/src/telemetry/breadcrumbs.test.ts +68 -0
- package/src/telemetry/breadcrumbs.ts +70 -0
- package/src/telemetry/client.ts +5 -0
- package/src/telemetry/events.ts +84 -3
- package/src/telemetry/system.ts +8 -0
- package/src/utils/sourcePatcher.ts +5 -1
- package/src/utils/studioFileHistory.test.ts +49 -0
- package/src/utils/studioFileHistory.ts +17 -1
- package/src/utils/studioHelpers.ts +2 -5
- package/src/utils/studioTelemetry.ts +16 -10
- package/src/utils/timelineDiscovery.ts +3 -24
- package/src/utils/typingTarget.test.ts +54 -0
- package/src/utils/typingTarget.ts +43 -0
- package/dist/assets/index-DerI0ikN.js +0 -428
- package/dist/assets/index-tBPidglp.css +0 -1
- package/dist/chunk-OBAG3GWK.js.map +0 -1
- package/src/components/StudioFeedbackBar.tsx +0 -217
- /package/dist/{domEditingLayers-AT7G6F4L.js.map → domEditingLayers-7AMZ7GFI.js.map} +0 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The volume lane's state and edits for the media section.
|
|
3
|
+
*
|
|
4
|
+
* Volume lives in a different panel section from the FX chain, but is automated
|
|
5
|
+
* the same way, so it reads and writes through the same helper the FX group uses
|
|
6
|
+
* rather than a second interpretation of the attribute.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { HF_AUDIO_AUTOMATION_DATA_KEY, VOLUME_TARGET } from "@hyperframes/core/audio-automation";
|
|
10
|
+
import type { DomEditSelection } from "./domEditingTypes";
|
|
11
|
+
import {
|
|
12
|
+
automationAttrValue,
|
|
13
|
+
HF_AUDIO_AUTOMATION_ATTR,
|
|
14
|
+
readPanelAutomation,
|
|
15
|
+
withoutLane,
|
|
16
|
+
withSeededLane,
|
|
17
|
+
} from "./propertyPanelAutomation";
|
|
18
|
+
|
|
19
|
+
export interface VolumeAutomationBinding {
|
|
20
|
+
volumeAutomated: boolean;
|
|
21
|
+
onAutomateVolume: () => void;
|
|
22
|
+
onRemoveVolumeAutomation: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useVolumeAutomation(
|
|
26
|
+
element: DomEditSelection,
|
|
27
|
+
onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise<void>,
|
|
28
|
+
): VolumeAutomationBinding {
|
|
29
|
+
// The chain is not needed to resolve a volume lane — volume is always a valid
|
|
30
|
+
// target — so this deliberately does not parse it.
|
|
31
|
+
const automation = readPanelAutomation(
|
|
32
|
+
element.dataAttributes?.[HF_AUDIO_AUTOMATION_DATA_KEY],
|
|
33
|
+
undefined,
|
|
34
|
+
);
|
|
35
|
+
const write = (next: Parameters<typeof automationAttrValue>[0]): void => {
|
|
36
|
+
// Quiet: clicking the toggle used to reload the preview and restart every
|
|
37
|
+
// playing track, while the same click on an effect parameter did not.
|
|
38
|
+
void onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(next) || null);
|
|
39
|
+
};
|
|
40
|
+
// `??` alone would let an empty `data-volume` through as Number("") === 0, so
|
|
41
|
+
// automating the track would seed its lane at silence. The engine reads the same
|
|
42
|
+
// empty value as unity.
|
|
43
|
+
const raw = element.dataAttributes?.["volume"];
|
|
44
|
+
const parsed = raw ? Number(raw) : 1;
|
|
45
|
+
const current = Number.isFinite(parsed) ? parsed : 1;
|
|
46
|
+
return {
|
|
47
|
+
volumeAutomated: automation.lanes.some((lane) => lane.target === VOLUME_TARGET),
|
|
48
|
+
// Seeded at the level the slider already shows, so automating the track does
|
|
49
|
+
// not change how loud it is.
|
|
50
|
+
onAutomateVolume: () =>
|
|
51
|
+
write(withSeededLane(automation, VOLUME_TARGET, Number.isFinite(current) ? current : 1)),
|
|
52
|
+
onRemoveVolumeAutomation: () => write(withoutLane(automation, VOLUME_TARGET)),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { StudioFeedbackCard } from "./StudioFeedbackCard";
|
|
3
|
+
import { requestStudioFeedback } from "./feedbackTrigger";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The feedback prompt for the crash screen.
|
|
7
|
+
*
|
|
8
|
+
* A crash unmounts the whole editor, and with it the card that normally lives
|
|
9
|
+
* in the overlay stack, so the request cannot come from there. The error
|
|
10
|
+
* boundary's fallback renders this instead: the card mounts and subscribes
|
|
11
|
+
* first (child effects run before the parent's), then the effect below asks.
|
|
12
|
+
*
|
|
13
|
+
* The crash screen is the one place a user is guaranteed to have an opinion
|
|
14
|
+
* and nothing else to do with it. Everything else about the ask is unchanged —
|
|
15
|
+
* same eligibility, same cooldowns — so a crash cannot be used to talk to
|
|
16
|
+
* someone who already said their piece this session.
|
|
17
|
+
*/
|
|
18
|
+
export function CrashFeedbackPrompt() {
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
// No `detail`: the crash screen already prints the error message directly
|
|
21
|
+
// above this card, and the crash event already carries it. Passing it here
|
|
22
|
+
// would quote the same sentence back to the user twice.
|
|
23
|
+
requestStudioFeedback({ reason: "crash" });
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
return <StudioFeedbackCard />;
|
|
27
|
+
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { memo, useState, useCallback, useRef, useEffect } from "react";
|
|
2
|
+
import {
|
|
3
|
+
trackStudioFeedback,
|
|
4
|
+
trackStudioFeedbackShown,
|
|
5
|
+
trackStudioFeedbackDismissed,
|
|
6
|
+
trackStudioFeedbackInterviewClick,
|
|
7
|
+
} from "../../telemetry/events";
|
|
8
|
+
import {
|
|
9
|
+
subscribeToFeedbackRequests,
|
|
10
|
+
markFeedbackAnswered,
|
|
11
|
+
markFeedbackDismissed,
|
|
12
|
+
followUpFor,
|
|
13
|
+
isProblemReason,
|
|
14
|
+
type FeedbackRequest,
|
|
15
|
+
type FollowUpQuestion,
|
|
16
|
+
} from "./feedbackTrigger";
|
|
17
|
+
import { projectProvenance } from "./projectProvenance";
|
|
18
|
+
|
|
19
|
+
/** A failed render is its own question; nothing is rotated in front of it. */
|
|
20
|
+
const FAILURE_FOLLOW_UP: FollowUpQuestion = {
|
|
21
|
+
id: "failure",
|
|
22
|
+
prompt: "That export failed.",
|
|
23
|
+
placeholder: "What were you exporting?",
|
|
24
|
+
presets: [
|
|
25
|
+
{ label: "Happens every time", hint: "Every export of this project fails this way" },
|
|
26
|
+
{ label: "First time", hint: "Exports normally work for me" },
|
|
27
|
+
{ label: "Long composition", hint: "Over a minute, or a lot of scenes" },
|
|
28
|
+
{ label: "Big media files", hint: "Large video or image assets in the project" },
|
|
29
|
+
{ label: "Has audio", hint: "The composition includes voiceover, music or sound" },
|
|
30
|
+
{ label: "Embedded video", hint: "One or more video clips inside the composition" },
|
|
31
|
+
],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The crash screen ask. "What were you doing" beats "what went wrong": the
|
|
36
|
+
* user cannot see the stack trace, but they know exactly what they touched,
|
|
37
|
+
* and that is the half the crash report is missing.
|
|
38
|
+
*/
|
|
39
|
+
const CRASH_FOLLOW_UP: FollowUpQuestion = {
|
|
40
|
+
id: "crash",
|
|
41
|
+
prompt: "Studio crashed. What were you doing?",
|
|
42
|
+
placeholder: "The last thing you touched",
|
|
43
|
+
presets: [
|
|
44
|
+
{ label: "Editing the timeline", hint: "Dragging, trimming or splitting clips" },
|
|
45
|
+
{ label: "Playing the preview", hint: "Scrubbing or playing back the composition" },
|
|
46
|
+
{ label: "Adding media", hint: "Importing video, images or audio" },
|
|
47
|
+
{ label: "Editing properties", hint: "Changing styles, transforms or keyframes" },
|
|
48
|
+
{ label: "Exporting", hint: "Starting or watching a render" },
|
|
49
|
+
{ label: "Just opened it", hint: "It broke before I did anything" },
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Long enough to read the render result first; short enough to stay polite. */
|
|
54
|
+
const AUTO_DISMISS_MS = 30_000;
|
|
55
|
+
/** Matches the .hf-toast-exit animation so the node leaves as it finishes. */
|
|
56
|
+
const EXIT_MS = 160;
|
|
57
|
+
/**
|
|
58
|
+
* The thanks state carries the interview link, so it has to outlast a glance.
|
|
59
|
+
* A 1.4s confirmation would flash a booking link nobody could reach.
|
|
60
|
+
*/
|
|
61
|
+
const THANKS_MS = 10_000;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Someone who just answered is the likeliest person in the product to say yes
|
|
65
|
+
* to a call, and this is the only moment we have their attention with goodwill.
|
|
66
|
+
*/
|
|
67
|
+
const INTERVIEW_URL = "https://calendar.app.google/yRHT7oPsHWcqFfFv5";
|
|
68
|
+
|
|
69
|
+
const RATINGS = Array.from({ length: 11 }, (_, n) => n);
|
|
70
|
+
|
|
71
|
+
type Step = "rating" | "comment" | "thanks";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The Studio feedback prompt. Shown by `feedbackTrigger` after a render
|
|
75
|
+
* finishes or fails — never on a timer, so the user always knows what they are
|
|
76
|
+
* being asked about.
|
|
77
|
+
*
|
|
78
|
+
* A failed render skips the 0-10 scale: scoring an export you never got is a
|
|
79
|
+
* question with no useful answer, and the free-text reply is the whole point.
|
|
80
|
+
*/
|
|
81
|
+
// fallow-ignore-next-line complexity
|
|
82
|
+
export const StudioFeedbackCard = memo(function StudioFeedbackCard() {
|
|
83
|
+
const [request, setRequest] = useState<FeedbackRequest | null>(null);
|
|
84
|
+
const [step, setStep] = useState<Step>("rating");
|
|
85
|
+
const [rating, setRating] = useState<number | null>(null);
|
|
86
|
+
const [followUp, setFollowUp] = useState<FollowUpQuestion>(FAILURE_FOLLOW_UP);
|
|
87
|
+
const [comment, setComment] = useState("");
|
|
88
|
+
/** Explanation of the chip under the pointer, shown on the reserved line. */
|
|
89
|
+
const [hint, setHint] = useState<string | null>(null);
|
|
90
|
+
const [exiting, setExiting] = useState(false);
|
|
91
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
92
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
93
|
+
// Read inside close(), which must not re-subscribe every keystroke.
|
|
94
|
+
const stateRef = useRef({ request, rating, step });
|
|
95
|
+
stateRef.current = { request, rating, step };
|
|
96
|
+
|
|
97
|
+
const clearTimer = useCallback(() => {
|
|
98
|
+
if (timerRef.current) {
|
|
99
|
+
clearTimeout(timerRef.current);
|
|
100
|
+
timerRef.current = null;
|
|
101
|
+
}
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
const close = useCallback(
|
|
105
|
+
(via: "close" | "escape" | "timeout" | "sent") => {
|
|
106
|
+
const { request: req, rating: picked, step: at } = stateRef.current;
|
|
107
|
+
if (!req) return;
|
|
108
|
+
clearTimer();
|
|
109
|
+
if (via === "sent") {
|
|
110
|
+
markFeedbackAnswered();
|
|
111
|
+
} else {
|
|
112
|
+
markFeedbackDismissed();
|
|
113
|
+
trackStudioFeedbackDismissed({
|
|
114
|
+
reason: req.reason,
|
|
115
|
+
render_id: req.renderId,
|
|
116
|
+
via,
|
|
117
|
+
had_rating: picked !== null || at === "comment",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
setExiting(true);
|
|
121
|
+
setTimeout(() => {
|
|
122
|
+
setRequest(null);
|
|
123
|
+
setExiting(false);
|
|
124
|
+
setRating(null);
|
|
125
|
+
setComment("");
|
|
126
|
+
setStep("rating");
|
|
127
|
+
}, EXIT_MS);
|
|
128
|
+
},
|
|
129
|
+
[clearTimer],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
useEffect(
|
|
133
|
+
() =>
|
|
134
|
+
subscribeToFeedbackRequests((next) => {
|
|
135
|
+
setRequest(next);
|
|
136
|
+
setRating(null);
|
|
137
|
+
setComment("");
|
|
138
|
+
setHint(null);
|
|
139
|
+
setFollowUp(next.reason === "crash" ? CRASH_FOLLOW_UP : FAILURE_FOLLOW_UP);
|
|
140
|
+
// A crash or a failed render has nothing to score, so those open
|
|
141
|
+
// straight at the ask instead of demanding a number first.
|
|
142
|
+
setStep(isProblemReason(next.reason) ? "comment" : "rating");
|
|
143
|
+
trackStudioFeedbackShown({ reason: next.reason, render_id: next.renderId });
|
|
144
|
+
}),
|
|
145
|
+
[],
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Auto-dismiss only while the prompt is still untouched — once the user
|
|
149
|
+
// starts typing or picks a score, the card is theirs until they close it.
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (!request || step === "thanks" || rating !== null || comment !== "") return;
|
|
152
|
+
timerRef.current = setTimeout(() => close("timeout"), AUTO_DISMISS_MS);
|
|
153
|
+
return clearTimer;
|
|
154
|
+
}, [request, step, rating, comment, close, clearTimer]);
|
|
155
|
+
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (step === "comment") inputRef.current?.focus();
|
|
158
|
+
}, [step]);
|
|
159
|
+
|
|
160
|
+
/** `preset` is the tapped chip's text; absent means the field was typed in. */
|
|
161
|
+
const submit = useCallback(
|
|
162
|
+
(preset?: string) => {
|
|
163
|
+
const req = stateRef.current.request;
|
|
164
|
+
if (!req) return;
|
|
165
|
+
const text = (preset ?? comment).trim();
|
|
166
|
+
// Nothing to send yet: a failure prompt with no words is just a dismiss.
|
|
167
|
+
if (rating === null && text === "") return close("close");
|
|
168
|
+
trackStudioFeedback({
|
|
169
|
+
reason: req.reason,
|
|
170
|
+
render_id: req.renderId,
|
|
171
|
+
// Which follow-up this comment answers. Without it, "nothing" against
|
|
172
|
+
// "what would you cut?" and "nothing" against "what should we fix?" are
|
|
173
|
+
// the same row, and neither means anything.
|
|
174
|
+
question: followUp.id,
|
|
175
|
+
// Tapped chips cluster into countable buckets; typed answers do not.
|
|
176
|
+
// Mixing them would make a chip's popularity look like a written theme.
|
|
177
|
+
answer_kind: preset === undefined ? "typed" : "preset",
|
|
178
|
+
// Provenance first so a producer's own context wins on a key clash.
|
|
179
|
+
context: { ...projectProvenance(), ...req.context },
|
|
180
|
+
...(rating === null ? {} : { rating }),
|
|
181
|
+
...(text ? { comment: text } : {}),
|
|
182
|
+
});
|
|
183
|
+
clearTimer();
|
|
184
|
+
setStep("thanks");
|
|
185
|
+
setTimeout(() => close("sent"), THANKS_MS);
|
|
186
|
+
},
|
|
187
|
+
[rating, comment, followUp.id, close, clearTimer],
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const pickRating = useCallback((n: number) => {
|
|
191
|
+
setRating(n);
|
|
192
|
+
setFollowUp(followUpFor(n));
|
|
193
|
+
setStep("comment");
|
|
194
|
+
}, []);
|
|
195
|
+
|
|
196
|
+
if (!request) return null;
|
|
197
|
+
|
|
198
|
+
const failed = isProblemReason(request.reason);
|
|
199
|
+
// Deliberately the CLI's question, word for word. Studio and CLI ratings only
|
|
200
|
+
// sit on one axis if they asked the same thing; what makes them comparable is
|
|
201
|
+
// `reason`, which records the moment, not a reworded prompt.
|
|
202
|
+
const title =
|
|
203
|
+
step === "rating" ? "How likely are you to recommend HyperFrames?" : followUp.prompt;
|
|
204
|
+
|
|
205
|
+
return (
|
|
206
|
+
<div
|
|
207
|
+
role="dialog"
|
|
208
|
+
aria-label="Send feedback to the HyperFrames team"
|
|
209
|
+
className={`motion-reduce:animate-none ${exiting ? "hf-toast-exit" : "hf-toast-enter"}`}
|
|
210
|
+
onKeyDown={(e) => {
|
|
211
|
+
if (e.key === "Escape") close("escape");
|
|
212
|
+
}}
|
|
213
|
+
>
|
|
214
|
+
<div
|
|
215
|
+
className="flex w-[min(340px,calc(100vw-48px))] flex-col gap-2.5 overflow-hidden rounded-2xl px-3.5 py-3"
|
|
216
|
+
style={{
|
|
217
|
+
background: failed
|
|
218
|
+
? "linear-gradient(135deg, rgba(127,29,29,0.55), rgba(80,10,10,0.45))"
|
|
219
|
+
: "linear-gradient(135deg, rgba(38,38,38,0.55), rgba(23,23,23,0.45))",
|
|
220
|
+
backdropFilter: "blur(16px) saturate(1.6)",
|
|
221
|
+
WebkitBackdropFilter: "blur(16px) saturate(1.6)",
|
|
222
|
+
border: `1px solid ${failed ? "rgba(239,68,68,0.18)" : "rgba(255,255,255,0.08)"}`,
|
|
223
|
+
boxShadow: [
|
|
224
|
+
"0 8px 32px rgba(0,0,0,0.35)",
|
|
225
|
+
`inset 0 1px 0 ${failed ? "rgba(239,68,68,0.12)" : "rgba(255,255,255,0.06)"}`,
|
|
226
|
+
"inset 0 -1px 0 rgba(0,0,0,0.15)",
|
|
227
|
+
].join(", "),
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
230
|
+
<div className="flex items-start gap-3">
|
|
231
|
+
<p
|
|
232
|
+
className={`min-w-0 flex-1 text-[12px] leading-5 ${failed ? "text-red-100" : "text-neutral-100"}`}
|
|
233
|
+
>
|
|
234
|
+
{step === "thanks" ? "Thanks, that helps." : title}
|
|
235
|
+
</p>
|
|
236
|
+
{step === "thanks" && (
|
|
237
|
+
<a
|
|
238
|
+
href={INTERVIEW_URL}
|
|
239
|
+
target="_blank"
|
|
240
|
+
rel="noopener noreferrer"
|
|
241
|
+
onClick={() => {
|
|
242
|
+
const req = stateRef.current.request;
|
|
243
|
+
if (req) trackStudioFeedbackInterviewClick({ reason: req.reason });
|
|
244
|
+
close("sent");
|
|
245
|
+
}}
|
|
246
|
+
className="h-6 flex-shrink-0 whitespace-nowrap rounded-full border border-white/20 bg-white/[0.08] px-2.5 text-[11px] leading-6 text-neutral-100 transition-[background-color,border-color,transform] duration-150 ease-out hover:border-white/35 hover:bg-white/[0.16] active:scale-[0.97] motion-reduce:transition-none"
|
|
247
|
+
>
|
|
248
|
+
Talk to us, 30 min
|
|
249
|
+
</a>
|
|
250
|
+
)}
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
onClick={() => close("close")}
|
|
254
|
+
className="-mr-1 -mt-0.5 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors duration-150 hover:bg-white/10 hover:text-neutral-200"
|
|
255
|
+
aria-label="Dismiss"
|
|
256
|
+
>
|
|
257
|
+
<svg
|
|
258
|
+
width="10"
|
|
259
|
+
height="10"
|
|
260
|
+
viewBox="0 0 10 10"
|
|
261
|
+
fill="none"
|
|
262
|
+
stroke="currentColor"
|
|
263
|
+
strokeWidth="1.5"
|
|
264
|
+
>
|
|
265
|
+
<path d="M2 2l6 6M8 2l-6 6" />
|
|
266
|
+
</svg>
|
|
267
|
+
</button>
|
|
268
|
+
</div>
|
|
269
|
+
|
|
270
|
+
{/* The failure text the user already saw, quoted back so the report
|
|
271
|
+
carries it without asking them to retype the error. */}
|
|
272
|
+
{failed && request.detail && step !== "thanks" && (
|
|
273
|
+
<p className="line-clamp-2 text-[11px] leading-4 text-red-200/70">{request.detail}</p>
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
{step === "rating" && (
|
|
277
|
+
<>
|
|
278
|
+
{/* Native radios: free arrow-key navigation, grouping and labels. */}
|
|
279
|
+
<fieldset className="grid grid-cols-11 gap-[3px]">
|
|
280
|
+
<legend className="sr-only">Rate this export from 0 to 10</legend>
|
|
281
|
+
{RATINGS.map((n) => (
|
|
282
|
+
<label
|
|
283
|
+
key={n}
|
|
284
|
+
// A resting fill, so the row reads as eleven controls rather
|
|
285
|
+
// than as a line of text that happens to be clickable.
|
|
286
|
+
className="flex h-7 cursor-pointer items-center justify-center rounded-md bg-white/[0.04] text-[11px] tabular-nums text-neutral-400 transition-[background-color,color,transform] duration-150 ease-out hover:bg-white/[0.14] hover:text-neutral-100 active:scale-[0.97] has-[:checked]:bg-white/90 has-[:checked]:text-neutral-900 has-[:focus-visible]:ring-1 has-[:focus-visible]:ring-white/50 motion-reduce:transition-none"
|
|
287
|
+
>
|
|
288
|
+
<input
|
|
289
|
+
type="radio"
|
|
290
|
+
name="hf-studio-feedback-rating"
|
|
291
|
+
value={n}
|
|
292
|
+
checked={rating === n}
|
|
293
|
+
onChange={() => pickRating(n)}
|
|
294
|
+
className="sr-only"
|
|
295
|
+
/>
|
|
296
|
+
{n}
|
|
297
|
+
</label>
|
|
298
|
+
))}
|
|
299
|
+
</fieldset>
|
|
300
|
+
<div className="flex justify-between text-[10px] leading-none text-neutral-500">
|
|
301
|
+
<span>Not likely</span>
|
|
302
|
+
<span>Extremely likely</span>
|
|
303
|
+
</div>
|
|
304
|
+
</>
|
|
305
|
+
)}
|
|
306
|
+
|
|
307
|
+
{step === "comment" && (
|
|
308
|
+
<>
|
|
309
|
+
{/* One tap is the whole answer. Sends immediately: making someone
|
|
310
|
+
tap a chip and then hunt for Send throws away the reason chips
|
|
311
|
+
exist. The field below stays for anything the chips miss. */}
|
|
312
|
+
<div className="flex flex-wrap gap-1.5">
|
|
313
|
+
{followUp.presets.map((preset) => (
|
|
314
|
+
<button
|
|
315
|
+
key={preset.label}
|
|
316
|
+
type="button"
|
|
317
|
+
onClick={() => submit(preset.label)}
|
|
318
|
+
onPointerEnter={() => setHint(preset.hint)}
|
|
319
|
+
onPointerLeave={() => setHint(null)}
|
|
320
|
+
onFocus={() => setHint(preset.hint)}
|
|
321
|
+
onBlur={() => setHint(null)}
|
|
322
|
+
// The hint reaches a screen reader through the label, since
|
|
323
|
+
// it never gets the hover that reveals it visually.
|
|
324
|
+
aria-label={`${preset.label}. ${preset.hint}`}
|
|
325
|
+
// Hover has to be unmistakable, not a shade brighter: the
|
|
326
|
+
// chip under the pointer is the one the hint line is talking
|
|
327
|
+
// about, so if you cannot tell which is hovered, the hint is
|
|
328
|
+
// attached to nothing.
|
|
329
|
+
className={`h-6 rounded-full border px-2.5 text-[11px] transition-[background-color,border-color,color,transform] duration-150 ease-out active:scale-[0.97] motion-reduce:transition-none ${
|
|
330
|
+
failed
|
|
331
|
+
? "border-red-300/20 bg-red-100/[0.06] text-red-100/80 hover:border-red-200/70 hover:bg-red-100/25 hover:text-white"
|
|
332
|
+
: "border-white/10 bg-white/[0.04] text-neutral-300 hover:border-white/60 hover:bg-white/25 hover:text-white"
|
|
333
|
+
}`}
|
|
334
|
+
>
|
|
335
|
+
{preset.label}
|
|
336
|
+
</button>
|
|
337
|
+
))}
|
|
338
|
+
</div>
|
|
339
|
+
{/* Inline, not a floating tooltip. The card is 340px in a corner,
|
|
340
|
+
so a bubble above the chips lands on top of the question and a
|
|
341
|
+
bubble below lands on the input. One reserved line occludes
|
|
342
|
+
nothing and needs no hover delay to be readable. */}
|
|
343
|
+
<p className="h-3.5 truncate text-[10.5px] leading-[14px] text-neutral-500">
|
|
344
|
+
{hint ?? "Tap one, or write your own."}
|
|
345
|
+
</p>
|
|
346
|
+
<div className="flex items-center gap-2">
|
|
347
|
+
<input
|
|
348
|
+
ref={inputRef}
|
|
349
|
+
type="text"
|
|
350
|
+
value={comment}
|
|
351
|
+
onChange={(e) => setComment(e.target.value)}
|
|
352
|
+
onKeyDown={(e) => {
|
|
353
|
+
if (e.key === "Enter") submit();
|
|
354
|
+
}}
|
|
355
|
+
placeholder={followUp.placeholder}
|
|
356
|
+
aria-label={followUp.prompt}
|
|
357
|
+
maxLength={500}
|
|
358
|
+
className="h-7 min-w-0 flex-1 rounded-md border border-white/10 bg-black/20 px-2 text-[11px] text-neutral-100 outline-none transition-colors duration-150 placeholder:text-neutral-500 focus:border-white/25"
|
|
359
|
+
/>
|
|
360
|
+
<button
|
|
361
|
+
type="button"
|
|
362
|
+
onClick={() => submit()}
|
|
363
|
+
className="h-7 flex-shrink-0 rounded-md bg-white/10 px-2.5 text-[11px] text-neutral-100 transition-[background-color,transform] duration-150 ease-out hover:bg-white/[0.16] active:scale-[0.97] motion-reduce:transition-none"
|
|
364
|
+
>
|
|
365
|
+
Send
|
|
366
|
+
</button>
|
|
367
|
+
</div>
|
|
368
|
+
</>
|
|
369
|
+
)}
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
|
4
|
+
|
|
5
|
+
// The policy module decides whether this profile may be measured at all; the
|
|
6
|
+
// trigger refuses to ask anyone it cannot hear, so every test here has to say
|
|
7
|
+
// which of those two worlds it is in.
|
|
8
|
+
vi.mock("../../telemetry/policy", () => ({
|
|
9
|
+
browserTelemetryAllowed: () => telemetryAllowed,
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
let telemetryAllowed = true;
|
|
13
|
+
|
|
14
|
+
const {
|
|
15
|
+
requestStudioFeedback,
|
|
16
|
+
subscribeToFeedbackRequests,
|
|
17
|
+
markFeedbackAnswered,
|
|
18
|
+
markFeedbackDismissed,
|
|
19
|
+
followUpFor,
|
|
20
|
+
isProblemReason,
|
|
21
|
+
} = await import("./feedbackTrigger");
|
|
22
|
+
|
|
23
|
+
const DAY_MS = 86_400_000;
|
|
24
|
+
|
|
25
|
+
function collect() {
|
|
26
|
+
const seen: unknown[] = [];
|
|
27
|
+
const unsubscribe = subscribeToFeedbackRequests((r) => seen.push(r));
|
|
28
|
+
return { seen, unsubscribe };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
telemetryAllowed = true;
|
|
33
|
+
localStorage.clear();
|
|
34
|
+
sessionStorage.clear();
|
|
35
|
+
vi.useRealTimers();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.useRealTimers();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("requestStudioFeedback eligibility", () => {
|
|
43
|
+
it("asks on the first eligible request", () => {
|
|
44
|
+
const { seen, unsubscribe } = collect();
|
|
45
|
+
requestStudioFeedback({ reason: "render_complete", renderId: "r1" });
|
|
46
|
+
expect(seen).toEqual([{ reason: "render_complete", renderId: "r1" }]);
|
|
47
|
+
unsubscribe();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("asks only once per tab session, however many renders finish", () => {
|
|
51
|
+
const { seen, unsubscribe } = collect();
|
|
52
|
+
requestStudioFeedback({ reason: "render_complete", renderId: "r1" });
|
|
53
|
+
requestStudioFeedback({ reason: "render_complete", renderId: "r2" });
|
|
54
|
+
requestStudioFeedback({ reason: "render_failed", renderId: "r3" });
|
|
55
|
+
expect(seen).toHaveLength(1);
|
|
56
|
+
unsubscribe();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("stays silent for 30 days after an answer", () => {
|
|
60
|
+
markFeedbackAnswered();
|
|
61
|
+
const { seen, unsubscribe } = collect();
|
|
62
|
+
requestStudioFeedback({ reason: "render_complete" });
|
|
63
|
+
expect(seen).toHaveLength(0);
|
|
64
|
+
unsubscribe();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("asks again once the 30 day answer cooldown has passed", () => {
|
|
68
|
+
localStorage.setItem("hyperframes-studio:feedbackAnsweredAt", String(Date.now() - 31 * DAY_MS));
|
|
69
|
+
const { seen, unsubscribe } = collect();
|
|
70
|
+
requestStudioFeedback({ reason: "render_complete" });
|
|
71
|
+
expect(seen).toHaveLength(1);
|
|
72
|
+
unsubscribe();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("stays silent for 7 days after a dismissal, then asks again", () => {
|
|
76
|
+
markFeedbackDismissed();
|
|
77
|
+
const first = collect();
|
|
78
|
+
requestStudioFeedback({ reason: "render_complete" });
|
|
79
|
+
expect(first.seen).toHaveLength(0);
|
|
80
|
+
first.unsubscribe();
|
|
81
|
+
|
|
82
|
+
sessionStorage.clear();
|
|
83
|
+
localStorage.setItem("hyperframes-studio:feedbackDismissedAt", String(Date.now() - 8 * DAY_MS));
|
|
84
|
+
const second = collect();
|
|
85
|
+
requestStudioFeedback({ reason: "render_complete" });
|
|
86
|
+
expect(second.seen).toHaveLength(1);
|
|
87
|
+
second.unsubscribe();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("never asks a profile that opted out of telemetry", () => {
|
|
91
|
+
telemetryAllowed = false;
|
|
92
|
+
const { seen, unsubscribe } = collect();
|
|
93
|
+
requestStudioFeedback({ reason: "crash" });
|
|
94
|
+
expect(seen).toHaveLength(0);
|
|
95
|
+
unsubscribe();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("does not burn the session slot on an ineligible request", () => {
|
|
99
|
+
telemetryAllowed = false;
|
|
100
|
+
const blocked = collect();
|
|
101
|
+
requestStudioFeedback({ reason: "render_complete" });
|
|
102
|
+
blocked.unsubscribe();
|
|
103
|
+
|
|
104
|
+
// Opting back in mid-session must not find the one ask already spent.
|
|
105
|
+
telemetryAllowed = true;
|
|
106
|
+
const { seen, unsubscribe } = collect();
|
|
107
|
+
requestStudioFeedback({ reason: "render_complete" });
|
|
108
|
+
expect(seen).toHaveLength(1);
|
|
109
|
+
unsubscribe();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("drops the request when nothing is listening", () => {
|
|
113
|
+
expect(() => requestStudioFeedback({ reason: "crash" })).not.toThrow();
|
|
114
|
+
// The ask was not spent, so the next real subscriber still gets asked.
|
|
115
|
+
const { seen, unsubscribe } = collect();
|
|
116
|
+
requestStudioFeedback({ reason: "crash" });
|
|
117
|
+
expect(seen).toHaveLength(1);
|
|
118
|
+
unsubscribe();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("follow-up selection", () => {
|
|
123
|
+
it("gives every detractor the complaint question, never a rotated one", () => {
|
|
124
|
+
for (let rating = 0; rating <= 6; rating++) {
|
|
125
|
+
expect(followUpFor(rating).id).toBe("detractor");
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("rotates among the three research questions above 6", () => {
|
|
130
|
+
const ids = new Set<string>();
|
|
131
|
+
for (let i = 0; i < 200; i++) ids.add(followUpFor(9).id);
|
|
132
|
+
expect([...ids].sort()).toEqual(["fix", "remove", "workaround"]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("gives every question scannable, distinct one-tap answers with explanations", () => {
|
|
136
|
+
for (const rating of [0, 7, 8, 9, 10]) {
|
|
137
|
+
const q = followUpFor(rating);
|
|
138
|
+
// Four is the floor for a useful spread; past seven the chips stop being
|
|
139
|
+
// scannable in a 340px card and start being a form.
|
|
140
|
+
expect(q.presets.length).toBeGreaterThanOrEqual(4);
|
|
141
|
+
expect(q.presets.length).toBeLessThanOrEqual(7);
|
|
142
|
+
expect(new Set(q.presets.map((p) => p.label)).size).toBe(q.presets.length);
|
|
143
|
+
for (const p of q.presets) {
|
|
144
|
+
expect(p.hint.length).toBeGreaterThan(0);
|
|
145
|
+
// A label that wraps to two lines in a chip row is not scannable.
|
|
146
|
+
expect(p.label.length).toBeLessThanOrEqual(24);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("offers no brand names, which are a popularity vote and not an instruction", () => {
|
|
152
|
+
const brands = /capcut|premiere|resolve|after effects|final cut|remotion|canva|descript/i;
|
|
153
|
+
for (const rating of [0, 7, 8, 9, 10]) {
|
|
154
|
+
for (const p of followUpFor(rating).presets) {
|
|
155
|
+
expect(p.label).not.toMatch(brands);
|
|
156
|
+
expect(p.hint).not.toMatch(brands);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("isProblemReason", () => {
|
|
163
|
+
it("treats everything except a finished render as a problem report", () => {
|
|
164
|
+
expect(isProblemReason("render_complete")).toBe(false);
|
|
165
|
+
expect(isProblemReason("render_failed")).toBe(true);
|
|
166
|
+
expect(isProblemReason("crash")).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
});
|