| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328 |
- // 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,
- );
- // --- topology map (structure) ---------------------------------------------------
- // The fake fleet: cluster "fake" (n0) + "fake2" (n1), both lan-only (no
- // ib_ip, no entities) → 2 devices, 2 lan cables, 2 frames, the passive
- // 10G glyph; no HA switch glyphs.
- await page.waitForSelector("#topo .dev", { timeout: 15000 });
- await page.waitForTimeout(300); // let the SSE settle — no mid-rebuild tearing
- const tc = await page.evaluate(() => ({
- dev: document.querySelectorAll("#topo .dev").length,
- cab: document.querySelectorAll("#topo path.c-10g:not(.flow)").length,
- fr: document.querySelectorAll("#topo .cluster-frame").length,
- sw: document.querySelectorAll("#topo .sw").length,
- }));
- ok(
- "topo: 2 devices + 2 lan cables + 2 frames + passive 10G glyph",
- tc.dev === 2 && tc.cab === 2 && tc.fr === 2 && tc.sw === 1,
- JSON.stringify(tc),
- );
- // --- 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"),
- );
- ok(
- "stop dialog offers per-node scope",
- (await page.locator("#stopnode option").count()) === 2 &&
- (await page.locator("#stopnode option").last().textContent()).includes("n1"),
- );
- await page.click("#dlg-cancel");
- // --- 9. window manager ---------------------------------------------------------
- const before = await page.locator("#win-state").boundingBox();
- const barBox = await page.locator("#win-state .win-bar").boundingBox();
- await page.mouse.move(barBox.x + barBox.width / 2, barBox.y + 5);
- await page.mouse.down();
- await page.mouse.move(barBox.x + barBox.width / 2 + 80, barBox.y + 5 + 50, { steps: 5 });
- await page.mouse.up();
- const after = await page.locator("#win-state").boundingBox();
- ok(
- "titlebar drag moves the state window",
- Math.abs(after.x - (before.x + 80)) < 3 && Math.abs(after.y - (before.y + 50)) < 3,
- `dx=${(after.x - before.x).toFixed(1)} dy=${(after.y - before.y).toFixed(1)}`,
- );
- // --- 10. minimize / restore ------------------------------------------------------
- await page.click("#win-state .wbtn[data-act=min]");
- ok(
- "minimize hides the window and adds a dock chip",
- !(await page.locator("#win-state").isVisible()) &&
- (await page.locator("#dock .dock-chip:has-text('Cluster state')").count()) === 1,
- );
- await page.click("#dock .dock-chip:has-text('Cluster state')");
- ok(
- "dock chip restores the window",
- await page.locator("#win-state").isVisible(),
- );
- // --- 11. log window: spawn, SSE lines, pause, close ------------------------------
- await page.evaluate(() => openLog("fake", "n0", "deepseek-v4-flash"));
- await page.waitForSelector(".logwin", { timeout: 10000 });
- ok("log window spawned (attach l1)", (await page.locator(".logwin").count()) === 1);
- await page.evaluate(() => onLog({ type: "log", attach: "l1", line: "hello-from-ui-test" }));
- ok(
- "SSE log line lands in the window",
- (await page.locator(".logwin .logpre").textContent()).includes("hello-from-ui-test"),
- );
- await page.click(".logwin .logpause");
- await page.evaluate(() => onLog({ type: "log", attach: "l1", line: "while-paused" }));
- ok(
- "paused window buffers the line",
- !(await page.locator(".logwin .logpre").textContent()).includes("while-paused"),
- );
- await page.click(".logwin .logpause");
- ok(
- "unpause flushes the buffer",
- (await page.locator(".logwin .logpre").textContent()).includes("while-paused"),
- );
- await page.click(".logwin .wbtn[data-act=close]");
- ok(
- "closing the log window removes it (detach sent)",
- (await page.locator(".logwin").count()) === 0,
- );
- // --- 11b. dead stream: 'stream ended' note + second click re-attaches -----------
- // (fake fleet: the SSH stream to the refused port dies instantly, so the full
- // dead-stream lifecycle is deterministic here)
- await page.evaluate(() => openLog("fake", "n0", "deepseek-v4-flash"));
- await page.waitForSelector(".logwin", { timeout: 10000 });
- await page.waitForFunction(
- () => (document.querySelector(".logwin .lognote").textContent || "").includes("stream ended"),
- { timeout: 8000 },
- );
- ok("dead stream shows the 'stream ended' note", true);
- // (dispatchEvent — the open log window overlaps the state window and would
- // swallow a physical click at the chip's coordinates)
- await page.locator("#clusters .chip").nth(0).dispatchEvent("click"); // back to cluster 'fake' (check 4 left it on 'fake2')
- await page.locator("#clusters .node").first().dispatchEvent("click");
- const flashed = await page.evaluate(() => document.querySelector(".logwin").classList.contains("flash"));
- ok(
- "second click gives feedback (flash) without a duplicate window",
- flashed && (await page.locator(".logwin").count()) === 1,
- );
- // --- 12. node click: log window with no containers ---------------------------------
- for (const btn of await page.locator(".logwin .wbtn[data-act=close]").all()) {
- await btn.click(); // clear any windows left by earlier checks
- }
- await page.locator("#clusters .chip").nth(1).click(); // fake2 / n1
- await page.click("#clusters .node");
- ok(
- "node click opens a log window (no containers note)",
- (await page.locator(".logwin").count()) === 1 &&
- (await page.locator(".logwin .lognote").textContent()).includes("no sm121 containers"),
- );
- ok(
- "visible logs button exists on the node row",
- (await page.locator("#clusters .node .logsbtn").count()) >= 1,
- );
- await page.click("#clusters .node .logsbtn");
- ok(
- "logs button on an already-open node fronts, not duplicates",
- (await page.locator(".logwin").count()) === 1,
- );
- // Regression (win-vs-object compare in onClosed): close used to leave the
- // logWins entry behind, so the re-open click just 'fronted' the detached
- // window and nothing appeared.
- await page.click(".logwin .wbtn[data-act=close]");
- ok("closing a log window removes it", (await page.locator(".logwin").count()) === 0);
- await page.click("#clusters .node .logsbtn");
- ok(
- "re-open after close shows a fresh window",
- (await page.locator(".logwin").count()) === 1,
- );
- // --- 13. raw window (via the dock + new-window menu) -------------------------------
- await page.click("#dock .plus");
- ok(
- "dock + menu lists state / jobs / logs / raw",
- (await page.locator(".dockmenu .dock-chip").count()) === 5, // topo / state / jobs / logs / raw
- );
- await page.click('.dockmenu .dock-chip:has-text("raw")');
- await page.waitForSelector(".rawwin", { timeout: 10000 });
- ok("raw window opens via the new-window menu", (await page.locator(".rawwin").count()) === 1);
- await page.selectOption(".rawwin select", "effective");
- await page.waitForFunction(
- () => (document.querySelector(".rawwin .rawpre").textContent || "").includes('"clusters"'),
- { timeout: 5000 },
- );
- ok("raw: effective shows the merged config JSON", true);
- await page.selectOption(".rawwin select", "config");
- await page.waitForFunction(
- () => (document.querySelector(".rawwin .rawpre").textContent || "").includes("ssh_user"),
- { timeout: 5000 },
- );
- ok("raw: config shows the file verbatim", true);
- ok(
- "skeleton shimmer cleared after first state",
- (await page.locator(".skeleton").count()) === 0,
- );
- const monoFont = await page.evaluate(
- () => getComputedStyle(document.querySelector(".rawwin .logpre")).fontFamily,
- );
- ok(
- "monospace content uses the alacritty font (DMMono Nerd Font)",
- monoFont.replace(/"/g, "").startsWith("DMMono Nerd Font"),
- monoFont,
- );
- // --- topology map (interaction) ---------------------------------------------------
- // frame chip click activates the cluster (frames sit behind devices —
- // the chip is the always-clickable part); hide the other windows first —
- // earlier test steps leave log/raw windows stacked over the map
- await page.evaluate(() => {
- for (const w of document.querySelectorAll(".win")) if (w.id !== "win-topo") w.style.display = "none";
- });
- await page.click('#topo [data-cluster="fake2"] .fchip');
- await page.waitForFunction(
- () => document.querySelector("#clusters .chip.active")?.textContent?.includes("fake2"),
- { timeout: 5000 },
- );
- ok("topo: frame chip click activates cluster", true);
- // --- 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");
|