diff --git a/src/web/traceViewer/ui/actionList.css b/src/web/traceViewer/ui/actionList.css index 34f0231a12..471d5f0b90 100644 --- a/src/web/traceViewer/ui/actionList.css +++ b/src/web/traceViewer/ui/actionList.css @@ -41,12 +41,10 @@ .action-entry.highlighted, .action-entry.selected { - color: white; background-color: var(--gray); } .action-entry.highlighted { - color: white; background-color: var(--light-gray); } @@ -54,15 +52,37 @@ background-color: var(--blue); } -.action-entry.highlighted > div, -.action-entry.selected > div { - color: white; +.action-entry.highlighted *, +.action-entry.selected * { + color: white !important; } .action-title { + flex: auto; + display: block; + overflow: hidden; + text-overflow: ellipsis; +} + +.action-icon { flex: none; - display: inline; - white-space: nowrap; + display: flex; + align-items: center; + padding-right: 3px; +} + +.action-icons { + display: flex; + flex-direction: row; + cursor: pointer; + height: 20px; + position: relative; + top: 1px; + border-bottom: 1px solid transparent; +} + +.action-icons:hover { + border-bottom: 1px solid white; } .action-error { @@ -85,3 +105,11 @@ padding-left: 5px; color: var(--blue); } + +.action-entry .codicon-error { + color: red; +} + +.action-entry .codicon-warning { + color: darkorange; +} diff --git a/src/web/traceViewer/ui/actionList.tsx b/src/web/traceViewer/ui/actionList.tsx index 6f468fdf12..2d8ae255c2 100644 --- a/src/web/traceViewer/ui/actionList.tsx +++ b/src/web/traceViewer/ui/actionList.tsx @@ -17,6 +17,7 @@ import './actionList.css'; import './tabbedPane.css'; import * as React from 'react'; +import * as modelUtil from './modelUtil'; import { ActionTraceEvent } from '../../../server/trace/common/traceEvents'; export interface ActionListProps { @@ -25,6 +26,7 @@ export interface ActionListProps { highlightedAction: ActionTraceEvent | undefined, onSelected: (action: ActionTraceEvent) => void, onHighlighted: (action: ActionTraceEvent | undefined) => void, + setSelectedTab: (tab: string) => void, } export const ActionList: React.FC = ({ @@ -33,6 +35,7 @@ export const ActionList: React.FC = ({ highlightedAction = undefined, onSelected = () => {}, onHighlighted = () => {}, + setSelectedTab = () => {}, }) => { const actionListRef = React.createRef(); @@ -72,6 +75,8 @@ export const ActionList: React.FC = ({ const { metadata } = action; const selectedSuffix = action === selectedAction ? ' selected' : ''; const highlightedSuffix = action === highlightedAction ? ' highlighted' : ''; + const page = modelUtil.page(action); + const { errors, warnings } = modelUtil.stats(action); return
= ({ onMouseEnter={() => onHighlighted(action)} onMouseLeave={() => (highlightedAction === action) && onHighlighted(undefined)} > - ; })}
diff --git a/src/web/traceViewer/ui/consoleTab.css b/src/web/traceViewer/ui/consoleTab.css index 5e84022105..648ddfed0b 100644 --- a/src/web/traceViewer/ui/consoleTab.css +++ b/src/web/traceViewer/ui/consoleTab.css @@ -36,7 +36,7 @@ background: #fff0f0; border-top-color: #ffd6d6; border-bottom-color: #ffd6d6; - color: red; + color: red; } .console-line.warning { diff --git a/src/web/traceViewer/ui/consoleTab.tsx b/src/web/traceViewer/ui/consoleTab.tsx index a16c619efa..c3dc58c4bb 100644 --- a/src/web/traceViewer/ui/consoleTab.tsx +++ b/src/web/traceViewer/ui/consoleTab.tsx @@ -17,34 +17,29 @@ import * as React from 'react'; import './consoleTab.css'; import { ActionTraceEvent } from '../../../server/trace/common/traceEvents'; -import { ContextEntry } from '../../../server/trace/viewer/traceModel'; import * as channels from '../../../protocol/channels'; +import * as modelUtil from './modelUtil'; export const ConsoleTab: React.FunctionComponent<{ - context: ContextEntry, action: ActionTraceEvent | undefined, - nextAction: ActionTraceEvent | undefined, -}> = ({ context, action, nextAction }) => { +}> = ({ action }) => { const entries = React.useMemo(() => { if (!action) return []; const entries: { message?: channels.ConsoleMessageInitializer, error?: channels.SerializedError }[] = []; - for (const page of context.pages) { - for (const event of page.events) { - if (event.metadata.method !== 'console' && event.metadata.method !== 'pageError') - continue; - if (event.metadata.startTime < action.metadata.startTime || (nextAction && event.metadata.startTime >= nextAction.metadata.startTime)) - continue; - if (event.metadata.method === 'console') { - const { guid } = event.metadata.params.message; - entries.push({ message: page.objects[guid] }); - } - if (event.metadata.method === 'pageError') - entries.push({ error: event.metadata.params.error }); + const page = modelUtil.page(action); + for (const event of modelUtil.eventsForAction(action)) { + if (event.metadata.method !== 'console' && event.metadata.method !== 'pageError') + continue; + if (event.metadata.method === 'console') { + const { guid } = event.metadata.params.message; + entries.push({ message: page.objects[guid] }); } + if (event.metadata.method === 'pageError') + entries.push({ error: event.metadata.params.error }); } return entries; - }, [context, action]); + }, [action]); return
{ entries.map((entry, index) => { diff --git a/src/web/traceViewer/ui/modelUtil.ts b/src/web/traceViewer/ui/modelUtil.ts new file mode 100644 index 0000000000..aa6ed86f9f --- /dev/null +++ b/src/web/traceViewer/ui/modelUtil.ts @@ -0,0 +1,91 @@ +/** + * 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 { ResourceSnapshot } from '../../../server/snapshot/snapshotTypes'; +import { ActionTraceEvent } from '../../../server/trace/common/traceEvents'; +import { ContextEntry, PageEntry } from '../../../server/trace/viewer/traceModel'; + +const contextSymbol = Symbol('context'); +const pageSymbol = Symbol('context'); +const nextSymbol = Symbol('next'); +const eventsSymbol = Symbol('events'); + +export function indexModel(context: ContextEntry) { + for (const page of context.pages) { + (page as any)[contextSymbol] = context; + for (let i = 0; i < page.actions.length; ++i) { + const action = page.actions[i] as any; + action[contextSymbol] = context; + action[pageSymbol] = page; + action[nextSymbol] = page.actions[i + 1]; + } + for (const event of page.events) { + (event as any)[contextSymbol] = context; + (event as any)[pageSymbol] = page; + } + } +} + +export function context(action: ActionTraceEvent): ContextEntry { + return (action as any)[contextSymbol]; +} + +export function page(action: ActionTraceEvent): PageEntry { + return (action as any)[pageSymbol]; +} + +export function next(action: ActionTraceEvent): ActionTraceEvent { + return (action as any)[nextSymbol]; +} + +export function stats(action: ActionTraceEvent): { errors: number, warnings: number } { + let errors = 0; + let warnings = 0; + const p = page(action); + for (const event of eventsForAction(action)) { + if (event.metadata.method === 'console') { + const { guid } = event.metadata.params.message; + const type = p.objects[guid].type; + if (type === 'warning') + ++warnings; + else if (type === 'error') + ++errors; + } + if (event.metadata.method === 'pageError') + ++errors; + } + return { errors, warnings }; +} + +export function eventsForAction(action: ActionTraceEvent): ActionTraceEvent[] { + let result: ActionTraceEvent[] = (action as any)[eventsSymbol]; + if (result) + return result; + + const nextAction = next(action); + result = page(action).events.filter(event => { + return event.metadata.startTime >= action.metadata.startTime && (!nextAction || event.metadata.startTime < nextAction.metadata.startTime); + }); + (action as any)[eventsSymbol] = result; + return result; +} + +export function resourcesForAction(action: ActionTraceEvent): ResourceSnapshot[] { + const nextAction = next(action); + return context(action).resources.filter(resource => { + return resource.timestamp > action.metadata.startTime && (!nextAction || resource.timestamp < nextAction.metadata.startTime); + }); +} diff --git a/src/web/traceViewer/ui/networkTab.tsx b/src/web/traceViewer/ui/networkTab.tsx index 7191619642..975a6071de 100644 --- a/src/web/traceViewer/ui/networkTab.tsx +++ b/src/web/traceViewer/ui/networkTab.tsx @@ -14,23 +14,18 @@ * limitations under the License. */ -import { ActionTraceEvent } from '../../../server/trace/common/traceEvents'; -import { ContextEntry } from '../../../server/trace/viewer/traceModel'; -import './networkTab.css'; import * as React from 'react'; +import { ActionTraceEvent } from '../../../server/trace/common/traceEvents'; +import * as modelUtil from './modelUtil'; import { NetworkResourceDetails } from './networkResourceDetails'; -import { ResourceSnapshot } from '../../../server/snapshot/snapshotTypes'; +import './networkTab.css'; export const NetworkTab: React.FunctionComponent<{ - context: ContextEntry, action: ActionTraceEvent | undefined, - nextAction: ActionTraceEvent | undefined, -}> = ({ context, action, nextAction }) => { +}> = ({ action }) => { const [selected, setSelected] = React.useState(0); - const resources: ResourceSnapshot[] = context.resources.filter(resource => { - return action && resource.timestamp > action.metadata.startTime && (!nextAction || resource.timestamp < nextAction.metadata.startTime); - }); + const resources = action ? modelUtil.resourcesForAction(action) : []; return
{ resources.map((resource, index) => { return ; diff --git a/src/web/traceViewer/ui/tabbedPane.tsx b/src/web/traceViewer/ui/tabbedPane.tsx index 14c7ee10c5..dd3bdf29d8 100644 --- a/src/web/traceViewer/ui/tabbedPane.tsx +++ b/src/web/traceViewer/ui/tabbedPane.tsx @@ -25,15 +25,16 @@ export interface TabbedPaneTab { export const TabbedPane: React.FunctionComponent<{ tabs: TabbedPaneTab[], -}> = ({ tabs }) => { - const [selected, setSelected] = React.useState(tabs.length ? tabs[0].id : ''); + selectedTab: string, + setSelectedTab: (tab: string) => void +}> = ({ tabs, selectedTab, setSelectedTab }) => { return
{ tabs.map(tab => { - return
setSelected(tab.id)} + return
setSelectedTab(tab.id)} key={tab.id}>
{tab.title}
@@ -42,7 +43,7 @@ export const TabbedPane: React.FunctionComponent<{
{ tabs.map(tab => { - if (selected === tab.id) + if (selectedTab === tab.id) return
{tab.render()}
; }) } diff --git a/src/web/traceViewer/ui/workbench.tsx b/src/web/traceViewer/ui/workbench.tsx index c84336c395..d127dadbdd 100644 --- a/src/web/traceViewer/ui/workbench.tsx +++ b/src/web/traceViewer/ui/workbench.tsx @@ -29,7 +29,7 @@ import { LogsTab } from './logsTab'; import { SplitView } from '../../components/splitView'; import { useAsyncMemo } from './helpers'; import { ConsoleTab } from './consoleTab'; - +import * as modelUtil from './modelUtil'; export const Workbench: React.FunctionComponent<{ debugNames: string[], @@ -37,20 +37,22 @@ export const Workbench: React.FunctionComponent<{ const [debugName, setDebugName] = React.useState(debugNames[0]); const [selectedAction, setSelectedAction] = React.useState(); const [highlightedAction, setHighlightedAction] = React.useState(); + const [selectedTab, setSelectedTab] = React.useState('logs'); let context = useAsyncMemo(async () => { if (!debugName) return emptyContext; - return (await fetch(`/context/${debugName}`).then(response => response.json())) as ContextEntry; + const context = (await fetch(`/context/${debugName}`).then(response => response.json())) as ContextEntry; + modelUtil.indexModel(context); + return context; }, [debugName], emptyContext); - const { actions, nextAction } = React.useMemo(() => { + const actions = React.useMemo(() => { const actions: ActionTraceEvent[] = []; for (const page of context.pages) actions.push(...page.actions); - const nextAction = selectedAction ? actions[actions.indexOf(selectedAction) + 1] : undefined; - return { actions, nextAction }; - }, [context, selectedAction]); + return actions; + }, [context]); const snapshotSize = context.options.viewport || { width: 1280, height: 720 }; const boundaries = { minimum: context.startTime, maximum: context.endTime }; @@ -58,6 +60,7 @@ export const Workbench: React.FunctionComponent<{ // Leave some nice free space on the right hand side. boundaries.maximum += (boundaries.maximum - boundaries.minimum) / 20; + return
🎭
@@ -87,10 +90,10 @@ export const Workbench: React.FunctionComponent<{ }, - { id: 'console', title: 'Console', render: () => }, + { id: 'console', title: 'Console', render: () => }, { id: 'source', title: 'Source', render: () => }, - { id: 'network', title: 'Network', render: () => }, - ]}/> + { id: 'network', title: 'Network', render: () => }, + ]} selectedTab={selectedTab} setSelectedTab={setSelectedTab}/> setHighlightedAction(action)} + setSelectedTab={setSelectedTab} />
; diff --git a/tests/trace-viewer/trace-viewer.spec.ts b/tests/trace-viewer/trace-viewer.spec.ts index 597508b418..d7a6424c85 100644 --- a/tests/trace-viewer/trace-viewer.spec.ts +++ b/tests/trace-viewer/trace-viewer.spec.ts @@ -28,8 +28,18 @@ class TraceViewerPage { return await this.page.$$eval('.action-title:visible', ee => ee.map(e => e.textContent)); } + async actionIconsText(action: string) { + const entry = await this.page.waitForSelector(`.action-entry:has-text("${action}")`); + await entry.waitForSelector('.action-icon-value:visible'); + return await entry.$$eval('.action-icon-value:visible', ee => ee.map(e => e.textContent)); + } + + async actionIcons(action: string) { + return await this.page.waitForSelector(`.action-entry:has-text("${action}") .action-icons`); + } + async selectAction(title: string) { - await this.page.click(`.action-title:text("${title}")`); + await this.page.click(`.action-title:has-text("${title}")`); } async logLines() { @@ -98,11 +108,6 @@ test.beforeAll(async ({ browser, browserName }, workerInfo) => { page.waitForNavigation(), page.waitForTimeout(200).then(() => page.goto('data:text/html,Hello world 2')) ]); - await page.evaluate(() => { - console.log('Log'); - console.warn('Warning'); - console.error('Error'); - }); await page.close(); traceFile = path.join(workerInfo.project.outputDir, browserName, 'trace.zip'); await context.tracing.stop({ path: traceFile }); @@ -116,13 +121,12 @@ test('should show empty trace viewer', async ({ showTraceViewer }, testInfo) => test('should open simple trace viewer', async ({ showTraceViewer }) => { const traceViewer = await showTraceViewer(traceFile); expect(await traceViewer.actionTitles()).toEqual([ - 'page.goto', + 'page.gotodata:text/html,Hello world', 'page.setContent', 'page.evaluate', - 'page.click', + 'page.click\"Click\"', 'page.waitForNavigation', - 'page.goto', - 'page.evaluate' + 'page.gotodata:text/html,Hello world 2', ]); }); @@ -155,3 +159,12 @@ test('should render console', async ({ showTraceViewer, browserName }) => { expect(stacks.length).toBe(1); expect(stacks[0]).toContain('Error: Unhandled exception'); }); + +test('should open console errors on click', async ({ showTraceViewer, browserName }) => { + test.fixme(browserName === 'firefox', 'Firefox generates stray console message for page error'); + const traceViewer = await showTraceViewer(traceFile); + expect(await traceViewer.actionIconsText('page.evaluate')).toEqual(['2', '1']); + expect(await traceViewer.page.isHidden('.console-tab')); + await (await traceViewer.actionIcons('page.evaluate')).click(); + expect(await traceViewer.page.waitForSelector('.console-tab')); +});