diff --git a/.github/workflows/tests_primary.yml b/.github/workflows/tests_primary.yml index b223309051..80a52bc96f 100644 --- a/.github/workflows/tests_primary.yml +++ b/.github/workflows/tests_primary.yml @@ -66,3 +66,6 @@ jobs: - run: npm run build - run: node lib/cli/cli install --with-deps - run: npm run ttest + if: matrix.os != 'ubuntu-latest' + - run: xvfb-run npm run ttest + if: matrix.os == 'ubuntu-latest' diff --git a/src/test/cli.ts b/src/test/cli.ts index 52741bda7b..f8b9bd2790 100644 --- a/src/test/cli.ts +++ b/src/test/cli.ts @@ -16,7 +16,7 @@ /* eslint-disable no-console */ -import { Command } from 'commander'; +import { Command, Option } from 'commander'; import fs from 'fs'; import path from 'path'; import type { Config } from './types'; @@ -45,6 +45,7 @@ export function addTestCommand(program: Command) { command.option('--browser ', `Browser to use for tests, one of "all", "chromium", "firefox" or "webkit" (default: "chromium")`); command.option('--headed', `Run tests in headed browsers (default: headless)`); command.option('--debug', `Run tests with Playwright Inspector. Shortcut for "PWDEBUG=1" environment variable and "--timeout=0 --maxFailures=1 --headed --workers=1" options`); + command.addOption(new Option('--reuse-context').hideHelp()); command.option('-c, --config ', `Configuration file, or a test directory with optional "${tsConfig}"/"${jsConfig}"`); command.option('--forbid-only', `Fail if test.only is called (default: false)`); command.option('-g, --grep ', `Only run tests matching this regular expression (default: ".*")`); @@ -96,14 +97,18 @@ async function createLoader(opts: { [key: string]: any }): Promise { } const overrides = overridesFromOptions(opts); - if (opts.headed || opts.debug) + if (opts.headed || opts.debug || opts.reuseContext) overrides.use = { headless: false }; - if (opts.debug) { + if (opts.debug || opts.reuseContext) { overrides.maxFailures = 1; overrides.timeout = 0; overrides.workers = 1; - process.env.PWDEBUG = '1'; } + if (opts.debug) + process.env.PWDEBUG = '1'; + if (opts.reuseContext) + process.env.PWTEST_REUSE_CONTEXT = '1'; + const loader = new Loader(defaultConfig, overrides); async function loadConfig(configFile: string) { diff --git a/src/test/index.ts b/src/test/index.ts index ad7c81b8b0..8d43fc4533 100644 --- a/src/test/index.ts +++ b/src/test/index.ts @@ -19,8 +19,9 @@ import * as path from 'path'; import type { LaunchOptions, BrowserContextOptions, Page, BrowserContext, BrowserType } from '../../types/types'; import type { TestType, PlaywrightTestArgs, PlaywrightTestOptions, PlaywrightWorkerArgs, PlaywrightWorkerOptions, TestInfo } from '../../types/test'; import { rootTestType } from './testType'; -import { createGuid, removeFolders } from '../utils/utils'; +import { assert, createGuid, removeFolders } from '../utils/utils'; import { GridClient } from '../grid/gridClient'; +import { Browser } from '../..'; export { expect } from './expect'; export const _baseTest: TestType<{}, {}> = rootTestType.test; @@ -31,8 +32,83 @@ type TestFixtures = PlaywrightTestArgs & PlaywrightTestOptions & { type WorkerAndFileFixtures = PlaywrightWorkerArgs & PlaywrightWorkerOptions & { _browserType: BrowserType; _artifactsDir: () => string, + _reuseBrowerContext: ReuseBrowerContextStorage, }; +class ReuseBrowerContextStorage { + private _browserContext?: BrowserContext; + private _uniqueOrigins = new Set(); + private _options?: BrowserContextOptions; + private _pauseNavigationEventCollection = false; + + isEnabled(): boolean { + return !!process.env.PWTEST_REUSE_CONTEXT; + } + + async obtainContext(browser: Browser, newContextOptions: BrowserContextOptions): Promise { + if (!this._browserContext) + return await this._createNewContext(browser); + return await this._refurbishExistingContext(newContextOptions); + } + + private async _createNewContext(browser: Browser): Promise { + this._browserContext = await browser.newContext(); + this._options = (this._browserContext as any)._options; + this._browserContext.on('page', page => page.on('framenavigated', frame => { + if (this._pauseNavigationEventCollection) + return; + this._uniqueOrigins.add(new URL(frame.url()).origin); + })); + return this._browserContext; + } + + async _refurbishExistingContext(newContextOptions: BrowserContextOptions): Promise { + assert(this._browserContext); + const pages = this._browserContext.pages(); + const page = pages[0]; + this._pauseNavigationEventCollection = true; + try { + const initialOrigin = new URL(page.url()).origin; + await page.route('**/*', route => route.fulfill({ body: ``, contentType: 'text/html' })); + while (this._uniqueOrigins.size > 0) { + const nextOrigin = this._uniqueOrigins.has(initialOrigin) ? initialOrigin : this._uniqueOrigins.values().next().value; + this._uniqueOrigins.delete(nextOrigin); + await page.goto(nextOrigin); + await page.evaluate(() => window.localStorage.clear()); + await page.evaluate(() => window.sessionStorage.clear()); + } + await page.unroute('**/*'); + await Promise.all(pages.slice(1).map(page => page.close())); + await page.goto('about:blank'); + await this._browserContext.clearCookies(); + await this._applyNewContextOptions(page, newContextOptions); + } finally { + this._pauseNavigationEventCollection = false; + } + return this._browserContext; + } + + private async _applyNewContextOptions(page: Page, newOptions: BrowserContextOptions) { + assert(this._options); + if ( + ( + this._options.viewport?.width !== newOptions.viewport?.width || + this._options.viewport?.height !== newOptions.viewport?.height + ) && + (newOptions.viewport?.height && newOptions.viewport?.width) + ) + await page.setViewportSize({ width: newOptions.viewport?.width, height: newOptions.viewport?.height }); + this._options = newOptions; + } + + async obtainPage(): Promise { + assert(this._browserContext); + if (this._browserContext.pages().length === 0) + return await this._browserContext.newPage(); + return this._browserContext.pages()[0]; + } +} + export const test = _baseTest.extend({ defaultBrowserType: [ 'chromium', { scope: 'worker' } ], browserName: [ ({ defaultBrowserType }, use) => use(defaultBrowserType), { scope: 'worker' } ], @@ -311,10 +387,17 @@ export const test = _baseTest.extend({ })); }, { auto: true }], - context: async ({ browser, video, _artifactsDir }, use, testInfo) => { + _reuseBrowerContext: [new ReuseBrowerContextStorage(), { scope: 'worker' }], + + context: async ({ browser, video, _artifactsDir, _reuseBrowerContext, _combinedContextOptions }, use, testInfo) => { const hook = hookType(testInfo); if (hook) throw new Error(`"context" and "page" fixtures are not supported in ${hook}. Use browser.newContext() instead.`); + if (_reuseBrowerContext.isEnabled()) { + const context = await _reuseBrowerContext.obtainContext(browser, _combinedContextOptions); + await use(context); + return; + } let videoMode = typeof video === 'string' ? video : video.mode; if (videoMode === 'retry-with-video') @@ -366,7 +449,11 @@ export const test = _baseTest.extend({ } }, - page: async ({ context }, use) => { + page: async ({ context, _reuseBrowerContext }, use) => { + if (_reuseBrowerContext.isEnabled()) { + await use(await _reuseBrowerContext.obtainPage()); + return; + } await use(await context.newPage()); }, }); diff --git a/tests/playwright-test/playwright.spec.ts b/tests/playwright-test/playwright.spec.ts index 2a8a5311cb..31c1a13a95 100644 --- a/tests/playwright-test/playwright.spec.ts +++ b/tests/playwright-test/playwright.spec.ts @@ -456,3 +456,83 @@ test('should work with video size', async ({ runInlineTest }, testInfo) => { expect(videoPlayer.videoWidth).toBe(220); expect(videoPlayer.videoHeight).toBe(110); }); + +test('should be able to re-use the context when debug mode is used', async ({ runInlineTest }, testInfo) => { + const result = await runInlineTest({ + 'a.test.ts': ` + const { test } = pwt; + + test.use({ + colorScheme: 'light', + viewport: { + width: 1920, + height: 1080, + }, + }) + + const host1 = 'http://host1.com/foobar'; + + test.beforeEach(async({page, context}) => { + context.route(host1, route => route.fulfill({body: '', contentType: 'text/html'}, {times: 1})); + console.log(page._guid + '|'); + console.log(context._guid + '|'); + }) + + test('initial setup', async ({ page }) => { + await page.goto(host1); + expect(await page.evaluate(() => window.localStorage.getItem('foobar'))).toBe(null); + await page.evaluate(() => window.localStorage.setItem('foobar', 'bar')); + expect(page.viewportSize()).toStrictEqual({ + width: 1920, + height: 1080, + }); + }); + + test('second run after persistent data has changed', async ({ page }) => { + await page.goto(host1); + expect(await page.evaluate(() => window.localStorage.getItem('foobar'))).toBe(null); + await page.evaluate(() => window.localStorage.setItem('foobar', 'bar')); + expect(page.viewportSize()).toStrictEqual({ + width: 1920, + height: 1080, + }); + }); + + test.describe('inside a describe block', () => { + test.use({ + colorScheme: 'dark', + viewport: { + width: 1000, + height: 500, + }, + }); + test('using different options', async ({ page }) => { + await page.goto(host1); + expect(await page.evaluate(() => window.localStorage.getItem('foobar'))).toBe(null); + expect(page.viewportSize()).toStrictEqual({ + width: 1000, + height: 500, + }); + }); + }); + + test('after the describe block', async ({ page }) => { + await page.goto(host1); + expect(await page.evaluate(() => window.localStorage.getItem('foobar'))).toBe(null); + expect(page.viewportSize()).toStrictEqual({ + width: 1920, + height: 1080, + }); + }); + ` + }, { '--reuse-context': true }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(4); + const pageIds = result.output.match(/page@(.*)\|/g); + const browserContextIds = result.output.match(/browser-context@(.*)\|/g); + expect(pageIds.length).toBe(4); + expect(new Set(pageIds).size).toBe(1); + expect(browserContextIds.length).toBe(4); + expect(new Set(browserContextIds).size).toBe(1); +}); +