Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
177b008328 | ||
|
|
9429463951 | ||
|
|
45f493da6c | ||
|
|
9e5ffd2ccf | ||
|
|
1051ea810a | ||
|
|
f20ae22ec6 | ||
|
|
13cd1b4bd9 | ||
|
|
c318f13895 | ||
|
|
1318e39fac | ||
|
|
c2b7fb29de | ||
|
|
aa6ac51f92 | ||
|
|
fea50e6840 |
@@ -137,7 +137,10 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|||||||
--ignore-https-errors ignore https errors
|
--ignore-https-errors ignore https errors
|
||||||
--isolated keep the browser profile in memory, do not save
|
--isolated keep the browser profile in memory, do not save
|
||||||
it to disk.
|
it to disk.
|
||||||
--no-image-responses do not send image responses to the client.
|
--image-responses <mode> whether to send image responses to the client.
|
||||||
|
Can be "allow", "omit", or "auto". Defaults to
|
||||||
|
"auto", which sends images if the client can
|
||||||
|
display them.
|
||||||
--no-sandbox disable the sandbox for all process types that
|
--no-sandbox disable the sandbox for all process types that
|
||||||
are normally sandboxed.
|
are normally sandboxed.
|
||||||
--output-dir <path> path to the directory for output files.
|
--output-dir <path> path to the directory for output files.
|
||||||
@@ -146,6 +149,8 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|||||||
example ".com,chromium.org,.domain.com"
|
example ".com,chromium.org,.domain.com"
|
||||||
--proxy-server <proxy> specify proxy server, for example
|
--proxy-server <proxy> specify proxy server, for example
|
||||||
"http://myproxy:3128" or "socks5://myproxy:8080"
|
"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
|
--storage-state <path> path to the storage state file for isolated
|
||||||
sessions.
|
sessions.
|
||||||
--user-agent <ua string> specify user agent string
|
--user-agent <ua string> specify user agent string
|
||||||
|
|||||||
9
config.d.ts
vendored
9
config.d.ts
vendored
@@ -94,6 +94,11 @@ export type Config = {
|
|||||||
*/
|
*/
|
||||||
vision?: boolean;
|
vision?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to save the Playwright trace of the session into the output directory.
|
||||||
|
*/
|
||||||
|
saveTrace?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The directory to save output files.
|
* The directory to save output files.
|
||||||
*/
|
*/
|
||||||
@@ -112,7 +117,7 @@ export type Config = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Do not send image responses to the client.
|
* Whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.
|
||||||
*/
|
*/
|
||||||
noImageResponses?: boolean;
|
imageResponses?: 'allow' | 'omit' | 'auto';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Generate test for scenario:
|
Use Playwright tools to generate test for scenario:
|
||||||
|
|
||||||
## GitHub PR Checks Navigation Checklist
|
## GitHub PR Checks Navigation Checklist
|
||||||
|
|
||||||
|
|||||||
2
index.js
2
index.js
@@ -15,5 +15,5 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createConnection } from './lib/index';
|
import { createConnection } from './lib/index.js';
|
||||||
export { createConnection };
|
export { createConnection };
|
||||||
|
|||||||
30
package-lock.json
generated
30
package-lock.json
generated
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.25",
|
"version": "0.0.27",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.25",
|
"version": "0.0.27",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"playwright": "1.53.0-alpha-1746832516000",
|
"playwright": "1.53.0-alpha-2025-05-27",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "1.53.0-alpha-1746832516000",
|
"@playwright/test": "1.53.0-alpha-2025-05-27",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
@@ -286,13 +286,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.53.0-alpha-1746832516000",
|
"version": "1.53.0-alpha-2025-05-27",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-1746832516000.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-2025-05-27.tgz",
|
||||||
"integrity": "sha512-Sec+6uzpA4MfwmQqJFBFVazffynqVwLO5swDxG7WoqgpUdn9gQX4K4tDG64SV6f4nOpwdM5LKTasPSXu02nn/Q==",
|
"integrity": "sha512-G2zG56kEQOWhk3nQyPKH5u41jyQw5jx+Kga5huUi7RjBjPEnNtiCMNXMNGCh6dDYCIyQkLJvz/o1H/QN26HLsg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.53.0-alpha-1746832516000"
|
"playwright": "1.53.0-alpha-2025-05-27"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3298,12 +3298,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.53.0-alpha-1746832516000",
|
"version": "1.53.0-alpha-2025-05-27",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-1746832516000.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-2025-05-27.tgz",
|
||||||
"integrity": "sha512-kcC1B2XJr4VaDAcVzi61SbYGkodq1QIqQXuPieXsNgZZ7cEKWzO2sI42yp2yie6wlCx0oLkSS2Q6jWSRVRLeaw==",
|
"integrity": "sha512-CD0BTwV5javEJ3hf3rhFJEvR3ZoWsu4HUQFfLH2mtVVe+grGPCP55FnlOjpDnJ5pP4Kibe/ZcmgPDg56ic/y9g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.53.0-alpha-1746832516000"
|
"playwright-core": "1.53.0-alpha-2025-05-27"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3316,9 +3316,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.53.0-alpha-1746832516000",
|
"version": "1.53.0-alpha-2025-05-27",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-1746832516000.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-2025-05-27.tgz",
|
||||||
"integrity": "sha512-4O98y4zV0rOP6CepMLC/VGuzqGaR1sS9AVh+i0CghWMQHM/8bxPJI8W38QndO0JU0V5nBD6j7DQeNt1mJ+CZ+g==",
|
"integrity": "sha512-uVxs7YjENoBMFyQhsZWImIBuo/oX7Mu63djhQN3qFz/NdXA/rOAnP73XzfB+VJNwRMKgIOtqHQgjOG3Rl/lm0A==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.25",
|
"version": "0.0.27",
|
||||||
"description": "Playwright Tools for MCP",
|
"description": "Playwright Tools for MCP",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -37,13 +37,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"playwright": "1.53.0-alpha-1746832516000",
|
"playwright": "1.53.0-alpha-2025-05-27",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "1.53.0-alpha-1746832516000",
|
"@playwright/test": "1.53.0-alpha-2025-05-27",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
|
|||||||
@@ -29,7 +29,14 @@ export default defineConfig<TestOptions>({
|
|||||||
{ name: 'chrome' },
|
{ name: 'chrome' },
|
||||||
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
|
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
|
||||||
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||||
...process.env.MCP_IN_DOCKER ? [{ name: 'chromium-docker', use: { mcpBrowser: 'chromium', mcpMode: 'docker' as const } }] : [],
|
...process.env.MCP_IN_DOCKER ? [{
|
||||||
|
name: 'chromium-docker',
|
||||||
|
grep: /browser_navigate|browser_click/,
|
||||||
|
use: {
|
||||||
|
mcpBrowser: 'chromium',
|
||||||
|
mcpMode: 'docker' as const
|
||||||
|
}
|
||||||
|
}] : [],
|
||||||
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||||
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -38,12 +38,13 @@ export type CLIOptions = {
|
|||||||
host?: string;
|
host?: string;
|
||||||
ignoreHttpsErrors?: boolean;
|
ignoreHttpsErrors?: boolean;
|
||||||
isolated?: boolean;
|
isolated?: boolean;
|
||||||
imageResponses: boolean;
|
imageResponses?: 'allow' | 'omit' | 'auto';
|
||||||
sandbox: boolean;
|
sandbox: boolean;
|
||||||
outputDir?: string;
|
outputDir?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
proxyBypass?: string;
|
proxyBypass?: string;
|
||||||
proxyServer?: string;
|
proxyServer?: string;
|
||||||
|
saveTrace?: boolean;
|
||||||
storageState?: string;
|
storageState?: string;
|
||||||
userAgent?: string;
|
userAgent?: string;
|
||||||
userDataDir?: string;
|
userDataDir?: string;
|
||||||
@@ -51,7 +52,7 @@ export type CLIOptions = {
|
|||||||
vision?: boolean;
|
vision?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultConfig: Config = {
|
const defaultConfig: FullConfig = {
|
||||||
browser: {
|
browser: {
|
||||||
browserName: 'chromium',
|
browserName: 'chromium',
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
@@ -67,16 +68,39 @@ const defaultConfig: Config = {
|
|||||||
allowedOrigins: undefined,
|
allowedOrigins: undefined,
|
||||||
blockedOrigins: undefined,
|
blockedOrigins: undefined,
|
||||||
},
|
},
|
||||||
|
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function resolveConfig(cliOptions: CLIOptions): Promise<Config> {
|
type BrowserUserConfig = NonNullable<Config['browser']>;
|
||||||
const config = await loadConfig(cliOptions.config);
|
|
||||||
|
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);
|
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');
|
||||||
|
if (result.browser.browserName === 'chromium')
|
||||||
|
(result.browser.launchOptions as any).cdpPort = await findFreePort();
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
|
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
|
||||||
let browserName: 'chromium' | 'firefox' | 'webkit';
|
let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
|
||||||
let channel: string | undefined;
|
let channel: string | undefined;
|
||||||
switch (cliOptions.browser) {
|
switch (cliOptions.browser) {
|
||||||
case 'chrome':
|
case 'chrome':
|
||||||
@@ -97,9 +121,6 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
case 'webkit':
|
case 'webkit':
|
||||||
browserName = 'webkit';
|
browserName = 'webkit';
|
||||||
break;
|
break;
|
||||||
default:
|
|
||||||
browserName = 'chromium';
|
|
||||||
channel = 'chrome';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Launch options
|
// Launch options
|
||||||
@@ -109,13 +130,9 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
headless: cliOptions.headless,
|
headless: cliOptions.headless,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (browserName === 'chromium') {
|
// --no-sandbox was passed, disable the sandbox
|
||||||
(launchOptions as any).cdpPort = await findFreePort();
|
if (!cliOptions.sandbox)
|
||||||
if (!cliOptions.sandbox) {
|
launchOptions.chromiumSandbox = false;
|
||||||
// --no-sandbox was passed, disable the sandbox
|
|
||||||
launchOptions.chromiumSandbox = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cliOptions.proxyServer) {
|
if (cliOptions.proxyServer) {
|
||||||
launchOptions.proxy = {
|
launchOptions.proxy = {
|
||||||
@@ -169,14 +186,11 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
allowedOrigins: cliOptions.allowedOrigins,
|
allowedOrigins: cliOptions.allowedOrigins,
|
||||||
blockedOrigins: cliOptions.blockedOrigins,
|
blockedOrigins: cliOptions.blockedOrigins,
|
||||||
},
|
},
|
||||||
|
saveTrace: cliOptions.saveTrace,
|
||||||
outputDir: cliOptions.outputDir,
|
outputDir: cliOptions.outputDir,
|
||||||
|
imageResponses: cliOptions.imageResponses,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!cliOptions.imageResponses) {
|
|
||||||
// --no-image-responses was passed, disable image responses
|
|
||||||
result.noImageResponses = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,11 +216,10 @@ async function loadConfig(configFile: string | undefined): Promise<Config> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function outputFile(config: Config, name: string): Promise<string> {
|
export async function outputFile(config: FullConfig, name: string): Promise<string> {
|
||||||
const result = config.outputDir ?? os.tmpdir();
|
await fs.promises.mkdir(config.outputDir, { recursive: true });
|
||||||
await fs.promises.mkdir(result, { recursive: true });
|
|
||||||
const fileName = sanitizeForFilePath(name);
|
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> {
|
function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
||||||
@@ -215,10 +228,10 @@ function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
|||||||
) as Partial<T>;
|
) as Partial<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeConfig(base: Config, overrides: Config): Config {
|
function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
|
||||||
const browser: Config['browser'] = {
|
const browser: FullConfig['browser'] = {
|
||||||
...pickDefined(base.browser),
|
browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
|
||||||
...pickDefined(overrides.browser),
|
isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
...pickDefined(base.browser?.launchOptions),
|
...pickDefined(base.browser?.launchOptions),
|
||||||
...pickDefined(overrides.browser?.launchOptions),
|
...pickDefined(overrides.browser?.launchOptions),
|
||||||
@@ -228,6 +241,9 @@ function mergeConfig(base: Config, overrides: Config): Config {
|
|||||||
...pickDefined(base.browser?.contextOptions),
|
...pickDefined(base.browser?.contextOptions),
|
||||||
...pickDefined(overrides.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)
|
if (browser.browserName !== 'chromium' && browser.launchOptions)
|
||||||
@@ -240,6 +256,6 @@ function mergeConfig(base: Config, overrides: Config): Config {
|
|||||||
network: {
|
network: {
|
||||||
...pickDefined(base.network),
|
...pickDefined(base.network),
|
||||||
...pickDefined(overrides.network),
|
...pickDefined(overrides.network),
|
||||||
},
|
}
|
||||||
};
|
} as FullConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ import { zodToJsonSchema } from 'zod-to-json-schema';
|
|||||||
import { Context, packageJSON } from './context.js';
|
import { Context, packageJSON } from './context.js';
|
||||||
import { snapshotTools, visionTools } from './tools.js';
|
import { snapshotTools, visionTools } from './tools.js';
|
||||||
|
|
||||||
import type { Config } from '../config.js';
|
|
||||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.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 allTools = config.vision ? visionTools : snapshotTools;
|
||||||
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
||||||
|
|
||||||
@@ -92,8 +92,7 @@ export class Connection {
|
|||||||
await new Promise<void>(resolve => {
|
await new Promise<void>(resolve => {
|
||||||
this.server.oninitialized = () => resolve();
|
this.server.oninitialized = () => resolve();
|
||||||
});
|
});
|
||||||
if (this.server.getClientVersion()?.name.includes('cursor'))
|
this.context.clientVersion = this.server.getClientVersion();
|
||||||
this.context.config.noImageResponses = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ import path from 'node:path';
|
|||||||
|
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
|
|
||||||
import { waitForCompletion } from './tools/utils.js';
|
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
||||||
import { ManualPromise } from './manualPromise.js';
|
import { ManualPromise } from './manualPromise.js';
|
||||||
import { Tab } from './tab.js';
|
import { Tab } from './tab.js';
|
||||||
|
import { outputFile } from './config.js';
|
||||||
|
|
||||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
|
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
|
||||||
import type { Config } from '../config.js';
|
import type { FullConfig } from './config.js';
|
||||||
import { outputFile } from './config.js';
|
|
||||||
|
|
||||||
type PendingAction = {
|
type PendingAction = {
|
||||||
dialogShown: ManualPromise<void>;
|
dialogShown: ManualPromise<void>;
|
||||||
@@ -41,19 +41,28 @@ type BrowserContextAndBrowser = {
|
|||||||
|
|
||||||
export class Context {
|
export class Context {
|
||||||
readonly tools: Tool[];
|
readonly tools: Tool[];
|
||||||
readonly config: Config;
|
readonly config: FullConfig;
|
||||||
private _browserContextPromise: Promise<BrowserContextAndBrowser> | undefined;
|
private _browserContextPromise: Promise<BrowserContextAndBrowser> | undefined;
|
||||||
private _tabs: Tab[] = [];
|
private _tabs: Tab[] = [];
|
||||||
private _currentTab: Tab | undefined;
|
private _currentTab: Tab | undefined;
|
||||||
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
||||||
private _pendingAction: PendingAction | undefined;
|
private _pendingAction: PendingAction | undefined;
|
||||||
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
||||||
|
clientVersion: { name: string; version: string; } | undefined;
|
||||||
|
|
||||||
constructor(tools: Tool[], config: Config) {
|
constructor(tools: Tool[], config: FullConfig) {
|
||||||
this.tools = tools;
|
this.tools = tools;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clientSupportsImages(): boolean {
|
||||||
|
if (this.config.imageResponses === 'allow')
|
||||||
|
return true;
|
||||||
|
if (this.config.imageResponses === 'omit')
|
||||||
|
return false;
|
||||||
|
return !this.clientVersion?.name.includes('cursor');
|
||||||
|
}
|
||||||
|
|
||||||
modalStates(): ModalState[] {
|
modalStates(): ModalState[] {
|
||||||
return this._modalStates;
|
return this._modalStates;
|
||||||
}
|
}
|
||||||
@@ -112,7 +121,7 @@ export class Context {
|
|||||||
const lines: string[] = ['### Open tabs'];
|
const lines: string[] = ['### Open tabs'];
|
||||||
for (let i = 0; i < this._tabs.length; i++) {
|
for (let i = 0; i < this._tabs.length; i++) {
|
||||||
const tab = this._tabs[i];
|
const tab = this._tabs[i];
|
||||||
const title = await tab.page.title();
|
const title = await tab.title();
|
||||||
const url = tab.page.url();
|
const url = tab.page.url();
|
||||||
const current = tab === this._currentTab ? ' (current)' : '';
|
const current = tab === this._currentTab ? ' (current)' : '';
|
||||||
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
|
lines.push(`- ${i + 1}:${current} [${title}] (${url})`);
|
||||||
@@ -149,7 +158,7 @@ export class Context {
|
|||||||
let actionResult: { content?: (ImageContent | TextContent)[] } | undefined;
|
let actionResult: { content?: (ImageContent | TextContent)[] } | undefined;
|
||||||
try {
|
try {
|
||||||
if (waitForNetwork)
|
if (waitForNetwork)
|
||||||
actionResult = await waitForCompletion(this, tab.page, async () => racingAction?.()) ?? undefined;
|
actionResult = await waitForCompletion(this, tab, async () => racingAction?.()) ?? undefined;
|
||||||
else
|
else
|
||||||
actionResult = await racingAction?.() ?? undefined;
|
actionResult = await racingAction?.() ?? undefined;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -193,7 +202,7 @@ ${code.join('\n')}
|
|||||||
|
|
||||||
result.push(
|
result.push(
|
||||||
`- Page URL: ${tab.page.url()}`,
|
`- Page URL: ${tab.page.url()}`,
|
||||||
`- Page Title: ${await tab.page.title()}`
|
`- Page Title: ${await tab.title()}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (captureSnapshot && tab.hasSnapshot())
|
if (captureSnapshot && tab.hasSnapshot())
|
||||||
@@ -213,10 +222,14 @@ ${code.join('\n')}
|
|||||||
}
|
}
|
||||||
|
|
||||||
async waitForTimeout(time: number) {
|
async waitForTimeout(time: number) {
|
||||||
if (this._currentTab && !this._javaScriptBlocked())
|
if (!this._currentTab || this._javaScriptBlocked()) {
|
||||||
await this._currentTab.page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
|
||||||
else
|
|
||||||
await new Promise(f => setTimeout(f, time));
|
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> {
|
private async _raceAgainstModalDialogs(action: () => Promise<ToolActionResult>): Promise<ToolActionResult> {
|
||||||
@@ -288,6 +301,8 @@ ${code.join('\n')}
|
|||||||
this._browserContextPromise = undefined;
|
this._browserContextPromise = undefined;
|
||||||
|
|
||||||
await promise.then(async ({ browserContext, browser }) => {
|
await promise.then(async ({ browserContext, browser }) => {
|
||||||
|
if (this.config.saveTrace)
|
||||||
|
await browserContext.tracing.stop();
|
||||||
await browserContext.close().then(async () => {
|
await browserContext.close().then(async () => {
|
||||||
await browser?.close();
|
await browser?.close();
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
@@ -324,6 +339,14 @@ ${code.join('\n')}
|
|||||||
for (const page of browserContext.pages())
|
for (const page of browserContext.pages())
|
||||||
this._onPageCreated(page);
|
this._onPageCreated(page);
|
||||||
browserContext.on('page', page => 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 };
|
return { browser, browserContext };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,12 +374,12 @@ ${code.join('\n')}
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createIsolatedContext(browserConfig: Config['browser']): Promise<BrowserContextAndBrowser> {
|
async function createIsolatedContext(browserConfig: FullConfig['browser']): Promise<BrowserContextAndBrowser> {
|
||||||
try {
|
try {
|
||||||
const browserName = browserConfig?.browserName ?? 'chromium';
|
const browserName = browserConfig?.browserName ?? 'chromium';
|
||||||
const browserType = playwright[browserName];
|
const browserType = playwright[browserName];
|
||||||
const browser = await browserType.launch(browserConfig?.launchOptions);
|
const browser = await browserType.launch(browserConfig.launchOptions);
|
||||||
const browserContext = await browser.newContext(browserConfig?.contextOptions);
|
const browserContext = await browser.newContext(browserConfig.contextOptions);
|
||||||
return { browser, browserContext };
|
return { browser, browserContext };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.message.includes('Executable doesn\'t exist'))
|
if (error.message.includes('Executable doesn\'t exist'))
|
||||||
@@ -365,12 +388,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 {
|
try {
|
||||||
const browserName = browserConfig?.browserName ?? 'chromium';
|
const browserName = browserConfig.browserName ?? 'chromium';
|
||||||
const userDataDir = browserConfig?.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
|
const userDataDir = browserConfig.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
|
||||||
const browserType = playwright[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 };
|
return { browserContext };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.message.includes('Executable doesn\'t exist'))
|
if (error.message.includes('Executable doesn\'t exist'))
|
||||||
@@ -379,7 +402,7 @@ async function launchPersistentContext(browserConfig: Config['browser']): Promis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createUserDataDir(browserConfig: Config['browser']) {
|
async function createUserDataDir(browserConfig: FullConfig['browser']) {
|
||||||
let cacheDirectory: string;
|
let cacheDirectory: string;
|
||||||
if (process.platform === 'linux')
|
if (process.platform === 'linux')
|
||||||
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
||||||
@@ -389,14 +412,10 @@ async function createUserDataDir(browserConfig: Config['browser']) {
|
|||||||
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
||||||
else
|
else
|
||||||
throw new Error('Unsupported platform: ' + process.platform);
|
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 });
|
await fs.promises.mkdir(result, { recursive: true });
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
|
||||||
return (locator as any)._generateLocatorString();
|
|
||||||
}
|
|
||||||
|
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));
|
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 { Connection, createConnection as createConnectionImpl } from './connection.js';
|
||||||
|
import { resolveConfig } from './config.js';
|
||||||
|
|
||||||
import type { Config } 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);
|
return createConnectionImpl(config);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
|
import { callOnPageNoTrace } from './tools/utils.js';
|
||||||
|
|
||||||
|
type PageEx = playwright.Page & {
|
||||||
|
_snapshotForAI: () => Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
export class PageSnapshot {
|
export class PageSnapshot {
|
||||||
private _page: playwright.Page;
|
private _page: playwright.Page;
|
||||||
@@ -35,16 +40,16 @@ export class PageSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _build() {
|
private async _build() {
|
||||||
const yamlDocument = await (this._page as any)._snapshotForAI();
|
const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI());
|
||||||
this._text = [
|
this._text = [
|
||||||
`- Page Snapshot`,
|
`- Page Snapshot`,
|
||||||
'```yaml',
|
'```yaml',
|
||||||
yamlDocument.toString({ indentSeq: false }).trim(),
|
snapshot,
|
||||||
'```',
|
'```',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
refLocator(ref: string): playwright.Locator {
|
refLocator(params: { element: string, ref: string }): playwright.Locator {
|
||||||
return this._page.locator(`aria-ref=${ref}`);
|
return this._page.locator(`aria-ref=${params.ref}`).describe(params.element);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
import { program } from 'commander';
|
import { program } from 'commander';
|
||||||
|
|
||||||
import { startHttpTransport, startStdioTransport } from './transport.js';
|
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 type { Connection } from './connection.js';
|
||||||
import { packageJSON } from './context.js';
|
import { packageJSON } from './context.js';
|
||||||
@@ -38,19 +40,20 @@ program
|
|||||||
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
|
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
|
||||||
.option('--ignore-https-errors', 'ignore https errors')
|
.option('--ignore-https-errors', 'ignore https errors')
|
||||||
.option('--isolated', 'keep the browser profile in memory, do not save it to disk.')
|
.option('--isolated', 'keep the browser profile in memory, do not save it to disk.')
|
||||||
.option('--no-image-responses', 'do not send image responses to the client.')
|
.option('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.')
|
||||||
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
|
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
|
||||||
.option('--output-dir <path>', 'path to the directory for output files.')
|
.option('--output-dir <path>', 'path to the directory for output files.')
|
||||||
.option('--port <port>', 'port to listen on for SSE transport.')
|
.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-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('--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('--storage-state <path>', 'path to the storage state file for isolated sessions.')
|
||||||
.option('--user-agent <ua string>', 'specify user agent string')
|
.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('--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('--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)')
|
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
|
||||||
.action(async options => {
|
.action(async options => {
|
||||||
const config = await resolveConfig(options);
|
const config = await resolveCLIConfig(options);
|
||||||
const connectionList: Connection[] = [];
|
const connectionList: Connection[] = [];
|
||||||
setupExitWatchdog(connectionList);
|
setupExitWatchdog(connectionList);
|
||||||
|
|
||||||
@@ -58,6 +61,14 @@ program
|
|||||||
startHttpTransport(config, +options.port, options.host, connectionList);
|
startHttpTransport(config, +options.port, options.host, connectionList);
|
||||||
else
|
else
|
||||||
await startStdioTransport(config, connectionList);
|
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[]) {
|
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 { PageSnapshot } from './pageSnapshot.js';
|
||||||
|
|
||||||
import type { Context } from './context.js';
|
import type { Context } from './context.js';
|
||||||
|
import { callOnPageNoTrace } from './tools/utils.js';
|
||||||
|
|
||||||
export class Tab {
|
export class Tab {
|
||||||
readonly context: Context;
|
readonly context: Context;
|
||||||
@@ -61,10 +62,18 @@ export class Tab {
|
|||||||
this._onPageClose(this);
|
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) {
|
async navigate(url: string) {
|
||||||
this._clearCollectedArtifacts();
|
this._clearCollectedArtifacts();
|
||||||
|
|
||||||
const downloadEvent = this.page.waitForEvent('download').catch(() => {});
|
const downloadEvent = callOnPageNoTrace(this.page, page => page.waitForEvent('download').catch(() => {}));
|
||||||
try {
|
try {
|
||||||
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||||
} catch (_e: unknown) {
|
} catch (_e: unknown) {
|
||||||
@@ -85,7 +94,7 @@ export class Tab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cap load event to 5 seconds, the page is operational at this point.
|
// 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 {
|
hasSnapshot(): boolean {
|
||||||
|
|||||||
@@ -57,14 +57,14 @@ const screenshot = defineTool({
|
|||||||
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
|
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
const locator = params.ref ? snapshot.refLocator(params.ref) : null;
|
const locator = params.ref ? snapshot.refLocator({ element: params.element || '', ref: params.ref }) : null;
|
||||||
|
|
||||||
if (locator)
|
if (locator)
|
||||||
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
|
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
|
||||||
else
|
else
|
||||||
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
|
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
|
||||||
|
|
||||||
const includeBase64 = !context.config.noImageResponses;
|
const includeBase64 = context.clientSupportsImages();
|
||||||
const action = async () => {
|
const action = async () => {
|
||||||
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ const click = defineTool({
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
const locator = tab.snapshotOrDie().refLocator(params.ref);
|
const locator = tab.snapshotOrDie().refLocator(params);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Click ${params.element}`,
|
`// Click ${params.element}`,
|
||||||
@@ -91,8 +91,8 @@ const drag = defineTool({
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
const startLocator = snapshot.refLocator(params.startRef);
|
const startLocator = snapshot.refLocator({ ref: params.startRef, element: params.startElement });
|
||||||
const endLocator = snapshot.refLocator(params.endRef);
|
const endLocator = snapshot.refLocator({ ref: params.endRef, element: params.endElement });
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Drag ${params.startElement} to ${params.endElement}`,
|
`// Drag ${params.startElement} to ${params.endElement}`,
|
||||||
@@ -120,7 +120,7 @@ const hover = defineTool({
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
const locator = snapshot.refLocator(params.ref);
|
const locator = snapshot.refLocator(params);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Hover over ${params.element}`,
|
`// Hover over ${params.element}`,
|
||||||
@@ -154,7 +154,7 @@ const type = defineTool({
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
const locator = snapshot.refLocator(params.ref);
|
const locator = snapshot.refLocator(params);
|
||||||
|
|
||||||
const code: string[] = [];
|
const code: string[] = [];
|
||||||
const steps: (() => Promise<void>)[] = [];
|
const steps: (() => Promise<void>)[] = [];
|
||||||
@@ -200,7 +200,7 @@ const selectOption = defineTool({
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
const locator = snapshot.refLocator(params.ref);
|
const locator = snapshot.refLocator(params);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Select options [${params.values.join(', ')}] in ${params.element}`,
|
`// Select options [${params.values.join(', ')}] in ${params.element}`,
|
||||||
|
|||||||
@@ -16,8 +16,9 @@
|
|||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
import type { Context } from '../context.js';
|
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>();
|
const requests = new Set<playwright.Request>();
|
||||||
let frameNavigated = false;
|
let frameNavigated = false;
|
||||||
let waitCallback: () => void = () => {};
|
let waitCallback: () => void = () => {};
|
||||||
@@ -36,9 +37,7 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
|
|||||||
frameNavigated = true;
|
frameNavigated = true;
|
||||||
dispose();
|
dispose();
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
void frame.waitForLoadState('load').then(() => {
|
void tab.waitForLoadState('load').then(waitCallback);
|
||||||
waitCallback();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTimeout = () => {
|
const onTimeout = () => {
|
||||||
@@ -46,15 +45,15 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
|
|||||||
waitCallback();
|
waitCallback();
|
||||||
};
|
};
|
||||||
|
|
||||||
page.on('request', requestListener);
|
tab.page.on('request', requestListener);
|
||||||
page.on('requestfinished', requestFinishedListener);
|
tab.page.on('requestfinished', requestFinishedListener);
|
||||||
page.on('framenavigated', frameNavigateListener);
|
tab.page.on('framenavigated', frameNavigateListener);
|
||||||
const timeout = setTimeout(onTimeout, 10000);
|
const timeout = setTimeout(onTimeout, 10000);
|
||||||
|
|
||||||
const dispose = () => {
|
const dispose = () => {
|
||||||
page.off('request', requestListener);
|
tab.page.off('request', requestListener);
|
||||||
page.off('requestfinished', requestFinishedListener);
|
tab.page.off('requestfinished', requestFinishedListener);
|
||||||
page.off('framenavigated', frameNavigateListener);
|
tab.page.off('framenavigated', frameNavigateListener);
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -81,3 +80,7 @@ export function sanitizeForFilePath(s: string) {
|
|||||||
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
||||||
return (locator as any)._generateLocatorString();
|
return (locator as any)._generateLocatorString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> {
|
||||||
|
return await (page as any)._wrapApiCall(() => callback(page), { internal: true });
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,16 +24,16 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|||||||
|
|
||||||
import { createConnection } from './connection.js';
|
import { createConnection } from './connection.js';
|
||||||
|
|
||||||
import type { Config } from '../config.js';
|
|
||||||
import type { Connection } from './connection.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);
|
const connection = await createConnection(config);
|
||||||
await connection.connect(new StdioServerTransport());
|
await connection.connect(new StdioServerTransport());
|
||||||
connectionList.push(connection);
|
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') {
|
if (req.method === 'POST') {
|
||||||
const sessionId = url.searchParams.get('sessionId');
|
const sessionId = url.searchParams.get('sessionId');
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -68,7 +68,7 @@ async function handleSSE(config: Config, req: http.IncomingMessage, res: http.Se
|
|||||||
res.end('Method not allowed');
|
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;
|
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
const transport = sessions.get(sessionId);
|
const transport = sessions.get(sessionId);
|
||||||
@@ -104,7 +104,7 @@ async function handleStreamable(config: Config, req: http.IncomingMessage, res:
|
|||||||
res.end('Invalid request');
|
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 sseSessions = new Map<string, SSEServerTransport>();
|
||||||
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
|
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
|
||||||
const httpServer = http.createServer(async (req, res) => {
|
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.',
|
'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(message);
|
console.error(message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,16 +16,21 @@
|
|||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('cdp server', async ({ cdpEndpoint, startClient, server }) => {
|
test('cdp server', async ({ cdpServer, startClient, server }) => {
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
await cdpServer.start();
|
||||||
|
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
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({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
@@ -43,18 +48,17 @@ test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
|||||||
// <internal code to capture accessibility snapshot>
|
// <internal code to capture accessibility snapshot>
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
- Page URL: data:text/html,hello world
|
- Page URL: ${server.HELLO_WORLD}
|
||||||
- Page Title:
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- generic [ref=e1]: hello world
|
- generic [ref=e1]: Hello, world!
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should throw connection error and allow re-connecting', async ({ cdpEndpoint, startClient, server }) => {
|
test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
|
||||||
const port = 3200 + test.info().parallelIndex;
|
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=http://localhost:${port}`] });
|
|
||||||
|
|
||||||
server.setContent('/', `
|
server.setContent('/', `
|
||||||
<title>Title</title>
|
<title>Title</title>
|
||||||
@@ -65,7 +69,7 @@ test('should throw connection error and allow re-connecting', async ({ cdpEndpoi
|
|||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
|
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
|
||||||
await cdpEndpoint(port);
|
await cdpServer.start();
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import fs from 'node:fs';
|
|||||||
import { Config } from '../config.js';
|
import { Config } from '../config.js';
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('config user data dir', async ({ startClient, localOutputPath, server }) => {
|
test('config user data dir', async ({ startClient, server }, testInfo) => {
|
||||||
server.setContent('/', `
|
server.setContent('/', `
|
||||||
<title>Title</title>
|
<title>Title</title>
|
||||||
<body>Hello, world!</body>
|
<body>Hello, world!</body>
|
||||||
@@ -27,10 +27,10 @@ test('config user data dir', async ({ startClient, localOutputPath, server }) =>
|
|||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
browser: {
|
browser: {
|
||||||
userDataDir: localOutputPath('user-data-dir'),
|
userDataDir: testInfo.outputPath('user-data-dir'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const configPath = localOutputPath('config.json');
|
const configPath = testInfo.outputPath('config.json');
|
||||||
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
|
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||||
|
|
||||||
const client = await startClient({ args: ['--config', configPath] });
|
const client = await startClient({ args: ['--config', configPath] });
|
||||||
@@ -42,3 +42,22 @@ test('config user data dir', async ({ startClient, localOutputPath, server }) =>
|
|||||||
const files = await fs.promises.readdir(config.browser!.userDataDir!);
|
const files = await fs.promises.readdir(config.browser!.userDataDir!);
|
||||||
expect(files.length).toBeGreaterThan(0);
|
expect(files.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe(() => {
|
||||||
|
test.use({ mcpBrowser: '' });
|
||||||
|
test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient }, testInfo) => {
|
||||||
|
const config: Config = {
|
||||||
|
browser: {
|
||||||
|
browserName: 'firefox',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const configPath = testInfo.outputPath('config.json');
|
||||||
|
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||||
|
|
||||||
|
const client = await startClient({ args: ['--config', configPath] });
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: 'data:text/html,<script>document.title = navigator.userAgent</script>' },
|
||||||
|
})).toContainTextContent(`Firefox`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -16,9 +16,8 @@
|
|||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
test('browser_file_upload', async ({ client, localOutputPath, server }) => {
|
test('browser_file_upload', async ({ client, server }, testInfo) => {
|
||||||
server.setContent('/', `
|
server.setContent('/', `
|
||||||
<input type="file" />
|
<input type="file" />
|
||||||
<button>Button</button>
|
<button>Button</button>
|
||||||
@@ -54,7 +53,7 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
})).toContainTextContent(`### Modal state
|
})).toContainTextContent(`### Modal state
|
||||||
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
||||||
|
|
||||||
const filePath = localOutputPath('test.txt');
|
const filePath = testInfo.outputPath('test.txt');
|
||||||
await fs.writeFile(filePath, 'Hello, world!');
|
await fs.writeFile(filePath, 'Hello, world!');
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -101,10 +100,9 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking on download link emits download', async ({ startClient, localOutputPath, server }) => {
|
test('clicking on download link emits download', async ({ startClient, server }, testInfo) => {
|
||||||
const outputDir = localOutputPath('output');
|
|
||||||
const client = await startClient({
|
const client = await startClient({
|
||||||
config: { outputDir },
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
});
|
});
|
||||||
|
|
||||||
server.setContent('/', `<a href="/download" download="test.txt">Download</a>`, 'text/html');
|
server.setContent('/', `<a href="/download" download="test.txt">Download</a>`, 'text/html');
|
||||||
@@ -123,10 +121,14 @@ test('clicking on download link emits download', async ({ startClient, localOutp
|
|||||||
});
|
});
|
||||||
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`
|
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`
|
||||||
### Downloads
|
### Downloads
|
||||||
- Downloaded file test.txt to ${path.join(outputDir, 'test.txt')}`);
|
- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('navigating to download link emits download', async ({ client, server, mcpBrowser }) => {
|
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser }, testInfo) => {
|
||||||
|
const client = await startClient({
|
||||||
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
|
});
|
||||||
|
|
||||||
test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436');
|
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) => {
|
server.route('/download', (req, res) => {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
|
|||||||
@@ -22,26 +22,30 @@ import { chromium } from 'playwright';
|
|||||||
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
|
|
||||||
import { TestServer } from './testserver/index.ts';
|
import { TestServer } from './testserver/index.ts';
|
||||||
|
|
||||||
import type { Config } from '../config';
|
import type { Config } from '../config';
|
||||||
|
import type { BrowserContext } from 'playwright';
|
||||||
|
|
||||||
export type TestOptions = {
|
export type TestOptions = {
|
||||||
mcpBrowser: string | undefined;
|
mcpBrowser: string | undefined;
|
||||||
mcpMode: 'docker' | undefined;
|
mcpMode: 'docker' | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CDPServer = {
|
||||||
|
endpoint: string;
|
||||||
|
start: () => Promise<BrowserContext>;
|
||||||
|
};
|
||||||
|
|
||||||
type TestFixtures = {
|
type TestFixtures = {
|
||||||
client: Client;
|
client: Client;
|
||||||
visionClient: Client;
|
visionClient: Client;
|
||||||
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<Client>;
|
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<Client>;
|
||||||
wsEndpoint: string;
|
wsEndpoint: string;
|
||||||
cdpEndpoint: (port?: number) => Promise<string>;
|
cdpServer: CDPServer;
|
||||||
server: TestServer;
|
server: TestServer;
|
||||||
httpsServer: TestServer;
|
httpsServer: TestServer;
|
||||||
mcpHeadless: boolean;
|
mcpHeadless: boolean;
|
||||||
localOutputPath: (filePath: string) => string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorkerFixtures = {
|
type WorkerFixtures = {
|
||||||
@@ -95,39 +99,25 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
await browserServer.close();
|
await browserServer.close();
|
||||||
},
|
},
|
||||||
|
|
||||||
cdpEndpoint: async ({ }, use, testInfo) => {
|
cdpServer: async ({ mcpBrowser }, use, testInfo) => {
|
||||||
let browserProcess: ChildProcessWithoutNullStreams | undefined;
|
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser!), 'CDP is not supported for non-Chromium browsers');
|
||||||
|
|
||||||
await use(async port => {
|
let browserContext: BrowserContext | undefined;
|
||||||
if (!port)
|
const port = 3200 + test.info().parallelIndex;
|
||||||
port = 3200 + test.info().parallelIndex;
|
await use({
|
||||||
if (browserProcess)
|
endpoint: `http://localhost:${port}`,
|
||||||
return `http://localhost:${port}`;
|
start: async () => {
|
||||||
browserProcess = spawn(chromium.executablePath(), [
|
browserContext = await chromium.launchPersistentContext(testInfo.outputPath('cdp-user-data-dir'), {
|
||||||
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
|
channel: mcpBrowser,
|
||||||
`--remote-debugging-port=${port}`,
|
headless: true,
|
||||||
`--no-first-run`,
|
args: [
|
||||||
`--no-sandbox`,
|
`--remote-debugging-port=${port}`,
|
||||||
`--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();
|
|
||||||
});
|
});
|
||||||
});
|
return browserContext;
|
||||||
return `http://localhost:${port}`;
|
}
|
||||||
});
|
|
||||||
await new Promise<void>(resolve => {
|
|
||||||
if (!browserProcess)
|
|
||||||
return resolve();
|
|
||||||
browserProcess.on('exit', () => resolve());
|
|
||||||
browserProcess.kill();
|
|
||||||
});
|
});
|
||||||
|
await browserContext?.close();
|
||||||
},
|
},
|
||||||
|
|
||||||
mcpHeadless: async ({ headless }, use) => {
|
mcpHeadless: async ({ headless }, use) => {
|
||||||
@@ -138,13 +128,6 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
|
|
||||||
mcpMode: [undefined, { option: true }],
|
mcpMode: [undefined, { option: true }],
|
||||||
|
|
||||||
localOutputPath: async ({ mcpMode }, use, testInfo) => {
|
|
||||||
await use(filePath => {
|
|
||||||
test.skip(mcpMode === 'docker', 'Mounting files is not supported in docker mode');
|
|
||||||
return testInfo.outputPath(filePath);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
_workerServers: [async ({}, use, workerInfo) => {
|
_workerServers: [async ({}, use, workerInfo) => {
|
||||||
const port = 8907 + workerInfo.workerIndex * 4;
|
const port = 8907 + workerInfo.workerIndex * 4;
|
||||||
const server = await TestServer.create(port);
|
const server = await TestServer.create(port);
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ test('isolated context', async ({ startClient, server }) => {
|
|||||||
expect(response2).toContainTextContent(`Storage: NO`);
|
expect(response2).toContainTextContent(`Storage: NO`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('isolated context with storage state', async ({ startClient, server, localOutputPath }) => {
|
test('isolated context with storage state', async ({ startClient, server }, testInfo) => {
|
||||||
const storageStatePath = localOutputPath('storage-state.json');
|
const storageStatePath = testInfo.outputPath('storage-state.json');
|
||||||
await fs.promises.writeFile(storageStatePath, JSON.stringify({
|
await fs.promises.writeFile(storageStatePath, JSON.stringify({
|
||||||
origins: [
|
origins: [
|
||||||
{
|
{
|
||||||
|
|||||||
28
tests/library.spec.ts
Normal file
28
tests/library.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* 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 { test, expect } from './fixtures.js';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import child_process from 'node:child_process';
|
||||||
|
|
||||||
|
test('library can be used from CommonJS', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/456' } }, async ({}, testInfo) => {
|
||||||
|
const file = testInfo.outputPath('main.cjs');
|
||||||
|
await fs.writeFile(file, `
|
||||||
|
import('@playwright/mcp')
|
||||||
|
.then(playwrightMCP => playwrightMCP.createConnection())
|
||||||
|
.then(() => console.log('OK'));
|
||||||
|
`);
|
||||||
|
expect(child_process.execSync(`node ${file}`, { encoding: 'utf-8' })).toContain('OK');
|
||||||
|
});
|
||||||
@@ -30,7 +30,11 @@ test('save as pdf unavailable', async ({ startClient, server }) => {
|
|||||||
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
|
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('save as pdf', async ({ client, mcpBrowser, server }) => {
|
test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
|
||||||
|
const client = await startClient({
|
||||||
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
|
});
|
||||||
|
|
||||||
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
|
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
@@ -44,9 +48,9 @@ test('save as pdf', async ({ client, mcpBrowser, server }) => {
|
|||||||
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
|
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server, localOutputPath }) => {
|
test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server }, testInfo) => {
|
||||||
|
const outputDir = testInfo.outputPath('output');
|
||||||
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
|
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
|
||||||
const outputDir = localOutputPath('output');
|
|
||||||
const client = await startClient({
|
const client = await startClient({
|
||||||
config: { outputDir },
|
config: { outputDir },
|
||||||
});
|
});
|
||||||
@@ -73,6 +77,7 @@ test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, ser
|
|||||||
const files = [...fs.readdirSync(outputDir)];
|
const files = [...fs.readdirSync(outputDir)];
|
||||||
|
|
||||||
expect(fs.existsSync(outputDir)).toBeTruthy();
|
expect(fs.existsSync(outputDir)).toBeTruthy();
|
||||||
expect(files).toHaveLength(1);
|
const pdfFiles = files.filter(f => f.endsWith('.pdf'));
|
||||||
expect(files[0]).toMatch(/^output.pdf$/);
|
expect(pdfFiles).toHaveLength(1);
|
||||||
|
expect(pdfFiles[0]).toMatch(/^output.pdf$/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ import fs from 'fs';
|
|||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('browser_take_screenshot (viewport)', async ({ client, server }) => {
|
test('browser_take_screenshot (viewport)', async ({ startClient, server }, testInfo) => {
|
||||||
|
const client = await startClient({
|
||||||
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
@@ -41,7 +44,10 @@ test('browser_take_screenshot (viewport)', async ({ client, server }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_take_screenshot (element)', async ({ client, server }) => {
|
test('browser_take_screenshot (element)', async ({ startClient, server }, testInfo) => {
|
||||||
|
const client = await startClient({
|
||||||
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
@@ -68,10 +74,10 @@ test('browser_take_screenshot (element)', async ({ client, server }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('--output-dir should work', async ({ startClient, localOutputPath, server }) => {
|
test('--output-dir should work', async ({ startClient, server }, testInfo) => {
|
||||||
const outputDir = localOutputPath('output');
|
const outputDir = testInfo.outputPath('output');
|
||||||
const client = await startClient({
|
const client = await startClient({
|
||||||
args: ['--output-dir', outputDir],
|
config: { outputDir },
|
||||||
});
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
@@ -83,13 +89,15 @@ test('--output-dir should work', async ({ startClient, localOutputPath, server }
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(fs.existsSync(outputDir)).toBeTruthy();
|
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]) {
|
for (const raw of [undefined, true]) {
|
||||||
test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, localOutputPath, server }) => {
|
test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, server }, testInfo) => {
|
||||||
|
const outputDir = testInfo.outputPath('output');
|
||||||
const ext = raw ? 'png' : 'jpeg';
|
const ext = raw ? 'png' : 'jpeg';
|
||||||
const outputDir = localOutputPath('output');
|
|
||||||
const client = await startClient({
|
const client = await startClient({
|
||||||
config: { outputDir },
|
config: { outputDir },
|
||||||
});
|
});
|
||||||
@@ -117,7 +125,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(fs.existsSync(outputDir)).toBeTruthy();
|
||||||
expect(files).toHaveLength(1);
|
expect(files).toHaveLength(1);
|
||||||
@@ -128,8 +136,8 @@ for (const raw of [undefined, true]) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, localOutputPath, server }) => {
|
test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, server }, testInfo) => {
|
||||||
const outputDir = localOutputPath('output');
|
const outputDir = testInfo.outputPath('output');
|
||||||
const client = await startClient({
|
const client = await startClient({
|
||||||
config: { outputDir },
|
config: { outputDir },
|
||||||
});
|
});
|
||||||
@@ -157,17 +165,19 @@ 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(fs.existsSync(outputDir)).toBeTruthy();
|
||||||
expect(files).toHaveLength(1);
|
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 (imageResponses=omit)', async ({ startClient, server }, testInfo) => {
|
||||||
|
const outputDir = testInfo.outputPath('output');
|
||||||
const client = await startClient({
|
const client = await startClient({
|
||||||
config: {
|
config: {
|
||||||
noImageResponses: true,
|
outputDir,
|
||||||
|
imageResponses: 'omit',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -192,8 +202,13 @@ test('browser_take_screenshot (noImageResponses)', async ({ startClient, server
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_take_screenshot (cursor)', async ({ startClient, server }) => {
|
test('browser_take_screenshot (cursor)', async ({ startClient, server }, testInfo) => {
|
||||||
const client = await startClient({ clientName: 'cursor:vscode' });
|
const outputDir = testInfo.outputPath('output');
|
||||||
|
|
||||||
|
const client = await startClient({
|
||||||
|
clientName: 'cursor:vscode',
|
||||||
|
config: { outputDir },
|
||||||
|
});
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ const test = baseTest.extend<{ serverEndpoint: string }>({
|
|||||||
serverEndpoint: async ({}, use) => {
|
serverEndpoint: async ({}, use) => {
|
||||||
const cp = spawn('node', [path.join(path.dirname(__filename), '../cli.js'), '--port', '0'], { stdio: 'pipe' });
|
const cp = spawn('node', [path.join(path.dirname(__filename), '../cli.js'), '--port', '0'], { stdio: 'pipe' });
|
||||||
try {
|
try {
|
||||||
let stdout = '';
|
let stderr = '';
|
||||||
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
|
const url = await new Promise<string>(resolve => cp.stderr?.on('data', data => {
|
||||||
stdout += data.toString();
|
stderr += data.toString();
|
||||||
const match = stdout.match(/Listening on (http:\/\/.*)/);
|
const match = stderr.match(/Listening on (http:\/\/.*)/);
|
||||||
if (match)
|
if (match)
|
||||||
resolve(match[1]);
|
resolve(match[1]);
|
||||||
}));
|
}));
|
||||||
@@ -65,11 +65,17 @@ test('streamable http transport', async ({ serverEndpoint }) => {
|
|||||||
expect(transport.sessionId, 'has session support').toBeDefined();
|
expect(transport.sessionId, 'has session support').toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sse transport via public API', async ({ server }) => {
|
test('sse transport via public API', async ({ server }, testInfo) => {
|
||||||
|
const userDataDir = testInfo.outputPath('user-data-dir');
|
||||||
const sessions = new Map<string, SSEServerTransport>();
|
const sessions = new Map<string, SSEServerTransport>();
|
||||||
const mcpServer = http.createServer(async (req, res) => {
|
const mcpServer = http.createServer(async (req, res) => {
|
||||||
if (req.method === 'GET') {
|
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);
|
const transport = new SSEServerTransport('/sse', res);
|
||||||
sessions.set(transport.sessionId, transport);
|
sessions.set(transport.sessionId, transport);
|
||||||
await connection.connect(transport);
|
await connection.connect(transport);
|
||||||
|
|||||||
@@ -14,8 +14,6 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { chromium } from 'playwright';
|
|
||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.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 }) => {
|
test('reuse first tab when navigating', async ({ startClient, cdpServer, server }) => {
|
||||||
server.setContent('/', `<title>Title</title><body>Body</body>`, 'text/html');
|
const browserContext = await cdpServer.start();
|
||||||
|
const pages = browserContext.pages();
|
||||||
|
|
||||||
const browser = await chromium.connectOverCDP(await cdpEndpoint());
|
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||||
const [context] = browser.contexts();
|
|
||||||
const pages = context.pages();
|
|
||||||
|
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(pages.length).toBe(1);
|
expect(pages.length).toBe(1);
|
||||||
|
|||||||
35
tests/trace.spec.ts
Normal file
35
tests/trace.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 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 }, testInfo) => {
|
||||||
|
const outputDir = testInfo.outputPath('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