computeruseprotocol 0.1.0__py3-none-any.whl

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.
cup/actions/_web.py ADDED
@@ -0,0 +1,555 @@
1
+ """Web action handler — CDP-based action execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from typing import Any
8
+
9
+ from cup.actions._handler import ActionHandler
10
+ from cup.actions._keys import parse_combo
11
+ from cup.actions.executor import ActionResult
12
+
13
+ # ---------------------------------------------------------------------------
14
+ # CDP key mapping for Input.dispatchKeyEvent
15
+ # ---------------------------------------------------------------------------
16
+
17
+ _CDP_KEY_MAP: dict[str, dict[str, str]] = {
18
+ "enter": {"key": "Enter", "code": "Enter"},
19
+ "tab": {"key": "Tab", "code": "Tab"},
20
+ "escape": {"key": "Escape", "code": "Escape"},
21
+ "backspace": {"key": "Backspace", "code": "Backspace"},
22
+ "delete": {"key": "Delete", "code": "Delete"},
23
+ "space": {"key": " ", "code": "Space"},
24
+ "up": {"key": "ArrowUp", "code": "ArrowUp"},
25
+ "down": {"key": "ArrowDown", "code": "ArrowDown"},
26
+ "left": {"key": "ArrowLeft", "code": "ArrowLeft"},
27
+ "right": {"key": "ArrowRight", "code": "ArrowRight"},
28
+ "home": {"key": "Home", "code": "Home"},
29
+ "end": {"key": "End", "code": "End"},
30
+ "pageup": {"key": "PageUp", "code": "PageUp"},
31
+ "pagedown": {"key": "PageDown", "code": "PageDown"},
32
+ "f1": {"key": "F1", "code": "F1"},
33
+ "f2": {"key": "F2", "code": "F2"},
34
+ "f3": {"key": "F3", "code": "F3"},
35
+ "f4": {"key": "F4", "code": "F4"},
36
+ "f5": {"key": "F5", "code": "F5"},
37
+ "f6": {"key": "F6", "code": "F6"},
38
+ "f7": {"key": "F7", "code": "F7"},
39
+ "f8": {"key": "F8", "code": "F8"},
40
+ "f9": {"key": "F9", "code": "F9"},
41
+ "f10": {"key": "F10", "code": "F10"},
42
+ "f11": {"key": "F11", "code": "F11"},
43
+ "f12": {"key": "F12", "code": "F12"},
44
+ }
45
+
46
+ _CDP_MODIFIER_MAP: dict[str, dict[str, Any]] = {
47
+ "ctrl": {"key": "Control", "code": "ControlLeft", "bit": 2},
48
+ "alt": {"key": "Alt", "code": "AltLeft", "bit": 1},
49
+ "shift": {"key": "Shift", "code": "ShiftLeft", "bit": 8},
50
+ "meta": {"key": "Meta", "code": "MetaLeft", "bit": 4},
51
+ }
52
+
53
+
54
+ def _get_click_point(box_model: dict) -> tuple[float, float]:
55
+ """Compute the center point of a DOM.getBoxModel result.
56
+
57
+ The content quad is returned as [x1,y1, x2,y2, x3,y3, x4,y4].
58
+ We average all four corners to get the center.
59
+ """
60
+ content = box_model.get("model", {}).get("content", [])
61
+ if len(content) >= 8:
62
+ xs = [content[i] for i in range(0, 8, 2)]
63
+ ys = [content[i] for i in range(1, 8, 2)]
64
+ return sum(xs) / 4, sum(ys) / 4
65
+ # Fallback: use border quad
66
+ border = box_model.get("model", {}).get("border", [])
67
+ if len(border) >= 8:
68
+ xs = [border[i] for i in range(0, 8, 2)]
69
+ ys = [border[i] for i in range(1, 8, 2)]
70
+ return sum(xs) / 4, sum(ys) / 4
71
+ raise RuntimeError("Cannot determine element position from box model")
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # WebActionHandler
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ class WebActionHandler(ActionHandler):
80
+ """Execute CUP actions on web pages via Chrome DevTools Protocol.
81
+
82
+ Native refs are (ws_url, backend_dom_node_id) tuples stored by the
83
+ web adapter during tree capture.
84
+ """
85
+
86
+ def __init__(self, *, cdp_host: str | None = None):
87
+ self._host = cdp_host or os.environ.get("CUP_CDP_HOST", "127.0.0.1")
88
+
89
+ def action(
90
+ self,
91
+ native_ref: Any,
92
+ action: str,
93
+ params: dict[str, Any],
94
+ ) -> ActionResult:
95
+ from cup.platforms.web import _cdp_close, _cdp_connect
96
+
97
+ ws_url, backend_node_id = native_ref
98
+ ws = _cdp_connect(ws_url, self._host)
99
+ try:
100
+ return self._dispatch(ws, backend_node_id, action, params)
101
+ except Exception as exc:
102
+ return ActionResult(
103
+ success=False,
104
+ message="",
105
+ error=f"Web action '{action}' failed: {exc}",
106
+ )
107
+ finally:
108
+ _cdp_close(ws)
109
+
110
+ def press(self, combo: str) -> ActionResult:
111
+ """Send a keyboard shortcut via CDP Input.dispatchKeyEvent.
112
+
113
+ This sends to the currently focused element in the most recently
114
+ used tab. We need a websocket URL — use the CDP target list.
115
+ """
116
+ from cup.platforms.web import (
117
+ _cdp_close,
118
+ _cdp_connect,
119
+ _cdp_get_targets,
120
+ )
121
+
122
+ port = int(os.environ.get("CUP_CDP_PORT", "9222"))
123
+ try:
124
+ targets = _cdp_get_targets(self._host, port)
125
+ except Exception as exc:
126
+ return ActionResult(
127
+ success=False,
128
+ message="",
129
+ error=f"Cannot connect to CDP for press: {exc}",
130
+ )
131
+
132
+ page_targets = [t for t in targets if t.get("type") == "page"]
133
+ if not page_targets:
134
+ return ActionResult(
135
+ success=False,
136
+ message="",
137
+ error="No browser tabs found for press",
138
+ )
139
+
140
+ ws_url = page_targets[0]["webSocketDebuggerUrl"]
141
+ ws = _cdp_connect(ws_url, self._host)
142
+ try:
143
+ self._send_key_combo(ws, combo)
144
+ return ActionResult(success=True, message=f"Pressed {combo}")
145
+ except Exception as exc:
146
+ return ActionResult(
147
+ success=False,
148
+ message="",
149
+ error=f"Failed to press keys: {exc}",
150
+ )
151
+ finally:
152
+ _cdp_close(ws)
153
+
154
+ # -- dispatch -----------------------------------------------------------
155
+
156
+ def _dispatch(
157
+ self,
158
+ ws: Any,
159
+ backend_node_id: int,
160
+ action: str,
161
+ params: dict,
162
+ ) -> ActionResult:
163
+
164
+ if action == "click":
165
+ return self._click(ws, backend_node_id)
166
+ elif action == "rightclick":
167
+ return self._mouse_click(ws, backend_node_id, button="right")
168
+ elif action == "doubleclick":
169
+ return self._mouse_click(ws, backend_node_id, button="left", click_count=2)
170
+ elif action == "type":
171
+ text = params.get("value", "")
172
+ return self._type(ws, backend_node_id, text)
173
+ elif action == "setvalue":
174
+ text = params.get("value", "")
175
+ return self._setvalue(ws, backend_node_id, text)
176
+ elif action == "toggle":
177
+ return self._toggle(ws, backend_node_id)
178
+ elif action in ("expand", "collapse"):
179
+ return self._click(ws, backend_node_id)
180
+ elif action == "select":
181
+ return self._select(ws, backend_node_id)
182
+ elif action == "scroll":
183
+ direction = params.get("direction", "down")
184
+ return self._scroll(ws, backend_node_id, direction)
185
+ elif action == "focus":
186
+ return self._focus(ws, backend_node_id)
187
+ elif action == "dismiss":
188
+ return self._dismiss(ws)
189
+ elif action == "increment":
190
+ return self._arrow_key(ws, backend_node_id, "ArrowUp")
191
+ elif action == "decrement":
192
+ return self._arrow_key(ws, backend_node_id, "ArrowDown")
193
+ else:
194
+ return ActionResult(
195
+ success=False,
196
+ message="",
197
+ error=f"Action '{action}' not implemented for web",
198
+ )
199
+
200
+ # -- individual actions -------------------------------------------------
201
+
202
+ def _click(self, ws: Any, backend_node_id: int) -> ActionResult:
203
+ return self._mouse_click(ws, backend_node_id, button="left", click_count=1)
204
+
205
+ def _mouse_click(
206
+ self,
207
+ ws: Any,
208
+ backend_node_id: int,
209
+ *,
210
+ button: str = "left",
211
+ click_count: int = 1,
212
+ ) -> ActionResult:
213
+ from cup.platforms.web import _cdp_send
214
+
215
+ resp = _cdp_send(
216
+ ws,
217
+ "DOM.getBoxModel",
218
+ {
219
+ "backendNodeId": backend_node_id,
220
+ },
221
+ )
222
+ x, y = _get_click_point(resp.get("result", {}))
223
+
224
+ for i in range(click_count):
225
+ _cdp_send(
226
+ ws,
227
+ "Input.dispatchMouseEvent",
228
+ {
229
+ "type": "mousePressed",
230
+ "x": x,
231
+ "y": y,
232
+ "button": button,
233
+ "clickCount": i + 1,
234
+ },
235
+ )
236
+ _cdp_send(
237
+ ws,
238
+ "Input.dispatchMouseEvent",
239
+ {
240
+ "type": "mouseReleased",
241
+ "x": x,
242
+ "y": y,
243
+ "button": button,
244
+ "clickCount": i + 1,
245
+ },
246
+ )
247
+
248
+ action_name = {
249
+ ("left", 1): "Clicked",
250
+ ("left", 2): "Double-clicked",
251
+ ("right", 1): "Right-clicked",
252
+ }.get((button, click_count), f"Mouse {button} x{click_count}")
253
+ return ActionResult(success=True, message=action_name)
254
+
255
+ def _type(self, ws: Any, backend_node_id: int, text: str) -> ActionResult:
256
+ from cup.platforms.web import _cdp_send
257
+
258
+ # Focus the element first
259
+ _cdp_send(ws, "DOM.focus", {"backendNodeId": backend_node_id})
260
+ time.sleep(0.05)
261
+
262
+ # Select all existing content, then type new text
263
+ self._send_key_combo(ws, "ctrl+a")
264
+ time.sleep(0.05)
265
+
266
+ # Use insertText for reliable text input
267
+ _cdp_send(ws, "Input.insertText", {"text": text})
268
+
269
+ return ActionResult(success=True, message=f"Typed: {text}")
270
+
271
+ def _setvalue(self, ws: Any, backend_node_id: int, text: str) -> ActionResult:
272
+ from cup.platforms.web import _cdp_send
273
+
274
+ # Resolve the backend node to a Runtime object
275
+ resp = _cdp_send(
276
+ ws,
277
+ "DOM.resolveNode",
278
+ {
279
+ "backendNodeId": backend_node_id,
280
+ },
281
+ )
282
+ object_id = resp.get("result", {}).get("object", {}).get("objectId")
283
+ if not object_id:
284
+ return ActionResult(
285
+ success=False,
286
+ message="",
287
+ error="Cannot resolve DOM node for setvalue",
288
+ )
289
+
290
+ # Set value and dispatch input/change events
291
+ _cdp_send(
292
+ ws,
293
+ "Runtime.callFunctionOn",
294
+ {
295
+ "objectId": object_id,
296
+ "functionDeclaration": """function(v) {
297
+ this.value = v;
298
+ this.dispatchEvent(new Event('input', {bubbles: true}));
299
+ this.dispatchEvent(new Event('change', {bubbles: true}));
300
+ }""",
301
+ "arguments": [{"value": text}],
302
+ },
303
+ )
304
+ return ActionResult(success=True, message=f"Set value to: {text}")
305
+
306
+ def _scroll(
307
+ self,
308
+ ws: Any,
309
+ backend_node_id: int,
310
+ direction: str,
311
+ ) -> ActionResult:
312
+ from cup.platforms.web import _cdp_send
313
+
314
+ resp = _cdp_send(
315
+ ws,
316
+ "DOM.getBoxModel",
317
+ {
318
+ "backendNodeId": backend_node_id,
319
+ },
320
+ )
321
+ x, y = _get_click_point(resp.get("result", {}))
322
+
323
+ delta_x, delta_y = 0, 0
324
+ if direction == "up":
325
+ delta_y = -200
326
+ elif direction == "down":
327
+ delta_y = 200
328
+ elif direction == "left":
329
+ delta_x = -200
330
+ elif direction == "right":
331
+ delta_x = 200
332
+
333
+ _cdp_send(
334
+ ws,
335
+ "Input.dispatchMouseEvent",
336
+ {
337
+ "type": "mouseWheel",
338
+ "x": x,
339
+ "y": y,
340
+ "deltaX": delta_x,
341
+ "deltaY": delta_y,
342
+ },
343
+ )
344
+ return ActionResult(success=True, message=f"Scrolled {direction}")
345
+
346
+ def _focus(self, ws: Any, backend_node_id: int) -> ActionResult:
347
+ from cup.platforms.web import _cdp_send
348
+
349
+ _cdp_send(ws, "DOM.focus", {"backendNodeId": backend_node_id})
350
+ return ActionResult(success=True, message="Focused")
351
+
352
+ def _toggle(self, ws: Any, backend_node_id: int) -> ActionResult:
353
+ from cup.platforms.web import _cdp_send
354
+
355
+ # Use JS .click() for reliable toggling of checkboxes/switches
356
+ resp = _cdp_send(
357
+ ws,
358
+ "DOM.resolveNode",
359
+ {
360
+ "backendNodeId": backend_node_id,
361
+ },
362
+ )
363
+ object_id = resp.get("result", {}).get("object", {}).get("objectId")
364
+ if not object_id:
365
+ # Fallback to coordinate click
366
+ return self._click(ws, backend_node_id)
367
+
368
+ _cdp_send(
369
+ ws,
370
+ "Runtime.callFunctionOn",
371
+ {
372
+ "objectId": object_id,
373
+ "functionDeclaration": "function() { this.click(); }",
374
+ },
375
+ )
376
+ return ActionResult(success=True, message="Toggled")
377
+
378
+ def _select(self, ws: Any, backend_node_id: int) -> ActionResult:
379
+ from cup.platforms.web import _cdp_send
380
+
381
+ # Handle <option> elements by setting selected on the option
382
+ # and dispatching change on the parent <select>
383
+ resp = _cdp_send(
384
+ ws,
385
+ "DOM.resolveNode",
386
+ {
387
+ "backendNodeId": backend_node_id,
388
+ },
389
+ )
390
+ object_id = resp.get("result", {}).get("object", {}).get("objectId")
391
+ if not object_id:
392
+ return self._click(ws, backend_node_id)
393
+
394
+ _cdp_send(
395
+ ws,
396
+ "Runtime.callFunctionOn",
397
+ {
398
+ "objectId": object_id,
399
+ "functionDeclaration": """function() {
400
+ if (this.tagName === 'OPTION') {
401
+ this.selected = true;
402
+ if (this.parentElement) {
403
+ this.parentElement.dispatchEvent(
404
+ new Event('change', {bubbles: true})
405
+ );
406
+ }
407
+ } else {
408
+ this.click();
409
+ }
410
+ }""",
411
+ },
412
+ )
413
+ return ActionResult(success=True, message="Selected")
414
+
415
+ def _dismiss(self, ws: Any) -> ActionResult:
416
+ from cup.platforms.web import _cdp_send
417
+
418
+ _cdp_send(
419
+ ws,
420
+ "Input.dispatchKeyEvent",
421
+ {
422
+ "type": "keyDown",
423
+ "key": "Escape",
424
+ "code": "Escape",
425
+ },
426
+ )
427
+ _cdp_send(
428
+ ws,
429
+ "Input.dispatchKeyEvent",
430
+ {
431
+ "type": "keyUp",
432
+ "key": "Escape",
433
+ "code": "Escape",
434
+ },
435
+ )
436
+ return ActionResult(success=True, message="Dismissed (Escape)")
437
+
438
+ def _arrow_key(
439
+ self,
440
+ ws: Any,
441
+ backend_node_id: int,
442
+ key: str,
443
+ ) -> ActionResult:
444
+ from cup.platforms.web import _cdp_send
445
+
446
+ _cdp_send(ws, "DOM.focus", {"backendNodeId": backend_node_id})
447
+ time.sleep(0.05)
448
+ code = key # ArrowUp, ArrowDown are both key and code
449
+ _cdp_send(
450
+ ws,
451
+ "Input.dispatchKeyEvent",
452
+ {
453
+ "type": "keyDown",
454
+ "key": key,
455
+ "code": code,
456
+ },
457
+ )
458
+ _cdp_send(
459
+ ws,
460
+ "Input.dispatchKeyEvent",
461
+ {
462
+ "type": "keyUp",
463
+ "key": key,
464
+ "code": code,
465
+ },
466
+ )
467
+ verb = "Incremented" if key == "ArrowUp" else "Decremented"
468
+ return ActionResult(success=True, message=verb)
469
+
470
+ # -- keyboard helpers ---------------------------------------------------
471
+
472
+ def _send_key_combo(self, ws: Any, combo: str) -> None:
473
+ """Parse a key combo and send via CDP Input.dispatchKeyEvent."""
474
+ from cup.platforms.web import _cdp_send
475
+
476
+ modifiers, keys = parse_combo(combo)
477
+
478
+ # Calculate modifier bitmask
479
+ mod_bits = 0
480
+ for mod in modifiers:
481
+ info = _CDP_MODIFIER_MAP.get(mod)
482
+ if info:
483
+ mod_bits |= info["bit"]
484
+
485
+ # Press modifiers down
486
+ for mod in modifiers:
487
+ info = _CDP_MODIFIER_MAP.get(mod)
488
+ if info:
489
+ _cdp_send(
490
+ ws,
491
+ "Input.dispatchKeyEvent",
492
+ {
493
+ "type": "keyDown",
494
+ "key": info["key"],
495
+ "code": info["code"],
496
+ "modifiers": mod_bits,
497
+ },
498
+ )
499
+
500
+ # Press and release main keys
501
+ for key in keys:
502
+ mapped = _CDP_KEY_MAP.get(key)
503
+ if mapped:
504
+ cdp_key = mapped["key"]
505
+ cdp_code = mapped["code"]
506
+ text = ""
507
+ elif len(key) == 1:
508
+ cdp_key = key
509
+ cdp_code = f"Key{key.upper()}" if key.isalpha() else ""
510
+ text = key
511
+ else:
512
+ continue
513
+
514
+ params: dict[str, Any] = {
515
+ "type": "keyDown",
516
+ "key": cdp_key,
517
+ "code": cdp_code,
518
+ "modifiers": mod_bits,
519
+ }
520
+ if text and not mod_bits:
521
+ params["text"] = text
522
+ _cdp_send(ws, "Input.dispatchKeyEvent", params)
523
+
524
+ _cdp_send(
525
+ ws,
526
+ "Input.dispatchKeyEvent",
527
+ {
528
+ "type": "keyUp",
529
+ "key": cdp_key,
530
+ "code": cdp_code,
531
+ "modifiers": mod_bits,
532
+ },
533
+ )
534
+
535
+ # Release modifiers
536
+ for mod in reversed(modifiers):
537
+ info = _CDP_MODIFIER_MAP.get(mod)
538
+ if info:
539
+ _cdp_send(
540
+ ws,
541
+ "Input.dispatchKeyEvent",
542
+ {
543
+ "type": "keyUp",
544
+ "key": info["key"],
545
+ "code": info["code"],
546
+ "modifiers": 0,
547
+ },
548
+ )
549
+
550
+ def open_app(self, name: str) -> ActionResult:
551
+ return ActionResult(
552
+ success=False,
553
+ message="",
554
+ error="open_app is not applicable for web platform",
555
+ )