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",
|
"@types/yazl": "^2.4.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.28.4",
|
"@typescript-eslint/eslint-plugin": "^4.28.4",
|
||||||
"@typescript-eslint/parser": "^4.28.4",
|
"@typescript-eslint/parser": "^4.28.4",
|
||||||
|
"ansi-to-html": "^0.7.1",
|
||||||
"babel-loader": "^8.2.2",
|
"babel-loader": "^8.2.2",
|
||||||
"chokidar": "^3.5.0",
|
"chokidar": "^3.5.0",
|
||||||
"commonmark": "^0.29.1",
|
"commonmark": "^0.29.1",
|
||||||
|
|
@ -2165,6 +2166,30 @@
|
||||||
"node": ">=4"
|
"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": {
|
"node_modules/anymatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
|
||||||
|
|
@ -11831,6 +11856,23 @@
|
||||||
"color-convert": "^1.9.0"
|
"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": {
|
"anymatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@
|
||||||
"@types/yazl": "^2.4.2",
|
"@types/yazl": "^2.4.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^4.28.4",
|
"@typescript-eslint/eslint-plugin": "^4.28.4",
|
||||||
"@typescript-eslint/parser": "^4.28.4",
|
"@typescript-eslint/parser": "^4.28.4",
|
||||||
|
"ansi-to-html": "^0.7.1",
|
||||||
"babel-loader": "^8.2.2",
|
"babel-loader": "^8.2.2",
|
||||||
"chokidar": "^3.5.0",
|
"chokidar": "^3.5.0",
|
||||||
"commonmark": "^0.29.1",
|
"commonmark": "^0.29.1",
|
||||||
|
|
|
||||||
|
|
@ -146,13 +146,7 @@ export function formatFailure(config: FullConfig, test: TestCase, index?: number
|
||||||
const tokens: string[] = [];
|
const tokens: string[] = [];
|
||||||
tokens.push(formatTestHeader(config, test, ' ', index));
|
tokens.push(formatTestHeader(config, test, ' ', index));
|
||||||
for (const result of test.results) {
|
for (const result of test.results) {
|
||||||
const resultTokens: string[] = [];
|
const resultTokens = formatResultFailure(test, result, ' ');
|
||||||
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), ' '));
|
|
||||||
if (!resultTokens.length)
|
if (!resultTokens.length)
|
||||||
continue;
|
continue;
|
||||||
const statusSuffix = result.status === 'passed' ? ' -- passed unexpectedly' : '';
|
const statusSuffix = result.status === 'passed' ? ' -- passed unexpectedly' : '';
|
||||||
|
|
@ -166,6 +160,17 @@ export function formatFailure(config: FullConfig, test: TestCase, index?: number
|
||||||
return tokens.join('\n');
|
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 {
|
function relativeTestPath(config: FullConfig, test: TestCase): string {
|
||||||
return path.relative(config.rootDir, test.location.file) || path.basename(test.location.file);
|
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 };
|
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);
|
return aPath.split(path.sep).join(path.posix.sep);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -248,7 +248,7 @@ function stdioEntry(s: string | Buffer): any {
|
||||||
return { buffer: s.toString('base64') };
|
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))
|
if (!Array.isArray(patterns))
|
||||||
patterns = [patterns];
|
patterns = [patterns];
|
||||||
return patterns.map(s => s.toString());
|
return patterns.map(s => s.toString());
|
||||||
|
|
|
||||||
|
|
@ -101,20 +101,6 @@ svg {
|
||||||
position: relative;
|
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 {
|
.code {
|
||||||
font-family: var(--monospace-font);
|
font-family: var(--monospace-font);
|
||||||
color: yellow;
|
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) {
|
if (!event.buttons) {
|
||||||
setResizing(null);
|
setResizing(null);
|
||||||
} else if (resizing) {
|
} 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;
|
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> }
|
></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 {
|
.codicon {
|
||||||
font: normal normal normal 16px/1 codicon;
|
font: normal normal normal 16px/1 codicon;
|
||||||
|
flex: none;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
text-rendering: auto;
|
text-rendering: auto;
|
||||||
|
|
|
||||||
|
|
@ -53,22 +53,3 @@ export function useMeasure<T extends Element>() {
|
||||||
}, [ref]);
|
}, [ref]);
|
||||||
return [measure, ref] as const;
|
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 './networkResourceDetails.css';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Expandable } from './helpers';
|
|
||||||
import type { ResourceSnapshot } from '../../../server/snapshot/snapshotTypes';
|
import type { ResourceSnapshot } from '../../../server/snapshot/snapshotTypes';
|
||||||
|
import { Expandable } from '../../components/expandable';
|
||||||
|
|
||||||
export const NetworkResourceDetails: React.FunctionComponent<{
|
export const NetworkResourceDetails: React.FunctionComponent<{
|
||||||
resource: ResourceSnapshot,
|
resource: ResourceSnapshot,
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,8 @@ import * as React from 'react';
|
||||||
|
|
||||||
export interface TabbedPaneTab {
|
export interface TabbedPaneTab {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string | JSX.Element;
|
||||||
count: number;
|
count?: number;
|
||||||
render: () => React.ReactElement;
|
render: () => React.ReactElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,11 @@ export const playwrightFixtures: Fixtures<PlaywrightTestOptions & PlaywrightTest
|
||||||
const contexts: BrowserContext[] = [];
|
const contexts: BrowserContext[] = [];
|
||||||
await run(async options => {
|
await run(async options => {
|
||||||
const context = await browser.newContext({ ...contextOptions, ...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);
|
contexts.push(context);
|
||||||
return context;
|
return context;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,7 @@ const webPackFiles = [
|
||||||
'src/server/injected/webpack.config.js',
|
'src/server/injected/webpack.config.js',
|
||||||
'src/web/traceViewer/webpack.config.js',
|
'src/web/traceViewer/webpack.config.js',
|
||||||
'src/web/recorder/webpack.config.js',
|
'src/web/recorder/webpack.config.js',
|
||||||
|
'src/web/htmlReport/webpack.config.js',
|
||||||
];
|
];
|
||||||
for (const file of webPackFiles) {
|
for (const file of webPackFiles) {
|
||||||
steps.push({
|
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/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/**'];
|
DEPS['src/test/'] = ['src/test/**', 'src/utils/utils.ts', 'src/utils/**'];
|
||||||
|
|
||||||
|
// HTML report
|
||||||
|
DEPS['src/web/htmlReport/'] = ['src/test/**', 'src/web/'];
|
||||||
|
|
||||||
|
|
||||||
checkDeps().catch(e => {
|
checkDeps().catch(e => {
|
||||||
console.error(e && e.stack ? e.stack : e);
|
console.error(e && e.stack ? e.stack : e);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue