diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx index 2e05b65f6e59d..84265d845d13b 100644 --- a/packages/recorder/src/recorder.tsx +++ b/packages/recorder/src/recorder.tsx @@ -165,6 +165,7 @@ export const Recorder: React.FC = ({ sidebarSize={200} main={} sidebar={ copy((selectedTab === 'locator' ? locator : ariaSnapshot) || '')} />] : []} tabs={[ { diff --git a/packages/trace-viewer/src/ui/actionList.tsx b/packages/trace-viewer/src/ui/actionList.tsx index d369aeede3ef8..101c532aeaa31 100644 --- a/packages/trace-viewer/src/ui/actionList.tsx +++ b/packages/trace-viewer/src/ui/actionList.tsx @@ -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 = ({ onSelected, onHighlighted, revealConsole, + revealAttachment, 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, 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 &&
{action.params.url}
} {action.class === 'APIRequestContext' && action.params.url &&
{excludeOrigin(action.params.url)}
} - {(showDuration || showBadges) &&
} + {(showDuration || showBadges || showAttachments) &&
} + {showAttachments && revealAttachment(action.attachments![0])} />} {showDuration &&
{time || }
} {showBadges &&
revealConsole?.()}> {!!errors &&
{errors}
} diff --git a/packages/trace-viewer/src/ui/attachmentsTab.css b/packages/trace-viewer/src/ui/attachmentsTab.css index db22a72f5dc39..c2455fc3c5860 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.css +++ b/packages/trace-viewer/src/ui/attachmentsTab.css @@ -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; diff --git a/packages/trace-viewer/src/ui/attachmentsTab.tsx b/packages/trace-viewer/src/ui/attachmentsTab.tsx index 69bfcd68cce2f..cf9ed2e681e24 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.tsx +++ b/packages/trace-viewer/src/ui/attachmentsTab.tsx @@ -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 = ({ attachment }) => { +const ExpandableAttachment: React.FunctionComponent = ({ attachment, reveal, 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 (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 = 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 +92,9 @@ const ExpandableAttachment: React.FunctionComponent = 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(); const screenshots = new Set(); @@ -139,12 +151,20 @@ export const AttachmentsTab: React.FunctionComponent<{ {attachments.size ?
Attachments
: undefined} {[...attachments.values()].map((a, i) => { return
- + isEqualAttachment(a, selected)) ?? false} + reveal={!!revealedAttachment && isEqualAttachment(a, revealedAttachment)} + />
; })}
; }; +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 = {}) { const params = new URLSearchParams(queryParams); if (attachment.sha1) { diff --git a/packages/trace-viewer/src/ui/networkResourceDetails.tsx b/packages/trace-viewer/src/ui/networkResourceDetails.tsx index 7fe5c7f732074..bc92d1121aa67 100644 --- a/packages/trace-viewer/src/ui/networkResourceDetails.tsx +++ b/packages/trace-viewer/src/ui/networkResourceDetails.tsx @@ -31,6 +31,7 @@ export const NetworkResourceDetails: React.FunctionComponent<{ return
]} tabs={[ { diff --git a/packages/trace-viewer/src/ui/recorder/recorderView.tsx b/packages/trace-viewer/src/ui/recorder/recorderView.tsx index 6ff6b665d3bb2..9b9b9abb19274 100644 --- a/packages/trace-viewer/src/ui/recorder/recorderView.tsx +++ b/packages/trace-viewer/src/ui/recorder/recorderView.tsx @@ -151,7 +151,7 @@ export const Workbench: React.FunctionComponent = () => { toggleTheme()}> ; - const sidebarTabbedPane = ; + const sidebarTabbedPane = ; const traceView = = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, openPage, onOpenExternally, revealSource }) => { const [selectedCallId, setSelectedCallId] = React.useState(undefined); const [revealedError, setRevealedError] = React.useState(undefined); + const [revealedAttachment, setRevealedAttachment] = React.useState(undefined); const [highlightedCallId, setHighlightedCallId] = React.useState(); const [highlightedEntry, setHighlightedEntry] = React.useState(); const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState(); @@ -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: () => + render: () => }; 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={ } sidebar={ 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 &&
{...leftToolbar}
} - {mode === 'default' &&
+ {mode === 'default' &&
{[...tabs.map(tab => ( )), + />)), ]}
} - {mode === 'select' &&
+ {mode === 'select' &&
} @@ -82,9 +84,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!()}
; }) }
@@ -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
onSelect?.(id)} + role='tab' title={title} - key={id}> + aria-controls={ariaControls}>
{title}
{!!count &&
{count}
} {!!errorCount &&
{errorCount}
} diff --git a/tests/playwright-test/ui-mode-test-attachments.spec.ts b/tests/playwright-test/ui-mode-test-attachments.spec.ts index 7016a36115781..1a9e5b56c2693 100644 --- a/tests/playwright-test/ui-mode-test-attachments.spec.ts +++ b/tests/playwright-test/ui-mode-test-attachments.spec.ts @@ -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 { return new Promise(resolve => { const chunks: Buffer[] = [];