diff --git a/packages/trace-viewer/src/ui/actionList.tsx b/packages/trace-viewer/src/ui/actionList.tsx index d369aeede3..e0dddf2928 100644 --- a/packages/trace-viewer/src/ui/actionList.tsx +++ b/packages/trace-viewer/src/ui/actionList.tsx @@ -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, + revealAttachments(): void, isLive?: boolean, } @@ -49,6 +51,7 @@ export const ActionList: React.FC = ({ onSelected, onHighlighted, revealConsole, + revealAttachments, isLive, }) => { const [treeState, setTreeState] = React.useState({ expandedItems: new Map() }); @@ -68,8 +71,8 @@ export const ActionList: React.FC = ({ }, [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, revealAttachments, isLive, showDuration: true, showBadges: true }); + }, [isLive, revealConsole, revealAttachments, 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, + revealAttachments?(): void, isLive?: boolean, showDuration?: 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 locator = action.params.selector ? asLocator(sdkLanguage || 'javascript', action.params.selector) : undefined; + const showAttachments = !!action.attachments?.length && !!revealAttachments; let time: string = ''; if (action.endTime) @@ -128,8 +133,9 @@ export const renderAction = ( {action.method === 'goto' && action.params.url &&
{action.params.url}
} {action.class === 'APIRequestContext' && action.params.url &&
{excludeOrigin(action.params.url)}
} - {(showDuration || showBadges) &&
} + {(showDuration || showBadges || showAttachments) &&
} {showDuration &&
{time || }
} + {showAttachments && } {showBadges &&
revealConsole?.()}> {!!errors &&
{errors}
} {!!warnings &&
{warnings}
} diff --git a/packages/trace-viewer/src/ui/attachmentsTab.tsx b/packages/trace-viewer/src/ui/attachmentsTab.tsx index 69bfcd68cc..93aa7951fa 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.tsx +++ b/packages/trace-viewer/src/ui/attachmentsTab.tsx @@ -17,7 +17,7 @@ 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'; @@ -29,16 +29,23 @@ type Attachment = AfterActionTraceEventAttachment & { traceUrl: string }; type ExpandableAttachmentProps = { attachment: Attachment; + highlight?: boolean; }; -const ExpandableAttachment: React.FunctionComponent = ({ attachment }) => { +const ExpandableAttachment: React.FunctionComponent = ({ attachment, highlight }) => { const [expanded, setExpanded] = React.useState(false); const [attachmentText, setAttachmentText] = React.useState(null); const [placeholder, setPlaceholder] = React.useState(null); + const ref = React.useRef(null); const isTextAttachment = isTextualMimeType(attachment.contentType); const hasContent = !!attachment.sha1 || !!attachment.path; + React.useEffect(() => { + if (highlight) + ref.current?.scrollIntoView({ behavior: 'smooth' }); + }, [highlight]); + React.useEffect(() => { if (expanded && attachmentText === null && placeholder === null) { setPlaceholder('Loading ...'); @@ -56,8 +63,9 @@ const ExpandableAttachment: React.FunctionComponent = return Math.min(Math.max(5, lineCount), 20) * lineHeight; }, [attachmentText]); - const title = - {linkifyText(attachment.name)} {hasContent && download} + const title = + {linkifyText(attachment.name)} + {hasContent && download} ; if (!isTextAttachment || !hasContent) @@ -82,7 +90,8 @@ const ExpandableAttachment: React.FunctionComponent = export const AttachmentsTab: React.FunctionComponent<{ model: MultiTraceModel | undefined, -}> = ({ model }) => { + selectedAction: ActionTraceEventInContext | undefined, +}> = ({ model, selectedAction }) => { const { diffMap, screenshots, attachments } = React.useMemo(() => { const attachments = new Set(); const screenshots = new Set(); @@ -139,12 +148,16 @@ export const AttachmentsTab: React.FunctionComponent<{ {attachments.size ?
Attachments
: undefined} {[...attachments.values()].map((a, i) => { return
- +
; })}
; }; +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 = {}) { const params = new URLSearchParams(queryParams); if (attachment.sha1) { diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index 8e22917053..5965194d45 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -231,7 +231,7 @@ export const Workbench: React.FunctionComponent<{ id: 'attachments', title: 'Attachments', count: attachments.length, - render: () => + render: () => }; const tabs: TabbedPaneTabModel[] = [ @@ -296,6 +296,7 @@ export const Workbench: React.FunctionComponent<{ setSelectedTime={setSelectedTime} onSelected={onActionSelected} onHighlighted={setHighlightedAction} + revealAttachments={() => selectPropertiesTab('attachments')} revealConsole={() => selectPropertiesTab('console')} isLive={isLive} /> diff --git a/packages/web/src/components/tabbedPane.tsx b/packages/web/src/components/tabbedPane.tsx index 5df94ec4c3..1bab84d270 100644 --- a/packages/web/src/components/tabbedPane.tsx +++ b/packages/web/src/components/tabbedPane.tsx @@ -47,7 +47,7 @@ export const TabbedPane: React.FunctionComponent<{ { leftToolbar &&
{...leftToolbar}
} - {mode === 'default' &&
+ {mode === 'default' &&
{[...tabs.map(tab => ( )), + />)), ]}
} - {mode === 'select' &&
+ {mode === 'select' &&
} @@ -82,9 +82,9 @@ export const TabbedPane: React.FunctionComponent<{ tabs.map(tab => { const className = 'tab-content tab-' + tab.id; if (tab.component) - return
{tab.component}
; + return
{tab.component}
; if (selectedTab === tab.id) - return
{tab.render!()}
; + return
{tab.render!()}
; }) }
@@ -101,6 +101,8 @@ export const TabbedPaneTab: React.FunctionComponent<{ }> = ({ id, title, count, errorCount, selected, onSelect }) => { return
onSelect?.(id)} + role='tab' + aria-controls={`tab-${id}`} title={title} key={id}>
{title}
diff --git a/tests/playwright-test/ui-mode-test-attachments.spec.ts b/tests/playwright-test/ui-mode-test-attachments.spec.ts index 7016a36115..8f1d3d2e42 100644 --- a/tests/playwright-test/ui-mode-test-attachments.spec.ts +++ b/tests/playwright-test/ui-mode-test-attachments.spec.ts @@ -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 { return new Promise(resolve => { const chunks: Buffer[] = [];