waydriver 0.2.0

Headless GUI testing for Wayland applications via AT-SPI accessibility APIs and PipeWire screen capture
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
//! XPath-based lazy locators for AT-SPI elements.
//!
//! A [`Locator`] bundles an [`Arc<Session>`] with an XPath expression. It
//! does **not** resolve until an async method is called on it — every
//! resolution takes a fresh AT-SPI snapshot, so locators survive widget
//! reparenting and destruction+recreation (dialog close/reopen, virtualized
//! list scroll, etc.) without manual retries.
//!
//! **Auto-wait.** Action methods (`click`, `set_text`) and metadata reads
//! (`name`, `role`, `text`, …) automatically poll with exponential backoff
//! until the element is resolvable — and, for actions, actionable (showing
//! and enabled) — within the session's default timeout. Override per-locator
//! with [`Locator::with_timeout`]. Explicit `wait_for_*` methods give tests
//! a way to poll on arbitrary state changes without implicitly tying them to
//! an action.
//!
//! Single-target methods (`click`, `name`, `text`, …) expect the selector to
//! match exactly one element and return [`Error::AmbiguousSelector`]
//! immediately — ambiguity is treated as a selector bug, not a retriable
//! condition. Disambiguate with [`Locator::nth`] / [`Locator::first`] /
//! [`Locator::last`] or refine the XPath.

use std::collections::HashMap;
use std::future::Future;
use std::sync::Arc;
use std::time::{Duration, Instant};

use atspi::connection::AccessibilityConnection;

use crate::atspi as atspi_client;
use crate::atspi::ElementInfo;
use crate::error::{Error, Result};
use crate::session::Session;

/// Initial backoff delay between poll attempts. Doubles each failed attempt
/// up to [`MAX_POLL_DELAY`].
const INITIAL_POLL_DELAY: Duration = Duration::from_millis(50);

/// Upper bound on the backoff delay. Keeps a very long timeout from
/// accumulating too much wait between attempts.
const MAX_POLL_DELAY: Duration = Duration::from_millis(500);

/// A lazy, re-resolving handle to one or more AT-SPI elements.
///
/// See the [module-level documentation](crate::locator) for the resolution model.
#[derive(Clone)]
pub struct Locator {
    session: Arc<Session>,
    xpath: String,
    /// Per-locator timeout override for auto-wait and `wait_for_*` calls.
    /// `None` means "use the session's default timeout at call time," which
    /// lets [`Session::set_default_timeout`] affect locators created before
    /// the change.
    timeout: Option<Duration>,
}

impl Locator {
    pub(crate) fn new(session: Arc<Session>, xpath: String) -> Self {
        Self {
            session,
            xpath,
            timeout: None,
        }
    }

    /// The XPath expression this locator resolves with.
    pub fn xpath(&self) -> &str {
        &self.xpath
    }

    /// Return a new locator with a per-call timeout override for auto-wait
    /// and `wait_for_*` methods. `Duration::ZERO` means "try once, don't
    /// wait," useful for negative assertions ("this element should NOT
    /// exist right now").
    pub fn with_timeout(&self, timeout: Duration) -> Locator {
        Locator {
            session: self.session.clone(),
            xpath: self.xpath.clone(),
            timeout: Some(timeout),
        }
    }

    // ── Composition (pure string manipulation, no I/O) ─────────────────────
    //
    // Composition preserves the per-locator timeout override, so a caller can
    // set a timeout once and it flows through `.nth()`, `.locate()`, etc.

    /// Scope a sub-expression to the nodes matched by this locator.
    ///
    /// If `sub` is absolute (starts with `/`), it replaces the current
    /// selector entirely. Otherwise it's evaluated as descendants of the
    /// current matches: `(self)//sub`.
    pub fn locate(&self, sub: &str) -> Locator {
        let trimmed = sub.trim();
        let new_xpath = if trimmed.starts_with('/') {
            trimmed.to_string()
        } else {
            format!("({})//{}", self.xpath, trimmed)
        };
        self.with_xpath(new_xpath)
    }

    /// Return a locator pinned to the `n`-th (0-indexed) match of this one.
    pub fn nth(&self, n: usize) -> Locator {
        self.with_xpath(format!("({})[{}]", self.xpath, n + 1))
    }

    /// Shorthand for `nth(0)`.
    pub fn first(&self) -> Locator {
        self.nth(0)
    }

    /// Locator for the last match of this selector.
    pub fn last(&self) -> Locator {
        self.with_xpath(format!("({})[last()]", self.xpath))
    }

