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
//! Quoting from [DOM Testing Library]'s motivations, as this crate's test
//! utilities are similar in design:
//!
//! > The more your tests resemble the way your software is used, the more
//! confidence they can give you.
//!
//! > As part of this goal, the utilities this library provides facilitate
//! querying the DOM in the same way the user would. Finding form elements by
//! their label text (just like a user would), finding links and buttons from
//! their text (like a user would), and more.
//!
//! These tools lend themselves to this basic test design:
//!
//! 1. setup test DOM
//! 1. execute user-oriented queries to find nodes of interest (see
//!    [`Query::find`] and [`Finder::by_label_text`])
//! 1. fire events as a user would (see [`crate::Dom::dispatch`])
//! 1. wait for async queries to complete (see [`Found::until`] and
//!    [`Until`])
//! 1. assert on results
//!
//! TODO write examples that work in doctests
//!
//! [DOM]: https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model/Introduction
//! [DOM Testing Library]: https://testing-library.com/docs/dom-testing-library/intro

use super::Dom;
use futures::prelude::*;

/// A type which can be queried as a DOM container, returning results from its
/// subtree.
pub trait Query: Sized {
    /// Begin a subtree query. The returned value supports methods like
    /// [`Finder::by_label_text`] which create queries against this container's
    /// children.
    fn find(&self) -> Finder<Self>;
}

impl<N> Query for N
where
    N: Dom,
{
    fn find(&self) -> Finder<Self> {
        Finder { target: self }
    }
}

/// Executes a search strategy over a DOM container's subtree via depth-first
/// pre-order traversal.
pub struct Finder<'n, N> {
    target: &'n N,
}

macro_rules! strat_method {
    (
        $(#[$outer:meta])+
        $met:ident $strat:ident
    ) => {
        $(#[$outer])*
        pub fn $met<'find, 'pat>(&'find self, pattern: &'pat str) -> Found<'find, 'pat, 'node, N> {
            Found { strat: Strategy::$strat, pattern, finder: self }
        }
    };
}

impl<'node, N> Finder<'node, N> {
    strat_method! {
        /// Find by `label`'s or `aria-label`'s normalized text content.
        ///
        /// The default choice for selecting form elements as it most closely
        /// mirrors how users interact with forms.
        by_label_text       LabelText
    }

    strat_method! {
        /// Find by `input`'s `placeholder` value.
        ///
        /// Used for form fields, choose if [`Finder::by_label_text`] is not available.
        by_placeholder_text PlaceholderText
    }

    strat_method! {
        /// Find by aria `role`.
        ///
        /// The default choice for interactive elements like buttons.
        by_role             Role
    }

    strat_method! {
        /// Find by element's normalized text content.
        by_text             Text
    }

    strat_method! {
        /// Find by form element's current `value`.
        by_display_value    DisplayValue
    }

    strat_method! {
        /// Find by `img`'s `alt` attribute.
        by_alt_text         AltText
    }

    strat_method! {
        /// Find by `title` attribute's or svg `title` tag's normalized text content.
        by_title            Title
    }

    strat_method! {
        /// Find by `data-testid` attribute. Not visible to humans, only
        /// use as a last resort.
        by_test_id          TestId
    }
}

/// The final description of a subtree query. The methods on this struct
/// execute the underlying search and return the results in various forms.
pub struct Found<'find, 'pat, 'node, N> {
    strat: Strategy,
    pattern: &'pat str,
    finder: &'find Finder<'node, N>,
}

