chore(ui): redesign html report a bit (#9577)

This commit is contained in:
Pavel Feldman 2021-10-17 19:58:06 -08:00 committed by GitHub
parent f83199fb4a
commit 6afa85927e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 604 additions and 366 deletions

View file

@ -23,9 +23,9 @@
--active-background: #333333;
--color: #252423;
--red: #F44336;
--green: #4CAF50;
--green: #367c39;
--purple: #9C27B0;
--yellow: #FFC107;
--yellow: #ff9207;
--white: #FFFFFF;
--blue: #0b7ad5;
--transparent-blue: #2196F355;

View file

@ -20,19 +20,31 @@ body {
rgb(0 0 0 / 10%) 0px -2px 4px,
rgb(0 0 0 / 15%) 0px -6.1px 12px,
rgb(0 0 0 / 25%) 0px 27px 28px;
--color-border-default: #d0d7de;
--color-border-muted: #d8dee4;
--color-canvas-subtle: #f6f8fa;
--color-danger-fg: #cf222e;
--color-fg-default: #24292f;
--color-fg-muted: #57606a;
--color-page-header-bg: #f6f8fa;
--color-primer-border-active: #fd8c73;
--color-success-fg: #1a7f37;
--color-accent-fg: #0969da;
color: var(--color-fg-default);
overflow: auto;
}
.suite-tree-column {
line-height: 18px;
flex: auto;
overflow: auto;
color: #616161;
background-color: #f3f3f3;
border-left: 1px solid #dfe1e5;
}
.test-case-column {
border-right: 1px solid #dfe1e5;
border-radius: 6px;
margin: 20px;
}
.tree-item-title {
@ -55,13 +67,12 @@ body {
.error-message {
white-space: pre;
font-family: monospace;
background: #000;
color: white;
padding: 5px;
overflow: auto;
margin: 20px;
flex: none;
box-shadow: var(--box-shadow-thick);
padding: 0;
background-color: var(--color-canvas-subtle);
border-radius: 6px;
padding: 16px;
}
.status-icon {
@ -90,13 +101,7 @@ body {
display: flex;
flex-direction: column;
padding: 0 16px;
overflow: auto;
}
.test-overview-title {
padding: 30px 10px 10px;
font-size: 18px;
flex: none;
margin-bottom: 20px;
}
.test-result .tabbed-pane .tab-content {
@ -113,10 +118,6 @@ body {
margin-left: 24px;
}
.test-result .tree-item-title:not(.selected):hover {
background-color: #e8e8e8;
}
.test-result .tree-item-title.selected {
background-color: #0060c0;
color: white;
@ -130,11 +131,22 @@ body {
flex: none;
}
.suite-tree-column .tab-strip,
.test-case-column .tab-strip {
border: none;
box-shadow: none;
background-color: transparent;
.columns > .tab-strip {
font-size: 14px;
line-height: 30px;
color: var(--color-fg-default);
height: 48px;
background-color: var(--color-page-header-bg);
min-width: 70px;
}
.tab-strip {
box-shadow: inset 0 -1px 0 var(--color-border-muted) !important;
}
.columns > .tab-strip .tab-element.selected {
font-weight: 600;
border-bottom-color: var(--color-primer-border-active);
}
.suite-tree-column .tab-element,
@ -146,6 +158,10 @@ body {
color: #aaa;
}
.test-case-column .tab-strip {
background-color: inherit;
}
.suite-tree-column .tab-element.selected,
.test-case-column .tab-element.selected {
color: #555;
@ -156,8 +172,9 @@ body {
display: flex;
align-items: center;
padding: 10px;
font-size: 18px;
cursor: pointer;
font-weight: 400;
font-size: 32px !important;
line-height: 1.25 !important;
}
.test-case-location {
@ -182,26 +199,25 @@ body {
text-overflow: ellipsis;
}
.stats-line {
padding-left: 5px;
}
.stats {
background-color: gray;
border-radius: 2px;
min-width: 14px;
color: white;
margin: 0 2px;
padding: 0 2px;
text-align: center;
}
.stats.expected {
background-color: var(--green);
color: var(--green);
}
.stats.unexpected {
background-color: var(--red);
color: var(--color-danger-fg);
}
.stats.flaky {
background-color: var(--yellow);
color: var(--yellow);
}
video, img {
@ -213,7 +229,179 @@ video, img {
min-height: 300px;
}
.columns {
.flow-container {
max-width: 1280px;
margin: 0 auto;
width: 100%;
}
.file-summary-list .chip-body a:not(:nth-child(1)) .test-summary,
.failed-test:not(:nth-child(1)) {
border-top: 1px solid var(--color-border-default);
}
.failed-file-subtitle {
padding-left: 5px;
font-weight: 600;
color: var(--color-danger-fg);
}
.failed-test {
padding: 0 15px 0 10px;
line-height: 28px;
}
.failed-test-title {
font-weight: 600;
}
.failed-test-path {
padding: 5px 5px 0 0;
color: var(--color-fg-muted);
}
.failed-test .error-message {
margin: 20px 0 0;
}
.failed-test:hover {
background-color: var(--color-page-header-bg);
}
a.no-decorations {
text-decoration: none;
color: initial;
}
.chip-header {
display: flex;
align-items: center;
border: 1px solid var(--color-border-default);
border-top-left-radius: 6px;
border-top-right-radius: 6px;
background-color: var(--color-page-header-bg);
padding: 10px;
border-bottom: none;
margin-top: 20px;
font-weight: 600;
}
.chip-header.expanded-false {
border: 1px solid var(--color-border-default);
border-radius: 6px;
}
.chip-header.expanded-false,
.chip-header.expanded-true {
cursor: pointer;
}
.chip-body {
border: 1px solid var(--color-border-default);
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
padding: 15px;
}
.failed-tests {
padding-bottom: 20px;
}
.file-summary-list .chip-body,
.failed-tests .chip-body {
padding: 0;
}
.test-summary {
height: 38px;
line-height: 38px;
align-items: center;
padding: 0 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.test-summary:hover {
background-color: var(--color-page-header-bg);
}
.test-summary-path {
padding: 0 0 0 5px;
color: var(--color-fg-muted);
}
.octicon {
display: inline-block;
overflow: visible !important;
vertical-align: text-bottom;
fill: currentColor;
}
.color-icon-success {
color: var(--color-success-fg) !important;
}
.color-text-danger {
color: var(--color-danger-fg) !important;
}
.color-text-warning {
color: var(--yellow) !important;
}
.color-fg-muted {
color: var(--color-fg-muted) !important;
}
.octicon {
margin-right: 7px;
flex: none;
}
.label {
display: inline-block;
padding: 0 7px;
font-size: 12px;
font-weight: 500;
line-height: 18px;
border: 1px solid transparent;
border-radius: 2em;
background-color: #757575;
color: white;
margin-left: 10px;
flex: none;
}
.label-color-0 { background-color: #2196f3; }
.label-color-1 { background-color: #4caf50; }
.label-color-2 { background-color: #ff9800; }
.label-color-3 { background-color: #ba68c8; }
.label-color-4 { background-color: #26a69a; }
.label-color-5 { background-color: #8d6e63; }
.label-color-6 { background-color: #607d8b; }
.label-color-7 { background-color: #fbc02d; }
@media only screen and (max-width: 600px) {
.chip-header {
border-radius: 0 !important;
border-right: none !important;
border-left: none !important;
}
.chip-body {
border-radius: 0 !important;
border-right: none !important;
border-left: none !important;
padding: 5px !important;
}
.test-result {
padding: 0 !important;
}
.test-case-column {
border-radius: 0 !important;
margin: 0 !important;
}
}

View file

@ -17,138 +17,107 @@
import './htmlReport.css';
import * as React from 'react';
import ansi2html from 'ansi-to-html';
import { SplitView } from '../components/splitView';
import { TreeItem } from '../components/treeItem';
import { TabbedPane } from '../traceViewer/ui/tabbedPane';
import { msToString } from '../uiUtils';
import type { ProjectTreeItem, SuiteTreeItem, TestCase, TestResult, TestStep, TestTreeItem, Location, TestFile, Stats, TestAttachment } from '@playwright/test/src/reporters/html';
type Filter = 'failing' | 'all';
type TestId = {
fileId: string;
testId: string;
};
import type { TestCase, TestResult, TestStep, TestFile, Stats, TestAttachment, HTMLReport, TestFileSummary } from '@playwright/test/src/reporters/html';
export const Report: React.FC = () => {
const [report, setReport] = React.useState<ProjectTreeItem[]>([]);
const [fetchError, setFetchError] = React.useState<string | undefined>();
const [testId, setTestId] = React.useState<TestId | undefined>();
const [filter, setFilter] = React.useState<Filter>('failing');
const [report, setReport] = React.useState<HTMLReport | undefined>();
const [expandedFiles, setExpandedFiles] = React.useState<Set<string>>(new Set());
React.useEffect(() => {
if (report)
return;
(async () => {
try {
const result = await fetch('data/projects.json', { cache: 'no-cache' });
const json = (await result.json()) as ProjectTreeItem[];
const hasFailures = !!json.find(p => !p.stats.ok);
if (!hasFailures)
setFilter('all');
setReport(json);
const report = await fetch('data/report.json', { cache: 'no-cache' }).then(r => r.json() as Promise<HTMLReport>);
if (report.files.length)
expandedFiles.add(report.files[0].fileId);
setReport(report);
} catch (e) {
setFetchError(e.message);
}
})();
}, []);
}, [report, expandedFiles]);
return <div className='hbox columns'>
<SplitView sidebarSize={300} orientation='horizontal' sidebarIsFirst={true}>
<TestCaseView key={testId?.testId} testId={testId}></TestCaseView>
<div className='suite-tree-column'>
<div className='tab-strip'>
<div key='all' title='All tests' className={'tab-element' + ('all' === filter ? ' selected' : '')} onClick={() => setFilter('all')}>All</div>
<div key='failing' title='Failing tests' className={'tab-element' + ('failing' === filter ? ' selected' : '')} onClick={() => setFilter('failing')}>Failing</div>
</div>
{!fetchError && filter === 'all' && report?.map((project, i) => <ProjectTreeItemView key={i} project={project} setTestId={setTestId} testId={testId} failingOnly={false}></ProjectTreeItemView>)}
{!fetchError && filter === 'failing' && report?.filter(p => !p.stats.ok).map((project, i) => <ProjectTreeItemView key={i} project={project} setTestId={setTestId} testId={testId} failingOnly={true}></ProjectTreeItemView>)}
</div>
</SplitView>
return <div className='vbox columns'>
{!fetchError && <div className='flow-container'>
<Route params=''>
<AllTestFilesSummaryView report={report} isFileExpanded={fileId => expandedFiles.has(fileId)} setFileExpanded={(fileId, expanded) => {
const newExpanded = new Set(expandedFiles);
if (expanded)
newExpanded.add(fileId);
else
newExpanded.delete(fileId);
setExpandedFiles(newExpanded);
}}></AllTestFilesSummaryView>
</Route>
<Route params='testId'>
{!!report?.testIdToFileId && <TestCaseView report={report}></TestCaseView>}
</Route>
</div>}
</div>;
};
const ProjectTreeItemView: React.FC<{
project: ProjectTreeItem;
testId?: TestId,
setTestId: (id: TestId) => void;
failingOnly: boolean;
}> = ({ project, testId, setTestId, failingOnly }) => {
const hasChildren = !(failingOnly && project.stats.ok);
return <TreeItem title={<div className='hbox'>
<div className='tree-text'>{project.name || 'Project'}</div>
<div style={{ flex: 'auto' }}></div>
<StatsView stats={project.stats}></StatsView>
</div>
} loadChildren={hasChildren ? () => {
return project.suites.filter(s => !(failingOnly && s.stats.ok)).map((s, i) => <SuiteTreeItemView key={i} suite={s} setTestId={setTestId} testId={testId} depth={1} failingOnly={failingOnly}></SuiteTreeItemView>) || [];
} : undefined} depth={0} expandByDefault={true}></TreeItem>;
const AllTestFilesSummaryView: React.FC<{
report?: HTMLReport;
isFileExpanded: (fileId: string) => boolean;
setFileExpanded: (fileId: string, expanded: boolean) => void;
}> = ({ report, isFileExpanded, setFileExpanded }) => {
return <div className='file-summary-list'>
{report && (report.files || []).map((file, i) => <TestFileSummaryView key={`file-${i}`} report={report} file={file} isFileExpanded={isFileExpanded} setFileExpanded={setFileExpanded}></TestFileSummaryView>)}
</div>;
};
const SuiteTreeItemView: React.FC<{
suite: SuiteTreeItem,
testId?: TestId,
setTestId: (id: TestId) => void;
failingOnly: boolean;
depth: number,
}> = ({ suite, testId, setTestId, failingOnly, depth }) => {
return <TreeItem title={<div className='hbox'>
<div className='tree-text' title={suite.title}>{suite.title || '<untitled>'}</div>
<div style={{ flex: 'auto' }}></div>
<StatsView stats={suite.stats}></StatsView>
</div>
} loadChildren={() => {
const suiteChildren = suite.suites.filter(s => !(failingOnly && s.stats.ok)).map((s, i) => <SuiteTreeItemView key={i} suite={s} setTestId={setTestId} testId={testId} depth={depth + 1} failingOnly={failingOnly}></SuiteTreeItemView>) || [];
const suiteCount = suite.suites.length;
const testChildren = suite.tests.filter(t => !(failingOnly && t.ok)).map((t, i) => <TestTreeItemView key={i + suiteCount} test={t} setTestId={setTestId} testId={testId} depth={depth + 1}></TestTreeItemView>) || [];
return [...suiteChildren, ...testChildren];
}} depth={depth}></TreeItem>;
};
const TestTreeItemView: React.FC<{
test: TestTreeItem,
testId?: TestId,
setTestId: (id: TestId) => void;
depth: number,
}> = ({ test, testId, setTestId, depth }) => {
return <TreeItem title={<div className='hbox'>
{statusIcon(test.outcome)}<div className='tree-text' title={test.title}>{test.title}</div>
<div style={{ flex: 'auto' }}></div>
{<div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{msToString(test.duration)}</div>}
</div>
} selected={test.testId === testId?.testId} depth={depth} onClick={() => setTestId({ testId: test.testId, fileId: test.fileId })}></TreeItem>;
const TestFileSummaryView: React.FC<{
report: HTMLReport;
file: TestFileSummary;
isFileExpanded: (fileId: string) => boolean;
setFileExpanded: (fileId: string, expanded: boolean) => void;
}> = ({ file, report, isFileExpanded, setFileExpanded }) => {
return <Chip expanded={isFileExpanded(file.fileId)} setExpanded={(expanded => setFileExpanded(file.fileId, expanded))} header={<span>{file.fileName}<StatsView stats={file.stats}></StatsView></span>}>
{file.tests.map((test, i) => <Link key={`test-${i}`} href={`/?testId=${test.testId}`}>
<div className='test-summary'>
<span style={{ float: 'right', marginTop: 10 }} className={'label label-color-' + (report.projectNames.indexOf(test.projectName) % 8)}>{test.projectName}</span>
{statusIcon(test.outcome)}
{test.title}
<span className='test-summary-path'> {test.path.join(' ')}</span>
</div>
</Link>)}
</Chip>;
};
const TestCaseView: React.FC<{
testId: TestId | undefined,
}> = ({ testId }) => {
const [file, setFile] = React.useState<TestFile | undefined>();
report: HTMLReport,
}> = ({ report }) => {
const [test, setTest] = React.useState<TestCase | undefined>();
React.useEffect(() => {
(async () => {
if (!testId || file?.fileId === testId.fileId)
const testId = new URL(window.location.href).searchParams.get('testId');
if (!testId || testId === test?.testId)
return;
try {
const result = await fetch(`data/${testId.fileId}.json`, { cache: 'no-cache' });
setFile((await result.json()) as TestFile);
} catch (e) {
const fileId = report.testIdToFileId[testId];
if (!fileId)
return;
const result = await fetch(`/data/${fileId}.json`, { cache: 'no-cache' });
const file = await result.json() as TestFile;
for (const t of file.tests) {
if (t.testId === testId) {
setTest(t);
break;
}
}
})();
});
let test: TestCase | undefined;
if (file && testId) {
for (const t of file.tests) {
if (t.testId === testId.testId) {
test = t;
break;
}
}
}
}, [test, report]);
const [selectedResultIndex, setSelectedResultIndex] = React.useState(0);
return <div className='test-case-column vbox'>
{ test && <div className='test-case-title'>{test?.title}</div> }
{ test && <div className='test-case-location'>{renderLocation(test.location, true)}</div> }
{ test && <TabbedPane tabs={
{test && <div className='test-case-title'>{test?.title}</div>}
{test && <div className='test-case-location'>{test.path.join(' ')}</div>}
{test && <div><span className={'label label-color-' + (report.projectNames.indexOf(test.projectName) % 8)}>{test.projectName}</span></div>}
{test && <TabbedPane tabs={
test.results.map((result, index) => ({
id: String(index),
title: <div style={{ display: 'flex', alignItems: 'center' }}>{statusIcon(result.status)} {retryLabel(index)}</div>,
@ -182,39 +151,49 @@ const TestResultView: React.FC<{
const actual = attachmentsMap.get('actual');
const diff = attachmentsMap.get('diff');
return <div className='test-result'>
{result.error && <ErrorMessage key='error-message' error={result.error}></ErrorMessage>}
{result.steps.map((step, i) => <StepTreeItem key={`step-${i}`} step={step} depth={0}></StepTreeItem>)}
{result.error && <Chip header='Errors'>
<ErrorMessage key='error-message' error={result.error} mode='light'></ErrorMessage>
</Chip>}
{!!result.steps.length && <Chip header='Test Steps'>
{result.steps.map((step, i) => <StepTreeItem key={`step-${i}`} step={step} depth={0}></StepTreeItem>)}
</Chip>}
{expected && actual && <div className='vbox'>
<ImageDiff actual={actual} expected={expected} diff={diff}></ImageDiff>
<AttachmentLink key={`expected`} attachment={expected}></AttachmentLink>
<AttachmentLink key={`actual`} attachment={actual}></AttachmentLink>
{diff && <AttachmentLink key={`diff`} attachment={diff}></AttachmentLink>}
<Chip header='Image mismatch'>
<ImageDiff actual={actual} expected={expected} diff={diff}></ImageDiff>
<AttachmentLink key={`expected`} attachment={expected}></AttachmentLink>
<AttachmentLink key={`actual`} attachment={actual}></AttachmentLink>
{diff && <AttachmentLink key={`diff`} attachment={diff}></AttachmentLink>}
</Chip>
</div>}
{!!screenshots.length && <div key='screenshots-title' className='test-overview-title'>Screenshots</div>}
{screenshots.map((a, i) => {
return <div key={`screenshot-${i}`} className='vbox'>
<img src={a.path} />
{!!screenshots.length && <Chip header='Screenshots'>
{screenshots.map((a, i) => {
return <div key={`screenshot-${i}`} className='vbox'>
<img src={a.path} />
<AttachmentLink attachment={a}></AttachmentLink>
</div>;
})}
</Chip>}
{!!traces.length && <Chip header='Traces'>
{traces.map((a, i) => <div key={`trace-${i}`} className='vbox'>
<AttachmentLink attachment={a} href={`trace/index.html?trace=${window.location.origin}/` + a.path}></AttachmentLink>
</div>)}
</Chip>}
{!!videos.length && <Chip header='Videos'>
{videos.map((a, i) => <div key={`video-${i}`} className='vbox'>
<video controls>
<source src={a.path} type={a.contentType}/>
</video>
<AttachmentLink attachment={a}></AttachmentLink>
</div>;
})}
</div>)}
</Chip>}
{!!traces.length && <div key='traces-title' className='test-overview-title'>Traces</div>}
{traces.map((a, i) => <div key={`trace-${i}`} className='vbox'>
<AttachmentLink attachment={a} href={`trace/index.html?trace=${window.location.origin}/` + a.path}></AttachmentLink>
</div>)}
{!!videos.length && <div key='videos-title' className='test-overview-title'>Videos</div>}
{videos.map((a, i) => <div key={`video-${i}`} className='vbox'>
<video controls>
<source src={a.path} type={a.contentType}/>
</video>
<AttachmentLink attachment={a}></AttachmentLink>
</div>)}
{!!otherAttachments.length && <div key='attachments-title' className='test-overview-title'>Attachments</div>}
{otherAttachments.map((a, i) => <AttachmentLink key={`attachment-link-${i}`} attachment={a}></AttachmentLink>)}
{!!otherAttachments.length && <Chip header='Attachments'>
{otherAttachments.map((a, i) => <AttachmentLink key={`attachment-link-${i}`} attachment={a}></AttachmentLink>)}
</Chip>}
</div>;
};
@ -230,7 +209,7 @@ const StepTreeItem: React.FC<{
</div>} loadChildren={step.steps.length + (step.error ? 1 : 0) ? () => {
const children = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1}></StepTreeItem>);
if (step.error)
children.unshift(<ErrorMessage key={-1} error={step.error}></ErrorMessage>);
children.unshift(<ErrorMessage key={-1} error={step.error} mode='light'></ErrorMessage>);
return children;
} : undefined} depth={depth}></TreeItem>;
};
@ -238,15 +217,16 @@ const StepTreeItem: React.FC<{
const StatsView: React.FC<{
stats: Stats
}> = ({ stats }) => {
return <div className='hbox' style={{ flex: 'none' }}>
{!!stats.expected && <div className='stats expected' title='Passed'>{stats.expected}</div>}
{!!stats.unexpected && <div className='stats unexpected' title='Failed'>{stats.unexpected}</div>}
{!!stats.flaky && <div className='stats flaky' title='Flaky'>{stats.flaky}</div>}
{!!stats.skipped && <div className='stats skipped' title='Skipped'>{stats.skipped}</div>}
</div>;
return <span className='stats-line'>
{!!stats.unexpected && <span className='stats unexpected'>{stats.unexpected} failed</span>}
{!!stats.flaky && <span className='stats flaky'>{stats.flaky} flaky</span>}
{!!stats.expected && <span className='stats expected'>{stats.expected} passed</span>}
{!!stats.skipped && <span className='stats skipped'>{stats.skipped} skipped</span>}
</span>;
};
export const AttachmentLink: React.FunctionComponent<{
const AttachmentLink: React.FunctionComponent<{
attachment: TestAttachment,
href?: string,
}> = ({ attachment, href }) => {
@ -259,7 +239,7 @@ export const AttachmentLink: React.FunctionComponent<{
} : undefined} depth={0}></TreeItem>;
};
export const ImageDiff: React.FunctionComponent<{
const ImageDiff: React.FunctionComponent<{
actual: TestAttachment,
expected: TestAttachment,
diff?: TestAttachment,
@ -284,7 +264,6 @@ export const ImageDiff: React.FunctionComponent<{
});
}
return <div className='vbox test-image-mismatch'>
<div className='test-overview-title'>Image mismatch</div>
<TabbedPane tabs={tabs} selectedTab={selectedTab} setSelectedTab={setSelectedTab} />
</div>;
};
@ -293,25 +272,27 @@ function statusIcon(status: 'failed' | 'timedOut' | 'skipped' | 'passed' | 'expe
switch (status) {
case 'failed':
case 'unexpected':
return <span className={'codicon codicon-error status-icon'}></span>;
return <svg className='octicon color-text-danger' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'>
<path fillRule='evenodd' d='M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z'></path>
</svg>;
case 'passed':
case 'expected':
return <span className={'codicon codicon-circle-filled status-icon'}></span>;
return <svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon color-icon-success'>
<path fillRule='evenodd' d='M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z'></path>
</svg>;
case 'timedOut':
return <span className={'codicon codicon-clock status-icon'}></span>;
case 'flaky':
return <span className={'codicon codicon-alert status-icon'}></span>;
return <svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon color-text-warning'>
<path fill-rule='evenodd' d='M8.22 1.754a.25.25 0 00-.44 0L1.698 13.132a.25.25 0 00.22.368h12.164a.25.25 0 00.22-.368L8.22 1.754zm-1.763-.707c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0114.082 15H1.918a1.75 1.75 0 01-1.543-2.575L6.457 1.047zM9 11a1 1 0 11-2 0 1 1 0 012 0zm-.25-5.25a.75.75 0 00-1.5 0v2.5a.75.75 0 001.5 0v-2.5z'></path>
</svg>;
case 'skipped':
return <span className={'codicon codicon-tag status-icon'}></span>;
return <svg className='octicon color-fg-muted' 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 0zm3.28 5.78a.75.75 0 00-1.06-1.06l-5.5 5.5a.75.75 0 101.06 1.06l5.5-5.5z'></path>
</svg>;
}
}
function renderLocation(location: Location | undefined, showFileName: boolean) {
if (!location)
return '';
return (showFileName ? location.file : '') + ':' + location.line;
}
function retryLabel(index: number) {
if (!index)
return 'Run';
@ -320,10 +301,17 @@ function retryLabel(index: number) {
const ErrorMessage: React.FC<{
error: string;
}> = ({ error }) => {
mode: 'dark' | 'light'
}> = ({ error, mode }) => {
const html = React.useMemo(() => {
return new ansi2html({ colors: ansiColors }).toHtml(escapeHTML(error));
}, [error]);
const config: any = {
fg: mode === 'dark' ? '#FFF' : '#252423',
bg: mode === 'dark' ? '#252423' : '#FFF',
};
if (mode === 'dark')
config.colors = ansiColors;
return new ansi2html(config).toHtml(escapeHTML(error));
}, [error, mode]);
return <div className='error-message' dangerouslySetInnerHTML={{ __html: html || '' }}></div>;
};
@ -349,3 +337,53 @@ const ansiColors = {
function escapeHTML(text: string): string {
return text.replace(/[&"<>]/g, c => ({ '&': '&amp;', '"': '&quot;', '<': '&lt;', '>': '&gt;' }[c]!));
}
const Chip: React.FunctionComponent<{
header: JSX.Element | string,
expanded?: boolean,
setExpanded?: (expanded: boolean) => void,
children?: any
}> = ({ header, expanded, setExpanded, children }) => {
return <div className='chip'>
<div className={'chip-header' + (setExpanded ? ' expanded-' + expanded : '')} onClick={() => setExpanded?.(!expanded)}>
{setExpanded && <div className={'codicon codicon-' + (expanded ? 'chevron-down' : 'chevron-right')}
style={{ cursor: 'pointer', color: 'var(--color)', marginRight: '4px' }}
/>}
{header}
</div>
{ (!setExpanded || expanded) && <div className='chip-body'>{children}</div>}
</div>;
};
function navigate(href: string) {
window.history.pushState({}, '', href);
const navEvent = new PopStateEvent('popstate');
window.dispatchEvent(navEvent);
}
const Link: React.FunctionComponent<{
href: string,
children: any
}> = ({ href, children }) => {
return <a onClick={event => {
event.preventDefault();
navigate(href);
}} className='no-decorations' href={href}>{children}</a>;
};
const Route: React.FunctionComponent<{
params: string,
children: any
}> = ({ params, children }) => {
const initialParams = [...new URL(window.location.href).searchParams.keys()].join('&');
const [currentParams, setCurrentParam] = React.useState(initialParams);
React.useEffect(() => {
const listener = () => {
const newParams = [...new URL(window.location.href).searchParams.keys()].join('&');
setCurrentParam(newParams);
};
window.addEventListener('popstate', listener);
return () => window.removeEventListener('popstate', listener);
}, []);
return currentParams === params ? children : null;
};

View file

@ -52,7 +52,7 @@
align-items: center;
justify-content: center;
user-select: none;
border-bottom: 3px solid transparent;
border-bottom: 2px solid transparent;
outline: none;
height: 100%;
}

View file

@ -316,7 +316,7 @@ export function formatTestTitle(config: FullConfig, test: TestCase, step?: TestS
const [, projectName, , ...titles] = test.titlePath();
const location = `${relativeTestPath(config, test)}:${test.location.line}:${test.location.column}`;
const projectTitle = projectName ? `[${projectName}] ` : '';
return `${projectTitle}${location} ${titles.join(' ')}${stepSuffix(step)}`;
return `${projectTitle}${location} ${titles.join(' ')}${stepSuffix(step)}`;
}
function formatTestHeader(config: FullConfig, test: TestCase, indent: string, index?: number): string {

View file

@ -32,6 +32,7 @@ export type Stats = {
flaky: number;
skipped: number;
ok: boolean;
duration: number;
};
export type Location = {
@ -40,46 +41,43 @@ export type Location = {
column: number;
};
export type ProjectTreeItem = {
name: string;
suites: SuiteTreeItem[];
stats: Stats;
export type HTMLReport = {
files: TestFileSummary[];
testIdToFileId: { [key: string]: string };
projectNames: string[];
};
export type SuiteTreeItem = {
title: string;
location?: Location;
duration: number;
suites: SuiteTreeItem[];
tests: TestTreeItem[];
stats: Stats;
};
export type TestTreeItem = {
testId: string,
fileId: string,
title: string;
location: Location;
duration: number;
outcome: 'skipped' | 'expected' | 'unexpected' | 'flaky';
ok: boolean;
};
export type TestAttachment = JsonAttachment;
export type TestFile = {
fileId: string;
path: string;
fileName: string;
tests: TestCase[];
};
export type TestCase = {
export type TestFileSummary = {
fileId: string;
fileName: string;
tests: TestCaseSummary[];
stats: Stats;
};
export type TestCaseSummary = {
testId: string,
fileId: string,
title: string;
path: string[];
projectName: string;
location: Location;
outcome: 'skipped' | 'expected' | 'unexpected' | 'flaky';
duration: number;
ok: boolean;
};
export type TestCase = TestCaseSummary & {
results: TestResult[];
};
export type TestAttachment = JsonAttachment;
export type TestResult = {
retry: number;
startTime: string;
@ -99,6 +97,11 @@ export type TestStep = {
steps: TestStep[];
};
type TestEntry = {
testCase: TestCase;
testCaseSummary: TestCaseSummary
};
class HtmlReporter {
private config!: FullConfig;
private suite!: Suite;
@ -124,16 +127,18 @@ class HtmlReporter {
const reportFolder = htmlReportFolder(this._outputFolder);
await removeFolders([reportFolder]);
const builder = new HtmlBuilder(reportFolder, this.config.rootDir);
const stats = builder.build(reports);
const ok = builder.build(reports);
if (!stats.ok && !process.env.CI && !process.env.PWTEST_SKIP_TEST_OUTPUT) {
await showHTMLReport(reportFolder);
} else {
console.log('');
console.log('All tests passed. To open last HTML report run:');
console.log(colors.cyan(`
if (!process.env.PWTEST_SKIP_TEST_OUTPUT) {
if (!ok && !process.env.CI && !process.env.PWTEST_SKIP_TEST_OUTPUT) {
await showHTMLReport(reportFolder);
} else {
console.log('');
console.log('All tests passed. To open last HTML report run:');
console.log(colors.cyan(`
npx playwright show-report
`));
}
}
}
}
@ -174,6 +179,7 @@ export async function showHTMLReport(reportFolder: string | undefined) {
class HtmlBuilder {
private _reportFolder: string;
private _tests = new Map<string, JsonTestCase>();
private _testPath = new Map<string, string[]>();
private _rootDir: string;
private _dataFolder: string;
@ -183,7 +189,7 @@ class HtmlBuilder {
this._dataFolder = path.join(this._reportFolder, 'data');
}
build(rawReports: JsonReport[]): Stats {
build(rawReports: JsonReport[]): boolean {
fs.mkdirSync(this._dataFolder, { recursive: true });
// Copy app.
@ -195,84 +201,114 @@ class HtmlBuilder {
const traceViewerFolder = path.join(require.resolve('playwright-core'), '..', 'lib', 'webpack', 'traceViewer');
const traceViewerTargetFolder = path.join(this._reportFolder, 'trace');
fs.mkdirSync(traceViewerTargetFolder, { recursive: true });
// TODO (#9471): remove file filter when the babel build is fixed.
for (const file of fs.readdirSync(traceViewerFolder)) {
if (fs.statSync(path.join(traceViewerFolder, file)).isFile())
fs.copyFileSync(path.join(traceViewerFolder, file), path.join(traceViewerTargetFolder, file));
}
for (const file of fs.readdirSync(traceViewerFolder))
fs.copyFileSync(path.join(traceViewerFolder, file), path.join(traceViewerTargetFolder, file));
const projects: ProjectTreeItem[] = [];
const data = new Map<string, { testFile: TestFile, testFileSummary: TestFileSummary }>();
for (const projectJson of rawReports) {
const suites: SuiteTreeItem[] = [];
for (const file of projectJson.suites) {
const relativeFileName = this._relativeLocation(file.location).file;
const fileId = calculateSha1(projectJson.project.name + ':' + relativeFileName);
const tests: JsonTestCase[] = [];
suites.push(this._createSuiteTreeItem(file, fileId, tests));
const testFile: TestFile = {
fileId,
path: relativeFileName,
tests: tests.map(t => this._createTestCase(t))
};
fs.writeFileSync(path.join(this._dataFolder, fileId + '.json'), JSON.stringify(testFile, undefined, 2));
const fileName = this._relativeLocation(file.location).file;
const fileId = calculateSha1(fileName);
let fileEntry = data.get(fileId);
if (!fileEntry) {
fileEntry = {
testFile: { fileId, fileName, tests: [] },
testFileSummary: { fileId, fileName, tests: [], stats: emptyStats() },
};
data.set(fileId, fileEntry);
}
const { testFile, testFileSummary } = fileEntry;
const testEntries: TestEntry[] = [];
this._processJsonSuite(file, fileId, projectJson.project.name, [], testEntries);
for (const test of testEntries) {
testFile.tests.push(test.testCase);
testFileSummary.tests.push(test.testCaseSummary);
}
}
projects.push({
name: projectJson.project.name,
suites,
stats: suites.reduce((a, s) => addStats(a, s.stats), emptyStats()),
}
let ok = true;
const testIdToFileId: { [key: string]: string } = {};
for (const [fileId, { testFile, testFileSummary }] of data) {
const stats = testFileSummary.stats;
for (const test of testFileSummary.tests) {
testIdToFileId[test.testId] = fileId;
if (test.outcome === 'expected')
++stats.expected;
if (test.outcome === 'skipped')
++stats.skipped;
if (test.outcome === 'unexpected')
++stats.unexpected;
if (test.outcome === 'flaky')
++stats.flaky;
++stats.total;
stats.duration += test.duration;
}
stats.ok = stats.unexpected + stats.flaky === 0;
if (!stats.ok)
ok = false;
testFileSummary.tests.sort((t1, t2) => {
const w1 = (t1.outcome === 'unexpected' ? 1000 : 0) + (t1.outcome === 'flaky' ? 1 : 0);
const w2 = (t2.outcome === 'unexpected' ? 1000 : 0) + (t2.outcome === 'flaky' ? 1 : 0);
if (w2 - w1)
return w2 - w1;
return t1.location.line - t2.location.line;
});
fs.writeFileSync(path.join(this._dataFolder, fileId + '.json'), JSON.stringify(testFile, undefined, 2));
}
fs.writeFileSync(path.join(this._dataFolder, 'projects.json'), JSON.stringify(projects, undefined, 2));
return projects.reduce((a, p) => addStats(a, p.stats), emptyStats());
}
private _createTestCase(test: JsonTestCase): TestCase {
return {
testId: test.testId,
title: test.title,
location: this._relativeLocation(test.location),
results: test.results.map(r => this._createTestResult(r))
const htmlReport: HTMLReport = {
files: [...data.values()].map(e => e.testFileSummary),
testIdToFileId,
projectNames: rawReports.map(r => r.project.name)
};
htmlReport.files.sort((f1, f2) => {
const w1 = f1.stats.unexpected * 1000 + f1.stats.flaky;
const w2 = f2.stats.unexpected * 1000 + f2.stats.flaky;
return w2 - w1;
});
fs.writeFileSync(path.join(this._dataFolder, 'report.json'), JSON.stringify(htmlReport, undefined, 2));
return ok;
}
private _createSuiteTreeItem(suite: JsonSuite, fileId: string, testCollector: JsonTestCase[]): SuiteTreeItem {
const suites = suite.suites.map(s => this._createSuiteTreeItem(s, fileId, testCollector));
const tests = suite.tests.map(t => this._createTestTreeItem(t, fileId));
testCollector.push(...suite.tests);
const stats = suites.reduce<Stats>((a, s) => addStats(a, s.stats), emptyStats());
for (const test of tests) {
if (test.outcome === 'expected')
++stats.expected;
if (test.outcome === 'skipped')
++stats.skipped;
if (test.outcome === 'unexpected')
++stats.unexpected;
if (test.outcome === 'flaky')
++stats.flaky;
++stats.total;
}
stats.ok = stats.unexpected + stats.flaky === 0;
return {
title: suite.title,
location: this._relativeLocation(suite.location),
duration: suites.reduce((a, s) => a + s.duration, 0) + tests.reduce((a, t) => a + t.duration, 0),
stats,
suites,
tests
};
private _processJsonSuite(suite: JsonSuite, fileId: string, projectName: string, path: string[], out: TestEntry[]) {
const newPath = [...path, suite.title];
suite.suites.map(s => this._processJsonSuite(s, fileId, projectName, newPath, out));
suite.tests.forEach(t => out.push(this._createTestEntry(t, fileId, projectName, newPath)));
}
private _createTestTreeItem(test: JsonTestCase, fileId: string): TestTreeItem {
private _createTestEntry(test: JsonTestCase, fileId: string, projectName: string, path: string[]): TestEntry {
const duration = test.results.reduce((a, r) => a + r.duration, 0);
this._tests.set(test.testId, test);
const location = this._relativeLocation(test.location);
path = [location.file + ':' + location.line, ...path.slice(1)];
this._testPath.set(test.testId, path);
return {
testId: test.testId,
fileId: fileId,
location: this._relativeLocation(test.location),
title: test.title,
duration,
outcome: test.outcome,
ok: test.ok
testCase: {
testId: test.testId,
fileId,
title: test.title,
projectName,
location,
duration,
outcome: test.outcome,
path,
results: test.results.map(r => this._createTestResult(r)),
ok: test.outcome === 'expected' || test.outcome === 'flaky',
},
testCaseSummary: {
testId: test.testId,
fileId,
title: test.title,
projectName,
location,
duration,
outcome: test.outcome,
path,
ok: test.outcome === 'expected' || test.outcome === 'flaky',
},
};
}
@ -346,18 +382,9 @@ const emptyStats = (): Stats => {
unexpected: 0,
flaky: 0,
skipped: 0,
ok: true
ok: true,
duration: 0,
};
};
const addStats = (stats: Stats, delta: Stats): Stats => {
stats.total += delta.total;
stats.skipped += delta.skipped;
stats.expected += delta.expected;
stats.unexpected += delta.unexpected;
stats.flaky += delta.flaky;
stats.ok = stats.ok && delta.ok;
return stats;
};
export default HtmlReporter;

View file

@ -59,90 +59,78 @@ test('should generate report', async ({ runInlineTest }, testInfo) => {
});
`,
}, { reporter: 'dot,html', retries: 1 });
const report = testInfo.outputPath('playwright-report', 'data', 'projects.json');
const report = testInfo.outputPath('playwright-report', 'data', 'report.json');
const reportObject = JSON.parse(fs.readFileSync(report, 'utf-8'));
delete reportObject[0].suites[0].duration;
delete reportObject[0].suites[0].location.line;
delete reportObject[0].suites[0].location.column;
delete reportObject.testIdToFileId;
delete reportObject.files[0].fileId;
delete reportObject.files[0].stats.duration;
const fileNames = new Set<string>();
for (const test of reportObject[0].suites[0].tests) {
for (const test of reportObject.files[0].tests) {
fileNames.add(testInfo.outputPath('playwright-report', 'data', test.fileId + '.json'));
delete test.testId;
delete test.fileId;
delete test.location.line;
delete test.location.column;
delete test.duration;
delete test.path;
}
expect(reportObject[0]).toEqual({
name: 'project-name',
suites: [
expect(reportObject).toEqual({
files: [
{
title: 'a.test.js',
location: {
file: 'a.test.js'
},
fileName: 'a.test.js',
tests: [
{
title: 'fails',
projectName: 'project-name',
location: {
file: 'a.test.js'
},
outcome: 'unexpected',
ok: false
},
{
title: 'flaky',
projectName: 'project-name',
location: {
file: 'a.test.js'
},
outcome: 'flaky',
ok: true
},
{
title: 'passes',
projectName: 'project-name',
location: {
file: 'a.test.js'
},
outcome: 'expected',
ok: true
},
{
title: 'skip',
projectName: 'project-name',
location: {
file: 'a.test.js'
},
outcome: 'skipped',
ok: false
}
],
stats: {
total: 4,
expected: 1,
unexpected: 1,
flaky: 1,
skipped: 1,
ok: false
},
suites: [],
tests: [
{
location: {
file: 'a.test.js'
},
title: 'passes',
outcome: 'expected',
ok: true
},
{
location: {
file: 'a.test.js'
},
title: 'fails',
outcome: 'unexpected',
ok: false
},
{
location: {
file: 'a.test.js'
},
title: 'skip',
outcome: 'skipped',
ok: true
},
{
location: {
file: 'a.test.js'
},
title: 'flaky',
outcome: 'flaky',
ok: true
}
]
ok: false,
}
}
],
stats: {
total: 4,
expected: 1,
unexpected: 1,
flaky: 1,
skipped: 1,
ok: false
}
projectNames: [
'project-name'
]
});
expect(fileNames.size).toBe(1);
const fileName = fileNames.values().next().value;
const testCase = JSON.parse(fs.readFileSync(fileName, 'utf-8'));
expect(testCase.tests).toHaveLength(4);
expect(testCase.tests.map(t => t.title)).toEqual(['passes', 'fails', 'skip', 'flaky']);
expect(testCase).toBeTruthy();
});
test('should not throw when attachment is missing', async ({ runInlineTest }) => {
@ -185,7 +173,6 @@ test('should include image diff', async ({ runInlineTest, page, showReport }) =>
expect(result.failed).toBe(1);
await showReport();
await page.click('text=a.test.js');
await page.click('text=fails');
const imageDiff = page.locator('.test-image-mismatch');
const image = imageDiff.locator('img');
@ -221,7 +208,6 @@ test('should include screenshot on failure', async ({ runInlineTest, page, showR
expect(result.failed).toBe(1);
await showReport();
await page.click('text=a.test.js');
await page.click('text=fails');
await expect(page.locator('text=Screenshots')).toBeVisible();
await expect(page.locator('img')).toBeVisible();
@ -245,7 +231,6 @@ test('should include stdio', async ({ runInlineTest, page, showReport }) => {
expect(result.failed).toBe(1);
await showReport();
await page.click('text=a.test.js');
await page.click('text=fails');
await page.locator('text=stdout').click();
await expect(page.locator('.attachment-body')).toHaveText('First line\nSecond line');