Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c318f13895 | ||
|
|
1318e39fac | ||
|
|
c2b7fb29de | ||
|
|
aa6ac51f92 | ||
|
|
fea50e6840 |
@@ -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
5
config.d.ts
vendored
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.25",
|
||||
"version": "0.0.26",
|
||||
"description": "Playwright Tools for MCP",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
13
src/tab.ts
13
src/tab.ts
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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$/);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
34
tests/trace.spec.ts
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user