feat: rewrite gitCommitInfo plugin, drop GlobalInfo & attachments (#13837)

This commit is contained in:
Ross Wollman 2022-05-02 16:28:14 -07:00 committed by GitHub
parent ed344a882b
commit 3b3cad7d69
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 350 additions and 732 deletions

View file

@ -1,131 +0,0 @@
# class: GlobalInfo
* langs: js
`GlobalInfo` contains information on the overall test run. The information spans projects and tests. Some reporters show global info.
You can write to GlobalInfo via your Global Setup hook, and read from it in a [Custom Reporter](../test-reporters.md):
```js js-flavor=js
// global-setup.js
module.exports = async (config, info) => {
await info.attach('agent.config.txt', { path: './agent.config.txt' });
};
```
```js js-flavor=ts
// global-setup.ts
import { chromium, FullConfig, GlobalInfo } from '@playwright/test';
async function globalSetup(config: FullConfig, info: GlobalInfo) {
await info.attach('agent.config.txt', { path: './agent.config.txt' });
}
export default globalSetup;
```
Access the attachments from the Root Suite in the Reporter:
```js js-flavor=js
// my-awesome-reporter.js
// @ts-check
/** @implements {import('@playwright/test/reporter').Reporter} */
class MyReporter {
onBegin(config, suite) {
this._suite = suite;
}
onEnd(result) {
console.log(`Finished the run with ${this._suite.attachments.length} global attachments!`);
}
}
module.exports = MyReporter;
```
```js js-flavor=ts
// my-awesome-reporter.ts
import { Reporter } from '@playwright/test/reporter';
class MyReporter implements Reporter {
private _suite;
onBegin(config, suite) {
this._suite = suite;
}
onEnd(result) {
console.log(`Finished the run with ${this._suite.attachments.length} global attachments!`);
}
}
export default MyReporter;
```
Finally, specify `globalSetup` in the configuration file and `reporter`:
```js js-flavor=js
// playwright.config.js
// @ts-check
/** @type {import('@playwright/test').PlaywrightTestConfig} */
const config = {
globalSetup: require.resolve('./global-setup'),
reporter: require.resolve('./my-awesome-reporter'),
};
module.exports = config;
```
```js js-flavor=ts
// playwright.config.ts
import { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
globalSetup: require.resolve('./global-setup'),
reporter: require.resolve('./my-awesome-reporter'),
};
export default config;
```
See [`TestInfo`](./class-testinfo.md) for related attachment functionality scoped to the test-level.
## method: GlobalInfo.attachments
- type: <[Array]<[Object]>>
- `name` <[string]> Attachment name.
- `contentType` <[string]> Content type of this attachment to properly present in the report, for example `'application/json'` or `'image/png'`.
- `path` ?<[string]> Optional path on the filesystem to the attached file.
- `body` ?<[Buffer]> Optional attachment body used instead of a file.
The list of files or buffers attached to the overall test run. Some reporters show global attachments.
To add an attachment, use [`method: GlobalInfo.attach`]. See [`property: TestInfo.attachments`] if you are looking for test-scoped attachments.
## async method: GlobalInfo.attach
Attach a value or a file from disk to the overall test run. Some reporters show global attachments. Either [`option: path`] or [`option: body`] must be specified, but not both.
See [`method: TestInfo.attach`] if you are looking for test-scoped attachments.
:::note
[`method: GlobalInfo.attach`] automatically takes care of copying attached files to a
location that is accessible to reporters. You can safely remove the attachment
after awaiting the attach call.
:::
### param: GlobalInfo.attach.name
- `name` <[string]>
Attachment name.
### option: GlobalInfo.attach.body
- `body` ?<[string]|[Buffer]>
Attachment body. Mutually exclusive with [`option: path`].
### option: GlobalInfo.attach.contentType
- `contentType` ?<[string]>
Optional content type of this attachment to properly present in the report, for example `'application/json'` or `'image/png'`. If omitted, content type is inferred based on the [`option: path`], or defaults to `text/plain` for [string] attachments and `application/octet-stream` for [Buffer] attachments.
### option: GlobalInfo.attach.path
- `path` ?<[string]>
Path on the filesystem to the attached file. Mutually exclusive with [`option: body`].

View file

@ -249,9 +249,9 @@ export default config;
``` ```
## property: TestConfig.metadata ## property: TestConfig.metadata
- type: ?<[any]> - type: ?<[Metadata]>
Any JSON-serializable metadata that will be put directly to the test report. Metadata that will be put directly to the test report serialized as JSON.
## property: TestConfig.name ## property: TestConfig.name
- type: ?<[string]> - type: ?<[string]>

View file

@ -63,12 +63,3 @@ Suite title.
- returns: <[Array]<[string]>> - returns: <[Array]<[string]>>
Returns a list of titles from the root down to this suite. Returns a list of titles from the root down to this suite.
## property: Suite.attachments
- type: <[Array]<[Object]>>
- `name` <[string]> Attachment name.
- `contentType` <[string]> Content type of this attachment to properly present in the report, for example `'application/json'` or `'image/png'`.
- `path` ?<[string]> Optional path on the filesystem to the attached file.
- `body` ?<[Buffer]> Optional attachment body used instead of a file.
The list of files or buffers attached to the suite. Root suite has attachments populated by [`method: GlobalInfo.attach`].

View file

@ -26,8 +26,9 @@ export const Chip: React.FC<{
noInsets?: boolean, noInsets?: boolean,
setExpanded?: (expanded: boolean) => void, setExpanded?: (expanded: boolean) => void,
children?: any, children?: any,
}> = ({ header, expanded, setExpanded, children, noInsets }) => { dataTestId?: string,
return <div className='chip'> }> = ({ header, expanded, setExpanded, children, noInsets, dataTestId }) => {
return <div className='chip' data-test-id={dataTestId}>
<div <div
className={'chip-header' + (setExpanded ? ' expanded-' + expanded : '')} className={'chip-header' + (setExpanded ? ' expanded-' + expanded : '')}
onClick={() => setExpanded?.(!expanded)} onClick={() => setExpanded?.(!expanded)}
@ -45,13 +46,15 @@ export const AutoChip: React.FC<{
initialExpanded?: boolean, initialExpanded?: boolean,
noInsets?: boolean, noInsets?: boolean,
children?: any, children?: any,
}> = ({ header, initialExpanded, noInsets, children }) => { dataTestId?: string,
}> = ({ header, initialExpanded, noInsets, children, dataTestId }) => {
const [expanded, setExpanded] = React.useState(initialExpanded || initialExpanded === undefined); const [expanded, setExpanded] = React.useState(initialExpanded || initialExpanded === undefined);
return <Chip return <Chip
header={header} header={header}
expanded={expanded} expanded={expanded}
setExpanded={setExpanded} setExpanded={setExpanded}
noInsets={noInsets} noInsets={noInsets}
dataTestId={dataTestId}
> >
{children} {children}
</Chip>; </Chip>;

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import type { HTMLReport, TestAttachment } from '@playwright-test/reporters/html'; import type { HTMLReport } from '@playwright-test/reporters/html';
import type zip from '@zip.js/zip.js'; import type zip from '@zip.js/zip.js';
// @ts-ignore // @ts-ignore
import zipImport from '@zip.js/zip.js/dist/zip-no-worker-inflate.min.js'; import zipImport from '@zip.js/zip.js/dist/zip-no-worker-inflate.min.js';
@ -26,59 +26,6 @@ import { ReportView } from './reportView';
// @ts-ignore // @ts-ignore
const zipjs = zipImport as typeof zip; const zipjs = zipImport as typeof zip;
export type Metadata = Partial<{
'generatedAt': number;
'revision.id': string;
'revision.author': string;
'revision.email': string;
'revision.subject': string;
'revision.timestamp': number;
'revision.link': string;
'revision.localPendingChanges': boolean;
'ci.link': string;
}>;
const extractMetadata = (attachments: TestAttachment[]): Metadata | undefined => {
// The last plugin to register for a given key will take precedence
attachments = [...attachments];
attachments.reverse();
const field = (name: string) => attachments.find(({ name: n }) => n === name)?.body;
const fieldAsJSON = (name: string) => {
const raw = field(name);
if (raw !== undefined)
return JSON.parse(raw);
};
const fieldAsNumber = (name: string) => {
const v = fieldAsJSON(name);
if (v !== undefined && typeof v !== 'number')
throw new Error(`Invalid value for field '${name}'. Expected type 'number', but got ${typeof v}.`);
return v;
};
const fieldAsBool = (name: string) => {
const v = fieldAsJSON(name);
if (v !== undefined && typeof v !== 'boolean')
throw new Error(`Invalid value for field '${name}'. Expected type 'boolean', but got ${typeof v}.`);
return v;
};
const out = {
'generatedAt': fieldAsNumber('generatedAt'),
'revision.id': field('revision.id'),
'revision.author': field('revision.author'),
'revision.email': field('revision.email'),
'revision.subject': field('revision.subject'),
'revision.timestamp': fieldAsNumber('revision.timestamp'),
'revision.link': field('revision.link'),
'revision.localPendingChanges': fieldAsBool('revision.localPendingChanges'),
'ci.link': field('ci.link'),
};
if (Object.entries(out).filter(([_, v]) => v !== undefined).length)
return out;
};
const ReportLoader: React.FC = () => { const ReportLoader: React.FC = () => {
const [report, setReport] = React.useState<LoadedReport | undefined>(); const [report, setReport] = React.useState<LoadedReport | undefined>();
React.useEffect(() => { React.useEffect(() => {
@ -96,14 +43,13 @@ window.onload = () => {
class ZipReport implements LoadedReport { class ZipReport implements LoadedReport {
private _entries = new Map<string, zip.Entry>(); private _entries = new Map<string, zip.Entry>();
private _json!: HTMLReport & { metadata?: Metadata }; private _json!: HTMLReport;
async load() { async load() {
const zipReader = new zipjs.ZipReader(new zipjs.Data64URIReader((window as any).playwrightReportBase64), { useWebWorkers: false }) as zip.ZipReader; const zipReader = new zipjs.ZipReader(new zipjs.Data64URIReader((window as any).playwrightReportBase64), { useWebWorkers: false }) as zip.ZipReader;
for (const entry of await zipReader.getEntries()) for (const entry of await zipReader.getEntries())
this._entries.set(entry.filename, entry); this._entries.set(entry.filename, entry);
this._json = await this.entry('report.json') as HTMLReport; this._json = await this.entry('report.json') as HTMLReport;
this._json.metadata = extractMetadata(this._json.attachments);
} }
json(): HTMLReport { json(): HTMLReport {

View file

@ -15,9 +15,8 @@
*/ */
import type { HTMLReport } from '@playwright-test/reporters/html'; import type { HTMLReport } from '@playwright-test/reporters/html';
import type { Metadata } from './index';
export interface LoadedReport { export interface LoadedReport {
json(): HTMLReport & { metadata?: Metadata }; json(): HTMLReport;
entry(name: string): Promise<Object | undefined>; entry(name: string): Promise<Object | undefined>;
} }

View file

@ -18,21 +18,61 @@ import * as React from 'react';
import './colors.css'; import './colors.css';
import './common.css'; import './common.css';
import * as icons from './icons'; import * as icons from './icons';
import type { Metadata } from './index';
import { AutoChip } from './chip'; import { AutoChip } from './chip';
import './reportView.css'; import './reportView.css';
import './theme.css'; import './theme.css';
export const MetadataView: React.FC<Metadata> = metadata => { 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
} | undefined;
class ErrorBoundary extends React.Component<{}, { error: Error | null, errorInfo: React.ErrorInfo | null }> {
state: { error: Error | null, errorInfo: React.ErrorInfo | null } = {
error: null,
errorInfo: null,
};
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
this.setState({ error, errorInfo });
}
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>
<p>
<pre style={{ overflow: 'scroll' }}>{this.state.error?.message}<br/>{this.state.error?.stack}<br/>{this.state.errorInfo?.componentStack}</pre>
</p>
</AutoChip>
);
}
return this.props.children;
}
}
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 ( return (
<AutoChip header={ <AutoChip header={
<span> <span>
{metadata['revision.id'] && <span style={{ float: 'right', fontFamily: 'var(--monospace-font)' }}> {metadata['revision.id'] && <span style={{ float: 'right', fontFamily: 'var(--monospace-font)' }}>
{metadata['revision.id'].slice(0, 7)} {metadata['revision.id'].slice(0, 7)}
</span>} </span>}
{metadata['revision.subject'] && metadata['revision.subject'] || 'no subject>'} {metadata['revision.subject'] || 'Commit Metainfo'}
{!metadata['revision.subject'] && 'Commit metainfo'} </span>} initialExpanded={false} dataTestId='metadata-chip'>
</span>} initialExpanded={false}>
{metadata['revision.subject'] && {metadata['revision.subject'] &&
<MetadatViewItem <MetadatViewItem
testId='revision.subject' testId='revision.subject'
@ -73,10 +113,10 @@ export const MetadataView: React.FC<Metadata> = metadata => {
icon='externalLink' icon='externalLink'
/> />
} }
{metadata['generatedAt'] && {metadata['timestamp'] &&
<MetadatViewItem <MetadatViewItem
content={<span style={{ color: 'var(--color-fg-subtle)' }}> content={<span style={{ color: 'var(--color-fg-subtle)' }}>
Report generated on {Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(metadata['generatedAt'])} Report generated on {Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(metadata['timestamp'])}
</span>}></MetadatViewItem> </span>}></MetadatViewItem>
} }
</AutoChip> </AutoChip>

View file

@ -23,6 +23,7 @@ import { HeaderView } from './headerView';
import { Route } from './links'; import { Route } from './links';
import type { LoadedReport } from './loadedReport'; import type { LoadedReport } from './loadedReport';
import './reportView.css'; import './reportView.css';
import type { Metainfo } from './metadataView';
import { MetadataView } from './metadataView'; import { MetadataView } from './metadataView';
import { TestCaseView } from './testCaseView'; import { TestCaseView } from './testCaseView';
import { TestFilesView } from './testFilesView'; import { TestFilesView } from './testFilesView';
@ -46,7 +47,7 @@ export const ReportView: React.FC<{
return <div className='htmlreport vbox px-4 pb-4'> return <div className='htmlreport vbox px-4 pb-4'>
<main> <main>
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>} {report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
{report?.json().metadata && <MetadataView {...report?.json().metadata!} />} {report?.json().metadata && <MetadataView {...report?.json().metadata as Metainfo} />}
<Route params=''> <Route params=''>
<TestFilesView report={report?.json()} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles}></TestFilesView> <TestFilesView report={report?.json()} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles}></TestFilesView>
</Route> </Route>

View file

@ -1,86 +0,0 @@
/**
* 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.
*/
import { createGuid } from 'playwright-core/lib/utils';
import { spawnAsync } from 'playwright-core/lib/utils/spawnAsync';
const GIT_OPERATIONS_TIMEOUT_MS = 1500;
const kContentTypePlainText = 'text/plain';
const kContentTypeJSON = 'application/json';
export interface Attachment {
name: string;
contentType: string;
path?: string;
body?: Buffer;
}
export const gitStatusFromCLI = async (gitDir: string): Promise<Attachment[]> => {
const separator = `:${createGuid().slice(0, 4)}:`;
const { code, stdout } = await spawnAsync(
'git',
['show', '-s', `--format=%H${separator}%s${separator}%an${separator}%ae${separator}%ct`, 'HEAD'],
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
);
if (code)
return [];
const showOutput = stdout.trim();
const [sha, subject, authorName, authorEmail, rawTimestamp] = showOutput.split(separator);
let timestamp: number = Number.parseInt(rawTimestamp, 10);
timestamp = Number.isInteger(timestamp) ? timestamp * 1000 : 0;
return [
{ name: 'revision.id', body: Buffer.from(sha), contentType: kContentTypePlainText },
{ name: 'revision.author', body: Buffer.from(authorName), contentType: kContentTypePlainText },
{ name: 'revision.email', body: Buffer.from(authorEmail), contentType: kContentTypePlainText },
{ name: 'revision.subject', body: Buffer.from(subject), contentType: kContentTypePlainText },
{ name: 'revision.timestamp', body: Buffer.from(JSON.stringify(timestamp)), contentType: kContentTypeJSON },
];
};
export const githubEnv = async (): Promise<Attachment[]> => {
const out: Attachment[] = [];
if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_SHA)
out.push({ name: 'revision.link', body: Buffer.from(`${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/commit/${process.env.GITHUB_SHA}`), contentType: kContentTypePlainText });
if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID)
out.push({ name: 'ci.link', body: Buffer.from(`${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`), contentType: kContentTypePlainText });
return out;
};
export const gitlabEnv = async (): Promise<Attachment[]> => {
// GitLab: https://docs.gitlab.com/ee/ci/variables/predefined_variables.html
const out: Attachment[] = [];
if (process.env.CI_PROJECT_URL && process.env.CI_COMMIT_SHA)
out.push({ name: 'revision.link', body: Buffer.from(`${process.env.CI_PROJECT_URL}/-/commit/${process.env.CI_COMMIT_SHA}`), contentType: kContentTypePlainText });
if (process.env.CI_JOB_URL)
out.push({ name: 'ci.link', body: Buffer.from(process.env.CI_JOB_URL), contentType: kContentTypePlainText });
return out;
};
export const jenkinsEnv = async (): Promise<Attachment[]> => {
// Jenkins: https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables
const out: Attachment[] = [];
if (process.env.BUILD_URL)
out.push({ name: 'ci.link', body: Buffer.from(process.env.BUILD_URL), contentType: kContentTypePlainText });
return out;
};
export const generationTimestamp = async (): Promise<Attachment[]> => {
return [{ name: 'generatedAt', body: Buffer.from(JSON.stringify(Date.now())), contentType: kContentTypeJSON }];
};

View file

@ -1,36 +0,0 @@
/**
* 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.
*/
import type { FullConfigInternal, GlobalInfo } from './types';
import { normalizeAndSaveAttachment } from './util';
import fs from 'fs';
export class GlobalInfoImpl implements GlobalInfo {
private _fullConfig: FullConfigInternal;
private _attachments: { name: string; path?: string | undefined; body?: Buffer | undefined; contentType: string; }[] = [];
constructor(config: FullConfigInternal) {
this._fullConfig = config;
}
attachments() {
return [...this._attachments];
}
async attach(name: string, options: { path?: string, body?: string | Buffer, contentType?: string } = {}) {
await fs.promises.mkdir(this._fullConfig._globalOutputDir, { recursive: true });
this._attachments.push(await normalizeAndSaveAttachment(this._fullConfig._globalOutputDir, name, options));
}
}

View file

@ -15,7 +15,7 @@
*/ */
import { installTransform, setCurrentlyLoadingTestFile } from './transform'; import { installTransform, setCurrentlyLoadingTestFile } from './transform';
import type { Config, Project, ReporterDescription, FullProjectInternal, GlobalInfo } from './types'; import type { Config, Project, ReporterDescription, FullProjectInternal } from './types';
import type { FullConfigInternal } from './types'; import type { FullConfigInternal } from './types';
import { getPackageJsonPath, mergeObjects, errorWithFile } from './util'; import { getPackageJsonPath, mergeObjects, errorWithFile } from './util';
import { setCurrentlyLoadingFileSuite } from './globals'; import { setCurrentlyLoadingFileSuite } from './globals';
@ -163,6 +163,7 @@ export class Loader {
this._fullConfig.workers = takeFirst(config.workers, baseFullConfig.workers); this._fullConfig.workers = takeFirst(config.workers, baseFullConfig.workers);
this._fullConfig.webServer = takeFirst(config.webServer, baseFullConfig.webServer); this._fullConfig.webServer = takeFirst(config.webServer, baseFullConfig.webServer);
this._fullConfig._plugins = takeFirst(config.plugins, baseFullConfig._plugins); this._fullConfig._plugins = takeFirst(config.plugins, baseFullConfig._plugins);
this._fullConfig.metadata = takeFirst(config.metadata, baseFullConfig.metadata);
this._fullConfig.projects = (config.projects || [config]).map(p => this._resolveProject(config, p, throwawayArtifactsPath)); this._fullConfig.projects = (config.projects || [config]).map(p => this._resolveProject(config, p, throwawayArtifactsPath));
} }
@ -210,7 +211,7 @@ export class Loader {
return suite; return suite;
} }
async loadGlobalHook(file: string, name: string): Promise<(config: FullConfigInternal, globalInfo?: GlobalInfo) => any> { async loadGlobalHook(file: string, name: string): Promise<(config: FullConfigInternal) => any> {
let hook = await this._requireOrImport(file); let hook = await this._requireOrImport(file);
if (hook && typeof hook === 'object' && ('default' in hook)) if (hook && typeof hook === 'object' && ('default' in hook))
hook = hook['default']; hook = hook['default'];
@ -516,6 +517,7 @@ export const baseFullConfig: FullConfigInternal = {
grep: /.*/, grep: /.*/,
grepInvert: null, grepInvert: null,
maxFailures: 0, maxFailures: 0,
metadata: {},
preserveOutput: 'always', preserveOutput: 'always',
projects: [], projects: [],
reporter: [ [process.env.CI ? 'dot' : 'list'] ], reporter: [ [process.env.CI ? 'dot' : 'list'] ],

View file

@ -0,0 +1,97 @@
/**
* 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.
*/
import type { Config, TestPlugin } from '../types';
import { createGuid } from 'playwright-core/lib/utils';
import { spawnAsync } from 'playwright-core/lib/utils/spawnAsync';
const GIT_OPERATIONS_TIMEOUT_MS = 1500;
export const gitCommitInfo = (options?: GitCommitInfoPluginOptions): TestPlugin => {
return {
name: 'playwright:git-commit-info',
configure: async (config: Config, 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();
config.metadata = config.metadata || {};
Object.assign(config.metadata, info);
},
};
};
export 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'> => {
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)
out['ci.link'] = process.env.BUILD_URL;
// GitLab: https://docs.gitlab.com/ee/ci/variables/predefined_variables.html
if (process.env.CI_PROJECT_URL && process.env.CI_COMMIT_SHA)
out['revision.link'] = `${process.env.CI_PROJECT_URL}/-/commit/${process.env.CI_COMMIT_SHA}`;
if (process.env.CI_JOB_URL)
out['ci.link'] = process.env.CI_JOB_URL;
// GitHub: https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables
if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_SHA)
out['revision.link'] = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/commit/${process.env.GITHUB_SHA}`;
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> => {
const separator = `:${createGuid().slice(0, 4)}:`;
const { code, stdout } = await spawnAsync(
'git',
['show', '-s', `--format=%H${separator}%s${separator}%an${separator}%ae${separator}%ct`, 'HEAD'],
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
);
if (code)
return;
const showOutput = stdout.trim();
const [id, subject, author, email, rawTimestamp] = showOutput.split(separator);
let timestamp: number = Number.parseInt(rawTimestamp, 10);
timestamp = Number.isInteger(timestamp) ? timestamp * 1000 : 0;
return {
'revision.id': id,
'revision.author': author,
'revision.email': email,
'revision.subject': subject,
'revision.timestamp': timestamp,
};
};

View file

@ -14,3 +14,4 @@
* limitations under the License. * limitations under the License.
*/ */
export { webServer } from './webServerPlugin'; export { webServer } from './webServerPlugin';
export { gitCommitInfo } from './gitCommitInfoPlugin';

View file

@ -28,7 +28,7 @@ import type { JsonAttachment, JsonReport, JsonSuite, JsonTestCase, JsonTestResul
import RawReporter from './raw'; import RawReporter from './raw';
import { stripAnsiEscapes } from './base'; import { stripAnsiEscapes } from './base';
import { getPackageJsonPath } from '../util'; import { getPackageJsonPath } from '../util';
import type { FullConfigInternal } from '../types'; import type { FullConfigInternal, Metadata } from '../types';
import type { ZipFile } from 'playwright-core/lib/zipBundle'; import type { ZipFile } from 'playwright-core/lib/zipBundle';
import { yazl } from 'playwright-core/lib/zipBundle'; import { yazl } from 'playwright-core/lib/zipBundle';
@ -49,7 +49,7 @@ export type Location = {
}; };
export type HTMLReport = { export type HTMLReport = {
attachments: TestAttachment[]; metadata: Metadata;
files: TestFileSummary[]; files: TestFileSummary[];
stats: Stats; stats: Stats;
projectNames: string[]; projectNames: string[];
@ -159,12 +159,12 @@ class HtmlReporter implements Reporter {
const projectSuites = this.suite.suites; const projectSuites = this.suite.suites;
const reports = projectSuites.map(suite => { const reports = projectSuites.map(suite => {
const rawReporter = new RawReporter(); const rawReporter = new RawReporter();
const report = rawReporter.generateProjectReport(this.config, suite, []); const report = rawReporter.generateProjectReport(this.config, suite);
return report; return report;
}); });
await removeFolders([outputFolder]); await removeFolders([outputFolder]);
const builder = new HtmlBuilder(outputFolder); const builder = new HtmlBuilder(outputFolder);
const { ok, singleTestId } = await builder.build(new RawReporter().generateAttachments(this.suite.attachments), reports); const { ok, singleTestId } = await builder.build(this.config.metadata, reports);
if (process.env.CI) if (process.env.CI)
return; return;
@ -255,7 +255,7 @@ class HtmlBuilder {
this._dataZipFile = new yazl.ZipFile(); this._dataZipFile = new yazl.ZipFile();
} }
async build(testReportAttachments: JsonAttachment[], rawReports: JsonReport[]): Promise<{ ok: boolean, singleTestId: string | undefined }> { async build(metadata: Metadata, rawReports: JsonReport[]): Promise<{ ok: boolean, singleTestId: string | undefined }> {
const data = new Map<string, { testFile: TestFile, testFileSummary: TestFileSummary }>(); const data = new Map<string, { testFile: TestFile, testFileSummary: TestFileSummary }>();
for (const projectJson of rawReports) { for (const projectJson of rawReports) {
@ -311,7 +311,7 @@ class HtmlBuilder {
this._addDataFile(fileId + '.json', testFile); this._addDataFile(fileId + '.json', testFile);
} }
const htmlReport: HTMLReport = { const htmlReport: HTMLReport = {
attachments: this._serializeAttachments(testReportAttachments), metadata,
files: [...data.values()].map(e => e.testFileSummary), files: [...data.values()].map(e => e.testFileSummary),
projectNames: rawReports.map(r => r.project.name), projectNames: rawReports.map(r => r.project.name),
stats: [...data.values()].reduce((a, e) => addStats(a, e.testFileSummary.stats), emptyStats()) stats: [...data.values()].reduce((a, e) => addStats(a, e.testFileSummary.stats), emptyStats())

View file

@ -23,6 +23,7 @@ import { formatResultFailure } from './base';
import { toPosixPath, serializePatterns } from './json'; import { toPosixPath, serializePatterns } from './json';
import { MultiMap } from 'playwright-core/lib/utils/multimap'; import { MultiMap } from 'playwright-core/lib/utils/multimap';
import { codeFrameColumns } from '../babelBundle'; import { codeFrameColumns } from '../babelBundle';
import type { Metadata } from '../types';
export type JsonLocation = Location; export type JsonLocation = Location;
export type JsonError = string; export type JsonError = string;
@ -30,7 +31,6 @@ export type JsonStackFrame = { file: string, line: number, column: number };
export type JsonReport = { export type JsonReport = {
config: JsonConfig, config: JsonConfig,
attachments: JsonAttachment[],
project: JsonProject, project: JsonProject,
suites: JsonSuite[], suites: JsonSuite[],
}; };
@ -38,7 +38,7 @@ export type JsonReport = {
export type JsonConfig = Omit<FullConfig, 'projects' | 'attachments'>; export type JsonConfig = Omit<FullConfig, 'projects' | 'attachments'>;
export type JsonProject = { export type JsonProject = {
metadata: any, metadata: Metadata,
name: string, name: string,
outputDir: string, outputDir: string,
repeatEach: number, repeatEach: number,
@ -112,7 +112,6 @@ class RawReporter {
async onEnd() { async onEnd() {
const projectSuites = this.suite.suites; const projectSuites = this.suite.suites;
const globalAttachments = this.generateAttachments(this.suite.attachments);
for (const suite of projectSuites) { for (const suite of projectSuites) {
const project = suite.project(); const project = suite.project();
assert(project, 'Internal Error: Invalid project structure'); assert(project, 'Internal Error: Invalid project structure');
@ -130,7 +129,7 @@ class RawReporter {
} }
if (!reportFile) if (!reportFile)
throw new Error('Internal error, could not create report file'); throw new Error('Internal error, could not create report file');
const report = this.generateProjectReport(this.config, suite, globalAttachments); const report = this.generateProjectReport(this.config, suite);
fs.writeFileSync(reportFile, JSON.stringify(report, undefined, 2)); fs.writeFileSync(reportFile, JSON.stringify(report, undefined, 2));
} }
} }
@ -163,13 +162,12 @@ class RawReporter {
return out; return out;
} }
generateProjectReport(config: FullConfig, suite: Suite, attachments: JsonAttachment[]): JsonReport { generateProjectReport(config: FullConfig, suite: Suite): JsonReport {
this.config = config; this.config = config;
const project = suite.project(); const project = suite.project();
assert(project, 'Internal Error: Invalid project structure'); assert(project, 'Internal Error: Invalid project structure');
const report: JsonReport = { const report: JsonReport = {
config, config,
attachments,
project: { project: {
metadata: project.metadata, metadata: project.metadata,
name: project.name, name: project.name,

View file

@ -42,7 +42,6 @@ import type { Config, FullProjectInternal } from './types';
import type { FullConfigInternal } from './types'; import type { FullConfigInternal } from './types';
import { raceAgainstTimeout } from 'playwright-core/lib/utils/timeoutRunner'; import { raceAgainstTimeout } from 'playwright-core/lib/utils/timeoutRunner';
import { SigIntWatcher } from './sigIntWatcher'; import { SigIntWatcher } from './sigIntWatcher';
import { GlobalInfoImpl } from './globalInfo';
const removeFolderAsync = promisify(rimraf); const removeFolderAsync = promisify(rimraf);
const readDirAsync = promisify(fs.readdir); const readDirAsync = promisify(fs.readdir);
@ -78,11 +77,9 @@ export type ConfigCLIOverrides = {
export class Runner { export class Runner {
private _loader: Loader; private _loader: Loader;
private _reporter!: Reporter; private _reporter!: Reporter;
private _globalInfo: GlobalInfoImpl;
constructor(configCLIOverrides?: ConfigCLIOverrides) { constructor(configCLIOverrides?: ConfigCLIOverrides) {
this._loader = new Loader(configCLIOverrides); this._loader = new Loader(configCLIOverrides);
this._globalInfo = new GlobalInfoImpl(this._loader.fullConfig());
} }
async loadConfigFromResolvedFile(resolvedConfigFile: string): Promise<FullConfigInternal> { async loadConfigFromResolvedFile(resolvedConfigFile: string): Promise<FullConfigInternal> {
@ -398,9 +395,6 @@ export class Runner {
const result: FullResult = { status: 'passed' }; const result: FullResult = { status: 'passed' };
// 13.5 Add copy of attachments.
rootSuite.attachments = this._globalInfo.attachments();
// 14. Run tests. // 14. Run tests.
try { try {
const sigintWatcher = new SigIntWatcher(); const sigintWatcher = new SigIntWatcher();
@ -466,7 +460,7 @@ export class Runner {
// The do global setup. // The do global setup.
if (config.globalSetup) if (config.globalSetup)
globalSetupResult = await (await this._loader.loadGlobalHook(config.globalSetup, 'globalSetup'))(this._loader.fullConfig(), this._globalInfo); globalSetupResult = await (await this._loader.loadGlobalHook(config.globalSetup, 'globalSetup'))(this._loader.fullConfig());
}, result); }, result);
if (result.status !== 'passed') { if (result.status !== 'passed') {

View file

@ -39,7 +39,6 @@ export type Modifier = {
export class Suite extends Base implements reporterTypes.Suite { export class Suite extends Base implements reporterTypes.Suite {
suites: Suite[] = []; suites: Suite[] = [];
tests: TestCase[] = []; tests: TestCase[] = [];
attachments: reporterTypes.Suite['attachments'] = [];
location?: Location; location?: Location;
parent?: Suite; parent?: Suite;
_use: FixturesWithLocation[] = []; _use: FixturesWithLocation[] = [];

View file

@ -628,9 +628,9 @@ interface TestConfig {
maxFailures?: number; maxFailures?: number;
/** /**
* Any JSON-serializable metadata that will be put directly to the test report. * Metadata that will be put directly to the test report serialized as JSON.
*/ */
metadata?: any; metadata?: Metadata;
/** /**
* Config name is visible in the report and during test execution, unless overridden by * Config name is visible in the report and during test execution, unless overridden by
@ -921,6 +921,8 @@ export interface Config<TestArgs = {}, WorkerArgs = {}> extends TestConfig {
use?: UseOptions<TestArgs, WorkerArgs>; use?: UseOptions<TestArgs, WorkerArgs>;
} }
export type Metadata = { [key: string]: string | number | boolean };
/** /**
* Playwright Test provides many options to configure how your tests are collected and executed, for example `timeout` or * Playwright Test provides many options to configure how your tests are collected and executed, for example `timeout` or
* `testDir`. These options are described in the [TestConfig] object in the [configuration file](https://playwright.dev/docs/test-configuration). * `testDir`. These options are described in the [TestConfig] object in the [configuration file](https://playwright.dev/docs/test-configuration).
@ -1055,6 +1057,10 @@ export interface FullConfig<TestArgs = {}, WorkerArgs = {}> {
* *
*/ */
maxFailures: number; maxFailures: number;
/**
* Metadata that will be put directly to the test report serialized as JSON.
*/
metadata: Metadata;
version: string; version: string;
/** /**
* Whether to preserve test output in the * Whether to preserve test output in the
@ -3501,122 +3507,6 @@ interface ScreenshotAssertions {
}): void; }): void;
} }
/**
* `GlobalInfo` contains information on the overall test run. The information spans projects and tests. Some reporters show
* global info.
*
* You can write to GlobalInfo via your Global Setup hook, and read from it in a [Custom Reporter](https://playwright.dev/docs/test-reporters):
*
* ```ts
* // global-setup.ts
* import { chromium, FullConfig, GlobalInfo } from '@playwright/test';
*
* async function globalSetup(config: FullConfig, info: GlobalInfo) {
* await info.attach('agent.config.txt', { path: './agent.config.txt' });
* }
*
* export default globalSetup;
* ```
*
* Access the attachments from the Root Suite in the Reporter:
*
* ```ts
* // my-awesome-reporter.ts
* import { Reporter } from '@playwright/test/reporter';
*
* class MyReporter implements Reporter {
* private _suite;
*
* onBegin(config, suite) {
* this._suite = suite;
* }
*
* onEnd(result) {
* console.log(`Finished the run with ${this._suite.attachments.length} global attachments!`);
* }
* }
* export default MyReporter;
* ```
*
* Finally, specify `globalSetup` in the configuration file and `reporter`:
*
* ```ts
* // playwright.config.ts
* import { PlaywrightTestConfig } from '@playwright/test';
*
* const config: PlaywrightTestConfig = {
* globalSetup: require.resolve('./global-setup'),
* reporter: require.resolve('./my-awesome-reporter'),
* };
* export default config;
* ```
*
* See [`TestInfo`](https://playwright.dev/docs/api/class-testinfo) for related attachment functionality scoped to the test-level.
*/
export interface GlobalInfo {
/**
* The list of files or buffers attached to the overall test run. Some reporters show global attachments.
*
* To add an attachment, use
* [globalInfo.attach(name[, options])](https://playwright.dev/docs/api/class-globalinfo#global-info-attach). See
* [testInfo.attachments](https://playwright.dev/docs/api/class-testinfo#test-info-attachments) if you are looking for
* test-scoped attachments.
*/
attachments(): Array<{
/**
* Attachment name.
*/
name: string;
/**
* Content type of this attachment to properly present in the report, for example `'application/json'` or `'image/png'`.
*/
contentType: string;
/**
* Optional path on the filesystem to the attached file.
*/
path?: string;
/**
* Optional attachment body used instead of a file.
*/
body?: Buffer;
}>;
/**
* Attach a value or a file from disk to the overall test run. Some reporters show global attachments. Either `path` or
* `body` must be specified, but not both.
*
* See [testInfo.attach(name[, options])](https://playwright.dev/docs/api/class-testinfo#test-info-attach) if you are
* looking for test-scoped attachments.
*
* > NOTE: [globalInfo.attach(name[, options])](https://playwright.dev/docs/api/class-globalinfo#global-info-attach)
* automatically takes care of copying attached files to a location that is accessible to reporters. You can safely remove
* the attachment after awaiting the attach call.
* @param name Attachment name.
* @param options
*/
attach(name: string, options?: {
/**
* Attachment body. Mutually exclusive with `path`.
*/
body?: string|Buffer;
/**
* Optional content type of this attachment to properly present in the report, for example `'application/json'` or
* `'image/png'`. If omitted, content type is inferred based on the `path`, or defaults to `text/plain` for [string]
* attachments and `application/octet-stream` for [Buffer] attachments.
*/
contentType?: string;
/**
* Path on the filesystem to the attached file. Mutually exclusive with `body`.
*/
path?: string;
}): Promise<void>;
}
/** /**
* Information about an error thrown during test execution. * Information about an error thrown during test execution.
*/ */

View file

@ -84,33 +84,7 @@ export interface Suite {
/** /**
* Returns a list of titles from the root down to this suite. * Returns a list of titles from the root down to this suite.
*/ */
titlePath(): Array<string>; titlePath(): Array<string>;}
/**
* The list of files or buffers attached to the suite. Root suite has attachments populated by
* [globalInfo.attach(name[, options])](https://playwright.dev/docs/api/class-globalinfo#global-info-attach).
*/
attachments: Array<{
/**
* Attachment name.
*/
name: string;
/**
* Content type of this attachment to properly present in the report, for example `'application/json'` or `'image/png'`.
*/
contentType: string;
/**
* Optional path on the filesystem to the attached file.
*/
path?: string;
/**
* Optional attachment body used instead of a file.
*/
body?: Buffer;
}>;}
/** /**
* `TestCase` corresponds to every [test.(call)(title, testFunction)](https://playwright.dev/docs/api/class-test#test-call) * `TestCase` corresponds to every [test.(call)(title, testFunction)](https://playwright.dev/docs/api/class-test#test-call)

View file

@ -20,13 +20,16 @@ loadEnv({ path: path.join(__dirname, '..', '..', '.env') });
import type { Config, PlaywrightTestOptions, PlaywrightWorkerOptions } from '@playwright/test'; import type { Config, PlaywrightTestOptions, PlaywrightWorkerOptions } from '@playwright/test';
import * as path from 'path'; import * as path from 'path';
import type { ServerWorkerOptions } from '../config/serverFixtures'; import type { ServerWorkerOptions } from '../config/serverFixtures';
import { gitCommitInfo } from '@playwright/test/lib/plugins';
process.env.PWPAGE_IMPL = 'android'; process.env.PWPAGE_IMPL = 'android';
const outputDir = path.join(__dirname, '..', '..', 'test-results'); const outputDir = path.join(__dirname, '..', '..', 'test-results');
const testDir = path.join(__dirname, '..'); const testDir = path.join(__dirname, '..');
const config: Config<ServerWorkerOptions & PlaywrightWorkerOptions & PlaywrightTestOptions> = { const config: Config<ServerWorkerOptions & PlaywrightWorkerOptions & PlaywrightTestOptions> = {
globalSetup: path.join(__dirname, '../config/globalSetup'), plugins: [
gitCommitInfo(),
],
testDir, testDir,
outputDir, outputDir,
timeout: 120000, timeout: 120000,

View file

@ -16865,9 +16865,9 @@ interface TestConfig {
maxFailures?: number; maxFailures?: number;
/** /**
* Any JSON-serializable metadata that will be put directly to the test report. * Metadata that will be put directly to the test report serialized as JSON.
*/ */
metadata?: any; metadata?: Metadata;
/** /**
* Config name is visible in the report and during test execution, unless overridden by * Config name is visible in the report and during test execution, unless overridden by
@ -17196,6 +17196,8 @@ export interface Config<TestArgs = {}, WorkerArgs = {}> extends TestConfig {
use?: UseOptions<TestArgs, WorkerArgs>; use?: UseOptions<TestArgs, WorkerArgs>;
} }
export type Metadata = { [key: string]: string | number | boolean };
/** /**
* Playwright Test provides many options to configure how your tests are collected and executed, for example `timeout` or * Playwright Test provides many options to configure how your tests are collected and executed, for example `timeout` or
* `testDir`. These options are described in the [TestConfig] object in the [configuration file](https://playwright.dev/docs/test-configuration). * `testDir`. These options are described in the [TestConfig] object in the [configuration file](https://playwright.dev/docs/test-configuration).
@ -17330,6 +17332,10 @@ export interface FullConfig<TestArgs = {}, WorkerArgs = {}> {
* *
*/ */
maxFailures: number; maxFailures: number;
/**
* Metadata that will be put directly to the test report serialized as JSON.
*/
metadata: Metadata;
version: string; version: string;
/** /**
* Whether to preserve test output in the * Whether to preserve test output in the
@ -19962,122 +19968,6 @@ interface ScreenshotAssertions {
}): void; }): void;
} }
/**
* `GlobalInfo` contains information on the overall test run. The information spans projects and tests. Some reporters show
* global info.
*
* You can write to GlobalInfo via your Global Setup hook, and read from it in a [Custom Reporter](https://playwright.dev/docs/test-reporters):
*
* ```ts
* // global-setup.ts
* import { chromium, FullConfig, GlobalInfo } from '@playwright/test';
*
* async function globalSetup(config: FullConfig, info: GlobalInfo) {
* await info.attach('agent.config.txt', { path: './agent.config.txt' });
* }
*
* export default globalSetup;
* ```
*
* Access the attachments from the Root Suite in the Reporter:
*
* ```ts
* // my-awesome-reporter.ts
* import { Reporter } from '@playwright/test/reporter';
*
* class MyReporter implements Reporter {
* private _suite;
*
* onBegin(config, suite) {
* this._suite = suite;
* }
*
* onEnd(result) {
* console.log(`Finished the run with ${this._suite.attachments.length} global attachments!`);
* }
* }
* export default MyReporter;
* ```
*
* Finally, specify `globalSetup` in the configuration file and `reporter`:
*
* ```ts
* // playwright.config.ts
* import { PlaywrightTestConfig } from '@playwright/test';
*
* const config: PlaywrightTestConfig = {
* globalSetup: require.resolve('./global-setup'),
* reporter: require.resolve('./my-awesome-reporter'),
* };
* export default config;
* ```
*
* See [`TestInfo`](https://playwright.dev/docs/api/class-testinfo) for related attachment functionality scoped to the test-level.
*/
export interface GlobalInfo {
/**
* The list of files or buffers attached to the overall test run. Some reporters show global attachments.
*
* To add an attachment, use
* [globalInfo.attach(name[, options])](https://playwright.dev/docs/api/class-globalinfo#global-info-attach). See
* [testInfo.attachments](https://playwright.dev/docs/api/class-testinfo#test-info-attachments) if you are looking for
* test-scoped attachments.
*/
attachments(): Array<{
/**
* Attachment name.
*/
name: string;
/**
* Content type of this attachment to properly present in the report, for example `'application/json'` or `'image/png'`.
*/
contentType: string;
/**
* Optional path on the filesystem to the attached file.
*/
path?: string;
/**
* Optional attachment body used instead of a file.
*/
body?: Buffer;
}>;
/**
* Attach a value or a file from disk to the overall test run. Some reporters show global attachments. Either `path` or
* `body` must be specified, but not both.
*
* See [testInfo.attach(name[, options])](https://playwright.dev/docs/api/class-testinfo#test-info-attach) if you are
* looking for test-scoped attachments.
*
* > NOTE: [globalInfo.attach(name[, options])](https://playwright.dev/docs/api/class-globalinfo#global-info-attach)
* automatically takes care of copying attached files to a location that is accessible to reporters. You can safely remove
* the attachment after awaiting the attach call.
* @param name Attachment name.
* @param options
*/
attach(name: string, options?: {
/**
* Attachment body. Mutually exclusive with `path`.
*/
body?: string|Buffer;
/**
* Optional content type of this attachment to properly present in the report, for example `'application/json'` or
* `'image/png'`. If omitted, content type is inferred based on the `path`, or defaults to `text/plain` for [string]
* attachments and `application/octet-stream` for [Buffer] attachments.
*/
contentType?: string;
/**
* Path on the filesystem to the attached file. Mutually exclusive with `body`.
*/
path?: string;
}): Promise<void>;
}
/** /**
* Information about an error thrown during test execution. * Information about an error thrown during test execution.
*/ */
@ -20609,33 +20499,7 @@ export interface Suite {
/** /**
* Returns a list of titles from the root down to this suite. * Returns a list of titles from the root down to this suite.
*/ */
titlePath(): Array<string>; titlePath(): Array<string>;}
/**
* The list of files or buffers attached to the suite. Root suite has attachments populated by
* [globalInfo.attach(name[, options])](https://playwright.dev/docs/api/class-globalinfo#global-info-attach).
*/
attachments: Array<{
/**
* Attachment name.
*/
name: string;
/**
* Content type of this attachment to properly present in the report, for example `'application/json'` or `'image/png'`.
*/
contentType: string;
/**
* Optional path on the filesystem to the attached file.
*/
path?: string;
/**
* Optional attachment body used instead of a file.
*/
body?: Buffer;
}>;}
/** /**
* `TestCase` corresponds to every [test.(call)(title, testFunction)](https://playwright.dev/docs/api/class-test#test-call) * `TestCase` corresponds to every [test.(call)(title, testFunction)](https://playwright.dev/docs/api/class-test#test-call)

View file

@ -1,31 +0,0 @@
/**
* 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.
*/
import type { FullConfig, GlobalInfo } from '@playwright/test';
// We're dogfooding this, so the …/lib/… import is acceptable
import * as ci from '@playwright/test/lib/ci';
async function globalSetup(config: FullConfig, globalInfo: GlobalInfo) {
const pluginResults = await Promise.all([
ci.generationTimestamp(),
ci.gitStatusFromCLI(config.rootDir),
ci.githubEnv(),
]);
await Promise.all(pluginResults.flat().map(attachment => globalInfo.attach(attachment.name, attachment)));
}
export default globalSetup;

View file

@ -20,13 +20,16 @@ loadEnv({ path: path.join(__dirname, '..', '..', '.env') });
import type { Config, PlaywrightTestOptions, PlaywrightWorkerOptions } from '@playwright/test'; import type { Config, PlaywrightTestOptions, PlaywrightWorkerOptions } from '@playwright/test';
import * as path from 'path'; import * as path from 'path';
import type { CoverageWorkerOptions } from '../config/coverageFixtures'; import type { CoverageWorkerOptions } from '../config/coverageFixtures';
import { gitCommitInfo } from '@playwright/test/lib/plugins';
process.env.PWPAGE_IMPL = 'electron'; process.env.PWPAGE_IMPL = 'electron';
const outputDir = path.join(__dirname, '..', '..', 'test-results'); const outputDir = path.join(__dirname, '..', '..', 'test-results');
const testDir = path.join(__dirname, '..'); const testDir = path.join(__dirname, '..');
const config: Config<CoverageWorkerOptions & PlaywrightWorkerOptions & PlaywrightTestOptions> = { const config: Config<CoverageWorkerOptions & PlaywrightWorkerOptions & PlaywrightTestOptions> = {
globalSetup: path.join(__dirname, '../config/globalSetup'), plugins: [
gitCommitInfo(),
],
testDir, testDir,
outputDir, outputDir,
timeout: 30000, timeout: 30000,

View file

@ -19,9 +19,13 @@ import type { PlaywrightTestConfig } from '@playwright/test';
import { config as loadEnv } from 'dotenv'; import { config as loadEnv } from 'dotenv';
loadEnv({ path: path.join(__dirname, '..', '..', '.env') }); loadEnv({ path: path.join(__dirname, '..', '..', '.env') });
import { gitCommitInfo } from '@playwright/test/lib/plugins';
const config: PlaywrightTestConfig = { const config: PlaywrightTestConfig = {
plugins: [
gitCommitInfo(),
],
testIgnore: '**\/fixture-scripts/**', testIgnore: '**\/fixture-scripts/**',
globalSetup: path.join(__dirname, 'globalSetup'),
timeout: 5 * 60 * 1000, timeout: 5 * 60 * 1000,
retries: 0, retries: 0,
reporter: process.env.CI ? 'dot' : [['list'], ['html', { open: 'on-failure' }]], reporter: process.env.CI ? 'dot' : [['list'], ['html', { open: 'on-failure' }]],

View file

@ -694,4 +694,3 @@ it('should not hang on resources served from cache', async ({ contextFactory, se
else else
expect(entries.length).toBe(2); expect(entries.length).toBe(2);
}); });

View file

@ -21,6 +21,7 @@ import type { Config, PlaywrightTestOptions, PlaywrightWorkerOptions } from '@pl
import * as path from 'path'; import * as path from 'path';
import type { TestModeWorkerOptions } from '../config/testModeFixtures'; import type { TestModeWorkerOptions } from '../config/testModeFixtures';
import type { CoverageWorkerOptions } from '../config/coverageFixtures'; import type { CoverageWorkerOptions } from '../config/coverageFixtures';
import { gitCommitInfo } from '@playwright/test/lib/plugins';
type BrowserName = 'chromium' | 'firefox' | 'webkit'; type BrowserName = 'chromium' | 'firefox' | 'webkit';
@ -44,7 +45,9 @@ const trace = !!process.env.PWTEST_TRACE;
const outputDir = path.join(__dirname, '..', '..', 'test-results'); const outputDir = path.join(__dirname, '..', '..', 'test-results');
const testDir = path.join(__dirname, '..'); const testDir = path.join(__dirname, '..');
const config: Config<CoverageWorkerOptions & PlaywrightWorkerOptions & PlaywrightTestOptions & TestModeWorkerOptions> = { const config: Config<CoverageWorkerOptions & PlaywrightWorkerOptions & PlaywrightTestOptions & TestModeWorkerOptions> = {
globalSetup: path.join(__dirname, '../config/globalSetup'), plugins: [
gitCommitInfo(),
],
testDir, testDir,
outputDir, outputDir,
expect: { expect: {

View file

@ -704,64 +704,152 @@ test('open tests from required file', async ({ runInlineTest, showReport, page }
]); ]);
}); });
test('should include metadata', async ({ runInlineTest, showReport, page }) => { test.describe('gitCommitInfo plugin', () => {
const beforeRunPlaywrightTest = async ({ baseDir }: { baseDir: string }) => { test('should include metadata', async ({ runInlineTest, showReport, page }) => {
const execGit = async (args: string[]) => { const beforeRunPlaywrightTest = async ({ baseDir }: { baseDir: string }) => {
const { code, stdout, stderr } = await spawnAsync('git', args, { stdio: 'pipe', cwd: baseDir }); const execGit = async (args: string[]) => {
if (!!code) const { code, stdout, stderr } = await spawnAsync('git', args, { stdio: 'pipe', cwd: baseDir });
throw new Error(`Non-zero exit of:\n$ git ${args.join(' ')}\nConsole:\nstdout:\n${stdout}\n\nstderr:\n${stderr}\n\n`); if (!!code)
return; 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']); const result = await runInlineTest({
await execGit(['config', '--local', 'user.email', 'shakespeare@example.local']); 'uncommitted.txt': `uncommitted file`,
await execGit(['config', '--local', 'user.name', 'William']); 'playwright.config.ts': `
await execGit(['add', '*.ts']); import path from 'path';
await execGit(['commit', '-m', 'awesome commit message']); import { gitCommitInfo } from '@playwright/test/lib/plugins';
};
const result = await runInlineTest({ const config = {
'uncommitted.txt': `uncommitted file`, plugins: [ gitCommitInfo() ],
'globalSetup.ts': ` }
import { FullConfig, GlobalInfo } from '@playwright/test';
import * as ci from '@playwright/test/lib/ci';
async function globalSetup(config: FullConfig, globalInfo: GlobalInfo) { export default config;
const pluginResults = await Promise.all([ `,
ci.generationTimestamp(), 'example.spec.ts': `
ci.gitStatusFromCLI(config.rootDir), const { test } = pwt;
ci.githubEnv(), test('sample', async ({}) => { expect(2).toBe(2); });
]); `,
}, { reporter: 'dot,html' }, { PW_TEST_HTML_REPORT_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, beforeRunPlaywrightTest);
await Promise.all(pluginResults.flat().map(attachment => globalInfo.attach(attachment.name, attachment))); await showReport();
}
export default globalSetup; expect(result.exitCode).toBe(0);
`, await page.click('text=awesome commit message');
'playwright.config.ts': ` await expect.soft(page.locator('data-test-id=revision.id')).toContainText(/^[a-f\d]+$/i);
import path from 'path'; await expect.soft(page.locator('data-test-id=revision.id >> a')).toHaveAttribute('href', 'https://playwright.dev/microsoft/playwright-example-for-test/commit/example-sha');
const config = { await expect.soft(page.locator('data-test-id=revision.timestamp')).toContainText(/AM|PM/);
globalSetup: path.join(__dirname, './globalSetup'), 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.locator('data-test-id=metadata-chip')).toBeVisible();
await expect.soft(page.locator('data-test-id=metadata-error')).not.toBeVisible();
});
export default config;
`,
'example.spec.ts': `
const { test } = pwt;
test('sample', async ({}) => { expect(2).toBe(2); });
`,
}, { reporter: 'dot,html' }, { PW_TEST_HTML_REPORT_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, beforeRunPlaywrightTest);
await showReport(); test('should use explicitly supplied metadata', async ({ runInlineTest, showReport, page }) => {
const result = await runInlineTest({
'uncommitted.txt': `uncommitted file`,
'playwright.config.ts': `
import path from 'path';
import { gitCommitInfo } from '@playwright/test/lib/plugins';
expect(result.exitCode).toBe(0); const config = {
await page.click('text=awesome commit message'); plugins: [ gitCommitInfo({
await expect.soft(page.locator('data-test-id=revision.id')).toContainText(/^[a-f\d]+$/i); info: {
await expect.soft(page.locator('data-test-id=revision.id >> a')).toHaveAttribute('href', 'https://playwright.dev/microsoft/playwright-example-for-test/commit/example-sha'); 'revision.id': '1234567890',
await expect.soft(page.locator('data-test-id=revision.timestamp')).toContainText(/AM|PM/); 'revision.subject': 'a better subject',
await expect.soft(page.locator('text=awesome commit message')).toHaveCount(2); 'revision.timestamp': new Date(),
await expect.soft(page.locator('text=William')).toBeVisible(); 'revision.author': 'William',
await expect.soft(page.locator('text=shakespeare@example.local')).toBeVisible(); 'revision.email': 'shakespeare@example.local',
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/); }) ],
}
export default config;
`,
'example.spec.ts': `
const { test } = pwt;
test('sample', async ({}) => { expect(2).toBe(2); });
`,
}, { reporter: 'dot,html' }, { PW_TEST_HTML_REPORT_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.locator('data-test-id=revision.id')).toContainText(/^[a-f\d]+$/i);
await expect.soft(page.locator('data-test-id=revision.id >> a')).toHaveAttribute('href', 'https://playwright.dev/microsoft/playwright-example-for-test/commit/example-sha');
await expect.soft(page.locator('data-test-id=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.locator('data-test-id=metadata-chip')).toBeVisible();
await expect.soft(page.locator('data-test-id=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': `
import path from 'path';
const config = {
plugins: [],
}
export default config;
`,
'example.spec.ts': `
const { test } = pwt;
test('my sample test', async ({}) => { expect(2).toBe(2); });
`,
}, { reporter: 'dot,html' }, { PW_TEST_HTML_REPORT_OPEN: 'never' }, undefined);
await showReport();
expect(result.exitCode).toBe(0);
await expect.soft(page.locator('text="my sample test"')).toBeVisible();
await expect.soft(page.locator('data-test-id=metadata-error')).not.toBeVisible();
await expect.soft(page.locator('data-test-id=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': `
import path from 'path';
const config = {
metadata: {
'revision.timestamp': 'hi',
},
}
export default config;
`,
'example.spec.ts': `
const { test } = pwt;
test('my sample test', async ({}) => { expect(2).toBe(2); });
`,
}, { reporter: 'dot,html' }, { PW_TEST_HTML_REPORT_OPEN: 'never' });
await showReport();
expect(result.exitCode).toBe(0);
await expect.soft(page.locator('text="my sample test"')).toBeVisible();
await expect.soft(page.locator('data-test-id=metadata-error')).toBeVisible();
await expect.soft(page.locator('data-test-id=metadata-chip')).not.toBeVisible();
});
}); });

View file

@ -66,6 +66,8 @@ export interface Config<TestArgs = {}, WorkerArgs = {}> extends TestConfig {
use?: UseOptions<TestArgs, WorkerArgs>; use?: UseOptions<TestArgs, WorkerArgs>;
} }
export type Metadata = { [key: string]: string | number | boolean };
// [internal] !!! DO NOT ADD TO THIS !!! // [internal] !!! DO NOT ADD TO THIS !!!
// [internal] It is part of the public API and is computed from the user's config. // [internal] It is part of the public API and is computed from the user's config.
// [internal] If you need new fields internally, add them to FullConfigInternal instead. // [internal] If you need new fields internally, add them to FullConfigInternal instead.
@ -78,6 +80,7 @@ export interface FullConfig<TestArgs = {}, WorkerArgs = {}> {
grep: RegExp | RegExp[]; grep: RegExp | RegExp[];
grepInvert: RegExp | RegExp[] | null; grepInvert: RegExp | RegExp[] | null;
maxFailures: number; maxFailures: number;
metadata: Metadata;
version: string; version: string;
preserveOutput: 'always' | 'never' | 'failures-only'; preserveOutput: 'always' | 'never' | 'failures-only';
projects: FullProject<TestArgs, WorkerArgs>[]; projects: FullProject<TestArgs, WorkerArgs>[];