chore(html): render steps and errors (#8826)

This commit is contained in:
Pavel Feldman 2021-09-10 07:52:29 -07:00 committed by GitHub
parent 09afd50ab3
commit ccff6e3036
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 69 additions and 24 deletions

View File

@ -100,7 +100,7 @@ export function addGenerateHtmlCommand(program: commander.CommanderStatic) {
for (const file of files)
reportFiles.add(path.join(reportFolder, file));
}
new HtmlBuilder([...reportFiles], output);
new HtmlBuilder([...reportFiles], output, loader.fullConfig().rootDir);
}).on('--help', () => {
console.log('');
console.log('Examples:');

View File

@ -16,15 +16,18 @@
import fs from 'fs';
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 { calculateSha1 } from '../../utils/utils';
import { toPosixPath } from '../reporters/json';
export class HtmlBuilder {
private _reportFolder: string;
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);
const dataFolder = path.join(this._reportFolder, 'data');
fs.mkdirSync(dataFolder, { recursive: true });
@ -37,12 +40,13 @@ export class HtmlBuilder {
const projectJson = JSON.parse(fs.readFileSync(projectFile, 'utf-8')) as JsonReport;
const suites: SuiteTreeItem[] = [];
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[] = [];
suites.push(this._createSuiteTreeItem(file, fileId, tests));
const testFile: TestFile = {
fileId,
path: file.location!.file,
path: relativeFileName,
tests: tests.map(t => this._createTestCase(t))
};
fs.writeFileSync(path.join(dataFolder, fileId + '.json'), JSON.stringify(testFile, undefined, 2));
@ -60,7 +64,7 @@ export class HtmlBuilder {
return {
testId: test.testId,
title: test.title,
location: test.location,
location: this._relativeLocation(test.location),
results: test.results.map(r => this._createTestResult(r))
};
}
@ -71,7 +75,7 @@ export class HtmlBuilder {
testCollector.push(...suite.tests);
return {
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),
failedTests: suites.reduce((a, s) => a + s.failedTests, 0) + tests.reduce((a, t) => t.outcome === 'unexpected' || t.outcome === 'flaky' ? a + 1 : a, 0),
suites,
@ -85,7 +89,7 @@ export class HtmlBuilder {
return {
testId: test.testId,
fileId: fileId,
location: test.location,
location: this._relativeLocation(test.location),
title: test.title,
duration,
outcome: test.outcome
@ -98,7 +102,7 @@ export class HtmlBuilder {
startTime: result.startTime,
retry: result.retry,
steps: result.steps.map(s => this._createTestStep(s)),
error: result.error,
error: result.error?.message,
status: result.status,
};
}
@ -110,7 +114,17 @@ export class HtmlBuilder {
duration: step.duration,
steps: step.steps.map(s => this._createTestStep(s)),
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,
};
}
}

View File

@ -57,18 +57,12 @@ export type TestCase = {
results: TestResult[];
};
export interface TestError {
message?: string;
stack?: string;
value?: string;
}
export type TestResult = {
retry: number;
startTime: string;
duration: number;
steps: TestStep[];
error?: TestError;
error?: string;
status: 'passed' | 'failed' | 'timedOut' | 'skipped';
};
@ -77,6 +71,6 @@ export type TestStep = {
startTime: string;
duration: number;
log?: string[];
error?: TestError;
error?: string;
steps: TestStep[];
};

View File

@ -22,8 +22,8 @@ import { assert, calculateSha1 } from '../../utils/utils';
import { sanitizeForFilePath } from '../util';
import { serializePatterns } from './json';
export type JsonStats = { expected: number, unexpected: number, flaky: number, skipped: number };
export type JsonLocation = Location;
export type JsonError = TestError;
export type JsonStackFrame = { file: string, line: number, column: number };
export type JsonReport = {
@ -86,7 +86,7 @@ export type JsonTestResult = {
startTime: string;
duration: number;
status: TestStatus;
error?: TestError;
error?: JsonError;
attachments: JsonAttachment[];
steps: JsonTestStep[];
};
@ -96,7 +96,7 @@ export type JsonTestStep = {
category: string,
startTime: string;
duration: number;
error?: TestError;
error?: JsonError;
steps: JsonTestStep[];
log?: string[];
};

View File

@ -52,7 +52,7 @@
padding: 5px;
overflow: auto;
margin: 20px 0;
flex: auto;
flex: none;
}
.status-icon {

View File

@ -16,6 +16,7 @@
import './htmlReport.css';
import * as React from 'react';
import ansi2html from 'ansi-to-html';
import { SplitView } from '../components/splitView';
import { TreeItem } from '../components/treeItem';
import { TabbedPane } from '../traceViewer/ui/tabbedPane';
@ -166,6 +167,7 @@ const TestResultView: React.FC<{
result: TestResult,
}> = ({ 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>)}
</div>;
};
@ -179,10 +181,13 @@ const StepTreeItem: React.FC<{
<span style={{ whiteSpace: 'pre' }}>{step.title}</span>
<div style={{ flex: 'auto' }}></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 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>;
};
@ -225,3 +230,35 @@ function retryLabel(index: number) {
return 'Run';
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 => ({ '&': '&amp;', '"': '&quot;', '<': '&lt;', '>': '&gt;' }[c]!));
}