chore: align test tree with vscode (#29864)

This commit is contained in:
Pavel Feldman 2024-03-14 15:44:35 -07:00 committed by GitHub
parent 23bfeec5c7
commit 94348bb3c5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 94 additions and 51 deletions

View File

@ -39,28 +39,31 @@ export type TestCaseItem = TreeItemBase & {
kind: 'case',
tests: reporterTypes.TestCase[];
children: TestItem[];
test: reporterTypes.TestCase | undefined;
project: reporterTypes.FullProject | undefined;
};
export type TestItem = TreeItemBase & {
kind: 'test',
test: reporterTypes.TestCase;
project: string;
project: reporterTypes.FullProject;
};
export type TreeItem = GroupItem | TestCaseItem | TestItem;
export class TestTree {
rootItem: GroupItem;
readonly treeItemMap = new Map<string, TreeItem>();
readonly visibleTestIds = new Set<string>();
readonly fileNames = new Set<string>();
private _treeItemById = new Map<string, TreeItem>();
private _treeItemByTestId = new Map<string, TestItem | TestCaseItem>();
readonly pathSeparator: string;
constructor(rootSuite: reporterTypes.Suite | undefined, loadErrors: reporterTypes.TestError[], projectFilters: Map<string, boolean>) {
const filterProjects = [...projectFilters.values()].some(Boolean);
constructor(rootFolder: string, rootSuite: reporterTypes.Suite | undefined, loadErrors: reporterTypes.TestError[], projectFilters: Map<string, boolean> | undefined, pathSeparator: string) {
const filterProjects = projectFilters && [...projectFilters.values()].some(Boolean);
this.pathSeparator = pathSeparator;
this.rootItem = {
kind: 'group',
subKind: 'folder',
id: 'root',
id: rootFolder,
title: '',
location: { file: '', line: 0, column: 0 },
duration: 0,
@ -69,8 +72,9 @@ export class TestTree {
status: 'none',
hasLoadErrors: false,
};
this._treeItemById.set(rootFolder, this.rootItem);
const visitSuite = (projectName: string, parentSuite: reporterTypes.Suite, parentGroup: GroupItem) => {
const visitSuite = (project: reporterTypes.FullProject, parentSuite: reporterTypes.Suite, parentGroup: GroupItem) => {
for (const suite of parentSuite.suites) {
const title = suite.title || '<anonymous>';
let group = parentGroup.children.find(item => item.kind === 'group' && item.title === title) as GroupItem | undefined;
@ -87,9 +91,9 @@ export class TestTree {
status: 'none',
hasLoadErrors: false,
};
parentGroup.children.push(group);
this._addChild(parentGroup, group);
}
visitSuite(projectName, suite, group);
visitSuite(project, suite, group);
}
for (const test of parentSuite.tests) {
@ -106,8 +110,10 @@ export class TestTree {
location: test.location,
duration: 0,
status: 'none',
project: undefined,
test: undefined,
};
parentGroup.children.push(testCaseItem);
this._addChild(parentGroup, testCaseItem);
}
const result = test.results[0];
@ -126,40 +132,47 @@ export class TestTree {
status = 'passed';
testCaseItem.tests.push(test);
testCaseItem.children.push({
const testItem: TestItem = {
kind: 'test',
id: test.id,
title: projectName,
title: project.name,
location: test.location!,
test,
parent: testCaseItem,
children: [],
status,
duration: test.results.length ? Math.max(0, test.results[0].duration) : 0,
project: projectName,
});
project,
};
this._addChild(testCaseItem, testItem);
this._treeItemByTestId.set(test.id, testItem);
testCaseItem.duration = (testCaseItem.children as TestItem[]).reduce((a, b) => a + b.duration, 0);
}
};
const fileMap = new Map<string, GroupItem>();
for (const projectSuite of rootSuite?.suites || []) {
if (filterProjects && !projectFilters.get(projectSuite.title))
continue;
for (const fileSuite of projectSuite.suites) {
const fileItem = this._fileItem(fileSuite.location!.file.split(pathSeparator), true, fileMap);
visitSuite(projectSuite.title, fileSuite, fileItem);
const fileItem = this._fileItem(fileSuite.location!.file.split(pathSeparator), true);
visitSuite(projectSuite.project()!, fileSuite, fileItem);
}
}
for (const loadError of loadErrors) {
if (!loadError.location)
continue;
const fileItem = this._fileItem(loadError.location.file.split(pathSeparator), true, fileMap);
const fileItem = this._fileItem(loadError.location.file.split(pathSeparator), true);
fileItem.hasLoadErrors = true;
}
}
private _addChild(parent: TreeItem, child: TreeItem) {
parent.children.push(child);
child.parent = parent;
this._treeItemById.set(child.id, child);
}
filterTree(filterText: string, statusFilters: Map<string, boolean>, runningTestIds: Set<string> | undefined) {
const tokens = filterText.trim().toLowerCase().split(' ');
const filtersStatuses = [...statusFilters.values()].some(Boolean);
@ -192,14 +205,14 @@ export class TestTree {
visit(this.rootItem);
}
private _fileItem(filePath: string[], isFile: boolean, fileItems: Map<string, GroupItem>): GroupItem {
private _fileItem(filePath: string[], isFile: boolean): GroupItem {
if (filePath.length === 0)
return this.rootItem;
const fileName = filePath.join(pathSeparator);
const existingFileItem = fileItems.get(fileName);
const fileName = filePath.join(this.pathSeparator);
const existingFileItem = this._treeItemById.get(fileName);
if (existingFileItem)
return existingFileItem;
const parentFileItem = this._fileItem(filePath.slice(0, filePath.length - 1), false, fileItems);
return existingFileItem as GroupItem;
const parentFileItem = this._fileItem(filePath.slice(0, filePath.length - 1), false);
const fileItem: GroupItem = {
kind: 'group',
subKind: isFile ? 'file' : 'folder',
@ -212,8 +225,7 @@ export class TestTree {
status: 'none',
hasLoadErrors: false,
};
parentFileItem.children.push(fileItem);
fileItems.set(fileName, fileItem);
this._addChild(parentFileItem, fileItem);
return fileItem;
}
@ -221,12 +233,16 @@ export class TestTree {
sortAndPropagateStatus(this.rootItem);
}
hideOnlyTests() {
flattenForSingleProject() {
const visit = (treeItem: TreeItem) => {
if (treeItem.kind === 'case' && treeItem.children.length === 1)
if (treeItem.kind === 'case' && treeItem.children.length === 1) {
treeItem.project = treeItem.children[0].project;
treeItem.test = treeItem.children[0].test;
treeItem.children = [];
else
this._treeItemByTestId.set(treeItem.test.id, treeItem);
} else {
treeItem.children.forEach(visit);
}
};
visit(this.rootItem);
}
@ -239,16 +255,41 @@ export class TestTree {
this.rootItem = shortRoot;
}
indexTree() {
testIds(): Set<string> {
const result = new Set<string>();
const visit = (treeItem: TreeItem) => {
if (treeItem.kind === 'group' && treeItem.location.file)
this.fileNames.add(treeItem.location.file);
if (treeItem.kind === 'case')
treeItem.tests.forEach(t => this.visibleTestIds.add(t.id));
treeItem.tests.forEach(t => result.add(t.id));
treeItem.children.forEach(visit);
this.treeItemMap.set(treeItem.id, treeItem);
};
visit(this.rootItem);
return result;
}
fileNames(): string[] {
const result = new Set<string>();
const visit = (treeItem: TreeItem) => {
if (treeItem.kind === 'group' && treeItem.subKind === 'file')
result.add(treeItem.id);
else
treeItem.children.forEach(visit);
};
visit(this.rootItem);
return [...result];
}
flatTreeItems(): TreeItem[] {
const result: TreeItem[] = [];
const visit = (treeItem: TreeItem) => {
result.push(treeItem);
treeItem.children.forEach(visit);
};
visit(this.rootItem);
return result;
}
treeItemById(id: string): TreeItem | undefined {
return this._treeItemById.get(id);
}
collectTestIds(treeItem?: TreeItem): Set<string> {
@ -312,5 +353,4 @@ export function sortAndPropagateStatus(treeItem: TreeItem) {
treeItem.status = 'passed';
}
export const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/';
export const statusEx = Symbol('statusEx');

View File

@ -16,7 +16,7 @@
*/
import type { FullConfig, FullProject, TestStatus, Metadata } from './test';
export type { FullConfig, TestStatus } from './test';
export type { FullConfig, TestStatus, FullProject } from './test';
/**
* `Suite` is a group of tests. All tests in Playwright Test form the following hierarchy:

View File

@ -15,7 +15,7 @@
*/
import { TeleReporterReceiver } from '@testIsomorphic/teleReceiver';
import { pathSeparator, statusEx } from '@testIsomorphic/testTree';
import { statusEx } from '@testIsomorphic/testTree';
import type { ReporterV2 } from 'playwright/src/reporters/reporterV2';
import type * as reporterTypes from 'playwright/types/testReporter';
@ -28,7 +28,8 @@ export type Progress = {
export type TeleSuiteUpdaterOptions = {
onUpdate: (source: TeleSuiteUpdater, force?: boolean) => void,
onError?: (error: reporterTypes.TestError) => void
onError?: (error: reporterTypes.TestError) => void;
pathSeparator: string;
};
export class TeleSuiteUpdater {
@ -51,7 +52,7 @@ export class TeleSuiteUpdater {
this._receiver = new TeleReporterReceiver(this._createReporter(), {
mergeProjects: true,
mergeTestCases: true,
resolvePath: (rootDir, relativePath) => rootDir + pathSeparator + relativePath,
resolvePath: (rootDir, relativePath) => rootDir + options.pathSeparator + relativePath,
clearPreviousResultsWhenTestBegins: true,
});
this._options = options;
@ -75,7 +76,7 @@ export class TeleSuiteUpdater {
}, {
mergeProjects: true,
mergeTestCases: false,
resolvePath: (rootDir, relativePath) => rootDir + pathSeparator + relativePath,
resolvePath: (rootDir, relativePath) => rootDir + this._options.pathSeparator + relativePath,
});
},

View File

@ -379,13 +379,12 @@ const TestList: React.FC<{
// Build the test tree.
const { testTree } = React.useMemo(() => {
const testTree = new TestTree(testModel.rootSuite, testModel.loadErrors, projectFilters);
const testTree = new TestTree('', testModel.rootSuite, testModel.loadErrors, projectFilters, pathSeparator);
testTree.filterTree(filterText, statusFilters, runningState?.testIds);
testTree.sortAndPropagateStatus();
testTree.shortenRoot();
testTree.hideOnlyTests();
testTree.indexTree();
setVisibleTestIds(testTree.visibleTestIds);
testTree.flattenForSingleProject();
setVisibleTestIds(testTree.testIds());
return { testTree };
}, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds, runningState]);
@ -394,8 +393,8 @@ const TestList: React.FC<{
// If collapse was requested, clear the expanded items and return w/o selected item.
if (collapseAllCount !== requestedCollapseAllCount) {
treeState.expandedItems.clear();
for (const item of testTree.treeItemMap.keys())
treeState.expandedItems.set(item, false);
for (const item of testTree.flatTreeItems())
treeState.expandedItems.set(item.id, false);
setCollapseAllCount(requestedCollapseAllCount);
setSelectedTreeItemId(undefined);
setTreeState({ ...treeState });
@ -424,7 +423,7 @@ const TestList: React.FC<{
// Compute selected item.
const { selectedTreeItem } = React.useMemo(() => {
const selectedTreeItem = selectedTreeItemId ? testTree.treeItemMap.get(selectedTreeItemId) : undefined;
const selectedTreeItem = selectedTreeItemId ? testTree.treeItemById(selectedTreeItemId) : undefined;
let testFile: SourceLocation | undefined;
if (selectedTreeItem) {
testFile = {
@ -450,11 +449,11 @@ const TestList: React.FC<{
if (isLoading)
return;
if (watchAll) {
sendMessageNoReply('watch', { fileNames: [...testTree.fileNames] });
sendMessageNoReply('watch', { fileNames: testTree.fileNames() });
} else {
const fileNames = new Set<string>();
for (const itemId of watchedTreeIds.value) {
const treeItem = testTree.treeItemMap.get(itemId);
const treeItem = testTree.treeItemById(itemId);
const fileName = treeItem?.location.file;
if (fileName)
fileNames.add(fileName);
@ -482,7 +481,7 @@ const TestList: React.FC<{
visit(testTree.rootItem);
} else {
for (const treeId of watchedTreeIds.value) {
const treeItem = testTree.treeItemMap.get(treeId);
const treeItem = testTree.treeItemById(treeId);
const fileName = treeItem?.location.file;
if (fileName && set.has(fileName))
testIds.push(...testTree.collectTestIds(treeItem));
@ -624,6 +623,7 @@ const refreshRootSuite = (): Promise<void> => {
onError: error => {
xtermDataSource.write((error.stack || error.value || '') + '\n');
},
pathSeparator,
});
return sendMessage('list', {});
};
@ -681,3 +681,5 @@ async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
const contextEntries = await response.json() as ContextEntry[];
return new MultiTraceModel(contextEntries);
}
export const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/';

View File

@ -15,7 +15,7 @@
*/
import type { FullConfig, FullProject, TestStatus, Metadata } from './test';
export type { FullConfig, TestStatus } from './test';
export type { FullConfig, TestStatus, FullProject } from './test';
export interface Suite {
project(): FullProject | undefined;