824 lines
36 KiB
Python
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&¤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;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
|