@playwright/mcp 0.0.30 → 0.0.32
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 +180 -320
- package/config.d.ts +5 -14
- package/index.d.ts +1 -6
- package/lib/browserContextFactory.js +3 -35
- package/lib/browserServerBackend.js +54 -0
- package/lib/config.js +64 -7
- package/lib/context.js +50 -163
- package/lib/extension/cdpRelay.js +370 -0
- package/lib/extension/main.js +33 -0
- package/lib/httpServer.js +20 -182
- package/lib/index.js +3 -2
- package/lib/log.js +21 -0
- package/lib/loop/loop.js +69 -0
- package/lib/loop/loopClaude.js +152 -0
- package/lib/loop/loopOpenAI.js +141 -0
- package/lib/loop/main.js +60 -0
- package/lib/loopTools/context.js +66 -0
- package/lib/loopTools/main.js +49 -0
- package/lib/loopTools/perform.js +32 -0
- package/lib/loopTools/snapshot.js +29 -0
- package/lib/loopTools/tool.js +18 -0
- package/lib/mcp/inProcessTransport.js +72 -0
- package/lib/mcp/server.js +88 -0
- package/lib/{transport.js → mcp/transport.js} +30 -42
- package/lib/package.js +3 -3
- package/lib/program.js +47 -17
- package/lib/response.js +98 -0
- package/lib/sessionLog.js +70 -0
- package/lib/tab.js +166 -21
- package/lib/tools/common.js +13 -25
- package/lib/tools/console.js +4 -15
- package/lib/tools/dialogs.js +14 -19
- package/lib/tools/evaluate.js +53 -0
- package/lib/tools/files.js +13 -19
- package/lib/tools/install.js +4 -8
- package/lib/tools/keyboard.js +51 -17
- package/lib/tools/mouse.js +99 -0
- package/lib/tools/navigate.js +22 -42
- package/lib/tools/network.js +5 -15
- package/lib/tools/pdf.js +8 -15
- package/lib/tools/screenshot.js +29 -30
- package/lib/tools/snapshot.js +49 -109
- package/lib/tools/tabs.js +21 -52
- package/lib/tools/tool.js +14 -0
- package/lib/tools/utils.js +7 -6
- package/lib/tools/wait.js +8 -11
- package/lib/tools.js +15 -26
- package/package.json +12 -5
- package/lib/browserServer.js +0 -151
- package/lib/connection.js +0 -82
- package/lib/pageSnapshot.js +0 -43
- package/lib/server.js +0 -48
- package/lib/tools/testing.js +0 -60
- package/lib/tools/vision.js +0 -189
package/config.d.ts
CHANGED
|
@@ -16,18 +16,13 @@
|
|
|
16
16
|
|
|
17
17
|
import type * as playwright from 'playwright';
|
|
18
18
|
|
|
19
|
-
export type ToolCapability = 'core' | 'tabs' | '
|
|
19
|
+
export type ToolCapability = 'core' | 'core-tabs' | 'core-install' | 'vision' | 'pdf';
|
|
20
20
|
|
|
21
21
|
export type Config = {
|
|
22
22
|
/**
|
|
23
23
|
* The browser to use.
|
|
24
24
|
*/
|
|
25
25
|
browser?: {
|
|
26
|
-
/**
|
|
27
|
-
* Use browser agent (experimental).
|
|
28
|
-
*/
|
|
29
|
-
browserAgent?: string;
|
|
30
|
-
|
|
31
26
|
/**
|
|
32
27
|
* The type of browser to use.
|
|
33
28
|
*/
|
|
@@ -85,19 +80,15 @@ export type Config = {
|
|
|
85
80
|
/**
|
|
86
81
|
* List of enabled tool capabilities. Possible values:
|
|
87
82
|
* - 'core': Core browser automation features.
|
|
88
|
-
* - 'tabs': Tab management features.
|
|
89
83
|
* - 'pdf': PDF generation and manipulation.
|
|
90
|
-
* - '
|
|
91
|
-
* - 'wait': Wait and timing utilities.
|
|
92
|
-
* - 'files': File upload/download support.
|
|
93
|
-
* - 'install': Browser installation utilities.
|
|
84
|
+
* - 'vision': Coordinate-based interactions.
|
|
94
85
|
*/
|
|
95
86
|
capabilities?: ToolCapability[];
|
|
96
87
|
|
|
97
88
|
/**
|
|
98
|
-
*
|
|
89
|
+
* Whether to save the Playwright session into the output directory.
|
|
99
90
|
*/
|
|
100
|
-
|
|
91
|
+
saveSession?: boolean;
|
|
101
92
|
|
|
102
93
|
/**
|
|
103
94
|
* Whether to save the Playwright trace of the session into the output directory.
|
|
@@ -124,5 +115,5 @@ export type Config = {
|
|
|
124
115
|
/**
|
|
125
116
|
* 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.
|
|
126
117
|
*/
|
|
127
|
-
imageResponses?: 'allow' | 'omit'
|
|
118
|
+
imageResponses?: 'allow' | 'omit';
|
|
128
119
|
};
|
package/index.d.ts
CHANGED
|
@@ -19,10 +19,5 @@ import type { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
|
19
19
|
import type { Config } from './config.js';
|
|
20
20
|
import type { BrowserContext } from 'playwright';
|
|
21
21
|
|
|
22
|
-
export
|
|
23
|
-
server: Server;
|
|
24
|
-
close(): Promise<void>;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Connection>;
|
|
22
|
+
export declare function createConnection(config?: Config, contextGetter?: () => Promise<BrowserContext>): Promise<Server>;
|
|
28
23
|
export {};
|
|
@@ -17,10 +17,8 @@ import fs from 'node:fs';
|
|
|
17
17
|
import net from 'node:net';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import os from 'node:os';
|
|
20
|
-
import debug from 'debug';
|
|
21
20
|
import * as playwright from 'playwright';
|
|
22
|
-
import {
|
|
23
|
-
const testDebug = debug('pw:mcp:test');
|
|
21
|
+
import { logUnhandledError, testDebug } from './log.js';
|
|
24
22
|
export function contextFactory(browserConfig) {
|
|
25
23
|
if (browserConfig.remoteEndpoint)
|
|
26
24
|
return new RemoteContextFactory(browserConfig);
|
|
@@ -28,8 +26,6 @@ export function contextFactory(browserConfig) {
|
|
|
28
26
|
return new CdpContextFactory(browserConfig);
|
|
29
27
|
if (browserConfig.isolated)
|
|
30
28
|
return new IsolatedContextFactory(browserConfig);
|
|
31
|
-
if (browserConfig.browserAgent)
|
|
32
|
-
return new BrowserServerContextFactory(browserConfig);
|
|
33
29
|
return new PersistentContextFactory(browserConfig);
|
|
34
30
|
}
|
|
35
31
|
class BaseContextFactory {
|
|
@@ -70,10 +66,10 @@ class BaseContextFactory {
|
|
|
70
66
|
testDebug(`close browser context (${this.name})`);
|
|
71
67
|
if (browser.contexts().length === 1)
|
|
72
68
|
this._browserPromise = undefined;
|
|
73
|
-
await browserContext.close().catch(
|
|
69
|
+
await browserContext.close().catch(logUnhandledError);
|
|
74
70
|
if (browser.contexts().length === 0) {
|
|
75
71
|
testDebug(`close browser (${this.name})`);
|
|
76
|
-
await browser.close().catch(
|
|
72
|
+
await browser.close().catch(logUnhandledError);
|
|
77
73
|
}
|
|
78
74
|
}
|
|
79
75
|
}
|
|
@@ -183,34 +179,6 @@ class PersistentContextFactory {
|
|
|
183
179
|
return result;
|
|
184
180
|
}
|
|
185
181
|
}
|
|
186
|
-
export class BrowserServerContextFactory extends BaseContextFactory {
|
|
187
|
-
constructor(browserConfig) {
|
|
188
|
-
super('persistent', browserConfig);
|
|
189
|
-
}
|
|
190
|
-
async _doObtainBrowser() {
|
|
191
|
-
const response = await fetch(new URL(`/json/launch`, this.browserConfig.browserAgent), {
|
|
192
|
-
method: 'POST',
|
|
193
|
-
body: JSON.stringify({
|
|
194
|
-
browserType: this.browserConfig.browserName,
|
|
195
|
-
userDataDir: this.browserConfig.userDataDir ?? await this._createUserDataDir(),
|
|
196
|
-
launchOptions: this.browserConfig.launchOptions,
|
|
197
|
-
contextOptions: this.browserConfig.contextOptions,
|
|
198
|
-
}),
|
|
199
|
-
});
|
|
200
|
-
const info = await response.json();
|
|
201
|
-
if (info.error)
|
|
202
|
-
throw new Error(info.error);
|
|
203
|
-
return await playwright.chromium.connectOverCDP(`http://localhost:${info.cdpPort}/`);
|
|
204
|
-
}
|
|
205
|
-
async _doCreateContext(browser) {
|
|
206
|
-
return this.browserConfig.isolated ? await browser.newContext() : browser.contexts()[0];
|
|
207
|
-
}
|
|
208
|
-
async _createUserDataDir() {
|
|
209
|
-
const dir = await userDataDir(this.browserConfig);
|
|
210
|
-
await fs.promises.mkdir(dir, { recursive: true });
|
|
211
|
-
return dir;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
182
|
async function injectCdpPort(browserConfig) {
|
|
215
183
|
if (browserConfig.browserName === 'chromium')
|
|
216
184
|
browserConfig.launchOptions.cdpPort = await findFreePort();
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Microsoft Corporation.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
import { Context } from './context.js';
|
|
17
|
+
import { logUnhandledError } from './log.js';
|
|
18
|
+
import { Response } from './response.js';
|
|
19
|
+
import { SessionLog } from './sessionLog.js';
|
|
20
|
+
import { filteredTools } from './tools.js';
|
|
21
|
+
import { packageJSON } from './package.js';
|
|
22
|
+
export class BrowserServerBackend {
|
|
23
|
+
name = 'Playwright';
|
|
24
|
+
version = packageJSON.version;
|
|
25
|
+
onclose;
|
|
26
|
+
_tools;
|
|
27
|
+
_context;
|
|
28
|
+
_sessionLog;
|
|
29
|
+
constructor(config, browserContextFactory) {
|
|
30
|
+
this._tools = filteredTools(config);
|
|
31
|
+
this._context = new Context(this._tools, config, browserContextFactory);
|
|
32
|
+
}
|
|
33
|
+
async initialize() {
|
|
34
|
+
this._sessionLog = this._context.config.saveSession ? await SessionLog.create(this._context.config) : undefined;
|
|
35
|
+
}
|
|
36
|
+
tools() {
|
|
37
|
+
return this._tools.map(tool => tool.schema);
|
|
38
|
+
}
|
|
39
|
+
async callTool(schema, parsedArguments) {
|
|
40
|
+
const response = new Response(this._context, schema.name, parsedArguments);
|
|
41
|
+
const tool = this._tools.find(tool => tool.schema.name === schema.name);
|
|
42
|
+
await tool.handle(this._context, parsedArguments, response);
|
|
43
|
+
if (this._sessionLog)
|
|
44
|
+
await this._sessionLog.log(response);
|
|
45
|
+
return await response.serialize();
|
|
46
|
+
}
|
|
47
|
+
serverInitialized(version) {
|
|
48
|
+
this._context.clientVersion = version;
|
|
49
|
+
}
|
|
50
|
+
serverClosed() {
|
|
51
|
+
this.onclose?.();
|
|
52
|
+
void this._context.dispose().catch(logUnhandledError);
|
|
53
|
+
}
|
|
54
|
+
}
|
package/lib/config.js
CHANGED
|
@@ -42,14 +42,18 @@ export async function resolveConfig(config) {
|
|
|
42
42
|
}
|
|
43
43
|
export async function resolveCLIConfig(cliOptions) {
|
|
44
44
|
const configInFile = await loadConfig(cliOptions.config);
|
|
45
|
-
const
|
|
46
|
-
const
|
|
45
|
+
const envOverrides = configFromEnv();
|
|
46
|
+
const cliOverrides = configFromCLIOptions(cliOptions);
|
|
47
|
+
let result = defaultConfig;
|
|
48
|
+
result = mergeConfig(result, configInFile);
|
|
49
|
+
result = mergeConfig(result, envOverrides);
|
|
50
|
+
result = mergeConfig(result, cliOverrides);
|
|
47
51
|
// Derive artifact output directory from config.outputDir
|
|
48
52
|
if (result.saveTrace)
|
|
49
53
|
result.browser.launchOptions.tracesDir = path.join(result.outputDir, 'traces');
|
|
50
54
|
return result;
|
|
51
55
|
}
|
|
52
|
-
export
|
|
56
|
+
export function configFromCLIOptions(cliOptions) {
|
|
53
57
|
let browserName;
|
|
54
58
|
let channel;
|
|
55
59
|
switch (cliOptions.browser) {
|
|
@@ -79,7 +83,7 @@ export async function configFromCLIOptions(cliOptions) {
|
|
|
79
83
|
headless: cliOptions.headless,
|
|
80
84
|
};
|
|
81
85
|
// --no-sandbox was passed, disable the sandbox
|
|
82
|
-
if (
|
|
86
|
+
if (cliOptions.sandbox === false)
|
|
83
87
|
launchOptions.chromiumSandbox = false;
|
|
84
88
|
if (cliOptions.proxyServer) {
|
|
85
89
|
launchOptions.proxy = {
|
|
@@ -113,7 +117,6 @@ export async function configFromCLIOptions(cliOptions) {
|
|
|
113
117
|
contextOptions.serviceWorkers = 'block';
|
|
114
118
|
const result = {
|
|
115
119
|
browser: {
|
|
116
|
-
browserAgent: cliOptions.browserAgent ?? process.env.PW_BROWSER_AGENT,
|
|
117
120
|
browserName,
|
|
118
121
|
isolated: cliOptions.isolated,
|
|
119
122
|
userDataDir: cliOptions.userDataDir,
|
|
@@ -125,18 +128,47 @@ export async function configFromCLIOptions(cliOptions) {
|
|
|
125
128
|
port: cliOptions.port,
|
|
126
129
|
host: cliOptions.host,
|
|
127
130
|
},
|
|
128
|
-
capabilities: cliOptions.caps
|
|
129
|
-
vision: !!cliOptions.vision,
|
|
131
|
+
capabilities: cliOptions.caps,
|
|
130
132
|
network: {
|
|
131
133
|
allowedOrigins: cliOptions.allowedOrigins,
|
|
132
134
|
blockedOrigins: cliOptions.blockedOrigins,
|
|
133
135
|
},
|
|
136
|
+
saveSession: cliOptions.saveSession,
|
|
134
137
|
saveTrace: cliOptions.saveTrace,
|
|
135
138
|
outputDir: cliOptions.outputDir,
|
|
136
139
|
imageResponses: cliOptions.imageResponses,
|
|
137
140
|
};
|
|
138
141
|
return result;
|
|
139
142
|
}
|
|
143
|
+
function configFromEnv() {
|
|
144
|
+
const options = {};
|
|
145
|
+
options.allowedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_ALLOWED_ORIGINS);
|
|
146
|
+
options.blockedOrigins = semicolonSeparatedList(process.env.PLAYWRIGHT_MCP_BLOCKED_ORIGINS);
|
|
147
|
+
options.blockServiceWorkers = envToBoolean(process.env.PLAYWRIGHT_MCP_BLOCK_SERVICE_WORKERS);
|
|
148
|
+
options.browser = envToString(process.env.PLAYWRIGHT_MCP_BROWSER);
|
|
149
|
+
options.caps = commaSeparatedList(process.env.PLAYWRIGHT_MCP_CAPS);
|
|
150
|
+
options.cdpEndpoint = envToString(process.env.PLAYWRIGHT_MCP_CDP_ENDPOINT);
|
|
151
|
+
options.config = envToString(process.env.PLAYWRIGHT_MCP_CONFIG);
|
|
152
|
+
options.device = envToString(process.env.PLAYWRIGHT_MCP_DEVICE);
|
|
153
|
+
options.executablePath = envToString(process.env.PLAYWRIGHT_MCP_EXECUTABLE_PATH);
|
|
154
|
+
options.headless = envToBoolean(process.env.PLAYWRIGHT_MCP_HEADLESS);
|
|
155
|
+
options.host = envToString(process.env.PLAYWRIGHT_MCP_HOST);
|
|
156
|
+
options.ignoreHttpsErrors = envToBoolean(process.env.PLAYWRIGHT_MCP_IGNORE_HTTPS_ERRORS);
|
|
157
|
+
options.isolated = envToBoolean(process.env.PLAYWRIGHT_MCP_ISOLATED);
|
|
158
|
+
if (process.env.PLAYWRIGHT_MCP_IMAGE_RESPONSES === 'omit')
|
|
159
|
+
options.imageResponses = 'omit';
|
|
160
|
+
options.sandbox = envToBoolean(process.env.PLAYWRIGHT_MCP_SANDBOX);
|
|
161
|
+
options.outputDir = envToString(process.env.PLAYWRIGHT_MCP_OUTPUT_DIR);
|
|
162
|
+
options.port = envToNumber(process.env.PLAYWRIGHT_MCP_PORT);
|
|
163
|
+
options.proxyBypass = envToString(process.env.PLAYWRIGHT_MCP_PROXY_BYPASS);
|
|
164
|
+
options.proxyServer = envToString(process.env.PLAYWRIGHT_MCP_PROXY_SERVER);
|
|
165
|
+
options.saveTrace = envToBoolean(process.env.PLAYWRIGHT_MCP_SAVE_TRACE);
|
|
166
|
+
options.storageState = envToString(process.env.PLAYWRIGHT_MCP_STORAGE_STATE);
|
|
167
|
+
options.userAgent = envToString(process.env.PLAYWRIGHT_MCP_USER_AGENT);
|
|
168
|
+
options.userDataDir = envToString(process.env.PLAYWRIGHT_MCP_USER_DATA_DIR);
|
|
169
|
+
options.viewportSize = envToString(process.env.PLAYWRIGHT_MCP_VIEWPORT_SIZE);
|
|
170
|
+
return configFromCLIOptions(options);
|
|
171
|
+
}
|
|
140
172
|
async function loadConfig(configFile) {
|
|
141
173
|
if (!configFile)
|
|
142
174
|
return {};
|
|
@@ -187,3 +219,28 @@ function mergeConfig(base, overrides) {
|
|
|
187
219
|
},
|
|
188
220
|
};
|
|
189
221
|
}
|
|
222
|
+
export function semicolonSeparatedList(value) {
|
|
223
|
+
if (!value)
|
|
224
|
+
return undefined;
|
|
225
|
+
return value.split(';').map(v => v.trim());
|
|
226
|
+
}
|
|
227
|
+
export function commaSeparatedList(value) {
|
|
228
|
+
if (!value)
|
|
229
|
+
return undefined;
|
|
230
|
+
return value.split(',').map(v => v.trim());
|
|
231
|
+
}
|
|
232
|
+
function envToNumber(value) {
|
|
233
|
+
if (!value)
|
|
234
|
+
return undefined;
|
|
235
|
+
return +value;
|
|
236
|
+
}
|
|
237
|
+
function envToBoolean(value) {
|
|
238
|
+
if (value === 'true' || value === '1')
|
|
239
|
+
return true;
|
|
240
|
+
if (value === 'false' || value === '0')
|
|
241
|
+
return false;
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
function envToString(value) {
|
|
245
|
+
return value ? value.trim() : undefined;
|
|
246
|
+
}
|
package/lib/context.js
CHANGED
|
@@ -14,10 +14,8 @@
|
|
|
14
14
|
* limitations under the License.
|
|
15
15
|
*/
|
|
16
16
|
import debug from 'debug';
|
|
17
|
-
import {
|
|
18
|
-
import { ManualPromise } from './manualPromise.js';
|
|
17
|
+
import { logUnhandledError } from './log.js';
|
|
19
18
|
import { Tab } from './tab.js';
|
|
20
|
-
import { outputFile } from './config.js';
|
|
21
19
|
const testDebug = debug('pw:mcp:test');
|
|
22
20
|
export class Context {
|
|
23
21
|
tools;
|
|
@@ -26,48 +24,28 @@ export class Context {
|
|
|
26
24
|
_browserContextFactory;
|
|
27
25
|
_tabs = [];
|
|
28
26
|
_currentTab;
|
|
29
|
-
_modalStates = [];
|
|
30
|
-
_pendingAction;
|
|
31
|
-
_downloads = [];
|
|
32
27
|
clientVersion;
|
|
28
|
+
static _allContexts = new Set();
|
|
29
|
+
_closeBrowserContextPromise;
|
|
33
30
|
constructor(tools, config, browserContextFactory) {
|
|
34
31
|
this.tools = tools;
|
|
35
32
|
this.config = config;
|
|
36
33
|
this._browserContextFactory = browserContextFactory;
|
|
37
34
|
testDebug('create context');
|
|
35
|
+
Context._allContexts.add(this);
|
|
38
36
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return true;
|
|
42
|
-
if (this.config.imageResponses === 'omit')
|
|
43
|
-
return false;
|
|
44
|
-
return !this.clientVersion?.name.includes('cursor');
|
|
45
|
-
}
|
|
46
|
-
modalStates() {
|
|
47
|
-
return this._modalStates;
|
|
48
|
-
}
|
|
49
|
-
setModalState(modalState, inTab) {
|
|
50
|
-
this._modalStates.push({ ...modalState, tab: inTab });
|
|
51
|
-
}
|
|
52
|
-
clearModalState(modalState) {
|
|
53
|
-
this._modalStates = this._modalStates.filter(state => state !== modalState);
|
|
54
|
-
}
|
|
55
|
-
modalStatesMarkdown() {
|
|
56
|
-
const result = ['### Modal state'];
|
|
57
|
-
if (this._modalStates.length === 0)
|
|
58
|
-
result.push('- There is no modal state present');
|
|
59
|
-
for (const state of this._modalStates) {
|
|
60
|
-
const tool = this.tools.find(tool => tool.clearsModalState === state.type);
|
|
61
|
-
result.push(`- [${state.description}]: can be handled by the "${tool?.schema.name}" tool`);
|
|
62
|
-
}
|
|
63
|
-
return result;
|
|
37
|
+
static async disposeAll() {
|
|
38
|
+
await Promise.all([...Context._allContexts].map(context => context.dispose()));
|
|
64
39
|
}
|
|
65
40
|
tabs() {
|
|
66
41
|
return this._tabs;
|
|
67
42
|
}
|
|
43
|
+
currentTab() {
|
|
44
|
+
return this._currentTab;
|
|
45
|
+
}
|
|
68
46
|
currentTabOrDie() {
|
|
69
47
|
if (!this._currentTab)
|
|
70
|
-
throw new Error('No
|
|
48
|
+
throw new Error('No open pages available. Use the "browser_navigate" tool to navigate to a page first.');
|
|
71
49
|
return this._currentTab;
|
|
72
50
|
}
|
|
73
51
|
async newTab() {
|
|
@@ -77,8 +55,12 @@ export class Context {
|
|
|
77
55
|
return this._currentTab;
|
|
78
56
|
}
|
|
79
57
|
async selectTab(index) {
|
|
80
|
-
|
|
81
|
-
|
|
58
|
+
const tab = this._tabs[index];
|
|
59
|
+
if (!tab)
|
|
60
|
+
throw new Error(`Tab ${index} not found`);
|
|
61
|
+
await tab.page.bringToFront();
|
|
62
|
+
this._currentTab = tab;
|
|
63
|
+
return tab;
|
|
82
64
|
}
|
|
83
65
|
async ensureTab() {
|
|
84
66
|
const { browserContext } = await this._ensureBrowserContext();
|
|
@@ -86,140 +68,34 @@ export class Context {
|
|
|
86
68
|
await browserContext.newPage();
|
|
87
69
|
return this._currentTab;
|
|
88
70
|
}
|
|
89
|
-
async listTabsMarkdown() {
|
|
90
|
-
if (
|
|
91
|
-
return
|
|
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
|
+
}
|
|
92
81
|
const lines = ['### Open tabs'];
|
|
93
82
|
for (let i = 0; i < this._tabs.length; i++) {
|
|
94
83
|
const tab = this._tabs[i];
|
|
95
84
|
const title = await tab.title();
|
|
96
85
|
const url = tab.page.url();
|
|
97
86
|
const current = tab === this._currentTab ? ' (current)' : '';
|
|
98
|
-
lines.push(`- ${i
|
|
87
|
+
lines.push(`- ${i}:${current} [${title}] (${url})`);
|
|
99
88
|
}
|
|
100
|
-
|
|
89
|
+
lines.push('');
|
|
90
|
+
return lines;
|
|
101
91
|
}
|
|
102
92
|
async closeTab(index) {
|
|
103
|
-
const tab = index === undefined ? this._currentTab : this._tabs[index
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const toolResult = await tool.handle(this, tool.schema.inputSchema.parse(params || {}));
|
|
110
|
-
const { code, action, waitForNetwork, captureSnapshot, resultOverride } = toolResult;
|
|
111
|
-
const racingAction = action ? () => this._raceAgainstModalDialogs(action) : undefined;
|
|
112
|
-
if (resultOverride)
|
|
113
|
-
return resultOverride;
|
|
114
|
-
if (!this._currentTab) {
|
|
115
|
-
return {
|
|
116
|
-
content: [{
|
|
117
|
-
type: 'text',
|
|
118
|
-
text: 'No open pages available. Use the "browser_navigate" tool to navigate to a page first.',
|
|
119
|
-
}],
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
const tab = this.currentTabOrDie();
|
|
123
|
-
// TODO: race against modal dialogs to resolve clicks.
|
|
124
|
-
let actionResult;
|
|
125
|
-
try {
|
|
126
|
-
if (waitForNetwork)
|
|
127
|
-
actionResult = await waitForCompletion(this, tab, async () => racingAction?.()) ?? undefined;
|
|
128
|
-
else
|
|
129
|
-
actionResult = await racingAction?.() ?? undefined;
|
|
130
|
-
}
|
|
131
|
-
finally {
|
|
132
|
-
if (captureSnapshot && !this._javaScriptBlocked())
|
|
133
|
-
await tab.captureSnapshot();
|
|
134
|
-
}
|
|
135
|
-
const result = [];
|
|
136
|
-
result.push(`- Ran Playwright code:
|
|
137
|
-
\`\`\`js
|
|
138
|
-
${code.join('\n')}
|
|
139
|
-
\`\`\`
|
|
140
|
-
`);
|
|
141
|
-
if (this.modalStates().length) {
|
|
142
|
-
result.push(...this.modalStatesMarkdown());
|
|
143
|
-
return {
|
|
144
|
-
content: [{
|
|
145
|
-
type: 'text',
|
|
146
|
-
text: result.join('\n'),
|
|
147
|
-
}],
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
if (this._downloads.length) {
|
|
151
|
-
result.push('', '### Downloads');
|
|
152
|
-
for (const entry of this._downloads) {
|
|
153
|
-
if (entry.finished)
|
|
154
|
-
result.push(`- Downloaded file ${entry.download.suggestedFilename()} to ${entry.outputFile}`);
|
|
155
|
-
else
|
|
156
|
-
result.push(`- Downloading file ${entry.download.suggestedFilename()} ...`);
|
|
157
|
-
}
|
|
158
|
-
result.push('');
|
|
159
|
-
}
|
|
160
|
-
if (this.tabs().length > 1)
|
|
161
|
-
result.push(await this.listTabsMarkdown(), '');
|
|
162
|
-
if (this.tabs().length > 1)
|
|
163
|
-
result.push('### Current tab');
|
|
164
|
-
result.push(`- Page URL: ${tab.page.url()}`, `- Page Title: ${await tab.title()}`);
|
|
165
|
-
if (captureSnapshot && tab.hasSnapshot())
|
|
166
|
-
result.push(tab.snapshotOrDie().text());
|
|
167
|
-
const content = actionResult?.content ?? [];
|
|
168
|
-
return {
|
|
169
|
-
content: [
|
|
170
|
-
...content,
|
|
171
|
-
{
|
|
172
|
-
type: 'text',
|
|
173
|
-
text: result.join('\n'),
|
|
174
|
-
}
|
|
175
|
-
],
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
async waitForTimeout(time) {
|
|
179
|
-
if (!this._currentTab || this._javaScriptBlocked()) {
|
|
180
|
-
await new Promise(f => setTimeout(f, time));
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
await callOnPageNoTrace(this._currentTab.page, page => {
|
|
184
|
-
return page.evaluate(() => new Promise(f => setTimeout(f, 1000)));
|
|
185
|
-
});
|
|
186
|
-
}
|
|
187
|
-
async _raceAgainstModalDialogs(action) {
|
|
188
|
-
this._pendingAction = {
|
|
189
|
-
dialogShown: new ManualPromise(),
|
|
190
|
-
};
|
|
191
|
-
let result;
|
|
192
|
-
try {
|
|
193
|
-
await Promise.race([
|
|
194
|
-
action().then(r => result = r),
|
|
195
|
-
this._pendingAction.dialogShown,
|
|
196
|
-
]);
|
|
197
|
-
}
|
|
198
|
-
finally {
|
|
199
|
-
this._pendingAction = undefined;
|
|
200
|
-
}
|
|
201
|
-
return result;
|
|
202
|
-
}
|
|
203
|
-
_javaScriptBlocked() {
|
|
204
|
-
return this._modalStates.some(state => state.type === 'dialog');
|
|
205
|
-
}
|
|
206
|
-
dialogShown(tab, dialog) {
|
|
207
|
-
this.setModalState({
|
|
208
|
-
type: 'dialog',
|
|
209
|
-
description: `"${dialog.type()}" dialog with message "${dialog.message()}"`,
|
|
210
|
-
dialog,
|
|
211
|
-
}, tab);
|
|
212
|
-
this._pendingAction?.dialogShown.resolve();
|
|
213
|
-
}
|
|
214
|
-
async downloadStarted(tab, download) {
|
|
215
|
-
const entry = {
|
|
216
|
-
download,
|
|
217
|
-
finished: false,
|
|
218
|
-
outputFile: await outputFile(this.config, download.suggestedFilename())
|
|
219
|
-
};
|
|
220
|
-
this._downloads.push(entry);
|
|
221
|
-
await download.saveAs(entry.outputFile);
|
|
222
|
-
entry.finished = true;
|
|
93
|
+
const tab = index === undefined ? this._currentTab : this._tabs[index];
|
|
94
|
+
if (!tab)
|
|
95
|
+
throw new Error(`Tab ${index} not found`);
|
|
96
|
+
const url = tab.page.url();
|
|
97
|
+
await tab.page.close();
|
|
98
|
+
return url;
|
|
223
99
|
}
|
|
224
100
|
_onPageCreated(page) {
|
|
225
101
|
const tab = new Tab(this, page, tab => this._onPageClosed(tab));
|
|
@@ -228,7 +104,6 @@ ${code.join('\n')}
|
|
|
228
104
|
this._currentTab = tab;
|
|
229
105
|
}
|
|
230
106
|
_onPageClosed(tab) {
|
|
231
|
-
this._modalStates = this._modalStates.filter(state => state.tab !== tab);
|
|
232
107
|
const index = this._tabs.indexOf(tab);
|
|
233
108
|
if (index === -1)
|
|
234
109
|
return;
|
|
@@ -236,9 +111,15 @@ ${code.join('\n')}
|
|
|
236
111
|
if (this._currentTab === tab)
|
|
237
112
|
this._currentTab = this._tabs[Math.min(index, this._tabs.length - 1)];
|
|
238
113
|
if (!this._tabs.length)
|
|
239
|
-
void this.
|
|
114
|
+
void this.closeBrowserContext();
|
|
115
|
+
}
|
|
116
|
+
async closeBrowserContext() {
|
|
117
|
+
if (!this._closeBrowserContextPromise)
|
|
118
|
+
this._closeBrowserContextPromise = this._closeBrowserContextImpl().catch(logUnhandledError);
|
|
119
|
+
await this._closeBrowserContextPromise;
|
|
120
|
+
this._closeBrowserContextPromise = undefined;
|
|
240
121
|
}
|
|
241
|
-
async
|
|
122
|
+
async _closeBrowserContextImpl() {
|
|
242
123
|
if (!this._browserContextPromise)
|
|
243
124
|
return;
|
|
244
125
|
testDebug('close context');
|
|
@@ -250,6 +131,10 @@ ${code.join('\n')}
|
|
|
250
131
|
await close();
|
|
251
132
|
});
|
|
252
133
|
}
|
|
134
|
+
async dispose() {
|
|
135
|
+
await this.closeBrowserContext();
|
|
136
|
+
Context._allContexts.delete(this);
|
|
137
|
+
}
|
|
253
138
|
async _setupRequestInterception(context) {
|
|
254
139
|
if (this.config.network?.allowedOrigins?.length) {
|
|
255
140
|
await context.route('**', route => route.abort('blockedbyclient'));
|
|
@@ -271,8 +156,10 @@ ${code.join('\n')}
|
|
|
271
156
|
return this._browserContextPromise;
|
|
272
157
|
}
|
|
273
158
|
async _setupBrowserContext() {
|
|
159
|
+
if (this._closeBrowserContextPromise)
|
|
160
|
+
throw new Error('Another browser context is being closed.');
|
|
274
161
|
// TODO: move to the browser context factory to make it based on isolation mode.
|
|
275
|
-
const result = await this._browserContextFactory.createContext();
|
|
162
|
+
const result = await this._browserContextFactory.createContext(this.clientVersion);
|
|
276
163
|
const { browserContext } = result;
|
|
277
164
|
await this._setupRequestInterception(browserContext);
|
|
278
165
|
for (const page of browserContext.pages())
|