From b800c1d35cc7464c5b4a9ae7e2a5916c934a107d Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Tue, 10 Aug 2021 17:06:25 -0700 Subject: [PATCH] feat(reporter): render attachments in html report (#8125) --- src/test/reporters/html.ts | 95 ++++++++++++++++++++----------- src/web/components/treeItem.tsx | 2 +- src/web/htmlReport/htmlReport.css | 14 ++++- src/web/htmlReport/htmlReport.tsx | 41 ++++++++----- 4 files changed, 105 insertions(+), 47 deletions(-) diff --git a/src/test/reporters/html.ts b/src/test/reporters/html.ts index 26db3c03be..bcc71a5854 100644 --- a/src/test/reporters/html.ts +++ b/src/test/reporters/html.ts @@ -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 { + 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 { + private async _serializeResult(testId: string, test: TestCase, result: TestResult): Promise { 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 { - const result: JsonAttachment[] = []; - for (const attachment of attachments) { + private async _createAttachments(testId: string, result: TestResult): Promise { + 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; diff --git a/src/web/components/treeItem.tsx b/src/web/components/treeItem.tsx index d3bee9aefc..a75452234d 100644 --- a/src/web/components/treeItem.tsx +++ b/src/web/components/treeItem.tsx @@ -29,7 +29,7 @@ export const TreeItem: React.FunctionComponent<{ return
{ onClick?.(); setExpanded(!expanded); }} >
+ style={{ cursor: 'pointer', color: 'var(--color)', visibility: loadChildren ? 'visible' : 'hidden' }} /> {title}
{expanded && loadChildren?.()} diff --git a/src/web/htmlReport/htmlReport.css b/src/web/htmlReport/htmlReport.css index af2e794213..197d9efefe 100644 --- a/src/web/htmlReport/htmlReport.css +++ b/src/web/htmlReport/htmlReport.css @@ -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; +} diff --git a/src/web/htmlReport/htmlReport.tsx b/src/web/htmlReport/htmlReport.tsx index 385659ef13..f8925cdae2 100644 --- a/src/web/htmlReport/htmlReport.tsx +++ b/src/web/htmlReport/htmlReport.tsx @@ -89,7 +89,7 @@ const ProjectTreeItem: React.FC<{ const location = renderLocation(suite?.location, true); return -
{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}
{suite?.title}
+
{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}
{suite?.title || 'Project'}
{!!suite?.location?.line && location &&
{location}
}
} loadChildren={() => { @@ -106,7 +106,7 @@ const ProjectFlatTreeItem: React.FC<{ const location = renderLocation(suite?.location, true); return -
{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}
{suite?.title}
+
{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}
{suite?.title || 'Project'}
{!!suite?.location?.line && location &&
{location}
}
} loadChildren={() => { @@ -169,21 +169,24 @@ const TestOverview: React.FC<{ test: JsonTestCase, result: JsonTestResult, }> = ({ test, result }) => { - const { attachments, screenshots } = React.useMemo(() => { - const attachments = new Map(); - const screenshots = result.attachments.filter(a => a.name === 'actual'); + const { screenshots, attachmentsMap } = React.useMemo(() => { + const attachmentsMap = new Map(); + 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
{test?.title}
{renderLocation(test.location, true)}
{msToString(result.duration)}
{result.failureSnippet &&
} {result.steps.map((step, i) => )} - {attachments.has('expected') && attachments.has('actual') && } + {attachmentsMap.has('expected') && attachmentsMap.has('actual') && } {!!screenshots.length &&
Screenshots
} - {screenshots.map(a =>
)} + {screenshots.map(a =>
)} + {!!result.attachments &&
Attachments
} + {result.attachments.map(a => )} +
; }; @@ -211,18 +214,18 @@ export const ImageDiff: React.FunctionComponent<{ tabs.push({ id: 'actual', title: 'Actual', - render: () =>
+ render: () =>
}); tabs.push({ id: 'expected', title: 'Expected', - render: () =>
+ render: () =>
}); if (diff) { tabs.push({ id: 'diff', title: 'Diff', - render: () =>
, + render: () =>
, }); } return
@@ -231,6 +234,18 @@ export const ImageDiff: React.FunctionComponent<{
; }; +export const AttachmentLink: React.FunctionComponent<{ + attachment: JsonAttachment, +}> = ({ attachment }) => { + return + + {attachment.sha1 && {attachment.name}} + {attachment.body && {attachment.name}} + } loadChildren={attachment.body ? () => { + return [
${attachment.body}
]; + } : undefined} depth={0}>
; +}; + 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) {