diff --git a/packages/html-reporter/src/links.tsx b/packages/html-reporter/src/links.tsx index 8c1dcc85dc..fc2b83ff73 100644 --- a/packages/html-reporter/src/links.tsx +++ b/packages/html-reporter/src/links.tsx @@ -22,6 +22,7 @@ import { CopyToClipboard } from './copyToClipboard'; import './links.css'; import { linkifyText } from '@web/renderUtils'; import { clsx } from '@web/uiUtils'; +import { componentID } from './testResultView'; export function navigate(href: string) { window.history.pushState({}, '', href); @@ -77,7 +78,7 @@ export const AttachmentLink: React.FunctionComponent<{ linkName?: string, openInNewTab?: boolean, }> = ({ attachment, href, linkName, openInNewTab }) => { - return + return params.set('attachment', attachment.name))} title={ {attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()} {attachment.path && {linkName || attachment.name}} {!attachment.path && ( diff --git a/packages/html-reporter/src/testCaseView.spec.tsx b/packages/html-reporter/src/testCaseView.spec.tsx index 7c9c99eeb3..b36cfbb671 100644 --- a/packages/html-reporter/src/testCaseView.spec.tsx +++ b/packages/html-reporter/src/testCaseView.spec.tsx @@ -27,12 +27,14 @@ const result: TestResult = { errors: [], steps: [{ title: 'Outer step', + category: 'test.step', startTime: new Date(100).toUTCString(), duration: 10, location: { file: 'test.spec.ts', line: 62, column: 0 }, count: 1, steps: [{ title: 'Inner step', + category: 'test.step', startTime: new Date(200).toUTCString(), duration: 10, location: { file: 'test.spec.ts', line: 82, column: 0 }, @@ -134,6 +136,7 @@ const resultWithAttachment: TestResult = { errors: [], steps: [{ title: 'Outer step', + category: 'test.step', startTime: new Date(100).toUTCString(), duration: 10, location: { file: 'test.spec.ts', line: 62, column: 0 }, diff --git a/packages/html-reporter/src/testResultView.tsx b/packages/html-reporter/src/testResultView.tsx index bb18422dd0..7c8bffc209 100644 --- a/packages/html-reporter/src/testResultView.tsx +++ b/packages/html-reporter/src/testResultView.tsx @@ -25,6 +25,7 @@ import { statusIcon } from './statusIcon'; import type { ImageDiff } from '@web/shared/imageDiffView'; import { ImageDiffView } from '@web/shared/imageDiffView'; import { TestErrorView, TestScreenshotErrorView } from './testErrorView'; +import * as icons from './icons'; import './testResultView.css'; function groupImageDiffs(screenshots: Set): ImageDiff[] { @@ -173,12 +174,20 @@ function classifyErrors(testErrors: string[], diffs: ImageDiff[]) { }); } +export function componentID(cb: (params: URLSearchParams) => void) { + const searchParams = new URLSearchParams(window.location.hash.slice(1)); + cb(searchParams); + return '?' + searchParams; +} + const StepTreeItem: React.FC<{ step: TestStep; depth: number, }> = ({ step, depth }) => { - return + const attachmentName = step.category === 'attach' ? step.title.match(/^attach "(.*)"$/)?.[1] : undefined; + return {msToString(step.duration)} + {attachmentName && params.set('attachment', attachmentName))} onClick={(evt) => { evt.stopPropagation(); }}>{icons.attachment()}} {statusIcon(step.error || step.duration === -1 ? 'failed' : 'passed')} {step.title} {step.count > 1 && <> ✕ {step.count}} diff --git a/packages/html-reporter/src/treeItem.css b/packages/html-reporter/src/treeItem.css index a8cedc4f6a..b111b543a1 100644 --- a/packages/html-reporter/src/treeItem.css +++ b/packages/html-reporter/src/treeItem.css @@ -25,6 +25,11 @@ cursor: pointer; } +.tree-item:target > .tree-item-title { + text-decoration: underline var(--color-underlinenav-icon); + text-decoration-thickness: 1.5px; +} + .tree-item-body { min-height: 18px; } diff --git a/packages/html-reporter/src/treeItem.tsx b/packages/html-reporter/src/treeItem.tsx index 507a9c0e71..735dfc09ae 100644 --- a/packages/html-reporter/src/treeItem.tsx +++ b/packages/html-reporter/src/treeItem.tsx @@ -26,10 +26,11 @@ export const TreeItem: React.FunctionComponent<{ depth: number, selected?: boolean, style?: React.CSSProperties, -}> = ({ title, loadChildren, onClick, expandByDefault, depth, selected, style }) => { + id?: string, +}> = ({ title, loadChildren, onClick, expandByDefault, depth, selected, style, id }) => { const [expanded, setExpanded] = React.useState(expandByDefault || false); const className = selected ? 'tree-item-title selected' : 'tree-item-title'; - return
+ return
{ onClick?.(); setExpanded(!expanded); }} > {loadChildren && !!expanded && icons.downArrow()} {loadChildren && !expanded && icons.rightArrow()} diff --git a/packages/html-reporter/src/types.ts b/packages/html-reporter/src/types.ts index 733e88e8b9..ea2cf453bc 100644 --- a/packages/html-reporter/src/types.ts +++ b/packages/html-reporter/src/types.ts @@ -102,6 +102,7 @@ export type TestResult = { export type TestStep = { title: string; + category: 'hook' | 'fixture' | 'test.step' | 'expect' | 'attach' | string; startTime: string; duration: number; location?: Location; diff --git a/packages/playwright/src/reporters/html.ts b/packages/playwright/src/reporters/html.ts index 584c11bae8..776a4ae69a 100644 --- a/packages/playwright/src/reporters/html.ts +++ b/packages/playwright/src/reporters/html.ts @@ -498,6 +498,7 @@ class HtmlBuilder { const { step, duration, count } = dedupedStep; const result: TestStep = { title: step.title, + category: step.category, startTime: step.startTime.toISOString(), duration, steps: dedupeSteps(step.steps).map(s => this._createTestStep(s)), diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 11169fadff..dc5da7e35e 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -845,7 +845,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { 'a.test.js': ` import { test, expect } from '@playwright/test'; test('passing', async ({ page }, testInfo) => { - testInfo.attach('axe-report.html', { + await testInfo.attach('axe-report.html', { contentType: 'text/html', body: '

Axe Report

', }); @@ -914,6 +914,27 @@ for (const useIntermediateMergeReport of [true, false] as const) { ])); }); + test('should link from attach step to attachment view', async ({ runInlineTest, page, showReport }) => { + const result = await runInlineTest({ + 'a.test.js': ` + import { test, expect } from '@playwright/test'; + test('passing', async ({ page }, testInfo) => { + await testInfo.attach('foo', { body: 'bar' }); + }); + `, + }, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' }); + expect(result.exitCode).toBe(0); + + await showReport(); + await page.getByRole('link', { name: 'passing' }).click(); + await page.getByLabel('attach "foo"').getByTitle('link to attachment').click(); + + await page.waitForURL(url => { + const navState = new URLSearchParams(url.hash.slice(1)); + return navState.get('attachment') === 'foo'; + }); + }); + test('should strikethrough textual diff', async ({ runInlineTest, showReport, page }) => { const result = await runInlineTest({ 'helper.ts': `