chore: split ui mode view into files (#30029)

This commit is contained in:
Pavel Feldman 2024-03-20 16:00:35 -07:00 committed by GitHub
parent 69f2ae1e4d
commit 8a1ff34578
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 622 additions and 465 deletions

View file

@ -90,6 +90,10 @@ export class TestServerConnection implements TestServerInterface, TestServerInte
});
}
private _sendMessageNoReply(method: string, params?: any) {
this._sendMessage(method, params).catch(() => {});
}
private _dispatchEvent(method: string, params?: any) {
if (method === 'report')
this._onReportEmitter.fire(params);
@ -105,18 +109,34 @@ export class TestServerConnection implements TestServerInterface, TestServerInte
await this._sendMessage('ping');
}
async pingNoReply() {
await this._sendMessageNoReply('ping');
}
async watch(params: { fileNames: string[]; }): Promise<void> {
await this._sendMessage('watch', params);
}
watchNoReply(params: { fileNames: string[]; }) {
this._sendMessageNoReply('watch', params);
}
async open(params: { location: Location; }): Promise<void> {
await this._sendMessage('open', params);
}
openNoReply(params: { location: Location; }) {
this._sendMessageNoReply('open', params);
}
async resizeTerminal(params: { cols: number; rows: number; }): Promise<void> {
await this._sendMessage('resizeTerminal', params);
}
resizeTerminalNoReply(params: { cols: number; rows: number; }) {
this._sendMessageNoReply('resizeTerminal', params);
}
async checkBrowsers(): Promise<{ hasBrowsers: boolean; }> {
return await this._sendMessage('checkBrowsers');
}
@ -153,6 +173,11 @@ export class TestServerConnection implements TestServerInterface, TestServerInte
await this._sendMessage('stopTests');
}
stopTestsNoReply() {
this._sendMessageNoReply('stopTests');
}
async closeGracefully(): Promise<void> {
await this._sendMessage('closeGracefully');
}

View file

@ -0,0 +1,71 @@
/*
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.
*/
.filters {
flex: none;
display: flex;
flex-direction: column;
margin: 2px 0;
}
.filter-list {
padding: 0 10px 10px 10px;
user-select: none;
}
.filter-title,
.filter-summary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: none;
cursor: pointer;
}
.filter-label {
color: var(--vscode-disabledForeground);
}
.filter-summary {
line-height: 24px;
margin-left: 24px;
}
.filter-summary .filter-label {
margin-left: 5px;
}
.filter-entry {
line-height: 24px;
}
.filter-entry label {
display: flex;
align-items: center;
cursor: pointer;
}
.filter-entry input {
flex: none;
display: flex;
align-items: center;
cursor: pointer;
}
.filter-entry label div {
overflow: hidden;
text-overflow: ellipsis;
}

View file

