From 7958bd7d6532aec40c2cac7c70091a740931390a Mon Sep 17 00:00:00 2001 From: Mabel Amaya Date: Mon, 3 Nov 2025 11:36:24 -0500 Subject: [PATCH 01/16] add styles for the new launch studio tooltip --- .../src/components/LaunchStudioIcon.scss | 14 +++++++++++++ .../src/components/LaunchStudioIcon.tsx | 2 +- packages/reporter/src/test/test.tsx | 21 +++++++++++++++---- 3 files changed, 32 insertions(+), 5 deletions(-) create mode 100644 packages/reporter/src/components/LaunchStudioIcon.scss diff --git a/packages/reporter/src/components/LaunchStudioIcon.scss b/packages/reporter/src/components/LaunchStudioIcon.scss new file mode 100644 index 00000000000..ac778cfbb70 --- /dev/null +++ b/packages/reporter/src/components/LaunchStudioIcon.scss @@ -0,0 +1,14 @@ +@import '../lib/variables.scss'; + +.launch-studio-tooltip { + background-color: $gray-900; + border-color: $gray-900; + min-width: 272px; + + .cy-tooltip-arrow { + svg { + fill: $gray-900; + stroke: $gray-900; + } + } +} \ No newline at end of file diff --git a/packages/reporter/src/components/LaunchStudioIcon.tsx b/packages/reporter/src/components/LaunchStudioIcon.tsx index 7b3b353e6cb..bc0ab169b30 100644 --- a/packages/reporter/src/components/LaunchStudioIcon.tsx +++ b/packages/reporter/src/components/LaunchStudioIcon.tsx @@ -12,7 +12,7 @@ export const LaunchStudioIcon: React.FC = ({ content, onC return ( = observer(({ model, events: eventsProps = event -
-
Edit in Studio
+
+
+ + Edit test in studio +
+ + Open a test in Studio to refine it with AI recommendations. + +
} onClick={_launchStudio} From e6a3c5c3620aa3385b902b1839e0747ea8272d87 Mon Sep 17 00:00:00 2001 From: Mabel Amaya Date: Mon, 3 Nov 2025 11:37:11 -0500 Subject: [PATCH 02/16] update got it text --- packages/reporter/src/test/test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/reporter/src/test/test.tsx b/packages/reporter/src/test/test.tsx index 6d13b9faaf5..918cf7f1e8c 100644 --- a/packages/reporter/src/test/test.tsx +++ b/packages/reporter/src/test/test.tsx @@ -78,7 +78,7 @@ const Test: React.FC = observer(({ model, events: eventsProps = event From 271777ef5a319bcf34c601fbccefd7aaf671d077 Mon Sep 17 00:00:00 2001 From: Mabel Amaya Date: Tue, 4 Nov 2025 16:10:59 -0500 Subject: [PATCH 03/16] open tooltip on first test only when opening the test list --- .../app/src/runner/SpecRunnerOpenMode.vue | 4 ++ packages/app/src/runner/event-manager.ts | 6 ++ packages/app/src/runner/reporter.ts | 1 + packages/app/src/store/runner-ui-store.ts | 2 + .../objectTypes/gql-LocalSettings.ts | 2 + packages/data-context/schemas/schema.graphql | 1 + .../src/components/LaunchStudioIcon.scss | 14 ----- .../src/components/LaunchStudioIcon.tsx | 11 +++- packages/reporter/src/lib/app-state.ts | 14 +++++ packages/reporter/src/lib/events.ts | 9 +++ packages/reporter/src/main.tsx | 7 ++- .../src/runnables/runnable-and-suite.tsx | 44 +++++++++------ packages/reporter/src/runnables/runnables.tsx | 30 +++++++--- packages/reporter/src/test/test.scss | 26 +++++++++ packages/reporter/src/test/test.tsx | 56 ++++++++++++++----- packages/types/src/preferences.ts | 2 + 16 files changed, 172 insertions(+), 57 deletions(-) delete mode 100644 packages/reporter/src/components/LaunchStudioIcon.scss create mode 100644 packages/reporter/src/test/test.scss diff --git a/packages/app/src/runner/SpecRunnerOpenMode.vue b/packages/app/src/runner/SpecRunnerOpenMode.vue index f22a78561e1..02aa262bcf0 100644 --- a/packages/app/src/runner/SpecRunnerOpenMode.vue +++ b/packages/app/src/runner/SpecRunnerOpenMode.vue @@ -183,6 +183,7 @@ fragment SpecRunner_Preferences on Query { reporterWidth specListWidth studioWidth + studioTooltipDismissed } } } @@ -361,6 +362,8 @@ preferences.update('autoScrollingEnabled', props.gql.localSettings.preferences.a preferences.update('showFetchRequests', props.gql.localSettings.preferences.showFetchRequests ?? true) +preferences.update('studioTooltipDismissed', props.gql.localSettings.preferences.studioTooltipDismissed ?? false) + // if the CYPRESS_NO_COMMAND_LOG environment variable is set, // don't use the widths or the open status of specs list from GraphQL if (!hideCommandLog) { @@ -444,6 +447,7 @@ onMounted(() => { preferences.update('isSpecsListOpen', state.isSpecsListOpen) preferences.update('autoScrollingEnabled', state.autoScrollingEnabled) preferences.update('showFetchRequests', state.showFetchRequests) + preferences.update('studioTooltipDismissed', state.studioTooltipDismissed) }) }) diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index 494a754a378..e570b171c38 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -300,6 +300,7 @@ export class EventManager { this.studioStore.setCanAccessStudioAI(canAccessStudioAI) this.studioStore.setSessionId(cloudStudioSessionId) this.studioStore.setActive(true) + this.reporterBus.emit('reporter:set:app:state', { isStudioNewTestPageActive: true }) }) } @@ -310,6 +311,7 @@ export class EventManager { const needsReload = this.studioStore.needsProtocolCleanup() this.studioStore.cancel() + this.reporterBus.emit('reporter:set:app:state', { isStudioNewTestPageActive: false }) // only reload the page if Studio has actually been used for recording if (needsReload) { @@ -501,6 +503,10 @@ export class EventManager { this.studioStore.setCanAccessStudioAI(canAccessStudioAI) this.studioStore.setSessionId(cloudStudioSessionId) + if (suiteId) { + this.reporterBus.emit('reporter:set:app:state', { isStudioNewTestPageActive: true }) + } + cb() }) } diff --git a/packages/app/src/runner/reporter.ts b/packages/app/src/runner/reporter.ts index 644078914a8..a7a08d28675 100644 --- a/packages/app/src/runner/reporter.ts +++ b/packages/app/src/runner/reporter.ts @@ -48,6 +48,7 @@ function renderReporter ( autoScrollingEnabled: runnerUiStore.autoScrollingEnabled, isSpecsListOpen: runnerUiStore.isSpecsListOpen, showFetchRequests: runnerUiStore.showFetchRequests, + studioTooltipDismissed: runnerUiStore.studioTooltipDismissed, error: null, resetStatsOnSpecChange: true, // Studio can only be enabled for e2e testing diff --git a/packages/app/src/store/runner-ui-store.ts b/packages/app/src/store/runner-ui-store.ts index cc21cff4108..aec42da92de 100644 --- a/packages/app/src/store/runner-ui-store.ts +++ b/packages/app/src/store/runner-ui-store.ts @@ -23,6 +23,7 @@ export interface RunnerUiState { autoScrollingEnabled: boolean isSpecsListOpen: boolean showFetchRequests: boolean + studioTooltipDismissed: boolean specListWidth: number reporterWidth: number studioWidth: number @@ -41,6 +42,7 @@ export const useRunnerUiStore = defineStore({ autoScrollingEnabled: true, isSpecsListOpen: false, showFetchRequests: true, + studioTooltipDismissed: false, specListWidth: runnerConstants.defaultSpecListWidth, reporterWidth: runnerConstants.defaultReporterWidth, studioWidth: runnerConstants.defaultStudioWidth, diff --git a/packages/data-context/graphql/schemaTypes/objectTypes/gql-LocalSettings.ts b/packages/data-context/graphql/schemaTypes/objectTypes/gql-LocalSettings.ts index 276ee544089..e519a92bdca 100644 --- a/packages/data-context/graphql/schemaTypes/objectTypes/gql-LocalSettings.ts +++ b/packages/data-context/graphql/schemaTypes/objectTypes/gql-LocalSettings.ts @@ -66,6 +66,8 @@ export const LocalSettingsPreferences = objectType({ return ctx.coreData.localSettings.preferences.notifyWhenRunCompletes || [] }, }) + + t.boolean('studioTooltipDismissed') }, }) diff --git a/packages/data-context/schemas/schema.graphql b/packages/data-context/schemas/schema.graphql index adf7cfa627d..a8d45959eb6 100644 --- a/packages/data-context/schemas/schema.graphql +++ b/packages/data-context/schemas/schema.graphql @@ -1425,6 +1425,7 @@ type LocalSettingsPreferences { shouldLaunchBrowserFromOpenBrowser: Boolean showFetchRequests: Boolean specListWidth: Int + studioTooltipDismissed: Boolean studioWidth: Int wasBrowserSetInCLI: Boolean } diff --git a/packages/reporter/src/components/LaunchStudioIcon.scss b/packages/reporter/src/components/LaunchStudioIcon.scss deleted file mode 100644 index ac778cfbb70..00000000000 --- a/packages/reporter/src/components/LaunchStudioIcon.scss +++ /dev/null @@ -1,14 +0,0 @@ -@import '../lib/variables.scss'; - -.launch-studio-tooltip { - background-color: $gray-900; - border-color: $gray-900; - min-width: 272px; - - .cy-tooltip-arrow { - svg { - fill: $gray-900; - stroke: $gray-900; - } - } -} \ No newline at end of file diff --git a/packages/reporter/src/components/LaunchStudioIcon.tsx b/packages/reporter/src/components/LaunchStudioIcon.tsx index bc0ab169b30..16b58433630 100644 --- a/packages/reporter/src/components/LaunchStudioIcon.tsx +++ b/packages/reporter/src/components/LaunchStudioIcon.tsx @@ -2,22 +2,27 @@ import React, { MouseEvent } from 'react' import Tooltip from '@cypress/react-tooltip' import { IconChevronRightMedium } from '@cypress-design/react-icon' +import cx from 'classnames' interface LaunchStudioIconProps { content: React.ReactNode onClick: (e: MouseEvent) => void + wrapperClassName?: string + className?: string + visible?: boolean } -export const LaunchStudioIcon: React.FC = ({ content, onClick }) => { +export const LaunchStudioIcon: React.FC = ({ content, onClick, className, wrapperClassName, visible }) => { return (
diff --git a/packages/reporter/src/lib/app-state.ts b/packages/reporter/src/lib/app-state.ts index f4e1e299fe0..7f853ede6e4 100644 --- a/packages/reporter/src/lib/app-state.ts +++ b/packages/reporter/src/lib/app-state.ts @@ -8,6 +8,7 @@ interface DefaultAppState { pinnedSnapshotId: number | string | null studioActive: boolean studioSingleTestActive: boolean + isStudioNewTestPageActive: boolean hasBeenPaused: boolean } @@ -20,6 +21,7 @@ const defaults: DefaultAppState = { pinnedSnapshotId: null, studioActive: false, studioSingleTestActive: false, + isStudioNewTestPageActive: false, hasBeenPaused: false, } @@ -33,9 +35,11 @@ class AppState { pinnedSnapshotId = defaults.pinnedSnapshotId studioActive = defaults.studioActive studioSingleTestActive = defaults.studioSingleTestActive + isStudioNewTestPageActive = defaults.isStudioNewTestPageActive showFetchRequests = true isStopped = false hasBeenPaused = defaults.hasBeenPaused + studioTooltipDismissed = false _resetAutoScrollingEnabledTo = true; [key: string]: any @@ -50,8 +54,10 @@ class AppState { pinnedSnapshotId: observable, studioActive: observable, studioSingleTestActive: observable, + isStudioNewTestPageActive: observable, showFetchRequests: observable, hasBeenPaused: observable, + studioTooltipDismissed: observable, }) } @@ -134,6 +140,10 @@ class AppState { this.studioSingleTestActive = studioSingleTestActive } + setIsStudioNewTestPageActive (isStudioNewTestPageActive: boolean) { + this.isStudioNewTestPageActive = isStudioNewTestPageActive + } + toggleShowFetchRequests () { this.showFetchRequests = !this.showFetchRequests } @@ -142,6 +152,10 @@ class AppState { this.showFetchRequests = showFetchRequests } + setStudioTooltipDismissed (dismissed: boolean) { + this.studioTooltipDismissed = dismissed + } + reset () { _.each(defaults, (value: any, key: string) => { this[key] = value diff --git a/packages/reporter/src/lib/events.ts b/packages/reporter/src/lib/events.ts index 857c8b58f12..92e642ec623 100644 --- a/packages/reporter/src/lib/events.ts +++ b/packages/reporter/src/lib/events.ts @@ -134,6 +134,14 @@ const events: Events = { appState.pinnedSnapshotId = null })) + runner.on('reporter:set:app:state', action('reporter:set:app:state', (stateUpdates: Partial) => { + Object.keys(stateUpdates).forEach((key) => { + if (key in appState) { + appState[key] = stateUpdates[key] + } + }) + })) + localBus.on('resume', action('resume', () => { appState.resume() statsStore.resume() @@ -205,6 +213,7 @@ const events: Events = { autoScrollingEnabled: appState.autoScrollingUserPref, isSpecsListOpen: appState.isSpecsListOpen, showFetchRequests: appState.showFetchRequests, + studioTooltipDismissed: appState.studioTooltipDismissed, }) }) diff --git a/packages/reporter/src/main.tsx b/packages/reporter/src/main.tsx index aec2d327492..de3977f41b7 100644 --- a/packages/reporter/src/main.tsx +++ b/packages/reporter/src/main.tsx @@ -37,6 +37,7 @@ export interface BaseReporterProps { autoScrollingEnabled?: boolean isSpecsListOpen?: boolean showFetchRequests?: boolean + studioTooltipDismissed?: boolean events: Events error?: RunnablesErrorModel resetStatsOnSpecChange?: boolean @@ -50,7 +51,7 @@ export interface SingleReporterProps extends BaseReporterProps { } // In React Class components (now deprecated), we used to use appState as a default prop. Now since defaultProps are not supported in functional components, we can use ES6 default params to accomplish the same thing -const Reporter: React.FC = observer(({ appState = appStateDefault, runner, className, error, runMode = 'single', studioEnabled, autoScrollingEnabled, isSpecsListOpen, showFetchRequests, resetStatsOnSpecChange, renderReporterHeader = (props: ReporterHeaderProps) =>
, runnerStore }) => { +const Reporter: React.FC = observer(({ appState = appStateDefault, runner, className, error, runMode = 'single', studioEnabled, autoScrollingEnabled, isSpecsListOpen, showFetchRequests, studioTooltipDismissed, resetStatsOnSpecChange, renderReporterHeader = (props: ReporterHeaderProps) =>
, runnerStore }) => { const previousSpecRunId = usePrevious(runnerStore.specRunId) const [isMounted, setIsMounted] = useState(false) const [isInitialized, setIsInitialized] = useState(false) @@ -86,6 +87,10 @@ const Reporter: React.FC = observer(({ appState = appStateD appState.setShowFetchRequests(showFetchRequests ?? true) })() + action('set:studio:tooltip:dismissed', () => { + appState.setStudioTooltipDismissed(studioTooltipDismissed ?? false) + })() + shortcuts.start() runnablesStore.setRunningSpec(runnerStore.spec.relative) // we need to know when the test is mounted for our reporter tests. see diff --git a/packages/reporter/src/runnables/runnable-and-suite.tsx b/packages/reporter/src/runnables/runnable-and-suite.tsx index 2dc02267272..ddfafea53d4 100644 --- a/packages/reporter/src/runnables/runnable-and-suite.tsx +++ b/packages/reporter/src/runnables/runnable-and-suite.tsx @@ -86,20 +86,31 @@ const Suite: React.FC = observer(({ eventManager = events, model, st ) }, [getHeaderIcon, model.title, studioEnabled, appState.studioActive]) - const runnablesList = useMemo(() => ( -
    - {_.map(model.children, (runnable, index) => { - return () - })} -
- ), [model.children, studioEnabled, canSaveStudioLogs]) + const runnablesList = useMemo(() => { + let foundFirstTest = false + + return ( +
    + {_.map(model.children, (runnable, index) => { + const isFirstTest = !foundFirstTest && runnable.type === 'test' + + if (isFirstTest) { + foundFirstTest = true + } + + return () + })} +
+ ) + }, [model.children, studioEnabled, canSaveStudioLogs]) return ( // we don't want to show the collapsible if there are no tests in the suite @@ -126,13 +137,14 @@ export interface RunnableProps { canSaveStudioLogs: boolean shouldShowConnectingDots: boolean spec?: Cypress.Cypress['spec'] + isFirstTest: boolean } // NOTE: some of the driver tests dig into the React instance for this component // in order to mess with its internal state. converting it to a functional // component breaks that, so it needs to stay a Class-based component or // else the driver tests need to be refactored to support it being functional -const Runnable: React.FC = observer(({ model, studioEnabled, canSaveStudioLogs, shouldShowConnectingDots, spec }) => { +const Runnable: React.FC = observer(({ model, studioEnabled, canSaveStudioLogs, shouldShowConnectingDots, spec, isFirstTest }) => { return (<>
  • = observer(({ model, studioEnabled, canS data-model-state={model.state} > {model.type === 'test' - ? + ? : = observer(({ runnables, studioEnabled, canSaveStudioLogs, spec }: RunnablesListProps) => { + let foundFirstTest = false + return (
      - {_.map(runnables, (runnable, index) => - ())} + {_.map(runnables, (runnable, index) => { + const isFirstTest = !foundFirstTest && runnable.type === 'test' + + if (isFirstTest) { + foundFirstTest = true + } + + return ( + + ) + })}
    ) diff --git a/packages/reporter/src/test/test.scss b/packages/reporter/src/test/test.scss new file mode 100644 index 00000000000..e64b22841fa --- /dev/null +++ b/packages/reporter/src/test/test.scss @@ -0,0 +1,26 @@ +@import "../lib/variables.scss"; + +.launch-studio-tooltip { + background-color: $gray-900; + border-color: $gray-900; + min-width: 272px; + + .cy-tooltip-arrow { + svg { + fill: $gray-900; + stroke: $gray-900; + } + } +} + +.edit-in-studio-tooltip { + border: 2px solid #9aa2fc !important; + align-items: center; + box-shadow: 0 0 0 3px #6470f366; + padding: 0 !important; + justify-content: center; + + svg { + flex-shrink: 0 !important; + } +} diff --git a/packages/reporter/src/test/test.tsx b/packages/reporter/src/test/test.tsx index 918cf7f1e8c..27adba6dc41 100644 --- a/packages/reporter/src/test/test.tsx +++ b/packages/reporter/src/test/test.tsx @@ -1,6 +1,6 @@ import { observer } from 'mobx-react' -import React, { MouseEvent, useCallback } from 'react' -import { IconGeneralSparkleSingleLarge } from '@cypress-design/react-icon' +import React, { MouseEvent, useCallback, useState } from 'react' +import { IconCypressStudio, IconGeneralSparkleSingleLarge } from '@cypress-design/react-icon' import events, { Events } from '../lib/events' import appState, { AppState } from '../lib/app-state' @@ -19,15 +19,18 @@ interface TestProps { model: TestModel studioEnabled: boolean spec?: Cypress.Cypress['spec'] + isFirstTest: boolean } -const Test: React.FC = observer(({ model, events: eventsProps = events, appState: appStateProps = appState, studioEnabled, spec }) => { +const Test: React.FC = observer(({ model, events: eventsProps = events, appState: appStateProps = appState, studioEnabled, spec, isFirstTest }) => { const { containerRef, isMounted, scrollIntoView } = useScrollIntoView({ appState: appStateProps, testState: model.state, isStudioActive: appStateProps.studioActive, }) + const [firstTestTooltipVisible, setFirstTestTooltipVisible] = useState(true) + const _launchStudio = useCallback((e: MouseEvent) => { e.preventDefault() e.stopPropagation() @@ -35,6 +38,15 @@ const Test: React.FC = observer(({ model, events: eventsProps = event eventsProps.emit('studio:init:test', { testId: model.id }) }, [eventsProps, model.id]) + const _handleDismissStudioTooltip = useCallback((e: MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + setFirstTestTooltipVisible(false) + appStateProps.setStudioTooltipDismissed(true) + eventsProps.emit('save:state') + }, [eventsProps, appStateProps]) + React.useEffect(() => { if (isMounted) { model.callbackAfterUpdate() @@ -62,11 +74,11 @@ const Test: React.FC = observer(({ model, events: eventsProps = event const isRunningAllSpecs = spec?.relative === '__all' if (studioEnabled && !appStateProps.studioActive && model.state !== 'pending' && !isRunningAllSpecs) { - controls.push( -
    @@ -76,16 +88,32 @@ const Test: React.FC = observer(({ model, events: eventsProps = event Open a test in Studio to refine it with AI recommendations. - -
    - } - onClick={_launchStudio} - />, - ) + } + onClick={(e) => { + _handleDismissStudioTooltip(e) + _launchStudio(e) + }} + className='launch-studio-tooltip' + wrapperClassName='edit-in-studio-tooltip' + visible={firstTestTooltipVisible} + />, + ) + } else { + controls.push( + +
    +
    Edit in Studio
    } + onClick={_launchStudio} + />, + ) + } } if (controls.length === 0) { diff --git a/packages/types/src/preferences.ts b/packages/types/src/preferences.ts index 792a26035b6..add6f15bc5d 100644 --- a/packages/types/src/preferences.ts +++ b/packages/types/src/preferences.ts @@ -54,6 +54,7 @@ export const allowedKeys: Readonly> = [ 'notifyWhenRunStartsFailing', 'notifyWhenRunCompletes', 'studioFirstUseInstructionsDismissed', + 'studioTooltipDismissed', ] as const type Maybe = T | null | undefined @@ -98,4 +99,5 @@ export type AllowedState = Partial<{ notifyWhenRunStartsFailing: Maybe notifyWhenRunCompletes: Maybe studioFirstUseInstructionsDismissed: Maybe + studioTooltipDismissed: Maybe }> From 4e980f7af362123f57d070624fc72c6695c1e058 Mon Sep 17 00:00:00 2001 From: Mabel Amaya Date: Tue, 4 Nov 2025 17:05:21 -0500 Subject: [PATCH 04/16] correctly determine the first test of all the runnables to display the tooltip only once --- .../src/runnables/runnable-and-suite.tsx | 48 ++++++++++++------- packages/reporter/src/runnables/runnables.tsx | 36 ++++++-------- 2 files changed, 45 insertions(+), 39 deletions(-) diff --git a/packages/reporter/src/runnables/runnable-and-suite.tsx b/packages/reporter/src/runnables/runnable-and-suite.tsx index ddfafea53d4..24c57795af0 100644 --- a/packages/reporter/src/runnables/runnable-and-suite.tsx +++ b/packages/reporter/src/runnables/runnable-and-suite.tsx @@ -20,12 +20,31 @@ export const shouldShowConnectionDots = (runnables: RunnableArray, runnable: Sui return runnable.type === 'test' && runnableIndex !== runnables.length - 1 && runnables[runnableIndex + 1].type === 'test' } +// Recursively find the ID of the first test in the runnable tree +export const findFirstTestId = (runnables: RunnableArray): string | null => { + for (const runnable of runnables) { + if (runnable.type === 'test') { + return runnable.id + } + + if (runnable.type === 'suite') { + const suite = runnable as SuiteModel + const firstTestId = findFirstTestId(suite.children) + + if (firstTestId) return firstTestId + } + } + + return null +} + interface SuiteProps { eventManager?: Events model: SuiteModel studioEnabled: boolean canSaveStudioLogs: boolean spec?: Cypress.Cypress['spec'] + firstTestId: string | null } const headerIconDefaultProps = { @@ -34,7 +53,7 @@ const headerIconDefaultProps = { className: 'header-icon', } -const Suite: React.FC = observer(({ eventManager = events, model, studioEnabled, canSaveStudioLogs, spec }: SuiteProps) => { +const Suite: React.FC = observer(({ eventManager = events, model, studioEnabled, canSaveStudioLogs, spec, firstTestId }: SuiteProps) => { const headerIconStyle = { marginTop: '1px', } @@ -87,30 +106,22 @@ const Suite: React.FC = observer(({ eventManager = events, model, st }, [getHeaderIcon, model.title, studioEnabled, appState.studioActive]) const runnablesList = useMemo(() => { - let foundFirstTest = false - return (
      - {_.map(model.children, (runnable, index) => { - const isFirstTest = !foundFirstTest && runnable.type === 'test' - - if (isFirstTest) { - foundFirstTest = true - } - - return ( ( + ) - })} + firstTestId={firstTestId} + /> + ))}
    ) - }, [model.children, studioEnabled, canSaveStudioLogs]) + }, [model.children, studioEnabled, canSaveStudioLogs, firstTestId]) return ( // we don't want to show the collapsible if there are no tests in the suite @@ -137,14 +148,16 @@ export interface RunnableProps { canSaveStudioLogs: boolean shouldShowConnectingDots: boolean spec?: Cypress.Cypress['spec'] - isFirstTest: boolean + firstTestId: string | null } // NOTE: some of the driver tests dig into the React instance for this component // in order to mess with its internal state. converting it to a functional // component breaks that, so it needs to stay a Class-based component or // else the driver tests need to be refactored to support it being functional -const Runnable: React.FC = observer(({ model, studioEnabled, canSaveStudioLogs, shouldShowConnectingDots, spec, isFirstTest }) => { +const Runnable: React.FC = observer(({ model, studioEnabled, canSaveStudioLogs, shouldShowConnectingDots, spec, firstTestId }) => { + const isFirstTest = model.type === 'test' && model.id === firstTestId + return (<>
  • = observer(({ model, studioEnabled, canS studioEnabled={studioEnabled} canSaveStudioLogs={canSaveStudioLogs} spec={spec} + firstTestId={firstTestId} />}
  • {shouldShowConnectingDots &&
    } diff --git a/packages/reporter/src/runnables/runnables.tsx b/packages/reporter/src/runnables/runnables.tsx index f882b64d7b7..81129a509a2 100644 --- a/packages/reporter/src/runnables/runnables.tsx +++ b/packages/reporter/src/runnables/runnables.tsx @@ -1,11 +1,11 @@ import _ from 'lodash' import { action } from 'mobx' import { observer } from 'mobx-react' -import React, { MouseEvent, useCallback, useEffect, useRef } from 'react' +import React, { MouseEvent, useCallback, useEffect, useMemo, useRef } from 'react' import events, { Events } from '../lib/events' import { RunnablesError, RunnablesErrorModel } from './runnable-error' -import Runnable, { shouldShowConnectionDots } from './runnable-and-suite' +import Runnable, { shouldShowConnectionDots, findFirstTestId } from './runnable-and-suite' import type { RunnablesStore, RunnableArray } from './runnables-store' import type { StatsStore } from '../header/stats-store' import type { Scroller, UserScrollCallback } from '../lib/scroller' @@ -93,30 +93,22 @@ interface RunnablesListProps { } const RunnablesList: React.FC = observer(({ runnables, studioEnabled, canSaveStudioLogs, spec }: RunnablesListProps) => { - let foundFirstTest = false + const firstTestId = useMemo(() => findFirstTestId(runnables), [runnables]) return (
      - {_.map(runnables, (runnable, index) => { - const isFirstTest = !foundFirstTest && runnable.type === 'test' - - if (isFirstTest) { - foundFirstTest = true - } - - return ( - - ) - })} + {_.map(runnables, (runnable, index) => ( + + ))}
    ) From b25eff049020364206e02ab24a2ff0486633b6e9 Mon Sep 17 00:00:00 2001 From: Mabel Amaya Date: Wed, 5 Nov 2025 16:00:01 -0500 Subject: [PATCH 05/16] update styling and text for tooltip --- packages/reporter/src/test/test.scss | 3 ++- packages/reporter/src/test/test.tsx | 22 +++++++++++++++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/reporter/src/test/test.scss b/packages/reporter/src/test/test.scss index e64b22841fa..dabcf73e617 100644 --- a/packages/reporter/src/test/test.scss +++ b/packages/reporter/src/test/test.scss @@ -3,7 +3,8 @@ .launch-studio-tooltip { background-color: $gray-900; border-color: $gray-900; - min-width: 272px; + min-width: 375px; + padding: 0; .cy-tooltip-arrow { svg { diff --git a/packages/reporter/src/test/test.tsx b/packages/reporter/src/test/test.tsx index 27adba6dc41..81e773a752f 100644 --- a/packages/reporter/src/test/test.tsx +++ b/packages/reporter/src/test/test.tsx @@ -1,6 +1,6 @@ import { observer } from 'mobx-react' import React, { MouseEvent, useCallback, useState } from 'react' -import { IconCypressStudio, IconGeneralSparkleSingleLarge } from '@cypress-design/react-icon' +import { IconActionDeleteSmall, IconCypressStudio, IconGeneralSparkleSingleLarge } from '@cypress-design/react-icon' import events, { Events } from '../lib/events' import appState, { AppState } from '../lib/app-state' @@ -79,14 +79,22 @@ const Test: React.FC = observer(({ model, events: eventsProps = event -
    - +
    Edit test in studio +
    - - Open a test in Studio to refine it with AI recommendations. + + + Open a test in Studio to make edits + + + + Refine test with AI recommendations + Coming soon
    } onClick={_launchStudio} />, From 77d06e6655022c4c061cef27e61c3b3333ba9798 Mon Sep 17 00:00:00 2001 From: Mabel Amaya Date: Wed, 5 Nov 2025 17:52:56 -0500 Subject: [PATCH 06/16] add some tests --- packages/reporter/cypress/e2e/unit/app_state.cy.ts | 11 +++++++++++ packages/reporter/cypress/e2e/unit/events.cy.ts | 2 ++ 2 files changed, 13 insertions(+) diff --git a/packages/reporter/cypress/e2e/unit/app_state.cy.ts b/packages/reporter/cypress/e2e/unit/app_state.cy.ts index 69ff6555fd0..e9528ff71e7 100644 --- a/packages/reporter/cypress/e2e/unit/app_state.cy.ts +++ b/packages/reporter/cypress/e2e/unit/app_state.cy.ts @@ -218,4 +218,15 @@ describe('app state', () => { expect(instance.studioActive).to.be.false }) }) + + context('#setStudioTooltipDismissed', () => { + it('sets studioTooltipDismissed', () => { + const instance = new AppState() + + expect(instance.studioTooltipDismissed).to.eq(false) + + instance.setStudioTooltipDismissed(true) + expect(instance.studioTooltipDismissed).to.eq(true) + }) + }) }) diff --git a/packages/reporter/cypress/e2e/unit/events.cy.ts b/packages/reporter/cypress/e2e/unit/events.cy.ts index 35aacaec0ff..79b145da7a9 100644 --- a/packages/reporter/cypress/e2e/unit/events.cy.ts +++ b/packages/reporter/cypress/e2e/unit/events.cy.ts @@ -355,11 +355,13 @@ describe('events', () => { appState.autoScrollingUserPref = false appState.isSpecsListOpen = true appState.showFetchRequests = false + appState.studioTooltipDismissed = true events.emit('save:state') expect(runner.emit).to.have.been.calledWith('save:state', { autoScrollingEnabled: false, isSpecsListOpen: true, showFetchRequests: false, + studioTooltipDismissed: true, }) }) From 8eafe353db8303baa8c5c73f3d86ffacdec8851b Mon Sep 17 00:00:00 2001 From: Mabel Amaya Date: Thu, 6 Nov 2025 12:15:43 -0500 Subject: [PATCH 07/16] add test for event --- packages/reporter/cypress/e2e/unit/events.cy.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/reporter/cypress/e2e/unit/events.cy.ts b/packages/reporter/cypress/e2e/unit/events.cy.ts index 79b145da7a9..4cc48ac4bb5 100644 --- a/packages/reporter/cypress/e2e/unit/events.cy.ts +++ b/packages/reporter/cypress/e2e/unit/events.cy.ts @@ -27,6 +27,7 @@ type AppStateStub = AppState & { temporarilySetAutoScrolling: SinonSpy setStudioActive: SinonSpy setStudioSingleTestActive: SinonSpy + setStudioTooltipDismissed: SinonSpy stop: SinonSpy } @@ -40,6 +41,7 @@ const appStateStub = () => { temporarilySetAutoScrolling: sinon.spy(), setStudioActive: sinon.spy(), setStudioSingleTestActive: sinon.spy(), + setStudioTooltipDismissed: sinon.spy(), stop: sinon.spy(), } as AppStateStub } @@ -265,6 +267,12 @@ describe('events', () => { runner.on.withArgs('reporter:snapshot:unpinned').callArgWith(1) expect(appState.pinnedSnapshotId).to.be.null }) + + it('sets studioTooltipDismissed on the app state on reporter:set:app:state', () => { + expect(appState.studioTooltipDismissed).to.not.exist + runner.on.withArgs('reporter:set:app:state').callArgWith(1, { studioTooltipDismissed: true }) + expect(appState.setStudioTooltipDismissed).to.have.been.calledWith(true) + }) }) context('from local bus', () => { From d08019a9939d723c10da345dadffa0413e0e422e Mon Sep 17 00:00:00 2001 From: Mabel Amaya Date: Thu, 6 Nov 2025 12:49:15 -0500 Subject: [PATCH 08/16] update event name to update studio new test page active --- packages/app/src/runner/event-manager.ts | 6 +++--- packages/reporter/cypress/e2e/unit/events.cy.ts | 8 ++++++++ packages/reporter/src/lib/events.ts | 8 ++------ 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index e570b171c38..23db3a42544 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -300,7 +300,7 @@ export class EventManager { this.studioStore.setCanAccessStudioAI(canAccessStudioAI) this.studioStore.setSessionId(cloudStudioSessionId) this.studioStore.setActive(true) - this.reporterBus.emit('reporter:set:app:state', { isStudioNewTestPageActive: true }) + this.reporterBus.emit('reporter:set:studio:new:test:page:active', true) }) } @@ -311,7 +311,7 @@ export class EventManager { const needsReload = this.studioStore.needsProtocolCleanup() this.studioStore.cancel() - this.reporterBus.emit('reporter:set:app:state', { isStudioNewTestPageActive: false }) + this.reporterBus.emit('reporter:set:studio:new:test:page:active', false) // only reload the page if Studio has actually been used for recording if (needsReload) { @@ -504,7 +504,7 @@ export class EventManager { this.studioStore.setSessionId(cloudStudioSessionId) if (suiteId) { - this.reporterBus.emit('reporter:set:app:state', { isStudioNewTestPageActive: true }) + this.reporterBus.emit('reporter:set:studio:new:test:page:active', true) } cb() diff --git a/packages/reporter/cypress/e2e/unit/events.cy.ts b/packages/reporter/cypress/e2e/unit/events.cy.ts index 79b145da7a9..98742f7292b 100644 --- a/packages/reporter/cypress/e2e/unit/events.cy.ts +++ b/packages/reporter/cypress/e2e/unit/events.cy.ts @@ -27,6 +27,7 @@ type AppStateStub = AppState & { temporarilySetAutoScrolling: SinonSpy setStudioActive: SinonSpy setStudioSingleTestActive: SinonSpy + setIsStudioNewTestPageActive: SinonSpy stop: SinonSpy } @@ -41,6 +42,7 @@ const appStateStub = () => { setStudioActive: sinon.spy(), setStudioSingleTestActive: sinon.spy(), stop: sinon.spy(), + setIsStudioNewTestPageActive: sinon.spy(), } as AppStateStub } @@ -265,6 +267,12 @@ describe('events', () => { runner.on.withArgs('reporter:snapshot:unpinned').callArgWith(1) expect(appState.pinnedSnapshotId).to.be.null }) + + it('sets isStudioNewTestPageActive on the app state on reporter:set:studio:new:test:page:active', () => { + appState.isStudioNewTestPageActive = false + runner.on.withArgs('reporter:set:studio:new:test:page:active').callArgWith(1, true) + expect(appState.setIsStudioNewTestPageActive).to.have.been.calledWith(true) + }) }) context('from local bus', () => { diff --git a/packages/reporter/src/lib/events.ts b/packages/reporter/src/lib/events.ts index 92e642ec623..43a78be514b 100644 --- a/packages/reporter/src/lib/events.ts +++ b/packages/reporter/src/lib/events.ts @@ -134,12 +134,8 @@ const events: Events = { appState.pinnedSnapshotId = null })) - runner.on('reporter:set:app:state', action('reporter:set:app:state', (stateUpdates: Partial) => { - Object.keys(stateUpdates).forEach((key) => { - if (key in appState) { - appState[key] = stateUpdates[key] - } - }) + runner.on('reporter:set:studio:new:test:page:active', action('reporter:set:studio:new:test:page:active', (isStudioNewTestPageActive: boolean) => { + appState.setIsStudioNewTestPageActive(isStudioNewTestPageActive) })) localBus.on('resume', action('resume', () => { From 1b3ca0f0948142e32325a0d70837891129dc6173 Mon Sep 17 00:00:00 2001 From: Mabel Amaya Date: Thu, 6 Nov 2025 13:38:13 -0500 Subject: [PATCH 09/16] add tests for tooltip --- packages/app/src/runner/event-manager.ts | 2 ++ packages/reporter/cypress/e2e/tests.cy.ts | 37 +++++++++++++++++++++-- packages/reporter/src/lib/events.ts | 1 + 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/app/src/runner/event-manager.ts b/packages/app/src/runner/event-manager.ts index 23db3a42544..877b6306aa6 100644 --- a/packages/app/src/runner/event-manager.ts +++ b/packages/app/src/runner/event-manager.ts @@ -889,6 +889,7 @@ export class EventManager { !!this.studioStore.newTestLineNumber const studioSingleTestActive = this.studioStore.newTestLineNumber != null || !!this.studioStore.testId + const isStudioNewTestPageActive = this.studioStore.isActive && !!this.studioStore.suiteId this.reporterBus.emit('reporter:start', { startTime: Cypress.runner.getStartTime(), @@ -901,6 +902,7 @@ export class EventManager { scrollTop: runState.scrollTop, studioActive: hasActiveStudio, studioSingleTestActive, + isStudioNewTestPageActive, } as ReporterStartInfo) } diff --git a/packages/reporter/cypress/e2e/tests.cy.ts b/packages/reporter/cypress/e2e/tests.cy.ts index 65ec0494bd9..be05b50357f 100644 --- a/packages/reporter/cypress/e2e/tests.cy.ts +++ b/packages/reporter/cypress/e2e/tests.cy.ts @@ -5,7 +5,7 @@ import { MobxRunnerStore } from '@packages/app/src/store/mobx-runner-store' let runner: EventEmitter let runnables: RootRunnable -function visitAndRenderReporter (studioEnabled: boolean = false, studioActive: boolean = false, specRelative: string = 'relative/path/to/foo.js') { +function visitAndRenderReporter (studioEnabled: boolean = false, studioActive: boolean = false, specRelative: string = 'relative/path/to/foo.js', isStudioNewTestPageActive: boolean = false) { cy.fixture('runnables').then((_runnables) => { runnables = _runnables }) @@ -30,7 +30,7 @@ function visitAndRenderReporter (studioEnabled: boolean = false, studioActive: b cy.get('.reporter.mounted').then(() => { runner.emit('runnables:ready', runnables) - runner.emit('reporter:start', { studioActive }) + runner.emit('reporter:start', { studioActive, isStudioNewTestPageActive }) }) return runnerStore @@ -265,4 +265,37 @@ describe('studio controls', () => { cy.wrap(runner.emit).should('be.calledWith', 'studio:init:test', { testId: 'r3' }) }) }) + + describe('display studio tooltip guide for new test page', () => { + beforeEach(() => { + const runnerStore = visitAndRenderReporter(true, false, 'relative/path/to/foo.js', true) + + runnerStore.setCanSaveStudioLogs(false) + }) + + it('displays studio tooltip guide for new test page', () => { + const assertNewTestPageTooltip = () => { + cy.get('.cy-tooltip').first().contains('Edit test in studio') + cy.get('.cy-tooltip').first().contains('Open a test in Studio to make edits') + cy.get('.cy-tooltip').first().contains('Refine test with AI recommendations') + } + + // when just entering the new test page, the tooltip should be displayed + + cy.get('.cy-tooltip').should('have.length', 1) + assertNewTestPageTooltip() + + // when hovering over other tests, the Edit in Studio tooltip should be displayed as well + cy.contains('failed with retries') + .closest('.collapsible-header') + .find('.runnable-controls-studio') + .realHover() + .should('be.visible') + .should('have.css', 'opacity', '1') + + cy.get('.cy-tooltip').should('have.length', 2) + assertNewTestPageTooltip() + cy.get('.cy-tooltip').eq(1).contains('Edit in Studio') + }) + }) }) diff --git a/packages/reporter/src/lib/events.ts b/packages/reporter/src/lib/events.ts index 43a78be514b..0de93638c91 100644 --- a/packages/reporter/src/lib/events.ts +++ b/packages/reporter/src/lib/events.ts @@ -90,6 +90,7 @@ const events: Events = { runnablesStore.setInitialScrollTop(startInfo.scrollTop) appState.setStudioActive(startInfo.studioActive) appState.setStudioSingleTestActive(startInfo.studioSingleTestActive) + appState.setIsStudioNewTestPageActive(startInfo.isStudioNewTestPageActive) if (runnablesStore.hasTests) { statsStore.start(startInfo) From e12a6a4363d76228c5c80c15edb69c8aed1ae805 Mon Sep 17 00:00:00 2001 From: Mabel Amaya Date: Thu, 6 Nov 2025 14:17:21 -0500 Subject: [PATCH 10/16] add more tests for studio tooltip guide --- packages/reporter/cypress/e2e/tests.cy.ts | 49 ++++++++++++++++--- .../src/components/LaunchStudioIcon.tsx | 5 +- packages/reporter/src/test/test.tsx | 5 +- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/packages/reporter/cypress/e2e/tests.cy.ts b/packages/reporter/cypress/e2e/tests.cy.ts index be05b50357f..d967b245ba6 100644 --- a/packages/reporter/cypress/e2e/tests.cy.ts +++ b/packages/reporter/cypress/e2e/tests.cy.ts @@ -273,15 +273,24 @@ describe('studio controls', () => { runnerStore.setCanSaveStudioLogs(false) }) - it('displays studio tooltip guide for new test page', () => { - const assertNewTestPageTooltip = () => { - cy.get('.cy-tooltip').first().contains('Edit test in studio') - cy.get('.cy-tooltip').first().contains('Open a test in Studio to make edits') - cy.get('.cy-tooltip').first().contains('Refine test with AI recommendations') - } + const assertNewTestPageTooltip = () => { + // studio tooltip guide should be displayed for the first test in the new test page + cy.get('.test').first().within(() => { + cy.get('[data-cy="studio-tooltip-guide"]').should('exist') + }) + + cy.get('.cy-tooltip').first().contains('Edit test in studio') + cy.get('.cy-tooltip').first().contains('Open a test in Studio to make edits') + cy.get('.cy-tooltip').first().contains('Refine test with AI recommendations') + } + + const assertStudioTooltipDismissed = () => { + cy.wrap(runner.emit).should('be.calledWith', 'save:state') + cy.get('[data-cy="studio-tooltip-guide"]').should('not.exist') + } + it('displays studio tooltip guide for new test page', () => { // when just entering the new test page, the tooltip should be displayed - cy.get('.cy-tooltip').should('have.length', 1) assertNewTestPageTooltip() @@ -297,5 +306,31 @@ describe('studio controls', () => { assertNewTestPageTooltip() cy.get('.cy-tooltip').eq(1).contains('Edit in Studio') }) + + it('dismisses studio tooltip guide with dismiss icon for new test page', () => { + cy.stub(runner, 'emit') + assertNewTestPageTooltip() + + cy.get('[data-cy="dismiss-studio-tooltip-icon"]').click() + assertStudioTooltipDismissed() + }) + + it('dismisses studio tooltip guide with got it button for new test page', () => { + cy.stub(runner, 'emit') + + assertNewTestPageTooltip() + cy.get('[data-cy="got-it-dont-show-again-button"]').click() + assertStudioTooltipDismissed() + }) + + it('dismisses studio tooltip guide when clicking on the test', () => { + cy.stub(runner, 'emit') + cy.stub(appState, 'setStudioTooltipDismissed').as('setStudioTooltipDismissed') + + assertNewTestPageTooltip() + + cy.get('[data-cy="studio-tooltip-guide"]').click({ force: true }) + assertStudioTooltipDismissed() + }) }) }) diff --git a/packages/reporter/src/components/LaunchStudioIcon.tsx b/packages/reporter/src/components/LaunchStudioIcon.tsx index 16b58433630..3bb4c21cb1c 100644 --- a/packages/reporter/src/components/LaunchStudioIcon.tsx +++ b/packages/reporter/src/components/LaunchStudioIcon.tsx @@ -10,9 +10,10 @@ interface LaunchStudioIconProps { wrapperClassName?: string className?: string visible?: boolean + dataCy?: string } -export const LaunchStudioIcon: React.FC = ({ content, onClick, className, wrapperClassName, visible }) => { +export const LaunchStudioIcon: React.FC = ({ content, onClick, className, wrapperClassName, visible, dataCy = 'launch-studio' }) => { return ( = ({ content, onC
    diff --git a/packages/reporter/src/test/test.tsx b/packages/reporter/src/test/test.tsx index 81e773a752f..ac67638319e 100644 --- a/packages/reporter/src/test/test.tsx +++ b/packages/reporter/src/test/test.tsx @@ -83,7 +83,7 @@ const Test: React.FC = observer(({ model, events: eventsProps = event >
    Edit test in studio -
    @@ -96,7 +96,7 @@ const Test: React.FC = observer(({ model, events: eventsProps = event Refine test with AI recommendations Coming soon -