feat(trace): render Node console messages in trace (#24139)

This commit is contained in:
Pavel Feldman 2023-07-10 18:36:28 -07:00 committed by GitHub
parent e234a6a037
commit 63915dc07a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 118 additions and 13 deletions

View file

@ -312,6 +312,15 @@ export class TestInfoImpl implements TestInfo {
return step; 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() { _interrupt() {
// Mark as interrupted so we can ignore TimeoutError thrown by interrupt() call. // Mark as interrupted so we can ignore TimeoutError thrown by interrupt() call.
this._wasInterrupted = true; this._wasInterrupted = true;

View file

@ -81,6 +81,7 @@ export class WorkerMain extends ProcessRunner {
...chunkToParams(chunk) ...chunkToParams(chunk)
}; };
this.dispatchEvent('stdOut', outPayload); this.dispatchEvent('stdOut', outPayload);
this._currentTest?._appendStdioToTrace('stdout', chunk);
return true; return true;
}; };
@ -90,6 +91,7 @@ export class WorkerMain extends ProcessRunner {
...chunkToParams(chunk) ...chunkToParams(chunk)
}; };
this.dispatchEvent('stdErr', outPayload); this.dispatchEvent('stdErr', outPayload);
this._currentTest?._appendStdioToTrace('stderr', chunk);
return true; return true;
}; };
} }
@ -633,9 +635,9 @@ function formatTestTitle(test: TestCase, projectName: string) {
return `${projectTitle}${location} ${titles.join(' ')}`; return `${projectTitle}${location} ${titles.join(' ')}`;
} }
function chunkToParams(chunk: Buffer | string): { text?: string, buffer?: string } { function chunkToParams(chunk: Uint8Array | string, encoding?: BufferEncoding): { text?: string, buffer?: string } {
if (chunk instanceof Buffer) if (chunk instanceof Uint8Array)
return { buffer: chunk.toString('base64') }; return { buffer: Buffer.from(chunk).toString('base64') };
if (typeof chunk !== 'string') if (typeof chunk !== 'string')
return { text: util.inspect(chunk) }; return { text: util.inspect(chunk) };
return { text: chunk }; return { text: chunk };

View file

@ -34,6 +34,7 @@ export type ContextEntry = {
resources: ResourceSnapshot[]; resources: ResourceSnapshot[];
actions: trace.ActionTraceEvent[]; actions: trace.ActionTraceEvent[];
events: trace.EventTraceEvent[]; events: trace.EventTraceEvent[];
stdio: trace.StdioTraceEvent[];
initializers: { [key: string]: any }; initializers: { [key: string]: any };
hasSource: boolean; hasSource: boolean;
}; };
@ -62,6 +63,7 @@ export function createEmptyContext(): ContextEntry {
resources: [], resources: [],
actions: [], actions: [],
events: [], events: [],
stdio: [],
initializers: {}, initializers: {},
hasSource: false hasSource: false
}; };

View file

