tablo-test 0.9.0

In-memory HTTP test client for Tablo panels, re-exported as `tablo::testing`.
Documentation
//! Reads values back out of rendered responses: bodies, cookies, form data, the page's language,
//! and the semantic HTML queries over tables, action buttons, fields, filters, the ignored-filters
//! banner, the impersonation banner, the import page, rich text values, the notification bell, the
//! global search, sections, the sidebar, tabs, a list's tab bar, widgets, and wizard steps.

mod bell;
mod choice_create;
mod controls;
mod detail;
pub mod dom;
mod ignored;
mod impersonation;
mod import;
mod list_tabs;
mod page_filters;
mod rich_text;
mod search;
mod sections;
mod sidebar;
mod summaries;
mod tabs;
mod widgets;
mod wizard;

pub use bell::{Bell, BellNotification, bell};
pub use choice_create::{CreateOptionLink, Picked, create_option_links, picked, posts_picking};
pub use controls::{
    DisabledAction, action_badge, action_label, action_tooltip, confirms_first, disabled_actions,
    selects_matching,
};
pub use detail::{DetailEntry, RecordNavLink, detail_entries, record_navigation};
pub use dom::{
    CellEdit, EmptyReason, EmptyTable, ErrorKind, FilterOption, Group, PickerColumn, Row,
    RowActions, column_picker, columns, empty_table, field_error, field_error_message,
    filter_options, find_href_with, groups, row_actions, row_keys, rows, sortable_columns,
};
use http_body_util::BodyExt;
pub use ignored::{IgnoredFilter, IgnoredReason, ignored_filters};
pub use impersonation::{ImpersonationBanner, impersonate_buttons, impersonation_banner};
pub use import::{ImportError, ImportRefusal, import_columns, import_errors, import_refusal};
pub use list_tabs::{TabLink, tab_links};
pub use page_filters::{PageFilterBar, page_filter_bar};
pub use rich_text::rich_text_values;
pub use search::{SearchGroup, SearchResult, search_box, search_results};
pub use sections::{Section, sections};
pub use sidebar::{
    NavGroup, area_back_link, nav_badge, nav_groups, nav_links, tenant_page_link, trail,
};
pub use summaries::{SummaryFigure, summaries};
use tablo_core::{Notification, auth::SESSION_COOKIE_PREFIX};
pub use tabs::{Tab, tabs};
use topcoat::router::Body;
pub use widgets::{
    ChartData, ChartSeries, RecordTableData, StatTile, charts, record_tables, stats, widgets,
};
pub use wizard::{WizardStep, wizard_steps};

/// One `Cookie` header value from `(name, value)` pairs, or `None` when empty.
pub fn cookie_header<'a>(cookies: impl IntoIterator<Item = (&'a str, &'a str)>) -> Option<String> {
    let mut jar = String::new();
    for (name, value) in cookies {
        if !jar.is_empty() {
            jar.push_str("; ");
        }
        jar.push_str(name);
        jar.push('=');
        jar.push_str(value);
    }
    (!jar.is_empty()).then_some(jar)
}

/// The language the page reads in, as its `<html lang>` names it.
pub fn page_language(html: &str) -> Option<String> {
    let (_, attrs, _) = dom::tags_where(html, |tag, _| tag == "html")
        .into_iter()
        .next()?;
    dom::attr_value(&attrs, "lang")
}

/// The search engine instructions the page's robots tag names, `None` when it has no such tag.
pub fn robots(html: &str) -> Option<String> {
    let (_, attrs, _) = dom::tags_where(html, |tag, attrs| {
        tag == "meta" && dom::attr_value(attrs, "name").as_deref() == Some("robots")
    })
    .into_iter()
    .next()?;
    dom::attr_value(&attrs, "content")
}

/// The text of each script the page carries inline, in document order.
pub fn inline_scripts(html: &str) -> Vec<String> {
    dom::tags_where(html, |tag, attrs| {
        tag == "script" && dom::attr_value(attrs, "src").is_none()
    })
    .into_iter()
    .map(|(_, _, inner)| inner)
    .collect()
}

/// The page's heading, as its `[data-page-title]` holds it; `None` when it has none.
pub fn page_title(html: &str) -> Option<String> {
    let (_, _, inner) = dom::tags_where(html, |_, attrs| {
        dom::attr_value(attrs, "data-page-title").is_some()
    })
    .into_iter()
    .next()?;
    Some(dom::strip_text(&inner))
}

/// One locale the shell's language menu offers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LanguageChoice {
    /// The locale the choice posts, as its tag spells it.
    pub locale: String,
    /// What the choice renders, in the locale the page reads.
    pub label: String,
    /// Whether the page reads in this locale.
    pub current: bool,
}

