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` for field errors, `[data-filter-name]` for filters, `[data-empty]` for the
7//! 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.
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.
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 action targets of the row whose checkbox value or action URL carries
95/// `key`, or `None` when no row matches.
96pub fn row_actions(html: &str, key: &str) -> Option<RowActions> {
97    rows(html).into_iter().find_map(|row| {
98        let matches = row.select_value.as_deref() == Some(key)
99            || row
100                .actions
101                .view
102                .as_deref()
103                .is_some_and(|href| href_contains_key(href, key))
104            || row
105                .actions
106                .edit
107                .as_deref()
108                .is_some_and(|href| href_contains_key(href, key))
109            || row
110                .actions
111                .delete_action
112                .as_deref()
113                .is_some_and(|action| href_contains_key(action, key));
114        matches.then_some(row.actions)
115    })
116}
117
118/// The validation message rendered for `name`, or `None` when the field
119/// renders no error.
120pub fn field_error(html: &str, name: &str) -> Option<String> {
121    let target = format!("{name}-error");
122    let (_, _, inner) = tags_where(html, |_, attrs| {
123        attr_value(attrs, "id").as_deref() == Some(target.as_str())
124    })
125    .into_iter()
126    .next()?;
127    let text = strip_text(&inner);
128    (!text.is_empty()).then_some(text)
129}
130
131/// The options of the select filter `name`, or `None` when `name` has no
132/// select control.
133pub fn filter_options(html: &str, name: &str) -> Option<Vec<FilterOption>> {
134    let (tag, _, inner) = tags_where(html, |_, attrs| {
135        attr_value(attrs, "data-filter-name").as_deref() == Some(name)
136    })
137    .into_iter()
138    .next()?;
139    if tag != "select" {
140        return None;
141    }
142    let options = elements(&inner, "option")
143        .into_iter()
144        .map(|(_, attrs, inner)| FilterOption {
145            value: attr_value(&attrs, "value").unwrap_or_default(),
146            label: strip_text(&inner),
147            selected: is_selected(&attrs),
148        })
149        .collect();
150    Some(options)
151}
152
153fn row(row_html: &str) -> Row {
154    let mut select_value = None;
155    for (tag, attrs, _) in open_tags(row_html) {
156        if tag == "input" && attr_value(&attrs, "aria-label").as_deref() == Some("Select row") {
157            select_value = attr_value(&attrs, "value");
158            break;
159        }
160    }
161    let mut cells = Vec::new();
162    for (_, _, inner) in elements(row_html, "td") {
163        cells.push(strip_text(&inner));
164    }
165    let mut view_href = None;
166    let mut edit_href = None;
167    let mut delete_action = None;
168    for (tag, attrs, _) in open_tags(row_html) {
169        match (tag.as_str(), attr_value(&attrs, "aria-label").as_deref()) {
170            ("a", Some("View")) => view_href = attr_value(&attrs, "href"),
171            ("a", Some("Edit")) => edit_href = attr_value(&attrs, "href"),
172            ("button", Some("Delete")) => delete_action = attr_value(&attrs, "formaction"),
173            _ => {}
174        }
175    }
176    Row {
177        select_value,
178        cells,
179        actions: RowActions {
180            view: view_href,
181            edit: edit_href,
182            delete_action,
183        },
184    }
185}
186
187fn href_contains_key(href: &str, key: &str) -> bool {
188    href.split(['?', '#', '/', '&'])
189        .any(|segment| segment == key)
190}
191
192fn is_selected(attrs: &Attrs) -> bool {
193    attrs.iter().any(|(name, _)| name == "selected")
194}
195
196fn attr_value(attrs: &Attrs, name: &str) -> Option<String> {
197    attrs
198        .iter()
199        .find(|(attr, _)| attr == name)
200        .and_then(|(_, value)| value.clone())
201}
202
203/// The tags `keep` accepts, each with its inner HTML up to the first matching close tag.
204///
205/// Only an accepted tag's inner HTML is read, so a query over one element costs one scan of the
206/// document rather than one per tag.
207fn tags_where(html: &str, keep: impl Fn(&str, &Attrs) -> bool) -> Vec<Element> {
208    open_tags(html)
209        .into_iter()
210        .filter(|(tag, attrs, _)| keep(tag, attrs))
211        .map(|(tag, attrs, open_end)| {
212            let inner = inner_html(html, &tag, open_end);
213            (tag, attrs, inner)
214        })
215        .collect()
216}
217
218fn inner_html(html: &str, tag: &str, open_end: usize) -> String {
219    if is_void(tag) {
220        return String::new();
221    }
222    // Same-name elements nest (a `div` in a `div`), so the close is the one that returns the
223    // depth to zero.
224    let after = &html[open_end + 1..];
225    let open = format!("<{tag}");
226    let close = format!("</{tag}>");
227    let mut depth = 1usize;
228    let mut pos = 0;
229    while let Some(at) = after[pos..].find('<') {
230        let at = pos + at;
231        let rest = &after[at..];
232        if rest.starts_with(close.as_str()) {
233            depth -= 1;
234            if depth == 0 {
235                return after[..at].to_string();
236            }
237            pos = at + close.len();
238        } else if rest.starts_with(open.as_str())
239            && rest[open.len()..].starts_with([' ', '>', '/', '\t', '\n'])
240        {
241            depth += 1;
242            pos = at + open.len();
243        } else {
244            pos = at + 1;
245        }
246    }
247    String::new()
248}
249
250fn elements(html: &str, tag: &str) -> Vec<Element> {
251    tags_where(html, |name, _| name == tag)
252}
253
254fn open_tags(html: &str) -> Vec<(String, Attrs, usize)> {
255    let mut out = Vec::new();
256    let mut pos = 0;
257    while let Some(start) = html[pos..].find('<') {
258        let abs_start = pos + start;
259        let Some(end) = tag_end(html, abs_start) else {
260            break;
261        };
262        let raw = &html[abs_start + 1..end];
263        pos = end + 1;
264        if raw.starts_with('!') || raw.starts_with('?') || raw.starts_with('/') {
265            continue;
266        }
267        let (tag, attrs) = parse_tag(raw);
268        if tag.is_empty() {
269            continue;
270        }
271        out.push((tag, attrs, end));
272    }
273    out
274}
275
276fn parse_tag(raw: &str) -> (String, Attrs) {
277    let mut attrs = Vec::new();
278    let bytes = raw.as_bytes();
279    let mut pos = 0;
280    while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'/' {
281        pos += 1;
282    }
283    let tag = raw[..pos].to_ascii_lowercase();
284    while pos < bytes.len() {
285        while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
286            pos += 1;
287        }
288        if pos >= bytes.len() || bytes[pos] == b'/' {
289            break;
290        }
291        let name_start = pos;
292        while pos < bytes.len()
293            && (bytes[pos].is_ascii_alphanumeric()
294                || matches!(bytes[pos], b'-' | b'_' | b':' | b'.' | b'@'))
295        {
296            pos += 1;
297        }
298        if name_start == pos {
299            pos += 1;
300            while pos < bytes.len() && !raw.is_char_boundary(pos) {
301                pos += 1;
302            }
303            continue;
304        }
305        let name = raw[name_start..pos].to_ascii_lowercase();
306        while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
307            pos += 1;
308        }
309        if pos >= bytes.len() || bytes[pos] != b'=' {
310            attrs.push((name, None));
311            continue;
312        }
313        pos += 1;
314        while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
315            pos += 1;
316        }
317        if pos >= bytes.len() {
318            attrs.push((name, Some(String::new())));
319            break;
320        }
321        let quote = bytes[pos];
322        if quote == b'"' || quote == b'\'' {
323            pos += 1;
324            let value_start = pos;
325            while pos < bytes.len() && bytes[pos] != quote {
326                pos += 1;
327            }
328            attrs.push((
329                name,
330                Some(decode_entities(&raw[value_start..pos.min(bytes.len())])),
331            ));
332            pos = (pos + 1).min(bytes.len());
333        } else {
334            let value_start = pos;
335            while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() {
336                pos += 1;
337            }
338            attrs.push((name, Some(decode_entities(&raw[value_start..pos]))));
339        }
340    }
341    (tag, attrs)
342}
343
344fn tag_end(html: &str, start: usize) -> Option<usize> {
345    let bytes = html.as_bytes();
346    let mut quote = None;
347    let mut pos = start + 1;
348    while pos < bytes.len() {
349        let byte = bytes[pos];
350        if let Some(q) = quote {
351            if byte == q {
352                quote = None;
353            }
354        } else if byte == b'"' || byte == b'\'' {
355            quote = Some(byte);
356        } else if byte == b'>' {
357            return Some(pos);
358        }
359        pos += 1;
360    }
361    None
362}
363
364fn is_void(tag: &str) -> bool {
365    matches!(
366        tag,
367        "area"
368            | "base"
369            | "br"
370            | "col"
371            | "embed"
372            | "hr"
373            | "img"
374            | "input"
375            | "link"
376            | "meta"
377            | "param"
378            | "source"
379            | "track"
380            | "wbr"
381    )
382}
383
384fn strip_text(fragment: &str) -> String {
385    let mut text = String::new();
386    let mut pos = 0;
387    while pos < fragment.len() {
388        if let Some(start) = fragment[pos..].find('<') {
389            text.push_str(&fragment[pos..pos + start]);
390            let abs = pos + start;
391            match tag_end(fragment, abs) {
392                Some(end) => pos = end + 1,
393                None => break,
394            }
395        } else {
396            text.push_str(&fragment[pos..]);
397            break;
398        }
399    }
400    decode_entities(&text).trim().to_string()
401}
402
403fn decode_entities(text: &str) -> String {
404    text.replace("&lt;", "<")
405        .replace("&gt;", ">")
406        .replace("&quot;", "\"")
407        .replace("&#x27;", "'")
408        .replace("&#39;", "'")
409        .replace("&amp;", "&")
410}
411
412#[cfg(test)]
413mod tests;