Skip to main content

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