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) 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:');

View File

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

View File

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

View File

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

View File

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

View File

@ -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 => ({ '&': '&amp;', '"': '&quot;', '<': '&lt;', '>': '&gt;' }[c]!));
}