tests.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. //! Web module tests: bus semantics, token gate, and a full server boot on an
  2. //! ephemeral port with a fake (unreachable) node.
  3. use crate::config::{Cluster, Config, Node};
  4. use crate::web::events::{Event, build_snapshot};
  5. use crate::web::{AppState, router};
  6. use std::collections::HashMap;
  7. use std::net::SocketAddr;
  8. pub(crate) async fn call(addr: SocketAddr, path: &str) -> reqwest::Response {
  9. reqwest::Client::new()
  10. .get(format!("http://{addr}{path}"))
  11. .send()
  12. .await
  13. .unwrap()
  14. }
  15. /// Config with one node at 127.0.0.1:2222 — connection refused instantly,
  16. /// machine-independent (no real SSH, no hardware).
  17. pub(crate) fn fake_config() -> Config {
  18. Config {
  19. ssh_user: "u".into(),
  20. ssh_key_path: "/nonexistent/key".into(),
  21. clusters: vec![Cluster {
  22. name: "fake".into(),
  23. master_ib_ip: None,
  24. nodes: vec![Node {
  25. name: "n0".into(),
  26. ssh_ip: "127.0.0.1:2222".into(),
  27. ib_ip: None,
  28. rank: 0,
  29. host_key: None,
  30. entity: None,
  31. }],
  32. extra_entities: vec![],
  33. }],
  34. nccl: HashMap::new(),
  35. hf_cache_dir: None,
  36. init_port: 25000,
  37. before_serve: HashMap::new(),
  38. exec_aliases: HashMap::new(),
  39. home_assistant: None,
  40. global_mods_dir: None,
  41. drop_caches: true,
  42. mount_aliases: HashMap::new(),
  43. recipe_sources: Vec::new(),
  44. docker_image_sources: Vec::new(),
  45. cluster_sources: Vec::new(),
  46. }
  47. }
  48. pub(crate) fn app_state(token: Option<&str>) -> AppState {
  49. app_state_with(fake_config(), token)
  50. }
  51. fn app_state_with(config: Config, token: Option<&str>) -> AppState {
  52. let (tx, _) = tokio::sync::broadcast::channel(16);
  53. AppState {
  54. config: std::sync::Arc::new(config),
  55. bus: tx,
  56. token: token.map(String::from),
  57. last: std::sync::Arc::new(std::sync::Mutex::new(None)),
  58. jobs: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
  59. log_attaches: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
  60. }
  61. }
  62. pub(crate) async fn boot() -> (SocketAddr, std::sync::Arc<tokio::task::JoinHandle<()>>) {
  63. boot_with(fake_config()).await
  64. }
  65. async fn boot_with(config: Config) -> (SocketAddr, std::sync::Arc<tokio::task::JoinHandle<()>>) {
  66. let app = app_state_with(config, None);
  67. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
  68. let addr = listener.local_addr().unwrap();
  69. let handle = tokio::spawn(async move {
  70. axum::serve(listener, router(app)).await.unwrap();
  71. });
  72. (addr, std::sync::Arc::new(handle))
  73. }
  74. #[tokio::test]
  75. async fn api_state_reports_unreachable_node() {
  76. let (addr, handle) = boot().await;
  77. let body: serde_json::Value = reqwest::get(format!("http://{addr}/api/state"))
  78. .await
  79. .unwrap()
  80. .json()
  81. .await
  82. .unwrap();
  83. assert_eq!(body["clusters"].as_array().unwrap().len(), 1);
  84. assert_eq!(body["clusters"][0]["name"], "fake");
  85. assert_eq!(body["clusters"][0]["nodes"][0]["reachable"], false);
  86. assert_eq!(body["clusters"][0]["free"], 0);
  87. handle.abort();
  88. }
  89. #[tokio::test]
  90. async fn token_gate_enforces_when_set() {
  91. let app = app_state(Some("s3cret"));
  92. let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
  93. let addr = listener.local_addr().unwrap();
  94. tokio::spawn(async move {
  95. axum::serve(listener, router(app)).await.unwrap();
  96. });
  97. let base = format!("http://{addr}/api/state");
  98. // No token → 401.
  99. assert_eq!(reqwest::get(&base).await.unwrap().status(), 401);
  100. // Wrong token → 401.
  101. assert_eq!(
  102. reqwest::get(format!("{base}?token=wrong"))
  103. .await
  104. .unwrap()
  105. .status(),
  106. 401
  107. );
  108. // Right token (query form — what SSE clients use) → 200.
  109. assert_eq!(
  110. reqwest::get(format!("{base}?token=s3cret"))
  111. .await
  112. .unwrap()
  113. .status(),
  114. 200
  115. );
  116. }
  117. #[tokio::test]
  118. async fn bus_delivers_state_events() {
  119. let (tx, mut rx) = tokio::sync::broadcast::channel::<Event>(8);
  120. let snap = crate::web::events::Snapshot {
  121. ts: 1,
  122. clusters: Vec::new(),
  123. jobs: Vec::new(),
  124. topo: Default::default(),
  125. attaches: Vec::new(),
  126. };
  127. tx.send(Event::State {
  128. ts: snap.ts,
  129. snapshot: snap.clone(),
  130. })
  131. .unwrap();
  132. let got = rx.recv().await.unwrap();
  133. match got {
  134. Event::State { ts, snapshot } => {
  135. assert_eq!(ts, 1);
  136. assert!(snapshot.clusters.is_empty());
  137. }
  138. Event::Job { .. } => panic!("unexpected job event"),
  139. Event::Log { .. } => panic!("unexpected log event"),
  140. }
  141. }
  142. #[tokio::test]
  143. async fn snapshot_serializes_with_type_tag() {
  144. let ev = Event::State {
  145. ts: 7,
  146. snapshot: build_snapshot(
  147. &fake_config(),
  148. &crate::web::probe::Fleet {
  149. nodes: std::collections::HashMap::new(),
  150. switch_off: std::collections::HashMap::new(),
  151. soft_off: std::collections::HashMap::new(),
  152. ha: std::collections::HashMap::new(),
  153. },
  154. &[],
  155. &[],
  156. &std::collections::HashMap::new(),
  157. ),
  158. };
  159. let json = serde_json::to_value(&ev).unwrap();
  160. assert_eq!(json["type"], "state");
  161. assert_eq!(json["ts"], 7);
  162. assert!(json["snapshot"].is_object());
  163. }
  164. // (P2/P3/P4 API round-trip tests moved to api_tests.rs — 500-line cap)
  165. // --- UI (ui.html) -------------------------------------------------------------
  166. // The embedded page is vanilla JS with no build step; these tests run the
  167. // real file through node: (1) syntax gate, (2) behavior probe of the pure
  168. // snapshot-compare logic (the anti-blink rule), extracted between the
  169. // SNAP-LT markers in ui.html. Skipped when node is unavailable.
  170. fn extract_script(html: &str) -> String {
  171. let start = html.find("<script>").unwrap() + "<script>".len();
  172. let end = html.rfind("</script>").unwrap();
  173. html[start..end].to_string()
  174. }
  175. #[test]
  176. fn ui_js_syntax_and_snapshot_logic() {
  177. let html = include_str!("ui.html");
  178. if std::process::Command::new("node")
  179. .arg("--version")
  180. .output()
  181. .is_err()
  182. {
  183. eprintln!("SKIP ui_js_syntax_and_snapshot_logic: node not available");
  184. return;
  185. }
  186. let script = extract_script(html);
  187. let pid = std::process::id();
  188. let js = std::env::temp_dir().join(format!("sm121-ui-{pid}.js"));
  189. std::fs::write(&js, &script).unwrap();
  190. let out = std::process::Command::new("node")
  191. .arg("--check")
  192. .arg(&js)
  193. .output()
  194. .unwrap();
  195. assert!(
  196. out.status.success(),
  197. "ui.html JS must parse: {}",
  198. String::from_utf8_lossy(&out.stderr)
  199. );
  200. // sameSnapshot: ts must be ignored, real changes must not be equal.
  201. let s = script.find("/*SNAP-LT:START*/").unwrap() + "/*SNAP-LT:START*/".len();
  202. let e = script.find("/*SNAP-LT:END*/").unwrap();
  203. let probe = format!(
  204. "{fn}
  205. const a = {{ ts: 1, clusters: [{{ name: 'c', up: true, free: 1, nodes: [] }}], jobs: [] }};
  206. const b = {{ ts: 99, clusters: [{{ name: 'c', up: true, free: 1, nodes: [] }}], jobs: [] }};
  207. const d = {{ ts: 99, clusters: [{{ name: 'c', up: true, free: 0, nodes: [] }}], jobs: [] }};
  208. if (sameSnapshot(a, b) !== true) throw new Error('ts must be ignored');
  209. if (sameSnapshot(a, d) !== false) throw new Error('changed fleet must differ');
  210. if (sameSnapshot(a, a) !== true) throw new Error('identity');
  211. console.log('ok');",
  212. fn = &script[s..e]
  213. );
  214. let js2 = std::env::temp_dir().join(format!("sm121-ui-probe-{pid}.js"));
  215. std::fs::write(&js2, probe).unwrap();
  216. let out = std::process::Command::new("node")
  217. .arg(&js2)
  218. .output()
  219. .unwrap();
  220. assert!(
  221. out.status.success(),
  222. "sameSnapshot probe failed: {}",
  223. String::from_utf8_lossy(&out.stderr)
  224. );
  225. let _ = std::fs::remove_file(&js);
  226. let _ = std::fs::remove_file(&js2);
  227. }
  228. /// serveBody (FLAGS-LT block): the serve flags → API body mapping is the
  229. /// trickiest pure logic in the UI — probe it in node like sameSnapshot.
  230. #[test]
  231. fn ui_js_serve_body_logic() {
  232. let html = include_str!("ui.html");
  233. if std::process::Command::new("node")
  234. .arg("--version")
  235. .output()
  236. .is_err()
  237. {
  238. eprintln!("SKIP ui_js_serve_body_logic: node not available");
  239. return;
  240. }
  241. let script = extract_script(html);
  242. let s = script.find("/*FLAGS-LT:START*/").unwrap() + "/*FLAGS-LT:START*/".len();
  243. let e = script.find("/*FLAGS-LT:END*/").unwrap();
  244. let pid = std::process::id();
  245. let probe = format!(
  246. "const state = {{ flags: {{}} }};\n{fn}\n\n\
  247. // all flags set: everything passes through\n\
  248. 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\
  249. 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\
  250. if (b.fit !== '2') throw new Error('fit N must win over bare: ' + b.fit);\n\
  251. if (b.mirror !== '/m') throw new Error('mirror path must win over bare: ' + b.mirror);\n\
  252. // bare fit / bare mirror (empty strings = whole cluster / default path)\n\
  253. b = serveBody('c', 'r', false, {{ fit: '', fitAll: true }});\n\
  254. if (b.fit !== '') throw new Error('bare fit');\n\
  255. b = serveBody('c', 'r', false, {{ mirror: '', mirrorAll: true }});\n\
  256. if (b.mirror !== '') throw new Error('bare mirror');\n\
  257. // no flags: only cluster/recipe/dry_run — server defaults win\n\
  258. b = serveBody('c', 'r', true, {{}});\n\
  259. for (const k of ['node', 'solo', 'force', 'clear', 'keep', 'fit', 'mirror']) {{\n\
  260. if (k in b) throw new Error('absent flag must be omitted: ' + k);\n\
  261. }}\n\
  262. // flagsOf caches per cluster\n\
  263. if (flagsOf('x') !== flagsOf('x')) throw new Error('flagsOf must cache');\n\
  264. console.log('ok');",
  265. fn = &script[s..e]
  266. );
  267. let js2 = std::env::temp_dir().join(format!("sm121-ui-flags-{pid}.js"));
  268. std::fs::write(&js2, &probe).unwrap();
  269. let out = std::process::Command::new("node")
  270. .arg(&js2)
  271. .output()
  272. .unwrap();
  273. assert!(
  274. out.status.success(),
  275. "serveBody probe failed: {}",
  276. String::from_utf8_lossy(&out.stderr)
  277. );
  278. let _ = std::fs::remove_file(&js2);
  279. }
  280. /// Two offline clusters (both nodes on refused loopback ports) — the
  281. /// fleet the Playwright UI checks render: chip strip with two chips,
  282. /// down-cluster action bar, type-the-name power flow.
  283. fn playwright_config() -> Config {
  284. let mut cfg = fake_config();
  285. cfg.clusters.push(Cluster {
  286. name: "fake2".into(),
  287. master_ib_ip: None,
  288. nodes: vec![Node {
  289. name: "n1".into(),
  290. ssh_ip: "127.0.0.1:2223".into(),
  291. ib_ip: None,
  292. rank: 0,
  293. host_key: None,
  294. entity: None,
  295. }],
  296. extra_entities: vec![],
  297. });
  298. cfg
  299. }
  300. /// Click-through UI tests: boots the real server on an ephemeral port and
  301. /// drives the embedded ui.html with Playwright (deno, npm-free).
  302. /// Skipped when deno or the chromium binary is unavailable.
  303. #[tokio::test]
  304. async fn ui_playwright_checks() {
  305. if std::process::Command::new("deno")
  306. .arg("--version")
  307. .output()
  308. .is_err()
  309. {
  310. eprintln!("SKIP ui_playwright_checks: deno not available");
  311. return;
  312. }
  313. let (addr, _h) = boot_with(playwright_config()).await;
  314. // tokio::process (not std::process::output — that would block this
  315. // runtime's only thread while the axum server runs on it → deadlock).
  316. let out = tokio::process::Command::new("deno")
  317. .arg("run")
  318. .arg("-A")
  319. .arg("src/web/ui.playwright.mjs")
  320. .env("SM121_WEB_URL", format!("http://{addr}"))
  321. .output()
  322. .await
  323. .unwrap();
  324. let stdout = String::from_utf8_lossy(&out.stdout);
  325. let stderr = String::from_utf8_lossy(&out.stderr);
  326. if stdout.contains("SKIP") {
  327. eprintln!("SKIP ui_playwright_checks:\n{stdout}");
  328. return;
  329. }
  330. assert!(
  331. out.status.success(),
  332. "playwright UI checks failed:\n{stdout}\n{stderr}"
  333. );
  334. eprintln!("{stdout}");
  335. }