diff --git a/packages/html-reporter/src/links.css b/packages/html-reporter/src/links.css index eb9390844b7c3..4abe8a6caa291 100644 --- a/packages/html-reporter/src/links.css +++ b/packages/html-reporter/src/links.css @@ -60,11 +60,6 @@ color: var(--color-scale-orange-6); border: 1px solid var(--color-scale-orange-4); } - .label-color-gray { - background-color: var(--color-scale-gray-0); - color: var(--color-scale-gray-6); - border: 1px solid var(--color-scale-gray-4); - } } @media(prefers-color-scheme: dark) { @@ -98,11 +93,6 @@ color: var(--color-scale-orange-2); border: 1px solid var(--color-scale-orange-4); } - .label-color-gray { - background-color: var(--color-scale-gray-9); - color: var(--color-scale-gray-2); - border: 1px solid var(--color-scale-gray-4); - } } .attachment-body { diff --git a/packages/html-reporter/src/links.tsx b/packages/html-reporter/src/links.tsx index 5f199568b5f56..5b79102ffe1c4 100644 --- a/packages/html-reporter/src/links.tsx +++ b/packages/html-reporter/src/links.tsx @@ -21,7 +21,7 @@ import { TreeItem } from './treeItem'; import { CopyToClipboard } from './copyToClipboard'; import './links.css'; import { linkifyText } from '@web/renderUtils'; -import { clsx } from '@web/uiUtils'; +import { clsx, useFlash } from '@web/uiUtils'; export function navigate(href: string | URL) { window.history.pushState({}, '', href); @@ -73,7 +73,8 @@ export const AttachmentLink: React.FunctionComponent<{ linkName?: string, openInNewTab?: boolean, }> = ({ attachment, result, href, linkName, openInNewTab }) => { - const isAnchored = useIsAnchored('attachment-' + result.attachments.indexOf(attachment)); + const [flash, triggerFlash] = useFlash(); + useAnchor('attachment-' + result.attachments.indexOf(attachment), triggerFlash); return {attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()} {attachment.path && {linkName || attachment.name}} @@ -84,7 +85,7 @@ export const AttachmentLink: React.FunctionComponent<{ )} } loadChildren={attachment.body ? () => { return [
{linkifyText(attachment.body!)}
]; - } : undefined} depth={0} style={{ lineHeight: '32px' }} selected={isAnchored}>
; + } : undefined} depth={0} style={{ lineHeight: '32px' }} flash={flash}>; }; export const SearchParamsContext = React.createContext(new URLSearchParams(window.location.hash.slice(1))); @@ -118,12 +119,12 @@ const kMissingContentType = 'x-playwright/missing'; export type AnchorID = string | string[] | ((id: string) => boolean) | undefined; -export function useAnchor(id: AnchorID, onReveal: () => void) { +export function useAnchor(id: AnchorID, onReveal: React.EffectCallback) { const searchParams = React.useContext(SearchParamsContext); const isAnchored = useIsAnchored(id); React.useEffect(() => { if (isAnchored) - onReveal(); + return onReveal(); }, [isAnchored, onReveal, searchParams]); } diff --git a/packages/html-reporter/src/testResultView.tsx b/packages/html-reporter/src/testResultView.tsx index 64497b3bcd10a..681f4b507aba5 100644 --- a/packages/html-reporter/src/testResultView.tsx +++ b/packages/html-reporter/src/testResultView.tsx @@ -176,6 +176,7 @@ const StepTreeItem: React.FC<{ }> = ({ test, step, result, depth }) => { return {msToString(step.duration)} + {step.attachments.length > 0 && { evt.stopPropagation(); }}>{icons.attachment()}} {statusIcon(step.error || step.duration === -1 ? 'failed' : (step.skipped ? 'skipped' : 'passed'))} {step.title} {step.count > 1 && <> ✕ {step.count}} @@ -183,20 +184,6 @@ const StepTreeItem: React.FC<{ } loadChildren={step.steps.length || step.snippet ? () => { const snippet = step.snippet ? [] : []; const steps = step.steps.map((s, i) => ); - const attachments = step.attachments.map(attachmentIndex => ( - - - {icons.attachment()}{result.attachments[attachmentIndex].name} - - - )); - return snippet.concat(steps, attachments); + return snippet.concat(steps); } : undefined} depth={depth}/>; }; diff --git a/packages/html-reporter/src/treeItem.css b/packages/html-reporter/src/treeItem.css index f37a759c2d4fa..b957d1ec5c032 100644 --- a/packages/html-reporter/src/treeItem.css +++ b/packages/html-reporter/src/treeItem.css @@ -25,11 +25,14 @@ cursor: pointer; } -.tree-item-title.selected { - text-decoration: underline var(--color-underlinenav-icon); - text-decoration-thickness: 1.5px; -} - .tree-item-body { min-height: 18px; } + +.yellow-flash { + animation: yellowflash-bg 2s; +} +@keyframes yellowflash-bg { + from { background: var(--color-attention-subtle); } + to { background: transparent; } +} diff --git a/packages/html-reporter/src/treeItem.tsx b/packages/html-reporter/src/treeItem.tsx index 926a398a053f7..7ae9b840f8968 100644 --- a/packages/html-reporter/src/treeItem.tsx +++ b/packages/html-reporter/src/treeItem.tsx @@ -25,12 +25,12 @@ export const TreeItem: React.FunctionComponent<{ onClick?: () => void, expandByDefault?: boolean, depth: number, - selected?: boolean, style?: React.CSSProperties, -}> = ({ title, loadChildren, onClick, expandByDefault, depth, selected, style }) => { + flash?: boolean +}> = ({ title, loadChildren, onClick, expandByDefault, depth, style, flash }) => { const [expanded, setExpanded] = React.useState(expandByDefault || false); - return
- { onClick?.(); setExpanded(!expanded); }} > + return
+ { onClick?.(); setExpanded(!expanded); }} > {loadChildren && !!expanded && icons.downArrow()} {loadChildren && !expanded && icons.rightArrow()} {!loadChildren && {icons.rightArrow()}} diff --git a/packages/trace-viewer/src/ui/attachmentsTab.css b/packages/trace-viewer/src/ui/attachmentsTab.css index c2455fc3c5860..7d487bb3f246c 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.css +++ b/packages/trace-viewer/src/ui/attachmentsTab.css @@ -55,3 +55,11 @@ a.codicon-cloud-download:hover{ background-color: var(--vscode-list-inactiveSelectionBackground) } + +.yellow-flash { + animation: yellowflash-bg 2s; +} +@keyframes yellowflash-bg { + from { background: var(--vscode-peekViewEditor-matchHighlightBackground); } + to { background: transparent; } +} diff --git a/packages/trace-viewer/src/ui/attachmentsTab.tsx b/packages/trace-viewer/src/ui/attachmentsTab.tsx index cf9ed2e681e24..7a636a83b0a10 100644 --- a/packages/trace-viewer/src/ui/attachmentsTab.tsx +++ b/packages/trace-viewer/src/ui/attachmentsTab.tsx @@ -17,36 +17,38 @@ import * as React from 'react'; import './attachmentsTab.css'; import { ImageDiffView } from '@web/shared/imageDiffView'; -import type { ActionTraceEventInContext, MultiTraceModel } from './modelUtil'; +import type { 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'; +import { clsx, useFlash } from '@web/uiUtils'; type Attachment = AfterActionTraceEventAttachment & { traceUrl: string }; type ExpandableAttachmentProps = { attachment: Attachment; - reveal: boolean; - highlight: boolean; + reveal?: any; }; -const ExpandableAttachment: React.FunctionComponent = ({ attachment, reveal, highlight }) => { +const ExpandableAttachment: React.FunctionComponent = ({ attachment, reveal }) => { const [expanded, setExpanded] = React.useState(false); const [attachmentText, setAttachmentText] = React.useState(null); const [placeholder, setPlaceholder] = React.useState(null); + const [flash, triggerFlash] = useFlash(); const ref = React.useRef(null); const isTextAttachment = isTextualMimeType(attachment.contentType); const hasContent = !!attachment.sha1 || !!attachment.path; React.useEffect(() => { - if (reveal) + if (reveal) { ref.current?.scrollIntoView({ behavior: 'smooth' }); - }, [reveal]); + return triggerFlash(); + } + }, [reveal, triggerFlash]); React.useEffect(() => { if (expanded && attachmentText === null && placeholder === null) { @@ -66,14 +68,14 @@ const ExpandableAttachment: React.FunctionComponent = }, [attachmentText]); const title = - {linkifyText(attachment.name)} + {linkifyText(attachment.name)} {hasContent && download} ; if (!isTextAttachment || !hasContent) return
{title}
; - return <> + return
{placeholder && {placeholder}} @@ -87,14 +89,13 @@ const ExpandableAttachment: React.FunctionComponent = wrapLines={false}>
} - ; +
; }; export const AttachmentsTab: React.FunctionComponent<{ model: MultiTraceModel | undefined, - selectedAction: ActionTraceEventInContext | undefined, - revealedAttachment?: AfterActionTraceEventAttachment, -}> = ({ model, selectedAction, revealedAttachment }) => { + revealedAttachment?: [AfterActionTraceEventAttachment, number], +}> = ({ model, revealedAttachment }) => { const { diffMap, screenshots, attachments } = React.useMemo(() => { const attachments = new Set(); const screenshots = new Set(); @@ -153,8 +154,7 @@ export const AttachmentsTab: React.FunctionComponent<{ return
isEqualAttachment(a, selected)) ?? false} - reveal={!!revealedAttachment && isEqualAttachment(a, revealedAttachment)} + reveal={(!!revealedAttachment && isEqualAttachment(a, revealedAttachment[0])) ? revealedAttachment : undefined} />
; })} diff --git a/packages/trace-viewer/src/ui/workbench.tsx b/packages/trace-viewer/src/ui/workbench.tsx index c6fcd7e54c305..de59892772acb 100644 --- a/packages/trace-viewer/src/ui/workbench.tsx +++ b/packages/trace-viewer/src/ui/workbench.tsx @@ -59,7 +59,7 @@ export const Workbench: React.FunctionComponent<{ }> = ({ model, showSourcesFirst, rootDir, fallbackLocation, isLive, hideTimeline, status, annotations, inert, onOpenExternally, revealSource }) => { const [selectedCallId, setSelectedCallId] = React.useState(undefined); const [revealedError, setRevealedError] = React.useState(undefined); - const [revealedAttachment, setRevealedAttachment] = React.useState(undefined); + const [revealedAttachment, setRevealedAttachment] = React.useState<[attachment: AfterActionTraceEventAttachment, renderCounter: number] | undefined>(undefined); const [highlightedCallId, setHighlightedCallId] = React.useState(); const [highlightedEntry, setHighlightedEntry] = React.useState(); const [highlightedConsoleMessage, setHighlightedConsoleMessage] = React.useState(); @@ -148,7 +148,12 @@ export const Workbench: React.FunctionComponent<{ const revealAttachment = React.useCallback((attachment: AfterActionTraceEventAttachment) => { selectPropertiesTab('attachments'); - setRevealedAttachment(attachment); + setRevealedAttachment(currentValue => { + if (!currentValue) + return [attachment, 0]; + const revealCounter = currentValue[1]; + return [attachment, revealCounter + 1]; + }); }, [selectPropertiesTab]); React.useEffect(() => { @@ -238,7 +243,7 @@ export const Workbench: React.FunctionComponent<{ id: 'attachments', title: 'Attachments', count: attachments.length, - render: () => + render: () => }; const tabs: TabbedPaneTabModel[] = [ diff --git a/packages/web/src/uiUtils.ts b/packages/web/src/uiUtils.ts index 3544ec4bdcf75..a0b7a59c36182 100644 --- a/packages/web/src/uiUtils.ts +++ b/packages/web/src/uiUtils.ts @@ -14,6 +14,7 @@ limitations under the License. */ +import type { EffectCallback } from 'react'; import React from 'react'; // Recalculates the value when dependencies change. @@ -224,3 +225,26 @@ export function scrollIntoViewIfNeeded(element: Element | undefined) { const kControlCodesRe = '\\u0000-\\u0020\\u007f-\\u009f'; export const kWebLinkRe = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + kControlCodesRe + '"]{2,}[^\\s' + kControlCodesRe + '"\')}\\],:;.!?]', 'ug'); + +/** + * Manages flash animation state. + * Calling `trigger` will turn `flash` to true for a second, and then back to false. + * If `trigger` is called while a flash is ongoing, the ongoing flash will be cancelled and after 50ms a new flash is started. + * @returns [flash, trigger] + */ +export function useFlash(): [boolean, EffectCallback] { + const [flash, setFlash] = React.useState(false); + const trigger = React.useCallback(() => { + const timeouts: any[] = []; + setFlash(currentlyFlashing => { + timeouts.push(setTimeout(() => setFlash(false), 1000)); + if (!currentlyFlashing) + return true; + + timeouts.push(setTimeout(() => setFlash(true), 50)); + return false; + }); + return () => timeouts.forEach(clearTimeout); + }, [setFlash]); + return [flash, trigger]; +} diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 2640cb61c9d7e..57ef76a7ca0fb 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -959,10 +959,9 @@ for (const useIntermediateMergeReport of [true, false] as const) { await showReport(); await page.getByRole('link', { name: 'passing' }).click(); - const attachment = page.getByTestId('attachments').getByText('foo-2', { exact: true }); + const attachment = page.getByText('foo-2', { exact: true }); await expect(attachment).not.toBeInViewport(); - await page.getByLabel('attach "foo-2"').click(); - await page.getByTitle('see "foo-2"').click(); + await page.getByLabel(`attach "foo-2"`).getByTitle('reveal attachment').click(); await expect(attachment).toBeInViewport(); await page.reload(); @@ -989,10 +988,9 @@ for (const useIntermediateMergeReport of [true, false] as const) { await showReport(); await page.getByRole('link', { name: 'passing' }).click(); - const attachment = page.getByTestId('attachments').getByText('attachment', { exact: true }); + const attachment = page.getByText('attachment', { exact: true }); await expect(attachment).not.toBeInViewport(); - await page.getByLabel('step').click(); - await page.getByTitle('see "attachment"').click(); + await page.getByLabel('step').getByTitle('reveal attachment').click(); await expect(attachment).toBeInViewport(); });