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