@wordbricks/playwright-mcp 0.1.25 → 0.1.26

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.
Files changed (82) hide show
  1. package/lib/browserContextFactory.js +399 -0
  2. package/lib/browserServerBackend.js +86 -0
  3. package/lib/config.js +300 -0
  4. package/lib/context.js +311 -0
  5. package/lib/extension/cdpRelay.js +352 -0
  6. package/lib/extension/extensionContextFactory.js +56 -0
  7. package/lib/frameworkPatterns.js +35 -0
  8. package/lib/hooks/antiBotDetectionHook.js +178 -0
  9. package/lib/hooks/core.js +145 -0
  10. package/lib/hooks/eventConsumer.js +52 -0
  11. package/lib/hooks/events.js +42 -0
  12. package/lib/hooks/formatToolCallEvent.js +12 -0
  13. package/lib/hooks/frameworkStateHook.js +182 -0
  14. package/lib/hooks/grouping.js +72 -0
  15. package/lib/hooks/jsonLdDetectionHook.js +182 -0
  16. package/lib/hooks/networkFilters.js +82 -0
  17. package/lib/hooks/networkSetup.js +61 -0
  18. package/lib/hooks/networkTrackingHook.js +67 -0
  19. package/lib/hooks/pageHeightHook.js +75 -0
  20. package/lib/hooks/registry.js +41 -0
  21. package/lib/hooks/requireTabHook.js +26 -0
  22. package/lib/hooks/schema.js +89 -0
  23. package/lib/hooks/waitHook.js +33 -0
  24. package/lib/index.js +41 -0
  25. package/lib/mcp/inProcessTransport.js +71 -0
  26. package/lib/mcp/proxyBackend.js +130 -0
  27. package/lib/mcp/server.js +91 -0
  28. package/lib/mcp/tool.js +44 -0
  29. package/lib/mcp/transport.js +188 -0
  30. package/lib/playwrightTransformer.js +520 -0
  31. package/lib/program.js +112 -0
  32. package/lib/response.js +192 -0
  33. package/lib/sessionLog.js +123 -0
  34. package/lib/tab.js +251 -0
  35. package/lib/tools/common.js +55 -0
  36. package/lib/tools/console.js +33 -0
  37. package/lib/tools/dialogs.js +50 -0
  38. package/lib/tools/evaluate.js +62 -0
  39. package/lib/tools/extractFrameworkState.js +225 -0
  40. package/lib/tools/files.js +48 -0
  41. package/lib/tools/form.js +66 -0
  42. package/lib/tools/getSnapshot.js +36 -0
  43. package/lib/tools/getVisibleHtml.js +68 -0
  44. package/lib/tools/install.js +51 -0
  45. package/lib/tools/keyboard.js +83 -0
  46. package/lib/tools/mouse.js +97 -0
  47. package/lib/tools/navigate.js +66 -0
  48. package/lib/tools/network.js +121 -0
  49. package/lib/tools/networkDetail.js +238 -0
  50. package/lib/tools/networkSearch/bodySearch.js +161 -0
  51. package/lib/tools/networkSearch/grouping.js +37 -0
  52. package/lib/tools/networkSearch/helpers.js +32 -0
  53. package/lib/tools/networkSearch/searchHtml.js +76 -0
  54. package/lib/tools/networkSearch/types.js +1 -0
  55. package/lib/tools/networkSearch/urlSearch.js +124 -0
  56. package/lib/tools/networkSearch.js +278 -0
  57. package/lib/tools/pdf.js +41 -0
  58. package/lib/tools/repl.js +414 -0
  59. package/lib/tools/screenshot.js +103 -0
  60. package/lib/tools/scroll.js +131 -0
  61. package/lib/tools/snapshot.js +161 -0
  62. package/lib/tools/tabs.js +62 -0
  63. package/lib/tools/tool.js +35 -0
  64. package/lib/tools/utils.js +78 -0
  65. package/lib/tools/wait.js +60 -0
  66. package/lib/tools.js +68 -0
  67. package/lib/utils/adBlockFilter.js +90 -0
  68. package/lib/utils/codegen.js +55 -0
  69. package/lib/utils/extensionPath.js +10 -0
  70. package/lib/utils/fileUtils.js +40 -0
  71. package/lib/utils/graphql.js +269 -0
  72. package/lib/utils/guid.js +22 -0
  73. package/lib/utils/httpServer.js +39 -0
  74. package/lib/utils/log.js +21 -0
  75. package/lib/utils/manualPromise.js +111 -0
  76. package/lib/utils/networkFormat.js +14 -0
  77. package/lib/utils/package.js +20 -0
  78. package/lib/utils/result.js +2 -0
  79. package/lib/utils/sanitizeHtml.js +130 -0
  80. package/lib/utils/truncate.js +103 -0
  81. package/lib/utils/withTimeout.js +7 -0
  82. 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];