Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 27 additions & 13 deletions crates/browser/src/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ use crate::{
error::Error,
pool::BrowserPool,
snapshot::{
extract_snapshot, find_element_by_ref, focus_element_by_ref, scroll_element_into_view,
DEEP_COLLECT_FN, DEEP_FIND_FN, extract_snapshot, find_element_by_ref, focus_element_by_ref,
scroll_element_into_view,
},
types::{
BrowserAction, BrowserConfig, BrowserKind, BrowserPreference, BrowserRequest,
Expand Down Expand Up @@ -578,13 +579,14 @@ impl BrowserManager {
let page = self.pool.get_page(&sid).await?;

let js = if let Some(ref_) = ref_ {
format!(
let body = format!(
r#"(() => {{
const el = document.querySelector(`[data-moltis-ref="{ref_}"]`);
const el = __mDeepFind(`[data-moltis-ref="{ref_}"]`);
if (el) el.scrollBy({x}, {y});
return !!el;
}})()"#
)
);
format!("{DEEP_FIND_FN}{body}")
} else {
format!("window.scrollBy({x}, {y}); true")
};
Expand Down Expand Up @@ -639,11 +641,11 @@ impl BrowserManager {

let check_js = if let Some(ref selector) = selector {
format!(
r#"document.querySelector({}) !== null"#,
r#"{DEEP_FIND_FN}__mDeepFind({}) !== null"#,
serde_json::to_string(selector).map_err(|e| Error::Cdp(e.to_string()))?
)
} else if let Some(ref_) = ref_ {
format!(r#"document.querySelector('[data-moltis-ref="{ref_}"]') !== null"#)
format!(r#"{DEEP_FIND_FN}__mDeepFind('[data-moltis-ref="{ref_}"]') !== null"#)
} else {
return Err(Error::InvalidAction("wait requires selector or ref".into()));
};
Expand Down Expand Up @@ -797,15 +799,16 @@ impl BrowserManager {

/// Highlight an element (for screenshots).
async fn highlight_element(&self, page: &Page, ref_: u32) -> Result<(), Error> {
let js = format!(
let body = format!(
r#"(() => {{
const el = document.querySelector(`[data-moltis-ref="{ref_}"]`);
const el = __mDeepFind(`[data-moltis-ref="{ref_}"]`);
if (el) {{
el.style.outline = '3px solid #ff0000';
el.style.outlineOffset = '2px';
}}
}})()"#
);
let js = format!("{DEEP_FIND_FN}{body}");

page.evaluate(js.as_str())
.await
Expand All @@ -816,14 +819,14 @@ impl BrowserManager {

/// Remove all element highlights.
async fn remove_highlights(&self, page: &Page) -> Result<(), Error> {
let js = r#"
document.querySelectorAll('[data-moltis-ref]').forEach(el => {
let js = format!(
r#"{DEEP_COLLECT_FN}__mDeepCollect('[data-moltis-ref]').forEach(el => {{
el.style.outline = '';
el.style.outlineOffset = '';
});
"#;
}});"#
);

page.evaluate(js)
page.evaluate(js.as_str())
.await
.map_err(|e| Error::JsEvalFailed(e.to_string()))?;

Expand Down Expand Up @@ -984,6 +987,17 @@ mod tests {
assert!(prefix.ends_with('a'));
}

#[test]
fn wait_selector_path_uses_shadow_piercing_lookup() {
let source = include_str!("manager.rs");

let flat_query = concat!("document.", "querySelector({}) !== null");
let deep_query = concat!("{DEEP_FIND_FN}", "__mDeepFind({}) !== null");

assert!(!source.contains(flat_query));
assert!(source.contains(deep_query));
}

#[tokio::test]
async fn manager_close_session_nonexistent_is_noop() {
let manager = BrowserManager::default();
Expand Down
96 changes: 87 additions & 9 deletions crates/browser/src/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,50 @@ use crate::{
types::{DomSnapshot, ElementBounds, ElementRef, ScrollDimensions, ViewportSize},
};

/// JavaScript to extract interactive elements from the DOM.
/// JS helper defining `__mDeepFind(sel)` — like `document.querySelector` but it
/// also descends into open shadow roots, so elements rendered inside web
/// components (e.g. Salesforce Lightning login fields) are reachable. Closed
/// shadow roots (`mode: 'closed'`) cannot be pierced from page script and are
/// skipped. Prepend to any snippet that resolves an element by ref.
pub(crate) const DEEP_FIND_FN: &str = r#"
window.__mDeepFind = window.__mDeepFind || ((sel) => {
const visit = (root) => {
let el = null;
try { el = root.querySelector(sel); } catch (e) { el = null; }
if (el) return el;
let all = [];
try { all = root.querySelectorAll('*'); } catch (e) { all = []; }
for (const h of all) {
if (h.shadowRoot) { const f = visit(h.shadowRoot); if (f) return f; }
}
return null;
};
return visit(document);
});
"#;
Comment thread
s-salamatov marked this conversation as resolved.

/// JS helper defining `__mDeepCollect(sel)` — like `document.querySelectorAll`
/// but also descends into open shadow roots. Returns a flat array of matches.
pub(crate) const DEEP_COLLECT_FN: &str = r#"
window.__mDeepCollect = window.__mDeepCollect || ((sel) => {
const out = [];
const visit = (root) => {
let matches = [];
try { matches = root.querySelectorAll('*'); } catch (e) { matches = []; }
for (const el of matches) {
let isMatch = false;
try { isMatch = el.matches(sel); } catch (e) { isMatch = false; }
if (isMatch) out.push(el);
if (el.shadowRoot) visit(el.shadowRoot);
}
};
visit(document);
return out;
});
"#;

/// JavaScript to extract interactive elements from the DOM (including those
/// inside open shadow roots, via `__mDeepCollect`).
const EXTRACT_ELEMENTS_JS: &str = r#"
(() => {
const interactive = [
Expand All @@ -25,7 +68,7 @@ const EXTRACT_ELEMENTS_JS: &str = r#"
];

const selector = interactive.join(', ');
const elements = document.querySelectorAll(selector);
const elements = __mDeepCollect(selector);
const results = [];

function isVisible(el) {
Expand Down Expand Up @@ -145,7 +188,7 @@ const EXTRACT_ELEMENTS_JS: &str = r#"
/// JavaScript to find an element by its ref number.
const FIND_BY_REF_JS: &str = r#"
((ref) => {
const el = document.querySelector(`[data-moltis-ref="${ref}"]`);
const el = __mDeepFind(`[data-moltis-ref="${ref}"]`);
if (!el) return null;
const rect = el.getBoundingClientRect();
return {
Expand All @@ -171,8 +214,9 @@ pub async fn extract_snapshot(page: &Page) -> Result<DomSnapshot, Error> {
.map_err(|e| Error::Cdp(e.to_string()))?
.unwrap_or_default();

let extract_js = format!("{DEEP_COLLECT_FN}{EXTRACT_ELEMENTS_JS}");
let result: Value = page
.evaluate(EXTRACT_ELEMENTS_JS)
.evaluate(extract_js.as_str())
.await
.map_err(|e| Error::JsEvalFailed(e.to_string()))?
.into_value()
Expand Down Expand Up @@ -206,7 +250,7 @@ pub async fn extract_snapshot(page: &Page) -> Result<DomSnapshot, Error> {

/// Find an element's center coordinates by its ref number.
pub async fn find_element_by_ref(page: &Page, ref_: u32) -> Result<(f64, f64), Error> {
let js = format!("({FIND_BY_REF_JS})({ref_})");
let js = format!("{DEEP_FIND_FN}({FIND_BY_REF_JS})({ref_})");

let result: Value = page
.evaluate(js.as_str())
Expand All @@ -231,14 +275,15 @@ pub async fn find_element_by_ref(page: &Page, ref_: u32) -> Result<(f64, f64), E

/// Focus an input element by its ref number.
pub async fn focus_element_by_ref(page: &Page, ref_: u32) -> Result<(), Error> {
let js = format!(
let body = format!(
r#"(() => {{
const el = document.querySelector(`[data-moltis-ref="{ref_}"]`);
const el = __mDeepFind(`[data-moltis-ref="{ref_}"]`);
if (!el) return false;
el.focus();
return true;
}})()"#
);
let js = format!("{DEEP_FIND_FN}{body}");

let result: Value = page
.evaluate(js.as_str())
Expand All @@ -256,14 +301,15 @@ pub async fn focus_element_by_ref(page: &Page, ref_: u32) -> Result<(), Error> {

/// Scroll an element into view by its ref number.
pub async fn scroll_element_into_view(page: &Page, ref_: u32) -> Result<(), Error> {
let js = format!(
let body = format!(
r#"(() => {{
const el = document.querySelector(`[data-moltis-ref="{ref_}"]`);
const el = __mDeepFind(`[data-moltis-ref="{ref_}"]`);
if (!el) return false;
el.scrollIntoView({{ behavior: 'instant', block: 'center' }});
return true;
}})()"#
);
let js = format!("{DEEP_FIND_FN}{body}");

let result: Value = page
.evaluate(js.as_str())
Expand Down Expand Up @@ -336,6 +382,38 @@ fn parse_scroll(result: &Value) -> Result<ScrollDimensions, Error> {
mod tests {
use super::*;

#[test]
fn deep_helpers_traverse_shadow_roots_and_are_idempotent() {
// Must recurse into open shadow roots so elements inside web components
// (e.g. Salesforce Lightning) are reachable.
assert!(DEEP_FIND_FN.contains("shadowRoot"));
assert!(DEEP_COLLECT_FN.contains("shadowRoot"));
// Defined via `window.x = window.x || (...)` so repeated injection into
// the global eval scope does not throw a redeclaration error.
assert!(DEEP_FIND_FN.contains("window.__mDeepFind = window.__mDeepFind ||"));
assert!(DEEP_COLLECT_FN.contains("window.__mDeepCollect = window.__mDeepCollect ||"));
}

#[test]
fn deep_collect_uses_one_dom_walk_for_matches_and_shadow_hosts() {
assert_eq!(
DEEP_COLLECT_FN.matches("querySelectorAll(").count(),
1,
"deep collection should not walk each root twice"
);
assert!(DEEP_COLLECT_FN.contains("el.matches(sel)"));
}

#[test]
fn snapshot_js_uses_shadow_piercing_helpers_not_flat_queries() {
// Element collection must go through the deep collector.
assert!(EXTRACT_ELEMENTS_JS.contains("__mDeepCollect(selector)"));
assert!(!EXTRACT_ELEMENTS_JS.contains("document.querySelectorAll(selector)"));
// Ref resolution must go through the deep finder.
assert!(FIND_BY_REF_JS.contains("__mDeepFind("));
assert!(!FIND_BY_REF_JS.contains("document.querySelector("));
}

#[test]
fn test_parse_elements_empty() {
let result = serde_json::json!({
Expand Down