diff --git a/packages/playwright-core/src/web/htmlReport/htmlReport.css b/packages/playwright-core/src/web/htmlReport/htmlReport.css index 9e7631a0fc..c6f6791f1d 100644 --- a/packages/playwright-core/src/web/htmlReport/htmlReport.css +++ b/packages/playwright-core/src/web/htmlReport/htmlReport.css @@ -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; + } } diff --git a/packages/playwright-core/src/web/htmlReport/htmlReport.tsx b/packages/playwright-core/src/web/htmlReport/htmlReport.tsx index 42d773ee41..49cd131ccd 100644 --- a/packages/playwright-core/src/web/htmlReport/htmlReport.tsx +++ b/packages/playwright-core/src/web/htmlReport/htmlReport.tsx @@ -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(); const [report, setReport] = React.useState(); - const [expandedFiles, setExpandedFiles] = React.useState>(new Set()); + const [expandedFiles, setExpandedFiles] = React.useState>(new Map()); + const [navigationId, setNavigationId] = React.useState(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); - 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
{!fetchError &&
- expandedFiles.has(fileId)} setFileExpanded={(fileId, expanded) => { - const newExpanded = new Set(expandedFiles); - if (expanded) - newExpanded.add(fileId); - else - newExpanded.delete(fileId); - setExpandedFiles(newExpanded); - }}> + + + + {!!report && } @@ -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, + setExpandedFiles: (value: Map) => void, + navigationId: number, + filter: Filter +}> = ({ report, filter, expandedFiles, setExpandedFiles, navigationId }) => { + const inputRef = React.useRef(null); return
- {report &&
- Ran {report.stats.total} tests - + {report &&
+
{ + event.preventDefault(); + navigate(`?q=${inputRef.current?.value || ''}`); + } + }> + + {/* Use navigationId to reset defaultValue */} + +
+
+ +
} - {report && (report.files || []).map((file, i) => )} + {report && (report.files || []).filter(f => !!f.tests.find(t => filter.matches(t))).map((file, i) => { + return { + 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}> + ; + })}
; }; @@ -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 setFileExpanded(file.fileId, expanded))} header={ {msToString(file.stats.duration)} {file.fileName} - + }> - {file.tests.map((test, i) => -
+ {file.tests.filter(t => filter.matches(t)).map(test => +
{msToString(test.duration)} {statusIcon(test.outcome)} - {test.title} - — {test.path.join(' › ')} - {report.projectNames.length > 1 && !!test.projectName && {test.projectName}} + + {test.title} + — {test.path.join(' › ')} + + {report.projectNames.length > 1 && !!test.projectName && + }
- )} + )} ; }; @@ -128,7 +168,7 @@ const TestCaseView: React.FC<{ return
{test &&
{test?.title}
} {test &&
{test.path.join(' › ')}
} - {test && !!test.projectName &&
{test.projectName}
} + {test && !!test.projectName && } {test && ({ id: String(index), @@ -226,18 +266,38 @@ const StepTreeItem: React.FC<{ } : undefined} depth={depth}>; }; -const StatsView: React.FC<{ +const StatsInlineView: React.FC<{ stats: Stats }> = ({ stats }) => { return - — - {!!stats.unexpected && {stats.unexpected} failed} - {!!stats.flaky && {stats.flaky} flaky} - {!!stats.expected && {stats.expected} passed} - {!!stats.skipped && {stats.skipped} skipped} + {!!stats.expected && Passed {stats.expected}} + {!!stats.unexpected && Failed {stats.unexpected}} + {!!stats.flaky && Flaky {stats.flaky}} ; }; +const StatsNavView: React.FC<{ + stats: Stats +}> = ({ stats }) => { + return ; +}; + 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 + + {projectName} + + ; +}; + const Link: React.FunctionComponent<{ href: string, - children: any -}> = ({ href, children }) => { - return { + className?: string, + children: any, +}> = ({ href, className, children }) => { + return { event.preventDefault(); + event.stopPropagation(); navigate(href); - }} className='no-decorations' href={href}>{children}; + }} href={href}>{children}; }; const Route: React.FunctionComponent<{ @@ -398,3 +471,48 @@ const Route: React.FunctionComponent<{ }, []); return currentParams === params ? children : null; }; + +class Filter { + project = new Set(); + outcome = new Set(); + 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; + } +}