mirror of
https://github.com/cypress-io/cypress.git
synced 2025-12-30 11:09:49 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', {})
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('aliases', () => {
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('.reporter').then(() => {
|
||||
cy.get('.reporter.mounted').then(() => {
|
||||
runner.emit('runnables:ready', runnables)
|
||||
runner.emit('reporter:start', {})
|
||||
})
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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', {})
|
||||
})
|
||||
|
||||
@@ -26,7 +26,7 @@ describe('hooks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('.reporter').then(() => {
|
||||
cy.get('.reporter.mounted').then(() => {
|
||||
runner.emit('runnables:ready', runnables)
|
||||
runner.emit('reporter:start', {})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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', {})
|
||||
})
|
||||
|
||||
@@ -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', {})
|
||||
})
|
||||
|
||||
@@ -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', {})
|
||||
})
|
||||
|
||||
@@ -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', {})
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('suites', () => {
|
||||
})
|
||||
})
|
||||
|
||||
cy.get('.reporter').then(() => {
|
||||
cy.get('.reporter.mounted').then(() => {
|
||||
runner.emit('runnables:ready', runnables)
|
||||
runner.emit('reporter:start', {})
|
||||
})
|
||||
|
||||
@@ -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', {})
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user