Skip to content
Open
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
12 changes: 10 additions & 2 deletions packages/extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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([
Expand All @@ -129,6 +133,10 @@ class PlaywrightExtension {
}
}

private _takenGroupStyles(exclude?: ConnectedTabGroup): GroupStyle[] {
return [...this._connections.values()].filter(group => group !== exclude).map(group => group.groupStyle);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

NIT: you could do this to avoid an extra iteration

Suggested change
return [...this._connections.values()].filter(group => group !== exclude).map(group => group.groupStyle);
return Array.from(this._connections.values(), group => group.groupStyle).filter(group => group !== exclude);

}

// Chrome may create the connect page inside the active client's group.
private async _releaseConnectPage(tabId: number): Promise<void> {
this._releaseTab(tabId);
Expand Down
22 changes: 19 additions & 3 deletions packages/extension/src/connectedTabGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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<string> {
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);
}
Expand Down Expand Up @@ -226,7 +242,7 @@ export async function ungroupTabs(tabIds: number[]): Promise<void> {

// 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<void>): Promise<void> {
async function retryOnDrag(fn: () => Promise<unknown>): Promise<void> {
const delays = [0, 100, 200, 400, 800];
let lastError: unknown;
for (const delay of delays) {
Expand Down
14 changes: 14 additions & 0 deletions packages/extension/src/relayConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export class RelayConnection {
onclose?: () => void;
ontabattached?: (tabId: number) => void;
ontabdetached?: (tabId: number) => void;
onsetgrouplabel?: (label: string) => Promise<string>;

get attachedTabs(): ReadonlySet<number> {
return this._attachedTabs;
Expand Down Expand Up @@ -279,6 +280,8 @@ export class RelayConnection {
}

private async _handleCommand(message: ProtocolCommand): Promise<any> {
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[];
Expand All @@ -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: {
Expand Down
2 changes: 2 additions & 0 deletions packages/playwright-core/src/tools/backend/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export type ContextConfig = {
initPage?: string[];
};
skillMode?: boolean;
// Connected to a running browser via the Playwright Extension.
extension?: boolean;
};

type ContextOptions = {
Expand Down
37 changes: 37 additions & 0 deletions packages/playwright-core/src/tools/backend/extensionSession.ts
Original file line number Diff line number Diff line change
@@ -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;
}
25 changes: 25 additions & 0 deletions packages/playwright-core/src/tools/backend/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import * as z from 'zod';
import { defineTool } from './tool';
import { extensionSessionFor } from './extensionSession';
import { renderTabsMarkdown } from './response';

const browserTabs = defineTool({
Expand Down Expand Up @@ -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 · <label>". Call it once, early in the task, so the user can tell which tab group belongs to which task, especially when several agents share the browser.',
inputSchema: z.object({
label: z.string().trim().min(1).max(50).describe('Short label describing the current task, e.g. "checkout flow bug".'),
}),
type: 'action',
},

handle: async (context, params, response) => {
const session = extensionSessionFor((await context.ensureBrowserContext()).browser());
if (!session)
throw new Error('This tool is only available when connected to a browser via the Playwright Extension.');
const { title } = await session.setGroupLabel(params.label);
response.addTextResult(`Tab group renamed to "${title}".`);
},
});

export default [
browserTabs,
browserSetGroupLabel,
];
2 changes: 2 additions & 0 deletions packages/playwright-core/src/tools/backend/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export type ModalState = FileUploadModalState | DialogModalState;
export type Tool<Input extends z.Schema = z.Schema> = {
capability: ToolCapability;
skillOnly?: boolean;
// Only listed when connected to a browser via the Playwright Extension.
extensionOnly?: boolean;
schema: ToolSchema<Input>;
handle: (context: Context, params: z.output<Input>, response: Response, signal?: AbortSignal) => Promise<void>;
};
Expand Down
4 changes: 2 additions & 2 deletions packages/playwright-core/src/tools/backend/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ export const browserTools: Tool<any>[] = [
...webstorage,
];

export function filteredTools(config: Pick<ContextConfig, 'capabilities'>) {
return browserTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability)).filter(tool => !tool.skillOnly).map(tool => ({
export function filteredTools(config: Pick<ContextConfig, 'capabilities' | 'extension'>) {
return browserTools.filter(tool => tool.capability.startsWith('core') || config.capabilities?.includes(tool.capability)).filter(tool => !tool.skillOnly && (!tool.extensionOnly || config.extension)).map(tool => ({
...tool,
schema: {
...tool.schema,
Expand Down
12 changes: 12 additions & 0 deletions packages/playwright-core/src/tools/cli-daemon/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,17 @@ const tabSelect = declareCommand({
toolParams: ({ index }) => ({ action: 'select', index }),
});

const tabGroupLabel = declareCommand({
name: 'tab-group-label',
description: 'Label this session\'s tab group (extension mode only)',
category: 'tabs',
args: z.object({
label: z.string().describe('Short label describing the current task, e.g. "checkout flow bug"'),
}),
toolName: 'browser_set_group_label',
toolParams: ({ label }) => ({ label }),
});

// Storage

const stateLoad = declareCommand({
Expand Down Expand Up @@ -1201,6 +1212,7 @@ const commandsArray: AnyCommandSchema[] = [
tabNew,
tabClose,
tabSelect,
tabGroupLabel,

// storage category
stateLoad,
Expand Down
24 changes: 18 additions & 6 deletions packages/playwright-core/src/tools/mcp/cdpRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,7 @@ export class CDPRelayServer {
this._profileDirectory = profileDirectory;
this._protocolVersion = parseInt(process.env.PLAYWRIGHT_EXTENSION_PROTOCOL ?? protocol.VERSION.toString(), 10);

const sendCommand = (method: string, params: any): Promise<any> => {
if (!this._extensionConnection)
throw new Error('Extension not connected');
return this._extensionConnection.send(method as keyof ExtensionCommandV2, params);
};
this._handler = new ExtensionProtocolV2(sendCommand);
this._handler = new ExtensionProtocolV2((method, params) => this._sendToExtension(method as keyof ExtensionCommandV2, params));

const uuid = crypto.randomUUID();
this._cdpPath = `/cdp/${uuid}`;
Expand Down Expand Up @@ -121,6 +116,23 @@ export class CDPRelayServer {
return `${this._wsHost}${this._extensionPath}`;
}

async setGroupLabel(label: string): Promise<{ title: string }> {
try {
return await this._sendToExtension('extension.setGroupLabel', [label]);
} catch (error: any) {
// Extensions predating this command reject it as unknown.
if (String(error?.message).includes('Unknown method'))
throw new Error('The installed Playwright Extension version does not support tab group labels. Please update the extension.');
throw error;
}
}

private _sendToExtension(method: keyof ExtensionCommandV2, params: any): Promise<any> {
if (!this._extensionConnection)
throw new Error('Extension not connected');
return this._extensionConnection.send(method, params);
}

async establishExtensionConnection(clientName: string) {
debugLogger('Establishing extension connection');
await this._openConnectPageInBrowser(clientName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import debug from 'debug';
import { defaultUserDataDirForChannel } from '@utils/chromiumChannels';
import { playwright } from '../../inprocess';
import { registerExtensionSession } from '../backend/extensionSession';
import { findPlaywrightExtensionProfile, playwrightExtensionInstallUrl } from '../utils/extension';
import { CDPRelayServer } from './cdpRelay';

Expand All @@ -40,6 +41,9 @@ export async function createExtensionBrowser(channel: string, executablePath: st
await relay.establishExtensionConnection(clientName);
const browser = await playwright.chromium.connectOverCDP(relay.cdpEndpoint(), { isLocal: true, timeout: 0, noDefaults: true });
browser.on('disconnected', () => relay.stop());
registerExtensionSession(browser, {
setGroupLabel: label => relay.setGroupLabel(label),
});
return browser;
} catch (error) {
relay.stop();
Expand Down
7 changes: 7 additions & 0 deletions packages/playwright-core/src/tools/mcp/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ export type ExtensionCommandV2 = {
params: [tabIds: number | number[]];
result: void;
};
// Playwright-specific: applies a user-facing label to the connection's own
// tab group. Returns the final title after deduplication against the other
// connections' groups.
'extension.setGroupLabel': {
params: [label: string];
result: { title: string };
};
};

// Protocol v2 events mirror chrome.<api>.<event>.addListener callback signatures.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ playwright-cli tab-new https://example.com/page
playwright-cli tab-close
playwright-cli tab-close 2
playwright-cli tab-select 0
# Extension mode only: label this session's tab group in the browser
playwright-cli tab-group-label "checkout flow bug"
```

### Storage
Expand Down
11 changes: 11 additions & 0 deletions tests/extension/cli.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,14 @@ test('attach <url> --extension', async ({ startAttach, cli, server }) => {
expect(output).toContain(`- Page Title: Title`);
}
});

test('tab-group-label --extension', {
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/41840' },
}, async ({ startAttach, cli }) => {
const { confirmationPage, cliPromise } = await startAttach();
await clickAllowAndSelect(confirmationPage, 'Welcome');
await cliPromise;

const { output } = await cli(['-s=chromium', 'tab-group-label', 'checkout flow bug']);
expect(output).toContain('Tab group renamed to "Playwright · checkout flow bug"');
});
25 changes: 25 additions & 0 deletions tests/extension/extension-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,31 @@ export async function startWithExtensionFlag(browserWithExtension: BrowserWithEx
return client;
}

// Connects without the approval dialog, so a test can start several clients
// and tell them apart by name.
export async function connectWithName(browserWithExtension: BrowserWithExtension, startClient: StartClient, token: string, clientName: string): Promise<Client> {
const { client } = await startClient({
clientName,
args: ['--extension'],
env: {
PLAYWRIGHT_MCP_EXTENSION_TOKEN: token,
PWTEST_EXTENSION_USER_DATA_DIR: browserWithExtension.userDataDir,
},
});
return client;
}

export async function playwrightGroups(browserContext: BrowserContext): Promise<{ title: string, color: string }[]> {
const [sw] = browserContext.serviceWorkers();
const groups = await sw.evaluate(async () => {
const chrome = (globalThis as any).chrome;
return await chrome.tabGroups.query({});
});
return groups
.map((group: any) => ({ title: group.title, color: group.color }))
.sort((a: any, b: any) => a.title.localeCompare(b.title));
}

export async function readExtensionToken(browserContext: BrowserContext): Promise<string> {
const page = await browserContext.newPage();
await page.goto(`chrome-extension://${extensionId}/status.html`);
Expand Down
Loading
Loading