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
- 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
- type: ?<[string]>

View file

@ -63,12 +63,3 @@ Suite title.
- returns: <[Array]<[string]>>
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,
setExpanded?: (expanded: boolean) => void,
children?: any,
}> = ({ header, expanded, setExpanded, children, noInsets }) => {
return <div className='chip'>
dataTestId?: string,
}> = ({ header, expanded, setExpanded, children, noInsets, dataTestId }) => {
return <div className='chip' data-test-id={dataTestId}>
<div
className={'chip-header' + (setExpanded ? ' expanded-' + expanded : '')}
onClick={() => setExpanded?.(!expanded)}
@ -45,13 +46,15 @@ export const AutoChip: React.FC<{
initialExpanded?: boolean,
noInsets?: boolean,
children?: any,
}> = ({ header, initialExpanded, noInsets, children }) => {
dataTestId?: string,
}> = ({ header, initialExpanded, noInsets, children, dataTestId }) => {
const [expanded, setExpanded] = React.useState(initialExpanded || initialExpanded === undefined);
return <Chip
header={header}
expanded={expanded}
setExpanded={setExpanded}
noInsets={noInsets}
dataTestId={dataTestId}
>
{children}
</Chip>;

View file

@ -14,7 +14,7 @@
* 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';
// @ts-ignore
import zipImport from '@zip.js/zip.js/dist/zip-no-worker-inflate.min.js';
@ -26,59 +26,6 @@ import { ReportView } from './reportView';
// @ts-ignore
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 [report, setReport] = React.useState<LoadedReport | undefined>();
React.useEffect(() => {
@ -96,14 +43,13 @@ window.onload = () => {
class ZipReport implements LoadedReport {
private _entries = new Map<string, zip.Entry>();
private _json!: HTMLReport & { metadata?: Metadata };
private _json!: HTMLReport;
async load() {
const zipReader = new zipjs.ZipReader(new zipjs.Data64URIReader((window as any).playwrightReportBase64), { useWebWorkers: false }) as zip.ZipReader;
for (const entry of await zipReader.getEntries())
this._entries.set(entry.filename, entry);
this._json = await this.entry('report.json') as HTMLReport;
this._json.metadata = extractMetadata(this._json.attachments);
}
json(): HTMLReport {

View file

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

View file

@ -18,21 +18,61 @@ import * as React from 'react';
import './colors.css';
import './common.css';
import * as icons from './icons';
import type { Metadata } from './index';
import { AutoChip } from './chip';
import './reportView.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 (
<AutoChip header={
<span>
{metadata['revision.id'] && <span style={{ float: 'right', fontFamily: 'var(--monospace-font)' }}>
{metadata['revision.id'].slice(0, 7)}
</span>}
{metadata['revision.subject'] && metadata['revision.subject'] || 'no subject>'}
{!metadata['revision.subject'] && 'Commit metainfo'}
</span>} initialExpanded={false}>
{metadata['revision.subject'] || 'Commit Metainfo'}
</span>} initialExpanded={false} dataTestId='metadata-chip'>
{metadata['revision.subject'] &&
<MetadatViewItem
testId='revision.subject'
@ -73,10 +113,10 @@ export const MetadataView: React.FC<Metadata> = metadata => {
icon='externalLink'
/>
}
{metadata['generatedAt'] &&
{metadata['timestamp'] &&
<MetadatViewItem
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>
}
</AutoChip>

View file

@ -23,6 +23,7 @@ import { HeaderView } from './headerView';
import { Route } from './links';
import type { LoadedReport } from './loadedReport';
import './reportView.css';
import type { Metainfo } from './metadataView';
import { MetadataView } from './metadataView';
import { TestCaseView } from './testCaseView';
import { TestFilesView } from './testFilesView';
@ -46,7 +47,7 @@ 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!} />}
{report?.json().metadata && <MetadataView {...report?.json().metadata as Metainfo} />}
<Route params=''>
<TestFilesView report={report?.json()} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles}></TestFilesView>
</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 type { Config, Project, ReporterDescription, FullProjectInternal, GlobalInfo } from './types';
import type { Config, Project, ReporterDescription, FullProjectInternal } from './types';
import type { FullConfigInternal } from './types';
import { getPackageJsonPath, mergeObjects, errorWithFile } from './util';
import { setCurrentlyLoadingFileSuite } from './globals';
@ -163,6 +163,7 @@ export class Loader {
this._fullConfig.workers = takeFirst(config.workers, baseFullConfig.workers);
this._fullConfig.webServer = takeFirst(config.webServer, baseFullConfig.webServer);
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));
}
@ -210,7 +211,7 @@ export class Loader {
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);
if (hook && typeof hook === 'object' && ('default' in hook))
hook = hook['default'];
@ -516,6 +517,7 @@ export const baseFullConfig: FullConfigInternal = {
grep: /.*/,
grepInvert: null,
maxFailures: 0,
metadata: {},
preserveOutput: 'always',
projects: [],
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.
*/
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 { stripAnsiEscapes } from './base';
import { getPackageJsonPath } from '../util';
import type { FullConfigInternal } from '../types';
import type { FullConfigInternal, Metadata } from '../types';
import type { ZipFile } from 'playwright-core/lib/zipBundle';
import { yazl } from 'playwright-core/lib/zipBundle';
@ -49,7 +49,7 @@ export type Location = {
};
export type HTMLReport = {
attachments: TestAttachment[];
metadata: Metadata;
files: TestFileSummary[];
stats: Stats;
projectNames: string[];
@ -159,12 +159,12 @@ class HtmlReporter implements Reporter {
const projectSuites = this.suite.suites;
const reports = projectSuites.map(suite => {
const rawReporter = new RawReporter();
const report = rawReporter.generateProjectReport(this.config, suite, []);
const report = rawReporter.generateProjectReport(this.config, suite);
return report;
});
await removeFolders([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)
return;
@ -255,7 +255,7 @@ class HtmlBuilder {
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 }>();
for (const projectJson of rawReports) {
@ -311,7 +311,7 @@ class HtmlBuilder {
this._addDataFile(fileId + '.json', testFile);
}
const htmlReport: HTMLReport = {
attachments: this._serializeAttachments(testReportAttachments),
metadata,
files: [...data.values()].map(e => e.testFileSummary),
projectNames: rawReports.map(r => r.project.name),
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 { MultiMap } from 'playwright-core/lib/utils/multimap';
import { codeFrameColumns } from '../babelBundle';
import type { Metadata } from '../types';
export type JsonLocation = Location;
export type JsonError = string;
@ -30,7 +31,6 @@ export type JsonStackFrame = { file: string, line: number, column: number };
export type JsonReport = {
config: JsonConfig,
attachments: JsonAttachment[],
project: JsonProject,
suites: JsonSuite[],
};
@ -38,7 +38,7 @@ export type JsonReport = {
export type JsonConfig = Omit<FullConfig, 'projects' | 'attachments'>;
export type JsonProject = {
metadata: any,
metadata: Metadata,
name: string,
outputDir: string,
repeatEach: number,
@ -112,7 +112,6 @@ class RawReporter {
async onEnd() {
const projectSuites = this.suite.suites;
const globalAttachments = this.generateAttachments(this.suite.attachments);
for (const suite of projectSuites) {
const project = suite.project();
assert(project, 'Internal Error: Invalid project structure');
@ -130,7 +129,7 @@ class RawReporter {
}
if (!reportFile)
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));
}
}
@ -163,13 +162,12 @@ class RawReporter {
return out;
}
generateProjectReport(config: FullConfig, suite: Suite, attachments: JsonAttachment[]): JsonReport {
generateProjectReport(config: FullConfig, suite: Suite): JsonReport {
this.config = config;
const project = suite.project();
assert(project, 'Internal Error: Invalid project structure');
const report: JsonReport = {
config,
attachments,
project: {
metadata: project.metadata,
name: project.name,

View file

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

View file

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

View file

@ -628,9 +628,9 @@ interface TestConfig {
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
@ -921,6 +921,8 @@ export interface Config<TestArgs = {}, WorkerArgs = {}> extends TestConfig {
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
* `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;
/**
* Metadata that will be put directly to the test report serialized as JSON.
*/
metadata: Metadata;
version: string;
/**
* Whether to preserve test output in the
@ -3501,122 +3507,6 @@ interface ScreenshotAssertions {
}): 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.
*/

View file

@ -84,33 +84,7 @@ export interface Suite {
/**
* Returns a list of titles from the root down to this suite.
*/
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;
}>;}
titlePath(): Array<string>;}
/**
* `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 * as path from 'path';
import type { ServerWorkerOptions } from '../config/serverFixtures';
import { gitCommitInfo } from '@playwright/test/lib/plugins';
process.env.PWPAGE_IMPL = 'android';
const outputDir = path.join(__dirname, '..', '..', 'test-results');
const testDir = path.join(__dirname, '..');
const config: Config<ServerWorkerOptions & PlaywrightWorkerOptions & PlaywrightTestOptions> = {
globalSetup: path.join(__dirname, '../config/globalSetup'),
plugins: [
gitCommitInfo(),
],
testDir,
outputDir,
timeout: 120000,

View file

@ -16865,9 +16865,9 @@ interface TestConfig {
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
@ -17196,6 +17196,8 @@ export interface Config<TestArgs = {}, WorkerArgs = {}> extends TestConfig {
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
* `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;
/**
* Metadata that will be put directly to the test report serialized as JSON.
*/
metadata: Metadata;
version: string;
/**
* Whether to preserve test output in the
@ -19962,122 +19968,6 @@ interface ScreenshotAssertions {
}): 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.
*/
@ -20609,33 +20499,7 @@ export interface Suite {
/**
* Returns a list of titles from the root down to this suite.
*/
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;
}>;}
titlePath(): Array<string>;}
/**
* `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 * as path from 'path';
import type { CoverageWorkerOptions } from '../config/coverageFixtures';
import { gitCommitInfo } from '@playwright/test/lib/plugins';
process.env.PWPAGE_IMPL = 'electron';
const outputDir = path.join(__dirname, '..', '..', 'test-results');
const testDir = path.join(__dirname, '..');
const config: Config<CoverageWorkerOptions & PlaywrightWorkerOptions & PlaywrightTestOptions> = {
globalSetup: path.join(__dirname, '../config/globalSetup'),
plugins: [
gitCommitInfo(),
],
testDir,
outputDir,
timeout: 30000,

View file

@ -19,9 +19,13 @@ import type { PlaywrightTestConfig } from '@playwright/test';
import { config as loadEnv } from 'dotenv';
loadEnv({ path: path.join(__dirname, '..', '..', '.env') });
import { gitCommitInfo } from '@playwright/test/lib/plugins';
const config: PlaywrightTestConfig = {
plugins: [
gitCommitInfo(),
],
testIgnore: '**\/fixture-scripts/**',
globalSetup: path.join(__dirname, 'globalSetup'),
timeout: 5 * 60 * 1000,
retries: 0,
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
expect(entries.length).toBe(2);
});

View file

@ -21,6 +21,7 @@ import type { Config, PlaywrightTestOptions, PlaywrightWorkerOptions } from '@pl
import * as path from 'path';
import type { TestModeWorkerOptions } from '../config/testModeFixtures';
import type { CoverageWorkerOptions } from '../config/coverageFixtures';
import { gitCommitInfo } from '@playwright/test/lib/plugins';
type BrowserName = 'chromium' | 'firefox' | 'webkit';
@ -44,7 +45,9 @@ const trace = !!process.env.PWTEST_TRACE;
const outputDir = path.join(__dirname, '..', '..', 'test-results');
const testDir = path.join(__dirname, '..');
const config: Config<CoverageWorkerOptions & PlaywrightWorkerOptions & PlaywrightTestOptions & TestModeWorkerOptions> = {
globalSetup: path.join(__dirname, '../config/globalSetup'),
plugins: [
gitCommitInfo(),
],
testDir,
outputDir,
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 }) => {
const beforeRunPlaywrightTest = async ({ baseDir }: { baseDir: string }) => {
const execGit = async (args: string[]) => {
const { code, stdout, stderr } = await spawnAsync('git', args, { stdio: 'pipe', cwd: baseDir });
if (!!code)
throw new Error(`Non-zero exit of:\n$ git ${args.join(' ')}\nConsole:\nstdout:\n${stdout}\n\nstderr:\n${stderr}\n\n`);
return;
test.describe('gitCommitInfo plugin', () => {
test('should include metadata', async ({ runInlineTest, showReport, page }) => {
const beforeRunPlaywrightTest = async ({ baseDir }: { baseDir: string }) => {
const execGit = async (args: string[]) => {
const { code, stdout, stderr } = await spawnAsync('git', args, { stdio: 'pipe', cwd: baseDir });
if (!!code)
throw new Error(`Non-zero exit of:\n$ git ${args.join(' ')}\nConsole:\nstdout:\n${stdout}\n\nstderr:\n${stderr}\n\n`);
return;
};
await execGit(['init']);
await execGit(['config', '--local', 'user.email', 'shakespeare@example.local']);
await execGit(['config', '--local', 'user.name', 'William']);
await execGit(['add', '*.ts']);
await execGit(['commit', '-m', 'awesome commit message']);
};
await execGit(['init']);
await execGit(['config', '--local', 'user.email', 'shakespeare@example.local']);
await execGit(['config', '--local', 'user.name', 'William']);
await execGit(['add', '*.ts']);
await execGit(['commit', '-m', 'awesome commit message']);
};
const result = await runInlineTest({
'uncommitted.txt': `uncommitted file`,
'playwright.config.ts': `
import path from 'path';
import { gitCommitInfo } from '@playwright/test/lib/plugins';
const result = await runInlineTest({
'uncommitted.txt': `uncommitted file`,
'globalSetup.ts': `
import { FullConfig, GlobalInfo } from '@playwright/test';
import * as ci from '@playwright/test/lib/ci';
const config = {
plugins: [ gitCommitInfo() ],
}
async function globalSetup(config: FullConfig, globalInfo: GlobalInfo) {
const pluginResults = await Promise.all([
ci.generationTimestamp(),
ci.gitStatusFromCLI(config.rootDir),
ci.githubEnv(),
]);
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 Promise.all(pluginResults.flat().map(attachment => globalInfo.attach(attachment.name, attachment)));
}
await showReport();
export default globalSetup;
`,
'playwright.config.ts': `
import path from 'path';
const config = {
globalSetup: path.join(__dirname, './globalSetup'),
}
expect(result.exitCode).toBe(0);
await page.click('text=awesome commit message');
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=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);
await page.click('text=awesome commit message');
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=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/);
const config = {
plugins: [ gitCommitInfo({
info: {
'revision.id': '1234567890',
'revision.subject': 'a better subject',
'revision.timestamp': new Date(),
'revision.author': 'William',
'revision.email': 'shakespeare@example.local',
},
}) ],
}
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>;
}
export type Metadata = { [key: string]: string | number | boolean };
// [internal] !!! DO NOT ADD TO THIS !!!
// [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.
@ -78,6 +80,7 @@ export interface FullConfig<TestArgs = {}, WorkerArgs = {}> {
grep: RegExp | RegExp[];
grepInvert: RegExp | RegExp[] | null;
maxFailures: number;
metadata: Metadata;
version: string;
preserveOutput: 'always' | 'never' | 'failures-only';
projects: FullProject<TestArgs, WorkerArgs>[];