mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
chore(html): render steps and errors (#8826)
This commit is contained in:
parent
09afd50ab3
commit
ccff6e3036
@ -100,7 +100,7 @@ export function addGenerateHtmlCommand(program: commander.CommanderStatic) {
|
|||||||
for (const file of files)
|
for (const file of files)
|
||||||
reportFiles.add(path.join(reportFolder, file));
|
reportFiles.add(path.join(reportFolder, file));
|
||||||
}
|
}
|
||||||
new HtmlBuilder([...reportFiles], output);
|
new HtmlBuilder([...reportFiles], output, loader.fullConfig().rootDir);
|
||||||
}).on('--help', () => {
|
}).on('--help', () => {
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log('Examples:');
|
console.log('Examples:');
|
||||||
|
|||||||
@ -16,15 +16,18 @@
|
|||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { ProjectTreeItem, SuiteTreeItem, TestTreeItem, TestCase, TestResult, TestStep, TestFile } from './types';
|
import { ProjectTreeItem, SuiteTreeItem, TestTreeItem, TestCase, TestResult, TestStep, TestFile, Location } from './types';
|
||||||
import { JsonReport, JsonSuite, JsonTestCase, JsonTestResult, JsonTestStep } from '../reporters/raw';
|
import { JsonReport, JsonSuite, JsonTestCase, JsonTestResult, JsonTestStep } from '../reporters/raw';
|
||||||
import { calculateSha1 } from '../../utils/utils';
|
import { calculateSha1 } from '../../utils/utils';
|
||||||
|
import { toPosixPath } from '../reporters/json';
|
||||||
|
|
||||||
export class HtmlBuilder {
|
export class HtmlBuilder {
|
||||||
private _reportFolder: string;
|
private _reportFolder: string;
|
||||||
private _tests = new Map<string, JsonTestCase>();
|
private _tests = new Map<string, JsonTestCase>();
|
||||||
|
private _rootDir: string;
|
||||||
|
|
||||||
constructor(rawReports: string[], outputDir: string) {
|
constructor(rawReports: string[], outputDir: string, rootDir: string) {
|
||||||
|
this._rootDir = rootDir;
|
||||||
this._reportFolder = path.resolve(process.cwd(), outputDir);
|
this._reportFolder = path.resolve(process.cwd(), outputDir);
|
||||||
const dataFolder = path.join(this._reportFolder, 'data');
|
const dataFolder = path.join(this._reportFolder, 'data');
|
||||||
fs.mkdirSync(dataFolder, { recursive: true });
|
fs.mkdirSync(dataFolder, { recursive: true });
|
||||||
@ -37,12 +40,13 @@ export class HtmlBuilder {
|
|||||||
const projectJson = JSON.parse(fs.readFileSync(projectFile, 'utf-8')) as JsonReport;
|
const projectJson = JSON.parse(fs.readFileSync(projectFile, 'utf-8')) as JsonReport;
|
||||||
const suites: SuiteTreeItem[] = [];
|
const suites: SuiteTreeItem[] = [];
|
||||||
for (const file of projectJson.suites) {
|
for (const file of projectJson.suites) {
|
||||||
const fileId = calculateSha1(projectFile + ':' + file.location!.file);
|
const relativeFileName = this._relativeLocation(file.location).file;
|
||||||
|
const fileId = calculateSha1(projectFile + ':' + relativeFileName);
|
||||||
const tests: JsonTestCase[] = [];
|
const tests: JsonTestCase[] = [];
|
||||||
suites.push(this._createSuiteTreeItem(file, fileId, tests));
|
suites.push(this._createSuiteTreeItem(file, fileId, tests));
|
||||||
const testFile: TestFile = {
|
const testFile: TestFile = {
|
||||||
fileId,
|
fileId,
|
||||||
path: file.location!.file,
|
path: relativeFileName,
|
||||||
tests: tests.map(t => this._createTestCase(t))
|
tests: tests.map(t => this._createTestCase(t))
|
||||||
};
|
};
|
||||||
fs.writeFileSync(path.join(dataFolder, fileId + '.json'), JSON.stringify(testFile, undefined, 2));
|
fs.writeFileSync(path.join(dataFolder, fileId + '.json'), JSON.stringify(testFile, undefined, 2));
|
||||||
@ -60,7 +64,7 @@ export class HtmlBuilder {
|
|||||||
return {
|
return {
|
||||||
testId: test.testId,
|
testId: test.testId,
|
||||||
title: test.title,
|
title: test.title,
|
||||||
location: test.location,
|
location: this._relativeLocation(test.location),
|
||||||
results: test.results.map(r => this._createTestResult(r))
|
results: test.results.map(r => this._createTestResult(r))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -71,7 +75,7 @@ export class HtmlBuilder {
|
|||||||
testCollector.push(...suite.tests);
|
testCollector.push(...suite.tests);
|
||||||
return {
|
return {
|
||||||
title: suite.title,
|
title: suite.title,
|
||||||
location: suite.location,
|
location: this._relativeLocation(suite.location),
|
||||||
duration: suites.reduce((a, s) => a + s.duration, 0) + tests.reduce((a, t) => a + t.duration, 0),
|
duration: suites.reduce((a, s) => a + s.duration, 0) + tests.reduce((a, t) => a + t.duration, 0),
|
||||||
failedTests: suites.reduce((a, s) => a + s.failedTests, 0) + tests.reduce((a, t) => t.outcome === 'unexpected' || t.outcome === 'flaky' ? a + 1 : a, 0),
|
failedTests: suites.reduce((a, s) => a + s.failedTests, 0) + tests.reduce((a, t) => t.outcome === 'unexpected' || t.outcome === 'flaky' ? a + 1 : a, 0),
|
||||||
suites,
|
suites,
|
||||||
@ -85,7 +89,7 @@ export class HtmlBuilder {
|
|||||||
return {
|
return {
|
||||||
testId: test.testId,
|
testId: test.testId,
|
||||||
fileId: fileId,
|
fileId: fileId,
|
||||||
location: test.location,
|
location: this._relativeLocation(test.location),
|
||||||
title: test.title,
|
title: test.title,
|
||||||
duration,
|
duration,
|
||||||
outcome: test.outcome
|
outcome: test.outcome
|
||||||
@ -98,7 +102,7 @@ export class HtmlBuilder {
|
|||||||
startTime: result.startTime,
|
startTime: result.startTime,
|
||||||
retry: result.retry,
|
retry: result.retry,
|
||||||
steps: result.steps.map(s => this._createTestStep(s)),
|
steps: result.steps.map(s => this._createTestStep(s)),
|
||||||
error: result.error,
|
error: result.error?.message,
|
||||||
status: result.status,
|
status: result.status,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -110,7 +114,17 @@ export class HtmlBuilder {
|
|||||||
duration: step.duration,
|
duration: step.duration,
|
||||||
steps: step.steps.map(s => this._createTestStep(s)),
|
steps: step.steps.map(s => this._createTestStep(s)),
|
||||||
log: step.log,
|
log: step.log,
|
||||||
error: step.error
|
error: step.error?.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private _relativeLocation(location: Location | undefined): Location {
|
||||||
|
if (!location)
|
||||||
|
return { file: '', line: 0, column: 0 };
|
||||||
|
return {
|
||||||
|
file: toPosixPath(path.relative(this._rootDir, location.file)),
|
||||||
|
line: location.line,
|
||||||
|
column: location.column,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,18 +57,12 @@ export type TestCase = {
|
|||||||
results: TestResult[];
|
results: TestResult[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TestError {
|
|
||||||
message?: string;
|
|
||||||
stack?: string;
|
|
||||||
value?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TestResult = {
|
export type TestResult = {
|
||||||
retry: number;
|
retry: number;
|
||||||
startTime: string;
|
startTime: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
steps: TestStep[];
|
steps: TestStep[];
|
||||||
error?: TestError;
|
error?: string;
|
||||||
status: 'passed' | 'failed' | 'timedOut' | 'skipped';
|
status: 'passed' | 'failed' | 'timedOut' | 'skipped';
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -77,6 +71,6 @@ export type TestStep = {
|
|||||||
startTime: string;
|
startTime: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
log?: string[];
|
log?: string[];
|
||||||
error?: TestError;
|
error?: string;
|
||||||
steps: TestStep[];
|
steps: TestStep[];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -22,8 +22,8 @@ import { assert, calculateSha1 } from '../../utils/utils';
|
|||||||
import { sanitizeForFilePath } from '../util';
|
import { sanitizeForFilePath } from '../util';
|
||||||
import { serializePatterns } from './json';
|
import { serializePatterns } from './json';
|
||||||
|
|
||||||
export type JsonStats = { expected: number, unexpected: number, flaky: number, skipped: number };
|
|
||||||
export type JsonLocation = Location;
|
export type JsonLocation = Location;
|
||||||
|
export type JsonError = TestError;
|
||||||
export type JsonStackFrame = { file: string, line: number, column: number };
|
export type JsonStackFrame = { file: string, line: number, column: number };
|
||||||
|
|
||||||
export type JsonReport = {
|
export type JsonReport = {
|
||||||
@ -86,7 +86,7 @@ export type JsonTestResult = {
|
|||||||
startTime: string;
|
startTime: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
status: TestStatus;
|
status: TestStatus;
|
||||||
error?: TestError;
|
error?: JsonError;
|
||||||
attachments: JsonAttachment[];
|
attachments: JsonAttachment[];
|
||||||
steps: JsonTestStep[];
|
steps: JsonTestStep[];
|
||||||
};
|
};
|
||||||
@ -96,7 +96,7 @@ export type JsonTestStep = {
|
|||||||
category: string,
|
category: string,
|
||||||
startTime: string;
|
startTime: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
error?: TestError;
|
error?: JsonError;
|
||||||
steps: JsonTestStep[];
|
steps: JsonTestStep[];
|
||||||
log?: string[];
|
log?: string[];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -52,7 +52,7 @@
|
|||||||
padding: 5px;
|
padding: 5px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
flex: auto;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-icon {
|
.status-icon {
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
import './htmlReport.css';
|
import './htmlReport.css';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import ansi2html from 'ansi-to-html';
|
||||||
import { SplitView } from '../components/splitView';
|
import { SplitView } from '../components/splitView';
|
||||||
import { TreeItem } from '../components/treeItem';
|
import { TreeItem } from '../components/treeItem';
|
||||||
import { TabbedPane } from '../traceViewer/ui/tabbedPane';
|
import { TabbedPane } from '../traceViewer/ui/tabbedPane';
|
||||||
@ -166,6 +167,7 @@ const TestResultView: React.FC<{
|
|||||||
result: TestResult,
|
result: TestResult,
|
||||||
}> = ({ test, result }) => {
|
}> = ({ test, result }) => {
|
||||||
return <div className='test-result'>
|
return <div className='test-result'>
|
||||||
|
{result.error && <ErrorMessage error={result.error}></ErrorMessage>}
|
||||||
{result.steps.map((step, i) => <StepTreeItem key={i} step={step} depth={0}></StepTreeItem>)}
|
{result.steps.map((step, i) => <StepTreeItem key={i} step={step} depth={0}></StepTreeItem>)}
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
@ -179,10 +181,13 @@ const StepTreeItem: React.FC<{
|
|||||||
<span style={{ whiteSpace: 'pre' }}>{step.title}</span>
|
<span style={{ whiteSpace: 'pre' }}>{step.title}</span>
|
||||||
<div style={{ flex: 'auto' }}></div>
|
<div style={{ flex: 'auto' }}></div>
|
||||||
<div>{msToString(step.duration)}</div>
|
<div>{msToString(step.duration)}</div>
|
||||||
</div>} loadChildren={step.steps.length + (step.log || []).length ? () => {
|
</div>} loadChildren={step.steps.length + (step.log || []).length + (step.error ? 1 : 0) ? () => {
|
||||||
const stepChildren = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1}></StepTreeItem>);
|
const stepChildren = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1}></StepTreeItem>);
|
||||||
const logChildren = (step.log || []).map((l, i) => <LogTreeItem key={step.steps.length + i} log={l} depth={depth + 1}></LogTreeItem>);
|
const logChildren = (step.log || []).map((l, i) => <LogTreeItem key={step.steps.length + i} log={l} depth={depth + 1}></LogTreeItem>);
|
||||||
return [...stepChildren, ...logChildren];
|
const children = [...stepChildren, ...logChildren];
|
||||||
|
if (step.error)
|
||||||
|
children.unshift(<ErrorMessage error={step.error}></ErrorMessage>);
|
||||||
|
return children;
|
||||||
} : undefined} depth={depth}></TreeItem>;
|
} : undefined} depth={depth}></TreeItem>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -225,3 +230,35 @@ function retryLabel(index: number) {
|
|||||||
return 'Run';
|
return 'Run';
|
||||||
return `Retry #${index}`;
|
return `Retry #${index}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ErrorMessage: React.FC<{
|
||||||
|
error: string;
|
||||||
|
}> = ({ error }) => {
|
||||||
|
const html = React.useMemo(() => {
|
||||||
|
return new ansi2html({ colors: ansiColors }).toHtml(escapeHTML(error));
|
||||||
|
}, [error]);
|
||||||
|
return <div className='error-message' dangerouslySetInnerHTML={{ __html: html || '' }}></div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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'
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHTML(text: string): string {
|
||||||
|
return text.replace(/[&"<>]/g, c => ({ '&': '&', '"': '"', '<': '<', '>': '>' }[c]!));
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user