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