mirror of
https://github.com/aphrody-labs/obscura.git
synced 2026-10-13 21:04:57 +00:00
Improve Playwright compatibility and engine performance
This commit is contained in:
1 parent
eb4ed1727d
commit
542df14693
40 files changed
+4757
-1017
No files matched your search
@@ -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
@@ -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",
|
||||
|
||||
@@ -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(¤t_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(¤t_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(¤t_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(¤t_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")]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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", ®istration, &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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
Vendored
+167
-53
@@ -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
@@ -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
|
||||
|
||||
+801
-187
File diff suppressed because it is too large.
Load diff
@@ -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));
|
||||
|
||||
+697
-176
File diff suppressed because it is too large.
Load diff
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+5
@@ -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,
|
||||
|
||||
Vendored
+6
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user