From 8a1ff34578cfe734b1b94ea158b81e710d272b34 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Wed, 20 Mar 2024 16:00:35 -0700 Subject: [PATCH] chore: split ui mode view into files (#30029) --- .../src/isomorphic/testServerConnection.ts | 25 + .../trace-viewer/src/ui/uiModeFiltersView.css | 71 +++ .../trace-viewer/src/ui/uiModeFiltersView.tsx | 94 ++++ packages/trace-viewer/src/ui/uiModeModel.ts | 25 + .../src/ui/uiModeTestListView.css | 41 ++ .../src/ui/uiModeTestListView.tsx | 167 +++++++ .../trace-viewer/src/ui/uiModeTraceView.tsx | 114 +++++ packages/trace-viewer/src/ui/uiModeView.css | 82 --- packages/trace-viewer/src/ui/uiModeView.tsx | 466 ++++-------------- packages/web/src/components/listView.tsx | 2 +- 10 files changed, 622 insertions(+), 465 deletions(-) create mode 100644 packages/trace-viewer/src/ui/uiModeFiltersView.css create mode 100644 packages/trace-viewer/src/ui/uiModeFiltersView.tsx create mode 100644 packages/trace-viewer/src/ui/uiModeModel.ts create mode 100644 packages/trace-viewer/src/ui/uiModeTestListView.css create mode 100644 packages/trace-viewer/src/ui/uiModeTestListView.tsx create mode 100644 packages/trace-viewer/src/ui/uiModeTraceView.tsx diff --git a/packages/playwright/src/isomorphic/testServerConnection.ts b/packages/playwright/src/isomorphic/testServerConnection.ts index 4c8b2ffb8d..9c3f84e1ec 100644 --- a/packages/playwright/src/isomorphic/testServerConnection.ts +++ b/packages/playwright/src/isomorphic/testServerConnection.ts @@ -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 { await this._sendMessage('watch', params); } + watchNoReply(params: { fileNames: string[]; }) { + this._sendMessageNoReply('watch', params); + } + async open(params: { location: Location; }): Promise { await this._sendMessage('open', params); } + openNoReply(params: { location: Location; }) { + this._sendMessageNoReply('open', params); + } + async resizeTerminal(params: { cols: number; rows: number; }): Promise { 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 { await this._sendMessage('closeGracefully'); } diff --git a/packages/trace-viewer/src/ui/uiModeFiltersView.css b/packages/trace-viewer/src/ui/uiModeFiltersView.css new file mode 100644 index 0000000000..5967da3e23 --- /dev/null +++ b/packages/trace-viewer/src/ui/uiModeFiltersView.css @@ -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; +} diff --git a/packages/trace-viewer/src/ui/uiModeFiltersView.tsx b/packages/trace-viewer/src/ui/uiModeFiltersView.tsx new file mode 100644 index 0000000000..a6cff6ecf8 --- /dev/null +++ b/packages/trace-viewer/src/ui/uiModeFiltersView.tsx @@ -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; + setStatusFilters: (filters: Map) => void; + projectFilters: Map; + setProjectFilters: (filters: Map) => void; + testModel: TestModel | undefined, + runTests: () => void; +}> = ({ filterText, setFilterText, statusFilters, setStatusFilters, projectFilters, setProjectFilters, testModel, runTests }) => { + const [expanded, setExpanded] = React.useState(false); + const inputRef = React.useRef(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
+ { + setFilterText(e.target.value); + }} + onKeyDown={e => { + if (e.key === 'Enter') + runTests(); + }} />}> + +
setExpanded(!expanded)}> + Status: {statusLine} + Projects: {projectsLine} +
+ {expanded &&
+
+ {[...statusFilters.entries()].map(([status, value]) => { + return
+ +
; + })} +
+
+ {[...projectFilters.entries()].map(([projectName, value]) => { + return
+ +
; + })} +
+
} +
; +}; diff --git a/packages/trace-viewer/src/ui/uiModeModel.ts b/packages/trace-viewer/src/ui/uiModeModel.ts new file mode 100644 index 0000000000..f2656901d7 --- /dev/null +++ b/packages/trace-viewer/src/ui/uiModeModel.ts @@ -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') ? '\\' : '/'; diff --git a/packages/trace-viewer/src/ui/uiModeTestListView.css b/packages/trace-viewer/src/ui/uiModeTestListView.css new file mode 100644 index 0000000000..ae6fd624ee --- /dev/null +++ b/packages/trace-viewer/src/ui/uiModeTestListView.css @@ -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; +} diff --git a/packages/trace-viewer/src/ui/uiModeTestListView.tsx b/packages/trace-viewer/src/ui/uiModeTestListView.tsx new file mode 100644 index 0000000000..422341f364 --- /dev/null +++ b/packages/trace-viewer/src/ui/uiModeTestListView.tsx @@ -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; + +export const TestListView: React.FC<{ + filterText: string, + testTree: TestTree, + testServerConnection: TestServerConnection | undefined, + testModel: TestModel, + runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', testIds: Set) => void, + runningState?: { testIds: Set, itemSelectedByUser?: boolean }, + watchAll: boolean, + watchedTreeIds: { value: Set }, + setWatchedTreeIds: (ids: { value: Set }) => void, + isLoading?: boolean, + onItemSelected: (item: { treeItem?: TreeItem, testCase?: reporterTypes.TestCase, testFile?: SourceLocation }) => void, + requestedCollapseAllCount: number, +}> = ({ filterText, testModel, testServerConnection, testTree, runTests, runningState, watchAll, watchedTreeIds, setWatchedTreeIds, isLoading, onItemSelected, requestedCollapseAllCount }) => { + const [treeState, setTreeState] = React.useState({ expandedItems: new Map() }); + const [selectedTreeItemId, setSelectedTreeItemId] = React.useState(); + const [collapseAllCount, setCollapseAllCount] = React.useState(requestedCollapseAllCount); + + // Look for a first failure within the run batch to select it. + React.useEffect(() => { + // If collapse was requested, clear the expanded items and return w/o selected item. + if (collapseAllCount !== requestedCollapseAllCount) { + treeState.expandedItems.clear(); + for (const item of testTree.flatTreeItems()) + treeState.expandedItems.set(item.id, false); + setCollapseAllCount(requestedCollapseAllCount); + setSelectedTreeItemId(undefined); + setTreeState({ ...treeState }); + return; + } + + if (!runningState || runningState.itemSelectedByUser) + return; + let selectedTreeItem: TreeItem | undefined; + const visit = (treeItem: TreeItem) => { + treeItem.children.forEach(visit); + if (selectedTreeItem) + return; + if (treeItem.status === 'failed') { + if (treeItem.kind === 'test' && runningState.testIds.has(treeItem.test.id)) + selectedTreeItem = treeItem; + else if (treeItem.kind === 'case' && runningState.testIds.has(treeItem.tests[0]?.id)) + selectedTreeItem = treeItem; + } + }; + visit(testTree.rootItem); + + if (selectedTreeItem) + setSelectedTreeItemId(selectedTreeItem.id); + }, [runningState, setSelectedTreeItemId, testTree, collapseAllCount, setCollapseAllCount, requestedCollapseAllCount, treeState, setTreeState]); + + // Compute selected item. + const { selectedTreeItem } = React.useMemo(() => { + 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(); + 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 { + return
+
{treeItem.title}
+ {!!treeItem.duration && treeItem.status !== 'skipped' &&
{msToString(treeItem.duration)}
} + + runTreeItem(treeItem)} disabled={!!runningState}> + testServerConnection?.openNoReply({ location: treeItem.location })} style={(treeItem.kind === 'group' && treeItem.subKind === 'folder') ? { visibility: 'hidden' } : {}}> + {!watchAll && { + if (watchedTreeIds.value.has(treeItem.id)) + watchedTreeIds.value.delete(treeItem.id); + else + watchedTreeIds.value.add(treeItem.id); + setWatchedTreeIds({ ...watchedTreeIds }); + }} toggled={watchedTreeIds.value.has(treeItem.id)}>} + +
; + }} + icon={treeItem => testStatusIcon(treeItem.status)} + selectedItem={selectedTreeItem} + onAccepted={runTreeItem} + onSelected={treeItem => { + if (runningState) + runningState.itemSelectedByUser = true; + setSelectedTreeItemId(treeItem.id); + }} + isError={treeItem => treeItem.kind === 'group' ? treeItem.hasLoadErrors : false} + autoExpandDepth={filterText ? 5 : 1} + noItemsMessage={isLoading ? 'Loading\u2026' : 'No tests'} />; +}; diff --git a/packages/trace-viewer/src/ui/uiModeTraceView.tsx b/packages/trace-viewer/src/ui/uiModeTraceView.tsx new file mode 100644 index 0000000000..86fd3fbd8c --- /dev/null +++ b/packages/trace-viewer/src/ui/uiModeTraceView.tsx @@ -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(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(); + 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 ; +}; + +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 { + 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); +} diff --git a/packages/trace-viewer/src/ui/uiModeView.css b/packages/trace-viewer/src/ui/uiModeView.css index 3c18145805..a45e586c52 100644 --- a/packages/trace-viewer/src/ui/uiModeView.css +++ b/packages/trace-viewer/src/ui/uiModeView.css @@ -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; -} diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index e406747db7..eb13d868ba 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -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(''); const [isShowingOutput, setIsShowingOutput] = React.useState(false); - const [statusFilters, setStatusFilters] = React.useState>(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) => { 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); }} /> - + requestedCollapseAllCount={collapseAllCount} /> ; }; -const FiltersView: React.FC<{ - filterText: string; - setFilterText: (text: string) => void; - statusFilters: Map; - setStatusFilters: (filters: Map) => void; - projectFilters: Map; - setProjectFilters: (filters: Map) => void; - testModel: TestModel | undefined, - runTests: () => void; -}> = ({ filterText, setFilterText, statusFilters, setStatusFilters, projectFilters, setProjectFilters, testModel, runTests }) => { - const [expanded, setExpanded] = React.useState(false); - const inputRef = React.useRef(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
- { - setFilterText(e.target.value); - }} - onKeyDown={e => { - if (e.key === 'Enter') - runTests(); - }} />}> - -
setExpanded(!expanded)}> - Status: {statusLine} - Projects: {projectsLine} -
- {expanded &&
-
- {[...statusFilters.entries()].map(([status, value]) => { - return
- -
; - })} -
-
- {[...projectFilters.entries()].map(([projectName, value]) => { - return
- -
; - })} -
-
} -
; -}; - -const TestTreeView = TreeView; - -const TestList: React.FC<{ - statusFilters: Map, - projectFilters: Map, - filterText: string, - testModel: TestModel, - runTests: (mode: 'bounce-if-busy' | 'queue-if-busy', testIds: Set) => void, - runningState?: { testIds: Set, itemSelectedByUser?: boolean }, - watchAll: boolean, - watchedTreeIds: { value: Set }, - setWatchedTreeIds: (ids: { value: Set }) => void, - isLoading?: boolean, - setVisibleTestIds: (testIds: Set) => 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({ expandedItems: new Map() }); - const [selectedTreeItemId, setSelectedTreeItemId] = React.useState(); - 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(); - 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 { - return
-
{treeItem.title}
- {!!treeItem.duration && treeItem.status !== 'skipped' &&
{msToString(treeItem.duration)}
} - - runTreeItem(treeItem)} disabled={!!runningState}> - testServerConnection?.open({ location: treeItem.location }).catch(() => {})} style={(treeItem.kind === 'group' && treeItem.subKind === 'folder') ? { visibility: 'hidden' } : {}}> - {!watchAll && { - if (watchedTreeIds.value.has(treeItem.id)) - watchedTreeIds.value.delete(treeItem.id); - else - watchedTreeIds.value.add(treeItem.id); - setWatchedTreeIds({ ...watchedTreeIds }); - }} toggled={watchedTreeIds.value.has(treeItem.id)}>} - -
; - }} - icon={treeItem => testStatusIcon(treeItem.status)} - selectedItem={selectedTreeItem} - onAccepted={runTreeItem} - onSelected={treeItem => { - if (runningState) - runningState.itemSelectedByUser = true; - setSelectedTreeItemId(treeItem.id); - }} - isError={treeItem => treeItem.kind === 'group' ? treeItem.hasLoadErrors : false} - autoExpandDepth={filterText ? 5 : 1} - noItemsMessage={isLoading ? 'Loading\u2026' : 'No tests'} />; -}; - -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(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(); - 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 ; -}; - 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 { - 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') ? '\\' : '/'; diff --git a/packages/web/src/components/listView.tsx b/packages/web/src/components/listView.tsx index a9bd9f8fb3..323e2cf506 100644 --- a/packages/web/src/components/listView.tsx +++ b/packages/web/src/components/listView.tsx @@ -85,7 +85,7 @@ export function ListView({ itemListRef.current.scrollTop = scrollPositions.get(name) || 0; }, [name]); - return
0 ? 'list' : undefined} data-testid={dataTestId || (name + '-list')}> + return
0 ? 'list' : undefined} data-testid={dataTestId || (name + '-list')}>