repld-tool 0.3.2__tar.gz → 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. {repld_tool-0.3.2 → repld_tool-0.3.3}/PKG-INFO +1 -1
  2. {repld_tool-0.3.2 → repld_tool-0.3.3}/pyproject.toml +1 -1
  3. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/observe.py +173 -14
  4. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/tab.py +299 -29
  5. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser_dispatch.py +41 -5
  6. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/help.py +55 -16
  7. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/protocol.py +71 -4
  8. {repld_tool-0.3.2 → repld_tool-0.3.3}/LICENSE +0 -0
  9. {repld_tool-0.3.2 → repld_tool-0.3.3}/README.md +0 -0
  10. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/.ruff_cache/.gitignore +0 -0
  11. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/.ruff_cache/0.15.22/14226964882126921391 +0 -0
  12. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/.ruff_cache/CACHEDIR.TAG +0 -0
  13. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/__init__.py +0 -0
  14. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/__main__.py +0 -0
  15. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/bg.py +0 -0
  16. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/bind.py +0 -0
  17. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/bridge.py +0 -0
  18. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/bridge_tools.py +0 -0
  19. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/__init__.py +0 -0
  20. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/browser.py +0 -0
  21. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/capture.py +0 -0
  22. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/cdp.py +0 -0
  23. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/har.py +0 -0
  24. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/inject.py +0 -0
  25. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/injected_source.py +0 -0
  26. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/pin.py +0 -0
  27. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/png.py +0 -0
  28. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/pool.py +0 -0
  29. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/row.py +0 -0
  30. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/selector.py +0 -0
  31. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/session.py +0 -0
  32. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/tab_query.py +0 -0
  33. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/target.py +0 -0
  34. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/channel.py +0 -0
  35. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/cli.py +0 -0
  36. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/cli_args.py +0 -0
  37. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/core_schemas.py +0 -0
  38. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/dashboard.py +0 -0
  39. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/dashboard_cmd.py +0 -0
  40. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/dashboard_html.py +0 -0
  41. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/display.py +0 -0
  42. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/eventlog.py +0 -0
  43. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/events.py +0 -0
  44. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/exec_cmd.py +0 -0
  45. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/gate_cmd.py +0 -0
  46. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/gates.py +0 -0
  47. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/gist_api.py +0 -0
  48. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/gist_cmd.py +0 -0
  49. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/gist_deps.py +0 -0
  50. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/gist_links.py +0 -0
  51. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/gist_lint.py +0 -0
  52. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/gists.py +0 -0
  53. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/ipc.py +0 -0
  54. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/kernel.py +0 -0
  55. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/kernel_context.py +0 -0
  56. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/lifecycle_cmd.py +0 -0
  57. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/log_cmd.py +0 -0
  58. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/paths.py +0 -0
  59. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/relaunch.py +0 -0
  60. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/render.py +0 -0
  61. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/runtime.py +0 -0
  62. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/sessions.py +0 -0
  63. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/spawn.py +0 -0
  64. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/state.py +0 -0
  65. {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/tasks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: repld-tool
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Persistent Python runtime with MCP channel push. Dev shell and autonomous-agent substrate in one package.
5
5
  Keywords: repl,mcp,agent,browser
6
6
  Author: Fredrik Angelsen
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "repld-tool"
3
- version = "0.3.2"
3
+ version = "0.3.3"
4
4
  description = "Persistent Python runtime with MCP channel push. Dev shell and autonomous-agent substrate in one package."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  import asyncio
12
12
  import time
13
+ from collections import Counter
13
14
  from dataclasses import dataclass, field
14
15
  from urllib.parse import urlparse
15
16
 
@@ -98,6 +99,11 @@ def _node_props(node: dict) -> str:
98
99
  return (" [" + ", ".join(props) + "]") if props else ""
99
100
 
100
101
 
102
+ # A tree signature: (role, name, props) multiset over the same nodes the
103
+ # renderer emits, used by the observation diff.
104
+ TreeSig = Counter
105
+
106
+
101
107
  def _build_lines(
102
108
  nodes_by_id: dict[str, dict],
103
109
  children_map: dict[str, list[str]],
@@ -105,6 +111,7 @@ def _build_lines(
105
111
  depth: int,
106
112
  max_depth: int,
107
113
  lines: list[str],
114
+ sig: TreeSig,
108
115
  ) -> None:
109
116
  if depth > max_depth:
110
117
  return
@@ -115,7 +122,9 @@ def _build_lines(
115
122
  if role in SKIP_ROLES:
116
123
  # Still recurse through skipped roles
117
124
  for child_id in children_map.get(node_id, []):
118
- _build_lines(nodes_by_id, children_map, child_id, depth, max_depth, lines)
125
+ _build_lines(
126
+ nodes_by_id, children_map, child_id, depth, max_depth, lines, sig
127
+ )
119
128
  return
120
129
 
121
130
  name = _node_name(node)
@@ -126,24 +135,31 @@ def _build_lines(
126
135
  label += f" {name!r}"
127
136
  label += props
128
137
  lines.append(label)
138
+ sig[(role, name, props)] += 1
129
139
 
130
140
  if role in LEAF_ROLES:
131
141
  return
132
142
 
133
143
  for child_id in children_map.get(node_id, []):
134
- _build_lines(nodes_by_id, children_map, child_id, depth + 1, max_depth, lines)
144
+ _build_lines(
145
+ nodes_by_id, children_map, child_id, depth + 1, max_depth, lines, sig
146
+ )
135
147
 
136
148
 
137
- async def build_tree(tab: "Tab", max_depth: int = 6) -> list[str]:
149
+ async def build_tree_sig(tab: "Tab", max_depth: int = 6) -> tuple[list[str], TreeSig]:
138
150
  """Compact accessibility tree from CDP Accessibility.getFullAXTree.
139
151
 
140
- Returns list of indented text lines.
152
+ Returns (indented text lines, signature multiset). The signature counts
153
+ exactly the nodes the lines render — same roles, same depth cap — so a
154
+ diff of two signatures describes what a reader of the two trees would see
155
+ change.
141
156
  """
142
157
  result = await tab._exec("Accessibility.getFullAXTree", {})
143
158
 
159
+ sig: TreeSig = Counter()
144
160
  nodes = result.get("nodes", [])
145
161
  if not nodes:
146
- return ["(empty tree)"]
162
+ return ["(empty tree)"], sig
147
163
 
148
164
  nodes_by_id: dict[str, dict] = {n["nodeId"]: n for n in nodes}
149
165
  children_map: dict[str, list[str]] = {}
@@ -161,9 +177,14 @@ async def build_tree(tab: "Tab", max_depth: int = 6) -> list[str]:
161
177
 
162
178
  lines: list[str] = []
163
179
  for root_id in root_ids:
164
- _build_lines(nodes_by_id, children_map, root_id, 0, max_depth, lines)
180
+ _build_lines(nodes_by_id, children_map, root_id, 0, max_depth, lines, sig)
165
181
 
166
- return lines or ["(empty tree)"]
182
+ return lines or ["(empty tree)"], sig
183
+
184
+
185
+ async def build_tree(tab: "Tab", max_depth: int = 6) -> list[str]:
186
+ lines, _ = await build_tree_sig(tab, max_depth)
187
+ return lines
167
188
 
168
189
 
169
190
  async def build_aria_tree(tab: "Tab") -> list[str]:
@@ -339,25 +360,29 @@ async def compose_tree(
339
360
  tab: "Tab",
340
361
  session: "BrowserSession",
341
362
  max_depth: int = 8,
342
- ) -> tuple[list[str], list["Tab"]]:
363
+ ) -> tuple[list[str], list["Tab"], dict[str, TreeSig]]:
343
364
  """Build accessibility tree with iframe children inlined.
344
365
 
345
- Returns (lines, iframe_child_tabs).
366
+ Returns (lines, iframe_child_tabs, signatures keyed by target_id).
346
367
  """
347
368
 
348
369
  # Get base tree
349
- lines = await build_tree(tab, max_depth=max_depth)
370
+ lines, sig = await build_tree_sig(tab, max_depth=max_depth)
371
+ sigs: dict[str, TreeSig] = {tab.target_id: sig}
350
372
  iframe_children = await _discover_iframe_children(tab, session)
351
373
 
352
374
  if not iframe_children:
353
- return lines, []
375
+ return lines, [], sigs
354
376
 
355
377
  child_trees: dict[str, list[str]] = {}
356
378
  for child in iframe_children:
357
- child_trees[child.target_id] = await build_tree(child, max_depth=max_depth - 2)
379
+ child_lines, child_sig = await build_tree_sig(child, max_depth=max_depth - 2)
380
+ child_trees[child.target_id] = child_lines
381
+ sigs[child.target_id] = child_sig
358
382
  return (
359
383
  _inline_iframe_lines(lines, iframe_children, child_trees),
360
384
  iframe_children,
385
+ sigs,
361
386
  )
362
387
 
363
388
 
@@ -428,6 +453,9 @@ class Observation:
428
453
  tree: list[str]
429
454
  network: list[NetworkEntry]
430
455
  console: list[str]
456
+ # None → no diff available (browser_open, or the page navigated);
457
+ # [] → diff computed, nothing changed.
458
+ changes: list[str] | None = None
431
459
 
432
460
 
433
461
  # ---------------------------------------------------------------------------
@@ -445,6 +473,11 @@ class PreObservation:
445
473
  # These were two separate fields until the cutoff moved to `events`; they
446
474
  # only ever held the same values, in both construction sites.
447
475
  snapshots: dict[str, int] = field(default_factory=dict)
476
+ # target_id → AX-tree signature for the observation diff. None (the
477
+ # browser_open path constructs PreObservation directly) suppresses the
478
+ # diff rather than reporting a fresh document as all-appeared.
479
+ tree_sigs: dict[str, TreeSig] | None = None
480
+ url: str = ""
448
481
 
449
482
 
450
483
  def _snapshot_max_ids(tabs: list["Tab"]) -> dict[str, int]:
@@ -475,12 +508,27 @@ def _snapshot_max_ids(tabs: list["Tab"]) -> dict[str, int]:
475
508
 
476
509
 
477
510
  async def pre_observe(tab: "Tab", session: "BrowserSession") -> PreObservation:
478
- """Capture state before a mutation. Fast — no blocking."""
511
+ """Capture state before a mutation.
512
+
513
+ The signature capture fetches the same AX trees post_observe will — same
514
+ depths (compose_tree's 8, children at 6) — so the diff compares like with
515
+ like. Best-effort: a page the AX domain chokes on still gets its mutation
516
+ observed, just without the changes section.
517
+ """
479
518
  iframe_children = await _discover_iframe_children(tab, session)
480
519
  all_tabs = [tab] + iframe_children
520
+ tree_sigs: dict[str, TreeSig] | None = {}
521
+ try:
522
+ _, tree_sigs[tab.target_id] = await build_tree_sig(tab, max_depth=8)
523
+ for child in iframe_children:
524
+ _, tree_sigs[child.target_id] = await build_tree_sig(child, max_depth=6)
525
+ except Exception:
526
+ tree_sigs = None
481
527
  return PreObservation(
482
528
  iframe_children=iframe_children,
483
529
  snapshots=_snapshot_max_ids(all_tabs),
530
+ tree_sigs=tree_sigs,
531
+ url=tab.url,
484
532
  )
485
533
 
486
534
 
@@ -563,6 +611,98 @@ def console_delta(tabs: list["Tab"], pre_ids: dict[str, int]) -> list[str]:
563
611
  return lines
564
612
 
565
613
 
614
+ # ---------------------------------------------------------------------------
615
+ # Tree diff
616
+ # ---------------------------------------------------------------------------
617
+
618
+ _DIFF_MAX_LINES = 12
619
+
620
+
621
+ def _diff_one(pre: TreeSig, post: TreeSig) -> tuple[Counter, Counter, list[tuple]]:
622
+ """(appeared, gone, changed) between two signatures.
623
+
624
+ An entry removed and added under the same (role, name) with different
625
+ props is a state change, not a departure plus an arrival — that pairing
626
+ is what turns "− button 'Save' [disabled] / + button 'Save'" into
627
+ "~ button 'Save' [disabled] → enabled".
628
+ """
629
+ added = post - pre
630
+ removed = pre - post
631
+ changed: list[tuple] = []
632
+ removed_by_rn: dict[tuple[str, str], list[tuple[str, int]]] = {}
633
+ for (role, name, props), cnt in removed.items():
634
+ removed_by_rn.setdefault((role, name), []).append((props, cnt))
635
+ for key in list(added):
636
+ role, name, new_props = key
637
+ buckets = removed_by_rn.get((role, name))
638
+ if not buckets:
639
+ continue
640
+ old_props, old_cnt = buckets[0]
641
+ n = min(added[key], old_cnt)
642
+ changed.append((role, name, old_props, new_props, n))
643
+ added[key] -= n
644
+ removed[(role, name, old_props)] -= n
645
+ if old_cnt - n:
646
+ buckets[0] = (old_props, old_cnt - n)
647
+ else:
648
+ buckets.pop(0)
649
+ if not buckets:
650
+ del removed_by_rn[(role, name)]
651
+ return +added, +removed, changed
652
+
653
+
654
+ def _entry_str(role: str, name: str, props: str) -> str:
655
+ label = role
656
+ if name:
657
+ label += f" {name!r}"
658
+ return label + props
659
+
660
+
661
+ def tree_diff_lines(
662
+ pre_sigs: dict[str, TreeSig],
663
+ post_sigs: dict[str, TreeSig],
664
+ main_target: str,
665
+ ) -> list[str]:
666
+ """Render the appeared/gone/changed summary, main tab first.
667
+
668
+ An iframe present on only one side reads as its whole tree
669
+ appearing/disappearing, which is accurate: the frame itself came or went.
670
+ """
671
+ detail: list[str] = []
672
+ totals = [0, 0, 0]
673
+ targets = [main_target] + [t for t in post_sigs if t != main_target]
674
+ targets += [t for t in pre_sigs if t not in post_sigs]
675
+ for target in targets:
676
+ added, removed, changed = _diff_one(
677
+ pre_sigs.get(target) or Counter(), post_sigs.get(target) or Counter()
678
+ )
679
+ totals[0] += sum(added.values())
680
+ totals[1] += sum(removed.values())
681
+ totals[2] += sum(n for *_, n in changed)
682
+ prefix = "" if target == main_target else f"{target} "
683
+ for (role, name, props), cnt in sorted(added.items(), key=lambda kv: -kv[1]):
684
+ mult = f" ×{cnt}" if cnt > 1 else ""
685
+ detail.append(f"+ {prefix}{_entry_str(role, name, props)}{mult}")
686
+ for role, name, old, new, n in changed:
687
+ mult = f" ×{n}" if n > 1 else ""
688
+ old_s = old.strip() or "[none]"
689
+ new_s = new.strip() or "[none]"
690
+ detail.append(
691
+ f"~ {prefix}{_entry_str(role, name, '')} {old_s} → {new_s}{mult}"
692
+ )
693
+ for (role, name, props), cnt in sorted(removed.items(), key=lambda kv: -kv[1]):
694
+ mult = f" ×{cnt}" if cnt > 1 else ""
695
+ detail.append(f"- {prefix}{_entry_str(role, name, props)}{mult}")
696
+
697
+ if not detail:
698
+ return []
699
+ header = f"changes: {totals[0]} appeared, {totals[1]} gone, {totals[2]} changed"
700
+ if len(detail) > _DIFF_MAX_LINES:
701
+ hidden = len(detail) - _DIFF_MAX_LINES
702
+ detail = detail[:_DIFF_MAX_LINES] + [f"… +{hidden} more (full tree below)"]
703
+ return [header] + [" " + d for d in detail]
704
+
705
+
566
706
  # ---------------------------------------------------------------------------
567
707
  # Formatting
568
708
  # ---------------------------------------------------------------------------
@@ -576,6 +716,14 @@ def format_observation(obs: Observation) -> str:
576
716
  parts.append(f"url: {obs.url} (settled in {obs.settle_ms}ms)")
577
717
  parts.append("")
578
718
 
719
+ # Changes — what this mutation did to the AX tree, before the full tree.
720
+ if obs.changes is not None:
721
+ if obs.changes:
722
+ parts.extend(obs.changes)
723
+ else:
724
+ parts.append("changes: none (AX tree unchanged)")
725
+ parts.append("")
726
+
579
727
  # Tree
580
728
  tree_count = len(obs.tree)
581
729
  parts.append(f"tree ({tree_count} nodes):")
@@ -640,7 +788,17 @@ async def post_observe(
640
788
 
641
789
  # Build composed tree — re-discovers iframes, which may differ from
642
790
  # pre.iframe_children if the mutation added/removed one.
643
- tree_lines, fresh_iframes = await compose_tree(tab, session)
791
+ tree_lines, fresh_iframes, post_sigs = await compose_tree(tab, session)
792
+
793
+ # Diff needs a pre signature *and* the same document — after a navigation
794
+ # the whole tree is new, and reporting it as a few hundred appearances
795
+ # would bury the one section that exists to be short.
796
+ changes: list[str] | None = None
797
+ if pre.tree_sigs is not None:
798
+ if tab.url == pre.url:
799
+ changes = tree_diff_lines(pre.tree_sigs, post_sigs, tab.target_id)
800
+ else:
801
+ changes = ["changes: (page navigated — tree replaced)"]
644
802
 
645
803
  # A mutation can spawn a brand-new iframe (e.g. an OAuth popup frame)
646
804
  # that wasn't in pre.iframe_children and so never got a quiet-period
@@ -675,6 +833,7 @@ async def post_observe(
675
833
  tree=tree_lines,
676
834
  network=net_entries,
677
835
  console=console_lines,
836
+ changes=changes,
678
837
  )
679
838
 
680
839
  text = format_observation(obs)
@@ -10,6 +10,7 @@ import asyncio
10
10
  import base64
11
11
  import json
12
12
  import pathlib
13
+ import re
13
14
  from dataclasses import dataclass
14
15
  from typing import Any
15
16
 
@@ -39,13 +40,38 @@ _HEARTBEAT_INTERVAL_S = 5
39
40
  _HEARTBEAT_MAX_MISSES = 3
40
41
  _HEARTBEAT_STALE_MS = _HEARTBEAT_INTERVAL_S * _HEARTBEAT_MAX_MISSES * 1000
41
42
 
42
- # Tab.key()'s named-key handling. Every other supported name (Enter, Escape,
43
- # Tab, ArrowDown, ...) already equals its own KeyboardEvent.code and needs no
44
- # `text` — Space is the one name whose real .key (" ") and .code ("Space")
45
- # diverge from each other and from the friendly name callers actually type.
43
+ # Tab.key()'s named-key handling. Space is the one name whose real .key (" ")
44
+ # and .code ("Space") diverge from each other and from the friendly name
45
+ # callers actually type.
46
46
  _KEY_ALIASES = {"Space": " "}
47
47
  _KEY_CODE = {" ": "Space"}
48
+ # Enter/Space must carry the produced character or Chromium's native
49
+ # button/form activation never fires — the DOM keydown/keyup still dispatch.
48
50
  _KEY_TEXT = {"Enter": "\r", " ": " "}
51
+ # Chromium's *editing* actions dispatch on windowsVirtualKeyCode, not the
52
+ # key/code strings: without it, Backspace fires keydown/keyup but deletes
53
+ # nothing and arrows never move the caret. Subset of Playwright's
54
+ # USKeyboardLayout; letters/digits are computed from the character.
55
+ _KEY_VK = {
56
+ "Backspace": 8,
57
+ "Tab": 9,
58
+ "Enter": 13,
59
+ "Escape": 27,
60
+ " ": 32,
61
+ "PageUp": 33,
62
+ "PageDown": 34,
63
+ "End": 35,
64
+ "Home": 36,
65
+ "ArrowLeft": 37,
66
+ "ArrowUp": 38,
67
+ "ArrowRight": 39,
68
+ "ArrowDown": 40,
69
+ "Insert": 45,
70
+ "Delete": 46,
71
+ **{f"F{i}": 111 + i for i in range(1, 13)},
72
+ }
73
+ # CDP Input.dispatchKeyEvent modifiers bitmask.
74
+ _MODIFIER_BITS = {"Alt": 1, "Control": 2, "Ctrl": 2, "Meta": 4, "Cmd": 4, "Shift": 8}
49
75
 
50
76
  # Grace period at the end of `_reattach`, after the ready signal is satisfied.
51
77
  # The re-attach re-enables this session's CDP domains through
@@ -796,6 +822,168 @@ class Tab(TabQueryMixin):
796
822
  el, selector=selector, button=button, click_count=click_count
797
823
  )
798
824
 
825
+ async def hover(self, selector: str) -> Receipt:
826
+ """Move the mouse over an element and leave it there.
827
+
828
+ Strictly resolved like click(). The pointer stays parked on the
829
+ element after the call, so :hover styling and mouseenter-revealed UI
830
+ (menus, toolbars, tooltips) remain up for a following click — and the
831
+ observation's `changes:` section reports what the hover revealed.
832
+ """
833
+ await self._ensure_front()
834
+ el = await inject.resolve_element(self, selector)
835
+ await inject.check_actionable(
836
+ self, el, ("visible", "stable"), selector=selector
837
+ )
838
+ x, y = await self._element_center_of(el, selector)
839
+ receipt = await self._receipt_for(el, x, y, verb="hovering")
840
+ await self._exec(
841
+ "Input.dispatchMouseEvent", {"type": "mouseMoved", "x": x, "y": y}
842
+ )
843
+ return receipt
844
+
845
+ @staticmethod
846
+ def _parse_point(spec: Any) -> tuple[float, float] | None:
847
+ """(x, y) from an 'x,y' string or a 2-tuple; None means selector."""
848
+ if isinstance(spec, (tuple, list)) and len(spec) == 2:
849
+ return float(spec[0]), float(spec[1])
850
+ if isinstance(spec, str):
851
+ m = re.fullmatch(r"\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*", spec)
852
+ if m:
853
+ return float(m.group(1)), float(m.group(2))
854
+ return None
855
+
856
+ async def _resolve_drag_point(
857
+ self, spec: Any, *, timeout: float | None = None
858
+ ) -> tuple[float, float, "inject.ResolvedElement | None", str]:
859
+ """(x, y, element-or-None, description) for a drag endpoint."""
860
+ pt = self._parse_point(spec)
861
+ if pt is not None:
862
+ return pt[0], pt[1], None, f"({pt[0]:.0f},{pt[1]:.0f})"
863
+ kw = {} if timeout is None else {"timeout": timeout}
864
+ el = await inject.resolve_element(self, spec, **kw)
865
+ await inject.check_actionable(
866
+ self, el, ("visible", "stable"), selector=spec, **kw
867
+ )
868
+ x, y = await self._element_center_of(el, spec)
869
+ preview, sel = await inject.describe_element(self, el)
870
+ desc = " — ".join(p for p in (preview, sel) if p)
871
+ return x, y, el, desc or str(spec)
872
+
873
+ async def drag(
874
+ self,
875
+ source: "str | tuple[float, float]",
876
+ to: "str | tuple[float, float]",
877
+ *,
878
+ steps: int = 12,
879
+ duration_ms: int = 400,
880
+ ) -> Receipt:
881
+ """Mouse drag from *source* to *to* — press, paced moves, release,
882
+ atomically in one call. Endpoints are selectors or (x, y) / 'x,y'.
883
+
884
+ Gesture state (an app's mousemove tracking) cannot survive being
885
+ split across separate calls, so the whole sequence is one method.
886
+ Dispatches real mouse events (buttons=1 held through the moves) —
887
+ covers pointer/mouse-event drag (SVG editors, drag libraries, sliders);
888
+ native HTML5 draggable=true DnD rides a separate browser pipeline
889
+ these events don't start, so a `changes: none` observation after
890
+ dragging one is the tell.
891
+
892
+ A drop target that only appears once the drag starts (drop zones) is
893
+ handled: if *to* doesn't resolve up front, the drag begins and the
894
+ selector is re-resolved mid-gesture.
895
+ """
896
+ await self._ensure_front()
897
+ x1, y1, src_el, src_desc = await self._resolve_drag_point(source)
898
+ if src_el is not None:
899
+ data = await inject.hit_receipt(self, src_el, x1, y1)
900
+ if data.get("hit") is not None and not data.get("related", True):
901
+ h = data["hit"]
902
+ hdesc = " — ".join(p for p in (h.get("preview"), h.get("sel")) if p)
903
+ push_kind(
904
+ f"drag source point ({x1:.0f},{y1:.0f}) occluded by {hdesc}",
905
+ "browser_warning",
906
+ target=self.target_id,
907
+ )
908
+
909
+ # Resolve the drop point before pressing when it already exists —
910
+ # a wrong-direction first nudge matters at 6 px drag scale.
911
+ deferred_target = False
912
+ try:
913
+ x2, y2, tgt_el, tgt_desc = await self._resolve_drag_point(to, timeout=0.5)
914
+ except RuntimeError:
915
+ deferred_target = True
916
+ x2 = y2 = 0.0
917
+ tgt_el, tgt_desc = None, str(to)
918
+
919
+ await self._exec(
920
+ "Input.dispatchMouseEvent", {"type": "mouseMoved", "x": x1, "y": y1}
921
+ )
922
+ await self._exec(
923
+ "Input.dispatchMouseEvent",
924
+ {
925
+ "type": "mousePressed",
926
+ "x": x1,
927
+ "y": y1,
928
+ "button": "left",
929
+ "buttons": 1,
930
+ "clickCount": 1,
931
+ },
932
+ )
933
+ # From here the button is down — never leave the page mid-drag: the
934
+ # release in `finally` fires at the last point the pointer reached.
935
+ cur_x, cur_y = x1, y1
936
+ try:
937
+ if deferred_target:
938
+ # Cross the typical dragstart threshold so drag-revealed drop
939
+ # zones mount, then resolve the selector for real.
940
+ cur_x, cur_y = x1 + 4, y1 + 4
941
+ await self._exec(
942
+ "Input.dispatchMouseEvent",
943
+ {"type": "mouseMoved", "x": cur_x, "y": cur_y, "buttons": 1},
944
+ )
945
+ x2, y2, tgt_el, tgt_desc = await self._resolve_drag_point(to)
946
+
947
+ delay = duration_ms / max(steps, 1) / 1000
948
+ for i in range(1, steps + 1):
949
+ frac = i / steps
950
+ cur_x = x1 + (x2 - x1) * frac
951
+ cur_y = y1 + (y2 - y1) * frac
952
+ await self._exec(
953
+ "Input.dispatchMouseEvent",
954
+ {"type": "mouseMoved", "x": cur_x, "y": cur_y, "buttons": 1},
955
+ )
956
+ await asyncio.sleep(delay)
957
+
958
+ drop_note = ""
959
+ warning = False
960
+ if tgt_el is not None:
961
+ data = await inject.hit_receipt(self, tgt_el, x2, y2)
962
+ if data.get("hit") is not None and not data.get("related", True):
963
+ h = data["hit"]
964
+ hdesc = " — ".join(p for p in (h.get("preview"), h.get("sel")) if p)
965
+ drop_note = f" — warning: drop point hits {hdesc}"
966
+ warning = True
967
+ finally:
968
+ await self._exec(
969
+ "Input.dispatchMouseEvent",
970
+ {
971
+ "type": "mouseReleased",
972
+ "x": cur_x,
973
+ "y": cur_y,
974
+ "button": "left",
975
+ "buttons": 0,
976
+ "clickCount": 1,
977
+ },
978
+ )
979
+ return Receipt(
980
+ line=(
981
+ f"dragged: {src_desc} ({x1:.0f},{y1:.0f}) → "
982
+ f"({x2:.0f},{y2:.0f}) {tgt_desc}{drop_note}"
983
+ ),
984
+ warning=warning,
985
+ )
986
+
799
987
  async def type_text(
800
988
  self,
801
989
  selector: str,
@@ -827,17 +1015,7 @@ class Tab(TabQueryMixin):
827
1015
  )
828
1016
 
829
1017
  before = await inject.read_value(self, el)
830
- for char in text:
831
- for event_type in ("keyDown", "keyUp"):
832
- await self._exec(
833
- "Input.dispatchKeyEvent",
834
- {
835
- "type": event_type,
836
- "text": char if event_type == "keyDown" else "",
837
- },
838
- )
839
- if delay_ms > 0:
840
- await asyncio.sleep(delay_ms / 1000)
1018
+ await self._type_chars(text, delay_ms=delay_ms)
841
1019
 
842
1020
  after = await inject.read_value(self, el)
843
1021
  if after == text:
@@ -865,14 +1043,32 @@ class Tab(TabQueryMixin):
865
1043
  await self.key("Enter")
866
1044
  return receipt
867
1045
 
1046
+ async def _type_chars(self, text: str, *, delay_ms: int = 0) -> None:
1047
+ """Key events into whatever holds focus — the shared core of
1048
+ type_text and select_option's type-to-filter."""
1049
+ for char in text:
1050
+ for event_type in ("keyDown", "keyUp"):
1051
+ await self._exec(
1052
+ "Input.dispatchKeyEvent",
1053
+ {
1054
+ "type": event_type,
1055
+ "text": char if event_type == "keyDown" else "",
1056
+ },
1057
+ )
1058
+ if delay_ms > 0:
1059
+ await asyncio.sleep(delay_ms / 1000)
1060
+
868
1061
  async def select_option(self, selector: str, option: str) -> Receipt:
869
1062
  """Select *option* in a dropdown — native <select> or custom listbox.
870
1063
 
871
1064
  Native <select>: finds the option by label (else value) and sets it
872
1065
  through the prototype setter + input/change events. Custom widgets
873
1066
  (react-select-style): clicks the field, waits for a role=option
874
- matching *option* (exact, then substring), clicks it. A miss lists
875
- the visible options' accessible names.
1067
+ matching *option* (exact, then substring), clicks it. When the click
1068
+ focuses an element declaring aria-autocomplete, *option* is typed
1069
+ into it first and resolution runs against the filtered listbox —
1070
+ which is what reaches an option a virtualized list hasn't rendered.
1071
+ A miss lists the visible options' accessible names.
876
1072
  """
877
1073
  await self._ensure_front()
878
1074
  el = await inject.resolve_element(self, selector)
@@ -895,6 +1091,25 @@ class Tab(TabQueryMixin):
895
1091
  return Receipt(line=f'selected "{option}" in {tdesc}')
896
1092
 
897
1093
  await self._click_element(el, selector=selector)
1094
+
1095
+ # Type-to-filter. aria-autocomplete on the freshly-focused element is
1096
+ # the widget's own declaration that typing narrows the listbox; going
1097
+ # through the filter is the only route to an option a virtualized
1098
+ # list keeps below its render window, where waiting can never make it
1099
+ # appear. The keystrokes go to document.activeElement regardless, so
1100
+ # the focused element is also the right one to ask.
1101
+ filtered = False
1102
+ ac = await inject.call_engine(
1103
+ self,
1104
+ "function() { const a = this.document.activeElement;"
1105
+ " const ac = a && a.getAttribute && a.getAttribute('aria-autocomplete');"
1106
+ " return !!ac && ac !== 'none'; }",
1107
+ [],
1108
+ )
1109
+ if ac.get("result", {}).get("value"):
1110
+ await self._type_chars(option)
1111
+ filtered = True
1112
+
898
1113
  opt_label = f"option {option!r}"
899
1114
  opt_selectors = [
900
1115
  f"role=option[name={selector_mod._text_body(option, exact=True)}]",
@@ -908,13 +1123,14 @@ class Tab(TabQueryMixin):
908
1123
  raise
909
1124
  except RuntimeError as exc:
910
1125
  names = await inject.list_option_names(self)
1126
+ via = " and typing it into the filter" if filtered else ""
911
1127
  if names:
912
1128
  raise RuntimeError(
913
- f"{opt_label} not found after opening {tdesc}"
1129
+ f"{opt_label} not found after opening {tdesc}{via}"
914
1130
  f" — visible options: {names}"
915
1131
  ) from exc
916
1132
  raise RuntimeError(
917
- f"{opt_label} not found — no listbox opened after clicking {tdesc}"
1133
+ f"{opt_label} not found — no listbox opened after clicking {tdesc}{via}"
918
1134
  ) from exc
919
1135
  await inject.check_actionable(
920
1136
  self, opt_el, ("visible", "stable"), selector=opt_label
@@ -961,26 +1177,80 @@ class Tab(TabQueryMixin):
961
1177
  verified = bool(v.get("result", {}).get("value"))
962
1178
  except Exception:
963
1179
  pass
964
- suffix = "" if verified else " (selection not verified)"
1180
+ suffix = " (typed to filter)" if filtered else ""
1181
+ suffix += "" if verified else " (selection not verified)"
965
1182
  return Receipt(line=f'selected "{option}" in {tdesc}{suffix}')
966
1183
 
967
1184
  async def key(self, key: str) -> None:
968
- """Dispatch a keyDown+keyUp pair for a named key (e.g. "Enter", "Escape", "Space").
1185
+ """Dispatch one key press — a named key, a printable character, or a
1186
+ modifier combo.
1187
+
1188
+ Accepts KeyboardEvent.key names ("Enter", "Backspace", "ArrowLeft",
1189
+ "F5", "Space" as an alias for " "), single printable characters ("a"),
1190
+ and "+"-joined combos with Ctrl/Control, Alt, Shift, Cmd/Meta
1191
+ modifiers ("Ctrl+A", "Shift+Tab"). A trailing "+" is the plus key
1192
+ itself ("Ctrl++").
1193
+ """
1194
+ await self._ensure_front()
1195
+ await self._dispatch_key(key)
969
1196
 
970
- `key` is a KeyboardEvent.key value ("Space" is accepted as a friendly
971
- alias for its real value, " ").
1197
+ async def keys(self, keys: list[str], *, delay_ms: int = 0) -> None:
1198
+ """Dispatch a sequence of key()/combo presses in one call.
1199
+
1200
+ One round trip for a keyboard navigation flow ("Ctrl+A", then
1201
+ "Backspace", then arrows) instead of one per keypress. delay_ms adds a
1202
+ pause between presses for apps that debounce key handling.
972
1203
  """
973
1204
  await self._ensure_front()
1205
+ for k in keys:
1206
+ await self._dispatch_key(k)
1207
+ if delay_ms:
1208
+ await asyncio.sleep(delay_ms / 1000)
1209
+
1210
+ async def _dispatch_key(self, spec: str) -> None:
1211
+ if spec.endswith("+"):
1212
+ # "Ctrl++" / bare "+": the key is a literal plus.
1213
+ key = "+"
1214
+ mod_part = spec[:-1].rstrip("+")
1215
+ mods = mod_part.split("+") if mod_part else []
1216
+ else:
1217
+ *mods, key = spec.split("+")
1218
+ modifiers = 0
1219
+ for m in mods:
1220
+ bit = _MODIFIER_BITS.get(m)
1221
+ if bit is None:
1222
+ raise ValueError(
1223
+ f"unknown modifier {m!r} in {spec!r} — use Ctrl/Alt/Shift/Cmd"
1224
+ )
1225
+ modifiers |= bit
1226
+
974
1227
  key = _KEY_ALIASES.get(key, key)
975
1228
  code = _KEY_CODE.get(key, key)
1229
+ vk = _KEY_VK.get(key)
1230
+ if len(key) == 1:
1231
+ ch = key.upper()
1232
+ if ch.isalpha():
1233
+ code = f"Key{ch}"
1234
+ elif ch.isdigit():
1235
+ code = f"Digit{ch}"
1236
+ if vk is None and (ch.isalpha() or ch.isdigit()):
1237
+ vk = ord(ch)
976
1238
  text = _KEY_TEXT.get(key)
1239
+ # A printable key produces its character — unless Ctrl/Alt/Meta is
1240
+ # held, where the browser runs the shortcut instead of inserting text.
1241
+ if text is None and len(key) == 1 and not (modifiers & 0b111):
1242
+ text = key
1243
+
977
1244
  for event_type in ("keyDown", "keyUp"):
978
- params: dict[str, str] = {"type": event_type, "key": key, "code": code}
979
- # Chromium's native button/checkbox/form activation for Enter and
980
- # Space only fires when the keyDown event also carries the
981
- # produced character in `text` — verified live: without it, the
982
- # DOM keydown/keyup still dispatch but no click ever follows,
983
- # indistinguishable from the key doing nothing.
1245
+ params: dict[str, Any] = {
1246
+ "type": event_type,
1247
+ "key": key,
1248
+ "code": code,
1249
+ "modifiers": modifiers,
1250
+ }
1251
+ if vk is not None:
1252
+ params["windowsVirtualKeyCode"] = vk
1253
+ params["nativeVirtualKeyCode"] = vk
984
1254
  if event_type == "keyDown" and text:
985
1255
  params["text"] = text
986
1256
  await self._exec("Input.dispatchKeyEvent", params)
@@ -346,7 +346,7 @@ class BrowserDispatchMixin:
346
346
  tab = self._get_tab(browser, args)
347
347
  session = self._session_for(browser, tab)
348
348
  if args.get("mode") == "ax":
349
- lines, _ = self._run_async(compose_tree(tab, session))
349
+ lines, _, _ = self._run_async(compose_tree(tab, session))
350
350
  else:
351
351
  lines, _ = self._run_async(compose_aria_tree(tab, session))
352
352
  return "\n".join(lines)
@@ -410,11 +410,14 @@ class BrowserDispatchMixin:
410
410
 
411
411
  def _bh_key(self, browser, args):
412
412
  tab = self._get_tab(browser, args)
413
+ if args.get("keys"):
414
+ mutate = lambda: self._run_async(tab.keys(list(args["keys"]))) # noqa: E731
415
+ elif args.get("key"):
416
+ mutate = lambda: self._run_async(tab.key(args["key"])) # noqa: E731
417
+ else:
418
+ raise ValueError("browser_key needs 'key' or 'keys'")
413
419
  return self._observed_mutation(
414
- browser,
415
- tab,
416
- lambda: self._run_async(tab.key(args["key"])),
417
- timeout=_SETTLE_INTERACTION_S,
420
+ browser, tab, mutate, timeout=_SETTLE_INTERACTION_S
418
421
  )
419
422
 
420
423
  def _bh_click(self, browser, args):
@@ -448,6 +451,37 @@ class BrowserDispatchMixin:
448
451
  )
449
452
  return f"{captured['receipt']}\n\n{obs}"
450
453
 
454
+ def _bh_hover(self, browser, args):
455
+ tab = self._get_tab(browser, args)
456
+ captured: dict = {}
457
+
458
+ def mutate():
459
+ captured["receipt"] = self._run_async(tab.hover(args["selector"]))
460
+
461
+ obs = self._observed_mutation(
462
+ browser, tab, mutate, timeout=_SETTLE_INTERACTION_S
463
+ )
464
+ return f"{captured['receipt']}\n\n{obs}"
465
+
466
+ def _bh_drag(self, browser, args):
467
+ tab = self._get_tab(browser, args)
468
+ captured: dict = {}
469
+
470
+ def mutate():
471
+ captured["receipt"] = self._run_async(
472
+ tab.drag(
473
+ args["from"],
474
+ args["to"],
475
+ steps=int(args.get("steps", 12)),
476
+ duration_ms=int(args.get("duration_ms", 400)),
477
+ )
478
+ )
479
+
480
+ obs = self._observed_mutation(
481
+ browser, tab, mutate, timeout=_SETTLE_INTERACTION_S
482
+ )
483
+ return f"{captured['receipt']}\n\n{obs}"
484
+
451
485
  def _bh_select(self, browser, args):
452
486
  tab = self._get_tab(browser, args)
453
487
  captured: dict = {}
@@ -483,6 +517,8 @@ class BrowserDispatchMixin:
483
517
  "browser_click": _bh_click,
484
518
  "browser_type": _bh_type,
485
519
  "browser_select": _bh_select,
520
+ "browser_hover": _bh_hover,
521
+ "browser_drag": _bh_drag,
486
522
  "browser_controls": _bh_controls,
487
523
  "browser_invoke": _bh_invoke,
488
524
  }
@@ -64,12 +64,17 @@ _BROWSER_MODEL = (
64
64
  "waits for visible/enabled/stable; click/type return a receipt line naming "
65
65
  "what was actually hit. type verifies the value landed, falling back to the "
66
66
  "native setter for framework-controlled inputs. browser_select drives "
67
- "dropdowns (native <select> or custom listbox). "
67
+ "dropdowns (native <select> or custom listbox; an aria-autocomplete field "
68
+ "gets the option typed in as a filter, reaching virtualized lists). "
69
+ "browser_hover parks the pointer on an element (hover-revealed UI stays "
70
+ "up); browser_drag runs press→moves→release atomically in one call "
71
+ "(selectors or 'x,y' endpoints). "
68
72
  "browser_tree default is an aria snapshot with [ref=eN] handles — reuse as "
69
73
  "aria-ref=eN selectors until the next snapshot or navigation; mode='ax' is "
70
74
  "the raw CDP tree. "
71
- "Mutations (click/type/select/navigate/key/open) settle then return "
72
- "receipt + tree + network delta + console delta. "
75
+ "Mutations (click/type/select/hover/drag/navigate/key/open) settle then "
76
+ "return receipt + a changes: AX-diff (what appeared/disappeared/changed "
77
+ "state) + tree + network delta + console delta. "
73
78
  "Tree crosses iframes. Network separates API calls from assets. "
74
79
  "get()/open() capture request/response bodies; watch() attaches lightweight. "
75
80
  "Read workflow: network → request → body. "
@@ -504,13 +509,42 @@ Convention: add data-testid to your root layout component.
504
509
  label (else value), sets it via the prototype setter + input/change
505
510
  events. Custom widgets (react-select-style): clicks the field, waits
506
511
  for a role=option matching the name (exact, then substring), clicks
507
- it. A miss lists the visible options' accessible names.
512
+ it. When the click focuses an element declaring aria-autocomplete,
513
+ the option text is typed in first and resolution runs against the
514
+ filtered listbox — reaches options a virtualized list hasn't rendered.
515
+ A miss lists the visible options' accessible names.
516
+
517
+ tab.hover(selector) → Receipt
518
+ Move the mouse over an element and leave it parked there — :hover
519
+ styling and mouseenter-revealed UI (menus, toolbars, tooltips) stay up
520
+ for a following click, and the observation's changes: section reports
521
+ what the hover revealed. Strict resolution + visible/stable wait like
522
+ click.
523
+
524
+ tab.drag(source, to, *, steps=12, duration_ms=400) → Receipt
525
+ Mouse drag in one call: press on source, paced mouseMoved events with
526
+ the button held, release on to. Endpoints are selectors (strictly
527
+ resolved, scrolled into view) or (x, y) tuples / 'x,y' strings. A
528
+ drop target that only appears once the drag starts is re-resolved
529
+ mid-gesture. Covers pointer/mouse-event drag (SVG editors, sliders,
530
+ drag libraries); native HTML5 draggable=true DnD rides a different
531
+ browser pipeline these events don't start — an observation saying
532
+ "changes: none" after dragging one is the tell. The Receipt names
533
+ both endpoints and warns if the drop point was occluded.
508
534
 
509
535
  tab.key(key) → None
510
- Dispatch a keyDown+keyUp pair for a named key (e.g. "Enter", "Escape",
511
- "Space"). Enter and Space carry the produced character so Chromium's
512
- native button/form activation fires, not just the DOM keydown/keyup —
513
- without it a focused button silently doesn't click.
536
+ Dispatch one key press: named keys ("Enter", "Backspace", "ArrowLeft",
537
+ "F5", "Space"), printable characters ("a"), and Ctrl/Alt/Shift/Cmd
538
+ combos ("Ctrl+A", "Shift+Tab"; trailing "+" is the plus key). Carries
539
+ the virtual key code, so editing actions actually happen — Backspace
540
+ deletes, arrows move the caret — and Enter/Space carry their character
541
+ so native button/form activation fires.
542
+
543
+ tab.keys(keys, *, delay_ms=0) → None
544
+ A sequence of key()/combo presses in one call — one round trip for a
545
+ keyboard flow ("Ctrl+A", "Backspace", "ArrowDown", "Enter") instead of
546
+ one per keypress. delay_ms pauses between presses for apps that
547
+ debounce key handling.
514
548
 
515
549
  tab.tap(selector_or_x, y=None) → Receipt
516
550
  Touch tap via Input.dispatchTouchEvent (touchstart/touchend).
@@ -790,7 +824,7 @@ Suppress filter (opt-in):
790
824
 
791
825
  == Selectors ==
792
826
 
793
- Same syntax across click, tap, type_text, select_option, wait_for:
827
+ Same syntax across click, tap, type_text, select_option, hover, drag, wait_for:
794
828
 
795
829
  .css-class, #id, [attr], tag CSS
796
830
  [data-testid='name'] CSS (recommended for own code)
@@ -899,10 +933,12 @@ wait_for_idle() and the MCP observation pipeline use the same settle logic:
899
933
 
900
934
  MCP browser tools (browser_click, browser_type, browser_navigate, etc.) run
901
935
  the full observation pipeline: pre_observe → mutate → settle → post_observe.
902
- They automatically wait for network idle and return tree + network delta +
903
- console delta. This means each MCP call returns only after the page is
904
- stable — the next call's auto-wait (2s) rarely fires because the element
905
- is already in the DOM.
936
+ They automatically wait for network idle and return a changes: section (an
937
+ AX-tree diff of what the mutation made appear/disappear/change state —
938
+ "changes: none" means the page visibly ignored the action) + tree +
939
+ network delta + console delta. This means each MCP call returns only
940
+ after the page is stable — the next call's auto-wait (2s) rarely fires
941
+ because the element is already in the DOM.
906
942
 
907
943
  exec-based mutations (calling tab.click(), tab.type_text() etc. in Python code)
908
944
  do NOT auto-settle. The method returns as soon as the CDP command completes.
@@ -1232,8 +1268,11 @@ Tab (async unless noted):
1232
1268
  tab.swipe(x1, y1, x2, y2, steps=, duration_ms=) → None (touch scroll)
1233
1269
  tab.scroll(selector, dy=, dx=, steps=, duration_ms=) → None (touch-scroll container)
1234
1270
  tab.type_text(selector, text, delay_ms=, press_enter=) → Receipt (clears first; verifies value, native-setter fallback for controlled inputs)
1235
- tab.select_option(selector, option) → Receipt (native <select> or custom listbox; a miss lists the options)
1236
- tab.key(key) → None (keyDown+keyUp, e.g. "Enter"/"Space" trigger native button activation)
1271
+ tab.select_option(selector, option) → Receipt (native <select> or custom listbox; types into an aria-autocomplete filter to reach virtualized options; a miss lists the options)
1272
+ tab.hover(selector) → Receipt (park the pointer on an element; hover-revealed UI stays up)
1273
+ tab.drag(source, to, steps=, duration_ms=) → Receipt (press→paced moves→release in one call; endpoints are selectors or (x,y)/'x,y')
1274
+ tab.key(key) → None (named keys, chars, "Ctrl+A" combos; VK-coded so Backspace/arrows actually edit)
1275
+ tab.keys(keys, delay_ms=) → None (sequence of presses in one call)
1237
1276
  tab.wait_for(selector, timeout=5) → None (wait for element to appear)
1238
1277
  tab.wait_for_idle(timeout=5, quiet=0.5) → int (network idle; returns settle ms)
1239
1278
  tab.fetch(url, method=, body=, headers=) → {status, ok, body, base64Encoded}
@@ -1300,7 +1339,7 @@ Browser:
1300
1339
  (HMR/navigation) it re-attaches and waits again. navigate() and reload()
1301
1340
  also wait for it. Convention: add data-testid to your root layout component.
1302
1341
 
1303
- Selectors (click/tap/type_text/select_option):
1342
+ Selectors (click/tap/type_text/select_option/hover/drag):
1304
1343
  .css-class, #id, [attr], tag CSS
1305
1344
  [data-testid='name'] CSS (recommended for own code)
1306
1345
  text=Submit exact text match
@@ -221,8 +221,11 @@ TOOLS = [
221
221
  {
222
222
  "name": "browser_key",
223
223
  "description": (
224
- "Send a key press (Enter, Escape, Tab, ArrowDown, etc). "
225
- "Returns observation (tree + network + console delta after settle)."
224
+ "Send key presses. Named keys (Enter, Escape, Backspace, "
225
+ "ArrowDown), printable characters, and Ctrl/Alt/Shift/Cmd combos "
226
+ "('Ctrl+A', 'Shift+Tab'). Pass keys=[...] for a whole sequence in "
227
+ "one call. Returns observation (tree + network + console delta "
228
+ "after settle)."
226
229
  ),
227
230
  "inputSchema": {
228
231
  "type": "object",
@@ -230,10 +233,15 @@ TOOLS = [
230
233
  "target": _TARGET_PARAM,
231
234
  "key": {
232
235
  "type": "string",
233
- "description": "Key name: Enter, Escape, Tab, ArrowDown, etc.",
236
+ "description": "One key or combo: Enter, Backspace, Ctrl+A, ...",
237
+ },
238
+ "keys": {
239
+ "type": "array",
240
+ "items": {"type": "string"},
241
+ "description": "Sequence of keys/combos, pressed in order",
234
242
  },
235
243
  },
236
- "required": ["target", "key"],
244
+ "required": ["target"],
237
245
  },
238
246
  },
239
247
  {
@@ -353,6 +361,63 @@ TOOLS = [
353
361
  "required": ["target", "selector", "option"],
354
362
  },
355
363
  },
364
+ {
365
+ "name": "browser_hover",
366
+ "description": (
367
+ "Hover an element, strictly resolved like browser_click, and "
368
+ "leave the pointer parked on it — hover-revealed UI (menus, "
369
+ "toolbars, tooltips) stays up for a following browser_click, and "
370
+ "the observation's changes: section reports what the hover "
371
+ "revealed. Returns receipt + observation."
372
+ ),
373
+ "inputSchema": {
374
+ "type": "object",
375
+ "properties": {
376
+ "target": _TARGET_PARAM,
377
+ "selector": _SELECTOR_PARAM,
378
+ },
379
+ "required": ["target", "selector"],
380
+ },
381
+ },
382
+ {
383
+ "name": "browser_drag",
384
+ "description": (
385
+ "Mouse drag in a single call: press, paced moves with the button "
386
+ "held, release — gesture state can't survive being split across "
387
+ "calls. Endpoints are selectors (strictly resolved) or 'x,y' "
388
+ "coordinates. A drop target that only appears once the drag "
389
+ "starts is re-resolved mid-gesture. Covers pointer/mouse-event "
390
+ "drag (SVG editors, sliders, drag libraries); native HTML5 "
391
+ "draggable DnD rides a different browser pipeline — an "
392
+ "observation saying 'changes: none' after dragging one is the "
393
+ "tell. Returns receipt + observation."
394
+ ),
395
+ "inputSchema": {
396
+ "type": "object",
397
+ "properties": {
398
+ "target": _TARGET_PARAM,
399
+ "from": {
400
+ "type": "string",
401
+ "description": "Drag origin: selector or 'x,y'",
402
+ },
403
+ "to": {
404
+ "type": "string",
405
+ "description": "Drop point: selector or 'x,y'",
406
+ },
407
+ "steps": {
408
+ "type": "integer",
409
+ "default": 12,
410
+ "description": "Intermediate move events",
411
+ },
412
+ "duration_ms": {
413
+ "type": "integer",
414
+ "default": 400,
415
+ "description": "Total move time across the steps",
416
+ },
417
+ },
418
+ "required": ["target", "from", "to"],
419
+ },
420
+ },
356
421
  {
357
422
  "name": "browser_console",
358
423
  "description": "Query captured console messages for a tab (console.log, errors, exceptions).",
@@ -486,6 +551,8 @@ _TOOL_ANNOTATIONS = {
486
551
  "browser_click": {"openWorldHint": True},
487
552
  "browser_type": {"openWorldHint": True},
488
553
  "browser_select": {"openWorldHint": True},
554
+ "browser_hover": {"openWorldHint": True},
555
+ "browser_drag": {"openWorldHint": True},
489
556
  "browser_invoke": {"openWorldHint": True},
490
557
  }
491
558
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes