Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0df6d7a441 | ||
|
|
4ea7041ba9 | ||
|
|
7dae68de78 | ||
|
|
60495ed9b0 | ||
|
|
0aaef661b1 | ||
|
|
abbe7858a2 | ||
|
|
767af21e02 | ||
|
|
27c498e0e7 | ||
|
|
0fb9646c4d | ||
|
|
9728527900 | ||
|
|
675b083db3 | ||
|
|
0b74cdaaf8 | ||
|
|
f31ef598bc | ||
|
|
656779531c | ||
|
|
eec177d3ac | ||
|
|
54ed7c3200 | ||
|
|
3cd74a824a | ||
|
|
177b008328 | ||
|
|
9429463951 | ||
|
|
45f493da6c | ||
|
|
9e5ffd2ccf | ||
|
|
1051ea810a | ||
|
|
f20ae22ec6 | ||
|
|
13cd1b4bd9 |
37
README.md
37
README.md
@@ -52,6 +52,12 @@ After installation, the Playwright MCP server will be available for use with you
|
|||||||
<details>
|
<details>
|
||||||
<summary><b>Install in Cursor</b></summary>
|
<summary><b>Install in Cursor</b></summary>
|
||||||
|
|
||||||
|
#### Click the button to install:
|
||||||
|
|
||||||
|
[](https://cursor.com/install-mcp?name=playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
|
||||||
|
|
||||||
|
#### Or install manually:
|
||||||
|
|
||||||
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`.
|
Go to `Cursor Settings` -> `MCP` -> `Add new MCP Server`. Name to your liking, use `command` type with the command `npx @playwright/mcp`. You can also verify config or add command like arguments via clicking `Edit`.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -106,6 +112,27 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
|
|||||||
```
|
```
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Install in Qodo Gen</b></summary>
|
||||||
|
|
||||||
|
Open [Qodo Gen](https://docs.qodo.ai/qodo-documentation/qodo-gen) chat panel in VSCode or IntelliJ → Connect more tools → + Add new MCP → Paste the following configuration:
|
||||||
|
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@playwright/mcp@latest"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Click <code>Save</code>.
|
||||||
|
</details>
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
Playwright MCP server supports following arguments. They can be provided in the JSON configuration above, as a part of the `"args"` list:
|
Playwright MCP server supports following arguments. They can be provided in the JSON configuration above, as a part of the `"args"` list:
|
||||||
@@ -124,6 +151,7 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|||||||
--block-service-workers block service workers
|
--block-service-workers block service workers
|
||||||
--browser <browser> browser or chrome channel to use, possible
|
--browser <browser> browser or chrome channel to use, possible
|
||||||
values: chrome, firefox, webkit, msedge.
|
values: chrome, firefox, webkit, msedge.
|
||||||
|
--browser-agent <endpoint> Use browser agent (experimental).
|
||||||
--caps <caps> comma-separated list of capabilities to enable,
|
--caps <caps> comma-separated list of capabilities to enable,
|
||||||
possible values: tabs, pdf, history, wait, files,
|
possible values: tabs, pdf, history, wait, files,
|
||||||
install. Default is all.
|
install. Default is all.
|
||||||
@@ -137,7 +165,10 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|||||||
--ignore-https-errors ignore https errors
|
--ignore-https-errors ignore https errors
|
||||||
--isolated keep the browser profile in memory, do not save
|
--isolated keep the browser profile in memory, do not save
|
||||||
it to disk.
|
it to disk.
|
||||||
--no-image-responses do not send image responses to the client.
|
--image-responses <mode> whether to send image responses to the client.
|
||||||
|
Can be "allow", "omit", or "auto". Defaults to
|
||||||
|
"auto", which sends images if the client can
|
||||||
|
display them.
|
||||||
--no-sandbox disable the sandbox for all process types that
|
--no-sandbox disable the sandbox for all process types that
|
||||||
are normally sandboxed.
|
are normally sandboxed.
|
||||||
--output-dir <path> path to the directory for output files.
|
--output-dir <path> path to the directory for output files.
|
||||||
@@ -196,7 +227,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}"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,7 +382,7 @@ http.createServer(async (req, res) => {
|
|||||||
// Creates a headless Playwright MCP server with SSE transport
|
// Creates a headless Playwright MCP server with SSE transport
|
||||||
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
|
const connection = await createConnection({ browser: { launchOptions: { headless: true } } });
|
||||||
const transport = new SSEServerTransport('/messages', res);
|
const transport = new SSEServerTransport('/messages', res);
|
||||||
await connection.connect(transport);
|
await connection.sever.connect(transport);
|
||||||
|
|
||||||
// ...
|
// ...
|
||||||
});
|
});
|
||||||
|
|||||||
9
config.d.ts
vendored
9
config.d.ts
vendored
@@ -23,6 +23,11 @@ export type Config = {
|
|||||||
* The browser to use.
|
* The browser to use.
|
||||||
*/
|
*/
|
||||||
browser?: {
|
browser?: {
|
||||||
|
/**
|
||||||
|
* Use browser agent (experimental).
|
||||||
|
*/
|
||||||
|
browserAgent?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The type of browser to use.
|
* The type of browser to use.
|
||||||
*/
|
*/
|
||||||
@@ -117,7 +122,7 @@ export type Config = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Do not send image responses to the client.
|
* Whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.
|
||||||
*/
|
*/
|
||||||
noImageResponses?: boolean;
|
imageResponses?: 'allow' | 'omit' | 'auto';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Generate test for scenario:
|
Use Playwright tools to generate test for scenario:
|
||||||
|
|
||||||
## GitHub PR Checks Navigation Checklist
|
## GitHub PR Checks Navigation Checklist
|
||||||
|
|
||||||
|
|||||||
7
index.d.ts
vendored
7
index.d.ts
vendored
@@ -16,14 +16,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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.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;
|
||||||
connect(transport: Transport): Promise<void>;
|
|
||||||
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 {};
|
||||||
|
|||||||
2
index.js
2
index.js
@@ -15,5 +15,5 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createConnection } from './lib/index';
|
import { createConnection } from './lib/index.js';
|
||||||
export { createConnection };
|
export { createConnection };
|
||||||
|
|||||||
342
package-lock.json
generated
342
package-lock.json
generated
@@ -1,17 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.26",
|
"version": "0.0.29",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.26",
|
"version": "0.0.29",
|
||||||
"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",
|
||||||
"playwright": "1.53.0-alpha-1746832516000",
|
"debug": "^4.4.1",
|
||||||
|
"mime": "^4.0.7",
|
||||||
|
"playwright": "1.53.0",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -20,8 +22,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "1.53.0-alpha-1746832516000",
|
"@playwright/test": "1.53.0",
|
||||||
"@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",
|
||||||
@@ -286,13 +289,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.53.0-alpha-1746832516000",
|
"version": "1.53.0",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-1746832516000.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0.tgz",
|
||||||
"integrity": "sha512-Sec+6uzpA4MfwmQqJFBFVazffynqVwLO5swDxG7WoqgpUdn9gQX4K4tDG64SV6f4nOpwdM5LKTasPSXu02nn/Q==",
|
"integrity": "sha512-15hjKreZDcp7t6TL/7jkAo6Df5STZN09jGiv5dbP9A6vMVncXRqE7/B2SncsyOwrkZRBH2i6/TPOL8BVmm3c7w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.53.0-alpha-1746832516000"
|
"playwright": "1.53.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -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",
|
||||||
@@ -834,16 +853,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
|
||||||
"integrity": "sha512-/hPxh61E+ll0Ujp24Ilm64cykicul1ypfwjVttduAiEdtnJFvLePSrIPk+HMImtNv5270wOGCb1Tns2rybMkoQ==",
|
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "^3.1.2",
|
"bytes": "^3.1.2",
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^1.0.5",
|
||||||
"debug": "^4.4.0",
|
"debug": "^4.4.0",
|
||||||
"http-errors": "^2.0.0",
|
"http-errors": "^2.0.0",
|
||||||
"iconv-lite": "^0.5.2",
|
"iconv-lite": "^0.6.3",
|
||||||
"on-finished": "^2.4.1",
|
"on-finished": "^2.4.1",
|
||||||
"qs": "^6.14.0",
|
"qs": "^6.14.0",
|
||||||
"raw-body": "^3.0.0",
|
"raw-body": "^3.0.0",
|
||||||
@@ -853,44 +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": {
|
|
||||||
"version": "6.14.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
|
||||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"side-channel": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||||
@@ -1156,12 +1137,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"
|
||||||
@@ -1224,16 +1205,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/destroy": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.8",
|
|
||||||
"npm": "1.2.8000 || >= 1.4.16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/doctrine": {
|
"node_modules/doctrine": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||||
@@ -1769,46 +1740,45 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "5.0.1",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
|
||||||
"integrity": "sha512-ORF7g6qGnD+YtUG9yx4DFoqCShNMmUKiXuT5oWMHiOvt/4WFbHC6yCwQMTSBMno7AqntNCAzzcnnjowRkTL9eQ==",
|
"integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.0.1",
|
"body-parser": "^2.2.0",
|
||||||
"content-disposition": "^1.0.0",
|
"content-disposition": "^1.0.0",
|
||||||
"content-type": "~1.0.4",
|
"content-type": "^1.0.5",
|
||||||
"cookie": "0.7.1",
|
"cookie": "^0.7.1",
|
||||||
"cookie-signature": "^1.2.1",
|
"cookie-signature": "^1.2.1",
|
||||||
"debug": "4.3.6",
|
"debug": "^4.4.0",
|
||||||
"depd": "2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
"encodeurl": "~2.0.0",
|
"escape-html": "^1.0.3",
|
||||||
"escape-html": "~1.0.3",
|
"etag": "^1.8.1",
|
||||||
"etag": "~1.8.1",
|
"finalhandler": "^2.1.0",
|
||||||
"finalhandler": "^2.0.0",
|
"fresh": "^2.0.0",
|
||||||
"fresh": "2.0.0",
|
"http-errors": "^2.0.0",
|
||||||
"http-errors": "2.0.0",
|
|
||||||
"merge-descriptors": "^2.0.0",
|
"merge-descriptors": "^2.0.0",
|
||||||
"methods": "~1.1.2",
|
|
||||||
"mime-types": "^3.0.0",
|
"mime-types": "^3.0.0",
|
||||||
"on-finished": "2.4.1",
|
"on-finished": "^2.4.1",
|
||||||
"once": "1.4.0",
|
"once": "^1.4.0",
|
||||||
"parseurl": "~1.3.3",
|
"parseurl": "^1.3.3",
|
||||||
"proxy-addr": "~2.0.7",
|
"proxy-addr": "^2.0.7",
|
||||||
"qs": "6.13.0",
|
"qs": "^6.14.0",
|
||||||
"range-parser": "~1.2.1",
|
"range-parser": "^1.2.1",
|
||||||
"router": "^2.0.0",
|
"router": "^2.2.0",
|
||||||
"safe-buffer": "5.2.1",
|
|
||||||
"send": "^1.1.0",
|
"send": "^1.1.0",
|
||||||
"serve-static": "^2.1.0",
|
"serve-static": "^2.2.0",
|
||||||
"setprototypeof": "1.2.0",
|
"statuses": "^2.0.1",
|
||||||
"statuses": "2.0.1",
|
"type-is": "^2.0.1",
|
||||||
"type-is": "^2.0.0",
|
"vary": "^1.1.2"
|
||||||
"utils-merge": "1.0.1",
|
|
||||||
"vary": "~1.1.2"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express-rate-limit": {
|
"node_modules/express-rate-limit": {
|
||||||
@@ -1930,29 +1900,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",
|
||||||
@@ -2037,7 +1984,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -2312,12 +2258,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.5.2",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
"integrity": "sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==",
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safer-buffer": ">= 2.1.2 < 3"
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -2928,15 +2874,6 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/methods": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/metric-lcs": {
|
"node_modules/metric-lcs": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/metric-lcs/-/metric-lcs-0.1.2.tgz",
|
||||||
@@ -2958,6 +2895,21 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mime": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime/-/mime-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-2OfDPL+e03E0LrXaGYOtTFIYhiuzep94NSsuhrNULq+stylcJedcHdzHtz0atMUuGwJfFYs0YL5xeC/Ca2x0eQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"mime": "bin/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mime-db": {
|
"node_modules/mime-db": {
|
||||||
"version": "1.54.0",
|
"version": "1.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||||
@@ -2968,12 +2920,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mime-types": {
|
"node_modules/mime-types": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
|
||||||
"integrity": "sha512-XqoSHeCGjVClAmoGFG3lVFqQFRIrTVw2OH3axRqAcfaw+gHWIfnASS92AV+Rl/mk0MupgZTRHQOjxY6YVnzK5w==",
|
"integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "^1.53.0"
|
"mime-db": "^1.54.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
@@ -3003,9 +2955,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": {
|
||||||
@@ -3298,12 +3250,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.53.0-alpha-1746832516000",
|
"version": "1.53.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-1746832516000.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0.tgz",
|
||||||
"integrity": "sha512-kcC1B2XJr4VaDAcVzi61SbYGkodq1QIqQXuPieXsNgZZ7cEKWzO2sI42yp2yie6wlCx0oLkSS2Q6jWSRVRLeaw==",
|
"integrity": "sha512-ghGNnIEYZC4E+YtclRn4/p6oYbdPiASELBIYkBXfaTVKreQUYbMUYQDwS12a8F0/HtIjr/CkGjtwABeFPGcS4Q==",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.53.0-alpha-1746832516000"
|
"playwright-core": "1.53.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -3316,10 +3267,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.53.0-alpha-1746832516000",
|
"version": "1.53.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-1746832516000.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0.tgz",
|
||||||
"integrity": "sha512-4O98y4zV0rOP6CepMLC/VGuzqGaR1sS9AVh+i0CghWMQHM/8bxPJI8W38QndO0JU0V5nBD6j7DQeNt1mJ+CZ+g==",
|
"integrity": "sha512-mGLg8m0pm4+mmtB7M89Xw/GSqoNC+twivl8ITteqvAndachozYe2ZA7srU6uleV1vEdAHYqjq+SV8SNxRRFYBw==",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
},
|
},
|
||||||
@@ -3371,12 +3321,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.13.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||||
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
|
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.0.6"
|
"side-channel": "^1.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
@@ -3430,18 +3380,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/raw-body/node_modules/iconv-lite": {
|
|
||||||
"version": "0.6.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
|
||||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -3529,11 +3467,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/router": {
|
"node_modules/router": {
|
||||||
"version": "2.1.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/router/-/router-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||||
"integrity": "sha512-/m/NSLxeYEgWNtyC+WtNHCF7jbGxOibVWKnn+1Psff4dJGOfoXP+MuC/f2CwSmyiHdOIzYnYFp4W6GxWfekaLA==",
|
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"debug": "^4.4.0",
|
||||||
|
"depd": "^2.0.0",
|
||||||
"is-promise": "^4.0.0",
|
"is-promise": "^4.0.0",
|
||||||
"parseurl": "^1.3.3",
|
"parseurl": "^1.3.3",
|
||||||
"path-to-regexp": "^8.0.0"
|
"path-to-regexp": "^8.0.0"
|
||||||
@@ -3661,19 +3601,18 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/send": {
|
"node_modules/send": {
|
||||||
"version": "1.1.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
|
||||||
"integrity": "sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==",
|
"integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.3.5",
|
"debug": "^4.3.5",
|
||||||
"destroy": "^1.2.0",
|
|
||||||
"encodeurl": "^2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
"escape-html": "^1.0.3",
|
"escape-html": "^1.0.3",
|
||||||
"etag": "^1.8.1",
|
"etag": "^1.8.1",
|
||||||
"fresh": "^0.5.2",
|
"fresh": "^2.0.0",
|
||||||
"http-errors": "^2.0.0",
|
"http-errors": "^2.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^3.0.1",
|
||||||
"ms": "^2.1.3",
|
"ms": "^2.1.3",
|
||||||
"on-finished": "^2.4.1",
|
"on-finished": "^2.4.1",
|
||||||
"range-parser": "^1.2.1",
|
"range-parser": "^1.2.1",
|
||||||
@@ -3683,52 +3622,16 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/send/node_modules/fresh": {
|
|
||||||
"version": "0.5.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
|
||||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/send/node_modules/mime-db": {
|
|
||||||
"version": "1.52.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
|
||||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/send/node_modules/mime-types": {
|
|
||||||
"version": "2.1.35",
|
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
|
||||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"mime-db": "1.52.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"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.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
|
||||||
"integrity": "sha512-A3We5UfEjG8Z7VkDv6uItWw6HY2bBSBJT1KtVESn6EOoOr2jAxNhxWCLY3jDE2WcuHXByWju74ck3ZgLwL8xmA==",
|
"integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"encodeurl": "^2.0.0",
|
"encodeurl": "^2.0.0",
|
||||||
"escape-html": "^1.0.3",
|
"escape-html": "^1.0.3",
|
||||||
"parseurl": "^1.3.3",
|
"parseurl": "^1.3.3",
|
||||||
"send": "^1.0.0"
|
"send": "^1.2.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
@@ -4061,9 +3964,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||||
"integrity": "sha512-gd0sGezQYCbWSbkZr75mln4YBidWUN60+devscpLF5mtRDUpiaTvKpBNrdaCvel1NdR2k6vclXybU5fBd2i+nw==",
|
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"content-type": "^1.0.5",
|
"content-type": "^1.0.5",
|
||||||
@@ -4211,15 +4114,6 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/utils-merge": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@playwright/mcp",
|
"name": "@playwright/mcp",
|
||||||
"version": "0.0.26",
|
"version": "0.0.29",
|
||||||
"description": "Playwright Tools for MCP",
|
"description": "Playwright Tools for MCP",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"ctest": "playwright test --project=chrome",
|
"ctest": "playwright test --project=chrome",
|
||||||
"ftest": "playwright test --project=firefox",
|
"ftest": "playwright test --project=firefox",
|
||||||
"wtest": "playwright test --project=webkit",
|
"wtest": "playwright test --project=webkit",
|
||||||
|
"run-server": "node lib/browserServer.js",
|
||||||
"clean": "rm -rf lib",
|
"clean": "rm -rf lib",
|
||||||
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
"npm-publish": "npm run clean && npm run build && npm run test && npm publish"
|
||||||
},
|
},
|
||||||
@@ -37,14 +38,17 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.11.0",
|
"@modelcontextprotocol/sdk": "^1.11.0",
|
||||||
"commander": "^13.1.0",
|
"commander": "^13.1.0",
|
||||||
"playwright": "1.53.0-alpha-1746832516000",
|
"debug": "^4.4.1",
|
||||||
|
"mime": "^4.0.7",
|
||||||
|
"playwright": "1.53.0",
|
||||||
"zod-to-json-schema": "^3.24.4"
|
"zod-to-json-schema": "^3.24.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
"@playwright/test": "1.53.0-alpha-1746832516000",
|
"@playwright/test": "1.53.0",
|
||||||
"@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",
|
||||||
|
|||||||
@@ -29,7 +29,14 @@ export default defineConfig<TestOptions>({
|
|||||||
{ name: 'chrome' },
|
{ name: 'chrome' },
|
||||||
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
|
{ name: 'msedge', use: { mcpBrowser: 'msedge' } },
|
||||||
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||||
...process.env.MCP_IN_DOCKER ? [{ name: 'chromium-docker', use: { mcpBrowser: 'chromium', mcpMode: 'docker' as const } }] : [],
|
...process.env.MCP_IN_DOCKER ? [{
|
||||||
|
name: 'chromium-docker',
|
||||||
|
grep: /browser_navigate|browser_click/,
|
||||||
|
use: {
|
||||||
|
mcpBrowser: 'chromium',
|
||||||
|
mcpMode: 'docker' as const
|
||||||
|
}
|
||||||
|
}] : [],
|
||||||
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||||
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||||
],
|
],
|
||||||
|
|||||||
266
src/browserContextFactory.ts
Normal file
266
src/browserContextFactory.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
/**
|
||||||
|
* 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 net from 'node:net';
|
||||||
|
import path from 'node:path';
|
||||||
|
import os from 'node:os';
|
||||||
|
|
||||||
|
import debug from 'debug';
|
||||||
|
import * as playwright from 'playwright';
|
||||||
|
import { userDataDir } from './fileUtils.js';
|
||||||
|
|
||||||
|
import type { FullConfig } from './config.js';
|
||||||
|
import type { BrowserInfo, LaunchBrowserRequest } from './browserServer.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);
|
||||||
|
if (browserConfig.browserAgent)
|
||||||
|
return new BrowserServerContextFactory(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> {
|
||||||
|
await injectCdpPort(this.browserConfig);
|
||||||
|
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> }> {
|
||||||
|
await injectCdpPort(this.browserConfig);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrowserServerContextFactory extends BaseContextFactory {
|
||||||
|
constructor(browserConfig: FullConfig['browser']) {
|
||||||
|
super('persistent', browserConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doObtainBrowser(): Promise<playwright.Browser> {
|
||||||
|
const response = await fetch(new URL(`/json/launch`, this.browserConfig.browserAgent), {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
browserType: this.browserConfig.browserName,
|
||||||
|
userDataDir: this.browserConfig.userDataDir ?? await this._createUserDataDir(),
|
||||||
|
launchOptions: this.browserConfig.launchOptions,
|
||||||
|
contextOptions: this.browserConfig.contextOptions,
|
||||||
|
} as LaunchBrowserRequest),
|
||||||
|
});
|
||||||
|
const info = await response.json() as BrowserInfo;
|
||||||
|
if (info.error)
|
||||||
|
throw new Error(info.error);
|
||||||
|
return await playwright.chromium.connectOverCDP(`http://localhost:${info.cdpPort}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async _doCreateContext(browser: playwright.Browser): Promise<playwright.BrowserContext> {
|
||||||
|
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _createUserDataDir() {
|
||||||
|
const dir = await userDataDir(this.browserConfig);
|
||||||
|
await fs.promises.mkdir(dir, { recursive: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function injectCdpPort(browserConfig: FullConfig['browser']) {
|
||||||
|
if (browserConfig.browserName === 'chromium')
|
||||||
|
(browserConfig.launchOptions as any).cdpPort = await findFreePort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findFreePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.listen(0, () => {
|
||||||
|
const { port } = server.address() as net.AddressInfo;
|
||||||
|
server.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
server.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
197
src/browserServer.ts
Normal file
197
src/browserServer.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
|
import net from 'net';
|
||||||
|
|
||||||
|
import { program } from 'commander';
|
||||||
|
import playwright from 'playwright';
|
||||||
|
|
||||||
|
import { HttpServer } from './httpServer.js';
|
||||||
|
import { packageJSON } from './package.js';
|
||||||
|
|
||||||
|
import type http from 'http';
|
||||||
|
|
||||||
|
export type LaunchBrowserRequest = {
|
||||||
|
browserType: string;
|
||||||
|
userDataDir: string;
|
||||||
|
launchOptions: playwright.LaunchOptions;
|
||||||
|
contextOptions: playwright.BrowserContextOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BrowserInfo = {
|
||||||
|
browserType: string;
|
||||||
|
userDataDir: string;
|
||||||
|
cdpPort: number;
|
||||||
|
launchOptions: playwright.LaunchOptions;
|
||||||
|
contextOptions: playwright.BrowserContextOptions;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BrowserEntry = {
|
||||||
|
browser?: playwright.Browser;
|
||||||
|
info: BrowserInfo;
|
||||||
|
};
|
||||||
|
|
||||||
|
class BrowserServer {
|
||||||
|
private _server = new HttpServer();
|
||||||
|
private _entries: BrowserEntry[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._setupExitHandler();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(port: number) {
|
||||||
|
await this._server.start({ port });
|
||||||
|
this._server.routePath('/json/list', (req, res) => {
|
||||||
|
this._handleJsonList(res);
|
||||||
|
});
|
||||||
|
this._server.routePath('/json/launch', async (req, res) => {
|
||||||
|
void this._handleLaunchBrowser(req, res).catch(e => console.error(e));
|
||||||
|
});
|
||||||
|
this._setEntries([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleJsonList(res: http.ServerResponse) {
|
||||||
|
const list = this._entries.map(browser => browser.info);
|
||||||
|
res.end(JSON.stringify(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleLaunchBrowser(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||||
|
const request = await readBody<LaunchBrowserRequest>(req);
|
||||||
|
let info = this._entries.map(entry => entry.info).find(info => info.userDataDir === request.userDataDir);
|
||||||
|
if (!info || info.error)
|
||||||
|
info = await this._newBrowser(request);
|
||||||
|
res.end(JSON.stringify(info));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _newBrowser(request: LaunchBrowserRequest): Promise<BrowserInfo> {
|
||||||
|
const cdpPort = await findFreePort();
|
||||||
|
(request.launchOptions as any).cdpPort = cdpPort;
|
||||||
|
const info: BrowserInfo = {
|
||||||
|
browserType: request.browserType,
|
||||||
|
userDataDir: request.userDataDir,
|
||||||
|
cdpPort,
|
||||||
|
launchOptions: request.launchOptions,
|
||||||
|
contextOptions: request.contextOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const browserType = playwright[request.browserType as 'chromium' | 'firefox' | 'webkit'];
|
||||||
|
const { browser, error } = await browserType.launchPersistentContext(request.userDataDir, {
|
||||||
|
...request.launchOptions,
|
||||||
|
...request.contextOptions,
|
||||||
|
handleSIGINT: false,
|
||||||
|
handleSIGTERM: false,
|
||||||
|
}).then(context => {
|
||||||
|
return { browser: context.browser()!, error: undefined };
|
||||||
|
}).catch(error => {
|
||||||
|
return { browser: undefined, error: error.message };
|
||||||
|
});
|
||||||
|
this._setEntries([...this._entries, {
|
||||||
|
browser,
|
||||||
|
info: {
|
||||||
|
browserType: request.browserType,
|
||||||
|
userDataDir: request.userDataDir,
|
||||||
|
cdpPort,
|
||||||
|
launchOptions: request.launchOptions,
|
||||||
|
contextOptions: request.contextOptions,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
}]);
|
||||||
|
browser?.on('disconnected', () => {
|
||||||
|
this._setEntries(this._entries.filter(entry => entry.browser !== browser));
|
||||||
|
});
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _updateReport() {
|
||||||
|
// Clear the current line and move cursor to top of screen
|
||||||
|
process.stdout.write('\x1b[2J\x1b[H');
|
||||||
|
process.stdout.write(`Playwright Browser Server v${packageJSON.version}\n`);
|
||||||
|
process.stdout.write(`Listening on ${this._server.urlPrefix('human-readable')}\n\n`);
|
||||||
|
|
||||||
|
if (this._entries.length === 0) {
|
||||||
|
process.stdout.write('No browsers currently running\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write('Running browsers:\n');
|
||||||
|
for (const entry of this._entries) {
|
||||||
|
const status = entry.browser ? 'running' : 'error';
|
||||||
|
const statusColor = entry.browser ? '\x1b[32m' : '\x1b[31m'; // green for running, red for error
|
||||||
|
process.stdout.write(`${statusColor}${entry.info.browserType}\x1b[0m (${entry.info.userDataDir}) - ${statusColor}${status}\x1b[0m\n`);
|
||||||
|
if (entry.info.error)
|
||||||
|
process.stdout.write(` Error: ${entry.info.error}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setEntries(entries: BrowserEntry[]) {
|
||||||
|
this._entries = entries;
|
||||||
|
this._updateReport();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _setupExitHandler() {
|
||||||
|
let isExiting = false;
|
||||||
|
const handleExit = async () => {
|
||||||
|
if (isExiting)
|
||||||
|
return;
|
||||||
|
isExiting = true;
|
||||||
|
setTimeout(() => process.exit(0), 15000);
|
||||||
|
for (const entry of this._entries)
|
||||||
|
await entry.browser?.close().catch(() => {});
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.stdin.on('close', handleExit);
|
||||||
|
process.on('SIGINT', handleExit);
|
||||||
|
process.on('SIGTERM', handleExit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
program
|
||||||
|
.name('browser-agent')
|
||||||
|
.option('-p, --port <port>', 'Port to listen on', '9224')
|
||||||
|
.action(async options => {
|
||||||
|
await main(options);
|
||||||
|
});
|
||||||
|
|
||||||
|
void program.parseAsync(process.argv);
|
||||||
|
|
||||||
|
async function main(options: { port: string }) {
|
||||||
|
const server = new BrowserServer();
|
||||||
|
await server.start(+options.port);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody<T>(req: http.IncomingMessage): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
req.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString())));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findFreePort(): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.listen(0, () => {
|
||||||
|
const { port } = server.address() as net.AddressInfo;
|
||||||
|
server.close(() => resolve(port));
|
||||||
|
});
|
||||||
|
server.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import net from 'net';
|
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { devices } from 'playwright';
|
import { devices } from 'playwright';
|
||||||
@@ -29,6 +28,7 @@ export type CLIOptions = {
|
|||||||
blockedOrigins?: string[];
|
blockedOrigins?: string[];
|
||||||
blockServiceWorkers?: boolean;
|
blockServiceWorkers?: boolean;
|
||||||
browser?: string;
|
browser?: string;
|
||||||
|
browserAgent?: string;
|
||||||
caps?: string;
|
caps?: string;
|
||||||
cdpEndpoint?: string;
|
cdpEndpoint?: string;
|
||||||
config?: string;
|
config?: string;
|
||||||
@@ -38,7 +38,7 @@ export type CLIOptions = {
|
|||||||
host?: string;
|
host?: string;
|
||||||
ignoreHttpsErrors?: boolean;
|
ignoreHttpsErrors?: boolean;
|
||||||
isolated?: boolean;
|
isolated?: boolean;
|
||||||
imageResponses: boolean;
|
imageResponses?: 'allow' | 'omit' | 'auto';
|
||||||
sandbox: boolean;
|
sandbox: boolean;
|
||||||
outputDir?: string;
|
outputDir?: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
@@ -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> {
|
||||||
@@ -98,7 +100,7 @@ export async function resolveCLIConfig(cliOptions: CLIOptions): Promise<FullConf
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
|
export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Config> {
|
||||||
let browserName: 'chromium' | 'firefox' | 'webkit';
|
let browserName: 'chromium' | 'firefox' | 'webkit' | undefined;
|
||||||
let channel: string | undefined;
|
let channel: string | undefined;
|
||||||
switch (cliOptions.browser) {
|
switch (cliOptions.browser) {
|
||||||
case 'chrome':
|
case 'chrome':
|
||||||
@@ -119,9 +121,6 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
case 'webkit':
|
case 'webkit':
|
||||||
browserName = 'webkit';
|
browserName = 'webkit';
|
||||||
break;
|
break;
|
||||||
default:
|
|
||||||
browserName = 'chromium';
|
|
||||||
channel = 'chrome';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Launch options
|
// Launch options
|
||||||
@@ -131,13 +130,9 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
headless: cliOptions.headless,
|
headless: cliOptions.headless,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (browserName === 'chromium') {
|
// --no-sandbox was passed, disable the sandbox
|
||||||
(launchOptions as any).cdpPort = await findFreePort();
|
if (!cliOptions.sandbox)
|
||||||
if (!cliOptions.sandbox) {
|
launchOptions.chromiumSandbox = false;
|
||||||
// --no-sandbox was passed, disable the sandbox
|
|
||||||
launchOptions.chromiumSandbox = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cliOptions.proxyServer) {
|
if (cliOptions.proxyServer) {
|
||||||
launchOptions.proxy = {
|
launchOptions.proxy = {
|
||||||
@@ -174,6 +169,7 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
|
|
||||||
const result: Config = {
|
const result: Config = {
|
||||||
browser: {
|
browser: {
|
||||||
|
browserAgent: cliOptions.browserAgent ?? process.env.PW_BROWSER_AGENT,
|
||||||
browserName,
|
browserName,
|
||||||
isolated: cliOptions.isolated,
|
isolated: cliOptions.isolated,
|
||||||
userDataDir: cliOptions.userDataDir,
|
userDataDir: cliOptions.userDataDir,
|
||||||
@@ -193,27 +189,12 @@ export async function configFromCLIOptions(cliOptions: CLIOptions): Promise<Conf
|
|||||||
},
|
},
|
||||||
saveTrace: cliOptions.saveTrace,
|
saveTrace: cliOptions.saveTrace,
|
||||||
outputDir: cliOptions.outputDir,
|
outputDir: cliOptions.outputDir,
|
||||||
|
imageResponses: cliOptions.imageResponses,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!cliOptions.imageResponses) {
|
|
||||||
// --no-image-responses was passed, disable image responses
|
|
||||||
result.noImageResponses = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findFreePort() {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const server = net.createServer();
|
|
||||||
server.listen(0, () => {
|
|
||||||
const { port } = server.address() as net.AddressInfo;
|
|
||||||
server.close(() => resolve(port));
|
|
||||||
});
|
|
||||||
server.on('error', reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadConfig(configFile: string | undefined): Promise<Config> {
|
async function loadConfig(configFile: string | undefined): Promise<Config> {
|
||||||
if (!configFile)
|
if (!configFile)
|
||||||
return {};
|
return {};
|
||||||
@@ -239,6 +220,8 @@ function pickDefined<T extends object>(obj: T | undefined): Partial<T> {
|
|||||||
|
|
||||||
function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
|
function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
|
||||||
const browser: FullConfig['browser'] = {
|
const browser: FullConfig['browser'] = {
|
||||||
|
...pickDefined(base.browser),
|
||||||
|
...pickDefined(overrides.browser),
|
||||||
browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
|
browserName: overrides.browser?.browserName ?? base.browser?.browserName ?? 'chromium',
|
||||||
isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
|
isolated: overrides.browser?.isolated ?? base.browser?.isolated ?? false,
|
||||||
launchOptions: {
|
launchOptions: {
|
||||||
@@ -250,9 +233,6 @@ function mergeConfig(base: FullConfig, overrides: Config): FullConfig {
|
|||||||
...pickDefined(base.browser?.contextOptions),
|
...pickDefined(base.browser?.contextOptions),
|
||||||
...pickDefined(overrides.browser?.contextOptions),
|
...pickDefined(overrides.browser?.contextOptions),
|
||||||
},
|
},
|
||||||
userDataDir: overrides.browser?.userDataDir ?? base.browser?.userDataDir,
|
|
||||||
cdpEndpoint: overrides.browser?.cdpEndpoint ?? base.browser?.cdpEndpoint,
|
|
||||||
remoteEndpoint: overrides.browser?.remoteEndpoint ?? base.browser?.remoteEndpoint,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (browser.browserName !== 'chromium' && browser.launchOptions)
|
if (browser.browserName !== 'chromium' && browser.launchOptions)
|
||||||
@@ -265,6 +245,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,26 +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();
|
|
||||||
});
|
|
||||||
if (this.server.getClientVersion()?.name.includes('cursor'))
|
|
||||||
this.context.config.noImageResponses = true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async close() {
|
async close() {
|
||||||
|
|||||||
115
src/context.ts
115
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,29 +25,39 @@ 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 })[] = [];
|
||||||
private _pendingAction: PendingAction | undefined;
|
private _pendingAction: PendingAction | undefined;
|
||||||
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
||||||
|
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 {
|
||||||
|
if (this.config.imageResponses === 'allow')
|
||||||
|
return true;
|
||||||
|
if (this.config.imageResponses === 'omit')
|
||||||
|
return false;
|
||||||
|
return !this.clientVersion?.name.includes('cursor');
|
||||||
}
|
}
|
||||||
|
|
||||||
modalStates(): ModalState[] {
|
modalStates(): ModalState[] {
|
||||||
@@ -83,7 +89,7 @@ export class Context {
|
|||||||
|
|
||||||
currentTabOrDie(): Tab {
|
currentTabOrDie(): Tab {
|
||||||
if (!this._currentTab)
|
if (!this._currentTab)
|
||||||
throw new Error('No current snapshot available. Capture a snapshot of navigate to a new location first.');
|
throw new Error('No current snapshot available. Capture a snapshot or navigate to a new location first.');
|
||||||
return this._currentTab;
|
return this._currentTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,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(() => {});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,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);
|
||||||
@@ -338,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'));
|
|
||||||
|
|||||||
37
src/fileUtils.ts
Normal file
37
src/fileUtils.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* 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 os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import type { FullConfig } from './config.js';
|
||||||
|
|
||||||
|
export function cacheDir() {
|
||||||
|
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);
|
||||||
|
return path.join(cacheDirectory, 'ms-playwright');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userDataDir(browserConfig: FullConfig['browser']) {
|
||||||
|
return path.join(cacheDir(), 'ms-playwright', `mcp-${browserConfig.launchOptions?.channel ?? browserConfig?.browserName}-profile`);
|
||||||
|
}
|
||||||
232
src/httpServer.ts
Normal file
232
src/httpServer.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* 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 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import http from 'http';
|
||||||
|
import net from 'net';
|
||||||
|
|
||||||
|
import mime from 'mime';
|
||||||
|
|
||||||
|
import { ManualPromise } from './manualPromise.js';
|
||||||
|
|
||||||
|
|
||||||
|
export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => void;
|
||||||
|
|
||||||
|
export type Transport = {
|
||||||
|
sendEvent?: (method: string, params: any) => void;
|
||||||
|
close?: () => void;
|
||||||
|
onconnect: () => void;
|
||||||
|
dispatch: (method: string, params: any) => Promise<any>;
|
||||||
|
onclose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class HttpServer {
|
||||||
|
private _server: http.Server;
|
||||||
|
private _urlPrefixPrecise: string = '';
|
||||||
|
private _urlPrefixHumanReadable: string = '';
|
||||||
|
private _port: number = 0;
|
||||||
|
private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this._server = http.createServer(this._onRequest.bind(this));
|
||||||
|
decorateServer(this._server);
|
||||||
|
}
|
||||||
|
|
||||||
|
server() {
|
||||||
|
return this._server;
|
||||||
|
}
|
||||||
|
|
||||||
|
routePrefix(prefix: string, handler: ServerRouteHandler) {
|
||||||
|
this._routes.push({ prefix, handler });
|
||||||
|
}
|
||||||
|
|
||||||
|
routePath(path: string, handler: ServerRouteHandler) {
|
||||||
|
this._routes.push({ exact: path, handler });
|
||||||
|
}
|
||||||
|
|
||||||
|
port(): number {
|
||||||
|
return this._port;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _tryStart(port: number | undefined, host: string) {
|
||||||
|
const errorPromise = new ManualPromise();
|
||||||
|
const errorListener = (error: Error) => errorPromise.reject(error);
|
||||||
|
this._server.on('error', errorListener);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this._server.listen(port, host);
|
||||||
|
await Promise.race([
|
||||||
|
new Promise(cb => this._server!.once('listening', cb)),
|
||||||
|
errorPromise,
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
this._server.removeListener('error', errorListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(options: { port?: number, preferredPort?: number, host?: string } = {}): Promise<void> {
|
||||||
|
const host = options.host || 'localhost';
|
||||||
|
if (options.preferredPort) {
|
||||||
|
try {
|
||||||
|
await this._tryStart(options.preferredPort, host);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!e || !e.message || !e.message.includes('EADDRINUSE'))
|
||||||
|
throw e;
|
||||||
|
await this._tryStart(undefined, host);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this._tryStart(options.port, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = this._server.address();
|
||||||
|
if (typeof address === 'string') {
|
||||||
|
this._urlPrefixPrecise = address;
|
||||||
|
this._urlPrefixHumanReadable = address;
|
||||||
|
} else {
|
||||||
|
this._port = address!.port;
|
||||||
|
const resolvedHost = address!.family === 'IPv4' ? address!.address : `[${address!.address}]`;
|
||||||
|
this._urlPrefixPrecise = `http://${resolvedHost}:${address!.port}`;
|
||||||
|
this._urlPrefixHumanReadable = `http://${host}:${address!.port}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
await new Promise(cb => this._server!.close(cb));
|
||||||
|
}
|
||||||
|
|
||||||
|
urlPrefix(purpose: 'human-readable' | 'precise'): string {
|
||||||
|
return purpose === 'human-readable' ? this._urlPrefixHumanReadable : this._urlPrefixPrecise;
|
||||||
|
}
|
||||||
|
|
||||||
|
serveFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string, headers?: { [name: string]: string }): boolean {
|
||||||
|
try {
|
||||||
|
for (const [name, value] of Object.entries(headers || {}))
|
||||||
|
response.setHeader(name, value);
|
||||||
|
if (request.headers.range)
|
||||||
|
this._serveRangeFile(request, response, absoluteFilePath);
|
||||||
|
else
|
||||||
|
this._serveFile(response, absoluteFilePath);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_serveFile(response: http.ServerResponse, absoluteFilePath: string) {
|
||||||
|
const content = fs.readFileSync(absoluteFilePath);
|
||||||
|
response.statusCode = 200;
|
||||||
|
const contentType = mime.getType(path.extname(absoluteFilePath)) || 'application/octet-stream';
|
||||||
|
response.setHeader('Content-Type', contentType);
|
||||||
|
response.setHeader('Content-Length', content.byteLength);
|
||||||
|
response.end(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
_serveRangeFile(request: http.IncomingMessage, response: http.ServerResponse, absoluteFilePath: string) {
|
||||||
|
const range = request.headers.range;
|
||||||
|
if (!range || !range.startsWith('bytes=') || range.includes(', ') || [...range].filter(char => char === '-').length !== 1) {
|
||||||
|
response.statusCode = 400;
|
||||||
|
return response.end('Bad request');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the range header: https://datatracker.ietf.org/doc/html/rfc7233#section-2.1
|
||||||
|
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
|
||||||
|
|
||||||
|
// Both start and end (when passing to fs.createReadStream) and the range header are inclusive and start counting at 0.
|
||||||
|
let start: number;
|
||||||
|
let end: number;
|
||||||
|
const size = fs.statSync(absoluteFilePath).size;
|
||||||
|
if (startStr !== '' && endStr === '') {
|
||||||
|
// No end specified: use the whole file
|
||||||
|
start = +startStr;
|
||||||
|
end = size - 1;
|
||||||
|
} else if (startStr === '' && endStr !== '') {
|
||||||
|
// No start specified: calculate start manually
|
||||||
|
start = size - +endStr;
|
||||||
|
end = size - 1;
|
||||||
|
} else {
|
||||||
|
start = +startStr;
|
||||||
|
end = +endStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle unavailable range request
|
||||||
|
if (Number.isNaN(start) || Number.isNaN(end) || start >= size || end >= size || start > end) {
|
||||||
|
// Return the 416 Range Not Satisfiable: https://datatracker.ietf.org/doc/html/rfc7233#section-4.4
|
||||||
|
response.writeHead(416, {
|
||||||
|
'Content-Range': `bytes */${size}`
|
||||||
|
});
|
||||||
|
return response.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sending Partial Content: https://datatracker.ietf.org/doc/html/rfc7233#section-4.1
|
||||||
|
response.writeHead(206, {
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${size}`,
|
||||||
|
'Accept-Ranges': 'bytes',
|
||||||
|
'Content-Length': end - start + 1,
|
||||||
|
'Content-Type': mime.getType(path.extname(absoluteFilePath))!,
|
||||||
|
});
|
||||||
|
|
||||||
|
const readable = fs.createReadStream(absoluteFilePath, { start, end });
|
||||||
|
readable.pipe(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
response.writeHead(200);
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
request.on('error', () => response.end());
|
||||||
|
try {
|
||||||
|
if (!request.url) {
|
||||||
|
response.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = new URL('http://localhost' + request.url);
|
||||||
|
for (const route of this._routes) {
|
||||||
|
if (route.exact && url.pathname === route.exact) {
|
||||||
|
route.handler(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (route.prefix && url.pathname.startsWith(route.prefix)) {
|
||||||
|
route.handler(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.statusCode = 404;
|
||||||
|
response.end();
|
||||||
|
} catch (e) {
|
||||||
|
response.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decorateServer(server: net.Server) {
|
||||||
|
const sockets = new Set<net.Socket>();
|
||||||
|
server.on('connection', socket => {
|
||||||
|
sockets.add(socket);
|
||||||
|
socket.once('close', () => sockets.delete(socket));
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = server.close;
|
||||||
|
server.close = (callback?: (err?: Error) => void) => {
|
||||||
|
for (const socket of sockets)
|
||||||
|
socket.destroy();
|
||||||
|
sockets.clear();
|
||||||
|
return close.call(server, callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
27
src/index.ts
27
src/index.ts
@@ -14,12 +14,33 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Connection, createConnection as createConnectionImpl } from './connection.js';
|
import { createConnection as createConnectionImpl } from './connection.js';
|
||||||
|
import type { Connection } from '../index.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'));
|
||||||
@@ -40,9 +40,6 @@ export class PageSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _build() {
|
private async _build() {
|
||||||
// FIXME: Rountrip evaluate to ensure _snapshotForAI works.
|
|
||||||
// This probably broke once we moved off locator snapshots
|
|
||||||
await this._page.evaluate(() => 1);
|
|
||||||
const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI());
|
const snapshot = await callOnPageNoTrace(this._page, page => (page as PageEx)._snapshotForAI());
|
||||||
this._text = [
|
this._text = [
|
||||||
`- Page Snapshot`,
|
`- Page Snapshot`,
|
||||||
@@ -52,7 +49,7 @@ export class PageSnapshot {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
refLocator(ref: string): playwright.Locator {
|
refLocator(params: { element: string, ref: string }): playwright.Locator {
|
||||||
return this._page.locator(`aria-ref=${ref}`);
|
return this._page.locator(`aria-ref=${params.ref}`).describe(params.element);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -31,6 +30,7 @@ program
|
|||||||
.option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList)
|
.option('--blocked-origins <origins>', 'semicolon-separated list of origins to block the browser from requesting. Blocklist is evaluated before allowlist. If used without the allowlist, requests not matching the blocklist are still allowed.', semicolonSeparatedList)
|
||||||
.option('--block-service-workers', 'block service workers')
|
.option('--block-service-workers', 'block service workers')
|
||||||
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
.option('--browser <browser>', 'browser or chrome channel to use, possible values: chrome, firefox, webkit, msedge.')
|
||||||
|
.option('--browser-agent <endpoint>', 'Use browser agent (experimental).')
|
||||||
.option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
|
.option('--caps <caps>', 'comma-separated list of capabilities to enable, possible values: tabs, pdf, history, wait, files, install. Default is all.')
|
||||||
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
.option('--cdp-endpoint <endpoint>', 'CDP endpoint to connect to.')
|
||||||
.option('--config <path>', 'path to the configuration file.')
|
.option('--config <path>', 'path to the configuration file.')
|
||||||
@@ -40,7 +40,7 @@ program
|
|||||||
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
|
.option('--host <host>', 'host to bind server to. Default is localhost. Use 0.0.0.0 to bind to all interfaces.')
|
||||||
.option('--ignore-https-errors', 'ignore https errors')
|
.option('--ignore-https-errors', 'ignore https errors')
|
||||||
.option('--isolated', 'keep the browser profile in memory, do not save it to disk.')
|
.option('--isolated', 'keep the browser profile in memory, do not save it to disk.')
|
||||||
.option('--no-image-responses', 'do not send image responses to the client.')
|
.option('--image-responses <mode>', 'whether to send image responses to the client. Can be "allow", "omit", or "auto". Defaults to "auto", which sends images if the client can display them.')
|
||||||
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
|
.option('--no-sandbox', 'disable the sandbox for all process types that are normally sandboxed.')
|
||||||
.option('--output-dir <path>', 'path to the directory for output files.')
|
.option('--output-dir <path>', 'path to the directory for output files.')
|
||||||
.option('--port <port>', 'port to listen on for SSE transport.')
|
.option('--port <port>', 'port to listen on for SSE transport.')
|
||||||
@@ -54,13 +54,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 +71,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,11 +83,10 @@ export class Tab {
|
|||||||
|| e.message.includes('Download is starting'); // firefox + webkit
|
|| e.message.includes('Download is starting'); // firefox + webkit
|
||||||
if (!mightBeDownload)
|
if (!mightBeDownload)
|
||||||
throw e;
|
throw e;
|
||||||
|
|
||||||
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
|
// on chromium, the download event is fired *after* page.goto rejects, so we wait a lil bit
|
||||||
const download = await Promise.race([
|
const download = await Promise.race([
|
||||||
downloadEvent,
|
downloadEvent,
|
||||||
new Promise(resolve => setTimeout(resolve, 500)),
|
new Promise(resolve => setTimeout(resolve, 1000)),
|
||||||
]);
|
]);
|
||||||
if (!download)
|
if (!download)
|
||||||
throw e;
|
throw e;
|
||||||
|
|||||||
@@ -57,14 +57,14 @@ const screenshot = defineTool({
|
|||||||
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
|
`// Screenshot ${isElementScreenshot ? params.element : 'viewport'} and save it as ${fileName}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
const locator = params.ref ? snapshot.refLocator(params.ref) : null;
|
const locator = params.ref ? snapshot.refLocator({ element: params.element || '', ref: params.ref }) : null;
|
||||||
|
|
||||||
if (locator)
|
if (locator)
|
||||||
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
|
code.push(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
|
||||||
else
|
else
|
||||||
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
|
code.push(`await page.screenshot(${javascript.formatObject(options)});`);
|
||||||
|
|
||||||
const includeBase64 = !context.config.noImageResponses;
|
const includeBase64 = context.clientSupportsImages();
|
||||||
const action = async () => {
|
const action = async () => {
|
||||||
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
const screenshot = locator ? await locator.screenshot(options) : await tab.page.screenshot(options);
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ const click = defineTool({
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const tab = context.currentTabOrDie();
|
const tab = context.currentTabOrDie();
|
||||||
const locator = tab.snapshotOrDie().refLocator(params.ref);
|
const locator = tab.snapshotOrDie().refLocator(params);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Click ${params.element}`,
|
`// Click ${params.element}`,
|
||||||
@@ -91,8 +91,8 @@ const drag = defineTool({
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
const startLocator = snapshot.refLocator(params.startRef);
|
const startLocator = snapshot.refLocator({ ref: params.startRef, element: params.startElement });
|
||||||
const endLocator = snapshot.refLocator(params.endRef);
|
const endLocator = snapshot.refLocator({ ref: params.endRef, element: params.endElement });
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Drag ${params.startElement} to ${params.endElement}`,
|
`// Drag ${params.startElement} to ${params.endElement}`,
|
||||||
@@ -120,7 +120,7 @@ const hover = defineTool({
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
const locator = snapshot.refLocator(params.ref);
|
const locator = snapshot.refLocator(params);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Hover over ${params.element}`,
|
`// Hover over ${params.element}`,
|
||||||
@@ -154,7 +154,7 @@ const type = defineTool({
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
const locator = snapshot.refLocator(params.ref);
|
const locator = snapshot.refLocator(params);
|
||||||
|
|
||||||
const code: string[] = [];
|
const code: string[] = [];
|
||||||
const steps: (() => Promise<void>)[] = [];
|
const steps: (() => Promise<void>)[] = [];
|
||||||
@@ -200,7 +200,7 @@ const selectOption = defineTool({
|
|||||||
|
|
||||||
handle: async (context, params) => {
|
handle: async (context, params) => {
|
||||||
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
const snapshot = context.currentTabOrDie().snapshotOrDie();
|
||||||
const locator = snapshot.refLocator(params.ref);
|
const locator = snapshot.refLocator(params);
|
||||||
|
|
||||||
const code = [
|
const code = [
|
||||||
`// Select options [${params.values.join(', ')}] in ${params.element}`,
|
`// Select options [${params.values.join(', ')}] in ${params.element}`,
|
||||||
|
|||||||
@@ -78,9 +78,9 @@ export function sanitizeForFilePath(s: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
export async function generateLocator(locator: playwright.Locator): Promise<string> {
|
||||||
return (locator as any)._frame._wrapApiCall(() => (locator as any)._generateLocatorString(), true);
|
return (locator as any)._generateLocatorString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> {
|
export async function callOnPageNoTrace<T>(page: playwright.Page, callback: (page: playwright.Page) => Promise<T>): Promise<T> {
|
||||||
return await (page as any)._wrapApiCall(() => callback(page), true);
|
return await (page as any)._wrapApiCall(() => callback(page), { internal: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
tests/browser-server.spec.ts
Normal file
77
tests/browser-server.spec.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* 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 path from 'path';
|
||||||
|
import url from 'node:url';
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { test as baseTest, expect } from './fixtures.js';
|
||||||
|
|
||||||
|
import type { ChildProcess } from 'child_process';
|
||||||
|
|
||||||
|
const __filename = url.fileURLToPath(import.meta.url);
|
||||||
|
|
||||||
|
const test = baseTest.extend<{ agentEndpoint: (options?: { args?: string[] }) => Promise<{ url: URL, stdout: () => string }> }>({
|
||||||
|
agentEndpoint: async ({}, use) => {
|
||||||
|
let cp: ChildProcess | undefined;
|
||||||
|
await use(async (options?: { args?: string[] }) => {
|
||||||
|
if (cp)
|
||||||
|
throw new Error('Process already running');
|
||||||
|
|
||||||
|
cp = spawn('node', [
|
||||||
|
path.join(path.dirname(__filename), '../lib/browserServer.js'),
|
||||||
|
...(options?.args || []),
|
||||||
|
], {
|
||||||
|
stdio: 'pipe',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DEBUG: 'pw:mcp:test',
|
||||||
|
DEBUG_COLORS: '0',
|
||||||
|
DEBUG_HIDE_DATE: '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let stdout = '';
|
||||||
|
const url = await new Promise<string>(resolve => cp!.stdout?.on('data', data => {
|
||||||
|
stdout += data.toString();
|
||||||
|
const match = stdout.match(/Listening on (http:\/\/.*)/);
|
||||||
|
if (match)
|
||||||
|
resolve(match[1]);
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { url: new URL(url), stdout: () => stdout };
|
||||||
|
});
|
||||||
|
cp?.kill('SIGTERM');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip(({ mcpBrowser }) => mcpBrowser !== 'chrome', 'Agent is CDP-only for now');
|
||||||
|
|
||||||
|
test('browser lifecycle', async ({ agentEndpoint, startClient, server }) => {
|
||||||
|
const { url: agentUrl } = await agentEndpoint();
|
||||||
|
const { client: client1 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] });
|
||||||
|
expect(await client1.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toContainTextContent('Hello, world!');
|
||||||
|
|
||||||
|
const { client: client2 } = await startClient({ args: ['--browser-agent', agentUrl.toString()] });
|
||||||
|
expect(await client2.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: server.HELLO_WORLD },
|
||||||
|
})).toContainTextContent('Hello, world!');
|
||||||
|
|
||||||
|
await client1.close();
|
||||||
|
await client2.close();
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -38,7 +38,7 @@ test('cdp server reuse tab', async ({ cdpServer, startClient, server }) => {
|
|||||||
element: 'Hello, world!',
|
element: 'Hello, world!',
|
||||||
ref: 'f0',
|
ref: 'f0',
|
||||||
},
|
},
|
||||||
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot of navigate to a new location first.`);
|
})).toHaveTextContent(`Error: No current snapshot available. Capture a snapshot or navigate to a new location first.`);
|
||||||
|
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_snapshot',
|
name: 'browser_snapshot',
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import fs from 'node:fs';
|
|||||||
import { Config } from '../config.js';
|
import { Config } from '../config.js';
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('config user data dir', async ({ startClient, localOutputPath, server }) => {
|
test('config user data dir', async ({ startClient, server }, testInfo) => {
|
||||||
server.setContent('/', `
|
server.setContent('/', `
|
||||||
<title>Title</title>
|
<title>Title</title>
|
||||||
<body>Hello, world!</body>
|
<body>Hello, world!</body>
|
||||||
@@ -27,13 +27,13 @@ test('config user data dir', async ({ startClient, localOutputPath, server }) =>
|
|||||||
|
|
||||||
const config: Config = {
|
const config: Config = {
|
||||||
browser: {
|
browser: {
|
||||||
userDataDir: localOutputPath('user-data-dir'),
|
userDataDir: testInfo.outputPath('user-data-dir'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const configPath = localOutputPath('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 },
|
||||||
@@ -42,3 +42,22 @@ test('config user data dir', async ({ startClient, localOutputPath, server }) =>
|
|||||||
const files = await fs.promises.readdir(config.browser!.userDataDir!);
|
const files = await fs.promises.readdir(config.browser!.userDataDir!);
|
||||||
expect(files.length).toBeGreaterThan(0);
|
expect(files.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe(() => {
|
||||||
|
test.use({ mcpBrowser: '' });
|
||||||
|
test('browserName', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/458' } }, async ({ startClient }, testInfo) => {
|
||||||
|
const config: Config = {
|
||||||
|
browser: {
|
||||||
|
browserName: 'firefox',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const configPath = testInfo.outputPath('config.json');
|
||||||
|
await fs.promises.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||||
|
|
||||||
|
const { client } = await startClient({ args: ['--config', configPath] });
|
||||||
|
expect(await client.callTool({
|
||||||
|
name: 'browser_navigate',
|
||||||
|
arguments: { url: 'data:text/html,<script>document.title = navigator.userAgent</script>' },
|
||||||
|
})).toContainTextContent(`Firefox`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,8 @@
|
|||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
test('browser_file_upload', async ({ client, localOutputPath, server }) => {
|
test('browser_file_upload', async ({ client, server }, testInfo) => {
|
||||||
server.setContent('/', `
|
server.setContent('/', `
|
||||||
<input type="file" />
|
<input type="file" />
|
||||||
<button>Button</button>
|
<button>Button</button>
|
||||||
@@ -54,7 +53,7 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
})).toContainTextContent(`### Modal state
|
})).toContainTextContent(`### Modal state
|
||||||
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
||||||
|
|
||||||
const filePath = localOutputPath('test.txt');
|
const filePath = testInfo.outputPath('test.txt');
|
||||||
await fs.writeFile(filePath, 'Hello, world!');
|
await fs.writeFile(filePath, 'Hello, world!');
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -101,10 +100,9 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking on download link emits download', async ({ startClient, localOutputPath, server }) => {
|
test('clicking on download link emits download', async ({ startClient, server }, testInfo) => {
|
||||||
const outputDir = localOutputPath('output');
|
const { client } = await startClient({
|
||||||
const client = await startClient({
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
config: { outputDir },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
server.setContent('/', `<a href="/download" download="test.txt">Download</a>`, 'text/html');
|
server.setContent('/', `<a href="/download" download="test.txt">Download</a>`, 'text/html');
|
||||||
@@ -123,13 +121,12 @@ test('clicking on download link emits download', async ({ startClient, localOutp
|
|||||||
});
|
});
|
||||||
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`
|
await expect.poll(() => client.callTool({ name: 'browser_snapshot' })).toContainTextContent(`
|
||||||
### Downloads
|
### Downloads
|
||||||
- Downloaded file test.txt to ${path.join(outputDir, 'test.txt')}`);
|
- Downloaded file test.txt to ${testInfo.outputPath('output', 'test.txt')}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('navigating to download link emits download', async ({ startClient, localOutputPath, mcpBrowser, server }) => {
|
test('navigating to download link emits download', async ({ startClient, server, mcpBrowser }, testInfo) => {
|
||||||
const outputDir = localOutputPath('output');
|
const { client } = await startClient({
|
||||||
const client = await startClient({
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
args: ['--output-dir', outputDir],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436');
|
test.skip(mcpBrowser === 'webkit' && process.platform === 'linux', 'https://github.com/microsoft/playwright/blob/8e08fdb52c27bb75de9bf87627bf740fadab2122/tests/library/download.spec.ts#L436');
|
||||||
|
|||||||
@@ -40,13 +40,12 @@ 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;
|
||||||
httpsServer: TestServer;
|
httpsServer: TestServer;
|
||||||
mcpHeadless: boolean;
|
mcpHeadless: boolean;
|
||||||
localOutputPath: (filePath: string) => string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type WorkerFixtures = {
|
type WorkerFixtures = {
|
||||||
@@ -56,20 +55,24 @@ 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) => {
|
||||||
const userDataDir = testInfo.outputPath('user-data-dir');
|
const userDataDir = mcpMode !== 'docker' ? testInfo.outputPath('user-data-dir') : undefined;
|
||||||
const configDir = path.dirname(test.info().config.configFile!);
|
const configDir = path.dirname(test.info().config.configFile!);
|
||||||
let client: Client | undefined;
|
let client: Client | undefined;
|
||||||
|
|
||||||
await use(async options => {
|
await use(async options => {
|
||||||
const args = ['--user-data-dir', path.relative(configDir, userDataDir)];
|
const args: string[] = [];
|
||||||
|
if (userDataDir)
|
||||||
|
args.push('--user-data-dir', userDataDir);
|
||||||
if (process.env.CI && process.platform === 'linux')
|
if (process.env.CI && process.platform === 'linux')
|
||||||
args.push('--no-sandbox');
|
args.push('--no-sandbox');
|
||||||
if (mcpHeadless)
|
if (mcpHeadless)
|
||||||
@@ -86,9 +89,15 @@ 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 => {
|
||||||
|
if (process.env.PWMCP_DEBUG)
|
||||||
|
process.stderr.write(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();
|
||||||
@@ -129,13 +138,6 @@ export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>(
|
|||||||
|
|
||||||
mcpMode: [undefined, { option: true }],
|
mcpMode: [undefined, { option: true }],
|
||||||
|
|
||||||
localOutputPath: async ({ mcpMode }, use, testInfo) => {
|
|
||||||
await use(filePath => {
|
|
||||||
test.skip(mcpMode === 'docker', 'Mounting files is not supported in docker mode');
|
|
||||||
return testInfo.outputPath(filePath);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
_workerServers: [async ({}, use, workerInfo) => {
|
_workerServers: [async ({}, use, workerInfo) => {
|
||||||
const port = 8907 + workerInfo.workerIndex * 4;
|
const port = 8907 + workerInfo.workerIndex * 4;
|
||||||
const server = await TestServer.create(port);
|
const server = await TestServer.create(port);
|
||||||
@@ -176,7 +178,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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,3 +241,7 @@ export const expect = baseExpect.extend({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function formatOutput(output: string): string[] {
|
||||||
|
return output.split('\n').map(line => line.replace(/^pw:mcp:test /, '').replace(/user data dir.*/, 'user data dir').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 },
|
||||||
@@ -104,8 +126,8 @@ test('isolated context', async ({ startClient, server }) => {
|
|||||||
expect(response2).toContainTextContent(`Storage: NO`);
|
expect(response2).toContainTextContent(`Storage: NO`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('isolated context with storage state', async ({ startClient, server, localOutputPath }) => {
|
test('isolated context with storage state', async ({ startClient, server }, testInfo) => {
|
||||||
const storageStatePath = localOutputPath('storage-state.json');
|
const storageStatePath = testInfo.outputPath('storage-state.json');
|
||||||
await fs.promises.writeFile(storageStatePath, JSON.stringify({
|
await fs.promises.writeFile(storageStatePath, JSON.stringify({
|
||||||
origins: [
|
origins: [
|
||||||
{
|
{
|
||||||
@@ -123,7 +145,7 @@ test('isolated context with storage state', async ({ startClient, server, localO
|
|||||||
</script>
|
</script>
|
||||||
`, 'text/html');
|
`, 'text/html');
|
||||||
|
|
||||||
const client = await startClient({ args: [
|
const { client } = await startClient({ args: [
|
||||||
`--isolated`,
|
`--isolated`,
|
||||||
`--storage-state=${storageStatePath}`,
|
`--storage-state=${storageStatePath}`,
|
||||||
] });
|
] });
|
||||||
|
|||||||
28
tests/library.spec.ts
Normal file
28
tests/library.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
import { test, expect } from './fixtures.js';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import child_process from 'node:child_process';
|
||||||
|
|
||||||
|
test('library can be used from CommonJS', { annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright-mcp/issues/456' } }, async ({}, testInfo) => {
|
||||||
|
const file = testInfo.outputPath('main.cjs');
|
||||||
|
await fs.writeFile(file, `
|
||||||
|
import('@playwright/mcp')
|
||||||
|
.then(playwrightMCP => playwrightMCP.createConnection())
|
||||||
|
.then(() => console.log('OK'));
|
||||||
|
`);
|
||||||
|
expect(child_process.execSync(`node ${file}`, { encoding: 'utf-8' })).toContain('OK');
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
@@ -30,10 +30,9 @@ test('save as pdf unavailable', async ({ startClient, server }) => {
|
|||||||
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
|
})).toHaveTextContent(/Tool \"browser_pdf_save\" not found/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('save as pdf', async ({ startClient, mcpBrowser, server, localOutputPath }) => {
|
test('save as pdf', async ({ startClient, mcpBrowser, server }, testInfo) => {
|
||||||
const outputDir = localOutputPath('output');
|
const { client } = await startClient({
|
||||||
const client = await startClient({
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
config: { outputDir },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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.');
|
||||||
@@ -49,10 +48,10 @@ test('save as pdf', async ({ startClient, mcpBrowser, server, localOutputPath })
|
|||||||
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
|
expect(response).toHaveTextContent(/Save page as.*page-[^:]+.pdf/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server, localOutputPath }) => {
|
test('save as pdf (filename: output.pdf)', async ({ startClient, mcpBrowser, server }, testInfo) => {
|
||||||
|
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 outputDir = localOutputPath('output');
|
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');
|
||||||
|
|||||||
@@ -18,10 +18,9 @@ import fs from 'fs';
|
|||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('browser_take_screenshot (viewport)', async ({ startClient, server, localOutputPath }) => {
|
test('browser_take_screenshot (viewport)', async ({ startClient, server }, testInfo) => {
|
||||||
const outputDir = localOutputPath('output');
|
const { client } = await startClient({
|
||||||
const client = await startClient({
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
args: ['--output-dir', outputDir],
|
|
||||||
});
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
@@ -45,10 +44,9 @@ test('browser_take_screenshot (viewport)', async ({ startClient, server, localOu
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_take_screenshot (element)', async ({ startClient, server, localOutputPath }) => {
|
test('browser_take_screenshot (element)', async ({ startClient, server }, testInfo) => {
|
||||||
const outputDir = localOutputPath('output');
|
const { client } = await startClient({
|
||||||
const client = await startClient({
|
config: { outputDir: testInfo.outputPath('output') },
|
||||||
args: ['--output-dir', outputDir],
|
|
||||||
});
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
@@ -76,10 +74,10 @@ test('browser_take_screenshot (element)', async ({ startClient, server, localOut
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('--output-dir should work', async ({ startClient, localOutputPath, server }) => {
|
test('--output-dir should work', async ({ startClient, server }, testInfo) => {
|
||||||
const outputDir = localOutputPath('output');
|
const outputDir = testInfo.outputPath('output');
|
||||||
const client = await startClient({
|
const { client } = await startClient({
|
||||||
args: ['--output-dir', outputDir],
|
config: { outputDir },
|
||||||
});
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
name: 'browser_navigate',
|
name: 'browser_navigate',
|
||||||
@@ -97,10 +95,10 @@ test('--output-dir should work', async ({ startClient, localOutputPath, server }
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const raw of [undefined, true]) {
|
for (const raw of [undefined, true]) {
|
||||||
test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, localOutputPath, server }) => {
|
test(`browser_take_screenshot (raw: ${raw})`, async ({ startClient, server }, testInfo) => {
|
||||||
|
const outputDir = testInfo.outputPath('output');
|
||||||
const ext = raw ? 'png' : 'jpeg';
|
const ext = raw ? 'png' : 'jpeg';
|
||||||
const outputDir = localOutputPath('output');
|
const { client } = await startClient({
|
||||||
const client = await startClient({
|
|
||||||
config: { outputDir },
|
config: { outputDir },
|
||||||
});
|
});
|
||||||
expect(await client.callTool({
|
expect(await client.callTool({
|
||||||
@@ -138,9 +136,9 @@ for (const raw of [undefined, true]) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, localOutputPath, server }) => {
|
test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient, server }, testInfo) => {
|
||||||
const outputDir = localOutputPath('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,11 +172,12 @@ test('browser_take_screenshot (filename: "output.jpeg")', async ({ startClient,
|
|||||||
expect(files[0]).toMatch(/^output\.jpeg$/);
|
expect(files[0]).toMatch(/^output\.jpeg$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_take_screenshot (noImageResponses)', async ({ startClient, server, localOutputPath }) => {
|
test('browser_take_screenshot (imageResponses=omit)', async ({ startClient, server }, testInfo) => {
|
||||||
const client = await startClient({
|
const outputDir = testInfo.outputPath('output');
|
||||||
|
const { client } = await startClient({
|
||||||
config: {
|
config: {
|
||||||
outputDir: localOutputPath('output'),
|
outputDir,
|
||||||
noImageResponses: true,
|
imageResponses: 'omit',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -203,9 +202,10 @@ test('browser_take_screenshot (noImageResponses)', async ({ startClient, server,
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('browser_take_screenshot (cursor)', async ({ startClient, server, localOutputPath }) => {
|
test('browser_take_screenshot (cursor)', async ({ startClient, server }, testInfo) => {
|
||||||
const outputDir = localOutputPath('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, localOutputPath }) => {
|
|
||||||
const userDataDir = localOutputPath('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 },
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ import path from 'path';
|
|||||||
|
|
||||||
import { test, expect } from './fixtures.js';
|
import { test, expect } from './fixtures.js';
|
||||||
|
|
||||||
test('check that trace is saved', async ({ startClient, server, localOutputPath }) => {
|
test('check that trace is saved', async ({ startClient, server }, testInfo) => {
|
||||||
const outputDir = localOutputPath('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