    /// Locator for the parent of the matched element(s).
    pub fn parent(&self) -> Locator {
        self.with_xpath(format!("({})/..", self.xpath))
    }

    fn with_xpath(&self, xpath: String) -> Locator {
        Locator {
            session: self.session.clone(),
            xpath,
            timeout: self.timeout,
        }
    }

    // ── Enumeration ─────────────────────────────────────────────────────────

    /// Number of elements matched by this selector. Does not auto-wait —
    /// returns the current count, which may be zero.
    pub async fn count(&self) -> Result<usize> {
        Ok(self.resolve_all_once().await?.len())
    }

    /// Enumerate each match as a locator pinned by ordinal.
    ///
    /// Each returned locator still re-resolves (so ordinal pins are
    /// evaluated on each use, not frozen to the AT-SPI identity observed at
    /// `all()` time).
    pub async fn all(&self) -> Result<Vec<Locator>> {
        let n = self.count().await?;
        Ok((0..n).map(|i| self.nth(i)).collect())
    }

    /// Take one AT-SPI snapshot and return full metadata for every match.
    ///
    /// More efficient than calling `all()` and then metadata methods on each
    /// returned locator, which would re-snapshot per match.
    pub async fn inspect_all(&self) -> Result<Vec<ElementInfo>> {
        let a11y = self.a11y()?;
        let xml =
            atspi_client::snapshot_tree(a11y, &self.session.app_bus_name, &self.session.app_path)
                .await?;
        atspi_client::evaluate_xpath_detailed(&xml, &self.xpath)
    }

    // ── Live metadata (auto-waits for the element to exist) ────────────────
    //
    // Every read re-snapshots the AT-SPI tree, so data is always as fresh as
    // the current call. The snapshot XML already captures name, role, states,
    // and toolkit attributes — no second D-Bus round-trip per field.

    /// Accessible name of the matched element, or `None` when the element
    /// has no accessible name set.
    pub async fn name(&self) -> Result<Option<String>> {
        Ok(self.wait_for_existing().await?.name)
    }

    /// Raw AT-SPI role name (e.g. `"push button"`, `"menu item"`).
    ///
    /// Falls back to the PascalCase XML element tag only when the snapshot
    /// lacks a `role` attribute — which shouldn't happen for live snapshots,
    /// but can in hand-crafted test XML.
    pub async fn role(&self) -> Result<String> {
        let info = self.wait_for_existing().await?;
        Ok(info.role_raw.unwrap_or(info.role))
    }

    /// Read a single toolkit attribute by key.
    pub async fn attribute(&self, key: &str) -> Result<Option<String>> {
        Ok(self.wait_for_existing().await?.attributes.remove(key))
    }

    /// All toolkit attributes as a map.
    pub async fn attributes(&self) -> Result<HashMap<String, String>> {
        Ok(self.wait_for_existing().await?.attributes)
    }

    /// Whether the matched element currently has the `Showing` state.
    pub async fn is_showing(&self) -> Result<bool> {
        self.has_state("showing").await
    }

    /// Whether the matched element is currently interactable.
    ///
    /// Returns true when the element has either the AT-SPI `Enabled` state
    /// or the `Sensitive` state — GTK reports the latter, Qt/others the
    /// former. Both mean "user can interact with this widget right now."
    pub async fn is_enabled(&self) -> Result<bool> {
        let info = self.wait_for_existing().await?;
        Ok(is_enabled_in(&info.states))
    }

    /// Text contents of the matched element via the AT-SPI Text interface.
    /// Unlike other metadata, text isn't captured in the snapshot — each
    /// call makes a live read through the Text proxy after auto-waiting for
    /// the element to exist.
    pub async fn text(&self) -> Result<String> {
        let info = self.wait_for_existing().await?;
        let a11y = self.a11y()?;
        let (bus, path) = info.ref_;
        atspi_client::read_text_on(a11y, &self.xpath, &bus, &path).await
    }

    // ── Actions (auto-wait for actionability) ──────────────────────────────

    /// Invoke the primary action (index 0) on the matched element.
    ///
    /// Auto-waits for the element to be resolvable, showing, and enabled
    /// within the effective timeout. Requires exactly one match.
    pub async fn click(&self) -> Result<()> {
        let info = self.wait_for_actionable().await?;
        let (bus, path) = info.ref_;
        let a11y = self.a11y()?;
        atspi_client::do_action_on(a11y, &self.xpath, &bus, &path).await
    }

