Skip to main content

allwright_surface_web/
frames.rs

1use super::*;
2
3fn frame_expression(selector: &str) -> Result<String, String> {
4    let segments = selector_segments_literal("frame", selector)?;
5    let query = selector_chain_query_all_js();
6    Ok(format!(
7        r#"(() => {{
8        const selectorSegments = {segments};
9        const elements = {query};
10        if (elements.length !== 1) throw new Error('Frame requires exactly one element; found ' + elements.length);
11        const element = elements[0];
12        if (!['iframe', 'frame'].includes(element.localName)) throw new Error('Frame locator must resolve to an iframe');
13        if (!element.isConnected || !element.contentWindow) throw new Error('Frame is detached or not ready');
14        const initialDocument = element.contentDocument;
15        const src = (element.getAttribute('src') || '').trim();
16        if (initialDocument && initialDocument.URL === 'about:blank' &&
17            (element.hasAttribute('srcdoc') || (src && !src.startsWith('javascript:') && new URL(src, document.baseURI).href !== 'about:blank'))) {{
18            throw new Error('Frame initial navigation is pending');
19        }}
20        return element.contentWindow;
21    }})()"#
22    ))
23}
24
25// Each attempt is bounded; the engine retries under the caller's overall deadline.
26// A loaded document must remain free of DOM mutations for 200ms.
27const READY: &str = r#"new Promise(resolve => {
28    if (document.readyState !== 'complete') { resolve(false); return; }
29    let quiet;
30    const finish = value => { clearTimeout(quiet); clearTimeout(limit); observer.disconnect(); resolve(value); };
31    const arm = () => { clearTimeout(quiet); quiet = setTimeout(() => finish(document.readyState === 'complete'), 200); };
32    const observer = new MutationObserver(arm);
33    const limit = setTimeout(() => finish(false), 500);
34    observer.observe(document, {subtree:true, childList:true, attributes:true, characterData:true});
35    arm();
36})"#;
37
38fn frame_context(response: &Value) -> Result<String, String> {
39    if response
40        .pointer("/result/result/type")
41        .and_then(Value::as_str)
42        != Some("window")
43    {
44        return Err(format!(
45            "cannot resolve frame: {}",
46            response
47                .pointer("/result/exceptionDetails")
48                .unwrap_or(response)
49        ));
50    }
51    required_string(response, "/result/result/value/context")
52}
53fn ready(response: &Value) -> Result<(), String> {
54    if response
55        .pointer("/result/result/value")
56        .and_then(Value::as_bool)
57        == Some(true)
58    {
59        Ok(())
60    } else {
61        Err("frame document is still loading or changing".to_string())
62    }
63}
64
65pub async fn resolve_frame(
66    browser: &BrowserSessionHandle,
67    page: &PageSessionHandle,
68    selector: &str,
69) -> Result<PageInfo, String> {
70    let expression = frame_expression(selector)?;
71    let params = |context: &str, expression: &str| json!({"expression":expression,"target":{"context":context},"awaitPromise":true});
72    let page_session = match (browser, page) {
73        (
74            BrowserSessionHandle::Chromium { cdp_websocket_url },
75            PageSessionHandle::Chromium {
76                target_id,
77                browsing_context_id,
78                mapper_target_id,
79            },
80        ) => {
81            let (mut cdp, mapper, parent) = chromium_bidi_session(
82                cdp_websocket_url,
83                mapper_target_id.as_deref(),
84                Some(browsing_context_id.as_deref().unwrap_or(target_id)),
85            )
86            .await?;
87            let response = cdp.send_bidi_command(&mapper.mapper_session_id, &json!({"id":7100,"method":"script.evaluate","params":params(&parent, &expression)})).await?;
88            let context = frame_context(&response)?;
89            ready(
90                &cdp.send_bidi_command(
91                    &mapper.mapper_session_id,
92                    &json!({"id":7101,"method":"script.evaluate","params":params(&context, READY)}),
93                )
94                .await?,
95            )?;
96            let current = cdp.send_bidi_command(&mapper.mapper_session_id, &json!({"id":7102,"method":"script.evaluate","params":params(&parent, &expression)})).await?;
97            if frame_context(&current)? != context {
98                return Err("frame was replaced while waiting".into());
99            }
100            PageSessionHandle::Chromium {
101                target_id: context.clone(),
102                browsing_context_id: Some(context),
103                mapper_target_id: Some(mapper.mapper_target_id),
104            }
105        }
106        (
107            BrowserSessionHandle::Firefox { connection_id, .. },
108            PageSessionHandle::Firefox {
109                browsing_context_id,
110            },
111        ) => {
112            let mut sessions = firefox_session_guard(connection_id).await?;
113            let bidi = &mut sessions.get_mut(connection_id).unwrap().connection;
114            let context = frame_context(
115                &bidi
116                    .send_command("script.evaluate", params(browsing_context_id, &expression))
117                    .await?,
118            )?;
119            bidi.frame_contexts.insert(context.clone());
120            ready(
121                &bidi
122                    .send_command("script.evaluate", params(&context, READY))
123                    .await?,
124            )?;
125            let current = bidi
126                .send_command("script.evaluate", params(browsing_context_id, &expression))
127                .await?;
128            if frame_context(&current)? != context {
129                return Err("frame was replaced while waiting".into());
130            }
131            PageSessionHandle::Firefox {
132                browsing_context_id: context,
133            }
134        }
135        _ => return Err("frame browser and page backend mismatch".into()),
136    };
137    Ok(PageInfo {
138        note: "resolved stable frame via WebDriver BiDi".into(),
139        page_session,
140    })
141}
142
143// getTree without a root lists active documents, excluding cached frame realms.
144pub(super) fn tree_contains_context(tree: &Value, context: &str) -> bool {
145    fn contains(nodes: &[Value], context: &str) -> bool {
146        nodes.iter().any(|node| {
147            node.get("context").and_then(Value::as_str) == Some(context)
148                || node
149                    .get("children")
150                    .and_then(Value::as_array)
151                    .is_some_and(|children| contains(children, context))
152        })
153    }
154    tree.pointer("/result/contexts")
155        .and_then(Value::as_array)
156        .is_some_and(|nodes| contains(nodes, context))
157}