diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 9c10d335507..53a7f2ccc66 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -6,6 +6,7 @@ _Released 11/18/2025 (PENDING)_ **Misc:** - The keyboard shortcuts modal now displays the keyboard shortcut for saving Studio changes - `⌘` + `s` for Mac or `Ctrl` + `s` for Windows/Linux. Addressed [#32862](https://github.com/cypress-io/cypress/issues/32862). Addressed in [#32864](https://github.com/cypress-io/cypress/pull/32864). +- Popup tooltip guide when opening the welcome to studio panel. Addresses [#11906](https://github.com/cypress-io/cypress-services/issues/11906). Addressed in [#32905](https://github.com/cypress-io/cypress/pull/32905). ## 15.6.0 diff --git a/packages/app/cypress/e2e/studio/studio-new-tests.cy.ts b/packages/app/cypress/e2e/studio/studio-new-tests.cy.ts index ea56d162cda..bc3a159f5d3 100644 --- a/packages/app/cypress/e2e/studio/studio-new-tests.cy.ts +++ b/packages/app/cypress/e2e/studio/studio-new-tests.cy.ts @@ -17,6 +17,8 @@ describe('Cypress Studio - New Test Creation', () => { inputNewTestName({ creatingNewTestFromWelcomeScreen: false }) + cy.findByTestId('studio-tooltip-guide').should('not.exist') + cy.contains('new-test').click() cy.percySnapshot() @@ -130,6 +132,8 @@ describe('studio functionality', () => { inputNewTestName({ creatingNewTestFromWelcomeScreen: false }) + cy.findByTestId('studio-tooltip-guide').should('not.exist') + // make sure that the visit has run and we're recording studio commands cy.get('[data-cy="record-button-recording"]').should('be.visible') diff --git a/packages/app/cypress/e2e/studio/studio-ui.cy.ts b/packages/app/cypress/e2e/studio/studio-ui.cy.ts index 6628e121392..92184fd3239 100644 --- a/packages/app/cypress/e2e/studio/studio-ui.cy.ts +++ b/packages/app/cypress/e2e/studio/studio-ui.cy.ts @@ -42,6 +42,9 @@ describe('studio functionality', () => { cy.findByTestId('studio-button').should('be.visible').click() cy.findByTestId('studio-panel').should('be.visible') + // studio guide tooltip should be visible + cy.findByTestId('studio-tooltip-guide').should('be.visible') + cy.contains('New test') cy.percySnapshot() 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..9e79e80bc19 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:studio:welcome:panel:active', entrySource === 'welcome') }) } @@ -310,6 +311,7 @@ export class EventManager { const needsReload = this.studioStore.needsProtocolCleanup() this.studioStore.cancel() + this.reporterBus.emit('reporter:set:studio:welcome:panel:active', false) // only reload the page if Studio has actually been used for recording if (needsReload) { @@ -883,6 +885,7 @@ export class EventManager { !!this.studioStore.newTestLineNumber const studioSingleTestActive = this.studioStore.newTestLineNumber != null || !!this.studioStore.testId + const isStudioWelcomePanelActive = this.studioStore.isActive && !!this.studioStore.suiteId && (this.studioStore.entrySource === 'welcome' || !this.studioStore.entrySource) this.reporterBus.emit('reporter:start', { startTime: Cypress.runner.getStartTime(), @@ -895,6 +898,7 @@ export class EventManager { scrollTop: runState.scrollTop, studioActive: hasActiveStudio, studioSingleTestActive, + isStudioWelcomePanelActive, } as ReporterStartInfo) } 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/cypress/e2e/tests.cy.ts b/packages/reporter/cypress/e2e/tests.cy.ts index 65ec0494bd9..4e1c8d8e52e 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', isStudioWelcomePanelActive: 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, isStudioWelcomePanelActive }) }) return runnerStore @@ -265,4 +265,72 @@ 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) + }) + + 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() + + // 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') + }) + + 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') + + assertNewTestPageTooltip() + + cy.get('[data-cy="studio-tooltip-guide"]').click({ force: true }) + assertStudioTooltipDismissed() + cy.wrap(runner.emit).should('be.calledWith', 'studio:init:test', { testId: 'r3' }) + }) + }) }) 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..b4602cbe3d6 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 + setIsStudioWelcomePanelActive: SinonSpy stop: SinonSpy } @@ -41,6 +42,7 @@ const appStateStub = () => { setStudioActive: sinon.spy(), setStudioSingleTestActive: sinon.spy(), stop: sinon.spy(), + setIsStudioWelcomePanelActive: 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 isStudioWelcomePanelActive on the app state on reporter:set:studio:welcome:panel:active', () => { + appState.isStudioWelcomePanelActive = false + runner.on.withArgs('reporter:set:studio:welcome:panel:active').callArgWith(1, true) + expect(appState.setIsStudioWelcomePanelActive).to.have.been.calledWith(true) + }) }) context('from local bus', () => { @@ -355,11 +363,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, }) }) diff --git a/packages/reporter/src/components/LaunchStudioIcon.tsx b/packages/reporter/src/components/LaunchStudioIcon.tsx index 7b3b353e6cb..3bb4c21cb1c 100644 --- a/packages/reporter/src/components/LaunchStudioIcon.tsx +++ b/packages/reporter/src/components/LaunchStudioIcon.tsx @@ -2,23 +2,29 @@ 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 + dataCy?: string } -export const LaunchStudioIcon: React.FC = ({ content, onClick }) => { +export const LaunchStudioIcon: React.FC = ({ content, onClick, className, wrapperClassName, visible, dataCy = 'launch-studio' }) => { return ( diff --git a/packages/reporter/src/lib/app-state.ts b/packages/reporter/src/lib/app-state.ts index f4e1e299fe0..194b4d8a4f7 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 + isStudioWelcomePanelActive: boolean hasBeenPaused: boolean } @@ -20,6 +21,7 @@ const defaults: DefaultAppState = { pinnedSnapshotId: null, studioActive: false, studioSingleTestActive: false, + isStudioWelcomePanelActive: false, hasBeenPaused: false, } @@ -33,9 +35,11 @@ class AppState { pinnedSnapshotId = defaults.pinnedSnapshotId studioActive = defaults.studioActive studioSingleTestActive = defaults.studioSingleTestActive + isStudioWelcomePanelActive = defaults.isStudioWelcomePanelActive 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, + isStudioWelcomePanelActive: observable, showFetchRequests: observable, hasBeenPaused: observable, + studioTooltipDismissed: observable, }) } @@ -134,6 +140,10 @@ class AppState { this.studioSingleTestActive = studioSingleTestActive } + setIsStudioWelcomePanelActive (isStudioWelcomePanelActive: boolean) { + this.isStudioWelcomePanelActive = isStudioWelcomePanelActive + } + 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..eb5d0df3bbe 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.setIsStudioWelcomePanelActive(startInfo.isStudioWelcomePanelActive) if (runnablesStore.hasTests) { statsStore.start(startInfo) @@ -134,6 +135,10 @@ const events: Events = { appState.pinnedSnapshotId = null })) + runner.on('reporter:set:studio:welcome:panel:active', action('reporter:set:studio:welcome:panel:active', (isStudioWelcomePanelActive: boolean) => { + appState.setIsStudioWelcomePanelActive(isStudioWelcomePanelActive) + })) + localBus.on('resume', action('resume', () => { appState.resume() statsStore.resume() @@ -205,6 +210,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..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', } @@ -86,20 +105,23 @@ 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(() => { + return ( +
    + {_.map(model.children, (runnable, index) => ( + + ))} +
+ ) + }, [model.children, studioEnabled, canSaveStudioLogs, firstTestId]) return ( // we don't want to show the collapsible if there are no tests in the suite @@ -126,13 +148,16 @@ export interface RunnableProps { canSaveStudioLogs: boolean shouldShowConnectingDots: boolean spec?: Cypress.Cypress['spec'] + 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 }) => { +const Runnable: React.FC = observer(({ model, studioEnabled, canSaveStudioLogs, shouldShowConnectingDots, spec, firstTestId }) => { + const isFirstTest = model.type === 'test' && model.id === firstTestId + return (<>
  • = observer(({ model, studioEnabled, canS data-model-state={model.state} > {model.type === 'test' - ? + ? : }
  • {shouldShowConnectingDots &&
    } diff --git a/packages/reporter/src/runnables/runnables.tsx b/packages/reporter/src/runnables/runnables.tsx index a536f5da457..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,18 +93,22 @@ interface RunnablesListProps { } const RunnablesList: React.FC = observer(({ runnables, studioEnabled, canSaveStudioLogs, spec }: RunnablesListProps) => { + const firstTestId = useMemo(() => findFirstTestId(runnables), [runnables]) + return (
      - {_.map(runnables, (runnable, index) => - ( ( + ))} + firstTestId={firstTestId} + /> + ))}
    ) diff --git a/packages/reporter/src/test/test.cy.tsx b/packages/reporter/src/test/test.cy.tsx index 2793c78c353..a7c1baabf77 100644 --- a/packages/reporter/src/test/test.cy.tsx +++ b/packages/reporter/src/test/test.cy.tsx @@ -1,5 +1,8 @@ import React from 'react' import Test from './test' +import { AppState } from '../lib/app-state' +import TestModel from './test-model' +import { Events } from '../lib/events' describe('test/test.tsx', () => { it('should mount', () => { @@ -16,13 +19,14 @@ describe('test/test.tsx', () => { const appState = { studioActive: false, - } + } as AppState cy.mount(
    ) @@ -47,13 +51,14 @@ describe('test/test.tsx', () => { const appState = { studioActive: false, - } + } as AppState cy.mount(
    ) @@ -65,4 +70,79 @@ describe('test/test.tsx', () => { cy.percySnapshot() }) + + it('should mount with studio tooltip guide', () => { + const model = { + isOpen: false, + level: 0, + state: 'passed', + title: 'foobar', + attempts: [], + setIsOpen: (isOpen) => model.isOpen = isOpen, + onOpenStateChangeRequested: (isOpen) => model.setIsOpen(isOpen), + callbackAfterUpdate: () => undefined, + } + + const appState = { + studioActive: false, + isStudioWelcomePanelActive: true, + studioTooltipDismissed: false, + } as AppState + + cy.mount(
    + +
    ) + + cy.get('[data-cy="studio-tooltip-guide"]').should('exist') + + cy.percySnapshot() + }) + + it('should handle dismissing studio tooltip guide and launching studio', () => { + const model = { + id: 'test-id', + isOpen: false, + level: 0, + state: 'passed', + title: 'foobar', + attempts: [], + setIsOpen: (isOpen) => model.isOpen = isOpen, + onOpenStateChangeRequested: (isOpen) => model.setIsOpen(isOpen), + callbackAfterUpdate: () => undefined, + } + + const appState = { + studioActive: false, + isStudioWelcomePanelActive: true, + studioTooltipDismissed: false, + setStudioTooltipDismissed: cy.stub().as('setStudioTooltipDismissed'), + } as unknown as AppState + + const mockEvents = { + emit: cy.stub().as('emit'), + } as unknown as Events + + cy.mount(
    + +
    ) + + cy.get('[data-cy="studio-tooltip-guide"]').click({ force: true }) + + cy.get('@setStudioTooltipDismissed').should('have.been.calledWith', true) + cy.get('@emit').should('have.been.calledWith', 'save:state') + cy.get('@emit').should('have.been.calledWith', 'studio:init:test', { testId: model.id }) + + cy.get('[data-cy="dismiss-studio-tooltip-icon"]').should('not.exist') + }) }) diff --git a/packages/reporter/src/test/test.scss b/packages/reporter/src/test/test.scss new file mode 100644 index 00000000000..dabcf73e617 --- /dev/null +++ b/packages/reporter/src/test/test.scss @@ -0,0 +1,27 @@ +@import "../lib/variables.scss"; + +.launch-studio-tooltip { + background-color: $gray-900; + border-color: $gray-900; + min-width: 375px; + padding: 0; + + .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 b63e41630d2..ff8794cc88d 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 { IconCypressStudio } from '@cypress-design/react-icon' +import React, { MouseEvent, useCallback, useState } from 'react' +import { IconActionDeleteSmall, IconCypressStudio, IconGeneralSparkleSingleLarge } from '@cypress-design/react-icon' import events, { Events } from '../lib/events' import appState, { AppState } from '../lib/app-state' @@ -11,6 +11,7 @@ import StateIcon from '../lib/state-icon' import { LaunchStudioIcon } from '../components/LaunchStudioIcon' import { useScrollIntoView } from '../lib/useScrollIntoView' import { SelfHealedBadge } from '../lib/selfHealedBadge' +import Button from '@cypress-design/react-button' interface TestProps { events?: Events @@ -18,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() @@ -34,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() @@ -61,18 +74,55 @@ const Test: React.FC = observer(({ model, events: eventsProps = event const isRunningAllSpecs = spec?.relative === '__all' if (studioEnabled && !appStateProps.studioActive && model.state !== 'pending' && !isRunningAllSpecs) { - controls.push( - -
    -
    Edit in Studio
    -
    - } - onClick={_launchStudio} - />, - ) + if (appStateProps.isStudioWelcomePanelActive && isFirstTest && !appStateProps.studioTooltipDismissed) { + controls.push( + +
    + Edit test in studio + +
    + + + Open a test in Studio to make edits + + + + Refine test with AI recommendations + Coming soon + + + } + onClick={(e) => { + _handleDismissStudioTooltip(e) + _launchStudio(e) + }} + className='launch-studio-tooltip' + wrapperClassName='edit-in-studio-tooltip' + visible={firstTestTooltipVisible} + dataCy="studio-tooltip-guide" + />, + ) + } 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 }>