/** * 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 { 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'; 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'; let updateRootSuite: (rootSuite: Suite, progress: Progress) => void = () => {}; let updateStepsProgress: () => void = () => {}; let runWatchedTests = (fileName: string) => {}; 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 [filterText, setFilterText] = useSetting('test-ui-filter-text', ''); const [filterExpanded, setFilterExpanded] = useSetting('test-ui-filter-expanded', false); const [isShowingOutput, setIsShowingOutput] = useSetting('test-ui-show-output', false); const [projects, setProjects] = React.useState>(new Map()); const [rootSuite, setRootSuite] = React.useState<{ value: Suite | undefined }>({ value: undefined }); const [progress, setProgress] = React.useState({ total: 0, passed: 0, failed: 0, skipped: 0 }); const [selectedTest, setSelectedTest] = React.useState(undefined); const [settingsVisible, setSettingsVisible] = React.useState(false); const [visibleTestIds, setVisibleTestIds] = React.useState([]); const [runningState, setRunningState] = React.useState<{ testIds: Set, itemSelectedByUser?: boolean }>(); const inputRef = React.useRef(null); React.useEffect(() => { inputRef.current?.focus(); refreshRootSuite(true); }, []); updateRootSuite = (rootSuite: Suite, newProgress: 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); setRootSuite({ value: rootSuite }); setProjects(new Map(projects)); setProgress(newProgress); }; 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 as TeleTestCase)._createTestResult('pending'); } 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, skipped: 0 }); setRunningState({ testIds: new Set(testIds) }); sendMessage('run', { testIds }).then(() => { setRunningState(undefined); }); }; 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; return
Output
xtermDataSource.clear()}>
setIsShowingOutput(false)}>
;
{isFinished && } {!isFinished && }
setSettingsVisible(false)}>Tests
runTests(visibleTestIds)} disabled={isRunningTest}> sendMessageNoReply('stop')} disabled={!isRunningTest}> refreshRootSuite(true)} disabled={isRunningTest}>
{ setSettingsVisible(!settingsVisible); }}> { setIsShowingOutput(!isShowingOutput); }}>
{!settingsVisible && { setFilterText(e.target.value); }} onKeyDown={e => { if (e.key === 'Enter') runTests(visibleTestIds); }}>} style={{ flex: 'none', marginTop: 8 }} expanded={filterExpanded} setExpanded={setFilterExpanded}>
Status:
updateFilter('s', '')}>all
{['failed', 'passed', 'skipped'].map(s =>
updateFilter('s', s)}>{s}
)}
{[...projects.values()].filter(v => v).length > 1 &&
Project:
updateFilter('p', '')}>all
{[...projects].filter(([k, v]) => v).map(([k, v]) => k).map(p =>
updateFilter('p', p)}>{p}
)}
}
} {settingsVisible && setSettingsVisible(false)}>}
Total: {progress.total}
{isRunningTest &&
Running {visibleTestIds.length}
} {!isRunningTest &&
Showing: {visibleTestIds.length}
}
{progress.passed} passed
{progress.failed} failed
{progress.skipped} skipped
; }; const TreeListView = TreeView; export const TestList: React.FC<{ projects: Map, filterText: string, rootSuite: { value: Suite | undefined }, runTests: (testIds: string[]) => void, runningState?: { testIds: Set, itemSelectedByUser?: boolean }, isVisible: boolean, setVisibleTestIds: (testIds: string[]) => void, onTestSelected: (test: TestCase | undefined) => void, }> = ({ projects, filterText, rootSuite, runTests, runningState, isVisible, onTestSelected, setVisibleTestIds }) => { const [treeState, setTreeState] = React.useState({ expandedItems: new Map() }); const [selectedTreeItemId, setSelectedTreeItemId] = React.useState(); const [watchedTreeIds] = React.useState>(new Set()); React.useEffect(() => { 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); setVisibleTestIds([...visibleTestIds]); return { rootItem, treeItemMap }; }, [filterText, rootSuite, projects, setVisibleTestIds]); React.useEffect(() => { // Look for a first failure within the run batch to select it. if (!runningState || runningState.itemSelectedByUser) return; let selectedTreeItem: TreeItem | undefined; const visit = (treeItem: TreeItem) => { if (selectedTreeItem) return; treeItem.children.forEach(visit); if (treeItem.status === 'failed') { if (treeItem.kind === 'test' && runningState.testIds.has(treeItem.test.id)) selectedTreeItem = treeItem; else if (treeItem.kind === 'case' && runningState.testIds.has(treeItem.tests[0]?.id)) selectedTreeItem = treeItem; } }; visit(rootItem); if (selectedTreeItem) setSelectedTreeItemId(selectedTreeItem.id); }, [runningState, setSelectedTreeItemId, rootItem]); 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]); const setWatchedTreeIds = (watchedTreeIds: Set) => { const fileNames = new Set(); for (const itemId of watchedTreeIds) { const treeItem = treeItemMap.get(itemId)!; fileNames.add(fileNameForTreeItem(treeItem)!); } sendMessageNoReply('watch', { fileNames: [...fileNames] }); }; const runTreeItem = (treeItem: TreeItem) => { setSelectedTreeItemId(treeItem.id); runTests(collectTestIds(treeItem)); }; runWatchedTests = (fileName: string) => { const testIds: string[] = []; for (const treeId of watchedTreeIds) { const treeItem = treeItemMap.get(treeId)!; if (fileNameForTreeItem(treeItem) === fileName) testIds.push(...collectTestIds(treeItem)); } runTests(testIds); }; if (!isVisible) return <>; return { return
{treeItem.title}
runTreeItem(treeItem)} disabled={!!runningState}> sendMessageNoReply('open', { location: locationToOpen(treeItem) })}> { if (watchedTreeIds.has(treeItem.id)) watchedTreeIds.delete(treeItem.id); else watchedTreeIds.add(treeItem.id); setWatchedTreeIds(watchedTreeIds); }} toggled={watchedTreeIds.has(treeItem.id)}>
; }} 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 => { if (runningState) runningState.itemSelectedByUser = true; 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 ; }; declare global { interface Window { binding(data: any): Promise; } } let receiver: TeleReporterReceiver | undefined; let throttleTimer: NodeJS.Timeout | undefined; let throttleData: { rootSuite: Suite, progress: Progress } | undefined; const throttledAction = () => { clearTimeout(throttleTimer); throttleTimer = undefined; updateRootSuite(throttleData!.rootSuite, throttleData!.progress); }; const throttleUpdateRootSuite = (rootSuite: Suite, progress: Progress, immediate = false) => { throttleData = { rootSuite, progress }; if (immediate) throttledAction(); else if (!throttleTimer) throttleTimer = setTimeout(throttledAction, 250); }; const refreshRootSuite = (eraseResults: boolean) => { if (!eraseResults) { sendMessageNoReply('list'); return; } let rootSuite: Suite; const progress: Progress = { total: 0, passed: 0, failed: 0, skipped: 0, }; receiver = new TeleReporterReceiver({ onBegin: (config: FullConfig, suite: Suite) => { if (!rootSuite) rootSuite = suite; progress.total = suite.allTests().length; progress.passed = 0; progress.failed = 0; progress.skipped = 0; throttleUpdateRootSuite(rootSuite, progress, true); }, onEnd: () => { throttleUpdateRootSuite(rootSuite, progress, true); }, onTestBegin: () => { throttleUpdateRootSuite(rootSuite, progress); }, onTestEnd: (test: TestCase) => { if (test.outcome() === 'skipped') ++progress.skipped; else if (test.outcome() === 'unexpected') ++progress.failed; else ++progress.passed; throttleUpdateRootSuite(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(message.params.fileName); 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 fileNameForTreeItem = (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; skipped: number; }; type TreeItemBase = { kind: 'root' | 'group' | 'case' | 'test', id: string; title: string; location: Location, parent: TreeItem | undefined; children: TreeItem[]; status: 'none' | 'running' | 'passed' | 'failed' | 'skipped'; }; type GroupItem = TreeItemBase & { kind: 'group', children: (TestCaseItem | GroupItem)[]; }; type TestCaseItem = TreeItemBase & { kind: 'case', tests: TestCase[]; children: TestItem[]; }; type TestItem = TreeItemBase & { kind: 'test', test: TestCase; project: string; }; 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 }, parent: undefined, 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!, parent: parentGroup, 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, parent: parentGroup, 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, parent: testCaseItem, children: [], status, project: projectName }); } }; 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 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))); const filter = (testCase: TestCaseItem) => { const title = testCase.tests[0].titlePath().join(' ').toLowerCase(); if (!textTokens.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.tests = (testCase.children as TestItem[]).map(c => c.test); return !!testCase.children.length; }; const visit = (treeItem: GroupItem) => { const newChildren: (GroupItem | TestCaseItem)[] = []; for (const child of treeItem.children) { if (child.kind === 'case') { if (filter(child)) 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]); } function useSetting(name: string, defaultValue: S): [S, React.Dispatch>] { const string = localStorage.getItem(name); let value = defaultValue; if (string !== null) value = JSON.parse(string); const [state, setState] = React.useState(value); const setStateWrapper = (value: React.SetStateAction) => { localStorage.setItem(name, JSON.stringify(value)); setState(value); }; return [state, setStateWrapper]; }