Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions packages/playwright-core/src/server/browserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,74 @@ export function normalizeProxySettings(proxy: types.ProxySettings): types.ProxyS
return { ...proxy, server, bypass };
}

// Chromium reference: https://source.chromium.org/chromium/chromium/src/+/main:components/embedder_support/user_agent_utils.cc;l=434
export function calculateUserAgentEmulation(options: types.BrowserContextOptions): {
navigatorPlatform: string | undefined;
userAgentMetadata: {
mobile: boolean;
model: string;
architecture: string;
platform: string;
platformVersion: string;
} | undefined;
} {
const ua = options.userAgent;
if (!ua)
return { navigatorPlatform: undefined, userAgentMetadata: undefined };

const userAgentMetadata = {
mobile: !!options.isMobile,
model: '',
architecture: 'x86',
platform: 'Windows',
platformVersion: '',
};

const androidMatch = ua.match(/Android (\d+(\.\d+)?(\.\d+)?)/);
const iPhoneMatch = ua.match(/iPhone OS (\d+(_\d+)?)/);
const iPadMatch = ua.match(/iPad; CPU OS (\d+(_\d+)?)/);
const macOSMatch = ua.match(/Mac OS X (\d+(_\d+)?(_\d+)?)/);
const windowsMatch = ua.match(/Windows\D+(\d+(\.\d+)?(\.\d+)?)/);
if (androidMatch) {
userAgentMetadata.platform = 'Android';
userAgentMetadata.platformVersion = androidMatch[1];
userAgentMetadata.architecture = 'arm';
} else if (iPhoneMatch) {
userAgentMetadata.platform = 'iOS';
userAgentMetadata.platformVersion = iPhoneMatch[1].replace(/_/g, '.');
userAgentMetadata.architecture = 'arm';
} else if (iPadMatch) {
userAgentMetadata.platform = 'iOS';
userAgentMetadata.platformVersion = iPadMatch[1].replace(/_/g, '.');
userAgentMetadata.architecture = 'arm';
} else if (macOSMatch) {
userAgentMetadata.platform = 'macOS';
userAgentMetadata.platformVersion = macOSMatch[1].replace(/_/g, '.');
if (!ua.includes('Intel'))
userAgentMetadata.architecture = 'arm';
} else if (windowsMatch) {
userAgentMetadata.platform = 'Windows';
userAgentMetadata.platformVersion = windowsMatch[1];
} else if (ua.toLowerCase().includes('linux')) {
userAgentMetadata.platform = 'Linux';
}
if (ua.includes('ARM') || ua.includes('aarch64'))
userAgentMetadata.architecture = 'arm';

let navigatorPlatform: string | undefined;
if (!process.env.PLAYWRIGHT_NO_UA_PLATFORM) {
switch (userAgentMetadata.platform) {
case 'Android': navigatorPlatform = userAgentMetadata.architecture === 'arm' ? 'Linux armv8l' : 'Linux x86_64'; break;
case 'iOS': navigatorPlatform = ua.includes('iPad') ? 'iPad' : 'iPhone'; break;
case 'macOS': navigatorPlatform = 'MacIntel'; break;
case 'Linux': navigatorPlatform = userAgentMetadata.architecture === 'arm' ? 'Linux aarch64' : 'Linux x86_64'; break;
case 'Windows': navigatorPlatform = 'Win32'; break;
}
}

return { navigatorPlatform, userAgentMetadata };
}

