Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fea3f26e85 | ||
|
|
dd5b41f1d8 | ||
|
|
05dc5d915b | ||
|
|
65a229c79f | ||
|
|
84664d4b09 | ||
|
|
445170a76b | ||
|
|
c28b480b51 | ||
|
|
65716b60dd | ||
|
|
75f74a54bc | ||
|
|
ef41c626ef | ||
|
|
95ca08fdb7 | ||
|
|
053c2f3d32 | ||
|
|
57b3c14276 | ||
|
|
85c85bd2fb | ||
|
|
09ba7989c3 | ||
|
|
a115c31953 |
48
.github/workflows/ci.yml
vendored
48
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
22
README.md
22
README.md
@@ -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
2
config.d.ts
vendored
@@ -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.
|
||||
|
||||
2
index.js
2
index.js
@@ -16,4 +16,4 @@
|
||||
*/
|
||||
|
||||
import { createConnection } from './lib/index';
|
||||
export default { createConnection };
|
||||
export { createConnection };
|
||||
|
||||
43
package-lock.json
generated
43
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' } },
|
||||
],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
16
src/tab.ts
16
src/tab.ts
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>`],
|
||||
|
||||
@@ -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], {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
]));
|
||||
});
|
||||
|
||||
|
||||
@@ -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!`);
|
||||
});
|
||||
|
||||
@@ -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!);
|
||||
|
||||
@@ -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!',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
|
||||
@@ -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.`);
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
|
||||
@@ -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$/);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
85
tests/wait.spec.ts
Normal 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`);
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "nodenext",
|
||||
"strict": true,
|
||||
|
||||
Reference in New Issue
Block a user