Skip to content

Commit

Permalink
refactor: introduce tree node weight metadata, move tags logic to a h…
Browse files Browse the repository at this point in the history
…elper
  • Loading branch information
shadowusr committed Dec 8, 2024
1 parent cdea202 commit 117a268
Show file tree
Hide file tree
Showing 8 changed files with 109 additions and 73 deletions.
2 changes: 1 addition & 1 deletion lib/static/constants/sort-tests.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
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-retries', label: 'Failed retries', type: SortType.ByFailedRetries};
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};
2 changes: 2 additions & 0 deletions lib/static/modules/reducers/sort-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export default (state: State, action: SomeAction): State => {
}
case actionNames.GROUP_TESTS_SET_CURRENT_EXPRESSION: {
let availableExpressions: SortByExpression[];

if (action.payload.expressionIds.length > 0) {
availableExpressions = [
SORT_BY_NAME,
Expand All @@ -55,6 +56,7 @@ export default (state: State, action: SomeAction): State => {
SORT_BY_FAILED_RETRIES
];
}

return applyStateUpdate(state, {
app: {
sortTestsData: {
Expand Down
3 changes: 2 additions & 1 deletion lib/static/modules/reducers/suites-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ export default (state: State, action: SomeAction): State => {
case actionNames.INIT_STATIC_REPORT:
case actionNames.INIT_GUI_REPORT:
case actionNames.SUITES_PAGE_SET_TREE_VIEW_MODE:
case actionNames.CHANGE_VIEW_MODE as any: // eslint-disable-line @typescript-eslint/no-explicit-any
case actionNames.GROUP_TESTS_SET_CURRENT_EXPRESSION: {
const {allTreeNodeIds} = getTreeViewItems(state);

const expandedTreeNodesById: Record<string, boolean> = {};
const expandedTreeNodesById: Record<string, boolean> = Object.assign({}, state.ui.suitesPage.expandedTreeNodesById);

for (const nodeId of allTreeNodeIds) {
expandedTreeNodesById[nodeId] = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const getSortIcon = (sortByExpression: SortByExpression): ReactNode => {
case SortType.ByName:
iconData = FontCase;
break;
case SortType.ByFailedRetries:
case SortType.ByFailedRuns:
iconData = ArrowRotateLeft;
break;
case SortType.ByTestsCount:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ export const SuitesTreeView = forwardRef<SuitesTreeViewHandle, SuitesTreeViewPro
// Groups on average take 3 lines: 2 lines of text (clamped) + 1 line for tags -> 73px in total
// Regular items on average take 1 line -> 32px
// Providing more precise estimates here greatly improves scrolling performance
return item.entityType === EntityType.Group ? 73 : 32;
const GROUP_ROW_HEIGHT = 73;
const REGULAR_ROW_HEIGHT = 32;

return item.entityType === EntityType.Group ? GROUP_ROW_HEIGHT : REGULAR_ROW_HEIGHT;
},
getItemKey: useCallback((index: number) => list.structure.visibleFlattenIds[index], [list]),
overscan: 50
Expand Down Expand Up @@ -153,8 +156,8 @@ export const SuitesTreeView = forwardRef<SuitesTreeViewHandle, SuitesTreeViewPro
styles['tree-view__item'],
{
// Global classes are useful for deeply nested elements like tags
'current': isSelected,
'error': item.entityType === EntityType.Browser && (item.status === TestStatus.FAIL || item.status === TestStatus.ERROR),
'current-tree-node': isSelected,
'error-tree-node': item.entityType === EntityType.Browser && (item.status === TestStatus.FAIL || item.status === TestStatus.ERROR),
[styles['tree-view__item--current']]: isSelected,
[styles['tree-view__item--browser']]: item.entityType === EntityType.Browser,
[styles['tree-view__item--error']]: item.entityType === EntityType.Browser && (item.status === TestStatus.FAIL || item.status === TestStatus.ERROR)
Expand Down
158 changes: 94 additions & 64 deletions lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,9 @@ export const buildTreeBottomUp = (entitiesContext: EntitiesContext, entities: (S
return parentNode;
}

const nodePartialId = treeViewMode === TreeViewMode.Tree ?
(isBrowserEntity(entity) ? entity.name : entity.suitePath[entity.suitePath.length - 1]) :
entity.id;
const nodePartialId = treeViewMode === TreeViewMode.Tree
? (isBrowserEntity(entity) ? entity.name : entity.suitePath[entity.suitePath.length - 1])
: entity.id;
const currentId = parentNode.data ? `${parentNode.data.id}/${nodePartialId}` : nodePartialId;
if (cache[currentId]) {
return cache[currentId];
Expand Down Expand Up @@ -193,15 +193,30 @@ export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeN
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 retries counts
// In this case weight for each node is [testsCount, retriesCount]
type TreeNodeWeight = (number | string)[];
// 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;
}

interface TreeNodeWeight {
value: TreeNodeWeightValue;
metadata: Partial<WeightMetadata>;
}

interface TreeWeightedSortResult {
sortedTreeNodes: TreeNode[];
weight: TreeNodeWeight;
}

const createWeight = (value: TreeNodeWeightValue, metadata?: Partial<WeightMetadata>): TreeNodeWeight => ({
value,
metadata: 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);
Expand All @@ -215,37 +230,37 @@ export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeN
const browserEntities = group.browserIds.flatMap(browserId => browsersState[browserId].shouldBeShown ? [browsers[browserId]] : []);

const testsCount = browserEntities.length;
const retriesCount = group.resultIds.filter(resultId => browsersState[results[resultId].parentId].shouldBeShown).length;
const runsCount = group.resultIds.filter(resultId => browsersState[results[resultId].parentId].shouldBeShown).length;

if (currentSortExpression.type === SortType.ByTestsCount) {
return [0, testsCount, retriesCount];
return createWeight([0, testsCount, runsCount], {testsCount, runsCount});
} else if (currentSortExpression.type === SortType.ByName) {
return [treeNode.data.title.join(' '), testsCount, retriesCount];
} else if (currentSortExpression.type === SortType.ByFailedRetries) {
return createWeight([treeNode.data.title.join(' '), testsCount, runsCount], {testsCount, runsCount});
} else if (currentSortExpression.type === SortType.ByFailedRuns) {
if (!childrenWeight) {
notifyOfUnsuccessfulWeightComputation();
return [0, 0, 0];
return createWeight([0, 0, 0]);
}

// For now, we assume there are no nested groups and suite/test weights are always 1 dimensional
return [childrenWeight[0], testsCount, retriesCount];
return createWeight([childrenWeight.value[0], testsCount, runsCount], Object.assign({}, {testsCount, runsCount}, childrenWeight.metadata));
}
break;
}
case EntityType.Suite: {
if (currentSortExpression.type === SortType.ByName) {
return [treeNode.data.title.join(' ')];
} else if (currentSortExpression.type === SortType.ByFailedRetries) {
return createWeight([treeNode.data.title.join(' ')]);
} else if (currentSortExpression.type === SortType.ByFailedRuns) {
if (!childrenWeight) {
notifyOfUnsuccessfulWeightComputation();
return [0];
return createWeight([0]);
}

return childrenWeight;
} else if (currentSortExpression.type === SortType.ByTestsCount) {
if (!childrenWeight) {
notifyOfUnsuccessfulWeightComputation();
return [0];
return createWeight([0]);
}

return childrenWeight;
Expand All @@ -254,46 +269,80 @@ export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeN
}
case EntityType.Browser: {
if (currentSortExpression.type === SortType.ByName) {
return [treeNode.data.title.join(' ')];
} else if (currentSortExpression.type === SortType.ByFailedRetries) {
return createWeight([treeNode.data.title.join(' ')]);
} else if (currentSortExpression.type === SortType.ByFailedRuns) {
const browser = browsers[treeNode.data.entityId];
const groupId = getGroupId(treeNode.data);

return [browser.resultIds.filter(resultId =>
const failedRunsCount = browser.resultIds.filter(resultId =>
(isFailStatus(results[resultId].status) || isErrorStatus(results[resultId].status)) &&
(!groupId || groups[groupId].resultIds.includes(resultId))
).length];
).length;

return createWeight([failedRunsCount], {failedRunsCount});
} else if (currentSortExpression.type === SortType.ByTestsCount) {
const browser = browsers[treeNode.data.entityId];
const groupId = getGroupId(treeNode.data);
const retriesCount = groupId ? browser.resultIds.filter(resultId => groups[groupId].resultIds.includes(resultId)).length : browser.resultIds.length;
const runsCount = groupId ? browser.resultIds.filter(resultId => groups[groupId].resultIds.includes(resultId)).length : browser.resultIds.length;

return [1, retriesCount];
return createWeight([1, runsCount], {runsCount});
}
break;
}
}

notifyOfUnsuccessfulWeightComputation();
return [0];
return createWeight([0]);
};

const aggregateWeights = (weights: TreeNodeWeight[]): TreeNodeWeight => {
if (!currentSortExpression || currentSortExpression.type === SortType.ByName) {
return [0];
return createWeight([0]);
}

if (currentSortExpression.type === SortType.ByFailedRetries || currentSortExpression.type === SortType.ByTestsCount) {
return weights.reduce((acc, weight) => {
const newAcc = acc.slice(0);
for (let i = 0; i < weight.length; i++) {
newAcc[i] = (acc[i] ?? 0) + weight[i];
if (currentSortExpression.type === SortType.ByFailedRuns || currentSortExpression.type === SortType.ByTestsCount) {
return weights.reduce<TreeNodeWeight>((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;
}
return newAcc;
}, new Array(weights[0]?.length));
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;
}

return newAccWeight;
}, createWeight(new Array(weights[0]?.value?.length)));
}

return createWeight([0]);
};

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 failedRunsCount = weight.metadata.failedRunsCount;
if (failedRunsCount !== undefined) {
tags.push(`${failedRunsCount} ${(failedRunsCount === 1 ? 'failed run' : 'failed runs')}`);
}

return [0];
return tags;
};

// Recursive tree sort. At each level of the tree, it does the following:
Expand All @@ -308,63 +357,44 @@ export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeN
if (treeNode.data.entityType === EntityType.Group && treeNode.children?.length) {
const sortResult = sortAndGetWeight(treeNode.children);

weights[treeNode.data.id] = extractWeight(treeNode, sortResult.weight);
const weight = extractWeight(treeNode, sortResult.weight);

const newTreeNode = Object.assign({}, treeNode, {
children: sortResult.sortedTreeNodes
});
newTreeNode.data.tags.push(...generateTagsForWeight(weight));

const testsCount = weights[treeNode.data.id][1] as number;
const retriesCount = weights[treeNode.data.id][2] as number;
newTreeNode.data.tags.push(`${testsCount} ${(testsCount === 1 ? 'test' : ' tests')}`);
newTreeNode.data.tags.push(`${retriesCount} ${(retriesCount === 1 ? 'retry' : ' retries')}`);

if (currentSortExpression.type === SortType.ByFailedRetries) {
const failedRetriesCount = weights[treeNode.data.id][0] as number;
newTreeNode.data.tags.push(`${failedRetriesCount} ${(failedRetriesCount === 1 ? 'failed retry' : ' failed retries')}`);
}

weights[treeNode.data.id] = weight;
treeNodesCopy[index] = newTreeNode;
} else if (treeNode.data.entityType === EntityType.Suite && treeNode.children?.length) {
const sortResult = sortAndGetWeight(treeNode.children);

weights[treeNode.data.id] = extractWeight(treeNode, sortResult.weight);
const weight = extractWeight(treeNode, sortResult.weight);

const newTreeNode = Object.assign({}, treeNode, {
children: sortResult.sortedTreeNodes
});
newTreeNode.data.tags.push(...generateTagsForWeight(weight));

const retriesCount = Number(sortResult.weight[0]);
if (currentSortExpression?.type === SortType.ByFailedRetries && retriesCount > 0) {
newTreeNode.data.tags.push(`${retriesCount} ${(retriesCount === 1 ? 'failed retry' : 'failed retries')}`);
} else if (currentSortExpression.type === SortType.ByTestsCount) {
const testsCount = weights[treeNode.data.id][0] as number;
const retriesCount = weights[treeNode.data.id][1] as number;
newTreeNode.data.tags.push(`${testsCount} ${(testsCount === 1 ? 'test' : ' tests')}`);
newTreeNode.data.tags.push(`${retriesCount} ${(retriesCount === 1 ? 'retry' : ' retries')}`);
}

weights[treeNode.data.id] = weight;
treeNodesCopy[index] = newTreeNode;
} else if (treeNode.data.entityType === EntityType.Browser) {
const newTreeNode = Object.assign({}, treeNode);
const weight = extractWeight(treeNode);

weights[treeNode.data.id] = extractWeight(treeNode);

const retriesCount = weights[treeNode.data.id][0] as number;
if (currentSortExpression?.type === SortType.ByFailedRetries && retriesCount > 0) {
newTreeNode.data.tags.push(`${retriesCount} ${(retriesCount === 1 ? 'failed retry' : 'failed retries')}`);
}
const newTreeNode = Object.assign({}, treeNode);
newTreeNode.data.tags.push(...generateTagsForWeight(weight));

weights[treeNode.data.id] = weight;
treeNodesCopy[index] = newTreeNode;
}
});

const sortedTreeNodes = treeNodesCopy.sort((a, b): number => {
const direction = currentSortDirection === SortDirection.Desc ? -1 : 1;

for (let i = 0; i < weights[a.data.id].length; i++) {
const aWeight = weights[a.data.id][i];
const bWeight = weights[b.data.id][i];
for (let i = 0; i < weights[a.data.id].value.length; i++) {
const aWeight = weights[a.data.id].value[i];
const bWeight = weights[b.data.id].value[i];
if (aWeight === bWeight) {
continue;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,12 @@
padding: 0 4px;
}

:global(.error) .tag {
:global(.error-tree-node) .tag {
background: var(--g-color-private-red-50);
color: var(--g-color-private-red-500-solid);
}

:global(.current) .tag {
:global(.current-tree-node) .tag {
background-color: rgba(255, 255, 255, .15);
color: rgba(255, 255, 255, .7);
}
Expand Down
2 changes: 1 addition & 1 deletion lib/static/new-ui/types/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ export type GroupByExpression = GroupByMetaExpression | GroupByErrorExpression;

export enum SortType {
ByName,
ByFailedRetries,
ByFailedRuns,
ByTestsCount
}

Expand Down

0 comments on commit 117a268

Please sign in to comment.