| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300 |
- // Report page UI tests: the live-update bell (SSE → diff → badge → panel).
- // Split from tests.rs at the 500-line cap (same split as web: tests + ui);
- // shared helpers (fixture) live in tests.rs.
- use super::tests::fixture;
- use super::{ReportRow, collect, html_page, markdown, serve};
- use crate::config::Config;
- use std::fs;
- #[test]
- fn page_has_bell_and_notif_panel() {
- let page = html_page(&[]);
- for needle in [
- "id=\"bell\"",
- "id=\"bellcount\"",
- "id=\"notifbox\"",
- "notifDiff(DATA, rows)",
- "auth(\"/events\")",
- "NOTIFDIFF:START",
- ] {
- assert!(page.contains(needle), "page missing {needle}");
- }
- }
- /// The stale branch of the state cell: an open ledger run the live probe
- /// contradicts renders orange with the `sm121 runlock close` hint.
- #[test]
- fn page_renders_stale_state() {
- let page = html_page(&[]);
- for needle in ["r.stale", "sm121 runlock close", "stale"] {
- assert!(page.contains(needle), "page missing {needle}");
- }
- }
- /// The `notifDiff` JS function (extracted between markers from the live
- /// page) must classify every update kind and order newest first — probed
- /// under node, like the web dashboard's UI probes.
- #[test]
- fn page_notif_diff_js() {
- if std::process::Command::new("node")
- .arg("--version")
- .output()
- .is_err()
- {
- eprintln!("SKIP page_notif_diff_js: node not available");
- return;
- }
- let page = html_page(&[]);
- let s = page.find("/*NOTIFDIFF:START*/").unwrap();
- let e = page.find("/*NOTIFDIFF:END*/").unwrap();
- let fnjs = &page[s + "/*NOTIFDIFF:START*/".len()..e];
- let probe = format!(
- "{fn}\n\
- const rep = (n, l, date, pct) => ({{ date: date, time: null, label: l, \
- file: date + \"_\" + l + \".md\", files: [date + \"_\" + l + \".md\"], \
- pct: pct, points: null, ratio: null, perf: [] }});
- const row = (n, model, image, reports) => ({{ name: n, title: n, model: model, \
- quant: \"NVFP4\", engine: \"vllm\", image: image, nodes: 1, tp: 1, \
- deploy: \"docker\", port: 30000, reports: reports }});
- const a0 = row(\"r-a\", \"m/A\", \"img:1\", [rep(\"r-a\", \"old\", \"2026-01-01\", 90.0)]);
- // 1: identical snapshots → empty
- if (JSON.stringify(notifDiff([a0], [a0])) !== \"[]\")
- throw new Error(\"identical must diff empty: \" + JSON.stringify(notifDiff([a0], [a0])));
- // 2: image change + new bench + new recipe
- const a1 = row(\"r-a\", \"m/A\", \"img:2\", [rep(\"r-a\", \"old\", \"2026-01-01\", 90.0), rep(\"r-a\", \"new\", \"2026-01-02\", 92.0)]);
- const b0 = row(\"r-b\", \"m/B\", \"img:3\", []);
- const d = notifDiff([a0], [a1, b0]);
- if (d.map(x => x.t).sort().join(\",\") !== \"bench,config,recipe\")
- throw new Error(\"kinds: \" + JSON.stringify(d));
- const bi = d.find(x => x.t === \"bench\");
- if (bi.name !== \"r-a\" || bi.label !== \"new\" || bi.score !== 92.0)
- throw new Error(\"bench item: \" + JSON.stringify(bi));
- if (bi.files[0] !== \"2026-01-02_new.md\") throw new Error(\"bench files: \" + JSON.stringify(bi));
- if (d.find(x => x.t === \"config\").name !== \"r-a\") throw new Error(\"config item\");
- const ri = d.find(x => x.t === \"recipe\");
- if (ri.name !== \"r-b\" || ri.label !== \"m/B\") throw new Error(\"recipe item: \" + JSON.stringify(ri));
- // 3: entry value change → change
- const a2 = row(\"r-a\", \"m/A\", \"img:1\", [rep(\"r-a\", \"old\", \"2026-01-01\", 95.0)]);
- const d2 = notifDiff([a0], [a2]);
- if (d2.length !== 1 || d2[0].t !== \"change\")
- throw new Error(\"expected one change: \" + JSON.stringify(d2));
- // 4: dropped row → removed
- const d3 = notifDiff([a1, b0], [a1]);
- if (d3.length !== 1 || d3[0].t !== \"removed\" || d3[0].name !== \"r-b\")
- throw new Error(\"expected removed r-b: \" + JSON.stringify(d3));
- // 5: newest first — the new bench must precede the config note
- if (d.findIndex(x => x.t === \"bench\") > d.findIndex(x => x.t === \"config\"))
- throw new Error(\"newest-first violated\");
- console.log(\"ok\");", fn = fnjs
- );
- let js = std::env::temp_dir().join(format!("sm121-report-notif-{}.js", std::process::id()));
- fs::write(&js, &probe).unwrap();
- let out = std::process::Command::new("node")
- .arg(&js)
- .output()
- .unwrap();
- let _ = fs::remove_file(&js);
- assert!(
- out.status.success(),
- "notifDiff probe failed: {}",
- String::from_utf8_lossy(&out.stderr)
- );
- }
- /// Click-through checks for the live-update bell: boots the real report
- /// router on an ephemeral port (fixture recipe dir), drives page.html with
- /// Playwright (deno, npm-free), drops a new report file into the watched
- /// dir and expects the badge + panel to react. Skipped without deno/browser.
- #[tokio::test]
- async fn report_playwright_checks() {
- if std::process::Command::new("deno")
- .arg("--version")
- .output()
- .is_err()
- {
- eprintln!("SKIP report_playwright_checks: deno not available");
- return;
- }
- let dir = fixture();
- let config: Config =
- toml::from_str(&format!("recipe_sources = [\"{}\"]", dir.to_str().unwrap())).unwrap();
- let cfg = std::sync::Arc::new(config);
- let rows = collect(&cfg, false);
- let page = html_page(&rows);
- let initial = serde_json::to_string(&rows).unwrap();
- let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
- let addr = listener.local_addr().unwrap();
- let app = serve::router(cfg, page, initial, false, "", None);
- let _h = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
- let reports = dir.join("test-recipe-nvfp4/reports");
- // Serialize with the web dashboard's Playwright suite (shared browser).
- // The lock + deno run live in spawn_blocking: the std guard must not
- // be held across an await, and std::process::output must not block
- // this runtime's only thread (the axum server runs on it).
- let url = format!("http://{addr}");
- let fixture_path = reports.to_str().unwrap().to_string();
- let out = tokio::task::spawn_blocking(move || {
- let _pw = crate::ui_test_lock::playwright_session();
- std::process::Command::new("deno")
- .arg("run")
- .arg("-A")
- .arg("src/report/report.playwright.mjs")
- .env("SM121_REPORT_URL", url)
- .env("SM121_REPORT_FIXTURE", fixture_path)
- .output()
- })
- .await
- .unwrap()
- .unwrap();
- let stdout = String::from_utf8_lossy(&out.stdout);
- let stderr = String::from_utf8_lossy(&out.stderr);
- let skip = stdout.contains("SKIP");
- fs::remove_dir_all(&dir).ok();
- if skip {
- eprintln!("SKIP report_playwright_checks:\n{stdout}");
- return;
- }
- assert!(
- out.status.success(),
- "report playwright checks failed:\n{stdout}{stderr}"
- );
- eprintln!("{stdout}");
- }
- // ----- rendering (moved from tests.rs at the 500-line cap) -----
- fn row(name: &str, model: &str, quant: &str, score: Option<serde_json::Value>) -> ReportRow {
- let reports = score.unwrap_or(
- serde_json::json!([{"file": "f.md", "files": ["f.md"], "perf": [], "date": "", "label": ""}]),
- );
- serde_json::from_value(serde_json::json!({
- "name": name, "title": name, "model": model, "quant": quant,
- "engine": "vllm", "image": "img:1", "nodes": 1, "tp": 1,
- "deploy": "docker", "port": 30000, "reports": reports,
- }))
- .unwrap()
- }
- #[test]
- fn markdown_lists_rows_and_scores() {
- let rows = vec![row(
- "r-a",
- "m/one",
- "NVFP4",
- Some(serde_json::json!([
- {"file": "f.md", "files": ["f.md"], "perf": [{"d": 0, "c": 1, "tg": 27.4}, {"d": 0, "c": 2, "tg": 33.3}], "date": "2026-01-02", "label": "l", "pct": 90.4, "points": "159/176", "ratio": 0.9034},
- ])),
- )];
- let md = markdown(&rows);
- assert!(md.contains(
- "| m/one | NVFP4 | r-a | — | vllm | 1×1 | 1 | 2026-01-02 | — | 0 | 159/176 | 90.4 | 27.4 | 33.3 |"
- ));
- let empty = markdown(&[]);
- assert!(empty.contains(fluent_i18n::t!("report-title", { "count" => 0 }).as_str()));
- }
- /// Mounted variant (the dashboard nests the panel at /recipes): the page's
- /// own absolute paths must move with the prefix or its SSE stream and
- /// viewer links would hit the dashboard's routes.
- #[test]
- fn html_page_at_rewrites_own_paths() {
- let page = super::html_page_at(&[], "/recipes");
- assert!(
- page.contains("auth(\"/recipes/events\")"),
- "SSE path not rewritten"
- );
- assert!(!page.contains("auth(\"/events\")"));
- assert!(
- page.contains("auth(\"/recipes/r/"),
- "viewer link not rewritten"
- );
- assert!(!page.contains("href=\"/r/"));
- assert!(
- !super::html_page(&[]).contains("/recipes"),
- "unprefixed page must stay clean"
- );
- // hosted copy is marked so it can drop its own header/live chrome
- assert!(
- page.contains("<body class=\"embed\">"),
- "embed marker missing"
- );
- assert!(
- super::html_page(&[]).contains("<body>"),
- "standalone page keeps its unmarked body"
- );
- // viewer links carry the auth token (the token gate rejects bare /r/)
- assert!(
- page.contains("auth(\"/recipes/r/\""),
- "viewer link not auth-wrapped"
- );
- assert!(
- !page.contains("href=\"/r/\""),
- "unwrapped viewer link remains"
- );
- }
- #[test]
- fn html_page_embeds_data_escaped() {
- let rows = vec![row("r-a", "m/one", "NVFP4", None)];
- let page = html_page(&rows);
- assert!(page.contains("r-a"));
- assert!(!page.contains("{{DATA}}"));
- // a model name containing </script> cannot break out of the JSON island
- let evil = vec![row("x", "a</script>b", "NVFP4", None)];
- assert!(!html_page(&evil).contains("</script>b"));
- }
- // ----- log rendering (serve.rs clean_log), moved at the 500-line cap -----
- #[test]
- fn clean_log_collapses_spinner_soup_and_dups() {
- use super::serve::clean_log;
- // one llama-benchy phase = many rich redraw frames (spinner glyph,
- // elapsed clock and run counter differ, the test name does not); old
- // pty-captured logs keep every frame as its own line, plus pty ^D/⌫
- // artifacts and duplicated summary lines
- let soup = "^D\u{8}\u{8}{\"event\": \"backend_detected\"}\n\n\
- \u{280b} Initializing\u{2026} \u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501} 0/18 0:00:00\n\
- \u{2819} Initializing\u{2026} \u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501} 0/18 0:00:00\n\
- \u{2839} pp2048 tg128 @ d0 c1 run 0 \u{2501}\u{2501}\u{2501}\u{2501}\u{257a}\u{2501} 1/18 0:00:02\n\
- \u{2838} pp2048 tg128 @ d0 c1 run 0 \u{2501}\u{2501}\u{2501}\u{2501}\u{257a}\u{2501} 1/18 0:00:02\n\
- \u{283c} pp2048 tg128 @ d0 c1 run 1 \u{2501}\u{2501}\u{2501}\u{2501}\u{257a}\u{2501} 2/18 0:00:05\n\
- \u{2713} Complete \u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501} 18/18 0:01:48\n\
- \u{2713} Complete \u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501}\u{2501} 18/18 0:01:48\n\
- \u{2502} pp2048 tg128 @ d0 \u{2502} c1 \u{2502} 1,230 \u{2502} 59.2 \u{2502} 2,630 \u{2502} 4,641 \u{2502} 2048+128\n";
- let out = clean_log(soup);
- let lines: Vec<&str> = out.lines().collect();
- // pty artifact gone; each spinner phase keeps its LAST frame; one
- // ✓ Complete; table row intact
- assert_eq!(
- lines.len(),
- 6,
- "event, blank, init, run, check, table: {}",
- out
- );
- assert!(lines[0].starts_with("{\"event\""), "no ^D: {}", lines[0]);
- assert!(
- lines[2].starts_with("\u{2819} Initializing"),
- "init last frame: {}",
- out
- );
- assert!(
- lines[3].starts_with("\u{283c} pp2048"),
- "run-0 frames dropped: {}",
- out
- );
- assert!(lines[3].contains("run 1"), "keeps the LAST frame: {}", out);
- assert!(
- lines[4].trim().starts_with("\u{2713} Complete"),
- "one check: {}",
- out
- );
- assert!(
- lines[5].starts_with("\u{2502} pp2048"),
- "table row intact: {}",
- out
- );
- // idempotent on already-clean logs
- assert_eq!(clean_log(&out), out);
- }
|