    /// Replace the contents of an editable text element.
    ///
    /// Auto-waits for the element to be resolvable, showing, and enabled.
    pub async fn set_text(&self, text: &str) -> Result<()> {
        let info = self.wait_for_actionable().await?;
        let (bus, path) = info.ref_;
        let a11y = self.a11y()?;
        atspi_client::set_text_on(a11y, &self.xpath, &bus, &path, text).await
    }

    /// Give keyboard focus to the matched element.
    ///
    /// Auto-waits for the element to be resolvable, showing, and `focusable`
    /// — the last is a weaker check than "actionable" because some widgets
    /// accept focus without accepting activation (read-only text boxes,
    /// scroll regions, etc.). Uses AT-SPI's `Component::grab_focus` under
    /// the hood.
    ///
    /// ## Toolkit caveats
    ///
    /// This relies on the target widget implementing the AT-SPI Component
    /// interface. Some toolkits (notably GTK4 in its current form) don't
    /// expose Component on all widgets — you may see
    /// `Error::Atspi("NotSupported")` from `grab_focus` even when the
    /// widget is visibly focusable on screen. When that happens the
    /// fallback is to drive focus via keyboard navigation (Tab /
    /// Shift+Tab) or synthesize a pointer click.
    pub async fn focus(&self) -> Result<()> {
        let info = self.wait_for_focusable().await?;
        let (bus, path) = info.ref_;
        let a11y = self.a11y()?;
        atspi_client::grab_focus_on(a11y, &self.xpath, &bus, &path).await
    }

    // ── Explicit waits ─────────────────────────────────────────────────────

    /// Poll until the element exists and has the `Showing` state. Returns
    /// `Ok(())` on success or the last encountered retriable error (or a
    /// `Timeout` error if the element existed but never became showing).
    pub async fn wait_for_visible(&self) -> Result<()> {
        let xpath = self.xpath.clone();
        poll_with_retry(self.effective_timeout(), &xpath, || async {
            let info = self.resolve_once_info().await?;
            if info.states.iter().any(|s| s == "showing") {
                Ok(Some(()))
            } else {
                Ok(None)
            }
        })
        .await
    }

    /// Poll until the element either doesn't exist or doesn't have the
    /// `Showing` state. The inverse of [`wait_for_visible`](Self::wait_for_visible).
    pub async fn wait_for_hidden(&self) -> Result<()> {
        let xpath = self.xpath.clone();
        poll_with_retry(self.effective_timeout(), &xpath, || async {
            match self.resolve_once_info().await {
                Ok(info) => {
                    if info.states.iter().any(|s| s == "showing") {
                        Ok(None) // still visible, keep polling
                    } else {
                        Ok(Some(()))
                    }
                }
                Err(Error::ElementNotFound { .. }) => Ok(Some(())), // gone entirely
                Err(e) => Err(e),
            }
        })
        .await
    }

    /// Poll until the element exists and is interactable (has either the
    /// `Enabled` or `Sensitive` state — see [`Locator::is_enabled`] for why
    /// both are treated as equivalent).
    pub async fn wait_for_enabled(&self) -> Result<()> {
        let xpath = self.xpath.clone();
        poll_with_retry(self.effective_timeout(), &xpath, || async {
            let info = self.resolve_once_info().await?;
            if is_enabled_in(&info.states) {
                Ok(Some(()))
            } else {
                Ok(None)
            }
        })
        .await
    }

    /// Poll until the selector matches exactly `n` elements. Useful for
    /// lists that populate asynchronously after a user action.
    pub async fn wait_for_count(&self, n: usize) -> Result<()> {
        let xpath = self.xpath.clone();
        poll_with_retry(self.effective_timeout(), &xpath, || async {
            let hits = self.resolve_all_once().await?;
            if hits.len() == n {
                Ok(Some(()))
            } else {
                Ok(None)
            }
        })
        .await
    }

    /// Poll until the element's text contents satisfy `pred`. Returns the
    /// matching text on success so the caller can inspect it further.
    pub async fn wait_for_text<F>(&self, pred: F) -> Result<String>
    where
        F: Fn(&str) -> bool,
    {
        let xpath = self.xpath.clone();
        poll_with_retry(self.effective_timeout(), &xpath, || async {
            let info = self.resolve_once_info().await?;
            let a11y = self.a11y()?;
            let (bus, path) = info.ref_;
            let text = atspi_client::read_text_on(a11y, &self.xpath, &bus, &path).await?;
            if pred(&text) {
                Ok(Some(text))
            } else {
                Ok(None)
            }
        })
        .await
    }

