diff --git a/lib/constants/defaults.ts b/lib/constants/defaults.ts index 6fc84fa2f..0fd4b17ac 100644 --- a/lib/constants/defaults.ts +++ b/lib/constants/defaults.ts @@ -1,11 +1,11 @@ import {DiffModes} from './diff-modes'; import {ViewMode} from './view-modes'; -import {ReporterConfig} from '../types'; +import {StoreReporterConfig} from '../types'; import {SaveFormat} from './save-formats'; export const CIRCLE_RADIUS = 150; -export const configDefaults: ReporterConfig = { +export const configDefaults: StoreReporterConfig = { baseHost: '', commandsWithShortHistory: [], customGui: {}, diff --git a/lib/db-utils/common.ts b/lib/db-utils/common.ts index 599d4a0e9..55e1ec3be 100644 --- a/lib/db-utils/common.ts +++ b/lib/db-utils/common.ts @@ -2,7 +2,7 @@ import _ from 'lodash'; import {logger} from '../common-utils'; import {DB_MAX_AVAILABLE_PAGE_SIZE, DB_SUITES_TABLE_NAME, SUITES_TABLE_COLUMNS, DB_COLUMN_INDEXES} from '../constants'; import {DbUrlsJsonData, RawSuitesRow, ReporterConfig} from '../types'; -import type {Database, Statement} from 'better-sqlite3'; +import type {Database as BetterSqlite3Database, Statement} from 'better-sqlite3'; import {ReadonlyDeep} from 'type-fest'; export const selectAllQuery = (tableName: string): string => `SELECT * FROM ${tableName}`; @@ -16,10 +16,19 @@ export const compareDatabaseRowsByTimestamp = (row1: RawSuitesRow, row2: RawSuit return (row1[DB_COLUMN_INDEXES.timestamp] as number) - (row2[DB_COLUMN_INDEXES.timestamp] as number); }; +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface Database {} + export interface DbLoadResult { url: string; status: string; data: null | unknown } +export interface DbDetails { + url: string; + status: string; + success: boolean; +} + export interface HandleDatabasesOptions { pluginConfig: ReporterConfig; loadDbJsonUrl: (dbJsonUrl: string) => Promise<{data: DbUrlsJsonData | null; status?: string}>; @@ -59,7 +68,7 @@ export const handleDatabases = async (dbJsonUrls: string[], opts: HandleDatabase ); }; -export const mergeTables = ({db, dbPaths, getExistingTables = (): string[] => []}: { db: Database, dbPaths: string[], getExistingTables?: (getTablesStatement: Statement<[]>) => string[] }): void => { +export const mergeTables = ({db, dbPaths, getExistingTables = (): string[] => []}: { db: BetterSqlite3Database, dbPaths: string[], getExistingTables?: (getTablesStatement: Statement<[]>) => string[] }): void => { db.prepare(`PRAGMA page_size = ${DB_MAX_AVAILABLE_PAGE_SIZE}`).run(); for (const dbPath of dbPaths) { diff --git a/lib/gui/server.ts b/lib/gui/server.ts index 8cdbb2c9a..28debb8f4 100644 --- a/lib/gui/server.ts +++ b/lib/gui/server.ts @@ -13,6 +13,16 @@ import {ServerArgs} from './index'; import {ServerReadyData} from './api'; import {ToolName} from '../constants'; import type {TestplaneToolAdapter} from '../adapters/tool/testplane'; +import {ToolRunnerTree} from './tool-runner'; + +interface CustomGuiError { + response: { + status: number; + data: string; + } +} + +export type GetInitResponse = (ToolRunnerTree & {customGuiError?: CustomGuiError}) | null; export const start = async (args: ServerArgs): Promise => { const {toolAdapter} = args; @@ -54,7 +64,7 @@ export const start = async (args: ServerArgs): Promise => { await (toolAdapter as TestplaneToolAdapter).initGuiHandler(); } - res.json(app.data); + res.json(app.data satisfies GetInitResponse); } catch (e: unknown) { const error = e as Error; if (!app.data) { @@ -68,7 +78,7 @@ export const start = async (args: ServerArgs): Promise => { data: `Error while trying to initialize custom GUI: ${error.message}` } } - }); + } satisfies GetInitResponse); } }); diff --git a/lib/static/components/gui.jsx b/lib/static/components/gui.jsx index c1276a511..be2857401 100644 --- a/lib/static/components/gui.jsx +++ b/lib/static/components/gui.jsx @@ -30,7 +30,7 @@ class Gui extends Component { }; componentDidMount() { - this.props.actions.initGuiReport(); + this.props.actions.thunkInitGuiReport(); this._subscribeToEvents(); } diff --git a/lib/static/modules/action-names.ts b/lib/static/modules/action-names.ts index 7cd1e4073..90f804924 100644 --- a/lib/static/modules/action-names.ts +++ b/lib/static/modules/action-names.ts @@ -61,9 +61,12 @@ export default { TOGGLE_GROUP_CHECKBOX: 'TOGGLE_GROUP_CHECKBOX', UPDATE_BOTTOM_PROGRESS_BAR: 'UPDATE_BOTTOM_PROGRESS_BAR', GROUP_TESTS_BY_KEY: 'GROUP_TESTS_BY_KEY', + GROUP_TESTS_SET_CURRENT_EXPRESSION: 'GROUP_TESTS_SET_CURRENT_EXPRESSION', TOGGLE_BROWSER_CHECKBOX: 'TOGGLE_BROWSER_CHECKBOX', SUITES_PAGE_SET_CURRENT_SUITE: 'SUITES_PAGE_SET_CURRENT_SUITE', SUITES_PAGE_SET_SECTION_EXPANDED: 'SUITES_PAGE_SET_SECTION_EXPANDED', + SUITES_PAGE_SET_TREE_NODE_EXPANDED: 'SUITES_PAGE_SET_TREE_NODE_EXPANDED', + SUITES_PAGE_SET_ALL_TREE_NODES: 'SUITES_PAGE_SET_ALL_TREE_NODES', SUITES_PAGE_SET_STEPS_EXPANDED: 'SUITES_PAGE_SET_STEPS_EXPANDED', VISUAL_CHECKS_PAGE_SET_CURRENT_NAMED_IMAGE: 'VISUAL_CHECKS_PAGE_SET_CURRENT_NAMED_IMAGE', UPDATE_LOADING_PROGRESS: 'UPDATE_LOADING_PROGRESS', diff --git a/lib/static/modules/actions/group-tests.ts b/lib/static/modules/actions/group-tests.ts new file mode 100644 index 000000000..6a4443706 --- /dev/null +++ b/lib/static/modules/actions/group-tests.ts @@ -0,0 +1,11 @@ +import actionNames from '@/static/modules/action-names'; +import {Action} from '@/static/modules/actions/types'; + +type SetCurrentGroupByExpressionAction = Action; + +export const setCurrentGroupByExpression = (payload: SetCurrentGroupByExpressionAction['payload']): SetCurrentGroupByExpressionAction => + ({type: actionNames.GROUP_TESTS_SET_CURRENT_EXPRESSION, payload}); + +export type GroupTestsAction = SetCurrentGroupByExpressionAction; diff --git a/lib/static/modules/actions/index.js b/lib/static/modules/actions/index.js index c663a3896..a3a05c9f0 100644 --- a/lib/static/modules/actions/index.js +++ b/lib/static/modules/actions/index.js @@ -1,17 +1,16 @@ import axios from 'axios'; import {isEmpty, difference} from 'lodash'; import {notify, dismissNotification as dismissNotify, POSITIONS} from 'reapop'; -import {StaticTestsTreeBuilder} from '../../../tests-tree-builder/static'; import actionNames from '../action-names'; import {types as modalTypes} from '../../components/modals'; import {QUEUED} from '../../../constants/test-statuses'; import {DiffModes} from '../../../constants/diff-modes'; import {getHttpErrorMessage} from '../utils'; -import {fetchDataFromDatabases, mergeDatabases, connectToDatabase, getMainDatabaseUrl, getSuitesTableRows} from '../../../db-utils/client'; +import {connectToDatabase, getMainDatabaseUrl} from '../../../db-utils/client'; import {setFilteredBrowsers} from '../query-params'; -import * as plugins from '../plugins'; -import performanceMarks from '../../../constants/performance-marks'; +export * from './lifecycle'; +export * from './group-tests'; export * from './static-accepter'; export * from './suites-page'; export * from './suites'; @@ -35,97 +34,6 @@ export const createNotificationError = (id, error, props = {dismissAfter: 0}) => export const dismissNotification = dismissNotify; -export const initGuiReport = () => { - return async (dispatch) => { - performance?.mark?.(performanceMarks.JS_EXEC); - try { - const appState = await axios.get('/init'); - - const mainDatabaseUrl = getMainDatabaseUrl(); - const db = await connectToDatabase(mainDatabaseUrl.href); - - performance?.mark?.(performanceMarks.DBS_LOADED); - - await plugins.loadAll(appState.data.config); - - performance?.mark?.(performanceMarks.PLUGINS_LOADED); - - dispatch({ - type: actionNames.INIT_GUI_REPORT, - payload: {...appState.data, db} - }); - - const {customGuiError} = appState.data; - - if (customGuiError) { - dispatch(createNotificationError('initGuiReport', {...customGuiError})); - delete appState.data.customGuiError; - } - } catch (e) { - dispatch(createNotificationError('initGuiReport', e)); - } - }; -}; - -export const initStaticReport = () => { - return async dispatch => { - performance?.mark?.(performanceMarks.JS_EXEC); - const dataFromStaticFile = window.data || {}; - let fetchDbDetails = []; - let db = null; - - try { - const mainDatabaseUrls = new URL('databaseUrls.json', window.location.href); - const fetchDbResponses = await fetchDataFromDatabases([mainDatabaseUrls.href], (dbUrl, progress) => { - dispatch({ - type: actionNames.UPDATE_LOADING_PROGRESS, - payload: {[dbUrl]: progress} - }); - }); - - performance?.mark?.(performanceMarks.DBS_LOADED); - - plugins.preloadAll(dataFromStaticFile.config); - - fetchDbDetails = fetchDbResponses.map(({url, status, data}) => ({url, status, success: !!data})); - - const dataForDbs = fetchDbResponses.map(({data}) => data).filter(data => data); - - db = await mergeDatabases(dataForDbs); - - performance?.mark?.(performanceMarks.DBS_MERGED); - } catch (e) { - dispatch(createNotificationError('initStaticReport', e)); - } - - await plugins.loadAll(dataFromStaticFile.config); - - performance?.mark?.(performanceMarks.PLUGINS_LOADED); - const testsTreeBuilder = StaticTestsTreeBuilder.create(); - - if (!db || isEmpty(fetchDbDetails)) { - return dispatch({ - type: actionNames.INIT_STATIC_REPORT, - payload: {...dataFromStaticFile, db, fetchDbDetails, tree: testsTreeBuilder.build([]).tree, stats: {}, skips: [], browsers: []} - }); - } - - const suitesRows = getSuitesTableRows(db); - - performance?.mark?.(performanceMarks.DB_EXTRACTED_ROWS); - - const {tree, stats, skips, browsers} = testsTreeBuilder.build(suitesRows); - - dispatch({ - type: actionNames.INIT_STATIC_REPORT, - payload: {...dataFromStaticFile, db, fetchDbDetails, tree, stats, skips, browsers} - }); - }; -}; - -export const finGuiReport = () => ({type: actionNames.FIN_GUI_REPORT}); -export const finStaticReport = () => ({type: actionNames.FIN_STATIC_REPORT}); - const runTests = ({tests = [], action = {}} = {}) => { return async (dispatch) => { try { diff --git a/lib/static/modules/actions/lifecycle.ts b/lib/static/modules/actions/lifecycle.ts new file mode 100644 index 000000000..6df9db467 --- /dev/null +++ b/lib/static/modules/actions/lifecycle.ts @@ -0,0 +1,134 @@ +import axios from 'axios'; +import {isEmpty} from 'lodash'; + +import performanceMarks from '@/constants/performance-marks'; +import { + connectToDatabase, Database, DbDetails, DbLoadResult, + fetchDataFromDatabases, + getMainDatabaseUrl, + getSuitesTableRows, + mergeDatabases +} from '@/db-utils/client'; +import * as plugins from '@/static/modules/plugins'; +import actionNames from '@/static/modules/action-names'; +import {FinalStats, SkipItem, StaticTestsTreeBuilder} from '@/tests-tree-builder/static'; +import {createNotificationError} from '@/static/modules/actions/index'; +import {Action, AppThunk} from '@/static/modules/actions/types'; +import {DataForStaticFile} from '@/server-utils'; +import {GetInitResponse} from '@/gui/server'; +import {Tree} from '@/tests-tree-builder/base'; +import {BrowserItem} from '@/types'; + +export type InitGuiReportAction = Action; +const initGuiReport = (payload: InitGuiReportAction['payload']): InitGuiReportAction => + ({type: actionNames.INIT_GUI_REPORT, payload}); + +export const thunkInitGuiReport = (): AppThunk => { + return async (dispatch) => { + performance?.mark?.(performanceMarks.JS_EXEC); + try { + const appState = await axios.get('/init'); + + if (!appState.data) { + throw new Error('Could not load app data. The report might be broken. Please check your project settings or try deleting results folder and relaunching UI server.'); + } + + const mainDatabaseUrl = getMainDatabaseUrl(); + const db = await connectToDatabase(mainDatabaseUrl.href); + + performance?.mark?.(performanceMarks.DBS_LOADED); + + await plugins.loadAll(appState.data.config); + + performance?.mark?.(performanceMarks.PLUGINS_LOADED); + + dispatch(initGuiReport({...appState.data, db})); + + if (appState.data.customGuiError) { + const {customGuiError} = appState.data; + + dispatch(createNotificationError('initGuiReport', {...customGuiError})); + delete appState.data.customGuiError; + } + } catch (e) { + dispatch(createNotificationError('initGuiReport', e)); + } + }; +}; + +export type InitStaticReportAction = Action & { + db: Database; + fetchDbDetails: DbDetails[], + tree: Tree; + stats: FinalStats | null; + skips: SkipItem[]; + browsers: BrowserItem[]; +}>; +const initStaticReport = (payload: InitStaticReportAction['payload']): InitStaticReportAction => + ({type: actionNames.INIT_STATIC_REPORT, payload}); + +export const thunkInitStaticReport = (): AppThunk => { + return async dispatch => { + performance?.mark?.(performanceMarks.JS_EXEC); + const dataFromStaticFile = (window as {data?: DataForStaticFile}).data || {} as Partial; + + let fetchDbDetails: DbDetails[] = []; + let db = null; + + try { + const mainDatabaseUrls = new URL('databaseUrls.json', window.location.href); + const fetchDbResponses = await fetchDataFromDatabases([mainDatabaseUrls.href], (dbUrl: string, progress: number) => { + dispatch({ + type: actionNames.UPDATE_LOADING_PROGRESS, + payload: {[dbUrl]: progress} + }); + }) as DbLoadResult[]; + + performance?.mark?.(performanceMarks.DBS_LOADED); + + plugins.preloadAll(dataFromStaticFile.config); + + fetchDbDetails = fetchDbResponses.map(({url, status, data}) => ({url, status, success: !!data})); + + const dataForDbs = fetchDbResponses.map(({data}) => data).filter(data => data); + + db = await mergeDatabases(dataForDbs); + + performance?.mark?.(performanceMarks.DBS_MERGED); + } catch (e) { + dispatch(createNotificationError('thunkInitStaticReport', e)); + } + + await plugins.loadAll(dataFromStaticFile.config); + + performance?.mark?.(performanceMarks.PLUGINS_LOADED); + const testsTreeBuilder = StaticTestsTreeBuilder.create(); + + if (!db || isEmpty(fetchDbDetails)) { + dispatch(initStaticReport({...dataFromStaticFile, db, fetchDbDetails, tree: testsTreeBuilder.build([]).tree, stats: null, skips: [], browsers: []})); + + return; + } + + const suitesRows = getSuitesTableRows(db); + + performance?.mark?.(performanceMarks.DB_EXTRACTED_ROWS); + + const {tree, stats, skips, browsers} = testsTreeBuilder.build(suitesRows); + + dispatch(initStaticReport({...dataFromStaticFile, db, fetchDbDetails, tree, stats, skips, browsers})); + }; +}; + +export type FinGuiReportAction = Action; +export const finGuiReport = (): FinGuiReportAction => ({type: actionNames.FIN_GUI_REPORT}); + +export type FinStaticReportAction = Action; +export const finStaticReport = (): FinStaticReportAction => ({type: actionNames.FIN_STATIC_REPORT}); + +export type LifecycleAction = + | InitGuiReportAction + | InitStaticReportAction + | FinGuiReportAction + | FinStaticReportAction; diff --git a/lib/static/modules/actions/suites-page.ts b/lib/static/modules/actions/suites-page.ts index 4c7cd52fa..75344de26 100644 --- a/lib/static/modules/actions/suites-page.ts +++ b/lib/static/modules/actions/suites-page.ts @@ -1,19 +1,32 @@ import actionNames from '@/static/modules/action-names'; import {Action} from '@/static/modules/actions/types'; -export type SuitesPageSetCurrentSuiteAction = Action>; +export const setCurrentTreeNode = (payload: SuitesPageSetCurrentTreeNodeAction['payload']): SuitesPageSetCurrentTreeNodeAction => { + return {type: actionNames.SUITES_PAGE_SET_CURRENT_SUITE, payload}; +}; + +type SetTreeNodeExpandedStateAction = Action; +export const setTreeNodeExpandedState = (payload: SetTreeNodeExpandedStateAction['payload']): SetTreeNodeExpandedStateAction => + ({type: actionNames.SUITES_PAGE_SET_TREE_NODE_EXPANDED, payload}); -export const suitesPageSetCurrentSuite = (suiteId: string): SuitesPageSetCurrentSuiteAction => { - return {type: actionNames.SUITES_PAGE_SET_CURRENT_SUITE, payload: {suiteId}}; -}; +type SetAllTreeNodesStateAction = Action; +export const setAllTreeNodesState = (payload: SetAllTreeNodesStateAction['payload']): SetAllTreeNodesStateAction => + ({type: actionNames.SUITES_PAGE_SET_ALL_TREE_NODES, payload}); type SetSectionExpandedStateAction = Action; - export const setSectionExpandedState = (payload: SetSectionExpandedStateAction['payload']): SetSectionExpandedStateAction => ({type: actionNames.SUITES_PAGE_SET_SECTION_EXPANDED, payload}); @@ -21,11 +34,12 @@ type SetStepsExpandedStateAction = Action; }>; - export const setStepsExpandedState = (payload: SetStepsExpandedStateAction['payload']): SetStepsExpandedStateAction => ({type: actionNames.SUITES_PAGE_SET_STEPS_EXPANDED, payload}); export type SuitesPageAction = - | SuitesPageSetCurrentSuiteAction + | SetTreeNodeExpandedStateAction + | SetAllTreeNodesStateAction + | SuitesPageSetCurrentTreeNodeAction | SetSectionExpandedStateAction | SetStepsExpandedStateAction; diff --git a/lib/static/modules/actions/suites.ts b/lib/static/modules/actions/suites.ts index c919a9e49..7057864a9 100644 --- a/lib/static/modules/actions/suites.ts +++ b/lib/static/modules/actions/suites.ts @@ -4,6 +4,9 @@ import {Action} from '@/static/modules/actions/types'; interface ChangeTestRetryPayload { browserId: string; retryIndex: number; + suitesPage?: { + treeNodeId: string; + } } export const changeTestRetry = (result: ChangeTestRetryPayload): Action => diff --git a/lib/static/modules/actions/types.ts b/lib/static/modules/actions/types.ts index 3045abad9..9b6285288 100644 --- a/lib/static/modules/actions/types.ts +++ b/lib/static/modules/actions/types.ts @@ -2,6 +2,11 @@ import type actionNames from '../action-names'; import type {Action as ReduxAction} from 'redux'; import type defaultState from '../default-state'; import type {Tree} from '../../../tests-tree-builder/base'; +import {GroupTestsAction} from '@/static/modules/actions/group-tests'; +import {ThunkAction} from 'redux-thunk'; +import {State} from '@/static/new-ui/types/store'; +import {LifecycleAction} from '@/static/modules/actions/lifecycle'; +import {SuitesPageAction} from '@/static/modules/actions/suites-page'; export type {Dispatch} from 'redux'; @@ -11,3 +16,10 @@ export type Action< Type extends typeof actionNames[keyof typeof actionNames], Payload = void > = Payload extends void ? ReduxAction : ReduxAction & {payload: Payload}; + +export type AppThunk = ThunkAction; + +export type SomeAction = + | GroupTestsAction + | LifecycleAction + | SuitesPageAction; diff --git a/lib/static/modules/default-state.ts b/lib/static/modules/default-state.ts index a10e83cd7..7a8ac68a6 100644 --- a/lib/static/modules/default-state.ts +++ b/lib/static/modules/default-state.ts @@ -25,6 +25,10 @@ export default Object.assign({config: configDefaults}, { } }, tree: { + groups: { + byId: {}, + allRootIds: [] + }, suites: { byId: {}, allIds: [], @@ -96,7 +100,9 @@ export default Object.assign({config: configDefaults}, { isInitialized: false, availableFeatures: [], suitesPage: { - currentBrowserId: null + currentBrowserId: null, + currentTreeNodeId: null, + currentGroupId: null }, visualChecksPage: { currentNamedImageId: null @@ -109,12 +115,19 @@ export default Object.assign({config: configDefaults}, { }, staticImageAccepterModal: { commitMessage: 'chore: update screenshot references' + }, + groupTestsData: { + availableSections: [], + availableExpressions: [], + currentExpressionIds: [] } }, ui: { suitesPage: { + retryIndexByTreeNodeId: {}, expandedSectionsById: {}, - expandedStepsByResultId: {} + expandedStepsByResultId: {}, + expandedTreeNodesById: {} }, staticImageAccepterToolbar: { offset: {x: 0, y: 0} diff --git a/lib/static/modules/reducers/index.js b/lib/static/modules/reducers/index.js index 3dc21a5cf..b83417062 100644 --- a/lib/static/modules/reducers/index.js +++ b/lib/static/modules/reducers/index.js @@ -29,6 +29,7 @@ import staticImageAccepter from './static-image-accepter'; import suitesPage from './suites-page'; import visualChecksPage from './visual-checks-page'; import isInitialized from './is-initialized'; +import newUiGroupedTests from './new-ui-grouped-tests'; // The order of specifying reducers is important. // At the top specify reducers that does not depend on other state fields. @@ -62,5 +63,6 @@ export default reduceReducers( progressBar, suitesPage, visualChecksPage, - isInitialized + isInitialized, + newUiGroupedTests ); diff --git a/lib/static/modules/reducers/new-ui-grouped-tests/index.ts b/lib/static/modules/reducers/new-ui-grouped-tests/index.ts new file mode 100644 index 000000000..749505273 --- /dev/null +++ b/lib/static/modules/reducers/new-ui-grouped-tests/index.ts @@ -0,0 +1,81 @@ +import actionNames from '../../action-names'; +import {applyStateUpdate} from '../../utils/state'; +import { + GroupByErrorExpression, + GroupByExpression, + GroupByMetaExpression, GroupBySection, + GroupByType, + State +} from '@/static/new-ui/types/store'; +import {SomeAction} from '@/static/modules/actions/types'; +import {groupTests} from './utils'; + +export default (state: State, action: SomeAction): State => { + switch (action.type) { + case actionNames.INIT_GUI_REPORT: + case actionNames.INIT_STATIC_REPORT: { + const availableSections: GroupBySection[] = [{ + id: 'meta', + label: 'meta' + }, { + id: 'error', + label: 'error' + }]; + + const availableExpressions: GroupByExpression[] = []; + + const availableMetaKeys = new Set(); + for (const result of Object.values(state.tree.results.byId)) { + Object.keys(result.metaInfo).forEach(key => availableMetaKeys.add(key)); + } + const availableMetaExpressions = Array.from(availableMetaKeys.values()).map((metaKey): GroupByMetaExpression => ({ + id: metaKey, + type: GroupByType.Meta, + sectionId: 'meta', + key: metaKey + })); + availableExpressions.push(...availableMetaExpressions); + + availableExpressions.push({ + id: 'error-message', + type: GroupByType.Error, + sectionId: 'error' + } satisfies GroupByErrorExpression); + + return applyStateUpdate(state, { + app: { + groupTestsData: { + availableExpressions, + currentExpressionIds: [], + availableSections + } + } + }); + } + + case actionNames.GROUP_TESTS_SET_CURRENT_EXPRESSION: { + const newExpressionIds = action.payload.expressionIds; + + const groupByExpressions = newExpressionIds + .map(id => state.app.groupTestsData.availableExpressions.find(expr => expr.id === id) as GroupByExpression); + const groupsById = groupTests(groupByExpressions, state.tree.results.byId, state.tree.images.byId, state.config.errorPatterns); + + return Object.assign({}, state, { + tree: Object.assign({}, state.tree, { + groups: { + byId: groupsById, + allRootIds: Object.keys(groupsById) + } + }), + app: Object.assign({}, state.app, { + groupTestsData: Object.assign({}, state.app.groupTestsData, { + currentExpressionIds: newExpressionIds + }) + }) + }); + } + + default: + return state; + } +}; diff --git a/lib/static/modules/reducers/new-ui-grouped-tests/utils.ts b/lib/static/modules/reducers/new-ui-grouped-tests/utils.ts new file mode 100644 index 000000000..d38bc407c --- /dev/null +++ b/lib/static/modules/reducers/new-ui-grouped-tests/utils.ts @@ -0,0 +1,155 @@ +import { + GroupByExpression, + GroupByMetaExpression, + GroupByType, + GroupEntity, ImageEntity, + ResultEntity, State +} from '@/static/new-ui/types/store'; +import {get, isNull, isObject, isString, isUndefined, toString} from 'lodash'; +import {isAssertViewError} from '@/common-utils'; +import stripAnsi from 'strip-ansi'; + +const stringify = (value: unknown): string => { + if (isString(value)) { + return value; + } + + if (isObject(value)) { + return JSON.stringify(value); + } + + if (isNull(value)) { + return 'null'; + } + + if (isUndefined(value)) { + return 'undefined'; + } + + return toString(value); +}; + +const extractErrors = (result: ResultEntity, images: ImageEntity[]): string[] => { + const IMAGE_COMPARISON_FAILED_MESSAGE = 'image comparison failed'; + + const errors = new Set(); + + images.forEach((image) => { + const imageErrorMessage = get(image, 'error.message'); + if (imageErrorMessage) { + errors.add(imageErrorMessage); + } + + if (get(image, 'diffImg')) { + errors.add(IMAGE_COMPARISON_FAILED_MESSAGE); + } + }); + + const {error} = result; + + if (errors.size > 0 && isAssertViewError(error)) { + return [...errors]; + } + + const errorMessage = get(error, 'message'); + if (errorMessage) { + errors.add(errorMessage); + } + + return [...errors]; +}; + +const groupTestsByMeta = (expr: GroupByMetaExpression, resultsById: Record): Record => { + const DEFAULT_GROUP = `__${GroupByType.Meta}__DEFAULT_GROUP`; + const results = Object.values(resultsById); + const groups: Record = {}; + let id = 1; + + for (const result of results) { + let groupingKey: string; + if (!result.metaInfo || !result.metaInfo[expr.key]) { + groupingKey = DEFAULT_GROUP; + } else { + groupingKey = `${GroupByType.Meta}__${expr.key}__${stringify(result.metaInfo[expr.key])}`; + } + + if (!groups[groupingKey]) { + groups[groupingKey] = { + id: id.toString(), + key: expr.key, + label: stringify(result.metaInfo[expr.key]), + resultIds: [], + browserIds: [] + }; + id++; + } + + groups[groupingKey].resultIds.push(result.id); + if (!groups[groupingKey].browserIds.includes(result.parentId)) { + groups[groupingKey].browserIds.push(result.parentId); + } + } + + return groups; +}; + +const groupTestsByError = (resultsById: Record, imagesById: Record, errorPatterns: State['config']['errorPatterns']): Record => { + const groups: Record = {}; + const results = Object.values(resultsById); + let id = 1; + + for (const result of results) { + const images = result.imageIds.map((imageId) => imagesById[imageId]); + const errors = extractErrors(result, images); + + for (const errorText of errors) { + const pattern = errorPatterns.find(p => p.regexp.test(errorText)); + + let groupingKey: string; + let groupLabel: string; + if (pattern) { + groupLabel = pattern.name; + groupingKey = `${GroupByType.Error}__${pattern.name}`; + } else { + groupLabel = errorText; + groupingKey = `${GroupByType.Error}__${errorText}`; + } + + if (!groups[groupingKey]) { + groups[groupingKey] = { + id: id.toString(), + key: 'error', + label: stripAnsi(groupLabel), + resultIds: [], + browserIds: [] + }; + id++; + } + + groups[groupingKey].resultIds.push(result.id); + if (!groups[groupingKey].browserIds.includes(result.parentId)) { + groups[groupingKey].browserIds.push(result.parentId); + } + } + } + + return groups; +}; + +export const groupTests = (groupByExpressions: GroupByExpression[], resultsById: Record, imagesById: Record, errorPatterns: State['config']['errorPatterns']): Record => { + const currentGroupByExpression = groupByExpressions[0]; + + if (!currentGroupByExpression) { + return {}; + } + + if (currentGroupByExpression.type === GroupByType.Meta) { + return groupTestsByMeta(currentGroupByExpression, resultsById); + } + + if (currentGroupByExpression.type === GroupByType.Error) { + return groupTestsByError(resultsById, imagesById, errorPatterns); + } + + return {}; +}; diff --git a/lib/static/modules/reducers/suites-page.ts b/lib/static/modules/reducers/suites-page.ts index 910e5d7a7..a935c784d 100644 --- a/lib/static/modules/reducers/suites-page.ts +++ b/lib/static/modules/reducers/suites-page.ts @@ -1,12 +1,96 @@ import {State} from '@/static/new-ui/types/store'; -import {SuitesPageAction} from '@/static/modules/actions/suites-page'; import actionNames from '@/static/modules/action-names'; import {applyStateUpdate} from '@/static/modules/utils/state'; +import {SomeAction} from '@/static/modules/actions/types'; +import {getTreeViewItems} from '@/static/new-ui/features/suites/components/SuitesTreeView/selectors'; +import {findTreeNodeId, getGroupId} from '@/static/new-ui/features/suites/utils'; -export default (state: State, action: SuitesPageAction): State => { +export default (state: State, action: SomeAction): State => { switch (action.type) { - case actionNames.SUITES_PAGE_SET_CURRENT_SUITE: - return applyStateUpdate(state, {app: {suitesPage: {currentBrowserId: action.payload.suiteId}}}) as State; + case actionNames.INIT_STATIC_REPORT: + case actionNames.INIT_GUI_REPORT: + case actionNames.GROUP_TESTS_SET_CURRENT_EXPRESSION: { + const {allTreeNodeIds} = getTreeViewItems(state); + + const expandedTreeNodesById: Record = {}; + + for (const nodeId of allTreeNodeIds) { + expandedTreeNodesById[nodeId] = true; + } + + let currentGroupId: string | null | undefined = null; + let currentTreeNodeId: string | null | undefined; + if (action.type === actionNames.GROUP_TESTS_SET_CURRENT_EXPRESSION) { + const {currentBrowserId} = state.app.suitesPage; + if (currentBrowserId) { + const {tree} = getTreeViewItems(state); + const browserTreeViewData = findTreeNodeId(tree, currentBrowserId); + currentTreeNodeId = browserTreeViewData?.id; + if (browserTreeViewData) { + currentGroupId = getGroupId(browserTreeViewData); + } + } + } + + return applyStateUpdate(state, { + app: { + suitesPage: { + currentGroupId, + currentTreeNodeId + } + }, + ui: { + suitesPage: { + expandedTreeNodesById + } + } + }); + } + case actionNames.SUITES_PAGE_SET_CURRENT_SUITE: { + const diff: Partial = {}; + + if (action.payload.treeNodeId) { + diff.currentTreeNodeId = action.payload.treeNodeId; + } + if (action.payload.browserId) { + diff.currentBrowserId = action.payload.browserId; + } + if (action.payload.groupId) { + diff.currentGroupId = action.payload.groupId; + } + + return applyStateUpdate(state, { + app: { + suitesPage: diff + } + }) as State; + } + case actionNames.SUITES_PAGE_SET_TREE_NODE_EXPANDED: { + return applyStateUpdate(state, { + ui: { + suitesPage: { + expandedTreeNodesById: { + [action.payload.nodeId]: action.payload.isExpanded + } + } + } + }) as State; + } + case actionNames.SUITES_PAGE_SET_ALL_TREE_NODES: { + const newExpandedTreeNodesById: Record = {}; + + for (const id in state.ui.suitesPage.expandedTreeNodesById) { + newExpandedTreeNodesById[id] = action.payload.isExpanded; + } + + return applyStateUpdate(state, { + ui: { + suitesPage: { + expandedTreeNodesById: newExpandedTreeNodesById + } + } + }) as State; + } case actionNames.SUITES_PAGE_SET_SECTION_EXPANDED: { return applyStateUpdate(state, { ui: { diff --git a/lib/static/modules/reducers/tree/index.js b/lib/static/modules/reducers/tree/index.js index 27eb67be4..7262d031d 100644 --- a/lib/static/modules/reducers/tree/index.js +++ b/lib/static/modules/reducers/tree/index.js @@ -38,6 +38,11 @@ export default ((state, action) => { tree.results.stateById = {}; tree.images.stateById = {}; + tree.groups = { + byId: {}, + allRootIds: [] + }; + updateAllSuitesStatus(tree, filteredBrowsers); initNodesStates({tree, view: state.view}); resolveUpdatedStatuses(tree.results.byId, tree.images.byId, tree.suites.byId); @@ -96,6 +101,17 @@ export default ((state, action) => { browserState.lastMatchedRetryIndex = null; } + const treeNodeId = action.payload.suitesPage?.treeNodeId; + if (treeNodeId) { + diff.ui = { + suitesPage: { + retryIndexByTreeNodeId: { + [treeNodeId]: retryIndex + } + } + }; + } + changeBrowserState(state.tree, browserId, browserState, diff.tree); return applyStateUpdate(state, diff); diff --git a/lib/static/modules/utils/index.js b/lib/static/modules/utils/index.js index 056cf977c..b68b3997a 100644 --- a/lib/static/modules/utils/index.js +++ b/lib/static/modules/utils/index.js @@ -14,13 +14,12 @@ import { isInvalidRefImageError } from '../../../common-utils'; import {ViewMode, SECTIONS, RESULT_KEYS, KEY_DELIMITER} from '../../../constants'; -import default_ from './state'; -const {applyStateUpdate, ensureDiffProperty, getUpdatedProperty} = default_; - -const AVAILABLE_GROUP_SECTIONS = Object.values(SECTIONS); +import {applyStateUpdate, ensureDiffProperty, getUpdatedProperty} from './state'; export {applyStateUpdate, ensureDiffProperty, getUpdatedProperty}; +const AVAILABLE_GROUP_SECTIONS = Object.values(SECTIONS); + export function isSuiteIdle(suite) { return isIdleStatus(suite.status); } @@ -177,25 +176,3 @@ export function getBlob(url) { xhr.send(); }); } - -export default { - applyStateUpdate, - ensureDiffProperty, - getUpdatedProperty, - isSuiteIdle, - isSuiteSuccessful, - isNodeFailed, - isNodeStaged, - isNodeSuccessful, - isAcceptable, - isScreenRevertable, - dateToLocaleString, - getHttpErrorMessage, - isTestNameMatchFilters, - isBrowserMatchViewMode, - shouldShowBrowser, - iterateSuites, - parseKeyToGroupTestsBy, - preloadImage, - getBlob -}; diff --git a/lib/static/modules/utils/state.js b/lib/static/modules/utils/state.ts similarity index 52% rename from lib/static/modules/utils/state.js rename to lib/static/modules/utils/state.ts index 3deb89b8a..4a4e16d4d 100644 --- a/lib/static/modules/utils/state.js +++ b/lib/static/modules/utils/state.ts @@ -1,17 +1,14 @@ import {get, isPlainObject, isUndefined} from 'lodash'; +import {State} from '@/static/new-ui/types/store'; +import {DeepPartial} from 'redux'; -/** - * Create new state from old state and diff object - * @param {Object} state - * @param {Object} diff - * @returns {Object} new state, created by overlaying diff to state - */ -export function applyStateUpdate(state, diff) { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function copyAndMerge(state: any, diff: any): unknown { const result = {...state}; for (const key in diff) { if (isPlainObject(diff[key]) && isPlainObject(state[key])) { - result[key] = applyStateUpdate(state[key], diff[key]); + result[key] = copyAndMerge(state[key], diff[key]); } else if (diff[key] !== undefined) { result[key] = diff[key]; } else { @@ -22,39 +19,32 @@ export function applyStateUpdate(state, diff) { return result; } +/** + * Create new state from old state and diff object + */ +export const applyStateUpdate = (state: State, diff: DeepPartial): State => copyAndMerge(state, diff) as State; + /** * Ensure diff has an object by given path * Usually it is being used to pass nested diff property to a helper function - * @param {Object} diff - * @param {Array} path */ -export function ensureDiffProperty(diff, path) { - let state = diff; +export function ensureDiffProperty(diff: object, path: string[]): void { + let state = diff as Record; for (let i = 0; i < path.length; i++) { const property = path[i]; state[property] = state[property] || {}; - state = state[property]; + state = state[property] as Record; } } /** - * - * @param {Object} state - * @param {Object} diff - * @param {string|Array} path - in _.get style - * @returns result of _.get(diff, path) if exists, _.get(state, path) else + * @returns Result of _.get(diff, path) if it exists, _.get(state, path) otherwise */ -export function getUpdatedProperty(state, diff, path) { +export function getUpdatedProperty(state: State, diff: State, path: string[]): unknown { const diffValue = get(diff, path); return isUndefined(diffValue) ? get(state, path) : diffValue; } - -export default { - applyStateUpdate, - ensureDiffProperty, - getUpdatedProperty -}; diff --git a/lib/static/new-ui/app/gui.tsx b/lib/static/new-ui/app/gui.tsx index 7bf236f85..d57dccefb 100644 --- a/lib/static/new-ui/app/gui.tsx +++ b/lib/static/new-ui/app/gui.tsx @@ -4,7 +4,7 @@ import {createRoot} from 'react-dom/client'; import {ClientEvents} from '@/gui/constants'; import {App} from './App'; import store from '../../modules/store'; -import {finGuiReport, initGuiReport, suiteBegin, testBegin, testResult, testsEnd} from '../../modules/actions'; +import {finGuiReport, thunkInitGuiReport, suiteBegin, testBegin, testResult, testsEnd} from '../../modules/actions'; const rootEl = document.getElementById('app') as HTMLDivElement; const root = createRoot(rootEl); @@ -36,7 +36,7 @@ function Gui(): ReactNode { }; useEffect(() => { - store.dispatch(initGuiReport()); + store.dispatch(thunkInitGuiReport()); subscribeToEvents(); return () => { diff --git a/lib/static/new-ui/app/report.tsx b/lib/static/new-ui/app/report.tsx index 6881b9605..30ddc8a37 100644 --- a/lib/static/new-ui/app/report.tsx +++ b/lib/static/new-ui/app/report.tsx @@ -2,14 +2,14 @@ import React, {ReactNode, useEffect} from 'react'; import {createRoot} from 'react-dom/client'; import {App} from './App'; import store from '../../modules/store'; -import {initStaticReport, finStaticReport} from '../../modules/actions'; +import {thunkInitStaticReport, finStaticReport} from '../../modules/actions'; const rootEl = document.getElementById('app') as HTMLDivElement; const root = createRoot(rootEl); function Report(): ReactNode { useEffect(() => { - store.dispatch(initStaticReport()); + store.dispatch(thunkInitStaticReport()); return () => { store.dispatch(finStaticReport()); diff --git a/lib/static/new-ui/components/AttemptPicker/index.tsx b/lib/static/new-ui/components/AttemptPicker/index.tsx index 51e0cf84b..96aeb10c1 100644 --- a/lib/static/new-ui/components/AttemptPicker/index.tsx +++ b/lib/static/new-ui/components/AttemptPicker/index.tsx @@ -8,7 +8,7 @@ import classNames from 'classnames'; import {Button, Icon, Spin} from '@gravity-ui/uikit'; import {RunTestsFeature} from '@/constants'; import {retryTest} from '@/static/modules/actions'; -import {getCurrentBrowser} from '@/static/new-ui/features/suites/selectors'; +import {getCurrentBrowser, getCurrentResultId} from '@/static/new-ui/features/suites/selectors'; interface AttemptPickerProps { onChange?: (browserId: string, resultId: string, attemptIndex: number) => unknown; @@ -65,17 +65,15 @@ function AttemptPickerInternal(props: AttemptPickerInternalProps): ReactNode { export const AttemptPicker = connect(state => { let resultIds: string[] = []; - let currentResultId = ''; const browserId = state.app.suitesPage.currentBrowserId; if (browserId && state.tree.browsers.byId[browserId]) { resultIds = state.tree.browsers.byId[browserId].resultIds; - currentResultId = resultIds[state.tree.browsers.stateById[browserId].retryIndex]; } return { browserId, resultIds, - currentResultId + currentResultId: getCurrentResultId(state) ?? '' }; })(AttemptPickerInternal); diff --git a/lib/static/new-ui/components/AttemptPickerItem/index.tsx b/lib/static/new-ui/components/AttemptPickerItem/index.tsx index 3de45c473..a65e9d26e 100644 --- a/lib/static/new-ui/components/AttemptPickerItem/index.tsx +++ b/lib/static/new-ui/components/AttemptPickerItem/index.tsx @@ -2,7 +2,6 @@ import {Button, ButtonProps} from '@gravity-ui/uikit'; import React, {ReactNode} from 'react'; import classNames from 'classnames'; import {connect} from 'react-redux'; -import {get} from 'lodash'; import {hasUnrelatedToScreenshotsErrors, isFailStatus} from '@/common-utils'; import {TestStatus} from '@/constants'; @@ -65,34 +64,35 @@ export interface AttemptPickerItemProps { interface AttemptPickerItemInternalProps extends AttemptPickerItemProps{ status: TestStatus; attempt: number; - keyToGroupTestsBy: string; + currentGroupId: string | null; matchedSelectedGroup: boolean; } function AttemptPickerItemInternal(props: AttemptPickerItemInternalProps): ReactNode { - const {status, attempt, isActive, onClick, title, keyToGroupTestsBy, matchedSelectedGroup} = props; + const {status, attempt, isActive, onClick, title, matchedSelectedGroup} = props; const buttonStyle = getButtonStyleByStatus(status); const className = classNames( styles.attemptPickerItem, {[styles['attempt-picker-item--active']]: isActive}, {[styles[`attempt-picker-item--${status}`]]: status}, - {[styles['attempt-picker-item--non-matched']]: keyToGroupTestsBy && !matchedSelectedGroup} + {[styles['attempt-picker-item--non-matched']]: props.currentGroupId && !matchedSelectedGroup} ); return ; } export const AttemptPickerItem = connect( - ({tree, view: {keyToGroupTestsBy}}: State, {resultId}: AttemptPickerItemProps) => { + ({tree, app: {suitesPage: {currentGroupId}}}: State, {resultId}: AttemptPickerItemProps) => { const result = tree.results.byId[resultId]; - const matchedSelectedGroup = get(tree.results.stateById[resultId], 'matchedSelectedGroup', false); + const group = Object.values(tree.groups.byId).find(group => group.id === currentGroupId); + const matchedSelectedGroup = Boolean(group?.resultIds.includes(resultId)); const {status, attempt} = result; return { status: isFailStatus(result.status) && hasUnrelatedToScreenshotsErrors((result as ResultEntityError).error) ? TestStatus.FAIL_ERROR : status, attempt, - keyToGroupTestsBy, + currentGroupId, matchedSelectedGroup }; } diff --git a/lib/static/new-ui/features/suites/components/GroupBySelect/index.tsx b/lib/static/new-ui/features/suites/components/GroupBySelect/index.tsx new file mode 100644 index 000000000..a7f978de4 --- /dev/null +++ b/lib/static/new-ui/features/suites/components/GroupBySelect/index.tsx @@ -0,0 +1,40 @@ +import {useDispatch, useSelector} from 'react-redux'; +import {Select, SelectOptionGroup, SelectProps} from '@gravity-ui/uikit'; +import {GroupByType} from '@/static/new-ui/types/store'; +import {setCurrentGroupByExpression} from '@/static/modules/actions'; +import React, {ReactNode} from 'react'; + +export function GroupBySelect(props: SelectProps): ReactNode { + const dispatch = useDispatch(); + + const groupByExpressionId = useSelector((state) => state.app.groupTestsData.currentExpressionIds)[0]; + + const groupBySections = useSelector((state) => state.app.groupTestsData.availableSections); + const groupByExpressions = useSelector((state) => state.app.groupTestsData.availableExpressions); + const groupByOptions = groupBySections + .map((section): SelectOptionGroup => ({ + label: section.label, + options: groupByExpressions.filter(expr => expr.sectionId === section.id).map(expr => { + if (expr.type === GroupByType.Meta) { + return { + content: expr.key, + value: expr.id + }; + } + + return { + content: 'message', + value: expr.id + }; + }) + })); + + const onGroupByUpdate = (value: string[]): void => { + const newGroupByExpressionId = value[0]; + if (newGroupByExpressionId !== groupByExpressionId) { + dispatch(setCurrentGroupByExpression({expressionIds: newGroupByExpressionId ? [newGroupByExpressionId] : []})); + } + }; + + return