diff --git a/packages/playwright-test/src/reporters/DEPS.list b/packages/playwright-test/src/reporters/DEPS.list index b5f5ed31c5..86e4a61ddb 100644 --- a/packages/playwright-test/src/reporters/DEPS.list +++ b/packages/playwright-test/src/reporters/DEPS.list @@ -10,5 +10,5 @@ [internalReporter.ts] ../transform/babelBundle.ts -[raw.ts] +[html.ts] ../transform/babelBundle.ts diff --git a/packages/playwright-test/src/reporters/html.ts b/packages/playwright-test/src/reporters/html.ts index c16e6a00f8..72503e75c9 100644 --- a/packages/playwright-test/src/reporters/html.ts +++ b/packages/playwright-test/src/reporters/html.ts @@ -15,15 +15,17 @@ */ import { colors, open } from 'playwright-core/lib/utilsBundle'; +import { MultiMap } from 'playwright-core/lib/utils'; import fs from 'fs'; import path from 'path'; import type { TransformCallback } from 'stream'; import { Transform } from 'stream'; -import type { FullConfig, Suite } from '../../types/testReporter'; +import { toPosixPath } from './json'; +import { codeFrameColumns } from '../transform/babelBundle'; +import type { FullConfig, Location, Suite, TestCase as TestCasePublic, TestResult as TestResultPublic, TestStep as TestStepPublic } from '../../types/testReporter'; +import type { SuitePrivate } from '../../types/reporterPrivate'; import { HttpServer, assert, calculateSha1, copyFileAndMakeWritable, gracefullyProcessExitDoNotHang, removeFolders, sanitizeForFilePath } from 'playwright-core/lib/utils'; -import type { JsonAttachment, JsonReport, JsonSuite, JsonTestCase, JsonTestResult, JsonTestStep } from './raw'; -import RawReporter from './raw'; -import { stripAnsiEscapes } from './base'; +import { formatResultFailure, stripAnsiEscapes } from './base'; import { resolveReporterOutputPath } from '../util'; import type { Metadata } from '../../types/test'; import type { ZipFile } from 'playwright-core/lib/zipBundle'; @@ -105,14 +107,9 @@ class HtmlReporter extends EmptyReporter { override async onEnd() { const projectSuites = this.suite.suites; - const reports = projectSuites.map(suite => { - const rawReporter = new RawReporter(); - const report = rawReporter.generateProjectReport(this.config, suite); - return report; - }); await removeFolders([this._outputFolder]); - const builder = new HtmlBuilder(this._outputFolder, this._attachmentsBaseURL); - this._buildResult = await builder.build(this.config.metadata, reports); + const builder = new HtmlBuilder(this.config, this._outputFolder, this._attachmentsBaseURL); + this._buildResult = await builder.build(this.config.metadata, projectSuites); } override async onExit() { @@ -186,27 +183,29 @@ export function startHtmlReportServer(folder: string): HttpServer { } class HtmlBuilder { + private _config: FullConfig; private _reportFolder: string; - private _tests = new Map(); private _testPath = new Map(); + private _stepsInFile = new MultiMap(); private _dataZipFile: ZipFile; private _hasTraces = false; private _attachmentsBaseURL: string; - constructor(outputDir: string, attachmentsBaseURL: string) { + constructor(config: FullConfig, outputDir: string, attachmentsBaseURL: string) { + this._config = config; this._reportFolder = outputDir; fs.mkdirSync(this._reportFolder, { recursive: true }); this._dataZipFile = new yazl.ZipFile(); this._attachmentsBaseURL = attachmentsBaseURL; } - async build(metadata: Metadata, rawReports: JsonReport[]): Promise<{ ok: boolean, singleTestId: string | undefined }> { + async build(metadata: Metadata, projectSuites: Suite[]): Promise<{ ok: boolean, singleTestId: string | undefined }> { const data = new Map(); - for (const projectJson of rawReports) { - for (const file of projectJson.suites) { - const fileName = file.location!.file; - const fileId = file.fileId!; + for (const projectSuite of projectSuites) { + for (const fileSuite of projectSuite.suites) { + const fileName = this._relativeLocation(fileSuite.location)!.file; + const fileId = (fileSuite as SuitePrivate)._fileId!; let fileEntry = data.get(fileId); if (!fileEntry) { fileEntry = { @@ -217,13 +216,14 @@ class HtmlBuilder { } const { testFile, testFileSummary } = fileEntry; const testEntries: TestEntry[] = []; - this._processJsonSuite(file, fileId, projectJson.project.name, projectJson.project.metadata?.reportName, [], testEntries); + this._processJsonSuite(fileSuite, fileId, projectSuite.project()!.name, projectSuite.project()!.metadata?.reportName, [], testEntries); for (const test of testEntries) { testFile.tests.push(test.testCase); testFileSummary.tests.push(test.testCaseSummary); } } } + createSnippets(this._stepsInFile); let ok = true; for (const [fileId, { testFile, testFileSummary }] of data) { @@ -256,7 +256,7 @@ class HtmlBuilder { const htmlReport: HTMLReport = { metadata, files: [...data.values()].map(e => e.testFileSummary), - projectNames: rawReports.map(r => r.project.name), + projectNames: projectSuites.map(r => r.project()!.name), stats: { ...[...data.values()].reduce((a, e) => addStats(a, e.testFileSummary.stats), emptyStats()), duration: metadata.totalTime } }; htmlReport.files.sort((f1, f2) => { @@ -314,46 +314,45 @@ class HtmlBuilder { this._dataZipFile.addBuffer(Buffer.from(JSON.stringify(data)), fileName); } - private _processJsonSuite(suite: JsonSuite, fileId: string, projectName: string, reportName: string | undefined, path: string[], outTests: TestEntry[]) { + private _processJsonSuite(suite: Suite, fileId: string, projectName: string, reportName: string | undefined, path: string[], outTests: TestEntry[]) { const newPath = [...path, suite.title]; - suite.suites.map(s => this._processJsonSuite(s, fileId, projectName, reportName, newPath, outTests)); + suite.suites.forEach(s => this._processJsonSuite(s, fileId, projectName, reportName, newPath, outTests)); suite.tests.forEach(t => outTests.push(this._createTestEntry(t, projectName, reportName, newPath))); } - private _createTestEntry(test: JsonTestCase, projectName: string, reportName: string | undefined, path: string[]): TestEntry { + private _createTestEntry(test: TestCasePublic, projectName: string, reportName: string | undefined, path: string[]): TestEntry { const duration = test.results.reduce((a, r) => a + r.duration, 0); - this._tests.set(test.testId, test); - const location = test.location; + const location = this._relativeLocation(test.location)!; path = [...path.slice(1)]; - this._testPath.set(test.testId, path); + this._testPath.set(test.id, path); - const results = test.results.map(r => this._createTestResult(r)); + const results = test.results.map(r => this._createTestResult(test, r)); return { testCase: { - testId: test.testId, + testId: test.id, title: test.title, projectName, reportName, location, duration, annotations: test.annotations, - outcome: test.outcome, + outcome: test.outcome(), path, results, - ok: test.outcome === 'expected' || test.outcome === 'flaky', + ok: test.outcome() === 'expected' || test.outcome() === 'flaky', }, testCaseSummary: { - testId: test.testId, + testId: test.id, title: test.title, projectName, reportName, location, duration, annotations: test.annotations, - outcome: test.outcome, + outcome: test.outcome(), path, - ok: test.outcome === 'expected' || test.outcome === 'flaky', + ok: test.outcome() === 'expected' || test.outcome() === 'flaky', results: results.map(result => { return { attachments: result.attachments.map(a => ({ name: a.name, contentType: a.contentType, path: a.path })) }; }), @@ -433,28 +432,45 @@ class HtmlBuilder { }).filter(Boolean) as TestAttachment[]; } - private _createTestResult(result: JsonTestResult): TestResult { + private _createTestResult(test: TestCasePublic, result: TestResultPublic): TestResult { return { duration: result.duration, - startTime: result.startTime, + startTime: result.startTime.toISOString(), retry: result.retry, - steps: result.steps.map(s => this._createTestStep(s)), - errors: result.errors, + steps: dedupeSteps(result.steps).map(s => this._createTestStep(s)), + errors: formatResultFailure(test, result, '', true).map(error => error.message), status: result.status, - attachments: this._serializeAttachments(result.attachments), + attachments: this._serializeAttachments([ + ...result.attachments, + ...result.stdout.map(m => stdioAttachment(m, 'stdout')), + ...result.stderr.map(m => stdioAttachment(m, 'stderr'))]), }; } - private _createTestStep(step: JsonTestStep): TestStep { - return { + private _createTestStep(dedupedStep: DedupedStep): TestStep { + const { step, duration, count } = dedupedStep; + const result: TestStep = { title: step.title, - startTime: step.startTime, - duration: step.duration, - snippet: step.snippet, - steps: step.steps.map(s => this._createTestStep(s)), - location: step.location, - error: step.error, - count: step.count + startTime: step.startTime.toISOString(), + duration, + steps: dedupeSteps(step.steps).map(s => this._createTestStep(s)), + location: this._relativeLocation(step.location), + error: step.error?.message, + count + }; + if (result.location) + this._stepsInFile.set(result.location.file, result); + return result; + } + + private _relativeLocation(location: Location | undefined): Location | undefined { + if (!location) + return undefined; + const file = toPosixPath(path.relative(this._config.rootDir, location.file)); + return { + file, + line: location.line, + column: location.column, }; } } @@ -512,4 +528,75 @@ function isTextContentType(contentType: string) { return contentType.startsWith('text/') || contentType.startsWith('application/json'); } +type JsonAttachment = { + name: string; + body?: string | Buffer; + path?: string; + contentType: string; +}; + +function stdioAttachment(chunk: Buffer | string, type: 'stdout' | 'stderr'): JsonAttachment { + if (typeof chunk === 'string') { + return { + name: type, + contentType: 'text/plain', + body: chunk + }; + } + return { + name: type, + contentType: 'application/octet-stream', + body: chunk + }; +} + +type DedupedStep = { step: TestStepPublic, count: number, duration: number }; + +function dedupeSteps(steps: TestStepPublic[]) { + const result: DedupedStep[] = []; + let lastResult = undefined; + for (const step of steps) { + const canDedupe = !step.error && step.duration >= 0 && step.location?.file && !step.steps.length; + const lastStep = lastResult?.step; + if (canDedupe && lastResult && lastStep && step.category === lastStep.category && step.title === lastStep.title && step.location?.file === lastStep.location?.file && step.location?.line === lastStep.location?.line && step.location?.column === lastStep.location?.column) { + ++lastResult.count; + lastResult.duration += step.duration; + continue; + } + lastResult = { step, count: 1, duration: step.duration }; + result.push(lastResult); + if (!canDedupe) + lastResult = undefined; + } + return result; +} + +function createSnippets(stepsInFile: MultiMap) { + for (const file of stepsInFile.keys()) { + let source: string; + try { + source = fs.readFileSync(file, 'utf-8') + '\n//'; + } catch (e) { + continue; + } + const lines = source.split('\n').length; + const highlighted = codeFrameColumns(source, { start: { line: lines, column: 1 } }, { highlightCode: true, linesAbove: lines, linesBelow: 0 }); + const highlightedLines = highlighted.split('\n'); + const lineWithArrow = highlightedLines[highlightedLines.length - 1]; + for (const step of stepsInFile.get(file)) { + // Don't bother with snippets that have less than 3 lines. + if (step.location!.line < 2 || step.location!.line >= lines) + continue; + // Cut out snippet. + const snippetLines = highlightedLines.slice(step.location!.line - 2, step.location!.line + 1); + // Relocate arrow. + const index = lineWithArrow.indexOf('^'); + const shiftedArrow = lineWithArrow.slice(0, index) + ' '.repeat(step.location!.column - 1) + lineWithArrow.slice(index); + // Insert arrow line. + snippetLines.splice(2, 0, shiftedArrow); + step.snippet = snippetLines.join('\n'); + } + } +} + export default HtmlReporter; diff --git a/packages/playwright-test/src/reporters/raw.ts b/packages/playwright-test/src/reporters/raw.ts deleted file mode 100644 index eb08788c7a..0000000000 --- a/packages/playwright-test/src/reporters/raw.ts +++ /dev/null @@ -1,322 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import fs from 'fs'; -import path from 'path'; -import type { FullConfig, Location, Suite, TestCase, TestResult, TestStatus, TestStep } from '../../types/testReporter'; -import { assert, sanitizeForFilePath } from 'playwright-core/lib/utils'; -import { formatResultFailure } from './base'; -import { toPosixPath, serializePatterns } from './json'; -import { MultiMap } from 'playwright-core/lib/utils'; -import { codeFrameColumns } from '../transform/babelBundle'; -import type { Metadata } from '../../types/test'; -import type { SuitePrivate } from '../../types/reporterPrivate'; - -export type JsonLocation = Location; -export type JsonError = string; -export type JsonStackFrame = { file: string, line: number, column: number }; - -export type JsonReport = { - config: JsonConfig, - project: JsonProject, - suites: JsonSuite[], -}; - -export type JsonConfig = Omit; - -export type JsonProject = { - metadata: Metadata, - name: string, - outputDir: string, - repeatEach: number, - retries: number, - testDir: string, - testIgnore: string[], - testMatch: string[], - timeout: number, -}; - -export type JsonSuite = { - fileId?: string; - title: string; - location?: JsonLocation; - suites: JsonSuite[]; - tests: JsonTestCase[]; -}; - -export type JsonTestCase = { - testId: string; - title: string; - location: JsonLocation; - expectedStatus: TestStatus; - timeout: number; - annotations: { type: string, description?: string }[]; - retries: number; - results: JsonTestResult[]; - ok: boolean; - outcome: 'skipped' | 'expected' | 'unexpected' | 'flaky'; -}; - -export type JsonAttachment = { - name: string; - body?: string | Buffer; - path?: string; - contentType: string; -}; - -export type JsonTestResult = { - retry: number; - workerIndex: number; - startTime: string; - duration: number; - status: TestStatus; - errors: JsonError[]; - attachments: JsonAttachment[]; - steps: JsonTestStep[]; -}; - -export type JsonTestStep = { - title: string; - category: string, - startTime: string; - duration: number; - error?: JsonError; - steps: JsonTestStep[]; - location?: Location; - snippet?: string; - count: number; -}; - -class RawReporter { - private config!: FullConfig; - private suite!: Suite; - private stepsInFile = new MultiMap(); - - onBegin(config: FullConfig, suite: Suite) { - this.config = config; - this.suite = suite; - } - - async onEnd() { - const projectSuites = this.suite.suites; - for (const suite of projectSuites) { - const project = suite.project(); - assert(project, 'Internal Error: Invalid project structure'); - const reportFolder = path.join(project.outputDir, 'report'); - fs.mkdirSync(reportFolder, { recursive: true }); - let reportFile: string | undefined; - for (let i = 0; i < 10; ++i) { - reportFile = path.join(reportFolder, sanitizeForFilePath(project.name || 'project') + (i ? '-' + i : '') + '.report'); - try { - if (fs.existsSync(reportFile)) - continue; - } catch (e) { - } - break; - } - if (!reportFile) - throw new Error('Internal error, could not create report file'); - const report = this.generateProjectReport(this.config, suite); - fs.writeFileSync(reportFile, JSON.stringify(report, undefined, 2)); - } - } - - generateAttachments(attachments: TestResult['attachments'], ioStreams?: Pick): JsonAttachment[] { - const out: JsonAttachment[] = []; - for (const attachment of attachments) { - if (attachment.body) { - out.push({ - name: attachment.name, - contentType: attachment.contentType, - body: attachment.body - }); - } else if (attachment.path) { - out.push({ - name: attachment.name, - contentType: attachment.contentType, - path: attachment.path - }); - } - } - - if (ioStreams) { - for (const chunk of ioStreams.stdout) - out.push(this._stdioAttachment(chunk, 'stdout')); - for (const chunk of ioStreams.stderr) - out.push(this._stdioAttachment(chunk, 'stderr')); - } - - return out; - } - - generateProjectReport(config: FullConfig, suite: Suite): JsonReport { - this.config = config; - const project = suite.project(); - assert(project, 'Internal Error: Invalid project structure'); - const report: JsonReport = { - config: filterOutPrivateFields(config), - project: { - metadata: project.metadata, - name: project.name, - outputDir: project.outputDir, - repeatEach: project.repeatEach, - retries: project.retries, - testDir: project.testDir, - testIgnore: serializePatterns(project.testIgnore), - testMatch: serializePatterns(project.testMatch), - timeout: project.timeout, - }, - suites: suite.suites.map(fileSuite => { - return this._serializeSuite(fileSuite); - }) - }; - for (const file of this.stepsInFile.keys()) { - let source: string; - try { - source = fs.readFileSync(file, 'utf-8') + '\n//'; - } catch (e) { - continue; - } - const lines = source.split('\n').length; - const highlighted = codeFrameColumns(source, { start: { line: lines, column: 1 } }, { highlightCode: true, linesAbove: lines, linesBelow: 0 }); - const highlightedLines = highlighted.split('\n'); - const lineWithArrow = highlightedLines[highlightedLines.length - 1]; - for (const step of this.stepsInFile.get(file)) { - // Don't bother with snippets that have less than 3 lines. - if (step.location!.line < 2 || step.location!.line >= lines) - continue; - // Cut out snippet. - const snippetLines = highlightedLines.slice(step.location!.line - 2, step.location!.line + 1); - // Relocate arrow. - const index = lineWithArrow.indexOf('^'); - const shiftedArrow = lineWithArrow.slice(0, index) + ' '.repeat(step.location!.column - 1) + lineWithArrow.slice(index); - // Insert arrow line. - snippetLines.splice(2, 0, shiftedArrow); - step.snippet = snippetLines.join('\n'); - } - } - return report; - } - - private _serializeSuite(suite: Suite): JsonSuite { - const location = this._relativeLocation(suite.location); - const result = { - title: suite.title, - fileId: (suite as SuitePrivate)._fileId, - location, - suites: suite.suites.map(s => this._serializeSuite(s)), - tests: suite.tests.map(t => this._serializeTest(t)), - }; - return result; - } - - private _serializeTest(test: TestCase): JsonTestCase { - return { - testId: test.id, - title: test.title, - location: this._relativeLocation(test.location)!, - expectedStatus: test.expectedStatus, - timeout: test.timeout, - annotations: test.annotations, - retries: test.retries, - ok: test.ok(), - outcome: test.outcome(), - results: test.results.map(r => this._serializeResult(test, r)), - }; - } - - private _serializeResult(test: TestCase, result: TestResult): JsonTestResult { - return { - retry: result.retry, - workerIndex: result.workerIndex, - startTime: result.startTime.toISOString(), - duration: result.duration, - status: result.status, - errors: formatResultFailure(test, result, '', true).map(error => error.message), - attachments: this.generateAttachments(result.attachments, result), - steps: dedupeSteps(result.steps.map(step => this._serializeStep(test, step))) - }; - } - - private _serializeStep(test: TestCase, step: TestStep): JsonTestStep { - const result: JsonTestStep = { - title: step.title, - category: step.category, - startTime: step.startTime.toISOString(), - duration: step.duration, - error: step.error?.message, - location: this._relativeLocation(step.location), - steps: dedupeSteps(step.steps.map(step => this._serializeStep(test, step))), - count: 1 - }; - - if (step.location) - this.stepsInFile.set(step.location.file, result); - return result; - } - - private _stdioAttachment(chunk: Buffer | string, type: 'stdout' | 'stderr'): JsonAttachment { - if (typeof chunk === 'string') { - return { - name: type, - contentType: 'text/plain', - body: chunk - }; - } - return { - name: type, - contentType: 'application/octet-stream', - body: chunk - }; - } - - private _relativeLocation(location: Location | undefined): Location | undefined { - if (!location) - return undefined; - const file = toPosixPath(path.relative(this.config.rootDir, location.file)); - return { - file, - line: location.line, - column: location.column, - }; - } -} - -function dedupeSteps(steps: JsonTestStep[]): JsonTestStep[] { - const result: JsonTestStep[] = []; - let lastStep: JsonTestStep | undefined; - for (const step of steps) { - const canDedupe = !step.error && step.duration >= 0 && step.location?.file && !step.steps.length; - if (canDedupe && lastStep && step.category === lastStep.category && step.title === lastStep.title && step.location?.file === lastStep.location?.file && step.location?.line === lastStep.location?.line && step.location?.column === lastStep.location?.column) { - ++lastStep.count; - lastStep.duration += step.duration; - continue; - } - result.push(step); - lastStep = canDedupe ? step : undefined; - } - return result; -} - -function filterOutPrivateFields(object: any): any { - if (!object || typeof object !== 'object') - return object; - if (Array.isArray(object)) - return object.map(filterOutPrivateFields); - return Object.fromEntries(Object.entries(object).filter(entry => !entry[0].startsWith('_')).map(entry => [entry[0], filterOutPrivateFields(entry[1])])); -} - -export default RawReporter; diff --git a/tests/playwright-test/reporter-raw.spec.ts b/tests/playwright-test/reporter-raw.spec.ts deleted file mode 100644 index 9b38d96db7..0000000000 --- a/tests/playwright-test/reporter-raw.spec.ts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import fs from 'fs'; -import path from 'path'; -import { test, expect } from './playwright-test-fixtures'; - -const kRawReporterPath = path.join(__dirname, '..', '..', 'packages', 'playwright-test', 'lib', 'reporters', 'raw.js'); - -test('should generate raw report', async ({ runInlineTest }, testInfo) => { - const result = await runInlineTest({ - 'a.test.js': ` - import { test, expect } from '@playwright/test'; - test('passes', async ({ page }, testInfo) => {}); - `, - }, { reporter: 'dot,' + kRawReporterPath }); - const json = JSON.parse(fs.readFileSync(testInfo.outputPath('test-results', 'report', 'project.report'), 'utf-8')); - expect(json.config).toBeTruthy(); - expect(json.project).toBeTruthy(); - expect(result.exitCode).toBe(0); -}); - -test('should use project name', async ({ runInlineTest }, testInfo) => { - const result = await runInlineTest({ - 'playwright.config.ts': ` - module.exports = { - projects: [{ - name: 'project-name', - outputDir: 'output' - }] - } - `, - 'a.test.js': ` - import { test, expect } from '@playwright/test'; - test('passes', async ({ page }, testInfo) => {}); - `, - }, { reporter: 'dot,' + kRawReporterPath }); - const json = JSON.parse(fs.readFileSync(testInfo.outputPath('output', 'report', 'project-name.report'), 'utf-8')); - expect(json.project.name).toBe('project-name'); - expect(result.exitCode).toBe(0); -}); - -test('should save stdio', async ({ runInlineTest }, testInfo) => { - await runInlineTest({ - 'a.test.js': ` - import { test, expect } from '@playwright/test'; - test('passes', async ({ page }, testInfo) => { - console.log('STDOUT'); - process.stdout.write(Buffer.from([1, 2, 3])); - console.error('STDERR'); - process.stderr.write(Buffer.from([4, 5, 6])); - }); - `, - }, { reporter: 'dot,' + kRawReporterPath }); - const json = JSON.parse(fs.readFileSync(testInfo.outputPath('test-results', 'report', 'project.report'), 'utf-8')); - const result = json.suites[0].tests[0].results[0]; - expect(result.attachments).toEqual([ - { name: 'stdout', contentType: 'text/plain', body: 'STDOUT\n' }, - { - name: 'stdout', - contentType: 'application/octet-stream', - body: { data: [1, 2, 3], type: 'Buffer' } - }, - { name: 'stderr', contentType: 'text/plain', body: 'STDERR\n' }, - { - name: 'stderr', - contentType: 'application/octet-stream', - body: { data: [4, 5, 6], type: 'Buffer' } - } - ]); -}); - -test('should save attachments', async ({ runInlineTest }, testInfo) => { - await runInlineTest({ - 'a.test.js': ` - import { test, expect } from '@playwright/test'; - test('passes', async ({ page }, testInfo) => { - testInfo.attachments.push({ - name: 'binary', - contentType: 'application/octet-stream', - body: Buffer.from([1,2,3]) - }); - testInfo.attachments.push({ - name: 'text', - contentType: 'text/plain', - path: 'dummy-path' - }); - }); - `, - }, { reporter: 'dot,' + kRawReporterPath }); - const json = JSON.parse(fs.readFileSync(testInfo.outputPath('test-results', 'report', 'project.report'), 'utf-8')); - const result = json.suites[0].tests[0].results[0]; - expect(result.attachments[0].name).toBe('binary'); - expect(Buffer.from(result.attachments[0].body, 'base64')).toEqual(Buffer.from([1, 2, 3])); - expect(result.attachments[1].name).toBe('text'); - const path2 = result.attachments[1].path; - expect(path2).toBe('dummy-path'); -}); - -test(`testInfo.attach should save attachments via path`, async ({ runInlineTest }, testInfo) => { - await runInlineTest({ - 'a.test.js': ` - const path = require('path'); - const fs = require('fs'); - import { test, expect } from '@playwright/test'; - test('infer contentType from path', async ({}, testInfo) => { - const tmpPath = testInfo.outputPath('example.json'); - await fs.promises.writeFile(tmpPath, 'We <3 Playwright!'); - await testInfo.attach('foo', { path: tmpPath }); - // Forcibly remove the tmp file to ensure attach is actually automagically copying it - await fs.promises.unlink(tmpPath); - }); - - test('explicit contentType (over extension)', async ({}, testInfo) => { - const tmpPath = testInfo.outputPath('example.json'); - await fs.promises.writeFile(tmpPath, 'We <3 Playwright!'); - await testInfo.attach('foo', { path: tmpPath, contentType: 'image/png' }); - // Forcibly remove the tmp file to ensure attach is actually automagically copying it - await fs.promises.unlink(tmpPath); - }); - - test('explicit contentType (over extension and name)', async ({}, testInfo) => { - const tmpPath = testInfo.outputPath('example.json'); - await fs.promises.writeFile(tmpPath, 'We <3 Playwright!'); - await testInfo.attach('example.png', { path: tmpPath, contentType: 'x-playwright/custom' }); - // Forcibly remove the tmp file to ensure attach is actually automagically copying it - await fs.promises.unlink(tmpPath); - }); - - test('fallback contentType', async ({}, testInfo) => { - const tmpPath = testInfo.outputPath('example.this-extension-better-not-map-to-an-actual-mimetype'); - await fs.promises.writeFile(tmpPath, 'We <3 Playwright!'); - await testInfo.attach('foo', { path: tmpPath }); - // Forcibly remove the tmp file to ensure attach is actually automagically copying it - await fs.promises.unlink(tmpPath); - }); - `, - }, { reporter: 'dot,' + kRawReporterPath, workers: 1 }); - const json = JSON.parse(fs.readFileSync(testInfo.outputPath('test-results', 'report', 'project.report'), 'utf-8')); - { - const result = json.suites[0].tests[0].results[0]; - expect(result.attachments[0].name).toBe('foo'); - expect(result.attachments[0].contentType).toBe('application/json'); - const p = result.attachments[0].path; - expect(p).toMatch(/[/\\]attachments[/\\]foo-[0-9a-f]+\.json$/); - const contents = fs.readFileSync(p); - expect(contents.toString()).toBe('We <3 Playwright!'); - } - { - const result = json.suites[0].tests[1].results[0]; - expect(result.attachments[0].name).toBe('foo'); - expect(result.attachments[0].contentType).toBe('image/png'); - const p = result.attachments[0].path; - expect(p).toMatch(/[/\\]attachments[/\\]foo-[0-9a-f]+\.json$/); - const contents = fs.readFileSync(p); - expect(contents.toString()).toBe('We <3 Playwright!'); - } - { - const result = json.suites[0].tests[2].results[0]; - expect(result.attachments[0].name).toBe('example.png'); - expect(result.attachments[0].contentType).toBe('x-playwright/custom'); - const p = result.attachments[0].path; - expect(p).toMatch(/[/\\]attachments[/\\]example-png-[0-9a-f]+\.json$/); - const contents = fs.readFileSync(p); - expect(contents.toString()).toBe('We <3 Playwright!'); - } - { - const result = json.suites[0].tests[3].results[0]; - expect(result.attachments[0].name).toBe('foo'); - expect(result.attachments[0].contentType).toBe('application/octet-stream'); - const p = result.attachments[0].path; - expect(p).toMatch(/[/\\]attachments[/\\]foo-[0-9a-f]+\.this-extension-better-not-map-to-an-actual-mimetype$/); - const contents = fs.readFileSync(p); - expect(contents.toString()).toBe('We <3 Playwright!'); - } -}); - -test(`testInfo.attach should save attachments via inline attachment`, async ({ runInlineTest }, testInfo) => { - await runInlineTest({ - 'a.test.js': ` - const path = require('path'); - const fs = require('fs'); - import { test, expect } from '@playwright/test'; - test('default contentType - string', async ({}, testInfo) => { - await testInfo.attach('example.json', { body: 'We <3 Playwright!' }); - }); - - test('default contentType - Buffer', async ({}, testInfo) => { - await testInfo.attach('example.json', { body: Buffer.from('We <3 Playwright!') }); - }); - - test('explicit contentType - string', async ({}, testInfo) => { - await testInfo.attach('example.json', { body: 'We <3 Playwright!', contentType: 'x-playwright/custom' }); - }); - - test('explicit contentType - Buffer', async ({}, testInfo) => { - await testInfo.attach('example.json', { body: Buffer.from('We <3 Playwright!'), contentType: 'x-playwright/custom' }); - }); - `, - }, { reporter: 'dot,' + kRawReporterPath, workers: 1 }); - const json = JSON.parse(fs.readFileSync(testInfo.outputPath('test-results', 'report', 'project.report'), 'utf-8')); - { - const result = json.suites[0].tests[0].results[0]; - expect(result.attachments[0].name).toBe('example.json'); - expect(result.attachments[0].contentType).toBe('text/plain'); - expect(Buffer.from(result.attachments[0].body, 'base64')).toEqual(Buffer.from('We <3 Playwright!')); - } - { - const result = json.suites[0].tests[1].results[0]; - expect(result.attachments[0].name).toBe('example.json'); - expect(result.attachments[0].contentType).toBe('application/octet-stream'); - expect(Buffer.from(result.attachments[0].body, 'base64')).toEqual(Buffer.from('We <3 Playwright!')); - } - { - const result = json.suites[0].tests[2].results[0]; - expect(result.attachments[0].name).toBe('example.json'); - expect(result.attachments[0].contentType).toBe('x-playwright/custom'); - expect(Buffer.from(result.attachments[0].body, 'base64')).toEqual(Buffer.from('We <3 Playwright!')); - } - { - const result = json.suites[0].tests[3].results[0]; - expect(result.attachments[0].name).toBe('example.json'); - expect(result.attachments[0].contentType).toBe('x-playwright/custom'); - expect(Buffer.from(result.attachments[0].body, 'base64')).toEqual(Buffer.from('We <3 Playwright!')); - } -}); - -test('dupe project names', async ({ runInlineTest }, testInfo) => { - await runInlineTest({ - 'playwright.config.ts': ` - module.exports = { - projects: [ - { name: 'project-name' }, - { name: 'project-name' }, - { name: 'project-name' }, - ] - } - `, - 'a.test.js': ` - import { test, expect } from '@playwright/test'; - test('passes', async ({ page }, testInfo) => {}); - `, - }, { reporter: 'dot,' + kRawReporterPath }); - const files = fs.readdirSync(testInfo.outputPath('test-results', 'report')); - expect(new Set(files)).toEqual(new Set(['project-name.report', 'project-name-1.report', 'project-name-2.report'])); -});