From d446d15d7729ff2fdb7f4513e30c44b2c69cef20 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Mon, 22 Sep 2025 15:41:53 -0700 Subject: [PATCH] feat(mcp): allow saving videos for sessions --- .../src/mcp/browser/browserContextFactory.ts | 32 +++++--- packages/playwright/src/mcp/browser/config.ts | 74 ++++++++++++++----- .../playwright/src/mcp/browser/context.ts | 24 +++++- .../playwright/src/mcp/browser/tools/pdf.ts | 2 +- .../src/mcp/browser/tools/screenshot.ts | 2 +- .../playwright/src/mcp/browser/tools/utils.ts | 4 +- packages/playwright/src/mcp/config.d.ts | 8 ++ packages/playwright/src/mcp/program.ts | 5 +- tests/mcp/fixtures.ts | 2 +- tests/mcp/video.spec.ts | 48 ++++++++++++ 10 files changed, 160 insertions(+), 41 deletions(-) create mode 100644 tests/mcp/video.spec.ts diff --git a/packages/playwright/src/mcp/browser/browserContextFactory.ts b/packages/playwright/src/mcp/browser/browserContextFactory.ts index 2710e96857506..7e75c7d56e4dd 100644 --- a/packages/playwright/src/mcp/browser/browserContextFactory.ts +++ b/packages/playwright/src/mcp/browser/browserContextFactory.ts @@ -27,7 +27,7 @@ import { outputFile } from './config'; import { firstRootPath } from '../sdk/server'; import type { FullConfig } from './config'; -import type { LaunchOptions } from '../../../../playwright-core/src/client/types'; +import type { LaunchOptions, BrowserContextOptions } from '../../../../playwright-core/src/client/types'; import type { ClientInfo } from '../sdk/server'; export function contextFactory(config: FullConfig): BrowserContextFactory { @@ -42,8 +42,13 @@ export function contextFactory(config: FullConfig): BrowserContextFactory { return new PersistentContextFactory(config); } +export type BrowserContextFactoryResult = { + browserContext: playwright.BrowserContext; + close: (afterClose: () => Promise) => Promise; +}; + export interface BrowserContextFactory { - createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }>; + createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise; } class BaseContextFactory implements BrowserContextFactory { @@ -75,23 +80,27 @@ class BaseContextFactory implements BrowserContextFactory { throw new Error('Not implemented'); } - async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { + async createContext(clientInfo: ClientInfo): Promise { testDebug(`create browser context (${this._logName})`); const browser = await this._obtainBrowser(clientInfo); const browserContext = await this._doCreateContext(browser); await addInitScript(browserContext, this.config.browser.initScript); - return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) }; + return { + browserContext, + close: (afterClose: () => Promise) => this._closeBrowserContext(browserContext, browser, afterClose) + }; } protected async _doCreateContext(browser: playwright.Browser): Promise { throw new Error('Not implemented'); } - private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) { + private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser, afterClose: () => Promise) { testDebug(`close browser context (${this._logName})`); if (browser.contexts().length === 1) this._browserPromise = undefined; await browserContext.close().catch(logUnhandledError); + await afterClose(); if (browser.contexts().length === 0) { testDebug(`close browser (${this._logName})`); await browser.close().catch(logUnhandledError); @@ -170,7 +179,7 @@ class PersistentContextFactory implements BrowserContextFactory { this.config = config; } - async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { + async createContext(clientInfo: ClientInfo): Promise { await injectCdpPort(this.config.browser); testDebug('create browser context (persistent)'); const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo); @@ -183,7 +192,7 @@ class PersistentContextFactory implements BrowserContextFactory { const browserType = playwright[this.config.browser.browserName]; for (let i = 0; i < 5; i++) { - const launchOptions: LaunchOptions = { + const launchOptions: LaunchOptions & BrowserContextOptions = { tracesDir, ...this.config.browser.launchOptions, ...this.config.browser.contextOptions, @@ -197,7 +206,7 @@ class PersistentContextFactory implements BrowserContextFactory { try { const browserContext = await browserType.launchPersistentContext(userDataDir, launchOptions); await addInitScript(browserContext, this.config.browser.initScript); - const close = () => this._closeBrowserContext(browserContext, userDataDir); + const close = (afterClose: () => Promise) => this._closeBrowserContext(browserContext, userDataDir, afterClose); return { browserContext, close }; } catch (error: any) { if (error.message.includes('Executable doesn\'t exist')) @@ -213,10 +222,11 @@ class PersistentContextFactory implements BrowserContextFactory { throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`); } - private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string) { + private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string, afterClose: () => Promise) { testDebug('close browser context (persistent)'); testDebug('release user data dir', userDataDir); await browserContext.close().catch(() => {}); + await afterClose(); this._userDataDirs.delete(userDataDir); testDebug('close browser context complete (persistent)'); } @@ -270,7 +280,7 @@ async function addInitScript(browserContext: playwright.BrowserContext, initScri } export class SharedContextFactory implements BrowserContextFactory { - private _contextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> | undefined; + private _contextPromise: Promise | undefined; private _baseFactory: BrowserContextFactory; private static _instance: SharedContextFactory | undefined; @@ -313,6 +323,6 @@ export class SharedContextFactory implements BrowserContextFactory { if (!contextPromise) return; const { close } = await contextPromise; - await close(); + await close(async () => {}); } } diff --git a/packages/playwright/src/mcp/browser/config.ts b/packages/playwright/src/mcp/browser/config.ts index 1e66feffd79fd..c4a67c986f9b3 100644 --- a/packages/playwright/src/mcp/browser/config.ts +++ b/packages/playwright/src/mcp/browser/config.ts @@ -28,6 +28,8 @@ import type * as playwright from '../../../types/test'; import type { Config, ToolCapability } from '../config'; import type { ClientInfo } from '../sdk/server'; +type ViewportSize = { width: number; height: number }; + export type CLIOptions = { allowedOrigins?: string[]; blockedOrigins?: string[]; @@ -53,6 +55,7 @@ export type CLIOptions = { proxyServer?: string; saveSession?: boolean; saveTrace?: boolean; + saveVideo?: ViewportSize; secrets?: Record; sharedBrowserContext?: boolean; storageState?: string; @@ -60,7 +63,7 @@ export type CLIOptions = { timeoutNavigation?: number; userAgent?: string; userDataDir?: string; - viewportSize?: string; + viewportSize?: ViewportSize; }; export const defaultConfig: FullConfig = { @@ -127,6 +130,8 @@ async function validateConfig(config: FullConfig): Promise { throw new Error(`Init script file does not exist: ${script}`); } } + if (config.sharedBrowserContext && config.saveVideo) + throw new Error('saveVideo is not supported when sharedBrowserContext is true'); } export function configFromCLIOptions(cliOptions: CLIOptions): Config { @@ -183,16 +188,8 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config { if (cliOptions.userAgent) contextOptions.userAgent = cliOptions.userAgent; - if (cliOptions.viewportSize) { - try { - const [width, height] = cliOptions.viewportSize.split(',').map(n => +n); - if (isNaN(width) || isNaN(height)) - throw new Error('bad values'); - contextOptions.viewport = { width, height }; - } catch (e) { - throw new Error('Invalid viewport size format: use "width,height", for example --viewport-size="800,600"'); - } - } + if (cliOptions.viewportSize) + contextOptions.viewport = cliOptions.viewportSize; if (cliOptions.ignoreHttpsErrors) contextOptions.ignoreHTTPSErrors = true; @@ -203,6 +200,14 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config { if (cliOptions.grantPermissions) contextOptions.permissions = cliOptions.grantPermissions; + if (cliOptions.saveVideo) { + contextOptions.recordVideo = { + // Videos are moved to output directory on saveAs. + dir: tmpDir(), + size: cliOptions.saveVideo, + }; + } + const result: Config = { browser: { browserName, @@ -225,6 +230,7 @@ export function configFromCLIOptions(cliOptions: CLIOptions): Config { }, saveSession: cliOptions.saveSession, saveTrace: cliOptions.saveTrace, + saveVideo: cliOptions.saveVideo, secrets: cliOptions.secrets, sharedBrowserContext: cliOptions.sharedBrowserContext, outputDir: cliOptions.outputDir, @@ -266,13 +272,14 @@ function configFromEnv(): Config { options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS); options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER); options.saveTrace = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_TRACE); + options.saveVideo = resolutionParser('--save-video', process.env.PLAYWRIGHT_MCP_SAVE_VIDEO); options.secrets = dotenvFileLoader(process.env.PLAYWRIGHT_MCP_SECRETS_FILE); options.storageState = envToString(process.env.PLAYWRIGHT_MCP_STORAGE_STATE); options.timeoutAction = numberParser(process.env.PLAYWRIGHT_MCP_TIMEOUT_ACTION); options.timeoutNavigation = numberParser(process.env.PLAYWRIGHT_MCP_TIMEOUT_NAVIGATION); options.userAgent = envToString(process.env.PLAYWRIGHT_MCP_USER_AGENT); options.userDataDir = envToString(process.env.PLAYWRIGHT_MCP_USER_DATA_DIR); - options.viewportSize = envToString(process.env.PLAYWRIGHT_MCP_VIEWPORT_SIZE); + options.viewportSize = resolutionParser('--viewport-size', process.env.PLAYWRIGHT_MCP_VIEWPORT_SIZE); return configFromCLIOptions(options); } @@ -287,27 +294,35 @@ async function loadConfig(configFile: string | undefined): Promise { } } -export async function outputFile(config: FullConfig, clientInfo: ClientInfo, fileName: string, options: { origin: 'code' | 'llm' | 'web' }): Promise { +function tmpDir(): string { + return path.join(process.env.PW_TMPDIR_FOR_TEST ?? os.tmpdir(), 'playwright-mcp-output'); +} + +export function outputDir(config: FullConfig, clientInfo: ClientInfo): string { const rootPath = firstRootPath(clientInfo); - const outputDir = config.outputDir + return config.outputDir ?? (rootPath ? path.join(rootPath, '.playwright-mcp') : undefined) - ?? path.join(process.env.PW_TMPDIR_FOR_TEST ?? os.tmpdir(), 'playwright-mcp-output', String(clientInfo.timestamp)); + ?? path.join(tmpDir(), String(clientInfo.timestamp)); +} + +export async function outputFile(config: FullConfig, clientInfo: ClientInfo, fileName: string, options: { origin: 'code' | 'llm' | 'web' }): Promise { + const dir = outputDir(config, clientInfo); // Trust code. if (options.origin === 'code') - return path.resolve(outputDir, fileName); + return path.resolve(dir, fileName); // Trust llm to use valid characters in file names. if (options.origin === 'llm') { fileName = fileName.split('\\').join('/'); - const resolvedFile = path.resolve(outputDir, fileName); - if (!resolvedFile.startsWith(path.resolve(outputDir) + path.sep)) + const resolvedFile = path.resolve(dir, fileName); + if (!resolvedFile.startsWith(path.resolve(dir) + path.sep)) throw new Error(`Resolved file path for ${fileName} is outside of the output directory`); return resolvedFile; } // Do not trust web, at all. - return path.join(outputDir, sanitizeForFilePath(fileName)); + return path.join(dir, sanitizeForFilePath(fileName)); } function pickDefined(obj: T | undefined): Partial { @@ -379,6 +394,27 @@ export function numberParser(value: string | undefined): number | undefined { return +value; } +export function resolutionParser(name: string, value: string | undefined): ViewportSize | undefined { + if (!value) + return undefined; + if (value.includes('x')) { + const [width, height] = value.split('x').map(v => +v); + if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0) + throw new Error(`Invalid resolution format: use ${name}="800x600"`); + return { width, height }; + } + + // Legacy format + if (value.includes(',')) { + const [width, height] = value.split(',').map(v => +v); + if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0) + throw new Error(`Invalid resolution format: use ${name}="800x600"`); + return { width, height }; + } + + throw new Error(`Invalid resolution format: use ${name}="800x600"`); +} + export function headerParser(arg: string | undefined, previous?: Record): Record { if (!arg) return previous || {}; diff --git a/packages/playwright/src/mcp/browser/context.ts b/packages/playwright/src/mcp/browser/context.ts index f06c8155dd7db..3c905a68d106e 100644 --- a/packages/playwright/src/mcp/browser/context.ts +++ b/packages/playwright/src/mcp/browser/context.ts @@ -14,16 +14,19 @@ * limitations under the License. */ +import fs from 'fs'; + import { debug } from 'playwright-core/lib/utilsBundle'; import { logUnhandledError } from '../log'; import { Tab } from './tab'; import { outputFile } from './config'; import * as codegen from './codegen'; +import { dateAsFileName } from './tools/utils'; import type * as playwright from '../../../types/test'; import type { FullConfig } from './config'; -import type { BrowserContextFactory } from './browserContextFactory'; +import type { BrowserContextFactory, BrowserContextFactoryResult } from './browserContextFactory'; import type * as actions from './actions'; import type { SessionLog } from './sessionLog'; import type { Tracing } from '../../../../playwright-core/src/client/tracing'; @@ -42,7 +45,7 @@ export class Context { readonly config: FullConfig; readonly sessionLog: SessionLog | undefined; readonly options: ContextOptions; - private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> | undefined; + private _browserContextPromise: Promise | undefined; private _browserContextFactory: BrowserContextFactory; private _tabs: Tab[] = []; private _currentTab: Tab | undefined; @@ -163,7 +166,20 @@ export class Context { await promise.then(async ({ browserContext, close }) => { if (this.config.saveTrace) await browserContext.tracing.stop(); - await close(); + const videos = browserContext.pages().map(page => page.video()).filter(video => !!video); + await close(async () => { + for (const video of videos) { + const name = await this.outputFile(dateAsFileName('webm'), { origin: 'code' }); + const path = await video.path(); + // video.saveAs() does not work for persistent contexts. + try { + if (fs.existsSync(path)) + await fs.promises.rename(path, name); + } catch (e) { + logUnhandledError(e); + } + } + }); }); } @@ -202,7 +218,7 @@ export class Context { return this._browserContextPromise; } - private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise }> { + private async _setupBrowserContext(): Promise { if (this._closeBrowserContextPromise) throw new Error('Another browser context is being closed.'); // TODO: move to the browser context factory to make it based on isolation mode. diff --git a/packages/playwright/src/mcp/browser/tools/pdf.ts b/packages/playwright/src/mcp/browser/tools/pdf.ts index 91d142dcefcbc..a58035e7a2c3d 100644 --- a/packages/playwright/src/mcp/browser/tools/pdf.ts +++ b/packages/playwright/src/mcp/browser/tools/pdf.ts @@ -35,7 +35,7 @@ const pdf = defineTabTool({ }, handle: async (tab, params, response) => { - const fileName = await tab.context.outputFile(params.filename ?? `page-${dateAsFileName()}.pdf`, { origin: 'llm' }); + const fileName = await tab.context.outputFile(params.filename ?? dateAsFileName('pdf'), { origin: 'llm' }); response.addCode(`await page.pdf(${javascript.formatObject({ path: fileName })});`); response.addResult(`Saved page as ${fileName}`); await tab.page.pdf({ path: fileName }); diff --git a/packages/playwright/src/mcp/browser/tools/screenshot.ts b/packages/playwright/src/mcp/browser/tools/screenshot.ts index c3d0c383be4e1..a3df61000beb5 100644 --- a/packages/playwright/src/mcp/browser/tools/screenshot.ts +++ b/packages/playwright/src/mcp/browser/tools/screenshot.ts @@ -51,7 +51,7 @@ const screenshot = defineTabTool({ handle: async (tab, params, response) => { const fileType = params.type || 'png'; - const fileName = await tab.context.outputFile(params.filename ?? `page-${dateAsFileName()}.${fileType}`, { origin: 'llm' }); + const fileName = await tab.context.outputFile(params.filename ?? dateAsFileName(fileType), { origin: 'llm' }); const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 90, diff --git a/packages/playwright/src/mcp/browser/tools/utils.ts b/packages/playwright/src/mcp/browser/tools/utils.ts index 8f5b3849247db..6adae9428e8e4 100644 --- a/packages/playwright/src/mcp/browser/tools/utils.ts +++ b/packages/playwright/src/mcp/browser/tools/utils.ts @@ -83,7 +83,7 @@ export async function callOnPageNoTrace(page: playwright.Page, callback: (pag return await (page as any)._wrapApiCall(() => callback(page), { internal: true }); } -export function dateAsFileName(): string { +export function dateAsFileName(extension: string): string { const date = new Date(); - return date.toISOString().replace(/[:.]/g, '-'); + return `page-${date.toISOString().replace(/[:.]/g, '-')}.${extension}`; } diff --git a/packages/playwright/src/mcp/config.d.ts b/packages/playwright/src/mcp/config.d.ts index 66b015ce7f71a..12ec2323bd85e 100644 --- a/packages/playwright/src/mcp/config.d.ts +++ b/packages/playwright/src/mcp/config.d.ts @@ -106,6 +106,14 @@ export type Config = { */ saveTrace?: boolean; + /** + * If specified, saves the Playwright video of the session into the output directory. + */ + saveVideo?: { + width: number; + height: number; + }; + /** * Reuse the same browser context between all connected HTTP clients. */ diff --git a/packages/playwright/src/mcp/program.ts b/packages/playwright/src/mcp/program.ts index 1af6c0e587ac2..55cf76370c21f 100644 --- a/packages/playwright/src/mcp/program.ts +++ b/packages/playwright/src/mcp/program.ts @@ -16,7 +16,7 @@ import { ProgramOption } from 'playwright-core/lib/utilsBundle'; import * as mcpServer from './sdk/server'; -import { commaSeparatedList, dotenvFileLoader, headerParser, numberParser, resolveCLIConfig, semicolonSeparatedList } from './browser/config'; +import { commaSeparatedList, dotenvFileLoader, headerParser, numberParser, resolutionParser, resolveCLIConfig, semicolonSeparatedList } from './browser/config'; import { setupExitWatchdog } from './browser/watchdog'; import { contextFactory } from './browser/browserContextFactory'; import { ProxyBackend } from './sdk/proxyBackend'; @@ -53,6 +53,7 @@ export function decorateCommand(command: Command, version: string) { .option('--proxy-server ', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"') .option('--save-session', 'Whether to save the Playwright MCP session into the output directory.') .option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.') + .option('--save-video ', 'Whether to save the video of the session into the output directory. For example "--save-video=800x600"', resolutionParser.bind(null, '--save-video')) .option('--secrets ', 'path to a file containing secrets in the dotenv format', dotenvFileLoader) .option('--shared-browser-context', 'reuse the same browser context between all connected HTTP clients.') .option('--storage-state ', 'path to the storage state file for isolated sessions.') @@ -60,7 +61,7 @@ export function decorateCommand(command: Command, version: string) { .option('--timeout-navigation ', 'specify navigation timeout in milliseconds, defaults to 60000ms', numberParser) .option('--user-agent ', 'specify user agent string') .option('--user-data-dir ', 'path to the user data directory. If not specified, a temporary directory will be created.') - .option('--viewport-size ', 'specify browser viewport size in pixels, for example "1280, 720"') + .option('--viewport-size ', 'specify browser viewport size in pixels, for example "1280x720"', resolutionParser.bind(null, '--viewport-size')) .addOption(new ProgramOption('--connect-tool', 'Allow to switch between different browser connection methods.').hideHelp()) .addOption(new ProgramOption('--vscode', 'VS Code tools.').hideHelp()) .addOption(new ProgramOption('--vision', 'Legacy option, use --caps=vision instead').hideHelp()) diff --git a/tests/mcp/fixtures.ts b/tests/mcp/fixtures.ts index f08d23b086f89..26f239f6be633 100644 --- a/tests/mcp/fixtures.ts +++ b/tests/mcp/fixtures.ts @@ -117,7 +117,7 @@ export const test = serverTest.extend { - if (process.env.PWMCP_DEBUG) + if (process.env.PWDEBUGIMPL) process.stderr.write(data); stderrBuffer += data.toString(); }); diff --git a/tests/mcp/video.spec.ts b/tests/mcp/video.spec.ts new file mode 100644 index 0000000000000..13c5f1898fbab --- /dev/null +++ b/tests/mcp/video.spec.ts @@ -0,0 +1,48 @@ +/** + * 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 fs from 'fs'; +import { test, expect } from './fixtures'; + +for (const mode of ['isolated', 'persistent']) { + test(`should work with --save-video (${mode})`, async ({ startClient, server }, testInfo) => { + const outputDir = testInfo.outputPath('output'); + + const { client } = await startClient({ + args: [ + '--save-video=800x600', + ...(mode === 'isolated' ? ['--isolated'] : []), + '--output-dir', outputDir, + ], + }); + + expect(await client.callTool({ + name: 'browser_navigate', + arguments: { url: server.HELLO_WORLD }, + })).toHaveResponse({ + code: expect.stringContaining(`page.goto('http://localhost`), + }); + + expect(await client.callTool({ + name: 'browser_close', + })).toHaveResponse({ + code: expect.stringContaining(`page.close()`), + }); + + const [file] = await fs.promises.readdir(outputDir); + expect(file).toMatch(/page-.*.webm/); + }); +}