From c4743ba424ff29b60bec9c5b9db50fdb216f1686 Mon Sep 17 00:00:00 2001 From: Max Schmitt Date: Fri, 22 Mar 2024 21:39:50 +0100 Subject: [PATCH] feat(vscode): allow running codegen with selected browser/options --- .../playwright-core/src/protocol/validator.ts | 3 + .../src/remote/playwrightConnection.ts | 34 +--- .../src/server/browserContext.ts | 23 +++ .../src/server/debugController.ts | 35 +++- .../playwright-core/src/server/playwright.ts | 35 ++++ packages/protocol/src/channels.ts | 6 + packages/protocol/src/protocol.yml | 8 + tests/library/debug-controller.spec.ts | 183 +++++++++++++++++- 8 files changed, 274 insertions(+), 53 deletions(-) diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 4a8f7fb3d10c8..be14dd17eaadb 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -400,6 +400,9 @@ scheme.DebugControllerNavigateResult = tOptional(tObject({})); scheme.DebugControllerSetRecorderModeParams = tObject({ mode: tEnum(['inspecting', 'recording', 'none']), testIdAttributeName: tOptional(tString), + browserName: tOptional(tEnum(['chromium', 'firefox', 'webkit'])), + contextOptions: tOptional(tType('BrowserNewContextForReuseParams')), + launchOptions: tOptional(tType('BrowserTypeLaunchParams')), }); scheme.DebugControllerSetRecorderModeResult = tOptional(tObject({})); scheme.DebugControllerHighlightParams = tObject({ diff --git a/packages/playwright-core/src/remote/playwrightConnection.ts b/packages/playwright-core/src/remote/playwrightConnection.ts index cce31207a876a..a2103151741ec 100644 --- a/packages/playwright-core/src/remote/playwrightConnection.ts +++ b/packages/playwright-core/src/remote/playwrightConnection.ts @@ -181,13 +181,7 @@ export class PlaywrightConnection { debugLogger.log('server', `[${this._id}] engaged reuse browsers mode for ${this._options.browserName}`); const playwright = this._preLaunched.playwright!; - const requestedOptions = launchOptionsHash(this._options.launchOptions); - let browser = playwright.allBrowsers().find(b => { - if (b.options.name !== this._options.browserName) - return false; - const existingOptions = launchOptionsHash(b.options.originalLaunchOptions); - return existingOptions === requestedOptions; - }); + let browser = playwright.findBrowserWithMatchingOptions(this._options.browserName, this._options.launchOptions); // Close remaining browsers of this type+channel. Keep different browser types for the speed. for (const b of playwright.allBrowsers()) { @@ -272,18 +266,6 @@ export class PlaywrightConnection { } } -function launchOptionsHash(options: LaunchOptions) { - const copy = { ...options }; - for (const k of Object.keys(copy)) { - const key = k as keyof LaunchOptions; - if (copy[key] === defaultLaunchOptions[key]) - delete copy[key]; - } - for (const key of optionsThatAllowBrowserReuse) - delete copy[key]; - return JSON.stringify(copy); -} - function filterLaunchOptions(options: LaunchOptions): LaunchOptions { return { channel: options.channel, @@ -299,17 +281,3 @@ function filterLaunchOptions(options: LaunchOptions): LaunchOptions { executablePath: isUnderTest() ? options.executablePath : undefined, }; } - -const defaultLaunchOptions: LaunchOptions = { - ignoreAllDefaultArgs: false, - handleSIGINT: false, - handleSIGTERM: false, - handleSIGHUP: false, - headless: true, - devtools: false, -}; - -const optionsThatAllowBrowserReuse: (keyof LaunchOptions)[] = [ - 'headless', - 'tracesDir', -]; diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 2a5dfe765f39d..57ecb59ba8406 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -228,6 +228,29 @@ export abstract class BrowserContext extends SdkObject { await page?.resetForReuse(metadata); } + async reapplyContextOptionsIfNeeded(options: channels.BrowserNewContextForReuseParams = {}) { + const promises: Promise[] = []; + const hash = (obj: any) => JSON.stringify(obj); + if (options.viewport && hash(options.viewport) !== hash(this._options.viewport)) + promises.push(...this.pages().map(page => page.setViewportSize(options.viewport!))); + if (options.extraHTTPHeaders && hash(options.extraHTTPHeaders) !== hash(this._options.extraHTTPHeaders)) + promises.push(this.setExtraHTTPHeaders(options.extraHTTPHeaders)); + if (options.geolocation && hash(options.geolocation) !== hash(this._options.geolocation)) + promises.push(this.setGeolocation(options.geolocation)); + if (options.offline !== undefined && options.offline !== this._options.offline) + promises.push(this.setOffline(!!options.offline)); + if (options.userAgent && options.userAgent !== this._options.userAgent) + promises.push(this.setUserAgent(options.userAgent)); + if (options.storageState && hash(options.storageState) !== hash(this._options.storageState)) + promises.push(this.setStorageState(serverSideCallMetadata(), options.storageState)); + if (options.permissions && hash(options.permissions) !== hash(this._options.permissions)) + promises.push(this.grantPermissions(options.permissions)); + const hashMedia = (colorScheme?: types.ColorScheme, reducedMotion?: types.ReducedMotion, forcedColors?: types.ForcedColors) => hash({ colorScheme, reducedMotion, forcedColors }); + if (hashMedia(options.colorScheme, options.reducedMotion, options.forcedColors) !== hashMedia(this._options.colorScheme, this._options.reducedMotion, this._options.forcedColors)) + promises.push(...this.pages().map(page => page.emulateMedia({ colorScheme: options.colorScheme, reducedMotion: options.reducedMotion, forcedColors: options.forcedColors }))); + await Promise.all(promises); + } + _browserClosed() { for (const page of this.pages()) page._didClose(); diff --git a/packages/playwright-core/src/server/debugController.ts b/packages/playwright-core/src/server/debugController.ts index ea2dc9a8054af..8ffd43741867c 100644 --- a/packages/playwright-core/src/server/debugController.ts +++ b/packages/playwright-core/src/server/debugController.ts @@ -25,6 +25,7 @@ import { Recorder } from './recorder'; import { EmptyRecorderApp } from './recorder/recorderApp'; import { asLocator } from '../utils/isomorphic/locatorGenerators'; import type { Language } from '../utils/isomorphic/locatorGenerators'; +import type { BrowserNewContextForReuseParams, BrowserTypeLaunchParams } from '@protocol/channels'; const internalMetadata = serverSideCallMetadata(); @@ -79,12 +80,12 @@ export class DebugController extends SdkObject { } } - async resetForReuse() { + async resetForReuse(params: BrowserNewContextForReuseParams | null = null) { const contexts = new Set(); for (const page of this._playwright.allPages()) contexts.add(page.context()); for (const context of contexts) - await context.resetForReuse(internalMetadata, null); + await context.resetForReuse(internalMetadata, params); } async navigate(url: string) { @@ -92,7 +93,14 @@ export class DebugController extends SdkObject { await p.mainFrame().goto(internalMetadata, url); } - async setRecorderMode(params: { mode: Mode, file?: string, testIdAttributeName?: string }) { + async setRecorderMode(params: { + mode: Mode, + file?: string, + testIdAttributeName?: string, + browserName?: 'chromium' | 'firefox' | 'webkit', + contextOptions?: BrowserNewContextForReuseParams, + launchOptions?: BrowserTypeLaunchParams, + }) { // TODO: |file| is only used in the legacy mode. await this._closeBrowsersWithoutPages(); @@ -105,15 +113,22 @@ export class DebugController extends SdkObject { return; } - if (!this._playwright.allBrowsers().length) - await this._playwright.chromium.launch(internalMetadata, { headless: !!process.env.PW_DEBUG_CONTROLLER_HEADLESS }); + // Previous browser + launchOptions did not match the previous one. + const launchOptions = { + ...params.launchOptions, + headless: !!process.env.PW_DEBUG_CONTROLLER_HEADLESS, + }; + const browserName = params.browserName ?? 'chromium'; + let browser = this._playwright.findBrowserWithMatchingOptions(browserName, launchOptions); + if (!browser) { + await this.closeAllBrowsers(); + browser = await this._playwright[browserName].launch(internalMetadata, launchOptions); + } // Create page if none. - const pages = this._playwright.allPages(); - if (!pages.length) { - const [browser] = this._playwright.allBrowsers(); - const { context } = await browser.newContextForReuse({}, internalMetadata); + const { context } = await browser.newContextForReuse(params.contextOptions || {}, internalMetadata); + await context.reapplyContextOptionsIfNeeded(params.contextOptions); + if (!context.pages().length) await context.newPage(internalMetadata); - } // Update test id attribute. if (params.testIdAttributeName) { for (const page of this._playwright.allPages()) diff --git a/packages/playwright-core/src/server/playwright.ts b/packages/playwright-core/src/server/playwright.ts index c4ffd518ce7b8..07ba9187ade56 100644 --- a/packages/playwright-core/src/server/playwright.ts +++ b/packages/playwright-core/src/server/playwright.ts @@ -28,6 +28,7 @@ import { debugLogger } from '../utils/debugLogger'; import type { Page } from './page'; import { DebugController } from './debugController'; import type { Language } from '../utils/isomorphic/locatorGenerators'; +import type { LaunchOptions } from './types'; type PlaywrightOptions = { socksProxyPort?: number; @@ -81,6 +82,40 @@ export class Playwright extends SdkObject { allPages(): Page[] { return [...this._allPages]; } + + findBrowserWithMatchingOptions(requestedBrowserName: string | null, requestedOptions: LaunchOptions): Browser | undefined { + return this.allBrowsers().find(b => { + if (b.options.name !== (requestedBrowserName ?? 'chromium')) + return false; + return launchOptionsHash(b.options.originalLaunchOptions) === launchOptionsHash(requestedOptions); + }); + } +} + +const defaultLaunchOptions: LaunchOptions = { + ignoreAllDefaultArgs: false, + handleSIGINT: false, + handleSIGTERM: false, + handleSIGHUP: false, + headless: true, + devtools: false, +}; + +const optionsThatAllowBrowserReuse: (keyof LaunchOptions)[] = [ + 'headless', + 'tracesDir', +]; + +function launchOptionsHash(options: LaunchOptions) { + const copy = { ...options }; + for (const k of Object.keys(copy)) { + const key = k as keyof LaunchOptions; + if (copy[key] === defaultLaunchOptions[key]) + delete copy[key]; + } + for (const key of optionsThatAllowBrowserReuse) + delete copy[key]; + return JSON.stringify(copy); } export function createPlaywright(options: PlaywrightOptions) { diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index c0af7ac678dcc..422ace2e9ade8 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -701,9 +701,15 @@ export type DebugControllerNavigateResult = void; export type DebugControllerSetRecorderModeParams = { mode: 'inspecting' | 'recording' | 'none', testIdAttributeName?: string, + browserName?: 'chromium' | 'firefox' | 'webkit', + contextOptions?: BrowserNewContextForReuseParams, + launchOptions?: BrowserTypeLaunchParams, }; export type DebugControllerSetRecorderModeOptions = { testIdAttributeName?: string, + browserName?: 'chromium' | 'firefox' | 'webkit', + contextOptions?: BrowserNewContextForReuseParams, + launchOptions?: BrowserTypeLaunchParams, }; export type DebugControllerSetRecorderModeResult = void; export type DebugControllerHighlightParams = { diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index acb847b08bc0f..49322812ad094 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -745,6 +745,14 @@ DebugController: - recording - none testIdAttributeName: string? + browserName: + type: enum? + literals: + - chromium + - firefox + - webkit + contextOptions: BrowserNewContextForReuseParams? + launchOptions: BrowserTypeLaunchParams? highlight: parameters: diff --git a/tests/library/debug-controller.spec.ts b/tests/library/debug-controller.spec.ts index 2da0ee1bf3744..dd4e9626a465b 100644 --- a/tests/library/debug-controller.spec.ts +++ b/tests/library/debug-controller.spec.ts @@ -18,10 +18,10 @@ import { expect, playwrightTest as baseTest } from '../config/browserTest'; import { PlaywrightServer } from '../../packages/playwright-core/lib/remote/playwrightServer'; import { createGuid } from '../../packages/playwright-core/lib/utils/crypto'; import { Backend } from '../config/debugControllerBackend'; -import type { Browser, BrowserContext } from '@playwright/test'; +import type { Browser, BrowserContext, BrowserContextOptions } from '@playwright/test'; import type * as channels from '@protocol/channels'; -type BrowserWithReuse = Browser & { _newContextForReuse: () => Promise }; +type BrowserWithReuse = Browser & { _newContextForReuse: (options?: BrowserContextOptions) => Promise }; type Fixtures = { wsEndpoint: string; backend: channels.DebugControllerChannel; @@ -30,8 +30,8 @@ type Fixtures = { }; const test = baseTest.extend({ - wsEndpoint: async ({ }, use) => { - process.env.PW_DEBUG_CONTROLLER_HEADLESS = '1'; + wsEndpoint: async ({ headless }, use) => { + process.env.PW_DEBUG_CONTROLLER_HEADLESS = headless ? '1' : ''; const server = new PlaywrightServer({ mode: 'extension', path: '/' + createGuid(), maxConnections: Number.MAX_VALUE, enableSocksProxy: false }); const wsEndpoint = await server.listen(); await use(wsEndpoint); @@ -195,23 +195,186 @@ test('test', async ({ page }) => { expect(events).toHaveLength(length); }); +test('should record with the same browser if triggered with the same options', async ({ backend, connectedBrowser }) => { + // This test emulates when the user records a test, stops recording, and then records another test with the same browserName/launchOptions/contextOptions + + const events = []; + backend.on('sourceChanged', event => events.push(event)); + + // 1. Start Recording + await backend.setRecorderMode({ mode: 'recording' }); + const context = await connectedBrowser._newContextForReuse(); + expect(context.pages().length).toBe(1); + + // 2. Record a click action. + const page = context.pages()[0]; + await page.setContent(''); + await page.getByRole('button').click(); + + // 3. Stop recording. + await backend.setRecorderMode({ mode: 'none' }); + + // 4. Start recording again. + await backend.setRecorderMode({ mode: 'recording' }); + expect(context.pages().length).toBe(1); + + // 5. Record another click action. + await page.getByRole('button').click(); + + // 4. Expect the click action to be recorded. + await expect.poll(() => events[events.length - 1]).toEqual({ + header: `import { test, expect } from '@playwright/test'; + +test('test', async ({ page }) => {`, + footer: `});`, + actions: [ + ` await page.getByRole('button', { name: 'Submit' }).click();`, + ], + text: `import { test, expect } from '@playwright/test'; + +test('test', async ({ page }) => { + await page.getByRole('button', { name: 'Submit' }).click(); +});` + }); +}); + +test('should record with a new browser if triggered with different browserName', async ({ wsEndpoint, playwright, backend }) => { + // This test emulates when the user records a test, stops recording, and then records another test with a different browserName + + const events = []; + backend.on('sourceChanged', event => events.push(event)); + + // 1. Start Recording + const browser1 = await playwright.chromium.connect(wsEndpoint, { + headers: { + 'x-playwright-reuse-context': '1', + } + }) as BrowserWithReuse; + await backend.setRecorderMode({ mode: 'recording' }); + + // 2. Record a click action. + { + const context = await browser1._newContextForReuse(); + expect(context.pages().length).toBe(1); + const page = context.pages()[0]; + await page.setContent(''); + await page.getByRole('button').click(); + expect(page.context().browser().browserType().name()).toBe('chromium'); + } + + // 3. Stop recording. + await backend.setRecorderMode({ mode: 'none' }); + + // 4. Start recording again with a different browserName. + await backend.setRecorderMode({ mode: 'recording', browserName: 'firefox' }); + + // 5. Record another click action. + { + expect(browser1.isConnected()).toBe(false); + const browser = await playwright.firefox.connect(wsEndpoint, { + headers: { + 'x-playwright-reuse-context': '1', + } + }) as BrowserWithReuse; + expect(browser.browserType().name()).toBe('firefox'); + const context = await browser._newContextForReuse(); + const page = context.pages()[0]; + await page.setContent(''); + await page.getByRole('button').click(); + } + + // 6. Expect the click action to be recorded. + await expect.poll(() => events[events.length - 1]).toEqual({ + header: `import { test, expect } from '@playwright/test'; + +test('test', async ({ page }) => {`, + footer: `});`, + actions: [ + " await page.goto('about:blank');", + ` await page.getByRole('button', { name: 'Submit' }).click();`, + ], + text: `import { test, expect } from '@playwright/test'; + +test('test', async ({ page }) => { + await page.goto('about:blank'); + await page.getByRole('button', { name: 'Submit' }).click(); +});` + }); +}); + +test('should record with same browser but re-applied context options if triggered with different contextOptions', async ({ playwright, wsEndpoint, backend, }) => { + // This test emulates when the user records a test, stops recording, and then records another test with different contextOptions + + const events = []; + backend.on('sourceChanged', event => events.push(event)); + + // 1. Start Recording + await backend.setRecorderMode({ mode: 'recording' }); + const browser = await playwright.chromium.connect(wsEndpoint, { + headers: { + 'x-playwright-reuse-context': '1', + } + }) as BrowserWithReuse; + const context = await browser._newContextForReuse({ userAgent: 'hello 123', viewport: { width: 1111, height: 1111 } }); + expect(context.pages().length).toBe(1); + + // 2. Record a click action. + const page = context.pages()[0]; + await page.setContent(''); + await page.getByRole('button').click(); + expect(await page.evaluate(() => window.innerWidth)).toBe(1111); + expect(await page.evaluate(() => window.innerHeight)).toBe(1111); + expect(await page.evaluate(() => navigator.userAgent)).toBe('hello 123'); + + // 3. Stop recording. + await backend.setRecorderMode({ mode: 'none' }); + + // 4. Start recording again with different contextOptions. + await backend.setRecorderMode({ mode: 'recording', contextOptions: { userAgent: 'hello 345', viewport: { width: 500, height: 500 } } }); + expect(context.pages().length).toBe(1); + expect(await page.evaluate(() => window.innerWidth)).toBe(500); + expect(await page.evaluate(() => window.innerHeight)).toBe(500); + expect(await page.evaluate(() => navigator.userAgent)).toBe('hello 345'); + + // 5. Record another click action. + await page.getByRole('button').click(); + + // 6. Expect the click action to be recorded. + await expect.poll(() => events[events.length - 1]).toEqual({ + header: `import { test, expect } from '@playwright/test'; + +test('test', async ({ page }) => {`, + footer: `});`, + actions: [ + ` await page.getByRole('button', { name: 'Submit' }).click();`, + ], + text: `import { test, expect } from '@playwright/test'; + +test('test', async ({ page }) => { + await page.getByRole('button', { name: 'Submit' }).click(); +});` + }); +}); + test('should record custom data-testid', async ({ backend, connectedBrowser }) => { // This test emulates "record at cursor" functionality // with custom test id attribute in the config. const events = []; backend.on('sourceChanged', event => events.push(event)); - // 1. "Show browser" (or "run test"). - const context = await connectedBrowser._newContextForReuse(); - const page = await context.newPage(); - await page.setContent(`
One
`); - + { + const page = await connectedBrowser._newContextForReuse().then(context => context.newPage()); + await page.setContent(`
One
`); + } // 2. "Record at cursor". await backend.setRecorderMode({ mode: 'recording', testIdAttributeName: 'data-custom-id' }); // 3. Record a click action. - await page.locator('div').click(); + { + const page = (connectedBrowser.contexts())[0].pages()[0]; + await page.locator('div').click(); + } // 4. Expect "getByTestId" locator. await expect.poll(() => events[events.length - 1]).toEqual({