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
- 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**
@ -242,7 +244,7 @@ Metadata that will be put directly to the test report serialized as JSON.
import { defineConfig } from '@playwright/test';
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
- 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**

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>;
};
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 = () => {
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'/>

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 './colors.css';
import './common.css';
import * as icons from './icons';
import { AutoChip } from './chip';
import './reportView.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 = {
'revision.id'?: string;
'revision.author'?: string;
'revision.email'?: string;
'revision.subject'?: string;
'revision.timestamp'?: number | Date;
'revision.link'?: string;
'ci.link'?: string;
'timestamp'?: number
};
type MetadataEntries = [string, unknown][];
export function filterMetadata(metadata: Metadata): MetadataEntries {
// TODO: do not plumb actualWorkers through metadata.
return Object.entries(metadata).filter(([key]) => key !== 'actualWorkers');
}
class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { 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() {
if (this.state.error || this.state.errorInfo) {
return (
<AutoChip header={'Commit Metainfo Error'} dataTestId='metadata-error'>
<p>An error was encountered when trying to render Commit Metainfo. Please file a GitHub issue to report this error.</p>
<div className='metadata-view p-3'>
<p>An error was encountered when trying to render metadata.</p>
<p>
<pre style={{ overflow: 'scroll' }}>{this.state.error?.message}<br/>{this.state.error?.stack}<br/>{this.state.errorInfo?.componentStack}</pre>
</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 => {
if (!Object.keys(metadata).find(k => k.startsWith('revision.') || k.startsWith('ci.')))
const InnerMetadataView: React.FC<{ metadataEntries: MetadataEntries }> = ({ metadataEntries }) => {
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 (
<AutoChip header={
<span>
{metadata['revision.id'] && <span style={{ float: 'right' }}>
{metadata['revision.id'].slice(0, 7)}
</span>}
{metadata['revision.subject'] || 'Commit Metainfo'}
</span>} initialExpanded={false} dataTestId='metadata-chip'>
{metadata['revision.subject'] &&
<MetadataViewItem
testId='revision.subject'
content={<span>{metadata['revision.subject']}</span>}
/>
}
{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>
);
return <div className='metadata-view'>
{gitCommitInfo && <>
<GitCommitInfoView info={gitCommitInfo}/>
{entries.length > 0 && <div className='metadata-separator' />}
</>}
{entries.map(([key, value]) => {
const valueString = typeof value !== 'object' || value === null || value === undefined ? String(value) : JSON.stringify(value);
const trimmedValue = valueString.length > 1000 ? valueString.slice(0, 1000) + '\u2026' : valueString;
return <div className='m-1 ml-5' key={key}>
<span style={{ fontWeight: 'bold' }} title={key}>{key}</span>
{valueString && <CopyToClipboardContainer value={valueString}>: <span title={trimmedValue}>{linkifyText(trimmedValue)}</span></CopyToClipboardContainer>}
</div>;
})}
</div>;
};
const MetadataViewItem: React.FC<{ content: JSX.Element | string; icon?: keyof typeof icons, href?: string, testId?: string }> = ({ content, icon, href, testId }) => {
return (
<div className='my-1 hbox' data-testid={testId} >
<div className='mr-2'>
{icons[icon || 'blank']()}
</div>
<div style={{ flex: 1 }}>
{href ? <a href={href} target='_blank' rel='noopener noreferrer'>{content}</a> : content}
const GitCommitInfoView: React.FC<{ info: GitCommitInfo }> = ({ info }) => {
const email = info['revision.email'] ? ` <${info['revision.email']}>` : '';
const author = `${info['revision.author'] || ''}${email}`;
const shortTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(info['revision.timestamp']);
const longTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(info['revision.timestamp']);
return <div className='hbox pl-4 pr-2 git-commit-info' style={{ alignItems: 'center' }}>
<div className='vbox'>
<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>
);
{!!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 type { LoadedReport } from './loadedReport';
import './reportView.css';
import type { Metainfo } from './metadataView';
import { MetadataView } from './metadataView';
import { TestCaseView } from './testCaseView';
import { TestFilesHeader, TestFilesView } from './testFilesView';
import './theme.css';
@ -50,6 +48,7 @@ export const ReportView: React.FC<{
const searchParams = React.useContext(SearchParamsContext);
const [expandedFiles, setExpandedFiles] = React.useState<Map<string, boolean>>(new Map());
const [filterText, setFilterText] = React.useState(searchParams.get('q') || '');
const [metadataVisible, setMetadataVisible] = React.useState(false);
const testIdToFileIdMap = React.useMemo(() => {
const map = new Map<string, string>();
@ -76,9 +75,8 @@ export const ReportView: React.FC<{
return <div className='htmlreport vbox px-4 pb-4'>
<main>
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
{report?.json().metadata && <MetadataView {...report?.json().metadata as Metainfo} />}
<Route predicate={testFilesRoutePredicate}>
<TestFilesHeader report={report?.json()} filteredStats={filteredStats} />
<TestFilesHeader report={report?.json()} filteredStats={filteredStats} metadataVisible={metadataVisible} toggleMetadataVisible={() => setMetadataVisible(visible => !visible)}/>
<TestFilesView
tests={filteredTests.files}
expandedFiles={expandedFiles}

View file

@ -21,6 +21,8 @@ import './testFileView.css';
import { msToString } from './utils';
import { AutoChip } from './chip';
import { TestErrorView } from './testErrorView';
import * as icons from './icons';
import { filterMetadata, MetadataView } from './metadataView';
export const TestFilesView: React.FC<{
tests: TestFileSummary[],
@ -62,17 +64,24 @@ export const TestFilesView: React.FC<{
export const TestFilesHeader: React.FC<{
report: HTMLReport | undefined,
filteredStats?: FilteredStats,
}> = ({ report, filteredStats }) => {
metadataVisible: boolean,
toggleMetadataVisible: () => void,
}> = ({ report, filteredStats, metadataVisible, toggleMetadataVisible }) => {
if (!report)
return;
const metadataEntries = filterMetadata(report.metadata || {});
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>}
{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 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>
{metadataVisible && <MetadataView metadataEntries={metadataEntries}/>}
{!!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

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

View file

@ -1220,7 +1220,12 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
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**
*
@ -1229,7 +1234,7 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
* import { defineConfig } from '@playwright/test';
*
* export default defineConfig({
* metadata: 'acceptance tests',
* metadata: { title: 'acceptance tests' },
* });
* ```
*
@ -1294,8 +1299,11 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
outputDir?: string;
/**
* Whether to populate [testConfig.metadata](https://playwright.dev/docs/api/class-testconfig#test-config-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
* [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**
*

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 }) => {
const files = {
'uncommitted.txt': `uncommitted file`,
'playwright.config.ts': `
import { test, expect } from '@playwright/test';
export default { populateGitInfo: true };
export default {
populateGitInfo: true,
metadata: { foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] }
};
`,
'example.spec.ts': `
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.name', 'William']);
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' }, {
PLAYWRIGHT_HTML_OPEN: 'never',
@ -1180,22 +1181,18 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await showReport();
expect(result.exitCode).toBe(0);
await page.click('text=awesome commit message');
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=awesome commit message')).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();
await page.getByRole('button', { name: 'Metadata' }).click();
await expect(page.locator('.metadata-view')).toMatchAriaSnapshot(`
- 'link "chore(html): make this test look nice"'
- text: /^William <shakespeare@example.local> on/
- link "logs"
- link /^[a-f0-9]{7}$/
- text: 'foo: value1 bar: {"prop":"value2"} baz: ["value3",123]'
`);
});
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({
'uncommitted.txt': `uncommitted file`,
'playwright.config.ts': `
export default { populateGitInfo: false };
`,
@ -1208,78 +1205,17 @@ for (const useIntermediateMergeReport of [true, false] as const) {
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();
await expect.soft(page.getByRole('button', { name: 'Metadata' })).toBeHidden();
await expect.soft(page.locator('.metadata-view')).toBeHidden();
});
test('should use explicitly supplied metadata', 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 }) => {
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': `
export default {
metadata: {
'revision.timestamp': 'hi',
'git.commit.info': { 'revision.timestamp': 'hi' }
},
};
`,
@ -1292,10 +1228,10 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await showReport();
expect(result.exitCode).toBe(0);
await expect.soft(page.locator('text="my sample test"')).toBeVisible();
await expect.soft(page.getByTestId('metadata-error')).toBeVisible();
await expect.soft(page.getByTestId('metadata-chip')).not.toBeVisible();
});
await page.getByRole('button', { name: 'Metadata' }).click();
await expect(page.locator('.metadata-view')).toMatchAriaSnapshot(`
- paragraph: An error was encountered when trying to render metadata.
`);
});
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': `
module.exports = class Reporter {
onBegin(config, suite) {
console.log('ci.link:', config.metadata['ci.link']);
console.log('ci.link:', config.metadata['git.commit.info']['ci.link']);
}
}
`,