//! An app made with Create, through `wai` built with the feature `app`: //! //! - `wai create --from app.oui` writes an ordinary WAI project: the app's files and its //! runtime under `vendor/`, the project file laid out as every template's is and naming the //! program and the room `wai dev` runs for it, and the licence; a template's project and an //! exported one share their shape; //! - `wai app check` passes on it, and refuses a project whose room is not its app's, whose //! runtime changed, whose program has a refused line, or that names no room; //! - `wai create --from` refuses a program with errors, one with no room, and a directory with //! anything in it; //! - `wai mcp --toolset app`: the tools are Create's operations (`app_guide`, `app_check`, //! `app_stream`, `app_export`); a program streams through the bridge to a page connected with //! the pairing code and to no page without it; an export is written inside the project //! directory alone; `--read-only` runs no bridge and offers no tool that streams or writes. #![cfg(feature = "app")] use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use std::io::{BufRead, BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, ChildStdout, Command, Output, Stdio}; const SHOW: &str = "root = App([show], \"Friday night\")\nshow = Show([player, chat, poll, people], \"Live from the studio\", 2)\nplayer = Player()\nchat = Chat(\"Talk\")\npoll = Poll(\"Which song next?\", [\"The first one\", \"The quiet one\"])\npeople = Participants()\n"; fn scratch(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("wai-cli-app-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d.canonicalize().unwrap() } fn hex(b: &[u8]) -> String { b.iter().map(|x| format!("{x:02x}")).collect() } /// A Create runtime as `web/runtime.mjs` lays one out: a few files and their listing. fn runtime(d: &Path) -> PathBuf { let rt = d.join("runtime"); let files = [("create/wai-app-app.js", "export {};\n"), ("create/pkg/wai_app_bg.wasm", "\0asm"), ("room/wai-app-room-client.js", "// the room client\n")]; let mut listed = Vec::new(); for (p, text) in files { let f = rt.join(p); std::fs::create_dir_all(f.parent().unwrap()).unwrap(); std::fs::write(&f, text).unwrap(); listed.push(json!({"path": p, "sha256": hex(&Sha256::digest(text.as_bytes()))})); } std::fs::write(rt.join("create/wai-app-runtime.json"), json!({"files": listed}).to_string()).unwrap(); rt } fn wai(dir: &Path, args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_wai")).current_dir(dir).args(args).output().expect("run wai") } fn text(o: &Output) -> String { format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) } /// `wai app check` in `dir`: its exit status and its JSON. fn app_check(dir: &Path) -> (i32, Value) { let o = wai(dir, &["app", "check", "--json"]); let v: Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|e| panic!("{e}: {}", text(&o))); assert_eq!((v["wai"].as_str(), v["op"].as_str()), (Some("envelope/1"), Some("app.check")), "{v}"); (o.status.code().unwrap(), v) } fn files_of(dir: &Path) -> Vec { fn walk(root: &Path, p: &Path, out: &mut Vec) { for e in std::fs::read_dir(p).unwrap().flatten() { let q = e.path(); if q.is_dir() { walk(root, &q, out); } else { out.push(q.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/")); } } } let mut out = Vec::new(); walk(dir, dir, &mut out); out.sort(); out } #[test] fn an_export_is_an_ordinary_project_and_its_check_holds() { let d = scratch("export"); let rt = runtime(&d); std::fs::write(d.join("app.oui"), SHOW).unwrap(); let o = wai(&d, &["create", "show", "--from", "app.oui", "--runtime", rt.to_str().unwrap()]); assert!(o.status.success(), "{}", text(&o)); let p = d.join("show"); let mut want: Vec = ["app.oui", "index.html", "README.md", "AGENTS.md", ".gitignore", "vendor.json", "wai.json", "LICENSE"].iter().map(|s| s.to_string()).collect(); want.extend(["vendor/create/wai-app-app.js", "vendor/create/pkg/wai_app_bg.wasm", "vendor/room/wai-app-room-client.js"].iter().map(|s| s.to_string())); want.sort(); assert_eq!(files_of(&p), want); // The project file: the template's layout, naming the program and the show's room. let wj = std::fs::read_to_string(p.join("wai.json")).unwrap(); assert_eq!( wj, "{\n \"title\": \"Friday night\",\n \"summary\": \"A WAI app made with Create: a show, with hosts on camera and an audience that watches and votes.\",\n \"app\": \"app.oui\",\n \"dev\": { \"site\": \".\", \"mode\": \"show\", \"audience\": 2 }\n}\n" ); // A template's project has the same shape: its project file read by the same reader and // laid out the same way, a README and the licence. let o = wai(&d, &["create", "room", "--template", "video-room"]); assert!(o.status.success(), "{}", text(&o)); let t = std::fs::read_to_string(d.join("room/wai.json")).unwrap(); for f in [&wj, &t] { let lines: Vec<&str> = f.lines().collect(); assert!(lines.first() == Some(&"{") && lines.last() == Some(&"}") && lines[1..lines.len() - 1].iter().all(|l| l.starts_with(" \"")), "{f}"); } assert_eq!(std::fs::read(d.join("room/LICENSE")).unwrap(), std::fs::read(p.join("LICENSE")).unwrap()); for r in [p.join("README.md"), d.join("room/README.md")] { assert!(std::fs::read_to_string(&r).unwrap().contains("Apache-2.0"), "{}", r.display()); } // The program in the project is the canonical form of the one given. assert!(std::fs::read_to_string(p.join("app.oui")).unwrap().starts_with("root = App(")); let (code, v) = app_check(&p); assert_eq!(code, 0, "{v}"); assert_eq!(v["data"]["room"]["kind"], "show"); assert!(v["data"]["failures"].as_array().unwrap().is_empty(), "{v}"); let _ = std::fs::remove_dir_all(&d); } #[test] fn the_check_refuses_a_project_that_is_not_whole() { let d = scratch("refused"); let rt = runtime(&d); std::fs::write(d.join("app.oui"), SHOW).unwrap(); assert!(wai(&d, &["create", "good", "--from", "app.oui", "--runtime", rt.to_str().unwrap()]).status.success()); let good = d.join("good"); let cases: [Case; 5] = [ ("whose room is not its app's", |p| edit(&p.join("wai.json"), "\"mode\": \"show\", \"audience\": 2", "\"mode\": \"room\""), "dev.mode is \"room\""), ("whose show has another audience", |p| edit(&p.join("wai.json"), "\"audience\": 2", "\"audience\": 7"), "dev.audience is 7"), ("that names no room", |p| edit(&p.join("wai.json"), ", \"mode\": \"show\", \"audience\": 2", ""), "names no room"), ("with a runtime file changed", |p| std::fs::write(p.join("vendor/create/wai-app-app.js"), "export {}; // changed\n").unwrap(), "vendor/create/wai-app-app.js: its SHA-256"), ("with a line the registry refuses", |p| edit(&p.join("app.oui"), "Participants()", "Hologram()"), "unknown-component"), ]; for (what, mutate, expect) in cases { let b = d.join("broken"); let _ = std::fs::remove_dir_all(&b); let o = Command::new("cp").args(["-R"]).arg(&good).arg(&b).output().unwrap(); assert!(o.status.success()); mutate(&b); let (code, v) = app_check(&b); let failures: String = v["data"]["failures"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect::>().join("\n"); assert!(code == 1 && failures.contains(expect), "a project {what}: {code} {v}"); } let _ = std::fs::remove_dir_all(&d); } /// A project broken one way: what is wrong with it, how to break it, and what the check says. type Case = (&'static str, fn(&Path), &'static str); fn edit(p: &Path, from: &str, to: &str) { let t = std::fs::read_to_string(p).unwrap(); assert_eq!(t.matches(from).count(), 1, "{from} in {}: {t}", p.display()); std::fs::write(p, t.replace(from, to)).unwrap(); } #[test] fn a_watch_room_names_its_file_and_its_check_wants_it() { let d = scratch("watch"); let rt = runtime(&d); std::fs::write(d.join("w.oui"), "root = App([w], \"Film night\")\nw = WatchTogether(\"film.wav\", [Participants()])\n").unwrap(); assert!(wai(&d, &["create", "w", "--from", "w.oui", "--runtime", rt.to_str().unwrap()]).status.success()); let p = d.join("w"); assert!(std::fs::read_to_string(p.join("wai.json")).unwrap().contains("\"dev\": { \"site\": \".\", \"mode\": \"watch\", \"watch\": \"media/film.wav\" }")); assert!(p.join("media/README.md").is_file()); let (code, v) = app_check(&p); assert!(code == 1 && v["data"]["failures"].to_string().contains("media/film.wav is not there"), "{v}"); std::fs::write(p.join("media/film.wav"), b"RIFF").unwrap(); let (code, v) = app_check(&p); assert_eq!(code, 0, "{v}"); let _ = std::fs::remove_dir_all(&d); } #[test] fn what_is_not_exported() { let d = scratch("not"); let rt = runtime(&d); let rt = rt.to_str().unwrap(); std::fs::write(d.join("bad.oui"), "root = App([r])\nr = Room([Poll(\"q\", [\"a\", \"b\"])])\n").unwrap(); std::fs::write(d.join("noroom.oui"), "root = App([t])\nt = Text(\"no room\")\n").unwrap(); std::fs::write(d.join("app.oui"), SHOW).unwrap(); for (prog, why) in [("bad.oui", "error"), ("noroom.oui", "no room")] { let o = wai(&d, &["create", "x", "--from", prog, "--runtime", rt]); assert!(o.status.code() == Some(2) && text(&o).contains(why), "{prog}: {}", text(&o)); assert!(!d.join("x").exists()); } std::fs::create_dir(d.join("full")).unwrap(); std::fs::write(d.join("full/mine.txt"), "mine").unwrap(); let o = wai(&d, &["create", "full", "--from", "app.oui", "--runtime", rt]); assert!(o.status.code() == Some(2) && text(&o).contains("not empty"), "{}", text(&o)); let o = wai(&d, &["create", "y", "--from", "app.oui", "--runtime", d.join("nowhere").to_str().unwrap()]); assert!(o.status.code() == Some(2) && text(&o).contains("runtime"), "{}", text(&o)); let _ = std::fs::remove_dir_all(&d); } // ------------------------------------------------------------------------------- the toolset struct Client { child: Child, stdin: ChildStdin, out: BufReader, next: i64, bridge: Option<(u16, String)>, } impl Client { fn start(project: &Path, extra: &[&str]) -> Client { let mut child = Command::new(env!("CARGO_BIN_EXE_wai")) .args(["mcp", "--project"]) .arg(project) .args(extra) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .unwrap(); let stdin = child.stdin.take().unwrap(); let out = BufReader::new(child.stdout.take().unwrap()); let mut c = Client { child, stdin, out, next: 1, bridge: None }; let init = c.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "cli_app test", "version": "1"}})); assert_eq!(init["result"]["serverInfo"]["name"], "wai", "{init}"); c } /// The bridge's address and pairing code, as the server says them at start. fn read_bridge(&mut self) { let mut err = BufReader::new(self.child.stderr.take().unwrap()); let mut first = String::new(); err.read_line(&mut first).unwrap(); let port: u16 = first.split("http://127.0.0.1:").nth(1).and_then(|s| s.split(',').next()).and_then(|s| s.parse().ok()).expect(&first); let code = first.trim().rsplit(' ').next().unwrap().to_string(); self.bridge = Some((port, code)); } fn request(&mut self, method: &str, params: Value) -> Value { let id = self.next; self.next += 1; writeln!(self.stdin, "{}", json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).unwrap(); self.stdin.flush().unwrap(); let mut l = String::new(); assert!(self.out.read_line(&mut l).unwrap() > 0, "the server closed its output"); serde_json::from_str(&l).unwrap() } fn call(&mut self, name: &str, args: Value) -> Result { let v = self.request("tools/call", json!({"name": name, "arguments": args})); let r = &v["result"]; assert!(r.is_object(), "{name}: {v}"); if r["isError"] == json!(true) { return Err(r["content"][0]["text"].as_str().unwrap_or("").to_string()); } Ok(r["structuredContent"].clone()) } fn names(&mut self) -> Vec { let l = self.request("tools/list", json!({})); l["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect() } } impl Drop for Client { fn drop(&mut self) { let _ = self.child.kill(); let _ = self.child.wait(); } } #[test] fn create_through_the_app_toolset() { let d = scratch("mcp"); let rt = runtime(&d); let mut c = Client::start(&d, &["--toolset", "app"]); c.read_bridge(); let (port, code) = c.bridge.clone().unwrap(); assert_eq!(c.names(), ["app_guide", "app_check", "app_stream", "app_export"]); let g = c.call("app_guide", json!({})).unwrap(); assert!(g["guide"].as_str().unwrap().contains("Poll(") && g["code"] == code.as_str(), "{g}"); let ok = c.call("app_check", json!({"program": SHOW})).unwrap(); assert_eq!((ok["ok"].as_bool(), ok["room"]["kind"].as_str()), (Some(true), Some("show")), "{ok}"); let refused = c.call("app_check", json!({"program": "root = App([w])\nw = WatchTogether(\"f.mp4\", [Chat()])\n"})).unwrap(); assert!(refused["ok"] == false && refused["problems"][0].as_str().unwrap().starts_with("line 2: disallowed-child"), "{refused}"); // No page is connected: the stream says how to connect one. assert!(c.call("app_stream", json!({"program": SHOW})).unwrap_err().contains(&code)); // A page without the code is refused; one with it reads the stream. let get = |path: &str| { let mut s = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap(); write!(s, "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n").unwrap(); s }; let mut resp = String::new(); get("/create/stream?code=wrong").read_to_string(&mut resp).unwrap(); assert!(resp.starts_with("HTTP/1.1 403"), "{resp}"); let mut page = BufReader::new(get(&format!("/create/stream?code={code}"))); let mut head = String::new(); page.read_line(&mut head).unwrap(); assert!(head.starts_with("HTTP/1.1 200"), "{head}"); let mut s = Err(String::new()); for _ in 0..100 { s = c.call("app_stream", json!({"program": SHOW, "chunk": 40, "pace_ms": 0})); if s.is_ok() { break; } std::thread::sleep(std::time::Duration::from_millis(20)); } let s = s.unwrap(); assert_eq!(s["delivered"], 1, "{s}"); let mut seen = String::new(); while !seen.contains("event: end") { let mut l = String::new(); page.read_line(&mut l).unwrap(); seen.push_str(&l); } assert!(seen.contains("event: start") && seen.contains("Friday night"), "{seen}"); // An export, inside the project directory alone. let e = c.call("app_export", json!({"program": SHOW, "dir": "show", "runtime_dir": rt})).unwrap(); assert!(e["files"].as_array().unwrap().iter().any(|f| f == "wai.json"), "{e}"); assert_eq!(app_check(&d.join("show")).0, 0); let outside = std::env::temp_dir().join(format!("wai-cli-app-outside-{}", std::process::id())); let err = c.call("app_export", json!({"program": SHOW, "dir": outside, "runtime_dir": rt})).unwrap_err(); assert!(err.contains("outside the project directory") && !outside.exists(), "{err}"); // Read-only: no bridge, and nothing that streams or writes. let mut ro = Client::start(&d, &["--toolset", "core,app", "--read-only"]); let names = ro.names(); assert!(names.contains(&"app_check".to_string()) && !names.iter().any(|n| n == "app_stream" || n == "app_export"), "{names:?}"); assert!(ro.call("app_guide", json!({})).unwrap()["bridge"].is_null()); let _ = std::fs::remove_dir_all(&d); }