1641 lines
71 KiB
Rust
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(¤t_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", ¶ms, &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));
|
|
}
|
|
}
|