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