diff --git a/packages/playwright-test/src/reporters/blob.ts b/packages/playwright-test/src/reporters/blob.ts index 115d8008d8..14ff77c071 100644 --- a/packages/playwright-test/src/reporters/blob.ts +++ b/packages/playwright-test/src/reporters/blob.ts @@ -16,14 +16,15 @@ import fs from 'fs'; import path from 'path'; -import { calculateSha1, createGuid } from 'playwright-core/lib/utils'; +import { ManualPromise, calculateSha1, createGuid } from 'playwright-core/lib/utils'; import { mime } from 'playwright-core/lib/utilsBundle'; import { Readable } from 'stream'; +import type { EventEmitter } from 'events'; import type { FullConfig, FullResult, TestResult } from '../../types/testReporter'; import type { Suite } from '../common/test'; import type { JsonAttachment, JsonEvent } from '../isomorphic/teleReceiver'; import { TeleReporterEmitter } from './teleEmitter'; - +import { yazl } from 'playwright-core/lib/zipBundle'; type BlobReporterOptions = { configDir: string; @@ -41,7 +42,7 @@ export class BlobReporter extends TeleReporterEmitter { private _copyFilePromises = new Set>(); private _outputDir!: string; - private _reportFile!: string; + private _reportName!: string; constructor(options: BlobReporterOptions) { super(message => this._messages.push(message), false); @@ -63,7 +64,7 @@ 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); + this._reportName = this._computeReportName(config); super.onBegin(config, suite); } @@ -71,10 +72,21 @@ export class BlobReporter extends TeleReporterEmitter { await super.onEnd(result); const lines = this._messages.map(m => JSON.stringify(m) + '\n'); const content = Readable.from(lines); + + const zipFile = new yazl.ZipFile(); + const zipFinishPromise = new ManualPromise(); + (zipFile as any as EventEmitter).on('error', error => zipFinishPromise.reject(error)); + const zipFileName = path.join(this._outputDir, this._reportName + '.zip'); + zipFile.outputStream.pipe(fs.createWriteStream(zipFileName)).on('close', () => { + zipFinishPromise.resolve(undefined); + }); + zipFile.addReadStream(content, this._reportName + '.jsonl'); + zipFile.end(); + 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}`)) + zipFinishPromise.catch(e => console.error(`Failed to write report ${zipFileName}: ${e}`)) ]); } @@ -94,13 +106,13 @@ export class BlobReporter extends TeleReporterEmitter { }); } - private _computeOutputFileName(config: FullConfig) { + private _computeReportName(config: FullConfig) { let shardSuffix = ''; if (config.shard) { const paddedNumber = `${config.shard.current}`.padStart(`${config.shard.total}`.length, '0'); shardSuffix = `${paddedNumber}-of-${config.shard.total}-`; } - return path.join(this._outputDir, `report-${shardSuffix}${createGuid()}.jsonl`); + return `report-${shardSuffix}${createGuid()}`; } private _startCopyingFile(from: string, to: string) { diff --git a/packages/playwright-test/src/reporters/merge.ts b/packages/playwright-test/src/reporters/merge.ts index 7e49611654..bb9bf7ece3 100644 --- a/packages/playwright-test/src/reporters/merge.ts +++ b/packages/playwright-test/src/reporters/merge.ts @@ -23,6 +23,7 @@ import type { JsonConfig, JsonEvent, JsonProject, JsonSuite, JsonTestResultEnd } import { TeleReporterReceiver } from '../isomorphic/teleReceiver'; import { createReporters } from '../runner/reporters'; import { Multiplexer } from './multiplexer'; +import { ZipFile } from 'playwright-core/lib/utils'; export async function createMergedReport(config: FullConfigInternal, dir: string, reporterDescriptions: ReporterDescription[], resolvePaths: boolean) { const shardFiles = await sortedShardFiles(dir); @@ -52,16 +53,30 @@ function patchAttachmentPaths(events: JsonEvent[], resourceDir: string) { } } -function parseEvents(reportJsonl: string): JsonEvent[] { +function parseEvents(reportJsonl: Buffer): JsonEvent[] { return reportJsonl.toString().split('\n').filter(line => line.length).map(line => JSON.parse(line)) as JsonEvent[]; } +async function extractReportFromZip(file: string): Promise { + const zipFile = new ZipFile(file); + const entryNames = await zipFile.entries(); + try { + for (const entryName of entryNames) { + if (entryName.endsWith('.jsonl')) + return await zipFile.read(entryName); + } + } finally { + zipFile.close(); + } + throw new Error(`Cannot find *.jsonl file in ${file}`); +} + async function mergeEvents(dir: string, shardReportFiles: string[]) { const events: JsonEvent[] = []; const beginEvents: JsonEvent[] = []; const endEvents: JsonEvent[] = []; for (const reportFile of shardReportFiles) { - const reportJsonl = await fs.promises.readFile(path.join(dir, reportFile), 'utf8'); + const reportJsonl = await extractReportFromZip(path.join(dir, reportFile)); const parsedEvents = parseEvents(reportJsonl); for (const event of parsedEvents) { if (event.method === 'onBegin') @@ -159,7 +174,7 @@ function mergeEndEvents(endEvents: JsonEvent[]): JsonEvent { async function sortedShardFiles(dir: string) { const files = await fs.promises.readdir(dir); - return files.filter(file => file.endsWith('.jsonl')).sort(); + return files.filter(file => file.startsWith('report-') && file.endsWith('.zip')).sort(); } class ProjectNamePatcher { diff --git a/tests/playwright-test/reporter-blob.spec.ts b/tests/playwright-test/reporter-blob.spec.ts index 19934913ad..1ad51d1d3c 100644 --- a/tests/playwright-test/reporter-blob.spec.ts +++ b/tests/playwright-test/reporter-blob.spec.ts @@ -148,7 +148,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([expect.stringMatching(/report-1-of-3.*.jsonl/), expect.stringMatching(/report-3-of-3.*.jsonl/), 'resources']); + expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-3.*.zip/), expect.stringMatching(/report-3-of-3.*.zip/), '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); @@ -212,7 +212,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([expect.stringMatching(/report-1-of-3.*.jsonl/), expect.stringMatching(/report-2-of-3.*.jsonl/), expect.stringMatching(/report-3-of-3.*.jsonl/), 'resources']); + expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-3.*.zip/), expect.stringMatching(/report-2-of-3.*.zip/), expect.stringMatching(/report-3-of-3.*.zip/), 'resources']); const { exitCode, output } = await mergeReports(reportDir, { 'PW_TEST_HTML_REPORT_OPEN': 'never' }, { additionalArgs: ['--reporter', 'html'] }); expect(exitCode).toBe(0); @@ -272,7 +272,7 @@ test('be able to merge incomplete shards', async ({ runInlineTest, mergeReports, const reportFiles = await fs.promises.readdir(reportDir); reportFiles.sort(); - expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-3.*.jsonl/), expect.stringMatching(/report-3-of-3.*.jsonl/), 'resources']); + expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-3.*.zip/), expect.stringMatching(/report-3-of-3.*.zip/), 'resources']); const { exitCode } = await mergeReports(reportDir, { 'PW_TEST_HTML_REPORT_OPEN': 'never' }, { additionalArgs: ['--reporter', 'html'] }); expect(exitCode).toBe(0); @@ -378,7 +378,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([expect.stringMatching(/report-1-of-3.*.jsonl/), expect.stringMatching(/report-2-of-3.*.jsonl/), expect.stringMatching(/report-3-of-3.*.jsonl/), 'resources']); + expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-3.*.zip/), expect.stringMatching(/report-2-of-3.*.zip/), expect.stringMatching(/report-3-of-3.*.zip/), '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); @@ -449,7 +449,7 @@ test('preserve attachments', async ({ runInlineTest, mergeReports, showReport, p const reportFiles = await fs.promises.readdir(reportDir); reportFiles.sort(); - expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-2.*.jsonl/), 'resources']); + expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-2.*.zip/), 'resources']); const { exitCode } = await mergeReports(reportDir, { 'PW_TEST_HTML_REPORT_OPEN': 'never' }, { additionalArgs: ['--reporter', 'html'] }); expect(exitCode).toBe(0); @@ -512,7 +512,7 @@ test('generate html with attachment urls', async ({ runInlineTest, mergeReports, const reportFiles = await fs.promises.readdir(reportDir); reportFiles.sort(); - expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-2.*.jsonl/), 'resources']); + expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-2.*.zip/), 'resources']); const { exitCode } = await mergeReports(reportDir, { 'PW_TEST_HTML_REPORT_OPEN': 'never' }, { additionalArgs: ['--reporter', 'html', '--attachments', 'missing'] }); expect(exitCode).toBe(0); @@ -586,7 +586,7 @@ test('resource names should not clash between runs', async ({ runInlineTest, sho const reportFiles = await fs.promises.readdir(reportDir); reportFiles.sort(); - expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-2.*.jsonl/), expect.stringMatching(/report-2-of-2.*.jsonl/), 'resources']); + expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-2.*.zip/), expect.stringMatching(/report-2-of-2.*.zip/), 'resources']); const { exitCode } = await mergeReports(reportDir, {}, { additionalArgs: ['--reporter', 'html'] }); expect(exitCode).toBe(0); @@ -661,7 +661,7 @@ test('multiple output reports', async ({ runInlineTest, mergeReports, showReport const reportFiles = await fs.promises.readdir(reportDir); reportFiles.sort(); - expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-2.*.jsonl/), 'resources']); + expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-2.*.zip/), '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); @@ -722,7 +722,7 @@ test('multiple output reports based on config', async ({ runInlineTest, mergeRep const reportFiles = await fs.promises.readdir(reportDir); reportFiles.sort(); - expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-2.*.jsonl/), expect.stringMatching(/report-2-of-2.*.jsonl/), 'resources']); + expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-2.*.zip/), expect.stringMatching(/report-2-of-2.*.zip/), '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); @@ -737,7 +737,7 @@ test('multiple output reports based on config', async ({ runInlineTest, mergeRep // Check report presence. const mergedBlobReportFiles = await fs.promises.readdir(test.info().outputPath('merged/merged-blob')); - expect(mergedBlobReportFiles).toEqual([expect.stringMatching(/report.*.jsonl/), 'resources']); + expect(mergedBlobReportFiles).toEqual([expect.stringMatching(/report.*.zip/), 'resources']); }); test('onError in the report', async ({ runInlineTest, mergeReports, showReport, page }) => { @@ -868,7 +868,7 @@ test('preserve config fields', async ({ runInlineTest, mergeReports }) => { const reportFiles = await fs.promises.readdir(reportDir); reportFiles.sort(); - expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-3.*.jsonl/), expect.stringMatching(/report-3-of-3.*.jsonl/), 'resources']); + expect(reportFiles).toEqual([expect.stringMatching(/report-1-of-3.*.zip/), expect.stringMatching(/report-3-of-3.*.zip/), 'resources']); const { exitCode } = await mergeReports(reportDir, {}, { additionalArgs: ['--reporter', test.info().outputPath('echo-reporter.js'), '-c', test.info().outputPath('merge.config.ts')] }); expect(exitCode).toBe(0); const json = JSON.parse(fs.readFileSync(test.info().outputPath('config.json')).toString()); @@ -1025,7 +1025,7 @@ test('preserve steps in html report', async ({ runInlineTest, mergeReports, show await runInlineTest(files); const reportFiles = await fs.promises.readdir(reportDir); reportFiles.sort(); - expect(reportFiles).toEqual([expect.stringMatching(/report-.*.jsonl/), 'resources']); + expect(reportFiles).toEqual([expect.stringMatching(/report-.*.zip/), 'resources']); // Run merger in a different directory to make sure relative paths will not be resolved // relative to the current directory. const mergeCwd = test.info().outputPath('foo');