Skip to content
Merged
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
8 changes: 4 additions & 4 deletions packages/playwright-core/browsers.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,16 @@
},
{
"name": "chromium-tip-of-tree",
"revision": "1402",
"revision": "1404",
"installByDefault": false,
"browserVersion": "146.0.7648.0",
"browserVersion": "146.0.7657.0",
"title": "Chrome Canary for Testing"
},
{
"name": "chromium-tip-of-tree-headless-shell",
"revision": "1402",
"revision": "1404",
"installByDefault": false,
"browserVersion": "146.0.7648.0",
"browserVersion": "146.0.7657.0",
"title": "Chrome Canary Headless Shell"
},
{
Expand Down
26 changes: 19 additions & 7 deletions packages/playwright-core/src/server/chromium/crBrowser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,13 +452,25 @@ export class CRBrowserContext extends BrowserContext<CREventsMap> {
['local-fonts', 'localFonts'],
['local-network-access', ['localNetworkAccess', 'localNetwork', 'loopbackNetwork']],
]);
const filtered = permissions.flatMap(permission => {
const protocolPermission = webPermissionToProtocol.get(permission);
if (!protocolPermission)
throw new Error('Unknown permission: ' + permission);
return typeof protocolPermission === 'string' ? [protocolPermission] : protocolPermission;
});
await this._browser._session.send('Browser.grantPermissions', { origin: origin === '*' ? undefined : origin, browserContextId: this._browserContextId, permissions: filtered });

const grantPermissions = async (mapping: Map<string, Protocol.Browser.PermissionType | Protocol.Browser.PermissionType[]>) => {
const filtered = permissions.flatMap(permission => {
const protocolPermission = mapping.get(permission);
if (!protocolPermission)
throw new Error('Unknown permission: ' + permission);
return typeof protocolPermission === 'string' ? [protocolPermission] : protocolPermission;
});
await this._browser._session.send('Browser.grantPermissions', { origin: origin === '*' ? undefined : origin, browserContextId: this._browserContextId, permissions: filtered });
};

try {
await grantPermissions(webPermissionToProtocol);
} catch (e) {
// Old stable browsers dislike the new permission name, so we use the fallback mapping.
const fallbackMapping = new Map(webPermissionToProtocol);
fallbackMapping.set('local-network-access', ['localNetworkAccess']);
await grantPermissions(fallbackMapping);
}
}

