mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
feat(test runner): createContext fixture for multi-context scenarios (#7779)
This commit is contained in:
parent
626dd23ce1
commit
dd0b089d13
@ -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]>
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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());
|
||||
},
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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
65
types/test.d.ts
vendored
@ -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.
|
||||
|
||||
1
utils/generate_types/overrides-test.d.ts
vendored
1
utils/generate_types/overrides-test.d.ts
vendored
@ -317,6 +317,7 @@ export interface PlaywrightWorkerArgs {
|
||||
}
|
||||
|
||||
export interface PlaywrightTestArgs {
|
||||
createContext: (options?: BrowserContextOptions) => Promise<BrowserContext>;
|
||||
context: BrowserContext;
|
||||
page: Page;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user