/// The locales the shell's language menu offers, in the order the panel lists them; empty when
/// the page shows no menu.
pub fn language_choices(html: &str) -> Vec<LanguageChoice> {
    dom::tags_where(html, |_, attrs| {
        dom::attr_value(attrs, "data-locale").is_some()
    })
    .into_iter()
    .map(|(_, attrs, inner)| LanguageChoice {
        locale: dom::attr_value(&attrs, "data-locale").unwrap_or_default(),
        label: dom::strip_text(&inner),
        current: dom::attr_value(&attrs, "aria-current").as_deref() == Some("true"),
    })
    .collect()
}

/// The direction the page's text runs in, `ltr` or `rtl`, as its `<html dir>` names it.
pub fn page_direction(html: &str) -> Option<String> {
    let (_, attrs, _) = dom::tags_where(html, |tag, _| tag == "html")
        .into_iter()
        .next()?;
    dom::attr_value(&attrs, "dir")
}

/// Collect a response body as bytes.
pub async fn body_bytes(response: http::Response<Body>) -> Vec<u8> {
    response
        .into_body()
        .collect()
        .await
        .expect("collect body")
        .to_bytes()
        .to_vec()
}

/// Collect a response body as a lossy UTF-8 string.
pub async fn body_string(response: http::Response<Body>) -> String {
    String::from_utf8_lossy(&body_bytes(response).await).into_owned()
}

/// URL-encode `(key, value)` pairs into an urlencoded form body.
pub fn form_body(pairs: &[(&str, &str)]) -> String {
    let mut serializer = form_urlencoded::Serializer::new(String::new());
    for (key, value) in pairs {
        serializer.append_pair(key, value);
    }
    serializer.finish()
}

/// Builds a multipart body, one part per entry: `None` is a text part,
/// `Some("")` the browser's "no file chosen" part, `Some(name)` a chosen file.
///
/// No part carries a `Content-Type`: the panel tells file parts from text parts by the
/// `filename` parameter alone. A test asserting on an upload's content type builds its own body.
pub fn multipart_body(boundary: &str, parts: &[(&str, Option<&str>, &str)]) -> String {
    let mut body = String::new();
    for (name, filename, content) in parts {
        body.push_str(&format!(
            "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\""
        ));
        if let Some(filename) = filename {
            body.push_str(&format!("; filename=\"{filename}\""));
        }
        body.push_str("\r\n\r\n");
        body.push_str(content);
        body.push_str("\r\n");
    }
    body.push_str(&format!("--{boundary}--\r\n"));
    body
}

/// Reads the value the named `<input>`s post when the form submits untouched, in either attribute
/// order or quote style: the last one's `value`, skipping a radio or checkbox left unchecked. A
/// toggle reads `true` or its hidden `false`, and a radio choice its checked option, or the hidden
/// blank before them when none is checked.
pub fn input_value(html: &str, name: &str) -> Option<String> {
    input_values(html, name).pop()
}

/// Reads every value the named `<input>`s post when the form submits untouched, in document
/// order, skipping a radio or checkbox left unchecked: each row a file list keeps posts one.
pub fn input_values(html: &str, name: &str) -> Vec<String> {
    let double = format!("name=\"{name}\"");
    let single = format!("name='{name}'");
    let mut posted = Vec::new();
    for tag in html.split('<').skip(1) {
        if !tag.starts_with("input") || (!tag.contains(&double) && !tag.contains(&single)) {
            continue;
        }
        let Some(end) = tag.find('>') else {
            continue;
        };
        let attrs = &tag[..end];
        let checkable = [
            "type=\"radio\"",
            "type=\"checkbox\"",
            "type='radio'",
            "type='checkbox'",
        ]
        .iter()
        .any(|kind| attrs.contains(kind));
        if checkable && !has_attribute(attrs, "checked") {
            continue;
        }
        for (prefix, term) in [("value=\"", '"'), ("value='", '\'')] {
            if let Some(start) = attrs.find(prefix) {
                let rest = &attrs[start + prefix.len()..];
                if let Some(end) = rest.find(term) {
                    // Unescape the matching quote entity the encoder may emit.
                    let raw = &rest[..end];
                    posted.push(raw.replace("&quot;", "\"").replace("&#x27;", "'"));
                    break;
                }
            }
        }
    }
    posted
}

