Improve Playwright compatibility and engine performance

This commit is contained in:
SGavrl committed 2026-09-27 18:16:51 +00:00
1 parent eb4ed1727d
commit 542df14693
40 files changed
+4757 -1017

No files matched your search

+4
View File
@@ -1,2 +1,6 @@
[env]
RUST_TEST_THREADS = "1"
[alias]
# Local runtime iteration only. Benchmark with the normal non-incremental build.
test-js = "nextest run --release --features render -p obscura-js --lib --config profile.release.package.obscura-js.incremental=true"
Generated
+35
View File
@@ -860,6 +860,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "dtoa"
version = "1.0.11"
@@ -915,6 +924,18 @@ dependencies = [
"num-traits",
]
[[package]]
name = "fast_image_resize"
version = "6.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9c50201dc184ba6553da1695aac20a042efffbe2d84542cee31917c86c3ab1e"
dependencies = [
"cfg-if",
"document-features",
"num-traits",
"thiserror 2.0.18",
]
[[package]]
name = "fastrand"
version = "2.4.1"
@@ -1698,6 +1719,12 @@ dependencies = [
"libc",
]
[[package]]
name = "jpeg-encoder"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0370574b86f7eca156b9f298392b5e69a23f8c86f3f865add60bbc2e79467a6"
[[package]]
name = "js-sys"
version = "0.3.102"
@@ -1773,6 +1800,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -1972,8 +2005,10 @@ version = "0.1.0"
dependencies = [
"anyhow",
"base64",
"fast_image_resize",
"futures-util",
"image",
"jpeg-encoder",
"libc",
"obscura-browser",
"obscura-dom",
+204 -98
View File
@@ -175,6 +175,8 @@ fn navigation_referrer(source: &Url, target: &Url) -> String {
#[derive(Debug, Clone)]
pub struct NetworkEvent {
pub request_id: String,
/// The live interception channel already emitted the request start.
pub intercepted: bool,
pub url: String,
pub method: String,
pub resource_type: String,
@@ -225,6 +227,13 @@ impl PendingFrameWork {
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PendingNavigationOutcome {
None,
Document,
SameDocument,
}
pub struct Page {
pub id: String,
pub frame_id: String,
@@ -250,6 +259,7 @@ pub struct Page {
/// viewport and survives navigation, matching device-metrics emulation.
screen_size_override: Option<(f32, f32)>,
screen_metrics_emulated: bool,
locale_override: Option<String>,
/// Metrics captured when CDP device emulation is first enabled. Chromium
/// keeps this baseline across subsequent override calls and restores it
/// only when the override is cleared.
@@ -1098,6 +1108,7 @@ impl Page {
viewport: (1280.0, 720.0),
screen_size_override: None,
screen_metrics_emulated: false,
locale_override: None,
device_metrics_baseline: None,
device_scale_factor: 1.0,
default_background_color_override: None,
@@ -1154,19 +1165,9 @@ impl Page {
}
fn should_block_url(&self, url: &str) -> bool {
for pattern in &self.blocked_url_patterns {
if url_matches_cdp_pattern(pattern, url) {
return true;
}
}
if self.intercept_enabled {
for pattern in &self.intercept_block_patterns {
if url_matches_cdp_pattern(pattern, url) {
return true;
}
}
}
false
self.blocked_url_patterns
.iter()
.any(|pattern| url_matches_cdp_pattern(pattern, url))
}
/// Moves fetched frame documents into Page ownership without doing work
@@ -1640,6 +1641,13 @@ impl Page {
}
}
pub fn set_locale_override(&mut self, locale: Option<String>) {
self.locale_override = locale.filter(|locale| !locale.is_empty());
if let Some(js) = &mut self.js {
js.set_locale(self.locale_override.as_deref().unwrap_or("en-US"));
}
}
/// Apply CDP device metrics relative to the metrics that were active when
/// emulation was first enabled. A zero protocol dimension/scale is passed
/// as `None` and therefore restores that axis from the retained baseline.
@@ -1790,6 +1798,9 @@ impl Page {
&self.context.ua_platform_version,
);
}
if let Some(locale) = &self.locale_override {
rt.set_locale(locale);
}
if let Some((lat, lon)) = env_geolocation() {
rt.set_geolocation(lat, lon);
}
@@ -1809,6 +1820,7 @@ impl Page {
rt.set_stealth_client(stealth.clone());
}
rt.set_intercept_page_id(&self.id);
if let Some(tx) = &self.intercept_tx {
rt.set_intercept_tx(tx.clone());
}
@@ -2930,9 +2942,6 @@ impl Page {
"globalThis.__documentReadyState__ = 'complete';\n\
try {\n\
const loadEvent = new Event('load', {bubbles:false,cancelable:false});\n\
if (typeof window.onload === 'function') {\n\
try { window.onload.call(window, loadEvent); } catch(e) {}\n\
}\n\
try { window.dispatchEvent(loadEvent); } catch(e) {}\n\
} catch(e) {}",
);
@@ -4047,6 +4056,28 @@ impl Page {
js.screenshot_prepared_region_with_surface_color(region, self.capture_surface_color())
}
#[cfg(feature = "render")]
pub fn screenshot_region_raster_with_animation_sample(
&self,
region: obscura_js::CaptureRegion,
animation_sample: obscura_js::AnimationSample,
) -> Result<image::RgbaImage, obscura_js::CaptureError> {
let js = self
.js
.as_ref()
.ok_or(obscura_js::CaptureError::PaintFailed)?;
if !js.set_animation_sample(animation_sample) {
return Err(obscura_js::CaptureError::PaintFailed);
}
let (width, height, pixels) = js
.screenshot_prepared_region_raster_with_surface_color(
region,
self.capture_surface_color(),
)?;
image::RgbaImage::from_raw(width, height, pixels)
.ok_or(obscura_js::CaptureError::PaintFailed)
}
/// Scrollable document dimensions from the retained render layout. Unlike
/// DOM properties evaluated in page JavaScript, this cannot be shadowed or
/// monkey-patched by the document being captured.
@@ -4130,6 +4161,7 @@ impl Page {
for ev in events {
self.network_events.push(NetworkEvent {
request_id: ev.request_id,
intercepted: ev.intercepted,
url: ev.url,
method: ev.method,
resource_type: "Fetch".to_string(),
@@ -4227,6 +4259,7 @@ impl Page {
tracing::debug!("evaluate_for_cdp error: {}", e);
obscura_js::runtime::RemoteObjectInfo {
thrown: false,
unserializable_value: None,
js_type: "undefined".into(),
subtype: None,
class_name: String::new(),
@@ -4240,6 +4273,7 @@ impl Page {
let val = self.evaluate(expression);
obscura_js::runtime::RemoteObjectInfo {
thrown: false,
unserializable_value: None,
js_type: match &val {
serde_json::Value::String(_) => "string".into(),
serde_json::Value::Number(_) => "number".into(),
@@ -4274,6 +4308,7 @@ impl Page {
let value = self.evaluate(expression);
Ok(obscura_js::runtime::RemoteObjectInfo {
thrown: false,
unserializable_value: None,
js_type: match &value {
serde_json::Value::String(_) => "string".into(),
serde_json::Value::Number(_) => "number".into(),
@@ -4313,6 +4348,7 @@ impl Page {
tracing::debug!("callFunctionOn error: {}", e);
obscura_js::runtime::RemoteObjectInfo {
thrown: false,
unserializable_value: None,
js_type: "undefined".into(),
subtype: None,
class_name: String::new(),
@@ -4325,6 +4361,7 @@ impl Page {
} else {
obscura_js::runtime::RemoteObjectInfo {
thrown: false,
unserializable_value: None,
js_type: "undefined".into(),
subtype: None,
class_name: String::new(),
@@ -4426,6 +4463,7 @@ impl Page {
.as_secs_f64();
self.network_events.push(NetworkEvent {
request_id: request_id.clone(),
intercepted: false,
url: url.to_string(),
method: method.to_string(),
resource_type: resource_type.to_string(),
@@ -4705,6 +4743,36 @@ impl Page {
}
pub async fn process_pending_navigation(&mut self) -> Result<bool, PageError> {
Ok(self.process_pending_navigation_outcome().await? != PendingNavigationOutcome::None)
}
/// Reject a document-driven cross-scheme jump to `file://` and restore
/// the virtual URL that the location setter changed before queuing it.
pub fn reject_page_initiated_file_navigation(&mut self, target: &str) -> bool {
let current_url = self.url_string();
if !cross_scheme_to_file(&current_url, target) {
return false;
}
tracing::warn!(
"blocking page-initiated cross-scheme navigation to file: {} -> {}",
current_url,
target,
);
if let Some(js) = self.js.as_mut() {
let _ = js.execute_script(
"<blocked-navigation>",
&format!(
"globalThis.__virtualUrl = {};",
serde_json::to_string(&current_url).unwrap_or_else(|_| "null".into())
),
);
}
true
}
pub async fn process_pending_navigation_outcome(
&mut self,
) -> Result<PendingNavigationOutcome, PageError> {
if let Some((url, method, body)) = self.take_pending_navigation() {
// SOP gate for navigations the page queued itself (a timer or
// handler assigning location, a link click, a form submit). They
@@ -4712,63 +4780,48 @@ impl Page {
// navigate_with_wait_post_inner never sees them. A web document
// must not drive itself into file:// even in a context that lets
// clients open local files (#1069).
let current_url = self.url_string();
if cross_scheme_to_file(&current_url, &url) {
tracing::warn!(
"blocking page-initiated cross-scheme navigation to file: {} -> {}",
current_url,
url,
);
// The location setter already published the target as the
// virtual URL; put location back on the document that is
// still loaded so nothing later adopts the blocked target.
if let Some(js) = self.js.as_mut() {
let _ = js.execute_script(
"<blocked-navigation>",
&format!(
"globalThis.__virtualUrl = {};",
serde_json::to_string(&current_url).unwrap_or_else(|_| "null".into())
),
);
}
return Ok(false);
if self.reject_page_initiated_file_navigation(&url) {
return Ok(PendingNavigationOutcome::None);
}
let source_url = self
.url
.as_ref()
.and_then(|source| {
Url::parse(&url)
.ok()
.map(|target| navigation_referrer(source, &target))
})
.unwrap_or_default();
let nav_timeout = self.navigation_timeout();
let nav_timeout_ms = duration_millis_u64(nav_timeout);
let result = tokio::time::timeout(
nav_timeout,
self.navigate_with_wait_post_inner(
&url,
crate::lifecycle::WaitUntil::Load,
&method,
&body,
&source_url,
),
)
.await
.map_err(|_| {
self.lifecycle = crate::lifecycle::LifecycleState::Failed;
PageError::NetworkError(format!("navigation exceeded {nav_timeout_ms}ms deadline"))
})?;
result?;
self.push_history(self.url_string());
Ok(true)
self.navigate_from_document(&url, &method, &body).await?;
Ok(PendingNavigationOutcome::Document)
} else {
// Fork: a page that routed itself through history has still
// navigated. See fork_virtual_url.rs.
Ok(self.sync_virtual_url())
Ok(if self.sync_virtual_url() {
PendingNavigationOutcome::SameDocument
} else {
PendingNavigationOutcome::None
})
}
}
/// Follow an in-document navigation, retaining its referrer and deadline
/// even when an embedder services intercepted requests outside dispatch.
pub async fn navigate_from_document(
&mut self,
url: &str,
method: &str,
body: &str,
) -> Result<(), PageError> {
let source_url = self.url.as_ref().and_then(|source| {
Url::parse(url).ok().map(|target| navigation_referrer(source, &target))
}).unwrap_or_default();
let nav_timeout = self.navigation_timeout();
let nav_timeout_ms = duration_millis_u64(nav_timeout);
tokio::time::timeout(
nav_timeout,
self.navigate_with_wait_post_inner(
url, crate::lifecycle::WaitUntil::Load, method, body, &source_url,
),
).await.map_err(|_| {
self.lifecycle = crate::lifecycle::LifecycleState::Failed;
PageError::NetworkError(format!("navigation exceeded {nav_timeout_ms}ms deadline"))
})??;
self.push_history(self.url_string());
Ok(())
}
pub fn set_intercept_tx(
&mut self,
tx: tokio::sync::mpsc::UnboundedSender<obscura_js::ops::InterceptedRequest>,
@@ -5547,9 +5600,10 @@ mod tests {
true,
));
let mut page = super::Page::new("stylesheet-graph".to_string(), context);
page.set_blocked_urls(vec!["*blocked.css".to_string()]);
page.intercept_block_patterns = vec!["*intercepted.css".to_string()];
page.enable_intercept(true);
page.set_blocked_urls(vec![
"*blocked.css".to_string(),
"*intercepted.css".to_string(),
]);
let request_count = std::sync::Arc::new(AtomicUsize::new(0));
let response_count = std::sync::Arc::new(AtomicUsize::new(0));
@@ -6060,6 +6114,34 @@ mod tests {
assert_eq!(script_requests.load(Ordering::SeqCst), 24);
}
#[tokio::test(flavor = "current_thread")]
async fn fetch_interception_patterns_do_not_block_parser_scripts() {
let (url, _) = spawn_script_resource_cache_server(false);
let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network(
"fetch-pattern-is-not-blocklist".to_string(),
None,
false,
None,
None,
true,
));
let mut page = super::Page::new("fetch-pattern-is-not-blocklist".to_string(), context);
page.intercept_block_patterns = vec!["*".to_string()];
page.enable_intercept(true);
page.navigate(&url).await.unwrap();
assert_eq!(
page.js
.as_mut()
.unwrap()
.evaluate("globalThis.__runs")
.unwrap(),
serde_json::json!(32.0),
"Fetch.enable patterns pause matching requests; they are not blocked URLs",
);
}
#[test]
fn external_scripts_require_a_successful_http_status() {
assert!(script_response_is_executable(200));
@@ -6754,6 +6836,52 @@ mod tests {
assert_eq!(result, serde_json::json!("1"));
}
#[tokio::test(flavor = "current_thread")]
async fn stale_release_cannot_delete_a_new_document_handle() {
let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network(
"cdp-handle-navigation".to_string(),
None,
false,
None,
None,
true,
));
let mut page = super::Page::new("cdp-handle-navigation".to_string(), context);
page.url = Some(url::Url::parse("http://example.com/old.html").unwrap());
page.dom = Some(parse_html("<html><body></body></html>"));
page.init_js();
let old_id = page
.evaluate_for_cdp_with_timeout("({ marker: 1 })", false, false, 1_000)
.await
.unwrap()
.object_id
.unwrap();
page.navigate_blank();
page.init_js();
let new_id = page
.evaluate_for_cdp_with_timeout("({ marker: 2 })", false, false, 1_000)
.await
.unwrap()
.object_id
.unwrap();
assert_ne!(old_id, new_id, "remote object IDs must not repeat across documents");
page.release_object(&old_id);
let result = page
.call_function_on_for_cdp_with_timeout(
"function() { return this.marker; }",
Some(&new_id),
&[],
true,
false,
1_000,
)
.await
.unwrap();
assert_eq!(result.value, Some(serde_json::json!(2.0)));
}
fn import_map_test_page(name: &str, base: &str, html: &str) -> super::Page {
let context = std::sync::Arc::new(crate::BrowserContext::with_storage_and_network(
name.to_string(),
@@ -8560,12 +8688,12 @@ mod tests {
);
}
/// Renderer misses follow the page's `Fetch.enable` interception policy
/// like the warmup scan: an intercepted URL is not fetched behind the
/// client's back, other URLs still load.
/// Fetch.enable patterns are not renderer block rules. Playwright enables
/// Fetch with `*` and filters routes client-side, so treating those patterns
/// as blocked URLs would remove every image and font from the page.
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn renderer_misses_honour_fetch_interception_patterns() {
async fn renderer_misses_ignore_fetch_interception_patterns() {
let (address, _peak, _open, seen_rx) = spawn_counting_svg_server(0, 6);
let page_url = format!("http://{address}/page");
let intercepted = format!("http://{address}/intercepted.ttf");
@@ -8588,15 +8716,15 @@ mod tests {
.evaluate("document.getElementById('a').getBoundingClientRect().width + document.getElementById('b').getBoundingClientRect().width")
.unwrap();
page.queue_pending_render_resources();
assert!(page.prepare_screenshot_resources(3_000).await <= 1);
assert_eq!(page.prepare_screenshot_resources(3_000).await, 2);
assert!(!page.has_pending_render_resources());
let mut lines = Vec::new();
while let Ok(line) = seen_rx.try_recv() {
lines.push(line);
}
assert!(
lines.iter().all(|line| !line.contains("/intercepted.ttf")),
"an intercepted URL must not be fetched: {lines:?}"
lines.iter().any(|line| line.starts_with("GET /intercepted.ttf ")),
"Fetch patterns must not block static resources: {lines:?}"
);
assert!(
lines.iter().any(|line| line.starts_with("GET /plain.ttf ")),
@@ -8605,28 +8733,6 @@ mod tests {
let js = page.js.as_ref().unwrap();
assert!(js.render_resource_is_known(&intercepted), "intercepted URL is settled as missing");
assert!(js.render_resource_is_known(&plain));
// Disabling interception lifts the rule for later misses.
page.intercept_block_patterns.clear();
page.enable_intercept(false);
let late = format!("http://{address}/late-intercepted.ttf");
page.js
.as_mut()
.unwrap()
.evaluate(&format!(
r#"(function() {{
document.fonts.add(new FontFace('C', 'url({late})'));
const a = document.getElementById('a');
a.style.fontFamily = 'C';
return a.getBoundingClientRect().width;
}})()"#
))
.unwrap();
page.queue_pending_render_resources();
page.prepare_screenshot_resources(3_000).await;
assert!(
seen_rx.try_iter().any(|line| line.starts_with("GET /late-intercepted.ttf ")),
"without interception the URL loads"
);
}
#[cfg(feature = "render")]
+7 -1
View File
@@ -4,13 +4,17 @@ version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
autotests = false
[[test]]
name = "integration"
[features]
default = []
stealth = ["obscura-browser/stealth"]
# Enable Page.captureScreenshot via obscura-render. Propagates render to
# obscura-browser so Page::screenshot is available.
render = ["obscura-browser/render", "dep:image", "dep:png"]
render = ["obscura-browser/render", "dep:image", "dep:jpeg-encoder", "dep:png", "dep:fast_image_resize"]
[dependencies]
obscura-dom = { workspace = true }
@@ -29,6 +33,8 @@ base64 = { workspace = true }
futures-util = "0.3"
obscura-net = { workspace = true }
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"], optional = true }
jpeg-encoder = { version = "0.7.1", features = ["simd"], optional = true }
fast_image_resize = { version = "6.1", optional = true }
png = { version = "0.18", optional = true }
[target.'cfg(target_env = "gnu")'.dependencies]
+82 -8
View File
@@ -677,7 +677,6 @@ fn is_v8_free_method(method: &str) -> bool {
| "Page.getFrameTree"
| "Page.setDownloadBehavior"
| "Page.setLifecycleEventsEnabled"
| "Page.addScriptToEvaluateOnNewDocument"
| "Page.removeScriptToEvaluateOnNewDocument"
| "Page.setInterceptFileChooserDialog"
| "Page.getNavigationHistory"
@@ -818,13 +817,10 @@ pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse {
};
#[cfg(feature = "render")]
if result.is_ok() && domains::page::command_can_change_screencast_frame(&req.method) {
if let Err(error) = domains::page::queue_screencast_frame(ctx, &req.session_id, false) {
// Frame delivery is an asynchronous side effect in Chromium; it
// must not rewrite an otherwise successful command response.
tracing::warn!(method = %req.method,
"could not produce command-driven screencast frame: {error}");
}
if result.is_ok()
&& domains::page::command_can_change_screencast_frame(&req.method)
{
domains::page::schedule_screencast_frame(ctx, &req.session_id);
}
// Stop the per-command watchdog. If it fired (the handler held V8 past the
@@ -1224,6 +1220,83 @@ mod tests {
assert!(err.message.contains("Unknown domain"));
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn read_only_runtime_calls_do_not_force_screencast_frames() {
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 start = CdpRequest {
id: 2,
method: "Page.startScreencast".into(),
params: json!({}),
session_id: Some(session_id.clone()),
};
let response = dispatch(&start, &mut ctx).await;
assert!(response.error.is_none(), "start failed: {:?}", response.error);
ctx.pending_events.clear();
let evaluate = CdpRequest {
id: 3,
method: "Runtime.evaluate".into(),
params: json!({"expression": "document.title", "returnByValue": true}),
session_id: Some(session_id),
};
let response = dispatch(&evaluate, &mut ctx).await;
assert!(response.error.is_none(), "evaluate failed: {:?}", response.error);
assert!(
ctx.pending_events
.iter()
.all(|event| event.method != "Page.screencastFrame"),
"a read-only runtime call must not synchronously rasterize a frame"
);
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn mutating_commands_schedule_screencast_after_the_command_response() {
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.clone());
let response = dispatch(
&CdpRequest {
id: 2,
method: "Page.startScreencast".into(),
params: json!({}),
session_id: session.clone(),
},
&mut ctx,
)
.await;
assert!(response.error.is_none(), "start failed: {:?}", response.error);
ctx.pending_events.clear();
let response = dispatch(
&CdpRequest {
id: 3,
method: "Emulation.setDefaultBackgroundColorOverride".into(),
params: json!({"color": {"r": 20, "g": 40, "b": 60}}),
session_id: session.clone(),
},
&mut ctx,
)
.await;
assert!(response.error.is_none(), "override failed: {:?}", response.error);
assert!(ctx.pending_events.is_empty(), "frame must not delay the command response");
assert!(ctx.screencasts[&session_id].autonomous_frame_pending);
crate::domains::page::pump_screencast_frames(&mut ctx).await;
assert!(ctx
.pending_events
.iter()
.any(|event| event.method == "Page.screencastFrame"));
}
#[tokio::test]
async fn send_message_to_target_unwraps_inner_call() {
let mut ctx = CdpContext::new();
@@ -1289,4 +1362,5 @@ mod tests {
let err = resp.error.expect("malformed inner messages must error");
assert_eq!(err.code, -32700);
}
}
@@ -121,6 +121,42 @@ pub async fn handle(
page.set_default_background_color_override(color);
Ok(json!({}))
}
"setUserAgentOverride" => {
let user_agent = params
.get("userAgent")
.and_then(Value::as_str)
.ok_or("Emulation.setUserAgentOverride requires userAgent")?;
let accept_language = params.get("acceptLanguage").and_then(Value::as_str);
let http_client = ctx
.get_session_page(session_id)
.ok_or("No page for session")?
.http_client
.clone();
if !user_agent.is_empty() {
http_client.set_user_agent(user_agent).await;
if let Some(js) = ctx
.get_session_page_mut(session_id)
.and_then(|page| page.js.as_mut())
{
js.set_user_agent(user_agent);
}
}
if let Some(accept_language) = accept_language {
http_client.set_accept_language(accept_language).await;
}
Ok(json!({}))
}
"setLocaleOverride" => {
let locale = params
.get("locale")
.and_then(Value::as_str)
.ok_or("Emulation.setLocaleOverride requires locale")?;
let page = ctx
.get_session_page_mut(session_id)
.ok_or("No page for session")?;
page.set_locale_override((!locale.is_empty()).then(|| locale.to_string()));
Ok(json!({}))
}
// Touch emulation does not affect layout yet, but acknowledging it is
// compatible with clients that pair it with a metrics override.
"setTouchEmulationEnabled" => Ok(json!({})),
@@ -170,6 +206,59 @@ mod tests {
);
}
#[tokio::test]
async fn locale_emulation_reaches_requests_and_survives_navigation() {
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = Some("locale-session".to_string());
ctx.sessions.insert(session_id.clone().unwrap(), page_id);
handle(
"setUserAgentOverride",
&json!({"userAgent": "", "acceptLanguage": "de-DE"}),
&mut ctx,
&session_id,
)
.await
.expect("accept-language override");
handle(
"setLocaleOverride",
&json!({"locale": "de-DE"}),
&mut ctx,
&session_id,
)
.await
.expect("locale override");
let http_client = ctx
.get_session_page(&session_id)
.unwrap()
.http_client
.clone();
assert_eq!(http_client.accept_language.read().await.as_str(), "de-DE");
assert_eq!(
ctx.get_session_page_mut(&session_id)
.unwrap()
.evaluate("[navigator.language, navigator.languages]"),
json!(["de-DE", ["de-DE", "de"]])
);
crate::domains::page::handle(
"navigate",
&json!({"url": "data:text/html,<p>locale</p>", "waitUntil": "load"}),
&mut ctx,
&session_id,
)
.await
.expect("navigate");
assert_eq!(
ctx.get_session_page_mut(&session_id)
.unwrap()
.evaluate("[navigator.language, navigator.languages]"),
json!(["de-DE", ["de-DE", "de"]])
);
}
#[tokio::test]
async fn omitted_screen_metrics_clear_only_the_screen_override() {
let mut ctx = CdpContext::new();
+91 -37
View File
@@ -81,6 +81,22 @@ const BACKSPACE_JS: &str = "(function() {\
t.dispatchEvent(globalThis.__obscura_markTrusted(new Event('input', {bubbles:true})));\
})()";
const DELETE_JS: &str = "(function() {\
var t = document.activeElement;\
if (!t || (t.localName !== 'input' && t.localName !== 'textarea')) return;\
var v = t.value || '';\
var s = t.selectionStart, e = t.selectionEnd;\
if (s == null) return;\
s = Math.max(0, Math.min(s, v.length));\
e = (e == null) ? s : Math.max(0, Math.min(e, v.length));\
var lo = Math.min(s, e), hi = Math.max(s, e);\
if (lo === hi) hi = Math.min(v.length, hi + 1);\
if (lo === hi) return;\
globalThis.__obscura_setFieldValue(t, 'value', v.slice(0, lo) + v.slice(hi));\
t.setSelectionRange(lo, lo);\
t.dispatchEvent(globalThis.__obscura_markTrusted(new Event('input', {bubbles:true})));\
})()";
fn mouse_button_code(button: &str) -> u8 {
match button {
"middle" => 1,
@@ -133,7 +149,35 @@ pub async fn handle(
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 event_type == "mouseMoved" {
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;\
if (!target) return;\
var old = globalThis.__obscura_hover_target || null;\
globalThis.__obscura_hover_target = target;\
globalThis.__obscura_setHovered(target);\
var init = {{bubbles:true,cancelable:true,view:globalThis,clientX:{x},clientY:{y},button:{button_code},buttons:{buttons},detail:0,altKey:{alt_key},ctrlKey:{ctrl_key},metaKey:{meta_key},shiftKey:{shift_key}}};\
if (old !== target) {{\
if (old) old.dispatchEvent(globalThis.__obscura_markTrusted(new MouseEvent('mouseout', Object.assign({{}}, init, {{relatedTarget:target}}))));\
target.dispatchEvent(globalThis.__obscura_markTrusted(new MouseEvent('mouseover', Object.assign({{}}, init, {{relatedTarget:old}}))));\
target.dispatchEvent(globalThis.__obscura_markTrusted(new MouseEvent('mouseenter', Object.assign({{}}, init, {{bubbles:false,relatedTarget:old}}))));\
}}\
target.dispatchEvent(globalThis.__obscura_markTrusted(new MouseEvent('mousemove', init)));\
}})()",
x = x,
y = y,
button_code = button_code,
buttons = buttons,
alt_key = alt_key,
ctrl_key = ctrl_key,
meta_key = meta_key,
shift_key = shift_key,
);
page.evaluate(&code);
}
} else if event_type == "mousePressed" {
if let Some(page) = ctx.get_session_page_mut(session_id) {
let code = format!(
"(function() {{\
@@ -166,7 +210,11 @@ pub async fn handle(
target.dispatchEvent(pointer);\
var evt = globalThis.__obscura_markTrusted(new MouseEvent('mousedown', {{bubbles:true,cancelable:true,composed: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);\
if (focusTarget && !globalThis.__obscura_isDisabled(focusTarget)) focusTarget.focus();\
if (!evt.defaultPrevented) {{\
var focusTarget = globalThis.__obscura_interactiveHost(target) || (target.closest && target.closest('[tabindex]'));\
if (focusTarget && !globalThis.__obscura_isDisabled(focusTarget)) focusTarget.focus();\
else if (document.activeElement && document.activeElement !== document.body) document.activeElement.blur();\
}}\
}})()",
x = x,
y = y,
@@ -215,22 +263,26 @@ pub async fn handle(
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;\
if (radio !== clickTarget) globalThis.__obscura_setFieldValue(radio, 'checked', false);\
}}\
}}\
clickTarget.checked = true;\
globalThis.__obscura_setFieldValue(clickTarget, 'checked', true);\
}} else if (checkable) {{\
clickTarget.checked = !oldChecked;\
globalThis.__obscura_setFieldValue(clickTarget, 'checked', !oldChecked);\
clickTarget.indeterminate = false;\
}}\
var click = globalThis.__obscura_markTrusted(new MouseEvent('click', {{bubbles:true,cancelable:true,composed: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; clickTarget.indeterminate = oldIndeterminate; }}\
for (var rr = 0; rr < radioStates.length; rr++) globalThis.__obscura_setFieldValue(radioStates[rr][0], 'checked', radioStates[rr][1]);\
}} else if (checkable) {{ globalThis.__obscura_setFieldValue(clickTarget, 'checked', oldChecked); clickTarget.indeterminate = oldIndeterminate; }}\
return;\
}}\
if ({click_count} === 2) {{\
var doubleClick = globalThis.__obscura_markTrusted(new MouseEvent('dblclick', {{bubbles:true,cancelable:true,view:globalThis,clientX:{x},clientY:{y},button:0,buttons:0,detail:2,altKey:{alt_key},ctrlKey:{ctrl_key},metaKey:{meta_key},shiftKey:{shift_key}}}));\
clickTarget.dispatchEvent(doubleClick);\
}}\
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) {{}}\
@@ -244,12 +296,17 @@ pub async fn handle(
}}\
var link = clickTarget.closest ? clickTarget.closest('a[href]') : null;\
if (!link && tag === 'A' && clickTarget.getAttribute('href')) link = clickTarget;\
var submitButton = tag === 'BUTTON' ? clickTarget : (clickTarget.closest ? clickTarget.closest('button') : null);\
var submitButtonType = submitButton ? ((submitButton.getAttribute('type') || '').toLowerCase()) : '';\
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) {{}} }}\
if (href && !href.startsWith('javascript:')) {{\
if (href.startsWith('#')) history.pushState(null, '', href);\
else location.assign(href);\
}}\
}} else if (submitButton && submitButtonType !== 'button' && submitButtonType !== 'reset' && !globalThis.__obscura_isDisabled(submitButton)) {{\
var form = submitButton.closest ? submitButton.closest('form') : null;\
if (form) {{ try {{ if (typeof form.requestSubmit === 'function') {{ form.requestSubmit(submitButton); }} else {{ form.submit(submitButton); }} }} 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) {{}} }}\
@@ -269,34 +326,18 @@ pub async fn handle(
shift_key = shift_key,
);
page.evaluate(&code);
// Document navigation runs after the input acknowledgement.
// Same-document routing still needs a frame notification.
if !page.has_pending_navigation() && page.sync_virtual_url() {
Some((page.id.clone(), page.frame_id.clone(), page.url_string()))
Some((page.frame_id.clone(), page.url_string()))
} else {
None
}
} else {
None
};
if let Some((page_id, frame_id, url)) = moved_frame {
let loader_id = ctx
.current_loader_ids
.get(&page_id)
.cloned()
.unwrap_or_else(|| format!("loader-blank-{page_id}"));
ctx.pending_events.push(crate::types::CdpEvent {
method: "Page.frameNavigated".into(),
params: json!({
"frame": crate::domains::page::frame_value(
&frame_id,
None,
&loader_id,
&url,
"text/html",
),
"type": "Navigation",
}),
session_id: Some(session_id.clone().unwrap_or_default()),
});
if let Some((frame_id, url)) = moved_frame {
crate::domains::page::emit_same_document_navigation(ctx, session_id, &frame_id, &url);
}
} else if event_type == "mouseWheel" {
let delta_x = params.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0);
@@ -362,9 +403,15 @@ pub async fn handle(
}
"dispatchKeyEvent" => {
let event_type = params.get("type").and_then(|v| v.as_str()).unwrap_or("");
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);
let key = params.get("key").and_then(|v| v.as_str()).unwrap_or("");
let code = params.get("code").and_then(|v| v.as_str()).unwrap_or("");
let text = params.get("text").and_then(|v| v.as_str()).unwrap_or("");
let virtual_key_code = params
.get("windowsVirtualKeyCode")
.and_then(|v| v.as_u64())
.unwrap_or(0);
if let Some(page) = ctx.get_session_page_mut(session_id) {
match event_type {
@@ -372,7 +419,7 @@ pub async fn handle(
let js = format!(
"(function() {{\
var target = document.activeElement || document.body;\
var evt = globalThis.__obscura_markTrusted(new KeyboardEvent('keydown', {{bubbles:true,cancelable:true,key:{key},code:{code}}}));\
var evt = globalThis.__obscura_markTrusted(new KeyboardEvent('keydown', {{bubbles:true,cancelable:true,key:{key},code:{code},keyCode:{virtual_key_code},which:{virtual_key_code},altKey:{alt_key},ctrlKey:{ctrl_key},metaKey:{meta_key},shiftKey:{shift_key}}}));\
target.dispatchEvent(evt);\
}})()",
// Escape backslash BEFORE single-quote (as the text
@@ -381,6 +428,7 @@ pub async fn handle(
// and produce a syntax error that drops the event.
key = js_str(key),
code = js_str(code),
virtual_key_code = virtual_key_code,
);
page.evaluate(&js);
@@ -396,7 +444,7 @@ pub async fn handle(
let js = "(function() {\
var target = document.activeElement;\
if (!target) return;\
target.dispatchEvent(globalThis.__obscura_markTrusted(new KeyboardEvent('keypress', {bubbles:true,key:'Enter',code:'Enter'})));\
target.dispatchEvent(globalThis.__obscura_markTrusted(new KeyboardEvent('keypress', {bubbles:true,key:'Enter',code:'Enter',keyCode:$VK,which:$VK,charCode:$VK,$MODIFIERS})));\
if (target.localName === 'textarea') {\
var value = target.value || '';\
var start = target.selectionStart, end = target.selectionEnd;\
@@ -410,23 +458,29 @@ pub async fn handle(
var form = target.form || (target.closest && target.closest('form'));\
if (form) {{ try {{ if (typeof form.requestSubmit === 'function') {{ form.requestSubmit(); }} else {{ form.submit(); }} }} catch(e) {{}} }}\
}\
})()";
page.evaluate(js);
})()".replace("$VK", &virtual_key_code.to_string())
.replace("$MODIFIERS", &format!("altKey:{alt_key},ctrlKey:{ctrl_key},metaKey:{meta_key},shiftKey:{shift_key}"));
page.evaluate(&js);
}
if key == "Backspace" {
page.evaluate(BACKSPACE_JS);
}
if key == "Delete" {
page.evaluate(DELETE_JS);
}
}
"keyUp" => {
let js = format!(
"(function() {{\
var target = document.activeElement || document.body;\
var evt = globalThis.__obscura_markTrusted(new KeyboardEvent('keyup', {{bubbles:true,key:{key},code:{code}}}));\
var evt = globalThis.__obscura_markTrusted(new KeyboardEvent('keyup', {{bubbles:true,key:{key},code:{code},keyCode:{virtual_key_code},which:{virtual_key_code},altKey:{alt_key},ctrlKey:{ctrl_key},metaKey:{meta_key},shiftKey:{shift_key}}}));\
target.dispatchEvent(evt);\
}})()",
key = js_str(key),
code = js_str(code),
virtual_key_code = virtual_key_code,
);
page.evaluate(&js);
}
+447 -97
View File
@@ -332,6 +332,22 @@ fn chromium_clip_region(
))
}
#[cfg(feature = "render")]
fn encode_jpeg(image: &image::RgbaImage, quality: u8) -> Result<Vec<u8>, String> {
let width = u16::try_from(image.width())
.map_err(|_| "JPEG screenshot width exceeds 65535 pixels".to_string())?;
let height = u16::try_from(image.height())
.map_err(|_| "JPEG screenshot height exceeds 65535 pixels".to_string())?;
let mut output = Vec::new();
let mut encoder = jpeg_encoder::Encoder::new(&mut output, quality.max(1));
// Chromium's default JPEG path uses 4:2:0 chroma sampling.
encoder.set_sampling_factor(jpeg_encoder::SamplingFactor::F_2_2);
encoder
.encode(image.as_raw(), width, height, jpeg_encoder::ColorType::Rgba)
.map_err(|error| format!("JPEG screenshot encoding failed: {error}"))?;
Ok(output)
}
#[cfg(feature = "render")]
fn encode_screenshot(
image: &image::RgbaImage,
@@ -361,22 +377,7 @@ fn encode_screenshot(
.map_err(|error| format!("PNG screenshot encoding failed: {error}"))?;
}
ScreenshotFormat::Jpeg => {
let rgb = image::DynamicImage::ImageRgba8(image.clone()).to_rgb8();
// image's pure-Rust JPEG encoder defines its quality range as
// 1..=100; Chromium permits zero, whose effective result is the
// lowest-quality encoding.
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(
&mut output,
options.quality.max(1),
);
encoder
.write_image(
rgb.as_raw(),
rgb.width(),
rgb.height(),
image::ExtendedColorType::Rgb8,
)
.map_err(|error| format!("JPEG screenshot encoding failed: {error}"))?;
output = encode_jpeg(image, options.quality)?;
}
ScreenshotFormat::Webp => {
if options.quality_supplied {
@@ -619,46 +620,75 @@ fn parse_screencast_state(params: &Value, session_id: i64) -> Result<ScreencastS
}
#[cfg(feature = "render")]
fn encode_screencast_frame(
renderer_png: Vec<u8>,
fn screencast_capture_region(
viewport: (f32, f32),
scroll: (f32, f32),
state: &ScreencastState,
) -> Result<Vec<u8>, String> {
if state.format == ScreencastFormat::Png
&& state.max_width.is_none()
&& state.max_height.is_none()
{
return Ok(renderer_png);
}
let source = image::load_from_memory_with_format(&renderer_png, image::ImageFormat::Png)
.map_err(|error| format!("Page.startScreencast could not decode renderer PNG: {error}"))?
.to_rgba8();
) -> (obscura_browser::CaptureRegion, (u32, u32)) {
let mut scale = 1.0_f64;
if let Some(max_width) = state.max_width {
scale = scale.min(f64::from(max_width) / f64::from(source.width()));
scale = scale.min(f64::from(max_width) / f64::from(viewport.0));
}
if let Some(max_height) = state.max_height {
scale = scale.min(f64::from(max_height) / f64::from(source.height()));
scale = scale.min(f64::from(max_height) / f64::from(viewport.1));
}
let size = (
(f64::from(source.width()) * scale).round().max(1.0) as u32,
(f64::from(source.height()) * scale).round().max(1.0) as u32,
let output = (
(f64::from(viewport.0) * scale).round().max(1.0) as u32,
(f64::from(viewport.1) * scale).round().max(1.0) as u32,
);
let raster = if size == source.dimensions() {
source
} else {
image::imageops::resize(
&source,
size.0,
size.1,
image::imageops::FilterType::Triangle,
)
};
let native = obscura_browser::CaptureRegion::new(
scroll.0,
scroll.1,
viewport.0,
viewport.1,
1.0,
);
if scale >= 1.0 || obscura_browser::validate_capture_region(native).is_ok() {
return (native, output);
}
let scaled = obscura_browser::CaptureRegion::with_output_size(
scroll.0,
scroll.1,
viewport.0,
viewport.1,
scale as f32,
output.0,
output.1,
);
(scaled, output)
}
#[cfg(feature = "render")]
fn resize_screencast_frame(
raster: &image::RgbaImage,
output: (u32, u32),
) -> Result<image::RgbaImage, String> {
use fast_image_resize::{images::{Image, ImageRef}, FilterType, PixelType, ResizeAlg, ResizeOptions, Resizer};
let source = ImageRef::new(raster.width(), raster.height(), raster.as_raw(), PixelType::U8x4)
.map_err(|error| error.to_string())?;
let mut destination = Image::new(output.0, output.1, PixelType::U8x4);
// Adaptive bilinear convolution matches image's Triangle downsampling.
// Keep its existing RGBA-channel policy, including transparent PNG frames.
let options = ResizeOptions::new()
.resize_alg(ResizeAlg::Convolution(FilterType::Bilinear))
.use_alpha(false);
Resizer::new().resize(&source, &mut destination, Some(&options))
.map_err(|error| error.to_string())?;
image::RgbaImage::from_raw(output.0, output.1, destination.into_vec())
.ok_or_else(|| "Invalid screencast raster dimensions".to_string())
}
#[cfg(feature = "render")]
fn encode_screencast_frame(
raster: &image::RgbaImage,
state: &ScreencastState,
) -> Result<Vec<u8>, String> {
let format = match state.format {
ScreencastFormat::Png => ScreenshotFormat::Png,
ScreencastFormat::Jpeg => ScreenshotFormat::Jpeg,
};
encode_screenshot(
&raster,
raster,
ScreenshotOptions {
format,
quality: state.quality,
@@ -671,8 +701,9 @@ fn encode_screencast_frame(
)
}
/// Queue a visible-viewport frame through normal CDP event transport. This is
/// intentionally command-driven until Obscura has a compositor frame pump.
/// Queue a visible-viewport frame through normal CDP event transport. Initial
/// frames are immediate; later damage is coalesced by the connection's 30 Hz
/// compositor pump.
#[cfg(feature = "render")]
pub(crate) fn queue_screencast_frame(
ctx: &mut CdpContext,
@@ -698,44 +729,33 @@ pub(crate) fn queue_screencast_frame(
state.clone()
};
let attached_session = Some(cdp_session_id.to_string());
let (viewport, scroll, png, activity_generation) = {
let (viewport, scroll, raster, activity_generation) = {
let page = ctx
.get_session_page_mut(&attached_session)
.ok_or("No page for session")?;
let animation_sample = page.live_animation_sample();
let viewport = page.viewport;
let scroll = page
.evaluate("[window.scrollX, window.scrollY]")
.as_array()
.map(|values| {
(
values.first().and_then(Value::as_f64).unwrap_or(0.0),
values.get(1).and_then(Value::as_f64).unwrap_or(0.0),
)
})
.unwrap_or((0.0, 0.0));
obscura_browser::validate_capture_region(obscura_browser::CaptureRegion::new(
scroll.0 as f32,
scroll.1 as f32,
viewport.0,
viewport.1,
1.0,
))
.map_err(capture_error_message)?;
let png = page
.screenshot_with_animation_sample(viewport, animation_sample)
.ok_or_else(|| {
"Page.startScreencast failed: the page has no visible DOM surface to render"
.to_string()
})?;
let scroll = page.screenshot_scroll_offset();
let (region, output) = screencast_capture_region(viewport, scroll, &state);
let raster = page
.screenshot_region_raster_with_animation_sample(
region,
animation_sample,
)
.map_err(capture_error_message)?;
let raster = if raster.dimensions() == output {
raster
} else {
resize_screencast_frame(&raster, output)?
};
let activity_generation = page
.js
.as_ref()
.map(|js| js.activity_generation())
.unwrap_or(0);
(viewport, scroll, png, activity_generation)
(viewport, scroll, raster, activity_generation)
};
let encoded = encode_screencast_frame(png, &state)?;
let encoded = encode_screencast_frame(&raster, &state)?;
use base64::Engine as _;
let data = base64::engine::general_purpose::STANDARD.encode(encoded);
let Some(live) = ctx.screencasts.get_mut(cdp_session_id) else {
@@ -818,8 +838,6 @@ pub(crate) fn command_can_change_screencast_frame(method: &str) -> bool {
"Page.navigate"
| "Page.reload"
| "Page.navigateToHistoryEntry"
| "Runtime.evaluate"
| "Runtime.callFunctionOn"
| "Input.dispatchMouseEvent"
| "Input.dispatchKeyEvent"
| "Input.dispatchTouchEvent"
@@ -833,6 +851,19 @@ pub(crate) fn command_can_change_screencast_frame(method: &str) -> bool {
)
}
#[cfg(feature = "render")]
pub(crate) fn schedule_screencast_frame(
ctx: &mut CdpContext,
cdp_session_id: &Option<String>,
) {
if let Some(state) = cdp_session_id
.as_deref()
.and_then(|session_id| ctx.screencasts.get_mut(session_id))
{
state.autonomous_frame_pending = true;
}
}
/// Emit the post-navigation event stream into `ctx.pending_events`. Shared
/// by both the in-process `do_navigate` path and the spawned path in
/// `server::process_navigation`, so the recent goto-returns-Response /
@@ -905,6 +936,23 @@ pub fn emit_navigation_events(
params: json!({"requestId": rid, "loaderId": loader_id, "documentURL": page_url, "request": {"url": net_event.url, "method": net_event.method, "headers": net_event.headers}, "timestamp": net_event.timestamp, "wallTime": net_event.timestamp, "initiator": {"type": "other"}, "type": net_event.resource_type, "frameId": frame_id}),
session_id: es.clone(),
});
if ctx.fetch_intercept.enabled {
ctx.pending_events.push(CdpEvent {
method: "Fetch.requestPaused".into(),
params: json!({
"requestId": rid,
"request": {
"url": net_event.url,
"method": net_event.method,
"headers": net_event.headers,
},
"frameId": frame_id,
"resourceType": net_event.resource_type,
"networkId": rid,
}),
session_id: es.clone(),
});
}
}
let contexts = ctx.commit_default_context(page_id, frame_id, page_url);
@@ -939,6 +987,9 @@ pub fn emit_navigation_events(
if ctx.fetch_intercept.enabled {
for (i, net_event) in network_events.iter().enumerate() {
if Some(i) == nav_idx || net_event.intercepted {
continue;
}
let rid = &nav_request_ids[i];
ctx.pending_events.push(CdpEvent {
method: "Fetch.requestPaused".into(),
@@ -960,7 +1011,7 @@ pub fn emit_navigation_events(
for (i, net_event) in network_events.iter().enumerate() {
let rid = &nav_request_ids[i];
if Some(i) != nav_idx {
if Some(i) != nav_idx && !net_event.intercepted {
ctx.pending_events.push(CdpEvent {
method: "Network.requestWillBeSent".into(),
params: json!({"requestId": rid, "loaderId": loader_id, "documentURL": page_url, "request": {"url": net_event.url, "method": net_event.method, "headers": net_event.headers}, "timestamp": net_event.timestamp, "wallTime": net_event.timestamp, "initiator": {"type": "other"}, "type": net_event.resource_type, "frameId": frame_id}),
@@ -1037,6 +1088,23 @@ pub fn emit_navigation_events(
));
}
pub(crate) fn emit_same_document_navigation(
ctx: &mut CdpContext,
session_id: &Option<String>,
frame_id: &str,
url: &str,
) {
ctx.pending_events.push(CdpEvent {
method: "Page.navigatedWithinDocument".into(),
params: json!({
"frameId": frame_id,
"url": url,
"navigationType": "historyApi",
}),
session_id: session_id.clone(),
});
}
/// Emit completed script-initiated requests after the document lifecycle has
/// already finished. These requests belong to the current document loader and
/// must not replay frame navigation or load lifecycle events.
@@ -1058,25 +1126,27 @@ pub(crate) fn emit_runtime_network_events(
.unwrap_or_else(|| format!("loader-blank-{page_id}"));
for network_event in network_events {
let request_id = &network_event.request_id;
ctx.pending_events.push(CdpEvent {
method: "Network.requestWillBeSent".into(),
params: json!({
"requestId": request_id,
"loaderId": loader_id,
"documentURL": page_url,
"request": {
"url": network_event.url,
"method": network_event.method,
"headers": network_event.headers,
},
"timestamp": network_event.timestamp,
"wallTime": network_event.timestamp,
"initiator": {"type": "script"},
"type": network_event.resource_type,
"frameId": frame_id,
}),
session_id: session_id.clone(),
});
if !network_event.intercepted {
ctx.pending_events.push(CdpEvent {
method: "Network.requestWillBeSent".into(),
params: json!({
"requestId": request_id,
"loaderId": loader_id,
"documentURL": page_url,
"request": {
"url": network_event.url,
"method": network_event.method,
"headers": network_event.headers,
},
"timestamp": network_event.timestamp,
"wallTime": network_event.timestamp,
"initiator": {"type": "script"},
"type": network_event.resource_type,
"frameId": frame_id,
}),
session_id: session_id.clone(),
});
}
ctx.pending_events.push(CdpEvent {
method: "Network.responseReceived".into(),
params: json!({
@@ -1377,9 +1447,30 @@ pub async fn handle(
}
"addScriptToEvaluateOnNewDocument" => {
let source = params.get("source").and_then(|v| v.as_str()).unwrap_or("");
let run_immediately = params.get("runImmediately").and_then(Value::as_bool).unwrap_or(false);
if run_immediately && params.get("worldName").and_then(Value::as_str)
.is_some_and(|name| !name.is_empty())
{
return Err("Immediate scripts in isolated worlds are not supported".to_string());
}
ctx.preload_counter += 1;
let identifier = format!("{}", ctx.preload_counter);
if !source.is_empty() {
if run_immediately {
let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?;
// Script exceptions do not reject the registration. Dispatch
// supplies the V8 lock and watchdog for this command.
if let Err(error) = page.execute_preload_script(source) {
tracing::warn!("Immediate preload: {error}");
}
if let Some(js) = &mut page.js {
for frame in &page.frames {
if let Err(error) = frame.execute_script(js, source) {
tracing::warn!("Immediate frame preload: {error}");
}
}
}
}
ctx.preload_scripts
.push((identifier.clone(), source.to_string()));
}
@@ -1763,7 +1854,7 @@ pub async fn handle(
}
}
fn timestamp() -> f64 {
pub(crate) fn timestamp() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
@@ -1775,6 +1866,60 @@ mod tests {
use super::*;
use crate::dispatch::CdpContext;
#[tokio::test(flavor = "current_thread")]
async fn immediate_preload_runs_in_existing_frames_and_remains_removable() {
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session = Some(format!("{page_id}-session"));
ctx.sessions.insert(session.clone().unwrap(), page_id);
handle("navigate", &json!({"url":"about:blank"}), &mut ctx, &session)
.await.unwrap();
{
let page = ctx.get_session_page_mut(&session).unwrap();
let frame = obscura_js::frame::FrameRealm::new(
page.js.as_mut().unwrap(), 1, 0, "https://frame.example/", "<p>child</p>",
).unwrap();
page.frames.push(frame);
}
let deferred = handle("addScriptToEvaluateOnNewDocument", &json!({
"source":"globalThis.deferred = (globalThis.deferred || 0) + 1",
}), &mut ctx, &session).await.unwrap();
assert_eq!(ctx.get_session_page_mut(&session).unwrap().js.as_mut().unwrap()
.evaluate("globalThis.deferred ?? 0").unwrap().as_f64(), Some(0.0));
let immediate = handle("addScriptToEvaluateOnNewDocument", &json!({
"source":"globalThis.immediate = (globalThis.immediate || 0) + 1",
"runImmediately":true,
}), &mut ctx, &session).await.unwrap();
{
let page = ctx.get_session_page_mut(&session).unwrap();
assert_eq!(page.js.as_mut().unwrap().evaluate("globalThis.immediate ?? 0").unwrap().as_f64(), Some(1.0));
assert_eq!(page.evaluate_in_frame(0, "globalThis.immediate ?? 0").unwrap().as_f64(), Some(1.0));
}
handle("navigate", &json!({"url":"data:text/html,<title>next</title>"}), &mut ctx, &session)
.await.unwrap();
assert_eq!(ctx.get_session_page_mut(&session).unwrap().js.as_mut().unwrap()
.evaluate("[globalThis.deferred, globalThis.immediate]").unwrap(), json!([1,1]));
for registration in [deferred, immediate] {
handle("removeScriptToEvaluateOnNewDocument", &registration, &mut ctx, &session)
.await.unwrap();
}
let throwing = handle("addScriptToEvaluateOnNewDocument", &json!({
"source":"throw new Error('script error is not a protocol error')", "runImmediately":true,
}), &mut ctx, &session).await.unwrap();
handle("removeScriptToEvaluateOnNewDocument", &throwing, &mut ctx, &session).await.unwrap();
handle("navigate", &json!({"url":"data:text/html,<title>removed</title>"}), &mut ctx, &session)
.await.unwrap();
assert_eq!(ctx.get_session_page_mut(&session).unwrap().js.as_mut().unwrap()
.evaluate("[globalThis.deferred ?? 0, globalThis.immediate ?? 0]").unwrap(), json!([0,0]));
// Named worlds currently share the main global. Do not silently inject
// an explicitly isolated immediate script into the page's own world.
let isolated = handle("addScriptToEvaluateOnNewDocument", &json!({
"source":"globalThis.leaked = true", "runImmediately":true, "worldName":"private",
}), &mut ctx, &session).await;
assert!(isolated.is_err());
assert!(ctx.preload_scripts.is_empty());
}
// #920: a history navigation that fails to load must not move the recorded
// currentIndex — the page never actually went anywhere, so a later
// getNavigationHistory must still report where it really is.
@@ -1907,6 +2052,7 @@ mod tests {
.insert(page_id.clone(), "loader-current".into());
let event = obscura_browser::NetworkEvent {
request_id: "fetch-7".into(),
intercepted: false,
url: "https://example.test/data.json".into(),
method: "GET".into(),
resource_type: "Fetch".into(),
@@ -1926,7 +2072,7 @@ mod tests {
"frame-1",
"https://example.test/",
&page_id,
&[event],
std::slice::from_ref(&event),
);
assert_eq!(ctx.pending_events.len(), 3);
@@ -1941,6 +2087,81 @@ mod tests {
"Page.frameNavigated" | "Page.lifecycleEvent"
)
}));
// Live interception already sent requestWillBeSent/requestPaused.
// Replaying either leaves Playwright waiting for a second request.
ctx.pending_events.clear();
let event = obscura_browser::NetworkEvent { intercepted: true, ..event };
emit_runtime_network_events(
&mut ctx, &session_id, "frame-1", "https://example.test/", &page_id,
std::slice::from_ref(&event),
);
assert_eq!(ctx.pending_events.iter().map(|e| e.method.as_str()).collect::<Vec<_>>(),
["Network.responseReceived", "Network.loadingFinished"]);
assert!(ctx.pending_events.iter().all(|e| e.params["requestId"] == "fetch-7"));
ctx.pending_events.clear();
ctx.fetch_intercept.enabled = true;
emit_navigation_events(
&mut ctx, &session_id, "frame-1", "loader-current", "https://example.test/",
&page_id, &[event], WaitUntil::Load, true,
);
let network = ctx.pending_events.iter().filter(|e|
e.method.starts_with("Network.") || e.method.starts_with("Fetch.")
).collect::<Vec<_>>();
assert_eq!(network.iter().map(|e| e.method.as_str()).collect::<Vec<_>>(),
["Network.responseReceived", "Network.loadingFinished"]);
assert!(network.iter().all(|e| e.params["requestId"] == "fetch-7"));
}
#[test]
fn intercepted_document_is_paused_before_frame_commit() {
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = Some(format!("{page_id}-session"));
ctx.sessions
.insert(session_id.clone().unwrap(), page_id.clone());
ctx.fetch_intercept.enabled = true;
let page_url = "https://example.test/";
let event = obscura_browser::NetworkEvent {
request_id: "document-internal".into(),
intercepted: false,
url: page_url.into(),
method: "GET".into(),
resource_type: "Document".into(),
status: 200,
headers: std::collections::HashMap::new(),
response_headers: std::sync::Arc::new(std::collections::HashMap::new()),
body_size: 0,
timestamp: 42.0,
};
emit_navigation_events(
&mut ctx,
&session_id,
"frame-1",
"loader-1",
page_url,
&page_id,
&[event],
WaitUntil::Load,
true,
);
let methods = ctx
.pending_events
.iter()
.map(|event| event.method.as_str())
.collect::<Vec<_>>();
let paused = methods
.iter()
.position(|method| *method == "Fetch.requestPaused")
.unwrap();
let committed = methods
.iter()
.position(|method| *method == "Page.frameNavigated")
.unwrap();
assert!(paused < committed, "document pause must precede frame commit: {methods:?}");
}
#[tokio::test]
@@ -2048,6 +2269,21 @@ mod tests {
(format, raster)
}
#[cfg(feature = "render")]
#[test]
fn jpeg_encoder_consumes_rgba_frames_directly() {
let raster = image::RgbaImage::from_pixel(32, 24, image::Rgba([20, 40, 60, 255]));
let encoded = encode_jpeg(&raster, 90).expect("encode RGBA frame");
let decoded = image::load_from_memory_with_format(&encoded, image::ImageFormat::Jpeg)
.expect("decode JPEG frame").to_rgb8();
assert_eq!(decoded.dimensions(), (32, 24));
for pixel in decoded.pixels() {
for (actual, expected) in pixel.0.into_iter().zip([20u8, 40, 60]) {
assert!(actual.abs_diff(expected) <= 3, "JPEG changed the frame color: {pixel:?}");
}
}
}
#[cfg(feature = "render")]
async fn screenshot_fixture() -> (CdpContext, Option<String>) {
let mut ctx = CdpContext::new();
@@ -2330,6 +2566,7 @@ mod tests {
)
.await;
assert!(response.error.is_none(), "override response: {response:?}");
pump_screencast_frames(&mut ctx).await;
let event = ctx
.pending_events
.iter()
@@ -2400,6 +2637,57 @@ mod tests {
assert_eq!(raster.dimensions(), (51, 41));
}
#[cfg(feature = "render")]
#[tokio::test]
async fn screencast_raster_preserves_straight_alpha() {
let (mut ctx, session) = transparent_surface_fixture(80).await;
crate::domains::emulation::handle(
"setDefaultBackgroundColorOverride",
&json!({"color": {"r": 255, "g": 0, "b": 0, "a": 16.0 / 255.0}}),
&mut ctx,
&session,
)
.await
.expect("semi-transparent surface");
let page = ctx.get_session_page_mut(&session).expect("page");
let raster = page
.screenshot_region_raster_with_animation_sample(
obscura_browser::CaptureRegion::new(0.0, 0.0, 100.0, 80.0, 1.0),
page.live_animation_sample(),
)
.expect("raster");
assert_eq!(raster.dimensions(), (100, 80));
assert!(raster.pixels().all(|pixel| pixel.0 == [255, 0, 0, 16]));
}
#[cfg(feature = "render")]
#[tokio::test]
async fn scaled_screencast_does_not_allocate_the_native_viewport() {
let (mut ctx, session) = screenshot_fixture().await;
ctx.get_session_page_mut(&session)
.expect("page")
.set_viewport((4097.0, 4097.0));
ctx.pending_events.clear();
handle(
"startScreencast",
&json!({"format": "jpeg", "maxWidth": 50, "maxHeight": 50}),
&mut ctx,
&session,
)
.await
.expect("scaled frame should rasterize at its output size");
let frame = ctx
.pending_events
.iter()
.find(|event| event.method == "Page.screencastFrame")
.expect("initial frame");
let (_, raster) = decode_capture(&frame.params);
assert_eq!(raster.dimensions(), (50, 50));
}
#[cfg(feature = "render")]
#[tokio::test]
async fn css_animation_drives_autonomous_screencast_frames_until_completion() {
@@ -2677,6 +2965,68 @@ mod tests {
assert_eq!(state.max_height, None);
}
#[cfg(feature = "render")]
#[test]
fn bounded_screencast_capture_defers_downscale_to_the_fast_encoder_path() {
let state = parse_screencast_state(
&json!({"format": "jpeg", "maxWidth": 800, "maxHeight": 450}),
1,
)
.expect("screencast state");
let (region, output) =
screencast_capture_region((1280.0, 720.0), (0.0, 0.0), &state);
assert_eq!(region.scale, 1.0);
assert_eq!(output, (800, 450));
let (oversized, output) =
screencast_capture_region((4097.0, 4097.0), (0.0, 0.0), &state);
assert!(oversized.scale < 1.0);
assert_eq!(output, (450, 450));
}
#[cfg(feature = "render")]
#[test]
fn screencast_resize_preserves_triangle_filter_and_rgba_channels() {
for (width, height, output) in [
(1280, 720, (800, 450)), (127, 79, (83, 51)), (81, 61, (1, 1)),
(1, 17, (1, 3)), (80, 60, (80, 60)),
] {
let source = image::RgbaImage::from_fn(width, height, |x, y| {
image::Rgba([(x * 37) as u8, (y * 71) as u8, ((x ^ y) * 53) as u8, ((x + y) * 29) as u8])
});
let reference = image::imageops::resize(&source, output.0, output.1, image::imageops::FilterType::Triangle);
let actual = resize_screencast_frame(&source, output).unwrap();
assert_eq!(actual.dimensions(), output);
let largest_rounding_error = reference.as_raw().iter().zip(actual.as_raw())
.map(|(a, b)| a.abs_diff(*b)).max().unwrap();
assert!(largest_rounding_error <= 2,
"{width}x{height} -> {output:?}: filter/channel mismatch {largest_rounding_error}");
}
}
#[cfg(feature = "render")]
#[tokio::test]
#[ignore = "release performance check; run alone on the evaluation host"]
async fn screencast_frame_budget_keeps_raster_work_below_ten_ms() {
let (mut ctx, session) = screenshot_fixture().await;
ctx.get_session_page_mut(&session).unwrap().set_viewport((1280.0, 720.0));
handle("startScreencast", &json!({
"format": "jpeg", "quality": 90, "maxWidth": 800, "maxHeight": 450,
}), &mut ctx, &session).await.unwrap();
let mut samples = Vec::new();
for _ in 0..21 {
ctx.pending_events.clear();
ctx.screencasts.get_mut(session.as_ref().unwrap()).unwrap().frames_in_flight = 0;
let started = std::time::Instant::now();
assert!(queue_screencast_frame(&mut ctx, &session, true).unwrap());
samples.push(started.elapsed());
}
samples.sort_unstable();
eprintln!("screencast frame median: {:?}", samples[10]);
assert!(samples[10] < std::time::Duration::from_millis(10),
"frame raster/resize/encode monopolizes the page loop: {:?}", samples[10]);
}
#[cfg(feature = "render")]
#[tokio::test]
async fn capture_screenshot_preserves_default_png_and_honors_clip_scale() {
+20 -2
View File
@@ -1,4 +1,5 @@
use obscura_browser::lifecycle::WaitUntil;
use obscura_browser::page::PendingNavigationOutcome;
use obscura_js::runtime::RemoteObjectInfo;
use serde_json::{json, Value};
@@ -49,11 +50,20 @@ async fn emit_post_eval_nav(
ctx: &mut CdpContext,
session_id: &Option<String>,
) -> Result<(), String> {
let server_managed_navigation = ctx.intercept_tx.is_some();
let page = ctx
.get_session_page_mut(session_id)
.ok_or("No page")?;
let did_navigate = page.process_pending_navigation().await.map_err(|e| e.to_string())?;
if !did_navigate {
// The connection processor owns the Fetch reply channel. Let it perform
// document navigation outside dispatch so destination requests can resume.
if server_managed_navigation && page.has_pending_navigation() {
return Ok(());
}
let navigation = page
.process_pending_navigation_outcome()
.await
.map_err(|e| e.to_string())?;
if navigation == PendingNavigationOutcome::None {
return Ok(());
}
let (frame_id, page_url, page_id, network_events, reached_idle) = {
@@ -66,6 +76,10 @@ async fn emit_post_eval_nav(
p.lifecycle.is_network_idle(),
)
};
if navigation == PendingNavigationOutcome::SameDocument {
super::page::emit_same_document_navigation(ctx, session_id, &frame_id, &page_url);
return Ok(());
}
let loader_id = format!("loader-{}", uuid::Uuid::new_v4());
super::page::emit_navigation_events(
ctx,
@@ -551,6 +565,10 @@ fn remote_object_from_info(info: &RemoteObjectInfo) -> Value {
obj["value"] = value.clone();
}
if let Some(ref value) = info.unserializable_value {
obj["unserializableValue"] = json!(value);
}
obj
}
+15 -4
View File
@@ -238,16 +238,19 @@ pub async fn handle(
Ok(json!({ "success": true }))
}
"setAutoAttach" => Ok(json!({})),
// No multi-target lifecycle to manage: obscura runs one page per session.
// Ack these so Chrome-shaped clients that call them do not warn (issue #340).
"detachFromTarget" => {
if let Some(session_id) = params.get("sessionId").and_then(Value::as_str) {
let page_id = ctx.sessions.get(session_id).cloned();
ctx.sessions.remove(session_id);
let page_id = ctx.sessions.remove(session_id);
ctx.runtime_enabled_sessions.remove(session_id);
ctx.lifecycle_enabled_sessions.remove(session_id);
if let Some(page_id) = page_id {
ctx.refresh_runtime_event_collection(&page_id);
let params = json!({"sessionId": session_id, "targetId": page_id});
let event = match parent_session_id {
Some(parent) => CdpEvent::with_session("Target.detachedFromTarget", params, parent.clone()),
None => CdpEvent::new("Target.detachedFromTarget", params),
};
ctx.pending_events.push(event);
}
#[cfg(feature = "render")]
ctx.screencasts.remove(session_id);
@@ -482,6 +485,7 @@ mod tests {
.await
.unwrap();
let session_id = attached["sessionId"].as_str().unwrap().to_string();
ctx.pending_events.clear();
handle(
"detachFromTarget",
@@ -492,6 +496,13 @@ mod tests {
.await
.expect("detach should succeed");
assert!(!ctx.sessions.contains_key(&session_id));
assert!(ctx.get_page(&page_id).is_some(), "detach must not destroy the target");
assert_eq!(ctx.pending_events.len(), 1);
let event = &ctx.pending_events[0];
assert_eq!(event.method, "Target.detachedFromTarget");
assert_eq!(event.session_id, parent_session);
assert_eq!(event.params["sessionId"], session_id);
assert_eq!(event.params["targetId"], page_id);
}
#[tokio::test]
+2 -1
View File
@@ -7,6 +7,7 @@ pub(crate) mod util;
pub use server::{
start, start_with_full_options, start_with_full_serve_options, start_with_host,
start_with_host_and_security, start_with_options, start_with_serve_options_and_limit,
start_with_host_and_security, start_with_options,
start_with_serve_options_and_limit, start_with_serve_options_limit_and_ready_file,
DEFAULT_MAX_CONNECTIONS,
};
+473 -102
View File
@@ -1,5 +1,8 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::net::SocketAddr;
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
@@ -295,6 +298,35 @@ pub async fn start_with_serve_options_and_limit(
storage_dir: Option<std::path::PathBuf>,
allow_private_network: bool,
max_connections: usize,
) -> anyhow::Result<()> {
start_with_serve_options_limit_and_ready_file(
port,
host,
proxy,
stealth,
user_agent,
allow_file_access,
storage_dir,
allow_private_network,
max_connections,
None,
)
.await
}
/// Full serve entry point with optional atomic startup notification.
#[allow(clippy::too_many_arguments)]
pub async fn start_with_serve_options_limit_and_ready_file(
port: u16,
host: &str,
proxy: Option<String>,
stealth: bool,
user_agent: Option<String>,
allow_file_access: bool,
storage_dir: Option<std::path::PathBuf>,
allow_private_network: bool,
max_connections: usize,
ready_file: Option<&Path>,
) -> anyhow::Result<()> {
let ip: std::net::IpAddr = host
.parse()
@@ -342,8 +374,12 @@ pub async fn start_with_serve_options_and_limit(
std_listener
.set_nonblocking(true)
.map_err(|e| anyhow::anyhow!("set_nonblocking: {}", e))?;
let bound_addr = std_listener
.local_addr()
.map_err(|e| anyhow::anyhow!("read bound address: {}", e))?;
let port = bound_addr.port();
info!("Obscura CDP server listening on ws://{}:{}", host, port);
info!("Obscura CDP server listening on ws://{}", bound_addr);
info!(
"DevTools endpoint: ws://{}:{}/devtools/browser",
host, port
@@ -355,6 +391,14 @@ pub async fn start_with_serve_options_and_limit(
info!("CDP bearer authentication enabled");
}
// Initialize V8 before announcing readiness. The accept thread starts
// afterwards, so a publication error can still terminate startup cleanly.
drop(obscura_js::runtime::ObscuraJsRuntime::new());
cap_malloc_arenas();
if let Some(path) = ready_file {
publish_ready_file(path, bound_addr)?;
}
let (ws_tx, mut ws_rx) = mpsc::channel::<std::net::TcpStream>(MAX_PENDING_WS_HANDOFFS);
// Ctrl-C / graceful shutdown coordination.
@@ -529,16 +573,6 @@ pub async fn start_with_serve_options_and_limit(
.ok();
}
// Force V8 and its process-global isolate tables (the leaptiering
// JSDispatchTable / external-pointer tables) to initialize once on this main
// thread before any connection thread creates an isolate. Creating the very
// first isolate off the main thread segfaults inside
// InitializeBuiltinJSDispatchTable (#430 thread-per-connection). Building and
// dropping one runtime here does the one-time setup single-threaded.
drop(obscura_js::runtime::ObscuraJsRuntime::new());
cap_malloc_arenas();
// Live CDP connections, incremented on accept and decremented when a
// connection thread exits (see `run_connection`).
let live_connections = Arc::new(AtomicUsize::new(0));
@@ -622,6 +656,41 @@ pub async fn start_with_serve_options_and_limit(
Ok(())
}
#[derive(serde::Serialize)]
struct ReadyRecord {
pid: u32,
host: String,
port: u16,
websocket_path: &'static str,
}
fn publish_ready_file(path: &Path, address: SocketAddr) -> anyhow::Result<()> {
let record = serde_json::to_vec(&ReadyRecord {
pid: std::process::id(),
host: address.ip().to_string(),
port: address.port(),
websocket_path: "/devtools/browser",
})?;
let temporary = path.with_file_name(format!(
".obscura-ready-{}.tmp",
uuid::Uuid::new_v4()
));
let result = (|| -> std::io::Result<()> {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temporary)?;
file.write_all(&record)?;
file.sync_all()?;
std::fs::rename(&temporary, path)
})();
if result.is_err() {
let _ = std::fs::remove_file(&temporary);
}
result.map_err(|e| anyhow::anyhow!("publish ready file {}: {}", path.display(), e))
}
/// Cap the number of per-thread malloc arenas glibc will create.
///
/// glibc hands each new thread its own 64 MiB arena (up to 8x cores). With one
@@ -1141,6 +1210,7 @@ async fn cdp_processor(
// next command/navigation, so static pages consume no polling budget.
let mut runtime_pump_armed = false;
let mut runtime_pump_error_streak = 0_u8;
let mut idle_pages = HashSet::new();
loop {
// Drain any deferred messages from the previous interception window
@@ -1151,16 +1221,6 @@ async fn cdp_processor(
Some(d)
} else {
let screencast_active = has_active_screencast(&ctx);
let live_page_route = ctx
.pages
.iter()
.find(|page| page.has_js())
.and_then(|page| {
ctx.sessions
.iter()
.find(|(_, page_id)| *page_id == &page.id)
.map(|(session_id, _)| (session_id.clone(), page.frame_id.clone()))
});
let has_intercept_rx = intercept_rx.is_some();
tokio::select! {
biased;
@@ -1172,7 +1232,7 @@ async fn cdp_processor(
tracing::info!("Shutdown signal received (connection processor)");
break;
},
pump_result = pump_live_page_event_loop(&mut ctx), if runtime_pump_armed => {
pump_result = pump_live_page_event_loop(&mut ctx, &mut idle_pages), if runtime_pump_armed => {
match pump_result {
Ok(reached_idle) => {
runtime_pump_error_streak = 0;
@@ -1187,7 +1247,7 @@ async fn cdp_processor(
}
}
service_live_page_render_resources(&mut ctx);
sync_live_page_network_events(&mut ctx);
sync_live_page_background_events(&mut ctx);
dispatch::drain_runtime_events(&mut ctx);
dispatch::drain_binding_calls(&mut ctx);
dispatch::drain_frame_events(&mut ctx);
@@ -1214,6 +1274,7 @@ async fn cdp_processor(
false,
)
.await;
idle_pages.clear();
runtime_pump_armed = ctx.pages.iter().any(|page| page.has_js());
}
None
@@ -1225,13 +1286,11 @@ async fn cdp_processor(
std::future::pending().await
}
}, if has_intercept_rx => {
if let (Some((session_id, frame_id)), Some(reply_tx)) =
(live_page_route.as_ref(), connection_reply_tx.as_ref())
{
if let Some(reply_tx) = connection_reply_tx.as_ref() {
emit_intercepted_request(
intercepted,
frame_id,
Some(session_id.clone()),
&ctx,
None,
reply_tx,
&mut intercepted_paused,
);
@@ -1249,6 +1308,8 @@ async fn cdp_processor(
&mut ctx,
connection_reply_tx.as_ref(),
).await;
idle_pages.clear();
runtime_pump_armed = ctx.pages.iter().any(|page| page.has_js());
None
}
}
@@ -1267,7 +1328,7 @@ async fn cdp_processor(
);
}
ServerMessage::Cdp(cdp_msg) => {
// Route every Page.navigate through the spawn-and-defer path,
// Route navigation and reload through the spawn-and-defer path,
// not just intercepted ones. Holding the V8 lock across a
// multi-second navigate inside the regular dispatch wedges the
// entire processor (40-site sweep: 39/40 timeouts). Spawning
@@ -1298,9 +1359,25 @@ async fn cdp_processor(
}
}
// Dispatch may have created a page or scheduled new asynchronous work.
// A single live isolate is the connection's current active target; the
// pump will park cheaply if its next task is a distant timer.
if let (Some(reply_tx), Some((session_id, url, method, body))) = (
connection_reply_tx.as_ref(),
take_live_pending_navigation(&ctx),
) {
let navigation = json!({
"id": 0,
"method": "Page.navigate",
"params": {"url": url, "__method": method, "__body": body},
"sessionId": session_id,
}).to_string();
process_with_interception(
&navigation, &mut ctx, reply_tx, &mut rx,
&mut intercept_rx, &mut intercepted_paused, &mut deferred, false,
).await;
}
// Dispatch may have created pages or scheduled work on an idle page.
// Recheck after commands, then park on the runtimes' own wakers.
idle_pages.clear();
runtime_pump_armed = ctx.pages.iter().any(|page| page.has_js());
runtime_pump_error_streak = 0;
@@ -1313,14 +1390,30 @@ async fn cdp_processor(
fn emit_intercepted_request(
intercepted: obscura_js::ops::InterceptedRequest,
frame_id: &str,
session_id: Option<String>,
ctx: &CdpContext,
navigating_page: Option<(&str, &str, Option<String>)>,
reply_tx: &mpsc::UnboundedSender<String>,
intercepted_paused: &mut HashMap<
String,
tokio::sync::oneshot::Sender<obscura_js::ops::InterceptResolution>,
>,
) {
// Navigation temporarily removes its Page from ctx. Other pages can still
// have requests queued on the same channel, so never infer their owner.
let route = navigating_page
.filter(|(page_id, _, _)| *page_id == intercepted.page_id)
.map(|(_, frame_id, session_id)| (frame_id, session_id))
.or_else(|| {
let page = ctx.pages.iter().find(|page| page.id == intercepted.page_id)?;
let (session_id, _) = ctx.sessions.iter().find(|(_, page_id)| **page_id == page.id)?;
Some((page.frame_id.as_str(), Some(session_id.clone())))
});
let Some((frame_id, session_id)) = route else {
let _ = intercepted.resolver.send(obscura_js::ops::InterceptResolution::Fail {
reason: "Aborted".into(),
});
return;
};
tracing::info!(
"INTERCEPTION: requestPaused for {} {} (sending to client)",
intercepted.method,
@@ -1372,26 +1465,42 @@ fn emit_intercepted_request(
intercepted_paused.insert(intercepted.request_id, intercepted.resolver);
}
async fn pump_live_page_event_loop(ctx: &mut CdpContext) -> Result<bool, String> {
// Several pages on one connection can be live at once (#872), each with its
// own event loop, so pump a turn on *every* live page rather than only the
// first. The pump stays armed until all live pages report idle.
let live_ids: Vec<String> = ctx
.pages
.iter()
.filter(|page| page.has_js())
.map(|page| page.id.clone())
async fn pump_live_page_event_loop(
ctx: &mut CdpContext,
idle_pages: &mut HashSet<String>,
) -> Result<bool, String> {
use std::future::Future;
use std::task::Poll;
// Register every page's waker before parking. Awaiting pages in order lets
// a distant timer on the first page hold up ready work on all its peers.
// A completed turn can emit events even when it leaves its page idle.
// Publish it now, then omit idle pages until a command schedules new work;
// otherwise an idle peer would keep a waiting connection spinning.
let mut turns: Vec<_> = ctx.pages.iter_mut()
.filter(|page| page.has_js() && !idle_pages.contains(&page.id))
.map(|page| (page.id.clone(), Box::pin(page.run_autonomous_event_loop_turn())))
.collect();
if live_ids.is_empty() {
return Ok(true);
}
let mut all_idle = true;
for page_id in live_ids {
if let Some(page) = ctx.get_page_mut(&page_id) {
all_idle &= page.run_autonomous_event_loop_turn().await?;
std::future::poll_fn(|cx| {
let mut all_idle = true;
let mut progressed = false;
for (page_id, future) in &mut turns {
match future.as_mut().poll(cx) {
Poll::Ready(Ok(idle)) => {
all_idle &= idle;
progressed = true;
if idle { idle_pages.insert(page_id.clone()); }
}
Poll::Ready(Err(error)) => return Poll::Ready(Err(error)),
Poll::Pending => all_idle = false,
}
}
}
Ok(all_idle)
if all_idle || progressed {
Poll::Ready(Ok(all_idle))
} else {
Poll::Pending
}
}).await
}
/// Apply finished background render-resource loads and start loads for
@@ -1405,8 +1514,8 @@ fn service_live_page_render_resources(ctx: &mut CdpContext) {
}
}
fn sync_live_page_network_events(ctx: &mut CdpContext) {
// Emit script-initiated network events for every live page, each attributed
fn sync_live_page_background_events(ctx: &mut CdpContext) {
// Emit script-initiated network/history events for every live page, attributed
// to its own session/frame — not just the first live page (#872).
let live_ids: Vec<String> = ctx
.pages
@@ -1423,17 +1532,27 @@ fn sync_live_page_network_events(ctx: &mut CdpContext) {
else {
continue;
};
let (frame_id, page_url, network_events) = {
let (frame_id, page_url, network_events, same_document_navigation) = {
let Some(page) = ctx.get_page_mut(&page_id) else {
continue;
};
page.sync_js_network_events();
// Timer-driven history changes need the same notification as
// Runtime.evaluate, even when the client is only waiting for an
// event. Leave document navigations to the interception loop.
let same_document_navigation = !page.has_pending_navigation() && page.sync_virtual_url();
(
page.frame_id.clone(),
page.url_string(),
page.network_events.drain(..).collect::<Vec<_>>(),
same_document_navigation,
)
};
if same_document_navigation {
crate::domains::page::emit_same_document_navigation(
ctx, &session_id, &frame_id, &page_url,
);
}
if network_events.is_empty() {
continue;
}
@@ -1507,7 +1626,7 @@ async fn pump_and_forward_screencast_frames(
forward_pending_events(ctx, reply_tx);
}
// Whether a raw CDP frame is exactly a `Page.navigate` call, and so should take
// Whether a raw CDP frame is a navigate/reload 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
// `Page.navigateToHistoryEntry` (goBack / goForward), which has no `url` param
@@ -1515,7 +1634,7 @@ async fn pump_and_forward_screencast_frames(
// literal text (e.g. a `Runtime.evaluate` expression). See issue #363.
fn is_navigate_method(text: &str) -> bool {
serde_json::from_str::<CdpRequest>(text)
.map(|req| req.method == "Page.navigate")
.map(|req| matches!(req.method.as_str(), "Page.navigate" | "Page.reload"))
.unwrap_or(false)
}
@@ -1647,7 +1766,22 @@ async fn process_with_interception(
// was never resumed once the dispatch-path resume was removed in #872) is
// therefore no longer needed and would strand concurrent pages.
let url = req.params.get("url").and_then(|v| v.as_str()).unwrap_or("");
let is_reload = req.method == "Page.reload";
let url_owned = if is_reload {
page.url_string()
} else {
req.params.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string()
};
if !send_command_response && page.reject_page_initiated_file_navigation(&url_owned) {
ctx.pages.push(page);
return;
}
// Preserve the regular handler's file-access gate on this async path.
if crate::util::url_is_file_scheme(&url_owned) && !page.context.allow_file_access {
ctx.pages.push(page);
process_cdp_message(text, ctx, reply_tx).await;
return;
}
let wait_until = crate::domains::page::parse_wait_until(&req.params);
let nav_method = req.params.get("__method").and_then(|v| v.as_str()).unwrap_or("GET").to_string();
let nav_body = req.params.get("__body").and_then(|v| v.as_str()).unwrap_or("").to_string();
@@ -1663,7 +1797,6 @@ async fn process_with_interception(
let loader_id = format!("loader-{}", uuid::Uuid::new_v4());
let (nav_done_tx, mut nav_done_rx) = mpsc::channel::<(obscura_browser::Page, Result<(), String>)>(1);
let url_owned = url.to_string();
let nav_v8_lock = ctx.v8_lock.clone();
tokio::task::spawn_local(async move {
@@ -1677,7 +1810,9 @@ async fn process_with_interception(
// must run BEFORE the page's own scripts (CDP contract). Hand them
// to the page so navigate_single can inject them at the right point.
page.set_preload_scripts(preload_scripts);
let result = if nav_method == "POST" && !nav_body.is_empty() {
let result = if !send_command_response {
page.navigate_from_document(&url_owned, &nav_method, &nav_body).await
} else if nav_method == "POST" && !nav_body.is_empty() {
page.navigate_with_wait_post(&url_owned, wait_until, &nav_method, &nav_body).await
} else {
page.navigate_with_wait(&url_owned, wait_until).await
@@ -1728,12 +1863,11 @@ async fn process_with_interception(
}, if has_irx => {
emit_intercepted_request(
intercepted,
&frame_id,
session_for_events.clone(),
ctx,
Some((&page_id, &frame_id, session_for_events.clone())),
reply_tx,
intercepted_paused,
);
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
}
Some(msg) = rx.recv() => {
tracing::info!("INTERCEPTION select: received CDP message during navigation");
@@ -1807,7 +1941,7 @@ async fn process_with_interception(
let response = match navigate_result {
Ok(()) => crate::types::CdpResponse::success(
req.id,
json!({"frameId": frame_id, "loaderId": loader_id}),
if is_reload { json!({}) } else { json!({"frameId": frame_id, "loaderId": loader_id}) },
req.session_id.clone(),
),
Err(e) => crate::types::CdpResponse::error(req.id, -32000, e, req.session_id.clone()),
@@ -1837,11 +1971,7 @@ async fn process_with_interception(
);
#[cfg(feature = "render")]
if navigation_succeeded {
if let Err(error) = crate::domains::page::queue_screencast_frame(
ctx, &session_for_events, false,
) {
tracing::warn!("could not produce post-navigation screencast frame: {error}");
}
crate::domains::page::schedule_screencast_frame(ctx, &session_for_events);
}
for event in ctx.pending_events.drain(..) {
if let Ok(json) = serde_json::to_string(&event) {
@@ -1885,21 +2015,6 @@ async fn process_cdp_message(
let _ = reply_tx.send(json);
}
if let Some((nav_url, nav_method, nav_body)) = check_pending_navigation(ctx, &req.session_id) {
tracing::info!("JS-triggered nav: {} {} (body: {} bytes)", nav_method, nav_url, nav_body.len());
let nav_req = CdpRequest {
id: 0,
method: "Page.navigate".to_string(),
params: json!({"url": nav_url, "__method": nav_method, "__body": nav_body}),
session_id: req.session_id.clone(),
};
let _ = dispatch::dispatch(&nav_req, ctx).await;
for event in ctx.pending_events.drain(..) {
if let Ok(json) = serde_json::to_string(&event) {
let _ = reply_tx.send(json);
}
}
}
}
pub(crate) fn decode_base64(input: &str) -> String {
@@ -1967,14 +2082,6 @@ fn fast_path_response(text: &str) -> Option<String> {
}
}
fn check_pending_navigation(ctx: &CdpContext, session_id: &Option<String>) -> Option<(String, String, String)> {
let page_id = session_id
.as_ref()
.and_then(|sid| ctx.sessions.get(sid))?;
let page = ctx.pages.iter().find(|p| &p.id == page_id)?;
page.take_pending_navigation()
}
async fn handle_connection_ws(
stream: TcpStream,
msg_tx: mpsc::UnboundedSender<ServerMessage>,
@@ -2058,10 +2165,11 @@ async fn handle_connection_ws(
mod tests {
use super::{
bearer_authorized, control_refusal, handle_fetch_resolution, is_navigate_method,
merge_cookie_delta, parse_cdp_headers, websocket_authority, ControlRefusal,
merge_cookie_delta, parse_cdp_headers, pump_live_page_event_loop,
websocket_authority, ControlRefusal,
};
#[cfg(feature = "render")]
use super::{pump_and_forward_screencast_frames, pump_live_page_event_loop};
use super::pump_and_forward_screencast_frames;
use obscura_net::{CookieInfo, CookieJar};
use serde_json::json;
use std::collections::HashMap;
@@ -2316,7 +2424,7 @@ mod tests {
"method": "Runtime.evaluate",
"sessionId": session_id,
"params": {
"expression": "(() => { setTimeout(() => globalThis.__autonomousDone = 'yes', 40); return 'armed'; })()",
"expression": "(() => { setTimeout(() => { globalThis.__autonomousDone = 'yes'; history.replaceState({}, '', '#silent'); }, 40); return 'armed'; })()",
"returnByValue": true,
},
}));
@@ -2339,7 +2447,20 @@ mod tests {
// This is deliberately host/client time. No CDP message is sent
// while the timeout becomes due; Chrome's renderer still runs,
// and Obscura's connection-owned page pump must do the same.
tokio::time::sleep(std::time::Duration::from_millis(120)).await;
loop {
let value: serde_json::Value = serde_json::from_str(
&tokio::time::timeout(
std::time::Duration::from_secs(2), reply_rx.recv(),
).await.expect("silent history navigation event timeout")
.expect("navigation event channel"),
).unwrap();
if value["method"] == "Page.navigatedWithinDocument" {
assert_eq!(value["sessionId"], session_id);
assert_eq!(value["params"]["url"], "about:blank#silent");
assert_eq!(value["params"]["navigationType"], "historyApi");
break;
}
}
send(json!({
"id": 3,
@@ -2524,16 +2645,198 @@ mod tests {
assert!(!cookies.iter().any(|c| c.name == "removed"));
}
// Issue #363: only an exact Page.navigate may take the spawn-and-defer
#[tokio::test(flavor = "current_thread")]
async fn intercepted_request_bursts_and_script_navigation_keep_servicing_replies() {
use std::time::Duration;
use serde_json::Value;
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
tokio::task::LocalSet::new().run_until(async {
let (server_tx, server_rx) = tokio::sync::mpsc::unbounded_channel();
let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel();
let context = crate::dispatch::CdpContext::new().default_context;
let processor = tokio::task::spawn_local(super::cdp_processor(
server_rx, context, std::sync::Arc::new(tokio::sync::Notify::new()),
));
server_tx.send(super::ServerMessage::NewConnection {
reply_tx: reply_tx.clone(),
}).unwrap();
reply_rx.recv().await.unwrap();
let send = |value: Value| {
server_tx.send(super::ServerMessage::Cdp(super::CdpMessage {
text: value.to_string(), reply_tx: reply_tx.clone(),
})).unwrap();
};
send(json!({"id": 1, "method": "Target.createTarget", "params": {"url": "about:blank"}}));
let mut session = None;
loop {
let value: Value = serde_json::from_str(&reply_rx.recv().await.unwrap()).unwrap();
if let Some(id) = value["params"]["sessionId"].as_str() {
session = Some(id.to_string());
}
if value["id"] == 1 { break; }
}
let session = session.expect("attached session");
send(json!({"id": 2, "method": "Fetch.enable", "sessionId": session,
"params": {"patterns": [{"urlPattern": "http://127.0.0.1:9/*"}]}}));
loop {
let value: Value = serde_json::from_str(&reply_rx.recv().await.unwrap()).unwrap();
if value["id"] == 2 { break; }
}
// No network dependency: each fetch is fulfilled by this client.
// Keep servicing replies even if navigation finishes before fetches.
let url = "data:text/html,<button style='position:fixed;left:0;top:0;width:100px;height:40px' onclick='location.reload()'>Reload</button><script type='module'>window.count=(await Promise.all(Array.from({length:64},(_,i)=>fetch('http://127.0.0.1:9/'+i).then(r=>r.text())))).filter(s=>s==='ok').length</script>";
let mut resolution_id = 100;
let mut commands = vec![
(3, "Page.navigate", json!({"url": url, "waitUntil": "networkidle0"})),
(4, "Page.reload", json!({"waitUntil": "networkidle0"})),
(5, "Runtime.evaluate", json!({"expression": "location.reload()"})),
(6, "Runtime.callFunctionOn", json!({"functionDeclaration": "function(){ location.reload(); }"})),
];
if cfg!(feature = "render") {
commands.push((7, "Input.dispatchMouseEvent", json!({
"type": "mouseReleased", "button": "left", "x": 10, "y": 10,
})));
}
for (id, method, params) in commands {
if id == 7 {
send(json!({"id": 50, "method": "Input.dispatchMouseEvent", "sessionId": session,
"params": {"type": "mousePressed", "button": "left", "x": 10, "y": 10}}));
loop {
let value: Value = serde_json::from_str(&reply_rx.recv().await.unwrap()).unwrap();
if value["id"] == 50 { break; }
}
}
send(json!({"id": id, "method": method, "sessionId": session, "params": params}));
let mut pauses = 0;
let mut acknowledged = false;
let mut navigated = false;
tokio::time::timeout(Duration::from_secs(3), async {
loop {
let value: Value = serde_json::from_str(&reply_rx.recv().await.unwrap()).unwrap();
if value["method"] == "Fetch.requestPaused"
&& value["params"]["request"]["url"].as_str()
.is_some_and(|url| url.starts_with("http://127.0.0.1:9/"))
{
pauses += 1;
resolution_id += 1;
send(json!({"id": resolution_id, "method": "Fetch.fulfillRequest",
"sessionId": session, "params": {"requestId": value["params"]["requestId"],
"responseCode": 200, "body": "b2s=",
"responseHeaders": [{"name": "access-control-allow-origin", "value": "*"}]}}));
}
if value["id"] == id {
assert!(value.get("error").is_none(), "{method}: {value}");
acknowledged = true;
}
if value["method"] == "Page.frameNavigated" {
navigated = true;
}
if acknowledged && navigated && pauses == 64 { break; }
}
}).await.unwrap_or_else(|_| panic!("{method} stalled after {pauses} intercepted requests"));
assert_eq!(pauses, 64, "{method} must service every paused request");
}
drop(server_tx);
tokio::time::timeout(Duration::from_secs(2), processor).await.unwrap().unwrap();
}).await;
}
#[tokio::test(flavor = "current_thread")]
async fn intercepted_requests_keep_their_page_and_unique_identity() {
use serde_json::Value;
use std::time::Duration;
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
tokio::task::LocalSet::new().run_until(async {
let (server_tx, server_rx) = tokio::sync::mpsc::unbounded_channel();
let (reply_tx, mut reply_rx) = tokio::sync::mpsc::unbounded_channel();
let context = crate::dispatch::CdpContext::new().default_context;
let processor = tokio::task::spawn_local(super::cdp_processor(
server_rx, context, std::sync::Arc::new(tokio::sync::Notify::new()),
));
server_tx.send(super::ServerMessage::NewConnection { reply_tx: reply_tx.clone() }).unwrap();
reply_rx.recv().await.unwrap();
let send = |value: Value| {
server_tx.send(super::ServerMessage::Cdp(super::CdpMessage {
text: value.to_string(), reply_tx: reply_tx.clone(),
})).unwrap();
};
let mut sessions = Vec::new();
for index in 0..2 {
send(json!({"id": 1, "method": "Target.createTarget", "params": {"url": "about:blank"}}));
loop {
let value: Value = serde_json::from_str(&reply_rx.recv().await.unwrap()).unwrap();
if let Some(session) = value["params"]["sessionId"].as_str() {
sessions.push(session.to_string());
}
if value["id"] == 1 { break; }
}
for (method, params) in [
("Page.navigate", json!({"url": "data:text/html,<title>owner</title>"})),
("Fetch.enable", json!({"patterns": [{"urlPattern": "*"}]})),
] {
send(json!({"id": 2, "method": method, "params": params, "sessionId": sessions[index]}));
loop {
let value: Value = serde_json::from_str(&reply_rx.recv().await.unwrap()).unwrap();
if value["id"] == 2 {
assert!(value.get("error").is_none(), "{value}");
break;
}
}
}
}
for (index, session) in sessions.iter().enumerate() {
send(json!({"id": 10 + index, "method": "Runtime.evaluate", "sessionId": session,
"params": {"expression": format!("void fetch('http://127.0.0.1:9/{index}').then(r=>r.text()).then(s=>window.answer=s)")}}));
}
let mut requests = Vec::new();
tokio::time::timeout(Duration::from_secs(3), async {
while requests.len() < 2 {
let value: Value = serde_json::from_str(&reply_rx.recv().await.unwrap()).unwrap();
if value["method"] == "Fetch.requestPaused" {
requests.push(value);
}
}
}).await.expect("both pages must pause independently");
assert_ne!(requests[0]["params"]["requestId"], requests[1]["params"]["requestId"],
"shared resolver map requires unique request IDs across pages");
for (index, request) in requests.iter().enumerate() {
let owner = request["params"]["request"]["url"].as_str().unwrap().rsplit('/').next().unwrap().parse::<usize>().unwrap();
assert_eq!(request["sessionId"], sessions[owner], "request belongs to its initiating page");
send(json!({"id": 20 + index, "method": "Fetch.fulfillRequest", "sessionId": sessions[owner],
"params": {"requestId": request["params"]["requestId"], "responseCode": 200,
"body": "b2s=", "responseHeaders": [{"name": "access-control-allow-origin", "value": "*"}]}}));
}
for session in &sessions {
send(json!({"id": 30, "method": "Runtime.evaluate", "sessionId": session,
"params": {"expression": "new Promise(resolve => {let t=setInterval(()=>{if(window.answer){clearInterval(t);resolve(window.answer)}},1)})", "awaitPromise": true, "returnByValue": true}}));
tokio::time::timeout(Duration::from_secs(3), async {
loop {
let value: Value = serde_json::from_str(&reply_rx.recv().await.unwrap()).unwrap();
if value["id"] == 30 {
assert_eq!(value["result"]["result"]["value"], "ok");
break;
}
}
}).await.expect("both resolvers complete");
}
drop(server_tx);
tokio::time::timeout(Duration::from_secs(2), processor).await.unwrap().unwrap();
}).await;
}
// Issue #363: only exact navigate/reload methods take the spawn-and-defer
// navigation path. A substring match also caught Page.navigateToHistoryEntry
// (goBack / goForward), which has no `url` param, so it was misrouted into
// the raw-navigate path and failed with "Invalid URL" instead of reaching
// its real handler.
#[test]
fn only_exact_page_navigate_routes_as_navigation() {
fn only_exact_page_navigate_and_reload_route_as_navigation() {
assert!(is_navigate_method(
r#"{"id":1,"method":"Page.navigate","params":{"url":"https://example.com"}}"#
));
assert!(is_navigate_method(
r#"{"id":3,"method":"Page.reload","params":{}}"#
));
assert!(!is_navigate_method(
r#"{"id":2,"method":"Page.navigateToHistoryEntry","params":{"entryId":0}}"#
));
@@ -2595,6 +2898,74 @@ mod tests {
assert!(reply_rx.try_recv().is_err(), "must not emit a duplicate response");
}
#[tokio::test(flavor = "current_thread")]
async fn autonomous_pages_do_not_wait_for_another_pages_distant_timer() {
let mut ctx = crate::dispatch::CdpContext::new();
let mut idle_pages = Default::default();
let mut pages = Vec::new();
for _ in 0..3 {
let id = ctx.create_page();
let session = Some(format!("{id}-session"));
ctx.sessions.insert(session.clone().unwrap(), id.clone());
crate::domains::page::handle(
"navigate", &json!({"url": "data:text/html,<body>timer</body>"}),
&mut ctx, &session,
).await.unwrap();
pages.push(id);
}
ctx.get_page_mut(&pages[0]).unwrap().evaluate(
"globalThis.slow = setTimeout(() => {}, 60000)",
);
for id in &pages[1..] {
assert_eq!(ctx.get_page_mut(id).unwrap().evaluate(
"(() => { globalThis.done = false; setTimeout(() => { globalThis.done = true; }, 20); return globalThis.done; })()",
), json!(false));
}
let started = std::time::Instant::now();
let completed = tokio::time::timeout(std::time::Duration::from_secs(1), async {
loop {
pump_live_page_event_loop(&mut ctx, &mut idle_pages).await.unwrap();
if pages[1..].iter().all(|id| {
ctx.get_page_mut(id).unwrap().evaluate("globalThis.done") == json!(true)
}) {
break;
}
}
}).await;
assert!(completed.is_ok(), "a parked page must not prevent other pages' timers from running");
assert!(started.elapsed() < std::time::Duration::from_millis(500),
"ready timers must not need the outer timeout's wake: {:?}", started.elapsed());
ctx.get_page_mut(&pages[2]).unwrap().evaluate(
"setTimeout(() => { globalThis.finalTurn = true; }, 0)",
);
idle_pages.clear(); // A new command rearms previously idle pages.
// This turn can do work and become idle in the same poll. Its result
// must still be published while the first page remains parked.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let started = std::time::Instant::now();
tokio::time::timeout(std::time::Duration::from_secs(1), async {
loop {
pump_live_page_event_loop(&mut ctx, &mut idle_pages).await.unwrap();
if ctx.get_page_mut(&pages[2]).unwrap().evaluate("globalThis.finalTurn") == json!(true) {
break;
}
}
}).await.expect("the last completed task must not remain buffered");
assert!(started.elapsed() < std::time::Duration::from_millis(500),
"a task becoming idle must still publish progress: {:?}", started.elapsed());
let parked = tokio::time::timeout(std::time::Duration::from_millis(50), async {
for _ in 0..8 {
pump_live_page_event_loop(&mut ctx, &mut idle_pages).await.unwrap();
}
}).await;
assert!(parked.is_err(), "idle peers must not repeatedly wake a parked connection");
ctx.get_page_mut(&pages[0]).unwrap().evaluate("clearTimeout(globalThis.slow)");
idle_pages.clear();
tokio::time::timeout(std::time::Duration::from_secs(1), async {
while !pump_live_page_event_loop(&mut ctx, &mut idle_pages).await.unwrap() {}
}).await.expect("all idle pages must disarm the pump instead of spinning");
}
#[cfg(feature = "render")]
#[tokio::test(flavor = "current_thread")]
async fn autonomous_screencast_pumps_timers_and_retains_backpressured_damage() {
@@ -2640,7 +3011,7 @@ mod tests {
.evaluate(
"setTimeout(() => document.body.setAttribute('style', 'margin:0;width:96px;height:64px;background:green'), 0)",
);
pump_live_page_event_loop(&mut ctx).await.unwrap();
pump_live_page_event_loop(&mut ctx, &mut Default::default()).await.unwrap();
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"),
@@ -2659,7 +3030,7 @@ mod tests {
.evaluate(
"setTimeout(() => document.body.setAttribute('style', 'margin:0;width:96px;height:64px;background:blue'), 0)",
);
pump_live_page_event_loop(&mut ctx).await.unwrap();
pump_live_page_event_loop(&mut ctx, &mut Default::default()).await.unwrap();
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);
@@ -2741,7 +3112,7 @@ mod tests {
"requestAnimationFrame(() => document.body.setAttribute('style','margin:0;width:96px;height:64px;background:lime'))",
);
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
pump_live_page_event_loop(&mut ctx).await.unwrap();
pump_live_page_event_loop(&mut ctx, &mut Default::default()).await.unwrap();
pump_and_forward_screencast_frames(&mut ctx, None).await;
let raf_frame = ctx
.pending_events
@@ -192,6 +192,52 @@ async fn cdp_click_submit_button_is_vetoed_by_prevent_default_listener() {
);
}
#[tokio::test(flavor = "current_thread")]
async fn cdp_click_on_submit_button_content_activates_the_button() {
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
let url = serve_form().await;
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = "session-nested-submit";
ctx.sessions.insert(session_id.to_string(), page_id.clone());
navigate(&mut ctx, &url, session_id).await;
cdp(
&mut ctx,
2,
"Runtime.evaluate",
json!({"expression": "globalThis.__submits=0;var b=document.getElementById('b');b.innerHTML='<span id=inner>Go</span>';b.form.addEventListener('submit',()=>globalThis.__submits++);document.elementFromPoint=()=>document.getElementById('inner')"}),
session_id,
)
.await;
cdp(
&mut ctx,
3,
"Input.dispatchMouseEvent",
json!({"type": "mousePressed", "x": 0.0, "y": 0.0, "button": "left", "clickCount": 1}),
session_id,
)
.await;
cdp(
&mut ctx,
4,
"Input.dispatchMouseEvent",
json!({"type": "mouseReleased", "x": 0.0, "y": 0.0, "button": "left", "clickCount": 1}),
session_id,
)
.await;
let result = cdp(
&mut ctx,
5,
"Runtime.evaluate",
json!({"expression": "globalThis.__submits", "returnByValue": true}),
session_id,
)
.await;
assert_eq!(result["result"]["value"], json!(1.0), "button descendants share its activation behavior");
}
// requestSubmit(submitter) must validate its argument before doing anything
// else (issue #424): a TypeError if the submitter is not a submit button, and a
// NotFoundError DOMException if it is not owned by the form. obscura accepted
@@ -104,6 +104,117 @@ async fn dispatch_key_event_escapes_backslash_in_key_and_code() {
);
}
#[tokio::test(flavor = "current_thread")]
async fn dispatch_key_event_exposes_legacy_virtual_key_codes() {
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
let url = serve_page().await;
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = "session-1";
ctx.sessions.insert(session_id.to_string(), page_id);
cdp(&mut ctx, 1, "Page.navigate", json!({"url": url, "waitUntil": "load"}), session_id).await;
cdp(
&mut ctx,
2,
"Runtime.evaluate",
json!({
"expression": "window.__codes = []; for (const type of ['keydown', 'keypress', 'keyup']) document.body.addEventListener(type, e => __codes.push([type, e.keyCode, e.which, e.charCode, e.shiftKey]))",
"returnByValue": true
}),
session_id,
).await;
for (id, event_type) in [(3, "keyDown"), (4, "keyUp")] {
cdp(
&mut ctx,
id,
"Input.dispatchKeyEvent",
json!({"type": event_type, "key": "Enter", "code": "Enter", "text": "\r", "windowsVirtualKeyCode": 13, "modifiers": 8}),
session_id,
).await;
}
let value = cdp(
&mut ctx,
5,
"Runtime.evaluate",
json!({"expression": "JSON.stringify(__codes)", "returnByValue": true}),
session_id,
).await;
assert_eq!(
value["result"]["value"],
r#"[["keydown",13,13,0,true],["keypress",13,13,13,true],["keyup",13,13,0,true]]"#
);
}
#[tokio::test(flavor = "current_thread")]
async fn dispatch_key_event_preserves_modifier_combinations() {
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
let url = serve_page().await;
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = "session-1";
ctx.sessions.insert(session_id.to_string(), page_id);
cdp(&mut ctx, 1, "Page.navigate", json!({"url": url, "waitUntil": "load"}), session_id).await;
cdp(&mut ctx, 2, "Runtime.evaluate", json!({
"expression": "window.__modifiers = []; for (const type of ['keydown','keyup']) document.addEventListener(type, e => __modifiers.push([type, e.altKey, e.ctrlKey, e.metaKey, e.shiftKey, ...['Alt','Control','Meta','Shift'].map(k => e.getModifierState(k))]))"
}), session_id).await;
let mut expected = Vec::new();
let mut id = 3;
for modifiers in 0..16 {
for (event_type, dom_type) in [("rawKeyDown", "keydown"), ("keyUp", "keyup")] {
cdp(&mut ctx, id, "Input.dispatchKeyEvent", json!({
"type": event_type, "key": "o", "code": "KeyO",
"windowsVirtualKeyCode": 79, "modifiers": modifiers
}), session_id).await;
id += 1;
let flags = [1, 2, 4, 8].map(|mask| modifiers & mask != 0);
expected.push(json!([dom_type, flags[0], flags[1], flags[2], flags[3],
flags[0], flags[1], flags[2], flags[3]]));
}
}
let result = cdp(&mut ctx, id, "Runtime.evaluate", json!({
"expression": "JSON.stringify(__modifiers)", "returnByValue": true
}), session_id).await;
let actual: Value = serde_json::from_str(result["result"]["value"].as_str().unwrap()).unwrap();
assert_eq!(actual, json!(expected));
}
#[tokio::test(flavor = "current_thread")]
async fn delete_key_removes_the_selected_range() {
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
let url = serve_page().await;
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = "session-1";
ctx.sessions.insert(session_id.to_string(), page_id);
cdp(&mut ctx, 1, "Page.navigate", json!({"url": url, "waitUntil": "load"}), session_id).await;
cdp(
&mut ctx,
2,
"Runtime.evaluate",
json!({
"expression": "i.focus(); i.value = 'abcd'; i.setSelectionRange(1, 3); window.__inputs = 0; i.addEventListener('input', () => __inputs++)",
"returnByValue": true
}),
session_id,
).await;
cdp(
&mut ctx,
3,
"Input.dispatchKeyEvent",
json!({"type": "keyDown", "key": "Delete", "code": "Delete", "windowsVirtualKeyCode": 46}),
session_id,
).await;
let value = cdp(
&mut ctx,
4,
"Runtime.evaluate",
json!({"expression": "JSON.stringify({value: i.value, start: i.selectionStart, inputs: __inputs})", "returnByValue": true}),
session_id,
).await;
assert_eq!(value["result"]["value"], r#"{"value":"ad","start":1,"inputs":1}"#);
}
// The same hazard one layer over: on the inserted *text* rather than on the key
// name. `insert_text_js` interpolates the text into the same kind of
// single-quoted literal. #433 escaped the backslash and the quote and stopped
@@ -13,7 +13,7 @@ async fn serve_fixture() -> String {
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>
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;
@@ -37,6 +37,9 @@ async fn serve_fixture() -> String {
width: 120px; height: 60px; }
#deep-target { width: 120px; height: 60px; }
#deep-child { display: block; width: 100%; height: 100%; }
#hover-host { position: absolute; left: 400px; top: 100px; width: 120px; height: 60px; }
#hover-host button { opacity: 0; }
#hover-host:hover button { opacity: 1; }
</style></head><body>
<div id="page"></div>
<div id="box"><div id="inner"></div></div>
@@ -50,7 +53,9 @@ async fn serve_fixture() -> String {
<input id="radio-a" type="radio" name="choice" checked>
<input id="radio-b" type="radio" name="choice">
</form>
</body></html>"#;
<a id="hash-link" href="#active">active</a>
<div id="hover-host"><button id="hover-child">delete</button></div>
</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()
@@ -382,6 +387,73 @@ async fn offset_geometry_is_relative_to_the_positioned_ancestor() {
assert_eq!(result["clientTop"], 3);
}
#[tokio::test(flavor = "current_thread")]
async fn hit_testing_prefers_a_child_created_before_its_parent() {
let (mut ctx, sid) = setup().await;
let result = evaluate(
&mut ctx,
2,
r#"(() => {
const child = document.createElement('input');
child.id = 'dynamic-child';
child.setAttribute('style', 'width: 40px; height: 40px');
const parent = document.createElement('section');
parent.id = 'dynamic-parent';
parent.setAttribute('style', 'position: absolute; left: 400px; top: 300px; width: 100px; height: 100px');
parent.appendChild(child);
document.body.appendChild(parent);
const rect = child.getBoundingClientRect();
return document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2).id;
})()"#,
&sid,
).await;
assert_eq!(result["result"]["value"], "dynamic-child");
}
#[tokio::test(flavor = "current_thread")]
async fn hit_testing_prefers_positioned_sibling_over_later_static_sibling() {
let (mut ctx, sid) = setup().await;
let result = evaluate(
&mut ctx,
2,
r#"(() => {
const parent = document.createElement('div');
parent.setAttribute('style', 'position: absolute; left: 400px; top: 300px; width: 100px; height: 100px');
const positioned = document.createElement('input');
positioned.id = 'positioned';
positioned.setAttribute('style', 'position: absolute; width: 40px; height: 40px');
const statik = document.createElement('label');
statik.id = 'static';
statik.setAttribute('style', 'display: block; width: 100px; height: 100px');
parent.append(positioned, statik);
document.body.appendChild(parent);
const rect = positioned.getBoundingClientRect();
return document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2).id;
})()"#,
&sid,
).await;
assert_eq!(result["result"]["value"], "positioned");
}
#[tokio::test(flavor = "current_thread")]
async fn mouse_move_applies_hover_styles_to_the_target_ancestry() {
let (mut ctx, sid) = setup().await;
cdp(
&mut ctx,
2,
"Input.dispatchMouseEvent",
json!({"type": "mouseMoved", "x": 410, "y": 110, "button": "none", "buttons": 0}),
&sid,
).await;
let result = evaluate(
&mut ctx,
3,
"getComputedStyle(document.getElementById('hover-child')).opacity",
&sid,
).await;
assert_eq!(result["result"]["value"], "1");
}
#[tokio::test(flavor = "current_thread")]
async fn press_release_orders_events_and_defers_click_activation() {
let (mut ctx, sid) = setup().await;
@@ -508,6 +580,139 @@ async fn press_release_orders_events_and_defers_click_activation() {
assert_eq!(released["log"][10]["trusted"], true);
}
#[tokio::test(flavor = "current_thread")]
async fn checkbox_activation_bypasses_framework_checked_setter() {
let (mut ctx, sid) = setup().await;
evaluate(
&mut ctx,
2,
r#"(() => {
const target = document.getElementById('check');
const native = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'checked');
let tracked = target.checked;
Object.defineProperty(target, 'checked', {
get() { return native.get.call(this); },
set(value) { tracked = !!value; native.set.call(this, value); }
});
document.elementFromPoint = () => target;
globalThis.frameworkSawChange = false;
target.addEventListener('change', () => { frameworkSawChange = tracked !== target.checked; });
})()"#,
&sid,
).await;
for (id, event_type) in [(3, "mousePressed"), (4, "mouseReleased")] {
cdp(
&mut ctx,
id,
"Input.dispatchMouseEvent",
json!({"type": event_type, "x": 10, "y": 10, "button": "left"}),
&sid,
).await;
}
let result = evaluate(&mut ctx, 5, "frameworkSawChange", &sid).await;
assert_eq!(result["result"]["value"], true);
}
#[tokio::test(flavor = "current_thread")]
async fn second_click_dispatches_dblclick() {
let (mut ctx, sid) = setup().await;
evaluate(
&mut ctx,
2,
r#"(() => {
const target = document.getElementById('page');
document.elementFromPoint = () => target;
globalThis.doubleLog = [];
for (const type of ['mousedown', 'mouseup', 'click', 'dblclick']) {
target.addEventListener(type, event => doubleLog.push([type, event.detail, event.isTrusted]));
}
})()"#,
&sid,
).await;
for (id, event_type) in [(3, "mousePressed"), (4, "mouseReleased")] {
cdp(
&mut ctx,
id,
"Input.dispatchMouseEvent",
json!({"type": event_type, "x": 10, "y": 10, "button": "left", "clickCount": 2}),
&sid,
).await;
}
let result = evaluate(&mut ctx, 5, "JSON.stringify(doubleLog)", &sid).await;
assert_eq!(
result["result"]["value"],
r#"[["mousedown",2,true],["mouseup",2,true],["click",2,true],["dblclick",2,true]]"#
);
}
#[tokio::test(flavor = "current_thread")]
async fn pressing_non_focusable_content_blurs_the_active_input() {
let (mut ctx, sid) = setup().await;
evaluate(
&mut ctx,
2,
r#"(() => {
const input = document.getElementById('check');
const page = document.getElementById('page');
globalThis.focusLog = [];
input.addEventListener('blur', event => focusLog.push([event.type, event.relatedTarget?.id || null]));
input.addEventListener('focusout', event => focusLog.push([event.type, event.relatedTarget?.id || null]));
input.focus();
document.elementFromPoint = () => page;
})()"#,
&sid,
).await;
cdp(
&mut ctx,
3,
"Input.dispatchMouseEvent",
json!({"type": "mousePressed", "x": 10, "y": 10, "button": "left"}),
&sid,
).await;
let result = evaluate(
&mut ctx,
4,
"JSON.stringify({events: focusLog, active: document.activeElement.tagName})",
&sid,
).await;
assert_eq!(
result["result"]["value"],
r#"{"events":[["blur",null],["focusout",null]],"active":"BODY"}"#
);
}
#[tokio::test(flavor = "current_thread")]
async fn clicking_a_hash_link_performs_same_document_navigation() {
let (mut ctx, sid) = setup().await;
evaluate(
&mut ctx,
2,
r#"(() => {
const link = document.getElementById('hash-link');
document.elementFromPoint = () => link;
globalThis.hashChanges = 0;
onhashchange = () => hashChanges++;
})()"#,
&sid,
).await;
for (id, event_type) in [(3, "mousePressed"), (4, "mouseReleased")] {
cdp(
&mut ctx,
id,
"Input.dispatchMouseEvent",
json!({"type": event_type, "x": 10, "y": 10, "button": "left"}),
&sid,
).await;
}
let result = evaluate(
&mut ctx,
5,
"JSON.stringify({hash: location.hash, changes: hashChanges})",
&sid,
).await;
assert_eq!(result["result"]["value"], r##"{"hash":"#active","changes":1}"##);
}
#[tokio::test(flavor = "current_thread")]
async fn radio_release_selects_only_the_target_in_its_group() {
let (mut ctx, sid) = setup().await;
+59
View File
@@ -0,0 +1,59 @@
// One link of the browser engine. Nextest still starts a process per test.
macro_rules! integration_tests {
($($module:ident),* $(,)?) => {
$(mod $module;)*
#[test]
fn every_integration_file_is_registered() {
use std::collections::BTreeSet;
let registered: BTreeSet<String> = [$(stringify!($module).to_owned(),)*]
.into_iter().collect();
let files: BTreeSet<String> = std::fs::read_dir(
concat!(env!("CARGO_MANIFEST_DIR"), "/tests"),
).unwrap().map(|entry| entry.unwrap().path())
.filter(|path| path.extension().is_some_and(|ext| ext == "rs"))
.map(|path| path.file_stem().unwrap().to_str().unwrap().to_owned())
.filter(|name| name != "integration")
.collect();
assert_eq!(registered, files, "register new test files in integration_tests!");
}
};
}
integration_tests!(
accept_thread_survives_silent_connections,
accessibility_names,
backspace_surrogate,
binding_called_session,
cdp_click_submit_parity,
child_frame_tree,
concurrent_connections_heavy_page,
concurrent_navigations,
concurrent_navigations_with_fetch,
concurrent_page_isolation,
control_plane_unblocked,
document_write_lifecycle,
dynamic_script_onload_fires,
dynamic_stylesheet_onload_fires,
execution_context_ownership,
execution_context_pruned_on_navigation,
file_navigation_gate,
form_submit_method_bypasses_listener,
iframe_event_dispatch,
input_key_event_escaping,
input_mouse_event_parity,
input_mouse_label_activation,
js_fetch_emits_network_events,
max_connections_cap,
nodefilter_constants,
page_frame_contract,
runtime_by_value_undefined,
runtime_console_events,
runtime_get_properties_objectid_escaping,
runtime_primitives,
scroll_event_on_assignment,
structured_clone_crypto_parity,
textarea_enter_selection,
treewalker_document_order,
window_conformance_parity,
);
@@ -216,14 +216,27 @@ async fn every_page_frame_path_uses_the_current_cdp_contract() {
Some(&session_id),
)
.await;
let route_frame = &ctx.pending_events[route_event_start..]
let route_event = &ctx.pending_events[route_event_start..]
.iter()
.find(|event| {
event.method == "Page.frameNavigated" && event.params["frame"]["id"] == page_id
})
.find(|event| event.method == "Page.navigatedWithinDocument")
.expect("same-document navigation event was not emitted")
.params["frame"];
assert_frame_contract(route_frame);
assert_eq!(route_frame["loaderId"], loader_id);
assert!(route_frame["url"].as_str().unwrap().ends_with("/next"));
.params;
assert_eq!(route_event["frameId"], page_id);
assert_eq!(route_event["navigationType"], "historyApi");
assert!(route_event["url"].as_str().unwrap().ends_with("/next"));
assert!(!ctx.pending_events[route_event_start..].iter().any(|event| {
matches!(
event.method.as_str(),
"Runtime.executionContextsCleared" | "Page.loadEventFired"
)
}));
let routed_tree = cdp(
&mut ctx,
10,
"Page.getFrameTree",
json!({}),
Some(&session_id),
)
.await;
assert_eq!(routed_tree["frameTree"]["frame"]["loaderId"], loader_id);
}
@@ -0,0 +1,59 @@
use obscura_cdp::dispatch::{dispatch, CdpContext};
use obscura_cdp::types::CdpRequest;
use serde_json::{json, Value};
#[tokio::test(flavor = "current_thread")]
async fn primitive_results_keep_their_types_without_retaining_handles() {
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = Some("primitive-results".to_string());
ctx.sessions.insert(session_id.clone().unwrap(), page_id);
for method in ["Runtime.evaluate", "Runtime.callFunctionOn"] {
for await_promise in [false, true] {
for return_by_value in [false, true] {
for (source, kind, value, unserializable) in [
("false", "boolean", Some(json!(false)), None),
("42", "number", Some(json!(42.0)), None),
("'<header>Header</header>'", "string", Some(json!("<header>Header</header>")), None),
("null", "object", Some(Value::Null), None),
("undefined", "undefined", None, None),
("-0", "number", None, Some("-0")),
("NaN", "number", None, Some("NaN")),
("Infinity", "number", None, Some("Infinity")),
("-Infinity", "number", None, Some("-Infinity")),
("9007199254740993n", "bigint", None, Some("9007199254740993n")),
] {
let expression = if await_promise {
format!("Promise.resolve({source})")
} else {
source.to_string()
};
let mut params = json!({"returnByValue": return_by_value, "awaitPromise": await_promise});
if method == "Runtime.evaluate" {
params["expression"] = json!(expression);
} else {
params["functionDeclaration"] = json!(format!("function() {{ return {expression}; }}"));
}
let response = dispatch(&CdpRequest {
id: 1, method: method.to_string(), params, session_id: session_id.clone(),
}, &mut ctx).await;
assert!(response.error.is_none(), "{method}: {:?}", response.error);
let reply = response.result.unwrap();
assert!(reply.get("exceptionDetails").is_none(), "{reply}");
let result = &reply["result"];
assert!(result.get("objectId").is_none(), "{method} {source}: {reply}");
assert_eq!(result["type"], kind, "{method} {source}: {reply}");
assert_eq!(result.get("value"), value.as_ref(), "{method} {source}: {reply}");
assert_eq!(result.get("unserializableValue").and_then(Value::as_str),
unserializable, "{method} {source}: {reply}");
if source == "null" {
assert_eq!(result["subtype"], "null", "{reply}");
}
}
}
}
}
let page = ctx.get_session_page_mut(&session_id).unwrap();
assert_eq!(page.evaluate("Object.keys(globalThis.__obscura_objects).length"), json!(0.0),
"primitive results have no client handle to release and must not stay rooted");
}
+13 -2
View File
@@ -87,6 +87,10 @@ enum Command {
#[arg(long, default_value_t = obscura_cdp::DEFAULT_MAX_CONNECTIONS)]
max_connections: usize,
/// Atomically publish the bound address after V8 initialization.
#[arg(long, value_name = "PATH")]
ready_file: Option<std::path::PathBuf>,
/// Allow CDP clients to navigate to file:// URLs. Off by
/// default so a CDP connection cannot read arbitrary local
/// files. Enable only when serving local HTML for testing
@@ -409,6 +413,7 @@ async fn run_cli() -> anyhow::Result<()> {
user_agent,
workers,
max_connections,
ready_file,
allow_file_access,
storage_dir,
font_dirs,
@@ -423,7 +428,12 @@ async fn run_cli() -> anyhow::Result<()> {
.filter(|s| !s.is_empty())
});
configure_font_directories(&font_dirs)?;
print_banner(port);
if ready_file.is_some() && workers != 1 {
anyhow::bail!("--ready-file requires --workers 1");
}
if port != 0 {
print_banner(port);
}
if let Some(ref dir) = storage_dir {
tracing::info!("Storage dir: {}", dir.display());
}
@@ -458,7 +468,7 @@ async fn run_cli() -> anyhow::Result<()> {
)
.await?;
} else {
obscura_cdp::start_with_serve_options_and_limit(
obscura_cdp::start_with_serve_options_limit_and_ready_file(
port,
&host,
proxy,
@@ -468,6 +478,7 @@ async fn run_cli() -> anyhow::Result<()> {
storage_dir,
args.allow_private_network,
max_connections,
ready_file.as_deref(),
)
.await?;
}
@@ -0,0 +1,152 @@
use std::io::{Read, Write};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde::Deserialize;
const WS_PATH: &str = "/devtools/browser";
#[derive(Deserialize)]
struct Ready {
pid: u32,
host: String,
port: u16,
websocket_path: String,
}
struct Server {
child: Child,
dir: PathBuf,
}
impl Server {
fn spawn() -> Self {
let dir = temp_dir();
std::fs::create_dir(&dir).unwrap();
let child = Command::new(env!("CARGO_BIN_EXE_obscura"))
.args([
"serve",
"--host",
"127.0.0.1",
"--port",
"0",
"--workers",
"1",
"--max-connections",
"1",
"--ready-file",
])
.arg(dir.join("ready.json"))
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
Self { child, dir }
}
fn wait_ready(&mut self) -> Ready {
let path = self.dir.join("ready.json");
let deadline = Instant::now() + Duration::from_secs(20);
loop {
if let Ok(bytes) = std::fs::read(&path) {
return serde_json::from_slice(&bytes).unwrap();
}
assert!(
self.child.try_wait().unwrap().is_none(),
"server exited before publishing readiness"
);
assert!(Instant::now() < deadline, "server did not become ready");
std::thread::sleep(Duration::from_millis(20));
}
}
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn temp_dir() -> PathBuf {
std::env::temp_dir().join(format!(
"obscura-ready-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
fn discovery(port: u16) -> String {
let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
write!(
stream,
"GET /json/version HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"
)
.unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).unwrap();
response
}
#[test]
fn ephemeral_servers_publish_distinct_usable_ports() {
let mut first = Server::spawn();
let mut second = Server::spawn();
let first_ready = first.wait_ready();
let second_ready = second.wait_ready();
for (server, ready) in [(&first, &first_ready), (&second, &second_ready)] {
assert_eq!(ready.pid, server.child.id());
assert_eq!(ready.host, "127.0.0.1");
assert_ne!(ready.port, 0);
assert_eq!(ready.websocket_path, WS_PATH);
assert_eq!(
std::fs::metadata(server.dir.join("ready.json"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
assert!(
discovery(ready.port).contains(&format!("ws://127.0.0.1:{}{}", ready.port, WS_PATH)),
"discovery must advertise the actual bound port"
);
}
assert_ne!(first_ready.port, second_ready.port);
}
#[test]
fn unwritable_ready_path_fails_startup() {
let missing_parent = temp_dir().join("missing").join("ready.json");
let status = Command::new(env!("CARGO_BIN_EXE_obscura"))
.args(["serve", "--port", "0", "--ready-file"])
.arg(&missing_parent)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.unwrap();
assert!(!status.success());
assert!(!Path::new(&missing_parent).exists());
}
#[test]
fn ready_file_rejects_multiple_workers() {
let ready_file = temp_dir().join("ready.json");
let output = Command::new(env!("CARGO_BIN_EXE_obscura"))
.args(["serve", "--port", "0", "--workers", "2", "--ready-file"])
.arg(&ready_file)
.output()
.unwrap();
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("--ready-file"));
}
+26 -19
View File
@@ -354,9 +354,10 @@ impl<'a> Element for DomElement<'a> {
fn prev_sibling_element(&self) -> Option<Self> {
let mut current = self.tree.with_node(self.node_id, |node| node.prev_sibling)?;
while let Some(sibling_id) = current {
let (element, previous) = self.tree.with_node(sibling_id,
|sibling| (sibling.is_element(), sibling.prev_sibling))?;
if element {
let (is_element, previous) = self.tree.with_node(sibling_id, |sibling| {
(sibling.is_element(), sibling.prev_sibling)
})?;
if is_element {
return Some(DomElement::new(self.tree, sibling_id));
}
current = previous;
@@ -367,9 +368,10 @@ impl<'a> Element for DomElement<'a> {
fn next_sibling_element(&self) -> Option<Self> {
let mut current = self.tree.with_node(self.node_id, |node| node.next_sibling)?;
while let Some(sibling_id) = current {
let (element, next) = self.tree.with_node(sibling_id,
|sibling| (sibling.is_element(), sibling.next_sibling))?;
if element {
let (is_element, next) = self.tree.with_node(sibling_id, |sibling| {
(sibling.is_element(), sibling.next_sibling)
})?;
if is_element {
return Some(DomElement::new(self.tree, sibling_id));
}
current = next;
@@ -380,9 +382,10 @@ impl<'a> Element for DomElement<'a> {
fn first_element_child(&self) -> Option<Self> {
let mut current = self.tree.with_node(self.node_id, |node| node.first_child)?;
while let Some(child_id) = current {
let (element, next) = self.tree.with_node(child_id,
|child| (child.is_element(), child.next_sibling))?;
if element {
let (is_element, next) = self.tree.with_node(child_id, |child| {
(child.is_element(), child.next_sibling)
})?;
if is_element {
return Some(DomElement::new(self.tree, child_id));
}
current = next;
@@ -502,10 +505,9 @@ impl<'a> Element for DomElement<'a> {
.form_control_checked(self.node_id)
.unwrap_or_else(|| self.has_boolean_attr("checked") || self.has_boolean_attr("selected"))
}
// Dynamic user-interaction pseudo-classes have no meaning against
// a static DOM snapshot with no live user input.
PseudoClass::Hover
| PseudoClass::Active
PseudoClass::Hover => self.tree.is_hovered(self.node_id),
// These dynamic states are not tracked yet.
PseudoClass::Active
| PseudoClass::Focus
| PseudoClass::FocusVisible
| PseudoClass::FocusWithin => false,
@@ -586,13 +588,18 @@ impl<'a> Element for DomElement<'a> {
.with_node(self.node_id, |node| {
let mut child = node.first_child;
while let Some(child_id) = child {
if let Some(child_node) = self.tree.get_node(child_id) {
match &child_node.data {
NodeData::Element { .. } => return false,
NodeData::Text { contents } if !contents.is_empty() => return false,
_ => {}
if let Some((nonempty, next)) = self.tree.with_node(child_id, |child| {
let nonempty = match &child.data {
NodeData::Element { .. } => true,
NodeData::Text { contents } => !contents.is_empty(),
_ => false,
};
(nonempty, child.next_sibling)
}) {
if nonempty {
return false;
}
child = child_node.next_sibling;
child = next;
} else {
break;
}
+199 -44
View File
@@ -258,6 +258,8 @@ pub struct DomTree {
pub struct ExternalStylesheet {
pub sources: Vec<Arc<str>>,
pub origin_clean: bool,
/// CSSOM edits are rule-owned, never mutations of the owner's DOM text.
pub cssom_rules: Option<Vec<Arc<str>>>,
}
/// Live control state is separate from content attributes and serialization.
@@ -275,6 +277,8 @@ pub(crate) struct DomTreeInner {
pub(crate) id_index: HashMap<String, NodeId>,
form_controls: HashMap<NodeId, FormControlState>,
external_stylesheets: HashMap<NodeId, ExternalStylesheet>,
external_stylesheet_generation: u64,
hovered: Option<NodeId>,
/// Shadow roots are arena nodes with their own child list. They are kept
/// outside the ordinary parent links so light-tree traversal never crosses
/// into a shadow tree by accident.
@@ -308,6 +312,8 @@ impl DomTree {
id_index: HashMap::new(),
form_controls: HashMap::new(),
external_stylesheets: HashMap::new(),
external_stylesheet_generation: 0,
hovered: None,
shadow_roots: HashMap::new(),
shadow_roots_by_host: HashMap::new(),
allow_declarative_shadow_roots: false,
@@ -340,8 +346,10 @@ impl DomTree {
ExternalStylesheet {
sources: vec![Arc::from(source)],
origin_clean,
cssom_rules: None,
},
);
inner.external_stylesheet_generation = inner.external_stylesheet_generation.wrapping_add(1);
true
}
@@ -366,18 +374,79 @@ impl DomTree {
.or_insert_with(|| ExternalStylesheet {
sources: Vec::new(),
origin_clean: true,
cssom_rules: None,
});
sheet.sources.push(Arc::from(source));
sheet.origin_clean &= origin_clean;
inner.external_stylesheet_generation = inner.external_stylesheet_generation.wrapping_add(1);
true
}
pub fn remove_external_stylesheet(&self, owner: NodeId) -> bool {
self.inner
.borrow_mut()
.external_stylesheets
.remove(&owner)
.is_some()
let mut inner = self.inner.borrow_mut();
let removed = inner.external_stylesheets.remove(&owner).is_some();
if removed {
inner.external_stylesheet_generation = inner.external_stylesheet_generation.wrapping_add(1);
}
removed
}
pub fn external_stylesheet_generation(&self) -> u64 {
self.inner.borrow().external_stylesheet_generation
}
pub fn has_cssom_stylesheet(&self, owner: NodeId) -> bool {
self.inner.borrow().external_stylesheets.get(&owner)
.is_some_and(|sheet| sheet.cssom_rules.is_some())
}
pub fn update_cssom_stylesheet(
&self, owner: NodeId, index: usize, delete_count: usize,
rules: Vec<String>, reset: bool,
) -> bool {
let mut inner = self.inner.borrow_mut();
let Some(element) = inner.nodes.get(owner.index()).and_then(Option::as_ref)
.and_then(Node::as_element) else { return false };
if element.local.as_ref() != "style" { return false; }
let sheet = inner.external_stylesheets.entry(owner).or_insert_with(|| ExternalStylesheet {
origin_clean: true, ..ExternalStylesheet::default()
});
// Only inline owners reach this path. A cross-origin @import is kept
// in sources, but does not make the owner's own CSS rules opaque.
if reset {
sheet.cssom_rules = Some(rules.into_iter().map(Arc::from).collect());
} else {
let Some(existing) = sheet.cssom_rules.as_mut() else { return false };
if index > existing.len() || delete_count > existing.len() - index { return false; }
existing.splice(index..index + delete_count, rules.into_iter().map(Arc::from));
}
true
}
pub fn clear_cssom_stylesheet(&self, owner: NodeId) -> bool {
Self::clear_cssom_stylesheet_inner(&mut self.inner.borrow_mut(), owner)
}
fn clear_cssom_stylesheet_inner(inner: &mut DomTreeInner, owner: NodeId) -> bool {
let removed = inner.external_stylesheets.get_mut(&owner)
.is_some_and(|sheet| sheet.cssom_rules.take().is_some());
if removed {
inner.external_stylesheet_generation = inner.external_stylesheet_generation.wrapping_add(1);
if inner.external_stylesheets.get(&owner).is_some_and(|sheet| sheet.sources.is_empty()) {
inner.external_stylesheets.remove(&owner);
}
}
removed
}
fn invalidate_cssom_source(inner: &mut DomTreeInner, node: NodeId) {
if inner.external_stylesheets.is_empty() { return; }
let mut current = Some(node);
for _ in 0..inner.nodes.len() {
let Some(id) = current else { break };
Self::clear_cssom_stylesheet_inner(inner, id);
current = inner.nodes.get(id.index()).and_then(Option::as_ref).and_then(|node| node.parent);
}
}
pub fn external_stylesheet(&self, owner: NodeId) -> Option<ExternalStylesheet> {
@@ -421,6 +490,29 @@ impl DomTree {
.is_some_and(|control| control.indeterminate)
}
pub fn set_hovered(&self, node: Option<NodeId>) -> bool {
let mut inner = self.inner.borrow_mut();
if inner.hovered == node {
return false;
}
inner.hovered = node;
true
}
pub fn is_hovered(&self, node: NodeId) -> bool {
let inner = self.inner.borrow();
let mut current = inner.hovered;
while let Some(id) = current {
if id == node {
return true;
}
current = inner.nodes.get(id.index())
.and_then(|entry| entry.as_ref())
.and_then(|entry| entry.parent);
}
false
}
pub fn update_form_control_state(&self, node: NodeId, update: impl FnOnce(&mut FormControlState)) {
let mut inner = self.inner.borrow_mut();
if inner.nodes.get(node.index()).is_some_and(|entry| entry.as_ref().is_some_and(Node::is_element)) {
@@ -613,6 +705,7 @@ impl DomTree {
.is_some_and(|node| node.first_child.is_none())
&& !inner.shadow_roots_by_host.contains_key(&root);
if is_leaf {
if !connected { Self::clear_cssom_stylesheet_inner(inner, root); }
if let Some(Some(node)) = inner.nodes.get_mut(root.index()) {
node.connected = connected;
}
@@ -625,6 +718,7 @@ impl DomTree {
if !seen.insert(node_id) {
continue;
}
if !connected { Self::clear_cssom_stylesheet_inner(inner, node_id); }
let (mut child, shadow_root) = match inner
.nodes
.get_mut(node_id.index())
@@ -733,6 +827,14 @@ impl DomTree {
self.inner.borrow().nodes.get(id.index())?.clone()
}
/// Read a node without copying its text or attributes. Drop the guard
/// before mutating this tree; use `get_node` when an owned snapshot is needed.
pub fn borrow_node(&self, id: NodeId) -> Option<std::cell::Ref<'_, Node>> {
std::cell::Ref::filter_map(self.inner.borrow(), |inner| {
inner.nodes.get(id.index())?.as_ref()
}).ok()
}
pub fn with_node<F, R>(&self, id: NodeId, f: F) -> Option<R>
where
F: FnOnce(&Node) -> R,
@@ -746,6 +848,10 @@ impl DomTree {
F: FnOnce(&mut Node) -> R,
{
let mut inner = self.inner.borrow_mut();
if inner.nodes.get(id.index()).and_then(Option::as_ref)
.is_some_and(|node| matches!(node.data, NodeData::Text { .. })) {
Self::invalidate_cssom_source(&mut inner, id);
}
inner.nodes.get_mut(id.index())?.as_mut().map(f)
}
@@ -806,6 +912,7 @@ impl DomTree {
self.detach_for_reparent(child_id, child_connected && !parent_connected);
let mut inner = self.inner.borrow_mut();
Self::invalidate_cssom_source(&mut inner, parent_id);
let old_last = inner.nodes.get(parent_id.index())
.and_then(|n| n.as_ref())
@@ -900,6 +1007,7 @@ impl DomTree {
node.prev_sibling = prev_id;
node.next_sibling = Some(existing_id);
}
Self::invalidate_cssom_source(&mut inner, parent_id);
if let Some(Some(node)) = inner.nodes.get_mut(existing_id.index()) {
node.prev_sibling = Some(new_sibling_id);
@@ -934,6 +1042,7 @@ impl DomTree {
Some(node) => (node.parent, node.prev_sibling, node.next_sibling),
None => return,
};
Self::invalidate_cssom_source(&mut inner, node_id);
if disconnect && inner
.nodes
.get(node_id.index())
@@ -1203,12 +1312,12 @@ impl DomTree {
/// only for HTML slots; same-local-name elements in other namespaces do
/// not participate in the flattened tree.
pub fn is_html_slot_element(&self, node: NodeId) -> bool {
self.get_node(node).is_some_and(|node| {
self.with_node(node, |node| {
node.as_element().is_some_and(|name| {
name.ns.as_ref() == "http://www.w3.org/1999/xhtml"
&& name.local.as_ref() == "slot"
})
})
}).unwrap_or(false)
}
/// Return the first slot to which `node` is assigned.
@@ -1218,26 +1327,23 @@ impl DomTree {
/// the empty/default name. The first same-name slot in shadow-tree order
/// wins, matching the HTML slot assignment algorithm.
pub fn assigned_slot(&self, node: NodeId) -> Option<NodeId> {
let node_ref = self.get_node(node)?;
let parent = node_ref.parent?;
let name = if node_ref.is_element() {
node_ref.get_attribute("slot").unwrap_or("").to_owned()
} else if node_ref.text_content_of_text_node().is_some() {
String::new()
} else {
return None;
};
drop(node_ref);
let parent = self.with_node(node, |node| node.parent).flatten()?;
let root = self.shadow_root(parent)?;
self.descendants(root).into_iter().find(|candidate| {
self.is_html_slot_element(*candidate)
&& self
.get_node(*candidate)
.and_then(|slot| slot.get_attribute("name").map(str::to_owned))
.unwrap_or_default()
== name
})
self.with_node(node, |node| {
let name = if node.is_element() {
node.get_attribute("slot").unwrap_or("")
} else if node.text_content_of_text_node().is_some() {
""
} else {
return None;
};
self.descendants(root).into_iter().find(|candidate| {
self.is_html_slot_element(*candidate)
&& self.with_node(*candidate, |slot| {
slot.get_attribute("name").unwrap_or("") == name
}).unwrap_or(false)
})
}).flatten()
}
/// Nodes directly assigned to an HTML slot. The first same-name slot wins;
@@ -1250,16 +1356,13 @@ impl DomTree {
let root = self.containing_shadow_root(slot)?;
let host = self.shadow_root_info(root)?.host;
let name = self
.get_node(slot)
.and_then(|slot| slot.get_attribute("name").map(str::to_owned))
.with_node(slot, |slot| slot.get_attribute("name").unwrap_or("").to_owned())
.unwrap_or_default();
let is_same_name_slot = |candidate: NodeId| {
self.is_html_slot_element(candidate)
&& self
.get_node(candidate)
.and_then(|slot| slot.get_attribute("name").map(str::to_owned))
.unwrap_or_default()
== name
.with_node(candidate, |slot| slot.get_attribute("name").unwrap_or("") == name)
.unwrap_or(false)
};
if self
.descendants(root)
@@ -1273,17 +1376,16 @@ impl DomTree {
self.children(host)
.into_iter()
.filter(|candidate| {
let Some(node) = self.get_node(*candidate) else {
return false;
};
let candidate_name = if node.is_element() {
node.get_attribute("slot").unwrap_or("")
} else if node.text_content_of_text_node().is_some() {
""
} else {
return false;
};
candidate_name == name
self.with_node(*candidate, |node| {
let candidate_name = if node.is_element() {
node.get_attribute("slot").unwrap_or("")
} else if node.text_content_of_text_node().is_some() {
""
} else {
return false;
};
candidate_name == name
}).unwrap_or(false)
})
.collect(),
)
@@ -1695,7 +1797,8 @@ impl DomTree {
}
pub fn len(&self) -> usize {
self.inner.borrow().nodes.iter().filter(|n| n.is_some()).count()
let inner = self.inner.borrow();
inner.nodes.len() - inner.free_list.len()
}
// Number of node slots (live plus freed), i.e. the same upper bound
@@ -1783,6 +1886,35 @@ impl Default for DomTree {
mod tests {
use super::*;
#[test]
fn cssom_stylesheet_storage_is_released_when_owner_is_detached() {
let tree = DomTree::new();
for _ in 0..128 {
let parent = element(&tree, "div");
let owner = element(&tree, "style");
tree.append_child(tree.document(), parent);
tree.append_child(parent, owner);
assert!(tree.update_cssom_stylesheet(owner, 0, 0, vec![".x { width: 3px }".into()], true));
tree.detach(parent);
}
assert!(tree.external_stylesheets().is_empty(), "detached inline sheets retained native storage");
}
#[test]
fn cssom_inline_rules_survive_cross_origin_import_loading() {
let tree = DomTree::new();
let owner = element(&tree, "style");
tree.append_child(tree.document(), owner);
assert!(tree.append_external_stylesheet(owner, ".import { color: red }".into(), false));
assert!(tree.update_cssom_stylesheet(owner, 0, 0, vec![".own { width: 3px }".into()], true),
"a foreign import does not taint its inline owner's own rules");
assert!(tree.append_external_stylesheet(owner, ".late { color: blue }".into(), false));
let sheet = tree.external_stylesheet(owner).unwrap();
assert_eq!(sheet.cssom_rules.unwrap()[0].as_ref(), ".own { width: 3px }");
assert_eq!(sheet.sources.len(), 2);
assert!(!sheet.origin_clean);
}
fn element(tree: &DomTree, local: &str) -> NodeId {
tree.new_node(NodeData::Element {
name: QualName::new(None, ns!(html), LocalName::from(local)),
@@ -1812,6 +1944,29 @@ mod tests {
assert!(node.is_document());
}
#[test]
fn live_node_count_does_not_scan_the_arena() {
let tree = DomTree::new();
let nodes: Vec<_> = (0..100_000)
.map(|_| tree.new_node(NodeData::Text { contents: String::new() }))
.collect();
assert_eq!(tree.len(), 100_001, "detached nodes are still live");
for &node in nodes.iter().step_by(2) {
tree.remove(node);
tree.remove(node);
}
assert_eq!(tree.len(), 50_001, "a repeated removal cannot free another slot");
for _ in 0..100 {
tree.new_node(NodeData::Text { contents: String::new() });
}
let started = std::time::Instant::now();
for _ in 0..10_000 {
assert_eq!(std::hint::black_box(&tree).len(), 50_101);
}
assert!(started.elapsed() < std::time::Duration::from_millis(500),
"repeated live-node counts scanned the arena: {:?}", started.elapsed());
}
#[test]
fn remove_out_of_range_id_is_a_noop() {
let tree = DomTree::new();
@@ -0,0 +1,66 @@
use obscura_dom::{parse_html, ShadowRootMode};
#[path = "../../test-support/allocations.rs"]
mod allocations;
#[test]
fn slot_queries_do_not_copy_unrelated_attributes_or_text() {
let payload = "x".repeat(64 * 1024);
let tree = parse_html(&format!(
"<x-card id=host><b id=light slot=title data-payload='{payload}'></b>{payload}</x-card>\
<div id=outside data-payload='{payload}'></div>\
<div id=source><slot id=named name=title data-payload='{payload}'></slot>\
<slot id=default></slot></div>"
));
let host = tree.get_element_by_id("host").unwrap();
let light = tree.get_element_by_id("light").unwrap();
let text = tree.children(host)[1];
let outside = tree.get_element_by_id("outside").unwrap();
let named = tree.get_element_by_id("named").unwrap();
let default = tree.get_element_by_id("default").unwrap();
let root = tree.attach_shadow_root(host, ShadowRootMode::Open).unwrap();
tree.append_child(root, named);
tree.append_child(root, default);
let (result, bytes) = allocations::measure(|| (
tree.is_html_slot_element(named),
tree.is_html_slot_element(outside),
tree.assigned_slot(outside),
tree.assigned_slot(light),
tree.assigned_slot(text),
tree.assigned_nodes(named),
tree.assigned_nodes(default),
));
assert_eq!(result, (true, false, None, Some(named), Some(default),
Some(vec![light]), Some(vec![text])));
// Small traversal/result vectors are allowed, copying a 64KiB DOM payload is not.
assert!(bytes < 4096, "slot queries allocated {bytes} bytes for a two-slot tree");
}
#[test]
fn relational_selector_walks_do_not_copy_unrelated_payloads() {
let payload = "x".repeat(64 * 1024);
let tree = parse_html(&format!(
"<section id=scope data-payload='{payload}'>\
<i id=first data-payload='{payload}'></i>{payload}\
<b id=target class=item data-payload='{payload}'></b><!--ignored-->\
<em id=last data-payload='{payload}'></em></section>"
));
let scope = tree.get_element_by_id("scope").unwrap();
let target = tree.get_element_by_id("target").unwrap();
let last = tree.get_element_by_id("last").unwrap();
for (selector, expected) in [
("section > .item", target),
("i + .item", target),
(".item + em", last),
("section:has(> .item)", scope),
(".item:first-of-type", target),
(".item:has(+ em)", target),
("section:not(:empty)", scope),
] {
let (matched, bytes) = allocations::measure(|| tree.query_selector_all(selector).unwrap());
assert_eq!(matched, vec![expected], "{selector}");
// Parsing/traversal may allocate, but not a 64KiB unrelated node payload.
assert!(bytes < 16 * 1024, "{selector} allocated {bytes} bytes");
}
}
+167 -53
View File
@@ -61,7 +61,9 @@ const __obscuraCore = globalThis.Deno.core;
'Navigator', 'PluginArray', 'Plugin', 'MimeType', 'MimeTypeArray',
'Animation', 'KeyframeEffect', 'DocumentTimeline',
'Text', 'Comment', 'CDATASection', 'ProcessingInstruction', 'CharacterData',
'CSSStyleDeclaration', 'DOMStringMap', 'DOMTokenList', 'NamedNodeMap', 'Screen', 'NetworkInformation',
'CSSStyleDeclaration', 'CSSRule', 'CSSStyleRule', 'CSSGroupingRule',
'CSSRuleList', 'StyleSheet', 'StyleSheetList', 'CSSStyleSheet',
'DOMStringMap', 'DOMTokenList', 'NamedNodeMap', 'Screen', 'NetworkInformation',
'MessageChannel', 'MessagePort', 'BroadcastChannel', 'CustomElementRegistry',
'Scheduler',
'XMLHttpRequestEventTarget', 'HTMLMediaElement', 'HTMLVideoElement',
@@ -1917,6 +1919,15 @@ function _eventTargetDispatch(target, event) {
event.currentTarget = target;
event.eventPhase = 2;
try {
// Other EventTargets register their IDL handlers in the listener list.
const eventHandler = target === globalThis ? target['on' + event.type] : null;
if (typeof eventHandler === 'function') {
try {
if (eventHandler.call(target, event) === false) event.preventDefault();
} catch (error) {
console.error(error);
}
}
const listeners = (_eventTargetListeners.get(target)?.get(String(event.type)) || []).slice();
for (const entry of listeners) {
const current = _eventTargetListeners.get(target)?.get(String(event.type));
@@ -4360,6 +4371,26 @@ class Element extends Node {
configurable: true
});
}
get label() {
if (this.localName === 'option') {
const label = this.getAttribute('label');
return label !== null ? label : this.textContent;
}
if (this.localName === 'optgroup') return this.getAttribute('label') || '';
return undefined;
}
set label(v) {
if (this.localName === 'option' || this.localName === 'optgroup') {
this.setAttribute('label', String(v));
return;
}
Object.defineProperty(this, 'label', {
value: v,
writable: true,
enumerable: true,
configurable: true
});
}
get disabled() { return this.hasAttribute("disabled"); }
set disabled(v) { if (v) this.setAttribute("disabled", ""); else this.removeAttribute("disabled"); }
get type() {
@@ -6749,7 +6780,13 @@ function _elementClassFor(nid) {
if (globalThis.SVGElement) return globalThis.SVGElement;
}
if (tag === "FORM" && globalThis.HTMLFormElement) return globalThis.HTMLFormElement;
if (tag === "INPUT" && globalThis.HTMLInputElement
&& _domParse("namespace_uri", nid) === "http://www.w3.org/1999/xhtml"
&& _domParse("local_name", nid) === "input") return globalThis.HTMLInputElement;
if (tag === "TEXTAREA" && globalThis.HTMLTextAreaElement) return globalThis.HTMLTextAreaElement;
if (tag === "META" && globalThis.HTMLMetaElement
&& _domParse("namespace_uri", nid) === "http://www.w3.org/1999/xhtml"
&& _domParse("local_name", nid) === "meta") return globalThis.HTMLMetaElement;
// Only HTML slots take part in slot assignment; a foreign-namespace "SLOT"
// (createElementNS + cloneNode lands here) stays a plain Element.
if (tag === "SLOT" && globalThis.HTMLSlotElement
@@ -6775,7 +6812,9 @@ function _elementClassForKnownName(namespace, qualifiedName) {
if (namespace === "http://www.w3.org/1999/xhtml") {
const tag = localName.toUpperCase();
if (tag === "FORM" && globalThis.HTMLFormElement) return globalThis.HTMLFormElement;
if (localName === "input" && globalThis.HTMLInputElement) return globalThis.HTMLInputElement;
if (tag === "TEXTAREA" && globalThis.HTMLTextAreaElement) return globalThis.HTMLTextAreaElement;
if (localName === "meta" && globalThis.HTMLMetaElement) return globalThis.HTMLMetaElement;
if (tag === "SLOT" && globalThis.HTMLSlotElement) return globalThis.HTMLSlotElement;
if (tag === "IMG") return HTMLImageElement;
if (tag === "CANVAS" && globalThis.HTMLCanvasElement) return globalThis.HTMLCanvasElement;
@@ -7242,8 +7281,12 @@ globalThis.navigator = {
defGetter('platform', function() {
return globalThis.__obscura_platform || "Win32";
});
defGetter('language', function() { return "en-US"; });
defGetter('languages', function() { return ["en-US", "en"]; });
defGetter('language', function() { return globalThis.__obscura_language || "en-US"; });
defGetter('languages', function() {
const language = globalThis.__obscura_language || "en-US";
const base = language.split('-')[0];
return base !== language ? [language, base] : [language];
});
// Cache plugins/mimeTypes so navigator.plugins === navigator.plugins.
var _plugins = new PluginArray([
@@ -8695,28 +8738,32 @@ globalThis.matchMedia = _markNative(function matchMedia(q) {
// getComputedStyle() repeatedly on the same few roots; rebuilding and parsing
// several hundred properties for every wrapper dominated real-page startup.
const _computedStyleSnapshotCache = new WeakMap();
globalThis.getComputedStyle = (el) => {
globalThis.getComputedStyle = (el, pseudo = '') => {
if (!el) el = document.body || {};
pseudo = String(pseudo || '').toLowerCase();
const style = el?.style || el?._style || new CSSStyleDeclaration();
// Render builds expose one immutable snapshot from the retained final
// cascade/layout. The native snapshot is shared per element and epoch while
// each call still returns a distinct, live CSSStyleDeclaration proxy.
const cacheable = (typeof el === 'object' && el !== null) || typeof el === 'function';
let snapshot = cacheable ? _computedStyleSnapshotCache.get(el) : null;
let snapshot = !pseudo && cacheable ? _computedStyleSnapshotCache.get(el) : null;
if (!snapshot) {
snapshot = { rendered: null, epoch: -1, names: [] };
if (cacheable) _computedStyleSnapshotCache.set(el, snapshot);
snapshot = { rendered: null, epoch: -1, names: [], complete: false };
if (!pseudo && cacheable) _computedStyleSnapshotCache.set(el, snapshot);
}
const refreshRendered = () => {
const refreshRendered = (property = '') => {
const hasRunningAnimation = typeof _animationsForTarget === 'function'
&& _animationsForTarget(el).some(animation => animation.playState === 'running');
if (snapshot.epoch === _domMutationEpoch && !hasRunningAnimation) return;
if (snapshot.epoch === _domMutationEpoch && !hasRunningAnimation
&& (snapshot.complete || (property && snapshot.rendered
&& Object.prototype.hasOwnProperty.call(snapshot.rendered, property)))) return;
snapshot.epoch = _domMutationEpoch;
snapshot.rendered = null;
snapshot.complete = true;
if (typeof __obscuraCore.ops.op_computed_style === 'function' && el?._nid != null) {
try {
const raw = __obscuraCore.ops.op_computed_style(String(el._nid | 0));
snapshot.rendered = raw ? JSON.parse(raw) : null;
const raw = __obscuraCore.ops.op_computed_style(String(el._nid | 0), pseudo, property);
if (raw) [snapshot.complete, snapshot.rendered] = JSON.parse(raw);
} catch (e) {}
}
snapshot.names = snapshot.rendered ? Object.keys(snapshot.rendered) : [];
@@ -8777,13 +8824,13 @@ globalThis.getComputedStyle = (el) => {
const lookup = (rawProp) => {
if (typeof rawProp !== 'string') return '';
refreshRendered();
let kebab = rawProp.replace(/([A-Z])/g, '-$1').toLowerCase();
// CSSOM camelCase vendor properties omit the punctuation from their JS
// spelling (`webkitLineClamp`) but computed-property names retain it
// (`-webkit-line-clamp`). Normalize the prefix once for every WebKit
// property instead of adding per-property aliases to the native snapshot.
if (kebab.startsWith('webkit-')) kebab = '-' + kebab;
refreshRendered(kebab);
if (snapshot.rendered && Object.prototype.hasOwnProperty.call(snapshot.rendered, kebab))
return snapshot.rendered[kebab];
// Non-render builds and properties outside the renderer snapshot retain
@@ -9014,10 +9061,44 @@ class CSSRuleList {
const _cssStyleSheetPrivate = new WeakMap();
class CSSStyleSheet {
class CSSGroupingRule extends CSSRule {
constructor(cssText = "", type = 0) {
super(cssText, type);
this._rules = [];
this._cssRules = new CSSRuleList(this);
}
_refreshFromOwner() {}
get cssRules() { return this._cssRules; }
insertRule(rule, index = 0) {
const idx = Number(index) >>> 0;
if (idx > this._rules.length) throw new DOMException("Rule index is out of range", "IndexSizeError");
const parsed = _splitTopLevelCssRules(String(rule));
if (!parsed.valid || parsed.rules.length !== 1) throw new DOMException("The rule could not be parsed", "SyntaxError");
const child = _cssRuleFromText(parsed.rules[0]);
if (!child) throw new DOMException("The rule could not be parsed", "SyntaxError");
child._parentStyleSheet = this.parentStyleSheet;
this._rules.splice(idx, 0, child);
this.parentStyleSheet?._ruleChanged();
return idx;
}
deleteRule(index) {
const idx = Number(index) >>> 0;
if (idx >= this._rules.length) throw new DOMException("Rule index is out of range", "IndexSizeError");
this._rules.splice(idx, 1);
this.parentStyleSheet?._ruleChanged();
}
}
class StyleSheet {
constructor() { this._disabled = false; }
get disabled() { return this._disabled; }
set disabled(value) { this._disabled = !!value; }
}
class CSSStyleSheet extends StyleSheet {
constructor(_options) {
super();
this.ownerRule = null;
this.disabled = false;
this._ownerNode = null;
this._sourceNode = null;
this._sourceText = "";
@@ -9031,6 +9112,8 @@ class CSSStyleSheet {
href: null,
originClean: true,
sourceText: "",
generation: -1,
cssom: false,
});
}
get type() { return "text/css"; }
@@ -9050,6 +9133,8 @@ class CSSStyleSheet {
state.href = null;
state.originClean = true;
state.sourceText = "";
state.generation = -1;
state.cssom = false;
this._ownerNode = ownerNode;
this._sourceNode = sourceNode;
this._sourceText = null;
@@ -9061,6 +9146,8 @@ class CSSStyleSheet {
state.href = href || null;
state.originClean = false;
state.sourceText = "";
state.generation = -1;
state.cssom = false;
this._ownerNode = ownerNode;
this._sourceNode = null;
this._sourceText = "";
@@ -9076,10 +9163,14 @@ class CSSStyleSheet {
}
_refreshFromOwner() {
const state = _cssStyleSheetPrivate.get(this);
if (!state) return;
if (!state || !(state.linked ? this._ownerNode : this._sourceNode)) return;
// Tracing iterates every rule. Copying the source for each item is O(n²).
// The host generations include native imports and their origin-clean state.
const generation = __obscuraCore.ops.op_stylesheet_generation(_realmFrameId, state.linked);
if (state.generation === generation) return;
state.generation = generation;
let text;
if (state.linked) {
if (!this._ownerNode) return;
let loaded;
try {
loaded = JSON.parse(__obscuraCore.ops.op_external_stylesheet_get(
@@ -9096,10 +9187,11 @@ class CSSStyleSheet {
}
text = String(loaded.css || "");
} else {
if (!this._sourceNode) return;
text = this._sourceNode.textContent || "";
}
if (text === state.sourceText) return;
if (text === state.sourceText && (!state.cssom ||
__obscuraCore.ops.op_cssom_stylesheet_has(this._ownerNode._nid, _realmFrameId))) return;
state.cssom = false;
const parsed = _splitTopLevelCssRules(text);
const rules = parsed.rules.map(_cssRuleFromText).filter(Boolean);
this._setRules(rules);
@@ -9112,23 +9204,34 @@ class CSSStyleSheet {
for (const rule of this._rules) rule._parentStyleSheet = this;
}
_serializeText() { return this._rules.map(rule => rule.cssText).join("\n"); }
_ruleChanged() {
const text = this._serializeText();
_ruleChanged(change) {
const state = _cssStyleSheetPrivate.get(this);
if (state) state.sourceText = text;
this._sourceText = text;
if (state?.linked && this._ownerNode) {
// ponytail: linked imports retain the existing whole-sheet write path;
// incremental writes need per-sheet host-import revision tracking.
const text = this._serializeText();
state.sourceText = text;
this._sourceText = text;
__obscuraCore.ops.op_external_stylesheet_set(
this._ownerNode._nid,
text,
this._ownerNode._nid, text,
state.href || globalThis.document?.URL || "about:blank",
true,
globalThis.__obscura_frameId || 0,
true, _realmFrameId,
);
} else if (this._ownerNode) {
// Initialize once, then transfer just the inserted/deleted rules. DOM
// source remains unchanged, including its text nodes and observers.
const reset = !state.cssom || !change;
state.cssom = __obscuraCore.ops.op_cssom_stylesheet_update(
this._ownerNode._nid, reset ? 0 : change.index,
reset ? 0 : change.deleteCount,
reset ? this._rules.map(rule => rule.cssText) : change.rules,
reset, _realmFrameId,
);
} else if (this._sourceNode && this._sourceNode.textContent !== text) {
this._sourceNode.textContent = text;
}
_syncAdoptedStyleSheet(this);
if (state && (state.linked || this._sourceNode)) {
state.generation = __obscuraCore.ops.op_stylesheet_generation(_realmFrameId, state.linked);
}
}
insertRule(rule, index = 0) {
if (arguments.length < 1) throw new TypeError("CSSStyleSheet.insertRule requires a rule");
@@ -9144,7 +9247,7 @@ class CSSStyleSheet {
if (!cssRule) throw new DOMException("The rule could not be parsed", "SyntaxError");
cssRule._parentStyleSheet = this;
this._rules.splice(idx, 0, cssRule);
this._ruleChanged();
this._ruleChanged({index: idx, deleteCount: 0, rules: [cssRule.cssText]});
return idx;
}
deleteRule(index) {
@@ -9155,7 +9258,7 @@ class CSSStyleSheet {
if (idx >= this._rules.length) throw new DOMException("Rule index is out of range", "IndexSizeError");
const [removed] = this._rules.splice(idx, 1);
if (removed) removed._parentStyleSheet = null;
this._ruleChanged();
this._ruleChanged({index: idx, deleteCount: 1, rules: []});
}
addRule(selector, style, index) {
this.insertRule(String(selector) + "{" + String(style) + "}", index ?? this._rules.length);
@@ -9200,6 +9303,7 @@ function _sheetForStyleElement(style) {
function _detachStyleSheet(style) {
const sheet = _styleElementSheets.get(style);
if (!sheet) return;
__obscuraCore.ops.op_cssom_stylesheet_clear(style._nid, _realmFrameId);
sheet._ownerNode = null;
sheet._sourceNode = null;
_styleElementSheets.delete(style);
@@ -9225,6 +9329,7 @@ function _sheetForLinkElement(link) {
function _detachLinkedStyleSheet(link) {
const sheet = _linkElementSheets.get(link);
if (!sheet) return;
__obscuraCore.ops.op_cssom_stylesheet_clear(link._nid, _realmFrameId);
sheet._ownerNode = null;
sheet._sourceNode = null;
_linkElementSheets.delete(link);
@@ -9294,7 +9399,9 @@ Object.defineProperty(Element.prototype, "sheet", {
});
globalThis.CSSRule = CSSRule;
globalThis.CSSStyleRule = CSSStyleRule;
globalThis.CSSGroupingRule = CSSGroupingRule;
globalThis.CSSRuleList = CSSRuleList;
globalThis.StyleSheet = StyleSheet;
globalThis.CSSStyleSheet = CSSStyleSheet;
globalThis.StyleSheetList = StyleSheetList;
@@ -10156,6 +10263,11 @@ globalThis.__obscura_setFieldValue = function(el, field, value) {
} catch (_e) {}
el[field] = value;
};
globalThis.__obscura_setHovered = function(el) {
if (typeof __obscuraCore.ops.op_set_hovered === 'function') {
__obscuraCore.ops.op_set_hovered(el?._nid | 0, 0);
}
};
// Build a FileList-like object: an array with the DOM's `item(i)` accessor.
function _makeFileList(files) {
@@ -10260,7 +10372,7 @@ globalThis.MouseEvent = class extends Event {
}
};
globalThis.KeyboardEvent = class extends Event {
constructor(t,o={}) { super(t,o);this.view=o.view||null;this.detail=o.detail||0;this.key=o.key||"";this.code=o.code||"";this.location=o.location||0;this.ctrlKey=!!o.ctrlKey;this.altKey=!!o.altKey;this.shiftKey=!!o.shiftKey;this.metaKey=!!o.metaKey;this.repeat=!!o.repeat; }
constructor(t,o={}) { super(t,o);this.view=o.view||null;this.detail=o.detail||0;this.key=o.key||"";this.code=o.code||"";this.location=o.location||0;this.ctrlKey=!!o.ctrlKey;this.altKey=!!o.altKey;this.shiftKey=!!o.shiftKey;this.metaKey=!!o.metaKey;this.repeat=!!o.repeat;this.keyCode=o.keyCode||0;this.charCode=o.charCode||0;this.which=o.which||0; }
getModifierState(key) {
switch (String(key)) {
case 'Alt': return this.altKey;
@@ -10395,10 +10507,6 @@ __obscuraCore.setUnhandledPromiseRejectionHandler((promise, reason) => {
cancelable: true,
});
globalThis.dispatchEvent(event);
if (typeof globalThis.onunhandledrejection === "function") {
try { globalThis.onunhandledrejection.call(globalThis, event); }
catch (error) { console.error(error); }
}
// Browsers report an unhandled rejection without terminating the page's
// event loop. Returning true tells deno_core that the host delivered it.
return true;
@@ -10407,10 +10515,6 @@ __obscuraCore.setUnhandledPromiseRejectionHandler((promise, reason) => {
__obscuraCore.setHandledPromiseRejectionHandler((promise, reason) => {
const event = new PromiseRejectionEvent("rejectionhandled", { promise, reason });
globalThis.dispatchEvent(event);
if (typeof globalThis.onrejectionhandled === "function") {
try { globalThis.onrejectionhandled.call(globalThis, event); }
catch (error) { console.error(error); }
}
});
globalThis.StorageEvent = class StorageEvent extends Event {
@@ -12046,7 +12150,12 @@ globalThis.HTMLSpanElement = Element;
globalThis.HTMLParagraphElement = Element;
globalThis.HTMLAnchorElement = Element;
globalThis.HTMLImageElement = HTMLImageElement;
globalThis.HTMLInputElement = Element;
globalThis.HTMLInputElement = class HTMLInputElement extends Element {};
// Framework value trackers read own prototype descriptors, not inherited ones.
Object.defineProperties(HTMLInputElement.prototype, {
value: Object.getOwnPropertyDescriptor(Element.prototype, 'value'),
checked: Object.getOwnPropertyDescriptor(Element.prototype, 'checked'),
});
globalThis.HTMLButtonElement = Element;
globalThis.HTMLFormElement = class HTMLFormElement extends Element {
get elements() { return HTMLCollection._from(this.querySelectorAll("input, select, textarea, button, fieldset, output, object")); }
@@ -12096,7 +12205,10 @@ globalThis.HTMLCanvasElement = Element;
globalThis.HTMLScriptElement = Element;
globalThis.HTMLStyleElement = Element;
globalThis.HTMLLinkElement = Element;
globalThis.HTMLMetaElement = Element;
globalThis.HTMLMetaElement = class HTMLMetaElement extends Element {
get httpEquiv() { return this.getAttribute('http-equiv') || ''; }
set httpEquiv(value) { this.setAttribute('http-equiv', String(value)); }
};
globalThis.HTMLHeadElement = Element;
globalThis.HTMLBodyElement = Element;
globalThis.HTMLHtmlElement = Element;
@@ -14095,13 +14207,8 @@ globalThis.IDBKeyRange = {
bound(l, u, lo, uo) { return { lower: l, upper: u, lowerOpen: !!lo, upperOpen: !!uo, includes(x) { return (lo ? x > l : x >= l) && (uo ? x < u : x <= u); } }; },
};
globalThis.caches = {
open() { return Promise.resolve({ match(){return Promise.resolve(undefined);}, put(){return Promise.resolve();}, delete(){return Promise.resolve(false);}, keys(){return Promise.resolve([]);} }); },
match() { return Promise.resolve(undefined); },
has() { return Promise.resolve(false); },
delete() { return Promise.resolve(false); },
keys() { return Promise.resolve([]); },
};
// Do not advertise CacheStorage until it can retain responses. A successful
// no-op cache selects broken persistence paths instead of normal fetch fallbacks.
_markNative(AudioContext); _markNative(OfflineAudioContext);
_markNative(SpeechSynthesisUtterance);
@@ -15723,8 +15830,8 @@ if (typeof Document !== 'undefined' && !Document.prototype.elementFromPoint) {
// don't form a proper containment hierarchy (a child's rect can lie far
// outside its parent's), so a tree walk that only descends into ancestors
// containing (x,y) would never reach a deep <input> inside <label><p>.
// Returns the deepest matching element (highest nid wins as a proxy for
// tree depth) so descendants beat ancestors.
// querySelectorAll returns tree order, which also makes descendants and
// later siblings replace the matching boxes behind them.
Document.prototype.elementFromPoint = function(x, y) {
if (typeof x !== 'number' || typeof y !== 'number' || !isFinite(x) || !isFinite(y)) {
return null;
@@ -15745,7 +15852,6 @@ if (typeof Document !== 'undefined' && !Document.prototype.elementFromPoint) {
}
var all = this.querySelectorAll('*');
var best = null;
var bestNid = -1;
for (var i = 0; i < all.length; i++) {
var el = all[i];
if (!el || !el.getBoundingClientRect) continue;
@@ -15792,8 +15898,16 @@ if (typeof Document !== 'undefined' && !Document.prototype.elementFromPoint) {
ancestor = ancestor.parentElement;
}
if (!visible) continue;
var nid = el._nid | 0;
if (nid > bestNid) { best = el; bestNid = nid; }
if (!best || best.contains(el)) {
best = el;
} else if (!el.contains(best)) {
// Positioned boxes paint above in-flow siblings at the auto layer.
// ponytail: z-index stacking needs renderer display-list hit testing
// when overlapping positioned layers become a measured blocker.
var elPositioned = getComputedStyle(el).position !== 'static';
var bestPositioned = getComputedStyle(best).position !== 'static';
if (elPositioned || !bestPositioned) best = el;
}
}
}
return best || this.body || this.documentElement || null;
+161 -23
View File
@@ -60,6 +60,7 @@ pub enum InterceptResolution {
pub struct InterceptedRequest {
pub request_id: String,
pub page_id: String,
pub url: String,
pub method: String,
pub headers: HashMap<String, String>,
@@ -83,6 +84,8 @@ pub struct JsNetworkEvent {
/// Matches the `fetch-{N}` id under which the body is stored, so CDP
/// Network.getResponseBody resolves for the same request.
pub request_id: String,
/// The interception consumer already received the request start.
pub intercepted: bool,
pub url: String,
pub method: String,
pub status: u16,
@@ -131,8 +134,8 @@ pub struct ObscuraState {
pub stealth_client: Option<Arc<StealthHttpClient>>,
pub pending_navigation: Option<(String, String, String)>,
pub intercept_tx: Option<tokio::sync::mpsc::UnboundedSender<InterceptedRequest>>,
pub intercept_counter: u64,
pub intercept_enabled: bool,
pub intercept_page_id: String,
// Queue of (binding_name, payload) calls made by page JS via the
// `op_binding_called` op. Drained by the CDP layer after each dispatch
// and emitted as `Runtime.bindingCalled` events.
@@ -146,7 +149,6 @@ pub struct ObscuraState {
pub runtime_exception_counter: u64,
pub network_response_bodies: HashMap<String, StoredNetworkResponseBody>,
pub network_response_body_order: VecDeque<String>,
pub network_response_body_counter: u64,
// Absolute URLs requested via JS fetch() / XHR (op_fetch_url), in request
// order. Surfaced by `--dump assets` so resources pulled in by script, not
// just static DOM attributes, are listed (issue #301).
@@ -357,8 +359,8 @@ impl ObscuraState {
stealth_client: None,
pending_navigation: None,
intercept_tx: None,
intercept_counter: 0,
intercept_enabled: false,
intercept_page_id: String::new(),
pending_binding_calls: Vec::new(),
pending_runtime_events: VecDeque::new(),
runtime_events_enabled: false,
@@ -367,7 +369,6 @@ impl ObscuraState {
runtime_exception_counter: 0,
network_response_bodies: HashMap::new(),
network_response_body_order: VecDeque::new(),
network_response_body_counter: 0,
fetched_urls: Vec::new(),
js_network_events: Vec::new(),
pending_frames: Vec::new(),
@@ -847,7 +848,7 @@ fn render_mutation_impact(
// Moving a connected node into a detached subtree removes its
// old box, while attaching a detached node creates a new one.
connected: node_is_connected(dom, parent) || node_is_connected(dom, child),
actual_change: !already_last,
actual_change: !already_last || dom.has_cssom_stylesheet(parent) || dom.has_cssom_stylesheet(child),
}
}
"remove_child" => {
@@ -875,7 +876,8 @@ fn render_mutation_impact(
dom.get_node(reference).and_then(|node| node.prev_sibling) == Some(new_node);
RenderMutationImpact {
connected: node_is_connected(dom, reference_parent) || new_was_connected,
actual_change: new_node != reference && !already_immediately_before,
actual_change: new_node != reference && (!already_immediately_before
|| dom.has_cssom_stylesheet(reference_parent) || dom.has_cssom_stylesheet(new_node)),
}
}
"set_inner_html" | "set_inner_html_context" => {
@@ -895,7 +897,10 @@ fn render_mutation_impact(
};
let changed = dom
.with_node(target, |node| match &node.data {
NodeData::Text { contents } | NodeData::Comment { contents } => {
NodeData::Text { contents } => {
contents.as_str() != arg2 || node.parent.is_some_and(|parent| dom.has_cssom_stylesheet(parent))
}
NodeData::Comment { contents } => {
contents.as_str() != arg2
}
NodeData::ProcessingInstruction { data, .. } => data.as_str() != arg2,
@@ -1415,6 +1420,58 @@ fn op_external_stylesheet_remove(state: &OpState, owner_nid: u32, frame_id: u32)
changed
}
/// CSSOM sources change through both DOM operations and native stylesheet loading.
#[op2(fast)]
fn op_stylesheet_generation(state: &OpState, frame_id: u32, linked: bool) -> f64 {
let shared = frame_state(state, frame_id);
let state = shared.borrow();
let generation = if linked {
state.dom.as_ref().map_or(0, |dom| dom.external_stylesheet_generation())
} else {
state.activity_generation.wrapping_add(
state.dom.as_ref().map_or(0, |dom| dom.external_stylesheet_generation()))
};
generation as f64
}
#[op2(fast)]
fn op_cssom_stylesheet_has(state: &OpState, owner_nid: u32, frame_id: u32) -> bool {
frame_state(state, frame_id).borrow().dom.as_ref()
.is_some_and(|dom| dom.has_cssom_stylesheet(NodeId::new(owner_nid)))
}
fn invalidate_cssom_render(state: &mut ObscuraState) {
state.activity_generation = state.activity_generation.wrapping_add(1);
#[cfg(feature = "render")]
{
state.prepared_render = None;
state.pending_style_mutations.clear();
state.resolved_scroll = None;
}
}
#[op2]
fn op_cssom_stylesheet_update(
state: &OpState, owner_nid: u32, index: u32, delete_count: u32,
#[serde] rules: Vec<String>, reset: bool, frame_id: u32,
) -> bool {
let shared = frame_state(state, frame_id);
let mut state = shared.borrow_mut();
let changed = state.dom.as_ref().is_some_and(|dom| dom.update_cssom_stylesheet(
NodeId::new(owner_nid), index as usize, delete_count as usize, rules, reset));
if changed { invalidate_cssom_render(&mut state); }
changed
}
#[op2(fast)]
fn op_cssom_stylesheet_clear(state: &OpState, owner_nid: u32, frame_id: u32) {
let shared = frame_state(state, frame_id);
let mut state = shared.borrow_mut();
if state.dom.as_ref().is_some_and(|dom| dom.clear_cssom_stylesheet(NodeId::new(owner_nid))) {
invalidate_cssom_render(&mut state);
}
}
/// Return CSSOM-readable bytes only for an origin-clean loaded sheet.
#[op2]
#[string]
@@ -2974,7 +3031,7 @@ async fn op_fetch_url(
url
);
let (cookie_jar, in_flight, page_in_flight, intercept_tx, proxy_url, callbacks, http_client) = {
let (cookie_jar, in_flight, page_in_flight, intercept_tx, page_id, request_id, proxy_url, callbacks, http_client) = {
let state_borrow = state.borrow();
let gs = state_borrow.borrow::<SharedState>().clone();
let mut gs = gs.borrow_mut();
@@ -3008,11 +3065,13 @@ async fn op_fetch_url(
gs.intercept_enabled,
gs.intercept_tx.is_some()
);
// Allocate once, before interception. CDP must use this identity for
// the request start, completion and retained response body.
// The CDP resolver map spans pages and navigations on a connection.
static NEXT_REQUEST_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
let request_id = format!("fetch-{}", NEXT_REQUEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
let itx = if gs.intercept_enabled {
gs.intercept_counter += 1;
gs.intercept_tx
.clone()
.map(|tx| (tx, format!("intercept-{}", gs.intercept_counter)))
gs.intercept_tx.clone()
} else {
None
};
@@ -3021,6 +3080,8 @@ async fn op_fetch_url(
in_flight,
Arc::clone(&gs.page_in_flight),
itx,
gs.intercept_page_id.clone(),
request_id,
proxy_url,
gs.callbacks.clone(),
gs.http_client.clone(),
@@ -3067,12 +3128,14 @@ async fn op_fetch_url(
let mut override_headers: Option<HashMap<String, String>> = None;
let mut override_body: Option<Vec<u8>> = None;
if let Some((tx, request_id)) = intercept_tx {
let mut was_intercepted = false;
if let Some(tx) = intercept_tx {
let custom_headers: HashMap<String, String> =
serde_json::from_str(&headers_json).unwrap_or_default();
let (resolve_tx, resolve_rx) = tokio::sync::oneshot::channel();
let intercepted = InterceptedRequest {
request_id: request_id.clone(),
page_id,
url: url.clone(),
method: method.clone(),
headers: custom_headers.clone(),
@@ -3080,6 +3143,7 @@ async fn op_fetch_url(
resolver: resolve_tx,
};
if tx.send(intercepted).is_ok() {
was_intercepted = true;
match resolve_rx.await {
Ok(InterceptResolution::Fulfill {
status,
@@ -3579,8 +3643,6 @@ async fn op_fetch_url(
let state_borrow = state.borrow();
let gs = state_borrow.borrow::<SharedState>().clone();
let mut gs = gs.borrow_mut();
gs.network_response_body_counter += 1;
let request_id = format!("fetch-{}", gs.network_response_body_counter);
let max_entries = response_body_entry_limit();
let max_bytes = response_body_byte_limit();
if max_entries > 0 && max_bytes > 0 && resp_bytes.len() <= max_bytes {
@@ -3608,6 +3670,7 @@ async fn op_fetch_url(
.as_secs_f64();
gs.js_network_events.push(JsNetworkEvent {
request_id: request_id.clone(),
intercepted: was_intercepted,
url: current_url.clone(),
method: current_method.as_str().to_string(),
status,
@@ -6106,6 +6169,10 @@ pub fn build_extension() -> Extension {
op_external_stylesheet_set(),
op_external_stylesheet_remove(),
op_external_stylesheet_get(),
op_stylesheet_generation(),
op_cssom_stylesheet_has(),
op_cssom_stylesheet_update(),
op_cssom_stylesheet_clear(),
op_runtime_events_enabled(),
op_console_msg(),
op_fetch_url(),
@@ -6151,6 +6218,7 @@ pub fn build_extension() -> Extension {
ops.push(op_resize_observer_measurements());
ops.push(op_intersection_observer_measurements());
ops.push(op_computed_style());
ops.push(op_set_hovered());
ops.push(op_css_supports());
ops.push(op_layout_metrics());
ops.push(op_element_scroll_metrics());
@@ -6379,6 +6447,8 @@ fn document_base_href(state: &ObscuraState) -> Option<String> {
pub struct BaseUrlCache {
activity_generation: u64,
document_generation: u64,
#[cfg(feature = "render")]
task_generation: u64,
url: String,
resolved: Option<String>,
raw_href: Option<String>,
@@ -6400,6 +6470,8 @@ fn base_values_memoized(state: &ObscuraState) -> (Option<String>, Option<String>
*state.base_url_cache.borrow_mut() = Some(BaseUrlCache {
activity_generation: state.activity_generation,
document_generation: state.document_generation,
#[cfg(feature = "render")]
task_generation: state.animation_task_generation,
url: state.url.clone(),
resolved: resolved.clone(),
raw_href: raw_href.clone(),
@@ -6420,6 +6492,14 @@ pub(crate) fn ensure_prepared_render(
state: &mut ObscuraState,
) -> Option<&obscura_render::PreparedRender> {
let base_url = document_base_url(state);
ensure_prepared_render_with_base_url(state, base_url)
}
#[cfg(feature = "render")]
fn ensure_prepared_render_with_base_url(
state: &mut ObscuraState,
base_url: Option<String>,
) -> Option<&obscura_render::PreparedRender> {
let viewport = state.viewport;
let render_media = state.render_media;
let animation_sample = state.animation_sample;
@@ -6504,13 +6584,25 @@ pub(crate) fn ensure_prepared_render(
state.prepared_render.as_ref()
}
#[cfg(feature = "render")]
fn document_base_url_for_javascript_task(state: &ObscuraState) -> Option<String> {
// CSSOM reads in one JS task share base resolution. Refresh next task
// as native DomTree edits need not increment the JS activity counter.
if state.base_url_cache.borrow().as_ref().is_some_and(|cached| {
cached.task_generation != state.animation_task_generation
}) {
*state.base_url_cache.borrow_mut() = None;
}
document_base_url_memoized(state)
}
/// Prepare enough state for a geometry-only CSSOM consumer. A forward sample
/// with only paint effects may read the retained layout without resampling its
/// styles. `animation_sample` on PreparedRender remains behind intentionally,
/// making a later paint or computed-style consumer take the exact path above.
#[cfg(feature = "render")]
fn ensure_prepared_geometry(state: &mut ObscuraState) -> Option<&obscura_render::PreparedRender> {
let base_url = document_base_url(state);
let base_url = document_base_url_for_javascript_task(state);
let reusable = state.pending_style_mutations.is_empty()
&& !state.animation_timeline.has_pending_start_candidates()
&& state.prepared_render.as_ref().is_some_and(|prepared| {
@@ -6522,7 +6614,7 @@ fn ensure_prepared_geometry(state: &mut ObscuraState) -> Option<&obscura_render:
if reusable {
return state.prepared_render.as_ref();
}
ensure_prepared_render(state)
ensure_prepared_render_with_base_url(state, base_url)
}
#[cfg(feature = "render")]
@@ -7241,21 +7333,51 @@ fn op_intersection_observer_measurements(state: &OpState, #[string] nids_json: S
serde_json::to_string(&measurements).unwrap_or_else(|_| "[]".to_string())
}
/// One renderer-computed CSS snapshot for `getComputedStyle()`. Returning all
/// supported properties together keeps a single JS style object to one native
/// call and one use of the retained prepared layout.
/// CSSOM snapshot and completeness flag. Non-geometric reads can resolve only
/// the ancestor cascade while leaving layout damage pending for geometry/paint.
#[cfg(feature = "render")]
#[op2]
#[string]
fn op_computed_style(state: &OpState, #[string] nid_str: String) -> String {
fn op_computed_style(
state: &OpState,
#[string] nid_str: String,
#[string] pseudo: String,
#[string] property: String,
) -> String {
let shared = state.borrow::<SharedState>().clone();
let nid: u32 = nid_str.parse().unwrap_or(0);
let nid = obscura_dom::tree::NodeId::new(nid);
let mut gs = shared.borrow_mut();
sample_live_document_animations(&mut gs);
let Some(prepared) = ensure_prepared_render(&mut gs) else {
let base_url = document_base_url_for_javascript_task(&gs);
let needs_layout = gs.prepared_render.as_ref().is_none_or(|prepared| {
prepared.viewport() != gs.viewport || prepared.base_url() != base_url.as_deref()
|| prepared.animation_sample() != gs.animation_sample
}) || !gs.pending_style_mutations.is_empty();
if pseudo.is_empty() && needs_layout && gs.animation_timeline.waapi_nodes().is_empty() {
if let Some(snapshot) = gs.dom.as_ref().and_then(|dom| {
obscura_render::dom::computed_style_without_layout(
dom, nid, gs.viewport, gs.render_media, &gs.stylesheet_cache, &property,
)
}) {
return serde_json::json!([false, snapshot]).to_string();
}
}
let Some(prepared) = ensure_prepared_render_with_base_url(&mut gs, base_url) else {
return String::new();
};
if matches!(pseudo.as_str(), ":before" | "::before" | ":after" | "::after") {
let Some((content, display)) =
prepared.computed_pseudo_content(nid, pseudo.ends_with("before"))
else {
return String::new();
};
let content = content.map_or_else(
|| "none".to_string(),
|value| serde_json::to_string(value).unwrap_or_else(|_| "none".to_string()),
);
return serde_json::json!([true, { "content": content, "display": display }]).to_string();
}
let Some(snapshot) = prepared.computed_style(nid) else {
return String::new();
};
@@ -7267,7 +7389,23 @@ fn op_computed_style(state: &OpState, #[string] nid_str: String) -> String {
for (name, value) in custom {
object.insert(name, serde_json::Value::String(value));
}
serde_json::Value::Object(object).to_string()
serde_json::json!([true, object]).to_string()
}
#[cfg(feature = "render")]
#[op2(fast)]
fn op_set_hovered(state: &OpState, nid: u32, frame_id: u32) {
let shared = frame_state(state, frame_id);
let mut state = shared.borrow_mut();
let changed = state.dom.as_ref().is_some_and(|dom| {
dom.set_hovered((nid != 0).then(|| NodeId::new(nid)))
});
if changed {
state.activity_generation = state.activity_generation.wrapping_add(1);
state.prepared_render = None;
state.pending_style_mutations.clear();
state.resolved_scroll = None;
}
}
/// Use the renderer's declaration parser as the single feature-query source
File diff suppressed because it is too large. Load diff
+30 -21
View File
@@ -111,7 +111,7 @@ fn relational_key_may_match(
tree: &DomTree,
node: NodeId,
) -> bool {
let Some(dom_node) = tree.get_node(node) else {
let Some(dom_node) = tree.borrow_node(node) else {
return false;
};
if dom_node.as_element().is_none() {
@@ -147,7 +147,7 @@ impl RelationalInvalidation {
}
pub(crate) fn relative_path_may_match(&self, tree: &DomTree, node: NodeId) -> bool {
let Some(dom_node) = tree.get_node(node) else {
let Some(dom_node) = tree.borrow_node(node) else {
return false;
};
let Some(element) = dom_node.as_element() else {
@@ -281,7 +281,7 @@ impl InvalidationMap {
{
return true;
}
let Some(node) = tree.get_node(node) else {
let Some(node) = tree.borrow_node(node) else {
return false;
};
let reaches_sibling = |dependencies: &[InvalidationDependency]| {
@@ -1592,9 +1592,9 @@ fn append_declaration_stream(target: &mut String, declarations: &str) {
const NO_CANDIDATE_SLOT: u32 = u32::MAX;
fn is_root_element(tree: &DomTree, nid: NodeId) -> bool {
tree.get_node(nid)
tree.borrow_node(nid)
.and_then(|node| node.parent)
.and_then(|parent| tree.get_node(parent))
.and_then(|parent| tree.borrow_node(parent))
.is_some_and(|parent| parent.is_document())
}
@@ -1722,7 +1722,7 @@ impl PseudoRuleMap {
if self.candidate_slot_count != 0 {
matcher.begin_candidate_collection(self.candidate_slot_count);
}
let Some(node) = tree.get_node(nid) else {
let Some(node) = tree.borrow_node(nid) else {
return false;
};
node.as_element().is_some_and(|element| {
@@ -2214,13 +2214,13 @@ impl<'a> ContainerQueryEvaluator<'a> {
.unwrap_or_default();
let mut candidate = match kind {
ContainerQuerySubjectKind::Element => {
self.tree.get_node(subject).and_then(|node| node.parent)
self.tree.borrow_node(subject).and_then(|node| node.parent)
}
ContainerQuerySubjectKind::OriginatingPseudo => Some(subject),
};
while let Some(id) = candidate {
self.stats.ancestor_steps += 1;
let parent = self.tree.get_node(id).and_then(|node| node.parent);
let parent = self.tree.borrow_node(id).and_then(|node| node.parent);
if let Some(container) = self.snapshot.boxes.get(&id) {
let supports_axis = match container.container_type {
crate::ContainerType::Normal => !required_axes.inline && !required_axes.block,
@@ -2432,6 +2432,20 @@ struct CachedStylesheet {
}
impl StylesheetCache {
// A style-only read must not replace the key that validates retained layout
// styles. A miss goes through normal preparation before taking this path.
pub(crate) fn get(
&self,
sources: &[String],
viewport: (f32, f32),
media_type: CssMediaType,
) -> Option<Arc<Stylesheet>> {
let entry = self.entry.as_ref()?;
(entry.viewport_bits == (viewport.0.to_bits(), viewport.1.to_bits())
&& entry.media_type == media_type && entry.sources == sources)
.then(|| Arc::clone(&entry.sheet))
}
pub(crate) fn get_or_parse(
&mut self,
tree: &DomTree,
@@ -2440,14 +2454,9 @@ impl StylesheetCache {
media_type: CssMediaType,
) -> (Arc<Stylesheet>, bool) {
let viewport_bits = (viewport.0.to_bits(), viewport.1.to_bits());
if let Some(entry) = self.entry.as_ref() {
if entry.viewport_bits == viewport_bits
&& entry.media_type == media_type
&& entry.sources == sources
{
self.hits = self.hits.saturating_add(1);
return (Arc::clone(&entry.sheet), true);
}
if let Some(sheet) = self.get(sources, viewport, media_type) {
self.hits = self.hits.saturating_add(1);
return (sheet, true);
}
self.misses = self.misses.saturating_add(1);
@@ -2564,7 +2573,7 @@ impl Stylesheet {
if self.candidate_slot_count != 0 {
matcher.begin_candidate_collection(self.candidate_slot_count);
}
let Some(node) = tree.get_node(nid) else {
let Some(node) = tree.borrow_node(nid) else {
return false;
};
let normal_match = node.as_element().is_some_and(|element| {
@@ -3062,7 +3071,7 @@ impl Stylesheet {
}
};
if let Some(node) = tree.get_node(nid) {
if let Some(node) = tree.borrow_node(nid) {
if let Some(element) = node.as_element() {
consider(
rules.by_local.get(element.local.as_ref()),
@@ -3225,7 +3234,7 @@ impl Stylesheet {
}
};
let supports_placeholder = tree
.get_node(nid)
.borrow_node(nid)
.is_some_and(|node| {
node.as_element().is_some_and(|element| {
matches!(element.local.as_ref(), "input" | "textarea")
@@ -3688,7 +3697,7 @@ impl Stylesheet {
);
}
if !self.by_attribute.is_empty() {
if let Some(node) = tree.get_node(nid) {
if let Some(node) = tree.borrow_node(nid) {
if let Some(attributes) = node.attrs() {
for attribute in attributes {
consider(
@@ -5654,7 +5663,7 @@ fn parse_generated_content_items(
.next()
.filter(|name| !name.is_empty())?;
let value = tree
.get_node(nid)
.borrow_node(nid)
.and_then(|node| node.get_attribute(attribute).map(str::to_owned))
.unwrap_or_default();
items.push(crate::GeneratedContentItem::Text(value));
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -2495,7 +2495,7 @@ fn collect_node_spans(
c: &mut Collector,
loaded_families: &HashMap<String, LoadedFamily>,
) {
let Some(node) = tree.get_node(cid) else {
let Some(node) = tree.borrow_node(cid) else {
return;
};
match &node.data {
@@ -3188,7 +3188,7 @@ fn inline_child_ok(
styles: &std::collections::HashMap<NodeId, LayoutStyle>,
has_text: &mut bool,
) -> bool {
let Some(node) = tree.get_node(cid) else {
let Some(node) = tree.borrow_node(cid) else {
return true;
};
match &node.data {
+10
View File
@@ -96,6 +96,8 @@ mod image_capability_tests {
#[cfg(feature = "paint")]
mod paint;
#[cfg(feature = "paint")]
pub use tiny_skia::Pixmap;
#[cfg(feature = "paint")]
pub use paint::{
image_intrinsic_dimensions, paint_dom, paint_dom_scrolled,
paint_dom_scrolled_at_animation_time,
@@ -988,6 +990,8 @@ pub struct LayoutStyle {
/// top-down before layout because `container-type` is otherwise
/// non-inherited.
pub(crate) container_type_inherit: bool,
/// Explicit `contain` flags, independent of size-query eligibility.
pub(crate) containment: u8,
/// Computed CSS `container-name`; empty represents `none` (not inherited).
pub container_names: Vec<String>,
/// The specified `container-name` value was CSS-wide `inherit`.
@@ -1700,6 +1704,12 @@ pub(crate) fn blockify_outer_display(style: &mut LayoutStyle) {
}
pub(crate) const CB_TRIGGER_TRANSFORM: u16 = 1 << 0;
pub(crate) const CONTAIN_SIZE: u8 = 1 << 0;
pub(crate) const CONTAIN_INLINE_SIZE: u8 = 1 << 1;
pub(crate) const CONTAIN_LAYOUT: u8 = 1 << 2;
pub(crate) const CONTAIN_STYLE: u8 = 1 << 3;
pub(crate) const CONTAIN_PAINT: u8 = 1 << 4;
pub(crate) const CONTAIN_INHERIT: u8 = 1 << 7;
pub(crate) const CB_TRIGGER_FILTER: u16 = 1 << 1;
pub(crate) const CB_TRIGGER_BACKDROP_FILTER: u16 = 1 << 2;
pub(crate) const CB_TRIGGER_PERSPECTIVE: u16 = 1 << 3;
+232 -123
View File
@@ -793,7 +793,10 @@ pub enum CaptureError {
EncodeFailed,
}
fn checked_capture_dimensions(region: CaptureRegion) -> Result<(u32, u32, u32, u32), CaptureError> {
fn checked_capture_dimensions(
region: CaptureRegion,
allocates_native_surface: bool,
) -> Result<(u32, u32, u32, u32), CaptureError> {
if !region.x.is_finite()
|| !region.y.is_finite()
|| !region.width.is_finite()
@@ -838,14 +841,21 @@ fn checked_capture_dimensions(region: CaptureRegion) -> Result<(u32, u32, u32, u
output_width as u32,
output_height as u32,
);
for (width, height) in [(dimensions.0, dimensions.1), (dimensions.2, dimensions.3)] {
let checked_surfaces = if allocates_native_surface {
&[(dimensions.0, dimensions.1), (dimensions.2, dimensions.3)][..]
} else {
&[(dimensions.2, dimensions.3)][..]
};
for &(width, height) in checked_surfaces {
if u64::from(width).saturating_mul(u64::from(height)) > MAX_CAPTURE_PIXELS {
return Err(CaptureError::AllocationLimitExceeded);
}
}
let native_pixels = u64::from(dimensions.0).saturating_mul(u64::from(dimensions.1));
let output_pixels = u64::from(dimensions.2).saturating_mul(u64::from(dimensions.3));
let peak_pixels = if dimensions.0 == dimensions.2 && dimensions.1 == dimensions.3 {
let peak_pixels = if !allocates_native_surface {
output_pixels
} else if dimensions.0 == dimensions.2 && dimensions.1 == dimensions.3 {
native_pixels
} else {
// Scaling owns the native RGBA surface and output RGBA surface at the
@@ -862,7 +872,7 @@ fn checked_capture_dimensions(region: CaptureRegion) -> Result<(u32, u32, u32, u
/// Protocol adapters use this on legacy viewport captures which preserve the
/// renderer's native PNG bytes but must obey the same limits as region capture.
pub fn validate_capture_region(region: CaptureRegion) -> Result<(), CaptureError> {
checked_capture_dimensions(region).map(|_| ())
checked_capture_dimensions(region, true).map(|_| ())
}
impl ResolvedScrollState {
@@ -1428,7 +1438,7 @@ impl PreparedRender {
width: 0.001,
height: 0.001,
};
let mut best: Option<(Vec<i32>, usize, usize, obscura_dom::tree::NodeId)> = None;
let mut best: Option<(Vec<i32>, bool, usize, usize, obscura_dom::tree::NodeId)> = None;
for (order, id) in crate::dom::rendered_descendants(tree, tree.document())
.into_iter()
.enumerate()
@@ -1456,20 +1466,32 @@ impl PreparedRender {
continue;
}
let path = stacking_path(tree, &self.layout, id);
let positioned = style.position.is_some() || style.position_fixed || style.position_sticky;
let depth = tree.ancestors(id).len();
let replace = best
.as_ref()
.is_none_or(|(best_path, best_depth, best_order, _)| {
compare_stacking_paths(&path, best_path)
.is_none_or(|(best_path, best_positioned, best_depth, best_order, best_id)| {
let stacking = compare_stacking_paths(&path, best_path);
if stacking != std::cmp::Ordering::Equal {
return stacking.is_gt();
}
if tree.ancestors(id).contains(best_id) {
return true;
}
if tree.ancestors(*best_id).contains(&id) {
return false;
}
positioned
.cmp(best_positioned)
.then_with(|| depth.cmp(best_depth))
.then_with(|| order.cmp(best_order))
.is_gt()
});
if replace {
best = Some((path, depth, order, id));
best = Some((path, positioned, depth, order, id));
}
}
best.map(|(_, _, _, id)| id)
best.map(|(_, _, _, _, id)| id)
}
/// A compact CSSOM snapshot derived from the same final cascade and
@@ -1482,119 +1504,7 @@ impl PreparedRender {
) -> Option<HashMap<&'static str, String>> {
let style = self.layout.styles.get(&id)?;
let rect = self.layout.rects.get(&id);
let mut out = HashMap::new();
let active_webkit_clamp = style.webkit_box_display.is_some()
&& style.webkit_box_orient_vertical
&& style.webkit_line_clamp.is_some();
let display = if style.display_contents {
"contents"
} else if style.display == crate::Display::None {
"none"
} else if active_webkit_clamp && style.webkit_box_display == Some(false) {
"flow-root"
} else if style.webkit_box_display == Some(false) && !active_webkit_clamp {
"-webkit-box"
} else if style.webkit_box_display == Some(true) && !active_webkit_clamp {
"-webkit-inline-box"
} else if style.internal_flex_container {
"block"
} else {
match (style.display, style.is_inline_block) {
(crate::Display::Flex, true) => "inline-flex",
(crate::Display::Grid, true) => "inline-grid",
(crate::Display::Block, true) => "inline-block",
(crate::Display::Flex, false) => "flex",
(crate::Display::Grid, false) => "grid",
(crate::Display::Inline, true) => "inline-block",
(crate::Display::Inline, false) => "inline",
_ => "block",
}
};
out.insert("display", display.to_string());
out.insert(
"float",
match style.float {
Some(crate::Float::Left) => "left",
Some(crate::Float::Right) => "right",
None => "none",
}
.to_string(),
);
out.insert(
"clear",
match style.clear {
Some(crate::Clear::Left) => "left",
Some(crate::Clear::Right) => "right",
Some(crate::Clear::Both) => "both",
None => "none",
}
.to_string(),
);
out.insert(
"position",
if style.position_fixed {
"fixed"
} else if style.position_sticky {
"sticky"
} else {
match style.position {
Some(taffy::Position::Absolute) => "absolute",
Some(taffy::Position::Relative) => "relative",
_ => "static",
}
}
.to_string(),
);
out.insert(
"z-index",
style
.z_index
.map_or_else(|| "auto".to_string(), |v| v.to_string()),
);
out.insert(
"visibility",
if style.visibility_hidden.unwrap_or(false) {
"hidden"
} else {
"visible"
}
.to_string(),
);
out.insert(
"pointer-events",
if style.pointer_events_none.unwrap_or(false) {
"none"
} else {
"auto"
}
.to_string(),
);
out.insert("opacity", css_number(style.opacity.unwrap_or(1.0)));
out.insert(
"background-color",
css_color(style.background_color.unwrap_or([0, 0, 0, 0])),
);
out.insert(
"background-origin",
match style.background_origin {
crate::BackgroundOrigin::BorderBox => "border-box",
crate::BackgroundOrigin::PaddingBox => "padding-box",
crate::BackgroundOrigin::ContentBox => "content-box",
}
.to_string(),
);
out.insert(
"background-clip",
match style.background_clip {
crate::BackgroundClip::BorderBox => "border-box",
crate::BackgroundClip::PaddingBox => "padding-box",
crate::BackgroundClip::ContentBox => "content-box",
crate::BackgroundClip::Text => "text",
}
.to_string(),
);
out.insert("color", css_color(style.color.unwrap_or([0, 0, 0, 255])));
let mut out = non_geometric_computed_style(style);
out.insert("font-size", css_px(style.font_size.unwrap_or(16.0)));
out.insert(
"font-weight",
@@ -1896,6 +1806,23 @@ impl PreparedRender {
Some(out)
}
pub fn computed_pseudo_content(
&self,
id: obscura_dom::tree::NodeId,
before: bool,
) -> Option<(Option<&str>, &'static str)> {
let style = self.layout.styles.get(&id)?;
let pseudo = if before {
style.before_pseudo.as_deref()
} else {
style.after_pseudo.as_deref()
};
Some((
pseudo.and_then(|style| style.before_content.as_deref()),
pseudo.map_or("none", computed_display),
))
}
/// Cascaded custom properties exposed by CSSOM alongside the compact
/// fixed-property snapshot above. These maps are shared across unchanged
/// inherited subtrees by `DomLayout`, so reading one does not require a
@@ -2089,6 +2016,125 @@ impl PreparedRender {
}
}
pub(crate) fn non_geometric_computed_style(style: &crate::LayoutStyle) -> HashMap<&'static str, String> {
let mut out = HashMap::new();
out.insert("display", computed_display(style).to_string());
out.insert(
"float",
match style.float {
Some(crate::Float::Left) => "left",
Some(crate::Float::Right) => "right",
None => "none",
}
.to_string(),
);
out.insert(
"clear",
match style.clear {
Some(crate::Clear::Left) => "left",
Some(crate::Clear::Right) => "right",
Some(crate::Clear::Both) => "both",
None => "none",
}
.to_string(),
);
out.insert(
"position",
if style.position_fixed {
"fixed"
} else if style.position_sticky {
"sticky"
} else {
match style.position {
Some(taffy::Position::Absolute) => "absolute",
Some(taffy::Position::Relative) => "relative",
_ => "static",
}
}
.to_string(),
);
out.insert(
"z-index",
style
.z_index
.map_or_else(|| "auto".to_string(), |v| v.to_string()),
);
out.insert(
"visibility",
if style.visibility_hidden.unwrap_or(false) {
"hidden"
} else {
"visible"
}
.to_string(),
);
out.insert(
"pointer-events",
if style.pointer_events_none.unwrap_or(false) {
"none"
} else {
"auto"
}
.to_string(),
);
out.insert("opacity", css_number(style.opacity.unwrap_or(1.0)));
out.insert(
"background-color",
css_color(style.background_color.unwrap_or([0, 0, 0, 0])),
);
out.insert(
"background-origin",
match style.background_origin {
crate::BackgroundOrigin::BorderBox => "border-box",
crate::BackgroundOrigin::PaddingBox => "padding-box",
crate::BackgroundOrigin::ContentBox => "content-box",
}
.to_string(),
);
out.insert(
"background-clip",
match style.background_clip {
crate::BackgroundClip::BorderBox => "border-box",
crate::BackgroundClip::PaddingBox => "padding-box",
crate::BackgroundClip::ContentBox => "content-box",
crate::BackgroundClip::Text => "text",
}
.to_string(),
);
out.insert("color", css_color(style.color.unwrap_or([0, 0, 0, 255])));
out
}
fn computed_display(style: &crate::LayoutStyle) -> &'static str {
let active_webkit_clamp = style.webkit_box_display.is_some()
&& style.webkit_box_orient_vertical
&& style.webkit_line_clamp.is_some();
if style.display_contents {
"contents"
} else if style.display == crate::Display::None {
"none"
} else if active_webkit_clamp && style.webkit_box_display == Some(false) {
"flow-root"
} else if style.webkit_box_display == Some(false) && !active_webkit_clamp {
"-webkit-box"
} else if style.webkit_box_display == Some(true) && !active_webkit_clamp {
"-webkit-inline-box"
} else if style.internal_flex_container {
"block"
} else {
match (style.display, style.is_inline_block) {
(crate::Display::Flex, true) => "inline-flex",
(crate::Display::Grid, true) => "inline-grid",
(crate::Display::Block | crate::Display::Inline, true) => "inline-block",
(crate::Display::Flex, false) => "flex",
(crate::Display::Grid, false) => "grid",
(crate::Display::Inline, false) => "inline",
_ => "block",
}
}
}
fn css_number(value: f32) -> String {
if value == 0.0 {
"0".to_string()
@@ -3081,7 +3127,7 @@ fn paint_prepared_region_with_scroll_policy(
canvas_surfaces: &dyn CanvasSurfaceSource,
) -> Result<Pixmap, CaptureError> {
let (native_width, native_height, output_width, output_height) =
checked_capture_dimensions(region)?;
checked_capture_dimensions(region, false)?;
let scale_matches_output =
(output_width as f64 - f64::from(region.width) * f64::from(region.scale)).abs() <= 1.0
&& (output_height as f64 - f64::from(region.height) * f64::from(region.scale)).abs()
@@ -3089,6 +3135,9 @@ fn paint_prepared_region_with_scroll_policy(
let native_scaled = (output_width != native_width || output_height != native_height)
&& scale_matches_output
&& native_raster_scale_supported(tree, &prepared.layout);
if !native_scaled {
checked_capture_dimensions(region, true)?;
}
let (paint_width, paint_height, raster_scale) = if native_scaled {
(output_width, output_height, region.scale)
} else {
@@ -12032,6 +12081,66 @@ mod tests {
assert_eq!(display("#authored-inline-block"), "inline-block");
}
#[test]
fn ancestor_cssom_matches_full_layout_and_rejects_geometry_dependencies() {
let tree = parse_html(r#"<!doctype html><style>
body { --tone:blue; color:var(--tone); pointer-events:none }
body[data-active=yes] { --tone:red }
#flex { display:flex }
#contents { display:contents }
#item { display:inline-flex; background-color:green; opacity:.4 }
#float { float:left; display:inline; position:relative; z-index:3 }
#inherit { display:inherit }
#animated { animation:fade 1s infinite }
@keyframes fade { to { opacity:0 } }
#container { container-type:inline-size }
@container (min-width:1px) { #query { color:green } }
</style><body>
<div id=flex><div id=contents><span id=item>label</span></div></div>
<span id=float>floating</span><div id=inherit>inherited</div>
<input id=hidden type=" HIDDEN "><div id=animated>animated</div>
<div id=container><span id=query>query</span></div>
</body>"#);
let viewport = (400.0, 200.0);
let mut cache = crate::StylesheetCache::default();
let mut resources = RenderResourceCache::default();
prepare_dom_with_dynamic_fonts_and_stylesheet_cache(
&tree, viewport, None, &mut resources, &[], &mut cache,
).unwrap();
let body = tree.query_selector("body").unwrap().unwrap();
for active in ["yes", "no"] {
tree.with_node_mut(body, |node| node.set_attribute("data-active", active.into()));
let full = prepare_dom(&tree, viewport, None, &mut resources).unwrap();
for name in ["flex", "contents", "item", "float"] {
let id = tree.get_element_by_id(name).unwrap();
let fast = crate::dom::computed_style_without_layout(
&tree, id, viewport, crate::CssMediaType::Screen, &cache, "color",
).expect("ordinary ancestors need no layout");
let expected = full.computed_style(id).unwrap();
for (property, value) in fast {
assert_eq!(value, expected[property], "{name}.{property} with {active}");
}
}
for name in ["inherit", "hidden", "animated", "query"] {
assert!(crate::dom::computed_style_without_layout(
&tree, tree.get_element_by_id(name).unwrap(), viewport,
crate::CssMediaType::Screen, &cache, "display",
).is_none(), "{name} must use full normalization");
}
}
let item = tree.get_element_by_id("item").unwrap();
assert!(crate::dom::computed_style_without_layout(
&tree, item, viewport, crate::CssMediaType::Screen, &cache, "width",
).is_none());
let source_bytes = cache.retained_source_bytes();
let style = tree.query_selector("style").unwrap().unwrap();
tree.append_text(style, "#item { color:purple }");
assert!(crate::dom::computed_style_without_layout(
&tree, item, viewport, crate::CssMediaType::Screen, &cache, "color",
).is_none(), "stylesheet changes must invalidate retained styles through the normal path");
assert_eq!(cache.retained_source_bytes(), source_bytes);
}
#[test]
fn native_placeholders_honor_default_author_color_opacity_and_value_state() {
let tree = parse_html(
+32 -7
View File
@@ -505,6 +505,33 @@ pub(crate) fn split_declarations(css: &str) -> Vec<&str> {
parts
}
fn parse_containment(value: &str) -> Option<u8> {
use crate::{CONTAIN_SIZE, CONTAIN_INLINE_SIZE, CONTAIN_LAYOUT, CONTAIN_STYLE, CONTAIN_PAINT};
let value = value.trim().to_ascii_lowercase();
match value.as_str() {
"none" | "initial" | "unset" | "revert" | "revert-layer" => return Some(0),
"inherit" => return Some(crate::CONTAIN_INHERIT),
"strict" => return Some(CONTAIN_SIZE | CONTAIN_LAYOUT | CONTAIN_STYLE | CONTAIN_PAINT),
"content" => return Some(CONTAIN_LAYOUT | CONTAIN_STYLE | CONTAIN_PAINT),
_ => {}
}
let mut flags = 0;
for token in value.split_ascii_whitespace() {
let flag = match token {
"size" => CONTAIN_SIZE,
"inline-size" => CONTAIN_INLINE_SIZE,
"layout" => CONTAIN_LAYOUT,
"style" => CONTAIN_STYLE,
"paint" => CONTAIN_PAINT,
_ => return None,
};
if flags & flag != 0 { return None; }
flags |= flag;
}
(flags != 0 && flags & (CONTAIN_SIZE | CONTAIN_INLINE_SIZE)
!= (CONTAIN_SIZE | CONTAIN_INLINE_SIZE)).then_some(flags)
}
fn parse_container_type(value: &str) -> Option<crate::ContainerType> {
match value.trim().to_ascii_lowercase().as_str() {
"normal" => Some(crate::ContainerType::Normal),
@@ -1862,13 +1889,11 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) {
non_none_value(value),
),
"contain" => {
let establishes = value.split_whitespace().any(|v| {
matches!(
v.to_ascii_lowercase().as_str(),
"layout" | "paint" | "strict" | "content"
)
});
set_containing_block_trigger(style, crate::CB_TRIGGER_CONTAIN, establishes);
if let Some(flags) = parse_containment(value) {
style.containment = flags;
set_containing_block_trigger(style, crate::CB_TRIGGER_CONTAIN,
flags & (crate::CONTAIN_LAYOUT | crate::CONTAIN_PAINT) != 0);
}
}
"will-change" => {
let establishes = value.split([',', ' ']).map(str::trim).any(|v| {
@@ -0,0 +1,52 @@
use std::collections::HashSet;
use obscura_dom::parse_html;
use obscura_render::layout_dom;
#[path = "../../test-support/allocations.rs"]
mod allocations;
#[test]
fn fixed_coordinate_walk_does_not_copy_dom_payloads() {
let payload = "x".repeat(64 * 1024);
let tree = parse_html(&format!(
"<main data-payload='{payload}'>\
<div id=fixed style='position:fixed' data-payload='{payload}'>\
<b id=child data-payload='{payload}'></b></div>\
<details data-payload='{payload}'><summary data-payload='{payload}'>closed</summary>\
<span id=hidden style='position:fixed'></span></details></main>"
));
let fixed = tree.get_element_by_id("fixed").unwrap();
let child = tree.get_element_by_id("child").unwrap();
let laid = layout_dom(&tree, (400.0, 300.0));
let (actual, bytes) = allocations::measure(|| laid.viewport_fixed_nodes(&tree));
assert_eq!(actual, HashSet::from([fixed, child]),
"fixed descendants share viewport coordinates; closed details content does not render");
assert!(bytes < 16 * 1024, "fixed-coordinate traversal allocated {bytes} bytes");
}
#[test]
fn layout_reads_do_not_copy_element_payloads() {
let make_tree = |payload: &str| {
parse_html(&format!(
"<!doctype html><style>body{{margin:0}}\
[data-payload^=x]{{height:10px}}</style>{}",
format!("<div data-payload='{payload}'></div>").repeat(32),
))
};
let small = make_tree("x");
let large = make_tree(&"x".repeat(64 * 1024));
// Warm the shared font database before measuring allocation traffic.
let _ = layout_dom(&small, (400.0, 600.0));
let (small_layout, small_bytes) = allocations::measure(|| layout_dom(&small, (400.0, 600.0)));
let (large_layout, large_bytes) = allocations::measure(|| layout_dom(&large, (400.0, 600.0)));
eprintln!("layout allocation bytes: small={small_bytes}, large={large_bytes}");
for (tree, laid) in [(&small, small_layout), (&large, large_layout)] {
let nodes = tree.query_selector_all("div").unwrap();
assert_eq!(nodes.len(), 32);
assert_eq!(laid.rects[&nodes[31]].y, 310.0);
assert_eq!(laid.rects[&nodes[31]].height, 10.0);
}
assert!(large_bytes < small_bytes + 128 * 1024,
"read-only layout copied payloads: small={small_bytes}, large={large_bytes}");
}
+43
View File
@@ -0,0 +1,43 @@
// Test-only allocation traffic, scoped to the calling test thread.
use std::alloc::{GlobalAlloc, Layout, System};
use std::cell::Cell;
thread_local! {
static ALLOCATED: Cell<Option<usize>> = const { Cell::new(None) };
}
struct CountAllocations;
fn record(bytes: usize) {
let _ = ALLOCATED.try_with(|count| {
if let Some(current) = count.get() {
count.set(Some(current + bytes));
}
});
}
unsafe impl GlobalAlloc for CountAllocations {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
record(layout.size());
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) }
}
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, size: usize) -> *mut u8 {
record(size);
unsafe { System.realloc(ptr, layout, size) }
}
}
#[global_allocator]
static ALLOCATOR: CountAllocations = CountAllocations;
pub fn measure<T>(f: impl FnOnce() -> T) -> (T, usize) {
ALLOCATED.with(|count| count.set(Some(0)));
let result = f();
let bytes = ALLOCATED.with(|count| count.replace(None).unwrap());
(result, bytes)
}
+16
View File
@@ -1,4 +1,20 @@
{
"sparse-grid-scroll": [
{"name": "last contents-wrapped cell reaches scrollport bottom", "color": "#087f5b", "x": 20, "y": 90, "width": 200, "height": 30},
{"name": "empty tracks retain a full scroll viewport", "color": "#7048e8", "x": 260, "y": 20, "width": 200, "height": 100},
{"name": "first cells scroll out of view", "color": "#e8590c", "count": 0}
],
"contained-flex-grid": [
{"name": "header retains its fixed height", "color": "#e8590c", "x": 1, "y": 1, "height": 40},
{"name": "grid viewport uses post-flex percentage height", "color": "#1971c2", "x": 9, "y": 41, "height": 550},
{"name": "following flow remains outside the panel", "color": "#7048e8", "x": 0, "y": 600, "width": 400, "height": 20}
],
"cssom-rule-ownership": [
{"name": "inserted and edited rule applies", "color": "#166534", "x": 20, "y": 20, "width": 91, "height": 30},
{"name": "same-value source data resets cascade", "color": "#166534", "x": 20, "y": 60, "width": 17, "height": 30},
{"name": "shadow CSSOM rule applies", "color": "#166534", "x": 20, "y": 100, "width": 43, "height": 30},
{"name": "source ownership and geometry contract passes", "color": "#166534", "x": 20, "y": 140, "width": 120, "height": 30}
],
"abs-fill": [
{"name": "blue fill", "color": "#0000ff", "width": 400, "height": 100},
{"name": "teal fill", "color": "#008080", "width": 400, "height": 100},
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<meta charset="utf-8">
<style>
html, body { margin: 0; }
#panel { display: flex; flex-direction: column; width: 400px; height: 600px;
box-sizing: border-box; border: 1px solid; }
#header { height: 40px; flex-shrink: 0; background: #e8590c; }
#content { flex: 1 1 auto; contain: size layout; padding: 0 8px 8px; }
#wrapper { height: 100%; }
#grid { display: grid; height: 100%; overflow: auto; background: #1971c2;
grid-template-rows: 34px repeat(598, 36px); }
#after { width: 400px; height: 20px; background: #7048e8; }
</style>
<div id="panel"><div id="header"></div><div id="content">
<div id="wrapper"><div id="grid"><div id="cell">Visible text</div></div></div>
</div></div>
<div id="after"></div>
+38
View File
@@ -0,0 +1,38 @@
<!doctype html>
<meta charset="utf-8">
<title>CSSOM rule ownership and synchronous rendering</title>
<style>
body { margin: 20px; background: white; }
.box { height: 30px; margin-bottom: 10px; background: #166534; }
#status { width: 120px; height: 30px; background: #b91c1c; }
</style>
<style id="rules">#inserted, #reset { width: 11px }</style>
<div class="box" id="inserted"></div>
<div class="box" id="reset"></div>
<div id="host"></div>
<div id="status"></div>
<script>
const owner = document.getElementById('rules');
const source = owner.textContent;
const sourceNode = owner.firstChild;
const sheet = owner.sheet;
for (let i = 0; i < 512; i++) sheet.insertRule(`.unused${i} { width: ${i}px }`, sheet.cssRules.length);
sheet.insertRule('#inserted { width: 73px }', sheet.cssRules.length);
sheet.cssRules[sheet.cssRules.length - 1].style.width = '91px';
const insertedWidth = document.getElementById('inserted').getBoundingClientRect().width;
const sourcePreserved = owner.textContent === source && owner.firstChild === sourceNode;
const resetStyle = document.createElement('style');
resetStyle.textContent = '#reset { width: 17px }';
document.head.append(resetStyle);
resetStyle.sheet.insertRule('#reset { width: 61px }', 1);
const changedWidth = document.getElementById('reset').getBoundingClientRect().width;
resetStyle.firstChild.data = resetStyle.firstChild.data;
const resetWidth = document.getElementById('reset').getBoundingClientRect().width;
const shadow = document.getElementById('host').attachShadow({mode: 'open'});
shadow.innerHTML = '<style>.box { width: 13px; height: 30px; margin-bottom: 10px; background: #166534 }</style><div class="box"></div>';
shadow.querySelector('style').sheet.insertRule('.box { width: 43px }', 1);
const shadowWidth = shadow.querySelector('.box').getBoundingClientRect().width;
const passed = sourcePreserved && insertedWidth === 91 && changedWidth === 61 && resetWidth === 17 && shadowWidth === 43;
document.getElementById('status').style.background = passed ? '#166534' : '#b91c1c';
document.documentElement.dataset.result = JSON.stringify({passed, sourcePreserved, insertedWidth, changedWidth, resetWidth, shadowWidth});
</script>
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<meta charset="utf-8">
<style>
html, body { margin:0; }
.grid { position:absolute;top:20px;left:20px;display:grid;width:200px;height:100px;
overflow:auto;grid-template-rows:repeat(10,30px);background:#1971c2; }
#empty { left:260px;background:#7048e8; }
.first { grid-row:1;background:#e8590c; }
.last { grid-row:10;background:#087f5b; }
</style>
<div id="wrapped" class="grid"><div style="display:contents">
<div class="first"></div><div class="last"></div>
</div></div>
<div id="empty" class="grid"><div class="first"></div></div>
<script>
addEventListener('load', () => {
document.getElementById('wrapped').scrollTop = 9999;
document.getElementById('empty').scrollTop = 9999;
});
</script>
+5
View File
@@ -883,6 +883,8 @@ pub struct DetailedGridInfo {
#[derive(Debug, Clone, PartialEq)]
#[cfg(feature = "detailed_layout_info")]
pub struct DetailedGridTracksInfo {
/// Farthest track edge from the container's border-box start, after alignment.
pub end_offset: f32,
/// Number of leading implicit grid tracks
pub negative_implicit_tracks: u16,
/// Number of explicit grid tracks
@@ -923,6 +925,9 @@ impl DetailedGridTracksInfo {
/// Construct DetailedGridTracksInfo from TrackCounts and GridTracks
fn from_grid_tracks_and_track_count(track_count: TrackCounts, grid_tracks: Vec<GridTrack>) -> Self {
DetailedGridTracksInfo {
end_offset: grid_tracks.iter()
.filter(|track| track.kind == GridTrackKind::Track && !track.is_collapsed)
.fold(0.0_f32, |end, track| end.max(track.offset + track.base_size)),
negative_implicit_tracks: track_count.negative_implicit,
explicit_tracks: track_count.explicit,
positive_implicit_tracks: track_count.positive_implicit,
+6
View File
@@ -327,6 +327,12 @@ where
(Display::Flex, true) => compute_flexbox_layout(tree, node_id, inputs),
#[cfg(feature = "grid")]
(Display::Grid, true) => compute_grid_layout(tree, node_id, inputs),
#[cfg(feature = "grid")]
(Display::Grid, false) if {
let node = &tree.taffy.nodes[node_id.into()];
!node.has_context && (!node.style.grid_template_rows.is_empty()
|| !node.style.grid_template_columns.is_empty())
} => compute_grid_layout(tree, node_id, inputs),
(_, false) => {
let node_key = node_id.into();
let style = &tree.taffy.nodes[node_key].style;