|
@@ -0,0 +1,243 @@
|
|
|
|
|
+//! Pure-logic probes of the embedded ui.html, run through node (the file
|
|
|
|
|
+//! is a no-build vanilla page): syntax gate + the extracted marker blocks.
|
|
|
|
|
+//! Skipped when node is unavailable. Split from tests.rs for the 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("<script>").unwrap() + "<script>".len();
|
|
|
|
|
+ let end = html.rfind("</script>").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);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// connectSSE (SSE-LT block): the live chip must never stick in a broken
|
|
|
|
|
+/// state — a CLOSED EventSource (gave up retrying) schedules a rebuild, and
|
|
|
|
|
+/// a stream silent past three poll ticks is rebuilt by the watchdog; fresh
|
|
|
|
|
+/// data must not trigger rebuilds.
|
|
|
|
|
+#[test]
|
|
|
|
|
+fn ui_js_sse_recovery_logic() {
|
|
|
|
|
+ let html = include_str!("ui.html");
|
|
|
|
|
+ if std::process::Command::new("node")
|
|
|
|
|
+ .arg("--version")
|
|
|
|
|
+ .output()
|
|
|
|
|
+ .is_err()
|
|
|
|
|
+ {
|
|
|
|
|
+ eprintln!("SKIP ui_js_sse_recovery_logic: node not available");
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ let script = extract_script(html);
|
|
|
|
|
+ let s = script.find("/*SSE-LT:START*/").unwrap() + "/*SSE-LT:START*/".len();
|
|
|
|
|
+ let e = script.find("/*SSE-LT:END*/").unwrap();
|
|
|
|
|
+ let body = r#"
|
|
|
|
|
+let created = 0, now = 1000000;
|
|
|
|
|
+class FakeES { constructor() { this.readyState = 0; created++; FakeES.last = this; } close() {} }
|
|
|
|
|
+const handlers = {}, timers = [], sseLog = [];
|
|
|
|
|
+global.document = { visibilityState: "visible", addEventListener: (t, f) => { handlers[t] = f; } };
|
|
|
|
|
+global.addEventListener = (t, f) => { handlers["w:" + t] = f; };
|
|
|
|
|
+global.EventSource = FakeES;
|
|
|
|
|
+global.setTimeout = (f, ms) => { timers.push({ f, ms }); return 0; };
|
|
|
|
|
+global.setInterval = (f) => { timers.push({ f, every: true }); return 0; };
|
|
|
|
|
+global.$ = () => ({ set className(v) {}, set textContent(v) { sseLog.push(v); } });
|
|
|
|
|
+global.sse = (cls, txt) => sseLog.push(txt);
|
|
|
|
|
+global.onState = global.onJob = global.onLog = () => {};
|
|
|
|
|
+global.Date = class extends Date { static now() { return now; } };
|
|
|
|
|
+const chip = () => sseLog[sseLog.length - 1];
|
|
|
|
|
+connectSSE();
|
|
|
|
|
+if (created !== 1 || chip() !== "connecting…") throw new Error("no initial connect");
|
|
|
|
|
+FakeES.last.readyState = 1; FakeES.last.onopen();
|
|
|
|
|
+if (chip() !== "live") throw new Error("open must set live");
|
|
|
|
|
+const tick = timers.find((t) => t.every);
|
|
|
|
|
+now += 100000; // silent past 3 poll ticks (95s)
|
|
|
|
|
+tick.f();
|
|
|
|
|
+if (created !== 2 || chip() !== "resyncing…") throw new Error("stale stream not rebuilt");
|
|
|
|
|
+FakeES.last.readyState = 2; FakeES.last.onerror();
|
|
|
|
|
+const scheduled = timers.filter((t) => !t.every);
|
|
|
|
|
+if (scheduled.length !== 1 || scheduled[0].ms !== 1000) throw new Error("CLOSED not scheduled");
|
|
|
|
|
+scheduled[0].f();
|
|
|
|
|
+if (created !== 3) throw new Error("CLOSED did not re-bounce");
|
|
|
|
|
+FakeES.last.readyState = 1; FakeES.last.onopen();
|
|
|
|
|
+now += 1000;
|
|
|
|
|
+tick.f();
|
|
|
|
|
+if (created !== 3 || chip() !== "live") throw new Error("fresh stream must not rebuild");
|
|
|
|
|
+console.log("ok");
|
|
|
|
|
+"#;
|
|
|
|
|
+ let probe = format!(
|
|
|
|
|
+ "{}
|
|
|
|
|
+{}",
|
|
|
|
|
+ &script[s..e],
|
|
|
|
|
+ body
|
|
|
|
|
+ );
|
|
|
|
|
+ let pid = std::process::id();
|
|
|
|
|
+ let js = std::env::temp_dir().join(format!("sm121-ui-sse-{pid}.js"));
|
|
|
|
|
+ std::fs::write(&js, probe).unwrap();
|
|
|
|
|
+ let out = std::process::Command::new("node")
|
|
|
|
|
+ .arg(&js)
|
|
|
|
|
+ .output()
|
|
|
|
|
+ .unwrap();
|
|
|
|
|
+ assert!(
|
|
|
|
|
+ out.status.success(),
|
|
|
|
|
+ "connectSSE probe failed: {}",
|
|
|
|
|
+ String::from_utf8_lossy(&out.stderr)
|
|
|
|
|
+ );
|
|
|
|
|
+ let _ = std::fs::remove_file(&js);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// 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]
|
|
|
|
|
+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);
|
|
|
|
|
+}
|