feat(reporter): render attachments in html report (#8125)
This commit is contained in:
parent
76150f1bcb
commit
b800c1d35c
|
|
@ -17,7 +17,7 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { FullConfig, Location, Suite, TestCase, TestError, TestResult, TestStatus, TestStep } from '../../../types/testReporter';
|
||||
import { calculateFileSha1 } from '../../utils/utils';
|
||||
import { calculateSha1 } from '../../utils/utils';
|
||||
import { formatResultFailure } from './base';
|
||||
import { serializePatterns, toPosixPath } from './json';
|
||||
|
||||
|
|
@ -52,6 +52,7 @@ export type JsonSuite = {
|
|||
};
|
||||
|
||||
export type JsonTestCase = {
|
||||
testId: string;
|
||||
title: string;
|
||||
location: JsonLocation;
|
||||
expectedStatus: TestStatus;
|
||||
|
|
@ -103,29 +104,30 @@ export type JsonTestStep = {
|
|||
};
|
||||
|
||||
class HtmlReporter {
|
||||
private _targetFolder: string;
|
||||
private _reportFolder: string;
|
||||
private _resourcesFolder: string;
|
||||
private config!: FullConfig;
|
||||
private suite!: Suite;
|
||||
|
||||
constructor() {
|
||||
this._reportFolder = path.resolve(process.cwd(), process.env[`PLAYWRIGHT_HTML_REPORT`] || 'playwright-report');
|
||||
this._resourcesFolder = path.join(this._reportFolder, 'resources');
|
||||
fs.mkdirSync(this._resourcesFolder, { recursive: true });
|
||||
const appFolder = path.join(__dirname, '..', '..', 'web', 'htmlReport');
|
||||
for (const file of fs.readdirSync(appFolder))
|
||||
fs.copyFileSync(path.join(appFolder, file), path.join(this._reportFolder, file));
|
||||
}
|
||||
|
||||
onBegin(config: FullConfig, suite: Suite) {
|
||||
this.config = config;
|
||||
this.suite = suite;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this._targetFolder = process.env[`PLAYWRIGHT_HTML_REPORT`] || 'playwright-report';
|
||||
fs.mkdirSync(this._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(this._targetFolder, file));
|
||||
}
|
||||
|
||||
async onEnd() {
|
||||
const stats: JsonStats = { expected: 0, unexpected: 0, skipped: 0, flaky: 0 };
|
||||
this.suite.allTests().forEach(t => {
|
||||
++stats[t.outcome()];
|
||||
});
|
||||
const reportFile = path.join(this._targetFolder, 'report.json');
|
||||
const output: JsonReport = {
|
||||
config: {
|
||||
...this.config,
|
||||
|
|
@ -147,7 +149,7 @@ class HtmlReporter {
|
|||
stats,
|
||||
suites: await Promise.all(this.suite.suites.map(s => this._serializeSuite(s)))
|
||||
};
|
||||
fs.writeFileSync(reportFile, JSON.stringify(output));
|
||||
fs.writeFileSync(path.join(this._reportFolder, 'report.json'), JSON.stringify(output));
|
||||
}
|
||||
|
||||
private _relativeLocation(location: Location | undefined): Location {
|
||||
|
|
@ -170,7 +172,9 @@ class HtmlReporter {
|
|||
}
|
||||
|
||||
private async _serializeTest(test: TestCase): Promise<JsonTestCase> {
|
||||
const testId = calculateSha1(test.titlePath().join('|'));
|
||||
return {
|
||||
testId,
|
||||
title: test.title,
|
||||
location: this._relativeLocation(test.location),
|
||||
expectedStatus: test.expectedStatus,
|
||||
|
|
@ -179,11 +183,11 @@ class HtmlReporter {
|
|||
retries: test.retries,
|
||||
ok: test.ok(),
|
||||
outcome: test.outcome(),
|
||||
results: await Promise.all(test.results.map(r => this._serializeResult(test, r))),
|
||||
results: await Promise.all(test.results.map(r => this._serializeResult(testId, test, r))),
|
||||
};
|
||||
}
|
||||
|
||||
private async _serializeResult(test: TestCase, result: TestResult): Promise<JsonTestResult> {
|
||||
private async _serializeResult(testId: string, test: TestCase, result: TestResult): Promise<JsonTestResult> {
|
||||
return {
|
||||
retry: result.retry,
|
||||
workerIndex: result.workerIndex,
|
||||
|
|
@ -192,29 +196,58 @@ class HtmlReporter {
|
|||
status: result.status,
|
||||
error: result.error,
|
||||
failureSnippet: formatResultFailure(test, result, '').join('') || undefined,
|
||||
attachments: await this._copyAttachments(result.attachments),
|
||||
attachments: await this._createAttachments(testId, result),
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
steps: this._serializeSteps(result.steps)
|
||||
};
|
||||
}
|
||||
|
||||
private async _copyAttachments(attachments: TestAttachment[]): Promise<JsonAttachment[]> {
|
||||
const result: JsonAttachment[] = [];
|
||||
for (const attachment of attachments) {
|
||||
private async _createAttachments(testId: string, result: TestResult): Promise<JsonAttachment[]> {
|
||||
const attachments: JsonAttachment[] = [];
|
||||
for (const attachment of result.attachments) {
|
||||
if (attachment.path) {
|
||||
const sha1 = await calculateFileSha1(attachment.path) + extension(attachment.contentType);
|
||||
fs.copyFileSync(attachment.path, path.join(this._targetFolder, sha1));
|
||||
result.push({
|
||||
const sha1 = calculateSha1(attachment.path) + path.extname(attachment.path);
|
||||
fs.copyFileSync(attachment.path, path.join(this._resourcesFolder, sha1));
|
||||
attachments.push({
|
||||
...attachment,
|
||||
body: undefined,
|
||||
sha1
|
||||
});
|
||||
} else if (attachment.body && isTextAttachment(attachment.contentType)) {
|
||||
attachments.push({ ...attachment, body: attachment.body.toString() });
|
||||
} else {
|
||||
const sha1 = calculateSha1(attachment.body!) + '.dat';
|
||||
fs.writeFileSync(path.join(this._resourcesFolder, sha1), attachment.body);
|
||||
attachments.push({
|
||||
...attachment,
|
||||
body: undefined,
|
||||
sha1
|
||||
});
|
||||
} else if (attachment.body) {
|
||||
result.push({ ...attachment, body: attachment.body.toString('base64') });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
if (result.stdout.length)
|
||||
attachments.push(this._stdioAttachment(testId, result, 'stdout'));
|
||||
if (result.stderr.length)
|
||||
attachments.push(this._stdioAttachment(testId, result, 'stderr'));
|
||||
return attachments;
|
||||
}
|
||||
|
||||
private _stdioAttachment(testId: string, result: TestResult, type: 'stdout' | 'stderr'): JsonAttachment {
|
||||
const sha1 = `${testId}.${result.retry}.${type}`;
|
||||
const fileName = path.join(this._resourcesFolder, sha1);
|
||||
for (const chunk of type === 'stdout' ? result.stdout : result.stderr) {
|
||||
if (typeof chunk === 'string')
|
||||
fs.appendFileSync(fileName, chunk + '\n');
|
||||
else
|
||||
fs.appendFileSync(fileName, chunk);
|
||||
}
|
||||
return {
|
||||
name: type,
|
||||
contentType: 'application/octet-stream',
|
||||
sha1
|
||||
};
|
||||
}
|
||||
|
||||
private _serializeSteps(steps: TestStep[]): JsonTestStep[] {
|
||||
|
|
@ -255,14 +288,12 @@ function containsStep(outer: TestStep, inner: TestStep): boolean {
|
|||
return true;
|
||||
}
|
||||
|
||||
function extension(contentType: string) {
|
||||
if (contentType === 'image/png')
|
||||
return '.png';
|
||||
if (contentType === 'image/jpeg' || contentType === 'image/jpg')
|
||||
return '.jpeg';
|
||||
if (contentType === 'video/webm')
|
||||
return '.webm';
|
||||
return '.data';
|
||||
function isTextAttachment(contentType: string) {
|
||||
if (contentType.startsWith('text/'))
|
||||
return true;
|
||||
if (contentType.includes('json'))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export default HtmlReporter;
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export const TreeItem: React.FunctionComponent<{
|
|||
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' }} />
|
||||
style={{ cursor: 'pointer', color: 'var(--color)', visibility: loadChildren ? 'visible' : 'hidden' }} />
|
||||
{title}
|
||||
</div>
|
||||
{expanded && loadChildren?.()}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,10 @@
|
|||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.codicon {
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
.codicon-clock.status-icon,
|
||||
.codicon-error.status-icon {
|
||||
color: red;
|
||||
|
|
@ -105,7 +109,7 @@
|
|||
}
|
||||
|
||||
.test-overview-title {
|
||||
padding: 4px 0 12px;
|
||||
padding: 10px 0;
|
||||
font-size: 18px;
|
||||
flex: none;
|
||||
}
|
||||
|
|
@ -150,3 +154,11 @@
|
|||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.attachment-body {
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
background-color: #dadada;
|
||||
border: 1px solid #ccc;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,7 +89,7 @@ const ProjectTreeItem: React.FC<{
|
|||
const location = renderLocation(suite?.location, true);
|
||||
|
||||
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>
|
||||
<div style={{ flex: 'auto', alignItems: 'center', display: 'flex' }}>{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}<div style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{suite?.title || 'Project'}</div></div>
|
||||
{!!suite?.location?.line && location && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{location}</div>}
|
||||
</div>
|
||||
} loadChildren={() => {
|
||||
|
|
@ -106,7 +106,7 @@ const ProjectFlatTreeItem: React.FC<{
|
|||
const location = renderLocation(suite?.location, true);
|
||||
|
||||
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>
|
||||
<div style={{ flex: 'auto', alignItems: 'center', display: 'flex' }}>{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}<div style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{suite?.title || 'Project'}</div></div>
|
||||
{!!suite?.location?.line && location && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{location}</div>}
|
||||
</div>
|
||||
} loadChildren={() => {
|
||||
|
|
@ -169,21 +169,24 @@ const TestOverview: React.FC<{
|
|||
test: JsonTestCase,
|
||||
result: JsonTestResult,
|
||||
}> = ({ test, result }) => {
|
||||
const { attachments, screenshots } = React.useMemo(() => {
|
||||
const attachments = new Map<string, JsonAttachment>();
|
||||
const screenshots = result.attachments.filter(a => a.name === 'actual');
|
||||
const { screenshots, attachmentsMap } = React.useMemo(() => {
|
||||
const attachmentsMap = new Map<string, JsonAttachment>();
|
||||
const screenshots = result.attachments.filter(a => a.name === 'screenshot');
|
||||
for (const a of result.attachments)
|
||||
attachments.set(a.name, a);
|
||||
return { attachments, screenshots };
|
||||
attachmentsMap.set(a.name, a);
|
||||
return { attachmentsMap, screenshots };
|
||||
}, [ result ]);
|
||||
return <div className="test-result">
|
||||
<div className='test-overview-title'>{test?.title}</div>
|
||||
<div className='test-overview-property'>{renderLocation(test.location, true)}<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>)}
|
||||
{attachments.has('expected') && attachments.has('actual') && <ImageDiff actual={attachments.get('actual')!} expected={attachments.get('expected')!} diff={attachments.get('diff')}></ImageDiff>}
|
||||
{attachmentsMap.has('expected') && attachmentsMap.has('actual') && <ImageDiff actual={attachmentsMap.get('actual')!} expected={attachmentsMap.get('expected')!} diff={attachmentsMap.get('diff')}></ImageDiff>}
|
||||
{!!screenshots.length && <div className='test-overview-title'>Screenshots</div>}
|
||||
{screenshots.map(a => <div className='image-preview'><img src={a.sha1} /></div>)}
|
||||
{screenshots.map(a => <div className='image-preview'><img src={'resources/' + a.sha1} /></div>)}
|
||||
{!!result.attachments && <div className='test-overview-title'>Attachments</div>}
|
||||
{result.attachments.map(a => <AttachmentLink attachment={a}></AttachmentLink>)}
|
||||
<div className='test-overview-title'></div>
|
||||
</div>;
|
||||
};
|
||||
|
||||
|
|
@ -211,18 +214,18 @@ export const ImageDiff: React.FunctionComponent<{
|
|||
tabs.push({
|
||||
id: 'actual',
|
||||
title: 'Actual',
|
||||
render: () => <div className='image-preview'><img src={actual.sha1}/></div>
|
||||
render: () => <div className='image-preview'><img src={'resources/' + actual.sha1}/></div>
|
||||
});
|
||||
tabs.push({
|
||||
id: 'expected',
|
||||
title: 'Expected',
|
||||
render: () => <div className='image-preview'><img src={expected.sha1}/></div>
|
||||
render: () => <div className='image-preview'><img src={'resources/' + expected.sha1}/></div>
|
||||
});
|
||||
if (diff) {
|
||||
tabs.push({
|
||||
id: 'diff',
|
||||
title: 'Diff',
|
||||
render: () => <div className='image-preview'><img src={diff.sha1}/></div>,
|
||||
render: () => <div className='image-preview'><img src={'resources/' + diff.sha1}/></div>,
|
||||
});
|
||||
}
|
||||
return <div className='vbox test-image-mismatch'>
|
||||
|
|
@ -231,6 +234,18 @@ export const ImageDiff: React.FunctionComponent<{
|
|||
</div>;
|
||||
};
|
||||
|
||||
export const AttachmentLink: React.FunctionComponent<{
|
||||
attachment: JsonAttachment,
|
||||
}> = ({ attachment }) => {
|
||||
return <TreeItem title={<div style={{ display: 'flex', alignItems: 'center', flex: 'auto', maxWidth: 430 }}>
|
||||
<span className={'codicon codicon-cloud-download'}></span>
|
||||
{attachment.sha1 && <a href={'resources/' + attachment.sha1} target='_blank'>{attachment.name}</a>}
|
||||
{attachment.body && <span>{attachment.name}</span>}
|
||||
</div>} loadChildren={attachment.body ? () => {
|
||||
return [<div className='attachment-body'>${attachment.body}</div>];
|
||||
} : undefined} depth={0}></TreeItem>;
|
||||
};
|
||||
|
||||
function testSuiteErrorStatusIcon(suite?: JsonSuite): JSX.Element | undefined {
|
||||
if (!suite)
|
||||
return;
|
||||
|
|
@ -291,7 +306,7 @@ function computeUnexpectedTests(suite: JsonSuite): JsonTestCase[] {
|
|||
function renderLocation(location: JsonLocation | undefined, showFileName: boolean) {
|
||||
if (!location)
|
||||
return '';
|
||||
return (showFileName ? location.file : '') + ':' + location.column;
|
||||
return (showFileName ? location.file : '') + ':' + location.line;
|
||||
}
|
||||
|
||||
function retryLabel(index: number) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue