chore(ui): redesign html report a bit (#9577)
This commit is contained in:
parent
f83199fb4a
commit
6afa85927e
|
|
@ -23,9 +23,9 @@
|
||||||
--active-background: #333333;
|
--active-background: #333333;
|
||||||
--color: #252423;
|
--color: #252423;
|
||||||
--red: #F44336;
|
--red: #F44336;
|
||||||
--green: #4CAF50;
|
--green: #367c39;
|
||||||
--purple: #9C27B0;
|
--purple: #9C27B0;
|
||||||
--yellow: #FFC107;
|
--yellow: #ff9207;
|
||||||
--white: #FFFFFF;
|
--white: #FFFFFF;
|
||||||
--blue: #0b7ad5;
|
--blue: #0b7ad5;
|
||||||
--transparent-blue: #2196F355;
|
--transparent-blue: #2196F355;
|
||||||
|
|
|
||||||
|
|
@ -20,19 +20,31 @@ body {
|
||||||
rgb(0 0 0 / 10%) 0px -2px 4px,
|
rgb(0 0 0 / 10%) 0px -2px 4px,
|
||||||
rgb(0 0 0 / 15%) 0px -6.1px 12px,
|
rgb(0 0 0 / 15%) 0px -6.1px 12px,
|
||||||
rgb(0 0 0 / 25%) 0px 27px 28px;
|
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 {
|
.suite-tree-column {
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
flex: auto;
|
flex: auto;
|
||||||
overflow: auto;
|
|
||||||
color: #616161;
|
color: #616161;
|
||||||
background-color: #f3f3f3;
|
background-color: #f3f3f3;
|
||||||
border-left: 1px solid #dfe1e5;
|
border-left: 1px solid #dfe1e5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-case-column {
|
.test-case-column {
|
||||||
border-right: 1px solid #dfe1e5;
|
border-radius: 6px;
|
||||||
|
margin: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-item-title {
|
.tree-item-title {
|
||||||
|
|
@ -55,13 +67,12 @@ body {
|
||||||
.error-message {
|
.error-message {
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
background: #000;
|
|
||||||
color: white;
|
|
||||||
padding: 5px;
|
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
margin: 20px;
|
|
||||||
flex: none;
|
flex: none;
|
||||||
box-shadow: var(--box-shadow-thick);
|
padding: 0;
|
||||||
|
background-color: var(--color-canvas-subtle);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-icon {
|
.status-icon {
|
||||||
|
|
@ -90,13 +101,7 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
overflow: auto;
|
margin-bottom: 20px;
|
||||||
}
|
|
||||||
|
|
||||||
.test-overview-title {
|
|
||||||
padding: 30px 10px 10px;
|
|
||||||
font-size: 18px;
|
|
||||||
flex: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-result .tabbed-pane .tab-content {
|
.test-result .tabbed-pane .tab-content {
|
||||||
|
|
@ -113,10 +118,6 @@ body {
|
||||||
margin-left: 24px;
|
margin-left: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-result .tree-item-title:not(.selected):hover {
|
|
||||||
background-color: #e8e8e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-result .tree-item-title.selected {
|
.test-result .tree-item-title.selected {
|
||||||
background-color: #0060c0;
|
background-color: #0060c0;
|
||||||
color: white;
|
color: white;
|
||||||
|
|
@ -130,11 +131,22 @@ body {
|
||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.suite-tree-column .tab-strip,
|
.columns > .tab-strip {
|
||||||
.test-case-column .tab-strip {
|
font-size: 14px;
|
||||||
border: none;
|
line-height: 30px;
|
||||||
box-shadow: none;
|
color: var(--color-fg-default);
|
||||||
background-color: transparent;
|
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,
|
.suite-tree-column .tab-element,
|
||||||
|
|
@ -146,6 +158,10 @@ body {
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.test-case-column .tab-strip {
|
||||||
|
background-color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.suite-tree-column .tab-element.selected,
|
.suite-tree-column .tab-element.selected,
|
||||||
.test-case-column .tab-element.selected {
|
.test-case-column .tab-element.selected {
|
||||||
color: #555;
|
color: #555;
|
||||||
|
|
@ -156,8 +172,9 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
font-size: 18px;
|
font-weight: 400;
|
||||||
cursor: pointer;
|
font-size: 32px !important;
|
||||||
|
line-height: 1.25 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-case-location {
|
.test-case-location {
|
||||||
|
|
@ -182,26 +199,25 @@ body {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stats-line {
|
||||||
|
padding-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
.stats {
|
.stats {
|
||||||
background-color: gray;
|
|
||||||
border-radius: 2px;
|
|
||||||
min-width: 14px;
|
|
||||||
color: white;
|
|
||||||
margin: 0 2px;
|
margin: 0 2px;
|
||||||
padding: 0 2px;
|
padding: 0 2px;
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats.expected {
|
.stats.expected {
|
||||||
background-color: var(--green);
|
color: var(--green);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats.unexpected {
|
.stats.unexpected {
|
||||||
background-color: var(--red);
|
color: var(--color-danger-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats.flaky {
|
.stats.flaky {
|
||||||
background-color: var(--yellow);
|
color: var(--yellow);
|
||||||
}
|
}
|
||||||
|
|
||||||
video, img {
|
video, img {
|
||||||
|
|
@ -213,7 +229,179 @@ video, img {
|
||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.columns {
|
.flow-container {
|
||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
margin: 0 auto;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,138 +17,107 @@
|
||||||
import './htmlReport.css';
|
import './htmlReport.css';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import ansi2html from 'ansi-to-html';
|
import ansi2html from 'ansi-to-html';
|
||||||
import { SplitView } from '../components/splitView';
|
|
||||||
import { TreeItem } from '../components/treeItem';
|
import { TreeItem } from '../components/treeItem';
|
||||||
import { TabbedPane } from '../traceViewer/ui/tabbedPane';
|
import { TabbedPane } from '../traceViewer/ui/tabbedPane';
|
||||||
import { msToString } from '../uiUtils';
|
import { msToString } from '../uiUtils';
|
||||||
import type { ProjectTreeItem, SuiteTreeItem, TestCase, TestResult, TestStep, TestTreeItem, Location, TestFile, Stats, TestAttachment } from '@playwright/test/src/reporters/html';
|
import type { TestCase, TestResult, TestStep, TestFile, Stats, TestAttachment, HTMLReport, TestFileSummary } from '@playwright/test/src/reporters/html';
|
||||||
|
|
||||||
type Filter = 'failing' | 'all';
|
|
||||||
|
|
||||||
type TestId = {
|
|
||||||
fileId: string;
|
|
||||||
testId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Report: React.FC = () => {
|
export const Report: React.FC = () => {
|
||||||
const [report, setReport] = React.useState<ProjectTreeItem[]>([]);
|
|
||||||
const [fetchError, setFetchError] = React.useState<string | undefined>();
|
const [fetchError, setFetchError] = React.useState<string | undefined>();
|
||||||
const [testId, setTestId] = React.useState<TestId | undefined>();
|
const [report, setReport] = React.useState<HTMLReport | undefined>();
|
||||||
const [filter, setFilter] = React.useState<Filter>('failing');
|
const [expandedFiles, setExpandedFiles] = React.useState<Set<string>>(new Set());
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
if (report)
|
||||||
|
return;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await fetch('data/projects.json', { cache: 'no-cache' });
|
const report = await fetch('data/report.json', { cache: 'no-cache' }).then(r => r.json() as Promise<HTMLReport>);
|
||||||
const json = (await result.json()) as ProjectTreeItem[];
|
if (report.files.length)
|
||||||
const hasFailures = !!json.find(p => !p.stats.ok);
|
expandedFiles.add(report.files[0].fileId);
|
||||||
if (!hasFailures)
|
setReport(report);
|
||||||
setFilter('all');
|
|
||||||
setReport(json);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setFetchError(e.message);
|
setFetchError(e.message);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, []);
|
}, [report, expandedFiles]);
|
||||||
|
|
||||||
return <div className='hbox columns'>
|
return <div className='vbox columns'>
|
||||||
<SplitView sidebarSize={300} orientation='horizontal' sidebarIsFirst={true}>
|
{!fetchError && <div className='flow-container'>
|
||||||
<TestCaseView key={testId?.testId} testId={testId}></TestCaseView>
|
<Route params=''>
|
||||||
<div className='suite-tree-column'>
|
<AllTestFilesSummaryView report={report} isFileExpanded={fileId => expandedFiles.has(fileId)} setFileExpanded={(fileId, expanded) => {
|
||||||
<div className='tab-strip'>
|
const newExpanded = new Set(expandedFiles);
|
||||||
<div key='all' title='All tests' className={'tab-element' + ('all' === filter ? ' selected' : '')} onClick={() => setFilter('all')}>All</div>
|
if (expanded)
|
||||||
<div key='failing' title='Failing tests' className={'tab-element' + ('failing' === filter ? ' selected' : '')} onClick={() => setFilter('failing')}>Failing</div>
|
newExpanded.add(fileId);
|
||||||
</div>
|
else
|
||||||
{!fetchError && filter === 'all' && report?.map((project, i) => <ProjectTreeItemView key={i} project={project} setTestId={setTestId} testId={testId} failingOnly={false}></ProjectTreeItemView>)}
|
newExpanded.delete(fileId);
|
||||||
{!fetchError && filter === 'failing' && report?.filter(p => !p.stats.ok).map((project, i) => <ProjectTreeItemView key={i} project={project} setTestId={setTestId} testId={testId} failingOnly={true}></ProjectTreeItemView>)}
|
setExpandedFiles(newExpanded);
|
||||||
</div>
|
}}></AllTestFilesSummaryView>
|
||||||
</SplitView>
|
</Route>
|
||||||
|
<Route params='testId'>
|
||||||
|
{!!report?.testIdToFileId && <TestCaseView report={report}></TestCaseView>}
|
||||||
|
</Route>
|
||||||
|
</div>}
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProjectTreeItemView: React.FC<{
|
const AllTestFilesSummaryView: React.FC<{
|
||||||
project: ProjectTreeItem;
|
report?: HTMLReport;
|
||||||
testId?: TestId,
|
isFileExpanded: (fileId: string) => boolean;
|
||||||
setTestId: (id: TestId) => void;
|
setFileExpanded: (fileId: string, expanded: boolean) => void;
|
||||||
failingOnly: boolean;
|
}> = ({ report, isFileExpanded, setFileExpanded }) => {
|
||||||
}> = ({ project, testId, setTestId, failingOnly }) => {
|
return <div className='file-summary-list'>
|
||||||
const hasChildren = !(failingOnly && project.stats.ok);
|
{report && (report.files || []).map((file, i) => <TestFileSummaryView key={`file-${i}`} report={report} file={file} isFileExpanded={isFileExpanded} setFileExpanded={setFileExpanded}></TestFileSummaryView>)}
|
||||||
return <TreeItem title={<div className='hbox'>
|
</div>;
|
||||||
<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 SuiteTreeItemView: React.FC<{
|
const TestFileSummaryView: React.FC<{
|
||||||
suite: SuiteTreeItem,
|
report: HTMLReport;
|
||||||
testId?: TestId,
|
file: TestFileSummary;
|
||||||
setTestId: (id: TestId) => void;
|
isFileExpanded: (fileId: string) => boolean;
|
||||||
failingOnly: boolean;
|
setFileExpanded: (fileId: string, expanded: boolean) => void;
|
||||||
depth: number,
|
}> = ({ file, report, isFileExpanded, setFileExpanded }) => {
|
||||||
}> = ({ suite, testId, setTestId, failingOnly, depth }) => {
|
return <Chip expanded={isFileExpanded(file.fileId)} setExpanded={(expanded => setFileExpanded(file.fileId, expanded))} header={<span>{file.fileName}<StatsView stats={file.stats}></StatsView></span>}>
|
||||||
return <TreeItem title={<div className='hbox'>
|
{file.tests.map((test, i) => <Link key={`test-${i}`} href={`/?testId=${test.testId}`}>
|
||||||
<div className='tree-text' title={suite.title}>{suite.title || '<untitled>'}</div>
|
<div className='test-summary'>
|
||||||
<div style={{ flex: 'auto' }}></div>
|
<span style={{ float: 'right', marginTop: 10 }} className={'label label-color-' + (report.projectNames.indexOf(test.projectName) % 8)}>{test.projectName}</span>
|
||||||
<StatsView stats={suite.stats}></StatsView>
|
{statusIcon(test.outcome)}
|
||||||
</div>
|
{test.title}
|
||||||
} loadChildren={() => {
|
<span className='test-summary-path'>— {test.path.join(' › ')}</span>
|
||||||
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>) || [];
|
</div>
|
||||||
const suiteCount = suite.suites.length;
|
</Link>)}
|
||||||
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>) || [];
|
</Chip>;
|
||||||
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 TestCaseView: React.FC<{
|
const TestCaseView: React.FC<{
|
||||||
testId: TestId | undefined,
|
report: HTMLReport,
|
||||||
}> = ({ testId }) => {
|
}> = ({ report }) => {
|
||||||
const [file, setFile] = React.useState<TestFile | undefined>();
|
const [test, setTest] = React.useState<TestCase | undefined>();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (!testId || file?.fileId === testId.fileId)
|
const testId = new URL(window.location.href).searchParams.get('testId');
|
||||||
|
if (!testId || testId === test?.testId)
|
||||||
return;
|
return;
|
||||||
try {
|
const fileId = report.testIdToFileId[testId];
|
||||||
const result = await fetch(`data/${testId.fileId}.json`, { cache: 'no-cache' });
|
if (!fileId)
|
||||||
setFile((await result.json()) as TestFile);
|
return;
|
||||||
} catch (e) {
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
}, [test, report]);
|
||||||
|
|
||||||
let test: TestCase | undefined;
|
|
||||||
if (file && testId) {
|
|
||||||
for (const t of file.tests) {
|
|
||||||
if (t.testId === testId.testId) {
|
|
||||||
test = t;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [selectedResultIndex, setSelectedResultIndex] = React.useState(0);
|
const [selectedResultIndex, setSelectedResultIndex] = React.useState(0);
|
||||||
return <div className='test-case-column vbox'>
|
return <div className='test-case-column vbox'>
|
||||||
{ test && <div className='test-case-title'>{test?.title}</div> }
|
{test && <div className='test-case-title'>{test?.title}</div>}
|
||||||
{ test && <div className='test-case-location'>{renderLocation(test.location, true)}</div> }
|
{test && <div className='test-case-location'>{test.path.join(' › ')}</div>}
|
||||||
{ test && <TabbedPane tabs={
|
{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) => ({
|
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>,
|
||||||
|
|
@ -182,39 +151,49 @@ const TestResultView: React.FC<{
|
||||||
const actual = attachmentsMap.get('actual');
|
const actual = attachmentsMap.get('actual');
|
||||||
const diff = attachmentsMap.get('diff');
|
const diff = attachmentsMap.get('diff');
|
||||||
return <div className='test-result'>
|
return <div className='test-result'>
|
||||||
{result.error && <ErrorMessage key='error-message' error={result.error}></ErrorMessage>}
|
{result.error && <Chip header='Errors'>
|
||||||
{result.steps.map((step, i) => <StepTreeItem key={`step-${i}`} step={step} depth={0}></StepTreeItem>)}
|
<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'>
|
{expected && actual && <div className='vbox'>
|
||||||
<ImageDiff actual={actual} expected={expected} diff={diff}></ImageDiff>
|
<Chip header='Image mismatch'>
|
||||||
<AttachmentLink key={`expected`} attachment={expected}></AttachmentLink>
|
<ImageDiff actual={actual} expected={expected} diff={diff}></ImageDiff>
|
||||||
<AttachmentLink key={`actual`} attachment={actual}></AttachmentLink>
|
<AttachmentLink key={`expected`} attachment={expected}></AttachmentLink>
|
||||||
{diff && <AttachmentLink key={`diff`} attachment={diff}></AttachmentLink>}
|
<AttachmentLink key={`actual`} attachment={actual}></AttachmentLink>
|
||||||
|
{diff && <AttachmentLink key={`diff`} attachment={diff}></AttachmentLink>}
|
||||||
|
</Chip>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
{!!screenshots.length && <div key='screenshots-title' className='test-overview-title'>Screenshots</div>}
|
{!!screenshots.length && <Chip header='Screenshots'>
|
||||||
{screenshots.map((a, i) => {
|
{screenshots.map((a, i) => {
|
||||||
return <div key={`screenshot-${i}`} className='vbox'>
|
return <div key={`screenshot-${i}`} className='vbox'>
|
||||||
<img src={a.path} />
|
<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>
|
<AttachmentLink attachment={a}></AttachmentLink>
|
||||||
</div>;
|
</div>)}
|
||||||
})}
|
</Chip>}
|
||||||
|
|
||||||
{!!traces.length && <div key='traces-title' className='test-overview-title'>Traces</div>}
|
{!!otherAttachments.length && <Chip header='Attachments'>
|
||||||
{traces.map((a, i) => <div key={`trace-${i}`} className='vbox'>
|
{otherAttachments.map((a, i) => <AttachmentLink key={`attachment-link-${i}`} attachment={a}></AttachmentLink>)}
|
||||||
<AttachmentLink attachment={a} href={`trace/index.html?trace=${window.location.origin}/` + a.path}></AttachmentLink>
|
</Chip>}
|
||||||
</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>)}
|
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -230,7 +209,7 @@ const StepTreeItem: React.FC<{
|
||||||
</div>} loadChildren={step.steps.length + (step.error ? 1 : 0) ? () => {
|
</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>);
|
const children = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1}></StepTreeItem>);
|
||||||
if (step.error)
|
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;
|
return children;
|
||||||
} : undefined} depth={depth}></TreeItem>;
|
} : undefined} depth={depth}></TreeItem>;
|
||||||
};
|
};
|
||||||
|
|
@ -238,15 +217,16 @@ const StepTreeItem: React.FC<{
|
||||||
const StatsView: React.FC<{
|
const StatsView: React.FC<{
|
||||||
stats: Stats
|
stats: Stats
|
||||||
}> = ({ stats }) => {
|
}> = ({ stats }) => {
|
||||||
return <div className='hbox' style={{ flex: 'none' }}>
|
return <span className='stats-line'>
|
||||||
{!!stats.expected && <div className='stats expected' title='Passed'>{stats.expected}</div>}
|
—
|
||||||
{!!stats.unexpected && <div className='stats unexpected' title='Failed'>{stats.unexpected}</div>}
|
{!!stats.unexpected && <span className='stats unexpected'>{stats.unexpected} failed</span>}
|
||||||
{!!stats.flaky && <div className='stats flaky' title='Flaky'>{stats.flaky}</div>}
|
{!!stats.flaky && <span className='stats flaky'>{stats.flaky} flaky</span>}
|
||||||
{!!stats.skipped && <div className='stats skipped' title='Skipped'>{stats.skipped}</div>}
|
{!!stats.expected && <span className='stats expected'>{stats.expected} passed</span>}
|
||||||
</div>;
|
{!!stats.skipped && <span className='stats skipped'>{stats.skipped} skipped</span>}
|
||||||
|
</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AttachmentLink: React.FunctionComponent<{
|
const AttachmentLink: React.FunctionComponent<{
|
||||||
attachment: TestAttachment,
|
attachment: TestAttachment,
|
||||||
href?: string,
|
href?: string,
|
||||||
}> = ({ attachment, href }) => {
|
}> = ({ attachment, href }) => {
|
||||||
|
|
@ -259,7 +239,7 @@ export const AttachmentLink: React.FunctionComponent<{
|
||||||
} : undefined} depth={0}></TreeItem>;
|
} : undefined} depth={0}></TreeItem>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ImageDiff: React.FunctionComponent<{
|
const ImageDiff: React.FunctionComponent<{
|
||||||
actual: TestAttachment,
|
actual: TestAttachment,
|
||||||
expected: TestAttachment,
|
expected: TestAttachment,
|
||||||
diff?: TestAttachment,
|
diff?: TestAttachment,
|
||||||
|
|
@ -284,7 +264,6 @@ export const ImageDiff: React.FunctionComponent<{
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return <div className='vbox test-image-mismatch'>
|
return <div className='vbox test-image-mismatch'>
|
||||||
<div className='test-overview-title'>Image mismatch</div>
|
|
||||||
<TabbedPane tabs={tabs} selectedTab={selectedTab} setSelectedTab={setSelectedTab} />
|
<TabbedPane tabs={tabs} selectedTab={selectedTab} setSelectedTab={setSelectedTab} />
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
@ -293,25 +272,27 @@ function statusIcon(status: 'failed' | 'timedOut' | 'skipped' | 'passed' | 'expe
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'failed':
|
case 'failed':
|
||||||
case 'unexpected':
|
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 'passed':
|
||||||
case 'expected':
|
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':
|
case 'timedOut':
|
||||||
return <span className={'codicon codicon-clock status-icon'}></span>;
|
return <span className={'codicon codicon-clock status-icon'}></span>;
|
||||||
case 'flaky':
|
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':
|
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) {
|
function retryLabel(index: number) {
|
||||||
if (!index)
|
if (!index)
|
||||||
return 'Run';
|
return 'Run';
|
||||||
|
|
@ -320,10 +301,17 @@ function retryLabel(index: number) {
|
||||||
|
|
||||||
const ErrorMessage: React.FC<{
|
const ErrorMessage: React.FC<{
|
||||||
error: string;
|
error: string;
|
||||||
}> = ({ error }) => {
|
mode: 'dark' | 'light'
|
||||||
|
}> = ({ error, mode }) => {
|
||||||
const html = React.useMemo(() => {
|
const html = React.useMemo(() => {
|
||||||
return new ansi2html({ colors: ansiColors }).toHtml(escapeHTML(error));
|
const config: any = {
|
||||||
}, [error]);
|
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>;
|
return <div className='error-message' dangerouslySetInnerHTML={{ __html: html || '' }}></div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -349,3 +337,53 @@ const ansiColors = {
|
||||||
function escapeHTML(text: string): string {
|
function escapeHTML(text: string): string {
|
||||||
return text.replace(/[&"<>]/g, c => ({ '&': '&', '"': '"', '<': '<', '>': '>' }[c]!));
|
return text.replace(/[&"<>]/g, c => ({ '&': '&', '"': '"', '<': '<', '>': '>' }[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;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
border-bottom: 3px solid transparent;
|
border-bottom: 2px solid transparent;
|
||||||
outline: none;
|
outline: none;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -316,7 +316,7 @@ export function formatTestTitle(config: FullConfig, test: TestCase, step?: TestS
|
||||||
const [, projectName, , ...titles] = test.titlePath();
|
const [, projectName, , ...titles] = test.titlePath();
|
||||||
const location = `${relativeTestPath(config, test)}:${test.location.line}:${test.location.column}`;
|
const location = `${relativeTestPath(config, test)}:${test.location.line}:${test.location.column}`;
|
||||||
const projectTitle = projectName ? `[${projectName}] › ` : '';
|
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 {
|
function formatTestHeader(config: FullConfig, test: TestCase, indent: string, index?: number): string {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ export type Stats = {
|
||||||
flaky: number;
|
flaky: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
|
duration: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Location = {
|
export type Location = {
|
||||||
|
|
@ -40,46 +41,43 @@ export type Location = {
|
||||||
column: number;
|
column: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProjectTreeItem = {
|
export type HTMLReport = {
|
||||||
name: string;
|
files: TestFileSummary[];
|
||||||
suites: SuiteTreeItem[];
|
testIdToFileId: { [key: string]: string };
|
||||||
stats: Stats;
|
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 = {
|
export type TestFile = {
|
||||||
fileId: string;
|
fileId: string;
|
||||||
path: string;
|
fileName: string;
|
||||||
tests: TestCase[];
|
tests: TestCase[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TestCase = {
|
export type TestFileSummary = {
|
||||||
|
fileId: string;
|
||||||
|
fileName: string;
|
||||||
|
tests: TestCaseSummary[];
|
||||||
|
stats: Stats;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestCaseSummary = {
|
||||||
testId: string,
|
testId: string,
|
||||||
|
fileId: string,
|
||||||
title: string;
|
title: string;
|
||||||
|
path: string[];
|
||||||
|
projectName: string;
|
||||||
location: Location;
|
location: Location;
|
||||||
|
outcome: 'skipped' | 'expected' | 'unexpected' | 'flaky';
|
||||||
|
duration: number;
|
||||||
|
ok: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TestCase = TestCaseSummary & {
|
||||||
results: TestResult[];
|
results: TestResult[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TestAttachment = JsonAttachment;
|
||||||
|
|
||||||
export type TestResult = {
|
export type TestResult = {
|
||||||
retry: number;
|
retry: number;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
|
|
@ -99,6 +97,11 @@ export type TestStep = {
|
||||||
steps: TestStep[];
|
steps: TestStep[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TestEntry = {
|
||||||
|
testCase: TestCase;
|
||||||
|
testCaseSummary: TestCaseSummary
|
||||||
|
};
|
||||||
|
|
||||||
class HtmlReporter {
|
class HtmlReporter {
|
||||||
private config!: FullConfig;
|
private config!: FullConfig;
|
||||||
private suite!: Suite;
|
private suite!: Suite;
|
||||||
|
|
@ -124,16 +127,18 @@ class HtmlReporter {
|
||||||
const reportFolder = htmlReportFolder(this._outputFolder);
|
const reportFolder = htmlReportFolder(this._outputFolder);
|
||||||
await removeFolders([reportFolder]);
|
await removeFolders([reportFolder]);
|
||||||
const builder = new HtmlBuilder(reportFolder, this.config.rootDir);
|
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) {
|
if (!process.env.PWTEST_SKIP_TEST_OUTPUT) {
|
||||||
await showHTMLReport(reportFolder);
|
if (!ok && !process.env.CI && !process.env.PWTEST_SKIP_TEST_OUTPUT) {
|
||||||
} else {
|
await showHTMLReport(reportFolder);
|
||||||
console.log('');
|
} else {
|
||||||
console.log('All tests passed. To open last HTML report run:');
|
console.log('');
|
||||||
console.log(colors.cyan(`
|
console.log('All tests passed. To open last HTML report run:');
|
||||||
|
console.log(colors.cyan(`
|
||||||
npx playwright show-report
|
npx playwright show-report
|
||||||
`));
|
`));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -174,6 +179,7 @@ export async function showHTMLReport(reportFolder: string | undefined) {
|
||||||
class HtmlBuilder {
|
class HtmlBuilder {
|
||||||
private _reportFolder: string;
|
private _reportFolder: string;
|
||||||
private _tests = new Map<string, JsonTestCase>();
|
private _tests = new Map<string, JsonTestCase>();
|
||||||
|
private _testPath = new Map<string, string[]>();
|
||||||
private _rootDir: string;
|
private _rootDir: string;
|
||||||
private _dataFolder: string;
|
private _dataFolder: string;
|
||||||
|
|
||||||
|
|
@ -183,7 +189,7 @@ class HtmlBuilder {
|
||||||
this._dataFolder = path.join(this._reportFolder, 'data');
|
this._dataFolder = path.join(this._reportFolder, 'data');
|
||||||
}
|
}
|
||||||
|
|
||||||
build(rawReports: JsonReport[]): Stats {
|
build(rawReports: JsonReport[]): boolean {
|
||||||
fs.mkdirSync(this._dataFolder, { recursive: true });
|
fs.mkdirSync(this._dataFolder, { recursive: true });
|
||||||
|
|
||||||
// Copy app.
|
// Copy app.
|
||||||
|
|
@ -195,84 +201,114 @@ class HtmlBuilder {
|
||||||
const traceViewerFolder = path.join(require.resolve('playwright-core'), '..', 'lib', 'webpack', 'traceViewer');
|
const traceViewerFolder = path.join(require.resolve('playwright-core'), '..', 'lib', 'webpack', 'traceViewer');
|
||||||
const traceViewerTargetFolder = path.join(this._reportFolder, 'trace');
|
const traceViewerTargetFolder = path.join(this._reportFolder, 'trace');
|
||||||
fs.mkdirSync(traceViewerTargetFolder, { recursive: true });
|
fs.mkdirSync(traceViewerTargetFolder, { recursive: true });
|
||||||
// TODO (#9471): remove file filter when the babel build is fixed.
|
for (const file of fs.readdirSync(traceViewerFolder))
|
||||||
for (const file of fs.readdirSync(traceViewerFolder)) {
|
fs.copyFileSync(path.join(traceViewerFolder, file), path.join(traceViewerTargetFolder, file));
|
||||||
if (fs.statSync(path.join(traceViewerFolder, file)).isFile())
|
|
||||||
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) {
|
for (const projectJson of rawReports) {
|
||||||
const suites: SuiteTreeItem[] = [];
|
|
||||||
for (const file of projectJson.suites) {
|
for (const file of projectJson.suites) {
|
||||||
const relativeFileName = this._relativeLocation(file.location).file;
|
const fileName = this._relativeLocation(file.location).file;
|
||||||
const fileId = calculateSha1(projectJson.project.name + ':' + relativeFileName);
|
const fileId = calculateSha1(fileName);
|
||||||
const tests: JsonTestCase[] = [];
|
let fileEntry = data.get(fileId);
|
||||||
suites.push(this._createSuiteTreeItem(file, fileId, tests));
|
if (!fileEntry) {
|
||||||
const testFile: TestFile = {
|
fileEntry = {
|
||||||
fileId,
|
testFile: { fileId, fileName, tests: [] },
|
||||||
path: relativeFileName,
|
testFileSummary: { fileId, fileName, tests: [], stats: emptyStats() },
|
||||||
tests: tests.map(t => this._createTestCase(t))
|
};
|
||||||
};
|
data.set(fileId, fileEntry);
|
||||||
fs.writeFileSync(path.join(this._dataFolder, fileId + '.json'), JSON.stringify(testFile, undefined, 2));
|
}
|
||||||
|
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,
|
let ok = true;
|
||||||
stats: suites.reduce((a, s) => addStats(a, s.stats), emptyStats()),
|
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));
|
const htmlReport: HTMLReport = {
|
||||||
return projects.reduce((a, p) => addStats(a, p.stats), emptyStats());
|
files: [...data.values()].map(e => e.testFileSummary),
|
||||||
}
|
testIdToFileId,
|
||||||
|
projectNames: rawReports.map(r => r.project.name)
|
||||||
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))
|
|
||||||
};
|
};
|
||||||
|
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 {
|
private _processJsonSuite(suite: JsonSuite, fileId: string, projectName: string, path: string[], out: TestEntry[]) {
|
||||||
const suites = suite.suites.map(s => this._createSuiteTreeItem(s, fileId, testCollector));
|
const newPath = [...path, suite.title];
|
||||||
const tests = suite.tests.map(t => this._createTestTreeItem(t, fileId));
|
suite.suites.map(s => this._processJsonSuite(s, fileId, projectName, newPath, out));
|
||||||
testCollector.push(...suite.tests);
|
suite.tests.forEach(t => out.push(this._createTestEntry(t, fileId, projectName, newPath)));
|
||||||
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 _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);
|
const duration = test.results.reduce((a, r) => a + r.duration, 0);
|
||||||
this._tests.set(test.testId, test);
|
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 {
|
return {
|
||||||
testId: test.testId,
|
testCase: {
|
||||||
fileId: fileId,
|
testId: test.testId,
|
||||||
location: this._relativeLocation(test.location),
|
fileId,
|
||||||
title: test.title,
|
title: test.title,
|
||||||
duration,
|
projectName,
|
||||||
outcome: test.outcome,
|
location,
|
||||||
ok: test.ok
|
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,
|
unexpected: 0,
|
||||||
flaky: 0,
|
flaky: 0,
|
||||||
skipped: 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;
|
export default HtmlReporter;
|
||||||
|
|
|
||||||
|
|
@ -59,90 +59,78 @@ test('should generate report', async ({ runInlineTest }, testInfo) => {
|
||||||
});
|
});
|
||||||
`,
|
`,
|
||||||
}, { reporter: 'dot,html', retries: 1 });
|
}, { 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'));
|
const reportObject = JSON.parse(fs.readFileSync(report, 'utf-8'));
|
||||||
delete reportObject[0].suites[0].duration;
|
delete reportObject.testIdToFileId;
|
||||||
delete reportObject[0].suites[0].location.line;
|
delete reportObject.files[0].fileId;
|
||||||
delete reportObject[0].suites[0].location.column;
|
delete reportObject.files[0].stats.duration;
|
||||||
|
|
||||||
const fileNames = new Set<string>();
|
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'));
|
fileNames.add(testInfo.outputPath('playwright-report', 'data', test.fileId + '.json'));
|
||||||
delete test.testId;
|
delete test.testId;
|
||||||
delete test.fileId;
|
delete test.fileId;
|
||||||
delete test.location.line;
|
delete test.location.line;
|
||||||
delete test.location.column;
|
delete test.location.column;
|
||||||
delete test.duration;
|
delete test.duration;
|
||||||
|
delete test.path;
|
||||||
}
|
}
|
||||||
expect(reportObject[0]).toEqual({
|
expect(reportObject).toEqual({
|
||||||
name: 'project-name',
|
files: [
|
||||||
suites: [
|
|
||||||
{
|
{
|
||||||
title: 'a.test.js',
|
fileName: 'a.test.js',
|
||||||
location: {
|
tests: [
|
||||||
file: 'a.test.js'
|
{
|
||||||
},
|
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: {
|
stats: {
|
||||||
total: 4,
|
total: 4,
|
||||||
expected: 1,
|
expected: 1,
|
||||||
unexpected: 1,
|
unexpected: 1,
|
||||||
flaky: 1,
|
flaky: 1,
|
||||||
skipped: 1,
|
skipped: 1,
|
||||||
ok: false
|
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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
stats: {
|
projectNames: [
|
||||||
total: 4,
|
'project-name'
|
||||||
expected: 1,
|
]
|
||||||
unexpected: 1,
|
|
||||||
flaky: 1,
|
|
||||||
skipped: 1,
|
|
||||||
ok: false
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 }) => {
|
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);
|
expect(result.failed).toBe(1);
|
||||||
|
|
||||||
await showReport();
|
await showReport();
|
||||||
await page.click('text=a.test.js');
|
|
||||||
await page.click('text=fails');
|
await page.click('text=fails');
|
||||||
const imageDiff = page.locator('.test-image-mismatch');
|
const imageDiff = page.locator('.test-image-mismatch');
|
||||||
const image = imageDiff.locator('img');
|
const image = imageDiff.locator('img');
|
||||||
|
|
@ -221,7 +208,6 @@ test('should include screenshot on failure', async ({ runInlineTest, page, showR
|
||||||
expect(result.failed).toBe(1);
|
expect(result.failed).toBe(1);
|
||||||
|
|
||||||
await showReport();
|
await showReport();
|
||||||
await page.click('text=a.test.js');
|
|
||||||
await page.click('text=fails');
|
await page.click('text=fails');
|
||||||
await expect(page.locator('text=Screenshots')).toBeVisible();
|
await expect(page.locator('text=Screenshots')).toBeVisible();
|
||||||
await expect(page.locator('img')).toBeVisible();
|
await expect(page.locator('img')).toBeVisible();
|
||||||
|
|
@ -245,7 +231,6 @@ test('should include stdio', async ({ runInlineTest, page, showReport }) => {
|
||||||
expect(result.failed).toBe(1);
|
expect(result.failed).toBe(1);
|
||||||
|
|
||||||
await showReport();
|
await showReport();
|
||||||
await page.click('text=a.test.js');
|
|
||||||
await page.click('text=fails');
|
await page.click('text=fails');
|
||||||
await page.locator('text=stdout').click();
|
await page.locator('text=stdout').click();
|
||||||
await expect(page.locator('.attachment-body')).toHaveText('First line\nSecond line');
|
await expect(page.locator('.attachment-body')).toHaveText('First line\nSecond line');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue