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:
Dmitry Gozman 2022-07-11 19:47:15 -07:00 committed by GitHub
parent 60b34e9091
commit 7727ebe758
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 134 additions and 43 deletions

View file

@ -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>;

View file

@ -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>;
};

View file

@ -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';

View file

@ -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>;
};

View file

@ -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();

View file

@ -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>;
};

View file

@ -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 {

View file

@ -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;
}

View file

@ -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}/>

View file

@ -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 })) };
}),
},
};
}