From 8991bbde339127305791080f294104eb40744254 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 29 Oct 2021 15:24:08 -0800 Subject: [PATCH] feat(html): live filtering, opt-out from auto-open (#9889) --- docs/src/test-reporters-js.md | 26 ++++ .../src/web/htmlReport/htmlReport.css | 69 +++++------ .../src/web/htmlReport/htmlReport.tsx | 116 ++++++++++-------- .../playwright-test/src/reporters/html.ts | 22 ++-- tests/config/default.config.ts | 8 +- 5 files changed, 137 insertions(+), 104 deletions(-) diff --git a/docs/src/test-reporters-js.md b/docs/src/test-reporters-js.md index 0285e4e43f..fb3b3104ab 100644 --- a/docs/src/test-reporters-js.md +++ b/docs/src/test-reporters-js.md @@ -238,6 +238,32 @@ HTML reporter produces a self-contained folder that contains report for the test npx playwright test --reporter=html ``` +By default, HTML report is opened automatically if some of the tests failed. You can control this behavior via the +`open` property in the Playwright config. The possible values for that property are `always`, `never` and `on-failure` +(default). + +```js js-flavor=js +// playwright.config.js +// @ts-check + +/** @type {import('@playwright/test').PlaywrightTestConfig} */ +const config = { + reporter: [ ['html', { open: 'never' }] ], +}; + +module.exports = config; +``` + +```js js-flavor=ts +// playwright.config.ts +import { PlaywrightTestConfig } from '@playwright/test'; + +const config: PlaywrightTestConfig = { + reporter: [ ['html', { open: 'never' }] ], +}; +export default config; +``` + By default, report is written into the `playwright-report` folder in the current working directory. One can override that location using the `PLAYWRIGHT_HTML_REPORT` environment variable or a reporter configuration. diff --git a/packages/playwright-core/src/web/htmlReport/htmlReport.css b/packages/playwright-core/src/web/htmlReport/htmlReport.css index c6f6791f1d..f96d43c491 100644 --- a/packages/playwright-core/src/web/htmlReport/htmlReport.css +++ b/packages/playwright-core/src/web/htmlReport/htmlReport.css @@ -32,6 +32,10 @@ html, body { overscroll-behavior-x: none; } +body { + width: 100vw; +} + body { overflow: auto; } @@ -349,6 +353,7 @@ a.no-decorations { } .color-text-warning { + color: var(--color-checks-step-warning-text) !important; } .color-fg-muted { @@ -377,36 +382,34 @@ a.no-decorations { @media(prefers-color-scheme: light) { .label-color-0 { - background-color: var(--color-scale-blue-4); - color: var(--color-scale-white); + background-color: var(--color-scale-blue-0); + color: var(--color-scale-blue-6); + border: 1px solid var(--color-scale-blue-4); } .label-color-1 { - background-color: var(--color-scale-green-4); - color: var(--color-scale-white); + background-color: var(--color-scale-yellow-0); + color: var(--color-scale-yellow-6); + border: 1px solid var(--color-scale-yellow-4); } .label-color-2 { - background-color: var(--color-scale-yellow-4); - color: var(--color-scale-white); + background-color: var(--color-scale-purple-0); + color: var(--color-scale-purple-6); + border: 1px solid var(--color-scale-purple-4); } .label-color-3 { - background-color: var(--color-scale-orange-4); - color: var(--color-scale-white); + background-color: var(--color-scale-pink-0); + color: var(--color-scale-pink-6); + border: 1px solid var(--color-scale-pink-4); } .label-color-4 { - background-color: var(--color-scale-red-4); - color: var(--color-scale-white); + background-color: var(--color-scale-coral-0); + color: var(--color-scale-coral-6); + border: 1px solid var(--color-scale-coral-4); } .label-color-5 { - background-color: var(--color-scale-purple-4); - color: var(--color-scale-white); - } - .label-color-6 { - background-color: var(--color-scale-pink-4); - color: var(--color-scale-white); - } - .label-color-7 { - background-color: var(--color-scale-coral-4); - color: var(--color-scale-white); + background-color: var(--color-scale-orange-0); + color: var(--color-scale-orange-6); + border: 1px solid var(--color-scale-orange-4); } } @@ -417,40 +420,30 @@ a.no-decorations { border: 1px solid var(--color-scale-blue-4); } .label-color-1 { - background-color: var(--color-scale-green-9); - color: var(--color-scale-green-2); - border: 1px solid var(--color-scale-green-4); - } - .label-color-2 { background-color: var(--color-scale-yellow-9); color: var(--color-scale-yellow-2); border: 1px solid var(--color-scale-yellow-4); } - .label-color-3 { - background-color: var(--color-scale-orange-9); - color: var(--color-scale-orange-2); - border: 1px solid var(--color-scale-orange-4); - } - .label-color-4 { - background-color: var(--color-scale-red-9); - color: var(--color-scale-red-2); - border: 1px solid var(--color-scale-red-4); - } - .label-color-5 { + .label-color-2 { background-color: var(--color-scale-purple-9); color: var(--color-scale-purple-2); border: 1px solid var(--color-scale-purple-4); } - .label-color-6 { + .label-color-3 { background-color: var(--color-scale-pink-9); color: var(--color-scale-pink-2); border: 1px solid var(--color-scale-pink-4); } - .label-color-7 { + .label-color-4 { background-color: var(--color-scale-coral-9); color: var(--color-scale-coral-2); border: 1px solid var(--color-scale-coral-4); } + .label-color-5 { + background-color: var(--color-scale-orange-9); + color: var(--color-scale-orange-2); + border: 1px solid var(--color-scale-orange-4); + } } .d-flex { diff --git a/packages/playwright-core/src/web/htmlReport/htmlReport.tsx b/packages/playwright-core/src/web/htmlReport/htmlReport.tsx index 49cd131ccd..fb1564ad29 100644 --- a/packages/playwright-core/src/web/htmlReport/htmlReport.tsx +++ b/packages/playwright-core/src/web/htmlReport/htmlReport.tsx @@ -23,11 +23,12 @@ import { msToString } from '../uiUtils'; import type { TestCase, TestResult, TestStep, TestFile, Stats, TestAttachment, HTMLReport, TestFileSummary, TestCaseSummary } from '@playwright/test/src/reporters/html'; export const Report: React.FC = () => { + const searchParams = new URLSearchParams(window.location.hash.slice(1)); + const [fetchError, setFetchError] = React.useState(); const [report, setReport] = React.useState(); 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') || ''); + const [filterText, setFilterText] = React.useState(searchParams.get('q') || ''); React.useEffect(() => { if (report) @@ -40,8 +41,8 @@ export const Report: React.FC = () => { setFetchError(e.message); } window.addEventListener('popstate', () => { - setNavigationId(Date.now()); - setFilterText(new URL(window.location.href).searchParams.get('q') || ''); + const params = new URLSearchParams(window.location.hash.slice(1)); + setFilterText(params.get('q') || ''); }); })(); }, [report]); @@ -51,10 +52,10 @@ export const Report: React.FC = () => { return
{!fetchError &&
- + - + {!!report && } @@ -67,29 +68,43 @@ const AllTestFilesSummaryView: React.FC<{ report?: HTMLReport, expandedFiles: Map, setExpandedFiles: (value: Map) => void, - navigationId: number, - filter: Filter -}> = ({ report, filter, expandedFiles, setExpandedFiles, navigationId }) => { - const inputRef = React.useRef(null); + filter: Filter, + filterText: string, + setFilterText: (filter: string) => void, +}> = ({ report, filter, expandedFiles, setExpandedFiles, filterText, setFilterText }) => { + + const filteredFiles = React.useMemo(() => { + const result: { file: TestFileSummary, defaultExpanded: boolean }[] = []; + let visibleTests = 0; + for (const file of report?.files || []) { + const tests = file.tests.filter(t => filter.matches(t)); + visibleTests += tests.length; + if (tests.length) + result.push({ file, defaultExpanded: visibleTests < 200 }); + } + return result; + }, [report, filter]); return
{report &&
{ event.preventDefault(); - navigate(`?q=${inputRef.current?.value || ''}`); + navigate(`#?q=${filterText || ''}`); } }> {/* Use navigationId to reset defaultValue */} - + { + setFilterText(e.target.value); + }}>
} - {report && (report.files || []).filter(f => !!f.tests.find(t => filter.matches(t))).map((file, i) => { + {report && filteredFiles.map(({ file, defaultExpanded }) => { return { const value = expandedFiles.get(fileId); if (value === undefined) - return i === 0; + return defaultExpanded; return !!value; }} setFileExpanded={(fileId, expanded) => { @@ -124,13 +139,12 @@ const TestFileSummaryView: React.FC<{ header={ {msToString(file.stats.duration)} {file.fileName} - }> {file.tests.filter(t => filter.matches(t)).map(test =>
{msToString(test.duration)} {statusIcon(test.outcome)} - + {test.title} — {test.path.join(' › ')} @@ -144,10 +158,11 @@ const TestFileSummaryView: React.FC<{ const TestCaseView: React.FC<{ report: HTMLReport, }> = ({ report }) => { + const searchParams = new URLSearchParams(window.location.hash.slice(1)); const [test, setTest] = React.useState(); + const testId = searchParams.get('testId'); React.useEffect(() => { (async () => { - const testId = new URL(window.location.href).searchParams.get('testId'); if (!testId || testId === test?.testId) return; const fileId = testId.split('-')[0]; @@ -162,7 +177,7 @@ const TestCaseView: React.FC<{ } } })(); - }, [test, report]); + }, [test, report, testId]); const [selectedResultIndex, setSelectedResultIndex] = React.useState(0); return
@@ -266,33 +281,23 @@ const StepTreeItem: React.FC<{ } : undefined} depth={depth}>; }; -const StatsInlineView: React.FC<{ - stats: Stats -}> = ({ stats }) => { - return - {!!stats.expected && Passed {stats.expected}} - {!!stats.unexpected && Failed {stats.unexpected}} - {!!stats.flaky && Flaky {stats.flaky}} - ; -}; - const StatsNavView: React.FC<{ stats: Stats }> = ({ stats }) => { return ; @@ -436,8 +441,8 @@ const ProjectLink: React.FunctionComponent<{ report: HTMLReport, projectName: string, }> = ({ report, projectName }) => { - return - + return + {projectName} ; @@ -448,22 +453,18 @@ const Link: React.FunctionComponent<{ className?: string, children: any, }> = ({ href, className, children }) => { - return { - event.preventDefault(); - event.stopPropagation(); - navigate(href); - }} href={href}>{children}; + return {children}; }; const Route: React.FunctionComponent<{ params: string, children: any }> = ({ params, children }) => { - const initialParams = [...new URL(window.location.href).searchParams.keys()].join('&'); + const initialParams = [...new URLSearchParams(window.location.hash.slice(1)).keys()].join('&'); const [currentParams, setCurrentParam] = React.useState(initialParams); React.useEffect(() => { const listener = () => { - const newParams = [...new URL(window.location.href).searchParams.keys()].join('&'); + const newParams = [...new URLSearchParams(window.location.hash.slice(1)).keys()].join('&'); setCurrentParam(newParams); }; window.addEventListener('popstate', listener); @@ -476,28 +477,33 @@ 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; + empty(): boolean { + return this.project.size + this.outcome.size + this.text.length === 0; } static parse(expression: string): Filter { - const filter = new Filter(expression); + const filter = new Filter(); 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]) { + if (match[i] === 'p:' && match[i + 1]) { filter.project.add(match[++i]); continue; } - if (match[i] === 'outcome:' && match[i + 1]) { - filter.outcome.add(match[++i]); + if (match[i] === 's:' && match[i + 1]) { + const status = match[++i]; + if (status === 'passed') + filter.outcome.add('expected'); + else if (status === 'failed') + filter.outcome.add('unexpected'); + else + filter.outcome.add(status); continue; } - filter.text.push(match[i]); + filter.text.push(match[i].toLowerCase()); } return filter; } @@ -508,7 +514,9 @@ class Filter { if (this.outcome.size && !this.outcome.has(test.outcome)) return false; if (this.text.length) { - const fullTitle = test.path.join(' ') + test.title; + if (!(test as any).fullTitle) + (test as any).fullTitle = (test.path.join(' ') + test.title).toLowerCase(); + const fullTitle = (test as any).fullTitle; const matches = !!this.text.find(t => fullTitle.includes(t)); if (!matches) return false; diff --git a/packages/playwright-test/src/reporters/html.ts b/packages/playwright-test/src/reporters/html.ts index 950d14a9ca..8a35bc5b9d 100644 --- a/packages/playwright-test/src/reporters/html.ts +++ b/packages/playwright-test/src/reporters/html.ts @@ -105,10 +105,12 @@ class HtmlReporter { private config!: FullConfig; private suite!: Suite; private _outputFolder: string | undefined; + private _open: 'always' | 'never' | 'on-failure'; - constructor(options: { outputFolder?: string } = {}) { + constructor(options: { outputFolder?: string, open?: 'always' | 'never' | 'on-failure' } = {}) { // TODO: resolve relative to config. this._outputFolder = options.outputFolder; + this._open = options.open || 'on-failure'; } onBegin(config: FullConfig, suite: Suite) { @@ -128,16 +130,18 @@ class HtmlReporter { const builder = new HtmlBuilder(reportFolder, this.config.rootDir); const ok = builder.build(reports); - 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(` + if (process.env.PWTEST_SKIP_TEST_OUTPUT || process.env.CI) + return; + + const shouldOpen = this._open === 'always' || (!ok && this._open === 'on-failure'); + if (shouldOpen) { + await showHTMLReport(reportFolder); + } else { + console.log(''); + console.log('To open last HTML report run:'); + console.log(colors.cyan(` npx playwright show-report `)); - } } } } diff --git a/tests/config/default.config.ts b/tests/config/default.config.ts index 1c92b5b5eb..3a44ff344a 100644 --- a/tests/config/default.config.ts +++ b/tests/config/default.config.ts @@ -48,9 +48,11 @@ const config: Config