Преглед изворни кода

web UI: Playwright click-through checks (deno, no npm)

cargo test ui_playwright_checks boots the real server with two offline
fake clusters and drives the embedded ui.html in real Chromium:
19 checks — load/render, offline LEDs, theme toggle + persistence,
chip switching, down-cluster action bar (power on/switch only),
type-the-name power gate, no-DOM-churn on identical state (the
anti-blink rule in a real browser), serve/stop confirm dialogs
(stubbed controls, cancel only — no POST), zero page-JS errors.

- src/web/ui.playwright.mjs — the checks (npm:playwright@1.62.1 via
  deno specifiers; SKIP-exit when the browser is missing)
- src/web/pw-install.mjs — one-time deno-native chromium download
  (playwright's cli.js is not in the package exports; lib/program is)
- runner uses tokio::process — std::process::output() blocks the
  current-thread runtime that also serves the UI (deadlock found live)
- negative-verified: breaking the name gate -> 'FAIL wrong name keeps
  confirm hidden'
- npm artifacts from the first attempt removed (user: deno, not npm)
Alexander Korolev пре 2 месеци
родитељ
комит
0f01b6378f
5 измењених фајлова са 244 додато и 8 уклоњено
  1. 6 3
      AGENTS.md
  2. 15 3
      specs/tech-architecture/web-dashboard.md
  3. 4 0
      src/web/pw-install.mjs
  4. 68 2
      src/web/tests.rs
  5. 151 0
      src/web/ui.playwright.mjs

+ 6 - 3
AGENTS.md

@@ -20,9 +20,12 @@ See [`DEVELOPMENT.md`](DEVELOPMENT.md) for architecture, module layout, and dev
 serve/stop/logs/health/doctor/exec/power/tmux/init-host-keys/config/web
 ```
 
-`sm121 web` — local axum + SSE dashboard (P1: read-only LED board +
-themes; actions/logs/raw land in P2–P4). Design/progress:
-`specs/tech-architecture/web-dashboard.md`.
+`sm121 web` — local axum + SSE dashboard: LED board (cluster-level health,
+non-blocking `extra_entities` as ⚙ badges), themes, serve/stop/power jobs
+with type-the-name power gate, live log-less jobs window fed by docker
+labels + SSE. UI checks: `cargo test ui_playwright_checks` (deno +
+Playwright; one-time browser: `deno run -A src/web/pw-install.mjs`).
+Design/progress: `specs/tech-architecture/web-dashboard.md`.
 
 Cluster: exact name, prefix (`4`→`4nodes-switch`), or `auto` (smallest with NCCL + matching `required_nodes`).
 

+ 15 - 3
specs/tech-architecture/web-dashboard.md

@@ -236,9 +236,21 @@ occupancy only); `--open` already implemented (trivial).
   {dry_run: true}` → job `ok` with rendered command; `GET
   /api/raw/effective` reflects a `cluster_sources` merge; token enforced
   when `--bind 0.0.0.0`.
-- UI: no automation in v1 — the phase-ship tests above + manual checklist
-  (theme persists, window drag/resize, SSE reconnect after network blip,
-  power confirm can't be armed without typing the name).
+- UI (Playwright under **deno** — no npm): `cargo test
+  ui_playwright_checks` boots the real server (two offline fake clusters)
+  and drives the embedded `ui.html` in real Chromium via
+  `src/web/ui.playwright.mjs` (19 checks: load/render, theme toggle +
+  persistence, chip switching, down-cluster action bar, type-the-name
+  power gate, no-DOM-churn on identical state, serve/stop confirm dialogs,
+  zero page-JS errors). One-time browser setup:
+  `deno run -A src/web/pw-install.mjs`. Skips cleanly when deno or the
+  browser is absent; negative-verified (a broken name gate →
+  `FAIL wrong name keeps confirm hidden`). Gotchas learned: under deno
+  `waitUntil: "load"` never fires (use `domcontentloaded`); the runner
+  must use `tokio::process` — a blocking `std::process::output()`
+  deadlocks the current-thread runtime that also serves the UI.
+  `ui_js_syntax_and_snapshot_logic` (node) covers script syntax +
+  `sameSnapshot` without a browser.
 
 ## Decisions (locked 2026-08-22)
 

+ 4 - 0
src/web/pw-install.mjs

@@ -0,0 +1,4 @@
+// One-time: download the chromium binary for the UI tests (deno-native, no npm).
+// Usage: deno run -A src/web/pw-install.mjs
+import { program } from "npm:playwright@1.62.1/lib/program";
+program.parse(["deno", "playwright", "install", "chromium"]);

+ 68 - 2
src/web/tests.rs

@@ -50,9 +50,13 @@ fn fake_config() -> Config {
 }
 
 fn app_state(token: Option<&str>) -> AppState {
+    app_state_with(fake_config(), token)
+}
+
+fn app_state_with(config: Config, token: Option<&str>) -> AppState {
     let (tx, _) = tokio::sync::broadcast::channel(16);
     AppState {
-        config: std::sync::Arc::new(fake_config()),
+        config: std::sync::Arc::new(config),
         bus: tx,
         token: token.map(String::from),
         last: std::sync::Arc::new(std::sync::Mutex::new(None)),
@@ -61,7 +65,11 @@ fn app_state(token: Option<&str>) -> AppState {
 }
 
 async fn boot() -> (SocketAddr, std::sync::Arc<tokio::task::JoinHandle<()>>) {
-    let app = app_state(None);
+    boot_with(fake_config()).await
+}
+
+async fn boot_with(config: Config) -> (SocketAddr, std::sync::Arc<tokio::task::JoinHandle<()>>) {
+    let app = app_state_with(config, None);
     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 {
@@ -289,3 +297,61 @@ console.log('ok');",
     let _ = std::fs::remove_file(&js);
     let _ = std::fs::remove_file(&js2);
 }
+
+/// Two offline clusters (both nodes on refused loopback ports) — the
+/// fleet the Playwright UI checks render: chip strip with two chips,
+/// down-cluster action bar, type-the-name power flow.
+fn playwright_config() -> Config {
+    let mut cfg = fake_config();
+    cfg.clusters.push(Cluster {
+        name: "fake2".into(),
+        master_ib_ip: None,
+        nodes: vec![Node {
+            name: "n1".into(),
+            ssh_ip: "127.0.0.1:2223".into(),
+            ib_ip: None,
+            rank: 0,
+            host_key: None,
+            entity: None,
+        }],
+        extra_entities: vec![],
+    });
+    cfg
+}
+
+/// Click-through UI tests: boots the real server on an ephemeral port and
+/// drives the embedded ui.html with Playwright (deno, npm-free).
+/// Skipped when deno or the chromium binary is unavailable.
+#[tokio::test]
+async fn ui_playwright_checks() {
+    if std::process::Command::new("deno")
+        .arg("--version")
+        .output()
+        .is_err()
+    {
+        eprintln!("SKIP ui_playwright_checks: deno not available");
+        return;
+    }
+    let (addr, _h) = boot_with(playwright_config()).await;
+    // tokio::process (not std::process::output — that would block this
+    // runtime's only thread while the axum server runs on it → deadlock).
+    let out = tokio::process::Command::new("deno")
+        .arg("run")
+        .arg("-A")
+        .arg("src/web/ui.playwright.mjs")
+        .env("SM121_WEB_URL", format!("http://{addr}"))
+        .output()
+        .await
+        .unwrap();
+    let stdout = String::from_utf8_lossy(&out.stdout);
+    let stderr = String::from_utf8_lossy(&out.stderr);
+    if stdout.contains("SKIP") {
+        eprintln!("SKIP ui_playwright_checks:\n{stdout}");
+        return;
+    }
+    assert!(
+        out.status.success(),
+        "playwright UI checks failed:\n{stdout}\n{stderr}"
+    );
+    eprintln!("{stdout}");
+}

+ 151 - 0
src/web/ui.playwright.mjs

@@ -0,0 +1,151 @@
+// Playwright checks for the embedded web UI (src/web/ui.html).
+// Runs against a live `sm121 web` server (base URL in $SM121_WEB_URL,
+// provided by the Rust test `ui_playwright_checks` in src/web/tests.rs,
+// which boots the server on an ephemeral port with a fake offline fleet).
+//
+// No npm: uses deno's npm-package specifiers. One-time browser setup:
+//   deno run -A src/web/pw-install.mjs
+//
+// Exits 0 with "SKIP" lines when the browser is missing; non-zero on
+// real failures.
+
+import { chromium } from "npm:playwright@1.62.1";
+
+const BASE = Deno.env.get("SM121_WEB_URL");
+if (!BASE) {
+  console.log("SKIP SM121_WEB_URL not set (run via cargo test ui_playwright_checks)");
+  Deno.exit(0);
+}
+
+let failed = 0;
+const ok = (name, cond, extra = "") => {
+  if (!cond) failed++;
+  console.log((cond ? "ok   " : "FAIL ") + name + (!cond && extra ? " — " + extra : ""));
+};
+
+let browser;
+try {
+  browser = await chromium.launch();
+} catch (e) {
+  console.log("SKIP playwright browser not installed — run: deno run -A src/web/pw-install.mjs");
+  console.log(String(e).split("\n").find((l) => l.trim()) || "");
+  Deno.exit(0);
+}
+
+const page = await (await browser.newContext()).newPage();
+const pageErrors = [];
+page.on("pageerror", (e) => pageErrors.push(String(e)));
+
+// (waitUntil: "domcontentloaded" — the page is one self-contained document;
+// the "load" event does not fire reliably when Playwright runs under deno)
+await page.goto(BASE, { waitUntil: "domcontentloaded" });
+await page.waitForSelector("#clusters .cluster", { timeout: 15000 });
+
+// --- 1. load ---------------------------------------------------------------
+ok(
+  "page loads: cluster panel + 2 chips + jobs empty state",
+  (await page.locator("#clusters .cluster").count()) === 1 &&
+    (await page.locator("#clusters .chip").count()) === 2 &&
+    (await page.locator("#jobs .jobs-empty").count()) === 1,
+);
+
+// --- 2. offline node rendering ----------------------------------------------
+ok(
+  "offline node: red LED + 'offline' meta",
+  (await page.locator("#clusters .node .led.off").count()) === 1 &&
+    (await page.locator("#clusters .nmeta").first().textContent()).includes("offline"),
+);
+
+// --- 3. theme ----------------------------------------------------------------
+const themeBefore = await page.evaluate(() => document.documentElement.dataset.theme);
+await page.click("#theme");
+const themeAfter = await page.evaluate(() => document.documentElement.dataset.theme);
+ok(
+  "theme toggle flips + persists to localStorage",
+  themeBefore !== themeAfter &&
+    (await page.evaluate(() => localStorage.getItem("sm121-theme"))) === themeAfter,
+);
+await page.click("#theme");
+ok(
+  "theme toggles back",
+  (await page.evaluate(() => document.documentElement.dataset.theme)) === themeBefore,
+);
+
+// --- 4. chip switch ------------------------------------------------------------
+const chips = page.locator("#clusters .chip");
+ok("one chip active initially", (await page.locator("#clusters .chip.active").count()) === 1);
+await chips.nth(1).click();
+ok("chip click activates it", await chips.nth(1).evaluate((el) => el.classList.contains("active")));
+ok(
+  "panel switched to the active cluster",
+  (await page.locator("#clusters .cluster-head b").first().textContent()) === "fake2",
+);
+
+// --- 5. down-cluster action bar -------------------------------------------------
+const labels = await page.locator("#clusters .actions .abtn").allTextContents();
+ok(
+  "down cluster offers only 'power on' + 'switch' (no serve/stop)",
+  JSON.stringify(labels) === JSON.stringify(["power on", "switch"]),
+);
+
+// --- 6. power: type-the-name gate ------------------------------------------------
+await page.click("#clusters .abtn:has-text('power on')");
+ok("power dialog opens", await page.locator("#dlg").isVisible());
+ok("type-to-confirm input shown", await page.locator("#dlg-type").isVisible());
+ok("confirm hidden initially", !(await page.locator("#dlg-ok").isVisible()));
+await page.fill("#dlg-type", "wrong-name");
+ok("wrong name keeps confirm hidden", !(await page.locator("#dlg-ok").isVisible()));
+await page.fill("#dlg-type", "fake2");
+ok("exact cluster name arms confirm", await page.locator("#dlg-ok").isVisible());
+await page.keyboard.press("Escape");
+ok("escape closes the dialog", !(await page.locator("#dlg").isVisible()));
+
+// --- 7. no DOM churn on an identical state (anti-blink) --------------------------
+const html1 = await page.evaluate(() => document.getElementById("clusters").outerHTML);
+await page.evaluate(() => onState(state.snap)); // identical snapshot, not forced
+const html2 = await page.evaluate(() => document.getElementById("clusters").outerHTML);
+ok("identical snapshot: no DOM churn (no blink)", html1 === html2);
+
+// --- 8. serve/stop dialogs (controls stubbed; cancel only — no POST) --------------
+await page.evaluate(() => {
+  const sel = document.createElement("select");
+  sel.id = "recipe-fake2";
+  const o = document.createElement("option");
+  o.value = "deepseek-v4-flash";
+  o.textContent = "deepseek-v4-flash";
+  sel.appendChild(o);
+  document.body.appendChild(sel);
+  const cb = document.createElement("input");
+  cb.type = "checkbox";
+  cb.id = "dry-fake2";
+  cb.checked = false;
+  document.body.appendChild(cb);
+});
+await page.evaluate(() => doServe("fake2"));
+ok(
+  "serve confirm dialog names the recipe",
+  (await page.locator("#dlg").isVisible()) &&
+    (await page.locator("#dlg-title").textContent()).includes("deepseek-v4-flash"),
+);
+await page.click("#dlg-cancel");
+ok(
+  "serve cancel closes with no job created",
+  !(await page.locator("#dlg").isVisible()) && (await page.locator("#jobs .job").count()) === 0,
+);
+await page.evaluate(() => doStop("fake2"));
+ok(
+  "stop dialog reports no running containers",
+  (await page.locator("#dlg").isVisible()) &&
+    (await page.locator("#dlg-body").textContent()).includes("no containers detected"),
+);
+await page.click("#dlg-cancel");
+
+// --- no page JS errors across the whole flow -------------------------------------
+ok("no page JS errors", pageErrors.length === 0, pageErrors.join("; "));
+
+await browser.close();
+if (failed) {
+  console.log(`\n${failed} UI check(s) FAILED`);
+  Deno.exit(1);
+}
+console.log("\nall UI checks passed");