/** * 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 { TreeView } from '@web/components/treeView'; import type { TreeState } from '@web/components/treeView'; import { TeleReporterReceiver } 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'; 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 [projects, setProjects] = React.useState>(new Map()); 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 [selectedTest, setSelectedTest] = React.useState(undefined); const [settingsVisible, setSettingsVisible] = React.useState(false); const [isWatchingFiles, setIsWatchingFiles] = React.useState(true); updateRootSuite = (rootSuite: Suite, { passed, failed }: Progress) => { for (const projectName of projects.keys()) { if (!rootSuite.suites.find(s => s.title === projectName)) projects.delete(projectName); } for (const projectSuite of rootSuite.suites) { if (!projects.has(projectSuite.title)) projects.set(projectSuite.title, false); } if (![...projects.values()].includes(true)) projects.set(projects.entries().next().value[0], true); progress.passed = passed; progress.failed = failed; setRootSuite({ value: rootSuite }); setProjects(new Map(projects)); setProgress({ ...progress }); }; const runTests = (testIds: string[]) => { setProgress({ total: testIds.length, passed: 0, failed: 0 }); setIsRunningTest(true); sendMessage('run', { testIds }).then(() => { setIsRunningTest(false); }); }; return
setSettingsVisible(false)}>Tests
sendMessageNoReply('stop')} disabled={!isRunningTest}> refreshRootSuite(true)} disabled={isRunningTest}> setIsWatchingFiles(!isWatchingFiles)}>
{ setSettingsVisible(!settingsVisible); }}>
{settingsVisible && setSettingsVisible(false)}>}
Running: {progress.total} tests | {progress.passed} passed | {progress.failed} failed
; }; const TreeListView = TreeView; export const TestList: React.FC<{ projects: Map, rootSuite: { value: Suite | undefined }, runTests: (testIds: string[]) => void, isRunningTest: boolean, isWatchingFiles: boolean, isVisible: boolean onTestSelected: (test: TestCase | undefined) => void, }> = ({ projects, rootSuite, runTests, isRunningTest, isWatchingFiles, isVisible, onTestSelected }) => { const [treeState, setTreeState] = React.useState({ expandedItems: new Map() }); const [filterText, setFilterText] = React.useState(''); const [selectedTreeItemId, setSelectedTreeItemId] = React.useState(); const inputRef = React.useRef(null); React.useEffect(() => { inputRef.current?.focus(); refreshRootSuite(true); }, []); const { rootItem, treeItemMap, visibleTestIds } = React.useMemo(() => { const rootItem = createTree(rootSuite.value, projects); filterTree(rootItem, 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); }; visit(rootItem); hideOnlyTests(rootItem); return { rootItem, treeItemMap, visibleTestIds }; }, [filterText, rootSuite, projects]); runVisibleTests = () => runTests([...visibleTestIds]); const { selectedTreeItem } = React.useMemo(() => { const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined; let selectedTest: TestCase | undefined; if (selectedTreeItem?.kind === 'test') selectedTest = selectedTreeItem.test; else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.tests.length === 1) selectedTest = selectedTreeItem.tests[0]; onTestSelected(selectedTest); return { selectedTreeItem }; }, [onTestSelected, selectedTreeItemId, treeItemMap]); React.useEffect(() => { sendMessageNoReply('watch', { fileName: isWatchingFiles ? fileName(selectedTreeItem) : undefined }); }, [selectedTreeItem, isWatchingFiles]); const runTreeItem = (treeItem: TreeItem) => { // expandedItems.set(treeItem.id, true); setSelectedTreeItemId(treeItem.id); runTests(collectTestIds(treeItem)); }; runWatchedTests = () => { runTests(collectTestIds(selectedTreeItem)); }; if (!isVisible) return <>; return
{ setFilterText(e.target.value); }} onKeyDown={e => { if (e.key === 'Enter') runVisibleTests(); }}> { return
{treeItem.title}
runTreeItem(treeItem)} disabled={isRunningTest}> sendMessageNoReply('open', { location: locationToOpen(treeItem) })}>
; }} icon={treeItem => { if (treeItem.kind === 'case') { let allOk = true; let hasFailed = false; let hasRunning = false; for (const test of treeItem.tests) { allOk = allOk && test.outcome() === 'expected'; hasFailed = hasFailed || (!!test.results.length && test.outcome() !== 'expected'); hasRunning = hasRunning || test.results.some(r => r.duration === -1); } if (hasRunning) return 'codicon-loading'; if (allOk) return 'codicon-check'; if (hasFailed) return 'codicon-error'; } 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'; } return 'codicon-circle-outline'; }} selectedItem={selectedTreeItem} onAccepted={runTreeItem} onSelected={treeItem => { setSelectedTreeItemId(treeItem.id); }} noItemsMessage='No tests' />
; }; export const SettingsView: React.FC<{ projects: Map, setProjects: (projectNames: Map) => void, onClose: () => void, }> = ({ projects, setProjects, onClose }) => { return
Projects
{[...projects.entries()].map(([projectName, value]) => { return
{ const copy = new Map(projects); copy.set(projectName, !copy.get(projectName)); if (![...copy.values()].includes(true)) copy.set(projectName, true); setProjects(copy); }} style={{ margin: '0 5px 0 10px' }} />
; })}
Appearance
toggleTheme()}>Toggle color mode
; }; export const TraceView: React.FC<{ test: TestCase | undefined, }> = ({ test }) => { const [model, setModel] = React.useState(); const [stepsProgress, setStepsProgress] = React.useState(0); updateStepsProgress = () => setStepsProgress(stepsProgress + 1); React.useEffect(() => { (async () => { if (!test) { setModel(undefined); return; } const result = 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); } })(); }, [test, stepsProgress]); const xterm = ; return xtermDataSource.clear()}>, ]}/>; }; declare global { interface Window { binding(data: any): Promise; } } let receiver: TeleReporterReceiver | undefined; const refreshRootSuite = (eraseResults: boolean) => { if (!eraseResults) { sendMessageNoReply('list'); return; } 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 === 'listChanged') { refreshRootSuite(false); return; } if (message.method === 'fileChanged') { runWatchedTests(); return; } if (message.method === 'stdio') { if (message.params.buffer) { const data = atob(message.params.buffer); xtermDataSource.write(data); } else { xtermDataSource.write(message.params.text); } return; } 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 locationToOpen = (treeItem?: TreeItem) => { if (!treeItem) return; if (treeItem.kind === 'test') return treeItem.test.location.file + ':' + treeItem.test.location.line; if (treeItem.kind === 'case') return treeItem.location.file + ':' + treeItem.location.line; if (treeItem.kind === 'file') return treeItem.file; }; const collectTestIds = (treeItem?: TreeItem): string[] => { if (!treeItem) return []; const testIds: string[] = []; const visit = (treeItem: TreeItem) => { if (treeItem.kind === 'case') testIds.push(...treeItem.tests.map(t => t.id)); else if (treeItem.kind === 'test') testIds.push(treeItem.id); else treeItem.children?.forEach(visit); }; visit(treeItem); return testIds; }; type Progress = { total: number; passed: number; failed: number; }; type TreeItemBase = { kind: 'root' | 'file' | 'case' | 'test', id: string; title: string; parent: TreeItem | null; children: TreeItem[]; expanded?: boolean; }; type RootItem = TreeItemBase & { kind: 'root', children: FileItem[]; }; type FileItem = TreeItemBase & { kind: 'file', file: string; children: TestCaseItem[]; }; type TestCaseItem = TreeItemBase & { kind: 'case', tests: TestCase[]; location: Location, }; type TestItem = TreeItemBase & { kind: 'test', test: TestCase; }; type TreeItem = RootItem | FileItem | TestCaseItem | TestItem; function createTree(rootSuite: Suite | undefined, projects: Map): RootItem { const rootItem: RootItem = { kind: 'root', id: 'root', title: '', parent: null, children: [], }; const fileItems = new Map(); for (const projectSuite of rootSuite?.suites || []) { if (!projects.get(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); rootItem.children.push(fileItem); } for (const test of fileSuite.allTests()) { const title = test.titlePath().slice(3).join(' › '); let testCaseItem = fileItem.children.find(t => t.title === title) as TestCaseItem; if (!testCaseItem) { testCaseItem = { kind: 'case', id: fileItem.id + ' / ' + title, title, parent: fileItem, children: [], tests: [], expanded: false, location: test.location, }; fileItem.children.push(testCaseItem); } testCaseItem.tests.push(test); testCaseItem.children.push({ kind: 'test', id: test.id, title: projectSuite.title, parent: testCaseItem, test, children: [], }); } (fileItem.children as TestCaseItem[]).sort((a, b) => a.location.line - b.location.line); } } return rootItem; } function filterTree(rootItem: RootItem, filterText: string) { const trimmedFilterText = filterText.trim(); const filterTokens = trimmedFilterText.toLowerCase().split(' '); const result: FileItem[] = []; for (const fileItem of rootItem.children) { if (trimmedFilterText) { const filteredCases: TestCaseItem[] = []; 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); } rootItem.children = result; } function hideOnlyTests(rootItem: RootItem) { const visit = (treeItem: TreeItem) => { if (treeItem.kind === 'case' && treeItem.children.length === 1) treeItem.children = []; else treeItem.children.forEach(visit); }; visit(rootItem); } 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]); }