Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
675b083db3 | ||
|
|
0b74cdaaf8 | ||
|
|
f31ef598bc | ||
|
|
656779531c | ||
|
|
eec177d3ac | ||
|
|
54ed7c3200 | ||
|
|
3cd74a824a |
@@ -199,7 +199,7 @@ state [here](https://playwright.dev/docs/auth).
|
|||||||
"args": [
|
"args": [
|
||||||
"@playwright/mcp@latest",
|
"@playwright/mcp@latest",
|
||||||
"--isolated",
|
"--isolated",
|
||||||
"--storage-state={path/to/storage.json}
|
"--storage-state={path/to/storage.json}"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
index.d.ts
vendored
3
index.d.ts
vendored
@@ -18,6 +18,7 @@
|
|||||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import type { Config } from './config';
|
import type { Config } from './config';
|
||||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
import type { BrowserContext } from 'playwright';
|
||||||
|
|
||||||
export type Connection = {
|
export type Connection = {
|
||||||
server: Server;
|
server: Server;
|
||||||
@@ -25,5 +26,5 @@ export type Connection = {
|
|||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export declare function createConnection(config?: Config): Promise<Connection>;
|
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Connection>;
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
112
package-lock.json
generated
112
package-lock.json
generated
@@ -1,16 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.27",
|
"version": "0.0.28",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.27",
|
"version": "0.0.28",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
|
"debug": "^4.4.1",
|
||||||
"playwright": "1.53.0-alpha-2025-05-27",
|
"playwright": "1.53.0-alpha-2025-05-27",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "1.53.0-alpha-2025-05-27",
|
"@playwright/test": "1.53.0-alpha-2025-05-27",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
|
"@types/debug": "^4.1.12",
|
||||||
"@types/node": "^22.13.10",
|
"@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",
|
||||||
@@ -354,6 +356,16 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/debug": {
|
||||||
|
"version": "4.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
|
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
|
||||||
@@ -375,6 +387,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"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",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
|
||||||
@@ -853,29 +872,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/body-parser/node_modules/debug": {
|
|
||||||
"version": "4.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
|
||||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/body-parser/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/body-parser/node_modules/qs": {
|
"node_modules/body-parser/node_modules/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||||
@@ -1156,12 +1152,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.3.6",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
|
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "2.1.2"
|
"ms": "^2.1.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0"
|
"node": ">=6.0"
|
||||||
@@ -1826,6 +1822,29 @@
|
|||||||
"express": "^4.11 || 5 || ^5.0.0-beta.1"
|
"express": "^4.11 || 5 || ^5.0.0-beta.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express/node_modules/debug": {
|
||||||
|
"version": "4.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
|
||||||
|
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/express/node_modules/ms": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -1930,29 +1949,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/finalhandler/node_modules/debug": {
|
|
||||||
"version": "4.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
|
||||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/finalhandler/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/find-root": {
|
"node_modules/find-root": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
|
||||||
@@ -3003,9 +2999,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/natural-compare": {
|
"node_modules/natural-compare": {
|
||||||
@@ -3713,12 +3709,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/send/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/serve-static": {
|
"node_modules/serve-static": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.27",
|
"version": "0.0.28",
|
||||||
"description": "Playwright Tools for MCP",
|
"description": "Playwright Tools for MCP",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
|
"debug": "^4.4.1",
|
||||||
"playwright": "1.53.0-alpha-2025-05-27",
|
"playwright": "1.53.0-alpha-2025-05-27",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "1.53.0-alpha-2025-05-27",
|
"@playwright/test": "1.53.0-alpha-2025-05-27",
|
||||||
"@stylistic/eslint-plugin": "^3.0.1",
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
|
"@types/debug": "^4.1.12",
|
||||||
"@types/node": "^22.13.10",
|
"@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",
|
||||||
|
|||||||
211
src/browserContextFactory.ts
Normal file
211
src/browserContextFactory.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import debug from 'debug';
|
||||||
|
import * as playwright from 'playwright';
|
||||||
|
|
||||||
|
import type { FullConfig } from './config.js';
|
||||||
|
|
||||||
|
const testDebug = debug('pw:mcp:test');
|
||||||
|
|
||||||
|
export function contextFactory(browserConfig: FullConfig['browser']): BrowserContextFactory {
|
||||||
|
if (browserConfig.remoteEndpoint)
|
||||||
|
return new RemoteContextFactory(browserConfig);
|
||||||
|
if (browserConfig.cdpEndpoint)
|
||||||
|
return new CdpContextFactory(browserConfig);
|
||||||
|
if (browserConfig.isolated)
|
||||||
|
return new IsolatedContextFactory(browserConfig);
|
||||||
|
return new PersistentContextFactory(browserConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserContextFactory {
|
||||||
|
createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BaseContextFactory implements BrowserContextFactory {
|
||||||
|
readonly browserConfig: FullConfig['browser'];
|
||||||
|
protected _browserPromise: Promise<playwright.Browser> | undefined;
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
constructor(name: string, browserConfig: FullConfig['browser']) {
|
||||||
|
this.name = name;
|
||||||
|
this.browserConfig = browserConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _obtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
if (this._browserPromise)
|
||||||
|
return this._browserPromise;
|
||||||
|
testDebug(`obtain browser (${this.name})`);
|
||||||
|
this._browserPromise = this._doObtainBrowser();
|
||||||
|
void this._browserPromise.then(browser => {
|
||||||
|
browser.on('disconnected', () => {
|
||||||
|
this._browserPromise = undefined;
|
||||||
|
});
|
||||||
|
}).catch(() => {
|
||||||
|
this._browserPromise = undefined;
|
||||||
|
});
|
||||||
|
return this._browserPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
|
testDebug(`create browser context (${this.name})`);
|
||||||
|
const browser = await this._obtainBrowser();
|
||||||
|
const browserContext = await this._doCreateContext(browser);
|
||||||
|
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _closeBrowserContext(browserContext: playwright.BrowserContext, browser: playwright.Browser) {
|
||||||
|
testDebug(`close browser context (${this.name})`);
|
||||||
|
if (browser.contexts().length === 1)
|
||||||
|
this._browserPromise = undefined;
|
||||||
|
await browserContext.close().catch(() => {});
|
||||||
|
if (browser.contexts().length === 0) {
|
||||||
|
testDebug(`close browser (${this.name})`);
|
||||||
|
await browser.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IsolatedContextFactory extends BaseContextFactory {
|
||||||
|
constructor(browserConfig: FullConfig['browser']) {
|
||||||
|
super('isolated', browserConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
const browserType = playwright[this.browserConfig.browserName];
|
||||||
|
return browserType.launch({
|
||||||
|
...this.browserConfig.launchOptions,
|
||||||
|
handleSIGINT: false,
|
||||||
|
handleSIGTERM: false,
|
||||||
|
}).catch(error => {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
|
return browser.newContext(this.browserConfig.contextOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CdpContextFactory extends BaseContextFactory {
|
||||||
|
constructor(browserConfig: FullConfig['browser']) {
|
||||||
|
super('cdp', browserConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
return playwright.chromium.connectOverCDP(this.browserConfig.cdpEndpoint!);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
|
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RemoteContextFactory extends BaseContextFactory {
|
||||||
|
constructor(browserConfig: FullConfig['browser']) {
|
||||||
|
super('remote', browserConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
const url = new URL(this.browserConfig.remoteEndpoint!);
|
||||||
|
url.searchParams.set('browser', this.browserConfig.browserName);
|
||||||
|
if (this.browserConfig.launchOptions)
|
||||||
|
url.searchParams.set('launch-options', JSON.stringify(this.browserConfig.launchOptions));
|
||||||
|
return playwright[this.browserConfig.browserName].connect(String(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
|
return browser.newContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PersistentContextFactory implements BrowserContextFactory {
|
||||||
|
readonly browserConfig: FullConfig['browser'];
|
||||||
|
private _userDataDirs = new Set<string>();
|
||||||
|
|
||||||
|
constructor(browserConfig: FullConfig['browser']) {
|
||||||
|
this.browserConfig = browserConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
|
testDebug('create browser context (persistent)');
|
||||||
|
const userDataDir = this.browserConfig.userDataDir ?? await this._createUserDataDir();
|
||||||
|
|
||||||
|
this._userDataDirs.add(userDataDir);
|
||||||
|
testDebug('lock user data dir', userDataDir);
|
||||||
|
|
||||||
|
const browserType = playwright[this.browserConfig.browserName];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
try {
|
||||||
|
const browserContext = await browserType.launchPersistentContext(userDataDir, {
|
||||||
|
...this.browserConfig.launchOptions,
|
||||||
|
...this.browserConfig.contextOptions,
|
||||||
|
handleSIGINT: false,
|
||||||
|
handleSIGTERM: false,
|
||||||
|
});
|
||||||
|
const close = () => this._closeBrowserContext(browserContext, userDataDir);
|
||||||
|
return { browserContext, close };
|
||||||
|
} 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.`);
|
||||||
|
if (error.message.includes('ProcessSingleton') || error.message.includes('Invalid URL')) {
|
||||||
|
// User data directory is already in use, try again.
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Browser is already in use for ${userDataDir}, use --isolated to run multiple instances of the same browser`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _closeBrowserContext(browserContext: playwright.BrowserContext, userDataDir: string) {
|
||||||
|
testDebug('close browser context (persistent)');
|
||||||
|
testDebug('release user data dir', userDataDir);
|
||||||
|
await browserContext.close().catch(() => {});
|
||||||
|
this._userDataDirs.delete(userDataDir);
|
||||||
|
testDebug('close browser context complete (persistent)');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _createUserDataDir() {
|
||||||
|
let cacheDirectory: string;
|
||||||
|
if (process.platform === 'linux')
|
||||||
|
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
||||||
|
else if (process.platform === 'darwin')
|
||||||
|
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
||||||
|
else if (process.platform === 'win32')
|
||||||
|
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
||||||
|
else
|
||||||
|
throw new Error('Unsupported platform: ' + process.platform);
|
||||||
|
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${this.browserConfig.launchOptions?.channel ?? this.browserConfig?.browserName}-profile`);
|
||||||
|
await fs.promises.mkdir(result, { recursive: true });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -68,19 +68,21 @@ const defaultConfig: FullConfig = {
|
|||||||
allowedOrigins: undefined,
|
allowedOrigins: undefined,
|
||||||
blockedOrigins: undefined,
|
blockedOrigins: undefined,
|
||||||
},
|
},
|
||||||
|
server: {},
|
||||||
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
|
outputDir: path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString())),
|
||||||
};
|
};
|
||||||
|
|
||||||
type BrowserUserConfig = NonNullable<Config['browser']>;
|
type BrowserUserConfig = NonNullable<Config['browser']>;
|
||||||
|
|
||||||
export type FullConfig = Config & {
|
export type FullConfig = Config & {
|
||||||
browser: BrowserUserConfig & {
|
browser: Omit<BrowserUserConfig, 'browserName'> & {
|
||||||
browserName: NonNullable<BrowserUserConfig['browserName']>;
|
browserName: 'chromium' | 'firefox' | 'webkit';
|
||||||
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
|
launchOptions: NonNullable<BrowserUserConfig['launchOptions']>;
|
||||||
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
|
contextOptions: NonNullable<BrowserUserConfig['contextOptions']>;
|
||||||
},
|
},
|
||||||
network: NonNullable<Config['network']>,
|
network: NonNullable<Config['network']>,
|
||||||
outputDir: string;
|
outputDir: string;
|
||||||
|
server: NonNullable<Config['server']>,
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function resolveConfig(config: Config): Promise<FullConfig> {
|
export async function resolveConfig(config: Config): Promise<FullConfig> {
|
||||||
@@ -256,6 +258,10 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
|
|||||||
network: {
|
network: {
|
||||||
...pickDefined(base.network),
|
...pickDefined(base.network),
|
||||||
...pickDefined(overrides.network),
|
...pickDefined(overrides.network),
|
||||||
}
|
},
|
||||||
|
server: {
|
||||||
|
...pickDefined(base.server),
|
||||||
|
...pickDefined(overrides.server),
|
||||||
|
},
|
||||||
} as FullConfig;
|
} as FullConfig;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,22 +14,24 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
import { Server as McpServer } from '@modelcontextprotocol/sdk/server/index.js';
|
||||||
import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
|
import { CallToolRequestSchema, ListToolsRequestSchema, Tool as McpTool } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||||
|
|
||||||
import { Context, packageJSON } from './context.js';
|
import { Context } from './context.js';
|
||||||
import { snapshotTools, visionTools } from './tools.js';
|
import { snapshotTools, visionTools } from './tools.js';
|
||||||
|
import { packageJSON } from './package.js';
|
||||||
|
|
||||||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
||||||
import { FullConfig } from './config.js';
|
import { FullConfig } from './config.js';
|
||||||
|
|
||||||
export async function createConnection(config: FullConfig): Promise<Connection> {
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
|
|
||||||
|
export function createConnection(config: FullConfig, browserContextFactory: BrowserContextFactory): Connection {
|
||||||
const allTools = config.vision ? visionTools : snapshotTools;
|
const allTools = config.vision ? visionTools : snapshotTools;
|
||||||
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
const tools = allTools.filter(tool => !config.capabilities || tool.capability === 'core' || config.capabilities.includes(tool.capability));
|
||||||
|
|
||||||
const context = new Context(tools, config);
|
const context = new Context(tools, config, browserContextFactory);
|
||||||
const server = new Server({ name: 'Playwright', version: packageJSON.version }, {
|
const server = new McpServer({ name: 'Playwright', version: packageJSON.version }, {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
tools: {},
|
tools: {},
|
||||||
}
|
}
|
||||||
@@ -74,25 +76,19 @@ export async function createConnection(config: FullConfig): Promise<Connection>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const connection = new Connection(server, context);
|
return new Connection(server, context);
|
||||||
return connection;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Connection {
|
export class Connection {
|
||||||
readonly server: Server;
|
readonly server: McpServer;
|
||||||
readonly context: Context;
|
readonly context: Context;
|
||||||
|
|
||||||
constructor(server: Server, context: Context) {
|
constructor(server: McpServer, context: Context) {
|
||||||
this.server = server;
|
this.server = server;
|
||||||
this.context = context;
|
this.context = context;
|
||||||
}
|
this.server.oninitialized = () => {
|
||||||
|
this.context.clientVersion = this.server.getClientVersion();
|
||||||
async connect(transport: Transport) {
|
};
|
||||||
await this.server.connect(transport);
|
|
||||||
await new Promise<void>(resolve => {
|
|
||||||
this.server.oninitialized = () => resolve();
|
|
||||||
});
|
|
||||||
this.context.clientVersion = this.server.getClientVersion();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
|
|||||||
104
src/context.ts
104
src/context.ts
@@ -14,11 +14,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'node:fs';
|
import debug from 'debug';
|
||||||
import url from 'node:url';
|
|
||||||
import os from 'node:os';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import * as playwright from 'playwright';
|
import * as playwright from 'playwright';
|
||||||
|
|
||||||
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
import { callOnPageNoTrace, waitForCompletion } from './tools/utils.js';
|
||||||
@@ -29,20 +25,19 @@ import { outputFile } from './config.js';
|
|||||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||||
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
|
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
|
||||||
import type { FullConfig } from './config.js';
|
import type { FullConfig } from './config.js';
|
||||||
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
|
|
||||||
type PendingAction = {
|
type PendingAction = {
|
||||||
dialogShown: ManualPromise<void>;
|
dialogShown: ManualPromise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BrowserContextAndBrowser = {
|
const testDebug = debug('pw:mcp:test');
|
||||||
browser?: playwright.Browser;
|
|
||||||
browserContext: playwright.BrowserContext;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class Context {
|
export class Context {
|
||||||
readonly tools: Tool[];
|
readonly tools: Tool[];
|
||||||
readonly config: FullConfig;
|
readonly config: FullConfig;
|
||||||
private _browserContextPromise: Promise<BrowserContextAndBrowser> | undefined;
|
private _browserContextPromise: Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> | undefined;
|
||||||
|
private _browserContextFactory: BrowserContextFactory;
|
||||||
private _tabs: Tab[] = [];
|
private _tabs: Tab[] = [];
|
||||||
private _currentTab: Tab | undefined;
|
private _currentTab: Tab | undefined;
|
||||||
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
||||||
@@ -50,9 +45,11 @@ export class Context {
|
|||||||
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
||||||
clientVersion: { name: string; version: string; } | undefined;
|
clientVersion: { name: string; version: string; } | undefined;
|
||||||
|
|
||||||
constructor(tools: Tool[], config: FullConfig) {
|
constructor(tools: Tool[], config: FullConfig, browserContextFactory: BrowserContextFactory) {
|
||||||
this.tools = tools;
|
this.tools = tools;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this._browserContextFactory = browserContextFactory;
|
||||||
|
testDebug('create context');
|
||||||
}
|
}
|
||||||
|
|
||||||
clientSupportsImages(): boolean {
|
clientSupportsImages(): boolean {
|
||||||
@@ -297,15 +294,15 @@ ${code.join('\n')}
|
|||||||
if (!this._browserContextPromise)
|
if (!this._browserContextPromise)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
testDebug('close context');
|
||||||
|
|
||||||
const promise = this._browserContextPromise;
|
const promise = this._browserContextPromise;
|
||||||
this._browserContextPromise = undefined;
|
this._browserContextPromise = undefined;
|
||||||
|
|
||||||
await promise.then(async ({ browserContext, browser }) => {
|
await promise.then(async ({ browserContext, close }) => {
|
||||||
if (this.config.saveTrace)
|
if (this.config.saveTrace)
|
||||||
await browserContext.tracing.stop();
|
await browserContext.tracing.stop();
|
||||||
await browserContext.close().then(async () => {
|
await close();
|
||||||
await browser?.close();
|
|
||||||
}).catch(() => {});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,8 +330,10 @@ ${code.join('\n')}
|
|||||||
return this._browserContextPromise;
|
return this._browserContextPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _setupBrowserContext(): Promise<BrowserContextAndBrowser> {
|
private async _setupBrowserContext(): Promise<{ browserContext: playwright.BrowserContext, close: () => Promise<void> }> {
|
||||||
const { browser, browserContext } = await this._createBrowserContext();
|
// TODO: move to the browser context factory to make it based on isolation mode.
|
||||||
|
const result = await this._browserContextFactory.createContext();
|
||||||
|
const { browserContext } = result;
|
||||||
await this._setupRequestInterception(browserContext);
|
await this._setupRequestInterception(browserContext);
|
||||||
for (const page of browserContext.pages())
|
for (const page of browserContext.pages())
|
||||||
this._onPageCreated(page);
|
this._onPageCreated(page);
|
||||||
@@ -347,75 +346,6 @@ ${code.join('\n')}
|
|||||||
sources: false,
|
sources: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { browser, browserContext };
|
return result;
|
||||||
}
|
|
||||||
|
|
||||||
private async _createBrowserContext(): Promise<BrowserContextAndBrowser> {
|
|
||||||
if (this.config.browser?.remoteEndpoint) {
|
|
||||||
const url = new URL(this.config.browser?.remoteEndpoint);
|
|
||||||
if (this.config.browser.browserName)
|
|
||||||
url.searchParams.set('browser', this.config.browser.browserName);
|
|
||||||
if (this.config.browser.launchOptions)
|
|
||||||
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
|
|
||||||
const browser = await playwright[this.config.browser?.browserName ?? 'chromium'].connect(String(url));
|
|
||||||
const browserContext = await browser.newContext();
|
|
||||||
return { browser, browserContext };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.config.browser?.cdpEndpoint) {
|
|
||||||
const browser = await playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
|
|
||||||
const browserContext = this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
|
|
||||||
return { browser, browserContext };
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.config.browser?.isolated ?
|
|
||||||
await createIsolatedContext(this.config.browser) :
|
|
||||||
await launchPersistentContext(this.config.browser);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createIsolatedContext(browserConfig: FullConfig['browser']): Promise<BrowserContextAndBrowser> {
|
|
||||||
try {
|
|
||||||
const browserName = browserConfig?.browserName ?? 'chromium';
|
|
||||||
const browserType = playwright[browserName];
|
|
||||||
const browser = await browserType.launch(browserConfig.launchOptions);
|
|
||||||
const browserContext = await browser.newContext(browserConfig.contextOptions);
|
|
||||||
return { browser, browserContext };
|
|
||||||
} 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 function launchPersistentContext(browserConfig: FullConfig['browser']): Promise<BrowserContextAndBrowser> {
|
|
||||||
try {
|
|
||||||
const browserName = browserConfig.browserName ?? 'chromium';
|
|
||||||
const userDataDir = browserConfig.userDataDir ?? await createUserDataDir({ ...browserConfig, browserName });
|
|
||||||
const browserType = playwright[browserName];
|
|
||||||
const browserContext = await browserType.launchPersistentContext(userDataDir, { ...browserConfig.launchOptions, ...browserConfig.contextOptions });
|
|
||||||
return { browserContext };
|
|
||||||
} 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 function createUserDataDir(browserConfig: FullConfig['browser']) {
|
|
||||||
let cacheDirectory: string;
|
|
||||||
if (process.platform === 'linux')
|
|
||||||
cacheDirectory = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
|
|
||||||
else if (process.platform === 'darwin')
|
|
||||||
cacheDirectory = path.join(os.homedir(), 'Library', 'Caches');
|
|
||||||
else if (process.platform === 'win32')
|
|
||||||
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
||||||
else
|
|
||||||
throw new Error('Unsupported platform: ' + process.platform);
|
|
||||||
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
|
|
||||||
await fs.promises.mkdir(result, { recursive: true });
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
|
||||||
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));
|
|
||||||
|
|||||||
24
src/index.ts
24
src/index.ts
@@ -16,10 +16,30 @@
|
|||||||
|
|
||||||
import { Connection, createConnection as createConnectionImpl } from './connection.js';
|
import { Connection, createConnection as createConnectionImpl } from './connection.js';
|
||||||
import { resolveConfig } from './config.js';
|
import { resolveConfig } from './config.js';
|
||||||
|
import { contextFactory } from './browserContextFactory.js';
|
||||||
|
|
||||||
import type { Config } from '../config.js';
|
import type { Config } from '../config.js';
|
||||||
|
import type { BrowserContext } from 'playwright';
|
||||||
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
|
|
||||||
export async function createConnection(userConfig: Config = {}): Promise<Connection> {
|
export async function createConnection(userConfig: Config = {}, contextGetter?: () => Promise<BrowserContext>): Promise<Connection> {
|
||||||
const config = await resolveConfig(userConfig);
|
const config = await resolveConfig(userConfig);
|
||||||
return createConnectionImpl(config);
|
const factory = contextGetter ? new SimpleBrowserContextFactory(contextGetter) : contextFactory(config.browser);
|
||||||
|
return createConnectionImpl(config, factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
class SimpleBrowserContextFactory implements BrowserContextFactory {
|
||||||
|
private readonly _contextGetter: () => Promise<BrowserContext>;
|
||||||
|
|
||||||
|
constructor(contextGetter: () => Promise<BrowserContext>) {
|
||||||
|
this._contextGetter = contextGetter;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createContext(): Promise<{ browserContext: BrowserContext, close: () => Promise<void> }> {
|
||||||
|
const browserContext = await this._contextGetter();
|
||||||
|
return {
|
||||||
|
browserContext,
|
||||||
|
close: () => browserContext.close()
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
src/package.ts
Normal file
22
src/package.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import url from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
export const packageJSON = JSON.parse(fs.readFileSync(path.join(path.dirname(__filename), '..', 'package.json'), 'utf8'));
|
||||||
@@ -15,14 +15,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { program } from 'commander';
|
import { program } from 'commander';
|
||||||
|
|
||||||
import { startHttpTransport, startStdioTransport } from './transport.js';
|
|
||||||
import { resolveCLIConfig } from './config.js';
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
||||||
|
|
||||||
import type { Connection } from './connection.js';
|
import { startHttpTransport, startStdioTransport } from './transport.js';
|
||||||
import { packageJSON } from './context.js';
|
import { resolveCLIConfig } from './config.js';
|
||||||
|
import { Server } from './server.js';
|
||||||
|
import { packageJSON } from './package.js';
|
||||||
|
|
||||||
program
|
program
|
||||||
.version('Version ' + packageJSON.version)
|
.version('Version ' + packageJSON.version)
|
||||||
@@ -54,13 +53,13 @@ program
|
|||||||
.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)')
|
||||||
.action(async options => {
|
.action(async options => {
|
||||||
const config = await resolveCLIConfig(options);
|
const config = await resolveCLIConfig(options);
|
||||||
const connectionList: Connection[] = [];
|
const server = new Server(config);
|
||||||
setupExitWatchdog(connectionList);
|
server.setupExitWatchdog();
|
||||||
|
|
||||||
if (options.port)
|
if (config.server.port !== undefined)
|
||||||
startHttpTransport(config, +options.port, options.host, connectionList);
|
startHttpTransport(server);
|
||||||
else
|
else
|
||||||
await startStdioTransport(config, connectionList);
|
await startStdioTransport(server);
|
||||||
|
|
||||||
if (config.saveTrace) {
|
if (config.saveTrace) {
|
||||||
const server = await startTraceViewerServer();
|
const server = await startTraceViewerServer();
|
||||||
@@ -71,21 +70,8 @@ program
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupExitWatchdog(connectionList: Connection[]) {
|
|
||||||
const handleExit = async () => {
|
|
||||||
setTimeout(() => process.exit(0), 15000);
|
|
||||||
for (const connection of connectionList)
|
|
||||||
await connection.close();
|
|
||||||
process.exit(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
process.stdin.on('close', handleExit);
|
|
||||||
process.on('SIGINT', handleExit);
|
|
||||||
process.on('SIGTERM', handleExit);
|
|
||||||
}
|
|
||||||
|
|
||||||
function semicolonSeparatedList(value: string): string[] {
|
function semicolonSeparatedList(value: string): string[] {
|
||||||
return value.split(';').map(v => v.trim());
|
return value.split(';').map(v => v.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
program.parse(process.argv);
|
void program.parseAsync(process.argv);
|
||||||
|
|||||||
59
src/server.ts
Normal file
59
src/server.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createConnection } from './connection.js';
|
||||||
|
import { contextFactory } from './browserContextFactory.js';
|
||||||
|
|
||||||
|
import type { FullConfig } from './config.js';
|
||||||
|
import type { Connection } from './connection.js';
|
||||||
|
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
|
||||||
|
import type { BrowserContextFactory } from './browserContextFactory.js';
|
||||||
|
|
||||||
|
export class Server {
|
||||||
|
readonly config: FullConfig;
|
||||||
|
private _connectionList: Connection[] = [];
|
||||||
|
private _browserConfig: FullConfig['browser'];
|
||||||
|
private _contextFactory: BrowserContextFactory;
|
||||||
|
|
||||||
|
constructor(config: FullConfig) {
|
||||||
|
this.config = config;
|
||||||
|
this._browserConfig = config.browser;
|
||||||
|
this._contextFactory = contextFactory(this._browserConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createConnection(transport: Transport): Promise<Connection> {
|
||||||
|
const connection = createConnection(this.config, this._contextFactory);
|
||||||
|
this._connectionList.push(connection);
|
||||||
|
await connection.server.connect(transport);
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
setupExitWatchdog() {
|
||||||
|
let isExiting = false;
|
||||||
|
const handleExit = async () => {
|
||||||
|
if (isExiting)
|
||||||
|
return;
|
||||||
|
isExiting = true;
|
||||||
|
setTimeout(() => process.exit(0), 15000);
|
||||||
|
await Promise.all(this._connectionList.map(connection => connection.close()));
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.stdin.on('close', handleExit);
|
||||||
|
process.on('SIGINT', handleExit);
|
||||||
|
process.on('SIGTERM', handleExit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,22 +18,20 @@ import http from 'node:http';
|
|||||||
import assert from 'node:assert';
|
import assert from 'node:assert';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
import debug from 'debug';
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
|
||||||
import { createConnection } from './connection.js';
|
import type { Server } from './server.js';
|
||||||
|
|
||||||
import type { Connection } from './connection.js';
|
export async function startStdioTransport(server: Server) {
|
||||||
import type { FullConfig } from './config.js';
|
await server.createConnection(new StdioServerTransport());
|
||||||
|
|
||||||
export async function startStdioTransport(config: FullConfig, connectionList: Connection[]) {
|
|
||||||
const connection = await createConnection(config);
|
|
||||||
await connection.connect(new StdioServerTransport());
|
|
||||||
connectionList.push(connection);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSSE(config: FullConfig, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>, connectionList: Connection[]) {
|
const testDebug = debug('pw:mcp:test');
|
||||||
|
|
||||||
|
async function handleSSE(server: Server, req: http.IncomingMessage, res: http.ServerResponse, url: URL, sessions: Map<string, SSEServerTransport>) {
|
||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
const sessionId = url.searchParams.get('sessionId');
|
const sessionId = url.searchParams.get('sessionId');
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -51,15 +49,13 @@ async function handleSSE(config: FullConfig, req: http.IncomingMessage, res: htt
|
|||||||
} else if (req.method === 'GET') {
|
} else if (req.method === 'GET') {
|
||||||
const transport = new SSEServerTransport('/sse', res);
|
const transport = new SSEServerTransport('/sse', res);
|
||||||
sessions.set(transport.sessionId, transport);
|
sessions.set(transport.sessionId, transport);
|
||||||
const connection = await createConnection(config);
|
testDebug(`create SSE session: ${transport.sessionId}`);
|
||||||
await connection.connect(transport);
|
const connection = await server.createConnection(transport);
|
||||||
connectionList.push(connection);
|
|
||||||
res.on('close', () => {
|
res.on('close', () => {
|
||||||
|
testDebug(`delete SSE session: ${transport.sessionId}`);
|
||||||
sessions.delete(transport.sessionId);
|
sessions.delete(transport.sessionId);
|
||||||
connection.close().catch(e => {
|
// eslint-disable-next-line no-console
|
||||||
// eslint-disable-next-line no-console
|
void connection.close().catch(e => console.error(e));
|
||||||
console.error(e);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -68,7 +64,7 @@ async function handleSSE(config: FullConfig, req: http.IncomingMessage, res: htt
|
|||||||
res.end('Method not allowed');
|
res.end('Method not allowed');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStreamable(config: FullConfig, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>, connectionList: Connection[]) {
|
async function handleStreamable(server: Server, req: http.IncomingMessage, res: http.ServerResponse, sessions: Map<string, StreamableHTTPServerTransport>) {
|
||||||
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
const sessionId = req.headers['mcp-session-id'] as string | undefined;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
const transport = sessions.get(sessionId);
|
const transport = sessions.get(sessionId);
|
||||||
@@ -91,12 +87,8 @@ async function handleStreamable(config: FullConfig, req: http.IncomingMessage, r
|
|||||||
if (transport.sessionId)
|
if (transport.sessionId)
|
||||||
sessions.delete(transport.sessionId);
|
sessions.delete(transport.sessionId);
|
||||||
};
|
};
|
||||||
const connection = await createConnection(config);
|
await server.createConnection(transport);
|
||||||
connectionList.push(connection);
|
await transport.handleRequest(req, res);
|
||||||
await Promise.all([
|
|
||||||
connection.connect(transport),
|
|
||||||
transport.handleRequest(req, res),
|
|
||||||
]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,17 +96,18 @@ async function handleStreamable(config: FullConfig, req: http.IncomingMessage, r
|
|||||||
res.end('Invalid request');
|
res.end('Invalid request');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startHttpTransport(config: FullConfig, port: number, hostname: string | undefined, connectionList: Connection[]) {
|
export function startHttpTransport(server: Server) {
|
||||||
const sseSessions = new Map<string, SSEServerTransport>();
|
const sseSessions = new Map<string, SSEServerTransport>();
|
||||||
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
|
const streamableSessions = new Map<string, StreamableHTTPServerTransport>();
|
||||||
const httpServer = http.createServer(async (req, res) => {
|
const httpServer = http.createServer(async (req, res) => {
|
||||||
const url = new URL(`http://localhost${req.url}`);
|
const url = new URL(`http://localhost${req.url}`);
|
||||||
if (url.pathname.startsWith('/mcp'))
|
if (url.pathname.startsWith('/mcp'))
|
||||||
await handleStreamable(config, req, res, streamableSessions, connectionList);
|
await handleStreamable(server, req, res, streamableSessions);
|
||||||
else
|
else
|
||||||
await handleSSE(config, req, res, url, sseSessions, connectionList);
|
await handleSSE(server, req, res, url, sseSessions);
|
||||||
});
|
});
|
||||||
httpServer.listen(port, hostname, () => {
|
const { host, port } = server.config.server;
|
||||||
|
httpServer.listen(port, host, () => {
|
||||||
const address = httpServer.address();
|
const address = httpServer.address();
|
||||||
assert(address, 'Could not bind server socket');
|
assert(address, 'Could not bind server socket');
|
||||||
let url: string;
|
let url: string;
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ test('test vision tool list', async ({ visionClient }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('test capabilities', async ({ startClient }) => {
|
test('test capabilities', async ({ startClient }) => {
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
args: ['--caps="core"'],
|
args: ['--caps="core"'],
|
||||||
});
|
});
|
||||||
const { tools } = await client.listTools();
|
const { tools } = await client.listTools();
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { test, expect } from './fixtures.js';
|
|||||||
|
|
||||||
test('cdp server', async ({ cdpServer, startClient, server }) => {
|
test('cdp server', async ({ cdpServer, startClient, server }) => {
|
||||||
await cdpServer.start();
|
await cdpServer.start();
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
@@ -27,7 +27,7 @@ test('cdp server', async ({ cdpServer, startClient, server }) => {
|
|||||||
|
|
||||||
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
||||||
const browserContext = await cdpServer.start();
|
const browserContext = await cdpServer.start();
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||||
|
|
||||||
const [page] = browserContext.pages();
|
const [page] = browserContext.pages();
|
||||||
await page.goto(server.HELLO_WORLD);
|
await page.goto(server.HELLO_WORLD);
|
||||||
@@ -58,7 +58,7 @@ test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
|
test('should throw connection error and allow re-connecting', async ({ cdpServer, startClient, server }) => {
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||||
|
|
||||||
server.setContent('/', `
|
server.setContent('/', `
|
||||||
<title>Title</title>
|
<title>Title</title>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ test('config user data dir', async ({ startClient, server }, testInfo) => {
|
|||||||
const configPath = testInfo.outputPath('config.json');
|
const configPath = testInfo.outputPath('config.json');
|
||||||
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
|
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||||
|
|
||||||
const client = await startClient({ args: ['--config', configPath] });
|
const { client } = await startClient({ args: ['--config', configPath] });
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
@@ -54,7 +54,7 @@ test.describe(() => {
|
|||||||
const configPath = testInfo.outputPath('config.json');
|
const configPath = testInfo.outputPath('config.json');
|
||||||
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
|
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||||
|
|
||||||
const client = await startClient({ args: ['--config', configPath] });
|
const { client } = await startClient({ args: ['--config', configPath] });
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: 'data:text/html,<script>document.title = navigator.userAgent</script>' },
|
arguments: { url: 'data:text/html,<script>document.title = navigator.userAgent</script>' },
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('--device should work', async ({ startClient, server }) => {
|
test('--device should work', async ({ startClient, server }) => {
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
args: ['--device', 'iPhone 15'],
|
args: ['--device', 'iPhone 15'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('clicking on download link emits download', async ({ startClient, server }, testInfo) => {
|
test('clicking on download link emits download', async ({ startClient, server }, testInfo) => {
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
config: { outputDir: testInfo.outputPath('output') },
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ test('clicking on download link emits download', async ({ startClient, server },
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser }, testInfo) => {
|
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser }, testInfo) => {
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
config: { outputDir: testInfo.outputPath('output') },
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ type CDPServer = {
|
|||||||
type TestFixtures = {
|
type TestFixtures = {
|
||||||
client: Client;
|
client: Client;
|
||||||
visionClient: Client;
|
visionClient: Client;
|
||||||
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<Client>;
|
startClient: (options?: { clientName?: string, args?: string[], config?: Config }) => Promise<{ client: Client, stderr: () => string }>;
|
||||||
wsEndpoint: string;
|
wsEndpoint: string;
|
||||||
cdpServer: CDPServer;
|
cdpServer: CDPServer;
|
||||||
server: TestServer;
|
server: TestServer;
|
||||||
@@ -55,11 +55,13 @@ type WorkerFixtures = {
|
|||||||
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
|
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
|
||||||
|
|
||||||
client: async ({ startClient }, use) => {
|
client: async ({ startClient }, use) => {
|
||||||
await use(await startClient());
|
const { client } = await startClient();
|
||||||
|
await use(client);
|
||||||
},
|
},
|
||||||
|
|
||||||
visionClient: async ({ startClient }, use) => {
|
visionClient: async ({ startClient }, use) => {
|
||||||
await use(await startClient({ args: ['--vision'] }));
|
const { client } = await startClient({ args: ['--vision'] });
|
||||||
|
await use(client);
|
||||||
},
|
},
|
||||||
|
|
||||||
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
startClient: async ({ mcpHeadless, mcpBrowser, mcpMode }, use, testInfo) => {
|
||||||
@@ -85,9 +87,13 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
|
|
||||||
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
|
client = new Client({ name: options?.clientName ?? 'test', version: '1.0.0' });
|
||||||
const transport = createTransport(args, mcpMode);
|
const transport = createTransport(args, mcpMode);
|
||||||
|
let stderr = '';
|
||||||
|
transport.stderr?.on('data', data => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
await client.ping();
|
await client.ping();
|
||||||
return client;
|
return { client, stderr: () => stderr };
|
||||||
});
|
});
|
||||||
|
|
||||||
await client?.close();
|
await client?.close();
|
||||||
@@ -168,7 +174,13 @@ function createTransport(args: string[], mcpMode: TestOptions['mcpMode']) {
|
|||||||
command: 'node',
|
command: 'node',
|
||||||
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
||||||
cwd: path.join(path.dirname(__filename), '..'),
|
cwd: path.join(path.dirname(__filename), '..'),
|
||||||
env: process.env as Record<string, string>,
|
stderr: 'pipe',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DEBUG: 'pw:mcp:test',
|
||||||
|
DEBUG_COLORS: '0',
|
||||||
|
DEBUG_HIDE_DATE: '1',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,3 +237,7 @@ export const expect = baseExpect.extend({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function formatOutput(output: string): string[] {
|
||||||
|
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/test-results.*/, '').trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,9 +16,10 @@
|
|||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect, formatOutput } from './fixtures.js';
|
||||||
|
|
||||||
test('test reopen browser', async ({ client, server }) => {
|
test('test reopen browser', async ({ startClient, server }) => {
|
||||||
|
const { client, stderr } = await startClient();
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
@@ -32,10 +33,31 @@ test('test reopen browser', async ({ client, server }) => {
|
|||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
||||||
|
|
||||||
|
await client.close();
|
||||||
|
|
||||||
|
if (process.platform === 'win32')
|
||||||
|
return;
|
||||||
|
|
||||||
|
await expect.poll(() => formatOutput(stderr()), { timeout: 0 }).toEqual([
|
||||||
|
'create context',
|
||||||
|
'create browser context (persistent)',
|
||||||
|
'lock user data dir',
|
||||||
|
'close context',
|
||||||
|
'close browser context (persistent)',
|
||||||
|
'release user data dir',
|
||||||
|
'close browser context complete (persistent)',
|
||||||
|
'create browser context (persistent)',
|
||||||
|
'lock user data dir',
|
||||||
|
'close context',
|
||||||
|
'close browser context (persistent)',
|
||||||
|
'release user data dir',
|
||||||
|
'close browser context complete (persistent)',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('executable path', async ({ startClient, server }) => {
|
test('executable path', async ({ startClient, server }) => {
|
||||||
const client = await startClient({ args: [`--executable-path=bogus`] });
|
const { client } = await startClient({ args: [`--executable-path=bogus`] });
|
||||||
const response = await client.callTool({
|
const response = await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
@@ -53,7 +75,7 @@ test('persistent context', async ({ startClient, server }) => {
|
|||||||
</script>
|
</script>
|
||||||
`, 'text/html');
|
`, 'text/html');
|
||||||
|
|
||||||
const client = await startClient();
|
const { client } = await startClient();
|
||||||
const response = await client.callTool({
|
const response = await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
@@ -66,7 +88,7 @@ test('persistent context', async ({ startClient, server }) => {
|
|||||||
name: 'browser_close',
|
name: 'browser_close',
|
||||||
});
|
});
|
||||||
|
|
||||||
const client2 = await startClient();
|
const { client: client2 } = await startClient();
|
||||||
const response2 = await client2.callTool({
|
const response2 = await client2.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
@@ -85,18 +107,18 @@ test('isolated context', async ({ startClient, server }) => {
|
|||||||
</script>
|
</script>
|
||||||
`, 'text/html');
|
`, 'text/html');
|
||||||
|
|
||||||
const client = await startClient({ args: [`--isolated`] });
|
const { client: client1 } = await startClient({ args: [`--isolated`] });
|
||||||
const response = await client.callTool({
|
const response = await client1.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
});
|
});
|
||||||
expect(response).toContainTextContent(`Storage: NO`);
|
expect(response).toContainTextContent(`Storage: NO`);
|
||||||
|
|
||||||
await client.callTool({
|
await client1.callTool({
|
||||||
name: 'browser_close',
|
name: 'browser_close',
|
||||||
});
|
});
|
||||||
|
|
||||||
const client2 = await startClient({ args: [`--isolated`] });
|
const { client: client2 } = await startClient({ args: [`--isolated`] });
|
||||||
const response2 = await client2.callTool({
|
const response2 = await client2.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.PREFIX },
|
arguments: { url: server.PREFIX },
|
||||||
@@ -123,7 +145,7 @@ test('isolated context with storage state', async ({ startClient, server }, test
|
|||||||
</script>
|
</script>
|
||||||
`, 'text/html');
|
`, 'text/html');
|
||||||
|
|
||||||
const client = await startClient({ args: [
|
const { client } = await startClient({ args: [
|
||||||
`--isolated`,
|
`--isolated`,
|
||||||
`--storage-state=${storageStatePath}`,
|
`--storage-state=${storageStatePath}`,
|
||||||
] });
|
] });
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import fs from 'fs';
|
|||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('save as pdf unavailable', async ({ startClient, server }) => {
|
test('save as pdf unavailable', async ({ startClient, server }) => {
|
||||||
const client = await startClient({ args: ['--caps="no-pdf"'] });
|
const { client } = await startClient({ args: ['--caps="no-pdf"'] });
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
@@ -31,7 +31,7 @@ test('save as pdf unavailable', async ({ startClient, server }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
|
test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
config: { outputDir: testInfo.outputPath('output') },
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
|
|||||||
test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server }, testInfo) => {
|
test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server }, testInfo) => {
|
||||||
const outputDir = testInfo.outputPath('output');
|
const outputDir = testInfo.outputPath('output');
|
||||||
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
|
test.skip(!!mcpBrowser && !['chromium', 'chrome', 'msedge'].includes(mcpBrowser), 'Save as PDF is only supported in Chromium.');
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
config: { outputDir },
|
config: { outputDir },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ test('default to allow all', async ({ server, client }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('blocked works', async ({ startClient }) => {
|
test('blocked works', async ({ startClient }) => {
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
args: ['--blocked-origins', 'microsoft.com;example.com;playwright.dev']
|
args: ['--blocked-origins', 'microsoft.com;example.com;playwright.dev']
|
||||||
});
|
});
|
||||||
const result = await fetchPage(client, 'https://example.com/');
|
const result = await fetchPage(client, 'https://example.com/');
|
||||||
@@ -46,7 +46,7 @@ test('blocked works', async ({ startClient }) => {
|
|||||||
|
|
||||||
test('allowed works', async ({ server, startClient }) => {
|
test('allowed works', async ({ server, startClient }) => {
|
||||||
server.setContent('/ppp', 'content:PPP', 'text/html');
|
server.setContent('/ppp', 'content:PPP', 'text/html');
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`]
|
args: ['--allowed-origins', `microsoft.com;${new URL(server.PREFIX).host};playwright.dev`]
|
||||||
});
|
});
|
||||||
const result = await fetchPage(client, server.PREFIX + 'ppp');
|
const result = await fetchPage(client, server.PREFIX + 'ppp');
|
||||||
@@ -54,7 +54,7 @@ test('allowed works', async ({ server, startClient }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('blocked takes precedence', async ({ startClient }) => {
|
test('blocked takes precedence', async ({ startClient }) => {
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
args: [
|
args: [
|
||||||
'--blocked-origins', 'example.com',
|
'--blocked-origins', 'example.com',
|
||||||
'--allowed-origins', 'example.com',
|
'--allowed-origins', 'example.com',
|
||||||
@@ -65,7 +65,7 @@ test('blocked takes precedence', async ({ startClient }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('allowed without blocked blocks all non-explicitly specified origins', async ({ startClient }) => {
|
test('allowed without blocked blocks all non-explicitly specified origins', async ({ startClient }) => {
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
args: ['--allowed-origins', 'playwright.dev'],
|
args: ['--allowed-origins', 'playwright.dev'],
|
||||||
});
|
});
|
||||||
const result = await fetchPage(client, 'https://example.com/');
|
const result = await fetchPage(client, 'https://example.com/');
|
||||||
@@ -74,7 +74,7 @@ test('allowed without blocked blocks all non-explicitly specified origins', asyn
|
|||||||
|
|
||||||
test('blocked without allowed allows non-explicitly specified origins', async ({ server, startClient }) => {
|
test('blocked without allowed allows non-explicitly specified origins', async ({ server, startClient }) => {
|
||||||
server.setContent('/ppp', 'content:PPP', 'text/html');
|
server.setContent('/ppp', 'content:PPP', 'text/html');
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
args: ['--blocked-origins', 'example.com'],
|
args: ['--blocked-origins', 'example.com'],
|
||||||
});
|
});
|
||||||
const result = await fetchPage(client, server.PREFIX + 'ppp');
|
const result = await fetchPage(client, server.PREFIX + 'ppp');
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import fs from 'fs';
|
|||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('browser_take_screenshot (viewport)', async ({ startClient, server }, testInfo) => {
|
test('browser_take_screenshot (viewport)', async ({ startClient, server }, testInfo) => {
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
config: { outputDir: testInfo.outputPath('output') },
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
});
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
@@ -45,7 +45,7 @@ test('browser_take_screenshot (viewport)', async ({ startClient, server }, testI
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('browser_take_screenshot (element)', async ({ startClient, server }, testInfo) => {
|
test('browser_take_screenshot (element)', async ({ startClient, server }, testInfo) => {
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
config: { outputDir: testInfo.outputPath('output') },
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
});
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
@@ -76,7 +76,7 @@ test('browser_take_screenshot (element)', async ({ startClient, server }, testIn
|
|||||||
|
|
||||||
test('--output-dir should work', async ({ startClient, server }, testInfo) => {
|
test('--output-dir should work', async ({ startClient, server }, testInfo) => {
|
||||||
const outputDir = testInfo.outputPath('output');
|
const outputDir = testInfo.outputPath('output');
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
config: { outputDir },
|
config: { outputDir },
|
||||||
});
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
@@ -98,7 +98,7 @@ for (const raw of [undefined, true]) {
|
|||||||
test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, server }, testInfo) => {
|
test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, server }, testInfo) => {
|
||||||
const outputDir = testInfo.outputPath('output');
|
const outputDir = testInfo.outputPath('output');
|
||||||
const ext = raw ? 'png' : 'jpeg';
|
const ext = raw ? 'png' : 'jpeg';
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
config: { outputDir },
|
config: { outputDir },
|
||||||
});
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
@@ -138,7 +138,7 @@ for (const raw of [undefined, true]) {
|
|||||||
|
|
||||||
test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, server }, testInfo) => {
|
test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, server }, testInfo) => {
|
||||||
const outputDir = testInfo.outputPath('output');
|
const outputDir = testInfo.outputPath('output');
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
config: { outputDir },
|
config: { outputDir },
|
||||||
});
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
@@ -174,7 +174,7 @@ test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient,
|
|||||||
|
|
||||||
test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, server }, testInfo) => {
|
test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, server }, testInfo) => {
|
||||||
const outputDir = testInfo.outputPath('output');
|
const outputDir = testInfo.outputPath('output');
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
config: {
|
config: {
|
||||||
outputDir,
|
outputDir,
|
||||||
imageResponses: 'omit',
|
imageResponses: 'omit',
|
||||||
@@ -205,7 +205,7 @@ test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, serv
|
|||||||
test('browser_take_screenshot (cursor)', async ({ startClient, server }, testInfo) => {
|
test('browser_take_screenshot (cursor)', async ({ startClient, server }, testInfo) => {
|
||||||
const outputDir = testInfo.outputPath('output');
|
const outputDir = testInfo.outputPath('output');
|
||||||
|
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
clientName: 'cursor:vscode',
|
clientName: 'cursor:vscode',
|
||||||
config: { outputDir },
|
config: { outputDir },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,96 +14,233 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
import url from 'node:url';
|
import url from 'node:url';
|
||||||
import http from 'node:http';
|
|
||||||
import { spawn } from 'node:child_process';
|
import { ChildProcess, spawn } from 'node:child_process';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { AddressInfo } from 'node:net';
|
|
||||||
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
||||||
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
|
||||||
import { createConnection } from '@playwright/mcp';
|
|
||||||
|
|
||||||
import { test as baseTest, expect } from './fixtures.js';
|
import { test as baseTest, expect } from './fixtures.js';
|
||||||
|
import type { Config } from '../config.d.ts';
|
||||||
|
|
||||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||||
const __filename = url.fileURLToPath(import.meta.url);
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
const test = baseTest.extend<{ serverEndpoint: string }>({
|
const test = baseTest.extend<{ serverEndpoint: (options?: { args?: string[], noPort?: boolean }) => Promise<{ url: URL, stderr: () => string }> }>({
|
||||||
serverEndpoint: async ({}, use) => {
|
serverEndpoint: async ({ mcpHeadless }, use, testInfo) => {
|
||||||
const cp = spawn('node', [path.join(path.dirname(__filename), '../cli.js'), '--port', '0'], { stdio: 'pipe' });
|
let cp: ChildProcess | undefined;
|
||||||
try {
|
const userDataDir = testInfo.outputPath('user-data-dir');
|
||||||
|
await use(async (options?: { args?: string[], noPort?: boolean }) => {
|
||||||
|
if (cp)
|
||||||
|
throw new Error('Process already running');
|
||||||
|
|
||||||
|
cp = spawn('node', [
|
||||||
|
path.join(path.dirname(__filename), '../cli.js'),
|
||||||
|
...(options?.noPort ? [] : ['--port=0']),
|
||||||
|
'--user-data-dir=' + userDataDir,
|
||||||
|
...(mcpHeadless ? ['--headless'] : []),
|
||||||
|
...(options?.args || []),
|
||||||
|
], {
|
||||||
|
stdio: 'pipe',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DEBUG: 'pw:mcp:test',
|
||||||
|
DEBUG_COLORS: '0',
|
||||||
|
DEBUG_HIDE_DATE: '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
const url = await new Promise<string>(resolve => cp.stderr?.on('data', data => {
|
const url = await new Promise<string>(resolve => cp!.stderr?.on('data', data => {
|
||||||
stderr += data.toString();
|
stderr += data.toString();
|
||||||
const match = stderr.match(/Listening on (http:\/\/.*)/);
|
const match = stderr.match(/Listening on (http:\/\/.*)/);
|
||||||
if (match)
|
if (match)
|
||||||
resolve(match[1]);
|
resolve(match[1]);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await use(url);
|
return { url: new URL(url), stderr: () => stderr };
|
||||||
} finally {
|
});
|
||||||
cp.kill();
|
cp?.kill('SIGTERM');
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sse transport', async ({ serverEndpoint }) => {
|
test('sse transport', async ({ serverEndpoint }) => {
|
||||||
const transport = new SSEClientTransport(new URL(serverEndpoint));
|
const { url } = await serverEndpoint();
|
||||||
|
const transport = new SSEClientTransport(url);
|
||||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
await client.ping();
|
await client.ping();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('sse transport (config)', async ({ serverEndpoint }) => {
|
||||||
|
const config: Config = {
|
||||||
|
server: {
|
||||||
|
port: 0,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const configFile = test.info().outputPath('config.json');
|
||||||
|
await fs.promises.writeFile(configFile, JSON.stringify(config, null, 2));
|
||||||
|
|
||||||
|
const { url } = await serverEndpoint({ noPort: true, args: ['--config=' + configFile] });
|
||||||
|
const transport = new SSEClientTransport(url);
|
||||||
|
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client.connect(transport);
|
||||||
|
await client.ping();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sse transport browser lifecycle (isolated)', async ({ serverEndpoint, server }) => {
|
||||||
|
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
|
||||||
|
|
||||||
|
const transport1 = new SSEClientTransport(url);
|
||||||
|
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client1.connect(transport1);
|
||||||
|
await client1.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
await client1.close();
|
||||||
|
|
||||||
|
const transport2 = new SSEClientTransport(url);
|
||||||
|
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client2.connect(transport2);
|
||||||
|
await client2.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
await client2.close();
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
const lines = stderr().split('\n');
|
||||||
|
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(2);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(2);
|
||||||
|
}).toPass();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sse transport browser lifecycle (isolated, multiclient)', async ({ serverEndpoint, server }) => {
|
||||||
|
const { url, stderr } = await serverEndpoint({ args: ['--isolated'] });
|
||||||
|
|
||||||
|
const transport1 = new SSEClientTransport(url);
|
||||||
|
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client1.connect(transport1);
|
||||||
|
await client1.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
|
||||||
|
const transport2 = new SSEClientTransport(url);
|
||||||
|
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client2.connect(transport2);
|
||||||
|
await client2.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
await client1.close();
|
||||||
|
|
||||||
|
const transport3 = new SSEClientTransport(url);
|
||||||
|
const client3 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client3.connect(transport3);
|
||||||
|
await client3.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
|
||||||
|
await client2.close();
|
||||||
|
await client3.close();
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
const lines = stderr().split('\n');
|
||||||
|
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(3);
|
||||||
|
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(3);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/create context/)).length).toBe(3);
|
||||||
|
expect(lines.filter(line => line.match(/close context/)).length).toBe(3);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/create browser context \(isolated\)/)).length).toBe(3);
|
||||||
|
expect(lines.filter(line => line.match(/close browser context \(isolated\)/)).length).toBe(3);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/obtain browser \(isolated\)/)).length).toBe(1);
|
||||||
|
expect(lines.filter(line => line.match(/close browser \(isolated\)/)).length).toBe(1);
|
||||||
|
}).toPass();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sse transport browser lifecycle (persistent)', async ({ serverEndpoint, server }) => {
|
||||||
|
const { url, stderr } = await serverEndpoint();
|
||||||
|
|
||||||
|
const transport1 = new SSEClientTransport(url);
|
||||||
|
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client1.connect(transport1);
|
||||||
|
await client1.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
await client1.close();
|
||||||
|
|
||||||
|
const transport2 = new SSEClientTransport(url);
|
||||||
|
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client2.connect(transport2);
|
||||||
|
await client2.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
await client2.close();
|
||||||
|
|
||||||
|
await expect(async () => {
|
||||||
|
const lines = stderr().split('\n');
|
||||||
|
expect(lines.filter(line => line.match(/create SSE session/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/delete SSE session/)).length).toBe(2);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/create context/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/close context/)).length).toBe(2);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/create browser context \(persistent\)/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/close browser context \(persistent\)/)).length).toBe(2);
|
||||||
|
|
||||||
|
expect(lines.filter(line => line.match(/lock user data dir/)).length).toBe(2);
|
||||||
|
expect(lines.filter(line => line.match(/release user data dir/)).length).toBe(2);
|
||||||
|
}).toPass();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sse transport browser lifecycle (persistent, multiclient)', async ({ serverEndpoint, server }) => {
|
||||||
|
const { url } = await serverEndpoint();
|
||||||
|
|
||||||
|
const transport1 = new SSEClientTransport(url);
|
||||||
|
const client1 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client1.connect(transport1);
|
||||||
|
await client1.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
|
||||||
|
const transport2 = new SSEClientTransport(url);
|
||||||
|
const client2 = new Client({ name: 'test', version: '1.0.0' });
|
||||||
|
await client2.connect(transport2);
|
||||||
|
const response = await client2.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
});
|
||||||
|
expect(response.isError).toBe(true);
|
||||||
|
expect(response.content?.[0].text).toContain('use --isolated to run multiple instances of the same browser');
|
||||||
|
|
||||||
|
await client1.close();
|
||||||
|
await client2.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('streamable http transport', async ({ serverEndpoint }) => {
|
test('streamable http transport', async ({ serverEndpoint }) => {
|
||||||
const transport = new StreamableHTTPClientTransport(new URL('/mcp', serverEndpoint));
|
const { url } = await serverEndpoint();
|
||||||
|
const transport = new StreamableHTTPClientTransport(new URL('/mcp', url));
|
||||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
const client = new Client({ name: 'test', version: '1.0.0' });
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
await client.ping();
|
await client.ping();
|
||||||
expect(transport.sessionId, 'has session support').toBeDefined();
|
expect(transport.sessionId, 'has session support').toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sse transport via public API', async ({ server }, testInfo) => {
|
|
||||||
const userDataDir = testInfo.outputPath('user-data-dir');
|
|
||||||
const sessions = new Map<string, SSEServerTransport>();
|
|
||||||
const mcpServer = http.createServer(async (req, res) => {
|
|
||||||
if (req.method === 'GET') {
|
|
||||||
const connection = await createConnection({
|
|
||||||
browser: {
|
|
||||||
userDataDir,
|
|
||||||
launchOptions: { headless: true }
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const transport = new SSEServerTransport('/sse', res);
|
|
||||||
sessions.set(transport.sessionId, transport);
|
|
||||||
await connection.connect(transport);
|
|
||||||
} else if (req.method === 'POST') {
|
|
||||||
const url = new URL(`http://localhost${req.url}`);
|
|
||||||
const sessionId = url.searchParams.get('sessionId');
|
|
||||||
if (!sessionId) {
|
|
||||||
res.statusCode = 400;
|
|
||||||
return res.end('Missing sessionId');
|
|
||||||
}
|
|
||||||
const transport = sessions.get(sessionId);
|
|
||||||
if (!transport) {
|
|
||||||
res.statusCode = 404;
|
|
||||||
return res.end('Session not found');
|
|
||||||
}
|
|
||||||
void transport.handlePostMessage(req, res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await new Promise<void>(resolve => mcpServer.listen(0, () => resolve()));
|
|
||||||
const serverUrl = `http://localhost:${(mcpServer.address() as AddressInfo).port}/sse`;
|
|
||||||
const transport = new SSEClientTransport(new URL(serverUrl));
|
|
||||||
const client = new Client({ name: 'test', version: '1.0.0' });
|
|
||||||
await client.connect(transport);
|
|
||||||
await client.ping();
|
|
||||||
expect(await client.callTool({
|
|
||||||
name: 'browser_navigate',
|
|
||||||
arguments: { url: server.HELLO_WORLD },
|
|
||||||
})).toContainTextContent(`- generic [ref=e1]: Hello, world!`);
|
|
||||||
await client.close();
|
|
||||||
mcpServer.close();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ test('reuse first tab when navigating', async ({ startClient, cdpServer, server
|
|||||||
const browserContext = await cdpServer.start();
|
const browserContext = await cdpServer.start();
|
||||||
const pages = browserContext.pages();
|
const pages = browserContext.pages();
|
||||||
|
|
||||||
const client = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
const { client } = await startClient({ args: [`--cdp-endpoint=${cdpServer.endpoint}`] });
|
||||||
await client.callTool({
|
await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
arguments: { url: server.HELLO_WORLD },
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { test, expect } from './fixtures.js';
|
|||||||
test('check that trace is saved', async ({ startClient, server }, testInfo) => {
|
test('check that trace is saved', async ({ startClient, server }, testInfo) => {
|
||||||
const outputDir = testInfo.outputPath('output');
|
const outputDir = testInfo.outputPath('output');
|
||||||
|
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
args: ['--save-trace', `--output-dir=${outputDir}`],
|
args: ['--save-trace', `--output-dir=${outputDir}`],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user