browserforce 1.0.0
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 +293 -0
- package/bin.js +269 -0
- package/mcp/package.json +14 -0
- package/mcp/src/exec-engine.js +424 -0
- package/mcp/src/index.js +372 -0
- package/mcp/src/snapshot.js +197 -0
- package/package.json +52 -0
- package/relay/package.json +1 -0
- package/relay/src/index.js +847 -0
- package/skills/browserforce/SKILL.md +123 -0
package/mcp/src/index.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// BrowserForce — MCP Server
|
|
2
|
+
// 2-tool architecture: execute (run Playwright code) + reset (reconnect)
|
|
3
|
+
// Connects to the relay via Playwright's CDP client.
|
|
4
|
+
|
|
5
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { chromium } from 'playwright-core';
|
|
9
|
+
import {
|
|
10
|
+
getCdpUrl, CodeExecutionTimeoutError, buildExecContext, runCode, formatResult,
|
|
11
|
+
} from './exec-engine.js';
|
|
12
|
+
|
|
13
|
+
// ─── Console Log Capture ─────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const MAX_LOGS_PER_PAGE = 5000;
|
|
16
|
+
const consoleLogs = new Map();
|
|
17
|
+
const pagesWithListeners = new WeakSet();
|
|
18
|
+
let contextListenerAttached = false;
|
|
19
|
+
|
|
20
|
+
function setupConsoleCapture(page) {
|
|
21
|
+
if (pagesWithListeners.has(page)) return;
|
|
22
|
+
pagesWithListeners.add(page);
|
|
23
|
+
|
|
24
|
+
consoleLogs.set(page, []);
|
|
25
|
+
|
|
26
|
+
page.on('console', (msg) => {
|
|
27
|
+
try {
|
|
28
|
+
const entry = `[${msg.type()}] ${msg.text()}`;
|
|
29
|
+
let logs = consoleLogs.get(page);
|
|
30
|
+
if (!logs) {
|
|
31
|
+
logs = [];
|
|
32
|
+
consoleLogs.set(page, logs);
|
|
33
|
+
}
|
|
34
|
+
logs.push(entry);
|
|
35
|
+
if (logs.length > MAX_LOGS_PER_PAGE) {
|
|
36
|
+
logs.shift();
|
|
37
|
+
}
|
|
38
|
+
} catch { /* msg.text() can throw if page navigated */ }
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
page.on('framenavigated', (frame) => {
|
|
42
|
+
if (frame === page.mainFrame()) {
|
|
43
|
+
consoleLogs.set(page, []);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
page.on('close', () => {
|
|
48
|
+
consoleLogs.delete(page);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function ensureAllPagesCapture() {
|
|
53
|
+
try {
|
|
54
|
+
const pages = getPages();
|
|
55
|
+
for (const page of pages) {
|
|
56
|
+
setupConsoleCapture(page);
|
|
57
|
+
}
|
|
58
|
+
} catch { /* not connected yet */ }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ─── Browser Connection ──────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
let browser = null;
|
|
64
|
+
|
|
65
|
+
async function ensureBrowser() {
|
|
66
|
+
if (browser?.isConnected()) return;
|
|
67
|
+
const cdpUrl = getCdpUrl();
|
|
68
|
+
browser = await chromium.connectOverCDP(cdpUrl);
|
|
69
|
+
browser.on('disconnected', () => {
|
|
70
|
+
browser = null;
|
|
71
|
+
contextListenerAttached = false;
|
|
72
|
+
consoleLogs.clear();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const ctx = browser.contexts()[0];
|
|
77
|
+
if (ctx && !contextListenerAttached) {
|
|
78
|
+
ctx.on('page', (page) => setupConsoleCapture(page));
|
|
79
|
+
contextListenerAttached = true;
|
|
80
|
+
for (const page of ctx.pages()) {
|
|
81
|
+
setupConsoleCapture(page);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch { /* context not ready yet — capture will attach lazily */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getContext() {
|
|
88
|
+
if (!browser?.isConnected()) throw new Error('Not connected to relay. Is the relay running?');
|
|
89
|
+
const contexts = browser.contexts();
|
|
90
|
+
if (contexts.length === 0) throw new Error('No browser context available');
|
|
91
|
+
return contexts[0];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getPages() {
|
|
95
|
+
return getContext().pages();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Persistent State ────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
let userState = {};
|
|
101
|
+
|
|
102
|
+
// ─── MCP Server ──────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
const server = new McpServer({
|
|
105
|
+
name: 'browserforce',
|
|
106
|
+
version: '1.0.0',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ─── Execute Tool Prompt ───────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
const EXECUTE_PROMPT = `Run Playwright JavaScript in the user's real Chrome browser.
|
|
112
|
+
This is their actual browser with real cookies, sessions, and tabs — not a sandbox.
|
|
113
|
+
|
|
114
|
+
═══ AVAILABLE SCOPE ═══
|
|
115
|
+
|
|
116
|
+
Variables:
|
|
117
|
+
page Default page (first tab in context — shared, avoid navigating it)
|
|
118
|
+
context Browser context — access all pages via context.pages()
|
|
119
|
+
state Persistent object across calls (cleared on reset). Store your working page here.
|
|
120
|
+
|
|
121
|
+
Helpers:
|
|
122
|
+
snapshot({ selector?, search? }) Accessibility tree as text. 10-100x cheaper than screenshots.
|
|
123
|
+
waitForPageLoad({ timeout? }) Smart load detection (filters analytics/ads, polls readyState).
|
|
124
|
+
getLogs({ count? }) Browser console logs captured for current page.
|
|
125
|
+
clearLogs() Clear captured console logs.
|
|
126
|
+
|
|
127
|
+
Globals: fetch, URL, URLSearchParams, Buffer, setTimeout, clearTimeout, TextEncoder, TextDecoder
|
|
128
|
+
|
|
129
|
+
═══ FIRST CALL — PAGE SETUP ═══
|
|
130
|
+
|
|
131
|
+
IMPORTANT: Do NOT navigate the user's existing tabs. Always create or reuse a dedicated tab.
|
|
132
|
+
|
|
133
|
+
On your first call, initialize state.page:
|
|
134
|
+
// Reuse an about:blank tab if one exists, otherwise create a new one
|
|
135
|
+
state.page = context.pages().find(p => p.url() === 'about:blank') || await context.newPage();
|
|
136
|
+
await state.page.goto('https://example.com');
|
|
137
|
+
await waitForPageLoad();
|
|
138
|
+
return await snapshot();
|
|
139
|
+
|
|
140
|
+
After setup, use state.page for ALL subsequent operations — not the default page variable.
|
|
141
|
+
If state.page was closed or navigated away, recreate it:
|
|
142
|
+
if (!state.page || state.page.isClosed()) {
|
|
143
|
+
state.page = await context.newPage();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
═══ WORKFLOW — OBSERVE → ACT → OBSERVE ═══
|
|
147
|
+
|
|
148
|
+
After every action, verify its result before proceeding:
|
|
149
|
+
|
|
150
|
+
1. OBSERVE: snapshot() to understand current page state
|
|
151
|
+
2. ACT: Perform ONE action (click, type, navigate, etc.)
|
|
152
|
+
3. OBSERVE: snapshot() again to verify the action worked
|
|
153
|
+
|
|
154
|
+
Never chain multiple actions blindly. If you click a button, verify it worked before clicking the next.
|
|
155
|
+
Each execute call should do ONE meaningful action and return verification.
|
|
156
|
+
|
|
157
|
+
When navigating:
|
|
158
|
+
await state.page.goto(url);
|
|
159
|
+
await waitForPageLoad();
|
|
160
|
+
return await snapshot();
|
|
161
|
+
|
|
162
|
+
When clicking:
|
|
163
|
+
await state.page.locator('role=button[name="Submit"]').click();
|
|
164
|
+
await waitForPageLoad();
|
|
165
|
+
return await snapshot();
|
|
166
|
+
|
|
167
|
+
When filling forms:
|
|
168
|
+
await state.page.locator('role=textbox[name="Email"]').fill('user@example.com');
|
|
169
|
+
return await snapshot();
|
|
170
|
+
|
|
171
|
+
═══ SNAPSHOT FIRST ═══
|
|
172
|
+
|
|
173
|
+
ALWAYS prefer snapshot() over screenshot():
|
|
174
|
+
- snapshot() returns a text accessibility tree — fast, cheap, searchable
|
|
175
|
+
- screenshot() returns a PNG image — expensive, requires vision processing
|
|
176
|
+
|
|
177
|
+
Use snapshot() for:
|
|
178
|
+
✓ Reading page content and text
|
|
179
|
+
✓ Finding interactive elements (buttons, links, inputs)
|
|
180
|
+
✓ Verifying actions succeeded
|
|
181
|
+
✓ Checking if a page loaded correctly
|
|
182
|
+
|
|
183
|
+
Use screenshot() ONLY for:
|
|
184
|
+
✓ Visual layout verification (grids, alignment, spacing)
|
|
185
|
+
✓ Seeing images, charts, or visual content
|
|
186
|
+
✓ Debugging when snapshot doesn't show the issue
|
|
187
|
+
|
|
188
|
+
Targeted snapshots: snapshot({ search: /pattern/i }) filters the tree.
|
|
189
|
+
Scoped snapshots: snapshot({ selector: '#main' }) limits to a subtree.
|
|
190
|
+
|
|
191
|
+
═══ PAGE MANAGEMENT ═══
|
|
192
|
+
|
|
193
|
+
Listing tabs: const pages = context.pages();
|
|
194
|
+
Creating a tab: const p = await context.newPage();
|
|
195
|
+
Navigating: await state.page.goto(url);
|
|
196
|
+
Current URL: state.page.url()
|
|
197
|
+
Page title: await state.page.title()
|
|
198
|
+
|
|
199
|
+
context.pages() returns ALL open tabs. Index 0 is usually the user's original tab.
|
|
200
|
+
Store your working page in state.page to avoid losing track of it.
|
|
201
|
+
|
|
202
|
+
For multi-tab workflows:
|
|
203
|
+
const pages = context.pages();
|
|
204
|
+
// Find a specific tab by URL
|
|
205
|
+
const gmail = pages.find(p => p.url().includes('mail.google'));
|
|
206
|
+
|
|
207
|
+
═══ INTERACTING WITH ELEMENTS ═══
|
|
208
|
+
|
|
209
|
+
Use Playwright locators with accessibility roles (from snapshot output):
|
|
210
|
+
await state.page.locator('role=button[name="Sign in"]').click();
|
|
211
|
+
await state.page.locator('role=textbox[name="Search"]').fill('query');
|
|
212
|
+
await state.page.locator('role=link[name="Settings"]').click();
|
|
213
|
+
|
|
214
|
+
If snapshot shows [ref=some-id] for an element with a data-testid or id:
|
|
215
|
+
await state.page.locator('[data-testid="some-id"]').click();
|
|
216
|
+
|
|
217
|
+
For text content:
|
|
218
|
+
const text = await state.page.locator('role=heading').textContent();
|
|
219
|
+
|
|
220
|
+
═══ COMMON PATTERNS ═══
|
|
221
|
+
|
|
222
|
+
Navigate and read:
|
|
223
|
+
await state.page.goto('https://example.com');
|
|
224
|
+
await waitForPageLoad();
|
|
225
|
+
return await snapshot();
|
|
226
|
+
|
|
227
|
+
Click and verify:
|
|
228
|
+
await state.page.locator('role=button[name="Next"]').click();
|
|
229
|
+
await waitForPageLoad();
|
|
230
|
+
return await snapshot();
|
|
231
|
+
|
|
232
|
+
Fill form and submit:
|
|
233
|
+
await state.page.locator('role=textbox[name="Username"]').fill('user');
|
|
234
|
+
await state.page.locator('role=textbox[name="Password"]').fill('pass');
|
|
235
|
+
await state.page.locator('role=button[name="Login"]').click();
|
|
236
|
+
await waitForPageLoad();
|
|
237
|
+
return await snapshot();
|
|
238
|
+
|
|
239
|
+
Extract data:
|
|
240
|
+
return await state.page.evaluate(() => {
|
|
241
|
+
return document.querySelector('.price').textContent;
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
Wait for specific element:
|
|
245
|
+
await state.page.locator('role=heading[name="Dashboard"]').waitFor();
|
|
246
|
+
return await snapshot();
|
|
247
|
+
|
|
248
|
+
Debug with console logs:
|
|
249
|
+
return getLogs({ count: 20 });
|
|
250
|
+
|
|
251
|
+
═══ ANTI-PATTERNS ═══
|
|
252
|
+
|
|
253
|
+
✗ Don't navigate the user's existing tabs — create your own via context.newPage()
|
|
254
|
+
✗ Don't screenshot() to read text — use snapshot()
|
|
255
|
+
✗ Don't chain actions without verifying — observe after each action
|
|
256
|
+
✗ Don't use page.waitForTimeout() — use waitForPageLoad() or waitFor()
|
|
257
|
+
✗ Don't forget to return a value — every call should return verification
|
|
258
|
+
✗ Don't write complex multi-step scripts — split into separate execute calls
|
|
259
|
+
✗ Don't use page variable directly — use state.page after first call setup
|
|
260
|
+
|
|
261
|
+
═══ ERROR RECOVERY ═══
|
|
262
|
+
|
|
263
|
+
If page closed: state.page = await context.newPage();
|
|
264
|
+
If navigation fails: Check state.page.url() to see where you actually are
|
|
265
|
+
If element missing: Use snapshot({ search: /element/ }) to find it
|
|
266
|
+
If connection lost: Call the reset tool, then re-initialize state.page
|
|
267
|
+
If timeout: Increase timeout param, or break into smaller steps
|
|
268
|
+
|
|
269
|
+
═══ API REFERENCE ═══
|
|
270
|
+
|
|
271
|
+
snapshot(options?)
|
|
272
|
+
options.selector CSS selector to scope the snapshot (e.g., '#main', '.sidebar')
|
|
273
|
+
options.search Regex string to filter tree nodes (e.g., 'button|link')
|
|
274
|
+
Returns: Text accessibility tree with interactive element refs
|
|
275
|
+
|
|
276
|
+
waitForPageLoad(options?)
|
|
277
|
+
options.timeout Max wait in ms (default: 30000)
|
|
278
|
+
Returns: { success, readyState, pendingRequests, waitTimeMs, timedOut }
|
|
279
|
+
Filters analytics/ad requests that never finish. Polls document.readyState.
|
|
280
|
+
|
|
281
|
+
getLogs(options?)
|
|
282
|
+
options.count Number of recent entries (default: all)
|
|
283
|
+
Returns: Array of "[type] message" strings from browser console
|
|
284
|
+
|
|
285
|
+
clearLogs()
|
|
286
|
+
Clears captured console logs for current page.
|
|
287
|
+
|
|
288
|
+
state
|
|
289
|
+
Persistent object — survives across execute calls. Cleared on reset.
|
|
290
|
+
Use state.page, state.data, state.anything to preserve working state.`;
|
|
291
|
+
|
|
292
|
+
server.tool(
|
|
293
|
+
'execute',
|
|
294
|
+
EXECUTE_PROMPT,
|
|
295
|
+
{
|
|
296
|
+
code: z.string().describe('JavaScript to run — page/context/state/snapshot/waitForPageLoad/getLogs in scope'),
|
|
297
|
+
timeout: z.number().optional().describe('Max execution time in ms (default: 30000)'),
|
|
298
|
+
},
|
|
299
|
+
async ({ code, timeout = 30000 }) => {
|
|
300
|
+
await ensureBrowser();
|
|
301
|
+
ensureAllPagesCapture();
|
|
302
|
+
const ctx = getContext();
|
|
303
|
+
const pages = ctx.pages();
|
|
304
|
+
const page = pages[0] || null;
|
|
305
|
+
|
|
306
|
+
if (page) setupConsoleCapture(page);
|
|
307
|
+
const execCtx = buildExecContext(page, ctx, userState, {
|
|
308
|
+
consoleLogs, setupConsoleCapture,
|
|
309
|
+
});
|
|
310
|
+
try {
|
|
311
|
+
const result = await runCode(code, execCtx, timeout);
|
|
312
|
+
const formatted = formatResult(result);
|
|
313
|
+
return { content: [formatted] };
|
|
314
|
+
} catch (err) {
|
|
315
|
+
const isTimeout = err instanceof CodeExecutionTimeoutError;
|
|
316
|
+
const hint = isTimeout ? '' : '\n\n[If connection lost, call reset tool to reconnect]';
|
|
317
|
+
return {
|
|
318
|
+
content: [{ type: 'text', text: `Error: ${err.message}${hint}` }],
|
|
319
|
+
isError: true,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
server.tool(
|
|
326
|
+
'reset',
|
|
327
|
+
'Reconnects to the relay, reinitializes the browser context, and clears persistent state. Use when: connection lost, pages closed unexpectedly, or state is corrupt.',
|
|
328
|
+
{},
|
|
329
|
+
async () => {
|
|
330
|
+
if (browser) {
|
|
331
|
+
try { await browser.close(); } catch { /* connection may already be dead */ }
|
|
332
|
+
}
|
|
333
|
+
browser = null;
|
|
334
|
+
userState = {};
|
|
335
|
+
contextListenerAttached = false;
|
|
336
|
+
consoleLogs.clear();
|
|
337
|
+
try {
|
|
338
|
+
await ensureBrowser();
|
|
339
|
+
ensureAllPagesCapture();
|
|
340
|
+
const pages = getPages();
|
|
341
|
+
return {
|
|
342
|
+
content: [{ type: 'text', text: `Reset complete. ${pages.length} page(s) available. Current URL: ${pages[0]?.url() ?? 'none'}` }],
|
|
343
|
+
};
|
|
344
|
+
} catch (err) {
|
|
345
|
+
return {
|
|
346
|
+
content: [{ type: 'text', text: `Reset failed: ${err.message}` }],
|
|
347
|
+
isError: true,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
// ─── Start Server ────────────────────────────────────────────────────────────
|
|
354
|
+
|
|
355
|
+
async function main() {
|
|
356
|
+
try {
|
|
357
|
+
await ensureBrowser();
|
|
358
|
+
process.stderr.write('[bf-mcp] Connected to relay\n');
|
|
359
|
+
} catch (err) {
|
|
360
|
+
process.stderr.write(`[bf-mcp] Warning: ${err.message}\n`);
|
|
361
|
+
process.stderr.write('[bf-mcp] Tools will attempt to connect on first use\n');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const transport = new StdioServerTransport();
|
|
365
|
+
await server.connect(transport);
|
|
366
|
+
process.stderr.write('[bf-mcp] MCP server running\n');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
main().catch((err) => {
|
|
370
|
+
process.stderr.write(`[bf-mcp] Fatal: ${err.message}\n`);
|
|
371
|
+
process.exit(1);
|
|
372
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// BrowserForce — Accessibility Snapshot Helpers
|
|
2
|
+
// Builds a text-based accessibility tree from Playwright's AX snapshot,
|
|
3
|
+
// with interactive element refs mapped to Playwright locators.
|
|
4
|
+
|
|
5
|
+
import { createPatch } from 'diff';
|
|
6
|
+
|
|
7
|
+
export const INTERACTIVE_ROLES = new Set([
|
|
8
|
+
'button', 'link', 'textbox', 'combobox', 'searchbox',
|
|
9
|
+
'checkbox', 'radio', 'slider', 'spinbutton', 'switch',
|
|
10
|
+
'menuitem', 'menuitemcheckbox', 'menuitemradio',
|
|
11
|
+
'option', 'tab', 'treeitem',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
export const CONTEXT_ROLES = new Set([
|
|
15
|
+
'navigation', 'main', 'contentinfo', 'banner', 'form',
|
|
16
|
+
'section', 'region', 'complementary', 'search',
|
|
17
|
+
'list', 'listitem', 'table', 'rowgroup', 'row', 'cell',
|
|
18
|
+
'heading', 'img',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
export const SKIP_ROLES = new Set([
|
|
22
|
+
'generic', 'none', 'presentation',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export const TEST_ID_ATTRS = [
|
|
26
|
+
'data-testid', 'data-test-id', 'data-test',
|
|
27
|
+
'data-cy', 'data-pw', 'data-qa', 'data-e2e',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export function escapeLocatorName(name) {
|
|
31
|
+
return name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildLocator(role, name, stableAttr) {
|
|
35
|
+
if (stableAttr) {
|
|
36
|
+
return `[${stableAttr.attr}="${escapeLocatorName(stableAttr.value)}"]`;
|
|
37
|
+
}
|
|
38
|
+
const trimmed = name?.trim();
|
|
39
|
+
if (trimmed) {
|
|
40
|
+
return `role=${role}[name="${escapeLocatorName(trimmed)}"]`;
|
|
41
|
+
}
|
|
42
|
+
return `role=${role}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function walkAxTree(node, visitor, depth = 0, parentNames = []) {
|
|
46
|
+
if (!node) return;
|
|
47
|
+
visitor(node, depth, parentNames);
|
|
48
|
+
const nextNames = node.name ? [...parentNames, node.name] : parentNames;
|
|
49
|
+
if (node.children) {
|
|
50
|
+
for (const child of node.children) {
|
|
51
|
+
walkAxTree(child, visitor, depth + 1, nextNames);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function hasInteractiveDescendant(node) {
|
|
57
|
+
if (!node.children) return false;
|
|
58
|
+
for (const child of node.children) {
|
|
59
|
+
if (INTERACTIVE_ROLES.has(child.role)) return true;
|
|
60
|
+
if (hasInteractiveDescendant(child)) return true;
|
|
61
|
+
}
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function hasMatchingDescendant(node, pattern) {
|
|
66
|
+
if (!node.children) return false;
|
|
67
|
+
for (const child of node.children) {
|
|
68
|
+
const text = `${child.role} ${child.name || ''}`;
|
|
69
|
+
if (pattern.test(text)) return true;
|
|
70
|
+
if (hasMatchingDescendant(child, pattern)) return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build snapshot text from an AX tree object.
|
|
77
|
+
*
|
|
78
|
+
* stableIdMap: Map<testid/id value → { attr, value }> keyed by the attribute
|
|
79
|
+
* value itself (not by accessible name) to avoid collisions when multiple
|
|
80
|
+
* elements share the same name.
|
|
81
|
+
*
|
|
82
|
+
* The ref for each interactive element is:
|
|
83
|
+
* 1. The stable attribute value if a matching stableIdMap entry exists for this
|
|
84
|
+
* node's DOM attributes (looked up by the page-evaluate caller).
|
|
85
|
+
* 2. Otherwise, a fallback counter ref: e1, e2, ...
|
|
86
|
+
*
|
|
87
|
+
* When multiple nodes resolve to the same ref, a -2, -3 suffix is appended.
|
|
88
|
+
*/
|
|
89
|
+
export function buildSnapshotText(axTree, stableIdMap, searchPattern) {
|
|
90
|
+
const lines = [];
|
|
91
|
+
const refs = [];
|
|
92
|
+
let refCounter = 0;
|
|
93
|
+
const refCounts = new Map();
|
|
94
|
+
|
|
95
|
+
walkAxTree(axTree, (node, depth) => {
|
|
96
|
+
const role = node.role;
|
|
97
|
+
if (SKIP_ROLES.has(role) || role === 'RootWebArea' || role === 'WebArea') {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const isInteractive = INTERACTIVE_ROLES.has(role);
|
|
102
|
+
const isContext = CONTEXT_ROLES.has(role);
|
|
103
|
+
const name = node.name || '';
|
|
104
|
+
|
|
105
|
+
if (!isInteractive && !isContext) {
|
|
106
|
+
if (!hasInteractiveDescendant(node)) return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (searchPattern) {
|
|
110
|
+
const text = `${role} ${name}`;
|
|
111
|
+
if (!searchPattern.test(text) && !hasMatchingDescendant(node, searchPattern)) {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const indent = ' '.repeat(depth);
|
|
117
|
+
let lineText = `${indent}- ${role}`;
|
|
118
|
+
if (name) {
|
|
119
|
+
lineText += ` "${escapeLocatorName(name)}"`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (isInteractive) {
|
|
123
|
+
refCounter++;
|
|
124
|
+
const stableEntry = node._stableAttr || null;
|
|
125
|
+
let baseRef = stableEntry ? stableEntry.value : `e${refCounter}`;
|
|
126
|
+
const count = refCounts.get(baseRef) ?? 0;
|
|
127
|
+
refCounts.set(baseRef, count + 1);
|
|
128
|
+
const ref = count === 0 ? baseRef : `${baseRef}-${count + 1}`;
|
|
129
|
+
const locator = buildLocator(role, name, stableEntry);
|
|
130
|
+
|
|
131
|
+
lineText += ` [ref=${ref}]`;
|
|
132
|
+
refs.push({ ref, role, name, locator });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (node.children?.length > 0) {
|
|
136
|
+
const hasRelevantChildren = node.children.some(c =>
|
|
137
|
+
INTERACTIVE_ROLES.has(c.role) || CONTEXT_ROLES.has(c.role) || hasInteractiveDescendant(c)
|
|
138
|
+
);
|
|
139
|
+
if (hasRelevantChildren) {
|
|
140
|
+
lineText += ':';
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
lines.push(lineText);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return { text: lines.join('\n'), refs };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function createSmartDiff(oldText, newText) {
|
|
151
|
+
if (oldText === newText) return { type: 'no-change', content: newText };
|
|
152
|
+
|
|
153
|
+
const patch = createPatch('snapshot', oldText, newText, 'previous', 'current', { context: 3 });
|
|
154
|
+
const patchLines = patch.split('\n');
|
|
155
|
+
const diffBody = patchLines.slice(4).join('\n');
|
|
156
|
+
|
|
157
|
+
const oldLineCount = oldText.split('\n').length;
|
|
158
|
+
const newLineCount = newText.split('\n').length;
|
|
159
|
+
const addedLines = (diffBody.match(/^\+[^+]/gm) || []).length;
|
|
160
|
+
const removedLines = (diffBody.match(/^-[^-]/gm) || []).length;
|
|
161
|
+
const changeRatio = Math.max(addedLines, removedLines) / Math.max(oldLineCount, newLineCount, 1);
|
|
162
|
+
|
|
163
|
+
if (changeRatio >= 0.5 || diffBody.length >= newText.length) {
|
|
164
|
+
return { type: 'full', content: newText };
|
|
165
|
+
}
|
|
166
|
+
return { type: 'diff', content: diffBody };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function parseSearchPattern(search) {
|
|
170
|
+
if (!search) return null;
|
|
171
|
+
try {
|
|
172
|
+
return new RegExp(search, 'i');
|
|
173
|
+
} catch (err) {
|
|
174
|
+
throw new Error(`Invalid search regex "${search}": ${err.message}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Annotate AX tree nodes with stable DOM attributes (data-testid, id, etc.).
|
|
180
|
+
*
|
|
181
|
+
* stableIdsByAttrValue: an object of { [attrValue]: { attr, value, names } }
|
|
182
|
+
* returned from getStableIds(). We match AX nodes to stable IDs by checking
|
|
183
|
+
* if the node's accessible name appears in the entry's names array.
|
|
184
|
+
*
|
|
185
|
+
* This runs once per snapshot before buildSnapshotText, attaching _stableAttr
|
|
186
|
+
* to matching nodes in-place.
|
|
187
|
+
*/
|
|
188
|
+
export function annotateStableAttrs(axTree, stableIds) {
|
|
189
|
+
walkAxTree(axTree, (node) => {
|
|
190
|
+
if (!INTERACTIVE_ROLES.has(node.role)) return;
|
|
191
|
+
const name = node.name || '';
|
|
192
|
+
const entry = stableIds[name];
|
|
193
|
+
if (entry) {
|
|
194
|
+
node._stableAttr = { attr: entry.attr, value: entry.value };
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "browserforce",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Give AI agents your real Chrome browser — your logins, cookies, and tabs. Works with OpenClaw, Claude, and any MCP agent.",
|
|
6
|
+
"homepage": "https://github.com/anthropics/browserforce",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/anthropics/browserforce.git"
|
|
10
|
+
},
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"browser",
|
|
14
|
+
"chrome",
|
|
15
|
+
"playwright",
|
|
16
|
+
"mcp",
|
|
17
|
+
"openclaw",
|
|
18
|
+
"ai-agent",
|
|
19
|
+
"cdp",
|
|
20
|
+
"automation",
|
|
21
|
+
"browserforce"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18.3.0"
|
|
25
|
+
},
|
|
26
|
+
"bin": {
|
|
27
|
+
"browserforce": "./bin.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin.js",
|
|
31
|
+
"relay/src/",
|
|
32
|
+
"relay/package.json",
|
|
33
|
+
"mcp/src/",
|
|
34
|
+
"mcp/package.json",
|
|
35
|
+
"skills/"
|
|
36
|
+
],
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
39
|
+
"diff": "^8.0.3",
|
|
40
|
+
"playwright-core": "^1.52.0",
|
|
41
|
+
"ws": "^8.19.0",
|
|
42
|
+
"zod": "^3.24.0"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"relay": "lsof -ti tcp:19222 | xargs kill -9 2>/dev/null; sleep 0.3; node relay/src/index.js",
|
|
46
|
+
"relay:dev": "lsof -ti tcp:19222 | xargs kill -9 2>/dev/null; sleep 0.3; node --watch relay/src/index.js",
|
|
47
|
+
"mcp": "node mcp/src/index.js",
|
|
48
|
+
"test": "node --test relay/test/relay-server.test.js && node --test mcp/test/mcp-tools.test.js && node --test test/cli.test.js",
|
|
49
|
+
"test:relay": "node --test relay/test/relay-server.test.js",
|
|
50
|
+
"test:mcp": "node --test mcp/test/mcp-tools.test.js"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "type": "commonjs" }
|