misc: Add more spec actions button and new back button for Studio mode (#32611)

* chore: Add more spec actions button

* Update styles, and update studio components

* Refactor Reporter components to streamline studio test handling

- Removed `displayStatsAndControls` prop from `Header` and adjusted rendering logic based on `isStudioSingleTest`.
- Updated `RunnableHeader` and `RunnablePopoverOptions` to conditionally render elements based on `isStudioSingleTest`.
- Improved code readability and maintainability by consolidating logic related to studio tests.

* Refactor Header and StudioTest components for improved styling and functionality

- Updated the `Header` component to remove the `isStudioSingleTest` prop from `RunnableHeader` rendering.
- Modified the `StudioTest` component to change the button styling from 'outline-dark' to 'outline-indigo' and updated the stroke color of the icon accordingly.

* Update tests

* Update tests

* Fix styles

* changelog entry

* Add all tests tooltip

* Update styles

* maybe make semantic-commit happy

---------

Co-authored-by: Jennifer Shehane <jennifer@cypress.io>
Co-authored-by: Jennifer Shehane <shehane.jennifer@gmail.com>
This commit is contained in:
Alejandro Estrada
2025-10-03 09:52:02 -05:00
committed by GitHub
parent ac0ad316a6
commit 3909ed07a0
27 changed files with 502 additions and 310 deletions

View File

@@ -15,6 +15,8 @@ _Released 10/07/2025 (PENDING)_
**Misc:**
- Added a dropdown menu in the Command Log that includes actions like Open in IDE and Add New Test in Studio, along with test preferences such as Auto-Scroll. Addresses [#32556](https://github.com/cypress-io/cypress/issues/32556) and [#32558](https://github.com/cypress-io/cypress/issues/32558). Addressed in [#32611](https://github.com/cypress-io/cypress/pull/32611).
- Updated the Studio test editing header to include a Back button. This change ensures the Specs button remains functional for expanding or collapsing the specs panel. Addresses [#32556](https://github.com/cypress-io/cypress/issues/32556) and [#32558](https://github.com/cypress-io/cypress/issues/32558). Addressed in [#32611](https://github.com/cypress-io/cypress/pull/32611).
- Fixed the Studio panel resizing when dragging. Addressed in [#32584](https://github.com/cypress-io/cypress/pull/32584).
## 15.3.0

View File

@@ -40,7 +40,7 @@ describe('Reporter Header', () => {
})
})
context('Testing Preferences', () => {
context('More actions button', () => {
const switchSelector = '[data-cy=auto-scroll-switch]'
context('preferences menu', () => {
@@ -54,19 +54,23 @@ describe('Reporter Header', () => {
cy.waitForSpecToFinish()
})
it('clicking the down arrow will open a panel showing Testing Preferences', () => {
cy.get('[data-cy=testing-preferences-toggle]').trigger('mouseover')
cy.get('.cy-tooltip').should('have.text', 'Open Testing Preferences')
it('clicking down more options will open a popover with more options', () => {
cy.get('[data-cy="runnable-options-button"]').trigger('mouseover')
cy.get('.cy-tooltip').should('have.text', 'Options')
cy.get('.testing-preferences').should('not.exist')
cy.get('[data-cy=testing-preferences-toggle]').click()
cy.get('.testing-preferences').should('be.visible')
cy.get('[data-cy=testing-preferences-toggle]').click()
cy.get('.testing-preferences').should('not.exist')
cy.get('[data-cy="more-options-runnable-popover"]').should('not.exist')
cy.get('[data-cy="runnable-options-button"]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('be.visible')
cy.get('[data-cy="runnable-options-button"]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('not.exist')
})
it('will show a toggle beside the auto-scrolling option', () => {
cy.get('[data-cy=testing-preferences-toggle]').click()
it('will show multiples actions in the popover', () => {
cy.get('[data-cy="runnable-options-button"]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('be.visible')
cy.get('[data-cy="more-options-runnable-popover"]').should('contain', 'Open in IDE')
cy.get('[data-cy="more-options-runnable-popover"]').should('contain', 'New test')
cy.get('[data-cy="more-options-runnable-popover"]').should('contain', 'Auto-scrolling')
cy.get(switchSelector).invoke('attr', 'aria-checked').should('eq', 'true')
cy.get(switchSelector).click()
cy.get(switchSelector).invoke('attr', 'aria-checked').should('eq', 'false')
@@ -95,7 +99,9 @@ describe('Reporter Header', () => {
})
})
cy.get('[data-cy=testing-preferences-toggle]').click()
cy.get('[data-cy="runnable-options-button"]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('be.visible')
cy.get(switchSelector).invoke('attr', 'aria-checked').should('eq', 'true')
})
})

View File

@@ -171,9 +171,9 @@ describe('src/cypress/runner', () => {
o.sinon.stub(ctx.actions.file, 'openFile')
})
cy.get('.open-in-ide-button').should('have.css', 'opacity', '0')
cy.get('.spec-file-name').realHover()
cy.get('.open-in-ide-button').first().should('have.css', 'opacity', '1').click()
cy.get('[data-cy="runnable-options-button"]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('be.visible')
cy.get('[data-cy="runnable-popover-open-ide"]').click()
cy.withCtx((ctx, o) => {
expect(ctx.actions.file.openFile).to.have.been.calledWith(o.sinon.match(new RegExp(`simple-cy-assert\.runner\.cy\.js$`)), 1, 1)

View File

@@ -30,7 +30,9 @@ export function launchStudio ({ specName = 'spec.cy.js', createNewTestFromSuite
if (createNewTestFromSuite || createNewTestFromSpecHeader) {
if (createNewTestFromSpecHeader) {
cy.findByTestId('create-new-test-from-spec-header').click()
cy.get('[data-cy="runnable-options-button"]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('be.visible')
cy.get('[data-cy="runnable-popover-new-test"]').click()
} else {
cy.get('@runnable-wrapper').realHover().findByTestId('create-new-test-from-suite').click()
}

View File

@@ -623,10 +623,13 @@ describe('studio functionality', () => {
o.sinon.stub(ctx.actions.file, 'openFile')
})
cy.get('.open-in-ide-button').should('have.css', 'opacity', '0')
cy.get('.spec-file-name').first().realHover()
cy.get('.open-in-ide-button').first().should('have.css', 'opacity', '1').click()
cy.get('.open-in-ide-button').first().contains('Open in IDE')
cy.get('[data-cy="runnable-options-button"]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('be.visible')
cy.get('[data-cy="runnable-popover-open-ide"]').contains('Open in IDE')
cy.get('[data-cy="runnable-popover-open-ide"]').click()
cy.contains('External editor preferences')
cy.percySnapshot()
})

View File

@@ -103,24 +103,28 @@ describe('header', () => {
runner.emit('run:start')
})
describe('preferences menu', () => {
it('can be toggled', () => {
cy.get('.testing-preferences').should('not.exist')
cy.get('[data-cy=testing-preferences-toggle]').click()
cy.get('.testing-preferences').should('be.visible')
cy.get('[data-cy=testing-preferences-toggle]').click()
cy.get('.testing-preferences').should('not.exist')
describe('more options menu', () => {
it('can be opened', () => {
cy.get('[data-cy="runnable-options-button"]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('be.visible')
cy.get('[data-cy="runnable-options-button"]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('not.exist')
cy.get('[data-cy="runnable-options-button"]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('be.visible')
})
it('has tooltip', () => {
cy.get('[data-cy=testing-preferences-toggle]').trigger('mouseover')
cy.get('.cy-tooltip').should('have.text', 'Open Testing Preferences')
cy.get('[data-cy="runnable-options-button"]').trigger('mouseover')
cy.get('.cy-tooltip').should('have.text', 'Options')
})
it('shows when auto-scrolling is enabled and can disable it', () => {
const switchSelector = '[data-cy=auto-scroll-switch]'
cy.get('[data-cy=testing-preferences-toggle]').click()
cy.get('[data-cy="runnable-options-button"]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('be.visible')
cy.get(switchSelector).invoke('attr', 'aria-checked').should('eq', 'true')
cy.get(switchSelector).click()
cy.get(switchSelector).invoke('attr', 'aria-checked').should('eq', 'false')
@@ -129,7 +133,9 @@ describe('header', () => {
it('can be toggled with shortcut', () => {
const switchSelector = '[data-cy=auto-scroll-switch]'
cy.get('[data-cy=testing-preferences-toggle]').click()
cy.get('[data-cy="runnable-options-button"]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('be.visible')
cy.get(switchSelector).invoke('attr', 'aria-checked').should('eq', 'true')
cy.get('body').type('a').then(() => {
cy.get(switchSelector).invoke('attr', 'aria-checked').should('eq', 'false')
@@ -138,11 +144,34 @@ describe('header', () => {
it('the auto-scroll toggle emits save:state event when clicked', () => {
cy.spy(runner, 'emit')
cy.get('[data-cy=testing-preferences-toggle]').click()
cy.get('[data-cy="runnable-options-button"]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('be.visible')
cy.get('[data-cy=auto-scroll-switch]').click()
cy.wrap(runner.emit).should('be.calledWith', 'save:state')
cy.percySnapshot()
})
it('opens the open in IDE button', () => {
cy.spy(runner, 'emit')
cy.get('[data-cy="runnable-options-button"]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('be.visible')
cy.get('[data-cy="runnable-popover-open-ide"]').click()
cy.wrap(runner.emit).should('be.calledWith', 'open:file:unified')
})
it('opens the new test button', () => {
cy.spy(runner, 'emit')
cy.get('[data-cy="runnable-options-button"]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('be.visible')
cy.get('[data-cy="runnable-popover-new-test"]').click()
cy.wrap(runner.emit).should('be.calledWith', 'studio:init:suite')
})
})
describe('stop button', () => {

View File

@@ -132,7 +132,7 @@ describe('runnables', () => {
runner.emit('reporter:start', { startTime: startTime.toISOString() })
})
cy.get('.runnable-header span:last').should('have.text', '00:12')
cy.get('.runnable-header .duration').should('have.text', '00:12')
})
it('does not display time if no time taken', () => {
@@ -208,8 +208,9 @@ describe('runnables', () => {
cy.stub(runner, 'emit').callThrough()
cy.get(selector).as('spec-title').contains('foo.js').realHover()
cy.get('.open-in-ide-button').click()
cy.get('[data-cy="runnable-options-button"]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('be.visible')
cy.get('[data-cy="runnable-popover-open-ide"]').click()
cy.get(selector).click().then(() => {
expect(runner.emit).to.be.calledWith('open:file:unified')
})

View File

@@ -118,11 +118,15 @@ describe('shortcuts', function () {
it('toggles auto-scrolling', () => {
cy.get('body').type('a')
cy.get('[data-cy=testing-preferences-toggle]').click()
cy.get('[data-cy="runnable-options-button"]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('be.visible')
cy.get('[data-cy=auto-scroll-switch]').invoke('attr', 'aria-checked').should('eq', 'false')
cy.get('[data-cy=testing-preferences-toggle]').click()
cy.get('[data-cy=runnable-options-button]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('not.exist')
cy.get('body').type('a')
cy.get('[data-cy=testing-preferences-toggle]').click()
cy.get('[data-cy="runnable-options-button"]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('be.visible')
cy.get('[data-cy=auto-scroll-switch]').invoke('attr', 'aria-checked').should('eq', 'true')
})

View File

@@ -60,19 +60,18 @@ describe('spec title', () => {
cy.percySnapshot()
})
it('displays Open in IDE button on spec name hover', () => {
cy.get('.open-in-ide-button').should('have.css', 'opacity', '0')
cy.get('.spec-file-name').realHover()
cy.get('.open-in-ide-button').should('have.css', 'opacity', '1')
cy.get('.open-in-ide-button').contains('Open in IDE')
it('displays Open in IDE button on more actions button', () => {
cy.get('[data-cy="runnable-options-button"]').click()
cy.get('[data-cy="more-options-runnable-popover"]').should('be.visible')
cy.get('[data-cy="runnable-popover-open-ide"]').contains('Open in IDE')
cy.percySnapshot()
})
itHandlesFileOpening({
getRunner: () => runner,
selector: '.open-in-ide-button',
previousClickSelector: '[data-cy="runnable-options-button"]',
selector: '[data-cy="runnable-popover-open-ide"]',
file: {
file: '/absolute/path/to/foo.js',
line: 0,

View File

@@ -141,25 +141,6 @@ describe('app state', () => {
})
})
context('#togglePreferencesMenu', () => {
it('toggles isPreferencesMenuOpen', () => {
const instance = new AppState()
instance.togglePreferencesMenu()
expect(instance.isPreferencesMenuOpen).to.be.true
instance.togglePreferencesMenu()
expect(instance.isPreferencesMenuOpen).to.be.false
})
it('sets reset value for autoScrollingEnabled', () => {
const instance = new AppState()
instance.togglePreferencesMenu()
instance.reset()
expect(instance.autoScrollingEnabled).to.be.true
})
})
context('#setStudioActive', () => {
it('sets studioActive', () => {
const instance = new AppState()

View File

@@ -14,9 +14,10 @@ interface HandlesFileOpeningProps {
selector: string
file: File
stackTrace?: boolean
previousClickSelector?: string
}
export const itHandlesFileOpening = ({ getRunner, selector, file, stackTrace = false }: HandlesFileOpeningProps) => {
export const itHandlesFileOpening = ({ getRunner, selector, file, stackTrace = false, previousClickSelector }: HandlesFileOpeningProps) => {
describe('it handles file opening', () => {
it('emits unified file open event', () => {
cy.stub(getRunner(), 'emit').callThrough()
@@ -25,6 +26,10 @@ export const itHandlesFileOpening = ({ getRunner, selector, file, stackTrace = f
cy.contains('Stack trace').click()
}
if (previousClickSelector) {
cy.get(previousClickSelector).click()
}
cy.get(selector).first().click().then(() => {
expect(getRunner().emit).to.be.calledWith('open:file:unified')
})

View File

@@ -1,4 +1,3 @@
import { action } from 'mobx'
import { observer } from 'mobx-react'
import React from 'react'
import Button from '@cypress-design/react-button'
@@ -8,7 +7,7 @@ import Tooltip from '@cypress/react-tooltip'
import defaultEvents, { Events } from '../lib/events'
import type { AppState } from '../lib/app-state'
import { IconChevronDownSmall, IconChevronUpSmall, IconActionNext, IconActionPlayLarge, IconActionRestart, IconActionStopCircle } from '@cypress-design/react-icon'
import { IconActionNext, IconActionPlayLarge, IconActionRestart, IconActionStopCircle } from '@cypress-design/react-icon'
const iconStrokeColor = 'gray-500'
const iconFillColor = 'gray-900'
@@ -16,37 +15,13 @@ const iconFillColor = 'gray-900'
interface Props {
events?: Events
appState: AppState
displayPreferencesButton?: boolean
}
const Controls: React.FC<Props> = observer(({ events = defaultEvents, appState, displayPreferencesButton = true }: Props) => {
const Controls: React.FC<Props> = observer(({ events = defaultEvents, appState }: Props) => {
const emit = (event: string) => () => events.emit(event)
const togglePreferencesMenu = () => {
appState.togglePreferencesMenu()
events.emit('save:state')
}
return (
<div className='controls'>
{displayPreferencesButton && (
<Tooltip placement='bottom' title={<p>Open Testing Preferences</p>} className='cy-tooltip'>
<div>
<Button
size='20'
variant='outline-dark'
aria-label='Open testing preferences'
data-cy='testing-preferences-toggle'
onClick={action('toggle:preferences:menu', togglePreferencesMenu)}
>
{appState.isPreferencesMenuOpen ? (
<IconChevronUpSmall strokeColor='gray-500' />
) : (
<IconChevronDownSmall strokeColor='gray-500' />
)}
</Button>
</div>
</Tooltip>
)}
{appState.isPaused && (
<Tooltip placement='bottom' title={<p>Resume <span className='kbd'>C</span></p>} className='cy-tooltip'>
<div>

View File

@@ -22,6 +22,8 @@ export interface ReporterHeaderProps {
}
const Header: React.FC<ReporterHeaderProps> = observer(({ appState, events = defaultEvents, statsStore, runnablesStore, spec }: ReporterHeaderProps) => {
const isStudioSingleTest = appState?.studioActive && appState.studioSingleTestActive
return <header>
<div className='spec-container'>
<Tooltip placement='bottom' title={<p>{appState.isSpecsListOpen ? 'Collapse' : 'Expand'} Specs List <span className='kbd'>F</span></p>} wrapperClassName='toggle-specs-wrapper' className='cy-tooltip'>
@@ -46,10 +48,10 @@ const Header: React.FC<ReporterHeaderProps> = observer(({ appState, events = def
</Tooltip>
{spec && <RunnableHeader spec={spec} statsStore={statsStore} runnablesStore={runnablesStore} />}
</div>
<div className='statsAndControls'>
{!isStudioSingleTest && <div className='statsAndControls'>
<Stats stats={statsStore} />
<Controls appState={appState} />
</div>
</div>}
</header>
})

View File

@@ -4,7 +4,6 @@ import { observable, makeObservable } from 'mobx'
interface DefaultAppState {
isPaused: boolean
isRunning: boolean
isPreferencesMenuOpen: boolean
nextCommandName: string | null | undefined
pinnedSnapshotId: number | string | null
studioActive: boolean
@@ -16,7 +15,6 @@ interface DefaultAppState {
const defaults: DefaultAppState = {
isPaused: false,
isRunning: false,
isPreferencesMenuOpen: false,
nextCommandName: null,
pinnedSnapshotId: null,
studioActive: false,
@@ -29,7 +27,6 @@ class AppState {
isSpecsListOpen = false
isPaused = defaults.isPaused
isRunning = defaults.isRunning
isPreferencesMenuOpen = defaults.isPreferencesMenuOpen
nextCommandName = defaults.nextCommandName
pinnedSnapshotId = defaults.pinnedSnapshotId
studioActive = defaults.studioActive
@@ -45,7 +42,6 @@ class AppState {
isSpecsListOpen: observable,
isPaused: observable,
isRunning: observable,
isPreferencesMenuOpen: observable,
nextCommandName: observable,
pinnedSnapshotId: observable,
studioActive: observable,
@@ -99,10 +95,6 @@ class AppState {
this.isSpecsListOpen = !this.isSpecsListOpen
}
togglePreferencesMenu () {
this.isPreferencesMenuOpen = !this.isPreferencesMenuOpen
}
setSpecsList (status: boolean) {
this.isSpecsListOpen = status
}

View File

@@ -15,9 +15,7 @@ import shortcuts from './lib/shortcuts'
import Header, { ReporterHeaderProps } from './header/header'
import Runnables from './runnables/runnables'
import TestingPreferences from './preferences/testing-preferences'
import type { MobxRunnerStore } from '@packages/app/src/store/mobx-runner-store'
import { StudioTestHeader } from './studio/StudioTestHeader'
function usePrevious (value) {
const ref = useRef()
@@ -105,18 +103,12 @@ const Reporter: React.FC<SingleReporterProps> = observer(({ appState = appStateD
}
}, [runnerStore.spec, runnerStore.specRunId, resetStatsOnSpecChange, previousSpecRunId])
const isStudioSingleTest = appState?.studioActive && appState.studioSingleTestActive
return (
<div className={cs(className, 'reporter', {
'mounted': isMounted,
})}>
{isStudioSingleTest && runnerStore.spec ? <StudioTestHeader
spec={runnerStore.spec}
/> : renderReporterHeader({ appState, statsStore, runnablesStore, spec: runnerStore.spec })}
{appState?.isPreferencesMenuOpen ? (
<TestingPreferences appState={appState} />
) : (
{renderReporterHeader({ appState, statsStore, runnablesStore, spec: runnerStore.spec })}
{
runnerStore.spec && <Runnables
appState={appState}
error={error}
@@ -127,7 +119,7 @@ const Reporter: React.FC<SingleReporterProps> = observer(({ appState = appStateD
studioEnabled={studioEnabled}
canSaveStudioLogs={runnerStore.canSaveStudioLogs}
/>
)}
}
</div>
)
})

View File

@@ -1,26 +0,0 @@
.testing-preferences {
.testing-preferences-header {
@include inner-header;
font-size: 16px;
font-weight: 400;
color: $gray-700;
&::before {
width: 0;
}
}
.testing-preference {
color: $gray-600;
margin: 16px;
.testing-preference-header {
color: $white;
display: flex;
font-size: 16px;
font-weight: 600;
justify-content: space-between;
margin-bottom: 8px;
}
}
}

View File

@@ -1,48 +0,0 @@
import { action } from 'mobx'
import { observer } from 'mobx-react'
import React from 'react'
import type { AppState } from '../lib/app-state'
import defaultEvents, { Events } from '../lib/events'
import Switch from '../lib/switch'
interface Props {
events?: Events
appState: AppState
}
const TestingPreferences: React.FC<Props> = observer(({
events = defaultEvents,
appState,
}: Props) => {
const toggleAutoScrollingUserPref = () => {
appState.toggleAutoScrollingUserPref()
events.emit('save:state')
}
return (
<div className="testing-preferences">
<div className="testing-preferences-header">
Testing Preferences
</div>
<div className="testing-preference">
<div className="testing-preference-header">
Auto-scrolling
<Switch
data-cy="auto-scroll-switch"
value={appState.autoScrollingUserPref}
onUpdate={action('toggle:auto:scrolling', toggleAutoScrollingUserPref)}
/>
</div>
<div>
Automatically scroll the command log while the tests are running.
</div>
</div>
</div>
)
})
TestingPreferences.displayName = 'TestingPreferences'
export default TestingPreferences

View File

@@ -6,6 +6,8 @@ import { RunnablesStore } from './runnables-store'
import { DebugDismiss } from '../header/DebugDismiss'
import { Duration } from '../duration/duration'
import { SpecFileName } from '../shared/SpecFileName'
import { RunnablePopoverOptions } from './runnable-popover-options'
import appState from '../lib/app-state'
const renderRunnableHeader = (children: ReactElement) => <div className="runnable-header" data-cy="runnable-header">{children}</div>
@@ -28,11 +30,14 @@ const RunnableHeader: React.FC<RunnableHeaderProps> = observer(({ spec, statsSto
)
}
const isStudioSingleTest = appState?.studioActive && appState.studioSingleTestActive
return renderRunnableHeader(
<>
<SpecFileName spec={spec} />
{runnablesStore.testFilter && runnablesStore.totalTests > 0 && <DebugDismiss matched={runnablesStore.totalTests} total={runnablesStore.totalUnfilteredTests} />}
<Duration duration={statsStore.duration} />
{!isStudioSingleTest && <Duration duration={statsStore.duration} />}
<RunnablePopoverOptions spec={spec} />
</>,
)
})

View File

@@ -0,0 +1,206 @@
import { action } from 'mobx'
import { observer } from 'mobx-react'
import React, { useState, useRef, useEffect } from 'react'
import { createPortal } from 'react-dom'
import cs from 'classnames'
import Tooltip from '@cypress/react-tooltip'
import Button from '@cypress-design/react-button'
import { IconActionAddMedium, IconWindowCodeEditor, IconMenuDotsVertical } from '@cypress-design/react-icon'
import defaultEvents, { Events } from '../lib/events'
import Switch from '../lib/switch'
import appState from '../lib/app-state'
interface Props {
events?: Events
spec: Cypress.Cypress['spec']
}
export const RunnablePopoverOptions: React.FC<Props> = observer(({
events = defaultEvents,
spec,
}: Props) => {
const relativeSpecPath = spec.relative
const isStudioSingleTest = appState?.studioActive && appState.studioSingleTestActive
const fileDetails = {
absoluteFile: spec.absolute,
column: 0,
displayFile: spec.name,
line: 0,
originalFile: relativeSpecPath,
relativeFile: relativeSpecPath,
}
const [isOpen, setIsOpen] = useState(false)
const [popoverPosition, setPopoverPosition] = useState({ top: 0, left: 0 })
const popoverRef = useRef<HTMLDivElement>(null)
const buttonContainerRef = useRef<HTMLDivElement>(null)
const togglePopover = () => {
if (!isOpen && buttonContainerRef.current) {
const rect = buttonContainerRef.current.getBoundingClientRect()
setPopoverPosition({
top: rect.bottom + 4,
left: rect.right - 250, // 250px is the popover width
})
}
setIsOpen(!isOpen)
}
const handleOpenInIDE = () => {
events.emit('open:file:unified', fileDetails)
setIsOpen(false)
}
const handleNewTest = () => {
events.emit('studio:init:suite', { suiteId: 'r1' })
setIsOpen(false)
}
const toggleAutoScrollingUserPref = () => {
appState.toggleAutoScrollingUserPref()
events.emit('save:state')
}
// TODO: to be implemented
// const toggleShowHttpRequests = () => {}
// Close popover when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
isOpen &&
popoverRef.current &&
buttonContainerRef.current &&
!popoverRef.current.contains(event.target as Node) &&
!buttonContainerRef.current.contains(event.target as Node)
) {
setIsOpen(false)
}
}
const handleScroll = () => {
if (isOpen) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
window.addEventListener('scroll', handleScroll, true)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
window.removeEventListener('scroll', handleScroll, true)
}
}, [isOpen])
const popoverContent = isOpen && (
<div
ref={popoverRef}
className="runnable-popover"
data-cy="more-options-runnable-popover"
style={{
top: `${popoverPosition.top}px`,
left: `${popoverPosition.left}px`,
}}
>
<div className="runnable-popover-section">
<div className="runnable-popover-section-title">This spec</div>
<button
className="runnable-popover-item"
onClick={handleOpenInIDE}
data-cy="runnable-popover-open-ide"
>
<IconWindowCodeEditor strokeColor="gray-500" fillColor="gray-500" />
<span>Open in IDE</span>
</button>
{!isStudioSingleTest && <button
className="runnable-popover-item"
onClick={handleNewTest}
data-cy="runnable-popover-new-test"
>
<IconActionAddMedium strokeColor="gray-500" />
<span>New test</span>
</button>}
</div>
<div className="runnable-popover-section">
<div className="runnable-popover-section-title">Testing preferences</div>
{/* // TODO: to be implemented */}
{/* <div className="runnable-popover-item-with-toggle">
<div className="runnable-popover-item-with-toggle-content">
<div className="runnable-popover-item-text">
<span className="runnable-popover-item-label">Show HTTP requests</span>
</div>
<Switch
data-cy="show-http-requests-switch"
value={false}
onUpdate={action('toggle:show:http:requests', toggleShowHttpRequests)}
/>
</div>
</div> */}
<div className="runnable-popover-item-with-toggle">
<div className="runnable-popover-item-with-toggle-content">
<div className="runnable-popover-item-text">
<span className="runnable-popover-item-label">Auto-scrolling</span>
</div>
<Switch
data-cy="auto-scroll-switch"
value={appState.autoScrollingUserPref}
onUpdate={action('toggle:auto:scrolling', toggleAutoScrollingUserPref)}
/>
</div>
<span className="runnable-popover-item-description">
Automatically scroll the command log while the tests are running.
</span>
</div>
</div>
</div>
)
const buttonComponent = () => (
<div>
<Button
size="32"
variant="outline-indigo"
aria-label="Options"
aria-expanded={isOpen}
data-cy="runnable-options-button"
onClick={togglePopover}
className={cs('runnable-options-button', {
'runnable-options-button-border': !isOpen,
})}
>
<IconMenuDotsVertical className='runnable-options-button-icon' />
</Button>
</div>
)
return (
<>
<div className="runnable-popover-container" ref={buttonContainerRef}>
{
isOpen ? buttonComponent() : (
<Tooltip placement='bottom' title={<p>Options</p>} className='cy-tooltip'>
{buttonComponent()}
</Tooltip>
)
}
</div>
{isOpen && createPortal(popoverContent, document.body)}
</>
)
})

View File

@@ -579,3 +579,114 @@ $dotted-line-left-padding: 19px;
}
}
}
.runnable-popover-container {
position: relative;
display: inline-block;
.runnable-options-button {
padding: 0;
width: 32px;
justify-content: center;
color: $gray-500;
}
.runnable-options-button-border {
border-color: rgb(255 255 255 / 0.2);
}
.runnable-options-button-icon {
rotate: 90deg;
}
}
.runnable-popover {
position: fixed;
z-index: 9999;
background: white;
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
width: 250px;
max-width: 250px;
padding: 8px;
font-family: $font-system;
.runnable-popover-section {
&:not(:last-child) {
border-bottom: 1px solid $gray-50;
}
.runnable-popover-section-title {
font-size: 14px;
font-weight: 500;
color: $gray-600;
padding: 8px 6px 6px 6px;
}
}
.runnable-popover-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 6px;
border: none;
background: transparent;
cursor: pointer;
font-size: 14px;
font-weight: 400;
color: $gray-900;
text-align: left;
&:hover {
background-color: $gray-100;
}
svg {
flex-shrink: 0;
width: 16px;
height: 16px;
}
span {
flex: 1;
}
}
.runnable-popover-item-with-toggle {
.runnable-popover-item-with-toggle-content {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
color: $gray-900;
padding: 8px 6px;
.runnable-popover-item-text {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
.runnable-popover-item-label {
font-size: 14px;
font-weight: 400;
}
}
}
.runnable-popover-item-description {
font-size: 14px;
color: $gray-700;
line-height: 20px;
font-weight: 400;
padding: 0 6px;
display: block;
}
.switch {
flex-shrink: 0;
margin-top: 2px;
}
}
}

View File

@@ -1,7 +1,5 @@
import React from 'react'
import { getFilenameParts } from '../lib/util'
import { OpenFileInIDEButton } from '../header/OpenFileInIDEButton'
import { CreateNewTestButton } from '../header/CreateNewTestButton'
const displayFileName = (spec: Cypress.Cypress['spec']) => {
const specParts = getFilenameParts(spec.name)
@@ -27,7 +25,5 @@ export const SpecFileName = ({ spec }: { spec: Cypress.Cypress['spec'] }) => {
return <div className='spec-file-name'>
{fileDetails.displayFile || fileDetails.originalFile}{!!fileDetails.line && `:${fileDetails.line}`}{!!fileDetails.column && `:${fileDetails.column}`}
<OpenFileInIDEButton fileDetails={fileDetails} />
<CreateNewTestButton suiteId='r1' dataCy='create-new-test-from-spec-header' />
</div>
}

View File

@@ -5,6 +5,7 @@ import { RunnablesStore } from '../runnables/runnables-store'
import { StatsStore } from '../header/stats-store'
import Test from '../test/test-model'
import scroller from '../lib/scroller'
import events from '../lib/events'
describe('StudioTest', () => {
let appState: AppState
@@ -97,6 +98,8 @@ describe('StudioTest', () => {
statsStore = {
duration: 1500,
} as unknown as StatsStore
cy.spy(events, 'emit').as('emitSpy')
})
it('renders component with test information', () => {
@@ -295,4 +298,21 @@ describe('StudioTest', () => {
// Should not render anything when no test is available
cy.get('.studio-single-test-container').should('not.exist')
})
it('handles back button click', () => {
cy.mount(
<StudioTest
appState={appState}
runnablesStore={runnablesStore}
statsStore={statsStore}
/>,
)
cy.get('[data-cy="studio-back-button"]').realHover()
cy.get('.cy-tooltip').should('be.visible')
cy.get('.cy-tooltip').should('contain.text', 'All tests')
cy.get('[data-cy="studio-back-button"]').click()
cy.get('@emitSpy').should('have.been.calledWith', 'studio:cancel', undefined)
})
})

View File

@@ -7,7 +7,9 @@
.studio-header__test-section {
display: flex;
align-items: flex-start;
border-bottom: 1px solid $gray-900;
border: 1px solid $gray-900;
border-left: none;
border-right: none;
padding: 16px;
gap: 8px;
font-size: 14px;
@@ -15,7 +17,8 @@
flex-shrink: 0;
.state-icon {
margin-top: 3px;
height: 32px;
min-height: 32px;
}
svg {
@@ -30,8 +33,19 @@
.studio-header__test-section-left {
display: flex;
flex-grow: 1;
gap: 8px;
margin-top: 5px;
gap: 16px;
.studio-header__back-button {
padding: 0;
width: 32px;
min-width: 32px;
justify-content: center;
}
.studio-header__test-section-left-content {
display: flex;
gap: 8px;
}
}
.studio-header__test-section-right {
@@ -44,6 +58,8 @@
color: $white;
font-weight: 500;
flex-grow: 1;
min-height: 32px;
margin-top: 5px;
}
.studio-header__test-tooltip-wrapper {

View File

@@ -1,4 +1,4 @@
import React, { useMemo, useRef } from 'react'
import React, { useCallback, useMemo, useRef } from 'react'
import { observer } from 'mobx-react'
import { RunnablesStore } from '../runnables/runnables-store'
import { Duration } from '../duration/duration'
@@ -8,9 +8,11 @@ import Tooltip from '@cypress/react-tooltip'
import cx from 'classnames'
import Attempts from '../attempts/attempts'
import { useScrollIntoView } from '../lib/useScrollIntoView'
import { IconChevronDownSmall, IconStatusFailedSolid, IconStatusPassedSolid, IconStatusQueuedOutline, IconStatusRunningOutline } from '@cypress-design/react-icon'
import { IconArrowLeft, IconChevronDownSmall, IconStatusFailedSolid, IconStatusPassedSolid, IconStatusQueuedOutline, IconStatusRunningOutline } from '@cypress-design/react-icon'
import Test from '../test/test-model'
import { StatsStore } from '../header/stats-store'
import Button from '@cypress-design/react-button'
import events from '../lib/events'
const getConnectors = (num: number) => {
let connectors: JSX.Element[] = []
@@ -70,6 +72,12 @@ export const StudioTest = observer(({ appState, runnablesStore, statsStore }: St
isStudioActive: appState.studioActive,
})
const handleBackButton = useCallback((e: React.MouseEvent<HTMLElement>) => {
e.preventDefault()
events.emit('studio:cancel', undefined)
}, [])
// Call callbackAfterUpdate when mounted and test changes
React.useEffect(() => {
if (isMounted && currentTest) {
@@ -86,21 +94,32 @@ export const StudioTest = observer(({ appState, runnablesStore, statsStore }: St
<div className='studio-single-test-container' >
<div className='studio-header__test-section'>
<div className='studio-header__test-section-left'>
<StatusIcon test={currentTest} />
{parentTitles.length > 0 ? (
<Tooltip title={<ul className='studio-tooltip__breadcrumb-list' ref={tooltipRef}>
{getParentTitlesListElements(parentTitles)}
</ul>}
wrapperClassName='studio-header__test-tooltip-wrapper' className={cx(
'studio-tooltip cy-tooltip',
)}>
{testTitle}
</Tooltip>
) : testTitle}
<Tooltip placement='bottom' title={<p>All tests</p>} className='cy-tooltip'>
<div>
<Button data-cy='studio-back-button' size='32' variant='outline-indigo' className='studio-header__back-button' onClick={handleBackButton}>
<IconArrowLeft size='16' strokeColor='indigo-400' />
</Button>
</div>
</Tooltip>
<div className='studio-header__test-section-left-content'>
<StatusIcon test={currentTest} />
{parentTitles.length > 0 ? (
<Tooltip title={<ul className='studio-tooltip__breadcrumb-list' ref={tooltipRef}>
{getParentTitlesListElements(parentTitles)}
</ul>}
wrapperClassName='studio-header__test-tooltip-wrapper' className={cx(
'studio-tooltip cy-tooltip',
)}>
{testTitle}
</Tooltip>
) : testTitle}
</div>
</div>
<div className='studio-header__test-section-right'>
<Duration duration={statsStore.duration} />
<Controls appState={appState} displayPreferencesButton={false} />
<Controls appState={appState} />
</div>
</div>
<div className='studio-single-test-attempts' ref={containerRef}>

View File

@@ -1,44 +0,0 @@
import React from 'react'
import { StudioTestHeader } from './StudioTestHeader'
import events from '../lib/events'
describe('StudioTestHeader', () => {
let mockSpec: Cypress.Cypress['spec']
beforeEach(() => {
// Mock the spec
mockSpec = {
name: 'cypress/e2e/example.cy.ts',
relative: 'cypress/e2e/example.cy.ts',
absolute: '/Users/test/cypress/e2e/example.cy.ts',
} as Cypress.Cypress['spec']
cy.spy(events, 'emit').as('emitSpy')
})
it('renders studio header with spec information', () => {
cy.mount(
<StudioTestHeader
spec={mockSpec}
/>,
)
cy.get('.studio-header').should('be.visible')
cy.get('.studio-header__file-section').should('be.visible')
cy.get('.spec-file-name').should('be.visible')
cy.get('.spec-file-name').should('contain.text', 'example.cy.ts')
cy.get('[data-cy="studio-back-button"]').should('be.visible')
cy.percySnapshot()
})
it('handles back button click', () => {
cy.mount(
<StudioTestHeader
spec={mockSpec}
/>,
)
cy.get('[data-cy="studio-back-button"]').click()
cy.get('@emitSpy').should('have.been.calledWith', 'studio:cancel', undefined)
})
})

View File

@@ -1,25 +0,0 @@
.studio-header {
display: flex;
flex-direction: column;
flex-shrink: 0;
.studio-header__file-section {
display: flex;
width: 100%;
align-items: center;
gap: 13px;
padding: 0 16px;
border-top: 1px solid $gray-900;
border-bottom: 1px solid $gray-900;
font-size: 14px;
line-height: 20px;
color: $gray-500;
min-height: $header-height;
.studio-header__back-button {
padding: 0;
width: 32px;
justify-content: center;
}
}
}

View File

@@ -1,31 +0,0 @@
import React, { useCallback } from 'react'
import { observer } from 'mobx-react'
import Button from '@cypress-design/react-button'
import { IconArrowLeft } from '@cypress-design/react-icon'
import events from '../lib/events'
import { SpecFileName } from '../shared/SpecFileName'
interface StudioHeaderProps {
spec: Cypress.Cypress['spec']
}
export const StudioTestHeader = observer(({ spec }: StudioHeaderProps) => {
const handleBackButton = useCallback((e: React.MouseEvent<HTMLElement>) => {
e.preventDefault()
events.emit('studio:cancel', undefined)
}, [])
return (
<>
<header className='studio-header'>
<div className='studio-header__file-section'>
<Button data-cy='studio-back-button' size='32' variant='outline-dark' className='studio-header__back-button' onClick={handleBackButton}>
<IconArrowLeft size='16' strokeColor='gray-500' />
</Button>
<SpecFileName spec={spec} />
</div>
</header>
</>
)
})