/** * 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 { ManualPromise, calculateSha1, createGuid, removeFolders } 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 { JsonAttachment, JsonEvent } from '../isomorphic/teleReceiver'; import { TeleReporterEmitter } from './teleEmitter'; import { yazl } from 'playwright-core/lib/zipBundle'; import { resolveReporterOutputPath } from '../util'; type BlobReporterOptions = { configDir: string; outputDir?: string; }; export type BlobReportMetadata = { projectSuffix?: string; shard?: { total: number, current: number }; }; export class BlobReporter extends TeleReporterEmitter { private _messages: JsonEvent[] = []; private _options: BlobReporterOptions; private _salt: string; private _copyFilePromises = new Set>(); private _outputDir!: Promise; private _reportName!: string; constructor(options: BlobReporterOptions) { super(message => this._messages.push(message), false); this._options = options; this._salt = createGuid(); } override onConfigure(config: FullConfig) { const outputDir = resolveReporterOutputPath('blob-report', this._options.configDir, this._options.outputDir); const removePromise = process.env.PWTEST_BLOB_DO_NOT_REMOVE ? Promise.resolve() : removeFolders([outputDir]); this._outputDir = removePromise.then(() => fs.promises.mkdir(path.join(outputDir, 'resources'), { recursive: true })).then(() => outputDir); this._reportName = `report-${createGuid()}`; const metadata: BlobReportMetadata = { projectSuffix: process.env.PWTEST_BLOB_SUFFIX, shard: config.shard ? config.shard : undefined, }; this._messages.push({ method: 'onBlobReportMetadata', params: metadata }); super.onConfigure(config); } override async onEnd(result: FullResult): Promise { await super.onEnd(result); const outputDir = await this._outputDir; 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(outputDir, this._reportName + '.zip'); zipFile.outputStream.pipe(fs.createWriteStream(zipFileName)).on('close', () => { zipFinishPromise.resolve(undefined); }).on('error', error => zipFinishPromise.reject(error)); zipFile.addReadStream(content, this._reportName + '.jsonl'); zipFile.end(); await Promise.all([ ...this._copyFilePromises, // Requires Node v14.18.0+ zipFinishPromise.catch(e => { throw new Error(`Failed to write report ${zipFileName}: ` + e.message); }), ]); } override _serializeAttachments(attachments: TestResult['attachments']): JsonAttachment[] { return super._serializeAttachments(attachments).map(attachment => { if (!attachment.path || !fs.statSync(attachment.path, { throwIfNoEntry: false })?.isFile()) return attachment; // Add run guid to avoid clashes between shards. const sha1 = calculateSha1(attachment.path + this._salt); const extension = mime.getExtension(attachment.contentType) || 'dat'; const newPath = `resources/${sha1}.${extension}`; this._startCopyingFile(attachment.path, newPath); return { ...attachment, path: newPath, }; }); } private _startCopyingFile(from: string, to: string) { const copyPromise: Promise = this._outputDir .then(dir => fs.promises.copyFile(from, path.join(dir, to))) .catch(e => { console.error(`Failed to copy file from "${from}" to "${to}": ${e}`); }) .then(() => { this._copyFilePromises.delete(copyPromise); }); this._copyFilePromises.add(copyPromise); } }