//! Web module tests: bus semantics, token gate, and a full server boot on an //! ephemeral port with a fake (unreachable) node. use crate::config::{Cluster, Config, Node}; use crate::web::events::{Event, build_snapshot}; use crate::web::{AppState, router}; use std::collections::HashMap; use std::net::SocketAddr; pub(crate) async fn call(addr: SocketAddr, path: &str) -> reqwest::Response { reqwest::Client::new() .get(format!("http://{addr}{path}")) .send() .await .unwrap() } /// Config with one node at 127.0.0.1:2222 — connection refused instantly, /// machine-independent (no real SSH, no hardware). pub(crate) fn fake_config() -> Config { Config { ssh_user: "u".into(), ssh_key_path: "/nonexistent/key".into(), clusters: vec![Cluster { name: "fake".into(), master_ib_ip: None, nodes: vec![Node { name: "n0".into(), ssh_ip: "127.0.0.1:2222".into(), ib_ip: None, rank: 0, host_key: None, entity: None, }], extra_entities: vec![], }], nccl: HashMap::new(), hf_cache_dir: None, init_port: 25000, before_serve: HashMap::new(), exec_aliases: HashMap::new(), home_assistant: None, global_mods_dir: None, drop_caches: true, mount_aliases: HashMap::new(), recipe_sources: Vec::new(), docker_image_sources: Vec::new(), cluster_sources: Vec::new(), } } pub(crate) fn app_state(token: Option<&str>) -> AppState { app_state_with(fake_config(), token) } fn app_state_with(config: Config, token: Option<&str>) -> AppState { let (tx, _) = tokio::sync::broadcast::channel(16); AppState { config: std::sync::Arc::new(config), bus: tx, token: token.map(String::from), last: std::sync::Arc::new(std::sync::Mutex::new(None)), jobs: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), log_attaches: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), } } pub(crate) async fn boot() -> (SocketAddr, std::sync::Arc>) { boot_with(fake_config()).await } async fn boot_with(config: Config) -> (SocketAddr, std::sync::Arc>) { let app = app_state_with(config, None); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let handle = tokio::spawn(async move { axum::serve(listener, router(app)).await.unwrap(); }); (addr, std::sync::Arc::new(handle)) } #[tokio::test] async fn api_state_reports_unreachable_node() { let (addr, handle) = boot().await; let body: serde_json::Value = reqwest::get(format!("http://{addr}/api/state")) .await .unwrap() .json() .await .unwrap(); assert_eq!(body["clusters"].as_array().unwrap().len(), 1); assert_eq!(body["clusters"][0]["name"], "fake"); assert_eq!(body["clusters"][0]["nodes"][0]["reachable"], false); assert_eq!(body["clusters"][0]["free"], 0); handle.abort(); } #[tokio::test] async fn token_gate_enforces_when_set() { let app = app_state(Some("s3cret")); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); tokio::spawn(async move { axum::serve(listener, router(app)).await.unwrap(); }); let base = format!("http://{addr}/api/state"); // No token → 401. assert_eq!(reqwest::get(&base).await.unwrap().status(), 401); // Wrong token → 401. assert_eq!( reqwest::get(format!("{base}?token=wrong")) .await .unwrap() .status(), 401 ); // Right token (query form — what SSE clients use) → 200. assert_eq!( reqwest::get(format!("{base}?token=s3cret")) .await .unwrap() .status(), 200 ); } #[tokio::test] async fn bus_delivers_state_events() { let (tx, mut rx) = tokio::sync::broadcast::channel::(8); let snap = crate::web::events::Snapshot { ts: 1, clusters: Vec::new(), jobs: Vec::new(), topo: Default::default(), attaches: Vec::new(), }; tx.send(Event::State { ts: snap.ts, snapshot: snap.clone(), }) .unwrap(); let got = rx.recv().await.unwrap(); match got { Event::State { ts, snapshot } => { assert_eq!(ts, 1); assert!(snapshot.clusters.is_empty()); } Event::Job { .. } => panic!("unexpected job event"), Event::Log { .. } => panic!("unexpected log event"), } } #[tokio::test] async fn snapshot_serializes_with_type_tag() { let ev = Event::State { ts: 7, snapshot: build_snapshot( &fake_config(), &crate::web::probe::Fleet { nodes: std::collections::HashMap::new(), switch_off: std::collections::HashMap::new(), soft_off: std::collections::HashMap::new(), ha: std::collections::HashMap::new(), }, &[], &[], &std::collections::HashMap::new(), ), }; let json = serde_json::to_value(&ev).unwrap(); assert_eq!(json["type"], "state"); assert_eq!(json["ts"], 7); assert!(json["snapshot"].is_object()); } // (P2/P3/P4 API round-trip tests moved to api_tests.rs — 500-line cap) // --- UI (ui.html) ------------------------------------------------------------- // The embedded page is vanilla JS with no build step; these tests run the // real file through node: (1) syntax gate, (2) behavior probe of the pure // snapshot-compare logic (the anti-blink rule), extracted between the // SNAP-LT markers in ui.html. Skipped when node is unavailable. fn extract_script(html: &str) -> String { let start = html.find("").unwrap(); html[start..end].to_string() } #[test] fn ui_js_syntax_and_snapshot_logic() { let html = include_str!("ui.html"); if std::process::Command::new("node") .arg("--version") .output() .is_err() { eprintln!("SKIP ui_js_syntax_and_snapshot_logic: node not available"); return; } let script = extract_script(html); let pid = std::process::id(); let js = std::env::temp_dir().join(format!("sm121-ui-{pid}.js")); std::fs::write(&js, &script).unwrap(); let out = std::process::Command::new("node") .arg("--check") .arg(&js) .output() .unwrap(); assert!( out.status.success(), "ui.html JS must parse: {}", String::from_utf8_lossy(&out.stderr) ); // sameSnapshot: ts must be ignored, real changes must not be equal. let s = script.find("/*SNAP-LT:START*/").unwrap() + "/*SNAP-LT:START*/".len(); let e = script.find("/*SNAP-LT:END*/").unwrap(); let probe = format!( "{fn} const a = {{ ts: 1, clusters: [{{ name: 'c', up: true, free: 1, nodes: [] }}], jobs: [] }}; const b = {{ ts: 99, clusters: [{{ name: 'c', up: true, free: 1, nodes: [] }}], jobs: [] }}; const d = {{ ts: 99, clusters: [{{ name: 'c', up: true, free: 0, nodes: [] }}], jobs: [] }}; if (sameSnapshot(a, b) !== true) throw new Error('ts must be ignored'); if (sameSnapshot(a, d) !== false) throw new Error('changed fleet must differ'); if (sameSnapshot(a, a) !== true) throw new Error('identity'); console.log('ok');", fn = &script[s..e] ); let js2 = std::env::temp_dir().join(format!("sm121-ui-probe-{pid}.js")); std::fs::write(&js2, probe).unwrap(); let out = std::process::Command::new("node") .arg(&js2) .output() .unwrap(); assert!( out.status.success(), "sameSnapshot probe failed: {}", String::from_utf8_lossy(&out.stderr) ); let _ = std::fs::remove_file(&js); let _ = std::fs::remove_file(&js2); } /// serveBody (FLAGS-LT block): the serve flags → API body mapping is the /// trickiest pure logic in the UI — probe it in node like sameSnapshot. #[test] fn ui_js_serve_body_logic() { let html = include_str!("ui.html"); if std::process::Command::new("node") .arg("--version") .output() .is_err() { eprintln!("SKIP ui_js_serve_body_logic: node not available"); return; } let script = extract_script(html); let s = script.find("/*FLAGS-LT:START*/").unwrap() + "/*FLAGS-LT:START*/".len(); let e = script.find("/*FLAGS-LT:END*/").unwrap(); let pid = std::process::id(); let probe = format!( "const state = {{ flags: {{}} }};\n{fn}\n\n\ // all flags set: everything passes through\n\ let b = serveBody('c', 'r', true, {{ node: 'n9', solo: true, force: true, clear: true, keep: true, fit: '2', fitAll: false, mirror: '/m', mirrorAll: true }});\n\ if (b.node !== 'n9' || b.solo !== true || b.force !== true || b.clear !== true || b.keep !== true || b.dry_run !== true) throw new Error('flag passthrough: ' + JSON.stringify(b));\n\ if (b.fit !== '2') throw new Error('fit N must win over bare: ' + b.fit);\n\ if (b.mirror !== '/m') throw new Error('mirror path must win over bare: ' + b.mirror);\n\ // bare fit / bare mirror (empty strings = whole cluster / default path)\n\ b = serveBody('c', 'r', false, {{ fit: '', fitAll: true }});\n\ if (b.fit !== '') throw new Error('bare fit');\n\ b = serveBody('c', 'r', false, {{ mirror: '', mirrorAll: true }});\n\ if (b.mirror !== '') throw new Error('bare mirror');\n\ // no flags: only cluster/recipe/dry_run — server defaults win\n\ b = serveBody('c', 'r', true, {{}});\n\ for (const k of ['node', 'solo', 'force', 'clear', 'keep', 'fit', 'mirror']) {{\n\ if (k in b) throw new Error('absent flag must be omitted: ' + k);\n\ }}\n\ // flagsOf caches per cluster\n\ if (flagsOf('x') !== flagsOf('x')) throw new Error('flagsOf must cache');\n\ console.log('ok');", fn = &script[s..e] ); let js2 = std::env::temp_dir().join(format!("sm121-ui-flags-{pid}.js")); std::fs::write(&js2, &probe).unwrap(); let out = std::process::Command::new("node") .arg(&js2) .output() .unwrap(); assert!( out.status.success(), "serveBody probe failed: {}", String::from_utf8_lossy(&out.stderr) ); let _ = std::fs::remove_file(&js2); } /// Two offline clusters (both nodes on refused loopback ports) — the /// fleet the Playwright UI checks render: chip strip with two chips, /// down-cluster action bar, type-the-name power flow. fn playwright_config() -> Config { let mut cfg = fake_config(); cfg.clusters.push(Cluster { name: "fake2".into(), master_ib_ip: None, nodes: vec![Node { name: "n1".into(), ssh_ip: "127.0.0.1:2223".into(), ib_ip: None, rank: 0, host_key: None, entity: None, }], extra_entities: vec![], }); cfg } /// Click-through UI tests: boots the real server on an ephemeral port and /// drives the embedded ui.html with Playwright (deno, npm-free). /// Skipped when deno or the chromium binary is unavailable. #[tokio::test] async fn ui_playwright_checks() { if std::process::Command::new("deno") .arg("--version") .output() .is_err() { eprintln!("SKIP ui_playwright_checks: deno not available"); return; } let (addr, _h) = boot_with(playwright_config()).await; // tokio::process (not std::process::output — that would block this // runtime's only thread while the axum server runs on it → deadlock). let out = tokio::process::Command::new("deno") .arg("run") .arg("-A") .arg("src/web/ui.playwright.mjs") .env("SM121_WEB_URL", format!("http://{addr}")) .output() .await .unwrap(); let stdout = String::from_utf8_lossy(&out.stdout); let stderr = String::from_utf8_lossy(&out.stderr); if stdout.contains("SKIP") { eprintln!("SKIP ui_playwright_checks:\n{stdout}"); return; } assert!( out.status.success(), "playwright UI checks failed:\n{stdout}\n{stderr}" ); eprintln!("{stdout}"); }