feat(trace viewer): link from attach action to attachment tab (#33265)
This commit is contained in:
parent
d4ad520a9b
commit
f554f42b82
|
@ -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={[
|
||||
{
|
||||
|
|
|
@ -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';
|
||||
|
@ -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[],
|
||||
|
@ -35,6 +36,7 @@ export interface ActionListProps {
|
|||
onSelected?: (action: ActionTraceEventInContext) => void,
|
||||
onHighlighted?: (action: ActionTraceEventInContext | undefined) => void,
|
||||
revealConsole?: () => void,
|
||||
revealAttachment(attachment: AfterActionTraceEventAttachment): void,
|
||||
isLive?: boolean,
|
||||
}
|
||||
|
||||
|
@ -49,6 +51,7 @@ export const ActionList: React.FC<ActionListProps> = ({
|
|||
onSelected,
|
||||
onHighlighted,
|
||||
revealConsole,
|
||||
revealAttachment,
|
||||
isLive,
|
||||
}) => {
|
||||
const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
|
||||
|
@ -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);
|
||||
|
@ -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)
|
||||
|
@ -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>}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 ...');
|
||||
|
@ -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)
|
||||
|
@ -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>();
|
||||
|
@ -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) {
|
||||
|
|
|
@ -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={[
|
||||
{
|
||||
|
|
|
@ -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}
|
||||
|
@ -249,6 +249,7 @@ const PropertiesView: React.FunctionComponent<{
|
|||
];
|
||||
|
||||
return <TabbedPane
|
||||
id='properties-tabs'
|
||||
tabs={tabs}
|
||||
selectedTab={selectedPropertiesTab}
|
||||
setSelectedTab={setSelectedPropertiesTab}
|
||||
|
|
|
@ -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,
|
||||
|
@ -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>();
|
||||
|
@ -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');
|
||||
|
@ -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[] = [
|
||||
|
@ -296,6 +303,7 @@ export const Workbench: React.FunctionComponent<{
|
|||
setSelectedTime={setSelectedTime}
|
||||
onSelected={onActionSelected}
|
||||
onHighlighted={setHighlightedAction}
|
||||
revealAttachment={revealAttachment}
|
||||
revealConsole={() => selectPropertiesTab('console')}
|
||||
isLive={isLive}
|
||||
/>
|
||||
|
@ -340,6 +348,7 @@ export const Workbench: React.FunctionComponent<{
|
|||
openPage={openPage} />}
|
||||
sidebar={
|
||||
<TabbedPane
|
||||
id='actionlist-sidebar'
|
||||
tabs={[actionsTab, metadataTab]}
|
||||
selectedTab={selectedNavigatorTab}
|
||||
setSelectedTab={setSelectedNavigatorTab}
|
||||
|
@ -347,6 +356,7 @@ export const Workbench: React.FunctionComponent<{
|
|||
}
|
||||
/>}
|
||||
sidebar={<TabbedPane
|
||||
id='workbench-sidebar'
|
||||
tabs={tabs}
|
||||
selectedTab={selectedPropertiesTab}
|
||||
setSelectedTab={selectPropertiesTab}
|
||||
|
|
|
@ -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)
|
||||
|
@ -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);
|
||||
}}>
|
||||
|
@ -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>}
|
||||
|
@ -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>
|
||||
|
@ -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>}
|
||||
|
|
|
@ -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();
|
||||
|
@ -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();
|
||||
|
@ -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[] = [];
|
||||
|
|
Loading…
Reference in New Issue