From b0614dd444428f820a31be9615473e5ffba2825b Mon Sep 17 00:00:00 2001 From: cpadm <57954026+cpAdm@users.noreply.github.com> Date: Sat, 14 Mar 2026 22:58:49 +0100 Subject: [PATCH 1/3] feat: Add pretty print toggle for request and response bodies in network details --- .../src/ui/networkResourceDetails.css | 9 ++ .../src/ui/networkResourceDetails.tsx | 105 ++++++++++++------ packages/trace-viewer/src/ui/uiModeView.tsx | 3 +- packages/web/src/components/toolbar.css | 6 +- packages/web/src/components/toolbarButton.css | 11 ++ packages/web/src/components/toolbarButton.tsx | 3 + .../ui-mode-test-network-tab.spec.ts | 24 ++-- 7 files changed, 110 insertions(+), 51 deletions(-) diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.css b/packages/trace-viewer/src/ui/networkResourceDetails.css index 4caa673b04e9a..333b3dc936b99 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.css +++ b/packages/trace-viewer/src/ui/networkResourceDetails.css @@ -19,6 +19,11 @@ overflow: auto; } +.network-response-toolbar { + border-top: 1px solid var(--vscode-panel-border); + border-bottom: none !important; +} + .network-request-details-tab > * { flex: none; } @@ -70,6 +75,10 @@ overflow: hidden; } +.network-response-body { + height: calc(100% - 31px /* Height of bottom toolbar */); +} + .network-request-request-body { max-height: 100%; } diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index 4ca5efc71038f..79292ffd89da1 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -29,8 +29,10 @@ import { msToString, useAsyncMemo, useSetting } from '@web/uiUtils'; import type { Entry } from '@trace/har'; import { useTraceModel } from './traceModelContext'; import { Expandable } from '@web/components/expandable'; +import { Toolbar } from '@web/components/toolbar'; type RequestBody = { text: string, mimeType?: string } | null; +type ResponseBody = { dataUrl?: string, text?: string, mimeType?: string, font?: BufferSource } | null; export const NetworkResourceDetails: React.FunctionComponent<{ @@ -48,9 +50,9 @@ export const NetworkResourceDetails: React.FunctionComponent<{ const requestContentType = requestContentTypeHeader ? requestContentTypeHeader.value : ''; if (resource.request.postData._sha1) { const response = await fetch(model.createRelativeUrl(`sha1/${resource.request.postData._sha1}`)); - return { text: formatBody(await response.text(), requestContentType), mimeType: requestContentType }; + return { text: await response.text(), mimeType: requestContentType }; } else { - return { text: formatBody(resource.request.postData.text, requestContentType), mimeType: requestContentType }; + return { text: resource.request.postData.text, mimeType: requestContentType }; } } else { return null; @@ -107,22 +109,37 @@ const CopyDropdown: React.FC<{ ); }; +const FormatToggleButton: React.FC<{ + toggled: boolean; + error?: boolean; + onToggle: () => void; +}> = ({ toggled, error, onToggle }) => { + return { + e.stopPropagation(); + onToggle(); + }}/>; +}; + const ExpandableSection: React.FC<{ title: string; showCount?: boolean, data?: { name: string, value: React.ReactNode }[], children?: React.ReactNode + titleChildren?: React.ReactNode; className?: string; -}> = ({ title, data, showCount, children, className }) => { +}> = ({ title, data, showCount, children, titleChildren, className }) => { const [expanded, setExpanded] = useSetting(`trace-viewer-network-details-${title.replaceAll(' ', '-')}`, true); return {title} - {showCount && × {data?.length ?? 0}} - + <> + {title} + {showCount && × {data?.length ?? 0}} + + { titleChildren } + } className={className} > @@ -166,11 +183,19 @@ const PayloadTab: React.FunctionComponent<{ resource: ResourceSnapshot; requestBody: RequestBody, }> = ({ resource, requestBody }) => { + const [showFormatted, setShowFormatted] = useSetting('trace-viewer-network-details-show-formatted-payload', true); + const formatResult = useFormattedBody(requestBody, showFormatted); + return
{resource.request.queryString.length === 0 && !requestBody && No payload for this request.} {resource.request.queryString.length > 0 && } - {requestBody && - + {requestBody && +
+ setShowFormatted(!showFormatted)} /> + + }> +
}
; }; @@ -179,7 +204,7 @@ const ResponseTab: React.FunctionComponent<{ resource: ResourceSnapshot; }> = ({ resource }) => { const model = useTraceModel(); - const [responseBody, setResponseBody] = React.useState<{ dataUrl?: string, text?: string, mimeType?: string, font?: BufferSource } | null>(null); + const [responseBody, setResponseBody] = React.useState(null); React.useEffect(() => { const readResources = async () => { @@ -197,8 +222,7 @@ const ResponseTab: React.FunctionComponent<{ const font = await response.arrayBuffer(); setResponseBody({ font }); } else { - const formattedBody = formatBody(await response.text(), resource.response.content.mimeType); - setResponseBody({ text: formattedBody, mimeType: resource.response.content.mimeType }); + setResponseBody({ text: await response.text(), mimeType: resource.response.content.mimeType }); } } else { setResponseBody(null); @@ -208,11 +232,20 @@ const ResponseTab: React.FunctionComponent<{ readResources(); }, [resource, model]); + const [showFormattedResponse, setShowFormattedResponse] = useSetting('trace-viewer-network-details-show-formatted-response', true); + const formatResult = useFormattedBody(responseBody, showFormattedResponse); + return
{!resource.response.content._sha1 &&
Response body is not available for this request.
} {responseBody && responseBody.font && } {responseBody && responseBody.dataUrl &&
} - {responseBody && responseBody.text && } + {responseBody && responseBody.text !== undefined &&
+ + +
+ setShowFormattedResponse(!showFormattedResponse)} /> +
+
}
; }; @@ -288,32 +321,34 @@ function formatXml(xml: string, indent = ' ') { return lines.join('\n'); } -function formatBody(body: string | null, contentType: string): string { - if (body === null) - return 'Loading...'; +function formatBody(body: string, contentType?: string): string { + if (!contentType) + return body; - const bodyStr = body; - if (bodyStr === '') - return ''; + if (isJsonMimeType(contentType)) + return JSON.stringify(JSON.parse(body), null, 2); - if (isJsonMimeType(contentType)) { - try { - return JSON.stringify(JSON.parse(bodyStr), null, 2); - } catch (err) { - return bodyStr; - } - } - - if (isXmlMimeType(contentType)) { - try { - return formatXml(bodyStr); - } catch { - return bodyStr; - } - } + if (isXmlMimeType(contentType)) + return formatXml(body); if (contentType.includes('application/x-www-form-urlencoded')) - return decodeURIComponent(bodyStr); + return decodeURIComponent(body); - return bodyStr; + return body; } + +const useFormattedBody = (body: RequestBody | ResponseBody, showFormatted: boolean) => { + return React.useMemo(() => { + if (body?.text === undefined) + return { text: '' }; + + if (!showFormatted) + return { text: body.text }; + + try { + return { text: formatBody(body.text, body.mimeType) }; + } catch { + return { text: body.text, error: true }; + } + }, [body, showFormatted]); +}; diff --git a/packages/trace-viewer/src/ui/uiModeView.tsx b/packages/trace-viewer/src/ui/uiModeView.tsx index 78caf2f9d778c..3f8f9a6391565 100644 --- a/packages/trace-viewer/src/ui/uiModeView.tsx +++ b/packages/trace-viewer/src/ui/uiModeView.tsx @@ -469,8 +469,7 @@ export const UIModeView: React.FC<{}> = ({
Playwright
reloadTests()} disabled={isRunningTest || isLoading}>
- { setIsShowingOutput(!isShowingOutput); }} /> - {outputContainsError &&
} + { setIsShowingOutput(!isShowingOutput); }} />
{!hasBrowsers && } diff --git a/packages/web/src/components/toolbar.css b/packages/web/src/components/toolbar.css index 5a532a77d5323..74b1f4664ae5d 100644 --- a/packages/web/src/components/toolbar.css +++ b/packages/web/src/components/toolbar.css @@ -28,7 +28,7 @@ background-color: var(--vscode-sideBar-background); } -.toolbar:after { +.toolbar:not(.no-shadow):after { content: ''; display: block; position: absolute; @@ -41,10 +41,6 @@ z-index: 100; } -.toolbar.no-shadow:after { - box-shadow: none; -} - .toolbar.no-min-height { min-height: 0; } diff --git a/packages/web/src/components/toolbarButton.css b/packages/web/src/components/toolbarButton.css index 813d9e4b913f7..4ddce82c3d0d5 100644 --- a/packages/web/src/components/toolbarButton.css +++ b/packages/web/src/components/toolbarButton.css @@ -15,6 +15,7 @@ */ .toolbar-button { + position: relative; flex: none; border: none; outline: none; @@ -43,6 +44,16 @@ color: var(--vscode-notificationLink-foreground); } +.toolbar-button-error-badge { + position: absolute; + top: 2px; + right: 2px; + width: 7px; + height: 7px; + border-radius: 50%; + background-color: var(--vscode-notificationsErrorIcon-foreground); +} + .toolbar-separator { flex: none; background-color: var(--vscode-menu-separatorBackground); diff --git a/packages/web/src/components/toolbarButton.tsx b/packages/web/src/components/toolbarButton.tsx index c7d0230b98f20..06fd773f5b577 100644 --- a/packages/web/src/components/toolbarButton.tsx +++ b/packages/web/src/components/toolbarButton.tsx @@ -29,6 +29,7 @@ export interface ToolbarButtonProps { testId?: string, className?: string, ariaLabel?: string, + errorBadge?: string, } export const ToolbarButton = React.forwardRef>(function ToolbarButton({ @@ -42,6 +43,7 @@ export const ToolbarButton = React.forwardRef {icon && } {children} + {errorBadge && } ; }); diff --git a/tests/playwright-test/ui-mode-test-network-tab.spec.ts b/tests/playwright-test/ui-mode-test-network-tab.spec.ts index 49220c5b1672b..35bb76cc44feb 100644 --- a/tests/playwright-test/ui-mode-test-network-tab.spec.ts +++ b/tests/playwright-test/ui-mode-test-network-tab.spec.ts @@ -187,6 +187,12 @@ test('should format JSON request body', async ({ runUITest, server }) => { ' }', '}', ], { useInnerText: true }); + + // Untoggle pretty print to see original request body + await payloadPanel.getByRole('button', { name: 'Pretty print', exact: true }).click(); + await expect(payloadPanel.locator('.CodeMirror-code .CodeMirror-line')).toHaveText([ + '{"data":{"key":"value","array":["value-1","value-2"]}}' + ], { useInnerText: true }); }); test('should format XML request body', async ({ runUITest, server }) => { @@ -213,6 +219,12 @@ test('should format XML request body', async ({ runUITest, server }) => { ' Hello & welcome!', '' ], { useInnerText: true }); + + // Untoggle pretty print to see original request body + await payloadPanel.getByRole('button', { name: 'Pretty print', exact: true }).click(); + await expect(payloadPanel.locator('.CodeMirror-code .CodeMirror-line')).toHaveText([ + 'Hello & welcome!' + ], { useInnerText: true }); }); test('should display list of query parameters (only if present)', async ({ runUITest, server }) => { @@ -380,15 +392,9 @@ test('should copy network request', async ({ runUITest, server }) => { await expect(async () => { const playwrightRequest = await page.evaluate(() => (window as any).__clipboardCall); expect(playwrightRequest).toContain(`await page.request.post('${server.PREFIX}/post-data-1', {`); - expect(playwrightRequest.replaceAll('\r\n', '\n')).toContain(` data: \`{ - "data": { - "key": "value", - "array": [ - "value-1", - "value-2" - ] - } -}\``); + expect(playwrightRequest.replaceAll('\r\n', '\n')).toContain( + ` data: '{"data":{"key":"value","array":["value-1","value-2"]}}'` + ); expect(playwrightRequest).toContain(`'content-type': 'application/json'`); }).toPass(); }); From 0d34f4adcd3cee66305e5bc20407eb0bedf13c9f Mon Sep 17 00:00:00 2001 From: cpadm <57954026+cpAdm@users.noreply.github.com> Date: Thu, 19 Mar 2026 14:58:12 +0100 Subject: [PATCH 2/3] chore: Fix Copilot's PR feedback --- .../src/ui/networkResourceDetails.css | 2 +- .../src/ui/networkResourceDetails.tsx | 4 +- packages/web/src/components/toolbarButton.tsx | 4 +- .../ui-mode-test-network-tab.spec.ts | 64 +++++++++++++++++-- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.css b/packages/trace-viewer/src/ui/networkResourceDetails.css index 333b3dc936b99..15e32c370166c 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.css +++ b/packages/trace-viewer/src/ui/networkResourceDetails.css @@ -76,7 +76,7 @@ } .network-response-body { - height: calc(100% - 31px /* Height of bottom toolbar */); + flex: 1; } .network-request-request-body { diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index 79292ffd89da1..243bc7696ddc4 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -239,7 +239,7 @@ const ResponseTab: React.FunctionComponent<{ {!resource.response.content._sha1 &&
Response body is not available for this request.
} {responseBody && responseBody.font && } {responseBody && responseBody.dataUrl &&
} - {responseBody && responseBody.text !== undefined &&
+ {responseBody && responseBody.text !== undefined &&
@@ -322,7 +322,7 @@ function formatXml(xml: string, indent = ' ') { } function formatBody(body: string, contentType?: string): string { - if (!contentType) + if (!body.trim() || !contentType) return body; if (isJsonMimeType(contentType)) diff --git a/packages/web/src/components/toolbarButton.tsx b/packages/web/src/components/toolbarButton.tsx index 06fd773f5b577..3b3728c0c3cec 100644 --- a/packages/web/src/components/toolbarButton.tsx +++ b/packages/web/src/components/toolbarButton.tsx @@ -45,6 +45,7 @@ export const ToolbarButton = React.forwardRef {icon && } {children} - {errorBadge && } + {errorBadge && } ; }); diff --git a/tests/playwright-test/ui-mode-test-network-tab.spec.ts b/tests/playwright-test/ui-mode-test-network-tab.spec.ts index 35bb76cc44feb..3a3cd2fad62a7 100644 --- a/tests/playwright-test/ui-mode-test-network-tab.spec.ts +++ b/tests/playwright-test/ui-mode-test-network-tab.spec.ts @@ -143,7 +143,7 @@ test('should filter network requests by url', async ({ runUITest, server }) => { await expect(networkItems.getByText('font.woff2')).toBeVisible(); }); -test('should format JSON request body', async ({ runUITest, server }) => { +test('should pretty-print JSON request body', async ({ runUITest, server }) => { const { page } = await runUITest({ 'network-tab.test.ts': ` import { test, expect } from '@playwright/test'; @@ -188,14 +188,14 @@ test('should format JSON request body', async ({ runUITest, server }) => { '}', ], { useInnerText: true }); - // Untoggle pretty print to see original request body + // Toggle off pretty print to see original request body await payloadPanel.getByRole('button', { name: 'Pretty print', exact: true }).click(); await expect(payloadPanel.locator('.CodeMirror-code .CodeMirror-line')).toHaveText([ '{"data":{"key":"value","array":["value-1","value-2"]}}' ], { useInnerText: true }); }); -test('should format XML request body', async ({ runUITest, server }) => { +test('should pretty-print XML request body', async ({ runUITest, server }) => { const { page } = await runUITest({ 'network-tab.test.ts': ` import { test, expect } from '@playwright/test'; @@ -220,13 +220,69 @@ test('should format XML request body', async ({ runUITest, server }) => { '' ], { useInnerText: true }); - // Untoggle pretty print to see original request body + // Toggle off pretty print to see original request body await payloadPanel.getByRole('button', { name: 'Pretty print', exact: true }).click(); await expect(payloadPanel.locator('.CodeMirror-code .CodeMirror-line')).toHaveText([ 'Hello & welcome!' ], { useInnerText: true }); }); +test('should pretty-print response bodies and show formatting errors', async ({ runUITest, server }) => { + server.setRoute('/response-json-good', (_, res) => res.setHeader('Content-Type', 'application/json').end('{"ok":true,"items":[1,2]}')); + server.setRoute('/response-json-bad', (_, res) => res.setHeader('Content-Type', 'application/json').end('{"ok":true,,}')); + + const { page } = await runUITest({ + 'network-tab.test.ts': ` + import { test } from '@playwright/test'; + test('network response tab', async ({ request }) => { + await Promise.all([ + request.get('${server.PREFIX}/response-json-good'), + request.get('${server.PREFIX}/response-json-bad'), + ].map(r => r.then(res => res.text()))); + }); + `, + }); + + await page.getByText('network response tab').dblclick(); + await expect(page.getByTestId('workbench-run-status')).toContainText('Passed'); + await page.getByRole('tab', { name: 'Network' }).click(); + + const networkList = page.getByRole('list', { name: 'Network requests' }).getByRole('listitem'); + const responsePanel = page.getByRole('tabpanel', { name: 'Response' }); + + // Pretty printed by default + await networkList.filter({ hasText: 'response-json-good' }).click(); + await page.getByRole('tabpanel', { name: 'Network' }).getByRole('tab', { name: 'Response' }).click(); + await expect(responsePanel.locator('.CodeMirror-code .CodeMirror-line')).toHaveText([ + '{', + ' "ok": true,', + ' "items": [', + ' 1,', + ' 2', + ' ]', + '}', + ], { useInnerText: true }); + + // Toggle off to see original body + const prettyPrint = responsePanel.getByRole('button', { name: 'Pretty print', exact: true }); + const prettyPrintError = responsePanel.getByTitle('Formatting failed'); + await prettyPrint.click(); + await expect(responsePanel.locator('.CodeMirror-code .CodeMirror-line')).toHaveText([ + '{"ok":true,"items":[1,2]}', + ], { useInnerText: true }); + await expect(prettyPrintError).toBeHidden(); + + // Re-enable pretty print so errors are surfaced + await prettyPrint.click(); + + // Malformed JSON shows badge and preserves original text + await networkList.filter({ hasText: 'response-json-bad' }).click(); + await expect(responsePanel.locator('.CodeMirror-code .CodeMirror-line')).toHaveText([ + '{"ok":true,,}', + ], { useInnerText: true }); + await expect(prettyPrintError).toBeVisible(); +}); + test('should display list of query parameters (only if present)', async ({ runUITest, server }) => { const { page } = await runUITest({ 'network-tab.test.ts': ` From fdedf7c259de3d5091a6045d90c7465f76df358f Mon Sep 17 00:00:00 2001 From: Chris <57954026+cpAdm@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:59:10 +0100 Subject: [PATCH 3/3] fix: 'No payload for this request' message Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Chris <57954026+cpAdm@users.noreply.github.com> --- packages/trace-viewer/src/ui/networkResourceDetails.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index 243bc7696ddc4..c7a6a93991140 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -184,11 +184,13 @@ const PayloadTab: React.FunctionComponent<{ requestBody: RequestBody, }> = ({ resource, requestBody }) => { const [showFormatted, setShowFormatted] = useSetting('trace-viewer-network-details-show-formatted-payload', true); + const hasQueryString = resource.request.queryString.length > 0; + const hasRequestBody = !!(requestBody || resource.request.postData); const formatResult = useFormattedBody(requestBody, showFormatted); return
- {resource.request.queryString.length === 0 && !requestBody && No payload for this request.} - {resource.request.queryString.length > 0 && } + {!hasQueryString && !hasRequestBody && No payload for this request.} + {hasQueryString && } {requestBody &&