diff --git a/docs/src/test-api/class-globalinfo.md b/docs/src/test-api/class-globalinfo.md deleted file mode 100644 index e220e74703..0000000000 --- a/docs/src/test-api/class-globalinfo.md +++ /dev/null @@ -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`]. diff --git a/docs/src/test-api/class-testconfig.md b/docs/src/test-api/class-testconfig.md index 29a01f8642..c78ea12a32 100644 --- a/docs/src/test-api/class-testconfig.md +++ b/docs/src/test-api/class-testconfig.md @@ -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]> diff --git a/docs/src/test-reporter-api/class-suite.md b/docs/src/test-reporter-api/class-suite.md index e5283fb370..ddf642bd00 100644 --- a/docs/src/test-reporter-api/class-suite.md +++ b/docs/src/test-reporter-api/class-suite.md @@ -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`]. diff --git a/packages/html-reporter/src/chip.tsx b/packages/html-reporter/src/chip.tsx index 20627c7a50..65f8863988 100644 --- a/packages/html-reporter/src/chip.tsx +++ b/packages/html-reporter/src/chip.tsx @@ -26,8 +26,9 @@ export const Chip: React.FC<{ noInsets?: boolean, setExpanded?: (expanded: boolean) => void, children?: any, -}> = ({ header, expanded, setExpanded, children, noInsets }) => { - return
+ dataTestId?: string, +}> = ({ header, expanded, setExpanded, children, noInsets, dataTestId }) => { + return
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 {children} ; diff --git a/packages/html-reporter/src/index.tsx b/packages/html-reporter/src/index.tsx index d2be445e5d..e26a01592c 100644 --- a/packages/html-reporter/src/index.tsx +++ b/packages/html-reporter/src/index.tsx @@ -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(); React.useEffect(() => { @@ -96,14 +43,13 @@ window.onload = () => { class ZipReport implements LoadedReport { private _entries = new Map(); - 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 { diff --git a/packages/html-reporter/src/loadedReport.ts b/packages/html-reporter/src/loadedReport.ts index e4d01d8c52..80606bf3b6 100644 --- a/packages/html-reporter/src/loadedReport.ts +++ b/packages/html-reporter/src/loadedReport.ts @@ -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; } diff --git a/packages/html-reporter/src/metadataView.tsx b/packages/html-reporter/src/metadataView.tsx index 16782fc00f..8c3d481878 100644 --- a/packages/html-reporter/src/metadataView.tsx +++ b/packages/html-reporter/src/metadataView.tsx @@ -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 => { +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 ( + +

An error was encountered when trying to render Commit Metainfo. Please file a GitHub issue to report this error.

+

+

{this.state.error?.message}
{this.state.error?.stack}
{this.state.errorInfo?.componentStack}
+

+
+ ); + } + + return this.props.children; + } +} + +export const MetadataView: React.FC = metadata => ; + +const InnerMetadataView: React.FC = metadata => { + if (!Object.keys(metadata).find(k => k.startsWith('revision.') || k.startsWith('ci.'))) + return null; + return ( {metadata['revision.id'] && {metadata['revision.id'].slice(0, 7)} } - {metadata['revision.subject'] && metadata['revision.subject'] || 'no subject>'} - {!metadata['revision.subject'] && 'Commit metainfo'} - } initialExpanded={false}> + {metadata['revision.subject'] || 'Commit Metainfo'} + } initialExpanded={false} dataTestId='metadata-chip'> {metadata['revision.subject'] && = metadata => { icon='externalLink' /> } - {metadata['generatedAt'] && + {metadata['timestamp'] && - 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'])} }> } diff --git a/packages/html-reporter/src/reportView.tsx b/packages/html-reporter/src/reportView.tsx index f65e7b2b88..2594160e88 100644 --- a/packages/html-reporter/src/reportView.tsx +++ b/packages/html-reporter/src/reportView.tsx @@ -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
{report?.json() && } - {report?.json().metadata && } + {report?.json().metadata && } diff --git a/packages/playwright-test/src/ci.ts b/packages/playwright-test/src/ci.ts deleted file mode 100644 index a259f914be..0000000000 --- a/packages/playwright-test/src/ci.ts +++ /dev/null @@ -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 => { - 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 => { - 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 => { - // 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 => { - // 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 => { - return [{ name: 'generatedAt', body: Buffer.from(JSON.stringify(Date.now())), contentType: kContentTypeJSON }]; -}; diff --git a/packages/playwright-test/src/globalInfo.ts b/packages/playwright-test/src/globalInfo.ts deleted file mode 100644 index 28d92256e3..0000000000 --- a/packages/playwright-test/src/globalInfo.ts +++ /dev/null @@ -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)); - } -} diff --git a/packages/playwright-test/src/loader.ts b/packages/playwright-test/src/loader.ts index cb8611c90a..3eecf0a6d0 100644 --- a/packages/playwright-test/src/loader.ts +++ b/packages/playwright-test/src/loader.ts @@ -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'] ], diff --git a/packages/playwright-test/src/plugins/gitCommitInfoPlugin.ts b/packages/playwright-test/src/plugins/gitCommitInfoPlugin.ts new file mode 100644 index 0000000000..b0ae3ddaf4 --- /dev/null +++ b/packages/playwright-test/src/plugins/gitCommitInfoPlugin.ts @@ -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 => { + 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 => { + 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, + }; +}; diff --git a/packages/playwright-test/src/plugins/index.ts b/packages/playwright-test/src/plugins/index.ts index a337ddc086..999abdc782 100644 --- a/packages/playwright-test/src/plugins/index.ts +++ b/packages/playwright-test/src/plugins/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export { webServer } from './webServerPlugin'; +export { gitCommitInfo } from './gitCommitInfoPlugin'; diff --git a/packages/playwright-test/src/reporters/html.ts b/packages/playwright-test/src/reporters/html.ts index 7607e86a95..07de403e2e 100644 --- a/packages/playwright-test/src/reporters/html.ts +++ b/packages/playwright-test/src/reporters/html.ts @@ -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(); 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()) diff --git a/packages/playwright-test/src/reporters/raw.ts b/packages/playwright-test/src/reporters/raw.ts index 5752d57189..8f9cafe8ca 100644 --- a/packages/playwright-test/src/reporters/raw.ts +++ b/packages/playwright-test/src/reporters/raw.ts @@ -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; 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, diff --git a/packages/playwright-test/src/runner.ts b/packages/playwright-test/src/runner.ts index 4ab1738912..b4d13efb96 100644 --- a/packages/playwright-test/src/runner.ts +++ b/packages/playwright-test/src/runner.ts @@ -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 { @@ -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') { diff --git a/packages/playwright-test/src/test.ts b/packages/playwright-test/src/test.ts index d341af2cb4..1f433d4f43 100644 --- a/packages/playwright-test/src/test.ts +++ b/packages/playwright-test/src/test.ts @@ -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[] = []; diff --git a/packages/playwright-test/types/test.d.ts b/packages/playwright-test/types/test.d.ts index 8c6cf90304..3e57650f74 100644 --- a/packages/playwright-test/types/test.d.ts +++ b/packages/playwright-test/types/test.d.ts @@ -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 extends TestConfig { use?: UseOptions; } +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 { * */ 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; -} - /** * Information about an error thrown during test execution. */ diff --git a/packages/playwright-test/types/testReporter.d.ts b/packages/playwright-test/types/testReporter.d.ts index 6cd9a127cf..6b0a9139cb 100644 --- a/packages/playwright-test/types/testReporter.d.ts +++ b/packages/playwright-test/types/testReporter.d.ts @@ -84,33 +84,7 @@ export interface Suite { /** * Returns a list of titles from the root down to this suite. */ - titlePath(): Array; - - /** - * 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;} /** * `TestCase` corresponds to every [test.(call)(title, testFunction)](https://playwright.dev/docs/api/class-test#test-call) diff --git a/tests/android/playwright.config.ts b/tests/android/playwright.config.ts index 1aa3d12f33..42f55df5c3 100644 --- a/tests/android/playwright.config.ts +++ b/tests/android/playwright.config.ts @@ -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 = { - globalSetup: path.join(__dirname, '../config/globalSetup'), + plugins: [ + gitCommitInfo(), + ], testDir, outputDir, timeout: 120000, diff --git a/tests/config/experimental.d.ts b/tests/config/experimental.d.ts index 059362427b..9eb57bd5b5 100644 --- a/tests/config/experimental.d.ts +++ b/tests/config/experimental.d.ts @@ -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 extends TestConfig { use?: UseOptions; } +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 { * */ 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; -} - /** * 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; - - /** - * 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;} /** * `TestCase` corresponds to every [test.(call)(title, testFunction)](https://playwright.dev/docs/api/class-test#test-call) diff --git a/tests/config/globalSetup.ts b/tests/config/globalSetup.ts deleted file mode 100644 index 2ea4ef32fc..0000000000 --- a/tests/config/globalSetup.ts +++ /dev/null @@ -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; diff --git a/tests/electron/playwright.config.ts b/tests/electron/playwright.config.ts index b380043ff2..bd717461fd 100644 --- a/tests/electron/playwright.config.ts +++ b/tests/electron/playwright.config.ts @@ -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 = { - globalSetup: path.join(__dirname, '../config/globalSetup'), + plugins: [ + gitCommitInfo(), + ], testDir, outputDir, timeout: 30000, diff --git a/tests/installation/playwright.config.ts b/tests/installation/playwright.config.ts index 78217d407a..bebc4d162f 100644 --- a/tests/installation/playwright.config.ts +++ b/tests/installation/playwright.config.ts @@ -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' }]], diff --git a/tests/library/har.spec.ts b/tests/library/har.spec.ts index e2d394ff53..be4a456fff 100644 --- a/tests/library/har.spec.ts +++ b/tests/library/har.spec.ts @@ -694,4 +694,3 @@ it('should not hang on resources served from cache', async ({ contextFactory, se else expect(entries.length).toBe(2); }); - diff --git a/tests/library/playwright.config.ts b/tests/library/playwright.config.ts index 7dfbd05bb7..9ad08996fa 100644 --- a/tests/library/playwright.config.ts +++ b/tests/library/playwright.config.ts @@ -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 = { - globalSetup: path.join(__dirname, '../config/globalSetup'), + plugins: [ + gitCommitInfo(), + ], testDir, outputDir, expect: { diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index c256fe9941..3de4c993ea 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -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(); + }); }); diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index 8ef3ab7974..05ca59f5f9 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -66,6 +66,8 @@ export interface Config extends TestConfig { use?: UseOptions; } +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 { grep: RegExp | RegExp[]; grepInvert: RegExp | RegExp[] | null; maxFailures: number; + metadata: Metadata; version: string; preserveOutput: 'always' | 'never' | 'failures-only'; projects: FullProject[];