/** * 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 type { TreeItem } from '@testIsomorphic/testTree'; import type { TestTree } from '@testIsomorphic/testTree'; import '@web/common.css'; import { Toolbar } from '@web/components/toolbar'; import { ToolbarButton } from '@web/components/toolbarButton'; import type { TreeState } from '@web/components/treeView'; import { TreeView } from '@web/components/treeView'; import '@web/third_party/vscode/codicon.css'; import { msToString } from '@web/uiUtils'; import type * as reporterTypes from 'playwright/types/testReporter'; import React from 'react'; import type { SourceLocation } from './modelUtil'; import { testStatusIcon } from './testUtils'; import './uiModeTestListView.css'; import type { TestServerConnection } from '@testIsomorphic/testServerConnection'; import { TagView } from './tag'; import type { TeleSuiteUpdaterTestModel } from '@testIsomorphic/teleSuiteUpdater'; const TestTreeView = TreeView; export const TestListView: React.FC<{ filterText: string, testTree: TestTree, testServerConnection: TestServerConnection | undefined, testModel?: TeleSuiteUpdaterTestModel, runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', testIds: Set) => void, runningState?: { testIds: Set, itemSelectedByUser?: boolean, completed?: boolean }, watchAll: boolean, watchedTreeIds: { value: Set }, setWatchedTreeIds: (ids: { value: Set }) => void, isLoading?: boolean, onItemSelected: (item: { treeItem?: TreeItem, testCase?: reporterTypes.TestCase, testFile?: SourceLocation }) => void, requestedCollapseAllCount: number, setFilterText: (text: string) => void, onRevealSource: () => void, }> = ({ filterText, testModel, testServerConnection, testTree, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, requestedCollapseAllCount, setFilterText, onRevealSource }) => { const [treeState, setTreeState] = React.useState({ expandedItems: new Map() }); const [selectedTreeItemId, setSelectedTreeItemId] = React.useState(); const [collapseAllCount, setCollapseAllCount] = React.useState(requestedCollapseAllCount); // Look for a first failure within the run batch to select it. React.useEffect(() => { // 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.flatTreeItems()) treeState.expandedItems.set(item.id, false); setCollapseAllCount(requestedCollapseAllCount); setSelectedTreeItemId(undefined); setTreeState({ ...treeState }); return; } if (!runningState || runningState.itemSelectedByUser) return; let selectedTreeItem: TreeItem | undefined; const visit = (treeItem: TreeItem) => { treeItem.children.forEach(visit); if (selectedTreeItem) return; if (treeItem.status === 'failed') { if (treeItem.kind === 'test' && runningState.testIds.has(treeItem.test.id)) selectedTreeItem = treeItem; else if (treeItem.kind === 'case' && runningState.testIds.has(treeItem.tests[0]?.id)) selectedTreeItem = treeItem; } }; visit(testTree.rootItem); if (selectedTreeItem) setSelectedTreeItemId(selectedTreeItem.id); }, [runningState, setSelectedTreeItemId, testTree, collapseAllCount, setCollapseAllCount, requestedCollapseAllCount, treeState, setTreeState]); // Compute selected item. const { selectedTreeItem } = React.useMemo(() => { if (!testModel) return { selectedTreeItem: undefined }; const selectedTreeItem = selectedTreeItemId ? testTree.treeItemById(selectedTreeItemId) : undefined; const testFile = itemLocation(selectedTreeItem, testModel); 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, testTree]); // Update watch all. React.useEffect(() => { if (isLoading) return; if (watchAll) { testServerConnection?.watchNoReply({ fileNames: testTree.fileNames() }); } else { const fileNames = new Set(); for (const itemId of watchedTreeIds.value) { const treeItem = testTree.treeItemById(itemId); const fileName = treeItem?.location.file; if (fileName) fileNames.add(fileName); } testServerConnection?.watchNoReply({ fileNames: [...fileNames] }); } }, [isLoading, testTree, watchAll, watchedTreeIds, testServerConnection]); const runTreeItem = (treeItem: TreeItem) => { setSelectedTreeItemId(treeItem.id); runTests('bounce-if-busy', testTree.collectTestIds(treeItem)); }; const handleTagClick = (e: React.MouseEvent, tag: string) => { e.preventDefault(); e.stopPropagation(); if (e.metaKey || e.ctrlKey) { const parts = filterText.split(' '); if (parts.includes(tag)) setFilterText(parts.filter(t => t !== tag).join(' ').trim()); else setFilterText((filterText + ' ' + tag).trim()); } else { // Replace all existing tags with this tag. setFilterText((filterText.split(' ').filter(t => !t.startsWith('@')).join(' ') + ' ' + tag).trim()); } }; return { return
{treeItem.title} {treeItem.kind === 'case' ? treeItem.tags.map(tag => handleTagClick(e, tag)} />) : null}
{!!treeItem.duration && treeItem.status !== 'skipped' &&
{msToString(treeItem.duration)}
} runTreeItem(treeItem)} disabled={!!runningState && !runningState.completed}> {!watchAll && { if (watchedTreeIds.value.has(treeItem.id)) watchedTreeIds.value.delete(treeItem.id); else watchedTreeIds.value.add(treeItem.id); setWatchedTreeIds({ ...watchedTreeIds }); }} toggled={watchedTreeIds.value.has(treeItem.id)}>}
; }} icon={treeItem => testStatusIcon(treeItem.status)} selectedItem={selectedTreeItem} onAccepted={runTreeItem} onSelected={treeItem => { if (runningState) runningState.itemSelectedByUser = true; setSelectedTreeItemId(treeItem.id); }} isError={treeItem => treeItem.kind === 'group' ? treeItem.hasLoadErrors : false} autoExpandDepth={filterText ? 5 : 1} noItemsMessage={isLoading ? 'Loading\u2026' : 'No tests'} />; }; function itemLocation(item: TreeItem | undefined, model: TeleSuiteUpdaterTestModel | undefined): SourceLocation | undefined { if (!item || !model) return; return { file: item.location.file, line: item.location.line, column: item.location.column, source: { errors: model.loadErrors.filter(e => e.location?.file === item.location.file).map(e => ({ line: e.location!.line, message: e.message! })), content: undefined, } }; }