From 7a566a3f07a30b381bce495935bc5ce3130a78f9 Mon Sep 17 00:00:00 2001 From: catwithapple Date: Tue, 19 Mar 2019 14:31:53 +0300 Subject: [PATCH] feat: add grouping by error type --- README.md | 22 +- lib/config.js | 36 ++ lib/constants/defaults.js | 3 +- lib/report-builder-factory/report-builder.js | 4 +- .../components/controls/common-controls.js | 5 + .../components/controls/common-filters.js | 4 +- ...ame-input.js => test-name-filter-input.js} | 22 +- lib/static/components/error-groups/item.js | 47 +++ lib/static/components/error-groups/list.js | 29 ++ lib/static/components/gui.js | 5 +- lib/static/components/main-tree.js | 62 ++++ lib/static/components/report.js | 4 +- .../components/section/section-common.js | 37 +- lib/static/components/suites.js | 75 +--- lib/static/modules/action-names.js | 1 + lib/static/modules/actions.js | 5 +- lib/static/modules/default-state.js | 6 +- lib/static/modules/group-errors.js | 136 +++++++ lib/static/modules/reducer.js | 94 ++++- lib/static/modules/utils.js | 62 ++-- lib/static/styles.css | 32 +- test/lib/static/components/suites.js | 4 + test/lib/static/modules/group-errors.js | 343 ++++++++++++++++++ test/lib/static/modules/utils.js | 139 ++++--- 24 files changed, 980 insertions(+), 197 deletions(-) rename lib/static/components/controls/{filter-by-name-input.js => test-name-filter-input.js} (61%) create mode 100644 lib/static/components/error-groups/item.js create mode 100644 lib/static/components/error-groups/list.js create mode 100644 lib/static/components/main-tree.js create mode 100644 lib/static/modules/group-errors.js create mode 100644 test/lib/static/modules/group-errors.js diff --git a/README.md b/README.md index 993115ecf..2cae6075b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ directory. * **baseHost** (optional) - `String` - it changes original host for view in the browser; by default original host does not change * **scaleImages** (optional) – `Boolean` – fit images into page width; `false` by default * **lazyLoadOffset** (optional) - `Number` - allows you to specify how far above and below the viewport you want to begin loading images. Lazy loading would be disabled if you specify 0. `800` by default. +* **errorPatterns** (optional) - `Array` - error message patterns for 'Group by error' mode. +Array element must be `Object` ({'*name*': `String`, '*pattern*': `String`}) or `String` (interpret as *name* and *pattern*). +Test will be associated with group if test error matches on group error pattern. +New group will be created if test cannot be associated with existing groups. Also there is ability to override plugin parameters by CLI options or environment variables (see [configparser](https://github.com/gemini-testing/configparser)). @@ -48,7 +52,14 @@ module.exports = { enabled: true, path: 'my/gemini-reports', defaultView: 'all', - baseHost: 'test.com' + baseHost: 'test.com', + errorPatterns: [ + 'Parameter .* must be a string', + { + name: 'Cannot read property of undefined', + pattern: 'Cannot read property .* of undefined' + } + ] } } }, @@ -69,7 +80,14 @@ module.exports = { enabled: true, path: 'my/hermione-reports', defaultView: 'all', - baseHost: 'test.com' + baseHost: 'test.com', + errorPatterns: [ + 'Parameter .* must be a string', + { + name: 'Cannot read property of undefined', + pattern: 'Cannot read property .* of undefined' + } + ] } }, //... diff --git a/lib/config.js b/lib/config.js index 14295c7aa..2c8361964 100644 --- a/lib/config.js +++ b/lib/config.js @@ -23,6 +23,36 @@ const assertString = (name) => assertType(name, _.isString, 'string'); const assertBoolean = (name) => assertType(name, _.isBoolean, 'boolean'); const assertNumber = (name) => assertType(name, _.isNumber, 'number'); +const assertErrorPatterns = (errorPatterns) => { + if (!_.isArray(errorPatterns)) { + throw new Error(`"errorPatterns" option must be array, but got ${typeof errorPatterns}`); + } + for (const patternInfo of errorPatterns) { + if (!_.isString(patternInfo) && !_.isPlainObject(patternInfo)) { + throw new Error(`Element of "errorPatterns" option must be plain object or string, but got ${typeof patternInfo}`); + } + if (_.isPlainObject(patternInfo)) { + for (const field of ['name', 'pattern']) { + if (!_.isString(patternInfo[field])) { + throw new Error(`Field "${field}" in element of "errorPatterns" option must be string, but got ${typeof patternInfo[field]}`); + } + } + } + } +}; + +const mapErrorPatterns = (errorPatterns) => { + return errorPatterns.map(patternInfo => { + if (typeof patternInfo === 'string') { + return { + name: patternInfo, + pattern: patternInfo + }; + } + return patternInfo; + }); +}; + const getParser = () => { return root(section({ enabled: option({ @@ -53,6 +83,12 @@ const getParser = () => { defaultValue: configDefaults.lazyLoadOffset, parseEnv: JSON.parse, validate: assertNumber('lazyLoadOffset') + }), + errorPatterns: option({ + defaultValue: configDefaults.errorPatterns, + parseEnv: JSON.parse, + validate: assertErrorPatterns, + map: mapErrorPatterns }) }), {envPrefix: ENV_PREFIX, cliPrefix: CLI_PREFIX}); }; diff --git a/lib/constants/defaults.js b/lib/constants/defaults.js index f97bf5f15..9cea20add 100644 --- a/lib/constants/defaults.js +++ b/lib/constants/defaults.js @@ -6,6 +6,7 @@ module.exports = { defaultView: 'all', baseHost: '', scaleImages: false, - lazyLoadOffset: 800 + lazyLoadOffset: 800, + errorPatterns: [] } }; diff --git a/lib/report-builder-factory/report-builder.js b/lib/report-builder-factory/report-builder.js index 005a82783..3f12f44cb 100644 --- a/lib/report-builder-factory/report-builder.js +++ b/lib/report-builder-factory/report-builder.js @@ -205,14 +205,14 @@ module.exports = class ReportBuilder { } getResult() { - const {defaultView, baseHost, scaleImages, lazyLoadOffset} = this._pluginConfig; + const {defaultView, baseHost, scaleImages, lazyLoadOffset, errorPatterns} = this._pluginConfig; this._sortTree(); return _.extend({ skips: _.uniq(this._skips, JSON.stringify), suites: this._tree.children, - config: {defaultView, baseHost, scaleImages, lazyLoadOffset}, + config: {defaultView, baseHost, scaleImages, lazyLoadOffset, errorPatterns}, extraItems: this._extraItems, date: new Date().toString() }, this._stats); diff --git a/lib/static/components/controls/common-controls.js b/lib/static/components/controls/common-controls.js index 64f0ad55b..a93b14a6f 100644 --- a/lib/static/components/controls/common-controls.js +++ b/lib/static/components/controls/common-controls.js @@ -64,6 +64,11 @@ class ControlButtons extends Component { isActive={Boolean(view.lazyLoadOffset)} handler={actions.toggleLazyLoad} /> + diff --git a/lib/static/components/controls/common-filters.js b/lib/static/components/controls/common-filters.js index 81af713e8..401d2f87b 100644 --- a/lib/static/components/controls/common-filters.js +++ b/lib/static/components/controls/common-filters.js @@ -1,13 +1,13 @@ 'use strict'; import React, {Component} from 'react'; -import FilterByNameInput from './filter-by-name-input'; +import TestNameFilterInput from './test-name-filter-input'; class CommonFilters extends Component { render() { return (
- +
); } diff --git a/lib/static/components/controls/filter-by-name-input.js b/lib/static/components/controls/test-name-filter-input.js similarity index 61% rename from lib/static/components/controls/filter-by-name-input.js rename to lib/static/components/controls/test-name-filter-input.js index 4dfa7e014..0137c1d7c 100644 --- a/lib/static/components/controls/filter-by-name-input.js +++ b/lib/static/components/controls/test-name-filter-input.js @@ -4,12 +4,12 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import _ from 'lodash'; +import {debounce} from 'lodash'; import * as actions from '../../modules/actions'; -class FilterByNameInput extends Component { +class TestNameFilterInput extends Component { static propTypes = { - filterByName: PropTypes.string.isRequired, + testNameFilter: PropTypes.string.isRequired, actions: PropTypes.object.isRequired } @@ -17,17 +17,17 @@ class FilterByNameInput extends Component { super(props); this.state = { - filterByName: this.props.filterByName + testNameFilter: this.props.testNameFilter }; this._onChange = (event) => { - this.setState({filterByName: event.target.value}); + this.setState({testNameFilter: event.target.value}); this._debouncedUpdate(); }; - this._debouncedUpdate = _.debounce( + this._debouncedUpdate = debounce( () => { - this.props.actions.updateFilterByName(this.state.filterByName); + this.props.actions.updateTestNameFilter(this.state.testNameFilter); }, 500, { @@ -41,8 +41,8 @@ class FilterByNameInput extends Component { ); @@ -50,6 +50,6 @@ class FilterByNameInput extends Component { } export default connect( - (state) => ({filterByName: state.view.filterByName}), + (state) => ({testNameFilter: state.view.testNameFilter}), (dispatch) => ({actions: bindActionCreators(actions, dispatch)}) -)(FilterByNameInput); +)(TestNameFilterInput); diff --git a/lib/static/components/error-groups/item.js b/lib/static/components/error-groups/item.js new file mode 100644 index 000000000..dfdd65cfb --- /dev/null +++ b/lib/static/components/error-groups/item.js @@ -0,0 +1,47 @@ +'use strict'; + +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import Suites from '../suites'; + +export default class ErrorGroupsItem extends Component { + state = { + collapsed: true + }; + + static propTypes = { + group: PropTypes.object.isRequired + } + + _toggleState = () => { + this.setState({collapsed: !this.state.collapsed}); + } + + render() { + const {name, pattern, count, tests} = this.props.group; + + const body = this.state.collapsed + ? null + :
+ +
+
; + + const className = classNames( + 'error-group', + {'error-group_collapsed': this.state.collapsed} + ); + + return ( +
+
+
{name}
+  ({count}) +
+ {body} +
+ ); + } +} diff --git a/lib/static/components/error-groups/list.js b/lib/static/components/error-groups/list.js new file mode 100644 index 000000000..90db29c39 --- /dev/null +++ b/lib/static/components/error-groups/list.js @@ -0,0 +1,29 @@ +'use strict'; + +import React, {Component} from 'react'; +import {connect} from 'react-redux'; +import PropTypes from 'prop-types'; + +import ErrorGroupsItem from './item'; + +class ErrorGroupsList extends Component { + static propTypes = { + groupedErrors: PropTypes.array.isRequired + }; + + render() { + const {groupedErrors} = this.props; + + return groupedErrors.length === 0 + ?
There is no test failure to be displayed.
+ : ( +
+ {groupedErrors.map(group => { + return ; + })} +
+ ); + } +} + +export default connect(({groupedErrors}) => ({groupedErrors}))(ErrorGroupsList); diff --git a/lib/static/components/gui.js b/lib/static/components/gui.js index 340252f8a..1d03f0fb5 100644 --- a/lib/static/components/gui.js +++ b/lib/static/components/gui.js @@ -2,12 +2,13 @@ import React, {Component, Fragment} from 'react'; import {connect} from 'react-redux'; + import {initial} from '../modules/actions'; import ControlButtons from './controls/gui-controls'; import SkippedList from './skipped-list'; -import Suites from './suites'; import Loading from './loading'; import ModalContainer from '../containers/modal'; +import MainTree from './main-tree'; class Gui extends Component { componentDidMount() { @@ -21,7 +22,7 @@ class Gui extends Component { - + diff --git a/lib/static/components/main-tree.js b/lib/static/components/main-tree.js new file mode 100644 index 000000000..82c23709b --- /dev/null +++ b/lib/static/components/main-tree.js @@ -0,0 +1,62 @@ +'use strict'; + +import React, {Component} from 'react'; +import {connect} from 'react-redux'; +import PropTypes from 'prop-types'; +import {bindActionCreators} from 'redux'; + +import ErrorGroupsList from './error-groups/list'; +import Suites from './suites'; +import clientEvents from '../../gui/constants/client-events'; +import {suiteBegin, testBegin, testResult, testsEnd} from '../modules/actions'; + +class MainTree extends Component { + static propTypes = { + gui: PropTypes.bool, + groupByError: PropTypes.bool.isRequired + } + + componentDidMount() { + this.props.gui && this._subscribeToEvents(); + } + + _subscribeToEvents() { + const {actions} = this.props; + const eventSource = new EventSource('/events'); + eventSource.addEventListener(clientEvents.BEGIN_SUITE, (e) => { + const data = JSON.parse(e.data); + actions.suiteBegin(data); + }); + + eventSource.addEventListener(clientEvents.BEGIN_STATE, (e) => { + const data = JSON.parse(e.data); + actions.testBegin(data); + }); + + [clientEvents.TEST_RESULT, clientEvents.ERROR].forEach((eventName) => { + eventSource.addEventListener(eventName, (e) => { + const data = JSON.parse(e.data); + actions.testResult(data); + }); + }); + + eventSource.addEventListener(clientEvents.END, () => { + this.props.actions.testsEnd(); + }); + } + + render() { + const {groupByError} = this.props; + + return groupByError + ? + : ; + } +} + +const actions = {testBegin, suiteBegin, testResult, testsEnd}; + +export default connect( + ({gui, view: {groupByError}}) => ({gui, groupByError}), + (dispatch) => ({actions: bindActionCreators(actions, dispatch)}) +)(MainTree); diff --git a/lib/static/components/report.js b/lib/static/components/report.js index e095238aa..23ffa5989 100644 --- a/lib/static/components/report.js +++ b/lib/static/components/report.js @@ -4,7 +4,7 @@ import React, {Component, Fragment} from 'react'; import Summary from './summary'; import ControlButtons from './controls/report-controls'; import SkippedList from './skipped-list'; -import Suites from './suites'; +import MainTree from './main-tree'; export default class Report extends Component { render() { @@ -13,7 +13,7 @@ export default class Report extends Component { - + ); } diff --git a/lib/static/components/section/section-common.js b/lib/static/components/section/section-common.js index afdbdff8a..df87531d8 100644 --- a/lib/static/components/section/section-common.js +++ b/lib/static/components/section/section-common.js @@ -8,7 +8,7 @@ import PropTypes from 'prop-types'; import {uniqueId} from 'lodash'; import SectionWrapper from './section-wrapper'; import SectionBrowser from './section-browser'; -import {hasFails, hasRetries, shouldSuiteBeShownByName, shouldSuiteBeShownByBrowser} from '../../modules/utils'; +import {hasFails, hasRetries, shouldSuiteBeShown, shouldBrowserBeShown} from '../../modules/utils'; import Title from './title/simple'; class SectionCommon extends Component { @@ -19,8 +19,9 @@ class SectionCommon extends Component { browsers: PropTypes.array, children: PropTypes.array }), - filterByName: PropTypes.string, + testNameFilter: PropTypes.string, filteredBrowsers: PropTypes.array, + errorGroupTests: PropTypes.object, shouldBeOpened: PropTypes.func, sectionStatusResolver: PropTypes.func, eventToUpdate: PropTypes.string @@ -55,14 +56,16 @@ class SectionCommon extends Component { } render() { - const {suite, filterByName, filteredBrowsers, sectionStatusResolver} = this.props; + const {suite, testNameFilter, filteredBrowsers, sectionStatusResolver, errorGroupTests} = this.props; const {opened} = this.state; const { name, browsers = [], children = [], - status + status, + suitePath } = suite; + const fullTestName = suitePath.join(' '); if (!opened) { return ( @@ -72,24 +75,24 @@ class SectionCommon extends Component { ); } - let visibleChildren = children; - if (filterByName) { - visibleChildren = visibleChildren.filter(child => shouldSuiteBeShownByName(child, filterByName)); - } - if (filteredBrowsers.length > 0) { - visibleChildren = visibleChildren.filter(child => shouldSuiteBeShownByBrowser(child, filteredBrowsers)); - } + const visibleChildren = children.filter(child => shouldSuiteBeShown({suite: child, testNameFilter, filteredBrowsers, errorGroupTests})); + const childrenTmpl = visibleChildren.map((child) => { const key = uniqueId(`${suite.suitePath}-${child.name}`); - return ; + return ; }); + const browserTmpl = browsers - .filter(({name}) => { - return filteredBrowsers.length === 0 || filteredBrowsers.includes(name); - }) - .map((browser) => { - return ; + .filter(browser => shouldBrowserBeShown({browser, fullTestName, filteredBrowsers, errorGroupTests})) + .map(browser => { + return ( + + ); }); return ( diff --git a/lib/static/components/suites.js b/lib/static/components/suites.js index 3e44466dd..ffcbb634b 100644 --- a/lib/static/components/suites.js +++ b/lib/static/components/suites.js @@ -3,61 +3,35 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; import LazilyRender from '@gemini-testing/react-lazily-render'; import SectionCommon from './section/section-common'; import clientEvents from '../../gui/constants/client-events'; -import {suiteBegin, testBegin, testResult, testsEnd} from '../modules/actions'; -import {shouldSuiteBeShownByName, shouldSuiteBeShownByBrowser} from '../modules/utils'; +import {shouldSuiteBeShown} from '../modules/utils'; class Suites extends Component { static propTypes = { suiteIds: PropTypes.arrayOf(PropTypes.string), - gui: PropTypes.bool, - lazyLoadOffset: PropTypes.number - } - - componentDidMount() { - this.props.gui && this._subscribeToEvents(); - } - - _subscribeToEvents() { - const {actions} = this.props; - const eventSource = new EventSource('/events'); - eventSource.addEventListener(clientEvents.BEGIN_SUITE, (e) => { - const data = JSON.parse(e.data); - actions.suiteBegin(data); - }); - - eventSource.addEventListener(clientEvents.BEGIN_STATE, (e) => { - const data = JSON.parse(e.data); - actions.testBegin(data); - }); - - [clientEvents.TEST_RESULT, clientEvents.ERROR].forEach((eventName) => { - eventSource.addEventListener(eventName, (e) => { - const data = JSON.parse(e.data); - actions.testResult(data); - }); - }); - - eventSource.addEventListener(clientEvents.END, () => { - this.props.actions.testsEnd(); - }); + lazyLoadOffset: PropTypes.number, + errorGroupTests: PropTypes.object } render() { - const {suites, suiteIds, filterByName, filteredBrowsers, lazyLoadOffset} = this.props; + const {suites, suiteIds, testNameFilter, filteredBrowsers, lazyLoadOffset, errorGroupTests} = this.props; + + const visibleSuiteIds = suiteIds.filter(id => + shouldSuiteBeShown({suite: suites[id], testNameFilter, filteredBrowsers, errorGroupTests}) + ); return (
- {suiteIds.map((suiteId) => { + {visibleSuiteIds.map((suiteId) => { const sectionProps = { key: suiteId, suite: suites[suiteId], - filterByName: filterByName, - filteredBrowsers: filteredBrowsers + testNameFilter, + filteredBrowsers, + errorGroupTests }; if (lazyLoadOffset > 0) { @@ -75,29 +49,16 @@ class Suites extends Component { } } -const actions = {testBegin, suiteBegin, testResult, testsEnd}; - export default connect( (state) => { - const {filterByName, filteredBrowsers, lazyLoadOffset} = state.view; - let suiteIds = state.suiteIds[state.view.viewMode]; - - if (filteredBrowsers.length > 0) { - suiteIds = suiteIds.filter(id => shouldSuiteBeShownByBrowser(state.suites[id], filteredBrowsers)); - } - - if (filterByName) { - suiteIds = suiteIds.filter(id => shouldSuiteBeShownByName(state.suites[id], filterByName)); - } + const {testNameFilter, filteredBrowsers, lazyLoadOffset} = state.view; return ({ - suiteIds, - suites: state.suites, - gui: state.gui, - filterByName, + suiteIds: state.suiteIds[state.view.viewMode], + testNameFilter, filteredBrowsers, - lazyLoadOffset + lazyLoadOffset, + suites: state.suites }); - }, - (dispatch) => ({actions: bindActionCreators(actions, dispatch)}) + } )(Suites); diff --git a/lib/static/modules/action-names.js b/lib/static/modules/action-names.js index b0bc82bb4..e0ece855d 100644 --- a/lib/static/modules/action-names.js +++ b/lib/static/modules/action-names.js @@ -28,6 +28,7 @@ export default { VIEW_TOGGLE_ONLY_DIFF: 'VIEW_TOGGLE_ONLY_DIFF', VIEW_UPDATE_BASE_HOST: 'VIEW_UPDATE_BASE_HOST', VIEW_UPDATE_FILTER_BY_NAME: 'VIEW_UPDATE_FILTER_BY_NAME', + VIEW_TOGGLE_GROUP_BY_ERROR: 'VIEW_TOGGLE_GROUP_BY_ERROR', VIEW_TOGGLE_SCALE_IMAGES: 'VIEW_TOGGLE_SCALE_IMAGES', VIEW_TOGGLE_LAZY_LOAD_IMAGES: 'VIEW_TOGGLE_LAZY_LOAD_IMAGES' }; diff --git a/lib/static/modules/actions.js b/lib/static/modules/actions.js index 682055ce7..c4bddb677 100644 --- a/lib/static/modules/actions.js +++ b/lib/static/modules/actions.js @@ -98,14 +98,15 @@ export const collapseAll = () => triggerViewChanges({type: actionNames.VIEW_COLL export const toggleSkipped = () => triggerViewChanges({type: actionNames.VIEW_TOGGLE_SKIPPED}); export const toggleOnlyDiff = () => triggerViewChanges({type: actionNames.VIEW_TOGGLE_ONLY_DIFF}); export const toggleScaleImages = () => triggerViewChanges({type: actionNames.VIEW_TOGGLE_SCALE_IMAGES}); +export const toggleGroupByError = () => ({type: actionNames.VIEW_TOGGLE_GROUP_BY_ERROR}); export const toggleLazyLoad = () => ({type: actionNames.VIEW_TOGGLE_LAZY_LOAD_IMAGES}); export const updateBaseHost = (host) => { window.localStorage.setItem('_gemini-replace-host', host); return {type: actionNames.VIEW_UPDATE_BASE_HOST, host}; }; -export const updateFilterByName = (filterByName) => { - return triggerViewChanges({type: actionNames.VIEW_UPDATE_FILTER_BY_NAME, filterByName}); +export const updateTestNameFilter = (testNameFilter) => { + return triggerViewChanges({type: actionNames.VIEW_UPDATE_FILTER_BY_NAME, testNameFilter}); }; export function changeViewMode(mode) { diff --git a/lib/static/modules/default-state.js b/lib/static/modules/default-state.js index 880c50b6f..b8838244b 100644 --- a/lib/static/modules/default-state.js +++ b/lib/static/modules/default-state.js @@ -7,6 +7,7 @@ export default Object.assign(defaults, { running: false, autoRun: false, skips: [], + groupedErrors: [], suites: {}, suiteIds: { all: [], @@ -32,7 +33,8 @@ export default Object.assign(defaults, { showOnlyDiff: false, scaleImages: false, baseHost: '', - filterByName: '', - filteredBrowsers: [] + testNameFilter: '', + filteredBrowsers: [], + groupByError: false } }); diff --git a/lib/static/modules/group-errors.js b/lib/static/modules/group-errors.js new file mode 100644 index 000000000..c1e8b4717 --- /dev/null +++ b/lib/static/modules/group-errors.js @@ -0,0 +1,136 @@ +'use strict'; + +const {get} = require('lodash'); +const {isSuccessStatus} = require('../../common-utils'); + +/** + * @param {object} suites + * @param {array} errorPatterns + * @param {array} filteredBrowsers + * @param {string} [testNameFilter] + * @return {array} + */ +function groupErrors({suites, errorPatterns = [], filteredBrowsers = [], testNameFilter = ''}) { + const testWithErrors = extractErrors(suites); + + const errorGroupsList = getErrorGroupList(testWithErrors, errorPatterns, filteredBrowsers, testNameFilter); + + errorGroupsList.sort((a, b) => { + const result = b.count - a.count; + if (result === 0) { + return a.name.localeCompare(b.name); + } + return result; + }); + + return errorGroupsList; +} + +function extractErrors(rootSuites) { + const testWithErrors = {}; + + const extract = (suites) => { + for (const suite of Object.values(suites)) { + const testName = suite.suitePath.join(' '); + const browsersWithError = {}; + + if (suite.browsers) { + for (const browser of suite.browsers) { + if (isSuccessStatus(browser.result.status)) { + continue; + } + const retries = [...browser.retries, browser.result]; + const errorsInBrowser = extractErrorsFromRetries(retries); + if (errorsInBrowser.length) { + browsersWithError[browser.name] = errorsInBrowser; + } + } + } + if (Object.keys(browsersWithError).length) { + testWithErrors[testName] = browsersWithError; + } + if (suite.children) { + extract(suite.children); + } + } + }; + + extract(rootSuites); + + return testWithErrors; +} + +function extractErrorsFromRetries(retries) { + const errorsInRetry = new Set(); + + for (const retry of retries) { + for (const {error} of [...retry.imagesInfo, retry]) { + if (get(error, 'message')) { + errorsInRetry.add(error.message); + } + } + } + return [...errorsInRetry]; +} + +function getErrorGroupList(testWithErrors, errorPatterns, filteredBrowsers, testNameFilter) { + const errorGroups = {}; + const errorPatternsWithRegExp = addRegExpToErrorPatterns(errorPatterns); + + for (const [testName, browsers] of Object.entries(testWithErrors)) { + if (testNameFilter && !testName.includes(testNameFilter)) { + continue; + } + + for (const [browserName, errors] of Object.entries(browsers)) { + if (filteredBrowsers.length !== 0 && !filteredBrowsers.includes(browserName)) { + continue; + } + for (const errorText of errors) { + const patternInfo = matchGroup(errorText, errorPatternsWithRegExp); + const {pattern, name} = patternInfo; + + if (!errorGroups.hasOwnProperty(name)) { + errorGroups[name] = { + pattern, + name, + tests: {}, + count: 0 + }; + } + const group = errorGroups[name]; + if (!group.tests.hasOwnProperty(testName)) { + group.tests[testName] = []; + } + if (!group.tests[testName].includes(browserName)) { + group.tests[testName].push(browserName); + group.count++; + } + } + } + } + + return Object.values(errorGroups); +} + +function addRegExpToErrorPatterns(errorPatterns) { + return errorPatterns.map(patternInfo => ({ + ...patternInfo, + regexp: new RegExp(patternInfo.pattern) + })); +} + +function matchGroup(errorText, errorPatternsWithRegExp) { + for (const group of errorPatternsWithRegExp) { + if (errorText.match(group.regexp)) { + return group; + } + } + + return { + name: errorText, + pattern: errorText + }; +} + +module.exports = {groupErrors}; diff --git a/lib/static/modules/reducer.js b/lib/static/modules/reducer.js index f4bf26a7e..b3355b68a 100644 --- a/lib/static/modules/reducer.js +++ b/lib/static/modules/reducer.js @@ -5,20 +5,25 @@ import actionNames from './action-names'; import defaultState from './default-state'; import {assign, merge, filter, map, clone, cloneDeep, reduce, find, last} from 'lodash'; import {isSuiteFailed, setStatusToAll, findNode, setStatusForBranch, dateToLocaleString} from './utils'; +import {groupErrors} from './group-errors'; const compiledData = window.data || defaultState; const localStorage = window.localStorage; -function getInitialState(compiledData) { +function getInitialState(data) { const {skips, suites, config, total, updated, passed, - failed, skipped, warned, retries, perBrowser, extraItems, gui = false, date} = compiledData; - const formattedSuites = formatSuitesData(suites); + failed, skipped, warned, retries, perBrowser, extraItems, gui = false, date} = data; + const {errorPatterns, scaleImages, lazyLoadOffset, defaultView} = config; const parsedURL = new URL(window.location.href); const filteredBrowsers = parsedURL.searchParams.getAll('browser'); + const formattedSuites = formatSuitesData(suites); + const groupedErrors = groupErrors({suites: formattedSuites.suites, errorPatterns, filteredBrowsers}); + return merge(defaultState, { gui, skips, + groupedErrors, config, extraItems, date: dateToLocaleString(date), @@ -27,11 +32,10 @@ function getInitialState(compiledData) { perBrowser }, view: { - viewMode: config.defaultView, - scaleImages: config.scaleImages, - lazyLoadOffset: config.lazyLoadOffset, + viewMode: defaultView, + scaleImages, + lazyLoadOffset, ..._loadBaseHost(config.baseHost, localStorage), - filterByName: '', filteredBrowsers } }, formattedSuites); @@ -40,21 +44,57 @@ function getInitialState(compiledData) { export default function reducer(state = getInitialState(compiledData), action) { switch (action.type) { case actionNames.VIEW_INITIAL: { - const {gui, autoRun, suites, skips, extraItems, config: {scaleImages, lazyLoadOffset}} = action.payload; + const { + gui, + autoRun, + suites, + skips, + extraItems, + config: {scaleImages, lazyLoadOffset} + } = action.payload; + const {errorPatterns} = state.config; + const {filteredBrowsers, testNameFilter} = state.view; + const formattedSuites = formatSuitesData(suites); + const groupedErrors = groupErrors({ + suites: formattedSuites.suites, + errorPatterns, + filteredBrowsers, + testNameFilter + }); - return merge({}, state, {gui, autoRun, skips, extraItems, view: {scaleImages, lazyLoadOffset}}, formattedSuites); + return merge( + {}, + state, + { + gui, + autoRun, + skips, + groupedErrors, + extraItems, + view: {scaleImages, lazyLoadOffset} + }, + formattedSuites + ); } case actionNames.RUN_ALL_TESTS: { const suites = clone(state.suites); setStatusToAll(suites, action.payload.status); - return merge({}, state, {running: true, suites}); // TODO: rewrite store on run all tests + // TODO: rewrite store on run all tests + return merge({}, state, {running: true, suites, view: {groupByError: false}}); } case actionNames.RUN_FAILED_TESTS: case actionNames.RETRY_SUITE: case actionNames.RETRY_TEST: { - return assign(clone(state), {running: true}); + return { + ...state, + running: true, + view: { + ...state.view, + groupByError: false + } + }; } case actionNames.SUITE_BEGIN: { const suites = clone(state.suites); @@ -129,14 +169,31 @@ export default function reducer(state = getInitialState(compiledData), action) { return _mutateStateView(state, {baseHost, parsedHost}); } case actionNames.VIEW_UPDATE_FILTER_BY_NAME: { - return _mutateStateView(state, { - filterByName: action.filterByName - }); + const {testNameFilter} = action; + const { + suites, + config: {errorPatterns}, + view: {filteredBrowsers} + } = state; + + const groupedErrors = groupErrors({suites, errorPatterns, filteredBrowsers, testNameFilter}); + + return { + ...state, + groupedErrors, + view: { + ...state.view, + testNameFilter + } + }; } case actionNames.CLOSE_SECTIONS: { const closeIds = action.payload; return assign(clone(state), {closeIds}); } + case actionNames.VIEW_TOGGLE_GROUP_BY_ERROR: { + return _mutateStateView(state, {groupByError: !state.view.groupByError}); + } case actionNames.TOGGLE_TEST_RESULT: { const {opened} = action.payload; return updateTestState(state, action, {opened}); @@ -165,6 +222,10 @@ export default function reducer(state = getInitialState(compiledData), action) { } function addTestResult(state, action) { + const { + config: {errorPatterns}, + view: {filteredBrowsers, testNameFilter} + } = state; const suites = clone(state.suites); [].concat(action.payload).forEach((suite) => { @@ -187,7 +248,9 @@ function addTestResult(state, action) { const suiteIds = clone(state.suiteIds); assign(suiteIds, {failed: getFailedSuiteIds(suites)}); - return assign({}, state, {suiteIds, suites}); + const groupedErrors = groupErrors({suites, errorPatterns, filteredBrowsers, testNameFilter}); + + return assign({}, state, {suiteIds, suites, groupedErrors}); } function updateTestState(state, action, testState) { @@ -292,3 +355,4 @@ function forceUpdateSuiteData(suites, test) { const id = getSuiteId(test); suites[id] = cloneDeep(suites[id]); } + diff --git a/lib/static/modules/utils.js b/lib/static/modules/utils.js index a63660a21..9c865927b 100644 --- a/lib/static/modules/utils.js +++ b/lib/static/modules/utils.js @@ -112,27 +112,6 @@ function setStatusForBranch(nodes, suitePath) { setStatusForBranch(nodes, suitePath.slice(0, -1)); } -function shouldSuiteBeShownByName(suite, filterByName) { - const suiteFullPath = suite.suitePath.join(' '); - - if (suiteFullPath.includes(filterByName)) { - return true; - } - if (suite.hasOwnProperty('children')) { - return suite.children.some(child => shouldSuiteBeShownByName(child, filterByName)); - } - - return false; -} - -function shouldSuiteBeShownByBrowser(suite, filteredBrowsers) { - if (suite.hasOwnProperty('browsers')) { - return suite.browsers.some(browser => filteredBrowsers.includes(browser.name)); - } - - return suite.children.some(child => shouldSuiteBeShownByBrowser(child, filteredBrowsers)); -} - function getStats(stats, filteredBrowsers) { if (filteredBrowsers.length === 0 || !stats.perBrowser) { return stats.all; @@ -162,6 +141,41 @@ function dateToLocaleString(date) { return new Date(date).toLocaleString(lang); } +function shouldSuiteBeShown({suite, testNameFilter = '', filteredBrowsers = [], errorGroupTests = {}}) { + const strictTestNameFilters = Object.keys(errorGroupTests); + + if (suite.hasOwnProperty('children')) { + return suite.children.some(child => shouldSuiteBeShown({suite: child, testNameFilter, errorGroupTests, filteredBrowsers})); + } + + const suiteFullPath = suite.suitePath.join(' '); + + const matchName = !testNameFilter || suiteFullPath.includes(testNameFilter); + + const strictMatchNames = strictTestNameFilters.length === 0 + || strictTestNameFilters.includes(suiteFullPath); + + const matchBrowsers = filteredBrowsers.length === 0 + || !suite.hasOwnProperty('browsers') + || suite.browsers.some(({name}) => filteredBrowsers.includes(name)); + + return matchName && strictMatchNames && matchBrowsers; +} + +function shouldBrowserBeShown({browser, fullTestName, filteredBrowsers = [], errorGroupTests = {}}) { + const {name} = browser; + let errorGroupBrowsers = []; + + if (errorGroupTests && errorGroupTests.hasOwnProperty(fullTestName)) { + errorGroupBrowsers = errorGroupTests[fullTestName]; + } + + const matchFilteredBrowsers = filteredBrowsers.length === 0 || filteredBrowsers.includes(name); + const matchErrorGroupBrowsers = errorGroupBrowsers.length === 0 || errorGroupBrowsers.includes(name); + + return matchFilteredBrowsers && matchErrorGroupBrowsers; +} + module.exports = { hasNoRefImageErrors, hasFails, @@ -172,8 +186,8 @@ module.exports = { findNode, setStatusToAll, setStatusForBranch, - shouldSuiteBeShownByName, - shouldSuiteBeShownByBrowser, getStats, - dateToLocaleString + dateToLocaleString, + shouldSuiteBeShown, + shouldBrowserBeShown }; diff --git a/lib/static/styles.css b/lib/static/styles.css index 15dc20b18..d17c367c8 100644 --- a/lib/static/styles.css +++ b/lib/static/styles.css @@ -148,14 +148,24 @@ background-size: contain; background-repeat: no-repeat; } +.error-group__title { + display: flex; + + user-select: none; +} + +.error-group__name { + cursor: pointer; + font-weight: bold; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} .section__title { font-weight: bold; cursor: pointer; - -moz-user-select: none; - -webkit-user-select: none; - -ms-user-select: none; user-select: none; } @@ -163,18 +173,17 @@ color: #ccc; cursor: default; - -moz-user-select: text; - -webkit-user-select: text; - -ms-user-select: text; user-select: text; } .section__title:before, +.error-group__title:before, .state-title:before { height: 18px; } .section__title:before, +.error-group__title:before, .state-title:before, .toggle-open__switcher:before { display: inline-block; @@ -185,6 +194,7 @@ } .section .section__title:hover, +.error-group__name:hover, .state-title:hover { color: #2d3e50; } @@ -210,11 +220,13 @@ color: #ccc; } -.section__body { +.section__body, +.error-group__body { padding-left: 15px; } -.section__body_guided { +.section__body_guided, +.error-group__body_guided { border-left: 1px dotted #ccc; } @@ -261,6 +273,7 @@ } .section_collapsed .section__title:before, +.error-group_collapsed .error-group__title:before, .state-title_collapsed:before, .toggle-open_collapsed .toggle-open__switcher:before { -webkit-transform: rotate(-90deg); @@ -305,9 +318,6 @@ font-weight: bold; cursor: pointer; - -moz-user-select: none; - -webkit-user-select: none; - -ms-user-select: none; user-select: none; } diff --git a/test/lib/static/components/suites.js b/test/lib/static/components/suites.js index e1487d165..61577217d 100644 --- a/test/lib/static/components/suites.js +++ b/test/lib/static/components/suites.js @@ -4,6 +4,7 @@ import proxyquire from 'proxyquire'; import {defaultsDeep} from 'lodash'; import {mkConnectedComponent} from './utils'; +import {mkState} from '../../../utils'; import {config} from 'lib/constants/defaults'; import clientEvents from 'lib/constants/client-events'; @@ -19,6 +20,9 @@ describe('', () => { initialState = defaultsDeep(initialState, { gui: false, suiteIds: {all: ['suite1']}, + suites: {'suite1': mkState({ + suitePath: ['suite1'] + })}, view: {viewMode: 'all', filteredBrowsers: [], lazyLoadOffset: config.lazyLoadOffset} }); diff --git a/test/lib/static/modules/group-errors.js b/test/lib/static/modules/group-errors.js new file mode 100644 index 000000000..c62aab152 --- /dev/null +++ b/test/lib/static/modules/group-errors.js @@ -0,0 +1,343 @@ +'use strict'; + +const {groupErrors} = require('../../../../lib/static/modules/group-errors'); +const { + mkSuite, + mkState, + mkBrowserResult, + mkSuiteTree, + mkTestResult +} = require('../../../utils'); + +describe('static/modules/group-errors', () => { + it('should not collect errors from success test', () => { + const suites = [ + mkSuiteTree({ + browsers: [ + mkBrowserResult({ + result: mkTestResult({ + status: 'success' + }), + retries: [ + mkTestResult({ + error: { + message: 'message stub' + } + }) + ] + }) + ] + }) + ]; + + const result = groupErrors({suites}); + + assert.deepEqual(result, []); + }); + + it('should collect errors from error and imagesInfo[].error', () => { + const suites = [ + mkSuiteTree({ + browsers: [ + mkBrowserResult({ + result: mkTestResult({ + error: { + message: 'message stub first' + }, + imagesInfo: [ + {error: {message: 'message stub second'}} + ] + }) + }) + ] + }) + ]; + + const result = groupErrors({suites}); + + assert.deepEqual(result, [ + { + count: 1, + name: 'message stub first', + pattern: 'message stub first', + tests: { + 'default-suite default-state': ['default-bro'] + } + }, + { + count: 1, + name: 'message stub second', + pattern: 'message stub second', + tests: { + 'default-suite default-state': ['default-bro'] + } + } + ]); + }); + + it('should collect errors from result and retries', () => { + const suites = [ + mkSuiteTree({ + browsers: [ + mkBrowserResult({ + result: mkTestResult({ + error: { + message: 'message stub first' + } + }), + retries: [ + mkTestResult({ + error: { + message: 'message stub second' + } + }) + ] + }) + ] + }) + ]; + + const result = groupErrors({suites}); + + assert.deepEqual(result, [ + { + count: 1, + name: 'message stub first', + pattern: 'message stub first', + tests: { + 'default-suite default-state': ['default-bro'] + } + }, + { + count: 1, + name: 'message stub second', + pattern: 'message stub second', + tests: { + 'default-suite default-state': ['default-bro'] + } + } + ]); + }); + + it('should collect errors from children recursively', () => { + const suites = [ + mkSuite({ + suitePath: ['suite'], + children: [ + mkSuite({ + suitePath: ['suite', 'state-one'], + children: [ + mkState({ + suitePath: ['suite', 'state-one', 'state-two'], + browsers: [ + mkBrowserResult({ + result: mkTestResult({ + error: { + message: 'message stub' + } + }) + }) + ] + }) + ] + }) + ] + }) + ]; + + const result = groupErrors({suites}); + + assert.deepEqual(result, [ + { + count: 1, + name: 'message stub', + pattern: 'message stub', + tests: { + 'suite state-one state-two': ['default-bro'] + } + } + ]); + }); + + it('should group errors from different browser but single test', () => { + const suites = [ + mkSuiteTree({ + browsers: [ + mkBrowserResult({ + name: 'browserOne', + result: mkTestResult({ + error: { + message: 'message stub' + } + }) + }), + mkBrowserResult({ + name: 'browserTwo', + result: mkTestResult({ + error: { + message: 'message stub' + } + }) + }) + ] + }) + ]; + + const result = groupErrors({suites}); + + assert.deepEqual(result, [ + { + count: 2, + name: 'message stub', + pattern: 'message stub', + tests: { + 'default-suite default-state': ['browserOne', 'browserTwo'] + } + } + ]); + }); + + it('should filter by test name', () => { + const suites = [ + mkSuite({ + suitePath: ['suite'], + children: [ + mkSuite({ + suitePath: ['suite', 'state-one'], + browsers: [ + mkBrowserResult({ + result: mkTestResult({ + error: { + message: 'message stub' + } + }) + }) + ] + }), + mkSuite({ + suitePath: ['suite', 'state-two'], + browsers: [ + mkBrowserResult({ + result: mkTestResult({ + error: { + message: 'message stub' + } + }) + }) + ] + }) + ] + }) + ]; + + const result = groupErrors({ + suites, + testNameFilter: 'suite state-one' + }); + assert.deepEqual(result, [ + { + count: 1, + name: 'message stub', + pattern: 'message stub', + tests: { + 'suite state-one': ['default-bro'] + } + } + ]); + }); + + it('should filter by browser', () => { + const suites = [ + mkSuite({ + suitePath: ['suite'], + children: [ + mkSuite({ + suitePath: ['suite', 'state'], + browsers: [ + mkBrowserResult({ + name: 'browser-one', + result: mkTestResult({ + error: { + message: 'message stub' + } + }) + }), + mkBrowserResult({ + name: 'browser-two', + result: mkTestResult({ + error: { + message: 'message stub' + } + }) + }) + ] + }) + ] + }) + ]; + + const result = groupErrors({ + suites, + filteredBrowsers: ['browser-one'] + }); + assert.deepEqual(result, [ + { + count: 1, + name: 'message stub', + pattern: 'message stub', + tests: { + 'suite state': ['browser-one'] + } + } + ]); + }); + + it('should group by regexp', () => { + const suites = [ + mkSuiteTree({ + browsers: [ + mkBrowserResult({ + result: mkTestResult({ + error: { + message: 'message stub first' + } + }), + retries: [ + mkTestResult({ + error: { + message: 'message stub second' + } + }) + ] + }) + ] + }) + ]; + const errorPatterns = [ + { + name: 'Name group: message stub first', + pattern: 'message .* first' + } + ]; + + const result = groupErrors({suites, errorPatterns}); + assert.deepEqual(result, [ + { + count: 1, + name: 'message stub second', + pattern: 'message stub second', + tests: { + 'default-suite default-state': ['default-bro'] + } + }, + { + count: 1, + name: 'Name group: message stub first', + pattern: 'message .* first', + tests: { + 'default-suite default-state': ['default-bro'] + } + } + ]); + }); +}); diff --git a/test/lib/static/modules/utils.js b/test/lib/static/modules/utils.js index c9f1566fd..db9e5b087 100644 --- a/test/lib/static/modules/utils.js +++ b/test/lib/static/modules/utils.js @@ -1,9 +1,19 @@ 'use strict'; const utils = require('../../../../lib/static/modules/utils'); -const {FAIL, ERROR, SUCCESS} = require('../../../../lib/constants/test-statuses'); -const {NO_REF_IMAGE_ERROR} = require('../../../../lib/constants/errors').getCommonErrors(); -const {mkSuite, mkState, mkBrowserResult} = require('../../../utils'); +const { + FAIL, + ERROR, + SUCCESS +} = require('../../../../lib/constants/test-statuses'); +const { + NO_REF_IMAGE_ERROR +} = require('../../../../lib/constants/errors').getCommonErrors(); +const { + mkSuite, + mkState, + mkBrowserResult +} = require('../../../utils'); describe('static/modules/utils', () => { describe('isAcceptable', () => { @@ -34,62 +44,97 @@ describe('static/modules/utils', () => { }); }); - describe('shouldSuiteBeShownByName', () => { - const suite = mkSuite({ - suitePath: ['Some suite'], - children: [ - mkState({ - suitePath: ['Some suite', 'test one'] - }) - ] - }); + describe('shouldSuiteBeShown', () => { + describe('testNameFilter', () => { + const suite = mkSuite({ + suitePath: ['Some suite'], + children: [ + mkState({ + suitePath: ['Some suite', 'test one'] + }) + ] + }); - it('should be true if top-level title matches', () => { - assert.isTrue(utils.shouldSuiteBeShownByName(suite, 'Some suite')); - }); + it('should be true if top-level title matches', () => { + assert.isTrue(utils.shouldSuiteBeShown({suite, testNameFilter: 'Some suite'})); + }); - it('should be true if bottom-level title matches', () => { - assert.isTrue(utils.shouldSuiteBeShownByName(suite, 'test one')); - }); + it('should be true if bottom-level title matches', () => { + assert.isTrue(utils.shouldSuiteBeShown({suite, testNameFilter: 'test one'})); + }); - it('should be false if no matches found', () => { - assert.isFalse(utils.shouldSuiteBeShownByName(suite, 'test two')); - }); + it('should be false if no matches found', () => { + assert.isFalse(utils.shouldSuiteBeShown({suite, testNameFilter: 'test two'})); + }); - it('should be true if full title matches', () => { - assert.isTrue(utils.shouldSuiteBeShownByName(suite, 'Some suite test one')); - }); + it('should be true if full title matches', () => { + assert.isTrue( + utils.shouldSuiteBeShown({suite, testNameFilter: 'Some suite test one'}) + ); + }); - it('should be false if only part of only top-level title matches', () => { - assert.isFalse(utils.shouldSuiteBeShownByName(suite, 'Some suite test two')); - }); + it('should be false if only part of only top-level title matches', () => { + assert.isFalse( + utils.shouldSuiteBeShown({suite, testNameFilter: 'Some suite test two'}) + ); + }); - it('should be false if only part of only bottom-level title matches', () => { - assert.isFalse(utils.shouldSuiteBeShownByName(suite, 'Another suite test one')); + it('should be false if only part of only bottom-level title matches', () => { + assert.isFalse( + utils.shouldSuiteBeShown({suite, testNameFilter: 'Another suite test one'}) + ); + }); }); - }); - describe('shouldSuiteBeShownByBrowser', () => { - const suite = mkSuite({ - children: [ - mkState({ - browsers: [ - mkBrowserResult({name: 'first-bro'}) - ] - }) - ] - }); + describe('errorGroupTests', () => { + const suite = mkSuite({ + suitePath: ['Some suite'], + children: [ + mkState({ + suitePath: ['Some suite', 'test one'] + }) + ] + }); - it('should be true if browser id is equal', () => { - assert.isTrue(utils.shouldSuiteBeShownByBrowser(suite, ['first-bro'])); - }); + it('should be false if top-level title matches', () => { + assert.isFalse(utils.shouldSuiteBeShown({suite, errorGroupTests: {'Some suite': []}})); + }); + + it('should be false if bottom-level title matches', () => { + assert.isFalse(utils.shouldSuiteBeShown({suite, errorGroupTests: {'test one': []}})); + }); + + it('should be false if no matches found', () => { + assert.isFalse(utils.shouldSuiteBeShown({suite, errorGroupTests: {'Some suite test two': []}})); + }); - it('should be false if browser id is not a strict match', () => { - assert.isFalse(utils.shouldSuiteBeShownByBrowser(suite, ['first'])); + it('should be true if full title matches', () => { + assert.isTrue( + utils.shouldSuiteBeShown({suite, errorGroupTests: {'Some suite test one': []}}) + ); + }); }); - it('should be false if browser id is not equal', () => { - assert.isFalse(utils.shouldSuiteBeShownByBrowser(suite, ['second-bro'])); + describe('filteredBrowsers', () => { + const suite = mkSuite({ + children: [ + mkState({ + browsers: [mkBrowserResult({name: 'first-bro'})] + }) + ] + }); + + it('should be true if browser id is equal', () => { + assert.isTrue(utils.shouldSuiteBeShown({suite, filteredBrowsers: ['first-bro']})); + }); + + it('should be false if browser id is not a strict match', () => { + assert.isFalse(utils.shouldSuiteBeShown({suite, filteredBrowsers: ['first']})); + }); + + it('should be false if browser id is not equal', () => { + assert.isFalse(utils.shouldSuiteBeShown({suite, filteredBrowsers: ['second-bro']})); + }); }); });