diff --git a/packages/playwright-test/src/worker/testInfo.ts b/packages/playwright-test/src/worker/testInfo.ts index 0d497fe294..58b6361d70 100644 --- a/packages/playwright-test/src/worker/testInfo.ts +++ b/packages/playwright-test/src/worker/testInfo.ts @@ -312,6 +312,15 @@ export class TestInfoImpl implements TestInfo { return step; } + _appendStdioToTrace(type: 'stdout' | 'stderr', chunk: string | Buffer) { + this._traceEvents.push({ + type, + timestamp: monotonicTime(), + text: typeof chunk === 'string' ? chunk : undefined, + base64: typeof chunk === 'string' ? undefined : chunk.toString('base64'), + }); + } + _interrupt() { // Mark as interrupted so we can ignore TimeoutError thrown by interrupt() call. this._wasInterrupted = true; diff --git a/packages/playwright-test/src/worker/workerMain.ts b/packages/playwright-test/src/worker/workerMain.ts index 7dc70cc8bb..51111849d2 100644 --- a/packages/playwright-test/src/worker/workerMain.ts +++ b/packages/playwright-test/src/worker/workerMain.ts @@ -81,6 +81,7 @@ export class WorkerMain extends ProcessRunner { ...chunkToParams(chunk) }; this.dispatchEvent('stdOut', outPayload); + this._currentTest?._appendStdioToTrace('stdout', chunk); return true; }; @@ -90,6 +91,7 @@ export class WorkerMain extends ProcessRunner { ...chunkToParams(chunk) }; this.dispatchEvent('stdErr', outPayload); + this._currentTest?._appendStdioToTrace('stderr', chunk); return true; }; } @@ -633,9 +635,9 @@ function formatTestTitle(test: TestCase, projectName: string) { return `${projectTitle}${location} › ${titles.join(' › ')}`; } -function chunkToParams(chunk: Buffer | string): { text?: string, buffer?: string } { - if (chunk instanceof Buffer) - return { buffer: chunk.toString('base64') }; +function chunkToParams(chunk: Uint8Array | string, encoding?: BufferEncoding): { text?: string, buffer?: string } { + if (chunk instanceof Uint8Array) + return { buffer: Buffer.from(chunk).toString('base64') }; if (typeof chunk !== 'string') return { text: util.inspect(chunk) }; return { text: chunk }; diff --git a/packages/trace-viewer/src/entries.ts b/packages/trace-viewer/src/entries.ts index e8c82f31a2..0adec7eb76 100644 --- a/packages/trace-viewer/src/entries.ts +++ b/packages/trace-viewer/src/entries.ts @@ -34,6 +34,7 @@ export type ContextEntry = { resources: ResourceSnapshot[]; actions: trace.ActionTraceEvent[]; events: trace.EventTraceEvent[]; + stdio: trace.StdioTraceEvent[]; initializers: { [key: string]: any }; hasSource: boolean; }; @@ -62,6 +63,7 @@ export function createEmptyContext(): ContextEntry { resources: [], actions: [], events: [], + stdio: [], initializers: {}, hasSource: false }; diff --git a/packages/trace-viewer/src/traceModel.ts b/packages/trace-viewer/src/traceModel.ts index 3518a3ab80..820a9e7a6e 100644 --- a/packages/trace-viewer/src/traceModel.ts +++ b/packages/trace-viewer/src/traceModel.ts @@ -177,6 +177,14 @@ export class TraceModel { contextEntry!.events.push(event); break; } + case 'stdout': { + contextEntry!.stdio.push(event); + break; + } + case 'stderr': { + contextEntry!.stdio.push(event); + break; + } case 'object': { contextEntry!.initializers[event.guid] = event.initializer; break; diff --git a/packages/trace-viewer/src/ui/consoleTab.tsx b/packages/trace-viewer/src/ui/consoleTab.tsx index ed97674c6f..e36839df13 100644 --- a/packages/trace-viewer/src/ui/consoleTab.tsx +++ b/packages/trace-viewer/src/ui/consoleTab.tsx @@ -20,10 +20,17 @@ import * as React from 'react'; import './consoleTab.css'; import * as modelUtil from './modelUtil'; import { ListView } from '@web/components/listView'; +import { ansi2htmlMarkup } from '@web/components/errorMessage'; type ConsoleEntry = { message?: channels.ConsoleMessageInitializer; error?: channels.SerializedError; + nodeMessage?: { + text?: string; + base64?: string; + isError: boolean; + }, + timestamp: number; highlight: boolean; }; @@ -46,25 +53,39 @@ export const ConsoleTab: React.FunctionComponent<{ entries.push({ message: modelUtil.context(event).initializers[guid], highlight: actionEvents.includes(event), + timestamp: event.time, }); } if (event.method === 'pageError') { entries.push({ error: event.params.error, highlight: actionEvents.includes(event), + timestamp: event.time, }); } } + for (const event of model.stdio) { + entries.push({ + nodeMessage: { + text: event.text, + base64: event.base64, + isError: event.type === 'stderr', + }, + timestamp: event.timestamp, + highlight: false, + }); + } + entries.sort((a, b) => a.timestamp - b.timestamp); return { entries }; }, [model, action]); return
!!entry.error || entry.message?.type === 'error'} + isError={entry => !!entry.error || entry.message?.type === 'error' || entry.nodeMessage?.isError || false} isWarning={entry => entry.message?.type === 'warning'} render={entry => { - const { message, error } = entry; + const { message, error, nodeMessage } = entry; if (message) { const url = message.location.url; const filename = url ? url.substring(url.lastIndexOf('/') + 1) : ''; @@ -82,15 +103,27 @@ export const ConsoleTab: React.FunctionComponent<{ {errorObject.message}
{errorObject.stack}
; - } else { - return
- - {String(value)} -
; } + return
+ + {String(value)} +
; + } + if (nodeMessage?.text) { + return
+ + +
; + } + if (nodeMessage?.base64) { + return
+ + +
; } return null; - }} + } + } isHighlighted={entry => !!entry.highlight} /> ; @@ -103,3 +136,7 @@ function iconClass(message: channels.ConsoleMessageInitializer): string { } return 'blank'; } + +function stdioClass(isError: boolean): string { + return isError ? 'error' : 'blank'; +} diff --git a/packages/trace-viewer/src/ui/modelUtil.ts b/packages/trace-viewer/src/ui/modelUtil.ts index 73b7457aad..0b007a4d02 100644 --- a/packages/trace-viewer/src/ui/modelUtil.ts +++ b/packages/trace-viewer/src/ui/modelUtil.ts @@ -59,6 +59,7 @@ export class MultiTraceModel { readonly pages: PageEntry[]; readonly actions: ActionTraceEventInContext[]; readonly events: trace.EventTraceEvent[]; + readonly stdio: trace.StdioTraceEvent[]; readonly hasSource: boolean; readonly sdkLanguage: Language | undefined; readonly testIdAttributeName: string | undefined; @@ -81,6 +82,7 @@ export class MultiTraceModel { this.pages = ([] as PageEntry[]).concat(...contexts.map(c => c.pages)); this.actions = mergeActions(contexts); this.events = ([] as EventTraceEvent[]).concat(...contexts.map(c => c.events)); + this.stdio = ([] as trace.StdioTraceEvent[]).concat(...contexts.map(c => c.stdio)); this.hasSource = contexts.some(c => c.hasSource); this.resources = [...contexts.map(c => c.resources)].flat(); diff --git a/packages/trace/src/trace.ts b/packages/trace/src/trace.ts index 0348281ff6..891d7be97e 100644 --- a/packages/trace/src/trace.ts +++ b/packages/trace/src/trace.ts @@ -122,6 +122,13 @@ export type ActionTraceEvent = { & Omit & Omit; +export type StdioTraceEvent = { + type: 'stdout' | 'stderr'; + timestamp: number; + text?: string; + base64?: string; +}; + export type TraceEvent = ContextCreatedTraceEvent | ScreencastFrameTraceEvent | @@ -132,4 +139,5 @@ export type TraceEvent = EventTraceEvent | ObjectTraceEvent | ResourceSnapshotTraceEvent | - FrameSnapshotTraceEvent; + FrameSnapshotTraceEvent | + StdioTraceEvent; diff --git a/packages/web/src/components/listView.css b/packages/web/src/components/listView.css index 3fbb74f3d0..e64e47dd80 100644 --- a/packages/web/src/components/listView.css +++ b/packages/web/src/components/listView.css @@ -20,7 +20,7 @@ flex: auto; position: relative; user-select: none; - overflow-y: auto; + overflow: hidden auto; outline: 1px solid transparent; } diff --git a/tests/playwright-test/ui-mode-test-output.spec.ts b/tests/playwright-test/ui-mode-test-output.spec.ts index 32d8e15719..14d1bb8f0f 100644 --- a/tests/playwright-test/ui-mode-test-output.spec.ts +++ b/tests/playwright-test/ui-mode-test-output.spec.ts @@ -74,3 +74,40 @@ test('should print buffers', async ({ runUITest }) => { await page.getByTitle('Run all').click(); await expect(page.getByTestId('output')).toContainText('HELLO'); }); + +test('should show console messages for test', 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('page message')); + console.log('node message'); + await page.evaluate(() => console.error('page error')); + console.error('node error'); + console.log('Colors: \x1b[31mRED\x1b[0m \x1b[32mGREEN\x1b[0m'); + }); + `, + }); + 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([ + 'page message', + 'node message', + 'page error', + 'node error', + 'Colors: RED GREEN', + ]); + + await expect(page.locator('.console-tab .list-view-entry .codicon')).toHaveClass([ + 'codicon codicon-blank', + 'codicon codicon-blank', + 'codicon codicon-error', + 'codicon codicon-error', + 'codicon codicon-blank', + ]); + + 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)'); +});