Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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 {
flex: 1;
}

.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)} />
</>
Comment on lines +195 to +198
}>
<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='vbox 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>
Comment on lines +246 to +247
<FormatToggleButton toggled={showFormattedResponse} error={formatResult.error} onToggle={() => setShowFormattedResponse(!showFormattedResponse)} />
</Toolbar>
</div>}
</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 (!body.trim() || !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);

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
5 changes: 5 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,7 +43,9 @@ export const ToolbarButton = React.forwardRef<HTMLButtonElement, React.PropsWith
testId,
className,
ariaLabel,
errorBadge,
}, ref) {
const errorId = React.useId();
return <button
ref={ref}
className={clsx(className, 'toolbar-button', icon, toggled && 'toggled')}
Expand All @@ -54,9 +57,11 @@ export const ToolbarButton = React.forwardRef<HTMLButtonElement, React.PropsWith
style={style}
data-testid={testId}
aria-label={ariaLabel || title}
aria-describedby={errorBadge ? errorId : undefined}
>
{icon && <span className={`codicon codicon-${icon}`} style={children ? { marginRight: 5 } : {}}></span>}
{children}
{errorBadge && <span id={errorId} className='toolbar-button-error-badge' title={errorBadge} aria-label={errorBadge}></span>}
</button>;
});

Expand Down
Loading
Loading