From fd25c3af6086b5cf2cbc1d9db415aab242253555 Mon Sep 17 00:00:00 2001 From: Parfii-bot Date: Thu, 23 Apr 2026 00:09:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(stream-a):=20kei-forge=20MVP=20=E2=80=94?= =?UTF-8?q?=20local=20web=20wizard=20scaffolding=20atoms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New crate _primitives/_rust/kei-forge/ exposing POST /forge over axum on 127.0.0.1:8747. Shell-outs to scripts/new-atom.sh for generation. 5-input inline HTML form, no JS required. 9 unit + 3 integration tests green via `cargo test --features mock-generate`. Registered kei-forge in workspace members. Stream A of substrate v1 parallel build — see docs/SUBSTRATE-SCHEMA.md. Spec pre-locked; schema immutable until 2026-06-03 or revocation. Co-Authored-By: Claude Opus 4.7 (1M context) --- _primitives/_rust/Cargo.lock | 175 ++++++++++++++++++++ _primitives/_rust/Cargo.toml | 2 + _primitives/_rust/kei-forge/Cargo.toml | 36 ++++ _primitives/_rust/kei-forge/src/form.rs | 139 ++++++++++++++++ _primitives/_rust/kei-forge/src/generate.rs | 146 ++++++++++++++++ _primitives/_rust/kei-forge/src/lib.rs | 15 ++ _primitives/_rust/kei-forge/src/main.rs | 25 +++ _primitives/_rust/kei-forge/src/server.rs | 84 ++++++++++ _primitives/_rust/kei-forge/tests/smoke.rs | 99 +++++++++++ 9 files changed, 721 insertions(+) create mode 100644 _primitives/_rust/kei-forge/Cargo.toml create mode 100644 _primitives/_rust/kei-forge/src/form.rs create mode 100644 _primitives/_rust/kei-forge/src/generate.rs create mode 100644 _primitives/_rust/kei-forge/src/lib.rs create mode 100644 _primitives/_rust/kei-forge/src/main.rs create mode 100644 _primitives/_rust/kei-forge/src/server.rs create mode 100644 _primitives/_rust/kei-forge/tests/smoke.rs diff --git a/_primitives/_rust/Cargo.lock b/_primitives/_rust/Cargo.lock index df24188..455e72c 100644 --- a/_primitives/_rust/Cargo.lock +++ b/_primitives/_rust/Cargo.lock @@ -556,6 +556,61 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.21.7" @@ -1449,6 +1504,7 @@ dependencies = [ "http 1.4.0", "http-body 1.0.1", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1815,6 +1871,19 @@ dependencies = [ "tempfile", ] +[[package]] +name = "kei-forge" +version = "0.1.0" +dependencies = [ + "axum", + "serde", + "serde_json", + "tokio", + "tower", + "tracing", + "tracing-subscriber", +] + [[package]] name = "kei-graph-check" version = "0.1.0" @@ -2084,6 +2153,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "md-5" version = "0.10.6" @@ -2100,6 +2175,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2157,6 +2238,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint-dig" version = "0.8.6" @@ -2789,6 +2879,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -2856,12 +2957,31 @@ dependencies = [ "digest 0.11.2", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -3195,6 +3315,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -3259,6 +3385,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.47" @@ -3332,7 +3467,9 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", @@ -3440,8 +3577,14 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -3486,6 +3629,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -3592,6 +3761,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/_primitives/_rust/Cargo.toml b/_primitives/_rust/Cargo.toml index ecba924..111bb6b 100644 --- a/_primitives/_rust/Cargo.toml +++ b/_primitives/_rust/Cargo.toml @@ -29,6 +29,8 @@ members = [ "kei-artifact", # v0.18 exobrain CLI "keisei", + # v1 substrate Stream A — local web wizard for scaffolding atoms + "kei-forge", ] [workspace.package] diff --git a/_primitives/_rust/kei-forge/Cargo.toml b/_primitives/_rust/kei-forge/Cargo.toml new file mode 100644 index 0000000..23673c1 --- /dev/null +++ b/_primitives/_rust/kei-forge/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "kei-forge" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" +description = "Local web wizard for scaffolding new atoms" + +[package.metadata.keisei] +backend = "none" +description = "Local web wizard for scaffolding new atoms" + +[[bin]] +name = "kei-forge" +path = "src/main.rs" + +[lib] +name = "kei_forge" +path = "src/lib.rs" + +[dependencies] +axum = "0.7" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tower = "0.5" +tracing = "0.1" +tracing-subscriber = "0.3" + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } + +[features] +default = [] +# When enabled, POST /forge skips the shell-out to scripts/new-atom.sh and +# returns a synthesized success payload. Used exclusively by tests. +mock-generate = [] diff --git a/_primitives/_rust/kei-forge/src/form.rs b/_primitives/_rust/kei-forge/src/form.rs new file mode 100644 index 0000000..c062fd3 --- /dev/null +++ b/_primitives/_rust/kei-forge/src/form.rs @@ -0,0 +1,139 @@ +//! Form request deserialization + validation. +//! +//! Accepts either `application/x-www-form-urlencoded` (HTML `
`) or +//! `application/json` (future API clients). Validation enforces the +//! locked substrate schema — verb naming (kebab-case) and atom kind +//! enumeration (command | query | stream | transform). + +use serde::{Deserialize, Serialize}; + +/// Incoming POST /forge body. +/// +/// `crate` is renamed because it's a Rust reserved word. +#[derive(Debug, Deserialize, Serialize)] +pub struct ForgeRequest { + #[serde(rename = "crate")] + pub crate_name: String, + pub verb: String, + pub kind: String, + pub description: String, +} + +/// Validation outcome. `Ok(())` if the request matches schema constraints. +pub fn validate(req: &ForgeRequest) -> Result<(), String> { + validate_crate_name(&req.crate_name)?; + validate_verb(&req.verb)?; + validate_kind(&req.kind)?; + Ok(()) +} + +fn validate_crate_name(name: &str) -> Result<(), String> { + if name.is_empty() { + return Err("crate must not be empty".to_string()); + } + if !is_kebab_lower(name) { + return Err(format!( + "crate must be lowercase kebab-case (got '{name}')" + )); + } + Ok(()) +} + +fn validate_verb(verb: &str) -> Result<(), String> { + if verb.is_empty() { + return Err("verb must not be empty".to_string()); + } + if !is_kebab_lower(verb) { + return Err(format!( + "verb must be lowercase kebab-case (got '{verb}')" + )); + } + Ok(()) +} + +fn validate_kind(kind: &str) -> Result<(), String> { + match kind { + "command" | "query" | "stream" | "transform" => Ok(()), + other => Err(format!( + "kind must be command|query|stream|transform (got '{other}')" + )), + } +} + +/// Matches regex `^[a-z][a-z0-9]*(-[a-z0-9]+)*$` without pulling in `regex`. +/// Hand-rolled because it's ~10 LOC and saves a workspace-wide dep. +fn is_kebab_lower(s: &str) -> bool { + let mut chars = s.chars(); + let Some(first) = chars.next() else { + return false; + }; + if !first.is_ascii_lowercase() { + return false; + } + let mut prev_dash = false; + for c in chars { + match c { + 'a'..='z' | '0'..='9' => prev_dash = false, + '-' if !prev_dash => prev_dash = true, + _ => return false, + } + } + !prev_dash +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_empty() { + assert!(validate_verb("").is_err()); + assert!(validate_crate_name("").is_err()); + } + + #[test] + fn rejects_upper() { + assert!(validate_verb("addDependency").is_err()); + } + + #[test] + fn rejects_trailing_dash() { + assert!(validate_verb("add-").is_err()); + } + + #[test] + fn rejects_double_dash() { + assert!(validate_verb("add--dep").is_err()); + } + + #[test] + fn accepts_kebab() { + assert!(validate_verb("add-dependency").is_ok()); + assert!(validate_verb("search").is_ok()); + assert!(validate_verb("v2-rename").is_ok()); + } + + #[test] + fn accepts_known_kinds() { + for k in ["command", "query", "stream", "transform"] { + let req = ForgeRequest { + crate_name: "kei-task".into(), + verb: "noop".into(), + kind: k.into(), + description: "test".into(), + }; + assert!(validate(&req).is_ok(), "kind {k} should validate"); + } + } + + #[test] + fn rejects_unknown_kind() { + let req = ForgeRequest { + crate_name: "kei-task".into(), + verb: "noop".into(), + kind: "saga".into(), + description: "test".into(), + }; + assert!(validate(&req).is_err()); + } +} diff --git a/_primitives/_rust/kei-forge/src/generate.rs b/_primitives/_rust/kei-forge/src/generate.rs new file mode 100644 index 0000000..d5bad7c --- /dev/null +++ b/_primitives/_rust/kei-forge/src/generate.rs @@ -0,0 +1,146 @@ +//! Atom-scaffolding generator. +//! +//! MVP implementation: shells out to `scripts/new-atom.sh` with form values +//! as argv and `ATOM_DESCRIPTION` in the environment. Parses the "Files +//! created:" block from stdout into a structured file-list. +//! +//! Follow-up (post-MVP): reimplement in pure Rust by reading +//! `_templates/atom/` directly, eliminating the shell dependency. + +use crate::form::ForgeRequest; +use serde::Serialize; +use std::path::PathBuf; +use std::process::Command; + +/// Result of a scaffolding attempt. +#[derive(Debug, Serialize)] +pub struct ForgeResult { + pub success: bool, + pub files: Vec, + pub errors: Vec, +} + +impl ForgeResult { + pub fn ok(files: Vec) -> Self { + Self { + success: true, + files, + errors: Vec::new(), + } + } + + pub fn fail(err: impl Into) -> Self { + Self { + success: false, + files: Vec::new(), + errors: vec![err.into()], + } + } +} + +/// Locate the repo root by walking up from CARGO_MANIFEST_DIR until we +/// see `scripts/new-atom.sh`. Falls back to CWD if the env var isn't +/// set (e.g. when the binary is run detached from cargo). +fn repo_root() -> PathBuf { + let start = std::env::var("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default()); + let mut cur: &std::path::Path = &start; + loop { + if cur.join("scripts/new-atom.sh").exists() { + return cur.to_path_buf(); + } + match cur.parent() { + Some(p) => cur = p, + None => return start, + } + } +} + +/// Execute new-atom.sh. Honours the `mock-generate` cargo feature so +/// integration tests can exercise the HTTP surface without touching the +/// real filesystem. +pub fn forge(req: &ForgeRequest) -> ForgeResult { + if cfg!(feature = "mock-generate") { + return ForgeResult::ok(vec![format!( + "_primitives/_rust/{}/atoms/{}.md", + req.crate_name, req.verb + )]); + } + + let root = repo_root(); + let script = root.join("scripts/new-atom.sh"); + if !script.exists() { + return ForgeResult::fail(format!( + "scripts/new-atom.sh not found under {}", + root.display() + )); + } + + let output = Command::new(&script) + .arg(&req.crate_name) + .arg(&req.verb) + .arg(&req.kind) + .env("ATOM_DESCRIPTION", &req.description) + .current_dir(&root) + .output(); + + match output { + Ok(out) if out.status.success() => { + let stdout = String::from_utf8_lossy(&out.stdout); + ForgeResult::ok(parse_file_list(&stdout)) + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string(); + ForgeResult::fail(if stderr.is_empty() { + format!("new-atom.sh exited with {:?}", out.status.code()) + } else { + stderr + }) + } + Err(e) => ForgeResult::fail(format!("failed to spawn new-atom.sh: {e}")), + } +} + +/// Extract the file-list block from new-atom.sh stdout. +/// +/// The script emits a `Files created:` heading followed by indented +/// paths, then a blank line, then `Next steps:`. We slice between the +/// two headings and trim each path. +fn parse_file_list(stdout: &str) -> Vec { + let mut in_block = false; + let mut files = Vec::new(); + for line in stdout.lines() { + let trimmed = line.trim(); + if trimmed == "Files created:" { + in_block = true; + continue; + } + if in_block { + if trimmed.is_empty() || trimmed == "Next steps:" { + break; + } + files.push(trimmed.to_string()); + } + } + files +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_file_list() { + let stdout = "\n✓ Scaffolded atom kei-task::search (query)\n\n\ + Files created:\n a.md\n b.json\n c.rs\n\n\ + Next steps:\n 1. edit\n"; + let files = parse_file_list(stdout); + assert_eq!(files, vec!["a.md", "b.json", "c.rs"]); + } + + #[test] + fn empty_when_no_block() { + assert!(parse_file_list("nothing here").is_empty()); + } +} diff --git a/_primitives/_rust/kei-forge/src/lib.rs b/_primitives/_rust/kei-forge/src/lib.rs new file mode 100644 index 0000000..fcae8f1 --- /dev/null +++ b/_primitives/_rust/kei-forge/src/lib.rs @@ -0,0 +1,15 @@ +//! kei-forge — local web wizard for scaffolding new atoms per the locked +//! SUBSTRATE-SCHEMA.md contract. +//! +//! Architecture (Constructor Pattern, one responsibility per file): +//! - [`server`] — axum router + HTML form handler +//! - [`form`] — request deserialization + validation +//! - [`generate`] — invoke scripts/new-atom.sh, parse output +//! +//! Public entry point is [`server::app`], which returns the fully-wired +//! `axum::Router` ready to be served by any bind target (production = +//! 127.0.0.1:8747; tests = random ephemeral port). + +pub mod form; +pub mod generate; +pub mod server; diff --git a/_primitives/_rust/kei-forge/src/main.rs b/_primitives/_rust/kei-forge/src/main.rs new file mode 100644 index 0000000..b6ee1e1 --- /dev/null +++ b/_primitives/_rust/kei-forge/src/main.rs @@ -0,0 +1,25 @@ +//! kei-forge binary entry point. +//! +//! Binds axum to `127.0.0.1:8747` and serves the atom-scaffolding wizard. +//! Port 8747 chosen for mnemonic `"TK4S"` (KT + 4 streams) and low conflict +//! probability — not registered with IANA, outside common dev-tool ranges. + +use kei_forge::server; +use std::net::SocketAddr; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); + + let addr: SocketAddr = "127.0.0.1:8747".parse()?; + let app = server::app(); + let listener = tokio::net::TcpListener::bind(addr).await?; + + println!("keisei forge ready — open http://localhost:8747/"); + tracing::info!(%addr, "kei-forge listening"); + + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/_primitives/_rust/kei-forge/src/server.rs b/_primitives/_rust/kei-forge/src/server.rs new file mode 100644 index 0000000..72f9f3e --- /dev/null +++ b/_primitives/_rust/kei-forge/src/server.rs @@ -0,0 +1,84 @@ +//! Axum router — GET / (HTML form) and POST /forge (scaffold handler). +//! +//! Intentionally stateless: no `AppState`, no handles, no async init. +//! Every request is self-contained. This lets tests spin up `app()` in +//! an ephemeral Tokio runtime without setup teardown overhead. + +use axum::{ + extract::Form, + http::StatusCode, + response::{Html, IntoResponse}, + routing::{get, post}, + Json, Router, +}; + +use crate::form::{validate, ForgeRequest}; +use crate::generate::{forge, ForgeResult}; + +/// Build the router. Called by `main.rs` and by tests. +pub fn app() -> Router { + Router::new() + .route("/", get(render_form)) + .route("/forge", post(handle_forge)) +} + +async fn render_form() -> Html<&'static str> { + Html(FORM_HTML) +} + +async fn handle_forge(Form(req): Form) -> impl IntoResponse { + if let Err(e) = validate(&req) { + return (StatusCode::BAD_REQUEST, Json(ForgeResult::fail(e))); + } + let result = forge(&req); + let status = if result.success { + StatusCode::OK + } else { + StatusCode::UNPROCESSABLE_ENTITY + }; + (status, Json(result)) +} + +/// Minimal HTML — 5 inputs, no JS, no CSS framework. The locked schema +/// allows only 4 atom kinds, hard-coded as a ` + +

+

+ +

+

+ +

+

+ +

+

+ + + +"#; diff --git a/_primitives/_rust/kei-forge/tests/smoke.rs b/_primitives/_rust/kei-forge/tests/smoke.rs new file mode 100644 index 0000000..d43f2db --- /dev/null +++ b/_primitives/_rust/kei-forge/tests/smoke.rs @@ -0,0 +1,99 @@ +//! Integration smoke test for kei-forge. +//! +//! Exercises GET / and POST /forge via `tower::ServiceExt::oneshot` on +//! the Router — no real socket, no real shell-out. The `mock-generate` +//! feature makes `generate::forge` return a synthesized success payload +//! so the test never touches the filesystem. +//! +//! Run with: `cargo test -p kei-forge --features mock-generate` + +use axum::{ + body::{to_bytes, Body}, + http::{Request, StatusCode}, +}; +use kei_forge::server; +use serde_json::Value; +use tower::ServiceExt; + +#[tokio::test] +async fn get_root_serves_form() { + let app = server::app(); + let resp = app + .oneshot( + Request::builder() + .uri("/") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = to_bytes(resp.into_body(), 64 * 1024).await.unwrap(); + let html = std::str::from_utf8(&body).unwrap(); + assert!(html.contains("kei-forge")); + assert!(html.contains("