Skip to content

Commit

Permalink
test: generate debug controller channel
Browse files Browse the repository at this point in the history
  • Loading branch information
mxschmitt committed Mar 20, 2024
1 parent 925aa8e commit 272544b
Show file tree
Hide file tree
Showing 7 changed files with 177 additions and 51 deletions.
34 changes: 1 addition & 33 deletions packages/playwright-core/src/remote/playwrightConnection.ts
Expand Up @@ -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)

Check failure on line 184 in packages/playwright-core/src/remote/playwrightConnection.ts

View workflow job for this annotation

GitHub Actions / docs & lint

Missing semicolon

// Close remaining browsers of this type+channel. Keep different browser types for the speed.
for (const b of playwright.allBrowsers()) {
Expand Down Expand Up @@ -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,
Expand All @@ -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',
];
1 change: 0 additions & 1 deletion packages/playwright-core/src/server/browser.ts
Expand Up @@ -66,7 +66,6 @@ export abstract class Browser extends SdkObject {
private _contextForReuse: { context: BrowserContext, hash: string } | undefined;
_closeReason: string | undefined;
_isCollocatedWithServer: boolean = true;
_originalLaunchOptions: types.LaunchOptions | undefined;

constructor(parent: SdkObject, options: BrowserOptions) {
super(parent, 'browser');
Expand Down
23 changes: 23 additions & 0 deletions packages/playwright-core/src/server/browserContext.ts
Expand Up @@ -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();
Expand Down
4 changes: 0 additions & 4 deletions packages/playwright-core/src/server/browserType.ts
Expand Up @@ -62,7 +62,6 @@ export abstract class BrowserType extends SdkObject {
}

async launch(metadata: CallMetadata, options: types.LaunchOptions, protocolLogger?: types.ProtocolLogger): Promise<Browser> {
const originalLaunchOptions = structuredClone(options);
options = this._validateLaunchOptions(options);
const controller = new ProgressController(metadata, this);
controller.setLogName('browser');
Expand All @@ -72,20 +71,17 @@ export abstract class BrowserType extends SdkObject {
return this._launchWithSeleniumHub(progress, seleniumHubUrl, options);
return this._innerLaunchWithRetries(progress, options, undefined, helper.debugProtocolLogger(protocolLogger)).catch(e => { throw this._rewriteStartupLog(e); });
}, TimeoutSettings.launchTimeout(options));
browser._originalLaunchOptions = originalLaunchOptions;
return browser;
}

async launchPersistentContext(metadata: CallMetadata, userDataDir: string, options: channels.BrowserTypeLaunchPersistentContextOptions & { useWebSocket?: boolean }): Promise<BrowserContext> {
const originalLaunchOptions = structuredClone(options);
options = this._validateLaunchOptions(options);
const controller = new ProgressController(metadata, this);
const persistent: channels.BrowserNewContextParams = options;
controller.setLogName('browser');
const browser = await controller.run(progress => {
return this._innerLaunchWithRetries(progress, options, persistent, helper.debugProtocolLogger(), userDataDir).catch(e => { throw this._rewriteStartupLog(e); });
}, TimeoutSettings.launchTimeout(options));
browser._originalLaunchOptions = originalLaunchOptions;
return browser._defaultContext!;
}

Expand Down
16 changes: 5 additions & 11 deletions packages/playwright-core/src/server/debugController.ts
Expand Up @@ -119,20 +119,14 @@ export class DebugController extends SdkObject {
headless: !!process.env.PW_DEBUG_CONTROLLER_HEADLESS,
};
const browserName = params.browserName ?? 'chromium';
const compatibleBrowser = this._playwright.allBrowsers().find(browser =>
browser.options.name === (params.browserName ?? 'chromium') &&
JSON.stringify(browser._originalLaunchOptions) === JSON.stringify(launchOptions)
);

if (!compatibleBrowser) {
let browser = this._playwright.findBrowserWithMatchingOptions(browserName, launchOptions);
if (!browser) {
await this.closeAllBrowsers();
await this._playwright[browserName].launch(internalMetadata, launchOptions);
browser = await this._playwright[browserName].launch(internalMetadata, launchOptions);
}
// Create page if none.
const [browser] = this._playwright.allBrowsers();
const { context, needsReset } = await browser.newContextForReuse(params.contextOptions || {}, internalMetadata);
if (needsReset)
this.resetForReuse(params.contextOptions);
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.
Expand Down
35 changes: 35 additions & 0 deletions packages/playwright-core/src/server/playwright.ts
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
115 changes: 113 additions & 2 deletions tests/library/debug-controller.spec.ts
Expand Up @@ -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);
Expand Down Expand Up @@ -195,6 +195,117 @@ 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();
});`
});
});

// Some test cases to think about:
// - Record in Chromium, stop recording, record in Firefox. Make sure that Firefox got launched and Chromium got closed.
// - Record in Chromium with some context options, stop recording. Record in Chromium with different context options. Make sure that Chromium got reused and context got re-applied.

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;
const context = await browser._newContextForReuse();
const page = context.pages()[0];
console.log(await page.evaluate(() => navigator.userAgent))

Check failure on line 285 in tests/library/debug-controller.spec.ts

View workflow job for this annotation

GitHub Actions / docs & lint

Missing semicolon
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 custom data-testid', async ({ backend, connectedBrowser }) => {
// This test emulates "record at cursor" functionality
// with custom test id attribute in the config.
Expand Down

0 comments on commit 272544b

Please sign in to comment.