Skip to content

Commit 24f06ec

Browse files
authored
feat(html report): show metadata (#34517)
1 parent 6c2c902 commit 24f06ec

File tree

12 files changed

+253
-292
lines changed

12 files changed

+253
-292
lines changed

docs/src/test-api/class-testconfig.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,15 +234,17 @@ export default defineConfig({
234234
* since: v1.10
235235
- type: ?<[Metadata]>
236236

237-
Metadata that will be put directly to the test report serialized as JSON.
237+
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.
238+
239+
See also [`property: TestConfig.populateGitInfo`] that populates metadata.
238240

239241
**Usage**
240242

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

244246
export default defineConfig({
245-
metadata: 'acceptance tests',
247+
metadata: { title: 'acceptance tests' },
246248
});
247249
```
248250

@@ -325,7 +327,9 @@ This path will serve as the base directory for each test file snapshot directory
325327
* since: v1.51
326328
- type: ?<[boolean]>
327329

328-
Whether to populate [`property: TestConfig.metadata`] with Git info. The metadata will automatically appear in the HTML report and is available in Reporter API.
330+
Whether to populate `'git.commit.info'` field of the [`property: TestConfig.metadata`] with Git commit info and CI/CD information.
331+
332+
This information will appear in the HTML and JSON reports and is available in the Reporter API.
329333

330334
**Usage**
331335

@@ -647,7 +651,7 @@ export default defineConfig({
647651
- `timeout` ?<[int]> How long to wait for the process to start up and be available in milliseconds. Defaults to 60000.
648652
- `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`.
649653
- `signal` <["SIGINT"|"SIGTERM"]>
650-
- `timeout` <[int]>
654+
- `timeout` <[int]>
651655
- `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.
652656

653657
Launch a development web server (or multiple) during the tests.

packages/html-reporter/src/icons.tsx

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -69,22 +69,6 @@ export const blank = () => {
6969
return <svg className='octicon' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'></svg>;
7070
};
7171

72-
export const externalLink = () => {
73-
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>;
74-
};
75-
76-
export const calendar = () => {
77-
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>;
78-
};
79-
80-
export const person = () => {
81-
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>;
82-
};
83-
84-
export const commit = () => {
85-
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>;
86-
};
87-
8872
export const image = () => {
8973
return <svg className='octicon' viewBox='0 0 48 48' version='1.1' width='20' height='20' aria-hidden='true'>
9074
<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'/>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
Copyright (c) Microsoft Corporation.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
.metadata-toggle {
18+
cursor: pointer;
19+
user-select: none;
20+
margin-left: 5px;
21+
}
22+
23+
.metadata-view {
24+
border: 1px solid var(--color-border-default);
25+
border-radius: 6px;
26+
margin-top: 8px;
27+
}
28+
29+
.metadata-separator {
30+
height: 1px;
31+
border-bottom: 1px solid var(--color-border-default);
32+
}
33+
34+
.metadata-view .copy-value-container {
35+
margin-top: -2px;
36+
}
37+
38+
.git-commit-info a {
39+
color: var(--color-fg-default);
40+
font-weight: 600;
41+
}

packages/html-reporter/src/metadataView.tsx

Lines changed: 54 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -17,21 +17,19 @@
1717
import * as React from 'react';
1818
import './colors.css';
1919
import './common.css';
20-
import * as icons from './icons';
21-
import { AutoChip } from './chip';
22-
import './reportView.css';
2320
import './theme.css';
21+
import './metadataView.css';
22+
import type { Metadata } from '@playwright/test';
23+
import type { GitCommitInfo } from '@testIsomorphic/types';
24+
import { CopyToClipboardContainer } from './copyToClipboard';
25+
import { linkifyText } from '@web/renderUtils';
2426

25-
export type Metainfo = {
26-
'revision.id'?: string;
27-
'revision.author'?: string;
28-
'revision.email'?: string;
29-
'revision.subject'?: string;
30-
'revision.timestamp'?: number | Date;
31-
'revision.link'?: string;
32-
'ci.link'?: string;
33-
'timestamp'?: number
34-
};
27+
type MetadataEntries = [string, unknown][];
28+
29+
export function filterMetadata(metadata: Metadata): MetadataEntries {
30+
// TODO: do not plumb actualWorkers through metadata.
31+
return Object.entries(metadata).filter(([key]) => key !== 'actualWorkers');
32+
}
3533

3634
class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error: Error | null, errorInfo: React.ErrorInfo | null }> {
3735
override state: { error: Error | null, errorInfo: React.ErrorInfo | null } = {
@@ -46,92 +44,63 @@ class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error
4644
override render() {
4745
if (this.state.error || this.state.errorInfo) {
4846
return (
49-
<AutoChip header={'Commit Metainfo Error'} dataTestId='metadata-error'>
50-
<p>An error was encountered when trying to render Commit Metainfo. Please file a GitHub issue to report this error.</p>
47+
<div className='metadata-view p-3'>
48+
<p>An error was encountered when trying to render metadata.</p>
5149
<p>
5250
<pre style={{ overflow: 'scroll' }}>{this.state.error?.message}<br/>{this.state.error?.stack}<br/>{this.state.errorInfo?.componentStack}</pre>
5351
</p>
54-
</AutoChip>
52+
</div>
5553
);
5654
}
5755

5856
return this.props.children;
5957
}
6058
}
6159

62-
export const MetadataView: React.FC<Metainfo> = metadata => <ErrorBoundary><InnerMetadataView {...metadata} /></ErrorBoundary>;
60+
export const MetadataView: React.FC<{ metadataEntries: MetadataEntries }> = ({ metadataEntries }) => {
61+
return <ErrorBoundary><InnerMetadataView metadataEntries={metadataEntries}/></ErrorBoundary>;
62+
};
6363

64-
const InnerMetadataView: React.FC<Metainfo> = metadata => {
65-
if (!Object.keys(metadata).find(k => k.startsWith('revision.') || k.startsWith('ci.')))
64+
const InnerMetadataView: React.FC<{ metadataEntries: MetadataEntries }> = ({ metadataEntries }) => {
65+
const gitCommitInfo = metadataEntries.find(([key]) => key === 'git.commit.info')?.[1] as GitCommitInfo | undefined;
66+
const entries = metadataEntries.filter(([key]) => key !== 'git.commit.info');
67+
if (!gitCommitInfo && !entries.length)
6668
return null;
67-
68-
return (
69-
<AutoChip header={
70-
<span>
71-
{metadata['revision.id'] && <span style={{ float: 'right' }}>
72-
{metadata['revision.id'].slice(0, 7)}
73-
</span>}
74-
{metadata['revision.subject'] || 'Commit Metainfo'}
75-
</span>} initialExpanded={false} dataTestId='metadata-chip'>
76-
{metadata['revision.subject'] &&
77-
<MetadataViewItem
78-
testId='revision.subject'
79-
content={<span>{metadata['revision.subject']}</span>}
80-
/>
81-
}
82-
{metadata['revision.id'] &&
83-
<MetadataViewItem
84-
testId='revision.id'
85-
content={<span>{metadata['revision.id']}</span>}
86-
href={metadata['revision.link']}
87-
icon='commit'
88-
/>
89-
}
90-
{(metadata['revision.author'] || metadata['revision.email']) &&
91-
<MetadataViewItem
92-
content={`${metadata['revision.author']} ${metadata['revision.email']}`}
93-
icon='person'
94-
/>
95-
}
96-
{metadata['revision.timestamp'] &&
97-
<MetadataViewItem
98-
testId='revision.timestamp'
99-
content={
100-
<>
101-
{Intl.DateTimeFormat(undefined, { dateStyle: 'full' }).format(metadata['revision.timestamp'])}
102-
{' '}
103-
{Intl.DateTimeFormat(undefined, { timeStyle: 'long' }).format(metadata['revision.timestamp'])}
104-
</>
105-
}
106-
icon='calendar'
107-
/>
108-
}
109-
{metadata['ci.link'] &&
110-
<MetadataViewItem
111-
content='CI/CD Logs'
112-
href={metadata['ci.link']}
113-
icon='externalLink'
114-
/>
115-
}
116-
{metadata['timestamp'] &&
117-
<MetadataViewItem
118-
content={<span style={{ color: 'var(--color-fg-subtle)' }}>
119-
Report generated on {Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(metadata['timestamp'])}
120-
</span>}></MetadataViewItem>
121-
}
122-
</AutoChip>
123-
);
69+
return <div className='metadata-view'>
70+
{gitCommitInfo && <>
71+
<GitCommitInfoView info={gitCommitInfo}/>
72+
{entries.length > 0 && <div className='metadata-separator' />}
73+
</>}
74+
{entries.map(([key, value]) => {
75+
const valueString = typeof value !== 'object' || value === null || value === undefined ? String(value) : JSON.stringify(value);
76+
const trimmedValue = valueString.length > 1000 ? valueString.slice(0, 1000) + '\u2026' : valueString;
77+
return <div className='m-1 ml-5' key={key}>
78+
<span style={{ fontWeight: 'bold' }} title={key}>{key}</span>
79+
{valueString && <CopyToClipboardContainer value={valueString}>: <span title={trimmedValue}>{linkifyText(trimmedValue)}</span></CopyToClipboardContainer>}
80+
</div>;
81+
})}
82+
</div>;
12483
};
12584

126-
const MetadataViewItem: React.FC<{ content: JSX.Element | string; icon?: keyof typeof icons, href?: string, testId?: string }> = ({ content, icon, href, testId }) => {
127-
return (
128-
<div className='my-1 hbox' data-testid={testId} >
129-
<div className='mr-2'>
130-
{icons[icon || 'blank']()}
131-
</div>
132-
<div style={{ flex: 1 }}>
133-
{href ? <a href={href} target='_blank' rel='noopener noreferrer'>{content}</a> : content}
85+
const GitCommitInfoView: React.FC<{ info: GitCommitInfo }> = ({ info }) => {
86+
const email = info['revision.email'] ? ` <${info['revision.email']}>` : '';
87+
const author = `${info['revision.author'] || ''}${email}`;
88+
const shortTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(info['revision.timestamp']);
89+
const longTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(info['revision.timestamp']);
90+
return <div className='hbox pl-4 pr-2 git-commit-info' style={{ alignItems: 'center' }}>
91+
<div className='vbox'>
92+
<a className='m-2' href={info['revision.link']} target='_blank' rel='noopener noreferrer'>
93+
<span title={info['revision.subject'] || ''}>{info['revision.subject'] || ''}</span>
94+
</a>
95+
<div className='hbox m-2 mt-1'>
96+
<div className='mr-1'>{author}</div>
97+
<div title={longTimestamp}> on {shortTimestamp}</div>
98+
{info['ci.link'] && <><span className='mx-2'>·</span><a href={info['ci.link']} target='_blank' rel='noopener noreferrer' title='CI/CD logs'>logs</a></>}
13499
</div>
135100
</div>
136-
);
101+
{!!info['revision.link'] && <a href={info['revision.link']} target='_blank' rel='noopener noreferrer'>
102+
<span title='View commit details'>{info['revision.id']?.slice(0, 7) || 'unknown'}</span>
103+
</a>}
104+
{!info['revision.link'] && !!info['revision.id'] && <span>{info['revision.id'].slice(0, 7)}</span>}
105+
</div>;
137106
};

packages/html-reporter/src/reportView.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@ import { HeaderView } from './headerView';
2323
import { Route, SearchParamsContext } from './links';
2424
import type { LoadedReport } from './loadedReport';
2525
import './reportView.css';
26-
import type { Metainfo } from './metadataView';
27-
import { MetadataView } from './metadataView';
2826
import { TestCaseView } from './testCaseView';
2927
import { TestFilesHeader, TestFilesView } from './testFilesView';
3028
import './theme.css';
@@ -50,6 +48,7 @@ export const ReportView: React.FC<{
5048
const searchParams = React.useContext(SearchParamsContext);
5149
const [expandedFiles, setExpandedFiles] = React.useState<Map<string, boolean>>(new Map());
5250
const [filterText, setFilterText] = React.useState(searchParams.get('q') || '');
51+
const [metadataVisible, setMetadataVisible] = React.useState(false);
5352

5453
const testIdToFileIdMap = React.useMemo(() => {
5554
const map = new Map<string, string>();
@@ -76,9 +75,8 @@ export const ReportView: React.FC<{
7675
return <div className='htmlreport vbox px-4 pb-4'>
7776
<main>
7877
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
79-
{report?.json().metadata && <MetadataView {...report?.json().metadata as Metainfo} />}
8078
<Route predicate={testFilesRoutePredicate}>
81-
<TestFilesHeader report={report?.json()} filteredStats={filteredStats} />
79+
<TestFilesHeader report={report?.json()} filteredStats={filteredStats} metadataVisible={metadataVisible} toggleMetadataVisible={() => setMetadataVisible(visible => !visible)}/>
8280
<TestFilesView
8381
tests={filteredTests.files}
8482
expandedFiles={expandedFiles}

packages/html-reporter/src/testFilesView.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import './testFileView.css';
2121
import { msToString } from './utils';
2222
import { AutoChip } from './chip';
2323
import { TestErrorView } from './testErrorView';
24+
import * as icons from './icons';
25+
import { filterMetadata, MetadataView } from './metadataView';
2426

2527
export const TestFilesView: React.FC<{
2628
tests: TestFileSummary[],
@@ -62,17 +64,24 @@ export const TestFilesView: React.FC<{
6264
export const TestFilesHeader: React.FC<{
6365
report: HTMLReport | undefined,
6466
filteredStats?: FilteredStats,
65-
}> = ({ report, filteredStats }) => {
67+
metadataVisible: boolean,
68+
toggleMetadataVisible: () => void,
69+
}> = ({ report, filteredStats, metadataVisible, toggleMetadataVisible }) => {
6670
if (!report)
6771
return;
72+
const metadataEntries = filterMetadata(report.metadata || {});
6873
return <>
69-
<div className='mt-2 mx-1' style={{ display: 'flex' }}>
74+
<div className='mx-1' style={{ display: 'flex', marginTop: 10 }}>
75+
{metadataEntries.length > 0 && <div className='metadata-toggle' role='button' onClick={toggleMetadataVisible} title={metadataVisible ? 'Hide metadata' : 'Show metadata'}>
76+
{metadataVisible ? icons.downArrow() : icons.rightArrow()}Metadata
77+
</div>}
7078
{report.projectNames.length === 1 && !!report.projectNames[0] && <div data-testid='project-name' style={{ color: 'var(--color-fg-subtle)' }}>Project: {report.projectNames[0]}</div>}
7179
{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>}
7280
<div style={{ flex: 'auto' }}></div>
7381
<div data-testid='overall-time' style={{ color: 'var(--color-fg-subtle)', marginRight: '10px' }}>{report ? new Date(report.startTime).toLocaleString() : ''}</div>
7482
<div data-testid='overall-duration' style={{ color: 'var(--color-fg-subtle)' }}>Total time: {msToString(report.duration ?? 0)}</div>
7583
</div>
84+
{metadataVisible && <MetadataView metadataEntries={metadataEntries}/>}
7685
{!!report.errors.length && <AutoChip header='Errors' dataTestId='report-errors'>
7786
{report.errors.map((error, index) => <TestErrorView key={'test-report-error-message-' + index} error={error}></TestErrorView>)}
7887
</AutoChip>}

packages/html-reporter/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@protocol/*": ["../protocol/src/*"],
2121
"@web/*": ["../web/src/*"],
2222
"@playwright/*": ["../playwright/src/*"],
23+
"@testIsomorphic/*": ["../playwright/src/isomorphic/*"],
2324
"playwright-core/lib/*": ["../playwright-core/src/*"],
2425
"playwright/lib/*": ["../playwright/src/*"],
2526
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export interface GitCommitInfo {
18+
'revision.id'?: string;
19+
'revision.author'?: string;
20+
'revision.email'?: string;
21+
'revision.subject'?: string;
22+
'revision.timestamp'?: number | Date;
23+
'revision.link'?: string;
24+
'ci.link'?: string;
25+
}

0 commit comments

Comments
 (0)