feat(html report): show metadata (#34517)

This commit is contained in:
Dmitry Gozman 2025-01-29 16:22:50 +00:00 committed by GitHub
parent 6c2c90203e
commit 24f06ec1bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 252 additions and 291 deletions

View file

@ -234,7 +234,9 @@ export default defineConfig({
* since: v1.10 * since: v1.10
- type: ?<[Metadata]> - type: ?<[Metadata]>
Metadata that will be put directly to the test report serialized as JSON. Metadata contains key-value pairs to be included in the report. For example, HTML report will display it as key-value pairs, and JSON report will include metadata serialized as json.
See also [`property: TestConfig.populateGitInfo`] that populates metadata.
**Usage** **Usage**
@ -242,7 +244,7 @@ Metadata that will be put directly to the test report serialized as JSON.
import { defineConfig } from '@playwright/test'; import { defineConfig } from '@playwright/test';
export default defineConfig({ export default defineConfig({
metadata: 'acceptance tests', metadata: { title: 'acceptance tests' },
}); });
``` ```
@ -325,7 +327,9 @@ This path will serve as the base directory for each test file snapshot directory
* since: v1.51 * since: v1.51
- type: ?<[boolean]> - type: ?<[boolean]>
Whether to populate [`property: TestConfig.metadata`] with Git info. The metadata will automatically appear in the HTML report and is available in Reporter API. Whether to populate `'git.commit.info'` field of the [`property: TestConfig.metadata`] with Git commit info and CI/CD information.
This information will appear in the HTML and JSON reports and is available in the Reporter API.
**Usage** **Usage**

View file

@ -69,22 +69,6 @@ export const blank = () => {
return <svg className='octicon' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'></svg>; return <svg className='octicon' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'></svg>;
}; };
export const externalLink = () => {
return <svg className='octicon' viewBox='0 0 16 16' width='16' height='16'><path fillRule='evenodd' d='M10.604 1h4.146a.25.25 0 01.25.25v4.146a.25.25 0 01-.427.177L13.03 4.03 9.28 7.78a.75.75 0 01-1.06-1.06l3.75-3.75-1.543-1.543A.25.25 0 0110.604 1zM3.75 2A1.75 1.75 0 002 3.75v8.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0014 12.25v-3.5a.75.75 0 00-1.5 0v3.5a.25.25 0 01-.25.25h-8.5a.25.25 0 01-.25-.25v-8.5a.25.25 0 01.25-.25h3.5a.75.75 0 000-1.5h-3.5z'></path></svg>;
};
export const calendar = () => {
return <svg className='octicon' viewBox='0 0 16 16' width='16' height='16'><path fillRule='evenodd' d='M4.75 0a.75.75 0 01.75.75V2h5V.75a.75.75 0 011.5 0V2h1.25c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0113.25 16H2.75A1.75 1.75 0 011 14.25V3.75C1 2.784 1.784 2 2.75 2H4V.75A.75.75 0 014.75 0zm0 3.5h8.5a.25.25 0 01.25.25V6h-11V3.75a.25.25 0 01.25-.25h2zm-2.25 4v6.75c0 .138.112.25.25.25h10.5a.25.25 0 00.25-.25V7.5h-11z'></path></svg>;
};
export const person = () => {
return <svg className='octicon' viewBox='0 0 16 16' width='16' height='16'><path fillRule='evenodd' d='M10.5 5a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0zm.061 3.073a4 4 0 10-5.123 0 6.004 6.004 0 00-3.431 5.142.75.75 0 001.498.07 4.5 4.5 0 018.99 0 .75.75 0 101.498-.07 6.005 6.005 0 00-3.432-5.142z'></path></svg>;
};
export const commit = () => {
return <svg className='octicon' viewBox='0 0 16 16' width='16' height='16'><path fillRule='evenodd' d='M10.5 7.75a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0zm1.43.75a4.002 4.002 0 01-7.86 0H.75a.75.75 0 110-1.5h3.32a4.001 4.001 0 017.86 0h3.32a.75.75 0 110 1.5h-3.32z'></path></svg>;
};
export const image = () => { export const image = () => {
return <svg className='octicon' viewBox='0 0 48 48' version='1.1' width='20' height='20' aria-hidden='true'> return <svg className='octicon' viewBox='0 0 48 48' version='1.1' width='20' height='20' aria-hidden='true'>
<path xmlns='http://www.w3.org/2000/svg' d='M11.85 32H36.2l-7.35-9.95-6.55 8.7-4.6-6.45ZM7 40q-1.2 0-2.1-.9Q4 38.2 4 37V11q0-1.2.9-2.1Q5.8 8 7 8h34q1.2 0 2.1.9.9.9.9 2.1v26q0 1.2-.9 2.1-.9.9-2.1.9Zm0-29v26-26Zm34 26V11H7v26Z'/> <path xmlns='http://www.w3.org/2000/svg' d='M11.85 32H36.2l-7.35-9.95-6.55 8.7-4.6-6.45ZM7 40q-1.2 0-2.1-.9Q4 38.2 4 37V11q0-1.2.9-2.1Q5.8 8 7 8h34q1.2 0 2.1.9.9.9.9 2.1v26q0 1.2-.9 2.1-.9.9-2.1.9Zm0-29v26-26Zm34 26V11H7v26Z'/>

View file

@ -0,0 +1,41 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.metadata-toggle {
cursor: pointer;
user-select: none;
margin-left: 5px;
}
.metadata-view {
border: 1px solid var(--color-border-default);
border-radius: 6px;
margin-top: 8px;
}
.metadata-separator {
height: 1px;
border-bottom: 1px solid var(--color-border-default);
}
.metadata-view .copy-value-container {
margin-top: -2px;
}
.git-commit-info a {
color: var(--color-fg-default);
font-weight: 600;
}

View file

@ -17,21 +17,19 @@
import * as React from 'react'; import * as React from 'react';
import './colors.css'; import './colors.css';
import './common.css'; import './common.css';
import * as icons from './icons';
import { AutoChip } from './chip';
import './reportView.css';
import './theme.css'; import './theme.css';
import './metadataView.css';
import type { Metadata } from '@playwright/test';
import type { GitCommitInfo } from '@testIsomorphic/types';
import { CopyToClipboardContainer } from './copyToClipboard';
import { linkifyText } from '@web/renderUtils';
export type Metainfo = { type MetadataEntries = [string, unknown][];
'revision.id'?: string;
'revision.author'?: string; export function filterMetadata(metadata: Metadata): MetadataEntries {
'revision.email'?: string; // TODO: do not plumb actualWorkers through metadata.
'revision.subject'?: string; return Object.entries(metadata).filter(([key]) => key !== 'actualWorkers');
'revision.timestamp'?: number | Date; }
'revision.link'?: string;
'ci.link'?: string;
'timestamp'?: number
};
class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error: Error | null, errorInfo: React.ErrorInfo | null }> { class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error: Error | null, errorInfo: React.ErrorInfo | null }> {
override state: { error: Error | null, errorInfo: React.ErrorInfo | null } = { override state: { error: Error | null, errorInfo: React.ErrorInfo | null } = {
@ -46,12 +44,12 @@ class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error
override render() { override render() {
if (this.state.error || this.state.errorInfo) { if (this.state.error || this.state.errorInfo) {
return ( return (
<AutoChip header={'Commit Metainfo Error'} dataTestId='metadata-error'> <div className='metadata-view p-3'>
<p>An error was encountered when trying to render Commit Metainfo. Please file a GitHub issue to report this error.</p> <p>An error was encountered when trying to render metadata.</p>
<p> <p>
<pre style={{ overflow: 'scroll' }}>{this.state.error?.message}<br/>{this.state.error?.stack}<br/>{this.state.errorInfo?.componentStack}</pre> <pre style={{ overflow: 'scroll' }}>{this.state.error?.message}<br/>{this.state.error?.stack}<br/>{this.state.errorInfo?.componentStack}</pre>
</p> </p>
</AutoChip> </div>
); );
} }
@ -59,79 +57,50 @@ class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error
} }
} }
export const MetadataView: React.FC<Metainfo> = metadata => <ErrorBoundary><InnerMetadataView {...metadata} /></ErrorBoundary>; export const MetadataView: React.FC<{ metadataEntries: MetadataEntries }> = ({ metadataEntries }) => {
return <ErrorBoundary><InnerMetadataView metadataEntries={metadataEntries}/></ErrorBoundary>;
};
const InnerMetadataView: React.FC<Metainfo> = metadata => { const InnerMetadataView: React.FC<{ metadataEntries: MetadataEntries }> = ({ metadataEntries }) => {
if (!Object.keys(metadata).find(k => k.startsWith('revision.') || k.startsWith('ci.'))) const gitCommitInfo = metadataEntries.find(([key]) => key === 'git.commit.info')?.[1] as GitCommitInfo | undefined;
const entries = metadataEntries.filter(([key]) => key !== 'git.commit.info');
if (!gitCommitInfo && !entries.length)
return null; return null;
return <div className='metadata-view'>
return ( {gitCommitInfo && <>
<AutoChip header={ <GitCommitInfoView info={gitCommitInfo}/>
<span> {entries.length > 0 && <div className='metadata-separator' />}
{metadata['revision.id'] && <span style={{ float: 'right' }}> </>}
{metadata['revision.id'].slice(0, 7)} {entries.map(([key, value]) => {
</span>} const valueString = typeof value !== 'object' || value === null || value === undefined ? String(value) : JSON.stringify(value);
{metadata['revision.subject'] || 'Commit Metainfo'} const trimmedValue = valueString.length > 1000 ? valueString.slice(0, 1000) + '\u2026' : valueString;
</span>} initialExpanded={false} dataTestId='metadata-chip'> return <div className='m-1 ml-5' key={key}>
{metadata['revision.subject'] && <span style={{ fontWeight: 'bold' }} title={key}>{key}</span>
<MetadataViewItem {valueString && <CopyToClipboardContainer value={valueString}>: <span title={trimmedValue}>{linkifyText(trimmedValue)}</span></CopyToClipboardContainer>}
testId='revision.subject' </div>;
content={<span>{metadata['revision.subject']}</span>} })}
/> </div>;
}
{metadata['revision.id'] &&
<MetadataViewItem
testId='revision.id'
content={<span>{metadata['revision.id']}</span>}
href={metadata['revision.link']}
icon='commit'
/>
}
{(metadata['revision.author'] || metadata['revision.email']) &&
<MetadataViewItem
content={`${metadata['revision.author']} ${metadata['revision.email']}`}
icon='person'
/>
}
{metadata['revision.timestamp'] &&
<MetadataViewItem
testId='revision.timestamp'
content={
<>
{Intl.DateTimeFormat(undefined, { dateStyle: 'full' }).format(metadata['revision.timestamp'])}
{' '}
{Intl.DateTimeFormat(undefined, { timeStyle: 'long' }).format(metadata['revision.timestamp'])}
</>
}
icon='calendar'
/>
}
{metadata['ci.link'] &&
<MetadataViewItem
content='CI/CD Logs'
href={metadata['ci.link']}
icon='externalLink'
/>
}
{metadata['timestamp'] &&
<MetadataViewItem
content={<span style={{ color: 'var(--color-fg-subtle)' }}>
Report generated on {Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(metadata['timestamp'])}
</span>}></MetadataViewItem>
}
</AutoChip>
);
}; };
const MetadataViewItem: React.FC<{ content: JSX.Element | string; icon?: keyof typeof icons, href?: string, testId?: string }> = ({ content, icon, href, testId }) => { const GitCommitInfoView: React.FC<{ info: GitCommitInfo }> = ({ info }) => {
return ( const email = info['revision.email'] ? ` <${info['revision.email']}>` : '';
<div className='my-1 hbox' data-testid={testId} > const author = `${info['revision.author'] || ''}${email}`;
<div className='mr-2'> const shortTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(info['revision.timestamp']);
{icons[icon || 'blank']()} const longTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(info['revision.timestamp']);
</div> return <div className='hbox pl-4 pr-2 git-commit-info' style={{ alignItems: 'center' }}>
<div style={{ flex: 1 }}> <div className='vbox'>
{href ? <a href={href} target='_blank' rel='noopener noreferrer'>{content}</a> : content} <a className='m-2' href={info['revision.link']} target='_blank' rel='noopener noreferrer'>
<span title={info['revision.subject'] || ''}>{info['revision.subject'] || ''}</span>
</a>
<div className='hbox m-2 mt-1'>
<div className='mr-1'>{author}</div>
<div title={longTimestamp}> on {shortTimestamp}</div>
{info['ci.link'] && <><span className='mx-2'>·</span><a href={info['ci.link']} target='_blank' rel='noopener noreferrer' title='CI/CD logs'>logs</a></>}
</div> </div>
</div> </div>
); {!!info['revision.link'] && <a href={info['revision.link']} target='_blank' rel='noopener noreferrer'>
<span title='View commit details'>{info['revision.id']?.slice(0, 7) || 'unknown'}</span>
</a>}
{!info['revision.link'] && !!info['revision.id'] && <span>{info['revision.id'].slice(0, 7)}</span>}
</div>;
}; };

View file

@ -23,8 +23,6 @@ import { 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';
import type { Metainfo } from './metadataView';
import { MetadataView } from './metadataView';
import { TestCaseView } from './testCaseView'; import { TestCaseView } from './testCaseView';
import { TestFilesHeader, TestFilesView } from './testFilesView'; import { TestFilesHeader, TestFilesView } from './testFilesView';
import './theme.css'; import './theme.css';
@ -50,6 +48,7 @@ export const ReportView: React.FC<{
const searchParams = React.useContext(SearchParamsContext); const searchParams = React.useContext(SearchParamsContext);
const [expandedFiles, setExpandedFiles] = React.useState<Map<string, boolean>>(new Map()); const [expandedFiles, setExpandedFiles] = React.useState<Map<string, boolean>>(new Map());
const [filterText, setFilterText] = React.useState(searchParams.get('q') || ''); const [filterText, setFilterText] = React.useState(searchParams.get('q') || '');
const [metadataVisible, setMetadataVisible] = React.useState(false);
const testIdToFileIdMap = React.useMemo(() => { const testIdToFileIdMap = React.useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
@ -76,9 +75,8 @@ export const ReportView: React.FC<{
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>}
{report?.json().metadata && <MetadataView {...report?.json().metadata as Metainfo} />}
<Route predicate={testFilesRoutePredicate}> <Route predicate={testFilesRoutePredicate}>
<TestFilesHeader report={report?.json()} filteredStats={filteredStats} /> <TestFilesHeader report={report?.json()} filteredStats={filteredStats} metadataVisible={metadataVisible} toggleMetadataVisible={() => setMetadataVisible(visible => !visible)}/>
<TestFilesView <TestFilesView
tests={filteredTests.files} tests={filteredTests.files}
expandedFiles={expandedFiles} expandedFiles={expandedFiles}

View file

@ -21,6 +21,8 @@ import './testFileView.css';
import { msToString } from './utils'; import { msToString } from './utils';
import { AutoChip } from './chip'; import { AutoChip } from './chip';
import { TestErrorView } from './testErrorView'; import { TestErrorView } from './testErrorView';
import * as icons from './icons';
import { filterMetadata, MetadataView } from './metadataView';
export const TestFilesView: React.FC<{ export const TestFilesView: React.FC<{
tests: TestFileSummary[], tests: TestFileSummary[],
@ -62,17 +64,24 @@ export const TestFilesView: React.FC<{
export const TestFilesHeader: React.FC<{ export const TestFilesHeader: React.FC<{
report: HTMLReport | undefined, report: HTMLReport | undefined,
filteredStats?: FilteredStats, filteredStats?: FilteredStats,
}> = ({ report, filteredStats }) => { metadataVisible: boolean,
toggleMetadataVisible: () => void,
}> = ({ report, filteredStats, metadataVisible, toggleMetadataVisible }) => {
if (!report) if (!report)
return; return;
const metadataEntries = filterMetadata(report.metadata || {});
return <> return <>
<div className='mt-2 mx-1' style={{ display: 'flex' }}> <div className='mx-1' style={{ display: 'flex', marginTop: 10 }}>
{metadataEntries.length > 0 && <div className='metadata-toggle' role='button' onClick={toggleMetadataVisible} title={metadataVisible ? 'Hide metadata' : 'Show metadata'}>
{metadataVisible ? icons.downArrow() : icons.rightArrow()}Metadata
</div>}
{report.projectNames.length === 1 && !!report.projectNames[0] && <div data-testid='project-name' style={{ color: 'var(--color-fg-subtle)' }}>Project: {report.projectNames[0]}</div>} {report.projectNames.length === 1 && !!report.projectNames[0] && <div data-testid='project-name' style={{ color: 'var(--color-fg-subtle)' }}>Project: {report.projectNames[0]}</div>}
{filteredStats && <div data-testid='filtered-tests-count' style={{ color: 'var(--color-fg-subtle)', padding: '0 10px' }}>Filtered: {filteredStats.total} {!!filteredStats.total && ('(' + msToString(filteredStats.duration) + ')')}</div>} {filteredStats && <div data-testid='filtered-tests-count' style={{ color: 'var(--color-fg-subtle)', padding: '0 10px' }}>Filtered: {filteredStats.total} {!!filteredStats.total && ('(' + msToString(filteredStats.duration) + ')')}</div>}
<div style={{ flex: 'auto' }}></div> <div style={{ flex: 'auto' }}></div>
<div data-testid='overall-time' style={{ color: 'var(--color-fg-subtle)', marginRight: '10px' }}>{report ? new Date(report.startTime).toLocaleString() : ''}</div> <div data-testid='overall-time' style={{ color: 'var(--color-fg-subtle)', marginRight: '10px' }}>{report ? new Date(report.startTime).toLocaleString() : ''}</div>
<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 metadataEntries={metadataEntries}/>}
{!!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

@ -20,6 +20,7 @@
"@protocol/*": ["../protocol/src/*"], "@protocol/*": ["../protocol/src/*"],
"@web/*": ["../web/src/*"], "@web/*": ["../web/src/*"],
"@playwright/*": ["../playwright/src/*"], "@playwright/*": ["../playwright/src/*"],
"@testIsomorphic/*": ["../playwright/src/isomorphic/*"],
"playwright-core/lib/*": ["../playwright-core/src/*"], "playwright-core/lib/*": ["../playwright-core/src/*"],
"playwright/lib/*": ["../playwright/src/*"], "playwright/lib/*": ["../playwright/src/*"],
} }

View file

@ -0,0 +1,25 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface GitCommitInfo {
'revision.id'?: string;
'revision.author'?: string;
'revision.email'?: string;
'revision.subject'?: string;
'revision.timestamp'?: number | Date;
'revision.link'?: string;
'ci.link'?: string;
}

View file

@ -18,6 +18,7 @@ import { createGuid, spawnAsync } from 'playwright-core/lib/utils';
import type { TestRunnerPlugin } from './'; import type { TestRunnerPlugin } from './';
import type { FullConfig } from '../../types/testReporter'; import type { FullConfig } from '../../types/testReporter';
import type { FullConfigInternal } from '../common/config'; import type { FullConfigInternal } from '../common/config';
import type { GitCommitInfo } from '../isomorphic/types';
const GIT_OPERATIONS_TIMEOUT_MS = 1500; const GIT_OPERATIONS_TIMEOUT_MS = 1500;
@ -31,38 +32,23 @@ export const gitCommitInfo = (options?: GitCommitInfoPluginOptions): TestRunnerP
name: 'playwright:git-commit-info', name: 'playwright:git-commit-info',
setup: async (config: FullConfig, configDir: string) => { setup: async (config: FullConfig, configDir: string) => {
const info = { const fromEnv = linksFromEnv();
...linksFromEnv(), const fromCLI = await gitStatusFromCLI(options?.directory || configDir);
...options?.info ? options.info : await gitStatusFromCLI(options?.directory || configDir), const info = { ...fromEnv, ...fromCLI };
timestamp: Date.now(), if (info['revision.timestamp'] instanceof Date)
}; info['revision.timestamp'] = info['revision.timestamp'].getTime();
// Normalize dates
const timestamp = info['revision.timestamp'];
if (timestamp instanceof Date)
info['revision.timestamp'] = timestamp.getTime();
config.metadata = config.metadata || {}; config.metadata = config.metadata || {};
Object.assign(config.metadata, info); config.metadata['git.commit.info'] = info;
}, },
}; };
}; };
export interface GitCommitInfoPluginOptions { interface GitCommitInfoPluginOptions {
directory?: string; directory?: string;
info?: Info;
} }
export interface Info { function linksFromEnv(): Pick<GitCommitInfo, 'revision.link' | 'ci.link'> {
'revision.id'?: string;
'revision.author'?: string;
'revision.email'?: string;
'revision.subject'?: string;
'revision.timestamp'?: number | Date;
'revision.link'?: string;
'ci.link'?: string;
}
const linksFromEnv = (): Pick<Info, 'revision.link' | 'ci.link'> => {
const out: { 'revision.link'?: string; 'ci.link'?: string; } = {}; const out: { 'revision.link'?: string; 'ci.link'?: string; } = {};
// Jenkins: https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables // Jenkins: https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables
if (process.env.BUILD_URL) if (process.env.BUILD_URL)
@ -78,9 +64,9 @@ const linksFromEnv = (): Pick<Info, 'revision.link' | 'ci.link'> => {
if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID) if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID)
out['ci.link'] = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; out['ci.link'] = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
return out; return out;
}; }
export const gitStatusFromCLI = async (gitDir: string): Promise<Info | undefined> => { async function gitStatusFromCLI(gitDir: string): Promise<GitCommitInfo | undefined> {
const separator = `:${createGuid().slice(0, 4)}:`; const separator = `:${createGuid().slice(0, 4)}:`;
const { code, stdout } = await spawnAsync( const { code, stdout } = await spawnAsync(
'git', 'git',
@ -101,4 +87,4 @@ export const gitStatusFromCLI = async (gitDir: string): Promise<Info | undefined
'revision.subject': subject, 'revision.subject': subject,
'revision.timestamp': timestamp, 'revision.timestamp': timestamp,
}; };
}; }

View file

@ -1220,7 +1220,12 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
maxFailures?: number; maxFailures?: number;
/** /**
* Metadata that will be put directly to the test report serialized as JSON. * Metadata contains key-value pairs to be included in the report. For example, HTML report will display it as
* key-value pairs, and JSON report will include metadata serialized as json.
*
* See also
* [testConfig.populateGitInfo](https://playwright.dev/docs/api/class-testconfig#test-config-populate-git-info) that
* populates metadata.
* *
* **Usage** * **Usage**
* *
@ -1229,7 +1234,7 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
* import { defineConfig } from '@playwright/test'; * import { defineConfig } from '@playwright/test';
* *
* export default defineConfig({ * export default defineConfig({
* metadata: 'acceptance tests', * metadata: { title: 'acceptance tests' },
* }); * });
* ``` * ```
* *
@ -1294,8 +1299,11 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
outputDir?: string; outputDir?: string;
/** /**
* Whether to populate [testConfig.metadata](https://playwright.dev/docs/api/class-testconfig#test-config-metadata) * Whether to populate `'git.commit.info'` field of the
* with Git info. The metadata will automatically appear in the HTML report and is available in Reporter API. * [testConfig.metadata](https://playwright.dev/docs/api/class-testconfig#test-config-metadata) with Git commit info
* and CI/CD information.
*
* This information will appear in the HTML and JSON reports and is available in the Reporter API.
* *
* **Usage** * **Usage**
* *

View file

@ -1141,13 +1141,14 @@ for (const useIntermediateMergeReport of [true, false] as const) {
]); ]);
}); });
test.describe('gitCommitInfo plugin', () => {
test('should include metadata with populateGitInfo = true', async ({ runInlineTest, writeFiles, showReport, page }) => { test('should include metadata with populateGitInfo = true', async ({ runInlineTest, writeFiles, showReport, page }) => {
const files = { const files = {
'uncommitted.txt': `uncommitted file`, 'uncommitted.txt': `uncommitted file`,
'playwright.config.ts': ` 'playwright.config.ts': `
import { test, expect } from '@playwright/test'; export default {
export default { populateGitInfo: true }; populateGitInfo: true,
metadata: { foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] }
};
`, `,
'example.spec.ts': ` 'example.spec.ts': `
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
@ -1167,7 +1168,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await execGit(['config', '--local', 'user.email', 'shakespeare@example.local']); await execGit(['config', '--local', 'user.email', 'shakespeare@example.local']);
await execGit(['config', '--local', 'user.name', 'William']); await execGit(['config', '--local', 'user.name', 'William']);
await execGit(['add', '*.ts']); await execGit(['add', '*.ts']);
await execGit(['commit', '-m', 'awesome commit message']); await execGit(['commit', '-m', 'chore(html): make this test look nice']);
const result = await runInlineTest(files, { reporter: 'dot,html' }, { const result = await runInlineTest(files, { reporter: 'dot,html' }, {
PLAYWRIGHT_HTML_OPEN: 'never', PLAYWRIGHT_HTML_OPEN: 'never',
@ -1180,22 +1181,18 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await showReport(); await showReport();
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
await page.click('text=awesome commit message'); await page.getByRole('button', { name: 'Metadata' }).click();
await expect.soft(page.getByTestId('revision.id')).toContainText(/^[a-f\d]+$/i); await expect(page.locator('.metadata-view')).toMatchAriaSnapshot(`
await expect.soft(page.getByTestId('revision.id').locator('a')).toHaveAttribute('href', 'https://playwright.dev/microsoft/playwright-example-for-test/commit/example-sha'); - 'link "chore(html): make this test look nice"'
await expect.soft(page.getByTestId('revision.timestamp')).toContainText(/AM|PM/); - text: /^William <shakespeare@example.local> on/
await expect.soft(page.locator('text=awesome commit message')).toHaveCount(2); - link "logs"
await expect.soft(page.locator('text=William')).toBeVisible(); - link /^[a-f0-9]{7}$/
await expect.soft(page.locator('text=shakespeare@example.local')).toBeVisible(); - text: 'foo: value1 bar: {"prop":"value2"} baz: ["value3",123]'
await expect.soft(page.locator('text=CI/CD Logs')).toHaveAttribute('href', 'https://playwright.dev/microsoft/playwright-example-for-test/actions/runs/example-run-id'); `);
await expect.soft(page.locator('text=Report generated on')).toContainText(/AM|PM/);
await expect.soft(page.getByTestId('metadata-chip')).toBeVisible();
await expect.soft(page.getByTestId('metadata-error')).not.toBeVisible();
}); });
test('should not include metadata with populateGitInfo = false', async ({ runInlineTest, showReport, page }) => { test('should not include git metadata with populateGitInfo = false', async ({ runInlineTest, showReport, page }) => {
const result = await runInlineTest({ const result = await runInlineTest({
'uncommitted.txt': `uncommitted file`,
'playwright.config.ts': ` 'playwright.config.ts': `
export default { populateGitInfo: false }; export default { populateGitInfo: false };
`, `,
@ -1208,78 +1205,17 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await showReport(); await showReport();
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
await expect.soft(page.locator('text="my sample test"')).toBeVisible(); await expect.soft(page.getByRole('button', { name: 'Metadata' })).toBeHidden();
await expect.soft(page.getByTestId('metadata-error')).not.toBeVisible(); await expect.soft(page.locator('.metadata-view')).toBeHidden();
await expect.soft(page.getByTestId('metadata-chip')).not.toBeVisible();
}); });
test('should use explicitly supplied metadata', async ({ runInlineTest, showReport, page }) => { test('should show an error when metadata has invalid fields', async ({ runInlineTest, showReport, page }) => {
const result = await runInlineTest({
'uncommitted.txt': `uncommitted file`,
'playwright.config.ts': `
import { gitCommitInfo } from 'playwright/lib/plugins';
import { test, expect } from '@playwright/test';
const plugin = gitCommitInfo({
info: {
'revision.id': '1234567890',
'revision.subject': 'a better subject',
'revision.timestamp': new Date(),
'revision.author': 'William',
'revision.email': 'shakespeare@example.local',
},
});
export default { '@playwright/test': { plugins: [plugin] } };
`,
'example.spec.ts': `
import { gitCommitInfo } from 'playwright/lib/plugins';
import { test, expect } from '@playwright/test';
test('sample', async ({}) => { expect(2).toBe(2); });
`,
}, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never', GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test', GITHUB_RUN_ID: 'example-run-id', GITHUB_SERVER_URL: 'https://playwright.dev', GITHUB_SHA: 'example-sha' }, undefined);
await showReport();
expect(result.exitCode).toBe(0);
await page.click('text=a better subject');
await expect.soft(page.getByTestId('revision.id')).toContainText(/^[a-f\d]+$/i);
await expect.soft(page.getByTestId('revision.id').locator('a')).toHaveAttribute('href', 'https://playwright.dev/microsoft/playwright-example-for-test/commit/example-sha');
await expect.soft(page.getByTestId('revision.timestamp')).toContainText(/AM|PM/);
await expect.soft(page.locator('text=a better subject')).toHaveCount(2);
await expect.soft(page.locator('text=William')).toBeVisible();
await expect.soft(page.locator('text=shakespeare@example.local')).toBeVisible();
await expect.soft(page.locator('text=CI/CD Logs')).toHaveAttribute('href', 'https://playwright.dev/microsoft/playwright-example-for-test/actions/runs/example-run-id');
await expect.soft(page.locator('text=Report generated on')).toContainText(/AM|PM/);
await expect.soft(page.getByTestId('metadata-chip')).toBeVisible();
await expect.soft(page.getByTestId('metadata-error')).not.toBeVisible();
});
test('should not have metadata by default', async ({ runInlineTest, showReport, page }) => {
const result = await runInlineTest({
'uncommitted.txt': `uncommitted file`,
'playwright.config.ts': `
export default {};
`,
'example.spec.ts': `
import { test, expect } from '@playwright/test';
test('my sample test', async ({}) => { expect(2).toBe(2); });
`,
}, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' }, undefined);
await showReport();
expect(result.exitCode).toBe(0);
await expect.soft(page.locator('text="my sample test"')).toBeVisible();
await expect.soft(page.getByTestId('metadata-error')).not.toBeVisible();
await expect.soft(page.getByTestId('metadata-chip')).not.toBeVisible();
});
test('should not include metadata if user supplies invalid values via metadata field', async ({ runInlineTest, showReport, page }) => {
const result = await runInlineTest({ const result = await runInlineTest({
'uncommitted.txt': `uncommitted file`, 'uncommitted.txt': `uncommitted file`,
'playwright.config.ts': ` 'playwright.config.ts': `
export default { export default {
metadata: { metadata: {
'revision.timestamp': 'hi', 'git.commit.info': { 'revision.timestamp': 'hi' }
}, },
}; };
`, `,
@ -1292,10 +1228,10 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await showReport(); await showReport();
expect(result.exitCode).toBe(0); expect(result.exitCode).toBe(0);
await expect.soft(page.locator('text="my sample test"')).toBeVisible(); await page.getByRole('button', { name: 'Metadata' }).click();
await expect.soft(page.getByTestId('metadata-error')).toBeVisible(); await expect(page.locator('.metadata-view')).toMatchAriaSnapshot(`
await expect.soft(page.getByTestId('metadata-chip')).not.toBeVisible(); - paragraph: An error was encountered when trying to render metadata.
}); `);
}); });
test('should report clashing folders', async ({ runInlineTest, useIntermediateMergeReport }) => { test('should report clashing folders', async ({ runInlineTest, useIntermediateMergeReport }) => {

View file

@ -21,7 +21,7 @@ test('should render html report git info metadata', async ({ runUITest }) => {
'reporter.ts': ` 'reporter.ts': `
module.exports = class Reporter { module.exports = class Reporter {
onBegin(config, suite) { onBegin(config, suite) {
console.log('ci.link:', config.metadata['ci.link']); console.log('ci.link:', config.metadata['git.commit.info']['ci.link']);
} }
} }
`, `,