iop/scripts/agent_benchmark/browser_cdp.py
toki 029ff0d2c8 feat(benchmark): 비교 파이프라인을 완성한다
동일한 IOP 경유 과업을 caller와 model 설정만 바꿔 재현하고, 실패를 포함한 실행·검증·채점 근거를 보존할 수 있어야 한다.
2026-08-12 01:44:26 +09:00

824 lines
36 KiB
Python

"""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;i<controls.length;i++)for(let j=i+1;j<controls.length;j++){let a=controls[i].getBoundingClientRect(),b=controls[j].getBoundingClientRect();if(Math.min(a.right,b.right)>Math.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<value.length;i++){if(value[i]==='(')depth++;else if(value[i]===')')depth--;else if(value[i]===','&&depth===0){result.push(value.slice(start,i));start=i+1;}}result.push(value.slice(start));return result;};
const paintedOutlineChanged=(before,after)=>(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&&current[1]!=='none'&&current[1]!=='hidden'&&paintAlpha(current[2])>0&&current.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;i<headings.length;i++)if(headings[i]>headings[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