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
32 changes: 21 additions & 11 deletions packages/playwright/src/mcp/browser/browserContextFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -42,8 +42,13 @@ export function contextFactory(config: FullConfig): BrowserContextFactory {
return new PersistentContextFactory(config);
}

export type BrowserContextFactoryResult = {
browserContext: playwright.BrowserContext;
close: (afterClose: () => Promise<void>) => Promise<void>;
};

export interface BrowserContextFactory {
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
createContext(clientInfo: ClientInfo, abortSignal: AbortSignal, toolName: string | undefined): Promise<BrowserContextFactoryResult>;
}

class BaseContextFactory implements BrowserContextFactory {
Expand Down Expand Up @@ -75,23 +80,27 @@ class BaseContextFactory implements BrowserContextFactory {
throw new Error('Not implemented');
}

async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
async createContext(clientInfo: ClientInfo): Promise<BrowserContextFactoryResult> {
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<void>) => this._closeBrowserContext(browserContext, browser, afterClose)
};
}

protected async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
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<void>) {
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);
Expand Down Expand Up @@ -170,7 +179,7 @@ class PersistentContextFactory implements BrowserContextFactory {
this.config = config;
}

async createContext(clientInfo: ClientInfo): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
async createContext(clientInfo: ClientInfo): Promise<BrowserContextFactoryResult> {
await injectCdpPort(this.config.browser);
testDebug('create browser context (persistent)');
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo);
Expand All @@ -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,
Expand All @@ -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<void>) => this._closeBrowserContext(browserContext, userDataDir, afterClose);
return { browserContext, close };
} catch (error: any) {
if (error.message.includes('Executable doesn\'t exist'))
Expand All @@ -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<void>) {
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)');
}
Expand Down Expand Up @@ -270,7 +280,7 @@ async function addInitScript(browserContext: playwright.BrowserContext, initScri
}

export class SharedContextFactory implements BrowserContextFactory {
private _contextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
private _contextPromise: Promise<BrowserContextFactoryResult> | undefined;
private _baseFactory: BrowserContextFactory;
private static _instance: SharedContextFactory | undefined;

Expand Down Expand Up @@ -313,6 +323,6 @@ export class SharedContextFactory implements BrowserContextFactory {
if (!contextPromise)
return;
const { close } = await contextPromise;
await close();
await close(async () => {});
}
}
74 changes: 55 additions & 19 deletions packages/playwright/src/mcp/browser/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -53,14 +55,15 @@ export type CLIOptions = {
proxyServer?: string;
saveSession?: boolean;
saveTrace?: boolean;
saveVideo?: ViewportSize;
secrets?: Record<string, string>;
sharedBrowserContext?: boolean;
storageState?: string;
timeoutAction?: number;
timeoutNavigation?: number;
userAgent?: string;
userDataDir?: string;
viewportSize?: string;
viewportSize?: ViewportSize;
};

export const defaultConfig: FullConfig = {
Expand Down Expand Up @@ -127,6 +130,8 @@ async function validateConfig(config: FullConfig): Promise<void> {
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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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);
}

Expand All @@ -287,27 +294,35 @@ async function loadConfig(configFile: string | undefined): Promise<Config> {
}
}

export async function outputFile(config: FullConfig, clientInfo: ClientInfo, fileName: string, options: { origin: 'code' | 'llm' | 'web' }): Promise<string> {
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<string> {
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<T extends object>(obj: T | undefined): Partial<T> {
Expand Down Expand Up @@ -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<string, string>): Record<string, string> {
if (!arg)
return previous || {};
Expand Down
24 changes: 20 additions & 4 deletions packages/playwright/src/mcp/browser/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<void> }> | undefined;
private _browserContextPromise: Promise<BrowserContextFactoryResult> | undefined;
private _browserContextFactory: BrowserContextFactory;
private _tabs: Tab[] = [];
private _currentTab: Tab | undefined;
Expand Down Expand Up @@ -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);
}
}
});
});
}

Expand Down Expand Up @@ -202,7 +218,7 @@ export class Context {
return this._browserContextPromise;
}

private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
private async _setupBrowserContext(): Promise<BrowserContextFactoryResult> {
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.
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright/src/mcp/browser/tools/pdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
2 changes: 1 addition & 1 deletion packages/playwright/src/mcp/browser/tools/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/playwright/src/mcp/browser/tools/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export async function callOnPageNoTrace<T>(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}`;
}
8 changes: 8 additions & 0 deletions packages/playwright/src/mcp/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Loading
Loading