diff --git a/packages/html-reporter/index.html b/packages/html-reporter/index.html index 054507220c9e8..fa2de9b3afa76 100644 --- a/packages/html-reporter/index.html +++ b/packages/html-reporter/index.html @@ -25,5 +25,12 @@
+ +

The Playwright Test Report must be loaded over the http:// or https:// protocols.

+
+ diff --git a/packages/html-reporter/src/index.tsx b/packages/html-reporter/src/index.tsx index 33f1ca3f5047c..cfaa434135f84 100644 --- a/packages/html-reporter/src/index.tsx +++ b/packages/html-reporter/src/index.tsx @@ -23,6 +23,7 @@ import * as ReactDOM from 'react-dom'; import './colors.css'; import type { LoadedReport } from './loadedReport'; import { ReportView } from './reportView'; +import { mergeReports } from './mergeReports'; // @ts-ignore const zipjs = zipImport as typeof zip; @@ -46,10 +47,27 @@ class ZipReport implements LoadedReport { private _json!: HTMLReport; async load() { - const zipReader = new zipjs.ZipReader(new zipjs.Data64URIReader((window as any).playwrightReportBase64), { useWebWorkers: false }) as zip.ZipReader; - for (const entry of await zipReader.getEntries()) - this._entries.set(entry.filename, entry); - this._json = await this.entry('report.json') as HTMLReport; + const metadata = (window as any).playwrightMetadata; + if (metadata.shard) { + const reports: HTMLReport[] = []; + for (let index = 1; index <= metadata.shard.total; index += 1) { + try { + const zipReader = new zipjs.ZipReader(new zipjs.HttpReader(`/report/report-${index}.zip`), { useWebWorkers: false }) as zip.ZipReader; + for (const entry of await zipReader.getEntries()) + this._entries.set(entry.filename, entry); + const currentJson = await this.entry(`report-${index}.json`) as HTMLReport; + reports.push(currentJson); + } catch (error) { + // Ignore not found error for viewing individual shard report. + } + } + this._json = mergeReports(reports); + } else { + const zipReader = new zipjs.ZipReader(new zipjs.HttpReader('/report/report.zip'), { useWebWorkers: false }) as zip.ZipReader; + for (const entry of await zipReader.getEntries()) + this._entries.set(entry.filename, entry); + this._json = await this.entry('report.json') as HTMLReport; + } } json(): HTMLReport { diff --git a/packages/html-reporter/src/mergeReports.ts b/packages/html-reporter/src/mergeReports.ts new file mode 100644 index 0000000000000..cb256bde9f6f4 --- /dev/null +++ b/packages/html-reporter/src/mergeReports.ts @@ -0,0 +1,47 @@ +/** + * 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 type { HTMLReport, Stats } from './types'; + +export function mergeReports(reports: HTMLReport[]): HTMLReport { + const [report, ...rest] = reports; + + for (const currentReport of rest) { + currentReport.files.forEach(file => { + const existingGroup = report.files.find(({ fileId }) => fileId === file.fileId); + + if (existingGroup) { + existingGroup.tests.push(...file.tests); + mergeStats(existingGroup.stats, file.stats); + } else { + report.files.push(file); + } + }); + + mergeStats(report.stats, currentReport.stats); + } + + return report; +} + +function mergeStats(stats: Stats, sourceStats: Stats) { + stats.total += sourceStats.total; + stats.expected += sourceStats.expected; + stats.unexpected += sourceStats.unexpected; + stats.flaky += sourceStats.flaky; + stats.skipped += sourceStats.skipped; + stats.duration += sourceStats.duration; + stats.ok = stats.ok && sourceStats.ok; +} diff --git a/packages/playwright-test/src/reporters/html.ts b/packages/playwright-test/src/reporters/html.ts index 8c2ef35fa5b99..b125f1cbc3a20 100644 --- a/packages/playwright-test/src/reporters/html.ts +++ b/packages/playwright-test/src/reporters/html.ts @@ -18,8 +18,6 @@ import { colors } from 'playwright-core/lib/utilsBundle'; import fs from 'fs'; import { open } from '../utilsBundle'; import path from 'path'; -import type { TransformCallback } from 'stream'; -import { Transform } from 'stream'; import type { FullConfig, Suite } from '../../types/testReporter'; import { HttpServer } from 'playwright-core/lib/utils/httpServer'; import { assert, calculateSha1, monotonicTime } from 'playwright-core/lib/utils'; @@ -103,6 +101,7 @@ class HtmlReporter implements ReporterInternal { async onEnd() { const duration = monotonicTime() - this._montonicStartTime; + const shard = this.config.shard; const projectSuites = this.suite.suites; const reports = projectSuites.map(suite => { const rawReporter = new RawReporter(); @@ -111,7 +110,7 @@ class HtmlReporter implements ReporterInternal { }); await removeFolders([this._outputFolder]); const builder = new HtmlBuilder(this._outputFolder); - this._buildResult = await builder.build({ ...this.config.metadata, duration }, reports); + this._buildResult = await builder.build({ ...this.config.metadata, duration, shard }, reports); } async _onExit() { @@ -204,7 +203,7 @@ class HtmlBuilder { this._dataZipFile = new yazl.ZipFile(); } - async build(metadata: Metadata & { duration: number }, rawReports: JsonReport[]): Promise<{ ok: boolean, singleTestId: string | undefined }> { + async build(metadata: Metadata & { duration: number, shard: FullConfigInternal['shard'] }, rawReports: JsonReport[]): Promise<{ ok: boolean, singleTestId: string | undefined }> { const data = new Map(); for (const projectJson of rawReports) { @@ -271,7 +270,8 @@ class HtmlBuilder { return w2 - w1; }); - this._addDataFile('report.json', htmlReport); + const reportName = metadata.shard ? `report-${metadata.shard.current}` : 'report'; + this._addDataFile(`${reportName}.json`, htmlReport); // Copy app. const appFolder = path.join(require.resolve('playwright-core'), '..', 'lib', 'webpack', 'htmlReport'); @@ -289,17 +289,19 @@ class HtmlBuilder { } } - // Inline report data. + // Inline metadata. const indexFile = path.join(this._reportFolder, 'index.html'); - fs.appendFileSync(indexFile, '`); + + // Write report data. + const reportFolder = path.join(this._reportFolder, 'report'); + fs.mkdirSync(reportFolder, { recursive: true }); await new Promise(f => { this._dataZipFile!.end(undefined, () => { this._dataZipFile!.outputStream - .pipe(new Base64Encoder()) - .pipe(fs.createWriteStream(indexFile, { flags: 'a' })).on('close', f); + .pipe(fs.createWriteStream(path.join(reportFolder, `${reportName}.zip`))).on('close', f); }); }); - fs.appendFileSync(indexFile, '";'); let singleTestId: string | undefined; if (htmlReport.stats.total === 1) { @@ -485,31 +487,6 @@ const addStats = (stats: Stats, delta: Stats): Stats => { return stats; }; -class Base64Encoder extends Transform { - private _remainder: Buffer | undefined; - - override _transform(chunk: any, encoding: BufferEncoding, callback: TransformCallback): void { - if (this._remainder) { - chunk = Buffer.concat([this._remainder, chunk]); - this._remainder = undefined; - } - - const remaining = chunk.length % 3; - if (remaining) { - this._remainder = chunk.slice(chunk.length - remaining); - chunk = chunk.slice(0, chunk.length - remaining); - } - chunk = chunk.toString('base64'); - this.push(Buffer.from(chunk)); - callback(); - } - - override _flush(callback: TransformCallback): void { - if (this._remainder) - this.push(Buffer.from(this._remainder.toString('base64'))); - callback(); - } -} function isTextContentType(contentType: string) { return contentType.startsWith('text/') || contentType.startsWith('application/json'); diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 194d8981eb196..1e25cf306c6e3 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -476,7 +476,6 @@ test('should warn user when viewing via file:// protocol', async ({ runInlineTes await test.step('view via local file://', async () => { const reportFolder = testInfo.outputPath('playwright-report'); await page.goto(url.pathToFileURL(path.join(reportFolder, 'index.html')).toString()); - await page.locator('[title="View trace"]').click(); await expect(page.locator('dialog')).toBeVisible(); await expect(page.locator('dialog')).toContainText('must be loaded over'); });