impl<'find, 'pat, 'node, N> Found<'find, 'pat, 'node, N>
where
    N: Dom,
{
    /// Wrap the query in a [`MutationObserver`] with async methods that resolve
    /// once the wrapped query could succeed or a 1 second timeout has expired.
    ///
    /// [`MutationObserver`]: https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
    pub fn until(&self) -> Until<'_, 'find, 'pat, 'node, N> {
        Until::new(self)
    }

    /// Execute the query and return the only matching node in the queried
    /// subtree.
    ///
    /// # Panics
    ///
    /// If more than one matching node is found.
    pub fn one(&self) -> Option<N> {
        let mut matches = self.many().into_iter();
        let first = matches.next();
        assert!(matches.next().is_none(), "`one()` returned more than one matching node");
        first
    }

    /// Execute the query and return a `Vec` of matching nodes in the queried
    /// subtree.
    pub fn many(&self) -> Vec<N> {
        // first accumulate the subtree
        let mut candidates = Vec::new();
        collect_children_dfs_preorder(self.finder.target, &mut candidates);

        // then keep only those which match
        candidates.retain(|n| self.matches(n));

        candidates
    }

    fn normalize(s: impl AsRef<str>) -> String {
        s.as_ref().split_whitespace().collect::<Vec<_>>().join(" ")
    }

    fn matches(&self, node: &N) -> bool {
        use Strategy::*;
        match self.strat {
            Text => Some(node.get_inner_text()),
            // TODO(#120) add tests and make sure this is correct
            LabelText => node
                .get_attribute("id")
                .map(|id| {
                    let selector = format!("label[for={}]", id);
                    self.finder.target.query_selector(&selector).map(|l| l.get_inner_text())
                })
                .flatten(),
            AltText => node.get_attribute("alt"),
            Title => node.get_attribute("title"),
            DisplayValue => node.get_attribute("value"),
            PlaceholderText => node.get_attribute("placeholder"),
            Role => node.get_attribute("role"),
            TestId => node.get_attribute("data-testid"),
        }
        // normalize the string, removing redundant whitespace
        .map(Self::normalize)
        .map(|text| text == self.pattern)
        .unwrap_or(false)
    }
}

/// Performs a depth-first pre-order traversal of the containing node,
/// adding all of its transitive children to `queue`.
fn collect_children_dfs_preorder<N: Dom>(node: &N, queue: &mut Vec<N>) {
    let mut next_child = node.first_child();
    while let Some(child) = next_child {
        collect_children_dfs_preorder(&child, queue);
        next_child = child.next_sibling();
        queue.push(child);
    }
}

/// Which portion of a queried node to examine.
#[derive(Clone, Copy)]
enum Strategy {
    LabelText,
    PlaceholderText,
    Text,
    DisplayValue,
    AltText,
    Title,
    Role,
    TestId,
}

/// A query which resolves asynchronously
pub struct Until<'query, 'find, 'pat, 'node, N> {
    query: &'query Found<'find, 'pat, 'node, N>,
}

impl<'query, 'find, 'pat, 'node, N> Until<'query, 'find, 'pat, 'node, N>
where
    N: Dom,
{
    fn new(query: &'query Found<'find, 'pat, 'node, N>) -> Self {
        Self { query }
    }

    /// Wait until the query can succeed then return the only matching node
    /// in the queried subtree.
    ///
    /// # Panics
    ///
    /// If more than one matching node is found.
    #[cfg(feature = "webdom")]
    pub async fn one(&self) -> Option<N> {
        let mut matches = self.many().await.into_iter();
        let first = matches.next();
        assert!(matches.next().is_none(), "`one()` returned more than one matching node");
        first
    }

    /// Wait until the query can succeed then return a `Vec` of matching nodes
    /// in the queried subtree.
    #[cfg(feature = "webdom")]
    pub async fn many(&self) -> Vec<N> {
        let mut mutations = self.query.finder.target.observe_mutations();
        let timeout = gloo_timers::future::TimeoutFuture::new(1_000);
        futures::pin_mut!(timeout);
        loop {
            futures::select_biased! {
                _ = timeout.as_mut().fuse() => return Vec::new(),
                _ = mutations.next().fuse() => {
                    let current_results = self.query.many();
                    if !current_results.is_empty() {
                        return current_results;
                    }
                }
            }
        }
    }
}