Skip to content

Commit

Permalink
feat(html report): show metadata
Browse files Browse the repository at this point in the history
  • Loading branch information
dgozman committed Jan 28, 2025
1 parent 61d595a commit 230708a
Show file tree
Hide file tree
Showing 9 changed files with 224 additions and 267 deletions.
12 changes: 8 additions & 4 deletions docs/src/test-api/class-testconfig.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,15 +234,17 @@ 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**

```js title="playwright.config.ts"
import { defineConfig } from '@playwright/test';

export default defineConfig({
metadata: 'acceptance tests',
metadata: { title: 'acceptance tests' },
});
```

Expand Down Expand Up @@ -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**

Expand Down Expand Up @@ -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.
Expand Down
41 changes: 41 additions & 0 deletions packages/html-reporter/src/metadataView.css
Original file line number Diff line number Diff line change
@@ -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;
}
136 changes: 51 additions & 85 deletions packages/html-reporter/src/metadataView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 '@playwright/plugins/gitCommitInfoPlugin';
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, any][];

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 } = {
Expand All @@ -46,92 +44,60 @@ 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>
);
}

return this.props.children;
}
}

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(e => e[0] === '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}/>}
{gitCommitInfo && entries.length > 0 && <div className='metadata-separator' />}
{entries.map(([key, value]) => {
let valueString = typeof value !== 'object' || value === null ? String(value) : JSON.stringify(value);
valueString = valueString.slice(0, 1000);
return <div className='m-1 ml-5' key={key}>
<span style={{ fontWeight: 'bold' }} title={key}>{key}</span>
{valueString && <CopyToClipboardContainer value={valueString}>: <span title={valueString}>{linkifyText(valueString)}</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'>logs</a></>}
</div>
</div>
);
<a href={info['revision.link']} target='_blank' rel='noopener noreferrer'>
<span title='View commit details'>{(info['revision.id'] || '').slice(0, 7)}</span>
</a>
</div>;
};
6 changes: 2 additions & 4 deletions packages/html-reporter/src/reportView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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>();
Expand All @@ -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(!metadataVisible)}/>
<TestFilesView
tests={filteredTests.files}
expandedFiles={expandedFiles}
Expand Down
13 changes: 11 additions & 2 deletions packages/html-reporter/src/testFilesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand Down Expand Up @@ -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>}
Expand Down
31 changes: 13 additions & 18 deletions packages/playwright/src/plugins/gitCommitInfoPlugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,28 +31,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 {
export interface GitCommitInfo {
'revision.id'?: string;
'revision.author'?: string;
'revision.email'?: string;
Expand All @@ -62,7 +57,7 @@ export interface Info {
'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)
Expand All @@ -78,9 +73,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',
Expand All @@ -101,4 +96,4 @@ export const gitStatusFromCLI = async (gitDir: string): Promise<Info | undefined
'revision.subject': subject,
'revision.timestamp': timestamp,
};
};
}
Loading

0 comments on commit 230708a

Please sign in to comment.