chore: split ui mode view into files (#30029)
This commit is contained in:
parent
69f2ae1e4d
commit
8a1ff34578
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
71
packages/trace-viewer/src/ui/uiModeFiltersView.css
Normal file
71
packages/trace-viewer/src/ui/uiModeFiltersView.css
Normal 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;
|
||||
}
|
||||
94
packages/trace-viewer/src/ui/uiModeFiltersView.tsx
Normal file
94
packages/trace-viewer/src/ui/uiModeFiltersView.tsx
Normal 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>;
|
||||
};
|
||||
25
packages/trace-viewer/src/ui/uiModeModel.ts
Normal file
25
packages/trace-viewer/src/ui/uiModeModel.ts
Normal 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') ? '\\' : '/';
|
||||
41
packages/trace-viewer/src/ui/uiModeTestListView.css
Normal file
41
packages/trace-viewer/src/ui/uiModeTestListView.css
Normal 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;
|
||||
}
|
||||
167
packages/trace-viewer/src/ui/uiModeTestListView.tsx
Normal file
167
packages/trace-viewer/src/ui/uiModeTestListView.tsx
Normal 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'} />;
|
||||
};
|
||||
114
packages/trace-viewer/src/ui/uiModeTraceView.tsx
Normal file
114
packages/trace-viewer/src/ui/uiModeTraceView.tsx
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') ? '\\' : '/';
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue