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, fields, filters, the ignored-filters banner, the
3//! import page, rich text values, the notification bell, the global search, and tabs.
4
5mod bell;
6pub mod dom;
7mod ignored;
8mod import;
9mod rich_text;
10mod search;
11mod tabs;
12
13pub use bell::{Bell, BellNotification, bell};
14pub use dom::{
15    EmptyReason, EmptyTable, ErrorKind, FilterOption, Group, PickerColumn, Row, RowActions,
16    column_picker, columns, confirms_first, empty_table, field_error, field_error_message,
17    filter_options, find_href_with, groups, nav_badge, row_actions, row_keys, rows,
18};
19use http_body_util::BodyExt;
20pub use ignored::{IgnoredFilter, IgnoredReason, ignored_filters};
21pub use import::{ImportError, ImportRefusal, import_columns, import_errors, import_refusal};
22pub use rich_text::rich_text_values;
23pub use search::{SearchGroup, SearchResult, search_box, search_results};
24use tablo_core::Notification;
25pub use tabs::{Tab, tabs};
26use topcoat::router::Body;
27
28/// One `Cookie` header value from `(name, value)` pairs, or `None` when empty.
29pub fn cookie_header<'a>(cookies: impl IntoIterator<Item = (&'a str, &'a str)>) -> Option<String> {
30    let mut jar = String::new();
31    for (name, value) in cookies {
32        if !jar.is_empty() {
33            jar.push_str("; ");
34        }
35        jar.push_str(name);
36        jar.push('=');
37        jar.push_str(value);
38    }
39    (!jar.is_empty()).then_some(jar)
40}
41
42/// The language the page reads in, as its `<html lang>` names it.
43pub fn page_language(html: &str) -> Option<String> {
44    let (_, attrs, _) = dom::tags_where(html, |tag, _| tag == "html")
45        .into_iter()
46        .next()?;
47    dom::attr_value(&attrs, "lang")
48}
49
50/// The direction the page's text runs in, `ltr` or `rtl`, as its `<html dir>` names it.
51pub fn page_direction(html: &str) -> Option<String> {
52    let (_, attrs, _) = dom::tags_where(html, |tag, _| tag == "html")
53        .into_iter()
54        .next()?;
55    dom::attr_value(&attrs, "dir")
56}
57
58/// Collect a response body as bytes.
59pub async fn body_bytes(response: http::Response<Body>) -> Vec<u8> {
60    response
61        .into_body()
62        .collect()
63        .await
64        .expect("collect body")
65        .to_bytes()
66        .to_vec()
67}
68
69/// Collect a response body as a lossy UTF-8 string.
70pub async fn body_string(response: http::Response<Body>) -> String {
71    String::from_utf8_lossy(&body_bytes(response).await).into_owned()
72}
73
74/// URL-encode `(key, value)` pairs into an urlencoded form body.
75pub fn form_body(pairs: &[(&str, &str)]) -> String {
76    let mut serializer = form_urlencoded::Serializer::new(String::new());
77    for (key, value) in pairs {
78        serializer.append_pair(key, value);
79    }
80    serializer.finish()
81}
82
83/// Builds a multipart body, one part per entry: `None` is a text part,
84/// `Some("")` the browser's "no file chosen" part, `Some(name)` a chosen file.
85///
86/// No part carries a `Content-Type`: the panel tells file parts from text parts by the
87/// `filename` parameter alone. A test asserting on an upload's content type builds its own body.
88pub fn multipart_body(boundary: &str, parts: &[(&str, Option<&str>, &str)]) -> String {
89    let mut body = String::new();
90    for (name, filename, content) in parts {
91        body.push_str(&format!(
92            "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\""
93        ));
94        if let Some(filename) = filename {
95            body.push_str(&format!("; filename=\"{filename}\""));
96        }
97        body.push_str("\r\n\r\n");
98        body.push_str(content);
99        body.push_str("\r\n");
100    }
101    body.push_str(&format!("--{boundary}--\r\n"));
102    body
103}
104
105/// Reads the value the named `<input>`s post when the form submits untouched, in either attribute
106/// order or quote style: the last one's `value`, skipping a radio or checkbox left unchecked. A
107/// toggle reads `true` or its hidden `false`, and a radio choice its checked option, or the hidden
108/// blank before them when none is checked.
109pub fn input_value(html: &str, name: &str) -> Option<String> {
110    let double = format!("name=\"{name}\"");
111    let single = format!("name='{name}'");
112    let mut posted = None;
113    for tag in html.split('<').skip(1) {
114        if !tag.starts_with("input") || (!tag.contains(&double) && !tag.contains(&single)) {
115            continue;
116        }
117        let Some(end) = tag.find('>') else {
118            continue;
119        };
120        let attrs = &tag[..end];
121        let checkable = [
122            "type=\"radio\"",
123            "type=\"checkbox\"",
124            "type='radio'",
125            "type='checkbox'",
126        ]
127        .iter()
128        .any(|kind| attrs.contains(kind));
129        if checkable && !has_attribute(attrs, "checked") {
130            continue;
131        }
132        for (prefix, term) in [("value=\"", '"'), ("value='", '\'')] {
133            if let Some(start) = attrs.find(prefix) {
134                let rest = &attrs[start + prefix.len()..];
135                if let Some(end) = rest.find(term) {
136                    // Unescape the matching quote entity the encoder may emit.
137                    let raw = &rest[..end];
138                    posted = Some(raw.replace("&quot;", "\"").replace("&#x27;", "'"));
139                    break;
140                }
141            }
142        }
143    }
144    posted
145}
146
147/// Whether the opening tag `attrs` carries the attribute `name`, valued or bare, skipping quoted
148/// values so a class naming `checked:` does not count.
149fn has_attribute(attrs: &str, name: &str) -> bool {
150    let mut rest = attrs;
151    while let Some(at) = rest.find(|c: char| !c.is_whitespace()) {
152        rest = &rest[at..];
153        let end = rest
154            .find(|c: char| c.is_whitespace() || c == '=' || c == '/')
155            .unwrap_or(rest.len());
156        if &rest[..end] == name {
157            return true;
158        }
159        rest = &rest[end..];
160        if let Some(value) = rest.strip_prefix('=') {
161            rest = match value.chars().next() {
162                Some(quote @ ('"' | '\'')) => value[1..]
163                    .find(quote)
164                    .map_or("", |close| &value[close + 2..]),
165                _ => value
166                    .find(char::is_whitespace)
167                    .map_or("", |space| &value[space..]),
168            };
169        } else if let Some(after) = rest.strip_prefix('/') {
170            rest = after;
171        }
172    }
173    false
174}
175
176/// Names the session cookie Topcoat's default token store writes.
177pub const SESSION_COOKIE: &str = "__Host-session";
178
179/// The `(name, value)` pairs a response's `Set-Cookie` headers carry.
180pub fn response_cookies(response: &http::Response<Body>) -> Vec<(String, String)> {
181    response
182        .headers()
183        .get_all(http::header::SET_COOKIE)
184        .iter()
185        .filter_map(|value| value.to_str().ok())
186        .filter_map(|value| value.split(';').next())
187        .filter_map(|pair| pair.split_once('='))
188        .map(|(name, value)| (name.trim().to_string(), value.trim().to_string()))
189        .collect()
190}
191
192/// The full `Set-Cookie` header for `name`, so tests can assert attributes.
193pub fn set_cookie_header(response: &http::Response<Body>, name: &str) -> Option<String> {
194    response
195        .headers()
196        .get_all(http::header::SET_COOKIE)
197        .iter()
198        .filter_map(|value| value.to_str().ok())
199        .find(|value| value.starts_with(&format!("{name}=")))
200        .map(str::to_string)
201}
202
203/// The session cookie value a response set, if any.
204pub fn session_cookie_value(response: &http::Response<Body>) -> Option<String> {
205    response_cookies(response)
206        .into_iter()
207        .find(|(name, _)| name == SESSION_COOKIE)
208        .map(|(_, value)| value)
209}
210
211/// The flash notification a response set for the next page, decoded.
212pub fn notification(response: &http::Response<Body>) -> Option<Notification> {
213    notification_in(&response_cookies(response))
214}
215
216/// The flash notification among a response's `(name, value)` cookie pairs, decoded.
217pub(crate) fn notification_in(cookies: &[(String, String)]) -> Option<Notification> {
218    let (_, value) = cookies
219        .iter()
220        .find(|(name, _)| name.ends_with("tablo_notification"))?;
221    let json = percent_encoding::percent_decode_str(value)
222        .decode_utf8()
223        .ok()?;
224    serde_json::from_str(&json).ok()
225}
226
227/// Whether the edit form refuses the save because another save changed the record since the form
228/// opened: it renders an element carrying `data-stale`.
229pub fn stale_form(html: &str) -> bool {
230    !dom::tags_where(html, |_, attrs| {
231        attrs.iter().any(|(attr, _)| attr == "data-stale")
232    })
233    .is_empty()
234}