feat(vscode): allow running codegen with selected browser/options

This commit is contained in:
Max Schmitt 2024-03-22 21:39:50 +01:00
parent 4a67437704
commit c4743ba424
8 changed files with 275 additions and 54 deletions

View file

@ -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({

View file

@ -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',
];

View file

@ -228,6 +228,29 @@ export abstract class BrowserContext extends SdkObject {
await page?.resetForReuse(metadata);
}
async reapplyContextOptionsIfNeeded(options: channels.BrowserNewContextForReuseParams = {}) {
const promises: Promise<any>[] = [];
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();

View file

@ -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<BrowserContext>();
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 });
// Create page if none.
const pages = this._playwright.allPages();
if (!pages.length) {
const [browser] = this._playwright.allBrowsers();
const { context } = await browser.newContextForReuse({}, internalMetadata);
await context.newPage(internalMetadata);
// 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 { 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())

View file

@ -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) {

View file

@ -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 = {

View file

@ -745,6 +745,14 @@ DebugController:
- recording
- none
testIdAttributeName: string?
browserName:
type: enum?
literals:
- chromium
- firefox
- webkit
contextOptions: BrowserNewContextForReuseParams?
launchOptions: BrowserTypeLaunchParams?
highlight:
parameters:

View file

@ -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<BrowserContext> };
type BrowserWithReuse = Browser & { _newContextForReuse: (options?: BrowserContextOptions) => Promise<BrowserContext> };
type Fixtures = {
wsEndpoint: string;
backend: channels.DebugControllerChannel;
@ -30,8 +30,8 @@ type Fixtures = {
};
const test = baseTest.extend<Fixtures>({
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('<button>Submit</button>');
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('<button>Submit</button>');
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('<button>Submit</button>');
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('<button>Submit</button>');
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(`<div data-custom-id='one'>One</div>`);
{
const page = await connectedBrowser._newContextForReuse().then(context => context.newPage());
await page.setContent(`<div data-custom-id='one'>One</div>`);
}
// 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({