Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions packages/trace-viewer/src/ui/networkResourceDetails.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -70,6 +75,10 @@
overflow: hidden;
}

.network-response-body {
height: calc(100% - 31px /* Height of bottom toolbar */);
}
Comment on lines +78 to +80

.network-request-request-body {
max-height: 100%;
}
Expand Down
105 changes: 70 additions & 35 deletions packages/trace-viewer/src/ui/networkResourceDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand All @@ -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;
Expand Down Expand Up @@ -107,22 +109,37 @@ const CopyDropdown: React.FC<{
);
};

const FormatToggleButton: React.FC<{
toggled: boolean;
error?: boolean;
onToggle: () => void;
}> = ({ toggled, error, onToggle }) => {
return <ToolbarButton icon='json' title='Pretty print' toggled={toggled} errorBadge={error ? 'Formatting failed' : undefined} onClick={e => {
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 <Expandable
expanded={expanded}
setExpanded={setExpanded}
expandOnTitleClick
title={
<span className='network-request-details-header'>{title}
{showCount && <span className='network-request-details-header-count'> × {data?.length ?? 0}</span>}
</span>
<>
<span className='network-request-details-header'>{title}
{showCount && <span className='network-request-details-header-count'> × {data?.length ?? 0}</span>}
</span>
{ titleChildren }
</>
}
className={className}
>
Expand Down Expand Up @@ -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 <div className='vbox network-request-details-tab'>
{resource.request.queryString.length === 0 && !requestBody && <em className='network-request-no-payload'>No payload for this request.</em>}
{resource.request.queryString.length > 0 && <ExpandableSection title='Query String Parameters' showCount data={resource.request.queryString}/>}
{requestBody && <ExpandableSection title='Request Body' className='network-request-request-body'>
<CodeMirrorWrapper text={requestBody.text} mimeType={requestBody.mimeType} readOnly lineNumbers={true}/>
{requestBody && <ExpandableSection title='Request Body' className='network-request-request-body' titleChildren={
<>
<div style={{ margin: 'auto' }}></div>
<FormatToggleButton toggled={showFormatted} error={formatResult.error} onToggle={() => setShowFormatted(!showFormatted)} />
</>
}>
<CodeMirrorWrapper text={formatResult.text} mimeType={requestBody.mimeType} readOnly lineNumbers={true}/>
</ExpandableSection>}
</div>;
};
Expand All @@ -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<ResponseBody>(null);

React.useEffect(() => {
const readResources = async () => {
Expand All @@ -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);
Expand All @@ -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 <div className='vbox network-request-details-tab'>
{!resource.response.content._sha1 && <div>Response body is not available for this request.</div>}
{responseBody && responseBody.font && <FontPreview font={responseBody.font} />}
{responseBody && responseBody.dataUrl && <div><img draggable='false' src={responseBody.dataUrl} /></div>}
{responseBody && responseBody.text && <CodeMirrorWrapper text={responseBody.text} mimeType={responseBody.mimeType} readOnly lineNumbers={true}/>}
{responseBody && responseBody.text !== undefined && <div className='network-response-body'>
<CodeMirrorWrapper text={formatResult.text} mimeType={responseBody.mimeType} readOnly lineNumbers={true}/>
<Toolbar noShadow={true} noMinHeight={true} className='network-response-toolbar'>
<div style={{ margin: 'auto' }}></div>
<FormatToggleButton toggled={showFormattedResponse} error={formatResult.error} onToggle={() => setShowFormattedResponse(!showFormattedResponse)} />
</Toolbar>
</div>}
Comment on lines +235 to +248
</div>;
};

Expand Down Expand Up @@ -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 '<Empty>';
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);
Comment on lines +324 to +332

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]);
};
3 changes: 1 addition & 2 deletions packages/trace-viewer/src/ui/uiModeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -469,8 +469,7 @@ export const UIModeView: React.FC<{}> = ({
<div className='section-title'>Playwright</div>
<ToolbarButton icon='refresh' title='Reload' onClick={() => reloadTests()} disabled={isRunningTest || isLoading}></ToolbarButton>
<div style={{ position: 'relative' }}>
<ToolbarButton icon={'terminal'} title={'Toggle output — ' + (isMac ? '⌃`' : 'Ctrl + `')} toggled={isShowingOutput} onClick={() => { setIsShowingOutput(!isShowingOutput); }} />
{outputContainsError && <div title='Output contains error' style={{ position: 'absolute', top: 2, right: 2, width: 7, height: 7, borderRadius: '50%', backgroundColor: 'var(--vscode-notificationsErrorIcon-foreground)' }} />}
<ToolbarButton icon={'terminal'} title={'Toggle output — ' + (isMac ? '⌃`' : 'Ctrl + `')} toggled={isShowingOutput} errorBadge={outputContainsError ? 'Output contains error' : undefined} onClick={() => { setIsShowingOutput(!isShowingOutput); }} />
</div>
{!hasBrowsers && <ToolbarButton icon='lightbulb-autofix' style={{ color: 'var(--vscode-list-warningForeground)' }} title='Playwright browsers are missing' onClick={openInstallDialog} />}
</Toolbar>
Expand Down
6 changes: 1 addition & 5 deletions packages/web/src/components/toolbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
background-color: var(--vscode-sideBar-background);
}

.toolbar:after {
.toolbar:not(.no-shadow):after {
content: '';
display: block;
position: absolute;
Expand All @@ -41,10 +41,6 @@
z-index: 100;
}

.toolbar.no-shadow:after {
box-shadow: none;
}

.toolbar.no-min-height {
min-height: 0;
}
Expand Down
11 changes: 11 additions & 0 deletions packages/web/src/components/toolbarButton.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

.toolbar-button {
position: relative;
flex: none;
border: none;
outline: none;
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions packages/web/src/components/toolbarButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface ToolbarButtonProps {
testId?: string,
className?: string,
ariaLabel?: string,
errorBadge?: string,
}

export const ToolbarButton = React.forwardRef<HTMLButtonElement, React.PropsWithChildren<ToolbarButtonProps>>(function ToolbarButton({
Expand All @@ -42,6 +43,7 @@ export const ToolbarButton = React.forwardRef<HTMLButtonElement, React.PropsWith
testId,
className,
ariaLabel,
errorBadge,
}, ref) {
return <button
ref={ref}
Expand All @@ -57,6 +59,7 @@ export const ToolbarButton = React.forwardRef<HTMLButtonElement, React.PropsWith
>
{icon && <span className={`codicon codicon-${icon}`} style={children ? { marginRight: 5 } : {}}></span>}
{children}
{errorBadge && <span className='toolbar-button-error-badge' title={errorBadge}></span>}
</button>;
});

Expand Down
24 changes: 15 additions & 9 deletions tests/playwright-test/ui-mode-test-network-tab.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Comment on lines +191 to +193
'{"data":{"key":"value","array":["value-1","value-2"]}}'
], { useInnerText: true });
});

test('should format XML request body', async ({ runUITest, server }) => {
Expand All @@ -213,6 +219,12 @@ test('should format XML request body', async ({ runUITest, server }) => {
' <body>Hello &amp; welcome!</body>',
'</note>'
], { 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([
'<?xml version="1.0"?><note to="Alice" from="Bob"><body>Hello &amp; welcome!</body></note>'
], { useInnerText: true });
});

test('should display list of query parameters (only if present)', async ({ runUITest, server }) => {
Expand Down Expand Up @@ -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();
});
Expand Down
Loading