diff --git a/packages/playwright-test/src/reporters/blob.ts b/packages/playwright-test/src/reporters/blob.ts index c911e96c6b..8269a93695 100644 --- a/packages/playwright-test/src/reporters/blob.ts +++ b/packages/playwright-test/src/reporters/blob.ts @@ -14,12 +14,10 @@ * limitations under the License. */ -import type { EventEmitter } from 'events'; import fs from 'fs'; import path from 'path'; -import { ManualPromise, calculateSha1 } from 'playwright-core/lib/utils'; +import { calculateSha1, createGuid } from 'playwright-core/lib/utils'; import { mime } from 'playwright-core/lib/utilsBundle'; -import { yazl } from 'playwright-core/lib/zipBundle'; import { Readable } from 'stream'; import type { FullConfig, FullResult, TestResult } from '../../types/testReporter'; import type { Suite } from '../common/test'; @@ -34,9 +32,10 @@ type BlobReporterOptions = { export class BlobReporter extends TeleReporterEmitter { private _messages: any[] = []; private _options: BlobReporterOptions; + private _copyFilePromises = new Set>(); - private readonly _zipFile = new yazl.ZipFile(); - private readonly _zipFinishPromise = new ManualPromise(); + private _outputDir!: string; + private _reportFile!: string; constructor(options: BlobReporterOptions) { super(message => this._messages.push(message)); @@ -44,17 +43,21 @@ export class BlobReporter extends TeleReporterEmitter { } override onBegin(config: FullConfig<{}, {}>, suite: Suite): void { + this._outputDir = path.resolve(this._options.configDir, this._options.outputDir || 'blob-report'); + fs.mkdirSync(path.join(this._outputDir, 'resources'), { recursive: true }); + this._reportFile = this._computeOutputFileName(config); super.onBegin(config, suite); - this._initializeZipFile(config); } override async onEnd(result: FullResult): Promise { await super.onEnd(result); const lines = this._messages.map(m => JSON.stringify(m) + '\n'); const content = Readable.from(lines); - this._zipFile.addReadStream(content, 'report.jsonl'); - this._zipFile.end(); - await this._zipFinishPromise; + await Promise.all([ + ...this._copyFilePromises, + // Requires Node v14.18.0+ + fs.promises.writeFile(this._reportFile, content as any).catch(e => console.error(`Failed to write report ${this._reportFile}: ${e}`)) + ]); } override _serializeAttachments(attachments: TestResult['attachments']): TestResult['attachments'] { @@ -64,7 +67,7 @@ export class BlobReporter extends TeleReporterEmitter { const sha1 = calculateSha1(attachment.path); const extension = mime.getExtension(attachment.contentType) || 'dat'; const newPath = `resources/${sha1}.${extension}`; - this._zipFile.addFile(attachment.path, newPath); + this._startCopyingFile(attachment.path, path.join(this._outputDir, newPath)); return { ...attachment, path: newPath, @@ -72,22 +75,19 @@ export class BlobReporter extends TeleReporterEmitter { }); } - private _initializeZipFile(config: FullConfig) { - (this._zipFile as any as EventEmitter).on('error', error => this._zipFinishPromise.reject(error)); - const zipFileName = this._computeOutputFileName(config); - fs.mkdirSync(path.dirname(zipFileName), { recursive: true }); - this._zipFile.outputStream.pipe(fs.createWriteStream(zipFileName)).on('close', () => { - this._zipFinishPromise.resolve(undefined); - }); - } - private _computeOutputFileName(config: FullConfig) { - const outputDir = path.resolve(this._options.configDir, this._options.outputDir || ''); let shardSuffix = ''; if (config.shard) { const paddedNumber = `${config.shard.current}`.padStart(`${config.shard.total}`.length, '0'); - shardSuffix = `-${paddedNumber}-of-${config.shard.total}`; + shardSuffix = `${paddedNumber}-of-${config.shard.total}-`; } - return path.join(outputDir, `report${shardSuffix}.zip`); + return path.join(this._outputDir, `report-${shardSuffix}${createGuid()}.jsonl`); + } + + private _startCopyingFile(from: string, to: string) { + const copyPromise: Promise = fs.promises.copyFile(from, to) + .catch(e => { console.error(`Failed to copy file from "${from}" to "${to}": ${e}`); }) + .then(() => { this._copyFilePromises.delete(copyPromise); }); + this._copyFilePromises.add(copyPromise); } } diff --git a/packages/playwright-test/src/reporters/merge.ts b/packages/playwright-test/src/reporters/merge.ts index a6da6a7c98..56c1d256bf 100644 --- a/packages/playwright-test/src/reporters/merge.ts +++ b/packages/playwright-test/src/reporters/merge.ts @@ -15,9 +15,7 @@ */ import fs from 'fs'; -import os from 'os'; import path from 'path'; -import { ZipFile, removeFolders } from 'playwright-core/lib/utils'; import type { ReporterDescription } from '../../types/test'; import type { FullResult } from '../../types/testReporter'; import type { FullConfigInternal } from '../common/config'; @@ -27,39 +25,13 @@ import { Multiplexer } from './multiplexer'; export async function createMergedReport(config: FullConfigInternal, dir: string, reporterDescriptions: ReporterDescription[]) { const shardFiles = await sortedShardFiles(dir); - const resourceDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-report-')); - await fs.promises.mkdir(resourceDir, { recursive: true }); - try { - const shardReports = await extractReports(dir, shardFiles, resourceDir); - const events = mergeEvents(shardReports); - patchAttachmentPaths(events, resourceDir); + const events = await mergeEvents(dir, shardFiles); + patchAttachmentPaths(events, dir); - const reporters = await createReporters(config, 'merge', reporterDescriptions); - const receiver = new TeleReporterReceiver(path.sep, new Multiplexer(reporters)); - for (const event of events) - await receiver.dispatch(event); - } finally { - await removeFolders([resourceDir]); - } -} - -async function extractReports(dir: string, shardFiles: string[], resourceDir: string): Promise { - const reports = []; - for (const file of shardFiles) { - const zipFile = new ZipFile(path.join(dir, file)); - const entryNames = await zipFile.entries(); - for (const entryName of entryNames) { - const content = await zipFile.read(entryName); - if (entryName.endsWith('report.jsonl')) { - reports.push(content.toString()); - } else { - const fileName = path.join(resourceDir, entryName); - await fs.promises.mkdir(path.dirname(fileName), { recursive: true }); - await fs.promises.writeFile(fileName, content); - } - } - } - return reports; + const reporters = await createReporters(config, 'merge', reporterDescriptions); + const receiver = new TeleReporterReceiver(path.sep, new Multiplexer(reporters)); + for (const event of events) + await receiver.dispatch(event); } function patchAttachmentPaths(events: JsonEvent[], resourceDir: string) { @@ -79,11 +51,12 @@ function parseEvents(reportJsonl: string): JsonEvent[] { return reportJsonl.toString().split('\n').filter(line => line.length).map(line => JSON.parse(line)) as JsonEvent[]; } -function mergeEvents(shardReports: string[]) { +async function mergeEvents(dir: string, shardReportFiles: string[]) { const events: JsonEvent[] = []; const beginEvents: JsonEvent[] = []; const endEvents: JsonEvent[] = []; - for (const reportJsonl of shardReports) { + for (const reportFile of shardReportFiles) { + const reportJsonl = await fs.promises.readFile(path.join(dir, reportFile), 'utf8'); const parsedEvents = parseEvents(reportJsonl); for (const event of parsedEvents) { if (event.method === 'onBegin') @@ -160,5 +133,5 @@ function mergeEndEvents(endEvents: JsonEvent[]): JsonEvent { async function sortedShardFiles(dir: string) { const files = await fs.promises.readdir(dir); - return files.filter(file => file.endsWith('.zip')).sort(); + return files.filter(file => file.endsWith('.jsonl')).sort(); } diff --git a/tests/playwright-test/reporter-blob.spec.ts b/tests/playwright-test/reporter-blob.spec.ts index 750614f133..2a473e52a1 100644 --- a/tests/playwright-test/reporter-blob.spec.ts +++ b/tests/playwright-test/reporter-blob.spec.ts @@ -91,7 +91,7 @@ class EchoReporter { module.exports = EchoReporter; `; -test('should call methods in right order', async ({ runInlineTest, mergeReports, showReport, page }) => { +test('should call methods in right order', async ({ runInlineTest, mergeReports }) => { test.slow(); const reportDir = test.info().outputPath('blob-report'); const files = { @@ -140,7 +140,7 @@ test('should call methods in right order', async ({ runInlineTest, mergeReports, await runInlineTest(files, { shard: `3/3` }); const reportFiles = await fs.promises.readdir(reportDir); reportFiles.sort(); - expect(reportFiles).toEqual(['report-1-of-3.zip', 'report-3-of-3.zip']); + expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-3.*.jsonl/), expect.stringMatching(/report-3-of-3.*.jsonl/), 'resources']); const { exitCode, output } = await mergeReports(reportDir, {}, { additionalArgs: ['--reporter', test.info().outputPath('echo-reporter.js')] }); expect(exitCode).toBe(0); const lines = output.split('\n').filter(l => l.trim().length); @@ -205,7 +205,7 @@ test('should merge into html', async ({ runInlineTest, mergeReports, showReport, await runInlineTest(files, { shard: `${i + 1}/${totalShards}` }); const reportFiles = await fs.promises.readdir(reportDir); reportFiles.sort(); - expect(reportFiles).toEqual(['report-1-of-3.zip', 'report-2-of-3.zip', 'report-3-of-3.zip']); + expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-3.*.jsonl/), expect.stringMatching(/report-2-of-3.*.jsonl/), expect.stringMatching(/report-3-of-3.*.jsonl/), 'resources']); const { exitCode, output } = await mergeReports(reportDir, { 'PW_TEST_HTML_REPORT_OPEN': 'never' }, { additionalArgs: ['--reporter', 'html'] }); expect(exitCode).toBe(0); @@ -266,7 +266,7 @@ test('be able to merge incomplete shards', async ({ runInlineTest, mergeReports, const reportFiles = await fs.promises.readdir(reportDir); reportFiles.sort(); - expect(reportFiles).toEqual(['report-1-of-3.zip', 'report-3-of-3.zip']); + expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-3.*.jsonl/), expect.stringMatching(/report-3-of-3.*.jsonl/), 'resources']); const { exitCode } = await mergeReports(reportDir, { 'PW_TEST_HTML_REPORT_OPEN': 'never' }, { additionalArgs: ['--reporter', 'html'] }); expect(exitCode).toBe(0); @@ -328,7 +328,7 @@ test('merge into list report by default', async ({ runInlineTest, mergeReports } await runInlineTest(files, { shard: `${i + 1}/${totalShards}` }); const reportFiles = await fs.promises.readdir(reportDir); reportFiles.sort(); - expect(reportFiles).toEqual(['report-1-of-3.zip', 'report-2-of-3.zip', 'report-3-of-3.zip']); + expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-3.*.jsonl/), expect.stringMatching(/report-2-of-3.*.jsonl/), expect.stringMatching(/report-3-of-3.*.jsonl/), 'resources']); const { exitCode, output } = await mergeReports(reportDir, { PW_TEST_DEBUG_REPORTERS: '1', PW_TEST_DEBUG_REPORTERS_PRINT_STEPS: '1', PWTEST_TTY_WIDTH: '80' }, { additionalArgs: ['--reporter', 'list'] }); expect(exitCode).toBe(0); @@ -400,7 +400,7 @@ test('preserve attachments', async ({ runInlineTest, mergeReports, showReport, p const reportFiles = await fs.promises.readdir(reportDir); reportFiles.sort(); - expect(reportFiles).toEqual(['report-1-of-2.zip']); + expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-2.*.jsonl/), 'resources']); const { exitCode } = await mergeReports(reportDir, { 'PW_TEST_HTML_REPORT_OPEN': 'never' }, { additionalArgs: ['--reporter', 'html'] }); expect(exitCode).toBe(0); @@ -454,7 +454,7 @@ test('multiple output reports', async ({ runInlineTest, mergeReports, showReport const reportFiles = await fs.promises.readdir(reportDir); reportFiles.sort(); - expect(reportFiles).toEqual(['report-1-of-2.zip']); + expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-2.*.jsonl/), 'resources']); const { exitCode, output } = await mergeReports(reportDir, { 'PW_TEST_HTML_REPORT_OPEN': 'never', 'PW_TEST_DEBUG_REPORTERS': '1' }, { additionalArgs: ['--reporter', 'html,line'] }); expect(exitCode).toBe(0); @@ -516,7 +516,7 @@ test('multiple output reports based on config', async ({ runInlineTest, mergeRep const reportFiles = await fs.promises.readdir(reportDir); reportFiles.sort(); - expect(reportFiles).toEqual(['report-1-of-2.zip', 'report-2-of-2.zip']); + expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-2.*.jsonl/), expect.stringMatching(/report-2-of-2.*.jsonl/), 'resources']); const { exitCode, output } = await mergeReports(reportDir, { 'PW_TEST_DEBUG_REPORTERS': '1' }, { additionalArgs: ['--config', test.info().outputPath('merged/playwright.config.ts')] }); expect(exitCode).toBe(0); @@ -530,8 +530,8 @@ test('multiple output reports based on config', async ({ runInlineTest, mergeRep expect((await fs.promises.stat(test.info().outputPath('merged/html/index.html'))).isFile).toBeTruthy(); // Check report presence. - expect((await fs.promises.stat(test.info().outputPath('merged/merged-blob/report.zip'))).isFile).toBeTruthy(); - + const mergedBlobReportFiles = await fs.promises.readdir(test.info().outputPath('merged/merged-blob')); + expect(mergedBlobReportFiles).toEqual([expect.stringMatching(/report.*.jsonl/), 'resources']); }); test('onError in the report', async ({ runInlineTest, mergeReports, showReport, page }) => {