Skip to content

Commit 26d198e

Browse files
authored
feat(html): support .zip reports in show-report (#40528)
1 parent 5c9a89a commit 26d198e

4 files changed

Lines changed: 130 additions & 6 deletions

File tree

docs/src/test-reporters-js.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,12 @@ Or if there is a custom folder name:
242242
npx playwright show-report my-report
243243
```
244244

245+
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:
246+
247+
```bash
248+
npx playwright show-report playwright-report.zip
249+
```
250+
245251
HTML report supports the following configuration options and environment variables:
246252

247253
| Environment Variable Name | Reporter Config Option| Description | Default

packages/playwright/src/program.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,12 @@ function addShowReportCommand(program: Command) {
100100
command.addHelpText('afterAll', `
101101
Arguments [report]:
102102
When specified, opens given report, otherwise opens last generated report.
103+
Accepts a directory or a .zip archive whose top-level entry is "index.html" (e.g. one downloaded from a CI artifact).
103104
104105
Examples:
105106
$ npx playwright show-report
106-
$ npx playwright show-report playwright-report`);
107+
$ npx playwright show-report playwright-report
108+
$ npx playwright show-report playwright-report.zip`);
107109
}
108110

109111
function addMergeReportsCommand(program: Command) {

packages/playwright/src/reporters/html.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,21 @@
1515
*/
1616

1717
import fs from 'fs';
18+
import os from 'os';
1819
import path from 'path';
1920
import { Transform } from 'stream';
2021

2122
import colors from 'colors/safe';
2223
import mime from 'mime';
2324
import open from 'open';
2425
import * as yazl from 'yazl';
25-
import { assert } from '@isomorphic/assert';
2626
import { MultiMap } from '@isomorphic/multimap';
2727
import { calculateSha1 } from '@utils/crypto';
2828
import { copyFileAndMakeWritable, removeFolders, sanitizeForFilePath, toPosixPath } from '@utils/fileUtils';
2929
import { getPackageManagerExecCommand, isCodingAgent } from '@utils/env';
3030
import { HttpServer, serveFolder } from '@utils/httpServer';
3131
import { gracefullyProcessExitDoNotHang } from '@utils/processLauncher';
32+
import { extractZip } from '@utils/third_party/extractZip';
3233

3334
// HMR: build-time flag — `true` in watch builds, `false` in release. esbuild's
3435
// `define` in the runner bundle replaces this so the dev-server code (incl.
@@ -217,12 +218,48 @@ function standaloneDefaultFolder(): string {
217218
return reportFolderFromEnv() ?? resolveReporterOutputPath('playwright-report', process.cwd(), undefined);
218219
}
219220

221+
async function resolveReportFolder(reportPath: string): Promise<string> {
222+
const stat = await fs.promises.stat(reportPath).catch(() => null);
223+
if (!stat)
224+
throw new Error(`No report found at "${reportPath}"`);
225+
if (stat.isDirectory())
226+
return reportPath;
227+
if (stat.isFile() && reportPath.toLowerCase().endsWith('.zip'))
228+
return await extractReportZip(reportPath);
229+
throw new Error(`No report found at "${reportPath}"`);
230+
}
231+
232+
async function extractReportZip(zipPath: string): Promise<string> {
233+
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-show-report-'));
234+
const cleanup = () => {
235+
try {
236+
fs.rmSync(tempDir, { recursive: true, force: true });
237+
} catch {
238+
}
239+
};
240+
// Default Node behavior on SIGINT/SIGTERM is to terminate, which fires 'exit'.
241+
process.on('exit', cleanup);
242+
try {
243+
await extractZip(zipPath, { dir: tempDir });
244+
} catch (e) {
245+
cleanup();
246+
throw new Error(`Failed to extract report from "${zipPath}": ${e.message}`);
247+
}
248+
const hasIndex = await fs.promises.access(path.join(tempDir, 'index.html')).then(() => true, () => false);
249+
if (!hasIndex) {
250+
cleanup();
251+
throw new Error(`No "index.html" found at the top level of "${zipPath}"`);
252+
}
253+
return tempDir;
254+
}
255+
220256
export async function showHTMLReport(reportFolder: string | undefined, host: string = 'localhost', port?: number, testId?: string) {
221-
const folder = reportFolder ?? standaloneDefaultFolder();
257+
const requestedPath = reportFolder ?? standaloneDefaultFolder();
258+
let folder: string;
222259
try {
223-
assert(fs.statSync(folder).isDirectory());
260+
folder = await resolveReportFolder(requestedPath);
224261
} catch (e) {
225-
writeLine(colors.red(`No report found at "${folder}"`));
262+
writeLine(colors.red(e.message));
226263
gracefullyProcessExitDoNotHang(1);
227264
return;
228265
}

tests/playwright-test/reporter-html.spec.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
import fs from 'fs';
1818
import path from 'path';
1919
import url from 'url';
20-
import { test as baseTest, expect as baseExpect, createImage } from './playwright-test-fixtures';
20+
import * as yazl from 'yazl';
21+
import { test as baseTest, expect as baseExpect, cliEntrypoint, createImage } from './playwright-test-fixtures';
2122
import { iso, utils } from '../../packages/playwright-core/lib/coreBundle';
2223

2324
type HttpServer = utils.HttpServer;
@@ -3524,6 +3525,84 @@ test('should support merge files option', async ({ runInlineTest, showReport, pa
35243525
`);
35253526
});
35263527

3528+
test.describe('show-report .zip support', () => {
3529+
test('should serve a zipped report', async ({ runInlineTest, childProcess, findFreePort, page }, testInfo) => {
3530+
await runInlineTest({
3531+
'a.test.js': `
3532+
import { test, expect } from '@playwright/test';
3533+
test('passes', async ({}) => {});
3534+
`,
3535+
}, { reporter: 'html' }, { PLAYWRIGHT_HTML_OPEN: 'never' });
3536+
3537+
const reportFolder = testInfo.outputPath('playwright-report');
3538+
const zipPath = testInfo.outputPath('report.zip');
3539+
await zipDirectory(reportFolder, zipPath);
3540+
3541+
const port = await findFreePort();
3542+
const proc = childProcess({
3543+
command: ['node', cliEntrypoint, 'show-report', zipPath, `--port=${port}`],
3544+
cwd: testInfo.outputPath(),
3545+
env: { ...process.env, PLAYWRIGHT_HTML_OPEN: 'never' },
3546+
});
3547+
await proc.waitForOutput('Serving HTML report at');
3548+
await page.goto(`http://localhost:${port}`);
3549+
await expect(page.locator('.subnav-item:has-text("Passed") .counter')).toHaveText('1');
3550+
await expect(page.locator('.test-file-test-outcome-expected >> text=passes')).toBeVisible();
3551+
});
3552+
3553+
test('should error on a non-zip non-directory path', async ({ runInlineTest, childProcess }, testInfo) => {
3554+
const filePath = testInfo.outputPath('not-a-report.txt');
3555+
await fs.promises.writeFile(filePath, 'hello');
3556+
const proc = childProcess({
3557+
command: ['node', cliEntrypoint, 'show-report', filePath],
3558+
cwd: testInfo.outputPath(),
3559+
});
3560+
const { exitCode } = await proc.exited;
3561+
expect(exitCode).toBe(1);
3562+
expect(proc.output).toContain(`No report found at "${filePath}"`);
3563+
});
3564+
3565+
test('should error when zip lacks a top-level index.html', async ({ childProcess }, testInfo) => {
3566+
const zipPath = testInfo.outputPath('nested.zip');
3567+
const zipFile = new yazl.ZipFile();
3568+
const finished = new Promise<void>(resolve => zipFile.outputStream.pipe(fs.createWriteStream(zipPath)).on('close', () => resolve()));
3569+
zipFile.addBuffer(Buffer.from('<html></html>'), 'nested/index.html');
3570+
zipFile.end();
3571+
await finished;
3572+
3573+
const proc = childProcess({
3574+
command: ['node', cliEntrypoint, 'show-report', zipPath],
3575+
cwd: testInfo.outputPath(),
3576+
});
3577+
const { exitCode } = await proc.exited;
3578+
expect(exitCode).toBe(1);
3579+
expect(proc.output).toContain(`No "index.html" found at the top level of "${zipPath}"`);
3580+
});
3581+
});
3582+
3583+
async function zipDirectory(sourceDir: string, zipPath: string): Promise<void> {
3584+
const zipFile = new yazl.ZipFile();
3585+
const finished = new Promise<void>((resolve, reject) => {
3586+
zipFile.outputStream.pipe(fs.createWriteStream(zipPath))
3587+
.on('close', () => resolve())
3588+
.on('error', reject);
3589+
});
3590+
const walk = async (dir: string, relative: string) => {
3591+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
3592+
for (const entry of entries) {
3593+
const absolute = path.join(dir, entry.name);
3594+
const relativeEntry = relative ? `${relative}/${entry.name}` : entry.name;
3595+
if (entry.isDirectory())
3596+
await walk(absolute, relativeEntry);
3597+
else if (entry.isFile())
3598+
zipFile.addFile(absolute, relativeEntry);
3599+
}
3600+
};
3601+
await walk(sourceDir, '');
3602+
zipFile.end();
3603+
await finished;
3604+
}
3605+
35273606
function readAllFromStream(stream: NodeJS.ReadableStream): Promise<Buffer> {
35283607
return new Promise(resolve => {
35293608
const chunks: Buffer[] = [];

0 commit comments

Comments
 (0)