Skip to main content

allwright_surface_web/
accessibility.rs

1//! DOM accessibility collection and serialization belong to the web plugin.
2use super::*;
3use allwright_plugin_sdk::AccessibilitySnapshotInfo;
4
5const ACCESSIBILITY_SEMANTICS: &str = include_str!("accessibility_semantics.js");
6const COLLECTOR: &str = include_str!("accessibility.js");
7
8fn expression(mode: &str) -> String {
9    let options = json!({"mode": mode}).to_string();
10    format!(
11        "(() => {{ {ACCESSIBILITY_SEMANTICS}\nreturn JSON.stringify(({COLLECTOR})({options})); }})()"
12    )
13}
14
15fn contexts(tree: &Value) -> Result<Vec<(String, Option<String>)>, String> {
16    let roots = tree
17        .pointer("/result/contexts")
18        .and_then(Value::as_array)
19        .ok_or("accessibility snapshot: browsingContext.getTree returned no contexts")?;
20    let mut pending: Vec<_> = roots.iter().rev().map(|node| (node, None)).collect();
21    let mut result = Vec::new();
22    while let Some((node, parent)) = pending.pop() {
23        let id = node
24            .get("context")
25            .and_then(Value::as_str)
26            .ok_or("accessibility snapshot: browsing context has no id")?
27            .to_string();
28        if let Some(children) = node.get("children").and_then(Value::as_array) {
29            pending.extend(children.iter().rev().map(|child| (child, Some(id.clone()))));
30        }
31        result.push((id, parent));
32    }
33    if result.is_empty() {
34        return Err("accessibility snapshot: page has no browsing context".to_string());
35    }
36    Ok(result)
37}
38
39fn serialize(snapshot: &Value, format: &str) -> Result<AccessibilitySnapshotInfo, String> {
40    let snapshot = match format {
41        "json" => serde_json::to_string_pretty(snapshot).map_err(|e| e.to_string())?,
42        "yaml" => super::accessibility_yaml::to_string(snapshot)?,
43        _ => return Err("accessibility snapshot format must be 'json' or 'yaml'".to_string()),
44    };
45    Ok(AccessibilitySnapshotInfo {
46        snapshot,
47        format: format.to_string(),
48    })
49}
50
51/// Capture every browsing context (including cross-origin frames) via WebDriver BiDi.
52/// The serialized tree is a DOM approximation, not the browser's platform AX tree.
53pub async fn accessibility_snapshot(
54    browser_session: &BrowserSessionHandle,
55    page_session: &PageSessionHandle,
56    format: &str,
57) -> Result<AccessibilitySnapshotInfo, String> {
58    accessibility_snapshot_with_mode(browser_session, page_session, format, "default").await
59}
60
61pub async fn accessibility_snapshot_with_mode(
62    browser_session: &BrowserSessionHandle,
63    page_session: &PageSessionHandle,
64    format: &str,
65    mode: &str,
66) -> Result<AccessibilitySnapshotInfo, String> {
67    let mode = if mode.is_empty() { "default" } else { mode };
68    if !matches!(mode, "default" | "ai" | "autoexpect" | "codegen") {
69        return Err(
70            "accessibility snapshot mode must be 'default', 'ai', 'autoexpect', or 'codegen'"
71                .into(),
72        );
73    }
74    let format = if format.is_empty() { "json" } else { format };
75    if !matches!(format, "json" | "yaml") {
76        return Err("accessibility snapshot format must be 'json' or 'yaml'".to_string());
77    }
78    let expression = expression(mode);
79    let mut documents = Vec::new();
80    match (browser_session, page_session) {
81        (
82            BrowserSessionHandle::Chromium { cdp_websocket_url },
83            PageSessionHandle::Chromium {
84                browsing_context_id,
85                mapper_target_id,
86                target_id,
87            },
88        ) => {
89            let (mut cdp, mapper, context) = chromium_bidi_session(
90                cdp_websocket_url,
91                mapper_target_id.as_deref(),
92                Some(browsing_context_id.as_deref().unwrap_or(target_id)),
93            )
94            .await?;
95            let tree = cdp
96                .send_bidi_command(
97                    &mapper.mapper_session_id,
98                    &json!({
99                        "id": 1, "method": "browsingContext.getTree", "params": { "root": context }
100                    }),
101                )
102                .await?;
103            for (id, parent) in contexts(&tree)? {
104                let mut document = chromium_bidi_evaluate_json_on_session(
105                    &mut cdp,
106                    &mapper.mapper_session_id,
107                    &id,
108                    &expression,
109                )
110                .await?;
111                document["contextId"] = json!(id);
112                document["parentContextId"] = json!(parent);
113                documents.push(document);
114            }
115        }
116        (
117            BrowserSessionHandle::Firefox { connection_id, .. },
118            PageSessionHandle::Firefox {
119                browsing_context_id,
120            },
121        ) => {
122            let mut sessions = firefox_session_guard(connection_id).await?;
123            let bidi = &mut sessions
124                .get_mut(connection_id)
125                .expect("Firefox session guard validated connection id")
126                .connection;
127            let tree = bidi
128                .send_command(
129                    "browsingContext.getTree",
130                    json!({
131                        "root": browsing_context_id
132                    }),
133                )
134                .await?;
135            for (id, parent) in contexts(&tree)? {
136                let mut document = firefox_evaluate_json(bidi, &id, &expression).await?;
137                document["contextId"] = json!(id);
138                document["parentContextId"] = json!(parent);
139                documents.push(document);
140            }
141        }
142        _ => {
143            return Err(
144                "browser/page backend mismatch while capturing accessibility snapshot".to_string(),
145            );
146        }
147    }
148    serialize(&json!({ "version": 1, "documents": documents }), format)
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn json_and_yaml_round_trip_the_same_typed_data() {
157        let value = json!({"version": 1, "documents": [{"root": {
158            "role": "checkbox", "name": "yes: \"no\"\n- [x] # 😀", "children": [],
159            "states": {"checked": "mixed", "disabled": false, "level": 2},
160            "properties": {"value": "null", "description": "true", "url": "https://example.test/?x=:"}
161        }}]});
162        let json = serialize(&value, "json").unwrap();
163        let yaml = serialize(&value, "yaml").unwrap();
164        assert_eq!(
165            serde_json::from_str::<Value>(&json.snapshot).unwrap(),
166            value
167        );
168        assert_eq!(
169            serde_yaml::from_str::<Value>(&yaml.snapshot).unwrap(),
170            value
171        );
172        assert!(serialize(&value, "xml").is_err());
173    }
174
175    #[test]
176    fn includes_nested_frame_contexts_in_document_order() {
177        let tree = json!({"result": {"contexts": [{"context": "top", "children": [
178            {"context": "child", "children": [{"context": "nested", "children": null}]},
179            {"context": "sibling", "children": []}
180        ]}]}});
181        assert_eq!(
182            contexts(&tree).unwrap(),
183            vec![
184                ("top".into(), None),
185                ("child".into(), Some("top".into())),
186                ("nested".into(), Some("child".into())),
187                ("sibling".into(), Some("top".into()))
188            ]
189        );
190        assert!(contexts(&json!({"result": {"contexts": []}})).is_err());
191    }
192}