Skip to main content

ui_automata/
tab_handle.rs

1use crate::{AutomataError, Browser, SelectorPath, registry::AnchorDef};
2
3/// State for a mounted Tab anchor.
4#[derive(Debug, Clone)]
5pub struct TabHandle {
6    /// CDP target ID for this tab.
7    pub tab_id: String,
8    /// Name of the parent Browser anchor.
9    pub parent_browser: String,
10    /// True if this tab was opened by the workflow (closed on unmount).
11    /// False if this tab was attached to an existing tab (left open on unmount).
12    pub created: bool,
13    /// Subflow depth when this tab was mounted (mirrors AnchorDef::mount_depth).
14    pub depth: usize,
15}
16
17impl TabHandle {
18    /// Open or attach to a CDP tab as described by `def`.
19    ///
20    /// - If `def.url` is set: opens a new tab, navigates to the URL, and waits
21    ///   for `document.readyState === 'complete'` (up to 30 s). `created = true`.
22    /// - Otherwise: polls `browser.tabs()` until one matches `def.selector`
23    ///   (up to 30 s). `created = false` — the tab is left open on unmount.
24    pub fn mount(def: &AnchorDef, browser: &impl Browser) -> Result<Self, AutomataError> {
25        let parent_browser = def.parent.clone().ok_or_else(|| {
26            AutomataError::Internal(format!("Tab anchor '{}' has no parent", def.name))
27        })?;
28
29        let (tab_id, created) = if def.selector.is_wildcard() {
30            // No selector — open/inherit a blank tab.
31            // Navigation is done separately via a BrowserNavigate action.
32            let tabs = browser
33                .tabs()
34                .map_err(|e| AutomataError::Internal(format!("browser.tabs(): {e}")))?;
35            let new_tab = tabs.into_iter().find(|(_, info)| {
36                info.url == "edge://newtab/" || info.url == "about:blank" || info.url.is_empty()
37            });
38            if let Some((existing_id, _)) = new_tab {
39                // Treat inherited blank/new tabs as created — they exist only
40                // for this workflow and should be closed on unmount.
41                (existing_id, true)
42            } else {
43                let tab_id = browser
44                    .open_tab(None)
45                    .map_err(|e| AutomataError::Internal(format!("open_tab(blank): {e}")))?;
46                (tab_id, true)
47            }
48        } else {
49            // Attach mode: poll browser.tabs() until one matches the selector.
50            let selector = def.selector.clone();
51            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
52            let tab_id = loop {
53                let tabs = browser
54                    .tabs()
55                    .map_err(|e| AutomataError::Internal(format!("browser.tabs(): {e}")))?;
56                if let Some((id, _)) = tabs
57                    .into_iter()
58                    .find(|(_, info)| selector.matches_tab_info(&info.title, &info.url))
59                {
60                    break id;
61                }
62                if std::time::Instant::now() >= deadline {
63                    return Err(AutomataError::Internal(format!(
64                        "Tab '{}': timed out waiting for tab matching '{selector}'",
65                        def.name
66                    )));
67                }
68                std::thread::sleep(std::time::Duration::from_millis(200));
69            };
70            (tab_id, false)
71        };
72
73        // Bring this tab to the foreground so UIA exposes its accessibility tree.
74        if let Err(e) = browser.activate_tab(&tab_id) {
75            log::warn!("activate_tab('{}') failed: {e}", tab_id);
76        }
77
78        log::debug!(
79            "mounted tab '{}' (tab_id={tab_id}, created={created})",
80            def.name
81        );
82        Ok(Self {
83            tab_id,
84            parent_browser,
85            created,
86            depth: def.mount_depth,
87        })
88    }
89
90    /// Close this tab via `browser` if the workflow created it; no-op if attached.
91    pub fn close_if_created(&self, browser: &impl Browser) {
92        if self.created {
93            if let Err(e) = browser.close_tab(&self.tab_id) {
94                log::warn!("close_tab('{}') failed: {e}", self.tab_id);
95            }
96        }
97    }
98
99    /// Build a document-scoped selector for this tab, verifying it is focused first.
100    ///
101    /// Checks `document.hasFocus()` via CDP and returns an error if the tab is not
102    /// the active foreground tab — prevents silently querying the wrong document.
103    ///
104    /// Returns `(parent_browser_name, scoped_selector)` so the caller can
105    /// delegate `find_descendant` to the parent Browser anchor.
106    pub fn scoped_selector(
107        &self,
108        browser: &impl Browser,
109        selector: &SelectorPath,
110    ) -> Result<(String, SelectorPath), AutomataError> {
111        // Guard: verify this tab is the active (visible) one before building a
112        // UIA selector.
113        let visibility = browser
114            .eval(&self.tab_id, "document.visibilityState")
115            .unwrap_or_default();
116        if visibility != "visible" {
117            return Err(AutomataError::Internal(format!(
118                "tab '{}' is not the active tab (visibilityState={:?})",
119                self.tab_id, visibility
120            )));
121        }
122        let doc_sel_str = format!(">> [id=RootWebArea] >> {selector}");
123        let doc_sel = SelectorPath::parse(&doc_sel_str)
124            .map_err(|e| AutomataError::Internal(format!("tab selector build: {e}")))?;
125        Ok((self.parent_browser.clone(), doc_sel))
126    }
127}