Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc48600a49 | ||
|
|
0d6bb2f547 | ||
|
|
795a9d578a | ||
|
|
4a19e18999 | ||
|
|
4d59e06184 | ||
|
|
6891a525b3 | ||
|
|
0f7fd1362f | ||
|
|
de08c24b96 | ||
|
|
71e51ea42a | ||
|
|
0c5a104e0f | ||
|
|
606b898a71 | ||
|
|
e729494bd9 | ||
|
|
77080e8ca4 | ||
|
|
31ac1ed191 | ||
|
|
b8ff009b0a | ||
|
|
42167878fb |
13
.github/workflows/ci.yml
vendored
13
.github/workflows/ci.yml
vendored
@@ -8,7 +8,11 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-test:
|
build-and-test:
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -22,6 +26,13 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
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.
|
||||||
|
run: npx playwright install msedge
|
||||||
|
|
||||||
- name: Run linting
|
- name: Run linting
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
|
|||||||
3
.github/workflows/publish.yml
vendored
3
.github/workflows/publish.yml
vendored
@@ -15,9 +15,10 @@ jobs:
|
|||||||
node-version: 18
|
node-version: 18
|
||||||
registry-url: https://registry.npmjs.org/
|
registry-url: https://registry.npmjs.org/
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
- run: npx playwright install --with-deps
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
- run: npm run test
|
- run: npm run ctest
|
||||||
- run: npm publish --provenance
|
- run: npm publish --provenance
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
lib/
|
lib/
|
||||||
node_modules/
|
node_modules/
|
||||||
test-results/
|
test-results/
|
||||||
|
.vscode/mcp.json
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const urlForWebsites = `vscode:mcp/install?${encodeURIComponent(config)}`;
|
|||||||
const urlForGithub = `https://insiders.vscode.dev/redirect?url=${encodeURIComponent(urlForWebsites)}`;
|
const urlForGithub = `https://insiders.vscode.dev/redirect?url=${encodeURIComponent(urlForWebsites)}`;
|
||||||
-->
|
-->
|
||||||
|
|
||||||
[<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522-y%2522%252C%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
||||||
|
|
||||||
Alternatively, you can install the Playwright MCP server using the VS Code CLI:
|
Alternatively, you can install the Playwright MCP server using the VS Code CLI:
|
||||||
|
|
||||||
@@ -323,6 +323,3 @@ server.connect(transport);
|
|||||||
- **browser_install**
|
- **browser_install**
|
||||||
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
|
- Description: Install the browser specified in the config. Call this if you get an error about the browser not being installed.
|
||||||
- Parameters: None
|
- Parameters: None
|
||||||
|
|
||||||
### Vision Mode
|
|
||||||
|
|
||||||
|
|||||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.10",
|
"version": "0.0.13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.10",
|
"version": "0.0.13",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
"@modelcontextprotocol/sdk": "^1.6.1",
|
||||||
@@ -377,6 +377,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.13.10",
|
"version": "22.13.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
|
||||||
|
"integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1019,6 +1021,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "13.1.0",
|
"version": "13.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
|
||||||
|
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -4188,6 +4192,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.20.0",
|
"version": "6.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
|
||||||
|
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -4376,6 +4382,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.24.2",
|
"version": "3.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
|
||||||
|
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
@@ -4383,6 +4391,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/zod-to-json-schema": {
|
"node_modules/zod-to-json-schema": {
|
||||||
"version": "3.24.4",
|
"version": "3.24.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.4.tgz",
|
||||||
|
"integrity": "sha512-0uNlcvgabyrni9Ag8Vghj21drk7+7tp7VTwwR7KxxXXc/3pbXz2PHlDgj3cICahgF1kHm4dExBFj7BXrZJXzig==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.10",
|
"version": "0.0.13",
|
||||||
"description": "Playwright Tools for MCP",
|
"description": "Playwright Tools for MCP",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"watch": "tsc --watch",
|
"watch": "tsc --watch",
|
||||||
"test": "playwright test",
|
"test": "playwright test",
|
||||||
|
"ctest": "playwright test --project=chrome",
|
||||||
"clean": "rm -rf lib",
|
"clean": "rm -rf lib",
|
||||||
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
|
|
||||||
import { defineConfig } from '@playwright/test';
|
import { defineConfig } from '@playwright/test';
|
||||||
|
|
||||||
|
import type { Project } from '@playwright/test';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './tests',
|
testDir: './tests',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
@@ -23,5 +25,11 @@ export default defineConfig({
|
|||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
reporter: 'list',
|
reporter: 'list',
|
||||||
projects: [{ name: 'default' }],
|
projects: [
|
||||||
|
{ name: 'chrome' },
|
||||||
|
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
|
||||||
|
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||||
|
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||||
|
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||||
|
].filter(Boolean) as Project[],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ type PageOrFrameLocator = playwright.Page | playwright.FrameLocator;
|
|||||||
type RunOptions = {
|
type RunOptions = {
|
||||||
captureSnapshot?: boolean;
|
captureSnapshot?: boolean;
|
||||||
waitForCompletion?: boolean;
|
waitForCompletion?: boolean;
|
||||||
status?: string;
|
|
||||||
noClearFileChooser?: boolean;
|
noClearFileChooser?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,7 +53,7 @@ export class Context {
|
|||||||
|
|
||||||
currentTab(): Tab {
|
currentTab(): Tab {
|
||||||
if (!this._currentTab)
|
if (!this._currentTab)
|
||||||
throw new Error('Navigate to a location to create a tab');
|
throw new Error('No current snapshot available. Capture a snapshot of navigate to a new location first.');
|
||||||
return this._currentTab;
|
return this._currentTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,8 +78,8 @@ export class Context {
|
|||||||
|
|
||||||
async listTabs(): Promise<string> {
|
async listTabs(): Promise<string> {
|
||||||
if (!this._tabs.length)
|
if (!this._tabs.length)
|
||||||
return 'No tabs open';
|
return '### No tabs open';
|
||||||
const lines: string[] = ['Open tabs:'];
|
const lines: string[] = ['### Open tabs'];
|
||||||
for (let i = 0; i < this._tabs.length; i++) {
|
for (let i = 0; i < this._tabs.length; i++) {
|
||||||
const tab = this._tabs[i];
|
const tab = this._tabs[i];
|
||||||
const title = await tab.page.title();
|
const title = await tab.page.title();
|
||||||
@@ -172,6 +171,10 @@ export class Context {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RunResult = {
|
||||||
|
code: string[];
|
||||||
|
};
|
||||||
|
|
||||||
class Tab {
|
class Tab {
|
||||||
readonly context: Context;
|
readonly context: Context;
|
||||||
readonly page: playwright.Page;
|
readonly page: playwright.Page;
|
||||||
@@ -207,37 +210,53 @@ class Tab {
|
|||||||
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
await this.page.waitForLoadState('load', { timeout: 5000 }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(callback: (tab: Tab) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
async run(callback: (tab: Tab) => Promise<RunResult>, options?: RunOptions): Promise<ToolResult> {
|
||||||
|
let runResult: RunResult | undefined;
|
||||||
try {
|
try {
|
||||||
if (!options?.noClearFileChooser)
|
if (!options?.noClearFileChooser)
|
||||||
this._fileChooser = undefined;
|
this._fileChooser = undefined;
|
||||||
if (options?.waitForCompletion)
|
if (options?.waitForCompletion)
|
||||||
await waitForCompletion(this.page, () => callback(this));
|
runResult = await waitForCompletion(this.page, () => callback(this)) ?? undefined;
|
||||||
else
|
else
|
||||||
await callback(this);
|
runResult = await callback(this) ?? undefined;
|
||||||
} finally {
|
} finally {
|
||||||
if (options?.captureSnapshot)
|
if (options?.captureSnapshot)
|
||||||
this._snapshot = await PageSnapshot.create(this.page);
|
this._snapshot = await PageSnapshot.create(this.page);
|
||||||
}
|
}
|
||||||
const tabList = this.context.tabs().length > 1 ? await this.context.listTabs() + '\n\nCurrent tab:' + '\n' : '';
|
|
||||||
const snapshot = this._snapshot?.text({ status: options?.status, hasFileChooser: !!this._fileChooser }) ?? options?.status ?? '';
|
const result: string[] = [];
|
||||||
|
result.push(`- Ran code:
|
||||||
|
\`\`\`js
|
||||||
|
${runResult.code.join('\n')}
|
||||||
|
\`\`\`
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (this.context.tabs().length > 1)
|
||||||
|
result.push(await this.context.listTabs(), '');
|
||||||
|
|
||||||
|
if (this._snapshot) {
|
||||||
|
if (this.context.tabs().length > 1)
|
||||||
|
result.push('### Current tab');
|
||||||
|
result.push(this._snapshot.text({ hasFileChooser: !!this._fileChooser }));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: tabList + snapshot,
|
text: result.join('\n'),
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async runAndWait(callback: (tab: Tab) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
async runAndWait(callback: (tab: Tab) => Promise<RunResult>, options?: RunOptions): Promise<ToolResult> {
|
||||||
return await this.run(callback, {
|
return await this.run(callback, {
|
||||||
waitForCompletion: true,
|
waitForCompletion: true,
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async runAndWaitWithSnapshot(callback: (tab: Tab) => Promise<void>, options?: RunOptions): Promise<ToolResult> {
|
async runAndWaitWithSnapshot(callback: (snapshot: PageSnapshot) => Promise<RunResult>, options?: RunOptions): Promise<ToolResult> {
|
||||||
return await this.run(callback, {
|
return await this.run(tab => callback(tab.lastSnapshot()), {
|
||||||
captureSnapshot: true,
|
captureSnapshot: true,
|
||||||
waitForCompletion: true,
|
waitForCompletion: true,
|
||||||
...options,
|
...options,
|
||||||
@@ -275,13 +294,9 @@ class PageSnapshot {
|
|||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
text(options?: { status?: string, hasFileChooser?: boolean }): string {
|
text(options: { hasFileChooser: boolean }): string {
|
||||||
const results: string[] = [];
|
const results: string[] = [];
|
||||||
if (options?.status) {
|
if (options.hasFileChooser) {
|
||||||
results.push(options.status);
|
|
||||||
results.push('');
|
|
||||||
}
|
|
||||||
if (options?.hasFileChooser) {
|
|
||||||
results.push('- There is a file chooser visible that requires browser_file_upload to be called');
|
results.push('- There is a file chooser visible that requires browser_file_upload to be called');
|
||||||
results.push('');
|
results.push('');
|
||||||
}
|
}
|
||||||
@@ -359,3 +374,7 @@ class PageSnapshot {
|
|||||||
return frame.locator(`aria-ref=${ref}`);
|
return frame.locator(`aria-ref=${ref}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
||||||
|
return (locator as any)._generateLocatorString();
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|||||||
import type { LaunchOptions } from 'playwright';
|
import type { LaunchOptions } from 'playwright';
|
||||||
|
|
||||||
const snapshotTools: Tool[] = [
|
const snapshotTools: Tool[] = [
|
||||||
...common,
|
...common(true),
|
||||||
...files(true),
|
...files(true),
|
||||||
...install,
|
...install,
|
||||||
...keyboard(true),
|
...keyboard(true),
|
||||||
@@ -43,7 +43,7 @@ const snapshotTools: Tool[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const screenshotTools: Tool[] = [
|
const screenshotTools: Tool[] = [
|
||||||
...common,
|
...common(false),
|
||||||
...files(false),
|
...files(false),
|
||||||
...install,
|
...install,
|
||||||
...keyboard(false),
|
...keyboard(false),
|
||||||
|
|||||||
53
src/javascript.ts
Normal file
53
src/javascript.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// adapted from:
|
||||||
|
// - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/utils/isomorphic/stringUtils.ts
|
||||||
|
// - https://github.com/microsoft/playwright/blob/76ee48dc9d4034536e3ec5b2c7ce8be3b79418a8/packages/playwright-core/src/server/codegen/javascript.ts
|
||||||
|
|
||||||
|
// NOTE: this function should not be used to escape any selectors.
|
||||||
|
export function escapeWithQuotes(text: string, char: string = '\'') {
|
||||||
|
const stringified = JSON.stringify(text);
|
||||||
|
const escapedText = stringified.substring(1, stringified.length - 1).replace(/\\"/g, '"');
|
||||||
|
if (char === '\'')
|
||||||
|
return char + escapedText.replace(/[']/g, '\\\'') + char;
|
||||||
|
if (char === '"')
|
||||||
|
return char + escapedText.replace(/["]/g, '\\"') + char;
|
||||||
|
if (char === '`')
|
||||||
|
return char + escapedText.replace(/[`]/g, '`') + char;
|
||||||
|
throw new Error('Invalid escape char');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quote(text: string) {
|
||||||
|
return escapeWithQuotes(text, '\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatObject(value: any, indent = ' '): string {
|
||||||
|
if (typeof value === 'string')
|
||||||
|
return quote(value);
|
||||||
|
if (Array.isArray(value))
|
||||||
|
return `[${value.map(o => formatObject(o)).join(', ')}]`;
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
const keys = Object.keys(value).filter(key => value[key] !== undefined).sort();
|
||||||
|
if (!keys.length)
|
||||||
|
return '{}';
|
||||||
|
const tokens: string[] = [];
|
||||||
|
for (const key of keys)
|
||||||
|
tokens.push(`${key}: ${formatObject(value[key])}`);
|
||||||
|
return `{\n${indent}${tokens.join(`,\n${indent}`)}\n}`;
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ program
|
|||||||
}
|
}
|
||||||
|
|
||||||
const launchOptions: LaunchOptions = {
|
const launchOptions: LaunchOptions = {
|
||||||
headless: !!options.headless,
|
headless: !!(options.headless ?? (os.platform() === 'linux' && !process.env.DISPLAY)),
|
||||||
channel,
|
channel,
|
||||||
executablePath: options.executablePath,
|
executablePath: options.executablePath,
|
||||||
};
|
};
|
||||||
@@ -100,11 +100,15 @@ program
|
|||||||
});
|
});
|
||||||
|
|
||||||
function setupExitWatchdog(serverList: ServerList) {
|
function setupExitWatchdog(serverList: ServerList) {
|
||||||
process.stdin.on('close', async () => {
|
const handleExit = async () => {
|
||||||
setTimeout(() => process.exit(0), 15000);
|
setTimeout(() => process.exit(0), 15000);
|
||||||
await serverList.closeAll();
|
await serverList.closeAll();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
};
|
||||||
|
|
||||||
|
process.stdin.on('close', handleExit);
|
||||||
|
process.on('SIGINT', handleExit);
|
||||||
|
process.on('SIGTERM', handleExit);
|
||||||
}
|
}
|
||||||
|
|
||||||
program.parse(process.argv);
|
program.parse(process.argv);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
|
||||||
import type { Tool } from './tool';
|
import type { Tool, ToolFactory } from './tool';
|
||||||
|
|
||||||
const waitSchema = z.object({
|
const waitSchema = z.object({
|
||||||
time: z.number().describe('The time to wait in seconds'),
|
time: z.number().describe('The time to wait in seconds'),
|
||||||
@@ -62,7 +62,37 @@ const close: Tool = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default [
|
const resizeSchema = z.object({
|
||||||
|
width: z.number().describe('Width of the browser window'),
|
||||||
|
height: z.number().describe('Height of the browser window'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const resize: ToolFactory = captureSnapshot => ({
|
||||||
|
capability: 'core',
|
||||||
|
schema: {
|
||||||
|
name: 'browser_resize',
|
||||||
|
description: 'Resize the browser window',
|
||||||
|
inputSchema: zodToJsonSchema(resizeSchema),
|
||||||
|
},
|
||||||
|
handle: async (context, params) => {
|
||||||
|
const validatedParams = resizeSchema.parse(params);
|
||||||
|
|
||||||
|
const tab = context.currentTab();
|
||||||
|
return await tab.run(async tab => {
|
||||||
|
await tab.page.setViewportSize({ width: validatedParams.width, height: validatedParams.height });
|
||||||
|
const code = [
|
||||||
|
`// Resize browser window to ${validatedParams.width}x${validatedParams.height}`,
|
||||||
|
`await page.setViewportSize({ width: ${validatedParams.width}, height: ${validatedParams.height} });`
|
||||||
|
];
|
||||||
|
return { code };
|
||||||
|
}, {
|
||||||
|
captureSnapshot,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default (captureSnapshot: boolean) => [
|
||||||
close,
|
close,
|
||||||
wait,
|
wait,
|
||||||
|
resize(captureSnapshot)
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -35,8 +35,11 @@ const uploadFile: ToolFactory = captureSnapshot => ({
|
|||||||
const tab = context.currentTab();
|
const tab = context.currentTab();
|
||||||
return await tab.runAndWait(async () => {
|
return await tab.runAndWait(async () => {
|
||||||
await tab.submitFileChooser(validatedParams.paths);
|
await tab.submitFileChooser(validatedParams.paths);
|
||||||
|
const code = [
|
||||||
|
`// <internal code to chose files ${validatedParams.paths.join(', ')}`,
|
||||||
|
];
|
||||||
|
return { code };
|
||||||
}, {
|
}, {
|
||||||
status: `Chose files ${validatedParams.paths.join(', ')}`,
|
|
||||||
captureSnapshot,
|
captureSnapshot,
|
||||||
noClearFileChooser: true,
|
noClearFileChooser: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,8 +34,12 @@ const pressKey: ToolFactory = captureSnapshot => ({
|
|||||||
const validatedParams = pressKeySchema.parse(params);
|
const validatedParams = pressKeySchema.parse(params);
|
||||||
return await context.currentTab().runAndWait(async tab => {
|
return await context.currentTab().runAndWait(async tab => {
|
||||||
await tab.page.keyboard.press(validatedParams.key);
|
await tab.page.keyboard.press(validatedParams.key);
|
||||||
|
const code = [
|
||||||
|
`// Press ${validatedParams.key}`,
|
||||||
|
`await page.keyboard.press('${validatedParams.key}');`,
|
||||||
|
];
|
||||||
|
return { code };
|
||||||
}, {
|
}, {
|
||||||
status: `Pressed key ${validatedParams.key}`,
|
|
||||||
captureSnapshot,
|
captureSnapshot,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,8 +35,12 @@ const navigate: ToolFactory = captureSnapshot => ({
|
|||||||
const currentTab = await context.ensureTab();
|
const currentTab = await context.ensureTab();
|
||||||
return await currentTab.run(async tab => {
|
return await currentTab.run(async tab => {
|
||||||
await tab.navigate(validatedParams.url);
|
await tab.navigate(validatedParams.url);
|
||||||
|
const code = [
|
||||||
|
`// Navigate to ${validatedParams.url}`,
|
||||||
|
`await page.goto('${validatedParams.url}');`,
|
||||||
|
];
|
||||||
|
return { code };
|
||||||
}, {
|
}, {
|
||||||
status: `Navigated to ${validatedParams.url}`,
|
|
||||||
captureSnapshot,
|
captureSnapshot,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -54,8 +58,12 @@ const goBack: ToolFactory = snapshot => ({
|
|||||||
handle: async context => {
|
handle: async context => {
|
||||||
return await context.currentTab().runAndWait(async tab => {
|
return await context.currentTab().runAndWait(async tab => {
|
||||||
await tab.page.goBack();
|
await tab.page.goBack();
|
||||||
|
const code = [
|
||||||
|
`// Navigate back`,
|
||||||
|
`await page.goBack();`,
|
||||||
|
];
|
||||||
|
return { code };
|
||||||
}, {
|
}, {
|
||||||
status: 'Navigated back',
|
|
||||||
captureSnapshot: snapshot,
|
captureSnapshot: snapshot,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -73,8 +81,12 @@ const goForward: ToolFactory = snapshot => ({
|
|||||||
handle: async context => {
|
handle: async context => {
|
||||||
return await context.currentTab().runAndWait(async tab => {
|
return await context.currentTab().runAndWait(async tab => {
|
||||||
await tab.page.goForward();
|
await tab.page.goForward();
|
||||||
|
const code = [
|
||||||
|
`// Navigate forward`,
|
||||||
|
`await page.goForward();`,
|
||||||
|
];
|
||||||
|
return { code };
|
||||||
}, {
|
}, {
|
||||||
status: 'Navigated forward',
|
|
||||||
captureSnapshot: snapshot,
|
captureSnapshot: snapshot,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const screenshot: Tool = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
const tab = context.currentTab();
|
const tab = await context.ensureTab();
|
||||||
const screenshot = await tab.page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' });
|
const screenshot = await tab.page.screenshot({ type: 'jpeg', quality: 50, scale: 'css' });
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }],
|
content: [{ type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }],
|
||||||
@@ -79,11 +79,16 @@ const click: Tool = {
|
|||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
return await context.currentTab().runAndWait(async tab => {
|
return await context.currentTab().runAndWait(async tab => {
|
||||||
const validatedParams = clickSchema.parse(params);
|
const validatedParams = clickSchema.parse(params);
|
||||||
|
const code = [
|
||||||
|
`// Click mouse at coordinates (${validatedParams.x}, ${validatedParams.y})`,
|
||||||
|
`await page.mouse.move(${validatedParams.x}, ${validatedParams.y});`,
|
||||||
|
`await page.mouse.down();`,
|
||||||
|
`await page.mouse.up();`,
|
||||||
|
];
|
||||||
await tab.page.mouse.move(validatedParams.x, validatedParams.y);
|
await tab.page.mouse.move(validatedParams.x, validatedParams.y);
|
||||||
await tab.page.mouse.down();
|
await tab.page.mouse.down();
|
||||||
await tab.page.mouse.up();
|
await tab.page.mouse.up();
|
||||||
}, {
|
return { code };
|
||||||
status: 'Clicked mouse',
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -110,8 +115,14 @@ const drag: Tool = {
|
|||||||
await tab.page.mouse.down();
|
await tab.page.mouse.down();
|
||||||
await tab.page.mouse.move(validatedParams.endX, validatedParams.endY);
|
await tab.page.mouse.move(validatedParams.endX, validatedParams.endY);
|
||||||
await tab.page.mouse.up();
|
await tab.page.mouse.up();
|
||||||
}, {
|
const code = [
|
||||||
status: `Dragged mouse from (${validatedParams.startX}, ${validatedParams.startY}) to (${validatedParams.endX}, ${validatedParams.endY})`,
|
`// Drag mouse from (${validatedParams.startX}, ${validatedParams.startY}) to (${validatedParams.endX}, ${validatedParams.endY})`,
|
||||||
|
`await page.mouse.move(${validatedParams.startX}, ${validatedParams.startY});`,
|
||||||
|
`await page.mouse.down();`,
|
||||||
|
`await page.mouse.move(${validatedParams.endX}, ${validatedParams.endY});`,
|
||||||
|
`await page.mouse.up();`,
|
||||||
|
];
|
||||||
|
return { code };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -132,11 +143,17 @@ const type: Tool = {
|
|||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = typeSchema.parse(params);
|
const validatedParams = typeSchema.parse(params);
|
||||||
return await context.currentTab().runAndWait(async tab => {
|
return await context.currentTab().runAndWait(async tab => {
|
||||||
|
const code = [
|
||||||
|
`// Type ${validatedParams.text}`,
|
||||||
|
`await page.keyboard.type('${validatedParams.text}');`,
|
||||||
|
];
|
||||||
await tab.page.keyboard.type(validatedParams.text);
|
await tab.page.keyboard.type(validatedParams.text);
|
||||||
if (validatedParams.submit)
|
if (validatedParams.submit) {
|
||||||
|
code.push(`// Submit text`);
|
||||||
|
code.push(`await page.keyboard.press('Enter');`);
|
||||||
await tab.page.keyboard.press('Enter');
|
await tab.page.keyboard.press('Enter');
|
||||||
}, {
|
}
|
||||||
status: `Typed text "${validatedParams.text}"`,
|
return { code };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import zodToJsonSchema from 'zod-to-json-schema';
|
|||||||
|
|
||||||
import type * as playwright from 'playwright';
|
import type * as playwright from 'playwright';
|
||||||
import type { Tool } from './tool';
|
import type { Tool } from './tool';
|
||||||
|
import { generateLocator } from '../context';
|
||||||
|
import * as javascript from '../javascript';
|
||||||
|
|
||||||
const snapshot: Tool = {
|
const snapshot: Tool = {
|
||||||
capability: 'core',
|
capability: 'core',
|
||||||
@@ -29,7 +31,11 @@ const snapshot: Tool = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
return await context.currentTab().run(async () => {}, { captureSnapshot: true });
|
const tab = await context.ensureTab();
|
||||||
|
return await tab.run(async () => {
|
||||||
|
const code = [`// <internal code to capture accessibility snapshot>`];
|
||||||
|
return { code };
|
||||||
|
}, { captureSnapshot: true });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -48,11 +54,14 @@ const click: Tool = {
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = elementSchema.parse(params);
|
const validatedParams = elementSchema.parse(params);
|
||||||
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
|
||||||
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
|
const locator = snapshot.refLocator(validatedParams.ref);
|
||||||
|
const code = [
|
||||||
|
`// Click ${validatedParams.element}`,
|
||||||
|
`await page.${await generateLocator(locator)}.click();`
|
||||||
|
];
|
||||||
await locator.click();
|
await locator.click();
|
||||||
}, {
|
return { code };
|
||||||
status: `Clicked "${validatedParams.element}"`,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -74,12 +83,15 @@ const drag: Tool = {
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = dragSchema.parse(params);
|
const validatedParams = dragSchema.parse(params);
|
||||||
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
|
||||||
const startLocator = tab.lastSnapshot().refLocator(validatedParams.startRef);
|
const startLocator = snapshot.refLocator(validatedParams.startRef);
|
||||||
const endLocator = tab.lastSnapshot().refLocator(validatedParams.endRef);
|
const endLocator = snapshot.refLocator(validatedParams.endRef);
|
||||||
|
const code = [
|
||||||
|
`// Drag ${validatedParams.startElement} to ${validatedParams.endElement}`,
|
||||||
|
`await page.${await generateLocator(startLocator)}.dragTo(page.${await generateLocator(endLocator)});`
|
||||||
|
];
|
||||||
await startLocator.dragTo(endLocator);
|
await startLocator.dragTo(endLocator);
|
||||||
}, {
|
return { code };
|
||||||
status: `Dragged "${validatedParams.startElement}" to "${validatedParams.endElement}"`,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -94,11 +106,14 @@ const hover: Tool = {
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = elementSchema.parse(params);
|
const validatedParams = elementSchema.parse(params);
|
||||||
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
|
||||||
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
|
const locator = snapshot.refLocator(validatedParams.ref);
|
||||||
|
const code = [
|
||||||
|
`// Hover over ${validatedParams.element}`,
|
||||||
|
`await page.${await generateLocator(locator)}.hover();`
|
||||||
|
];
|
||||||
await locator.hover();
|
await locator.hover();
|
||||||
}, {
|
return { code };
|
||||||
status: `Hovered over "${validatedParams.element}"`,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -119,16 +134,25 @@ const type: Tool = {
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = typeSchema.parse(params);
|
const validatedParams = typeSchema.parse(params);
|
||||||
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
|
||||||
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
|
const locator = snapshot.refLocator(validatedParams.ref);
|
||||||
if (validatedParams.slowly)
|
|
||||||
|
const code: string[] = [];
|
||||||
|
if (validatedParams.slowly) {
|
||||||
|
code.push(`// Press "${validatedParams.text}" sequentially into "${validatedParams.element}"`);
|
||||||
|
code.push(`await page.${await generateLocator(locator)}.pressSequentially(${javascript.quote(validatedParams.text)});`);
|
||||||
await locator.pressSequentially(validatedParams.text);
|
await locator.pressSequentially(validatedParams.text);
|
||||||
else
|
} else {
|
||||||
|
code.push(`// Fill "${validatedParams.text}" into "${validatedParams.element}"`);
|
||||||
|
code.push(`await page.${await generateLocator(locator)}.fill(${javascript.quote(validatedParams.text)});`);
|
||||||
await locator.fill(validatedParams.text);
|
await locator.fill(validatedParams.text);
|
||||||
if (validatedParams.submit)
|
}
|
||||||
|
if (validatedParams.submit) {
|
||||||
|
code.push(`// Submit text`);
|
||||||
|
code.push(`await page.${await generateLocator(locator)}.press('Enter');`);
|
||||||
await locator.press('Enter');
|
await locator.press('Enter');
|
||||||
}, {
|
}
|
||||||
status: `Typed "${validatedParams.text}" into "${validatedParams.element}"`,
|
return { code };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -147,11 +171,14 @@ const selectOption: Tool = {
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = selectOptionSchema.parse(params);
|
const validatedParams = selectOptionSchema.parse(params);
|
||||||
return await context.currentTab().runAndWaitWithSnapshot(async tab => {
|
return await context.currentTab().runAndWaitWithSnapshot(async snapshot => {
|
||||||
const locator = tab.lastSnapshot().refLocator(validatedParams.ref);
|
const locator = snapshot.refLocator(validatedParams.ref);
|
||||||
|
const code = [
|
||||||
|
`// Select options [${validatedParams.values.join(', ')}] in ${validatedParams.element}`,
|
||||||
|
`await page.${await generateLocator(locator)}.selectOption(${javascript.formatObject(validatedParams.values)});`
|
||||||
|
];
|
||||||
await locator.selectOption(validatedParams.values);
|
await locator.selectOption(validatedParams.values);
|
||||||
}, {
|
return { code };
|
||||||
status: `Selected option in "${validatedParams.element}"`,
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,7 +51,12 @@ const selectTab: ToolFactory = captureSnapshot => ({
|
|||||||
const validatedParams = selectTabSchema.parse(params);
|
const validatedParams = selectTabSchema.parse(params);
|
||||||
await context.selectTab(validatedParams.index);
|
await context.selectTab(validatedParams.index);
|
||||||
const currentTab = await context.ensureTab();
|
const currentTab = await context.ensureTab();
|
||||||
return await currentTab.run(async () => {}, { captureSnapshot });
|
return await currentTab.run(async () => {
|
||||||
|
const code = [
|
||||||
|
`// <internal code to select tab ${validatedParams.index}>`,
|
||||||
|
];
|
||||||
|
return { code };
|
||||||
|
}, { captureSnapshot });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,7 +76,12 @@ const newTab: Tool = {
|
|||||||
await context.newTab();
|
await context.newTab();
|
||||||
if (validatedParams.url)
|
if (validatedParams.url)
|
||||||
await context.currentTab().navigate(validatedParams.url);
|
await context.currentTab().navigate(validatedParams.url);
|
||||||
return await context.currentTab().run(async () => {}, { captureSnapshot: true });
|
return await context.currentTab().run(async () => {
|
||||||
|
const code = [
|
||||||
|
`// <internal code to open a new tab>`,
|
||||||
|
];
|
||||||
|
return { code };
|
||||||
|
}, { captureSnapshot: true });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,9 +99,15 @@ const closeTab: ToolFactory = captureSnapshot => ({
|
|||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const validatedParams = closeTabSchema.parse(params);
|
const validatedParams = closeTabSchema.parse(params);
|
||||||
await context.closeTab(validatedParams.index);
|
await context.closeTab(validatedParams.index);
|
||||||
const currentTab = await context.currentTab();
|
const currentTab = context.currentTab();
|
||||||
if (currentTab)
|
if (currentTab) {
|
||||||
return await currentTab.run(async () => {}, { captureSnapshot });
|
return await currentTab.run(async () => {
|
||||||
|
const code = [
|
||||||
|
`// <internal code to close tab ${validatedParams.index}>`,
|
||||||
|
];
|
||||||
|
return { code };
|
||||||
|
}, { captureSnapshot });
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ test('browser_navigate', async ({ client }) => {
|
|||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
- Ran 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>');
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
@@ -50,7 +54,12 @@ test('browser_click', async ({ client }) => {
|
|||||||
element: 'Submit button',
|
element: 'Submit button',
|
||||||
ref: 's1e3',
|
ref: 's1e3',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`Clicked "Submit button"
|
})).toHaveTextContent(`
|
||||||
|
- Ran code:
|
||||||
|
\`\`\`js
|
||||||
|
// Click Submit button
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><button>Submit</button></html>
|
- Page URL: data:text/html,<html><title>Title</title><button>Submit</button></html>
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
@@ -77,7 +86,12 @@ test('browser_select_option', async ({ client }) => {
|
|||||||
ref: 's1e3',
|
ref: 's1e3',
|
||||||
values: ['bar'],
|
values: ['bar'],
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`Selected option in "Select"
|
})).toHaveTextContent(`
|
||||||
|
- Ran code:
|
||||||
|
\`\`\`js
|
||||||
|
// Select options [bar] in Select
|
||||||
|
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: data:text/html,<html><title>Title</title><select><option value="foo">Foo</option><option value="bar">Bar</option></select></html>
|
||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
@@ -105,7 +119,12 @@ test('browser_select_option (multiple)', async ({ client }) => {
|
|||||||
ref: 's1e3',
|
ref: 's1e3',
|
||||||
values: ['bar', 'baz'],
|
values: ['bar', 'baz'],
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`Selected option in "Select"
|
})).toHaveTextContent(`
|
||||||
|
- Ran code:
|
||||||
|
\`\`\`js
|
||||||
|
// Select options [bar, baz] in Select
|
||||||
|
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: 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 Title: Title
|
- Page Title: Title
|
||||||
@@ -233,3 +252,26 @@ test('browser_type (slowly)', async ({ client }) => {
|
|||||||
].join('\n'),
|
].join('\n'),
|
||||||
}]);
|
}]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('browser_resize', async ({ client }) => {
|
||||||
|
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>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: 'browser_resize',
|
||||||
|
arguments: {
|
||||||
|
width: 390,
|
||||||
|
height: 780,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response).toContainTextContent(`- Ran code:
|
||||||
|
\`\`\`js
|
||||||
|
// Resize browser window to 390x780
|
||||||
|
await page.setViewportSize({ width: 390, height: 780 });
|
||||||
|
\`\`\``);
|
||||||
|
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent('Window size: 390x780');
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ test('test snapshot tool list', async ({ client }) => {
|
|||||||
'browser_navigate',
|
'browser_navigate',
|
||||||
'browser_pdf_save',
|
'browser_pdf_save',
|
||||||
'browser_press_key',
|
'browser_press_key',
|
||||||
|
'browser_resize',
|
||||||
'browser_snapshot',
|
'browser_snapshot',
|
||||||
'browser_tab_close',
|
'browser_tab_close',
|
||||||
'browser_tab_list',
|
'browser_tab_list',
|
||||||
@@ -53,6 +54,7 @@ test('test vision tool list', async ({ visionClient }) => {
|
|||||||
'browser_navigate',
|
'browser_navigate',
|
||||||
'browser_pdf_save',
|
'browser_pdf_save',
|
||||||
'browser_press_key',
|
'browser_press_key',
|
||||||
|
'browser_resize',
|
||||||
'browser_screen_capture',
|
'browser_screen_capture',
|
||||||
'browser_screen_click',
|
'browser_screen_click',
|
||||||
'browser_screen_drag',
|
'browser_screen_drag',
|
||||||
|
|||||||
@@ -23,15 +23,34 @@ test('cdp server', async ({ cdpEndpoint, startClient }) => {
|
|||||||
arguments: {
|
arguments: {
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toContainTextContent(`- text: Hello, world!`);
|
||||||
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
});
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
||||||
- Page Title: Title
|
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'Hello, world!',
|
||||||
|
ref: 'f0',
|
||||||
|
},
|
||||||
|
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot of navigate to a new location first.`);
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_snapshot',
|
||||||
|
arguments: {},
|
||||||
|
})).toHaveTextContent(`
|
||||||
|
- Ran code:
|
||||||
|
\`\`\`js
|
||||||
|
// <internal code to capture accessibility snapshot>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
- Page URL: data:text/html,hello world
|
||||||
|
- Page Title:
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- text: Hello, world!
|
- text: hello world
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`
|
`);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { chromium } from 'playwright';
|
|||||||
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
type Fixtures = {
|
type Fixtures = {
|
||||||
client: Client;
|
client: Client;
|
||||||
@@ -27,6 +28,10 @@ type Fixtures = {
|
|||||||
startClient: (options?: { args?: string[] }) => Promise<Client>;
|
startClient: (options?: { args?: string[] }) => Promise<Client>;
|
||||||
wsEndpoint: string;
|
wsEndpoint: string;
|
||||||
cdpEndpoint: string;
|
cdpEndpoint: string;
|
||||||
|
|
||||||
|
// Cli options.
|
||||||
|
mcpHeadless: boolean;
|
||||||
|
mcpBrowser: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const test = baseTest.extend<Fixtures>({
|
export const test = baseTest.extend<Fixtures>({
|
||||||
@@ -39,12 +44,16 @@ export const test = baseTest.extend<Fixtures>({
|
|||||||
await use(await startClient({ args: ['--vision'] }));
|
await use(await startClient({ args: ['--vision'] }));
|
||||||
},
|
},
|
||||||
|
|
||||||
startClient: async ({ }, use, testInfo) => {
|
startClient: async ({ mcpHeadless, mcpBrowser }, use, testInfo) => {
|
||||||
const userDataDir = testInfo.outputPath('user-data-dir');
|
const userDataDir = testInfo.outputPath('user-data-dir');
|
||||||
let client: StdioClientTransport | undefined;
|
let client: StdioClientTransport | undefined;
|
||||||
|
|
||||||
use(async options => {
|
use(async options => {
|
||||||
const args = ['--headless', '--user-data-dir', userDataDir];
|
const args = ['--user-data-dir', userDataDir];
|
||||||
|
if (mcpHeadless)
|
||||||
|
args.push('--headless');
|
||||||
|
if (mcpBrowser)
|
||||||
|
args.push(`--browser=${mcpBrowser}`);
|
||||||
if (options?.args)
|
if (options?.args)
|
||||||
args.push(...options.args);
|
args.push(...options.args);
|
||||||
const transport = new StdioClientTransport({
|
const transport = new StdioClientTransport({
|
||||||
@@ -68,13 +77,32 @@ export const test = baseTest.extend<Fixtures>({
|
|||||||
|
|
||||||
cdpEndpoint: async ({ }, use, testInfo) => {
|
cdpEndpoint: async ({ }, use, testInfo) => {
|
||||||
const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!);
|
const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!);
|
||||||
const browser = await chromium.launchPersistentContext(testInfo.outputPath('user-data-dir'), {
|
const executablePath = chromium.executablePath();
|
||||||
channel: 'chrome',
|
const browserProcess = spawn(executablePath, [
|
||||||
args: [`--remote-debugging-port=${port}`],
|
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
|
||||||
|
`--remote-debugging-port=${port}`,
|
||||||
|
`--no-first-run`,
|
||||||
|
`--no-sandbox`,
|
||||||
|
`--headless`,
|
||||||
|
`data:text/html,hello world`,
|
||||||
|
], {
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
await new Promise<void>(resolve => {
|
||||||
|
browserProcess.stderr.on('data', data => {
|
||||||
|
if (data.toString().includes('DevTools listening on '))
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
await use(`http://localhost:${port}`);
|
await use(`http://localhost:${port}`);
|
||||||
await browser.close();
|
browserProcess.kill();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
mcpHeadless: async ({ headless }, use) => {
|
||||||
|
await use(headless);
|
||||||
|
},
|
||||||
|
|
||||||
|
mcpBrowser: ['chromium', { option: true }],
|
||||||
});
|
});
|
||||||
|
|
||||||
type Response = Awaited<ReturnType<Client['callTool']>>;
|
type Response = Awaited<ReturnType<Client['callTool']>>;
|
||||||
|
|||||||
@@ -39,5 +39,5 @@ test('stitched aria frames', async ({ client }) => {
|
|||||||
element: 'World',
|
element: 'World',
|
||||||
ref: 'f1s1e3',
|
ref: 'f1s1e3',
|
||||||
},
|
},
|
||||||
})).toContainTextContent('Clicked "World"');
|
})).toContainTextContent(`// Click World`);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,16 +33,7 @@ test('test reopen browser', async ({ client }) => {
|
|||||||
arguments: {
|
arguments: {
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toContainTextContent(`- text: Hello, world!`);
|
||||||
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
|
||||||
- Page Title: Title
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- text: Hello, world!
|
|
||||||
\`\`\`
|
|
||||||
`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('executable path', async ({ startClient }) => {
|
test('executable path', async ({ startClient }) => {
|
||||||
|
|||||||
@@ -30,23 +30,14 @@ test('save as pdf unavailable', async ({ startClient }) => {
|
|||||||
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
|
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('save as pdf', async ({ client }) => {
|
test('save as pdf', async ({ client, mcpBrowser }) => {
|
||||||
|
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: {
|
||||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toContainTextContent(`- text: Hello, world!`);
|
||||||
Navigated to data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
|
||||||
|
|
||||||
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
|
||||||
- Page Title: Title
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
|
||||||
- text: Hello, world!
|
|
||||||
\`\`\`
|
|
||||||
`
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await client.callTool({
|
const response = await client.callTool({
|
||||||
name: 'browser_pdf_save',
|
name: 'browser_pdf_save',
|
||||||
|
|||||||
@@ -31,11 +31,16 @@ async function createTab(client: Client, title: string, body: string) {
|
|||||||
|
|
||||||
test('create new tab', async ({ client }) => {
|
test('create new tab', async ({ client }) => {
|
||||||
expect(await createTab(client, 'Tab one', 'Body one')).toHaveTextContent(`
|
expect(await createTab(client, 'Tab one', 'Body one')).toHaveTextContent(`
|
||||||
Open tabs:
|
- Ran code:
|
||||||
|
\`\`\`js
|
||||||
|
// <internal code to open a new tab>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Open tabs
|
||||||
- 1: [] (about:blank)
|
- 1: [] (about:blank)
|
||||||
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||||
|
|
||||||
Current tab:
|
### Current tab
|
||||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
@@ -44,12 +49,17 @@ Current tab:
|
|||||||
\`\`\``);
|
\`\`\``);
|
||||||
|
|
||||||
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
|
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
|
||||||
Open tabs:
|
- Ran code:
|
||||||
|
\`\`\`js
|
||||||
|
// <internal code to open a new tab>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Open tabs
|
||||||
- 1: [] (about:blank)
|
- 1: [] (about:blank)
|
||||||
- 2: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
- 2: [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||||
- 3: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
|
- 3: (current) [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
|
||||||
|
|
||||||
Current tab:
|
### Current tab
|
||||||
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
|
- Page URL: data:text/html,<title>Tab two</title><body>Body two</body>
|
||||||
- Page Title: Tab two
|
- Page Title: Tab two
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
@@ -67,12 +77,17 @@ test('select tab', async ({ client }) => {
|
|||||||
index: 2,
|
index: 2,
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
Open tabs:
|
- Ran code:
|
||||||
|
\`\`\`js
|
||||||
|
// <internal code to select tab 2>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Open tabs
|
||||||
- 1: [] (about:blank)
|
- 1: [] (about:blank)
|
||||||
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||||
- 3: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
|
- 3: [Tab two] (data:text/html,<title>Tab two</title><body>Body two</body>)
|
||||||
|
|
||||||
Current tab:
|
### Current tab
|
||||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
@@ -90,11 +105,16 @@ test('close tab', async ({ client }) => {
|
|||||||
index: 3,
|
index: 3,
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toHaveTextContent(`
|
||||||
Open tabs:
|
- Ran code:
|
||||||
|
\`\`\`js
|
||||||
|
// <internal code to close tab 3>
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Open tabs
|
||||||
- 1: [] (about:blank)
|
- 1: [] (about:blank)
|
||||||
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
- 2: (current) [Tab one] (data:text/html,<title>Tab one</title><body>Body one</body>)
|
||||||
|
|
||||||
Current tab:
|
### Current tab
|
||||||
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
- Page URL: data:text/html,<title>Tab one</title><body>Body one</body>
|
||||||
- Page Title: Tab one
|
- Page Title: Tab one
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
|
|||||||
Reference in New Issue
Block a user