Reduce render latency and improve headless input
This commit is contained in:
1 parent
b8bdac2163
commit
222b50e480
15 files changed
+2268
-147
No files matched your search
@@ -27,6 +27,14 @@ pub(crate) struct ScreencastState {
|
||||
pub command_frame_counter: u64,
|
||||
pub session_id: i64,
|
||||
pub frames_in_flight: u8,
|
||||
/// Last connected-document generation observed by the frame producer.
|
||||
/// The autonomous pump uses this as a cheap compositor-damage signal, so
|
||||
/// an idle screencast does not continuously rasterize identical frames.
|
||||
pub observed_activity_generation: u64,
|
||||
/// A changed generation remains pending across sampling skips and
|
||||
/// acknowledgement backpressure. Once capacity returns, the newest page
|
||||
/// state is captured instead of losing the change.
|
||||
pub autonomous_frame_pending: bool,
|
||||
}
|
||||
|
||||
pub struct CdpContext {
|
||||
|
||||
@@ -55,6 +55,37 @@ const BACKSPACE_JS: &str = "(function() {\
|
||||
t.dispatchEvent(globalThis.__obscura_markTrusted(new Event('input', {bubbles:true})));\
|
||||
})()";
|
||||
|
||||
fn mouse_button_code(button: &str) -> u8 {
|
||||
match button {
|
||||
"middle" => 1,
|
||||
"right" => 2,
|
||||
"back" => 3,
|
||||
"forward" => 4,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_button_mask(button: &str) -> u64 {
|
||||
match button {
|
||||
"right" => 2,
|
||||
"middle" => 4,
|
||||
"back" => 8,
|
||||
"forward" => 16,
|
||||
"none" => 0,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn modifier_flags(modifiers: u64) -> (bool, bool, bool, bool) {
|
||||
// CDP Input.Modifier: Alt=1, Ctrl=2, Meta=4, Shift=8.
|
||||
(
|
||||
modifiers & 1 != 0,
|
||||
modifiers & 2 != 0,
|
||||
modifiers & 4 != 0,
|
||||
modifiers & 8 != 0,
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn handle(
|
||||
method: &str,
|
||||
params: &Value,
|
||||
@@ -66,8 +97,15 @@ pub async fn handle(
|
||||
let event_type = params.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let x = params.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let y = params.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let _button = params.get("button").and_then(|v| v.as_str()).unwrap_or("left");
|
||||
let button = params.get("button").and_then(|v| v.as_str()).unwrap_or("left");
|
||||
let button_code = mouse_button_code(button);
|
||||
let buttons = params
|
||||
.get("buttons")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or_else(|| mouse_button_mask(button));
|
||||
let click_count = params.get("clickCount").and_then(|v| v.as_u64()).unwrap_or(1);
|
||||
let modifiers = params.get("modifiers").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let (alt_key, ctrl_key, meta_key, shift_key) = modifier_flags(modifiers);
|
||||
|
||||
if event_type == "mousePressed" {
|
||||
if let Some(page) = ctx.get_session_page_mut(session_id) {
|
||||
@@ -76,48 +114,21 @@ pub async fn handle(
|
||||
var target = (document.elementFromPoint && document.elementFromPoint({x},{y})) || globalThis.__obscura_click_target || document.activeElement || document.body;\
|
||||
if (!target) return;\
|
||||
globalThis.__obscura_click_target = target;\
|
||||
var evt = globalThis.__obscura_markTrusted(new MouseEvent('mousedown', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0,detail:{click_count}}}));\
|
||||
globalThis.__obscura_mouse_down = {{target:target,button:{button_code},clickCount:{click_count}}};\
|
||||
var evt = globalThis.__obscura_markTrusted(new MouseEvent('mousedown', {{bubbles:true,cancelable:true,view:globalThis,clientX:{x},clientY:{y},button:{button_code},buttons:{buttons},detail:{click_count},altKey:{alt_key},ctrlKey:{ctrl_key},metaKey:{meta_key},shiftKey:{shift_key}}}));\
|
||||
target.dispatchEvent(evt);\
|
||||
var click = globalThis.__obscura_markTrusted(new MouseEvent('click', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0,detail:{click_count}}}));\
|
||||
var cancelled = !target.dispatchEvent(click);\
|
||||
if (!cancelled) {{\
|
||||
var link = target.closest ? target.closest('a[href]') : null;\
|
||||
if (!link && target.tagName === 'A' && target.getAttribute('href')) link = target;\
|
||||
if (link) {{\
|
||||
var href = link.getAttribute('href');\
|
||||
if (href && !href.startsWith('#') && !href.startsWith('javascript:')) {{\
|
||||
location.assign(href);\
|
||||
}}\
|
||||
}} else {{\
|
||||
var tag = target.tagName;\
|
||||
var type = (target.getAttribute && target.getAttribute('type') || '').toLowerCase();\
|
||||
if (tag === 'BUTTON' && type !== 'button' && type !== 'reset') {{\
|
||||
var form = target.closest ? target.closest('form') : null;\
|
||||
if (form) {{ try {{ if (typeof form.requestSubmit === 'function') {{ form.requestSubmit(target); }} else {{ form.submit(target); }} }} catch(e) {{}} }}\
|
||||
}} else if (tag === 'INPUT' && (type === 'submit' || type === 'image')) {{\
|
||||
var form2 = target.closest ? target.closest('form') : null;\
|
||||
if (form2) {{ try {{ if (typeof form2.requestSubmit === 'function') {{ form2.requestSubmit(target); }} else {{ form2.submit(target); }} }} catch(e) {{}} }}\
|
||||
}} else if (tag === 'INPUT' && (type === 'checkbox' || type === 'radio')) {{\
|
||||
target.checked = !target.checked;\
|
||||
try {{ target.dispatchEvent(globalThis.__obscura_markTrusted(new Event('change', {{bubbles:true}}))); }} catch(e) {{}}\
|
||||
}} else if ({click_count} >= 3 && (tag === 'INPUT' || tag === 'TEXTAREA')) {{\
|
||||
// Triple-click selects all text (browser native behavior not replicated
|
||||
// by synthetic MouseEvent, so we do it manually).
|
||||
var len = target.value ? target.value.length : 0;\
|
||||
if (target.setSelectionRange) {{\
|
||||
target.setSelectionRange(0, len);\
|
||||
}} else {{\
|
||||
target.selectionStart = 0;\
|
||||
target.selectionEnd = len;\
|
||||
}}\
|
||||
}}\
|
||||
}}\
|
||||
}}\
|
||||
}})()",
|
||||
x = x, y = y, click_count = click_count,
|
||||
x = x,
|
||||
y = y,
|
||||
button_code = button_code,
|
||||
buttons = buttons,
|
||||
click_count = click_count,
|
||||
alt_key = alt_key,
|
||||
ctrl_key = ctrl_key,
|
||||
meta_key = meta_key,
|
||||
shift_key = shift_key,
|
||||
);
|
||||
page.evaluate(&code);
|
||||
page.process_pending_navigation().await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
} else if event_type == "mouseReleased" {
|
||||
if let Some(page) = ctx.get_session_page_mut(session_id) {
|
||||
@@ -125,10 +136,125 @@ pub async fn handle(
|
||||
"(function() {{\
|
||||
var target = (document.elementFromPoint && document.elementFromPoint({x},{y})) || globalThis.__obscura_click_target || document.activeElement || document.body;\
|
||||
if (!target) return;\
|
||||
var evt = globalThis.__obscura_markTrusted(new MouseEvent('mouseup', {{bubbles:true,cancelable:true,clientX:{x},clientY:{y},button:0}}));\
|
||||
var down = globalThis.__obscura_mouse_down;\
|
||||
globalThis.__obscura_mouse_down = null;\
|
||||
var evt = globalThis.__obscura_markTrusted(new MouseEvent('mouseup', {{bubbles:true,cancelable:true,view:globalThis,clientX:{x},clientY:{y},button:{button_code},buttons:0,detail:{click_count},altKey:{alt_key},ctrlKey:{ctrl_key},metaKey:{meta_key},shiftKey:{shift_key}}}));\
|
||||
target.dispatchEvent(evt);\
|
||||
if (!down || down.button !== {button_code} || {button_code} !== 0) return;\
|
||||
var clickTarget = down.target;\
|
||||
while (clickTarget && clickTarget !== target && !(clickTarget.contains && clickTarget.contains(target))) {{\
|
||||
clickTarget = clickTarget.parentElement;\
|
||||
}}\
|
||||
if (!clickTarget) return;\
|
||||
var tag = clickTarget.tagName;\
|
||||
var type = (clickTarget.getAttribute && clickTarget.getAttribute('type') || '').toLowerCase();\
|
||||
var checkable = tag === 'INPUT' && (type === 'checkbox' || type === 'radio');\
|
||||
var oldChecked = checkable ? !!clickTarget.checked : false;\
|
||||
var radioStates = null;\
|
||||
if (checkable && type === 'radio') {{\
|
||||
var radioName = clickTarget.getAttribute('name') || '';\
|
||||
if (radioName) {{\
|
||||
var candidates = document.querySelectorAll('input');\
|
||||
radioStates = [];\
|
||||
for (var ri = 0; ri < candidates.length; ri++) {{\
|
||||
var radio = candidates[ri];\
|
||||
if ((radio.getAttribute('type') || '').toLowerCase() !== 'radio' || (radio.getAttribute('name') || '') !== radioName || radio.form !== clickTarget.form) continue;\
|
||||
radioStates.push([radio, !!radio.checked]);\
|
||||
if (radio !== clickTarget) radio.checked = false;\
|
||||
}}\
|
||||
}}\
|
||||
clickTarget.checked = true;\
|
||||
}} else if (checkable) {{\
|
||||
clickTarget.checked = !oldChecked;\
|
||||
}}\
|
||||
var click = globalThis.__obscura_markTrusted(new MouseEvent('click', {{bubbles:true,cancelable:true,view:globalThis,clientX:{x},clientY:{y},button:0,buttons:0,detail:{click_count},altKey:{alt_key},ctrlKey:{ctrl_key},metaKey:{meta_key},shiftKey:{shift_key}}}));\
|
||||
var cancelled = !clickTarget.dispatchEvent(click);\
|
||||
if (cancelled) {{\
|
||||
if (radioStates) {{\
|
||||
for (var rr = 0; rr < radioStates.length; rr++) radioStates[rr][0].checked = radioStates[rr][1];\
|
||||
}} else if (checkable) clickTarget.checked = oldChecked;\
|
||||
return;\
|
||||
}}\
|
||||
if (checkable && clickTarget.checked !== oldChecked) {{\
|
||||
try {{ clickTarget.dispatchEvent(globalThis.__obscura_markTrusted(new Event('input', {{bubbles:true}}))); }} catch(e) {{}}\
|
||||
try {{ clickTarget.dispatchEvent(globalThis.__obscura_markTrusted(new Event('change', {{bubbles:true}}))); }} catch(e) {{}}\
|
||||
return;\
|
||||
}}\
|
||||
var link = clickTarget.closest ? clickTarget.closest('a[href]') : null;\
|
||||
if (!link && tag === 'A' && clickTarget.getAttribute('href')) link = clickTarget;\
|
||||
if (link) {{\
|
||||
var href = link.getAttribute('href');\
|
||||
if (href && !href.startsWith('#') && !href.startsWith('javascript:')) location.assign(href);\
|
||||
}} else if (tag === 'BUTTON' && type !== 'button' && type !== 'reset') {{\
|
||||
var form = clickTarget.closest ? clickTarget.closest('form') : null;\
|
||||
if (form) {{ try {{ if (typeof form.requestSubmit === 'function') {{ form.requestSubmit(clickTarget); }} else {{ form.submit(clickTarget); }} }} catch(e) {{}} }}\
|
||||
}} else if (tag === 'INPUT' && (type === 'submit' || type === 'image')) {{\
|
||||
var form2 = clickTarget.closest ? clickTarget.closest('form') : null;\
|
||||
if (form2) {{ try {{ if (typeof form2.requestSubmit === 'function') {{ form2.requestSubmit(clickTarget); }} else {{ form2.submit(clickTarget); }} }} catch(e) {{}} }}\
|
||||
}} else if ({click_count} >= 3 && (tag === 'INPUT' || tag === 'TEXTAREA')) {{\
|
||||
var len = clickTarget.value ? clickTarget.value.length : 0;\
|
||||
if (clickTarget.setSelectionRange) clickTarget.setSelectionRange(0, len);\
|
||||
else {{ clickTarget.selectionStart = 0; clickTarget.selectionEnd = len; }}\
|
||||
}}\
|
||||
}})()",
|
||||
x = x, y = y,
|
||||
x = x,
|
||||
y = y,
|
||||
button_code = button_code,
|
||||
click_count = click_count,
|
||||
alt_key = alt_key,
|
||||
ctrl_key = ctrl_key,
|
||||
meta_key = meta_key,
|
||||
shift_key = shift_key,
|
||||
);
|
||||
page.evaluate(&code);
|
||||
page.process_pending_navigation().await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
} else if event_type == "mouseWheel" {
|
||||
let delta_x = params.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let delta_y = params.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
if let Some(page) = ctx.get_session_page_mut(session_id) {
|
||||
let code = format!(
|
||||
"(function() {{\
|
||||
var target = (document.elementFromPoint && document.elementFromPoint({x},{y})) || document.body || document.documentElement;\
|
||||
if (!target) return;\
|
||||
var wheel = globalThis.__obscura_markTrusted(new WheelEvent('wheel', {{bubbles:true,cancelable:true,view:globalThis,clientX:{x},clientY:{y},deltaX:{delta_x},deltaY:{delta_y},deltaMode:0,altKey:{alt_key},ctrlKey:{ctrl_key},metaKey:{meta_key},shiftKey:{shift_key}}}));\
|
||||
if (!target.dispatchEvent(wheel)) return;\
|
||||
var dx = {delta_x}, dy = {delta_y};\
|
||||
var root = document.scrollingElement || document.documentElement || document.body;\
|
||||
var scrollTarget = null;\
|
||||
var el = target;\
|
||||
while (el && el.nodeType === 1 && el !== root && el !== document.body && el !== document.documentElement) {{\
|
||||
var maxX = Math.max(0, (el.scrollWidth || 0) - (el.clientWidth || 0));\
|
||||
var maxY = Math.max(0, (el.scrollHeight || 0) - (el.clientHeight || 0));\
|
||||
var style = null;\
|
||||
try {{ style = getComputedStyle(el); }} catch (_e) {{}}\
|
||||
var ox = style ? (style.overflowX || style.overflow || '') : '';\
|
||||
var oy = style ? (style.overflowY || style.overflow || '') : '';\
|
||||
var allowX = ox === 'auto' || ox === 'scroll' || ox === 'overlay';\
|
||||
var allowY = oy === 'auto' || oy === 'scroll' || oy === 'overlay';\
|
||||
var consumesX = allowX && ((dx > 0 && el.scrollLeft < maxX) || (dx < 0 && el.scrollLeft > 0));\
|
||||
var consumesY = allowY && ((dy > 0 && el.scrollTop < maxY) || (dy < 0 && el.scrollTop > 0));\
|
||||
if (consumesX || consumesY) {{ scrollTarget = el; break; }}\
|
||||
el = el.parentElement;\
|
||||
}}\
|
||||
if (!scrollTarget) scrollTarget = root;\
|
||||
if (scrollTarget === root && root && typeof root.scrollBy === 'function') {{\
|
||||
var beforeX = root.scrollLeft, beforeY = root.scrollTop;\
|
||||
root.scrollBy(dx, dy);\
|
||||
if (root.scrollLeft !== beforeX || root.scrollTop !== beforeY) setTimeout(function() {{\
|
||||
try {{ document.dispatchEvent(new Event('scroll', {{bubbles:false}})); }} catch (_e) {{}}\
|
||||
try {{ globalThis.dispatchEvent(new Event('scroll', {{bubbles:false}})); }} catch (_e) {{}}\
|
||||
}}, 0);\
|
||||
}} else if (scrollTarget && typeof scrollTarget.scrollBy === 'function') scrollTarget.scrollBy(dx, dy);\
|
||||
}})()",
|
||||
x = x,
|
||||
y = y,
|
||||
delta_x = delta_x,
|
||||
delta_y = delta_y,
|
||||
alt_key = alt_key,
|
||||
ctrl_key = ctrl_key,
|
||||
meta_key = meta_key,
|
||||
shift_key = shift_key,
|
||||
);
|
||||
page.evaluate(&code);
|
||||
}
|
||||
|
||||
@@ -266,6 +266,26 @@ fn encode_screenshot(
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Keep capture itself an observation of the retained page state. Chromium's
|
||||
/// capture methods do not start a hidden resource-loading phase, and doing so
|
||||
/// here added up to three seconds to every screenshot/PDF/screencast start.
|
||||
/// The browser navigation and settle paths seed render resources proactively.
|
||||
/// Retain the old environment variable only as an explicit diagnostic escape
|
||||
/// hatch for callers investigating a slow or missing asset.
|
||||
#[cfg(feature = "render")]
|
||||
pub(crate) async fn prepare_capture_resources_if_requested(
|
||||
page: &mut obscura_browser::Page,
|
||||
) {
|
||||
let Some(deadline_ms) = std::env::var("OBSCURA_RENDER_RESOURCE_DEADLINE_MS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|value| *value != 0)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let _ = page.prepare_screenshot_resources(deadline_ms).await;
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
fn screencast_int32(params: &Value, name: &str) -> Result<Option<i64>, String> {
|
||||
match params.get(name) {
|
||||
@@ -316,6 +336,8 @@ fn parse_screencast_state(params: &Value, session_id: i64) -> Result<ScreencastS
|
||||
command_frame_counter: 0,
|
||||
session_id,
|
||||
frames_in_flight: 0,
|
||||
observed_activity_generation: 0,
|
||||
autonomous_frame_pending: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -399,7 +421,7 @@ pub(crate) fn queue_screencast_frame(
|
||||
state.clone()
|
||||
};
|
||||
let attached_session = Some(cdp_session_id.to_string());
|
||||
let (viewport, scroll, png) = {
|
||||
let (viewport, scroll, png, activity_generation) = {
|
||||
let page = ctx
|
||||
.get_session_page_mut(&attached_session)
|
||||
.ok_or("No page for session")?;
|
||||
@@ -425,7 +447,12 @@ pub(crate) fn queue_screencast_frame(
|
||||
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 activity_generation = page
|
||||
.js
|
||||
.as_ref()
|
||||
.map(|js| js.activity_generation())
|
||||
.unwrap_or(0);
|
||||
(viewport, scroll, png, activity_generation)
|
||||
};
|
||||
let encoded = encode_screencast_frame(png, &state)?;
|
||||
use base64::Engine as _;
|
||||
@@ -437,6 +464,8 @@ pub(crate) fn queue_screencast_frame(
|
||||
return Ok(false);
|
||||
}
|
||||
live.frames_in_flight = live.frames_in_flight.saturating_add(1);
|
||||
live.observed_activity_generation = activity_generation;
|
||||
live.autonomous_frame_pending = false;
|
||||
ctx.pending_events.push(CdpEvent {
|
||||
method: "Page.screencastFrame".into(),
|
||||
params: json!({
|
||||
@@ -454,6 +483,56 @@ pub(crate) fn queue_screencast_frame(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Advance active page task queues for one bounded compositor slice and emit
|
||||
/// a frame when connected-document activity has changed. Chromium feeds
|
||||
/// `Page.screencastFrame` from its video consumer on compositor frames; this
|
||||
/// is Obscura's single-threaded equivalent until it owns a real compositor.
|
||||
///
|
||||
/// Generation tracking avoids full-page raster work while the page is idle.
|
||||
/// A dirty generation is retained across `everyNthFrame` sampling and the
|
||||
/// acknowledgement window, ensuring neither mechanism loses the latest frame.
|
||||
#[cfg(feature = "render")]
|
||||
pub(crate) async fn pump_screencast_frames(ctx: &mut CdpContext) {
|
||||
const EVENT_LOOP_SLICE_MS: u64 = 4;
|
||||
|
||||
let sessions: Vec<String> = ctx.screencasts.keys().cloned().collect();
|
||||
for cdp_session_id in sessions {
|
||||
if !ctx.screencasts.contains_key(&cdp_session_id) {
|
||||
continue;
|
||||
}
|
||||
let attached_session = Some(cdp_session_id.clone());
|
||||
let activity_generation = {
|
||||
let Some(page) = ctx.get_session_page_mut(&attached_session) else {
|
||||
continue;
|
||||
};
|
||||
let Some(js) = page.js.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
// A V8 watchdog bounds synchronous callbacks as well as the async
|
||||
// timeout, so a hostile timer cannot freeze the CDP connection.
|
||||
let _ = js.run_event_loop_bounded(EVENT_LOOP_SLICE_MS).await;
|
||||
js.activity_generation()
|
||||
};
|
||||
|
||||
let should_attempt = {
|
||||
let Some(state) = ctx.screencasts.get_mut(&cdp_session_id) else {
|
||||
continue;
|
||||
};
|
||||
if state.observed_activity_generation != activity_generation {
|
||||
state.observed_activity_generation = activity_generation;
|
||||
state.autonomous_frame_pending = true;
|
||||
}
|
||||
state.autonomous_frame_pending
|
||||
};
|
||||
if !should_attempt {
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = queue_screencast_frame(ctx, &attached_session, false) {
|
||||
tracing::warn!(cdp_session_id, "could not produce autonomous screencast frame: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub(crate) fn command_can_change_screencast_frame(method: &str) -> bool {
|
||||
matches!(
|
||||
@@ -1097,16 +1176,10 @@ pub async fn handle(
|
||||
.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;
|
||||
prepare_capture_resources_if_requested(page).await;
|
||||
let stream_id = ctx.next_screencast_session();
|
||||
let state = parse_screencast_state(params, stream_id)?;
|
||||
ctx.screencasts.insert(cdp_session.clone(), state);
|
||||
@@ -1121,15 +1194,11 @@ pub async fn handle(
|
||||
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}),
|
||||
)
|
||||
tracing::debug!(cdp_session, stream_id, "started activity-driven screencast");
|
||||
Ok(json!({
|
||||
"obscuraFrameSource": "activity-driven",
|
||||
"obscuraAutonomousFrames": true,
|
||||
}))
|
||||
}
|
||||
#[cfg(not(feature = "render"))]
|
||||
Err("Page.startScreencast requires a build with the render feature".into())
|
||||
@@ -1183,13 +1252,7 @@ pub async fn handle(
|
||||
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;
|
||||
prepare_capture_resources_if_requested(page).await;
|
||||
let viewport = page.viewport;
|
||||
let device_scale_factor = f64::from(page.device_scale_factor);
|
||||
let trusted_scroll = page.screenshot_scroll_offset();
|
||||
@@ -1447,8 +1510,8 @@ mod tests {
|
||||
)
|
||||
.await
|
||||
.expect("start screencast");
|
||||
assert_eq!(result["obscuraFrameSource"], "command-driven");
|
||||
assert_eq!(result["obscuraAutonomousFrames"], false);
|
||||
assert_eq!(result["obscuraFrameSource"], "activity-driven");
|
||||
assert_eq!(result["obscuraAutonomousFrames"], true);
|
||||
assert_eq!(ctx.pending_events.len(), 2);
|
||||
assert_eq!(
|
||||
ctx.pending_events[0].method,
|
||||
@@ -1535,6 +1598,96 @@ mod tests {
|
||||
assert!(!queue_screencast_frame(&mut ctx, &session, false).unwrap());
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn capture_methods_do_not_start_default_resource_warmups() {
|
||||
use std::io::{Read as _, Write as _};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let requests = std::sync::Arc::new(AtomicUsize::new(0));
|
||||
let observed = requests.clone();
|
||||
std::thread::spawn(move || {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
while std::time::Instant::now() < deadline {
|
||||
match listener.accept() {
|
||||
Ok((mut stream, _)) => {
|
||||
let mut request = [0u8; 2048];
|
||||
let _ = stream.read(&mut request);
|
||||
observed.fetch_add(1, Ordering::SeqCst);
|
||||
let body = br#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>"#;
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: image/svg+xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
let _ = stream.write_all(body);
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let context = std::sync::Arc::new(
|
||||
obscura_browser::BrowserContext::with_storage_and_network(
|
||||
"capture-without-warmup".to_string(),
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
true,
|
||||
),
|
||||
);
|
||||
let mut ctx = CdpContext::new_with_shared_context(context);
|
||||
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'></body></html>",
|
||||
"waitUntil": "load",
|
||||
}),
|
||||
&mut ctx,
|
||||
&session,
|
||||
)
|
||||
.await
|
||||
.expect("navigate capture fixture");
|
||||
|
||||
for (index, method) in ["captureScreenshot", "startScreencast", "printToPDF"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let asset = format!("http://{address}/capture-{index}.svg");
|
||||
let script = format!(
|
||||
"(function(){{const box=document.createElement('div');box.setAttribute('style','width:10px;height:10px;background-image:url('+{}+')');document.body.appendChild(box);}})()",
|
||||
serde_json::to_string(&asset).unwrap()
|
||||
);
|
||||
ctx.get_session_page_mut(&session)
|
||||
.expect("page")
|
||||
.evaluate(&script);
|
||||
handle(method, &json!({}), &mut ctx, &session)
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{method} failed: {error}"));
|
||||
}
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(75));
|
||||
assert_eq!(
|
||||
requests.load(Ordering::SeqCst),
|
||||
0,
|
||||
"capture APIs must observe retained state without initiating network warmup"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[test]
|
||||
fn screencast_options_validate_protocol_shapes() {
|
||||
|
||||
@@ -135,14 +135,10 @@ pub async fn print_to_pdf(
|
||||
#[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;
|
||||
crate::domains::page::prepare_capture_resources_if_requested(page).await;
|
||||
let pdf = page
|
||||
.raster_pdf(options.raster)
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
@@ -711,6 +711,12 @@ async fn cdp_processor(
|
||||
// registers and stays registered across iterations, so a later
|
||||
// `notify_waiters()` wakes this processor even while it is mid-dispatch.
|
||||
let mut shutdown = Box::pin(shutdown_notify.notified());
|
||||
// Chromium's PageHandler receives compositor video frames continuously.
|
||||
// Obscura has no separate compositor thread yet, so active screencasts get
|
||||
// a bounded 30 Hz opportunity on this connection's owning LocalSet.
|
||||
let mut screencast_tick = tokio::time::interval(tokio::time::Duration::from_millis(33));
|
||||
screencast_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut connection_reply_tx: Option<mpsc::UnboundedSender<String>> = None;
|
||||
|
||||
loop {
|
||||
// Drain any deferred messages from the previous interception window
|
||||
@@ -725,6 +731,13 @@ async fn cdp_processor(
|
||||
Some(m) => m,
|
||||
None => break,
|
||||
},
|
||||
_ = screencast_tick.tick(), if has_active_screencast(&ctx) => {
|
||||
pump_and_forward_screencast_frames(
|
||||
&mut ctx,
|
||||
connection_reply_tx.as_ref(),
|
||||
).await;
|
||||
continue;
|
||||
},
|
||||
_ = &mut shutdown => {
|
||||
tracing::info!("Shutdown signal received (connection processor)");
|
||||
break;
|
||||
@@ -734,6 +747,7 @@ async fn cdp_processor(
|
||||
|
||||
match msg {
|
||||
ServerMessage::NewConnection { reply_tx } => {
|
||||
connection_reply_tx = Some(reply_tx.clone());
|
||||
let _ = reply_tx.send(
|
||||
json!({"__init": true})
|
||||
.to_string(),
|
||||
@@ -772,6 +786,37 @@ async fn cdp_processor(
|
||||
let _ = &ctx;
|
||||
}
|
||||
|
||||
fn has_active_screencast(ctx: &CdpContext) -> bool {
|
||||
#[cfg(feature = "render")]
|
||||
{
|
||||
!ctx.screencasts.is_empty()
|
||||
}
|
||||
#[cfg(not(feature = "render"))]
|
||||
{
|
||||
let _ = ctx;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
async fn pump_and_forward_screencast_frames(
|
||||
ctx: &mut CdpContext,
|
||||
reply_tx: Option<&mpsc::UnboundedSender<String>>,
|
||||
) {
|
||||
#[cfg(feature = "render")]
|
||||
crate::domains::page::pump_screencast_frames(ctx).await;
|
||||
#[cfg(not(feature = "render"))]
|
||||
let _ = ctx;
|
||||
|
||||
let Some(reply_tx) = reply_tx else {
|
||||
return;
|
||||
};
|
||||
for event in ctx.pending_events.drain(..) {
|
||||
if let Ok(json) = serde_json::to_string(&event) {
|
||||
let _ = reply_tx.send(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Whether a raw CDP frame is exactly a `Page.navigate` call, and so should take
|
||||
// the spawn-and-defer navigation path. Matching on the parsed method rather than
|
||||
// a `contains("Page.navigate")` substring avoids catching
|
||||
@@ -1357,6 +1402,8 @@ async fn handle_connection_ws(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_navigate_method, merge_cookie_delta, parse_cdp_headers};
|
||||
#[cfg(feature = "render")]
|
||||
use super::pump_and_forward_screencast_frames;
|
||||
use obscura_net::{CookieInfo, CookieJar};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -1436,4 +1483,91 @@ mod tests {
|
||||
fn parse_cdp_headers_absent_is_none() {
|
||||
assert!(parse_cdp_headers(&json!({"url": "https://example.com"})).is_none());
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn autonomous_screencast_pumps_timers_and_retains_backpressured_damage() {
|
||||
let mut ctx = crate::dispatch::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.clone());
|
||||
ctx.get_session_page_mut(&session)
|
||||
.expect("page")
|
||||
.set_viewport((96.0, 64.0));
|
||||
crate::domains::page::handle(
|
||||
"navigate",
|
||||
&json!({
|
||||
"url": "data:text/html,<html style='margin:0'><body style='margin:0;width:96px;height:64px;background:red'></body></html>",
|
||||
"waitUntil": "load",
|
||||
}),
|
||||
&mut ctx,
|
||||
&session,
|
||||
)
|
||||
.await
|
||||
.expect("navigate screencast fixture");
|
||||
ctx.pending_events.clear();
|
||||
crate::domains::page::handle(
|
||||
"startScreencast",
|
||||
&json!({}),
|
||||
&mut ctx,
|
||||
&session,
|
||||
)
|
||||
.await
|
||||
.expect("start screencast");
|
||||
let stream_id = ctx
|
||||
.pending_events
|
||||
.iter()
|
||||
.find(|event| event.method == "Page.screencastFrame")
|
||||
.and_then(|event| event.params["sessionId"].as_i64())
|
||||
.expect("initial stream id");
|
||||
ctx.pending_events.clear();
|
||||
|
||||
let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
ctx.get_session_page_mut(&session)
|
||||
.expect("page")
|
||||
.evaluate(
|
||||
"setTimeout(() => document.body.setAttribute('style', 'margin:0;width:96px;height:64px;background:green'), 0)",
|
||||
);
|
||||
pump_and_forward_screencast_frames(&mut ctx, Some(&reply_tx)).await;
|
||||
let first_update: serde_json::Value = serde_json::from_str(
|
||||
&reply_rx.try_recv().expect("timer mutation should emit a frame"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(first_update["method"], "Page.screencastFrame");
|
||||
assert_eq!(first_update["params"]["sessionId"], stream_id);
|
||||
assert!(reply_rx.try_recv().is_err());
|
||||
assert_eq!(ctx.screencasts[&session_id].frames_in_flight, 2);
|
||||
|
||||
// The second mutation is pumped while the two-frame acknowledgement
|
||||
// window is full. It must not emit yet, but its damage must remain
|
||||
// pending and appear immediately after capacity is returned.
|
||||
ctx.get_session_page_mut(&session)
|
||||
.expect("page")
|
||||
.evaluate(
|
||||
"setTimeout(() => document.body.setAttribute('style', 'margin:0;width:96px;height:64px;background:blue'), 0)",
|
||||
);
|
||||
pump_and_forward_screencast_frames(&mut ctx, Some(&reply_tx)).await;
|
||||
assert!(reply_rx.try_recv().is_err());
|
||||
assert!(ctx.screencasts[&session_id].autonomous_frame_pending);
|
||||
|
||||
crate::domains::page::handle(
|
||||
"screencastFrameAck",
|
||||
&json!({"sessionId": stream_id}),
|
||||
&mut ctx,
|
||||
&session,
|
||||
)
|
||||
.await
|
||||
.expect("ack current frame");
|
||||
pump_and_forward_screencast_frames(&mut ctx, Some(&reply_tx)).await;
|
||||
let after_ack: serde_json::Value = serde_json::from_str(
|
||||
&reply_rx
|
||||
.try_recv()
|
||||
.expect("backpressured damage should emit after ack"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(after_ack["method"], "Page.screencastFrame");
|
||||
assert_eq!(after_ack["params"]["sessionId"], stream_id);
|
||||
assert!(!ctx.screencasts[&session_id].autonomous_frame_pending);
|
||||
}
|
||||
}
|
||||
@@ -131,6 +131,15 @@ async fn request_submit_is_vetoed_by_prevent_default_listener() {
|
||||
)
|
||||
.await;
|
||||
|
||||
cdp(
|
||||
&mut ctx,
|
||||
4,
|
||||
"Input.dispatchMouseEvent",
|
||||
json!({"type": "mouseReleased", "x": 0.0, "y": 0.0, "button": "left", "clickCount": 1}),
|
||||
session_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
let page = ctx.get_page_mut(&page_id).unwrap();
|
||||
assert_ne!(
|
||||
page.url.as_ref().unwrap().path(),
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
#![cfg(feature = "render")]
|
||||
|
||||
use obscura_cdp::dispatch::{dispatch, CdpContext};
|
||||
use obscura_cdp::types::CdpRequest;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
async fn serve_fixture() -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut buf = [0u8; 2048];
|
||||
let _ = socket.read(&mut buf).await.unwrap();
|
||||
let body = r#"<!doctype html><html><head><style>
|
||||
html, body { margin: 0; }
|
||||
#page { width: 1800px; height: 2400px; }
|
||||
#box { position: absolute; left: 20px; top: 20px; width: 180px;
|
||||
height: 120px; overflow: auto; border: 10px solid black; }
|
||||
#inner { width: 700px; height: 800px; }
|
||||
</style></head><body>
|
||||
<div id="page"></div>
|
||||
<div id="box"><div id="inner"></div></div>
|
||||
<input id="check" type="checkbox">
|
||||
<form id="radio-form">
|
||||
<input id="radio-a" type="radio" name="choice" checked>
|
||||
<input id="radio-b" type="radio" name="choice">
|
||||
</form>
|
||||
</body></html>"#;
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
});
|
||||
format!("http://{addr}/")
|
||||
}
|
||||
|
||||
async fn cdp(
|
||||
ctx: &mut CdpContext,
|
||||
id: u64,
|
||||
method: &str,
|
||||
params: Value,
|
||||
session_id: &str,
|
||||
) -> Value {
|
||||
let response = dispatch(
|
||||
&CdpRequest {
|
||||
id,
|
||||
method: method.to_string(),
|
||||
params,
|
||||
session_id: Some(session_id.to_string()),
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.await;
|
||||
assert!(response.error.is_none(), "CDP {method} failed: {:?}", response.error);
|
||||
response.result.unwrap_or_else(|| json!({}))
|
||||
}
|
||||
|
||||
async fn evaluate(ctx: &mut CdpContext, id: u64, expression: &str, session_id: &str) -> Value {
|
||||
cdp(
|
||||
ctx,
|
||||
id,
|
||||
"Runtime.evaluate",
|
||||
json!({"expression": expression, "returnByValue": true, "awaitPromise": true}),
|
||||
session_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn setup() -> (CdpContext, String) {
|
||||
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
|
||||
let url = serve_fixture().await;
|
||||
let mut ctx = CdpContext::new();
|
||||
let page_id = ctx.create_page();
|
||||
let session_id = "input-mouse-session";
|
||||
ctx.sessions.insert(session_id.to_string(), page_id);
|
||||
cdp(
|
||||
&mut ctx,
|
||||
1,
|
||||
"Page.navigate",
|
||||
json!({"url": url, "waitUntil": "load"}),
|
||||
session_id,
|
||||
)
|
||||
.await;
|
||||
(ctx, session_id.to_string())
|
||||
}
|
||||
|
||||
async fn wheel(ctx: &mut CdpContext, id: u64, sid: &str, x: f64, y: f64, dx: f64, dy: f64) {
|
||||
cdp(
|
||||
ctx,
|
||||
id,
|
||||
"Input.dispatchMouseEvent",
|
||||
json!({"type": "mouseWheel", "x": x, "y": y, "deltaX": dx, "deltaY": dy}),
|
||||
sid,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn scroll_state(ctx: &mut CdpContext, id: u64, sid: &str) -> Value {
|
||||
let result = evaluate(
|
||||
ctx,
|
||||
id,
|
||||
r#"JSON.stringify({
|
||||
rootX: scrollX, rootY: scrollY,
|
||||
boxX: document.getElementById('box').scrollLeft,
|
||||
boxY: document.getElementById('box').scrollTop,
|
||||
rootScrollWidth: document.scrollingElement.scrollWidth,
|
||||
rootClientWidth: document.scrollingElement.clientWidth,
|
||||
pageRect: document.getElementById('page').getBoundingClientRect().toJSON(),
|
||||
maxBoxX: document.getElementById('box').scrollWidth - document.getElementById('box').clientWidth,
|
||||
maxBoxY: document.getElementById('box').scrollHeight - document.getElementById('box').clientHeight
|
||||
})"#,
|
||||
sid,
|
||||
)
|
||||
.await;
|
||||
serde_json::from_str(result["result"]["value"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn wheel_over_page_scrolls_the_root_on_both_axes() {
|
||||
let (mut ctx, sid) = setup().await;
|
||||
wheel(&mut ctx, 2, &sid, 600.0, 300.0, 45.0, 160.0).await;
|
||||
let state = scroll_state(&mut ctx, 3, &sid).await;
|
||||
assert_eq!(state["rootX"], 45.0, "unexpected root geometry: {state}");
|
||||
assert_eq!(state["rootY"], 160.0);
|
||||
assert_eq!(state["boxX"], 0.0);
|
||||
assert_eq!(state["boxY"], 0.0);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn wheel_over_nested_overflow_scrolls_the_nested_container() {
|
||||
let (mut ctx, sid) = setup().await;
|
||||
wheel(&mut ctx, 2, &sid, 50.0, 50.0, 70.0, 110.0).await;
|
||||
let state = scroll_state(&mut ctx, 3, &sid).await;
|
||||
assert_eq!(state["boxX"], 70.0);
|
||||
assert_eq!(state["boxY"], 110.0);
|
||||
assert_eq!(state["rootX"], 0.0, "nested wheel must not leak to the viewport");
|
||||
assert_eq!(state["rootY"], 0.0, "nested wheel must not leak to the viewport");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn wheel_offsets_clamp_to_nested_scroll_extents() {
|
||||
let (mut ctx, sid) = setup().await;
|
||||
wheel(&mut ctx, 2, &sid, 50.0, 50.0, 100_000.0, 100_000.0).await;
|
||||
let state = scroll_state(&mut ctx, 3, &sid).await;
|
||||
assert_eq!(state["boxX"], state["maxBoxX"]);
|
||||
assert_eq!(state["boxY"], state["maxBoxY"]);
|
||||
|
||||
wheel(&mut ctx, 4, &sid, 50.0, 50.0, -100_000.0, -100_000.0).await;
|
||||
let state = scroll_state(&mut ctx, 5, &sid).await;
|
||||
assert_eq!(state["boxX"], 0.0);
|
||||
assert_eq!(state["boxY"], 0.0);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn wheel_chains_to_root_when_nested_scroller_is_saturated() {
|
||||
let (mut ctx, sid) = setup().await;
|
||||
evaluate(
|
||||
&mut ctx,
|
||||
2,
|
||||
"(() => { const box = document.getElementById('box'); box.scrollTop = box.scrollHeight; })()",
|
||||
&sid,
|
||||
)
|
||||
.await;
|
||||
let saturated = scroll_state(&mut ctx, 3, &sid).await;
|
||||
assert_eq!(saturated["boxY"], saturated["maxBoxY"]);
|
||||
|
||||
wheel(&mut ctx, 4, &sid, 50.0, 50.0, 0.0, 90.0).await;
|
||||
let state = scroll_state(&mut ctx, 5, &sid).await;
|
||||
assert_eq!(state["boxY"], state["maxBoxY"], "inner remains clamped");
|
||||
assert_eq!(state["rootY"], 90.0, "remaining wheel gesture chains to the viewport");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn canceling_wheel_prevents_its_scroll_default() {
|
||||
let (mut ctx, sid) = setup().await;
|
||||
evaluate(
|
||||
&mut ctx,
|
||||
2,
|
||||
r#"(() => {
|
||||
globalThis.wheelProbe = null;
|
||||
const page = document.getElementById('page');
|
||||
document.elementFromPoint = () => page;
|
||||
page.addEventListener('wheel', event => {
|
||||
wheelProbe = {
|
||||
x: event.clientX, y: event.clientY,
|
||||
dx: event.deltaX, dy: event.deltaY,
|
||||
ctrl: event.ctrlKey, trusted: event.isTrusted
|
||||
};
|
||||
event.preventDefault();
|
||||
});
|
||||
})()"#,
|
||||
&sid,
|
||||
)
|
||||
.await;
|
||||
cdp(
|
||||
&mut ctx,
|
||||
3,
|
||||
"Input.dispatchMouseEvent",
|
||||
json!({
|
||||
"type": "mouseWheel", "x": 600.0, "y": 300.0,
|
||||
"deltaX": 25.0, "deltaY": 75.0, "modifiers": 2
|
||||
}),
|
||||
&sid,
|
||||
)
|
||||
.await;
|
||||
let state = scroll_state(&mut ctx, 4, &sid).await;
|
||||
assert_eq!(state["rootX"], 0.0);
|
||||
assert_eq!(state["rootY"], 0.0);
|
||||
let probe = evaluate(&mut ctx, 5, "JSON.stringify(wheelProbe)", &sid).await;
|
||||
let probe: Value = serde_json::from_str(probe["result"]["value"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(probe["x"], 600.0);
|
||||
assert_eq!(probe["y"], 300.0);
|
||||
assert_eq!(probe["dx"], 25.0);
|
||||
assert_eq!(probe["dy"], 75.0);
|
||||
assert_eq!(probe["ctrl"], true);
|
||||
assert_eq!(probe["trusted"], true);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn hit_testing_clips_scrolled_children_at_overflow_padding_edge() {
|
||||
let (mut ctx, sid) = setup().await;
|
||||
let result = evaluate(
|
||||
&mut ctx,
|
||||
2,
|
||||
r#"(() => {
|
||||
const box = document.getElementById('box');
|
||||
box.scrollLeft = 50;
|
||||
const inner = document.getElementById('inner').getBoundingClientRect();
|
||||
return JSON.stringify({
|
||||
hit: document.elementFromPoint(25, 50).id,
|
||||
innerLeft: inner.left, innerRight: inner.right,
|
||||
boxLeft: box.getBoundingClientRect().left
|
||||
});
|
||||
})()"#,
|
||||
&sid,
|
||||
)
|
||||
.await;
|
||||
let result: Value = serde_json::from_str(result["result"]["value"].as_str().unwrap()).unwrap();
|
||||
assert!(result["innerLeft"].as_f64().unwrap() <= 25.0);
|
||||
assert!(result["innerRight"].as_f64().unwrap() >= 25.0);
|
||||
assert_eq!(result["boxLeft"], 20.0);
|
||||
assert_eq!(result["hit"], "box", "content hidden behind the border cannot win hit testing");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn press_release_orders_events_and_defers_click_activation() {
|
||||
let (mut ctx, sid) = setup().await;
|
||||
evaluate(
|
||||
&mut ctx,
|
||||
2,
|
||||
r#"(() => {
|
||||
const target = document.getElementById('check');
|
||||
document.elementFromPoint = () => target;
|
||||
globalThis.mouseLog = [];
|
||||
for (const type of ['mousedown', 'mouseup', 'click', 'input', 'change']) {
|
||||
target.addEventListener(type, event => mouseLog.push({
|
||||
type, checked: target.checked, x: event.clientX,
|
||||
ctrl: event.ctrlKey, shift: event.shiftKey, trusted: event.isTrusted
|
||||
}));
|
||||
}
|
||||
})()"#,
|
||||
&sid,
|
||||
)
|
||||
.await;
|
||||
|
||||
cdp(
|
||||
&mut ctx,
|
||||
3,
|
||||
"Input.dispatchMouseEvent",
|
||||
json!({
|
||||
"type": "mousePressed", "x": 31.0, "y": 42.0,
|
||||
"button": "left", "clickCount": 1, "modifiers": 10
|
||||
}),
|
||||
&sid,
|
||||
)
|
||||
.await;
|
||||
let pressed = evaluate(
|
||||
&mut ctx,
|
||||
4,
|
||||
"JSON.stringify({log: mouseLog, checked: document.getElementById('check').checked})",
|
||||
&sid,
|
||||
)
|
||||
.await;
|
||||
let pressed: Value = serde_json::from_str(pressed["result"]["value"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(pressed["checked"], false, "checkbox activation must wait for release");
|
||||
assert_eq!(pressed["log"][0]["type"], "mousedown");
|
||||
assert_eq!(pressed["log"].as_array().unwrap().len(), 1, "press must not synthesize click");
|
||||
|
||||
cdp(
|
||||
&mut ctx,
|
||||
5,
|
||||
"Input.dispatchMouseEvent",
|
||||
json!({
|
||||
"type": "mouseReleased", "x": 31.0, "y": 42.0,
|
||||
"button": "left", "clickCount": 1, "modifiers": 10
|
||||
}),
|
||||
&sid,
|
||||
)
|
||||
.await;
|
||||
let released = evaluate(
|
||||
&mut ctx,
|
||||
6,
|
||||
"JSON.stringify({log: mouseLog, checked: document.getElementById('check').checked})",
|
||||
&sid,
|
||||
)
|
||||
.await;
|
||||
let released: Value = serde_json::from_str(released["result"]["value"].as_str().unwrap()).unwrap();
|
||||
let types: Vec<&str> = released["log"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|entry| entry["type"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(types, ["mousedown", "mouseup", "click", "input", "change"]);
|
||||
assert_eq!(released["checked"], true);
|
||||
assert_eq!(released["log"][2]["checked"], true, "click sees checkbox pre-activation");
|
||||
assert_eq!(released["log"][2]["x"], 31.0);
|
||||
assert_eq!(released["log"][2]["ctrl"], true);
|
||||
assert_eq!(released["log"][2]["shift"], true);
|
||||
assert_eq!(released["log"][2]["trusted"], true);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn radio_release_selects_only_the_target_in_its_group() {
|
||||
let (mut ctx, sid) = setup().await;
|
||||
evaluate(
|
||||
&mut ctx,
|
||||
2,
|
||||
r#"(() => {
|
||||
const a = document.getElementById('radio-a');
|
||||
const b = document.getElementById('radio-b');
|
||||
document.elementFromPoint = () => b;
|
||||
globalThis.radioEvents = [];
|
||||
for (const radio of [a, b]) {
|
||||
for (const type of ['mousedown', 'mouseup', 'click', 'input', 'change']) {
|
||||
radio.addEventListener(type, () => radioEvents.push(radio.id + ':' + type));
|
||||
}
|
||||
}
|
||||
})()"#,
|
||||
&sid,
|
||||
)
|
||||
.await;
|
||||
cdp(
|
||||
&mut ctx,
|
||||
3,
|
||||
"Input.dispatchMouseEvent",
|
||||
json!({"type": "mousePressed", "x": 10.0, "y": 10.0, "button": "left"}),
|
||||
&sid,
|
||||
)
|
||||
.await;
|
||||
cdp(
|
||||
&mut ctx,
|
||||
4,
|
||||
"Input.dispatchMouseEvent",
|
||||
json!({"type": "mouseReleased", "x": 10.0, "y": 10.0, "button": "left"}),
|
||||
&sid,
|
||||
)
|
||||
.await;
|
||||
let result = evaluate(
|
||||
&mut ctx,
|
||||
5,
|
||||
"JSON.stringify({a: document.getElementById('radio-a').checked, b: document.getElementById('radio-b').checked, events: radioEvents})",
|
||||
&sid,
|
||||
)
|
||||
.await;
|
||||
let result: Value = serde_json::from_str(result["result"]["value"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(result["a"], false);
|
||||
assert_eq!(result["b"], true);
|
||||
assert_eq!(
|
||||
result["events"],
|
||||
json!(["radio-b:mousedown", "radio-b:mouseup", "radio-b:click", "radio-b:input", "radio-b:change"]),
|
||||
"the newly selected radio alone receives activation events"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user