    // ── Internals ──────────────────────────────────────────────────────────

    async fn has_state(&self, state: &str) -> Result<bool> {
        Ok(self
            .wait_for_existing()
            .await?
            .states
            .iter()
            .any(|s| s == state))
    }

    fn a11y(&self) -> Result<&AccessibilityConnection> {
        self.session
            .a11y_connection
            .as_ref()
            .ok_or_else(|| Error::Atspi("session has no AT-SPI connection".into()))
    }

    /// Effective timeout for this locator: the per-locator override if set,
    /// otherwise the session's current default timeout.
    fn effective_timeout(&self) -> Duration {
        self.timeout
            .unwrap_or_else(|| self.session.default_timeout())
    }

    async fn snapshot(&self) -> Result<String> {
        let a11y = self.a11y()?;
        atspi_client::snapshot_tree(a11y, &self.session.app_bus_name, &self.session.app_path).await
    }

    /// Single-shot: snapshot + evaluate_xpath, no retry.
    async fn resolve_all_once(&self) -> Result<Vec<(String, String)>> {
        let xml = self.snapshot().await?;
        atspi_client::evaluate_xpath(&xml, &self.xpath)
    }

    /// Single-shot: snapshot + evaluate_xpath_detailed + expect-one, no retry.
    /// `ElementNotFound` if zero matches, `AmbiguousSelector` if more than one.
    async fn resolve_once_info(&self) -> Result<ElementInfo> {
        let xml = self.snapshot().await?;
        let mut hits = atspi_client::evaluate_xpath_detailed(&xml, &self.xpath)?;
        select_exactly_one(&self.xpath, hits.len())?;
        Ok(hits.pop().unwrap())
    }

    /// Auto-wait: poll until the selector resolves to exactly one element.
    /// Retries on `ElementNotFound`/`ElementStale`; fatal on `InvalidSelector`
    /// and `AmbiguousSelector`.
    async fn wait_for_existing(&self) -> Result<ElementInfo> {
        let xpath = self.xpath.clone();
        poll_with_retry(self.effective_timeout(), &xpath, || async {
            Ok(Some(self.resolve_once_info().await?))
        })
        .await
    }

    /// Auto-wait: poll until the selector resolves to exactly one element
    /// that is visible on screen and interactable. "Visible" = the `Showing`
    /// state; "interactable" = either `Enabled` or `Sensitive` — toolkits
    /// differ on which they report (GTK → Sensitive, Qt → Enabled).
    async fn wait_for_actionable(&self) -> Result<ElementInfo> {
        let xpath = self.xpath.clone();
        poll_with_retry(self.effective_timeout(), &xpath, || async {
            let info = self.resolve_once_info().await?;
            let showing = info.states.iter().any(|s| s == "showing");
            if showing && is_enabled_in(&info.states) {
                Ok(Some(info))
            } else {
                Ok(None)
            }
        })
        .await
    }

    /// Auto-wait: poll until the selector resolves to exactly one element
    /// that is showing and has the `Focusable` state. Weaker than
    /// actionability because a read-only but navigable widget can accept
    /// focus without accepting activation.
    async fn wait_for_focusable(&self) -> Result<ElementInfo> {
        let xpath = self.xpath.clone();
        poll_with_retry(self.effective_timeout(), &xpath, || async {
            let info = self.resolve_once_info().await?;
            let showing = info.states.iter().any(|s| s == "showing");
            let focusable = info.states.iter().any(|s| s == "focusable");
            if showing && focusable {
                Ok(Some(info))
            } else {
                Ok(None)
            }
        })
        .await
    }
}

/// Whether the given snapshot state-set represents an "interactable"
/// element. AT-SPI has two closely-related states here: `Enabled` (the
/// newer, more generic name) and `Sensitive` (GTK's legacy name for the
/// same concept). Different toolkits report one, the other, or both, so
/// auto-wait and `is_enabled` accept either.
fn is_enabled_in(states: &[String]) -> bool {
    states.iter().any(|s| s == "enabled" || s == "sensitive")
}

