feat(html): add filter field (#9874)

This commit is contained in:
Pavel Feldman 2021-10-29 08:39:34 -08:00 committed by GitHub
parent 34e55007d0
commit 0566af86e1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 290 additions and 59 deletions

View file

@ -41,14 +41,12 @@ body {
height: 100%;
color: var(--color-fg-default);
font-size: 14px;
font-family: SegoeUI-SemiBold-final,Segoe UI Semibold,SegoeUI-Regular-final,Segoe UI,"Segoe UI Web (West European)",Segoe,-apple-system,BlinkMacSystemFont,Roboto,Helvetica Neue,Tahoma,Helvetica,Arial,sans-serif;
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
min-width: 0;
min-height: 0;
}
svg {
@ -206,7 +204,8 @@ svg {
}
.stats-line {
padding-left: 5px;
font-weight: normal;
padding-left: 25px;
}
.stats {
@ -214,18 +213,6 @@ svg {
padding: 0 2px;
}
.stats.expected {
color: var(--color-success-fg);
}
.stats.unexpected {
color: var(--color-danger-fg);
}
.stats.flaky {
color: var(--color-attention-fg);
}
video, img {
flex: none;
box-shadow: var(--box-shadow-thick);
@ -241,11 +228,11 @@ video, img {
}
.file-summary-list {
padding-bottom: 20px;
padding: 20px 0;
}
.file-summary-list .chip-body a:not(:nth-child(1)) .test-summary,
.failed-test:not(:nth-child(1)) {
.file-summary-list .chip-body .test-summary:not(:first-child),
.failed-test:not(:first-child) {
border-top: 1px solid var(--color-border-default);
}
@ -466,6 +453,123 @@ a.no-decorations {
}
}
.d-flex {
display: flex !important;
}
.d-inline {
display: inline !important;
}
.pl-2 {
padding-left: 8px !important;
}
.ml-2 {
margin-left: 8px !important;
}
.no-wrap {
white-space: nowrap !important;
}
.float-left {
float: left !important;
}
article, aside, details, figcaption, figure, footer, header, main, menu, nav, section {
display: block;
}
.form-control, .form-select {
padding: 5px 12px;
font-size: 14px;
line-height: 20px;
color: var(--color-fg-default);
vertical-align: middle;
background-color: var(--color-canvas-default);
background-repeat: no-repeat;
background-position: right 8px center;
border: 1px solid var(--color-border-default);
border-radius: 6px;
outline: none;
box-shadow: var(--color-primer-shadow-inset);
}
.input-contrast {
background-color: var(--color-canvas-inset);
}
.width-full {
width: 100% !important;
}
.subnav-search {
position: relative;
}
.subnav-search-input {
width: 320px;
padding-left: 32px;
color: var(--color-fg-muted);
}
.subnav-search-icon {
position: absolute;
top: 9px;
left: 8px;
display: block;
color: var(--color-fg-muted);
text-align: center;
pointer-events: none;
}
.subnav-search-context + .subnav-search {
margin-left: -1px;
}
.subnav-item {
position: relative;
float: left;
padding: 5px 16px;
font-weight: 500;
line-height: 20px;
color: var(--color-fg-default);
border: 1px solid var(--color-border-default);
}
.subnav-item:hover {
background-color: var(--color-canvas-subtle);
}
.subnav-item:first-child {
border-top-left-radius: 6px;
border-bottom-left-radius: 6px;
}
.subnav-item:last-child {
border-top-right-radius: 6px;
border-bottom-right-radius: 6px;
}
.subnav-item + .subnav-item {
margin-left: -1px;
}
.counter {
display: inline-block;
min-width: 20px;
padding: 0 6px;
font-size: 12px;
font-weight: 500;
line-height: 18px;
color: var(--color-fg-default);
text-align: center;
background-color: var(--color-neutral-muted);
border: 1px solid transparent;
border-radius: 2em;
}
@media only screen and (max-width: 600px) {
.chip-header {
border-radius: 0 !important;
@ -488,4 +592,13 @@ a.no-decorations {
border-radius: 0 !important;
margin: 0 !important;
}
.subnav-item, .form-control {
border-radius: 0 !important;
}
.subnav-item {
padding: 5px 3px;
border: none;
}
}

View file

@ -20,12 +20,14 @@ import ansi2html from 'ansi-to-html';
import { downArrow, rightArrow, TreeItem } from '../components/treeItem';
import { TabbedPane } from '../traceViewer/ui/tabbedPane';
import { msToString } from '../uiUtils';
import type { TestCase, TestResult, TestStep, TestFile, Stats, TestAttachment, HTMLReport, TestFileSummary } from '@playwright/test/src/reporters/html';
import type { TestCase, TestResult, TestStep, TestFile, Stats, TestAttachment, HTMLReport, TestFileSummary, TestCaseSummary } from '@playwright/test/src/reporters/html';
export const Report: React.FC = () => {
const [fetchError, setFetchError] = React.useState<string | undefined>();
const [report, setReport] = React.useState<HTMLReport | undefined>();
const [expandedFiles, setExpandedFiles] = React.useState<Set<string>>(new Set());
const [expandedFiles, setExpandedFiles] = React.useState<Map<string, boolean>>(new Map());
const [navigationId, setNavigationId] = React.useState<number>(Date.now());
const [filterText, setFilterText] = React.useState(new URL(window.location.href).searchParams.get('q') || '');
React.useEffect(() => {
if (report)
@ -33,26 +35,26 @@ export const Report: React.FC = () => {
(async () => {
try {
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);
}
window.addEventListener('popstate', () => {
setNavigationId(Date.now());
setFilterText(new URL(window.location.href).searchParams.get('q') || '');
});
})();
}, [report, expandedFiles]);
}, [report]);
const filter = React.useMemo(() => Filter.parse(filterText), [filterText]);
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>
<AllTestFilesSummaryView report={report} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles} navigationId={navigationId}></AllTestFilesSummaryView>
</Route>
<Route params='q'>
<AllTestFilesSummaryView report={report} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles} navigationId={navigationId}></AllTestFilesSummaryView>
</Route>
<Route params='testId'>
{!!report && <TestCaseView report={report}></TestCaseView>}
@ -62,16 +64,50 @@ export const Report: React.FC = () => {
};
const AllTestFilesSummaryView: React.FC<{
report?: HTMLReport;
isFileExpanded: (fileId: string) => boolean;
setFileExpanded: (fileId: string, expanded: boolean) => void;
}> = ({ report, isFileExpanded, setFileExpanded }) => {
report?: HTMLReport,
expandedFiles: Map<string, boolean>,
setExpandedFiles: (value: Map<string, boolean>) => void,
navigationId: number,
filter: Filter
}> = ({ report, filter, expandedFiles, setExpandedFiles, navigationId }) => {
const inputRef = React.useRef<HTMLInputElement | null>(null);
return <div className='file-summary-list'>
{report && <div className='global-stats'>
<span>Ran {report.stats.total} tests</span>
<StatsView stats={report.stats}></StatsView>
{report && <div className='d-flex'>
<form className='subnav-search width-full' onSubmit={
event => {
event.preventDefault();
navigate(`?q=${inputRef.current?.value || ''}`);
}
}>
<svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon subnav-search-icon'>
<path fillRule='evenodd' d='M11.5 7a4.499 4.499 0 11-8.998 0A4.499 4.499 0 0111.5 7zm-.82 4.74a6 6 0 111.06-1.06l3.04 3.04a.75.75 0 11-1.06 1.06l-3.04-3.04z'></path>
</svg>
{/* Use navigationId to reset defaultValue */}
<input key={`filter-${navigationId}`} type='search' className='form-control subnav-search-input input-contrast width-full' defaultValue={filter.expression} ref={inputRef}></input>
</form>
<div className='ml-2 pl-2 d-flex'>
<StatsNavView stats={report.stats}></StatsNavView>
</div>
</div>}
{report && (report.files || []).map((file, i) => <TestFileSummaryView key={`file-${i}`} report={report} file={file} isFileExpanded={isFileExpanded} setFileExpanded={setFileExpanded}></TestFileSummaryView>)}
{report && (report.files || []).filter(f => !!f.tests.find(t => filter.matches(t))).map((file, i) => {
return <TestFileSummaryView
key={`file-${file.fileId}`}
report={report}
file={file}
isFileExpanded={fileId => {
const value = expandedFiles.get(fileId);
if (value === undefined)
return i === 0;
return !!value;
}}
setFileExpanded={(fileId, expanded) => {
const newExpanded = new Map(expandedFiles);
newExpanded.set(fileId, expanded);
setExpandedFiles(newExpanded);
}}
filter={filter}>
</TestFileSummaryView>;
})}
</div>;
};
@ -80,24 +116,28 @@ const TestFileSummaryView: React.FC<{
file: TestFileSummary;
isFileExpanded: (fileId: string) => boolean;
setFileExpanded: (fileId: string, expanded: boolean) => void;
}> = ({ file, report, isFileExpanded, setFileExpanded }) => {
filter: Filter;
}> = ({ file, report, isFileExpanded, setFileExpanded, filter }) => {
return <Chip
expanded={isFileExpanded(file.fileId)}
setExpanded={(expanded => setFileExpanded(file.fileId, expanded))}
header={<span>
<span style={{ float: 'right' }}>{msToString(file.stats.duration)}</span>
{file.fileName}
<StatsView stats={file.stats}></StatsView>
<StatsInlineView stats={file.stats}></StatsInlineView>
</span>}>
{file.tests.map((test, i) => <Link key={`test-${i}`} href={`?testId=${test.testId}`}>
<div className={'test-summary outcome-' + test.outcome}>
{file.tests.filter(t => filter.matches(t)).map(test =>
<div key={`test-${test.testId}`} className={'test-summary outcome-' + test.outcome}>
<span style={{ float: 'right' }}>{msToString(test.duration)}</span>
{statusIcon(test.outcome)}
{test.title}
<span className='test-summary-path'> {test.path.join(' ')}</span>
{report.projectNames.length > 1 && !!test.projectName && <span className={'label label-color-' + (report.projectNames.indexOf(test.projectName) % 8)}>{test.projectName}</span>}
<Link href={`?testId=${test.testId}`}>
{test.title}
<span className='test-summary-path'> {test.path.join(' ')}</span>
</Link>
{report.projectNames.length > 1 && !!test.projectName &&
<ProjectLink report={report} projectName={test.projectName}></ProjectLink>}
</div>
</Link>)}
)}
</Chip>;
};
@ -128,7 +168,7 @@ const TestCaseView: React.FC<{
return <div className='test-case-column vbox'>
{test && <div className='test-case-title'>{test?.title}</div>}
{test && <div className='test-case-location'>{test.path.join(' ')}</div>}
{test && !!test.projectName && <div><span className={'label label-color-' + (report.projectNames.indexOf(test.projectName) % 8)}>{test.projectName}</span></div>}
{test && !!test.projectName && <ProjectLink report={report} projectName={test.projectName}></ProjectLink>}
{test && <TabbedPane tabs={
test.results.map((result, index) => ({
id: String(index),
@ -226,18 +266,38 @@ const StepTreeItem: React.FC<{
} : undefined} depth={depth}></TreeItem>;
};
const StatsView: React.FC<{
const StatsInlineView: React.FC<{
stats: Stats
}> = ({ stats }) => {
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>}
{!!stats.expected && <span className='stats'>Passed <span className='counter' style={{ backgroundColor: 'var(--color-scale-green-1)' }}>{stats.expected}</span></span>}
{!!stats.unexpected && <span className='stats'>Failed <span className='counter' style={{ backgroundColor: 'var(--color-scale-red-1)' }}>{stats.unexpected}</span></span>}
{!!stats.flaky && <span className='stats'>Flaky <span className='counter' style={{ backgroundColor: 'var(--color-scale-yellow-1)' }}>{stats.flaky}</span></span>}
</span>;
};
const StatsNavView: React.FC<{
stats: Stats
}> = ({ stats }) => {
return <nav className='subnav-links d-flex no-wrap'>
<Link className='subnav-item' href='?'>
All <span className='d-inline counter'>{stats.total}</span>
</Link>
<Link className='subnav-item' href='?q=outcome:expected'>
Passed <span className='d-inline counter' style={{ backgroundColor: 'var(--color-scale-green-1)' }}>{stats.expected}</span>
</Link>
<Link className='subnav-item' href='?q=outcome:unexpected'>
Failed <span className='d-inline counter' style={{ backgroundColor: 'var(--color-scale-red-1)' }}>{stats.unexpected}</span>
</Link>
<Link className='subnav-item' href='?q=outcome:flaky'>
Flaky <span className='d-inline counter' style={{ backgroundColor: 'var(--color-scale-yellow-1)' }}>{stats.flaky}</span>
</Link>
<Link className='subnav-item' href='?q=outcome:skipped'>
Skipped <span className='d-inline counter'>{stats.skipped}</span>
</Link>
</nav>;
};
const AttachmentLink: React.FunctionComponent<{
attachment: TestAttachment,
href?: string,
@ -372,14 +432,27 @@ function navigate(href: string) {
window.dispatchEvent(navEvent);
}
const ProjectLink: React.FunctionComponent<{
report: HTMLReport,
projectName: string,
}> = ({ report, projectName }) => {
return <Link href={`?q=project:${projectName}`}>
<span className={'label label-color-' + (report.projectNames.indexOf(projectName) % 8)}>
{projectName}
</span>
</Link>;
};
const Link: React.FunctionComponent<{
href: string,
children: any
}> = ({ href, children }) => {
return <a onClick={event => {
className?: string,
children: any,
}> = ({ href, className, children }) => {
return <a className={`no-decorations${className ? ' ' + className : ''}`} onClick={event => {
event.preventDefault();
event.stopPropagation();
navigate(href);
}} className='no-decorations' href={href}>{children}</a>;
}} href={href}>{children}</a>;
};
const Route: React.FunctionComponent<{
@ -398,3 +471,48 @@ const Route: React.FunctionComponent<{
}, []);
return currentParams === params ? children : null;
};
class Filter {
project = new Set<string>();
outcome = new Set<string>();
text: string[] = [];
expression: string;
private static regex = /(".*?"|[\w]+:|[^"\s:]+)(?=\s*|\s*$)/g;
private constructor(expression: string) {
this.expression = expression;
}
static parse(expression: string): Filter {
const filter = new Filter(expression);
const match = (expression.match(Filter.regex) || []).map(t => {
return t.startsWith('"') && t.endsWith('"') ? t.substring(1, t.length - 1) : t;
});
for (let i = 0; i < match.length; ++i) {
if (match[i] === 'project:' && match[i + 1]) {
filter.project.add(match[++i]);
continue;
}
if (match[i] === 'outcome:' && match[i + 1]) {
filter.outcome.add(match[++i]);
continue;
}
filter.text.push(match[i]);
}
return filter;
}
matches(test: TestCaseSummary): boolean {
if (this.project.size && !this.project.has(test.projectName))
return false;
if (this.outcome.size && !this.outcome.has(test.outcome))
return false;
if (this.text.length) {
const fullTitle = test.path.join(' ') + test.title;
const matches = !!this.text.find(t => fullTitle.includes(t));
if (!matches)
return false;
}
return true;
}
}