mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 07:36:50 +03:00
1640 lines
64 KiB
Rust
1640 lines
64 KiB
Rust
//! Headless Chrome launch and a small Chrome DevTools Protocol client.
|
|
//!
|
|
//! Why hand-written over `tungstenite` rather than `headless_chrome`: the
|
|
//! JS engine's observable behaviour is puppeteer's (its launch flags,
|
|
//! `newPage` setup, `goto` lifecycle semantics, `evaluate` error text,
|
|
//! `screenshot` parameters). `headless_chrome` re-implements those with its
|
|
//! own defaults (different flags, its own navigation wait, a tab abstraction
|
|
//! that hides lifecycle events, and a `fetch` feature that downloads
|
|
//! browsers), so matching puppeteer would mean fighting the crate. The
|
|
//! protocol surface we need is tiny (a dozen methods, six events), and a
|
|
//! flat JSON-RPC layer over one websocket keeps every CDP call visible and
|
|
//! auditable against puppeteer's source.
|
|
//!
|
|
//! Everything here is synchronous: one websocket, one thread; events that
|
|
//! arrive while a command is in flight are queued and drained by the page
|
|
//! state machine after every call.
|
|
|
|
use std::collections::{HashMap, HashSet, VecDeque};
|
|
use std::io::{BufRead, BufReader};
|
|
use std::net::TcpStream;
|
|
use std::path::PathBuf;
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::sync::mpsc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use serde_json::{Map, Value, json};
|
|
use tungstenite::client::IntoClientRequest;
|
|
use tungstenite::protocol::WebSocketConfig;
|
|
use tungstenite::{Message, WebSocket};
|
|
|
|
/// puppeteer's `launch` timeout (`timeout: 30000` in BrowserLauncher).
|
|
const LAUNCH_TIMEOUT: Duration = Duration::from_secs(30);
|
|
/// puppeteer's per-command `protocolTimeout` (180 s).
|
|
const PROTOCOL_TIMEOUT: Duration = Duration::from_secs(180);
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct CdpError {
|
|
pub message: String,
|
|
}
|
|
|
|
impl CdpError {
|
|
pub fn new(message: impl Into<String>) -> Self {
|
|
CdpError {
|
|
message: message.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<String> for CdpError {
|
|
fn from(message: String) -> Self {
|
|
CdpError { message }
|
|
}
|
|
}
|
|
|
|
pub type CdpResult<T> = Result<T, CdpError>;
|
|
|
|
/// puppeteer's `ChromeLauncher.defaultArgs()` (headless, no extensions) plus
|
|
/// the user args, in the order puppeteer emits them. `--remote-debugging-port`
|
|
/// and `--user-data-dir` are appended by the launcher afterwards, as
|
|
/// puppeteer's `computeLaunchArguments` does.
|
|
pub fn default_chrome_args(user_args: &[String], dangerous_no_sandbox: bool) -> Vec<String> {
|
|
let mut args: Vec<String> = [
|
|
"--allow-pre-commit-input",
|
|
"--disable-background-networking",
|
|
"--disable-background-timer-throttling",
|
|
"--disable-backgrounding-occluded-windows",
|
|
"--disable-breakpad",
|
|
"--disable-client-side-phishing-detection",
|
|
"--disable-component-extensions-with-background-pages",
|
|
"--disable-crash-reporter",
|
|
"--disable-default-apps",
|
|
"--disable-dev-shm-usage",
|
|
"--disable-hang-monitor",
|
|
"--disable-infobars",
|
|
"--disable-ipc-flooding-protection",
|
|
"--disable-popup-blocking",
|
|
"--disable-prompt-on-repost",
|
|
"--disable-renderer-backgrounding",
|
|
"--disable-search-engine-choice-screen",
|
|
"--disable-sync",
|
|
"--enable-automation",
|
|
"--export-tagged-pdf",
|
|
"--force-color-profile=srgb",
|
|
"--generate-pdf-document-outline",
|
|
"--metrics-recording-only",
|
|
"--no-first-run",
|
|
"--password-store=basic",
|
|
"--use-mock-keychain",
|
|
"--disable-features=Translate,AcceptCHFrame,MediaRouter,OptimizationHints,WebUIReloadButton,ProcessPerSiteUpToMainFrameThreshold,IsolateSandboxedIframes",
|
|
"--enable-features=PdfOopif",
|
|
]
|
|
.iter()
|
|
.map(|s| s.to_string())
|
|
.collect();
|
|
if dangerous_no_sandbox && !user_args.iter().any(|a| a == "--no-sandbox") {
|
|
args.push("--no-sandbox".to_string());
|
|
}
|
|
args.push("--headless=new".to_string());
|
|
args.push("--hide-scrollbars".to_string());
|
|
args.push("--mute-audio".to_string());
|
|
args.push("--disable-extensions".to_string());
|
|
if user_args.iter().all(|a| a.starts_with('-')) {
|
|
args.push("about:blank".to_string());
|
|
}
|
|
args.extend(user_args.iter().cloned());
|
|
args
|
|
}
|
|
|
|
/// A running headless browser: the process, its temp profile, and the
|
|
/// browser-level CDP connection.
|
|
pub struct Browser {
|
|
child: Child,
|
|
user_data_dir: PathBuf,
|
|
conn: Connection,
|
|
/// Sessions of `worker` targets whose exceptions count as page errors,
|
|
/// keyed by session id → owning page session id.
|
|
worker_owner: HashMap<String, String>,
|
|
}
|
|
|
|
// A timestamp is not a reservation: concurrent calls can observe the same tick.
|
|
// Exclusive mkdir also avoids reusing a stale profile from a previous process.
|
|
fn reserve_profile(parent: &std::path::Path, stamp: u128) -> CdpResult<PathBuf> {
|
|
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
|
for _ in 0..128 {
|
|
let serial = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
let path = parent.join(format!(
|
|
"impeccable_dev_chrome_profile-{}-{stamp}-{serial}",
|
|
std::process::id()
|
|
));
|
|
match std::fs::create_dir(&path) {
|
|
Ok(()) => return Ok(path),
|
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
|
Err(e) => {
|
|
return Err(CdpError::new(format!(
|
|
"Failed to create a temporary browser profile: {e}"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
Err(CdpError::new(
|
|
"Failed to reserve a unique temporary browser profile",
|
|
))
|
|
}
|
|
|
|
impl Browser {
|
|
/// Launch `executable` headless the way puppeteer does and connect to its
|
|
/// DevTools websocket.
|
|
pub fn launch(
|
|
executable: &std::path::Path,
|
|
user_args: &[String],
|
|
dangerous_no_sandbox: bool,
|
|
) -> CdpResult<Browser> {
|
|
let mut args = default_chrome_args(user_args, dangerous_no_sandbox);
|
|
args.push("--remote-debugging-port=0".to_string());
|
|
let stamp = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_nanos())
|
|
.unwrap_or(0);
|
|
let user_data_dir = reserve_profile(&std::env::temp_dir(), stamp)?;
|
|
args.push(format!("--user-data-dir={}", user_data_dir.display()));
|
|
|
|
let mut child = match Command::new(executable)
|
|
.args(&args)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
{
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
let _ = std::fs::remove_dir_all(&user_data_dir);
|
|
return Err(CdpError::new(format!(
|
|
"Failed to launch the browser process! {}: {e}",
|
|
executable.display()
|
|
)));
|
|
}
|
|
};
|
|
let stderr = child.stderr.take();
|
|
let (tx, rx) = mpsc::channel::<String>();
|
|
if let Some(stderr) = stderr {
|
|
// Drain stderr for the life of the process so a chatty browser
|
|
// never blocks on a full pipe; forward lines for endpoint discovery.
|
|
std::thread::spawn(move || {
|
|
let reader = BufReader::new(stderr);
|
|
for line in reader.lines() {
|
|
match line {
|
|
Ok(l) => {
|
|
let _ = tx.send(l);
|
|
}
|
|
Err(_) => break,
|
|
}
|
|
}
|
|
});
|
|
}
|
|
let started = Instant::now();
|
|
let mut collected = String::new();
|
|
let ws_url = loop {
|
|
let remaining = LAUNCH_TIMEOUT.saturating_sub(started.elapsed());
|
|
if remaining.is_zero() {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
let _ = std::fs::remove_dir_all(&user_data_dir);
|
|
return Err(CdpError::new(format!(
|
|
"Timed out after {} ms while waiting for the WS endpoint URL to appear in stdout!",
|
|
LAUNCH_TIMEOUT.as_millis()
|
|
)));
|
|
}
|
|
match rx.recv_timeout(remaining.min(Duration::from_millis(200))) {
|
|
Ok(line) => {
|
|
if let Some(rest) = line.strip_prefix("DevTools listening on ") {
|
|
break rest.trim().to_string();
|
|
}
|
|
collected.push_str(&line);
|
|
collected.push('\n');
|
|
}
|
|
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
|
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
|
let _ = child.wait();
|
|
let _ = std::fs::remove_dir_all(&user_data_dir);
|
|
return Err(CdpError::new(format!(
|
|
"Failed to launch the browser process!{}",
|
|
if collected.trim().is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(" {}", collected.trim())
|
|
}
|
|
)));
|
|
}
|
|
}
|
|
if let Ok(Some(status)) = child.try_wait() {
|
|
// Give the reader a moment to flush the last lines.
|
|
while let Ok(line) = rx.recv_timeout(Duration::from_millis(50)) {
|
|
collected.push_str(&line);
|
|
collected.push('\n');
|
|
}
|
|
let _ = std::fs::remove_dir_all(&user_data_dir);
|
|
return Err(CdpError::new(format!(
|
|
"Failed to launch the browser process! (exit {status}){}",
|
|
if collected.trim().is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(" {}", collected.trim())
|
|
}
|
|
)));
|
|
}
|
|
};
|
|
let conn = match Connection::connect(&ws_url) {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
let _ = std::fs::remove_dir_all(&user_data_dir);
|
|
return Err(e);
|
|
}
|
|
};
|
|
Ok(Browser {
|
|
child,
|
|
user_data_dir,
|
|
conn,
|
|
worker_owner: HashMap::new(),
|
|
})
|
|
}
|
|
|
|
/// `browser.close()`: `Browser.close`, wait for exit, remove the temp
|
|
/// profile.
|
|
pub fn close(mut self) {
|
|
let _ = self
|
|
.conn
|
|
.send(None, "Browser.close", json!({}), Duration::from_secs(5));
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
match self.child.try_wait() {
|
|
Ok(Some(_)) => break,
|
|
Ok(None) if Instant::now() < deadline => {
|
|
std::thread::sleep(Duration::from_millis(25))
|
|
}
|
|
_ => {
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
for _ in 0..10 {
|
|
if std::fs::remove_dir_all(&self.user_data_dir).is_ok() || !self.user_data_dir.exists()
|
|
{
|
|
break;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(100));
|
|
}
|
|
}
|
|
|
|
/// Browser-reported capture environment, independent of page script overrides.
|
|
pub fn version(&mut self) -> CdpResult<Value> {
|
|
self.conn
|
|
.send(None, "Browser.getVersion", json!({}), PROTOCOL_TIMEOUT)
|
|
}
|
|
|
|
/// `browser.newPage()`: a fresh target in the default context, attached
|
|
/// flat, with puppeteer's page setup applied.
|
|
pub fn new_page(&mut self) -> CdpResult<Page<'_>> {
|
|
let created = self.conn.send(
|
|
None,
|
|
"Target.createTarget",
|
|
json!({ "url": "about:blank" }),
|
|
PROTOCOL_TIMEOUT,
|
|
)?;
|
|
let target_id = created
|
|
.get("targetId")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| CdpError::new("Target.createTarget returned no targetId"))?
|
|
.to_string();
|
|
let attached = self.conn.send(
|
|
None,
|
|
"Target.attachToTarget",
|
|
json!({ "targetId": target_id, "flatten": true }),
|
|
PROTOCOL_TIMEOUT,
|
|
)?;
|
|
let session_id = attached
|
|
.get("sessionId")
|
|
.and_then(Value::as_str)
|
|
.ok_or_else(|| CdpError::new("Target.attachToTarget returned no sessionId"))?
|
|
.to_string();
|
|
let mut page = Page {
|
|
browser: self,
|
|
session_id,
|
|
target_id,
|
|
frames: HashMap::new(),
|
|
main_frame_id: String::new(),
|
|
page_errors: Vec::new(),
|
|
swapped: false,
|
|
same_document_navigation: false,
|
|
iframe_sessions: HashSet::new(),
|
|
response_capture: None,
|
|
execution_contexts: HashMap::new(),
|
|
};
|
|
page.initialize()?;
|
|
Ok(page)
|
|
}
|
|
}
|
|
|
|
impl Drop for Browser {
|
|
fn drop(&mut self) {
|
|
// Belt and braces for the error paths that skip `close()`.
|
|
if let Ok(None) = self.child.try_wait() {
|
|
let _ = self.child.kill();
|
|
let _ = self.child.wait();
|
|
}
|
|
let _ = std::fs::remove_dir_all(&self.user_data_dir);
|
|
}
|
|
}
|
|
|
|
/// Origin-scoped basic-auth injection state (JS `applyOriginScopedAuth`,
|
|
/// issue #657): while armed, `Fetch.requestPaused` events on the session are
|
|
/// answered inline with `Fetch.continueRequest`, adding the Authorization
|
|
/// header only for same-origin requests.
|
|
struct OriginAuth {
|
|
session_id: String,
|
|
origin: String,
|
|
header: String,
|
|
}
|
|
|
|
/// One websocket to the browser; JSON-RPC ids and an event queue.
|
|
struct Connection {
|
|
ws: WebSocket<TcpStream>,
|
|
next_id: u64,
|
|
events: VecDeque<Value>,
|
|
auth: Option<OriginAuth>,
|
|
}
|
|
|
|
impl Connection {
|
|
fn connect(ws_url: &str) -> CdpResult<Connection> {
|
|
let parsed = url_host_port(ws_url)
|
|
.ok_or_else(|| CdpError::new(format!("Unexpected DevTools endpoint: {ws_url}")))?;
|
|
let stream = TcpStream::connect(&parsed).map_err(|e| {
|
|
CdpError::new(format!("Could not connect to the browser at {ws_url}: {e}"))
|
|
})?;
|
|
let _ = stream.set_nodelay(true);
|
|
let request = ws_url
|
|
.into_client_request()
|
|
.map_err(|e| CdpError::new(format!("Bad DevTools endpoint {ws_url}: {e}")))?;
|
|
let config = WebSocketConfig::default()
|
|
.max_message_size(Some(256 * 1024 * 1024))
|
|
.max_frame_size(Some(256 * 1024 * 1024));
|
|
let (ws, _response) =
|
|
tungstenite::client::client_with_config(request, stream, Some(config)).map_err(
|
|
|e| CdpError::new(format!("WebSocket handshake with the browser failed: {e}")),
|
|
)?;
|
|
Ok(Connection {
|
|
ws,
|
|
next_id: 0,
|
|
events: VecDeque::new(),
|
|
auth: None,
|
|
})
|
|
}
|
|
|
|
/// JS `page.on('request', ...)` handler body from `applyOriginScopedAuth`:
|
|
/// answer a paused request, attaching Authorization only when the request
|
|
/// URL's origin equals the armed origin. Returns true when the message was
|
|
/// a `Fetch.requestPaused` for the armed session (consumed).
|
|
fn maybe_handle_request_paused(&mut self, msg: &Value) -> bool {
|
|
let Some(auth) = &self.auth else { return false };
|
|
if msg.get("method").and_then(Value::as_str) != Some("Fetch.requestPaused") {
|
|
return false;
|
|
}
|
|
if msg.get("sessionId").and_then(Value::as_str) != Some(auth.session_id.as_str()) {
|
|
return false;
|
|
}
|
|
let (session_id, origin, header) = (
|
|
auth.session_id.clone(),
|
|
auth.origin.clone(),
|
|
auth.header.clone(),
|
|
);
|
|
let params = msg.get("params").cloned().unwrap_or(Value::Null);
|
|
let Some(request_id) = params.get("requestId").and_then(Value::as_str) else {
|
|
return true;
|
|
};
|
|
let req_url = params
|
|
.pointer("/request/url")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
// JS: invalid request URL -> continue without auth.
|
|
let same_origin = url::Url::parse(req_url)
|
|
.map(|u| u.origin().ascii_serialization() == origin)
|
|
.unwrap_or(false);
|
|
let mut cont = Map::new();
|
|
cont.insert("requestId".into(), json!(request_id));
|
|
if same_origin {
|
|
// JS `{ ...request.headers(), authorization: header }`: puppeteer's
|
|
// request.headers() lowercases names, so authorization overrides.
|
|
let mut headers: Vec<Value> = Vec::new();
|
|
if let Some(obj) = params
|
|
.pointer("/request/headers")
|
|
.and_then(Value::as_object)
|
|
{
|
|
for (k, v) in obj {
|
|
let name = k.to_ascii_lowercase();
|
|
if name == "authorization" {
|
|
continue;
|
|
}
|
|
headers.push(json!({ "name": name, "value": v.as_str().unwrap_or("") }));
|
|
}
|
|
}
|
|
headers.push(json!({ "name": "authorization", "value": header }));
|
|
cont.insert("headers".into(), Value::Array(headers));
|
|
}
|
|
// JS `void request.continue(...).catch(() => {})`.
|
|
let _ = self.post(
|
|
Some(&session_id),
|
|
"Fetch.continueRequest",
|
|
Value::Object(cont),
|
|
);
|
|
true
|
|
}
|
|
|
|
/// Write a command and return its id without waiting for the reply.
|
|
fn post(&mut self, session_id: Option<&str>, method: &str, params: Value) -> CdpResult<u64> {
|
|
self.next_id += 1;
|
|
let id = self.next_id;
|
|
let mut msg = Map::new();
|
|
msg.insert("id".into(), json!(id));
|
|
msg.insert("method".into(), json!(method));
|
|
msg.insert("params".into(), params);
|
|
if let Some(sid) = session_id {
|
|
msg.insert("sessionId".into(), json!(sid));
|
|
}
|
|
let text = Value::Object(msg).to_string();
|
|
self.ws
|
|
.send(Message::Text(text.into()))
|
|
.map_err(|e| CdpError::new(format!("Protocol error ({method}): {e}")))?;
|
|
Ok(id)
|
|
}
|
|
|
|
/// Send a command and wait for its result. Events that arrive meanwhile
|
|
/// are queued. A `{ error }` reply becomes `Protocol error (method): message`
|
|
/// (puppeteer's wording).
|
|
fn send(
|
|
&mut self,
|
|
session_id: Option<&str>,
|
|
method: &str,
|
|
params: Value,
|
|
timeout: Duration,
|
|
) -> CdpResult<Value> {
|
|
let id = self.post(session_id, method, params)?;
|
|
let deadline = Instant::now() + timeout;
|
|
loop {
|
|
let msg = match self.read_one(deadline)? {
|
|
Some(m) => m,
|
|
None => {
|
|
return Err(CdpError::new(format!(
|
|
"{method} timed out. Increase the 'protocolTimeout' setting in launch/connect calls for a higher timeout if needed."
|
|
)));
|
|
}
|
|
};
|
|
if msg.get("id").and_then(Value::as_u64) == Some(id) {
|
|
if let Some(err) = msg.get("error") {
|
|
let text = err.get("message").and_then(Value::as_str).unwrap_or("");
|
|
let data = err.get("data").and_then(Value::as_str);
|
|
let full = match data {
|
|
Some(d) if !d.is_empty() => format!("{text} {d}"),
|
|
_ => text.to_string(),
|
|
};
|
|
return Err(CdpError::new(format!("Protocol error ({method}): {full}")));
|
|
}
|
|
return Ok(msg.get("result").cloned().unwrap_or(Value::Null));
|
|
}
|
|
if msg.get("method").is_some() {
|
|
self.events.push_back(msg);
|
|
}
|
|
// Replies to fire-and-forget posts are dropped.
|
|
}
|
|
}
|
|
|
|
/// Read one message, or `None` when `deadline` passes first.
|
|
fn read_one(&mut self, deadline: Instant) -> CdpResult<Option<Value>> {
|
|
loop {
|
|
let remaining = deadline.saturating_duration_since(Instant::now());
|
|
if remaining.is_zero() {
|
|
return Ok(None);
|
|
}
|
|
let _ = self
|
|
.ws
|
|
.get_ref()
|
|
.set_read_timeout(Some(remaining.max(Duration::from_millis(1))));
|
|
match self.ws.read() {
|
|
Ok(Message::Text(t)) => match serde_json::from_str::<Value>(&t) {
|
|
Ok(v) => {
|
|
// Answer paused requests inline so a pending command
|
|
// (goto, evaluate) whose completion depends on them
|
|
// cannot deadlock the single-threaded client.
|
|
if self.maybe_handle_request_paused(&v) {
|
|
continue;
|
|
}
|
|
return Ok(Some(v));
|
|
}
|
|
Err(_) => continue,
|
|
},
|
|
Ok(Message::Close(_)) => {
|
|
return Err(CdpError::new("Protocol error: Target closed"));
|
|
}
|
|
Ok(_) => continue,
|
|
Err(tungstenite::Error::Io(e))
|
|
if matches!(
|
|
e.kind(),
|
|
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
|
|
) =>
|
|
{
|
|
return Ok(None);
|
|
}
|
|
Err(e) => {
|
|
return Err(CdpError::new(format!(
|
|
"Protocol error: connection closed ({e})"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Pull queued events, then any events already readable without waiting.
|
|
fn take_events(&mut self) -> Vec<Value> {
|
|
let mut out: Vec<Value> = self.events.drain(..).collect();
|
|
// Non-blocking sweep of what's already on the wire.
|
|
let deadline = Instant::now() + Duration::from_millis(1);
|
|
while let Ok(Some(msg)) = self.read_one(deadline) {
|
|
if msg.get("method").is_some() {
|
|
out.push(msg);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
fn url_host_port(ws_url: &str) -> Option<String> {
|
|
let rest = ws_url.strip_prefix("ws://")?;
|
|
let host_port = rest.split('/').next()?;
|
|
if host_port.contains(':') {
|
|
Some(host_port.to_string())
|
|
} else {
|
|
Some(format!("{host_port}:80"))
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Debug)]
|
|
struct FrameState {
|
|
parent: Option<String>,
|
|
loader_id: String,
|
|
lifecycle: HashSet<String>,
|
|
has_started_loading: bool,
|
|
}
|
|
|
|
/// puppeteer's `Viewport` subset we use.
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct Viewport {
|
|
pub width: u32,
|
|
pub height: u32,
|
|
}
|
|
|
|
/// A puppeteer `Page`: one attached target with lifecycle tracking, page
|
|
/// error capture, and the evaluate/screenshot helpers the URL engine uses.
|
|
pub struct Page<'a> {
|
|
browser: &'a mut Browser,
|
|
session_id: String,
|
|
target_id: String,
|
|
frames: HashMap<String, FrameState>,
|
|
main_frame_id: String,
|
|
page_errors: Vec<String>,
|
|
swapped: bool,
|
|
same_document_navigation: bool,
|
|
/// Auto-attached OOPIF sessions whose Page events feed the frame map.
|
|
iframe_sessions: HashSet<String>,
|
|
response_capture: Option<crate::response_capture::ResponseCapture>,
|
|
execution_contexts: HashMap<i64, String>,
|
|
}
|
|
|
|
/// An opaque, document-bound isolated execution context. Never falls back to
|
|
/// the page world or a newly navigated document.
|
|
pub struct IsolatedWorld {
|
|
unique_id: String,
|
|
context_id: i64,
|
|
session_id: String,
|
|
frame_id: String,
|
|
loader_id: String,
|
|
}
|
|
|
|
/// A raw `Runtime.evaluate` outcome.
|
|
pub enum EvalOutcome {
|
|
Value(Value),
|
|
/// The page threw: message as puppeteer's `createEvaluationError` renders it.
|
|
Exception(String),
|
|
}
|
|
|
|
impl<'a> Page<'a> {
|
|
fn send(&mut self, method: &str, params: Value) -> CdpResult<Value> {
|
|
let sid = self.session_id.clone();
|
|
let out = self
|
|
.browser
|
|
.conn
|
|
.send(Some(&sid), method, params, PROTOCOL_TIMEOUT);
|
|
self.pump_events();
|
|
out
|
|
}
|
|
|
|
/// Start a fresh diagnostic journal before navigation. Cache, authentication,
|
|
/// and service-worker behavior remain unchanged. No response is refetched.
|
|
pub fn begin_response_capture(&mut self) -> CdpResult<()> {
|
|
self.send(
|
|
"Network.enable",
|
|
json!({
|
|
"maxTotalBufferSize": crate::response_capture::MAX_TOTAL,
|
|
"maxResourceBufferSize": crate::response_capture::MAX_BODY
|
|
}),
|
|
)?;
|
|
self.response_capture = Some(crate::response_capture::ResponseCapture::default());
|
|
Ok(())
|
|
}
|
|
|
|
/// All requests observed for the current document, including failed/missing dependencies.
|
|
pub fn observed_response_urls(&mut self) -> CdpResult<Vec<String>> {
|
|
self.pump_events();
|
|
let capture=self.response_capture.as_ref().ok_or_else(||CdpError::new("response capture is not enabled"))?;
|
|
let loader=self.frames.get(&self.main_frame_id).map(|f|f.loader_id.as_str()).unwrap_or("");
|
|
Ok(capture.urls(&self.main_frame_id,loader))
|
|
}
|
|
|
|
/// Retrieve observed main-document response payloads for exact URLs.
|
|
/// This is transport evidence only: it does not prove rendered use or fidelity.
|
|
pub fn response_evidence(
|
|
&mut self,
|
|
urls: &[String],
|
|
) -> CdpResult<crate::response_capture::ResponseEvidence> {
|
|
self.pump_events();
|
|
let capture = self
|
|
.response_capture
|
|
.as_ref()
|
|
.ok_or_else(|| CdpError::new("response capture is not enabled"))?;
|
|
let frame = self.main_frame_id.clone();
|
|
let loader = self
|
|
.frames
|
|
.get(&frame)
|
|
.map(|f| f.loader_id.clone())
|
|
.unwrap_or_default();
|
|
let revision = capture.revision;
|
|
let pending = capture.pending(urls, &frame, &loader);
|
|
for (index, request_id) in pending {
|
|
let body = self
|
|
.send("Network.getResponseBody", json!({"requestId": request_id}))
|
|
.map_err(|e| e.message);
|
|
self.response_capture
|
|
.as_mut()
|
|
.unwrap()
|
|
.store_body(index, body);
|
|
}
|
|
Ok(self
|
|
.response_capture
|
|
.as_ref()
|
|
.unwrap()
|
|
.evidence(urls, &frame, &loader, revision))
|
|
}
|
|
|
|
/// puppeteer's `CdpPage` + `FrameManager.initialize` for a new target.
|
|
fn initialize(&mut self) -> CdpResult<()> {
|
|
self.send("Page.enable", json!({}))?;
|
|
let tree = self.send("Page.getFrameTree", json!({}))?;
|
|
if let Some(ft) = tree.get("frameTree") {
|
|
self.handle_frame_tree(ft, None);
|
|
}
|
|
self.send("Page.setLifecycleEventsEnabled", json!({ "enabled": true }))?;
|
|
// URL mode injects only the snapshot producer (plain JS) and runs the
|
|
// rules natively in this process over `SnapshotDom` (triage D2; see
|
|
// WASM-BUNDLE.md in the detector repo): no WebAssembly is compiled next
|
|
// to the page, so the page's Content-Security-Policy no longer gates
|
|
// the scan and `Page.setBypassCSP` is not needed. Leaving CSP enforced keeps the
|
|
// scan passive — a strict-CSP page's blocked inline scripts stay
|
|
// blocked, matching the puppeteer engine (which never bypassed CSP).
|
|
self.send("Runtime.enable", json!({}))?;
|
|
self.send("Network.enable", json!({}))?;
|
|
self.send("Performance.enable", json!({}))?;
|
|
self.send("Log.enable", json!({}))?;
|
|
self.send(
|
|
"Target.setAutoAttach",
|
|
json!({ "autoAttach": true, "waitForDebuggerOnStart": true, "flatten": true }),
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn handle_frame_tree(&mut self, tree: &Value, parent: Option<String>) {
|
|
let Some(frame) = tree.get("frame") else {
|
|
return;
|
|
};
|
|
let Some(id) = frame.get("id").and_then(Value::as_str) else {
|
|
return;
|
|
};
|
|
let id = id.to_string();
|
|
let parent_id = frame
|
|
.get("parentId")
|
|
.and_then(Value::as_str)
|
|
.map(|s| s.to_string())
|
|
.or(parent);
|
|
if parent_id.is_none() && self.main_frame_id.is_empty() {
|
|
self.main_frame_id = id.clone();
|
|
}
|
|
let entry = self.frames.entry(id.clone()).or_default();
|
|
entry.parent = parent_id;
|
|
if let Some(children) = tree.get("childFrames").and_then(Value::as_array) {
|
|
for child in children {
|
|
self.handle_frame_tree(child, Some(id.clone()));
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Drain queued CDP events into page state (frames, errors, auto-attach).
|
|
fn pump_events(&mut self) {
|
|
let events = self.browser.conn.take_events();
|
|
for ev in events {
|
|
self.on_event(&ev);
|
|
}
|
|
}
|
|
|
|
fn on_event(&mut self, ev: &Value) {
|
|
let method = ev.get("method").and_then(Value::as_str).unwrap_or("");
|
|
let session = ev.get("sessionId").and_then(Value::as_str).unwrap_or("");
|
|
let params = ev.get("params").cloned().unwrap_or(Value::Null);
|
|
if session == self.session_id {
|
|
match method {
|
|
"Runtime.executionContextCreated" => {
|
|
if let (Some(id), Some(unique)) = (
|
|
params.pointer("/context/id").and_then(Value::as_i64),
|
|
params.pointer("/context/uniqueId").and_then(Value::as_str),
|
|
) {
|
|
if self.execution_contexts.len() < 512 {
|
|
self.execution_contexts.insert(id, unique.to_owned());
|
|
}
|
|
}
|
|
}
|
|
"Runtime.executionContextDestroyed" => {
|
|
if let Some(id) = params.get("executionContextId").and_then(Value::as_i64) {
|
|
self.execution_contexts.remove(&id);
|
|
}
|
|
}
|
|
"Runtime.executionContextsCleared" => self.execution_contexts.clear(),
|
|
_ => {}
|
|
}
|
|
if let Some(capture) = &mut self.response_capture {
|
|
capture.event(method, ¶ms);
|
|
}
|
|
}
|
|
let is_page_session = session == self.session_id || self.iframe_sessions.contains(session);
|
|
match method {
|
|
"Target.attachedToTarget" => {
|
|
let Some(new_session) = params.get("sessionId").and_then(Value::as_str) else {
|
|
return;
|
|
};
|
|
let new_session = new_session.to_string();
|
|
let ttype = params
|
|
.pointer("/targetInfo/type")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
let waiting = params
|
|
.get("waitingForDebugger")
|
|
.and_then(Value::as_bool)
|
|
.unwrap_or(false);
|
|
// Only targets hanging off this page (or its iframes) matter.
|
|
if !is_page_session {
|
|
if waiting {
|
|
let _ = self.browser.conn.post(
|
|
Some(&new_session),
|
|
"Runtime.runIfWaitingForDebugger",
|
|
json!({}),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
// Fire-and-forget setup, in order, so the target is configured
|
|
// before it resumes (puppeteer's FrameManager / WebWorker init).
|
|
let conn = &mut self.browser.conn;
|
|
match ttype {
|
|
"iframe" => {
|
|
self.iframe_sessions.insert(new_session.clone());
|
|
let _ = conn.post(Some(&new_session), "Page.enable", json!({}));
|
|
let _ = conn.post(
|
|
Some(&new_session),
|
|
"Page.setLifecycleEventsEnabled",
|
|
json!({ "enabled": true }),
|
|
);
|
|
let _ = conn.post(Some(&new_session), "Runtime.enable", json!({}));
|
|
let _ = conn.post(Some(&new_session), "Network.enable", json!({}));
|
|
}
|
|
"worker" => {
|
|
self.browser
|
|
.worker_owner
|
|
.insert(new_session.clone(), self.session_id.clone());
|
|
let _ = conn.post(Some(&new_session), "Runtime.enable", json!({}));
|
|
}
|
|
_ => {}
|
|
}
|
|
let _ = conn.post(
|
|
Some(&new_session),
|
|
"Target.setAutoAttach",
|
|
json!({ "autoAttach": true, "waitForDebuggerOnStart": true, "flatten": true }),
|
|
);
|
|
if waiting {
|
|
let _ = conn.post(
|
|
Some(&new_session),
|
|
"Runtime.runIfWaitingForDebugger",
|
|
json!({}),
|
|
);
|
|
}
|
|
}
|
|
"Target.detachedFromTarget" => {
|
|
if let Some(sid) = params.get("sessionId").and_then(Value::as_str) {
|
|
self.iframe_sessions.remove(sid);
|
|
self.browser.worker_owner.remove(sid);
|
|
}
|
|
}
|
|
"Runtime.exceptionThrown" => {
|
|
let counts = session == self.session_id
|
|
|| self
|
|
.browser
|
|
.worker_owner
|
|
.get(session)
|
|
.map(|owner| *owner == self.session_id)
|
|
.unwrap_or(false);
|
|
if counts {
|
|
if let Some(details) = params.get("exceptionDetails") {
|
|
let message = client_error_message(details);
|
|
// detect-url.mjs: first line, trimmed, 160 chars, deduped.
|
|
let first = message.split('\n').next().unwrap_or("");
|
|
let trimmed = impeccable_core::js::trim(first);
|
|
let sliced: String = trimmed.chars().take(160).collect();
|
|
if !sliced.is_empty() && !self.page_errors.contains(&sliced) {
|
|
self.page_errors.push(sliced);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ if !is_page_session => {}
|
|
"Page.frameAttached" => {
|
|
let (Some(id), Some(parent)) = (
|
|
params.get("frameId").and_then(Value::as_str),
|
|
params.get("parentFrameId").and_then(Value::as_str),
|
|
) else {
|
|
return;
|
|
};
|
|
if !self.frames.contains_key(id) {
|
|
self.frames.insert(
|
|
id.to_string(),
|
|
FrameState {
|
|
parent: Some(parent.to_string()),
|
|
..Default::default()
|
|
},
|
|
);
|
|
}
|
|
}
|
|
"Page.frameDetached" => {
|
|
let Some(id) = params.get("frameId").and_then(Value::as_str) else {
|
|
return;
|
|
};
|
|
let reason = params
|
|
.get("reason")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("remove");
|
|
if reason == "swap" {
|
|
if id == self.main_frame_id {
|
|
self.swapped = true;
|
|
}
|
|
} else {
|
|
self.remove_frame_recursively(id);
|
|
}
|
|
}
|
|
"Page.frameNavigated" => {
|
|
let Some(frame) = params.get("frame") else {
|
|
return;
|
|
};
|
|
let Some(id) = frame.get("id").and_then(Value::as_str) else {
|
|
return;
|
|
};
|
|
let is_main = frame.get("parentId").and_then(Value::as_str).is_none();
|
|
if is_main && id != self.main_frame_id {
|
|
// Main frame id changed (cross-process swap): carry state over.
|
|
let old = self.frames.remove(&self.main_frame_id).unwrap_or_default();
|
|
self.frames.insert(id.to_string(), old);
|
|
self.main_frame_id = id.to_string();
|
|
} else if !self.frames.contains_key(id) {
|
|
self.frames.insert(
|
|
id.to_string(),
|
|
FrameState {
|
|
parent: frame
|
|
.get("parentId")
|
|
.and_then(Value::as_str)
|
|
.map(String::from),
|
|
..Default::default()
|
|
},
|
|
);
|
|
}
|
|
let ntype = params
|
|
.get("type")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("Navigation");
|
|
if ntype == "BackForwardCacheRestore" {
|
|
self.swapped = true;
|
|
}
|
|
}
|
|
"Page.navigatedWithinDocument" => {
|
|
if params.get("frameId").and_then(Value::as_str)
|
|
== Some(self.main_frame_id.as_str())
|
|
{
|
|
self.same_document_navigation = true;
|
|
}
|
|
}
|
|
"Page.frameStartedLoading" => {
|
|
if let Some(id) = params.get("frameId").and_then(Value::as_str) {
|
|
if let Some(f) = self.frames.get_mut(id) {
|
|
f.has_started_loading = true;
|
|
}
|
|
}
|
|
}
|
|
"Page.frameStoppedLoading" => {
|
|
if let Some(id) = params.get("frameId").and_then(Value::as_str) {
|
|
if let Some(f) = self.frames.get_mut(id) {
|
|
f.lifecycle.insert("DOMContentLoaded".into());
|
|
f.lifecycle.insert("load".into());
|
|
}
|
|
}
|
|
}
|
|
"Page.lifecycleEvent" => {
|
|
let (Some(id), Some(name)) = (
|
|
params.get("frameId").and_then(Value::as_str),
|
|
params.get("name").and_then(Value::as_str),
|
|
) else {
|
|
return;
|
|
};
|
|
let loader = params.get("loaderId").and_then(Value::as_str).unwrap_or("");
|
|
if let Some(f) = self.frames.get_mut(id) {
|
|
if name == "init" {
|
|
f.loader_id = loader.to_string();
|
|
f.lifecycle.clear();
|
|
}
|
|
f.lifecycle.insert(name.to_string());
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn remove_frame_recursively(&mut self, id: &str) {
|
|
let children: Vec<String> = self
|
|
.frames
|
|
.iter()
|
|
.filter(|(_, f)| f.parent.as_deref() == Some(id))
|
|
.map(|(k, _)| k.clone())
|
|
.collect();
|
|
for c in children {
|
|
self.remove_frame_recursively(&c);
|
|
}
|
|
self.frames.remove(id);
|
|
}
|
|
|
|
/// LifecycleWatcher#checkLifecycle over the frame tree.
|
|
fn lifecycle_complete(&self, frame_id: &str, expected: &str) -> bool {
|
|
let Some(frame) = self.frames.get(frame_id) else {
|
|
return false;
|
|
};
|
|
if !frame.lifecycle.contains(expected) {
|
|
return false;
|
|
}
|
|
for (id, child) in &self.frames {
|
|
if child.parent.as_deref() == Some(frame_id)
|
|
&& child.has_started_loading
|
|
&& !self.lifecycle_complete(id, expected)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Explicit screenshot-environment preference; never injected as page CSS.
|
|
pub fn set_reduced_motion(&mut self, reduce: bool) -> CdpResult<()> {
|
|
self.send("Emulation.setEmulatedMedia", json!({"features": [{
|
|
"name": "prefers-reduced-motion", "value": if reduce { "reduce" } else { "no-preference" }
|
|
}]}))?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn set_viewport(&mut self, viewport: Viewport) -> CdpResult<()> {
|
|
self.send(
|
|
"Emulation.setDeviceMetricsOverride",
|
|
json!({
|
|
"mobile": false,
|
|
"width": viewport.width,
|
|
"height": viewport.height,
|
|
"deviceScaleFactor": 1,
|
|
"screenOrientation": { "angle": 0, "type": "portraitPrimary" },
|
|
}),
|
|
)?;
|
|
self.send(
|
|
"Emulation.setTouchEmulationEnabled",
|
|
json!({ "enabled": false }),
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// JS detect-url.mjs#applyOriginScopedAuth(page, href, credentials):
|
|
/// page.authenticate is page-wide (a cross-origin redirect that then 401s
|
|
/// would receive the credentials), so Authorization is attached only to
|
|
/// requests on the scan origin, via Fetch interception.
|
|
pub fn apply_origin_scoped_auth(
|
|
&mut self,
|
|
href: &str,
|
|
username: &str,
|
|
password: &str,
|
|
) -> CdpResult<()> {
|
|
let Ok(parsed) = url::Url::parse(href) else {
|
|
return Ok(());
|
|
};
|
|
let origin = parsed.origin().ascii_serialization();
|
|
if origin.is_empty() {
|
|
return Ok(());
|
|
}
|
|
// JS `basicAuthHeader(credentials)`.
|
|
use base64::Engine as _;
|
|
let header = format!(
|
|
"Basic {}",
|
|
base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}"))
|
|
);
|
|
// JS `page.setRequestInterception(true)`.
|
|
self.send("Fetch.enable", json!({}))?;
|
|
self.browser.conn.auth = Some(OriginAuth {
|
|
session_id: self.session_id.clone(),
|
|
origin,
|
|
header,
|
|
});
|
|
Ok(())
|
|
}
|
|
|
|
/// `page.goto(url, { waitUntil, timeout })`. `wait_until` is puppeteer's
|
|
/// name (`load`, `domcontentloaded`, `networkidle0`, `networkidle2`).
|
|
pub fn goto(&mut self, url: &str, wait_until: &str, timeout: Duration) -> CdpResult<()> {
|
|
let expected = match wait_until {
|
|
"load" => "load",
|
|
"domcontentloaded" => "DOMContentLoaded",
|
|
"networkidle0" => "networkIdle",
|
|
"networkidle2" => "networkAlmostIdle",
|
|
other => {
|
|
return Err(CdpError::new(format!(
|
|
"Unknown value for options.waitUntil: {other}"
|
|
)));
|
|
}
|
|
};
|
|
self.pump_events();
|
|
let initial_loader = self
|
|
.frames
|
|
.get(&self.main_frame_id)
|
|
.map(|f| f.loader_id.clone())
|
|
.unwrap_or_default();
|
|
self.swapped = false;
|
|
self.same_document_navigation = false;
|
|
let deadline = Instant::now() + timeout;
|
|
let timeout_msg = format!("Navigation timeout of {} ms exceeded", timeout.as_millis());
|
|
|
|
let sid = self.session_id.clone();
|
|
let frame_id = self.main_frame_id.clone();
|
|
let nav_id = self.browser.conn.post(
|
|
Some(&sid),
|
|
"Page.navigate",
|
|
json!({ "url": url, "frameId": frame_id }),
|
|
)?;
|
|
// Race the navigate reply against the lifecycle watcher's timeout.
|
|
let ensure_new_document;
|
|
loop {
|
|
let msg = match self.browser.conn.read_one(deadline)? {
|
|
Some(m) => m,
|
|
None => return Err(CdpError::new(timeout_msg)),
|
|
};
|
|
if msg.get("id").and_then(Value::as_u64) == Some(nav_id) {
|
|
if let Some(err) = msg.get("error") {
|
|
let text = err.get("message").and_then(Value::as_str).unwrap_or("");
|
|
return Err(CdpError::new(format!(
|
|
"Protocol error (Page.navigate): {text}"
|
|
)));
|
|
}
|
|
let result = msg.get("result").cloned().unwrap_or(Value::Null);
|
|
ensure_new_document = result
|
|
.get("loaderId")
|
|
.and_then(Value::as_str)
|
|
.map(|s| !s.is_empty())
|
|
.unwrap_or(false);
|
|
if let Some(text) = result.get("errorText").and_then(Value::as_str) {
|
|
if !text.is_empty() && text != "net::ERR_HTTP_RESPONSE_CODE_FAILURE" {
|
|
return Err(CdpError::new(format!("{text} at {url}")));
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
if msg.get("method").is_some() {
|
|
self.on_event(&msg);
|
|
if !self.frames.contains_key(&self.main_frame_id) {
|
|
return Err(CdpError::new("Navigating frame was detached"));
|
|
}
|
|
}
|
|
}
|
|
// Wait for the lifecycle + (new-document | same-document) condition.
|
|
loop {
|
|
self.pump_events();
|
|
if !self.frames.contains_key(&self.main_frame_id) {
|
|
return Err(CdpError::new("Navigating frame was detached"));
|
|
}
|
|
let lifecycle_ok = self.lifecycle_complete(&self.main_frame_id.clone(), expected);
|
|
if lifecycle_ok {
|
|
let loader_changed = self
|
|
.frames
|
|
.get(&self.main_frame_id)
|
|
.map(|f| f.loader_id != initial_loader)
|
|
.unwrap_or(false);
|
|
let new_doc = self.swapped || loader_changed;
|
|
if ensure_new_document {
|
|
if new_doc {
|
|
return Ok(());
|
|
}
|
|
} else if self.same_document_navigation {
|
|
return Ok(());
|
|
}
|
|
}
|
|
match self.browser.conn.read_one(deadline)? {
|
|
Some(msg) => {
|
|
if msg.get("method").is_some() {
|
|
self.on_event(&msg);
|
|
}
|
|
}
|
|
None => return Err(CdpError::new(timeout_msg)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create an opt-in world with separate JS globals/prototypes, on the current
|
|
/// main document. No universal cross-origin access is granted.
|
|
pub fn create_isolated_world(&mut self) -> CdpResult<IsolatedWorld> {
|
|
self.pump_events();
|
|
let frame_id = self.main_frame_id.clone();
|
|
let loader_id = self
|
|
.frames
|
|
.get(&frame_id)
|
|
.map(|f| f.loader_id.clone())
|
|
.unwrap_or_default();
|
|
if frame_id.is_empty() || loader_id.is_empty() {
|
|
return Err(CdpError::new("no current document for isolated capture"));
|
|
}
|
|
let created=self.send("Page.createIsolatedWorld",json!({"frameId":frame_id,"worldName":"impeccable-diagnostic-capture","grantUniveralAccess":false}))?;
|
|
let id = created["executionContextId"]
|
|
.as_i64()
|
|
.ok_or_else(|| CdpError::new("isolated world returned no context id"))?;
|
|
let unique_id = self
|
|
.execution_contexts
|
|
.get(&id)
|
|
.cloned()
|
|
.ok_or_else(|| CdpError::new("isolated context identity unavailable"))?;
|
|
let world = IsolatedWorld {
|
|
unique_id,
|
|
context_id: id,
|
|
session_id: self.session_id.clone(),
|
|
frame_id,
|
|
loader_id,
|
|
};
|
|
self.validate_world(&world)?;
|
|
Ok(world)
|
|
}
|
|
|
|
/// Inspect native DOM coverage, including authored closed shadow roots that
|
|
/// page-world selectors cannot see. This never treats hidden trees as absent.
|
|
pub fn capture_dom_coverage(&mut self, world: &IsolatedWorld) -> CdpResult<Value> {
|
|
self.validate_world(world)?;
|
|
let tree = self.send("DOM.getDocument", json!({"depth":-1,"pierce":true}))?;
|
|
self.validate_world(world)?;
|
|
let root = tree
|
|
.get("root")
|
|
.ok_or_else(|| CdpError::new("native DOM coverage unavailable"))?;
|
|
let mut stack = vec![root];
|
|
let mut nodes = 0;
|
|
let mut closed = 0;
|
|
while let Some(node) = stack.pop() {
|
|
nodes += 1;
|
|
if nodes > 5000 {
|
|
return Err(CdpError::new("native capture DOM exceeds 5000 nodes"));
|
|
}
|
|
if node.get("shadowRootType").and_then(Value::as_str) == Some("closed") {
|
|
closed += 1;
|
|
}
|
|
for key in ["children", "shadowRoots", "pseudoElements"] {
|
|
if let Some(children) = node.get(key).and_then(Value::as_array) {
|
|
stack.extend(children);
|
|
}
|
|
}
|
|
for key in ["contentDocument", "templateContent"] {
|
|
if let Some(child) = node.get(key).filter(|v| v.is_object()) {
|
|
stack.push(child);
|
|
}
|
|
}
|
|
}
|
|
Ok(json!({"inspectedNodes":nodes,"closedShadowRoots":closed}))
|
|
}
|
|
|
|
/// Inspector-owned stylesheet; never a page-authored style element.
|
|
pub fn create_capture_stylesheet(&mut self, world: &IsolatedWorld) -> CdpResult<String> {
|
|
self.validate_world(world)?;
|
|
self.send("DOM.enable", json!({}))?;
|
|
self.send("CSS.enable", json!({}))?;
|
|
let value = self.send("CSS.createStyleSheet", json!({"frameId":world.frame_id}))?;
|
|
self.validate_world(world)?;
|
|
value["styleSheetId"].as_str().map(str::to_owned)
|
|
.ok_or_else(|| CdpError::new("capture stylesheet unavailable"))
|
|
}
|
|
|
|
pub fn set_capture_stylesheet(&mut self, world: &IsolatedWorld, sheet: &str, text: &str) -> CdpResult<()> {
|
|
self.validate_world(world)?;
|
|
if text.len() > 65536 { return Err(CdpError::new("capture stylesheet exceeds bound")); }
|
|
self.send("CSS.setStyleSheetText", json!({"styleSheetId":sheet,"text":text}))?;
|
|
self.validate_world(world)
|
|
}
|
|
|
|
/// Main-document generated boxes from native layout, indexed in querySelectorAll order.
|
|
/// Do not infer pseudo geometry from its owner's rectangle.
|
|
pub fn capture_pseudo_geometry(&mut self, world: &IsolatedWorld, retained: &[Value]) -> CdpResult<Value> {
|
|
self.validate_world(world)?;
|
|
// Text-only generated boxes need no image geometry. Query candidates in
|
|
// the isolated world before the bounded native layout lookup.
|
|
let candidates = self.evaluate_value_in_world(world,
|
|
"[...document.querySelectorAll('*')].flatMap((el,index)=>['before','after'].filter(p=>{const s=getComputedStyle(el,'::'+p);return s.backgroundImage!=='none'||s.content.includes('url(')}).map(pseudo=>({index,pseudo})))")?;
|
|
let mut candidates = candidates.as_array().ok_or_else(|| CdpError::new("pseudo candidates unavailable"))?.clone();
|
|
// Suppression can remove a pseudo's only URL. Keep its identity eligible
|
|
// for a fresh native geometry lookup; never reuse its previous box.
|
|
for candidate in retained {
|
|
if !candidates.contains(candidate) { candidates.push(candidate.clone()); }
|
|
}
|
|
if candidates.len()>256 {return Err(CdpError::new("capture pseudo image candidates exceed bound"));}
|
|
if candidates.is_empty() {return Ok(json!([]));}
|
|
let tree = self.send("DOM.getDocument", json!({"depth":-1}))?;
|
|
let mut stack = vec![(tree["root"].clone(), String::new())];
|
|
let mut index = 0usize;
|
|
let mut pseudos = Vec::new();
|
|
while let Some((node, selector)) = stack.pop() {
|
|
let owner = index;
|
|
if node["nodeType"] == 1 { index += 1; }
|
|
if index > 5000 { return Err(CdpError::new("capture DOM exceeds bound")); }
|
|
if let Some(items) = node["pseudoElements"].as_array() {
|
|
for pseudo in items {
|
|
if !matches!(pseudo["pseudoType"].as_str(), Some("before" | "after")) { continue; }
|
|
if !candidates.iter().any(|c|c["index"].as_u64()==Some(owner as u64)&&c["pseudo"]==pseudo["pseudoType"]) {continue;}
|
|
if pseudos.len() >= 256 { return Err(CdpError::new("capture pseudo elements exceed bound")); }
|
|
let layout = self.send("DOM.getBoxModel", json!({"nodeId":pseudo["nodeId"]}));
|
|
let bounds = layout.ok().and_then(|v| {
|
|
let q = v["model"]["border"].as_array()?;
|
|
if q.len()!=8 {return None;}
|
|
let values: Option<Vec<f64>>=q.iter().map(Value::as_f64).collect();
|
|
let values=values?;
|
|
let xs=[values[0],values[2],values[4],values[6]];
|
|
let ys=[values[1],values[3],values[5],values[7]];
|
|
let x=xs.into_iter().fold(f64::INFINITY,f64::min);
|
|
let y=ys.into_iter().fold(f64::INFINITY,f64::min);
|
|
Some(json!({"x":x,"y":y,"w":xs.into_iter().fold(f64::NEG_INFINITY,f64::max)-x,"h":ys.into_iter().fold(f64::NEG_INFINITY,f64::max)-y}))
|
|
});
|
|
pseudos.push(json!({"index":owner,"pseudo":format!("::{}",pseudo["pseudoType"].as_str().unwrap()),"selector":selector,"backendNodeId":pseudo["backendNodeId"],"box":bounds}));
|
|
}
|
|
}
|
|
if let Some(children)=node["children"].as_array() {
|
|
let elements: Vec<_>=children.iter().filter(|n|n["nodeType"]==1).collect();
|
|
for (i,child) in elements.into_iter().enumerate().rev() {
|
|
let path=if selector.is_empty(){":root".to_owned()}else{format!("{selector}>:nth-child({})",i+1)};
|
|
stack.push((child.clone(),path));
|
|
}
|
|
}
|
|
}
|
|
self.validate_world(world)?;
|
|
Ok(json!(pseudos))
|
|
}
|
|
|
|
fn validate_world(&mut self, world: &IsolatedWorld) -> CdpResult<()> {
|
|
self.pump_events();
|
|
if self.session_id != world.session_id
|
|
|| self.main_frame_id != world.frame_id
|
|
|| self
|
|
.frames
|
|
.get(&world.frame_id)
|
|
.map(|f| f.loader_id.as_str())
|
|
!= Some(world.loader_id.as_str())
|
|
|| self.execution_contexts.get(&world.context_id) != Some(&world.unique_id)
|
|
{
|
|
return Err(CdpError::new(
|
|
"isolated capture context was destroyed or document changed",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn evaluate_value_in_world(
|
|
&mut self,
|
|
world: &IsolatedWorld,
|
|
expression: &str,
|
|
) -> CdpResult<Value> {
|
|
self.validate_world(world)?;
|
|
let result = self.evaluate_with_context(expression, Some(&world.unique_id))?;
|
|
self.validate_world(world)?;
|
|
match result {
|
|
EvalOutcome::Value(v) => Ok(v),
|
|
EvalOutcome::Exception(message) => Err(CdpError::new(message)),
|
|
}
|
|
}
|
|
|
|
/// `page.evaluate(<expression string>)`: `Runtime.evaluate` with
|
|
/// `awaitPromise` + `returnByValue`, in the main world.
|
|
pub fn evaluate(&mut self, expression: &str) -> CdpResult<EvalOutcome> {
|
|
self.evaluate_with_context(expression, None)
|
|
}
|
|
|
|
fn evaluate_with_context(
|
|
&mut self,
|
|
expression: &str,
|
|
unique_context: Option<&str>,
|
|
) -> CdpResult<EvalOutcome> {
|
|
let mut params = json!({"expression":expression,"returnByValue":true,"awaitPromise":true,"userGesture":true});
|
|
if let Some(id) = unique_context {
|
|
params["uniqueContextId"] = json!(id);
|
|
}
|
|
let res = self.send("Runtime.evaluate", params);
|
|
let res = match res {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
// ExecutionContext#rewriteError
|
|
if e.message.contains("Object reference chain is too long")
|
|
|| e.message.contains("Object couldn't be returned by value")
|
|
{
|
|
return Ok(EvalOutcome::Value(Value::Null));
|
|
}
|
|
if e.message.ends_with("Cannot find context with specified id")
|
|
|| e.message.ends_with("Inspected target navigated or closed")
|
|
{
|
|
return Err(CdpError::new(
|
|
"Execution context was destroyed, most likely because of a navigation.",
|
|
));
|
|
}
|
|
return Err(e);
|
|
}
|
|
};
|
|
if let Some(details) = res.get("exceptionDetails") {
|
|
return Ok(EvalOutcome::Exception(client_error_message(details)));
|
|
}
|
|
let remote = res.get("result").cloned().unwrap_or(Value::Null);
|
|
Ok(EvalOutcome::Value(value_from_remote_object(&remote)))
|
|
}
|
|
|
|
/// `page.evaluate` that treats a page exception as an engine error, the
|
|
/// way `await page.evaluate(...)` throws in the JS.
|
|
pub fn evaluate_value(&mut self, expression: &str) -> CdpResult<Value> {
|
|
match self.evaluate(expression)? {
|
|
EvalOutcome::Value(v) => Ok(v),
|
|
EvalOutcome::Exception(message) => Err(CdpError::new(message)),
|
|
}
|
|
}
|
|
|
|
/// Preserve transparency when capturing isolated component paint.
|
|
pub fn set_transparent_background(&mut self) -> CdpResult<()> {
|
|
self.send("Emulation.setDefaultBackgroundColorOverride", json!({"color":{"r":0,"g":0,"b":0,"a":0}}))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Capture the current viewport without Chromium's beyond-viewport resize.
|
|
/// Use for observations that must not trigger responsive source selection.
|
|
pub fn screenshot_viewport(&mut self) -> CdpResult<String> {
|
|
let res = self.send(
|
|
"Page.captureScreenshot",
|
|
json!({
|
|
"format": "png", "optimizeForSpeed": false,
|
|
"fromSurface": true, "captureBeyondViewport": false,
|
|
}),
|
|
)?;
|
|
res.get("data")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_owned)
|
|
.ok_or_else(|| CdpError::new("viewport screenshot returned no PNG"))
|
|
}
|
|
|
|
/// `page.screenshot({ encoding: 'base64', clip, captureBeyondViewport: true })`.
|
|
/// Returns base64 PNG data.
|
|
pub fn screenshot_clip(
|
|
&mut self,
|
|
x: f64,
|
|
y: f64,
|
|
width: f64,
|
|
height: f64,
|
|
) -> CdpResult<String> {
|
|
// Page.screenshot: roundRectangle(normalizeRectangle(clip)).
|
|
let (x, width) = if width < 0.0 {
|
|
(x + width, -width)
|
|
} else {
|
|
(x, width)
|
|
};
|
|
let (y, height) = if height < 0.0 {
|
|
(y + height, -height)
|
|
} else {
|
|
(y, height)
|
|
};
|
|
let round = |v: f64| impeccable_core::js::math_round(v);
|
|
let res = self.send(
|
|
"Page.captureScreenshot",
|
|
json!({
|
|
"format": "png",
|
|
"optimizeForSpeed": false,
|
|
"fromSurface": true,
|
|
"clip": { "x": round(x), "y": round(y), "width": round(width), "height": round(height), "scale": 1 },
|
|
"captureBeyondViewport": true,
|
|
}),
|
|
)?;
|
|
Ok(res
|
|
.get("data")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_string())
|
|
}
|
|
|
|
/// The deduped `pageerror` messages so far.
|
|
pub fn page_errors(&mut self) -> Vec<String> {
|
|
self.pump_events();
|
|
self.page_errors.clone()
|
|
}
|
|
|
|
/// `page.close()`: `Target.closeTarget`, errors swallowed like the JS
|
|
/// `page.close().catch(() => {})`.
|
|
pub fn close(self) {
|
|
let target_id = self.target_id.clone();
|
|
let _ = self.browser.conn.send(
|
|
None,
|
|
"Target.closeTarget",
|
|
json!({ "targetId": target_id }),
|
|
Duration::from_secs(10),
|
|
);
|
|
// Detached sessions may keep queued events; drop anything stale.
|
|
self.browser.conn.events.clear();
|
|
// Interception (origin-scoped auth) dies with the page; a shared
|
|
// browser's next page must not inherit it.
|
|
if self
|
|
.browser
|
|
.conn
|
|
.auth
|
|
.as_ref()
|
|
.map(|a| a.session_id == self.session_id)
|
|
.unwrap_or(false)
|
|
{
|
|
self.browser.conn.auth = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// puppeteer `valueFromPrimitiveRemoteObject` for `returnByValue` results.
|
|
fn value_from_remote_object(remote: &Value) -> Value {
|
|
if let Some(unser) = remote.get("unserializableValue").and_then(Value::as_str) {
|
|
return match unser {
|
|
"-0" => json!(-0.0),
|
|
"NaN" | "Infinity" | "-Infinity" => Value::Null,
|
|
other => Value::String(other.to_string()),
|
|
};
|
|
}
|
|
remote.get("value").cloned().unwrap_or(Value::Null)
|
|
}
|
|
|
|
/// `String(err?.message || err)` over puppeteer's `createClientError` /
|
|
/// `createEvaluationError`: the message for an error object, the stringified
|
|
/// primitive otherwise.
|
|
pub fn client_error_message(details: &Value) -> String {
|
|
let exception = details.get("exception");
|
|
let Some(exception) = exception else {
|
|
return details
|
|
.get("text")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_string();
|
|
};
|
|
let etype = exception.get("type").and_then(Value::as_str).unwrap_or("");
|
|
let subtype = exception
|
|
.get("subtype")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
let has_object_id = exception.get("objectId").is_some();
|
|
if (etype != "object" || subtype != "error") && !has_object_id {
|
|
// A thrown primitive: String(value).
|
|
if let Some(unser) = exception.get("unserializableValue").and_then(Value::as_str) {
|
|
return match unser {
|
|
"-0" => "0".to_string(),
|
|
other if other.ends_with('n') => other.trim_end_matches('n').to_string(),
|
|
other => other.to_string(),
|
|
};
|
|
}
|
|
return match exception.get("value") {
|
|
None => "undefined".to_string(),
|
|
Some(Value::Null) => "null".to_string(),
|
|
Some(Value::String(s)) => s.clone(),
|
|
Some(Value::Bool(b)) => b.to_string(),
|
|
Some(Value::Number(n)) => {
|
|
impeccable_core::js::number_to_string(n.as_f64().unwrap_or(f64::NAN))
|
|
}
|
|
Some(other) => other.to_string(),
|
|
};
|
|
}
|
|
// getErrorDetails
|
|
let description = exception
|
|
.get("description")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
let mut lines: Vec<&str> = if exception.get("description").is_some() {
|
|
description.split("\n at ").collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
let frames = details
|
|
.pointer("/stackTrace/callFrames")
|
|
.and_then(Value::as_array)
|
|
.map(|a| a.len())
|
|
.unwrap_or(0);
|
|
let size = frames.min(lines.len().saturating_sub(1));
|
|
if size > 0 {
|
|
let keep = lines.len() - size;
|
|
lines.truncate(keep);
|
|
}
|
|
let name = exception
|
|
.get("className")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("");
|
|
let mut message = lines.join("\n");
|
|
if !name.is_empty() {
|
|
let prefix = format!("{name}: ");
|
|
if message.starts_with(&prefix) {
|
|
message = message[prefix.len()..].to_string();
|
|
}
|
|
}
|
|
message
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn error_message_extraction_matches_puppeteer() {
|
|
let details = json!({
|
|
"text": "Uncaught",
|
|
"exception": {
|
|
"type": "object", "subtype": "error", "className": "ReferenceError",
|
|
"description": "ReferenceError: foo is not defined\n at <anonymous>:1:1",
|
|
"objectId": "1"
|
|
},
|
|
"stackTrace": { "callFrames": [{ "functionName": "", "url": "", "lineNumber": 0, "columnNumber": 0 }] }
|
|
});
|
|
assert_eq!(client_error_message(&details), "foo is not defined");
|
|
let primitive =
|
|
json!({ "text": "Uncaught", "exception": { "type": "string", "value": "boom" } });
|
|
assert_eq!(client_error_message(&primitive), "boom");
|
|
let none = json!({ "text": "Uncaught SyntaxError" });
|
|
assert_eq!(client_error_message(&none), "Uncaught SyntaxError");
|
|
let syntax = json!({
|
|
"text": "Uncaught",
|
|
"exception": { "type": "object", "subtype": "error", "className": "SyntaxError",
|
|
"description": "SyntaxError: Unexpected token '}'", "objectId": "2" }
|
|
});
|
|
assert_eq!(client_error_message(&syntax), "Unexpected token '}'");
|
|
}
|
|
|
|
#[test]
|
|
fn concurrent_launches_with_identical_clock_ticks_reserve_distinct_profiles() {
|
|
let profiles = std::thread::scope(|scope| {
|
|
let handles: Vec<_> = (0..32)
|
|
.map(|_| scope.spawn(|| reserve_profile(&std::env::temp_dir(), 7).unwrap()))
|
|
.collect();
|
|
handles
|
|
.into_iter()
|
|
.map(|h| h.join().unwrap())
|
|
.collect::<Vec<_>>()
|
|
});
|
|
assert_eq!(
|
|
profiles
|
|
.iter()
|
|
.collect::<std::collections::HashSet<_>>()
|
|
.len(),
|
|
32
|
|
);
|
|
for path in profiles {
|
|
std::fs::remove_dir(path).unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn default_args_shape() {
|
|
let args = default_chrome_args(&[], false);
|
|
assert_eq!(args[0], "--allow-pre-commit-input");
|
|
assert!(args.contains(&"--headless=new".to_string()));
|
|
assert!(args.contains(&"--hide-scrollbars".to_string()));
|
|
assert_eq!(args.last().unwrap(), "about:blank");
|
|
let with = default_chrome_args(&["--no-sandbox".to_string()], false);
|
|
assert_eq!(with.last().unwrap(), "--no-sandbox");
|
|
assert!(with.contains(&"about:blank".to_string()));
|
|
}
|
|
}
|