Skip to main content

glass/browser/session/
diff.rs

1//! Accessibility tree diffing.
2//!
3//! Computes a structured diff between two [`CompactAccessibilitySnapshot`]
4//! values, identifying added, removed, and changed interactive elements.
5//! Useful for verifying UI transitions after actions.
6
7use super::*;
8use std::collections::{HashMap, HashSet};
9
10/// Diff between two compact accessibility observations.
11///
12/// Returned by [`diff_accessibility`] and [`BrowserSession::diff_observation`].
13#[derive(Debug, Clone, serde::Serialize)]
14pub struct AccessibilityDiff {
15    /// Revision of the "before" snapshot.
16    pub from_revision: u64,
17    /// Revision of the "after" snapshot.
18    pub to_revision: u64,
19    /// Elements present in "after" but not "before".
20    pub added: Vec<DiffElement>,
21    /// Elements present in "before" but not "after".
22    pub removed: Vec<DiffElement>,
23    /// Elements present in both snapshots with changed properties.
24    pub changed: Vec<DiffChange>,
25    /// Total interactive elements in the "after" snapshot.
26    pub total_after: usize,
27}
28
29/// A single element that was added or removed in a diff.
30#[derive(Debug, Clone, serde::Serialize)]
31pub struct DiffElement {
32    /// Revisioned reference string (e.g. `"r7:b42"`).
33    pub reference: String,
34    /// Accessibility role (e.g. `"button"`, `"textbox"`).
35    pub role: String,
36    /// Accessible name of the element.
37    pub name: String,
38}
39
40/// An element present in both snapshots whose properties changed.
41#[derive(Debug, Clone, serde::Serialize)]
42pub struct DiffChange {
43    /// Revisioned reference string.
44    pub reference: String,
45    /// Current role of the element.
46    pub role: String,
47    /// Current accessible name.
48    pub name: String,
49    /// Names of the properties that changed (e.g. `["name", "value"]`).
50    pub changed_properties: Vec<String>,
51}
52
53/// Compute a diff between two compact accessibility snapshots.
54///
55/// Elements are matched by their revisioned reference. The diff
56/// detects:
57///
58/// - **Added**: elements only in `after`.
59/// - **Removed**: elements only in `before`.
60/// - **Changed**: elements in both whose `name`, `role`, `value`,
61///   `checked`, `selectedOption`, `empty`, `readOnly`, or `required`
62///   differ.
63pub fn diff_accessibility(
64    before: &CompactAccessibilitySnapshot,
65    after: &CompactAccessibilitySnapshot,
66) -> AccessibilityDiff {
67    let before_refs: HashSet<&str> = before
68        .interactive
69        .iter()
70        .map(|e| e.reference.as_str())
71        .collect();
72    let after_refs: HashSet<&str> = after
73        .interactive
74        .iter()
75        .map(|e| e.reference.as_str())
76        .collect();
77
78    let mut added = Vec::new();
79    let mut removed = Vec::new();
80    let mut changed = Vec::new();
81
82    for elem in &after.interactive {
83        if !before_refs.contains(elem.reference.as_str()) {
84            added.push(DiffElement {
85                reference: elem.reference.clone(),
86                role: elem.role.clone(),
87                name: elem.name.clone(),
88            });
89        }
90    }
91    for elem in &before.interactive {
92        if !after_refs.contains(elem.reference.as_str()) {
93            removed.push(DiffElement {
94                reference: elem.reference.clone(),
95                role: elem.role.clone(),
96                name: elem.name.clone(),
97            });
98        }
99    }
100
101    let after_by_ref: HashMap<&str, &CompactInteractiveElement> = after
102        .interactive
103        .iter()
104        .map(|e| (e.reference.as_str(), e))
105        .collect();
106    for before_elem in &before.interactive {
107        if let Some(after_elem) = after_by_ref.get(before_elem.reference.as_str()) {
108            let mut props = Vec::new();
109            if before_elem.name != after_elem.name {
110                props.push("name".into());
111            }
112            if before_elem.role != after_elem.role {
113                props.push("role".into());
114            }
115            if before_elem.value != after_elem.value {
116                props.push("value".into());
117            }
118            if before_elem.checked != after_elem.checked {
119                props.push("checked".into());
120            }
121            if before_elem.selected_option != after_elem.selected_option {
122                props.push("selectedOption".into());
123            }
124            if before_elem.empty != after_elem.empty {
125                props.push("empty".into());
126            }
127            if before_elem.read_only != after_elem.read_only {
128                props.push("readOnly".into());
129            }
130            if before_elem.required != after_elem.required {
131                props.push("required".into());
132            }
133            if !props.is_empty() {
134                changed.push(DiffChange {
135                    reference: before_elem.reference.clone(),
136                    role: after_elem.role.clone(),
137                    name: after_elem.name.clone(),
138                    changed_properties: props,
139                });
140            }
141        }
142    }
143
144    AccessibilityDiff {
145        from_revision: before.revision,
146        to_revision: after.revision,
147        added,
148        removed,
149        changed,
150        total_after: after.interactive.len(),
151    }
152}
153
154impl BrowserSession {
155    /// Observe the current page and diff against a prior snapshot.
156    ///
157    /// Takes a fresh observation and computes an `AccessibilityDiff`
158    /// comparing it to `before`. Useful for verifying UI state changes
159    /// after performing actions.
160    pub async fn diff_observation(
161        &self,
162        before: &CompactAccessibilitySnapshot,
163    ) -> BrowserResult<AccessibilityDiff> {
164        let ctx = self.observe().await?;
165        Ok(diff_accessibility(before, &ctx.accessibility))
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    fn make_element(reference: &str, role: &str, name: &str) -> CompactInteractiveElement {
174        CompactInteractiveElement {
175            reference: reference.to_string(),
176            role: role.to_string(),
177            name: name.to_string(),
178            backend_dom_node_id: 1,
179            ancestor_path: Vec::new(),
180            shadow_host_path: None,
181            input_type: None,
182            value: None,
183            checked: None,
184            selected_option: None,
185            empty: false,
186            read_only: false,
187            required: false,
188        }
189    }
190
191    fn make_snapshot(
192        revision: u64,
193        elements: Vec<CompactInteractiveElement>,
194    ) -> CompactAccessibilitySnapshot {
195        CompactAccessibilitySnapshot {
196            page: crate::browser::session::PageInfo {
197                url: "about:blank".to_string(),
198                title: String::new(),
199                ready_state: "complete".to_string(),
200                target_id: "t".to_string(),
201                frame_id: "f".to_string(),
202            },
203            revision,
204            roots: vec![],
205            interactive: elements,
206            truncated: false,
207            omitted_count: 0,
208            ranking_applied: false,
209            completeness: None,
210        }
211    }
212
213    #[test]
214    fn diff_same_elements_produces_no_changes() {
215        let elements = vec![make_element("r1:b42", "button", "Save")];
216        let before = make_snapshot(1, elements.clone());
217        let after = make_snapshot(2, elements);
218        let diff = diff_accessibility(&before, &after);
219        assert_eq!(diff.from_revision, 1);
220        assert_eq!(diff.to_revision, 2);
221        assert!(diff.added.is_empty());
222        assert!(diff.removed.is_empty());
223        assert!(diff.changed.is_empty());
224        assert_eq!(diff.total_after, 1);
225    }
226
227    #[test]
228    fn diff_detects_name_change() {
229        let before = make_snapshot(1, vec![make_element("r1:b1", "button", "Save")]);
230        let after = make_snapshot(2, vec![make_element("r1:b1", "button", "Submit")]);
231        let diff = diff_accessibility(&before, &after);
232        assert_eq!(diff.changed.len(), 1);
233        assert_eq!(diff.changed[0].reference, "r1:b1");
234        assert!(
235            diff.changed[0]
236                .changed_properties
237                .contains(&"name".to_string())
238        );
239    }
240
241    #[test]
242    fn diff_detects_role_change() {
243        let before = make_snapshot(1, vec![make_element("r1:b1", "button", "OK")]);
244        let after = make_snapshot(2, vec![make_element("r1:b1", "link", "OK")]);
245        let diff = diff_accessibility(&before, &after);
246        assert_eq!(diff.changed.len(), 1);
247        assert!(
248            diff.changed[0]
249                .changed_properties
250                .contains(&"role".to_string())
251        );
252    }
253
254    #[test]
255    fn diff_detects_added_and_removed_elements() {
256        let before = make_snapshot(1, vec![make_element("r1:b1", "button", "Old")]);
257        let after = make_snapshot(2, vec![make_element("r2:b2", "button", "New")]);
258        let diff = diff_accessibility(&before, &after);
259        assert_eq!(diff.added.len(), 1);
260        assert_eq!(diff.added[0].reference, "r2:b2");
261        assert_eq!(diff.removed.len(), 1);
262        assert_eq!(diff.removed[0].reference, "r1:b1");
263        assert!(diff.changed.is_empty());
264    }
265
266    #[test]
267    fn diff_detects_value_change() {
268        let mut before_elem = make_element("r1:b1", "textbox", "Search");
269        before_elem.value = Some("old".to_string());
270        let mut after_elem = make_element("r1:b1", "textbox", "Search");
271        after_elem.value = Some("new".to_string());
272        let before = make_snapshot(1, vec![before_elem]);
273        let after = make_snapshot(2, vec![after_elem]);
274        let diff = diff_accessibility(&before, &after);
275        assert_eq!(diff.changed.len(), 1);
276        assert!(
277            diff.changed[0]
278                .changed_properties
279                .contains(&"value".to_string())
280        );
281    }
282
283    #[test]
284    fn diff_detects_checked_state_change() {
285        let mut before_elem = make_element("r1:b1", "checkbox", "Agree");
286        before_elem.checked = Some(false);
287        let mut after_elem = make_element("r1:b1", "checkbox", "Agree");
288        after_elem.checked = Some(true);
289        let before = make_snapshot(1, vec![before_elem]);
290        let after = make_snapshot(2, vec![after_elem]);
291        let diff = diff_accessibility(&before, &after);
292        assert_eq!(diff.changed.len(), 1);
293        assert!(
294            diff.changed[0]
295                .changed_properties
296                .contains(&"checked".to_string())
297        );
298    }
299
300    #[test]
301    fn diff_with_both_empty_snapshots_is_clean() {
302        let before = make_snapshot(1, vec![]);
303        let after = make_snapshot(2, vec![]);
304        let diff = diff_accessibility(&before, &after);
305        assert_eq!(diff.from_revision, 1);
306        assert_eq!(diff.to_revision, 2);
307        assert_eq!(diff.total_after, 0);
308        assert!(diff.added.is_empty());
309        assert!(diff.removed.is_empty());
310        assert!(diff.changed.is_empty());
311    }
312
313    #[test]
314    fn diff_with_empty_before_marks_all_as_added() {
315        let before = make_snapshot(1, vec![]);
316        let after = make_snapshot(
317            2,
318            vec![
319                make_element("r2:b1", "button", "Click"),
320                make_element("r2:b2", "textbox", "Name"),
321            ],
322        );
323        let diff = diff_accessibility(&before, &after);
324        assert_eq!(diff.added.len(), 2);
325        assert!(diff.removed.is_empty());
326        assert!(diff.changed.is_empty());
327        assert_eq!(diff.total_after, 2);
328    }
329
330    #[test]
331    fn diff_with_empty_after_marks_all_as_removed() {
332        let before = make_snapshot(
333            1,
334            vec![
335                make_element("r1:b1", "button", "Old"),
336                make_element("r1:b2", "link", "Gone"),
337            ],
338        );
339        let after = make_snapshot(2, vec![]);
340        let diff = diff_accessibility(&before, &after);
341        assert!(diff.added.is_empty());
342        assert_eq!(diff.removed.len(), 2);
343        assert!(diff.changed.is_empty());
344        assert_eq!(diff.total_after, 0);
345    }
346
347    #[test]
348    fn diff_element_serializes_to_json() {
349        let elem = DiffElement {
350            reference: "r7:b42".to_string(),
351            role: "button".to_string(),
352            name: "Submit".to_string(),
353        };
354        let json = serde_json::to_value(&elem).unwrap();
355        assert_eq!(json["reference"], "r7:b42");
356        assert_eq!(json["role"], "button");
357        assert_eq!(json["name"], "Submit");
358    }
359
360    #[test]
361    fn diff_change_serializes_to_json() {
362        let change = DiffChange {
363            reference: "r3:b99".to_string(),
364            role: "textbox".to_string(),
365            name: "Search".to_string(),
366            changed_properties: vec!["value".to_string(), "name".to_string()],
367        };
368        let json = serde_json::to_value(&change).unwrap();
369        assert_eq!(json["reference"], "r3:b99");
370        assert_eq!(json["role"], "textbox");
371        let props: Vec<&str> = json["changed_properties"]
372            .as_array()
373            .unwrap()
374            .iter()
375            .map(|v| v.as_str().unwrap())
376            .collect();
377        assert!(props.contains(&"value"));
378        assert!(props.contains(&"name"));
379    }
380
381    #[test]
382    fn accessibility_diff_serializes_full_structure() {
383        let diff = AccessibilityDiff {
384            from_revision: 5,
385            to_revision: 6,
386            added: vec![DiffElement {
387                reference: "r6:b1".to_string(),
388                role: "button".to_string(),
389                name: "New".to_string(),
390            }],
391            removed: vec![],
392            changed: vec![DiffChange {
393                reference: "r5:b2".to_string(),
394                role: "textbox".to_string(),
395                name: "Email".to_string(),
396                changed_properties: vec!["value".to_string()],
397            }],
398            total_after: 2,
399        };
400        let json = serde_json::to_value(&diff).unwrap();
401        assert_eq!(json["from_revision"], 5);
402        assert_eq!(json["to_revision"], 6);
403        assert_eq!(json["total_after"], 2);
404        assert_eq!(json["added"].as_array().unwrap().len(), 1);
405        assert_eq!(json["changed"].as_array().unwrap().len(), 1);
406        assert!(json["removed"].as_array().unwrap().is_empty());
407    }
408}