// 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) -> 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(""), "embed marker missing" ); assert!( super::html_page(&[]).contains(""), "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 cannot break out of the JSON island let evil = vec![row("x", "ab", "NVFP4", None)]; assert!(!html_page(&evil).contains("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); }