|
|
@@ -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");
|