diff --git a/lib/gui/app.ts b/lib/gui/app.ts index 97f34f317..4bac8d6ea 100644 --- a/lib/gui/app.ts +++ b/lib/gui/app.ts @@ -1,6 +1,6 @@ import type {Response} from 'express'; -import {ToolRunner, ToolRunnerTree, UndoAcceptImagesResult} from './tool-runner'; +import {RunParams, ToolRunner, ToolRunnerTree, UndoAcceptImagesResult} from './tool-runner'; import {TestBranch, TestEqualDiffsData, TestRefUpdateData} from '../tests-tree-builder/gui'; import type {ServerArgs} from './index'; @@ -29,8 +29,8 @@ export class App { return this._toolRunner.finalize(); } - async run(tests: TestSpec[]): Promise { - return this._toolRunner.run(tests); + async run(tests: TestSpec[], params: RunParams): Promise { + return this._toolRunner.run(tests, params); } getTestsDataToUpdateRefs(imageIds: string[] = []): TestRefUpdateData[] { @@ -56,4 +56,8 @@ export class App { addClient(connection: Response): void { this._toolRunner.addClient(connection); } + + sendClientEvent(event: string, data: unknown): void { + this._toolRunner.sendClientEvent(event, data); + } } diff --git a/lib/gui/constants/client-events.ts b/lib/gui/constants/client-events.ts index 4d1a48563..0404c6672 100644 --- a/lib/gui/constants/client-events.ts +++ b/lib/gui/constants/client-events.ts @@ -11,6 +11,8 @@ export const ClientEvents = { END: 'end', + REPEAT_LEFT: 'repeatsLeft', + CONNECTED: 'connected', DOM_SNAPSHOTS: 'DOM_SNAPSHOTS' diff --git a/lib/gui/server.ts b/lib/gui/server.ts index 02b543135..33838dbf5 100644 --- a/lib/gui/server.ts +++ b/lib/gui/server.ts @@ -7,7 +7,7 @@ import type {Config} from 'testplane'; import {listenWithFallback} from './listen-with-fallback'; import {App} from './app'; -import {MAX_REQUEST_SIZE} from './constants'; +import {ClientEvents, MAX_REQUEST_SIZE} from './constants'; import {logger} from '../common-utils'; import {initPluginsRoutes} from './routes/plugins'; import {BrowserFeature, Feature, ToolName} from '../constants'; @@ -160,10 +160,20 @@ export const start = async (args: ServerArgs): Promise => { } }); - server.post('/run', (req, res) => { + server.post('/run', async (req, res) => { try { // do not wait for completion so that response does not hang and browser does not restart it by timeout - app.run(req.body); + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + (async () => { + const {tests, repeatCount} = req.body; + + for (let i = 0; i < repeatCount; i++) { + await app.run(tests, {retry: repeatCount === 1}); + + app.sendClientEvent(ClientEvents.REPEAT_LEFT, {repeatLeft: repeatCount - i - 1}); + } + })(); + res.sendStatus(OK); } catch (e) { res.status(INTERNAL_SERVER_ERROR).send(`Error while trying to run tests: ${(e as Error).message}`); diff --git a/lib/gui/tool-runner/index.ts b/lib/gui/tool-runner/index.ts index f16ea02a4..0120154ed 100644 --- a/lib/gui/tool-runner/index.ts +++ b/lib/gui/tool-runner/index.ts @@ -57,6 +57,10 @@ export interface UndoAcceptImagesResult { removedResults: string[]; } +export interface RunParams { + retry?: boolean; +} + export class ToolRunner { private _testFiles: string[]; private _toolAdapter: ToolAdapter; @@ -337,13 +341,13 @@ export class ToolRunner { return comparisons.filter(Boolean).map(image => (image as TestEqualDiffsData).id); } - async run(tests: TestSpec[] = []): Promise { + async run(tests: TestSpec[] = [], runParams: RunParams = {retry: true}): Promise { const testCollection = this._ensureTestCollection(); const shouldRunAllTests = _.isEmpty(tests); // if tests are not passed, then run all tests with all available retries // if tests are specified, then retry only passed tests without retries - return shouldRunAllTests + return (shouldRunAllTests && runParams.retry) ? this._toolAdapter.run(testCollection, tests, this._globalOpts) : this._toolAdapter.runWithoutRetries(testCollection, tests, this._globalOpts); } diff --git a/lib/static/components/extension-point.jsx b/lib/static/components/extension-point.jsx index e5a7b9578..6041c7b74 100644 --- a/lib/static/components/extension-point.jsx +++ b/lib/static/components/extension-point.jsx @@ -3,6 +3,9 @@ import PropTypes from 'prop-types'; import ErrorBoundary from './error-boundary'; import * as plugins from '../modules/plugins'; +import {TestRepeaterComponent} from './test-repeater'; +import {ExtensionPointName} from '@/static/new-ui/constants/plugins'; + export default class ExtensionPoint extends Component { static propTypes = { name: PropTypes.string.isRequired, @@ -12,10 +15,19 @@ export default class ExtensionPoint extends Component { render() { const loadedPluginConfigs = plugins.getLoadedConfigs(); - if (loadedPluginConfigs.length) { - const {name: pointName, children: reportComponent, ...componentProps} = this.props; - const pluginComponents = getExtensionPointComponents(loadedPluginConfigs, pointName); - return getComponentsComposition(pluginComponents, reportComponent, componentProps); + const {name: pointName, children: reportComponent, ...componentProps} = this.props; + const pluginComponents = getExtensionPointComponents(loadedPluginConfigs, pointName); + + const style = pointName === ExtensionPointName.RunTestOptions ? + {display: 'flex', gap: '12px', flexDirection: 'column'} : {} + ; + + if (pluginComponents.length) { + return ( +
+ {getComponentsComposition(pluginComponents, reportComponent, componentProps)} +
+ ); } return this.props.children; @@ -60,26 +72,30 @@ function composeComponents(PluginComponent, pluginProps, currentComponent, posit } } +const defaultComponents = [ + TestRepeaterComponent +]; + export function getExtensionPointComponents(loadedPluginConfigs, pointName) { - return loadedPluginConfigs - .map(config => { - try { - const PluginComponent = plugins.get(config.name, config.component); - return { - PluginComponent, - name, - point: getComponentPoint(PluginComponent, config), - position: getComponentPosition(PluginComponent, config), - config - }; - } catch (err) { - console.error(err); - return {}; - } - }) - .filter(({point, position}) => { - return point && position && point === pointName; - }); + return [ + ...defaultComponents, + ...loadedPluginConfigs + .map(config => { + try { + const PluginComponent = plugins.get(config.name, config.component); + return { + PluginComponent, + name, + point: getComponentPoint(PluginComponent, config), + position: getComponentPosition(PluginComponent, config), + config + }; + } catch (err) { + console.error(err); + return {}; + } + }) + ].filter(({point, position}) => (point && position && point === pointName)); } function getComponentPoint(component, config) { diff --git a/lib/static/components/test-repeater/index.module.css b/lib/static/components/test-repeater/index.module.css new file mode 100644 index 000000000..6d90446b5 --- /dev/null +++ b/lib/static/components/test-repeater/index.module.css @@ -0,0 +1,11 @@ +.test-repeater-container { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.test-repeater-input { + display: flex; + flex-direction: row; +} diff --git a/lib/static/components/test-repeater/index.tsx b/lib/static/components/test-repeater/index.tsx new file mode 100644 index 000000000..c33a69a1a --- /dev/null +++ b/lib/static/components/test-repeater/index.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import {useDispatch, useSelector} from 'react-redux'; +import {NumberInput, Button, Icon} from '@gravity-ui/uikit'; +import {Plus, Minus} from '@gravity-ui/icons'; +import styles from './index.module.css'; +import {setRepeatCount} from '@/static/modules/actions'; +import {ExtensionPointName} from '@/static/new-ui/constants/plugins'; + +const MAX_REPEATER_COUNT = 99; +const MIN_REPEATER_COUNT = 1; + +const PluginComponent = (): React.ReactNode => { + const dispatch = useDispatch(); + const repeatCount = useSelector((state) => state.repeatCount); + + const changeRepeatCount = (newValue: number): void => { + if (newValue >= MIN_REPEATER_COUNT && newValue < MAX_REPEATER_COUNT) { + dispatch(setRepeatCount(newValue)); + } + }; + + return ( +
+ Number of repeats + changeRepeatCount(parseInt(e.target.value, 10))} + qa='repeat-count' + hiddenControls + endContent={( +
+ + +
+ )} + /> +
+ ); +}; + +export const TestRepeaterComponent = { + PluginComponent, + name: 'Test Repeater', + position: 'after', + point: ExtensionPointName.RunTestOptions, + config: {} +}; diff --git a/lib/static/modules/action-names.ts b/lib/static/modules/action-names.ts index 12cad9a26..4af3d8e91 100644 --- a/lib/static/modules/action-names.ts +++ b/lib/static/modules/action-names.ts @@ -5,6 +5,8 @@ export default { FIN_STATIC_REPORT: 'FIN_STATIC_REPORT', RUN_ALL_TESTS: 'RUN_ALL_TESTS', RUN_FAILED_TESTS: 'RUN_FAILED_TESTS', + SET_REPEAT_COUNT: 'SET_REPEAT_COUNT', + SET_REPEAT_LEFT: 'SET_REPEAT_LEFT', STOP_TESTS: 'STOP_TESTS', RETRY_SUITE: 'RETRY_SUITE', RETRY_TEST: 'RETRY_TEST', diff --git a/lib/static/modules/actions/run-tests.ts b/lib/static/modules/actions/run-tests.ts index e2755a01d..c8db818f8 100644 --- a/lib/static/modules/actions/run-tests.ts +++ b/lib/static/modules/actions/run-tests.ts @@ -11,12 +11,16 @@ import {TestStatus} from '@/constants'; export type RunTestAction = Action; export const runTest = (): RunTestAction => ({type: actionNames.RETRY_TEST}); +export const setRepeatCount = (repeatCount: number): Action => ({type: actionNames.SET_REPEAT_COUNT, payload: {repeatCount}}); +export const setRepeatLeft = (repeatLeft: number): Action => ({type: actionNames.SET_REPEAT_LEFT, payload: {repeatLeft}}); export const thunkRunTests = ({tests = []}: {tests?: TestSpec[]} = {}): AppThunk => { - return async (dispatch) => { + return async (dispatch, getState) => { + const {repeatCount} = getState(); + dispatch(runTest()); try { - await axios.post('/run', tests); + await axios.post('/run', {tests, repeatCount}); } catch (e) { // TODO: report error via notifications console.error('Error while running tests:', e); diff --git a/lib/static/modules/default-state.ts b/lib/static/modules/default-state.ts index 01cc0e3c8..b32a07bea 100644 --- a/lib/static/modules/default-state.ts +++ b/lib/static/modules/default-state.ts @@ -7,6 +7,8 @@ import {MIN_SECTION_SIZE_PERCENT} from '../new-ui/features/suites/constants'; export default Object.assign({config: configDefaults}, { gui: true, running: false, + repeatCount: 1, + repeatLeft: 0, processing: false, stopping: false, autoRun: false, diff --git a/lib/static/modules/reducers/running.js b/lib/static/modules/reducers/running.js index e3bd400ed..cb03d4512 100644 --- a/lib/static/modules/reducers/running.js +++ b/lib/static/modules/reducers/running.js @@ -3,6 +3,13 @@ import {initSearch} from '@/static/modules/search'; export default (state, action) => { switch (action.type) { + case actionNames.SET_REPEAT_COUNT: { + return {...state, repeatCount: action.payload.repeatCount}; + } + case actionNames.SET_REPEAT_LEFT: { + return {...state, repeatLeft: action.payload.repeatLeft}; + } + case actionNames.TEST_BEGIN: case actionNames.RUN_ALL_TESTS: case actionNames.RUN_FAILED_TESTS: case actionNames.RETRY_SUITE: diff --git a/lib/static/new-ui/app/gui.tsx b/lib/static/new-ui/app/gui.tsx index f214956bc..fa549541f 100644 --- a/lib/static/new-ui/app/gui.tsx +++ b/lib/static/new-ui/app/gui.tsx @@ -10,7 +10,7 @@ import { suiteBegin, testBegin, testResult, - thunkTestsEnd + thunkTestsEnd, setRepeatLeft } from '../../modules/actions'; import {setGuiServerConnectionStatus} from '@/static/modules/actions/gui-server-connection'; import actionNames from '@/static/modules/action-names'; @@ -61,6 +61,11 @@ function Gui(): ReactNode { eventSource.addEventListener(ClientEvents.END, () => { store.dispatch(thunkTestsEnd()); }); + + eventSource.addEventListener(ClientEvents.REPEAT_LEFT, (e) => { + const data = JSON.parse(e.data); + store.dispatch(setRepeatLeft(data.repeatLeft)); + }); }; useEffect(() => { diff --git a/lib/static/new-ui/components/RunTest/index.module.css b/lib/static/new-ui/components/RunTest/index.module.css index 84a7c42e3..8cb3f4786 100644 --- a/lib/static/new-ui/components/RunTest/index.module.css +++ b/lib/static/new-ui/components/RunTest/index.module.css @@ -9,6 +9,12 @@ padding-right: 4px; } +.repeat-count { + display: flex; + align-items: center; + opacity: 0.5; +} + .run-options-button::before { border-left: none; } diff --git a/lib/static/new-ui/components/RunTest/index.tsx b/lib/static/new-ui/components/RunTest/index.tsx index 026a7afcf..72f42be89 100644 --- a/lib/static/new-ui/components/RunTest/index.tsx +++ b/lib/static/new-ui/components/RunTest/index.tsx @@ -2,7 +2,7 @@ import React, {forwardRef, ReactNode, useCallback, useState} from 'react'; import styles from './index.module.css'; import {Button, ButtonProps, Icon, Popover, Spin} from '@gravity-ui/uikit'; -import {ArrowRotateRight, ChevronDown} from '@gravity-ui/icons'; +import {ArrowRotateRight, ChevronDown, Xmark} from '@gravity-ui/icons'; import {thunkRunTest} from '@/static/modules/actions'; import {useDispatch, useSelector} from 'react-redux'; import {RunTestsFeature} from '@/constants'; @@ -13,6 +13,7 @@ import classNames from 'classnames'; import ExtensionPoint, {getExtensionPointComponents} from '../../../components/extension-point'; import * as plugins from '../../../modules/plugins'; import {ExtensionPointName} from '../../constants/plugins'; +import {useIsRunning} from '@/static/new-ui/hooks/useIsRunning'; interface RunTestProps { browser: BrowserEntity | null; @@ -23,7 +24,8 @@ interface RunTestProps { export const RunTestButton = forwardRef( ({browser, buttonProps, buttonText, hotkey}, ref) => { - const isRunning = useSelector(state => state.running); + const isRunning = useIsRunning(); + const repeatCount = useSelector(state => state.repeatCount); const analytics = useAnalytics(); const dispatch = useDispatch(); @@ -57,9 +59,12 @@ export const RunTestButton = forwardRef - {isRunning ? : }{buttonText === undefined ? 'Retry' : buttonText}{hotkey} + {isRunning ? : }{buttonText === undefined ? 'Retry' : buttonText} + {(repeatCount > 1) && {repeatCount}} + {hotkey} {hasRunTestOptions && diff --git a/lib/static/new-ui/components/TreeActionsToolbar/index.tsx b/lib/static/new-ui/components/TreeActionsToolbar/index.tsx index 9ce16754d..801a2f3c2 100644 --- a/lib/static/new-ui/components/TreeActionsToolbar/index.tsx +++ b/lib/static/new-ui/components/TreeActionsToolbar/index.tsx @@ -53,6 +53,7 @@ import {useHotkey} from '@/static/new-ui/hooks/useHotkey'; import ExtensionPoint, {getExtensionPointComponents} from '../../../components/extension-point'; import {ExtensionPointName} from '../../constants/plugins'; import * as plugins from '../../../modules/plugins'; +import {useIsRunning} from '@/static/new-ui/hooks/useIsRunning'; interface TreeActionsToolbarProps { onHighlightCurrentTest?: () => void; @@ -65,6 +66,7 @@ export function TreeActionsToolbar({onHighlightCurrentTest, className}: TreeActi const dispatch = useDispatch(); const analytics = useAnalytics(); + const repeatCount = useSelector(state => state.repeatCount); const rootSuiteIds = useSelector(state => state.tree.suites.allRootIds); const suitesStateById = useSelector(state => state.tree.suites.stateById); const browsersStateById = useSelector(state => state.tree.browsers.stateById); @@ -75,7 +77,7 @@ export function TreeActionsToolbar({onHighlightCurrentTest, className}: TreeActi const isRunTestsAvailable = useSelector(state => state.app.availableFeatures) .find(feature => feature.name === RunTestsFeature.name); - const isRunning = useSelector(state => (state.running)); + const isRunning = useIsRunning(); const isEditScreensAvailable = useSelector(state => state.app.availableFeatures) .find(feature => feature.name === EditScreensFeature.name); @@ -212,11 +214,13 @@ export function TreeActionsToolbar({onHighlightCurrentTest, className}: TreeActi trigger='click' > 1} view='flat' disabled={isRunning || !isInitialized} className={classNames(styles.iconButton)} icon={} tooltip='View run options' + qa="tree-run-test-options" /> } {isEditScreensAvailable && ( diff --git a/lib/static/new-ui/components/TreeViewItemTitle/index.tsx b/lib/static/new-ui/components/TreeViewItemTitle/index.tsx index 236491a68..8b9a6464e 100644 --- a/lib/static/new-ui/components/TreeViewItemTitle/index.tsx +++ b/lib/static/new-ui/components/TreeViewItemTitle/index.tsx @@ -13,6 +13,7 @@ import {ClipboardButton} from '@/static/new-ui/components/ClipboardButton'; import {RunTestsFeature} from '@/constants'; import {TestSpec} from '@/adapters/tool/types'; import styles from './index.module.css'; +import {useIsRunning} from '@/static/new-ui/hooks/useIsRunning'; interface TreeViewItemTitleProps { className?: string; @@ -56,7 +57,7 @@ export function TreeViewItemTitle({item}: TreeViewItemTitleProps): React.JSX.Ele const suites = useSelector(getSuites); const browsers = useSelector(getBrowsers); const browsersState = useSelector(getBrowsersState); - const isRunning = useSelector(state => state.running); + const isRunning = useIsRunning(); const isRunTestsAvailable = useSelector(state => state.app.availableFeatures) .find(feature => feature.name === RunTestsFeature.name); diff --git a/lib/static/new-ui/features/suites/components/ScreenshotsTreeViewItem/index.tsx b/lib/static/new-ui/features/suites/components/ScreenshotsTreeViewItem/index.tsx index 16a4cc5a8..ddef55b4b 100644 --- a/lib/static/new-ui/features/suites/components/ScreenshotsTreeViewItem/index.tsx +++ b/lib/static/new-ui/features/suites/components/ScreenshotsTreeViewItem/index.tsx @@ -23,6 +23,7 @@ import {ErrorHandler} from '../../../error-handling/components/ErrorHandling'; import {useNavigate, useParams} from 'react-router-dom'; import {getUrl} from '@/static/new-ui/utils/getUrl'; import {useFocusedImage} from '@/static/new-ui/features/suites/components/TestSteps/FocusedImageContext'; +import {useIsRunning} from '@/static/new-ui/hooks/useIsRunning'; interface ScreenshotsTreeViewItemProps { image: ImageEntity; @@ -59,7 +60,7 @@ export function ScreenshotsTreeViewItem(props: ScreenshotsTreeViewItemProps): Re const isEditScreensAvailable = useSelector(state => state.app.availableFeatures) .find(feature => feature.name === EditScreensFeature.name); const isStaticImageAccepterEnabled = useSelector(state => state.staticImageAccepter.enabled); - const isRunning = useSelector(state => state.running); + const isRunning = useIsRunning(); const isProcessing = useSelector(state => state.processing); const isGui = useSelector(state => state.gui); diff --git a/lib/static/new-ui/features/suites/components/TestControlPanel/index.tsx b/lib/static/new-ui/features/suites/components/TestControlPanel/index.tsx index 49dbeb914..e5d29fbb2 100644 --- a/lib/static/new-ui/features/suites/components/TestControlPanel/index.tsx +++ b/lib/static/new-ui/features/suites/components/TestControlPanel/index.tsx @@ -17,6 +17,7 @@ import {toggleTimeTravelPlayerVisibility} from '@/static/modules/actions/snapsho import {thunkRunTest} from '@/static/modules/actions'; import {hasBrowsers} from '@/static/new-ui/types/store'; import {BrowserSelect} from './BrowserSelect'; +import {useIsRunning} from '@/static/new-ui/hooks/useIsRunning'; interface TestControlPanelProps { onAttemptChange?: (browserId: string, resultId: string, attemptIndex: number) => unknown; @@ -66,7 +67,7 @@ export function TestControlPanel(props: TestControlPanelProps): ReactNode { const isRunTestsAvailable = isFeatureAvailable(RunTestsFeature); const isPlayerAvailable = useSelector(isTimeTravelPlayerAvailable); const isPlayerVisible = useSelector(state => state.ui.suitesPage.isSnapshotsPlayerVisible); - const isRunning = useSelector(state => state.running); + const isRunning = useIsRunning(); const showDivider = isRunTestsAvailable && isPlayerAvailable; diff --git a/lib/static/new-ui/features/suites/components/TestInfo/index.tsx b/lib/static/new-ui/features/suites/components/TestInfo/index.tsx index b9c431b0b..1051e308f 100644 --- a/lib/static/new-ui/features/suites/components/TestInfo/index.tsx +++ b/lib/static/new-ui/features/suites/components/TestInfo/index.tsx @@ -14,12 +14,13 @@ import {RunTestLoading} from '@/static/new-ui/components/RunTestLoading'; import styles from './index.module.css'; import ExtensionPoint from '../../../../../components/extension-point'; import {ExtensionPointName} from '../../../../constants/plugins'; +import {useIsRunning} from '@/static/new-ui/hooks/useIsRunning'; export function TestInfo(): ReactNode { const currentResult = useSelector(getCurrentResult); const steps = useSelector(getTestSteps); - const isRunning = useSelector(state => state.running); + const isRunning = useIsRunning(); const isPlayerAvailable = useSelector(isTimeTravelPlayerAvailable); const isPlayerVisible = useSelector(state => state.ui.suitesPage.isSnapshotsPlayerVisible); diff --git a/lib/static/new-ui/features/visual-checks/components/VisualChecksPage/VisualChecksStickyHeader.tsx b/lib/static/new-ui/features/visual-checks/components/VisualChecksPage/VisualChecksStickyHeader.tsx index 45386f5ee..be8e782b7 100644 --- a/lib/static/new-ui/features/visual-checks/components/VisualChecksPage/VisualChecksStickyHeader.tsx +++ b/lib/static/new-ui/features/visual-checks/components/VisualChecksPage/VisualChecksStickyHeader.tsx @@ -39,6 +39,7 @@ import {Page} from '@/constants'; import {TreeViewData} from '@/static/new-ui/components/TreeView'; import {TreeViewItemData} from '@/static/new-ui/features/suites/components/SuitesPage/types'; import {getCurrentImageSuiteHash} from '@/static/new-ui/features/visual-checks/components/VisualChecksPage/selectors'; +import {useIsRunning} from '@/static/new-ui/hooks/useIsRunning'; interface VisualChecksStickyHeaderProps { currentNamedImage: NamedImageEntity | null; @@ -137,7 +138,7 @@ export function VisualChecksStickyHeader({currentNamedImage, treeData, onImageCh const isStaticImageAccepterEnabled = useSelector(state => state.staticImageAccepter.enabled); const isEditScreensAvailable = useSelector(state => state.app.availableFeatures) .find(feature => feature.name === EditScreensFeature.name); - const isRunning = useSelector(state => state.running); + const isRunning = useIsRunning(); const isProcessing = useSelector(state => state.processing); const isGui = useSelector(state => state.gui); diff --git a/lib/static/new-ui/hooks/useIsRunning.ts b/lib/static/new-ui/hooks/useIsRunning.ts new file mode 100644 index 000000000..d11734925 --- /dev/null +++ b/lib/static/new-ui/hooks/useIsRunning.ts @@ -0,0 +1,3 @@ +import {useSelector} from 'react-redux'; + +export const useIsRunning = (): boolean => useSelector(state => state.running || state.repeatLeft > 0); diff --git a/lib/static/new-ui/types/store.ts b/lib/static/new-ui/types/store.ts index 13165d648..738978045 100644 --- a/lib/static/new-ui/types/store.ts +++ b/lib/static/new-ui/types/store.ts @@ -350,6 +350,8 @@ export interface State { baseHost: string; }; running: boolean; + repeatCount: number; + repeatLeft: number; processing: boolean; gui: boolean; apiValues: HtmlReporterValues; diff --git a/test/func/tests/common-gui/run-test-options.testplane.js b/test/func/tests/common-gui/run-test-options.testplane.js new file mode 100644 index 000000000..d77ac6d81 --- /dev/null +++ b/test/func/tests/common-gui/run-test-options.testplane.js @@ -0,0 +1,88 @@ +const childProcess = require('child_process'); +const {existsSync} = require('fs'); +const fs = require('fs/promises'); +const path = require('path'); +const {promisify} = require('util'); + +const treeKill = promisify(require('tree-kill')); + +const {PORTS} = require('../../utils/constants'); +const {runGui} = require('../utils'); + +const serverHost = process.env.SERVER_HOST ?? 'host.docker.internal'; + +const projectName = process.env.PROJECT_UNDER_TEST; +const projectDir = path.resolve(__dirname, '../../fixtures', projectName); +const guiUrl = `http://${serverHost}:${PORTS[projectName].gui}`; + +const reportDir = path.join(projectDir, 'report'); +const reportBackupDir = path.join(projectDir, 'report-backup'); +const screensDir = path.join(projectDir, 'screens'); + +describe('GUI mode', () => { + describe('Run test options', () => { + let guiProcess; + + beforeEach(async ({browser}) => { + if (existsSync(reportBackupDir)) { + await fs.rm(reportDir, {recursive: true, force: true, maxRetries: 3}); + await fs.cp(reportBackupDir, reportDir, {recursive: true, force: true}); + } else { + await fs.cp(reportDir, reportBackupDir, {recursive: true}); + } + + guiProcess = await runGui(projectDir); + + await browser.url(guiUrl + '/new-ui'); + + await browser.waitUntil(async () => { + const title = await browser.getTitle(); + return title.includes('GUI report'); + }, {timeout: 10000}); + }); + + afterEach(async () => { + await treeKill(guiProcess.pid); + + childProcess.execSync('git restore .', {cwd: screensDir}); + childProcess.execSync('git clean -dfx .', {cwd: screensDir}); + }); + + describe('Repeat tests', () => { + it('should run test n times', async ({browser}) => { + const REPEAT_COUNT = 3; + + const testElement = await browser.$('[data-list-item="tests to run/test with image comparison diff/chrome"]'); + await testElement.click(); + + const runTestOptionsButton = await browser.$('[data-qa="run-test-options"]'); + await runTestOptionsButton.waitForClickable(); + + await runTestOptionsButton.click(); + + const repeatCountInput = await browser.$('[data-qa="repeat-count"] input'); + await repeatCountInput.waitForDisplayed(); + await repeatCountInput.setValue(REPEAT_COUNT); + + const treeRunTestOptionsButton = await browser.$('[data-qa="tree-run-test-options"]'); + const treeRunTestOptionsButtonSelected = await treeRunTestOptionsButton.getAttribute('aria-pressed'); + + expect(treeRunTestOptionsButtonSelected).toBe('true'); + + const runTestButton = await browser.$('[data-qa="run-test"]'); + await runTestButton.waitForClickable(); + await runTestButton.click(); + + await browser.waitUntil(async () => { + const attemptsList = await browser.$$('[data-qa="retry-switcher"]'); + + return attemptsList.length === (REPEAT_COUNT + 1); + }, {timeout: 10000}); + + const attemptsList = await browser.$$('[data-qa="retry-switcher"]'); + + expect(attemptsList.length).toBe(4); + }); + }); + }); +}); diff --git a/test/unit/lib/static/components/extension-point.jsx b/test/unit/lib/static/components/extension-point.jsx index 8fea3f51a..838a59847 100644 --- a/test/unit/lib/static/components/extension-point.jsx +++ b/test/unit/lib/static/components/extension-point.jsx @@ -59,6 +59,6 @@ describe('', () => { ); - assert.strictEqual(component.container.innerHTML, '
Before
child
After
'); + assert.strictEqual(component.container.innerHTML, '
Before
child
After
'); }); }); diff --git a/test/unit/lib/static/modules/actions/run-tests.ts b/test/unit/lib/static/modules/actions/run-tests.ts index 974328788..2e7f45c1b 100644 --- a/test/unit/lib/static/modules/actions/run-tests.ts +++ b/test/unit/lib/static/modules/actions/run-tests.ts @@ -36,7 +36,7 @@ describe('lib/static/modules/actions/run-tests', () => { it('should retry passed test', async () => { dispatch.callsFake((action) => { if (typeof action === 'function') { - return action(dispatch, sinon.stub(), null); + return action(dispatch, () => ({repeatCount: 1}), null); } return action; }); @@ -44,7 +44,7 @@ describe('lib/static/modules/actions/run-tests', () => { await actions.thunkRunTest({test})(dispatch, sinon.stub(), null); - assert.calledOnceWith(axios.post, '/run', [test]); + assert.calledOnceWith(axios.post, '/run', {tests: [test], repeatCount: 1}); assert.calledWith(dispatch, {type: actionNames.RETRY_TEST}); }); }); @@ -53,7 +53,7 @@ describe('lib/static/modules/actions/run-tests', () => { it('should run all failed tests', async () => { dispatch.callsFake((action) => { if (typeof action === 'function') { - return action(dispatch, sinon.stub(), null); + return action(dispatch, () => ({repeatCount: 1}), null); } return action; }); @@ -64,7 +64,7 @@ describe('lib/static/modules/actions/run-tests', () => { await actions.thunkRunFailedTests({tests: failedTests})(dispatch, sinon.stub(), null); - assert.calledOnceWith(axios.post, '/run', failedTests); + assert.calledOnceWith(axios.post, '/run', {tests: failedTests, repeatCount: 1}); assert.calledWith(dispatch, {type: actionNames.RUN_FAILED_TESTS}); }); });