5 Commits

Author SHA1 Message Date
Pavel Feldman
c318f13895 chore: mark v0.0.26 (#441) 2025-05-17 08:20:37 -07:00
Pavel Feldman
1318e39fac chore: fix operation over cdp (#440)
Ref https://github.com/microsoft/playwright-mcp/issues/439
2025-05-17 08:20:22 -07:00
Pavel Feldman
c2b7fb29de chore: start trace server (#427) 2025-05-14 20:15:09 -07:00
Pavel Feldman
aa6ac51f92 feat(trace): allow saving trajectory as trace (#426) 2025-05-14 18:08:44 -07:00
Pavel Feldman
fea50e6840 chore: introduce resolved config (#425) 2025-05-14 16:01:08 -07:00
21 changed files with 270 additions and 139 deletions

View File

@@ -146,6 +146,8 @@ Playwright MCP server supports following arguments. They can be provided in the
example ".com,chromium.org,.domain.com"
--proxy-server <proxy> specify proxy server, for example
"http://myproxy:3128" or "socks5://myproxy:8080"
--save-trace Whether to save the Playwright Trace of the
session into the output directory.
--storage-state <path> path to the storage state file for isolated
sessions.
--user-agent <ua string> specify user agent string

5
config.d.ts vendored
View File

@@ -94,6 +94,11 @@ export type Config = {
*/
vision?: boolean;
/**
* Whether to save the Playwright trace of the session into the output directory.
*/
saveTrace?: boolean;
/**
* The directory to save output files.
*/

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@playwright/mcp",
"version": "0.0.25",
"version": "0.0.26",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@playwright/mcp",
"version": "0.0.25",
"version": "0.0.26",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@playwright/mcp",
"version": "0.0.25",
"version": "0.0.26",
"description": "Playwright Tools for MCP",
"type": "module",
"repository": {

View File

@@ -44,6 +44,7 @@ export type CLIOptions = {
port?: number;
proxyBypass?: string;
proxyServer?: string;
saveTrace?: boolean;
storageState?: string;
userAgent?: string;
userDataDir?: string;
@@ -51,7 +52,7 @@ export type CLIOptions = {
vision?: boolean;
};
const defaultConfig: Config = {
const defaultConfig: FullConfig = {
browser: {
browserName: 'chromium',
launchOptions: {
@@ -67,12 +68,33 @@ const defaultConfig: Config = {
allowedOrigins: undefined,
blockedOrigins: undefined,
},
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
};
export async function resolveConfig(cliOptions: CLIOptions): Promise<Config> {
const config = await loadConfig(cliOptions.config);
type BrowserUserConfig = NonNullable<Config['browser']>;
export type FullConfig = Config & {
browser: BrowserUserConfig & {
browserName: NonNullable<BrowserUserConfig['browserName']>;
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
},
network: NonNullable<Config['network']>,
outputDir: string;
};
export async function resolveConfig(config: Config): Promise<FullConfig> {
return mergeConfig(defaultConfig, config);
}
export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConfig> {
const configInFile = await loadConfig(cliOptions.config);
const cliOverrides = await configFromCLIOptions(cliOptions);
return mergeConfig(defaultConfig, mergeConfig(config, cliOverrides));
const result = mergeConfig(mergeConfig(defaultConfig, configInFile), cliOverrides);
// Derive artifact output directory from config.outputDir
if (result.saveTrace)
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
return result;
}
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
@@ -169,6 +191,7 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
allowedOrigins: cliOptions.allowedOrigins,
blockedOrigins: cliOptions.blockedOrigins,
},
saveTrace: cliOptions.saveTrace,
outputDir: cliOptions.outputDir,
};
@@ -202,11 +225,10 @@ async function loadConfig(configFile: string | undefined): Promise<Config> {
}
}
export async function outputFile(config: Config, name: string): Promise<string> {
const result = config.outputDir ?? os.tmpdir();
await fs.promises.mkdir(result, { recursive: true });
export async function outputFile(config: FullConfig, name: string): Promise<string> {
await fs.promises.mkdir(config.outputDir, { recursive: true });
const fileName = sanitizeForFilePath(name);
return path.join(result, fileName);
return path.join(config.outputDir, fileName);
}
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
@@ -215,10 +237,10 @@ function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
) as Partial<T>;
}
function mergeConfig(base: Config, overrides: Config): Config {
const browser: Config['browser'] = {
...pickDefined(base.browser),
...pickDefined(overrides.browser),
function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
const browser: FullConfig['browser'] = {
browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
launchOptions: {
...pickDefined(base.browser?.launchOptions),
...pickDefined(overrides.browser?.launchOptions),
@@ -228,6 +250,9 @@ function mergeConfig(base: Config, overrides: Config): Config {
...pickDefined(base.browser?.contextOptions),
...pickDefined(overrides.browser?.contextOptions),
},
userDataDir: overrides.browser?.userDataDir ?? base.browser?.userDataDir,
cdpEndpoint: overrides.browser?.cdpEndpoint ?? base.browser?.cdpEndpoint,
remoteEndpoint: overrides.browser?.remoteEndpoint ?? base.browser?.remoteEndpoint,
};
if (browser.browserName !== 'chromium' && browser.launchOptions)
@@ -240,6 +265,6 @@ function mergeConfig(base: Config, overrides: Config): Config {
network: {
...pickDefined(base.network),
...pickDefined(overrides.network),
},
};
}
} as FullConfig;
}

View File

@@ -21,10 +21,10 @@ import { zodToJsonSchema } from 'zod-to-json-schema';
import { Context, packageJSON } from './context.js';
import { snapshotTools, visionTools } from './tools.js';
import type { Config } from '../config.js';
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
import { FullConfig } from './config.js';
export async function createConnection(config: Config): Promise<Connection> {
export async function createConnection(config: FullConfig): Promise<Connection> {
const allTools = config.vision ? visionTools : snapshotTools;
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));

View File

@@ -21,14 +21,14 @@ import path from 'node:path';
import * as playwright from 'playwright';
import { waitForCompletion } from './tools/utils.js';
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
import { ManualPromise } from './manualPromise.js';
import { Tab } from './tab.js';
import { outputFile } from './config.js';
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
import type { Config } from '../config.js';
import { outputFile } from './config.js';
import type { FullConfig } from './config.js';
type PendingAction = {
dialogShown: ManualPromise<void>;
@@ -41,7 +41,7 @@ type BrowserContextAndBrowser = {
export class Context {
readonly tools: Tool[];
readonly config: Config;
readonly config: FullConfig;
private _browserContextPromise: Promise<BrowserContextAndBrowser> | undefined;
private _tabs: Tab[] = [];
private _currentTab: Tab | undefined;
@@ -49,7 +49,7 @@ export class Context {
private _pendingAction: PendingAction | undefined;
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
constructor(tools: Tool[], config: Config) {
constructor(tools: Tool[], config: FullConfig) {
this.tools = tools;
this.config = config;
}
@@ -112,7 +112,7 @@ export class Context {
const lines: string[] = ['### Open tabs'];
for (let i = 0; i < this._tabs.length; i++) {
const tab = this._tabs[i];
const title = await tab.page.title();
const title = await tab.title();
const url = tab.page.url();
const current = tab === this._currentTab ? ' (current)' : '';
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
@@ -149,7 +149,7 @@ export class Context {
let actionResult: { content?: (ImageContent | TextContent)[] } | undefined;
try {
if (waitForNetwork)
actionResult = await waitForCompletion(this, tab.page, async () => racingAction?.()) ?? undefined;
actionResult = await waitForCompletion(this, tab, async () => racingAction?.()) ?? undefined;
else
actionResult = await racingAction?.() ?? undefined;
} finally {
@@ -193,7 +193,7 @@ ${code.join('\n')}
result.push(
`- Page URL: ${tab.page.url()}`,
`- Page Title: ${await tab.page.title()}`
`- Page Title: ${await tab.title()}`
);
if (captureSnapshot && tab.hasSnapshot())
@@ -213,10 +213,14 @@ ${code.join('\n')}
}
async waitForTimeout(time: number) {
if (this._currentTab && !this._javaScriptBlocked())
await this._currentTab.page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
else
if (!this._currentTab || this._javaScriptBlocked()) {
await new Promise(f => setTimeout(f, time));
return;
}
await callOnPageNoTrace(this._currentTab.page, page => {
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
});
}
private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
@@ -288,6 +292,8 @@ ${code.join('\n')}
this._browserContextPromise = undefined;
await promise.then(async ({ browserContext, browser }) => {
if (this.config.saveTrace)
await browserContext.tracing.stop();
await browserContext.close().then(async () => {
await browser?.close();
}).catch(() => {});
@@ -324,6 +330,14 @@ ${code.join('\n')}
for (const page of browserContext.pages())
this._onPageCreated(page);
browserContext.on('page', page => this._onPageCreated(page));
if (this.config.saveTrace) {
await browserContext.tracing.start({
name: 'trace',
screenshots: false,
snapshots: true,
sources: false,
});
}
return { browser, browserContext };
}
@@ -351,12 +365,12 @@ ${code.join('\n')}
}
}
async function createIsolatedContext(browserConfig: Config['browser']): Promise<BrowserContextAndBrowser> {
async function createIsolatedContext(browserConfig: FullConfig['browser']): Promise<BrowserContextAndBrowser> {
try {
const browserName = browserConfig?.browserName ?? 'chromium';
const browserType = playwright[browserName];
const browser = await browserType.launch(browserConfig?.launchOptions);
const browserContext = await browser.newContext(browserConfig?.contextOptions);
const browser = await browserType.launch(browserConfig.launchOptions);
const browserContext = await browser.newContext(browserConfig.contextOptions);
return { browser, browserContext };
} catch (error: any) {
if (error.message.includes('Executable doesn\'t exist'))
@@ -365,12 +379,12 @@ async function createIsolatedContext(browserConfig: Config['browser']): Promise<
}
}
async function launchPersistentContext(browserConfig: Config['browser']): Promise<BrowserContextAndBrowser> {
async function launchPersistentContext(browserConfig: FullConfig['browser']): Promise<BrowserContextAndBrowser> {
try {
const browserName = browserConfig?.browserName ?? 'chromium';
const userDataDir = browserConfig?.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
const browserName = browserConfig.browserName ?? 'chromium';
const userDataDir = browserConfig.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
const browserType = playwright[browserName];
const browserContext = await browserType.launchPersistentContext(userDataDir, { ...browserConfig?.launchOptions, ...browserConfig?.contextOptions });
const browserContext = await browserType.launchPersistentContext(userDataDir, { ...browserConfig.launchOptions, ...browserConfig.contextOptions });
return { browserContext };
} catch (error: any) {
if (error.message.includes('Executable doesn\'t exist'))
@@ -379,7 +393,7 @@ async function launchPersistentContext(browserConfig: Config['browser']): Promis
}
}
async function createUserDataDir(browserConfig: Config['browser']) {
async function createUserDataDir(browserConfig: FullConfig['browser']) {
let cacheDirectory: string;
if (process.platform === 'linux')
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
@@ -389,14 +403,10 @@ async function createUserDataDir(browserConfig: Config['browser']) {
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
else
throw new Error('Unsupported platform: ' + process.platform);
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserConfig?.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
await fs.promises.mkdir(result, { recursive: true });
return result;
}
export async function generateLocator(locator: playwright.Locator): Promise<string> {
return (locator as any)._generateLocatorString();
}
const __filename = url.fileURLToPath(import.meta.url);
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));

View File

@@ -15,9 +15,11 @@
*/
import { Connection, createConnection as createConnectionImpl } from './connection.js';
import { resolveConfig } from './config.js';
import type { Config } from '../config.js';
export async function createConnection(config: Config = {}): Promise<Connection> {
export async function createConnection(userConfig: Config = {}): Promise<Connection> {
const config = await resolveConfig(userConfig);
return createConnectionImpl(config);
}

View File

@@ -15,6 +15,11 @@
*/
import * as playwright from 'playwright';
import { callOnPageNoTrace } from './tools/utils.js';
type PageEx = playwright.Page & {
_snapshotForAI: () => Promise<string>;
};
export class PageSnapshot {
private _page: playwright.Page;
@@ -35,11 +40,14 @@ export class PageSnapshot {
}
private async _build() {
const yamlDocument = await (this._page as any)._snapshotForAI();
// FIXME: Rountrip evaluate to ensure _snapshotForAI works.
// This probably broke once we moved off locator snapshots
await this._page.evaluate(() => 1);
const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI());
this._text = [
`- Page Snapshot`,
'```yaml',
yamlDocument.toString({ indentSeq: false }).trim(),
snapshot,
'```',
].join('\n');
}

View File

@@ -17,7 +17,9 @@
import { program } from 'commander';
import { startHttpTransport, startStdioTransport } from './transport.js';
import { resolveConfig } from './config.js';
import { resolveCLIConfig } from './config.js';
// @ts-ignore
import { startTraceViewerServer } from 'playwright-core/lib/server';
import type { Connection } from './connection.js';
import { packageJSON } from './context.js';
@@ -44,13 +46,14 @@ program
.option('--port <port>', 'port to listen on for SSE transport.')
.option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
.option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
.option('--save-trace', 'Whether to save the Playwright Trace of the session into the output directory.')
.option('--storage-state <path>', 'path to the storage state file for isolated sessions.')
.option('--user-agent <ua string>', 'specify user agent string')
.option('--user-data-dir <path>', 'path to the user data directory. If not specified, a temporary directory will be created.')
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"')
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
.action(async options => {
const config = await resolveConfig(options);
const config = await resolveCLIConfig(options);
const connectionList: Connection[] = [];
setupExitWatchdog(connectionList);
@@ -58,6 +61,14 @@ program
startHttpTransport(config, +options.port, options.host, connectionList);
else
await startStdioTransport(config, connectionList);
if (config.saveTrace) {
const server = await startTraceViewerServer();
const urlPrefix = server.urlPrefix('human-readable');
const url = urlPrefix + '/trace/index.html?trace=' + config.browser.launchOptions.tracesDir + '/trace.json';
// eslint-disable-next-line no-console
console.error('\nTrace viewer listening on ' + url);
}
});
function setupExitWatchdog(connectionList: Connection[]) {

View File

@@ -19,6 +19,7 @@ import * as playwright from 'playwright';
import { PageSnapshot } from './pageSnapshot.js';
import type { Context } from './context.js';
import { callOnPageNoTrace } from './tools/utils.js';
export class Tab {
readonly context: Context;
@@ -61,10 +62,18 @@ export class Tab {
this._onPageClose(this);
}
async title(): Promise<string> {
return await callOnPageNoTrace(this.page, page => page.title());
}
async waitForLoadState(state: 'load', options?: { timeout?: number }): Promise<void> {
await callOnPageNoTrace(this.page, page => page.waitForLoadState(state, options).catch(() => {}));
}
async navigate(url: string) {
this._clearCollectedArtifacts();
const downloadEvent = this.page.waitForEvent('download').catch(() => {});
const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => {}));
try {
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
} catch (_e: unknown) {
@@ -85,7 +94,7 @@ export class Tab {
}
// Cap load event to 5 seconds, the page is operational at this point.
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
await this.waitForLoadState('load', { timeout: 5000 });
}
hasSnapshot(): boolean {

View File

@@ -16,8 +16,9 @@
import type * as playwright from 'playwright';
import type { Context } from '../context.js';
import type { Tab } from '../tab.js';
export async function waitForCompletion<R>(context: Context, page: playwright.Page, callback: () => Promise<R>): Promise<R> {
export async function waitForCompletion<R>(context: Context, tab: Tab, callback: () => Promise<R>): Promise<R> {
const requests = new Set<playwright.Request>();
let frameNavigated = false;
let waitCallback: () => void = () => {};
@@ -36,9 +37,7 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
frameNavigated = true;
dispose();
clearTimeout(timeout);
void frame.waitForLoadState('load').then(() => {
waitCallback();
});
void tab.waitForLoadState('load').then(waitCallback);
};
const onTimeout = () => {
@@ -46,15 +45,15 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
waitCallback();
};
page.on('request', requestListener);
page.on('requestfinished', requestFinishedListener);
page.on('framenavigated', frameNavigateListener);
tab.page.on('request', requestListener);
tab.page.on('requestfinished', requestFinishedListener);
tab.page.on('framenavigated', frameNavigateListener);
const timeout = setTimeout(onTimeout, 10000);
const dispose = () => {
page.off('request', requestListener);
page.off('requestfinished', requestFinishedListener);
page.off('framenavigated', frameNavigateListener);
tab.page.off('request', requestListener);
tab.page.off('requestfinished', requestFinishedListener);
tab.page.off('framenavigated', frameNavigateListener);
clearTimeout(timeout);
};
@@ -79,5 +78,9 @@ export function sanitizeForFilePath(s: string) {
}
export async function generateLocator(locator: playwright.Locator): Promise<string> {
return (locator as any)._generateLocatorString();
return (locator as any)._frame._wrapApiCall(() => (locator as any)._generateLocatorString(), true);
}
export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> {
return await (page as any)._wrapApiCall(() => callback(page), true);
}

View File

@@ -24,16 +24,16 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { createConnection } from './connection.js';
import type { Config } from '../config.js';
import type { Connection } from './connection.js';
import type { FullConfig } from './config.js';
export async function startStdioTransport(config: Config, connectionList: Connection[]) {
export async function startStdioTransport(config: FullConfig, connectionList: Connection[]) {
const connection = await createConnection(config);
await connection.connect(new StdioServerTransport());
connectionList.push(connection);
}
async function handleSSE(config: Config, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>, connectionList: Connection[]) {
async function handleSSE(config: FullConfig, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>, connectionList: Connection[]) {
if (req.method === 'POST') {
const sessionId = url.searchParams.get('sessionId');
if (!sessionId) {
@@ -68,7 +68,7 @@ async function handleSSE(config: Config, req: http.IncomingMessage, res: http.Se
res.end('Method not allowed');
}
async function handleStreamable(config: Config, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>, connectionList: Connection[]) {
async function handleStreamable(config: FullConfig, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>, connectionList: Connection[]) {
const sessionId = req.headers['mcp-session-id'] as string | undefined;
if (sessionId) {
const transport = sessions.get(sessionId);
@@ -104,7 +104,7 @@ async function handleStreamable(config: Config, req: http.IncomingMessage, res:
res.end('Invalid request');
}
export function startHttpTransport(config: Config, port: number, hostname: string | undefined, connectionList: Connection[]) {
export function startHttpTransport(config: FullConfig, port: number, hostname: string | undefined, connectionList: Connection[]) {
const sseSessions = new Map<string, SSEServerTransport>();
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
const httpServer = http.createServer(async (req, res) => {
@@ -140,6 +140,6 @@ export function startHttpTransport(config: Config, port: number, hostname: strin
'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
].join('\n');
// eslint-disable-next-line no-console
console.log(message);
console.error(message);
});
}

View File

@@ -16,16 +16,21 @@
import { test, expect } from './fixtures.js';
test('cdp server', async ({ cdpEndpoint, startClient, server }) => {
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
test('cdp server', async ({ cdpServer, startClient, server }) => {
await cdpServer.start();
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
});
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
const browserContext = await cdpServer.start();
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
const [page] = browserContext.pages();
await page.goto(server.HELLO_WORLD);
expect(await client.callTool({
name: 'browser_click',
@@ -43,18 +48,17 @@ test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
// <internal code to capture accessibility snapshot>
\`\`\`
- Page URL: data:text/html,hello world
- Page Title:
- Page URL: ${server.HELLO_WORLD}
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- generic [ref=e1]: hello world
- generic [ref=e1]: Hello, world!
\`\`\`
`);
});
test('should throw connection error and allow re-connecting', async ({ cdpEndpoint, startClient, server }) => {
const port = 3200 + test.info().parallelIndex;
const client = await startClient({ args: [`--cdp-endpoint=http://localhost:${port}`] });
test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
server.setContent('/', `
<title>Title</title>
@@ -65,7 +69,7 @@ test('should throw connection error and allow re-connecting', async ({ cdpEndpoi
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
await cdpEndpoint(port);
await cdpServer.start();
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },

View File

@@ -126,7 +126,12 @@ test('clicking on download link emits download', async ({ startClient, localOutp
- Downloaded file test.txt to ${path.join(outputDir, 'test.txt')}`);
});
test('navigating to download link emits download', async ({ client, server, mcpBrowser }) => {
test('navigating to download link emits download', async ({ startClient, localOutputPath, mcpBrowser, server }) => {
const outputDir = localOutputPath('output');
const client = await startClient({
args: ['--output-dir', outputDir],
});
test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436');
server.route('/download', (req, res) => {
res.writeHead(200, {

View File

@@ -22,22 +22,27 @@ import { chromium } from 'playwright';
import { test as baseTest, expect as baseExpect } from '@playwright/test';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
import { TestServer } from './testserver/index.ts';
import type { Config } from '../config';
import type { BrowserContext } from 'playwright';
export type TestOptions = {
mcpBrowser: string | undefined;
mcpMode: 'docker' | undefined;
};
type CDPServer = {
endpoint: string;
start: () => Promise<BrowserContext>;
};
type TestFixtures = {
client: Client;
visionClient: Client;
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<Client>;
wsEndpoint: string;
cdpEndpoint: (port?: number) => Promise<string>;
cdpServer: CDPServer;
server: TestServer;
httpsServer: TestServer;
mcpHeadless: boolean;
@@ -95,39 +100,25 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
await browserServer.close();
},
cdpEndpoint: async ({ }, use, testInfo) => {
let browserProcess: ChildProcessWithoutNullStreams | undefined;
cdpServer: async ({ mcpBrowser }, use, testInfo) => {
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser!), 'CDP is not supported for non-Chromium browsers');
await use(async port => {
if (!port)
port = 3200 + test.info().parallelIndex;
if (browserProcess)
return `http://localhost:${port}`;
browserProcess = spawn(chromium.executablePath(), [
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
`--remote-debugging-port=${port}`,
`--no-first-run`,
`--no-sandbox`,
`--headless`,
'--use-mock-keychain',
`data:text/html,hello world`,
], {
stdio: 'pipe',
});
await new Promise<void>(resolve => {
browserProcess!.stderr.on('data', data => {
if (data.toString().includes('DevTools listening on '))
resolve();
let browserContext: BrowserContext | undefined;
const port = 3200 + test.info().parallelIndex;
await use({
endpoint: `http://localhost:${port}`,
start: async () => {
browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
channel: mcpBrowser,
headless: true,
args: [
`--remote-debugging-port=${port}`,
],
});
});
return `http://localhost:${port}`;
});
await new Promise<void>(resolve => {
if (!browserProcess)
return resolve();
browserProcess.on('exit', () => resolve());
browserProcess.kill();
return browserContext;
}
});
await browserContext?.close();
},
mcpHeadless: async ({ headless }, use) => {

View File

@@ -30,7 +30,12 @@ test('save as pdf unavailable', async ({ startClient, server }) => {
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
});
test('save as pdf', async ({ client, mcpBrowser, server }) => {
test('save as pdf', async ({ startClient, mcpBrowser, server, localOutputPath }) => {
const outputDir = localOutputPath('output');
const client = await startClient({
config: { outputDir },
});
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
expect(await client.callTool({
@@ -73,6 +78,7 @@ test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, ser
const files = [...fs.readdirSync(outputDir)];
expect(fs.existsSync(outputDir)).toBeTruthy();
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/^output.pdf$/);
const pdfFiles = files.filter(f => f.endsWith('.pdf'));
expect(pdfFiles).toHaveLength(1);
expect(pdfFiles[0]).toMatch(/^output.pdf$/);
});

View File

@@ -18,7 +18,11 @@ import fs from 'fs';
import { test, expect } from './fixtures.js';
test('browser_take_screenshot (viewport)', async ({ client, server }) => {
test('browser_take_screenshot (viewport)', async ({ startClient, server, localOutputPath }) => {
const outputDir = localOutputPath('output');
const client = await startClient({
args: ['--output-dir', outputDir],
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
@@ -41,7 +45,11 @@ test('browser_take_screenshot (viewport)', async ({ client, server }) => {
});
});
test('browser_take_screenshot (element)', async ({ client, server }) => {
test('browser_take_screenshot (element)', async ({ startClient, server, localOutputPath }) => {
const outputDir = localOutputPath('output');
const client = await startClient({
args: ['--output-dir', outputDir],
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
@@ -83,7 +91,9 @@ test('--output-dir should work', async ({ startClient, localOutputPath, server }
});
expect(fs.existsSync(outputDir)).toBeTruthy();
expect([...fs.readdirSync(outputDir)]).toHaveLength(1);
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg'));
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/^page-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.jpeg$/);
});
for (const raw of [undefined, true]) {
@@ -117,7 +127,7 @@ for (const raw of [undefined, true]) {
],
});
const files = [...fs.readdirSync(outputDir)];
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith(`.${ext}`));
expect(fs.existsSync(outputDir)).toBeTruthy();
expect(files).toHaveLength(1);
@@ -157,16 +167,17 @@ test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient,
],
});
const files = [...fs.readdirSync(outputDir)];
const files = [...fs.readdirSync(outputDir)].filter(f => f.endsWith('.jpeg'));
expect(fs.existsSync(outputDir)).toBeTruthy();
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/^output.jpeg$/);
expect(files[0]).toMatch(/^output\.jpeg$/);
});
test('browser_take_screenshot (noImageResponses)', async ({ startClient, server }) => {
test('browser_take_screenshot (noImageResponses)', async ({ startClient, server, localOutputPath }) => {
const client = await startClient({
config: {
outputDir: localOutputPath('output'),
noImageResponses: true,
},
});
@@ -192,8 +203,12 @@ test('browser_take_screenshot (noImageResponses)', async ({ startClient, server
});
});
test('browser_take_screenshot (cursor)', async ({ startClient, server }) => {
const client = await startClient({ clientName: 'cursor:vscode' });
test('browser_take_screenshot (cursor)', async ({ startClient, server, localOutputPath }) => {
const outputDir = localOutputPath('output');
const client = await startClient({
clientName: 'cursor:vscode',
config: { outputDir },
});
expect(await client.callTool({
name: 'browser_navigate',

View File

@@ -35,10 +35,10 @@ const test = baseTest.extend<{ serverEndpoint: string }>({
serverEndpoint: async ({}, use) => {
const cp = spawn('node', [path.join(path.dirname(__filename), '../cli.js'), '--port', '0'], { stdio: 'pipe' });
try {
let stdout = '';
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
stdout += data.toString();
const match = stdout.match(/Listening on (http:\/\/.*)/);
let stderr = '';
const url = await new Promise<string>(resolve => cp.stderr?.on('data', data => {
stderr += data.toString();
const match = stderr.match(/Listening on (http:\/\/.*)/);
if (match)
resolve(match[1]);
}));
@@ -65,11 +65,17 @@ test('streamable http transport', async ({ serverEndpoint }) => {
expect(transport.sessionId, 'has session support').toBeDefined();
});
test('sse transport via public API', async ({ server }) => {
test('sse transport via public API', async ({ server, localOutputPath }) => {
const userDataDir = localOutputPath('user-data-dir');
const sessions = new Map<string, SSEServerTransport>();
const mcpServer = http.createServer(async (req, res) => {
if (req.method === 'GET') {
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
const connection = await createConnection({
browser: {
userDataDir,
launchOptions: { headless: true }
},
});
const transport = new SSEServerTransport('/sse', res);
sessions.set(transport.sessionId, transport);
await connection.connect(transport);

View File

@@ -14,8 +14,6 @@
* limitations under the License.
*/
import { chromium } from 'playwright';
import { test, expect } from './fixtures.js';
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
@@ -139,17 +137,14 @@ test('close tab', async ({ client }) => {
\`\`\``);
});
test('reuse first tab when navigating', async ({ startClient, cdpEndpoint, server }) => {
server.setContent('/', `<title>Title</title><body>Body</body>`, 'text/html');
test('reuse first tab when navigating', async ({ startClient, cdpServer, server }) => {
const browserContext = await cdpServer.start();
const pages = browserContext.pages();
const browser = await chromium.connectOverCDP(await cdpEndpoint());
const [context] = browser.contexts();
const pages = context.pages();
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
arguments: { url: server.HELLO_WORLD },
});
expect(pages.length).toBe(1);

34
tests/trace.spec.ts Normal file
View File

@@ -0,0 +1,34 @@
/**
* 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 path from 'path';
import { test, expect } from './fixtures.js';
test('check that trace is saved', async ({ startClient, server, localOutputPath }) => {
const outputDir = localOutputPath('output');
const client = await startClient({
args: ['--save-trace', `--output-dir=${outputDir}`],
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
expect(fs.existsSync(path.join(outputDir, 'traces', 'trace.trace'))).toBeTruthy();
});