Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
128e75b9f4 | ||
|
|
2366dbf36c | ||
|
|
0de7c0d38c | ||
|
|
0a5518b252 | ||
|
|
4f16786432 | ||
|
|
9042c03faa | ||
|
|
d316441142 | ||
|
|
aeb4cf65e9 | ||
|
|
a7392fc266 | ||
|
|
88fbf50841 |
5
.github/workflows/publish.yml
vendored
5
.github/workflows/publish.yml
vendored
@@ -5,6 +5,9 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
publish-npm:
|
publish-npm:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
@@ -15,6 +18,6 @@ jobs:
|
|||||||
- run: npm run build
|
- run: npm run build
|
||||||
- run: npm run lint
|
- run: npm run lint
|
||||||
- run: npm run test
|
- run: npm run test
|
||||||
- run: npm publish
|
- run: npm publish --provenance
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -59,9 +59,25 @@ code-insiders --add-mcp '{"name":"playwright","command":"npx","args":["@playwrig
|
|||||||
|
|
||||||
After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
|
After installation, the Playwright MCP server will be available for use with your GitHub Copilot agent in VS Code.
|
||||||
|
|
||||||
|
### CLI Options
|
||||||
|
|
||||||
|
The Playwright MCP server supports the following command-line options:
|
||||||
|
|
||||||
|
- `--browser <browser>`: Browser or chrome channel to use. Possible values:
|
||||||
|
- `chrome`, `firefox`, `webkit`, `msedge`
|
||||||
|
- Chrome channels: `chrome-beta`, `chrome-canary`, `chrome-dev`
|
||||||
|
- Edge channels: `msedge-beta`, `msedge-canary`, `msedge-dev`
|
||||||
|
- Default: `chrome`
|
||||||
|
- `--cdp-endpoint <endpoint>`: CDP endpoint to connect to
|
||||||
|
- `--executable-path <path>`: Path to the browser executable
|
||||||
|
- `--headless`: Run browser in headless mode (headed by default)
|
||||||
|
- `--port <port>`: Port to listen on for SSE transport
|
||||||
|
- `--user-data-dir <path>`: Path to the user data directory
|
||||||
|
- `--vision`: Run server that uses screenshots (Aria snapshots are used by default)
|
||||||
|
|
||||||
### User data directory
|
### User data directory
|
||||||
|
|
||||||
Playwright MCP will launch Chrome browser with the new profile, located at
|
Playwright MCP will launch the browser with the new profile, located at
|
||||||
|
|
||||||
```
|
```
|
||||||
- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-chrome-profile` on Windows
|
- `%USERPROFILE%\AppData\Local\ms-playwright\mcp-chrome-profile` on Windows
|
||||||
@@ -69,7 +85,7 @@ Playwright MCP will launch Chrome browser with the new profile, located at
|
|||||||
- `~/.cache/ms-playwright/mcp-chrome-profile` on Linux
|
- `~/.cache/ms-playwright/mcp-chrome-profile` on Linux
|
||||||
```
|
```
|
||||||
|
|
||||||
All the logged in information will be stored in that profile, you can delete it between sessions if you'dlike to clear the offline state.
|
All the logged in information will be stored in that profile, you can delete it between sessions if you'd like to clear the offline state.
|
||||||
|
|
||||||
|
|
||||||
### Running headless browser (Browser without GUI).
|
### Running headless browser (Browser without GUI).
|
||||||
|
|||||||
43
package-lock.json
generated
43
package-lock.json
generated
@@ -1,17 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.7",
|
"version": "0.0.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.7",
|
"version": "0.0.8",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
"@modelcontextprotocol/sdk": "^1.6.1",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"playwright": "1.52.0-alpha-1743011787000",
|
"playwright": "^1.52.0-alpha-1743163434000",
|
||||||
|
"yaml": "^2.7.1",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "1.52.0-alpha-1743011787000",
|
"@playwright/test": "^1.52.0-alpha-1743163434000",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
@@ -285,13 +286,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.52.0-alpha-1743011787000",
|
"version": "1.52.0-alpha-1743163434000",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0-alpha-1743011787000.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0-alpha-1743163434000.tgz",
|
||||||
"integrity": "sha512-ikJR8JXof5IBvErrmIsR3ixov4nKlQe/6PSYK/R6eTEe6eoT+eEXlaNY4z6mn9dF02Z1zYGxzAbb8TvSvuwh4Q==",
|
"integrity": "sha512-4uBgNlJ6hgPtB8DrwQsgoKuVoe7j+nPqudna7CLXWCmmT3LYPMD5aOjGoBkszr+R9NejtKashq/bOi/ny9hsIA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.52.0-alpha-1743011787000"
|
"playwright": "1.52.0-alpha-1743163434000"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3296,12 +3297,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.52.0-alpha-1743011787000",
|
"version": "1.52.0-alpha-1743163434000",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0-alpha-1743011787000.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0-alpha-1743163434000.tgz",
|
||||||
"integrity": "sha512-wg9Tu4ZDKJWo7hBKpeuD/XLtLOQ7fCCuBfekgUrPLStA12O3224E1fbp/xGFnmi47SF71Y8F6C2Beyd3gYFWlQ==",
|
"integrity": "sha512-4uYv49ekPjolydfFfTfFQ2z4URF9UZMVUXLy7aXam/tPxEQ5O7+jQC+yzrDMGmhcj5QkMnxjlyk7N2V9a0QLdQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.52.0-alpha-1743011787000"
|
"playwright-core": "1.52.0-alpha-1743163434000"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3314,9 +3315,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.52.0-alpha-1743011787000",
|
"version": "1.52.0-alpha-1743163434000",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0-alpha-1743011787000.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0-alpha-1743163434000.tgz",
|
||||||
"integrity": "sha512-yOpMfKxTBRqdm50b52cojvTCNttWN+Xk6LXF+KU4ufcGwcRjUud1xdHmHHvQNFFanXM1MBYnDKsMkRvjPsuYOw==",
|
"integrity": "sha512-Tn4u3Ywwjkh847/bYWlXIrNxv5DRJRDgtb+VYMXHvNCKkrxL6yfZ1ApIAYD7IAkkKH/KLTXszGWl3a/Z/KDfQA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
@@ -4348,6 +4349,18 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.7",
|
"version": "0.0.8",
|
||||||
"description": "Playwright Tools for MCP",
|
"description": "Playwright Tools for MCP",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -32,18 +32,19 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.6.1",
|
"@modelcontextprotocol/sdk": "^1.6.1",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"playwright": "1.52.0-alpha-1743011787000",
|
"playwright": "^1.52.0-alpha-1743163434000",
|
||||||
|
"yaml": "^2.7.1",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "1.52.0-alpha-1743011787000",
|
"@playwright/test": "^1.52.0-alpha-1743163434000",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
|
"@types/node": "^22.13.10",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
"@typescript-eslint/parser": "^8.26.1",
|
"@typescript-eslint/parser": "^8.26.1",
|
||||||
"@typescript-eslint/utils": "^8.26.1",
|
"@typescript-eslint/utils": "^8.26.1",
|
||||||
"@types/node": "^22.13.10",
|
|
||||||
"eslint": "^9.19.0",
|
"eslint": "^9.19.0",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-notice": "^1.0.0",
|
"eslint-plugin-notice": "^1.0.0",
|
||||||
|
|||||||
140
src/context.ts
140
src/context.ts
@@ -14,21 +14,31 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { fork } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
|
import yaml from 'yaml';
|
||||||
|
|
||||||
|
export type ContextOptions = {
|
||||||
|
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||||
|
userDataDir: string;
|
||||||
|
launchOptions?: playwright.LaunchOptions;
|
||||||
|
cdpEndpoint?: string;
|
||||||
|
remoteEndpoint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export class Context {
|
export class Context {
|
||||||
private _userDataDir: string;
|
private _options: ContextOptions;
|
||||||
private _launchOptions: playwright.LaunchOptions | undefined;
|
|
||||||
private _browser: playwright.Browser | undefined;
|
private _browser: playwright.Browser | undefined;
|
||||||
private _page: playwright.Page | undefined;
|
private _page: playwright.Page | undefined;
|
||||||
private _console: playwright.ConsoleMessage[] = [];
|
private _console: playwright.ConsoleMessage[] = [];
|
||||||
private _createPagePromise: Promise<playwright.Page> | undefined;
|
private _createPagePromise: Promise<playwright.Page> | undefined;
|
||||||
private _fileChooser: playwright.FileChooser | undefined;
|
private _fileChooser: playwright.FileChooser | undefined;
|
||||||
private _lastSnapshotFrames: playwright.FrameLocator[] = [];
|
private _lastSnapshotFrames: (playwright.Page | playwright.FrameLocator)[] = [];
|
||||||
|
|
||||||
constructor(userDataDir: string, launchOptions?: playwright.LaunchOptions) {
|
constructor(options: ContextOptions) {
|
||||||
this._userDataDir = userDataDir;
|
this._options = options;
|
||||||
this._launchOptions = launchOptions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createPage(): Promise<playwright.Page> {
|
async createPage(): Promise<playwright.Page> {
|
||||||
@@ -64,6 +74,25 @@ export class Context {
|
|||||||
this._console.length = 0;
|
this._console.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async install(): Promise<string> {
|
||||||
|
const channel = this._options.launchOptions?.channel ?? this._options.browserName ?? 'chrome';
|
||||||
|
const cli = path.join(require.resolve('playwright/package.json'), '..', 'cli.js');
|
||||||
|
const child = fork(cli, ['install', channel], {
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
const output: string[] = [];
|
||||||
|
child.stdout?.on('data', data => output.push(data.toString()));
|
||||||
|
child.stderr?.on('data', data => output.push(data.toString()));
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
child.on('close', code => {
|
||||||
|
if (code === 0)
|
||||||
|
resolve(channel);
|
||||||
|
else
|
||||||
|
reject(new Error(`Failed to install browser: ${output.join('')}`));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
existingPage(): playwright.Page {
|
existingPage(): playwright.Page {
|
||||||
if (!this._page)
|
if (!this._page)
|
||||||
throw new Error('Navigate to a location to create a page');
|
throw new Error('Navigate to a location to create a page');
|
||||||
@@ -96,55 +125,98 @@ export class Context {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _createPage(): Promise<{ browser?: playwright.Browser, page: playwright.Page }> {
|
private async _createPage(): Promise<{ browser?: playwright.Browser, page: playwright.Page }> {
|
||||||
if (process.env.PLAYWRIGHT_WS_ENDPOINT) {
|
if (this._options.remoteEndpoint) {
|
||||||
const url = new URL(process.env.PLAYWRIGHT_WS_ENDPOINT);
|
const url = new URL(this._options.remoteEndpoint);
|
||||||
if (this._launchOptions)
|
if (this._options.browserName)
|
||||||
url.searchParams.set('launch-options', JSON.stringify(this._launchOptions));
|
url.searchParams.set('browser', this._options.browserName);
|
||||||
const browser = await playwright.chromium.connect(String(url));
|
if (this._options.launchOptions)
|
||||||
|
url.searchParams.set('launch-options', JSON.stringify(this._options.launchOptions));
|
||||||
|
const browser = await playwright[this._options.browserName ?? 'chromium'].connect(String(url));
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
return { browser, page };
|
return { browser, page };
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = await playwright.chromium.launchPersistentContext(this._userDataDir, this._launchOptions);
|
if (this._options.cdpEndpoint) {
|
||||||
|
const browser = await playwright.chromium.connectOverCDP(this._options.cdpEndpoint);
|
||||||
|
const browserContext = browser.contexts()[0];
|
||||||
|
let [page] = browserContext.pages();
|
||||||
|
if (!page)
|
||||||
|
page = await browserContext.newPage();
|
||||||
|
return { browser, page };
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await this._launchPersistentContext();
|
||||||
const [page] = context.pages();
|
const [page] = context.pages();
|
||||||
return { page };
|
return { page };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _launchPersistentContext(): Promise<playwright.BrowserContext> {
|
||||||
|
try {
|
||||||
|
const browserType = this._options.browserName ? playwright[this._options.browserName] : playwright.chromium;
|
||||||
|
return await browserType.launchPersistentContext(this._options.userDataDir, this._options.launchOptions);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message.includes('Executable doesn\'t exist'))
|
||||||
|
throw new Error(`Browser specified in your config is not installed. Either install it (likely) or change the config.`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async allFramesSnapshot() {
|
async allFramesSnapshot() {
|
||||||
const page = this.existingPage();
|
this._lastSnapshotFrames = [];
|
||||||
const visibleFrames = await page.locator('iframe').filter({ visible: true }).all();
|
const yaml = await this._allFramesSnapshot(this.existingPage());
|
||||||
this._lastSnapshotFrames = visibleFrames.map(frame => frame.contentFrame());
|
return yaml.toString().trim();
|
||||||
|
}
|
||||||
|
|
||||||
const snapshots = await Promise.all([
|
private async _allFramesSnapshot(frame: playwright.Page | playwright.FrameLocator): Promise<yaml.Document> {
|
||||||
page.locator('html').ariaSnapshot({ ref: true }),
|
const frameIndex = this._lastSnapshotFrames.push(frame) - 1;
|
||||||
...this._lastSnapshotFrames.map(async (frame, index) => {
|
const snapshotString = await frame.locator('body').ariaSnapshot({ ref: true });
|
||||||
const snapshot = await frame.locator('html').ariaSnapshot({ ref: true });
|
const snapshot = yaml.parseDocument(snapshotString);
|
||||||
const args = [];
|
|
||||||
const src = await frame.owner().getAttribute('src');
|
|
||||||
if (src)
|
|
||||||
args.push(`src=${src}`);
|
|
||||||
const name = await frame.owner().getAttribute('name');
|
|
||||||
if (name)
|
|
||||||
args.push(`name=${name}`);
|
|
||||||
return `\n# iframe ${args.join(' ')}\n` + snapshot.replaceAll('[ref=', `[ref=f${index}`);
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
|
|
||||||
return snapshots.join('\n');
|
const visit = async (node: any): Promise<unknown> => {
|
||||||
|
if (yaml.isPair(node)) {
|
||||||
|
await Promise.all([
|
||||||
|
visit(node.key).then(k => node.key = k),
|
||||||
|
visit(node.value).then(v => node.value = v)
|
||||||
|
]);
|
||||||
|
} else if (yaml.isSeq(node) || yaml.isMap(node)) {
|
||||||
|
node.items = await Promise.all(node.items.map(visit));
|
||||||
|
} else if (yaml.isScalar(node)) {
|
||||||
|
if (typeof node.value === 'string') {
|
||||||
|
const value = node.value;
|
||||||
|
if (frameIndex > 0)
|
||||||
|
node.value = value.replace('[ref=', `[ref=f${frameIndex}`);
|
||||||
|
if (value.startsWith('iframe ')) {
|
||||||
|
const ref = value.match(/\[ref=(.*)\]/)?.[1];
|
||||||
|
if (ref) {
|
||||||
|
try {
|
||||||
|
const childSnapshot = await this._allFramesSnapshot(frame.frameLocator(`aria-ref=${ref}`));
|
||||||
|
return snapshot.createPair(node.value, childSnapshot);
|
||||||
|
} catch (error) {
|
||||||
|
return snapshot.createPair(node.value, '<could not take iframe snapshot>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
await visit(snapshot.contents);
|
||||||
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
refLocator(ref: string): playwright.Locator {
|
refLocator(ref: string): playwright.Locator {
|
||||||
const page = this.existingPage();
|
let frame = this._lastSnapshotFrames[0];
|
||||||
let frame: playwright.Frame | playwright.FrameLocator = page.mainFrame();
|
|
||||||
const match = ref.match(/^f(\d+)(.*)/);
|
const match = ref.match(/^f(\d+)(.*)/);
|
||||||
if (match) {
|
if (match) {
|
||||||
const frameIndex = parseInt(match[1], 10);
|
const frameIndex = parseInt(match[1], 10);
|
||||||
if (!this._lastSnapshotFrames[frameIndex])
|
|
||||||
throw new Error(`Frame does not exist. Provide ref from the most current snapshot.`);
|
|
||||||
frame = this._lastSnapshotFrames[frameIndex];
|
frame = this._lastSnapshotFrames[frameIndex];
|
||||||
ref = match[2];
|
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 frame.locator(`aria-ref=${ref}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const commonTools: Tool[] = [
|
|||||||
common.wait,
|
common.wait,
|
||||||
common.pdf,
|
common.pdf,
|
||||||
common.close,
|
common.close,
|
||||||
|
common.install,
|
||||||
];
|
];
|
||||||
|
|
||||||
const snapshotTools: Tool[] = [
|
const snapshotTools: Tool[] = [
|
||||||
@@ -64,8 +65,10 @@ const resources: Resource[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
|
browserName?: 'chromium' | 'firefox' | 'webkit';
|
||||||
userDataDir?: string;
|
userDataDir?: string;
|
||||||
launchOptions?: LaunchOptions;
|
launchOptions?: LaunchOptions;
|
||||||
|
cdpEndpoint?: string;
|
||||||
vision?: boolean;
|
vision?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -78,7 +81,9 @@ export function createServer(options?: Options): Server {
|
|||||||
version: packageJSON.version,
|
version: packageJSON.version,
|
||||||
tools,
|
tools,
|
||||||
resources,
|
resources,
|
||||||
|
browserName: options?.browserName,
|
||||||
userDataDir: options?.userDataDir ?? '',
|
userDataDir: options?.userDataDir ?? '',
|
||||||
launchOptions: options?.launchOptions,
|
launchOptions: options?.launchOptions,
|
||||||
|
cdpEndpoint: options?.cdpEndpoint,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,20 +35,56 @@ const packageJSON = require('../package.json');
|
|||||||
program
|
program
|
||||||
.version('Version ' + packageJSON.version)
|
.version('Version ' + packageJSON.version)
|
||||||
.name(packageJSON.name)
|
.name(packageJSON.name)
|
||||||
|
.option('--browser <browser>', 'Browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
||||||
|
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
||||||
|
.option('--executable-path <path>', 'Path to the browser executable.')
|
||||||
.option('--headless', 'Run browser in headless mode, headed by default')
|
.option('--headless', 'Run browser in headless mode, headed by default')
|
||||||
|
.option('--port <port>', 'Port to listen on for SSE transport.')
|
||||||
.option('--user-data-dir <path>', 'Path to the user data directory')
|
.option('--user-data-dir <path>', 'Path to the user data directory')
|
||||||
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
|
.option('--vision', 'Run server that uses screenshots (Aria snapshots are used by default)')
|
||||||
.option('--port <port>', 'Port to listen on for SSE transport.')
|
|
||||||
.action(async options => {
|
.action(async options => {
|
||||||
|
let browserName: 'chromium' | 'firefox' | 'webkit';
|
||||||
|
let channel: string | undefined;
|
||||||
|
switch (options.browser) {
|
||||||
|
case 'chrome':
|
||||||
|
case 'chrome-beta':
|
||||||
|
case 'chrome-canary':
|
||||||
|
case 'chrome-dev':
|
||||||
|
case 'msedge':
|
||||||
|
case 'msedge-beta':
|
||||||
|
case 'msedge-canary':
|
||||||
|
case 'msedge-dev':
|
||||||
|
browserName = 'chromium';
|
||||||
|
channel = options.browser;
|
||||||
|
break;
|
||||||
|
case 'chromium':
|
||||||
|
browserName = 'chromium';
|
||||||
|
break;
|
||||||
|
case 'firefox':
|
||||||
|
browserName = 'firefox';
|
||||||
|
break;
|
||||||
|
case 'webkit':
|
||||||
|
browserName = 'webkit';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
browserName = 'chromium';
|
||||||
|
channel = 'chrome';
|
||||||
|
}
|
||||||
|
|
||||||
const launchOptions: LaunchOptions = {
|
const launchOptions: LaunchOptions = {
|
||||||
headless: !!options.headless,
|
headless: !!options.headless,
|
||||||
channel: 'chrome',
|
channel,
|
||||||
|
executablePath: options.executablePath,
|
||||||
};
|
};
|
||||||
const userDataDir = options.userDataDir ?? await createUserDataDir();
|
|
||||||
|
const userDataDir = options.userDataDir ?? await createUserDataDir(browserName);
|
||||||
|
|
||||||
const serverList = new ServerList(() => createServer({
|
const serverList = new ServerList(() => createServer({
|
||||||
|
browserName,
|
||||||
userDataDir,
|
userDataDir,
|
||||||
launchOptions,
|
launchOptions,
|
||||||
vision: !!options.vision,
|
vision: !!options.vision,
|
||||||
|
cdpEndpoint: options.cdpEndpoint,
|
||||||
}));
|
}));
|
||||||
setupExitWatchdog(serverList);
|
setupExitWatchdog(serverList);
|
||||||
|
|
||||||
@@ -70,7 +106,7 @@ function setupExitWatchdog(serverList: ServerList) {
|
|||||||
|
|
||||||
program.parse(process.argv);
|
program.parse(process.argv);
|
||||||
|
|
||||||
async function createUserDataDir() {
|
async function createUserDataDir(browserName: 'chromium' | 'firefox' | 'webkit') {
|
||||||
let cacheDirectory: string;
|
let cacheDirectory: string;
|
||||||
if (process.platform === 'linux')
|
if (process.platform === 'linux')
|
||||||
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
||||||
@@ -80,7 +116,7 @@ async function createUserDataDir() {
|
|||||||
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
||||||
else
|
else
|
||||||
throw new Error('Unsupported platform: ' + process.platform);
|
throw new Error('Unsupported platform: ' + process.platform);
|
||||||
const result = path.join(cacheDirectory, 'ms-playwright', 'mcp-chrome-profile');
|
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserName}-profile`);
|
||||||
await fs.promises.mkdir(result, { recursive: true });
|
await fs.promises.mkdir(result, { recursive: true });
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,20 +21,18 @@ import { Context } from './context';
|
|||||||
|
|
||||||
import type { Tool } from './tools/tool';
|
import type { Tool } from './tools/tool';
|
||||||
import type { Resource } from './resources/resource';
|
import type { Resource } from './resources/resource';
|
||||||
import type { LaunchOptions } from 'playwright';
|
import type { ContextOptions } from './context';
|
||||||
|
|
||||||
type Options = {
|
type Options = ContextOptions & {
|
||||||
name: string;
|
name: string;
|
||||||
version: string;
|
version: string;
|
||||||
tools: Tool[];
|
tools: Tool[];
|
||||||
resources: Resource[],
|
resources: Resource[],
|
||||||
userDataDir: string;
|
|
||||||
launchOptions?: LaunchOptions;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createServerWithTools(options: Options): Server {
|
export function createServerWithTools(options: Options): Server {
|
||||||
const { name, version, tools, resources, userDataDir, launchOptions } = options;
|
const { name, version, tools, resources } = options;
|
||||||
const context = new Context(userDataDir, launchOptions);
|
const context = new Context(options);
|
||||||
const server = new Server({ name, version }, {
|
const server = new Server({ name, version }, {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import path from 'path';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
|
||||||
import { captureAriaSnapshot, runAndWait } from './utils';
|
import { captureAriaSnapshot, runAndWait, sanitizeForFilePath } from './utils';
|
||||||
|
|
||||||
import type { ToolFactory, Tool } from './tool';
|
import type { ToolFactory, Tool } from './tool';
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ export const pdf: Tool = {
|
|||||||
},
|
},
|
||||||
handle: async context => {
|
handle: async context => {
|
||||||
const page = context.existingPage();
|
const page = context.existingPage();
|
||||||
const fileName = path.join(os.tmpdir(), `/page-${new Date().toISOString()}.pdf`);
|
const fileName = path.join(os.tmpdir(), sanitizeForFilePath(`page-${new Date().toISOString()}`)) + '.pdf';
|
||||||
await page.pdf({ path: fileName });
|
await page.pdf({ path: fileName });
|
||||||
return {
|
return {
|
||||||
content: [{
|
content: [{
|
||||||
@@ -174,3 +174,20 @@ export const chooseFile: ToolFactory = snapshot => ({
|
|||||||
}, snapshot);
|
}, snapshot);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const install: Tool = {
|
||||||
|
schema: {
|
||||||
|
name: 'browser_install',
|
||||||
|
description: 'Install the browser specified in the config. Call this if you get an error about the browser not being installed.',
|
||||||
|
inputSchema: zodToJsonSchema(z.object({})),
|
||||||
|
},
|
||||||
|
handle: async context => {
|
||||||
|
const channel = await context.install();
|
||||||
|
return {
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `Browser ${channel} installed`,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -106,3 +106,7 @@ export async function captureAriaSnapshot(context: Context, status: string = '')
|
|||||||
content: [{ type: 'text', text: lines.join('\n') }],
|
content: [{ type: 'text', text: lines.join('\n') }],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sanitizeForFilePath(s: string) {
|
||||||
|
return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ test('test tool list', async ({ client, visionClient }) => {
|
|||||||
'browser_wait',
|
'browser_wait',
|
||||||
'browser_save_as_pdf',
|
'browser_save_as_pdf',
|
||||||
'browser_close',
|
'browser_close',
|
||||||
|
'browser_install',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const { tools: visionTools } = await visionClient.listTools();
|
const { tools: visionTools } = await visionClient.listTools();
|
||||||
@@ -53,6 +54,7 @@ test('test tool list', async ({ client, visionClient }) => {
|
|||||||
'browser_wait',
|
'browser_wait',
|
||||||
'browser_save_as_pdf',
|
'browser_save_as_pdf',
|
||||||
'browser_close',
|
'browser_close',
|
||||||
|
'browser_install',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,7 +79,7 @@ test('test browser_navigate', async ({ client }) => {
|
|||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- document [ref=s1e2]: Hello, world!
|
- text: Hello, world!
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
@@ -95,7 +97,7 @@ test('test browser_click', async ({ client }) => {
|
|||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Submit button',
|
element: 'Submit button',
|
||||||
ref: 's1e4',
|
ref: 's1e3',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`"Submit button" clicked
|
})).toHaveTextContent(`"Submit button" clicked
|
||||||
|
|
||||||
@@ -103,8 +105,7 @@ test('test browser_click', async ({ client }) => {
|
|||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- document [ref=s2e2]:
|
- button "Submit" [ref=s2e3]
|
||||||
- button "Submit" [ref=s2e4]
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
@@ -131,7 +132,7 @@ test('test reopen browser', async ({ client }) => {
|
|||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- document [ref=s1e2]: Hello, world!
|
- text: Hello, world!
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
@@ -148,7 +149,7 @@ test('single option', async ({ client }) => {
|
|||||||
name: 'browser_select_option',
|
name: 'browser_select_option',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Select',
|
element: 'Select',
|
||||||
ref: 's1e4',
|
ref: 's1e3',
|
||||||
values: ['bar'],
|
values: ['bar'],
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`Selected option in "Select"
|
})).toHaveTextContent(`Selected option in "Select"
|
||||||
@@ -157,10 +158,9 @@ test('single option', async ({ client }) => {
|
|||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- document [ref=s2e2]:
|
- combobox [ref=s2e3]:
|
||||||
- combobox [ref=s2e4]:
|
- option "Foo" [ref=s2e4]
|
||||||
- option "Foo" [ref=s2e5]
|
- option "Bar" [selected] [ref=s2e5]
|
||||||
- option "Bar" [selected] [ref=s2e6]
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
@@ -177,7 +177,7 @@ test('multiple option', async ({ client }) => {
|
|||||||
name: 'browser_select_option',
|
name: 'browser_select_option',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Select',
|
element: 'Select',
|
||||||
ref: 's1e4',
|
ref: 's1e3',
|
||||||
values: ['bar', 'baz'],
|
values: ['bar', 'baz'],
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`Selected option in "Select"
|
})).toHaveTextContent(`Selected option in "Select"
|
||||||
@@ -186,11 +186,10 @@ test('multiple option', async ({ client }) => {
|
|||||||
- Page Title: Title
|
- Page Title: Title
|
||||||
- Page Snapshot
|
- Page Snapshot
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- document [ref=s2e2]:
|
- listbox [ref=s2e3]:
|
||||||
- listbox [ref=s2e4]:
|
- option "Foo" [ref=s2e4]
|
||||||
- option "Foo" [ref=s2e5]
|
- option "Bar" [selected] [ref=s2e5]
|
||||||
- option "Bar" [selected] [ref=s2e6]
|
- option "Baz" [selected] [ref=s2e6]
|
||||||
- option "Baz" [selected] [ref=s2e7]
|
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
});
|
});
|
||||||
@@ -217,21 +216,26 @@ test('stitched aria frames', async ({ client }) => {
|
|||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: {
|
arguments: {
|
||||||
url: 'data:text/html,<h1>Hello</h1><iframe src="data:text/html,<h1>World</h1>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>',
|
url: `data:text/html,<h1>Hello</h1><iframe src="data:text/html,<button>World</button><main><iframe src='data:text/html,<p>Nested</p>'></iframe></main>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>`,
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`
|
})).toContainTextContent(`
|
||||||
- Page URL: data:text/html,<h1>Hello</h1><iframe src="data:text/html,<h1>World</h1>"></iframe><iframe src="data:text/html,<h1>Should be invisible</h1>" style="display: none;"></iframe>
|
|
||||||
- Page Title:
|
|
||||||
- Page Snapshot
|
|
||||||
\`\`\`yaml
|
\`\`\`yaml
|
||||||
- document [ref=s1e2]:
|
- heading "Hello" [level=1] [ref=s1e3]
|
||||||
- heading "Hello" [level=1] [ref=s1e4]
|
- iframe [ref=s1e4]:
|
||||||
|
- button "World" [ref=f1s1e3]
|
||||||
# iframe src=data:text/html,<h1>World</h1>
|
- main [ref=f1s1e4]:
|
||||||
- document [ref=f0s1e2]:
|
- iframe [ref=f1s1e5]:
|
||||||
- heading "World" [level=1] [ref=f0s1e4]
|
- paragraph [ref=f2s1e3]: Nested
|
||||||
\`\`\`
|
\`\`\`
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_click',
|
||||||
|
arguments: {
|
||||||
|
element: 'World',
|
||||||
|
ref: 'f1s1e3',
|
||||||
|
},
|
||||||
|
})).toContainTextContent('"World" clicked');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_choose_file', async ({ client }) => {
|
test('browser_choose_file', async ({ client }) => {
|
||||||
@@ -240,13 +244,13 @@ test('browser_choose_file', async ({ client }) => {
|
|||||||
arguments: {
|
arguments: {
|
||||||
url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>',
|
url: 'data:text/html,<html><title>Title</title><input type="file" /><button>Button</button></html>',
|
||||||
},
|
},
|
||||||
})).toContainTextContent('- textbox [ref=s1e4]');
|
})).toContainTextContent('- textbox [ref=s1e3]');
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Textbox',
|
element: 'Textbox',
|
||||||
ref: 's1e4',
|
ref: 's1e3',
|
||||||
},
|
},
|
||||||
})).toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
|
})).toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
|
||||||
|
|
||||||
@@ -262,7 +266,7 @@ test('browser_choose_file', async ({ client }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response).not.toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
|
expect(response).not.toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
|
||||||
expect(response).toContainTextContent('textbox [ref=s3e4]: C:\\fakepath\\test.txt');
|
expect(response).toContainTextContent('textbox [ref=s3e3]: C:\\fakepath\\test.txt');
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -270,12 +274,12 @@ test('browser_choose_file', async ({ client }) => {
|
|||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Textbox',
|
element: 'Textbox',
|
||||||
ref: 's3e4',
|
ref: 's3e3',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response).toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
|
expect(response).toContainTextContent('There is a file chooser visible that requires browser_choose_file to be called');
|
||||||
expect(response).toContainTextContent('button "Button" [ref=s4e5]');
|
expect(response).toContainTextContent('button "Button" [ref=s4e4]');
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -283,7 +287,7 @@ test('browser_choose_file', async ({ client }) => {
|
|||||||
name: 'browser_click',
|
name: 'browser_click',
|
||||||
arguments: {
|
arguments: {
|
||||||
element: 'Button',
|
element: 'Button',
|
||||||
ref: 's4e5',
|
ref: 's4e4',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -313,3 +317,54 @@ test('sse transport', async () => {
|
|||||||
cp.kill();
|
cp.kill();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('cdp server', async ({ cdpEndpoint, startClient }) => {
|
||||||
|
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||||
|
},
|
||||||
|
})).toHaveTextContent(`
|
||||||
|
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||||
|
- Page Title: Title
|
||||||
|
- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- text: Hello, world!
|
||||||
|
\`\`\`
|
||||||
|
`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('save as pdf', async ({ client }) => {
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||||
|
},
|
||||||
|
})).toHaveTextContent(`
|
||||||
|
- Page URL: data:text/html,<html><title>Title</title><body>Hello, world!</body></html>
|
||||||
|
- Page Title: Title
|
||||||
|
- Page Snapshot
|
||||||
|
\`\`\`yaml
|
||||||
|
- text: Hello, world!
|
||||||
|
\`\`\`
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: 'browser_save_as_pdf',
|
||||||
|
});
|
||||||
|
expect(response).toHaveTextContent(/^Saved as.*page-[^:]+.pdf$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executable path', async ({ startClient }) => {
|
||||||
|
const client = await startClient({ args: [`--executable-path=bogus`] });
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: {
|
||||||
|
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response).toContainTextContent(`executable doesn't exist`);
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,8 +24,9 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|||||||
type Fixtures = {
|
type Fixtures = {
|
||||||
client: Client;
|
client: Client;
|
||||||
visionClient: Client;
|
visionClient: Client;
|
||||||
startClient: (options?: { env?: NodeJS.ProcessEnv, vision?: boolean }) => Promise<Client>;
|
startClient: (options?: { args?: string[], vision?: boolean }) => Promise<Client>;
|
||||||
wsEndpoint: string;
|
wsEndpoint: string;
|
||||||
|
cdpEndpoint: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const test = baseTest.extend<Fixtures>({
|
export const test = baseTest.extend<Fixtures>({
|
||||||
@@ -46,6 +47,8 @@ export const test = baseTest.extend<Fixtures>({
|
|||||||
const args = ['--headless', '--user-data-dir', userDataDir];
|
const args = ['--headless', '--user-data-dir', userDataDir];
|
||||||
if (options?.vision)
|
if (options?.vision)
|
||||||
args.push('--vision');
|
args.push('--vision');
|
||||||
|
if (options?.args)
|
||||||
|
args.push(...options.args);
|
||||||
const transport = new StdioClientTransport({
|
const transport = new StdioClientTransport({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: [path.join(__dirname, '../cli.js'), ...args],
|
args: [path.join(__dirname, '../cli.js'), ...args],
|
||||||
@@ -64,20 +67,29 @@ export const test = baseTest.extend<Fixtures>({
|
|||||||
await use(browserServer.wsEndpoint());
|
await use(browserServer.wsEndpoint());
|
||||||
await browserServer.close();
|
await browserServer.close();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
cdpEndpoint: async ({ }, use, testInfo) => {
|
||||||
|
const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!);
|
||||||
|
const browser = await chromium.launchPersistentContext(testInfo.outputPath('user-data-dir'), {
|
||||||
|
channel: 'chrome',
|
||||||
|
args: [`--remote-debugging-port=${port}`],
|
||||||
|
});
|
||||||
|
await use(`http://localhost:${port}`);
|
||||||
|
await browser.close();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
type Response = Awaited<ReturnType<Client['callTool']>>;
|
type Response = Awaited<ReturnType<Client['callTool']>>;
|
||||||
|
|
||||||
export const expect = baseExpect.extend({
|
export const expect = baseExpect.extend({
|
||||||
toHaveTextContent(response: Response, content: string | string[]) {
|
toHaveTextContent(response: Response, content: string | RegExp) {
|
||||||
const isNot = this.isNot;
|
const isNot = this.isNot;
|
||||||
try {
|
try {
|
||||||
content = Array.isArray(content) ? content : [content];
|
const text = (response.content as any)[0].text;
|
||||||
const texts = (response.content as any).map(c => c.text);
|
|
||||||
if (isNot)
|
if (isNot)
|
||||||
baseExpect(texts).not.toEqual(content);
|
baseExpect(text).not.toMatch(content);
|
||||||
else
|
else
|
||||||
baseExpect(texts).toEqual(content);
|
baseExpect(text).toMatch(content);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
pass: isNot,
|
pass: isNot,
|
||||||
|
|||||||
Reference in New Issue
Block a user