Skip to content

Commit

Permalink
feat(html): link from attachment step to attachment (#33267)
Browse files Browse the repository at this point in the history
  • Loading branch information
Skn0tt authored Dec 16, 2024
1 parent 6270918 commit 512cb36
Show file tree
Hide file tree
Showing 8 changed files with 117 additions and 58 deletions.
4 changes: 2 additions & 2 deletions packages/html-reporter/src/chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import './colors.css';
import './common.css';
import * as icons from './icons';
import { clsx } from '@web/uiUtils';
import { useAnchor } from './links';
import { type AnchorID, useAnchor } from './links';

export const Chip: React.FC<{
header: JSX.Element | string,
Expand Down Expand Up @@ -53,7 +53,7 @@ export const AutoChip: React.FC<{
noInsets?: boolean,
children?: any,
dataTestId?: string,
revealOnAnchorId?: string,
revealOnAnchorId?: AnchorID,
}> = ({ header, initialExpanded, noInsets, children, dataTestId, revealOnAnchorId }) => {
const [expanded, setExpanded] = React.useState(initialExpanded ?? true);
const onReveal = React.useCallback(() => setExpanded(true), []);
Expand Down
50 changes: 34 additions & 16 deletions packages/html-reporter/src/links.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
limitations under the License.
*/

import type { TestAttachment } from './types';
import type { TestAttachment, TestCase, TestCaseSummary, TestResult, TestResultSummary } from './types';
import * as React from 'react';
import * as icons from './icons';
import { TreeItem } from './treeItem';
Expand Down Expand Up @@ -72,6 +72,7 @@ export const AttachmentLink: React.FunctionComponent<{
linkName?: string,
openInNewTab?: boolean,
}> = ({ attachment, href, linkName, openInNewTab }) => {
const isAnchored = useIsAnchored('attachment-' + attachment.name);
return <TreeItem title={<span>
{attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()}
{attachment.path && <a href={href || attachment.path} download={downloadFileNameForAttachment(attachment)}>{linkName || attachment.name}</a>}
Expand All @@ -82,7 +83,7 @@ export const AttachmentLink: React.FunctionComponent<{
)}
</span>} loadChildren={attachment.body ? () => {
return [<div key={1} className='attachment-body'><CopyToClipboard value={attachment.body!}/>{linkifyText(attachment.body!)}</div>];
} : undefined} depth={0} style={{ lineHeight: '32px' }}></TreeItem>;
} : undefined} depth={0} style={{ lineHeight: '32px' }} selected={isAnchored}></TreeItem>;
};

export const SearchParamsContext = React.createContext<URLSearchParams>(new URLSearchParams(window.location.hash.slice(1)));
Expand Down Expand Up @@ -114,23 +115,29 @@ export function generateTraceUrl(traces: TestAttachment[]) {

const kMissingContentType = 'x-playwright/missing';

type AnchorID = string | ((id: string | null) => boolean) | undefined;
export type AnchorID = string | string[] | ((id: string) => boolean) | undefined;

export function useAnchor(id: AnchorID, onReveal: () => void) {
const searchParams = React.useContext(SearchParamsContext);
const isAnchored = useIsAnchored(id);
React.useEffect(() => {
if (typeof id === 'undefined')
return;

const listener = () => {
const params = new URLSearchParams(window.location.hash.slice(1));
const anchor = params.get('anchor');
const isRevealed = typeof id === 'function' ? id(anchor) : anchor === id;
if (isRevealed)
onReveal();
};
window.addEventListener('popstate', listener);
return () => window.removeEventListener('popstate', listener);
}, [id, onReveal]);
if (isAnchored)
onReveal();
}, [isAnchored, onReveal, searchParams]);
}

export function useIsAnchored(id: AnchorID) {
const searchParams = React.useContext(SearchParamsContext);
const anchor = searchParams.get('anchor');
if (anchor === null)
return false;
if (typeof id === 'undefined')
return false;
if (typeof id === 'string')
return id === anchor;
if (Array.isArray(id))
return id.includes(anchor);
return id(anchor);
}

