mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
feat(test-runner): basic html reporter (#7994)
This commit is contained in:
parent
4015fb2af6
commit
a8d404cd29
42
package-lock.json
generated
42
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
208
src/test/reporters/html.ts
Normal file
208
src/test/reporters/html.ts
Normal file
@ -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<FullConfig, 'projects'> & {
|
||||
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<TestStep, JsonTestStep>();
|
||||
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;
|
||||
@ -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());
|
||||
|
||||
@ -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;
|
||||
|
||||
36
src/web/components/expandable.tsx
Normal file
36
src/web/components/expandable.tsx
Normal file
@ -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 <div style={{ ...style, display: 'flex', flexDirection: 'column' }}>
|
||||
<div className='expandable-title' style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', whiteSpace: 'nowrap' }}>
|
||||
<div
|
||||
className={'codicon codicon-' + (expanded ? 'chevron-down' : 'chevron-right')}
|
||||
style={{ cursor: 'pointer', color: 'var(--color)', marginRight: '4px'}}
|
||||
onClick={() => setExpanded(!expanded)} />
|
||||
{title}
|
||||
</div>
|
||||
{ expanded && <div className='expandable-body' style={{ display: 'flex', flex: 'auto', margin: '5px 0 5px 20px' }}>{body}</div> }
|
||||
</div>;
|
||||
};
|
||||
@ -63,9 +63,12 @@ export const SplitView: React.FC<SplitViewProps> = ({
|
||||
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);
|
||||
}
|
||||
}}
|
||||
></div> }
|
||||
|
||||
37
src/web/components/treeItem.tsx
Normal file
37
src/web/components/treeItem.tsx
Normal file
@ -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 <div style={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
|
||||
<div className={className} style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', whiteSpace: 'nowrap', paddingLeft: depth * 20 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} >
|
||||
<div className={'codicon codicon-' + (expanded ? 'chevron-down' : 'chevron-right')}
|
||||
style={{ cursor: 'pointer', color: 'var(--color)', marginRight: '4px', visibility: loadChildren ? 'visible' : 'hidden' }} />
|
||||
{title}
|
||||
</div>
|
||||
{expanded && loadChildren?.()}
|
||||
</div>;
|
||||
};
|
||||
119
src/web/htmlReport/htmlReport.css
Normal file
119
src/web/htmlReport/htmlReport.css
Normal file
@ -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;
|
||||
}
|
||||
277
src/web/htmlReport/htmlReport.tsx
Normal file
277
src/web/htmlReport/htmlReport.tsx
Normal file
@ -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<JsonReport | undefined>();
|
||||
const [selectedTest, setSelectedTest] = React.useState<JsonTestCase | undefined>();
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
const result = await fetch('report.json');
|
||||
const json = await result.json();
|
||||
setReport(json);
|
||||
})();
|
||||
}, []);
|
||||
const [filter, setFilter] = React.useState<Filter>('Failing');
|
||||
|
||||
const failingTests = React.useMemo(() => {
|
||||
const map = new Map<JsonSuite, JsonTestCase[]>();
|
||||
for (const project of report?.suites || [])
|
||||
map.set(project, computeFailingTests(project));
|
||||
return map;
|
||||
}, [report]);
|
||||
|
||||
return <div className='hbox'>
|
||||
<FilterView filter={filter} setFilter={setFilter}></FilterView>
|
||||
<SplitView sidebarSize={500} orientation='horizontal' sidebarIsFirst={true}>
|
||||
<TestCaseView test={selectedTest}></TestCaseView>
|
||||
<div className='suite-tree'>
|
||||
{filter === 'All' && report?.suites.map((s, i) => <ProjectTreeItem key={i} suite={s} setSelectedTest={setSelectedTest} selectedTest={selectedTest}></ProjectTreeItem>)}
|
||||
{filter === 'Failing' && report?.suites.map((s, i) => {
|
||||
const hasFailingTests = !!failingTests.get(s)?.length;
|
||||
return hasFailingTests && <ProjectFlatTreeItem key={i} suite={s} setSelectedTest={setSelectedTest} selectedTest={selectedTest} failingTests={failingTests.get(s)!}></ProjectFlatTreeItem>;
|
||||
})}
|
||||
</div>
|
||||
</SplitView>
|
||||
</div>;
|
||||
};
|
||||
|
||||
const FilterView: React.FC<{
|
||||
filter: Filter,
|
||||
setFilter: (filter: Filter) => void
|
||||
}> = ({ filter, setFilter }) => {
|
||||
return <div className='sidebar'>
|
||||
{
|
||||
(['Failing', 'All'] as Filter[]).map(item => {
|
||||
const selected = item === filter;
|
||||
return <div key={item} className={selected ? 'selected' : ''} onClick={e => {
|
||||
setFilter(item);
|
||||
}}>{item}</div>;
|
||||
})
|
||||
}
|
||||
</div>;
|
||||
};
|
||||
|
||||
const ProjectTreeItem: React.FC<{
|
||||
suite?: JsonSuite;
|
||||
selectedTest?: JsonTestCase,
|
||||
setSelectedTest: (test: JsonTestCase) => void;
|
||||
}> = ({ suite, setSelectedTest, selectedTest }) => {
|
||||
const location = renderLocation(suite?.location);
|
||||
|
||||
return <TreeItem title={<div className='hbox'>
|
||||
<div style={{ flex: 'auto', alignItems: 'center', display: 'flex' }}>{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}<div style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{suite?.title}</div></div>
|
||||
{!!suite?.location?.line && location && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{location}</div>}
|
||||
</div>
|
||||
} loadChildren={() => {
|
||||
return suite?.suites.map((s, i) => <SuiteTreeItem key={i} suite={s} setSelectedTest={setSelectedTest} selectedTest={selectedTest} depth={1}></SuiteTreeItem>) || [];
|
||||
}} depth={0} expandByDefault={true}></TreeItem>;
|
||||
};
|
||||
|
||||
const ProjectFlatTreeItem: React.FC<{
|
||||
suite?: JsonSuite;
|
||||
failingTests: JsonTestCase[],
|
||||
selectedTest?: JsonTestCase,
|
||||
setSelectedTest: (test: JsonTestCase) => void;
|
||||
}> = ({ suite, setSelectedTest, selectedTest, failingTests }) => {
|
||||
const location = renderLocation(suite?.location);
|
||||
|
||||
return <TreeItem title={<div className='hbox'>
|
||||
<div style={{ flex: 'auto', alignItems: 'center', display: 'flex' }}>{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}<div style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{suite?.title}</div></div>
|
||||
{!!suite?.location?.line && location && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{location}</div>}
|
||||
</div>
|
||||
} loadChildren={() => {
|
||||
return failingTests.map((t, i) => <TestTreeItem key={i} test={t} setSelectedTest={setSelectedTest} selectedTest={selectedTest} showFileName={false} depth={1}></TestTreeItem>) || [];
|
||||
}} depth={0} expandByDefault={true}></TreeItem>;
|
||||
};
|
||||
|
||||
const SuiteTreeItem: React.FC<{
|
||||
suite?: JsonSuite;
|
||||
selectedTest?: JsonTestCase,
|
||||
setSelectedTest: (test: JsonTestCase) => void;
|
||||
depth: number,
|
||||
}> = ({ suite, setSelectedTest, selectedTest, depth }) => {
|
||||
const location = renderLocation(suite?.location);
|
||||
return <TreeItem title={<div className='hbox'>
|
||||
<div style={{ flex: 'auto', alignItems: 'center', display: 'flex' }}>{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}<div style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{suite?.title}</div></div>
|
||||
{!!suite?.location?.line && location && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{location}</div>}
|
||||
</div>
|
||||
} loadChildren={() => {
|
||||
const suiteChildren = suite?.suites.map((s, i) => <SuiteTreeItem key={i} suite={s} setSelectedTest={setSelectedTest} selectedTest={selectedTest} depth={depth + 1}></SuiteTreeItem>) || [];
|
||||
const testChildren = suite?.tests.map((t, i) => <TestTreeItem key={i} test={t} setSelectedTest={setSelectedTest} selectedTest={selectedTest} showFileName={false} depth={depth + 1}></TestTreeItem>) || [];
|
||||
return [...suiteChildren, ...testChildren];
|
||||
}} depth={depth}></TreeItem>;
|
||||
};
|
||||
|
||||
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 <TreeItem title={<div className='hbox'>
|
||||
<div style={{ flex: 'auto', alignItems: 'center', display: 'flex' }}>{testCaseStatusIcon(test)}<div style={{overflow: 'hidden', textOverflow: 'ellipsis'}}>{test.title}</div></div>
|
||||
{showFileName && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{name}:{test.location.line}</div>}
|
||||
{!showFileName && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{msToString(test.results.reduce((v, a) => v + a.duration, 0))}</div>}
|
||||
</div>
|
||||
} selected={test === selectedTest} depth={depth} expandByDefault={expandByDefault} onClick={() => setSelectedTest(test)}></TreeItem>;
|
||||
};
|
||||
|
||||
const TestCaseView: React.FC<{
|
||||
test?: JsonTestCase,
|
||||
}> = ({ test }) => {
|
||||
const [selectedTab, setSelectedTab] = React.useState<string>('0');
|
||||
return <div className="test-case vbox">
|
||||
{ test && <TabbedPane tabs={
|
||||
test?.results.map((result, index) => ({
|
||||
id: String(index),
|
||||
title: <div style={{ display: 'flex', alignItems: 'center' }}>{statusIcon(result.status)} {retryLabel(index)}</div>,
|
||||
render: () => <TestOverview test={test} result={result}></TestOverview>
|
||||
})) || []} selectedTab={selectedTab} setSelectedTab={setSelectedTab} /> }
|
||||
</div>;
|
||||
};
|
||||
|
||||
const TestOverview: React.FC<{
|
||||
test: JsonTestCase,
|
||||
result: JsonTestResult,
|
||||
}> = ({ test, result }) => {
|
||||
return <div className="test-result">
|
||||
<div className='test-overview-title'>{test?.title}</div>
|
||||
<div className='test-overview-property'>{renderLocation(test.location)}<div style={{ flex: 'auto' }}></div><div>{msToString(result.duration)}</div></div>
|
||||
{ result.failureSnippet && <div className='error-message' dangerouslySetInnerHTML={{__html: new ansi2html({
|
||||
colors: ansiColors
|
||||
}).toHtml(result.failureSnippet.trim()) }}></div> }
|
||||
{ result.steps.map((step, i) => <StepTreeItem key={i} step={step} depth={0}></StepTreeItem>) }
|
||||
{/* <div style={{whiteSpace: 'pre'}}>{ JSON.stringify(result.steps, undefined, 2) }</div> */}
|
||||
</div>;
|
||||
};
|
||||
|
||||
const StepTreeItem: React.FC<{
|
||||
step: JsonTestStep;
|
||||
depth: number,
|
||||
}> = ({ step, depth }) => {
|
||||
return <TreeItem title={<div style={{ display: 'flex', alignItems: 'center', flex: 'auto', maxWidth: 430 }}>
|
||||
{testStepStatusIcon(step)}
|
||||
{step.title}
|
||||
<div style={{ flex: 'auto' }}></div>
|
||||
<div>{msToString(step.duration)}</div>
|
||||
</div>} loadChildren={step.steps.length ? () => {
|
||||
return step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1}></StepTreeItem>);
|
||||
} : undefined} depth={depth}></TreeItem>;
|
||||
};
|
||||
|
||||
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 <span></span>;
|
||||
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 <span className={'codicon codicon-error status-icon'}></span>;
|
||||
case 'passed':
|
||||
case 'expected':
|
||||
return <span className={'codicon codicon-circle-filled status-icon'}></span>;
|
||||
case 'timedOut':
|
||||
return <span className={'codicon codicon-clock status-icon'}></span>;
|
||||
case 'flaky':
|
||||
return <span className={'codicon codicon-alert status-icon'}></span>;
|
||||
case 'skipped':
|
||||
return <span className={'codicon codicon-tag status-icon'}></span>;
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
};
|
||||
27
src/web/htmlReport/index.html
Normal file
27
src/web/htmlReport/index.html
Normal file
@ -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.
|
||||
-->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Playwright Test Report</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id=root></div>
|
||||
</body>
|
||||
</html>
|
||||
27
src/web/htmlReport/index.tsx
Normal file
27
src/web/htmlReport/index.tsx
Normal file
@ -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(<Report />, document.querySelector('#root'));
|
||||
})();
|
||||
50
src/web/htmlReport/webpack.config.js
Normal file
50
src/web/htmlReport/webpack.config.js
Normal file
@ -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,
|
||||
})
|
||||
]
|
||||
};
|
||||
1
src/web/third_party/vscode/codicon.css
vendored
1
src/web/third_party/vscode/codicon.css
vendored
@ -10,6 +10,7 @@
|
||||
|
||||
.codicon {
|
||||
font: normal normal normal 16px/1 codicon;
|
||||
flex: none;
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
text-rendering: auto;
|
||||
|
||||
@ -53,22 +53,3 @@ export function useMeasure<T extends Element>() {
|
||||
}, [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 <div style={{ ...style, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', whiteSpace: 'nowrap' }}>
|
||||
<div
|
||||
className={'codicon codicon-' + (expanded ? 'chevron-down' : 'chevron-right')}
|
||||
style={{ cursor: 'pointer', color: 'var(--color)', marginRight: '4px'}}
|
||||
onClick={() => setExpanded(!expanded)} />
|
||||
{title}
|
||||
</div>
|
||||
{ expanded && <div style={{ display: 'flex', flex: 'auto', margin: '5px 0 5px 20px' }}>{body}</div> }
|
||||
</div>;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -139,6 +139,11 @@ export const playwrightFixtures: Fixtures<PlaywrightTestOptions & PlaywrightTest
|
||||
const contexts: BrowserContext[] = [];
|
||||
await run(async options => {
|
||||
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;
|
||||
});
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user