|
|
@@ -70,7 +70,10 @@ pub(crate) async fn boot() -> (SocketAddr, std::sync::Arc<tokio::task::JoinHandl
|
|
|
}
|
|
|
|
|
|
async fn boot_with(config: Config) -> (SocketAddr, std::sync::Arc<tokio::task::JoinHandle<()>>) {
|
|
|
- let app = app_state_with(config, None);
|
|
|
+ boot_app(app_state_with(config, None)).await
|
|
|
+}
|
|
|
+
|
|
|
+async fn boot_app(app: AppState) -> (SocketAddr, std::sync::Arc<tokio::task::JoinHandle<()>>) {
|
|
|
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 {
|
|
|
@@ -124,6 +127,34 @@ async fn token_gate_enforces_when_set() {
|
|
|
);
|
|
|
}
|
|
|
|
|
|
+#[tokio::test]
|
|
|
+async fn sse_replays_cached_snapshot_on_connect() {
|
|
|
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
|
+ // Nothing is broadcast after this point: the frame must come from the
|
|
|
+ // cached snapshot, otherwise a fresh client waits a whole poll tick.
|
|
|
+ let app = app_state(None);
|
|
|
+ *app.last.lock().unwrap() = Some(crate::web::events::Snapshot {
|
|
|
+ ts: 1,
|
|
|
+ clusters: Vec::new(),
|
|
|
+ jobs: Vec::new(),
|
|
|
+ topo: Default::default(),
|
|
|
+ attaches: Vec::new(),
|
|
|
+ });
|
|
|
+ let (addr, handle) = boot_app(app).await;
|
|
|
+ let mut sock = tokio::net::TcpStream::connect(addr).await.unwrap();
|
|
|
+ sock.write_all(b"GET /events HTTP/1.1\r\nHost: x\r\n\r\n")
|
|
|
+ .await
|
|
|
+ .unwrap();
|
|
|
+ let mut buf = vec![0u8; 4096];
|
|
|
+ let n = sock.read(&mut buf).await.unwrap();
|
|
|
+ let text = String::from_utf8_lossy(&buf[..n]);
|
|
|
+ assert!(
|
|
|
+ text.contains("\"type\":\"state\""),
|
|
|
+ "no replayed frame: {text}"
|
|
|
+ );
|
|
|
+ handle.abort();
|
|
|
+}
|
|
|
+
|
|
|
#[tokio::test]
|
|
|
async fn bus_delivers_state_events() {
|
|
|
let (tx, mut rx) = tokio::sync::broadcast::channel::<Event>(8);
|
|
|
@@ -242,6 +273,54 @@ console.log('ok');",
|
|
|
let _ = std::fs::remove_file(&js2);
|
|
|
}
|
|
|
|
|
|
+/// runningFromLabels (JOBS-LT block): one row per launcher job. A container
|
|
|
+/// is reported by every cluster sharing its node — the list must not turn
|
|
|
+/// one serving job into a board-wide "everything is busy".
|
|
|
+#[test]
|
|
|
+fn ui_js_running_jobs_grouping() {
|
|
|
+ let html = include_str!("ui.html");
|
|
|
+ if std::process::Command::new("node")
|
|
|
+ .arg("--version")
|
|
|
+ .output()
|
|
|
+ .is_err()
|
|
|
+ {
|
|
|
+ eprintln!("SKIP ui_js_running_jobs_grouping: node not available");
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ let script = extract_script(html);
|
|
|
+ let s = script.find("/*JOBS-LT:START*/").unwrap() + "/*JOBS-LT:START*/".len();
|
|
|
+ let e = script.find("/*JOBS-LT:END*/").unwrap();
|
|
|
+ let body = r#"
|
|
|
+const c = (cluster, node) => ({ name: cluster, up: true, nodes: [{ name: node, containers: [
|
|
|
+ { name: "sm121-a-" + node, status: "running", cluster: "ds4", recipe: "ds4-flash" }] }] });
|
|
|
+const state = { snap: { clusters: [
|
|
|
+ c("4nodes-switch", "dgx3"), c("ds4", "dgx4"), c("1node-3", "dgx3"), c("ds4", "dgx3"),
|
|
|
+ { name: "down", up: false, nodes: [{ name: "dgx9", containers: [
|
|
|
+ { name: "x", status: "running", cluster: "z", recipe: "zz" }] }] },
|
|
|
+] } };
|
|
|
+const run = runningFromLabels();
|
|
|
+if (run.length !== 1) throw new Error("one row per job, got " + JSON.stringify(run));
|
|
|
+if (run[0].nodes.join(",") !== "dgx3,dgx4") throw new Error("nodes: " + run[0].nodes);
|
|
|
+if (run[0].statuses.join(",") !== "running") throw new Error("statuses: " + run[0].statuses);
|
|
|
+if (run[0].cluster !== "ds4" || run[0].recipe !== "ds4-flash") throw new Error("job id: " + JSON.stringify(run[0]));
|
|
|
+console.log("ok");
|
|
|
+"#;
|
|
|
+ let probe = format!("{}\n{}", &script[s..e], body);
|
|
|
+ let pid = std::process::id();
|
|
|
+ let js = std::env::temp_dir().join(format!("sm121-ui-jobs-{pid}.js"));
|
|
|
+ std::fs::write(&js, probe).unwrap();
|
|
|
+ let out = std::process::Command::new("node")
|
|
|
+ .arg(&js)
|
|
|
+ .output()
|
|
|
+ .unwrap();
|
|
|
+ assert!(
|
|
|
+ out.status.success(),
|
|
|
+ "runningFromLabels probe failed: {}",
|
|
|
+ String::from_utf8_lossy(&out.stderr)
|
|
|
+ );
|
|
|
+ let _ = std::fs::remove_file(&js);
|
|
|
+}
|
|
|
+
|
|
|
/// 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]
|