diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.css b/packages/trace-viewer/src/ui/networkResourceDetails.css
index ab80a62b8d..54a92ce07c 100644
--- a/packages/trace-viewer/src/ui/networkResourceDetails.css
+++ b/packages/trace-viewer/src/ui/networkResourceDetails.css
@@ -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;
}
diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx
index 08418a9def..ec57706390 100644
--- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx
+++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx
@@ -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
- {routeStatus &&
{routeStatus}
}
- {resource.response._failureText &&
{resource.response._failureText}
}
- {!resource.response._failureText &&
{resource.response.status}
}
-
{resource.request.method}
-
{resourceName}
-
{contentType}
-
;
- }, [contentType, resource, resourceName, routeStatus]);
-
- return
-
- {expanded && }
-
-
;
+ return ]}
+ rightToolbar={[]}
+ tabs={[
+ {
+ id: 'request',
+ title: 'Request',
+ render: () => ,
+ },
+ {
+ id: 'response',
+ title: 'Response',
+ render: () => ,
+ },
+ {
+ id: 'body',
+ title: 'Body',
+ render: () => ,
+ },
+ ]}
+ 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
+
URL
+
{resource.request.url}
+
Request Headers
+
{resource.request.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}
+ {requestBody &&
Request Body
}
+ {requestBody &&
}
+ ;
+};
+
+const ResponseTab: React.FunctionComponent<{
+ resource: ResourceSnapshot;
+}> = ({ resource }) => {
+ return
+
Response Headers
+
{resource.response.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}
+
;
+};
+
+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
-
URL
-
{resource.request.url}
-
Request Headers
-
{resource.request.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}
- {requestBody &&
Request Body
}
- {requestBody &&
}
-
,
- },
- {
- id: 'response',
- title: 'Response',
- render: () =>
-
{msToString(resource.time)}
-
Response Headers
-
{resource.response.headers.map(pair => `${pair.name}: ${pair.value}`).join('\n')}
-
,
- },
- {
- id: 'body',
- title: 'Body',
- render: () =>
- {!resource.response.content._sha1 &&
Response body is not available for this request.
}
- {responseBody && responseBody.dataUrl &&

}
- {responseBody && responseBody.text &&
}
-
,
- },
- ]} selectedTab={selectedTab} setSelectedTab={setSelectedTab}/>;
+ return
+ {!resource.response.content._sha1 &&
Response body is not available for this request.
}
+ {responseBody && responseBody.dataUrl &&

}
+ {responseBody && responseBody.text &&
}
+ ;
};
-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';
diff --git a/packages/trace-viewer/src/ui/networkTab.css b/packages/trace-viewer/src/ui/networkTab.css
index c0b3a05bf8..9bd7eaa4ac 100644
--- a/packages/trace-viewer/src/ui/networkTab.css
+++ b/packages/trace-viewer/src/ui/networkTab.css
@@ -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%;
-}
\ No newline at end of file
+.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;
+}
diff --git a/packages/trace-viewer/src/ui/networkTab.tsx b/packages/trace-viewer/src/ui/networkTab.tsx
index e5e4e56a00..83063f19b3 100644
--- a/packages/trace-viewer/src/ui/networkTab.tsx
+++ b/packages/trace-viewer/src/ui/networkTab.tsx
@@ -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;
+
+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();
+ const [filter, setFilter] = React.useState(undefined);
+ const [negateFilter, setNegateFilter] = React.useState(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 {
- resources.map((resource, index) => )
- }
;
+ 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 &&
+
+ }
+ onSelected={setResource}
+ />
+
}
+ {resource && setResource(undefined)} />}
+ >;
};
+
+const NetworkHeader: React.FunctionComponent<{
+ filter: Filter | undefined,
+ negateFilter: boolean,
+ toggleFilter: (filter: Filter) => void,
+}> = ({ toggleFilter, filter, negateFilter }) => {
+ return
+
toggleFilter('status') }>
+ Status
+
+
+
+
toggleFilter('method') }>
+ Method
+
+
+
+
toggleFilter('file') }>
+ Request
+
+
+
+
toggleFilter('content-type') }>
+ Content Type
+
+
+
+
toggleFilter('time') }>
+ Time
+
+
+
+
toggleFilter('size') }>
+ Size
+
+
+
+
Route
+
;
+};
+
+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
+
+
{resource.response.status}
+
+
+
{resource.request.method}
+
+
+
{contentType}
+
{msToString(resource.time)}
+
{bytesToString(resource.response._transferSize! > 0 ? resource.response._transferSize! : resource.response.bodySize)}
+
+ {routeStatus &&
{routeStatus}
}
+
+
;
+};
+
+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);
+ };
+ }
+}
diff --git a/packages/web/src/components/tabbedPane.tsx b/packages/web/src/components/tabbedPane.tsx
index 0483f80c73..4d4386844a 100644
--- a/packages/web/src/components/tabbedPane.tsx
+++ b/packages/web/src/components/tabbedPane.tsx
@@ -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
+ setSelectedTab: (tab: string) => void,
+ dataTestId?: string,
+}> = ({ tabs, selectedTab, setSelectedTab, leftToolbar, rightToolbar, dataTestId }) => {
+ return
- { leftToolbar &&
+ { leftToolbar &&
{...leftToolbar}
}
@@ -48,7 +49,7 @@ export const TabbedPane: React.FunctionComponent<{
>)),
]}
- {rightToolbar &&
+ {rightToolbar &&
{...rightToolbar}
}
diff --git a/packages/web/src/uiUtils.ts b/packages/web/src/uiUtils.ts
index 1d037a6323..7e00693234 100644
--- a/packages/web/src/uiUtils.ts
+++ b/packages/web/src/uiUtils.ts
@@ -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
(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;
diff --git a/tests/config/traceViewerFixtures.ts b/tests/config/traceViewerFixtures.ts
index 7c3d029687..60797ab941 100644
--- a/tests/config/traceViewerFixtures.ts
+++ b/tests/config/traceViewerFixtures.ts
@@ -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]');
}
diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts
index 98daeb1b0b..4f37c2d930 100644
--- a/tests/library/trace-viewer.spec.ts
+++ b/tests/library/trace-viewer.spec.ts
@@ -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 }) => {
diff --git a/tests/playwright-test/reporter-blob.spec.ts b/tests/playwright-test/reporter-blob.spec.ts
index 2924625e54..3f03fa0db2 100644
--- a/tests/playwright-test/reporter-blob.spec.ts
+++ b/tests/playwright-test/reporter-blob.spec.ts
@@ -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)`,
diff --git a/tests/playwright-test/reporter-list.spec.ts b/tests/playwright-test/reporter-list.spec.ts
index b16b078065..245b0e26d3 100644
--- a/tests/playwright-test/reporter-list.spec.ts
+++ b/tests/playwright-test/reporter-list.spec.ts
@@ -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',