const paramsThatAllowContextReuse: (keyof channels.BrowserNewContextForReuseParams)[] = [
'colorScheme',
'forcedColors',
Expand Down
50 changes: 4 additions & 46 deletions packages/playwright-core/src/server/chromium/crPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import * as frames from '../frames';
import { helper } from '../helper';
import * as network from '../network';
import { Page, PageBinding, Worker } from '../page';
import { calculateUserAgentEmulation } from '../browserContext';
import { CRBrowserContext } from './crBrowser';
import { CRCoverage } from './crCoverage';
import { DragManager } from './crDragDrop';
Expand Down Expand Up @@ -988,10 +989,12 @@ class FrameSession {

async _updateUserAgent(): Promise<void> {
const options = this._crPage._browserContext._options;
const { navigatorPlatform, userAgentMetadata } = calculateUserAgentEmulation(options);
await this._client.send('Emulation.setUserAgentOverride', {
userAgent: options.userAgent || '',
acceptLanguage: options.locale,
userAgentMetadata: calculateUserAgentMetadata(options),
platform: navigatorPlatform,
userAgentMetadata,
});
}

Expand Down Expand Up @@ -1158,48 +1161,3 @@ async function emulateTimezone(session: CRSession, timezoneId: string) {
throw exception;
}
}

// Chromium reference: https://source.chromium.org/chromium/chromium/src/+/main:components/embedder_support/user_agent_utils.cc;l=434;drc=70a6711e08e9f9e0d8e4c48e9ba5cab62eb010c2
function calculateUserAgentMetadata(options: types.BrowserContextOptions) {
const ua = options.userAgent;
if (!ua)
return undefined;
const metadata: Protocol.Emulation.UserAgentMetadata = {
mobile: !!options.isMobile,
model: '',
architecture: 'x86',
platform: 'Windows',
platformVersion: '',
};
const androidMatch = ua.match(/Android (\d+(\.\d+)?(\.\d+)?)/);
const iPhoneMatch = ua.match(/iPhone OS (\d+(_\d+)?)/);
const iPadMatch = ua.match(/iPad; CPU OS (\d+(_\d+)?)/);
const macOSMatch = ua.match(/Mac OS X (\d+(_\d+)?(_\d+)?)/);
const windowsMatch = ua.match(/Windows\D+(\d+(\.\d+)?(\.\d+)?)/);
if (androidMatch) {
metadata.platform = 'Android';
metadata.platformVersion = androidMatch[1];
metadata.architecture = 'arm';
} else if (iPhoneMatch) {
metadata.platform = 'iOS';
metadata.platformVersion = iPhoneMatch[1];
metadata.architecture = 'arm';
} else if (iPadMatch) {
metadata.platform = 'iOS';
metadata.platformVersion = iPadMatch[1];
metadata.architecture = 'arm';
} else if (macOSMatch) {
metadata.platform = 'macOS';
metadata.platformVersion = macOSMatch[1];
if (!ua.includes('Intel'))
metadata.architecture = 'arm';
} else if (windowsMatch) {
metadata.platform = 'Windows';
metadata.platformVersion = windowsMatch[1];
} else if (ua.toLowerCase().includes('linux')) {
metadata.platform = 'Linux';
}
if (ua.includes('ARM'))
metadata.architecture = 'arm';
return metadata;
}
9 changes: 7 additions & 2 deletions packages/playwright-core/src/server/firefox/ffBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

import { assert } from '../../utils';
import { Browser } from '../browser';
import { BrowserContext, verifyGeolocation } from '../browserContext';
import { BrowserContext, calculateUserAgentEmulation, verifyGeolocation } from '../browserContext';
import * as network from '../network';
import { ConnectionEvents, FFConnection } from './ffConnection';
import { FFPage } from './ffPage';
Expand Down Expand Up @@ -189,8 +189,11 @@ export class FFBrowserContext extends BrowserContext {
promises.push(this.doUpdateDefaultViewport());
if (this._options.hasTouch)
promises.push(this._browser.session.send('Browser.setTouchOverride', { browserContextId, hasTouch: true }));
if (this._options.userAgent)
if (this._options.userAgent) {
promises.push(this._browser.session.send('Browser.setUserAgentOverride', { browserContextId, userAgent: this._options.userAgent }));
const { navigatorPlatform } = calculateUserAgentEmulation(this._options);
promises.push(this._browser.session.send('Browser.setPlatformOverride', { browserContextId, platform: navigatorPlatform || null }));
}
if (this._options.bypassCSP)
promises.push(this._browser.session.send('Browser.setBypassCSP', { browserContextId, bypassCSP: true }));
if (this._options.ignoreHTTPSErrors || this._options.internalIgnoreHTTPSErrors)
Expand Down Expand Up @@ -325,6 +328,8 @@ export class FFBrowserContext extends BrowserContext {

async setUserAgent(userAgent: string | undefined): Promise<void> {
await this._browser.session.send('Browser.setUserAgentOverride', { browserContextId: this._browserContextId, userAgent: userAgent || null });
const { navigatorPlatform } = calculateUserAgentEmulation({ userAgent });
await this._browser.session.send('Browser.setPlatformOverride', { browserContextId: this._browserContextId, platform: navigatorPlatform || null });
}

async doUpdateOffline(): Promise<void> {
Expand Down
3 changes: 3 additions & 0 deletions packages/playwright-core/src/server/webkit/wkPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { eventsHelper } from '../utils/eventsHelper';
import { hostPlatform } from '../utils/hostPlatform';
import { splitErrorMessage } from '../../utils/isomorphic/stackTrace';
import { PNG, jpegjs } from '../../utilsBundle';
import { calculateUserAgentEmulation } from '../browserContext';
import * as dialog from '../dialog';
import * as dom from '../dom';
import { TargetClosedError } from '../errors';
Expand Down Expand Up @@ -691,6 +692,8 @@ export class WKPage implements PageDelegate {
async updateUserAgent(): Promise<void> {
const contextOptions = this._browserContext._options;
this._updateState('Page.overrideUserAgent', { value: contextOptions.userAgent });
const { navigatorPlatform } = calculateUserAgentEmulation(contextOptions);
this._updateState('Page.overridePlatform', navigatorPlatform ? { value: navigatorPlatform } : { });
}

async bringToFront(): Promise<void> {
Expand Down
61 changes: 61 additions & 0 deletions tests/library/browsercontext-user-agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,38 @@ it('custom user agent for download', async ({ server, contextFactory, browserVer
expect(req.headers['user-agent']).toBe('MyCustomUA');
});

it('should override navigator.platform to match custom user agent', async ({ browser, server }) => {
{
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
});
const page = await context.newPage();
await page.goto(server.EMPTY_PAGE);
expect.soft(await page.evaluate(() => navigator.platform)).toBe('Win32');
await context.close();
}

{
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
});
const page = await context.newPage();
await page.goto(server.EMPTY_PAGE);
expect.soft(await page.evaluate(() => navigator.platform)).toBe('MacIntel');
await context.close();
}

{
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36',
});
const page = await context.newPage();
await page.goto(server.EMPTY_PAGE);
expect.soft(await page.evaluate(() => navigator.platform)).toBe('Linux armv8l');
await context.close();
}
});

it('should work for navigator.userAgentData and sec-ch-ua headers', async ({ playwright, browserName, browser, server }) => {
it.skip(browserName !== 'chromium', 'This API is Chromium-only');

Expand Down Expand Up @@ -139,6 +171,35 @@ it('should work for navigator.userAgentData and sec-ch-ua headers', async ({ pla
expect.soft(await page.evaluate(() => (window.navigator as any).userAgentData.toJSON())).toEqual(
expect.objectContaining({ mobile: true, platform: 'Android' })
);
expect.soft(await page.evaluate(() => navigator.platform)).toBe('Linux armv8l');
await context.close();
}

{
const context = await browser.newContext(playwright.devices['Desktop Chrome']);
const page = await context.newPage();
const [request] = await Promise.all([
server.waitForRequest('/empty.html'),
page.goto(server.EMPTY_PAGE),
]);
expect.soft(request.headers['sec-ch-ua-platform']).toBe(`"Windows"`);
expect.soft(await page.evaluate(() => (window.navigator as any).userAgentData.toJSON())).toEqual(
expect.objectContaining({ mobile: false, platform: 'Windows' })
);
expect.soft(await page.evaluate(() => navigator.platform)).toBe('Win32');
await context.close();
}

{
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
});
const page = await context.newPage();
await page.goto(server.EMPTY_PAGE);
expect.soft(await page.evaluate(() => (window.navigator as any).userAgentData.toJSON())).toEqual(
expect.objectContaining({ platform: 'macOS' })
);
expect.soft(await page.evaluate(() => navigator.platform)).toBe('MacIntel');
await context.close();
}
});
Loading