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 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 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: '',

View File

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

View File

@ -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<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;
}
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<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 {
if (typeof chunk === 'string') {
return {

View File

@ -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<InternalGlobalSetupFunction> = [];
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<Config> {
@ -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') {

View File

@ -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[] = [];

View File

@ -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[]){

View File

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

View File

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

View File

@ -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<void>;
}
interface SuiteFunction {
(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.
*/
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)

View File

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

View File

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

View File

@ -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;
`,

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) => {
await runInlineTest({
'playwright.config.ts': `

View File

@ -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<void>;
}
interface SuiteFunction {
(title: string, callback: () => void): void;
}