From d8e89c424e130ea68391a0181c46bda56c572335 Mon Sep 17 00:00:00 2001 From: Devin Rousso Date: Wed, 22 Jul 2026 14:10:00 -0600 Subject: [PATCH] fix(mcp): launch the Chrome profile that has the extension installed 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` --- .../playwright-core/src/tools/mcp/cdpRelay.ts | 20 ++++++--- .../src/tools/mcp/extensionContextFactory.ts | 5 ++- .../src/tools/utils/extension.ts | 45 ++++++++++++++----- tests/extension/extension.spec.ts | 26 +++++++++++ 4 files changed, 77 insertions(+), 19 deletions(-) diff --git a/packages/playwright-core/src/tools/mcp/cdpRelay.ts b/packages/playwright-core/src/tools/mcp/cdpRelay.ts index 60915e7e64b6f..b1a654131afe1 100644 --- a/packages/playwright-core/src/tools/mcp/cdpRelay.ts +++ b/packages/playwright-core/src/tools/mcp/cdpRelay.ts @@ -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(); - 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 => { @@ -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); diff --git a/packages/playwright-core/src/tools/mcp/extensionContextFactory.ts b/packages/playwright-core/src/tools/mcp/extensionContextFactory.ts index ae2f2bedafe6d..16d8a6ae6c8b2 100644 --- a/packages/playwright-core/src/tools/mcp/extensionContextFactory.ts +++ b/packages/playwright-core/src/tools/mcp/extensionContextFactory.ts @@ -27,15 +27,16 @@ const debugLogger = debug('pw:mcp:relay'); export async function createExtensionBrowser(channel: string, executablePath: string | undefined, clientName: string): Promise { // 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 { diff --git a/packages/playwright-core/src/tools/utils/extension.ts b/packages/playwright-core/src/tools/utils/extension.ts index 1f8901ea65472..94a459766cffe 100644 --- a/packages/playwright-core/src/tools/utils/extension.ts +++ b/packages/playwright-core/src/tools/utils/extension.ts @@ -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 { - // Chrome stores profiles as `Default` and `Profile ` subdirs of the user data dir; - // the extension may be installed into any of them. +export async function findPlaywrightExtensionProfile(userDataDir: string): Promise { + 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 { 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 { + 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 { + return await findPlaywrightExtensionProfile(userDataDir) !== undefined; } async function isExtensionInstalledInProfile(profileDir: string): Promise { diff --git a/tests/extension/extension.spec.ts b/tests/extension/extension.spec.ts index 68c5c50b0af30..1f3c7b4b88f18 100644 --- a/tests/extension/extension.spec.ts +++ b/tests/extension/extension.spec.ts @@ -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');