feat(html report): improve test list view (#15543)
- Two lines per test: title and location. - Align project labels. - Add trace badge that opens trace viewer. - Add video and image diff badges that show scrolled test result view.
This commit is contained in:
parent
60b34e9091
commit
7727ebe758
|
|
@ -27,8 +27,9 @@ export const Chip: React.FC<{
|
|||
setExpanded?: (expanded: boolean) => void,
|
||||
children?: any,
|
||||
dataTestId?: string,
|
||||
}> = ({ header, expanded, setExpanded, children, noInsets, dataTestId }) => {
|
||||
return <div className='chip' data-test-id={dataTestId}>
|
||||
targetRef?: React.RefObject<HTMLDivElement>,
|
||||
}> = ({ header, expanded, setExpanded, children, noInsets, dataTestId, targetRef }) => {
|
||||
return <div className='chip' data-test-id={dataTestId} ref={targetRef}>
|
||||
<div
|
||||
className={'chip-header' + (setExpanded ? ' expanded-' + expanded : '')}
|
||||
onClick={() => setExpanded?.(!expanded)}
|
||||
|
|
@ -47,7 +48,8 @@ export const AutoChip: React.FC<{
|
|||
noInsets?: boolean,
|
||||
children?: any,
|
||||
dataTestId?: string,
|
||||
}> = ({ header, initialExpanded, noInsets, children, dataTestId }) => {
|
||||
targetRef?: React.RefObject<HTMLDivElement>,
|
||||
}> = ({ header, initialExpanded, noInsets, children, dataTestId, targetRef }) => {
|
||||
const [expanded, setExpanded] = React.useState(initialExpanded || initialExpanded === undefined);
|
||||
return <Chip
|
||||
header={header}
|
||||
|
|
@ -55,6 +57,7 @@ export const AutoChip: React.FC<{
|
|||
setExpanded={setExpanded}
|
||||
noInsets={noInsets}
|
||||
dataTestId={dataTestId}
|
||||
targetRef={targetRef}
|
||||
>
|
||||
{children}
|
||||
</Chip>;
|
||||
|
|
|
|||
|
|
@ -84,3 +84,25 @@ export const person = () => {
|
|||
export const commit = () => {
|
||||
return <svg className='octicon' viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M10.5 7.75a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0zm1.43.75a4.002 4.002 0 01-7.86 0H.75a.75.75 0 110-1.5h3.32a4.001 4.001 0 017.86 0h3.32a.75.75 0 110 1.5h-3.32z"></path></svg>;
|
||||
};
|
||||
|
||||
export const image = () => {
|
||||
return <svg className='octicon' style={{ color: 'var(--color-fg-subtle)' }} viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'>
|
||||
<path fillRule='evenodd' d='M1.75 2.5a.25.25 0 00-.25.25v10.5c0 .138.112.25.25.25h.94a.76.76 0 01.03-.03l6.077-6.078a1.75 1.75 0 012.412-.06L14.5 10.31V2.75a.25.25 0 00-.25-.25H1.75zm12.5 11H4.81l5.048-5.047a.25.25 0 01.344-.009l4.298 3.889v.917a.25.25 0 01-.25.25zm1.75-.25V2.75A1.75 1.75 0 0014.25 1H1.75A1.75 1.75 0 000 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0016 13.25zM5.5 6a.5.5 0 11-1 0 .5.5 0 011 0zM7 6a2 2 0 11-4 0 2 2 0 014 0z'></path>
|
||||
</svg>;
|
||||
};
|
||||
|
||||
export const video = () => {
|
||||
return <svg className='octicon' style={{ color: 'var(--color-fg-subtle)' }} viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'>
|
||||
<path fill-rule='evenodd' d='M1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0zM8 0a8 8 0 100 16A8 8 0 008 0zM6.379 5.227A.25.25 0 006 5.442v5.117a.25.25 0 00.379.214l4.264-2.559a.25.25 0 000-.428L6.379 5.227z'></path>
|
||||
</svg>;
|
||||
};
|
||||
|
||||
export const trace = () => {
|
||||
return <svg className='octicon' style={{ color: 'var(--color-fg-subtle)' }} viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'>
|
||||
<path fill-rule='evenodd' d='M1.679 7.932c.412-.621 1.242-1.75 2.366-2.717C5.175 4.242 6.527 3.5 8 3.5c1.473 0 2.824.742 3.955 1.715 1.124.967 1.954 2.096 2.366 2.717a.119.119 0 010 .136c-.412.621-1.242 1.75-2.366 2.717C10.825 11.758 9.473 12.5 8 12.5c-1.473 0-2.824-.742-3.955-1.715C2.92 9.818 2.09 8.69 1.679 8.068a.119.119 0 010-.136zM8 2c-1.981 0-3.67.992-4.933 2.078C1.797 5.169.88 6.423.43 7.1a1.619 1.619 0 000 1.798c.45.678 1.367 1.932 2.637 3.024C4.329 13.008 6.019 14 8 14c1.981 0 3.67-.992 4.933-2.078 1.27-1.091 2.187-2.345 2.637-3.023a1.619 1.619 0 000-1.798c-.45-.678-1.367-1.932-2.637-3.023C11.671 2.992 9.981 2 8 2zm0 8a2 2 0 100-4 2 2 0 000 4z'></path>
|
||||
</svg>;
|
||||
};
|
||||
|
||||
export const empty = () => {
|
||||
return <svg className='octicon' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'></svg>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,20 +27,16 @@ export function navigate(href: string) {
|
|||
}
|
||||
|
||||
export const Route: React.FunctionComponent<{
|
||||
params: string,
|
||||
predicate: (params: URLSearchParams) => boolean,
|
||||
children: any
|
||||
}> = ({ params, children }) => {
|
||||
const initialParams = [...new URLSearchParams(window.location.hash.slice(1)).keys()].join('&');
|
||||
const [currentParams, setCurrentParam] = React.useState(initialParams);
|
||||
}> = ({ predicate, children }) => {
|
||||
const [matches, setMatches] = React.useState(predicate(new URLSearchParams(window.location.hash.slice(1))));
|
||||
React.useEffect(() => {
|
||||
const listener = () => {
|
||||
const newParams = [...new URLSearchParams(window.location.hash.slice(1)).keys()].join('&');
|
||||
setCurrentParam(newParams);
|
||||
};
|
||||
const listener = () => setMatches(predicate(new URLSearchParams(window.location.hash.slice(1))));
|
||||
window.addEventListener('popstate', listener);
|
||||
return () => window.removeEventListener('popstate', listener);
|
||||
}, []);
|
||||
return currentParams === params ? children : null;
|
||||
}, [predicate]);
|
||||
return matches ? children : null;
|
||||
};
|
||||
|
||||
export const Link: React.FunctionComponent<{
|
||||
|
|
@ -79,4 +75,8 @@ export const AttachmentLink: React.FunctionComponent<{
|
|||
} : undefined} depth={0} style={{ lineHeight: '32px' }}></TreeItem>;
|
||||
};
|
||||
|
||||
export function generateTraceUrl(traces: TestAttachment[]) {
|
||||
return `trace/index.html?${traces.map((a, i) => `trace=${new URL(a.path!, window.location.href)}`).join('&')}`;
|
||||
}
|
||||
|
||||
const kMissingContentType = 'x-playwright/missing';
|
||||
|
|
|
|||
|
|
@ -35,6 +35,10 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
// These are extracted to preserve the function identity between renders to avoid re-triggering effects.
|
||||
const testFilesRoutePredicate = (params: URLSearchParams) => !params.has('testId');
|
||||
const testCaseRoutePredicate = (params: URLSearchParams) => params.has('testId');
|
||||
|
||||
export const ReportView: React.FC<{
|
||||
report: LoadedReport | undefined,
|
||||
}> = ({ report }) => {
|
||||
|
|
@ -48,13 +52,10 @@ export const ReportView: React.FC<{
|
|||
<main>
|
||||
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
|
||||
{report?.json().metadata && <MetadataView {...report?.json().metadata as Metainfo} />}
|
||||
<Route params=''>
|
||||
<Route predicate={testFilesRoutePredicate}>
|
||||
<TestFilesView report={report?.json()} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles}></TestFilesView>
|
||||
</Route>
|
||||
<Route params='q'>
|
||||
<TestFilesView report={report?.json()} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles}></TestFilesView>
|
||||
</Route>
|
||||
<Route params='testId'>
|
||||
<Route predicate={testCaseRoutePredicate}>
|
||||
{!!report && <TestCaseViewLoader report={report}></TestCaseViewLoader>}
|
||||
</Route>
|
||||
</main>
|
||||
|
|
@ -67,6 +68,8 @@ const TestCaseViewLoader: React.FC<{
|
|||
const searchParams = new URLSearchParams(window.location.hash.slice(1));
|
||||
const [test, setTest] = React.useState<TestCase | undefined>();
|
||||
const testId = searchParams.get('testId');
|
||||
const anchor = (searchParams.get('anchor') || '') as 'video' | 'diff' | '';
|
||||
const run = +(searchParams.get('run') || '0');
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
if (!testId || testId === test?.testId)
|
||||
|
|
@ -83,5 +86,5 @@ const TestCaseViewLoader: React.FC<{
|
|||
}
|
||||
})();
|
||||
}, [test, report, testId]);
|
||||
return <TestCaseView projectNames={report.json().projectNames} test={test}></TestCaseView>;
|
||||
return <TestCaseView projectNames={report.json().projectNames} test={test} anchor={anchor} run={run}></TestCaseView>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ const testCase: TestCase = {
|
|||
};
|
||||
|
||||
test('should render test case', async ({ mount }) => {
|
||||
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={testCase}></TestCaseView>);
|
||||
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={testCase} run={0} anchor=''></TestCaseView>);
|
||||
await expect(component.locator('text=Annotation text').first()).toBeVisible();
|
||||
await component.locator('text=Annotations').click();
|
||||
await expect(component.locator('text=Annotation text')).not.toBeVisible();
|
||||
|
|
|
|||
|
|
@ -27,8 +27,10 @@ import { TestResultView } from './testResultView';
|
|||
export const TestCaseView: React.FC<{
|
||||
projectNames: string[],
|
||||
test: TestCase | undefined,
|
||||
}> = ({ projectNames, test }) => {
|
||||
const [selectedResultIndex, setSelectedResultIndex] = React.useState(0);
|
||||
anchor: 'video' | 'diff' | '',
|
||||
run: number,
|
||||
}> = ({ projectNames, test, run, anchor }) => {
|
||||
const [selectedResultIndex, setSelectedResultIndex] = React.useState(run);
|
||||
|
||||
return <div className='test-case-column vbox'>
|
||||
{test && <div className='test-case-path'>{test.path.join(' › ')}</div>}
|
||||
|
|
@ -45,7 +47,7 @@ export const TestCaseView: React.FC<{
|
|||
test.results.map((result, index) => ({
|
||||
id: String(index),
|
||||
title: <div style={{ display: 'flex', alignItems: 'center' }}>{statusIcon(result.status)} {retryLabel(index)}</div>,
|
||||
render: () => <TestResultView test={test!} result={result}></TestResultView>
|
||||
render: () => <TestResultView test={test!} result={result} anchor={anchor}></TestResultView>
|
||||
})) || []} selectedTab={String(selectedResultIndex)} setSelectedTab={id => setSelectedResultIndex(+id)} />}
|
||||
</div>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -15,8 +15,7 @@
|
|||
*/
|
||||
|
||||
.test-file-test {
|
||||
height: 38px;
|
||||
line-height: 38px;
|
||||
line-height: 32px;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
white-space: nowrap;
|
||||
|
|
@ -28,9 +27,24 @@
|
|||
background-color: var(--color-canvas-subtle);
|
||||
}
|
||||
|
||||
.test-file-path {
|
||||
.test-file-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.test-file-details-row {
|
||||
padding: 0 0 0 8px;
|
||||
color: var(--color-fg-muted);
|
||||
margin-left: 15px;
|
||||
margin-top: -11px;
|
||||
font-weight: normal;
|
||||
color: var(--color-fg-subtle);
|
||||
}
|
||||
|
||||
.test-file-path {
|
||||
margin-right: 7px;
|
||||
}
|
||||
|
||||
.test-file-badge {
|
||||
margin-left: 7px;
|
||||
}
|
||||
|
||||
.test-file-test-outcome-skipped {
|
||||
|
|
|
|||
|
|
@ -14,14 +14,15 @@
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { HTMLReport, TestFileSummary } from '@playwright-test/reporters/html';
|
||||
import type { HTMLReport, TestCaseSummary, TestFileSummary } from '@playwright-test/reporters/html';
|
||||
import * as React from 'react';
|
||||
import { msToString } from './uiUtils';
|
||||
import { Chip } from './chip';
|
||||
import type { Filter } from './filter';
|
||||
import { Link, ProjectLink } from './links';
|
||||
import { generateTraceUrl, Link, ProjectLink } from './links';
|
||||
import { statusIcon } from './statusIcon';
|
||||
import './testFileView.css';
|
||||
import { video, image, trace } from './icons';
|
||||
|
||||
export const TestFileView: React.FC<React.PropsWithChildren<{
|
||||
report: HTMLReport;
|
||||
|
|
@ -40,15 +41,37 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
|
|||
</span>}>
|
||||
{file.tests.filter(t => filter.matches(t)).map(test =>
|
||||
<div key={`test-${test.testId}`} className={'test-file-test test-file-test-outcome-' + test.outcome}>
|
||||
<span style={{ float: 'right' }}>{msToString(test.duration)}</span>
|
||||
{report.projectNames.length > 1 && !!test.projectName &&
|
||||
<span style={{ float: 'right' }}><ProjectLink projectNames={report.projectNames} projectName={test.projectName}></ProjectLink></span>}
|
||||
{statusIcon(test.outcome)}
|
||||
<Link href={`#?testId=${test.testId}`} title={[...test.path, test.title].join(' › ')}>
|
||||
{[...test.path, test.title].join(' › ')}
|
||||
<span className='test-file-path'>— {test.location.file}:{test.location.line}</span>
|
||||
<span style={{ float: 'right', minWidth: '50px', textAlign: 'right' }}>{msToString(test.duration)}</span>
|
||||
{report.projectNames.length > 1 && !!test.projectName &&
|
||||
<span style={{ float: 'right' }}><ProjectLink projectNames={report.projectNames} projectName={test.projectName}></ProjectLink></span>}
|
||||
{statusIcon(test.outcome)}
|
||||
<span className='test-file-title'>{[...test.path, test.title].join(' › ')}</span>
|
||||
<div className='test-file-details-row'>
|
||||
<span className='test-file-path'>{test.location.file}:{test.location.line}</span>
|
||||
{imageDiffBadge(test)}
|
||||
{videoBadge(test)}
|
||||
{traceBadge(test)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Chip>;
|
||||
};
|
||||
|
||||
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&run=${test.results.indexOf(resultWithImageDiff)}`} title='View images' className='test-file-badge'>{image()}</Link> : undefined;
|
||||
}
|
||||
|
||||
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=video&run=${test.results.indexOf(resultWithVideo)}`} title='View video' className='test-file-badge'>{video()}</Link> : undefined;
|
||||
}
|
||||
|
||||
function traceBadge(test: TestCaseSummary): JSX.Element | undefined {
|
||||
const firstTraces = test.results.map(result => result.attachments.filter(attachment => attachment.name === 'trace')).filter(traces => traces.length > 0)[0];
|
||||
return firstTraces ? <Link href={generateTraceUrl(firstTraces)} title='View trace' className='test-file-badge'>{trace()}</Link> : undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { TreeItem } from './treeItem';
|
|||
import { msToString } from './uiUtils';
|
||||
import { AutoChip } from './chip';
|
||||
import { traceImage } from './images';
|
||||
import { AttachmentLink } from './links';
|
||||
import { AttachmentLink, generateTraceUrl } from './links';
|
||||
import { statusIcon } from './statusIcon';
|
||||
import type { ImageDiff } from './imageDiffView';
|
||||
import { ImageDiffView } from './imageDiffView';
|
||||
|
|
@ -64,7 +64,8 @@ function groupImageDiffs(screenshots: Set<TestAttachment>): ImageDiff[] {
|
|||
export const TestResultView: React.FC<{
|
||||
test: TestCase,
|
||||
result: TestResult,
|
||||
}> = ({ result }) => {
|
||||
anchor: 'video' | 'diff' | '',
|
||||
}> = ({ result, anchor }) => {
|
||||
|
||||
const { screenshots, videos, traces, otherAttachments, diffs } = React.useMemo(() => {
|
||||
const attachments = result?.attachments || [];
|
||||
|
|
@ -77,6 +78,20 @@ export const TestResultView: React.FC<{
|
|||
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs };
|
||||
}, [ result ]);
|
||||
|
||||
const videoRef = React.useRef<HTMLDivElement>(null);
|
||||
const imageDiffRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const [scrolled, setScrolled] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
if (scrolled)
|
||||
return;
|
||||
setScrolled(true);
|
||||
if (anchor === 'video')
|
||||
videoRef.current?.scrollIntoView({ block: 'start', inline: 'start' });
|
||||
if (anchor === 'diff')
|
||||
imageDiffRef.current?.scrollIntoView({ block: 'start', inline: 'start' });
|
||||
}, [scrolled, anchor, setScrolled, videoRef]);
|
||||
|
||||
return <div className='test-result'>
|
||||
{!!result.errors.length && <AutoChip header='Errors'>
|
||||
{result.errors.map((error, index) => <ErrorMessage key={'test-result-error-message-' + index} error={error}></ErrorMessage>)}
|
||||
|
|
@ -86,7 +101,7 @@ export const TestResultView: React.FC<{
|
|||
</AutoChip>}
|
||||
|
||||
{diffs.map((diff, index) =>
|
||||
<AutoChip key={`diff-${index}`} header={`Image mismatch: ${diff.name}`}>
|
||||
<AutoChip key={`diff-${index}`} header={`Image mismatch: ${diff.name}`} targetRef={imageDiffRef}>
|
||||
<ImageDiffView key='image-diff' imageDiff={diff}></ImageDiffView>
|
||||
</AutoChip>
|
||||
)}
|
||||
|
|
@ -102,14 +117,14 @@ export const TestResultView: React.FC<{
|
|||
|
||||
{!!traces.length && <AutoChip header='Traces'>
|
||||
{<div>
|
||||
<a href={`trace/index.html?${traces.map((a, i) => `trace=${new URL(a.path!, window.location.href)}`).join('&')}`}>
|
||||
<a href={generateTraceUrl(traces)}>
|
||||
<img src={traceImage} style={{ width: 192, height: 117, marginLeft: 20 }} />
|
||||
</a>
|
||||
{traces.map((a, i) => <AttachmentLink key={`trace-${i}`} attachment={a} linkName={traces.length === 1 ? 'trace' : `trace-${i + 1}`}></AttachmentLink>)}
|
||||
</div>}
|
||||
</AutoChip>}
|
||||
|
||||
{!!videos.length && <AutoChip header='Videos'>
|
||||
{!!videos.length && <AutoChip header='Videos' targetRef={videoRef}>
|
||||
{videos.map((a, i) => <div key={`video-${i}`}>
|
||||
<video controls>
|
||||
<source src={a.path} type={a.contentType}/>
|
||||
|
|
|
|||
|
|
@ -79,9 +79,14 @@ export type TestCaseSummary = {
|
|||
outcome: 'skipped' | 'expected' | 'unexpected' | 'flaky';
|
||||
duration: number;
|
||||
ok: boolean;
|
||||
results: TestResultSummary[];
|
||||
};
|
||||
|
||||
export type TestCase = TestCaseSummary & {
|
||||
export type TestResultSummary = {
|
||||
attachments: { name: string, contentType: string, path?: string }[];
|
||||
};
|
||||
|
||||
export type TestCase = Omit<TestCaseSummary, 'results'> & {
|
||||
results: TestResult[];
|
||||
};
|
||||
|
||||
|
|
@ -92,7 +97,6 @@ export type TestAttachment = {
|
|||
contentType: string;
|
||||
};
|
||||
|
||||
|
||||
export type TestResult = {
|
||||
retry: number;
|
||||
startTime: string;
|
||||
|
|
@ -399,6 +403,8 @@ class HtmlBuilder {
|
|||
path = [...path.slice(1)];
|
||||
this._testPath.set(test.testId, path);
|
||||
|
||||
const results = test.results.map(r => this._createTestResult(r));
|
||||
|
||||
return {
|
||||
testCase: {
|
||||
testId: test.testId,
|
||||
|
|
@ -409,7 +415,7 @@ class HtmlBuilder {
|
|||
annotations: test.annotations,
|
||||
outcome: test.outcome,
|
||||
path,
|
||||
results: test.results.map(r => this._createTestResult(r)),
|
||||
results,
|
||||
ok: test.outcome === 'expected' || test.outcome === 'flaky',
|
||||
},
|
||||
testCaseSummary: {
|
||||
|
|
@ -422,6 +428,9 @@ class HtmlBuilder {
|
|||
outcome: test.outcome,
|
||||
path,
|
||||
ok: test.outcome === 'expected' || test.outcome === 'flaky',
|
||||
results: results.map(result => {
|
||||
return { attachments: result.attachments.map(a => ({ name: a.name, contentType: a.contentType, path: a.path })) };
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue