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 the `column` checkboxes for the column
9//! 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, row};
15
16pub(crate) type Attrs = Vec<(String, Option<String>)>;
17pub(crate) type Element = (String, Attrs, String);
18
19/// One data row of a rendered table.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct Row {
22    /// The bulk checkbox value, the record key when the row is selectable.
23    pub select_value: Option<String>,
24    /// The cell texts in column order, including the bulk and actions cells.
25    pub cells: Vec<String>,
26    /// The row's actions, nested so hrefs have one source.
27    pub actions: RowActions,
28}
29
30/// The action targets of one row.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct RowActions {
33    /// The `View` link target, `None` when policy hides it.
34    pub view: Option<String>,
35    /// The `Edit` link target, `None` when policy hides it.
36    pub edit: Option<String>,
37    /// The `Delete` POST target, carried as its button's `formaction`; `None` when policy hides
38    /// it.
39    pub delete_action: Option<String>,
40}
41
42/// One `<option>` of a filter select, or one checkbox of a filter checklist.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct FilterOption {
45    /// The submitted value.
46    pub value: String,
47    /// The shown label.
48    pub label: String,
49    /// Whether the request selected it, or checked it.
50    pub selected: bool,
51}
52
53/// One toggleable column the column picker offers.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct PickerColumn {
56    /// The column's name, which the picker posts.
57    pub name: String,
58    /// Whether the reader's choice shows it: its box is checked.
59    pub shown: bool,
60}
61
62/// Why a table rendered no rows, and the links it offers out of that state.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct EmptyTable {
65    /// What emptied it.
66    pub reason: EmptyReason,
67    /// The link that clears the search or the filters, `None` when nothing narrowed the table.
68    pub clear: Option<String>,
69    /// The link back to the first page, `None` without a cursor.
70    pub first_page: Option<String>,
71}
72
73/// What emptied a table, as its zero-rows message's `data-empty` names it.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75#[non_exhaustive]
76pub enum EmptyReason {
77    /// The table holds no records.
78    NoRecords,
79    /// The request's search matched no record.
80    Search,
81    /// The request's filters matched no record.
82    Filters,
83}
84
85/// The zero-rows message of `html`, or `None` when the table renders rows.
86///
87/// # Panics
88///
89/// When the message names no reason this version knows: a renderer that drops `data-empty`'s
90/// value would otherwise read as any reason.
91pub fn empty_table(html: &str) -> Option<EmptyTable> {
92    let (_, attrs, inner) = tags_where(html, |_, attrs| attr_value(attrs, "data-empty").is_some())
93        .into_iter()
94        .next()?;
95    let link = |kind: &str| {
96        open_tags(&inner)
97            .into_iter()
98            .find(|(_, attrs, _)| attr_value(attrs, "data-empty-link").as_deref() == Some(kind))
99            .and_then(|(_, attrs, _)| attr_value(&attrs, "href"))
100    };
101    let reason = match attr_value(&attrs, "data-empty").as_deref() {
102        Some(empty::NONE) => EmptyReason::NoRecords,
103        Some(empty::SEARCH) => EmptyReason::Search,
104        Some(empty::FILTERS) => EmptyReason::Filters,
105        other => panic!("the zero-rows message names no known reason: {other:?}"),
106    };
107    Some(EmptyTable {
108        reason,
109        clear: link(empty::CLEAR_LINK),
110        first_page: link(empty::FIRST_PAGE_LINK),
111    })
112}
113
114/// The names of the columns the table's header shows, in order, without the bulk and actions
115/// columns.
116pub fn columns(html: &str) -> Vec<String> {
117    open_tags(html)
118        .into_iter()
119        .filter(|(tag, _, _)| tag == "th")
120        .filter_map(|(_, attrs, _)| attr_value(&attrs, "data-column"))
121        .collect()
122}
123
124/// The toggleable columns the column picker offers, in order, or `None` when the table renders
125/// no picker.
126pub fn column_picker(html: &str) -> Option<Vec<PickerColumn>> {
127    let columns: Vec<PickerColumn> = open_tags(html)
128        .into_iter()
129        .filter(|(tag, attrs, _)| {
130            tag == "input"
131                && attr_value(attrs, "type").as_deref() == Some("checkbox")
132                && attr_value(attrs, "name").as_deref() == Some("column")
133        })
134        .map(|(_, attrs, _)| PickerColumn {
135            name: attr_value(&attrs, "value").unwrap_or_default(),
136            shown: attrs.iter().any(|(name, _)| name == "checked"),
137        })
138        .collect();
139    (!columns.is_empty()).then_some(columns)
140}
141
142/// The data rows of `html` in document order, excluding group headers.
143pub fn rows(html: &str) -> Vec<Row> {
144    let mut out = Vec::new();
145    for (_, attrs, inner) in elements(html, "tr") {
146        let Some(id) = attr_value(&attrs, "id") else {
147            continue;
148        };
149        if !id.starts_with("row-") {
150            continue;
151        }
152        out.push(row(&inner));
153    }
154    out
155}
156
157/// One group of a grouped table: its header's label and the rows under it on this page.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct Group {
160    /// The label the group's header shows.
161    pub label: String,
162    /// The group's rows on this page, in document order.
163    pub rows: Vec<Row>,
164}
165
166/// The groups of `html` in document order, empty when the table renders ungrouped. A group holds
167/// the rows from its header to the next one.
168pub fn groups(html: &str) -> Vec<Group> {
169    let mut out: Vec<Group> = Vec::new();
170    for (_, attrs, inner) in elements(html, "tr") {
171        if let Some(label) = attr_value(&attrs, "data-group") {
172            out.push(Group {
173                label,
174                rows: Vec::new(),
175            });
176        } else if attr_value(&attrs, "id").is_some_and(|id| id.starts_with("row-"))
177            && let Some(group) = out.last_mut()
178        {
179            group.rows.push(row(&inner));
180        }
181    }
182    out
183}
184
185/// The record key of every row in document order, read from its bulk checkbox: a row with no
186/// checkbox has none to read.
187pub fn row_keys(html: &str) -> Vec<String> {
188    rows(html)
189        .into_iter()
190        .filter_map(|row| row.select_value)
191        .collect()
192}
193
194/// The first `href` in `html` that contains `needle`, its entities decoded.
195pub fn find_href_with(html: &str, needle: &str) -> Option<String> {
196    open_tags(html)
197        .into_iter()
198        .filter_map(|(_, attrs, _)| attr_value(&attrs, "href"))
199        .find(|href| href.contains(needle))
200}
201
202/// Whether the button posting to `action` asks first: a confirming trigger is a plain button that
203/// opens the dialog, a direct write is a submit button. `None` when no button posts there.
204pub fn confirms_first(html: &str, action: &str) -> Option<bool> {
205    open_tags(html)
206        .into_iter()
207        .find(|(tag, attrs, _)| {
208            tag == "button" && attr_value(attrs, "formaction").as_deref() == Some(action)
209        })
210        .map(|(_, attrs, _)| attr_value(&attrs, "type").as_deref() == Some("button"))
211}
212
213/// The action targets of the row whose checkbox value or action URL carries
214/// `key`, or `None` when no row matches.
215pub fn row_actions(html: &str, key: &str) -> Option<RowActions> {
216    rows(html).into_iter().find_map(|row| {
217        let matches = row.select_value.as_deref() == Some(key)
218            || row
219                .actions
220                .view
221                .as_deref()
222                .is_some_and(|href| href_contains_key(href, key))
223            || row
224                .actions
225                .edit
226                .as_deref()
227                .is_some_and(|href| href_contains_key(href, key))
228            || row
229                .actions
230                .delete_action
231                .as_deref()
232                .is_some_and(|action| href_contains_key(action, key));
233        matches.then_some(row.actions)
234    })
235}
236
237/// Why a field renders an error, as its error slot's `data-error` names it.
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239#[non_exhaustive]
240pub enum ErrorKind {
241    /// Posted empty, and the field has no blank answer.
242    Required,
243    /// Posted a value the field, the resource's validation or the uploader refused.
244    Invalid,
245    /// A choice whose options the user may not read.
246    Unavailable,
247}
248
249/// Why the field posting `name` was refused, or `None` when it renders no error.
250///
251/// # Panics
252///
253/// When the slot names no kind this version knows: a renderer that drops `data-error` would
254/// otherwise read as any refusal.
255///
256/// Assert on the kind rather than the sentence, which is wording; [`field_error_message`] reads
257/// the sentence for a test whose subject is an app's own message.
258pub fn field_error(html: &str, name: &str) -> Option<ErrorKind> {
259    let (attrs, _) = error_slot(html, name)?;
260    Some(match attr_value(&attrs, "data-error").as_deref() {
261        Some(error::REQUIRED) => ErrorKind::Required,
262        Some(error::INVALID) => ErrorKind::Invalid,
263        Some(error::UNAVAILABLE) => ErrorKind::Unavailable,
264        other => panic!("the error slot of `{name}` names no known kind: {other:?}"),
265    })
266}
267
268/// The sentence rendered under the field posting `name`, or `None` when it renders no error.
269pub fn field_error_message(html: &str, name: &str) -> Option<String> {
270    error_slot(html, name).map(|(_, text)| text)
271}
272
273/// The attributes and text of the error slot `#{name}-error`, when it renders one.
274fn error_slot(html: &str, name: &str) -> Option<(Attrs, String)> {
275    let target = format!("{name}-error");
276    let (_, attrs, inner) = tags_where(html, |_, attrs| {
277        attr_value(attrs, "id").as_deref() == Some(target.as_str())
278    })
279    .into_iter()
280    .next()?;
281    let text = strip_text(&inner);
282    (!text.is_empty()).then_some((attrs, text))
283}
284
285/// The options of the select or checklist filter `name`, or `None` when `name` names neither. A
286/// checklist offering no option reads as an empty list, since its control still renders.
287pub fn filter_options(html: &str, name: &str) -> Option<Vec<FilterOption>> {
288    let (tag, _, inner) = tags_where(html, |_, attrs| {
289        attr_value(attrs, "data-filter-name").as_deref() == Some(name)
290    })
291    .into_iter()
292    .next()?;
293    if tag != "select" {
294        let options: Vec<FilterOption> = elements(&inner, "label")
295            .into_iter()
296            .filter_map(|(_, _, inner)| {
297                let (_, attrs, _) = open_tags(&inner).into_iter().find(|(tag, attrs, _)| {
298                    tag == "input" && attr_value(attrs, "type").as_deref() == Some("checkbox")
299                })?;
300                Some(FilterOption {
301                    value: attr_value(&attrs, "value").unwrap_or_default(),
302                    label: strip_text(&inner),
303                    selected: attrs.iter().any(|(name, _)| name == "checked"),
304                })
305            })
306            .collect();
307        // A checklist renders the button that opens it, with or without an option; another
308        // control's container, such as a date filter's, renders none.
309        return inner.contains("<button").then_some(options);
310    }
311    let options = elements(&inner, "option")
312        .into_iter()
313        .map(|(_, attrs, inner)| FilterOption {
314            value: attr_value(&attrs, "value").unwrap_or_default(),
315            label: strip_text(&inner),
316            selected: is_selected(&attrs),
317        })
318        .collect();
319    Some(options)
320}
321
322/// The badge text of the sidebar entry linking to `href`, or `None` when that entry shows no
323/// badge or no entry links there.
324pub fn nav_badge(html: &str, href: &str) -> Option<String> {
325    let menu = |name: &'static str| {
326        move |_: &str, attrs: &Attrs| attr_value(attrs, "data-sidebar").as_deref() == Some(name)
327    };
328    let (_, _, entry) = tags_where(html, menu("menu-item"))
329        .into_iter()
330        .find(|(_, _, inner)| {
331            open_tags(inner).iter().any(|(tag, attrs, _)| {
332                tag == "a" && attr_value(attrs, "href").as_deref() == Some(href)
333            })
334        })?;
335    let (_, _, badge) = tags_where(&entry, menu("menu-badge")).into_iter().next()?;
336    Some(strip_text(&badge))
337}
338
339fn row(row_html: &str) -> Row {
340    let mut select_value = None;
341    for (tag, attrs, _) in open_tags(row_html) {
342        if tag == "input" && attr_value(&attrs, "data-row-select").is_some() {
343            select_value = attr_value(&attrs, "value");
344            break;
345        }
346    }
347    let mut cells = Vec::new();
348    for (_, _, inner) in elements(row_html, "td") {
349        cells.push(strip_text(&inner));
350    }
351    let mut view_href = None;
352    let mut edit_href = None;
353    let mut delete_action = None;
354    for (_, attrs, _) in open_tags(row_html) {
355        match attr_value(&attrs, "data-row-action").as_deref() {
356            Some(row::VIEW) => view_href = attr_value(&attrs, "href"),
357            Some(row::EDIT) => edit_href = attr_value(&attrs, "href"),
358            Some(row::DELETE) => delete_action = attr_value(&attrs, "formaction"),
359            _ => {}
360        }
361    }
362    Row {
363        select_value,
364        cells,
365        actions: RowActions {
366            view: view_href,
367            edit: edit_href,
368            delete_action,
369        },
370    }
371}
372
373fn href_contains_key(href: &str, key: &str) -> bool {
374    href.split(['?', '#', '/', '&'])
375        .any(|segment| segment == key)
376}
377
378fn is_selected(attrs: &Attrs) -> bool {
379    attrs.iter().any(|(name, _)| name == "selected")
380}
381
382pub(crate) fn attr_value(attrs: &Attrs, name: &str) -> Option<String> {
383    attrs
384        .iter()
385        .find(|(attr, _)| attr == name)
386        .and_then(|(_, value)| value.clone())
387}
388
389/// The tags `keep` accepts, each with its inner HTML up to the first matching close tag.
390///
391/// Only an accepted tag's inner HTML is read, so a query over one element costs one scan of the
392/// document rather than one per tag.
393pub(crate) fn tags_where(html: &str, keep: impl Fn(&str, &Attrs) -> bool) -> Vec<Element> {
394    open_tags(html)
395        .into_iter()
396        .filter(|(tag, attrs, _)| keep(tag, attrs))
397        .map(|(tag, attrs, open_end)| {
398            let inner = inner_html(html, &tag, open_end);
399            (tag, attrs, inner)
400        })
401        .collect()
402}
403
404fn inner_html(html: &str, tag: &str, open_end: usize) -> String {
405    if is_void(tag) {
406        return String::new();
407    }
408    // Same-name elements nest (a `div` in a `div`), so the close is the one that returns the
409    // depth to zero.
410    let after = &html[open_end + 1..];
411    let open = format!("<{tag}");
412    let close = format!("</{tag}>");
413    let mut depth = 1usize;
414    let mut pos = 0;
415    while let Some(at) = after[pos..].find('<') {
416        let at = pos + at;
417        let rest = &after[at..];
418        if rest.starts_with(close.as_str()) {
419            depth -= 1;
420            if depth == 0 {
421                return after[..at].to_string();
422            }
423            pos = at + close.len();
424        } else if rest.starts_with(open.as_str())
425            && rest[open.len()..].starts_with([' ', '>', '/', '\t', '\n'])
426        {
427            depth += 1;
428            pos = at + open.len();
429        } else {
430            pos = at + 1;
431        }
432    }
433    String::new()
434}
435
436fn elements(html: &str, tag: &str) -> Vec<Element> {
437    tags_where(html, |name, _| name == tag)
438}
439
440fn open_tags(html: &str) -> Vec<(String, Attrs, usize)> {
441    let mut out = Vec::new();
442    let mut pos = 0;
443    while let Some(start) = html[pos..].find('<') {
444        let abs_start = pos + start;
445        let Some(end) = tag_end(html, abs_start) else {
446            break;
447        };
448        let raw = &html[abs_start + 1..end];
449        pos = end + 1;
450        if raw.starts_with('!') || raw.starts_with('?') || raw.starts_with('/') {
451            continue;
452        }
453        let (tag, attrs) = parse_tag(raw);
454        if tag.is_empty() {
455            continue;
456        }
457        out.push((tag, attrs, end));
458    }
459    out
460}
461
462fn parse_tag(raw: &str) -> (String, Attrs) {
463    let mut attrs = Vec::new();
464    let bytes = raw.as_bytes();
465    let mut pos = 0;
466    while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'/' {
467        pos += 1;
468    }
469    let tag = raw[..pos].to_ascii_lowercase();
470    while pos < bytes.len() {
471        while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
472            pos += 1;
473        }
474        if pos >= bytes.len() || bytes[pos] == b'/' {
475            break;
476        }
477        let name_start = pos;
478        while pos < bytes.len()
479            && (bytes[pos].is_ascii_alphanumeric()
480                || matches!(bytes[pos], b'-' | b'_' | b':' | b'.' | b'@'))
481        {
482            pos += 1;
483        }
484        if name_start == pos {
485            pos += 1;
486            while pos < bytes.len() && !raw.is_char_boundary(pos) {
487                pos += 1;
488            }
489            continue;
490        }
491        let name = raw[name_start..pos].to_ascii_lowercase();
492        while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
493            pos += 1;
494        }
495        if pos >= bytes.len() || bytes[pos] != b'=' {
496            attrs.push((name, None));
497            continue;
498        }
499        pos += 1;
500        while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
501            pos += 1;
502        }
503        if pos >= bytes.len() {
504            attrs.push((name, Some(String::new())));
505            break;
506        }
507        let quote = bytes[pos];
508        if quote == b'"' || quote == b'\'' {
509            pos += 1;
510            let value_start = pos;
511            while pos < bytes.len() && bytes[pos] != quote {
512                pos += 1;
513            }
514            attrs.push((
515                name,
516                Some(decode_entities(&raw[value_start..pos.min(bytes.len())])),
517            ));
518            pos = (pos + 1).min(bytes.len());
519        } else {
520            let value_start = pos;
521            while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() {
522                pos += 1;
523            }
524            attrs.push((name, Some(decode_entities(&raw[value_start..pos]))));
525        }
526    }
527    (tag, attrs)
528}
529
530fn tag_end(html: &str, start: usize) -> Option<usize> {
531    let bytes = html.as_bytes();
532    let mut quote = None;
533    let mut pos = start + 1;
534    while pos < bytes.len() {
535        let byte = bytes[pos];
536        if let Some(q) = quote {
537            if byte == q {
538                quote = None;
539            }
540        } else if byte == b'"' || byte == b'\'' {
541            quote = Some(byte);
542        } else if byte == b'>' {
543            return Some(pos);
544        }
545        pos += 1;
546    }
547    None
548}
549
550fn is_void(tag: &str) -> bool {
551    matches!(
552        tag,
553        "area"
554            | "base"
555            | "br"
556            | "col"
557            | "embed"
558            | "hr"
559            | "img"
560            | "input"
561            | "link"
562            | "meta"
563            | "param"
564            | "source"
565            | "track"
566            | "wbr"
567    )
568}
569
570pub(crate) fn strip_text(fragment: &str) -> String {
571    let mut text = String::new();
572    let mut pos = 0;
573    while pos < fragment.len() {
574        if let Some(start) = fragment[pos..].find('<') {
575            text.push_str(&fragment[pos..pos + start]);
576            let abs = pos + start;
577            match tag_end(fragment, abs) {
578                Some(end) => pos = end + 1,
579                None => break,
580            }
581        } else {
582            text.push_str(&fragment[pos..]);
583            break;
584        }
585    }
586    decode_entities(&text).trim().to_string()
587}
588
589fn decode_entities(text: &str) -> String {
590    text.replace("&lt;", "<")
591        .replace("&gt;", ">")
592        .replace("&quot;", "\"")
593        .replace("&#x27;", "'")
594        .replace("&#39;", "'")
595        .replace("&amp;", "&")
596}
597
598#[cfg(test)]
599mod tests;