From 8bf8091cb148e4150e4e4a7c5897939091681638 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 5 Mar 2024 15:11:56 -0800 Subject: [PATCH] chore: extract the tele test tree (#29824) --- .../playwright/src/isomorphic/teleReceiver.ts | 8 +- .../playwright/src/isomorphic/testTree.ts | 316 ++++++++++++ .../trace-viewer/src/ui/teleSuiteUpdater.ts | 137 +++++ packages/trace-viewer/src/ui/uiModeView.tsx | 483 ++---------------- 4 files changed, 509 insertions(+), 435 deletions(-) create mode 100644 packages/playwright/src/isomorphic/testTree.ts create mode 100644 packages/trace-viewer/src/ui/teleSuiteUpdater.ts diff --git a/packages/playwright/src/isomorphic/teleReceiver.ts b/packages/playwright/src/isomorphic/teleReceiver.ts index db573a0f6f..97a2bfa709 100644 --- a/packages/playwright/src/isomorphic/teleReceiver.ts +++ b/packages/playwright/src/isomorphic/teleReceiver.ts @@ -126,6 +126,7 @@ type TeleReporterReceiverOptions = { mergeTestCases: boolean; resolvePath: (rootDir: string, relativePath: string) => string; configOverrides?: Pick; + clearPreviousResultsWhenTestBegins?: boolean; }; export class TeleReporterReceiver { @@ -135,7 +136,6 @@ export class TeleReporterReceiver { private _tests = new Map(); private _rootDir!: string; private _listOnly = false; - private _clearPreviousResultsWhenTestBegins: boolean = false; private _config!: reporterTypes.FullConfig; constructor(reporter: Partial, options: TeleReporterReceiverOptions) { @@ -189,10 +189,6 @@ export class TeleReporterReceiver { return this._onExit(); } - _setClearPreviousResultsWhenTestBegins() { - this._clearPreviousResultsWhenTestBegins = true; - } - private _onConfigure(config: JsonConfig) { this._rootDir = config.rootDir; this._config = this._parseConfig(config); @@ -234,7 +230,7 @@ export class TeleReporterReceiver { private _onTestBegin(testId: string, payload: JsonTestResultStart) { const test = this._tests.get(testId)!; - if (this._clearPreviousResultsWhenTestBegins) + if (this._options.clearPreviousResultsWhenTestBegins) test._clearResults(); const testResult = test._createTestResult(payload.id); testResult.retry = payload.retry; diff --git a/packages/playwright/src/isomorphic/testTree.ts b/packages/playwright/src/isomorphic/testTree.ts new file mode 100644 index 0000000000..3c523670f5 --- /dev/null +++ b/packages/playwright/src/isomorphic/testTree.ts @@ -0,0 +1,316 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type TestItemStatus = 'none' | 'running' | 'scheduled' | 'passed' | 'failed' | 'skipped'; +import type * as reporterTypes from '../../types/testReporter'; + +export type TreeItemBase = { + kind: 'root' | 'group' | 'case' | 'test', + id: string; + title: string; + location: reporterTypes.Location, + duration: number; + parent: TreeItem | undefined; + children: TreeItem[]; + status: TestItemStatus; +}; + +export type GroupItem = TreeItemBase & { + kind: 'group'; + subKind: 'folder' | 'file' | 'describe'; + hasLoadErrors: boolean; + children: (TestCaseItem | GroupItem)[]; +}; + +export type TestCaseItem = TreeItemBase & { + kind: 'case', + tests: reporterTypes.TestCase[]; + children: TestItem[]; +}; + +export type TestItem = TreeItemBase & { + kind: 'test', + test: reporterTypes.TestCase; + project: string; +}; + +export type TreeItem = GroupItem | TestCaseItem | TestItem; + +export class TestTree { + rootItem: GroupItem; + readonly treeItemMap = new Map(); + readonly visibleTestIds = new Set(); + readonly fileNames = new Set(); + + constructor(rootSuite: reporterTypes.Suite | undefined, loadErrors: reporterTypes.TestError[], projectFilters: Map) { + const filterProjects = [...projectFilters.values()].some(Boolean); + this.rootItem = { + kind: 'group', + subKind: 'folder', + id: 'root', + title: '', + location: { file: '', line: 0, column: 0 }, + duration: 0, + parent: undefined, + children: [], + status: 'none', + hasLoadErrors: false, + }; + + const visitSuite = (projectName: string, 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; + if (!group) { + group = { + kind: 'group', + subKind: 'describe', + id: 'suite:' + parentSuite.titlePath().join('\x1e') + '\x1e' + title, // account for anonymous suites + title, + location: suite.location!, + duration: 0, + parent: parentGroup, + children: [], + status: 'none', + hasLoadErrors: false, + }; + parentGroup.children.push(group); + } + visitSuite(projectName, suite, group); + } + + for (const test of parentSuite.tests) { + const title = test.title; + let testCaseItem = parentGroup.children.find(t => t.kind !== 'group' && t.title === title) as TestCaseItem; + if (!testCaseItem) { + testCaseItem = { + kind: 'case', + id: 'test:' + test.titlePath().join('\x1e'), + title, + parent: parentGroup, + children: [], + tests: [], + location: test.location, + duration: 0, + status: 'none', + }; + parentGroup.children.push(testCaseItem); + } + + const result = test.results[0]; + let status: 'none' | 'running' | 'scheduled' | 'passed' | 'failed' | 'skipped' = 'none'; + if ((result as any)?.[statusEx] === 'scheduled') + status = 'scheduled'; + else if ((result as any)?.[statusEx] === 'running') + status = 'running'; + else if (result?.status === 'skipped') + status = 'skipped'; + else if (result?.status === 'interrupted') + status = 'none'; + else if (result && test.outcome() !== 'expected') + status = 'failed'; + else if (result && test.outcome() === 'expected') + status = 'passed'; + + testCaseItem.tests.push(test); + testCaseItem.children.push({ + kind: 'test', + id: test.id, + title: projectName, + location: test.location!, + test, + parent: testCaseItem, + children: [], + status, + duration: test.results.length ? Math.max(0, test.results[0].duration) : 0, + project: projectName, + }); + 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); + } + } + + for (const loadError of loadErrors) { + if (!loadError.location) + continue; + const fileItem = this._fileItem(loadError.location.file.split(pathSeparator), true, fileMap); + fileItem.hasLoadErrors = true; + } + } + + filterTree(filterText: string, statusFilters: Map, runningTestIds: Set | undefined) { + const tokens = filterText.trim().toLowerCase().split(' '); + const filtersStatuses = [...statusFilters.values()].some(Boolean); + + const filter = (testCase: TestCaseItem) => { + const titleWithTags = [...testCase.tests[0].titlePath(), ...testCase.tests[0].tags].join(' ').toLowerCase(); + if (!tokens.every(token => titleWithTags.includes(token)) && !testCase.tests.some(t => runningTestIds?.has(t.id))) + return false; + testCase.children = (testCase.children as TestItem[]).filter(test => { + return !filtersStatuses || runningTestIds?.has(test.test.id) || statusFilters.get(test.status); + }); + testCase.tests = (testCase.children as TestItem[]).map(c => c.test); + return !!testCase.children.length; + }; + + const visit = (treeItem: GroupItem) => { + const newChildren: (GroupItem | TestCaseItem)[] = []; + for (const child of treeItem.children) { + if (child.kind === 'case') { + if (filter(child)) + newChildren.push(child); + } else { + visit(child); + if (child.children.length || child.hasLoadErrors) + newChildren.push(child); + } + } + treeItem.children = newChildren; + }; + visit(this.rootItem); + } + + private _fileItem(filePath: string[], isFile: boolean, fileItems: Map): GroupItem { + if (filePath.length === 0) + return this.rootItem; + const fileName = filePath.join(pathSeparator); + const existingFileItem = fileItems.get(fileName); + if (existingFileItem) + return existingFileItem; + const parentFileItem = this._fileItem(filePath.slice(0, filePath.length - 1), false, fileItems); + const fileItem: GroupItem = { + kind: 'group', + subKind: isFile ? 'file' : 'folder', + id: fileName, + title: filePath[filePath.length - 1], + location: { file: fileName, line: 0, column: 0 }, + duration: 0, + parent: parentFileItem, + children: [], + status: 'none', + hasLoadErrors: false, + }; + parentFileItem.children.push(fileItem); + fileItems.set(fileName, fileItem); + return fileItem; + } + + sortAndPropagateStatus() { + sortAndPropagateStatus(this.rootItem); + } + + hideOnlyTests() { + const visit = (treeItem: TreeItem) => { + if (treeItem.kind === 'case' && treeItem.children.length === 1) + treeItem.children = []; + else + treeItem.children.forEach(visit); + }; + visit(this.rootItem); + } + + shortenRoot() { + let shortRoot = this.rootItem; + while (shortRoot.children.length === 1 && shortRoot.children[0].kind === 'group' && shortRoot.children[0].subKind === 'folder') + shortRoot = shortRoot.children[0]; + shortRoot.location = this.rootItem.location; + this.rootItem = shortRoot; + } + + indexTree() { + 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.children.forEach(visit); + this.treeItemMap.set(treeItem.id, treeItem); + }; + visit(this.rootItem); + } + + collectTestIds(treeItem?: TreeItem): Set { + const testIds = new Set(); + if (!treeItem) + return testIds; + + const visit = (treeItem: TreeItem) => { + if (treeItem.kind === 'case') + treeItem.tests.map(t => t.id).forEach(id => testIds.add(id)); + else if (treeItem.kind === 'test') + testIds.add(treeItem.id); + else + treeItem.children?.forEach(visit); + }; + visit(treeItem); + return testIds; + } + + locationToOpen(treeItem?: TreeItem) { + if (!treeItem) + return; + return treeItem.location.file + ':' + treeItem.location.line; + } +} + +export function sortAndPropagateStatus(treeItem: TreeItem) { + for (const child of treeItem.children) + sortAndPropagateStatus(child); + + if (treeItem.kind === 'group') { + treeItem.children.sort((a, b) => { + const fc = a.location.file.localeCompare(b.location.file); + return fc || a.location.line - b.location.line; + }); + } + + let allPassed = treeItem.children.length > 0; + let allSkipped = treeItem.children.length > 0; + let hasFailed = false; + let hasRunning = false; + let hasScheduled = false; + + for (const child of treeItem.children) { + allSkipped = allSkipped && child.status === 'skipped'; + allPassed = allPassed && (child.status === 'passed' || child.status === 'skipped'); + hasFailed = hasFailed || child.status === 'failed'; + hasRunning = hasRunning || child.status === 'running'; + hasScheduled = hasScheduled || child.status === 'scheduled'; + } + + if (hasRunning) + treeItem.status = 'running'; + else if (hasScheduled) + treeItem.status = 'scheduled'; + else if (hasFailed) + treeItem.status = 'failed'; + else if (allSkipped) + treeItem.status = 'skipped'; + else if (allPassed) + treeItem.status = 'passed'; +} + +export const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/'; +export const statusEx = Symbol('statusEx'); diff --git a/packages/trace-viewer/src/ui/teleSuiteUpdater.ts b/packages/trace-viewer/src/ui/teleSuiteUpdater.ts new file mode 100644 index 0000000000..50abfbf276 --- /dev/null +++ b/packages/trace-viewer/src/ui/teleSuiteUpdater.ts @@ -0,0 +1,137 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TeleReporterReceiver } from '@testIsomorphic/teleReceiver'; +import { pathSeparator, statusEx } from '@testIsomorphic/testTree'; +import type { ReporterV2 } from 'playwright/src/reporters/reporterV2'; +import type * as reporterTypes from 'playwright/types/testReporter'; + +export type Progress = { + total: number; + passed: number; + failed: number; + skipped: number; +}; + +export type TeleSuiteUpdaterOptions = { + onUpdate: (source: TeleSuiteUpdater, force?: boolean) => void, + onError?: (error: reporterTypes.TestError) => void +}; + +export class TeleSuiteUpdater { + rootSuite: reporterTypes.Suite | undefined; + config: reporterTypes.FullConfig | undefined; + readonly loadErrors: reporterTypes.TestError[] = []; + readonly progress: Progress = { + total: 0, + passed: 0, + failed: 0, + skipped: 0, + }; + + private _receiver: TeleReporterReceiver; + private _lastRunReceiver: TeleReporterReceiver | undefined; + private _lastRunTestCount = 0; + private _options: TeleSuiteUpdaterOptions; + + constructor(options: TeleSuiteUpdaterOptions) { + this._receiver = new TeleReporterReceiver(this._createReporter(), { + mergeProjects: true, + mergeTestCases: true, + resolvePath: (rootDir, relativePath) => rootDir + pathSeparator + relativePath, + clearPreviousResultsWhenTestBegins: true, + }); + this._options = options; + } + + private _createReporter(): ReporterV2 { + return { + version: () => 'v2', + + onConfigure: (c: reporterTypes.FullConfig) => { + this.config = c; + // TeleReportReceiver is merging everything into a single suite, so when we + // run one test, we still get many tests via rootSuite.allTests().length. + // To work around that, have a dedicated per-run receiver that will only have + // suite for a single test run, and hence will have correct total. + this._lastRunReceiver = new TeleReporterReceiver({ + onBegin: (suite: reporterTypes.Suite) => { + this._lastRunTestCount = suite.allTests().length; + this._lastRunReceiver = undefined; + } + }, { + mergeProjects: true, + mergeTestCases: false, + resolvePath: (rootDir, relativePath) => rootDir + pathSeparator + relativePath, + }); + }, + + onBegin: (suite: reporterTypes.Suite) => { + if (!this.rootSuite) + this.rootSuite = suite; + this.progress.total = this._lastRunTestCount; + this.progress.passed = 0; + this.progress.failed = 0; + this.progress.skipped = 0; + this._options.onUpdate(this, true); + }, + + onEnd: () => { + this._options.onUpdate(this, true); + }, + + onTestBegin: (test: reporterTypes.TestCase, testResult: reporterTypes.TestResult) => { + (testResult as any)[statusEx] = 'running'; + this._options.onUpdate(this); + }, + + onTestEnd: (test: reporterTypes.TestCase, testResult: reporterTypes.TestResult) => { + if (test.outcome() === 'skipped') + ++this.progress.skipped; + else if (test.outcome() === 'unexpected') + ++this.progress.failed; + else + ++this.progress.passed; + (testResult as any)[statusEx] = testResult.status; + this._options.onUpdate(this); + }, + + onError: (error: reporterTypes.TestError) => { + this.loadErrors.push(error); + this._options.onError?.(error); + this._options.onUpdate(this); + }, + + printsToStdio: () => { + return false; + }, + + onStdOut: () => {}, + onStdErr: () => {}, + onExit: () => {}, + onStepBegin: () => {}, + onStepEnd: () => {}, + }; + } + + dispatch(mode: 'test' | 'list', message: any) { + // The order of receiver dispatches matters here, we want to assign `lastRunTestCount` + // before we use it. + if (mode === 'test') + this._lastRunReceiver?.dispatch('test', message)?.catch(() => {}); + this._receiver.dispatch(mode, message)?.catch(() => {}); + } +} diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 8e5bb3831b..164167f272 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -20,9 +20,11 @@ import '@web/common.css'; import React from 'react'; import { TreeView } from '@web/components/treeView'; import type { TreeState } from '@web/components/treeView'; -import { baseFullConfig, TeleReporterReceiver, TeleSuite } from '@testIsomorphic/teleReceiver'; +import { baseFullConfig, TeleSuite } from '@testIsomorphic/teleReceiver'; +import { TeleSuiteUpdater } from './teleSuiteUpdater'; +import type { Progress } from './teleSuiteUpdater'; import type { TeleTestCase } from '@testIsomorphic/teleReceiver'; -import type { FullConfig, Suite, TestCase, Location, TestError, TestResult } from 'playwright/types/testReporter'; +import type * as reporterTypes from 'playwright/types/testReporter'; import { SplitView } from '@web/components/splitView'; import { idForAction, MultiTraceModel } from './modelUtil'; import type { SourceLocation } from './modelUtil'; @@ -38,10 +40,11 @@ import { artifactsFolderName } from '@testIsomorphic/folders'; import { msToString, settings, useSetting } from '@web/uiUtils'; import type { ActionTraceEvent } from '@trace/trace'; import { connect } from './wsPort'; +import { statusEx, TestTree } from '@testIsomorphic/testTree'; +import type { TreeItem } from '@testIsomorphic/testTree'; import { testStatusIcon } from './testUtils'; -import type { UITestStatus } from './testUtils'; -let updateRootSuite: (config: FullConfig, rootSuite: Suite, loadErrors: TestError[], progress: Progress | undefined) => void = () => {}; +let updateRootSuite: (config: reporterTypes.FullConfig, rootSuite: reporterTypes.Suite, loadErrors: reporterTypes.TestError[], progress: Progress | undefined) => void = () => {}; let runWatchedTests = (fileNames: string[]) => {}; let xtermSize = { cols: 80, rows: 24 }; @@ -58,9 +61,9 @@ const xtermDataSource: XtermDataSource = { }; type TestModel = { - config: FullConfig | undefined; - rootSuite: Suite | undefined; - loadErrors: TestError[]; + config: reporterTypes.FullConfig | undefined; + rootSuite: reporterTypes.Suite | undefined; + loadErrors: reporterTypes.TestError[]; }; export const UIModeView: React.FC<{}> = ({ @@ -76,7 +79,7 @@ export const UIModeView: React.FC<{}> = ({ const [projectFilters, setProjectFilters] = React.useState>(new Map()); const [testModel, setTestModel] = React.useState({ config: undefined, rootSuite: undefined, loadErrors: [] }); const [progress, setProgress] = React.useState(); - const [selectedItem, setSelectedItem] = React.useState<{ treeItem?: TreeItem, testFile?: SourceLocation, testCase?: TestCase }>({}); + const [selectedItem, setSelectedItem] = React.useState<{ treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase }>({}); const [visibleTestIds, setVisibleTestIds] = React.useState>(new Set()); const [isLoading, setIsLoading] = React.useState(false); const [runningState, setRunningState] = React.useState<{ testIds: Set, itemSelectedByUser?: boolean } | undefined>(); @@ -94,7 +97,7 @@ export const UIModeView: React.FC<{}> = ({ setIsLoading(true); setWatchedTreeIds({ value: new Set() }); updateRootSuite(baseFullConfig, new TeleSuite('', 'root'), [], undefined); - refreshRootSuite(true).then(async () => { + refreshRootSuite().then(async () => { setIsLoading(false); const { hasBrowsers } = await sendMessage('checkBrowsers'); setHasBrowsers(hasBrowsers); @@ -114,7 +117,7 @@ export const UIModeView: React.FC<{}> = ({ }); }, [reloadTests]); - updateRootSuite = React.useCallback((config: FullConfig, rootSuite: Suite, loadErrors: TestError[], newProgress: Progress | undefined) => { + updateRootSuite = React.useCallback((config: reporterTypes.FullConfig, rootSuite: reporterTypes.Suite, loadErrors: reporterTypes.TestError[], newProgress: Progress | undefined) => { const selectedProjects = config.configFile ? settings.getObject(config.configFile + ':projects', undefined) : undefined; for (const projectName of projectFilters.keys()) { if (!rootSuite.suites.find(s => s.title === projectName)) @@ -367,7 +370,7 @@ const TestList: React.FC<{ setWatchedTreeIds: (ids: { value: Set }) => void, isLoading?: boolean, setVisibleTestIds: (testIds: Set) => void, - onItemSelected: (item: { treeItem?: TreeItem, testCase?: TestCase, testFile?: SourceLocation }) => void, + onItemSelected: (item: { treeItem?: TreeItem, testCase?: reporterTypes.TestCase, testFile?: SourceLocation }) => void, requestedCollapseAllCount: number, }> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, setVisibleTestIds, requestedCollapseAllCount }) => { const [treeState, setTreeState] = React.useState({ expandedItems: new Map() }); @@ -375,27 +378,15 @@ const TestList: React.FC<{ const [collapseAllCount, setCollapseAllCount] = React.useState(requestedCollapseAllCount); // Build the test tree. - const { rootItem, treeItemMap, fileNames } = React.useMemo(() => { - let rootItem = createTree(testModel.rootSuite, testModel.loadErrors, projectFilters); - filterTree(rootItem, filterText, statusFilters, runningState?.testIds); - sortAndPropagateStatus(rootItem); - rootItem = shortenRoot(rootItem); - - hideOnlyTests(rootItem); - const treeItemMap = new Map(); - const visibleTestIds = new Set(); - const fileNames = new Set(); - const visit = (treeItem: TreeItem) => { - if (treeItem.kind === 'group' && treeItem.location.file) - fileNames.add(treeItem.location.file); - if (treeItem.kind === 'case') - treeItem.tests.forEach(t => visibleTestIds.add(t.id)); - treeItem.children.forEach(visit); - treeItemMap.set(treeItem.id, treeItem); - }; - visit(rootItem); - setVisibleTestIds(visibleTestIds); - return { rootItem, treeItemMap, fileNames }; + const { testTree } = React.useMemo(() => { + const testTree = new TestTree(testModel.rootSuite, testModel.loadErrors, projectFilters); + testTree.filterTree(filterText, statusFilters, runningState?.testIds); + testTree.sortAndPropagateStatus(); + testTree.shortenRoot(); + testTree.hideOnlyTests(); + testTree.indexTree(); + setVisibleTestIds(testTree.visibleTestIds); + return { testTree }; }, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds, runningState]); // Look for a first failure within the run batch to select it. @@ -403,7 +394,7 @@ 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 treeItemMap.keys()) + for (const item of testTree.treeItemMap.keys()) treeState.expandedItems.set(item, false); setCollapseAllCount(requestedCollapseAllCount); setSelectedTreeItemId(undefined); @@ -425,15 +416,15 @@ const TestList: React.FC<{ selectedTreeItem = treeItem; } }; - visit(rootItem); + visit(testTree.rootItem); if (selectedTreeItem) setSelectedTreeItemId(selectedTreeItem.id); - }, [runningState, setSelectedTreeItemId, rootItem, collapseAllCount, setCollapseAllCount, requestedCollapseAllCount, treeState, setTreeState, treeItemMap]); + }, [runningState, setSelectedTreeItemId, testTree, collapseAllCount, setCollapseAllCount, requestedCollapseAllCount, treeState, setTreeState]); // Compute selected item. const { selectedTreeItem } = React.useMemo(() => { - const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined; + const selectedTreeItem = selectedTreeItemId ? testTree.treeItemMap.get(selectedTreeItemId) : undefined; let testFile: SourceLocation | undefined; if (selectedTreeItem) { testFile = { @@ -445,36 +436,36 @@ const TestList: React.FC<{ } }; } - let selectedTest: TestCase | undefined; + let selectedTest: reporterTypes.TestCase | undefined; if (selectedTreeItem?.kind === 'test') selectedTest = selectedTreeItem.test; else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.tests.length === 1) selectedTest = selectedTreeItem.tests[0]; onItemSelected({ treeItem: selectedTreeItem, testCase: selectedTest, testFile }); return { selectedTreeItem }; - }, [onItemSelected, selectedTreeItemId, testModel, treeItemMap]); + }, [onItemSelected, selectedTreeItemId, testModel, testTree]); // Update watch all. React.useEffect(() => { if (isLoading) return; if (watchAll) { - sendMessageNoReply('watch', { fileNames: [...fileNames] }); + sendMessageNoReply('watch', { fileNames: [...testTree.fileNames] }); } else { const fileNames = new Set(); for (const itemId of watchedTreeIds.value) { - const treeItem = treeItemMap.get(itemId); + const treeItem = testTree.treeItemMap.get(itemId); const fileName = treeItem?.location.file; if (fileName) fileNames.add(fileName); } sendMessageNoReply('watch', { fileNames: [...fileNames] }); } - }, [isLoading, rootItem, fileNames, watchAll, watchedTreeIds, treeItemMap]); + }, [isLoading, testTree, watchAll, watchedTreeIds]); const runTreeItem = (treeItem: TreeItem) => { setSelectedTreeItemId(treeItem.id); - runTests('bounce-if-busy', collectTestIds(treeItem)); + runTests('bounce-if-busy', testTree.collectTestIds(treeItem)); }; runWatchedTests = (changedTestFiles: string[]) => { @@ -484,17 +475,17 @@ const TestList: React.FC<{ const visit = (treeItem: TreeItem) => { const fileName = treeItem.location.file; if (fileName && set.has(fileName)) - testIds.push(...collectTestIds(treeItem)); + testIds.push(...testTree.collectTestIds(treeItem)); if (treeItem.kind === 'group' && treeItem.subKind === 'folder') treeItem.children.forEach(visit); }; - visit(rootItem); + visit(testTree.rootItem); } else { for (const treeId of watchedTreeIds.value) { - const treeItem = treeItemMap.get(treeId); + const treeItem = testTree.treeItemMap.get(treeId); const fileName = treeItem?.location.file; if (fileName && set.has(fileName)) - testIds.push(...collectTestIds(treeItem)); + testIds.push(...testTree.collectTestIds(treeItem)); } } runTests('queue-if-busy', new Set(testIds)); @@ -504,7 +495,7 @@ const TestList: React.FC<{ name='tests' treeState={treeState} setTreeState={setTreeState} - rootItem={rootItem} + rootItem={testTree.rootItem} dataTestId='test-tree' render={treeItem => { return
@@ -512,7 +503,7 @@ const TestList: React.FC<{ {!!treeItem.duration && treeItem.status !== 'skipped' &&
{msToString(treeItem.duration)}
} runTreeItem(treeItem)} disabled={!!runningState}> - sendMessageNoReply('open', { location: locationToOpen(treeItem) })} style={(treeItem.kind === 'group' && treeItem.subKind === 'folder') ? { visibility: 'hidden' } : {}}> + sendMessageNoReply('open', { location: testTree.locationToOpen(treeItem) })} style={(treeItem.kind === 'group' && treeItem.subKind === 'folder') ? { visibility: 'hidden' } : {}}> {!watchAll && { if (watchedTreeIds.value.has(treeItem.id)) watchedTreeIds.value.delete(treeItem.id); @@ -537,7 +528,7 @@ const TestList: React.FC<{ }; const TraceView: React.FC<{ - item: { treeItem?: TreeItem, testFile?: SourceLocation, testCase?: TestCase }, + item: { treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase }, rootDir?: string, }> = ({ item, rootDir }) => { const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>(); @@ -608,19 +599,17 @@ const TraceView: React.FC<{ status={item.treeItem?.status} />; }; -let receiver: TeleReporterReceiver | undefined; -let lastRunReceiver: TeleReporterReceiver | undefined; -let lastRunTestCount: number; +let teleSuiteUpdater: TeleSuiteUpdater | undefined; let throttleTimer: NodeJS.Timeout | undefined; -let throttleData: { config: FullConfig, rootSuite: Suite, loadErrors: TestError[], progress: Progress } | undefined; +let throttleData: { config: reporterTypes.FullConfig, rootSuite: reporterTypes.Suite, loadErrors: reporterTypes.TestError[], progress: Progress } | undefined; const throttledAction = () => { clearTimeout(throttleTimer); throttleTimer = undefined; updateRootSuite(throttleData!.config, throttleData!.rootSuite, throttleData!.loadErrors, throttleData!.progress); }; -const throttleUpdateRootSuite = (config: FullConfig, rootSuite: Suite, loadErrors: TestError[], progress: Progress, immediate = false) => { +const throttleUpdateRootSuite = (config: reporterTypes.FullConfig, rootSuite: reporterTypes.Suite, loadErrors: reporterTypes.TestError[], progress: Progress, immediate = false) => { throttleData = { config, rootSuite, loadErrors, progress }; if (immediate) throttledAction(); @@ -628,91 +617,15 @@ const throttleUpdateRootSuite = (config: FullConfig, rootSuite: Suite, loadError throttleTimer = setTimeout(throttledAction, 250); }; -const refreshRootSuite = (eraseResults: boolean): Promise => { - if (!eraseResults) - return sendMessage('list', {}); - - let rootSuite: Suite; - const loadErrors: TestError[] = []; - const progress: Progress = { - total: 0, - passed: 0, - failed: 0, - skipped: 0, - }; - let config: FullConfig; - receiver = new TeleReporterReceiver({ - version: () => 'v2', - - onConfigure: (c: FullConfig) => { - config = c; - // TeleReportReceiver is merging everything into a single suite, so when we - // run one test, we still get many tests via rootSuite.allTests().length. - // To work around that, have a dedicated per-run receiver that will only have - // suite for a single test run, and hence will have correct total. - lastRunReceiver = new TeleReporterReceiver({ - onBegin: (suite: Suite) => { - lastRunTestCount = suite.allTests().length; - lastRunReceiver = undefined; - } - }, { - mergeProjects: true, - mergeTestCases: false, - resolvePath: (rootDir, relativePath) => rootDir + pathSeparator + relativePath, - }); +const refreshRootSuite = (): Promise => { + teleSuiteUpdater = new TeleSuiteUpdater({ + onUpdate: (source, immediate) => { + throttleUpdateRootSuite(source.config!, source.rootSuite || new TeleSuite('', 'root'), source.loadErrors, source.progress, immediate); }, - - onBegin: (suite: Suite) => { - if (!rootSuite) - rootSuite = suite; - progress.total = lastRunTestCount; - progress.passed = 0; - progress.failed = 0; - progress.skipped = 0; - throttleUpdateRootSuite(config, rootSuite, loadErrors, progress, true); - }, - - onEnd: () => { - throttleUpdateRootSuite(config, rootSuite, loadErrors, progress, true); - }, - - onTestBegin: (test: TestCase, testResult: TestResult) => { - (testResult as any)[statusEx] = 'running'; - throttleUpdateRootSuite(config, rootSuite, loadErrors, progress); - }, - - onTestEnd: (test: TestCase, testResult: TestResult) => { - if (test.outcome() === 'skipped') - ++progress.skipped; - else if (test.outcome() === 'unexpected') - ++progress.failed; - else - ++progress.passed; - (testResult as any)[statusEx] = testResult.status; - throttleUpdateRootSuite(config, rootSuite, loadErrors, progress); - }, - - onError: (error: TestError) => { + onError: error => { xtermDataSource.write((error.stack || error.value || '') + '\n'); - loadErrors.push(error); - throttleUpdateRootSuite(config, rootSuite ?? new TeleSuite('', 'root'), loadErrors, progress); }, - - printsToStdio: () => { - return false; - }, - - onStdOut: () => {}, - onStdErr: () => {}, - onExit: () => {}, - onStepBegin: () => {}, - onStepEnd: () => {}, - }, { - mergeProjects: true, - mergeTestCases: true, - resolvePath: (rootDir, relativePath) => rootDir + pathSeparator + relativePath, }); - receiver._setClearPreviousResultsWhenTestBegins(); return sendMessage('list', {}); }; @@ -729,7 +642,7 @@ const sendMessageNoReply = (method: string, params?: any) => { const dispatchEvent = (method: string, params?: any) => { if (method === 'listChanged') { - refreshRootSuite(false).catch(() => {}); + sendMessage('list', {}).catch(() => {}); return; } @@ -749,304 +662,19 @@ const dispatchEvent = (method: string, params?: any) => { } if (method === 'listReport') - receiver?.dispatch('list', params)?.catch(() => {}); - - if (method === 'testReport') { - // The order of receiver dispatches matters here, we want to assign `lastRunTestCount` - // before we use it. - lastRunReceiver?.dispatch('test', params)?.catch(() => {}); - receiver?.dispatch('test', params)?.catch(() => {}); - } + teleSuiteUpdater?.dispatch('list', params); + if (method === 'testReport') + teleSuiteUpdater?.dispatch('test', params); }; -const outputDirForTestCase = (testCase: TestCase): string | undefined => { - for (let suite: Suite | undefined = testCase.parent; suite; suite = suite.parent) { +const outputDirForTestCase = (testCase: reporterTypes.TestCase): string | undefined => { + for (let suite: reporterTypes.Suite | undefined = testCase.parent; suite; suite = suite.parent) { if (suite.project()) return suite.project()?.outputDir; } return undefined; }; -const locationToOpen = (treeItem?: TreeItem) => { - if (!treeItem) - return; - return treeItem.location.file + ':' + treeItem.location.line; -}; - -const collectTestIds = (treeItem?: TreeItem): Set => { - const testIds = new Set(); - if (!treeItem) - return testIds; - - const visit = (treeItem: TreeItem) => { - if (treeItem.kind === 'case') - treeItem.tests.map(t => t.id).forEach(id => testIds.add(id)); - else if (treeItem.kind === 'test') - testIds.add(treeItem.id); - else - treeItem.children?.forEach(visit); - }; - visit(treeItem); - return testIds; -}; - -type Progress = { - total: number; - passed: number; - failed: number; - skipped: number; -}; - -type TreeItemBase = { - kind: 'root' | 'group' | 'case' | 'test', - id: string; - title: string; - location: Location, - duration: number; - parent: TreeItem | undefined; - children: TreeItem[]; - status: UITestStatus; -}; - -type GroupItem = TreeItemBase & { - kind: 'group'; - subKind: 'folder' | 'file' | 'describe'; - hasLoadErrors: boolean; - children: (TestCaseItem | GroupItem)[]; -}; - -type TestCaseItem = TreeItemBase & { - kind: 'case', - tests: TestCase[]; - children: TestItem[]; -}; - -type TestItem = TreeItemBase & { - kind: 'test', - test: TestCase; - project: string; -}; - -type TreeItem = GroupItem | TestCaseItem | TestItem; - -function getFileItem(rootItem: GroupItem, filePath: string[], isFile: boolean, fileItems: Map): GroupItem { - if (filePath.length === 0) - return rootItem; - const fileName = filePath.join(pathSeparator); - const existingFileItem = fileItems.get(fileName); - if (existingFileItem) - return existingFileItem; - const parentFileItem = getFileItem(rootItem, filePath.slice(0, filePath.length - 1), false, fileItems); - const fileItem: GroupItem = { - kind: 'group', - subKind: isFile ? 'file' : 'folder', - id: fileName, - title: filePath[filePath.length - 1], - location: { file: fileName, line: 0, column: 0 }, - duration: 0, - parent: parentFileItem, - children: [], - status: 'none', - hasLoadErrors: false, - }; - parentFileItem.children.push(fileItem); - fileItems.set(fileName, fileItem); - return fileItem; -} - -function createTree(rootSuite: Suite | undefined, loadErrors: TestError[], projectFilters: Map): GroupItem { - const filterProjects = [...projectFilters.values()].some(Boolean); - const rootItem: GroupItem = { - kind: 'group', - subKind: 'folder', - id: 'root', - title: '', - location: { file: '', line: 0, column: 0 }, - duration: 0, - parent: undefined, - children: [], - status: 'none', - hasLoadErrors: false, - }; - - const visitSuite = (projectName: string, parentSuite: 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; - if (!group) { - group = { - kind: 'group', - subKind: 'describe', - id: 'suite:' + parentSuite.titlePath().join('\x1e') + '\x1e' + title, // account for anonymous suites - title, - location: suite.location!, - duration: 0, - parent: parentGroup, - children: [], - status: 'none', - hasLoadErrors: false, - }; - parentGroup.children.push(group); - } - visitSuite(projectName, suite, group); - } - - for (const test of parentSuite.tests) { - const title = test.title; - let testCaseItem = parentGroup.children.find(t => t.kind !== 'group' && t.title === title) as TestCaseItem; - if (!testCaseItem) { - testCaseItem = { - kind: 'case', - id: 'test:' + test.titlePath().join('\x1e'), - title, - parent: parentGroup, - children: [], - tests: [], - location: test.location, - duration: 0, - status: 'none', - }; - parentGroup.children.push(testCaseItem); - } - - const result = test.results[0]; - let status: 'none' | 'running' | 'scheduled' | 'passed' | 'failed' | 'skipped' = 'none'; - if ((result as any)?.[statusEx] === 'scheduled') - status = 'scheduled'; - else if ((result as any)?.[statusEx] === 'running') - status = 'running'; - else if (result?.status === 'skipped') - status = 'skipped'; - else if (result?.status === 'interrupted') - status = 'none'; - else if (result && test.outcome() !== 'expected') - status = 'failed'; - else if (result && test.outcome() === 'expected') - status = 'passed'; - - testCaseItem.tests.push(test); - testCaseItem.children.push({ - kind: 'test', - id: test.id, - title: projectName, - location: test.location!, - test, - parent: testCaseItem, - children: [], - status, - duration: test.results.length ? Math.max(0, test.results[0].duration) : 0, - project: projectName, - }); - 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 = getFileItem(rootItem, fileSuite.location!.file.split(pathSeparator), true, fileMap); - visitSuite(projectSuite.title, fileSuite, fileItem); - } - } - - for (const loadError of loadErrors) { - if (!loadError.location) - continue; - const fileItem = getFileItem(rootItem, loadError.location.file.split(pathSeparator), true, fileMap); - fileItem.hasLoadErrors = true; - } - return rootItem; -} - -function filterTree(rootItem: GroupItem, filterText: string, statusFilters: Map, runningTestIds: Set | undefined) { - const tokens = filterText.trim().toLowerCase().split(' '); - const filtersStatuses = [...statusFilters.values()].some(Boolean); - - const filter = (testCase: TestCaseItem) => { - const titleWithTags = [...testCase.tests[0].titlePath(), ...testCase.tests[0].tags].join(' ').toLowerCase(); - if (!tokens.every(token => titleWithTags.includes(token)) && !testCase.tests.some(t => runningTestIds?.has(t.id))) - return false; - testCase.children = (testCase.children as TestItem[]).filter(test => { - return !filtersStatuses || runningTestIds?.has(test.test.id) || statusFilters.get(test.status); - }); - testCase.tests = (testCase.children as TestItem[]).map(c => c.test); - return !!testCase.children.length; - }; - - const visit = (treeItem: GroupItem) => { - const newChildren: (GroupItem | TestCaseItem)[] = []; - for (const child of treeItem.children) { - if (child.kind === 'case') { - if (filter(child)) - newChildren.push(child); - } else { - visit(child); - if (child.children.length || child.hasLoadErrors) - newChildren.push(child); - } - } - treeItem.children = newChildren; - }; - visit(rootItem); -} - -function sortAndPropagateStatus(treeItem: TreeItem) { - for (const child of treeItem.children) - sortAndPropagateStatus(child); - - if (treeItem.kind === 'group') { - treeItem.children.sort((a, b) => { - const fc = a.location.file.localeCompare(b.location.file); - return fc || a.location.line - b.location.line; - }); - } - - let allPassed = treeItem.children.length > 0; - let allSkipped = treeItem.children.length > 0; - let hasFailed = false; - let hasRunning = false; - let hasScheduled = false; - - for (const child of treeItem.children) { - allSkipped = allSkipped && child.status === 'skipped'; - allPassed = allPassed && (child.status === 'passed' || child.status === 'skipped'); - hasFailed = hasFailed || child.status === 'failed'; - hasRunning = hasRunning || child.status === 'running'; - hasScheduled = hasScheduled || child.status === 'scheduled'; - } - - if (hasRunning) - treeItem.status = 'running'; - else if (hasScheduled) - treeItem.status = 'scheduled'; - else if (hasFailed) - treeItem.status = 'failed'; - else if (allSkipped) - treeItem.status = 'skipped'; - else if (allPassed) - treeItem.status = 'passed'; -} - -function shortenRoot(rootItem: GroupItem): GroupItem { - let shortRoot = rootItem; - while (shortRoot.children.length === 1 && shortRoot.children[0].kind === 'group' && shortRoot.children[0].subKind === 'folder') - shortRoot = shortRoot.children[0]; - shortRoot.location = rootItem.location; - return shortRoot; -} - -function hideOnlyTests(rootItem: GroupItem) { - const visit = (treeItem: TreeItem) => { - if (treeItem.kind === 'case' && treeItem.children.length === 1) - treeItem.children = []; - else - treeItem.children.forEach(visit); - }; - visit(rootItem); -} - async function loadSingleTraceFile(url: string): Promise { const params = new URLSearchParams(); params.set('trace', url); @@ -1054,6 +682,3 @@ async function loadSingleTraceFile(url: string): Promise { const contextEntries = await response.json() as ContextEntry[]; return new MultiTraceModel(contextEntries); } - -const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/'; -const statusEx = Symbol('statusEx');