zenpilot 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
zenpilot/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ __all__ = ["main"]
zenpilot/bidi.py ADDED
@@ -0,0 +1,109 @@
1
+ """Minimal WebDriver BiDi client: command/response matching and event listeners."""
2
+
3
+ import asyncio
4
+ import itertools
5
+ import json
6
+ from collections.abc import Callable
7
+
8
+ import websockets
9
+
10
+
11
+ class BiDiError(Exception):
12
+ """An error response from the browser, e.g. error="no such node"."""
13
+
14
+ def __init__(self, error: str, message: str):
15
+ super().__init__(f"{error}: {message}")
16
+ self.error = error
17
+ self.message = message
18
+
19
+
20
+ class BiDi:
21
+ def __init__(self, url: str):
22
+ self.url = url
23
+ self._ws = None
24
+ self._ids = itertools.count(1)
25
+ self._pending: dict[int, asyncio.Future] = {}
26
+ self._listeners: list[tuple[str, Callable[[dict], bool], asyncio.Future]] = []
27
+ self._reader: asyncio.Task | None = None
28
+
29
+ @property
30
+ def connected(self) -> bool:
31
+ return self._reader is not None and not self._reader.done()
32
+
33
+ async def connect(self) -> dict:
34
+ """Open the socket and create a session. Returns session.new's result."""
35
+ # websockets sends no Origin header, which the remote agent requires.
36
+ self._ws = await websockets.connect(self.url, max_size=None, ping_interval=None)
37
+ self._reader = asyncio.create_task(self._read_loop())
38
+ try:
39
+ return await self.send("session.new", {"capabilities": {}})
40
+ except BaseException:
41
+ await self._ws.close()
42
+ raise
43
+
44
+ async def close(self) -> None:
45
+ """End the session (Firefox keeps orphaned sessions alive) and close."""
46
+ if not self.connected:
47
+ return
48
+ try:
49
+ await self.send("session.end", timeout=2)
50
+ except (BiDiError, ConnectionError, TimeoutError):
51
+ pass
52
+ await self._ws.close()
53
+
54
+ async def send(self, method: str, params: dict | None = None, timeout: float = 30) -> dict:
55
+ if not self.connected:
56
+ raise ConnectionError("not connected to the browser")
57
+ id_ = next(self._ids)
58
+ fut = asyncio.get_running_loop().create_future()
59
+ self._pending[id_] = fut
60
+ try:
61
+ await self._ws.send(json.dumps({"id": id_, "method": method, "params": params or {}}))
62
+ return await asyncio.wait_for(fut, timeout)
63
+ finally:
64
+ self._pending.pop(id_, None)
65
+
66
+ def expect(self, method: str, predicate: Callable[[dict], bool] = lambda p: True) -> asyncio.Future:
67
+ """Future resolving with the params of the next matching event.
68
+
69
+ Register it *before* triggering the action that emits the event. Cancel it
70
+ if you stop waiting; cancelled futures are dropped on the next event.
71
+ """
72
+ fut = asyncio.get_running_loop().create_future()
73
+ self._listeners.append((method, predicate, fut))
74
+ return fut
75
+
76
+ async def _read_loop(self) -> None:
77
+ try:
78
+ async for raw in self._ws:
79
+ self._dispatch(json.loads(raw))
80
+ except websockets.ConnectionClosed:
81
+ pass
82
+ finally:
83
+ err = ConnectionError("connection to the browser closed")
84
+ for fut in [*self._pending.values(), *(f for _, _, f in self._listeners)]:
85
+ if not fut.done():
86
+ fut.set_exception(err)
87
+ self._listeners.clear()
88
+
89
+ def _dispatch(self, msg: dict) -> None:
90
+ if msg.get("type") == "event":
91
+ method, params = msg["method"], msg["params"]
92
+ keep = []
93
+ for listener in self._listeners:
94
+ m, pred, fut = listener
95
+ if fut.done():
96
+ continue
97
+ if m == method and pred(params):
98
+ fut.set_result(params)
99
+ else:
100
+ keep.append(listener)
101
+ self._listeners = keep
102
+ return
103
+ fut = self._pending.get(msg.get("id"))
104
+ if fut is None or fut.done():
105
+ return
106
+ if msg.get("type") == "error":
107
+ fut.set_exception(BiDiError(msg.get("error", "unknown error"), msg.get("message", "")))
108
+ else:
109
+ fut.set_result(msg.get("result", {}))
zenpilot/browser.py ADDED
@@ -0,0 +1,497 @@
1
+ """Browser operations over WebDriver BiDi: tabs, page reading and trusted input."""
2
+
3
+ import asyncio
4
+ import base64
5
+ from collections.abc import Callable
6
+ from contextlib import asynccontextmanager
7
+
8
+ import websockets
9
+
10
+ from .bidi import BiDi, BiDiError
11
+ from .tree import MAX_ITEMS, WALKER_JS, Item, Ref, Refs, render, unwrap
12
+
13
+ # BiDi errors that mean "this node or frame no longer exists".
14
+ STALE_ERRORS = {"no such node", "no such element", "stale element reference", "detached shadow root", "no such frame"}
15
+ ENTER, BACKSPACE = "\ue007", "\ue003" # WebDriver key codes
16
+ SPECIAL_KEYS = {"\n": ENTER, "\t": "\ue004"}
17
+ # Firefox allows one BiDi session. Releasing it when idle lets Claude Desktop and
18
+ # several Claude Code sessions take turns; refs and tab ids expire with it.
19
+ IDLE_RELEASE_SECONDS = 120
20
+
21
+ # Scrolls the element into view and reports anything covering its center.
22
+ PREPARE_JS = """(el) => {
23
+ if (!el.isConnected) return ["stale", "", ""];
24
+ el.scrollIntoView({block: "center", inline: "center", behavior: "instant"});
25
+ const r = el.getBoundingClientRect();
26
+ if (r.width === 0 || r.height === 0) return ["hidden", el.tagName, ""];
27
+ const top = el.getRootNode().elementFromPoint?.(r.left + r.width / 2, r.top + r.height / 2);
28
+ const ok = !top || el === top || el.contains(top) || top.contains(el) || top.control === el;
29
+ const classes = typeof top?.className === "string" ? top.className.trim().split(/\\s+/).filter(Boolean) : [];
30
+ const cover = ok ? "" : top.tagName.toLowerCase() + (top.id ? "#" + top.id : "") +
31
+ (classes.length ? "." + classes.slice(0, 2).join(".") : "");
32
+ return ["ok", el.tagName, cover];
33
+ }"""
34
+
35
+ SELECT_CONTENT_JS = """(el) => {
36
+ if (typeof el.select === "function" && el.type !== "file") el.select();
37
+ else if (el.isContentEditable) getSelection().selectAllChildren(el);
38
+ }"""
39
+
40
+ GET_TEXT_JS = """(max) => {
41
+ const articles = document.querySelectorAll("article");
42
+ const root = document.querySelector("main, [role=main]") || (articles.length === 1 ? articles[0] : document.body);
43
+ const text = (root?.innerText || "").replace(/[ \\t]+\\n/g, "\\n").replace(/\\n{3,}/g, "\\n\\n").trim();
44
+ return [document.title, text.length, text.slice(0, max)];
45
+ }"""
46
+
47
+ TAB_STATE_JS = "() => [document.title, document.visibilityState, document.hasFocus()]"
48
+
49
+ SCROLL_INFO_JS = """() => [innerWidth, innerHeight, scrollX, scrollY,
50
+ document.documentElement.scrollWidth, document.documentElement.scrollHeight]"""
51
+
52
+ # Scroll offsets of the window and everything under the viewport's center.
53
+ SCROLL_STATE_JS = """() => {
54
+ const out = [scrollX, scrollY];
55
+ for (let el = document.elementFromPoint(innerWidth / 2, innerHeight / 2); el; el = el.parentElement)
56
+ out.push(el.scrollLeft, el.scrollTop);
57
+ return out.join(",");
58
+ }"""
59
+
60
+ # Fallback for when wheel events don't scroll (Zen's window is hidden): scroll the
61
+ # nearest scrollable ancestor of the viewport's center, like the wheel would.
62
+ SCROLL_JS_FALLBACK = """(dx, dy) => {
63
+ let el = document.elementFromPoint(innerWidth / 2, innerHeight / 2);
64
+ for (; el && el !== document.documentElement; el = el.parentElement) {
65
+ const s = getComputedStyle(el);
66
+ const canY = dy && /(auto|scroll)/.test(s.overflowY) && el.scrollHeight > el.clientHeight;
67
+ const canX = dx && /(auto|scroll)/.test(s.overflowX) && el.scrollWidth > el.clientWidth;
68
+ if (canY || canX) break;
69
+ }
70
+ const target = el && el !== document.documentElement ? el : document.scrollingElement;
71
+ target.scrollBy({left: dx, top: dy, behavior: "instant"});
72
+ }"""
73
+
74
+
75
+ class BrowserError(Exception):
76
+ """A failure with a message written for the model."""
77
+
78
+
79
+ class BrowserTimeout(BrowserError):
80
+ pass
81
+
82
+
83
+ def _arg(v) -> dict:
84
+ if isinstance(v, dict): # {"sharedId": ...}
85
+ return v
86
+ if v is None:
87
+ return {"type": "null"}
88
+ if isinstance(v, bool):
89
+ return {"type": "boolean", "value": v}
90
+ if isinstance(v, (int, float)):
91
+ return {"type": "number", "value": v}
92
+ return {"type": "string", "value": str(v)}
93
+
94
+
95
+ def _stale(key: str) -> BrowserError:
96
+ return BrowserError(f"Ref {key} is stale: the page changed since read_page. Call read_page again for fresh refs.")
97
+
98
+
99
+ class Browser:
100
+ def __init__(self):
101
+ self._bidi: BiDi | None = None
102
+ self._lock = asyncio.Lock()
103
+ self._next_tab = 1 # never reset, so old tab ids can't alias new tabs
104
+ self._tab_ids: dict[str, str] = {} # context -> "t1"
105
+ self._contexts: dict[str, str] = {} # "t1" -> context
106
+ self._last_used: str | None = None # context zenpilot last opened, switched to or navigated
107
+ self._busy = 0
108
+ self._idle_timer: asyncio.TimerHandle | None = None
109
+ self.refs = Refs()
110
+
111
+ # -- connection -------------------------------------------------------
112
+
113
+ async def connect(self, port: int) -> None:
114
+ async with self._lock:
115
+ if self._bidi and self._bidi.connected:
116
+ return
117
+ # Firefox issues new context ids per session, so old ids and refs are void.
118
+ self._tab_ids.clear()
119
+ self._contexts.clear()
120
+ self._last_used = None
121
+ self.refs.clear()
122
+ bidi = BiDi(f"ws://127.0.0.1:{port}/session")
123
+ try:
124
+ await bidi.connect()
125
+ except (OSError, websockets.InvalidHandshake) as e:
126
+ raise BrowserError(
127
+ f"Can't reach Zen's remote agent on 127.0.0.1:{port} ({e}). "
128
+ "Ask the user to run `zenpilot launch`, or `zenpilot doctor` to see what's wrong."
129
+ ) from None
130
+ except BiDiError as e:
131
+ if e.error == "session not created":
132
+ raise BrowserError(
133
+ "Zen already has an active automation session. Either another zenpilot "
134
+ "(Claude Desktop or Claude Code) is using it, or a previous one was killed "
135
+ "without ending its session; in that case Zen must be restarted."
136
+ ) from None
137
+ raise
138
+ try:
139
+ await bidi.send(
140
+ "session.subscribe",
141
+ {
142
+ "events": [
143
+ "browsingContext.navigationStarted",
144
+ "browsingContext.load",
145
+ "browsingContext.contextCreated",
146
+ ]
147
+ },
148
+ )
149
+ except BaseException:
150
+ await bidi.close() # don't leave an orphaned session behind
151
+ raise
152
+ self._bidi = bidi
153
+
154
+ @property
155
+ def connected(self) -> bool:
156
+ return bool(self._bidi and self._bidi.connected)
157
+
158
+ async def close(self) -> None:
159
+ if self._bidi:
160
+ await self._bidi.close()
161
+
162
+ @asynccontextmanager
163
+ async def session(self, port: int):
164
+ """Hold the BiDi session for one tool call; release it after a quiet period."""
165
+ if self._idle_timer:
166
+ self._idle_timer.cancel()
167
+ self._busy += 1
168
+ try:
169
+ await self.connect(port)
170
+ yield
171
+ finally:
172
+ self._busy -= 1
173
+ loop = asyncio.get_running_loop()
174
+ self._idle_timer = loop.call_later(IDLE_RELEASE_SECONDS, lambda: loop.create_task(self._release_if_idle()))
175
+
176
+ async def _release_if_idle(self) -> None:
177
+ async with self._lock:
178
+ if self._busy == 0:
179
+ await self.close()
180
+
181
+ async def call(self, method: str, params: dict | None = None, timeout: float = 30) -> dict:
182
+ try:
183
+ return await self._bidi.send(method, params, timeout)
184
+ except ConnectionError:
185
+ raise BrowserError(
186
+ "Lost the connection to Zen. Retry; if it persists, check that Zen is running."
187
+ ) from None
188
+ except TimeoutError:
189
+ raise BrowserTimeout(f"Zen did not answer {method} within {timeout:g}s.") from None
190
+
191
+ async def run(self, context: str, fn: str, *args, timeout: float = 15):
192
+ """Call a JS function in a browsing context and return its unwrapped result."""
193
+ r = await self.call(
194
+ "script.callFunction",
195
+ {
196
+ "functionDeclaration": fn,
197
+ "target": {"context": context},
198
+ "arguments": [_arg(a) for a in args],
199
+ "awaitPromise": True,
200
+ "resultOwnership": "none",
201
+ "serializationOptions": {"maxDomDepth": 0},
202
+ },
203
+ timeout,
204
+ )
205
+ if r["type"] == "exception":
206
+ raise BrowserError(f"Script error in page: {r['exceptionDetails'].get('text', '')}")
207
+ return unwrap(r["result"])
208
+
209
+ # -- tabs ---------------------------------------------------------------
210
+
211
+ def _tab_id(self, context: str) -> str:
212
+ if context not in self._tab_ids:
213
+ tab = f"t{self._next_tab}"
214
+ self._next_tab += 1
215
+ self._tab_ids[context] = tab
216
+ self._contexts[tab] = context
217
+ return self._tab_ids[context]
218
+
219
+ async def tabs(self, probe: bool = True) -> list[dict]:
220
+ """Top-level tabs. With probe, also fetch titles and work out the active tab."""
221
+ contexts = (await self.call("browsingContext.getTree", {"maxDepth": 0}))["contexts"]
222
+ tabs = [
223
+ {
224
+ "id": self._tab_id(c["context"]),
225
+ "context": c["context"],
226
+ "url": c["url"],
227
+ "window": c.get("clientWindow"),
228
+ "title": "",
229
+ "active": False,
230
+ }
231
+ for c in contexts
232
+ ]
233
+ live = {t["context"] for t in tabs}
234
+ for ctx in [c for c in self._tab_ids if c not in live]:
235
+ self.refs.drop_tab(self._tab_ids[ctx])
236
+ del self._contexts[self._tab_ids.pop(ctx)]
237
+ if not probe or not tabs:
238
+ return tabs
239
+
240
+ async def state(tab):
241
+ try:
242
+ return await self.run(tab["context"], TAB_STATE_JS, timeout=3)
243
+ except (BrowserError, BiDiError):
244
+ return ["", "hidden", False]
245
+
246
+ states = await asyncio.gather(*(state(t) for t in tabs))
247
+ windows = (await self.call("browser.getClientWindows"))["clientWindows"]
248
+ focused_window = next((w["clientWindow"] for w in windows if w.get("active")), None)
249
+
250
+ # Zen has no "selected tab" API. Focus and visibility identify it while the window
251
+ # is on screen; when it's covered they go stale, so the tab zenpilot last used breaks ties.
252
+ def rank(t, state):
253
+ _, visibility, focus = state
254
+ visible = visibility == "visible"
255
+ in_focused_window = visible and t["window"] == focused_window
256
+ return 4 * focus + 2 * visible + in_focused_window + 2.5 * (t["context"] == self._last_used)
257
+
258
+ for t, state in zip(tabs, states, strict=True):
259
+ t["title"] = state[0] or ""
260
+ max(zip(tabs, states, strict=True), key=lambda ts: rank(*ts))[0]["active"] = True
261
+ return tabs
262
+
263
+ async def tab(self, tab_id: str | None) -> dict:
264
+ tabs = await self.tabs(probe=tab_id is None)
265
+ if tab_id is None:
266
+ if not tabs:
267
+ raise BrowserError("Zen has no open tabs. Use new_tab.")
268
+ return next(t for t in tabs if t["active"])
269
+ tab = next((t for t in tabs if t["id"] == tab_id.strip().lower()), None)
270
+ if tab is None:
271
+ raise BrowserError(f"Unknown tab id '{tab_id}'. Call list_tabs for current ids.")
272
+ return tab
273
+
274
+ async def url_of(self, context: str) -> str:
275
+ return (await self.call("browsingContext.getTree", {"root": context, "maxDepth": 0}))["contexts"][0]["url"]
276
+
277
+ async def new_tab(self) -> str:
278
+ # A foreground create waits for the tab to become visible, which never happens
279
+ # while Zen's window is covered. Create in the background, then activate.
280
+ context = (await self.call("browsingContext.create", {"type": "tab", "background": True}))["context"]
281
+ await self.activate({"context": context})
282
+ return self._tab_id(context)
283
+
284
+ async def activate(self, tab: dict) -> None:
285
+ self._last_used = tab["context"]
286
+ try:
287
+ # Selects the tab at once, but the reply waits on window focus and
288
+ # visibility changes that a covered window never delivers.
289
+ await self.call("browsingContext.activate", {"context": tab["context"]}, timeout=1)
290
+ except BrowserTimeout:
291
+ pass
292
+
293
+ async def close_tab(self, tab: dict) -> None:
294
+ await self.call("browsingContext.close", {"context": tab["context"]})
295
+ self.refs.drop_tab(tab["id"])
296
+
297
+ async def navigate(self, tab: dict, url: str) -> tuple[str, bool]:
298
+ """Navigate and wait for load. Returns (final url, still_loading)."""
299
+ self.refs.drop_tab(tab["id"])
300
+ self._last_used = tab["context"]
301
+ try:
302
+ await self.call("browsingContext.navigate", {"context": tab["context"], "url": url, "wait": "complete"})
303
+ loading = False
304
+ except BrowserTimeout:
305
+ loading = True
306
+ except BiDiError as e:
307
+ raise BrowserError(f"Navigation to {url} failed: {e.message or e.error}") from None
308
+ return await self.url_of(tab["context"]), loading
309
+
310
+ # -- page content -----------------------------------------------------
311
+
312
+ async def read_page(self, tab: dict, frame_allowed: Callable[[str], str | None]) -> str:
313
+ """Outline of the page; frame_allowed(url) returns a reason string to skip a frame."""
314
+ self.refs.drop_tab(tab["id"])
315
+ items: list[Item] = []
316
+ truncated = False
317
+
318
+ async def collect(context: str, base: int):
319
+ nonlocal truncated
320
+ out, trunc = await self.run(context, WALKER_JS, MAX_ITEMS, timeout=20)
321
+ truncated |= trunc
322
+ for depth, line, node, frame in out:
323
+ item = Item(base + depth, line, context, node and node["sharedId"])
324
+ items.append(item)
325
+ if not frame:
326
+ continue
327
+ try:
328
+ url = await self.url_of(frame["context"])
329
+ if reason := frame_allowed(url):
330
+ item.line += f" [skipped: {reason}]"
331
+ else:
332
+ await collect(frame["context"], base + depth + 1)
333
+ except (BrowserError, BiDiError):
334
+ item.line += " [not readable]"
335
+
336
+ await collect(tab["context"], 0)
337
+ return render(items, self.refs, tab["id"], truncated)
338
+
339
+ async def get_text(self, tab: dict, max_chars: int) -> tuple[str, str]:
340
+ title, length, text = await self.run(tab["context"], GET_TEXT_JS, max_chars)
341
+ if length > max_chars:
342
+ text += f"\n[truncated: showing {max_chars} of {length} characters]"
343
+ return title, text or "[no text]"
344
+
345
+ async def screenshot(self, tab: dict) -> bytes:
346
+ r = await self.call(
347
+ "browsingContext.captureScreenshot",
348
+ {
349
+ "context": tab["context"],
350
+ "format": {"type": "image/jpeg", "quality": 0.8},
351
+ },
352
+ )
353
+ return base64.b64decode(r["data"])
354
+
355
+ # -- input ----------------------------------------------------------------
356
+
357
+ def ref(self, key: str) -> Ref:
358
+ ref = self.refs.get(key)
359
+ if ref is None:
360
+ raise BrowserError(
361
+ f"Unknown ref '{key}'. Refs come from the latest read_page of a tab and expire "
362
+ "when read_page runs again or the page navigates. Call read_page again."
363
+ )
364
+ return ref
365
+
366
+ def top_context(self, ref: Ref) -> str:
367
+ return self._contexts.get(ref.tab, ref.context)
368
+
369
+ async def ref_url(self, key: str) -> str:
370
+ """URL of the tab a ref belongs to."""
371
+ try:
372
+ return await self.url_of(self.top_context(self.ref(key)))
373
+ except BiDiError:
374
+ raise BrowserError(f"The tab ref {key} belonged to is gone. Call list_tabs, then read_page.") from None
375
+
376
+ async def _prepare(self, key: str, ref: Ref) -> tuple[str, str]:
377
+ """Scroll the node into view. Returns (tagName, covering element or "")."""
378
+ try:
379
+ status, tag, cover = await self.run(ref.context, PREPARE_JS, {"sharedId": ref.shared_id})
380
+ except BiDiError as e:
381
+ if e.error in STALE_ERRORS:
382
+ raise _stale(key) from None
383
+ raise
384
+ if status == "stale":
385
+ raise _stale(key)
386
+ if status == "hidden":
387
+ raise BrowserError(f"Ref {key} is no longer visible. Call read_page again.")
388
+ return tag, cover
389
+
390
+ async def _actions(self, ref: Ref, actions: list[dict]) -> None:
391
+ await self.call("input.performActions", {"context": ref.context, "actions": actions})
392
+
393
+ async def _click(self, ref: Ref) -> None:
394
+ origin = {"type": "element", "element": {"sharedId": ref.shared_id}}
395
+ await self._actions(
396
+ ref,
397
+ [
398
+ {
399
+ "type": "pointer",
400
+ "id": "mouse",
401
+ "parameters": {"pointerType": "mouse"},
402
+ "actions": [
403
+ {"type": "pointerMove", "x": 0, "y": 0, "origin": origin},
404
+ {"type": "pointerDown", "button": 0},
405
+ {"type": "pointerUp", "button": 0},
406
+ ],
407
+ }
408
+ ],
409
+ )
410
+
411
+ async def _keys(self, ref: Ref, keys: list[str]) -> None:
412
+ for i in range(0, len(keys), 100):
413
+ chunk = [{"type": t, "value": k} for k in keys[i : i + 100] for t in ("keyDown", "keyUp")]
414
+ await self._actions(ref, [{"type": "key", "id": "keyboard", "actions": chunk}])
415
+
416
+ async def _settle(self, ref: Ref, action) -> str:
417
+ """Run an input action; if it starts a navigation or opens a tab, wait and report it."""
418
+ top = self.top_context(ref)
419
+ started = self._bidi.expect("browsingContext.navigationStarted", lambda p: p["context"] == top)
420
+ loaded = self._bidi.expect("browsingContext.load", lambda p: p["context"] == top)
421
+ created = self._bidi.expect("browsingContext.contextCreated", lambda p: p.get("parent") is None)
422
+ try:
423
+ await action()
424
+ await asyncio.wait({started, created}, timeout=0.4)
425
+ notes = []
426
+ if started.done() and not started.exception():
427
+ try:
428
+ await asyncio.wait_for(loaded, 15)
429
+ notes.append(f"Page navigated to {await self.url_of(top)}")
430
+ except TimeoutError:
431
+ notes.append("A navigation started and is still loading")
432
+ if created.done() and not created.exception():
433
+ tab = self._tab_id(created.result()["context"])
434
+ notes.append(f"A new tab {tab} opened")
435
+ return ". ".join(notes)
436
+ finally:
437
+ for fut in (started, loaded, created):
438
+ fut.cancel()
439
+
440
+ async def click(self, key: str) -> str:
441
+ ref = self.ref(key)
442
+ _, cover = await self._prepare(key, ref)
443
+ result = f"Clicked {key}."
444
+ if cover:
445
+ result += f" Note: its center was covered by <{cover}>, which received the click."
446
+ if nav := await self._settle(ref, lambda: self._click(ref)):
447
+ result += f" {nav}."
448
+ return result
449
+
450
+ async def type(self, key: str, text: str, submit: bool) -> str:
451
+ ref = self.ref(key)
452
+ tag, _ = await self._prepare(key, ref)
453
+ if tag == "SELECT":
454
+ # Clicking would open the native popup; focus and type to pick an option instead.
455
+ await self.run(ref.context, "(el) => el.focus()", {"sharedId": ref.shared_id})
456
+ else:
457
+ await self._click(ref)
458
+ await self.run(ref.context, SELECT_CONTENT_JS, {"sharedId": ref.shared_id})
459
+ await self._keys(ref, [SPECIAL_KEYS.get(ch, ch) for ch in text] or [BACKSPACE])
460
+ result = f"Typed {len(text)} characters into {key}."
461
+ if submit and (nav := await self._settle(ref, lambda: self._keys(ref, [ENTER]))):
462
+ result += f" Pressed Enter. {nav}."
463
+ elif submit:
464
+ result += " Pressed Enter."
465
+ return result
466
+
467
+ async def scroll(self, tab: dict, direction: str, amount: int) -> str:
468
+ ctx = tab["context"]
469
+ w, h, *_ = await self.run(ctx, SCROLL_INFO_JS)
470
+ dx, dy = {"down": (0, amount), "up": (0, -amount), "right": (amount, 0), "left": (-amount, 0)}[direction]
471
+ before = await self.run(ctx, SCROLL_STATE_JS)
472
+ await self.call(
473
+ "input.performActions",
474
+ {
475
+ "context": ctx,
476
+ "actions": [
477
+ {
478
+ "type": "wheel",
479
+ "id": "wheel",
480
+ "actions": [{"type": "scroll", "x": int(w // 2), "y": int(h // 2), "deltaX": dx, "deltaY": dy}],
481
+ }
482
+ ],
483
+ },
484
+ )
485
+ # Smooth scrolling takes a while: wait until the position settles (up to 2s),
486
+ # or give up after 0.4s if nothing moved at all.
487
+ state = before
488
+ for i in range(20):
489
+ await asyncio.sleep(0.1)
490
+ now = await self.run(ctx, SCROLL_STATE_JS)
491
+ if now == state and (now != before or i >= 3):
492
+ break
493
+ state = now
494
+ if state == before:
495
+ await self.run(ctx, SCROLL_JS_FALLBACK, dx, dy)
496
+ _, h, x, y, sw, sh = await self.run(ctx, SCROLL_INFO_JS)
497
+ return f"Scrolled {direction}. Viewport now at y={y:.0f} of {sh:.0f} (height {h:.0f}), x={x:.0f} of {sw:.0f}."