/// Whether the opening tag `attrs` carries the attribute `name`, valued or bare, skipping quoted
/// values so a class naming `checked:` does not count.
fn has_attribute(attrs: &str, name: &str) -> bool {
    let mut rest = attrs;
    while let Some(at) = rest.find(|c: char| !c.is_whitespace()) {
        rest = &rest[at..];
        let end = rest
            .find(|c: char| c.is_whitespace() || c == '=' || c == '/')
            .unwrap_or(rest.len());
        if &rest[..end] == name {
            return true;
        }
        rest = &rest[end..];
        if let Some(value) = rest.strip_prefix('=') {
            rest = match value.chars().next() {
                Some(quote @ ('"' | '\'')) => value[1..]
                    .find(quote)
                    .map_or("", |close| &value[close + 2..]),
                _ => value
                    .find(char::is_whitespace)
                    .map_or("", |space| &value[space..]),
            };
        } else if let Some(after) = rest.strip_prefix('/') {
            rest = after;
        }
    }
    false
}

/// The `(name, value)` pairs a response's `Set-Cookie` headers carry.
pub fn response_cookies(response: &http::Response<Body>) -> Vec<(String, String)> {
    response
        .headers()
        .get_all(http::header::SET_COOKIE)
        .iter()
        .filter_map(|value| value.to_str().ok())
        .filter_map(|value| value.split(';').next())
        .filter_map(|pair| pair.split_once('='))
        .map(|(name, value)| (name.trim().to_string(), value.trim().to_string()))
        .collect()
}

/// The full `Set-Cookie` header for `name`, so tests can assert attributes.
pub fn set_cookie_header(response: &http::Response<Body>, name: &str) -> Option<String> {
    response
        .headers()
        .get_all(http::header::SET_COOKIE)
        .iter()
        .filter_map(|value| value.to_str().ok())
        .find(|value| value.starts_with(&format!("{name}=")))
        .map(str::to_string)
}

/// The value of the session cookie a response set, on whichever panel, if any.
pub fn session_cookie_value(response: &http::Response<Body>) -> Option<String> {
    response_cookies(response)
        .into_iter()
        .find(|(name, _)| name.starts_with(SESSION_COOKIE_PREFIX))
        .map(|(_, value)| value)
}

/// The flash notification a response set for the next page, decoded.
pub fn notification(response: &http::Response<Body>) -> Option<Notification> {
    notification_in(&response_cookies(response))
}

/// The flash notification among a response's `(name, value)` cookie pairs, decoded.
pub(crate) fn notification_in(cookies: &[(String, String)]) -> Option<Notification> {
    let (_, value) = cookies
        .iter()
        .find(|(name, _)| name.ends_with("tablo_notification"))?;
    let json = percent_encoding::percent_decode_str(value)
        .decode_utf8()
        .ok()?;
    serde_json::from_str(&json).ok()
}

/// Whether the edit form refuses the save because another save changed the record since the form
/// opened: it renders an element carrying `data-stale`.
pub fn stale_form(html: &str) -> bool {
    !dom::tags_where(html, |_, attrs| {
        attrs.iter().any(|(attr, _)| attr == "data-stale")
    })
    .is_empty()
}

/// The message a record fn declined the save with, which the form renders above its fields in
/// an element carrying `data-declined`.
pub fn declined_message(html: &str) -> Option<String> {
    let (_, _, inner) = dom::tags_where(html, |_, attrs| {
        attrs.iter().any(|(attr, _)| attr == "data-declined")
    })
    .into_iter()
    .next()?;
    Some(dom::strip_text(&inner))
}

/// Where a panel behind external auth sends a user out to: the page it refuses them on, and the
/// page its Sign out answers with, link the app's sign-out from a `data-external-sign-out` element.
pub fn external_sign_out(html: &str) -> Option<String> {
    let (_, attrs, _) = dom::tags_where(html, |_, attrs| {
        attrs
            .iter()
            .any(|(attr, _)| attr == "data-external-sign-out")
    })
    .into_iter()
    .next()?;
    attrs.into_iter().find(|(attr, _)| attr == "href")?.1
}

/// The URL a `<meta http-equiv="refresh">` moves the page on to.
pub fn refreshes_to(html: &str) -> Option<String> {
    let (_, attrs, _) = dom::tags_where(html, |name, attrs| {
        name == "meta" && attrs.iter().any(|(attr, _)| attr == "http-equiv")
    })
    .into_iter()
    .next()?;
    let content = attrs.into_iter().find(|(attr, _)| attr == "content")?.1?;
    content.split_once("url=").map(|(_, url)| url.to_string())
}

/// Whether a form renders dirty, so leaving the page asks first: a refused save keeps what the
/// user typed, and the server marks its form with `data-form-dirty`.
pub fn dirty_form(html: &str) -> bool {
    !dom::tags_where(html, |name, attrs| {
        name == "form" && attrs.iter().any(|(attr, _)| attr == "data-form-dirty")
    })
    .is_empty()
}