Files
obscura/crates/obscura-cdp/src/domains/page.rs
T

1641 lines
71 KiB
Rust

use obscura_browser::lifecycle::WaitUntil;
use serde_json::{json, Value};
use crate::dispatch::CdpContext;
use crate::types::CdpEvent;
use crate::util::url_is_file_scheme;
#[cfg(feature = "render")]
use crate::dispatch::{ScreencastFormat, ScreencastState};
#[cfg(feature = "render")]
const DEFAULT_SCREENSHOT_QUALITY: i64 = 80;
#[cfg(feature = "render")]
const MAX_SCREENCAST_FRAMES_IN_FLIGHT: u8 = 2;
#[cfg(feature = "render")]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ScreenshotFormat {
Png,
Jpeg,
Webp,
}
#[cfg(feature = "render")]
#[derive(Clone, Copy, Debug)]
struct ScreenshotClip {
x: f64,
y: f64,
width: f64,
height: f64,
scale: f64,
}
#[cfg(feature = "render")]
#[derive(Clone, Copy, Debug)]
struct ScreenshotOptions {
format: ScreenshotFormat,
quality: u8,
quality_supplied: bool,
clip: Option<ScreenshotClip>,
from_surface: bool,
capture_beyond_viewport: bool,
optimize_for_speed: bool,
}
#[cfg(feature = "render")]
fn screenshot_bool(params: &Value, name: &str, default: bool) -> Result<bool, String> {
match params.get(name) {
None => Ok(default),
Some(Value::Bool(value)) => Ok(*value),
Some(_) => Err(format!("Invalid parameters: {name} must be a boolean")),
}
}
#[cfg(feature = "render")]
fn screenshot_number(object: &serde_json::Map<String, Value>, name: &str) -> Result<f64, String> {
let value = object
.get(name)
.ok_or_else(|| format!("Invalid parameters: mandatory clip.{name} field missing"))?
.as_f64()
.ok_or_else(|| format!("Invalid parameters: clip.{name} must be a number"))?;
if !value.is_finite() {
return Err(format!("Invalid parameters: clip.{name} must be finite"));
}
Ok(value)
}
#[cfg(feature = "render")]
fn parse_screenshot_options(params: &Value) -> Result<ScreenshotOptions, String> {
if !params.is_object() {
return Err("Invalid parameters: expected an object".to_string());
}
let format = match params.get("format") {
None => ScreenshotFormat::Png,
Some(Value::String(value)) => match value.as_str() {
"png" => ScreenshotFormat::Png,
"jpeg" => ScreenshotFormat::Jpeg,
"webp" => ScreenshotFormat::Webp,
_ => return Err("Invalid image format".to_string()),
},
Some(_) => return Err("Invalid parameters: format must be a string".to_string()),
};
let quality_supplied = params.get("quality").is_some();
let quality = match params.get("quality") {
None => DEFAULT_SCREENSHOT_QUALITY,
Some(value) => value
.as_i64()
.filter(|value| i32::try_from(*value).is_ok())
.ok_or_else(|| "Invalid parameters: quality must be an integer".to_string())?,
};
// Chromium accepts an int32 outside [0, 100], but deliberately falls back
// to its default quality instead of rejecting the request.
let quality = if (0..=100).contains(&quality) {
quality
} else {
DEFAULT_SCREENSHOT_QUALITY
} as u8;
let clip = match params.get("clip") {
None => None,
Some(Value::Object(object)) => {
let clip = ScreenshotClip {
x: screenshot_number(object, "x")?,
y: screenshot_number(object, "y")?,
width: screenshot_number(object, "width")?,
height: screenshot_number(object, "height")?,
scale: screenshot_number(object, "scale")?,
};
if clip.width == 0.0 {
return Err("Cannot take screenshot with 0 width.".to_string());
}
if clip.height == 0.0 {
return Err("Cannot take screenshot with 0 height.".to_string());
}
// Chromium's current handler only checks zero, but negative sizes
// and scales enter invalid gfx sizes and may stall the request.
// Fail deterministically instead of risking an allocation or hang.
if clip.width < 0.0 {
return Err("Cannot take screenshot with negative width.".to_string());
}
if clip.height < 0.0 {
return Err("Cannot take screenshot with negative height.".to_string());
}
if clip.scale <= 0.0 {
return Err("Cannot take screenshot with non-positive scale.".to_string());
}
Some(clip)
}
Some(_) => return Err("Invalid parameters: clip must be an object".to_string()),
};
Ok(ScreenshotOptions {
format,
quality,
quality_supplied,
clip,
from_surface: screenshot_bool(params, "fromSurface", true)?,
capture_beyond_viewport: screenshot_bool(params, "captureBeyondViewport", false)?,
optimize_for_speed: screenshot_bool(params, "optimizeForSpeed", false)?,
})
}
#[cfg(feature = "render")]
fn screenshot_output_size(clip: ScreenshotClip) -> Result<(u32, u32), String> {
// Chromium converts the CSS clip size to an integer gfx::Size before
// applying scale to the requested output bitmap.
let width = (clip.width.trunc() * clip.scale).round();
let height = (clip.height.trunc() * clip.scale).round();
const MAX_DIMENSION: f64 = 128.0 * 1024.0;
if width <= 0.0 || height <= 0.0 {
return Err("Screenshot clip is too small at the requested scale.".to_string());
}
if width >= MAX_DIMENSION || height >= MAX_DIMENSION {
return Err("Page is too large.".to_string());
}
let pixels = width * height;
// Four RGBA bytes per pixel keeps this guard below a 256 MiB surface.
if !pixels.is_finite() || pixels > 64.0 * 1024.0 * 1024.0 {
return Err("Screenshot bitmap is too large.".to_string());
}
Ok((width as u32, height as u32))
}
#[cfg(feature = "render")]
fn sample_screenshot_region(
source: &image::RgbaImage,
clip: ScreenshotClip,
relative_x: f64,
relative_y: f64,
) -> Result<image::RgbaImage, String> {
let (output_width, output_height) = screenshot_output_size(clip)?;
let mut output = image::RgbaImage::new(output_width, output_height);
let source_width = source.width() as i64;
let source_height = source.height() as i64;
// Sampling pixel centers gives exact copies for integral, scale=1 clips
// while also handling fractional clip origins and non-integral scales.
for output_y in 0..output_height {
let sample_y =
relative_y + (f64::from(output_y) + 0.5) * clip.height / f64::from(output_height) - 0.5;
let y0 = sample_y.floor() as i64;
let y1 = y0 + 1;
let fy = sample_y - y0 as f64;
for output_x in 0..output_width {
let sample_x = relative_x
+ (f64::from(output_x) + 0.5) * clip.width / f64::from(output_width)
- 0.5;
let x0 = sample_x.floor() as i64;
let x1 = x0 + 1;
let fx = sample_x - x0 as f64;
let pixel = |x: i64, y: i64| {
source.get_pixel(
x.clamp(0, source_width - 1) as u32,
y.clamp(0, source_height - 1) as u32,
)
};
let p00 = pixel(x0, y0);
let p10 = pixel(x1, y0);
let p01 = pixel(x0, y1);
let p11 = pixel(x1, y1);
let mut rgba = [0u8; 4];
for channel in 0..4 {
let top = f64::from(p00[channel]) * (1.0 - fx) + f64::from(p10[channel]) * fx;
let bottom = f64::from(p01[channel]) * (1.0 - fx) + f64::from(p11[channel]) * fx;
rgba[channel] = (top * (1.0 - fy) + bottom * fy).round().clamp(0.0, 255.0) as u8;
}
output.put_pixel(output_x, output_y, image::Rgba(rgba));
}
}
Ok(output)
}
#[cfg(feature = "render")]
fn encode_screenshot(
image: &image::RgbaImage,
options: ScreenshotOptions,
) -> Result<Vec<u8>, String> {
use image::ImageEncoder as _;
let mut output = Vec::new();
match options.format {
ScreenshotFormat::Png => {
let encoder = if options.optimize_for_speed {
image::codecs::png::PngEncoder::new_with_quality(
&mut output,
image::codecs::png::CompressionType::Fast,
image::codecs::png::FilterType::NoFilter,
)
} else {
image::codecs::png::PngEncoder::new(&mut output)
};
encoder
.write_image(
image.as_raw(),
image.width(),
image.height(),
image::ExtendedColorType::Rgba8,
)
.map_err(|error| format!("PNG screenshot encoding failed: {error}"))?;
}
ScreenshotFormat::Jpeg => {
let rgb = image::DynamicImage::ImageRgba8(image.clone()).to_rgb8();
// image's pure-Rust JPEG encoder defines its quality range as
// 1..=100; Chromium permits zero, whose effective result is the
// lowest-quality encoding.
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(
&mut output,
options.quality.max(1),
);
encoder
.write_image(
rgb.as_raw(),
rgb.width(),
rgb.height(),
image::ExtendedColorType::Rgb8,
)
.map_err(|error| format!("JPEG screenshot encoding failed: {error}"))?;
}
ScreenshotFormat::Webp => {
if options.quality_supplied {
return Err(
"WebP screenshot quality is not supported by the current lossless encoder"
.to_string(),
);
}
image::codecs::webp::WebPEncoder::new_lossless(&mut output)
.write_image(
image.as_raw(),
image.width(),
image.height(),
image::ExtendedColorType::Rgba8,
)
.map_err(|error| format!("WebP screenshot encoding failed: {error}"))?;
}
}
Ok(output)
}
#[cfg(feature = "render")]
fn screencast_int32(params: &Value, name: &str) -> Result<Option<i64>, String> {
match params.get(name) {
None => Ok(None),
Some(value) => value.as_i64()
.filter(|value| i32::try_from(*value).is_ok()).map(Some)
.ok_or_else(|| format!("Invalid parameters: {name} must be an integer")),
}
}
#[cfg(feature = "render")]
fn parse_screencast_state(params: &Value, session_id: i64) -> Result<ScreencastState, String> {
if !params.is_object() { return Err("Invalid parameters: expected an object".into()); }
let format = match params.get("format") {
None => ScreencastFormat::Png,
Some(Value::String(value)) if value == "png" => ScreencastFormat::Png,
Some(Value::String(value)) if value == "jpeg" => ScreencastFormat::Jpeg,
Some(Value::String(_)) => return Err("Invalid parameters: screencast format must be png or jpeg".into()),
Some(_) => return Err("Invalid parameters: format must be a string".into()),
};
let quality = screencast_int32(params, "quality")?.unwrap_or(DEFAULT_SCREENSHOT_QUALITY);
let quality = if (0..=100).contains(&quality) { quality } else { DEFAULT_SCREENSHOT_QUALITY } as u8;
let dimension = |name: &str| -> Result<Option<u32>, String> {
Ok(screencast_int32(params, name)?.filter(|value| *value > 0).map(|value| value as u32))
};
let every_nth_frame = screencast_int32(params, "everyNthFrame")?.unwrap_or(1);
if every_nth_frame <= 0 {
return Err("Invalid parameters: everyNthFrame must be greater than zero".into());
}
Ok(ScreencastState {
format, quality,
max_width: dimension("maxWidth")?, max_height: dimension("maxHeight")?,
every_nth_frame: every_nth_frame as u32, command_frame_counter: 0,
session_id, frames_in_flight: 0,
})
}
#[cfg(feature = "render")]
fn encode_screencast_frame(renderer_png: Vec<u8>, state: &ScreencastState) -> Result<Vec<u8>, String> {
if state.format == ScreencastFormat::Png && state.max_width.is_none() && state.max_height.is_none() {
return Ok(renderer_png);
}
let source = image::load_from_memory_with_format(&renderer_png, image::ImageFormat::Png)
.map_err(|error| format!("Page.startScreencast could not decode renderer PNG: {error}"))?
.to_rgba8();
let mut scale = 1.0_f64;
if let Some(max_width) = state.max_width {
scale = scale.min(f64::from(max_width) / f64::from(source.width()));
}
if let Some(max_height) = state.max_height {
scale = scale.min(f64::from(max_height) / f64::from(source.height()));
}
let size = (
(f64::from(source.width()) * scale).floor().max(1.0) as u32,
(f64::from(source.height()) * scale).floor().max(1.0) as u32,
);
let raster = if size == source.dimensions() { source } else {
image::imageops::resize(&source, size.0, size.1, image::imageops::FilterType::Triangle)
};
let format = match state.format {
ScreencastFormat::Png => ScreenshotFormat::Png,
ScreencastFormat::Jpeg => ScreenshotFormat::Jpeg,
};
encode_screenshot(&raster, ScreenshotOptions {
format, quality: state.quality, quality_supplied: true, clip: None,
from_surface: true, capture_beyond_viewport: false, optimize_for_speed: false,
})
}
/// Queue a visible-viewport frame through normal CDP event transport. This is
/// intentionally command-driven until Obscura has a compositor frame pump.
#[cfg(feature = "render")]
pub(crate) fn queue_screencast_frame(
ctx: &mut CdpContext, cdp_session_id: &Option<String>, force: bool,
) -> Result<bool, String> {
let cdp_session_id = cdp_session_id.as_deref()
.ok_or("Page.startScreencast requires an attached target session")?;
let state = {
let Some(state) = ctx.screencasts.get_mut(cdp_session_id) else { return Ok(false); };
if state.frames_in_flight >= MAX_SCREENCAST_FRAMES_IN_FLIGHT { return Ok(false); }
if !force {
state.command_frame_counter = state.command_frame_counter.saturating_add(1);
if state.command_frame_counter % u64::from(state.every_nth_frame) != 0 { return Ok(false); }
}
state.clone()
};
let attached_session = Some(cdp_session_id.to_string());
let (viewport, scroll, png) = {
let page = ctx.get_session_page_mut(&attached_session).ok_or("No page for session")?;
let viewport = page.viewport;
let scroll = page.evaluate("[window.scrollX, window.scrollY]").as_array()
.map(|values| (
values.first().and_then(Value::as_f64).unwrap_or(0.0),
values.get(1).and_then(Value::as_f64).unwrap_or(0.0),
)).unwrap_or((0.0, 0.0));
let png = page.screenshot(viewport).ok_or_else(||
"Page.startScreencast failed: the page has no visible DOM surface to render".to_string())?;
(viewport, scroll, png)
};
let encoded = encode_screencast_frame(png, &state)?;
use base64::Engine as _;
let data = base64::engine::general_purpose::STANDARD.encode(encoded);
let Some(live) = ctx.screencasts.get_mut(cdp_session_id) else { return Ok(false); };
if live.session_id != state.session_id { return Ok(false); }
live.frames_in_flight = live.frames_in_flight.saturating_add(1);
ctx.pending_events.push(CdpEvent {
method: "Page.screencastFrame".into(),
params: json!({
"data": data,
"metadata": {
"offsetTop": 0.0, "pageScaleFactor": 1.0,
"deviceWidth": viewport.0, "deviceHeight": viewport.1,
"scrollOffsetX": scroll.0, "scrollOffsetY": scroll.1,
"timestamp": timestamp(),
},
"sessionId": state.session_id,
}),
session_id: Some(cdp_session_id.to_string()),
});
Ok(true)
}
#[cfg(feature = "render")]
pub(crate) fn command_can_change_screencast_frame(method: &str) -> bool {
matches!(method,
"Page.navigate" | "Page.reload" | "Page.navigateToHistoryEntry"
| "Runtime.evaluate" | "Runtime.callFunctionOn"
| "Input.dispatchMouseEvent" | "Input.dispatchKeyEvent" | "Input.dispatchTouchEvent"
| "Emulation.setDeviceMetricsOverride" | "Emulation.clearDeviceMetricsOverride"
| "DOM.setAttributeValue" | "DOM.removeNode" | "DOM.focus" | "DOM.setFileInputFiles"
)
}
/// Emit the post-navigation event stream into `ctx.pending_events`. Shared
/// by both the in-process `do_navigate` path and the spawned path in
/// `server::process_navigation`, so the recent goto-returns-Response /
/// per-isolated-world fixes don't have to be duplicated.
pub fn emit_navigation_events(
ctx: &mut CdpContext,
session_id: &Option<String>,
frame_id: &str,
loader_id: &str,
page_url: &str,
page_id: &str,
network_events: &[obscura_browser::NetworkEvent],
wait_until: WaitUntil,
reached_network_idle: bool,
) {
let es = session_id.clone();
let ts = timestamp();
// Real Chrome uses the navigation's loaderId as the main document's
// request id, and Puppeteer/Playwright identify the navigation response
// via `requestId === loaderId && type === "Document"` (issue #189).
let nav_request_ids: Vec<String> = {
let mut nav_seen = false;
network_events.iter().map(|ev| {
if !nav_seen && ev.resource_type == "Document" && ev.url == page_url {
nav_seen = true;
loader_id.to_string()
} else {
ev.request_id.clone()
}
}).collect()
};
let nav_idx: Option<usize> = network_events
.iter()
.position(|ev| ev.resource_type == "Document" && ev.url == page_url);
// The main resource's body is stored under its internal request id, but the
// client sees it as `loader_id` (the requestId we report above). Alias it so
// Network.getResponseBody(loaderId) resolves, which is the only way a client
// navigating straight to an image/PDF/other resource can read the main body
// (issue #340). Also read the real Content-Type so frameNavigated reports the
// actual mime instead of a hardcoded text/html.
let mut nav_mime = "text/html".to_string();
if let Some(idx) = nav_idx {
let internal_id = &network_events[idx].request_id;
if let Some(ct) = network_events[idx].response_headers.get("content-type") {
// Strip any `; charset=...` parameter; frameNavigated wants the essence.
nav_mime = ct.split(';').next().unwrap_or(ct).trim().to_string();
}
if internal_id != loader_id {
if let Some(page) = ctx.get_page_mut(page_id) {
page.alias_response_body(internal_id, loader_id);
}
}
}
// Playwright needs `Network.requestWillBeSent` for the main document to
// arrive BEFORE `Page.frameNavigated` (issue #190).
if let Some(idx) = nav_idx {
let net_event = &network_events[idx];
let rid = &nav_request_ids[idx];
ctx.pending_events.push(CdpEvent {
method: "Network.requestWillBeSent".into(),
params: json!({"requestId": rid, "loaderId": loader_id, "documentURL": page_url, "request": {"url": net_event.url, "method": net_event.method, "headers": net_event.headers}, "timestamp": net_event.timestamp, "wallTime": net_event.timestamp, "initiator": {"type": "other"}, "type": net_event.resource_type, "frameId": frame_id}),
session_id: es.clone(),
});
}
// executionContextsCleared invalidates every prior context id, so a
// Runtime.evaluate / callFunctionOn targeting a pre-navigation context
// must be rejected (Chrome: "Cannot find context with specified id"). The
// default world (id 2) and isolated worlds are re-registered below as their
// executionContextCreated events are emitted. Issue #407: previously this
// set was insert-only, so stale ids kept validating and grew unbounded.
ctx.valid_context_ids.clear();
let mut phase1 = vec![
CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "init", "timestamp": ts}), session_id: es.clone() },
CdpEvent { method: "Runtime.executionContextsCleared".into(), params: json!({}), session_id: es.clone() },
CdpEvent { method: "Page.frameNavigated".into(), params: json!({"frame": {"id": frame_id, "loaderId": loader_id, "url": page_url, "domainAndRegistry": "", "securityOrigin": page_url, "mimeType": nav_mime, "adFrameStatus": {"adFrameType": "none"}}, "type": "Navigation"}), session_id: es.clone() },
CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 2, "origin": page_url, "name": "", "uniqueId": format!("ctx-nav-{}", page_id), "auxData": {"isDefault": true, "type": "default", "frameId": frame_id}}}), session_id: es.clone() },
];
// The default world is re-created as context id 2; re-register it. Isolated
// worlds register themselves via next_isolated_context in the loop below.
ctx.valid_context_ids.insert(2);
let world_names: Vec<String> = if ctx.isolated_worlds.is_empty() {
vec!["__puppeteer_utility_world__24.40.0".to_string()]
} else {
ctx.isolated_worlds.clone()
};
// Issue #192: fresh, monotonically increasing executionContextId per re-create.
for world_name in &world_names {
let world_ctx_id = ctx.next_isolated_context();
phase1.push(CdpEvent {
method: "Runtime.executionContextCreated".into(),
params: json!({"context": {"id": world_ctx_id, "origin": page_url, "name": world_name, "uniqueId": format!("ctx-isolated-nav-{}-{}", page_id, world_ctx_id), "auxData": {"isDefault": false, "type": "isolated", "frameId": frame_id}}}),
session_id: es.clone(),
});
}
phase1.push(CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "commit", "timestamp": ts}), session_id: es.clone() });
ctx.pending_events.extend(phase1);
if ctx.fetch_intercept.enabled {
for (i, net_event) in network_events.iter().enumerate() {
let rid = &nav_request_ids[i];
ctx.pending_events.push(CdpEvent {
method: "Fetch.requestPaused".into(),
params: json!({
"requestId": rid,
"request": {
"url": net_event.url,
"method": net_event.method,
"headers": net_event.headers,
},
"frameId": frame_id,
"resourceType": net_event.resource_type,
"networkId": rid,
}),
session_id: es.clone(),
});
}
}
for (i, net_event) in network_events.iter().enumerate() {
let rid = &nav_request_ids[i];
if Some(i) != nav_idx {
ctx.pending_events.push(CdpEvent {
method: "Network.requestWillBeSent".into(),
params: json!({"requestId": rid, "loaderId": loader_id, "documentURL": page_url, "request": {"url": net_event.url, "method": net_event.method, "headers": net_event.headers}, "timestamp": net_event.timestamp, "wallTime": net_event.timestamp, "initiator": {"type": "other"}, "type": net_event.resource_type, "frameId": frame_id}),
session_id: es.clone(),
});
}
ctx.pending_events.push(CdpEvent {
method: "Network.responseReceived".into(),
params: json!({"requestId": rid, "loaderId": loader_id, "timestamp": net_event.timestamp, "type": net_event.resource_type, "response": {"url": net_event.url, "status": net_event.status, "statusText": "", "headers": &*net_event.response_headers, "mimeType": net_event.response_headers.get("content-type").cloned().unwrap_or_default()}, "frameId": frame_id}),
session_id: es.clone(),
});
ctx.pending_events.push(CdpEvent {
method: "Network.loadingFinished".into(),
params: json!({"requestId": rid, "timestamp": net_event.timestamp, "encodedDataLength": net_event.body_size}),
session_id: es.clone(),
});
}
let mut phase3 = vec![
CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "DOMContentLoaded", "timestamp": ts}), session_id: es.clone() },
CdpEvent { method: "Page.domContentEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() },
CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "load", "timestamp": ts}), session_id: es.clone() },
CdpEvent { method: "Page.loadEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() },
];
if reached_network_idle || matches!(wait_until, WaitUntil::Load | WaitUntil::DomContentLoaded) {
let idle_ts = timestamp();
phase3.push(CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "networkIdle", "timestamp": idle_ts}), session_id: es.clone() });
}
phase3.push(CdpEvent { method: "Page.frameStoppedLoading".into(), params: json!({"frameId": frame_id}), session_id: es });
ctx.pending_events.extend(phase3);
// Target.targetInfoChanged: strict CDP clients (browser-use, and
// Puppeteer/Playwright `page.url()` tracking) cache the TargetInfo from
// attachedToTarget and only refresh it on this event. Without it they keep
// reporting the pre-navigation url/title (about:blank) and never see the
// loaded page. Emit it browser-level (no sessionId) with the new url/title.
let (tic_title, tic_ctx) = ctx
.get_page(page_id)
.map(|p| (p.title.clone(), p.context.id.clone()))
.unwrap_or_default();
ctx.pending_events.push(CdpEvent::new(
"Target.targetInfoChanged",
json!({
"targetInfo": {
"targetId": page_id,
"type": "page",
"title": tic_title,
"url": page_url,
"attached": true,
"canAccessOpener": false,
"browserContextId": tic_ctx,
}
}),
));
}
/// Parse the `waitUntil` argument that Puppeteer/Playwright pass on
/// `Page.navigate`.
pub fn parse_wait_until(params: &Value) -> WaitUntil {
params
.get("waitUntil")
.and_then(|v| {
if let Some(s) = v.as_str() {
Some(WaitUntil::from_str(s))
} else if let Some(arr) = v.as_array() {
arr.iter()
.filter_map(|item| item.as_str())
.map(WaitUntil::from_str)
.max_by_key(|w| match w {
WaitUntil::DomContentLoaded => 0,
WaitUntil::Load => 1,
WaitUntil::NetworkIdle2 => 2,
WaitUntil::NetworkIdle0 => 3,
})
} else {
None
}
})
// Puppeteer and Playwright drive navigation via `Page.navigate`
// without a server-side waitUntil — they wait for `Page.lifecycleEvent`
// on the client side. Defaulting the server to `Load` means we run
// every parser/deferred/async script on JS-heavy pages before
// emitting `load`, which on sites like github.com / reddit.com
// pushes nav past 25s and clients time out at 15s. Real Chrome
// streams `DOMContentLoaded` as soon as the parser is done; we
// batch our event emission at the end of navigation, so the
// closest we can get is to default to `DomContentLoaded` and skip
// the full-load wait. CLI callers that pass `--wait-until load`
// (or `networkidle*`) are unaffected; they get the old behaviour.
.unwrap_or(WaitUntil::DomContentLoaded)
}
async fn do_navigate(
url: &str,
params: &Value,
ctx: &mut CdpContext,
session_id: &Option<String>,
) -> Result<Value, String> {
let wait_until = parse_wait_until(params);
// Block CDP-initiated file:// navigation by default.
// Anyone who can reach the CDP port (default localhost,
// but Docker images bind 0.0.0.0) could otherwise read
// any file the obscura process can read. Opt in via
// `obscura serve --allow-file-access` when local-HTML
// testing is the intended workflow.
let allow_file_access = ctx
.get_session_page(session_id)
.map(|page| page.context.allow_file_access)
.unwrap_or(ctx.default_context.allow_file_access);
if url_is_file_scheme(url) && !allow_file_access {
return Err(
"Page.navigate to file:// is disabled. Restart with `obscura serve --allow-file-access` to enable.".to_string()
);
}
let preload_scripts: Vec<String> = ctx.preload_scripts.iter().map(|(_, s)| s.clone()).collect();
let (frame_id, loader_id, network_events, page_url, page_id, reached_network_idle) = {
let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?;
let frame_id = page.frame_id.clone();
let loader_id = format!("loader-{}", uuid::Uuid::new_v4());
// Preloads (addBinding shims, addScriptToEvaluateOnNewDocument sources)
// must run BEFORE the page's own scripts (CDP contract). Hand them to
// the page so navigate_single can inject them at the right point.
page.set_preload_scripts(preload_scripts);
let nav_method = params.get("__method").and_then(|v| v.as_str()).unwrap_or("GET");
let nav_body = params.get("__body").and_then(|v| v.as_str()).unwrap_or("");
if nav_method == "POST" && !nav_body.is_empty() {
page.navigate_with_wait_post(url, wait_until, nav_method, nav_body).await.map_err(|e| e.to_string())?;
} else {
page.navigate_with_wait(url, wait_until).await.map_err(|e| e.to_string())?;
}
let reached_network_idle = page.lifecycle.is_network_idle();
// Fold in script-initiated requests (fetch/XHR/dynamic resource) so they
// emit as Network events alongside static subresources (#406).
page.sync_js_network_events();
let network_events: Vec<_> = page.network_events.drain(..).collect();
let page_url = page.url_string();
let page_id = page.id.clone();
(frame_id, loader_id, network_events, page_url, page_id, reached_network_idle)
};
emit_navigation_events(
ctx,
session_id,
&frame_id,
&loader_id,
&page_url,
&page_id,
&network_events,
wait_until,
reached_network_idle,
);
Ok(json!({
"frameId": frame_id,
"loaderId": loader_id,
}))
}
pub async fn handle(
method: &str,
params: &Value,
ctx: &mut CdpContext,
session_id: &Option<String>,
) -> Result<Value, String> {
match method {
"enable" => Ok(json!({})),
"navigate" => {
let url = params.get("url").and_then(|v| v.as_str())
.ok_or("url required")?;
do_navigate(url, params, ctx, session_id).await
}
"reload" => {
let current_url = ctx.get_session_page(session_id)
.map(|p| p.url_string())
.unwrap_or_else(|| "about:blank".to_string());
let reload_params = json!({
"waitUntil": params.get("waitUntil").cloned().unwrap_or(json!("load"))
});
do_navigate(&current_url, &reload_params, ctx, session_id).await
}
"getFrameTree" => {
let page = ctx.get_session_page(session_id).ok_or("No page for session")?;
Ok(json!({
"frameTree": {
"frame": {
"id": page.frame_id,
"loaderId": "initial-loader",
"url": page.url_string(),
"domainAndRegistry": "",
"securityOrigin": page.url_string(),
"mimeType": "text/html",
"adFrameStatus": { "adFrameType": "none" },
},
"childFrames": [],
}
}))
}
"createIsolatedWorld" => {
let (frame_id_param, world_name, page_url, page_id) = {
let page = ctx.get_session_page(session_id).ok_or("No page for session")?;
(
params.get("frameId").and_then(|v| v.as_str())
.unwrap_or(&page.frame_id).to_string(),
params.get("worldName").and_then(|v| v.as_str())
.unwrap_or("").to_string(),
page.url_string(),
page.id.clone(),
)
};
// Track this world so Page.navigate can re-emit a context for it
// post-navigation. Without this, Playwright (and Puppeteer)
// hang in any operation that uses the utility world — including
// page.title() — because their utility world is gone after
// Runtime.executionContextsCleared and never re-created.
if !world_name.is_empty() && !ctx.isolated_worlds.contains(&world_name) {
ctx.isolated_worlds.push(world_name.clone());
}
// Issue #192: every isolated world emission gets a fresh id from
// the monotonic counter and is registered as a valid contextId.
// Reusing id 100 across navigations made Playwright's bookkeeping
// diverge (it expected 101 on the second nav) and Runtime.evaluate
// failed with "Cannot find context with specified id: 101".
let context_id = ctx.next_isolated_context();
ctx.pending_events.push(CdpEvent {
method: "Runtime.executionContextCreated".to_string(),
params: json!({
"context": {
"id": context_id,
"origin": page_url,
"name": world_name,
"uniqueId": format!("ctx-isolated-{}-{}", page_id, context_id),
"auxData": {
"isDefault": false,
"type": "isolated",
"frameId": frame_id_param,
}
}
}),
session_id: session_id.clone(),
});
Ok(json!({ "executionContextId": context_id }))
}
"setLifecycleEventsEnabled" => Ok(json!({})),
"addScriptToEvaluateOnNewDocument" => {
let source = params.get("source").and_then(|v| v.as_str()).unwrap_or("");
ctx.preload_counter += 1;
let identifier = format!("{}", ctx.preload_counter);
if !source.is_empty() {
ctx.preload_scripts.push((identifier.clone(), source.to_string()));
}
Ok(json!({ "identifier": identifier }))
}
"removeScriptToEvaluateOnNewDocument" => {
let identifier = params.get("identifier").and_then(|v| v.as_str()).unwrap_or("");
ctx.preload_scripts.retain(|(id, _)| id != identifier);
Ok(json!({}))
}
"setInterceptFileChooserDialog" => Ok(json!({})),
// Obscura does not download files to disk, so there is no behavior to
// configure; ack it so clients that set it do not warn (issue #340).
"setDownloadBehavior" => Ok(json!({})),
"getLayoutMetrics" => {
// Playwright calls this before every page.screenshot(). Report the
// same live CSS viewport that responsive page code and paint use.
let (width, height) = ctx
.get_session_page(session_id)
.map(|page| (page.viewport.0 as f64, page.viewport.1 as f64))
.unwrap_or((1280.0, 720.0));
let mut page_x = 0.0;
let mut page_y = 0.0;
let mut content_width = width;
let mut content_height = height;
if let Some(values) = ctx
.get_session_page_mut(session_id)
.map(|page| {
page.evaluate(
"[window.scrollX, window.scrollY, \
document.documentElement && document.documentElement.scrollWidth, \
document.documentElement && document.documentElement.scrollHeight]",
)
})
.and_then(|value| value.as_array().cloned())
{
page_x = values.first().and_then(Value::as_f64).unwrap_or(0.0);
page_y = values.get(1).and_then(Value::as_f64).unwrap_or(0.0);
content_width = values
.get(2)
.and_then(Value::as_f64)
.filter(|value| *value > 0.0)
.unwrap_or(width);
content_height = values
.get(3)
.and_then(Value::as_f64)
.filter(|value| *value > 0.0)
.unwrap_or(height);
}
let layout_viewport = json!({
"pageX": page_x, "pageY": page_y,
"clientWidth": width, "clientHeight": height,
});
let visual_viewport = json!({
"offsetX": 0.0, "offsetY": 0.0,
"pageX": page_x, "pageY": page_y,
"clientWidth": width, "clientHeight": height,
"scale": 1.0, "zoom": 1.0,
});
let content_size = json!({
"x": 0.0, "y": 0.0,
"width": content_width, "height": content_height,
});
Ok(json!({
"layoutViewport": layout_viewport,
"visualViewport": visual_viewport,
"contentSize": content_size,
"cssLayoutViewport": layout_viewport,
"cssVisualViewport": visual_viewport,
"cssContentSize": content_size,
}))
}
"getNavigationHistory" => {
let page = ctx.get_session_page(session_id).ok_or("No page for session")?;
// Synthesize an entry for the current page when history is empty
// (initial about:blank, never-navigated targets). Puppeteer's
// goBack reads `currentIndex` and `entries[currentIndex-1]`;
// an empty entries[] used to make every back/forward fail.
let entries: Vec<Value> = if page.history.is_empty() {
vec![json!({
"id": 0,
"url": page.url_string(),
"userTypedURL": page.url_string(),
"title": page.title,
"transitionType": "typed",
})]
} else {
page.history.iter().enumerate().map(|(i, url)| json!({
"id": i as u64,
"url": url,
"userTypedURL": url,
"title": if i == page.history_index { page.title.clone() } else { String::new() },
"transitionType": "typed",
})).collect()
};
Ok(json!({
"currentIndex": if page.history.is_empty() { 0 } else { page.history_index },
"entries": entries,
}))
}
"navigateToHistoryEntry" => {
let entry_id = params.get("entryId").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let target_url = {
let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?;
let url = page.history.get(entry_id).cloned();
if url.is_some() {
page.set_history_index(entry_id);
}
url
};
if let Some(url) = target_url {
// Stash + restore history so push_history doesn't clobber
// the cursor we just moved.
let stash = {
let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?;
(page.history.clone(), page.history_index)
};
let (frame_id, page_id, network_events, page_url, reached_idle) = {
let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?;
page.navigate_with_wait(&url, WaitUntil::DomContentLoaded).await.map_err(|e| e.to_string())?;
page.history = stash.0;
page.history_index = stash.1;
(
page.frame_id.clone(),
page.id.clone(),
page.network_events.drain(..).collect::<Vec<_>>(),
page.url_string(),
page.lifecycle.is_network_idle(),
)
};
let loader_id = format!("loader-{}", uuid::Uuid::new_v4());
emit_navigation_events(
ctx, session_id,
&frame_id, &loader_id, &page_url, &page_id,
&network_events, WaitUntil::DomContentLoaded, reached_idle,
);
}
Ok(json!({}))
}
"resetNavigationHistory" => {
if let Some(page) = ctx.get_session_page_mut(session_id) {
page.history.clear();
page.history_index = 0;
}
Ok(json!({}))
}
"printToPDF" => {
// Obscura has no layout/rendering engine, so PDF generation is
// intentionally not implemented. Returning a distinct, descriptive
// error (rather than the generic "Unknown Page method" fallback)
// tells Playwright/Puppeteer/headless_chrome clients exactly why
// the call failed and what to do instead.
Err(
"Page.printToPDF is not supported by Obscura: no layout engine. \
Use Runtime.evaluate (e.g. page.evaluate) to extract the rendered \
HTML, then render to PDF in your client (wkhtmltopdf, weasyprint, \
a separate headless Chromium pipeline, etc.)."
.to_string(),
)
}
"startScreencast" => {
#[cfg(feature = "render")]
{
let cdp_session = session_id.as_ref()
.ok_or("Page.startScreencast requires an attached target session")?.clone();
let resource_deadline_ms = std::env::var("OBSCURA_RENDER_RESOURCE_DEADLINE_MS")
.ok().and_then(|value| value.parse::<u64>().ok()).unwrap_or(3_000);
let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?;
let _ = page.prepare_screenshot_resources(resource_deadline_ms).await;
let stream_id = ctx.next_screencast_session();
let state = parse_screencast_state(params, stream_id)?;
ctx.screencasts.insert(cdp_session.clone(), state);
let pending_before = ctx.pending_events.len();
ctx.pending_events.push(CdpEvent {
method: "Page.screencastVisibilityChanged".into(),
params: json!({"visible": true}), session_id: session_id.clone(),
});
if let Err(error) = queue_screencast_frame(ctx, session_id, true) {
ctx.pending_events.truncate(pending_before);
ctx.screencasts.remove(&cdp_session);
return Err(error);
}
tracing::debug!(cdp_session, stream_id,
"started command-driven screencast; autonomous compositor frames unavailable");
// Extension fields expose the MVP boundary to direct callers.
Ok(json!({"obscuraFrameSource": "command-driven", "obscuraAutonomousFrames": false}))
}
#[cfg(not(feature = "render"))]
Err("Page.startScreencast requires a build with the render feature".into())
}
"stopScreencast" => {
#[cfg(feature = "render")]
{
if let Some(cdp_session) = session_id.as_ref() { ctx.screencasts.remove(cdp_session); }
Ok(json!({}))
}
#[cfg(not(feature = "render"))]
Err("Page.stopScreencast requires a build with the render feature".into())
}
"screencastFrameAck" => {
#[cfg(feature = "render")]
{
let acknowledged = screencast_int32(params, "sessionId")?
.ok_or("Invalid parameters: sessionId is required")?;
if let Some(cdp_session) = session_id.as_ref() {
if let Some(state) = ctx.screencasts.get_mut(cdp_session) {
// Ignore delayed acknowledgements from a replaced stream.
if state.session_id == acknowledged {
state.frames_in_flight = state.frames_in_flight.saturating_sub(1);
}
}
}
Ok(json!({}))
}
#[cfg(not(feature = "render"))]
Err("Page.screencastFrameAck requires a build with the render feature".into())
}
"captureScreenshot" => {
#[cfg(feature = "render")]
{
let options = parse_screenshot_options(params)?;
if !options.from_surface {
return Err(
"Page.captureScreenshot fromSurface=false is not supported: Obscura has no separate browser-window compositor surface"
.to_string(),
);
}
if options.capture_beyond_viewport && options.clip.is_none() {
return Err(
"Page.captureScreenshot full-page captureBeyondViewport=true is not supported without a clip: Obscura cannot yet record an off-viewport surface without relayout or scripted scrolling"
.to_string(),
);
}
if options.format == ScreenshotFormat::Webp && options.quality_supplied {
return Err(
"WebP screenshot quality is not supported by the current lossless encoder"
.to_string(),
);
}
let page = ctx
.get_session_page_mut(session_id)
.ok_or("No page for session")?;
let resource_deadline_ms = std::env::var("OBSCURA_RENDER_RESOURCE_DEADLINE_MS")
.ok().and_then(|value| value.parse::<u64>().ok()).unwrap_or(3_000);
let _ = page.prepare_screenshot_resources(resource_deadline_ms).await;
let viewport = page.viewport;
let scroll = if options.clip.is_some() {
page.evaluate("[window.scrollX, window.scrollY]")
.as_array()
.map(|values| {
(
values.first().and_then(Value::as_f64).unwrap_or(0.0),
values.get(1).and_then(Value::as_f64).unwrap_or(0.0),
)
})
.unwrap_or((0.0, 0.0))
} else {
(0.0, 0.0)
};
let png = page.screenshot(viewport).ok_or_else(|| {
"Page.captureScreenshot failed: the page has no DOM to render".to_string()
})?;
// Keep the common path allocation-free and byte-for-byte
// compatible with the renderer's native PNG encoder.
let encoded = if options.format == ScreenshotFormat::Png
&& options.clip.is_none()
&& !options.optimize_for_speed
{
png
} else {
let source = image::load_from_memory_with_format(&png, image::ImageFormat::Png)
.map_err(|error| {
format!("Page.captureScreenshot could not decode renderer PNG: {error}")
})?
.to_rgba8();
let raster = if let Some(clip) = options.clip {
let relative_x = clip.x - scroll.0;
let relative_y = clip.y - scroll.1;
let epsilon = 1e-6;
if relative_x < -epsilon
|| relative_y < -epsilon
|| relative_x + clip.width > f64::from(source.width()) + epsilon
|| relative_y + clip.height > f64::from(source.height()) + epsilon
{
return Err(format!(
"Page.captureScreenshot clip lies outside the current viewport surface; Obscura cannot yet record off-viewport pixels even with captureBeyondViewport=true (visible page rect: x={} y={} width={} height={})",
scroll.0, scroll.1, viewport.0, viewport.1
));
}
sample_screenshot_region(
&source,
clip,
relative_x.max(0.0),
relative_y.max(0.0),
)?
} else {
source
};
encode_screenshot(&raster, options)?
};
use base64::Engine as _;
let data = base64::engine::general_purpose::STANDARD.encode(encoded);
Ok(json!({ "data": data }))
}
#[cfg(not(feature = "render"))]
Err(
"Page.captureScreenshot requires a build with the render feature"
.to_string(),
)
}
"captureSnapshot" => {
// A DOM/layer-tree snapshot (not a raster image). Distinct from
// captureScreenshot; keep the clear error so clients fail fast.
Err(format!(
"Page.{method} is not supported by Obscura: no layout or paint engine. \
For visual snapshots, drive a real headless Chromium for the \
screenshot leg of your pipeline and use Obscura for the scraping leg."
))
}
_ => Err(format!("Unknown Page method: {}", method)),
}
}
fn timestamp() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dispatch::CdpContext;
#[tokio::test]
async fn get_layout_metrics_returns_chrome_default_viewport() {
let mut ctx = CdpContext::new();
let result = handle("getLayoutMetrics", &json!({}), &mut ctx, &None)
.await
.expect("getLayoutMetrics should succeed without a session");
// CDP spec requires three top-level shapes; Playwright's screenshot
// path reads contentSize.width/height to size the capture. Without
// them the screenshot call panics with "cannot read property of
// undefined".
for key in [
"layoutViewport",
"visualViewport",
"contentSize",
"cssLayoutViewport",
"cssVisualViewport",
"cssContentSize",
] {
assert!(result.get(key).is_some(), "missing key: {key}");
}
let layout = &result["layoutViewport"];
assert_eq!(layout["clientWidth"].as_f64(), Some(1280.0));
assert_eq!(layout["clientHeight"].as_f64(), Some(720.0));
let visual = &result["visualViewport"];
assert_eq!(visual["scale"].as_f64(), Some(1.0));
assert_eq!(visual["clientWidth"].as_f64(), Some(1280.0));
let content = &result["contentSize"];
assert_eq!(content["width"].as_f64(), Some(1280.0));
// Without a live page the content height falls back to the viewport.
assert_eq!(content["height"].as_f64(), Some(720.0));
}
#[cfg(feature = "render")]
#[tokio::test]
async fn cdp_metrics_and_capture_follow_the_scrolled_viewport() {
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = format!("{page_id}-session");
ctx.sessions.insert(session_id.clone(), page_id);
let session = Some(session_id);
ctx.get_session_page_mut(&session)
.expect("page")
.set_viewport((100.0, 80.0));
handle(
"navigate",
&json!({
"url": "data:text/html,<html style='margin:0'><body style='margin:0'><div style='height:80px;background:red'></div><div style='height:80px;background:blue'></div><div style='position:fixed;left:0;top:0;width:20px;height:20px;background:green'></div></body></html>",
"waitUntil": "load",
}),
&mut ctx,
&session,
)
.await
.expect("navigate");
let top = handle("captureScreenshot", &json!({}), &mut ctx, &session)
.await
.expect("top capture");
ctx.get_session_page_mut(&session)
.expect("page")
.evaluate("return (window.scrollTo(0, 80), window.scrollY)");
let metrics = handle("getLayoutMetrics", &json!({}), &mut ctx, &session)
.await
.expect("metrics");
assert_eq!(metrics["layoutViewport"]["pageY"].as_f64(), Some(80.0));
assert_eq!(metrics["visualViewport"]["pageY"].as_f64(), Some(80.0));
assert_eq!(metrics["contentSize"]["width"].as_f64(), Some(100.0));
assert!(
metrics["contentSize"]["height"].as_f64().unwrap_or_default() >= 160.0,
"contentSize must expose the scrollable document: {metrics}"
);
let scrolled = handle("captureScreenshot", &json!({}), &mut ctx, &session)
.await
.expect("scrolled capture");
let top_data = top["data"].as_str().expect("top png");
let scrolled_data = scrolled["data"].as_str().expect("scrolled png");
assert_ne!(
top_data, scrolled_data,
"CDP captureScreenshot must paint the current scroll offset"
);
}
#[cfg(feature = "render")]
fn decode_capture(result: &Value) -> (image::ImageFormat, image::RgbaImage) {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(result["data"].as_str().expect("screenshot data"))
.expect("base64 screenshot");
let format = image::guess_format(&bytes).expect("image format");
let raster = image::load_from_memory(&bytes)
.expect("decodable screenshot")
.to_rgba8();
(format, raster)
}
#[cfg(feature = "render")]
async fn screenshot_fixture() -> (CdpContext, Option<String>) {
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = format!("{page_id}-session");
ctx.sessions.insert(session_id.clone(), page_id);
let session = Some(session_id);
ctx.get_session_page_mut(&session)
.expect("page")
.set_viewport((100.0, 80.0));
handle(
"navigate",
&json!({
"url": "data:text/html,<html style='margin:0'><body style='margin:0;height:160px;background:red'><div style='position:absolute;left:50px;top:0;width:50px;height:80px;background:blue'></div><div style='position:absolute;left:0;top:80px;width:100px;height:80px;background:green'></div></body></html>",
"waitUntil": "load",
}),
&mut ctx,
&session,
)
.await
.expect("navigate screenshot fixture");
(ctx, session)
}
#[cfg(feature = "render")]
#[tokio::test]
async fn screencast_initial_frame_metadata_and_encoding_match_options() {
let (mut ctx, session) = screenshot_fixture().await;
ctx.pending_events.clear();
let result = handle("startScreencast", &json!({
"format": "jpeg", "quality": 35, "maxWidth": 50, "maxHeight": 100,
}), &mut ctx, &session).await.expect("start screencast");
assert_eq!(result["obscuraFrameSource"], "command-driven");
assert_eq!(result["obscuraAutonomousFrames"], false);
assert_eq!(ctx.pending_events.len(), 2);
assert_eq!(ctx.pending_events[0].method, "Page.screencastVisibilityChanged");
let frame = &ctx.pending_events[1];
assert_eq!(frame.method, "Page.screencastFrame");
assert_eq!(frame.session_id, session);
let (format, raster) = decode_capture(&frame.params);
assert_eq!(format, image::ImageFormat::Jpeg);
assert_eq!(raster.dimensions(), (50, 40));
assert_eq!(frame.params["metadata"]["deviceWidth"], 100.0);
assert_eq!(frame.params["metadata"]["deviceHeight"], 80.0);
assert_eq!(frame.params["metadata"]["scrollOffsetY"], 0.0);
assert!(frame.params["metadata"]["timestamp"].as_f64().unwrap_or(0.0) > 0.0);
assert_eq!(frame.params["sessionId"], 1);
}
#[cfg(feature = "render")]
#[tokio::test]
async fn screencast_sampling_backpressure_and_stale_acks_are_bounded() {
let (mut ctx, session) = screenshot_fixture().await;
ctx.pending_events.clear();
handle("startScreencast", &json!({"everyNthFrame": 2}), &mut ctx, &session)
.await.expect("start sampled stream");
let old_id = ctx.pending_events[1].params["sessionId"].as_i64().unwrap();
ctx.pending_events.clear();
assert!(!queue_screencast_frame(&mut ctx, &session, false).unwrap());
assert!(queue_screencast_frame(&mut ctx, &session, false).unwrap());
assert!(!queue_screencast_frame(&mut ctx, &session, false).unwrap(),
"two unacknowledged frames must apply backpressure before capture");
let key = session.as_ref().unwrap();
handle("screencastFrameAck", &json!({"sessionId": old_id + 99}), &mut ctx, &session)
.await.expect("stale ack");
assert_eq!(ctx.screencasts[key].frames_in_flight, 2);
handle("screencastFrameAck", &json!({"sessionId": old_id}), &mut ctx, &session)
.await.expect("current ack");
assert_eq!(ctx.screencasts[key].frames_in_flight, 1);
ctx.pending_events.clear();
handle("startScreencast", &json!({}), &mut ctx, &session).await.expect("restart");
let new_id = ctx.pending_events[1].params["sessionId"].as_i64().unwrap();
assert!(new_id > old_id);
handle("screencastFrameAck", &json!({"sessionId": old_id}), &mut ctx, &session)
.await.expect("old generation ack");
assert_eq!(ctx.screencasts[key].frames_in_flight, 1);
handle("stopScreencast", &json!({}), &mut ctx, &session).await.expect("stop");
assert!(!ctx.screencasts.contains_key(key));
assert!(!queue_screencast_frame(&mut ctx, &session, false).unwrap());
}
#[cfg(feature = "render")]
#[test]
fn screencast_options_validate_protocol_shapes() {
assert!(parse_screencast_state(&json!({"format": "webp"}), 1).is_err());
assert!(parse_screencast_state(&json!({"everyNthFrame": 0}), 1).is_err());
assert!(parse_screencast_state(&json!({"maxWidth": 20.5}), 1).is_err());
let state = parse_screencast_state(
&json!({"quality": 101, "maxWidth": 0, "maxHeight": -1}), 1,
).expect("Chromium-compatible fallbacks");
assert_eq!(state.quality, DEFAULT_SCREENSHOT_QUALITY as u8);
assert_eq!(state.max_width, None);
assert_eq!(state.max_height, None);
}
#[cfg(feature = "render")]
#[tokio::test]
async fn capture_screenshot_preserves_default_png_and_honors_clip_scale() {
let (mut ctx, session) = screenshot_fixture().await;
let native = {
let page = ctx.get_session_page(&session).expect("page");
page.screenshot(page.viewport).expect("native png")
};
let default = handle("captureScreenshot", &json!({}), &mut ctx, &session)
.await
.expect("default screenshot");
use base64::Engine as _;
let default_bytes = base64::engine::general_purpose::STANDARD
.decode(default["data"].as_str().expect("default data"))
.expect("default base64");
assert_eq!(
default_bytes, native,
"default CDP path must preserve the renderer PNG bytes exactly"
);
let clipped = handle(
"captureScreenshot",
&json!({
"captureBeyondViewport": true,
"clip": {"x": 50.0, "y": 0.0, "width": 50.0, "height": 40.0, "scale": 2.0}
}),
&mut ctx,
&session,
)
.await
.expect("clipped screenshot");
let (format, raster) = decode_capture(&clipped);
assert_eq!(format, image::ImageFormat::Png);
assert_eq!(raster.dimensions(), (100, 80));
let center = raster.get_pixel(50, 40).0;
assert!(
center[2] > 200 && center[0] < 50,
"clip x/y must select the blue half of the live surface: {center:?}"
);
ctx.get_session_page_mut(&session)
.expect("page")
.evaluate("window.scrollTo(0, 80)");
let page_coordinate_clip = handle(
"captureScreenshot",
&json!({
"clip": {"x": 0.0, "y": 80.0, "width": 20.0, "height": 20.0, "scale": 1.0}
}),
&mut ctx,
&session,
)
.await
.expect("clip in page coordinates after scroll");
let (_, raster) = decode_capture(&page_coordinate_clip);
let center = raster.get_pixel(10, 10).0;
assert!(
center[1] > 80 && center[0] < 50 && center[2] < 50,
"clip coordinates must remain page-relative after scrolling: {center:?}"
);
}
#[cfg(feature = "render")]
#[tokio::test]
async fn capture_screenshot_encodes_jpeg_and_lossless_webp() {
let (mut ctx, session) = screenshot_fixture().await;
let jpeg = handle(
"captureScreenshot",
&json!({"format": "jpeg", "quality": 35}),
&mut ctx,
&session,
)
.await
.expect("jpeg screenshot");
let (format, raster) = decode_capture(&jpeg);
assert_eq!(format, image::ImageFormat::Jpeg);
assert_eq!(raster.dimensions(), (100, 80));
let fast_png = handle(
"captureScreenshot",
&json!({"optimizeForSpeed": true}),
&mut ctx,
&session,
)
.await
.expect("fast PNG screenshot");
let (format, raster) = decode_capture(&fast_png);
assert_eq!(format, image::ImageFormat::Png);
assert_eq!(raster.dimensions(), (100, 80));
let webp = handle(
"captureScreenshot",
&json!({"format": "webp"}),
&mut ctx,
&session,
)
.await
.expect("lossless webp screenshot");
let (format, raster) = decode_capture(&webp);
assert_eq!(format, image::ImageFormat::WebP);
assert_eq!(raster.dimensions(), (100, 80));
let error = handle(
"captureScreenshot",
&json!({"format": "webp", "quality": 35}),
&mut ctx,
&session,
)
.await
.expect_err("lossy WebP must not be silently faked");
assert!(error.contains("lossless encoder"), "{error}");
}
#[cfg(feature = "render")]
#[test]
fn capture_screenshot_validates_like_chromium() {
let options = parse_screenshot_options(&json!({
"format": "jpeg",
"quality": -1,
}))
.expect("out-of-range integer quality falls back");
assert_eq!(options.quality, 80);
let options = parse_screenshot_options(&json!({
"format": "jpeg",
"quality": 101,
}))
.expect("out-of-range integer quality falls back");
assert_eq!(options.quality, 80);
assert_eq!(
parse_screenshot_options(&json!({"format": "gif"})).expect_err("bad format"),
"Invalid image format"
);
assert!(parse_screenshot_options(&json!({"format": 3}))
.expect_err("format type")
.contains("format must be a string"));
assert!(parse_screenshot_options(&json!({"quality": 50.5}))
.expect_err("quality type")
.contains("quality must be an integer"));
assert!(parse_screenshot_options(&json!({"fromSurface": "false"}))
.expect_err("fromSurface type")
.contains("fromSurface must be a boolean"));
assert!(parse_screenshot_options(&json!({"captureBeyondViewport": 1}))
.expect_err("captureBeyondViewport type")
.contains("captureBeyondViewport must be a boolean"));
assert_eq!(
parse_screenshot_options(&json!({
"clip": {"x": 0, "y": 0, "width": 0, "height": 10, "scale": 1}
}))
.expect_err("zero width"),
"Cannot take screenshot with 0 width."
);
assert!(parse_screenshot_options(&json!({
"clip": {"x": 0, "y": 0, "width": 10, "height": 10}
}))
.expect_err("missing scale")
.contains("mandatory clip.scale field missing"));
}
#[cfg(feature = "render")]
#[tokio::test]
async fn capture_screenshot_rejects_unrepresented_surfaces() {
let (mut ctx, session) = screenshot_fixture().await;
let beyond = handle(
"captureScreenshot",
&json!({"captureBeyondViewport": true}),
&mut ctx,
&session,
)
.await
.expect_err("full-page recording is unsupported");
assert!(beyond.contains("captureBeyondViewport=true"), "{beyond}");
let off_surface = handle(
"captureScreenshot",
&json!({"fromSurface": false}),
&mut ctx,
&session,
)
.await
.expect_err("browser-window compositor is unsupported");
assert!(off_surface.contains("fromSurface=false"), "{off_surface}");
let off_viewport = handle(
"captureScreenshot",
&json!({
"clip": {"x": 90, "y": 0, "width": 20, "height": 20, "scale": 1}
}),
&mut ctx,
&session,
)
.await
.expect_err("partial off-viewport clip is unsupported");
assert!(
off_viewport.contains("outside the current viewport surface"),
"{off_viewport}"
);
}
#[tokio::test]
async fn unknown_page_method_still_errors() {
let mut ctx = CdpContext::new();
let err = handle("notARealMethod", &json!({}), &mut ctx, &None)
.await
.expect_err("unknown methods must surface as errors");
assert!(err.contains("Unknown Page method"));
}
#[tokio::test]
async fn print_to_pdf_returns_descriptive_unsupported_error() {
// Regression for #53: Page.printToPDF must be handled explicitly so
// Playwright clients receive a descriptive error rather than the
// generic "Unknown Page method" fallback.
let mut ctx = CdpContext::new();
let err = handle("printToPDF", &json!({}), &mut ctx, &None)
.await
.expect_err("printToPDF must error until a real renderer exists");
assert!(
!err.contains("Unknown Page method"),
"printToPDF must NOT fall through to the catch-all: {err}"
);
assert!(
err.contains("not supported by Obscura"),
"error must clearly state PDF is unsupported: {err}"
);
// Direct user to a workaround so the message is actionable.
assert!(
err.to_lowercase().contains("evaluate")
|| err.to_lowercase().contains("html"),
"error must point to a workaround: {err}"
);
}
/// Regression for #45: same idea as printToPDF for captureScreenshot.
/// Playwright's `page.screenshot()` calls Page.captureScreenshot via CDP;
/// without an explicit arm, clients see "Unknown Page method" and have
/// no idea why their screenshot request failed.
#[tokio::test]
async fn capture_screenshot_returns_descriptive_unsupported_error() {
let mut ctx = CdpContext::new();
let err = handle("captureScreenshot", &json!({}), &mut ctx, &None)
.await
.expect_err("captureScreenshot must error until a real paint exists");
assert!(
!err.contains("Unknown Page method"),
"captureScreenshot must NOT fall through to the catch-all: {err}"
);
#[cfg(not(feature = "render"))]
assert!(
err.contains("requires a build with the render feature"),
"error must clearly state screenshot needs the render feature: {err}"
);
// Same for the MHTML snapshot sibling method.
let err2 = handle("captureSnapshot", &json!({}), &mut ctx, &None)
.await
.expect_err("captureSnapshot must error until a real renderer exists");
assert!(
!err2.contains("Unknown Page method"),
"captureSnapshot must NOT fall through: {err2}"
);
}
#[tokio::test]
async fn navigation_emits_target_info_changed_with_url_and_title() {
// Strict CDP clients (browser-use, Puppeteer/Playwright `page.url()`)
// refresh a target's url/title only on Target.targetInfoChanged. A
// navigation must emit it with the post-nav url/title, otherwise those
// clients stay stuck on the pre-nav about:blank.
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = format!("{}-session", page_id);
ctx.sessions.insert(session_id.clone(), page_id.clone());
let params = json!({
"url": "data:text/html,<title>Hello</title><button>B</button>",
"waitUntil": "load",
});
handle("navigate", &params, &mut ctx, &Some(session_id.clone()))
.await
.expect("navigate should succeed");
let evt = ctx
.pending_events
.iter()
.find(|e| e.method == "Target.targetInfoChanged")
.expect("navigation must emit Target.targetInfoChanged");
// Browser-level event (no sessionId) so the root connection's
// targetInfoChanged handler receives it.
assert!(
evt.session_id.is_none(),
"targetInfoChanged must be browser-level (no sessionId)"
);
let info = evt.params["targetInfo"].clone();
// The payload must carry the live post-navigation url/title and the
// canAccessOpener field strict clients require on every TargetInfo.
let (exp_url, exp_title) = {
let page = ctx.get_page(&page_id).expect("page exists");
(page.url_string(), page.title.clone())
};
assert_eq!(info["targetId"], json!(page_id));
assert_eq!(info["type"], "page");
assert_eq!(info["url"], json!(exp_url));
assert_eq!(info["title"], json!(exp_title));
assert!(
info["url"].as_str().unwrap_or_default().starts_with("data:"),
"url should reflect the navigated page, got {}",
info["url"]
);
assert_eq!(info["canAccessOpener"], json!(false));
}
}