Skip to main content

tablo_test/protocol/
dom.rs

1//! Semantic queries over rendered HTML for tests.
2//!
3//! The table and form renderers own their markup; tests assert the behavior
4//! the markup carries. Each helper reads one stable hook:
5//! `tr[id^="row-"]` for rows, `tr[data-group]` for group headers, `[aria-label]` links and buttons
6//! for actions, `#{name}-error` and its `data-error` for field errors, `[data-filter-name]` for
7//! filters, `[data-empty]` for the zero-rows message, `[data-sidebar=menu-item]` for sidebar
8//! entries, `th[data-column]` for the shown columns and its `aria-sort` for the sortable ones, and
9//! the `column` checkboxes for the column picker.
10//!
11//! The queries assume one table per document: rows never nest, and cells carry text rather than
12//! nested tables.
13
14use tablo_core::dom::{empty, error};
15
16pub use self::rows::{CellEdit, Group, Row, RowActions, groups, row_actions, row_keys, rows};
17
18pub(crate) type Attrs = Vec<(String, Option<String>)>;
19pub(crate) type Element = (String, Attrs, String);
20
21/// One `<option>` of a filter select, or one checkbox of a filter checklist.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct FilterOption {
24    /// The submitted value.
25    pub value: String,
26    /// The shown label.
27    pub label: String,
28    /// Whether the request selected it, or checked it.
29    pub selected: bool,
30}
31
32/// One toggleable column the column picker offers.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct PickerColumn {
35    /// The column's name, which the picker posts.
36    pub name: String,
37    /// Whether the reader's choice shows it: its box is checked.
38    pub shown: bool,
39}
40
41/// Why a table rendered no rows, and the links it offers out of that state.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct EmptyTable {
44    /// What emptied it.
45    pub reason: EmptyReason,
46    /// The link that clears the search or the filters, `None` when nothing narrowed the table.
47    pub clear: Option<String>,
48    /// The link back to the first page, `None` without a cursor.
49    pub first_page: Option<String>,
50}
51
52/// What emptied a table, as its zero-rows message's `data-empty` names it.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54#[non_exhaustive]
55pub enum EmptyReason {
56    /// The table holds no records.
57    NoRecords,
58    /// The request's search matched no record.
59    Search,
60    /// The request's filters matched no record.
61    Filters,
62}
63
64/// The zero-rows message of `html`, or `None` when the table renders rows.
65///
66/// # Panics
67///
68/// When the message names no reason this version knows: a renderer that drops `data-empty`'s
69/// value would otherwise read as any reason.
70pub fn empty_table(html: &str) -> Option<EmptyTable> {
71    let (_, attrs, inner) = tags_where(html, |_, attrs| attr_value(attrs, "data-empty").is_some())
72        .into_iter()
73        .next()?;
74    let link = |kind: &str| {
75        open_tags(&inner)
76            .into_iter()
77            .find(|(_, attrs, _)| attr_value(attrs, "data-empty-link").as_deref() == Some(kind))
78            .and_then(|(_, attrs, _)| attr_value(&attrs, "href"))
79    };
80    let reason = match attr_value(&attrs, "data-empty").as_deref() {
81        Some(empty::NONE) => EmptyReason::NoRecords,
82        Some(empty::SEARCH) => EmptyReason::Search,
83        Some(empty::FILTERS) => EmptyReason::Filters,
84        other => panic!("the zero-rows message names no known reason: {other:?}"),
85    };
86    Some(EmptyTable {
87        reason,
88        clear: link(empty::CLEAR_LINK),
89        first_page: link(empty::FIRST_PAGE_LINK),
90    })
91}
92
93/// The names of the columns the table's header shows, in order, without the bulk and actions
94/// columns.
95pub fn columns(html: &str) -> Vec<String> {
96    open_tags(html)
97        .into_iter()
98        .filter(|(tag, _, _)| tag == "th")
99        .filter_map(|(_, attrs, _)| attr_value(&attrs, "data-column"))
100        .collect()
101}
102
103/// The names of the columns whose header sorts the table, in order: those the header marks with
104/// `aria-sort`.
105pub fn sortable_columns(html: &str) -> Vec<String> {
106    open_tags(html)
107        .into_iter()
108        .filter(|(tag, attrs, _)| tag == "th" && attr_value(attrs, "aria-sort").is_some())
109        .filter_map(|(_, attrs, _)| attr_value(&attrs, "data-column"))
110        .collect()
111}
112
113/// The toggleable columns the column picker offers, in order, or `None` when the table renders
114/// no picker.
115pub fn column_picker(html: &str) -> Option<Vec<PickerColumn>> {
116    let columns: Vec<PickerColumn> = open_tags(html)
117        .into_iter()
118        .filter(|(tag, attrs, _)| {
119            tag == "input"
120                && attr_value(attrs, "type").as_deref() == Some("checkbox")
121                && attr_value(attrs, "name").as_deref() == Some("column")
122        })
123        .map(|(_, attrs, _)| PickerColumn {
124            name: attr_value(&attrs, "value").unwrap_or_default(),
125            shown: attrs.iter().any(|(name, _)| name == "checked"),
126        })
127        .collect();
128    (!columns.is_empty()).then_some(columns)
129}
130
131/// The first `href` in `html` that contains `needle`, its entities decoded.
132pub fn find_href_with(html: &str, needle: &str) -> Option<String> {
133    open_tags(html)
134        .into_iter()
135        .filter_map(|(_, attrs, _)| attr_value(&attrs, "href"))
136        .find(|href| href.contains(needle))
137}
138
139/// Why a field renders an error, as its error slot's `data-error` names it.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141#[non_exhaustive]
142pub enum ErrorKind {
143    /// Posted empty, and the field has no blank answer.
144    Required,
145    /// Posted a value the field, the resource's validation or the uploader refused.
146    Invalid,
147    /// A choice whose options the user may not read.
148    Unavailable,
149}
150
151/// Why the field posting `name` was refused, or `None` when it renders no error.
152///
153/// # Panics
154///
155/// When the slot names no kind this version knows: a renderer that drops `data-error` would
156/// otherwise read as any refusal.
157///
158/// Assert on the kind rather than the sentence, which is wording; [`field_error_message`] reads
159/// the sentence for a test whose subject is an app's own message.
160pub fn field_error(html: &str, name: &str) -> Option<ErrorKind> {
161    let (attrs, _) = error_slot(html, name)?;
162    Some(match attr_value(&attrs, "data-error").as_deref() {
163        Some(error::REQUIRED) => ErrorKind::Required,
164        Some(error::INVALID) => ErrorKind::Invalid,
165        Some(error::UNAVAILABLE) => ErrorKind::Unavailable,
166        other => panic!("the error slot of `{name}` names no known kind: {other:?}"),
167    })
168}
169
170/// The sentence rendered under the field posting `name`, or `None` when it renders no error.
171pub fn field_error_message(html: &str, name: &str) -> Option<String> {
172    error_slot(html, name).map(|(_, text)| text)
173}
174
175/// The attributes and text of the error slot `#{name}-error`, when it renders one.
176fn error_slot(html: &str, name: &str) -> Option<(Attrs, String)> {
177    let target = format!("{name}-error");
178    let (_, attrs, inner) = tags_where(html, |_, attrs| {
179        attr_value(attrs, "id").as_deref() == Some(target.as_str())
180    })
181    .into_iter()
182    .next()?;
183    let text = strip_text(&inner);
184    (!text.is_empty()).then_some((attrs, text))
185}
186
187/// The options of the select or checklist filter `name`, or `None` when `name` names neither. A
188/// checklist offering no option reads as an empty list, since its control still renders.
189pub fn filter_options(html: &str, name: &str) -> Option<Vec<FilterOption>> {
190    let (tag, _, inner) = tags_where(html, |_, attrs| {
191        attr_value(attrs, "data-filter-name").as_deref() == Some(name)
192    })
193    .into_iter()
194    .next()?;
195    if tag != "select" {
196        let options: Vec<FilterOption> = elements(&inner, "label")
197            .into_iter()
198            .filter_map(|(_, _, inner)| {
199                let (_, attrs, _) = open_tags(&inner).into_iter().find(|(tag, attrs, _)| {
200                    tag == "input" && attr_value(attrs, "type").as_deref() == Some("checkbox")
201                })?;
202                Some(FilterOption {
203                    value: attr_value(&attrs, "value").unwrap_or_default(),
204                    label: strip_text(&inner),
205                    selected: attrs.iter().any(|(name, _)| name == "checked"),
206                })
207            })
208            .collect();
209        // A checklist renders the button that opens it, with or without an option; another
210        // control's container, such as a date filter's, renders none.
211        return inner.contains("<button").then_some(options);
212    }
213    let options = elements(&inner, "option")
214        .into_iter()
215        .map(|(_, attrs, inner)| FilterOption {
216            value: attr_value(&attrs, "value").unwrap_or_default(),
217            label: strip_text(&inner),
218            selected: is_selected(&attrs),
219        })
220        .collect();
221    Some(options)
222}
223
224pub(crate) fn is_selected(attrs: &Attrs) -> bool {
225    attrs.iter().any(|(name, _)| name == "selected")
226}
227
228pub(crate) fn attr_value(attrs: &Attrs, name: &str) -> Option<String> {
229    attrs
230        .iter()
231        .find(|(attr, _)| attr == name)
232        .and_then(|(_, value)| value.clone())
233}
234
235/// The tags `keep` accepts, each with its inner HTML up to the first matching close tag.
236///
237/// Only an accepted tag's inner HTML is read, so a query over one element costs one scan of the
238/// document rather than one per tag.
239pub(crate) fn tags_where(html: &str, keep: impl Fn(&str, &Attrs) -> bool) -> Vec<Element> {
240    open_tags(html)
241        .into_iter()
242        .filter(|(tag, attrs, _)| keep(tag, attrs))
243        .map(|(tag, attrs, open_end)| {
244            let inner = inner_html(html, &tag, open_end);
245            (tag, attrs, inner)
246        })
247        .collect()
248}
249
250fn inner_html(html: &str, tag: &str, open_end: usize) -> String {
251    if is_void(tag) {
252        return String::new();
253    }
254    // Same-name elements nest (a `div` in a `div`), so the close is the one that returns the
255    // depth to zero.
256    let after = &html[open_end + 1..];
257    let open = format!("<{tag}");
258    let close = format!("</{tag}>");
259    let mut depth = 1usize;
260    let mut pos = 0;
261    while let Some(at) = after[pos..].find('<') {
262        let at = pos + at;
263        let rest = &after[at..];
264        if rest.starts_with(close.as_str()) {
265            depth -= 1;
266            if depth == 0 {
267                return after[..at].to_string();
268            }
269            pos = at + close.len();
270        } else if rest.starts_with(open.as_str())
271            && rest[open.len()..].starts_with([' ', '>', '/', '\t', '\n'])
272        {
273            depth += 1;
274            pos = at + open.len();
275        } else {
276            pos = at + 1;
277        }
278    }
279    String::new()
280}
281
282pub(crate) fn elements(html: &str, tag: &str) -> Vec<Element> {
283    tags_where(html, |name, _| name == tag)
284}
285
286pub(crate) fn open_tags(html: &str) -> Vec<(String, Attrs, usize)> {
287    let mut out = Vec::new();
288    let mut pos = 0;
289    while let Some(start) = html[pos..].find('<') {
290        let abs_start = pos + start;
291        let Some(end) = tag_end(html, abs_start) else {
292            break;
293        };
294        let raw = &html[abs_start + 1..end];
295        pos = end + 1;
296        if raw.starts_with('!') || raw.starts_with('?') || raw.starts_with('/') {
297            continue;
298        }
299        let (tag, attrs) = parse_tag(raw);
300        if tag.is_empty() {
301            continue;
302        }
303        out.push((tag, attrs, end));
304    }
305    out
306}
307
308fn parse_tag(raw: &str) -> (String, Attrs) {
309    let mut attrs = Vec::new();
310    let bytes = raw.as_bytes();
311    let mut pos = 0;
312    while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'/' {
313        pos += 1;
314    }
315    let tag = raw[..pos].to_ascii_lowercase();
316    while pos < bytes.len() {
317        while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
318            pos += 1;
319        }
320        if pos >= bytes.len() || bytes[pos] == b'/' {
321            break;
322        }
323        let name_start = pos;
324        while pos < bytes.len()
325            && (bytes[pos].is_ascii_alphanumeric()
326                || matches!(bytes[pos], b'-' | b'_' | b':' | b'.' | b'@'))
327        {
328            pos += 1;
329        }
330        if name_start == pos {
331            pos += 1;
332            while pos < bytes.len() && !raw.is_char_boundary(pos) {
333                pos += 1;
334            }
335            continue;
336        }
337        let name = raw[name_start..pos].to_ascii_lowercase();
338        while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
339            pos += 1;
340        }
341        if pos >= bytes.len() || bytes[pos] != b'=' {
342            attrs.push((name, None));
343            continue;
344        }
345        pos += 1;
346        while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
347            pos += 1;
348        }
349        if pos >= bytes.len() {
350            attrs.push((name, Some(String::new())));
351            break;
352        }
353        let quote = bytes[pos];
354        if quote == b'"' || quote == b'\'' {
355            pos += 1;
356            let value_start = pos;
357            while pos < bytes.len() && bytes[pos] != quote {
358                pos += 1;
359            }
360            attrs.push((
361                name,
362                Some(decode_entities(&raw[value_start..pos.min(bytes.len())])),
363            ));
364            pos = (pos + 1).min(bytes.len());
365        } else {
366            let value_start = pos;
367            while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() {
368                pos += 1;
369            }
370            attrs.push((name, Some(decode_entities(&raw[value_start..pos]))));
371        }
372    }
373    (tag, attrs)
374}
375
376fn tag_end(html: &str, start: usize) -> Option<usize> {
377    let bytes = html.as_bytes();
378    let mut quote = None;
379    let mut pos = start + 1;
380    while pos < bytes.len() {
381        let byte = bytes[pos];
382        if let Some(q) = quote {
383            if byte == q {
384                quote = None;
385            }
386        } else if byte == b'"' || byte == b'\'' {
387            quote = Some(byte);
388        } else if byte == b'>' {
389            return Some(pos);
390        }
391        pos += 1;
392    }
393    None
394}
395
396fn is_void(tag: &str) -> bool {
397    matches!(
398        tag,
399        "area"
400            | "base"
401            | "br"
402            | "col"
403            | "embed"
404            | "hr"
405            | "img"
406            | "input"
407            | "link"
408            | "meta"
409            | "param"
410            | "source"
411            | "track"
412            | "wbr"
413    )
414}
415
416pub(crate) fn strip_text(fragment: &str) -> String {
417    let mut text = String::new();
418    let mut pos = 0;
419    while pos < fragment.len() {
420        if let Some(start) = fragment[pos..].find('<') {
421            text.push_str(&fragment[pos..pos + start]);
422            let abs = pos + start;
423            match tag_end(fragment, abs) {
424                Some(end) => pos = end + 1,
425                None => break,
426            }
427        } else {
428            text.push_str(&fragment[pos..]);
429            break;
430        }
431    }
432    decode_entities(&text).trim().to_string()
433}
434
435fn decode_entities(text: &str) -> String {
436    text.replace("&lt;", "<")
437        .replace("&gt;", ">")
438        .replace("&quot;", "\"")
439        .replace("&#x27;", "'")
440        .replace("&#39;", "'")
441        .replace("&amp;", "&")
442}
443
444mod rows;
445#[cfg(test)]
446mod tests;