@ -177,6 +177,14 @@ export class TraceModel {
contextEntry!.events.push(event); contextEntry!.events.push(event);
break; break;
} }
case 'stdout': {
contextEntry!.stdio.push(event);
break;
}
case 'stderr': {
contextEntry!.stdio.push(event);
break;
}
case 'object': { case 'object': {
contextEntry!.initializers[event.guid] = event.initializer; contextEntry!.initializers[event.guid] = event.initializer;
break; break;

View file

@ -20,10 +20,17 @@ import * as React from 'react';
import './consoleTab.css'; import './consoleTab.css';
import * as modelUtil from './modelUtil'; import * as modelUtil from './modelUtil';
import { ListView } from '@web/components/listView'; import { ListView } from '@web/components/listView';
import { ansi2htmlMarkup } from '@web/components/errorMessage';
type ConsoleEntry = { type ConsoleEntry = {
message?: channels.ConsoleMessageInitializer; message?: channels.ConsoleMessageInitializer;
error?: channels.SerializedError; error?: channels.SerializedError;
nodeMessage?: {
text?: string;
base64?: string;
isError: boolean;
},
timestamp: number;
highlight: boolean; highlight: boolean;
}; };
@ -46,25 +53,39 @@ export const ConsoleTab: React.FunctionComponent<{
entries.push({ entries.push({
message: modelUtil.context(event).initializers[guid], message: modelUtil.context(event).initializers[guid],
highlight: actionEvents.includes(event), highlight: actionEvents.includes(event),
timestamp: event.time,
}); });
} }
if (event.method === 'pageError') { if (event.method === 'pageError') {
entries.push({ entries.push({
error: event.params.error, error: event.params.error,
highlight: actionEvents.includes(event), 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 }; return { entries };
}, [model, action]); }, [model, action]);
return <div className='console-tab'> return <div className='console-tab'>
<ConsoleListView <ConsoleListView
items={entries} items={entries}
isError={entry => !!entry.error || entry.message?.type === 'error'} isError={entry => !!entry.error || entry.message?.type === 'error' || entry.nodeMessage?.isError || false}
isWarning={entry => entry.message?.type === 'warning'} isWarning={entry => entry.message?.type === 'warning'}
render={entry => { render={entry => {
const { message, error } = entry; const { message, error, nodeMessage } = entry;
if (message) { if (message) {
const url = message.location.url; const url = message.location.url;
const filename = url ? url.substring(url.lastIndexOf('/') + 1) : '<anonymous>'; const filename = url ? url.substring(url.lastIndexOf('/') + 1) : '<anonymous>';
@ -82,15 +103,27 @@ export const ConsoleTab: React.FunctionComponent<{
<span className='console-line-message'>{errorObject.message}</span> <span className='console-line-message'>{errorObject.message}</span>
<div className='console-stack'>{errorObject.stack}</div> <div className='console-stack'>{errorObject.stack}</div>
</div>; </div>;
} else {
return <div className='console-line'>
<span className={'codicon codicon-error'}></span>
<span className='console-line-message'>{String(value)}</span>
</div>;
} }
return <div className='console-line'>
<span className={'codicon codicon-error'}></span>
<span className='console-line-message'>{String(value)}</span>
</div>;
}
if (nodeMessage?.text) {
return <div className='console-line'>
<span className={'codicon codicon-' + stdioClass(nodeMessage.isError)}></span>
<span className='console-line-message' dangerouslySetInnerHTML={{ __html: ansi2htmlMarkup(nodeMessage.text.trim()) || '' }}></span>
</div>;
}
if (nodeMessage?.base64) {
return <div className='console-line'>
<span className={'codicon codicon-' + stdioClass(nodeMessage.isError)}></span>
<span className='console-line-message' dangerouslySetInnerHTML={{ __html: ansi2htmlMarkup(atob(nodeMessage.base64).trim()) || '' }}></span>
</div>;
} }
return null; return null;
}} }
}
isHighlighted={entry => !!entry.highlight} isHighlighted={entry => !!entry.highlight}
/> />
</div>; </div>;
@ -103,3 +136,7 @@ function iconClass(message: channels.ConsoleMessageInitializer): string {
} }
return 'blank'; return 'blank';
} }
function stdioClass(isError: boolean): string {
return isError ? 'error' : 'blank';
}

View file

@ -59,6 +59,7 @@ export class MultiTraceModel {
readonly pages: PageEntry[]; readonly pages: PageEntry[];
readonly actions: ActionTraceEventInContext[]; readonly actions: ActionTraceEventInContext[];
readonly events: trace.EventTraceEvent[]; readonly events: trace.EventTraceEvent[];
readonly stdio: trace.StdioTraceEvent[];
readonly hasSource: boolean; readonly hasSource: boolean;
readonly sdkLanguage: Language | undefined; readonly sdkLanguage: Language | undefined;
readonly testIdAttributeName: string | undefined; readonly testIdAttributeName: string | undefined;
@ -81,6 +82,7 @@ export class MultiTraceModel {
this.pages = ([] as PageEntry[]).concat(...contexts.map(c => c.pages)); this.pages = ([] as PageEntry[]).concat(...contexts.map(c => c.pages));
this.actions = mergeActions(contexts); this.actions = mergeActions(contexts);
this.events = ([] as EventTraceEvent[]).concat(...contexts.map(c => c.events)); 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.hasSource = contexts.some(c => c.hasSource);
this.resources = [...contexts.map(c => c.resources)].flat(); this.resources = [...contexts.map(c => c.resources)].flat();

View file

@ -122,6 +122,13 @@ export type ActionTraceEvent = {
& Omit<AfterActionTraceEvent, 'type'> & Omit<AfterActionTraceEvent, 'type'>
& Omit<InputActionTraceEvent, 'type'>; & Omit<InputActionTraceEvent, 'type'>;
export type StdioTraceEvent = {
type: 'stdout' | 'stderr';
timestamp: number;
text?: string;
base64?: string;
};
export type TraceEvent = export type TraceEvent =
ContextCreatedTraceEvent | ContextCreatedTraceEvent |
ScreencastFrameTraceEvent | ScreencastFrameTraceEvent |
@ -132,4 +139,5 @@ export type TraceEvent =
EventTraceEvent | EventTraceEvent |
ObjectTraceEvent | ObjectTraceEvent |
ResourceSnapshotTraceEvent | ResourceSnapshotTraceEvent |
FrameSnapshotTraceEvent; FrameSnapshotTraceEvent |
StdioTraceEvent;

View file

@ -20,7 +20,7 @@
flex: auto; flex: auto;
position: relative; position: relative;
user-select: none; user-select: none;
overflow-y: auto; overflow: hidden auto;
outline: 1px solid transparent; outline: 1px solid transparent;
} }

View file

@ -74,3 +74,40 @@ test('should print buffers', async ({ runUITest }) => {
await page.getByTitle('Run all').click(); await page.getByTitle('Run all').click();
await expect(page.getByTestId('output')).toContainText('HELLO'); 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)');
});