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,
|
setExpanded?: (expanded: boolean) => void,
|
||||||
children?: any,
|
children?: any,
|
||||||
dataTestId?: string,
|
dataTestId?: string,
|
||||||
}> = ({ header, expanded, setExpanded, children, noInsets, dataTestId }) => {
|
targetRef?: React.RefObject<HTMLDivElement>,
|
||||||
return <div className='chip' data-test-id={dataTestId}>
|
}> = ({ header, expanded, setExpanded, children, noInsets, dataTestId, targetRef }) => {
|
||||||
|
return <div className='chip' data-test-id={dataTestId} ref={targetRef}>
|
||||||
<div
|
<div
|
||||||
className={'chip-header' + (setExpanded ? ' expanded-' + expanded : '')}
|
className={'chip-header' + (setExpanded ? ' expanded-' + expanded : '')}
|
||||||
onClick={() => setExpanded?.(!expanded)}
|
onClick={() => setExpanded?.(!expanded)}
|
||||||
|
|
@ -47,7 +48,8 @@ export const AutoChip: React.FC<{
|
||||||
noInsets?: boolean,
|
noInsets?: boolean,
|
||||||
children?: any,
|
children?: any,
|
||||||
dataTestId?: string,
|
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);
|
const [expanded, setExpanded] = React.useState(initialExpanded || initialExpanded === undefined);
|
||||||
return <Chip
|
return <Chip
|
||||||
header={header}
|
header={header}
|
||||||
|
|
@ -55,6 +57,7 @@ export const AutoChip: React.FC<{
|
||||||
setExpanded={setExpanded}
|
setExpanded={setExpanded}
|
||||||
noInsets={noInsets}
|
noInsets={noInsets}
|
||||||
dataTestId={dataTestId}
|
dataTestId={dataTestId}
|
||||||
|
targetRef={targetRef}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Chip>;
|
</Chip>;
|
||||||
|
|
|
||||||
|
|
@ -84,3 +84,25 @@ export const person = () => {
|
||||||
export const commit = () => {
|
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>;
|
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<{
|
export const Route: React.FunctionComponent<{
|
||||||
params: string,
|
predicate: (params: URLSearchParams) => boolean,
|
||||||
children: any
|
children: any
|
||||||
}> = ({ params, children }) => {
|
}> = ({ predicate, children }) => {
|
||||||
const initialParams = [...new URLSearchParams(window.location.hash.slice(1)).keys()].join('&');
|
const [matches, setMatches] = React.useState(predicate(new URLSearchParams(window.location.hash.slice(1))));
|
||||||
const [currentParams, setCurrentParam] = React.useState(initialParams);
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const listener = () => {
|
const listener = () => setMatches(predicate(new URLSearchParams(window.location.hash.slice(1))));
|
||||||
const newParams = [...new URLSearchParams(window.location.hash.slice(1)).keys()].join('&');
|
|
||||||
setCurrentParam(newParams);
|
|
||||||
};
|
|
||||||
window.addEventListener('popstate', listener);
|
window.addEventListener('popstate', listener);
|
||||||
return () => window.removeEventListener('popstate', listener);
|
return () => window.removeEventListener('popstate', listener);
|
||||||
}, []);
|
}, [predicate]);
|
||||||
return currentParams === params ? children : null;
|
return matches ? children : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Link: React.FunctionComponent<{
|
export const Link: React.FunctionComponent<{
|
||||||
|
|
@ -79,4 +75,8 @@ export const AttachmentLink: React.FunctionComponent<{
|
||||||
} : undefined} depth={0} style={{ lineHeight: '32px' }}></TreeItem>;
|
} : 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';
|
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<{
|
export const ReportView: React.FC<{
|
||||||
report: LoadedReport | undefined,
|
report: LoadedReport | undefined,
|
||||||
}> = ({ report }) => {
|
}> = ({ report }) => {
|
||||||
|
|
@ -48,13 +52,10 @@ export const ReportView: React.FC<{
|
||||||
<main>
|
<main>
|
||||||
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
|
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
|
||||||
{report?.json().metadata && <MetadataView {...report?.json().metadata as Metainfo} />}
|
{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>
|
<TestFilesView report={report?.json()} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles}></TestFilesView>
|
||||||
</Route>
|
</Route>
|
||||||
<Route params='q'>
|
<Route predicate={testCaseRoutePredicate}>
|
||||||
<TestFilesView report={report?.json()} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles}></TestFilesView>
|
|
||||||
</Route>
|
|
||||||
<Route params='testId'>
|
|
||||||
{!!report && <TestCaseViewLoader report={report}></TestCaseViewLoader>}
|
{!!report && <TestCaseViewLoader report={report}></TestCaseViewLoader>}
|
||||||
</Route>
|
</Route>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -67,6 +68,8 @@ const TestCaseViewLoader: React.FC<{
|
||||||
const searchParams = new URLSearchParams(window.location.hash.slice(1));
|
const searchParams = new URLSearchParams(window.location.hash.slice(1));
|
||||||
const [test, setTest] = React.useState<TestCase | undefined>();
|
const [test, setTest] = React.useState<TestCase | undefined>();
|
||||||
const testId = searchParams.get('testId');
|
const testId = searchParams.get('testId');
|
||||||
|
const anchor = (searchParams.get('anchor') || '') as 'video' | 'diff' | '';
|
||||||
|
const run = +(searchParams.get('run') || '0');
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (!testId || testId === test?.testId)
|
if (!testId || testId === test?.testId)
|
||||||
|
|
@ -83,5 +86,5 @@ const TestCaseViewLoader: React.FC<{
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [test, report, testId]);
|
}, [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 }) => {
|
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 expect(component.locator('text=Annotation text').first()).toBeVisible();
|
||||||
await component.locator('text=Annotations').click();
|
await component.locator('text=Annotations').click();
|
||||||
await expect(component.locator('text=Annotation text')).not.toBeVisible();
|
await expect(component.locator('text=Annotation text')).not.toBeVisible();
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,10 @@ import { TestResultView } from './testResultView';
|
||||||
export const TestCaseView: React.FC<{
|
export const TestCaseView: React.FC<{
|
||||||
projectNames: string[],
|
projectNames: string[],
|
||||||
test: TestCase | undefined,
|
test: TestCase | undefined,
|
||||||
}> = ({ projectNames, test }) => {
|
anchor: 'video' | 'diff' | '',
|
||||||
const [selectedResultIndex, setSelectedResultIndex] = React.useState(0);
|
run: number,
|
||||||
|
}> = ({ projectNames, test, run, anchor }) => {
|
||||||
|
const [selectedResultIndex, setSelectedResultIndex] = React.useState(run);
|
||||||
|
|
||||||
return <div className='test-case-column vbox'>
|
return <div className='test-case-column vbox'>
|
||||||
{test && <div className='test-case-path'>{test.path.join(' › ')}</div>}
|
{test && <div className='test-case-path'>{test.path.join(' › ')}</div>}
|
||||||
|
|
@ -45,7 +47,7 @@ export const TestCaseView: React.FC<{
|
||||||
test.results.map((result, index) => ({
|
test.results.map((result, index) => ({
|
||||||
id: String(index),
|
id: String(index),
|
||||||
title: <div style={{ display: 'flex', alignItems: 'center' }}>{statusIcon(result.status)} {retryLabel(index)}</div>,
|
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)} />}
|
})) || []} selectedTab={String(selectedResultIndex)} setSelectedTab={id => setSelectedResultIndex(+id)} />}
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.test-file-test {
|
.test-file-test {
|
||||||
height: 38px;
|
line-height: 32px;
|
||||||
line-height: 38px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
@ -28,9 +27,24 @@
|
||||||
background-color: var(--color-canvas-subtle);
|
background-color: var(--color-canvas-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-file-path {
|
.test-file-title {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-file-details-row {
|
||||||
padding: 0 0 0 8px;
|
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 {
|
.test-file-test-outcome-skipped {
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,15 @@
|
||||||
limitations under the License.
|
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 * as React from 'react';
|
||||||
import { msToString } from './uiUtils';
|
import { msToString } from './uiUtils';
|
||||||
import { Chip } from './chip';
|
import { Chip } from './chip';
|
||||||
import type { Filter } from './filter';
|
import type { Filter } from './filter';
|
||||||
import { Link, ProjectLink } from './links';
|
import { generateTraceUrl, Link, ProjectLink } from './links';
|
||||||
import { statusIcon } from './statusIcon';
|
import { statusIcon } from './statusIcon';
|
||||||
import './testFileView.css';
|
import './testFileView.css';
|
||||||
|
import { video, image, trace } from './icons';
|
||||||
|
|
||||||
export const TestFileView: React.FC<React.PropsWithChildren<{
|
export const TestFileView: React.FC<React.PropsWithChildren<{
|
||||||
report: HTMLReport;
|
report: HTMLReport;
|
||||||
|
|
@ -40,15 +41,37 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
|
||||||
</span>}>
|
</span>}>
|
||||||
{file.tests.filter(t => filter.matches(t)).map(test =>
|
{file.tests.filter(t => filter.matches(t)).map(test =>
|
||||||
<div key={`test-${test.testId}`} className={'test-file-test test-file-test-outcome-' + test.outcome}>
|
<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(' › ')}>
|
<Link href={`#?testId=${test.testId}`} title={[...test.path, test.title].join(' › ')}>
|
||||||
{[...test.path, test.title].join(' › ')}
|
<span style={{ float: 'right', minWidth: '50px', textAlign: 'right' }}>{msToString(test.duration)}</span>
|
||||||
<span className='test-file-path'>— {test.location.file}:{test.location.line}</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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Chip>;
|
</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 { msToString } from './uiUtils';
|
||||||
import { AutoChip } from './chip';
|
import { AutoChip } from './chip';
|
||||||
import { traceImage } from './images';
|
import { traceImage } from './images';
|
||||||
import { AttachmentLink } from './links';
|
import { AttachmentLink, generateTraceUrl } from './links';
|
||||||
import { statusIcon } from './statusIcon';
|
import { statusIcon } from './statusIcon';
|
||||||
import type { ImageDiff } from './imageDiffView';
|
import type { ImageDiff } from './imageDiffView';
|
||||||
import { ImageDiffView } from './imageDiffView';
|
import { ImageDiffView } from './imageDiffView';
|
||||||
|
|
@ -64,7 +64,8 @@ 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 }) => {
|
anchor: 'video' | 'diff' | '',
|
||||||
|
}> = ({ result, anchor }) => {
|
||||||
|
|
||||||
const { screenshots, videos, traces, otherAttachments, diffs } = React.useMemo(() => {
|
const { screenshots, videos, traces, otherAttachments, diffs } = React.useMemo(() => {
|
||||||
const attachments = result?.attachments || [];
|
const attachments = result?.attachments || [];
|
||||||
|
|
@ -77,6 +78,20 @@ export const TestResultView: React.FC<{
|
||||||
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs };
|
return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs };
|
||||||
}, [ result ]);
|
}, [ 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'>
|
return <div className='test-result'>
|
||||||
{!!result.errors.length && <AutoChip header='Errors'>
|
{!!result.errors.length && <AutoChip header='Errors'>
|
||||||
{result.errors.map((error, index) => <ErrorMessage key={'test-result-error-message-' + index} error={error}></ErrorMessage>)}
|
{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>}
|
</AutoChip>}
|
||||||
|
|
||||||
{diffs.map((diff, index) =>
|
{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>
|
<ImageDiffView key='image-diff' imageDiff={diff}></ImageDiffView>
|
||||||
</AutoChip>
|
</AutoChip>
|
||||||
)}
|
)}
|
||||||
|
|
@ -102,14 +117,14 @@ export const TestResultView: React.FC<{
|
||||||
|
|
||||||
{!!traces.length && <AutoChip header='Traces'>
|
{!!traces.length && <AutoChip header='Traces'>
|
||||||
{<div>
|
{<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 }} />
|
<img src={traceImage} style={{ width: 192, height: 117, marginLeft: 20 }} />
|
||||||
</a>
|
</a>
|
||||||
{traces.map((a, i) => <AttachmentLink key={`trace-${i}`} attachment={a} linkName={traces.length === 1 ? 'trace' : `trace-${i + 1}`}></AttachmentLink>)}
|
{traces.map((a, i) => <AttachmentLink key={`trace-${i}`} attachment={a} linkName={traces.length === 1 ? 'trace' : `trace-${i + 1}`}></AttachmentLink>)}
|
||||||
</div>}
|
</div>}
|
||||||
</AutoChip>}
|
</AutoChip>}
|
||||||
|
|
||||||
{!!videos.length && <AutoChip header='Videos'>
|
{!!videos.length && <AutoChip header='Videos' targetRef={videoRef}>
|
||||||
{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}/>
|
||||||
|
|
|
||||||
|
|
@ -79,9 +79,14 @@ export type TestCaseSummary = {
|
||||||
outcome: 'skipped' | 'expected' | 'unexpected' | 'flaky';
|
outcome: 'skipped' | 'expected' | 'unexpected' | 'flaky';
|
||||||
duration: number;
|
duration: number;
|
||||||
ok: boolean;
|
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[];
|
results: TestResult[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -92,7 +97,6 @@ export type TestAttachment = {
|
||||||
contentType: string;
|
contentType: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export type TestResult = {
|
export type TestResult = {
|
||||||
retry: number;
|
retry: number;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
|
|
@ -399,6 +403,8 @@ class HtmlBuilder {
|
||||||
path = [...path.slice(1)];
|
path = [...path.slice(1)];
|
||||||
this._testPath.set(test.testId, path);
|
this._testPath.set(test.testId, path);
|
||||||
|
|
||||||
|
const results = test.results.map(r => this._createTestResult(r));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
testCase: {
|
testCase: {
|
||||||
testId: test.testId,
|
testId: test.testId,
|
||||||
|
|
@ -409,7 +415,7 @@ class HtmlBuilder {
|
||||||
annotations: test.annotations,
|
annotations: test.annotations,
|
||||||
outcome: test.outcome,
|
outcome: test.outcome,
|
||||||
path,
|
path,
|
||||||
results: test.results.map(r => this._createTestResult(r)),
|
results,
|
||||||
ok: test.outcome === 'expected' || test.outcome === 'flaky',
|
ok: test.outcome === 'expected' || test.outcome === 'flaky',
|
||||||
},
|
},
|
||||||
testCaseSummary: {
|
testCaseSummary: {
|
||||||
|
|
@ -422,6 +428,9 @@ class HtmlBuilder {
|
||||||
outcome: test.outcome,
|
outcome: test.outcome,
|
||||||
path,
|
path,
|
||||||
ok: test.outcome === 'expected' || test.outcome === 'flaky',
|
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