async doClearPermissions() {
Expand Down
7 changes: 3 additions & 4 deletions packages/playwright/src/mcp/browser/browserServerBackend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import { FullConfig } from './config';
import { Context } from './context';
import { logUnhandledError } from '../log';
import { Response, serializeResponse } from './response';
import { Response } from './response';
import { SessionLog } from './sessionLog';
import { browserTools, filteredTools } from './tools';
import { toMcpTool } from '../sdk/tool';
Expand Down Expand Up @@ -68,13 +68,12 @@ export class BrowserServerBackend implements ServerBackend {
const parsedArguments = tool.schema.inputSchema.parse(rawArguments || {}) as any;
const cwd = rawArguments?._meta && typeof rawArguments?._meta === 'object' && (rawArguments._meta as any)?.cwd;
const context = this._context!;
const response = Response.create(context, name, parsedArguments);
const response = Response.create(context, name, parsedArguments, cwd);
context.setRunningTool(name);
let responseObject: mcpServer.CallToolResult;
try {
await tool.handle(context, parsedArguments, response);
const sections = await response.build();
responseObject = await serializeResponse(context, sections, cwd ?? context.firstRootPath());
responseObject = await response.serialize();
this._sessionLog?.logResponse(name, parsedArguments, responseObject);
} catch (error: any) {
return {
Expand Down
237 changes: 123 additions & 114 deletions packages/playwright/src/mcp/browser/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,28 +28,27 @@ import type { Context } from './context';

export const requestDebug = debug('pw:mcp:request');

type Result = {
text?: string;
data?: Buffer;
isBase64?: boolean;
title: string;
file?: {
prefix: string;
ext: string;
suggestedFilename?: string;
contentType?: string;
};
type FilenameTemplate = {
prefix: string;
ext: string;
suggestedFilename?: string;
};

type ResolvedFile = {
fileName: string;
relativeName: string;
printableLink: string;
};

export type Section = {
title: string;
content: Result[];
content: string[];
isError?: boolean;
codeframe?: 'yaml' | 'js';
};

export class Response {
private _results: Result[] = [];
private _results: string[] = [];
private _errors: string[] = [];
private _code: string[] = [];
private _context: Context;
Expand All @@ -58,30 +57,65 @@ export class Response {

readonly toolName: string;
readonly toolArgs: Record<string, any>;
private _relativeTo: string | undefined;
private _imageResults: { data: Buffer, imageType: 'png' | 'jpeg' }[] = [];

private constructor(ordinal: number, context: Context, toolName: string, toolArgs: Record<string, any>) {
private constructor(context: Context, toolName: string, toolArgs: Record<string, any>, relativeTo?: string) {
this._context = context;
this.toolName = toolName;
this.toolArgs = toolArgs;
this._relativeTo = relativeTo ?? context.firstRootPath();
}

static _ordinal = 0;
static create(context: Context, toolName: string, toolArgs: Record<string, any>, relativeTo?: string) {
return new Response(context, toolName, toolArgs, relativeTo);
}

private _computRelativeTo(fileName: string): string {
if (this._relativeTo)
return path.relative(this._relativeTo, fileName);
return fileName;
}

static create(context: Context, toolName: string, toolArgs: Record<string, any>) {
return new Response(++Response._ordinal, context, toolName, toolArgs);
async resolveFile(template: FilenameTemplate, title: string): Promise<ResolvedFile> {
let fileName: string;
if (template.suggestedFilename)
fileName = await this._context.outputFile(template.suggestedFilename, { origin: 'llm', title });
else
fileName = await this._context.outputFile(dateAsFileName(template.prefix, template.ext), { origin: 'code', title });
const relativeName = this._computRelativeTo(fileName);
const printableLink = `- [${title}](${relativeName})`;
return { fileName, relativeName, printableLink };
}

addTextResult(text: string) {
this._results.push({ title: '', text });
this._results.push(text);
}

addResult(title: string, data: string | Buffer, file: Result['file']) {
this._results.push({
text: typeof data === 'string' ? data : undefined,
data: typeof data === 'string' ? undefined : data,
title,
file
});
async addResult(title: string, data: Buffer | string, file: FilenameTemplate) {
if (this._context.config.outputMode === 'file' || file.suggestedFilename || typeof data !== 'string') {
const resolvedFile = await this.resolveFile(file, title);
await this.addFileResult(resolvedFile, data);
} else {
this.addTextResult(data);
}
}

async addFileResult(resolvedFile: ResolvedFile, data: Buffer | string | null) {
if (typeof data === 'string')
await fs.promises.writeFile(resolvedFile.fileName, data, 'utf-8');
else if (data)
await fs.promises.writeFile(resolvedFile.fileName, data);
this.addTextResult(resolvedFile.printableLink);
}

addFileLink(title: string, fileName: string) {
const relativeName = this._computRelativeTo(fileName);
this.addTextResult(`- [${title}](${relativeName})`);
}

async registerImageResult(data: Buffer, imageType: 'png' | 'jpeg') {
this._imageResults.push({ data, imageType });
}

addError(error: string) {
Expand All @@ -101,74 +135,104 @@ export class Response {
this._includeSnapshotFileName = includeSnapshotFileName;
}

async build(): Promise<Section[]> {
const rootPath = this._context.firstRootPath();
const sections: Section[] = [];
const addSection = (title: string, codeframe?: 'yaml' | 'js') => {
const section = { title, content: [] as Result[], isError: title === 'Error', codeframe };
sections.push(section);
return section.content;
async serialize(): Promise<CallToolResult> {
const redactText = (text: string): string => {
for (const [secretName, secretValue] of Object.entries(this._context.config.secrets ?? {}))
text = text.replaceAll(secretValue, `<secret>${secretName}</secret>`);
return text;
};

if (this._errors.length) {
const content = addSection('Error');
content.push({ text: this._errors.join('\n'), title: 'error' });
const sections = await this._build();

const text: string[] = [];
for (const section of sections) {
text.push(`### ${section.title}`);
if (section.codeframe)
text.push(`\`\`\`${section.codeframe}`);
text.push(...section.content);
if (section.codeframe)
text.push('```');
}

if (this._results.length) {
const content = addSection('Result');
content.push(...this._results);
const content: (TextContent | ImageContent)[] = [
{
type: 'text',
text: redactText(text.join('\n')),
}
];

// Image attachments.
if (this._context.config.imageResponses !== 'omit') {
for (const imageResult of this._imageResults) {
const scaledData = scaleImageToFitMessage(imageResult.data, imageResult.imageType);
content.push({ type: 'image', data: scaledData.toString('base64'), mimeType: imageResult.imageType === 'png' ? 'image/png' : 'image/jpeg' });
}
}

return {
content,
...(sections.some(section => section.isError) ? { isError: true } : {}),
};
}

private async _build(): Promise<Section[]> {
const sections: Section[] = [];
const addSection = (title: string, content: string[], codeframe?: 'yaml' | 'js') => {
const section = { title, content, isError: title === 'Error', codeframe };
sections.push(section);
return content;
};

if (this._errors.length)
addSection('Error', this._errors);

if (this._results.length)
addSection('Result', this._results);

// Code
if (this._context.config.codegen !== 'none' && this._code.length) {
const content = addSection('Ran Playwright code', 'js');
for (const code of this._code)
content.push({ text: code, title: 'code' });
}
if (this._context.config.codegen !== 'none' && this._code.length)
addSection('Ran Playwright code', this._code, 'js');

// Render tab titles upon changes or when more than one tab.
const tabSnapshot = this._context.currentTab() ? await this._context.currentTabOrDie().captureSnapshot() : undefined;
const tabHeaders = await Promise.all(this._context.tabs().map(tab => tab.headerSnapshot()));
if (this._includeSnapshot !== 'none' || tabHeaders.some(header => header.changed)) {
if (tabHeaders.length !== 1) {
const content = addSection('Open tabs');
content.push({ text: renderTabsMarkdown(tabHeaders).join('\n'), title: 'Open tabs' });
}

const content = addSection('Page');
content.push({ text: renderTabMarkdown(tabHeaders[0]).join('\n'), title: 'Page' });
if (tabHeaders.length !== 1)
addSection('Open tabs', renderTabsMarkdown(tabHeaders));
addSection('Page', renderTabMarkdown(tabHeaders[0]));
}

// Handle modal states.
if (tabSnapshot?.modalStates.length) {
const content = addSection('Modal state');
content.push({ text: renderModalStates(this._context.config, tabSnapshot.modalStates).join('\n'), title: 'Modal state' });
}
if (tabSnapshot?.modalStates.length)
addSection('Modal state', renderModalStates(this._context.config, tabSnapshot.modalStates));

// Handle tab snapshot
if (tabSnapshot && this._includeSnapshot !== 'none') {
const content = addSection('Snapshot', 'yaml');
const snapshot = this._includeSnapshot === 'full' ? tabSnapshot.ariaSnapshot : tabSnapshot.ariaSnapshotDiff ?? tabSnapshot.ariaSnapshot;
content.push({ text: snapshot, title: 'snapshot', file: { prefix: 'page', ext: 'yml', suggestedFilename: this._includeSnapshotFileName } });
if (this._context.config.outputMode === 'file' || this._includeSnapshotFileName) {
const resolvedFile = await this.resolveFile({ prefix: 'page', ext: 'yml', suggestedFilename: this._includeSnapshotFileName }, 'Snapshot');
await fs.promises.writeFile(resolvedFile.fileName, snapshot, 'utf-8');
addSection('Snapshot', [resolvedFile.printableLink]);
} else {
addSection('Snapshot', [snapshot], 'yaml');
}
}

// Handle tab log
if (tabSnapshot?.events.filter(event => event.type !== 'request').length) {
const content = addSection('Events');
const text: string[] = [];
for (const event of tabSnapshot.events) {
if (event.type === 'console') {
if (shouldIncludeMessage(this._context.config.console.level, event.message.type))
text.push(`- ${trimMiddle(event.message.toString(), 100)}`);
} else if (event.type === 'download-start') {

text.push(`- Downloading file ${event.download.download.suggestedFilename()} ...`);
} else if (event.type === 'download-finish') {
text.push(`- Downloaded file ${event.download.download.suggestedFilename()} to "${rootPath ? path.relative(rootPath, event.download.outputFile) : event.download.outputFile}"`);
text.push(`- Downloaded file ${event.download.download.suggestedFilename()} to "${this._computRelativeTo(event.download.outputFile)}"`);
}
}
content.push({ text: text.join('\n'), title: 'events' });
addSection('Events', text);
}
return sections;
}
Expand Down Expand Up @@ -217,61 +281,6 @@ function parseSections(text: string): Map<string, string> {
return sections;
}

export async function serializeResponse(context: Context, sections: Section[], relativeTo?: string): Promise<CallToolResult> {
const redactText = (text: string): string => {
for (const [secretName, secretValue] of Object.entries(context.config.secrets ?? {}))
text = text.replaceAll(secretValue, `<secret>${secretName}</secret>`);
return text;
};

const text: string[] = [];
for (const section of sections) {
text.push(`### ${section.title}`);
const codeframe: string[] = [];
for (const result of section.content) {
if (result.file && (result.file.suggestedFilename || context.config.outputMode === 'file' || result.data)) {
const generatedFileName = await context.outputFile(dateAsFileName(result.file.prefix, result.file.ext), { origin: 'code', title: section.title });
const fileName = result.file.suggestedFilename ? await context.outputFile(result.file.suggestedFilename, { origin: 'llm', title: section.title }) : generatedFileName;
text.push(`- [${result.title}](${relativeTo ? path.relative(relativeTo, fileName) : fileName})`);
if (result.data)
await fs.promises.writeFile(fileName, result.data, 'utf-8');
else
await fs.promises.writeFile(fileName, result.text!);
} else {
if (result.text !== undefined)
codeframe.push(result.text);
}
}

if (codeframe?.length) {
if (section.codeframe)
text.push(`\`\`\`${section.codeframe}\n${codeframe.join('\n')}\n\`\`\``);
else
text.push(codeframe.join('\n'));
}
}

const content: (TextContent | ImageContent)[] = [
{
type: 'text',
text: redactText(text.join('\n')),
}
];

// Image attachments.
if (context.config.imageResponses !== 'omit') {
for (const result of sections.flatMap(section => section.content).filter(result => result.file?.contentType)) {
const scaledData = scaleImageToFitMessage(result.data as Buffer, result.file!.contentType === 'image/png' ? 'png' : 'jpeg');
content.push({ type: 'image', data: scaledData.toString('base64'), mimeType: result.file!.contentType! });
}
}

return {
content,
...(sections.some(section => section.isError) ? { isError: true } : {}),
};
}

export function parseResponse(response: CallToolResult) {
if (response.content?.[0].type !== 'text')
return undefined;
Expand Down
Loading
Loading