okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petcut idle daemon memory: lean vm defaults, sweep gc, no per-tool copies of catalogs, encoded replay events, lazy browser plugin, 10 min kernel idlec90606d

main
49.5 KiB1239 linesraw
1"""Semantic observations and deliberately explicit actions, on a live CDP page."""
2
3from __future__ import annotations
4
5from typing import SupportsIndex
6
7import asyncio
8import base64
9import contextlib
10import copy
11import math
12import time
13import uuid
14from collections import OrderedDict
15from contextlib import asynccontextmanager
16from datetime import datetime, timezone
17from pathlib import Path
18from typing import Any, TYPE_CHECKING
19
20from . import hooks, javascript as js
21from .capture import CaptureLimits, FrameSession, Reference, capture_frames
22from .errors import (
23    ActionOutcomeUnknown,
24    BrowserError,
25    ConnectionLost,
26    JavaScriptError,
27    NotActionable,
28    ProtocolError,
29    SelectionError,
30    StaleReference,
31    UnsupportedOperation,
32    WaitTimeout,
33    TargetGone,
34    RecoveryError,
35)
36from .observation import (
37    Node,
38    Observation,
39    attributes_from_node,
40    find,
41)
42from .events import EventScope
43from .recovery import PageCDPSession
44
45if TYPE_CHECKING:
46    from .session import Browser
47from .transport import CDPSession, JSON, positive
48
49
50def now() -> str:
51    return datetime.now(timezone.utc).isoformat()
52
53
54class Page:
55    def __init__(
56        self, browser: Browser, target_id: str, session_id: str, *, owned: bool
57    ) -> None:
58        self.browser = browser
59        self.target_id = target_id
60        self.cdp = PageCDPSession(self, session_id)
61        self.owned = owned
62        self._sessions = {session_id: FrameSession(self.cdp, target_id)}
63        self._tasks: set[asyncio.Task[None]] = set()
64        self._snapshots: OrderedDict[str, dict[str, Reference]] = OrderedDict()
65        self._lock = asyncio.Lock()
66        self._settings_lock = asyncio.Lock()
67        self._settings: dict[str, JSON] = {}
68        self._recovering = False
69        self._destroyed = False
70        self.last_recovery: JSON | None = None
71        self._closed = False
72        self._main_frame = ""
73        self.last_action: JSON | None = None
74        self._frame_sessions: dict[str, str | None] = {}
75        self._frame_info: dict[str, JSON] = {}
76
77    async def _initialize(self) -> None:
78        session_id = self.cdp.session_id
79        assert session_id is not None
80        await self._init_session(self._sessions[session_id])
81        self._main_frame = self._sessions[session_id].root_frame
82
83    async def _init_session(self, state: FrameSession) -> None:
84        try:
85            for domain in ("Page", "DOM", "Runtime", "Accessibility"):
86                await state.cdp.send(f"{domain}.enable")
87            await state.cdp.send("Page.setLifecycleEventsEnabled", {"enabled": True})
88            tree = await state.cdp.send("Page.getFrameTree")
89            state.root_frame = tree["frameTree"]["frame"]["id"]
90            await state.cdp.send(
91                "Target.setAutoAttach",
92                {
93                    "autoAttach": True,
94                    "waitForDebuggerOnStart": False,
95                    "flatten": True,
96                    "filter": [{"type": "iframe", "exclude": False}, {"exclude": True}],
97                },
98            )
99            state.ready = True
100        except Exception as exc:
101            state.error = type(exc).__name__ + ": " + str(exc)
102            if state.cdp.session_id == self.cdp.session_id:
103                raise
104
105    def _on_event(self, method: str, params: JSON, session_id: str) -> None:
106        state = self._sessions.get(session_id)
107        if (
108            method
109            in {
110                "DOM.documentUpdated",
111                "Runtime.executionContextsCleared",
112                "Page.frameNavigated",
113            }
114            and state
115        ):
116            state.generation += 1
117        if method == "Page.frameDetached" and params.get("reason") != "swap" and state:
118            state.generation += 1
119        if (
120            method == "Target.attachedToTarget"
121            and params.get("targetInfo", {}).get("type") == "iframe"
122        ):
123            sid = params["sessionId"]
124            if sid in self._sessions:
125                return
126            child = FrameSession(
127                CDPSession(self.browser._connection, sid),
128                params["targetInfo"]["targetId"],
129            )
130            self._sessions[sid] = child
131            self.browser._routes[sid] = self
132            task = asyncio.create_task(
133                self._init_session(child), name="browser-iframe-init"
134            )
135            self._tasks.add(task)
136            task.add_done_callback(self._tasks.discard)
137
138    def _detached(self, sid: str) -> None:
139        state = self._sessions.pop(sid, None)
140        if state is not None:
141            state.generation += 1
142        if sid == self.cdp.session_id:
143            self._invalidate()
144
145    def _invalidate(self, *, destroyed: bool = False) -> None:
146        """Invalidate a whole attachment, including child sessions and references."""
147        self._closed = True
148        self._destroyed = self._destroyed or destroyed
149        self._snapshots.clear()
150        for task in self._tasks:
151            task.cancel()
152        for sid, state in tuple(self._sessions.items()):
153            state.generation += 1
154            state.cdp.connection.detach_session(sid)
155            if self.browser._routes.get(sid) is self:
156                self.browser._routes.pop(sid, None)
157        self._sessions.clear()
158        self._frame_sessions.clear()
159        self._frame_info.clear()
160        self._main_frame = ""
161
162    async def _stop_tasks(self) -> None:
163        tasks = list(self._tasks)
164        for task in tasks:
165            task.cancel()
166        await asyncio.gather(*tasks, return_exceptions=True)
167
168    @property
169    def attached(self) -> bool:
170        """Local attachment status, not a network health probe."""
171        return (
172            not self._closed
173            and not self._destroyed
174            and not self._recovering
175            and not self.cdp.connection.closed
176            and self.cdp.connection is self.browser._connection
177        )
178
179    @property
180    def recovery_settings(self) -> list[JSON]:
181        """Copy of acknowledged, replayable root-page settings; not browser state."""
182        return copy.deepcopy(list(self._settings.values()))
183
184    def _check(self) -> None:
185        if self._destroyed:
186            raise TargetGone(
187                "The original tab is closed; reattachment cannot recreate it.",
188                target_id=self.target_id,
189            )
190        if not self.attached:
191            failure = self.cdp.connection._closed
192            raise ConnectionLost(
193                "This page handle is detached or disconnected; use await page.reattach().",
194                target_id=self.target_id,
195                recovery="await page.reattach()",
196                connection_error=failure.to_dict() if failure else None,
197            )
198
199    async def reattach(
200        self,
201        *,
202        timeout: float = 15,
203        restore_settings: bool = True,
204        max_message_bytes: int | None = None,
205    ) -> Page:
206        """Recover this exact target and Python object, without navigating or retrying actions.
207
208        Reopens a lost browser socket when needed, retaining headers and process
209        ownership. Other tabs on that socket require their own explicit reattach.
210        Healthy handles are no-ops. References and event streams from a replaced
211        session remain invalid. Only the documented acknowledged settings replay.
212        """
213        positive(timeout, "timeout")
214        if not isinstance(restore_settings, bool):
215            raise TypeError("restore_settings must be a bool")
216        if max_message_bytes is not None and (
217            isinstance(max_message_bytes, bool)
218            or not isinstance(max_message_bytes, int)
219            or max_message_bytes < 1024
220        ):
221            raise ValueError("max_message_bytes must be an integer of at least 1024")
222        try:
223            async with asyncio.timeout(timeout):
224                # The browser lock serializes connection replacement, attaching,
225                # disconnecting and closing. Then serialize with actions/settings.
226                async with self.browser._lifecycle_lock:
227                    async with self._lock:
228                        async with self._settings_lock:
229                            return await self._reattach_locked(
230                                restore_settings=restore_settings,
231                                max_message_bytes=max_message_bytes,
232                            )
233        except TimeoutError as exc:
234            raise RecoveryError(
235                "Reattachment exceeded its deadline; no action was replayed.",
236                target_id=self.target_id,
237                timeout=timeout,
238            ) from exc
239
240    async def _reattach_locked(
241        self, *, restore_settings: bool, max_message_bytes: int | None
242    ) -> Page:
243        if self._destroyed:
244            raise TargetGone(
245                "The original tab is closed; no replacement was created.",
246                target_id=self.target_id,
247            )
248        if self.browser._close_task is not None:
249            raise ConnectionLost(
250                "Browser.close() was called; use a new browser handle."
251            )
252        connection = self.browser._connection
253        if (
254            not connection.closed
255            and max_message_bytes is not None
256            and max_message_bytes != connection.max_message_bytes
257        ):
258            raise ValueError(
259                "Changing max_message_bytes requires a lost or explicitly disconnected browser socket"
260            )
261        if self.attached:
262            return self
263        other = self.browser._pages.get(self.target_id)
264        if other is not None and other is not self:
265            raise RecoveryError(
266                "Another page handle now owns this target's attachment; use that handle.",
267                target_id=self.target_id,
268            )
269        receipt: JSON = {
270            "status": "recovering",
271            "target_id": self.target_id,
272            "old_session_id": self.cdp.session_id,
273            "restored_methods": [],
274            "event_history_gap": True,
275            "started_at": now(),
276            "transport_reconnected": False,
277        }
278        self.last_recovery = receipt
279        self._recovering = True
280        new_sid: str | None = None
281        attach_started = False
282        # _invalidate() kills the previous session's event streams and references.
283        self._invalidate()
284        try:
285            await self._stop_tasks()
286            receipt["transport_reconnected"] = await self.browser._ensure_connection(
287                max_message_bytes
288            )
289            targets = await self.browser.pages()
290            if not any(t["targetId"] == self.target_id for t in targets):
291                self._destroyed = True
292                raise TargetGone(
293                    "The original target no longer exists; no new tab was created.",
294                    target_id=self.target_id,
295                )
296            attach_started = True
297            try:
298                result = await self.browser.cdp.send(
299                    "Target.attachToTarget",
300                    {
301                        "targetId": self.target_id,
302                        "flatten": True,
303                    },
304                )
305            except ProtocolError as exc:
306                # A protocol rejection acknowledges that no attachment succeeded.
307                attach_started = False
308                if self._destroyed or not any(
309                    t["targetId"] == self.target_id for t in await self.browser.pages()
310                ):
311                    self._destroyed = True
312                    raise TargetGone(
313                        "The target closed during reattachment; no replacement was created.",
314                        target_id=self.target_id,
315                    ) from exc
316                raise
317            new_sid = result["sessionId"]
318            self.cdp = PageCDPSession(self, new_sid)
319            self._sessions = {new_sid: FrameSession(self.cdp, self.target_id)}
320            self.browser._pages[self.target_id] = self
321            self.browser._routes[new_sid] = self
322            # _closed stays True until initialization and settings both succeed.
323            await self._initialize()
324            if restore_settings:
325                for item in self.recovery_settings:
326                    try:
327                        # Bypass recording: restoration is not a new configuration.
328                        await self.cdp.connection.send(
329                            item["method"], item["params"], session_id=new_sid
330                        )
331                    except BrowserError as exc:
332                        raise RecoveryError(
333                            "Emulation restoration failed; this handle remains detached.",
334                            target_id=self.target_id,
335                            failed_method=item["method"],
336                            restored_methods=list(receipt["restored_methods"]),
337                            cause=exc.to_dict(),
338                        ) from exc
339                    receipt["restored_methods"].append(item["method"])
340            if (
341                self.cdp.connection.closed
342                or new_sid not in self._sessions
343                or self._destroyed
344            ):
345                raise ConnectionLost(
346                    "Target detached again during reattachment.",
347                    target_id=self.target_id,
348                )
349            self._closed = False
350            receipt.update(
351                status="reattached",
352                new_session_id=new_sid,
353                finished_at=now(),
354                settings_restored=restore_settings,
355            )
356            return self
357        except BaseException as exc:
358            receipt.update(
359                status="cancelled"
360                if isinstance(exc, asyncio.CancelledError)
361                else "failed",
362                error=type(exc).__name__,
363                finished_at=now(),
364            )
365            self._invalidate()
366            # Bounded cleanup survives repeated interruption. Never close the tab.
367            from .launch import _finish_cleanup
368
369            async def cleanup() -> None:
370                await self._stop_tasks()
371                if new_sid is not None and not self.browser._connection.closed:
372                    try:
373                        await self.browser.cdp.send(
374                            "Target.detachFromTarget", {"sessionId": new_sid}, timeout=2
375                        )
376                    except BrowserError:
377                        # Cannot prove cleanup of the failed attachment. Release
378                        # every session on this socket, while preserving tabs.
379                        receipt["transport_disconnected_for_cleanup"] = True
380                        await self.browser._disconnect()
381                elif attach_started and not self.browser._connection.closed:
382                    # The attach may have succeeded but its session ID was lost.
383                    # Closing the socket releases unknown sessions without closing
384                    # their tabs. Other page handles must explicitly reattach too.
385                    receipt["transport_disconnected_for_cleanup"] = True
386                    await self.browser._disconnect()
387
388            await _finish_cleanup(
389                asyncio.create_task(cleanup(), name="browser-reattach-cleanup")
390            )
391            raise
392        finally:
393            self._recovering = False
394
395    def descriptor(self) -> JSON:
396        """Serializable reconnect data; no sockets, headers or actionable references."""
397        return {"endpoint": self.browser.endpoint, "target_id": self.target_id}
398
399    def events(self, method: str, **options: Any) -> EventScope:
400        """Root-target events only; for OOPIFs use frame_cdp() with EventScope."""
401        return EventScope(self.cdp, method, **options)
402
403    async def frames(self) -> list[JSON]:
404        self._check()
405        for _ in range(4):
406            tasks = list(self._tasks)
407            if not tasks:
408                break
409            await asyncio.gather(*tasks)
410        frames: dict[str, JSON] = {}
411        routes: dict[str, str | None] = {}
412        # Root target first; an OOPIF's own session takes precedence over its placeholder.
413        states = sorted(
414            self._sessions.values(),
415            key=lambda s: s.cdp.session_id != self.cdp.session_id,
416        )
417        for state in states:
418            if not state.ready:
419                continue
420            try:
421                tree = (await state.cdp.send("Page.getFrameTree"))["frameTree"]
422            except ProtocolError:
423                continue
424            stack = [(tree, None)]
425            while stack:
426                entry, parent = stack.pop()
427                f = entry["frame"]
428                fid = f["id"]
429                old = frames.get(fid, {})
430                frames[fid] = {
431                    "id": fid,
432                    "parent_id": f.get("parentId", parent or old.get("parent_id")),
433                    "url": f.get("url", ""),
434                    "name": f.get("name", ""),
435                    "session_id": state.cdp.session_id,
436                    "out_of_process": state.cdp.session_id != self.cdp.session_id,
437                }
438                routes[fid] = state.cdp.session_id
439                for child in reversed(entry.get("childFrames", [])):
440                    stack.append((child, fid))
441        self._frame_sessions = routes
442        self._frame_info = frames
443        return list(frames.values())
444
445    async def frame_cdp(self, frame_id: str) -> CDPSession:
446        await self.frames()
447        sid = self._frame_sessions.get(frame_id)
448        if sid is None:
449            raise UnsupportedOperation("Frame is not attached.", frame_id=frame_id)
450        return self._sessions[sid].cdp
451
452    async def observe(
453        self,
454        *,
455        max_nodes: int = 1500,
456        max_text: int = 1500,
457        max_depth: int = 50,
458        frame_id: str | None = None,
459        retain: int = 8,
460    ) -> Observation:
461        """Capture AX + safe DOM attributes into JSON-compatible records.
462
463        max_nodes bounds retained records, NOT Chromium's full per-frame AX capture.
464        max_depth and the connection's message-size ceiling bound other dimensions.
465        Coverage reports omissions/races. Capture is not atomic with page scripts.
466        """
467        if min(max_nodes, max_text, max_depth, retain) < 1 or retain > 64:
468            raise ValueError(
469                "Capture limits must be positive; retain must be at most 64"
470            )
471        self._check()
472        started = time.monotonic()
473        snapshot_id = "s" + uuid.uuid4().hex[:12]
474        frames = await self.frames()
475        chosen = [f for f in frames if frame_id is None or f["id"] == frame_id]
476        if not chosen:
477            raise UnsupportedOperation(
478                "Requested frame was not found.", frame_id=frame_id
479            )
480        warnings: list[JSON] = []
481        for state in self._sessions.values():
482            if not state.ready:
483                warnings.append(
484                    {
485                        "kind": "frame_not_ready",
486                        "target_id": state.target_id,
487                        "error": state.error,
488                    }
489                )
490        captured = await capture_frames(
491            chosen,
492            self._sessions,
493            CaptureLimits(snapshot_id, max_nodes, max_text, max_depth),
494        )
495        warnings.extend(captured.warnings)
496        rows, refs = captured.rows, captured.references
497        self._snapshots[snapshot_id] = refs
498        while len(self._snapshots) > retain:
499            self._snapshots.popitem(last=False)
500        title = await self.evaluate("document.title")
501        return {
502            "snapshot_id": snapshot_id,
503            "target_id": self.target_id,
504            "url": self._frame_info.get(self._main_frame, {}).get("url", ""),
505            "title": title,
506            "captured_at": now(),
507            "capture_ms": round((time.monotonic() - started) * 1000, 1),
508            "nodes": rows,
509            "frames": frames,
510            "coverage": {
511                "complete": not warnings,
512                "warnings": warnings,
513                "scope_frame_id": frame_id,
514                "atomic": False,
515                "max_nodes_is_retention_limit": True,
516            },
517        }
518
519    def _ref(self, ref: str | Node) -> tuple[str, Reference, FrameSession]:
520        self._check()
521        key = ref.get("ref") if isinstance(ref, dict) else ref
522        if not isinstance(key, str) or ":" not in key:
523            raise StaleReference("This node has no actionable reference.")
524        record = self._snapshots.get(key.split(":", 1)[0], {}).get(key)
525        if record is None:
526            raise StaleReference(
527                "Reference is unknown, evicted, or from another connection.", ref=key
528            )
529        state = self._sessions.get(record.session_id)
530        if state is None or state.generation != record.generation:
531            raise StaleReference(
532                "Document or frame changed after observation.", ref=key
533            )
534        return key, record, state
535
536    async def _call(
537        self,
538        cdp: CDPSession,
539        object_id: str,
540        function: str,
541        *,
542        arguments: list[JSON] | None = None,
543    ) -> Any:
544        response = await cdp.send(
545            "Runtime.callFunctionOn",
546            {
547                "objectId": object_id,
548                "functionDeclaration": function,
549                "arguments": arguments or [],
550                "returnByValue": True,
551                "awaitPromise": True,
552            },
553        )
554        return self._value(response)
555
556    @staticmethod
557    def _value(response: JSON) -> Any:
558        if "exceptionDetails" in response:
559            detail = response["exceptionDetails"]
560            raise JavaScriptError(
561                "Page JavaScript threw an exception.",
562                description=detail.get("exception", {}).get(
563                    "description", detail.get("text")
564                ),
565            )
566        result = response.get("result", {})
567        if "unserializableValue" in result:
568            raise JavaScriptError(
569                "Result is not JSON-serializable; convert it explicitly in JavaScript.",
570                value=result["unserializableValue"],
571            )
572        if "objectId" in result:
573            raise JavaScriptError(
574                "Result did not serialize by value; return plain JSON data."
575            )
576        return result.get("value")
577
578    @asynccontextmanager
579    async def _resolved(self, ref: str | Node):
580        key, record, state = self._ref(ref)
581        object_id = None
582        try:
583            try:
584                node = (
585                    await state.cdp.send(
586                        "DOM.describeNode", {"backendNodeId": record.backend_id}
587                    )
588                )["node"]
589                if attributes_from_node(node) != record.attributes:
590                    raise StaleReference(
591                        "Element identity attributes changed.", ref=key
592                    )
593                ax = (
594                    await state.cdp.send(
595                        "Accessibility.getPartialAXTree",
596                        {
597                            "backendNodeId": record.backend_id,
598                            "fetchRelatives": False,
599                        },
600                    )
601                )["nodes"]
602                current = next(
603                    (n for n in ax if n.get("backendDOMNodeId") == record.backend_id),
604                    None,
605                )
606                if (
607                    current is None
608                    or current.get("ignored")
609                    or current.get("role", {}).get("value") != record.role
610                    or current.get("name", {}).get("value", "") != record.name
611                ):
612                    raise StaleReference(
613                        "Element role or accessible name changed.", ref=key
614                    )
615                obj = (
616                    await state.cdp.send(
617                        "DOM.resolveNode", {"backendNodeId": record.backend_id}
618                    )
619                )["object"]
620                object_id = obj.get("objectId")
621                if not object_id:
622                    raise StaleReference("Element no longer resolves.", ref=key)
623                status = await self._call(state.cdp, object_id, js.STATE)
624                self._ref(key)
625                if not status.get("connected"):
626                    raise StaleReference("Element was detached.", ref=key)
627            except ProtocolError as exc:
628                raise StaleReference(
629                    "Element no longer resolves in its original document.", ref=key
630                ) from exc
631            yield key, record, state, object_id, status
632        finally:
633            if object_id and not self.browser._connection.closed:
634                with contextlib.suppress(BrowserError):
635                    await state.cdp.send(
636                        "Runtime.releaseObject", {"objectId": object_id}, timeout=2
637                    )
638
639    @asynccontextmanager
640    async def _action(self, action: str, ref: str | Node | None = None):
641        async with self._lock:
642            self._check()
643            receipt: JSON = {
644                "action": action,
645                "ref": ref.get("ref") if isinstance(ref, dict) else ref,
646                "status": "preparing",
647                "may_have_executed": False,
648                "started_at": now(),
649            }
650            self.last_action = receipt
651            try:
652                yield receipt
653                receipt["status"] = "input_dispatched"
654                receipt["finished_at"] = now()
655            except BaseException as exc:
656                receipt["status"] = (
657                    "outcome_unknown" if receipt["may_have_executed"] else "rejected"
658                )
659                receipt["error"] = type(exc).__name__
660                receipt["finished_at"] = now()
661                if receipt["may_have_executed"] and isinstance(exc, Exception):
662                    raise ActionOutcomeUnknown(
663                        "Action may have happened. Observe application state before retrying.",
664                        receipt=copy.deepcopy(receipt),
665                        cause=exc.to_dict()
666                        if isinstance(exc, BrowserError)
667                        else str(exc),
668                    ) from exc
669                if receipt["may_have_executed"]:
670                    exc.add_note(
671                        "Browser input may have been dispatched; inspect page.last_action and observe before retrying."
672                    )
673                raise
674
675    async def evaluate(
676        self,
677        expression: str,
678        *,
679        frame_id: str | None = None,
680        timeout: float | None = None,
681    ) -> Any:
682        """Evaluate an expression or a zero-argument function; await promises; return data.
683
684        This is a raw escape hatch: it may mutate the page and bypasses action locking.
685        Explicitly invoke functions taking arguments in the supplied expression.
686        """
687        self._check()
688        cdp = self.cdp
689        params: JSON = {
690            "expression": f"(async () => {{ const v = ({expression}\n); return typeof v === 'function' ? await v() : await v; }})()",
691            "returnByValue": True,
692            "awaitPromise": True,
693        }
694        if frame_id is not None:
695            cdp = await self.frame_cdp(frame_id)
696            # Isolated worlds avoid guessing a frame's default executionContextId.
697            world = await cdp.send(
698                "Page.createIsolatedWorld",
699                {
700                    "frameId": frame_id,
701                    "worldName": "prime-browser-evaluate",
702                },
703            )
704            params["contextId"] = world["executionContextId"]
705        result = await cdp.send("Runtime.evaluate", params, timeout=timeout)
706        return self._value(result)
707
708    async def goto(
709        self, url: str, *, wait: str = "domcontentloaded", timeout: float = 30
710    ) -> JSON:
711        if wait not in {"commit", "domcontentloaded", "load"}:
712            raise ValueError("wait must be commit, domcontentloaded, or load")
713        positive(timeout, "timeout")
714        async with self._action("goto") as receipt:
715            try:
716                async with asyncio.timeout(timeout):
717                    # Subscribe first: lifecycle events may precede the navigate reply.
718                    async with self.events(
719                        "Page.lifecycleEvent", enable=False
720                    ) as events:
721                        receipt["may_have_executed"] = True
722                        result = await self.cdp.send(
723                            "Page.navigate", {"url": url}, timeout=timeout
724                        )
725                        if result.get("errorText") or result.get("isDownload"):
726                            raise BrowserError(
727                                "Navigation failed or became a download.", result=result
728                            )
729                        loader = result.get("loaderId")
730                        if wait != "commit" and loader:
731                            milestone = (
732                                "DOMContentLoaded"
733                                if wait == "domcontentloaded"
734                                else "load"
735                            )
736                            await events.next(
737                                where=lambda e: (
738                                    e.get("loaderId") == loader
739                                    and e.get("name") == milestone
740                                ),
741                                timeout=timeout,
742                            )
743                        receipt["navigation"] = result
744                        receipt["milestone"] = wait
745            except TimeoutError as exc:
746                raise WaitTimeout(
747                    "Navigation did not reach its milestone before the deadline."
748                ) from exc
749        return copy.deepcopy(receipt)
750
751    async def wait_for(
752        self,
753        *,
754        timeout: float = 10,
755        interval: float = 0.15,
756        state: str = "present",
757        **query: Any,
758    ) -> Node | None:
759        """Poll semantic state; no input is dispatched. 'absent' requires full coverage."""
760        positive(timeout, "timeout")
761        positive(interval, "interval")
762        if state not in {"present", "absent"}:
763            raise ValueError("state must be present or absent")
764        if not query:
765            raise ValueError(
766                "Supply a semantic query such as role='button', name='Save'"
767            )
768        last_coverage = None
769        try:
770            async with asyncio.timeout(timeout):
771                while True:
772                    snapshot = await self.observe(frame_id=query.get("frame_id"))
773                    matches = find(snapshot, **query)
774                    last_coverage = snapshot["coverage"]
775                    if state == "absent" and not matches:
776                        if last_coverage["complete"]:
777                            return None
778                    elif state == "present" and matches:
779                        if len(matches) != 1:
780                            raise SelectionError(
781                                "State query is ambiguous.",
782                                count=len(matches),
783                                query=query,
784                            )
785                        return matches[0]
786                    await asyncio.sleep(interval)
787        except TimeoutError as exc:
788            raise WaitTimeout(
789                "Semantic condition was not met.",
790                query=query,
791                state=state,
792                last_coverage=last_coverage,
793            ) from exc
794
795    async def _hit(self, cdp: CDPSession, x: float, y: float, viewport: JSON) -> JSON:
796        # DOM hit testing uses document coordinates; Input uses viewport coordinates.
797        try:
798            return await cdp.send(
799                "DOM.getNodeForLocation",
800                {
801                    "x": int(x + viewport["pageX"]),
802                    "y": int(y + viewport["pageY"]),
803                    "includeUserAgentShadowDOM": True,
804                },
805            )
806        except ProtocolError as exc:
807            raise NotActionable("No hit-testable node at this point.") from exc
808
809    async def _to_main_point(
810        self, state: FrameSession, x: float, y: float
811    ) -> tuple[float, float]:
812        """Cross OOPIF boundaries, checking each frame's embedding for occlusion."""
813        visited = set()
814        while state.cdp.session_id != self.cdp.session_id:
815            if state.root_frame in visited:
816                raise UnsupportedOperation("Cycle in the frame ancestry map.")
817            visited.add(state.root_frame)
818            parent_id = self._frame_info.get(state.root_frame, {}).get("parent_id")
819            parent_sid = self._frame_sessions.get(parent_id)
820            parent = self._sessions.get(parent_sid)
821            if parent is None:
822                raise StaleReference("Frame ancestry changed; observe again.")
823            owner = await parent.cdp.send(
824                "DOM.getFrameOwner", {"frameId": state.root_frame}
825            )
826            await parent.cdp.send(
827                "DOM.scrollIntoViewIfNeeded", {"backendNodeId": owner["backendNodeId"]}
828            )
829            box = (
830                await parent.cdp.send(
831                    "DOM.getBoxModel", {"backendNodeId": owner["backendNodeId"]}
832                )
833            )["model"]["content"]
834            # Scale/translate are supported. Do not guess perspective/rotation geometry.
835            if (
836                abs(box[0] - box[6]) > 0.25
837                or abs(box[2] - box[4]) > 0.25
838                or abs(box[1] - box[3]) > 0.25
839                or abs(box[5] - box[7]) > 0.25
840                or box[2] <= box[0]
841                or box[7] <= box[1]
842            ):
843                raise UnsupportedOperation(
844                    "Rotated, mirrored or perspective-transformed cross-process frames are not supported."
845                )
846            size = self._value(
847                await state.cdp.send(
848                    "Runtime.evaluate",
849                    {
850                        "expression": "({width:innerWidth,height:innerHeight})",
851                        "returnByValue": True,
852                    },
853                )
854            )
855            if not size or size["width"] <= 0 or size["height"] <= 0:
856                raise NotActionable("Frame has no visible viewport.")
857            x = box[0] + x * (box[2] - box[0]) / size["width"]
858            y = box[1] + y * (box[7] - box[1]) / size["height"]
859            metrics = await parent.cdp.send("Page.getLayoutMetrics")
860            viewport = metrics["cssLayoutViewport"]
861            if not (
862                0 <= x < viewport["clientWidth"] and 0 <= y < viewport["clientHeight"]
863            ):
864                raise NotActionable(
865                    "Frame click point is clipped by its parent viewport."
866                )
867            hit = await self._hit(parent.cdp, x, y, viewport)
868            if hit.get("backendNodeId") != owner["backendNodeId"]:
869                raise NotActionable("An ancestor frame is obscured by another element.")
870            state = parent
871        return x, y
872
873    async def _click_point(
874        self, record: Reference, state: FrameSession, object_id: str
875    ) -> tuple[float, float]:
876        await state.cdp.send(
877            "DOM.scrollIntoViewIfNeeded", {"backendNodeId": record.backend_id}
878        )
879        quads = (
880            await state.cdp.send(
881                "DOM.getContentQuads", {"backendNodeId": record.backend_id}
882            )
883        )["quads"]
884        metrics = await state.cdp.send("Page.getLayoutMetrics")
885        viewport = metrics["cssLayoutViewport"]
886        if metrics["cssVisualViewport"].get("scale", 1) != 1:
887            raise UnsupportedOperation(
888                "Pinch-zoomed viewports are not supported by high-level click."
889            )
890        width, height = viewport["clientWidth"], viewport["clientHeight"]
891        covering: JSON = {}
892        for quad in quads:
893            xs, ys = quad[0::2], quad[1::2]
894            left, right = max(0, min(xs)), min(width, max(xs))
895            top, bottom = max(0, min(ys)), min(height, max(ys))
896            if right - left < 1 or bottom - top < 1:
897                continue
898            x, y = (left + right) / 2, (top + bottom) / 2
899            try:
900                hit = await self._hit(state.cdp, x, y, viewport)
901            except NotActionable:
902                continue
903            matched = hit.get("backendNodeId") == record.backend_id
904            if not matched:
905                obj = (
906                    await state.cdp.send(
907                        "DOM.resolveNode", {"backendNodeId": hit["backendNodeId"]}
908                    )
909                )["object"].get("objectId")
910                if obj:
911                    try:
912                        matched = bool(
913                            await self._call(
914                                state.cdp,
915                                object_id,
916                                js.CONTAINS,
917                                arguments=[{"objectId": obj}],
918                            )
919                        )
920                    except (JavaScriptError, ProtocolError):
921                        pass
922                    finally:
923                        await state.cdp.send("Runtime.releaseObject", {"objectId": obj})
924            if matched:
925                return await self._to_main_point(state, x, y)
926            covering = await self._ax_summary(state, hit.get("backendNodeId"))
927        raise NotActionable(
928            "No unobstructed, visible click point was found"
929            + ("; something else is over the target." if covering else "."),
930            **({"covering": covering} if covering else {}),
931        )
932
933    async def _ax_summary(self, state: FrameSession, backend_id: int | None) -> JSON:
934        """Name what is over a target, so "something covers it" is actionable."""
935        if backend_id is None:
936            return {}
937        try:
938            nodes = (
939                await state.cdp.send(
940                    "Accessibility.getPartialAXTree",
941                    {
942                        "backendNodeId": backend_id,
943                        "fetchRelatives": False,
944                    },
945                )
946            )["nodes"]
947        except ProtocolError:
948            return {"backendNodeId": backend_id}
949        node = next((n for n in nodes if n.get("backendDOMNodeId") == backend_id), None)
950        if node is None:
951            return {"backendNodeId": backend_id}
952        return {
953            "backendNodeId": backend_id,
954            "role": str(node.get("role", {}).get("value", "")),
955            "name": str(node.get("name", {}).get("value", ""))[:80],
956        }
957
958    async def click(self, ref: str | Node, *, button: str = "left") -> JSON:
959        if button not in {"left", "right", "middle"}:
960            raise ValueError("button must be left, right, or middle")
961        async with self._action("click", ref) as receipt:
962            async with self._resolved(ref) as (key, record, state, obj, status):
963                if status["disabled"]:
964                    raise NotActionable("Element is disabled.", ref=key)
965                x, y = await self._click_point(record, state, obj)
966                self._ref(key)
967                receipt["may_have_executed"] = True
968                pressed = False
969                try:
970                    await self.cdp.send(
971                        "Input.dispatchMouseEvent",
972                        {"type": "mouseMoved", "x": x, "y": y},
973                    )
974                    pressed = True
975                    await self.cdp.send(
976                        "Input.dispatchMouseEvent",
977                        {
978                            "type": "mousePressed",
979                            "x": x,
980                            "y": y,
981                            "button": button,
982                            "clickCount": 1,
983                        },
984                    )
985                    await self.cdp.send(
986                        "Input.dispatchMouseEvent",
987                        {
988                            "type": "mouseReleased",
989                            "x": x,
990                            "y": y,
991                            "button": button,
992                            "clickCount": 1,
993                        },
994                    )
995                    pressed = False
996                finally:
997                    if pressed and not self.browser._connection.closed:
998                        with contextlib.suppress(Exception):
999                            await self.cdp.send(
1000                                "Input.dispatchMouseEvent",
1001                                {
1002                                    "type": "mouseReleased",
1003                                    "x": x,
1004                                    "y": y,
1005                                    "button": button,
1006                                    "clickCount": 1,
1007                                },
1008                                timeout=1,
1009                            )
1010        return copy.deepcopy(receipt)
1011
1012    async def fill(self, ref: str | Node, text: str) -> JSON:
1013        if not isinstance(text, str):
1014            raise TypeError("text must be a string")
1015        async with self._action("fill", ref) as receipt:
1016            async with self._resolved(ref) as (key, record, state, obj, status):
1017                if status["disabled"] or status["readonly"] or not status["visible"]:
1018                    held = [
1019                        name
1020                        for name, value in (
1021                            ("disabled", status["disabled"]),
1022                            ("read-only", status["readonly"]),
1023                            ("not visible", not status["visible"]),
1024                        )
1025                        if value
1026                    ]
1027                    raise NotActionable(
1028                        f"Field is {' and '.join(held)}.",
1029                        ref=key,
1030                        disabled=status["disabled"],
1031                        readonly=status["readonly"],
1032                    )
1033                supported = (
1034                    status["tag"] == "textarea"
1035                    or status["editable"]
1036                    or (
1037                        status["tag"] == "input"
1038                        and status["type"]
1039                        in {"text", "search", "url", "tel", "email", "password"}
1040                    )
1041                )
1042                if not supported:
1043                    raise UnsupportedOperation(
1044                        "fill supports text-like inputs, textarea and contenteditable only.",
1045                        tag=status["tag"],
1046                        type=status["type"],
1047                    )
1048                receipt["may_have_executed"] = True
1049                if not await self._call(state.cdp, obj, js.SELECT_TEXT):
1050                    raise NotActionable("Could not focus and select the field.")
1051                if text:
1052                    await state.cdp.send("Input.insertText", {"text": text})
1053                else:
1054                    await self._key(state.cdp, "Backspace")
1055                actual = await self._call(state.cdp, obj, js.READ_VALUE)
1056                if actual != text:
1057                    raise BrowserError(
1058                        "Field value did not match after input; no retry was attempted.",
1059                        expected_length=len(text),
1060                        actual_length=len(str(actual)),
1061                    )
1062        return copy.deepcopy(receipt)
1063
1064    async def _key(self, cdp: CDPSession, key: str) -> None:
1065        special = {
1066            "Enter": ("Enter", 13, "\r"),
1067            "Tab": ("Tab", 9, ""),
1068            "Escape": ("Escape", 27, ""),
1069            "Backspace": ("Backspace", 8, ""),
1070            "Delete": ("Delete", 46, ""),
1071            "ArrowLeft": ("ArrowLeft", 37, ""),
1072            "ArrowUp": ("ArrowUp", 38, ""),
1073            "ArrowRight": ("ArrowRight", 39, ""),
1074            "ArrowDown": ("ArrowDown", 40, ""),
1075            "Home": ("Home", 36, ""),
1076            "End": ("End", 35, ""),
1077            "PageUp": ("PageUp", 33, ""),
1078            "PageDown": ("PageDown", 34, ""),
1079            "Space": ("Space", 32, " "),
1080        }
1081        if key not in special:
1082            raise ValueError(
1083                "Unsupported key; use named keys or raw Input.dispatchKeyEvent for chords"
1084            )
1085        code, vk, text = special[key]
1086        params = {
1087            "key": " " if key == "Space" else key,
1088            "code": code,
1089            "windowsVirtualKeyCode": vk,
1090        }
1091        try:
1092            await cdp.send(
1093                "Input.dispatchKeyEvent", {"type": "keyDown", **params, "text": text}
1094            )
1095        finally:
1096            if not self.browser._connection.closed:
1097                await cdp.send(
1098                    "Input.dispatchKeyEvent", {"type": "keyUp", **params}, timeout=2
1099                )
1100
1101    async def press(self, key: str, *, ref: str | Node | None = None) -> JSON:
1102        if key not in {
1103            "Enter",
1104            "Tab",
1105            "Escape",
1106            "Backspace",
1107            "Delete",
1108            "ArrowLeft",
1109            "ArrowUp",
1110            "ArrowRight",
1111            "ArrowDown",
1112            "Home",
1113            "End",
1114            "PageUp",
1115            "PageDown",
1116            "Space",
1117        }:
1118            raise ValueError(
1119                "Unsupported key; use named keys or raw Input.dispatchKeyEvent for chords"
1120            )
1121        async with self._action("press", ref) as receipt:
1122            if ref is None:
1123                receipt["may_have_executed"] = True
1124                await self._key(self.cdp, key)
1125            else:
1126                async with self._resolved(ref) as (refkey, record, state, obj, status):
1127                    if status["disabled"] or not status["visible"]:
1128                        raise NotActionable(
1129                            "Element cannot receive keyboard input.", ref=refkey
1130                        )
1131                    receipt["may_have_executed"] = True
1132                    await state.cdp.send(
1133                        "DOM.focus", {"backendNodeId": record.backend_id}
1134                    )
1135                    await self._key(state.cdp, key)
1136        return copy.deepcopy(receipt)
1137
1138    async def scroll(
1139        self,
1140        *,
1141        dy: float = 600,
1142        dx: float = 0,
1143        x: float | None = None,
1144        y: float | None = None,
1145    ) -> JSON:
1146        if not all(math.isfinite(v) for v in (dx, dy)):
1147            raise ValueError("Scroll deltas must be finite")
1148        async with self._action("scroll") as receipt:
1149            metrics = await self.cdp.send("Page.getLayoutMetrics")
1150            viewport = metrics.get("cssLayoutViewport", metrics["layoutViewport"])
1151            x = viewport["clientWidth"] / 2 if x is None else x
1152            y = viewport["clientHeight"] / 2 if y is None else y
1153            if not all(math.isfinite(v) for v in (x, y)):
1154                raise ValueError("Scroll coordinates must be finite")
1155            receipt["may_have_executed"] = True
1156            await self.cdp.send(
1157                "Input.dispatchMouseEvent",
1158                {
1159                    "type": "mouseWheel",
1160                    "x": x,
1161                    "y": y,
1162                    "deltaX": dx,
1163                    "deltaY": dy,
1164                },
1165            )
1166        return copy.deepcopy(receipt)
1167
1168    async def screenshot(
1169        self,
1170        path: str | Path | None = None,
1171        *,
1172        full_page: bool = False,
1173        max_pixels: int = 16_000_000,
1174    ) -> Path:
1175        self._check()
1176        if max_pixels < 1:
1177            raise ValueError("max_pixels must be positive")
1178        metrics = await self.cdp.send("Page.getLayoutMetrics")
1179        viewport = metrics.get("cssLayoutViewport", metrics["layoutViewport"])
1180        params: JSON = {
1181            "format": "png",
1182            "fromSurface": True,
1183            "captureBeyondViewport": full_page,
1184        }
1185        if full_page:
1186            size = metrics.get("cssContentSize", metrics["contentSize"])
1187            width, height = size["width"], size["height"]
1188            params["clip"] = {
1189                "x": size["x"],
1190                "y": size["y"],
1191                "width": width,
1192                "height": height,
1193                "scale": 1,
1194            }
1195        else:
1196            width, height = viewport["clientWidth"], viewport["clientHeight"]
1197        ratio = await self.evaluate("devicePixelRatio")
1198        if width * height * ratio * ratio > max_pixels:
1199            raise UnsupportedOperation(
1200                "Screenshot exceeds pixel budget; use a viewport capture or raise max_pixels."
1201            )
1202        data = (await self.cdp.send("Page.captureScreenshot", params))["data"]
1203        destination = (
1204            Path(path)
1205            if path is not None
1206            else Path(".browser-artifacts") / (uuid.uuid4().hex + ".png")
1207        )
1208        destination = destination.expanduser().resolve()
1209        if destination.suffix.lower() != ".png":
1210            raise ValueError("Screenshot path must end in .png")
1211        raw = base64.b64decode(data, validate=True)
1212        if hooks.attach_image is not None:
1213            try:
1214                hooks.attach_image(raw)
1215            except Exception:
1216                pass
1217
1218        def write() -> None:
1219            destination.parent.mkdir(parents=True, exist_ok=True)
1220            destination.write_bytes(raw)
1221
1222        await asyncio.to_thread(write)
1223        return destination
1224
1225    async def close(self, *, allow_existing: bool = False) -> None:
1226        self._check()
1227        if not self.owned and not allow_existing:
1228            raise UnsupportedOperation(
1229                "This tab was not created by this connection; pass allow_existing=True to explicitly close it."
1230            )
1231        await self.browser.cdp.send("Target.closeTarget", {"targetId": self.target_id})
1232        self._invalidate(destroyed=True)
1233        await self._stop_tasks()
1234        self.browser._pages.pop(self.target_id, None)
1235
1236    def __reduce_ex__(self, protocol: SupportsIndex, /) -> Any:
1237        raise TypeError(
1238            "Live pages cannot be saved; save page.descriptor() and ordinary observations."
1239        )