/** * 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'; 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'; import { SplitView } from '@web/components/splitView'; 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'; let updateRootSuite: (rootSuite: Suite, progress: Progress) => void = () => {}; let updateStepsProgress: () => void = () => {}; let runWatchedTests = () => {}; let runVisibleTests = () => {}; const xtermDataSource: XtermDataSource = { pending: [], clear: () => {}, write: data => xtermDataSource.pending.push(data), resize: (cols: number, rows: number) => sendMessageNoReply('resizeTerminal', { cols, rows }), }; export const WatchModeView: React.FC<{}> = ({ }) => { const [projectNames, setProjectNames] = React.useState([]); const [rootSuite, setRootSuite] = React.useState<{ value: Suite | undefined }>({ value: undefined }); const [isRunningTest, setIsRunningTest] = React.useState(false); const [progress, setProgress] = React.useState({ total: 0, passed: 0, failed: 0 }); const [selectedTestItem, setSelectedTestItem] = React.useState(undefined); const [settingsVisible, setSettingsVisible] = React.useState(false); const [isWatchingFiles, setIsWatchingFiles] = React.useState(true); updateRootSuite = (rootSuite: Suite, { passed, failed }: Progress) => { setRootSuite({ value: rootSuite }); progress.passed = passed; progress.failed = failed; setProgress({ ...progress }); }; 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]); return
setSettingsVisible(false)}>Tests
sendMessageNoReply('stop')} disabled={!isRunningTest}> setIsWatchingFiles(!isWatchingFiles)}>
{ setSettingsVisible(!settingsVisible); }}>
{ !settingsVisible && } {settingsVisible && setSettingsVisible(false)}>}
Running: {progress.total} tests | {progress.passed} passed | {progress.failed} failed
; }; export const TestList: React.FC<{ projectNames: string[], rootSuite: { value: Suite | undefined }, runTests: (testIds: string[]) => void, isRunningTest: boolean, isWatchingFiles: boolean, onTestItemSelected: (test: TestItem | undefined) => void, }> = ({ projectNames, rootSuite, runTests, isRunningTest, isWatchingFiles, onTestItemSelected }) => { const [filterText, setFilterText] = React.useState(''); const [selectedTreeItemId, setSelectedTreeItemId] = React.useState(); const [expandedItems, setExpandedItems] = React.useState>(new Map()); const inputRef = React.useRef(null); React.useEffect(() => { inputRef.current?.focus(); resetCollectingRootSuite(); }, []); const { filteredItems, treeItemMap, visibleTestIds } = React.useMemo(() => { const treeItems = createTree(rootSuite.value, projectNames); const filteredItems = filterTree(treeItems, filterText); const treeItemMap = new Map(); const visibleTestIds = new Set(); 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]); 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]); onTestItemSelected(selectedTestItem); const runTreeItem = (treeItem: TreeItem) => { expandedItems.set(treeItem.id, true); setSelectedTreeItemId(treeItem.id); runTests(collectTestIds(treeItem)); }; runWatchedTests = () => { runTests(collectTestIds(selectedTreeItem)); }; return
{ setFilterText(e.target.value); }} onKeyDown={e => { if (e.key === 'Enter') runVisibleTests(); }}> treeItem.id } itemRender={(treeItem: TreeItem) => { return
{treeItem.title}
runTreeItem(treeItem)} disabled={isRunningTest}>
; }} 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)); } 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)); }} noItemsMessage='No tests' />
; }; export const SettingsView: React.FC<{ projectNames: string[], setProjectNames: (projectNames: string[]) => void, onClose: () => void, }> = ({ projectNames, setProjectNames, onClose }) => { return
Projects
{projectNames.map(projectName => { return
{ 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' }} />
; })}
Appearance
toggleTheme()}>Toggle color mode
; }; export const TraceView: React.FC<{ testItem: TestItem | undefined, }> = ({ testItem }) => { const [model, setModel] = React.useState(); const [stepsProgress, setStepsProgress] = React.useState(0); updateStepsProgress = () => setStepsProgress(stepsProgress + 1); React.useEffect(() => { (async () => { if (!testItem) { setModel(undefined); return; } const result = testItem.test?.results?.[0]; if (result) { 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); } })(); }, [testItem, stepsProgress]); const xterm = ; return xtermDataSource.clear()}>, ]}/>; }; declare global { interface Window { binding(data: any): Promise; } } 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'); }; (window as any).dispatch = (message: any) => { if (message.method === 'fileChanged') { runWatchedTests(); } 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); } }; const sendMessage = async (method: string, params: any) => { await (window as any).sendMessage({ method, params }); }; const sendMessageNoReply = (method: string, params?: any) => { sendMessage(method, params).catch((e: Error) => { // eslint-disable-next-line no-console console.error(e); }); }; 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(); 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, 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 { 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]); }