@playwright/mcp 0.0.32 → 0.0.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -3
- package/lib/browserContextFactory.js +78 -60
- package/lib/browserServerBackend.js +47 -21
- package/lib/config.js +8 -8
- package/lib/context.js +76 -28
- package/lib/extension/cdpRelay.js +29 -50
- package/lib/extension/extensionContextFactory.js +56 -0
- package/lib/index.js +3 -1
- package/lib/loopTools/context.js +1 -1
- package/lib/loopTools/main.js +7 -5
- package/lib/mcp/proxyBackend.js +106 -0
- package/lib/mcp/server.js +32 -24
- package/lib/mcp/tool.js +29 -0
- package/lib/mcp/transport.js +1 -1
- package/lib/program.js +30 -21
- package/lib/response.js +81 -14
- package/lib/sessionLog.js +85 -34
- package/lib/tab.js +55 -52
- package/lib/tools/common.js +0 -1
- package/lib/tools/evaluate.js +1 -1
- package/lib/tools/files.js +0 -1
- package/lib/tools/keyboard.js +1 -3
- package/lib/tools/navigate.js +0 -3
- package/lib/tools/pdf.js +2 -4
- package/lib/tools/screenshot.js +13 -10
- package/lib/tools/snapshot.js +3 -8
- package/lib/tools/tool.js +5 -4
- package/lib/tools/utils.js +0 -7
- package/lib/tools/wait.js +3 -4
- package/lib/{javascript.js → utils/codegen.js} +1 -1
- package/lib/{fileUtils.js → utils/fileUtils.js} +6 -2
- package/lib/utils/guid.js +22 -0
- package/lib/{package.js → utils/package.js} +1 -1
- package/package.json +9 -9
- package/lib/extension/main.js +0 -33
- /package/lib/{httpServer.js → utils/httpServer.js} +0 -0
- /package/lib/{log.js → utils/log.js} +0 -0
- /package/lib/{manualPromise.js → utils/manualPromise.js} +0 -0
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ Follow the MCP install [guide](https://modelcontextprotocol.io/quickstart/user),
|
|
|
61
61
|
|
|
62
62
|
#### Click the button to install:
|
|
63
63
|
|
|
64
|
-
[](
|
|
64
|
+
[](cursor://anysphere.cursor-deeplink/mcp/install?name=Playwright&config=eyJjb21tYW5kIjoibnB4IEBwbGF5d3JpZ2h0L21jcEBsYXRlc3QifQ%3D%3D)
|
|
65
65
|
|
|
66
66
|
#### Or install manually:
|
|
67
67
|
|
|
@@ -100,6 +100,29 @@ Go to `Advanced settings` -> `Extensions` -> `Add custom extension`. Name to you
|
|
|
100
100
|
Go to `Program` in the right sidebar -> `Install` -> `Edit mcp.json`. Use the standard config above.
|
|
101
101
|
</details>
|
|
102
102
|
|
|
103
|
+
<details>
|
|
104
|
+
<summary>opencode</summary>
|
|
105
|
+
|
|
106
|
+
Follow the MCP Servers [documentation](https://opencode.ai/docs/mcp-servers/). For example in `~/.config/opencode/opencode.json`:
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{
|
|
110
|
+
"$schema": "https://opencode.ai/config.json",
|
|
111
|
+
"mcp": {
|
|
112
|
+
"playwright": {
|
|
113
|
+
"type": "local",
|
|
114
|
+
"command": [
|
|
115
|
+
"npx",
|
|
116
|
+
"@playwright/mcp@latest"
|
|
117
|
+
],
|
|
118
|
+
"enabled": true
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
</details>
|
|
125
|
+
|
|
103
126
|
<details>
|
|
104
127
|
<summary>Qodo Gen</summary>
|
|
105
128
|
|
|
@@ -158,6 +181,9 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|
|
158
181
|
--config <path> path to the configuration file.
|
|
159
182
|
--device <device> device to emulate, for example: "iPhone 15"
|
|
160
183
|
--executable-path <path> path to the browser executable.
|
|
184
|
+
--extension Connect to a running browser instance
|
|
185
|
+
(Edge/Chrome only). Requires the "Playwright MCP
|
|
186
|
+
Bridge" browser extension to be installed.
|
|
161
187
|
--headless run browser in headless mode, headed by default
|
|
162
188
|
--host <host> host to bind server to. Default is localhost. Use
|
|
163
189
|
0.0.0.0 to bind to all interfaces.
|
|
@@ -191,7 +217,7 @@ Playwright MCP server supports following arguments. They can be provided in the
|
|
|
191
217
|
|
|
192
218
|
### User profile
|
|
193
219
|
|
|
194
|
-
You can run Playwright MCP with persistent profile like a regular browser (default),
|
|
220
|
+
You can run Playwright MCP with persistent profile like a regular browser (default), in isolated contexts for testing sessions, or connect to your existing browser using the browser extension.
|
|
195
221
|
|
|
196
222
|
**Persistent profile**
|
|
197
223
|
|
|
@@ -231,6 +257,10 @@ state [here](https://playwright.dev/docs/auth).
|
|
|
231
257
|
}
|
|
232
258
|
```
|
|
233
259
|
|
|
260
|
+
**Browser Extension**
|
|
261
|
+
|
|
262
|
+
The Playwright MCP Chrome Extension allows you to connect to existing browser tabs and leverage your logged-in sessions and browser state. See [extension/README.md](extension/README.md) for installation and setup instructions.
|
|
263
|
+
|
|
234
264
|
### Configuration file
|
|
235
265
|
|
|
236
266
|
The Playwright MCP server can be configured using a JSON configuration file. You can specify the configuration file
|
|
@@ -544,7 +574,7 @@ http.createServer(async (req, res) => {
|
|
|
544
574
|
- Title: Take a screenshot
|
|
545
575
|
- Description: Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.
|
|
546
576
|
- Parameters:
|
|
547
|
-
- `
|
|
577
|
+
- `type` (string, optional): Image format for the screenshot. Default is png.
|
|
548
578
|
- `filename` (string, optional): File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.
|
|
549
579
|
- `element` (string, optional): Human-readable element description used to obtain permission to screenshot the element. If not provided, the screenshot will be taken of viewport. If element is provided, ref must be provided too.
|
|
550
580
|
- `ref` (string, optional): Exact target element reference from the page snapshot. If not provided, the screenshot will be taken of viewport. If ref is provided, element must be provided too.
|
|
@@ -13,34 +13,41 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
|
-
import fs from '
|
|
17
|
-
import net from '
|
|
18
|
-
import path from '
|
|
19
|
-
import os from 'node:os';
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import net from 'net';
|
|
18
|
+
import path from 'path';
|
|
20
19
|
import * as playwright from 'playwright';
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
// @ts-ignore
|
|
21
|
+
import { registryDirectory } from 'playwright-core/lib/server/registry/index';
|
|
22
|
+
// @ts-ignore
|
|
23
|
+
import { startTraceViewerServer } from 'playwright-core/lib/server';
|
|
24
|
+
import { logUnhandledError, testDebug } from './utils/log.js';
|
|
25
|
+
import { createHash } from './utils/guid.js';
|
|
26
|
+
import { outputFile } from './config.js';
|
|
27
|
+
export function contextFactory(config) {
|
|
28
|
+
if (config.browser.remoteEndpoint)
|
|
29
|
+
return new RemoteContextFactory(config);
|
|
30
|
+
if (config.browser.cdpEndpoint)
|
|
31
|
+
return new CdpContextFactory(config);
|
|
32
|
+
if (config.browser.isolated)
|
|
33
|
+
return new IsolatedContextFactory(config);
|
|
34
|
+
return new PersistentContextFactory(config);
|
|
30
35
|
}
|
|
31
36
|
class BaseContextFactory {
|
|
32
|
-
browserConfig;
|
|
33
|
-
_browserPromise;
|
|
34
37
|
name;
|
|
35
|
-
|
|
38
|
+
description;
|
|
39
|
+
config;
|
|
40
|
+
_browserPromise;
|
|
41
|
+
constructor(name, description, config) {
|
|
36
42
|
this.name = name;
|
|
37
|
-
this.
|
|
43
|
+
this.description = description;
|
|
44
|
+
this.config = config;
|
|
38
45
|
}
|
|
39
|
-
async _obtainBrowser() {
|
|
46
|
+
async _obtainBrowser(clientInfo) {
|
|
40
47
|
if (this._browserPromise)
|
|
41
48
|
return this._browserPromise;
|
|
42
49
|
testDebug(`obtain browser (${this.name})`);
|
|
43
|
-
this._browserPromise = this._doObtainBrowser();
|
|
50
|
+
this._browserPromise = this._doObtainBrowser(clientInfo);
|
|
44
51
|
void this._browserPromise.then(browser => {
|
|
45
52
|
browser.on('disconnected', () => {
|
|
46
53
|
this._browserPromise = undefined;
|
|
@@ -50,12 +57,12 @@ class BaseContextFactory {
|
|
|
50
57
|
});
|
|
51
58
|
return this._browserPromise;
|
|
52
59
|
}
|
|
53
|
-
async _doObtainBrowser() {
|
|
60
|
+
async _doObtainBrowser(clientInfo) {
|
|
54
61
|
throw new Error('Not implemented');
|
|
55
62
|
}
|
|
56
|
-
async createContext() {
|
|
63
|
+
async createContext(clientInfo) {
|
|
57
64
|
testDebug(`create browser context (${this.name})`);
|
|
58
|
-
const browser = await this._obtainBrowser();
|
|
65
|
+
const browser = await this._obtainBrowser(clientInfo);
|
|
59
66
|
const browserContext = await this._doCreateContext(browser);
|
|
60
67
|
return { browserContext, close: () => this._closeBrowserContext(browserContext, browser) };
|
|
61
68
|
}
|
|
@@ -74,14 +81,15 @@ class BaseContextFactory {
|
|
|
74
81
|
}
|
|
75
82
|
}
|
|
76
83
|
class IsolatedContextFactory extends BaseContextFactory {
|
|
77
|
-
constructor(
|
|
78
|
-
super('isolated',
|
|
84
|
+
constructor(config) {
|
|
85
|
+
super('isolated', 'Create a new isolated browser context', config);
|
|
79
86
|
}
|
|
80
|
-
async _doObtainBrowser() {
|
|
81
|
-
await injectCdpPort(this.
|
|
82
|
-
const browserType = playwright[this.
|
|
87
|
+
async _doObtainBrowser(clientInfo) {
|
|
88
|
+
await injectCdpPort(this.config.browser);
|
|
89
|
+
const browserType = playwright[this.config.browser.browserName];
|
|
83
90
|
return browserType.launch({
|
|
84
|
-
|
|
91
|
+
tracesDir: await startTraceServer(this.config, clientInfo.rootPath),
|
|
92
|
+
...this.config.browser.launchOptions,
|
|
85
93
|
handleSIGINT: false,
|
|
86
94
|
handleSIGTERM: false,
|
|
87
95
|
}).catch(error => {
|
|
@@ -91,53 +99,57 @@ class IsolatedContextFactory extends BaseContextFactory {
|
|
|
91
99
|
});
|
|
92
100
|
}
|
|
93
101
|
async _doCreateContext(browser) {
|
|
94
|
-
return browser.newContext(this.
|
|
102
|
+
return browser.newContext(this.config.browser.contextOptions);
|
|
95
103
|
}
|
|
96
104
|
}
|
|
97
105
|
class CdpContextFactory extends BaseContextFactory {
|
|
98
|
-
constructor(
|
|
99
|
-
super('cdp',
|
|
106
|
+
constructor(config) {
|
|
107
|
+
super('cdp', 'Connect to a browser over CDP', config);
|
|
100
108
|
}
|
|
101
109
|
async _doObtainBrowser() {
|
|
102
|
-
return playwright.chromium.connectOverCDP(this.
|
|
110
|
+
return playwright.chromium.connectOverCDP(this.config.browser.cdpEndpoint);
|
|
103
111
|
}
|
|
104
112
|
async _doCreateContext(browser) {
|
|
105
|
-
return this.
|
|
113
|
+
return this.config.browser.isolated ? await browser.newContext() : browser.contexts()[0];
|
|
106
114
|
}
|
|
107
115
|
}
|
|
108
116
|
class RemoteContextFactory extends BaseContextFactory {
|
|
109
|
-
constructor(
|
|
110
|
-
super('remote',
|
|
117
|
+
constructor(config) {
|
|
118
|
+
super('remote', 'Connect to a browser using a remote endpoint', config);
|
|
111
119
|
}
|
|
112
120
|
async _doObtainBrowser() {
|
|
113
|
-
const url = new URL(this.
|
|
114
|
-
url.searchParams.set('browser', this.
|
|
115
|
-
if (this.
|
|
116
|
-
url.searchParams.set('launch-options', JSON.stringify(this.
|
|
117
|
-
return playwright[this.
|
|
121
|
+
const url = new URL(this.config.browser.remoteEndpoint);
|
|
122
|
+
url.searchParams.set('browser', this.config.browser.browserName);
|
|
123
|
+
if (this.config.browser.launchOptions)
|
|
124
|
+
url.searchParams.set('launch-options', JSON.stringify(this.config.browser.launchOptions));
|
|
125
|
+
return playwright[this.config.browser.browserName].connect(String(url));
|
|
118
126
|
}
|
|
119
127
|
async _doCreateContext(browser) {
|
|
120
128
|
return browser.newContext();
|
|
121
129
|
}
|
|
122
130
|
}
|
|
123
131
|
class PersistentContextFactory {
|
|
124
|
-
|
|
132
|
+
config;
|
|
133
|
+
name = 'persistent';
|
|
134
|
+
description = 'Create a new persistent browser context';
|
|
125
135
|
_userDataDirs = new Set();
|
|
126
|
-
constructor(
|
|
127
|
-
this.
|
|
136
|
+
constructor(config) {
|
|
137
|
+
this.config = config;
|
|
128
138
|
}
|
|
129
|
-
async createContext() {
|
|
130
|
-
await injectCdpPort(this.
|
|
139
|
+
async createContext(clientInfo) {
|
|
140
|
+
await injectCdpPort(this.config.browser);
|
|
131
141
|
testDebug('create browser context (persistent)');
|
|
132
|
-
const userDataDir = this.
|
|
142
|
+
const userDataDir = this.config.browser.userDataDir ?? await this._createUserDataDir(clientInfo.rootPath);
|
|
143
|
+
const tracesDir = await startTraceServer(this.config, clientInfo.rootPath);
|
|
133
144
|
this._userDataDirs.add(userDataDir);
|
|
134
145
|
testDebug('lock user data dir', userDataDir);
|
|
135
|
-
const browserType = playwright[this.
|
|
146
|
+
const browserType = playwright[this.config.browser.browserName];
|
|
136
147
|
for (let i = 0; i < 5; i++) {
|
|
137
148
|
try {
|
|
138
149
|
const browserContext = await browserType.launchPersistentContext(userDataDir, {
|
|
139
|
-
|
|
140
|
-
...this.
|
|
150
|
+
tracesDir,
|
|
151
|
+
...this.config.browser.launchOptions,
|
|
152
|
+
...this.config.browser.contextOptions,
|
|
141
153
|
handleSIGINT: false,
|
|
142
154
|
handleSIGTERM: false,
|
|
143
155
|
});
|
|
@@ -164,17 +176,12 @@ class PersistentContextFactory {
|
|
|
164
176
|
this._userDataDirs.delete(userDataDir);
|
|
165
177
|
testDebug('close browser context complete (persistent)');
|
|
166
178
|
}
|
|
167
|
-
async _createUserDataDir() {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
else if (process.platform === 'win32')
|
|
174
|
-
cacheDirectory = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
175
|
-
else
|
|
176
|
-
throw new Error('Unsupported platform: ' + process.platform);
|
|
177
|
-
const result = path.join(cacheDirectory, 'ms-playwright', `mcp-${this.browserConfig.launchOptions?.channel ?? this.browserConfig?.browserName}-profile`);
|
|
179
|
+
async _createUserDataDir(rootPath) {
|
|
180
|
+
const dir = process.env.PWMCP_PROFILES_DIR_FOR_TEST ?? registryDirectory;
|
|
181
|
+
const browserToken = this.config.browser.launchOptions?.channel ?? this.config.browser?.browserName;
|
|
182
|
+
// Hesitant putting hundreds of files into the user's workspace, so using it for hashing instead.
|
|
183
|
+
const rootPathToken = rootPath ? `-${createHash(rootPath)}` : '';
|
|
184
|
+
const result = path.join(dir, `mcp-${browserToken}${rootPathToken}`);
|
|
178
185
|
await fs.promises.mkdir(result, { recursive: true });
|
|
179
186
|
return result;
|
|
180
187
|
}
|
|
@@ -193,3 +200,14 @@ async function findFreePort() {
|
|
|
193
200
|
server.on('error', reject);
|
|
194
201
|
});
|
|
195
202
|
}
|
|
203
|
+
async function startTraceServer(config, rootPath) {
|
|
204
|
+
if (!config.saveTrace)
|
|
205
|
+
return undefined;
|
|
206
|
+
const tracesDir = await outputFile(config, rootPath, `traces-${Date.now()}`);
|
|
207
|
+
const server = await startTraceViewerServer();
|
|
208
|
+
const urlPrefix = server.urlPrefix('human-readable');
|
|
209
|
+
const url = urlPrefix + '/trace/index.html?trace=' + tracesDir + '/trace.json';
|
|
210
|
+
// eslint-disable-next-line no-console
|
|
211
|
+
console.error('\nTrace viewer listening on ' + url);
|
|
212
|
+
return tracesDir;
|
|
213
|
+
}
|
|
@@ -13,42 +13,68 @@
|
|
|
13
13
|
* See the License for the specific language governing permissions and
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
16
17
|
import { Context } from './context.js';
|
|
17
|
-
import { logUnhandledError } from './log.js';
|
|
18
|
+
import { logUnhandledError } from './utils/log.js';
|
|
18
19
|
import { Response } from './response.js';
|
|
19
20
|
import { SessionLog } from './sessionLog.js';
|
|
20
21
|
import { filteredTools } from './tools.js';
|
|
21
|
-
import { packageJSON } from './package.js';
|
|
22
|
+
import { packageJSON } from './utils/package.js';
|
|
23
|
+
import { toMcpTool } from './mcp/tool.js';
|
|
22
24
|
export class BrowserServerBackend {
|
|
23
25
|
name = 'Playwright';
|
|
24
26
|
version = packageJSON.version;
|
|
25
|
-
onclose;
|
|
26
27
|
_tools;
|
|
27
28
|
_context;
|
|
28
29
|
_sessionLog;
|
|
29
|
-
|
|
30
|
+
_config;
|
|
31
|
+
_browserContextFactory;
|
|
32
|
+
constructor(config, factory) {
|
|
33
|
+
this._config = config;
|
|
34
|
+
this._browserContextFactory = factory;
|
|
30
35
|
this._tools = filteredTools(config);
|
|
31
|
-
this._context = new Context(this._tools, config, browserContextFactory);
|
|
32
36
|
}
|
|
33
|
-
async initialize() {
|
|
34
|
-
|
|
37
|
+
async initialize(clientVersion, roots) {
|
|
38
|
+
let rootPath;
|
|
39
|
+
if (roots.length > 0) {
|
|
40
|
+
const firstRootUri = roots[0]?.uri;
|
|
41
|
+
const url = firstRootUri ? new URL(firstRootUri) : undefined;
|
|
42
|
+
rootPath = url ? fileURLToPath(url) : undefined;
|
|
43
|
+
}
|
|
44
|
+
this._sessionLog = this._config.saveSession ? await SessionLog.create(this._config, rootPath) : undefined;
|
|
45
|
+
this._context = new Context({
|
|
46
|
+
tools: this._tools,
|
|
47
|
+
config: this._config,
|
|
48
|
+
browserContextFactory: this._browserContextFactory,
|
|
49
|
+
sessionLog: this._sessionLog,
|
|
50
|
+
clientInfo: { ...clientVersion, rootPath },
|
|
51
|
+
});
|
|
35
52
|
}
|
|
36
|
-
|
|
37
|
-
return this._tools.map(tool => tool.schema);
|
|
53
|
+
async listTools() {
|
|
54
|
+
return this._tools.map(tool => toMcpTool(tool.schema));
|
|
38
55
|
}
|
|
39
|
-
async callTool(
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
async callTool(name, rawArguments) {
|
|
57
|
+
const tool = this._tools.find(tool => tool.schema.name === name);
|
|
58
|
+
if (!tool)
|
|
59
|
+
throw new Error(`Tool "${name}" not found`);
|
|
60
|
+
const parsedArguments = tool.schema.inputSchema.parse(rawArguments || {});
|
|
61
|
+
const context = this._context;
|
|
62
|
+
const response = new Response(context, name, parsedArguments);
|
|
63
|
+
context.setRunningTool(true);
|
|
64
|
+
try {
|
|
65
|
+
await tool.handle(context, parsedArguments, response);
|
|
66
|
+
await response.finish();
|
|
67
|
+
this._sessionLog?.logResponse(response);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
response.addError(String(error));
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
context.setRunningTool(false);
|
|
74
|
+
}
|
|
75
|
+
return response.serialize();
|
|
49
76
|
}
|
|
50
77
|
serverClosed() {
|
|
51
|
-
this.
|
|
52
|
-
void this._context.dispose().catch(logUnhandledError);
|
|
78
|
+
void this._context?.dispose().catch(logUnhandledError);
|
|
53
79
|
}
|
|
54
80
|
}
|
package/lib/config.js
CHANGED
|
@@ -17,7 +17,7 @@ import fs from 'fs';
|
|
|
17
17
|
import os from 'os';
|
|
18
18
|
import path from 'path';
|
|
19
19
|
import { devices } from 'playwright';
|
|
20
|
-
import { sanitizeForFilePath } from './
|
|
20
|
+
import { sanitizeForFilePath } from './utils/fileUtils.js';
|
|
21
21
|
const defaultConfig = {
|
|
22
22
|
browser: {
|
|
23
23
|
browserName: 'chromium',
|
|
@@ -35,7 +35,7 @@ const defaultConfig = {
|
|
|
35
35
|
blockedOrigins: undefined,
|
|
36
36
|
},
|
|
37
37
|
server: {},
|
|
38
|
-
|
|
38
|
+
saveTrace: false,
|
|
39
39
|
};
|
|
40
40
|
export async function resolveConfig(config) {
|
|
41
41
|
return mergeConfig(defaultConfig, config);
|
|
@@ -48,9 +48,6 @@ export async function resolveCLIConfig(cliOptions) {
|
|
|
48
48
|
result = mergeConfig(result, configInFile);
|
|
49
49
|
result = mergeConfig(result, envOverrides);
|
|
50
50
|
result = mergeConfig(result, cliOverrides);
|
|
51
|
-
// Derive artifact output directory from config.outputDir
|
|
52
|
-
if (result.saveTrace)
|
|
53
|
-
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
|
|
54
51
|
return result;
|
|
55
52
|
}
|
|
56
53
|
export function configFromCLIOptions(cliOptions) {
|
|
@@ -179,10 +176,13 @@ async function loadConfig(configFile) {
|
|
|
179
176
|
throw new Error(`Failed to load config file: ${configFile}, ${error}`);
|
|
180
177
|
}
|
|
181
178
|
}
|
|
182
|
-
export async function outputFile(config, name) {
|
|
183
|
-
|
|
179
|
+
export async function outputFile(config, rootPath, name) {
|
|
180
|
+
const outputDir = config.outputDir
|
|
181
|
+
?? (rootPath ? path.join(rootPath, '.playwright-mcp') : undefined)
|
|
182
|
+
?? path.join(os.tmpdir(), 'playwright-mcp-output', sanitizeForFilePath(new Date().toISOString()));
|
|
183
|
+
await fs.promises.mkdir(outputDir, { recursive: true });
|
|
184
184
|
const fileName = sanitizeForFilePath(name);
|
|
185
|
-
return path.join(
|
|
185
|
+
return path.join(outputDir, fileName);
|
|
186
186
|
}
|
|
187
187
|
function pickDefined(obj) {
|
|
188
188
|
return Object.fromEntries(Object.entries(obj ?? {}).filter(([_, v]) => v !== undefined));
|
package/lib/context.js
CHANGED
|
@@ -14,23 +14,31 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
import debug from 'debug';
|
|
17
|
-
import { logUnhandledError } from './log.js';
|
|
17
|
+
import { logUnhandledError } from './utils/log.js';
|
|
18
18
|
import { Tab } from './tab.js';
|
|
19
|
+
import { outputFile } from './config.js';
|
|
19
20
|
const testDebug = debug('pw:mcp:test');
|
|
20
21
|
export class Context {
|
|
21
22
|
tools;
|
|
22
23
|
config;
|
|
24
|
+
sessionLog;
|
|
25
|
+
options;
|
|
23
26
|
_browserContextPromise;
|
|
24
27
|
_browserContextFactory;
|
|
25
28
|
_tabs = [];
|
|
26
29
|
_currentTab;
|
|
27
|
-
|
|
30
|
+
_clientInfo;
|
|
28
31
|
static _allContexts = new Set();
|
|
29
32
|
_closeBrowserContextPromise;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
this.
|
|
33
|
+
_isRunningTool = false;
|
|
34
|
+
_abortController = new AbortController();
|
|
35
|
+
constructor(options) {
|
|
36
|
+
this.tools = options.tools;
|
|
37
|
+
this.config = options.config;
|
|
38
|
+
this.sessionLog = options.sessionLog;
|
|
39
|
+
this.options = options;
|
|
40
|
+
this._browserContextFactory = options.browserContextFactory;
|
|
41
|
+
this._clientInfo = options.clientInfo;
|
|
34
42
|
testDebug('create context');
|
|
35
43
|
Context._allContexts.add(this);
|
|
36
44
|
}
|
|
@@ -68,27 +76,6 @@ export class Context {
|
|
|
68
76
|
await browserContext.newPage();
|
|
69
77
|
return this._currentTab;
|
|
70
78
|
}
|
|
71
|
-
async listTabsMarkdown(force = false) {
|
|
72
|
-
if (this._tabs.length === 1 && !force)
|
|
73
|
-
return [];
|
|
74
|
-
if (!this._tabs.length) {
|
|
75
|
-
return [
|
|
76
|
-
'### No open tabs',
|
|
77
|
-
'Use the "browser_navigate" tool to navigate to a page first.',
|
|
78
|
-
'',
|
|
79
|
-
];
|
|
80
|
-
}
|
|
81
|
-
const lines = ['### Open tabs'];
|
|
82
|
-
for (let i = 0; i < this._tabs.length; i++) {
|
|
83
|
-
const tab = this._tabs[i];
|
|
84
|
-
const title = await tab.title();
|
|
85
|
-
const url = tab.page.url();
|
|
86
|
-
const current = tab === this._currentTab ? ' (current)' : '';
|
|
87
|
-
lines.push(`- ${i}:${current} [${title}] (${url})`);
|
|
88
|
-
}
|
|
89
|
-
lines.push('');
|
|
90
|
-
return lines;
|
|
91
|
-
}
|
|
92
79
|
async closeTab(index) {
|
|
93
80
|
const tab = index === undefined ? this._currentTab : this._tabs[index];
|
|
94
81
|
if (!tab)
|
|
@@ -97,6 +84,9 @@ export class Context {
|
|
|
97
84
|
await tab.page.close();
|
|
98
85
|
return url;
|
|
99
86
|
}
|
|
87
|
+
async outputFile(name) {
|
|
88
|
+
return outputFile(this.config, this._clientInfo.rootPath, name);
|
|
89
|
+
}
|
|
100
90
|
_onPageCreated(page) {
|
|
101
91
|
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
|
102
92
|
this._tabs.push(tab);
|
|
@@ -119,6 +109,12 @@ export class Context {
|
|
|
119
109
|
await this._closeBrowserContextPromise;
|
|
120
110
|
this._closeBrowserContextPromise = undefined;
|
|
121
111
|
}
|
|
112
|
+
isRunningTool() {
|
|
113
|
+
return this._isRunningTool;
|
|
114
|
+
}
|
|
115
|
+
setRunningTool(isRunningTool) {
|
|
116
|
+
this._isRunningTool = isRunningTool;
|
|
117
|
+
}
|
|
122
118
|
async _closeBrowserContextImpl() {
|
|
123
119
|
if (!this._browserContextPromise)
|
|
124
120
|
return;
|
|
@@ -132,6 +128,7 @@ export class Context {
|
|
|
132
128
|
});
|
|
133
129
|
}
|
|
134
130
|
async dispose() {
|
|
131
|
+
this._abortController.abort('MCP context disposed');
|
|
135
132
|
await this.closeBrowserContext();
|
|
136
133
|
Context._allContexts.delete(this);
|
|
137
134
|
}
|
|
@@ -159,9 +156,11 @@ export class Context {
|
|
|
159
156
|
if (this._closeBrowserContextPromise)
|
|
160
157
|
throw new Error('Another browser context is being closed.');
|
|
161
158
|
// TODO: move to the browser context factory to make it based on isolation mode.
|
|
162
|
-
const result = await this._browserContextFactory.createContext(this.
|
|
159
|
+
const result = await this._browserContextFactory.createContext(this._clientInfo, this._abortController.signal);
|
|
163
160
|
const { browserContext } = result;
|
|
164
161
|
await this._setupRequestInterception(browserContext);
|
|
162
|
+
if (this.sessionLog)
|
|
163
|
+
await InputRecorder.create(this, browserContext);
|
|
165
164
|
for (const page of browserContext.pages())
|
|
166
165
|
this._onPageCreated(page);
|
|
167
166
|
browserContext.on('page', page => this._onPageCreated(page));
|
|
@@ -176,3 +175,52 @@ export class Context {
|
|
|
176
175
|
return result;
|
|
177
176
|
}
|
|
178
177
|
}
|
|
178
|
+
export class InputRecorder {
|
|
179
|
+
_context;
|
|
180
|
+
_browserContext;
|
|
181
|
+
constructor(context, browserContext) {
|
|
182
|
+
this._context = context;
|
|
183
|
+
this._browserContext = browserContext;
|
|
184
|
+
}
|
|
185
|
+
static async create(context, browserContext) {
|
|
186
|
+
const recorder = new InputRecorder(context, browserContext);
|
|
187
|
+
await recorder._initialize();
|
|
188
|
+
return recorder;
|
|
189
|
+
}
|
|
190
|
+
async _initialize() {
|
|
191
|
+
const sessionLog = this._context.sessionLog;
|
|
192
|
+
await this._browserContext._enableRecorder({
|
|
193
|
+
mode: 'recording',
|
|
194
|
+
recorderMode: 'api',
|
|
195
|
+
}, {
|
|
196
|
+
actionAdded: (page, data, code) => {
|
|
197
|
+
if (this._context.isRunningTool())
|
|
198
|
+
return;
|
|
199
|
+
const tab = Tab.forPage(page);
|
|
200
|
+
if (tab)
|
|
201
|
+
sessionLog.logUserAction(data.action, tab, code, false);
|
|
202
|
+
},
|
|
203
|
+
actionUpdated: (page, data, code) => {
|
|
204
|
+
if (this._context.isRunningTool())
|
|
205
|
+
return;
|
|
206
|
+
const tab = Tab.forPage(page);
|
|
207
|
+
if (tab)
|
|
208
|
+
sessionLog.logUserAction(data.action, tab, code, true);
|
|
209
|
+
},
|
|
210
|
+
signalAdded: (page, data) => {
|
|
211
|
+
if (this._context.isRunningTool())
|
|
212
|
+
return;
|
|
213
|
+
if (data.signal.name !== 'navigation')
|
|
214
|
+
return;
|
|
215
|
+
const tab = Tab.forPage(page);
|
|
216
|
+
const navigateAction = {
|
|
217
|
+
name: 'navigate',
|
|
218
|
+
url: data.signal.url,
|
|
219
|
+
signals: [],
|
|
220
|
+
};
|
|
221
|
+
if (tab)
|
|
222
|
+
sessionLog.logUserAction(navigateAction, tab, `await page.goto('${data.signal.url}');`, false);
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|