Skip to content

Commit 0edafe4

Browse files
authored
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 <#41916>
1 parent 244a1ff commit 0edafe4

4 files changed

Lines changed: 77 additions & 19 deletions

File tree

packages/playwright-core/src/tools/mcp/cdpRelay.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import ws, { WebSocketServer as wsServer } from 'ws';
3434
import { ManualPromise } from '@isomorphic/manualPromise';
3535
import { registry } from '../../server/registry/index';
3636

37-
import { playwrightExtensionId } from '../utils/extension';
37+
import { findPlaywrightExtensionProfile, playwrightExtensionId } from '../utils/extension';
3838
import { addressToString } from '../utils/mcp/http';
3939
import { logUnhandledError } from './log';
4040
import { ExtensionProtocolV2 } from './cdpRelayV2';
@@ -61,6 +61,7 @@ export class CDPRelayServer {
6161
private _wsHost: string;
6262
private _browserChannel: string;
6363
private _executablePath?: string;
64+
private _userDataDir?: string;
6465
private _cdpPath: string;
6566
private _extensionPath: string;
6667
private _wss: WebSocketServer;
@@ -70,10 +71,11 @@ export class CDPRelayServer {
7071
private _handler: ExtensionProtocolV2;
7172
private _extensionConnectionPromise = new ManualPromise<void>();
7273

73-
constructor(server: http.Server, browserChannel: string, executablePath?: string) {
74+
constructor(server: http.Server, browserChannel: string, executablePath?: string, userDataDir?: string) {
7475
this._wsHost = addressToString(server.address(), { protocol: 'ws' });
7576
this._browserChannel = browserChannel;
7677
this._executablePath = executablePath;
78+
this._userDataDir = userDataDir;
7779
this._protocolVersion = parseInt(process.env.PLAYWRIGHT_EXTENSION_PROTOCOL ?? protocol.VERSION.toString(), 10);
7880

7981
const sendCommand = (method: string, params: any): Promise<any> => {
@@ -102,14 +104,14 @@ export class CDPRelayServer {
102104

103105
async establishExtensionConnection(clientName: string) {
104106
debugLogger('Establishing extension connection');
105-
this._openConnectPageInBrowser(clientName);
107+
await this._openConnectPageInBrowser(clientName);
106108
debugLogger('Waiting for incoming extension connection');
107109
await this._extensionConnectionPromise;
108110
await this._handler.ready();
109111
debugLogger('Extension connection established');
110112
}
111113

112-
private _openConnectPageInBrowser(clientName: string) {
114+
private async _openConnectPageInBrowser(clientName: string) {
113115
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
114116
const url = new URL(`chrome-extension://${playwrightExtensionId}/connect.html`);
115117
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
@@ -137,9 +139,13 @@ export class CDPRelayServer {
137139
}
138140

139141
const args: string[] = [];
140-
const userDataDir = process.env.PWTEST_EXTENSION_USER_DATA_DIR;
141-
if (userDataDir)
142-
args.push(`--user-data-dir=${userDataDir}`);
142+
const testUserDataDir = process.env.PWTEST_EXTENSION_USER_DATA_DIR;
143+
if (testUserDataDir)
144+
args.push(`--user-data-dir=${testUserDataDir}`);
145+
const userDataDir = testUserDataDir ?? this._userDataDir;
146+
const profileDirectory = userDataDir ? await findPlaywrightExtensionProfile(userDataDir) : undefined;
147+
if (profileDirectory)
148+
args.push(`--profile-directory=${profileDirectory}`);
143149
if (os.platform() === 'linux' && channel === 'chromium')
144150
args.push('--no-sandbox');
145151
args.push(href);

packages/playwright-core/src/tools/mcp/extensionContextFactory.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,16 @@ const debugLogger = debug('pw:mcp:relay');
2727

2828
export async function createExtensionBrowser(channel: string, executablePath: string | undefined, clientName: string): Promise<playwrightTypes.Browser> {
2929
// 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.
30+
let userDataDir: string | undefined;
3031
if (!executablePath) {
31-
const userDataDir = process.env.PWTEST_EXTENSION_USER_DATA_DIR ?? defaultUserDataDirForChannel(channel);
32+
userDataDir = process.env.PWTEST_EXTENSION_USER_DATA_DIR ?? defaultUserDataDirForChannel(channel);
3233
if (userDataDir && !await isPlaywrightExtensionInstalled(userDataDir))
3334
throw new Error(`Playwright Extension not found in "${userDataDir}". Install it from ${playwrightExtensionInstallUrl}`);
3435
}
3536

3637
const httpServer = createHttpServer();
3738
await startHttpServer(httpServer, {});
38-
const relay = new CDPRelayServer(httpServer, channel, executablePath);
39+
const relay = new CDPRelayServer(httpServer, channel, executablePath, userDataDir);
3940
debugLogger(`CDP relay server started, extension endpoint: ${relay.extensionEndpoint()}.`);
4041

4142
try {

packages/playwright-core/src/tools/utils/extension.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,22 +22,47 @@ export const playwrightExtensionId = 'mmlmfjhmonkocbjadbfplnigmagldckm';
2222

2323
export const playwrightExtensionInstallUrl = `https://chromewebstore.google.com/detail/playwright-extension/${playwrightExtensionId}`;
2424

25-
export async function isPlaywrightExtensionInstalled(userDataDir: string): Promise<boolean> {
26-
// Chrome stores profiles as `Default` and `Profile <N>` subdirs of the user data dir;
27-
// the extension may be installed into any of them.
25+
export async function findPlaywrightExtensionProfile(userDataDir: string): Promise<string | undefined> {
26+
const profiles = await listProfileDirectories(userDataDir);
27+
const lastUsed = await readLastUsedProfile(userDataDir);
28+
const ordered = lastUsed && profiles.includes(lastUsed)
29+
? [lastUsed, ...profiles.filter(profile => profile !== lastUsed)]
30+
: profiles;
31+
for (const profile of ordered) {
32+
if (await isExtensionInstalledInProfile(path.join(userDataDir, profile)))
33+
return profile;
34+
}
35+
return undefined;
36+
}
37+
38+
async function listProfileDirectories(userDataDir: string): Promise<string[]> {
2839
let entries: string[];
2940
try {
3041
entries = await fs.promises.readdir(userDataDir);
3142
} catch {
32-
return false;
43+
return [];
3344
}
34-
for (const entry of entries) {
35-
if (entry !== 'Default' && !entry.startsWith('Profile '))
36-
continue;
37-
if (await isExtensionInstalledInProfile(path.join(userDataDir, entry)))
38-
return true;
45+
const profiles = entries.filter(entry => entry === 'Default' || /^Profile \d+$/.test(entry));
46+
profiles.sort((a, b) => profileRank(a) - profileRank(b));
47+
return profiles;
48+
}
49+
50+
function profileRank(profile: string): number {
51+
return profile === 'Default' ? -1 : parseInt(profile.slice('Profile '.length), 10);
52+
}
53+
54+
async function readLastUsedProfile(userDataDir: string): Promise<string | undefined> {
55+
try {
56+
const localState = JSON.parse(await fs.promises.readFile(path.join(userDataDir, 'Local State'), 'utf-8'));
57+
const lastUsed = localState?.profile?.last_used;
58+
return typeof lastUsed === 'string' ? lastUsed : undefined;
59+
} catch {
60+
return undefined;
3961
}
40-
return false;
62+
}
63+
64+
export async function isPlaywrightExtensionInstalled(userDataDir: string): Promise<boolean> {
65+
return await findPlaywrightExtensionProfile(userDataDir) !== undefined;
4166
}
4267

4368
async function isExtensionInstalledInProfile(profileDir: string): Promise<boolean> {

tests/extension/extension.spec.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616

1717
import fs from 'fs/promises';
18+
import path from 'path';
1819

1920
import { test, testWithOldExtensionVersion, expect, extensionId, clickAllowAndSelect, connectAndNavigate, startWithExtensionFlag } from './extension-fixtures';
2021
import { utils } from '../../packages/playwright-core/lib/coreBundle';
@@ -248,6 +249,31 @@ test(`custom executablePath skips local extension check`, {
248249
}).toPass();
249250
});
250251

252+
test(`launches the profile that has the extension`, {
253+
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41916' },
254+
}, async ({ startClient, server }, testInfo) => {
255+
// The extension lives in a non-default profile only; the launch must target that profile via
256+
// `--profile-directory`, otherwise Chrome opens the default profile without the extension and the
257+
// connection hangs. A fake executable records the launch arguments, so no real browser is needed.
258+
const userDataDir = testInfo.outputPath('multi-profile');
259+
await fs.mkdir(path.join(userDataDir, 'Default'), { recursive: true });
260+
await fs.mkdir(path.join(userDataDir, 'Profile 1', 'Extensions', extensionId), { recursive: true });
261+
262+
const executablePath = testInfo.outputPath('echo.sh');
263+
await fs.writeFile(executablePath, '#!/bin/bash\necho "Custom exec args: $@" > "$(dirname "$0")/output.txt"', { mode: 0o755 });
264+
265+
const { client } = await startClient({
266+
args: [`--extension`, `--executable-path=${executablePath}`],
267+
env: { PWTEST_EXTENSION_USER_DATA_DIR: userDataDir },
268+
});
269+
270+
client.callTool({ name: 'browser_navigate', arguments: { url: server.HELLO_WORLD } }).catch(() => {});
271+
await expect(async () => {
272+
const output = await fs.readFile(testInfo.outputPath('output.txt'), 'utf8');
273+
expect(output).toContain(`--profile-directory=Profile 1`);
274+
}).toPass();
275+
});
276+
251277
test(`fails when extension is missing in custom userDataDir`, async ({ startClient, server }) => {
252278
const userDataDir = test.info().outputPath('empty-profile');
253279

0 commit comments

Comments
 (0)