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}
+
+
+
{resourceName}
+
+
{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',