From e91243ac9041eeee5d00446c25341beeb6f5e095 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 14 Sep 2021 13:55:31 -0700 Subject: [PATCH] feat(html): auto-open report (#8908) --- src/test/reporters/html.ts | 81 ++++++++++++++++++++++++++++--- src/test/reporters/raw.ts | 9 ++-- src/web/components/treeItem.tsx | 2 +- src/web/htmlReport/htmlReport.css | 27 +++++++++-- src/web/htmlReport/htmlReport.tsx | 52 ++++++++++++-------- 5 files changed, 133 insertions(+), 38 deletions(-) diff --git a/src/test/reporters/html.ts b/src/test/reporters/html.ts index 787bc59da3..d6bf8d65ed 100644 --- a/src/test/reporters/html.ts +++ b/src/test/reporters/html.ts @@ -14,13 +14,25 @@ * limitations under the License. */ +import colors from 'colors/safe'; import fs from 'fs'; +import open from 'open'; import path from 'path'; import { FullConfig, Suite } from '../../../types/testReporter'; -import { calculateSha1 } from '../../utils/utils'; +import { HttpServer } from '../../utils/httpServer'; +import { calculateSha1, removeFolders } from '../../utils/utils'; import { toPosixPath } from '../reporters/json'; import RawReporter, { JsonReport, JsonSuite, JsonTestCase, JsonTestResult, JsonTestStep } from './raw'; +export type Stats = { + total: number; + expected: number; + unexpected: number; + flaky: number; + skipped: number; + ok: boolean; +}; + export type Location = { file: string; line: number; @@ -30,7 +42,7 @@ export type Location = { export type ProjectTreeItem = { name: string; suites: SuiteTreeItem[]; - failedTests: number; + stats: Stats; }; export type SuiteTreeItem = { @@ -39,7 +51,7 @@ export type SuiteTreeItem = { duration: number; suites: SuiteTreeItem[]; tests: TestTreeItem[]; - failedTests: number; + stats: Stats; }; export type TestTreeItem = { @@ -49,6 +61,7 @@ export type TestTreeItem = { location: Location; duration: number; outcome: 'skipped' | 'expected' | 'unexpected' | 'flaky'; + ok: boolean; }; export type TestFile = { @@ -99,7 +112,26 @@ class HtmlReporter { return report; }); const reportFolder = path.resolve(process.cwd(), process.env[`PLAYWRIGHT_HTML_REPORT`] || 'playwright-report'); + await removeFolders([reportFolder]); new HtmlBuilder(reports, reportFolder, this.config.rootDir); + + if (!process.env.CI) { + const server = new HttpServer(); + server.routePrefix('/', (request, response) => { + let relativePath = request.url!; + if (relativePath === '/') + relativePath = '/index.html'; + const absolutePath = path.join(reportFolder, ...relativePath.split('/')); + return server.serveFile(response, absolutePath); + }); + const url = await server.start(); + console.log(''); + console.log(colors.cyan(` Serving HTML report at ${url}. Press Ctrl+C to quit.`)); + console.log(''); + open(url); + process.on('SIGINT', () => process.exit(0)); + await new Promise(() => {}); + } } } @@ -135,7 +167,7 @@ class HtmlBuilder { projects.push({ name: projectJson.project.name, suites, - failedTests: suites.reduce((a, s) => a + s.failedTests, 0) + stats: suites.reduce((a, s) => addStats(a, s.stats), emptyStats()), }); } fs.writeFileSync(path.join(dataFolder, 'projects.json'), JSON.stringify(projects, undefined, 2)); @@ -154,11 +186,22 @@ class HtmlBuilder { const suites = suite.suites.map(s => this._createSuiteTreeItem(s, fileId, testCollector)); const tests = suite.tests.map(t => this._createTestTreeItem(t, fileId)); testCollector.push(...suite.tests); + const stats = suites.reduce((a, s) => addStats(a, s.stats), emptyStats()); + for (const test of tests) { + if (test.outcome === 'expected') + ++stats.expected; + 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), - failedTests: suites.reduce((a, s) => a + s.failedTests, 0) + tests.reduce((a, t) => t.outcome === 'unexpected' || t.outcome === 'flaky' ? a + 1 : a, 0), + stats, suites, tests }; @@ -173,7 +216,8 @@ class HtmlBuilder { location: this._relativeLocation(test.location), title: test.title, duration, - outcome: test.outcome + outcome: test.outcome, + ok: test.ok }; } @@ -183,7 +227,7 @@ class HtmlBuilder { startTime: result.startTime, retry: result.retry, steps: result.steps.map(s => this._createTestStep(s)), - error: result.error?.message, + error: result.error, status: result.status, }; } @@ -195,7 +239,7 @@ class HtmlBuilder { duration: step.duration, steps: step.steps.map(s => this._createTestStep(s)), log: step.log, - error: step.error?.message + error: step.error }; } @@ -210,4 +254,25 @@ class HtmlBuilder { } } +const emptyStats = (): Stats => { + return { + total: 0, + expected: 0, + unexpected: 0, + flaky: 0, + skipped: 0, + ok: true + }; +}; + +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; diff --git a/src/test/reporters/raw.ts b/src/test/reporters/raw.ts index 631c094ff1..b6b13206b5 100644 --- a/src/test/reporters/raw.ts +++ b/src/test/reporters/raw.ts @@ -17,13 +17,14 @@ import fs from 'fs'; import path from 'path'; import { FullProject } from '../../../types/test'; -import { FullConfig, Location, Suite, TestCase, TestError, TestResult, TestStatus, TestStep } from '../../../types/testReporter'; +import { FullConfig, Location, Suite, TestCase, TestResult, TestStatus, TestStep } from '../../../types/testReporter'; import { assert, calculateSha1 } from '../../utils/utils'; import { sanitizeForFilePath } from '../util'; +import { formatResultFailure } from './base'; import { serializePatterns } from './json'; export type JsonLocation = Location; -export type JsonError = TestError; +export type JsonError = string; export type JsonStackFrame = { file: string, line: number, column: number }; export type JsonReport = { @@ -187,7 +188,7 @@ class RawReporter { startTime: result.startTime.toISOString(), duration: result.duration, status: result.status, - error: result.error, + error: formatResultFailure(test, result, '').join('').trim(), attachments: this._createAttachments(result), steps: this._serializeSteps(test, result.steps) }; @@ -200,7 +201,7 @@ class RawReporter { category: step.category, startTime: step.startTime.toISOString(), duration: step.duration, - error: step.error, + error: step.error?.message, steps: this._serializeSteps(test, step.steps), log: step.data.log || undefined, }; diff --git a/src/web/components/treeItem.tsx b/src/web/components/treeItem.tsx index a75452234d..15369ea529 100644 --- a/src/web/components/treeItem.tsx +++ b/src/web/components/treeItem.tsx @@ -27,7 +27,7 @@ export const TreeItem: React.FunctionComponent<{ const [expanded, setExpanded] = React.useState(expandByDefault || false); const className = selected ? 'tree-item-title selected' : 'tree-item-title'; return
-
{ onClick?.(); setExpanded(!expanded); }} > +
{ onClick?.(); setExpanded(!expanded); }} >
{title} diff --git a/src/web/htmlReport/htmlReport.css b/src/web/htmlReport/htmlReport.css index 3afea0ad45..adea104852 100644 --- a/src/web/htmlReport/htmlReport.css +++ b/src/web/htmlReport/htmlReport.css @@ -51,8 +51,9 @@ color: white; padding: 5px; overflow: auto; - margin: 20px 0; + margin: 20px; flex: none; + box-shadow: var(--box-shadow); } .status-icon { @@ -164,9 +165,6 @@ display: flex; align-items: center; padding: 0 10px 10px; - color: var(--blue); - text-decoration: underline; - cursor: pointer; } .test-details-column { @@ -183,3 +181,24 @@ overflow: hidden; text-overflow: ellipsis; } + +.stats { + background-color: gray; + border-radius: 2px; + min-width: 10px; + color: white; + margin: 0 2px; + padding: 0 2px; +} + +.stats.expected { + background-color: var(--green); +} + +.stats.unexpected { + background-color: var(--red); +} + +.stats.flaky { + background-color: var(--yellow); +} diff --git a/src/web/htmlReport/htmlReport.tsx b/src/web/htmlReport/htmlReport.tsx index 5dcab9d023..b4f5ccc953 100644 --- a/src/web/htmlReport/htmlReport.tsx +++ b/src/web/htmlReport/htmlReport.tsx @@ -21,7 +21,7 @@ import { SplitView } from '../components/splitView'; import { TreeItem } from '../components/treeItem'; import { TabbedPane } from '../traceViewer/ui/tabbedPane'; import { msToString } from '../uiUtils'; -import type { ProjectTreeItem, SuiteTreeItem, TestCase, TestResult, TestStep, TestTreeItem, Location, TestFile } from '../../test/reporters/html'; +import type { ProjectTreeItem, SuiteTreeItem, TestCase, TestResult, TestStep, TestTreeItem, Location, TestFile, Stats } from '../../test/reporters/html'; type Filter = 'Failing' | 'All'; @@ -60,7 +60,7 @@ export const Report: React.FC = () => { }}>{item}
; }) }
- {!fetchError && filter === 'All' && report?.map((project, i) => )} + {!fetchError && filter === 'All' && report?.map((project, i) => )} {!fetchError && filter === 'Failing' && report?.map((project, i) => )}
@@ -71,49 +71,52 @@ const ProjectTreeItemView: React.FC<{ project: ProjectTreeItem; testId?: TestId, setTestId: (id: TestId) => void; - failingOnly?: boolean; + failingOnly: boolean; }> = ({ project, testId, setTestId, failingOnly }) => { + const hasChildren = !(failingOnly && project.stats.ok); return - {statusIconForFailedTests(project.failedTests)}
{project.name || 'Project'}
+
{project.name || 'Project'}
+
+
- } loadChildren={() => { - return project.suites.map((s, i) => ) || []; - }} depth={0} expandByDefault={true}>; + } loadChildren={hasChildren ? () => { + return project.suites.filter(s => !(failingOnly && s.stats.ok)).map((s, i) => ) || []; + } : undefined} depth={0} expandByDefault={true}>; }; const SuiteTreeItemView: React.FC<{ suite: SuiteTreeItem, testId?: TestId, setTestId: (id: TestId) => void; + failingOnly: boolean; depth: number, showFileName: boolean, -}> = ({ suite, testId, setTestId, showFileName, depth }) => { +}> = ({ suite, testId, setTestId, showFileName, failingOnly, depth }) => { const location = renderLocation(suite.location, showFileName); return - {statusIconForFailedTests(suite.failedTests)}
{suite.title}
+
{suite.title}
+
+ {!!suite.location?.line && location &&
{location}
} } loadChildren={() => { - const suiteChildren = suite.suites.map((s, i) => ) || []; + const suiteChildren = suite.suites.filter(s => !(failingOnly && s.stats.ok)).map((s, i) => ) || []; const suiteCount = suite.suites.length; - const testChildren = suite.tests.map((t, i) => ) || []; + const testChildren = suite.tests.filter(t => !(failingOnly && t.ok)).map((t, i) => ) || []; return [...suiteChildren, ...testChildren]; }} depth={depth}>
; }; const TestTreeItemView: React.FC<{ test: TestTreeItem, - showFileName: boolean, testId?: TestId, setTestId: (id: TestId) => void; depth: number, -}> = ({ test, testId, setTestId, showFileName, depth }) => { - const fileName = test.location.file; - const name = fileName.substring(fileName.lastIndexOf('/') + 1); +}> = ({ test, testId, setTestId, depth }) => { return - {statusIcon(test.outcome)}
{test.title}
- {showFileName &&
{name}:{test.location.line}
} - {!showFileName &&
{msToString(test.duration)}
} + {statusIcon(test.outcome)}
{test.title}
+
+ {
{msToString(test.duration)}
} } selected={test.testId === testId?.testId} depth={depth} onClick={() => setTestId({ testId: test.testId, fileId: test.fileId })}>
; }; @@ -189,9 +192,16 @@ const StepTreeItem: React.FC<{ } : undefined} depth={depth}>; }; -function statusIconForFailedTests(failedTests: number) { - return failedTests ? statusIcon('failed') : statusIcon('passed'); -} +const StatsView: React.FC<{ + stats: Stats +}> = ({ stats }) => { + return
+ {!!stats.expected &&
{stats.expected}
} + {!!stats.unexpected &&
{stats.unexpected}
} + {!!stats.flaky &&
{stats.flaky}
} + {!!stats.skipped &&
{stats.skipped}
} +
; +}; function statusIcon(status: 'failed' | 'timedOut' | 'skipped' | 'passed' | 'expected' | 'unexpected' | 'flaky'): JSX.Element { switch (status) {