sova-devtools 0.1.7

In-app DevTools bar for Sova (HTML inject, SSE timeline, request snapshots)
Documentation
//! Integration: inject HTML only; JSON untouched.

use sova_core::{Html, Json, Response, ResponseAssert, TestClient};
use sova_devtools::DevTools;

#[tokio::test]
async fn injects_html_skips_json() {
    let mut app = sova_core::App::new();
    app.use_middleware(sova_core::request_id());
    app.install(DevTools::new().enabled(true));
    app.get("/h", || async { Html("<html><body>hi</body></html>") });
    app.get("/j", || async { Json(serde_json::json!({ "ok": true })) });

    let client = TestClient::new(app).expect("client");
    let html = client.get("/h").await;
    html.assert_status(200);
    let cc = html
        .headers()
        .get("cache-control")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");
    assert!(
        cc.contains("no-store"),
        "html should disable bfcache, got {cc:?}"
    );
    let body = String::from_utf8_lossy(html.body_bytes().expect("body"));
    assert!(body.contains("sova-devtools"), "{body}");
    assert!(body.contains("<!-- sova_devtools -->"), "{body}");
    assert!(body.contains("bridge.js"), "{body}");
    assert!(!body.contains("app.js"), "{body}");

    let json = client.get("/j").await;
    json.assert_status(200);
    let json_cc = json.headers().get("cache-control");
    assert!(
        json_cc.is_none(),
        "json must not get DevTools cache headers"
    );
    let raw = String::from_utf8_lossy(json.body_bytes().expect("body"));
    assert!(!raw.contains("sova-devtools"), "{raw}");
    assert!(
        raw.contains("\"ok\":true") || raw.contains("\"ok\": true"),
        "{raw}"
    );
}

#[tokio::test]
async fn requests_api_lists_snapshots() {
    let mut app = sova_core::App::new();
    app.install(DevTools::new().enabled(true));
    app.get("/", || async { Html("<html><body>x</body></html>") });
    let client = TestClient::new(app).expect("client");
    let _ = client.get("/").await;
    let list = client.get("/_devtools/requests").await;
    list.assert_status(200);
}

#[tokio::test]
async fn snapshot_has_route_and_encoding() {
    let mut app = sova_core::App::new();
    app.use_middleware(sova_core::request_id());
    app.install(DevTools::new().enabled(true));
    app.get("/page", || async {
        Response::html("<html><body>ok</body></html>").header("content-encoding", "gzip")
    });
    let client = TestClient::new(app).expect("client");
    let page = client.get("/page").await;
    page.assert_status(200);

    let list = client.get("/_devtools/requests").await;
    list.assert_status(200);
    let arr: serde_json::Value =
        serde_json::from_slice(list.body_bytes().expect("body")).expect("json");
    let id = arr
        .as_array()
        .and_then(|a| a.first())
        .and_then(|m| m.get("id"))
        .and_then(|v| v.as_str())
        .expect("snap id")
        .to_string();

    let snap = client.get(&format!("/_devtools/requests/{id}")).await;
    snap.assert_status(200);
    let body: serde_json::Value =
        serde_json::from_slice(snap.body_bytes().expect("body")).expect("json");
    assert_eq!(body["path"], "/page");
    assert_eq!(body["encoding"], "gzip");
    assert!(body.get("route").is_some(), "{body}");
    assert!(body.get("cache").is_some(), "{body}");
}

#[tokio::test]
async fn store_tracing_fills_cache_lines() {
    let _ = sova_core::ensure_tracing();
    let mut app = sova_core::App::new();
    app.use_middleware(sova_core::request_id());
    app.install(DevTools::new().enabled(true));
    app.get("/cache-demo", || async {
        tracing::debug!(
            target: "sova.store",
            op = "get",
            backend = "cache",
            key = "demo:key",
            hit = true,
            duration_ms = 1.5,
            "sova.store"
        );
        Html("<html><body>c</body></html>")
    });
    let client = TestClient::new(app).expect("client");
    let _ = client.get("/cache-demo").await;

    let list = client.get("/_devtools/requests").await;
    let arr: serde_json::Value =
        serde_json::from_slice(list.body_bytes().expect("body")).expect("json");
    let id = arr
        .as_array()
        .and_then(|a| a.first())
        .and_then(|m| m.get("id"))
        .and_then(|v| v.as_str())
        .expect("id")
        .to_string();
    let snap = client.get(&format!("/_devtools/requests/{id}")).await;
    let body: serde_json::Value =
        serde_json::from_slice(snap.body_bytes().expect("body")).expect("json");
    let cache = body["cache"].as_array().expect("cache arr");
    assert!(
        cache.iter().any(|c| c["op"] == "get" && c["key"] == "demo:key"),
        "{body}"
    );
}

#[tokio::test]
async fn tasks_tracing_fills_jobs() {
    let _ = sova_core::ensure_tracing();
    let mut app = sova_core::App::new();
    app.use_middleware(sova_core::request_id());
    app.install(DevTools::new().enabled(true));
    app.get("/job-demo", || async {
        tracing::debug!(
            target: "sova.tasks",
            name = "ping",
            queue = "default",
            id = "t_test",
            status = "enqueued",
            duration_ms = 0.5,
            "sova.tasks enqueue"
        );
        Html("<html><body>j</body></html>")
    });
    let client = TestClient::new(app).expect("client");
    let _ = client.get("/job-demo").await;

    let list = client.get("/_devtools/requests").await;
    let arr: serde_json::Value =
        serde_json::from_slice(list.body_bytes().expect("body")).expect("json");
    let id = arr
        .as_array()
        .and_then(|a| a.first())
        .and_then(|m| m.get("id"))
        .and_then(|v| v.as_str())
        .expect("id")
        .to_string();
    let snap = client.get(&format!("/_devtools/requests/{id}")).await;
    let body: serde_json::Value =
        serde_json::from_slice(snap.body_bytes().expect("body")).expect("json");
    let jobs = body["jobs"].as_array().expect("jobs");
    assert!(
        jobs.iter().any(|j| j["name"] == "ping" && j["status"] == "enqueued"),
        "{body}"
    );
}