10 Commits

Author SHA1 Message Date
Max Schmitt
128e75b9f4 devops: fix npm publishing due to proverance (#112)
Like
[upstream](3ad5c2731a/.github/workflows/publish_release_npm.yml (L15))
and in the
[docs](https://docs.npmjs.com/generating-provenance-statements#example-github-actions-workflow).
2025-04-02 00:37:13 +02:00
Pavel Feldman
2366dbf36c chore: mark v0.0.8 (#111) 2025-04-01 15:16:28 -07:00
Pavel Feldman
0de7c0d38c chore: follow up with iframe stitch (#110) 2025-04-01 15:10:23 -07:00
Simon Knott
0a5518b252 chore: stitch together iframes into one tree (#71) 2025-04-01 14:47:53 -07:00
Pavel Feldman
4f16786432 chore: merge browser and channel settings (#100) 2025-04-01 10:26:48 -07:00
Pavel Feldman
9042c03faa chore: support channel and executable path params (#90)
Fixes https://github.com/microsoft/playwright-mcp/issues/89
2025-03-31 15:30:08 -07:00
Pavel Feldman
d316441142 chore: sanitize file path when saving (#99)
Fixes https://github.com/microsoft/playwright-mcp/issues/96
2025-03-31 15:01:58 -07:00
Yoshiki Nakagawa
aeb4cf65e9 Fixed typo in README.md (#88) 2025-03-31 09:33:38 +01:00
Pavel Feldman
a7392fc266 chore: allow passing cdp endpoint (#86)
Fixes https://github.com/microsoft/playwright-mcp/issues/84
2025-03-30 09:05:58 -07:00
Max Schmitt
88fbf50841 devops: use --provenance when publishing to NPM (#83)
Similar to how we do it upstream:
e2c8163b14/utils/publish_all_packages.sh (L97)

Reference: https://docs.npmjs.com/generating-provenance-statements
2025-03-29 19:17:54 +01:00
12 changed files with 340 additions and 108 deletions

View File

@@ -5,6 +5,9 @@ on:
jobs:
publish-npm:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -15,6 +18,6 @@ jobs:
- run: npm run build
- run: npm run lint
- run: npm run test
- run: npm publish
- run: npm publish --provenance
env:
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}

View File

@@ -59,9 +59,25 @@ code-insiders --add-mcp '{"name":"playwright","command":"npx","args":["@playwrig
After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
### CLI Options
The Playwright MCP server supports the following command-line options:
- `--browser <browser>`: Browser or chrome channel to use. Possible values:
- `chrome`, `firefox`, `webkit`, `msedge`
- Chrome channels: `chrome-beta`, `chrome-canary`, `chrome-dev`
- Edge channels: `msedge-beta`, `msedge-canary`, `msedge-dev`
- Default: `chrome`
- `--cdp-endpoint <endpoint>`: CDP endpoint to connect to
- `--executable-path <path>`: Path to the browser executable
- `--headless`: Run browser in headless mode (headed by default)
- `--port <port>`: Port to listen on for SSE transport
- `--user-data-dir <path>`: Path to the user data directory
- `--vision`: Run server that uses screenshots (Aria snapshots are used by default)
### User data directory
Playwright MCP will launch Chrome browser with the new profile, located at
Playwright MCP will launch the browser with the new profile, located at
```
- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-chrome-profile` on Windows
@@ -69,7 +85,7 @@ Playwright MCP will launch Chrome browser with the new profile, located at
- `~/.cache/ms-playwright/mcp-chrome-profile` on Linux
```
All the logged in information will be stored in that profile, you can delete it between sessions if you'dlike to clear the offline state.
All the logged in information will be stored in that profile, you can delete it between sessions if you'd like to clear the offline state.
### Running headless browser (Browser without GUI).

43
package-lock.json generated
View File

@@ -1,17 +1,18 @@
{
"name": "@playwright/mcp",
"version": "0.0.7",
"version": "0.0.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@playwright/mcp",
"version": "0.0.7",
"version": "0.0.8",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
"commander": "^13.1.0",
"playwright": "1.52.0-alpha-1743011787000",
"playwright": "^1.52.0-alpha-1743163434000",
"yaml": "^2.7.1",
"zod-to-json-schema": "^3.24.4"
},
"bin": {
@@ -20,7 +21,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "1.52.0-alpha-1743011787000",
"@playwright/test": "^1.52.0-alpha-1743163434000",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.26.1",
@@ -285,13 +286,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.52.0-alpha-1743011787000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0-alpha-1743011787000.tgz",
"integrity": "sha512-ikJR8JXof5IBvErrmIsR3ixov4nKlQe/6PSYK/R6eTEe6eoT+eEXlaNY4z6mn9dF02Z1zYGxzAbb8TvSvuwh4Q==",
"version": "1.52.0-alpha-1743163434000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0-alpha-1743163434000.tgz",
"integrity": "sha512-4uBgNlJ6hgPtB8DrwQsgoKuVoe7j+nPqudna7CLXWCmmT3LYPMD5aOjGoBkszr+R9NejtKashq/bOi/ny9hsIA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.52.0-alpha-1743011787000"
"playwright": "1.52.0-alpha-1743163434000"
},
"bin": {
"playwright": "cli.js"
@@ -3296,12 +3297,12 @@
}
},
"node_modules/playwright": {
"version": "1.52.0-alpha-1743011787000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0-alpha-1743011787000.tgz",
"integrity": "sha512-wg9Tu4ZDKJWo7hBKpeuD/XLtLOQ7fCCuBfekgUrPLStA12O3224E1fbp/xGFnmi47SF71Y8F6C2Beyd3gYFWlQ==",
"version": "1.52.0-alpha-1743163434000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0-alpha-1743163434000.tgz",
"integrity": "sha512-4uYv49ekPjolydfFfTfFQ2z4URF9UZMVUXLy7aXam/tPxEQ5O7+jQC+yzrDMGmhcj5QkMnxjlyk7N2V9a0QLdQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.52.0-alpha-1743011787000"
"playwright-core": "1.52.0-alpha-1743163434000"
},
"bin": {
"playwright": "cli.js"
@@ -3314,9 +3315,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.52.0-alpha-1743011787000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0-alpha-1743011787000.tgz",
"integrity": "sha512-yOpMfKxTBRqdm50b52cojvTCNttWN+Xk6LXF+KU4ufcGwcRjUud1xdHmHHvQNFFanXM1MBYnDKsMkRvjPsuYOw==",
"version": "1.52.0-alpha-1743163434000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0-alpha-1743163434000.tgz",
"integrity": "sha512-Tn4u3Ywwjkh847/bYWlXIrNxv5DRJRDgtb+VYMXHvNCKkrxL6yfZ1ApIAYD7IAkkKH/KLTXszGWl3a/Z/KDfQA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@@ -4348,6 +4349,18 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
"integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "@playwright/mcp",
"version": "0.0.7",
"version": "0.0.8",
"description": "Playwright Tools for MCP",
"repository": {
"type": "git",
@@ -32,18 +32,19 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.6.1",
"commander": "^13.1.0",
"playwright": "1.52.0-alpha-1743011787000",
"playwright": "^1.52.0-alpha-1743163434000",
"yaml": "^2.7.1",
"zod-to-json-schema": "^3.24.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "1.52.0-alpha-1743011787000",
"@playwright/test": "^1.52.0-alpha-1743163434000",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
"@typescript-eslint/utils": "^8.26.1",
"@types/node": "^22.13.10",
"eslint": "^9.19.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-notice": "^1.0.0",

View File

@@ -14,21 +14,31 @@
* limitations under the License.
*/
import { fork } from 'child_process';
import path from 'path';
import * as playwright from 'playwright';
import yaml from 'yaml';
export type ContextOptions = {
browserName?: 'chromium' | 'firefox' | 'webkit';
userDataDir: string;
launchOptions?: playwright.LaunchOptions;
cdpEndpoint?: string;
remoteEndpoint?: string;
};
export class Context {
private _userDataDir: string;
private _launchOptions: playwright.LaunchOptions | undefined;
private _options: ContextOptions;
private _browser: playwright.Browser | undefined;
private _page: playwright.Page | undefined;
private _console: playwright.ConsoleMessage[] = [];
private _createPagePromise: Promise<playwright.Page> | undefined;
private _fileChooser: playwright.FileChooser | undefined;
private _lastSnapshotFrames: playwright.FrameLocator[] = [];
private _lastSnapshotFrames: (playwright.Page | playwright.FrameLocator)[] = [];
constructor(userDataDir: string, launchOptions?: playwright.LaunchOptions) {
this._userDataDir = userDataDir;
this._launchOptions = launchOptions;
constructor(options: ContextOptions) {
this._options = options;
}
async createPage(): Promise<playwright.Page> {
@@ -64,6 +74,25 @@ export class Context {
this._console.length = 0;
}
async install(): Promise<string> {
const channel = this._options.launchOptions?.channel ?? this._options.browserName ?? 'chrome';
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
const child = fork(cli, ['install', channel], {
stdio: 'pipe',
});
const output: string[] = [];
child.stdout?.on('data', data => output.push(data.toString()));
child.stderr?.on('data', data => output.push(data.toString()));
return new Promise((resolve, reject) => {
child.on('close', code => {
if (code === 0)
resolve(channel);
else
reject(new Error(`Failed to install browser: ${output.join('')}`));
});
});
}
existingPage(): playwright.Page {
if (!this._page)
throw new Error('Navigate to a location to create a page');
@@ -96,55 +125,98 @@ export class Context {
}
private async _createPage(): Promise<{ browser?: playwright.Browser, page: playwright.Page }> {
if (process.env.PLAYWRIGHT_WS_ENDPOINT) {
const url = new URL(process.env.PLAYWRIGHT_WS_ENDPOINT);
if (this._launchOptions)
url.searchParams.set('launch-options', JSON.stringify(this._launchOptions));
const browser = await playwright.chromium.connect(String(url));
if (this._options.remoteEndpoint) {
const url = new URL(this._options.remoteEndpoint);
if (this._options.browserName)
url.searchParams.set('browser', this._options.browserName);
if (this._options.launchOptions)
url.searchParams.set('launch-options', JSON.stringify(this._options.launchOptions));
const browser = await playwright[this._options.browserName ?? 'chromium'].connect(String(url));
const page = await browser.newPage();
return { browser, page };
}
const context = await playwright.chromium.launchPersistentContext(this._userDataDir, this._launchOptions);
if (this._options.cdpEndpoint) {
const browser = await playwright.chromium.connectOverCDP(this._options.cdpEndpoint);
const browserContext = browser.contexts()[0];
let [page] = browserContext.pages();
if (!page)
page = await browserContext.newPage();
return { browser, page };
}
const context = await this._launchPersistentContext();
const [page] = context.pages();
return { page };
}
private async _launchPersistentContext(): Promise<playwright.BrowserContext> {
try {
const browserType = this._options.browserName ? playwright[this._options.browserName] : playwright.chromium;
return await browserType.launchPersistentContext(this._options.userDataDir, this._options.launchOptions);
} catch (error: any) {
if (error.message.includes('Executable doesn\'t exist'))
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
throw error;
}
}
async allFramesSnapshot() {
const page = this.existingPage();
const visibleFrames = await page.locator('iframe').filter({ visible: true }).all();
this._lastSnapshotFrames = visibleFrames.map(frame => frame.contentFrame());
this._lastSnapshotFrames = [];
const yaml = await this._allFramesSnapshot(this.existingPage());
return yaml.toString().trim();
}
const snapshots = await Promise.all([
page.locator('html').ariaSnapshot({ ref: true }),
...this._lastSnapshotFrames.map(async (frame, index) => {
const snapshot = await frame.locator('html').ariaSnapshot({ ref: true });
const args = [];
const src = await frame.owner().getAttribute('src');
if (src)
args.push(`src=${src}`);
const name = await frame.owner().getAttribute('name');
if (name)
args.push(`name=${name}`);
return `\n# iframe ${args.join(' ')}\n` + snapshot.replaceAll('[ref=', `[ref=f${index}`);
})
]);
private async _allFramesSnapshot(frame: playwright.Page | playwright.FrameLocator): Promise<yaml.Document> {
const frameIndex = this._lastSnapshotFrames.push(frame) - 1;
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true });
const snapshot = yaml.parseDocument(snapshotString);
return snapshots.join('\n');
const visit = async (node: any): Promise<unknown> => {
if (yaml.isPair(node)) {
await Promise.all([
visit(node.key).then(k => node.key = k),
visit(node.value).then(v => node.value = v)
]);
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
node.items = await Promise.all(node.items.map(visit));
} else if (yaml.isScalar(node)) {
if (typeof node.value === 'string') {
const value = node.value;
if (frameIndex > 0)
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
if (value.startsWith('iframe ')) {
const ref = value.match(/\[ref=(.*)\]/)?.[1];
if (ref) {
try {
const childSnapshot = await this._allFramesSnapshot(frame.frameLocator(`aria-ref=${ref}`));
return snapshot.createPair(node.value, childSnapshot);
} catch (error) {
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
}
}
}
}
}
return node;
};
await visit(snapshot.contents);
return snapshot;
}
refLocator(ref: string): playwright.Locator {
const page = this.existingPage();
let frame: playwright.Frame | playwright.FrameLocator = page.mainFrame();
let frame = this._lastSnapshotFrames[0];
const match = ref.match(/^f(\d+)(.*)/);
if (match) {
const frameIndex = parseInt(match[1], 10);
if (!this._lastSnapshotFrames[frameIndex])
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
frame = this._lastSnapshotFrames[frameIndex];
ref = match[2];
}
if (!frame)
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
return frame.locator(`aria-ref=${ref}`);
}
}

View File

@@ -30,6 +30,7 @@ const commonTools: Tool[] = [
common.wait,
common.pdf,
common.close,
common.install,
];
const snapshotTools: Tool[] = [
@@ -64,8 +65,10 @@ const resources: Resource[] = [
];
type Options = {
browserName?: 'chromium' | 'firefox' | 'webkit';
userDataDir?: string;
launchOptions?: LaunchOptions;
cdpEndpoint?: string;
vision?: boolean;
};
@@ -78,7 +81,9 @@ export function createServer(options?: Options): Server {
version: packageJSON.version,
tools,
resources,
browserName: options?.browserName,
userDataDir: options?.userDataDir ?? '',
launchOptions: options?.launchOptions,
cdpEndpoint: options?.cdpEndpoint,
});
}

View File

@@ -35,20 +35,56 @@ const packageJSON = require('../package.json');
program
.version('Version ' + packageJSON.version)
.name(packageJSON.name)
.option('--browser <browser>', 'Browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
.option('--executable-path <path>', 'Path to the browser executable.')
.option('--headless', 'Run browser in headless mode, headed by default')
.option('--port <port>', 'Port to listen on for SSE transport.')
.option('--user-data-dir <path>', 'Path to the user data directory')
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
.option('--port <port>', 'Port to listen on for SSE transport.')
.action(async options => {
let browserName: 'chromium' | 'firefox' | 'webkit';
let channel: string | undefined;
switch (options.browser) {
case 'chrome':
case 'chrome-beta':
case 'chrome-canary':
case 'chrome-dev':
case 'msedge':
case 'msedge-beta':
case 'msedge-canary':
case 'msedge-dev':
browserName = 'chromium';
channel = options.browser;
break;
case 'chromium':
browserName = 'chromium';
break;
case 'firefox':
browserName = 'firefox';
break;
case 'webkit':
browserName = 'webkit';
break;
default:
browserName = 'chromium';
channel = 'chrome';
}
const launchOptions: LaunchOptions = {
headless: !!options.headless,
channel: 'chrome',
channel,
executablePath: options.executablePath,
};
const userDataDir = options.userDataDir ?? await createUserDataDir();
const userDataDir = options.userDataDir ?? await createUserDataDir(browserName);
const serverList = new ServerList(() => createServer({
browserName,
userDataDir,
launchOptions,
vision: !!options.vision,
cdpEndpoint: options.cdpEndpoint,
}));
setupExitWatchdog(serverList);
@@ -70,7 +106,7 @@ function setupExitWatchdog(serverList: ServerList) {
program.parse(process.argv);
async function createUserDataDir() {
async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') {
let cacheDirectory: string;
if (process.platform === 'linux')
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
@@ -80,7 +116,7 @@ async function createUserDataDir() {
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-chrome-profile');
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserName}-profile`);
await fs.promises.mkdir(result, { recursive: true });
return result;
}

View File

@@ -21,20 +21,18 @@ import { Context } from './context';
import type { Tool } from './tools/tool';
import type { Resource } from './resources/resource';
import type { LaunchOptions } from 'playwright';
import type { ContextOptions } from './context';
type Options = {
type Options = ContextOptions & {
name: string;
version: string;
tools: Tool[];
resources: Resource[],
userDataDir: string;
launchOptions?: LaunchOptions;
};
export function createServerWithTools(options: Options): Server {
const { name, version, tools, resources, userDataDir, launchOptions } = options;
const context = new Context(userDataDir, launchOptions);
const { name, version, tools, resources } = options;
const context = new Context(options);
const server = new Server({ name, version }, {
capabilities: {
tools: {},

View File

@@ -20,7 +20,7 @@ import path from 'path';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { captureAriaSnapshot, runAndWait } from './utils';
import { captureAriaSnapshot, runAndWait, sanitizeForFilePath } from './utils';
import type { ToolFactory, Tool } from './tool';
@@ -127,7 +127,7 @@ export const pdf: Tool = {
},
handle: async context => {
const page = context.existingPage();
const fileName = path.join(os.tmpdir(), `/page-${new Date().toISOString()}.pdf`);
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + '.pdf';
await page.pdf({ path: fileName });
return {
content: [{
@@ -174,3 +174,20 @@ export const chooseFile: ToolFactory = snapshot => ({
}, snapshot);
},
});
export const install: Tool = {
schema: {
name: 'browser_install',
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
inputSchema: zodToJsonSchema(z.object({})),
},
handle: async context => {
const channel = await context.install();
return {
content: [{
type: 'text',
text: `Browser ${channel} installed`,
}],
};
},
};

View File

@@ -106,3 +106,7 @@ export async function captureAriaSnapshot(context: Context, status: string = '')
content: [{ type: 'text', text: lines.join('\n') }],
};
}
export function sanitizeForFilePath(s: string) {
return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
}

View File

@@ -36,6 +36,7 @@ test('test tool list', async ({ client, visionClient }) => {
'browser_wait',
'browser_save_as_pdf',
'browser_close',
'browser_install',
]);
const { tools: visionTools } = await visionClient.listTools();
@@ -53,6 +54,7 @@ test('test tool list', async ({ client, visionClient }) => {
'browser_wait',
'browser_save_as_pdf',
'browser_close',
'browser_install',
]);
});
@@ -77,7 +79,7 @@ test('test browser_navigate', async ({ client }) => {
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- document [ref=s1e2]: Hello, world!
- text: Hello, world!
\`\`\`
`
);
@@ -95,7 +97,7 @@ test('test browser_click', async ({ client }) => {
name: 'browser_click',
arguments: {
element: 'Submit button',
ref: 's1e4',
ref: 's1e3',
},
})).toHaveTextContent(`"Submit button" clicked
@@ -103,8 +105,7 @@ test('test browser_click', async ({ client }) => {
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- document [ref=s2e2]:
- button "Submit" [ref=s2e4]
- button "Submit" [ref=s2e3]
\`\`\`
`);
});
@@ -131,7 +132,7 @@ test('test reopen browser', async ({ client }) => {
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- document [ref=s1e2]: Hello, world!
- text: Hello, world!
\`\`\`
`);
});
@@ -148,7 +149,7 @@ test('single option', async ({ client }) => {
name: 'browser_select_option',
arguments: {
element: 'Select',
ref: 's1e4',
ref: 's1e3',
values: ['bar'],
},
})).toHaveTextContent(`Selected option in "Select"
@@ -157,10 +158,9 @@ test('single option', async ({ client }) => {
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- document [ref=s2e2]:
- combobox [ref=s2e4]:
- option "Foo" [ref=s2e5]
- option "Bar" [selected] [ref=s2e6]
- combobox [ref=s2e3]:
- option "Foo" [ref=s2e4]
- option "Bar" [selected] [ref=s2e5]
\`\`\`
`);
});
@@ -177,7 +177,7 @@ test('multiple option', async ({ client }) => {
name: 'browser_select_option',
arguments: {
element: 'Select',
ref: 's1e4',
ref: 's1e3',
values: ['bar', 'baz'],
},
})).toHaveTextContent(`Selected option in "Select"
@@ -186,11 +186,10 @@ test('multiple option', async ({ client }) => {
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- document [ref=s2e2]:
- listbox [ref=s2e4]:
- option "Foo" [ref=s2e5]
- option "Bar" [selected] [ref=s2e6]
- option "Baz" [selected] [ref=s2e7]
- listbox [ref=s2e3]:
- option "Foo" [ref=s2e4]
- option "Bar" [selected] [ref=s2e5]
- option "Baz" [selected] [ref=s2e6]
\`\`\`
`);
});
@@ -217,21 +216,26 @@ test('stitched aria frames', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<h1>Hello</h1><iframe src="data:text/html,<h1>World</h1>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>',
url: `data:text/html,<h1>Hello</h1><iframe src="data:text/html,<button>World</button><main><iframe src='data:text/html,<p>Nested</p>'></iframe></main>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>`,
},
})).toHaveTextContent(`
- Page URL: data:text/html,<h1>Hello</h1><iframe src="data:text/html,<h1>World</h1>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>
- Page Title:
- Page Snapshot
})).toContainTextContent(`
\`\`\`yaml
- document [ref=s1e2]:
- heading "Hello" [level=1] [ref=s1e4]
# iframe src=data:text/html,<h1>World</h1>
- document [ref=f0s1e2]:
- heading "World" [level=1] [ref=f0s1e4]
- heading "Hello" [level=1] [ref=s1e3]
- iframe [ref=s1e4]:
- button "World" [ref=f1s1e3]
- main [ref=f1s1e4]:
- iframe [ref=f1s1e5]:
- paragraph [ref=f2s1e3]: Nested
\`\`\`
`);
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'World',
ref: 'f1s1e3',
},
})).toContainTextContent('"World" clicked');
});
test('browser_choose_file', async ({ client }) => {
@@ -240,13 +244,13 @@ test('browser_choose_file', async ({ client }) => {
arguments: {
url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>',
},
})).toContainTextContent('- textbox [ref=s1e4]');
})).toContainTextContent('- textbox [ref=s1e3]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Textbox',
ref: 's1e4',
ref: 's1e3',
},
})).toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
@@ -262,7 +266,7 @@ test('browser_choose_file', async ({ client }) => {
});
expect(response).not.toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
expect(response).toContainTextContent('textbox [ref=s3e4]: C:\\fakepath\\test.txt');
expect(response).toContainTextContent('textbox [ref=s3e3]: C:\\fakepath\\test.txt');
}
{
@@ -270,12 +274,12 @@ test('browser_choose_file', async ({ client }) => {
name: 'browser_click',
arguments: {
element: 'Textbox',
ref: 's3e4',
ref: 's3e3',
},
});
expect(response).toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
expect(response).toContainTextContent('button "Button" [ref=s4e5]');
expect(response).toContainTextContent('button "Button" [ref=s4e4]');
}
{
@@ -283,7 +287,7 @@ test('browser_choose_file', async ({ client }) => {
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's4e5',
ref: 's4e4',
},
});
@@ -313,3 +317,54 @@ test('sse transport', async () => {
cp.kill();
}
});
test('cdp server', async ({ cdpEndpoint, startClient }) => {
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toHaveTextContent(`
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
\`\`\`
`
);
});
test('save as pdf', async ({ client }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toHaveTextContent(`
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- text: Hello, world!
\`\`\`
`
);
const response = await client.callTool({
name: 'browser_save_as_pdf',
});
expect(response).toHaveTextContent(/^Saved as.*page-[^:]+.pdf$/);
});
test('executable path', async ({ startClient }) => {
const client = await startClient({ args: [`--executable-path=bogus`] });
const response = await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
});
expect(response).toContainTextContent(`executable doesn't exist`);
});

