Improve modern rendering and output parity
This commit is contained in:
1 parent
f428aa37fa
commit
c8a5477296
25 files changed
+12885
-5053
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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", ¶ms, &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);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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", ¶ms, &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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(¶ms).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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user