mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
feat(test-runner): introduce GlobalInfo (#13083)
This commit is contained in:
parent
f0156d057e
commit
1af32e400f
123
docs/src/test-api/class-globalinfo.md
Normal file
123
docs/src/test-api/class-globalinfo.md
Normal 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`].
|
@ -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`].
|
||||||
|
35
packages/playwright-test/src/globalInfo.ts
Normal file
35
packages/playwright-test/src/globalInfo.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
@ -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: '',
|
||||||
|
@ -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;
|
||||||
|
@ -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 {
|
||||||
|
@ -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') {
|
||||||
|
@ -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[] = [];
|
||||||
|
@ -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[]){
|
||||||
|
@ -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.
|
||||||
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
78
packages/playwright-test/types/test.d.ts
vendored
78
packages/playwright-test/types/test.d.ts
vendored
@ -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;
|
||||||
}
|
}
|
||||||
|
28
packages/playwright-test/types/testReporter.d.ts
vendored
28
packages/playwright-test/types/testReporter.d.ts
vendored
@ -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)
|
||||||
|
@ -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;
|
||||||
|
@ -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');
|
|
||||||
});
|
|
||||||
|
@ -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;
|
||||||
`,
|
`,
|
||||||
|
@ -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': `
|
||||||
|
5
utils/generate_types/overrides-test.d.ts
vendored
5
utils/generate_types/overrides-test.d.ts
vendored
@ -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;
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user