feat(trace viewer): link from attach action to attachment tab

This commit is contained in:
Simon Knott 2024-10-23 14:56:59 +02:00
parent 69f56b9f63
commit d4adcfed17
No known key found for this signature in database
GPG key ID: 8CEDC00028084AEC
5 changed files with 63 additions and 17 deletions

View file

@ -25,6 +25,7 @@ import type { TreeState } from '@web/components/treeView';
import { TreeView } from '@web/components/treeView'; import { TreeView } from '@web/components/treeView';
import type { ActionTraceEventInContext, ActionTreeItem } from './modelUtil'; import type { ActionTraceEventInContext, ActionTreeItem } from './modelUtil';
import type { Boundaries } from './geometry'; import type { Boundaries } from './geometry';
import { ToolbarButton } from '@web/components/toolbarButton';
export interface ActionListProps { export interface ActionListProps {
actions: ActionTraceEventInContext[], actions: ActionTraceEventInContext[],
@ -35,6 +36,7 @@ export interface ActionListProps {
onSelected?: (action: ActionTraceEventInContext) => void, onSelected?: (action: ActionTraceEventInContext) => void,
onHighlighted?: (action: ActionTraceEventInContext | undefined) => void, onHighlighted?: (action: ActionTraceEventInContext | undefined) => void,
revealConsole?: () => void, revealConsole?: () => void,
revealAttachments(): void,
isLive?: boolean, isLive?: boolean,
} }
@ -49,6 +51,7 @@ export const ActionList: React.FC<ActionListProps> = ({
onSelected, onSelected,
onHighlighted, onHighlighted,
revealConsole, revealConsole,
revealAttachments,
isLive, isLive,
}) => { }) => {
const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() }); const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
@ -68,8 +71,8 @@ export const ActionList: React.FC<ActionListProps> = ({
}, [setSelectedTime]); }, [setSelectedTime]);
const render = React.useCallback((item: ActionTreeItem) => { const render = React.useCallback((item: ActionTreeItem) => {
return renderAction(item.action!, { sdkLanguage, revealConsole, isLive, showDuration: true, showBadges: true }); return renderAction(item.action!, { sdkLanguage, revealConsole, revealAttachments, isLive, showDuration: true, showBadges: true });
}, [isLive, revealConsole, sdkLanguage]); }, [isLive, revealConsole, revealAttachments, sdkLanguage]);
const isVisible = React.useCallback((item: ActionTreeItem) => { const isVisible = React.useCallback((item: ActionTreeItem) => {
return !selectedTime || !item.action || (item.action!.startTime <= selectedTime.maximum && item.action!.endTime >= selectedTime.minimum); return !selectedTime || !item.action || (item.action!.startTime <= selectedTime.maximum && item.action!.endTime >= selectedTime.minimum);
@ -106,13 +109,15 @@ export const renderAction = (
options: { options: {
sdkLanguage?: Language, sdkLanguage?: Language,
revealConsole?: () => void, revealConsole?: () => void,
revealAttachments?(): void,
isLive?: boolean, isLive?: boolean,
showDuration?: boolean, showDuration?: boolean,
showBadges?: boolean, showBadges?: boolean,
}) => { }) => {
const { sdkLanguage, revealConsole, isLive, showDuration, showBadges } = options; const { sdkLanguage, revealConsole, revealAttachments, isLive, showDuration, showBadges } = options;
const { errors, warnings } = modelUtil.stats(action); const { errors, warnings } = modelUtil.stats(action);
const locator = action.params.selector ? asLocator(sdkLanguage || 'javascript', action.params.selector) : undefined; const locator = action.params.selector ? asLocator(sdkLanguage || 'javascript', action.params.selector) : undefined;
const showAttachments = !!action.attachments?.length && !!revealAttachments;
let time: string = ''; let time: string = '';
if (action.endTime) if (action.endTime)
@ -128,8 +133,9 @@ export const renderAction = (
{action.method === 'goto' && action.params.url && <div className='action-url' title={action.params.url}>{action.params.url}</div>} {action.method === 'goto' && action.params.url && <div className='action-url' title={action.params.url}>{action.params.url}</div>}
{action.class === 'APIRequestContext' && action.params.url && <div className='action-url' title={action.params.url}>{excludeOrigin(action.params.url)}</div>} {action.class === 'APIRequestContext' && action.params.url && <div className='action-url' title={action.params.url}>{excludeOrigin(action.params.url)}</div>}
</div> </div>
{(showDuration || showBadges) && <div className='spacer'></div>} {(showDuration || showBadges || showAttachments) && <div className='spacer'></div>}
{showDuration && <div className='action-duration'>{time || <span className='codicon codicon-loading'></span>}</div>} {showDuration && <div className='action-duration'>{time || <span className='codicon codicon-loading'></span>}</div>}
{showAttachments && <ToolbarButton icon='attach' title='Open Attachment' onClick={revealAttachments} />}
{showBadges && <div className='action-icons' onClick={() => revealConsole?.()}> {showBadges && <div className='action-icons' onClick={() => revealConsole?.()}>
{!!errors && <div className='action-icon'><span className='codicon codicon-error'></span><span className='action-icon-value'>{errors}</span></div>} {!!errors && <div className='action-icon'><span className='codicon codicon-error'></span><span className='action-icon-value'>{errors}</span></div>}
{!!warnings && <div className='action-icon'><span className='codicon codicon-warning'></span><span className='action-icon-value'>{warnings}</span></div>} {!!warnings && <div className='action-icon'><span className='codicon codicon-warning'></span><span className='action-icon-value'>{warnings}</span></div>}

View file

@ -17,7 +17,7 @@
import * as React from 'react'; import * as React from 'react';
import './attachmentsTab.css'; import './attachmentsTab.css';
import { ImageDiffView } from '@web/shared/imageDiffView'; import { ImageDiffView } from '@web/shared/imageDiffView';
import type { MultiTraceModel } from './modelUtil'; import type { ActionTraceEventInContext, MultiTraceModel } from './modelUtil';
import { PlaceholderPanel } from './placeholderPanel'; import { PlaceholderPanel } from './placeholderPanel';
import type { AfterActionTraceEventAttachment } from '@trace/trace'; import type { AfterActionTraceEventAttachment } from '@trace/trace';
import { CodeMirrorWrapper, lineHeight } from '@web/components/codeMirrorWrapper'; import { CodeMirrorWrapper, lineHeight } from '@web/components/codeMirrorWrapper';
@ -29,16 +29,23 @@ type Attachment = AfterActionTraceEventAttachment & { traceUrl: string };
type ExpandableAttachmentProps = { type ExpandableAttachmentProps = {
attachment: Attachment; attachment: Attachment;
highlight?: boolean;
}; };
const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> = ({ attachment }) => { const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> = ({ attachment, highlight }) => {
const [expanded, setExpanded] = React.useState(false); const [expanded, setExpanded] = React.useState(false);
const [attachmentText, setAttachmentText] = React.useState<string | null>(null); const [attachmentText, setAttachmentText] = React.useState<string | null>(null);
const [placeholder, setPlaceholder] = React.useState<string | null>(null); const [placeholder, setPlaceholder] = React.useState<string | null>(null);
const ref = React.useRef<HTMLSpanElement>(null);
const isTextAttachment = isTextualMimeType(attachment.contentType); const isTextAttachment = isTextualMimeType(attachment.contentType);
const hasContent = !!attachment.sha1 || !!attachment.path; const hasContent = !!attachment.sha1 || !!attachment.path;
React.useEffect(() => {
if (highlight)
ref.current?.scrollIntoView({ behavior: 'smooth' });
}, [highlight]);
React.useEffect(() => { React.useEffect(() => {
if (expanded && attachmentText === null && placeholder === null) { if (expanded && attachmentText === null && placeholder === null) {
setPlaceholder('Loading ...'); setPlaceholder('Loading ...');
@ -56,8 +63,9 @@ const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> =
return Math.min(Math.max(5, lineCount), 20) * lineHeight; return Math.min(Math.max(5, lineCount), 20) * lineHeight;
}, [attachmentText]); }, [attachmentText]);
const title = <span style={{ marginLeft: 5 }}> const title = <span style={{ marginLeft: 5 }} ref={ref} title={attachment.name}>
{linkifyText(attachment.name)} {hasContent && <a style={{ marginLeft: 5 }} href={downloadURL(attachment)}>download</a>} <span style={highlight ? { textDecoration: 'underline var(--vscode-terminal-findMatchBackground)', textDecorationThickness: 1.5 } : {}}>{linkifyText(attachment.name)}</span>
{hasContent && <a style={{ marginLeft: 5 }} href={downloadURL(attachment)}>download</a>}
</span>; </span>;
if (!isTextAttachment || !hasContent) if (!isTextAttachment || !hasContent)
@ -82,7 +90,8 @@ const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> =
export const AttachmentsTab: React.FunctionComponent<{ export const AttachmentsTab: React.FunctionComponent<{
model: MultiTraceModel | undefined, model: MultiTraceModel | undefined,
}> = ({ model }) => { selectedAction: ActionTraceEventInContext | undefined,
}> = ({ model, selectedAction }) => {
const { diffMap, screenshots, attachments } = React.useMemo(() => { const { diffMap, screenshots, attachments } = React.useMemo(() => {
const attachments = new Set<Attachment>(); const attachments = new Set<Attachment>();
const screenshots = new Set<Attachment>(); const screenshots = new Set<Attachment>();
@ -139,12 +148,16 @@ export const AttachmentsTab: React.FunctionComponent<{
{attachments.size ? <div className='attachments-section'>Attachments</div> : undefined} {attachments.size ? <div className='attachments-section'>Attachments</div> : undefined}
{[...attachments.values()].map((a, i) => { {[...attachments.values()].map((a, i) => {
return <div className='attachment-item' key={attachmentKey(a, i)}> return <div className='attachment-item' key={attachmentKey(a, i)}>
<ExpandableAttachment attachment={a} /> <ExpandableAttachment attachment={a} highlight={isActiveAttachment(a, selectedAction)} />
</div>; </div>;
})} })}
</div>; </div>;
}; };
function isActiveAttachment(attachment: Attachment, activeAction: ActionTraceEventInContext | undefined): boolean {
return activeAction?.attachments?.some(a => a.name === attachment.name && a.path === attachment.path && a.sha1 === attachment.sha1) ?? false;
}
function attachmentURL(attachment: Attachment, queryParams: Record<string, string> = {}) { function attachmentURL(attachment: Attachment, queryParams: Record<string, string> = {}) {
const params = new URLSearchParams(queryParams); const params = new URLSearchParams(queryParams);
if (attachment.sha1) { if (attachment.sha1) {

View file

@ -231,7 +231,7 @@ export const Workbench: React.FunctionComponent<{
id: 'attachments', id: 'attachments',
title: 'Attachments', title: 'Attachments',
count: attachments.length, count: attachments.length,
render: () => <AttachmentsTab model={model} /> render: () => <AttachmentsTab model={model} selectedAction={selectedAction} />
}; };
const tabs: TabbedPaneTabModel[] = [ const tabs: TabbedPaneTabModel[] = [
@ -296,6 +296,7 @@ export const Workbench: React.FunctionComponent<{
setSelectedTime={setSelectedTime} setSelectedTime={setSelectedTime}
onSelected={onActionSelected} onSelected={onActionSelected}
onHighlighted={setHighlightedAction} onHighlighted={setHighlightedAction}
revealAttachments={() => selectPropertiesTab('attachments')}
revealConsole={() => selectPropertiesTab('console')} revealConsole={() => selectPropertiesTab('console')}
isLive={isLive} isLive={isLive}
/> />

View file

@ -47,7 +47,7 @@ export const TabbedPane: React.FunctionComponent<{
{ leftToolbar && <div style={{ flex: 'none', display: 'flex', margin: '0 4px', alignItems: 'center' }}> { leftToolbar && <div style={{ flex: 'none', display: 'flex', margin: '0 4px', alignItems: 'center' }}>
{...leftToolbar} {...leftToolbar}
</div>} </div>}
{mode === 'default' && <div style={{ flex: 'auto', display: 'flex', height: '100%', overflow: 'hidden' }}> {mode === 'default' && <div style={{ flex: 'auto', display: 'flex', height: '100%', overflow: 'hidden' }} role='tablist'>
{[...tabs.map(tab => ( {[...tabs.map(tab => (
<TabbedPaneTab <TabbedPaneTab
key={tab.id} key={tab.id}
@ -57,10 +57,10 @@ export const TabbedPane: React.FunctionComponent<{
errorCount={tab.errorCount} errorCount={tab.errorCount}
selected={selectedTab === tab.id} selected={selectedTab === tab.id}
onSelect={setSelectedTab} onSelect={setSelectedTab}
></TabbedPaneTab>)), />)),
]} ]}
</div>} </div>}
{mode === 'select' && <div style={{ flex: 'auto', display: 'flex', height: '100%', overflow: 'hidden' }}> {mode === 'select' && <div style={{ flex: 'auto', display: 'flex', height: '100%', overflow: 'hidden' }} role='tablist'>
<select style={{ width: '100%', background: 'none', cursor: 'pointer' }} onChange={e => { <select style={{ width: '100%', background: 'none', cursor: 'pointer' }} onChange={e => {
setSelectedTab?.(tabs[e.currentTarget.selectedIndex].id); setSelectedTab?.(tabs[e.currentTarget.selectedIndex].id);
}}> }}>
@ -70,7 +70,7 @@ export const TabbedPane: React.FunctionComponent<{
suffix = ` (${tab.count})`; suffix = ` (${tab.count})`;
if (tab.errorCount) if (tab.errorCount)
suffix = ` (${tab.errorCount})`; suffix = ` (${tab.errorCount})`;
return <option key={tab.id} value={tab.id} selected={tab.id === selectedTab}>{tab.title}{suffix}</option>; return <option key={tab.id} value={tab.id} selected={tab.id === selectedTab} role='tab' aria-controls={`tab-${tab.id}`}>{tab.title}{suffix}</option>;
})} })}
</select> </select>
</div>} </div>}
@ -82,9 +82,9 @@ export const TabbedPane: React.FunctionComponent<{
tabs.map(tab => { tabs.map(tab => {
const className = 'tab-content tab-' + tab.id; const className = 'tab-content tab-' + tab.id;
if (tab.component) if (tab.component)
return <div key={tab.id} className={className} style={{ display: selectedTab === tab.id ? 'inherit' : 'none' }}>{tab.component}</div>; return <div key={tab.id} id={`tab-${tab.id}`} role='tabpanel' title={tab.title} className={className} style={{ display: selectedTab === tab.id ? 'inherit' : 'none' }}>{tab.component}</div>;
if (selectedTab === tab.id) if (selectedTab === tab.id)
return <div key={tab.id} className={className}>{tab.render!()}</div>; return <div key={tab.id} id={`tab-${tab.id}`} role='tabpanel' title={tab.title} className={className}>{tab.render!()}</div>;
}) })
} }
</div> </div>
@ -101,6 +101,8 @@ export const TabbedPaneTab: React.FunctionComponent<{
}> = ({ id, title, count, errorCount, selected, onSelect }) => { }> = ({ id, title, count, errorCount, selected, onSelect }) => {
return <div className={clsx('tabbed-pane-tab', selected && 'selected')} return <div className={clsx('tabbed-pane-tab', selected && 'selected')}
onClick={() => onSelect?.(id)} onClick={() => onSelect?.(id)}
role='tab'
aria-controls={`tab-${id}`}
title={title} title={title}
key={id}> key={id}>
<div className='tabbed-pane-tab-label'>{title}</div> <div className='tabbed-pane-tab-label'>{title}</div>

View file

@ -148,6 +148,30 @@ test('should linkify string attachments', async ({ runUITest, server }) => {
} }
}); });
test('should link from attachment step to attachments view', async ({ runUITest, server }) => {
const { page } = await runUITest({
'a.test.ts': `
import { test } from '@playwright/test';
test('attach test', async () => {
for (let i = 0; i < 100; i++)
await test.info().attach('spacer-' + i);
await test.info().attach('my-attachment', { body: 'bar' });
});
`,
});
await page.getByText('attach test').click();
await page.getByTitle('Run all').click();
await expect(page.getByTestId('status-line')).toHaveText('1/1 passed (100%)');
await page.getByRole('tab', { name: 'Attachments' }).click();
const panel = page.getByRole('tabpanel', { name: 'Attachments' });
const attachment = panel.getByTitle('my-attachment');
await expect(attachment).not.toBeInViewport();
await page.getByText('attach "my-attachment"').click();
await expect(attachment).toBeInViewport();
});
function readAllFromStream(stream: NodeJS.ReadableStream): Promise<Buffer> { function readAllFromStream(stream: NodeJS.ReadableStream): Promise<Buffer> {
return new Promise(resolve => { return new Promise(resolve => {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];