chore: more network panel polish (#26780)

This commit is contained in:
Pavel Feldman 2023-08-29 22:20:28 -07:00 committed by GitHub
parent 34c6197f9e
commit c209d7e708
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 391 additions and 176 deletions

View File

@ -14,61 +14,12 @@
limitations under the License.
*/
.network-request {
display: flex;
white-space: nowrap;
align-items: center;
padding: 0 3px;
flex: none;
outline: none;
}
.network-request-title {
height: 28px;
display: flex;
align-items: center;
flex: 1;
}
.network-request-title-status {
padding: 0 2px;
border-radius: 4px;
margin: 2px;
line-height: 20px;
min-width: 30px;
text-align: center;
}
.network-request-title-status.status-failure {
color: var(--vscode-statusBar-foreground);
background-color: var(--vscode-statusBarItem-errorBackground);
}
.network-request-title-status.status-route {
color: var(--vscode-statusBar-foreground);
background-color: var(--vscode-statusBar-background);
}
.network-request-title-status.status-route.api {
color: var(--vscode-statusBar-foreground);
background-color: var(--vscode-statusBarItem-remoteBackground);
}
.network-request-title-url {
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.network-request-title-content-type {
margin: 0 6px;
}
.network-request-details {
width: 100%;
user-select: text;
line-height: 24px;
margin-left: 10px;
overflow: auto;
}
.network-request-details-url {
@ -77,7 +28,7 @@
margin-left: 10px;
}
.network-request-headers {
.network-request-details-headers {
white-space: pre;
overflow: hidden;
margin-left: 10px;
@ -88,10 +39,6 @@
font-weight: bold;
}
.network-request-details-time {
float: right;
}
.network-request .cm-wrapper {
.network-request-details .cm-wrapper {
height: 300px;
}

View File

@ -15,54 +15,48 @@
*/
import type { ResourceSnapshot } from '@trace/snapshot';
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';
import { TabbedPane } from '@web/components/tabbedPane';
import { CodeMirrorWrapper } from '@web/components/codeMirrorWrapper';
import type { Language } from '@web/components/codeMirrorWrapper';
import { ToolbarButton } from '@web/components/toolbarButton';
export const NetworkResource: React.FunctionComponent<{
resource: Entry,
}> = ({ resource }) => {
const [expanded, setExpanded] = React.useState(false);
export const NetworkResourceDetails: React.FunctionComponent<{
resource: ResourceSnapshot;
onClose: () => void;
}> = ({ resource, onClose }) => {
const [selectedTab, setSelectedTab] = React.useState('request');
const { routeStatus, resourceName, contentType } = React.useMemo(() => {
const routeStatus = formatRouteStatus(resource);
const resourceName = resource.request.url.substring(resource.request.url.lastIndexOf('/'));
let contentType = resource.response.content.mimeType;
const charset = contentType.match(/^(.*);\s*charset=.*$/);
if (charset)
contentType = charset[1];
return { routeStatus, resourceName, contentType };
}, [resource]);
const renderTitle = React.useCallback(() => {
return <div className='network-request-title'>
{routeStatus && <div className={`network-request-title-status status-route ${routeStatus}`}>{routeStatus}</div> }
{resource.response._failureText && <div className={'network-request-title-status status-failure'}>{resource.response._failureText}</div>}
{!resource.response._failureText && <div className={'network-request-title-status ' + formatStatus(resource.response.status)}>{resource.response.status}</div>}
<div className='network-request-title-status'>{resource.request.method}</div>
<div className='network-request-title-url'>{resourceName}</div>
<div className='network-request-title-content-type'>{contentType}</div>
</div>;
}, [contentType, resource, resourceName, routeStatus]);
return <div className='network-request'>
<Expandable expanded={expanded} setExpanded={setExpanded} title={renderTitle()} expandOnTitleClick={true}>
{expanded && <NetworkResourceDetails resource={resource} />}
</Expandable>
</div>;
return <TabbedPane
dataTestId='network-request-details'
leftToolbar={[<ToolbarButton icon='arrow-left' title='Back' onClick={onClose}></ToolbarButton>]}
rightToolbar={[<ToolbarButton icon='close' title='Close' onClick={onClose}></ToolbarButton>]}
tabs={[
{
id: 'request',
title: 'Request',
render: () => <RequestTab resource={resource}/>,
},
{
id: 'response',
title: 'Response',
render: () => <ResponseTab resource={resource}/>,
},
{
id: 'body',
title: 'Body',
render: () => <BodyTab resource={resource}/>,
},
]}
selectedTab={selectedTab}
setSelectedTab={setSelectedTab} />;
};
const NetworkResourceDetails: React.FunctionComponent<{
resource: ResourceSnapshot,
const RequestTab: React.FunctionComponent<{
resource: ResourceSnapshot;
}> = ({ resource }) => {
const [requestBody, setRequestBody] = React.useState<{ text: string, language?: Language } | null>(null);
const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string, language?: Language } | null>(null);
const [selectedTab, setSelectedTab] = React.useState('request');
React.useEffect(() => {
const readResources = async () => {
@ -77,7 +71,36 @@ const NetworkResourceDetails: React.FunctionComponent<{
setRequestBody({ text: formatBody(resource.request.postData.text, requestContentType), language });
}
}
};
readResources();
}, [resource]);
return <div className='network-request-details'>
<div className='network-request-details-header'>URL</div>
<div className='network-request-details-url'>{resource.request.url}</div>
<div className='network-request-details-header'>Request Headers</div>
<div className='network-request-details-headers'>{resource.request.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}</div>
{requestBody && <div className='network-request-details-header'>Request Body</div>}
{requestBody && <CodeMirrorWrapper text={requestBody.text} language={requestBody.language} readOnly lineNumbers={true}/>}
</div>;
};
const ResponseTab: React.FunctionComponent<{
resource: ResourceSnapshot;
}> = ({ resource }) => {
return <div className='network-request-details'>
<div className='network-request-details-header'>Response Headers</div>
<div className='network-request-details-headers'>{resource.response.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}</div>
</div>;
};
const BodyTab: React.FunctionComponent<{
resource: ResourceSnapshot;
}> = ({ resource }) => {
const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string, language?: Language } | null>(null);
React.useEffect(() => {
const readResources = async () => {
if (resource.response.content._sha1) {
const useBase64 = resource.response.content.mimeType.includes('image');
const response = await fetch(`sha1/${resource.response.content._sha1}`);
@ -98,48 +121,13 @@ const NetworkResourceDetails: React.FunctionComponent<{
readResources();
}, [resource]);
return <TabbedPane tabs={[
{
id: 'request',
title: 'Request',
render: () => <div className='network-request-details'>
<div className='network-request-details-header'>URL</div>
<div className='network-request-details-url'>{resource.request.url}</div>
<div className='network-request-details-header'>Request Headers</div>
<div className='network-request-headers'>{resource.request.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}</div>
{requestBody && <div className='network-request-details-header'>Request Body</div>}
{requestBody && <CodeMirrorWrapper text={requestBody.text} language={requestBody.language} readOnly/>}
</div>,
},
{
id: 'response',
title: 'Response',
render: () => <div className='network-request-details'>
<div className='network-request-details-time'>{msToString(resource.time)}</div>
<div className='network-request-details-header'>Response Headers</div>
<div className='network-request-headers'>{resource.response.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}</div>
</div>,
},
{
id: 'body',
title: 'Body',
render: () => <div className='network-request-details'>
{!resource.response.content._sha1 && <div>Response body is not available for this request.</div>}
{responseBody && responseBody.dataUrl && <img draggable='false' src={responseBody.dataUrl} />}
{responseBody && responseBody.text && <CodeMirrorWrapper text={responseBody.text} language={responseBody.language} readOnly/>}
</div>,
},
]} selectedTab={selectedTab} setSelectedTab={setSelectedTab}/>;
return <div className='network-request-details'>
{!resource.response.content._sha1 && <div>Response body is not available for this request.</div>}
{responseBody && responseBody.dataUrl && <img draggable='false' src={responseBody.dataUrl} />}
{responseBody && responseBody.text && <CodeMirrorWrapper text={responseBody.text} language={responseBody.language} readOnly lineNumbers={true}/>}
</div>;
};
function formatStatus(status: number): string {
if (status >= 200 && status < 400)
return 'status-success';
if (status >= 400)
return 'status-failure';
return '';
}
function formatBody(body: string | null, contentType: string): string {
if (body === null)
return 'Loading...';
@ -162,18 +150,6 @@ function formatBody(body: string | null, contentType: string): string {
return bodyStr;
}
function formatRouteStatus(request: Entry): string {
if (request._wasAborted)
return 'aborted';
if (request._wasContinued)
return 'continued';
if (request._wasFulfilled)
return 'fulfilled';
if (request._apiRequest)
return 'api';
return '';
}
function mimeTypeToHighlighter(mimeType: string): Language | undefined {
if (mimeType.includes('javascript') || mimeType.includes('json'))
return 'javascript';

View File

@ -14,17 +14,113 @@
limitations under the License.
*/
.network-tab {
.network-request-status .status-failure {
color: var(--vscode-statusBar-foreground);
background-color: var(--vscode-statusBarItem-errorBackground);
}
.network-request-status .status-route {
color: var(--vscode-statusBar-foreground);
background-color: var(--vscode-statusBar-background);
}
.network-request-status .status-route.api {
color: var(--vscode-statusBar-foreground);
background-color: var(--vscode-statusBarItem-remoteBackground);
}
.network-request-status {
flex: 0 0 70px;
}
.network-request-method {
flex: 0 0 70px;
}
.network-request-file {
display: flex;
flex-direction: column;
flex: auto;
overflow: auto;
flex: 1;
}
.network-tab:focus {
outline: none;
.network-request-file-url {
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.network-request .expandable {
width: 100%;
}
.network-request-content-type,
.network-request-time,
.network-request-route,
.network-request-size {
overflow: hidden;
text-overflow: ellipsis;
flex: 0.5;
}
.network-request-route {
flex: 0.25;
}
.network-request-header {
height: 28px;
border-bottom: 1px solid var(--vscode-panel-border);
flex: none;
align-items: center;
cursor: pointer;
user-select: none;
}
.network-request-header .codicon-triangle-up {
display: none;
}
.network-request-header .codicon-triangle-down {
display: none;
}
.network-request-header > div {
display: flex;
align-items: center;
white-space: nowrap;
}
.network-request-header.filter-status.positive .network-request-status .codicon-triangle-down {
display: initial !important;
}
.network-request-header.filter-status.negative .network-request-status .codicon-triangle-up {
display: initial !important;
}
.network-request-header.filter-method.positive .network-request-method .codicon-triangle-down {
display: initial !important;
}
.network-request-header.filter-method.negative .network-request-method .codicon-triangle-up {
display: initial !important;
}
.network-request-header.filter-file.positive .network-request-file .codicon-triangle-down {
display: initial !important;
}
.network-request-header.filter-file.negative .network-request-file .codicon-triangle-up {
display: initial !important;
}
.network-request-header.filter-content-type.positive .network-request-content-type .codicon-triangle-down {
display: initial !important;
}
.network-request-header.filter-content-type.negative .network-request-content-type .codicon-triangle-up {
display: initial !important;
}
.network-request-header.filter-time.positive .network-request-time .codicon-triangle-down {
display: initial !important;
}
.network-request-header.filter-time.negative .network-request-time .codicon-triangle-up {
display: initial !important;
}
.network-request-header.filter-size.positive .network-request-size .codicon-triangle-down {
display: initial !important;
}
.network-request-header.filter-size.negative .network-request-size .codicon-triangle-up {
display: initial !important;
}

View File

@ -14,25 +14,198 @@
* limitations under the License.
*/
import type { Entry } from '@trace/har';
import { ListView } from '@web/components/listView';
import * as React from 'react';
import type * as modelUtil from './modelUtil';
import { NetworkResource } from './networkResourceDetails';
import './networkTab.css';
import type { Boundaries } from '../geometry';
import type * as modelUtil from './modelUtil';
import './networkTab.css';
import { NetworkResourceDetails } from './networkResourceDetails';
import { bytesToString, msToString } from '@web/uiUtils';
const NetworkListView = ListView<Entry>;
type Filter = 'status' | 'method' | 'file' | 'time' | 'size' | 'content-type';
export const NetworkTab: React.FunctionComponent<{
model: modelUtil.MultiTraceModel | undefined,
selectedTime: Boundaries | undefined,
}> = ({ model, selectedTime }) => {
const [resource, setResource] = React.useState<Entry | undefined>();
const [filter, setFilter] = React.useState<Filter | undefined>(undefined);
const [negateFilter, setNegateFilter] = React.useState<boolean>(false);
const resources = React.useMemo(() => {
const resources = model?.resources || [];
if (!selectedTime)
return resources;
return resources.filter(resource => {
const filtered = resources.filter(resource => {
if (!selectedTime)
return true;
return !!resource._monotonicTime && (resource._monotonicTime >= selectedTime.minimum && resource._monotonicTime <= selectedTime.maximum);
});
}, [model, selectedTime]);
return <div className='network-tab'> {
resources.map((resource, index) => <NetworkResource key={index} resource={resource}></NetworkResource>)
}</div>;
if (filter)
sort(filtered, filter, negateFilter);
return filtered;
}, [filter, model, negateFilter, selectedTime]);
const toggleFilter = React.useCallback((f: Filter) => {
if (filter === f) {
setNegateFilter(!negateFilter);
} else {
setNegateFilter(false);
setFilter(f);
}
}, [filter, negateFilter]);
return <>
{!resource && <div className='vbox'>
<NetworkHeader filter={filter} negateFilter={negateFilter} toggleFilter={toggleFilter} />
<NetworkListView
dataTestId='network-request-list'
items={resources}
render={entry => <NetworkResource resource={entry}></NetworkResource>}
onSelected={setResource}
/>
</div>}
{resource && <NetworkResourceDetails resource={resource} onClose={() => setResource(undefined)} />}
</>;
};
const NetworkHeader: React.FunctionComponent<{
filter: Filter | undefined,
negateFilter: boolean,
toggleFilter: (filter: Filter) => void,
}> = ({ toggleFilter, filter, negateFilter }) => {
return <div className={'hbox network-request-header' + (filter ? ' filter-' + filter : '') + (negateFilter ? ' negative' : ' positive')}>
<div className='network-request-status' onClick={() => toggleFilter('status') }>
&nbsp;Status
<span className='codicon codicon-triangle-up' />
<span className='codicon codicon-triangle-down' />
</div>
<div className='network-request-method' onClick={() => toggleFilter('method') }>
Method
<span className='codicon codicon-triangle-up' />
<span className='codicon codicon-triangle-down' />
</div>
<div className='network-request-file' onClick={() => toggleFilter('file') }>
Request
<span className='codicon codicon-triangle-up' />
<span className='codicon codicon-triangle-down' />
</div>
<div className='network-request-content-type' onClick={() => toggleFilter('content-type') }>
Content Type
<span className='codicon codicon-triangle-up' />
<span className='codicon codicon-triangle-down' />
</div>
<div className='network-request-time' onClick={() => toggleFilter('time') }>
Time
<span className='codicon codicon-triangle-up' />
<span className='codicon codicon-triangle-down' />
</div>
<div className='network-request-size' onClick={() => toggleFilter('size') }>
Size
<span className='codicon codicon-triangle-up' />
<span className='codicon codicon-triangle-down' />
</div>
<div className='network-request-route'>Route</div>
</div>;
};
const NetworkResource: React.FunctionComponent<{
resource: Entry,
}> = ({ resource }) => {
const { routeStatus, resourceName, contentType } = React.useMemo(() => {
const routeStatus = formatRouteStatus(resource);
const resourceName = resource.request.url.substring(resource.request.url.lastIndexOf('/'));
let contentType = resource.response.content.mimeType;
const charset = contentType.match(/^(.*);\s*charset=.*$/);
if (charset)
contentType = charset[1];
return { routeStatus, resourceName, contentType };
}, [resource]);
return <div className='hbox'>
<div className='hbox network-request-status'>
<div className={formatStatus(resource.response.status)} title={resource.response.statusText}>{resource.response.status}</div>
</div>
<div className='hbox network-request-method'>
<div>{resource.request.method}</div>
</div>
<div className='network-request-file'>
<div className='network-request-file-url' title={resource.request.url}>{resourceName}</div>
</div>
<div className='network-request-content-type' title={contentType}>{contentType}</div>
<div className='network-request-time'>{msToString(resource.time)}</div>
<div className='network-request-size'>{bytesToString(resource.response._transferSize! > 0 ? resource.response._transferSize! : resource.response.bodySize)}</div>
<div className='network-request-route'>
{routeStatus && <div className={`status-route ${routeStatus}`}>{routeStatus}</div>}
</div>
</div>;
};
function formatStatus(status: number): string {
if (status >= 200 && status < 400)
return 'status-success';
if (status >= 400)
return 'status-failure';
return '';
}
function formatRouteStatus(request: Entry): string {
if (request._wasAborted)
return 'aborted';
if (request._wasContinued)
return 'continued';
if (request._wasFulfilled)
return 'fulfilled';
if (request._apiRequest)
return 'api';
return '';
}
function sort(resources: Entry[], filter: Filter | undefined, negate: boolean) {
const c = comparator(filter);
if (c)
resources.sort(c);
if (negate)
resources.reverse();
}
function comparator(filter: Filter | undefined) {
if (filter === 'time')
return (a: Entry, b: Entry) => a.time - b.time;
if (filter === 'status')
return (a: Entry, b: Entry) => a.response.status - b.response.status;
if (filter === 'method') {
return (a: Entry, b: Entry) => {
const valueA = a.request.method;
const valueB = b.request.method;
return valueA.localeCompare(valueB);
};
}
if (filter === 'size') {
return (a: Entry, b: Entry) => {
const sizeA = a.response._transferSize! > 0 ? a.response._transferSize! : a.response.bodySize;
const sizeB = b.response._transferSize! > 0 ? b.response._transferSize! : b.response.bodySize;
return sizeA - sizeB;
};
}
if (filter === 'content-type') {
return (a: Entry, b: Entry) => {
const valueA = a.response.content.mimeType;
const valueB = b.response.content.mimeType;
return valueA.localeCompare(valueB);
};
}
if (filter === 'file') {
return (a: Entry, b: Entry) => {
const nameA = a.request.url.substring(a.request.url.lastIndexOf('/'));
const nameB = b.request.url.substring(b.request.url.lastIndexOf('/'));
return nameA.localeCompare(nameB);
};
}
}

View File

@ -30,12 +30,13 @@ export const TabbedPane: React.FunctionComponent<{
leftToolbar?: React.ReactElement[],
rightToolbar?: React.ReactElement[],
selectedTab: string,
setSelectedTab: (tab: string) => void
}> = ({ tabs, selectedTab, setSelectedTab, leftToolbar, rightToolbar }) => {
return <div className='tabbed-pane'>
setSelectedTab: (tab: string) => void,
dataTestId?: string,
}> = ({ tabs, selectedTab, setSelectedTab, leftToolbar, rightToolbar, dataTestId }) => {
return <div className='tabbed-pane' data-testid={dataTestId}>
<div className='vbox'>
<Toolbar>
{ leftToolbar && <div style={{ flex: 'none', display: 'flex', margin: '0 4px' }}>
{ leftToolbar && <div style={{ flex: 'none', display: 'flex', margin: '0 4px', alignItems: 'center' }}>
{...leftToolbar}
</div>}
<div style={{ flex: 'auto', display: 'flex', height: '100%', overflow: 'hidden' }}>
@ -48,7 +49,7 @@ export const TabbedPane: React.FunctionComponent<{
></TabbedPaneTab>)),
]}
</div>
{rightToolbar && <div style={{ flex: 'none', display: 'flex' }}>
{rightToolbar && <div style={{ flex: 'none', display: 'flex', alignItems: 'center' }}>
{...rightToolbar}
</div>}
</Toolbar>

View File

@ -80,6 +80,28 @@ export function msToString(ms: number): string {
return days.toFixed(1) + 'd';
}
export function bytesToString(bytes: number): string {
if (bytes < 0 || !isFinite(bytes))
return '-';
if (bytes === 0)
return '0';
if (bytes < 1000)
return bytes.toFixed(0);
const kb = bytes / 1024;
if (kb < 1000)
return kb.toFixed(1) + 'K';
const mb = kb / 1024;
if (mb < 1000)
return mb.toFixed(1) + 'M';
const gb = mb / 1024;
return gb.toFixed(1) + 'G';
}
export function lowerBound<S, T>(array: S[], object: T, comparator: (object: T, b: S) => number, left?: number, right?: number): number {
let l = left || 0;
let r = right !== undefined ? right : array.length;

View File

@ -51,7 +51,7 @@ class TraceViewerPage {
this.consoleLineMessages = page.locator('.console-line-message');
this.consoleStacks = page.locator('.console-stack');
this.stackFrames = page.getByTestId('stack-trace').locator('.list-view-entry');
this.networkRequests = page.locator('.network-request-title');
this.networkRequests = page.getByTestId('network-request-list').locator('.list-view-entry');
this.snapshotContainer = page.locator('.snapshot-container iframe.snapshot-visible[name=snapshot]');
}

View File

@ -253,7 +253,7 @@ test('should have network request overrides', async ({ page, server, runAndTrace
await traceViewer.selectAction('http://localhost');
await traceViewer.showNetworkTab();
await expect(traceViewer.networkRequests).toContainText([/200GET\/frame.htmltext\/html/]);
await expect(traceViewer.networkRequests).toContainText([/aborted.*style.cssx-unknown/]);
await expect(traceViewer.networkRequests).toContainText([/GET\/style.cssx-unknown.*aborted/]);
await expect(traceViewer.networkRequests).not.toContainText([/continued/]);
});
@ -264,8 +264,8 @@ test('should have network request overrides 2', async ({ page, server, runAndTra
});
await traceViewer.selectAction('http://localhost');
await traceViewer.showNetworkTab();
await expect(traceViewer.networkRequests).toContainText([/200GET\/frame.htmltext\/html/]);
await expect(traceViewer.networkRequests).toContainText([/continued.*script.jsapplication\/javascript/]);
await expect.soft(traceViewer.networkRequests).toContainText([/200GET\/frame.htmltext\/html.*/]);
await expect.soft(traceViewer.networkRequests).toContainText([/200GET\/script.jsapplication\/javascript.*continued/]);
});
test('should show snapshot URL', async ({ page, runAndTrace, server }) => {

View File

@ -446,7 +446,7 @@ test('merge into list report by default', async ({ runInlineTest, mergeReports }
const text = stripAnsi(output);
expect(text).toContain('Running 10 tests using 3 workers');
const lines = text.split('\n').filter(l => l.match(/^\d :/)).map(l => l.replace(/\d+ms/, 'Xms'));
const lines = text.split('\n').filter(l => l.match(/^\d :/)).map(l => l.replace(/[.\d]+m?s/, 'Xms'));
expect(lines).toEqual([
`0 : 1 a.test.js:3:11 math 1`,
`0 : ${POSITIVE_STATUS_MARK} 1 a.test.js:3:11 math 1 (Xms)`,

View File

@ -72,7 +72,7 @@ for (const useIntermediateMergeReport of [false, true] as const) {
`,
}, { reporter: 'list' }, { PW_TEST_DEBUG_REPORTERS: '1', PW_TEST_DEBUG_REPORTERS_PRINT_STEPS: '1', PWTEST_TTY_WIDTH: '80' });
const text = result.output;
const lines = text.split('\n').filter(l => l.match(/^\d :/)).map(l => l.replace(/\d+ms/, 'Xms'));
const lines = text.split('\n').filter(l => l.match(/^\d :/)).map(l => l.replace(/[.\d]+m?s/, 'Xms'));
lines.pop(); // Remove last item that contains [v] and time in ms.
expect(lines).toEqual([
'0 : 1 a.test.ts:3:15 passes',
@ -107,7 +107,7 @@ for (const useIntermediateMergeReport of [false, true] as const) {
});`,
}, { reporter: 'list' }, { PW_TEST_DEBUG_REPORTERS: '1', PWTEST_TTY_WIDTH: '80' });
const text = result.output;
const lines = text.split('\n').filter(l => l.match(/^\d :/)).map(l => l.replace(/\d+ms/, 'Xms'));
const lines = text.split('\n').filter(l => l.match(/^\d :/)).map(l => l.replace(/[.\d]+m?s/, 'Xms'));
lines.pop(); // Remove last item that contains [v] and time in ms.
expect(lines).toEqual([
'0 : 1 a.test.ts:3:11 passes',