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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# 🎭 Playwright

[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[![Chromium version](https://img.shields.io/badge/chromium-151.0.7922.34-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-152.0.4-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[![WebKit version](https://img.shields.io/badge/webkit-26.5-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop --> [![Join Discord](https://img.shields.io/badge/join-discord-informational)](https://aka.ms/playwright/discord)
[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[![Chromium version](https://img.shields.io/badge/chromium-151.0.7922.34-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-153.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[![WebKit version](https://img.shields.io/badge/webkit-26.5-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop --> [![Join Discord](https://img.shields.io/badge/join-discord-informational)](https://aka.ms/playwright/discord)

## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright)

Expand Down Expand Up @@ -298,7 +298,7 @@ The [Playwright VS Code extension](https://marketplace.visualstudio.com/items?it
| :--- | :---: | :---: | :---: |
| Chromium<sup>1</sup> <!-- GEN:chromium-version -->151.0.7922.34<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| WebKit <!-- GEN:webkit-version -->26.5<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Firefox <!-- GEN:firefox-version -->152.0.4<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Firefox <!-- GEN:firefox-version -->153.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |

Headless and headed execution on all platforms. <sup>1</sup> Uses [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing) by default.

Expand Down
2 changes: 1 addition & 1 deletion browser_patches/firefox/UPSTREAM_CONFIG.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
REMOTE_URL="https://github.com/mozilla-firefox/firefox"
BASE_BRANCH="release"
BASE_REVISION="d4faced9e237d6431856c0873cb035cbbc25817b"
BASE_REVISION="f1b6c0f86b96b7e0688c26f65803576f27cdaf88"
2 changes: 2 additions & 0 deletions browser_patches/firefox/juggler/TargetRegistry.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const IDENTITY_NAME = 'JUGGLER ';
const ALL_PERMISSIONS = [
'geo',
'desktop-notification',
'local-network',
'loopback-network',
];

let globalTabAndWindowActivationChain = Promise.resolve();
Expand Down
214 changes: 107 additions & 107 deletions browser_patches/firefox/patches/bootstrap.diff

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/isomorphic/deviceDescriptorsSource.json
Original file line number Diff line number Diff line change
Expand Up @@ -2662,7 +2662,7 @@
"defaultBrowserType": "chromium"
},
"Desktop Firefox HiDPI": {
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0.4) Gecko/20100101 Firefox/152.0.4",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101 Firefox/153.0",
"screen": {
"width": 1792,
"height": 1120
Expand Down Expand Up @@ -2722,7 +2722,7 @@
"defaultBrowserType": "chromium"
},
"Desktop Firefox": {
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:152.0.4) Gecko/20100101 Firefox/152.0.4",
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101 Firefox/153.0",
"screen": {
"width": 1920,
"height": 1080
Expand Down
4 changes: 2 additions & 2 deletions packages/playwright-core/browsers.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@
},
{
"name": "firefox",
"revision": "1536",
"revision": "1538",
"installByDefault": true,
"browserVersion": "152.0.4",
"browserVersion": "153.0",
"title": "Firefox"
},
{
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright-core/src/client/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export class Request extends ChannelOwner<channels.RequestChannel> implements ap
}

postData(): string | null {
return (this._fallbackOverrides.postDataBuffer || this._initializer.postData)?.toString('utf-8') || null;
return (this._fallbackOverrides.postDataBuffer ?? this._initializer.postData)?.toString('utf-8') ?? null;
}

postDataBuffer(): Buffer | null {
Expand Down
21 changes: 11 additions & 10 deletions packages/playwright-core/src/server/firefox/ffBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,18 +278,19 @@ export class FFBrowserContext extends BrowserContext {
}

async doGrantPermissions(origin: string, permissions: string[]) {
const webPermissionToProtocol = new Map<string, string>([
['geolocation', 'geo'],
['persistent-storage', 'persistent-storage'],
['push', 'push'],
['notifications', 'desktop-notification'],
['screen-wake-lock', 'screen-wake-lock'],
const webPermissionToProtocol = new Map<string, string[]>([
['geolocation', ['geo']],
['persistent-storage', ['persistent-storage']],
['push', ['push']],
['notifications', ['desktop-notification']],
['screen-wake-lock', ['screen-wake-lock']],
['local-network-access', ['local-network', 'loopback-network']],
]);
const filtered = permissions.map(permission => {
const protocolPermission = webPermissionToProtocol.get(permission);
if (!protocolPermission)
const filtered = permissions.flatMap(permission => {
const protocolPermissions = webPermissionToProtocol.get(permission);
if (!protocolPermissions)
throw new Error('Unknown permission: ' + permission);
return protocolPermission;
return protocolPermissions;
});
await this._browser.session.send('Browser.grantPermissions', { origin: origin, browserContextId: this._browserContextId, permissions: filtered });
}
Expand Down
20 changes: 13 additions & 7 deletions packages/playwright-core/src/tools/mcp/cdpRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import ws, { WebSocketServer as wsServer } from 'ws';
import { ManualPromise } from '@isomorphic/manualPromise';
import { registry } from '../../server/registry/index';

import { playwrightExtensionId } from '../utils/extension';
import { findPlaywrightExtensionProfile, playwrightExtensionId } from '../utils/extension';
import { addressToString } from '../utils/mcp/http';
import { logUnhandledError } from './log';
import { ExtensionProtocolV2 } from './cdpRelayV2';
Expand All @@ -61,6 +61,7 @@ export class CDPRelayServer {
private _wsHost: string;
private _browserChannel: string;
private _executablePath?: string;
private _userDataDir?: string;
private _cdpPath: string;
private _extensionPath: string;
private _wss: WebSocketServer;
Expand All @@ -70,10 +71,11 @@ export class CDPRelayServer {
private _handler: ExtensionProtocolV2;
private _extensionConnectionPromise = new ManualPromise<void>();

constructor(server: http.Server, browserChannel: string, executablePath?: string) {
constructor(server: http.Server, browserChannel: string, executablePath?: string, userDataDir?: string) {
this._wsHost = addressToString(server.address(), { protocol: 'ws' });
this._browserChannel = browserChannel;
this._executablePath = executablePath;
this._userDataDir = userDataDir;
this._protocolVersion = parseInt(process.env.PLAYWRIGHT_EXTENSION_PROTOCOL ?? protocol.VERSION.toString(), 10);

const sendCommand = (method: string, params: any): Promise<any> => {
Expand Down Expand Up @@ -102,14 +104,14 @@ export class CDPRelayServer {

async establishExtensionConnection(clientName: string) {
debugLogger('Establishing extension connection');
this._openConnectPageInBrowser(clientName);
await this._openConnectPageInBrowser(clientName);
debugLogger('Waiting for incoming extension connection');
await this._extensionConnectionPromise;
await this._handler.ready();
debugLogger('Extension connection established');
}

private _openConnectPageInBrowser(clientName: string) {
private async _openConnectPageInBrowser(clientName: string) {
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
const url = new URL(`chrome-extension://${playwrightExtensionId}/connect.html`);
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
Expand Down Expand Up @@ -137,9 +139,13 @@ export class CDPRelayServer {
}

const args: string[] = [];
const userDataDir = process.env.PWTEST_EXTENSION_USER_DATA_DIR;
if (userDataDir)
args.push(`--user-data-dir=${userDataDir}`);
const testUserDataDir = process.env.PWTEST_EXTENSION_USER_DATA_DIR;
if (testUserDataDir)
args.push(`--user-data-dir=${testUserDataDir}`);
const userDataDir = testUserDataDir ?? this._userDataDir;
const profileDirectory = userDataDir ? await findPlaywrightExtensionProfile(userDataDir) : undefined;
if (profileDirectory)
args.push(`--profile-directory=${profileDirectory}`);
if (os.platform() === 'linux' && channel === 'chromium')
args.push('--no-sandbox');
args.push(href);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,16 @@ const debugLogger = debug('pw:mcp:relay');

export async function createExtensionBrowser(channel: string, executablePath: string | undefined, clientName: string): Promise<playwrightTypes.Browser> {
// Custom executablePath may target a browser in a different filesystem (e.g. Windows chrome.exe from WSL2), so the local profile path is not meaningful.
let userDataDir: string | undefined;
if (!executablePath) {
const userDataDir = process.env.PWTEST_EXTENSION_USER_DATA_DIR ?? defaultUserDataDirForChannel(channel);
userDataDir = process.env.PWTEST_EXTENSION_USER_DATA_DIR ?? defaultUserDataDirForChannel(channel);
if (userDataDir && !await isPlaywrightExtensionInstalled(userDataDir))
throw new Error(`Playwright Extension not found in "${userDataDir}". Install it from ${playwrightExtensionInstallUrl}`);
}

const httpServer = createHttpServer();
await startHttpServer(httpServer, {});
const relay = new CDPRelayServer(httpServer, channel, executablePath);
const relay = new CDPRelayServer(httpServer, channel, executablePath, userDataDir);
debugLogger(`CDP relay server started, extension endpoint: ${relay.extensionEndpoint()}.`);

try {
Expand Down
45 changes: 35 additions & 10 deletions packages/playwright-core/src/tools/utils/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,47 @@ export const playwrightExtensionId = 'mmlmfjhmonkocbjadbfplnigmagldckm';

export const playwrightExtensionInstallUrl = `https://chromewebstore.google.com/detail/playwright-extension/${playwrightExtensionId}`;

export async function isPlaywrightExtensionInstalled(userDataDir: string): Promise<boolean> {
// Chrome stores profiles as `Default` and `Profile <N>` subdirs of the user data dir;
// the extension may be installed into any of them.
export async function findPlaywrightExtensionProfile(userDataDir: string): Promise<string | undefined> {
const profiles = await listProfileDirectories(userDataDir);
const lastUsed = await readLastUsedProfile(userDataDir);
const ordered = lastUsed && profiles.includes(lastUsed)
? [lastUsed, ...profiles.filter(profile => profile !== lastUsed)]
: profiles;
for (const profile of ordered) {
if (await isExtensionInstalledInProfile(path.join(userDataDir, profile)))
return profile;
}
return undefined;
}

async function listProfileDirectories(userDataDir: string): Promise<string[]> {
let entries: string[];
try {
entries = await fs.promises.readdir(userDataDir);
} catch {
return false;
return [];
}
for (const entry of entries) {
if (entry !== 'Default' && !entry.startsWith('Profile '))
continue;
if (await isExtensionInstalledInProfile(path.join(userDataDir, entry)))
return true;
const profiles = entries.filter(entry => entry === 'Default' || /^Profile \d+$/.test(entry));
profiles.sort((a, b) => profileRank(a) - profileRank(b));
return profiles;
}

function profileRank(profile: string): number {
return profile === 'Default' ? -1 : parseInt(profile.slice('Profile '.length), 10);
}

async function readLastUsedProfile(userDataDir: string): Promise<string | undefined> {
try {
const localState = JSON.parse(await fs.promises.readFile(path.join(userDataDir, 'Local State'), 'utf-8'));
const lastUsed = localState?.profile?.last_used;
return typeof lastUsed === 'string' ? lastUsed : undefined;
} catch {
return undefined;
}
return false;
}

export async function isPlaywrightExtensionInstalled(userDataDir: string): Promise<boolean> {
return await findPlaywrightExtensionProfile(userDataDir) !== undefined;
}

async function isExtensionInstalledInProfile(profileDir: string): Promise<boolean> {
Expand Down
26 changes: 26 additions & 0 deletions tests/extension/extension.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import fs from 'fs/promises';
import path from 'path';

import { test, testWithOldExtensionVersion, expect, extensionId, clickAllowAndSelect, connectAndNavigate, startWithExtensionFlag } from './extension-fixtures';
import { utils } from '../../packages/playwright-core/lib/coreBundle';
Expand Down Expand Up @@ -248,6 +249,31 @@ test(`custom executablePath skips local extension check`, {
}).toPass();
});

test(`launches the profile that has the extension`, {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41916' },
}, async ({ startClient, server }, testInfo) => {
// The extension lives in a non-default profile only; the launch must target that profile via
// `--profile-directory`, otherwise Chrome opens the default profile without the extension and the
// connection hangs. A fake executable records the launch arguments, so no real browser is needed.
const userDataDir = testInfo.outputPath('multi-profile');
await fs.mkdir(path.join(userDataDir, 'Default'), { recursive: true });
await fs.mkdir(path.join(userDataDir, 'Profile 1', 'Extensions', extensionId), { recursive: true });

const executablePath = testInfo.outputPath('echo.sh');
await fs.writeFile(executablePath, '#!/bin/bash\necho "Custom exec args: $@" > "$(dirname "$0")/output.txt"', { mode: 0o755 });

const { client } = await startClient({
args: [`--extension`, `--executable-path=${executablePath}`],
env: { PWTEST_EXTENSION_USER_DATA_DIR: userDataDir },
});

client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD } }).catch(() => {});
await expect(async () => {
const output = await fs.readFile(testInfo.outputPath('output.txt'), 'utf8');
expect(output).toContain(`--profile-directory=Profile 1`);
}).toPass();
});

test(`fails when extension is missing in custom userDataDir`, async ({ startClient, server }) => {
const userDataDir = test.info().outputPath('empty-profile');

Expand Down
3 changes: 1 addition & 2 deletions tests/library/browsercontext-locale.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,7 @@ it('should not change default locale in another context', async ({ browser }) =>
}
});

it('should propagate locale to workers', async ({ browser, browserName, isBidi, server }) => {
it.fail(browserName === 'firefox' && !isBidi, 'https://github.com/microsoft/playwright/issues/38919');
it('should propagate locale to workers', async ({ browser, browserName, server }) => {
const context = await browser.newContext({ locale: 'ru-RU' });
const page = await context.newPage();
await page.goto(server.EMPTY_PAGE);
Expand Down
3 changes: 1 addition & 2 deletions tests/library/browsercontext-timezone-id.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,7 @@ it('should affect Intl.DateTimeFormat().resolvedOptions().timeZone', async ({ br
await context.close();
});

it('should propagate timezone to workers', async ({ browser, browserName, isBidi, server }) => {
it.fail(browserName === 'firefox' && !isBidi, 'https://github.com/microsoft/playwright/issues/38919');
it('should propagate timezone to workers', async ({ browser, server }) => {
const context = await browser.newContext({ timezoneId: 'America/Jamaica' });
const page = await context.newPage();
await page.goto(server.EMPTY_PAGE);
Expand Down
2 changes: 1 addition & 1 deletion tests/library/permissions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ it('local network request is allowed from public origin', {
it.skip(browserName === 'webkit');
it.skip(browserName === 'chromium' && browserMajorVersion < 145, 'local-network-access permission support has changed between versions');

if (browserName === 'chromium')
if (browserName === 'chromium' || browserName === 'firefox')
await context.grantPermissions(['local-network-access']);
const serverRequests = [];
server.setRoute('/cors', (req, res) => {
Expand Down
12 changes: 12 additions & 0 deletions tests/page/page-request-continue.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1010,3 +1010,15 @@ it('should not forward Host header on cross-origin redirect', {
expect(firstHost).toBe(new URL(server.PREFIX).host);
expect(redirectedHost).toBe(new URL(server.CROSS_PROCESS_PREFIX).host);
});

it('postData should return empty string when overriding body with empty string', async ({ page, server }) => {
await page.goto(server.EMPTY_PAGE);
await page.route('**/*', route => {
void route.continue({ postData: '' });
});
const [request] = await Promise.all([
page.waitForRequest('**'),
page.evaluate(({ url }) => fetch(url, { method: 'POST', body: 'original' }), { url: server.PREFIX + '/sleep.zzz' }),
]);
expect(request.postData()).toBe('');
});
Loading