diff --git a/docs/src/test-api/class-globalinfo.md b/docs/src/test-api/class-globalinfo.md new file mode 100644 index 0000000000..1a368cceed --- /dev/null +++ b/docs/src/test-api/class-globalinfo.md @@ -0,0 +1,123 @@ +# 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. + +## 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-reporter-api/class-suite.md b/docs/src/test-reporter-api/class-suite.md index ddf642bd00..e5283fb370 100644 --- a/docs/src/test-reporter-api/class-suite.md +++ b/docs/src/test-reporter-api/class-suite.md @@ -63,3 +63,12 @@ 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/playwright-test/src/globalInfo.ts b/packages/playwright-test/src/globalInfo.ts new file mode 100644 index 0000000000..9d8f03f9d6 --- /dev/null +++ b/packages/playwright-test/src/globalInfo.ts @@ -0,0 +1,35 @@ +/** + * 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 6c19c9d9ff..e9ed83615e 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, PreserveOutput, FullProjectInternal } from './types'; +import type { Config, Project, ReporterDescription, PreserveOutput, FullProjectInternal, GlobalInfo } from './types'; import type { FullConfigInternal } from './types'; import { getPackageJsonPath, mergeObjects, errorWithFile } from './util'; import { setCurrentlyLoadingFileSuite } from './globals'; @@ -102,6 +102,7 @@ export class Loader { this._fullConfig._configDir = configDir; this._fullConfig.rootDir = config.testDir || this._configDir; + this._fullConfig._globalOutputDir = takeFirst(config.outputDir, throwawayArtifactsPath, baseFullConfig._globalOutputDir); this._fullConfig.forbidOnly = takeFirst(this._configOverrides.forbidOnly, config.forbidOnly, baseFullConfig.forbidOnly); this._fullConfig.fullyParallel = takeFirst(this._configOverrides.fullyParallel, config.fullyParallel, baseFullConfig.fullyParallel); this._fullConfig.globalSetup = takeFirst(this._configOverrides.globalSetup, config.globalSetup, baseFullConfig.globalSetup); @@ -169,7 +170,7 @@ export class Loader { return suite; } - async loadGlobalHook(file: string, name: string): Promise<(config: FullConfigInternal) => any> { + async loadGlobalHook(file: string, name: string): Promise<(config: FullConfigInternal, globalInfo?: GlobalInfo) => any> { let hook = await this._requireOrImport(file); if (hook && typeof hook === 'object' && ('default' in hook)) hook = hook['default']; @@ -474,7 +475,7 @@ const baseFullConfig: FullConfigInternal = { version: require('../package.json').version, workers: 1, webServer: null, - _attachments: [], + _globalOutputDir: path.resolve(process.cwd()), _configDir: '', _testGroupsCount: 0, _screenshotsDir: '', diff --git a/packages/playwright-test/src/reporters/html.ts b/packages/playwright-test/src/reporters/html.ts index 7ae48b2c29..fb0628be6f 100644 --- a/packages/playwright-test/src/reporters/html.ts +++ b/packages/playwright-test/src/reporters/html.ts @@ -158,12 +158,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.config), reports); + const { ok, singleTestId } = await builder.build(new RawReporter().generateAttachments(this.suite.attachments), reports); if (process.env.CI) return; diff --git a/packages/playwright-test/src/reporters/raw.ts b/packages/playwright-test/src/reporters/raw.ts index 9ae8e8c543..8f9e8128ce 100644 --- a/packages/playwright-test/src/reporters/raw.ts +++ b/packages/playwright-test/src/reporters/raw.ts @@ -23,7 +23,6 @@ import { formatResultFailure } from './base'; import { toPosixPath, serializePatterns } from './json'; import { MultiMap } from 'playwright-core/lib/utils/multimap'; import { codeFrameColumns } from '@babel/code-frame'; -import type { FullConfigInternal } from '../types'; export type JsonLocation = Location; export type JsonError = string; @@ -31,6 +30,7 @@ export type JsonStackFrame = { file: string, line: number, column: number }; export type JsonReport = { config: JsonConfig, + attachments: JsonAttachment[], project: JsonProject, suites: JsonSuite[], }; @@ -112,6 +112,7 @@ 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'); @@ -129,21 +130,46 @@ class RawReporter { } if (!reportFile) throw new Error('Internal error, could not create report file'); - const report = this.generateProjectReport(this.config, suite); + const report = this.generateProjectReport(this.config, suite, globalAttachments); fs.writeFileSync(reportFile, JSON.stringify(report, undefined, 2)); } } - generateAttachments(config: FullConfig): JsonAttachment[] { - return this._createAttachments((config as FullConfigInternal)._attachments); + generateAttachments(attachments: TestResult['attachments'], ioStreams?: Pick): JsonAttachment[] { + const out: JsonAttachment[] = []; + for (const attachment of attachments) { + if (attachment.body) { + out.push({ + name: attachment.name, + contentType: attachment.contentType, + body: attachment.body + }); + } else if (attachment.path) { + out.push({ + name: attachment.name, + contentType: attachment.contentType, + path: attachment.path + }); + } + } + + if (ioStreams) { + for (const chunk of ioStreams.stdout) + out.push(this._stdioAttachment(chunk, 'stdout')); + for (const chunk of ioStreams.stderr) + out.push(this._stdioAttachment(chunk, 'stderr')); + } + + return out; } - generateProjectReport(config: FullConfig, suite: Suite): JsonReport { + generateProjectReport(config: FullConfig, suite: Suite, attachments: JsonAttachment[]): 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, @@ -228,7 +254,7 @@ class RawReporter { duration: result.duration, status: result.status, errors: formatResultFailure(this.config, test, result, '', true).map(error => error.message), - attachments: this._createAttachments(result.attachments, result), + attachments: this.generateAttachments(result.attachments, result), steps: dedupeSteps(result.steps.map(step => this._serializeStep(test, step))) }; } @@ -250,34 +276,6 @@ class RawReporter { return result; } - private _createAttachments(attachments: TestResult['attachments'], ioStreams?: Pick): JsonAttachment[] { - const out: JsonAttachment[] = []; - for (const attachment of attachments) { - if (attachment.body) { - out.push({ - name: attachment.name, - contentType: attachment.contentType, - body: attachment.body - }); - } else if (attachment.path) { - out.push({ - name: attachment.name, - contentType: attachment.contentType, - path: attachment.path - }); - } - } - - if (ioStreams) { - for (const chunk of ioStreams.stdout) - out.push(this._stdioAttachment(chunk, 'stdout')); - for (const chunk of ioStreams.stderr) - out.push(this._stdioAttachment(chunk, 'stderr')); - } - - return out; - } - private _stdioAttachment(chunk: Buffer | string, type: 'stdout' | 'stderr'): JsonAttachment { if (typeof chunk === 'string') { return { diff --git a/packages/playwright-test/src/runner.ts b/packages/playwright-test/src/runner.ts index 3aefa5c3ed..305d82f175 100644 --- a/packages/playwright-test/src/runner.ts +++ b/packages/playwright-test/src/runner.ts @@ -44,6 +44,7 @@ import type { FullConfigInternal } from './types'; import { WebServer } from './webServer'; 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); @@ -62,9 +63,11 @@ export class Runner { private _loader: Loader; private _reporter!: Reporter; private _internalGlobalSetups: Array = []; + private _globalInfo: GlobalInfoImpl; constructor(configOverrides: Config, options: { defaultConfig?: Config } = {}) { this._loader = new Loader(options.defaultConfig || {}, configOverrides); + this._globalInfo = new GlobalInfoImpl(this._loader.fullConfig()); } async loadConfigFromResolvedFile(resolvedConfigFile: string): Promise { @@ -393,6 +396,9 @@ 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(); @@ -455,7 +461,7 @@ export class Runner { internalGlobalTeardowns.push(await internalGlobalSetup()); webServer = config.webServer ? await WebServer.create(config.webServer, this._reporter) : undefined; if (config.globalSetup) - globalSetupResult = await (await this._loader.loadGlobalHook(config.globalSetup, 'globalSetup'))(this._loader.fullConfig()); + globalSetupResult = await (await this._loader.loadGlobalHook(config.globalSetup, 'globalSetup'))(this._loader.fullConfig(), this._globalInfo); }, result); if (result.status !== 'passed') { diff --git a/packages/playwright-test/src/test.ts b/packages/playwright-test/src/test.ts index 652a63789c..7783d0fe3e 100644 --- a/packages/playwright-test/src/test.ts +++ b/packages/playwright-test/src/test.ts @@ -39,6 +39,7 @@ 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/src/testInfo.ts b/packages/playwright-test/src/testInfo.ts index 0bb2ab91c6..fdbdc48852 100644 --- a/packages/playwright-test/src/testInfo.ts +++ b/packages/playwright-test/src/testInfo.ts @@ -15,9 +15,7 @@ */ import fs from 'fs'; -import * as mime from 'mime'; import path from 'path'; -import { calculateSha1 } from 'playwright-core/lib/utils'; import type { TestError, TestInfo, TestStatus } from '../types/test'; import type { FullConfigInternal, FullProjectInternal } from './types'; import type { WorkerInitParams } from './ipc'; @@ -26,7 +24,7 @@ import type { ProjectImpl } from './project'; import type { TestCase } from './test'; import { TimeoutManager } from './timeoutManager'; import type { Annotation, TestStepInternal } from './types'; -import { addSuffixToFilePath, getContainedPath, monotonicTime, sanitizeForFilePath, serializeError, trimLongString } from './util'; +import { addSuffixToFilePath, getContainedPath, monotonicTime, normalizeAndSaveAttachment, sanitizeForFilePath, serializeError, trimLongString } from './util'; export class TestInfoImpl implements TestInfo { private _projectImpl: ProjectImpl; @@ -231,19 +229,7 @@ export class TestInfoImpl implements TestInfo { // ------------ TestInfo methods ------------ async attach(name: string, options: { path?: string, body?: string | Buffer, contentType?: string } = {}) { - if ((options.path !== undefined ? 1 : 0) + (options.body !== undefined ? 1 : 0) !== 1) - throw new Error(`Exactly one of "path" and "body" must be specified`); - if (options.path !== undefined) { - const hash = calculateSha1(options.path); - const dest = this.outputPath('attachments', hash + path.extname(options.path)); - await fs.promises.mkdir(path.dirname(dest), { recursive: true }); - await fs.promises.copyFile(options.path, dest); - const contentType = options.contentType ?? (mime.getType(path.basename(options.path)) || 'application/octet-stream'); - this.attachments.push({ name, contentType, path: dest }); - } else { - const contentType = options.contentType ?? (typeof options.body === 'string' ? 'text/plain' : 'application/octet-stream'); - this.attachments.push({ name, contentType, body: typeof options.body === 'string' ? Buffer.from(options.body) : options.body }); - } + this.attachments.push(await normalizeAndSaveAttachment(this.outputPath(), name, options)); } outputPath(...pathSegments: string[]){ diff --git a/packages/playwright-test/src/types.ts b/packages/playwright-test/src/types.ts index 9bd2247a40..c4455fca51 100644 --- a/packages/playwright-test/src/types.ts +++ b/packages/playwright-test/src/types.ts @@ -41,9 +41,13 @@ export interface TestStepInternal { * increasing the surface area of the public API type called FullConfig. */ export interface FullConfigInternal extends FullConfigPublic { + /** + * Location for GlobalInfo scoped data. This my differ from the projec-level outputDir + * since GlobalInfo (and this config), only respect top-level configurations. + */ + _globalOutputDir: string; _configDir: string; _testGroupsCount: number; - _attachments: { name: string, path?: string, body?: Buffer, contentType: string }[]; _screenshotsDir: string; // Overrides the public field. diff --git a/packages/playwright-test/src/util.ts b/packages/playwright-test/src/util.ts index 130dca1697..49d5b2b268 100644 --- a/packages/playwright-test/src/util.ts +++ b/packages/playwright-test/src/util.ts @@ -14,8 +14,9 @@ * limitations under the License. */ -import util from 'util'; import fs from 'fs'; +import * as mime from 'mime'; +import util from 'util'; import path from 'path'; import url from 'url'; import colors from 'colors/safe'; @@ -279,3 +280,19 @@ export function getPackageJsonPath(folderPath: string): string { folderToPackageJsonPath.set(folderPath, result); return result; } + +export async function normalizeAndSaveAttachment(outputPath: string, name: string, options: { path?: string, body?: string | Buffer, contentType?: string } = {}): Promise<{ name: string; path?: string | undefined; body?: Buffer | undefined; contentType: string; }> { + if ((options.path !== undefined ? 1 : 0) + (options.body !== undefined ? 1 : 0) !== 1) + throw new Error(`Exactly one of "path" and "body" must be specified`); + if (options.path !== undefined) { + const hash = calculateSha1(options.path); + const dest = path.join(outputPath, 'attachments', hash + path.extname(options.path)); + await fs.promises.mkdir(path.dirname(dest), { recursive: true }); + await fs.promises.copyFile(options.path, dest); + const contentType = options.contentType ?? (mime.getType(path.basename(options.path)) || 'application/octet-stream'); + return { name, contentType, path: dest }; + } else { + const contentType = options.contentType ?? (typeof options.body === 'string' ? 'text/plain' : 'application/octet-stream'); + return { name, contentType, body: typeof options.body === 'string' ? Buffer.from(options.body) : options.body }; + } +} diff --git a/packages/playwright-test/types/test.d.ts b/packages/playwright-test/types/test.d.ts index a9ebbab961..859ae55ae7 100644 --- a/packages/playwright-test/types/test.d.ts +++ b/packages/playwright-test/types/test.d.ts @@ -1630,6 +1630,84 @@ export interface TestInfo { */ workerIndex: number;} +/** + * `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(): { name: string, path?: string, body?: Buffer, contentType: string }[]; + /** + * 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 + * @param options + */ + attach(name: string, options?: { contentType?: string, path?: string, body?: string | Buffer }): Promise; +} + interface SuiteFunction { (title: string, callback: () => void): void; } diff --git a/packages/playwright-test/types/testReporter.d.ts b/packages/playwright-test/types/testReporter.d.ts index 8415ff7afe..c60bbbf24c 100644 --- a/packages/playwright-test/types/testReporter.d.ts +++ b/packages/playwright-test/types/testReporter.d.ts @@ -84,7 +84,33 @@ export interface Suite { /** * Returns a list of titles from the root down to this suite. */ - titlePath(): Array;} + 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; + }>;} /** * `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 index fec4194f31..2ea4ef32fc 100644 --- a/tests/config/globalSetup.ts +++ b/tests/config/globalSetup.ts @@ -13,21 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { FullConfig } from '@playwright/test'; +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) { - (config as any)._attachments = [ - ...await ci.generationTimestamp(), - ...await ci.gitStatusFromCLI(config.rootDir), - ...await ci.githubEnv(), - // In the future, we would add some additional plugins like: - // ...await ci.azurePipelinePlugin(), - // (and these would likley all get bundled into one call and controlled with one config instead - // of manually manipulating the attachments array) - ]; +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/playwright-test/reporter-attachment.spec.ts b/tests/playwright-test/reporter-attachment.spec.ts index aafb45ff30..bfc12005e8 100644 --- a/tests/playwright-test/reporter-attachment.spec.ts +++ b/tests/playwright-test/reporter-attachment.spec.ts @@ -186,34 +186,3 @@ test(`testInfo.attach allow empty buffer body`, async ({ runInlineTest }) => { expect(result.failed).toBe(1); expect(stripAnsi(result.output)).toMatch(/^.*attachment #1: name \(text\/plain\).*\n.*\n.*------/gm); }); - -test(`TestConfig.attachments works`, async ({ runInlineTest }) => { - const result = await runInlineTest({ - 'globalSetup.ts': ` - import { FullConfig } from '@playwright/test'; - - async function globalSetup(config: FullConfig) { - (config as any)._attachments = [{ contentType: 'text/plain', body: Buffer.from('example data'), name: 'my-attachment.txt' }]; - }; - - export default globalSetup; - `, - 'playwright.config.ts': ` - import path from 'path'; - const config = { - globalSetup: path.join(__dirname, './globalSetup'), - } - - export default config; - `, - 'example.spec.ts': ` - const { test } = pwt; - test('sample', async ({}) => { expect(2).toBe(2); }); - `, - }, { reporter: 'json' }); - - expect(result.exitCode).toBe(0); - expect((result.report.config as any)._attachments).toHaveLength(1); - expect((result.report.config as any)._attachments[0].name).toBe('my-attachment.txt'); - expect(Buffer.from((result.report.config as any)._attachments[0].body, 'base64').toString()).toBe('example data'); -}); diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 0099d7bc55..0df6fb58a1 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -723,16 +723,18 @@ 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'; - import { FullConfig } from '@playwright/test'; - async function globalSetup(config: FullConfig) { - (config as any)._attachments = [ - ...await ci.generationTimestamp(), - ...await ci.gitStatusFromCLI(config.rootDir), - ...await ci.githubEnv(), - ]; - }; + 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/playwright-test/reporter-raw.spec.ts b/tests/playwright-test/reporter-raw.spec.ts index 427e59f497..0c7a94d6d2 100644 --- a/tests/playwright-test/reporter-raw.spec.ts +++ b/tests/playwright-test/reporter-raw.spec.ts @@ -238,6 +238,59 @@ test(`testInfo.attach should save attachments via inline attachment`, async ({ r } }); +test(`GlobalInfo.attach works`, async ({ runInlineTest }, testInfo) => { + const external = testInfo.outputPath('external.txt'); + const result = await runInlineTest({ + 'globalSetup.ts': ` + import fs from 'fs'; + import { FullConfig, GlobalInfo } from '@playwright/test'; + + async function globalSetup(config: FullConfig, globalInfo: GlobalInfo) { + const external = '${external}'; + await fs.promises.writeFile(external, 'external'); + await globalInfo.attach('inline.txt', { body: Buffer.from('inline'), contentType: 'text/plain' }); + await globalInfo.attach('external.txt', { path: external, contentType: 'text/plain' }); + // The attach call above should have saved it to a safe place + await fs.promises.unlink(external); + }; + + export default globalSetup; + `, + 'playwright.config.ts': ` + import path from 'path'; + const config = { + globalSetup: path.join(__dirname, './globalSetup'), + } + + export default config; + `, + 'example.spec.ts': ` + const { test } = pwt; + test('sample', async ({}) => { expect(2).toBe(2); }); + `, + }, { reporter: 'dot,' + kRawReporterPath, workers: 1 }, {}, { usesCustomOutputDir: true }); + + expect(result.exitCode).toBe(0); + const outputPath = testInfo.outputPath('test-results', 'report', 'project.report'); + const json = JSON.parse(fs.readFileSync(outputPath, 'utf-8')); + { + const attachment = json.attachments[0]; + expect(attachment.name).toBe('inline.txt'); + expect(attachment.contentType).toBe('text/plain'); + expect(attachment.path).toBeUndefined(); + expect(Buffer.from(attachment.body, 'base64').toString()).toEqual('inline'); + } + { + const attachment = json.attachments[1]; + expect(attachment.name).toBe('external.txt'); + expect(attachment.contentType).toBe('text/plain'); + const contents = fs.readFileSync(attachment.path); + expect(attachment.path.startsWith(path.join(testInfo.outputDir, 'attachments')), 'Attachment should be in our output directory.').toBeTruthy(); + expect(contents.toString()).toEqual('external'); + expect(attachment.body).toBeUndefined(); + } +}); + test('dupe project names', async ({ runInlineTest }, testInfo) => { await runInlineTest({ 'playwright.config.ts': ` diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index db356f64ad..790c48981a 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -199,6 +199,11 @@ export interface TestInfo { status?: TestStatus; } +export interface GlobalInfo { + attachments(): { name: string, path?: string, body?: Buffer, contentType: string }[]; + attach(name: string, options?: { contentType?: string, path?: string, body?: string | Buffer }): Promise; +} + interface SuiteFunction { (title: string, callback: () => void): void; }