Skip to content

Commit

Permalink
feat(trace viewer): link from attach action to attachment tab (#33265)
Browse files Browse the repository at this point in the history
  • Loading branch information
Skn0tt authored Nov 6, 2024
1 parent d4ad520 commit f554f42
Show file tree
Hide file tree
Showing 9 changed files with 98 additions and 25 deletions.
1 change: 1 addition & 0 deletions packages/recorder/src/recorder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export const Recorder: React.FC<RecorderProps> = ({
sidebarSize={200}
main={<CodeMirrorWrapper text={source.text} language={source.language} highlight={source.highlight} revealLine={source.revealLine} readOnly={true} lineNumbers={true} />}
sidebar={<TabbedPane
id='recorder-sidebar'
rightToolbar={selectedTab === 'locator' || selectedTab === 'aria' ? [<ToolbarButton key={1} icon='files' title='Copy' onClick={() => copy((selectedTab === 'locator' ? locator : ariaSnapshot) || '')} />] : []}
tabs={[
{
Expand Down
16 changes: 11 additions & 5 deletions packages/trace-viewer/src/ui/actionList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
limitations under the License.
*/

import type { ActionTraceEvent } from '@trace/trace';
import type { ActionTraceEvent, AfterActionTraceEventAttachment } from '@trace/trace';
import { msToString } from '@web/uiUtils';
import * as React from 'react';
import './actionList.css';
Expand All @@ -25,6 +25,7 @@ import type { TreeState } from '@web/components/treeView';
import { TreeView } from '@web/components/treeView';
import type { ActionTraceEventInContext, ActionTreeItem } from './modelUtil';
import type { Boundaries } from './geometry';
import { ToolbarButton } from '@web/components/toolbarButton';

export interface ActionListProps {
actions: ActionTraceEventInContext[],
Expand All @@ -35,6 +36,7 @@ export interface ActionListProps {
onSelected?: (action: ActionTraceEventInContext) => void,
onHighlighted?: (action: ActionTraceEventInContext | undefined) => void,
revealConsole?: () => void,
revealAttachment(attachment: AfterActionTraceEventAttachment): void,
isLive?: boolean,
}

Expand All @@ -49,6 +51,7 @@ export const ActionList: React.FC<ActionListProps> = ({
onSelected,
onHighlighted,
revealConsole,
revealAttachment,
isLive,
}) => {
const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
Expand All @@ -68,8 +71,8 @@ export const ActionList: React.FC<ActionListProps> = ({
}, [setSelectedTime]);

const render = React.useCallback((item: ActionTreeItem) => {
return renderAction(item.action!, { sdkLanguage, revealConsole, isLive, showDuration: true, showBadges: true });
}, [isLive, revealConsole, sdkLanguage]);
return renderAction(item.action!, { sdkLanguage, revealConsole, revealAttachment, isLive, showDuration: true, showBadges: true });
}, [isLive, revealConsole, revealAttachment, sdkLanguage]);

const isVisible = React.useCallback((item: ActionTreeItem) => {
return !selectedTime || !item.action || (item.action!.startTime <= selectedTime.maximum && item.action!.endTime >= selectedTime.minimum);
Expand Down Expand Up @@ -106,13 +109,15 @@ export const renderAction = (
options: {
sdkLanguage?: Language,
revealConsole?: () => void,
revealAttachment?(attachment: AfterActionTraceEventAttachment): void,
isLive?: boolean,
showDuration?: boolean,
showBadges?: boolean,
}) => {
const { sdkLanguage, revealConsole, isLive, showDuration, showBadges } = options;
const { sdkLanguage, revealConsole, revealAttachment, isLive, showDuration, showBadges } = options;
const { errors, warnings } = modelUtil.stats(action);
const locator = action.params.selector ? asLocator(sdkLanguage || 'javascript', action.params.selector) : undefined;
const showAttachments = !!action.attachments?.length && !!revealAttachment;

let time: string = '';
if (action.endTime)
Expand All @@ -128,7 +133,8 @@ export const renderAction = (
{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>}
</div>
{(showDuration || showBadges) && <div className='spacer'></div>}
{(showDuration || showBadges || showAttachments) && <div className='spacer'></div>}
{showAttachments && <ToolbarButton icon='attach' title='Open Attachment' onClick={() => revealAttachment(action.attachments![0])} />}
{showDuration && <div className='action-duration'>{time || <span className='codicon codicon-loading'></span>}</div>}
{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>}
Expand Down
5 changes: 5 additions & 0 deletions packages/trace-viewer/src/ui/attachmentsTab.css
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@
margin: 4px 8px;
}

.attachment-title-highlight {
text-decoration: underline var(--vscode-terminal-findMatchBackground);
text-decoration-thickness: 1.5px;
}

.attachment-item img {
flex: none;
min-width: 200px;
Expand Down
32 changes: 26 additions & 6 deletions packages/trace-viewer/src/ui/attachmentsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,37 @@
import * as React from 'react';
import './attachmentsTab.css';
import { ImageDiffView } from '@web/shared/imageDiffView';
import type { MultiTraceModel } from './modelUtil';
import type { ActionTraceEventInContext, MultiTraceModel } from './modelUtil';
import { PlaceholderPanel } from './placeholderPanel';
import type { AfterActionTraceEventAttachment } from '@trace/trace';
import { CodeMirrorWrapper, lineHeight } from '@web/components/codeMirrorWrapper';
import { isTextualMimeType } from '@isomorphic/mimeType';
import { Expandable } from '@web/components/expandable';
import { linkifyText } from '@web/renderUtils';
import { clsx } from '@web/uiUtils';

type Attachment = AfterActionTraceEventAttachment & { traceUrl: string };

type ExpandableAttachmentProps = {
attachment: Attachment;
reveal: boolean;
highlight: boolean;
};

const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> = ({ attachment }) => {
const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> = ({ attachment, reveal, highlight }) => {
const [expanded, setExpanded] = React.useState(false);
const [attachmentText, setAttachmentText] = 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 hasContent = !!attachment.sha1 || !!attachment.path;

React.useEffect(() => {
if (reveal)
ref.current?.scrollIntoView({ behavior: 'smooth' });
}, [reveal]);

React.useEffect(() => {
if (expanded && attachmentText === null && placeholder === null) {
setPlaceholder('Loading ...');
Expand All @@ -56,8 +65,9 @@ const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> =
return Math.min(Math.max(5, lineCount), 20) * lineHeight;
}, [attachmentText]);

const title = <span style={{ marginLeft: 5 }}>
{linkifyText(attachment.name)} {hasContent && <a style={{ marginLeft: 5 }} href={downloadURL(attachment)}>download</a>}
const title = <span style={{ marginLeft: 5 }} ref={ref} aria-label={attachment.name}>
<span className={clsx(highlight && 'attachment-title-highlight')}>{linkifyText(attachment.name)}</span>
{hasContent && <a style={{ marginLeft: 5 }} href={downloadURL(attachment)}>download</a>}
</span>;

if (!isTextAttachment || !hasContent)
Expand All @@ -82,7 +92,9 @@ const ExpandableAttachment: React.FunctionComponent<ExpandableAttachmentProps> =

export const AttachmentsTab: React.FunctionComponent<{
model: MultiTraceModel | undefined,
}> = ({ model }) => {
selectedAction: ActionTraceEventInContext | undefined,
revealedAttachment?: AfterActionTraceEventAttachment,
}> = ({ model, selectedAction, revealedAttachment }) => {
const { diffMap, screenshots, attachments } = React.useMemo(() => {
const attachments = new Set<Attachment>();
const screenshots = new Set<Attachment>();
Expand Down Expand Up @@ -139,12 +151,20 @@ export const AttachmentsTab: React.FunctionComponent<{
{attachments.size ? <div className='attachments-section'>Attachments</div> : undefined}
{[...attachments.values()].map((a, i) => {
return <div className='attachment-item' key={attachmentKey(a, i)}>
<ExpandableAttachment attachment={a} />
<ExpandableAttachment
attachment={a}
highlight={selectedAction?.attachments?.some(selected => isEqualAttachment(a, selected)) ?? false}
reveal={!!revealedAttachment && isEqualAttachment(a, revealedAttachment)}
/>
</div>;
})}
</div>;
};

function isEqualAttachment(a: Attachment, b: AfterActionTraceEventAttachment): boolean {
return a.name === b.name && a.path === b.path && a.sha1 === b.sha1;
}

function attachmentURL(attachment: Attachment, queryParams: Record<string, string> = {}) {
const params = new URLSearchParams(queryParams);
if (attachment.sha1) {
Expand Down
1 change: 1 addition & 0 deletions packages/trace-viewer/src/ui/networkResourceDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const NetworkResourceDetails: React.FunctionComponent<{

return <TabbedPane
dataTestId='network-request-details'
id='network-request-tabs'
leftToolbar={[<ToolbarButton key='close' icon='close' title='Close' onClick={onClose}></ToolbarButton>]}
tabs={[
{
Expand Down
3 changes: 2 additions & 1 deletion packages/trace-viewer/src/ui/recorder/recorderView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export const Workbench: React.FunctionComponent = () => {
<ToolbarButton icon='color-mode' title='Toggle color mode' toggled={false} onClick={() => toggleTheme()}></ToolbarButton>
</Toolbar>;

const sidebarTabbedPane = <TabbedPane tabs={[actionsTab]} />;
const sidebarTabbedPane = <TabbedPane id='recorder-actions-tab' tabs={[actionsTab]} />;
const traceView = <TraceView
sdkLanguage={sdkLanguage}
callId={traceCallId}
Expand Down Expand Up @@ -249,6 +249,7 @@ const PropertiesView: React.FunctionComponent<{
];

return <TabbedPane
id='properties-tabs'
tabs={tabs}
selectedTab={selectedPropertiesTab}
setSelectedTab={setSelectedPropertiesTab}
Expand Down
12 changes: 11 additions & 1 deletion packages/trace-viewer/src/ui/workbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import type { Entry } from '@trace/har';
import './workbench.css';
import { testStatusIcon, testStatusText } from './testUtils';
import type { UITestStatus } from './testUtils';
import type { AfterActionTraceEventAttachment } from '@trace/trace';

export const Workbench: React.FunctionComponent<{
model?: modelUtil.MultiTraceModel,
Expand All @@ -58,6 +59,7 @@ export const Workbench: React.FunctionComponent<{
}> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, openPage, onOpenExternally, revealSource }) => {
const [selectedCallId, setSelectedCallId] = React.useState<string | undefined>(undefined);
const [revealedError, setRevealedError] = React.useState<ErrorDescription | undefined>(undefined);
const [revealedAttachment, setRevealedAttachment] = React.useState<AfterActionTraceEventAttachment | undefined>(undefined);
const [highlightedCallId, setHighlightedCallId] = React.useState<string | undefined>();
const [highlightedEntry, setHighlightedEntry] = React.useState<Entry | undefined>();
const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState<ConsoleEntry | undefined>();
Expand Down Expand Up @@ -144,6 +146,11 @@ export const Workbench: React.FunctionComponent<{
selectPropertiesTab('inspector');
}, [selectPropertiesTab]);

const revealAttachment = React.useCallback((attachment: AfterActionTraceEventAttachment) => {
selectPropertiesTab('attachments');
setRevealedAttachment(attachment);
}, [selectPropertiesTab]);

React.useEffect(() => {
if (revealSource)
selectPropertiesTab('source');
Expand Down Expand Up @@ -231,7 +238,7 @@ export const Workbench: React.FunctionComponent<{
id: 'attachments',
title: 'Attachments',
count: attachments.length,
render: () => <AttachmentsTab model={model} />
render: () => <AttachmentsTab model={model} selectedAction={selectedAction} revealedAttachment={revealedAttachment} />
};

const tabs: TabbedPaneTabModel[] = [
Expand Down Expand Up @@ -296,6 +303,7 @@ export const Workbench: React.FunctionComponent<{
setSelectedTime={setSelectedTime}
onSelected={onActionSelected}
onHighlighted={setHighlightedAction}
revealAttachment={revealAttachment}
revealConsole={() => selectPropertiesTab('console')}
isLive={isLive}
/>
Expand Down Expand Up @@ -340,13 +348,15 @@ export const Workbench: React.FunctionComponent<{
openPage={openPage} />}
sidebar={
<TabbedPane
id='actionlist-sidebar'
tabs={[actionsTab, metadataTab]}
selectedTab={selectedNavigatorTab}
setSelectedTab={setSelectedNavigatorTab}
/>
}
/>}
sidebar={<TabbedPane
id='workbench-sidebar'
tabs={tabs}
selectedTab={selectedPropertiesTab}
setSelectedTab={selectPropertiesTab}
Expand Down
24 changes: 14 additions & 10 deletions packages/web/src/components/tabbedPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ export const TabbedPane: React.FunctionComponent<{
setSelectedTab?: (tab: string) => void,
dataTestId?: string,
mode?: 'default' | 'select',
}> = ({ tabs, selectedTab, setSelectedTab, leftToolbar, rightToolbar, dataTestId, mode }) => {
id: string,
}> = ({ tabs, selectedTab, setSelectedTab, leftToolbar, rightToolbar, dataTestId, mode, id }) => {
if (!selectedTab)
selectedTab = tabs[0].id;
if (!mode)
Expand All @@ -47,20 +48,21 @@ export const TabbedPane: React.FunctionComponent<{
{ leftToolbar && <div style={{ flex: 'none', display: 'flex', margin: '0 4px', alignItems: 'center' }}>
{...leftToolbar}
</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 => (
<TabbedPaneTab
key={tab.id}
id={tab.id}
ariaControls={`pane-${id}-tab-${tab.id}`}
title={tab.title}
count={tab.count}
errorCount={tab.errorCount}
selected={selectedTab === tab.id}
onSelect={setSelectedTab}
></TabbedPaneTab>)),
/>)),
]}
</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 => {
setSelectedTab?.(tabs[e.currentTarget.selectedIndex].id);
}}>
Expand All @@ -70,7 +72,7 @@ export const TabbedPane: React.FunctionComponent<{
suffix = ` (${tab.count})`;
if (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={`pane-${id}-tab-${tab.id}`}>{tab.title}{suffix}</option>;
})}
</select>
</div>}
Expand All @@ -82,9 +84,9 @@ export const TabbedPane: React.FunctionComponent<{
tabs.map(tab => {
const className = 'tab-content tab-' + tab.id;
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={`pane-${id}-tab-${tab.id}`} role='tabpanel' aria-label={tab.title} className={className} style={{ display: selectedTab === tab.id ? 'inherit' : 'none' }}>{tab.component}</div>;
if (selectedTab === tab.id)
return <div key={tab.id} className={className}>{tab.render!()}</div>;
return <div key={tab.id} id={`pane-${id}-tab-${tab.id}`} role='tabpanel' aria-label={tab.title} className={className}>{tab.render!()}</div>;
})
}
</div>
Expand All @@ -97,12 +99,14 @@ export const TabbedPaneTab: React.FunctionComponent<{
count?: number,
errorCount?: number,
selected?: boolean,
onSelect?: (id: string) => void
}> = ({ id, title, count, errorCount, selected, onSelect }) => {
onSelect?: (id: string) => void,
ariaControls?: string,
}> = ({ id, title, count, errorCount, selected, onSelect, ariaControls }) => {
return <div className={clsx('tabbed-pane-tab', selected && 'selected')}
onClick={() => onSelect?.(id)}
role='tab'
title={title}
key={id}>
aria-controls={ariaControls}>
<div className='tabbed-pane-tab-label'>{title}</div>
{!!count && <div className='tabbed-pane-tab-counter'>{count}</div>}
{!!errorCount && <div className='tabbed-pane-tab-counter error'>{errorCount}</div>}
Expand Down
29 changes: 27 additions & 2 deletions tests/playwright-test/ui-mode-test-attachments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ test('should linkify string attachments', async ({ runUITest, server }) => {
}

{
await attachmentsPane.getByText('Second download').click();
await attachmentsPane.getByLabel('Second').click();
const url = server.PREFIX + '/two.html';
const promise = page.waitForEvent('popup');
await attachmentsPane.getByText(url).click();
Expand All @@ -139,7 +139,7 @@ test('should linkify string attachments', async ({ runUITest, server }) => {
}

{
await attachmentsPane.getByText('Third download').click();
await attachmentsPane.getByLabel('Third').click();
const url = server.PREFIX + '/three.html';
const promise = page.waitForEvent('popup');
await attachmentsPane.getByText('[markdown link]').click();
Expand All @@ -148,6 +148,31 @@ test('should linkify string attachments', async ({ runUITest, server }) => {
}
});

test('should link from attachment step to attachments view', async ({ runUITest }) => {
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.getByLabel('my-attachment');
await page.getByRole('treeitem', { name: 'attach "spacer-1"' }).getByLabel('Open Attachment').click();
await expect(attachment).not.toBeInViewport();
await page.getByRole('treeitem', { name: 'attach "my-attachment"' }).getByLabel('Open Attachment').click();
await expect(attachment).toBeInViewport();
});

function readAllFromStream(stream: NodeJS.ReadableStream): Promise<Buffer> {
return new Promise(resolve => {
const chunks: Buffer[] = [];
Expand Down

0 comments on commit f554f42

Please sign in to comment.