feat(test-runner): basic html reporter (#7994)

This commit is contained in:
Pavel Feldman 2021-08-05 13:36:47 -07:00 committed by GitHub
parent 4015fb2af6
commit a8d404cd29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 857 additions and 47 deletions

42
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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
View 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;

View File

@ -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());

View File

@ -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;

View 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>;
};

View File

@ -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> }

View 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>;
};

View 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;
}

View 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'
};

View 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>

View 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'));
})();

View 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,
})
]
};

View File

@ -10,6 +10,7 @@
.codicon {
font: normal normal normal 16px/1 codicon;
flex: none;
display: inline-block;
text-decoration: none;
text-rendering: auto;

View File

@ -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>;
};

View File

@ -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,

View File

@ -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;
}

View File

@ -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;
});

View File

@ -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({

View File

@ -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);