use axum::Router;
use axum::body::Body;
use axum::http::{HeaderMap, Request, StatusCode, header};
use http_body_util::BodyExt;
use tower::ServiceExt;
use zeyn::prelude::*;
use zeyn::{Identity, MemoryViewStore};
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
#[screen(route = "/home", public)]
pub fn home() -> Screen {
Screen::new("Home")
}
fn ocean() -> Theme {
Theme::named("ocean", [("--z-brand", "#0b5394"), ("--z-bg", "#eef4f8")]).label("Ocean")
}
fn build(app: App) -> Router {
app.feature(Feature::new("h").screen(home)).router().unwrap()
}
async fn get(router: &Router, path: &str, cookie: &str) -> (StatusCode, HeaderMap, String) {
let req = Request::get(path).header(header::COOKIE, cookie).header("x-user", "ada").body(Body::empty()).unwrap();
let res = router.clone().oneshot(req).await.unwrap();
let (status, headers) = (res.status(), res.headers().clone());
(status, headers, String::from_utf8(res.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap())
}
async fn choose(router: &Router, theme: &str) -> (StatusCode, HeaderMap) {
let req = Request::post("/_zeyn/theme")
.header(header::CONTENT_TYPE, "application/json")
.header(header::COOKIE, format!("zeyn_csrf={TOKEN}"))
.header("x-zeyn-csrf", TOKEN)
.header("x-user", "ada")
.body(Body::from(serde_json::json!({ "theme": theme }).to_string()))
.unwrap();
let res = router.clone().oneshot(req).await.unwrap();
(res.status(), res.headers().clone())
}
fn html_tag(page: &str) -> &str {
let start = page.find("<html").unwrap();
&page[start..start + page[start..].find('>').unwrap() + 1]
}
#[tokio::test]
async fn every_app_gets_a_picker_with_system_light_and_dark_and_follows_the_device_by_default() {
let r = build(App::new("T"));
let (_, _, page) = get(&r, "/home", "").await;
assert_eq!(html_tag(&page), r#"<html lang="en">"#);
assert!(page.contains(r#"<meta name="color-scheme" content="light dark">"#));
assert!(page.contains(r#"<span id="z-theme-label">Theme</span><div class="z-select" data-text-none=""><select id="z-theme" data-zeyn-theme-picker>"#));
assert!(page.contains(r#"<option value="system" data-scheme="" selected>System</option>"#));
assert!(page.contains(r#"<option value="light" data-scheme="light">Light</option>"#));
assert!(page.contains(r#"<option value="dark" data-scheme="dark">Dark</option>"#));
assert!(!page.contains("/_zeyn/theme.css"), "no stylesheet request when no theme changes a token");
}
#[tokio::test]
async fn the_cookie_is_rendered_on_html_so_the_first_paint_is_right() {
let r = build(App::new("T"));
let (_, _, page) = get(&r, "/home", "zeyn_theme=dark").await;
assert_eq!(html_tag(&page), r#"<html lang="en" data-theme="dark">"#);
assert!(page.contains(r#"<meta name="color-scheme" content="dark">"#));
assert!(page.contains(r#"<option value="dark" data-scheme="dark" selected>Dark</option>"#));
assert!(!page.contains("<script>"), "no inline script");
let (_, _, odd) = get(&r, "/home", r#"zeyn_theme="><script>"#).await;
assert_eq!(html_tag(&odd), r#"<html lang="en">"#);
}
#[tokio::test]
async fn app_themes_are_served_as_one_stylesheet_and_picked_by_name() {
let r = build(App::new("T").theme(Theme::new().radius(6)).themes([
Theme::light(),
Theme::dark().brand("#2a6f97"),
ocean(),
]));
let (_, h, css) = get(&r, "/_zeyn/theme.css", "").await;
assert_eq!(h.get(header::CONTENT_TYPE).unwrap(), "text/css; charset=utf-8");
assert!(css.starts_with(":root{--z-radius:6px;}\n"));
assert!(css.contains("@media (prefers-color-scheme: dark){:root:not([data-theme]){--z-brand:#2a6f97;}}"));
assert!(css.contains(r#":root[data-theme="dark"]:not([data-zeyn-theme]){--z-brand:#2a6f97;}"#));
assert!(css.contains(r#":root[data-zeyn-theme="ocean"]{--z-brand:#0b5394;--z-bg:#eef4f8;}"#));
let (_, _, page) = get(&r, "/home", "zeyn_theme=ocean").await;
assert_eq!(html_tag(&page), r#"<html lang="en" data-theme="light" data-zeyn-theme="ocean">"#);
assert!(page.contains(r#"<option value="ocean" data-scheme="light" data-custom selected>Ocean</option>"#));
let link = page.find(r#"<link rel="stylesheet" href="/_zeyn/theme.css">"#).unwrap();
assert!(link > page.find("/_zeyn/components.css").unwrap(), "theme tokens load after every kit stylesheet");
}
#[tokio::test]
async fn choosing_a_theme_sets_the_cookie_and_rejects_unknown_themes_and_missing_csrf() {
let r = build(App::new("T").themes([Theme::light(), Theme::dark(), ocean()]));
let (status, h) = choose(&r, "ocean").await;
assert_eq!(status, StatusCode::NO_CONTENT);
assert_eq!(h.get(header::SET_COOKIE).unwrap(), "zeyn_theme=ocean; Path=/; Max-Age=31536000; SameSite=Lax");
assert_eq!(choose(&r, "system").await.0, StatusCode::NO_CONTENT);
assert_eq!(choose(&r, "neon").await.0, StatusCode::NOT_FOUND);
let req = Request::post("/_zeyn/theme")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"theme":"dark"}"#))
.unwrap();
assert_eq!(r.clone().oneshot(req).await.unwrap().status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn a_signed_in_users_choice_is_saved_in_the_view_store_and_follows_them_to_a_new_device() {
let r = build(
App::new("T")
.auth(|h: &HeaderMap| Some(Identity::user(h.get("x-user")?.to_str().ok()?)))
.datagrid_views(MemoryViewStore::default()),
);
assert_eq!(choose(&r, "dark").await.0, StatusCode::NO_CONTENT);
let (_, h, page) = get(&r, "/home", "").await;
assert_eq!(html_tag(&page), r#"<html lang="en" data-theme="dark">"#);
let cookies: Vec<_> = h.get_all(header::SET_COOKIE).iter().map(|v| v.to_str().unwrap().to_string()).collect();
assert!(cookies.iter().any(|c| c.starts_with("zeyn_theme=dark;")));
let (_, _, light) = get(&r, "/home", "zeyn_theme=light").await;
assert_eq!(html_tag(&light), r#"<html lang="en" data-theme="light">"#, "the cookie of this device wins");
}
#[tokio::test]
async fn a_hidden_picker_or_a_single_theme_locks_the_app() {
let hidden = build(App::new("T").theme_picker(false).default_theme("dark"));
let (_, _, page) = get(&hidden, "/home", "zeyn_theme=light").await;
assert_eq!(html_tag(&page), r#"<html lang="en" data-theme="dark">"#);
assert!(!page.contains("data-zeyn-theme-picker"));
assert_eq!(choose(&hidden, "light").await.0, StatusCode::NOT_FOUND);
let corporate = build(App::new("T").themes([ocean()]));
let (_, _, page) = get(&corporate, "/home", "zeyn_theme=dark").await;
assert_eq!(html_tag(&page), r#"<html lang="en" data-theme="light" data-zeyn-theme="ocean">"#);
assert!(!page.contains("data-zeyn-theme-picker"));
let dark = build(App::new("T").theme(Theme::dark()));
let (_, _, page) = get(&dark, "/home", "zeyn_theme=light").await;
assert_eq!(html_tag(&page), r#"<html lang="en" data-theme="dark">"#);
assert!(!page.contains("data-zeyn-theme-picker"));
}
#[test]
fn the_built_in_themes_meet_wcag_aa_contrast() {
assert_eq!(App::new("T").themes([Theme::light(), Theme::dark()]).check(), Ok(()));
}
#[test]
fn bad_tokens_names_defaults_and_low_contrast_are_reported_by_check() {
let problems = App::new("T")
.themes([
Theme::light(),
Theme::named("Bad Name", [("--z-brand", "#123456")]),
Theme::named(
"evil",
[("--z-bg", "red;}body{display:none"), ("--z-bg", "url(//x)"), ("--z-made-up", "#fff")],
),
Theme::named("faint", [("--z-text", "#9aa0a6")]),
Theme::named("night", [("--z-surface", "#000000")]).dark_base(),
])
.default_theme("neon")
.check()
.unwrap_err();
let all = problems.join("\n");
assert!(all.contains("theme name `Bad Name` is not allowed"), "{all}");
assert!(all.contains("theme `evil`: the value of `--z-bg` is not allowed"), "{all}");
assert!(all.contains("theme `evil`: `--z-made-up` is not a theme token"), "{all}");
assert!(all.contains("theme `faint`: `--z-text` on `--z-surface` has contrast 2.6"), "{all}");
assert!(all.contains("WCAG 2.2 AA needs 4.5:1"), "{all}");
assert!(!all.contains("theme `night`"), "dark text tokens on a black surface still pass: {all}");
assert!(all.contains("default theme `neon` is not one of the offered themes"), "{all}");
}