diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index 500161a47e..cd95c08ade 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -407,13 +407,14 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps } private _onConsoleMessage(message: ConsoleMessage) { - const object: trace.ObjectTraceEvent = { + const object: trace.ConsoleMessageTraceEvent = { type: 'object', class: 'ConsoleMessage', guid: message.guid, initializer: { type: message.type(), text: message.text(), + args: message.args().map(a => ({ preview: a.toString(), value: a.rawValue() })), location: message.location(), }, }; diff --git a/packages/trace-viewer/src/entries.ts b/packages/trace-viewer/src/entries.ts index 0adec7eb76..16b42523a8 100644 --- a/packages/trace-viewer/src/entries.ts +++ b/packages/trace-viewer/src/entries.ts @@ -35,7 +35,7 @@ export type ContextEntry = { actions: trace.ActionTraceEvent[]; events: trace.EventTraceEvent[]; stdio: trace.StdioTraceEvent[]; - initializers: { [key: string]: any }; + initializers: { [key: string]: trace.ConsoleMessageTraceEvent['initializer'] }; hasSource: boolean; }; diff --git a/packages/trace-viewer/src/ui/consoleTab.css b/packages/trace-viewer/src/ui/consoleTab.css index d9e57b3e44..50a881cb97 100644 --- a/packages/trace-viewer/src/ui/consoleTab.css +++ b/packages/trace-viewer/src/ui/consoleTab.css @@ -19,11 +19,11 @@ display: flex; flex: auto; white-space: pre; - user-select: text; } .console-line { width: 100%; + user-select: text; } .console-line .codicon { @@ -41,12 +41,20 @@ word-break: break-word; white-space: pre-wrap; position: relative; - user-select: text; } .console-location { padding-right: 3px; float: right; + color: var(--vscode-editorCodeLens-foreground); + user-select: none; +} + +.console-time { + float: left; + min-width: 30px; + color: var(--vscode-editorCodeLens-foreground); + user-select: none; } .console-stack { diff --git a/packages/trace-viewer/src/ui/consoleTab.tsx b/packages/trace-viewer/src/ui/consoleTab.tsx index 4adf898c2d..29d298dd73 100644 --- a/packages/trace-viewer/src/ui/consoleTab.tsx +++ b/packages/trace-viewer/src/ui/consoleTab.tsx @@ -21,9 +21,11 @@ import * as modelUtil from './modelUtil'; import { ListView } from '@web/components/listView'; import { ansi2htmlMarkup } from '@web/components/errorMessage'; import type { Boundaries } from '../geometry'; +import { msToString } from '@web/uiUtils'; +import type * as trace from '@trace/trace'; type ConsoleEntry = { - message?: channels.ConsoleMessageInitializer; + message?: trace.ConsoleMessageTraceEvent['initializer']; error?: channels.SerializedError; nodeMessage?: { text?: string; @@ -37,8 +39,9 @@ const ConsoleListView = ListView; export const ConsoleTab: React.FunctionComponent<{ model: modelUtil.MultiTraceModel | undefined, + boundaries: Boundaries, selectedTime: Boundaries | undefined, -}> = ({ model, selectedTime }) => { +}> = ({ model, boundaries, selectedTime }) => { const { entries } = React.useMemo(() => { if (!model) return { entries: [] }; @@ -87,31 +90,37 @@ export const ConsoleTab: React.FunctionComponent<{ isWarning={entry => entry.message?.type === 'warning'} render={entry => { const { message, error, nodeMessage } = entry; + const timestamp = msToString(entry.timestamp - boundaries.minimum); if (message) { + const text = message.args ? format(message.args) : message.text; const url = message.location.url; const filename = url ? url.substring(url.lastIndexOf('/') + 1) : ''; return
+ {timestamp} {filename}:{message.location.lineNumber} - {message.text} + {text}
; } if (error) { const { error: errorObject, value } = error; if (errorObject) { return
+ {timestamp} {errorObject.message}
{errorObject.stack}
; } return
+ {timestamp} {String(value)}
; } if (nodeMessage?.text) { return
+ {timestamp}
; @@ -128,7 +137,7 @@ export const ConsoleTab: React.FunctionComponent<{ ; }; -function iconClass(message: channels.ConsoleMessageInitializer): string { +function iconClass(message: trace.ConsoleMessageTraceEvent['initializer']): string { switch (message.type) { case 'error': return 'error'; case 'warning': return 'warning'; @@ -139,3 +148,75 @@ function iconClass(message: channels.ConsoleMessageInitializer): string { function stdioClass(isError: boolean): string { return isError ? 'error' : 'blank'; } + +function format(args: { preview: string, value: any }[]): JSX.Element[] { + if (args.length === 1) + return [{args[0].preview}]; + const hasMessageFormat = typeof args[0].value === 'string' && args[0].value.includes('%'); + const messageFormat = hasMessageFormat ? args[0].value as string : ''; + const tail = hasMessageFormat ? args.slice(1) : args; + let argIndex = 0; + + const regex = /%([%sdifoOc])/g; + let match; + const formatted: JSX.Element[] = []; + let tokens: JSX.Element[] = []; + formatted.push({tokens}); + let formatIndex = 0; + while ((match = regex.exec(messageFormat)) !== null) { + const text = messageFormat.substring(formatIndex, match.index); + tokens.push({text}); + formatIndex = match.index + 2; + const specifier = match[0][1]; + if (specifier === '%') { + tokens.push(%); + } else if (specifier === 's' || specifier === 'o' || specifier === 'O' || specifier === 'd' || specifier === 'i' || specifier === 'f') { + const value = tail[argIndex++]; + const styleObject: any = {}; + if (typeof value?.value !== 'string') + styleObject['color'] = 'var(--vscode-debugTokenExpression-number)'; + tokens.push({value?.preview || ''}); + } else if (specifier === 'c') { + tokens = []; + const format = tail[argIndex++]; + const styleObject = format ? parseCSSStyle(format.preview) : {}; + formatted.push({tokens}); + } + } + if (formatIndex < messageFormat.length) + tokens.push({messageFormat.substring(formatIndex)}); + for (; argIndex < tail.length; argIndex++) { + const value = tail[argIndex]; + const styleObject: any = {}; + if (tokens.length) + tokens.push( ); + if (typeof value?.value !== 'string') + styleObject['color'] = 'var(--vscode-debugTokenExpression-number)'; + tokens.push({value?.preview || ''}); + } + return formatted; +} + +function parseCSSStyle(cssFormat: string): Record { + try { + const styleObject: Record = {}; + const cssText = cssFormat.replace(/;$/, '').replace(/: /g, ':').replace(/; /g, ';'); + const cssProperties = cssText.split(';'); + for (const property of cssProperties) { + const [key, value] = property.split(':'); + if (!supportProperty(key)) + continue; + // cssProperties are background-color, JSDom ones are backgroundColor + const cssKey = key.replace(/-([a-z])/g, g => g[1].toUpperCase()); + styleObject[cssKey] = value; + } + return styleObject; + } catch (e) { + return {}; + } +} + +function supportProperty(cssKey: string): boolean { + const prefixes = ['background', 'border', 'color', 'font', 'line', 'margin', 'padding', 'text']; + return prefixes.some(p => cssKey.startsWith(p)); +} diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index ae3ba4447a..2e56314b49 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -19,6 +19,7 @@ import { Expandable } from '@web/components/expandable'; import * as React from 'react'; import './networkResourceDetails.css'; import type { Entry } from '@trace/har'; +import { msToString } from '@web/uiUtils'; export const NetworkResourceDetails: React.FunctionComponent<{ resource: ResourceSnapshot, @@ -88,7 +89,7 @@ export const NetworkResourceDetails: React.FunctionComponent<{ className='network-request'>
-
{resource.time}ms
+
{msToString(resource.time)}
URL
{resource.request.url}
Request Headers
diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index b25bf061ea..3786d515eb 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -105,7 +105,7 @@ export const Workbench: React.FunctionComponent<{ const consoleTab: TabbedPaneTabModel = { id: 'console', title: 'Console', - render: () => + render: () => }; const networkTab: TabbedPaneTabModel = { id: 'network', @@ -154,7 +154,7 @@ export const Workbench: React.FunctionComponent<{ selectedTime={selectedTime} setSelectedTime={setSelectedTime} /> - + await expect(page.getByText('RED', { exact: true })).toHaveCSS('color', 'rgb(204, 0, 0)'); await expect(page.getByText('GREEN', { exact: true })).toHaveCSS('color', 'rgb(0, 204, 0)'); }); + +test('should format console messages in page', async ({ runUITest }, testInfo) => { + const { page } = await runUITest({ + 'a.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('print', async ({ page }) => { + await page.evaluate(() => { + console.log('Object %O', { a: 1 }); + console.log('Date %o', new Date()); + console.log('Regex %o', /a/); + console.log('Number %f', -0, 'one', 2); + console.log('Download the %cReact DevTools%c for a better development experience: %chttps://fb.me/react-devtools', 'font-weight:bold;color:red;outline:blue', '', 'color: blue; text-decoration: underline'); + console.log('Array', 'of', 'values'); + }); + }); + `, + }); + await page.getByTitle('Run all').click(); + await page.getByText('Console').click(); + await page.getByText('print').click(); + + await expect(page.locator('.console-tab .console-line-message')).toHaveText([ + 'Object {a: 1}', + /Date.*/, + 'Regex /a/', + 'Number 0 one 2', + 'Download the React DevTools for a better development experience: https://fb.me/react-devtools', + 'Array of values', + ]); + + const label = page.getByText('React DevTools'); + await expect(label).toHaveCSS('color', 'rgb(255, 0, 0)'); + await expect(label).toHaveCSS('font-weight', '700'); + // blue should not be used, should inherit color red. + await expect(label).toHaveCSS('outline', 'rgb(255, 0, 0) none 0px'); + + const link = page.getByText('https://fb.me/react-devtools'); + await expect(link).toHaveCSS('color', 'rgb(0, 0, 255)'); + await expect(link).toHaveCSS('text-decoration', 'none solid rgb(0, 0, 255)'); +});