2024-03-21 00:00:35 +01:00
|
|
|
/**
|
|
|
|
|
* 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';
|
2024-03-26 01:06:22 +01:00
|
|
|
import { TagView } from './tag';
|
2024-08-22 17:29:10 +02:00
|
|
|
import type { TeleSuiteUpdaterTestModel } from '@testIsomorphic/teleSuiteUpdater';
|
2024-03-21 00:00:35 +01:00
|
|
|
|
|
|
|
|
const TestTreeView = TreeView<TreeItem>;
|
|
|
|
|
|
|
|
|
|
export const TestListView: React.FC<{
|
|
|
|
|
filterText: string,
|
|
|
|
|
testTree: TestTree,
|
|
|
|
|
testServerConnection: TestServerConnection | undefined,
|
2024-08-22 17:29:10 +02:00
|
|
|
testModel?: TeleSuiteUpdaterTestModel,
|
2024-03-21 00:00:35 +01:00
|
|
|
runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', testIds: Set<string>) => void,
|
2024-08-12 13:50:11 +02:00
|
|
|
runningState?: { testIds: Set<string>, itemSelectedByUser?: boolean, completed?: boolean },
|
2024-03-21 00:00:35 +01:00
|
|
|
watchAll: boolean,
|
|
|
|
|
watchedTreeIds: { value: Set<string> },
|
|
|
|
|
setWatchedTreeIds: (ids: { value: Set<string> }) => void,
|
|
|
|
|
isLoading?: boolean,
|
|
|
|
|
onItemSelected: (item: { treeItem?: TreeItem, testCase?: reporterTypes.TestCase, testFile?: SourceLocation }) => void,
|
|
|
|
|
requestedCollapseAllCount: number,
|
2024-07-29 16:32:13 +02:00
|
|
|
setFilterText: (text: string) => void,
|
|
|
|
|
onRevealSource: () => void,
|
|
|
|
|
}> = ({ filterText, testModel, testServerConnection, testTree, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, requestedCollapseAllCount, setFilterText, onRevealSource }) => {
|
2024-03-21 00:00:35 +01:00
|
|
|
const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
|
|
|
|
|
const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>();
|
|
|
|
|
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(() => {
|
2024-03-21 05:09:49 +01:00
|
|
|
if (!testModel)
|
|
|
|
|
return { selectedTreeItem: undefined };
|
2024-03-21 00:00:35 +01:00
|
|
|
const selectedTreeItem = selectedTreeItemId ? testTree.treeItemById(selectedTreeItemId) : undefined;
|
2024-07-29 16:32:13 +02:00
|
|
|
const testFile = itemLocation(selectedTreeItem, testModel);
|
2024-03-21 00:00:35 +01:00
|
|
|
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<string>();
|
|
|
|
|
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));
|
|
|
|
|
};
|
|
|
|
|
|
2024-03-26 01:06:22 +01:00
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2024-03-21 00:00:35 +01:00
|
|
|
return <TestTreeView
|
|
|
|
|
name='tests'
|
|
|
|
|
treeState={treeState}
|
|
|
|
|
setTreeState={setTreeState}
|
|
|
|
|
rootItem={testTree.rootItem}
|
|
|
|
|
dataTestId='test-tree'
|
|
|
|
|
render={treeItem => {
|
|
|
|
|
return <div className='hbox ui-mode-list-item'>
|
2024-03-26 01:06:22 +01:00
|
|
|
<div className='ui-mode-list-item-title'>
|
|
|
|
|
<span title={treeItem.title}>{treeItem.title}</span>
|
|
|
|
|
{treeItem.kind === 'case' ? treeItem.tags.map(tag => <TagView key={tag} tag={tag.slice(1)} onClick={e => handleTagClick(e, tag)} />) : null}
|
|
|
|
|
</div>
|
2024-03-21 00:00:35 +01:00
|
|
|
{!!treeItem.duration && treeItem.status !== 'skipped' && <div className='ui-mode-list-item-time'>{msToString(treeItem.duration)}</div>}
|
|
|
|
|
<Toolbar noMinHeight={true} noShadow={true}>
|
2024-08-12 13:50:11 +02:00
|
|
|
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={!!runningState && !runningState.completed}></ToolbarButton>
|
2024-07-29 16:32:13 +02:00
|
|
|
<ToolbarButton icon='go-to-file' title='Show source' onClick={onRevealSource} style={(treeItem.kind === 'group' && treeItem.subKind === 'folder') ? { visibility: 'hidden' } : {}}></ToolbarButton>
|
2024-03-21 00:00:35 +01:00
|
|
|
{!watchAll && <ToolbarButton icon='eye' title='Watch' onClick={() => {
|
|
|
|
|
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)}></ToolbarButton>}
|
|
|
|
|
</Toolbar>
|
|
|
|
|
</div>;
|
|
|
|
|
}}
|
|
|
|
|
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'} />;
|
|
|
|
|
};
|
2024-07-29 16:32:13 +02:00
|
|
|
|
2024-08-22 17:29:10 +02:00
|
|
|
function itemLocation(item: TreeItem | undefined, model: TeleSuiteUpdaterTestModel | undefined): SourceLocation | undefined {
|
2024-07-29 16:32:13 +02:00
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|