592 lines
20 KiB
TypeScript
Raw Normal View History

2023-03-01 15:27:23 -08:00
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@web/third_party/vscode/codicon.css';
import { Workbench } from './workbench';
2023-03-01 15:27:23 -08:00
import '@web/common.css';
import React from 'react';
import { ListView } from '@web/components/listView';
import { TeleReporterReceiver } from '../../../playwright-test/src/isomorphic/teleReceiver';
import type { FullConfig, Suite, TestCase, TestResult, TestStep } from '../../../playwright-test/types/testReporter';
2023-03-01 15:27:23 -08:00
import { SplitView } from '@web/components/splitView';
import { MultiTraceModel } from './modelUtil';
2023-03-01 15:27:23 -08:00
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';
2023-03-07 14:24:50 -08:00
import type { XtermDataSource } from '@web/components/xtermWrapper';
import { XtermWrapper } from '@web/components/xtermWrapper';
2023-03-01 15:27:23 -08:00
let updateRootSuite: (rootSuite: Suite, progress: Progress) => void = () => {};
let updateStepsProgress: () => void = () => {};
let runWatchedTests = () => {};
2023-03-07 12:43:16 -08:00
let runVisibleTests = () => {};
2023-03-01 15:27:23 -08:00
2023-03-07 14:24:50 -08:00
const xtermDataSource: XtermDataSource = {
pending: [],
clear: () => {},
write: data => xtermDataSource.pending.push(data),
resize: (cols: number, rows: number) => sendMessageNoReply('resizeTerminal', { cols, rows }),
};
2023-03-01 15:27:23 -08:00
export const WatchModeView: React.FC<{}> = ({
}) => {
2023-03-07 12:43:16 -08:00
const [projectNames, setProjectNames] = React.useState<string[]>([]);
const [rootSuite, setRootSuite] = React.useState<{ value: Suite | undefined }>({ value: undefined });
2023-03-07 12:43:16 -08:00
const [isRunningTest, setIsRunningTest] = React.useState<boolean>(false);
const [progress, setProgress] = React.useState<Progress>({ total: 0, passed: 0, failed: 0 });
2023-03-07 12:43:16 -08:00
const [selectedTestItem, setSelectedTestItem] = React.useState<TestItem | undefined>(undefined);
const [settingsVisible, setSettingsVisible] = React.useState<boolean>(false);
const [isWatchingFiles, setIsWatchingFiles] = React.useState<boolean>(true);
2023-03-07 12:43:16 -08:00
updateRootSuite = (rootSuite: Suite, { passed, failed }: Progress) => {
setRootSuite({ value: rootSuite });
progress.passed = passed;
progress.failed = failed;
setProgress({ ...progress });
};
2023-03-07 12:43:16 -08:00
const runTests = (testIds: string[]) => {
setProgress({ total: testIds.length, passed: 0, failed: 0 });
setIsRunningTest(true);
sendMessage('run', { testIds }).then(() => {
setIsRunningTest(false);
});
};
React.useEffect(() => {
if (projectNames.length === 0 && rootSuite.value?.suites.length)
setProjectNames([rootSuite.value?.suites[0].title]);
}, [projectNames, rootSuite]);
2023-03-07 14:24:50 -08:00
return <div className='vbox'>
<SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}>
<TraceView testItem={selectedTestItem}></TraceView>
<div className='vbox watch-mode-sidebar'>
<Toolbar>
<div className='section-title' style={{ cursor: 'pointer' }} onClick={() => setSettingsVisible(false)}>Tests</div>
<ToolbarButton icon='play' title='Run' onClick={runVisibleTests} disabled={isRunningTest}></ToolbarButton>
<ToolbarButton icon='debug-stop' title='Stop' onClick={() => sendMessageNoReply('stop')} disabled={!isRunningTest}></ToolbarButton>
<ToolbarButton icon='refresh' title='Reload' onClick={resetCollectingRootSuite} disabled={isRunningTest}></ToolbarButton>
<ToolbarButton icon='eye-watch' title='Watch' toggled={isWatchingFiles} onClick={() => setIsWatchingFiles(!isWatchingFiles)}></ToolbarButton>
2023-03-07 12:43:16 -08:00
<div className='spacer'></div>
2023-03-07 14:24:50 -08:00
<ToolbarButton icon='gear' title='Toggle color mode' toggled={settingsVisible} onClick={() => { setSettingsVisible(!settingsVisible); }}></ToolbarButton>
</Toolbar>
{ !settingsVisible && <TestList
projectNames={projectNames}
rootSuite={rootSuite}
isRunningTest={isRunningTest}
isWatchingFiles={isWatchingFiles}
2023-03-07 14:24:50 -08:00
runTests={runTests}
onTestItemSelected={setSelectedTestItem} />}
{settingsVisible && <SettingsView projectNames={projectNames} setProjectNames={setProjectNames} onClose={() => setSettingsVisible(false)}></SettingsView>}
</div>
</SplitView>
<div className='status-line'>
2023-03-07 12:43:16 -08:00
Running: {progress.total} tests | {progress.passed} passed | {progress.failed} failed
</div>
2023-03-07 14:24:50 -08:00
</div>;
2023-03-07 12:43:16 -08:00
};
export const TestList: React.FC<{
projectNames: string[],
rootSuite: { value: Suite | undefined },
runTests: (testIds: string[]) => void,
isRunningTest: boolean,
isWatchingFiles: boolean,
2023-03-07 12:43:16 -08:00
onTestItemSelected: (test: TestItem | undefined) => void,
}> = ({ projectNames, rootSuite, runTests, isRunningTest, isWatchingFiles, onTestItemSelected }) => {
2023-03-01 15:27:23 -08:00
const [filterText, setFilterText] = React.useState<string>('');
2023-03-07 12:43:16 -08:00
const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>();
const [expandedItems, setExpandedItems] = React.useState<Map<string, boolean>>(new Map());
2023-03-01 15:27:23 -08:00
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
inputRef.current?.focus();
resetCollectingRootSuite();
2023-03-01 15:27:23 -08:00
}, []);
const { filteredItems, treeItemMap, visibleTestIds } = React.useMemo(() => {
const treeItems = createTree(rootSuite.value, projectNames);
const filteredItems = filterTree(treeItems, filterText);
const treeItemMap = new Map<string, TreeItem>();
const visibleTestIds = new Set<string>();
const visit = (treeItem: TreeItem) => {
if (treeItem.kind === 'test')
visibleTestIds.add(treeItem.id);
treeItem.children?.forEach(visit);
treeItemMap.set(treeItem.id, treeItem);
};
filteredItems.forEach(visit);
return { treeItemMap, visibleTestIds, filteredItems };
}, [filterText, rootSuite, projectNames]);
2023-03-07 12:43:16 -08:00
runVisibleTests = () => runTests([...visibleTestIds]);
const { listItems } = React.useMemo(() => {
const listItems = flattenTree(filteredItems, expandedItems, !!filterText.trim());
return { listItems };
}, [filteredItems, filterText, expandedItems]);
const { selectedTreeItem, selectedTestItem } = React.useMemo(() => {
const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined;
let selectedTestItem: TestItem | undefined;
if (selectedTreeItem?.kind === 'test')
selectedTestItem = selectedTreeItem;
else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.children?.length === 1)
selectedTestItem = selectedTreeItem.children[0]! as TestItem;
return { selectedTreeItem, selectedTestItem };
}, [selectedTreeItemId, treeItemMap]);
React.useEffect(() => {
sendMessageNoReply('watch', { fileName: isWatchingFiles ? fileName(selectedTestItem) : undefined });
}, [selectedTestItem, isWatchingFiles]);
2023-03-07 12:43:16 -08:00
onTestItemSelected(selectedTestItem);
const runTreeItem = (treeItem: TreeItem) => {
expandedItems.set(treeItem.id, true);
setSelectedTreeItemId(treeItem.id);
runTests(collectTestIds(treeItem));
};
runWatchedTests = () => {
runTests(collectTestIds(selectedTreeItem));
};
2023-03-07 12:43:16 -08:00
return <div className='vbox'>
<Toolbar>
<input ref={inputRef} type='search' placeholder='Filter (e.g. text, @tag)' spellCheck={false} value={filterText}
onChange={e => {
setFilterText(e.target.value);
2023-03-01 15:27:23 -08:00
}}
2023-03-07 12:43:16 -08:00
onKeyDown={e => {
if (e.key === 'Enter')
runVisibleTests();
}}></input>
</Toolbar>
<ListView
items={listItems}
itemKey={(treeItem: TreeItem) => treeItem.id }
itemRender={(treeItem: TreeItem) => {
return <div className='hbox watch-mode-list-item'>
<div className='watch-mode-list-item-title'>{treeItem.title}</div>
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={isRunningTest}></ToolbarButton>
</div>;
}}
itemIcon={(treeItem: TreeItem) => {
if (treeItem.kind === 'case' && treeItem.children?.length === 1)
treeItem = treeItem.children[0];
if (treeItem.kind === 'test') {
const ok = treeItem.test.outcome() === 'expected';
const failed = treeItem.test.results.length && treeItem.test.outcome() !== 'expected';
const running = treeItem.test.results.some(r => r.duration === -1);
if (running)
return 'codicon-loading';
if (ok)
return 'codicon-check';
if (failed)
return 'codicon-error';
} else {
return treeItem.expanded ? 'codicon-chevron-down' : 'codicon-chevron-right';
}
}}
itemIndent={(treeItem: TreeItem) => treeItem.kind === 'file' ? 0 : treeItem.kind === 'case' ? 1 : 2}
selectedItem={selectedTreeItem}
onAccepted={runTreeItem}
onLeftArrow={(treeItem: TreeItem) => {
if (treeItem.children && treeItem.expanded) {
expandedItems.set(treeItem.id, false);
setExpandedItems(new Map(expandedItems));
2023-03-07 12:43:16 -08:00
} else {
setSelectedTreeItemId(treeItem.parent?.id);
}
}}
onRightArrow={(treeItem: TreeItem) => {
if (treeItem.children) {
expandedItems.set(treeItem.id, true);
setExpandedItems(new Map(expandedItems));
}
}}
onSelected={(treeItem: TreeItem) => {
setSelectedTreeItemId(treeItem.id);
}}
onIconClicked={(treeItem: TreeItem) => {
if (treeItem.kind === 'test')
return;
if (treeItem.expanded)
expandedItems.set(treeItem.id, false);
else
expandedItems.set(treeItem.id, true);
setExpandedItems(new Map(expandedItems));
}}
2023-03-07 14:24:50 -08:00
noItemsMessage='No tests' />
</div>;
};
export const SettingsView: React.FC<{
projectNames: string[],
setProjectNames: (projectNames: string[]) => void,
onClose: () => void,
}> = ({ projectNames, setProjectNames, 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>
{projectNames.map(projectName => {
return <div style={{ display: 'flex', alignItems: 'center', lineHeight: '24px' }}>
<input id={`project-${projectName}`} type='checkbox' checked={projectNames.includes(projectName)} onClick={() => {
const copy = [...projectNames];
if (copy.includes(projectName))
copy.splice(copy.indexOf(projectName), 1);
else
copy.push(projectName);
setProjectNames(copy);
}} style={{ margin: '0 5px 0 10px' }} />
<label htmlFor={`project-${projectName}`}>
{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>
2023-03-07 12:43:16 -08:00
</div>;
2023-03-01 15:27:23 -08:00
};
export const TraceView: React.FC<{
testItem: TestItem | undefined,
2023-03-07 12:43:16 -08:00
}> = ({ testItem }) => {
2023-03-01 15:27:23 -08:00
const [model, setModel] = React.useState<MultiTraceModel | undefined>();
const [stepsProgress, setStepsProgress] = React.useState(0);
updateStepsProgress = () => setStepsProgress(stepsProgress + 1);
2023-03-01 15:27:23 -08:00
React.useEffect(() => {
(async () => {
if (!testItem) {
2023-03-01 15:27:23 -08:00
setModel(undefined);
return;
}
const result = testItem.test?.results?.[0];
if (result) {
2023-03-01 15:27:23 -08:00
const attachment = result.attachments.find(a => a.name === 'trace');
if (attachment && attachment.path)
loadSingleTraceFile(attachment.path).then(setModel);
else
setModel(stepsToModel(result));
} else {
setModel(undefined);
2023-03-01 15:27:23 -08:00
}
})();
2023-03-07 12:43:16 -08:00
}, [testItem, stepsProgress]);
2023-03-01 15:27:23 -08:00
2023-03-07 14:24:50 -08:00
const xterm = <XtermWrapper source={xtermDataSource}></XtermWrapper>;
return <Workbench model={model} output={xterm} rightToolbar={[
<ToolbarButton icon='trash' title='Clear output' onClick={() => xtermDataSource.clear()}></ToolbarButton>,
]}/>;
2023-03-01 15:27:23 -08:00
};
declare global {
interface Window {
binding(data: any): Promise<void>;
}
}
let receiver: TeleReporterReceiver | undefined;
const resetCollectingRootSuite = () => {
let rootSuite: Suite;
const progress: Progress = {
total: 0,
passed: 0,
failed: 0,
};
receiver = new TeleReporterReceiver({
onBegin: (config: FullConfig, suite: Suite) => {
if (!rootSuite)
rootSuite = suite;
progress.passed = 0;
progress.failed = 0;
updateRootSuite(rootSuite, progress);
},
onTestBegin: () => {
updateRootSuite(rootSuite, progress);
},
onTestEnd: (test: TestCase) => {
if (test.outcome() === 'unexpected')
++progress.failed;
else
++progress.passed;
updateRootSuite(rootSuite, progress);
},
onStepBegin: () => {
updateStepsProgress();
},
onStepEnd: () => {
updateStepsProgress();
},
});
sendMessageNoReply('list');
};
2023-03-01 15:27:23 -08:00
(window as any).dispatch = (message: any) => {
2023-03-07 14:24:50 -08:00
if (message.method === 'fileChanged') {
runWatchedTests();
2023-03-07 14:24:50 -08:00
} else if (message.method === 'stdio') {
if (message.params.buffer) {
const data = atob(message.params.buffer);
xtermDataSource.write(data);
} else {
xtermDataSource.write(message.params.text);
}
} else {
receiver?.dispatch(message);
2023-03-07 14:24:50 -08:00
}
};
const sendMessage = async (method: string, params: any) => {
await (window as any).sendMessage({ method, params });
2023-03-01 15:27:23 -08:00
};
const sendMessageNoReply = (method: string, params?: any) => {
sendMessage(method, params).catch((e: Error) => {
// eslint-disable-next-line no-console
console.error(e);
2023-03-01 15:27:23 -08:00
});
};
const fileName = (treeItem?: TreeItem): string | undefined => {
if (!treeItem)
return;
if (treeItem.kind === 'file')
return treeItem.file;
return fileName(treeItem.parent || undefined);
};
const collectTestIds = (treeItem?: TreeItem): string[] => {
if (!treeItem)
return [];
const testIds: string[] = [];
const visit = (treeItem: TreeItem) => {
if (treeItem.kind === 'test')
testIds.push(treeItem.id);
treeItem.children?.forEach(visit);
};
visit(treeItem);
return testIds;
};
type Progress = {
total: number;
passed: number;
failed: number;
};
type TreeItemBase = {
kind: 'file' | 'case' | 'test',
id: string;
title: string;
parent: TreeItem | null;
children?: TreeItem[];
expanded?: boolean;
};
type FileItem = TreeItemBase & {
kind: 'file',
file: string;
};
type TestCaseItem = TreeItemBase & {
kind: 'case',
};
type TestItem = TreeItemBase & {
kind: 'test',
test: TestCase;
};
type TreeItem = FileItem | TestCaseItem | TestItem;
function createTree(rootSuite: Suite | undefined, projectNames: string[]): FileItem[] {
const fileItems = new Map<string, FileItem>();
for (const projectSuite of rootSuite?.suites || []) {
if (!projectNames.includes(projectSuite.title))
continue;
for (const fileSuite of projectSuite.suites) {
const file = fileSuite.location!.file;
let fileItem = fileItems.get(file);
if (!fileItem) {
fileItem = {
kind: 'file',
id: fileSuite.title,
title: fileSuite.title,
file,
parent: null,
children: [],
expanded: false,
};
fileItems.set(fileSuite.location!.file, fileItem);
}
for (const test of fileSuite.allTests()) {
const title = test.titlePath().slice(3).join(' ');
let testCaseItem = fileItem.children!.find(t => t.title === title);
if (!testCaseItem) {
testCaseItem = {
kind: 'case',
id: fileItem.id + ' / ' + title,
title,
parent: fileItem,
children: [],
expanded: false,
};
fileItem.children!.push(testCaseItem);
}
testCaseItem.children!.push({
kind: 'test',
id: test.id,
title: projectSuite.title,
parent: testCaseItem,
test,
});
}
}
}
return [...fileItems.values()];
}
function filterTree(fileItems: FileItem[], filterText: string): FileItem[] {
const trimmedFilterText = filterText.trim();
const filterTokens = trimmedFilterText.toLowerCase().split(' ');
const result: FileItem[] = [];
for (const fileItem of fileItems) {
if (trimmedFilterText) {
const filteredCases: TreeItem[] = [];
for (const testCaseItem of fileItem.children!) {
const fullTitle = (fileItem.title + ' ' + testCaseItem.title).toLowerCase();
if (filterTokens.every(token => fullTitle.includes(token)))
filteredCases.push(testCaseItem);
}
fileItem.children = filteredCases;
}
if (fileItem.children!.length)
result.push(fileItem);
}
return result;
}
function flattenTree(fileItems: FileItem[], expandedItems: Map<string, boolean | undefined>, hasFilter: boolean): TreeItem[] {
const result: TreeItem[] = [];
for (const fileItem of fileItems) {
result.push(fileItem);
const expandState = expandedItems.get(fileItem.id);
const autoExpandMatches = result.length < 100 && (hasFilter && expandState !== false);
fileItem.expanded = expandState || autoExpandMatches;
if (fileItem.expanded) {
for (const testCaseItem of fileItem.children!) {
result.push(testCaseItem);
testCaseItem.expanded = !!expandedItems.get(testCaseItem.id);
if (testCaseItem.expanded && testCaseItem.children!.length > 1)
result.push(...testCaseItem.children!);
}
}
}
return result;
}
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);
}
function stepsToModel(result: TestResult): MultiTraceModel {
let startTime = Number.MAX_VALUE;
let endTime = Number.MIN_VALUE;
const actions: trace.ActionTraceEvent[] = [];
const flatSteps: TestStep[] = [];
const visit = (step: TestStep) => {
flatSteps.push(step);
step.steps.forEach(visit);
};
result.steps.forEach(visit);
for (const step of flatSteps) {
let callId: string;
if (step.category === 'pw:api')
callId = `call@${actions.length}`;
else if (step.category === 'expect')
callId = `expect@${actions.length}`;
else
continue;
const action: trace.ActionTraceEvent = {
type: 'action',
callId,
startTime: step.startTime.getTime(),
endTime: step.startTime.getTime() + step.duration,
apiName: step.title,
class: '',
method: '',
params: {},
wallTime: step.startTime.getTime(),
log: [],
snapshots: [],
error: step.error ? { name: 'Error', message: step.error.message || step.error.value || '' } : undefined,
};
if (startTime > action.startTime)
startTime = action.startTime;
if (endTime < action.endTime)
endTime = action.endTime;
actions.push(action);
}
const contextEntry: ContextEntry = {
traceUrl: '',
startTime,
endTime,
browserName: '',
options: {
viewport: undefined,
deviceScaleFactor: undefined,
isMobile: undefined,
userAgent: undefined
},
pages: [],
resources: [],
actions,
events: [],
initializers: {},
hasSource: false
};
return new MultiTraceModel([contextEntry]);
}