feat(test-runner): introduce GlobalInfo (#13083)

This commit is contained in:
Ross Wollman 2022-04-08 13:22:14 -07:00 committed by GitHub
parent f0156d057e
commit 1af32e400f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 420 additions and 109 deletions

View File

@ -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`].

View File

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

View File

@ -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));
}
}

View File

@ -15,7 +15,7 @@
*/ */
import { installTransform, setCurrentlyLoadingTestFile } from './transform'; 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 type { FullConfigInternal } from './types';
import { getPackageJsonPath, mergeObjects, errorWithFile } from './util'; import { getPackageJsonPath, mergeObjects, errorWithFile } from './util';
import { setCurrentlyLoadingFileSuite } from './globals'; import { setCurrentlyLoadingFileSuite } from './globals';
@ -102,6 +102,7 @@ export class Loader {
this._fullConfig._configDir = configDir; this._fullConfig._configDir = configDir;
this._fullConfig.rootDir = config.testDir || this._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.forbidOnly = takeFirst(this._configOverrides.forbidOnly, config.forbidOnly, baseFullConfig.forbidOnly);
this._fullConfig.fullyParallel = takeFirst(this._configOverrides.fullyParallel, config.fullyParallel, baseFullConfig.fullyParallel); this._fullConfig.fullyParallel = takeFirst(this._configOverrides.fullyParallel, config.fullyParallel, baseFullConfig.fullyParallel);
this._fullConfig.globalSetup = takeFirst(this._configOverrides.globalSetup, config.globalSetup, baseFullConfig.globalSetup); this._fullConfig.globalSetup = takeFirst(this._configOverrides.globalSetup, config.globalSetup, baseFullConfig.globalSetup);
@ -169,7 +170,7 @@ export class Loader {
return suite; 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); let hook = await this._requireOrImport(file);
if (hook && typeof hook === 'object' && ('default' in hook)) if (hook && typeof hook === 'object' && ('default' in hook))
hook = hook['default']; hook = hook['default'];
@ -474,7 +475,7 @@ const baseFullConfig: FullConfigInternal = {
version: require('../package.json').version, version: require('../package.json').version,
workers: 1, workers: 1,
webServer: null, webServer: null,
_attachments: [], _globalOutputDir: path.resolve(process.cwd()),
_configDir: '', _configDir: '',
_testGroupsCount: 0, _testGroupsCount: 0,
_screenshotsDir: '', _screenshotsDir: '',

View File

@ -158,12 +158,12 @@ class HtmlReporter implements Reporter {
const projectSuites = this.suite.suites; const projectSuites = this.suite.suites;
const reports = projectSuites.map(suite => { const reports = projectSuites.map(suite => {
const rawReporter = new RawReporter(); const rawReporter = new RawReporter();
const report = rawReporter.generateProjectReport(this.config, suite); const report = rawReporter.generateProjectReport(this.config, suite, []);
return report; return report;
}); });
await removeFolders([outputFolder]); await removeFolders([outputFolder]);
const builder = new HtmlBuilder(outputFolder); const builder = new HtmlBuilder(outputFolder);
const { ok, singleTestId } = await builder.build(new RawReporter().generateAttachments(this.config), reports); const { ok, singleTestId } = await builder.build(new RawReporter().generateAttachments(this.suite.attachments), reports);
if (process.env.CI) if (process.env.CI)
return; return;

View File

@ -23,7 +23,6 @@ import { formatResultFailure } from './base';
import { toPosixPath, serializePatterns } from './json'; import { toPosixPath, serializePatterns } from './json';
import { MultiMap } from 'playwright-core/lib/utils/multimap'; import { MultiMap } from 'playwright-core/lib/utils/multimap';
import { codeFrameColumns } from '@babel/code-frame'; import { codeFrameColumns } from '@babel/code-frame';
import type { FullConfigInternal } from '../types';
export type JsonLocation = Location; export type JsonLocation = Location;
export type JsonError = string; export type JsonError = string;
@ -31,6 +30,7 @@ export type JsonStackFrame = { file: string, line: number, column: number };
export type JsonReport = { export type JsonReport = {
config: JsonConfig, config: JsonConfig,
attachments: JsonAttachment[],
project: JsonProject, project: JsonProject,
suites: JsonSuite[], suites: JsonSuite[],
}; };
@ -112,6 +112,7 @@ class RawReporter {
async onEnd() { async onEnd() {
const projectSuites = this.suite.suites; const projectSuites = this.suite.suites;
const globalAttachments = this.generateAttachments(this.suite.attachments);
for (const suite of projectSuites) { for (const suite of projectSuites) {
const project = suite.project(); const project = suite.project();
assert(project, 'Internal Error: Invalid project structure'); assert(project, 'Internal Error: Invalid project structure');
@ -129,21 +130,46 @@ class RawReporter {
} }
if (!reportFile) if (!reportFile)
throw new Error('Internal error, could not create report file'); throw new Error('Internal error, could not create report file');
const report = this.generateProjectReport(this.config, suite); const report = this.generateProjectReport(this.config, suite, globalAttachments);
fs.writeFileSync(reportFile, JSON.stringify(report, undefined, 2)); fs.writeFileSync(reportFile, JSON.stringify(report, undefined, 2));
} }
} }
generateAttachments(config: FullConfig): JsonAttachment[] { generateAttachments(attachments: TestResult['attachments'], ioStreams?: Pick<TestResult, 'stdout' | 'stderr'>): JsonAttachment[] {
return this._createAttachments((config as FullConfigInternal)._attachments); 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; this.config = config;
const project = suite.project(); const project = suite.project();
assert(project, 'Internal Error: Invalid project structure'); assert(project, 'Internal Error: Invalid project structure');
const report: JsonReport = { const report: JsonReport = {
config, config,
attachments,
project: { project: {
metadata: project.metadata, metadata: project.metadata,
name: project.name, name: project.name,
@ -228,7 +254,7 @@ class RawReporter {
duration: result.duration, duration: result.duration,
status: result.status, status: result.status,
errors: formatResultFailure(this.config, test, result, '', true).map(error => error.message), 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))) steps: dedupeSteps(result.steps.map(step => this._serializeStep(test, step)))
}; };
} }
@ -250,34 +276,6 @@ class RawReporter {
return result; return result;
} }
private _createAttachments(attachments: TestResult['attachments'], ioStreams?: Pick<TestResult, 'stdout' | 'stderr'>): 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 { private _stdioAttachment(chunk: Buffer | string, type: 'stdout' | 'stderr'): JsonAttachment {
if (typeof chunk === 'string') { if (typeof chunk === 'string') {
return { return {

View File

@ -44,6 +44,7 @@ import type { FullConfigInternal } from './types';
import { WebServer } from './webServer'; import { WebServer } from './webServer';
import { raceAgainstTimeout } from 'playwright-core/lib/utils/timeoutRunner'; import { raceAgainstTimeout } from 'playwright-core/lib/utils/timeoutRunner';
import { SigIntWatcher } from './sigIntWatcher'; import { SigIntWatcher } from './sigIntWatcher';
import { GlobalInfoImpl } from './globalInfo';
const removeFolderAsync = promisify(rimraf); const removeFolderAsync = promisify(rimraf);
const readDirAsync = promisify(fs.readdir); const readDirAsync = promisify(fs.readdir);
@ -62,9 +63,11 @@ export class Runner {
private _loader: Loader; private _loader: Loader;
private _reporter!: Reporter; private _reporter!: Reporter;
private _internalGlobalSetups: Array<InternalGlobalSetupFunction> = []; private _internalGlobalSetups: Array<InternalGlobalSetupFunction> = [];
private _globalInfo: GlobalInfoImpl;
constructor(configOverrides: Config, options: { defaultConfig?: Config } = {}) { constructor(configOverrides: Config, options: { defaultConfig?: Config } = {}) {
this._loader = new Loader(options.defaultConfig || {}, configOverrides); this._loader = new Loader(options.defaultConfig || {}, configOverrides);
this._globalInfo = new GlobalInfoImpl(this._loader.fullConfig());
} }
async loadConfigFromResolvedFile(resolvedConfigFile: string): Promise<Config> { async loadConfigFromResolvedFile(resolvedConfigFile: string): Promise<Config> {
@ -393,6 +396,9 @@ export class Runner {
const result: FullResult = { status: 'passed' }; const result: FullResult = { status: 'passed' };
// 13.5 Add copy of attachments.
rootSuite.attachments = this._globalInfo.attachments();
// 14. Run tests. // 14. Run tests.
try { try {
const sigintWatcher = new SigIntWatcher(); const sigintWatcher = new SigIntWatcher();
@ -455,7 +461,7 @@ export class Runner {
internalGlobalTeardowns.push(await internalGlobalSetup()); internalGlobalTeardowns.push(await internalGlobalSetup());
webServer = config.webServer ? await WebServer.create(config.webServer, this._reporter) : undefined; webServer = config.webServer ? await WebServer.create(config.webServer, this._reporter) : undefined;
if (config.globalSetup) 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); }, result);
if (result.status !== 'passed') { if (result.status !== 'passed') {

View File

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

View File

@ -15,9 +15,7 @@
*/ */
import fs from 'fs'; import fs from 'fs';
import * as mime from 'mime';
import path from 'path'; import path from 'path';
import { calculateSha1 } from 'playwright-core/lib/utils';
import type { TestError, TestInfo, TestStatus } from '../types/test'; import type { TestError, TestInfo, TestStatus } from '../types/test';
import type { FullConfigInternal, FullProjectInternal } from './types'; import type { FullConfigInternal, FullProjectInternal } from './types';
import type { WorkerInitParams } from './ipc'; import type { WorkerInitParams } from './ipc';
@ -26,7 +24,7 @@ import type { ProjectImpl } from './project';
import type { TestCase } from './test'; import type { TestCase } from './test';
import { TimeoutManager } from './timeoutManager'; import { TimeoutManager } from './timeoutManager';
import type { Annotation, TestStepInternal } from './types'; 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 { export class TestInfoImpl implements TestInfo {
private _projectImpl: ProjectImpl; private _projectImpl: ProjectImpl;
@ -231,19 +229,7 @@ export class TestInfoImpl implements TestInfo {
// ------------ TestInfo methods ------------ // ------------ TestInfo methods ------------
async attach(name: string, options: { path?: string, body?: string | Buffer, contentType?: string } = {}) { async attach(name: string, options: { path?: string, body?: string | Buffer, contentType?: string } = {}) {
if ((options.path !== undefined ? 1 : 0) + (options.body !== undefined ? 1 : 0) !== 1) this.attachments.push(await normalizeAndSaveAttachment(this.outputPath(), name, options));
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 });
}
} }
outputPath(...pathSegments: string[]){ outputPath(...pathSegments: string[]){

View File

@ -41,9 +41,13 @@ export interface TestStepInternal {
* increasing the surface area of the public API type called FullConfig. * increasing the surface area of the public API type called FullConfig.
*/ */
export interface FullConfigInternal extends FullConfigPublic { 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; _configDir: string;
_testGroupsCount: number; _testGroupsCount: number;
_attachments: { name: string, path?: string, body?: Buffer, contentType: string }[];
_screenshotsDir: string; _screenshotsDir: string;
// Overrides the public field. // Overrides the public field.

View File

@ -14,8 +14,9 @@
* limitations under the License. * limitations under the License.
*/ */
import util from 'util';
import fs from 'fs'; import fs from 'fs';
import * as mime from 'mime';
import util from 'util';
import path from 'path'; import path from 'path';
import url from 'url'; import url from 'url';
import colors from 'colors/safe'; import colors from 'colors/safe';
@ -279,3 +280,19 @@ export function getPackageJsonPath(folderPath: string): string {
folderToPackageJsonPath.set(folderPath, result); folderToPackageJsonPath.set(folderPath, result);
return 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 };
}
}

View File

@ -1630,6 +1630,84 @@ export interface TestInfo {
*/ */
workerIndex: number;} 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<void>;
}
interface SuiteFunction { interface SuiteFunction {
(title: string, callback: () => void): void; (title: string, callback: () => void): void;
} }

View File

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

View File

@ -13,21 +13,19 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * 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 // We're dogfooding this, so the …/lib/… import is acceptable
import * as ci from '@playwright/test/lib/ci'; import * as ci from '@playwright/test/lib/ci';
async function globalSetup(config: FullConfig) { async function globalSetup(config: FullConfig, globalInfo: GlobalInfo) {
(config as any)._attachments = [ const pluginResults = await Promise.all([
...await ci.generationTimestamp(), ci.generationTimestamp(),
...await ci.gitStatusFromCLI(config.rootDir), ci.gitStatusFromCLI(config.rootDir),
...await ci.githubEnv(), 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 await Promise.all(pluginResults.flat().map(attachment => globalInfo.attach(attachment.name, attachment)));
// of manually manipulating the attachments array)
];
} }
export default globalSetup; export default globalSetup;

View File

@ -186,34 +186,3 @@ test(`testInfo.attach allow empty buffer body`, async ({ runInlineTest }) => {
expect(result.failed).toBe(1); expect(result.failed).toBe(1);
expect(stripAnsi(result.output)).toMatch(/^.*attachment #1: name \(text\/plain\).*\n.*\n.*------/gm); 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');
});

View File

@ -723,16 +723,18 @@ test('should include metadata', async ({ runInlineTest, showReport, page }) => {
const result = await runInlineTest({ const result = await runInlineTest({
'uncommitted.txt': `uncommitted file`, 'uncommitted.txt': `uncommitted file`,
'globalSetup.ts': ` 'globalSetup.ts': `
import { FullConfig, GlobalInfo } from '@playwright/test';
import * as ci from '@playwright/test/lib/ci'; import * as ci from '@playwright/test/lib/ci';
import { FullConfig } from '@playwright/test';
async function globalSetup(config: FullConfig) { async function globalSetup(config: FullConfig, globalInfo: GlobalInfo) {
(config as any)._attachments = [ const pluginResults = await Promise.all([
...await ci.generationTimestamp(), ci.generationTimestamp(),
...await ci.gitStatusFromCLI(config.rootDir), ci.gitStatusFromCLI(config.rootDir),
...await ci.githubEnv(), ci.githubEnv(),
]; ]);
};
await Promise.all(pluginResults.flat().map(attachment => globalInfo.attach(attachment.name, attachment)));
}
export default globalSetup; export default globalSetup;
`, `,

View File

@ -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) => { test('dupe project names', async ({ runInlineTest }, testInfo) => {
await runInlineTest({ await runInlineTest({
'playwright.config.ts': ` 'playwright.config.ts': `

View File

@ -199,6 +199,11 @@ export interface TestInfo {
status?: TestStatus; 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<void>;
}
interface SuiteFunction { interface SuiteFunction {
(title: string, callback: () => void): void; (title: string, callback: () => void): void;
} }