feat(trace-viewer): add error links to actions (#7434)

This commit is contained in:
Pavel Feldman 2021-07-01 20:46:56 -07:00 committed by GitHub
parent 4c7343fe96
commit 82b21e912e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 200 additions and 63 deletions

View file

@ -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;
}

View file

@ -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<ActionListProps> = ({
@ -33,6 +35,7 @@ export const ActionList: React.FC<ActionListProps> = ({
highlightedAction = undefined,
onSelected = () => {},
onHighlighted = () => {},
setSelectedTab = () => {},
}) => {
const actionListRef = React.createRef<HTMLDivElement>();
@ -72,6 +75,8 @@ export const ActionList: React.FC<ActionListProps> = ({
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 <div
className={'action-entry' + selectedSuffix + highlightedSuffix}
key={metadata.id}
@ -79,10 +84,15 @@ export const ActionList: React.FC<ActionListProps> = ({
onMouseEnter={() => onHighlighted(action)}
onMouseLeave={() => (highlightedAction === action) && onHighlighted(undefined)}
>
<div className={'action-error codicon codicon-issues'} hidden={!metadata.error} />
<div className='action-title'>{metadata.apiName || metadata.method}</div>
{metadata.params.selector && <div className='action-selector' title={metadata.params.selector}>{metadata.params.selector}</div>}
{metadata.method === 'goto' && metadata.params.url && <div className='action-url' title={metadata.params.url}>{metadata.params.url}</div>}
<div className='action-title'>
<span>{metadata.apiName}</span>
{metadata.params.selector && <div className='action-selector' title={metadata.params.selector}>{metadata.params.selector}</div>}
{metadata.method === 'goto' && metadata.params.url && <div className='action-url' title={metadata.params.url}>{metadata.params.url}</div>}
</div>
<div className='action-icons' onClick={() => setSelectedTab('console')}>
{!!errors && <div className='action-icon'><span className={'codicon codicon-error'}></span><span className="action-icon-value">{errors}</span></div>}
{!!warnings && <div className='action-icon'><span className={'codicon codicon-warning'}></span><span className="action-icon-value">{warnings}</span></div>}
</div>
</div>;
})}
</div>

View file

@ -36,7 +36,7 @@
background: #fff0f0;
border-top-color: #ffd6d6;
border-bottom-color: #ffd6d6;
color: red;
color: red;
}
.console-line.warning {

View file

@ -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 <div className='console-tab'>{
entries.map((entry, index) => {

View file

@ -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);
});
}

View file

@ -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 <div className='network-tab'>{
resources.map((resource, index) => {
return <NetworkResourceDetails resource={resource} key={index} index={index} selected={selected === index} setSelected={setSelected} />;

View file

@ -25,15 +25,16 @@ export interface TabbedPaneTab {
export const TabbedPane: React.FunctionComponent<{
tabs: TabbedPaneTab[],
}> = ({ tabs }) => {
const [selected, setSelected] = React.useState<string>(tabs.length ? tabs[0].id : '');
selectedTab: string,
setSelectedTab: (tab: string) => void
}> = ({ tabs, selectedTab, setSelectedTab }) => {
return <div className='tabbed-pane'>
<div className='vbox'>
<div className='hbox' style={{ flex: 'none' }}>
<div className='tab-strip'>{
tabs.map(tab => {
return <div className={'tab-element ' + (selected === tab.id ? 'selected' : '')}
onClick={() => setSelected(tab.id)}
return <div className={'tab-element ' + (selectedTab === tab.id ? 'selected' : '')}
onClick={() => setSelectedTab(tab.id)}
key={tab.id}>
<div className='tab-label'>{tab.title}</div>
</div>
@ -42,7 +43,7 @@ export const TabbedPane: React.FunctionComponent<{
</div>
{
tabs.map(tab => {
if (selected === tab.id)
if (selectedTab === tab.id)
return <div key={tab.id} className='tab-content'>{tab.render()}</div>;
})
}

View file

@ -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<ActionTraceEvent | undefined>();
const [highlightedAction, setHighlightedAction] = React.useState<ActionTraceEvent | undefined>();
const [selectedTab, setSelectedTab] = React.useState<string>('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 <div className='vbox workbench'>
<div className='hbox header'>
<div className='logo'>🎭</div>
@ -87,10 +90,10 @@ export const Workbench: React.FunctionComponent<{
<SnapshotTab action={selectedAction} snapshotSize={snapshotSize} />
<TabbedPane tabs={[
{ id: 'logs', title: 'Log', render: () => <LogsTab action={selectedAction} /> },
{ id: 'console', title: 'Console', render: () => <ConsoleTab context={context} action={selectedAction} nextAction={nextAction}/> },
{ id: 'console', title: 'Console', render: () => <ConsoleTab action={selectedAction} /> },
{ id: 'source', title: 'Source', render: () => <SourceTab action={selectedAction} /> },
{ id: 'network', title: 'Network', render: () => <NetworkTab context={context} action={selectedAction} nextAction={nextAction}/> },
]}/>
{ id: 'network', title: 'Network', render: () => <NetworkTab action={selectedAction} /> },
]} selectedTab={selectedTab} setSelectedTab={setSelectedTab}/>
</SplitView>
<ActionList
actions={actions}
@ -100,6 +103,7 @@ export const Workbench: React.FunctionComponent<{
setSelectedAction(action);
}}
onHighlighted={action => setHighlightedAction(action)}
setSelectedTab={setSelectedTab}
/>
</SplitView>
</div>;

View file

@ -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,<html>Hello world 2</html>'))
]);
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,<html>Hello world</html>',
'page.setContent',
'page.evaluate',
'page.click',
'page.click\"Click\"',
'page.waitForNavigation',
'page.goto',
'page.evaluate'
'page.gotodata:text/html,<html>Hello world 2</html>',
]);
});
@ -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'));
});