feat(trace-viewer): add error links to actions (#7434)
This commit is contained in:
parent
4c7343fe96
commit
82b21e912e
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
background: #fff0f0;
|
||||
border-top-color: #ffd6d6;
|
||||
border-bottom-color: #ffd6d6;
|
||||
color: red;
|
||||
color: red;
|
||||
}
|
||||
|
||||
.console-line.warning {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
91
src/web/traceViewer/ui/modelUtil.ts
Normal file
91
src/web/traceViewer/ui/modelUtil.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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} />;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue