wabot-testing 0.1.0

Test harnesses for Wabot: a scriptable LLM adapter plus chat-bot and agent harnesses that drive the real production paths.
Documentation
//! Drive UI controllers in a test. Port of
//! `wabot-ts/src/testing/uiHarness.ts`.
//!
//! UI controllers are served by the REST stack, so this builds on
//! [`RestHarness`] and adds what a page test actually asserts on:
//! rendered HTML, the islands in it, boosted-navigation fragments and
//! action responses.
//!
//! Islands are **not** hydrated — there is no browser here. What the
//! harness checks is the server's half of the contract: that a host
//! element is emitted with the right id and props, which is what the
//! client runtime needs to mount anything at all. A mismatch there is
//! the failure that shows up as "the island silently never appeared".

use serde::Serialize;
use wabot_feature_rest_controller::axum::http::StatusCode;
use wabot_feature_rest_controller::axum::Router;
use wabot_feature_ui_controller::nav::NAV_HEADER;
use wabot_feature_ui_controller::runtime::action_route;

use crate::rest::{RestHarness, TestResponse};

/// Mounts UI routes and exercises the real pipeline: middlewares,
/// extractors, rendering, the static cache and actions.
///
/// ```ignore
/// let harness = UiHarness::new(NotesController::register_ui_routes(&container, ui_router()));
///
/// let page = harness.get("/notes").await;
/// page.assert_ok();
/// assert!(page.contains("<h1>Notes</h1>"));
/// assert!(page.has_island("notes-form"));
/// ```
#[derive(Clone)]
pub struct UiHarness {
    rest: RestHarness,
}

impl UiHarness {
    /// Build from a router — usually
    /// `MyController::register_ui_routes(&container, ui_router())`.
    ///
    /// Pass the real `ui_router()` rather than `Router::new()` if the
    /// test touches the client runtime or boosted navigation, since
    /// that is where `/_wabot/client.js` lives.
    pub fn new(router: Router) -> Self {
        Self {
            rest: RestHarness::new(router),
        }
    }

    /// The underlying REST harness, for headers, cookies and anything
    /// page-shaped this doesn't cover.
    pub fn rest(&self) -> &RestHarness {
        &self.rest
    }

    pub fn with_header(&self, name: impl Into<String>, value: impl Into<String>) -> Self {
        Self {
            rest: self.rest.with_header(name, value),
        }
    }

    pub fn with_bearer(&self, token: impl std::fmt::Display) -> Self {
        Self {
            rest: self.rest.with_bearer(token),
        }
    }

    pub fn with_cookie(&self, name: &str, value: impl std::fmt::Display) -> Self {
        Self {
            rest: self.rest.with_cookie(name, value),
        }
    }

    /// GET a view and get the rendered document.
    pub async fn get(&self, path: &str) -> Page {
        Page(self.rest.get(path).send().await)
    }

    /// GET a view the way the client runtime does after the first
    /// load: `X-Wabot-Nav: 1`, answered with a JSON fragment instead
    /// of a document.
    pub async fn navigate(&self, path: &str) -> Fragment {
        Fragment(self.rest.get(path).header(NAV_HEADER, "1").send().await)
    }

    /// POST to an `#[action]`, addressing it the way the client
    /// runtime does — `<controller>/_action/<name>`, built with the
    /// framework's own route helper so the convention can't drift.
    pub async fn action<T: Serialize>(&self, base: &str, name: &str, body: &T) -> TestResponse {
        self.rest
            .post(&action_route(base, name))
            .json(body)
            .send()
            .await
    }

    /// The client runtime, to check it is being served at all.
    pub async fn client_runtime(&self) -> TestResponse {
        self.rest
            .get(wabot_feature_ui_controller::nav::CLIENT_RUNTIME_PATH)
            .send()
            .await
    }
}

/// A rendered page.
pub struct Page(pub TestResponse);

impl Page {
    pub fn status(&self) -> StatusCode {
        self.0.status
    }

