Skip to main content

smix_screen/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3#![deny(rustdoc::broken_intra_doc_links)]
4
5//! smix-screen — A11yNode + Rect + Bounds + Role types + visibility
6//! primitives (stone).
7//!
8//! # Scope
9//!
10//! - Pure types (`Rect`, `Bounds`, `Role`, `A11yNode`) with serde wire
11//!   compatibility (camelCase JSON, matching the existing Swift-side
12//!   SmixRunnerCore `/tree` route shape).
13//! - Pure functions (`is_visible_enough`, `visible_area`) that the
14//!   selector resolver consumes. No I/O — protocol parsing and types only.
15//!
16//! # Visibility semantics
17//!
18//! - `b.w <= 0 || b.h <= 0` → invisible (zero-bounds early reject)
19//! - `root.w <= 0 || root.h <= 0` → conservative pass (unknown root)
20//! - Otherwise → any non-empty rectangle intersection with `tree.bounds`
21//!
22//! Matches swift `TreeRoute.isVisible` (any frame ∩ appFrame intersection)
23//! and maestro `ViewHierarchy.kt:40-50` `isVisible(node)`.
24
25#![doc(html_root_url = "https://docs.smix.dev/smix-screen")]
26
27use serde::{Deserialize, Serialize};
28
29/// Logical-points rectangle (origin top-left, +x right, +y down — matches
30/// UIKit / XCUITest coordinate space). All fields `f64` because the
31/// runner `/tree` route emits floating-point points (sub-pixel scale
32/// factors).
33#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
34pub struct Rect {
35    /// Top-left x coordinate in logical points.
36    pub x: f64,
37    /// Top-left y coordinate in logical points.
38    pub y: f64,
39    /// Width in logical points (zero or negative = invisible).
40    pub w: f64,
41    /// Height in logical points (zero or negative = invisible).
42    pub h: f64,
43}
44
45/// Bounds alias — `Bounds` and `Rect` are used interchangeably.
46/// Downstream code may prefer one name or the other.
47pub type Bounds = Rect;
48
49/// Element summary — projected view of an [`A11yNode`] used in
50/// AI-readable failure prompts and `driver.describe()` output.
51///
52/// `role` is `Some(Role)` for known XCUIElement types, `None` otherwise
53/// (serde serializes `None` as null and omits via `skip_serializing_if`;
54/// readers must accept both shapes).
55#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase")]
57pub struct ElementSummary {
58    /// Semantic role (None when the underlying XCUIElement type doesn't map).
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub role: Option<Role>,
61    /// Primary display name (label → title → text → value → placeholder).
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub name: Option<String>,
64    /// Accessibility identifier (`node.identifier`), if present.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub id: Option<String>,
67    /// Visible text, only when distinct from `name`.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub text: Option<String>,
70    /// Geometric bounds in logical points.
71    pub bounds: Rect,
72    /// Whether the element is currently enabled (interactable).
73    pub enabled: bool,
74}
75
76/// Aggregate screen description. `elements` is a DFS-collected ordered
77/// list of the visible+enabled [`ElementSummary`] entries a reader could
78/// name (anonymous layout containers are skipped); `screenshot` is an
79/// optional base64 PNG.
80///
81/// `frontApp` and `capturedAt` are filled at capture. Only `summary` is
82/// caller-populated. This comment used to call all three of them that,
83/// which is how two fields stayed unconditionally empty while a CLI
84/// help string promised a title and a status bar.
85#[derive(Clone, Debug, Default, Serialize, Deserialize)]
86#[serde(rename_all = "camelCase")]
87pub struct ScreenDescription {
88    /// Optional base64-encoded PNG screenshot of the screen.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub screenshot: Option<String>,
91    /// Nameable visible+enabled elements in DFS pre-order.
92    pub elements: Vec<ElementSummary>,
93    /// Bundle id of the app this description was taken from, read from
94    /// the a11y tree root's identifier.
95    ///
96    /// Deliberately not called "frontmost": the runner reports the app
97    /// it resolved for the request, which is what a caller can act on.
98    /// `None` when the root carried no identifier — distinct from an
99    /// empty string, which would claim knowledge of an empty answer.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub front_app: Option<String>,
102    /// Free-form one-line summary. The caller writes this; nothing in
103    /// smix produces it.
104    pub summary: String,
105    /// Wall-clock capture timestamp (Unix epoch milliseconds).
106    pub captured_at: f64,
107}
108
109/// Collect up to `limit` visible+enabled nodes a reader could name (DFS
110/// pre-order), projecting each via [`summarize_node`]. Default limit = 1000.
111///
112/// Anonymous layout scaffolding is skipped — see [`has_identity`].
113///
114/// "Visible" here means what the resolver means by it: Apple's flag *and*
115/// [`is_visible_enough`]. Filtering on the flag alone let a node with zero
116/// bounds be listed as visible in the same failure message that said the
117/// selector matching it was not visible — one message, two definitions,
118/// and the reader left to guess which one the tool believed.
119#[must_use]
120pub fn collect_visible_summaries(tree: &A11yNode, limit: usize) -> Vec<ElementSummary> {
121    let mut out: Vec<ElementSummary> = Vec::new();
122    fn walk(n: &A11yNode, root: &A11yNode, limit: usize, out: &mut Vec<ElementSummary>) {
123        if out.len() >= limit {
124            return;
125        }
126        if n.enabled && n.visible && is_visible_enough(n, root) {
127            let s = summarize_node(n);
128            if has_identity(&s) {
129                out.push(s);
130            }
131        }
132        for c in &n.children {
133            if out.len() >= limit {
134                return;
135            }
136            walk(c, root, limit, out);
137        }
138    }
139    walk(tree, tree, limit, &mut out);
140    out
141}
142
143/// Whether a summary tells a reader anything.
144///
145/// A node with no role, name, id, or text renders as the bare word "unknown"
146/// — it cannot be recognized or acted on. Real app trees are mostly these:
147/// on a stock Settings screen, 120 of 166 visible nodes are anonymous
148/// layout scaffolding, and they sit at the top in DFS order. A failure
149/// showing its ten "visible elements" would spend nine of them on
150/// `Application → Window → other → other …` and tell the reader nothing,
151/// which defeats the point of putting the screen in the failure at all.
152fn has_identity(s: &ElementSummary) -> bool {
153    s.role.is_some() || s.name.is_some() || s.id.is_some() || s.text.is_some()
154}
155
156/// Default visible-summary limit.
157pub const DEFAULT_VISIBLE_LIMIT: usize = 1000;
158
159/// Project an [`A11yNode`] to an [`ElementSummary`].
160///
161/// `name` priority scan: label → title → text → value → placeholderValue.
162/// `text` is only set when distinct from `name`.
163#[must_use]
164pub fn summarize_node(node: &A11yNode) -> ElementSummary {
165    let name = node
166        .label
167        .clone()
168        .or_else(|| node.title.clone())
169        .or_else(|| node.text.clone())
170        .or_else(|| node.value.clone())
171        .or_else(|| node.placeholder_value.clone());
172    let text = match (&node.text, &name) {
173        (Some(t), Some(n)) if t != n => Some(t.clone()),
174        _ => None,
175    };
176    ElementSummary {
177        role: node.role,
178        name,
179        id: node.identifier.clone(),
180        text,
181        bounds: node.bounds,
182        enabled: node.enabled,
183    }
184}
185
186/// Accessibility role enum (29 variants).
187///
188/// `serde(rename_all = "camelCase")` keeps the JSON wire identical to the
189/// Swift-side `/tree` route output ("staticText" not "static_text").
190#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
191#[serde(rename_all = "camelCase")]
192pub enum Role {
193    /// Tappable button (UIButton / SwiftUI Button).
194    Button,
195    /// Hyperlink (anchor-like target).
196    Link,
197    /// Plain editable text input (UITextField / TextInput).
198    TextField,
199    /// Password / sensitive text input (input masked).
200    SecureTextField,
201    /// Search-style text input (UISearchBar).
202    SearchField,
203    /// On/off toggle (UISwitch).
204    Switch,
205    /// Generic toggle button (two-state).
206    Toggle,
207    /// Multi-state checkbox.
208    CheckBox,
209    /// Radio button (one-of-many select).
210    Radio,
211    /// Image element (UIImageView).
212    Image,
213    /// Read-only display label (UILabel).
214    StaticText,
215    /// Tab element inside a tab bar.
216    Tab,
217    /// Tab bar container (UITabBar).
218    TabBar,
219    /// Top navigation bar (UINavigationBar).
220    NavigationBar,
221    /// List / collection cell (UITableViewCell / UICollectionViewCell).
222    Cell,
223    /// System alert popup (UIAlertController .alert style).
224    Alert,
225    /// Modal dialog (UIAlertController .dialog / custom modal).
226    Dialog,
227    /// Continuous slider input (UISlider).
228    Slider,
229    /// Progress indicator (UIProgressView).
230    ProgressBar,
231    /// Date / wheel-style picker (UIPickerView).
232    Picker,
233    /// Drop-down or action menu.
234    Menu,
235    /// Single menu item inside a Menu.
236    MenuItem,
237    /// Scrollable container (UIScrollView).
238    ScrollView,
239    /// Segmented control (UISegmentedControl).
240    SegmentedControl,
241    /// Table view (UITableView).
242    Table,
243    /// Collection view (UICollectionView).
244    CollectionView,
245    /// Embedded web view (WKWebView).
246    WebView,
247    /// On-screen software keyboard.
248    Keyboard,
249}
250
251impl Role {
252    /// camelCase string name matching the wire `roleSchema` enum variants.
253    /// Used by error / log / describe_selector renderers that need the
254    /// wire form without pulling in serde_json.
255    #[must_use]
256    pub fn as_str(self) -> &'static str {
257        match self {
258            Role::Button => "button",
259            Role::Link => "link",
260            Role::TextField => "textField",
261            Role::SecureTextField => "secureTextField",
262            Role::SearchField => "searchField",
263            Role::Switch => "switch",
264            Role::Toggle => "toggle",
265            Role::CheckBox => "checkBox",
266            Role::Radio => "radio",
267            Role::Image => "image",
268            Role::StaticText => "staticText",
269            Role::Tab => "tab",
270            Role::TabBar => "tabBar",
271            Role::NavigationBar => "navigationBar",
272            Role::Cell => "cell",
273            Role::Alert => "alert",
274            Role::Dialog => "dialog",
275            Role::Slider => "slider",
276            Role::ProgressBar => "progressBar",
277            Role::Picker => "picker",
278            Role::Menu => "menu",
279            Role::MenuItem => "menuItem",
280            Role::ScrollView => "scrollView",
281            Role::SegmentedControl => "segmentedControl",
282            Role::Table => "table",
283            Role::CollectionView => "collectionView",
284            Role::WebView => "webView",
285            Role::Keyboard => "keyboard",
286        }
287    }
288}
289
290impl std::fmt::Display for Role {
291    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292        f.write_str(self.as_str())
293    }
294}
295
296/// Derive the curated [`Role`] from the raw XCUIElement type name (e.g.
297/// `"button"`, `"radioButton"`, `"progressIndicator"`).
298///
299/// The Swift `/tree` route only emits `rawType` on the wire — it never
300/// fills `role` — so the Rust-side `A11yNode.role` is `None` for every
301/// real-sim payload. This function gives the sense layer a single
302/// canonical place to lift `rawType` strings into semantic [`Role`]
303/// values, matching the reverse direction of the swift
304/// `TreeRoute.elementTypeName(_:)` table.
305///
306/// Returns `None` when the raw type has no curated semantic (`"any"`,
307/// `"other"`, `"window"`, `"group"`, …) — Selector::Role still won't
308/// match those, which is the intended behaviour.
309#[must_use]
310pub fn role_from_raw_type(raw_type: &str) -> Option<Role> {
311    Some(match raw_type {
312        "button" => Role::Button,
313        "link" => Role::Link,
314        "textField" => Role::TextField,
315        "secureTextField" => Role::SecureTextField,
316        "searchField" => Role::SearchField,
317        "switch" => Role::Switch,
318        "toggle" => Role::Toggle,
319        "checkBox" => Role::CheckBox,
320        // Swift wire uses "radioButton"; Rust enum uses Radio.
321        "radioButton" => Role::Radio,
322        "image" => Role::Image,
323        "staticText" => Role::StaticText,
324        "tabBar" => Role::TabBar,
325        "navigationBar" => Role::NavigationBar,
326        "cell" => Role::Cell,
327        "alert" => Role::Alert,
328        "dialog" => Role::Dialog,
329        "slider" => Role::Slider,
330        // Swift wire uses "progressIndicator"; Rust enum uses ProgressBar.
331        "progressIndicator" => Role::ProgressBar,
332        "picker" => Role::Picker,
333        "menu" => Role::Menu,
334        "menuItem" => Role::MenuItem,
335        "scrollView" => Role::ScrollView,
336        "segmentedControl" => Role::SegmentedControl,
337        "table" => Role::Table,
338        "collectionView" => Role::CollectionView,
339        "webView" => Role::WebView,
340        "keyboard" => Role::Keyboard,
341        // Role::Tab has no corresponding swift elementTypeName case —
342        // tabs come through as their containing element type. Leave None.
343        _ => return None,
344    })
345}
346
347/// Recursively fill `node.role` from `node.raw_type` whenever it is
348/// currently `None`. Host-set roles (test fixtures, recorder output) are
349/// left untouched.
350///
351/// Call once on the root after a wire deserialize (runner /tree response,
352/// recorder snapshot replay, ...) to make `Selector::Role` work against
353/// real-sim payloads where the wire only carries `rawType`.
354///
355/// iOS `UITabBar` items are `button`s nested inside a `tabBar` subtree
356/// — there is NO distinct tab `XCUIElement.ElementType` (the swift
357/// `elementTypeName` table has no "tab" case), so `rawType` alone can
358/// never yield [`Role::Tab`]. A button that lives anywhere inside a
359/// `tabBar` subtree is structurally a tab item, so it derives
360/// [`Role::Tab`] instead of [`Role::Button`]. The inference is ancestor-
361/// based (the real tree nests the tab buttons under wrapper `other`
362/// nodes), the only locale-invariant way to make
363/// `Selector::Role { Role::Tab }` match real tab-bar items.
364pub fn derive_roles_recursive(node: &mut A11yNode) {
365    derive_roles_inner(node, false);
366}
367
368fn derive_roles_inner(node: &mut A11yNode, inside_tab_bar: bool) {
369    if node.role.is_none() {
370        node.role = if inside_tab_bar && node.raw_type == "button" {
371            Some(Role::Tab)
372        } else {
373            role_from_raw_type(&node.raw_type)
374        };
375    }
376    let child_inside = inside_tab_bar || node.raw_type == "tabBar";
377    for child in &mut node.children {
378        derive_roles_inner(child, child_inside);
379    }
380}
381
382/// Older wire payloads predate the `elementTypeRaw` field; default to
383/// 1 (`.other`), which is the safest fallback and matches how Swift
384/// `elementTypeName` treats unknown raw values.
385fn default_element_type_raw() -> u64 {
386    1
387}
388
389/// Accessibility tree node — a single-node snapshot as returned by `/tree`,
390/// mirroring the Swift-side `TreeRoute.nodeToDict` shape.
391///
392/// `rawType` carries the underlying Apple `XCUIElement.ElementType` raw
393/// name (e.g. "any", "other", "staticText"); `role` is the curated semantic
394/// mapping (None when XCUIElement type doesn't map to a known [`Role`]).
395///
396/// Each optional string field maps to a single Apple a11y attribute, in
397/// the order the standard iOS a11y drivers scan them.
398///
399/// `#[serde(default)]` on the recursive `children: Vec<A11yNode>` allows
400/// terminal nodes in JSON to omit the field entirely (`/tree` route emits
401/// `"children":[]` consistently but we accept both for forward-compat).
402#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
403#[serde(rename_all = "camelCase")]
404pub struct A11yNode {
405    /// Raw Apple `XCUIElement.ElementType` name (e.g. `"any"`, `"other"`).
406    pub raw_type: String,
407    /// Raw numeric `XCUIElement.ElementType.rawValue`.
408    /// `raw_type` above is a string form derived by `elementTypeName`,
409    /// but consumers debugging a degraded a11y tree (RN Fabric on iOS
410    /// 26.5 is the motivating case) need the numeric form to spot
411    /// "iOS types this as .button (9) but identifier / label empty"
412    /// — the signature of an app-side accessibility-bridge drop. The
413    /// numeric form also disambiguates the alert/dialog button
414    /// promotion (rawType is lifted to "button" for consumer
415    /// selectors, but the original ElementType number stays here).
416    /// Defaults to 1 (`.other`) for wire payloads that omit it.
417    #[serde(default = "default_element_type_raw")]
418    pub element_type_raw: u64,
419    /// Curated semantic role; None when the raw type doesn't map.
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub role: Option<Role>,
422    /// Accessibility identifier (Apple `accessibilityIdentifier`).
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub identifier: Option<String>,
425    /// Accessibility label (Apple `accessibilityLabel`).
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub label: Option<String>,
428    /// Element title (Apple `title`).
429    #[serde(default, skip_serializing_if = "Option::is_none")]
430    pub title: Option<String>,
431    /// Placeholder text shown when the field is empty.
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    pub placeholder_value: Option<String>,
434    /// Element value (Apple `value`).
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub value: Option<String>,
437    /// Visible text content (Apple `text`).
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub text: Option<String>,
440    /// Geometric bounds in logical points.
441    pub bounds: Rect,
442    /// Whether the element is currently interactable.
443    pub enabled: bool,
444    /// Whether the element is currently selected.
445    pub selected: bool,
446    /// Whether the element currently has keyboard focus.
447    pub has_focus: bool,
448    /// Whether Apple's accessibility runtime reports this element as visible.
449    pub visible: bool,
450    /// Child nodes in stable DFS pre-order.
451    #[serde(default)]
452    pub children: Vec<A11yNode>,
453}
454
455// -------------------- visibility primitives ------------------------------
456
457/// Visibility check.
458///
459/// Returns `false` for nodes with zero-or-negative bounds (early reject).
460/// Returns `true` when tree.bounds is unknown (`w<=0||h<=0`) — conservative
461/// pass: a node with sensible bounds shouldn't be filtered just because we
462/// don't have a viewport to clip against. Otherwise checks for any
463/// non-empty rectangle intersection between `node.bounds` and `tree.bounds`.
464///
465/// Pure / branch-only / no allocations — LLVM should inline aggressively.
466#[inline]
467#[must_use]
468pub fn is_visible_enough(node: &A11yNode, tree: &A11yNode) -> bool {
469    let b = node.bounds;
470    if b.w <= 0.0 || b.h <= 0.0 {
471        return false;
472    }
473    let root = tree.bounds;
474    if root.w <= 0.0 || root.h <= 0.0 {
475        return true; // unknown root, conservative pass
476    }
477    let x1 = b.x.max(root.x);
478    let y1 = b.y.max(root.y);
479    let x2 = (b.x + b.w).min(root.x + root.w);
480    let y2 = (b.y + b.h).min(root.y + root.h);
481    x2 > x1 && y2 > y1
482}
483
484/// Intersection area in logical points².
485///
486/// Used by resolver multi-candidate sorting (favor truly visible elements
487/// over partial-offscreen residuals). Returns `0.0` for zero-bounds
488/// nodes; returns `b.w * b.h` when tree.bounds is unknown.
489#[inline]
490#[must_use]
491pub fn visible_area(node: &A11yNode, tree: &A11yNode) -> f64 {
492    let b = node.bounds;
493    if b.w <= 0.0 || b.h <= 0.0 {
494        return 0.0;
495    }
496    let root = tree.bounds;
497    if root.w <= 0.0 || root.h <= 0.0 {
498        return b.w * b.h;
499    }
500    let x1 = b.x.max(root.x);
501    let y1 = b.y.max(root.y);
502    let x2 = (b.x + b.w).min(root.x + root.w);
503    let y2 = (b.y + b.h).min(root.y + root.h);
504    if x2 <= x1 || y2 <= y1 {
505        return 0.0;
506    }
507    (x2 - x1) * (y2 - y1)
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    fn node(
515        raw_type: &str,
516        id: Option<&str>,
517        label: Option<&str>,
518        children: Vec<A11yNode>,
519    ) -> A11yNode {
520        A11yNode {
521            raw_type: raw_type.into(),
522            element_type_raw: 1,
523            role: role_from_raw_type(raw_type),
524            identifier: id.map(str::to_string),
525            label: label.map(str::to_string),
526            title: None,
527            placeholder_value: None,
528            value: None,
529            text: None,
530            bounds: Rect {
531                x: 0.0,
532                y: 0.0,
533                w: 10.0,
534                h: 10.0,
535            },
536            enabled: true,
537            selected: false,
538            has_focus: false,
539            visible: true,
540            children,
541        }
542    }
543
544    /// A real app tree, in miniature: the button is buried under layers of
545    /// anonymous layout. Measured on a stock Settings screen, 120 of 166
546    /// visible nodes look like these wrappers.
547    fn scaffolded_tree() -> A11yNode {
548        node(
549            "application",
550            Some("com.apple.Preferences"),
551            Some("Settings"),
552            vec![node(
553                "window",
554                None,
555                None,
556                vec![node(
557                    "other",
558                    None,
559                    None,
560                    vec![node(
561                        "other",
562                        None,
563                        None,
564                        vec![node(
565                            "other",
566                            None,
567                            None,
568                            vec![node(
569                                "button",
570                                Some("com.apple.settings.general"),
571                                Some("General"),
572                                vec![],
573                            )],
574                        )],
575                    )],
576                )],
577            )],
578        )
579    }
580
581    #[test]
582    fn anonymous_scaffolding_does_not_take_up_the_reader_s_slots() {
583        // The failure surface asks for ten. Spending nine on `other → other →
584        // other` tells the reader nothing about the screen.
585        let got = collect_visible_summaries(&scaffolded_tree(), 10);
586        assert!(
587            got.iter().all(|s| s.role.is_some()
588                || s.name.is_some()
589                || s.id.is_some()
590                || s.text.is_some()),
591            "every entry must be something a reader can recognize; got {got:?}"
592        );
593    }
594
595    #[test]
596    fn the_element_a_reader_wants_survives_a_small_limit() {
597        // Before the filter, a limit of 3 returned application + window +
598        // other, and the button — the thing you would tap — never appeared.
599        let got = collect_visible_summaries(&scaffolded_tree(), 3);
600        assert!(
601            got.iter()
602                .any(|s| s.id.as_deref() == Some("com.apple.settings.general")),
603            "the named button must reach a short list; got {got:?}"
604        );
605    }
606
607    #[test]
608    fn a_node_with_an_id_but_no_role_still_counts() {
609        // Role is None for uncurated types. An id alone is plenty to act on.
610        let tree = node("other", Some("qa-bubble"), None, vec![]);
611        let got = collect_visible_summaries(&tree, 10);
612        assert_eq!(got.len(), 1, "an id is identity enough; got {got:?}");
613    }
614
615    #[test]
616    fn invisible_and_disabled_nodes_stay_out() {
617        let mut hidden = node("button", Some("hidden-btn"), None, vec![]);
618        hidden.visible = false;
619        let mut off = node("button", Some("disabled-btn"), None, vec![]);
620        off.enabled = false;
621        let tree = node("application", Some("app"), None, vec![hidden, off]);
622        let got = collect_visible_summaries(&tree, 10);
623        assert_eq!(
624            got.len(),
625            1,
626            "only the app node is visible+enabled; got {got:?}"
627        );
628    }
629}