"""Contained loopback Chromium rendering for benchmark web evidence. Only regular files beneath one workspace are served. The CDP client is a small, bounded RFC6455 implementation and every browser is launched in an owned process group which is reaped before its temporary profile is removed. """ from __future__ import annotations import base64 import hashlib import ipaddress import json import os import secrets import shutil import signal import socket import stat import struct import subprocess import tempfile import threading import time from dataclasses import dataclass from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import unquote_to_bytes, urlparse from urllib.request import urlopen from scripts.agent_benchmark.manifest import VIEWPORT_ID_RE MAX_HANDSHAKE_BYTES = 16 * 1024 MAX_MESSAGE_BYTES = 8 * 1024 * 1024 MAX_STATIC_BYTES = 32 * 1024 * 1024 MAX_PENDING_FIRE_COMMANDS = 4096 WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" class BrowserError(Exception): """A redacted renderer/protocol failure safe for durable evidence.""" @dataclass(frozen=True) class ViewportObservation: id: str width: int height: int screenshot: str screenshot_digest: str screenshot_size: int image_facts: tuple[dict, ...] layout: dict accessibility: dict @dataclass(frozen=True) class RenderObservation: browser: str origin: str requests: tuple[dict, ...] console: tuple[dict, ...] viewports: tuple[ViewportObservation, ...] def _digest(data: bytes) -> str: return "sha256:" + hashlib.sha256(data).hexdigest() def _decode_request_path(raw: str) -> tuple[tuple[str, ...], str]: try: decoded = unquote_to_bytes(raw).decode("utf-8", "strict") except (UnicodeDecodeError, ValueError) as exc: raise FileNotFoundError from exc if not decoded.startswith("/") or "\x00" in decoded or "\\" in decoded: raise FileNotFoundError parts = tuple(decoded[1:].split("/")) if not parts or any(part in ("", ".", "..") for part in parts): raise FileNotFoundError return parts, decoded def _read_contained_regular_no_follow(root: Path, raw: str) -> tuple[bytes, str]: """Open every path component relative to ``root`` without following links.""" parts, decoded = _decode_request_path(raw) directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC if hasattr(os, "O_NOFOLLOW"): directory_flags |= os.O_NOFOLLOW file_flags = os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK if hasattr(os, "O_NOFOLLOW"): file_flags |= os.O_NOFOLLOW descriptors: list[int] = [] try: current = os.open(root, directory_flags) descriptors.append(current) for component in parts[:-1]: current = os.open(component, directory_flags, dir_fd=current) descriptors.append(current) fd = os.open(parts[-1], file_flags, dir_fd=current) descriptors.append(fd) info = os.fstat(fd) if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_STATIC_BYTES: raise FileNotFoundError chunks: list[bytes] = [] remaining = info.st_size while remaining: chunk = os.read(fd, min(remaining, 1024 * 1024)) if not chunk: raise FileNotFoundError chunks.append(chunk) remaining -= len(chunk) # Refuse a file that grew beyond the bounded snapshot while being read. if os.read(fd, 1): raise FileNotFoundError return b"".join(chunks), Path(decoded).suffix.lower() except (OSError, ValueError) as exc: raise FileNotFoundError from exc finally: for descriptor in reversed(descriptors): try: os.close(descriptor) except OSError: pass class _StaticServer(ThreadingHTTPServer): daemon_threads = True allow_reuse_address = True def __init__(self, root: Path): if root.is_symlink() or not root.is_dir(): raise BrowserError("workspace_unavailable") self.root = root.resolve() self.requests: list[dict] = [] self._requests_lock = threading.Lock() super().__init__(("127.0.0.1", 0), _StaticHandler) def record_request(self, record: dict) -> None: with self._requests_lock: self.requests.append(record) def request_snapshot(self) -> tuple[dict, ...]: with self._requests_lock: return tuple(dict(item) for item in self.requests) class _StaticHandler(BaseHTTPRequestHandler): def log_message(self, *_args): return def do_GET(self): server: _StaticServer = self.server # type: ignore[assignment] raw = self.path.split("?", 1)[0].split("#", 1)[0] if raw == "/favicon.ico": server.record_request( {"kind": "local", "path": raw, "allowed": True, "status": 204} ) self.send_response(204) self.end_headers() return if raw == "/": raw = "/index.html" try: data, suffix = _read_contained_regular_no_follow(server.root, raw) except FileNotFoundError: server.record_request( {"kind": "local", "path": raw, "allowed": False, "status": 404} ) self.send_error(404) return server.record_request( {"kind": "local", "path": raw, "allowed": True, "status": 200} ) content_type = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", }.get(suffix, "application/octet-stream") self.send_response(200) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(data))) self.end_headers() self.wfile.write(data) class _CDP: """Bounded RFC6455 client with strict response/event correlation.""" def __init__(self, url: str, deadline: float): parsed = urlparse(url) try: address = ipaddress.ip_address(parsed.hostname or "") except ValueError as exc: raise BrowserError("CDP endpoint is not loopback") from exc if parsed.scheme != "ws" or not address.is_loopback or parsed.port is None: raise BrowserError("CDP endpoint is not loopback") self.deadline = deadline self.seq = 0 self.events: list[dict] = [] self.event_handler = None self._fire_ids: set[int] = set() self._buffer = bytearray() self._closed = False timeout = self._remaining() try: self.sock = socket.create_connection( (parsed.hostname, parsed.port), timeout ) key = base64.b64encode(secrets.token_bytes(16)).decode("ascii") request = ( f"GET {parsed.path or '/'} HTTP/1.1\r\n" f"Host: {parsed.hostname}:{parsed.port}\r\n" "Upgrade: websocket\r\nConnection: Upgrade\r\n" f"Sec-WebSocket-Key: {key}\r\n" "Sec-WebSocket-Version: 13\r\n\r\n" ).encode("ascii") self._sendall(request) self._read_http(key) except Exception: sock = getattr(self, "sock", None) if sock is not None: try: sock.close() except OSError: pass raise def _remaining(self) -> float: remaining = self.deadline - time.monotonic() if remaining <= 0: raise BrowserError("CDP deadline expired") return remaining def _read_http(self, key: str) -> None: data = bytearray() while b"\r\n\r\n" not in data: if len(data) >= MAX_HANDSHAKE_BYTES: raise BrowserError("oversized CDP handshake") try: self.sock.settimeout(self._remaining()) chunk = self.sock.recv(4096) except (OSError, socket.timeout) as exc: raise BrowserError("CDP handshake failed") from exc if not chunk: raise BrowserError("CDP handshake closed") data.extend(chunk) if len(data) > MAX_HANDSHAKE_BYTES: raise BrowserError("oversized CDP handshake") header, tail = bytes(data).split(b"\r\n\r\n", 1) lines = header.split(b"\r\n") status_parts = lines[0].split(b" ", 2) if len(status_parts) < 2 or status_parts[0] != b"HTTP/1.1" or status_parts[1] != b"101": raise BrowserError("CDP WebSocket upgrade failed") headers: dict[str, str] = {} for line in lines[1:]: if b":" not in line: raise BrowserError("CDP WebSocket headers are invalid") name, value = line.split(b":", 1) try: headers[name.decode("ascii").strip().lower()] = value.decode( "ascii" ).strip() except UnicodeDecodeError as exc: raise BrowserError("CDP WebSocket headers are invalid") from exc expected = base64.b64encode( hashlib.sha1((key + WEBSOCKET_GUID).encode("ascii")).digest() ).decode("ascii") if ( headers.get("upgrade", "").lower() != "websocket" or "upgrade" not in {part.strip().lower() for part in headers.get("connection", "").split(",")} or headers.get("sec-websocket-accept") != expected ): raise BrowserError("CDP WebSocket handshake is invalid") self._buffer.extend(tail) def _exact(self, size: int) -> bytes: data = bytearray() if self._buffer: take = min(size, len(self._buffer)) data.extend(self._buffer[:take]) del self._buffer[:take] while len(data) < size: try: self.sock.settimeout(self._remaining()) chunk = self.sock.recv(size - len(data)) except socket.timeout as exc: raise BrowserError("CDP deadline expired") from exc except OSError as exc: raise BrowserError("CDP socket read failed") from exc if not chunk: raise BrowserError("CDP socket closed") data.extend(chunk) return bytes(data) def _frame(self) -> tuple[bool, int, bytes]: first, second = self._exact(2) fin = bool(first & 0x80) if first & 0x70: raise BrowserError("reserved CDP frame bits") opcode = first & 0x0F masked = bool(second & 0x80) if masked: raise BrowserError("masked CDP server frame") length = second & 0x7F if length == 126: length = struct.unpack("!H", self._exact(2))[0] if length < 126: raise BrowserError("non-canonical CDP frame length") elif length == 127: encoded = self._exact(8) if encoded[0] & 0x80: raise BrowserError("invalid CDP frame length") length = struct.unpack("!Q", encoded)[0] if length <= 65535: raise BrowserError("non-canonical CDP frame length") if length > MAX_MESSAGE_BYTES: raise BrowserError("oversized CDP frame") if opcode >= 8 and (not fin or length > 125): raise BrowserError("invalid CDP control frame") return fin, opcode, self._exact(length) def receive_message(self) -> dict: fragments = bytearray() fragmented = False while True: fin, opcode, payload = self._frame() if opcode == 8: if len(payload) == 1: raise BrowserError("invalid CDP close frame") try: self._send(payload, opcode=8) except BrowserError: pass raise BrowserError("CDP socket closed") if opcode == 9: self._send(payload, opcode=10) continue if opcode == 10: continue if opcode == 2: raise BrowserError("binary CDP message") if opcode == 1: if fragmented: raise BrowserError("interleaved CDP data frame") fragments.extend(payload) fragmented = not fin elif opcode == 0: if not fragmented: raise BrowserError("unexpected CDP continuation") fragments.extend(payload) fragmented = not fin else: raise BrowserError("unsupported CDP frame") if len(fragments) > MAX_MESSAGE_BYTES: raise BrowserError("oversized CDP message") if fragmented: continue try: message = json.loads(fragments.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise BrowserError("malformed CDP message") from exc if not isinstance(message, dict): raise BrowserError("malformed CDP message") return message def _sendall(self, data: bytes) -> None: try: self.sock.settimeout(self._remaining()) self.sock.sendall(data) except socket.timeout as exc: raise BrowserError("CDP deadline expired") from exc except OSError as exc: raise BrowserError("CDP socket write failed") from exc def _send(self, data: bytes, *, opcode: int = 1) -> None: if len(data) > MAX_MESSAGE_BYTES: raise BrowserError("oversized CDP client message") mask = secrets.token_bytes(4) size = len(data) if size < 126: header = bytes((0x80 | opcode, 0x80 | size)) elif size <= 65535: header = bytes((0x80 | opcode, 0x80 | 126)) + struct.pack("!H", size) else: header = bytes((0x80 | opcode, 0x80 | 127)) + struct.pack("!Q", size) masked = bytes(value ^ mask[index % 4] for index, value in enumerate(data)) self._sendall(header + mask + masked) @staticmethod def _command(ident: int, method: str, params: dict | None) -> bytes: return json.dumps( {"id": ident, "method": method, "params": params or {}}, separators=(",", ":"), ).encode("utf-8") def call(self, method: str, params: dict | None = None) -> dict: self.seq += 1 ident = self.seq self._send(self._command(ident, method, params)) while True: message = self.receive_message() response_id = message.get("id") if response_id is not None: if isinstance(response_id, bool) or not isinstance(response_id, int): raise BrowserError("invalid CDP response id") if response_id in self._fire_ids: self._fire_ids.remove(response_id) continue if response_id != ident: raise BrowserError("uncorrelated CDP response") if "error" in message: raise BrowserError(f"CDP {method} failed") result = message.get("result", {}) if not isinstance(result, dict): raise BrowserError("invalid CDP result") return result if not isinstance(message.get("method"), str) or not isinstance( message.get("params", {}), dict ): raise BrowserError("invalid CDP event") self.events.append(message) if self.event_handler is not None: self.event_handler(message) def fire(self, method: str, params: dict | None = None) -> None: """Send an event-callback command without entering a nested receive loop.""" if len(self._fire_ids) >= MAX_PENDING_FIRE_COMMANDS: raise BrowserError("too many pending CDP commands") self.seq += 1 self._fire_ids.add(self.seq) self._send(self._command(self.seq, method, params)) def close(self) -> None: if self._closed: return self._closed = True try: self.sock.shutdown(socket.SHUT_RDWR) except OSError: pass try: self.sock.close() except OSError: pass _OBSERVATION_SCRIPT = r"""(() => { const rect = e => { const r=e.getBoundingClientRect(); return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom}; }; const visible = e => { const r=e.getBoundingClientRect(),s=getComputedStyle(e); return r.width>0&&r.height>0&&s.display!=='none'&&s.visibility!=='hidden'&&Number(s.opacity)>0; }; const controls=[...document.querySelectorAll('a[href],button,input,select,textarea,[tabindex]')].filter(e=>!e.disabled&&visible(e)); const clipped=[...document.querySelectorAll('img,a[href],button,input,select,textarea,[tabindex]')].filter(visible).filter(e=>{const r=e.getBoundingClientRect();return r.left<0||r.right>innerWidth;}).length; let overlaps=0; for(let i=0;iMath.max(a.left,b.left)&&Math.min(a.bottom,b.bottom)>Math.max(a.top,b.top))overlaps++;} const name=e=>(e.getAttribute('aria-label')||e.getAttribute('alt')||e.getAttribute('title')||e.value||e.textContent||'').trim(); const rgba=s=>{let m=s.match(/[\d.]+/g)||[];return m.slice(0,4).map(Number);}; const lum=c=>{c=c/255;return c<=.03928?c/12.92:Math.pow((c+.055)/1.055,2.4);}; const contrast=e=>{let s=getComputedStyle(e),f=rgba(s.color),b=rgba(s.backgroundColor),p=e.parentElement;while((b.length<3||(b.length>=4&&b[3]===0))&&p){b=rgba(getComputedStyle(p).backgroundColor);p=p.parentElement;}if(f.length<3||b.length<3)return 0;let a=.2126*lum(f[0])+.7152*lum(f[1])+.0722*lum(f[2]),z=.2126*lum(b[0])+.7152*lum(b[1])+.0722*lum(b[2]);return (Math.max(a,z)+.05)/(Math.min(a,z)+.05);}; const focusStyle=e=>{let s=getComputedStyle(e);return {outline_style:s.outlineStyle,outline_width:s.outlineWidth,outline_color:s.outlineColor,outline_offset:s.outlineOffset,box_shadow:s.boxShadow,background_color:s.backgroundColor,background_image:s.backgroundImage,border_top:[s.borderTopWidth,s.borderTopStyle,s.borderTopColor],border_right:[s.borderRightWidth,s.borderRightStyle,s.borderRightColor],border_bottom:[s.borderBottomWidth,s.borderBottomStyle,s.borderBottomColor],border_left:[s.borderLeftWidth,s.borderLeftStyle,s.borderLeftColor]};}; const changed=(before,after,keys)=>keys.some(key=>before[key]!==after[key]); const colorProbe=document.createElement('canvas').getContext('2d'); const paintAlpha=value=>{if(!colorProbe)return 0;colorProbe.fillStyle='rgba(0,0,0,0)';colorProbe.fillStyle=value;let normalized=colorProbe.fillStyle,m=normalized.match(/^rgba?\(([^)]+)\)$/i);if(m){let parts=m[1].replace(/\//g,' ').split(/[,\s]+/).filter(Boolean);return normalized.toLowerCase().startsWith('rgba')?Number(parts[3]||0):1;}if(/^#[0-9a-f]{6}$/i.test(normalized))return 1;if(/^#[0-9a-f]{8}$/i.test(normalized))return parseInt(normalized.slice(7,9),16)/255;return 0;}; const splitShadows=value=>{let result=[],start=0,depth=0;for(let i=0;i(parseFloat(after.outline_width)||0)>0&&after.outline_style!=='none'&&after.outline_style!=='hidden'&&paintAlpha(after.outline_color)>0&&changed(before,after,['outline_style','outline_width','outline_color','outline_offset']); const paintedShadowChanged=(before,after)=>before.box_shadow!==after.box_shadow&&after.box_shadow!=='none'&&splitShadows(after.box_shadow).some(shadow=>{let color=(shadow.match(/rgba?\([^)]*\)|#[0-9a-f]{3,8}\b/ig)||[])[0]||'transparent';return paintAlpha(color)>0&&(shadow.match(/-?\d+(?:\.\d+)?px/g)||[]).some(value=>Math.abs(parseFloat(value))>0);}); const paintedBorderChanged=(before,after)=>['border_top','border_right','border_bottom','border_left'].some(key=>{let current=after[key],prior=before[key];return (parseFloat(current[0])||0)>0&¤t[1]!=='none'&¤t[1]!=='hidden'&&paintAlpha(current[2])>0&¤t.some((value,index)=>value!==prior[index]);}); const paintedBackgroundColorChanged=(before,after)=>before.background_color!==after.background_color&&paintAlpha(after.background_color)>0; const unfocus=()=>{let active=document.activeElement;if(active&&typeof active.blur==='function')active.blur();}; const focus=controls.map(e=>{unfocus();e.blur();let before=focusStyle(e);e.focus();let after=focusStyle(e),focused=document.activeElement===e;let indicator=paintedOutlineChanged(before,after)||paintedShadowChanged(before,after)||paintedBorderChanged(before,after)||paintedBackgroundColorChanged(before,after);return {name:!!name(e),tab_index:e.tabIndex,focused,focus_visible:focused&&indicator,contrast:contrast(e)};}); const headings=[...document.querySelectorAll('h1,h2,h3,h4,h5,h6')].map(e=>Number(e.tagName.slice(1))); let heading_progression=true; for(let i=1;iheadings[i-1]+1)heading_progression=false; return { images:[...document.images].map(e=>({src:e.getAttribute('src')||'',alt:e.alt||'',complete:e.complete,natural_width:e.naturalWidth,natural_height:e.naturalHeight,visible:visible(e),rect:rect(e)})), layout:{scroll_width:document.documentElement.scrollWidth,client_width:document.documentElement.clientWidth,clipped,overlaps}, accessibility:{h1_count:document.querySelectorAll('h1').length,headings,heading_progression,main_count:document.querySelectorAll('main').length,landmarks:document.querySelectorAll('main,nav,header,footer,[role="main"],[role="navigation"],[role="banner"],[role="contentinfo"]').length,controls:focus} }; })()""" def _write_new(path: Path, data: bytes) -> None: flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_CLOEXEC if hasattr(os, "O_NOFOLLOW"): flags |= os.O_NOFOLLOW try: fd = os.open(path, flags, 0o600) except OSError as exc: raise BrowserError("screenshot_collision") from exc try: with os.fdopen(fd, "wb", closefd=True) as handle: handle.write(data) handle.flush() os.fsync(handle.fileno()) except Exception: try: path.unlink() except OSError: pass raise def _terminate_owned_process_group(process: subprocess.Popen, grace: float = 3.0) -> None: """Terminate then kill the session led by ``process`` and reap its leader.""" pgid = process.pid if pgid == os.getpgrp(): raise BrowserError("browser process group ownership is invalid") try: os.killpg(pgid, signal.SIGTERM) except ProcessLookupError: pass except OSError as exc: raise BrowserError("browser process cleanup failed") from exc try: process.wait(timeout=grace) except subprocess.TimeoutExpired: try: os.killpg(pgid, signal.SIGKILL) except ProcessLookupError: pass except OSError as exc: raise BrowserError("browser process cleanup failed") from exc try: process.wait(timeout=grace) except subprocess.TimeoutExpired as exc: raise BrowserError("browser process cleanup failed") from exc # The leader can exit before a descendant. Kill any surviving member and # give the kernel a bounded moment to reap it under its own parent. try: os.killpg(pgid, 0) except ProcessLookupError: return except OSError: return try: os.killpg(pgid, signal.SIGKILL) except ProcessLookupError: return end = time.monotonic() + grace while time.monotonic() < end: try: os.killpg(pgid, 0) except ProcessLookupError: return except OSError: return time.sleep(0.02) raise BrowserError("browser process cleanup failed") def _console_projection(events: list[dict]) -> tuple[dict, ...]: projected: list[dict] = [] for event in events: method = event.get("method") params = event.get("params", {}) if method == "Runtime.consoleAPICalled": projected.append({"kind": "console", "level": str(params.get("type", "unknown"))[:32]}) elif method == "Runtime.exceptionThrown": projected.append({"kind": "exception", "level": "error"}) elif method == "Log.entryAdded": entry = params.get("entry", {}) projected.append({"kind": "log", "level": str(entry.get("level", "unknown"))[:32]}) return tuple(projected) class BrowserRenderer: def __init__(self, browser_binary: str = "chromium"): self.browser_binary = browser_binary def render( self, *, workspace_root: str | Path, output_root: str | Path, viewports, timeout_seconds: int, ) -> RenderObservation: raw_root, raw_out = Path(workspace_root), Path(output_root) if raw_root.is_symlink() or not raw_root.is_dir(): raise BrowserError("workspace_unavailable") if raw_out.is_symlink() or not raw_out.is_dir(): raise BrowserError("output_unavailable") root, out = raw_root.resolve(), raw_out.resolve() viewport_list = tuple(viewports) if not viewport_list: raise BrowserError("viewport_unavailable") for viewport in viewport_list: if ( not isinstance(getattr(viewport, "id", None), str) or not VIEWPORT_ID_RE.fullmatch(viewport.id) or isinstance(getattr(viewport, "width", None), bool) or not isinstance(viewport.width, int) or not 1 <= viewport.width <= 10000 or isinstance(getattr(viewport, "height", None), bool) or not isinstance(viewport.height, int) or not 1 <= viewport.height <= 10000 ): raise BrowserError("viewport_unavailable") targets = [out / f"screenshot-{viewport.id}.png" for viewport in viewport_list] if len(set(targets)) != len(targets) or any( target.exists() or target.is_symlink() for target in targets ): raise BrowserError("screenshot_collision") binary = self.browser_binary if os.sep not in binary: binary = shutil.which(binary) or "" if not binary: raise BrowserError("browser_unavailable") deadline = time.monotonic() + max(3, timeout_seconds) server = _StaticServer(root) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() profile = tempfile.TemporaryDirectory( prefix="iop-browser-", ignore_cleanup_errors=True ) process: subprocess.Popen | None = None cdp: _CDP | None = None cleanup_error: Exception | None = None completed = False try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as port_socket: port_socket.bind(("127.0.0.1", 0)) port = int(port_socket.getsockname()[1]) try: process = subprocess.Popen( [ binary, "--headless", "--no-sandbox", "--disable-gpu", "--disable-extensions", "--disable-background-networking", "--no-first-run", "--remote-allow-origins=*", "--remote-debugging-address=127.0.0.1", f"--remote-debugging-port={port}", f"--user-data-dir={profile.name}", "about:blank", ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) except OSError as exc: raise BrowserError("browser_start_failed") from exc target = None while time.monotonic() < deadline: if process.poll() is not None: raise BrowserError("browser_start_failed") try: with urlopen( f"http://127.0.0.1:{port}/json", timeout=0.5 ) as response: candidates = json.load(response) if not isinstance(candidates, list): raise ValueError("invalid target list") target = next( ( item for item in candidates if isinstance(item, dict) and item.get("type") == "page" ), None, ) if target: break except (OSError, ValueError, json.JSONDecodeError): time.sleep(0.05) if not target or not isinstance(target.get("webSocketDebuggerUrl"), str): raise BrowserError("browser_cdp_unavailable") cdp = _CDP(target["webSocketDebuggerUrl"], deadline) for method in ( "Page.enable", "Runtime.enable", "Log.enable", "Accessibility.enable", ): cdp.call(method) cdp.call("Page.bringToFront") version = str(cdp.call("Browser.getVersion").get("product", "unknown"))[:128] origin = f"http://127.0.0.1:{server.server_address[1]}" denied: list[dict] = [] def intercept(event): if event.get("method") != "Fetch.requestPaused": return params = event.get("params", {}) request = params.get("request", {}) url = request.get("url", "") request_id = params.get("requestId") if not isinstance(request_id, str) or not request_id: raise BrowserError("browser request identity is invalid") if isinstance(url, str) and url.startswith(origin + "/"): cdp.fire( "Fetch.continueRequest", {"requestId": request_id} ) else: encoded = str(url).encode("utf-8", "replace") denied.append( { "kind": "external", "url_digest": _digest(encoded), "allowed": False, "status": 0, } ) cdp.fire( "Fetch.failRequest", { "requestId": request_id, "errorReason": "BlockedByClient", }, ) cdp.event_handler = intercept cdp.call( "Fetch.enable", {"patterns": [{"urlPattern": "*", "requestStage": "Request"}]}, ) results: list[ViewportObservation] = [] for viewport, screenshot in zip(viewport_list, targets): cdp.call( "Emulation.setDeviceMetricsOverride", { "width": viewport.width, "height": viewport.height, "deviceScaleFactor": 1, "mobile": viewport.width < 600, }, ) cdp.call("Page.navigate", {"url": origin + "/index.html"}) cdp.call( "Runtime.evaluate", { "expression": "new Promise((resolve,reject)=>{const done=()=>requestAnimationFrame(()=>requestAnimationFrame(resolve));if(document.readyState==='complete')done();else window.addEventListener('load',done,{once:true});setTimeout(()=>reject(new Error('load timeout')),5000);})", "awaitPromise": True, "returnByValue": True, }, ) facts = cdp.call( "Runtime.evaluate", {"expression": _OBSERVATION_SCRIPT, "returnByValue": True}, ) parsed = facts.get("result", {}).get("value", {}) if not isinstance(parsed, dict) or set(parsed) != { "images", "layout", "accessibility", }: raise BrowserError("browser_observation_invalid") ax_nodes = cdp.call("Accessibility.getFullAXTree").get("nodes", []) if not isinstance(ax_nodes, list): raise BrowserError("browser_accessibility_invalid") ax = { "nodes": len(ax_nodes), "non_ignored": sum( 1 for node in ax_nodes if isinstance(node, dict) and not node.get("ignored", False) ), "named": sum( 1 for node in ax_nodes if isinstance(node, dict) and isinstance(node.get("name"), dict) and bool(node["name"].get("value")) ), } accessibility = dict(parsed["accessibility"]) accessibility["ax"] = ax try: png = base64.b64decode( cdp.call("Page.captureScreenshot", {"format": "png"})["data"], validate=True, ) except (KeyError, ValueError, TypeError) as exc: raise BrowserError("browser_screenshot_invalid") from exc if not png.startswith(b"\x89PNG\r\n\x1a\n"): raise BrowserError("browser_screenshot_invalid") _write_new(screenshot, png) results.append( ViewportObservation( viewport.id, viewport.width, viewport.height, screenshot.name, _digest(png), len(png), tuple(parsed["images"]), parsed["layout"], accessibility, ) ) observation = RenderObservation( version, origin, tuple([*server.request_snapshot(), *denied]), _console_projection(cdp.events), tuple(results), ) completed = True return observation finally: if cdp is not None: cdp.close() server.shutdown() server.server_close() thread.join(2) if process is not None: try: _terminate_owned_process_group(process) except Exception as exc: # preserve cleanup failure after body success cleanup_error = exc if not completed: for target in targets: try: if target.is_file() and not target.is_symlink(): target.unlink() except OSError: cleanup_error = cleanup_error or BrowserError( "screenshot_cleanup_failed" ) profile.cleanup() if cleanup_error is not None: raise cleanup_error