feat(html): link from attachment step to attachment (#33267)
This commit is contained in:
parent
6270918f67
commit
512cb36c9b
|
|
@ -20,7 +20,7 @@ import './colors.css';
|
||||||
import './common.css';
|
import './common.css';
|
||||||
import * as icons from './icons';
|
import * as icons from './icons';
|
||||||
import { clsx } from '@web/uiUtils';
|
import { clsx } from '@web/uiUtils';
|
||||||
import { useAnchor } from './links';
|
import { type AnchorID, useAnchor } from './links';
|
||||||
|
|
||||||
export const Chip: React.FC<{
|
export const Chip: React.FC<{
|
||||||
header: JSX.Element | string,
|
header: JSX.Element | string,
|
||||||
|
|
@ -53,7 +53,7 @@ export const AutoChip: React.FC<{
|
||||||
noInsets?: boolean,
|
noInsets?: boolean,
|
||||||
children?: any,
|
children?: any,
|
||||||
dataTestId?: string,
|
dataTestId?: string,
|
||||||
revealOnAnchorId?: string,
|
revealOnAnchorId?: AnchorID,
|
||||||
}> = ({ header, initialExpanded, noInsets, children, dataTestId, revealOnAnchorId }) => {
|
}> = ({ header, initialExpanded, noInsets, children, dataTestId, revealOnAnchorId }) => {
|
||||||
const [expanded, setExpanded] = React.useState(initialExpanded ?? true);
|
const [expanded, setExpanded] = React.useState(initialExpanded ?? true);
|
||||||
const onReveal = React.useCallback(() => setExpanded(true), []);
|
const onReveal = React.useCallback(() => setExpanded(true), []);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
limitations under the License.
|
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 React from 'react';
|
||||||
import * as icons from './icons';
|
import * as icons from './icons';
|
||||||
import { TreeItem } from './treeItem';
|
import { TreeItem } from './treeItem';
|
||||||
|
|
@ -72,6 +72,7 @@ export const AttachmentLink: React.FunctionComponent<{
|
||||||
linkName?: string,
|
linkName?: string,
|
||||||
openInNewTab?: boolean,
|
openInNewTab?: boolean,
|
||||||
}> = ({ attachment, href, linkName, openInNewTab }) => {
|
}> = ({ attachment, href, linkName, openInNewTab }) => {
|
||||||
|
const isAnchored = useIsAnchored('attachment-' + attachment.name);
|
||||||
return <TreeItem title={<span>
|
return <TreeItem title={<span>
|
||||||
{attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()}
|
{attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()}
|
||||||
{attachment.path && <a href={href || attachment.path} download={downloadFileNameForAttachment(attachment)}>{linkName || attachment.name}</a>}
|
{attachment.path && <a href={href || attachment.path} download={downloadFileNameForAttachment(attachment)}>{linkName || attachment.name}</a>}
|
||||||
|
|
@ -82,7 +83,7 @@ export const AttachmentLink: React.FunctionComponent<{
|
||||||
)}
|
)}
|
||||||
</span>} loadChildren={attachment.body ? () => {
|
</span>} loadChildren={attachment.body ? () => {
|
||||||
return [<div key={1} className='attachment-body'><CopyToClipboard value={attachment.body!}/>{linkifyText(attachment.body!)}</div>];
|
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)));
|
export const SearchParamsContext = React.createContext<URLSearchParams>(new URLSearchParams(window.location.hash.slice(1)));
|
||||||
|
|
@ -114,23 +115,29 @@ export function generateTraceUrl(traces: TestAttachment[]) {
|
||||||
|
|
||||||
const kMissingContentType = 'x-playwright/missing';
|
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) {
|
export function useAnchor(id: AnchorID, onReveal: () => void) {
|
||||||
|
const searchParams = React.useContext(SearchParamsContext);
|
||||||
|
const isAnchored = useIsAnchored(id);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (typeof id === 'undefined')
|
if (isAnchored)
|
||||||
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();
|
onReveal();
|
||||||
};
|
}, [isAnchored, onReveal, searchParams]);
|
||||||
window.addEventListener('popstate', listener);
|
}
|
||||||
return () => window.removeEventListener('popstate', listener);
|
|
||||||
}, [id, onReveal]);
|
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 }>) {
|
export function Anchor({ id, children }: React.PropsWithChildren<{ id: AnchorID }>) {
|
||||||
|
|
@ -142,3 +149,14 @@ export function Anchor({ id, children }: React.PropsWithChildren<{ id: AnchorID
|
||||||
|
|
||||||
return <div ref={ref}>{children}</div>;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import * as React from 'react';
|
||||||
import { TabbedPane } from './tabbedPane';
|
import { TabbedPane } from './tabbedPane';
|
||||||
import { AutoChip } from './chip';
|
import { AutoChip } from './chip';
|
||||||
import './common.css';
|
import './common.css';
|
||||||
import { Link, ProjectLink, SearchParamsContext } from './links';
|
import { Link, ProjectLink, SearchParamsContext, testResultHref } from './links';
|
||||||
import { statusIcon } from './statusIcon';
|
import { statusIcon } from './statusIcon';
|
||||||
import './testCaseView.css';
|
import './testCaseView.css';
|
||||||
import { TestResultView } from './testResultView';
|
import { TestResultView } from './testResultView';
|
||||||
|
|
@ -53,9 +53,9 @@ export const TestCaseView: React.FC<{
|
||||||
{test && <div className='hbox'>
|
{test && <div className='hbox'>
|
||||||
<div className='test-case-path'>{test.path.join(' › ')}</div>
|
<div className='test-case-path'>{test.path.join(' › ')}</div>
|
||||||
<div style={{ flex: 'auto' }}></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 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>}
|
</div>}
|
||||||
{test && <div className='test-case-title'>{test?.title}</div>}
|
{test && <div className='test-case-title'>{test?.title}</div>}
|
||||||
{test && <div className='hbox'>
|
{test && <div className='hbox'>
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import * as React from 'react';
|
||||||
import { hashStringToInt, msToString } from './utils';
|
import { hashStringToInt, msToString } from './utils';
|
||||||
import { Chip } from './chip';
|
import { Chip } from './chip';
|
||||||
import { filterWithToken } from './filter';
|
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 { statusIcon } from './statusIcon';
|
||||||
import './testFileView.css';
|
import './testFileView.css';
|
||||||
import { video, image, trace } from './icons';
|
import { video, image, trace } from './icons';
|
||||||
|
|
@ -48,7 +48,7 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
|
||||||
{statusIcon(test.outcome)}
|
{statusIcon(test.outcome)}
|
||||||
</span>
|
</span>
|
||||||
<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>
|
<span className='test-file-title'>{[...test.path, test.title].join(' › ')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
{projectNames.length > 1 && !!test.projectName &&
|
{projectNames.length > 1 && !!test.projectName &&
|
||||||
|
|
@ -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>
|
<span data-testid='test-duration' style={{ minWidth: '50px', textAlign: 'right' }}>{msToString(test.duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='test-file-details-row'>
|
<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>
|
<span className='test-file-path'>{test.location.file}:{test.location.line}</span>
|
||||||
</Link>
|
</Link>
|
||||||
{imageDiffBadge(test)}
|
{imageDiffBadge(test)}
|
||||||
|
|
@ -72,15 +72,17 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
|
||||||
};
|
};
|
||||||
|
|
||||||
function imageDiffBadge(test: TestCaseSummary): JSX.Element | undefined {
|
function imageDiffBadge(test: TestCaseSummary): JSX.Element | undefined {
|
||||||
const resultWithImageDiff = test.results.find(result => result.attachments.some(attachment => {
|
for (const result of test.results) {
|
||||||
return attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/);
|
for (const attachment of result.attachments) {
|
||||||
}));
|
if (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;
|
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 {
|
function videoBadge(test: TestCaseSummary): JSX.Element | undefined {
|
||||||
const resultWithVideo = test.results.find(result => result.attachments.some(attachment => attachment.name === 'video'));
|
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 {
|
function traceBadge(test: TestCaseSummary): JSX.Element | undefined {
|
||||||
|
|
|
||||||
|
|
@ -20,15 +20,20 @@ import { TreeItem } from './treeItem';
|
||||||
import { msToString } from './utils';
|
import { msToString } from './utils';
|
||||||
import { AutoChip } from './chip';
|
import { AutoChip } from './chip';
|
||||||
import { traceImage } from './images';
|
import { traceImage } from './images';
|
||||||
import { Anchor, AttachmentLink, generateTraceUrl } from './links';
|
import { Anchor, AttachmentLink, generateTraceUrl, testResultHref } from './links';
|
||||||
import { statusIcon } from './statusIcon';
|
import { statusIcon } from './statusIcon';
|
||||||
import type { ImageDiff } from '@web/shared/imageDiffView';
|
import type { ImageDiff } from '@web/shared/imageDiffView';
|
||||||
import { ImageDiffView } from '@web/shared/imageDiffView';
|
import { ImageDiffView } from '@web/shared/imageDiffView';
|
||||||
import { TestErrorView, TestScreenshotErrorView } from './testErrorView';
|
import { TestErrorView, TestScreenshotErrorView } from './testErrorView';
|
||||||
|
import * as icons from './icons';
|
||||||
import './testResultView.css';
|
import './testResultView.css';
|
||||||
|
|
||||||
function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiff[] {
|
interface ImageDiffWithAnchors extends ImageDiff {
|
||||||
const snapshotNameToImageDiff = new Map<string, ImageDiff>();
|
anchors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiffWithAnchors[] {
|
||||||
|
const snapshotNameToImageDiff = new Map<string, ImageDiffWithAnchors>();
|
||||||
for (const attachment of screenshots) {
|
for (const attachment of screenshots) {
|
||||||
const match = attachment.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/);
|
const match = attachment.name.match(/^(.*)-(expected|actual|diff|previous)(\.[^.]+)?$/);
|
||||||
if (!match)
|
if (!match)
|
||||||
|
|
@ -37,9 +42,10 @@ function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiff[] {
|
||||||
const snapshotName = name + extension;
|
const snapshotName = name + extension;
|
||||||
let imageDiff = snapshotNameToImageDiff.get(snapshotName);
|
let imageDiff = snapshotNameToImageDiff.get(snapshotName);
|
||||||
if (!imageDiff) {
|
if (!imageDiff) {
|
||||||
imageDiff = { name: snapshotName };
|
imageDiff = { name: snapshotName, anchors: [`attachment-${name}`] };
|
||||||
snapshotNameToImageDiff.set(snapshotName, imageDiff);
|
snapshotNameToImageDiff.set(snapshotName, imageDiff);
|
||||||
}
|
}
|
||||||
|
imageDiff.anchors.push(`attachment-${attachment.name}`);
|
||||||
if (category === 'actual')
|
if (category === 'actual')
|
||||||
imageDiff.actual = { attachment };
|
imageDiff.actual = { attachment };
|
||||||
if (category === 'expected')
|
if (category === 'expected')
|
||||||
|
|
@ -64,18 +70,19 @@ function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiff[] {
|
||||||
export const TestResultView: React.FC<{
|
export const TestResultView: React.FC<{
|
||||||
test: TestCase,
|
test: TestCase,
|
||||||
result: TestResult,
|
result: TestResult,
|
||||||
}> = ({ result }) => {
|
}> = ({ test, result }) => {
|
||||||
const { screenshots, videos, traces, otherAttachments, diffs, errors, htmls } = React.useMemo(() => {
|
const { screenshots, videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors } = React.useMemo(() => {
|
||||||
const attachments = result?.attachments || [];
|
const attachments = result?.attachments || [];
|
||||||
const screenshots = new Set(attachments.filter(a => a.contentType.startsWith('image/')));
|
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 videos = attachments.filter(a => a.contentType.startsWith('video/'));
|
||||||
const traces = attachments.filter(a => a.name === 'trace');
|
const traces = attachments.filter(a => a.name === 'trace');
|
||||||
const htmls = attachments.filter(a => a.contentType.startsWith('text/html'));
|
|
||||||
const otherAttachments = new Set<TestAttachment>(attachments);
|
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 diffs = groupImageDiffs(screenshots);
|
||||||
const errors = classifyErrors(result.errors, diffs);
|
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]);
|
}, [result]);
|
||||||
|
|
||||||
return <div className='test-result'>
|
return <div className='test-result'>
|
||||||
|
|
@ -87,29 +94,29 @@ export const TestResultView: React.FC<{
|
||||||
})}
|
})}
|
||||||
</AutoChip>}
|
</AutoChip>}
|
||||||
{!!result.steps.length && <AutoChip header='Test Steps'>
|
{!!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>}
|
</AutoChip>}
|
||||||
|
|
||||||
{diffs.map((diff, index) =>
|
{diffs.map((diff, index) =>
|
||||||
<Anchor key={`diff-${index}`} id={`diff-${index}`}>
|
<Anchor key={`diff-${index}`} id={diff.anchors}>
|
||||||
<AutoChip dataTestId='test-results-image-diff' header={`Image mismatch: ${diff.name}`} revealOnAnchorId={`diff-${index}`}>
|
<AutoChip dataTestId='test-results-image-diff' header={`Image mismatch: ${diff.name}`} revealOnAnchorId={diff.anchors}>
|
||||||
<ImageDiffView diff={diff}/>
|
<ImageDiffView diff={diff}/>
|
||||||
</AutoChip>
|
</AutoChip>
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!!screenshots.length && <AutoChip header='Screenshots'>
|
{!!screenshots.length && <AutoChip header='Screenshots' revealOnAnchorId={screenshotAnchors}>
|
||||||
{screenshots.map((a, i) => {
|
{screenshots.map((a, i) => {
|
||||||
return <div key={`screenshot-${i}`}>
|
return <Anchor key={`screenshot-${i}`} id={`attachment-${a.name}`}>
|
||||||
<a href={a.path}>
|
<a href={a.path}>
|
||||||
<img className='screenshot' src={a.path} />
|
<img className='screenshot' src={a.path} />
|
||||||
</a>
|
</a>
|
||||||
<AttachmentLink attachment={a}></AttachmentLink>
|
<AttachmentLink attachment={a}></AttachmentLink>
|
||||||
</div>;
|
</Anchor>;
|
||||||
})}
|
})}
|
||||||
</AutoChip>}
|
</AutoChip>}
|
||||||
|
|
||||||
{!!traces.length && <Anchor id='traces'><AutoChip header='Traces' revealOnAnchorId='traces'>
|
{!!traces.length && <Anchor id='attachment-trace'><AutoChip header='Traces' revealOnAnchorId='attachment-trace'>
|
||||||
{<div>
|
{<div>
|
||||||
<a href={generateTraceUrl(traces)}>
|
<a href={generateTraceUrl(traces)}>
|
||||||
<img className='screenshot' src={traceImage} style={{ width: 192, height: 117, marginLeft: 20 }} />
|
<img className='screenshot' src={traceImage} style={{ width: 192, height: 117, marginLeft: 20 }} />
|
||||||
|
|
@ -118,7 +125,7 @@ export const TestResultView: React.FC<{
|
||||||
</div>}
|
</div>}
|
||||||
</AutoChip></Anchor>}
|
</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}`}>
|
{videos.map((a, i) => <div key={`video-${i}`}>
|
||||||
<video controls>
|
<video controls>
|
||||||
<source src={a.path} type={a.contentType}/>
|
<source src={a.path} type={a.contentType}/>
|
||||||
|
|
@ -127,11 +134,12 @@ export const TestResultView: React.FC<{
|
||||||
</div>)}
|
</div>)}
|
||||||
</AutoChip></Anchor>}
|
</AutoChip></Anchor>}
|
||||||
|
|
||||||
{!!(otherAttachments.size + htmls.length) && <AutoChip header='Attachments'>
|
{!!otherAttachments.size && <AutoChip header='Attachments' revealOnAnchorId={otherAttachmentAnchors}>
|
||||||
{[...htmls].map((a, i) => (
|
{[...otherAttachments].map((a, i) =>
|
||||||
<AttachmentLink key={`html-link-${i}`} attachment={a} openInNewTab />)
|
<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>}
|
</AutoChip>}
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
@ -161,19 +169,23 @@ function classifyErrors(testErrors: string[], diffs: ImageDiff[]) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const StepTreeItem: React.FC<{
|
const StepTreeItem: React.FC<{
|
||||||
|
test: TestCase;
|
||||||
|
result: TestResult;
|
||||||
step: TestStep;
|
step: TestStep;
|
||||||
depth: number,
|
depth: number,
|
||||||
}> = ({ step, depth }) => {
|
}> = ({ test, step, result, depth }) => {
|
||||||
return <TreeItem title={<span>
|
const attachmentName = step.title.match(/^attach "(.*)"$/)?.[1];
|
||||||
|
return <TreeItem title={<span aria-label={step.title}>
|
||||||
<span style={{ float: 'right' }}>{msToString(step.duration)}</span>
|
<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')}
|
{statusIcon(step.error || step.duration === -1 ? 'failed' : 'passed')}
|
||||||
<span>{step.title}</span>
|
<span>{step.title}</span>
|
||||||
{step.count > 1 && <> ✕ <span className='test-result-counter'>{step.count}</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>}
|
{step.location && <span className='test-result-path'>— {step.location.file}:{step.location.line}</span>}
|
||||||
</span>} loadChildren={step.steps.length + (step.snippet ? 1 : 0) ? () => {
|
</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)
|
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;
|
return children;
|
||||||
} : undefined} depth={depth}></TreeItem>;
|
} : undefined} depth={depth}/>;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,11 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-item-title.selected {
|
||||||
|
text-decoration: underline var(--color-underlinenav-icon);
|
||||||
|
text-decoration-thickness: 1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-item-body {
|
.tree-item-body {
|
||||||
min-height: 18px;
|
min-height: 18px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import './treeItem.css';
|
import './treeItem.css';
|
||||||
import * as icons from './icons';
|
import * as icons from './icons';
|
||||||
|
import { clsx } from '@web/uiUtils';
|
||||||
|
|
||||||
export const TreeItem: React.FunctionComponent<{
|
export const TreeItem: React.FunctionComponent<{
|
||||||
title: JSX.Element,
|
title: JSX.Element,
|
||||||
|
|
@ -28,9 +29,8 @@ export const TreeItem: React.FunctionComponent<{
|
||||||
style?: React.CSSProperties,
|
style?: React.CSSProperties,
|
||||||
}> = ({ title, loadChildren, onClick, expandByDefault, depth, selected, style }) => {
|
}> = ({ title, loadChildren, onClick, expandByDefault, depth, selected, style }) => {
|
||||||
const [expanded, setExpanded] = React.useState(expandByDefault || false);
|
const [expanded, setExpanded] = React.useState(expandByDefault || false);
|
||||||
const className = selected ? 'tree-item-title selected' : 'tree-item-title';
|
|
||||||
return <div className={'tree-item'} style={style}>
|
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.downArrow()}
|
||||||
{loadChildren && !expanded && icons.rightArrow()}
|
{loadChildren && !expanded && icons.rightArrow()}
|
||||||
{!loadChildren && <span style={{ visibility: 'hidden' }}>{icons.rightArrow()}</span>}
|
{!loadChildren && <span style={{ visibility: 'hidden' }}>{icons.rightArrow()}</span>}
|
||||||
|
|
|
||||||
|
|
@ -847,7 +847,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
|
||||||
'a.test.js': `
|
'a.test.js': `
|
||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
test('passing', async ({ page }, testInfo) => {
|
test('passing', async ({ page }, testInfo) => {
|
||||||
testInfo.attach('axe-report.html', {
|
await testInfo.attach('axe-report.html', {
|
||||||
contentType: 'text/html',
|
contentType: 'text/html',
|
||||||
body: '<h1>Axe Report</h1>',
|
body: '<h1>Axe Report</h1>',
|
||||||
});
|
});
|
||||||
|
|
@ -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 }) => {
|
test('should highlight textual diff', async ({ runInlineTest, showReport, page }) => {
|
||||||
const result = await runInlineTest({
|
const result = await runInlineTest({
|
||||||
'helper.ts': `
|
'helper.ts': `
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue