mirror of
https://github.com/hanzoai/playwright.git
synced 2026-09-24 17:01:53 +00:00
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:
@@ -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> {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user