@ -0,0 +1,94 @@
/**
* 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 '@web/common.css';
import { Expandable } from '@web/components/expandable';
import '@web/third_party/vscode/codicon.css';
import { settings } from '@web/uiUtils';
import React from 'react';
import './uiModeFiltersView.css';
import type { TestModel } from './uiModeModel';
export const FiltersView: React.FC<{
filterText: string;
setFilterText: (text: string) => void;
statusFilters: Map<string, boolean>;
setStatusFilters: (filters: Map<string, boolean>) => void;
projectFilters: Map<string, boolean>;
setProjectFilters: (filters: Map<string, boolean>) => void;
testModel: TestModel | undefined,
runTests: () => void;
}> = ({ filterText, setFilterText, statusFilters, setStatusFilters, projectFilters, setProjectFilters, testModel, runTests }) => {
const [expanded, setExpanded] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
inputRef.current?.focus();
}, []);
const statusLine = [...statusFilters.entries()].filter(([_, v]) => v).map(([s]) => s).join(' ') || 'all';
const projectsLine = [...projectFilters.entries()].filter(([_, v]) => v).map(([p]) => p).join(' ') || 'all';
return <div className='filters'>
<Expandable
expanded={expanded}
setExpanded={setExpanded}
title={<input ref={inputRef} type='search' placeholder='Filter (e.g. text, @tag)' spellCheck={false} value={filterText}
onChange={e => {
setFilterText(e.target.value);
}}
onKeyDown={e => {
if (e.key === 'Enter')
runTests();
}} />}>
</Expandable>
<div className='filter-summary' title={'Status: ' + statusLine + '\nProjects: ' + projectsLine} onClick={() => setExpanded(!expanded)}>
<span className='filter-label'>Status:</span> {statusLine}
<span className='filter-label'>Projects:</span> {projectsLine}
</div>
{expanded && <div className='hbox' style={{ marginLeft: 14, maxHeight: 200, overflowY: 'auto' }}>
<div className='filter-list'>
{[...statusFilters.entries()].map(([status, value]) => {
return <div className='filter-entry'>
<label>
<input type='checkbox' checked={value} onClick={() => {
const copy = new Map(statusFilters);
copy.set(status, !copy.get(status));
setStatusFilters(copy);
}}/>
<div>{status}</div>
</label>
</div>;
})}
</div>
<div className='filter-list'>
{[...projectFilters.entries()].map(([projectName, value]) => {
return <div className='filter-entry'>
<label>
<input type='checkbox' checked={value} onClick={() => {
const copy = new Map(projectFilters);
copy.set(projectName, !copy.get(projectName));
setProjectFilters(copy);
const configFile = testModel?.config?.configFile;
if (configFile)
settings.setObject(configFile + ':projects', [...copy.entries()].filter(([_, v]) => v).map(([k]) => k));
}}/>
<div>{projectName || 'untitled'}</div>
</label>
</div>;
})}
</div>
</div>}
</div>;
};

View file

@ -0,0 +1,25 @@
/*
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 * as reporterTypes from 'playwright/types/testReporter';
export type TestModel = {
config: reporterTypes.FullConfig | undefined;
rootSuite: reporterTypes.Suite | undefined;
loadErrors: reporterTypes.TestError[];
};
export const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/';

View file

@ -0,0 +1,41 @@
/*
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.
*/
.ui-mode-list-item {
flex: auto;
}
.ui-mode-list-item-title {
flex: auto;
text-overflow: ellipsis;
overflow: hidden;
}
.ui-mode-list-item-time {
flex: none;
color: var(--vscode-editorCodeLens-foreground);
margin: 0 4px;
user-select: none;
}
.tests-list-view .list-view-entry.selected .ui-mode-list-item-time,
.tests-list-view .list-view-entry.highlighted .ui-mode-list-item-time {
display: none;
}
.tests-list-view .list-view-entry:not(.highlighted):not(.selected) .toolbar-button:not(.toggled) {
display: none;
}

View file

