cdpilot 0.1.2 → 0.2.1
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 +45 -1
- package/bin/cdpilot.js +153 -16
- package/package.json +7 -4
- package/src/cdpilot.py +1637 -161
package/src/cdpilot.py
CHANGED
|
@@ -25,18 +25,202 @@ import time
|
|
|
25
25
|
import urllib.request
|
|
26
26
|
import subprocess
|
|
27
27
|
import shutil
|
|
28
|
+
import platform
|
|
29
|
+
import socket
|
|
30
|
+
import difflib
|
|
31
|
+
import hashlib
|
|
32
|
+
import re as _re
|
|
28
33
|
|
|
29
|
-
# ───
|
|
30
|
-
#
|
|
31
|
-
#
|
|
34
|
+
# ─── Project-Based Multi-Instance Configuration ───
|
|
35
|
+
# Each project directory (cwd) gets its own browser instance with
|
|
36
|
+
# a unique CDP port and isolated profile directory. Zero-config.
|
|
32
37
|
|
|
33
|
-
|
|
38
|
+
CDPILOT_HOME = os.path.expanduser("~/.cdpilot")
|
|
39
|
+
REGISTRY_FILE = os.path.join(CDPILOT_HOME, "registry.json")
|
|
40
|
+
CDPILOT_PORT_RANGE_START = 9222
|
|
41
|
+
CDPILOT_PORT_RANGE_END = 9322
|
|
42
|
+
IS_MCP_SESSION = os.environ.get("CDPILOT_MCP_SESSION") == "1"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_project_id():
|
|
46
|
+
"""Derive a deterministic project ID from the current working directory.
|
|
47
|
+
|
|
48
|
+
Prefers CDPILOT_PROJECT_ID env (set by bin/cdpilot.js based on caller's cwd)
|
|
49
|
+
over os.getcwd() which may differ when invoked via npx or absolute path.
|
|
50
|
+
"""
|
|
51
|
+
env_id = os.environ.get("CDPILOT_PROJECT_ID")
|
|
52
|
+
if env_id:
|
|
53
|
+
return env_id
|
|
54
|
+
cwd = os.getcwd()
|
|
55
|
+
dir_name = os.path.basename(cwd)
|
|
56
|
+
safe_name = _re.sub(r'[^a-zA-Z0-9-]', '', dir_name)[:20]
|
|
57
|
+
hash_suffix = hashlib.md5(cwd.encode()).hexdigest()[:6]
|
|
58
|
+
return f"{safe_name}-{hash_suffix}" if safe_name else hash_suffix
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _is_port_free(port):
|
|
62
|
+
"""Check if a port is available for binding."""
|
|
63
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
64
|
+
try:
|
|
65
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
66
|
+
s.bind(("127.0.0.1", port))
|
|
67
|
+
return True
|
|
68
|
+
except OSError:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _load_registry():
|
|
73
|
+
"""Read the global project registry."""
|
|
74
|
+
try:
|
|
75
|
+
with open(REGISTRY_FILE) as f:
|
|
76
|
+
data = json.load(f)
|
|
77
|
+
return data.get("projects", {})
|
|
78
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
79
|
+
return {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _save_registry(projects):
|
|
83
|
+
"""Write the global project registry."""
|
|
84
|
+
os.makedirs(os.path.dirname(REGISTRY_FILE), exist_ok=True)
|
|
85
|
+
with open(REGISTRY_FILE, 'w') as f:
|
|
86
|
+
json.dump({"version": 1, "projects": projects}, f, indent=2)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _register_project(project_id, port, profile_dir, pid=None):
|
|
90
|
+
"""Register or update a project in the global registry."""
|
|
91
|
+
registry = _load_registry()
|
|
92
|
+
existing = registry.get(project_id, {})
|
|
93
|
+
registry[project_id] = {
|
|
94
|
+
"cwd": os.getcwd(),
|
|
95
|
+
"port": port,
|
|
96
|
+
"profile_dir": profile_dir,
|
|
97
|
+
"pid": pid,
|
|
98
|
+
"created": existing.get("created", time.strftime("%Y-%m-%dT%H:%M:%S")),
|
|
99
|
+
"last_used": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
|
100
|
+
"status": "running" if pid else "stopped",
|
|
101
|
+
}
|
|
102
|
+
_save_registry(registry)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _cleanup_registry():
|
|
106
|
+
"""Update status for dead processes and return cleaned registry."""
|
|
107
|
+
registry = _load_registry()
|
|
108
|
+
changed = False
|
|
109
|
+
for pid_key, info in registry.items():
|
|
110
|
+
if info.get("status") == "running":
|
|
111
|
+
port = info.get("port")
|
|
112
|
+
if port and _is_port_free(port):
|
|
113
|
+
info["status"] = "stopped"
|
|
114
|
+
info["pid"] = None
|
|
115
|
+
changed = True
|
|
116
|
+
if changed:
|
|
117
|
+
_save_registry(registry)
|
|
118
|
+
return registry
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _allocate_port(project_id):
|
|
122
|
+
"""Find a free port for the given project."""
|
|
123
|
+
registry = _load_registry()
|
|
124
|
+
|
|
125
|
+
# Reuse existing port if still free
|
|
126
|
+
if project_id in registry:
|
|
127
|
+
existing_port = registry[project_id].get("port")
|
|
128
|
+
if existing_port and _is_port_free(existing_port):
|
|
129
|
+
return existing_port
|
|
130
|
+
|
|
131
|
+
# Collect ports used by other active projects
|
|
132
|
+
used_ports = set()
|
|
133
|
+
for pid, info in registry.items():
|
|
134
|
+
if pid != project_id and info.get("port"):
|
|
135
|
+
used_ports.add(info["port"])
|
|
136
|
+
|
|
137
|
+
# Find first free port in range
|
|
138
|
+
for port in range(CDPILOT_PORT_RANGE_START, CDPILOT_PORT_RANGE_END):
|
|
139
|
+
if port not in used_ports and _is_port_free(port):
|
|
140
|
+
return port
|
|
141
|
+
|
|
142
|
+
raise RuntimeError(
|
|
143
|
+
f"No free port in range {CDPILOT_PORT_RANGE_START}-{CDPILOT_PORT_RANGE_END}"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _resolve_project_config():
|
|
148
|
+
"""Determine port, profile dir, and project ID based on cwd + env vars."""
|
|
149
|
+
env_port = os.environ.get("CDP_PORT")
|
|
150
|
+
env_profile = os.environ.get("CDPILOT_PROFILE")
|
|
151
|
+
|
|
152
|
+
# Treat CDP_PORT=0 as "auto-allocate"
|
|
153
|
+
has_explicit_port = env_port and env_port != "0"
|
|
154
|
+
|
|
155
|
+
# Full manual override (legacy behavior)
|
|
156
|
+
if has_explicit_port and env_profile:
|
|
157
|
+
return int(env_port), env_profile, None
|
|
158
|
+
|
|
159
|
+
project_id = _get_project_id()
|
|
160
|
+
registry = _load_registry()
|
|
161
|
+
default_profile = os.path.join(CDPILOT_HOME, "projects", project_id, "profile")
|
|
162
|
+
|
|
163
|
+
if project_id in registry:
|
|
164
|
+
info = registry[project_id]
|
|
165
|
+
port = int(env_port) if has_explicit_port else info.get("port", 9222)
|
|
166
|
+
profile = env_profile or info.get("profile_dir", default_profile)
|
|
167
|
+
return port, profile, project_id
|
|
168
|
+
|
|
169
|
+
# New project: allocate port
|
|
170
|
+
try:
|
|
171
|
+
port = int(env_port) if has_explicit_port else _allocate_port(project_id)
|
|
172
|
+
except RuntimeError:
|
|
173
|
+
port = 9222 # fallback
|
|
174
|
+
profile = env_profile or default_profile
|
|
175
|
+
return port, profile, project_id
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _migrate_legacy_profile():
|
|
179
|
+
"""Migrate old single-profile layout to project-based layout."""
|
|
180
|
+
legacy_profile = os.path.join(CDPILOT_HOME, "profile")
|
|
181
|
+
if (os.path.isdir(legacy_profile) and not os.path.islink(legacy_profile)
|
|
182
|
+
and not os.path.exists(REGISTRY_FILE)):
|
|
183
|
+
project_id = _get_project_id()
|
|
184
|
+
new_dir = os.path.join(CDPILOT_HOME, "projects", project_id)
|
|
185
|
+
new_profile = os.path.join(new_dir, "profile")
|
|
186
|
+
if not os.path.exists(new_profile):
|
|
187
|
+
os.makedirs(new_dir, exist_ok=True)
|
|
188
|
+
os.rename(legacy_profile, new_profile)
|
|
189
|
+
os.symlink(new_profile, legacy_profile)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# Resolve project config at module load time
|
|
193
|
+
try:
|
|
194
|
+
_migrate_legacy_profile()
|
|
195
|
+
except Exception:
|
|
196
|
+
pass
|
|
197
|
+
CDP_PORT, PROFILE_DIR, PROJECT_ID = _resolve_project_config()
|
|
34
198
|
CDP_BASE = f"http://127.0.0.1:{CDP_PORT}"
|
|
35
199
|
CHROME_BIN = os.environ.get("CHROME_BIN")
|
|
36
|
-
|
|
37
|
-
|
|
200
|
+
|
|
201
|
+
if platform.system() == "Windows":
|
|
202
|
+
SCREENSHOT_DIR = os.path.expandvars(r"%TEMP%")
|
|
203
|
+
else:
|
|
204
|
+
SCREENSHOT_DIR = "/tmp"
|
|
38
205
|
|
|
39
206
|
DEV_EXTENSIONS_FILE = os.path.join(PROFILE_DIR, 'dev-extensions.json')
|
|
207
|
+
|
|
208
|
+
# ─── Auto-Wait JS Helper ───────────────────────────────────────────────────────
|
|
209
|
+
# Tarayıcıya inject edilir; MutationObserver ile element görünene kadar bekler.
|
|
210
|
+
WAIT_AND_QUERY_JS = """
|
|
211
|
+
window.__cdpilot_waitFor = function(selector, timeout) {
|
|
212
|
+
return new Promise(function(resolve) {
|
|
213
|
+
var el = document.querySelector(selector);
|
|
214
|
+
if (el) { resolve(el); return; }
|
|
215
|
+
var obs = new MutationObserver(function() {
|
|
216
|
+
var found = document.querySelector(selector);
|
|
217
|
+
if (found) { obs.disconnect(); resolve(found); }
|
|
218
|
+
});
|
|
219
|
+
obs.observe(document.documentElement, {childList: true, subtree: true});
|
|
220
|
+
setTimeout(function() { obs.disconnect(); resolve(null); }, timeout || 5000);
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
"""
|
|
40
224
|
PROXY_CONFIG_FILE = os.path.join(PROFILE_DIR, 'proxy.json')
|
|
41
225
|
HEADLESS_CONFIG_FILE = os.path.join(PROFILE_DIR, 'headless.json')
|
|
42
226
|
DOWNLOAD_CONFIG_FILE = os.path.join(PROFILE_DIR, 'download-config.json')
|
|
@@ -88,41 +272,100 @@ def _cleanup_stale_sessions():
|
|
|
88
272
|
INTERCEPT_RULES = [] # list of (pattern, action, data) tuples
|
|
89
273
|
DIALOG_MODE = None # 'accept', 'dismiss', or None
|
|
90
274
|
_current_session_id = None # lazy init
|
|
275
|
+
_A11Y_REF_MAP = {} # ref_num -> backendNodeId mapping
|
|
276
|
+
_A11Y_REF_FILE = os.path.join(PROFILE_DIR, 'a11y-refs.json')
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _save_a11y_refs(ref_map):
|
|
280
|
+
"""Persist a11y ref map to disk for cross-process access."""
|
|
281
|
+
os.makedirs(os.path.dirname(_A11Y_REF_FILE), exist_ok=True)
|
|
282
|
+
with open(_A11Y_REF_FILE, 'w') as f:
|
|
283
|
+
json.dump(ref_map, f)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _load_a11y_refs():
|
|
287
|
+
"""Load a11y ref map from disk."""
|
|
288
|
+
try:
|
|
289
|
+
with open(_A11Y_REF_FILE) as f:
|
|
290
|
+
data = json.load(f)
|
|
291
|
+
return {int(k): v for k, v in data.items()}
|
|
292
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
293
|
+
return {}
|
|
91
294
|
|
|
92
295
|
# ─── Visual Indicator Overlay CSS ───
|
|
93
296
|
|
|
94
297
|
GLOW_CSS = """
|
|
95
298
|
(function() {
|
|
96
|
-
if (document.getElementById('cdpilot-glow'))
|
|
97
|
-
|
|
98
|
-
|
|
299
|
+
if (document.getElementById('cdpilot-glow-overlay')) {
|
|
300
|
+
document.getElementById('cdpilot-glow-overlay').style.opacity = '1';
|
|
301
|
+
clearTimeout(window.__cdpilot_glow_timeout);
|
|
302
|
+
return 'glow refreshed';
|
|
303
|
+
}
|
|
304
|
+
var style = document.createElement('style');
|
|
305
|
+
style.id = 'cdpilot-glow-style';
|
|
99
306
|
style.textContent = `
|
|
100
307
|
@keyframes cdpilot-pulse {
|
|
101
|
-
0%, 100% { box-shadow: inset 0 0 20px 4px rgba(34,
|
|
102
|
-
50% { box-shadow: inset 0 0 30px 6px rgba(34,
|
|
308
|
+
0%, 100% { box-shadow: inset 0 0 20px 4px rgba(34,197,94,0.25), inset 0 0 60px 8px rgba(34,197,94,0.08); }
|
|
309
|
+
50% { box-shadow: inset 0 0 30px 6px rgba(34,197,94,0.35), inset 0 0 80px 12px rgba(34,197,94,0.12); }
|
|
103
310
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
top: 0; left: 0; right: 0; bottom: 0;
|
|
108
|
-
pointer-events: none;
|
|
109
|
-
z-index: 2147483647;
|
|
311
|
+
#cdpilot-glow-overlay {
|
|
312
|
+
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
|
313
|
+
pointer-events: none; z-index: 2147483646;
|
|
110
314
|
animation: cdpilot-pulse 2s ease-in-out infinite;
|
|
111
|
-
border: 2px solid rgba(34,
|
|
112
|
-
|
|
113
|
-
|
|
315
|
+
border: 2px solid rgba(34,197,94,0.3);
|
|
316
|
+
transition: opacity 1s ease;
|
|
317
|
+
opacity: 1;
|
|
318
|
+
}
|
|
319
|
+
#cdpilot-ai-toast {
|
|
320
|
+
position: fixed; bottom: -80px; left: 50%; transform: translateX(-50%);
|
|
321
|
+
z-index: 2147483647; background: rgba(15,0,0,0.92); color: #ef4444;
|
|
322
|
+
padding: 14px 28px; border-radius: 12px;
|
|
323
|
+
font: 600 14px/1.4 system-ui,-apple-system,sans-serif;
|
|
324
|
+
transition: bottom 0.4s cubic-bezier(0.34,1.56,0.64,1);
|
|
325
|
+
border: 1px solid rgba(239,68,68,0.4);
|
|
326
|
+
box-shadow: 0 4px 24px rgba(0,0,0,0.5), 0 0 20px rgba(239,68,68,0.15);
|
|
327
|
+
pointer-events: none; white-space: nowrap; backdrop-filter: blur(8px);
|
|
114
328
|
}
|
|
115
329
|
`;
|
|
116
330
|
document.head.appendChild(style);
|
|
331
|
+
var overlay = document.createElement('div');
|
|
332
|
+
overlay.id = 'cdpilot-glow-overlay';
|
|
333
|
+
document.body.appendChild(overlay);
|
|
334
|
+
var toast = document.createElement('div');
|
|
335
|
+
toast.id = 'cdpilot-ai-toast';
|
|
336
|
+
toast.textContent = '\\u26A0\\uFE0F Browser is controlled by AI — please wait';
|
|
337
|
+
document.body.appendChild(toast);
|
|
338
|
+
var _tt, _throttle = 0;
|
|
339
|
+
function _showWarn() {
|
|
340
|
+
if (!document.getElementById('cdpilot-glow-overlay')) return;
|
|
341
|
+
var now = Date.now();
|
|
342
|
+
if (now - _throttle < 2000) return;
|
|
343
|
+
_throttle = now;
|
|
344
|
+
toast.style.bottom = '24px';
|
|
345
|
+
clearTimeout(_tt);
|
|
346
|
+
_tt = setTimeout(function() { toast.style.bottom = '-80px'; }, 3000);
|
|
347
|
+
}
|
|
348
|
+
window.__cdpilot_warn = _showWarn;
|
|
349
|
+
document.addEventListener('mousemove', _showWarn, true);
|
|
350
|
+
clearTimeout(window.__cdpilot_glow_timeout);
|
|
117
351
|
return 'glow active';
|
|
118
352
|
})()
|
|
119
353
|
"""
|
|
120
354
|
|
|
121
355
|
GLOW_OFF_CSS = """
|
|
122
356
|
(function() {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
357
|
+
var overlay = document.getElementById('cdpilot-glow-overlay');
|
|
358
|
+
var style = document.getElementById('cdpilot-glow-style');
|
|
359
|
+
var toast = document.getElementById('cdpilot-ai-toast');
|
|
360
|
+
if (overlay) { overlay.style.opacity = '0'; setTimeout(function() { overlay.remove(); }, 1000); }
|
|
361
|
+
if (style) setTimeout(function() { style.remove(); }, 1100);
|
|
362
|
+
if (toast) toast.remove();
|
|
363
|
+
if (window.__cdpilot_warn) {
|
|
364
|
+
document.removeEventListener('mousemove', window.__cdpilot_warn, true);
|
|
365
|
+
delete window.__cdpilot_warn;
|
|
366
|
+
}
|
|
367
|
+
clearTimeout(window.__cdpilot_glow_timeout);
|
|
368
|
+
return overlay ? 'glow fading' : 'already off';
|
|
126
369
|
})()
|
|
127
370
|
"""
|
|
128
371
|
|
|
@@ -131,38 +374,36 @@ GLOW_OFF_CSS = """
|
|
|
131
374
|
INPUT_BLOCKER_ON = """
|
|
132
375
|
(function() {
|
|
133
376
|
if (document.getElementById('cdpilot-input-blocker')) return 'blocker already active';
|
|
134
|
-
|
|
377
|
+
var overlay = document.createElement('div');
|
|
135
378
|
overlay.id = 'cdpilot-input-blocker';
|
|
136
|
-
overlay.style.cssText =
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
379
|
+
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:2147483646;cursor:not-allowed;background:transparent;';
|
|
380
|
+
var toast = document.createElement('div');
|
|
381
|
+
toast.id = 'cdpilot-warning-toast';
|
|
382
|
+
toast.textContent = '\\u26A0\\uFE0F Browser is controlled by AI \\u2014 please wait';
|
|
383
|
+
toast.style.cssText = 'position:fixed;bottom:-80px;left:50%;transform:translateX(-50%);z-index:2147483647;background:rgba(15,0,0,0.92);color:#ef4444;padding:14px 28px;border-radius:12px;font:600 14px/1.4 system-ui,-apple-system,sans-serif;transition:bottom 0.4s cubic-bezier(0.34,1.56,0.64,1);border:1px solid rgba(239,68,68,0.4);box-shadow:0 4px 24px rgba(0,0,0,0.5),0 0 20px rgba(239,68,68,0.15);pointer-events:none;white-space:nowrap;backdrop-filter:blur(8px);';
|
|
384
|
+
document.body.appendChild(toast);
|
|
385
|
+
var _tt;
|
|
386
|
+
function _warn() {
|
|
387
|
+
toast.style.bottom = '24px';
|
|
388
|
+
clearTimeout(_tt);
|
|
389
|
+
_tt = setTimeout(function() { toast.style.bottom = '-80px'; }, 3000);
|
|
390
|
+
}
|
|
391
|
+
overlay.addEventListener('mousedown', function(e) { e.stopPropagation(); e.preventDefault(); _warn(); }, true);
|
|
392
|
+
overlay.addEventListener('mouseup', function(e) { e.stopPropagation(); e.preventDefault(); }, true);
|
|
393
|
+
overlay.addEventListener('click', function(e) { e.stopPropagation(); e.preventDefault(); _warn(); }, true);
|
|
394
|
+
overlay.addEventListener('dblclick', function(e) { e.stopPropagation(); e.preventDefault(); }, true);
|
|
395
|
+
overlay.addEventListener('contextmenu', function(e) { e.stopPropagation(); e.preventDefault(); }, true);
|
|
396
|
+
overlay.addEventListener('wheel', function(e) { e.stopPropagation(); e.preventDefault(); }, {capture:true, passive:false});
|
|
147
397
|
document.addEventListener('keydown', function _cb(e) {
|
|
148
|
-
if (!document.getElementById('cdpilot-input-blocker')) {
|
|
149
|
-
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
e.stopPropagation(); e.preventDefault();
|
|
398
|
+
if (!document.getElementById('cdpilot-input-blocker')) { document.removeEventListener('keydown', _cb, true); return; }
|
|
399
|
+
e.stopPropagation(); e.preventDefault(); _warn();
|
|
153
400
|
}, true);
|
|
154
401
|
document.addEventListener('keyup', function _cb(e) {
|
|
155
|
-
if (!document.getElementById('cdpilot-input-blocker')) {
|
|
156
|
-
document.removeEventListener('keyup', _cb, true);
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
402
|
+
if (!document.getElementById('cdpilot-input-blocker')) { document.removeEventListener('keyup', _cb, true); return; }
|
|
159
403
|
e.stopPropagation(); e.preventDefault();
|
|
160
404
|
}, true);
|
|
161
405
|
document.addEventListener('keypress', function _cb(e) {
|
|
162
|
-
if (!document.getElementById('cdpilot-input-blocker')) {
|
|
163
|
-
document.removeEventListener('keypress', _cb, true);
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
406
|
+
if (!document.getElementById('cdpilot-input-blocker')) { document.removeEventListener('keypress', _cb, true); return; }
|
|
166
407
|
e.stopPropagation(); e.preventDefault();
|
|
167
408
|
}, true);
|
|
168
409
|
document.body.appendChild(overlay);
|
|
@@ -172,56 +413,207 @@ INPUT_BLOCKER_ON = """
|
|
|
172
413
|
|
|
173
414
|
INPUT_BLOCKER_OFF = """
|
|
174
415
|
(function() {
|
|
175
|
-
|
|
176
|
-
if (el)
|
|
177
|
-
|
|
416
|
+
var el = document.getElementById('cdpilot-input-blocker');
|
|
417
|
+
if (el) el.remove();
|
|
418
|
+
var toast = document.getElementById('cdpilot-warning-toast');
|
|
419
|
+
if (toast) toast.remove();
|
|
420
|
+
return el ? 'input blocker off' : 'blocker already off';
|
|
178
421
|
})()
|
|
179
422
|
"""
|
|
180
423
|
|
|
181
|
-
# ───
|
|
424
|
+
# ─── Visual Feedback System (cursor, ripple, keystroke) ───
|
|
182
425
|
|
|
183
|
-
|
|
426
|
+
VISUAL_FEEDBACK_JS = """
|
|
427
|
+
(function() {
|
|
428
|
+
if (window.__cdpilot_vfx) return 'vfx already active';
|
|
429
|
+
var style = document.createElement('style');
|
|
430
|
+
style.id = 'cdpilot-vfx-style';
|
|
431
|
+
style.textContent = `
|
|
432
|
+
@keyframes cdpilot-ripple-anim {
|
|
433
|
+
0% { transform: translate(-50%,-50%) scale(0); opacity: 1; }
|
|
434
|
+
100% { transform: translate(-50%,-50%) scale(1); opacity: 0; }
|
|
435
|
+
}
|
|
436
|
+
.cdpilot-ripple {
|
|
437
|
+
position: fixed; width: 50px; height: 50px;
|
|
438
|
+
border: 2.5px solid #22c55e; border-radius: 50%;
|
|
439
|
+
pointer-events: none; z-index: 2147483647;
|
|
440
|
+
animation: cdpilot-ripple-anim 0.6s ease-out forwards;
|
|
441
|
+
box-shadow: 0 0 12px rgba(34,197,94,0.4);
|
|
442
|
+
}
|
|
443
|
+
.cdpilot-ripple-inner {
|
|
444
|
+
position: fixed; width: 8px; height: 8px;
|
|
445
|
+
background: #22c55e; border-radius: 50%;
|
|
446
|
+
pointer-events: none; z-index: 2147483647;
|
|
447
|
+
transform: translate(-50%,-50%);
|
|
448
|
+
opacity: 0.8;
|
|
449
|
+
animation: cdpilot-ripple-anim 0.4s ease-out 0.1s forwards;
|
|
450
|
+
}
|
|
451
|
+
#cdpilot-cursor {
|
|
452
|
+
position: fixed; pointer-events: none; z-index: 2147483647;
|
|
453
|
+
transition: left 0.2s cubic-bezier(0.25,0.8,0.25,1), top 0.2s cubic-bezier(0.25,0.8,0.25,1);
|
|
454
|
+
filter: drop-shadow(0 0 4px rgba(34,197,94,0.6));
|
|
455
|
+
}
|
|
456
|
+
#cdpilot-keystroke {
|
|
457
|
+
position: fixed; bottom: 80px; left: 50%;
|
|
458
|
+
transform: translateX(-50%);
|
|
459
|
+
background: rgba(0,0,0,0.88); color: #22c55e;
|
|
460
|
+
padding: 10px 20px; border-radius: 8px;
|
|
461
|
+
font: 700 15px/1.4 'SF Mono',Monaco,Menlo,monospace;
|
|
462
|
+
pointer-events: none; z-index: 2147483647;
|
|
463
|
+
border: 1px solid rgba(34,197,94,0.4);
|
|
464
|
+
box-shadow: 0 4px 20px rgba(0,0,0,0.4), 0 0 15px rgba(34,197,94,0.1);
|
|
465
|
+
opacity: 0; transition: opacity 0.3s ease;
|
|
466
|
+
backdrop-filter: blur(8px);
|
|
467
|
+
}
|
|
468
|
+
`;
|
|
469
|
+
document.head.appendChild(style);
|
|
470
|
+
var cursor = document.createElement('div');
|
|
471
|
+
cursor.id = 'cdpilot-cursor';
|
|
472
|
+
cursor.style.display = 'none';
|
|
473
|
+
cursor.innerHTML = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M5.5 3.21V20.8l5.71-5.71h8.3L5.5 3.21z" fill="#22c55e" stroke="#15803d" stroke-width="1.2"/></svg>';
|
|
474
|
+
document.body.appendChild(cursor);
|
|
475
|
+
var ks = document.createElement('div');
|
|
476
|
+
ks.id = 'cdpilot-keystroke';
|
|
477
|
+
document.body.appendChild(ks);
|
|
478
|
+
window.__cdpilot_vfx = {
|
|
479
|
+
ripple: function(x, y) {
|
|
480
|
+
var el = document.createElement('div');
|
|
481
|
+
el.className = 'cdpilot-ripple';
|
|
482
|
+
el.style.left = x + 'px'; el.style.top = y + 'px';
|
|
483
|
+
document.body.appendChild(el);
|
|
484
|
+
var inner = document.createElement('div');
|
|
485
|
+
inner.className = 'cdpilot-ripple-inner';
|
|
486
|
+
inner.style.left = x + 'px'; inner.style.top = y + 'px';
|
|
487
|
+
document.body.appendChild(inner);
|
|
488
|
+
setTimeout(function() { el.remove(); inner.remove(); }, 700);
|
|
489
|
+
},
|
|
490
|
+
moveCursor: function(x, y) {
|
|
491
|
+
cursor.style.display = 'block';
|
|
492
|
+
cursor.style.left = (x - 3) + 'px';
|
|
493
|
+
cursor.style.top = (y - 2) + 'px';
|
|
494
|
+
},
|
|
495
|
+
hideCursor: function() { cursor.style.display = 'none'; },
|
|
496
|
+
keystroke: function(text) {
|
|
497
|
+
ks.textContent = text;
|
|
498
|
+
ks.style.opacity = '1';
|
|
499
|
+
clearTimeout(ks.__tid);
|
|
500
|
+
ks.__tid = setTimeout(function() { ks.style.opacity = '0'; }, 2000);
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
return 'vfx active';
|
|
504
|
+
})()
|
|
505
|
+
"""
|
|
184
506
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
var
|
|
190
|
-
|
|
191
|
-
|
|
507
|
+
VISUAL_FEEDBACK_OFF = """
|
|
508
|
+
(function() {
|
|
509
|
+
delete window.__cdpilot_vfx;
|
|
510
|
+
['cdpilot-vfx-style','cdpilot-cursor','cdpilot-keystroke'].forEach(function(id) {
|
|
511
|
+
var el = document.getElementById(id); if (el) el.remove();
|
|
512
|
+
});
|
|
513
|
+
document.querySelectorAll('.cdpilot-ripple,.cdpilot-ripple-inner').forEach(function(el) { el.remove(); });
|
|
514
|
+
return 'vfx off';
|
|
515
|
+
})()
|
|
516
|
+
"""
|
|
517
|
+
|
|
518
|
+
# ─── Glow Auto-Timeout (fade out after 10s idle) ───
|
|
519
|
+
|
|
520
|
+
GLOW_TIMEOUT_JS = """
|
|
521
|
+
clearTimeout(window.__cdpilot_glow_timeout);
|
|
522
|
+
window.__cdpilot_glow_timeout = setTimeout(function() {
|
|
523
|
+
var o = document.getElementById('cdpilot-glow-overlay');
|
|
524
|
+
if (o) { o.style.opacity = '0'; setTimeout(function() { o.remove(); }, 1000); }
|
|
525
|
+
var s = document.getElementById('cdpilot-glow-style');
|
|
526
|
+
if (s) setTimeout(function() { s.remove(); }, 1100);
|
|
527
|
+
var t = document.getElementById('cdpilot-ai-toast');
|
|
528
|
+
if (t) t.remove();
|
|
529
|
+
if (window.__cdpilot_warn) {
|
|
530
|
+
document.removeEventListener('mousemove', window.__cdpilot_warn, true);
|
|
531
|
+
delete window.__cdpilot_warn;
|
|
532
|
+
}
|
|
533
|
+
['cdpilot-vfx-style','cdpilot-cursor','cdpilot-keystroke'].forEach(function(id) {
|
|
534
|
+
var el = document.getElementById(id); if (el) el.remove();
|
|
535
|
+
});
|
|
536
|
+
delete window.__cdpilot_vfx;
|
|
537
|
+
}, 10000);
|
|
192
538
|
"""
|
|
193
539
|
|
|
540
|
+
# ─── Automation Indicator Wrapper ───
|
|
541
|
+
|
|
542
|
+
_glow_script_id = None # addScriptToEvaluateOnNewDocument identifier
|
|
543
|
+
|
|
194
544
|
async def _control_start(ws_url):
|
|
195
|
-
"""Enable
|
|
545
|
+
"""Enable glow, input blocker, and visual feedback at command start."""
|
|
196
546
|
global _glow_script_id
|
|
197
547
|
try:
|
|
198
|
-
#
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
(
|
|
202
|
-
|
|
548
|
+
# Remove previous persistent script if exists (prevent accumulation)
|
|
549
|
+
cmds = []
|
|
550
|
+
if _glow_script_id:
|
|
551
|
+
cmds.append((900, "Page.removeScriptToEvaluateOnNewDocument", {"identifier": _glow_script_id}))
|
|
552
|
+
_glow_script_id = None
|
|
553
|
+
persistent_source = GLOW_CSS + "\n" + VISUAL_FEEDBACK_JS
|
|
554
|
+
cmds.extend([
|
|
555
|
+
(901, "Page.addScriptToEvaluateOnNewDocument", {"source": persistent_source}),
|
|
556
|
+
(902, "Runtime.evaluate", {"expression": GLOW_CSS, "returnByValue": True}),
|
|
557
|
+
(903, "Runtime.evaluate", {"expression": VISUAL_FEEDBACK_JS, "returnByValue": True}),
|
|
558
|
+
(904, "Runtime.evaluate", {"expression": INPUT_BLOCKER_ON, "returnByValue": True}),
|
|
203
559
|
])
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
560
|
+
r = await cdp_send(ws_url, cmds)
|
|
561
|
+
resp_901 = r.get(901, {})
|
|
562
|
+
result = resp_901.get("result", {})
|
|
563
|
+
if isinstance(result, dict) and "identifier" in result:
|
|
564
|
+
_glow_script_id = result["identifier"]
|
|
207
565
|
except Exception:
|
|
208
|
-
pass
|
|
566
|
+
pass
|
|
209
567
|
|
|
210
568
|
async def _control_end(ws_url):
|
|
211
|
-
"""
|
|
569
|
+
"""Remove input blocker, keep glow alive.
|
|
570
|
+
|
|
571
|
+
In MCP session mode (CDPILOT_MCP_SESSION=1): glow stays permanently,
|
|
572
|
+
no timeout — mimics Claude's persistent orange glow behavior.
|
|
573
|
+
In CLI mode: glow fades after 10s idle (GLOW_TIMEOUT_JS).
|
|
574
|
+
"""
|
|
212
575
|
global _glow_script_id
|
|
213
576
|
try:
|
|
214
577
|
cmds = [
|
|
215
|
-
(903, "Runtime.evaluate", {"expression":
|
|
216
|
-
(
|
|
578
|
+
(903, "Runtime.evaluate", {"expression": INPUT_BLOCKER_OFF, "returnByValue": True}),
|
|
579
|
+
# Re-inject glow+vfx on current page (may be new after navigation)
|
|
580
|
+
(906, "Runtime.evaluate", {"expression": GLOW_CSS, "returnByValue": True}),
|
|
581
|
+
(907, "Runtime.evaluate", {"expression": VISUAL_FEEDBACK_JS, "returnByValue": True}),
|
|
217
582
|
]
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
cmds.append((
|
|
221
|
-
|
|
583
|
+
if not IS_MCP_SESSION:
|
|
584
|
+
# CLI mode: start 10s auto-cleanup timeout
|
|
585
|
+
cmds.append((904, "Runtime.evaluate", {"expression": GLOW_TIMEOUT_JS, "returnByValue": True}))
|
|
586
|
+
# Don't remove persistent script — it auto-cleans via GLOW_TIMEOUT_JS (CLI)
|
|
587
|
+
# or stays forever (MCP session).
|
|
588
|
+
_glow_script_id = None
|
|
222
589
|
await cdp_send(ws_url, cmds)
|
|
223
590
|
except Exception:
|
|
224
|
-
pass
|
|
591
|
+
pass
|
|
592
|
+
|
|
593
|
+
async def _vfx_ripple(ws_url, x, y):
|
|
594
|
+
"""Show click ripple + move cursor at (x, y)."""
|
|
595
|
+
js = f"if(window.__cdpilot_vfx){{window.__cdpilot_vfx.moveCursor({x},{y});window.__cdpilot_vfx.ripple({x},{y});}}"
|
|
596
|
+
try:
|
|
597
|
+
await cdp_send(ws_url, [(999, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
598
|
+
except Exception:
|
|
599
|
+
pass
|
|
600
|
+
|
|
601
|
+
async def _vfx_keystroke(ws_url, text):
|
|
602
|
+
"""Show keystroke display."""
|
|
603
|
+
safe = json.dumps(text)
|
|
604
|
+
js = f"if(window.__cdpilot_vfx){{window.__cdpilot_vfx.keystroke({safe});}}"
|
|
605
|
+
try:
|
|
606
|
+
await cdp_send(ws_url, [(999, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
607
|
+
except Exception:
|
|
608
|
+
pass
|
|
609
|
+
|
|
610
|
+
async def _vfx_move_cursor(ws_url, x, y):
|
|
611
|
+
"""Move fake cursor to (x, y)."""
|
|
612
|
+
js = f"if(window.__cdpilot_vfx){{window.__cdpilot_vfx.moveCursor({x},{y});}}"
|
|
613
|
+
try:
|
|
614
|
+
await cdp_send(ws_url, [(999, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
615
|
+
except Exception:
|
|
616
|
+
pass
|
|
225
617
|
|
|
226
618
|
# ─── Connection Helpers ───
|
|
227
619
|
|
|
@@ -421,22 +813,32 @@ async def cdp_send(ws_url, commands, timeout=15):
|
|
|
421
813
|
"""Send multiple CDP commands and collect results."""
|
|
422
814
|
import websockets
|
|
423
815
|
results = {}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
816
|
+
try:
|
|
817
|
+
async with websockets.connect(ws_url, max_size=100 * 1024 * 1024) as ws:
|
|
818
|
+
for cmd_id, method, params in commands:
|
|
819
|
+
await ws.send(json.dumps({"id": cmd_id, "method": method, "params": params or {}}))
|
|
427
820
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
821
|
+
pending = {c[0] for c in commands}
|
|
822
|
+
start = time.time()
|
|
823
|
+
while pending and (time.time() - start) < timeout:
|
|
824
|
+
try:
|
|
825
|
+
resp = await asyncio.wait_for(ws.recv(), timeout=2)
|
|
826
|
+
data = json.loads(resp)
|
|
827
|
+
if "id" in data and data["id"] in pending:
|
|
828
|
+
pending.discard(data["id"])
|
|
829
|
+
results[data["id"]] = data.get("result", data.get("error", {}))
|
|
830
|
+
except asyncio.TimeoutError:
|
|
831
|
+
continue
|
|
832
|
+
return results
|
|
833
|
+
except ConnectionRefusedError:
|
|
834
|
+
print("Browser is not running. Run 'cdpilot launch' first.", file=sys.stderr)
|
|
835
|
+
sys.exit(1)
|
|
836
|
+
except Exception as e:
|
|
837
|
+
err = str(e)
|
|
838
|
+
if "websocket" in err.lower() or "connect" in err.lower() or "ws://" in err.lower():
|
|
839
|
+
print(f"Browser is not running or CDP port {CDP_PORT} is unreachable. Run 'cdpilot launch' first.", file=sys.stderr)
|
|
840
|
+
sys.exit(1)
|
|
841
|
+
raise
|
|
440
842
|
|
|
441
843
|
|
|
442
844
|
async def navigate_collect(ws_url, url, network=False, console=False, glow=True):
|
|
@@ -495,11 +897,16 @@ async def navigate_collect(ws_url, url, network=False, console=False, glow=True)
|
|
|
495
897
|
if loaded:
|
|
496
898
|
break
|
|
497
899
|
|
|
498
|
-
# Inject visual indicator (
|
|
900
|
+
# Inject visual indicator (glow overlay + visual feedback)
|
|
499
901
|
if glow:
|
|
500
902
|
await ws.send(json.dumps({
|
|
501
903
|
"id": 200, "method": "Runtime.evaluate",
|
|
502
|
-
"params": {"expression":
|
|
904
|
+
"params": {"expression": GLOW_CSS, "returnByValue": True}
|
|
905
|
+
}))
|
|
906
|
+
sid += 1
|
|
907
|
+
await ws.send(json.dumps({
|
|
908
|
+
"id": sid, "method": "Runtime.evaluate",
|
|
909
|
+
"params": {"expression": VISUAL_FEEDBACK_JS, "returnByValue": True}
|
|
503
910
|
}))
|
|
504
911
|
|
|
505
912
|
# Inject dev extension content scripts via the existing WS connection
|
|
@@ -556,6 +963,49 @@ async def navigate_collect(ws_url, url, network=False, console=False, glow=True)
|
|
|
556
963
|
|
|
557
964
|
# ─── Helper Functions ───
|
|
558
965
|
|
|
966
|
+
def _find_browser():
|
|
967
|
+
"""İşletim sistemine göre tarayıcı ikili dosyasını otomatik olarak bulur."""
|
|
968
|
+
for b in ["brave-browser", "google-chrome", "chromium-browser", "chromium"]:
|
|
969
|
+
found = shutil.which(b)
|
|
970
|
+
if found:
|
|
971
|
+
return found
|
|
972
|
+
|
|
973
|
+
system = platform.system()
|
|
974
|
+
if system == "Darwin":
|
|
975
|
+
paths = [
|
|
976
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
977
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
978
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
979
|
+
]
|
|
980
|
+
elif system == "Linux":
|
|
981
|
+
paths = [
|
|
982
|
+
"/usr/bin/brave-browser",
|
|
983
|
+
"/usr/bin/google-chrome",
|
|
984
|
+
"/usr/bin/chromium-browser",
|
|
985
|
+
"/usr/bin/chromium",
|
|
986
|
+
"/snap/bin/chromium",
|
|
987
|
+
]
|
|
988
|
+
elif system == "Windows":
|
|
989
|
+
paths = [
|
|
990
|
+
os.path.expandvars(r"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe"),
|
|
991
|
+
os.path.expandvars(r"C:\Program Files\Google\Chrome\Application\chrome.exe"),
|
|
992
|
+
os.path.expandvars(r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe"),
|
|
993
|
+
os.path.expandvars(r"C:\Program Files\Chromium\Application\chromium.exe"),
|
|
994
|
+
]
|
|
995
|
+
else:
|
|
996
|
+
return None
|
|
997
|
+
|
|
998
|
+
for path in paths:
|
|
999
|
+
if os.path.exists(path):
|
|
1000
|
+
return path
|
|
1001
|
+
return None
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _is_port_in_use(port):
|
|
1005
|
+
"""Check if a port is actively in use."""
|
|
1006
|
+
return not _is_port_free(port)
|
|
1007
|
+
|
|
1008
|
+
|
|
559
1009
|
def get_dev_extensions():
|
|
560
1010
|
"""Read registered dev mode extension paths."""
|
|
561
1011
|
if os.path.exists(DEV_EXTENSIONS_FILE):
|
|
@@ -741,17 +1191,34 @@ def get_headless_config():
|
|
|
741
1191
|
|
|
742
1192
|
def cmd_launch():
|
|
743
1193
|
"""Launch the browser with CDP enabled (isolated session — does not touch existing browser)."""
|
|
1194
|
+
global CHROME_BIN, CDP_PORT, CDP_BASE
|
|
744
1195
|
if cdp_get('/json/version'):
|
|
745
|
-
|
|
1196
|
+
proj_label = f' [{PROJECT_ID}]' if PROJECT_ID else ''
|
|
1197
|
+
print(f'Browser already running on port {CDP_PORT}{proj_label}.')
|
|
746
1198
|
return
|
|
1199
|
+
if _is_port_in_use(CDP_PORT):
|
|
1200
|
+
if PROJECT_ID:
|
|
1201
|
+
# Auto-allocate a new port
|
|
1202
|
+
new_port = _allocate_port(PROJECT_ID)
|
|
1203
|
+
print(f'Port {CDP_PORT} busy, using {new_port}.')
|
|
1204
|
+
CDP_PORT = new_port
|
|
1205
|
+
CDP_BASE = f"http://127.0.0.1:{CDP_PORT}"
|
|
1206
|
+
else:
|
|
1207
|
+
print(f'Error: Port {CDP_PORT} is in use. Set CDP_PORT to a different port.', file=sys.stderr)
|
|
1208
|
+
sys.exit(1)
|
|
747
1209
|
|
|
748
1210
|
if not CHROME_BIN:
|
|
749
|
-
|
|
750
|
-
|
|
1211
|
+
bin_path = _find_browser()
|
|
1212
|
+
if not bin_path:
|
|
1213
|
+
print('No supported browser found. Install Brave, Chrome, or Chromium and ensure it is in PATH or set CHROME_BIN.', file=sys.stderr)
|
|
1214
|
+
sys.exit(1)
|
|
1215
|
+
CHROME_BIN = bin_path
|
|
1216
|
+
print(f'Browser found: {bin_path}')
|
|
751
1217
|
|
|
752
1218
|
os.makedirs(PROFILE_DIR, exist_ok=True)
|
|
753
1219
|
|
|
754
|
-
|
|
1220
|
+
proj_label = f' [{PROJECT_ID}]' if PROJECT_ID else ''
|
|
1221
|
+
print(f'Launching browser (isolated session, port {CDP_PORT}){proj_label}...')
|
|
755
1222
|
|
|
756
1223
|
chrome_args = [
|
|
757
1224
|
CHROME_BIN,
|
|
@@ -816,12 +1283,15 @@ def cmd_launch():
|
|
|
816
1283
|
chrome_args.append('--headless=new')
|
|
817
1284
|
print(' Mode: headless')
|
|
818
1285
|
|
|
819
|
-
subprocess.Popen(chrome_args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
|
1286
|
+
proc = subprocess.Popen(chrome_args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
|
|
820
1287
|
|
|
821
1288
|
for _ in range(20):
|
|
822
1289
|
time.sleep(0.5)
|
|
823
1290
|
if cdp_get('/json/version'):
|
|
824
|
-
|
|
1291
|
+
if PROJECT_ID:
|
|
1292
|
+
_register_project(PROJECT_ID, CDP_PORT, PROFILE_DIR, pid=proc.pid)
|
|
1293
|
+
proj_label = f' [{PROJECT_ID}]' if PROJECT_ID else ''
|
|
1294
|
+
print(f'CDP ready! (port {CDP_PORT}){proj_label}')
|
|
825
1295
|
return
|
|
826
1296
|
print('Failed to start CDP (timeout).', file=sys.stderr)
|
|
827
1297
|
sys.exit(1)
|
|
@@ -850,31 +1320,164 @@ async def cmd_go(url):
|
|
|
850
1320
|
async def cmd_content():
|
|
851
1321
|
ws, _ = get_page_ws()
|
|
852
1322
|
r = await cdp_send(ws, [(1, "Runtime.evaluate", {
|
|
853
|
-
"expression": "document.body.innerText.substring(0,
|
|
1323
|
+
"expression": "document.body.innerText.substring(0, 1048576)",
|
|
854
1324
|
"returnByValue": True,
|
|
855
1325
|
})])
|
|
856
|
-
|
|
1326
|
+
content = r.get(1, {}).get("result", {}).get("value", "(empty)")
|
|
1327
|
+
print(content)
|
|
1328
|
+
if len(content) >= 1048576:
|
|
1329
|
+
print("[Output truncated at 1MB]")
|
|
857
1330
|
|
|
858
1331
|
|
|
859
1332
|
async def cmd_html():
|
|
860
1333
|
ws, _ = get_page_ws()
|
|
861
1334
|
r = await cdp_send(ws, [(1, "Runtime.evaluate", {
|
|
862
|
-
"expression": "document.documentElement.outerHTML.substring(0,
|
|
1335
|
+
"expression": "document.documentElement.outerHTML.substring(0, 1048576)",
|
|
863
1336
|
"returnByValue": True,
|
|
864
1337
|
})])
|
|
865
|
-
|
|
1338
|
+
html_content = r.get(1, {}).get("result", {}).get("value", "(empty)")
|
|
1339
|
+
print(html_content)
|
|
1340
|
+
if len(html_content) >= 1048576:
|
|
1341
|
+
print("[Output truncated at 1MB]")
|
|
866
1342
|
|
|
867
1343
|
|
|
868
|
-
async def cmd_shot(output=None):
|
|
1344
|
+
async def cmd_shot(output=None, quality=None, element=None, fmt=None):
|
|
1345
|
+
"""Take screenshot. Supports --quality, --element, --format for token savings.
|
|
1346
|
+
|
|
1347
|
+
Args:
|
|
1348
|
+
output: File path (auto-detects format from extension)
|
|
1349
|
+
quality: JPEG quality 1-100 (only for jpeg format, saves ~5-7x tokens)
|
|
1350
|
+
element: CSS selector to capture only that element (crop)
|
|
1351
|
+
fmt: Force format: 'png', 'jpeg', or 'webp'
|
|
1352
|
+
"""
|
|
869
1353
|
if not output:
|
|
870
1354
|
output = f"{SCREENSHOT_DIR}/screenshot.png"
|
|
871
1355
|
ws, _ = get_page_ws()
|
|
872
|
-
|
|
873
|
-
|
|
1356
|
+
|
|
1357
|
+
# Auto-detect format from extension
|
|
1358
|
+
if fmt is None:
|
|
1359
|
+
ext = os.path.splitext(output)[1].lower()
|
|
1360
|
+
fmt = {"jpg": "jpeg", ".jpg": "jpeg", ".jpeg": "jpeg", ".webp": "webp"}.get(ext, "png")
|
|
1361
|
+
|
|
1362
|
+
params = {"format": fmt}
|
|
1363
|
+
if fmt == "jpeg" and quality:
|
|
1364
|
+
params["quality"] = max(1, min(100, int(quality)))
|
|
1365
|
+
elif fmt == "jpeg" and quality is None:
|
|
1366
|
+
params["quality"] = 80 # sensible default
|
|
1367
|
+
|
|
1368
|
+
# Element-level crop: get bounding rect, use clip
|
|
1369
|
+
if element:
|
|
1370
|
+
safe_sel = json.dumps(element)
|
|
1371
|
+
js = f"""
|
|
1372
|
+
(function() {{
|
|
1373
|
+
var el = document.querySelector({safe_sel});
|
|
1374
|
+
if (!el) return null;
|
|
1375
|
+
el.scrollIntoView({{block: 'center'}});
|
|
1376
|
+
var r = el.getBoundingClientRect();
|
|
1377
|
+
return JSON.stringify({{x: r.x, y: r.y, width: r.width, height: r.height}});
|
|
1378
|
+
}})()
|
|
1379
|
+
"""
|
|
1380
|
+
cr = await cdp_send(ws, [(2, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
1381
|
+
rect_str = cr.get(2, {}).get("result", {}).get("value")
|
|
1382
|
+
if rect_str:
|
|
1383
|
+
rect = json.loads(rect_str)
|
|
1384
|
+
params["clip"] = {
|
|
1385
|
+
"x": rect["x"], "y": rect["y"],
|
|
1386
|
+
"width": rect["width"], "height": rect["height"],
|
|
1387
|
+
"scale": 1
|
|
1388
|
+
}
|
|
1389
|
+
else:
|
|
1390
|
+
print(f"Element not found: {element}, taking full page", file=sys.stderr)
|
|
1391
|
+
|
|
1392
|
+
r = await cdp_send(ws, [(1, "Page.captureScreenshot", params)])
|
|
1393
|
+
b64 = r.get(1, {}).get("result", {}).get("data", "") or r.get(1, {}).get("data", "")
|
|
1394
|
+
if b64:
|
|
1395
|
+
data = base64.b64decode(b64)
|
|
1396
|
+
with open(output, "wb") as f:
|
|
1397
|
+
f.write(data)
|
|
1398
|
+
size_kb = len(data) / 1024
|
|
1399
|
+
print(f"{output} ({size_kb:.1f}KB)")
|
|
1400
|
+
else:
|
|
1401
|
+
print("Screenshot failed", file=sys.stderr)
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
async def cmd_shot_annotated(output=None):
|
|
1405
|
+
"""Etkileşimli elementler üzerine @N badge eklenmiş annotated screenshot al."""
|
|
1406
|
+
if not output:
|
|
1407
|
+
output = f"{SCREENSHOT_DIR}/screenshot-annotated.png"
|
|
1408
|
+
ws_url, _ = get_page_ws()
|
|
1409
|
+
|
|
1410
|
+
# A11y tree'den etkileşimli node'ları topla
|
|
1411
|
+
await cdp_send(ws_url, [
|
|
1412
|
+
(0, "Accessibility.enable", {}),
|
|
1413
|
+
(9, "DOM.enable", {}),
|
|
1414
|
+
])
|
|
1415
|
+
res = await cdp_send(ws_url, [(1, "Accessibility.getFullAXTree", {})])
|
|
1416
|
+
nodes = res.get(1, {}).get("nodes", [])
|
|
1417
|
+
|
|
1418
|
+
interactive_roles = {
|
|
1419
|
+
"button", "link", "textbox", "textField", "combobox", "comboBox",
|
|
1420
|
+
"checkbox", "radio", "menuitem", "menuItem", "searchbox", "searchBox",
|
|
1421
|
+
"spinbutton", "spinButton", "switch", "tab", "slider",
|
|
1422
|
+
}
|
|
1423
|
+
targets = [
|
|
1424
|
+
n for n in nodes
|
|
1425
|
+
if not n.get("ignored")
|
|
1426
|
+
and n.get("role", {}).get("value", "") in interactive_roles
|
|
1427
|
+
and n.get("backendDOMNodeId")
|
|
1428
|
+
]
|
|
1429
|
+
|
|
1430
|
+
# Her node için ekran koordinatlarını al
|
|
1431
|
+
badge_count = 0
|
|
1432
|
+
inject_parts = []
|
|
1433
|
+
for idx, node in enumerate(targets, start=1):
|
|
1434
|
+
backend_id = node["backendDOMNodeId"]
|
|
1435
|
+
res_b = await cdp_send(ws_url, [(11, "DOM.getBoxModel", {"backendNodeId": backend_id})])
|
|
1436
|
+
model = res_b.get(11, {}).get("model")
|
|
1437
|
+
if not model:
|
|
1438
|
+
continue
|
|
1439
|
+
content = model.get("content", model.get("border", []))
|
|
1440
|
+
if len(content) < 8:
|
|
1441
|
+
continue
|
|
1442
|
+
left = int(content[0])
|
|
1443
|
+
top = int(content[1])
|
|
1444
|
+
width = int(content[2] - content[0])
|
|
1445
|
+
height = int(content[5] - content[1])
|
|
1446
|
+
if width == 0 or height == 0:
|
|
1447
|
+
continue
|
|
1448
|
+
label = json.dumps(f"@{idx}")
|
|
1449
|
+
inject_parts.append(
|
|
1450
|
+
f"(function(){{"
|
|
1451
|
+
f"var b=document.createElement('span');"
|
|
1452
|
+
f"b.textContent={label};"
|
|
1453
|
+
f"b.setAttribute('data-cdpilot-badge','1');"
|
|
1454
|
+
f"b.style.cssText='position:fixed;left:{left}px;top:{top}px;"
|
|
1455
|
+
f"background:#22c55e;color:#fff;font-size:11px;font-weight:bold;"
|
|
1456
|
+
f"padding:1px 4px;border-radius:3px;z-index:99999;"
|
|
1457
|
+
f"pointer-events:none;line-height:1.4;';"
|
|
1458
|
+
f"document.body.appendChild(b);"
|
|
1459
|
+
f"}})();"
|
|
1460
|
+
)
|
|
1461
|
+
badge_count += 1
|
|
1462
|
+
|
|
1463
|
+
# Badge'leri inject et
|
|
1464
|
+
if inject_parts:
|
|
1465
|
+
inject_js = "\n".join(inject_parts)
|
|
1466
|
+
await cdp_send(ws_url, [(20, "Runtime.evaluate", {"expression": inject_js})])
|
|
1467
|
+
|
|
1468
|
+
# Screenshot al
|
|
1469
|
+
r = await cdp_send(ws_url, [(21, "Page.captureScreenshot", {"format": "png", "captureBeyondViewport": False})])
|
|
1470
|
+
b64 = r.get(21, {}).get("data", "")
|
|
1471
|
+
|
|
1472
|
+
# Badge'leri temizle
|
|
1473
|
+
await cdp_send(ws_url, [(22, "Runtime.evaluate", {
|
|
1474
|
+
"expression": "document.querySelectorAll('[data-cdpilot-badge]').forEach(function(e){e.remove();})"
|
|
1475
|
+
})])
|
|
1476
|
+
|
|
874
1477
|
if b64:
|
|
875
1478
|
with open(output, "wb") as f:
|
|
876
1479
|
f.write(base64.b64decode(b64))
|
|
877
|
-
print(f"{output}")
|
|
1480
|
+
print(f"{output} ({badge_count} badge)")
|
|
878
1481
|
else:
|
|
879
1482
|
print("Screenshot failed", file=sys.stderr)
|
|
880
1483
|
|
|
@@ -904,35 +1507,54 @@ async def cmd_eval(js_code):
|
|
|
904
1507
|
async def cmd_click(selector):
|
|
905
1508
|
ws, _ = get_page_ws()
|
|
906
1509
|
safe_sel = json.dumps(selector)
|
|
907
|
-
js = f"""
|
|
908
|
-
|
|
909
|
-
|
|
1510
|
+
js = WAIT_AND_QUERY_JS + f"""
|
|
1511
|
+
(function() {{
|
|
1512
|
+
return window.__cdpilot_waitFor({safe_sel}, 5000).then(function(el) {{
|
|
1513
|
+
if (!el) return 'Timeout waiting for: ' + {safe_sel};
|
|
910
1514
|
el.scrollIntoView({{behavior:'smooth', block:'center'}});
|
|
1515
|
+
if (window.__cdpilot_vfx) {{
|
|
1516
|
+
var r = el.getBoundingClientRect();
|
|
1517
|
+
var cx = Math.round(r.left + r.width/2), cy = Math.round(r.top + r.height/2);
|
|
1518
|
+
window.__cdpilot_vfx.moveCursor(cx, cy);
|
|
1519
|
+
window.__cdpilot_vfx.ripple(cx, cy);
|
|
1520
|
+
}}
|
|
911
1521
|
el.click();
|
|
912
1522
|
return 'Clicked: ' + el.tagName + ' ' + (el.textContent || '').substring(0, 60).trim();
|
|
913
|
-
}})
|
|
914
|
-
|
|
1523
|
+
}});
|
|
1524
|
+
}})()"""
|
|
1525
|
+
r = await cdp_send(ws, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True, "awaitPromise": True})])
|
|
915
1526
|
print(r.get(1, {}).get("result", {}).get("value", "?"))
|
|
916
1527
|
|
|
917
1528
|
|
|
918
1529
|
async def cmd_fill(selector, value):
|
|
919
|
-
"""Fill an input field (React/Vue compatible)."""
|
|
1530
|
+
"""Fill an input field with auto-wait (React/Vue compatible)."""
|
|
920
1531
|
ws, _ = get_page_ws()
|
|
921
1532
|
safe_sel = json.dumps(selector)
|
|
922
1533
|
safe_value = json.dumps(value)
|
|
923
|
-
js = f"""
|
|
924
|
-
|
|
925
|
-
|
|
1534
|
+
js = WAIT_AND_QUERY_JS + f"""
|
|
1535
|
+
(function() {{
|
|
1536
|
+
return window.__cdpilot_waitFor({safe_sel}, 5000).then(function(el) {{
|
|
1537
|
+
if (!el) return 'Timeout waiting for: ' + {safe_sel};
|
|
926
1538
|
el.focus();
|
|
927
|
-
|
|
1539
|
+
if (window.__cdpilot_vfx) {{
|
|
1540
|
+
var r = el.getBoundingClientRect();
|
|
1541
|
+
window.__cdpilot_vfx.moveCursor(Math.round(r.left + r.width/2), Math.round(r.top + r.height/2));
|
|
1542
|
+
window.__cdpilot_vfx.keystroke('\\u2328 ' + {safe_value}.substring(0, 30));
|
|
1543
|
+
}}
|
|
1544
|
+
var nativeSet = Object.getOwnPropertyDescriptor(
|
|
928
1545
|
window.HTMLInputElement.prototype, 'value'
|
|
929
|
-
)
|
|
930
|
-
nativeSet.
|
|
1546
|
+
);
|
|
1547
|
+
if (nativeSet && nativeSet.set) {{
|
|
1548
|
+
nativeSet.set.call(el, {safe_value});
|
|
1549
|
+
}} else {{
|
|
1550
|
+
el.value = {safe_value};
|
|
1551
|
+
}}
|
|
931
1552
|
el.dispatchEvent(new Event('input', {{bubbles: true}}));
|
|
932
1553
|
el.dispatchEvent(new Event('change', {{bubbles: true}}));
|
|
933
1554
|
return 'Filled: ' + el.tagName + ' = ' + el.value.substring(0, 50);
|
|
934
|
-
}})
|
|
935
|
-
|
|
1555
|
+
}});
|
|
1556
|
+
}})()"""
|
|
1557
|
+
r = await cdp_send(ws, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True, "awaitPromise": True})])
|
|
936
1558
|
print(r.get(1, {}).get("result", {}).get("value", "?"))
|
|
937
1559
|
|
|
938
1560
|
|
|
@@ -968,6 +1590,55 @@ async def cmd_wait(selector, timeout=5):
|
|
|
968
1590
|
print(r.get(1, {}).get("result", {}).get("value", "?"))
|
|
969
1591
|
|
|
970
1592
|
|
|
1593
|
+
async def cmd_batch():
|
|
1594
|
+
"""stdin'den JSON komut dizisi oku, sırayla çalıştır, sonuçları JSON olarak yaz.
|
|
1595
|
+
|
|
1596
|
+
Kullanım:
|
|
1597
|
+
echo '[{"cmd":"go","args":["https://example.com"]},{"cmd":"shot","args":["/tmp/out.png"]}]' | cdpilot batch
|
|
1598
|
+
"""
|
|
1599
|
+
try:
|
|
1600
|
+
raw = sys.stdin.read()
|
|
1601
|
+
data = json.loads(raw)
|
|
1602
|
+
if not isinstance(data, list):
|
|
1603
|
+
print(json.dumps({"error": "Input must be a JSON array"}), file=sys.stderr)
|
|
1604
|
+
sys.exit(1)
|
|
1605
|
+
except json.JSONDecodeError as exc:
|
|
1606
|
+
print(json.dumps({"error": f"JSON parse error: {exc}"}), file=sys.stderr)
|
|
1607
|
+
sys.exit(1)
|
|
1608
|
+
|
|
1609
|
+
results = []
|
|
1610
|
+
for item in data:
|
|
1611
|
+
cmd_name = item.get("cmd", "")
|
|
1612
|
+
cmd_args = item.get("args", [])
|
|
1613
|
+
try:
|
|
1614
|
+
if cmd_name == "go":
|
|
1615
|
+
await cmd_go(cmd_args[0] if cmd_args else "")
|
|
1616
|
+
elif cmd_name == "click":
|
|
1617
|
+
await cmd_click(cmd_args[0] if cmd_args else "")
|
|
1618
|
+
elif cmd_name in ("fill", "type"):
|
|
1619
|
+
await cmd_fill(cmd_args[0] if cmd_args else "", cmd_args[1] if len(cmd_args) > 1 else "")
|
|
1620
|
+
elif cmd_name == "shot":
|
|
1621
|
+
await cmd_shot(cmd_args[0] if cmd_args else None)
|
|
1622
|
+
elif cmd_name == "shot-annotated":
|
|
1623
|
+
await cmd_shot_annotated(cmd_args[0] if cmd_args else None)
|
|
1624
|
+
elif cmd_name == "wait":
|
|
1625
|
+
await cmd_wait(cmd_args[0] if cmd_args else "body", int(cmd_args[1]) if len(cmd_args) > 1 else 5)
|
|
1626
|
+
elif cmd_name == "eval":
|
|
1627
|
+
await cmd_eval(" ".join(cmd_args) if cmd_args else "")
|
|
1628
|
+
elif cmd_name == "submit":
|
|
1629
|
+
await cmd_submit(cmd_args[0] if cmd_args else "form")
|
|
1630
|
+
else:
|
|
1631
|
+
results.append({"cmd": cmd_name, "status": "error", "error": f"Unsupported command: {cmd_name}"})
|
|
1632
|
+
continue
|
|
1633
|
+
results.append({"cmd": cmd_name, "status": "ok"})
|
|
1634
|
+
except SystemExit:
|
|
1635
|
+
results.append({"cmd": cmd_name, "status": "error", "error": "Command exited with error"})
|
|
1636
|
+
except Exception as exc:
|
|
1637
|
+
results.append({"cmd": cmd_name, "status": "error", "error": str(exc)})
|
|
1638
|
+
|
|
1639
|
+
print(json.dumps(results, indent=2, ensure_ascii=False))
|
|
1640
|
+
|
|
1641
|
+
|
|
971
1642
|
async def cmd_network(url=None):
|
|
972
1643
|
ws, page = get_page_ws()
|
|
973
1644
|
if url is None:
|
|
@@ -1080,8 +1751,16 @@ async def cmd_emulate(device):
|
|
|
1080
1751
|
|
|
1081
1752
|
async def cmd_glow(state="on"):
|
|
1082
1753
|
ws, page = get_page_ws()
|
|
1083
|
-
|
|
1084
|
-
|
|
1754
|
+
if state == "on":
|
|
1755
|
+
await cdp_send(ws, [
|
|
1756
|
+
(1, "Runtime.evaluate", {"expression": GLOW_CSS, "returnByValue": True}),
|
|
1757
|
+
(2, "Runtime.evaluate", {"expression": VISUAL_FEEDBACK_JS, "returnByValue": True}),
|
|
1758
|
+
])
|
|
1759
|
+
else:
|
|
1760
|
+
await cdp_send(ws, [
|
|
1761
|
+
(1, "Runtime.evaluate", {"expression": GLOW_OFF_CSS, "returnByValue": True}),
|
|
1762
|
+
(2, "Runtime.evaluate", {"expression": VISUAL_FEEDBACK_OFF, "returnByValue": True}),
|
|
1763
|
+
])
|
|
1085
1764
|
print(f"Visual indicator {'on' if state == 'on' else 'off'}")
|
|
1086
1765
|
|
|
1087
1766
|
|
|
@@ -1412,7 +2091,8 @@ def cmd_ext_remove(ext_id):
|
|
|
1412
2091
|
async def cmd_new_tab(url='about:blank'):
|
|
1413
2092
|
"""Open a new tab."""
|
|
1414
2093
|
import urllib.parse
|
|
1415
|
-
|
|
2094
|
+
safe_chars = ":/?#[]@!$&'()*+,;="
|
|
2095
|
+
data = cdp_get(f'/json/new?{urllib.parse.quote(url, safe=safe_chars)}')
|
|
1416
2096
|
if data:
|
|
1417
2097
|
print(f'New tab opened: {data.get("url", url)}')
|
|
1418
2098
|
print(f' ID: {data.get("id", "?")}')
|
|
@@ -1617,29 +2297,160 @@ def cmd_headless(state=None):
|
|
|
1617
2297
|
print('Restart browser (stop → launch).')
|
|
1618
2298
|
|
|
1619
2299
|
|
|
2300
|
+
def _stop_browser_on_port(port):
|
|
2301
|
+
"""Stop the browser process listening on the given port."""
|
|
2302
|
+
import signal
|
|
2303
|
+
try:
|
|
2304
|
+
result = subprocess.run(
|
|
2305
|
+
["lsof", "-ti", f":{port}"],
|
|
2306
|
+
capture_output=True, text=True
|
|
2307
|
+
)
|
|
2308
|
+
pids = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()]
|
|
2309
|
+
if pids:
|
|
2310
|
+
for pid in pids:
|
|
2311
|
+
os.kill(int(pid), signal.SIGTERM)
|
|
2312
|
+
return True
|
|
2313
|
+
else:
|
|
2314
|
+
subprocess.run(
|
|
2315
|
+
["pkill", "-f", f"remote-debugging-port={port}"],
|
|
2316
|
+
capture_output=True, text=True
|
|
2317
|
+
)
|
|
2318
|
+
return True
|
|
2319
|
+
except Exception:
|
|
2320
|
+
return False
|
|
2321
|
+
|
|
2322
|
+
|
|
1620
2323
|
def cmd_stop():
|
|
1621
2324
|
"""Stop the browser instance managed by cdpilot."""
|
|
2325
|
+
if platform.system() == "Windows":
|
|
2326
|
+
browser_procs = ["brave.exe", "chrome.exe", "chromium.exe"]
|
|
2327
|
+
stopped_any = False
|
|
2328
|
+
for proc in browser_procs:
|
|
2329
|
+
try:
|
|
2330
|
+
result = subprocess.run(
|
|
2331
|
+
["taskkill", "/F", "/IM", proc],
|
|
2332
|
+
capture_output=True, text=True
|
|
2333
|
+
)
|
|
2334
|
+
if result.returncode == 0:
|
|
2335
|
+
print(f" {proc} terminated")
|
|
2336
|
+
stopped_any = True
|
|
2337
|
+
except Exception:
|
|
2338
|
+
pass
|
|
2339
|
+
if stopped_any:
|
|
2340
|
+
print(f"Browser stopped (port {CDP_PORT}).")
|
|
2341
|
+
else:
|
|
2342
|
+
print(f"No browser process found (port {CDP_PORT}).", file=sys.stderr)
|
|
2343
|
+
return
|
|
2344
|
+
|
|
1622
2345
|
import signal
|
|
1623
2346
|
try:
|
|
1624
2347
|
result = subprocess.run(
|
|
1625
2348
|
["lsof", "-ti", f":{CDP_PORT}"],
|
|
1626
2349
|
capture_output=True, text=True
|
|
1627
2350
|
)
|
|
1628
|
-
pids = result.stdout.strip().split("\n")
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
os.kill(int(pid
|
|
1632
|
-
print(f" PID {pid
|
|
1633
|
-
|
|
2351
|
+
pids = [p.strip() for p in result.stdout.strip().split("\n") if p.strip()]
|
|
2352
|
+
if pids:
|
|
2353
|
+
for pid in pids:
|
|
2354
|
+
os.kill(int(pid), signal.SIGTERM)
|
|
2355
|
+
print(f" PID {pid} terminated")
|
|
2356
|
+
print(f"Browser stopped (port {CDP_PORT}).")
|
|
2357
|
+
else:
|
|
2358
|
+
# lsof bulamazsa pkill ile dene
|
|
2359
|
+
subprocess.run(
|
|
2360
|
+
["pkill", "-f", f"remote-debugging-port={CDP_PORT}"],
|
|
2361
|
+
capture_output=True, text=True
|
|
2362
|
+
)
|
|
2363
|
+
print(f"Browser stopped (port {CDP_PORT}).")
|
|
1634
2364
|
except Exception as e:
|
|
1635
2365
|
print(f"Stop error: {e}", file=sys.stderr)
|
|
1636
2366
|
|
|
2367
|
+
# Update registry
|
|
2368
|
+
if PROJECT_ID:
|
|
2369
|
+
registry = _load_registry()
|
|
2370
|
+
if PROJECT_ID in registry:
|
|
2371
|
+
registry[PROJECT_ID]["status"] = "stopped"
|
|
2372
|
+
registry[PROJECT_ID]["pid"] = None
|
|
2373
|
+
_save_registry(registry)
|
|
2374
|
+
|
|
1637
2375
|
|
|
1638
2376
|
def cmd_version():
|
|
1639
2377
|
"""Show cdpilot version."""
|
|
1640
2378
|
print(f"cdpilot v{__version__}")
|
|
1641
2379
|
|
|
1642
2380
|
|
|
2381
|
+
def cmd_projects():
|
|
2382
|
+
"""List all registered cdpilot project instances."""
|
|
2383
|
+
registry = _cleanup_registry()
|
|
2384
|
+
if not registry:
|
|
2385
|
+
print("No registered projects.")
|
|
2386
|
+
return
|
|
2387
|
+
|
|
2388
|
+
current = PROJECT_ID
|
|
2389
|
+
print(f"{'Project':<28} {'Port':<7} {'Status':<10} {'CWD'}")
|
|
2390
|
+
print("\u2500" * 90)
|
|
2391
|
+
|
|
2392
|
+
for pid, info in sorted(registry.items(),
|
|
2393
|
+
key=lambda x: x[1].get("last_used", ""), reverse=True):
|
|
2394
|
+
port = info.get("port", "?")
|
|
2395
|
+
status = info.get("status", "?")
|
|
2396
|
+
cwd = info.get("cwd", "?")
|
|
2397
|
+
# Live check
|
|
2398
|
+
if status == "running" and _is_port_free(port):
|
|
2399
|
+
status = "stopped"
|
|
2400
|
+
icon = "\U0001f7e2" if status == "running" else "\u26ab"
|
|
2401
|
+
marker = " \u2190 current" if pid == current else ""
|
|
2402
|
+
if len(cwd) > 45:
|
|
2403
|
+
cwd = "..." + cwd[-42:]
|
|
2404
|
+
print(f" {pid:<26} {port:<7} {icon} {status:<8} {cwd}{marker}")
|
|
2405
|
+
|
|
2406
|
+
print(f"\nTotal: {len(registry)} project(s)")
|
|
2407
|
+
|
|
2408
|
+
|
|
2409
|
+
def cmd_project_stop(name):
|
|
2410
|
+
"""Stop a specific project's browser instance."""
|
|
2411
|
+
registry = _load_registry()
|
|
2412
|
+
target_id = None
|
|
2413
|
+
for pid, info in registry.items():
|
|
2414
|
+
if name in pid or name in info.get("cwd", ""):
|
|
2415
|
+
target_id = pid
|
|
2416
|
+
break
|
|
2417
|
+
|
|
2418
|
+
if not target_id:
|
|
2419
|
+
print(f"Project not found: {name}", file=sys.stderr)
|
|
2420
|
+
sys.exit(1)
|
|
2421
|
+
|
|
2422
|
+
info = registry[target_id]
|
|
2423
|
+
port = info.get("port")
|
|
2424
|
+
if port and not _is_port_free(port):
|
|
2425
|
+
_stop_browser_on_port(port)
|
|
2426
|
+
print(f"Stopped: {target_id} (port {port})")
|
|
2427
|
+
else:
|
|
2428
|
+
print(f"Project already stopped: {target_id}")
|
|
2429
|
+
|
|
2430
|
+
info["status"] = "stopped"
|
|
2431
|
+
info["pid"] = None
|
|
2432
|
+
_save_registry(registry)
|
|
2433
|
+
|
|
2434
|
+
|
|
2435
|
+
def cmd_stop_all():
|
|
2436
|
+
"""Stop all active cdpilot browser instances."""
|
|
2437
|
+
registry = _cleanup_registry()
|
|
2438
|
+
stopped = 0
|
|
2439
|
+
for pid, info in registry.items():
|
|
2440
|
+
port = info.get("port")
|
|
2441
|
+
if port and info.get("status") == "running" and not _is_port_free(port):
|
|
2442
|
+
_stop_browser_on_port(port)
|
|
2443
|
+
info["status"] = "stopped"
|
|
2444
|
+
info["pid"] = None
|
|
2445
|
+
stopped += 1
|
|
2446
|
+
print(f" Stopped: {pid} (port {port})")
|
|
2447
|
+
_save_registry(registry)
|
|
2448
|
+
if stopped:
|
|
2449
|
+
print(f"\n{stopped} instance(s) stopped.")
|
|
2450
|
+
else:
|
|
2451
|
+
print("No active instances.")
|
|
2452
|
+
|
|
2453
|
+
|
|
1643
2454
|
# ─── New CDP Commands ───
|
|
1644
2455
|
|
|
1645
2456
|
async def _get_element_center(ws_url, selector):
|
|
@@ -1858,12 +2669,604 @@ async def cmd_a11y(subcmd=""):
|
|
|
1858
2669
|
sys.exit(1)
|
|
1859
2670
|
|
|
1860
2671
|
|
|
2672
|
+
async def cmd_describe():
|
|
2673
|
+
"""Full page description for AI agents — structured data + screenshot fallback.
|
|
2674
|
+
|
|
2675
|
+
Outputs URL/title, accessibility snapshot, screenshot path, and text content.
|
|
2676
|
+
AI agents can use the a11y snapshot for cheap navigation; fall back to the
|
|
2677
|
+
screenshot with a vision model when the snapshot is insufficient (canvas, WebGL).
|
|
2678
|
+
"""
|
|
2679
|
+
global _A11Y_REF_MAP
|
|
2680
|
+
_A11Y_REF_MAP = {}
|
|
2681
|
+
ws_url, page = get_page_ws()
|
|
2682
|
+
|
|
2683
|
+
print("=== Page Description ===")
|
|
2684
|
+
print(f"URL: {page.get('url', 'N/A')}")
|
|
2685
|
+
print(f"Title: {page.get('title', 'N/A')}")
|
|
2686
|
+
print()
|
|
2687
|
+
|
|
2688
|
+
# ── Accessibility Snapshot ──────────────────────────────────────────────
|
|
2689
|
+
print("=== Accessibility Snapshot ===")
|
|
2690
|
+
|
|
2691
|
+
ROLE_NORMALIZE = {
|
|
2692
|
+
"textField": "textbox",
|
|
2693
|
+
"comboBox": "combobox",
|
|
2694
|
+
"checkBox": "checkbox",
|
|
2695
|
+
"radioButton": "radio",
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
SKIP_ROLES = {
|
|
2699
|
+
"none", "presentation", "generic", "LineBreak", "InlineTextBox",
|
|
2700
|
+
"ignored", "unknown"
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
INTERACTIVE_ROLES = {
|
|
2704
|
+
"button", "link", "textbox", "checkbox", "radio", "combobox", "listbox",
|
|
2705
|
+
"menuitem", "menuitemcheckbox", "menuitemradio", "option", "spinbutton",
|
|
2706
|
+
"slider", "switch", "tab", "treeitem"
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
STRUCTURAL_ROLES = {
|
|
2710
|
+
"heading", "img", "navigation", "menu", "list", "listitem", "table",
|
|
2711
|
+
"grid", "row", "cell", "columnheader", "rowheader", "dialog", "alert",
|
|
2712
|
+
"main", "banner", "contentinfo", "region", "figure", "article", "section"
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
def _get_prop(node, prop_name):
|
|
2716
|
+
for p in node.get("properties", []):
|
|
2717
|
+
if p.get("name") == prop_name:
|
|
2718
|
+
return p.get("value", {}).get("value", "")
|
|
2719
|
+
return ""
|
|
2720
|
+
|
|
2721
|
+
await cdp_send(ws_url, [(0, "Accessibility.enable", {})])
|
|
2722
|
+
res = await cdp_send(ws_url, [(1, "Accessibility.getFullAXTree", {})])
|
|
2723
|
+
nodes = res.get(1, {}).get("nodes", [])
|
|
2724
|
+
|
|
2725
|
+
if not nodes:
|
|
2726
|
+
print("Could not get accessibility tree.", file=sys.stderr)
|
|
2727
|
+
|
|
2728
|
+
ref_count = 0
|
|
2729
|
+
interactive_count = 0
|
|
2730
|
+
output_lines = []
|
|
2731
|
+
|
|
2732
|
+
for node in nodes:
|
|
2733
|
+
backend_node_id = node.get("backendDOMNodeId") or node.get("backendNodeId")
|
|
2734
|
+
role = node.get("role", {}).get("value")
|
|
2735
|
+
name = node.get("name", {}).get("value")
|
|
2736
|
+
description = node.get("description", {}).get("value")
|
|
2737
|
+
ignored = node.get("ignored", False)
|
|
2738
|
+
|
|
2739
|
+
if ignored:
|
|
2740
|
+
continue
|
|
2741
|
+
if not role or role in SKIP_ROLES:
|
|
2742
|
+
continue
|
|
2743
|
+
if not backend_node_id:
|
|
2744
|
+
continue
|
|
2745
|
+
if not name and not description and role in {"staticText", "text", "paragraph"}:
|
|
2746
|
+
continue
|
|
2747
|
+
|
|
2748
|
+
normalized = ROLE_NORMALIZE.get(role, role)
|
|
2749
|
+
|
|
2750
|
+
if normalized == "heading":
|
|
2751
|
+
level = _get_prop(node, "level")
|
|
2752
|
+
if level:
|
|
2753
|
+
normalized = f"heading/{level}"
|
|
2754
|
+
|
|
2755
|
+
base_role = normalized.split("/")[0]
|
|
2756
|
+
is_interactive = base_role in INTERACTIVE_ROLES
|
|
2757
|
+
is_structural = base_role in STRUCTURAL_ROLES
|
|
2758
|
+
|
|
2759
|
+
if not (is_interactive or (is_structural and (name or description))):
|
|
2760
|
+
continue
|
|
2761
|
+
|
|
2762
|
+
ref_count += 1
|
|
2763
|
+
_A11Y_REF_MAP[ref_count] = backend_node_id
|
|
2764
|
+
|
|
2765
|
+
if is_interactive:
|
|
2766
|
+
interactive_count += 1
|
|
2767
|
+
|
|
2768
|
+
display_name = name or description or ""
|
|
2769
|
+
attrs = []
|
|
2770
|
+
|
|
2771
|
+
if base_role == "link":
|
|
2772
|
+
href = _get_prop(node, "url")
|
|
2773
|
+
if href:
|
|
2774
|
+
attrs.append(f"href={href}")
|
|
2775
|
+
|
|
2776
|
+
if _get_prop(node, "disabled") == "true":
|
|
2777
|
+
attrs.append("disabled")
|
|
2778
|
+
|
|
2779
|
+
if _get_prop(node, "required") == "true":
|
|
2780
|
+
attrs.append("required")
|
|
2781
|
+
|
|
2782
|
+
if base_role in {"textbox", "combobox"}:
|
|
2783
|
+
val = _get_prop(node, "value")
|
|
2784
|
+
if val:
|
|
2785
|
+
attrs.append(f"value={val!r}")
|
|
2786
|
+
ph = _get_prop(node, "placeholder")
|
|
2787
|
+
if ph:
|
|
2788
|
+
attrs.append(f"placeholder={ph!r}")
|
|
2789
|
+
|
|
2790
|
+
if base_role in {"checkbox", "radio"}:
|
|
2791
|
+
if _get_prop(node, "checked") == "true":
|
|
2792
|
+
attrs.append("checked")
|
|
2793
|
+
|
|
2794
|
+
if _get_prop(node, "expanded") == "true":
|
|
2795
|
+
attrs.append("expanded")
|
|
2796
|
+
|
|
2797
|
+
attr_str = (" " + " ".join(attrs)) if attrs else ""
|
|
2798
|
+
output_lines.append(f"@{ref_count} [{normalized}] \"{display_name}\"{attr_str}")
|
|
2799
|
+
|
|
2800
|
+
_save_a11y_refs(_A11Y_REF_MAP)
|
|
2801
|
+
|
|
2802
|
+
for line in output_lines:
|
|
2803
|
+
print(line)
|
|
2804
|
+
print(f"\n[{interactive_count} interactive, {ref_count} total shown]")
|
|
2805
|
+
print()
|
|
2806
|
+
|
|
2807
|
+
# ── Screenshot ─────────────────────────────────────────────────────────
|
|
2808
|
+
print("=== Screenshot ===")
|
|
2809
|
+
ts = int(time.time())
|
|
2810
|
+
shot_path = f"{SCREENSHOT_DIR}/cdpilot-describe-{ts}.png"
|
|
2811
|
+
shot_res = await cdp_send(ws_url, [(2, "Page.captureScreenshot", {"format": "png", "captureBeyondViewport": False})])
|
|
2812
|
+
shot_data = shot_res.get(2, {}).get("data")
|
|
2813
|
+
if shot_data:
|
|
2814
|
+
with open(shot_path, "wb") as f:
|
|
2815
|
+
f.write(base64.b64decode(shot_data))
|
|
2816
|
+
print(f"Saved: {shot_path}")
|
|
2817
|
+
else:
|
|
2818
|
+
print("Could not capture screenshot.", file=sys.stderr)
|
|
2819
|
+
print()
|
|
2820
|
+
|
|
2821
|
+
# ── Text Content ────────────────────────────────────────────────────────
|
|
2822
|
+
print("=== Page Content (first 2000 chars) ===")
|
|
2823
|
+
eval_res = await cdp_send(ws_url, [(3, "Runtime.evaluate", {
|
|
2824
|
+
"expression": "document.body ? document.body.innerText : ''",
|
|
2825
|
+
"returnByValue": True
|
|
2826
|
+
})])
|
|
2827
|
+
page_text = eval_res.get(3, {}).get("result", {}).get("value", "")
|
|
2828
|
+
print(page_text[:2000])
|
|
2829
|
+
|
|
2830
|
+
|
|
2831
|
+
# ─── Testing Commands ───
|
|
2832
|
+
|
|
2833
|
+
async def cmd_assert(selector, expected_text=None, check_visible=True):
|
|
2834
|
+
"""Assert element exists, optionally check text content and visibility."""
|
|
2835
|
+
ws_url, _ = get_page_ws()
|
|
2836
|
+
visible_check = ""
|
|
2837
|
+
if check_visible:
|
|
2838
|
+
visible_check = """
|
|
2839
|
+
var rect = el.getBoundingClientRect();
|
|
2840
|
+
var style = window.getComputedStyle(el);
|
|
2841
|
+
if (style.display === 'none' || style.visibility === 'hidden' ||
|
|
2842
|
+
style.opacity === '0' || (rect.width === 0 && rect.height === 0)) {
|
|
2843
|
+
return 'FAIL: Element found but not visible: ' + sel;
|
|
2844
|
+
}"""
|
|
2845
|
+
text_check = ""
|
|
2846
|
+
if expected_text:
|
|
2847
|
+
safe_text = json.dumps(expected_text)
|
|
2848
|
+
text_check = f"""
|
|
2849
|
+
var actual = el.textContent || el.value || '';
|
|
2850
|
+
if (actual.indexOf({safe_text}) === -1) {{
|
|
2851
|
+
return 'FAIL: Expected text ' + {safe_text} + ' not found in: ' + actual.substring(0, 100);
|
|
2852
|
+
}}"""
|
|
2853
|
+
safe_sel = json.dumps(selector)
|
|
2854
|
+
js = f"""
|
|
2855
|
+
(function() {{
|
|
2856
|
+
var sel = {safe_sel};
|
|
2857
|
+
var el = document.querySelector(sel);
|
|
2858
|
+
if (!el) return 'FAIL: Element not found: ' + sel;
|
|
2859
|
+
{visible_check}
|
|
2860
|
+
{text_check}
|
|
2861
|
+
var tag = el.tagName.toLowerCase();
|
|
2862
|
+
var txt = (el.textContent || '').substring(0, 60).trim();
|
|
2863
|
+
return 'PASS: ' + tag + (txt ? ' "' + txt + '"' : '') + ' (' + sel + ')';
|
|
2864
|
+
}})()
|
|
2865
|
+
"""
|
|
2866
|
+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
2867
|
+
result = r.get(1, {}).get("result", {}).get("value", "ERROR")
|
|
2868
|
+
print(result)
|
|
2869
|
+
|
|
2870
|
+
|
|
2871
|
+
async def cmd_wait_for(selector, timeout_ms=5000):
|
|
2872
|
+
"""Wait for element to appear in DOM, up to timeout."""
|
|
2873
|
+
ws_url, _ = get_page_ws()
|
|
2874
|
+
safe_sel = json.dumps(selector)
|
|
2875
|
+
js = f"""
|
|
2876
|
+
(function() {{
|
|
2877
|
+
return new Promise(function(resolve) {{
|
|
2878
|
+
var sel = {safe_sel};
|
|
2879
|
+
var el = document.querySelector(sel);
|
|
2880
|
+
if (el) return resolve('FOUND: ' + el.tagName + ' "' + (el.textContent || '').substring(0, 60).trim() + '"');
|
|
2881
|
+
var obs = new MutationObserver(function() {{
|
|
2882
|
+
var el = document.querySelector(sel);
|
|
2883
|
+
if (el) {{ obs.disconnect(); resolve('FOUND: ' + el.tagName + ' "' + (el.textContent || '').substring(0, 60).trim() + '"'); }}
|
|
2884
|
+
}});
|
|
2885
|
+
obs.observe(document.body, {{childList: true, subtree: true}});
|
|
2886
|
+
setTimeout(function() {{ obs.disconnect(); resolve('TIMEOUT: ' + sel + ' not found after {timeout_ms}ms'); }}, {timeout_ms});
|
|
2887
|
+
}});
|
|
2888
|
+
}})()
|
|
2889
|
+
"""
|
|
2890
|
+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True, "awaitPromise": True})], timeout=max(15, timeout_ms // 1000 + 5))
|
|
2891
|
+
result = r.get(1, {}).get("result", {}).get("value", "ERROR")
|
|
2892
|
+
print(result)
|
|
2893
|
+
|
|
2894
|
+
|
|
2895
|
+
async def cmd_check(checks_json=None):
|
|
2896
|
+
"""Run batch assertions. Input: JSON array of {selector, text?} objects."""
|
|
2897
|
+
ws_url, _ = get_page_ws()
|
|
2898
|
+
if checks_json is None:
|
|
2899
|
+
raw = sys.stdin.read().strip()
|
|
2900
|
+
else:
|
|
2901
|
+
raw = checks_json
|
|
2902
|
+
try:
|
|
2903
|
+
checks = json.loads(raw)
|
|
2904
|
+
except (json.JSONDecodeError, TypeError):
|
|
2905
|
+
print("Error: Invalid JSON. Expected: [{\"selector\": \"...\", \"text\": \"...\"}]")
|
|
2906
|
+
sys.exit(1)
|
|
2907
|
+
|
|
2908
|
+
passed = 0
|
|
2909
|
+
failed = 0
|
|
2910
|
+
results = []
|
|
2911
|
+
for i, check in enumerate(checks, 1):
|
|
2912
|
+
sel = check.get("selector", "")
|
|
2913
|
+
text = check.get("text")
|
|
2914
|
+
safe_sel = json.dumps(sel)
|
|
2915
|
+
text_check = ""
|
|
2916
|
+
if text:
|
|
2917
|
+
safe_text = json.dumps(text)
|
|
2918
|
+
text_check = f"""
|
|
2919
|
+
var actual = el.textContent || el.value || '';
|
|
2920
|
+
if (actual.indexOf({safe_text}) === -1) {{
|
|
2921
|
+
return 'FAIL: Expected ' + {safe_text} + ' in: ' + actual.substring(0, 80);
|
|
2922
|
+
}}"""
|
|
2923
|
+
js = f"""
|
|
2924
|
+
(function() {{
|
|
2925
|
+
var el = document.querySelector({safe_sel});
|
|
2926
|
+
if (!el) return 'FAIL: Not found: ' + {safe_sel};
|
|
2927
|
+
{text_check}
|
|
2928
|
+
return 'PASS: ' + el.tagName + ' (' + {safe_sel} + ')';
|
|
2929
|
+
}})()
|
|
2930
|
+
"""
|
|
2931
|
+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
2932
|
+
result = r.get(1, {}).get("result", {}).get("value", "ERROR")
|
|
2933
|
+
if result.startswith("PASS"):
|
|
2934
|
+
passed += 1
|
|
2935
|
+
else:
|
|
2936
|
+
failed += 1
|
|
2937
|
+
results.append(f" {i}. {result}")
|
|
2938
|
+
|
|
2939
|
+
print(f"Test Report: {passed} passed, {failed} failed")
|
|
2940
|
+
print("─" * 40)
|
|
2941
|
+
for line in results:
|
|
2942
|
+
print(line)
|
|
2943
|
+
|
|
2944
|
+
|
|
2945
|
+
async def cmd_assert_url(expected_url):
|
|
2946
|
+
"""Assert current page URL contains the expected substring."""
|
|
2947
|
+
ws_url, _ = get_page_ws()
|
|
2948
|
+
safe_expected = json.dumps(expected_url)
|
|
2949
|
+
js = f"""(function() {{
|
|
2950
|
+
var href = window.location.href;
|
|
2951
|
+
var expected = {safe_expected};
|
|
2952
|
+
if (href.indexOf(expected) !== -1) return 'PASS: URL ' + href + ' contains \"' + expected + '\"';
|
|
2953
|
+
return 'FAIL: URL ' + href + ' does not contain \"' + expected + '\"';
|
|
2954
|
+
}})()"""
|
|
2955
|
+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
2956
|
+
result = r.get(1, {}).get("result", {}).get("value", "ERROR")
|
|
2957
|
+
print(result)
|
|
2958
|
+
|
|
2959
|
+
|
|
2960
|
+
async def cmd_assert_title(expected_title):
|
|
2961
|
+
"""Assert page title contains the expected substring."""
|
|
2962
|
+
ws_url, _ = get_page_ws()
|
|
2963
|
+
safe_expected = json.dumps(expected_title)
|
|
2964
|
+
js = f"""(function() {{
|
|
2965
|
+
var title = document.title;
|
|
2966
|
+
var expected = {safe_expected};
|
|
2967
|
+
if (title.indexOf(expected) !== -1) return 'PASS: Title \"' + title + '\" contains \"' + expected + '\"';
|
|
2968
|
+
return 'FAIL: Title \"' + title + '\" does not contain \"' + expected + '\"';
|
|
2969
|
+
}})()"""
|
|
2970
|
+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
2971
|
+
result = r.get(1, {}).get("result", {}).get("value", "ERROR")
|
|
2972
|
+
print(result)
|
|
2973
|
+
|
|
2974
|
+
|
|
2975
|
+
async def cmd_assert_count(selector, expected_count):
|
|
2976
|
+
"""Assert the number of elements matching a CSS selector equals expected_count."""
|
|
2977
|
+
ws_url, _ = get_page_ws()
|
|
2978
|
+
safe_sel = json.dumps(selector)
|
|
2979
|
+
exp = int(expected_count)
|
|
2980
|
+
js = f"""(function() {{
|
|
2981
|
+
var count = document.querySelectorAll({safe_sel}).length;
|
|
2982
|
+
var exp = {exp};
|
|
2983
|
+
if (count === exp) return 'PASS: Found ' + count + ' element(s) matching {safe_sel} (expected ' + exp + ')';
|
|
2984
|
+
return 'FAIL: Expected ' + exp + ' \"{selector}\" but found ' + count;
|
|
2985
|
+
}})()"""
|
|
2986
|
+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
2987
|
+
result = r.get(1, {}).get("result", {}).get("value", "ERROR")
|
|
2988
|
+
print(result)
|
|
2989
|
+
|
|
2990
|
+
|
|
2991
|
+
async def cmd_assert_value(selector, expected_value):
|
|
2992
|
+
"""Assert an input/textarea/select element's value equals expected_value."""
|
|
2993
|
+
ws_url, _ = get_page_ws()
|
|
2994
|
+
safe_sel = json.dumps(selector)
|
|
2995
|
+
safe_expected = json.dumps(expected_value)
|
|
2996
|
+
js = f"""(function() {{
|
|
2997
|
+
var el = document.querySelector({safe_sel});
|
|
2998
|
+
if (!el) return 'FAIL: Element not found: ' + {safe_sel};
|
|
2999
|
+
var val = el.value;
|
|
3000
|
+
var expected = {safe_expected};
|
|
3001
|
+
if (val === expected) return 'PASS: Value matches \"' + expected + '\"';
|
|
3002
|
+
return 'FAIL: Expected value \"' + expected + '\" but got \"' + val + '\"';
|
|
3003
|
+
}})()"""
|
|
3004
|
+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
3005
|
+
result = r.get(1, {}).get("result", {}).get("value", "ERROR")
|
|
3006
|
+
print(result)
|
|
3007
|
+
|
|
3008
|
+
|
|
3009
|
+
async def cmd_assert_attr(selector, attr, expected):
|
|
3010
|
+
"""Assert element attribute value contains expected substring."""
|
|
3011
|
+
ws_url, _ = get_page_ws()
|
|
3012
|
+
safe_sel = json.dumps(selector)
|
|
3013
|
+
safe_attr = json.dumps(attr)
|
|
3014
|
+
safe_expected = json.dumps(expected)
|
|
3015
|
+
js = f"""(function() {{
|
|
3016
|
+
var el = document.querySelector({safe_sel});
|
|
3017
|
+
if (!el) return 'FAIL: Element not found: ' + {safe_sel};
|
|
3018
|
+
var val = el.getAttribute({safe_attr}) || '';
|
|
3019
|
+
var expected = {safe_expected};
|
|
3020
|
+
if (val.indexOf(expected) !== -1) return 'PASS: ' + {safe_sel} + '[' + {safe_attr} + '] = \"' + val + '\"';
|
|
3021
|
+
return 'FAIL: Expected ' + {safe_sel} + '[' + {safe_attr} + '] to contain \"' + expected + '\" but got \"' + val + '\"';
|
|
3022
|
+
}})()"""
|
|
3023
|
+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
3024
|
+
result = r.get(1, {}).get("result", {}).get("value", "ERROR")
|
|
3025
|
+
print(result)
|
|
3026
|
+
|
|
3027
|
+
|
|
3028
|
+
async def cmd_assert_visible(selector, should_be_visible=True):
|
|
3029
|
+
"""Assert element is visible (or hidden). should_be_visible=True checks for visible, False for hidden."""
|
|
3030
|
+
ws_url, _ = get_page_ws()
|
|
3031
|
+
safe_sel = json.dumps(selector)
|
|
3032
|
+
expect_label = "visible" if should_be_visible else "hidden"
|
|
3033
|
+
opposite_label = "hidden" if should_be_visible else "visible"
|
|
3034
|
+
js = f"""(function() {{
|
|
3035
|
+
var el = document.querySelector({safe_sel});
|
|
3036
|
+
if (!el) return 'FAIL: Element not found: ' + {safe_sel};
|
|
3037
|
+
var style = window.getComputedStyle(el);
|
|
3038
|
+
var rect = el.getBoundingClientRect();
|
|
3039
|
+
var isVisible = (
|
|
3040
|
+
style.display !== 'none' &&
|
|
3041
|
+
style.visibility !== 'hidden' &&
|
|
3042
|
+
style.opacity !== '0' &&
|
|
3043
|
+
(rect.width > 0 || rect.height > 0)
|
|
3044
|
+
);
|
|
3045
|
+
var expectVisible = {str(should_be_visible).lower()};
|
|
3046
|
+
if (isVisible === expectVisible) return 'PASS: ' + {safe_sel} + ' is {expect_label}';
|
|
3047
|
+
return 'FAIL: ' + {safe_sel} + ' expected {expect_label} but is {opposite_label}';
|
|
3048
|
+
}})()"""
|
|
3049
|
+
r = await cdp_send(ws_url, [(1, "Runtime.evaluate", {"expression": js, "returnByValue": True})])
|
|
3050
|
+
result = r.get(1, {}).get("result", {}).get("value", "ERROR")
|
|
3051
|
+
print(result)
|
|
3052
|
+
|
|
3053
|
+
|
|
3054
|
+
async def cmd_screenshot_diff(path1, path2):
|
|
3055
|
+
"""Compare two screenshot files byte-by-byte. No CDP required."""
|
|
3056
|
+
for path in (path1, path2):
|
|
3057
|
+
if not os.path.exists(path):
|
|
3058
|
+
print(f"ERROR: File not found: {path}")
|
|
3059
|
+
return
|
|
3060
|
+
size1 = os.path.getsize(path1)
|
|
3061
|
+
size2 = os.path.getsize(path2)
|
|
3062
|
+
with open(path1, "rb") as f1, open(path2, "rb") as f2:
|
|
3063
|
+
data1 = f1.read()
|
|
3064
|
+
data2 = f2.read()
|
|
3065
|
+
if data1 == data2:
|
|
3066
|
+
print("MATCH: Files are identical")
|
|
3067
|
+
else:
|
|
3068
|
+
kb1 = size1 / 1024
|
|
3069
|
+
kb2 = size2 / 1024
|
|
3070
|
+
print(f"DIFF: Files differ ({os.path.basename(path1)}: {kb1:.1f}KB, {os.path.basename(path2)}: {kb2:.1f}KB)")
|
|
3071
|
+
|
|
3072
|
+
|
|
3073
|
+
async def cmd_a11y_snapshot():
|
|
3074
|
+
"""Output a compact accessibility snapshot for AI agent navigation.
|
|
3075
|
+
|
|
3076
|
+
Each line: @ref [role] "name" attributes...
|
|
3077
|
+
Use 'click-ref @N' to click an element by its reference number.
|
|
3078
|
+
"""
|
|
3079
|
+
global _A11Y_REF_MAP
|
|
3080
|
+
_A11Y_REF_MAP = {}
|
|
3081
|
+
ws_url, _ = get_page_ws()
|
|
3082
|
+
await cdp_send(ws_url, [(0, "Accessibility.enable", {})])
|
|
3083
|
+
res = await cdp_send(ws_url, [(1, "Accessibility.getFullAXTree", {})])
|
|
3084
|
+
nodes = res.get(1, {}).get("nodes", [])
|
|
3085
|
+
if not nodes:
|
|
3086
|
+
print("Could not get accessibility tree.", file=sys.stderr)
|
|
3087
|
+
sys.exit(1)
|
|
3088
|
+
|
|
3089
|
+
ROLE_NORMALIZE = {
|
|
3090
|
+
"textField": "textbox",
|
|
3091
|
+
"comboBox": "combobox",
|
|
3092
|
+
"checkBox": "checkbox",
|
|
3093
|
+
"radioButton": "radio",
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
SKIP_ROLES = {
|
|
3097
|
+
"none", "presentation", "generic", "LineBreak", "InlineTextBox",
|
|
3098
|
+
"ignored", "unknown"
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
INTERACTIVE_ROLES = {
|
|
3102
|
+
"button", "link", "textbox", "checkbox", "radio", "combobox", "listbox",
|
|
3103
|
+
"menuitem", "menuitemcheckbox", "menuitemradio", "option", "spinbutton",
|
|
3104
|
+
"slider", "switch", "tab", "treeitem"
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
STRUCTURAL_ROLES = {
|
|
3108
|
+
"heading", "img", "navigation", "menu", "list", "listitem", "table",
|
|
3109
|
+
"grid", "row", "cell", "columnheader", "rowheader", "dialog", "alert",
|
|
3110
|
+
"main", "banner", "contentinfo", "region", "figure", "article", "section"
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
def _get_prop(node, prop_name):
|
|
3114
|
+
for p in node.get("properties", []):
|
|
3115
|
+
if p.get("name") == prop_name:
|
|
3116
|
+
return p.get("value", {}).get("value", "")
|
|
3117
|
+
return ""
|
|
3118
|
+
|
|
3119
|
+
ref_count = 0
|
|
3120
|
+
interactive_count = 0
|
|
3121
|
+
output_lines = []
|
|
3122
|
+
|
|
3123
|
+
for node in nodes:
|
|
3124
|
+
backend_node_id = node.get("backendDOMNodeId") or node.get("backendNodeId")
|
|
3125
|
+
role = node.get("role", {}).get("value")
|
|
3126
|
+
name = node.get("name", {}).get("value")
|
|
3127
|
+
description = node.get("description", {}).get("value")
|
|
3128
|
+
ignored = node.get("ignored", False)
|
|
3129
|
+
|
|
3130
|
+
if ignored:
|
|
3131
|
+
continue
|
|
3132
|
+
if not role or role in SKIP_ROLES:
|
|
3133
|
+
continue
|
|
3134
|
+
if not backend_node_id:
|
|
3135
|
+
continue
|
|
3136
|
+
if not name and not description and role in {"staticText", "text", "paragraph"}:
|
|
3137
|
+
continue
|
|
3138
|
+
|
|
3139
|
+
normalized = ROLE_NORMALIZE.get(role, role)
|
|
3140
|
+
|
|
3141
|
+
# heading/N format
|
|
3142
|
+
if normalized == "heading":
|
|
3143
|
+
level = _get_prop(node, "level")
|
|
3144
|
+
if level:
|
|
3145
|
+
normalized = f"heading/{level}"
|
|
3146
|
+
|
|
3147
|
+
# Determine inclusion
|
|
3148
|
+
base_role = normalized.split("/")[0]
|
|
3149
|
+
is_interactive = base_role in INTERACTIVE_ROLES
|
|
3150
|
+
is_structural = base_role in STRUCTURAL_ROLES
|
|
3151
|
+
if not (is_interactive or (is_structural and (name or description))):
|
|
3152
|
+
continue
|
|
3153
|
+
|
|
3154
|
+
display_name = name or description or ""
|
|
3155
|
+
|
|
3156
|
+
attrs = []
|
|
3157
|
+
|
|
3158
|
+
if base_role == "link":
|
|
3159
|
+
href = _get_prop(node, "url")
|
|
3160
|
+
if href:
|
|
3161
|
+
attrs.append(f"href={href}")
|
|
3162
|
+
|
|
3163
|
+
if _get_prop(node, "disabled") == "true":
|
|
3164
|
+
attrs.append("disabled")
|
|
3165
|
+
|
|
3166
|
+
if _get_prop(node, "required") == "true":
|
|
3167
|
+
attrs.append("required")
|
|
3168
|
+
|
|
3169
|
+
if base_role in {"textbox", "combobox"}:
|
|
3170
|
+
val = _get_prop(node, "value")
|
|
3171
|
+
if val:
|
|
3172
|
+
attrs.append(f"value={val!r}")
|
|
3173
|
+
ph = _get_prop(node, "placeholder")
|
|
3174
|
+
if ph:
|
|
3175
|
+
attrs.append(f"placeholder={ph!r}")
|
|
3176
|
+
|
|
3177
|
+
if base_role in {"checkbox", "radio"}:
|
|
3178
|
+
if _get_prop(node, "checked") == "true":
|
|
3179
|
+
attrs.append("checked")
|
|
3180
|
+
|
|
3181
|
+
if _get_prop(node, "expanded") == "true":
|
|
3182
|
+
attrs.append("expanded")
|
|
3183
|
+
|
|
3184
|
+
ref_count += 1
|
|
3185
|
+
_A11Y_REF_MAP[ref_count] = backend_node_id
|
|
3186
|
+
|
|
3187
|
+
if is_interactive:
|
|
3188
|
+
interactive_count += 1
|
|
3189
|
+
|
|
3190
|
+
attr_str = (" " + " ".join(attrs)) if attrs else ""
|
|
3191
|
+
line = f"@{ref_count} [{normalized}] \"{display_name}\"{attr_str}"
|
|
3192
|
+
output_lines.append(line)
|
|
3193
|
+
|
|
3194
|
+
_save_a11y_refs(_A11Y_REF_MAP)
|
|
3195
|
+
|
|
3196
|
+
for line in output_lines:
|
|
3197
|
+
print(line)
|
|
3198
|
+
print(f"\n[{interactive_count} interactive, {ref_count} total shown]")
|
|
3199
|
+
|
|
3200
|
+
|
|
3201
|
+
async def cmd_click_ref(ref_str):
|
|
3202
|
+
"""Click an element by its @N reference from the last a11y-snapshot."""
|
|
3203
|
+
ws_url, _ = get_page_ws()
|
|
3204
|
+
|
|
3205
|
+
try:
|
|
3206
|
+
ref_num = int(ref_str.lstrip("@"))
|
|
3207
|
+
except ValueError:
|
|
3208
|
+
print(f"Error: Invalid reference '{ref_str}'. Expected @N (e.g. @3).", file=sys.stderr)
|
|
3209
|
+
sys.exit(1)
|
|
3210
|
+
|
|
3211
|
+
ref_map = _A11Y_REF_MAP or _load_a11y_refs()
|
|
3212
|
+
backend_node_id = ref_map.get(ref_num)
|
|
3213
|
+
if not backend_node_id:
|
|
3214
|
+
print(f"Error: Reference '@{ref_num}' not found. Run 'a11y-snapshot' first.", file=sys.stderr)
|
|
3215
|
+
sys.exit(1)
|
|
3216
|
+
|
|
3217
|
+
# Get element box model via backendNodeId directly
|
|
3218
|
+
await cdp_send(ws_url, [(0, "DOM.enable", {})])
|
|
3219
|
+
res1 = await cdp_send(ws_url, [(1, "DOM.getBoxModel", {"backendNodeId": backend_node_id})])
|
|
3220
|
+
model = res1.get(1, {}).get("model")
|
|
3221
|
+
if not model:
|
|
3222
|
+
# Fallback: resolve to objectId and use JS
|
|
3223
|
+
res_r = await cdp_send(ws_url, [(10, "DOM.resolveNode", {"backendNodeId": backend_node_id})])
|
|
3224
|
+
oid = res_r.get(10, {}).get("object", {}).get("objectId")
|
|
3225
|
+
if oid:
|
|
3226
|
+
res_js = await cdp_send(ws_url, [(11, "Runtime.callFunctionOn", {
|
|
3227
|
+
"functionDeclaration": "function(){var r=this.getBoundingClientRect();return{x:Math.round(r.left+r.width/2),y:Math.round(r.top+r.height/2)};}",
|
|
3228
|
+
"objectId": oid, "returnByValue": True,
|
|
3229
|
+
})])
|
|
3230
|
+
val = res_js.get(11, {}).get("result", {}).get("value")
|
|
3231
|
+
if val and "x" in val:
|
|
3232
|
+
model = None # skip box model path
|
|
3233
|
+
x, y = val["x"], val["y"]
|
|
3234
|
+
if model:
|
|
3235
|
+
# content quad: [x1,y1, x2,y2, x3,y3, x4,y4]
|
|
3236
|
+
content = model.get("content", model.get("border", []))
|
|
3237
|
+
if len(content) >= 8:
|
|
3238
|
+
x = int((content[0] + content[2] + content[4] + content[6]) / 4)
|
|
3239
|
+
y = int((content[1] + content[3] + content[5] + content[7]) / 4)
|
|
3240
|
+
val = {"x": x, "y": y}
|
|
3241
|
+
else:
|
|
3242
|
+
val = None
|
|
3243
|
+
elif not model and 'x' not in dir():
|
|
3244
|
+
val = None
|
|
3245
|
+
|
|
3246
|
+
if not val or "x" not in val:
|
|
3247
|
+
print(f"Error: Could not get coordinates for @{ref_num}.", file=sys.stderr)
|
|
3248
|
+
sys.exit(1)
|
|
3249
|
+
|
|
3250
|
+
x, y = val["x"], val["y"]
|
|
3251
|
+
await _vfx_ripple(ws_url, x, y)
|
|
3252
|
+
await cdp_send(ws_url, [
|
|
3253
|
+
(3, "Input.dispatchMouseEvent", {
|
|
3254
|
+
"type": "mousePressed", "x": x, "y": y, "button": "left", "clickCount": 1
|
|
3255
|
+
}),
|
|
3256
|
+
(4, "Input.dispatchMouseEvent", {
|
|
3257
|
+
"type": "mouseReleased", "x": x, "y": y, "button": "left", "clickCount": 1
|
|
3258
|
+
}),
|
|
3259
|
+
])
|
|
3260
|
+
print(f"Clicked @{ref_num} (backendNodeId={backend_node_id}): ({x}, {y})")
|
|
3261
|
+
|
|
3262
|
+
|
|
1861
3263
|
# ─── 3. Advanced Input Commands ───
|
|
1862
3264
|
|
|
1863
3265
|
async def cmd_hover(selector):
|
|
1864
3266
|
"""Move the mouse cursor to the specified element."""
|
|
1865
3267
|
ws_url, _ = get_page_ws()
|
|
1866
3268
|
x, y = await _get_element_center(ws_url, selector)
|
|
3269
|
+
await _vfx_move_cursor(ws_url, x, y)
|
|
1867
3270
|
await cdp_send(ws_url, [(1, "Input.dispatchMouseEvent",
|
|
1868
3271
|
{"type": "mouseMoved", "x": x, "y": y, "button": "none", "modifiers": 0})])
|
|
1869
3272
|
print(f"Hover: {selector} ({x}, {y})")
|
|
@@ -1873,6 +3276,7 @@ async def cmd_dblclick(selector):
|
|
|
1873
3276
|
"""Double-click the specified element."""
|
|
1874
3277
|
ws_url, _ = get_page_ws()
|
|
1875
3278
|
x, y = await _get_element_center(ws_url, selector)
|
|
3279
|
+
await _vfx_ripple(ws_url, x, y)
|
|
1876
3280
|
cmds = [
|
|
1877
3281
|
(1, "Input.dispatchMouseEvent", {"type": "mousePressed", "x": x, "y": y, "button": "left", "clickCount": 1}),
|
|
1878
3282
|
(2, "Input.dispatchMouseEvent", {"type": "mouseReleased", "x": x, "y": y, "button": "left", "clickCount": 1}),
|
|
@@ -1887,6 +3291,7 @@ async def cmd_rightclick(selector):
|
|
|
1887
3291
|
"""Right-click the specified element."""
|
|
1888
3292
|
ws_url, _ = get_page_ws()
|
|
1889
3293
|
x, y = await _get_element_center(ws_url, selector)
|
|
3294
|
+
await _vfx_ripple(ws_url, x, y)
|
|
1890
3295
|
cmds = [
|
|
1891
3296
|
(1, "Input.dispatchMouseEvent", {"type": "mousePressed", "x": x, "y": y, "button": "right", "clickCount": 1}),
|
|
1892
3297
|
(2, "Input.dispatchMouseEvent", {"type": "mouseReleased", "x": x, "y": y, "button": "right", "clickCount": 1}),
|
|
@@ -1900,6 +3305,7 @@ async def cmd_drag(from_selector, to_selector):
|
|
|
1900
3305
|
ws_url, _ = get_page_ws()
|
|
1901
3306
|
fx, fy = await _get_element_center(ws_url, from_selector)
|
|
1902
3307
|
tx, ty = await _get_element_center(ws_url, to_selector)
|
|
3308
|
+
await _vfx_ripple(ws_url, fx, fy)
|
|
1903
3309
|
|
|
1904
3310
|
import websockets
|
|
1905
3311
|
async with websockets.connect(ws_url, max_size=100 * 1024 * 1024) as ws:
|
|
@@ -1913,6 +3319,7 @@ async def cmd_drag(from_selector, to_selector):
|
|
|
1913
3319
|
for i in range(1, steps + 1):
|
|
1914
3320
|
ix = int(fx + (tx - fx) * i / steps)
|
|
1915
3321
|
iy = int(fy + (ty - fy) * i / steps)
|
|
3322
|
+
await _vfx_move_cursor(ws_url, ix, iy)
|
|
1916
3323
|
await send_mouse(10 + i, "mouseMoved", ix, iy)
|
|
1917
3324
|
await asyncio.sleep(0.05)
|
|
1918
3325
|
await send_mouse(20, "mouseReleased", tx, ty)
|
|
@@ -1957,6 +3364,7 @@ async def cmd_keys(combo):
|
|
|
1957
3364
|
print("Error: specify a valid key (e.g. ctrl+a, enter, tab).", file=sys.stderr)
|
|
1958
3365
|
sys.exit(1)
|
|
1959
3366
|
|
|
3367
|
+
await _vfx_keystroke(ws_url, combo.upper())
|
|
1960
3368
|
cmds = [
|
|
1961
3369
|
(1, "Input.dispatchKeyEvent", {"type": "keyDown", "modifiers": modifiers,
|
|
1962
3370
|
"key": key_name, "windowsVirtualKeyCode": key_code, "nativeVirtualKeyCode": key_code}),
|
|
@@ -2222,34 +3630,58 @@ class MCPServer:
|
|
|
2222
3630
|
|
|
2223
3631
|
def _register_tools(self):
|
|
2224
3632
|
return [
|
|
2225
|
-
{"name": "browser_navigate", "description": "Navigate to a URL",
|
|
2226
|
-
"inputSchema": {"type": "object", "properties": {"url": {"type": "string", "description": "URL to navigate to"}}, "required": ["url"]}},
|
|
2227
|
-
{"name": "browser_screenshot", "description": "
|
|
2228
|
-
"inputSchema": {"type": "object", "properties": {"filename": {"type": "string", "description": "Output
|
|
2229
|
-
{"name": "browser_click", "description": "Click an element by CSS selector",
|
|
2230
|
-
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector"}}, "required": ["selector"]}},
|
|
2231
|
-
{"name": "browser_type", "description": "Type text into an input element",
|
|
2232
|
-
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector"}, "text": {"type": "string", "description": "Text to type"}}, "required": ["selector", "text"]}},
|
|
2233
|
-
{"name": "browser_content", "description": "Get text content of the current page",
|
|
3633
|
+
{"name": "browser_navigate", "description": "Navigate the browser to a URL and return the page text content. Use this to open websites, follow links, or load web applications. Waits for page load before returning. Returns first 10000 chars of visible text.",
|
|
3634
|
+
"inputSchema": {"type": "object", "properties": {"url": {"type": "string", "description": "Full URL to navigate to (must include https://)"}}, "required": ["url"]}},
|
|
3635
|
+
{"name": "browser_screenshot", "description": "Capture a screenshot of the current browser viewport. Supports element-level cropping to save tokens (capture only a specific element instead of full page). Use JPEG format with quality parameter for smaller files (~5x smaller than PNG). Prefer element cropping + JPEG for token-efficient AI workflows.",
|
|
3636
|
+
"inputSchema": {"type": "object", "properties": {"filename": {"type": "string", "description": "Output file path (e.g. /tmp/screenshot.png). Extension determines format: .png, .jpg, .webp", "default": "screenshot.png"}, "element": {"type": "string", "description": "CSS selector to capture only that element (crops to bounding box). Saves ~3-7x tokens vs full page."}, "quality": {"type": "number", "description": "JPEG quality 1-100 (only for .jpg files). Lower = smaller file = fewer tokens. Default: 80"}}}},
|
|
3637
|
+
{"name": "browser_click", "description": "Click an element on the page identified by CSS selector. Auto-waits up to 5 seconds for the element to appear (MutationObserver). Scrolls the element into view before clicking. Returns the tag name and text of the clicked element.",
|
|
3638
|
+
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the element to click (e.g. '#submit-btn', '.nav a', 'button[type=submit]')"}}, "required": ["selector"]}},
|
|
3639
|
+
{"name": "browser_type", "description": "Type text into an input or textarea element. Uses React-compatible value setting (native setter + input/change events). Auto-waits up to 5 seconds for the element. Use browser_fill as an alias.",
|
|
3640
|
+
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the input element"}, "text": {"type": "string", "description": "Text value to type into the element"}}, "required": ["selector", "text"]}},
|
|
3641
|
+
{"name": "browser_content", "description": "Get the visible text content of the current page (document.body.innerText). Returns up to 10000 characters. Use this to read page content without HTML markup. Prefer browser_a11y for structured navigation data.",
|
|
3642
|
+
"inputSchema": {"type": "object", "properties": {}}},
|
|
3643
|
+
{"name": "browser_html", "description": "Get the full HTML source of the current page (document.documentElement.outerHTML). Returns up to 50000 characters. Use sparingly — prefer browser_content or browser_a11y for most tasks as they return smaller, more useful data.",
|
|
2234
3644
|
"inputSchema": {"type": "object", "properties": {}}},
|
|
2235
|
-
{"name": "
|
|
3645
|
+
{"name": "browser_eval", "description": "Execute arbitrary JavaScript code in the browser page context and return the result. Use for custom DOM queries, data extraction, or page manipulation that other tools don't cover. Expression is evaluated via Runtime.evaluate.",
|
|
3646
|
+
"inputSchema": {"type": "object", "properties": {"expression": {"type": "string", "description": "JavaScript expression to evaluate (e.g. 'document.title', 'document.querySelectorAll(\"a\").length')"}}, "required": ["expression"]}},
|
|
3647
|
+
{"name": "browser_tabs", "description": "List all open browser tabs with their IDs, URLs, and titles. Use this to see what pages are open and get tab IDs for switching between them with other navigation commands.",
|
|
2236
3648
|
"inputSchema": {"type": "object", "properties": {}}},
|
|
2237
|
-
{"name": "
|
|
2238
|
-
"inputSchema": {"type": "object", "properties": {"
|
|
2239
|
-
{"name": "
|
|
3649
|
+
{"name": "browser_console", "description": "Navigate to a URL and capture all browser console output (log, warn, error, info) and uncaught exceptions. Use for debugging JavaScript errors, monitoring API calls logged to console, or verifying application behavior.",
|
|
3650
|
+
"inputSchema": {"type": "object", "properties": {"url": {"type": "string", "description": "URL to navigate to while capturing console output"}}}},
|
|
3651
|
+
{"name": "browser_network", "description": "Navigate to a URL and monitor all network requests/responses. Returns each request's URL, HTTP status code, and MIME type. Use for debugging API calls, checking resource loading, or verifying network behavior.",
|
|
3652
|
+
"inputSchema": {"type": "object", "properties": {"url": {"type": "string", "description": "URL to navigate to while monitoring network traffic"}}}},
|
|
3653
|
+
{"name": "browser_a11y", "description": "Get the accessibility tree of the current page as structured data. Returns interactive elements with @N references that can be used with browser_click. This is the PREFERRED way to understand page structure — uses ~500 tokens vs ~250K for screenshots. Use 'summary' mode for a compact view.",
|
|
3654
|
+
"inputSchema": {"type": "object", "properties": {"mode": {"type": "string", "enum": ["full", "summary"], "description": "Output detail level: 'full' for complete tree, 'summary' for interactive elements only", "default": "full"}}}},
|
|
3655
|
+
{"name": "browser_fill", "description": "Set an input element's value using React-compatible method (native descriptor setter + input/change events). Auto-waits up to 5 seconds for the element. Works with regular inputs, textareas, and React controlled components.",
|
|
3656
|
+
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the input element"}, "value": {"type": "string", "description": "Value to set in the input field"}}, "required": ["selector", "value"]}},
|
|
3657
|
+
{"name": "browser_launch", "description": "Launch an isolated browser instance with Chrome DevTools Protocol enabled. Uses existing Brave/Chrome/Chromium installation — no browser download needed. Creates an isolated profile directory so your personal browser data is never touched.",
|
|
2240
3658
|
"inputSchema": {"type": "object", "properties": {}}},
|
|
2241
|
-
{"name": "
|
|
2242
|
-
"inputSchema": {"type": "object", "properties": {"url": {"type": "string", "description": "URL to navigate and capture logs"}}}},
|
|
2243
|
-
{"name": "browser_network", "description": "Monitor network requests",
|
|
2244
|
-
"inputSchema": {"type": "object", "properties": {"url": {"type": "string", "description": "URL to navigate and monitor"}}}},
|
|
2245
|
-
{"name": "browser_a11y", "description": "Get accessibility tree of the current page",
|
|
2246
|
-
"inputSchema": {"type": "object", "properties": {"mode": {"type": "string", "enum": ["full", "summary"], "default": "full"}}}},
|
|
2247
|
-
{"name": "browser_fill", "description": "Set input value (React-compatible)",
|
|
2248
|
-
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector"}, "value": {"type": "string", "description": "Value to set"}}, "required": ["selector", "value"]}},
|
|
2249
|
-
{"name": "browser_launch", "description": "Launch browser with CDP enabled",
|
|
3659
|
+
{"name": "browser_close", "description": "Close the currently active browser tab. Use this to clean up after automation tasks or to close unwanted popups/tabs that were opened during navigation.",
|
|
2250
3660
|
"inputSchema": {"type": "object", "properties": {}}},
|
|
2251
|
-
{"name": "
|
|
3661
|
+
{"name": "browser_describe", "description": "Get a comprehensive page description combining three data sources: (1) accessibility tree with @N references for interactive elements, (2) a PNG screenshot saved to disk, and (3) visible text content. Use this when browser_a11y alone is insufficient — for canvas/WebGL content, visual verification, or complex dynamic UIs. This is the vision fallback tool.",
|
|
2252
3662
|
"inputSchema": {"type": "object", "properties": {}}},
|
|
3663
|
+
{"name": "browser_assert", "description": "Assert that an element matching the CSS selector exists and optionally contains expected text. Returns PASS or FAIL with details. Use this for automated testing — verify page state after navigation or interaction. Checks visibility by default.",
|
|
3664
|
+
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector to check for existence"}, "text": {"type": "string", "description": "Optional: expected text content (substring match)"}, "visible": {"type": "boolean", "description": "Check element is visible (not hidden/zero-size)", "default": True}}, "required": ["selector"]}},
|
|
3665
|
+
{"name": "browser_wait_for", "description": "Wait for an element matching the CSS selector to appear in the DOM, up to the specified timeout. Uses MutationObserver for efficient waiting. Returns the element's tag and text when found, or TIMEOUT if not found. Use before interactions with dynamically loaded content.",
|
|
3666
|
+
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector to wait for"}, "timeout": {"type": "number", "description": "Maximum wait time in milliseconds", "default": 5000}}, "required": ["selector"]}},
|
|
3667
|
+
{"name": "browser_check", "description": "Run a batch of assertions on the current page and return a test report. Each check verifies element existence and optional text content. Returns a summary with PASS/FAIL count. Use this for comprehensive page validation after a series of actions.",
|
|
3668
|
+
"inputSchema": {"type": "object", "properties": {"checks": {"type": "array", "description": "Array of checks, each with 'selector' (required) and 'text' (optional)", "items": {"type": "object", "properties": {"selector": {"type": "string"}, "text": {"type": "string"}}, "required": ["selector"]}}}, "required": ["checks"]}},
|
|
3669
|
+
{"name": "browser_assert_url", "description": "Assert the current page URL contains the expected substring. Returns PASS with the full URL or FAIL. Use this after navigation to verify you landed on the correct page.",
|
|
3670
|
+
"inputSchema": {"type": "object", "properties": {"expected_url": {"type": "string", "description": "Expected substring to find in the current URL (e.g. 'example.com', '/dashboard', '?tab=settings')"}}, "required": ["expected_url"]}},
|
|
3671
|
+
{"name": "browser_assert_title", "description": "Assert the current page title contains the expected substring. Returns PASS with full title or FAIL. Useful for verifying page identity without relying on URL.",
|
|
3672
|
+
"inputSchema": {"type": "object", "properties": {"expected_title": {"type": "string", "description": "Expected substring to find in the page title (e.g. 'Dashboard', 'Login')"}}, "required": ["expected_title"]}},
|
|
3673
|
+
{"name": "browser_assert_count", "description": "Assert the number of elements matching a CSS selector equals an expected count. Returns PASS with count or FAIL with actual vs expected. Use this to verify list items, table rows, search results, or repeated components.",
|
|
3674
|
+
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector to count matching elements"}, "expected_count": {"type": "integer", "description": "Expected number of matching elements"}}, "required": ["selector", "expected_count"]}},
|
|
3675
|
+
{"name": "browser_assert_value", "description": "Assert an input, textarea, or select element's current value equals the expected string. Returns PASS or FAIL with actual value. Use this to verify form field state after filling or after page load.",
|
|
3676
|
+
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the input/textarea/select element"}, "expected_value": {"type": "string", "description": "Expected exact value of the element"}}, "required": ["selector", "expected_value"]}},
|
|
3677
|
+
{"name": "browser_assert_attr", "description": "Assert an element's HTML attribute contains the expected substring. Returns PASS with actual value or FAIL. Use this to verify href, src, data-*, aria-* and other attributes without reading full page HTML.",
|
|
3678
|
+
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the element"}, "attr": {"type": "string", "description": "Attribute name (e.g. 'href', 'src', 'data-id', 'aria-label')"}, "expected": {"type": "string", "description": "Expected substring in the attribute value"}}, "required": ["selector", "attr", "expected"]}},
|
|
3679
|
+
{"name": "browser_assert_visible", "description": "Assert an element is visible on the page (not hidden by CSS). Checks display, visibility, opacity and bounding rect. Returns PASS or FAIL. Use this to verify modals opened, elements shown after interaction, or content loaded.",
|
|
3680
|
+
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the element to check for visibility"}}, "required": ["selector"]}},
|
|
3681
|
+
{"name": "browser_assert_hidden", "description": "Assert an element exists but is hidden (display:none, visibility:hidden, opacity:0, or zero size). Returns PASS or FAIL. Use this to verify modals closed, tooltips dismissed, or conditional sections hidden.",
|
|
3682
|
+
"inputSchema": {"type": "object", "properties": {"selector": {"type": "string", "description": "CSS selector for the element expected to be hidden"}}, "required": ["selector"]}},
|
|
3683
|
+
{"name": "browser_screenshot_diff", "description": "Compare two screenshot PNG files byte-by-byte. Returns MATCH if files are identical or DIFF with file sizes if different. Use this for visual regression testing — take a baseline screenshot, perform actions, take another screenshot, then compare.",
|
|
3684
|
+
"inputSchema": {"type": "object", "properties": {"path1": {"type": "string", "description": "Absolute path to the first (baseline) screenshot PNG"}, "path2": {"type": "string", "description": "Absolute path to the second (current) screenshot PNG"}}, "required": ["path1", "path2"]}},
|
|
2253
3685
|
]
|
|
2254
3686
|
|
|
2255
3687
|
def _handle_request(self, request):
|
|
@@ -2287,7 +3719,7 @@ class MCPServer:
|
|
|
2287
3719
|
import io, subprocess
|
|
2288
3720
|
tool_map = {
|
|
2289
3721
|
"browser_navigate": lambda a: ["go", a.get("url", "")],
|
|
2290
|
-
"browser_screenshot": lambda a: ["shot"] + ([self._safe_filename(a["filename"])] if a.get("filename") else []),
|
|
3722
|
+
"browser_screenshot": lambda a: ["shot"] + ([self._safe_filename(a["filename"])] if a.get("filename") else []) + ([f"--element={a['element']}"] if a.get("element") else []) + ([f"--quality={a['quality']}"] if a.get("quality") else []),
|
|
2291
3723
|
"browser_click": lambda a: ["click", a.get("selector", "")],
|
|
2292
3724
|
"browser_type": lambda a: ["type", a.get("selector", ""), a.get("text", "")],
|
|
2293
3725
|
"browser_content": lambda a: ["content"],
|
|
@@ -2300,15 +3732,29 @@ class MCPServer:
|
|
|
2300
3732
|
"browser_fill": lambda a: ["fill", a.get("selector", ""), a.get("value", "")],
|
|
2301
3733
|
"browser_launch": lambda a: ["launch"],
|
|
2302
3734
|
"browser_close": lambda a: ["close"],
|
|
3735
|
+
"browser_describe": lambda a: ["describe"],
|
|
3736
|
+
"browser_assert": lambda a: ["assert", a.get("selector", "")] + ([a["text"]] if a.get("text") else []),
|
|
3737
|
+
"browser_wait_for": lambda a: ["wait-for", a.get("selector", "")] + ([str(a["timeout"])] if a.get("timeout") else []),
|
|
3738
|
+
"browser_check": lambda a: ["check", json.dumps(a.get("checks", []))],
|
|
3739
|
+
"browser_assert_url": lambda a: ["assert-url", a.get("expected_url", "")],
|
|
3740
|
+
"browser_assert_title": lambda a: ["assert-title", a.get("expected_title", "")],
|
|
3741
|
+
"browser_assert_count": lambda a: ["assert-count", a.get("selector", ""), str(a.get("expected_count", 0))],
|
|
3742
|
+
"browser_assert_value": lambda a: ["assert-value", a.get("selector", ""), a.get("expected_value", "")],
|
|
3743
|
+
"browser_assert_attr": lambda a: ["assert-attr", a.get("selector", ""), a.get("attr", ""), a.get("expected", "")],
|
|
3744
|
+
"browser_assert_visible": lambda a: ["assert-visible", a.get("selector", "")],
|
|
3745
|
+
"browser_assert_hidden": lambda a: ["assert-hidden", a.get("selector", "")],
|
|
3746
|
+
"browser_screenshot_diff": lambda a: ["screenshot-diff", a.get("path1", ""), a.get("path2", "")],
|
|
2303
3747
|
}
|
|
2304
3748
|
if tool_name not in tool_map:
|
|
2305
3749
|
return {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32602, "message": f"Unknown tool: {tool_name}"}}
|
|
2306
3750
|
|
|
2307
3751
|
cli_args = [a for a in tool_map[tool_name](args) if a]
|
|
2308
3752
|
try:
|
|
3753
|
+
env = os.environ.copy()
|
|
3754
|
+
env["CDPILOT_MCP_SESSION"] = "1"
|
|
2309
3755
|
result = subprocess.run(
|
|
2310
3756
|
[sys.executable, __file__] + cli_args,
|
|
2311
|
-
capture_output=True, text=True, timeout=30
|
|
3757
|
+
capture_output=True, text=True, timeout=30, env=env
|
|
2312
3758
|
)
|
|
2313
3759
|
output = result.stdout.strip()
|
|
2314
3760
|
errors = result.stderr.strip()
|
|
@@ -2373,6 +3819,9 @@ if __name__ == "__main__":
|
|
|
2373
3819
|
'session': cmd_session,
|
|
2374
3820
|
'sessions': cmd_sessions,
|
|
2375
3821
|
'session-close': lambda: cmd_session_close(args[0] if args else None),
|
|
3822
|
+
'projects': cmd_projects,
|
|
3823
|
+
'project-stop': lambda: cmd_project_stop(args[0] if args else ''),
|
|
3824
|
+
'stop-all': cmd_stop_all,
|
|
2376
3825
|
}
|
|
2377
3826
|
|
|
2378
3827
|
if cmd == "mcp":
|
|
@@ -2414,7 +3863,14 @@ if __name__ == "__main__":
|
|
|
2414
3863
|
"go": lambda: (require_args(1, "go <url>"), cmd_go(args[0]))[1] if not args else cmd_go(args[0]),
|
|
2415
3864
|
"content": cmd_content,
|
|
2416
3865
|
"html": cmd_html,
|
|
2417
|
-
"shot": lambda: cmd_shot(
|
|
3866
|
+
"shot": lambda: cmd_shot(
|
|
3867
|
+
output=next((a for a in args if not a.startswith("--")), None),
|
|
3868
|
+
quality=next((a.split("=")[1] for a in args if a.startswith("--quality=")), None),
|
|
3869
|
+
element=next((a.split("=")[1] for a in args if a.startswith("--element=")), None),
|
|
3870
|
+
fmt=next((a.split("=")[1] for a in args if a.startswith("--format=")), None),
|
|
3871
|
+
),
|
|
3872
|
+
"shot-annotated": lambda: cmd_shot_annotated(args[0] if args else None),
|
|
3873
|
+
"batch": cmd_batch,
|
|
2418
3874
|
"eval": lambda: (require_args(1, "eval <js>"), None)[1] if not args else cmd_eval(" ".join(args)),
|
|
2419
3875
|
"click": lambda: (require_args(1, "click <selector>"), None)[1] if not args else cmd_click(args[0]),
|
|
2420
3876
|
"fill": lambda: (require_args(2, "fill <selector> <value>"), None)[1] if len(args) < 2 else cmd_fill(args[0], " ".join(args[1:])),
|
|
@@ -2438,6 +3894,20 @@ if __name__ == "__main__":
|
|
|
2438
3894
|
'multi-eval': lambda: (require_args(1, 'multi-eval <js>'), None)[1] if not args else cmd_multi_eval(' '.join(args)),
|
|
2439
3895
|
'intercept': lambda: (require_args(1, 'intercept [block|mock|headers|clear|list] ...'), None)[1] if not args else cmd_intercept(args[0], *args[1:]),
|
|
2440
3896
|
'a11y': lambda: cmd_a11y(' '.join(args)),
|
|
3897
|
+
'a11y-snapshot': cmd_a11y_snapshot,
|
|
3898
|
+
'describe': cmd_describe,
|
|
3899
|
+
'assert': lambda: (require_args(1, 'assert <selector> [text]'), None)[1] if not args else cmd_assert(args[0], args[1] if len(args) > 1 else None),
|
|
3900
|
+
'wait-for': lambda: (require_args(1, 'wait-for <selector> [timeout_ms]'), None)[1] if not args else cmd_wait_for(args[0], int(args[1]) if len(args) > 1 else 5000),
|
|
3901
|
+
'check': lambda: cmd_check(args[0] if args else None),
|
|
3902
|
+
'assert-url': lambda: (require_args(1, 'assert-url <expected>'), None)[1] if not args else cmd_assert_url(args[0]),
|
|
3903
|
+
'assert-title': lambda: (require_args(1, 'assert-title <expected>'), None)[1] if not args else cmd_assert_title(args[0]),
|
|
3904
|
+
'assert-count': lambda: (require_args(2, 'assert-count <selector> <n>'), None)[1] if len(args) < 2 else cmd_assert_count(args[0], int(args[1])),
|
|
3905
|
+
'assert-value': lambda: (require_args(2, 'assert-value <selector> <value>'), None)[1] if len(args) < 2 else cmd_assert_value(args[0], args[1]),
|
|
3906
|
+
'assert-attr': lambda: (require_args(3, 'assert-attr <selector> <attr> <expected>'), None)[1] if len(args) < 3 else cmd_assert_attr(args[0], args[1], args[2]),
|
|
3907
|
+
'assert-visible': lambda: (require_args(1, 'assert-visible <selector>'), None)[1] if not args else cmd_assert_visible(args[0], True),
|
|
3908
|
+
'assert-hidden': lambda: (require_args(1, 'assert-hidden <selector>'), None)[1] if not args else cmd_assert_visible(args[0], False),
|
|
3909
|
+
'screenshot-diff': lambda: (require_args(2, 'screenshot-diff <path1> <path2>'), None)[1] if len(args) < 2 else cmd_screenshot_diff(args[0], args[1]),
|
|
3910
|
+
'click-ref': lambda: (require_args(1, 'click-ref <@N>'), None)[1] if not args else cmd_click_ref(args[0]),
|
|
2441
3911
|
'hover': lambda: (require_args(1, 'hover <selector>'), None)[1] if not args else cmd_hover(args[0]),
|
|
2442
3912
|
'dblclick': lambda: (require_args(1, 'dblclick <selector>'), None)[1] if not args else cmd_dblclick(args[0]),
|
|
2443
3913
|
'rightclick': lambda: (require_args(1, 'rightclick <selector>'), None)[1] if not args else cmd_rightclick(args[0]),
|
|
@@ -2454,7 +3924,8 @@ if __name__ == "__main__":
|
|
|
2454
3924
|
|
|
2455
3925
|
# Commands that do not require the visual indicator / input blocker
|
|
2456
3926
|
NO_CONTROL_CMDS = {'glow', 'stop', 'tabs', 'close', 'close-tab', 'new-tab',
|
|
2457
|
-
'dialog', 'download', 'throttle', 'permission', 'intercept'
|
|
3927
|
+
'dialog', 'download', 'throttle', 'permission', 'intercept',
|
|
3928
|
+
'batch', 'screenshot-diff'}
|
|
2458
3929
|
# Clean up idle sessions before running any command
|
|
2459
3930
|
_cleanup_idle_sessions()
|
|
2460
3931
|
|
|
@@ -2483,5 +3954,10 @@ if __name__ == "__main__":
|
|
|
2483
3954
|
asyncio.run(_wrapped())
|
|
2484
3955
|
_update_session_timestamp()
|
|
2485
3956
|
else:
|
|
2486
|
-
print(f"Unknown command: {cmd}")
|
|
2487
|
-
|
|
3957
|
+
print(f"Unknown command: {cmd}", file=sys.stderr)
|
|
3958
|
+
all_cmds = sorted(set(list(sync_cmds.keys()) + list(async_map.keys())))
|
|
3959
|
+
matches = difflib.get_close_matches(cmd, all_cmds, n=1, cutoff=0.6)
|
|
3960
|
+
if matches:
|
|
3961
|
+
print(f"Did you mean: {matches[0]}?", file=sys.stderr)
|
|
3962
|
+
print(f"\nAvailable commands: {', '.join(all_cmds)}", file=sys.stderr)
|
|
3963
|
+
sys.exit(1)
|