chore: reimplement filters (#21647)

This commit is contained in:
Pavel Feldman 2023-03-13 22:19:31 -07:00 committed by GitHub
parent ccd5d7fd10
commit f5fa18a279
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 515 additions and 221 deletions

View File

@ -46,6 +46,8 @@ class UIMode {
config._internal.configCLIOverrides.updateSnapshots = undefined;
config._internal.listOnly = false;
config._internal.passWithNoTests = true;
for (const project of config.projects)
project._internal.deps = [];
for (const p of config.projects)
p.retries = 0;

View File

@ -125,7 +125,7 @@ export const NetworkResourceDetails: React.FunctionComponent<{
return <div
className={'network-request ' + (selected ? 'selected' : '')} onClick={() => setSelected(index)}>
<Expandable expanded={expanded} setExpanded={setExpanded} style={{ width: '100%' }} title={ renderTitle() }>
<Expandable expanded={expanded} setExpanded={setExpanded} title={ renderTitle() }>
<div className='network-request-details'>
<div className='network-request-details-time'>{resource.time}ms</div>
<div className='network-request-details-header'>URL</div>

View File

@ -40,15 +40,31 @@
overflow: hidden
}
.watch-mode-sidebar .toolbar {
min-height: 24px;
}
.watch-mode-sidebar .toolbar-button {
margin: 0;
}
.watch-mode .section-title {
display: flex;
flex-direction: row;
align-items: center;
font-size: 11px;
text-transform: uppercase;
font-weight: bold;
margin: 5px;
text-overflow: ellipsis;
overflow: hidden;
padding: 8px;
}
.watch-mode img {
flex: none;
margin: 0 4px;
width: 24px;
height: 24px;
}
.status-line {
@ -68,38 +84,12 @@
margin: 0 5px;
}
.list-view {
margin-top: 5px;
}
.list-view-entry:not(.highlighted):not(.selected) .toolbar-button:not(.toggled) {
display: none;
}
.filters {
flex: none;
margin-left: 5px;
line-height: 24px;
}
.filters > span {
color: var(--vscode-panelTitle-inactiveForeground);
padding-left: 3px;
}
.filters > div {
display: inline-block;
margin: 0 5px;
user-select: none;
cursor: pointer;
}
.filters > div:hover,
.filters > div.filters-toggled {
color: var(--vscode-notificationLink-foreground);
}
.watch-mode-sidebar input[type=search] {
flex: auto;
padding: 0 5px;
line-height: 24px;
outline: none;
@ -108,3 +98,51 @@
color: var(--vscode-input-foreground);
background-color: var(--vscode-input-background);
}
.filters {
flex: none;
display: flex;
flex-direction: column;
margin-top: 8px;
}
.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-top: 2px;
margin-left: 20px;
}
.filter-summary .filter-label {
margin-left: 5px;
}
.filter-entry label {
display: flex;
align-items: center;
cursor: pointer;
}
.filter-entry input {
flex: none;
display: flex;
align-items: center;
cursor: pointer;
}
.filter-entry label div {
overflow: hidden;
text-overflow: ellipsis;
}

View File

@ -20,7 +20,7 @@ import '@web/common.css';
import React from 'react';
import { TreeView } from '@web/components/treeView';
import type { TreeState } from '@web/components/treeView';
import { TeleReporterReceiver } from '../../../playwright-test/src/isomorphic/teleReceiver';
import { TeleReporterReceiver, TeleSuite } from '../../../playwright-test/src/isomorphic/teleReceiver';
import type { TeleTestCase } from '../../../playwright-test/src/isomorphic/teleReceiver';
import type { FullConfig, Suite, TestCase, TestResult, TestStep, Location } from '../../../playwright-test/types/testReporter';
import { SplitView } from '@web/components/splitView';
@ -28,12 +28,12 @@ import { MultiTraceModel } from './modelUtil';
import './watchMode.css';
import { ToolbarButton } from '@web/components/toolbarButton';
import { Toolbar } from '@web/components/toolbar';
import { toggleTheme } from '@web/theme';
import type { ContextEntry } from '../entries';
import type * as trace from '@trace/trace';
import type { XtermDataSource } from '@web/components/xtermWrapper';
import { XtermWrapper } from '@web/components/xtermWrapper';
import { Expandable } from '@web/components/expandable';
import { toggleTheme } from '@web/theme';
let updateRootSuite: (rootSuite: Suite, progress: Progress) => void = () => {};
let updateStepsProgress: () => void = () => {};
@ -52,39 +52,51 @@ const xtermDataSource: XtermDataSource = {
export const WatchModeView: React.FC<{}> = ({
}) => {
const [filterText, setFilterText] = useSetting<string>('test-ui-filter-text', '');
const [filterExpanded, setFilterExpanded] = useSetting<boolean>('test-ui-filter-expanded', false);
const [isShowingOutput, setIsShowingOutput] = useSetting<boolean>('test-ui-show-output', false);
const [filterText, setFilterText] = React.useState<string>('');
const [isShowingOutput, setIsShowingOutput] = React.useState<boolean>(false);
const [projects, setProjects] = React.useState<Map<string, boolean>>(new Map());
const [statusFilters, setStatusFilters] = React.useState<Map<string, boolean>>(new Map([
['passed', false],
['failed', false],
['skipped', false],
]));
const [projectFilters, setProjectFilters] = React.useState<Map<string, boolean>>(new Map());
const [rootSuite, setRootSuite] = React.useState<{ value: Suite | undefined }>({ value: undefined });
const [progress, setProgress] = React.useState<Progress>({ total: 0, passed: 0, failed: 0, skipped: 0 });
const [selectedTest, setSelectedTest] = React.useState<TestCase | undefined>(undefined);
const [settingsVisible, setSettingsVisible] = React.useState<boolean>(false);
const [visibleTestIds, setVisibleTestIds] = React.useState<string[]>([]);
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const [runningState, setRunningState] = React.useState<{ testIds: Set<string>, itemSelectedByUser?: boolean }>();
const inputRef = React.useRef<HTMLInputElement>(null);
const reloadTests = () => {
setIsLoading(true);
updateRootSuite(new TeleSuite('', 'root'), { total: 0, passed: 0, failed: 0, skipped: 0 });
refreshRootSuite(true).then(() => {
setIsLoading(false);
});
};
React.useEffect(() => {
inputRef.current?.focus();
refreshRootSuite(true);
reloadTests();
}, []);
updateRootSuite = (rootSuite: Suite, newProgress: Progress) => {
for (const projectName of projects.keys()) {
for (const projectName of projectFilters.keys()) {
if (!rootSuite.suites.find(s => s.title === projectName))
projects.delete(projectName);
projectFilters.delete(projectName);
}
for (const projectSuite of rootSuite.suites) {
if (!projects.has(projectSuite.title))
projects.set(projectSuite.title, false);
if (!projectFilters.has(projectSuite.title))
projectFilters.set(projectSuite.title, false);
}
if (![...projects.values()].includes(true))
projects.set(projects.entries().next().value[0], true);
if (projectFilters.size && ![...projectFilters.values()].includes(true))
projectFilters.set(projectFilters.entries().next().value[0], true);
setRootSuite({ value: rootSuite });
setProjects(new Map(projects));
setProjectFilters(new Map(projectFilters));
setProgress(newProgress);
};
@ -108,24 +120,6 @@ export const WatchModeView: React.FC<{}> = ({
});
};
const updateFilter = (name: string, value: string) => {
const result: string[] = [];
const prefix = name + ':';
for (const t of filterText.split(' ')) {
if (t.startsWith(prefix)) {
if (value) {
result.push(prefix + value);
value = '';
}
} else {
result.push(t);
}
}
if (value)
result.unshift(prefix + value);
setFilterText(result.join(' '));
};
const isRunningTest = !!runningState;
const result = selectedTest?.results[0];
const isFinished = result && result.duration >= 0;
@ -134,7 +128,6 @@ export const WatchModeView: React.FC<{}> = ({
<div className='vbox'>
<div className={'vbox' + (isShowingOutput ? '' : ' hidden')}>
<Toolbar>
<div className='section-title'>Output</div>
<ToolbarButton icon='circle-slash' title='Clear output' onClick={() => xtermDataSource.clear()}></ToolbarButton>
<div className='spacer'></div>
<ToolbarButton icon='close' title='Close' onClick={() => setIsShowingOutput(false)}></ToolbarButton>
@ -148,52 +141,42 @@ export const WatchModeView: React.FC<{}> = ({
</div>
<div className='vbox watch-mode-sidebar'>
<Toolbar>
<div className='section-title' style={{ cursor: 'pointer' }} onClick={() => setSettingsVisible(false)}>Tests</div>
<ToolbarButton icon='play' title='Run all' onClick={() => runTests(visibleTestIds)} disabled={isRunningTest}></ToolbarButton>
<ToolbarButton icon='debug-stop' title='Stop' onClick={() => sendMessageNoReply('stop')} disabled={!isRunningTest}></ToolbarButton>
<ToolbarButton icon='refresh' title='Reload' onClick={() => refreshRootSuite(true)} disabled={isRunningTest}></ToolbarButton>
<img src='icon-32x32.png' />
<div className='section-title'>Playwright</div>
<div className='spacer'></div>
<ToolbarButton icon='gear' title='Toggle color mode' toggled={settingsVisible} onClick={() => { setSettingsVisible(!settingsVisible); }}></ToolbarButton>
<ToolbarButton icon='terminal' title='Toggle color mode' toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }}></ToolbarButton>
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()} />
<ToolbarButton icon='refresh' title='Reload' onClick={() => reloadTests()} disabled={isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='terminal' title='Toggle output' toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }} />
</Toolbar>
<FiltersView
filterText={filterText}
setFilterText={setFilterText}
statusFilters={statusFilters}
setStatusFilters={setStatusFilters}
projectFilters={projectFilters}
setProjectFilters={setProjectFilters}
runTests={() => runTests(visibleTestIds)} />
<Toolbar>
<div className='section-title'>Tests</div>
<div className='spacer'></div>
<ToolbarButton icon='play' title='Run all' onClick={() => runTests(visibleTestIds)} disabled={isRunningTest || isLoading}></ToolbarButton>
<ToolbarButton icon='debug-stop' title='Stop' onClick={() => sendMessageNoReply('stop')} disabled={!isRunningTest || isLoading}></ToolbarButton>
</Toolbar>
{!settingsVisible && <Expandable
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(visibleTestIds);
}}></input>}
style={{ flex: 'none', marginTop: 8 }}
expanded={filterExpanded}
setExpanded={setFilterExpanded}>
<div className='filters'>
<span>Status:</span>
<div onClick={() => updateFilter('s', '')}>all</div>
{['failed', 'passed', 'skipped'].map(s => <div className={filterText.includes('s:' + s) ? 'filters-toggled' : ''} onClick={() => updateFilter('s', s)}>{s}</div>)}
</div>
{[...projects.values()].filter(v => v).length > 1 && <div className='filters'>
<span>Project:</span>
<div onClick={() => updateFilter('p', '')}>all</div>
{[...projects].filter(([k, v]) => v).map(([k, v]) => k).map(p => <div className={filterText.includes('p:' + p) ? 'filters-toggled' : ''} onClick={() => updateFilter('p', p)}>{p}</div>)}
</div>}
</Expandable>}
<TestList
projects={projects}
statusFilters={statusFilters}
projectFilters={projectFilters}
filterText={filterText}
rootSuite={rootSuite}
runningState={runningState}
runTests={runTests}
onTestSelected={setSelectedTest}
isVisible={!settingsVisible}
setVisibleTestIds={setVisibleTestIds} />
{settingsVisible && <SettingsView projects={projects} setProjects={setProjects} onClose={() => setSettingsVisible(false)}></SettingsView>}
</div>
</SplitView>
<div className='status-line'>
<div>Total: {progress.total}</div>
{isRunningTest && <div><span className='codicon codicon-loading'></span>Running {visibleTestIds.length}</div>}
{isRunningTest && <div><span className='codicon codicon-loading'></span>{`Running ${visibleTestIds.length}\u2026`}</div>}
{isLoading && <div><span className='codicon codicon-loading'></span> {'Loading\u2026'}</div>}
{!isRunningTest && <div>Showing: {visibleTestIds.length}</div>}
<div>{progress.passed} passed</div>
<div>{progress.failed} failed</div>
@ -202,29 +185,89 @@ export const WatchModeView: React.FC<{}> = ({
</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;
runTests: () => void;
}> = ({ filterText, setFilterText, statusFilters, setStatusFilters, projectFilters, setProjectFilters, 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();
}} />}>
{<div className='filter-title' title={statusLine} onClick={() => setExpanded(false)}><span className='filter-label'>Status:</span> {statusLine}</div>}
{[...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 className='filter-title' title={projectsLine}><span className='filter-label'>Projects:</span> {projectsLine}</div>}
{[...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);
}}/>
<div>{projectName}</div>
</label>
</div>;
})}
</Expandable>
{!expanded && <div className='filter-summary' title={'Status: ' + statusLine + '\nProjects: ' + projectsLine} onClick={() => setExpanded(true)}>
<span className='filter-label'>Status:</span> {statusLine}
<span className='filter-label'>Projects:</span> {projectsLine}
</div>}
</div>;
};
const TestTreeView = TreeView<TreeItem>;
export const TestList: React.FC<{
projects: Map<string, boolean>,
const TestList: React.FC<{
statusFilters: Map<string, boolean>,
projectFilters: Map<string, boolean>,
filterText: string,
rootSuite: { value: Suite | undefined },
runTests: (testIds: string[]) => void,
runningState?: { testIds: Set<string>, itemSelectedByUser?: boolean },
isVisible: boolean,
setVisibleTestIds: (testIds: string[]) => void,
onTestSelected: (test: TestCase | undefined) => void,
}> = ({ projects, filterText, rootSuite, runTests, runningState, isVisible, onTestSelected, setVisibleTestIds }) => {
}> = ({ statusFilters, projectFilters, filterText, rootSuite, runTests, runningState, onTestSelected, setVisibleTestIds }) => {
const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>();
const [watchedTreeIds] = React.useState<Set<string>>(new Set());
React.useEffect(() => {
refreshRootSuite(true);
}, []);
const { rootItem, treeItemMap } = React.useMemo(() => {
const rootItem = createTree(rootSuite.value, projects);
filterTree(rootItem, filterText);
const rootItem = createTree(rootSuite.value, projectFilters);
filterTree(rootItem, filterText, statusFilters);
hideOnlyTests(rootItem);
const treeItemMap = new Map<string, TreeItem>();
const visibleTestIds = new Set<string>();
@ -237,7 +280,7 @@ export const TestList: React.FC<{
visit(rootItem);
setVisibleTestIds([...visibleTestIds]);
return { rootItem, treeItemMap };
}, [filterText, rootSuite, projects, setVisibleTestIds]);
}, [filterText, rootSuite, statusFilters, projectFilters, setVisibleTestIds]);
React.useEffect(() => {
// Look for a first failure within the run batch to select it.
@ -245,9 +288,9 @@ export const TestList: React.FC<{
return;
let selectedTreeItem: TreeItem | undefined;
const visit = (treeItem: TreeItem) => {
treeItem.children.forEach(visit);
if (selectedTreeItem)
return;
treeItem.children.forEach(visit);
if (treeItem.status === 'failed') {
if (treeItem.kind === 'test' && runningState.testIds.has(treeItem.test.id))
selectedTreeItem = treeItem;
@ -296,9 +339,6 @@ export const TestList: React.FC<{
runTests(testIds);
};
if (!isVisible)
return <></>;
return <TestTreeView
treeState={treeState}
setTreeState={setTreeState}
@ -340,39 +380,7 @@ export const TestList: React.FC<{
noItemsMessage='No tests' />;
};
export const SettingsView: React.FC<{
projects: Map<string, boolean>,
setProjects: (projectNames: Map<string, boolean>) => void,
onClose: () => void,
}> = ({ projects, setProjects, onClose }) => {
return <div className='vbox'>
<div className='hbox' style={{ flex: 'none' }}>
<div className='section-title' style={{ marginTop: 10 }}>Projects</div>
<div className='spacer'></div>
<ToolbarButton icon='close' title='Close settings' toggled={false} onClick={onClose}></ToolbarButton>
</div>
{[...projects.entries()].map(([projectName, value]) => {
return <div style={{ display: 'flex', alignItems: 'center', lineHeight: '24px', marginLeft: 5 }}>
<input id={`project-${projectName}`} type='checkbox' checked={value} style={{ cursor: 'pointer' }} onClick={() => {
const copy = new Map(projects);
copy.set(projectName, !copy.get(projectName));
if (![...copy.values()].includes(true))
copy.set(projectName, true);
setProjects(copy);
}}/>
<label htmlFor={`project-${projectName}`} style={{ cursor: 'pointer' }}>
{projectName}
</label>
</div>;
})}
<div className='section-title'>Appearance</div>
<div style={{ marginLeft: 3 }}>
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}>Toggle color mode</ToolbarButton>
</div>
</div>;
};
export const InProgressTraceView: React.FC<{
const InProgressTraceView: React.FC<{
testResult: TestResult | undefined,
}> = ({ testResult }) => {
const [model, setModel] = React.useState<MultiTraceModel | undefined>();
@ -386,7 +394,7 @@ export const InProgressTraceView: React.FC<{
return <Workbench model={model} hideTimelineBars={true} hideStackFrames={true} showSourcesFirst={true} />;
};
export const FinishedTraceView: React.FC<{
const FinishedTraceView: React.FC<{
testResult: TestResult,
}> = ({ testResult }) => {
const [model, setModel] = React.useState<MultiTraceModel | undefined>();
@ -425,11 +433,9 @@ const throttleUpdateRootSuite = (rootSuite: Suite, progress: Progress, immediate
throttleTimer = setTimeout(throttledAction, 250);
};
const refreshRootSuite = (eraseResults: boolean) => {
if (!eraseResults) {
sendMessageNoReply('list');
return;
}
const refreshRootSuite = (eraseResults: boolean): Promise<void> => {
if (!eraseResults)
return sendMessage('list', {});
let rootSuite: Suite;
const progress: Progress = {
@ -477,12 +483,12 @@ const refreshRootSuite = (eraseResults: boolean) => {
updateStepsProgress();
},
});
sendMessageNoReply('list');
return sendMessage('list', {});
};
(window as any).dispatch = (message: any) => {
if (message.method === 'listChanged') {
refreshRootSuite(false);
refreshRootSuite(false).catch(() => {});
return;
}
@ -577,7 +583,8 @@ type TestItem = TreeItemBase & {
type TreeItem = GroupItem | TestCaseItem | TestItem;
function createTree(rootSuite: Suite | undefined, projects: Map<string, boolean>): GroupItem {
function createTree(rootSuite: Suite | undefined, projectFilters: Map<string, boolean>): GroupItem {
const filterProjects = [...projectFilters.values()].some(Boolean);
const rootItem: GroupItem = {
kind: 'group',
id: 'root',
@ -650,7 +657,7 @@ function createTree(rootSuite: Suite | undefined, projects: Map<string, boolean>
};
for (const projectSuite of rootSuite?.suites || []) {
if (!projects.get(projectSuite.title))
if (filterProjects && !projectFilters.get(projectSuite.title))
continue;
visitSuite(projectSuite.title, projectSuite, rootItem);
}
@ -687,21 +694,15 @@ function createTree(rootSuite: Suite | undefined, projects: Map<string, boolean>
return rootItem;
}
function filterTree(rootItem: GroupItem, filterText: string) {
const trimmedFilterText = filterText.trim();
const filterTokens = trimmedFilterText.toLowerCase().split(' ');
const textTokens = filterTokens.filter(token => !token.match(/^[sp]:/));
const statuses = new Set(filterTokens.filter(t => t.startsWith('s:')).map(t => t.substring(2)));
if (statuses.size)
statuses.add('running');
const projects = new Set(filterTokens.filter(t => t.startsWith('p:')).map(t => t.substring(2)));
function filterTree(rootItem: GroupItem, filterText: string, statusFilters: Map<string, boolean>) {
const tokens = filterText.trim().toLowerCase().split(' ');
const filtersStatuses = [...statusFilters.values()].some(Boolean);
const filter = (testCase: TestCaseItem) => {
const title = testCase.tests[0].titlePath().join(' ').toLowerCase();
if (!textTokens.every(token => title.includes(token)))
if (!tokens.every(token => title.includes(token)))
return false;
testCase.children = (testCase.children as TestItem[]).filter(test => !statuses.size || statuses.has(test.status));
testCase.children = (testCase.children as TestItem[]).filter(test => !projects.size || projects.has(test.project));
testCase.children = (testCase.children as TestItem[]).filter(test => !filtersStatuses || statusFilters.get(test.status));
testCase.tests = (testCase.children as TestItem[]).map(c => c.test);
return !!testCase.children.length;
};
@ -803,17 +804,3 @@ function stepsToModel(result: TestResult): MultiTraceModel {
return new MultiTraceModel([contextEntry]);
}
function useSetting<S>(name: string, defaultValue: S): [S, React.Dispatch<React.SetStateAction<S>>] {
const string = localStorage.getItem(name);
let value = defaultValue;
if (string !== null)
value = JSON.parse(string);
const [state, setState] = React.useState<S>(value);
const setStateWrapper = (value: React.SetStateAction<S>) => {
localStorage.setItem(name, JSON.stringify(value));
setState(value);
};
return [state, setStateWrapper];
}

View File

@ -118,7 +118,8 @@ body.dark-mode .CodeMirror span.cm-type {
.CodeMirror .CodeMirror-gutters {
z-index: 0;
background: var(--vscode-editorGutter-background);
background: 1px solid var(--vscode-editorGroup-border);
border-right: none;
}
.CodeMirror .CodeMirror-gutterwrapper {

View File

@ -0,0 +1,31 @@
/*
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.
*/
.expandable {
flex: none;
flex-direction: column;
line-height: 28px;
}
.expandable-title {
flex: none;
display: flex;
flex-direction: row;
align-items: center;
white-space: nowrap;
user-select: none;
cursor: pointer;
}

View File

@ -15,21 +15,21 @@
*/
import * as React from 'react';
import './expandable.css';
export const Expandable: React.FunctionComponent<React.PropsWithChildren<{
title: JSX.Element | string,
setExpanded: Function,
expanded: boolean,
style?: React.CSSProperties,
}>> = ({ title, children, setExpanded, expanded, style }) => {
return <div style={{ ...style, display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', whiteSpace: 'nowrap' }}>
}>> = ({ title, children, setExpanded, expanded }) => {
return <div className={'expandable' + (expanded ? ' expanded' : '')}>
<div className='expandable-title'>
<div
className={'codicon codicon-' + (expanded ? 'chevron-down' : 'chevron-right')}
style={{ cursor: 'pointer', color: 'var(--vscode-foreground)', marginLeft: '5px' }}
onClick={() => setExpanded(!expanded)} />
{title}
</div>
{ expanded && <div style={{ margin: '5px 0 5px 20px' }}>{children}</div> }
{ expanded && <div style={{ marginLeft: 25 }}>{children}</div> }
</div>;
};

View File

@ -14,6 +14,8 @@
limitations under the License.
*/
import React from 'react';
export function msToString(ms: number): string {
if (!isFinite(ms))
return '-';
@ -76,3 +78,17 @@ export function copy(text: string) {
document.execCommand('copy');
textArea.remove();
}
export function useSetting<S>(name: string, defaultValue: S): [S, React.Dispatch<React.SetStateAction<S>>] {
const string = localStorage.getItem(name);
let value = defaultValue;
if (string !== null)
value = JSON.parse(string);
const [state, setState] = React.useState<S>(value);
const setStateWrapper = (value: React.SetStateAction<S>) => {
localStorage.setItem(name, JSON.stringify(value));
setState(value);
};
return [state, setStateWrapper];
}

View File

@ -69,13 +69,14 @@ export function dumpTestTree(page: Page): () => Promise<string> {
export const test = base
.extend<Fixtures>({
runUITest: async ({ childProcess, playwright, headless }, use, testInfo: TestInfo) => {
testInfo.slow();
const cacheDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-test-cache-'));
let testProcess: TestChildProcess | undefined;
let browser: Browser | undefined;
await use(async (files: Files, env: NodeJS.ProcessEnv = {}, options: RunOptions = {}) => {
const baseDir = await writeFiles(testInfo, files, true);
testProcess = childProcess({
command: ['node', cliEntrypoint, 'ui', ...(options.additionalArgs || [])],
command: ['node', cliEntrypoint, 'ui', '--workers=1', ...(options.additionalArgs || [])],
env: {
...cleanEnv(env),
PWTEST_UNDER_TEST: '1',

View File

@ -0,0 +1,150 @@
/**
* 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 { test, expect, dumpTestTree } from './ui-mode-fixtures';
test.describe.configure({ mode: 'parallel' });
const basicTestTree = {
'a.test.ts': `
import { test, expect } from '@playwright/test';
test('passes', () => {});
test('fails', () => { expect(1).toBe(2); });
test.describe('suite', () => {
test('inner passes', () => {});
test('inner fails', () => { expect(1).toBe(2); });
});
`,
'b.test.ts': `
import { test, expect } from '@playwright/test';
test('passes', () => {});
test('fails', () => { expect(1).toBe(2); });
`,
};
test('should filter by title', async ({ runUITest }) => {
const page = await runUITest(basicTestTree);
await page.getByPlaceholder('Filter').fill('inner');
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
suite
inner passes
inner fails
`);
});
test('should filter by status', async ({ runUITest }) => {
const page = await runUITest(basicTestTree);
await page.getByTitle('Run all').click();
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
fails <=
suite
b.test.ts
passes
fails
`);
await expect(page.getByText('Status: all')).toBeVisible();
await page.getByText('Status:').click();
await page.getByLabel('failed').setChecked(true);
await expect(page.getByText('Status: failed')).toBeVisible();
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
fails <=
suite
b.test.ts
fails
`);
await page.getByLabel('passed').setChecked(true);
await expect(page.getByText('Status: passed failed')).toBeVisible();
await expect.poll(dumpTestTree(page), { timeout: 5000 }).toBe(`
a.test.ts
passes
fails <=
suite
b.test.ts
passes
fails
`);
});
test('should filter by project', async ({ runUITest }) => {
const page = await runUITest({
...basicTestTree,
'playwright.config.ts': `
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'foo' },
{ name: 'bar' },
],
});
`
});
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
fails
suite
b.test.ts
passes
fails
`);
await expect(page.getByText('Projects: foo')).toBeVisible();
await page.getByText('Status:').click();
await expect(page.getByLabel('foo')).toBeChecked();
await expect(page.getByLabel('bar')).not.toBeChecked();
await page.getByLabel('bar').setChecked(true);
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
fails
suite
b.test.ts
passes
fails
`);
await page.getByText('passes').first().click();
await page.keyboard.press('ArrowRight');
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes <=
foo
bar
fails
suite
b.test.ts
passes
fails
`);
await expect(page.getByText('Projects: foo bar')).toBeVisible();
});

View File

@ -41,13 +41,13 @@ const basicTestTree = {
test('should run visible', async ({ runUITest }) => {
const page = await runUITest(basicTestTree);
await expect.poll(dumpTestTree(page), { timeout: 0 }).toContain(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts
`);
await page.getByTitle('Run all').click();
await expect.poll(dumpTestTree(page), { timeout: 0 }).toBe(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
fails <=
@ -72,7 +72,7 @@ test('should run on double click', async ({ runUITest }) => {
await page.getByText('passes').dblclick();
await expect.poll(dumpTestTree(page), { timeout: 0 }).toBe(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes <=
fails
@ -91,9 +91,88 @@ test('should run on Enter', async ({ runUITest }) => {
await page.getByText('fails').click();
await page.keyboard.press('Enter');
await expect.poll(dumpTestTree(page), { timeout: 0 }).toBe(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
fails <=
`);
});
test('should run by project', async ({ runUITest }) => {
const page = await runUITest({
...basicTestTree,
'playwright.config.ts': `
import { defineConfig } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'foo' },
{ name: 'bar' },
],
});
`
});
await page.getByTitle('Run all').click();
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
fails <=
suite
b.test.ts
passes
fails
c.test.ts
passes
skipped
`);
await page.getByText('Status:').click();
await page.getByLabel('bar').setChecked(true);
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
fails <=
suite
b.test.ts
passes
fails
c.test.ts
passes
skipped
`);
await page.getByText('Status:').click();
await page.getByTestId('test-tree').getByText('passes').first().click();
await page.keyboard.press('ArrowRight');
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts
passes <=
foo
bar
fails
`);
await expect(page.getByText('Projects: foo bar')).toBeVisible();
await page.getByTitle('Run all').click();
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
foo
bar
fails
foo <=
bar
suite
b.test.ts
passes
fails
c.test.ts
passes
skipped
`);
});

View File

@ -37,7 +37,7 @@ const basicTestTree = {
test('should list tests', async ({ runUITest }) => {
const page = await runUITest(basicTestTree);
await expect.poll(dumpTestTree(page), { timeout: 0 }).toBe(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
fails
@ -51,7 +51,7 @@ test('should list tests', async ({ runUITest }) => {
test('should traverse up/down', async ({ runUITest }) => {
const page = await runUITest(basicTestTree);
await page.getByText('a.test.ts').click();
await expect.poll(dumpTestTree(page), { timeout: 0 }).toContain(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts <=
passes
fails
@ -59,14 +59,14 @@ test('should traverse up/down', async ({ runUITest }) => {
`);
await page.keyboard.press('ArrowDown');
await expect.poll(dumpTestTree(page), { timeout: 0 }).toContain(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts
passes <=
fails
suite
`);
await page.keyboard.press('ArrowDown');
await expect.poll(dumpTestTree(page), { timeout: 0 }).toContain(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts
passes
fails <=
@ -74,7 +74,7 @@ test('should traverse up/down', async ({ runUITest }) => {
`);
await page.keyboard.press('ArrowUp');
await expect.poll(dumpTestTree(page), { timeout: 0 }).toContain(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts
passes <=
fails
@ -87,7 +87,7 @@ test('should expand / collapse groups', async ({ runUITest }) => {
await page.getByText('suite').click();
await page.keyboard.press('ArrowRight');
await expect.poll(dumpTestTree(page), { timeout: 0 }).toContain(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts
passes
fails
@ -97,7 +97,7 @@ test('should expand / collapse groups', async ({ runUITest }) => {
`);
await page.keyboard.press('ArrowLeft');
await expect.poll(dumpTestTree(page), { timeout: 0 }).toContain(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts
passes
fails
@ -106,25 +106,14 @@ test('should expand / collapse groups', async ({ runUITest }) => {
await page.getByText('passes').first().click();
await page.keyboard.press('ArrowLeft');
await expect.poll(dumpTestTree(page), { timeout: 0 }).toContain(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts <=
passes
fails
`);
await page.keyboard.press('ArrowLeft');
await expect.poll(dumpTestTree(page), { timeout: 0 }).toContain(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts <=
`);
});
test('should filter by title', async ({ runUITest }) => {
const page = await runUITest(basicTestTree);
await page.getByPlaceholder('Filter').fill('inner');
await expect.poll(dumpTestTree(page), { timeout: 0 }).toBe(`
a.test.ts
suite
inner passes
inner fails
`);
});

View File

@ -37,7 +37,7 @@ const basicTestTree = {
test('should pick new / deleted files', async ({ runUITest, writeFiles, deleteFile }) => {
const page = await runUITest(basicTestTree);
await expect.poll(dumpTestTree(page), { timeout: 0 }).toBe(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
fails
@ -55,7 +55,7 @@ test('should pick new / deleted files', async ({ runUITest, writeFiles, deleteFi
`
});
await expect.poll(dumpTestTree(page), { timeout: 0 }).toBe(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
fails
@ -70,7 +70,7 @@ test('should pick new / deleted files', async ({ runUITest, writeFiles, deleteFi
await deleteFile('a.test.ts');
await expect.poll(dumpTestTree(page), { timeout: 0 }).toBe(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
b.test.ts
passes
fails
@ -82,7 +82,7 @@ test('should pick new / deleted files', async ({ runUITest, writeFiles, deleteFi
test('should pick new / deleted tests', async ({ runUITest, writeFiles, deleteFile }) => {
const page = await runUITest(basicTestTree);
await expect.poll(dumpTestTree(page), { timeout: 0 }).toBe(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
fails
@ -101,7 +101,7 @@ test('should pick new / deleted tests', async ({ runUITest, writeFiles, deleteFi
`
});
await expect.poll(dumpTestTree(page), { timeout: 0 }).toBe(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
new
@ -120,7 +120,7 @@ test('should pick new / deleted tests', async ({ runUITest, writeFiles, deleteFi
`
});
await expect.poll(dumpTestTree(page), { timeout: 0 }).toBe(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
new
b.test.ts
@ -131,7 +131,7 @@ test('should pick new / deleted tests', async ({ runUITest, writeFiles, deleteFi
test('should pick new / deleted nested tests', async ({ runUITest, writeFiles, deleteFile }) => {
const page = await runUITest(basicTestTree);
await expect.poll(dumpTestTree(page), { timeout: 0 }).toContain(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts
passes
fails
@ -140,7 +140,7 @@ test('should pick new / deleted nested tests', async ({ runUITest, writeFiles, d
await page.getByText('suite').click();
await page.keyboard.press('ArrowRight');
await expect.poll(dumpTestTree(page), { timeout: 0 }).toContain(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts
passes
fails
@ -160,7 +160,7 @@ test('should pick new / deleted nested tests', async ({ runUITest, writeFiles, d
`
});
await expect.poll(dumpTestTree(page), { timeout: 0 }).toContain(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toContain(`
a.test.ts
passes
suite <=

View File

@ -31,7 +31,7 @@ test('should watch files', async ({ runUITest, writeFiles }) => {
await page.getByRole('listitem').filter({ hasText: 'fails' }).getByTitle('Watch').click();
await page.getByRole('listitem').filter({ hasText: 'fails' }).getByTitle('Run').click();
await expect.poll(dumpTestTree(page), { timeout: 0 }).toBe(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
fails 👁 <=
@ -45,7 +45,7 @@ test('should watch files', async ({ runUITest, writeFiles }) => {
`
});
await expect.poll(dumpTestTree(page), { timeout: 0 }).toBe(`
await expect.poll(dumpTestTree(page), { timeout: 15000 }).toBe(`
a.test.ts
passes
fails 👁 <=