1use 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
51pub 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}