From a8d404cd29fad1627502e634c35d07c444d057e2 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 5 Aug 2021 13:36:47 -0700 Subject: [PATCH] feat(test-runner): basic html reporter (#7994) --- package-lock.json | 42 +++ package.json | 1 + src/test/reporters/base.ts | 19 +- src/test/reporters/html.ts | 208 +++++++++++++ src/test/reporters/json.ts | 4 +- src/web/common.css | 14 - src/web/components/expandable.tsx | 36 +++ src/web/components/splitView.tsx | 7 +- src/web/components/treeItem.tsx | 37 +++ src/web/htmlReport/htmlReport.css | 119 ++++++++ src/web/htmlReport/htmlReport.tsx | 277 ++++++++++++++++++ src/web/htmlReport/index.html | 27 ++ src/web/htmlReport/index.tsx | 27 ++ src/web/htmlReport/webpack.config.js | 50 ++++ src/web/third_party/vscode/codicon.css | 1 + src/web/traceViewer/ui/helpers.tsx | 19 -- .../traceViewer/ui/networkResourceDetails.tsx | 2 +- src/web/traceViewer/ui/tabbedPane.tsx | 4 +- tests/config/browserTest.ts | 5 + utils/build/build.js | 1 + utils/check_deps.js | 4 + 21 files changed, 857 insertions(+), 47 deletions(-) create mode 100644 src/test/reporters/html.ts create mode 100644 src/web/components/expandable.tsx create mode 100644 src/web/components/treeItem.tsx create mode 100644 src/web/htmlReport/htmlReport.css create mode 100644 src/web/htmlReport/htmlReport.tsx create mode 100644 src/web/htmlReport/index.html create mode 100644 src/web/htmlReport/index.tsx create mode 100644 src/web/htmlReport/webpack.config.js diff --git a/package-lock.json b/package-lock.json index 1b05dad2c1..6a51fb4f0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,6 +78,7 @@ "@types/yazl": "^2.4.2", "@typescript-eslint/eslint-plugin": "^4.28.4", "@typescript-eslint/parser": "^4.28.4", + "ansi-to-html": "^0.7.1", "babel-loader": "^8.2.2", "chokidar": "^3.5.0", "commonmark": "^0.29.1", @@ -2165,6 +2166,30 @@ "node": ">=4" } }, + "node_modules/ansi-to-html": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.1.tgz", + "integrity": "sha512-PPpOy/TeLE6xERG5CNNpm1cLTIW1IeWULleeVc089paF45zfz5gzNPXeSQyxt1sUiKVIYZlY86AYx3fsMdIr5w==", + "dev": true, + "dependencies": { + "entities": "^2.2.0" + }, + "bin": { + "ansi-to-html": "bin/ansi-to-html" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/ansi-to-html/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/anymatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", @@ -11831,6 +11856,23 @@ "color-convert": "^1.9.0" } }, + "ansi-to-html": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.1.tgz", + "integrity": "sha512-PPpOy/TeLE6xERG5CNNpm1cLTIW1IeWULleeVc089paF45zfz5gzNPXeSQyxt1sUiKVIYZlY86AYx3fsMdIr5w==", + "dev": true, + "requires": { + "entities": "^2.2.0" + }, + "dependencies": { + "entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "dev": true + } + } + }, "anymatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz", diff --git a/package.json b/package.json index 6345e08b5b..eb7d9e00df 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "@types/yazl": "^2.4.2", "@typescript-eslint/eslint-plugin": "^4.28.4", "@typescript-eslint/parser": "^4.28.4", + "ansi-to-html": "^0.7.1", "babel-loader": "^8.2.2", "chokidar": "^3.5.0", "commonmark": "^0.29.1", diff --git a/src/test/reporters/base.ts b/src/test/reporters/base.ts index 18e328c050..4862c42f41 100644 --- a/src/test/reporters/base.ts +++ b/src/test/reporters/base.ts @@ -146,13 +146,7 @@ export function formatFailure(config: FullConfig, test: TestCase, index?: number const tokens: string[] = []; tokens.push(formatTestHeader(config, test, ' ', index)); for (const result of test.results) { - const resultTokens: string[] = []; - if (result.status === 'timedOut') { - resultTokens.push(''); - resultTokens.push(indent(colors.red(`Timeout of ${test.timeout}ms exceeded.`), ' ')); - } - if (result.error !== undefined) - resultTokens.push(indent(formatError(result.error, test.location.file), ' ')); + const resultTokens = formatResultFailure(test, result, ' '); if (!resultTokens.length) continue; const statusSuffix = result.status === 'passed' ? ' -- passed unexpectedly' : ''; @@ -166,6 +160,17 @@ export function formatFailure(config: FullConfig, test: TestCase, index?: number return tokens.join('\n'); } +export function formatResultFailure(test: TestCase, result: TestResult, initialIndent: string): string[] { + const resultTokens: string[] = []; + if (result.status === 'timedOut') { + resultTokens.push(''); + resultTokens.push(indent(colors.red(`Timeout of ${test.timeout}ms exceeded.`), initialIndent)); + } + if (result.error !== undefined) + resultTokens.push(indent(formatError(result.error, test.location.file), initialIndent)); + return resultTokens; +} + function relativeTestPath(config: FullConfig, test: TestCase): string { return path.relative(config.rootDir, test.location.file) || path.basename(test.location.file); } diff --git a/src/test/reporters/html.ts b/src/test/reporters/html.ts new file mode 100644 index 0000000000..1be9ab1b71 --- /dev/null +++ b/src/test/reporters/html.ts @@ -0,0 +1,208 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs'; +import path from 'path'; +import { Suite, TestError, TestStatus, Location, TestCase, TestResult, TestStep, FullConfig } from '../../../types/testReporter'; +import { BaseReporter, formatResultFailure } from './base'; +import { serializePatterns, toPosixPath } from './json'; + +export type JsonStats = { expected: number, unexpected: number, flaky: number, skipped: number }; +export type JsonLocation = Location; + +export type JsonConfig = Omit & { + projects: { + outputDir: string, + repeatEach: number, + retries: number, + metadata: any, + name: string, + testDir: string, + testIgnore: string[], + testMatch: string[], + timeout: number, + }[], +}; + +export type JsonReport = { + config: JsonConfig, + stats: JsonStats, + suites: JsonSuite[], +}; + +export type JsonSuite = { + title: string; + location?: JsonLocation; + suites: JsonSuite[]; + tests: JsonTestCase[]; +}; + +export type JsonTestCase = { + title: string; + location: JsonLocation; + expectedStatus: TestStatus; + timeout: number; + annotations: { type: string, description?: string }[]; + retries: number; + results: JsonTestResult[]; + ok: boolean; + outcome: 'skipped' | 'expected' | 'unexpected' | 'flaky'; +}; + +export type JsonTestResult = { + retry: number; + workerIndex: number; + startTime: string; + duration: number; + status: TestStatus; + error?: TestError; + failureSnippet?: string; + attachments: { name: string, path?: string, body?: Buffer, contentType: string }[]; + stdout: (string | Buffer)[]; + stderr: (string | Buffer)[]; + steps: JsonTestStep[]; +}; + +export type JsonTestStep = { + title: string; + category: string, + startTime: string; + duration: number; + error?: TestError; + steps: JsonTestStep[]; +}; + +class HtmlReporter extends BaseReporter { + async onEnd() { + const targetFolder = process.env[`PLAYWRIGHT_HTML_REPORT`] || 'playwright-report'; + fs.mkdirSync(targetFolder, { recursive: true }); + const appFolder = path.join(__dirname, '..', '..', 'web', 'htmlReport'); + for (const file of fs.readdirSync(appFolder)) + fs.copyFileSync(path.join(appFolder, file), path.join(targetFolder, file)); + const stats: JsonStats = { expected: 0, unexpected: 0, skipped: 0, flaky: 0 }; + const reportFile = path.join(targetFolder, 'report.json'); + const output: JsonReport = { + config: { + ...this.config, + rootDir: toPosixPath(this.config.rootDir), + projects: this.config.projects.map(project => { + return { + outputDir: toPosixPath(project.outputDir), + repeatEach: project.repeatEach, + retries: project.retries, + metadata: project.metadata, + name: project.name, + testDir: toPosixPath(project.testDir), + testIgnore: serializePatterns(project.testIgnore), + testMatch: serializePatterns(project.testMatch), + timeout: project.timeout, + }; + }) + }, + stats, + suites: this.suite.suites.map(s => this._serializeSuite(s)) + }; + fs.writeFileSync(reportFile, JSON.stringify(output)); + } + + private _relativeLocation(location: Location | undefined): Location { + if (!location) + return { file: '', line: 0, column: 0 }; + return { + file: toPosixPath(path.relative(this.config.rootDir, location.file)), + line: location.line, + column: location.column, + }; + } + + private _serializeSuite(suite: Suite): JsonSuite { + return { + title: suite.title, + location: this._relativeLocation(suite.location), + suites: suite.suites.map(s => this._serializeSuite(s)), + tests: suite.tests.map(t => this._serializeTest(t)), + }; + } + + private _serializeTest(test: TestCase): JsonTestCase { + return { + title: test.title, + location: this._relativeLocation(test.location), + expectedStatus: test.expectedStatus, + timeout: test.timeout, + annotations: test.annotations, + retries: test.retries, + ok: test.ok(), + outcome: test.outcome(), + results: test.results.map(r => this._serializeResult(test, r)), + }; + } + + private _serializeResult(test: TestCase, result: TestResult): JsonTestResult { + return { + retry: result.retry, + workerIndex: result.workerIndex, + startTime: result.startTime.toISOString(), + duration: result.duration, + status: result.status, + error: result.error, + failureSnippet: formatResultFailure(test, result, '').join('') || undefined, + attachments: result.attachments, + stdout: result.stdout, + stderr: result.stderr, + steps: this._serializeSteps(result.steps) + }; + } + + private _serializeSteps(steps: TestStep[]): JsonTestStep[] { + const stepStack: TestStep[] = []; + const result: JsonTestStep[] = []; + const stepMap = new Map(); + for (const step of steps) { + let lastStep = stepStack[stepStack.length - 1]; + while (lastStep && !containsStep(lastStep, step)) { + stepStack.pop(); + lastStep = stepStack[stepStack.length - 1]; + } + const collection = stepMap.get(lastStep!)?.steps || result; + const jsonStep = { + title: step.title, + category: step.category, + startTime: step.startTime.toISOString(), + duration: step.duration, + error: step.error, + steps: [] + }; + collection.push(jsonStep); + stepMap.set(step, jsonStep); + stepStack.push(step); + } + return result; + } +} + + +function containsStep(outer: TestStep, inner: TestStep): boolean { + if (outer.startTime.getTime() > inner.startTime.getTime()) + return false; + if (outer.startTime.getTime() + outer.duration < inner.startTime.getTime() + inner.duration) + return false; + if (outer.startTime.getTime() + outer.duration <= inner.startTime.getTime()) + return false; + return true; +} + +export default HtmlReporter; diff --git a/src/test/reporters/json.ts b/src/test/reporters/json.ts index 3c85d7a32e..8cb60b387b 100644 --- a/src/test/reporters/json.ts +++ b/src/test/reporters/json.ts @@ -71,7 +71,7 @@ export interface JSONReportTestResult { } export type JSONReportSTDIOEntry = { text: string } | { buffer: string }; -function toPosixPath(aPath: string): string { +export function toPosixPath(aPath: string): string { return aPath.split(path.sep).join(path.posix.sep); } @@ -248,7 +248,7 @@ function stdioEntry(s: string | Buffer): any { return { buffer: s.toString('base64') }; } -function serializePatterns(patterns: string | RegExp | (string | RegExp)[]): string[] { +export function serializePatterns(patterns: string | RegExp | (string | RegExp)[]): string[] { if (!Array.isArray(patterns)) patterns = [patterns]; return patterns.map(s => s.toString()); diff --git a/src/web/common.css b/src/web/common.css index 5edf6b8900..0165f1fffb 100644 --- a/src/web/common.css +++ b/src/web/common.css @@ -101,20 +101,6 @@ svg { position: relative; } -::-webkit-scrollbar { - width: 14px; - height: 14px; -} - -::-webkit-scrollbar-thumb { - border: 1px solid #ccc; - background-color: var(--light-background); -} - -::-webkit-scrollbar-corner { - background-color: var(--background); -} - .code { font-family: var(--monospace-font); color: yellow; diff --git a/src/web/components/expandable.tsx b/src/web/components/expandable.tsx new file mode 100644 index 0000000000..2d67adb661 --- /dev/null +++ b/src/web/components/expandable.tsx @@ -0,0 +1,36 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import * as React from 'react'; + +export const Expandable: React.FunctionComponent<{ + title: JSX.Element, + body: JSX.Element, + setExpanded: Function, + expanded: Boolean, + style?: React.CSSProperties, +}> = ({ title, body, setExpanded, expanded, style }) => { + return
+
+
setExpanded(!expanded)} /> + {title} +
+ { expanded &&
{body}
} +
; +}; diff --git a/src/web/components/splitView.tsx b/src/web/components/splitView.tsx index a610f9bae2..a976d152c7 100644 --- a/src/web/components/splitView.tsx +++ b/src/web/components/splitView.tsx @@ -63,9 +63,12 @@ export const SplitView: React.FC = ({ if (!event.buttons) { setResizing(null); } else if (resizing) { - const clientOffset = orientation === 'vertical' ? event.clientY : event.clientX; + const splitView = (event.target as HTMLElement).parentElement!; + const rect = splitView.getBoundingClientRect(); + const clientOffset = orientation === 'vertical' ? event.clientY - rect.y : event.clientX - rect.x; const resizingPosition = sidebarIsFirst ? clientOffset : resizing.size - clientOffset + resizing.offset; - setSize(Math.max(kMinSidebarSize, resizingPosition)); + const size = Math.min(Math.max(kMinSidebarSize, resizingPosition), (orientation === 'vertical' ? rect.height : rect.width) - kMinSidebarSize); + setSize(size); } }} >
} diff --git a/src/web/components/treeItem.tsx b/src/web/components/treeItem.tsx new file mode 100644 index 0000000000..d3bee9aefc --- /dev/null +++ b/src/web/components/treeItem.tsx @@ -0,0 +1,37 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import * as React from 'react'; + +export const TreeItem: React.FunctionComponent<{ + title: JSX.Element, + loadChildren?: () => JSX.Element[], + onClick?: () => void, + expandByDefault?: boolean, + depth: number, + selected?: boolean +}> = ({ title, loadChildren, onClick, expandByDefault, depth, selected }) => { + const [expanded, setExpanded] = React.useState(expandByDefault || false); + const className = selected ? 'tree-item-title selected' : 'tree-item-title'; + return
+
{ onClick?.(); setExpanded(!expanded); }} > +
+ {title} +
+ {expanded && loadChildren?.()} +
; +}; diff --git a/src/web/htmlReport/htmlReport.css b/src/web/htmlReport/htmlReport.css new file mode 100644 index 0000000000..8f6eb75dc4 --- /dev/null +++ b/src/web/htmlReport/htmlReport.css @@ -0,0 +1,119 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +.sidebar { + line-height: 24px; + color: #fff6; + background-color: #2c2c2c; + font-size: 14px; + flex: 0 0 80px; +} + +.sidebar > div { + padding: 12px; + cursor: pointer; + display: flex; + justify-content: center; +} + +.sidebar > div.selected { + color: white; +} + +.suite-tree { + line-height: 18px; + flex: auto; + overflow: auto; + color: #616161; + background-color: #f3f3f3; +} + +.tree-item-title { + padding: 8px 0; + cursor: pointer; +} + +.tree-item-body { + min-height: 18px; +} + +.suite-tree .tree-item-title:not(.selected):hover { + background-color: #e8e8e8; +} + +.suite-tree .tree-item-title.selected { + background-color: #0060c0; + color: white; +} + +.suite-tree .tree-item-title.selected * { + color: white !important; +} + + +.test-case { + flex: auto; +} + +.test-case .tab-content { + overflow: auto; +} + +.error-message { + white-space: pre; + font-family: monospace; + background: #000; + color: white; + padding: 5px; + overflow: auto; + margin: 20px 0; +} + +.status-icon { + padding-right: 3px; +} + +.codicon-clock.status-icon, +.codicon-error.status-icon { + color: red; +} + +.codicon-alert.status-icon { + color: orange; +} + +.codicon-circle-filled.status-icon { + color: green; +} + +.test-result { + padding: 10px; + flex: auto; +} + +.test-overview-title { + padding: 4px 0 12px; + font-size: 18px; + flex: none; +} + +.test-overview-property { + display: flex; + flex-direction: row; + align-items: center; + max-width: 450px; + line-height: 24px; +} \ No newline at end of file diff --git a/src/web/htmlReport/htmlReport.tsx b/src/web/htmlReport/htmlReport.tsx new file mode 100644 index 0000000000..df58150718 --- /dev/null +++ b/src/web/htmlReport/htmlReport.tsx @@ -0,0 +1,277 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import './htmlReport.css'; +import * as React from 'react'; +import { SplitView } from '../components/splitView'; +import { TreeItem } from '../components/treeItem'; +import { TabbedPane } from '../traceViewer/ui/tabbedPane'; +import ansi2html from 'ansi-to-html'; +import { JsonLocation, JsonReport, JsonSuite, JsonTestCase, JsonTestResult, JsonTestStep } from '../../test/reporters/html'; +import { msToString } from '../uiUtils'; + +type Filter = 'Failing' | 'All'; + +export const Report: React.FC = () => { + const [report, setReport] = React.useState(); + const [selectedTest, setSelectedTest] = React.useState(); + + React.useEffect(() => { + (async () => { + const result = await fetch('report.json'); + const json = await result.json(); + setReport(json); + })(); + }, []); + const [filter, setFilter] = React.useState('Failing'); + + const failingTests = React.useMemo(() => { + const map = new Map(); + for (const project of report?.suites || []) + map.set(project, computeFailingTests(project)); + return map; + }, [report]); + + return
+ + + +
+ {filter === 'All' && report?.suites.map((s, i) => )} + {filter === 'Failing' && report?.suites.map((s, i) => { + const hasFailingTests = !!failingTests.get(s)?.length; + return hasFailingTests && ; + })} +
+
+
; +}; + +const FilterView: React.FC<{ + filter: Filter, + setFilter: (filter: Filter) => void +}> = ({ filter, setFilter }) => { + return
+ { + (['Failing', 'All'] as Filter[]).map(item => { + const selected = item === filter; + return
{ + setFilter(item); + }}>{item}
; + }) + } +
; +}; + +const ProjectTreeItem: React.FC<{ + suite?: JsonSuite; + selectedTest?: JsonTestCase, + setSelectedTest: (test: JsonTestCase) => void; +}> = ({ suite, setSelectedTest, selectedTest }) => { + const location = renderLocation(suite?.location); + + return +
{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}
{suite?.title}
+ {!!suite?.location?.line && location &&
{location}
} +
+ } loadChildren={() => { + return suite?.suites.map((s, i) => ) || []; + }} depth={0} expandByDefault={true}>; +}; + +const ProjectFlatTreeItem: React.FC<{ + suite?: JsonSuite; + failingTests: JsonTestCase[], + selectedTest?: JsonTestCase, + setSelectedTest: (test: JsonTestCase) => void; +}> = ({ suite, setSelectedTest, selectedTest, failingTests }) => { + const location = renderLocation(suite?.location); + + return +
{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}
{suite?.title}
+ {!!suite?.location?.line && location &&
{location}
} + + } loadChildren={() => { + return failingTests.map((t, i) => ) || []; + }} depth={0} expandByDefault={true}>
; +}; + +const SuiteTreeItem: React.FC<{ + suite?: JsonSuite; + selectedTest?: JsonTestCase, + setSelectedTest: (test: JsonTestCase) => void; + depth: number, +}> = ({ suite, setSelectedTest, selectedTest, depth }) => { + const location = renderLocation(suite?.location); + return +
{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}
{suite?.title}
+ {!!suite?.location?.line && location &&
{location}
} + + } loadChildren={() => { + const suiteChildren = suite?.suites.map((s, i) => ) || []; + const testChildren = suite?.tests.map((t, i) => ) || []; + return [...suiteChildren, ...testChildren]; + }} depth={depth}>
; +}; + +const TestTreeItem: React.FC<{ + expandByDefault?: boolean, + test: JsonTestCase; + showFileName: boolean, + selectedTest?: JsonTestCase, + setSelectedTest: (test: JsonTestCase) => void; + depth: number, +}> = ({ test, setSelectedTest, selectedTest, showFileName, expandByDefault, depth }) => { + const fileName = test.location.file; + const name = fileName.substring(fileName.lastIndexOf('/') + 1); + return +
{testCaseStatusIcon(test)}
{test.title}
+ {showFileName &&
{name}:{test.location.line}
} + {!showFileName &&
{msToString(test.results.reduce((v, a) => v + a.duration, 0))}
} + + } selected={test === selectedTest} depth={depth} expandByDefault={expandByDefault} onClick={() => setSelectedTest(test)}>
; +}; + +const TestCaseView: React.FC<{ + test?: JsonTestCase, +}> = ({ test }) => { + const [selectedTab, setSelectedTab] = React.useState('0'); + return
+ { test && ({ + id: String(index), + title:
{statusIcon(result.status)} {retryLabel(index)}
, + render: () => + })) || []} selectedTab={selectedTab} setSelectedTab={setSelectedTab} /> } +
; +}; + +const TestOverview: React.FC<{ + test: JsonTestCase, + result: JsonTestResult, +}> = ({ test, result }) => { + return
+
{test?.title}
+
{renderLocation(test.location)}
{msToString(result.duration)}
+ { result.failureSnippet &&
} + { result.steps.map((step, i) => ) } + {/*
{ JSON.stringify(result.steps, undefined, 2) }
*/} +
; +}; + +const StepTreeItem: React.FC<{ + step: JsonTestStep; + depth: number, +}> = ({ step, depth }) => { + return + {testStepStatusIcon(step)} + {step.title} +
+
{msToString(step.duration)}
+ } loadChildren={step.steps.length ? () => { + return step.steps.map((s, i) => ); + } : undefined} depth={depth}>
; +}; + +function testSuiteErrorStatusIcon(suite?: JsonSuite): JSX.Element | undefined { + if (!suite) + return; + for (const child of suite.suites) { + const icon = testSuiteErrorStatusIcon(child); + if (icon) + return icon; + } + for (const test of suite.tests) { + if (test.outcome !== 'expected' && test.outcome !== 'skipped') + return testCaseStatusIcon(test); + } +} + +function testCaseStatusIcon(test?: JsonTestCase): JSX.Element { + if (!test) + return statusIcon('passed'); + return statusIcon(test.outcome); +} + +function testStepStatusIcon(step: JsonTestStep): JSX.Element { + if (step.category === 'internal') + return ; + return statusIcon(step.error ? 'failed' : 'passed'); +} + +function statusIcon(status: 'failed' | 'timedOut' | 'skipped' | 'passed' | 'expected' | 'unexpected' | 'flaky'): JSX.Element { + switch (status) { + case 'failed': + case 'unexpected': + return ; + case 'passed': + case 'expected': + return ; + case 'timedOut': + return ; + case 'flaky': + return ; + case 'skipped': + return ; + } +} + +function computeFailingTests(suite: JsonSuite): JsonTestCase[] { + const failedTests: JsonTestCase[] = []; + const visit = (suite: JsonSuite) => { + for (const child of suite.suites) + visit(child); + for (const test of suite.tests) { + if (test.results.find(r => r.status === 'failed' || r.status === 'timedOut')) + failedTests.push(test); + } + }; + visit(suite); + return failedTests; +} + +function renderLocation(location?: JsonLocation) { + if (!location) + return ''; + return location.file + ':' + location.column; +} + +function retryLabel(index: number) { + if (!index) + return 'Run'; + return `Retry #${index}`; +} + +const ansiColors = { + 0: '#000', + 1: '#C00', + 2: '#0C0', + 3: '#C50', + 4: '#00C', + 5: '#C0C', + 6: '#0CC', + 7: '#CCC', + 8: '#555', + 9: '#F55', + 10: '#5F5', + 11: '#FF5', + 12: '#55F', + 13: '#F5F', + 14: '#5FF', + 15: '#FFF' +}; diff --git a/src/web/htmlReport/index.html b/src/web/htmlReport/index.html new file mode 100644 index 0000000000..f79a89f37d --- /dev/null +++ b/src/web/htmlReport/index.html @@ -0,0 +1,27 @@ + + + + + + + + Playwright Test Report + + +
+ + diff --git a/src/web/htmlReport/index.tsx b/src/web/htmlReport/index.tsx new file mode 100644 index 0000000000..7fbcea2a55 --- /dev/null +++ b/src/web/htmlReport/index.tsx @@ -0,0 +1,27 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '../third_party/vscode/codicon.css'; +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; +import { applyTheme } from '../theme'; +import '../common.css'; +import { Report } from './htmlReport'; + +(async () => { + applyTheme(); + ReactDOM.render(, document.querySelector('#root')); +})(); diff --git a/src/web/htmlReport/webpack.config.js b/src/web/htmlReport/webpack.config.js new file mode 100644 index 0000000000..ef05ece662 --- /dev/null +++ b/src/web/htmlReport/webpack.config.js @@ -0,0 +1,50 @@ +const path = require('path'); +const HtmlWebPackPlugin = require('html-webpack-plugin'); + +const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development'; + +module.exports = { + mode, + entry: { + app: path.join(__dirname, 'index.tsx'), + }, + resolve: { + extensions: ['.ts', '.js', '.tsx', '.jsx'] + }, + devtool: mode === 'production' ? false : 'source-map', + output: { + globalObject: 'self', + filename: '[name].bundle.js', + path: path.resolve(__dirname, '../../../lib/web/htmlReport') + }, + module: { + rules: [ + { + test: /\.(j|t)sx?$/, + loader: 'babel-loader', + options: { + presets: [ + "@babel/preset-typescript", + "@babel/preset-react" + ] + }, + exclude: /node_modules/ + }, + { + test: /\.css$/, + use: ['style-loader', 'css-loader'] + }, + { + test: /\.ttf$/, + use: ['file-loader'] + } + ] + }, + plugins: [ + new HtmlWebPackPlugin({ + title: 'Playwright Test Report', + template: path.join(__dirname, 'index.html'), + inject: true, + }) + ] +}; diff --git a/src/web/third_party/vscode/codicon.css b/src/web/third_party/vscode/codicon.css index 4cc718af34..980d3053ae 100644 --- a/src/web/third_party/vscode/codicon.css +++ b/src/web/third_party/vscode/codicon.css @@ -10,6 +10,7 @@ .codicon { font: normal normal normal 16px/1 codicon; + flex: none; display: inline-block; text-decoration: none; text-rendering: auto; diff --git a/src/web/traceViewer/ui/helpers.tsx b/src/web/traceViewer/ui/helpers.tsx index a84a407c7d..a8302c0efa 100644 --- a/src/web/traceViewer/ui/helpers.tsx +++ b/src/web/traceViewer/ui/helpers.tsx @@ -53,22 +53,3 @@ export function useMeasure() { }, [ref]); return [measure, ref] as const; } - -export const Expandable: React.FunctionComponent<{ - title: JSX.Element, - body: JSX.Element, - setExpanded: Function, - expanded: Boolean, - style?: React.CSSProperties, -}> = ({ title, body, setExpanded, expanded, style }) => { - return
-
-
setExpanded(!expanded)} /> - {title} -
- { expanded &&
{body}
} -
; -}; diff --git a/src/web/traceViewer/ui/networkResourceDetails.tsx b/src/web/traceViewer/ui/networkResourceDetails.tsx index 204e32ad4d..5c6c93fb1e 100644 --- a/src/web/traceViewer/ui/networkResourceDetails.tsx +++ b/src/web/traceViewer/ui/networkResourceDetails.tsx @@ -16,8 +16,8 @@ import './networkResourceDetails.css'; import * as React from 'react'; -import { Expandable } from './helpers'; import type { ResourceSnapshot } from '../../../server/snapshot/snapshotTypes'; +import { Expandable } from '../../components/expandable'; export const NetworkResourceDetails: React.FunctionComponent<{ resource: ResourceSnapshot, diff --git a/src/web/traceViewer/ui/tabbedPane.tsx b/src/web/traceViewer/ui/tabbedPane.tsx index 8bcd46f9c3..077a170f3d 100644 --- a/src/web/traceViewer/ui/tabbedPane.tsx +++ b/src/web/traceViewer/ui/tabbedPane.tsx @@ -19,8 +19,8 @@ import * as React from 'react'; export interface TabbedPaneTab { id: string; - title: string; - count: number; + title: string | JSX.Element; + count?: number; render: () => React.ReactElement; } diff --git a/tests/config/browserTest.ts b/tests/config/browserTest.ts index ed494e7f7e..763fece9bf 100644 --- a/tests/config/browserTest.ts +++ b/tests/config/browserTest.ts @@ -139,6 +139,11 @@ export const playwrightFixtures: Fixtures { const context = await browser.newContext({ ...contextOptions, ...options }); + (context as any)._csi = { + onApiCall: (name: string) => { + return (testInfo as any)._addStep('pw:api', name); + }, + }; contexts.push(context); return context; }); diff --git a/utils/build/build.js b/utils/build/build.js index 382f241f49..8587b919df 100644 --- a/utils/build/build.js +++ b/utils/build/build.js @@ -114,6 +114,7 @@ const webPackFiles = [ 'src/server/injected/webpack.config.js', 'src/web/traceViewer/webpack.config.js', 'src/web/recorder/webpack.config.js', + 'src/web/htmlReport/webpack.config.js', ]; for (const file of webPackFiles) { steps.push({ diff --git a/utils/check_deps.js b/utils/check_deps.js index 3e8fb52a1f..ea6a68d26a 100644 --- a/utils/check_deps.js +++ b/utils/check_deps.js @@ -163,6 +163,10 @@ DEPS['src/server/trace/recorder/'] = ['src/server/trace/common/', ...DEPS['src/s DEPS['src/server/trace/viewer/'] = ['src/server/trace/common/', 'src/server/trace/recorder/', 'src/server/chromium/', ...DEPS['src/server/trace/common/']]; DEPS['src/test/'] = ['src/test/**', 'src/utils/utils.ts', 'src/utils/**']; +// HTML report +DEPS['src/web/htmlReport/'] = ['src/test/**', 'src/web/']; + + checkDeps().catch(e => { console.error(e && e.stack ? e.stack : e); process.exit(1);