Skip to content

Commit deb7e86

Browse files
feat(extension): allow disabling controlled tab groups (#43814)
Co-authored-by: microsoft-playwright-automation[bot] <203992400+microsoft-playwright-automation[bot]@users.noreply.github.com>
1 parent a294eb0 commit deb7e86

13 files changed

Lines changed: 110 additions & 29 deletions

File tree

‎packages/extension/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ When the LLM interacts with the browser for the first time, it will load a page
4242

4343
Several clients can be connected at the same time. Each one gets its own tab group, named after the client and colored apart from the other groups, and only sees the tabs in that group — a tab can belong to a single client at a time. Drag tabs in and out of a group to change what a client can reach, and use the extension's status page to see every connection and disconnect them individually.
4444

45+
To disable automatic grouping, pass `--no-tab-group` alongside `--extension`, or set `PLAYWRIGHT_MCP_NO_TAB_GROUP=1`. Grouping stays enabled by default. With grouping disabled, the selected tab and tabs opened by the client stay in their existing groups, if any. This does not grant access to all existing tabs or change connection approval; each controlled tab still belongs to only one client. Use the status page to see controlled tabs and disconnect clients.
46+
4547
### Bypassing the Connection Approval Dialog
4648

4749
By default, you'll need to approve each connection when the MCP server tries to connect to your browser. To bypass this approval dialog and allow automatic connections, you can use an authentication token.
@@ -93,4 +95,3 @@ If the extension is installed in several Chrome profiles, the connection is made
9395

9496
The `PLAYWRIGHT_MCP_PROFILE_DIR_NAME` environment variable can be used instead of the option. The authentication token is specific to the profile, so use the one displayed in that profile.
9597

96-

‎packages/extension/src/background.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ type PageMessage = {
2929
// selection happens.
3030
tab?: chrome.tabs.Tab;
3131
clientName?: string;
32+
tabGroup?: boolean;
3233
} | {
3334
type: 'getConnectionStatus';
3435
} | {
@@ -74,7 +75,7 @@ class PlaywrightExtension {
7475
// sender.tab and UI-supplied tabs come from chrome.tabs.query / runtime
7576
// message sender, where `id` is always defined.
7677
const selectedTab = (message.tab ?? sender.tab!) as chrome.tabs.Tab & { id: number };
77-
this._connectTab(sender.tab!.id!, selectedTab, message.clientName).then(
78+
this._connectTab(sender.tab!.id!, selectedTab, message.clientName, message.tabGroup !== false).then(
7879
() => sendResponse({ success: true }),
7980
(error: any) => sendResponse({ success: false, error: error.message }));
8081
return true; // Return true to indicate that the response will be sent asynchronously
@@ -99,7 +100,7 @@ class PlaywrightExtension {
99100
}
100101
}
101102

102-
private async _connectTab(selectorTabId: number, tab: chrome.tabs.Tab & { id: number }, clientName: string | undefined): Promise<void> {
103+
private async _connectTab(selectorTabId: number, tab: chrome.tabs.Tab & { id: number }, clientName: string | undefined, tabGroup: boolean): Promise<void> {
103104
try {
104105
await this._cleanupPromise;
105106
this._releaseTab(selectorTabId);
@@ -111,8 +112,8 @@ class PlaywrightExtension {
111112
throw new Error('Pending client connection closed');
112113

113114
const id = ++this._lastConnectionId;
114-
const taken = [...this._connections.values()].map(group => group.groupStyle);
115-
const group = new ConnectedTabGroup(connection, tab, clientName, uniqueGroupStyle(clientName, taken), tabId => this._pendingConnections.has(tabId));
115+
const taken = [...this._connections.values()].flatMap(group => group.groupStyle ? [group.groupStyle] : []);
116+
const group = new ConnectedTabGroup(connection, tab, clientName, tabGroup ? uniqueGroupStyle(clientName, taken) : undefined, tabId => this._pendingConnections.has(tabId));
116117
group.onclose = () => this._connections.delete(id);
117118
this._connections.set(id, group);
118119

‎packages/extension/src/connectedTabGroup.ts‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,17 +61,17 @@ export async function cleanupStalePlaywrightGroups(): Promise<void> {
6161
}
6262
}
6363

64-
// The Playwright tab group for an active RelayConnection. The Chrome tab group
65-
// is the single source of truth for which tabs the client targets:
64+
// The controlled tabs for an active RelayConnection. When grouping is enabled,
65+
// the Chrome tab group is the single source of truth for which tabs the client targets:
6666
// - User drags a tab in/out → `_onTabGroupChanged` attaches/detaches.
6767
// - Relay attaches on its own (initial tab, popup, Target.createTarget) →
6868
// `_onTabAttached` pulls the new tab into the group, whose onUpdated event
6969
// flows back through `_onTabGroupChanged` for consistency.
70-
// `_groupTabIds` caches group membership from Chrome events so hot-path checks
71-
// in `_onTabUpdated` stay synchronous.
70+
// `_groupTabIds` tracks controlled tabs, caching group membership from Chrome
71+
// events when grouping is enabled.
7272
export class ConnectedTabGroup {
7373
readonly clientName: string | undefined;
74-
readonly groupStyle: GroupStyle;
74+
readonly groupStyle: GroupStyle | undefined;
7575
private _connection: RelayConnection;
7676
private _isTabReserved: (tabId: number) => boolean;
7777
private _groupId: number | null = null;
@@ -81,7 +81,7 @@ export class ConnectedTabGroup {
8181

8282
onclose?: () => void;
8383

84-
constructor(connection: RelayConnection, selectedTab: chrome.tabs.Tab, clientName: string | undefined, groupStyle: GroupStyle, isTabReserved: (tabId: number) => boolean) {
84+
constructor(connection: RelayConnection, selectedTab: chrome.tabs.Tab, clientName: string | undefined, groupStyle: GroupStyle | undefined, isTabReserved: (tabId: number) => boolean) {
8585
this.clientName = clientName;
8686
this.groupStyle = groupStyle;
8787
this._isTabReserved = isTabReserved;
@@ -117,7 +117,7 @@ export class ConnectedTabGroup {
117117
}
118118

119119
private _onTabUpdated(tabId: number, changeInfo: chrome.tabs.OnUpdatedInfo, tab: chrome.tabs.Tab): void {
120-
if (changeInfo.groupId !== undefined)
120+
if (this.groupStyle && changeInfo.groupId !== undefined)
121121
this._onTabGroupChanged(tabId, tab);
122122
if (changeInfo.url === undefined)
123123
return;
@@ -160,13 +160,18 @@ export class ConnectedTabGroup {
160160

161161
private _onTabAttached(tabId: number): void {
162162
void this._updateBadge(tabId, CONNECTED_BADGE);
163-
void this._addTabToGroup(tabId);
163+
if (this.groupStyle)
164+
void this._addTabToGroup(tabId);
165+
else
166+
this._groupTabIds.add(tabId);
164167
}
165168

166169
// The debugger detached (drag-out, tab close, or external action). Clear the
167170
// badge but leave the tab in the group — the user's intent is still there,
168171
// and a subsequent navigation will re-attach via _onTabUpdated.
169172
private _onTabDetached(tabId: number): void {
173+
if (!this.groupStyle)
174+
this._groupTabIds.delete(tabId);
170175
void this._updateBadge(tabId, { text: '' });
171176
}
172177

@@ -175,7 +180,7 @@ export class ConnectedTabGroup {
175180
chrome.tabs.onRemoved.removeListener(this._onTabRemovedListener);
176181
const groupTabs = [...this._groupTabIds];
177182
this._groupTabIds.clear();
178-
if (groupTabs.length)
183+
if (this.groupStyle && groupTabs.length)
179184
void ungroupTabs(groupTabs);
180185
this.onclose?.();
181186
}
@@ -197,13 +202,14 @@ export class ConnectedTabGroup {
197202
// that arrives concurrently (`_groupId` still null, wasInGroup still false)
198203
// becomes a harmless no-op rather than taking the drag-out branch.
199204
private async _addTabToGroup(tabId: number): Promise<void> {
200-
if (this._groupTabIds.has(tabId))
205+
const groupStyle = this.groupStyle;
206+
if (!groupStyle || this._groupTabIds.has(tabId))
201207
return;
202208
try {
203209
await retryOnDrag(async () => {
204210
if (this._groupId === null) {
205211
this._groupId = await chrome.tabs.group({ tabIds: [tabId] });
206-
await chrome.tabGroups.update(this._groupId, this.groupStyle);
212+
await chrome.tabGroups.update(this._groupId, groupStyle);
207213
} else {
208214
await chrome.tabs.group({ groupId: this._groupId, tabIds: [tabId] });
209215
}

‎packages/extension/src/ui/connect.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type Status =
2626
| { type: 'error'; versionMismatch: { extensionVersion: string; } };
2727

2828
const SUPPORTED_PROTOCOL_VERSION = 2;
29+
const tabGroup = new URLSearchParams(window.location.search).get('tabGroup') !== 'false';
2930

3031
// Client name comes from the URL and never changes for the lifetime of this page.
3132
const clientInfo = (() => {
@@ -142,6 +143,7 @@ const ConnectApp: React.FC = () => {
142143
type: 'connectToTab',
143144
tab,
144145
clientName: clientInfo,
146+
tabGroup,
145147
});
146148

147149
if (response?.success) {
@@ -185,7 +187,7 @@ const ConnectApp: React.FC = () => {
185187
{showTabList && (
186188
<div>
187189
<div className='tab-section-title'>
188-
You can drag tabs into the Playwright group later to make them accessible to the client.
190+
{tabGroup && <>You can drag tabs into the Playwright group later to make them accessible to the client. </>}
189191
Optionally, select a tab to allow and immediately switch to it:
190192
</div>
191193
<div>

‎packages/playwright-core/src/tools/mcp/browserFactory.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ async function createBrowser(config: FullConfig, clientInfo: ClientInfo, cliOpti
7676
ownership = 'own';
7777
} else if (config.extension) {
7878
const { channel, executablePath, profileDirName } = resolveExtensionOptions(cliOptions);
79-
browser = await createExtensionBrowser(channel, executablePath, config.browser.userDataDir, profileDirName, clientInfo.clientName);
79+
browser = await createExtensionBrowser(channel, executablePath, config.browser.userDataDir, profileDirName, clientInfo.clientName, config.tabGroup);
8080
ownership = 'attached';
8181
} else {
8282
browser = await createPersistentBrowser(config, clientInfo);

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,9 @@ export class CDPRelayServer {
128128
return `${this._wsHost}${this._extensionPath}`;
129129
}
130130

131-
async establishExtensionConnection(clientName: string) {
131+
async establishExtensionConnection(clientName: string, tabGroup = true) {
132132
debugLogger('Establishing extension connection');
133-
await this._openConnectPageInBrowser(clientName);
133+
await this._openConnectPageInBrowser(clientName, tabGroup);
134134
debugLogger('Waiting for incoming extension connection');
135135
// Without a token the user has to approve the connection in the browser, which can take arbitrarily long.
136136
const deadline = this._token ? monotonicTime() + extensionConnectionTimeout : 0;
@@ -145,7 +145,7 @@ export class CDPRelayServer {
145145
debugLogger('Extension connection established');
146146
}
147147

148-
private async _openConnectPageInBrowser(clientName: string) {
148+
private async _openConnectPageInBrowser(clientName: string, tabGroup: boolean) {
149149
const mcpRelayEndpoint = `${this._wsHost}${this._extensionPath}`;
150150
const url = new URL(`chrome-extension://${playwrightExtensionId}/connect.html`);
151151
url.searchParams.set('mcpRelayUrl', mcpRelayEndpoint);
@@ -156,6 +156,8 @@ export class CDPRelayServer {
156156
};
157157
url.searchParams.set('client', JSON.stringify(client));
158158
url.searchParams.set('protocolVersion', this._protocolVersion.toString());
159+
if (!tabGroup)
160+
url.searchParams.set('tabGroup', 'false');
159161
if (this._token)
160162
url.searchParams.set('token', this._token);
161163
const href = url.toString();

‎packages/playwright-core/src/tools/mcp/config.d.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ export type Config = {
109109
*/
110110
extension?: boolean;
111111

112+
/**
113+
* Group controlled tabs when using the extension. Defaults to true.
114+
*/
115+
tabGroup?: boolean;
116+
112117
server?: {
113118
/**
114119
* The port to listen on for SSE or MCP transport.

‎packages/playwright-core/src/tools/mcp/config.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export type CLIOptions = {
7878
snapshotBoxes?: boolean;
7979
snapshotMode?: 'full' | 'none';
8080
storageState?: string;
81+
tabGroup?: boolean;
8182
testIdAttribute?: string;
8283
timeoutAction?: number;
8384
timeoutNavigation?: number;
@@ -373,6 +374,7 @@ function configFromCLIOptions(cliOptions: CLIOptions): Config & { configFile?: s
373374
remoteEndpoint: cliOptions.endpoint,
374375
},
375376
extension: cliOptions.extension,
377+
tabGroup: cliOptions.tabGroup === false ? false : undefined,
376378
server: {
377379
port: cliOptions.port,
378380
host: cliOptions.host,
@@ -476,7 +478,11 @@ export function configFromEnv(env?: NodeJS.ProcessEnv): Config & { configFile?:
476478
options.webmcp = envToBoolean(e.PLAYWRIGHT_MCP_WEBMCP);
477479
options.userDataDir = envToString(e.PLAYWRIGHT_MCP_USER_DATA_DIR);
478480
options.viewportSize = resolutionParser('--viewport-size', e.PLAYWRIGHT_MCP_VIEWPORT_SIZE);
479-
return configFromCLIOptions(options);
481+
const config = configFromCLIOptions(options);
482+
const noTabGroup = envToBoolean(e.PLAYWRIGHT_MCP_NO_TAB_GROUP);
483+
if (noTabGroup !== undefined)
484+
config.tabGroup = !noTabGroup;
485+
return config;
480486
}
481487

482488
export async function loadConfig(configFile: string | undefined): Promise<Config> {

‎packages/playwright-core/src/tools/mcp/configIni.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ const longhandTypes: Record<string, LonghandType> = {
155155

156156
// top-level
157157
'extension': 'boolean',
158+
'tabGroup': 'boolean',
158159
'capabilities': 'string[]',
159160
'saveSession': 'boolean',
160161
'saveVideo': 'size',

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import type * as playwrightTypes from '../../..';
2626

2727
const debugLogger = debug('pw:mcp:relay');
2828

29-
export async function createExtensionBrowser(channel: string, executablePath: string | undefined, customUserDataDir: string | undefined, profileDirName: string | undefined, clientName: string): Promise<playwrightTypes.Browser> {
29+
export async function createExtensionBrowser(channel: string, executablePath: string | undefined, customUserDataDir: string | undefined, profileDirName: string | undefined, clientName: string, tabGroup = true): Promise<playwrightTypes.Browser> {
3030
customUserDataDir ??= process.env.PWTEST_EXTENSION_USER_DATA_DIR;
3131
// 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.
3232
const userDataDir = customUserDataDir ?? (executablePath ? undefined : defaultUserDataDirForChannel(channel));
@@ -39,7 +39,7 @@ export async function createExtensionBrowser(channel: string, executablePath: st
3939
debugLogger(`CDP relay server started, extension endpoint: ${relay.extensionEndpoint()}.`);
4040

4141
try {
42-
await relay.establishExtensionConnection(clientName);
42+
await relay.establishExtensionConnection(clientName, tabGroup);
4343
const browser = await playwright.chromium.connectOverCDP(relay.cdpEndpoint(), { isLocal: true, timeout: 0, noDefaults: true });
4444
browser.on('disconnected', () => relay.stop());
4545
return browser;

0 commit comments

Comments
 (0)