Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/src/test-reporters-js.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/playwright/src/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
45 changes: 41 additions & 4 deletions packages/playwright/src/reporters/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,21 @@
*/

import fs from 'fs';
import os from 'os';
import path from 'path';
import { Transform } from 'stream';

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.
Expand Down Expand Up @@ -217,12 +218,48 @@ function standaloneDefaultFolder(): string {
return reportFolderFromEnv() ?? resolveReporterOutputPath('playwright-report', process.cwd(), undefined);
}

async function resolveReportFolder(reportPath: string): Promise<string> {
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<string> {
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);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does it work with Ctrl+C which is the default way to exit show-report?

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;
}
Expand Down
81 changes: 80 additions & 1 deletion tests/playwright-test/reporter-html.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<void>(resolve => zipFile.outputStream.pipe(fs.createWriteStream(zipPath)).on('close', () => resolve()));
zipFile.addBuffer(Buffer.from('<html></html>'), '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<void> {
const zipFile = new yazl.ZipFile();
const finished = new Promise<void>((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<Buffer> {
return new Promise(resolve => {
const chunks: Buffer[] = [];
Expand Down
Loading