repld-tool 0.3.1__tar.gz → 0.3.2__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.1 → repld_tool-0.3.2}/PKG-INFO +1 -1
- {repld_tool-0.3.1 → repld_tool-0.3.2}/pyproject.toml +1 -1
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/bridge.py +3 -4
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/selector.py +82 -10
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/tab.py +64 -21
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser_dispatch.py +46 -1
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/help.py +16 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/protocol.py +16 -4
- {repld_tool-0.3.1 → repld_tool-0.3.2}/LICENSE +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/README.md +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/.ruff_cache/.gitignore +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/.ruff_cache/0.15.22/14226964882126921391 +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/.ruff_cache/CACHEDIR.TAG +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/__init__.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/__main__.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/bg.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/bind.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/bridge_tools.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/__init__.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/browser.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/capture.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/cdp.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/har.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/inject.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/injected_source.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/observe.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/pin.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/png.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/pool.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/row.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/session.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/tab_query.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/target.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/channel.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/cli.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/cli_args.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/core_schemas.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/dashboard.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/dashboard_cmd.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/dashboard_html.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/display.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/eventlog.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/events.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/exec_cmd.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/gate_cmd.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/gates.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/gist_api.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/gist_cmd.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/gist_deps.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/gist_links.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/gist_lint.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/gists.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/ipc.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/kernel.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/kernel_context.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/lifecycle_cmd.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/log_cmd.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/paths.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/relaunch.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/render.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/runtime.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/sessions.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/spawn.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/state.py +0 -0
- {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/tasks.py +0 -0
|
@@ -334,10 +334,9 @@ class Bridge:
|
|
|
334
334
|
return True
|
|
335
335
|
|
|
336
336
|
if method == "ping":
|
|
337
|
-
#
|
|
338
|
-
#
|
|
339
|
-
#
|
|
340
|
-
# declines that isn't in _NEEDS_KERNEL is answered -32601.
|
|
337
|
+
# Answered here, not by a kernel — the bridge is the thing being
|
|
338
|
+
# pinged. Must not fall through: anything the intercept declines
|
|
339
|
+
# that isn't in _NEEDS_KERNEL is answered -32601.
|
|
341
340
|
self._to_client(_response(rid, {}))
|
|
342
341
|
return True
|
|
343
342
|
|
|
@@ -43,7 +43,20 @@ _ROLE_CSS: dict[str, str] = {
|
|
|
43
43
|
# identifiers. Anchored and dot-free, so a real expression (`window.ready`,
|
|
44
44
|
# `app.isLoaded`) still reads as JS.
|
|
45
45
|
_BARE_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9-]*$")
|
|
46
|
-
_SELECTOR_PREFIXES = (
|
|
46
|
+
_SELECTOR_PREFIXES = (
|
|
47
|
+
".",
|
|
48
|
+
"#",
|
|
49
|
+
"[",
|
|
50
|
+
"data-",
|
|
51
|
+
"text=",
|
|
52
|
+
"role=",
|
|
53
|
+
"label=",
|
|
54
|
+
"aria-ref=",
|
|
55
|
+
"placeholder=",
|
|
56
|
+
"testid=",
|
|
57
|
+
"getBy",
|
|
58
|
+
"locator(",
|
|
59
|
+
)
|
|
47
60
|
|
|
48
61
|
|
|
49
62
|
def looks_like_selector(s: str) -> bool:
|
|
@@ -56,12 +69,9 @@ def looks_like_selector(s: str) -> bool:
|
|
|
56
69
|
|
|
57
70
|
|
|
58
71
|
def _q(text: str) -> str:
|
|
59
|
-
# ensure_ascii=False is load-bearing
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
# selector carrying a non-ASCII name (`text=Pågår`, a Norwegian label)
|
|
63
|
-
# matched nothing. Found live on a real page; the raw engine matched what
|
|
64
|
-
# the translated selector missed.
|
|
72
|
+
# ensure_ascii=False is load-bearing: the engine's selector parser reads
|
|
73
|
+
# json.dumps' default \u-escapes as literal characters, so `text=Pågår`
|
|
74
|
+
# would match nothing.
|
|
65
75
|
return json.dumps(text, ensure_ascii=False)
|
|
66
76
|
|
|
67
77
|
|
|
@@ -81,6 +91,59 @@ def _js_regex_escape(text: str) -> str:
|
|
|
81
91
|
_ROLE_RE = re.compile(r'^role=(\w+)(?:\[name([*^]?=)["\']?(.+?)["\']?\])?$')
|
|
82
92
|
_HAS_TEXT_RE = re.compile(r"^(.+?):has-text\(['\"](.+?)['\"]\)$")
|
|
83
93
|
|
|
94
|
+
# Playwright locator syntax, accepted as *input* so the strict-mode error's own
|
|
95
|
+
# suggestions (`aka getByTestId('x')`) are pasteable back into click/type.
|
|
96
|
+
# Mirrors packages/isomorphic/locatorUtils.ts' getBy*Selector builders — only
|
|
97
|
+
# the simple single-call forms; chains (`.filter()`, `>>`) error with guidance
|
|
98
|
+
# rather than falling through to css= and failing as nonsense.
|
|
99
|
+
_GETBY_RE = re.compile(
|
|
100
|
+
r"^(getByRole|getByTestId|getByText|getByLabel|getByPlaceholder"
|
|
101
|
+
r"|getByAltText|getByTitle|locator)\s*\((.*)\)\s*$",
|
|
102
|
+
re.S,
|
|
103
|
+
)
|
|
104
|
+
_GETBY_ARGS_RE = re.compile(
|
|
105
|
+
r"""^\s*(['"])(?P<v>(?:\\.|(?!\1).)*)\1\s*(?:,\s*\{(?P<opts>.*)\}\s*)?$""",
|
|
106
|
+
re.S,
|
|
107
|
+
)
|
|
108
|
+
_GETBY_NAME_RE = re.compile(r"""name\s*:\s*(['"])(?P<v>(?:\\.|(?!\1).)*)\1""")
|
|
109
|
+
_GETBY_EXACT_RE = re.compile(r"exact\s*:\s*true")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _unescape(v: str) -> str:
|
|
113
|
+
return re.sub(r"\\(.)", r"\1", v)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _translate_getby(fn: str, args: str) -> list[str]:
|
|
117
|
+
m = _GETBY_ARGS_RE.match(args)
|
|
118
|
+
if m is None:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"cannot parse {fn}({args!r}) — only the simple quoted-argument "
|
|
121
|
+
"forms are supported (no chaining, no regex/variable arguments)"
|
|
122
|
+
)
|
|
123
|
+
value = _unescape(m.group("v"))
|
|
124
|
+
opts = m.group("opts") or ""
|
|
125
|
+
exact = bool(_GETBY_EXACT_RE.search(opts))
|
|
126
|
+
if fn == "locator":
|
|
127
|
+
return translate_fallbacks(value)
|
|
128
|
+
if fn == "getByTestId":
|
|
129
|
+
return [f"internal:testid=[data-testid={_text_body(value, exact=True)}]"]
|
|
130
|
+
if fn == "getByRole":
|
|
131
|
+
name_m = _GETBY_NAME_RE.search(opts)
|
|
132
|
+
if name_m is None:
|
|
133
|
+
return [f"role={value}"]
|
|
134
|
+
name = _unescape(name_m.group("v"))
|
|
135
|
+
return [f"role={value}[name={_text_body(name, exact=exact)}]"]
|
|
136
|
+
if fn == "getByText":
|
|
137
|
+
return [f"internal:text={_text_body(value, exact=exact)}"]
|
|
138
|
+
if fn == "getByLabel":
|
|
139
|
+
return [f"internal:label={_text_body(value, exact=exact)}"]
|
|
140
|
+
attr = {
|
|
141
|
+
"getByPlaceholder": "placeholder",
|
|
142
|
+
"getByAltText": "alt",
|
|
143
|
+
"getByTitle": "title",
|
|
144
|
+
}[fn]
|
|
145
|
+
return [f"internal:attr=[{attr}={_text_body(value, exact=exact)}]"]
|
|
146
|
+
|
|
84
147
|
|
|
85
148
|
def translate(selector: str) -> str:
|
|
86
149
|
"""Translate one repld selector to the engine's selector language."""
|
|
@@ -98,9 +161,8 @@ def translate_fallbacks(selector: str) -> list[str]:
|
|
|
98
161
|
if selector.startswith("text="):
|
|
99
162
|
text = selector[5:]
|
|
100
163
|
# internal:text, not the public text= engine: only the internal one
|
|
101
|
-
# accepts the `"…"s` exact
|
|
102
|
-
#
|
|
103
|
-
# `text="Save"s` as the literal string `"Save"s` and matches nothing).
|
|
164
|
+
# accepts the `"…"s` exact body — the public engine parses
|
|
165
|
+
# `text="Save"s` as the literal string `"Save"s` and matches nothing.
|
|
104
166
|
return [
|
|
105
167
|
f"internal:text={_text_body(text, exact=True)}",
|
|
106
168
|
f"css=[aria-label={_q(text)}]",
|
|
@@ -123,6 +185,16 @@ def translate_fallbacks(selector: str) -> list[str]:
|
|
|
123
185
|
if selector.startswith("label="):
|
|
124
186
|
return [f"internal:label={_text_body(selector[6:], exact=True)}"]
|
|
125
187
|
|
|
188
|
+
if selector.startswith("placeholder="):
|
|
189
|
+
return [f"internal:attr=[placeholder={_text_body(selector[12:], exact=True)}]"]
|
|
190
|
+
|
|
191
|
+
if selector.startswith("testid="):
|
|
192
|
+
return [f"internal:testid=[data-testid={_text_body(selector[7:], exact=True)}]"]
|
|
193
|
+
|
|
194
|
+
m = _GETBY_RE.match(selector)
|
|
195
|
+
if m:
|
|
196
|
+
return _translate_getby(m.group(1), m.group(2))
|
|
197
|
+
|
|
126
198
|
m = _HAS_TEXT_RE.match(selector)
|
|
127
199
|
if m:
|
|
128
200
|
base, text = m.group(1), m.group(2)
|
|
@@ -552,6 +552,18 @@ class Tab(TabQueryMixin):
|
|
|
552
552
|
if result.get("result", {}).get("value") == "hidden":
|
|
553
553
|
await self._exec("Page.bringToFront")
|
|
554
554
|
|
|
555
|
+
async def set_viewport(self, width: int, height: int) -> None:
|
|
556
|
+
"""Emulate a fixed viewport (Emulation.setDeviceMetricsOverride).
|
|
557
|
+
|
|
558
|
+
deviceScaleFactor pinned to 1 so page pixels are screenshot pixels.
|
|
559
|
+
Use a fresh tab per distinct size — re-overriding an already-
|
|
560
|
+
overridden tab can leave clientWidth and innerWidth disagreeing.
|
|
561
|
+
"""
|
|
562
|
+
await self._exec(
|
|
563
|
+
"Emulation.setDeviceMetricsOverride",
|
|
564
|
+
{"width": width, "height": height, "deviceScaleFactor": 1, "mobile": False},
|
|
565
|
+
)
|
|
566
|
+
|
|
555
567
|
async def front(self) -> None:
|
|
556
568
|
"""Bring this tab's window to the front (Page.bringToFront).
|
|
557
569
|
|
|
@@ -649,13 +661,23 @@ class Tab(TabQueryMixin):
|
|
|
649
661
|
async def _element_center_of(
|
|
650
662
|
self, el: inject.ResolvedElement, selector: str
|
|
651
663
|
) -> tuple[float, float]:
|
|
652
|
-
"""Center point of a resolved element handle.
|
|
664
|
+
"""Center point of a resolved element handle, scrolled into view first.
|
|
665
|
+
|
|
666
|
+
The scroll is load-bearing: quads are viewport coordinates and
|
|
667
|
+
clipping isn't visibility, so an element below a scroll fold (the
|
|
668
|
+
page's or a scrollable listbox's) passes the visible check while its
|
|
669
|
+
center lies outside the clip — a click there hits whatever is
|
|
670
|
+
actually at that point. Best-effort: a detached or unscrollable node
|
|
671
|
+
falls through to the hit test.
|
|
653
672
|
|
|
654
673
|
DOM.getContentQuads returns no quads for a zero-area element; the
|
|
655
|
-
bounding-rect fallback keeps
|
|
656
|
-
|
|
657
|
-
the resolve policy that a lone invisible match is still the target.
|
|
674
|
+
bounding-rect fallback keeps a lone hidden match (an off-screen real
|
|
675
|
+
control behind a styled proxy) addressable, per the resolve policy.
|
|
658
676
|
"""
|
|
677
|
+
try:
|
|
678
|
+
await self._exec("DOM.scrollIntoViewIfNeeded", {"objectId": el.object_id})
|
|
679
|
+
except RuntimeError:
|
|
680
|
+
pass
|
|
659
681
|
quads = await self._exec("DOM.getContentQuads", {"objectId": el.object_id})
|
|
660
682
|
qs = quads.get("quads") or []
|
|
661
683
|
if qs:
|
|
@@ -700,18 +722,13 @@ class Tab(TabQueryMixin):
|
|
|
700
722
|
"""Coordinate resolution + hit receipt + dispatch for a resolved element.
|
|
701
723
|
|
|
702
724
|
The hit test runs *before* dispatch — the click's own handlers may
|
|
703
|
-
re-render the DOM
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
visible, resolvable button was never coordinate-clickable. The DOM
|
|
711
|
-
click is isTrusted=false — an app that insists on trusted events will
|
|
712
|
-
ignore it, which is why the receipt names the path taken. Modified
|
|
713
|
-
clicks (right/double) have no element.click() equivalent, so they keep
|
|
714
|
-
the coordinate dispatch plus the warning.
|
|
725
|
+
re-render the DOM. When the point lands on something unrelated, a
|
|
726
|
+
plain left-click switches to `element.click()`: dispatching at the
|
|
727
|
+
coordinates can start a drag on a pan layer, while a DOM click
|
|
728
|
+
reaches the resolved element the way assistive tech does. It is
|
|
729
|
+
isTrusted=false, so the receipt names the path taken. Modified clicks
|
|
730
|
+
(right/double) have no element.click() equivalent and keep the
|
|
731
|
+
coordinate dispatch plus the warning.
|
|
715
732
|
"""
|
|
716
733
|
x, y = await self._element_center_of(el, selector)
|
|
717
734
|
data = await inject.hit_receipt(self, el, x, y)
|
|
@@ -902,17 +919,43 @@ class Tab(TabQueryMixin):
|
|
|
902
919
|
await inject.check_actionable(
|
|
903
920
|
self, opt_el, ("visible", "stable"), selector=opt_label
|
|
904
921
|
)
|
|
922
|
+
# A just-opened menu is still moving: Popper repositions the listbox
|
|
923
|
+
# after first paint, and the engine's 1-rAF stable check passes inside
|
|
924
|
+
# that window — a click at the pre-reposition point lands outside the
|
|
925
|
+
# menu. Sample the option's center 120 ms apart until it stops moving.
|
|
926
|
+
loop = asyncio.get_running_loop()
|
|
927
|
+
prev: tuple[float, float] | None = None
|
|
928
|
+
deadline = loop.time() + 1.5
|
|
929
|
+
while True:
|
|
930
|
+
pos = await self._element_center_of(opt_el, opt_label)
|
|
931
|
+
if (
|
|
932
|
+
prev is not None
|
|
933
|
+
and abs(pos[0] - prev[0]) < 1
|
|
934
|
+
and abs(pos[1] - prev[1]) < 1
|
|
935
|
+
):
|
|
936
|
+
break
|
|
937
|
+
if loop.time() >= deadline:
|
|
938
|
+
break
|
|
939
|
+
prev = pos
|
|
940
|
+
await asyncio.sleep(0.12)
|
|
905
941
|
await self._click_element(opt_el, selector=opt_label)
|
|
906
942
|
|
|
907
|
-
#
|
|
908
|
-
#
|
|
909
|
-
#
|
|
943
|
+
# Verify what the widget *renders*: react-select never stores the
|
|
944
|
+
# choice in input.value and puts role=combobox on the input itself, so
|
|
945
|
+
# a `closest` self-match walks up two levels to the value container
|
|
946
|
+
# instead. try/except: the click may have replaced the field element
|
|
947
|
+
# outright, detaching our handle — not a failure.
|
|
910
948
|
verified = False
|
|
911
949
|
try:
|
|
912
950
|
v = await inject.call_engine(
|
|
913
951
|
self,
|
|
914
|
-
"function(el, opt) {
|
|
915
|
-
"
|
|
952
|
+
"function(el, opt) {"
|
|
953
|
+
" let root = el.closest('[role=\"combobox\"]');"
|
|
954
|
+
" if (!root || root === el)"
|
|
955
|
+
" root = el.parentElement"
|
|
956
|
+
" ? (el.parentElement.parentElement || el.parentElement) : el;"
|
|
957
|
+
" const t = (el.value || '') + ' ' + (root.textContent || '');"
|
|
958
|
+
" return t.includes(opt); }",
|
|
916
959
|
[{"objectId": el.object_id}, {"value": option}],
|
|
917
960
|
)
|
|
918
961
|
verified = bool(v.get("result", {}).get("value"))
|
|
@@ -42,6 +42,45 @@ _SETTLE_DEFAULT_S = 3.0
|
|
|
42
42
|
_POST_TYPE_DEBOUNCE_S = 0.3
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
# Noise control, not redaction — repld's contract is that the agent works with
|
|
46
|
+
# the user's real sessions, so nothing is hidden: `full=true` (or tab.request()
|
|
47
|
+
# in exec) returns everything. The cap only trims the cookie jar and bearer
|
|
48
|
+
# tokens that otherwise dominate every request dump.
|
|
49
|
+
_HEADER_VALUE_CAP = 120
|
|
50
|
+
_COOKIE_VALUE_CAP = 24
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _cap(value, limit: int):
|
|
54
|
+
if not isinstance(value, str) or len(value) <= limit:
|
|
55
|
+
return value
|
|
56
|
+
return f"{value[:limit]}…(+{len(value) - limit} chars — full=true for the rest)"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _compact_credentials(entry: dict) -> dict:
|
|
60
|
+
out = json.loads(json.dumps(entry, default=str)) # deep copy, wire-shaped
|
|
61
|
+
for side in ("request", "response"):
|
|
62
|
+
part = out.get(side)
|
|
63
|
+
if not isinstance(part, dict):
|
|
64
|
+
continue
|
|
65
|
+
headers = part.get("headers")
|
|
66
|
+
if isinstance(headers, dict):
|
|
67
|
+
for k, v in headers.items():
|
|
68
|
+
if k.lower() in ("cookie", "set-cookie") and isinstance(v, str):
|
|
69
|
+
headers[k] = "; ".join(
|
|
70
|
+
f"{n}={_cap(val, _COOKIE_VALUE_CAP)}" if val else pair
|
|
71
|
+
for pair in v.split("; ")
|
|
72
|
+
for n, _, val in (pair.partition("="),)
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
headers[k] = _cap(v, _HEADER_VALUE_CAP)
|
|
76
|
+
cookies = part.get("cookies")
|
|
77
|
+
if isinstance(cookies, list):
|
|
78
|
+
for c in cookies:
|
|
79
|
+
if isinstance(c, dict) and "value" in c:
|
|
80
|
+
c["value"] = _cap(c["value"], _COOKIE_VALUE_CAP)
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
45
84
|
async def route_detach(browser, target, port) -> str | None:
|
|
46
85
|
"""Shared target/port detach routing (MCP tool + dashboard RPC).
|
|
47
86
|
|
|
@@ -246,7 +285,10 @@ class BrowserDispatchMixin:
|
|
|
246
285
|
|
|
247
286
|
def _bh_request(self, browser, args):
|
|
248
287
|
tab = self._get_tab(browser, args)
|
|
249
|
-
|
|
288
|
+
entry = tab.request(args["request_id"])
|
|
289
|
+
if args.get("full"):
|
|
290
|
+
return entry
|
|
291
|
+
return _compact_credentials(entry)
|
|
250
292
|
|
|
251
293
|
def _bh_body(self, browser, args):
|
|
252
294
|
tab = self._get_tab(browser, args)
|
|
@@ -350,6 +392,9 @@ class BrowserDispatchMixin:
|
|
|
350
392
|
from .browser.observe import PreObservation, post_observe
|
|
351
393
|
|
|
352
394
|
tab = self._run_async(browser.open(args["url"]))
|
|
395
|
+
if args.get("viewport"):
|
|
396
|
+
w, _, h = str(args["viewport"]).lower().partition("x")
|
|
397
|
+
self._run_async(tab.set_viewport(int(w), int(h)))
|
|
353
398
|
session = self._session_for(browser, tab)
|
|
354
399
|
key = tab.target_id
|
|
355
400
|
pre = PreObservation(iframe_children=[], snapshots={key: 0})
|
|
@@ -519,6 +519,12 @@ Convention: add data-testid to your root layout component.
|
|
|
519
519
|
3s timeout — raises TimeoutError if the page's touch handler blocks
|
|
520
520
|
(common on complex apps like Messenger/React).
|
|
521
521
|
|
|
522
|
+
tab.set_viewport(width, height) → None
|
|
523
|
+
Emulate a fixed viewport at deviceScaleFactor 1
|
|
524
|
+
(Emulation.setDeviceMetricsOverride), so screenshot coordinates are
|
|
525
|
+
page pixels with no multiplier math. browser_open's viewport= param
|
|
526
|
+
calls this on open. Use a fresh tab per distinct size.
|
|
527
|
+
|
|
522
528
|
tab.front() → None
|
|
523
529
|
Bring this tab's window to the front (Page.bringToFront).
|
|
524
530
|
click/tap/type_text/key already call this automatically when Chrome
|
|
@@ -791,8 +797,14 @@ Same syntax across click, tap, type_text, select_option, wait_for:
|
|
|
791
797
|
text=Submit exact text match
|
|
792
798
|
role=button[name="Save"] ARIA role + accessible name
|
|
793
799
|
label=Username input by label
|
|
800
|
+
placeholder=Search or create input by placeholder
|
|
801
|
+
testid=workflow.add-status data-testid shorthand
|
|
794
802
|
button:has-text('OK') CSS + text filter
|
|
795
803
|
aria-ref=e12 ref from tab.tree() / browser_tree
|
|
804
|
+
getByRole('button', { name: 'OK' }) Playwright locator call — what the
|
|
805
|
+
strict-mode error suggests, pasteable
|
|
806
|
+
as-is (also getByTestId/getByText/
|
|
807
|
+
getByLabel/getByPlaceholder/locator)
|
|
796
808
|
|
|
797
809
|
Resolution runs through a vendored build of Playwright's InjectedScript
|
|
798
810
|
engine, evaluated once per document in an isolated world (main world on the
|
|
@@ -1215,6 +1227,7 @@ Tab (async unless noted):
|
|
|
1215
1227
|
tab.tree(mode="aria") → list[str] (aria: [ref=eN] snapshot; "ax": raw CDP tree)
|
|
1216
1228
|
tab.click(selector, button=, click_count=) → Receipt (strict resolve + actionability wait; names what was hit)
|
|
1217
1229
|
tab.tap(selector_or_x, y=) → Receipt (touch event, 3s timeout)
|
|
1230
|
+
tab.set_viewport(width, height) → None (fixed viewport at scale 1 — screenshot px == page px)
|
|
1218
1231
|
tab.front() → None (Page.bringToFront; click/tap/type_text/key auto-call this on a hidden tab)
|
|
1219
1232
|
tab.swipe(x1, y1, x2, y2, steps=, duration_ms=) → None (touch scroll)
|
|
1220
1233
|
tab.scroll(selector, dy=, dx=, steps=, duration_ms=) → None (touch-scroll container)
|
|
@@ -1293,8 +1306,11 @@ Selectors (click/tap/type_text/select_option):
|
|
|
1293
1306
|
text=Submit exact text match
|
|
1294
1307
|
role=button[name="Save"] ARIA role + accessible name (accname)
|
|
1295
1308
|
label=Username input by label
|
|
1309
|
+
placeholder=Search input by placeholder
|
|
1310
|
+
testid=x data-testid shorthand
|
|
1296
1311
|
button:has-text('OK') CSS + text filter
|
|
1297
1312
|
aria-ref=e12 ref from tab.tree() (dies on next snapshot/navigation)
|
|
1313
|
+
getByRole('button', { name: 'OK' }) Playwright locator call — ambiguity-error suggestions paste as-is
|
|
1298
1314
|
|
|
1299
1315
|
All forms resolve through the vendored Playwright engine (isolated world,
|
|
1300
1316
|
once per document) and pierce open shadow roots. Strict: >1 match with no
|
|
@@ -43,8 +43,10 @@ _TARGET_PARAM = {"type": "string", "description": _TARGET_DESC}
|
|
|
43
43
|
_SELECTOR_PARAM = {
|
|
44
44
|
"type": "string",
|
|
45
45
|
"description": (
|
|
46
|
-
"CSS, text=Label, role=button[name='OK'], label=Name, "
|
|
47
|
-
"tag:has-text('...'),
|
|
46
|
+
"CSS, text=Label, role=button[name='OK'], label=Name, placeholder=Hint, "
|
|
47
|
+
"testid=x, tag:has-text('...'), aria-ref=e12 from browser_tree, or a "
|
|
48
|
+
"Playwright locator call as suggested by ambiguity errors "
|
|
49
|
+
"(getByRole('button', {name: 'OK'}), getByTestId('x'), ...)"
|
|
48
50
|
),
|
|
49
51
|
}
|
|
50
52
|
|
|
@@ -148,13 +150,17 @@ TOOLS = [
|
|
|
148
150
|
"description": (
|
|
149
151
|
"Inspect a captured request by request_id. Returns full HAR entry "
|
|
150
152
|
"with request/response headers, postData, auth scheme, timing — "
|
|
151
|
-
"everything except the response body (use browser_body for that)."
|
|
153
|
+
"everything except the response body (use browser_body for that). "
|
|
154
|
+
"Cookie and long header values are truncated by default (noise, "
|
|
155
|
+
"not secrecy — they dominate every dump); pass full=true, or use "
|
|
156
|
+
"tab.request(id) in exec, for the verbatim values."
|
|
152
157
|
),
|
|
153
158
|
"inputSchema": {
|
|
154
159
|
"type": "object",
|
|
155
160
|
"properties": {
|
|
156
161
|
"target": _TARGET_PARAM,
|
|
157
162
|
"request_id": {"type": "string"},
|
|
163
|
+
"full": {"type": "boolean", "default": False},
|
|
158
164
|
},
|
|
159
165
|
"required": ["target", "request_id"],
|
|
160
166
|
},
|
|
@@ -196,12 +202,18 @@ TOOLS = [
|
|
|
196
202
|
"name": "browser_open",
|
|
197
203
|
"description": (
|
|
198
204
|
"Open new tab and navigate. "
|
|
199
|
-
"Returns observation with target: header for the new tab ID."
|
|
205
|
+
"Returns observation with target: header for the new tab ID. "
|
|
206
|
+
"viewport='1440x900' emulates a fixed viewport at scale 1, so "
|
|
207
|
+
"screenshot coordinates are page pixels with no multiplier math."
|
|
200
208
|
),
|
|
201
209
|
"inputSchema": {
|
|
202
210
|
"type": "object",
|
|
203
211
|
"properties": {
|
|
204
212
|
"url": {"type": "string"},
|
|
213
|
+
"viewport": {
|
|
214
|
+
"type": "string",
|
|
215
|
+
"description": "Fixed viewport as 'WIDTHxHEIGHT', e.g. '1440x900'",
|
|
216
|
+
},
|
|
205
217
|
},
|
|
206
218
|
"required": ["url"],
|
|
207
219
|
},
|
|
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
|