@wordbricks/playwright-mcp 0.1.25 → 0.1.27
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/lib/browserContextFactory.js +616 -0
- package/lib/browserServerBackend.js +86 -0
- package/lib/config.js +302 -0
- package/lib/context.js +320 -0
- package/lib/extension/cdpRelay.js +352 -0
- package/lib/extension/extensionContextFactory.js +56 -0
- package/lib/frameworkPatterns.js +35 -0
- package/lib/hooks/antiBotDetectionHook.js +178 -0
- package/lib/hooks/core.js +145 -0
- package/lib/hooks/eventConsumer.js +52 -0
- package/lib/hooks/events.js +42 -0
- package/lib/hooks/formatToolCallEvent.js +12 -0
- package/lib/hooks/frameworkStateHook.js +182 -0
- package/lib/hooks/grouping.js +72 -0
- package/lib/hooks/jsonLdDetectionHook.js +182 -0
- package/lib/hooks/networkFilters.js +82 -0
- package/lib/hooks/networkSetup.js +61 -0
- package/lib/hooks/networkTrackingHook.js +67 -0
- package/lib/hooks/pageHeightHook.js +75 -0
- package/lib/hooks/registry.js +41 -0
- package/lib/hooks/requireTabHook.js +26 -0
- package/lib/hooks/schema.js +89 -0
- package/lib/hooks/waitHook.js +33 -0
- package/lib/index.js +41 -0
- package/lib/mcp/inProcessTransport.js +71 -0
- package/lib/mcp/proxyBackend.js +130 -0
- package/lib/mcp/server.js +91 -0
- package/lib/mcp/tool.js +44 -0
- package/lib/mcp/transport.js +188 -0
- package/lib/playwrightTransformer.js +520 -0
- package/lib/program.js +112 -0
- package/lib/response.js +192 -0
- package/lib/sessionLog.js +123 -0
- package/lib/tab.js +251 -0
- package/lib/tools/common.js +55 -0
- package/lib/tools/console.js +33 -0
- package/lib/tools/dialogs.js +50 -0
- package/lib/tools/evaluate.js +62 -0
- package/lib/tools/extractFrameworkState.js +225 -0
- package/lib/tools/files.js +48 -0
- package/lib/tools/form.js +66 -0
- package/lib/tools/getSnapshot.js +36 -0
- package/lib/tools/getVisibleHtml.js +68 -0
- package/lib/tools/install.js +51 -0
- package/lib/tools/keyboard.js +83 -0
- package/lib/tools/mouse.js +97 -0
- package/lib/tools/navigate.js +66 -0
- package/lib/tools/network.js +121 -0
- package/lib/tools/networkDetail.js +238 -0
- package/lib/tools/networkSearch/bodySearch.js +161 -0
- package/lib/tools/networkSearch/grouping.js +37 -0
- package/lib/tools/networkSearch/helpers.js +32 -0
- package/lib/tools/networkSearch/searchHtml.js +76 -0
- package/lib/tools/networkSearch/types.js +1 -0
- package/lib/tools/networkSearch/urlSearch.js +124 -0
- package/lib/tools/networkSearch.js +278 -0
- package/lib/tools/pdf.js +41 -0
- package/lib/tools/repl.js +414 -0
- package/lib/tools/screenshot.js +103 -0
- package/lib/tools/scroll.js +131 -0
- package/lib/tools/snapshot.js +161 -0
- package/lib/tools/tabs.js +62 -0
- package/lib/tools/tool.js +35 -0
- package/lib/tools/utils.js +78 -0
- package/lib/tools/wait.js +60 -0
- package/lib/tools.js +68 -0
- package/lib/utils/adBlockFilter.js +90 -0
- package/lib/utils/codegen.js +55 -0
- package/lib/utils/extensionPath.js +10 -0
- package/lib/utils/fileUtils.js +40 -0
- package/lib/utils/graphql.js +269 -0
- package/lib/utils/guid.js +22 -0
- package/lib/utils/httpServer.js +39 -0
- package/lib/utils/log.js +21 -0
- package/lib/utils/manualPromise.js +111 -0
- package/lib/utils/networkFormat.js +14 -0
- package/lib/utils/package.js +20 -0
- package/lib/utils/result.js +2 -0
- package/lib/utils/sanitizeHtml.js +130 -0
- package/lib/utils/truncate.js +103 -0
- package/lib/utils/withTimeout.js +7 -0
- package/package.json +11 -1
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import debug from "debug";
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import ms from "ms";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { transformScript } from "../playwrightTransformer.js";
|
|
7
|
+
import * as javascript from "../utils/codegen.js";
|
|
8
|
+
import { truncate } from "../utils/truncate.js";
|
|
9
|
+
import { defineTabTool } from "./tool.js";
|
|
10
|
+
const EVALUATE_TIMEOUT_MS = ms("30s");
|
|
11
|
+
const debugLog = debug("pw:mcp:repl");
|
|
12
|
+
const replSchema = z.object({
|
|
13
|
+
script: z
|
|
14
|
+
.string()
|
|
15
|
+
.describe("JavaScript code to execute in the browser console."),
|
|
16
|
+
});
|
|
17
|
+
const contextStates = new WeakMap();
|
|
18
|
+
function getPageState(page) {
|
|
19
|
+
let pageStates = contextStates.get(page.context());
|
|
20
|
+
if (!pageStates) {
|
|
21
|
+
pageStates = new WeakMap();
|
|
22
|
+
contextStates.set(page.context(), pageStates);
|
|
23
|
+
}
|
|
24
|
+
let state = pageStates.get(page);
|
|
25
|
+
if (!state) {
|
|
26
|
+
state = {
|
|
27
|
+
librariesInjected: false,
|
|
28
|
+
consoleListenerSetup: false,
|
|
29
|
+
};
|
|
30
|
+
pageStates.set(page, state);
|
|
31
|
+
}
|
|
32
|
+
return state;
|
|
33
|
+
}
|
|
34
|
+
// Helpers
|
|
35
|
+
async function setupConsoleCapture(page) {
|
|
36
|
+
await page.evaluate(() => {
|
|
37
|
+
// Store original console methods
|
|
38
|
+
window.__originalConsole = {
|
|
39
|
+
// eslint-disable-next-line no-console
|
|
40
|
+
log: console.log,
|
|
41
|
+
// eslint-disable-next-line no-console
|
|
42
|
+
error: console.error,
|
|
43
|
+
// eslint-disable-next-line no-console
|
|
44
|
+
warn: console.warn,
|
|
45
|
+
// eslint-disable-next-line no-console
|
|
46
|
+
info: console.info,
|
|
47
|
+
};
|
|
48
|
+
// Create array to store console outputs
|
|
49
|
+
window.__consoleOutputs = [];
|
|
50
|
+
// Override console methods to capture output
|
|
51
|
+
const captureConsole = (method) => {
|
|
52
|
+
const handler = (...args) => {
|
|
53
|
+
// Call original method
|
|
54
|
+
window.__originalConsole?.[method](...args);
|
|
55
|
+
// Capture output
|
|
56
|
+
const output = args
|
|
57
|
+
.map((arg) => {
|
|
58
|
+
if (arg === undefined)
|
|
59
|
+
return "undefined";
|
|
60
|
+
if (arg === null)
|
|
61
|
+
return "null";
|
|
62
|
+
if (typeof arg === "object") {
|
|
63
|
+
try {
|
|
64
|
+
return JSON.stringify(arg);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return String(arg);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return String(arg);
|
|
71
|
+
})
|
|
72
|
+
.join(" ");
|
|
73
|
+
window.__consoleOutputs?.push(output);
|
|
74
|
+
};
|
|
75
|
+
Object.defineProperty(console, method, {
|
|
76
|
+
value: handler,
|
|
77
|
+
configurable: true,
|
|
78
|
+
writable: true,
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
captureConsole("log");
|
|
82
|
+
captureConsole("error");
|
|
83
|
+
captureConsole("warn");
|
|
84
|
+
captureConsole("info");
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async function injectLibraries(cdpSession) {
|
|
88
|
+
try {
|
|
89
|
+
const nodeModulesPath = join(process.cwd(), "node_modules");
|
|
90
|
+
// Inject utility libraries into the page context
|
|
91
|
+
const json5Code = readFileSync(join(nodeModulesPath, "json5", "dist", "index.min.js"), "utf8");
|
|
92
|
+
const jsonpathCode = readFileSync(join(nodeModulesPath, "jsonpath-plus", "dist", "index-browser-umd.min.cjs"), "utf8");
|
|
93
|
+
const lodashCode = readFileSync(join(nodeModulesPath, "lodash", "lodash.min.js"), "utf8");
|
|
94
|
+
// Inject libraries using CDP
|
|
95
|
+
await cdpSession.send("Runtime.evaluate", {
|
|
96
|
+
expression: `
|
|
97
|
+
(function(){
|
|
98
|
+
try {
|
|
99
|
+
window.mcp = window.mcp || {};
|
|
100
|
+
window.mcp.helpers = window.mcp.helpers || {};
|
|
101
|
+
|
|
102
|
+
var prev_ = window._;
|
|
103
|
+
var prevJSON5 = window.JSON5;
|
|
104
|
+
var prevJSONPath = window.JSONPath;
|
|
105
|
+
|
|
106
|
+
// Load libs
|
|
107
|
+
${json5Code}
|
|
108
|
+
${jsonpathCode}
|
|
109
|
+
${lodashCode}
|
|
110
|
+
|
|
111
|
+
// Capture injected references
|
|
112
|
+
var injectedJSON5 = window.JSON5;
|
|
113
|
+
var injectedJSONPathFn = (window.JSONPath && window.JSONPath.JSONPath) ? window.JSONPath.JSONPath : window.JSONPath;
|
|
114
|
+
var injectedLodash = window._;
|
|
115
|
+
|
|
116
|
+
// Restore previous globals to avoid conflicts
|
|
117
|
+
if (typeof prevJSON5 !== 'undefined') window.JSON5 = prevJSON5;
|
|
118
|
+
if (typeof prevJSONPath !== 'undefined') window.JSONPath = prevJSONPath;
|
|
119
|
+
if (injectedLodash && injectedLodash.noConflict) {
|
|
120
|
+
injectedLodash = injectedLodash.noConflict();
|
|
121
|
+
} else if (typeof prev_ !== 'undefined') {
|
|
122
|
+
window._ = prev_;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Expose helpers under a simple namespace
|
|
126
|
+
window.mcp.JSON5 = injectedJSON5;
|
|
127
|
+
window.mcp.JSONPath = injectedJSONPathFn;
|
|
128
|
+
window.mcp._ = injectedLodash;
|
|
129
|
+
|
|
130
|
+
// GraphQL helpers
|
|
131
|
+
if (!window.mcp.GraphQLClient) {
|
|
132
|
+
class GraphQLClient {
|
|
133
|
+
constructor(endpoint, options = {}) {
|
|
134
|
+
this.endpoint = endpoint;
|
|
135
|
+
this.options = options || {};
|
|
136
|
+
}
|
|
137
|
+
async request(document, variables, requestHeaders) {
|
|
138
|
+
const method = ((this.options.method || 'POST')).toUpperCase();
|
|
139
|
+
const headers = Object.assign(
|
|
140
|
+
{ 'content-type': 'application/json' },
|
|
141
|
+
this.options.headers || {},
|
|
142
|
+
requestHeaders || {}
|
|
143
|
+
);
|
|
144
|
+
const bodyObj = {
|
|
145
|
+
query: typeof document === 'string' ? document : String(document),
|
|
146
|
+
variables: variables || undefined,
|
|
147
|
+
};
|
|
148
|
+
let url = this.endpoint;
|
|
149
|
+
const fetchOptions = { method, headers, credentials: (this.options.credentials || 'same-origin') };
|
|
150
|
+
if (method === 'GET') {
|
|
151
|
+
const params = new URLSearchParams();
|
|
152
|
+
params.set('query', bodyObj.query);
|
|
153
|
+
if (variables) params.set('variables', JSON.stringify(variables));
|
|
154
|
+
url += (url.includes('?') ? '&' : '?') + params.toString();
|
|
155
|
+
} else {
|
|
156
|
+
fetchOptions.body = JSON.stringify(bodyObj);
|
|
157
|
+
}
|
|
158
|
+
const res = await fetch(url, fetchOptions);
|
|
159
|
+
let json;
|
|
160
|
+
try {
|
|
161
|
+
json = await res.json();
|
|
162
|
+
} catch (e) {
|
|
163
|
+
throw new Error('Invalid JSON response: ' + (e && e.message ? e.message : String(e)));
|
|
164
|
+
}
|
|
165
|
+
if (!res.ok) {
|
|
166
|
+
const errMsg = (json && json.error) ? json.error : (json && json.errors ? (json.errors.map(e => e.message).join('; ')) : res.statusText);
|
|
167
|
+
throw new Error('HTTP ' + res.status + ' ' + errMsg);
|
|
168
|
+
}
|
|
169
|
+
if (json.errors && (!this.options || this.options.errorPolicy !== 'all')) {
|
|
170
|
+
const msg = json.errors.map(e => e.message).join('; ');
|
|
171
|
+
throw new Error('GraphQL errors: ' + msg);
|
|
172
|
+
}
|
|
173
|
+
return (this.options && this.options.errorPolicy === 'all') ? json : json.data;
|
|
174
|
+
}
|
|
175
|
+
setHeaders(headers) {
|
|
176
|
+
this.options = this.options || {};
|
|
177
|
+
this.options.headers = Object.assign({}, this.options.headers || {}, headers || {});
|
|
178
|
+
}
|
|
179
|
+
setEndpoint(endpoint) { this.endpoint = endpoint; }
|
|
180
|
+
}
|
|
181
|
+
function gql(strings, ...values) {
|
|
182
|
+
let out = '';
|
|
183
|
+
for (let i = 0; i < strings.length; i++) {
|
|
184
|
+
out += strings[i];
|
|
185
|
+
if (i < values.length) out += values[i];
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
async function graphqlRequest(urlOrConfig, document, variables, requestHeaders) {
|
|
190
|
+
if (typeof urlOrConfig === 'string') {
|
|
191
|
+
const client = new GraphQLClient(urlOrConfig);
|
|
192
|
+
return client.request(document, variables, requestHeaders);
|
|
193
|
+
}
|
|
194
|
+
const { url, ...rest } = urlOrConfig || {};
|
|
195
|
+
const client = new GraphQLClient(url, rest);
|
|
196
|
+
return client.request(document, variables, requestHeaders);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
window.mcp.GraphQLClient = GraphQLClient;
|
|
200
|
+
window.mcp.gql = gql;
|
|
201
|
+
window.mcp.graphqlRequest = graphqlRequest;
|
|
202
|
+
|
|
203
|
+
// Expose globals only if not already present
|
|
204
|
+
if (!window.GraphQLClient) window.GraphQLClient = GraphQLClient;
|
|
205
|
+
if (!window.gql) window.gql = gql;
|
|
206
|
+
if (!window.graphqlRequest) window.graphqlRequest = graphqlRequest;
|
|
207
|
+
}
|
|
208
|
+
} catch (e) {}
|
|
209
|
+
})();
|
|
210
|
+
`,
|
|
211
|
+
replMode: true,
|
|
212
|
+
});
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
debugLog("Failed to inject libraries:", error);
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const repl = defineTabTool({
|
|
221
|
+
capability: "core",
|
|
222
|
+
schema: {
|
|
223
|
+
name: "browser_repl",
|
|
224
|
+
title: "Browser REPL",
|
|
225
|
+
description: "DevTools-like browser REPL. Per-tab state persists across calls (const/let/functions); supports top-level await. Survives SPA nav; resets on full reload or when switching tabs. Helpers available via window.mcp: JSON5, JSONPath, _, GraphQLClient, gql, graphqlRequest. No need for wrapping in IIFE.",
|
|
226
|
+
inputSchema: replSchema,
|
|
227
|
+
type: "action",
|
|
228
|
+
},
|
|
229
|
+
handle: async (tab, params, response) => {
|
|
230
|
+
const page = tab.page;
|
|
231
|
+
const state = getPageState(page);
|
|
232
|
+
// Get or create CDP session for this page
|
|
233
|
+
async function getOrCreateSession() {
|
|
234
|
+
if (!state.cdpSession) {
|
|
235
|
+
state.cdpSession = await page.context().newCDPSession(page);
|
|
236
|
+
await state.cdpSession.send("Runtime.enable");
|
|
237
|
+
}
|
|
238
|
+
return state.cdpSession;
|
|
239
|
+
}
|
|
240
|
+
const session = await getOrCreateSession();
|
|
241
|
+
// Set up console output capture if not already done
|
|
242
|
+
if (!state.consoleListenerSetup) {
|
|
243
|
+
await setupConsoleCapture(page);
|
|
244
|
+
state.consoleListenerSetup = true;
|
|
245
|
+
}
|
|
246
|
+
// Transform Playwright selectors to DOM-compatible code
|
|
247
|
+
let transformedScript = transformScript(params.script);
|
|
248
|
+
// Check if script has return statements outside functions
|
|
249
|
+
const hasReturnOutsideFunction = /^[^{]*\breturn\b/.test(transformedScript.trim());
|
|
250
|
+
const hasTopLevelAwait = /^[^{]*\bawait\b/.test(transformedScript.trim());
|
|
251
|
+
// Wrap script appropriately if it has return statements
|
|
252
|
+
if (hasReturnOutsideFunction && hasTopLevelAwait) {
|
|
253
|
+
// Wrap in async function for both return and await
|
|
254
|
+
transformedScript = `(async function() { ${transformedScript} })()`;
|
|
255
|
+
}
|
|
256
|
+
else if (hasReturnOutsideFunction) {
|
|
257
|
+
// Wrap in regular function for return statements
|
|
258
|
+
transformedScript = `(function() { ${transformedScript} })()`;
|
|
259
|
+
}
|
|
260
|
+
// Add code to response - show the original script, not transformed
|
|
261
|
+
response.addCode(`// Execute in browser REPL\nawait page.evaluate(${javascript.quote(params.script)});`);
|
|
262
|
+
response.setIncludeSnapshot();
|
|
263
|
+
await tab.waitForCompletion(async () => {
|
|
264
|
+
try {
|
|
265
|
+
// Clear console outputs for this evaluation
|
|
266
|
+
await page.evaluate(() => {
|
|
267
|
+
window.__consoleOutputs = [];
|
|
268
|
+
});
|
|
269
|
+
// Inject libraries if not already done
|
|
270
|
+
if (!state.librariesInjected)
|
|
271
|
+
state.librariesInjected = await injectLibraries(session);
|
|
272
|
+
// Execute the script with REPL mode for console-like behavior
|
|
273
|
+
let evaluationResponse;
|
|
274
|
+
try {
|
|
275
|
+
evaluationResponse = await session.send("Runtime.evaluate", {
|
|
276
|
+
expression: transformedScript,
|
|
277
|
+
replMode: true, // Chrome DevTools console behavior (non-standard, tolerated by CDP)
|
|
278
|
+
includeCommandLineAPI: true, // Includes console utilities like $, $$, etc.
|
|
279
|
+
awaitPromise: true, // Automatically await promises
|
|
280
|
+
returnByValue: true, // Return the actual value, not just object reference
|
|
281
|
+
timeout: EVALUATE_TIMEOUT_MS,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
// If REPL mode fails due to wrapped functions, try without REPL mode
|
|
286
|
+
if (hasReturnOutsideFunction || hasTopLevelAwait) {
|
|
287
|
+
evaluationResponse = await session.send("Runtime.evaluate", {
|
|
288
|
+
expression: transformedScript,
|
|
289
|
+
replMode: false, // Disable REPL mode for wrapped functions
|
|
290
|
+
includeCommandLineAPI: true,
|
|
291
|
+
awaitPromise: true,
|
|
292
|
+
returnByValue: true,
|
|
293
|
+
timeout: EVALUATE_TIMEOUT_MS,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (evaluationResponse.exceptionDetails) {
|
|
301
|
+
const details = evaluationResponse.exceptionDetails;
|
|
302
|
+
// Try to get the actual error message from multiple sources
|
|
303
|
+
let errorMessage = details.text || "Evaluation failed";
|
|
304
|
+
// If we have exception details, try to extract more info
|
|
305
|
+
if (details.exception) {
|
|
306
|
+
if (details.exception.description)
|
|
307
|
+
errorMessage = details.exception.description;
|
|
308
|
+
else if (details.exception.value !== undefined)
|
|
309
|
+
errorMessage = String(details.exception.value);
|
|
310
|
+
}
|
|
311
|
+
// Add location information if available
|
|
312
|
+
if (details.lineNumber !== undefined) {
|
|
313
|
+
errorMessage += ` (at line ${details.lineNumber}`;
|
|
314
|
+
if (details.columnNumber !== undefined)
|
|
315
|
+
errorMessage += `:${details.columnNumber}`;
|
|
316
|
+
errorMessage += ")";
|
|
317
|
+
}
|
|
318
|
+
// Check if it's a timeout error
|
|
319
|
+
if (errorMessage.includes("Timeout") ||
|
|
320
|
+
errorMessage.includes("timeout"))
|
|
321
|
+
throw new Error(`Script execution timed out after ${EVALUATE_TIMEOUT_MS / 1000} seconds`);
|
|
322
|
+
throw new Error(errorMessage);
|
|
323
|
+
}
|
|
324
|
+
// Get console outputs from page context
|
|
325
|
+
const consoleOutputs = await page.evaluate(() => {
|
|
326
|
+
return window.__consoleOutputs || [];
|
|
327
|
+
});
|
|
328
|
+
// Format the output
|
|
329
|
+
const formatOutput = () => {
|
|
330
|
+
const MAX_OUTPUT_LENGTH = 10_000;
|
|
331
|
+
const MAX_CONSOLE_OUTPUTS = 100;
|
|
332
|
+
let output = "";
|
|
333
|
+
// Add console output if any
|
|
334
|
+
if (consoleOutputs && consoleOutputs.length > 0) {
|
|
335
|
+
// Limit number of console outputs and truncate each one
|
|
336
|
+
const truncatedOutputs = consoleOutputs
|
|
337
|
+
.slice(0, MAX_CONSOLE_OUTPUTS)
|
|
338
|
+
.map((o) => String(truncate(o, { maxStringLength: 1000 })));
|
|
339
|
+
output = truncatedOutputs.join("\n");
|
|
340
|
+
if (consoleOutputs.length > MAX_CONSOLE_OUTPUTS)
|
|
341
|
+
output += `\n... (${consoleOutputs.length - MAX_CONSOLE_OUTPUTS} more console outputs omitted)`;
|
|
342
|
+
}
|
|
343
|
+
// Add evaluation result only if:
|
|
344
|
+
// 1. It's not undefined, OR
|
|
345
|
+
// 2. There's no console output (so we need to show something)
|
|
346
|
+
const result = evaluationResponse.result.value;
|
|
347
|
+
if (result !== undefined) {
|
|
348
|
+
let resultString;
|
|
349
|
+
try {
|
|
350
|
+
resultString =
|
|
351
|
+
typeof result === "string"
|
|
352
|
+
? result
|
|
353
|
+
: JSON.stringify(result, null, 2);
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
// Handle circular references or other stringify errors
|
|
357
|
+
resultString = String(result);
|
|
358
|
+
}
|
|
359
|
+
// Truncate result if too long (first structure-limit, then clamp string)
|
|
360
|
+
const truncatedValue = truncate(result, {
|
|
361
|
+
maxStringLength: 200,
|
|
362
|
+
maxItems: 20,
|
|
363
|
+
});
|
|
364
|
+
let limited = "";
|
|
365
|
+
try {
|
|
366
|
+
limited =
|
|
367
|
+
typeof truncatedValue === "string"
|
|
368
|
+
? truncatedValue
|
|
369
|
+
: JSON.stringify(truncatedValue, null, 2);
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
limited = resultString;
|
|
373
|
+
}
|
|
374
|
+
const truncatedResult = limited.length > 5000
|
|
375
|
+
? limited.substring(0, 5000) + "\n... (truncated)"
|
|
376
|
+
: limited;
|
|
377
|
+
if (output)
|
|
378
|
+
output += "\n" + truncatedResult;
|
|
379
|
+
else
|
|
380
|
+
output = truncatedResult;
|
|
381
|
+
}
|
|
382
|
+
else if (!consoleOutputs || consoleOutputs.length === 0) {
|
|
383
|
+
// Only show "undefined" if there's no console output
|
|
384
|
+
output = "undefined";
|
|
385
|
+
}
|
|
386
|
+
// Final truncation of the entire output
|
|
387
|
+
if (output.length > MAX_OUTPUT_LENGTH) {
|
|
388
|
+
output =
|
|
389
|
+
output.substring(0, MAX_OUTPUT_LENGTH) +
|
|
390
|
+
"\n... (output truncated)";
|
|
391
|
+
}
|
|
392
|
+
return output;
|
|
393
|
+
};
|
|
394
|
+
response.addResult(formatOutput());
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
const errorToMessage = (e) => {
|
|
398
|
+
if (e instanceof Error)
|
|
399
|
+
return e.message;
|
|
400
|
+
if (typeof e === "string")
|
|
401
|
+
return e;
|
|
402
|
+
try {
|
|
403
|
+
return JSON.stringify(e);
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return "Unknown error occurred";
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
response.addError(errorToMessage(error));
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
export default [repl];
|
|
@@ -0,0 +1,103 @@
|
|
|
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 { z } from "zod";
|
|
17
|
+
import * as javascript from "../utils/codegen.js";
|
|
18
|
+
import { defineTabTool } from "./tool.js";
|
|
19
|
+
import { generateLocator } from "./utils.js";
|
|
20
|
+
const screenshotSchema = z
|
|
21
|
+
.object({
|
|
22
|
+
type: z
|
|
23
|
+
.enum(["png", "jpeg"])
|
|
24
|
+
.default("png")
|
|
25
|
+
.describe("Image format for the screenshot. Default is png."),
|
|
26
|
+
filename: z
|
|
27
|
+
.string()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe("File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified."),
|
|
30
|
+
element: z
|
|
31
|
+
.string()
|
|
32
|
+
.optional()
|
|
33
|
+
.describe("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."),
|
|
34
|
+
ref: z
|
|
35
|
+
.string()
|
|
36
|
+
.optional()
|
|
37
|
+
.describe("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."),
|
|
38
|
+
fullPage: z
|
|
39
|
+
.boolean()
|
|
40
|
+
.optional()
|
|
41
|
+
.describe("When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots."),
|
|
42
|
+
})
|
|
43
|
+
.refine((data) => {
|
|
44
|
+
return !!data.element === !!data.ref;
|
|
45
|
+
}, {
|
|
46
|
+
message: "Both element and ref must be provided or neither.",
|
|
47
|
+
path: ["ref", "element"],
|
|
48
|
+
})
|
|
49
|
+
.refine((data) => {
|
|
50
|
+
return !(data.fullPage && (data.element || data.ref));
|
|
51
|
+
}, {
|
|
52
|
+
message: "fullPage cannot be used with element screenshots.",
|
|
53
|
+
path: ["fullPage"],
|
|
54
|
+
});
|
|
55
|
+
const screenshot = defineTabTool({
|
|
56
|
+
capability: "core",
|
|
57
|
+
schema: {
|
|
58
|
+
name: "browser_take_screenshot",
|
|
59
|
+
title: "Take a screenshot",
|
|
60
|
+
description: `Take a screenshot of the current page. You can't perform actions based on the screenshot, use browser_snapshot for actions.`,
|
|
61
|
+
inputSchema: screenshotSchema,
|
|
62
|
+
type: "readOnly",
|
|
63
|
+
},
|
|
64
|
+
handle: async (tab, params, response) => {
|
|
65
|
+
const fileType = params.type || "png";
|
|
66
|
+
const fileName = await tab.context.outputFile(params.filename ?? `page-${new Date().toISOString()}.${fileType}`);
|
|
67
|
+
const options = {
|
|
68
|
+
type: fileType,
|
|
69
|
+
quality: fileType === "png" ? undefined : 90,
|
|
70
|
+
scale: "css",
|
|
71
|
+
path: fileName,
|
|
72
|
+
...(params.fullPage !== undefined && { fullPage: params.fullPage }),
|
|
73
|
+
};
|
|
74
|
+
const isElementScreenshot = params.element && params.ref;
|
|
75
|
+
const screenshotTarget = isElementScreenshot
|
|
76
|
+
? params.element
|
|
77
|
+
: params.fullPage
|
|
78
|
+
? "full page"
|
|
79
|
+
: "viewport";
|
|
80
|
+
response.addCode(`// Screenshot ${screenshotTarget} and save it as ${fileName}`);
|
|
81
|
+
// Only get snapshot when element screenshot is needed
|
|
82
|
+
const locator = params.ref
|
|
83
|
+
? await tab.refLocator({ element: params.element || "", ref: params.ref })
|
|
84
|
+
: null;
|
|
85
|
+
if (locator)
|
|
86
|
+
response.addCode(`await page.${await generateLocator(locator)}.screenshot(${javascript.formatObject(options)});`);
|
|
87
|
+
else
|
|
88
|
+
response.addCode(`await page.screenshot(${javascript.formatObject(options)});`);
|
|
89
|
+
const buffer = locator
|
|
90
|
+
? await locator.screenshot(options)
|
|
91
|
+
: await tab.page.screenshot(options);
|
|
92
|
+
response.addResult(`Took the ${screenshotTarget} screenshot and saved it as ${fileName}`);
|
|
93
|
+
// https://github.com/microsoft/playwright-mcp/issues/817
|
|
94
|
+
// Never return large images to LLM, saving them to the file system is enough.
|
|
95
|
+
if (!params.fullPage) {
|
|
96
|
+
response.addImage({
|
|
97
|
+
contentType: fileType === "png" ? "image/png" : "image/jpeg",
|
|
98
|
+
data: buffer,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
export default [screenshot];
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import ms from "ms";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { defineTabTool } from "./tool.js";
|
|
4
|
+
/**
|
|
5
|
+
* Generate random number between min and max
|
|
6
|
+
*/
|
|
7
|
+
function randomBetween(min, max) {
|
|
8
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Generate human-like scroll pattern
|
|
12
|
+
*/
|
|
13
|
+
function generateScrollPattern(totalDelta) {
|
|
14
|
+
const pattern = [];
|
|
15
|
+
const abs = Math.abs(totalDelta);
|
|
16
|
+
const direction = totalDelta > 0 ? 1 : -1;
|
|
17
|
+
// Faster profile: fewer steps overall, still eased
|
|
18
|
+
const steps = abs < 250
|
|
19
|
+
? randomBetween(3, 6)
|
|
20
|
+
: abs < 1200
|
|
21
|
+
? randomBetween(4, 10)
|
|
22
|
+
: randomBetween(8, 16);
|
|
23
|
+
const base = abs / steps;
|
|
24
|
+
const easeInOut = (t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t); // 0..1
|
|
25
|
+
for (let i = 0; i < steps; i++) {
|
|
26
|
+
const t = steps > 1 ? i / (steps - 1) : 0;
|
|
27
|
+
const momentum = 0.6 + easeInOut(t) * 0.7; // ~0.6 .. 1.3
|
|
28
|
+
const variation = 0.1 + Math.random() * 0.2; // +/- ~15%
|
|
29
|
+
const scrollAmount = base * momentum * (1 + (Math.random() - 0.5) * variation);
|
|
30
|
+
// Larger per-event wheel to reduce total time: clamp 10..180px
|
|
31
|
+
const clamped = Math.max(10, Math.min(180, Math.round(scrollAmount)));
|
|
32
|
+
// Short delays: 15–60ms + small size factor
|
|
33
|
+
const delay = randomBetween(ms("15ms"), ms("60ms")) + Math.floor(clamped * 0.08);
|
|
34
|
+
pattern.push({
|
|
35
|
+
delta: clamped * direction,
|
|
36
|
+
delay,
|
|
37
|
+
});
|
|
38
|
+
// Rare micro-pause (short) to keep a bit of human feel
|
|
39
|
+
if (Math.random() < 0.05 && i < steps - 1)
|
|
40
|
+
pattern.push({ delta: 0, delay: randomBetween(ms("30ms"), ms("80ms")) });
|
|
41
|
+
}
|
|
42
|
+
// Smaller, rarer overshoot for long moves only
|
|
43
|
+
if (abs > 600 && Math.random() < 0.1) {
|
|
44
|
+
pattern.push({
|
|
45
|
+
delta: -direction * randomBetween(6, 18),
|
|
46
|
+
delay: randomBetween(ms("60ms"), ms("120ms")),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return pattern;
|
|
50
|
+
}
|
|
51
|
+
const scrollSchema = z.object({
|
|
52
|
+
amount: z
|
|
53
|
+
.number()
|
|
54
|
+
.describe("Vertical scroll amount in pixels (positive scrolls down, negative up)"),
|
|
55
|
+
});
|
|
56
|
+
const scrollWheel = defineTabTool({
|
|
57
|
+
capability: "core",
|
|
58
|
+
schema: {
|
|
59
|
+
name: "browser_scroll",
|
|
60
|
+
title: "Scroll page",
|
|
61
|
+
description: "Scroll the page using mouse wheel with human-like behavior",
|
|
62
|
+
inputSchema: scrollSchema,
|
|
63
|
+
type: "readOnly",
|
|
64
|
+
},
|
|
65
|
+
handle: async (tab, params, response) => {
|
|
66
|
+
const requestedDeltaY = params.amount || 0;
|
|
67
|
+
// Capture initial scroll position
|
|
68
|
+
const initialScrollPosition = await tab.page.evaluate(() => ({
|
|
69
|
+
x: window.scrollX,
|
|
70
|
+
y: window.scrollY,
|
|
71
|
+
}));
|
|
72
|
+
response.addCode(`// Scroll page by ${requestedDeltaY}px (vertical)`);
|
|
73
|
+
await tab.waitForCompletion(async () => {
|
|
74
|
+
// Position mouse at a slightly randomized viewport center
|
|
75
|
+
const viewport = tab.page.viewportSize();
|
|
76
|
+
if (viewport) {
|
|
77
|
+
const x = viewport.width / 2 + randomBetween(-100, 100);
|
|
78
|
+
const y = viewport.height / 2 + randomBetween(-100, 100);
|
|
79
|
+
await tab.page.mouse.move(x, y);
|
|
80
|
+
response.addCode(`await page.mouse.move(${x}, ${y});`);
|
|
81
|
+
}
|
|
82
|
+
// Add initial "hover" delay (short)
|
|
83
|
+
await tab.page.waitForTimeout(randomBetween(ms("40ms"), ms("120ms")));
|
|
84
|
+
// Generate and execute human-like scroll pattern
|
|
85
|
+
const scrollPatternY = requestedDeltaY
|
|
86
|
+
? generateScrollPattern(requestedDeltaY)
|
|
87
|
+
: [];
|
|
88
|
+
const maxSteps = scrollPatternY.length;
|
|
89
|
+
for (let i = 0; i < maxSteps; i++) {
|
|
90
|
+
let deltaY = scrollPatternY[i]?.delta || 0;
|
|
91
|
+
const delay = scrollPatternY[i]?.delay || 0;
|
|
92
|
+
// Clamp vertical delta to avoid scrolling past page edges
|
|
93
|
+
const allowedY = await tab.page.evaluate((dy) => {
|
|
94
|
+
const doc = document.scrollingElement || document.documentElement;
|
|
95
|
+
const maxY = Math.max(0, (doc.scrollHeight || document.documentElement.scrollHeight) -
|
|
96
|
+
window.innerHeight);
|
|
97
|
+
const curY = window.scrollY || doc.scrollTop || 0;
|
|
98
|
+
let a = dy;
|
|
99
|
+
if (curY + dy < 0)
|
|
100
|
+
a = -curY;
|
|
101
|
+
if (curY + dy > maxY)
|
|
102
|
+
a = maxY - curY;
|
|
103
|
+
return a;
|
|
104
|
+
}, deltaY);
|
|
105
|
+
deltaY = allowedY;
|
|
106
|
+
// Skip if nothing to do (already at edge)
|
|
107
|
+
if (deltaY === 0)
|
|
108
|
+
continue;
|
|
109
|
+
await tab.page.mouse.wheel(0, deltaY);
|
|
110
|
+
response.addCode(`await page.mouse.wheel(0, ${deltaY});`);
|
|
111
|
+
// Wait between scroll events
|
|
112
|
+
if (i < maxSteps - 1 && delay > 0) {
|
|
113
|
+
await tab.page.waitForTimeout(delay);
|
|
114
|
+
response.addCode(`await page.waitForTimeout(${delay});`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Short final pause
|
|
118
|
+
const finalDelay = randomBetween(ms("80ms"), ms("200ms"));
|
|
119
|
+
await tab.page.waitForTimeout(finalDelay);
|
|
120
|
+
response.addCode(`await page.waitForTimeout(${finalDelay});`);
|
|
121
|
+
});
|
|
122
|
+
// Capture final scroll position
|
|
123
|
+
const finalScrollPosition = await tab.page.evaluate(() => ({
|
|
124
|
+
x: window.scrollX,
|
|
125
|
+
y: window.scrollY,
|
|
126
|
+
}));
|
|
127
|
+
// Always show scroll position for scroll tool
|
|
128
|
+
response.addResult(`Scroll position:\nBefore: x=${initialScrollPosition.x}, y=${initialScrollPosition.y}\nAfter: x=${finalScrollPosition.x}, y=${finalScrollPosition.y}`);
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
export default [scrollWheel];
|