diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a2aa70e..3caae2b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ internal refactors, CI, or test-only changes. ## [Unreleased] +### Added +- `Document` accessibility role: a browser page or embedded web view (AT-SPI `document web`/`document frame`, `AXWebArea`, Android `WebView`) now reads as a `Document` whose children are the page's elements, on Linux, macOS, Android and iOS. + +### Fixed +- A web view whose content the platform has not published is disclosed in the snapshot with its id, bounds and the pixel path, instead of arriving as an indistinguishable empty group. + ## [1.5.0] - 2026-08-22 ### Added diff --git a/crates/glass-a11y-linux/src/mapping.rs b/crates/glass-a11y-linux/src/mapping.rs index cb69cdf9..f1e5a3ef 100644 --- a/crates/glass-a11y-linux/src/mapping.rs +++ b/crates/glass-a11y-linux/src/mapping.rs @@ -50,6 +50,7 @@ pub(crate) fn map_role(role: Role) -> AxRole { Role::ToolBar => AxRole::Toolbar, Role::StatusBar => AxRole::StatusBar, Role::Heading => AxRole::Heading, + Role::DocumentWeb | Role::DocumentFrame => AxRole::Document, _ => AxRole::Other, } } @@ -108,6 +109,17 @@ mod tests { assert_eq!(map_role(Role::Calendar), AxRole::Other); } + #[test] + fn web_documents_map_to_document() { + // A browser's page root and an ARIA role=document region are both web documents; + // a text document (DocumentText) is a text area and stays one. + assert_eq!(map_role(Role::DocumentWeb), AxRole::Document); + assert_eq!(map_role(Role::DocumentFrame), AxRole::Document); + assert_eq!(map_role(Role::DocumentText), AxRole::TextArea); + // An embedded object (, ) is not a document. + assert_eq!(map_role(Role::Embedded), AxRole::Other); + } + #[test] fn states_map_to_flags() { let s = @@ -177,6 +189,7 @@ mod tests { (Role::ToolBar, AxRole::Toolbar), (Role::StatusBar, AxRole::StatusBar), (Role::Heading, AxRole::Heading), + (Role::DocumentWeb, AxRole::Document), ]; #[test] diff --git a/crates/glass-a11y-macos/src/mapping.rs b/crates/glass-a11y-macos/src/mapping.rs index bda1ece9..acb09021 100644 --- a/crates/glass-a11y-macos/src/mapping.rs +++ b/crates/glass-a11y-macos/src/mapping.rs @@ -40,6 +40,8 @@ pub const ROLE_TOKENS: &[(&str, AxRole)] = &[ ("AXSplitter", AxRole::Separator), ("AXHeading", AxRole::Heading), ("AXMenuButton", AxRole::Button), + // The root of a web engine's subtree. + ("AXWebArea", AxRole::Document), ]; /// Subroles that decide a role, and the base roles that can carry one. @@ -231,6 +233,11 @@ mod tests { assert_eq!(map_role("", None), AxRole::Other); } + #[test] + fn a_web_area_is_a_document() { + assert_eq!(map_role("AXWebArea", None), AxRole::Document); + } + #[test] fn a_switch_is_a_togglebutton_whichever_base_role_carries_it() { // `AXToggle` is deliberately absent: AppKit documents it for on/off *buttons*, and no probe diff --git a/crates/glass-a11y-windows/src/mapping.rs b/crates/glass-a11y-windows/src/mapping.rs index b834819e..f21fc972 100644 --- a/crates/glass-a11y-windows/src/mapping.rs +++ b/crates/glass-a11y-windows/src/mapping.rs @@ -329,7 +329,8 @@ mod tests { #[test] fn document_maps_from_an_observed_token() { // Observed on a stock text editor — see the probe test in - // crates/glass-windows/tests/onbox.rs. + // crates/glass-windows/tests/onbox.rs. What a web document reports here is unread, so + // the role-support matrix records the Document cell as a gap until one is read. assert_eq!(map_role(50030, false), AxRole::TextArea); } diff --git a/crates/glass-android/src/a11y_service.rs b/crates/glass-android/src/a11y_service.rs index ac55bdc2..a6129d32 100644 --- a/crates/glass-android/src/a11y_service.rs +++ b/crates/glass-android/src/a11y_service.rs @@ -987,10 +987,7 @@ fn movement_candidates<'a>(tree: &'a AxTree, target: &AxTarget) -> Vec<&'a AxNod /// A rectangle as `(x,y wxh)`. fn rect(b: Option) -> String { - b.map_or_else( - || "(no bounds)".to_string(), - |r| format!("({},{} {}x{})", r.x, r.y, r.width, r.height), - ) + b.map_or_else(|| "(no bounds)".to_string(), |r| r.to_string()) } /// The refusal for a target more than one node now matches on everything but position, so its id diff --git a/crates/glass-android/src/axmap.rs b/crates/glass-android/src/axmap.rs index 2b269482..296928d5 100644 --- a/crates/glass-android/src/axmap.rs +++ b/crates/glass-android/src/axmap.rs @@ -43,7 +43,7 @@ pub const CLASS_TOKENS: &[(&str, AxRole)] = &[ ("RecyclerView", AxRole::List), ("ListView", AxRole::List), ("GridView", AxRole::List), - ("WebView", AxRole::Group), + ("WebView", AxRole::Document), // Containers the leaf-suffix rule below cannot catch, each observed in a real app's // tree: the AndroidX card container, the AppCompat linear layout (shipped under two // package names), the view that hosts a Compose hierarchy, and a swipe-paged container. @@ -629,6 +629,13 @@ mod tests { } } + #[test] + fn a_webview_is_a_document_not_a_group() { + // glass#506: as a Group, a WebView whose content the reader could not enter was + // indistinguishable from an empty container. + assert_eq!(class_to_role("android.webkit.WebView"), AxRole::Document); + } + #[test] fn class_tokens_have_no_duplicates() { for (i, (leaf, _)) in CLASS_TOKENS.iter().enumerate() { diff --git a/crates/glass-core/src/accessibility.rs b/crates/glass-core/src/accessibility.rs index f025bc80..391e3f59 100644 --- a/crates/glass-core/src/accessibility.rs +++ b/crates/glass-core/src/accessibility.rs @@ -48,6 +48,10 @@ pub enum AxRole { Toolbar, StatusBar, Heading, + /// A web document or web area — the root of a subtree a web engine publishes (a + /// browser tab's page, a WebView's content). Its children are the page's own elements; + /// a `Document` with no children is disclosed by [`AxTree::document_guidance`]. + Document, Other, } @@ -55,7 +59,7 @@ impl AxRole { /// Every role except [`AxRole::Other`], which is the sink for unmapped native tokens /// rather than a mapping target. Used by the per-backend role-parity tests and by /// [`crate::role_support::ROLE_SUPPORT`]. - pub const ALL: [AxRole; 33] = [ + pub const ALL: [AxRole; 34] = [ AxRole::Application, AxRole::Window, AxRole::Dialog, @@ -89,6 +93,7 @@ impl AxRole { AxRole::Toolbar, AxRole::StatusBar, AxRole::Heading, + AxRole::Document, ]; /// Whether this role denotes an element a user acts on (clicks / types into) — @@ -153,6 +158,7 @@ impl AxRole { "toolbar" => Toolbar, "statusbar" => StatusBar, "heading" => Heading, + "document" => Document, "other" => Other, _ => return None, }) @@ -217,6 +223,14 @@ pub struct AxRect { pub height: u32, } +impl std::fmt::Display for AxRect { + /// `(x,y wxh)` — the agent-facing bounds format, defined once so the outline and a notice + /// naming the same element cannot drift. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "({},{} {}x{})", self.x, self.y, self.width, self.height) + } +} + impl AxRect { /// Whether this rect shares any area with `other`. /// @@ -751,6 +765,71 @@ impl AxTree { never will). Drive it by pixels instead: glass_screenshot, then glass_click at x,y.", ) } + + /// Every `Document` with no children, in pre-order. A web engine that has not published + /// its tree — or an empty page — arrives exactly like this, and the outline alone cannot + /// tell the two apart. + pub fn unpublished_documents(&self) -> Vec<&AxNode> { + fn walk<'a>(node: &'a AxNode, out: &mut Vec<&'a AxNode>) { + if node.role == AxRole::Document && node.children.is_empty() { + out.push(node); + } + for child in &node.children { + walk(child, out); + } + } + let mut out = Vec::new(); + walk(&self.root, &mut out); + out + } + + /// The disclosure for [`Self::unpublished_documents`]: one notice naming every childless + /// `Document` by id and bounds, or `None` when there is nothing to disclose. Same shape as + /// [`Truncation::notice`] and [`Self::empty_guidance`] — what is missing, then the pixel + /// path — because a web page the reader cannot enter fails the agent the same way a + /// truncated tree does. Aggregated like [`Self::unreadable_notice`] rather than repeated + /// per document: a page of ad iframes would otherwise spend the budget `max_nodes` guards. + /// + /// A bound or a failed child read empties a `Document`'s child list exactly like an + /// unpublished tree does, so only a complete walk ([`Self::is_complete`]) names a cause; + /// otherwise this hedges and defers to the notice beside it, which owns the recourse — + /// core does not know that only a `Nodes` bound is raisable by `max_nodes`. + pub fn document_guidance(&self) -> Option { + let docs = self.unpublished_documents(); + if docs.is_empty() { + return None; + } + let list: Vec = docs + .iter() + .map(|d| match &d.bounds { + Some(b) => format!("#{} {b}", d.id.0), + None => format!("#{} (bounds unknown)", d.id.0), + }) + .collect(); + let one = docs.len() == 1; + let (s, have, they, them) = if one { + ("", "has", "it", "it") + } else { + ("s", "have", "they", "them") + }; + let (n, list) = (docs.len(), list.join(", ")); + Some(if self.is_complete() { + format!( + "… {n} Document element{s} {have} no readable content: {list}. The web engine \ + has not published its accessibility tree, or the page is empty. Elements \ + inside {them} cannot be addressed by id. Drive by pixels: glass_screenshot, \ + then glass_click at x,y inside the bounds above." + ) + } else { + format!( + "… {n} Document element{s} {have} no readable content in this snapshot: \ + {list}, and the walk did not complete — see the notice beside this one — so \ + {they} may hold content that was never reached. Follow that notice, or take \ + a fresh glass_a11y_snapshot, before driving by pixels: glass_screenshot, \ + then glass_click at x,y inside the bounds above." + ) + }) + } } /// Context the display backend supplies so the a11y reader can locate the right @@ -1350,7 +1429,8 @@ mod tests { | AxRole::Separator | AxRole::Toolbar | AxRole::StatusBar - | AxRole::Heading => {} + | AxRole::Heading + | AxRole::Document => {} // Deliberately excluded from `ALL`: the sink for unmapped native tokens, not a // mapping target. AxRole::Other => {} @@ -1692,7 +1772,7 @@ mod tests { #[test] fn every_role_parses_from_its_name() { use AxRole::*; - let pairs: [(&str, AxRole); 34] = [ + let pairs: [(&str, AxRole); 35] = [ ("application", Application), ("window", Window), ("dialog", Dialog), @@ -1726,6 +1806,7 @@ mod tests { ("toolbar", Toolbar), ("statusbar", StatusBar), ("heading", Heading), + ("document", Document), ("other", Other), ]; @@ -2268,6 +2349,18 @@ mod tests { assert!(matches!(drift_target().relocate(&tree), Located::AtId(n) if n.id == AxNodeId(1))); } + #[test] + fn bounds_render_as_origin_then_size() { + // The shared format the outline line and the document notice both render through. + let r = AxRect { + x: 40, + y: 120, + width: 800, + height: 600, + }; + assert_eq!(r.to_string(), "(40,120 800x600)"); + } + #[test] fn rects_overlap_only_where_they_share_area() { let a = AxRect { @@ -2385,6 +2478,162 @@ mod tests { assert!(sample_tree().empty_guidance().is_none()); } + fn document(name: &str, children: Vec) -> AxNode { + let mut d = leaf(AxRole::Document, name); + d.bounds = Some(AxRect { + x: 40, + y: 120, + width: 800, + height: 600, + }); + d.children = children; + d + } + + /// A Window with a Back button and one childless `Document` (#2), walked completely. + fn tree_with_a_childless_document() -> AxTree { + let mut tree = AxTree::new(AxNode { + children: vec![leaf(AxRole::Button, "Back"), document("page", vec![])], + ..leaf(AxRole::Window, "App") + }); + tree.assign_ids(); + tree + } + + #[test] + fn a_childless_document_is_reported_with_its_id_and_bounds() { + let tree = tree_with_a_childless_document(); + let found = tree.unpublished_documents(); + assert_eq!(found.len(), 1); + assert_eq!(found[0].id, AxNodeId(2)); + let hint = tree + .document_guidance() + .expect("a childless Document yields guidance"); + assert!( + hint.contains("#2 (40,120 800x600)"), + "id and bounds: {hint}" + ); + assert!( + hint.contains("glass_screenshot"), + "names the pixel path: {hint}" + ); + assert!(hint.contains("glass_click"), "names the pixel path: {hint}"); + } + + #[test] + fn a_complete_walk_names_the_engine_and_the_empty_page() { + // A complete walk is the one case where glass can name a cause. + let hint = tree_with_a_childless_document() + .document_guidance() + .unwrap(); + assert!( + hint.starts_with("… 1 Document element has no readable content:"), + "singular, aggregated: {hint}" + ); + assert!(hint.contains("has not published"), "{hint}"); + } + + #[test] + fn a_document_emptied_by_a_bound_is_not_blamed_on_the_web_engine() { + // A bound leaves a Document childless too, so the notice cannot claim the engine + // published nothing. + let mut tree = tree_with_a_childless_document(); + tree.truncated = Some(Truncation { + limit: TruncationLimit::Nodes, + limit_value: 20, + nodes_walked: 20, + }); + let hint = tree.document_guidance().unwrap(); + assert!( + !hint.contains("has not published"), + "a bounded walk cannot know that: {hint}" + ); + assert!(hint.contains("in this snapshot"), "hedged: {hint}"); + // `max_nodes` is an MCP-layer detail; core doesn't know a Nodes hit is raisable by it. + assert!( + !hint.contains("max_nodes"), + "not core's recourse to name: {hint}" + ); + assert!( + hint.contains("the notice beside this one"), + "defers to the notice that knows the cause: {hint}" + ); + } + + #[test] + fn a_document_emptied_by_an_unread_subtree_is_not_blamed_on_the_web_engine() { + let mut tree = tree_with_a_childless_document(); + tree.unreadable = 1; + let hint = tree.document_guidance().unwrap(); + assert!( + !hint.contains("has not published"), + "a dropped subtree cannot know that: {hint}" + ); + assert!(hint.contains("in this snapshot"), "hedged: {hint}"); + } + + #[test] + fn a_populated_document_is_not_reported() { + let mut tree = AxTree::new(AxNode { + children: vec![document("page", vec![leaf(AxRole::Heading, "Hello")])], + ..leaf(AxRole::Window, "App") + }); + tree.assign_ids(); + assert!(tree.unpublished_documents().is_empty()); + assert!(tree.document_guidance().is_none()); + } + + #[test] + fn every_childless_document_is_reported_in_pre_order() { + // A populated document with an empty iframe inside it, and an empty one after it. + let mut tree = AxTree::new(AxNode { + children: vec![ + document( + "outer", + vec![leaf(AxRole::Heading, "H"), document("iframe", vec![])], + ), + document("second", vec![]), + ], + ..leaf(AxRole::Window, "App") + }); + tree.assign_ids(); + let ids: Vec = tree.unpublished_documents().iter().map(|n| n.id).collect(); + assert_eq!(ids, vec![AxNodeId(3), AxNodeId(4)]); + // One notice for both, ids listed in the same pre-order. + let hint = tree.document_guidance().unwrap(); + assert!( + hint.starts_with("… 2 Document elements have no readable content:"), + "plural, aggregated: {hint}" + ); + let (third, fourth) = (hint.find("#3").unwrap(), hint.find("#4").unwrap()); + assert!(third < fourth, "pre-order: {hint}"); + } + + #[test] + fn a_tree_without_documents_yields_no_document_guidance() { + assert!(sample_tree().document_guidance().is_none()); + // An empty tree is the empty_guidance case, not this one. + assert!( + AxTree::new(leaf(AxRole::Window, "App")) + .document_guidance() + .is_none() + ); + } + + #[test] + fn a_document_without_bounds_still_names_the_pixel_path() { + let mut d = document("page", vec![]); + d.bounds = None; + let mut tree = AxTree::new(AxNode { + children: vec![d], + ..leaf(AxRole::Window, "App") + }); + tree.assign_ids(); + let hint = tree.document_guidance().unwrap(); + assert!(hint.contains("bounds unknown"), "{hint}"); + assert!(hint.contains("glass_screenshot"), "{hint}"); + } + fn sample_tree() -> AxTree { let mut button = leaf(AxRole::Button, "Save"); button.bounds = Some(AxRect { @@ -2493,6 +2742,9 @@ mod tests { AxRole::Group, AxRole::Label, AxRole::Image, + // Making it interactable would chip every web page root in `marks` and widen + // `role:"Document"` onto any focusable Group/Other through `element_match`. + AxRole::Document, AxRole::Other, ] { assert!(!r.is_interactable(), "{r:?} should not be interactable"); diff --git a/crates/glass-core/src/outline.rs b/crates/glass-core/src/outline.rs index 8c09cbcb..9769388e 100644 --- a/crates/glass-core/src/outline.rs +++ b/crates/glass-core/src/outline.rs @@ -66,7 +66,7 @@ pub(crate) fn write_line(node: &AxNode, depth: usize, out: &mut String) { let _ = write!(out, " desc={description:?}"); } if let Some(b) = &node.bounds { - let _ = write!(out, " ({},{} {}x{})", b.x, b.y, b.width, b.height); + let _ = write!(out, " {b}"); } let states = node.states.active(); if !states.is_empty() { @@ -257,6 +257,23 @@ mod tests { assert!(render_compact(&tree_of(node(AxRole::Group, None))).contains("Group")); } + #[test] + fn a_childless_document_under_a_wrapper_still_renders_its_own_line() { + // `document_guidance` names the Document by id, so that id has to be a line the + // agent can see. + let out = render_compact(&tree_of(wrap(node(AxRole::Document, None), 1))); + assert!(out.contains("Document"), "{out}"); + } + + #[test] + fn a_single_child_document_is_never_collapsed_as_scaffolding() { + // The shape that would be elided if `Document` joined the scaffolding roles. + let mut doc = node(AxRole::Document, None); + doc.children = vec![node(AxRole::Heading, Some("Title"))]; + let out = render_compact(&tree_of(doc)); + assert!(out.contains("Document"), "{out}"); + } + #[test] fn a_semantic_role_is_never_elided() { let mut list = node(AxRole::List, None); diff --git a/crates/glass-core/src/role_support.rs b/crates/glass-core/src/role_support.rs index 22c3a150..b1fbd52d 100644 --- a/crates/glass-core/src/role_support.rs +++ b/crates/glass-core/src/role_support.rs @@ -622,6 +622,21 @@ pub const ROLE_SUPPORT: &[(AxRole, [RoleSupport; AxBackend::ALL.len()])] = { Mapped, ], ), + ( + R::Document, + [ + Mapped, + Gap { + unmapped: None, + why: "UIA's Document control type maps to TextArea, read from a text \ + editor's edit surface; what a web document reports on this backend has \ + not been read, so the cell waits on that reading", + }, + Mapped, + Mapped, + Mapped, + ], + ), ] }; @@ -876,4 +891,21 @@ mod tests { ); } } + + #[test] + fn document_row_declares_every_backend() { + for backend in AxBackend::ALL { + assert!( + support(AxRole::Document, backend).is_some(), + "{backend:?} has no Document cell" + ); + } + // Windows keeps UIA Document on TextArea until a web document is read there. + // `unmapped` stays `None`: UIA's Document IS mapped, just to another role, so + // "`Document` arrives unmapped" would send a reader hunting for `Other(Document)`. + assert!(matches!( + support(AxRole::Document, AxBackend::Windows), + Some(RoleSupport::Gap { unmapped: None, .. }) + )); + } } diff --git a/crates/glass-core/tests/role_support_doc.rs b/crates/glass-core/tests/role_support_doc.rs index ef69d5c6..0eedfb48 100644 --- a/crates/glass-core/tests/role_support_doc.rs +++ b/crates/glass-core/tests/role_support_doc.rs @@ -46,3 +46,13 @@ fn crlf_normalization_works() { let extracted = normalized[start..end].trim(); assert_eq!(extracted, generated.trim()); } + +/// Prints the generated block so it can be pasted between the markers. Ignored: it is a +/// tool, not a check. +/// +/// `cargo test -p glass-core --test role_support_doc print_generated -- --ignored --nocapture` +#[test] +#[ignore = "prints the block for docs/reference/a11y-roles.md; run on demand"] +fn print_generated() { + print!("{}", glass_core::role_support::render_markdown()); +} diff --git a/crates/glass-ios/src/axmap.rs b/crates/glass-ios/src/axmap.rs index cb2cdb2b..684d624f 100644 --- a/crates/glass-ios/src/axmap.rs +++ b/crates/glass-ios/src/axmap.rs @@ -52,6 +52,8 @@ pub const ROLE_TOKENS: &[(&str, AxRole)] = &[ ("AXGroup", AxRole::Group), // A screen or section title. ("AXHeading", AxRole::Heading), + // The root of a web engine's subtree. + ("AXWebArea", AxRole::Document), ]; /// Map an idb AX role string (e.g. `AXButton`) to a normalized [`AxRole`]. @@ -318,6 +320,11 @@ mod tests { assert_eq!(ax_role("AXWhatever"), AxRole::Other); } + #[test] + fn a_web_area_is_a_document() { + assert_eq!(ax_role("AXWebArea"), AxRole::Document); + } + #[test] fn an_empty_tree_maps_to_an_empty_window_rather_than_an_error() { // What an app reports for the second or so it takes to render. `IosA11y` relies on diff --git a/crates/glass-mcp/src/params.rs b/crates/glass-mcp/src/params.rs index c3e32368..f79498d1 100644 --- a/crates/glass-mcp/src/params.rs +++ b/crates/glass-mcp/src/params.rs @@ -333,7 +333,7 @@ pub struct WaitStableArgs { pub struct WaitForElementArgs { /// Substring of the element's accessible name (selector). pub name: Option, - /// Element role filter, e.g. "Button", "ProgressBar" (selector). + /// Element role filter, e.g. "Button", "ProgressBar", "Document" (selector). pub role: Option, /// What to wait for (default "appears"): appears|disappears|enabled|disabled| /// checked|unchecked|selected|unselected|expanded|collapsed|focused|visible|hidden. @@ -354,7 +354,7 @@ pub struct ScrollToElementArgs { /// Substring of the target element's accessible name (selector). `name` and/or /// `role` is required. pub name: Option, - /// Element role filter, e.g. "ListItem", "Button" (selector). + /// Element role filter, e.g. "ListItem", "Button", "Document" (selector). pub role: Option, /// Additionally require the matched element's `value` to contain this substring. /// Not a standalone selector — `name` and/or `role` is still required. diff --git a/crates/glass-mcp/src/server.rs b/crates/glass-mcp/src/server.rs index 67c794d1..63f12f9c 100644 --- a/crates/glass-mcp/src/server.rs +++ b/crates/glass-mcp/src/server.rs @@ -504,8 +504,10 @@ impl GlassServer { glass_scroll_to_element select on name, not description. Pass an #id to \ glass_click_element. Errors if the backend or app exposes no \ accessibility tree (e.g. a canvas/black-box app) — fall back to \ - glass_screenshot then. Optional max_nodes: raise the element cap, or 0 \ - to remove the element-count limit (default caps protect the token budget)." + glass_screenshot then. Web content arrives under a `Document` element, \ + and a childless `Document` is disclosed with the pixel path. Optional \ + max_nodes: raise the element cap, or 0 to remove the element-count limit \ + (default caps protect the token budget)." )] async fn glass_a11y_snapshot( &self, diff --git a/crates/glass-mcp/src/tools.rs b/crates/glass-mcp/src/tools.rs index 38209f3a..e951ef81 100644 --- a/crates/glass-mcp/src/tools.rs +++ b/crates/glass-mcp/src/tools.rs @@ -289,13 +289,14 @@ fn a11y_truncation_steer(tree: &glass_core::AxTree) -> Option { }) } -/// Every disclosure a snapshot owes the agent: the elements it does not show, and what it turned -/// out to describe. One function, not three call-site lists, so `a11y_snapshot` and the -/// `return:"snapshot"` fold disclose identically. +/// Every disclosure a snapshot owes the agent: the elements it does not show, the web content +/// it could not enter, and what it turned out to describe. One function, not three call-site +/// lists, so `a11y_snapshot` and the `return:"snapshot"` fold disclose identically. fn a11y_steers(tree: &glass_core::AxTree) -> Vec { [ a11y_truncation_steer(tree), tree.unreadable_notice(), + tree.document_guidance(), tree.subject_notice(), ] .into_iter() @@ -816,6 +817,29 @@ pub(crate) mod testutil { t } + /// `fake_tree` with a childless `Document` child — the unpublished-web-content shape. + pub fn unpublished_document_tree() -> AxTree { + let mut t = fake_tree(); + t.root.children.push(AxNode { + id: AxNodeId(0), + role: AxRole::Document, + raw_role: "document web".into(), + name: Some("page".into()), + description: None, + value: None, + states: AxStates::default(), + bounds: Some(AxRect { + x: 0, + y: 40, + width: 100, + height: 60, + }), + children: vec![], + }); + t.assign_ids(); + t + } + pub fn glass_with_a11y(platform: FakePlatform, tree: AxTree) -> Glass { glass_with_a11y_outcome(platform, tree, SetOutcome::Ok) } @@ -1248,6 +1272,40 @@ mod tests { } } + #[test] + fn a11y_snapshot_discloses_an_unpublished_document_as_a_trusted_block() { + let mut g = glass_with_a11y(FakePlatform::new(100, 100), unpublished_document_tree()); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + let out = a11y_snapshot(&mut g, &A11ySnapshotArgs { max_nodes: None }).unwrap(); + assert_envelope(&out, "glass_a11y_snapshot"); + assert_eq!( + out.0.len(), + 3, + "envelope + wrapped outline + document guidance" + ); + match (&out.0[1], &out.0[2]) { + (OutContent::Text(body), OutContent::Text(steer)) => { + assert!( + !body.contains("has no readable content"), + "guidance must not be inside the untrusted body: {body}" + ); + assert!(steer.contains("Document"), "{steer}"); + assert!(steer.contains("glass_screenshot"), "{steer}"); + } + other => panic!("unexpected blocks: {other:?}"), + } + } + #[test] fn a_snapshot_of_another_app_says_so_in_its_text() { let mut tree = empty_tree(); @@ -1762,6 +1820,59 @@ mod tests { ); } + #[test] + fn return_snapshot_discloses_an_unpublished_document_the_same_way_a_snapshot_does() { + // The fold's steer wiring, not `a11y_steers` itself: every fold test until now used a + // tree with nothing to disclose, so dropping the `extend` broke nothing. + let mut g = glass_with_a11y(FakePlatform::new(100, 100), unpublished_document_tree()); + g.start(&AppSpec { + build: None, + run: vec!["x".into()], + cwd: None, + env: vec![], + window_hint: None, + timeout_ms: 1, + sandbox: SandboxLevel::Off, + a11y: false, + }) + .unwrap(); + // Populates the id cache click_element resolves against, and is the parity tree. + let tree = g.a11y_snapshot(None).unwrap(); + let out = click_element( + &mut g, + &ClickElementArgs { + id: 1, + return_: Some("snapshot".into()), + }, + ) + .unwrap(); + let texts: Vec<&String> = out + .0 + .iter() + .filter_map(|c| match c { + OutContent::Text(t) => Some(t), + _ => None, + }) + .collect(); + let steer = texts + .iter() + .find(|t| t.contains("has no readable content")) + .unwrap_or_else(|| panic!("the fold owes the document guidance: {texts:?}")); + assert!( + !steer.starts_with(crate::untrusted::NOTE) && !steer.contains("⟦untrusted:"), + "glass's own guidance, outside the untrusted envelope: {steer}" + ); + assert!(steer.contains("glass_screenshot"), "{steer}"); + // Parity with `a11y_snapshot`: a fifth steer added to one call site and not the + // other fails here too. + for expected in a11y_steers(&tree) { + assert!( + texts.iter().any(|t| **t == expected), + "the fold dropped a steer: {expected}" + ); + } + } + #[test] fn return_snapshot_settles_before_folding() { use glass_core::Frame; diff --git a/docs/reference/a11y-roles.md b/docs/reference/a11y-roles.md index 0bf2e229..5e3fa160 100644 --- a/docs/reference/a11y-roles.md +++ b/docs/reference/a11y-roles.md @@ -60,6 +60,12 @@ window root sized to the app window, and the accessibility-service reader labels window's own root node. The outline does not name that node's widget class — the root has a role now, and the outline only names the token of an element that has none. +**A web document is a `Document`.** A browser page or an embedded web view arrives as one +`Document` element with the page's elements as its children; an `