/// Classify the match count from a single-target selector resolution:
/// zero → `ElementNotFound`, one → `Ok(())`, more than one →
/// `AmbiguousSelector`. Leaves the Vec intact so callers can pop the sole
/// element themselves.
fn select_exactly_one(xpath: &str, count: usize) -> Result<()> {
    match count {
        0 => Err(Error::ElementNotFound {
            xpath: xpath.to_string(),
        }),
        1 => Ok(()),
        n => Err(Error::AmbiguousSelector {
            xpath: xpath.to_string(),
            count: n,
        }),
    }
}

/// Poll `f` with exponential backoff until it returns `Ok(Some(T))`, a
/// non-retriable error, or the `timeout` deadline elapses.
///
/// Retriable errors ([`Error::ElementNotFound`], [`Error::ElementStale`]) are
/// swallowed and retried. Fatal errors ([`Error::InvalidSelector`],
/// [`Error::AmbiguousSelector`], etc.) return immediately.
///
/// On timeout with a retriable last error, that error is surfaced directly
/// so callers can still pattern-match on `ElementNotFound` / `ElementStale`.
/// On timeout where the predicate returned `Ok(None)` (element exists but
/// some state isn't satisfied), a [`Error::Timeout`] is returned with the
/// xpath context.
pub(crate) async fn poll_with_retry<T, F, Fut>(
    timeout: Duration,
    xpath: &str,
    mut f: F,
) -> Result<T>
where
    F: FnMut() -> Fut,
    Fut: Future<Output = Result<Option<T>>>,
{
    let deadline = Instant::now() + timeout;
    let mut delay = INITIAL_POLL_DELAY;
    // The initial `None` is overwritten on every first iteration, but rustc's
    // liveness analysis doesn't see that — `#[allow]` is cleaner than
    // restructuring around a declare-before-init pattern.
    #[allow(unused_assignments)]
    let mut last_err: Option<Error> = None;
    let mut attempts: u32 = 0;
    loop {
        attempts += 1;
        match f().await {
            Ok(Some(v)) => return Ok(v),
            Ok(None) => {
                // Predicate observed the element but its state wasn't yet
                // satisfied. Clear last_err so we don't surface a stale
                // not-found from an earlier attempt when the element
                // appeared but isn't quite ready.
                last_err = None;
            }
            Err(e) if is_retriable(&e) => {
                last_err = Some(e);
            }
            Err(e) => return Err(e),
        }

        if Instant::now() >= deadline {
            return Err(last_err.unwrap_or_else(|| {
                Error::Timeout(format!(
                    "wait for '{xpath}' timed out after {attempts} attempt(s) \
                     ({}ms budget)",
                    timeout.as_millis()
                ))
            }));
        }

        tokio::time::sleep(delay).await;
        delay = (delay * 2).min(MAX_POLL_DELAY);
    }
}

/// Whether an error during polling should be swallowed and retried.
fn is_retriable(e: &Error) -> bool {
    matches!(
        e,
        Error::ElementNotFound { .. } | Error::ElementStale { .. }
    )
}

#[cfg(test)]
mod tests {
    use super::{is_retriable, poll_with_retry, select_exactly_one, Error};
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::sync::Arc;
    use std::time::Duration;

    // We can't instantiate a Locator without a Session, so composition
    // tests mirror the pure string logic via these helpers.

    fn compose_locate(outer: &str, sub: &str) -> String {
        let trimmed = sub.trim();
        if trimmed.starts_with('/') {
            trimmed.to_string()
        } else {
            format!("({outer})//{trimmed}")
        }
    }

    fn compose_nth(outer: &str, n: usize) -> String {
        format!("({outer})[{}]", n + 1)
    }

    fn compose_parent(outer: &str) -> String {
        format!("({outer})/..")
    }

    #[test]
    fn locate_relative_scopes() {
        assert_eq!(
            compose_locate("//Dialog[@name='X']", "PushButton"),
            "(//Dialog[@name='X'])//PushButton"
        );
    }

    #[test]
    fn locate_absolute_replaces() {
        assert_eq!(compose_locate("//Dialog", "//Menu"), "//Menu");
    }

    #[test]
    fn nth_is_one_indexed_in_xpath() {
        assert_eq!(compose_nth("//PushButton", 0), "(//PushButton)[1]");
        assert_eq!(compose_nth("//PushButton", 4), "(//PushButton)[5]");
    }

    #[test]
    fn parent_appends_dot_dot() {
        assert_eq!(
            compose_parent("//PushButton[@name='OK']"),
            "(//PushButton[@name='OK'])/.."
        );
    }

    // ── select_exactly_one dispatch ─────────────────────────────────────────