View File

@@ -24,8 +24,9 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
type Fixtures = {
client: Client;
visionClient: Client;
startClient: (options?: { env?: NodeJS.ProcessEnv, vision?: boolean }) => Promise<Client>;
startClient: (options?: { args?: string[], vision?: boolean }) => Promise<Client>;
wsEndpoint: string;
cdpEndpoint: string;
};
export const test = baseTest.extend<Fixtures>({
@@ -46,6 +47,8 @@ export const test = baseTest.extend<Fixtures>({
const args = ['--headless', '--user-data-dir', userDataDir];
if (options?.vision)
args.push('--vision');
if (options?.args)
args.push(...options.args);
const transport = new StdioClientTransport({
command: 'node',
args: [path.join(__dirname, '../cli.js'), ...args],
@@ -64,20 +67,29 @@ export const test = baseTest.extend<Fixtures>({
await use(browserServer.wsEndpoint());
await browserServer.close();
},
cdpEndpoint: async ({ }, use, testInfo) => {
const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!);
const browser = await chromium.launchPersistentContext(testInfo.outputPath('user-data-dir'), {
channel: 'chrome',
args: [`--remote-debugging-port=${port}`],
});
await use(`http://localhost:${port}`);
await browser.close();
},
});
type Response = Awaited<ReturnType<Client['callTool']>>;
export const expect = baseExpect.extend({
toHaveTextContent(response: Response, content: string | string[]) {
toHaveTextContent(response: Response, content: string | RegExp) {
const isNot = this.isNot;
try {
content = Array.isArray(content) ? content : [content];
const texts = (response.content as any).map(c => c.text);
const text = (response.content as any)[0].text;
if (isNot)
baseExpect(texts).not.toEqual(content);
baseExpect(text).not.toMatch(content);
else
baseExpect(texts).toEqual(content);
baseExpect(text).toMatch(content);
} catch (e) {
return {
pass: isNot,