16 Commits

Author SHA1 Message Date
Pavel Feldman
fea3f26e85 chore: mark v0.0.24 (#401) 2025-05-12 09:40:59 -07:00
Pavel Feldman
dd5b41f1d8 chore: account for undefined arguments (#400) 2025-05-12 09:35:33 -07:00
Pavel Feldman
05dc5d915b chore: mark v0.0.23 (#399) 2025-05-12 09:13:48 -07:00
Taiga Mikami
65a229c79f Fix import in README from createServer to createConnection (#396)
Probably, `createServer` is not from `@playwright/mcp`.
2025-05-12 08:46:21 -07:00
Max Schmitt
84664d4b09 test: unflake 'should throw connection error and allow re-connecting' (#398)
Fixes
https://github.com/microsoft/playwright-mcp/actions/runs/14940263450/job/41976152764#step:8:315
2025-05-12 09:45:09 +02:00
Pavel Feldman
445170a76b chore: roll playwright 5/9 (#394) 2025-05-09 18:01:17 -07:00
Pavel Feldman
c28b480b51 feat(wait): allow waiting for given text (#390)
Fixes https://github.com/microsoft/playwright-mcp/issues/389
2025-05-09 15:35:28 -07:00
Max Schmitt
65716b60dd fix: createConnection() via public API (#384)
Fixes https://github.com/microsoft/playwright-mcp/issues/382
2025-05-09 21:50:38 +02:00
Max Schmitt
75f74a54bc docs: reference to new Docker image (#380) 2025-05-09 21:01:10 +02:00
Max Schmitt
ef41c626ef chore: unset skipLibCheck in tsconfig.json (#386)
Follow-up for
https://github.com/microsoft/playwright-mcp/pull/385#discussion_r2081541865.

> `skipLibCheck`: Skip type checking all .d.ts files.
2025-05-09 14:35:09 +02:00
Max Schmitt
95ca08fdb7 fix: use of wrong launchOptions type in public API (#385) 2025-05-09 14:16:04 +02:00
Max Schmitt
053c2f3d32 test: fix SSE MCP SDK imports (#383) 2025-05-09 14:08:19 +02:00
Pavel Feldman
57b3c14276 chore: only reset network log upon explicit navigation (#377)
Fixes https://github.com/microsoft/playwright-mcp/issues/376
2025-05-08 17:02:09 -07:00
おがどら
85c85bd2fb chore: support custom filename in screenshot function (#349) 2025-05-08 11:04:18 -07:00
Max Schmitt
09ba7989c3 test: run tests on MCP server inside Docker (#361)
https://github.com/microsoft/playwright-mcp/issues/346
2025-05-07 18:04:20 +02:00
Max Schmitt
a115c31953 chore: rename console to consoleMessages (#372)
Motivation: `console` is a global object in Node.js and having a method
like that confuses intellisense.
2025-05-07 16:40:08 +02:00
38 changed files with 685 additions and 401 deletions

View File

@@ -30,32 +30,56 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18
uses: actions/setup-node@v4
with:
# https://github.com/microsoft/playwright-mcp/issues/344
node-version: '18.19'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Playwright install
run: npx playwright install --with-deps
- name: Install MS Edge
if: ${{ matrix.os == 'macos-latest' }} # MS Edge is not preinstalled on macOS runners.
# MS Edge is not preinstalled on macOS runners.
if: ${{ matrix.os == 'macos-latest' }}
run: npx playwright install msedge
- name: Build
run: npm run build
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run tests
run: npm test -- --forbid-only
run: npm test
test_docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Playwright install
run: npx playwright install --with-deps chromium
- name: Build
run: npm run build
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
tags: playwright-mcp-dev:latest
cache-from: type=gha
cache-to: type=gha,mode=max
load: true
- name: Run tests
shell: bash
run: |
# Used for the Docker tests to share the test-results folder with the container.
umask 0000
npm run test -- --project=chromium-docker
env:
MCP_IN_DOCKER: 1

View File

@@ -207,7 +207,7 @@ And then in MCP client config, set the `url` to the SSE endpoint:
"mcpServers": {
"playwright": {
"command": "docker",
"args": ["run", "-i", "--rm", "--init", "mcp/playwright"]
"args": ["run", "-i", "--rm", "--init", "--pull=always", "mcr.microsoft.com/playwright/mcp"]
}
}
}
@@ -216,7 +216,7 @@ And then in MCP client config, set the `url` to the SSE endpoint:
You can build the Docker image yourself.
```
docker build -t mcp/playwright .
docker build -t mcr.microsoft.com/playwright/mcp .
```
### Programmatic usage
@@ -224,14 +224,14 @@ docker build -t mcp/playwright .
```js
import http from 'http';
import { createServer } from '@playwright/mcp';
import { createConnection } from '@playwright/mcp';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
http.createServer(async (req, res) => {
// ...
// Creates a headless Playwright MCP server with SSE transport
const connection = await createConnection({ headless: true });
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
const transport = new SSEServerTransport('/messages', res);
await connection.connect(transport);
@@ -341,6 +341,7 @@ X Y coordinate space, based on the provided screenshot.
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
- Parameters:
- `raw` (boolean, optional): Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
- Read-only: **true**
@@ -501,7 +502,8 @@ X Y coordinate space, based on the provided screenshot.
- **browser_pdf_save**
- Title: Save as PDF
- Description: Save page as PDF
- Parameters: None
- Parameters:
- `filename` (string, optional): File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.
- Read-only: **true**
### Utilities
@@ -516,11 +518,13 @@ X Y coordinate space, based on the provided screenshot.
<!-- NOTE: This has been generated via update-readme.js -->
- **browser_wait**
- Title: Wait
- Description: Wait for a specified time in seconds
- **browser_wait_for**
- Title: Wait for
- Description: Wait for text to appear or disappear or a specified time to pass
- Parameters:
- `time` (number): The time to wait in seconds
- `time` (number, optional): The time to wait in seconds
- `text` (string, optional): The text to wait for
- `textGone` (string, optional): The text to wait for to disappear
- Read-only: **true**
<!-- NOTE: This has been generated via update-readme.js -->

2
config.d.ts vendored
View File

@@ -40,7 +40,7 @@ export type Config = {
*
* This is useful for settings options like `channel`, `headless`, `executablePath`, etc.
*/
launchOptions?: playwright.BrowserLaunchOptions;
launchOptions?: playwright.LaunchOptions;
/**
* Context options for the browser context.

View File

@@ -16,4 +16,4 @@
*/
import { createConnection } from './lib/index';
export default { createConnection };
export { createConnection };

43
package-lock.json generated
View File

@@ -1,18 +1,17 @@
{
"name": "@playwright/mcp",
"version": "0.0.22",
"version": "0.0.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@playwright/mcp",
"version": "0.0.22",
"version": "0.0.24",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0",
"playwright": "1.53.0-alpha-1746218818000",
"yaml": "^2.7.1",
"playwright": "1.53.0-alpha-1746832516000",
"zod-to-json-schema": "^3.24.4"
},
"bin": {
@@ -21,7 +20,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "1.53.0-alpha-1746218818000",
"@playwright/test": "1.53.0-alpha-1746832516000",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.26.1",
@@ -287,13 +286,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.53.0-alpha-1746218818000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-1746218818000.tgz",
"integrity": "sha512-J05FD0oOCVbjbp4IjQi5tOPKywchi5EENS9jRjgkA5N9jd/+BaZ3jT8HlLMIgALdk/eLsprQa7vh9h45Q1FOPA==",
"version": "1.53.0-alpha-1746832516000",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-1746832516000.tgz",
"integrity": "sha512-Sec+6uzpA4MfwmQqJFBFVazffynqVwLO5swDxG7WoqgpUdn9gQX4K4tDG64SV6f4nOpwdM5LKTasPSXu02nn/Q==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.53.0-alpha-1746218818000"
"playwright": "1.53.0-alpha-1746832516000"
},
"bin": {
"playwright": "cli.js"
@@ -3299,12 +3298,12 @@
}
},
"node_modules/playwright": {
"version": "1.53.0-alpha-1746218818000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-1746218818000.tgz",
"integrity": "sha512-mVIjtdqIawIqWVyvCaLmV6XTALCT4oWWrbMjoHyyWRln3jQjnm3RUO9LkaINz+Yh88O3FkuY6RfjGXPXeFeJ4Q==",
"version": "1.53.0-alpha-1746832516000",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-1746832516000.tgz",
"integrity": "sha512-kcC1B2XJr4VaDAcVzi61SbYGkodq1QIqQXuPieXsNgZZ7cEKWzO2sI42yp2yie6wlCx0oLkSS2Q6jWSRVRLeaw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.53.0-alpha-1746218818000"
"playwright-core": "1.53.0-alpha-1746832516000"
},
"bin": {
"playwright": "cli.js"
@@ -3317,9 +3316,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.53.0-alpha-1746218818000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-1746218818000.tgz",
"integrity": "sha512-iaIZmhO/psGssWpxIprJkFrn2h4xFjgL0jZsKGtReAMZ/XhlqMUJxtSitwWM4BV+wxJIptsZD0s5Ml2KU62Z3w==",
"version": "1.53.0-alpha-1746832516000",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-1746832516000.tgz",
"integrity": "sha512-4O98y4zV0rOP6CepMLC/VGuzqGaR1sS9AVh+i0CghWMQHM/8bxPJI8W38QndO0JU0V5nBD6j7DQeNt1mJ+CZ+g==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@@ -4350,18 +4349,6 @@
"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.22",
"version": "0.0.24",
"description": "Playwright Tools for MCP",
"type": "module",
"repository": {
@@ -37,14 +37,13 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.11.0",
"commander": "^13.1.0",
"playwright": "1.53.0-alpha-1746218818000",
"yaml": "^2.7.1",
"playwright": "1.53.0-alpha-1746832516000",
"zod-to-json-schema": "^3.24.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.19.0",
"@playwright/test": "1.53.0-alpha-1746218818000",
"@playwright/test": "1.53.0-alpha-1746832516000",
"@stylistic/eslint-plugin": "^3.0.1",
"@types/node": "^22.13.10",
"@typescript-eslint/eslint-plugin": "^8.26.1",

View File

@@ -29,6 +29,7 @@ export default defineConfig<TestOptions>({
{ name: 'chrome' },
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
...process.env.MCP_IN_DOCKER ? [{ name: 'chromium-docker', use: { mcpBrowser: 'chromium', mcpMode: 'docker' as const } }] : [],
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
],

View File

@@ -99,7 +99,7 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
};
if (browserName === 'chromium')
(launchOptions as any).webSocketPort = await findFreePort();
(launchOptions as any).cdpPort = await findFreePort();
const contextOptions: BrowserContextOptions | undefined = cliOptions.device ? devices[cliOptions.device] : undefined;
@@ -175,7 +175,7 @@ function mergeConfig(base: Config, overrides: Config): Config {
},
};
if (browser.browserName !== 'chromium')
if (browser.browserName !== 'chromium' && browser.launchOptions)
delete browser.launchOptions.channel;
return {

View File

@@ -125,7 +125,7 @@ export class Context {
async run(tool: Tool, params: Record<string, unknown> | undefined) {
// Tab management is done outside of the action() call.
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params));
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
@@ -374,7 +374,7 @@ async function createUserDataDir(browserConfig: Config['browser']) {
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
else
throw new Error('Unsupported platform: ' + process.platform);
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserConfig?.launchOptions.channel ?? browserConfig?.browserName}-profile`);
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserConfig?.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
await fs.promises.mkdir(result, { recursive: true });
return result;
}

View File

@@ -14,10 +14,10 @@
* limitations under the License.
*/
import { Connection } from './connection.js';
import { Connection, createConnection as createConnectionImpl } from './connection.js';
import type { Config } from '../config.js';
export async function createConnection(config: Config = {}): Promise<Connection> {
return createConnection(config);
return createConnectionImpl(config);
}

View File

@@ -15,20 +15,18 @@
*/
import * as playwright from 'playwright';
import yaml from 'yaml';
type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
export class PageSnapshot {
private _frameLocators: PageOrFrameLocator[] = [];
private _page: playwright.Page;
private _text!: string;
constructor() {
constructor(page: playwright.Page) {
this._page = page;
}
static async create(page: playwright.Page): Promise<PageSnapshot> {
const snapshot = new PageSnapshot();
await snapshot._build(page);
const snapshot = new PageSnapshot(page);
await snapshot._build();
return snapshot;
}
@@ -36,8 +34,8 @@ export class PageSnapshot {
return this._text;
}
private async _build(page: playwright.Page) {
const yamlDocument = await this._snapshotFrame(page);
private async _build() {
const yamlDocument = await (this._page as any)._snapshotForAI();
this._text = [
`- Page Snapshot`,
'```yaml',
@@ -46,56 +44,7 @@ export class PageSnapshot {
].join('\n');
}
private async _snapshotFrame(frame: playwright.Page | playwright.FrameLocator) {
const frameIndex = this._frameLocators.push(frame) - 1;
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true, emitGeneric: true });
const snapshot = yaml.parseDocument(snapshotString);
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._snapshotFrame(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 {
let frame = this._frameLocators[0];
const match = ref.match(/^f(\d+)(.*)/);
if (match) {
const frameIndex = parseInt(match[1], 10);
frame = this._frameLocators[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}`);
return this._page.locator(`aria-ref=${ref}`);
}
}

View File

@@ -23,7 +23,7 @@ import type { Context } from './context.js';
export class Tab {
readonly context: Context;
readonly page: playwright.Page;
private _console: playwright.ConsoleMessage[] = [];
private _consoleMessages: playwright.ConsoleMessage[] = [];
private _requests: Map<playwright.Request, playwright.Response | null> = new Map();
private _snapshot: PageSnapshot | undefined;
private _onPageClose: (tab: Tab) => void;
@@ -32,13 +32,9 @@ export class Tab {
this.context = context;
this.page = page;
this._onPageClose = onPageClose;
page.on('console', event => this._console.push(event));
page.on('console', event => this._consoleMessages.push(event));
page.on('request', request => this._requests.set(request, null));
page.on('response', response => this._requests.set(response.request(), response));
page.on('framenavigated', frame => {
if (!frame.parentFrame())
this._clearCollectedArtifacts();
});
page.on('close', () => this._onClose());
page.on('filechooser', chooser => {
this.context.setModalState({
@@ -56,7 +52,7 @@ export class Tab {
}
private _clearCollectedArtifacts() {
this._console.length = 0;
this._consoleMessages.length = 0;
this._requests.clear();
}
@@ -66,6 +62,8 @@ export class Tab {
}
async navigate(url: string) {
this._clearCollectedArtifacts();
const downloadEvent = this.page.waitForEvent('download').catch(() => {});
try {
await this.page.goto(url, { waitUntil: 'domcontentloaded' });
@@ -100,8 +98,8 @@ export class Tab {
return this._snapshot;
}
console(): playwright.ConsoleMessage[] {
return this._console;
consoleMessages(): playwright.ConsoleMessage[] {
return this._consoleMessages;
}
requests(): Map<playwright.Request, playwright.Response | null> {

View File

@@ -21,19 +21,44 @@ const wait: ToolFactory = captureSnapshot => defineTool({
capability: 'wait',
schema: {
name: 'browser_wait',
title: 'Wait',
description: 'Wait for a specified time in seconds',
name: 'browser_wait_for',
title: 'Wait for',
description: 'Wait for text to appear or disappear or a specified time to pass',
inputSchema: z.object({
time: z.number().describe('The time to wait in seconds'),
time: z.number().optional().describe('The time to wait in seconds'),
text: z.string().optional().describe('The text to wait for'),
textGone: z.string().optional().describe('The text to wait for to disappear'),
}),
type: 'readOnly',
},
handle: async (context, params) => {
await new Promise(f => setTimeout(f, Math.min(10000, params.time * 1000)));
if (!params.text && !params.textGone && !params.time)
throw new Error('Either time, text or textGone must be provided');
const code: string[] = [];
if (params.time) {
code.push(`await new Promise(f => setTimeout(f, ${params.time!} * 1000));`);
await new Promise(f => setTimeout(f, Math.min(10000, params.time! * 1000)));
}
const tab = context.currentTabOrDie();
const locator = params.text ? tab.page.getByText(params.text).first() : undefined;
const goneLocator = params.textGone ? tab.page.getByText(params.textGone).first() : undefined;
if (goneLocator) {
code.push(`await page.getByText(${JSON.stringify(params.textGone)}).first().waitFor({ state: 'hidden' });`);
await goneLocator.waitFor({ state: 'hidden' });
}
if (locator) {
code.push(`await page.getByText(${JSON.stringify(params.text)}).first().waitFor({ state: 'visible' });`);
await locator.waitFor({ state: 'visible' });
}
return {
code: [`// Waited for ${params.time} seconds`],
code,
captureSnapshot,
waitForNetwork: false,
};

View File

@@ -27,7 +27,7 @@ const console = defineTool({
type: 'readOnly',
},
handle: async context => {
const messages = context.currentTabOrDie().console();
const messages = context.currentTabOrDie().consoleMessages();
const log = messages.map(message => `[${message.type().toUpperCase()}] ${message.text()}`).join('\n');
return {
code: [`// <internal code to get console messages>`],

View File

@@ -33,7 +33,7 @@ const install = defineTool({
},
handle: async context => {
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.launchOptions.browserName ?? 'chrome';
const channel = context.config.browser?.launchOptions?.channel ?? context.config.browser?.browserName ?? 'chrome';
const cliUrl = import.meta.resolve('playwright/package.json');
const cliPath = path.join(fileURLToPath(cliUrl), '..', 'cli.js');
const child = fork(cliPath, ['install', channel], {

View File

@@ -20,6 +20,10 @@ import { defineTool } from './tool.js';
import * as javascript from '../javascript.js';
import { outputFile } from '../config.js';
const pdfSchema = z.object({
filename: z.string().optional().describe('File name to save the pdf to. Defaults to `page-{timestamp}.pdf` if not specified.'),
});
const pdf = defineTool({
capability: 'pdf',
@@ -27,13 +31,13 @@ const pdf = defineTool({
name: 'browser_pdf_save',
title: 'Save as PDF',
description: 'Save page as PDF',
inputSchema: z.object({}),
inputSchema: pdfSchema,
type: 'readOnly',
},
handle: async context => {
handle: async (context, params) => {
const tab = context.currentTabOrDie();
const fileName = await outputFile(context.config, `page-${new Date().toISOString()}.pdf`);
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.pdf`);
const code = [
`// Save page as ${fileName}`,

View File

@@ -220,6 +220,7 @@ const selectOption = defineTool({
const screenshotSchema = z.object({
raw: z.boolean().optional().describe('Whether to return without compression (in PNG format). Default is false, which returns a JPEG image.'),
filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'),
element: z.string().optional().describe('Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.'),
ref: z.string().optional().describe('Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.'),
}).refine(data => {
@@ -243,7 +244,7 @@ const screenshot = defineTool({
const tab = context.currentTabOrDie();
const snapshot = tab.snapshotOrDie();
const fileType = params.raw ? 'png' : 'jpeg';
const fileName = await outputFile(context.config, `page-${new Date().toISOString()}.${fileType}`);
const fileName = await outputFile(context.config, params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
const options: playwright.PageScreenshotOptions = { type: fileType, quality: fileType === 'png' ? undefined : 50, scale: 'css', path: fileName };
const isElementScreenshot = params.element && params.ref;

View File

@@ -43,7 +43,7 @@ test('test snapshot tool list', async ({ client }) => {
'browser_tab_new',
'browser_tab_select',
'browser_take_screenshot',
'browser_wait',
'browser_wait_for',
]));
});
@@ -72,7 +72,7 @@ test('test vision tool list', async ({ visionClient }) => {
'browser_tab_list',
'browser_tab_new',
'browser_tab_select',
'browser_wait',
'browser_wait_for',
]));
});

View File

@@ -16,14 +16,12 @@
import { test, expect } from './fixtures.js';
test('cdp server', async ({ cdpEndpoint, startClient }) => {
test('cdp server', async ({ cdpEndpoint, startClient, server }) => {
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
});
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
@@ -39,7 +37,6 @@ test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
expect(await client.callTool({
name: 'browser_snapshot',
arguments: {},
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
@@ -50,25 +47,27 @@ test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
- Page Title:
- Page Snapshot
\`\`\`yaml
- generic [ref=s1e2]: hello world
- generic [ref=e1]: hello world
\`\`\`
`);
});
test('should throw connection error and allow re-connecting', async ({ cdpEndpoint, startClient }) => {
test('should throw connection error and allow re-connecting', async ({ cdpEndpoint, startClient, server }) => {
const port = 3200 + test.info().parallelIndex;
const client = await startClient({ args: [`--cdp-endpoint=http://localhost:${port}`] });
server.setContent('/', `
<title>Title</title>
<body>Hello, world!</body>
`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
arguments: { url: server.PREFIX },
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
await cdpEndpoint(port);
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
arguments: { url: server.PREFIX },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
});

View File

@@ -19,21 +19,24 @@ import fs from 'node:fs';
import { Config } from '../config.js';
import { test, expect } from './fixtures.js';
test('config user data dir', async ({ startClient, mcpBrowser }, testInfo) => {
test('config user data dir', async ({ startClient, localOutputPath, server }) => {
server.setContent('/', `
<title>Title</title>
<body>Hello, world!</body>
`, 'text/html');
const config: Config = {
browser: {
userDataDir: testInfo.outputPath('user-data-dir'),
userDataDir: localOutputPath('user-data-dir'),
},
};
const configPath = testInfo.outputPath('config.json');
const configPath = localOutputPath('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,<html><body>Hello, world!</body></html>',
},
arguments: { url: server.PREFIX },
})).toContainTextContent(`Hello, world!`);
const files = await fs.promises.readdir(config.browser!.userDataDir!);

View File

@@ -16,17 +16,26 @@
import { test, expect } from './fixtures.js';
test('browser_console_messages', async ({ client }) => {
test('browser_console_messages', async ({ client, server }) => {
server.setContent('/', `
<!DOCTYPE html>
<html>
<script>
console.log("Hello, world!");
console.error("Error");
</script>
</html>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><script>console.log("Hello, world!");console.error("Error"); </script></html>',
url: server.PREFIX,
},
});
const resource = await client.callTool({
name: 'browser_console_messages',
arguments: {},
});
expect(resource).toHaveTextContent([
'[LOG] Hello, world!',

View File

@@ -16,42 +16,43 @@
import { test, expect } from './fixtures.js';
test('browser_navigate', async ({ client }) => {
test('browser_navigate', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
arguments: { url: server.HELLO_WORLD },
})).toHaveTextContent(`
- Ran Playwright code:
\`\`\`js
// Navigate to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
await page.goto('data:text/html,<html><title>Title</title><body>Hello, world!</body></html>');
// Navigate to ${server.HELLO_WORLD}
await page.goto('${server.HELLO_WORLD}');
\`\`\`
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
- Page URL: ${server.HELLO_WORLD}
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- generic [ref=s1e2]: Hello, world!
- generic [ref=e1]: Hello, world!
\`\`\`
`
);
});
test('browser_click', async ({ client }) => {
test('browser_click', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<button>Submit</button>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button>Submit</button></html>',
},
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Submit button',
ref: 's1e3',
ref: 'e2',
},
})).toHaveTextContent(`
- Ran Playwright code:
@@ -60,28 +61,34 @@ test('browser_click', async ({ client }) => {
await page.getByRole('button', { name: 'Submit' }).click();
\`\`\`
- Page URL: data:text/html,<html><title>Title</title><button>Submit</button></html>
- Page URL: ${server.PREFIX}
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- button "Submit" [ref=s2e3]
- button "Submit" [ref=e2]
\`\`\`
`);
});
test('browser_select_option', async ({ client }) => {
test('browser_select_option', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<select>
<option value="foo">Foo</option>
<option value="bar">Bar</option>
</select>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>',
},
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_select_option',
arguments: {
element: 'Select',
ref: 's1e3',
ref: 'e2',
values: ['bar'],
},
})).toHaveTextContent(`
@@ -91,30 +98,37 @@ test('browser_select_option', async ({ client }) => {
await page.getByRole('combobox').selectOption(['bar']);
\`\`\`
- Page URL: data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>
- Page URL: ${server.PREFIX}
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- combobox [ref=s2e3]:
- combobox [ref=e2]:
- option "Foo"
- option "Bar" [selected]
\`\`\`
`);
});
test('browser_select_option (multiple)', async ({ client }) => {
test('browser_select_option (multiple)', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<select multiple>
<option value="foo">Foo</option>
<option value="bar">Bar</option>
<option value="baz">Baz</option>
</select>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><select multiple><option value="foo">Foo</option><option value="bar">Bar</option><option value="baz">Baz</option></select></html>',
},
arguments: { url: server.PREFIX },
});
expect(await client.callTool({
name: 'browser_select_option',
arguments: {
element: 'Select',
ref: 's1e3',
ref: 'e2',
values: ['bar', 'baz'],
},
})).toHaveTextContent(`
@@ -124,52 +138,62 @@ test('browser_select_option (multiple)', async ({ client }) => {
await page.getByRole('listbox').selectOption(['bar', 'baz']);
\`\`\`
- Page URL: data:text/html,<html><title>Title</title><select multiple><option value="foo">Foo</option><option value="bar">Bar</option><option value="baz">Baz</option></select></html>
- Page URL: ${server.PREFIX}
- Page Title: Title
- Page Snapshot
\`\`\`yaml
- listbox [ref=s2e3]:
- option "Foo" [ref=s2e4]
- option "Bar" [selected] [ref=s2e5]
- option "Baz" [selected] [ref=s2e6]
- listbox [ref=e2]:
- option "Foo" [ref=e3]
- option "Bar" [selected] [ref=e4]
- option "Baz" [selected] [ref=e5]
\`\`\`
`);
});
test('browser_type', async ({ client }) => {
test('browser_type', async ({ client, server }) => {
server.setContent('/', `
<!DOCTYPE html>
<html>
<input type='keypress' onkeypress="console.log('Key pressed:', event.key, ', Text:', event.target.value)"></input>
</html>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: `data:text/html,<input type='keypress' onkeypress="console.log('Key pressed:', event.key, ', Text:', event.target.value)"></input>`,
url: server.PREFIX,
},
});
await client.callTool({
name: 'browser_type',
arguments: {
element: 'textbox',
ref: 's1e3',
ref: 'e2',
text: 'Hi!',
submit: true,
},
});
expect(await client.callTool({
name: 'browser_console_messages',
arguments: {},
})).toHaveTextContent('[LOG] Key pressed: Enter , Text: Hi!');
});
test('browser_type (slowly)', async ({ client }) => {
test('browser_type (slowly)', async ({ client, server }) => {
server.setContent('/', `
<input type='text' onkeydown="console.log('Key pressed:', event.key, 'Text:', event.target.value)"></input>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: `data:text/html,<input type='text' onkeydown="console.log('Key pressed:', event.key, 'Text:', event.target.value)"></input>`,
url: server.PREFIX,
},
});
await client.callTool({
name: 'browser_type',
arguments: {
element: 'textbox',
ref: 's1e3',
ref: 'e2',
text: 'Hi!',
submit: true,
slowly: true,
@@ -177,7 +201,6 @@ test('browser_type (slowly)', async ({ client }) => {
});
expect(await client.callTool({
name: 'browser_console_messages',
arguments: {},
})).toHaveTextContent([
'[LOG] Key pressed: H Text: ',
'[LOG] Key pressed: i Text: H',
@@ -186,12 +209,18 @@ test('browser_type (slowly)', async ({ client }) => {
].join('\n'));
});
test('browser_resize', async ({ client }) => {
test('browser_resize', async ({ client, server }) => {
server.setContent('/', `
<title>Resize Test</title>
<body>
<div id="size">Waiting for resize...</div>
<script>new ResizeObserver(() => { document.getElementById("size").textContent = \`Window size: \${window.innerWidth}x\${window.innerHeight}\`; }).observe(document.body);
</script>
</body>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Resize Test</title><body><div id="size">Waiting for resize...</div><script>new ResizeObserver(() => { document.getElementById("size").textContent = `Window size: ${window.innerWidth}x${window.innerHeight}`; }).observe(document.body);</script></body></html>',
},
arguments: { url: server.PREFIX },
});
const response = await client.callTool({
@@ -206,5 +235,5 @@ test('browser_resize', async ({ client }) => {
// Resize browser window to 390x780
await page.setViewportSize({ width: 390, height: 780 });
\`\`\``);
await expect.poll(() => client.callTool({ name: 'browser_snapshot', arguments: {} })).toContainTextContent('Window size: 390x780');
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780');
});

View File

@@ -19,19 +19,18 @@ import { test, expect } from './fixtures.js';
// https://github.com/microsoft/playwright/issues/35663
test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
test('alert dialog', async ({ client }) => {
test('alert dialog', async ({ client, server }) => {
server.setContent('/', `<button onclick="alert('Alert')">Button</button>`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button onclick="alert(\'Alert\')">Button</button></html>',
},
})).toContainTextContent('- button "Button" [ref=s1e3]');
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's1e3',
ref: 'e2',
},
})).toHaveTextContent(`- Ran Playwright code:
\`\`\`js
@@ -55,29 +54,35 @@ await page.getByRole('button', { name: 'Button' }).click();
// <internal code to handle "alert" dialog>
\`\`\`
- Page URL: data:text/html,<html><title>Title</title><button onclick="alert('Alert')">Button</button></html>
- Page Title: Title
- Page URL: ${server.PREFIX}
- Page Title:
- Page Snapshot
\`\`\`yaml
- button "Button" [ref=s2e3]
- button "Button" [ref=e2]
\`\`\`
`);
});
test('two alert dialogs', async ({ client }) => {
test('two alert dialogs', async ({ client, server }) => {
test.fixme(true, 'Race between the dialog and ariaSnapshot');
server.setContent('/', `
<title>Title</title>
<body>
<button onclick="alert('Alert 1');alert('Alert 2');">Button</button>
</body>
`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button onclick="alert(\'Alert 1\');alert(\'Alert 2\');">Button</button></html>',
},
})).toContainTextContent('- button "Button" [ref=s1e3]');
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's1e3',
ref: 'e2',
},
})).toHaveTextContent(`- Ran Playwright code:
\`\`\`js
@@ -98,19 +103,24 @@ await page.getByRole('button', { name: 'Button' }).click();
expect(result).not.toContainTextContent('### Modal state');
});
test('confirm dialog (true)', async ({ client }) => {
test('confirm dialog (true)', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<body>
<button onclick="document.body.textContent = confirm('Confirm')">Button</button>
</body>
`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = confirm(\'Confirm\')">Button</button></html>',
},
})).toContainTextContent('- button "Button" [ref=s1e3]');
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's1e3',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
@@ -126,23 +136,28 @@ test('confirm dialog (true)', async ({ client }) => {
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- generic [ref=s2e2]: "true"
- generic [ref=e1]: "true"
\`\`\``);
});
test('confirm dialog (false)', async ({ client }) => {
test('confirm dialog (false)', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<body>
<button onclick="document.body.textContent = confirm('Confirm')">Button</button>
</body>
`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = confirm(\'Confirm\')">Button</button></html>',
},
})).toContainTextContent('- button "Button" [ref=s1e3]');
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's1e3',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- ["confirm" dialog with message "Confirm"]: can be handled by the "browser_handle_dialog" tool`);
@@ -156,23 +171,28 @@ test('confirm dialog (false)', async ({ client }) => {
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- generic [ref=s2e2]: "false"
- generic [ref=e1]: "false"
\`\`\``);
});
test('prompt dialog', async ({ client }) => {
test('prompt dialog', async ({ client, server }) => {
server.setContent('/', `
<title>Title</title>
<body>
<button onclick="document.body.textContent = prompt('Prompt')">Button</button>
</body>
`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button onclick="document.body.textContent = prompt(\'Prompt\')">Button</button></html>',
},
})).toContainTextContent('- button "Button" [ref=s1e3]');
arguments: { url: server.PREFIX },
})).toContainTextContent('- button "Button" [ref=e2]');
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's1e3',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- ["prompt" dialog with message "Prompt"]: can be handled by the "browser_handle_dialog" tool`);
@@ -187,6 +207,6 @@ test('prompt dialog', async ({ client }) => {
expect(result).toContainTextContent(`- Page Snapshot
\`\`\`yaml
- generic [ref=s2e2]: Answer
- generic [ref=e1]: Answer
\`\`\``);
});

View File

@@ -18,16 +18,20 @@ import { test, expect } from './fixtures.js';
import fs from 'fs/promises';
import path from 'path';
test('browser_file_upload', async ({ client }) => {
test('browser_file_upload', async ({ client, localOutputPath, server }) => {
server.setContent('/', `
<input type="file" />
<button>Button</button>
`, 'text/html');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>',
},
arguments: { url: server.PREFIX },
})).toContainTextContent(`
\`\`\`yaml
- button "Choose File" [ref=s1e3]
- button "Button" [ref=s1e4]
- generic [ref=e1]:
- button "Choose File" [ref=e2]
- button "Button" [ref=e3]
\`\`\``);
{
@@ -45,12 +49,12 @@ The tool "browser_file_upload" can only be used when there is related modal stat
name: 'browser_click',
arguments: {
element: 'Textbox',
ref: 's1e3',
ref: 'e2',
},
})).toContainTextContent(`### Modal state
- [File chooser]: can be handled by the "browser_file_upload" tool`);
const filePath = test.info().outputPath('test.txt');
const filePath = localOutputPath('test.txt');
await fs.writeFile(filePath, 'Hello, world!');
{
@@ -64,8 +68,9 @@ The tool "browser_file_upload" can only be used when there is related modal stat
expect(response).not.toContainTextContent('### Modal state');
expect(response).toContainTextContent(`
\`\`\`yaml
- button "Choose File" [ref=s3e3]
- button "Button" [ref=s3e4]
- generic [ref=e1]:
- button "Choose File" [ref=e2]
- button "Button" [ref=e3]
\`\`\``);
}
@@ -74,7 +79,7 @@ The tool "browser_file_upload" can only be used when there is related modal stat
name: 'browser_click',
arguments: {
element: 'Textbox',
ref: 's3e3',
ref: 'e2',
},
});
@@ -86,7 +91,7 @@ The tool "browser_file_upload" can only be used when there is related modal stat
name: 'browser_click',
arguments: {
element: 'Button',
ref: 's4e4',
ref: 'e3',
},
});
@@ -96,26 +101,27 @@ The tool "browser_file_upload" can only be used when there is related modal stat
}
});
test('clicking on download link emits download', async ({ startClient }, testInfo) => {
const outputDir = testInfo.outputPath('output');
test('clicking on download link emits download', async ({ startClient, localOutputPath, server }) => {
const outputDir = localOutputPath('output');
const client = await startClient({
config: { outputDir },
});
server.setContent('/', `<a href="/download" download="test.txt">Download</a>`, 'text/html');
server.setContent('/download', 'Data', 'text/plain');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<a href="data:text/plain,Hello world!" download="test.txt">Download</a>',
},
})).toContainTextContent('- link "Download" [ref=s1e3]');
arguments: { url: server.PREFIX },
})).toContainTextContent('- link "Download" [ref=e2]');
await client.callTool({
name: 'browser_click',
arguments: {
element: 'Download link',
ref: 's1e3',
ref: 'e2',
},
});
await expect.poll(() => client.callTool({ name: 'browser_snapshot', arguments: {} })).toContainTextContent(`
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`
### Downloads
- Downloaded file test.txt to ${path.join(outputDir, 'test.txt')}`);
});
@@ -133,7 +139,7 @@ test('navigating to download link emits download', async ({ client, server, mcpB
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: server.PREFIX + '/download',
url: server.PREFIX + 'download',
},
})).toContainTextContent('### Downloads');
});

View File

@@ -29,6 +29,7 @@ import type { Config } from '../config';
export type TestOptions = {
mcpBrowser: string | undefined;
mcpMode: 'docker' | undefined;
};
type TestFixtures = {
@@ -40,6 +41,7 @@ type TestFixtures = {
server: TestServer;
httpsServer: TestServer;
mcpHeadless: boolean;
localOutputPath: (filePath: string) => string;
};
type WorkerFixtures = {
@@ -56,12 +58,13 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
await use(await startClient({ args: ['--vision'] }));
},
startClient: async ({ mcpHeadless, mcpBrowser }, use, testInfo) => {
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
const userDataDir = testInfo.outputPath('user-data-dir');
const configDir = path.dirname(test.info().config.configFile!);
let client: Client | undefined;
await use(async options => {
const args = ['--user-data-dir', userDataDir];
const args = ['--user-data-dir', path.relative(configDir, userDataDir)];
if (mcpHeadless)
args.push('--headless');
if (mcpBrowser)
@@ -71,15 +74,11 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
if (options?.config) {
const configFile = testInfo.outputPath('config.json');
await fs.promises.writeFile(configFile, JSON.stringify(options.config, null, 2));
args.push(`--config=${configFile}`);
args.push(`--config=${path.relative(configDir, configFile)}`);
}
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
const transport = new StdioClientTransport({
command: 'node',
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
});
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
const transport = createTransport(args, mcpMode);
await client.connect(transport);
await client.ping();
return client;
@@ -121,7 +120,12 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
});
return `http://localhost:${port}`;
});
browserProcess?.kill();
await new Promise<void>(resolve => {
if (!browserProcess)
return resolve();
browserProcess.on('exit', () => resolve());
browserProcess.kill();
});
},
mcpHeadless: async ({ headless }, use) => {
@@ -130,6 +134,15 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
mcpBrowser: ['chrome', { 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) => {
const port = 8907 + workerInfo.workerIndex * 4;
const server = await TestServer.create(port);
@@ -156,6 +169,23 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
},
});
function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) {
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
if (mcpMode === 'docker') {
const dockerArgs = ['run', '--rm', '-i', '--network=host', '-v', `${test.info().project.outputDir}:/app/test-results`];
return new StdioClientTransport({
command: 'docker',
args: [...dockerArgs, 'playwright-mcp-dev:latest', ...args],
});
}
return new StdioClientTransport({
command: 'node',
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
cwd: path.join(path.dirname(__filename), '..'),
});
}
type Response = Awaited<ReturnType<Client['callTool']>>;
export const expect = baseExpect.extend({

View File

@@ -20,6 +20,7 @@ for (const mcpHeadless of [false, true]) {
test.describe(`mcpHeadless: ${mcpHeadless}`, () => {
test.use({ mcpHeadless });
test.skip(process.platform === 'linux', 'Auto-detection wont let this test run on linux');
test.skip(({ mcpMode, mcpHeadless }) => mcpMode === 'docker' && !mcpHeadless, 'Headed mode is not supported in docker');
test('browser', async ({ client, server, mcpBrowser }) => {
test.skip(!['chrome', 'msedge', 'chromium'].includes(mcpBrowser ?? ''), 'Only chrome is supported for this test');
server.route('/', (req, res) => {

View File

@@ -24,19 +24,21 @@ test('stitched aria frames', async ({ client }) => {
},
})).toContainTextContent(`
\`\`\`yaml
- heading "Hello" [level=1] [ref=s1e3]
- iframe [ref=s1e4]:
- button "World" [ref=f1s1e3]
- main [ref=f1s1e4]:
- iframe [ref=f1s1e5]:
- paragraph [ref=f2s1e3]: Nested
- generic [ref=e1]:
- heading "Hello" [level=1] [ref=e2]
- iframe [ref=e3]:
- generic [ref=f1e1]:
- button "World" [ref=f1e2]
- main [ref=f1e3]:
- iframe [ref=f1e4]:
- paragraph [ref=f2e2]: Nested
\`\`\``);
expect(await client.callTool({
name: 'browser_click',
arguments: {
element: 'World',
ref: 'f1s1e3',
ref: 'f1e2',
},
})).toContainTextContent(`// Click World`);
});

View File

@@ -20,6 +20,5 @@ test('browser_install', async ({ client, mcpBrowser }) => {
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
expect(await client.callTool({
name: 'browser_install',
arguments: {},
})).toContainTextContent(`No open pages available.`);
});

View File

@@ -16,34 +16,27 @@
import { test, expect } from './fixtures.js';
test('test reopen browser', async ({ client }) => {
test('test reopen browser', async ({ client, server }) => {
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
arguments: { url: server.HELLO_WORLD },
});
expect(await client.callTool({
name: 'browser_close',
arguments: {},
})).toContainTextContent('No open pages available');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
});
test('executable path', async ({ startClient }) => {
test('executable path', async ({ startClient, server }) => {
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>',
},
arguments: { url: server.HELLO_WORLD },
});
expect(response).toContainTextContent(`executable doesn't exist`);
});

View File

@@ -17,15 +17,11 @@
import { test, expect } from './fixtures.js';
test('browser_network_requests', async ({ client, server }) => {
server.route('/', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<button onclick="fetch('/json')">Click me</button>`);
});
server.setContent('/', `
<button onclick="fetch('/json')">Click me</button>
`, 'text/html');
server.route('/json', (req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ name: 'John Doe' }));
});
server.setContent('/json', JSON.stringify({ name: 'John Doe' }), 'application/json');
await client.callTool({
name: 'browser_navigate',
@@ -38,12 +34,12 @@ test('browser_network_requests', async ({ client, server }) => {
name: 'browser_click',
arguments: {
element: 'Click me button',
ref: 's1e3',
ref: 'e2',
},
});
await expect.poll(() => client.callTool({
name: 'browser_network_requests',
arguments: {},
})).toHaveTextContent(`[GET] http://localhost:${server.PORT}/json => [200] OK`);
})).toHaveTextContent(`[GET] ${`${server.PREFIX}`} => [200] OK
[GET] ${`${server.PREFIX}json`} => [200] OK`);
});

View File

@@ -14,15 +14,15 @@
* limitations under the License.
*/
import fs from 'fs';
import { test, expect } from './fixtures.js';
test('save as pdf unavailable', async ({ startClient }) => {
test('save as pdf unavailable', async ({ startClient, server }) => {
const client = await startClient({ args: ['--caps="no-pdf"'] });
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
arguments: { url: server.HELLO_WORLD },
});
expect(await client.callTool({
@@ -30,18 +30,49 @@ test('save as pdf unavailable', async ({ startClient }) => {
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
});
test('save as pdf', async ({ client, mcpBrowser }) => {
test('save as pdf', async ({ client, mcpBrowser, server }) => {
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
const response = await client.callTool({
name: 'browser_pdf_save',
arguments: {},
});
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
});
test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server, localOutputPath }) => {
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
const outputDir = localOutputPath('output');
const client = await startClient({
config: { outputDir },
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
expect(await client.callTool({
name: 'browser_pdf_save',
arguments: {
filename: 'output.pdf',
},
})).toEqual({
content: [
{
type: 'text',
text: expect.stringContaining(`output.pdf`),
},
],
});
const files = [...fs.readdirSync(outputDir)];
expect(fs.existsSync(outputDir)).toBeTruthy();
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/^output.pdf$/);
});

View File

@@ -31,11 +31,8 @@ const fetchPage = async (client: Client, url: string) => {
};
test('default to allow all', async ({ server, client }) => {
server.route('/ppp', (_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('content:PPP');
});
const result = await fetchPage(client, server.PREFIX + '/ppp');
server.setContent('/ppp', 'content:PPP', 'text/html');
const result = await fetchPage(client, server.PREFIX + 'ppp');
expect(result).toContain('content:PPP');
});
@@ -48,14 +45,11 @@ test('blocked works', async ({ startClient }) => {
});
test('allowed works', async ({ server, startClient }) => {
server.route('/ppp', (_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('content:PPP');
});
server.setContent('/ppp', 'content:PPP', 'text/html');
const client = await startClient({
args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`]
});
const result = await fetchPage(client, server.PREFIX + '/ppp');
const result = await fetchPage(client, server.PREFIX + 'ppp');
expect(result).toContain('content:PPP');
});
@@ -79,13 +73,10 @@ test('allowed without blocked blocks all non-explicitly specified origins', asyn
});
test('blocked without allowed allows non-explicitly specified origins', async ({ server, startClient }) => {
server.route('/ppp', (_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('content:PPP');
});
server.setContent('/ppp', 'content:PPP', 'text/html');
const client = await startClient({
args: ['--blocked-origins', 'example.com'],
});
const result = await fetchPage(client, server.PREFIX + '/ppp');
const result = await fetchPage(client, server.PREFIX + 'ppp');
expect(result).toContain('content:PPP');
});

View File

@@ -18,17 +18,14 @@ import fs from 'fs';
import { test, expect } from './fixtures.js';
test('browser_take_screenshot (viewport)', async ({ client }) => {
test('browser_take_screenshot (viewport)', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`Navigate to data:text/html`);
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
expect(await client.callTool({
name: 'browser_take_screenshot',
arguments: {},
})).toEqual({
content: [
{
@@ -44,19 +41,17 @@ test('browser_take_screenshot (viewport)', async ({ client }) => {
});
});
test('browser_take_screenshot (element)', async ({ client }) => {
test('browser_take_screenshot (element)', async ({ client, server }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><button>Hello, world!</button></html>',
},
})).toContainTextContent(`[ref=s1e3]`);
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`[ref=e1]`);
expect(await client.callTool({
name: 'browser_take_screenshot',
arguments: {
element: 'hello button',
ref: 's1e3',
ref: 'e1',
},
})).toEqual({
content: [
@@ -66,57 +61,110 @@ test('browser_take_screenshot (element)', async ({ client }) => {
type: 'image',
},
{
text: expect.stringContaining(`page.getByRole('button', { name: 'Hello, world!' }).screenshot`),
text: expect.stringContaining(`page.getByText('Hello, world!').screenshot`),
type: 'text',
},
],
});
});
test('--output-dir should work', async ({ startClient }, testInfo) => {
const outputDir = testInfo.outputPath('output');
test('--output-dir should work', async ({ startClient, localOutputPath, server }) => {
const outputDir = localOutputPath('output');
const client = await startClient({
args: ['--output-dir', outputDir],
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`Navigate to data:text/html`);
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
await client.callTool({
name: 'browser_take_screenshot',
arguments: {},
});
expect(fs.existsSync(outputDir)).toBeTruthy();
expect([...fs.readdirSync(outputDir)]).toHaveLength(1);
});
for (const raw of [undefined, true]) {
test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, localOutputPath, server }) => {
const ext = raw ? 'png' : 'jpeg';
const outputDir = localOutputPath('output');
const client = await startClient({
config: { outputDir },
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
})).toContainTextContent(`Navigate to http://localhost`);
test('browser_take_screenshot (outputDir)', async ({ startClient }, testInfo) => {
const outputDir = testInfo.outputPath('output');
expect(await client.callTool({
name: 'browser_take_screenshot',
arguments: { raw },
})).toEqual({
content: [
{
data: expect.any(String),
mimeType: `image/${ext}`,
type: 'image',
},
{
text: expect.stringMatching(
new RegExp(`page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}\\-\\d{3}Z\\.${ext}`)
),
type: 'text',
},
],
});
const files = [...fs.readdirSync(outputDir)];
expect(fs.existsSync(outputDir)).toBeTruthy();
expect(files).toHaveLength(1);
expect(files[0]).toMatch(
new RegExp(`^page-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z\\.${ext}$`)
);
});
}
test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, localOutputPath, server }) => {
const outputDir = localOutputPath('output');
const client = await startClient({
config: { outputDir },
});
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`Navigate to data:text/html`);
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
await client.callTool({
expect(await client.callTool({
name: 'browser_take_screenshot',
arguments: {},
arguments: {
filename: 'output.jpeg',
},
})).toEqual({
content: [
{
data: expect.any(String),
mimeType: 'image/jpeg',
type: 'image',
},
{
text: expect.stringContaining(`output.jpeg`),
type: 'text',
},
],
});
const files = [...fs.readdirSync(outputDir)];
expect(fs.existsSync(outputDir)).toBeTruthy();
expect([...fs.readdirSync(outputDir)]).toHaveLength(1);
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/^output.jpeg$/);
});
test('browser_take_screenshot (noImageResponses)', async ({ startClient }) => {
test('browser_take_screenshot (noImageResponses)', async ({ startClient, server }) => {
const client = await startClient({
config: {
noImageResponses: true,
@@ -125,19 +173,15 @@ test('browser_take_screenshot (noImageResponses)', async ({ startClient }) => {
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`Navigate to data:text/html`);
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
await client.callTool({
name: 'browser_take_screenshot',
arguments: {},
});
expect(await client.callTool({
name: 'browser_take_screenshot',
arguments: {},
})).toEqual({
content: [
{
@@ -148,24 +192,20 @@ test('browser_take_screenshot (noImageResponses)', async ({ startClient }) => {
});
});
test('browser_take_screenshot (cursor)', async ({ startClient }) => {
test('browser_take_screenshot (cursor)', async ({ startClient, server }) => {
const client = await startClient({ clientName: 'cursor:vscode' });
expect(await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
},
})).toContainTextContent(`Navigate to data:text/html`);
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`Navigate to http://localhost`);
await client.callTool({
name: 'browser_take_screenshot',
arguments: {},
});
expect(await client.callTool({
name: 'browser_take_screenshot',
arguments: {},
})).toEqual({
content: [
{

View File

@@ -15,10 +15,18 @@
*/
import url from 'node:url';
import http from 'node:http';
import { spawn } from 'node:child_process';
import path from 'node:path';
import { test as baseTest } from './fixtures.js';
import { expect } from 'playwright/test';
import type { AddressInfo } from 'node:net';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { createConnection } from '@playwright/mcp';
import { test as baseTest, expect } from './fixtures.js';
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
const __filename = url.fileURLToPath(import.meta.url);
@@ -43,9 +51,6 @@ const test = baseTest.extend<{ serverEndpoint: string }>({
});
test('sse transport', async ({ serverEndpoint }) => {
// need dynamic import b/c of some ESM nonsense
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
const transport = new SSEClientTransport(new URL(serverEndpoint));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
@@ -53,12 +58,46 @@ test('sse transport', async ({ serverEndpoint }) => {
});
test('streamable http transport', async ({ serverEndpoint }) => {
// need dynamic import b/c of some ESM nonsense
const { StreamableHTTPClientTransport } = await import('@modelcontextprotocol/sdk/client/streamableHttp.js');
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
const transport = new StreamableHTTPClientTransport(new URL('/mcp', serverEndpoint));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
expect(transport.sessionId, 'has session support').toBeDefined();
});
test('sse transport via public API', async ({ server }) => {
const sessions = new Map<string, SSEServerTransport>();
const mcpServer = http.createServer(async (req, res) => {
if (req.method === 'GET') {
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
const transport = new SSEServerTransport('/sse', res);
sessions.set(transport.sessionId, transport);
await connection.connect(transport);
} else if (req.method === 'POST') {
const url = new URL(`http://localhost${req.url}`);
const sessionId = url.searchParams.get('sessionId');
if (!sessionId) {
res.statusCode = 400;
return res.end('Missing sessionId');
}
const transport = sessions.get(sessionId);
if (!transport) {
res.statusCode = 404;
return res.end('Session not found');
}
void transport.handlePostMessage(req, res);
}
});
await new Promise<void>(resolve => mcpServer.listen(0, () => resolve()));
const serverUrl = `http://localhost:${(mcpServer.address() as AddressInfo).port}/sse`;
const transport = new SSEClientTransport(new URL(serverUrl));
const client = new Client({ name: 'test', version: '1.0.0' });
await client.connect(transport);
await client.ping();
expect(await client.callTool({
name: 'browser_navigate',
arguments: { url: server.HELLO_WORLD },
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
await client.close();
mcpServer.close();
});

View File

@@ -32,7 +32,6 @@ async function createTab(client: Client, title: string, body: string) {
test('list initial tabs', async ({ client }) => {
expect(await client.callTool({
name: 'browser_tab_list',
arguments: {},
})).toHaveTextContent(`### Open tabs
- 1: (current) [] (about:blank)`);
});
@@ -41,7 +40,6 @@ test('list first tab', async ({ client }) => {
await createTab(client, 'Tab one', 'Body one');
expect(await client.callTool({
name: 'browser_tab_list',
arguments: {},
})).toHaveTextContent(`### Open tabs
- 1: [] (about:blank)
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)`);
@@ -63,7 +61,7 @@ test('create new tab', async ({ client }) => {
- Page Title: Tab one
- Page Snapshot
\`\`\`yaml
- generic [ref=s1e2]: Body one
- generic [ref=e1]: Body one
\`\`\``);
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
@@ -82,7 +80,7 @@ test('create new tab', async ({ client }) => {
- Page Title: Tab two
- Page Snapshot
\`\`\`yaml
- generic [ref=s1e2]: Body two
- generic [ref=e1]: Body two
\`\`\``);
});
@@ -110,7 +108,7 @@ test('select tab', async ({ client }) => {
- Page Title: Tab one
- Page Snapshot
\`\`\`yaml
- generic [ref=s2e2]: Body one
- generic [ref=e1]: Body one
\`\`\``);
});
@@ -137,11 +135,13 @@ test('close tab', async ({ client }) => {
- Page Title: Tab one
- Page Snapshot
\`\`\`yaml
- generic [ref=s2e2]: Body one
- generic [ref=e1]: Body one
\`\`\``);
});
test('reuse first tab when navigating', async ({ startClient, cdpEndpoint }) => {
test('reuse first tab when navigating', async ({ startClient, cdpEndpoint, server }) => {
server.setContent('/', `<title>Title</title><body>Body</body>`, 'text/html');
const browser = await chromium.connectOverCDP(await cdpEndpoint());
const [context] = browser.contexts();
const pages = context.pages();
@@ -149,9 +149,7 @@ test('reuse first tab when navigating', async ({ startClient, cdpEndpoint }) =>
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
await client.callTool({
name: 'browser_navigate',
arguments: {
url: 'data:text/html,<title>Title</title><body>Body</body>',
},
arguments: { url: server.PREFIX },
});
expect(pages.length).toBe(1);

View File

@@ -38,6 +38,7 @@ export class TestServer {
readonly PORT: number;
readonly PREFIX: string;
readonly CROSS_PROCESS_PREFIX: string;
readonly HELLO_WORLD: string;
static async create(port: number): Promise<TestServer> {
const server = new TestServer(port);
@@ -67,8 +68,9 @@ export class TestServer {
const same_origin = 'localhost';
const protocol = sslOptions ? 'https' : 'http';
this.PORT = port;
this.PREFIX = `${protocol}://${same_origin}:${port}`;
this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}`;
this.PREFIX = `${protocol}://${same_origin}:${port}/`;
this.CROSS_PROCESS_PREFIX = `${protocol}://${cross_origin}:${port}/`;
this.HELLO_WORLD = `${this.PREFIX}hello-world`;
}
setCSP(path: string, csp: string) {
@@ -88,6 +90,13 @@ export class TestServer {
this._routes.set(path, handler);
}
setContent(path: string, content: string, mimeType: string) {
this.route(path, (req, res) => {
res.writeHead(200, { 'Content-Type': mimeType });
res.end(mimeType === 'text/html' ? `<!DOCTYPE html>${content}` : content);
});
}
redirect(from: string, to: string) {
this.route(from, (req, res) => {
const headers = this._extraHeaders.get(req.url!) || {};
@@ -120,6 +129,15 @@ export class TestServer {
for (const subscriber of this._requestSubscribers.values())
subscriber[rejectSymbol].call(null, error);
this._requestSubscribers.clear();
this.setContent('/favicon.ico', '', 'image/x-icon');
this.setContent('/', ``, 'text/html');
this.setContent('/hello-world', `
<title>Title</title>
<body>Hello, world!</body>
`, 'text/html');
}
_onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
@@ -144,7 +162,11 @@ export class TestServer {
this._requestSubscribers.delete(path);
}
const handler = this._routes.get(path);
if (handler)
if (handler) {
handler.call(null, request, response);
} else {
response.writeHead(404);
response.end();
}
}
}

85
tests/wait.spec.ts Normal file
View File

@@ -0,0 +1,85 @@
/**
* 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';
test('browser_wait_for(text)', async ({ client, server }) => {
server.setContent('/', `
<script>
function update() {
setTimeout(() => {
document.querySelector('div').textContent = 'Text to appear';
}, 1000);
}
</script>
<body>
<button onclick="update()">Click me</button>
<div>Text to disappear</div>
</body>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
await client.callTool({
name: 'browser_click',
arguments: {
element: 'Click me',
ref: 'e2',
},
});
expect(await client.callTool({
name: 'browser_wait_for',
arguments: { text: 'Text to appear' },
})).toContainTextContent(`- generic [ref=e3]: Text to appear`);
});
test('browser_wait_for(textGone)', async ({ client, server }) => {
server.setContent('/', `
<script>
function update() {
setTimeout(() => {
document.querySelector('div').textContent = 'Text to appear';
}, 1000);
}
</script>
<body>
<button onclick="update()">Click me</button>
<div>Text to disappear</div>
</body>
`, 'text/html');
await client.callTool({
name: 'browser_navigate',
arguments: { url: server.PREFIX },
});
await client.callTool({
name: 'browser_click',
arguments: {
element: 'Click me',
ref: 'e2',
},
});
expect(await client.callTool({
name: 'browser_wait_for',
arguments: { textGone: 'Text to disappear' },
})).toContainTextContent(`- generic [ref=e3]: Text to appear`);
});

View File

@@ -1,7 +1,6 @@
{
"compilerOptions": {
"target": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
"moduleResolution": "nodenext",
"strict": true,