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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does it work in edge too?

args.push(`--profile-directory=${profileDirectory}`);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we should accept an option for explicit profile selection (the directory names don't match profile names in the ui though)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a --profile-directory option and PLAYWRIGHT_MCP_PROFILE_DIRECTORY environment variable

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commented below, let's remove it for now.

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
Loading