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
7 changes: 6 additions & 1 deletion packages/playwright-core/src/client/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export class Video implements api.Video {
private _artifactReadyPromise: ManualPromise<Artifact>;
private _isRemote = false;
private _page: Page;
private _path: string | undefined;

constructor(page: Page, connection: Connection) {
this._page = page;
Expand All @@ -39,7 +40,8 @@ export class Video implements api.Video {
}

async start(options: { size?: { width: number, height: number } } = {}): Promise<void> {
await this._page._channel.videoStart(options);
const result = await this._page._channel.videoStart(options);
this._path = result.path;
this._artifactReadyPromise = new ManualPromise<Artifact>();
this._artifact = this._page._closedOrCrashedScope.safeRace(this._artifactReadyPromise);
}
Expand All @@ -55,6 +57,9 @@ export class Video implements api.Video {
async path(): Promise<string> {
if (this._isRemote)
throw new Error(`Path is not available when connecting remotely. Use saveAs() to save a local copy.`);
if (this._path)
return this._path;

const artifact = await this._artifact;
if (!artifact)
throw new Error('Page did not produce any video frames');
Expand Down
4 changes: 3 additions & 1 deletion packages/playwright-core/src/protocol/validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1495,7 +1495,9 @@ scheme.PageVideoStartParams = tObject({
height: tInt,
})),
});
scheme.PageVideoStartResult = tOptional(tObject({}));
scheme.PageVideoStartResult = tObject({
path: tString,
});
scheme.PageVideoStopParams = tOptional(tObject({}));
scheme.PageVideoStopResult = tOptional(tObject({}));
scheme.PageUpdateSubscriptionParams = tObject({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -337,12 +337,12 @@ export class PageDispatcher extends Dispatcher<Page, channels.PageChannel, Brows
await progress.race(this._page.bringToFront());
}

async videoStart(params: channels.PageVideoStartParams, progress: Progress): Promise<void> {
await this._page.screencast.startExplicitVideoRecording(params);
async videoStart(params: channels.PageVideoStartParams, progress: Progress): Promise<channels.PageVideoStartResult> {
return await this._page.screencast.startExplicitVideoRecording(params);
}

async videoStop(params: channels.PageVideoStopParams, progress: Progress): Promise<channels.PageVideoStopResult> {
await this._page.screencast.stopVideoRecording();
await this._page.screencast.stopExplicitVideoRecording();
}

async startJSCoverage(params: channels.PageStartJSCoverageParams, progress: Progress): Promise<void> {
Expand Down
46 changes: 31 additions & 15 deletions packages/playwright-core/src/server/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,21 +589,37 @@ export class Registry {
const currentDockerVersion = readDockerVersionSync();
const preferredDockerVersion = currentDockerVersion ? dockerVersion(currentDockerVersion.dockerImageNameTemplate) : null;
const isOutdatedDockerImage = currentDockerVersion && preferredDockerVersion && currentDockerVersion.dockerImageName !== preferredDockerVersion.dockerImageName;
const prettyMessage = isOutdatedDockerImage ? [
`Looks like ${sdkLanguage === 'javascript' ? 'Playwright Test or ' : ''}Playwright was just updated to ${preferredDockerVersion.driverVersion}.`,
`Please update docker image as well.`,
`- current: ${currentDockerVersion.dockerImageName}`,
`- required: ${preferredDockerVersion.dockerImageName}`,
``,
`<3 Playwright Team`,
].join('\n') : [
`Looks like ${sdkLanguage === 'javascript' ? 'Playwright Test or ' : ''}Playwright was just installed or updated.`,
`Please run the following command to download new browser${installByDefault ? 's' : ''}:`,
``,
` ${installCommand}`,
``,
`<3 Playwright Team`,
].join('\n');
const isFfmpeg = name === 'ffmpeg';
let prettyMessage;
if (isOutdatedDockerImage) {
prettyMessage = [
`Looks like Playwright was just updated to ${preferredDockerVersion.driverVersion}.`,
`Please update docker image as well.`,
`- current: ${currentDockerVersion.dockerImageName}`,
`- required: ${preferredDockerVersion.dockerImageName}`,
``,
`<3 Playwright Team`,
].join('\n');
} else if (isFfmpeg) {
prettyMessage = [
`Video rendering requires ffmpeg binary.`,
`Downloading it will not affect any of the system-wide settings.`,
`Please run the following command:`,
``,
` ${buildPlaywrightCLICommand(sdkLanguage, 'install ffmpeg')}`,
``,
`<3 Playwright Team`,
].join('\n');
} else {
prettyMessage = [
`Looks like Playwright was just installed or updated.`,
`Please run the following command to download new browser${installByDefault ? 's' : ''}:`,
``,
` ${installCommand}`,
``,
`<3 Playwright Team`,
].join('\n');
}
throw new Error(`Executable doesn't exist at ${e}\n${wrapInASCIIBox(prettyMessage, 1)}`);
}
return e;
Expand Down
12 changes: 11 additions & 1 deletion packages/playwright-core/src/server/screencast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,16 @@ export class Screencast {

private _launchVideoRecorder(dir: string, size: { width: number, height: number }): types.VideoOptions {
assert(!this._videoId);
// Do this first, it likes to throw.
const ffmpegPath = registry.findExecutable('ffmpeg')!.executablePathOrDie(this._page.browserContext._browser.sdkLanguage());

this._videoId = createGuid();
const outputFile = path.join(dir, this._videoId + '.webm');
const videoOptions = {
...size,
outputFile,
};
const ffmpegPath = registry.findExecutable('ffmpeg')!.executablePathOrDie(this._page.browserContext._browser.sdkLanguage());

this._videoRecorder = new VideoRecorder(ffmpegPath, videoOptions);
this._frameListener = eventsHelper.addEventListener(this._page, Page.Events.ScreencastFrame, frame => this._videoRecorder!.writeFrame(frame.buffer, frame.frameSwapWallTime / 1000));
this._page.waitForInitializedOrError().then(p => {
Expand Down Expand Up @@ -125,6 +128,13 @@ export class Screencast {
const size = validateVideoSize(options.size, this._page.emulatedSize()?.viewport);
const videoOptions = this._launchVideoRecorder(this._page.browserContext._browser.options.artifactsDir, size);
await this.startVideoRecording(videoOptions);
return { path: videoOptions.outputFile };
}

async stopExplicitVideoRecording() {
if (!this._videoId)
throw new Error('Video is not being recorded');
await this.stopVideoRecording();
}

private async _setOptions(options: { width: number, height: number, quality: number } | null): Promise<void> {
Expand Down
10 changes: 3 additions & 7 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, serializeStructuredResponse } from './response';
import { Response, serializeResponse } from './response';
import { SessionLog } from './sessionLog';
import { browserTools, filteredTools } from './tools';
import { toMcpTool } from '../sdk/tool';
Expand All @@ -33,15 +33,13 @@ export class BrowserServerBackend implements ServerBackend {
private _sessionLog: SessionLog | undefined;
private _config: FullConfig;
private _browserContextFactory: BrowserContextFactory;
private _isStructuredOutput: boolean;

onBrowserContextClosed: (() => void) | undefined;

constructor(config: FullConfig, factory: BrowserContextFactory, options: { allTools?: boolean, structuredOutput?: boolean } = {}) {
this._config = config;
this._browserContextFactory = factory;
this._tools = options.allTools ? browserTools : filteredTools(config);
this._isStructuredOutput = options.structuredOutput ?? false;
}

async initialize(clientInfo: mcpServer.ClientInfo): Promise<void> {
Expand All @@ -68,17 +66,15 @@ 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);
context.setRunningTool(name);
let responseObject: mcpServer.CallToolResult;
try {
await tool.handle(context, parsedArguments, response);
const sections = await response.build();
if (this._isStructuredOutput)
responseObject = await serializeStructuredResponse(sections);
else
responseObject = await serializeResponse(context, sections, context.firstRootPath());
responseObject = await serializeResponse(context, sections, cwd ?? context.firstRootPath());
this._sessionLog?.logResponse(name, parsedArguments, responseObject);
} catch (error: any) {
return {
Expand Down
3 changes: 2 additions & 1 deletion packages/playwright/src/mcp/browser/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,6 @@ const defaultDaemonConfig = (cliOptions: CLIOptions) => mergeConfig(defaultConfi
},
},
outputMode: 'file',
codegen: 'none',
snapshot: {
mode: 'full',
},
Expand Down Expand Up @@ -278,6 +277,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config {
initPage: cliOptions.initPage,
initScript: cliOptions.initScript,
},
extension: cliOptions.extension,
server: {
port: cliOptions.port,
host: cliOptions.host,
Expand Down Expand Up @@ -328,6 +328,7 @@ function configFromEnv(): Config {
options.consoleLevel = enumParser<'error' | 'warning' | 'info' | 'debug'>('--console-level', ['error', 'warning', 'info', 'debug'], process.env.PLAYWRIGHT_MCP_CONSOLE_LEVEL);
options.device = envToString(process.env.PLAYWRIGHT_MCP_DEVICE);
options.executablePath = envToString(process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH);
options.extension = envToBoolean(process.env.PLAYWRIGHT_MCP_EXTENSION);
options.grantPermissions = commaSeparatedList(process.env.PLAYWRIGHT_MCP_GRANT_PERMISSIONS);
options.headless = envToBoolean(process.env.PLAYWRIGHT_MCP_HEADLESS);
options.host = envToString(process.env.PLAYWRIGHT_MCP_HOST);
Expand Down
52 changes: 19 additions & 33 deletions packages/playwright/src/mcp/browser/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export type Section = {
title: string;
content: Result[];
isError?: boolean;
codeframe?: 'yaml' | 'js';
};

export class Response {
Expand Down Expand Up @@ -103,8 +104,8 @@ export class Response {
async build(): Promise<Section[]> {
const rootPath = this._context.firstRootPath();
const sections: Section[] = [];
const addSection = (title: string) => {
const section = { title, content: [] as Result[], isError: title === 'Error' };
const addSection = (title: string, codeframe?: 'yaml' | 'js') => {
const section = { title, content: [] as Result[], isError: title === 'Error', codeframe };
sections.push(section);
return section.content;
};
Expand All @@ -122,7 +123,7 @@ export class Response {

// Code
if (this._context.config.codegen !== 'none' && this._code.length) {
const content = addSection('Ran Playwright code');
const content = addSection('Ran Playwright code', 'js');
for (const code of this._code)
content.push({ text: code, title: 'code' });
}
Expand All @@ -148,7 +149,7 @@ export class Response {

// Handle tab snapshot
if (tabSnapshot && this._includeSnapshot !== 'none') {
const content = addSection('Snapshot');
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 } });
}
Expand Down Expand Up @@ -216,7 +217,7 @@ function parseSections(text: string): Map<string, string> {
return sections;
}

export async function serializeResponse(context: Context, sections: Section[], rootPath?: string): Promise<CallToolResult> {
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>`);
Expand All @@ -226,29 +227,30 @@ export async function serializeResponse(context: Context, sections: Section[], r
const text: string[] = [];
for (const section of sections) {
text.push(`### ${section.title}`);
const codeframe: string[] = [];
for (const result of section.content) {
if (!result.file) {
if (result.text !== undefined)
text.push(result.text);
continue;
}

if (result.file.suggestedFilename || context.config.outputMode === 'file' || result.data) {
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}](${rootPath ? path.relative(rootPath, fileName) : fileName})`);
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.file.ext === 'yml')
text.push(`\`\`\`yaml\n${result.text!}\n\`\`\``);
else
text.push(result.text!);
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',
Expand All @@ -270,22 +272,6 @@ export async function serializeResponse(context: Context, sections: Section[], r
};
}

export async function serializeStructuredResponse(sections: Section[]): Promise<CallToolResult> {
for (const section of sections) {
for (const result of section.content) {
if (!result.data)
continue;
result.isBase64 = true;
result.text = result.data.toString('base64');
result.data = undefined;
}
}
return {
content: [{ type: 'text' as const, text: '', _meta: { sections } }],
isError: sections.some(section => section.isError),
};
}

export function parseResponse(response: CallToolResult) {
if (response.content?.[0].type !== 'text')
return undefined;
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright/src/mcp/browser/tools/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,13 @@ const stopVideo = defineTabTool({
},

handle: async (tab, params, response) => {
const tmpPath = await tab.page.video().path();
let videoPath: string | undefined;
if (params.filename) {
const suggestedFilename = params.filename ?? dateAsFileName('video', 'webm');
videoPath = await tab.context.outputFile(suggestedFilename, { origin: 'llm', title: 'Saving video' });
}
await tab.page.video().stop({ path: videoPath });
const tmpPath = await tab.page.video().path();
response.addTextResult(`Video recording stopped: ${videoPath ?? tmpPath}`);
},
});
Expand Down
7 changes: 7 additions & 0 deletions packages/playwright/src/mcp/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ export type Config = {
initScript?: string[];
},

/**
* Connect to a running browser instance (Edge/Chrome only). If specified, `browser`
* config is ignored.
* Requires the "Playwright MCP Bridge" browser extension to be installed.
*/
extension?: boolean;

server?: {
/**
* The port to listen on for SSE or MCP transport.
Expand Down
4 changes: 3 additions & 1 deletion packages/playwright/src/mcp/extension/cdpRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

import { spawn } from 'child_process';
import http from 'http';
import os from 'os';

import { debug, ws, wsServer } from 'playwright-core/lib/utilsBundle';
import { registry } from 'playwright-core/lib/server/registry/index';
Expand Down Expand Up @@ -145,8 +146,9 @@ export class CDPRelayServer {
const args: string[] = [];
if (this._userDataDir)
args.push(`--user-data-dir=${this._userDataDir}`);
if (os.platform() === 'linux' && this._browserChannel === 'chromium')
args.push('--no-sandbox');
args.push(href);

spawn(executablePath, args, {
windowsHide: true,
detached: true,
Expand Down
6 changes: 3 additions & 3 deletions packages/playwright/src/mcp/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,19 +111,19 @@ export function decorateCommand(command: Command, version: string) {
const extensionContextFactory = new ExtensionContextFactory(config.browser.launchOptions.channel || 'chrome', config.browser.userDataDir, config.browser.launchOptions.executablePath);

if (options.daemon) {
const contextFactory = options.extension ? extensionContextFactory : browserContextFactory;
const contextFactory = config.extension ? extensionContextFactory : browserContextFactory;
const serverBackendFactory: mcpServer.ServerBackendFactory = {
name: 'Playwright',
nameInConfig: 'playwright-daemon',
version,
create: () => new BrowserServerBackend(config, contextFactory, { allTools: true, structuredOutput: true })
create: () => new BrowserServerBackend(config, contextFactory, { allTools: true })
};
const socketPath = await startMcpDaemonServer(options.daemon, serverBackendFactory, options.daemonVersion);
console.error(`Daemon server listening on ${socketPath}`);
return;
}

if (options.extension) {
if (config.extension) {
const serverBackendFactory: mcpServer.ServerBackendFactory = {
name: 'Playwright w/ extension',
nameInConfig: 'playwright-extension',
Expand Down
Loading
Loading