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
|---|---|---|---|
| `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_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`

View File

@ -18,6 +18,14 @@
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) {
.header-view-status-container {
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<{
stats: Stats
}> = ({ stats }) => {

View File

@ -19,7 +19,7 @@ import * as React from 'react';
import './colors.css';
import './common.css';
import { Filter } from './filter';
import { HeaderView } from './headerView';
import { HeaderTitleView, HeaderView } from './headerView';
import { Route, SearchParamsContext } from './links';
import type { LoadedReport } from './loadedReport';
import './reportView.css';
@ -72,6 +72,15 @@ export const ReportView: React.FC<{
return result;
}, [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'>
<main>
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
@ -127,7 +136,7 @@ const TestCaseViewLoader: React.FC<{
if (test === 'not-found') {
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>;
}

View File

@ -34,14 +34,6 @@
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-duration {
flex: none;

View File

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

View File

@ -23,6 +23,7 @@ import { AutoChip } from './chip';
import { TestErrorView } from './testErrorView';
import * as icons from './icons';
import { isMetadataEmpty, MetadataView } from './metadataView';
import { HeaderTitleView } from './headerView';
export const TestFilesView: React.FC<{
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>
{metadataVisible && <MetadataView metadata={report.metadata}/>}
{report.title && <HeaderTitleView title={report.title} />}
{!!report.errors.length && <AutoChip header='Errors' dataTestId='report-errors'>
{report.errors.map((error, index) => <TestErrorView key={'test-report-error-message-' + index} error={error}></TestErrorView>)}
</AutoChip>}

View File

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

View File

@ -54,6 +54,7 @@ type HtmlReporterOptions = {
host?: string,
port?: number,
attachmentsBaseURL?: string,
title?: string,
_mode?: 'test' | 'list';
_isTestServer?: boolean;
};
@ -67,6 +68,7 @@ class HtmlReporter implements ReporterV2 {
private _open: string | undefined;
private _port: number | undefined;
private _host: string | undefined;
private _title: string | undefined;
private _buildResult: { ok: boolean, singleTestId: string | undefined } | undefined;
private _topLevelErrors: api.TestError[] = [];
@ -87,12 +89,13 @@ class HtmlReporter implements ReporterV2 {
}
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._open = open;
this._host = host;
this._port = port;
this._attachmentsBaseURL = attachmentsBaseURL;
this._title = title;
const reportedWarnings = new Set<string>();
for (const project of this.config.projects) {
if (this._isSubdirectory(outputFolder, project.outputDir) || this._isSubdirectory(project.outputDir, outputFolder)) {
@ -112,7 +115,7 @@ class HtmlReporter implements ReporterV2 {
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);
return {
outputFolder,
@ -120,6 +123,7 @@ class HtmlReporter implements ReporterV2 {
attachmentsBaseURL: process.env.PLAYWRIGHT_HTML_ATTACHMENTS_BASE_URL || this._options.attachmentsBaseURL || 'data/',
host: process.env.PLAYWRIGHT_HTML_HOST || this._options.host,
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) {
const projectSuites = this.suite.suites;
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);
}
@ -232,13 +236,15 @@ class HtmlBuilder {
private _dataZipFile: ZipFile;
private _hasTraces = false;
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._reportFolder = outputDir;
fs.mkdirSync(this._reportFolder, { recursive: true });
this._dataZipFile = new yazl.ZipFile();
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 }> {
@ -295,6 +301,7 @@ class HtmlBuilder {
}
const htmlReport: HTMLReport = {
metadata,
title: this._title,
startTime: result.startTime.getTime(),
duration: result.duration,
files: [...data.values()].map(e => e.testFileSummary),

View File

@ -26,7 +26,7 @@ export type ReporterDescription = Readonly<
['github'] |
['junit'] | ['junit', { outputFile?: string, stripANSIControlSequences?: boolean, includeProjectInTestName?: boolean }] |
['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'] |
[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]+/);
});
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 }) => {
const result = await runInlineTest({
'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` });
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 page.goBack();
@ -2341,7 +2362,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await notificationsChromiumTestCase.locator('.test-file-title').click();
await expect(page).toHaveURL(/testId/);
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 page.goBack();
@ -2353,7 +2374,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await monitoringFirefoxTestCase.locator('.test-file-title').click();
await expect(page).toHaveURL(/testId/);
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']);
});
});

View File

@ -25,7 +25,7 @@ export type ReporterDescription = Readonly<
['github'] |
['junit'] | ['junit', { outputFile?: string, stripANSIControlSequences?: boolean, includeProjectInTestName?: boolean }] |
['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'] |
[string] | [string, any]
>;