    #[test]
    fn select_exactly_one_zero_is_not_found() {
        let err = select_exactly_one("//Missing", 0).unwrap_err();
        assert!(matches!(err, Error::ElementNotFound { .. }));
        // Error carries the xpath so callers can see what didn't match.
        assert!(err.to_string().contains("//Missing"));
    }

    #[test]
    fn select_exactly_one_one_is_ok() {
        assert!(select_exactly_one("//PushButton[@name='OK']", 1).is_ok());
    }

    #[test]
    fn select_exactly_one_many_is_ambiguous_with_count() {
        let err = select_exactly_one("//PushButton", 7).unwrap_err();
        match err {
            Error::AmbiguousSelector { count, xpath } => {
                assert_eq!(count, 7);
                assert_eq!(xpath, "//PushButton");
            }
            other => panic!("expected AmbiguousSelector, got {other:?}"),
        }
    }

    // ── Real Locator methods against a test Session ─────────────────────────
    //
    // These use Session::new_for_test (cfg(test)-gated) to construct a
    // Session with no AT-SPI connection. Composition methods never touch
    // the connection, so they work fine; async I/O methods are covered
    // separately by e2e tests against a real compositor.

    use std::path::{Path, PathBuf};

    use async_trait::async_trait;

    use crate::backend::{CaptureBackend, CompositorRuntime, InputBackend, PipeWireStream};
    use crate::error::Result as WdResult;
    use crate::session::Session;

    struct StubCompositor;
    #[async_trait]
    impl CompositorRuntime for StubCompositor {
        async fn start(&mut self, _resolution: Option<&str>) -> WdResult<()> {
            Ok(())
        }
        async fn stop(&mut self) -> WdResult<()> {
            Ok(())
        }
        fn id(&self) -> &str {
            "stub"
        }
        fn wayland_display(&self) -> &str {
            "wayland-stub"
        }
        fn runtime_dir(&self) -> &Path {
            Path::new("/tmp")
        }
    }

    struct StubInput;
    #[async_trait]
    impl InputBackend for StubInput {
        async fn press_keysym(&self, _keysym: u32) -> WdResult<()> {
            Ok(())
        }
        async fn key_down(&self, _keysym: u32) -> WdResult<()> {
            Ok(())
        }
        async fn key_up(&self, _keysym: u32) -> WdResult<()> {
            Ok(())
        }
        async fn pointer_motion_relative(&self, _dx: f64, _dy: f64) -> WdResult<()> {
            Ok(())
        }
        async fn pointer_button(&self, _button: u32) -> WdResult<()> {
            Ok(())
        }
    }

    struct StubCapture;
    #[async_trait]
    impl CaptureBackend for StubCapture {
        async fn start_stream(&self) -> WdResult<PipeWireStream> {
            unimplemented!("not used in composition tests")
        }
        async fn stop_stream(&self, _stream: PipeWireStream) -> WdResult<()> {
            Ok(())
        }
        fn pipewire_socket(&self) -> PathBuf {
            PathBuf::from("/tmp/stub")
        }
    }

    fn test_session() -> Arc<Session> {
        Arc::new(Session::new_for_test(
            "stub".into(),
            "app".into(),
            Box::new(StubInput),
            Box::new(StubCapture),
            Box::new(StubCompositor),
        ))
    }

    #[tokio::test]
    async fn session_locate_carries_xpath_verbatim() {
        let s = test_session();
        let loc = s.locate("//PushButton[@name='OK']");
        assert_eq!(loc.xpath(), "//PushButton[@name='OK']");
    }

    #[tokio::test]
    async fn session_root_locator_uses_wildcard() {
        let s = test_session();
        assert_eq!(s.root().xpath(), "/*");
    }

    #[tokio::test]
    async fn session_find_by_id_composes_xpath() {
        let s = test_session();
        assert_eq!(s.find_by_id("submit").xpath(), "//*[@id='submit']");
    }

    #[tokio::test]
    async fn session_find_by_name_composes_xpath() {
        let s = test_session();
        assert_eq!(s.find_by_name("OK").xpath(), "//*[@name='OK']");
    }

    #[tokio::test]
    async fn session_find_by_role_name_composes_xpath() {
        let s = test_session();
        assert_eq!(
            s.find_by_role_name("PushButton", "OK").xpath(),
            "//PushButton[@name='OK']"
        );
    }

