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.
- {repld_tool-0.3.2 → repld_tool-0.3.3}/PKG-INFO +1 -1
- {repld_tool-0.3.2 → repld_tool-0.3.3}/pyproject.toml +1 -1
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/observe.py +173 -14
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/tab.py +299 -29
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser_dispatch.py +41 -5
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/help.py +55 -16
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/protocol.py +71 -4
- {repld_tool-0.3.2 → repld_tool-0.3.3}/LICENSE +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/README.md +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/.ruff_cache/.gitignore +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/.ruff_cache/0.15.22/14226964882126921391 +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/.ruff_cache/CACHEDIR.TAG +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/__init__.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/__main__.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/bg.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/bind.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/bridge.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/bridge_tools.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/__init__.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/browser.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/capture.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/cdp.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/har.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/inject.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/injected_source.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/pin.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/png.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/pool.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/row.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/selector.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/session.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/tab_query.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/browser/target.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/channel.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/cli.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/cli_args.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/core_schemas.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/dashboard.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/dashboard_cmd.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/dashboard_html.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/display.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/eventlog.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/events.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/exec_cmd.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/gate_cmd.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/gates.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/gist_api.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/gist_cmd.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/gist_deps.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/gist_links.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/gist_lint.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/gists.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/ipc.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/kernel.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/kernel_context.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/lifecycle_cmd.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/log_cmd.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/paths.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/relaunch.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/render.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/runtime.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/sessions.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/spawn.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/state.py +0 -0
- {repld_tool-0.3.2 → repld_tool-0.3.3}/src/repld/tasks.py +0 -0
|
@@ -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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
43
|
-
#
|
|
44
|
-
#
|
|
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
|
-
|
|
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.
|
|
875
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
971
|
-
|
|
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,
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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
|
|
72
|
-
"receipt +
|
|
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.
|
|
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
|
|
511
|
-
"Space")
|
|
512
|
-
|
|
513
|
-
|
|
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
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
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.
|
|
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
|
|
225
|
-
"
|
|
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": "
|
|
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"
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|