From f1a7b7ec864528c05997d55d4883f331be6a520a Mon Sep 17 00:00:00 2001 From: shadowusr <58862284+shadowusr@users.noreply.github.com> Date: Tue, 17 Dec 2024 15:54:50 +0300 Subject: [PATCH] feat: add ability to sort by duration and start time (#623) * feat: add ability to sort by duration and start time * fix: display average time, use duration field for testplane tests * test: implement unit tests for tree sorting * test: fix tests after modifying tree builder helpers and fix review issues --- lib/adapters/test-result/index.ts | 3 + lib/adapters/test-result/playwright.ts | 4 + lib/adapters/test-result/reporter.ts | 4 + lib/adapters/test-result/sqlite.ts | 4 + lib/adapters/test-result/testplane.ts | 18 +- lib/adapters/test-result/transformers/db.ts | 3 +- lib/adapters/test-result/utils/index.ts | 3 +- lib/adapters/test/index.ts | 3 +- lib/adapters/test/testplane.ts | 4 +- lib/constants/database.ts | 8 +- lib/gui/tool-runner/index.ts | 13 +- lib/server-utils.ts | 2 +- lib/sqlite-client.ts | 2 + lib/static/constants/sort-tests.ts | 9 + lib/static/modules/reducers/sort-tests.ts | 18 +- .../new-ui/components/MetaInfo/index.tsx | 7 + .../suites/components/SortBySelect/index.tsx | 10 +- .../suites/components/SuitesTreeView/utils.ts | 353 +++++--- lib/static/new-ui/types/store.ts | 7 +- lib/types.ts | 1 + package-lock.json | 54 +- package.json | 3 + test/setup/globals.js | 2 + .../lib/adapters/test-result/testplane.ts | 2 +- test/unit/lib/adapters/test/playwright.ts | 12 +- test/unit/lib/adapters/test/testplane.ts | 6 +- test/unit/lib/sqlite-client.js | 3 +- .../modals/screenshot-accepter/header.jsx | 132 +-- .../modals/screenshot-accepter/index.jsx | 294 +++---- .../suites/components/SuitesTreeView/utils.ts | 779 ++++++++++++++++++ test/unit/lib/static/utils.tsx | 188 +++-- 31 files changed, 1462 insertions(+), 489 deletions(-) create mode 100644 test/unit/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts diff --git a/lib/adapters/test-result/index.ts b/lib/adapters/test-result/index.ts index 1281ec072..ad14dcdb2 100644 --- a/lib/adapters/test-result/index.ts +++ b/lib/adapters/test-result/index.ts @@ -21,6 +21,9 @@ export interface ReporterTestResult { readonly state: { name: string }; readonly status: TestStatus; readonly testPath: string[]; + /** Test start timestamp in ms */ readonly timestamp: number | undefined; readonly url?: string; + /** Test duration in ms */ + readonly duration: number; } diff --git a/lib/adapters/test-result/playwright.ts b/lib/adapters/test-result/playwright.ts index 3912fccaf..b24d29b60 100644 --- a/lib/adapters/test-result/playwright.ts +++ b/lib/adapters/test-result/playwright.ts @@ -364,4 +364,8 @@ export class PlaywrightTestResultAdapter implements ReporterTestResult { return _.groupBy(imageAttachments, a => a.name.replace(ANY_IMAGE_ENDING_REGEXP, '')); } + + get duration(): number { + return this._testResult.duration; + } } diff --git a/lib/adapters/test-result/reporter.ts b/lib/adapters/test-result/reporter.ts index 5b59e0b09..1c0f862d5 100644 --- a/lib/adapters/test-result/reporter.ts +++ b/lib/adapters/test-result/reporter.ts @@ -107,4 +107,8 @@ export class ReporterTestAdapter implements ReporterTestResult { get url(): string | undefined { return this._testResult.url; } + + get duration(): number { + return this._testResult.duration; + } } diff --git a/lib/adapters/test-result/sqlite.ts b/lib/adapters/test-result/sqlite.ts index 9d9902f59..b866c4ab5 100644 --- a/lib/adapters/test-result/sqlite.ts +++ b/lib/adapters/test-result/sqlite.ts @@ -151,4 +151,8 @@ export class SqliteTestResultAdapter implements ReporterTestResult { get url(): string | undefined { return this._testResult[DB_COLUMN_INDEXES.suiteUrl]; } + + get duration(): number { + return this._testResult[DB_COLUMN_INDEXES.duration]; + } } diff --git a/lib/adapters/test-result/testplane.ts b/lib/adapters/test-result/testplane.ts index 108a42873..bad43238c 100644 --- a/lib/adapters/test-result/testplane.ts +++ b/lib/adapters/test-result/testplane.ts @@ -73,14 +73,16 @@ const getHistory = (history?: TestplaneTestResult['history']): TestStepCompresse export interface TestplaneTestResultAdapterOptions { attempt: number; status: TestStatus; + duration: number; } export class TestplaneTestResultAdapter implements ReporterTestResult { private _testResult: TestplaneTest | TestplaneTestResult; private _errorDetails: ErrorDetails | null; - private _timestamp: number | undefined; + private _timestamp: number; private _attempt: number; private _status: TestStatus; + private _duration: number; static create( this: new (testResult: TestplaneTest | TestplaneTestResult, options: TestplaneTestResultAdapterOptions) => TestplaneTestResultAdapter, @@ -90,11 +92,12 @@ export class TestplaneTestResultAdapter implements ReporterTestResult { return new this(testResult, options); } - constructor(testResult: TestplaneTest | TestplaneTestResult, {attempt, status}: TestplaneTestResultAdapterOptions) { + constructor(testResult: TestplaneTest | TestplaneTestResult, {attempt, status, duration}: TestplaneTestResultAdapterOptions) { this._testResult = testResult; this._errorDetails = null; - this._timestamp = (this._testResult as TestplaneTestResult).timestamp ?? - (this._testResult as TestplaneTestResult).startTime ?? Date.now(); + this._timestamp = (this._testResult as TestplaneTestResult).startTime + ?? (this._testResult as TestplaneTestResult).timestamp + ?? Date.now(); this._status = status; const browserVersion = _.get(this._testResult, 'meta.browserVersion', this._testResult.browserVersion); @@ -102,6 +105,7 @@ export class TestplaneTestResultAdapter implements ReporterTestResult { _.set(this._testResult, 'meta.browserVersion', browserVersion); this._attempt = attempt; + this._duration = duration; } get fullName(): string { @@ -259,7 +263,11 @@ export class TestplaneTestResultAdapter implements ReporterTestResult { set timestamp(timestamp) { if (!_.isNumber(this._timestamp)) { - this._timestamp = timestamp; + this._timestamp = timestamp ?? 0; } } + + get duration(): number { + return this._duration; + } } diff --git a/lib/adapters/test-result/transformers/db.ts b/lib/adapters/test-result/transformers/db.ts index 7953d25ae..2db111367 100644 --- a/lib/adapters/test-result/transformers/db.ts +++ b/lib/adapters/test-result/transformers/db.ts @@ -38,7 +38,8 @@ export class DbTestResultTransformer { screenshot: Boolean(testResult.screenshot), multipleTabs: testResult.multipleTabs, status: testResult.status, - timestamp: testResult.timestamp ?? Date.now() + timestamp: testResult.timestamp ?? Date.now(), + duration: testResult.duration }; } } diff --git a/lib/adapters/test-result/utils/index.ts b/lib/adapters/test-result/utils/index.ts index 8998bef70..969fa852e 100644 --- a/lib/adapters/test-result/utils/index.ts +++ b/lib/adapters/test-result/utils/index.ts @@ -31,7 +31,8 @@ export const copyAndUpdate = ( 'status', 'testPath', 'timestamp', - 'url' + 'url', + 'duration' ] as const; // Type-level check that we didn't forget to include any keys diff --git a/lib/adapters/test/index.ts b/lib/adapters/test/index.ts index 0638ca4ce..3d9c12ddc 100644 --- a/lib/adapters/test/index.ts +++ b/lib/adapters/test/index.ts @@ -10,7 +10,8 @@ export interface CreateTestResultOpts { sessionId?: string; meta?: { url?: string; - } + }; + duration: number; } export interface TestAdapter { diff --git a/lib/adapters/test/testplane.ts b/lib/adapters/test/testplane.ts index 472a1fc2a..8b2ff915e 100644 --- a/lib/adapters/test/testplane.ts +++ b/lib/adapters/test/testplane.ts @@ -53,7 +53,7 @@ export class TestplaneTestAdapter implements TestAdapter { } createTestResult(opts: CreateTestResultOpts): ReporterTestResult { - const {status, assertViewResults, error, sessionId, meta, attempt = UNKNOWN_ATTEMPT} = opts; + const {status, assertViewResults, error, sessionId, meta, attempt = UNKNOWN_ATTEMPT, duration} = opts; const test = this._test.clone(); [ @@ -68,7 +68,7 @@ export class TestplaneTestAdapter implements TestAdapter { } }); - return TestplaneTestResultAdapter.create(test, {attempt, status}); + return TestplaneTestResultAdapter.create(test, {attempt, status, duration}); } } diff --git a/lib/constants/database.ts b/lib/constants/database.ts index 23edbc78f..0a571fbec 100644 --- a/lib/constants/database.ts +++ b/lib/constants/database.ts @@ -14,7 +14,8 @@ export const DB_COLUMNS = { SCREENSHOT: 'screenshot', MULTIPLE_TABS: 'multipleTabs', STATUS: 'status', - TIMESTAMP: 'timestamp' + TIMESTAMP: 'timestamp', + DURATION: 'duration' } as const; export const SUITES_TABLE_COLUMNS = [ @@ -31,7 +32,8 @@ export const SUITES_TABLE_COLUMNS = [ {name: DB_COLUMNS.SCREENSHOT, type: DB_TYPES.int}, //boolean - 0 or 1 {name: DB_COLUMNS.MULTIPLE_TABS, type: DB_TYPES.int}, //boolean - 0 or 1 {name: DB_COLUMNS.STATUS, type: DB_TYPES.text}, - {name: DB_COLUMNS.TIMESTAMP, type: DB_TYPES.int} + {name: DB_COLUMNS.TIMESTAMP, type: DB_TYPES.int}, + {name: DB_COLUMNS.DURATION, type: DB_TYPES.int} ] as const; export const DB_MAX_AVAILABLE_PAGE_SIZE = 65536; // helps to speed up queries @@ -55,7 +57,7 @@ interface DbColumnIndexes { [DB_COLUMNS.MULTIPLE_TABS]: 11, [DB_COLUMNS.STATUS]: 12, [DB_COLUMNS.TIMESTAMP]: 13, - + [DB_COLUMNS.DURATION]: 14, } export const DB_COLUMN_INDEXES = SUITES_TABLE_COLUMNS.reduce((acc: Record, {name}, index) => { diff --git a/lib/gui/tool-runner/index.ts b/lib/gui/tool-runner/index.ts index 499f59925..a21d181b6 100644 --- a/lib/gui/tool-runner/index.ts +++ b/lib/gui/tool-runner/index.ts @@ -194,7 +194,8 @@ export class ToolRunner { attempt: latestAttempt, error: test.error, sessionId, - meta: {url} + meta: {url}, + duration: 0 }); const estimatedStatus = reportBuilder.getUpdatedReferenceTestStatus(latestResult); @@ -205,7 +206,8 @@ export class ToolRunner { attempt: UNKNOWN_ATTEMPT, error: test.error, sessionId, - meta: {url} + meta: {url}, + duration: 0 }); const formattedResult = reportBuilder.provideAttempt(formattedResultWithoutAttempt); @@ -232,7 +234,8 @@ export class ToolRunner { attempt: UNKNOWN_ATTEMPT, error: test.error, sessionId, - meta: {url} + meta: {url}, + duration: 0 }); await Promise.all(formattedResultWithoutAttempt.imagesInfo.map(async (imageInfo) => { @@ -343,9 +346,9 @@ export class ToolRunner { this._testAdapters[testId] = test; if (test.pending) { - queue.add(async () => reportBuilder.addTestResult(test.createTestResult({status: SKIPPED}))); + queue.add(async () => reportBuilder.addTestResult(test.createTestResult({status: SKIPPED, duration: 0}))); } else { - queue.add(async () => reportBuilder.addTestResult(test.createTestResult({status: IDLE}))); + queue.add(async () => reportBuilder.addTestResult(test.createTestResult({status: IDLE, duration: 0}))); } } diff --git a/lib/server-utils.ts b/lib/server-utils.ts index bfce2e393..85df2a66e 100644 --- a/lib/server-utils.ts +++ b/lib/server-utils.ts @@ -302,7 +302,7 @@ export const formatTestResult = ( status: TestStatus, attempt: number = UNKNOWN_ATTEMPT ): ReporterTestResult => { - return new TestplaneTestResultAdapter(rawResult, {attempt, status}); + return new TestplaneTestResultAdapter(rawResult, {attempt, status, duration: (rawResult as TestplaneTestResult).duration}); }; export const saveErrorDetails = async (testResult: ReporterTestResult, reportPath: string): Promise => { diff --git a/lib/sqlite-client.ts b/lib/sqlite-client.ts index 20e0239be..b90efad88 100644 --- a/lib/sqlite-client.ts +++ b/lib/sqlite-client.ts @@ -49,6 +49,8 @@ export interface DbTestResult { suiteUrl: string; /* Unix time in ms. Example: `1700563430266` */ timestamp: number; + /* Duration in ms. Example: `1601` */ + duration: number; } interface SqliteClientOptions { diff --git a/lib/static/constants/sort-tests.ts b/lib/static/constants/sort-tests.ts index ec5e7c2ac..d755f1e85 100644 --- a/lib/static/constants/sort-tests.ts +++ b/lib/static/constants/sort-tests.ts @@ -3,3 +3,12 @@ import {SortByExpression, SortType} from '@/static/new-ui/types/store'; export const SORT_BY_NAME: SortByExpression = {id: 'by-name', label: 'Name', type: SortType.ByName}; export const SORT_BY_FAILED_RETRIES: SortByExpression = {id: 'by-failed-runs', label: 'Failed runs count', type: SortType.ByFailedRuns}; export const SORT_BY_TESTS_COUNT: SortByExpression = {id: 'by-tests-count', label: 'Tests count', type: SortType.ByTestsCount}; +export const SORT_BY_START_TIME: SortByExpression = {id: 'by-start-time', label: 'Start time', type: SortType.ByStartTime}; +export const SORT_BY_DURATION: SortByExpression = {id: 'by-duration', label: 'Duration', type: SortType.ByDuration}; + +export const DEFAULT_AVAILABLE_EXPRESSIONS: SortByExpression[] = [ + SORT_BY_NAME, + SORT_BY_FAILED_RETRIES, + SORT_BY_START_TIME, + SORT_BY_DURATION +]; diff --git a/lib/static/modules/reducers/sort-tests.ts b/lib/static/modules/reducers/sort-tests.ts index 1e7722332..0b14dd8a6 100644 --- a/lib/static/modules/reducers/sort-tests.ts +++ b/lib/static/modules/reducers/sort-tests.ts @@ -2,16 +2,16 @@ import {SortByExpression, SortDirection, State} from '@/static/new-ui/types/stor import {SomeAction} from '@/static/modules/actions/types'; import actionNames from '@/static/modules/action-names'; import {applyStateUpdate} from '@/static/modules/utils'; -import {SORT_BY_FAILED_RETRIES, SORT_BY_NAME, SORT_BY_TESTS_COUNT} from '@/static/constants/sort-tests'; +import { + DEFAULT_AVAILABLE_EXPRESSIONS, + SORT_BY_TESTS_COUNT +} from '@/static/constants/sort-tests'; export default (state: State, action: SomeAction): State => { switch (action.type) { case actionNames.INIT_STATIC_REPORT: case actionNames.INIT_GUI_REPORT: { - const availableExpressions: SortByExpression[] = [ - SORT_BY_NAME, - SORT_BY_FAILED_RETRIES - ]; + const availableExpressions = DEFAULT_AVAILABLE_EXPRESSIONS; return applyStateUpdate(state, { app: { @@ -46,15 +46,11 @@ export default (state: State, action: SomeAction): State => { if (action.payload.expressionIds.length > 0) { availableExpressions = [ - SORT_BY_NAME, - SORT_BY_FAILED_RETRIES, + ...DEFAULT_AVAILABLE_EXPRESSIONS, SORT_BY_TESTS_COUNT ]; } else { - availableExpressions = [ - SORT_BY_NAME, - SORT_BY_FAILED_RETRIES - ]; + availableExpressions = DEFAULT_AVAILABLE_EXPRESSIONS; } return applyStateUpdate(state, { diff --git a/lib/static/new-ui/components/MetaInfo/index.tsx b/lib/static/new-ui/components/MetaInfo/index.tsx index aa2de7ee7..c1b858c9c 100644 --- a/lib/static/new-ui/components/MetaInfo/index.tsx +++ b/lib/static/new-ui/components/MetaInfo/index.tsx @@ -102,6 +102,13 @@ function MetaInfoInternal(props: MetaInfoInternalProps): ReactNode { }; }); + if (result.duration !== undefined) { + metaInfoItemsWithResolvedUrls.push({ + label: 'duration', + content: `${new Intl.NumberFormat().format(result.duration)} ms` + }); + } + const hasUrlMetaInfoItem = metaInfoItemsWithResolvedUrls.some(item => item.label === 'url'); if (!hasUrlMetaInfoItem && result.suiteUrl) { metaInfoItemsWithResolvedUrls.push({ diff --git a/lib/static/new-ui/features/suites/components/SortBySelect/index.tsx b/lib/static/new-ui/features/suites/components/SortBySelect/index.tsx index 6f9382fab..059b40a70 100644 --- a/lib/static/new-ui/features/suites/components/SortBySelect/index.tsx +++ b/lib/static/new-ui/features/suites/components/SortBySelect/index.tsx @@ -3,7 +3,9 @@ import { BarsAscendingAlignLeftArrowUp, BarsDescendingAlignLeftArrowDown, FontCase, - SquareLetterT + SquareLetterT, + Clock, + Stopwatch } from '@gravity-ui/icons'; import {Icon, Select, SelectProps} from '@gravity-ui/uikit'; import React, {ReactNode} from 'react'; @@ -26,6 +28,12 @@ const getSortIcon = (sortByExpression: SortByExpression): ReactNode => { case SortType.ByTestsCount: iconData = SquareLetterT; break; + case SortType.ByStartTime: + iconData = Clock; + break; + case SortType.ByDuration: + iconData = Stopwatch; + break; } return ; }; diff --git a/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts b/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts index f7660e18b..4a74de570 100644 --- a/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts +++ b/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts @@ -34,7 +34,7 @@ export const getTitlePath = (suites: Record, entity: SuiteE return [...getTitlePath(suites, suites[entity.parentId]), entity.name]; }; -interface EntitiesContext { +export interface EntitiesContext { browsers: Record; browsersState: Record; results: Record; @@ -189,161 +189,252 @@ export const collectTreeLeafIds = (treeNodes: (TreeNode | TreeRoot)[]): {allTree return {allTreeNodeIds, visibleTreeNodeIds}; }; -export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeNode[]): TreeNode[] => { - const {groups, results, currentSortExpression, currentSortDirection, browsers, browsersState} = entitiesContext; - - // Weight of a single node is an array, because sorting may be performed by multiple fields at once - // For example, sort by tests count, but if tests counts are equal, compare runs counts - // In this case weight for each node is [testsCount, runsCount] - type TreeNodeWeightValue = (number | string)[]; - interface WeightMetadata { - testsCount: number; - runsCount: number; - failedRunsCount: number; - } +// Weight of a single node is an array, because sorting may be performed by multiple fields at once +// For example, sort by tests count, but if tests counts are equal, compare runs counts +// In this case weight for each node is [testsCount, runsCount] +type TreeNodeWeightValue = (number | string)[]; +interface WeightMetadata { + testsCount: number; + runsCount: number; + failedRunsCount: number; + duration: number; + startTime: number; +} - interface TreeNodeWeight { - value: TreeNodeWeightValue; - metadata: Partial; - } +interface TreeNodeWeight { + value: TreeNodeWeightValue; + metadata: Partial; +} - interface TreeWeightedSortResult { - sortedTreeNodes: TreeNode[]; - weight: TreeNodeWeight; +interface TreeWeightedSortResult { + sortedTreeNodes: TreeNode[]; + weight: TreeNodeWeight; +} + +const createWeight = (value: TreeNodeWeightValue, metadata?: Partial): TreeNodeWeight => ({ + value, + metadata: metadata ?? {} +}); + +const createInvalidWeight = (treeNode?: TreeNode): TreeNodeWeight => { + if (treeNode) { + console.warn('Failed to determine suite weight for tree node listed below. Please let us know at ' + NEW_ISSUE_LINK); + console.warn(treeNode); } - const createWeight = (value: TreeNodeWeightValue, metadata?: Partial): TreeNodeWeight => ({ - value, - metadata: metadata ?? {} - }); + return {value: [0], metadata: {}}; +}; - const extractWeight = (treeNode: TreeNode, childrenWeight?: TreeNodeWeight): TreeNodeWeight => { - const notifyOfUnsuccessfulWeightComputation = (): void => { - console.warn('Failed to determine suite weight for tree node listed below. Please let us now at ' + NEW_ISSUE_LINK); - console.warn(treeNode); - }; +const extractWeight = (entitesContext: EntitiesContext, treeNode: TreeNode, childrenWeight?: TreeNodeWeight): TreeNodeWeight => { + const {groups, browsersState, browsers, results, currentSortExpression} = entitesContext; - switch (treeNode.data.entityType) { - case EntityType.Group: { - const group = groups[treeNode.data.entityId]; + switch (treeNode.data.entityType) { + case EntityType.Group: { + const group = groups[treeNode.data.entityId]; - const browserEntities = group.browserIds.flatMap(browserId => browsersState[browserId].shouldBeShown ? [browsers[browserId]] : []); + const browserEntities = group.browserIds.flatMap(browserId => browsersState[browserId].shouldBeShown ? [browsers[browserId]] : []); - const testsCount = browserEntities.length; - const runsCount = group.resultIds.filter(resultId => browsersState[results[resultId].parentId].shouldBeShown).length; + const testsCount = browserEntities.length; + const runsCount = group.resultIds.filter(resultId => browsersState[results[resultId].parentId].shouldBeShown).length; - if (currentSortExpression.type === SortType.ByTestsCount) { - return createWeight([0, testsCount, runsCount], {testsCount, runsCount}); - } else if (currentSortExpression.type === SortType.ByName) { - return createWeight([treeNode.data.title.join(' '), testsCount, runsCount], {testsCount, runsCount}); - } else if (currentSortExpression.type === SortType.ByFailedRuns) { - if (!childrenWeight) { - notifyOfUnsuccessfulWeightComputation(); - return createWeight([0, 0, 0]); - } + if (currentSortExpression.type === SortType.ByTestsCount) { + return createWeight([0, testsCount, runsCount], {testsCount, runsCount}); + } - // For now, we assume there are no nested groups and suite/test weights are always 1 dimensional - return createWeight([childrenWeight.value[0], testsCount, runsCount], Object.assign({}, {testsCount, runsCount}, childrenWeight.metadata)); - } - break; + if (currentSortExpression.type === SortType.ByName) { + return createWeight([treeNode.data.title.join(' '), testsCount, runsCount], {testsCount, runsCount}); } - case EntityType.Suite: { - if (currentSortExpression.type === SortType.ByName) { - return createWeight([treeNode.data.title.join(' ')]); - } else if (currentSortExpression.type === SortType.ByFailedRuns) { - if (!childrenWeight) { - notifyOfUnsuccessfulWeightComputation(); - return createWeight([0]); - } - - return childrenWeight; - } else if (currentSortExpression.type === SortType.ByTestsCount) { - if (!childrenWeight) { - notifyOfUnsuccessfulWeightComputation(); - return createWeight([0]); - } - - return childrenWeight; - } - break; + + if (!childrenWeight) { + return createInvalidWeight(treeNode); } - case EntityType.Browser: { - if (currentSortExpression.type === SortType.ByName) { - return createWeight([treeNode.data.title.join(' ')]); - } else if (currentSortExpression.type === SortType.ByFailedRuns) { - const browser = browsers[treeNode.data.entityId]; - const groupId = getGroupId(treeNode.data); - - const failedRunsCount = browser.resultIds.filter(resultId => - (isFailStatus(results[resultId].status) || isErrorStatus(results[resultId].status)) && - (!groupId || groups[groupId].resultIds.includes(resultId)) - ).length; - - return createWeight([failedRunsCount], {failedRunsCount}); - } else if (currentSortExpression.type === SortType.ByTestsCount) { - const browser = browsers[treeNode.data.entityId]; - const groupId = getGroupId(treeNode.data); - const runsCount = groupId ? browser.resultIds.filter(resultId => groups[groupId].resultIds.includes(resultId)).length : browser.resultIds.length; - - return createWeight([1, runsCount], {runsCount}); - } - break; + + if (currentSortExpression.type === SortType.ByFailedRuns) { + // For now, we assume there are no nested groups and suite/test weights are always 1 dimensional + return createWeight([childrenWeight.value[0], testsCount, runsCount], Object.assign({}, {testsCount, runsCount}, childrenWeight.metadata)); } - } - notifyOfUnsuccessfulWeightComputation(); - return createWeight([0]); - }; + if ( + currentSortExpression.type === SortType.ByDuration || + currentSortExpression.type === SortType.ByStartTime + ) { + return childrenWeight; + } - const aggregateWeights = (weights: TreeNodeWeight[]): TreeNodeWeight => { - if (!currentSortExpression || currentSortExpression.type === SortType.ByName) { - return createWeight([0]); + break; } + case EntityType.Suite: { + if (currentSortExpression.type === SortType.ByName) { + return createWeight([treeNode.data.title.join(' ')]); + } - if (currentSortExpression.type === SortType.ByFailedRuns || currentSortExpression.type === SortType.ByTestsCount) { - return weights.reduce((accWeight, weight) => { - const newAccWeight = createWeight(accWeight.value.slice(0), accWeight.metadata); - for (let i = 0; i < weight.value.length; i++) { - newAccWeight.value[i] = Number(accWeight.value[i] ?? 0) + Number(weight.value[i]); - } + if (!childrenWeight) { + return createInvalidWeight(treeNode); + } - if (weight.metadata.testsCount !== undefined) { - newAccWeight.metadata.testsCount = (newAccWeight.metadata.testsCount ?? 0) + weight.metadata.testsCount; - } - if (weight.metadata.runsCount !== undefined) { - newAccWeight.metadata.runsCount = (newAccWeight.metadata.runsCount ?? 0) + weight.metadata.runsCount; - } - if (weight.metadata.failedRunsCount !== undefined) { - newAccWeight.metadata.failedRunsCount = (newAccWeight.metadata.failedRunsCount ?? 0) + weight.metadata.failedRunsCount; - } + if (currentSortExpression.type === SortType.ByDuration) { + return createWeight([childrenWeight.value[0], treeNode.data.title.join(' ')], childrenWeight.metadata); + } - return newAccWeight; - }, createWeight(new Array(weights[0]?.value?.length))); + if ( + currentSortExpression.type === SortType.ByFailedRuns || + currentSortExpression.type === SortType.ByTestsCount || + currentSortExpression.type === SortType.ByStartTime + ) { + return childrenWeight; + } + + break; } + case EntityType.Browser: { + if (currentSortExpression.type === SortType.ByName) { + return createWeight([treeNode.data.title.join(' ')]); + } - return createWeight([0]); - }; + if (currentSortExpression.type === SortType.ByFailedRuns) { + const browser = browsers[treeNode.data.entityId]; + const groupId = getGroupId(treeNode.data); + + const failedRunsCount = browser.resultIds.filter(resultId => + (isFailStatus(results[resultId].status) || isErrorStatus(results[resultId].status)) && + (!groupId || groups[groupId].resultIds.includes(resultId)) + ).length; + + return createWeight([failedRunsCount], {failedRunsCount}); + } + + if (currentSortExpression.type === SortType.ByTestsCount) { + const browser = browsers[treeNode.data.entityId]; + const groupId = getGroupId(treeNode.data); + const runsCount = groupId ? browser.resultIds.filter(resultId => groups[groupId].resultIds.includes(resultId)).length : browser.resultIds.length; + + return createWeight([1, runsCount], {runsCount}); + } + + if (currentSortExpression.type === SortType.ByDuration) { + const browser = browsers[treeNode.data.entityId]; + const groupId = getGroupId(treeNode.data); + const resultIds = groupId ? browser.resultIds.filter(resultId => groups[groupId].resultIds.includes(resultId)) : browser.resultIds; + const totalTime = resultIds.reduce((accTime, resultId) => accTime + (results[resultId].duration ?? 0), 0); + + return createWeight([totalTime, treeNode.data.title.join(' ')], {runsCount: resultIds.length, duration: totalTime}); + } - const generateTagsForWeight = (weight: TreeNodeWeight): string[] => { - const tags: string[] = []; + if (currentSortExpression.type === SortType.ByStartTime) { + const browser = browsers[treeNode.data.entityId]; + const startTime = results[browser.resultIds[0]].timestamp; - const testsCount = weight.metadata.testsCount; - if (testsCount !== undefined) { - tags.push(`${testsCount} ${(testsCount === 1 ? 'test' : 'tests')}`); + return createWeight([startTime], {startTime}); + } + + break; } + } + + return createInvalidWeight(treeNode); +}; + +const aggregateWeights = ({currentSortExpression}: EntitiesContext, weights: TreeNodeWeight[]): TreeNodeWeight => { + if (!currentSortExpression || currentSortExpression.type === SortType.ByName) { + return createInvalidWeight(); + } + + if ( + currentSortExpression.type === SortType.ByFailedRuns || + currentSortExpression.type === SortType.ByTestsCount || + currentSortExpression.type === SortType.ByDuration + ) { + return weights.reduce((accWeight, weight) => { + const newAccWeight = createWeight(accWeight.value.slice(0), accWeight.metadata); + for (let i = 0; i < weight.value.length; i++) { + newAccWeight.value[i] = Number(accWeight.value[i] ?? 0) + Number(weight.value[i]); + } + + if (weight.metadata.testsCount !== undefined) { + newAccWeight.metadata.testsCount = (newAccWeight.metadata.testsCount ?? 0) + weight.metadata.testsCount; + } + if (weight.metadata.runsCount !== undefined) { + newAccWeight.metadata.runsCount = (newAccWeight.metadata.runsCount ?? 0) + weight.metadata.runsCount; + } + if (weight.metadata.failedRunsCount !== undefined) { + newAccWeight.metadata.failedRunsCount = (newAccWeight.metadata.failedRunsCount ?? 0) + weight.metadata.failedRunsCount; + } + if (weight.metadata.duration !== undefined) { + newAccWeight.metadata.duration = (newAccWeight.metadata.duration ?? 0) + weight.metadata.duration; + } + + return newAccWeight; + }, createWeight(new Array(weights[0]?.value?.length))); + } + + if (currentSortExpression.type === SortType.ByStartTime) { + const MAX_TIMESTAMP = 8640000000000000; + + return weights.reduce((accWeight, weight) => { + const newAccWeight = createWeight(accWeight.value.slice(0), accWeight.metadata); + for (let i = 0; i < weight.value.length; i++) { + newAccWeight.value[i] = Math.min(Number(accWeight.value[i] || MAX_TIMESTAMP), Number(weight.value[i])); + } + + if (weight.metadata.startTime !== undefined) { + newAccWeight.metadata.startTime = Math.min((newAccWeight.metadata.startTime || MAX_TIMESTAMP), weight.metadata.startTime); + } + + return newAccWeight; + }, createWeight(new Array(weights[0]?.value?.length))); + } + + return createInvalidWeight(); +}; + +const generateTagsForWeight = (weight: TreeNodeWeight): string[] => { + const tags: string[] = []; + + const testsCount = weight.metadata.testsCount; + if (testsCount !== undefined) { + tags.push(`${testsCount} ${(testsCount === 1 ? 'test' : 'tests')}`); + } + + const runsCount = weight.metadata.runsCount; + if (runsCount !== undefined) { + tags.push(`${runsCount} ${(runsCount === 1 ? 'run' : 'runs')}`); + } - const runsCount = weight.metadata.runsCount; - if (runsCount !== undefined) { - tags.push(`${runsCount} ${(runsCount === 1 ? 'run' : 'runs')}`); + const failedRunsCount = weight.metadata.failedRunsCount; + if (failedRunsCount !== undefined) { + tags.push(`${failedRunsCount} ${(failedRunsCount === 1 ? 'failed run' : 'failed runs')}`); + } + + const duration = weight.metadata.duration; + if (duration !== undefined) { + const durationSeconds = Math.round(duration / 1000 * 10) / 10; + let averageDurationSeconds = 0; + if (weight.metadata.runsCount && weight.metadata.runsCount > 1) { + averageDurationSeconds = Math.round(durationSeconds / weight.metadata.runsCount * 10) / 10; } - const failedRunsCount = weight.metadata.failedRunsCount; - if (failedRunsCount !== undefined) { - tags.push(`${failedRunsCount} ${(failedRunsCount === 1 ? 'failed run' : 'failed runs')}`); + if (durationSeconds < 0.1) { + tags.push('~0s in total'); + } else { + tags.push(`${durationSeconds}s in total${averageDurationSeconds > 0.1 ? `, ${averageDurationSeconds}s on avg.` : ''}`); } + } - return tags; - }; + const startTime = weight.metadata.startTime; + if (startTime !== undefined) { + tags.push(`Started at ${new Date(startTime).toLocaleTimeString('en-US', { + hour12: false, + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + })}`); + } + + return tags; +}; + +export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeNode[]): TreeNode[] => { + const {currentSortDirection} = entitiesContext; // Recursive tree sort. At each level of the tree, it does the following: // 1. Compute weights of the current branch @@ -357,7 +448,7 @@ export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeN if (treeNode.data.entityType === EntityType.Group && treeNode.children?.length) { const sortResult = sortAndGetWeight(treeNode.children); - const weight = extractWeight(treeNode, sortResult.weight); + const weight = extractWeight(entitiesContext, treeNode, sortResult.weight); const newTreeNode = Object.assign({}, treeNode, { children: sortResult.sortedTreeNodes @@ -369,7 +460,7 @@ export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeN } else if (treeNode.data.entityType === EntityType.Suite && treeNode.children?.length) { const sortResult = sortAndGetWeight(treeNode.children); - const weight = extractWeight(treeNode, sortResult.weight); + const weight = extractWeight(entitiesContext, treeNode, sortResult.weight); const newTreeNode = Object.assign({}, treeNode, { children: sortResult.sortedTreeNodes @@ -379,7 +470,7 @@ export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeN weights[treeNode.data.id] = weight; treeNodesCopy[index] = newTreeNode; } else if (treeNode.data.entityType === EntityType.Browser) { - const weight = extractWeight(treeNode); + const weight = extractWeight(entitiesContext, treeNode); const newTreeNode = Object.assign({}, treeNode); newTreeNode.data.tags.push(...generateTagsForWeight(weight)); @@ -411,7 +502,7 @@ export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeN return { sortedTreeNodes: sortedTreeNodes, - weight: aggregateWeights(Object.values(weights)) + weight: aggregateWeights(entitiesContext, Object.values(weights)) }; }; diff --git a/lib/static/new-ui/types/store.ts b/lib/static/new-ui/types/store.ts index a31efac7f..61ddf3c7a 100644 --- a/lib/static/new-ui/types/store.ts +++ b/lib/static/new-ui/types/store.ts @@ -53,6 +53,7 @@ export interface BrowserEntity { id: string; name: string; resultIds: string[]; + imageIds: string[]; parentId: string; } @@ -75,6 +76,7 @@ export interface ResultEntityCommon { /** @note Browser Name/ID, e.g. `chrome-desktop` */ name: string; skipReason?: string; + duration?: number; } export interface ResultEntityError extends ResultEntityCommon { @@ -88,6 +90,7 @@ export const isResultEntityError = (result: ResultEntity): result is ResultEntit interface ImageEntityCommon { id: string; + /** @note Corresponding ResultEntity id */ parentId: string; } @@ -213,7 +216,9 @@ export type GroupByExpression = GroupByMetaExpression | GroupByErrorExpression; export enum SortType { ByName, ByFailedRuns, - ByTestsCount + ByTestsCount, + ByStartTime, + ByDuration } export enum SortDirection { diff --git a/lib/types.ts b/lib/types.ts index b5c14d061..ad5ee948c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -272,6 +272,7 @@ export type RawSuitesRow = [ multipleTabs: number, status: string, timestamp: number, + duration: number, ]; export type LabeledSuitesRow = { diff --git a/package-lock.json b/package-lock.json index 03a7e833f..1278222ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "html-reporter", - "version": "10.7.0", + "version": "10.11.0", "license": "MIT", "workspaces": [ "test/func/fixtures/*", @@ -75,6 +75,7 @@ "@types/bluebird": "^3.5.3", "@types/chai": "^4.3.5", "@types/chai-as-promised": "^7.1.1", + "@types/chai-subset": "^1.3.5", "@types/debug": "^4.1.8", "@types/escape-html": "^1.0.4", "@types/express": "4.16", @@ -100,7 +101,9 @@ "buffer": "^6.0.3", "chai": "^4.1.2", "chai-as-promised": "^7.1.1", + "chai-deep-equal-ignore-undefined": "^1.1.1", "chai-dom": "^1.12.0", + "chai-subset": "^1.6.0", "classnames": "^2.2.5", "concurrently": "^7.6.0", "conventional-changelog-lint": "^1.0.1", @@ -4402,6 +4405,15 @@ "@types/chai": "*" } }, + "node_modules/@types/chai-subset": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.5.tgz", + "integrity": "sha512-c2mPnw+xHtXDoHmdtcCXGwyLMiauiAyxWMzhGpqHC4nqI/Y5G2XhTampslK2rb59kpcuHon03UH8W6iYUzw88A==", + "dev": true, + "dependencies": { + "@types/chai": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -7998,6 +8010,15 @@ "chai": ">= 2.1.2 < 5" } }, + "node_modules/chai-deep-equal-ignore-undefined": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/chai-deep-equal-ignore-undefined/-/chai-deep-equal-ignore-undefined-1.1.1.tgz", + "integrity": "sha512-BE4nUR2Jbqmmv8A0EuAydFRB/lXgXWAfa9TvO3YzHeGHAU7ZRwPZyu074oDl/CZtNXM7jXINpQxKBOe7N0P4bg==", + "dev": true, + "peerDependencies": { + "chai": ">= 4.0.0 < 5" + } + }, "node_modules/chai-dom": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/chai-dom/-/chai-dom-1.12.0.tgz", @@ -8010,6 +8031,15 @@ "chai": ">= 3" } }, + "node_modules/chai-subset": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/chai-subset/-/chai-subset-1.6.0.tgz", + "integrity": "sha512-K3d+KmqdS5XKW5DWPd5sgNffL3uxdDe+6GdnJh3AYPhwnBGRY5urfvfcbRtWIvvpz+KxkL9FeBB6MZewLUNwug==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -32445,6 +32475,15 @@ "@types/chai": "*" } }, + "@types/chai-subset": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.5.tgz", + "integrity": "sha512-c2mPnw+xHtXDoHmdtcCXGwyLMiauiAyxWMzhGpqHC4nqI/Y5G2XhTampslK2rb59kpcuHon03UH8W6iYUzw88A==", + "dev": true, + "requires": { + "@types/chai": "*" + } + }, "@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -35235,6 +35274,13 @@ "check-error": "^1.0.2" } }, + "chai-deep-equal-ignore-undefined": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/chai-deep-equal-ignore-undefined/-/chai-deep-equal-ignore-undefined-1.1.1.tgz", + "integrity": "sha512-BE4nUR2Jbqmmv8A0EuAydFRB/lXgXWAfa9TvO3YzHeGHAU7ZRwPZyu074oDl/CZtNXM7jXINpQxKBOe7N0P4bg==", + "dev": true, + "requires": {} + }, "chai-dom": { "version": "1.12.0", "resolved": "https://registry.npmjs.org/chai-dom/-/chai-dom-1.12.0.tgz", @@ -35242,6 +35288,12 @@ "dev": true, "requires": {} }, + "chai-subset": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/chai-subset/-/chai-subset-1.6.0.tgz", + "integrity": "sha512-K3d+KmqdS5XKW5DWPd5sgNffL3uxdDe+6GdnJh3AYPhwnBGRY5urfvfcbRtWIvvpz+KxkL9FeBB6MZewLUNwug==", + "dev": true + }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", diff --git a/package.json b/package.json index 39b38c978..1445a0faf 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "@types/bluebird": "^3.5.3", "@types/chai": "^4.3.5", "@types/chai-as-promised": "^7.1.1", + "@types/chai-subset": "^1.3.5", "@types/debug": "^4.1.8", "@types/escape-html": "^1.0.4", "@types/express": "4.16", @@ -165,7 +166,9 @@ "buffer": "^6.0.3", "chai": "^4.1.2", "chai-as-promised": "^7.1.1", + "chai-deep-equal-ignore-undefined": "^1.1.1", "chai-dom": "^1.12.0", + "chai-subset": "^1.6.0", "classnames": "^2.2.5", "concurrently": "^7.6.0", "conventional-changelog-lint": "^1.0.1", diff --git a/test/setup/globals.js b/test/setup/globals.js index 08e638f97..2726e7fa4 100644 --- a/test/setup/globals.js +++ b/test/setup/globals.js @@ -20,6 +20,8 @@ require.extensions['.module.css'] = function(module) { }; chai.use(require('chai-as-promised')); +chai.use(require('chai-deep-equal-ignore-undefined')); +chai.use(require('chai-subset')); chai.use(require('chai-dom')); sinon.assert.expose(chai.assert, {prefix: ''}); diff --git a/test/unit/lib/adapters/test-result/testplane.ts b/test/unit/lib/adapters/test-result/testplane.ts index 4c13284b2..5709a8332 100644 --- a/test/unit/lib/adapters/test-result/testplane.ts +++ b/test/unit/lib/adapters/test-result/testplane.ts @@ -46,7 +46,7 @@ describe('TestplaneTestResultAdapter', () => { testResult: TestplaneTestResult, {status = TestStatus.SUCCESS}: {status?: TestStatus} = {} ): TestplaneTestResultAdapter => { - return new TestplaneTestResultAdapter(testResult, {status, attempt: 0}) as TestplaneTestResultAdapter; + return new TestplaneTestResultAdapter(testResult, {status, attempt: 0, duration: 0}) as TestplaneTestResultAdapter; }; const mkTestResult_ = (result: Partial): TestplaneTestResult => _.defaults(result, { diff --git a/test/unit/lib/adapters/test/playwright.ts b/test/unit/lib/adapters/test/playwright.ts index 686446264..fa05e70d8 100644 --- a/test/unit/lib/adapters/test/playwright.ts +++ b/test/unit/lib/adapters/test/playwright.ts @@ -89,7 +89,7 @@ describe('lib/adapters/test/playwright', () => { }); const testAdapter = PlaywrightTestAdapter.create(test); - testAdapter.createTestResult({status: TestStatus.SUCCESS}); + testAdapter.createTestResult({status: TestStatus.SUCCESS, duration: 0}); const testCase = (PlaywrightTestResultAdapter.create as SinonStub).args[0][0] as unknown as TestCase; assert.calledOnceWith(PlaywrightTestResultAdapter.create as SinonStub, { @@ -111,7 +111,7 @@ describe('lib/adapters/test/playwright', () => { it('should create test result adapter with generated test result', () => { const testAdapter = PlaywrightTestAdapter.create(mkTest_()); - testAdapter.createTestResult({status: TestStatus.SUCCESS, attempt: 100500}); + testAdapter.createTestResult({status: TestStatus.SUCCESS, attempt: 100500, duration: 0}); assert.calledOnceWith(PlaywrightTestResultAdapter.create as SinonStub, sinon.match.any, { attachments: [], @@ -139,7 +139,7 @@ describe('lib/adapters/test/playwright', () => { isUpdated: true }; - testAdapter.createTestResult({status: TestStatus.UPDATED, assertViewResults: [assertViewResult]}); + testAdapter.createTestResult({status: TestStatus.UPDATED, assertViewResults: [assertViewResult], duration: 0}); const testResult = (PlaywrightTestResultAdapter.create as SinonStub).args[0][1] as unknown as TestResult; assert.deepEqual(testResult.attachments as PlaywrightAttachment[], [{ @@ -167,7 +167,7 @@ describe('lib/adapters/test/playwright', () => { isUpdated: false } as unknown as ImageDiffError; - testAdapter.createTestResult({status: TestStatus.FAIL, assertViewResults: [assertViewResult]}); + testAdapter.createTestResult({status: TestStatus.FAIL, assertViewResults: [assertViewResult], duration: 0}); const testResult = (PlaywrightTestResultAdapter.create as SinonStub).args[0][1] as unknown as TestResult; assert.deepEqual(testResult.attachments[1] as PlaywrightAttachment, { @@ -194,7 +194,7 @@ describe('lib/adapters/test/playwright', () => { isUpdated: false } as unknown as ImageDiffError; - testAdapter.createTestResult({status: TestStatus.FAIL, assertViewResults: [assertViewResult]}); + testAdapter.createTestResult({status: TestStatus.FAIL, assertViewResults: [assertViewResult], duration: 0}); const testResult = (PlaywrightTestResultAdapter.create as SinonStub).args[0][1] as unknown as TestResult; assert.deepEqual(testResult.attachments[1] as PlaywrightAttachment, { @@ -221,7 +221,7 @@ describe('lib/adapters/test/playwright', () => { isUpdated: false } as unknown as ImageDiffError; - testAdapter.createTestResult({status: TestStatus.FAIL, assertViewResults: [assertViewResult]}); + testAdapter.createTestResult({status: TestStatus.FAIL, assertViewResults: [assertViewResult], duration: 0}); const testResult = (PlaywrightTestResultAdapter.create as SinonStub).args[0][1] as unknown as TestResult; assert.deepEqual(testResult.attachments[1] as PlaywrightAttachment, { diff --git a/test/unit/lib/adapters/test/testplane.ts b/test/unit/lib/adapters/test/testplane.ts index c9f55c01a..af676ee06 100644 --- a/test/unit/lib/adapters/test/testplane.ts +++ b/test/unit/lib/adapters/test/testplane.ts @@ -97,12 +97,12 @@ describe('lib/adapters/test/testplane', () => { const test = mkState({clone: () => clonedTest}) as unknown as Test; const status = TestStatus.SUCCESS; const attempt = 0; - sandbox.stub(TestplaneTestResultAdapter, 'create').withArgs(clonedTest, {status, attempt}).returns(testResultAdapter); + sandbox.stub(TestplaneTestResultAdapter, 'create').withArgs(clonedTest, {status, attempt, duration: 0}).returns(testResultAdapter); - const formattedTestResult = TestplaneTestAdapter.create(test).createTestResult({status, attempt}); + const formattedTestResult = TestplaneTestAdapter.create(test).createTestResult({status, attempt, duration: 0}); assert.equal(formattedTestResult, testResultAdapter); - assert.calledOnceWith(TestplaneTestResultAdapter.create as SinonStub, clonedTest, {status, attempt}); + assert.calledOnceWith(TestplaneTestResultAdapter.create as SinonStub, clonedTest, {status, attempt, duration: 0}); }); }); }); diff --git a/test/unit/lib/sqlite-client.js b/test/unit/lib/sqlite-client.js index 7b697566e..a2b7e7fbe 100644 --- a/test/unit/lib/sqlite-client.js +++ b/test/unit/lib/sqlite-client.js @@ -47,7 +47,8 @@ describe('lib/sqlite-client', () => { {cid: 10, name: 'screenshot', type: 'INT'}, {cid: 11, name: 'multipleTabs', type: 'INT'}, {cid: 12, name: 'status', type: 'TEXT'}, - {cid: 13, name: 'timestamp', type: 'INT'} + {cid: 13, name: 'timestamp', type: 'INT'}, + {cid: 14, name: 'duration', type: 'INT'} ]; const columns = db.prepare('PRAGMA table_info(suites);').all(); diff --git a/test/unit/lib/static/components/modals/screenshot-accepter/header.jsx b/test/unit/lib/static/components/modals/screenshot-accepter/header.jsx index ddbc9cbda..c3475f74a 100644 --- a/test/unit/lib/static/components/modals/screenshot-accepter/header.jsx +++ b/test/unit/lib/static/components/modals/screenshot-accepter/header.jsx @@ -6,10 +6,10 @@ import {DiffModes} from 'lib/constants'; import ScreenshotAccepterHeader from 'lib/static/components/modals/screenshot-accepter/header'; import { addBrowserToTree, addResultToTree, - addSuiteToTree, generateResultId, + addSuiteToTree, mkBrowserEntity, mkConnectedComponent, mkEmptyTree, - mkRealStore, + mkRealStore, mkResultEntity, mkSuiteEntityLeaf, renderWithStore } from '../../../utils'; @@ -202,16 +202,20 @@ describe('', () => { const user = userEvent.setup(); const onScreenshotAccept = sandbox.stub(); const tree = mkEmptyTree(); - addSuiteToTree({tree, suiteName: 'test-1'}); - addBrowserToTree({tree, suiteName: 'test-1', browserName: 'bro-1'}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 0}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 1}); + const suite = mkSuiteEntityLeaf('test-1'); + addSuiteToTree({tree, suite}); + const browser = mkBrowserEntity('bro-1', {parentId: suite.id}); + addBrowserToTree({tree, browser}); + const result1 = mkResultEntity('res-1', {parentId: browser.id}); + addResultToTree({tree, result: result1}); + const result2 = mkResultEntity('res-2', {parentId: browser.id, attempt: 1}); + addResultToTree({tree, result: result2}); const store = mkRealStore({initialState: {tree}}); const component = renderWithStore(', () => { const user = userEvent.setup(); const onScreenshotAccept = sandbox.stub(); const tree = mkEmptyTree(); - addSuiteToTree({tree, suiteName: 'test-1'}); - addBrowserToTree({tree, suiteName: 'test-1', browserName: 'bro-1'}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 0}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 1}); + const suite = mkSuiteEntityLeaf('test-1'); + addSuiteToTree({tree, suite}); + const browser = mkBrowserEntity('bro-1', {parentId: suite.id}); + addBrowserToTree({tree, browser}); + const result1 = mkResultEntity('res-1', {parentId: browser.id}); + addResultToTree({tree, result: result1}); + const result2 = mkResultEntity('res-2', {parentId: browser.id, attempt: 1}); + addResultToTree({tree, result: result2}); const store = mkRealStore({initialState: {tree}}); renderWithStore(', () => { it('should render correctly', () => { const onRetryChange = sandbox.stub(); const tree = mkEmptyTree(); - addSuiteToTree({tree, suiteName: 'test-1'}); - addBrowserToTree({tree, suiteName: 'test-1', browserName: 'bro-1'}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 0}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 1}); + const suite = mkSuiteEntityLeaf('test-1'); + addSuiteToTree({tree, suite}); + const browser = mkBrowserEntity('bro-1', {parentId: suite.id}); + addBrowserToTree({tree, browser}); + const result1 = mkResultEntity('res-1', {parentId: browser.id}); + addResultToTree({tree, result: result1}); + const result2 = mkResultEntity('res-2', {parentId: browser.id, attempt: 1}); + addResultToTree({tree, result: result2}); const store = mkRealStore({initialState: {tree}}); const component = renderWithStore(', () => { const user = userEvent.setup(); const onRetryChange = sandbox.stub(); const tree = mkEmptyTree(); - addSuiteToTree({tree, suiteName: 'test-1'}); - addBrowserToTree({tree, suiteName: 'test-1', browserName: 'bro-1'}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 0}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 1}); + const suite = mkSuiteEntityLeaf('test-1'); + addSuiteToTree({tree, suite}); + const browser = mkBrowserEntity('bro-1', {parentId: suite.id}); + addBrowserToTree({tree, browser}); + const result1 = mkResultEntity('res-1', {parentId: browser.id}); + addResultToTree({tree, result: result1}); + const result2 = mkResultEntity('res-2', {parentId: browser.id, attempt: 1}); + addResultToTree({tree, result: result2}); const store = mkRealStore({initialState: {tree}}); const component = renderWithStore(', () => { const user = userEvent.setup(); const onRetryChange = sandbox.stub(); const tree = mkEmptyTree(); - addSuiteToTree({tree, suiteName: 'test-1'}); - addBrowserToTree({tree, suiteName: 'test-1', browserName: 'bro-1'}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 0}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 1}); + const suite = mkSuiteEntityLeaf('test-1'); + addSuiteToTree({tree, suite}); + const browser = mkBrowserEntity('bro-1', {parentId: suite.id}); + addBrowserToTree({tree, browser}); + const result1 = mkResultEntity('res-1', {parentId: browser.id}); + addResultToTree({tree, result: result1}); + const result2 = mkResultEntity('res-2', {parentId: browser.id, attempt: 1}); + addResultToTree({tree, result: result2}); const store = mkRealStore({initialState: {tree}}); renderWithStore(', () => { const user = userEvent.setup(); const onRetryChange = sandbox.stub(); const tree = mkEmptyTree(); - addSuiteToTree({tree, suiteName: 'test-1'}); - addBrowserToTree({tree, suiteName: 'test-1', browserName: 'bro-1'}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 0}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 1}); + const suite = mkSuiteEntityLeaf('test-1'); + addSuiteToTree({tree, suite}); + const browser = mkBrowserEntity('bro-1', {parentId: suite.id}); + addBrowserToTree({tree, browser}); + const result1 = mkResultEntity('res-1', {parentId: browser.id}); + addResultToTree({tree, result: result1}); + const result2 = mkResultEntity('res-2', {parentId: browser.id, attempt: 1}); + addResultToTree({tree, result: result2}); const store = mkRealStore({initialState: {tree}}); renderWithStore(', () => { const user = userEvent.setup(); const onRetryChange = sandbox.stub(); const tree = mkEmptyTree(); - addSuiteToTree({tree, suiteName: 'test-1'}); - addBrowserToTree({tree, suiteName: 'test-1', browserName: 'bro-1'}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 0}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 1}); + const suite = mkSuiteEntityLeaf('test-1'); + addSuiteToTree({tree, suite}); + const browser = mkBrowserEntity('bro-1', {parentId: suite.id}); + addBrowserToTree({tree, browser}); + const result1 = mkResultEntity('res-1', {parentId: browser.id}); + addResultToTree({tree, result: result1}); + const result2 = mkResultEntity('res-2', {parentId: browser.id, attempt: 1}); + addResultToTree({tree, result: result2}); const store = mkRealStore({initialState: {tree}}); renderWithStore(', () => { const user = userEvent.setup(); const onRetryChange = sandbox.stub(); const tree = mkEmptyTree(); - addSuiteToTree({tree, suiteName: 'test-1'}); - addBrowserToTree({tree, suiteName: 'test-1', browserName: 'bro-1'}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 0}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 1}); + const suite = mkSuiteEntityLeaf('test-1'); + addSuiteToTree({tree, suite}); + const browser = mkBrowserEntity('bro-1', {parentId: suite.id}); + addBrowserToTree({tree, browser}); + const result1 = mkResultEntity('res-1', {parentId: browser.id}); + addResultToTree({tree, result: result1}); + const result2 = mkResultEntity('res-2', {parentId: browser.id, attempt: 1}); + addResultToTree({tree, result: result2}); const store = mkRealStore({initialState: {tree}}); renderWithStore(', () => { it('should render header with correct images counter', () => { const tree = mkEmptyTree(); - addSuiteToTree({tree, suiteName: 'test-1'}); - addBrowserToTree({tree, suiteName: 'test-1', browserName: 'bro-1'}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 0}); - addImageToTree({ - tree, - suiteName: 'test-1', - browserName: 'bro-1', - attempt: 0, - stateName: 'state-1', - expectedImgPath: 'img1-expected.png', - actualImgPath: 'img1-actual.png', - diffImgPath: 'img1-diff.png' - }); - addImageToTree({ - tree, - suiteName: 'test-1', - browserName: 'bro-1', - attempt: 0, - stateName: 'state-2', - expectedImgPath: 'img2-expected.png', - actualImgPath: 'img2-actual.png', - diffImgPath: 'img2-diff.png' - }); + const suite = mkSuiteEntityLeaf('test-1'); + addSuiteToTree({tree, suite}); + const browser = mkBrowserEntity('bro-1', {parentId: suite.id}); + addBrowserToTree({tree, browser}); + const result = mkResultEntity('res-1', {parentId: browser.id}); + addResultToTree({tree, result}); + const image1 = mkImageEntityFail('state-1', {parentId: result.id}); + addImageToTree({tree, image: image1}); + const image2 = mkImageEntityFail('state-2', {parentId: result.id}); + addImageToTree({tree, image: image2}); const store = mkRealStore({initialState: {tree}}); - const currentImageId = generateImageId({suiteName: 'test-1', browserName: 'bro-1', attempt: 0, stateName: 'state-1'}); + const currentImageId = image1.id; const component = renderWithStore(, store); @@ -89,95 +76,66 @@ describe('', () => { it('should change attempt by clicking on retry-switcher', async () => { const user = userEvent.setup(); const tree = mkEmptyTree(); - addSuiteToTree({tree, suiteName: 'test-1'}); - addBrowserToTree({tree, suiteName: 'test-1', browserName: 'bro-1'}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 0}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 1}); - addImageToTree({ - tree, - suiteName: 'test-1', - browserName: 'bro-1', - attempt: 0, - stateName: 'state-1', - expectedImgPath: 'img1-expected.png', - actualImgPath: 'img1-actual.png', - diffImgPath: 'img1-diff.png' - }); - addImageToTree({ - tree, - suiteName: 'test-1', - browserName: 'bro-1', - attempt: 1, - stateName: 'state-1', - expectedImgPath: 'img2-expected.png', - actualImgPath: 'img2-actual.png', - diffImgPath: 'img2-diff.png' - }); + const suite = mkSuiteEntityLeaf('test-1'); + addSuiteToTree({tree, suite}); + const browser = mkBrowserEntity('bro-1', {parentId: suite.id}); + addBrowserToTree({tree, browser}); + const result1 = mkResultEntity('res-1', {parentId: browser.id}); + addResultToTree({tree, result: result1}); + const image1 = mkImageEntityFail('img-1', {stateName: 'plain', parentId: result1.id}); + addImageToTree({tree, image: image1}); + + const result2 = mkResultEntity('res-2', {parentId: browser.id, attempt: 1}); + addResultToTree({tree, result: result2}); + const image2 = mkImageEntityFail('img-2', {stateName: 'plain', parentId: result2.id}); + addImageToTree({tree, image: image2}); const store = mkRealStore({initialState: {tree}}); - const currentImageId = generateImageId({suiteName: 'test-1', browserName: 'bro-1', attempt: 1, stateName: 'state-1'}); + const currentImageId = image2.id; const component = renderWithStore(, store); // By default, last failed attempt is selected. We select first one. await user.click(component.getByText('1', {selector: 'button[data-qa="retry-switcher"] > *'})); const imageElements = component.getAllByRole('img'); - imageElements.every(imageElement => expect(imageElement.src).to.include('img1')); + imageElements.every(imageElement => expect(imageElement.src).to.include('img-1')); }); it('should change image by clicking on "next" button', async () => { const user = userEvent.setup(); const tree = mkEmptyTree(); - addSuiteToTree({tree, suiteName: 'test-1'}); - addBrowserToTree({tree, suiteName: 'test-1', browserName: 'bro-1'}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 0}); - addImageToTree({ - tree, - suiteName: 'test-1', - browserName: 'bro-1', - attempt: 0, - stateName: 'state-1', - expectedImgPath: 'img1-expected.png', - actualImgPath: 'img1-actual.png', - diffImgPath: 'img1-diff.png' - }); - addImageToTree({ - tree, - suiteName: 'test-1', - browserName: 'bro-1', - attempt: 0, - stateName: 'state-2', - expectedImgPath: 'img2-expected.png', - actualImgPath: 'img2-actual.png', - diffImgPath: 'img2-diff.png' - }); + const suite = mkSuiteEntityLeaf('test-1'); + addSuiteToTree({tree, suite}); + const browser = mkBrowserEntity('bro-1', {parentId: suite.id}); + addBrowserToTree({tree, browser}); + const result = mkResultEntity('res-1', {parentId: browser.id}); + addResultToTree({tree, result}); + const image1 = mkImageEntityFail('state-1', {parentId: result.id}); + addImageToTree({tree, image: image1}); + const image2 = mkImageEntityFail('state-2', {parentId: result.id}); + addImageToTree({tree, image: image2}); const store = mkRealStore({initialState: {tree}}); - const currentImageId = generateImageId({suiteName: 'test-1', browserName: 'bro-1', attempt: 0, stateName: 'state-1'}); + const currentImageId = image1.id; const component = renderWithStore(, store); await user.click(component.getByTitle('Show next image', {exact: false})); const imageElements = component.getAllByRole('img'); - imageElements.every(imageElement => expect(imageElement.src).to.include('img2')); + imageElements.every(imageElement => expect(imageElement.src).to.include('state-2')); }); it('should show a success message after accepting last screenshot', async () => { const user = userEvent.setup(); const tree = mkEmptyTree(); - addSuiteToTree({tree, suiteName: 'test-1'}); - addBrowserToTree({tree, suiteName: 'test-1', browserName: 'bro-1'}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 0}); - addImageToTree({ - tree, - suiteName: 'test-1', - browserName: 'bro-1', - attempt: 0, - stateName: 'state-1', - expectedImgPath: 'img1-expected.png', - actualImgPath: 'img1-actual.png', - diffImgPath: 'img1-diff.png' - }); + const suite = mkSuiteEntityLeaf('test-1'); + addSuiteToTree({tree, suite}); + const browser = mkBrowserEntity('bro-1', {parentId: suite.id}); + addBrowserToTree({tree, browser}); + const result = mkResultEntity('res-1', {parentId: browser.id}); + addResultToTree({tree, result}); + const image1 = mkImageEntityFail('state-1', {parentId: result.id}); + addImageToTree({tree, image: image1}); const store = mkRealStore({initialState: {tree}}); - const currentImageId = generateImageId({suiteName: 'test-1', browserName: 'bro-1', attempt: 0, stateName: 'state-1'}); + const currentImageId = image1.id; const component = renderWithStore(, store); await user.click(component.getByText('Accept', {selector: 'button > *'})); @@ -188,24 +146,19 @@ describe('', () => { it('should should display meta info', async () => { const user = userEvent.setup(); const tree = mkEmptyTree(); - addSuiteToTree({tree, suiteName: 'test-1'}); - addBrowserToTree({tree, suiteName: 'test-1', browserName: 'bro-1'}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 0, metaInfo: { + const suite = mkSuiteEntityLeaf('test-1'); + addSuiteToTree({tree, suite}); + const browser = mkBrowserEntity('bro-1', {parentId: suite.id}); + addBrowserToTree({tree, browser}); + const result = mkResultEntity('res-1', {parentId: browser.id, metaInfo: { key1: 'some-value-1', key2: 'some-value-2' }}); - addImageToTree({ - tree, - suiteName: 'test-1', - browserName: 'bro-1', - attempt: 0, - stateName: 'state-1', - expectedImgPath: 'img1-expected.png', - actualImgPath: 'img1-actual.png', - diffImgPath: 'img1-diff.png' - }); + addResultToTree({tree, result}); + const image1 = mkImageEntityFail('state-1', {parentId: result.id}); + addImageToTree({tree, image: image1}); const store = mkRealStore({initialState: {tree}}); - const currentImageId = generateImageId({suiteName: 'test-1', browserName: 'bro-1', attempt: 0, stateName: 'state-1'}); + const currentImageId = image1.id; const component = renderWithStore(, store); await user.click(component.getByText('Show meta', {selector: 'button > *'})); @@ -217,21 +170,16 @@ describe('', () => { it('should return to original state after clicking "undo"', async () => { const user = userEvent.setup(); const tree = mkEmptyTree(); - addSuiteToTree({tree, suiteName: 'test-1'}); - addBrowserToTree({tree, suiteName: 'test-1', browserName: 'bro-1'}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 0}); - addImageToTree({ - tree, - suiteName: 'test-1', - browserName: 'bro-1', - attempt: 0, - stateName: 'state-1', - expectedImgPath: 'img1-expected.png', - actualImgPath: 'img1-actual.png', - diffImgPath: 'img1-diff.png' - }); + const suite = mkSuiteEntityLeaf('test-1'); + addSuiteToTree({tree, suite}); + const browser = mkBrowserEntity('bro-1', {parentId: suite.id}); + addBrowserToTree({tree, browser}); + const result = mkResultEntity('res-1', {parentId: browser.id}); + addResultToTree({tree, result}); + const image1 = mkImageEntityFail('state-1', {parentId: result.id}); + addImageToTree({tree, image: image1}); const store = mkRealStore({initialState: {tree}}); - const currentImageId = generateImageId({suiteName: 'test-1', browserName: 'bro-1', attempt: 0, stateName: 'state-1'}); + const currentImageId = image1.id; const component = renderWithStore(, store); await user.click(component.getByText('Accept', {selector: 'button > *'})); @@ -239,7 +187,7 @@ describe('', () => { expect(component.getByTestId('screenshot-accepter-progress-bar').dataset.content).to.equal('0/1'); const imageElements = component.getAllByRole('img'); - imageElements.every(imageElement => expect(imageElement.src).to.include('img1')); + imageElements.every(imageElement => expect(imageElement.src).to.include('state-1')); }); describe('exiting from screenshot accepter', () => { @@ -258,23 +206,18 @@ describe('', () => { const reduxAction = sinon.stub(); const user = userEvent.setup(); const tree = mkEmptyTree(); - addSuiteToTree({tree, suiteName: 'test-1'}); - addBrowserToTree({tree, suiteName: 'test-1', browserName: 'bro-1'}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 0}); - addImageToTree({ - tree, - suiteName: 'test-1', - browserName: 'bro-1', - attempt: 0, - stateName: 'state-1', - expectedImgPath: 'img1-expected.png', - diffImgPath: 'img1-diff.png', - actualImgPath: 'img1-actual.png' - }); + const suite = mkSuiteEntityLeaf('test-1'); + addSuiteToTree({tree, suite}); + const browser = mkBrowserEntity('bro-1', {parentId: suite.id}); + addBrowserToTree({tree, browser}); + const result = mkResultEntity('res-1', {parentId: browser.id}); + addResultToTree({tree, result}); + const image1 = mkImageEntityFail('state-1', {parentId: result.id}); + addImageToTree({tree, image: image1}); const middleware = mkDispatchInterceptorMiddleware(reduxAction); const store = mkRealStore({initialState: {tree, view: {expand: EXPAND_ALL}}, middlewares: [middleware]}); - const currentImageId = generateImageId({suiteName: 'test-1', browserName: 'bro-1', attempt: 0, stateName: 'state-1'}); + const currentImageId = image1.id; const component = renderWithStore(, store); @@ -287,23 +230,18 @@ describe('', () => { const reduxAction = sinon.stub(); const user = userEvent.setup(); const tree = mkEmptyTree(); - addSuiteToTree({tree, suiteName: 'test-1'}); - addBrowserToTree({tree, suiteName: 'test-1', browserName: 'bro-1'}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 0}); - addImageToTree({ - tree, - suiteName: 'test-1', - browserName: 'bro-1', - attempt: 0, - stateName: 'state-1', - expectedImgPath: 'img1-expected.png', - diffImgPath: 'img1-diff.png', - actualImgPath: 'img1-actual.png' - }); + const suite = mkSuiteEntityLeaf('test-1'); + addSuiteToTree({tree, suite}); + const browser = mkBrowserEntity('bro-1', {parentId: suite.id}); + addBrowserToTree({tree, browser}); + const result = mkResultEntity('res-1', {parentId: browser.id}); + addResultToTree({tree, result}); + const image1 = mkImageEntityFail('state-1', {parentId: result.id}); + addImageToTree({tree, image: image1}); const middleware = mkDispatchInterceptorMiddleware(reduxAction); const store = mkRealStore({initialState: {tree, view: {expand: EXPAND_ALL}}, middlewares: [middleware]}); - const currentImageId = generateImageId({suiteName: 'test-1', browserName: 'bro-1', attempt: 0, stateName: 'state-1'}); + const currentImageId = image1.id; const component = renderWithStore(, store); @@ -317,23 +255,18 @@ describe('', () => { const reduxAction = sinon.stub(); const user = userEvent.setup(); const tree = mkEmptyTree(); - addSuiteToTree({tree, suiteName: 'test-1'}); - addBrowserToTree({tree, suiteName: 'test-1', browserName: 'bro-1'}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 0}); - addImageToTree({ - tree, - suiteName: 'test-1', - browserName: 'bro-1', - attempt: 0, - stateName: 'state-1', - expectedImgPath: 'img1-expected.png', - diffImgPath: 'img1-diff.png', - actualImgPath: 'img1-actual.png' - }); + const suite = mkSuiteEntityLeaf('test-1'); + addSuiteToTree({tree, suite}); + const browser = mkBrowserEntity('bro-1', {parentId: suite.id}); + addBrowserToTree({tree, browser}); + const result = mkResultEntity('res-1', {parentId: browser.id}); + addResultToTree({tree, result}); + const image1 = mkImageEntityFail('state-1', {parentId: result.id}); + addImageToTree({tree, image: image1}); const middleware = mkDispatchInterceptorMiddleware(reduxAction); const store = mkRealStore({initialState: {tree, view: {expand: EXPAND_ALL}}, middlewares: [middleware]}); - const currentImageId = generateImageId({suiteName: 'test-1', browserName: 'bro-1', attempt: 0, stateName: 'state-1'}); + const currentImageId = image1.id; const component = renderWithStore(, store); @@ -352,29 +285,20 @@ describe('', () => { beforeEach(() => { const tree = mkEmptyTree(); - addSuiteToTree({tree, suiteName: 'test-1'}); - addBrowserToTree({tree, suiteName: 'test-1', browserName: 'bro-1'}); - addResultToTree({tree, suiteName: 'test-1', browserName: 'bro-1', attempt: 0}); + const suite = mkSuiteEntityLeaf('test-1'); + addSuiteToTree({tree, suite}); + const browser = mkBrowserEntity('bro-1', {parentId: suite.id}); + addBrowserToTree({tree, browser}); + const result = mkResultEntity('res-1', {parentId: browser.id}); + addResultToTree({tree, result}); + for (let i = 1; i <= 10; i++) { - addImageToTree({ - tree, - suiteName: 'test-1', - browserName: 'bro-1', - attempt: 0, - stateName: `state-${i}`, - expectedImgPath: `img${i}-expected.png`, - diffImgPath: `img${i}-diff.png`, - actualImgPath: `img${i}-actual.png` - }); + const image = mkImageEntityFail(`state-${i}`, {parentId: result.id}); + addImageToTree({tree, image}); } const store = mkRealStore({initialState: {tree, view: {expand: EXPAND_ALL}}}); - const currentImageId = generateImageId({ - suiteName: 'test-1', - browserName: 'bro-1', - attempt: 0, - stateName: 'state-5' - }); + const currentImageId = Object.values(tree.images.byId)[4].id; component = renderWithStore(, store); }); @@ -383,7 +307,7 @@ describe('', () => { // Current image is 5. [2, 3, 4, 6, 7, 8].forEach(ind => { eachLabel_(label => { - assert.calledWith(preloadImageStub, `img${ind}-${label}.png`); + assert.calledWith(preloadImageStub, `state-${ind}-${label}`); }); }); }); @@ -391,7 +315,7 @@ describe('', () => { it('should not preload other images', () => { [1, 9, 10].forEach(ind => { eachLabel_(label => { - assert.neverCalledWith(preloadImageStub, `img${ind}-${label}.png`); + assert.neverCalledWith(preloadImageStub, `state-${ind}-${label}`); }); }); }); @@ -401,7 +325,7 @@ describe('', () => { await user.click(component.getByTitle('Show next image', {exact: false})); eachLabel_(label => { - assert.calledWith(preloadImageStub, `img9-${label}.png`); + assert.calledWith(preloadImageStub, `state-9-${label}`); }); }); @@ -410,7 +334,7 @@ describe('', () => { await user.click(component.getByText('Accept', {selector: 'button > *'})); eachLabel_(label => { - assert.calledWith(preloadImageStub, `img9-${label}.png`); + assert.calledWith(preloadImageStub, `state-9-${label}`); }); }); }); diff --git a/test/unit/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts b/test/unit/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts new file mode 100644 index 000000000..8d15bbd04 --- /dev/null +++ b/test/unit/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts @@ -0,0 +1,779 @@ +import {expect} from 'chai'; +import { + buildTreeBottomUp, + EntitiesContext, + formatEntityToTreeNodeData, + getTitlePath, + sortTreeNodes +} from '@/static/new-ui/features/suites/components/SuitesTreeView/utils'; +import { + BrowserEntity, + ResultEntity, + SortDirection, + SuiteEntity, + TreeEntity, + TreeViewMode +} from '@/static/new-ui/types/store'; +import { + addBrowserToTree, addGroupToTree, + addImageToTree, + addResultToTree, + addSuiteToTree, + mkBrowserEntity, + mkEmptyTree, + mkGroupEntity, + mkImageEntityFail, + mkImageEntitySuccess, + mkResultEntity, + mkSuiteEntityLeaf, + mkTreeNodeData +} from '../../../../../utils'; +import {EntityType, TreeNode} from '@/static/new-ui/features/suites/components/SuitesPage/types'; +import { + SORT_BY_DURATION, + SORT_BY_FAILED_RETRIES, + SORT_BY_NAME, SORT_BY_START_TIME, + SORT_BY_TESTS_COUNT +} from '@/static/constants/sort-tests'; +import {TestStatus} from '@/constants'; + +describe('getTitlePath', () => { + const suite1 = mkSuiteEntityLeaf('suite-1'); + const suite2 = mkSuiteEntityLeaf('suite-2'); + const mockSuites: Record = { + [suite1.id]: suite1, + [suite2.id]: suite2 + }; + + it('should return empty array when entity is undefined', () => { + const result = getTitlePath(mockSuites, undefined); + + expect(result).to.deep.equal([]); + }); + + it('should return suitePath for a SuiteEntity', () => { + const suite = suite1; + + const result = getTitlePath(mockSuites, suite); + + expect(result).to.deep.equal(suite.suitePath); + }); + + it('should construct path for a BrowserEntity', () => { + const suite = suite1; + const browser = mkBrowserEntity('suite-1', {parentId: suite.id}); + + const result = getTitlePath(mockSuites, browser); + + expect(result).to.deep.equal([...suite.suitePath, browser.name]); + }); + + it('should handle browser with non-existent parent suite', () => { + const browser: BrowserEntity = mkBrowserEntity('suite-1', {parentId: 'unknown-parent'}); + + const result = getTitlePath(mockSuites, browser); + + expect(result).to.deep.equal([browser.name]); + }); +}); + +describe('formatEntityToTreeNodeData', () => { + const baseContext: EntitiesContext = { + browsers: {}, + browsersState: {}, + results: {}, + images: {}, + suites: {}, + groups: {}, + treeViewMode: TreeViewMode.Tree, + currentSortDirection: SortDirection.Asc, + currentSortExpression: SORT_BY_NAME + }; + + describe('when formatting Suite entity', () => { + const suite = mkSuiteEntityLeaf('test'); + + it('should format suite entity correctly', () => { + const result = formatEntityToTreeNodeData( + baseContext, + suite, + 'tree-node-id' + ); + + expect(result).to.deep.equal({ + id: 'tree-node-id', + entityType: EntityType.Suite, + entityId: suite.id, + title: [suite.name], + status: suite.status, + tags: [], + parentData: undefined + }); + }); + }); + + describe('when formatting Group entity', () => { + const group = mkGroupEntity('group-1', { + key: 'url', + label: 'example.com' + }); + + it('should format group entity correctly', () => { + const result = formatEntityToTreeNodeData( + baseContext, + group, + group.id + ); + + expect(result).to.deep.equal({ + id: group.id, + entityType: EntityType.Group, + entityId: group.id, + prefix: 'url:', + title: ['example.com'], + status: null, + tags: [] + }); + }); + }); + + describe('when formatting Browser entity', () => { + let tree: TreeEntity; + let suite: SuiteEntity; + let browser: BrowserEntity; + let result: ResultEntity; + let context: EntitiesContext; + + beforeEach(() => { + tree = mkEmptyTree(); + + suite = mkSuiteEntityLeaf('suite-1'); + addSuiteToTree({tree, suite}); + + browser = mkBrowserEntity('browser-1', {parentId: suite.id}); + addBrowserToTree({tree, browser}); + + result = mkResultEntity('result-1', {parentId: browser.id}); + addResultToTree({tree, result}); + + context = { + ...baseContext, + suites: tree.suites.byId, + browsers: tree.browsers.byId, + results: tree.results.byId, + images: tree.images.byId + }; + }); + + it('should format browser entity in tree mode', () => { + const treeNodeData = formatEntityToTreeNodeData( + context, + browser, + browser.id + ); + + expect(treeNodeData).to.deepEqualIgnoreUndefined({ + id: browser.id, + entityType: EntityType.Browser, + entityId: browser.id, + title: [browser.name], + status: TestStatus.SUCCESS, + images: [], + tags: [] + }); + }); + + it('should format browser entity in flat mode', () => { + const flatContext = { + ...context, + treeViewMode: TreeViewMode.List + }; + + const treeNodeData = formatEntityToTreeNodeData( + flatContext, + browser, + browser.id + ); + + expect(treeNodeData.title).to.deep.equal([...suite.suitePath, browser.name]); + }); + + describe('when handling images', () => { + it('should add failed images from last retry', () => { + const imageOfFirstRetry = mkImageEntitySuccess('image-1', {parentId: result.id}); + addImageToTree({tree, image: imageOfFirstRetry}); + + const lastResult = mkResultEntity('last-result', {parentId: browser.id, attempt: 1}); + addResultToTree({tree, result: lastResult}); + + const successImage = mkImageEntitySuccess('image-1', {parentId: lastResult.id}); + addImageToTree({tree, image: successImage}); + const failedImage = mkImageEntityFail('image-2', {parentId: lastResult.id}); + addImageToTree({tree, image: failedImage}); + + const treeNodeData = formatEntityToTreeNodeData( + context, + browser, + browser.id + ); + + expect(treeNodeData.images).to.deep.equal([failedImage]); + }); + }); + + describe('when handling errors', () => { + it('should handle error information correctly', () => { + const lastResult = mkResultEntity('last-result', { + parentId: browser.id, + attempt: 1, + status: TestStatus.ERROR, + error: { + name: 'TestError', + message: 'Test failed', + stack: 'Error: Test failed\n at test.js:1:1\n at main.js:5:5\n at index.js:10:10' + } + }); + addResultToTree({tree, result: lastResult}); + + const result = formatEntityToTreeNodeData( + context, + browser, + browser.id + ); + + expect(result.errorTitle).to.equal('TestError'); + expect(result.errorStack).to.equal( + 'Error: Test failed\n at test.js:1:1\n at main.js:5:5' + ); + }); + }); + + describe('when handling skip reason', () => { + it('should include skip reason', () => { + const lastResult = mkResultEntity('last-result', { + parentId: browser.id, + attempt: 1, + status: TestStatus.SKIPPED, + skipReason: 'Some skip reason' + }); + addResultToTree({tree, result: lastResult}); + + const result = formatEntityToTreeNodeData( + context, + browser, + browser.id + ); + + expect(result.skipReason).to.equal('Some skip reason'); + }); + }); + }); +}); + +describe('buildTreeBottomUp', () => { + const baseContext: EntitiesContext = { + browsers: {}, + browsersState: {}, + results: {}, + images: {}, + suites: {}, + groups: {}, + treeViewMode: TreeViewMode.Tree, + currentSortDirection: SortDirection.Asc, + currentSortExpression: SORT_BY_NAME + }; + + let entitiesTree: TreeEntity; + let suite: SuiteEntity; + let browser: BrowserEntity; + let result: ResultEntity; + let context: EntitiesContext; + + beforeEach(() => { + entitiesTree = mkEmptyTree(); + + suite = mkSuiteEntityLeaf('suite-1', {suitePath: ['Suite 1']}); + addSuiteToTree({tree: entitiesTree, suite}); + + browser = mkBrowserEntity('browser-1', {parentId: suite.id}); + addBrowserToTree({tree: entitiesTree, browser}); + + result = mkResultEntity('result-1', {parentId: browser.id}); + addResultToTree({tree: entitiesTree, result}); + + context = { + ...baseContext, + suites: entitiesTree.suites.byId, + browsers: entitiesTree.browsers.byId, + results: entitiesTree.results.byId, + images: entitiesTree.images.byId, + browsersState: entitiesTree.browsers.stateById + }; + }); + + describe('in Tree mode', () => { + it('should create empty tree when no entities provided', () => { + const tree = buildTreeBottomUp(baseContext, []); + + expect(tree).to.deepEqualIgnoreUndefined({ + isRoot: true + }); + }); + + it('should build simple tree with a single suite', () => { + const tree = buildTreeBottomUp(context, [suite]); + + const expectedTree = { + isRoot: true, + children: [ + { + data: { + entityType: EntityType.Suite, + entityId: suite.id + } + } + ] + }; + expect(tree).to.containSubset(expectedTree); + }); + + it('should build tree with nested suites', () => { + const parentSuite = mkSuiteEntityLeaf('parent', {suitePath: ['parent']}); + addSuiteToTree({tree: entitiesTree, suite: parentSuite}); + + const childSuite = mkSuiteEntityLeaf('child', { + parentId: parentSuite.id, + suitePath: ['parent', 'child'] + }); + addSuiteToTree({tree: entitiesTree, suite: childSuite}); + + const tree = buildTreeBottomUp(context, [childSuite]); + + const expectedTree = { + isRoot: true, + children: [{ + data: { + entityType: EntityType.Suite, + entityId: parentSuite.id + }, + children: [{ + data: { + entityType: EntityType.Suite, + entityId: childSuite.id + } + }] + }] + }; + expect(tree).to.containSubset(expectedTree); + }); + + it('should build tree with browser nodes', () => { + const tree = buildTreeBottomUp(context, [browser]); + + const expectedTree = { + isRoot: true, + children: [{ + data: { + entityType: EntityType.Suite, + entityId: suite.id + }, + children: [{ + data: { + entityType: EntityType.Browser, + entityId: browser.id + } + }] + }] + }; + expect(tree).to.containSubset(expectedTree); + }); + + it('should skip hidden browsers', () => { + const hiddenBrowser = mkBrowserEntity('hidden-browser', {parentId: suite.id}); + addBrowserToTree({tree: entitiesTree, browser: hiddenBrowser}); + + entitiesTree.browsers.stateById[hiddenBrowser.id].shouldBeShown = false; + + const tree = buildTreeBottomUp(context, [browser, hiddenBrowser]); + + const expectedTree = { + isRoot: true, + children: [{ + data: { + entityType: EntityType.Suite, + entityId: suite.id, + title: [suite.name] + }, + children: [{ + data: { + entityType: EntityType.Browser, + entityId: hiddenBrowser.id, + title: [hiddenBrowser.name] + } + }] + }] + }; + expect(tree).to.not.containSubset(expectedTree); + }); + + it('should handle multiple entities at same level', () => { + const newSuite = mkSuiteEntityLeaf('new-suite'); + addSuiteToTree({tree: entitiesTree, suite: newSuite}); + + const browser1 = mkBrowserEntity('chrome', {parentId: newSuite.id}); + addBrowserToTree({tree: entitiesTree, browser: browser1}); + const result1 = mkResultEntity('new-result-1', {parentId: browser1.id}); + addResultToTree({tree: entitiesTree, result: result1}); + + const browser2 = mkBrowserEntity('firefox', {parentId: newSuite.id}); + addBrowserToTree({tree: entitiesTree, browser: browser2}); + const result2 = mkResultEntity('new-result-1', {parentId: browser2.id}); + addResultToTree({tree: entitiesTree, result: result2}); + + const tree = buildTreeBottomUp(context, [browser, browser1, browser2]); + + const expectedTree = { + isRoot: true, + children: [{ + data: { + entityType: EntityType.Suite, + entityId: suite.id + }, + children: [{ + data: { + entityType: EntityType.Browser, + entityId: browser.id + } + }] + }, { + data: { + entityType: EntityType.Suite, + entityId: newSuite.id + }, + children: [{ + data: { + entityType: EntityType.Browser, + entityId: browser1.id + } + }, { + data: { + entityType: EntityType.Browser, + entityId: browser2.id + } + }] + }] + }; + expect(tree).to.containSubset(expectedTree); + }); + + it('should use provided root data', () => { + const rootData: TreeNode['data'] = { + id: 'some-group', + entityType: EntityType.Group, + entityId: 'some-group', + title: ['Group'], + status: TestStatus.SUCCESS, + tags: [] + }; + + const tree = buildTreeBottomUp(context, [browser], rootData); + + const expectedTree = { + data: { + entityType: EntityType.Group, + entityId: rootData.entityId + }, + children: [{ + data: { + entityType: EntityType.Suite, + entityId: suite.id + }, + children: [{ + data: { + entityType: EntityType.Browser, + entityId: browser.id + } + }] + }] + }; + expect(tree).to.containSubset(expectedTree); + }); + }); + + describe('in List mode', () => { + it('should create flat structure', () => { + context.treeViewMode = TreeViewMode.List; + + const newBrowser = mkBrowserEntity('new-browser', {parentId: suite.id}); + addBrowserToTree({tree: entitiesTree, browser: newBrowser}); + const newResult = mkResultEntity('new-result', {parentId: newBrowser.id}); + addResultToTree({tree: entitiesTree, result: newResult}); + + const tree = buildTreeBottomUp(context, [browser, newBrowser]); + + const expectedTree = { + isRoot: true, + children: [{ + data: { + entityType: EntityType.Browser, + entityId: browser.id + } + }, { + data: { + entityType: EntityType.Browser, + entityId: newBrowser.id + } + }] + }; + expect(tree).to.containSubset(expectedTree); + }); + }); + + describe('caching behavior', () => { + it('should reuse existing nodes for same entity', () => { + const tree = buildTreeBottomUp(context, [browser, browser, suite]); + + expect(tree.children).to.have.length(1); + expect(tree.children?.[0].children).to.have.length(1); + }); + }); +}); + +describe('sortTreeNodes', () => { + const baseContext: EntitiesContext = { + browsers: {}, + browsersState: {}, + results: {}, + images: {}, + suites: {}, + groups: {}, + treeViewMode: TreeViewMode.Tree, + currentSortDirection: SortDirection.Asc, + currentSortExpression: SORT_BY_NAME + }; + + let entitiesTree: TreeEntity; + let suite: SuiteEntity; + let context: EntitiesContext; + + beforeEach(() => { + entitiesTree = mkEmptyTree(); + + suite = mkSuiteEntityLeaf('suite-1', {suitePath: ['Suite 1']}); + addSuiteToTree({tree: entitiesTree, suite}); + + context = { + ...baseContext, + groups: entitiesTree.groups.byId, + suites: entitiesTree.suites.byId, + browsers: entitiesTree.browsers.byId, + results: entitiesTree.results.byId, + images: entitiesTree.images.byId, + browsersState: entitiesTree.browsers.stateById + }; + }); + + describe('Sorting by name', () => { + it('should sort nodes alphabetically in ascending order', () => { + const nodes: TreeNode[] = [ + {data: mkTreeNodeData('B')}, + {data: mkTreeNodeData('A')}, + {data: mkTreeNodeData('C')} + ]; + + context.currentSortExpression = SORT_BY_NAME; + + const sorted = sortTreeNodes(context, nodes); + + expect(sorted.map(n => n.data.title[0])).to.deep.equal(['A', 'B', 'C']); + }); + + it('should sort nodes alphabetically in descending order', () => { + const nodes: TreeNode[] = [ + {data: mkTreeNodeData('B')}, + {data: mkTreeNodeData('A')}, + {data: mkTreeNodeData('C')} + ]; + + context.currentSortExpression = SORT_BY_NAME; + context.currentSortDirection = SortDirection.Desc; + + const sorted = sortTreeNodes(context, nodes); + + expect(sorted.map(n => n.data.title[0])).to.deep.equal(['C', 'B', 'A']); + }); + }); + + describe('Sorting by failed runs', () => { + it('should sort nodes by failed runs count', () => { + const browser1 = mkBrowserEntity('browser1', {parentId: suite.id}); + addBrowserToTree({tree: entitiesTree, browser: browser1}); + const result1 = mkResultEntity('result1', {parentId: browser1.id}); + addResultToTree({tree: entitiesTree, result: result1}); + + const browser2 = mkBrowserEntity('browser2', {parentId: suite.id}); + addBrowserToTree({tree: entitiesTree, browser: browser2}); + const result2 = mkResultEntity('result2', {parentId: browser2.id, status: TestStatus.FAIL}); + addResultToTree({tree: entitiesTree, result: result2}); + const result3 = mkResultEntity('result3', {parentId: browser2.id, status: TestStatus.ERROR}); + addResultToTree({tree: entitiesTree, result: result3}); + + const browser3 = mkBrowserEntity('browser3', {parentId: suite.id}); + addBrowserToTree({tree: entitiesTree, browser: browser3}); + const result4 = mkResultEntity('result4', {parentId: browser3.id, status: TestStatus.ERROR}); + addResultToTree({tree: entitiesTree, result: result4}); + + const nodes: TreeNode[] = [ + {data: mkTreeNodeData('Browser 1', {entityId: browser1.id})}, + {data: mkTreeNodeData('Browser 2', {entityId: browser2.id})}, + {data: mkTreeNodeData('Browser 3', {entityId: browser3.id})} + ]; + + context.currentSortExpression = SORT_BY_FAILED_RETRIES; + context.currentSortDirection = SortDirection.Desc; + + const sorted = sortTreeNodes(context, nodes); + + expect(sorted.map(n => n.data.entityId)).to.deep.equal(['browser2', 'browser3', 'browser1']); + }); + }); + + describe('Sorting by duration', () => { + it('should sort nodes by total duration', () => { + const browser1 = mkBrowserEntity('browser1', {parentId: suite.id}); + addBrowserToTree({tree: entitiesTree, browser: browser1}); + const result1 = mkResultEntity('result1', {parentId: browser1.id, duration: 100}); + addResultToTree({tree: entitiesTree, result: result1}); + + const browser2 = mkBrowserEntity('browser2', {parentId: suite.id}); + addBrowserToTree({tree: entitiesTree, browser: browser2}); + const result2 = mkResultEntity('result2', {parentId: browser2.id, duration: 100}); + addResultToTree({tree: entitiesTree, result: result2}); + const result3 = mkResultEntity('result3', {parentId: browser2.id, duration: 100}); + addResultToTree({tree: entitiesTree, result: result3}); + + const browser3 = mkBrowserEntity('browser3', {parentId: suite.id}); + addBrowserToTree({tree: entitiesTree, browser: browser3}); + const result4 = mkResultEntity('result4', {parentId: browser3.id, duration: 150}); + addResultToTree({tree: entitiesTree, result: result4}); + + const nodes: TreeNode[] = [ + {data: mkTreeNodeData('Browser 1', {entityId: browser1.id})}, + {data: mkTreeNodeData('Browser 2', {entityId: browser2.id})}, + {data: mkTreeNodeData('Browser 3', {entityId: browser3.id})} + ]; + + context.currentSortExpression = SORT_BY_DURATION; + context.currentSortDirection = SortDirection.Desc; + + const sorted = sortTreeNodes(context, nodes); + + expect(sorted.map(n => n.data.entityId)).to.deep.equal(['browser2', 'browser3', 'browser1']); + }); + }); + + describe('Sorting nested structures', () => { + it('should sort groups with nested browsers', () => { + // Creating Group 1, that has 1 test and 1 run. + const browser1 = mkBrowserEntity('browser1', {parentId: suite.id}); + addBrowserToTree({tree: entitiesTree, browser: browser1}); + const result1 = mkResultEntity('result1', {parentId: browser1.id, duration: 100}); + addResultToTree({tree: entitiesTree, result: result1}); + const group1 = mkGroupEntity('group1', {resultIds: [result1.id], browserIds: [browser1.id]}); + addGroupToTree({tree: entitiesTree, group: group1}); + + // Creating Group 2, that has 2 tests and 2 runs. + const browser2 = mkBrowserEntity('browser2', {parentId: suite.id}); + addBrowserToTree({tree: entitiesTree, browser: browser2}); + const result2 = mkResultEntity('result2', {parentId: browser2.id, duration: 100}); + addResultToTree({tree: entitiesTree, result: result2}); + const browser3 = mkBrowserEntity('browser3', {parentId: suite.id}); + addBrowserToTree({tree: entitiesTree, browser: browser3}); + const result4 = mkResultEntity('result4', {parentId: browser3.id, duration: 150}); + addResultToTree({tree: entitiesTree, result: result4}); + const group2 = mkGroupEntity('group2', {resultIds: [result2.id], browserIds: [browser2.id, browser3.id]}); + addGroupToTree({tree: entitiesTree, group: group2}); + + // Creating Group 3, that has 1 test and 2 runs. + const browser4 = mkBrowserEntity('browser4', {parentId: suite.id}); + addBrowserToTree({tree: entitiesTree, browser: browser4}); + const result5 = mkResultEntity('result5', {parentId: browser4.id, duration: 100}); + addResultToTree({tree: entitiesTree, result: result5}); + const result6 = mkResultEntity('result6', {parentId: browser4.id, duration: 100}); + addResultToTree({tree: entitiesTree, result: result6}); + const group3 = mkGroupEntity('group3', {resultIds: [result5.id, result6.id], browserIds: [browser4.id]}); + addGroupToTree({tree: entitiesTree, group: group3}); + + const nodes: TreeNode[] = [ + { + data: mkTreeNodeData('Group 1', {entityType: EntityType.Group, entityId: group1.id}), + children: [{ + data: mkTreeNodeData('Suite 1', {entityType: EntityType.Suite, entityId: suite.id}), + children: [{ + data: mkTreeNodeData('Browser 1', {entityType: EntityType.Browser, entityId: browser1.id}) + }] + }] + }, + { + data: mkTreeNodeData('Group 2', {entityType: EntityType.Group, entityId: group2.id}), + children: [{ + data: mkTreeNodeData('Suite 1', {entityType: EntityType.Suite, entityId: suite.id}), + children: [{ + data: mkTreeNodeData('Browser 2', {entityType: EntityType.Browser, entityId: browser2.id}) + }, { + data: mkTreeNodeData('Browser 3', {entityType: EntityType.Browser, entityId: browser3.id}) + }] + }] + }, + { + data: mkTreeNodeData('Group 3', {entityType: EntityType.Group, entityId: group3.id}), + children: [{ + data: mkTreeNodeData('Suite 1', {entityType: EntityType.Suite, entityId: suite.id}), + children: [{ + data: mkTreeNodeData('Browser 4', {entityType: EntityType.Browser, entityId: browser4.id}) + }] + }] + } + ]; + + context.currentSortExpression = SORT_BY_TESTS_COUNT; + context.currentSortDirection = SortDirection.Desc; + + const sorted = sortTreeNodes(context, nodes); + + expect(sorted.map(n => n.data.entityId)).to.deep.equal(['group2', 'group3', 'group1']); + }); + + it('should sort suites with nested browsers', () => { + const browser1 = mkBrowserEntity('browser1', {parentId: suite.id}); + addBrowserToTree({tree: entitiesTree, browser: browser1}); + const result1 = mkResultEntity('result1', {parentId: browser1.id, timestamp: 100}); + addResultToTree({tree: entitiesTree, result: result1}); + + const browser2 = mkBrowserEntity('browser2', {parentId: suite.id}); + addBrowserToTree({tree: entitiesTree, browser: browser2}); + const result2 = mkResultEntity('result2', {parentId: browser2.id, timestamp: 10}); + addResultToTree({tree: entitiesTree, result: result2}); + const result3 = mkResultEntity('result3', {parentId: browser2.id, timestamp: 150}); + addResultToTree({tree: entitiesTree, result: result3}); + + const browser3 = mkBrowserEntity('browser3', {parentId: suite.id}); + addBrowserToTree({tree: entitiesTree, browser: browser3}); + const result4 = mkResultEntity('result4', {parentId: browser3.id, timestamp: 150}); + addResultToTree({tree: entitiesTree, result: result4}); + + const nodes: TreeNode[] = [ + {data: mkTreeNodeData('Browser 1', {entityId: browser1.id})}, + {data: mkTreeNodeData('Browser 2', {entityId: browser2.id})}, + {data: mkTreeNodeData('Browser 3', {entityId: browser3.id})} + ]; + + context.currentSortExpression = SORT_BY_START_TIME; + context.currentSortDirection = SortDirection.Desc; + + const sorted = sortTreeNodes(context, nodes); + + expect(sorted.map(n => n.data.entityId)).to.deep.equal(['browser3', 'browser1', 'browser2']); + }); + }); +}); diff --git a/test/unit/lib/static/utils.tsx b/test/unit/lib/static/utils.tsx index dcd617a61..561b3f5d4 100644 --- a/test/unit/lib/static/utils.tsx +++ b/test/unit/lib/static/utils.tsx @@ -11,8 +11,16 @@ import defaultState from '@/static/modules/default-state'; import {TestStatus} from '@/constants'; import reducer from '@/static/modules/reducers'; import localStorage from '@/static/modules/middlewares/local-storage'; -import {BrowserEntity, ImageEntityFail, State, SuiteEntity, TreeEntity} from '@/static/new-ui/types/store'; +import { + BrowserEntity, GroupEntity, ImageEntity, + ImageEntityFail, ImageEntitySuccess, ResultEntity, + State, + SuiteEntity, + SuiteEntityLeaf, + TreeEntity +} from '@/static/new-ui/types/store'; import {UNCHECKED} from '@/constants/checked-statuses'; +import {EntityType, TreeViewItemData} from '@/static/new-ui/features/suites/components/SuitesPage/types'; export const mkState = ({initialState}: { initialState: Partial }): State => { return _.defaultsDeep(initialState ?? {}, defaultState); @@ -22,6 +30,77 @@ export const mkRealStore = ({initialState, middlewares = []}: {initialState: Sta return createStore(reducer, exports.mkState({initialState}), applyMiddleware(thunk, localStorage, ...middlewares)); }; +export const mkGroupEntity = (name: string, overrides?: Partial): GroupEntity => (_.merge({ + id: name, + type: EntityType.Group, + key: 'group-by-key', + label: 'Group Label', + browserIds: [], + resultIds: [] +} satisfies GroupEntity, overrides)); + +export const mkSuiteEntityLeaf = (name: string, overrides?: Partial): SuiteEntityLeaf => (_.mergeWith({ + id: name, + name, + parentId: null, + status: TestStatus.SUCCESS, + suitePath: ['Root Suite', `Suite ${name}`], + browserIds: [] +}, overrides, (_dest, src) => Array.isArray(src) ? src : undefined)); + +export const mkBrowserEntity = (name: string, overrides?: Partial): BrowserEntity => (_.merge({ + id: name, + name, + parentId: '', + resultIds: [], + imageIds: [] +} satisfies BrowserEntity, overrides)); + +export const mkResultEntity = (name: string, overrides?: Partial): ResultEntity => (_.merge({ + id: name, + parentId: '', + attempt: 0, + imageIds: [], + status: TestStatus.SUCCESS, + timestamp: 1, + metaInfo: {}, + suiteUrl: 'suite-url', + history: [], + suitePath: [], + name: '', + duration: 123 +} satisfies ResultEntity, overrides)); + +export const mkImageEntitySuccess = (name: string, overrides?: Partial): ImageEntitySuccess => (_.merge({ + id: name, + parentId: '', + status: TestStatus.SUCCESS, + stateName: name, + expectedImg: {path: `${name}-expected`, size: {width: 0, height: 0}}, + refImg: {path: `${name}-ref`, size: {width: 0, height: 0}} +} satisfies ImageEntitySuccess, overrides)); + +export const mkImageEntityFail = (name: string, overrides?: Partial): ImageEntityFail => (_.merge({ + id: name, + parentId: '', + status: TestStatus.FAIL, + stateName: name, + diffClusters: [], + expectedImg: {path: `${name}-expected`, size: {width: 0, height: 0}}, + refImg: {path: `${name}-ref`, size: {width: 0, height: 0}}, + diffImg: {path: `${name}-diff`, size: {width: 0, height: 0}}, + actualImg: {path: `${name}-actual`, size: {width: 0, height: 0}} +} satisfies ImageEntityFail, overrides)); + +export const mkTreeNodeData = (name: string, overrides?: Partial): TreeViewItemData => _.merge({ + id: name, + entityType: EntityType.Browser, + entityId: '', + title: [name], + status: TestStatus.SUCCESS, + tags: [] +} satisfies TreeViewItemData, overrides); + interface GenerateBrowserIdData { suiteName: string; browserName: string; @@ -51,32 +130,32 @@ export const generateImageId = ({suiteName, browserName, attempt, stateName}: Ge export const mkEmptyTree = (): TreeEntity => _.cloneDeep(defaultState.tree); +interface AddGroupToTreeData { + tree: TreeEntity; + group: GroupEntity; +} + +export const addGroupToTree = ({tree, group}: AddGroupToTreeData): void => { + tree.groups.byId[group.id] = group; +}; + interface AddSuiteToTreeData { tree: TreeEntity; - suiteName: string; + suite: SuiteEntity; } -export const addSuiteToTree = ({tree, suiteName}: AddSuiteToTreeData): void => { - tree.suites.byId[suiteName] = {id: suiteName} as SuiteEntity; +export const addSuiteToTree = ({tree, suite}: AddSuiteToTreeData): void => { + tree.suites.byId[suite.id] = suite; }; interface AddBrowserToTreeData { tree: TreeEntity; - suiteName: string; - browserName: string; + browser: BrowserEntity; } -export const addBrowserToTree = ({tree, suiteName, browserName}: AddBrowserToTreeData): void => { - const fullId = `${suiteName} ${browserName}`; - - tree.browsers.byId[fullId] = { - id: browserName, - name: browserName, - parentId: suiteName, - resultIds: [], - imageIds: [] - } as BrowserEntity; - tree.browsers.stateById[fullId] = { +export const addBrowserToTree = ({tree, browser}: AddBrowserToTreeData): void => { + tree.browsers.byId[browser.id] = browser; + tree.browsers.stateById[browser.id] = { shouldBeShown: true, checkStatus: UNCHECKED, retryIndex: 0, @@ -86,79 +165,30 @@ export const addBrowserToTree = ({tree, suiteName, browserName}: AddBrowserToTre interface AddResultToTreeData { tree: TreeEntity; - suiteName: string; - browserName: string; - attempt: number; - metaInfo: Record; + result: ResultEntity; } -export const addResultToTree = ({tree, suiteName, browserName, attempt, metaInfo = {}}: AddResultToTreeData): void => { - const browserId = `${suiteName} ${browserName}`; - const fullId = `${browserId} ${attempt}`; - - tree.results.byId[fullId] = { - id: fullId, - parentId: browserId, - attempt, - imageIds: [], - metaInfo, - status: TestStatus.IDLE, - timestamp: 0, - suitePath: [], - name: browserName - }; - tree.results.stateById[fullId] = { +export const addResultToTree = ({tree, result}: AddResultToTreeData): void => { + tree.results.byId[result.id] = result; + tree.results.stateById[result.id] = { matchedSelectedGroup: false }; - tree.browsers.byId[browserId].resultIds.push(fullId); + tree.browsers.byId[result.parentId].resultIds.push(result.id); }; interface AddImageToTreeData { tree: TreeEntity; - suiteName: string; - browserName: string; - attempt: number; - stateName: string; - status: TestStatus; - expectedImgPath: string; - actualImgPath: string; - diffImgPath: string; + image: ImageEntity; } -export const addImageToTree = ({tree, suiteName, browserName, attempt, stateName, status = TestStatus.FAIL, expectedImgPath, actualImgPath, diffImgPath}: AddImageToTreeData): void => { - const browserId = `${suiteName} ${browserName}`; - const resultId = `${browserId} ${attempt}`; - const fullId = `${resultId} ${stateName}`; - - tree.images.byId[fullId] = { - id: fullId, - parentId: resultId, - stateName, - status: status as TestStatus.FAIL - } as ImageEntityFail; - - if (expectedImgPath) { - (tree.images.byId[fullId] as ImageEntityFail).expectedImg = { - path: expectedImgPath, - size: {height: 1, width: 2} - }; - } - if (actualImgPath) { - (tree.images.byId[fullId] as ImageEntityFail).actualImg = { - path: actualImgPath, - size: {height: 1, width: 2} - }; - } - if (diffImgPath) { - (tree.images.byId[fullId] as ImageEntityFail).diffImg = { - path: diffImgPath, - size: {height: 1, width: 2} - }; - } - - (tree.browsers.byId[browserId] as any).imageIds.push(fullId); // TODO: why is this needed? - tree.results.byId[resultId].imageIds.push(fullId); +export const addImageToTree = ({tree, image}: AddImageToTreeData): void => { + tree.images.byId[image.id] = image; + + tree.results.byId[image.parentId].imageIds.push(image.id); + + const result = tree.results.byId[image.parentId]; + (tree.browsers.byId[result.parentId] as any).imageIds.push(image.id); // TODO: why is this needed? }; export const renderWithStore = (component: ReactNode, store: Store): RenderResult => {