mirror of
https://github.com/microsoft/playwright.git
synced 2024-09-17 15:29:34 +03:00
feat(trace): render Node console messages in trace (#24139)
This commit is contained in:
parent
e234a6a037
commit
63915dc07a
@ -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;
|
||||
|
@ -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 };
|
||||
|
@ -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
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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 <div className='console-tab'>
|
||||
<ConsoleListView
|
||||
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'}
|
||||
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) : '<anonymous>';
|
||||
@ -82,15 +103,27 @@ export const ConsoleTab: React.FunctionComponent<{
|
||||
<span className='console-line-message'>{errorObject.message}</span>
|
||||
<div className='console-stack'>{errorObject.stack}</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;
|
||||
}}
|
||||
}
|
||||
}
|
||||
isHighlighted={entry => !!entry.highlight}
|
||||
/>
|
||||
</div>;
|
||||
@ -103,3 +136,7 @@ function iconClass(message: channels.ConsoleMessageInitializer): string {
|
||||
}
|
||||
return 'blank';
|
||||
}
|
||||
|
||||
function stdioClass(isError: boolean): string {
|
||||
return isError ? 'error' : 'blank';
|
||||
}
|
||||
|
@ -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();
|
||||
|
||||
|
@ -122,6 +122,13 @@ export type ActionTraceEvent = {
|
||||
& Omit<AfterActionTraceEvent, 'type'>
|
||||
& Omit<InputActionTraceEvent, 'type'>;
|
||||
|
||||
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;
|
||||
|
@ -20,7 +20,7 @@
|
||||
flex: auto;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
overflow-y: auto;
|
||||
overflow: hidden auto;
|
||||
outline: 1px solid transparent;
|
||||
}
|
||||
|
||||
|
@ -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)');
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user