    #[tokio::test]
    async fn locator_locate_appends_descendant_when_relative() {
        let s = test_session();
        let dialog = s.locate("//Dialog[@name='Confirm']");
        let inner = dialog.locate("PushButton");
        assert_eq!(inner.xpath(), "(//Dialog[@name='Confirm'])//PushButton");
    }

    #[tokio::test]
    async fn locator_locate_absolute_replaces_scope() {
        let s = test_session();
        let dialog = s.locate("//Dialog");
        // Absolute sub-xpath ignores the outer scope entirely.
        assert_eq!(dialog.locate("//Menu").xpath(), "//Menu");
    }

    #[tokio::test]
    async fn locator_nth_wraps_with_one_indexed_predicate() {
        let s = test_session();
        let loc = s.locate("//PushButton").nth(2);
        assert_eq!(loc.xpath(), "(//PushButton)[3]");
    }

    #[tokio::test]
    async fn locator_first_is_nth_zero() {
        let s = test_session();
        let loc = s.locate("//PushButton").first();
        assert_eq!(loc.xpath(), "(//PushButton)[1]");
    }

    #[tokio::test]
    async fn locator_last_uses_last_function() {
        let s = test_session();
        let loc = s.locate("//PushButton").last();
        assert_eq!(loc.xpath(), "(//PushButton)[last()]");
    }

    #[tokio::test]
    async fn locator_parent_appends_dot_dot() {
        let s = test_session();
        let loc = s.locate("//PushButton[@name='OK']").parent();
        assert_eq!(loc.xpath(), "(//PushButton[@name='OK'])/..");
    }

    #[tokio::test]
    async fn locator_composition_chains() {
        // Exercise a realistic chain: find a dialog, descend to a specific
        // button, pin to the 2nd match. This confirms each composition step
        // wraps the previous xpath correctly.
        let s = test_session();
        let loc = s
            .locate("//Dialog[@name='Confirm']")
            .locate("PushButton")
            .nth(1);
        assert_eq!(loc.xpath(), "((//Dialog[@name='Confirm'])//PushButton)[2]");
    }

    #[tokio::test]
    async fn locator_clone_preserves_xpath() {
        let s = test_session();
        let loc = s.locate("//PushButton");
        let cloned = loc.clone();
        assert_eq!(cloned.xpath(), "//PushButton");
    }

    #[tokio::test]
    async fn locator_click_on_session_without_a11y_errors_cleanly() {
        // Test-support Session has no AT-SPI connection; click() should
        // surface that as an Atspi error rather than panicking.
        let s = test_session();
        let err = s.locate("//PushButton").click().await.unwrap_err();
        assert!(matches!(err, Error::Atspi(_)));
        assert!(err.to_string().contains("no AT-SPI connection"));
    }

    #[tokio::test]
    async fn session_dump_tree_without_a11y_errors_cleanly() {
        let s = test_session();
        let err = s.dump_tree().await.unwrap_err();
        assert!(matches!(err, Error::Atspi(_)));
        assert!(err.to_string().contains("no AT-SPI connection"));
    }

    #[tokio::test]
    async fn with_timeout_overrides_session_default() {
        let s = test_session();
        // Default timeout comes from Session (5s fallback). Per-locator
        // override replaces it; both locators share the xpath.
        let base = s.locate("//PushButton");
        let quick = base.with_timeout(Duration::from_millis(100));
        assert_eq!(quick.xpath(), base.xpath());
        // We can't easily inspect `effective_timeout` because it's private,
        // but we verify the override takes a different code path by
        // exercising it through wait behavior below.
    }

    // ── poll_with_retry ────────────────────────────────────────────────────

    #[tokio::test]
    async fn poll_returns_value_on_first_try() {
        let result: Result<i32, Error> =
            poll_with_retry(Duration::from_secs(5), "x", || async { Ok(Some(42)) }).await;
        assert_eq!(result.unwrap(), 42);
    }

    #[tokio::test]
    async fn poll_succeeds_after_retries() {
        let attempts = Arc::new(AtomicUsize::new(0));
        let attempts_cloned = attempts.clone();
        let result: Result<&'static str, Error> =
            poll_with_retry(Duration::from_secs(5), "x", move || {
                let a = attempts_cloned.clone();
                async move {
                    let n = a.fetch_add(1, Ordering::SeqCst);
                    if n < 2 {
                        Err(Error::ElementNotFound { xpath: "x".into() })
                    } else {
                        Ok(Some("found"))
                    }
                }
            })
            .await;
        assert_eq!(result.unwrap(), "found");
        assert_eq!(attempts.load(Ordering::SeqCst), 3);
    }

