diff --git a/packages/playwright/src/isomorphic/testTree.ts b/packages/playwright/src/isomorphic/testTree.ts index 3c523670f5..8f11dea0d7 100644 --- a/packages/playwright/src/isomorphic/testTree.ts +++ b/packages/playwright/src/isomorphic/testTree.ts @@ -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(); - readonly visibleTestIds = new Set(); - readonly fileNames = new Set(); + private _treeItemById = new Map(); + private _treeItemByTestId = new Map(); + readonly pathSeparator: string; - constructor(rootSuite: reporterTypes.Suite | undefined, loadErrors: reporterTypes.TestError[], projectFilters: Map) { - const filterProjects = [...projectFilters.values()].some(Boolean); + constructor(rootFolder: string, rootSuite: reporterTypes.Suite | undefined, loadErrors: reporterTypes.TestError[], projectFilters: Map | 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 || ''; 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(); 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, runningTestIds: Set | 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): 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 { + const result = new Set(); 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(); + 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 { @@ -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'); diff --git a/packages/playwright/types/testReporter.d.ts b/packages/playwright/types/testReporter.d.ts index 728f5299ef..ed6f62e71d 100644 --- a/packages/playwright/types/testReporter.d.ts +++ b/packages/playwright/types/testReporter.d.ts @@ -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: diff --git a/packages/trace-viewer/src/ui/teleSuiteUpdater.ts b/packages/trace-viewer/src/ui/teleSuiteUpdater.ts index 50abfbf276..82e7ab2913 100644 --- a/packages/trace-viewer/src/ui/teleSuiteUpdater.ts +++ b/packages/trace-viewer/src/ui/teleSuiteUpdater.ts @@ -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, }); }, diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index c7406fb49f..0d7bc46bad 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -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(); 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 => { onError: error => { xtermDataSource.write((error.stack || error.value || '') + '\n'); }, + pathSeparator, }); return sendMessage('list', {}); }; @@ -681,3 +681,5 @@ async function loadSingleTraceFile(url: string): Promise { const contextEntries = await response.json() as ContextEntry[]; return new MultiTraceModel(contextEntries); } + +export const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/'; diff --git a/utils/generate_types/overrides-testReporter.d.ts b/utils/generate_types/overrides-testReporter.d.ts index db8ae7c6de..9aeb9c9d31 100644 --- a/utils/generate_types/overrides-testReporter.d.ts +++ b/utils/generate_types/overrides-testReporter.d.ts @@ -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;