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.
Files changed (65) hide show
  1. {repld_tool-0.3.1 → repld_tool-0.3.2}/PKG-INFO +1 -1
  2. {repld_tool-0.3.1 → repld_tool-0.3.2}/pyproject.toml +1 -1
  3. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/bridge.py +3 -4
  4. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/selector.py +82 -10
  5. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/tab.py +64 -21
  6. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser_dispatch.py +46 -1
  7. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/help.py +16 -0
  8. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/protocol.py +16 -4
  9. {repld_tool-0.3.1 → repld_tool-0.3.2}/LICENSE +0 -0
  10. {repld_tool-0.3.1 → repld_tool-0.3.2}/README.md +0 -0
  11. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/.ruff_cache/.gitignore +0 -0
  12. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/.ruff_cache/0.15.22/14226964882126921391 +0 -0
  13. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/.ruff_cache/CACHEDIR.TAG +0 -0
  14. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/__init__.py +0 -0
  15. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/__main__.py +0 -0
  16. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/bg.py +0 -0
  17. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/bind.py +0 -0
  18. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/bridge_tools.py +0 -0
  19. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/__init__.py +0 -0
  20. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/browser.py +0 -0
  21. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/capture.py +0 -0
  22. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/cdp.py +0 -0
  23. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/har.py +0 -0
  24. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/inject.py +0 -0
  25. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/injected_source.py +0 -0
  26. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/observe.py +0 -0
  27. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/pin.py +0 -0
  28. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/png.py +0 -0
  29. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/pool.py +0 -0
  30. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/row.py +0 -0
  31. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/session.py +0 -0
  32. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/tab_query.py +0 -0
  33. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/browser/target.py +0 -0
  34. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/channel.py +0 -0
  35. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/cli.py +0 -0
  36. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/cli_args.py +0 -0
  37. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/core_schemas.py +0 -0
  38. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/dashboard.py +0 -0
  39. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/dashboard_cmd.py +0 -0
  40. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/dashboard_html.py +0 -0
  41. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/display.py +0 -0
  42. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/eventlog.py +0 -0
  43. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/events.py +0 -0
  44. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/exec_cmd.py +0 -0
  45. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/gate_cmd.py +0 -0
  46. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/gates.py +0 -0
  47. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/gist_api.py +0 -0
  48. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/gist_cmd.py +0 -0
  49. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/gist_deps.py +0 -0
  50. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/gist_links.py +0 -0
  51. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/gist_lint.py +0 -0
  52. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/gists.py +0 -0
  53. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/ipc.py +0 -0
  54. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/kernel.py +0 -0
  55. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/kernel_context.py +0 -0
  56. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/lifecycle_cmd.py +0 -0
  57. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/log_cmd.py +0 -0
  58. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/paths.py +0 -0
  59. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/relaunch.py +0 -0
  60. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/render.py +0 -0
  61. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/runtime.py +0 -0
  62. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/sessions.py +0 -0
  63. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/spawn.py +0 -0
  64. {repld_tool-0.3.1 → repld_tool-0.3.2}/src/repld/state.py +0 -0
  65. {repld_tool-0.3.1 → repld_tool-0.3.2}/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.1
3
+ Version: 0.3.2
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.1"
3
+ version = "0.3.2"
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 = [
@@ -334,10 +334,9 @@ class Bridge:
334
334
  return True
335
335
 
336
336
  if method == "ping":
337
- # A liveness probe deserves a real answer, and answering it is
338
- # exactly as honest with no kernel as with one — the bridge is the
339
- # thing being pinged. Must not fall through: anything the intercept
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 = (".", "#", "[", "data-", "text=", "role=", "label=", "aria-ref=")
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, everywhere this module quotes user
60
- # text: json.dumps' default emits å-style escapes, and the engine's
61
- # selector parser reads those as six literal characters — so every
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-case-sensitive body, which is what matches
102
- # the legacy `===` semantics (verified live — the public engine parses
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 the single-hidden-match case (an
656
- off-screen real control behind a styled proxy) addressable, matching
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, so "what will this land on" is the honest answer.
704
- When the point lands on something unrelated to the target, a plain
705
- left-click switches to `element.click()` instead of dispatching at the
706
- coordinates: the coordinate path is worse than a no-op there (on a
707
- drag/pan layer it can start a drag), while a DOM click reaches the
708
- resolved element the way assistive tech does. Found live: a workflow
709
- editor's accessible button list sits under its SVG diagram, so the
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
- # Best-effort: the field (or its re-rendered value) should now carry
908
- # the option text. try/except because the click may have replaced the
909
- # field element outright, which detaches our handle — not a failure.
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) { const t = (el.value || '') + ' ' +"
915
- " (el.textContent || ''); return t.includes(opt); }",
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
- return tab.request(args["request_id"])
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('...'), or aria-ref=e12 from browser_tree"
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