feat: rewrite gitCommitInfo plugin, drop GlobalInfo & attachments (#13837)
This commit is contained in:
parent
ed344a882b
commit
3b3cad7d69
|
|
@ -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`].
|
||||
|
|
@ -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]>
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }];
|
||||
};
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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'] ],
|
||||
|
|
|
|||
97
packages/playwright-test/src/plugins/gitCommitInfoPlugin.ts
Normal file
97
packages/playwright-test/src/plugins/gitCommitInfoPlugin.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -14,3 +14,4 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
export { webServer } from './webServerPlugin';
|
||||
export { gitCommitInfo } from './gitCommitInfoPlugin';
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
126
packages/playwright-test/types/test.d.ts
vendored
126
packages/playwright-test/types/test.d.ts
vendored
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
28
packages/playwright-test/types/testReporter.d.ts
vendored
28
packages/playwright-test/types/testReporter.d.ts
vendored
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
154
tests/config/experimental.d.ts
vendored
154
tests/config/experimental.d.ts
vendored
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' }]],
|
||||
|
|
|
|||
|
|
@ -694,4 +694,3 @@ it('should not hang on resources served from cache', async ({ contextFactory, se
|
|||
else
|
||||
expect(entries.length).toBe(2);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -704,7 +704,8 @@ test('open tests from required file', async ({ runInlineTest, showReport, page }
|
|||
]);
|
||||
});
|
||||
|
||||
test('should include metadata', async ({ runInlineTest, showReport, page }) => {
|
||||
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 });
|
||||
|
|
@ -722,26 +723,12 @@ test('should include metadata', async ({ runInlineTest, showReport, page }) => {
|
|||
|
||||
const result = await runInlineTest({
|
||||
'uncommitted.txt': `uncommitted file`,
|
||||
'globalSetup.ts': `
|
||||
import { FullConfig, GlobalInfo } from '@playwright/test';
|
||||
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;
|
||||
`,
|
||||
'playwright.config.ts': `
|
||||
import path from 'path';
|
||||
import { gitCommitInfo } from '@playwright/test/lib/plugins';
|
||||
|
||||
const config = {
|
||||
globalSetup: path.join(__dirname, './globalSetup'),
|
||||
plugins: [ gitCommitInfo() ],
|
||||
}
|
||||
|
||||
export default config;
|
||||
|
|
@ -764,4 +751,105 @@ test('should include metadata', async ({ runInlineTest, showReport, page }) => {
|
|||
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 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';
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
3
utils/generate_types/overrides-test.d.ts
vendored
3
utils/generate_types/overrides-test.d.ts
vendored
|
|
@ -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>[];
|
||||
|
|
|
|||
Loading…
Reference in a new issue