/** * 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 = () => {}; let xtermSize = { cols: 80, rows: 24 }; const xtermDataSource: XtermDataSource = { pending: [], clear: () => {}, write: data => xtermDataSource.pending.push(data), resize: (cols: number, rows: number) => { xtermSize = { cols, rows }; 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[]) => { // Clear test results. { const testIdSet = new Set(testIds); for (const test of rootSuite.value?.allTests() || []) { if (testIdSet.has(test.id)) test.results = []; } setRootSuite({ ...rootSuite }); } const time = ' [' + new Date().toLocaleTimeString() + ']'; xtermDataSource.write('\x1B[2m—'.repeat(Math.max(0, xtermSize.cols - time.length)) + time + '\x1B[22m'); setProgress({ total: testIds.length, passed: 0, failed: 0 }); setIsRunningTest(true); sendMessage('run', { testIds }).then(() => { setIsRunningTest(false); }); }; const result = selectedTest?.results[0]; return
{(result && result.duration >= 0) ? : }
setSettingsVisible(false)}>Tests
runVisibleTests()} disabled={isRunningTest}> 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 } = React.useMemo(() => { const rootItem = createTree(rootSuite.value, projects); filterTree(rootItem, filterText); hideOnlyTests(rootItem); const treeItemMap = new Map(); const visibleTestIds = new Set(); const visit = (treeItem: TreeItem) => { if (treeItem.kind === 'case') treeItem.tests.forEach(t => visibleTestIds.add(t.id)); treeItem.children.forEach(visit); treeItemMap.set(treeItem.id, treeItem); }; visit(rootItem); runVisibleTests = () => runTests([...visibleTestIds]); return { rootItem, treeItemMap }; }, [filterText, rootSuite, projects, runTests]); 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) => { 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.status === 'running') return 'codicon-loading'; if (treeItem.status === 'failed') return 'codicon-error'; if (treeItem.status === 'passed') return 'codicon-check'; if (treeItem.status === 'skipped') return 'codicon-circle-slash'; 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); }}/>
; })}
Appearance
toggleTheme()}>Toggle color mode
; }; export const InProgressTraceView: React.FC<{ testResult: TestResult | undefined, }> = ({ testResult }) => { const [model, setModel] = React.useState(); const [stepsProgress, setStepsProgress] = React.useState(0); updateStepsProgress = () => setStepsProgress(stepsProgress + 1); React.useEffect(() => { setModel(testResult ? stepsToModel(testResult) : undefined); }, [stepsProgress, testResult]); return ; }; export const FinishedTraceView: React.FC<{ testResult: TestResult, }> = ({ testResult }) => { const [model, setModel] = React.useState(); React.useEffect(() => { // Test finished. const attachment = testResult.attachments.find(a => a.name === 'trace'); if (attachment && attachment.path) loadSingleTraceFile(attachment.path).then(setModel); }, [testResult]); return ; }; export const TraceView: React.FC<{ model: MultiTraceModel | undefined, }> = ({ model }) => { 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); // This will update selected trace viewer. updateStepsProgress(); }, 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 => { return treeItem?.location.file; }; const locationToOpen = (treeItem?: TreeItem) => { if (!treeItem) return; return treeItem.location.file + ':' + treeItem.location.line; }; 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' | 'group' | 'case' | 'test', id: string; title: string; location: Location, children: TreeItem[]; status: 'none' | 'running' | 'passed' | 'failed' | 'skipped'; }; type GroupItem = TreeItemBase & { kind: 'group', children: (TestCaseItem | GroupItem)[]; }; type TestCaseItem = TreeItemBase & { kind: 'case', tests: TestCase[]; }; type TestItem = TreeItemBase & { kind: 'test', test: TestCase; }; type TreeItem = GroupItem | TestCaseItem | TestItem; function createTree(rootSuite: Suite | undefined, projects: Map): GroupItem { const rootItem: GroupItem = { kind: 'group', id: 'root', title: '', location: { file: '', line: 0, column: 0 }, children: [], status: 'none', }; const visitSuite = (projectName: string, parentSuite: Suite, parentGroup: GroupItem) => { for (const suite of parentSuite.suites) { const title = suite.title; let group = parentGroup.children.find(item => item.title === title) as GroupItem | undefined; if (!group) { group = { kind: 'group', id: parentGroup.id + '\x1e' + title, title, location: suite.location!, children: [], status: 'none', }; parentGroup.children.push(group); } visitSuite(projectName, suite, group); } for (const test of parentSuite.tests) { const title = test.title; let testCaseItem = parentGroup.children.find(t => t.title === title) as TestCaseItem; if (!testCaseItem) { testCaseItem = { kind: 'case', id: parentGroup.id + '\x1e' + title, title, children: [], tests: [], location: test.location, status: 'none', }; parentGroup.children.push(testCaseItem); } let status: 'none' | 'running' | 'passed' | 'failed' | 'skipped' = 'none'; if (test.results.some(r => r.duration === -1)) status = 'running'; else if (test.results.length && test.outcome() === 'skipped') status = 'skipped'; else if (test.results.length && test.outcome() !== 'expected') status = 'failed'; else if (test.results.length && test.outcome() === 'expected') status = 'passed'; testCaseItem.tests.push(test); testCaseItem.children.push({ kind: 'test', id: test.id, title: projectName, location: test.location!, test, children: [], status, }); } }; for (const projectSuite of rootSuite?.suites || []) { if (!projects.get(projectSuite.title)) continue; visitSuite(projectSuite.title, projectSuite, rootItem); } const propagateStatus = (treeItem: TreeItem) => { for (const child of treeItem.children) propagateStatus(child); let allPassed = treeItem.children.length > 0; let allSkipped = treeItem.children.length > 0; let hasFailed = false; let hasRunning = false; for (const child of treeItem.children) { allSkipped = allSkipped && child.status === 'skipped'; allPassed = allPassed && (child.status === 'passed' || child.status === 'skipped'); hasFailed = hasFailed || child.status === 'failed'; hasRunning = hasRunning || child.status === 'running'; } if (hasRunning) treeItem.status = 'running'; else if (hasFailed) treeItem.status = 'failed'; else if (allSkipped) treeItem.status = 'skipped'; else if (allPassed) treeItem.status = 'passed'; }; propagateStatus(rootItem); return rootItem; } function filterTree(rootItem: GroupItem, filterText: string) { const trimmedFilterText = filterText.trim(); const filterTokens = trimmedFilterText.toLowerCase().split(' '); const visit = (treeItem: GroupItem) => { const newChildren: (GroupItem | TestCaseItem)[] = []; for (const child of treeItem.children) { if (child.kind === 'case') { const title = child.tests[0].titlePath().join(' ').toLowerCase(); if (filterTokens.every(token => title.includes(token))) newChildren.push(child); } else { visit(child); if (child.children.length) newChildren.push(child); } } treeItem.children = newChildren; }; visit(rootItem); } function hideOnlyTests(rootItem: GroupItem) { 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]); }