From b9b0faf120839912f8fe5f31f1877b40abd0db27 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 1 Jul 2021 14:31:20 -0700 Subject: [PATCH] feat(trace-viewer): render console messages (#7418) --- src/server/firefox/ffPage.ts | 5 +- src/server/trace/viewer/traceViewer.ts | 7 +- src/server/webkit/wkPage.ts | 15 ++-- src/web/traceViewer/ui/consoleTab.css | 74 ++++++++++++++++ src/web/traceViewer/ui/consoleTab.tsx | 87 +++++++++++++++++++ src/web/traceViewer/ui/workbench.tsx | 2 + tests/assets/error.html | 3 +- tests/page/page-event-pageerror.spec.ts | 27 ++++-- .../trace-viewer/trace-viewer.spec.ts | 54 ++++++++++-- 9 files changed, 246 insertions(+), 28 deletions(-) create mode 100644 src/web/traceViewer/ui/consoleTab.css create mode 100644 src/web/traceViewer/ui/consoleTab.tsx rename tests/{chromium => }/trace-viewer/trace-viewer.spec.ts (64%) diff --git a/src/server/firefox/ffPage.ts b/src/server/firefox/ffPage.ts index 3a79dfe55f..66feb65926 100644 --- a/src/server/firefox/ffPage.ts +++ b/src/server/firefox/ffPage.ts @@ -228,10 +228,9 @@ export class FFPage implements PageDelegate { } _onUncaughtError(params: Protocol.Page.uncaughtErrorPayload) { - const {name, message} = splitErrorMessage(params.message); - + const { name, message } = splitErrorMessage(params.message); const error = new Error(message); - error.stack = params.stack; + error.stack = params.message + '\n' + params.stack.split('\n').filter(Boolean).map(a => a.replace(/([^@]*)@(.*)/, ' at $1 ($2)')).join('\n'); error.name = name; this._page.emit(Page.Events.PageError, error); } diff --git a/src/server/trace/viewer/traceViewer.ts b/src/server/trace/viewer/traceViewer.ts index 7d26ff21d3..d70c0f7e12 100644 --- a/src/server/trace/viewer/traceViewer.ts +++ b/src/server/trace/viewer/traceViewer.ts @@ -120,13 +120,14 @@ export class TraceViewer { const urlPrefix = await this._server.start(); const traceViewerPlaywright = createPlaywright(true); - const args = [ + const traceViewerBrowser = isUnderTest() ? 'chromium' : this._browserName; + const args = traceViewerBrowser === 'chromium' ? [ '--app=data:text/html,', '--window-size=1280,800' - ]; + ] : []; if (isUnderTest()) args.push(`--remote-debugging-port=0`); - const context = await traceViewerPlaywright[this._browserName as 'chromium'].launchPersistentContext(internalCallMetadata(), '', { + const context = await traceViewerPlaywright[traceViewerBrowser as 'chromium'].launchPersistentContext(internalCallMetadata(), '', { // TODO: store language in the trace. sdkLanguage: 'javascript', args, diff --git a/src/server/webkit/wkPage.ts b/src/server/webkit/wkPage.ts index 9218cc2758..c3191461d8 100644 --- a/src/server/webkit/wkPage.ts +++ b/src/server/webkit/wkPage.ts @@ -502,16 +502,21 @@ export class WKPage implements PageDelegate { return; } if (level === 'error' && source === 'javascript') { - const {name, message} = splitErrorMessage(text); - const error = new Error(message); + const { name, message } = splitErrorMessage(text); + + let stack: string; if (event.message.stackTrace) { - error.stack = event.message.stackTrace.map(callFrame => { - return `${callFrame.functionName}@${callFrame.url}:${callFrame.lineNumber}:${callFrame.columnNumber}`; + stack = text + '\n' + event.message.stackTrace.map(callFrame => { + return ` at ${callFrame.functionName || 'unknown'} (${callFrame.url}:${callFrame.lineNumber}:${callFrame.columnNumber})`; }).join('\n'); } else { - error.stack = ''; + stack = ''; } + + const error = new Error(message); + error.stack = stack; error.name = name; + this._page.emit(Page.Events.PageError, error); return; } diff --git a/src/web/traceViewer/ui/consoleTab.css b/src/web/traceViewer/ui/consoleTab.css new file mode 100644 index 0000000000..5e84022105 --- /dev/null +++ b/src/web/traceViewer/ui/consoleTab.css @@ -0,0 +1,74 @@ +/* + 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. +*/ + + +.console-tab { + flex: auto; + line-height: 16px; + white-space: pre; + overflow: auto; + padding-top: 3px; + user-select: text; +} + +.console-line { + flex: none; + padding: 3px 0 3px 3px; + align-items: center; + border-top: 1px solid transparent; + border-bottom: 1px solid transparent; +} + +.console-line.error { + background: #fff0f0; + border-top-color: #ffd6d6; + border-bottom-color: #ffd6d6; + color: red; +} + +.console-line.warning { + background: #fffbe5; + border-top-color: #fff5c2; + border-bottom-color: #fff5c2; +} + +.console-line .codicon { + padding: 0 2px 0 3px; + position: relative; + flex: none; + top: 1px; +} + +.console-line.warning .codicon { + color: darkorange; +} + +.console-line-message { + white-space: initial; + word-break: break-word; + position: relative; + top: -2px; +} + +.console-location { + padding-right: 3px; + float: right; +} + +.console-stack { + white-space: pre-wrap; + margin: 3px; +} diff --git a/src/web/traceViewer/ui/consoleTab.tsx b/src/web/traceViewer/ui/consoleTab.tsx new file mode 100644 index 0000000000..a16c619efa --- /dev/null +++ b/src/web/traceViewer/ui/consoleTab.tsx @@ -0,0 +1,87 @@ +/** + * 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 * 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'; + +export const ConsoleTab: React.FunctionComponent<{ + context: ContextEntry, + action: ActionTraceEvent | undefined, + nextAction: ActionTraceEvent | undefined, +}> = ({ context, action, nextAction }) => { + 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 }); + } + } + return entries; + }, [context, action]); + + return
{ + entries.map((entry, index) => { + const { message, error } = entry; + if (message) { + const url = message.location.url; + const filename = url ? url.substring(url.lastIndexOf('/') + 1) : ''; + return
+ {filename}:{message.location.lineNumber} + + {message.text} +
; + } + if (error) { + const { error: errorObject, value } = error; + if (errorObject) { + return
+ + {errorObject.message} +
{errorObject.stack}
+
; + } else { + return
+ + {value} +
; + } + } + return null; + }) + }
; +}; + +function iconClass(message: channels.ConsoleMessageInitializer): string { + switch (message.type) { + case 'error': return 'error'; + case 'warning': return 'warning'; + } + return 'blank'; +} diff --git a/src/web/traceViewer/ui/workbench.tsx b/src/web/traceViewer/ui/workbench.tsx index b44cdc716d..c84336c395 100644 --- a/src/web/traceViewer/ui/workbench.tsx +++ b/src/web/traceViewer/ui/workbench.tsx @@ -28,6 +28,7 @@ import { SnapshotTab } from './snapshotTab'; import { LogsTab } from './logsTab'; import { SplitView } from '../../components/splitView'; import { useAsyncMemo } from './helpers'; +import { ConsoleTab } from './consoleTab'; export const Workbench: React.FunctionComponent<{ @@ -86,6 +87,7 @@ export const Workbench: React.FunctionComponent<{ }, + { id: 'console', title: 'Console', render: () => }, { id: 'source', title: 'Source', render: () => }, { id: 'network', title: 'Network', render: () => }, ]}/> diff --git a/tests/assets/error.html b/tests/assets/error.html index 26978c466b..9532d14cf7 100644 --- a/tests/assets/error.html +++ b/tests/assets/error.html @@ -11,8 +11,7 @@ function b() { } function c() { - window.e = new Error('Fancy error!'); - throw window.e; + throw new Error('Fancy error!'); } //# sourceURL=myscript.js diff --git a/tests/page/page-event-pageerror.spec.ts b/tests/page/page-event-pageerror.spec.ts index e31bad6091..c56ba845ed 100644 --- a/tests/page/page-event-pageerror.spec.ts +++ b/tests/page/page-event-pageerror.spec.ts @@ -18,17 +18,32 @@ import { test as it, expect } from './pageTest'; it('should fire', async ({page, server, browserName}) => { + const url = server.PREFIX + '/error.html'; const [error] = await Promise.all([ page.waitForEvent('pageerror'), - page.goto(server.PREFIX + '/error.html'), + page.goto(url), ]); expect(error.name).toBe('Error'); expect(error.message).toBe('Fancy error!'); - let stack = await page.evaluate(() => window['e'].stack); - // Note that WebKit reports the stack of the 'throw' statement instead of the Error constructor call. - if (browserName === 'webkit') - stack = stack.replace('14:25', '15:19'); - expect(error.stack).toBe(stack); + if (browserName === 'chromium') { + expect(error.stack).toBe(`Error: Fancy error! + at c (myscript.js:14:11) + at b (myscript.js:10:5) + at a (myscript.js:6:5) + at myscript.js:3:1`); + } else if (browserName === 'webkit') { + expect(error.stack).toBe(`Error: Fancy error! + at c (${url}:14:36) + at b (${url}:10:6) + at a (${url}:6:6) + at global code (${url}:3:2)`); + } else if (browserName === 'firefox') { + expect(error.stack).toBe(`Error: Fancy error! + at c (myscript.js:14:11) + at b (myscript.js:10:5) + at a (myscript.js:6:5) + at (myscript.js:3:1)`); + } }); it('should not receive console message for pageError', async ({ page, server, browserName }) => { diff --git a/tests/chromium/trace-viewer/trace-viewer.spec.ts b/tests/trace-viewer/trace-viewer.spec.ts similarity index 64% rename from tests/chromium/trace-viewer/trace-viewer.spec.ts rename to tests/trace-viewer/trace-viewer.spec.ts index 5414a4e919..597508b418 100644 --- a/tests/chromium/trace-viewer/trace-viewer.spec.ts +++ b/tests/trace-viewer/trace-viewer.spec.ts @@ -15,10 +15,10 @@ */ import path from 'path'; -import type { Browser, Page } from '../../../index'; -import { showTraceViewer } from '../../../lib/server/trace/viewer/traceViewer'; -import { playwrightTest } from '../../config/browserTest'; -import { expect } from '../../config/test-runner'; +import type { Browser, Page } from '../../index'; +import { showTraceViewer } from '../../lib/server/trace/viewer/traceViewer'; +import { playwrightTest } from '../config/browserTest'; +import { expect } from '../config/test-runner'; class TraceViewerPage { constructor(public page: Page) {} @@ -48,15 +48,30 @@ class TraceViewerPage { const result = [...set]; return result.sort(); } + + async consoleLines() { + await this.page.waitForSelector('.console-line-message:visible'); + return await this.page.$$eval('.console-line-message:visible', ee => ee.map(e => e.textContent)); + } + + async consoleLineTypes() { + await this.page.waitForSelector('.console-line-message:visible'); + return await this.page.$$eval('.console-line:visible', ee => ee.map(e => e.className)); + } + + async consoleStacks() { + await this.page.waitForSelector('.console-stack:visible'); + return await this.page.$$eval('.console-stack:visible', ee => ee.map(e => e.textContent)); + } } const test = playwrightTest.extend<{ showTraceViewer: (trace: string) => Promise }>({ - showTraceViewer: async ({ browserType, browserName, headless }, use) => { + showTraceViewer: async ({ playwright, browserName, headless }, use) => { let browser: Browser; let contextImpl: any; await use(async (trace: string) => { contextImpl = await showTraceViewer(trace, browserName, headless); - browser = await browserType.connectOverCDP({ endpointURL: contextImpl._browser.options.wsEndpoint }); + browser = await playwright.chromium.connectOverCDP({ endpointURL: contextImpl._browser.options.wsEndpoint }); return new TraceViewerPage(browser.contexts()[0].pages()[0]); }); await browser.close(); @@ -66,12 +81,18 @@ const test = playwrightTest.extend<{ showTraceViewer: (trace: string) => Promise let traceFile: string; -test.beforeAll(async ({ browser }, workerInfo) => { +test.beforeAll(async ({ browser, browserName }, workerInfo) => { const context = await browser.newContext(); await context.tracing.start({ name: 'test', screenshots: true, snapshots: true }); const page = await context.newPage(); await page.goto('data:text/html,Hello world'); await page.setContent(''); + await page.evaluate(() => { + console.log('Info'); + console.warn('Warning'); + console.error('Error'); + setTimeout(() => { throw new Error('Unhandled exception'); }, 0); + }); await page.click('"Click"'); await Promise.all([ page.waitForNavigation(), @@ -83,7 +104,7 @@ test.beforeAll(async ({ browser }, workerInfo) => { console.error('Error'); }); await page.close(); - traceFile = path.join(workerInfo.project.outputDir, 'trace.zip'); + traceFile = path.join(workerInfo.project.outputDir, browserName, 'trace.zip'); await context.tracing.stop({ path: traceFile }); }); @@ -97,6 +118,7 @@ test('should open simple trace viewer', async ({ showTraceViewer }) => { expect(await traceViewer.actionTitles()).toEqual([ 'page.goto', 'page.setContent', + 'page.evaluate', 'page.click', 'page.waitForNavigation', 'page.goto', @@ -107,7 +129,6 @@ test('should open simple trace viewer', async ({ showTraceViewer }) => { test('should contain action log', async ({ showTraceViewer }) => { const traceViewer = await showTraceViewer(traceFile); await traceViewer.selectAction('page.click'); - const logLines = await traceViewer.logLines(); expect(logLines.length).toBeGreaterThan(10); expect(logLines).toContain('attempting click action'); @@ -119,3 +140,18 @@ test('should render events', async ({ showTraceViewer }) => { const events = await traceViewer.eventBars(); expect(events).toContain('page_console'); }); + +test('should render console', async ({ showTraceViewer, browserName }) => { + test.fixme(browserName === 'firefox', 'Firefox generates stray console message for page error'); + const traceViewer = await showTraceViewer(traceFile); + await traceViewer.selectAction('page.evaluate'); + await traceViewer.page.click('"Console"'); + + const events = await traceViewer.consoleLines(); + expect(events).toEqual(['Info', 'Warning', 'Error', 'Unhandled exception']); + const types = await traceViewer.consoleLineTypes(); + expect(types).toEqual(['console-line log', 'console-line warning', 'console-line error', 'console-line error']); + const stacks = await traceViewer.consoleStacks(); + expect(stacks.length).toBe(1); + expect(stacks[0]).toContain('Error: Unhandled exception'); +});