@ -0,0 +1,167 @@
/**
* 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 type { TestModel } from './uiModeModel';
import './uiModeTestListView.css';
import type { TestServerConnection } from '@testIsomorphic/testServerConnection';
const TestTreeView = TreeView<TreeItem>;
export const TestListView: React.FC<{
filterText: string,
testTree: TestTree,
testServerConnection: TestServerConnection | undefined,
testModel: TestModel,
runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', testIds: Set<string>) => void,
runningState?: { testIds: Set<string>, itemSelectedByUser?: boolean },
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,
}> = ({ filterText, testModel, testServerConnection, testTree, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, requestedCollapseAllCount }) => {
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(() => {
const selectedTreeItem = selectedTreeItemId ? testTree.treeItemById(selectedTreeItemId) : undefined;
let testFile: SourceLocation | undefined;
if (selectedTreeItem) {
testFile = {
file: selectedTreeItem.location.file,
line: selectedTreeItem.location.line,
source: {
errors: testModel.loadErrors.filter(e => e.location?.file === selectedTreeItem.location.file).map(e => ({ line: e.location!.line, message: e.message! })),
content: 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, 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));
};
return <TestTreeView
name='tests'
treeState={treeState}
setTreeState={setTreeState}
rootItem={testTree.rootItem}
dataTestId='test-tree'
render={treeItem => {
return <div className='hbox ui-mode-list-item'>
<div className='ui-mode-list-item-title' title={treeItem.title}>{treeItem.title}</div>
{!!treeItem.duration && treeItem.status !== 'skipped' && <div className='ui-mode-list-item-time'>{msToString(treeItem.duration)}</div>}
<Toolbar noMinHeight={true} noShadow={true}>
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={!!runningState}></ToolbarButton>
<ToolbarButton icon='go-to-file' title='Open in VS Code' onClick={() => testServerConnection?.openNoReply({ location: treeItem.location })} style={(treeItem.kind === 'group' && treeItem.subKind === 'folder') ? { visibility: 'hidden' } : {}}></ToolbarButton>
{!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'} />;
};

View file

@ -0,0 +1,114 @@
/**
* 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 { artifactsFolderName } from '@testIsomorphic/folders';
import type { TreeItem } from '@testIsomorphic/testTree';
import type { ActionTraceEvent } from '@trace/trace';
import '@web/common.css';
import '@web/third_party/vscode/codicon.css';
import type * as reporterTypes from 'playwright/types/testReporter';
import React from 'react';
import type { ContextEntry } from '../entries';
import type { SourceLocation } from './modelUtil';
import { idForAction, MultiTraceModel } from './modelUtil';
import { Workbench } from './workbench';
export const TraceView: React.FC<{
item: { treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase },
rootDir?: string,
}> = ({ item, rootDir }) => {
const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>();
const [counter, setCounter] = React.useState(0);
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
const { outputDir } = React.useMemo(() => {
const outputDir = item.testCase ? outputDirForTestCase(item.testCase) : undefined;
return { outputDir };
}, [item]);
// Preserve user selection upon live-reloading trace model by persisting the action id.
// This avoids auto-selection of the last action every time we reload the model.
const [selectedActionId, setSelectedActionId] = React.useState<string | undefined>();
const onSelectionChanged = React.useCallback((action: ActionTraceEvent) => setSelectedActionId(idForAction(action)), [setSelectedActionId]);
const initialSelection = selectedActionId ? model?.model.actions.find(a => idForAction(a) === selectedActionId) : undefined;
React.useEffect(() => {
if (pollTimer.current)
clearTimeout(pollTimer.current);
const result = item.testCase?.results[0];
if (!result) {
setModel(undefined);
return;
}
// Test finished.
const attachment = result && result.duration >= 0 && result.attachments.find(a => a.name === 'trace');
if (attachment && attachment.path) {
loadSingleTraceFile(attachment.path).then(model => setModel({ model, isLive: false }));
return;
}
if (!outputDir) {
setModel(undefined);
return;
}
const traceLocation = `${outputDir}/${artifactsFolderName(result!.workerIndex)}/traces/${item.testCase?.id}.json`;
// Start polling running test.
pollTimer.current = setTimeout(async () => {
try {
const model = await loadSingleTraceFile(traceLocation);
setModel({ model, isLive: true });
} catch {
setModel(undefined);
} finally {
setCounter(counter + 1);
}
}, 500);
return () => {
if (pollTimer.current)
clearTimeout(pollTimer.current);
};
}, [outputDir, item, setModel, counter, setCounter]);
return <Workbench
key='workbench'
model={model?.model}
showSourcesFirst={true}
rootDir={rootDir}
initialSelection={initialSelection}
onSelectionChanged={onSelectionChanged}
fallbackLocation={item.testFile}
isLive={model?.isLive}
status={item.treeItem?.status} />;
};
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;
};
async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
const params = new URLSearchParams();
params.set('trace', url);
const response = await fetch(`contexts?${params.toString()}`);
const contextEntries = await response.json() as ContextEntry[];
return new MultiTraceModel(contextEntries);
}

View file

@ -30,28 +30,6 @@
color: var(--vscode-debugIcon-stopForeground);
}
.ui-mode-list-item {
flex: auto;
}
.ui-mode-list-item-title {
flex: auto;
text-overflow: ellipsis;
overflow: hidden;
}
.ui-mode-list-item-time {
flex: none;
color: var(--vscode-editorCodeLens-foreground);
margin: 0 4px;
user-select: none;
}
.list-view-entry.selected .ui-mode-list-item-time,
.list-view-entry.highlighted .ui-mode-list-item-time {
display: none;
}
.ui-mode .section-title {
display: flex;
flex: auto;
@ -111,10 +89,6 @@
text-overflow: ellipsis;
}
.list-view-entry:not(.highlighted):not(.selected) .toolbar-button:not(.toggled) {
display: none;
}
.ui-mode-sidebar input[type=search] {
flex: auto;
padding: 0 5px;
@ -125,59 +99,3 @@
color: var(--vscode-input-foreground);
background-color: var(--vscode-input-background);
}
.filters {
flex: none;
display: flex;
flex-direction: column;
margin: 2px 0;
}
.filter-list {
padding: 0 10px 10px 10px;
user-select: none;
}
.filter-title,
.filter-summary {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: none;
cursor: pointer;
}
.filter-label {
color: var(--vscode-disabledForeground);
}
.filter-summary {
line-height: 24px;
margin-left: 24px;
}
.filter-summary .filter-label {
margin-left: 5px;
}
.filter-entry {
line-height: 24px;
}
.filter-entry label {
display: flex;
align-items: center;
cursor: pointer;
}
.filter-entry input {
flex: none;
display: flex;
align-items: center;
cursor: pointer;
}
.filter-entry label div {
overflow: hidden;
text-overflow: ellipsis;
}

View file

@ -15,37 +15,34 @@
*/
import '@web/third_party/vscode/codicon.css';
import { Workbench } from './workbench';
import '@web/common.css';
import React from 'react';
import { TreeView } from '@web/components/treeView';
import type { TreeState } from '@web/components/treeView';
import { baseFullConfig, TeleSuite } from '@testIsomorphic/teleReceiver';
import { TeleSuiteUpdater } from './teleSuiteUpdater';
import type { Progress } from './teleSuiteUpdater';
import type { TeleTestCase } from '@testIsomorphic/teleReceiver';
import type * as reporterTypes from 'playwright/types/testReporter';
import { SplitView } from '@web/components/splitView';
import { idForAction, MultiTraceModel } from './modelUtil';
import type { SourceLocation } from './modelUtil';
import './uiModeView.css';
import { ToolbarButton } from '@web/components/toolbarButton';
import { Toolbar } from '@web/components/toolbar';
import type { ContextEntry } from '../entries';
import type { XtermDataSource } from '@web/components/xtermWrapper';
import { XtermWrapper } from '@web/components/xtermWrapper';
import { Expandable } from '@web/components/expandable';
import { toggleTheme } from '@web/theme';
import { artifactsFolderName } from '@testIsomorphic/folders';
import { msToString, settings, useSetting } from '@web/uiUtils';
import type { ActionTraceEvent } from '@trace/trace';
import { settings, useSetting } from '@web/uiUtils';
import { statusEx, TestTree } from '@testIsomorphic/testTree';
import type { TreeItem } from '@testIsomorphic/testTree';
import { testStatusIcon } from './testUtils';
import type { Disposable } from '@testIsomorphic/events';
import { TestServerConnection } from '@testIsomorphic/testServerConnection';
import { pathSeparator } from './uiModeModel';
import type { TestModel } from './uiModeModel';
import { FiltersView } from './uiModeFiltersView';
import { TestListView } from './uiModeTestListView';
import { TraceView } from './uiModeTraceView';
let updateRootSuite: (config: reporterTypes.FullConfig, rootSuite: reporterTypes.Suite, loadErrors: reporterTypes.TestError[], progress: Progress | undefined) => void = () => {};
let runWatchedTests = (fileNames: string[]) => {};
// let runWatchedTests = (fileNames: string[]) => {};
let xtermSize = { cols: 80, rows: 24 };
const xtermDataSource: XtermDataSource = {
@ -55,17 +52,10 @@ const xtermDataSource: XtermDataSource = {
resize: () => {},
};
type TestModel = {
config: reporterTypes.FullConfig | undefined;
rootSuite: reporterTypes.Suite | undefined;
loadErrors: reporterTypes.TestError[];
};
export const UIModeView: React.FC<{}> = ({
}) => {
const [filterText, setFilterText] = React.useState<string>('');
const [isShowingOutput, setIsShowingOutput] = React.useState<boolean>(false);
const [statusFilters, setStatusFilters] = React.useState<Map<string, boolean>>(new Map([
['passed', false],
['failed', false],
@ -94,8 +84,6 @@ export const UIModeView: React.FC<{}> = ({
const wsURL = new URL(`../${guid}`, window.location.toString());
wsURL.protocol = (window.location.protocol === 'https:' ? 'wss:' : 'ws:');
const connection = new TestServerConnection(wsURL.toString());
wireConnectionListeners(connection);
connection.onClose(() => setIsDisconnected(true));
setTestServerConnection(connection);
setIsLoading(true);
setWatchedTreeIds({ value: new Set() });
@ -110,6 +98,19 @@ export const UIModeView: React.FC<{}> = ({
})();
}, []);
React.useEffect(() => {
if (!testServerConnection)
return;
const disposables = [
...wireConnectionListeners(testServerConnection),
testServerConnection.onClose(() => setIsDisconnected(true))
];
return () => {
for (const disposable of disposables)
disposable.dispose();
};
}, [testServerConnection]);
React.useEffect(() => {
inputRef.current?.focus();
setIsLoading(true);
@ -137,6 +138,16 @@ export const UIModeView: React.FC<{}> = ({
setProgress(undefined);
}, [projectFilters, runningState]);
const { testTree } = React.useMemo(() => {
const testTree = new TestTree('', testModel.rootSuite, testModel.loadErrors, projectFilters, pathSeparator);
testTree.filterTree(filterText, statusFilters, runningState?.testIds);
testTree.sortAndPropagateStatus();
testTree.shortenRoot();
testTree.flattenForSingleProject();
setVisibleTestIds(testTree.testIds());
return { testTree };
}, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds, runningState]);
const runTests = React.useCallback((mode: 'queue-if-busy' | 'bounce-if-busy', testIds: Set<string>) => {
if (!testServerConnection)
return;
@ -178,13 +189,41 @@ export const UIModeView: React.FC<{}> = ({
});
}, [projectFilters, runningState, testModel, testServerConnection]);
React.useEffect(() => {
if (!testServerConnection)
return;
const disposable = testServerConnection.onTestFilesChanged(params => {
const testIds: string[] = [];
const set = new Set(params.testFiles);
if (watchAll) {
const visit = (treeItem: TreeItem) => {
const fileName = treeItem.location.file;
if (fileName && set.has(fileName))
testIds.push(...testTree.collectTestIds(treeItem));
if (treeItem.kind === 'group' && treeItem.subKind === 'folder')
treeItem.children.forEach(visit);
};
visit(testTree.rootItem);
} else {
for (const treeId of watchedTreeIds.value) {
const treeItem = testTree.treeItemById(treeId);
const fileName = treeItem?.location.file;
if (fileName && set.has(fileName))
testIds.push(...testTree.collectTestIds(treeItem));
}
}
runTests('queue-if-busy', new Set(testIds));
});
return () => disposable.dispose();
}, [runTests, testServerConnection, testTree, watchAll, watchedTreeIds]);
React.useEffect(() => {
if (!testServerConnection)
return;
const onShortcutEvent = (e: KeyboardEvent) => {
if (e.code === 'F6') {
e.preventDefault();
testServerConnection?.stopTests().catch(() => {});
testServerConnection?.stopTestsNoReply();
} else if (e.code === 'F5') {
e.preventDefault();
reloadTests();
@ -287,339 +326,24 @@ export const UIModeView: React.FC<{}> = ({
setCollapseAllCount(collapseAllCount + 1);
}} />
</Toolbar>
<TestList
statusFilters={statusFilters}
projectFilters={projectFilters}
<TestListView
filterText={filterText}
testModel={testModel}
testTree={testTree}
testServerConnection={testServerConnection}
runningState={runningState}
runTests={runTests}
onItemSelected={setSelectedItem}
setVisibleTestIds={setVisibleTestIds}
watchAll={watchAll}
watchedTreeIds={watchedTreeIds}
setWatchedTreeIds={setWatchedTreeIds}
isLoading={isLoading}
requestedCollapseAllCount={collapseAllCount}
testServerConnection={testServerConnection} />
requestedCollapseAllCount={collapseAllCount} />
</div>
</SplitView>
</div>;
};
const FiltersView: React.FC<{
filterText: string;
setFilterText: (text: string) => void;
statusFilters: Map<string, boolean>;
setStatusFilters: (filters: Map<string, boolean>) => void;
projectFilters: Map<string, boolean>;
setProjectFilters: (filters: Map<string, boolean>) => void;
testModel: TestModel | undefined,
runTests: () => void;
}> = ({ filterText, setFilterText, statusFilters, setStatusFilters, projectFilters, setProjectFilters, testModel, runTests }) => {
const [expanded, setExpanded] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
inputRef.current?.focus();
}, []);
const statusLine = [...statusFilters.entries()].filter(([_, v]) => v).map(([s]) => s).join(' ') || 'all';
const projectsLine = [...projectFilters.entries()].filter(([_, v]) => v).map(([p]) => p).join(' ') || 'all';
return <div className='filters'>
<Expandable
expanded={expanded}
setExpanded={setExpanded}
title={<input ref={inputRef} type='search' placeholder='Filter (e.g. text, @tag)' spellCheck={false} value={filterText}
onChange={e => {
setFilterText(e.target.value);
}}
onKeyDown={e => {
if (e.key === 'Enter')
runTests();
}} />}>
</Expandable>
<div className='filter-summary' title={'Status: ' + statusLine + '\nProjects: ' + projectsLine} onClick={() => setExpanded(!expanded)}>
<span className='filter-label'>Status:</span> {statusLine}
<span className='filter-label'>Projects:</span> {projectsLine}
</div>
{expanded && <div className='hbox' style={{ marginLeft: 14, maxHeight: 200, overflowY: 'auto' }}>
<div className='filter-list'>
{[...statusFilters.entries()].map(([status, value]) => {
return <div className='filter-entry'>
<label>
<input type='checkbox' checked={value} onClick={() => {
const copy = new Map(statusFilters);
copy.set(status, !copy.get(status));
setStatusFilters(copy);
}}/>
<div>{status}</div>
</label>
</div>;
})}
</div>
<div className='filter-list'>
{[...projectFilters.entries()].map(([projectName, value]) => {
return <div className='filter-entry'>
<label>
<input type='checkbox' checked={value} onClick={() => {
const copy = new Map(projectFilters);
copy.set(projectName, !copy.get(projectName));
setProjectFilters(copy);
const configFile = testModel?.config?.configFile;
if (configFile)
settings.setObject(configFile + ':projects', [...copy.entries()].filter(([_, v]) => v).map(([k]) => k));
}}/>
<div>{projectName || 'untitled'}</div>
</label>
</div>;
})}
</div>
</div>}
</div>;
};
const TestTreeView = TreeView<TreeItem>;
const TestList: React.FC<{
statusFilters: Map<string, boolean>,
projectFilters: Map<string, boolean>,
filterText: string,
testModel: TestModel,
runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', testIds: Set<string>) => void,
runningState?: { testIds: Set<string>, itemSelectedByUser?: boolean },
watchAll: boolean,
watchedTreeIds: { value: Set<string> },
setWatchedTreeIds: (ids: { value: Set<string> }) => void,
isLoading?: boolean,
setVisibleTestIds: (testIds: Set<string>) => void,
onItemSelected: (item: { treeItem?: TreeItem, testCase?: reporterTypes.TestCase, testFile?: SourceLocation }) => void,
requestedCollapseAllCount: number,
testServerConnection: TestServerConnection | undefined,
}> = ({ statusFilters, projectFilters, filterText, testModel, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, setVisibleTestIds, requestedCollapseAllCount, testServerConnection }) => {
const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>();
const [collapseAllCount, setCollapseAllCount] = React.useState(requestedCollapseAllCount);
// Build the test tree.
const { testTree } = React.useMemo(() => {
const testTree = new TestTree('', testModel.rootSuite, testModel.loadErrors, projectFilters, pathSeparator);
testTree.filterTree(filterText, statusFilters, runningState?.testIds);
testTree.sortAndPropagateStatus();
testTree.shortenRoot();
testTree.flattenForSingleProject();
setVisibleTestIds(testTree.testIds());
return { testTree };
}, [filterText, testModel, statusFilters, projectFilters, setVisibleTestIds, runningState]);
// 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(() => {
const selectedTreeItem = selectedTreeItemId ? testTree.treeItemById(selectedTreeItemId) : undefined;
let testFile: SourceLocation | undefined;
if (selectedTreeItem) {
testFile = {
file: selectedTreeItem.location.file,
line: selectedTreeItem.location.line,
source: {
errors: testModel.loadErrors.filter(e => e.location?.file === selectedTreeItem.location.file).map(e => ({ line: e.location!.line, message: e.message! })),
content: 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, testTree]);
// Update watch all.
React.useEffect(() => {
if (isLoading || !testServerConnection)
return;
if (watchAll) {
testServerConnection.watch({ fileNames: testTree.fileNames() }).catch(() => {});
} 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.watch({ fileNames: [...fileNames] }).catch(() => {});
}
}, [isLoading, testTree, watchAll, watchedTreeIds, testServerConnection]);
const runTreeItem = (treeItem: TreeItem) => {
setSelectedTreeItemId(treeItem.id);
runTests('bounce-if-busy', testTree.collectTestIds(treeItem));
};
runWatchedTests = (changedTestFiles: string[]) => {
const testIds: string[] = [];
const set = new Set(changedTestFiles);
if (watchAll) {
const visit = (treeItem: TreeItem) => {
const fileName = treeItem.location.file;
if (fileName && set.has(fileName))
testIds.push(...testTree.collectTestIds(treeItem));
if (treeItem.kind === 'group' && treeItem.subKind === 'folder')
treeItem.children.forEach(visit);
};
visit(testTree.rootItem);
} else {
for (const treeId of watchedTreeIds.value) {
const treeItem = testTree.treeItemById(treeId);
const fileName = treeItem?.location.file;
if (fileName && set.has(fileName))
testIds.push(...testTree.collectTestIds(treeItem));
}
}
runTests('queue-if-busy', new Set(testIds));
};
return <TestTreeView
name='tests'
treeState={treeState}
setTreeState={setTreeState}
rootItem={testTree.rootItem}
dataTestId='test-tree'
render={treeItem => {
return <div className='hbox ui-mode-list-item'>
<div className='ui-mode-list-item-title' title={treeItem.title}>{treeItem.title}</div>
{!!treeItem.duration && treeItem.status !== 'skipped' && <div className='ui-mode-list-item-time'>{msToString(treeItem.duration)}</div>}
<Toolbar noMinHeight={true} noShadow={true}>
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={!!runningState}></ToolbarButton>
<ToolbarButton icon='go-to-file' title='Open in VS Code' onClick={() => testServerConnection?.open({ location: treeItem.location }).catch(() => {})} style={(treeItem.kind === 'group' && treeItem.subKind === 'folder') ? { visibility: 'hidden' } : {}}></ToolbarButton>
{!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'} />;
};
const TraceView: React.FC<{
item: { treeItem?: TreeItem, testFile?: SourceLocation, testCase?: reporterTypes.TestCase },
rootDir?: string,
}> = ({ item, rootDir }) => {
const [model, setModel] = React.useState<{ model: MultiTraceModel, isLive: boolean } | undefined>();
const [counter, setCounter] = React.useState(0);
const pollTimer = React.useRef<NodeJS.Timeout | null>(null);
const { outputDir } = React.useMemo(() => {
const outputDir = item.testCase ? outputDirForTestCase(item.testCase) : undefined;
return { outputDir };
}, [item]);
// Preserve user selection upon live-reloading trace model by persisting the action id.
// This avoids auto-selection of the last action every time we reload the model.
const [selectedActionId, setSelectedActionId] = React.useState<string | undefined>();
const onSelectionChanged = React.useCallback((action: ActionTraceEvent) => setSelectedActionId(idForAction(action)), [setSelectedActionId]);
const initialSelection = selectedActionId ? model?.model.actions.find(a => idForAction(a) === selectedActionId) : undefined;
React.useEffect(() => {
if (pollTimer.current)
clearTimeout(pollTimer.current);
const result = item.testCase?.results[0];
if (!result) {
setModel(undefined);
return;
}
// Test finished.
const attachment = result && result.duration >= 0 && result.attachments.find(a => a.name === 'trace');
if (attachment && attachment.path) {
loadSingleTraceFile(attachment.path).then(model => setModel({ model, isLive: false }));
return;
}
if (!outputDir) {
setModel(undefined);
return;
}
const traceLocation = `${outputDir}/${artifactsFolderName(result!.workerIndex)}/traces/${item.testCase?.id}.json`;
// Start polling running test.
pollTimer.current = setTimeout(async () => {
try {
const model = await loadSingleTraceFile(traceLocation);
setModel({ model, isLive: true });
} catch {
setModel(undefined);
} finally {
setCounter(counter + 1);
}
}, 500);
return () => {
if (pollTimer.current)
clearTimeout(pollTimer.current);
};
}, [outputDir, item, setModel, counter, setCounter]);
return <Workbench
key='workbench'
model={model?.model}
showSourcesFirst={true}
rootDir={rootDir}
initialSelection={initialSelection}
onSelectionChanged={onSelectionChanged}
fallbackLocation={item.testFile}
isLive={model?.isLive}
status={item.treeItem?.status} />;
};
let teleSuiteUpdater: TeleSuiteUpdater | undefined;
let throttleTimer: NodeJS.Timeout | undefined;
@ -652,49 +376,27 @@ const refreshRootSuite = async (testServerConnection: TestServerConnection): Pro
teleSuiteUpdater?.processListReport(report);
};
const wireConnectionListeners = (testServerConnection: TestServerConnection) => {
testServerConnection.onListChanged(async () => {
const { report } = await testServerConnection.listTests({});
teleSuiteUpdater?.processListReport(report);
});
testServerConnection.onTestFilesChanged(params => {
runWatchedTests(params.testFiles);
});
testServerConnection.onStdio(params => {
if (params.buffer) {
const data = atob(params.buffer);
xtermDataSource.write(data);
} else {
xtermDataSource.write(params.text!);
}
});
testServerConnection.onReport(params => {
teleSuiteUpdater?.processTestReport(params);
});
const wireConnectionListeners = (testServerConnection: TestServerConnection): Disposable[] => {
const disposables: Disposable[] = [
testServerConnection.onListChanged(async () => {
const { report } = await testServerConnection.listTests({});
teleSuiteUpdater?.processListReport(report);
}),
testServerConnection.onStdio(params => {
if (params.buffer) {
const data = atob(params.buffer);
xtermDataSource.write(data);
} else {
xtermDataSource.write(params.text!);
}
}),
testServerConnection.onReport(params => {
teleSuiteUpdater?.processTestReport(params);
}),
];
xtermDataSource.resize = (cols, rows) => {
xtermSize = { cols, rows };
testServerConnection.resizeTerminal({ cols, rows }).catch(() => {});
testServerConnection.resizeTerminalNoReply({ cols, rows });
};
return disposables;
};
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;
};
async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
const params = new URLSearchParams();
params.set('trace', url);
const response = await fetch(`contexts?${params.toString()}`);
const contextEntries = await response.json() as ContextEntry[];
return new MultiTraceModel(contextEntries);
}
export const pathSeparator = navigator.userAgent.toLowerCase().includes('windows') ? '\\' : '/';

View file

@ -85,7 +85,7 @@ export function ListView<T>({
itemListRef.current.scrollTop = scrollPositions.get(name) || 0;
}, [name]);
return <div className='list-view vbox' role={items.length > 0 ? 'list' : undefined} data-testid={dataTestId || (name + '-list')}>
return <div className={`list-view vbox ` + name + '-list-view' } role={items.length > 0 ? 'list' : undefined} data-testid={dataTestId || (name + '-list')}>
<div
className='list-view-content'
tabIndex={0}