feat(html report): show metadata (#34517)
This commit is contained in:
parent
6c2c90203e
commit
24f06ec1bf
|
@ -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**
|
||||
|
||||
|
@ -647,7 +651,7 @@ export default defineConfig({
|
|||
- `timeout` ?<[int]> How long to wait for the process to start up and be available in milliseconds. Defaults to 60000.
|
||||
- `gracefulShutdown` ?<[Object]> How to shut down the process. If unspecified, the process group is forcefully `SIGKILL`ed. If set to `{ signal: 'SIGTERM', timeout: 500 }`, the process group is sent a `SIGTERM` signal, followed by `SIGKILL` if it doesn't exit within 500ms. You can also use `SIGINT` as the signal instead. A `0` timeout means no `SIGKILL` will be sent. Windows doesn't support `SIGTERM` and `SIGINT` signals, so this option is ignored on Windows. Note that shutting down a Docker container requires `SIGTERM`.
|
||||
- `signal` <["SIGINT"|"SIGTERM"]>
|
||||
- `timeout` <[int]>
|
||||
- `timeout` <[int]>
|
||||
- `url` ?<[string]> The url on your http server that is expected to return a 2xx, 3xx, 400, 401, 402, or 403 status code when the server is ready to accept connections. Redirects (3xx status codes) are being followed and the new location is checked. Either `port` or `url` should be specified.
|
||||
|
||||
Launch a development web server (or multiple) during the tests.
|
||||
|
|
|
@ -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'/>
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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>;
|
||||
|
||||
const InnerMetadataView: React.FC<Metainfo> = metadata => {
|
||||
if (!Object.keys(metadata).find(k => k.startsWith('revision.') || k.startsWith('ci.')))
|
||||
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>
|
||||
);
|
||||
export const MetadataView: React.FC<{ metadataEntries: MetadataEntries }> = ({ metadataEntries }) => {
|
||||
return <ErrorBoundary><InnerMetadataView metadataEntries={metadataEntries}/></ErrorBoundary>;
|
||||
};
|
||||
|
||||
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 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 <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 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>;
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>}
|
||||
|
|
|
@ -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/*"],
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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 {
|
||||
directory?: string;
|
||||
info?: Info;
|
||||
interface GitCommitInfoPluginOptions {
|
||||
directory?: string;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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**
|
||||
*
|
||||
|
|
|
@ -978,8 +978,8 @@ for (const useIntermediateMergeReport of [true, false] as const) {
|
|||
|
||||
await test.step('step', async () => {
|
||||
testInfo.attachments.push({ name: 'attachment', body: 'content', contentType: 'text/plain' });
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
});
|
||||
`,
|
||||
}, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' });
|
||||
|
@ -1095,7 +1095,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
|
|||
const result = await runInlineTest({
|
||||
'a.spec.js': `
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
|
||||
|
||||
const test = base.extend({
|
||||
fixture1: [async ({}, use) => {
|
||||
await use();
|
||||
|
@ -1141,161 +1141,97 @@ 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 };
|
||||
`,
|
||||
'example.spec.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('sample', async ({}) => { expect(2).toBe(2); });
|
||||
`,
|
||||
};
|
||||
const baseDir = await writeFiles(files);
|
||||
test('should include metadata with populateGitInfo = true', async ({ runInlineTest, writeFiles, showReport, page }) => {
|
||||
const files = {
|
||||
'uncommitted.txt': `uncommitted file`,
|
||||
'playwright.config.ts': `
|
||||
export default {
|
||||
populateGitInfo: true,
|
||||
metadata: { foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] }
|
||||
};
|
||||
`,
|
||||
'example.spec.ts': `
|
||||
import { test, expect } from '@playwright/test';
|
||||
test('sample', async ({}) => { expect(2).toBe(2); });
|
||||
`,
|
||||
};
|
||||
const baseDir = await writeFiles(files);
|
||||
|
||||
const execGit = async (args: string[]) => {
|
||||
const { code, stdout, stderr } = await spawnAsync('git', args, { stdio: 'pipe', cwd: baseDir });
|
||||
if (!!code)
|
||||
throw new Error(`Non-zero exit of:\n$ git ${args.join(' ')}\nConsole:\nstdout:\n${stdout}\n\nstderr:\n${stderr}\n\n`);
|
||||
return;
|
||||
};
|
||||
const execGit = async (args: string[]) => {
|
||||
const { code, stdout, stderr } = await spawnAsync('git', args, { stdio: 'pipe', cwd: baseDir });
|
||||
if (!!code)
|
||||
throw new Error(`Non-zero exit of:\n$ git ${args.join(' ')}\nConsole:\nstdout:\n${stdout}\n\nstderr:\n${stderr}\n\n`);
|
||||
return;
|
||||
};
|
||||
|
||||
await execGit(['init']);
|
||||
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(['init']);
|
||||
await execGit(['config', '--local', 'user.email', 'shakespeare@example.local']);
|
||||
await execGit(['config', '--local', 'user.name', 'William']);
|
||||
await execGit(['add', '*.ts']);
|
||||
await execGit(['commit', '-m', 'chore(html): make this test look nice']);
|
||||
|
||||
const result = await runInlineTest(files, { 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',
|
||||
});
|
||||
|
||||
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();
|
||||
const result = await runInlineTest(files, { 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',
|
||||
});
|
||||
|
||||
test('should not include metadata with populateGitInfo = false', async ({ runInlineTest, showReport, page }) => {
|
||||
const result = await runInlineTest({
|
||||
'uncommitted.txt': `uncommitted file`,
|
||||
'playwright.config.ts': `
|
||||
export default { populateGitInfo: false };
|
||||
`,
|
||||
'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();
|
||||
|
||||
await showReport();
|
||||
expect(result.exitCode).toBe(0);
|
||||
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]'
|
||||
`);
|
||||
});
|
||||
|
||||
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 git metadata with populateGitInfo = false', async ({ runInlineTest, showReport, page }) => {
|
||||
const result = await runInlineTest({
|
||||
'playwright.config.ts': `
|
||||
export default { populateGitInfo: false };
|
||||
`,
|
||||
'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);
|
||||
|
||||
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();
|
||||
|
||||
await showReport();
|
||||
expect(result.exitCode).toBe(0);
|
||||
await expect.soft(page.getByRole('button', { name: 'Metadata' })).toBeHidden();
|
||||
await expect.soft(page.locator('.metadata-view')).toBeHidden();
|
||||
});
|
||||
|
||||
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 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: {
|
||||
'git.commit.info': { 'revision.timestamp': 'hi' }
|
||||
},
|
||||
};
|
||||
`,
|
||||
'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' });
|
||||
|
||||
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();
|
||||
|
||||
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({
|
||||
'uncommitted.txt': `uncommitted file`,
|
||||
'playwright.config.ts': `
|
||||
export default {
|
||||
metadata: {
|
||||
'revision.timestamp': 'hi',
|
||||
},
|
||||
};
|
||||
`,
|
||||
'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' });
|
||||
|
||||
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();
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
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 }) => {
|
||||
|
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
`,
|
||||
|
|
Loading…
Reference in New Issue