diff --git a/.circleci/workflows.yml b/.circleci/workflows.yml index 24137864f6..c4549e571a 100644 --- a/.circleci/workflows.yml +++ b/.circleci/workflows.yml @@ -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 diff --git a/packages/app/cypress/e2e/cypress-in-cypress-e2e.cy.ts b/packages/app/cypress/e2e/cypress-in-cypress-e2e.cy.ts index 0921ad6ca1..8636836d07 100644 --- a/packages/app/cypress/e2e/cypress-in-cypress-e2e.cy.ts +++ b/packages/app/cypress/e2e/cypress-in-cypress-e2e.cy.ts @@ -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() diff --git a/packages/app/cypress/e2e/specs_list_e2e.cy.ts b/packages/app/cypress/e2e/specs_list_e2e.cy.ts index 51fbd1734f..e0083cea42 100644 --- a/packages/app/cypress/e2e/specs_list_e2e.cy.ts +++ b/packages/app/cypress/e2e/specs_list_e2e.cy.ts @@ -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 diff --git a/packages/app/src/runner/index.ts b/packages/app/src/runner/index.ts index 3df4950325..a5f810b3e6 100644 --- a/packages/app/src/runner/index.ts +++ b/packages/app/src/runner/index.ts @@ -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) } diff --git a/packages/app/src/runner/reporter.ts b/packages/app/src/runner/reporter.ts index b2c8800253..495ff90db7 100644 --- a/packages/app/src/runner/reporter.ts +++ b/packages/app/src/runner/reporter.ts @@ -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) } diff --git a/packages/app/src/runner/useEventManager.ts b/packages/app/src/runner/useEventManager.ts index 3a56d712cf..f039b561ee 100644 --- a/packages/app/src/runner/useEventManager.ts +++ b/packages/app/src/runner/useEventManager.ts @@ -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() } } diff --git a/packages/driver/cypress/support/utils.ts b/packages/driver/cypress/support/utils.ts index d96a83a96c..473b8afbea 100644 --- a/packages/driver/cypress/support/utils.ts +++ b/packages/driver/cypress/support/utils.ts @@ -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() diff --git a/packages/reporter/cypress/e2e/agents.cy.ts b/packages/reporter/cypress/e2e/agents.cy.ts index 5a11678763..afc1e6ce0e 100644 --- a/packages/reporter/cypress/e2e/agents.cy.ts +++ b/packages/reporter/cypress/e2e/agents.cy.ts @@ -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', {}) }) diff --git a/packages/reporter/cypress/e2e/aliases.cy.ts b/packages/reporter/cypress/e2e/aliases.cy.ts index 370b534213..c6b61a2f1a 100644 --- a/packages/reporter/cypress/e2e/aliases.cy.ts +++ b/packages/reporter/cypress/e2e/aliases.cy.ts @@ -26,7 +26,7 @@ describe('aliases', () => { }) }) - cy.get('.reporter').then(() => { + cy.get('.reporter.mounted').then(() => { runner.emit('runnables:ready', runnables) runner.emit('reporter:start', {}) }) diff --git a/packages/reporter/cypress/e2e/commands.cy.ts b/packages/reporter/cypress/e2e/commands.cy.ts index 3b793d02e5..ce0267afb0 100644 --- a/packages/reporter/cypress/e2e/commands.cy.ts +++ b/packages/reporter/cypress/e2e/commands.cy.ts @@ -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, { diff --git a/packages/reporter/cypress/e2e/header.cy.ts b/packages/reporter/cypress/e2e/header.cy.ts index 6f057d33ca..4e4c8cc1b2 100755 --- a/packages/reporter/cypress/e2e/header.cy.ts +++ b/packages/reporter/cypress/e2e/header.cy.ts @@ -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', {}) }) diff --git a/packages/reporter/cypress/e2e/hooks.cy.ts b/packages/reporter/cypress/e2e/hooks.cy.ts index 8e5e95b50c..62e37ff4c1 100644 --- a/packages/reporter/cypress/e2e/hooks.cy.ts +++ b/packages/reporter/cypress/e2e/hooks.cy.ts @@ -26,7 +26,7 @@ describe('hooks', () => { }) }) - cy.get('.reporter').then(() => { + cy.get('.reporter.mounted').then(() => { runner.emit('runnables:ready', runnables) runner.emit('reporter:start', {}) }) diff --git a/packages/reporter/cypress/e2e/memory.cy.ts b/packages/reporter/cypress/e2e/memory.cy.ts index 0e11fc21e4..a14903235f 100644 --- a/packages/reporter/cypress/e2e/memory.cy.ts +++ b/packages/reporter/cypress/e2e/memory.cy.ts @@ -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 }) }) diff --git a/packages/reporter/cypress/e2e/routes.cy.ts b/packages/reporter/cypress/e2e/routes.cy.ts index 5fbd7796df..587adb0187 100644 --- a/packages/reporter/cypress/e2e/routes.cy.ts +++ b/packages/reporter/cypress/e2e/routes.cy.ts @@ -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', {}) }) diff --git a/packages/reporter/cypress/e2e/runnables.cy.ts b/packages/reporter/cypress/e2e/runnables.cy.ts index 93fd70651b..eb108faa23 100644 --- a/packages/reporter/cypress/e2e/runnables.cy.ts +++ b/packages/reporter/cypress/e2e/runnables.cy.ts @@ -45,7 +45,7 @@ describe('runnables', () => { start = (renderProps?: Partial) => { render(renderProps) - return cy.get('.reporter').then(() => { + return cy.get('.reporter.mounted').then(() => { runner.emit('runnables:ready', runnables) runner.emit('reporter:start', {}) }) diff --git a/packages/reporter/cypress/e2e/shortcuts.cy.ts b/packages/reporter/cypress/e2e/shortcuts.cy.ts index 0c1d152dca..4ada861cb4 100755 --- a/packages/reporter/cypress/e2e/shortcuts.cy.ts +++ b/packages/reporter/cypress/e2e/shortcuts.cy.ts @@ -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', {}) }) diff --git a/packages/reporter/cypress/e2e/spec_title.cy.ts b/packages/reporter/cypress/e2e/spec_title.cy.ts index 07520189a1..51f89915d6 100644 --- a/packages/reporter/cypress/e2e/spec_title.cy.ts +++ b/packages/reporter/cypress/e2e/spec_title.cy.ts @@ -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', {}) }) diff --git a/packages/reporter/cypress/e2e/suites.cy.ts b/packages/reporter/cypress/e2e/suites.cy.ts index fc3ce49724..ba91185ab3 100644 --- a/packages/reporter/cypress/e2e/suites.cy.ts +++ b/packages/reporter/cypress/e2e/suites.cy.ts @@ -29,7 +29,7 @@ describe('suites', () => { }) }) - cy.get('.reporter').then(() => { + cy.get('.reporter.mounted').then(() => { runner.emit('runnables:ready', runnables) runner.emit('reporter:start', {}) }) diff --git a/packages/reporter/cypress/e2e/test_errors.cy.ts b/packages/reporter/cypress/e2e/test_errors.cy.ts index f9a6ad35b3..a9f797e01e 100644 --- a/packages/reporter/cypress/e2e/test_errors.cy.ts +++ b/packages/reporter/cypress/e2e/test_errors.cy.ts @@ -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', {}) }) diff --git a/packages/reporter/cypress/e2e/tests.cy.ts b/packages/reporter/cypress/e2e/tests.cy.ts index bb5081ccba..b1ba923e33 100644 --- a/packages/reporter/cypress/e2e/tests.cy.ts +++ b/packages/reporter/cypress/e2e/tests.cy.ts @@ -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 }) }) diff --git a/packages/reporter/src/attempts/attempt-model.ts b/packages/reporter/src/attempts/attempt-model.ts index 5367b51c80..674640b1ab 100644 --- a/packages/reporter/src/attempts/attempt-model.ts +++ b/packages/reporter/src/attempts/attempt-model.ts @@ -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 diff --git a/packages/reporter/src/attempts/attempts.tsx b/packages/reporter/src/attempts/attempts.tsx index a1b9748729..09502ff951 100644 --- a/packages/reporter/src/attempts/attempts.tsx +++ b/packages/reporter/src/attempts/attempts.tsx @@ -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 }) => ) -const StudioError = () => ( -
-
-
- Studio cannot add commands to a failing test. -
-
-
-) - -function renderAttemptContent (model: AttemptModel, studioActive: boolean) { - // performance optimization - don't render contents if not open - return ( -
- - - -
- {model.hasCommands ? : } -
- {model.state === 'failed' && ( -
- - {studioActive && } -
- )} -
- ) -} - interface AttemptProps { model: AttemptModel scrollIntoView: Function studioActive: boolean } -@observer -class Attempt extends Component { - componentDidUpdate () { - this.props.scrollIntoView() - } +const Attempt: React.FC = 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 ( -
  • + } + hideExpander + headerClass='attempt-name' + contentClass='attempt-content' + isOpen={model.isOpen} + onOpenStateChangeRequested={(isOpen: boolean) => model.setIsOpen(isOpen)} > - } - hideExpander - headerClass='attempt-name' - contentClass='attempt-content' - isOpen={model.isOpen} - > - {renderAttemptContent(model, studioActive)} - -
  • - ) - } -} +
    + + + +
    + {model.hasCommands ? : } +
    + {model.state === 'failed' && ( +
    + + {studioActive && ( +
    +
    +
    + Studio cannot add commands to a failing test. +
    +
    +
    + )} +
    + )} +
    + + + ) +}) const Attempts = observer(({ test, scrollIntoView, studioActive }: {test: TestModel, scrollIntoView: Function, studioActive: boolean}) => { return (
      contentClass?: string - hideExpander: boolean + hideExpander?: boolean children?: ReactNode + onOpenStateChangeRequested?: (isOpen: boolean) => void } -interface State { - isOpen: boolean -} +const Collapsible: React.FC = ({ isOpen: isOpenAsProp = false, header, headerClass = '', headerStyle = {}, headerExtras, contentClass = '', hideExpander = false, containerRef = null, onOpenStateChangeRequested, children }) => { + const [isOpenState, setIsOpenState] = useState(isOpenAsProp) -class Collapsible extends Component { - 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 ( -
      -
      + const isOpen = onOpenStateChangeRequested ? isOpenAsProp : isOpenState + + return ( +
      +
      +
      -
      - {!this.props.hideExpander && } - - {this.props.header} - -
      + {!hideExpander && } + + {header} +
      - {this.props.headerExtras}
      - {this.state.isOpen && ( -
      - {this.props.children} -
      - )} + {headerExtras}
      - ) - } - - _toggleOpen = () => { - this.setState({ isOpen: !this.state.isOpen }) - } - - _onClick = (e: MouseEvent) => { - e.stopPropagation() - this._toggleOpen() - } - - _onKeyPress = () => { - this._toggleOpen() - } + {isOpen && ( +
      + {children} +
      + )} +
      + ) } export default Collapsible diff --git a/packages/reporter/src/commands/command.tsx b/packages/reporter/src/commands/command.tsx index d518fddcd7..4bc8c883b8 100644 --- a/packages/reporter/src/commands/command.tsx +++ b/packages/reporter/src/commands/command.tsx @@ -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 | 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) => { + const _removeStudioCommand = useCallback((e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() events.emit('studio:remove:command', model.number) - } + }, [events, model.number]) return ( @@ -369,139 +365,48 @@ const CommandControls = observer(({ model, commandName, events }) => { ) }) -@observer -class Command extends Component { - @observable isOpen: boolean|null = null - private _showTimeout?: TimeoutID +const Command: React.FC = observer(({ model, aliasesWithDuplicates, groupId }) => { + const [showTimeout, setShowTimeout] = useState(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 = [] - 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() } - - const commandName = model.name ? nameClassName(model.name) : '' - const groupPlaceholder: Array = [] - - 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() - } - } - - return ( - <> -
    • -
      - - -
      this._snapshot(true)} - onMouseLeave={() => this._snapshot(false)} - > - {groupPlaceholder} - - -
      -
      -
      - - {this._children()} -
    • - {model.showError && ( -
    • - -
    • - )} - - ) } - _children () { - const { appState, events, model, runnablesStore } = this.props - - if (!model.hasChildren || !model.isOpen) { - return null - } - - return ( -
        - {model.children.map((child) => ( - - ))} -
      - ) + 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 { // 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 ( + <> +
    • +
      + + +
      _snapshot(true)} + onMouseLeave={() => _snapshot(false)} + > + {groupPlaceholder} + + +
      +
      +
      + + {model.hasChildren && model.isOpen && ( +
        + {model.children.map((child) => ( + + ))} +
      + )} +
    • + {model.showError && ( +
    • + +
    • + )} + + ) +}) export { Aliases, AliasesReferences, Message, Progress } diff --git a/packages/reporter/src/errors/error-code-frame.tsx b/packages/reporter/src/errors/error-code-frame.tsx index ec4fa4bf0c..f25270d280 100644 --- a/packages/reporter/src/errors/error-code-frame.tsx +++ b/packages/reporter/src/errors/error-code-frame.tsx @@ -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 { - componentDidMount () { - Prism.highlightAllUnder(this.refs.codeFrame as ParentNode) - } +const ErrorCodeFrame: React.FC = observer(({ codeFrame }) => { + const codeFrameRef = useRef(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 ( -
      - -
      -          {frame}
      -        
      -
      - ) - } -} + useEffect(() => { + Prism.highlightAllUnder(codeFrameRef.current as unknown as ParentNode) + }, []) + + return ( +
      + +
      +        {frame}
      +      
      +
      + ) +}) export default ErrorCodeFrame diff --git a/packages/reporter/src/errors/test-error.tsx b/packages/reporter/src/errors/test-error.tsx index 26ee4772a0..17d9d99473 100644 --- a/packages/reporter/src/errors/test-error.tsx +++ b/packages/reporter/src/errors/test-error.tsx @@ -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 = ({ 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 = [] 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() } } @@ -99,10 +92,10 @@ const TestError = (props: TestErrorProps) => { header='View stack trace' headerClass='runnable-err-stack-expander' headerExtras={ - +
      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) diff --git a/packages/reporter/src/lib/flash-on-click.tsx b/packages/reporter/src/lib/flash-on-click.tsx index 377b86149f..8d92a57cda 100644 --- a/packages/reporter/src/lib/flash-on-click.tsx +++ b/packages/reporter/src/lib/flash-on-click.tsx @@ -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 { - 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(this.props.children) - - return ( - - {cloneElement(child as ReactElement, { onClick: this._onClick })} - - ) - } - - @action _onClick = (e: MouseEvent) => { - const { onClick, shouldShowMessage } = this.props +const FlashOnClick: React.FC = 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(children) + + return ( + + {cloneElement(child as ReactElement, { onClick: _onClick })} + + ) +}) export default FlashOnClick diff --git a/packages/reporter/src/lib/switch.tsx b/packages/reporter/src/lib/switch.tsx index ab25cffcb8..14a3286090 100644 --- a/packages/reporter/src/lib/switch.tsx +++ b/packages/reporter/src/lib/switch.tsx @@ -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 { - @action _onClick = (e: MouseEvent) => { - const { onUpdate } = this.props - +const Switch: React.FC = 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 ( - - ) - } -} + return ( + + ) +}) export default Switch diff --git a/packages/reporter/src/main.tsx b/packages/reporter/src/main.tsx index e8a201ce1e..ce2d436b16 100644 --- a/packages/reporter/src/main.tsx +++ b/packages/reporter/src/main.tsx @@ -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 { - static defaultProps: Partial = { - 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 = observer(({ appState = appStateDefault, runner, className, error, runMode = 'single', studioEnabled, autoScrollingEnabled, isSpecsListOpen, resetStatsOnSpecChange, renderReporterHeader = (props: ReporterHeaderProps) =>
      , 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) =>
      , - runnerStore, - } = this.props + }) - return ( -
      - {renderReporterHeader({ appState, statsStore, runnablesStore })} - {appState?.isPreferencesMenuOpen ? ( - - ) : ( - runnerStore.spec && - )} -
      - ) + 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 { 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 ( +
      + {renderReporterHeader({ appState, statsStore, runnablesStore })} + {appState?.isPreferencesMenuOpen ? ( + + ) : ( + runnerStore.spec && + )} +
      + ) +}) 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) diff --git a/packages/reporter/src/runnables/runnable-and-suite.tsx b/packages/reporter/src/runnables/runnable-and-suite.tsx index e7743a5a2d..af2143cc2b 100644 --- a/packages/reporter/src/runnables/runnable-and-suite.tsx +++ b/packages/reporter/src/runnables/runnable-and-suite.tsx @@ -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 { - static defaultProps = { - appState, - } - - render () { - const { appState, model, studioEnabled, canSaveStudioLogs } = this.props - - return ( -
    • - {model.type === 'test' - ? - : } -
    • - ) - } -} +const Runnable: React.FC = observer(({ appState: appStateProps = appState, model, studioEnabled, canSaveStudioLogs }) => { + return ( +
    • + {model.type === 'test' + ? + : } +
    • + ) +}) export { Suite } diff --git a/packages/reporter/src/runnables/runnable-header.tsx b/packages/reporter/src/runnables/runnable-header.tsx index e28ee9512e..3a411cb5e6 100644 --- a/packages/reporter/src/runnables/runnable-header.tsx +++ b/packages/reporter/src/runnables/runnable-header.tsx @@ -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 { - render () { - const { spec, statsStore } = this.props - - const relativeSpecPath = spec.relative - - if (spec.relative === '__all') { - if (spec.specFilter) { - return renderRunnableHeader( - Specs matching "{spec.specFilter}", - ) - } +const RunnableHeader: React.FC = observer(({ spec, statsStore }) => { + const relativeSpecPath = spec.relative + if (spec.relative === '__all') { + if (spec.specFilter) { return renderRunnableHeader( - All Specs, + Specs matching "{spec.specFilter}", ) } - const displayFileName = () => { - const specParts = getFilenameParts(spec.name) - - return ( - <> - {specParts[0]}{specParts[1]} - - ) - } - - const fileDetails = { - absoluteFile: spec.absolute, - column: 0, - displayFile: displayFileName(), - line: 0, - originalFile: relativeSpecPath, - relativeFile: relativeSpecPath, - } - return renderRunnableHeader( - <> - - {Boolean(statsStore.duration) && ( - {formatDuration(statsStore.duration)} - )} - , + All Specs, ) } -} + + const displayFileName = () => { + const specParts = getFilenameParts(spec.name) + + return ( + <> + {specParts[0]}{specParts[1]} + + ) + } + + const fileDetails = { + absoluteFile: spec.absolute, + column: 0, + displayFile: displayFileName(), + line: 0, + originalFile: relativeSpecPath, + relativeFile: relativeSpecPath, + } + + return renderRunnableHeader( + <> + + {Boolean(statsStore.duration) && ( + {formatDuration(statsStore.duration)} + )} + , + ) +}) export default RunnableHeader diff --git a/packages/reporter/src/runnables/runnables.tsx b/packages/reporter/src/runnables/runnables.tsx index 3bde4ec38b..98d022e978 100644 --- a/packages/reporter/src/runnables/runnables.tsx +++ b/packages/reporter/src/runnables/runnables.tsx @@ -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 { - render () { - const { error, runnablesStore, spec, studioEnabled, canSaveStudioLogs } = this.props - - return ( -
      - - -
      - ) - } - - componentDidMount () { - const { scroller, appState } = this.props +const Runnables: React.FC = observer(({ appState, scroller, error, runnablesStore, spec, studioEnabled, canSaveStudioLogs }) => { + const containerRef = useRef(null) + useEffect(() => { let maybeHandleScroll: UserScrollCallback | undefined = undefined if (window.__CYPRESS_MODE__ === 'open') { @@ -191,9 +173,22 @@ class Runnables extends Component { // 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 ( +
      + + +
      + ) +}) export { RunnablesList } diff --git a/packages/reporter/src/test/test-model.ts b/packages/reporter/src/test/test-model.ts index 09070b1118..85982f564d 100644 --- a/packages/reporter/src/test/test-model.ts +++ b/packages/reporter/src/test/test-model.ts @@ -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) { diff --git a/packages/reporter/src/test/test.cy.tsx b/packages/reporter/src/test/test.cy.tsx index ea44776d7b..2793c78c35 100644 --- a/packages/reporter/src/test/test.cy.tsx +++ b/packages/reporter/src/test/test.cy.tsx @@ -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 = { diff --git a/packages/reporter/src/test/test.tsx b/packages/reporter/src/test/test.tsx index 665fc444b9..47bc7dbea9 100644 --- a/packages/reporter/src/test/test.tsx +++ b/packages/reporter/src/test/test.tsx @@ -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 = observer(({ events: eventsProps = events, canSaveStudioLogs }) => { + const [copySuccess, setCopySuccess] = useState(false) -@observer -class StudioControls extends Component { - 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 ( -
      - Cancel - + Cancel + + - - -
      - ) - } -} + {copySuccess ? ( + + ) : ( + + )} + + + +
      + ) +}) 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 { - static defaultProps = { - events, - appState, - runnablesStore, - scroller, - } +const Test: React.FC = observer(({ model, events: eventsProps = events, appState: appStateProps = appState, scroller: scrollerProps = scroller, studioEnabled, canSaveStudioLogs }) => { + const containerRef = useRef(null) + const [isMounted, setIsMounted] = useState(false) - containerRef: RefObject + 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() - } + 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 ( - - {this._contents()} - - ) - } - - _header () { - const { appState, model } = this.props - + const _header = () => { return (<> - + {model.title} {model.state} - {this._controls()} + {_controls()} ) } - _controls () { + const _controls = () => { let controls: Array = [] - if (this.props.model.state === 'failed') { + if (model.state === 'failed') { controls.push( - + @@ -195,12 +147,12 @@ class Test extends Component { ) } - if (this.props.studioEnabled && !appState.studioActive) { + if (studioEnabled && !appStateProps.studioActive) { controls.push( , ) } @@ -216,25 +168,23 @@ class Test extends Component { ) } - _contents () { - const { appState, model } = this.props - - return ( + return ( + model.setIsOpen(isOpen)} + hideExpander + >
      - this._scrollIntoView()} /> - {appState.studioActive && } + _scrollIntoView()} /> + {appStateProps.studioActive && }
      - ) - } - - _launchStudio = (e: MouseEvent) => { - e.preventDefault() - e.stopPropagation() - - const { model, events } = this.props - - events.emit('studio:init:test', model.id) - } -} +
      + ) +}) export default Test