feat(test runner): createContext fixture for multi-context scenarios (#7779)

This commit is contained in:
Dmitry Gozman 2021-07-29 14:03:58 -07:00 committed by GitHub
parent 626dd23ce1
commit dd0b089d13
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 222 additions and 40 deletions

View File

@ -130,6 +130,66 @@ Learn how to [configure context](./test-configuration.md) through other fixtures
The [`property: Fixtures.page`] belongs to this context.
## property: Fixtures.createContext
- type: <[function]\([BrowserContextOptions]|[void]\):[BrowserContext]>
A function that creates a new context, taking into account all options set
through [configuration file](./test-configuration.md) or [`method: Test.use`] calls. All contexts created by this function are similar to the default [`property: Fixtures.context`].
This function is useful for multi-context scenarios, for example testing
two users talking over the chat application.
A single `options` argument will be merged with all the default options from [configuration file](./test-configuration.md) or [`method: Test.use`] calls and passed to [`method: Browser.newContext`]. If you'd like to undo some of these options, override them with some value or `undefined`. For example:
```js js-flavor=ts
// example.spec.ts
import { test } from '@playwright/test';
// All contexts will use this storage state.
test.use({ storageState: 'state.json' });
test('my test', async ({ createContext }) => {
// An isolated context
const context1 = await createContext();
// Another isolated context with custom options
const context2 = await createContext({
// Undo 'state.json' from above
storageState: undefined,
// Set custom locale
locale: 'en-US',
});
// ...
});
```
```js js-flavor=js
// example.spec.js
// @ts-check
const { test } = require('@playwright/test');
// All contexts will use this storage state.
test.use({ storageState: 'state.json' });
test('my test', async ({ createContext }) => {
// An isolated context
const context1 = await createContext();
// Another isolated context with custom options
const context2 = await createContext({
// Undo 'state.json' from above
storageState: undefined,
// Set custom locale
locale: 'en-US',
});
// ...
});
```
## property: Fixtures.contextOptions
- type: <[Object]>

View File

@ -61,6 +61,8 @@ export class Tracing implements InstrumentationListener {
if (this._recordingTraceEvents)
throw new Error('Tracing has already been started');
this._recordingTraceEvents = true;
// TODO: passing the same name for two contexts makes them write into a single file
// and conflict.
this._traceFile = path.join(this._tracesDir, (options.name || createGuid()) + '.trace');
this._appendEventChain = mkdirIfNeeded(this._traceFile);

View File

@ -17,7 +17,7 @@
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import type { LaunchOptions, BrowserContextOptions, Page } from '../../types/types';
import type { LaunchOptions, BrowserContextOptions, Page, BrowserContext } from '../../types/types';
import type { TestType, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions } from '../../types/test';
import { rootTestType } from './testType';
import { createGuid, removeFolders } from '../utils/utils';
@ -79,7 +79,7 @@ export const test = _baseTest.extend<PlaywrightTestArgs & PlaywrightTestOptions,
},
contextOptions: {},
context: async ({ browser, screenshot, trace, video, acceptDownloads, bypassCSP, colorScheme, deviceScaleFactor, extraHTTPHeaders, hasTouch, geolocation, httpCredentials, ignoreHTTPSErrors, isMobile, javaScriptEnabled, locale, offline, permissions, proxy, storageState, viewport, timezoneId, userAgent, baseURL, contextOptions }, use, testInfo) => {
createContext: async ({ browser, screenshot, trace, video, acceptDownloads, bypassCSP, colorScheme, deviceScaleFactor, extraHTTPHeaders, hasTouch, geolocation, httpCredentials, ignoreHTTPSErrors, isMobile, javaScriptEnabled, locale, offline, permissions, proxy, storageState, viewport, timezoneId, userAgent, baseURL, contextOptions }, use, testInfo) => {
testInfo.snapshotSuffix = process.platform;
if (process.env.PWDEBUG)
testInfo.setTimeout(0);
@ -93,17 +93,7 @@ export const test = _baseTest.extend<PlaywrightTestArgs & PlaywrightTestOptions,
const captureVideo = (videoMode === 'on' || videoMode === 'retain-on-failure' || (videoMode === 'on-first-retry' && testInfo.retry === 1));
const captureTrace = (trace === 'on' || trace === 'retain-on-failure' || (trace === 'on-first-retry' && testInfo.retry === 1));
let recordVideoDir: string | null = null;
const recordVideoSize = typeof video === 'string' ? undefined : video.size;
if (captureVideo) {
await fs.promises.mkdir(artifactsFolder, { recursive: true });
recordVideoDir = artifactsFolder;
}
const options: BrowserContextOptions = {
recordVideo: recordVideoDir ? { dir: recordVideoDir, size: recordVideoSize } : undefined,
...contextOptions,
};
const options: BrowserContextOptions = {};
if (acceptDownloads !== undefined)
options.acceptDownloads = acceptDownloads;
if (bypassCSP !== undefined)
@ -145,33 +135,55 @@ export const test = _baseTest.extend<PlaywrightTestArgs & PlaywrightTestOptions,
if (baseURL !== undefined)
options.baseURL = baseURL;
const context = await browser.newContext(options);
context.setDefaultTimeout(0);
const allContexts: BrowserContext[] = [];
const allPages: Page[] = [];
context.on('page', page => allPages.push(page));
if (captureTrace) {
const name = path.relative(testInfo.project.outputDir, testInfo.outputDir).replace(/[\/\\]/g, '-');
await context.tracing.start({ name, screenshots: true, snapshots: true });
}
await use(async (additionalOptions = {}) => {
let recordVideoDir: string | null = null;
const recordVideoSize = typeof video === 'string' ? undefined : video.size;
if (captureVideo) {
await fs.promises.mkdir(artifactsFolder, { recursive: true });
recordVideoDir = artifactsFolder;
}
await use(context);
const combinedOptions = {
recordVideo: recordVideoDir ? { dir: recordVideoDir, size: recordVideoSize } : undefined,
...contextOptions,
...options,
...additionalOptions,
};
const context = await browser.newContext(combinedOptions);
context.setDefaultTimeout(0);
context.on('page', page => allPages.push(page));
if (captureTrace) {
const name = path.relative(testInfo.project.outputDir, testInfo.outputDir).replace(/[\/\\]/g, '-');
const suffix = allContexts.length ? '-' + allContexts.length : '';
await context.tracing.start({ name: name + suffix, screenshots: true, snapshots: true });
}
allContexts.push(context);
return context;
});
const testFailed = testInfo.status !== testInfo.expectedStatus;
const preserveTrace = captureTrace && (trace === 'on' || (testFailed && trace === 'retain-on-failure') || (trace === 'on-first-retry' && testInfo.retry === 1));
if (preserveTrace) {
const tracePath = testInfo.outputPath(`trace.zip`);
await context.tracing.stop({ path: tracePath });
testInfo.attachments.push({ name: 'trace', path: tracePath, contentType: 'application/zip' });
} else if (captureTrace) {
await context.tracing.stop();
}
await Promise.all(allContexts.map(async (context, contextIndex) => {
const preserveTrace = captureTrace && (trace === 'on' || (testFailed && trace === 'retain-on-failure') || (trace === 'on-first-retry' && testInfo.retry === 1));
if (preserveTrace) {
const suffix = contextIndex ? '-' + contextIndex : '';
const tracePath = testInfo.outputPath(`trace${suffix}.zip`);
await context.tracing.stop({ path: tracePath });
testInfo.attachments.push({ name: 'trace', path: tracePath, contentType: 'application/zip' });
} else if (captureTrace) {
await context.tracing.stop();
}
}));
const captureScreenshots = (screenshot === 'on' || (screenshot === 'only-on-failure' && testFailed));
if (captureScreenshots) {
await Promise.all(allPages.map(async (page, index) => {
const screenshotPath = testInfo.outputPath(`test-${testFailed ? 'failed' : 'finished'}-${++index}.png`);
const screenshotPath = testInfo.outputPath(`test-${testFailed ? 'failed' : 'finished'}-${index + 1}.png`);
try {
await page.screenshot({ timeout: 5000, path: screenshotPath });
testInfo.attachments.push({ name: 'screenshot', path: screenshotPath, contentType: 'image/png' });
@ -180,8 +192,9 @@ export const test = _baseTest.extend<PlaywrightTestArgs & PlaywrightTestOptions,
}));
}
const prependToError = testInfo.status === 'timedOut' ? formatPendingCalls((context as any)._connection.pendingProtocolCalls()) : '';
await context.close();
const prependToError = (testInfo.status === 'timedOut' && allContexts.length) ?
formatPendingCalls((allContexts[0] as any)._connection.pendingProtocolCalls()) : '';
await Promise.all(allContexts.map(context => context.close()));
if (prependToError) {
if (!testInfo.error) {
testInfo.error = { value: prependToError };
@ -209,6 +222,10 @@ export const test = _baseTest.extend<PlaywrightTestArgs & PlaywrightTestOptions,
}
},
context: async ({ createContext }, use) => {
await use(await createContext());
},
page: async ({ context }, use) => {
await use(await context.newPage());
},

View File

@ -221,6 +221,8 @@ test('should work with screenshot: only-on-failure', async ({ runInlineTest }, t
});
test('fail', async ({ page }) => {
await page.setContent('<div>FAIL</div>');
const page2 = await page.context().newPage();
await page2.setContent('<div>FAIL</div>');
test.expect(1 + 1).toBe(1);
});
`,
@ -230,9 +232,11 @@ test('should work with screenshot: only-on-failure', async ({ runInlineTest }, t
expect(result.passed).toBe(1);
expect(result.failed).toBe(1);
const screenshotPass = testInfo.outputPath('test-results', 'a-pass-chromium', 'test-failed-1.png');
const screenshotFail = testInfo.outputPath('test-results', 'a-fail-chromium', 'test-failed-1.png');
const screenshotFail1 = testInfo.outputPath('test-results', 'a-fail-chromium', 'test-failed-1.png');
const screenshotFail2 = testInfo.outputPath('test-results', 'a-fail-chromium', 'test-failed-2.png');
expect(fs.existsSync(screenshotPass)).toBe(false);
expect(fs.existsSync(screenshotFail)).toBe(true);
expect(fs.existsSync(screenshotFail1)).toBe(true);
expect(fs.existsSync(screenshotFail2)).toBe(true);
});
test('should work with video: retain-on-failure', async ({ runInlineTest }, testInfo) => {
@ -327,3 +331,36 @@ test('should work with video size', async ({ runInlineTest }, testInfo) => {
expect(videoPlayer.videoWidth).toBe(220);
expect(videoPlayer.videoHeight).toBe(110);
});
test('should work with multiple contexts and trace: on', async ({ runInlineTest }, testInfo) => {
const result = await runInlineTest({
'playwright.config.ts': `
module.exports = { use: { trace: 'on' } };
`,
'a.test.ts': `
const { test } = pwt;
test('pass', async ({ page, createContext }) => {
await page.setContent('<div>PASS</div>');
const context1 = await createContext();
const page1 = await context1.newPage();
await page1.setContent('<div>PASS</div>');
const context2 = await createContext({ locale: 'en-US' });
const page2 = await context2.newPage();
await page2.setContent('<div>PASS</div>');
test.expect(1 + 1).toBe(2);
});
`,
}, { workers: 1 });
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(1);
const traceDefault = testInfo.outputPath('test-results', 'a-pass', 'trace.zip');
const trace1 = testInfo.outputPath('test-results', 'a-pass', 'trace-1.zip');
const trace2 = testInfo.outputPath('test-results', 'a-pass', 'trace-2.zip');
expect(fs.existsSync(traceDefault)).toBe(true);
expect(fs.existsSync(trace1)).toBe(true);
expect(fs.existsSync(trace2)).toBe(true);
});

View File

@ -50,8 +50,8 @@ test('should respect shard=1/2', async ({ runInlineTest }) => {
expect(result.passed).toBe(3);
expect(result.skipped).toBe(0);
expect(result.output).toContain('test2-done');
expect(result.output).toContain('test4-done');
expect(result.output).toContain('test5-done');
expect(result.output).toContain('test1-done');
expect(result.output).toContain('test3-done');
});
test('should respect shard=2/2', async ({ runInlineTest }) => {
@ -59,8 +59,8 @@ test('should respect shard=2/2', async ({ runInlineTest }) => {
expect(result.exitCode).toBe(0);
expect(result.passed).toBe(2);
expect(result.skipped).toBe(0);
expect(result.output).toContain('test1-done');
expect(result.output).toContain('test3-done');
expect(result.output).toContain('test4-done');
expect(result.output).toContain('test5-done');
});
test('should respect shard=1/2 in config', async ({ runInlineTest }) => {
@ -74,6 +74,6 @@ test('should respect shard=1/2 in config', async ({ runInlineTest }) => {
expect(result.passed).toBe(3);
expect(result.skipped).toBe(0);
expect(result.output).toContain('test2-done');
expect(result.output).toContain('test4-done');
expect(result.output).toContain('test5-done');
expect(result.output).toContain('test1-done');
expect(result.output).toContain('test3-done');
});

65
types/test.d.ts vendored
View File

@ -2675,6 +2675,71 @@ export interface PlaywrightWorkerArgs {
*
*/
export interface PlaywrightTestArgs {
/**
* A function that creates a new context, taking into account all options set through
* [configuration file](https://playwright.dev/docs/test-configuration) or
* [test.use(fixtures)](https://playwright.dev/docs/api/class-test#test-use) calls. All contexts created by this function
* are similar to the default [fixtures.context](https://playwright.dev/docs/api/class-fixtures#fixtures-context).
*
* This function is useful for multi-context scenarios, for example testing two users talking over the chat application.
*
* A single `options` argument will be merged with all the default options from
* [configuration file](https://playwright.dev/docs/test-configuration) or
* [test.use(fixtures)](https://playwright.dev/docs/api/class-test#test-use) calls and passed to
* [browser.newContext([options])](https://playwright.dev/docs/api/class-browser#browser-new-context). If you'd like to
* undo some of these options, override them with some value or `undefined`. For example:
*
* ```js js-flavor=ts
* // example.spec.ts
*
* import { test } from '@playwright/test';
*
* // All contexts will use this storage state.
* test.use({ storageState: 'state.json' });
*
* test('my test', async ({ createContext }) => {
* // An isolated context
* const context1 = await createContext();
*
* // Another isolated context with custom options
* const context2 = await createContext({
* // Undo 'state.json' from above
* storageState: undefined,
* // Set custom locale
* locale: 'en-US',
* });
*
* // ...
* });
* ```
*
* ```js js-flavor=js
* // example.spec.js
* // @ts-check
*
* const { test } = require('@playwright/test');
*
* // All contexts will use this storage state.
* test.use({ storageState: 'state.json' });
*
* test('my test', async ({ createContext }) => {
* // An isolated context
* const context1 = await createContext();
*
* // Another isolated context with custom options
* const context2 = await createContext({
* // Undo 'state.json' from above
* storageState: undefined,
* // Set custom locale
* locale: 'en-US',
* });
*
* // ...
* });
* ```
*
*/
createContext: (options?: BrowserContextOptions) => Promise<BrowserContext>;
/**
* Isolated [BrowserContext] instance, created for each test. Since contexts are isolated between each other, every test
* gets a fresh environment, even when multiple tests run in a single [Browser] for maximum efficiency.

View File

@ -317,6 +317,7 @@ export interface PlaywrightWorkerArgs {
}
export interface PlaywrightTestArgs {
createContext: (options?: BrowserContextOptions) => Promise<BrowserContext>;
context: BrowserContext;
page: Page;
}