Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8a59e0d0d | ||
|
|
21533d9000 | ||
|
|
49979641fa | ||
|
|
43aa4001b5 | ||
|
|
7e087af6a6 | ||
|
|
927a1280f1 | ||
|
|
292e75d464 | ||
|
|
2c9376e50f | ||
|
|
062cdd0704 | ||
|
|
a713300c5b | ||
|
|
a15f0f301b | ||
|
|
23ce973377 | ||
|
|
685dea9e19 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
lib/
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
.vscode/mcp.json
|
||||
|
||||
.idea
|
||||
|
||||
16
README.md
16
README.md
@@ -17,7 +17,7 @@ A Model Context Protocol (MCP) server that provides browser automation capabilit
|
||||
|
||||
<!--
|
||||
// Generate using:
|
||||
node utils/generate_links.js
|
||||
node utils/generate-links.js
|
||||
-->
|
||||
|
||||
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20Server&color=0098FF" alt="Install in VS Code">](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [<img alt="Install in VS Code Insiders" src="https://img.shields.io/badge/VS_Code_Insiders-VS_Code_Insiders?style=flat-square&label=Install%20Server&color=24bfa5">](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
|
||||
@@ -142,7 +142,8 @@ The Playwright MCP server can be configured using a JSON configuration file. Her
|
||||
'history' | // Browser history
|
||||
'wait' | // Wait utilities
|
||||
'files' | // File handling
|
||||
'install' // Browser installation
|
||||
'install' | // Browser installation
|
||||
'testing' // Testing
|
||||
>;
|
||||
|
||||
// Enable vision mode (screenshots instead of accessibility snapshots)
|
||||
@@ -485,4 +486,15 @@ X Y coordinate space, based on the provided screenshot.
|
||||
- `accept` (boolean): Whether to accept the dialog.
|
||||
- `promptText` (string, optional): The text of the prompt in case of a prompt dialog.
|
||||
|
||||
### Testing
|
||||
|
||||
<!-- NOTE: This has been generated via update-readme.js -->
|
||||
|
||||
- **browser_generate_playwright_test**
|
||||
- Description: Generate a Playwright test for given scenario
|
||||
- Parameters:
|
||||
- `name` (string): The name of the test
|
||||
- `description` (string): The description of the test
|
||||
- `steps` (array): The steps of the test
|
||||
|
||||
<!--- End of generated section -->
|
||||
|
||||
2
cli.js
2
cli.js
@@ -15,4 +15,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
require('./lib/program');
|
||||
import './lib/program.js';
|
||||
|
||||
2
config.d.ts
vendored
2
config.d.ts
vendored
@@ -16,7 +16,7 @@
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
|
||||
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install';
|
||||
export type ToolCapability = 'core' | 'tabs' | 'pdf' | 'history' | 'wait' | 'files' | 'install' | 'testing';
|
||||
|
||||
export type Config = {
|
||||
/**
|
||||
|
||||
@@ -33,6 +33,7 @@ const plugins = {
|
||||
};
|
||||
|
||||
export const baseRules = {
|
||||
"import/extensions": ["error", "ignorePackages", {ts: "always"}],
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
2,
|
||||
@@ -179,6 +180,7 @@ export const baseRules = {
|
||||
|
||||
// react
|
||||
"react/react-in-jsx-scope": 0,
|
||||
"no-console": 2,
|
||||
};
|
||||
|
||||
const languageOptions = {
|
||||
|
||||
47
examples.md
47
examples.md
@@ -1,47 +0,0 @@
|
||||
# Examples
|
||||
|
||||
Below are some examples of prompts to use with Playwright MCP. Draw inspiration from them to create your own. If you come up with a new prompt that works well, feel free to let us know in an issue or pull request.
|
||||
|
||||
## Write a regression test for a bug report
|
||||
|
||||
We have received a user report about a bug on our website https://debs-obrien.github.io/playwright-movies-app. I'd like you to verify if this bug exists by trying to follow the steps. If you see different behaviour, explain how it's different. If you see the same behaviour, please write me a Playwright regression test that encapsulates the buggy user flow. While writing the test, draw inspiration from the Playwright code you ran. Here's the user report:
|
||||
|
||||
Howdy. I love your movies site, but there's something fishy going on. I'm looking at The Garfield Movie - a spectacular film - but somehow it's showing the wrong length. The movie is 90mins long, I measured myself yesterday! But somehow your site is claiming 101mins. Am I looking at it wrong, or is there a bug?
|
||||
|
||||
**Output:**
|
||||
|
||||
```ts
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Regression test for bug: The Garfield Movie showing incorrect runtime
|
||||
* User reported actual runtime is 90 minutes, but site shows 101 minutes
|
||||
*/
|
||||
test('verify The Garfield Movie runtime bug', async ({ page }) => {
|
||||
// Navigate to the movies app home page
|
||||
await page.goto('https://debs-obrien.github.io/playwright-movies-app');
|
||||
|
||||
// Wait for page to load completely
|
||||
await expect(page.getByRole('heading', { name: 'Popular', level: 1 })).toBeVisible();
|
||||
|
||||
// Look for The Garfield Movie and click on it
|
||||
const garfieldMovieLink = page.getByRole('link', { name: /poster of The Garfield Movie/i });
|
||||
await expect(garfieldMovieLink).toBeVisible();
|
||||
await garfieldMovieLink.click();
|
||||
|
||||
// Verify we're on the correct movie page
|
||||
await expect(page.getByRole('heading', { name: 'The Garfield Movie', level: 1 })).toBeVisible();
|
||||
|
||||
// Check the runtime information
|
||||
// The bug: Expected runtime should be 90 min, but site shows 101 min
|
||||
const movieInfoText = await page.locator('text=English / 101 min. / 2024').textContent();
|
||||
|
||||
// This test will fail because of the bug (which is what we want to demonstrate)
|
||||
// Once fixed, this assertion should be updated to the correct runtime (90 min)
|
||||
expect(movieInfoText).toContain('90 min');
|
||||
|
||||
// Alternative assertion that verifies the incorrect runtime is still present
|
||||
// Uncomment this and comment the above assertion to verify the bug exists
|
||||
// expect(movieInfoText).toContain('101 min');
|
||||
});
|
||||
```
|
||||
10
examples/generate-test.md
Normal file
10
examples/generate-test.md
Normal file
@@ -0,0 +1,10 @@
|
||||
Generate test for scenario:
|
||||
|
||||
## GitHub PR Checks Navigation Checklist
|
||||
|
||||
1. Open the [Microsoft Playwright GitHub repository](https://github.com/microsoft/playwright).
|
||||
2. Click on the **Pull requests** tab.
|
||||
3. Find and open the pull request titled **"chore: make noWaitAfter a default"**.
|
||||
4. Switch to the **Checks** tab for that pull request.
|
||||
5. Expand the **infra** check suite to view its jobs.
|
||||
6. Click on the **docs & lint** job to view its details.
|
||||
4
index.js
4
index.js
@@ -15,5 +15,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const { createServer } = require('./lib/index');
|
||||
module.exports = { createServer };
|
||||
import { createServer } from './lib/index';
|
||||
export default { createServer };
|
||||
|
||||
30
package-lock.json
generated
30
package-lock.json
generated
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.18",
|
||||
"version": "0.0.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.18",
|
||||
"version": "0.0.20",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.10.1",
|
||||
"commander": "^13.1.0",
|
||||
"playwright": "1.53.0-alpha-2025-04-25",
|
||||
"playwright": "1.53.0-alpha-1746218818000",
|
||||
"yaml": "^2.7.1",
|
||||
"zod-to-json-schema": "^3.24.4"
|
||||
},
|
||||
@@ -21,7 +21,7 @@
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@playwright/test": "1.53.0-alpha-2025-04-25",
|
||||
"@playwright/test": "1.53.0-alpha-1746218818000",
|
||||
"@stylistic/eslint-plugin": "^3.0.1",
|
||||
"@types/node": "^22.13.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||
@@ -287,13 +287,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.53.0-alpha-2025-04-25",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-2025-04-25.tgz",
|
||||
"integrity": "sha512-3y4C2ZjAc2oUpwavC2yG2JzH53TOKgcMZvWb5GmpxnOa6fhuSVXK0kIsiIaImKmdffIVM1agsqNHp8yldeBTHQ==",
|
||||
"version": "1.53.0-alpha-1746218818000",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.53.0-alpha-1746218818000.tgz",
|
||||
"integrity": "sha512-J05FD0oOCVbjbp4IjQi5tOPKywchi5EENS9jRjgkA5N9jd/+BaZ3jT8HlLMIgALdk/eLsprQa7vh9h45Q1FOPA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.53.0-alpha-2025-04-25"
|
||||
"playwright": "1.53.0-alpha-1746218818000"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -3299,12 +3299,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.53.0-alpha-2025-04-25",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-2025-04-25.tgz",
|
||||
"integrity": "sha512-b5VT4lWgyhhy99zHeCoUBt/FQckPxeQVA5ksvxBv0HeqcEvzZzhuyqrrcZewJyflE+5U+bmvqI+yoU0ks8mE3Q==",
|
||||
"version": "1.53.0-alpha-1746218818000",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.53.0-alpha-1746218818000.tgz",
|
||||
"integrity": "sha512-mVIjtdqIawIqWVyvCaLmV6XTALCT4oWWrbMjoHyyWRln3jQjnm3RUO9LkaINz+Yh88O3FkuY6RfjGXPXeFeJ4Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.53.0-alpha-2025-04-25"
|
||||
"playwright-core": "1.53.0-alpha-1746218818000"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -3317,9 +3317,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.53.0-alpha-2025-04-25",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-2025-04-25.tgz",
|
||||
"integrity": "sha512-gjV01l6A4q/zg+/pwEX50k9lhYWaE9NcDVypSDD331jB3EYrdk0LeDQxqz5XFDOzq/tC/8QTouDs9a/s/p95hA==",
|
||||
"version": "1.53.0-alpha-1746218818000",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.53.0-alpha-1746218818000.tgz",
|
||||
"integrity": "sha512-iaIZmhO/psGssWpxIprJkFrn2h4xFjgL0jZsKGtReAMZ/XhlqMUJxtSitwWM4BV+wxJIptsZD0s5Ml2KU62Z3w==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "@playwright/mcp",
|
||||
"version": "0.0.18",
|
||||
"version": "0.0.20",
|
||||
"description": "Playwright Tools for MCP",
|
||||
"type": "module",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/microsoft/playwright-mcp.git"
|
||||
@@ -36,14 +37,14 @@
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.10.1",
|
||||
"commander": "^13.1.0",
|
||||
"playwright": "1.53.0-alpha-2025-04-25",
|
||||
"playwright": "1.53.0-alpha-1746218818000",
|
||||
"yaml": "^2.7.1",
|
||||
"zod-to-json-schema": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@playwright/test": "1.53.0-alpha-2025-04-25",
|
||||
"@playwright/test": "1.53.0-alpha-1746218818000",
|
||||
"@stylistic/eslint-plugin": "^3.0.1",
|
||||
"@types/node": "^22.13.10",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
|
||||
import { defineConfig } from '@playwright/test';
|
||||
|
||||
import type { Project } from '@playwright/test';
|
||||
import type { TestOptions } from './tests/fixtures.js';
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig<TestOptions>({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
@@ -31,5 +31,5 @@ export default defineConfig({
|
||||
{ name: 'chromium', use: { mcpBrowser: 'chromium' } },
|
||||
{ name: 'firefox', use: { mcpBrowser: 'firefox' } },
|
||||
{ name: 'webkit', use: { mcpBrowser: 'webkit' } },
|
||||
].filter(Boolean) as Project[],
|
||||
],
|
||||
});
|
||||
|
||||
@@ -20,10 +20,9 @@ import os from 'os';
|
||||
import path from 'path';
|
||||
import { devices } from 'playwright';
|
||||
|
||||
import { sanitizeForFilePath } from './tools/utils';
|
||||
|
||||
import type { Config, ToolCapability } from '../config';
|
||||
import type { Config, ToolCapability } from '../config.js';
|
||||
import type { BrowserContextOptions, LaunchOptions } from 'playwright';
|
||||
import { sanitizeForFilePath } from './tools/utils.js';
|
||||
|
||||
export type CLIOptions = {
|
||||
browser?: string;
|
||||
|
||||
@@ -16,13 +16,14 @@
|
||||
|
||||
import * as playwright from 'playwright';
|
||||
|
||||
import { waitForCompletion } from './tools/utils';
|
||||
import { ManualPromise } from './manualPromise';
|
||||
import { Tab } from './tab';
|
||||
import { waitForCompletion } from './tools/utils.js';
|
||||
import { ManualPromise } from './manualPromise.js';
|
||||
import { Tab } from './tab.js';
|
||||
|
||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
|
||||
import type { ModalState, Tool, ToolActionResult } from './tools/tool';
|
||||
import type { Config } from '../config';
|
||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { ModalState, Tool, ToolActionResult } from './tools/tool.js';
|
||||
import type { Config } from '../config.js';
|
||||
import { outputFile } from './config.js';
|
||||
|
||||
type PendingAction = {
|
||||
dialogShown: ManualPromise<void>;
|
||||
@@ -38,6 +39,7 @@ export class Context {
|
||||
private _currentTab: Tab | undefined;
|
||||
private _modalStates: (ModalState & { tab: Tab })[] = [];
|
||||
private _pendingAction: PendingAction | undefined;
|
||||
private _downloads: { download: playwright.Download, finished: boolean, outputFile: string }[] = [];
|
||||
|
||||
constructor(tools: Tool[], config: Config) {
|
||||
this.tools = tools;
|
||||
@@ -164,6 +166,17 @@ ${code.join('\n')}
|
||||
};
|
||||
}
|
||||
|
||||
if (this._downloads.length) {
|
||||
result.push('', '### Downloads');
|
||||
for (const entry of this._downloads) {
|
||||
if (entry.finished)
|
||||
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
|
||||
else
|
||||
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
|
||||
}
|
||||
result.push('');
|
||||
}
|
||||
|
||||
if (this.tabs().length > 1)
|
||||
result.push(await this.listTabsMarkdown(), '');
|
||||
|
||||
@@ -228,6 +241,17 @@ ${code.join('\n')}
|
||||
this._pendingAction?.dialogShown.resolve();
|
||||
}
|
||||
|
||||
async downloadStarted(tab: Tab, download: playwright.Download) {
|
||||
const entry = {
|
||||
download,
|
||||
finished: false,
|
||||
outputFile: await outputFile(this.config, download.suggestedFilename())
|
||||
};
|
||||
this._downloads.push(entry);
|
||||
await download.saveAs(entry.outputFile);
|
||||
entry.finished = true;
|
||||
}
|
||||
|
||||
private _onPageCreated(page: playwright.Page) {
|
||||
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
||||
this._tabs.push(tab);
|
||||
@@ -275,8 +299,12 @@ ${code.join('\n')}
|
||||
}
|
||||
|
||||
private async _createBrowserContext(): Promise<{ browser?: playwright.Browser, browserContext: playwright.BrowserContext }> {
|
||||
if (!this._createBrowserContextPromise)
|
||||
if (!this._createBrowserContextPromise) {
|
||||
this._createBrowserContextPromise = this._innerCreateBrowserContext();
|
||||
void this._createBrowserContextPromise.catch(() => {
|
||||
this._createBrowserContextPromise = undefined;
|
||||
});
|
||||
}
|
||||
return this._createBrowserContextPromise;
|
||||
}
|
||||
|
||||
|
||||
36
src/index.ts
36
src/index.ts
@@ -14,22 +14,22 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createServerWithTools } from './server';
|
||||
import common from './tools/common';
|
||||
import console from './tools/console';
|
||||
import dialogs from './tools/dialogs';
|
||||
import files from './tools/files';
|
||||
import install from './tools/install';
|
||||
import keyboard from './tools/keyboard';
|
||||
import navigate from './tools/navigate';
|
||||
import network from './tools/network';
|
||||
import pdf from './tools/pdf';
|
||||
import snapshot from './tools/snapshot';
|
||||
import tabs from './tools/tabs';
|
||||
import screen from './tools/screen';
|
||||
|
||||
import type { Tool } from './tools/tool';
|
||||
import type { Config } from '../config';
|
||||
import { createServerWithTools } from './server.js';
|
||||
import common from './tools/common.js';
|
||||
import console from './tools/console.js';
|
||||
import dialogs from './tools/dialogs.js';
|
||||
import files from './tools/files.js';
|
||||
import install from './tools/install.js';
|
||||
import keyboard from './tools/keyboard.js';
|
||||
import navigate from './tools/navigate.js';
|
||||
import network from './tools/network.js';
|
||||
import pdf from './tools/pdf.js';
|
||||
import snapshot from './tools/snapshot.js';
|
||||
import tabs from './tools/tabs.js';
|
||||
import screen from './tools/screen.js';
|
||||
import testing from './tools/testing.js';
|
||||
import type { Tool } from './tools/tool.js';
|
||||
import type { Config } from '../config.js';
|
||||
import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
|
||||
const snapshotTools: Tool<any>[] = [
|
||||
@@ -44,6 +44,7 @@ const snapshotTools: Tool<any>[] = [
|
||||
...pdf,
|
||||
...snapshot,
|
||||
...tabs(true),
|
||||
...testing,
|
||||
];
|
||||
|
||||
const screenshotTools: Tool<any>[] = [
|
||||
@@ -58,9 +59,10 @@ const screenshotTools: Tool<any>[] = [
|
||||
...pdf,
|
||||
...screen,
|
||||
...tabs(false),
|
||||
...testing,
|
||||
];
|
||||
|
||||
const packageJSON = require('../package.json');
|
||||
import packageJSON from '../package.json' with { type: 'json' };
|
||||
|
||||
export async function createServer(config: Config = {}): Promise<Server> {
|
||||
const allTools = config.vision ? screenshotTools : snapshotTools;
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
|
||||
import { program } from 'commander';
|
||||
|
||||
import { createServer } from './index';
|
||||
import { ServerList } from './server';
|
||||
import { createServer } from './index.js';
|
||||
import { ServerList } from './server.js';
|
||||
|
||||
import { startHttpTransport, startStdioTransport } from './transport';
|
||||
import { startHttpTransport, startStdioTransport } from './transport.js';
|
||||
|
||||
import { resolveConfig } from './config';
|
||||
import { resolveConfig } from './config.js';
|
||||
|
||||
const packageJSON = require('../package.json');
|
||||
import packageJSON from '../package.json' with { type: 'json' };
|
||||
|
||||
program
|
||||
.version('Version ' + packageJSON.version)
|
||||
@@ -41,7 +41,6 @@ program
|
||||
.option('--config <path>', 'Path to the configuration file.')
|
||||
.action(async options => {
|
||||
const config = await resolveConfig(options);
|
||||
console.error(config);
|
||||
const serverList = new ServerList(() => createServer(config));
|
||||
setupExitWatchdog(serverList);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Context } from '../context';
|
||||
import type { Context } from '../context.js';
|
||||
|
||||
export type ResourceSchema = {
|
||||
uri: string;
|
||||
|
||||
@@ -18,10 +18,10 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
||||
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
import { Context } from './context';
|
||||
import { Context } from './context.js';
|
||||
|
||||
import type { Tool } from './tools/tool';
|
||||
import type { Config } from '../config';
|
||||
import type { Tool } from './tools/tool.js';
|
||||
import type { Config } from '../config.js';
|
||||
|
||||
type MCPServerOptions = {
|
||||
name: string;
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
|
||||
import * as playwright from 'playwright';
|
||||
|
||||
import { PageSnapshot } from './pageSnapshot';
|
||||
import { PageSnapshot } from './pageSnapshot.js';
|
||||
|
||||
import type { Context } from './context';
|
||||
import type { Context } from './context.js';
|
||||
|
||||
export class Tab {
|
||||
readonly context: Context;
|
||||
@@ -48,6 +48,9 @@ export class Tab {
|
||||
}, this);
|
||||
});
|
||||
page.on('dialog', dialog => this.context.dialogShown(this, dialog));
|
||||
page.on('download', download => {
|
||||
void this.context.downloadStarted(this, download);
|
||||
});
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
page.setDefaultTimeout(5000);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool, type ToolFactory } from './tool';
|
||||
import { defineTool, type ToolFactory } from './tool.js';
|
||||
|
||||
const wait: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'wait',
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool } from './tool';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
const console = defineTool({
|
||||
capability: 'core',
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool, type ToolFactory } from './tool';
|
||||
import { defineTool, type ToolFactory } from './tool.js';
|
||||
|
||||
const handleDialog: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'core',
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool, type ToolFactory } from './tool';
|
||||
import { defineTool, type ToolFactory } from './tool.js';
|
||||
|
||||
const uploadFile: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'files',
|
||||
|
||||
@@ -18,7 +18,10 @@ import { fork } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool } from './tool';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const install = defineTool({
|
||||
capability: 'install',
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool, type ToolFactory } from './tool';
|
||||
import { defineTool, type ToolFactory } from './tool.js';
|
||||
|
||||
const pressKey: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'core',
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool, type ToolFactory } from './tool';
|
||||
import { defineTool, type ToolFactory } from './tool.js';
|
||||
|
||||
const navigate: ToolFactory = captureSnapshot => defineTool({
|
||||
capability: 'core',
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool } from './tool';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool } from './tool';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
import * as javascript from '../javascript';
|
||||
import { outputFile } from '../config';
|
||||
import * as javascript from '../javascript.js';
|
||||
import { outputFile } from '../config.js';
|
||||
|
||||
const pdf = defineTool({
|
||||
capability: 'pdf',
|
||||
@@ -31,7 +31,7 @@ const pdf = defineTool({
|
||||
|
||||
handle: async context => {
|
||||
const tab = context.currentTabOrDie();
|
||||
const fileName = await outputFile(context.config, `page-${new Date().toISOString()}'.pdf'`);
|
||||
const fileName = await outputFile(context.config, `page-${new Date().toISOString()}.pdf`);
|
||||
|
||||
const code = [
|
||||
`// Save page as ${fileName}`,
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool } from './tool';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
import * as javascript from '../javascript';
|
||||
import * as javascript from '../javascript.js';
|
||||
|
||||
const elementSchema = z.object({
|
||||
element: z.string().describe('Human-readable element description used to obtain permission to interact with the element'),
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { defineTool } from './tool';
|
||||
import * as javascript from '../javascript';
|
||||
import { outputFile } from '../config';
|
||||
import { defineTool } from './tool.js';
|
||||
import * as javascript from '../javascript.js';
|
||||
import { outputFile } from '../config.js';
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import { defineTool, type ToolFactory } from './tool';
|
||||
import { defineTool, type ToolFactory } from './tool.js';
|
||||
|
||||
const listTabs = defineTool({
|
||||
capability: 'tabs',
|
||||
|
||||
65
src/tools/testing.ts
Normal file
65
src/tools/testing.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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 { z } from 'zod';
|
||||
import { defineTool } from './tool.js';
|
||||
|
||||
const generateTestSchema = z.object({
|
||||
name: z.string().describe('The name of the test'),
|
||||
description: z.string().describe('The description of the test'),
|
||||
steps: z.array(z.string()).describe('The steps of the test'),
|
||||
});
|
||||
|
||||
const generateTest = defineTool({
|
||||
capability: 'testing',
|
||||
|
||||
schema: {
|
||||
name: 'browser_generate_playwright_test',
|
||||
description: 'Generate a Playwright test for given scenario',
|
||||
inputSchema: generateTestSchema,
|
||||
},
|
||||
|
||||
handle: async (context, params) => {
|
||||
return {
|
||||
resultOverride: {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: instructions(params),
|
||||
}],
|
||||
},
|
||||
code: [],
|
||||
captureSnapshot: false,
|
||||
waitForNetwork: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const instructions = (params: { name: string, description: string, steps: string[] }) => [
|
||||
`## Instructions`,
|
||||
`- You are a playwright test generator.`,
|
||||
`- You are given a scenario and you need to generate a playwright test for it.`,
|
||||
'- DO NOT generate test code based on the scenario alone. DO run steps one by one using the tools provided instead.',
|
||||
'- Only after all steps are completed, emit a Playwright TypeScript test that uses @playwright/test based on message history',
|
||||
'- Save generated test file in the tests directory',
|
||||
`Test name: ${params.name}`,
|
||||
`Description: ${params.description}`,
|
||||
`Steps:`,
|
||||
...params.steps.map((step, index) => `- ${index + 1}. ${step}`),
|
||||
].join('\n');
|
||||
|
||||
export default [
|
||||
generateTest,
|
||||
];
|
||||
@@ -14,11 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types';
|
||||
import type { ImageContent, TextContent } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { z } from 'zod';
|
||||
import type { Context } from '../context';
|
||||
import type { Context } from '../context.js';
|
||||
import type * as playwright from 'playwright';
|
||||
import type { ToolCapability } from '../../config';
|
||||
import type { ToolCapability } from '../../config.js';
|
||||
|
||||
export type ToolSchema<Input extends InputType> = {
|
||||
name: string;
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import type * as playwright from 'playwright';
|
||||
import type { Context } from '../context';
|
||||
import type { Context } from '../context.js';
|
||||
|
||||
export async function waitForCompletion<R>(context: Context, page: playwright.Page, callback: () => Promise<R>): Promise<R> {
|
||||
const requests = new Set<playwright.Request>();
|
||||
@@ -71,5 +71,9 @@ export async function waitForCompletion<R>(context: Context, page: playwright.Pa
|
||||
}
|
||||
|
||||
export function sanitizeForFilePath(s: string) {
|
||||
return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
|
||||
const sanitize = (s: string) => s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
|
||||
const separator = s.lastIndexOf('.');
|
||||
if (separator === -1)
|
||||
return sanitize(s);
|
||||
return sanitize(s.substring(0, separator)) + '.' + sanitize(s.substring(separator + 1));
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import http from 'node:http';
|
||||
import assert from 'node:assert';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
import { ServerList } from './server';
|
||||
import { ServerList } from './server.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
||||
@@ -49,7 +49,10 @@ async function handleSSE(req: http.IncomingMessage, res: http.ServerResponse, ur
|
||||
const server = await serverList.create();
|
||||
res.on('close', () => {
|
||||
sessions.delete(transport.sessionId);
|
||||
serverList.close(server).catch(e => console.error(e));
|
||||
serverList.close(server).catch(e => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
});
|
||||
});
|
||||
return await server.connect(transport);
|
||||
}
|
||||
@@ -113,15 +116,19 @@ export function startHttpTransport(port: number, hostname: string | undefined, s
|
||||
resolvedHost = 'localhost';
|
||||
url = `http://${resolvedHost}:${resolvedPort}`;
|
||||
}
|
||||
console.log(`Listening on ${url}`);
|
||||
console.log('Put this in your client config:');
|
||||
console.log(JSON.stringify({
|
||||
'mcpServers': {
|
||||
'playwright': {
|
||||
'url': `${url}/sse`
|
||||
const message = [
|
||||
`Listening on ${url}`,
|
||||
'Put this in your client config:',
|
||||
JSON.stringify({
|
||||
'mcpServers': {
|
||||
'playwright': {
|
||||
'url': `${url}/sse`
|
||||
}
|
||||
}
|
||||
}
|
||||
}, undefined, 2));
|
||||
console.log('If your client supports streamable HTTP, you can use the /mcp endpoint instead.');
|
||||
}, undefined, 2),
|
||||
'If your client supports streamable HTTP, you can use the /mcp endpoint instead.',
|
||||
].join('\n');
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(message);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('test snapshot tool list', async ({ client }) => {
|
||||
const { tools } = await client.listTools();
|
||||
@@ -23,6 +23,7 @@ test('test snapshot tool list', async ({ client }) => {
|
||||
'browser_console_messages',
|
||||
'browser_drag',
|
||||
'browser_file_upload',
|
||||
'browser_generate_playwright_test',
|
||||
'browser_handle_dialog',
|
||||
'browser_hover',
|
||||
'browser_select_option',
|
||||
@@ -52,6 +53,7 @@ test('test vision tool list', async ({ visionClient }) => {
|
||||
'browser_close',
|
||||
'browser_console_messages',
|
||||
'browser_file_upload',
|
||||
'browser_generate_playwright_test',
|
||||
'browser_handle_dialog',
|
||||
'browser_install',
|
||||
'browser_navigate_back',
|
||||
|
||||
@@ -14,20 +14,20 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('cdp server', async ({ cdpEndpoint, startClient }) => {
|
||||
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
|
||||
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toContainTextContent(`- text: Hello, world!`);
|
||||
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
|
||||
});
|
||||
|
||||
test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
||||
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
|
||||
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_click',
|
||||
@@ -50,7 +50,25 @@ test('cdp server reuse tab', async ({ cdpEndpoint, startClient }) => {
|
||||
- Page Title:
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: hello world
|
||||
- generic [ref=s1e2]: hello world
|
||||
\`\`\`
|
||||
`);
|
||||
});
|
||||
|
||||
test('should throw connection error and allow re-connecting', async ({ cdpEndpoint, startClient }) => {
|
||||
const port = 3200 + test.info().parallelIndex;
|
||||
const client = await startClient({ args: [`--cdp-endpoint=http://localhost:${port}`] });
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toContainTextContent(`Error: browserType.connectOverCDP: connect ECONNREFUSED`);
|
||||
await cdpEndpoint(port);
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('browser_console_messages', async ({ client }) => {
|
||||
await client.callTool({
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('browser_navigate', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
@@ -33,7 +33,7 @@ await page.goto('data:text/html,<html><title>Title</title><body>Hello, world!</b
|
||||
- Page Title: Title
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: Hello, world!
|
||||
- generic [ref=s1e2]: Hello, world!
|
||||
\`\`\`
|
||||
`
|
||||
);
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('browser_take_screenshot (viewport)', async ({ startClient, server }) => {
|
||||
test('--device should work', async ({ startClient, server }) => {
|
||||
const client = await startClient({
|
||||
args: ['--device', 'iPhone 15'],
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
// https://github.com/microsoft/playwright/issues/35663
|
||||
test.skip(({ mcpBrowser, mcpHeadless }) => mcpBrowser === 'webkit' && mcpHeadless);
|
||||
@@ -126,7 +126,7 @@ test('confirm dialog (true)', async ({ client }) => {
|
||||
expect(result).toContainTextContent('// <internal code to handle "confirm" dialog>');
|
||||
expect(result).toContainTextContent(`- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: "true"
|
||||
- generic [ref=s2e2]: "true"
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
@@ -156,7 +156,7 @@ test('confirm dialog (false)', async ({ client }) => {
|
||||
|
||||
expect(result).toContainTextContent(`- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: "false"
|
||||
- generic [ref=s2e2]: "false"
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
@@ -187,6 +187,6 @@ test('prompt dialog', async ({ client }) => {
|
||||
|
||||
expect(result).toContainTextContent(`- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: Answer
|
||||
- generic [ref=s2e2]: Answer
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import { test, expect } from './fixtures.js';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
test('browser_file_upload', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
@@ -25,9 +26,8 @@ test('browser_file_upload', async ({ client }) => {
|
||||
},
|
||||
})).toContainTextContent(`
|
||||
\`\`\`yaml
|
||||
- generic [ref=s1e2]:
|
||||
- button "Choose File" [ref=s1e3]
|
||||
- button "Button" [ref=s1e4]
|
||||
- button "Choose File" [ref=s1e3]
|
||||
- button "Button" [ref=s1e4]
|
||||
\`\`\``);
|
||||
|
||||
{
|
||||
@@ -64,9 +64,8 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
||||
expect(response).not.toContainTextContent('### Modal state');
|
||||
expect(response).toContainTextContent(`
|
||||
\`\`\`yaml
|
||||
- generic [ref=s3e2]:
|
||||
- button "Choose File" [ref=s3e3]
|
||||
- button "Button" [ref=s3e4]
|
||||
- button "Choose File" [ref=s3e3]
|
||||
- button "Button" [ref=s3e4]
|
||||
\`\`\``);
|
||||
}
|
||||
|
||||
@@ -96,3 +95,27 @@ The tool "browser_file_upload" can only be used when there is related modal stat
|
||||
- [File chooser]: can be handled by the "browser_file_upload" tool`);
|
||||
}
|
||||
});
|
||||
|
||||
test('clicking on download link emits download', async ({ startClient }, testInfo) => {
|
||||
const outputDir = testInfo.outputPath('output');
|
||||
const client = await startClient({
|
||||
config: { outputDir },
|
||||
});
|
||||
|
||||
expect(await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
url: 'data:text/html,<a href="data:text/plain,Hello world!" download="test.txt">Download</a>',
|
||||
},
|
||||
})).toContainTextContent('- link "Download" [ref=s1e3]');
|
||||
await client.callTool({
|
||||
name: 'browser_click',
|
||||
arguments: {
|
||||
element: 'Download link',
|
||||
ref: 's1e3',
|
||||
},
|
||||
});
|
||||
await expect.poll(() => client.callTool({ name: 'browser_snapshot', arguments: {} })).toContainTextContent(`
|
||||
### Downloads
|
||||
- Downloaded file test.txt to ${path.join(outputDir, 'test.txt')}`);
|
||||
});
|
||||
|
||||
@@ -15,34 +15,38 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import url from 'url';
|
||||
import path from 'path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
import { test as baseTest, expect as baseExpect } from '@playwright/test';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { spawn } from 'child_process';
|
||||
import { TestServer } from './testserver';
|
||||
import { ChildProcessWithoutNullStreams, spawn } from 'child_process';
|
||||
import { TestServer } from './testserver/index.ts';
|
||||
|
||||
import type { Config } from '../config';
|
||||
|
||||
export type TestOptions = {
|
||||
mcpBrowser: string | undefined;
|
||||
};
|
||||
|
||||
type TestFixtures = {
|
||||
client: Client;
|
||||
visionClient: Client;
|
||||
startClient: (options?: { args?: string[], config?: Config }) => Promise<Client>;
|
||||
wsEndpoint: string;
|
||||
cdpEndpoint: string;
|
||||
cdpEndpoint: (port?: number) => Promise<string>;
|
||||
server: TestServer;
|
||||
httpsServer: TestServer;
|
||||
mcpHeadless: boolean;
|
||||
mcpBrowser: string | undefined;
|
||||
};
|
||||
|
||||
type WorkerFixtures = {
|
||||
_workerServers: { server: TestServer, httpsServer: TestServer };
|
||||
};
|
||||
|
||||
export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
|
||||
export const test = baseTest.extend<TestFixtures & TestOptions, WorkerFixtures>({
|
||||
|
||||
client: async ({ startClient }, use) => {
|
||||
await use(await startClient());
|
||||
@@ -69,9 +73,11 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
|
||||
await fs.promises.writeFile(configFile, JSON.stringify(options.config, null, 2));
|
||||
args.push(`--config=${configFile}`);
|
||||
}
|
||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'node',
|
||||
args: [path.join(__dirname, '../cli.js'), ...args],
|
||||
args: [path.join(path.dirname(__filename), '../cli.js'), ...args],
|
||||
});
|
||||
client = new Client({ name: 'test', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
@@ -89,27 +95,33 @@ export const test = baseTest.extend<TestFixtures, WorkerFixtures>({
|
||||
},
|
||||
|
||||
cdpEndpoint: async ({ }, use, testInfo) => {
|
||||
const port = 3200 + (+process.env.TEST_PARALLEL_INDEX!);
|
||||
const executablePath = chromium.executablePath();
|
||||
const browserProcess = spawn(executablePath, [
|
||||
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--no-first-run`,
|
||||
`--no-sandbox`,
|
||||
`--headless`,
|
||||
'--use-mock-keychain',
|
||||
`data:text/html,hello world`,
|
||||
], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
await new Promise<void>(resolve => {
|
||||
browserProcess.stderr.on('data', data => {
|
||||
if (data.toString().includes('DevTools listening on '))
|
||||
resolve();
|
||||
let browserProcess: ChildProcessWithoutNullStreams | undefined;
|
||||
|
||||
await use(async port => {
|
||||
if (!port)
|
||||
port = 3200 + test.info().parallelIndex;
|
||||
if (browserProcess)
|
||||
return `http://localhost:${port}`;
|
||||
browserProcess = spawn(chromium.executablePath(), [
|
||||
`--user-data-dir=${testInfo.outputPath('user-data-dir')}`,
|
||||
`--remote-debugging-port=${port}`,
|
||||
`--no-first-run`,
|
||||
`--no-sandbox`,
|
||||
`--headless`,
|
||||
'--use-mock-keychain',
|
||||
`data:text/html,hello world`,
|
||||
], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
await new Promise<void>(resolve => {
|
||||
browserProcess!.stderr.on('data', data => {
|
||||
if (data.toString().includes('DevTools listening on '))
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
return `http://localhost:${port}`;
|
||||
});
|
||||
await use(`http://localhost:${port}`);
|
||||
browserProcess.kill();
|
||||
browserProcess?.kill();
|
||||
},
|
||||
|
||||
mcpHeadless: async ({ headless }, use) => {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
for (const mcpHeadless of [false, true]) {
|
||||
test.describe(`mcpHeadless: ${mcpHeadless}`, () => {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('stitched aria frames', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
@@ -24,14 +24,12 @@ test('stitched aria frames', async ({ client }) => {
|
||||
},
|
||||
})).toContainTextContent(`
|
||||
\`\`\`yaml
|
||||
- generic [ref=s1e2]:
|
||||
- heading "Hello" [level=1] [ref=s1e3]
|
||||
- iframe [ref=s1e4]:
|
||||
- generic [ref=f1s1e2]:
|
||||
- button "World" [ref=f1s1e3]
|
||||
- main [ref=f1s1e4]:
|
||||
- iframe [ref=f1s1e5]:
|
||||
- paragraph [ref=f2s1e3]: Nested
|
||||
- heading "Hello" [level=1] [ref=s1e3]
|
||||
- iframe [ref=s1e4]:
|
||||
- button "World" [ref=f1s1e3]
|
||||
- main [ref=f1s1e4]:
|
||||
- iframe [ref=f1s1e5]:
|
||||
- paragraph [ref=f2s1e3]: Nested
|
||||
\`\`\``);
|
||||
|
||||
expect(await client.callTool({
|
||||
|
||||
25
tests/install.spec.ts
Normal file
25
tests/install.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('browser_install', async ({ client, mcpBrowser }) => {
|
||||
test.skip(mcpBrowser !== 'chromium', 'Test only chromium');
|
||||
expect(await client.callTool({
|
||||
name: 'browser_install',
|
||||
arguments: {},
|
||||
})).toContainTextContent(`No open pages available.`);
|
||||
});
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('test reopen browser', async ({ client }) => {
|
||||
await client.callTool({
|
||||
@@ -34,7 +34,7 @@ test('test reopen browser', async ({ client }) => {
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toContainTextContent(`- text: Hello, world!`);
|
||||
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
|
||||
});
|
||||
|
||||
test('executable path', async ({ startClient }) => {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('browser_network_requests', async ({ client, server }) => {
|
||||
server.route('/', (req, res) => {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('save as pdf unavailable', async ({ startClient }) => {
|
||||
const client = await startClient({ args: ['--caps="no-pdf"'] });
|
||||
@@ -37,7 +37,7 @@ test('save as pdf', async ({ client, mcpBrowser }) => {
|
||||
arguments: {
|
||||
url: 'data:text/html,<html><title>Title</title><body>Hello, world!</body></html>',
|
||||
},
|
||||
})).toContainTextContent(`- text: Hello, world!`);
|
||||
})).toContainTextContent(`- generic [ref=s1e2]: Hello, world!`);
|
||||
|
||||
const response = await client.callTool({
|
||||
name: 'browser_pdf_save',
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import fs from 'fs';
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('browser_take_screenshot (viewport)', async ({ client }) => {
|
||||
expect(await client.callTool({
|
||||
|
||||
@@ -14,14 +14,18 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import url from 'node:url';
|
||||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
import { test as baseTest } from './fixtures';
|
||||
import { test as baseTest } from './fixtures.js';
|
||||
import { expect } from 'playwright/test';
|
||||
|
||||
// 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 test = baseTest.extend<{ serverEndpoint: string }>({
|
||||
serverEndpoint: async ({}, use) => {
|
||||
const cp = spawn('node', [path.join(__dirname, '../cli.js'), '--port', '0'], { stdio: 'pipe' });
|
||||
const cp = spawn('node', [path.join(path.dirname(__filename), '../cli.js'), '--port', '0'], { stdio: 'pipe' });
|
||||
try {
|
||||
let stdout = '';
|
||||
const url = await new Promise<string>(resolve => cp.stdout?.on('data', data => {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
|
||||
@@ -63,7 +63,7 @@ test('create new tab', async ({ client }) => {
|
||||
- Page Title: Tab one
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: Body one
|
||||
- generic [ref=s1e2]: Body one
|
||||
\`\`\``);
|
||||
|
||||
expect(await createTab(client, 'Tab two', 'Body two')).toHaveTextContent(`
|
||||
@@ -82,7 +82,7 @@ test('create new tab', async ({ client }) => {
|
||||
- Page Title: Tab two
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: Body two
|
||||
- generic [ref=s1e2]: Body two
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
@@ -110,7 +110,7 @@ test('select tab', async ({ client }) => {
|
||||
- Page Title: Tab one
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: Body one
|
||||
- generic [ref=s2e2]: Body one
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
@@ -137,16 +137,16 @@ test('close tab', async ({ client }) => {
|
||||
- Page Title: Tab one
|
||||
- Page Snapshot
|
||||
\`\`\`yaml
|
||||
- text: Body one
|
||||
- generic [ref=s2e2]: Body one
|
||||
\`\`\``);
|
||||
});
|
||||
|
||||
test('reuse first tab when navigating', async ({ startClient, cdpEndpoint }) => {
|
||||
const browser = await chromium.connectOverCDP(cdpEndpoint);
|
||||
const browser = await chromium.connectOverCDP(await cdpEndpoint());
|
||||
const [context] = browser.contexts();
|
||||
const pages = context.pages();
|
||||
|
||||
const client = await startClient({ args: [`--cdp-endpoint=${cdpEndpoint}`] });
|
||||
const client = await startClient({ args: [`--cdp-endpoint=${await cdpEndpoint()}`] });
|
||||
await client.callTool({
|
||||
name: 'browser_navigate',
|
||||
arguments: {
|
||||
|
||||
@@ -16,13 +16,18 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import url from 'node:url';
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import path from 'path';
|
||||
import debug from 'debug';
|
||||
|
||||
const fulfillSymbol = Symbol('fulfil callback');
|
||||
const rejectSymbol = Symbol('reject callback');
|
||||
|
||||
// NOTE: Can be removed when we drop Node.js 18 support and changed to import.meta.filename.
|
||||
const __filename = url.fileURLToPath(import.meta.url);
|
||||
|
||||
export class TestServer {
|
||||
private _server: http.Server;
|
||||
readonly debugServer: any;
|
||||
@@ -42,8 +47,8 @@ export class TestServer {
|
||||
|
||||
static async createHTTPS(port: number): Promise<TestServer> {
|
||||
const server = new TestServer(port, {
|
||||
key: await fs.promises.readFile(path.join(__dirname, 'key.pem')),
|
||||
cert: await fs.promises.readFile(path.join(__dirname, 'cert.pem')),
|
||||
key: await fs.promises.readFile(path.join(path.dirname(__filename), 'key.pem')),
|
||||
cert: await fs.promises.readFile(path.join(path.dirname(__filename), 'cert.pem')),
|
||||
passphrase: 'aaaa',
|
||||
});
|
||||
await new Promise(x => server._server.once('listening', x));
|
||||
@@ -56,7 +61,7 @@ export class TestServer {
|
||||
else
|
||||
this._server = http.createServer(this._onRequest.bind(this));
|
||||
this._server.listen(port);
|
||||
this.debugServer = require('debug')('pw:testserver');
|
||||
this.debugServer = debug('pw:testserver');
|
||||
|
||||
const cross_origin = '127.0.0.1';
|
||||
const same_origin = 'localhost';
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures';
|
||||
import { test, expect } from './fixtures.js';
|
||||
|
||||
test('do not falsely advertise user agent as a test driver', async ({ client, server, mcpBrowser }) => {
|
||||
test.skip(mcpBrowser === 'firefox');
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
"target": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "nodenext",
|
||||
"strict": true,
|
||||
"module": "CommonJS",
|
||||
"outDir": "./lib"
|
||||
"module": "NodeNext",
|
||||
"rootDir": "src",
|
||||
"outDir": "./lib",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
|
||||
@@ -16,21 +16,23 @@
|
||||
*/
|
||||
// @ts-check
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const zodToJsonSchema = require('zod-to-json-schema').default;
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import url from 'node:url'
|
||||
import zodToJsonSchema from 'zod-to-json-schema'
|
||||
|
||||
const commonTools = require('../lib/tools/common').default;
|
||||
const consoleTools = require('../lib/tools/console').default;
|
||||
const dialogsTools = require('../lib/tools/dialogs').default;
|
||||
const filesTools = require('../lib/tools/files').default;
|
||||
const installTools = require('../lib/tools/install').default;
|
||||
const keyboardTools = require('../lib/tools/keyboard').default;
|
||||
const navigateTools = require('../lib/tools/navigate').default;
|
||||
const pdfTools = require('../lib/tools/pdf').default;
|
||||
const snapshotTools = require('../lib/tools/snapshot').default;
|
||||
const tabsTools = require('../lib/tools/tabs').default;
|
||||
const screenTools = require('../lib/tools/screen').default;
|
||||
import commonTools from '../lib/tools/common.js';
|
||||
import consoleTools from '../lib/tools/console.js';
|
||||
import dialogsTools from '../lib/tools/dialogs.js';
|
||||
import filesTools from '../lib/tools/files.js';
|
||||
import installTools from '../lib/tools/install.js';
|
||||
import keyboardTools from '../lib/tools/keyboard.js';
|
||||
import navigateTools from '../lib/tools/navigate.js';
|
||||
import pdfTools from '../lib/tools/pdf.js';
|
||||
import snapshotTools from '../lib/tools/snapshot.js';
|
||||
import tabsTools from '../lib/tools/tabs.js';
|
||||
import screenTools from '../lib/tools/screen.js';
|
||||
import testTools from '../lib/tools/testing.js';
|
||||
|
||||
// Category definitions for tools
|
||||
const categories = {
|
||||
@@ -61,8 +63,14 @@ const categories = {
|
||||
...installTools,
|
||||
...dialogsTools(true),
|
||||
],
|
||||
'Testing': [
|
||||
...testTools,
|
||||
],
|
||||
};
|
||||
|
||||
// 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 kStartMarker = `<!--- Generated by ${path.basename(__filename)} -->`;
|
||||
const kEndMarker = `<!--- End of generated section -->`;
|
||||
|
||||
@@ -152,7 +160,7 @@ async function updateReadme() {
|
||||
}
|
||||
}
|
||||
|
||||
const readmePath = path.join(__dirname, '..', 'README.md');
|
||||
const readmePath = path.join(path.dirname(__filename), '..', 'README.md');
|
||||
const readmeContent = await fs.promises.readFile(readmePath, 'utf-8');
|
||||
const startMarker = readmeContent.indexOf(kStartMarker);
|
||||
const endMarker = readmeContent.indexOf(kEndMarker);
|
||||
|
||||
Reference in New Issue
Block a user