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;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue