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.
7//!
8//! The queries assume one table per document and flat renderer markup:
9//! rows never nest, and cells carry text rather than nested tables.
10
11type Attrs = Vec<(String, Option<String>)>;
12type Element = (String, Attrs, String);
13
14/// One data row of a rendered table.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct Row {
17    /// The bulk checkbox value, the record key when the row is selectable.
18    pub select_value: Option<String>,
19    /// The cell texts in column order, including the bulk and actions cells.
20    pub cells: Vec<String>,
21    /// The row's actions, nested so hrefs have one source.
22    pub actions: RowActions,
23}
24
25/// The action targets of one row.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct RowActions {
28    /// The `View` link target, `None` when policy hides it.
29    pub view: Option<String>,
30    /// The `Edit` link target, `None` when policy hides it.
31    pub edit: Option<String>,
32    /// The `Delete` POST target, carried as its button's `formaction`; `None` when policy hides
33    /// it.
34    pub delete_action: Option<String>,
35}
36
37/// One `<option>` of a filter select.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct FilterOption {
40    /// The submitted value.
41    pub value: String,
42    /// The shown label.
43    pub label: String,
44    /// Whether the request selected it.
45    pub selected: bool,
46}
47
48/// The data rows of `html` in document order, excluding group headers.
49pub fn rows(html: &str) -> Vec<Row> {
50    let mut out = Vec::new();
51    for (_, attrs, inner) in elements(html, "tr") {
52        let Some(id) = attr_value(&attrs, "id") else {
53            continue;
54        };
55        if !id.starts_with("row-") {
56            continue;
57        }
58        out.push(row(&inner));
59    }
60    out
61}
62
63/// The action targets of the row whose checkbox value or action URL carries
64/// `key`, or `None` when no row matches.
65pub fn row_actions(html: &str, key: &str) -> Option<RowActions> {
66    rows(html).into_iter().find_map(|row| {
67        let matches = row.select_value.as_deref() == Some(key)
68            || row
69                .actions
70                .view
71                .as_deref()
72                .is_some_and(|href| href_contains_key(href, key))
73            || row
74                .actions
75                .edit
76                .as_deref()
77                .is_some_and(|href| href_contains_key(href, key))
78            || row
79                .actions
80                .delete_action
81                .as_deref()
82                .is_some_and(|action| href_contains_key(action, key));
83        matches.then_some(row.actions)
84    })
85}
86
87/// The validation message rendered for `name`, or `None` when the field
88/// renders no error.
89pub fn field_error(html: &str, name: &str) -> Option<String> {
90    let target = format!("{name}-error");
91    for (_, attrs, inner) in tags(html) {
92        if attr_value(&attrs, "id").as_deref() == Some(target.as_str()) {
93            let text = strip_text(&inner);
94            if text.is_empty() {
95                return None;
96            }
97            return Some(text);
98        }
99    }
100    None
101}
102
103/// The options of the select filter `name`, or `None` when `name` has no
104/// select control.
105pub fn filter_options(html: &str, name: &str) -> Option<Vec<FilterOption>> {
106    for (tag, attrs, inner) in tags(html) {
107        if attr_value(&attrs, "data-filter-name").as_deref() != Some(name) {
108            continue;
109        }
110        if tag != "select" {
111            return None;
112        }
113        let mut options = Vec::new();
114        for (_, option_attrs, option_inner) in elements(&inner, "option") {
115            options.push(FilterOption {
116                value: attr_value(&option_attrs, "value").unwrap_or_default(),
117                label: strip_text(&option_inner),
118                selected: is_selected(&option_attrs),
119            });
120        }
121        return Some(options);
122    }
123    None
124}
125
126fn row(row_html: &str) -> Row {
127    let mut select_value = None;
128    for (tag, attrs, _) in tags(row_html) {
129        if tag == "input" && attr_value(&attrs, "aria-label").as_deref() == Some("Select row") {
130            select_value = attr_value(&attrs, "value");
131            break;
132        }
133    }
134    let mut cells = Vec::new();
135    for (_, _, inner) in elements(row_html, "td") {
136        cells.push(strip_text(&inner));
137    }
138    let mut view_href = None;
139    let mut edit_href = None;
140    let mut delete_action = None;
141    for (tag, attrs, _) in tags(row_html) {
142        match (tag.as_str(), attr_value(&attrs, "aria-label").as_deref()) {
143            ("a", Some("View")) => view_href = attr_value(&attrs, "href"),
144            ("a", Some("Edit")) => edit_href = attr_value(&attrs, "href"),
145            ("button", Some("Delete")) => delete_action = attr_value(&attrs, "formaction"),
146            _ => {}
147        }
148    }
149    Row {
150        select_value,
151        cells,
152        actions: RowActions {
153            view: view_href,
154            edit: edit_href,
155            delete_action,
156        },
157    }
158}
159
160fn href_contains_key(href: &str, key: &str) -> bool {
161    href.split(['?', '#', '/', '&'])
162        .any(|segment| segment == key)
163}
164
165fn is_selected(attrs: &Attrs) -> bool {
166    attrs.iter().any(|(name, _)| name == "selected")
167}
168
169fn attr_value(attrs: &Attrs, name: &str) -> Option<String> {
170    attrs
171        .iter()
172        .find(|(attr, _)| attr == name)
173        .and_then(|(_, value)| value.clone())
174}
175
176fn tags(html: &str) -> Vec<Element> {
177    open_tags(html)
178        .into_iter()
179        .map(|(tag, attrs, open_end)| {
180            if is_void(&tag) {
181                return (tag, attrs, String::new());
182            }
183            let close = format!("</{tag}>");
184            let after = &html[open_end + 1..];
185            match after.find(close.as_str()) {
186                Some(close_at) => (tag, attrs, after[..close_at].to_string()),
187                None => (tag, attrs, String::new()),
188            }
189        })
190        .collect()
191}
192
193fn elements(html: &str, tag: &str) -> Vec<Element> {
194    tags(html)
195        .into_iter()
196        .filter(|(name, _, _)| name == tag)
197        .collect()
198}
199
200fn open_tags(html: &str) -> Vec<(String, Attrs, usize)> {
201    let mut out = Vec::new();
202    let mut pos = 0;
203    while let Some(start) = html[pos..].find('<') {
204        let abs_start = pos + start;
205        let Some(end) = tag_end(html, abs_start) else {
206            break;
207        };
208        let raw = &html[abs_start + 1..end];
209        pos = end + 1;
210        if raw.starts_with('!') || raw.starts_with('?') || raw.starts_with('/') {
211            continue;
212        }
213        let (tag, attrs) = parse_tag(raw);
214        if tag.is_empty() {
215            continue;
216        }
217        out.push((tag, attrs, end));
218    }
219    out
220}
221
222fn parse_tag(raw: &str) -> (String, Attrs) {
223    let mut attrs = Vec::new();
224    let bytes = raw.as_bytes();
225    let mut pos = 0;
226    while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() && bytes[pos] != b'/' {
227        pos += 1;
228    }
229    let tag = raw[..pos].to_ascii_lowercase();
230    while pos < bytes.len() {
231        while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
232            pos += 1;
233        }
234        if pos >= bytes.len() || bytes[pos] == b'/' {
235            break;
236        }
237        let name_start = pos;
238        while pos < bytes.len()
239            && (bytes[pos].is_ascii_alphanumeric()
240                || matches!(bytes[pos], b'-' | b'_' | b':' | b'.' | b'@'))
241        {
242            pos += 1;
243        }
244        if name_start == pos {
245            pos += 1;
246            while pos < bytes.len() && !raw.is_char_boundary(pos) {
247                pos += 1;
248            }
249            continue;
250        }
251        let name = raw[name_start..pos].to_ascii_lowercase();
252        while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
253            pos += 1;
254        }
255        if pos >= bytes.len() || bytes[pos] != b'=' {
256            attrs.push((name, None));
257            continue;
258        }
259        pos += 1;
260        while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
261            pos += 1;
262        }
263        if pos >= bytes.len() {
264            attrs.push((name, Some(String::new())));
265            break;
266        }
267        let quote = bytes[pos];
268        if quote == b'"' || quote == b'\'' {
269            pos += 1;
270            let value_start = pos;
271            while pos < bytes.len() && bytes[pos] != quote {
272                pos += 1;
273            }
274            attrs.push((
275                name,
276                Some(decode_entities(&raw[value_start..pos.min(bytes.len())])),
277            ));
278            pos = (pos + 1).min(bytes.len());
279        } else {
280            let value_start = pos;
281            while pos < bytes.len() && !bytes[pos].is_ascii_whitespace() {
282                pos += 1;
283            }
284            attrs.push((name, Some(decode_entities(&raw[value_start..pos]))));
285        }
286    }
287    (tag, attrs)
288}
289
290fn tag_end(html: &str, start: usize) -> Option<usize> {
291    let bytes = html.as_bytes();
292    let mut quote = None;
293    let mut pos = start + 1;
294    while pos < bytes.len() {
295        let byte = bytes[pos];
296        if let Some(q) = quote {
297            if byte == q {
298                quote = None;
299            }
300        } else if byte == b'"' || byte == b'\'' {
301            quote = Some(byte);
302        } else if byte == b'>' {
303            return Some(pos);
304        }
305        pos += 1;
306    }
307    None
308}
309
310fn is_void(tag: &str) -> bool {
311    matches!(
312        tag,
313        "area"
314            | "base"
315            | "br"
316            | "col"
317            | "embed"
318            | "hr"
319            | "img"
320            | "input"
321            | "link"
322            | "meta"
323            | "param"
324            | "source"
325            | "track"
326            | "wbr"
327    )
328}
329
330fn strip_text(fragment: &str) -> String {
331    let mut text = String::new();
332    let mut pos = 0;
333    while pos < fragment.len() {
334        if let Some(start) = fragment[pos..].find('<') {
335            text.push_str(&fragment[pos..pos + start]);
336            let abs = pos + start;
337            match tag_end(fragment, abs) {
338                Some(end) => pos = end + 1,
339                None => break,
340            }
341        } else {
342            text.push_str(&fragment[pos..]);
343            break;
344        }
345    }
346    decode_entities(&text).trim().to_string()
347}
348
349fn decode_entities(text: &str) -> String {
350    text.replace("&lt;", "<")
351        .replace("&gt;", ">")
352        .replace("&quot;", "\"")
353        .replace("&#x27;", "'")
354        .replace("&#39;", "'")
355        .replace("&amp;", "&")
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    const TABLE: &str = r#"<table><thead><tr><th>Name</th></tr></thead><tbody>
363<tr id="row-Ada-12345678"><td><input type="checkbox" value="Ada" aria-label="Select row"></td><td>Ada</td><td><div class="flex"><a href="/admin/dummies/Ada" aria-label="View">V</a><a href="/admin/dummies/Ada/edit" aria-label="Edit">E</a><button type="button" formaction="/admin/dummies/Ada/delete" aria-label="Delete">D</button></div></td></tr>
364<tr id="group-Active-87654321"><td colspan="3">Active (1 on this page)</td></tr>
365<tr id="row-Ken-abcdef12"><td></td><td>Ken</td><td></td></tr>
366<tr><td colspan="3">No records yet</td></tr>
367<tr id="row-NoBox-99999999"><td></td><td>NoBox</td><td><div class="flex"><a href="/admin/dummies/NoBox" aria-label="View">V</a></div></td></tr>
368<tr id="row-Prefixed-aaaaaaaa"><td></td><td>Prefixed</td><td><div class="flex"><button type="button" formaction="/admin/rel/Prefixed/delete?return=%2Fadmin%2Fposts" aria-label="Delete">D</button></div></td></tr>
369</tbody></table>"#;
370
371    #[test]
372    fn rows_skips_group_headers_and_reads_actions() {
373        let found = rows(TABLE);
374        assert_eq!(found.len(), 4);
375        assert_eq!(found[0].select_value.as_deref(), Some("Ada"));
376        assert!(found[0].cells.iter().any(|cell| cell == "Ada"));
377        assert_eq!(found[0].actions.view.as_deref(), Some("/admin/dummies/Ada"));
378        assert_eq!(
379            found[0].actions.edit.as_deref(),
380            Some("/admin/dummies/Ada/edit")
381        );
382        assert_eq!(found[1].select_value, None);
383        assert!(found[1].cells.iter().any(|cell| cell == "Ken"));
384        assert_eq!(found[1].actions.view, None);
385    }
386
387    #[test]
388    fn row_actions_finds_by_key() {
389        let actions = row_actions(TABLE, "Ada").expect("Ada has actions");
390        assert_eq!(actions.view.as_deref(), Some("/admin/dummies/Ada"));
391        assert_eq!(actions.edit.as_deref(), Some("/admin/dummies/Ada/edit"));
392        assert_eq!(
393            actions.delete_action.as_deref(),
394            Some("/admin/dummies/Ada/delete")
395        );
396        let without_checkbox =
397            row_actions(TABLE, "NoBox").expect("a row without a checkbox matches by URL");
398        assert_eq!(
399            without_checkbox.view.as_deref(),
400            Some("/admin/dummies/NoBox")
401        );
402        assert_eq!(without_checkbox.edit, None);
403        let prefixed = row_actions(TABLE, "Prefixed").expect("a delete action matches by URL");
404        assert_eq!(
405            prefixed.delete_action.as_deref(),
406            Some("/admin/rel/Prefixed/delete?return=%2Fadmin%2Fposts")
407        );
408        assert_eq!(row_actions(TABLE, "Ad"), None);
409        assert_eq!(row_actions(TABLE, "Nobody"), None);
410    }
411
412    #[test]
413    fn field_error_reads_the_error_slot() {
414        let html = r#"<div class="ac-field ac-field--error"><input name="name" aria-invalid="true" aria-describedby="name-error"><div id="name-error" class="ac-error">name is required</div></div>"#;
415        assert_eq!(
416            field_error(html, "name").as_deref(),
417            Some("name is required")
418        );
419        assert_eq!(field_error(html, "email"), None);
420    }
421
422    #[test]
423    fn filter_options_reads_values_and_selection() {
424        let html = r#"<label>Status<select name="f.status" data-filter-name="status"><option value="">All</option><option value="draft">draft</option><option value="published" selected="">published</option></select></label><label>Created<input type="date" name="f.created_at" data-filter-name="created_at" value="2024-01-15"></label>"#;
425        let options = filter_options(html, "status").expect("status has options");
426        assert_eq!(options.len(), 3);
427        assert!(options.iter().any(|option| option.value == "draft"));
428        assert_eq!(
429            options
430                .iter()
431                .find(|option| option.value == "published")
432                .map(|option| option.selected),
433            Some(true)
434        );
435        assert_eq!(filter_options(html, "created_at"), None);
436        assert_eq!(filter_options(html, "other"), None);
437    }
438
439    #[test]
440    fn single_quotes_and_entities_decode() {
441        let html =
442            "<select data-filter-name='status'><option value='a&amp;b'>A &amp; B</option></select>";
443        let options = filter_options(html, "status").expect("options");
444        assert_eq!(options[0].value, "a&b");
445        assert_eq!(options[0].label, "A & B");
446    }
447}