diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 801f69084c5a4..2c296981adb16 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -18,6 +18,8 @@ import { debugLog } from './relayConnection'; import { PendingConnections } from './pendingConnection'; import { ConnectedTabGroup, cleanupStalePlaywrightGroups, isNonDebuggableUrl, ungroupTabs, uniqueGroupStyle } from './connectedTabGroup'; +import type { GroupStyle } from './connectedTabGroup'; + type PageMessage = { type: 'connectionRequested'; mcpRelayUrl: string; @@ -111,9 +113,11 @@ class PlaywrightExtension { throw new Error('Pending client connection closed'); const id = ++this._lastConnectionId; - const taken = [...this._connections.values()].map(group => group.groupStyle); - const group = new ConnectedTabGroup(connection, tab, clientName, uniqueGroupStyle(clientName, taken), tabId => this._pendingConnections.has(tabId)); + const group = new ConnectedTabGroup(connection, tab, clientName, uniqueGroupStyle(clientName, this._takenGroupStyles()), tabId => this._pendingConnections.has(tabId)); group.onclose = () => this._connections.delete(id); + // The command arrives over this connection's own socket, so a client can + // only ever relabel its own group. + connection.onsetgrouplabel = label => group.setGroupLabel(label, this._takenGroupStyles(group)); this._connections.set(id, group); await Promise.all([ @@ -129,6 +133,10 @@ class PlaywrightExtension { } } + private _takenGroupStyles(exclude?: ConnectedTabGroup): GroupStyle[] { + return [...this._connections.values()].filter(group => group !== exclude).map(group => group.groupStyle); + } + // Chrome may create the connect page inside the active client's group. private async _releaseConnectPage(tabId: number): Promise { this._releaseTab(tabId); diff --git a/packages/extension/src/connectedTabGroup.ts b/packages/extension/src/connectedTabGroup.ts index 023e084e33cb2..09984605935a7 100644 --- a/packages/extension/src/connectedTabGroup.ts +++ b/packages/extension/src/connectedTabGroup.ts @@ -34,13 +34,17 @@ export type GroupStyle = { color: GroupColor; }; -export function uniqueGroupStyle(clientName: string | undefined, taken: readonly GroupStyle[]): GroupStyle { +function uniqueGroupTitle(name: string, taken: readonly GroupStyle[]): string { const titles = new Set(taken.map(style => style.title)); - const base = PLAYWRIGHT_GROUP_TITLE_PREFIX + (clientName || 'unknown'); + const base = PLAYWRIGHT_GROUP_TITLE_PREFIX + name; let title = base; for (let i = 2; titles.has(title); i++) title = `${base} (${i})`; + return title; +} +export function uniqueGroupStyle(clientName: string | undefined, taken: readonly GroupStyle[]): GroupStyle { + const title = uniqueGroupTitle(clientName || 'unknown', taken); const colors = new Set(taken.map(style => style.color)); const color = PLAYWRIGHT_GROUP_COLORS.find(candidate => !colors.has(candidate)) ?? PLAYWRIGHT_GROUP_COLORS[0]; return { title, color }; @@ -105,6 +109,18 @@ export class ConnectedTabGroup { return [...this._groupTabIds]; } + // Applies a client-chosen label to the group title, deduplicated against + // the other connections' groups. Returns the final title. If the Chrome + // group does not exist yet, the title is applied upon its creation. + async setGroupLabel(label: string, taken: readonly GroupStyle[]): Promise { + const title = uniqueGroupTitle(label, taken); + this.groupStyle.title = title; + const groupId = this._groupId; + if (groupId !== null) + await retryOnDrag(() => chrome.tabGroups.update(groupId, { title })); + return title; + } + close(reason: string): void { this._connection.close(reason); } @@ -226,7 +242,7 @@ export async function ungroupTabs(tabIds: number[]): Promise { // Chrome throws "user may be dragging a tab" while a drag is in progress. // Retry with backoff until it clears (or we give up). -async function retryOnDrag(fn: () => Promise): Promise { +async function retryOnDrag(fn: () => Promise): Promise { const delays = [0, 100, 200, 400, 800]; let lastError: unknown; for (const delay of delays) { diff --git a/packages/extension/src/relayConnection.ts b/packages/extension/src/relayConnection.ts index 928820ef973a8..3a327c7cddcc6 100644 --- a/packages/extension/src/relayConnection.ts +++ b/packages/extension/src/relayConnection.ts @@ -72,6 +72,7 @@ export class RelayConnection { onclose?: () => void; ontabattached?: (tabId: number) => void; ontabdetached?: (tabId: number) => void; + onsetgrouplabel?: (label: string) => Promise; get attachedTabs(): ReadonlySet { return this._attachedTabs; @@ -279,6 +280,8 @@ export class RelayConnection { } private async _handleCommand(message: ProtocolCommand): Promise { + if (message.method === 'extension.setGroupLabel') + return await this._setGroupLabel((message.params as unknown[])?.[0]); if (!ALLOWED_CHROME_COMMANDS.has(message.method)) throw new Error(`Unknown method: ${message.method}`); const args = (message.params ?? []) as any[]; @@ -292,6 +295,17 @@ export class RelayConnection { return result ?? {}; } + // Mirrors the zod limit in the browser_set_group_label tool, re-checked here + // because the extension cannot trust the connecting client. + private async _setGroupLabel(rawLabel: unknown): Promise<{ title: string }> { + const label = String(rawLabel ?? '').trim().slice(0, 50); + if (!label) + throw new Error('Label must not be empty'); + if (!this.onsetgrouplabel) + throw new Error('No tab group for this connection'); + return { title: await this.onsetgrouplabel(label) }; + } + private _sendError(code: number, message: string): void { this._sendMessage({ error: { diff --git a/packages/playwright-core/src/tools/backend/context.ts b/packages/playwright-core/src/tools/backend/context.ts index 5d7e41ad31513..01bf71225d3c9 100644 --- a/packages/playwright-core/src/tools/backend/context.ts +++ b/packages/playwright-core/src/tools/backend/context.ts @@ -65,6 +65,8 @@ export type ContextConfig = { initPage?: string[]; }; skillMode?: boolean; + // Connected to a running browser via the Playwright Extension. + extension?: boolean; }; type ContextOptions = { diff --git a/packages/playwright-core/src/tools/backend/extensionSession.ts b/packages/playwright-core/src/tools/backend/extensionSession.ts new file mode 100644 index 0000000000000..98af74f104190 --- /dev/null +++ b/packages/playwright-core/src/tools/backend/extensionSession.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type * as playwright from '../../..'; + +// Side channel between the extension relay and the tools: commands that are +// specific to the Playwright Extension connection and have no CDP equivalent. +export type ExtensionSession = { + // Applies a user-facing label to the connection's tab group in the browser. + // Returns the final title after deduplication. + setGroupLabel(label: string): Promise<{ title: string }>; +}; + +const extensionSessionSymbol = Symbol('extensionSession'); + +export function registerExtensionSession(browser: playwright.Browser, session: ExtensionSession): void { + // eslint-disable-next-line no-restricted-syntax + (browser as any)[extensionSessionSymbol] = session; +} + +export function extensionSessionFor(browser: playwright.Browser | null): ExtensionSession | undefined { + // eslint-disable-next-line no-restricted-syntax + return browser ? (browser as any)[extensionSessionSymbol] : undefined; +} diff --git a/packages/playwright-core/src/tools/backend/tabs.ts b/packages/playwright-core/src/tools/backend/tabs.ts index 93ad12a02f508..511c4ffcaf325 100644 --- a/packages/playwright-core/src/tools/backend/tabs.ts +++ b/packages/playwright-core/src/tools/backend/tabs.ts @@ -16,6 +16,7 @@ import * as z from 'zod'; import { defineTool } from './tool'; +import { extensionSessionFor } from './extensionSession'; import { renderTabsMarkdown } from './response'; const browserTabs = defineTool({ @@ -65,6 +66,30 @@ const browserTabs = defineTool({ }, }); +const browserSetGroupLabel = defineTool({ + capability: 'core-tabs', + extensionOnly: true, + + schema: { + name: 'browser_set_group_label', + title: 'Label the tab group', + description: 'Set a short label on this session\'s browser tab group, shown to the user as "Playwright ·