Skip to main content

tablo_test/
protocol.rs

1//! Reads values back out of rendered responses: bodies, cookies, form data, the page's language,
2//! and the semantic HTML queries over tables, action buttons, fields, filters, the ignored-filters
3//! banner, the impersonation banner, the import page, rich text values, the notification bell, the
4//! global search, sections, the sidebar, tabs, a list's tab bar, widgets, and wizard steps.
5
6mod bell;
7mod choice_create;
8mod controls;
9mod detail;
10pub mod dom;
11mod ignored;
12mod impersonation;
13mod import;
14mod list_tabs;
15mod page_filters;
16mod rich_text;
17mod search;
18mod sections;
19mod sidebar;
20mod summaries;
21mod tabs;
22mod widgets;
23mod wizard;
24
25pub use bell::{Bell, BellNotification, bell};
26pub use choice_create::{CreateOptionLink, Picked, create_option_links, picked, posts_picking};
27pub use controls::{
28    DisabledAction, action_badge, action_label, action_tooltip, confirms_first, disabled_actions,
29    selects_matching,
30};
31pub use detail::{DetailEntry, RecordNavLink, detail_entries, record_navigation};
32pub use dom::{
33    CellEdit, EmptyReason, EmptyTable, ErrorKind, FilterOption, Group, PickerColumn, Row,
34    RowActions, column_picker, columns, empty_table, field_error, field_error_message,
35    filter_options, find_href_with, groups, row_actions, row_keys, rows, sortable_columns,
36};
37use http_body_util::BodyExt;
38pub use ignored::{IgnoredFilter, IgnoredReason, ignored_filters};
39pub use impersonation::{ImpersonationBanner, impersonate_buttons, impersonation_banner};
40pub use import::{ImportError, ImportRefusal, import_columns, import_errors, import_refusal};
41pub use list_tabs::{TabLink, tab_links};
42pub use page_filters::{PageFilterBar, page_filter_bar};
43pub use rich_text::rich_text_values;
44pub use search::{SearchGroup, SearchResult, search_box, search_results};
45pub use sections::{Section, sections};
46pub use sidebar::{
47    NavGroup, area_back_link, nav_badge, nav_groups, nav_links, tenant_page_link, trail,
48};
49pub use summaries::{SummaryFigure, summaries};
50use tablo_core::{Notification, auth::SESSION_COOKIE_PREFIX};
51pub use tabs::{Tab, tabs};
52use topcoat::router::Body;
53pub use widgets::{
54    ChartData, ChartSeries, RecordTableData, StatTile, charts, record_tables, stats, widgets,
55};
56pub use wizard::{WizardStep, wizard_steps};
57
58/// One `Cookie` header value from `(name, value)` pairs, or `None` when empty.
59pub fn cookie_header<'a>(cookies: impl IntoIterator<Item = (&'a str, &'a str)>) -> Option<String> {
60    let mut jar = String::new();
61    for (name, value) in cookies {
62        if !jar.is_empty() {
63            jar.push_str("; ");
64        }
65        jar.push_str(name);
66        jar.push('=');
67        jar.push_str(value);
68    }
69    (!jar.is_empty()).then_some(jar)
70}
71
72/// The language the page reads in, as its `<html lang>` names it.
73pub fn page_language(html: &str) -> Option<String> {
74    let (_, attrs, _) = dom::tags_where(html, |tag, _| tag == "html")
75        .into_iter()
76        .next()?;
77    dom::attr_value(&attrs, "lang")
78}
79
80/// The search engine instructions the page's robots tag names, `None` when it has no such tag.
81pub fn robots(html: &str) -> Option<String> {
82    let (_, attrs, _) = dom::tags_where(html, |tag, attrs| {
83        tag == "meta" && dom::attr_value(attrs, "name").as_deref() == Some("robots")
84    })
85    .into_iter()
86    .next()?;
87    dom::attr_value(&attrs, "content")
88}
89
90/// The text of each script the page carries inline, in document order.
91pub fn inline_scripts(html: &str) -> Vec<String> {
92    dom::tags_where(html, |tag, attrs| {
93        tag == "script" && dom::attr_value(attrs, "src").is_none()
94    })
95    .into_iter()
96    .map(|(_, _, inner)| inner)
97    .collect()
98}
99
100/// The page's heading, as its `[data-page-title]` holds it; `None` when it has none.
101pub fn page_title(html: &str) -> Option<String> {
102    let (_, _, inner) = dom::tags_where(html, |_, attrs| {
103        dom::attr_value(attrs, "data-page-title").is_some()
104    })
105    .into_iter()
106    .next()?;
107    Some(dom::strip_text(&inner))
108}
109
110/// One locale the shell's language menu offers.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct LanguageChoice {
113    /// The locale the choice posts, as its tag spells it.
114    pub locale: String,
115    /// What the choice renders, in the locale the page reads.
116    pub label: String,
117    /// Whether the page reads in this locale.
118    pub current: bool,
119}
120
121/// The locales the shell's language menu offers, in the order the panel lists them; empty when
122/// the page shows no menu.
123pub fn language_choices(html: &str) -> Vec<LanguageChoice> {
124    dom::tags_where(html, |_, attrs| {
125        dom::attr_value(attrs, "data-locale").is_some()
126    })
127    .into_iter()
128    .map(|(_, attrs, inner)| LanguageChoice {
129        locale: dom::attr_value(&attrs, "data-locale").unwrap_or_default(),
130        label: dom::strip_text(&inner),
131        current: dom::attr_value(&attrs, "aria-current").as_deref() == Some("true"),
132    })
133    .collect()
134}
135
136/// The direction the page's text runs in, `ltr` or `rtl`, as its `<html dir>` names it.
137pub fn page_direction(html: &str) -> Option<String> {
138    let (_, attrs, _) = dom::tags_where(html, |tag, _| tag == "html")
139        .into_iter()
140        .next()?;
141    dom::attr_value(&attrs, "dir")
142}
143
144/// Collect a response body as bytes.
145pub async fn body_bytes(response: http::Response<Body>) -> Vec<u8> {
146    response
147        .into_body()
148        .collect()
149        .await
150        .expect("collect body")
151        .to_bytes()
152        .to_vec()
153}
154
155/// Collect a response body as a lossy UTF-8 string.
156pub async fn body_string(response: http::Response<Body>) -> String {
157    String::from_utf8_lossy(&body_bytes(response).await).into_owned()
158}
159
160/// URL-encode `(key, value)` pairs into an urlencoded form body.
161pub fn form_body(pairs: &[(&str, &str)]) -> String {
162    let mut serializer = form_urlencoded::Serializer::new(String::new());
163    for (key, value) in pairs {
164        serializer.append_pair(key, value);
165    }
166    serializer.finish()
167}
168
169/// Builds a multipart body, one part per entry: `None` is a text part,
170/// `Some("")` the browser's "no file chosen" part, `Some(name)` a chosen file.
171///
172/// No part carries a `Content-Type`: the panel tells file parts from text parts by the
173/// `filename` parameter alone. A test asserting on an upload's content type builds its own body.
174pub fn multipart_body(boundary: &str, parts: &[(&str, Option<&str>, &str)]) -> String {
175    let mut body = String::new();
176    for (name, filename, content) in parts {
177        body.push_str(&format!(
178            "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\""
179        ));
180        if let Some(filename) = filename {
181            body.push_str(&format!("; filename=\"{filename}\""));
182        }
183        body.push_str("\r\n\r\n");
184        body.push_str(content);
185        body.push_str("\r\n");
186    }
187    body.push_str(&format!("--{boundary}--\r\n"));
188    body
189}
190
191/// Reads the value the named `<input>`s post when the form submits untouched, in either attribute
192/// order or quote style: the last one's `value`, skipping a radio or checkbox left unchecked. A
193/// toggle reads `true` or its hidden `false`, and a radio choice its checked option, or the hidden
194/// blank before them when none is checked.
195pub fn input_value(html: &str, name: &str) -> Option<String> {
196    input_values(html, name).pop()
197}
198
199/// Reads every value the named `<input>`s post when the form submits untouched, in document
200/// order, skipping a radio or checkbox left unchecked: each row a file list keeps posts one.
201pub fn input_values(html: &str, name: &str) -> Vec<String> {
202    let double = format!("name=\"{name}\"");
203    let single = format!("name='{name}'");
204    let mut posted = Vec::new();
205    for tag in html.split('<').skip(1) {
206        if !tag.starts_with("input") || (!tag.contains(&double) && !tag.contains(&single)) {
207            continue;
208        }
209        let Some(end) = tag.find('>') else {
210            continue;
211        };
212        let attrs = &tag[..end];
213        let checkable = [
214            "type=\"radio\"",
215            "type=\"checkbox\"",
216            "type='radio'",
217            "type='checkbox'",
218        ]
219        .iter()
220        .any(|kind| attrs.contains(kind));
221        if checkable && !has_attribute(attrs, "checked") {
222            continue;
223        }
224        for (prefix, term) in [("value=\"", '"'), ("value='", '\'')] {
225            if let Some(start) = attrs.find(prefix) {
226                let rest = &attrs[start + prefix.len()..];
227                if let Some(end) = rest.find(term) {
228                    // Unescape the matching quote entity the encoder may emit.
229                    let raw = &rest[..end];
230                    posted.push(raw.replace("&quot;", "\"").replace("&#x27;", "'"));
231                    break;
232                }
233            }
234        }
235    }
236    posted
237}
238
239/// Whether the opening tag `attrs` carries the attribute `name`, valued or bare, skipping quoted
240/// values so a class naming `checked:` does not count.
241fn has_attribute(attrs: &str, name: &str) -> bool {
242    let mut rest = attrs;
243    while let Some(at) = rest.find(|c: char| !c.is_whitespace()) {
244        rest = &rest[at..];
245        let end = rest
246            .find(|c: char| c.is_whitespace() || c == '=' || c == '/')
247            .unwrap_or(rest.len());
248        if &rest[..end] == name {
249            return true;
250        }
251        rest = &rest[end..];
252        if let Some(value) = rest.strip_prefix('=') {
253            rest = match value.chars().next() {
254                Some(quote @ ('"' | '\'')) => value[1..]
255                    .find(quote)
256                    .map_or("", |close| &value[close + 2..]),
257                _ => value
258                    .find(char::is_whitespace)
259                    .map_or("", |space| &value[space..]),
260            };
261        } else if let Some(after) = rest.strip_prefix('/') {
262            rest = after;
263        }
264    }
265    false
266}
267
268/// The `(name, value)` pairs a response's `Set-Cookie` headers carry.
269pub fn response_cookies(response: &http::Response<Body>) -> Vec<(String, String)> {
270    response
271        .headers()
272        .get_all(http::header::SET_COOKIE)
273        .iter()
274        .filter_map(|value| value.to_str().ok())
275        .filter_map(|value| value.split(';').next())
276        .filter_map(|pair| pair.split_once('='))
277        .map(|(name, value)| (name.trim().to_string(), value.trim().to_string()))
278        .collect()
279}
280
281/// The full `Set-Cookie` header for `name`, so tests can assert attributes.
282pub fn set_cookie_header(response: &http::Response<Body>, name: &str) -> Option<String> {
283    response
284        .headers()
285        .get_all(http::header::SET_COOKIE)
286        .iter()
287        .filter_map(|value| value.to_str().ok())
288        .find(|value| value.starts_with(&format!("{name}=")))
289        .map(str::to_string)
290}
291
292/// The value of the session cookie a response set, on whichever panel, if any.
293pub fn session_cookie_value(response: &http::Response<Body>) -> Option<String> {
294    response_cookies(response)
295        .into_iter()
296        .find(|(name, _)| name.starts_with(SESSION_COOKIE_PREFIX))
297        .map(|(_, value)| value)
298}
299
300/// The flash notification a response set for the next page, decoded.
301pub fn notification(response: &http::Response<Body>) -> Option<Notification> {
302    notification_in(&response_cookies(response))
303}
304
305/// The flash notification among a response's `(name, value)` cookie pairs, decoded.
306pub(crate) fn notification_in(cookies: &[(String, String)]) -> Option<Notification> {
307    let (_, value) = cookies
308        .iter()
309        .find(|(name, _)| name.ends_with("tablo_notification"))?;
310    let json = percent_encoding::percent_decode_str(value)
311        .decode_utf8()
312        .ok()?;
313    serde_json::from_str(&json).ok()
314}
315
316/// Whether the edit form refuses the save because another save changed the record since the form
317/// opened: it renders an element carrying `data-stale`.
318pub fn stale_form(html: &str) -> bool {
319    !dom::tags_where(html, |_, attrs| {
320        attrs.iter().any(|(attr, _)| attr == "data-stale")
321    })
322    .is_empty()
323}
324
325/// The message a record fn declined the save with, which the form renders above its fields in
326/// an element carrying `data-declined`.
327pub fn declined_message(html: &str) -> Option<String> {
328    let (_, _, inner) = dom::tags_where(html, |_, attrs| {
329        attrs.iter().any(|(attr, _)| attr == "data-declined")
330    })
331    .into_iter()
332    .next()?;
333    Some(dom::strip_text(&inner))
334}
335
336/// Where a panel behind external auth sends a user out to: the page it refuses them on, and the
337/// page its Sign out answers with, link the app's sign-out from a `data-external-sign-out` element.
338pub fn external_sign_out(html: &str) -> Option<String> {
339    let (_, attrs, _) = dom::tags_where(html, |_, attrs| {
340        attrs
341            .iter()
342            .any(|(attr, _)| attr == "data-external-sign-out")
343    })
344    .into_iter()
345    .next()?;
346    attrs.into_iter().find(|(attr, _)| attr == "href")?.1
347}
348
349/// The URL a `<meta http-equiv="refresh">` moves the page on to.
350pub fn refreshes_to(html: &str) -> Option<String> {
351    let (_, attrs, _) = dom::tags_where(html, |name, attrs| {
352        name == "meta" && attrs.iter().any(|(attr, _)| attr == "http-equiv")
353    })
354    .into_iter()
355    .next()?;
356    let content = attrs.into_iter().find(|(attr, _)| attr == "content")?.1?;
357    content.split_once("url=").map(|(_, url)| url.to_string())
358}
359
360/// Whether a form renders dirty, so leaving the page asks first: a refused save keeps what the
361/// user typed, and the server marks its form with `data-form-dirty`.
362pub fn dirty_form(html: &str) -> bool {
363    !dom::tags_where(html, |name, attrs| {
364        name == "form" && attrs.iter().any(|(attr, _)| attr == "data-form-dirty")
365    })
366    .is_empty()
367}