    /// The HTML, for a bespoke assertion.
    pub fn html(&self) -> &str {
        &self.0.body
    }

    pub fn contains(&self, fragment: &str) -> bool {
        self.0.body.contains(fragment)
    }

    /// Whether an island host was emitted for `id`.
    ///
    /// The server's whole job for an island is putting this element in
    /// the document; if it isn't there the island never mounts, and
    /// nothing else in a test would notice.
    pub fn has_island(&self, id: &str) -> bool {
        self.0
            .body
            .contains(&format!("data-island=\"{}\"", html_escape(id)))
    }

    /// The props handed to an island, decoded from the attribute.
    ///
    /// `None` when there is no such island, so a test distinguishes
    /// "not rendered" from "rendered with nothing".
    pub fn island_props(&self, id: &str) -> Option<serde_json::Value> {
        let marker = format!("data-island=\"{}\"", html_escape(id));
        let start = self.0.body.find(&marker)? + marker.len();
        let rest = &self.0.body[start..];
        let props_at = rest.find("data-props=\"")? + "data-props=\"".len();
        let rest = &rest[props_at..];
        let end = rest.find('"')?;
        serde_json::from_str(&html_unescape(&rest[..end])).ok()
    }

    /// Ids of every island host in the document, in order.
    pub fn islands(&self) -> Vec<String> {
        let mut ids = Vec::new();
        let mut rest = self.0.body.as_str();
        while let Some(at) = rest.find("data-island=\"") {
            rest = &rest[at + "data-island=\"".len()..];
            if let Some(end) = rest.find('"') {
                ids.push(html_unescape(&rest[..end]));
                rest = &rest[end..];
            } else {
                break;
            }
        }
        ids
    }

    pub fn header(&self, name: &str) -> Option<&str> {
        self.0.header(name)
    }

    /// Assert the status, showing the body when it doesn't match.
    pub fn assert_status(&self, expected: StatusCode) -> &Self {
        self.0.assert_status(expected);
        self
    }

    pub fn assert_ok(&self) -> &Self {
        self.assert_status(StatusCode::OK)
    }

    /// Assert the HTML contains `fragment`, printing the document when
    /// it doesn't — a page test's failure is unreadable without it.
    pub fn assert_contains(&self, fragment: &str) -> &Self {
        assert!(
            self.contains(fragment),
            "expected the page to contain {fragment:?}, got:\n{}",
            self.0.body
        );
        self
    }
}

/// A boosted-navigation response.
pub struct Fragment(pub TestResponse);

impl Fragment {
    pub fn status(&self) -> StatusCode {
        self.0.status
    }

    /// The parsed payload — `html`, `title`, `meta`, `scripts`,
    /// `styles`, `maxAge`.
    pub fn payload(&self) -> serde_json::Value {
        self.0.value()
    }

    /// The outlet contents. This is the assertion that matters: a
    /// fragment carrying a whole document would mean the shell gets
    /// nested inside itself on every soft navigation.
    pub fn html(&self) -> String {
        self.payload()["html"]
            .as_str()
            .unwrap_or_default()
            .to_string()
    }

    pub fn title(&self) -> Option<String> {
        self.payload()["title"].as_str().map(str::to_string)
    }

    /// Island modules the client must import before hydrating.
    pub fn scripts(&self) -> Vec<String> {
        self.payload()["scripts"]
            .as_array()
            .map(|items| {
                items
                    .iter()
                    .filter_map(|s| s.as_str().map(str::to_string))
                    .collect()
            })
            .unwrap_or_default()
    }

    pub fn assert_ok(&self) -> &Self {
        self.0.assert_ok();
        self
    }
}

/// The escaping the island helper applies to attribute values.
fn html_escape(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

fn html_unescape(value: &str) -> String {
    value
        .replace("&quot;", "\"")
        .replace("&gt;", ">")
        .replace("&lt;", "<")
        .replace("&amp;", "&")
}