From 007b9cf5ace240d2fc4184718debb7b890cdeaca Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 30 Apr 2026 08:11:48 -0700 Subject: [PATCH] feat(html): support .zip reports in show-report Fixes: https://github.com/microsoft/playwright/issues/40456 --- docs/src/test-reporters-js.md | 6 ++ packages/playwright/src/program.ts | 4 +- packages/playwright/src/reporters/html.ts | 45 +++++++++++- tests/playwright-test/reporter-html.spec.ts | 81 ++++++++++++++++++++- 4 files changed, 130 insertions(+), 6 deletions(-) diff --git a/docs/src/test-reporters-js.md b/docs/src/test-reporters-js.md index b5c50d866063a..57d34a9bb4c45 100644 --- a/docs/src/test-reporters-js.md +++ b/docs/src/test-reporters-js.md @@ -242,6 +242,12 @@ Or if there is a custom folder name: npx playwright show-report my-report ``` +You can also pass a `.zip` archive — for example one downloaded from a CI artifact. The archive must contain `index.html` at its top level. Playwright will extract it to a temporary directory and serve the report: + +```bash +npx playwright show-report playwright-report.zip +``` + HTML report supports the following configuration options and environment variables: | Environment Variable Name | Reporter Config Option| Description | Default diff --git a/packages/playwright/src/program.ts b/packages/playwright/src/program.ts index fa9d276c7358b..f3ad17431ae25 100644 --- a/packages/playwright/src/program.ts +++ b/packages/playwright/src/program.ts @@ -100,10 +100,12 @@ function addShowReportCommand(program: Command) { command.addHelpText('afterAll', ` Arguments [report]: When specified, opens given report, otherwise opens last generated report. + Accepts a directory or a .zip archive whose top-level entry is "index.html" (e.g. one downloaded from a CI artifact). Examples: $ npx playwright show-report - $ npx playwright show-report playwright-report`); + $ npx playwright show-report playwright-report + $ npx playwright show-report playwright-report.zip`); } function addMergeReportsCommand(program: Command) { diff --git a/packages/playwright/src/reporters/html.ts b/packages/playwright/src/reporters/html.ts index 457009782eb69..654bf23522dd9 100644 --- a/packages/playwright/src/reporters/html.ts +++ b/packages/playwright/src/reporters/html.ts @@ -15,6 +15,7 @@ */ import fs from 'fs'; +import os from 'os'; import path from 'path'; import { Transform } from 'stream'; @@ -22,13 +23,13 @@ import colors from 'colors/safe'; import mime from 'mime'; import open from 'open'; import * as yazl from 'yazl'; -import { assert } from '@isomorphic/assert'; import { MultiMap } from '@isomorphic/multimap'; import { calculateSha1 } from '@utils/crypto'; import { copyFileAndMakeWritable, removeFolders, sanitizeForFilePath, toPosixPath } from '@utils/fileUtils'; import { getPackageManagerExecCommand, isCodingAgent } from '@utils/env'; import { HttpServer, serveFolder } from '@utils/httpServer'; import { gracefullyProcessExitDoNotHang } from '@utils/processLauncher'; +import { extractZip } from '@utils/third_party/extractZip'; // HMR: build-time flag — `true` in watch builds, `false` in release. esbuild's // `define` in the runner bundle replaces this so the dev-server code (incl. @@ -217,12 +218,48 @@ function standaloneDefaultFolder(): string { return reportFolderFromEnv() ?? resolveReporterOutputPath('playwright-report', process.cwd(), undefined); } +async function resolveReportFolder(reportPath: string): Promise { + const stat = await fs.promises.stat(reportPath).catch(() => null); + if (!stat) + throw new Error(`No report found at "${reportPath}"`); + if (stat.isDirectory()) + return reportPath; + if (stat.isFile() && reportPath.toLowerCase().endsWith('.zip')) + return await extractReportZip(reportPath); + throw new Error(`No report found at "${reportPath}"`); +} + +async function extractReportZip(zipPath: string): Promise { + const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-show-report-')); + const cleanup = () => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + } + }; + // Default Node behavior on SIGINT/SIGTERM is to terminate, which fires 'exit'. + process.on('exit', cleanup); + try { + await extractZip(zipPath, { dir: tempDir }); + } catch (e) { + cleanup(); + throw new Error(`Failed to extract report from "${zipPath}": ${e.message}`); + } + const hasIndex = await fs.promises.access(path.join(tempDir, 'index.html')).then(() => true, () => false); + if (!hasIndex) { + cleanup(); + throw new Error(`No "index.html" found at the top level of "${zipPath}"`); + } + return tempDir; +} + export async function showHTMLReport(reportFolder: string | undefined, host: string = 'localhost', port?: number, testId?: string) { - const folder = reportFolder ?? standaloneDefaultFolder(); + const requestedPath = reportFolder ?? standaloneDefaultFolder(); + let folder: string; try { - assert(fs.statSync(folder).isDirectory()); + folder = await resolveReportFolder(requestedPath); } catch (e) { - writeLine(colors.red(`No report found at "${folder}"`)); + writeLine(colors.red(e.message)); gracefullyProcessExitDoNotHang(1); return; } diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index fb975b2ce4973..eb2d1e58d60e7 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -17,7 +17,8 @@ import fs from 'fs'; import path from 'path'; import url from 'url'; -import { test as baseTest, expect as baseExpect, createImage } from './playwright-test-fixtures'; +import * as yazl from 'yazl'; +import { test as baseTest, expect as baseExpect, cliEntrypoint, createImage } from './playwright-test-fixtures'; import { iso, utils } from '../../packages/playwright-core/lib/coreBundle'; type HttpServer = utils.HttpServer; @@ -3524,6 +3525,84 @@ test('should support merge files option', async ({ runInlineTest, showReport, pa `); }); +test.describe('show-report .zip support', () => { + test('should serve a zipped report', async ({ runInlineTest, childProcess, findFreePort, page }, testInfo) => { + await runInlineTest({ + 'a.test.js': ` + import { test, expect } from '@playwright/test'; + test('passes', async ({}) => {}); + `, + }, { reporter: 'html' }, { PLAYWRIGHT_HTML_OPEN: 'never' }); + + const reportFolder = testInfo.outputPath('playwright-report'); + const zipPath = testInfo.outputPath('report.zip'); + await zipDirectory(reportFolder, zipPath); + + const port = await findFreePort(); + const proc = childProcess({ + command: ['node', cliEntrypoint, 'show-report', zipPath, `--port=${port}`], + cwd: testInfo.outputPath(), + env: { ...process.env, PLAYWRIGHT_HTML_OPEN: 'never' }, + }); + await proc.waitForOutput('Serving HTML report at'); + await page.goto(`http://localhost:${port}`); + await expect(page.locator('.subnav-item:has-text("Passed") .counter')).toHaveText('1'); + await expect(page.locator('.test-file-test-outcome-expected >> text=passes')).toBeVisible(); + }); + + test('should error on a non-zip non-directory path', async ({ runInlineTest, childProcess }, testInfo) => { + const filePath = testInfo.outputPath('not-a-report.txt'); + await fs.promises.writeFile(filePath, 'hello'); + const proc = childProcess({ + command: ['node', cliEntrypoint, 'show-report', filePath], + cwd: testInfo.outputPath(), + }); + const { exitCode } = await proc.exited; + expect(exitCode).toBe(1); + expect(proc.output).toContain(`No report found at "${filePath}"`); + }); + + test('should error when zip lacks a top-level index.html', async ({ childProcess }, testInfo) => { + const zipPath = testInfo.outputPath('nested.zip'); + const zipFile = new yazl.ZipFile(); + const finished = new Promise(resolve => zipFile.outputStream.pipe(fs.createWriteStream(zipPath)).on('close', () => resolve())); + zipFile.addBuffer(Buffer.from(''), 'nested/index.html'); + zipFile.end(); + await finished; + + const proc = childProcess({ + command: ['node', cliEntrypoint, 'show-report', zipPath], + cwd: testInfo.outputPath(), + }); + const { exitCode } = await proc.exited; + expect(exitCode).toBe(1); + expect(proc.output).toContain(`No "index.html" found at the top level of "${zipPath}"`); + }); +}); + +async function zipDirectory(sourceDir: string, zipPath: string): Promise { + const zipFile = new yazl.ZipFile(); + const finished = new Promise((resolve, reject) => { + zipFile.outputStream.pipe(fs.createWriteStream(zipPath)) + .on('close', () => resolve()) + .on('error', reject); + }); + const walk = async (dir: string, relative: string) => { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const absolute = path.join(dir, entry.name); + const relativeEntry = relative ? `${relative}/${entry.name}` : entry.name; + if (entry.isDirectory()) + await walk(absolute, relativeEntry); + else if (entry.isFile()) + zipFile.addFile(absolute, relativeEntry); + } + }; + await walk(sourceDir, ''); + zipFile.end(); + await finished; +} + function readAllFromStream(stream: NodeJS.ReadableStream): Promise { return new Promise(resolve => { const chunks: Buffer[] = [];