fix(mcp): launch the Chrome profile that has the extension installed (#41939)

extension mode validated that the playwright extension was installed in any of Chrome's profiles under the user data directory, but then launched without `--profile-directory`, meaning it could open a different profile than the one that was checked

when the opened profile lacks the extension, `connect.html` can't load and the connection silently hangs for ~120s

resolve the profile that actually has the extension (preferring the last-used profile from Local State so we don't switch away from the user's session unnecessarily) and pass it as `--profile-directory`

fixes <https://github.com/microsoft/playwright/issues/41916>
This commit is contained in:
Devin Rousso
2026-07-22 15:03:44 -06:00
committed by GitHub
parent 244a1ffdd8
commit 0edafe4baa
4 changed files with 77 additions and 19 deletions
@@ -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';
@@ -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;
@@ -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> => {
@@ -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);
@@ -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);
@@ -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 {
@@ -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> {
+26
View File
@@ -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';
@@ -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');