feat(html): allow setting a title to display (#35659)

This commit is contained in:
Adam Gastineau 2025-04-28 10:30:25 -07:00 committed by GitHub
parent ed23a93512
commit 9b59a6aea6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 64 additions and 20 deletions

View File

@ -246,6 +246,7 @@ HTML report supports the following configuration options and environment variabl
| Environment Variable Name | Reporter Config Option| Description | Default | Environment Variable Name | Reporter Config Option| Description | Default
|---|---|---|---| |---|---|---|---|
| `PLAYWRIGHT_HTML_TITLE` | `title` | A title to display in the generated report. | No title is displayed by default
| `PLAYWRIGHT_HTML_OUTPUT_DIR` | `outputFolder` | Directory to save the report to. | `playwright-report` | `PLAYWRIGHT_HTML_OUTPUT_DIR` | `outputFolder` | Directory to save the report to. | `playwright-report`
| `PLAYWRIGHT_HTML_OPEN` | `open` | When to open the html report in the browser, one of `'always'`, `'never'` or `'on-failure'` | `'on-failure'` | `PLAYWRIGHT_HTML_OPEN` | `open` | When to open the html report in the browser, one of `'always'`, `'never'` or `'on-failure'` | `'on-failure'`
| `PLAYWRIGHT_HTML_HOST` | `host` | When report opens in the browser, it will be served bound to this hostname. | `localhost` | `PLAYWRIGHT_HTML_HOST` | `host` | When report opens in the browser, it will be served bound to this hostname. | `localhost`

View File

@ -18,6 +18,14 @@
float: right; float: right;
} }
.header-title {
flex: none;
padding: 8px;
font-weight: 400;
font-size: 32px !important;
line-height: 1.25 !important;
}
@media only screen and (max-width: 600px) { @media only screen and (max-width: 600px) {
.header-view-status-container { .header-view-status-container {
float: none; float: none;

View File

@ -59,6 +59,8 @@ export const HeaderView: React.FC<{
</>); </>);
}; };
export const HeaderTitleView: React.FC<{ title: string }> = ({ title }) => <div className='header-title'>{title}</div>;
const StatsNavView: React.FC<{ const StatsNavView: React.FC<{
stats: Stats stats: Stats
}> = ({ stats }) => { }> = ({ stats }) => {

View File

@ -19,7 +19,7 @@ import * as React from 'react';
import './colors.css'; import './colors.css';
import './common.css'; import './common.css';
import { Filter } from './filter'; import { Filter } from './filter';
import { HeaderView } from './headerView'; import { HeaderTitleView, HeaderView } from './headerView';
import { Route, SearchParamsContext } from './links'; import { Route, SearchParamsContext } from './links';
import type { LoadedReport } from './loadedReport'; import type { LoadedReport } from './loadedReport';
import './reportView.css'; import './reportView.css';
@ -72,6 +72,15 @@ export const ReportView: React.FC<{
return result; return result;
}, [report, filter]); }, [report, filter]);
const reportTitle = report?.json()?.title;
React.useEffect(() => {
if (reportTitle)
document.title = reportTitle;
else
document.title = 'Playwright Test Report';
}, [reportTitle]);
return <div className='htmlreport vbox px-4 pb-4'> return <div className='htmlreport vbox px-4 pb-4'>
<main> <main>
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>} {report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
@ -127,7 +136,7 @@ const TestCaseViewLoader: React.FC<{
if (test === 'not-found') { if (test === 'not-found') {
return <div className='test-case-column vbox'> return <div className='test-case-column vbox'>
<div className='test-case-title'>Test not found</div> <HeaderTitleView title='Test not found' />
<div className='test-case-location'>Test ID: {testId}</div> <div className='test-case-location'>Test ID: {testId}</div>
</div>; </div>;
} }

View File

@ -34,14 +34,6 @@
color: var(--color-fg-default); color: var(--color-fg-default);
} }
.test-case-title {
flex: none;
padding: 8px;
font-weight: 400;
font-size: 32px !important;
line-height: 1.25 !important;
}
.test-case-location, .test-case-location,
.test-case-duration { .test-case-duration {
flex: none; flex: none;

View File

@ -28,6 +28,7 @@ import { linkifyText } from '@web/renderUtils';
import { hashStringToInt, msToString } from './utils'; import { hashStringToInt, msToString } from './utils';
import { clsx } from '@web/uiUtils'; import { clsx } from '@web/uiUtils';
import { CopyToClipboardContainer } from './copyToClipboard'; import { CopyToClipboardContainer } from './copyToClipboard';
import { HeaderTitleView } from './headerView';
export const TestCaseView: React.FC<{ export const TestCaseView: React.FC<{
projectNames: string[], projectNames: string[],
@ -51,7 +52,7 @@ export const TestCaseView: React.FC<{
<div style={{ width: 10 }}></div> <div style={{ width: 10 }}></div>
<div className={clsx(!next && 'hidden')}><Link href={testResultHref({ test: next }) + filterParam}>next »</Link></div> <div className={clsx(!next && 'hidden')}><Link href={testResultHref({ test: next }) + filterParam}>next »</Link></div>
</div> </div>
<div className='test-case-title'>{test.title}</div> <HeaderTitleView title={test.title} />
<div className='hbox'> <div className='hbox'>
<div className='test-case-location'> <div className='test-case-location'>
<CopyToClipboardContainer value={`${test.location.file}:${test.location.line}`}> <CopyToClipboardContainer value={`${test.location.file}:${test.location.line}`}>

View File

@ -23,6 +23,7 @@ import { AutoChip } from './chip';
import { TestErrorView } from './testErrorView'; import { TestErrorView } from './testErrorView';
import * as icons from './icons'; import * as icons from './icons';
import { isMetadataEmpty, MetadataView } from './metadataView'; import { isMetadataEmpty, MetadataView } from './metadataView';
import { HeaderTitleView } from './headerView';
export const TestFilesView: React.FC<{ export const TestFilesView: React.FC<{
tests: TestFileSummary[], tests: TestFileSummary[],
@ -83,6 +84,7 @@ export const TestFilesHeader: React.FC<{
<div data-testid='overall-duration' style={{ color: 'var(--color-fg-subtle)' }}>Total time: {msToString(report.duration ?? 0)}</div> <div data-testid='overall-duration' style={{ color: 'var(--color-fg-subtle)' }}>Total time: {msToString(report.duration ?? 0)}</div>
</div> </div>
{metadataVisible && <MetadataView metadata={report.metadata}/>} {metadataVisible && <MetadataView metadata={report.metadata}/>}
{report.title && <HeaderTitleView title={report.title} />}
{!!report.errors.length && <AutoChip header='Errors' dataTestId='report-errors'> {!!report.errors.length && <AutoChip header='Errors' dataTestId='report-errors'>
{report.errors.map((error, index) => <TestErrorView key={'test-report-error-message-' + index} error={error}></TestErrorView>)} {report.errors.map((error, index) => <TestErrorView key={'test-report-error-message-' + index} error={error}></TestErrorView>)}
</AutoChip>} </AutoChip>}

View File

@ -38,6 +38,7 @@ export type Location = {
export type HTMLReport = { export type HTMLReport = {
metadata: Metadata; metadata: Metadata;
title: string | undefined;
files: TestFileSummary[]; files: TestFileSummary[];
stats: Stats; stats: Stats;
projectNames: string[]; projectNames: string[];

View File

@ -54,6 +54,7 @@ type HtmlReporterOptions = {
host?: string, host?: string,
port?: number, port?: number,
attachmentsBaseURL?: string, attachmentsBaseURL?: string,
title?: string,
_mode?: 'test' | 'list'; _mode?: 'test' | 'list';
_isTestServer?: boolean; _isTestServer?: boolean;
}; };
@ -67,6 +68,7 @@ class HtmlReporter implements ReporterV2 {
private _open: string | undefined; private _open: string | undefined;
private _port: number | undefined; private _port: number | undefined;
private _host: string | undefined; private _host: string | undefined;
private _title: string | undefined;
private _buildResult: { ok: boolean, singleTestId: string | undefined } | undefined; private _buildResult: { ok: boolean, singleTestId: string | undefined } | undefined;
private _topLevelErrors: api.TestError[] = []; private _topLevelErrors: api.TestError[] = [];
@ -87,12 +89,13 @@ class HtmlReporter implements ReporterV2 {
} }
onBegin(suite: api.Suite) { onBegin(suite: api.Suite) {
const { outputFolder, open, attachmentsBaseURL, host, port } = this._resolveOptions(); const { outputFolder, open, attachmentsBaseURL, host, port, title } = this._resolveOptions();
this._outputFolder = outputFolder; this._outputFolder = outputFolder;
this._open = open; this._open = open;
this._host = host; this._host = host;
this._port = port; this._port = port;
this._attachmentsBaseURL = attachmentsBaseURL; this._attachmentsBaseURL = attachmentsBaseURL;
this._title = title;
const reportedWarnings = new Set<string>(); const reportedWarnings = new Set<string>();
for (const project of this.config.projects) { for (const project of this.config.projects) {
if (this._isSubdirectory(outputFolder, project.outputDir) || this._isSubdirectory(project.outputDir, outputFolder)) { if (this._isSubdirectory(outputFolder, project.outputDir) || this._isSubdirectory(project.outputDir, outputFolder)) {
@ -112,7 +115,7 @@ class HtmlReporter implements ReporterV2 {
this.suite = suite; this.suite = suite;
} }
_resolveOptions(): { outputFolder: string, open: HtmlReportOpenOption, attachmentsBaseURL: string, host: string | undefined, port: number | undefined } { _resolveOptions(): { outputFolder: string, open: HtmlReportOpenOption, attachmentsBaseURL: string, host: string | undefined, port: number | undefined, title: string | undefined } {
const outputFolder = reportFolderFromEnv() ?? resolveReporterOutputPath('playwright-report', this._options.configDir, this._options.outputFolder); const outputFolder = reportFolderFromEnv() ?? resolveReporterOutputPath('playwright-report', this._options.configDir, this._options.outputFolder);
return { return {
outputFolder, outputFolder,
@ -120,6 +123,7 @@ class HtmlReporter implements ReporterV2 {
attachmentsBaseURL: process.env.PLAYWRIGHT_HTML_ATTACHMENTS_BASE_URL || this._options.attachmentsBaseURL || 'data/', attachmentsBaseURL: process.env.PLAYWRIGHT_HTML_ATTACHMENTS_BASE_URL || this._options.attachmentsBaseURL || 'data/',
host: process.env.PLAYWRIGHT_HTML_HOST || this._options.host, host: process.env.PLAYWRIGHT_HTML_HOST || this._options.host,
port: process.env.PLAYWRIGHT_HTML_PORT ? +process.env.PLAYWRIGHT_HTML_PORT : this._options.port, port: process.env.PLAYWRIGHT_HTML_PORT ? +process.env.PLAYWRIGHT_HTML_PORT : this._options.port,
title: process.env.PLAYWRIGHT_HTML_TITLE || this._options.title,
}; };
} }
@ -135,7 +139,7 @@ class HtmlReporter implements ReporterV2 {
async onEnd(result: api.FullResult) { async onEnd(result: api.FullResult) {
const projectSuites = this.suite.suites; const projectSuites = this.suite.suites;
await removeFolders([this._outputFolder]); await removeFolders([this._outputFolder]);
const builder = new HtmlBuilder(this.config, this._outputFolder, this._attachmentsBaseURL); const builder = new HtmlBuilder(this.config, this._outputFolder, this._attachmentsBaseURL, this._title);
this._buildResult = await builder.build(this.config.metadata, projectSuites, result, this._topLevelErrors); this._buildResult = await builder.build(this.config.metadata, projectSuites, result, this._topLevelErrors);
} }
@ -232,13 +236,15 @@ class HtmlBuilder {
private _dataZipFile: ZipFile; private _dataZipFile: ZipFile;
private _hasTraces = false; private _hasTraces = false;
private _attachmentsBaseURL: string; private _attachmentsBaseURL: string;
private _title: string | undefined;
constructor(config: api.FullConfig, outputDir: string, attachmentsBaseURL: string) { constructor(config: api.FullConfig, outputDir: string, attachmentsBaseURL: string, title: string | undefined) {
this._config = config; this._config = config;
this._reportFolder = outputDir; this._reportFolder = outputDir;
fs.mkdirSync(this._reportFolder, { recursive: true }); fs.mkdirSync(this._reportFolder, { recursive: true });
this._dataZipFile = new yazl.ZipFile(); this._dataZipFile = new yazl.ZipFile();
this._attachmentsBaseURL = attachmentsBaseURL; this._attachmentsBaseURL = attachmentsBaseURL;
this._title = title;
} }
async build(metadata: Metadata, projectSuites: api.Suite[], result: api.FullResult, topLevelErrors: api.TestError[]): Promise<{ ok: boolean, singleTestId: string | undefined }> { async build(metadata: Metadata, projectSuites: api.Suite[], result: api.FullResult, topLevelErrors: api.TestError[]): Promise<{ ok: boolean, singleTestId: string | undefined }> {
@ -295,6 +301,7 @@ class HtmlBuilder {
} }
const htmlReport: HTMLReport = { const htmlReport: HTMLReport = {
metadata, metadata,
title: this._title,
startTime: result.startTime.getTime(), startTime: result.startTime.getTime(),
duration: result.duration, duration: result.duration,
files: [...data.values()].map(e => e.testFileSummary), files: [...data.values()].map(e => e.testFileSummary),

View File

@ -26,7 +26,7 @@ export type ReporterDescription = Readonly<
['github'] | ['github'] |
['junit'] | ['junit', { outputFile?: string, stripANSIControlSequences?: boolean, includeProjectInTestName?: boolean }] | ['junit'] | ['junit', { outputFile?: string, stripANSIControlSequences?: boolean, includeProjectInTestName?: boolean }] |
['json'] | ['json', { outputFile?: string }] | ['json'] | ['json', { outputFile?: string }] |
['html'] | ['html', { outputFolder?: string, open?: 'always' | 'never' | 'on-failure', host?: string, port?: number, attachmentsBaseURL?: string }] | ['html'] | ['html', { outputFolder?: string, open?: 'always' | 'never' | 'on-failure', host?: string, port?: number, attachmentsBaseURL?: string, title?: string }] |
['null'] | ['null'] |
[string] | [string, any] [string] | [string, any]
>; >;

View File

@ -435,6 +435,27 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await expect(page.locator('div').filter({ hasText: /^Tracestrace$/ }).getByRole('link').first()).toHaveAttribute('href', /trace=(https:\/\/some-url\.com\/)[^/\s]+?\.[^/\s]+/); await expect(page.locator('div').filter({ hasText: /^Tracestrace$/ }).getByRole('link').first()).toHaveAttribute('href', /trace=(https:\/\/some-url\.com\/)[^/\s]+?\.[^/\s]+/);
}); });
test('should display title if provided', async ({ runInlineTest, page, showReport }, testInfo) => {
const result = await runInlineTest({
'playwright.config.ts': `
module.exports = {
reporter: [['html', { title: 'Custom report title' }], ['line']]
};
`,
'a.test.js': `
import { test, expect } from '@playwright/test';
test('passes', async ({ page }) => {
await page.evaluate('2 + 2');
});
`
}, {}, { PLAYWRIGHT_HTML_OPEN: 'never' });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
await showReport();
await expect(page.locator('.header-title')).toHaveText('Custom report title');
});
test('should include stdio', async ({ runInlineTest, page, showReport }) => { test('should include stdio', async ({ runInlineTest, page, showReport }) => {
const result = await runInlineTest({ const result = await runInlineTest({
'a.test.js': ` 'a.test.js': `
@ -1819,7 +1840,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
const testTitle = page.locator('.test-file-test .test-file-title', { hasText: `${tag} passes` }); const testTitle = page.locator('.test-file-test .test-file-title', { hasText: `${tag} passes` });
await testTitle.click(); await testTitle.click();
await expect(page.locator('.test-case-title', { hasText: `${tag} passes` })).toBeVisible(); await expect(page.locator('.header-title', { hasText: `${tag} passes` })).toBeVisible();
await expect(page.locator('.label', { hasText: tag })).toBeVisible(); await expect(page.locator('.label', { hasText: tag })).toBeVisible();
await page.goBack(); await page.goBack();
@ -2341,7 +2362,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await notificationsChromiumTestCase.locator('.test-file-title').click(); await notificationsChromiumTestCase.locator('.test-file-title').click();
await expect(page).toHaveURL(/testId/); await expect(page).toHaveURL(/testId/);
await expect(page.locator('.test-case-path')).toHaveText('Root describe @Notifications'); await expect(page.locator('.test-case-path')).toHaveText('Root describe @Notifications');
await expect(page.locator('.test-case-title')).toHaveText('Test failed -- @call @call-details @e2e @regression #VQ458'); await expect(page.locator('.header-title')).toHaveText('Test failed -- @call @call-details @e2e @regression #VQ458');
await expect(page.locator('.label')).toHaveText(['chromium', 'Notifications', 'call', 'call-details', 'e2e', 'regression']); await expect(page.locator('.label')).toHaveText(['chromium', 'Notifications', 'call', 'call-details', 'e2e', 'regression']);
await page.goBack(); await page.goBack();
@ -2353,7 +2374,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await monitoringFirefoxTestCase.locator('.test-file-title').click(); await monitoringFirefoxTestCase.locator('.test-file-title').click();
await expect(page).toHaveURL(/testId/); await expect(page).toHaveURL(/testId/);
await expect(page.locator('.test-case-path')).toHaveText('Root describe @Monitoring'); await expect(page.locator('.test-case-path')).toHaveText('Root describe @Monitoring');
await expect(page.locator('.test-case-title')).toHaveText('Test passed -- @call @call-details @e2e @regression #VQ457'); await expect(page.locator('.header-title')).toHaveText('Test passed -- @call @call-details @e2e @regression #VQ457');
await expect(page.locator('.label')).toHaveText(['firefox', 'Monitoring', 'call', 'call-details', 'e2e', 'regression']); await expect(page.locator('.label')).toHaveText(['firefox', 'Monitoring', 'call', 'call-details', 'e2e', 'regression']);
}); });
}); });

View File

@ -25,7 +25,7 @@ export type ReporterDescription = Readonly<
['github'] | ['github'] |
['junit'] | ['junit', { outputFile?: string, stripANSIControlSequences?: boolean, includeProjectInTestName?: boolean }] | ['junit'] | ['junit', { outputFile?: string, stripANSIControlSequences?: boolean, includeProjectInTestName?: boolean }] |
['json'] | ['json', { outputFile?: string }] | ['json'] | ['json', { outputFile?: string }] |
['html'] | ['html', { outputFolder?: string, open?: 'always' | 'never' | 'on-failure', host?: string, port?: number, attachmentsBaseURL?: string }] | ['html'] | ['html', { outputFolder?: string, open?: 'always' | 'never' | 'on-failure', host?: string, port?: number, attachmentsBaseURL?: string, title?: string }] |
['null'] | ['null'] |
[string] | [string, any] [string] | [string, any]
>; >;