Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions packages/recorder/src/recorder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,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
Loading