Improve modern rendering and output parity

This commit is contained in:
SGavrl committed 2026-08-03 19:46:24 +00:00
1 parent f428aa37fa
commit c8a5477296
25 files changed
+12885 -5053

No files matched your search

+103 -52
View File
@@ -11,7 +11,10 @@ use crate::types::{CdpEvent, CdpRequest, CdpResponse};
#[cfg(feature = "render")]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ScreencastFormat { Png, Jpeg }
pub(crate) enum ScreencastFormat {
Png,
Jpeg,
}
#[cfg(feature = "render")]
#[derive(Clone, Debug)]
@@ -257,10 +260,15 @@ impl CdpContext {
self.pages.retain(|p| p.id != id);
#[cfg(feature = "render")]
{
let removed: Vec<String> = self.sessions.iter()
let removed: Vec<String> = self
.sessions
.iter()
.filter(|(_, page_id)| page_id.as_str() == id)
.map(|(session_id, _)| session_id.clone()).collect();
for session_id in removed { self.screencasts.remove(&session_id); }
.map(|(session_id, _)| session_id.clone())
.collect();
for session_id in removed {
self.screencasts.remove(&session_id);
}
}
self.sessions.retain(|_, v| v != id);
}
@@ -273,9 +281,7 @@ impl CdpContext {
}
pub fn get_session_page(&self, session_id: &Option<String>) -> Option<&Page> {
let page_id = session_id
.as_ref()
.and_then(|sid| self.sessions.get(sid))?;
let page_id = session_id.as_ref().and_then(|sid| self.sessions.get(sid))?;
self.get_page(page_id)
}
@@ -311,38 +317,67 @@ impl CdpContext {
/// reads. `get_session_page_mut` triggers `suspend_js`/`resume_js` and
/// must stay behind the lock.
fn is_v8_free_method(method: &str) -> bool {
matches!(method,
"Target.getTargets" | "Target.setDiscoverTargets"
| "Target.attachToTarget" | "Target.attachToBrowserTarget"
| "Target.setAutoAttach"
| "Target.getBrowserContexts" | "Target.createBrowserContext"
| "Target.disposeBrowserContext" | "Target.getTargetInfo"
| "Target.detachFromTarget" | "Target.activateTarget"
| "Browser.getVersion" | "Browser.close" | "Browser.getWindowForTarget"
| "Browser.setDownloadBehavior" | "Browser.getWindowBounds" | "Browser.setWindowBounds"
| "Page.enable" | "Page.disable" | "Page.getFrameTree"
| "Page.setDownloadBehavior"
| "Page.setLifecycleEventsEnabled"
| "Page.addScriptToEvaluateOnNewDocument" | "Page.removeScriptToEvaluateOnNewDocument"
| "Page.setInterceptFileChooserDialog" | "Page.getNavigationHistory"
| "Page.resetNavigationHistory" | "Page.printToPDF"
| "Page.captureSnapshot"
| "Page.stopScreencast" | "Page.screencastFrameAck"
| "Page.createIsolatedWorld"
| "Runtime.enable" | "Runtime.disable"
| "Runtime.runIfWaitingForDebugger" | "Runtime.getExceptionDetails"
| "Runtime.discardConsoleEntries"
| "Network.enable" | "Network.disable" | "Network.setCacheDisabled"
| "Network.setRequestInterception" | "Network.setBlockedURLs"
| "Network.setExtraHTTPHeaders"
| "Network.setUserAgentOverride"
| "Network.getCookies" | "Network.getAllCookies"
| "Network.setCookie" | "Network.setCookies"
| "Network.deleteCookies" | "Network.clearBrowserCookies"
| "Network.getResponseBody"
| "Fetch.continueRequest" | "Fetch.fulfillRequest"
| "Fetch.failRequest" | "Fetch.getResponseBody"
| "Storage.getCookies" | "Storage.setCookies" | "Storage.deleteCookies"
matches!(
method,
"Target.getTargets"
| "Target.setDiscoverTargets"
| "Target.attachToTarget"
| "Target.attachToBrowserTarget"
| "Target.setAutoAttach"
| "Target.getBrowserContexts"
| "Target.createBrowserContext"
| "Target.disposeBrowserContext"
| "Target.getTargetInfo"
| "Target.detachFromTarget"
| "Target.activateTarget"
| "Browser.getVersion"
| "Browser.close"
| "Browser.getWindowForTarget"
| "Browser.setDownloadBehavior"
| "Browser.getWindowBounds"
| "Browser.setWindowBounds"
| "Page.enable"
| "Page.disable"
| "Page.getFrameTree"
| "Page.setDownloadBehavior"
| "Page.setLifecycleEventsEnabled"
| "Page.addScriptToEvaluateOnNewDocument"
| "Page.removeScriptToEvaluateOnNewDocument"
| "Page.setInterceptFileChooserDialog"
| "Page.getNavigationHistory"
| "Page.resetNavigationHistory"
| "Page.captureSnapshot"
| "Page.stopScreencast"
| "Page.screencastFrameAck"
| "Page.createIsolatedWorld"
| "Runtime.enable"
| "Runtime.disable"
| "Runtime.runIfWaitingForDebugger"
| "Runtime.getExceptionDetails"
| "Runtime.discardConsoleEntries"
| "Network.enable"
| "Network.disable"
| "Network.setCacheDisabled"
| "Network.setRequestInterception"
| "Network.setBlockedURLs"
| "Network.setExtraHTTPHeaders"
| "Network.setUserAgentOverride"
| "Network.getCookies"
| "Network.getAllCookies"
| "Network.setCookie"
| "Network.setCookies"
| "Network.deleteCookies"
| "Network.clearBrowserCookies"
| "Network.getResponseBody"
| "Fetch.continueRequest"
| "Fetch.fulfillRequest"
| "Fetch.failRequest"
| "Fetch.getResponseBody"
| "IO.read"
| "IO.close"
| "Storage.getCookies"
| "Storage.setCookies"
| "Storage.deleteCookies"
)
}
@@ -401,7 +436,9 @@ pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse {
} else {
ctx.get_session_page(&req.session_id)
.and_then(|p| p.isolate_handle())
.map(|h| obscura_js::cdp_watchdog::arm(h, std::time::Duration::from_millis(cmd_budget_ms)))
.map(|h| {
obscura_js::cdp_watchdog::arm(h, std::time::Duration::from_millis(cmd_budget_ms))
})
};
let (domain, method) = match req.method.split_once('.') {
@@ -421,7 +458,9 @@ pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse {
"Browser" => domains::browser::handle(method, &req.params).await,
"Page" => domains::page::handle(method, &req.params, ctx, &req.session_id).await,
"DOM" => domains::dom::handle(method, &req.params, ctx, &req.session_id).await,
"DOMSnapshot" => domains::domsnapshot::handle(method, &req.params, ctx, &req.session_id).await,
"DOMSnapshot" => {
domains::domsnapshot::handle(method, &req.params, ctx, &req.session_id).await
}
"Runtime" => domains::runtime::handle(method, &req.params, ctx, &req.session_id).await,
"Network" => domains::network::handle(method, &req.params, ctx, &req.session_id).await,
"Fetch" => domains::fetch::handle(method, &req.params, ctx, &req.session_id).await,
@@ -430,16 +469,14 @@ pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse {
"Emulation" => domains::emulation::handle(method, &req.params, ctx, &req.session_id).await,
"Storage" => domains::storage::handle(method, &req.params, ctx, &req.session_id).await,
"LP" => domains::lp::handle(method, &req.params, ctx, &req.session_id).await,
"Accessibility" => domains::accessibility::handle(method, &req.params, ctx, &req.session_id).await,
"Accessibility" => {
domains::accessibility::handle(method, &req.params, ctx, &req.session_id).await
}
// Accepted but no-op. Puppeteer's FrameManager.initialize calls
// Audits.enable on connect — refusing it breaks puppeteer.connect()
// before any user code runs.
"Log" | "Performance" | "Security" | "CSS"
| "ServiceWorker" | "Inspector"
| "Debugger" | "Profiler" | "HeapProfiler" | "Overlay"
| "Audits" => {
Ok(json!({}))
}
"Log" | "Performance" | "Security" | "CSS" | "ServiceWorker" | "Inspector" | "Debugger"
| "Profiler" | "HeapProfiler" | "Overlay" | "Audits" => Ok(json!({})),
_ => Err(format!("Unknown domain: {}", domain)),
};
@@ -599,7 +636,11 @@ mod tests {
async fn audits_enable_returns_empty_success() {
let mut ctx = CdpContext::new();
let resp = dispatch(&req("Audits.enable"), &mut ctx).await;
assert!(resp.error.is_none(), "Audits.enable should not error: {:?}", resp.error);
assert!(
resp.error.is_none(),
"Audits.enable should not error: {:?}",
resp.error
);
assert_eq!(resp.result, Some(json!({})));
}
@@ -631,7 +672,11 @@ mod tests {
};
let resp = dispatch(&outer, &mut ctx).await;
assert!(resp.error.is_none(), "wrapper must succeed: {:?}", resp.error);
assert!(
resp.error.is_none(),
"wrapper must succeed: {:?}",
resp.error
);
assert_eq!(resp.id, 99);
assert_eq!(resp.result, Some(json!({})));
@@ -647,8 +692,14 @@ mod tests {
let parsed: serde_json::Value = serde_json::from_str(inner_msg).unwrap();
assert_eq!(parsed["id"], 42);
// Browser.getVersion returns a populated result object, not an error.
assert!(parsed.get("result").is_some(), "inner response carries result");
assert!(parsed.get("error").is_none(), "inner response is not an error");
assert!(
parsed.get("result").is_some(),
"inner response carries result"
);
assert!(
parsed.get("error").is_none(),
"inner response is not an error"
);
}
#[tokio::test]
+122 -23
View File
@@ -2,6 +2,22 @@ use serde_json::{json, Value};
use crate::dispatch::CdpContext;
const MAX_DEVICE_METRIC_DIMENSION: i64 = 10_000_000;
const DEFAULT_VIEWPORT: (f32, f32) = (1280.0, 720.0);
fn metric_dimension(params: &Value, name: &str) -> Result<u32, String> {
let value = params
.get(name)
.and_then(Value::as_i64)
.ok_or_else(|| format!("Emulation.setDeviceMetricsOverride requires integer {name}"))?;
if !(0..=MAX_DEVICE_METRIC_DIMENSION).contains(&value) {
return Err(format!(
"Emulation.setDeviceMetricsOverride {name} must be between 0 and {MAX_DEVICE_METRIC_DIMENSION}"
));
}
Ok(value as u32)
}
pub async fn handle(
method: &str,
params: &Value,
@@ -10,31 +26,42 @@ pub async fn handle(
) -> Result<Value, String> {
match method {
"setDeviceMetricsOverride" => {
let width = params
.get("width")
let width = metric_dimension(params, "width")?;
let height = metric_dimension(params, "height")?;
let device_scale_factor = params
.get("deviceScaleFactor")
.and_then(Value::as_f64)
.ok_or("Emulation.setDeviceMetricsOverride requires width")?;
let height = params
.get("height")
.and_then(Value::as_f64)
.ok_or("Emulation.setDeviceMetricsOverride requires height")?;
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
.ok_or("Emulation.setDeviceMetricsOverride requires deviceScaleFactor")?;
if !device_scale_factor.is_finite() || device_scale_factor < 0.0 {
return Err(
"Emulation.setDeviceMetricsOverride requires positive finite dimensions"
"Emulation.setDeviceMetricsOverride requires a non-negative finite deviceScaleFactor"
.to_string(),
);
}
let page = ctx
.get_session_page_mut(session_id)
.ok_or("No page for session")?;
page.set_viewport((width as f32, height as f32));
page.set_viewport((
if width == 0 {
DEFAULT_VIEWPORT.0
} else {
width as f32
},
if height == 0 {
DEFAULT_VIEWPORT.1
} else {
height as f32
},
));
page.set_device_scale_factor(device_scale_factor as f32);
Ok(json!({}))
}
"clearDeviceMetricsOverride" => {
let page = ctx
.get_session_page_mut(session_id)
.ok_or("No page for session")?;
page.set_viewport((1280.0, 720.0));
page.set_viewport(DEFAULT_VIEWPORT);
page.set_device_scale_factor(1.0);
Ok(json!({}))
}
// Touch emulation does not affect layout yet, but acknowledging it is
@@ -53,12 +80,11 @@ mod tests {
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = Some("viewport-session".to_string());
ctx.sessions
.insert(session_id.clone().unwrap(), page_id);
ctx.sessions.insert(session_id.clone().unwrap(), page_id);
handle(
"setDeviceMetricsOverride",
&json!({"width": 1024, "height": 768, "deviceScaleFactor": 1}),
&json!({"width": 1024, "height": 768, "deviceScaleFactor": 2}),
&mut ctx,
&session_id,
)
@@ -69,30 +95,103 @@ mod tests {
.get_session_page_mut(&session_id)
.expect("page for session");
assert_eq!(page.viewport, (1024.0, 768.0));
assert_eq!(page.device_scale_factor, 2.0);
assert_eq!(
page.evaluate(
"return [innerWidth, innerHeight, visualViewport.width,\
visualViewport.height];"
visualViewport.height, devicePixelRatio];"
),
json!([1024, 768, 1024, 768])
json!([1024, 768, 1024, 768, 2])
);
}
#[tokio::test]
async fn device_metrics_override_rejects_zero_dimensions() {
async fn device_metrics_override_rejects_fractional_and_out_of_range_dimensions() {
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = Some("viewport-session".to_string());
ctx.sessions
.insert(session_id.clone().unwrap(), page_id);
let error = handle(
ctx.sessions.insert(session_id.clone().unwrap(), page_id);
for params in [
json!({"width": 0.5, "height": 768, "deviceScaleFactor": 1}),
json!({"width": 10_000_001, "height": 768, "deviceScaleFactor": 1}),
json!({"width": -1, "height": 768, "deviceScaleFactor": 1}),
] {
assert!(
handle("setDeviceMetricsOverride", &params, &mut ctx, &session_id)
.await
.is_err(),
"must reject {params}"
);
}
}
#[tokio::test]
async fn zero_dimensions_disable_size_override() {
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = Some("zero-size-session".to_string());
ctx.sessions.insert(session_id.clone().unwrap(), page_id);
handle(
"setDeviceMetricsOverride",
&json!({"width": 0, "height": 768}),
&json!({"width": 0, "height": 0, "deviceScaleFactor": 0}),
&mut ctx,
&session_id,
)
.await
.expect_err("zero width must fail");
assert!(error.contains("positive finite dimensions"));
.expect("zero disables the overrides");
let page = ctx.get_session_page_mut(&session_id).unwrap();
assert_eq!(page.viewport, DEFAULT_VIEWPORT);
assert_eq!(page.device_scale_factor, 1.0);
}
#[tokio::test]
async fn device_scale_factor_zero_disables_override_and_clear_restores_defaults() {
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = Some("scale-session".to_string());
ctx.sessions.insert(session_id.clone().unwrap(), page_id);
handle(
"setDeviceMetricsOverride",
&json!({"width": 640, "height": 480, "deviceScaleFactor": 3}),
&mut ctx,
&session_id,
)
.await
.unwrap();
assert_eq!(
ctx.get_session_page(&session_id)
.unwrap()
.device_scale_factor,
3.0
);
handle(
"setDeviceMetricsOverride",
&json!({"width": 640, "height": 480, "deviceScaleFactor": 0}),
&mut ctx,
&session_id,
)
.await
.expect("zero disables the scale override");
assert_eq!(
ctx.get_session_page(&session_id)
.unwrap()
.device_scale_factor,
1.0
);
handle(
"clearDeviceMetricsOverride",
&json!({}),
&mut ctx,
&session_id,
)
.await
.unwrap();
let page = ctx.get_session_page(&session_id).unwrap();
assert_eq!(page.viewport, (1280.0, 720.0));
assert_eq!(page.device_scale_factor, 1.0);
}
}
+17 -7
View File
@@ -115,10 +115,19 @@ pub async fn handle(
if let Some(paused) = ctx.fetch_intercept.paused.remove(request_id) {
let _ = paused.resolver.send(FetchResolution::Continue {
url: params.get("url").and_then(|v| v.as_str()).map(|s| s.to_string()),
method: params.get("method").and_then(|v| v.as_str()).map(|s| s.to_string()),
url: params
.get("url")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
method: params
.get("method")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
headers: None,
post_data: params.get("postData").and_then(|v| v.as_str()).map(|s| s.to_string()),
post_data: params
.get("postData")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
});
}
Ok(json!({}))
@@ -178,9 +187,7 @@ pub async fn handle(
}
Ok(json!({}))
}
"getResponseBody" => {
Ok(json!({ "body": "", "base64Encoded": false }))
}
"getResponseBody" => Ok(json!({ "body": "", "base64Encoded": false })),
"takeResponseBodyAsStream" => {
// Hand the client a streaming handle for a large response body so it
// can pull it in chunks via IO.read and free it with IO.close,
@@ -206,7 +213,10 @@ pub async fn handle(
format!("Fetch.takeResponseBodyAsStream: no cached body for {request_id}")
})?;
let handle = ctx.io_streams.insert(bytes);
let handle = ctx
.io_streams
.insert(bytes)
.map_err(|error| format!("Fetch.takeResponseBodyAsStream: {error}"))?;
Ok(json!({ "stream": handle }))
}
_ => Err(format!("Unknown Fetch method: {}", method)),
+150 -50
View File
@@ -9,6 +9,7 @@ use crate::dispatch::CdpContext;
// order of magnitude; keeping chunks bounded is the point of streaming (issue
// #360), so we never return the whole body in one IO.read.
const DEFAULT_CHUNK: usize = 1 << 20; // 1 MiB
const MAX_READ_CHUNK: usize = 4 << 20; // 4 MiB
fn io_stream_max_entries() -> usize {
std::env::var("OBSCURA_IO_STREAM_MAX_ENTRIES")
@@ -35,48 +36,90 @@ fn io_stream_max_bytes() -> usize {
/// memory stays bounded regardless of client behavior. Reading an evicted
/// handle fails cleanly (the client re-takes or gives up), which is the right
/// trade against an OOM.
#[derive(Default)]
pub struct IoStreamStore {
streams: HashMap<String, (Vec<u8>, usize)>,
order: VecDeque<String>,
total_bytes: usize,
counter: u64,
max_entries: usize,
max_bytes: usize,
}
impl Default for IoStreamStore {
fn default() -> Self {
Self::with_limits(io_stream_max_entries(), io_stream_max_bytes())
}
}
impl IoStreamStore {
/// Store a body and return its handle, evicting the oldest streams if this
/// pushes the store past its entry or byte cap.
pub fn insert(&mut self, bytes: Vec<u8>) -> String {
let handle = format!("stream-{}", self.counter);
self.counter += 1;
self.total_bytes += bytes.len();
self.streams.insert(handle.clone(), (bytes, 0));
self.order.push_back(handle.clone());
fn with_limits(max_entries: usize, max_bytes: usize) -> Self {
Self {
streams: HashMap::new(),
order: VecDeque::new(),
total_bytes: 0,
counter: 0,
max_entries: max_entries.max(1),
max_bytes,
}
}
let max_entries = io_stream_max_entries().max(1);
let max_bytes = io_stream_max_bytes();
// Evict oldest streams while over either cap. Never evict the stream we
// just inserted (it sits at the back of `order`); a lone oversized body
// is kept, since the client explicitly asked to stream it.
while self.order.len() > 1
&& (self.order.len() > max_entries || self.total_bytes > max_bytes)
/// Store a body and return its handle, evicting the oldest streams if this
/// would push the store past its entry or byte cap. A single body larger
/// than the byte cap is rejected rather than becoming an unbounded
/// exception to the store's memory contract.
pub fn insert(&mut self, bytes: Vec<u8>) -> Result<String, String> {
if bytes.len() > self.max_bytes {
return Err(format!(
"IO stream body is {} bytes, exceeding the {}-byte per-context limit",
bytes.len(),
self.max_bytes,
));
}
while !self.order.is_empty()
&& (self.order.len() >= self.max_entries
|| self
.total_bytes
.checked_add(bytes.len())
.is_none_or(|total| total > self.max_bytes))
{
if let Some(oldest) = self.order.pop_front() {
if let Some((b, _)) = self.streams.remove(&oldest) {
self.total_bytes -= b.len();
if let Some((body, _)) = self.streams.remove(&oldest) {
self.total_bytes = self.total_bytes.saturating_sub(body.len());
}
}
}
handle
let handle = format!("stream-{}", self.counter);
self.counter = self
.counter
.checked_add(1)
.ok_or("IO stream handle space exhausted")?;
self.total_bytes = self
.total_bytes
.checked_add(bytes.len())
.ok_or("IO stream byte accounting overflow")?;
self.streams.insert(handle.clone(), (bytes, 0));
self.order.push_back(handle.clone());
Ok(handle)
}
/// Read up to `size` bytes from the stream, advancing its cursor. Returns
/// the base64 chunk and whether EOF was reached, or None for an unknown or
/// already-freed handle.
pub fn read(&mut self, handle: &str, size: usize) -> Option<(String, bool)> {
pub fn read(
&mut self,
handle: &str,
offset: Option<usize>,
size: usize,
) -> Option<(String, bool)> {
let (bytes, cursor) = self.streams.get_mut(handle)?;
if let Some(offset) = offset {
*cursor = offset.min(bytes.len());
}
let size = size.min(MAX_READ_CHUNK);
let start = (*cursor).min(bytes.len());
let end = start.saturating_add(size.max(1)).min(bytes.len());
let end = start.saturating_add(size).min(bytes.len());
let data = base64::engine::general_purpose::STANDARD.encode(&bytes[start..end]);
*cursor = end;
Some((data, end >= bytes.len()))
@@ -103,13 +146,29 @@ pub async fn handle(method: &str, params: &Value, ctx: &mut CdpContext) -> Resul
.ok_or("IO.read requires handle")?;
let size = params
.get("size")
.and_then(|v| v.as_u64())
.map(|s| s as usize)
.map(|value| {
value
.as_i64()
.filter(|size| *size >= 0)
.and_then(|size| usize::try_from(size).ok())
.ok_or("IO.read size must be a non-negative integer")
})
.transpose()?
.unwrap_or(DEFAULT_CHUNK);
let offset = params
.get("offset")
.map(|value| {
value
.as_i64()
.filter(|offset| *offset >= 0)
.and_then(|offset| usize::try_from(offset).ok())
.ok_or("IO.read offset must be a non-negative integer")
})
.transpose()?;
let (data, eof) = ctx
.io_streams
.read(handle, size)
.read(handle, offset, size)
.ok_or_else(|| format!("IO.read: unknown handle {handle}"))?;
Ok(json!({ "data": data, "eof": eof, "base64Encoded": true }))
@@ -136,48 +195,89 @@ mod tests {
#[test]
fn reads_chunks_then_frees() {
let mut store = IoStreamStore::default();
let h = store.insert(b"hello".to_vec());
let mut store = IoStreamStore::with_limits(4, 1024);
let h = store.insert(b"hello".to_vec()).unwrap();
let (d1, eof1) = store.read(&h, 3).unwrap();
let (d1, eof1) = store.read(&h, None, 3).unwrap();
assert_eq!(decode(&d1), b"hel");
assert!(!eof1);
let (d2, eof2) = store.read(&h, 3).unwrap();
let (d2, eof2) = store.read(&h, None, 3).unwrap();
assert_eq!(decode(&d2), b"lo");
assert!(eof2);
store.remove(&h);
assert!(store.read(&h, 3).is_none());
assert!(store.read(&h, None, 3).is_none());
}
#[test]
fn read_offset_seeks_and_zero_size_does_not_advance() {
let mut store = IoStreamStore::with_limits(2, 1024);
let handle = store.insert(b"abcdef".to_vec()).unwrap();
let (empty, eof) = store.read(&handle, Some(1), 0).unwrap();
assert_eq!(decode(&empty), b"");
assert!(!eof);
let (middle, eof) = store.read(&handle, None, 2).unwrap();
assert_eq!(decode(&middle), b"bc");
assert!(!eof);
let (tail, eof) = store.read(&handle, Some(4), 10).unwrap();
assert_eq!(decode(&tail), b"ef");
assert!(eof);
}
#[tokio::test]
async fn read_rejects_negative_or_non_integer_ranges() {
let mut ctx = CdpContext::new();
let handle_id = ctx.io_streams.insert(b"data".to_vec()).unwrap();
for params in [
json!({"handle": handle_id.clone(), "offset": -1}),
json!({"handle": handle_id.clone(), "size": -1}),
json!({"handle": handle_id, "offset": 1.5}),
] {
assert!(handle("read", &params, &mut ctx).await.is_err(), "{params}");
}
}
#[test]
fn evicts_oldest_over_entry_cap() {
std::env::set_var("OBSCURA_IO_STREAM_MAX_ENTRIES", "3");
let mut store = IoStreamStore::default();
let h0 = store.insert(vec![0]);
let h1 = store.insert(vec![1]);
let _h2 = store.insert(vec![2]);
let h3 = store.insert(vec![3]); // 4th entry, cap 3 -> h0 evicted
std::env::remove_var("OBSCURA_IO_STREAM_MAX_ENTRIES");
let mut store = IoStreamStore::with_limits(3, 1024);
let h0 = store.insert(vec![0]).unwrap();
let h1 = store.insert(vec![1]).unwrap();
let _h2 = store.insert(vec![2]).unwrap();
let h3 = store.insert(vec![3]).unwrap(); // 4th entry, cap 3 -> h0 evicted
assert!(store.read(&h0, 10).is_none(), "oldest stream should be evicted");
assert!(store.read(&h1, 10).is_some());
assert!(store.read(&h3, 10).is_some());
assert!(
store.read(&h0, None, 10).is_none(),
"oldest stream should be evicted"
);
assert!(store.read(&h1, None, 10).is_some());
assert!(store.read(&h3, None, 10).is_some());
}
#[test]
fn evicts_over_byte_cap_but_keeps_newest() {
std::env::set_var("OBSCURA_IO_STREAM_MAX_BYTES", "10");
let mut store = IoStreamStore::default();
let h0 = store.insert(vec![0u8; 8]);
let h1 = store.insert(vec![1u8; 8]); // 16 > 10 -> h0 evicted
// A single body larger than the cap is still kept (client asked for it).
let big = store.insert(vec![2u8; 100]);
std::env::remove_var("OBSCURA_IO_STREAM_MAX_BYTES");
fn evicts_over_byte_cap_and_rejects_oversized_body() {
let mut store = IoStreamStore::with_limits(4, 10);
let h0 = store.insert(vec![0u8; 8]).unwrap();
let h1 = store.insert(vec![1u8; 8]).unwrap(); // 16 > 10 -> h0 evicted
let error = store
.insert(vec![2u8; 100])
.expect_err("a single body cannot bypass the context byte cap");
assert!(store.read(&h0, 100).is_none());
assert!(store.read(&big, 200).is_some(), "the just-inserted body is never evicted");
let _ = h1;
assert!(store.read(&h0, None, 100).is_none());
assert!(store.read(&h1, None, 100).is_some());
assert!(error.contains("exceeding"), "{error}");
}
#[test]
fn requested_read_size_is_capped() {
let mut store = IoStreamStore::with_limits(2, MAX_READ_CHUNK * 2);
let handle = store.insert(vec![7u8; MAX_READ_CHUNK + 17]).unwrap();
let (first, eof) = store.read(&handle, None, usize::MAX).unwrap();
assert_eq!(decode(&first).len(), MAX_READ_CHUNK);
assert!(!eof);
let (second, eof) = store.read(&handle, None, usize::MAX).unwrap();
assert_eq!(decode(&second).len(), 17);
assert!(eof);
}
}
+11 -10
View File
@@ -1,14 +1,15 @@
pub mod target;
pub mod accessibility;
pub mod browser;
pub mod page;
pub mod dom;
pub mod domsnapshot;
pub mod runtime;
pub mod network;
pub mod fetch;
pub mod io;
pub mod input;
pub mod storage;
pub mod accessibility;
pub mod lp;
pub mod emulation;
pub mod fetch;
pub mod input;
pub mod io;
pub mod lp;
pub mod network;
pub mod page;
pub mod pdf;
pub mod runtime;
pub mod storage;
pub mod target;
File diff suppressed because it is too large. Load diff
+361
View File
@@ -0,0 +1,361 @@
#[cfg(feature = "render")]
use serde_json::json;
use serde_json::Value;
use crate::dispatch::CdpContext;
#[cfg(feature = "render")]
const MAX_BASE64_PDF_BYTES: usize = 64 * 1024 * 1024;
#[cfg(feature = "render")]
fn base64_encoded_len(raw_len: usize) -> Option<usize> {
raw_len.checked_add(2)?.checked_div(3)?.checked_mul(4)
}
#[cfg(feature = "render")]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PdfTransferMode {
ReturnAsBase64,
ReturnAsStream,
}
#[cfg(feature = "render")]
#[derive(Clone, Copy, Debug)]
struct ParsedPdfOptions {
raster: obscura_browser::RasterPdfOptions,
transfer_mode: PdfTransferMode,
requested_print_background: bool,
requested_tagged_pdf: bool,
}
#[cfg(feature = "render")]
fn number(params: &Value, name: &str, default: f32) -> Result<f32, String> {
match params.get(name) {
None => Ok(default),
Some(value) => value
.as_f64()
.filter(|value| value.is_finite())
.map(|value| value as f32)
.ok_or_else(|| format!("Invalid parameters: {name} must be a finite number")),
}
}
#[cfg(feature = "render")]
fn boolean(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 parse_options(params: &Value) -> Result<ParsedPdfOptions, String> {
if !params.is_object() {
return Err("Invalid parameters: expected an object".to_string());
}
let unsupported_true = [
("displayHeaderFooter", "headers and footers"),
("preferCSSPageSize", "CSS @page sizing"),
("generateDocumentOutline", "document outlines"),
];
for (name, capability) in unsupported_true {
if boolean(params, name, false)? {
return Err(format!(
"Page.printToPDF does not yet support {capability} ({name}=true)"
));
}
}
// Current Puppeteer and Playwright always send their false default. The
// raster paginator cannot suppress backgrounds without a second paint
// mode, so accept the protocol shape and disclose the limitation in the
// extension capability fields returned with the result.
let requested_print_background = boolean(params, "printBackground", false)?;
// Puppeteer currently sends true by default. Raster PDFs have no semantic
// structure to tag, but rejecting the request makes the standard client
// unusable. Accept and truthfully report `taggedPdf:false` in the response.
let requested_tagged_pdf = boolean(params, "generateTaggedPDF", false)?;
let scale = number(params, "scale", 1.0)?;
if (scale - 1.0).abs() > f32::EPSILON {
return Err(
"Page.printToPDF scale is not supported by the raster paginator; only scale=1 is accepted"
.to_string(),
);
}
if let Some(value) = params.get("pageRanges") {
let ranges = value
.as_str()
.ok_or("Invalid parameters: pageRanges must be a string")?;
if !ranges.trim().is_empty() {
return Err("Page.printToPDF pageRanges are not yet supported".to_string());
}
}
for name in ["headerTemplate", "footerTemplate"] {
if let Some(value) = params.get(name) {
let template = value
.as_str()
.ok_or_else(|| format!("Invalid parameters: {name} must be a string"))?;
if !template.is_empty() {
return Err(format!("Page.printToPDF {name} is not yet supported"));
}
}
}
let transfer_mode = match params.get("transferMode") {
None => PdfTransferMode::ReturnAsBase64,
Some(Value::String(value)) => match value.as_str() {
"ReturnAsBase64" => PdfTransferMode::ReturnAsBase64,
"ReturnAsStream" => PdfTransferMode::ReturnAsStream,
_ => return Err("Invalid parameters: unknown transferMode".into()),
},
Some(_) => return Err("Invalid parameters: transferMode must be a string".into()),
};
let defaults = obscura_browser::RasterPdfOptions::default();
Ok(ParsedPdfOptions {
raster: obscura_browser::RasterPdfOptions {
landscape: boolean(params, "landscape", defaults.landscape)?,
paper_width_in: number(params, "paperWidth", defaults.paper_width_in)?,
paper_height_in: number(params, "paperHeight", defaults.paper_height_in)?,
margin_top_in: number(params, "marginTop", defaults.margin_top_in)?,
margin_bottom_in: number(params, "marginBottom", defaults.margin_bottom_in)?,
margin_left_in: number(params, "marginLeft", defaults.margin_left_in)?,
margin_right_in: number(params, "marginRight", defaults.margin_right_in)?,
},
transfer_mode,
requested_print_background,
requested_tagged_pdf,
})
}
pub async fn print_to_pdf(
params: &Value,
ctx: &mut CdpContext,
session_id: &Option<String>,
) -> Result<Value, String> {
#[cfg(feature = "render")]
{
let options = parse_options(params)?;
let 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(deadline_ms).await;
let pdf = page
.raster_pdf(options.raster)
.map_err(|error| error.to_string())?;
let mut response = json!({
"obscuraPrintMode": "screen-raster",
"obscuraPrintBackground": true,
"obscuraRequestedPrintBackground": options.requested_print_background,
"obscuraTaggedPDF": false,
"obscuraRequestedTaggedPDF": options.requested_tagged_pdf,
"obscuraCapabilities": {
"cssPagedMedia": false,
"honorsPrintBackground": false,
"taggedPdf": false,
},
});
let mut ignored_options = Vec::new();
if !options.requested_print_background {
ignored_options.push("printBackground");
}
if options.requested_tagged_pdf {
ignored_options.push("generateTaggedPDF");
}
response["obscuraIgnoredOptions"] = json!(ignored_options);
match options.transfer_mode {
PdfTransferMode::ReturnAsBase64 => {
use base64::Engine as _;
let encoded_len = base64_encoded_len(pdf.len())
.ok_or("Page.printToPDF base64 response size overflow")?;
if encoded_len > MAX_BASE64_PDF_BYTES {
return Err(format!(
"Page.printToPDF base64 response would be {encoded_len} bytes, exceeding the {MAX_BASE64_PDF_BYTES}-byte response limit; use transferMode=ReturnAsStream"
));
}
response["data"] =
Value::String(base64::engine::general_purpose::STANDARD.encode(pdf));
}
PdfTransferMode::ReturnAsStream => {
let handle = ctx
.io_streams
.insert(pdf)
.map_err(|error| format!("Page.printToPDF could not open stream: {error}"))?;
response["data"] = Value::String(String::new());
response["stream"] = Value::String(handle);
}
}
Ok(response)
}
#[cfg(not(feature = "render"))]
{
let _ = (params, ctx, session_id);
Err("Page.printToPDF requires a build with the render feature".to_string())
}
}
#[cfg(all(test, feature = "render"))]
mod tests {
use super::*;
use crate::domains::page;
#[test]
fn parser_accepts_standard_client_defaults_and_rejects_unrepresented_features() {
let standard = parse_options(&json!({
"transferMode": "ReturnAsStream",
"displayHeaderFooter": false,
"headerTemplate": "",
"footerTemplate": "",
"printBackground": false,
"scale": 1,
"pageRanges": "",
"preferCSSPageSize": false,
"generateTaggedPDF": true,
"generateDocumentOutline": false,
}))
.expect("current Puppeteer defaults must be accepted");
assert_eq!(standard.transfer_mode, PdfTransferMode::ReturnAsStream);
assert!(!standard.requested_print_background);
assert!(standard.requested_tagged_pdf);
for params in [
json!({"displayHeaderFooter": true}),
json!({"preferCSSPageSize": true}),
json!({"scale": 0.5}),
json!({"pageRanges": "2-3"}),
json!({"headerTemplate": "<span>title</span>"}),
json!({"generateDocumentOutline": true}),
] {
assert!(parse_options(&params).is_err(), "must reject {params}");
}
}
#[test]
fn base64_size_preflight_is_exact_and_overflow_safe() {
assert_eq!(base64_encoded_len(0), Some(0));
assert_eq!(base64_encoded_len(1), Some(4));
assert_eq!(base64_encoded_len(2), Some(4));
assert_eq!(base64_encoded_len(3), Some(4));
assert_eq!(base64_encoded_len(4), Some(8));
assert_eq!(base64_encoded_len(usize::MAX), None);
let largest_raw = (MAX_BASE64_PDF_BYTES / 4) * 3;
assert_eq!(base64_encoded_len(largest_raw), Some(MAX_BASE64_PDF_BYTES));
assert!(base64_encoded_len(largest_raw + 1).unwrap() > MAX_BASE64_PDF_BYTES);
}
#[tokio::test]
async fn print_to_pdf_returns_paginated_pdf_with_requested_media_box() {
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)
.unwrap()
.set_viewport((100.0, 80.0));
page::handle(
"navigate",
&json!({
"url": "data:text/html,<html style='margin:0'><body style='margin:0;height:400px;background:linear-gradient(red,blue)'></body></html>",
"waitUntil": "load",
}),
&mut ctx,
&session,
).await.expect("navigate fixture");
let response = print_to_pdf(
&json!({
"paperWidth": 4.0, "paperHeight": 6.0,
"marginTop": 0.5, "marginBottom": 0.5,
"marginLeft": 0.5, "marginRight": 0.5,
"printBackground": true,
}),
&mut ctx,
&session,
)
.await
.expect("raster PDF");
assert_eq!(response["obscuraPrintMode"], "screen-raster");
assert_eq!(response["obscuraPrintBackground"], true);
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(response["data"].as_str().unwrap())
.unwrap();
assert!(bytes.starts_with(b"%PDF-1.4"));
assert!(bytes.ends_with(b"%%EOF\n"));
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/MediaBox [0 0 288.000 432.000]"));
assert!(
text.matches("/Subtype /Image").count() >= 2,
"400px tall fixture should paginate at this printable aspect ratio"
);
let streamed = page::handle(
"printToPDF",
&json!({
"transferMode": "ReturnAsStream",
"landscape": false,
"displayHeaderFooter": false,
"headerTemplate": "",
"footerTemplate": "",
"printBackground": false,
"scale": 1,
"paperWidth": 8.5,
"paperHeight": 11,
"marginTop": 0,
"marginBottom": 0,
"marginLeft": 0,
"marginRight": 0,
"pageRanges": "",
"preferCSSPageSize": false,
"generateTaggedPDF": true,
"generateDocumentOutline": false,
}),
&mut ctx,
&session,
)
.await
.expect("Puppeteer/Playwright-shaped stream request");
assert_eq!(streamed["data"], "");
assert_eq!(streamed["obscuraPrintBackground"], true);
assert_eq!(streamed["obscuraRequestedPrintBackground"], false);
assert_eq!(streamed["obscuraTaggedPDF"], false);
assert_eq!(streamed["obscuraRequestedTaggedPDF"], true);
assert_eq!(
streamed["obscuraIgnoredOptions"],
json!(["printBackground", "generateTaggedPDF"])
);
let handle = streamed["stream"].as_str().expect("protocol stream handle");
let mut streamed_bytes = Vec::new();
loop {
let chunk = crate::domains::io::handle(
"read",
&json!({"handle": handle, "size": 1024}),
&mut ctx,
)
.await
.expect("read PDF stream");
streamed_bytes.extend(
base64::engine::general_purpose::STANDARD
.decode(chunk["data"].as_str().unwrap())
.unwrap(),
);
if chunk["eof"] == true {
break;
}
}
assert!(streamed_bytes.starts_with(b"%PDF-1.4"));
assert!(streamed_bytes.ends_with(b"%%EOF\n"));
crate::domains::io::handle("close", &json!({"handle": handle}), &mut ctx)
.await
.expect("close PDF stream");
assert!(
crate::domains::io::handle("read", &json!({"handle": handle}), &mut ctx,)
.await
.is_err(),
"closed PDF handles must release their buffer"
);
}
}