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');