    #[tokio::test]
    async fn poll_surfaces_last_retriable_error_on_timeout() {
        let result: Result<&'static str, Error> =
            poll_with_retry(Duration::from_millis(50), "//Missing", || async {
                Err::<Option<&'static str>, _>(Error::ElementNotFound {
                    xpath: "//Missing".into(),
                })
            })
            .await;
        let err = result.unwrap_err();
        assert!(
            matches!(err, Error::ElementNotFound { .. }),
            "expected ElementNotFound, got {err}"
        );
    }

    #[tokio::test]
    async fn poll_returns_timeout_when_predicate_keeps_saying_none() {
        // No retriable error — predicate just kept observing "element
        // present but state not satisfied." That should produce a Timeout
        // error, not some stale cached retriable error.
        let result: Result<i32, Error> =
            poll_with_retry(Duration::from_millis(50), "//Pending", || async {
                Ok::<Option<i32>, Error>(None)
            })
            .await;
        let err = result.unwrap_err();
        match err {
            Error::Timeout(msg) => assert!(
                msg.contains("//Pending"),
                "timeout message should include the xpath: {msg}"
            ),
            other => panic!("expected Timeout, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn poll_bails_immediately_on_non_retriable_error() {
        let attempts = Arc::new(AtomicUsize::new(0));
        let attempts_cloned = attempts.clone();
        let result: Result<&'static str, Error> =
            poll_with_retry(Duration::from_secs(5), "//Bad", move || {
                let a = attempts_cloned.clone();
                async move {
                    a.fetch_add(1, Ordering::SeqCst);
                    Err(Error::InvalidSelector {
                        xpath: "//Bad".into(),
                        reason: "oops".into(),
                    })
                }
            })
            .await;
        let err = result.unwrap_err();
        assert!(matches!(err, Error::InvalidSelector { .. }));
        // We should only attempt once — no retries for fatal errors.
        assert_eq!(attempts.load(Ordering::SeqCst), 1);
    }

    #[tokio::test]
    async fn poll_ambiguous_selector_is_not_retriable() {
        let attempts = Arc::new(AtomicUsize::new(0));
        let attempts_cloned = attempts.clone();
        let result: Result<&'static str, Error> =
            poll_with_retry(Duration::from_secs(5), "//PushButton", move || {
                let a = attempts_cloned.clone();
                async move {
                    a.fetch_add(1, Ordering::SeqCst);
                    Err(Error::AmbiguousSelector {
                        xpath: "//PushButton".into(),
                        count: 3,
                    })
                }
            })
            .await;
        assert!(matches!(
            result.unwrap_err(),
            Error::AmbiguousSelector { count: 3, .. }
        ));
        assert_eq!(attempts.load(Ordering::SeqCst), 1);
    }

    #[tokio::test]
    async fn poll_zero_timeout_is_single_shot() {
        // Duration::ZERO → try once, if failing surface the error without
        // any sleep. Useful for negative assertions.
        let attempts = Arc::new(AtomicUsize::new(0));
        let attempts_cloned = attempts.clone();
        let start = std::time::Instant::now();
        let _: Result<i32, Error> = poll_with_retry(Duration::ZERO, "//X", move || {
            let a = attempts_cloned.clone();
            async move {
                a.fetch_add(1, Ordering::SeqCst);
                Err(Error::ElementNotFound {
                    xpath: "//X".into(),
                })
            }
        })
        .await;
        // One attempt, returns promptly (give it a generous 100ms budget for
        // scheduler noise).
        assert_eq!(attempts.load(Ordering::SeqCst), 1);
        assert!(
            start.elapsed() < Duration::from_millis(100),
            "zero-timeout poll should not sleep, took {:?}",
            start.elapsed()
        );
    }

    #[test]
    fn is_retriable_matches_expected_errors() {
        assert!(is_retriable(&Error::ElementNotFound { xpath: "x".into() }));
        assert!(is_retriable(&Error::ElementStale {
            xpath: "x".into(),
            bus: "b".into(),
            path: "/p".into(),
        }));
        assert!(!is_retriable(&Error::AmbiguousSelector {
            xpath: "x".into(),
            count: 2,
        }));
        assert!(!is_retriable(&Error::InvalidSelector {
            xpath: "x".into(),
            reason: "r".into(),
        }));
        assert!(!is_retriable(&Error::Atspi("boom".into())));
        assert!(!is_retriable(&Error::Timeout("nope".into())));
    }
}