export function Anchor({ id, children }: React.PropsWithChildren<{ id: AnchorID }>) {
Expand All @@ -142,3 +149,14 @@ export function Anchor({ id, children }: React.PropsWithChildren<{ id: AnchorID

return <div ref={ref}>{children}</div>;
}

export function testResultHref({ test, result, anchor }: { test?: TestCase | TestCaseSummary, result?: TestResult | TestResultSummary, anchor?: string }) {
const params = new URLSearchParams();
if (test)
params.set('testId', test.testId);
if (test && result)
params.set('run', '' + test.results.indexOf(result as any));
if (anchor)
params.set('anchor', anchor);
return `#?` + params;
}
6 changes: 3 additions & 3 deletions packages/html-reporter/src/testCaseView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import * as React from 'react';
import { TabbedPane } from './tabbedPane';
import { AutoChip } from './chip';
import './common.css';
import { Link, ProjectLink, SearchParamsContext } from './links';
import { Link, ProjectLink, SearchParamsContext, testResultHref } from './links';
import { statusIcon } from './statusIcon';
import './testCaseView.css';
import { TestResultView } from './testResultView';
Expand Down Expand Up @@ -53,9 +53,9 @@ export const TestCaseView: React.FC<{
{test && <div className='hbox'>
<div className='test-case-path'>{test.path.join(' › ')}</div>
<div style={{ flex: 'auto' }}></div>
<div className={clsx(!prev && 'hidden')}><Link href={`#?testId=${prev?.testId}${filterParam}`}>« previous</Link></div>
<div className={clsx(!prev && 'hidden')}><Link href={testResultHref({ test: prev }) + filterParam}>« previous</Link></div>
<div style={{ width: 10 }}></div>
<div className={clsx(!next && 'hidden')}><Link href={`#?testId=${next?.testId}${filterParam}`}>next »</Link></div>
<div className={clsx(!next && 'hidden')}><Link href={testResultHref({ test: next }) + filterParam}>next »</Link></div>
</div>}
{test && <div className='test-case-title'>{test?.title}</div>}
{test && <div className='hbox'>
Expand Down
18 changes: 10 additions & 8 deletions packages/html-reporter/src/testFileView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import * as React from 'react';
import { hashStringToInt, msToString } from './utils';
import { Chip } from './chip';
import { filterWithToken } from './filter';
import { generateTraceUrl, Link, navigate, ProjectLink, SearchParamsContext } from './links';
import { generateTraceUrl, Link, navigate, ProjectLink, SearchParamsContext, testResultHref } from './links';
import { statusIcon } from './statusIcon';
import './testFileView.css';
import { video, image, trace } from './icons';
Expand Down Expand Up @@ -48,7 +48,7 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
{statusIcon(test.outcome)}
</span>
<span>
<Link href={`#?testId=${test.testId}${filterParam}`} title={[...test.path, test.title].join(' › ')}>
<Link href={testResultHref({ test }) + filterParam} title={[...test.path, test.title].join(' › ')}>
<span className='test-file-title'>{[...test.path, test.title].join(' › ')}</span>
</Link>
{projectNames.length > 1 && !!test.projectName &&
Expand All @@ -59,7 +59,7 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
<span data-testid='test-duration' style={{ minWidth: '50px', textAlign: 'right' }}>{msToString(test.duration)}</span>
</div>
<div className='test-file-details-row'>
<Link href={`#?testId=${test.testId}`} title={[...test.path, test.title].join(' › ')} className='test-file-path-link'>
<Link href={testResultHref({ test })} title={[...test.path, test.title].join(' › ')} className='test-file-path-link'>
<span className='test-file-path'>{test.location.file}:{test.location.line}</span>
</Link>
{imageDiffBadge(test)}
Expand All @@ -72,15 +72,17 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
};

function imageDiffBadge(test: TestCaseSummary): JSX.Element | undefined {
const resultWithImageDiff = test.results.find(result => result.attachments.some(attachment => {
return attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/);
}));
return resultWithImageDiff ? <Link href={`#?testId=${test.testId}&anchor=diff-0&run=${test.results.indexOf(resultWithImageDiff)}`} title='View images' className='test-file-badge'>{image()}</Link> : undefined;
for (const result of test.results) {
for (const attachment of result.attachments) {
if (attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/))
return <Link href={testResultHref({ test, result, anchor: `attachment-${attachment.name}` })} title='View images' className='test-file-badge'>{image()}</Link>;
}
}
}

function videoBadge(test: TestCaseSummary): JSX.Element | undefined {
const resultWithVideo = test.results.find(result => result.attachments.some(attachment => attachment.name === 'video'));
return resultWithVideo ? <Link href={`#?testId=${test.testId}&anchor=videos&run=${test.results.indexOf(resultWithVideo)}`} title='View video' className='test-file-badge'>{video()}</Link> : undefined;
return resultWithVideo ? <Link href={testResultHref({ test, result: resultWithVideo, anchor: 'attachment-video' })} title='View video' className='test-file-badge'>{video()}</Link> : undefined;
}

function traceBadge(test: TestCaseSummary): JSX.Element | undefined {
Expand Down
64 changes: 38 additions & 26 deletions packages/html-reporter/src/testResultView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,20 @@ import { TreeItem } from './treeItem';
import { msToString } from './utils';
import { AutoChip } from './chip';
import { traceImage } from './images';
import { Anchor, AttachmentLink, generateTraceUrl } from './links';
import { Anchor, AttachmentLink, generateTraceUrl, testResultHref } from './links';
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<TestAttachment>): ImageDiff[] {
const snapshotNameToImageDiff = new Map<string, ImageDiff>();
interface ImageDiffWithAnchors extends ImageDiff {
anchors: string[];
}

function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiffWithAnchors[] {
const snapshotNameToImageDiff = new Map<string, ImageDiffWithAnchors>();
for (const attachment of screenshots) {
const match = attachment.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/);
if (!match)
Expand All @@ -37,9 +42,10 @@ function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiff[] {
const snapshotName = name + extension;
let imageDiff = snapshotNameToImageDiff.get(snapshotName);
if (!imageDiff) {
imageDiff = { name: snapshotName };
imageDiff = { name: snapshotName, anchors: [`attachment-${name}`] };
snapshotNameToImageDiff.set(snapshotName, imageDiff);
}
imageDiff.anchors.push(`attachment-${attachment.name}`);
if (category === 'actual')
imageDiff.actual = { attachment };
if (category === 'expected')
Expand All @@ -64,18 +70,19 @@ function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiff[] {
export const TestResultView: React.FC<{
test: TestCase,
result: TestResult,
}> = ({ result }) => {
const { screenshots, videos, traces, otherAttachments, diffs, errors, htmls } = React.useMemo(() => {
}> = ({ test, result }) => {
const { screenshots, videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors } = React.useMemo(() => {
const attachments = result?.attachments || [];
const screenshots = new Set(attachments.filter(a => a.contentType.startsWith('image/')));
const screenshotAnchors = [...screenshots].map(a => `attachment-${a.name}`);
const videos = attachments.filter(a => a.contentType.startsWith('video/'));
const traces = attachments.filter(a => a.name === 'trace');
const htmls = attachments.filter(a => a.contentType.startsWith('text/html'));
const otherAttachments = new Set<TestAttachment>(attachments);
[...screenshots, ...videos, ...traces, ...htmls].forEach(a => otherAttachments.delete(a));
[...screenshots, ...videos, ...traces].forEach(a => otherAttachments.delete(a));
const otherAttachmentAnchors = [...otherAttachments].map(a => `attachment-${a.name}`);
const diffs = groupImageDiffs(screenshots);
const errors = classifyErrors(result.errors, diffs);
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, htmls };
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors };
}, [result]);

return <div className='test-result'>
Expand All @@ -87,29 +94,29 @@ export const TestResultView: React.FC<{
})}
</AutoChip>}
{!!result.steps.length && <AutoChip header='Test Steps'>
{result.steps.map((step, i) => <StepTreeItem key={`step-${i}`} step={step} depth={0}></StepTreeItem>)}
{result.steps.map((step, i) => <StepTreeItem key={`step-${i}`} step={step} result={result} test={test} depth={0}/>)}
</AutoChip>}

{diffs.map((diff, index) =>
<Anchor key={`diff-${index}`} id={`diff-${index}`}>
<AutoChip dataTestId='test-results-image-diff' header={`Image mismatch: ${diff.name}`} revealOnAnchorId={`diff-${index}`}>
<Anchor key={`diff-${index}`} id={diff.anchors}>
<AutoChip dataTestId='test-results-image-diff' header={`Image mismatch: ${diff.name}`} revealOnAnchorId={diff.anchors}>
<ImageDiffView diff={diff}/>
</AutoChip>
</Anchor>
)}

{!!screenshots.length && <AutoChip header='Screenshots'>
{!!screenshots.length && <AutoChip header='Screenshots' revealOnAnchorId={screenshotAnchors}>
{screenshots.map((a, i) => {
return <div key={`screenshot-${i}`}>
return <Anchor key={`screenshot-${i}`} id={`attachment-${a.name}`}>
<a href={a.path}>
<img className='screenshot' src={a.path} />
</a>
<AttachmentLink attachment={a}></AttachmentLink>
</div>;
</Anchor>;
})}
</AutoChip>}

{!!traces.length && <Anchor id='traces'><AutoChip header='Traces' revealOnAnchorId='traces'>
{!!traces.length && <Anchor id='attachment-trace'><AutoChip header='Traces' revealOnAnchorId='attachment-trace'>
{<div>
<a href={generateTraceUrl(traces)}>
<img className='screenshot' src={traceImage} style={{ width: 192, height: 117, marginLeft: 20 }} />
Expand All @@ -118,7 +125,7 @@ export const TestResultView: React.FC<{
</div>}
</AutoChip></Anchor>}

{!!videos.length && <Anchor id='videos'><AutoChip header='Videos' revealOnAnchorId='videos'>
{!!videos.length && <Anchor id='attachment-video'><AutoChip header='Videos' revealOnAnchorId='attachment-video'>
{videos.map((a, i) => <div key={`video-${i}`}>
<video controls>
<source src={a.path} type={a.contentType}/>
Expand All @@ -127,11 +134,12 @@ export const TestResultView: React.FC<{
</div>)}
</AutoChip></Anchor>}

{!!(otherAttachments.size + htmls.length) && <AutoChip header='Attachments'>
{[...htmls].map((a, i) => (
<AttachmentLink key={`html-link-${i}`} attachment={a} openInNewTab />)
{!!otherAttachments.size && <AutoChip header='Attachments' revealOnAnchorId={otherAttachmentAnchors}>
{[...otherAttachments].map((a, i) =>
<Anchor key={`attachment-link-${i}`} id={`attachment-${a.name}`}>
<AttachmentLink attachment={a} openInNewTab={a.contentType.startsWith('text/html')} />
</Anchor>
)}
{[...otherAttachments].map((a, i) => <AttachmentLink key={`attachment-link-${i}`} attachment={a}></AttachmentLink>)}
</AutoChip>}
</div>;
};
Expand Down Expand Up @@ -161,19 +169,23 @@ function classifyErrors(testErrors: string[], diffs: ImageDiff[]) {
}

const StepTreeItem: React.FC<{
test: TestCase;
result: TestResult;
step: TestStep;
depth: number,
}> = ({ step, depth }) => {
return <TreeItem title={<span>
}> = ({ test, step, result, depth }) => {
const attachmentName = step.title.match(/^attach "(.*)"$/)?.[1];
return <TreeItem title={<span aria-label={step.title}>
<span style={{ float: 'right' }}>{msToString(step.duration)}</span>
{attachmentName && <a style={{ float: 'right' }} title='link to attachment' href={testResultHref({ test, result, anchor: `attachment-${attachmentName}` })} onClick={evt => { evt.stopPropagation(); }}>{icons.attachment()}</a>}
{statusIcon(step.error || step.duration === -1 ? 'failed' : 'passed')}
<span>{step.title}</span>
{step.count > 1 && <><span className='test-result-counter'>{step.count}</span></>}
{step.location && <span className='test-result-path'>{step.location.file}:{step.location.line}</span>}
</span>} loadChildren={step.steps.length + (step.snippet ? 1 : 0) ? () => {
const children = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1}></StepTreeItem>);
const children = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1} result={result} test={test} />);
if (step.snippet)
children.unshift(<TestErrorView testId='test-snippet' key='line' error={step.snippet}></TestErrorView>);
children.unshift(<TestErrorView testId='test-snippet' key='line' error={step.snippet}/>);
return children;
} : undefined} depth={depth}></TreeItem>;
} : undefined} depth={depth}/>;
};
5 changes: 5 additions & 0 deletions packages/html-reporter/src/treeItem.css
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
cursor: pointer;
}

.tree-item-title.selected {
text-decoration: underline var(--color-underlinenav-icon);
text-decoration-thickness: 1.5px;
}

.tree-item-body {
min-height: 18px;
}
4 changes: 2 additions & 2 deletions packages/html-reporter/src/treeItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import * as React from 'react';
import './treeItem.css';
import * as icons from './icons';
import { clsx } from '@web/uiUtils';

export const TreeItem: React.FunctionComponent<{
title: JSX.Element,
Expand All @@ -28,9 +29,8 @@ export const TreeItem: React.FunctionComponent<{
style?: React.CSSProperties,
}> = ({ title, loadChildren, onClick, expandByDefault, depth, selected, style }) => {
const [expanded, setExpanded] = React.useState(expandByDefault || false);
const className = selected ? 'tree-item-title selected' : 'tree-item-title';
return <div className={'tree-item'} style={style}>
<span className={className} style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} >
<span className={clsx('tree-item-title', selected && 'selected')} style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} >
{loadChildren && !!expanded && icons.downArrow()}
{loadChildren && !expanded && icons.rightArrow()}
{!loadChildren && <span style={{ visibility: 'hidden' }}>{icons.rightArrow()}</span>}
Expand Down
24 changes: 23 additions & 1 deletion tests/playwright-test/reporter-html.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -847,7 +847,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: '<h1>Axe Report</h1>',
});
Expand Down Expand Up @@ -916,6 +916,28 @@ 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) => {
for (let i = 0; i < 100; i++)
await testInfo.attach('foo-1', { body: 'bar' });
await testInfo.attach('foo-2', { body: 'bar' });
});
`,
}, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' });
expect(result.exitCode).toBe(0);

await showReport();
await page.getByRole('link', { name: 'passing' }).click();

const attachment = page.getByText('foo-2', { exact: true });
await expect(attachment).not.toBeInViewport();
await page.getByLabel('attach "foo-2"').getByTitle('link to attachment').click();
await expect(attachment).toBeInViewport();
});

test('should highlight textual diff', async ({ runInlineTest, showReport, page }) => {
const result = await runInlineTest({
'helper.ts': `
Expand Down

0 comments on commit 512cb36

Please sign in to comment.