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
//! 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 crate::{
    event::{Blur, Click, Event, EventBuilder, KeyDown, KeyUp},
    Dom, Node,
};
use futures::prelude::*;
use std::fmt::{Debug, Formatter, Result as FmtResult};

/// Convenience methods for dispatching events to targets, primarily useful for
/// testing.
pub trait TargetExt {
    /// Dispatch a click event to the target.
    fn click(&self) {
        self.event(Click::new().build());
    }

    /// "Type" the provided text followed by the `<Enter>` key.
    fn keyboardln(&self, contents: &str) {
        self.keyboard(contents);
        self.enter();
        self.blur();
    }

    /// "Type" the provided text.
    fn keyboard(&self, contents: &str) {
        let mut prev = 0;
        // skip the first index because it's always 0
        for (next, _) in contents.char_indices().skip(1) {
            self.key(Key::Text(&contents[prev..next]));
            prev = next;
        }
        // the loop always leaves a key left at the end to clean up
        self.key(Key::Text(&contents[prev..]));
    }

    /// "Press" the `<Enter>` key.
    fn enter(&self) {
        self.key(Key::Enter);
    }

    /// Dismiss the target, causing it to lose focus.
    fn blur(&self) {
        self.event(Blur::new().build());
    }

    /// Emit a pair of keydown/keyup events with `key`.
    fn key(&self, key: Key<'_>);

    /// Dispatch the given event to this target.
    fn event<E>(&self, event: E)
    where
        E: Event;
}

/// A keyboard "key" which can be entered.
pub enum Key<'a> {
    /// The enter key.
    Enter,
    /// A non-modifier key.
    Text(&'a str),
}

impl TargetExt for Node {
    fn key(&self, key: Key<'_>) {
        let key_str = match key {
            Key::Enter => "Enter",
            Key::Text(key) => {
                match self {
                    #[cfg(feature = "webdom")]
                    Node::Concrete(n) => {
                        use wasm_bindgen::JsCast;
                        // TODO append the key to our value if we're a textarea
                        if let Some(input) = n.dyn_ref::<web_sys::HtmlInputElement>() {
                            let new_input = input.value() + key;
                            input.set_value(&new_input);
                        }
                    }
                    #[cfg(feature = "rsdom")]
                    Node::Virtual(_) => (), // TODO support events for virtual nodes
                }
                key
            }
        };

        let mut down = KeyDown::new();
        down.key(key_str);
        self.event(down.build());

        let mut up = KeyUp::new();
        up.key(key_str);
        self.event(up.build());
    }

    fn event<E: Event>(&self, event: E) {
        Dom::dispatch(self, event);
    }
}

/// 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 }
    }
}

/// The outcome of a failed query.
#[derive(Debug)]
pub enum QueryError<'a, N> {
    /// Couldn't find any matching nodes.
    Missing {
        /// the original query
        lookup: &'a dyn Debug,
    },
    /// Couldn't find any matching nodes in the time allotted.
    Timeout {
        /// the original query
        lookup: &'a dyn Debug,
    },
    /// Found more nodes than the 1 requested.
    TooMany {
        /// the first node we found
        matched: N,
        /// unexpected nodes
        extra: Vec<N>,
        /// the original query
        lookup: &'a dyn Debug,
    },
}

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

impl<'n, N: Debug> Debug for Finder<'n, N> {
    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
        write!(f, "{:#?}", &self.target)
    }
}

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.
#[derive(Debug)]
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 + Debug,
{
    /// 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) -> Result<N, QueryError<'_, N>> {
        let mut matches = self.many()?.into_iter();
        let matched = matches.next().expect("empty results are a query error");
        let extra = matches.collect::<Vec<_>>();

        if extra.is_empty() {
            Ok(matched)
        } else {
            Err(QueryError::TooMany { matched, extra, lookup: self })
        }
    }

    /// Execute the query and return a `Vec` of matching nodes in the queried
    /// subtree.
    pub fn many(&self) -> Result<Vec<N>, QueryError<'_, 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));

        if candidates.is_empty() {
            Err(QueryError::Missing { lookup: self })
        } else {
            Ok(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, Debug)]
enum Strategy {
    LabelText,
    PlaceholderText,
    Text,
    DisplayValue,
    AltText,
    Title,
    Role,
    TestId,
}

/// A query which resolves asynchronously
#[derive(Debug)]
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 + Debug,
{
    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) -> Result<N, QueryError<'_, N>> {
        let mut matches = self.many().await?.into_iter();
        let matched = matches.next().expect("empty results are a query error");
        let extra = matches.collect::<Vec<_>>();

        if extra.is_empty() {
            Ok(matched)
        } else {
            Err(QueryError::TooMany { matched, extra, lookup: self })
        }
    }

    /// 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) -> Result<Vec<N>, QueryError<'_, N>> {
        macro_rules! try_query {
            () => {{
                if let Ok(current_results) = self.query.many() {
                    return Ok(current_results);
                }
            }};
        }

        let mut mutations = self.query.finder.target.observe_mutations();
        let timeout = gloo_timers::future::TimeoutFuture::new(1_000);
        futures::pin_mut!(timeout);

        try_query!(); // see if we can eagerly eval
        loop {
            futures::select_biased! {
                _ = timeout.as_mut().fuse() => {
                    try_query!(); // first see if we can succeed after timing out
                    return Err(QueryError::Timeout { lookup: self });
                },
                _ = mutations.next().fuse() => try_query!(),
            }
        }
    }
}