chore: fix deprecation warnings and refactor react reporter to use functional components and hooks (#31284)

* chore: fix deprecation warnings and refactor react reporter to use functional components with hooks

* chore: update code to reflect feedback from code review

* fix issues with scrollIntoView() on updated component and refactor isOpen logic in collapsible to not attempt to sync state

* fix issues with tests after refactor

* see if event registration fixes windows flake
This commit is contained in:
Bill Glesias
2025-04-07 10:37:19 -04:00
committed by GitHub
parent 920128a1e5
commit d691e19bdb
35 changed files with 586 additions and 731 deletions

View File

@@ -38,7 +38,7 @@ mainBuildFilters: &mainBuildFilters
- /^release\/\d+\.\d+\.\d+$/
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- 'update-v8-snapshot-cache-on-develop'
- 'renovate/webdriverio-monorepo'
- 'chore/fix_react_18_deprecation_warnings'
# usually we don't build Mac app - it takes a long time
# but sometimes we want to really confirm we are doing the right thing
@@ -49,7 +49,7 @@ macWorkflowFilters: &darwin-workflow-filters
- equal: [ develop, << pipeline.git.branch >> ]
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
- equal: [ 'renovate/webdriverio-monorepo', << pipeline.git.branch >> ]
- equal: [ 'chore/fix_react_18_deprecation_warnings', << pipeline.git.branch >> ]
- matches:
pattern: /^release\/\d+\.\d+\.\d+$/
value: << pipeline.git.branch >>
@@ -60,7 +60,7 @@ linuxArm64WorkflowFilters: &linux-arm64-workflow-filters
- equal: [ develop, << pipeline.git.branch >> ]
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
- equal: [ 'renovate/webdriverio-monorepo', << pipeline.git.branch >> ]
- equal: [ 'chore/fix_react_18_deprecation_warnings', << pipeline.git.branch >> ]
- matches:
pattern: /^release\/\d+\.\d+\.\d+$/
value: << pipeline.git.branch >>
@@ -83,7 +83,7 @@ windowsWorkflowFilters: &windows-workflow-filters
- equal: [ develop, << pipeline.git.branch >> ]
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
- equal: [ 'renovate/webdriverio-monorepo', << pipeline.git.branch >> ]
- equal: [ 'chore/fix_react_18_deprecation_warnings', << pipeline.git.branch >> ]
- matches:
pattern: /^release\/\d+\.\d+\.\d+$/
value: << pipeline.git.branch >>
@@ -157,7 +157,7 @@ commands:
name: Set environment variable to determine whether or not to persist artifacts
command: |
echo "Setting SHOULD_PERSIST_ARTIFACTS variable"
echo 'if ! [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "release/"* && "$CIRCLE_BRANCH" != "bump-win-version-info" ]]; then
echo 'if ! [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "release/"* && "$CIRCLE_BRANCH" != "chore/fix_react_18_deprecation_warnings" ]]; then
export SHOULD_PERSIST_ARTIFACTS=true
fi' >> "$BASH_ENV"
# You must run `setup_should_persist_artifacts` command and be using bash before running this command

View File

@@ -72,7 +72,9 @@ describe('Cypress In Cypress E2E', { viewportWidth: 1500, defaultCommandTimeout:
cy.get('.hook-open-in-ide').should('exist')
cy.get('#unified-runner').should('have.attr', 'style', 'width: 1000px; height: 660px; transform: scale(0.769697); position: absolute; margin-left: -25px;')
cy.get('#unified-runner').then((el) => {
expect(el[0].getAttribute('style')).to.match(/width: 1000px; height: 660px; transform: scale\(0.769\d+\); position: absolute; margin-left: -25px;/)
})
})
it('navigation between specs and other parts of the app works', () => {
@@ -168,7 +170,7 @@ describe('Cypress In Cypress E2E', { viewportWidth: 1500, defaultCommandTimeout:
cy.contains('SyntaxError')
.should('be.visible')
.invoke('outerHeight')
.should('eq', expectedAutHeight)
.should('be.closeTo', expectedAutHeight, 1)
// Checking the height here might seem excessive
// but we really want some warning if this changes
@@ -199,8 +201,12 @@ describe('Cypress In Cypress E2E', { viewportWidth: 1500, defaultCommandTimeout:
cy.get('.toggle-specs-wrapper').click()
cy.get('#unified-runner').should('have.css', 'width', '333px')
cy.get('#unified-runner').should('have.css', 'height', '333px')
cy.get('#unified-runner').then((el) => {
// CSS properties are calculated over inline styles, which means we get a close representation
// of the actual values, but not the exact values (+/- 1 pixel), hence the use of matching the style
// attribute.
expect(el[0].getAttribute('style')).to.match(/width: 333px; height: 333px/)
})
})
it('stops correctly running spec while switching specs', () => {
@@ -208,6 +214,9 @@ describe('Cypress In Cypress E2E', { viewportWidth: 1500, defaultCommandTimeout:
cy.specsPageIsVisible()
cy.contains('withFailure.spec').click()
cy.contains('[aria-controls=reporter-inline-specs-list]', 'Specs')
// A bit of a hack, but our cy-in-cy test needs to wait for the reporter to fully render before pressing the "f" key to expand the "Search specs" menu.
// Otherwise, the "f" keypress happens before the event is registered, which causes the "Search Specs" menu to not expand.
cy.get('[data-cy="runnable-header"]').should('be.visible')
cy.get('body').type('f')
cy.contains('Search specs')
cy.contains('withWait.spec').click()

View File

@@ -135,6 +135,10 @@ describe('App: Spec List (E2E)', () => {
cy.contains('[aria-controls=reporter-inline-specs-list]', 'Specs')
cy.findByText('Your tests are loading...').should('not.be.visible')
cy.contains('[aria-controls=reporter-inline-specs-list]', 'Specs')
// A bit of a hack, but our cy-in-cy test needs to wait for the reporter to fully render before pressing the "f" key to expand the "Search specs" menu.
// Otherwise, the "f" keypress happens before the event is registered, which causes the "Search Specs" menu to not expand.
cy.get('[data-cy="runnable-header"]').should('be.visible')
// open the inline spec list
cy.get('body').type('f')
@@ -363,6 +367,9 @@ describe('App: Spec List (E2E)', () => {
cy.contains('input', targetSpecFile).should('not.exist')
// A bit of a hack, but our cy-in-cy test needs to wait for the reporter to fully render before expanding the "Search specs" menu.
// Otherwise, the click happens before the event is registered, which causes the "Search Specs" menu to not expand.
cy.get('[data-cy="runnable-header"]').should('be.visible')
cy.contains('button', 'Specs').click({ force: true })
// wait until specs list is visible

View File

@@ -421,6 +421,13 @@ async function executeSpec (spec: SpecFile, isRerun: boolean = false) {
// initializes a bunch of listeners watches spec file for changes.
await getEventManager().setup(config)
if (!_eventManager) {
// with functional react components and bridging the unified runner between Vue and React,
// we sometimes get into a state where the runner has torn down the reporter, which in turn tears down the event manager,
// while we are in the process of executing the next spec. In this case, we have a no-op execute spec and need to exit early without error.
return
}
if (window.__CYPRESS_TESTING_TYPE__ === 'e2e') {
return runSpecE2E(config, spec)
}

View File

@@ -6,11 +6,19 @@ import type { EventManager } from './event-manager'
import { useRunnerUiStore } from '../store/runner-ui-store'
let hasInitializeReporter = false
let reactDomRoot: any = null
export function setInitializedReporter (val: boolean) {
hasInitializeReporter = val
}
export function unmountReporter () {
if (reactDomRoot) {
reactDomRoot.unmount()
reactDomRoot = null
}
}
async function resetReporter () {
if (hasInitializeReporter) {
await getEventManager().resetReporter()
@@ -50,7 +58,7 @@ function renderReporter (
testFilter: specsStore.testFilter,
})
const reactDomRoot = window.UnifiedRunner.ReactDOM.createRoot(root)
reactDomRoot = window.UnifiedRunner.ReactDOM.createRoot(root)
reactDomRoot.render(reporter)
}

View File

@@ -4,6 +4,7 @@ import { addCrossOriginIframe, getAutIframeModel, getEventManager, UnifiedRunner
import { useAutStore, useSpecStore } from '../store'
import { useStudioStore } from '../store/studio-store'
import { empty, getReporterElement, getRunnerElement } from './utils'
import { unmountReporter } from './reporter'
export function useEventManager () {
const eventManager = getEventManager()
@@ -85,13 +86,14 @@ export function useEventManager () {
// TODO: UNIFY-1318 - this should be handled by whoever starts it, reporter?
window.UnifiedRunner.shortcuts.stop()
const reporterElement = getReporterElement()
if (reporterElement) {
// reporter can be disabled by the user,
// so sometimes will not exist to be cleaned up
empty(reporterElement)
// NOTE: we do not use empty() on the reporter as it is written in react.
// As of React 18, its better to call unmount on the root, which effectively does the same thing as empty().
unmountReporter()
}
}

View File

@@ -13,15 +13,17 @@ export const getCommandLogWithText = (command, type?) => {
.closest('.command')
}
export const findReactInstance = function (dom) {
// This work around is super hacky to get the appState from the Test Mobx Observable Model
// this is needed to pause the runner to assert on the test
export const findAppStateFromTest = function (dom) {
let key = _.keys(dom).find((key) => key.startsWith('__reactFiber')) as string
let internalInstance = dom[key]
if (internalInstance == null) return null
return internalInstance._debugOwner
? internalInstance._debugOwner.stateNode
: internalInstance.return.stateNode
? internalInstance._debugOwner.memoizedProps.model.store.appState
: internalInstance.return.memoizedProps.model.store.appState
}
export const clickCommandLog = (sel, type?) => {
@@ -31,13 +33,17 @@ export const clickCommandLog = (sel, type?) => {
.then(() => {
const commandLogEl = getCommandLogWithText(sel, type)
const reactCommandInstance = findReactInstance(commandLogEl[0])
const activeTestEl = commandLogEl[0].closest('li.test.runnable.runnable-active')
if (!reactCommandInstance) {
// We are manually manipulating the state of the appState to stop the runner.
// This does NOT happen in the wild and is only for testing purposes.
const appStateInstance = findAppStateFromTest(activeTestEl)
if (!appStateInstance) {
assert(false, 'failed to get command log React instance')
}
reactCommandInstance.props.appState.isRunning = false
appStateInstance.isRunning = false
const inner = $(commandLogEl).find('.command-wrapper-text')
inner.get(0).click()

View File

@@ -27,7 +27,7 @@ describe('agents', () => {
})
start = () => {
cy.get('.reporter').then(() => {
cy.get('.reporter.mounted').then(() => {
runner.emit('runnables:ready', runnables)
runner.emit('reporter:start', {})
})

View File

@@ -26,7 +26,7 @@ describe('aliases', () => {
})
})
cy.get('.reporter').then(() => {
cy.get('.reporter.mounted').then(() => {
runner.emit('runnables:ready', runnables)
runner.emit('reporter:start', {})
})

View File

@@ -27,7 +27,7 @@ describe('commands', { viewportHeight: 1000 }, () => {
})
})
cy.get('.reporter').then(() => {
cy.get('.reporter.mounted').then(() => {
runner.emit('runnables:ready', runnables)
runner.emit('reporter:start', {})
addCommand(runner, {

View File

@@ -32,7 +32,7 @@ describe('header', () => {
})
})
cy.get('.reporter').then(() => {
cy.get('.reporter.mounted').then(() => {
runner.emit('runnables:ready', { ...runnables, testFilter: opts?.testFilter, totalUnfilteredTests: opts?.totalUnfilteredTests })
runner.emit('reporter:start', {})
})

View File

@@ -26,7 +26,7 @@ describe('hooks', () => {
})
})
cy.get('.reporter').then(() => {
cy.get('.reporter.mounted').then(() => {
runner.emit('runnables:ready', runnables)
runner.emit('reporter:start', {})
})

View File

@@ -29,7 +29,7 @@ function visitAndRenderReporter (studioEnabled: boolean = false, studioActive: b
})
})
cy.get('.reporter').then(() => {
cy.get('.reporter.mounted').then(() => {
runner.emit('runnables:ready', runnables)
runner.emit('reporter:start', { studioActive })
})

View File

@@ -27,7 +27,7 @@ describe('routes', () => {
})
start = () => {
cy.get('.reporter').then(() => {
cy.get('.reporter.mounted').then(() => {
runner.emit('runnables:ready', runnables)
runner.emit('reporter:start', {})
})

View File

@@ -45,7 +45,7 @@ describe('runnables', () => {
start = (renderProps?: Partial<BaseReporterProps>) => {
render(renderProps)
return cy.get('.reporter').then(() => {
return cy.get('.reporter.mounted').then(() => {
runner.emit('runnables:ready', runnables)
runner.emit('reporter:start', {})
})

View File

@@ -36,7 +36,7 @@ describe('shortcuts', function () {
})
})
cy.get('.reporter').then(() => {
cy.get('.reporter.mounted').then(() => {
runner.emit('runnables:ready', this.runnables)
runner.emit('reporter:start', {})
})

View File

@@ -13,7 +13,7 @@ describe('spec title', () => {
win.render({ runner, runnerStore: { spec } })
})
cy.get('.reporter').then(() => {
cy.get('.reporter.mounted').then(() => {
runner.emit('runnables:ready', {})
runner.emit('reporter:start', {})
})

View File

@@ -29,7 +29,7 @@ describe('suites', () => {
})
})
cy.get('.reporter').then(() => {
cy.get('.reporter.mounted').then(() => {
runner.emit('runnables:ready', runnables)
runner.emit('reporter:start', {})
})

View File

@@ -20,7 +20,7 @@ describe('test errors', () => {
// @ts-ignore
runnablesWithErr.suites[0].tests[0].err = err
cy.get('.reporter').then(() => {
cy.get('.reporter.mounted').then(() => {
runner.emit('runnables:ready', runnablesWithErr)
runner.emit('reporter:start', {})
})

View File

@@ -28,7 +28,7 @@ function visitAndRenderReporter (studioEnabled: boolean = false, studioActive: b
})
})
cy.get('.reporter').then(() => {
cy.get('.reporter.mounted').then(() => {
runner.emit('runnables:ready', runnables)
runner.emit('reporter:start', { studioActive })
})

View File

@@ -200,6 +200,10 @@ export default class Attempt {
}
}
@action setIsOpen (isOpen: boolean) {
this._isOpen = isOpen
}
@action finish (props: UpdatableTestProps, isInteractive: boolean) {
this.update(props)
this.isActive = false

View File

@@ -1,6 +1,6 @@
import cs from 'classnames'
import { observer } from 'mobx-react'
import React, { Component } from 'react'
import React, { useEffect, useState } from 'react'
import type { TestState } from '@packages/types'
import Agents from '../agents/agents'
@@ -35,73 +35,62 @@ const AttemptHeader = ({ index, state }: {index: number, state: TestState }) =>
</span>
)
const StudioError = () => (
<div className='runnable-err-wrapper studio-err-wrapper'>
<div className='runnable-err'>
<div className='runnable-err-message'>
Studio cannot add commands to a failing test.
</div>
</div>
</div>
)
function renderAttemptContent (model: AttemptModel, studioActive: boolean) {
// performance optimization - don't render contents if not open
return (
<div className={`attempt-${model.id + 1}`}>
<Sessions model={model.sessions} />
<Agents model={model} />
<Routes model={model} />
<div ref='commands' className='runnable-commands-region'>
{model.hasCommands ? <Hooks model={model} /> : <NoCommands />}
</div>
{model.state === 'failed' && (
<div className='attempt-error-region'>
<TestError {...model.error} />
{studioActive && <StudioError />}
</div>
)}
</div>
)
}
interface AttemptProps {
model: AttemptModel
scrollIntoView: Function
studioActive: boolean
}
@observer
class Attempt extends Component<AttemptProps> {
componentDidUpdate () {
this.props.scrollIntoView()
}
const Attempt: React.FC<AttemptProps> = observer(({ model, scrollIntoView, studioActive }) => {
const [isMounted, setIsMounted] = useState(false)
render () {
const { model, studioActive } = this.props
useEffect(() => {
if (isMounted) {
scrollIntoView()
} else {
setIsMounted(true)
}
})
// HACK: causes component update when command log is added
model.commands.length
return (
<li
key={model.id}
className={cs('attempt-item', `attempt-state-${model.state}`)}
ref="container"
return (
<li
key={model.id}
className={cs('attempt-item', `attempt-state-${model.state}`)}
>
<Collapsible
header={<AttemptHeader index={model.id} state={model.state} />}
hideExpander
headerClass='attempt-name'
contentClass='attempt-content'
isOpen={model.isOpen}
onOpenStateChangeRequested={(isOpen: boolean) => model.setIsOpen(isOpen)}
>
<Collapsible
header={<AttemptHeader index={model.id} state={model.state} />}
hideExpander
headerClass='attempt-name'
contentClass='attempt-content'
isOpen={model.isOpen}
>
{renderAttemptContent(model, studioActive)}
</Collapsible>
</li>
)
}
}
<div className={`attempt-${model.id + 1}`}>
<Sessions model={model.sessions} />
<Agents model={model} />
<Routes model={model} />
<div className='runnable-commands-region'>
{model.hasCommands ? <Hooks model={model} /> : <NoCommands />}
</div>
{model.state === 'failed' && (
<div className='attempt-error-region'>
<TestError {...model.error} />
{studioActive && (
<div className='runnable-err-wrapper studio-err-wrapper'>
<div className='runnable-err'>
<div className='runnable-err-message'>
Studio cannot add commands to a failing test.
</div>
</div>
</div>
)}
</div>
)}
</div>
</Collapsible>
</li>
)
})
const Attempts = observer(({ test, scrollIntoView, studioActive }: {test: TestModel, scrollIntoView: Function, studioActive: boolean}) => {
return (<ul className={cs('attempts', {

View File

@@ -1,11 +1,9 @@
import cs from 'classnames'
import React, { Component, CSSProperties, MouseEvent, ReactNode, RefObject } from 'react'
import React, { CSSProperties, MouseEvent, ReactNode, RefObject, useCallback, useState } from 'react'
import { onEnterOrSpace } from '../lib/util'
import ChevronIcon from '@packages/frontend-shared/src/assets/icons/chevron-down-small_x8.svg'
interface Props {
interface CollapsibleProps {
isOpen?: boolean
headerClass?: string
headerStyle?: CSSProperties
@@ -13,81 +11,56 @@ interface Props {
headerExtras?: ReactNode
containerRef?: RefObject<HTMLDivElement>
contentClass?: string
hideExpander: boolean
hideExpander?: boolean
children?: ReactNode
onOpenStateChangeRequested?: (isOpen: boolean) => void
}
interface State {
isOpen: boolean
}
const Collapsible: React.FC<CollapsibleProps> = ({ isOpen: isOpenAsProp = false, header, headerClass = '', headerStyle = {}, headerExtras, contentClass = '', hideExpander = false, containerRef = null, onOpenStateChangeRequested, children }) => {
const [isOpenState, setIsOpenState] = useState(isOpenAsProp)
class Collapsible extends Component<Props, State> {
static defaultProps = {
isOpen: false,
headerClass: '',
headerStyle: {},
contentClass: '',
hideExpander: false,
}
constructor (props: Props) {
super(props)
this.state = { isOpen: props.isOpen || false }
}
componentDidUpdate (prevProps: Props) {
if (this.props.isOpen != null && this.props.isOpen !== prevProps.isOpen) {
this.setState({ isOpen: this.props.isOpen })
const toggleOpenState = useCallback((e?: MouseEvent) => {
e?.stopPropagation()
if (onOpenStateChangeRequested) {
onOpenStateChangeRequested(!isOpen)
} else {
setIsOpenState(!isOpen)
}
}
}, [isOpenState, onOpenStateChangeRequested])
render () {
return (
<div className={cs('collapsible', { 'is-open': this.state.isOpen })} ref={this.props.containerRef}>
<div className={cs('collapsible-header-wrapper', this.props.headerClass)}>
const isOpen = onOpenStateChangeRequested ? isOpenAsProp : isOpenState
return (
<div className={cs('collapsible', { 'is-open': isOpen })} ref={containerRef}>
<div className={cs('collapsible-header-wrapper', headerClass)}>
<div
aria-expanded={isOpen}
className='collapsible-header'
onClick={toggleOpenState}
onKeyUp={onEnterOrSpace(toggleOpenState)}
role='button'
tabIndex={0}
>
<div
aria-expanded={this.state.isOpen}
className='collapsible-header'
onClick={this._onClick}
onKeyPress={onEnterOrSpace(this._onKeyPress)}
role='button'
tabIndex={0}
className='collapsible-header-inner'
style={headerStyle}
tabIndex={-1}
>
<div
className='collapsible-header-inner'
style={this.props.headerStyle}
tabIndex={-1}
>
{!this.props.hideExpander && <ChevronIcon className='collapsible-indicator' />}
<span className='collapsible-header-text'>
{this.props.header}
</span>
</div>
{!hideExpander && <ChevronIcon className='collapsible-indicator' />}
<span className='collapsible-header-text'>
{header}
</span>
</div>
{this.props.headerExtras}
</div>
{this.state.isOpen && (
<div className={cs('collapsible-content', this.props.contentClass)}>
{this.props.children}
</div>
)}
{headerExtras}
</div>
)
}
_toggleOpen = () => {
this.setState({ isOpen: !this.state.isOpen })
}
_onClick = (e: MouseEvent) => {
e.stopPropagation()
this._toggleOpen()
}
_onKeyPress = () => {
this._toggleOpen()
}
{isOpen && (
<div className={cs('collapsible-content', contentClass)}>
{children}
</div>
)}
</div>
)
}
export default Collapsible

View File

@@ -1,18 +1,17 @@
import _ from 'lodash'
import cs from 'classnames'
import Markdown from 'markdown-it'
import { action, observable, makeObservable } from 'mobx'
import { observer } from 'mobx-react'
import React, { Component } from 'react'
import React, { useCallback, useState } from 'react'
import Tooltip from '@cypress/react-tooltip'
import appState, { AppState } from '../lib/app-state'
import events, { Events } from '../lib/events'
import appState from '../lib/app-state'
import events from '../lib/events'
import FlashOnClick from '../lib/flash-on-click'
import StateIcon from '../lib/state-icon'
import Tag from '../lib/tag'
import type { TimeoutID } from '../lib/types'
import runnablesStore, { RunnablesStore } from '../runnables/runnables-store'
import runnablesStore from '../runnables/runnables-store'
import type { Alias, AliasObject } from '../instruments/instrument-model'
import { determineTagType } from '../sessions/utils'
@@ -290,9 +289,6 @@ const Progress = observer(({ model }: ProgressProps) => {
interface Props {
model: CommandModel
aliasesWithDuplicates: Array<Alias> | null
appState: AppState
events: Events
runnablesStore: RunnablesStore
groupId?: number
}
@@ -317,12 +313,12 @@ const CommandControls = observer(({ model, commandName, events }) => {
const isSessionCommand = commandName === 'session'
const displayNumOfChildren = !isSystemEvent && !isSessionCommand && model.hasChildren && !model.isOpen
const _removeStudioCommand = (e: React.MouseEvent<HTMLElement, globalThis.MouseEvent>) => {
const _removeStudioCommand = useCallback((e: React.MouseEvent<HTMLElement, globalThis.MouseEvent>) => {
e.preventDefault()
e.stopPropagation()
events.emit('studio:remove:command', model.number)
}
}, [events, model.number])
return (
<span className='command-controls'>
@@ -369,139 +365,48 @@ const CommandControls = observer(({ model, commandName, events }) => {
)
})
@observer
class Command extends Component<Props> {
@observable isOpen: boolean|null = null
private _showTimeout?: TimeoutID
const Command: React.FC<Props> = observer(({ model, aliasesWithDuplicates, groupId }) => {
const [showTimeout, setShowTimeout] = useState<TimeoutID | undefined>(undefined)
static defaultProps = {
appState,
events,
runnablesStore,
if (model.group && groupId !== model.group) {
return null
}
constructor (props: Props) {
super(props)
makeObservable(this)
}
const commandName = model.name ? nameClassName(model.name) : ''
const groupPlaceholder: Array<JSX.Element> = []
render () {
const { model, aliasesWithDuplicates } = this.props
let groupLevel = 0
if (model.group && this.props.groupId !== model.group) {
return null
if (model.groupLevel !== undefined) {
// cap the group nesting to 5 levels to keep the log text legible
groupLevel = model.groupLevel < 6 ? model.groupLevel : 5
for (let i = 1; i < groupLevel; i++) {
groupPlaceholder.push(<span key={`${groupId}-${i}`} className='command-group-block' />)
}
const commandName = model.name ? nameClassName(model.name) : ''
const groupPlaceholder: Array<JSX.Element> = []
let groupLevel = 0
if (model.groupLevel !== undefined) {
// cap the group nesting to 5 levels to keep the log text legible
groupLevel = model.groupLevel < 6 ? model.groupLevel : 5
for (let i = 1; i < groupLevel; i++) {
groupPlaceholder.push(<span key={`${this.props.groupId}-${i}`} className='command-group-block' />)
}
}
return (
<>
<li className={cs('command', `command-name-${commandName}`, { 'command-is-studio': model.isStudio })}>
<div
className={cs(
'command-wrapper',
`command-state-${model.state}`,
`command-type-${model.type}`,
{
'command-is-event': !!model.event,
'command-is-pinned': this._isPinned(),
'command-is-interactive': (model.hasConsoleProps || model.hasSnapshot),
},
)}
>
<NavColumns model={model} isPinned={this._isPinned()} toggleColumnPin={this._toggleColumnPin} />
<FlashOnClick
message='Printed output to your console'
onClick={this._toggleColumnPin}
shouldShowMessage={this._shouldShowClickMessage}
wrapperClassName={cs('command-pin-target', { 'command-group': !!this.props.groupId })}
>
<div
className='command-wrapper-text'
onMouseEnter={() => this._snapshot(true)}
onMouseLeave={() => this._snapshot(false)}
>
{groupPlaceholder}
<CommandDetails model={model} groupId={this.props.groupId} aliasesWithDuplicates={aliasesWithDuplicates} />
<CommandControls model={model} commandName={commandName} events={this.props.events} />
</div>
</FlashOnClick>
</div>
<Progress model={model} />
{this._children()}
</li>
{model.showError && (
<li>
<TestError
err={model.err}
testId={model.testId}
commandId={model.id}
// if the err is recovered and the current command is a log group, nest the test error within the group
groupLevel={model.group && model.hasChildren ? ++groupLevel : groupLevel}
/>
</li>
)}
</>
)
}
_children () {
const { appState, events, model, runnablesStore } = this.props
if (!model.hasChildren || !model.isOpen) {
return null
}
return (
<ul className='command-child-container'>
{model.children.map((child) => (
<Command
key={child.id}
model={child}
appState={appState}
events={events}
runnablesStore={runnablesStore}
aliasesWithDuplicates={null}
groupId={model.id}
/>
))}
</ul>
)
const _isPinned = () => {
return appState.pinnedSnapshotId === model.id
}
_isPinned () {
return this.props.appState.pinnedSnapshotId === this.props.model.id
const _shouldShowClickMessage = () => {
return !appState.isRunning && !!model.hasConsoleProps
}
_shouldShowClickMessage = () => {
return !this.props.appState.isRunning && !!this.props.model.hasConsoleProps
}
const _toggleColumnPin = () => {
if (appState.isRunning) return
@action _toggleColumnPin = () => {
if (this.props.appState.isRunning) return
const { testId, id } = model
const { testId, id } = this.props.model
if (this._isPinned()) {
this.props.appState.pinnedSnapshotId = null
this.props.events.emit('unpin:snapshot', testId, id)
this._snapshot(true)
if (_isPinned()) {
appState.pinnedSnapshotId = null
events.emit('unpin:snapshot', testId, id)
_snapshot(true)
} else {
this.props.appState.pinnedSnapshotId = id as number
this.props.events.emit('pin:snapshot', testId, id)
this.props.events.emit('show:command', testId, id)
appState.pinnedSnapshotId = id as number
events.emit('pin:snapshot', testId, id)
events.emit('show:command', testId, id)
}
}
@@ -522,31 +427,90 @@ class Command extends Component<Props> {
// over many commands, unless you're hovered for
// 50ms, it won't show the snapshot at all. so we
// optimize for both snapshot showing + restoring
_snapshot (show: boolean) {
const { model, runnablesStore } = this.props
const _snapshot = (show: boolean) => {
if (show) {
runnablesStore.attemptingShowSnapshot = true
this._showTimeout = setTimeout(() => {
setShowTimeout(setTimeout(() => {
runnablesStore.showingSnapshot = true
this.props.events.emit('show:snapshot', model.testId, model.id)
}, 50)
events.emit('show:snapshot', model.testId, model.id)
}, 50))
} else {
runnablesStore.attemptingShowSnapshot = false
clearTimeout(this._showTimeout as TimeoutID)
clearTimeout(showTimeout as TimeoutID)
setTimeout(() => {
// if we are currently showing a snapshot but
// we aren't trying to show a different snapshot
if (runnablesStore.showingSnapshot && !runnablesStore.attemptingShowSnapshot) {
runnablesStore.showingSnapshot = false
this.props.events.emit('hide:snapshot', model.testId, model.id)
events.emit('hide:snapshot', model.testId, model.id)
}
}, 50)
}
}
}
return (
<>
<li className={cs('command', `command-name-${commandName}`, { 'command-is-studio': model.isStudio })}>
<div
className={cs(
'command-wrapper',
`command-state-${model.state}`,
`command-type-${model.type}`,
{
'command-is-event': !!model.event,
'command-is-pinned': _isPinned(),
'command-is-interactive': (model.hasConsoleProps || model.hasSnapshot),
},
)}
>
<NavColumns model={model} isPinned={_isPinned()} toggleColumnPin={_toggleColumnPin} />
<FlashOnClick
message='Printed output to your console'
onClick={_toggleColumnPin}
shouldShowMessage={_shouldShowClickMessage}
wrapperClassName={cs('command-pin-target', { 'command-group': !!groupId })}
>
<div
className='command-wrapper-text'
onMouseEnter={() => _snapshot(true)}
onMouseLeave={() => _snapshot(false)}
>
{groupPlaceholder}
<CommandDetails model={model} groupId={groupId} aliasesWithDuplicates={aliasesWithDuplicates} />
<CommandControls model={model} commandName={commandName} events={events} />
</div>
</FlashOnClick>
</div>
<Progress model={model} />
{model.hasChildren && model.isOpen && (
<ul className='command-child-container'>
{model.children.map((child) => (
<Command
key={child.id}
model={child}
aliasesWithDuplicates={null}
groupId={model.id}
/>
))}
</ul>
)}
</li>
{model.showError && (
<li>
<TestError
err={model.err}
testId={model.testId}
commandId={model.id}
// if the err is recovered and the current command is a log group, nest the test error within the group
groupLevel={model.group && model.hasChildren ? ++groupLevel : groupLevel}
/>
</li>
)}
</>
)
})
export { Aliases, AliasesReferences, Message, Progress }

View File

@@ -1,4 +1,4 @@
import React, { Component } from 'react'
import React, { useEffect, useRef } from 'react'
import { observer } from 'mobx-react'
import Prism from 'prismjs'
@@ -9,28 +9,27 @@ interface Props {
codeFrame: CodeFrame
}
@observer
class ErrorCodeFrame extends Component<Props> {
componentDidMount () {
Prism.highlightAllUnder(this.refs.codeFrame as ParentNode)
}
const ErrorCodeFrame: React.FC<Props> = observer(({ codeFrame }) => {
const codeFrameRef = useRef<null | HTMLPreElement>(null)
render () {
const { line, frame, language } = this.props.codeFrame
const { line, frame, language } = codeFrame
// since we pull out 2 lines above the highlighted code, it will always
// be the 3rd line unless it's at the top of the file (lines 1 or 2)
const highlightLine = Math.min(line, 3)
// since we pull out 2 lines above the highlighted code, it will always
// be the 3rd line unless it's at the top of the file (lines 1 or 2)
const highlightLine = Math.min(line, 3)
return (
<div className='test-err-code-frame'>
<FileNameOpener className="runnable-err-file-path" fileDetails={this.props.codeFrame} hasIcon />
<pre ref='codeFrame' data-line={highlightLine}>
<code className={`language-${language || 'text'}`}>{frame}</code>
</pre>
</div>
)
}
}
useEffect(() => {
Prism.highlightAllUnder(codeFrameRef.current as unknown as ParentNode)
}, [])
return (
<div className='test-err-code-frame'>
<FileNameOpener className="runnable-err-file-path" fileDetails={codeFrame} hasIcon />
<pre ref={codeFrameRef} data-line={highlightLine}>
<code className={`language-${language || 'text'}`}>{frame}</code>
</pre>
</div>
)
})
export default ErrorCodeFrame

View File

@@ -1,5 +1,5 @@
import _ from 'lodash'
import React, { MouseEvent } from 'react'
import React, { MouseEvent, useCallback } from 'react'
import cs from 'classnames'
import { observer } from 'mobx-react'
import Markdown from 'markdown-it'
@@ -44,11 +44,14 @@ interface TestErrorProps {
testId?: string
commandId?: number
// the command group level to nest the recovered in-test error
groupLevel: number
groupLevel?: number
}
const TestError = (props: TestErrorProps) => {
const { err } = props
const TestError: React.FC<TestErrorProps> = ({ err, groupLevel = 0, testId, commandId }) => {
const _onPrint = useCallback((e: MouseEvent) => {
e.stopPropagation()
events.emit('show:error', { err, groupLevel, testId, commandId })
}, [err, groupLevel, testId, commandId])
if (!err || !err.displayMessage) return null
@@ -56,23 +59,13 @@ const TestError = (props: TestErrorProps) => {
md.enable(['backticks', 'emphasis', 'escape'])
const onPrint = () => {
events.emit('show:error', props)
}
const _onPrintClick = (e: MouseEvent) => {
e.stopPropagation()
onPrint()
}
const { codeFrame } = err
const groupPlaceholder: Array<JSX.Element> = []
if (err.isRecovered) {
// cap the group nesting to 5 levels to keep the log text legible
for (let i = 0; i < props.groupLevel; i++) {
for (let i = 0; i < groupLevel; i++) {
groupPlaceholder.push(<span key={`${err.name}-err-${i}`} className='err-group-block' />)
}
}
@@ -99,10 +92,10 @@ const TestError = (props: TestErrorProps) => {
header='View stack trace'
headerClass='runnable-err-stack-expander'
headerExtras={
<FlashOnClick onClick={_onPrintClick} message="Printed output to your console">
<FlashOnClick onClick={_onPrint} message="Printed output to your console">
<div
className="runnable-err-print"
onKeyPress={onEnterOrSpace(onPrint)}
onKeyDown={onEnterOrSpace(() => events.emit('show:error', { err, groupLevel, testId, commandId }))}
role='button'
tabIndex={0}
>
@@ -121,8 +114,4 @@ const TestError = (props: TestErrorProps) => {
)
}
TestError.defaultProps = {
groupLevel: 0,
}
export default observer(TestError)

View File

@@ -1,7 +1,6 @@
import { action, observable, makeObservable } from 'mobx'
import { action } from 'mobx'
import { observer } from 'mobx-react'
import PropTypes from 'prop-types'
import React, { Children, cloneElement, Component, MouseEvent, ReactElement, ReactNode } from 'react'
import React, { Children, cloneElement, MouseEvent, ReactElement, ReactNode, useCallback, useState } from 'react'
// @ts-ignore
import Tooltip from '@cypress/react-tooltip'
@@ -10,55 +9,35 @@ interface Props {
onClick: ((e: MouseEvent) => void)
shouldShowMessage?: (() => boolean)
wrapperClassName?: string
children: React.ReactNode
}
@observer
class FlashOnClick extends Component<Props> {
static propTypes = {
message: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
shouldShowMessage: PropTypes.func,
wrapperClassName: PropTypes.string,
}
static defaultProps = {
shouldShowMessage: () => true,
}
@observable _show = false
constructor (props: Props) {
super(props)
makeObservable(this)
}
render () {
const child = Children.only<ReactNode>(this.props.children)
return (
<Tooltip
placement='top'
title={this.props.message}
visible={this._show}
className='cy-tooltip'
wrapperClassName={this.props.wrapperClassName}
>
{cloneElement(child as ReactElement, { onClick: this._onClick })}
</Tooltip>
)
}
@action _onClick = (e: MouseEvent) => {
const { onClick, shouldShowMessage } = this.props
const FlashOnClick: React.FC<Props> = observer(({ message, onClick, wrapperClassName, children, shouldShowMessage = () => true }) => {
const [show, setShow] = useState(false)
const _onClick = useCallback((e: MouseEvent) => {
onClick(e)
if (shouldShowMessage && !shouldShowMessage()) return
this._show = true
setShow(true)
setTimeout(action('hide:console:message', () => {
this._show = false
setShow(false)
}), 1500)
}
}
}, [onClick, shouldShowMessage])
const child = Children.only<ReactNode>(children)
return (
<Tooltip
placement='top'
title={message}
visible={show}
className='cy-tooltip'
wrapperClassName={wrapperClassName}
>
{cloneElement(child as ReactElement, { onClick: _onClick })}
</Tooltip>
)
})
export default FlashOnClick

View File

@@ -1,6 +1,5 @@
import { action, makeObservable } from 'mobx'
import { observer } from 'mobx-react'
import React, { Component } from 'react'
import React, { useCallback } from 'react'
interface Props {
value: boolean
@@ -9,34 +8,23 @@ interface Props {
onUpdate: (e: MouseEvent) => void
}
@observer
class Switch extends Component<Props> {
@action _onClick = (e: MouseEvent) => {
const { onUpdate } = this.props
const Switch: React.FC<Props> = observer(({ value, 'data-cy': dataCy, size = 'lg', onUpdate }) => {
const _onClick = useCallback((e: MouseEvent) => {
onUpdate(e)
}
}, [onUpdate])
constructor (props: Props) {
super(props)
makeObservable(this)
}
render () {
const { 'data-cy': dataCy, size = 'lg', value } = this.props
return (
<button
data-cy={dataCy}
className={`switch switch-${size}`}
role="switch"
aria-checked={value}
onClick={this._onClick}
>
<span className="indicator" />
</button>
)
}
}
return (
<button
data-cy={dataCy}
className={`switch switch-${size}`}
role="switch"
aria-checked={value}
// @ts-expect-error
onClick={_onClick}
>
<span className="indicator" />
</button>
)
})
export default Switch

View File

@@ -1,15 +1,15 @@
/* global JSX */
import { action, runInAction } from 'mobx'
import { action } from 'mobx'
import { observer } from 'mobx-react'
import cs from 'classnames'
import React, { Component } from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
// @ts-ignore
import EQ from 'css-element-queries/src/ElementQueries'
import type { RunnablesErrorModel } from './runnables/runnable-error'
import appState, { AppState } from './lib/app-state'
import events, { Runner, Events } from './lib/events'
import appStateDefault, { AppState } from './lib/app-state'
import events, { Events, Runner } from './lib/events'
import runnablesStore, { RunnablesStore } from './runnables/runnables-store'
import scroller, { Scroller } from './lib/scroller'
import statsStore, { StatsStore } from './header/stats-store'
@@ -20,6 +20,16 @@ import Runnables from './runnables/runnables'
import TestingPreferences from './preferences/testing-preferences'
import type { MobxRunnerStore } from '@packages/app/src/store/mobx-runner-store'
function usePrevious (value) {
const ref = useRef()
useEffect(() => {
ref.current = value
}, [])
return ref.current
}
export interface BaseReporterProps {
appState: AppState
className?: string
@@ -38,77 +48,29 @@ export interface BaseReporterProps {
}
export interface SingleReporterProps extends BaseReporterProps{
runMode: 'single'
runMode?: 'single'
}
@observer
class Reporter extends Component<SingleReporterProps> {
static defaultProps: Partial<SingleReporterProps> = {
runMode: 'single',
appState,
events,
runnablesStore,
scroller,
statsStore,
}
// 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<SingleReporterProps> = observer(({ appState = appStateDefault, runner, className, error, runMode = 'single', studioEnabled, autoScrollingEnabled, isSpecsListOpen, resetStatsOnSpecChange, renderReporterHeader = (props: ReporterHeaderProps) => <Header {...props}/>, runnerStore }) => {
const previousSpecRunId = usePrevious(runnerStore.specRunId)
const [isMounted, setIsMounted] = useState(false)
const [isInitialized, setIsInitialized] = useState(false)
render () {
const {
// this registration needs to happen synchronously and not async inside useEffect or else the events will not be registered and the reporter might hang inside cy-in-cy tests
if (!isInitialized) {
events.init({
appState,
className,
runnablesStore,
scroller,
error,
statsStore,
studioEnabled,
renderReporterHeader = (props: ReporterHeaderProps) => <Header {...props}/>,
runnerStore,
} = this.props
})
return (
<div className={cs(className, 'reporter', {
'studio-active': appState.studioActive,
})}>
{renderReporterHeader({ appState, statsStore, runnablesStore })}
{appState?.isPreferencesMenuOpen ? (
<TestingPreferences appState={appState} />
) : (
runnerStore.spec && <Runnables
appState={appState}
error={error}
runnablesStore={runnablesStore}
scroller={scroller}
spec={runnerStore.spec}
statsStore={statsStore}
studioEnabled={studioEnabled}
canSaveStudioLogs={runnerStore.canSaveStudioLogs}
/>
)}
</div>
)
events.listen(runner)
setIsInitialized(true)
}
// this hook will only trigger if we switch spec file at runtime
// it never happens in normal e2e but can happen in component-testing mode
componentDidUpdate (newProps: BaseReporterProps) {
if (!this.props.runnerStore.spec) {
throw Error(`Expected runnerStore.spec not to be null.`)
}
this.props.runnablesStore.setRunningSpec(this.props.runnerStore.spec.relative)
if (
this.props.resetStatsOnSpecChange &&
this.props.runnerStore.specRunId !== newProps.runnerStore.specRunId
) {
runInAction('reporter:stats:reset', () => {
this.props.statsStore.reset()
})
}
}
componentDidMount () {
const { appState, runnablesStore, runner, scroller, statsStore, autoScrollingEnabled, isSpecsListOpen, runnerStore } = this.props
useEffect(() => {
if (!runnerStore.spec) {
throw Error(`Expected runnerStore.spec not to be null.`)
}
@@ -122,24 +84,52 @@ class Reporter extends Component<SingleReporterProps> {
appState.setSpecsList(isSpecsListOpen ?? false)
})()
this.props.events.init({
appState,
runnablesStore,
scroller,
statsStore,
})
this.props.events.listen(runner)
shortcuts.start()
EQ.init()
this.props.runnablesStore.setRunningSpec(runnerStore.spec.relative)
}
runnablesStore.setRunningSpec(runnerStore.spec.relative)
// we need to know when the test is mounted for our reporter tests. see
setIsMounted(true)
componentWillUnmount () {
shortcuts.stop()
}
}
return () => shortcuts.stop()
}, [])
useEffect(() => {
if (!runnerStore.spec) {
throw Error(`Expected runnerStore.spec not to be null.`)
}
runnablesStore.setRunningSpec(runnerStore.spec.relative)
if (
resetStatsOnSpecChange &&
runnerStore.specRunId !== previousSpecRunId
) {
statsStore.reset()
}
}, [runnerStore.spec, runnerStore.specRunId, resetStatsOnSpecChange, previousSpecRunId])
return (
<div className={cs(className, 'reporter', {
'studio-active': appState.studioActive,
'mounted': isMounted,
})}>
{renderReporterHeader({ appState, statsStore, runnablesStore })}
{appState?.isPreferencesMenuOpen ? (
<TestingPreferences appState={appState} />
) : (
runnerStore.spec && <Runnables
appState={appState}
error={error}
runnablesStore={runnablesStore}
scroller={scroller}
spec={runnerStore.spec}
statsStore={statsStore}
studioEnabled={studioEnabled}
canSaveStudioLogs={runnerStore.canSaveStudioLogs}
/>
)}
</div>
)
})
declare global {
interface Window {
@@ -152,7 +142,7 @@ declare global {
// NOTE: this is for testing Cypress-in-Cypress
if (window.Cypress) {
window.state = appState
window.state = appStateDefault
window.render = (props) => {
const container: HTMLElement = document.getElementById('app') as HTMLElement
const root = createRoot(container)

View File

@@ -1,7 +1,7 @@
import cs from 'classnames'
import _ from 'lodash'
import { observer } from 'mobx-react'
import React, { Component, MouseEvent } from 'react'
import React, { MouseEvent, useCallback } from 'react'
import { indent } from '../lib/util'
@@ -23,12 +23,12 @@ interface SuiteProps {
}
const Suite = observer(({ eventManager = events, model, studioEnabled, canSaveStudioLogs }: SuiteProps) => {
const _launchStudio = (e: MouseEvent) => {
const _launchStudio = useCallback((e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
eventManager.emit('studio:init:suite', model.id)
}
}, [eventManager, model.id])
const _header = () => (
<>
@@ -76,30 +76,21 @@ export interface RunnableProps {
// 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
@observer
class Runnable extends Component<RunnableProps> {
static defaultProps = {
appState,
}
render () {
const { appState, model, studioEnabled, canSaveStudioLogs } = this.props
return (
<li
className={cs(`${model.type} runnable runnable-${model.state}`, {
'runnable-retried': model.hasRetried,
'runnable-studio': appState.studioActive,
})}
data-model-state={model.state}
>
{model.type === 'test'
? <Test model={model as TestModel} studioEnabled={studioEnabled} canSaveStudioLogs={canSaveStudioLogs} />
: <Suite model={model as SuiteModel} studioEnabled={studioEnabled} canSaveStudioLogs={canSaveStudioLogs} />}
</li>
)
}
}
const Runnable: React.FC<RunnableProps> = observer(({ appState: appStateProps = appState, model, studioEnabled, canSaveStudioLogs }) => {
return (
<li
className={cs(`${model.type} runnable runnable-${model.state}`, {
'runnable-retried': model.hasRetried,
'runnable-studio': appStateProps.studioActive,
})}
data-model-state={model.state}
>
{model.type === 'test'
? <Test model={model as TestModel} studioEnabled={studioEnabled} canSaveStudioLogs={canSaveStudioLogs} />
: <Suite model={model as SuiteModel} studioEnabled={studioEnabled} canSaveStudioLogs={canSaveStudioLogs} />}
</li>
)
})
export { Suite }

View File

@@ -1,5 +1,5 @@
import { observer } from 'mobx-react'
import React, { Component, ReactElement } from 'react'
import React, { ReactElement } from 'react'
import type { StatsStore } from '../header/stats-store'
import { formatDuration, getFilenameParts } from '../lib/util'
@@ -12,53 +12,48 @@ interface RunnableHeaderProps {
statsStore: StatsStore
}
@observer
class RunnableHeader extends Component<RunnableHeaderProps> {
render () {
const { spec, statsStore } = this.props
const relativeSpecPath = spec.relative
if (spec.relative === '__all') {
if (spec.specFilter) {
return renderRunnableHeader(
<span><span>Specs matching "{spec.specFilter}"</span></span>,
)
}
const RunnableHeader: React.FC<RunnableHeaderProps> = observer(({ spec, statsStore }) => {
const relativeSpecPath = spec.relative
if (spec.relative === '__all') {
if (spec.specFilter) {
return renderRunnableHeader(
<span><span>All Specs</span></span>,
<span><span>Specs matching "{spec.specFilter}"</span></span>,
)
}
const displayFileName = () => {
const specParts = getFilenameParts(spec.name)
return (
<>
<strong>{specParts[0]}</strong>{specParts[1]}
</>
)
}
const fileDetails = {
absoluteFile: spec.absolute,
column: 0,
displayFile: displayFileName(),
line: 0,
originalFile: relativeSpecPath,
relativeFile: relativeSpecPath,
}
return renderRunnableHeader(
<>
<FileNameOpener fileDetails={fileDetails} hasIcon />
{Boolean(statsStore.duration) && (
<span className='duration' data-cy="spec-duration">{formatDuration(statsStore.duration)}</span>
)}
</>,
<span><span>All Specs</span></span>,
)
}
}
const displayFileName = () => {
const specParts = getFilenameParts(spec.name)
return (
<>
<strong>{specParts[0]}</strong>{specParts[1]}
</>
)
}
const fileDetails = {
absoluteFile: spec.absolute,
column: 0,
displayFile: displayFileName(),
line: 0,
originalFile: relativeSpecPath,
relativeFile: relativeSpecPath,
}
return renderRunnableHeader(
<>
<FileNameOpener fileDetails={fileDetails} hasIcon />
{Boolean(statsStore.duration) && (
<span className='duration' data-cy="spec-duration">{formatDuration(statsStore.duration)}</span>
)}
</>,
)
})
export default RunnableHeader

View File

@@ -1,7 +1,7 @@
import _ from 'lodash'
import { action } from 'mobx'
import { observer } from 'mobx-react'
import React, { Component, MouseEvent } from 'react'
import React, { MouseEvent, useCallback, useEffect, useRef } from 'react'
import events, { Events } from '../lib/events'
import { RunnablesError, RunnablesErrorModel } from './runnable-error'
@@ -33,12 +33,12 @@ interface RunnablesEmptyStateProps {
}
const RunnablesEmptyState = ({ spec, studioEnabled, eventManager = events }: RunnablesEmptyStateProps) => {
const _launchStudio = (e: MouseEvent) => {
const _launchStudio = useCallback((e: MouseEvent) => {
e.preventDefault()
// root runnable always has r1 as id
eventManager.emit('studio:init:suite', 'r1')
}
}, [eventManager])
const isAllSpecs = spec.absolute === '__all' || spec.relative === '__all'
@@ -154,28 +154,10 @@ export interface RunnablesProps {
canSaveStudioLogs: boolean
}
@observer
class Runnables extends Component<RunnablesProps> {
render () {
const { error, runnablesStore, spec, studioEnabled, canSaveStudioLogs } = this.props
return (
<div ref='container' className='container'>
<RunnableHeader spec={spec} statsStore={statsStore} />
<RunnablesContent
runnablesStore={runnablesStore}
studioEnabled={studioEnabled}
canSaveStudioLogs={canSaveStudioLogs}
spec={spec}
error={error}
/>
</div>
)
}
componentDidMount () {
const { scroller, appState } = this.props
const Runnables: React.FC<RunnablesProps> = observer(({ appState, scroller, error, runnablesStore, spec, studioEnabled, canSaveStudioLogs }) => {
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
let maybeHandleScroll: UserScrollCallback | undefined = undefined
if (window.__CYPRESS_MODE__ === 'open') {
@@ -191,9 +173,22 @@ class Runnables extends Component<RunnablesProps> {
// we need to always call scroller.setContainer, but the callback can be undefined
// so we pass maybeHandleScroll. If we don't, Cypress blows up with an error like
// `A container must be set on the scroller with scroller.setContainer(container)`
scroller.setContainer(this.refs.container as Element, maybeHandleScroll)
}
}
scroller.setContainer(containerRef.current as Element, maybeHandleScroll)
}, [])
return (
<div ref={containerRef} className='container'>
<RunnableHeader spec={spec} statsStore={statsStore} />
<RunnablesContent
runnablesStore={runnablesStore}
studioEnabled={studioEnabled}
canSaveStudioLogs={canSaveStudioLogs}
spec={spec}
error={error}
/>
</div>
)
})
export { RunnablesList }

View File

@@ -187,6 +187,10 @@ export default class Test extends Runnable {
cb()
}
@action setIsOpen (isOpen: boolean) {
this._isOpen = isOpen
}
// this is called to sync up the command log UI for the sake of
// screenshots, so we only ever need to open the last attempt
setIsOpenWhenActive (isOpen: boolean) {

View File

@@ -9,6 +9,9 @@ describe('test/test.tsx', () => {
state: 'passed',
title: 'foobar',
attempts: [],
setIsOpen: (isOpen) => model.isOpen = isOpen,
onOpenStateChangeRequested: (isOpen) => model.setIsOpen(isOpen),
callbackAfterUpdate: () => undefined,
}
const appState = {
@@ -37,6 +40,9 @@ describe('test/test.tsx', () => {
state: 'passed',
title: 'foobar',
attempts: [],
setIsOpen: (isOpen) => model.isOpen = isOpen,
onOpenStateChangeRequested: (isOpen) => model.setIsOpen(isOpen),
callbackAfterUpdate: () => undefined,
}
const appState = {

View File

@@ -1,5 +1,5 @@
import { observer } from 'mobx-react'
import React, { Component, createRef, RefObject, MouseEvent } from 'react'
import React, { MouseEvent, useCallback, useEffect, useRef, useState } from 'react'
// @ts-ignore
import Tooltip from '@cypress/react-tooltip'
import cs from 'classnames'
@@ -8,7 +8,6 @@ import events, { Events } from '../lib/events'
import appState, { AppState } from '../lib/app-state'
import Collapsible from '../collapsible/collapsible'
import { indent } from '../lib/util'
import runnablesStore, { RunnablesStore } from '../runnables/runnables-store'
import TestModel from './test-model'
import scroller, { Scroller } from '../lib/scroller'
@@ -21,173 +20,126 @@ import ClipboardIcon from '@packages/frontend-shared/src/assets/icons/general-cl
import WarningIcon from '@packages/frontend-shared/src/assets/icons/warning_x16.svg'
interface StudioControlsProps {
events: Events
model: TestModel
events?: Events
canSaveStudioLogs: boolean
}
interface StudioControlsState {
copySuccess: boolean
}
const StudioControls: React.FC<StudioControlsProps> = observer(({ events: eventsProps = events, canSaveStudioLogs }) => {
const [copySuccess, setCopySuccess] = useState(false)
@observer
class StudioControls extends Component<StudioControlsProps, StudioControlsState> {
static defaultProps = {
events,
}
state = {
copySuccess: false,
}
_cancel = (e: MouseEvent) => {
const _cancel = useCallback((e: MouseEvent) => {
e.preventDefault()
this.props.events.emit('studio:cancel')
}
eventsProps.emit('studio:cancel')
}, [eventsProps])
_save = (e: MouseEvent) => {
const _save = useCallback((e: MouseEvent) => {
e.preventDefault()
this.props.events.emit('studio:save')
}
eventsProps.emit('studio:save')
}, [eventsProps])
_copy = (e: MouseEvent) => {
const _copy = useCallback((e: MouseEvent) => {
e.preventDefault()
this.props.events.emit('studio:copy:to:clipboard', () => {
this.setState({ copySuccess: true })
eventsProps.emit('studio:copy:to:clipboard', () => {
setCopySuccess(true)
})
}
}, [eventsProps])
_endCopySuccess = () => {
if (this.state.copySuccess) {
this.setState({ copySuccess: false })
const _endCopySuccess = useCallback(() => {
if (copySuccess) {
setCopySuccess(false)
}
}
}, [copySuccess])
render () {
const { canSaveStudioLogs } = this.props
const { copySuccess } = this.state
return (
<div className='studio-controls'>
<a className='studio-cancel' onClick={this._cancel}>Cancel</a>
<Tooltip
title={copySuccess ? 'Commands Copied!' : 'Copy Commands to Clipboard'}
className='cy-tooltip'
wrapperClassName='studio-copy-wrapper'
visible={!canSaveStudioLogs ? false : null}
updateCue={copySuccess}
return (
<div className='studio-controls'>
<a className='studio-cancel' onClick={_cancel}>Cancel</a>
<Tooltip
title={copySuccess ? 'Commands Copied!' : 'Copy Commands to Clipboard'}
className='cy-tooltip'
wrapperClassName='studio-copy-wrapper'
visible={!canSaveStudioLogs ? false : null}
updateCue={copySuccess}
>
<button
className={cs('studio-copy', {
'studio-copy-success': copySuccess,
})}
disabled={!canSaveStudioLogs}
onClick={_copy}
onMouseLeave={_endCopySuccess}
>
<button
className={cs('studio-copy', {
'studio-copy-success': copySuccess,
})}
disabled={!canSaveStudioLogs}
onClick={this._copy}
onMouseLeave={this._endCopySuccess}
>
{copySuccess ? (
<CheckIcon />
) : (
<ClipboardIcon />
)}
</button>
</Tooltip>
<button className='studio-save' disabled={!canSaveStudioLogs} onClick={this._save}>Save Commands</button>
</div>
)
}
}
{copySuccess ? (
<CheckIcon />
) : (
<ClipboardIcon />
)}
</button>
</Tooltip>
<button className='studio-save' disabled={!canSaveStudioLogs} onClick={_save}>Save Commands</button>
</div>
)
})
interface TestProps {
events: Events
appState: AppState
runnablesStore: RunnablesStore
scroller: Scroller
events?: Events
appState?: AppState
scroller?: Scroller
model: TestModel
studioEnabled: boolean
canSaveStudioLogs: boolean
}
@observer
class Test extends Component<TestProps> {
static defaultProps = {
events,
appState,
runnablesStore,
scroller,
}
const Test: React.FC<TestProps> = observer(({ model, events: eventsProps = events, appState: appStateProps = appState, scroller: scrollerProps = scroller, studioEnabled, canSaveStudioLogs }) => {
const containerRef = useRef(null)
const [isMounted, setIsMounted] = useState(false)
containerRef: RefObject<HTMLDivElement>
useEffect(() => {
_scrollIntoView()
if (!isMounted) {
setIsMounted(true)
} else {
model.callbackAfterUpdate()
}
})
constructor (props: TestProps) {
super(props)
const _launchStudio = useCallback((e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
this.containerRef = createRef<HTMLDivElement>()
}
eventsProps.emit('studio:init:test', model.id)
}, [eventsProps, model.id])
componentDidMount () {
this._scrollIntoView()
}
componentDidUpdate () {
this._scrollIntoView()
this.props.model.callbackAfterUpdate()
}
_scrollIntoView () {
const { appState, model, scroller } = this.props
const { state } = model
if (appState.autoScrollingEnabled && (appState.isRunning || appState.studioActive) && state !== 'processing') {
const _scrollIntoView = () => {
if (appStateProps.autoScrollingEnabled && (appStateProps.isRunning || appStateProps.studioActive) && model.state !== 'processing') {
window.requestAnimationFrame(() => {
// since this executes async in a RAF the ref might be null
if (this.containerRef.current) {
scroller.scrollIntoView(this.containerRef.current as HTMLElement)
if (containerRef.current) {
scrollerProps.scrollIntoView(containerRef.current as HTMLElement)
}
})
}
}
render () {
const { model } = this.props
return (
<Collapsible
containerRef={this.containerRef}
header={this._header()}
headerClass='runnable-wrapper'
headerStyle={{ paddingLeft: indent(model.level) }}
contentClass='runnable-instruments'
isOpen={model.isOpen}
hideExpander
>
{this._contents()}
</Collapsible>
)
}
_header () {
const { appState, model } = this.props
const _header = () => {
return (<>
<StateIcon aria-hidden className="runnable-state-icon" state={model.state} isStudio={appState.studioActive} />
<StateIcon aria-hidden className="runnable-state-icon" state={model.state} isStudio={appStateProps.studioActive} />
<span className='runnable-title'>
<span>{model.title}</span>
<span className='visually-hidden'>{model.state}</span>
</span>
{this._controls()}
{_controls()}
</>)
}
_controls () {
const _controls = () => {
let controls: Array<JSX.Element> = []
if (this.props.model.state === 'failed') {
if (model.state === 'failed') {
controls.push(
<Tooltip key={`test-failed-${this.props.model}`} placement='top' title='One or more commands failed' className='cy-tooltip'>
<Tooltip key={`test-failed-${model}`} placement='top' title='One or more commands failed' className='cy-tooltip'>
<span>
<WarningIcon className="runnable-controls-status" />
</span>
@@ -195,12 +147,12 @@ class Test extends Component<TestProps> {
)
}
if (this.props.studioEnabled && !appState.studioActive) {
if (studioEnabled && !appStateProps.studioActive) {
controls.push(
<LaunchStudioIcon
key={`studio-command-${this.props.model}`}
key={`studio-command-${model}`}
title='Add Commands to Test'
onClick={this._launchStudio}
onClick={_launchStudio}
/>,
)
}
@@ -216,25 +168,23 @@ class Test extends Component<TestProps> {
)
}
_contents () {
const { appState, model } = this.props
return (
return (
<Collapsible
containerRef={containerRef}
header={_header()}
headerClass='runnable-wrapper'
headerStyle={{ paddingLeft: indent(model.level) }}
contentClass='runnable-instruments'
isOpen={model.isOpen}
onOpenStateChangeRequested={(isOpen: boolean) => model.setIsOpen(isOpen)}
hideExpander
>
<div style={{ paddingLeft: indent(model.level) }}>
<Attempts studioActive={appState.studioActive} test={model} scrollIntoView={() => this._scrollIntoView()} />
{appState.studioActive && <StudioControls model={model} canSaveStudioLogs={this.props.canSaveStudioLogs}/>}
<Attempts studioActive={appStateProps.studioActive} test={model} scrollIntoView={() => _scrollIntoView()} />
{appStateProps.studioActive && <StudioControls canSaveStudioLogs={canSaveStudioLogs}/>}
</div>
)
}
_launchStudio = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
const { model, events } = this.props
events.emit('studio:init:test', model.id)
}
}
</Collapsible>
)
})
export default Test