// 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, ); // the header carries window chrome only while something IS maximized // (`display: flex` used to defeat the `hidden` attribute → permanent litter) ok( "header is bare until a window is maximized", await page.evaluate(() => document.querySelector("#maxinfo").getBoundingClientRect().width === 0), ); // --- 13. raw window (via the dock + new-window menu) ------------------------------- await page.click("#dock .plus"); ok( "dock + menu lists topo / state / jobs / recipes / logs / raw", (await page.locator(".dockmenu .dock-chip").count()) === 6, ); 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"'), null, { 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 .fchip[data-cluster="fake2"]'); await page.waitForFunction( () => document.querySelector("#clusters .chip.active")?.textContent?.includes("fake2"), { timeout: 5000 }, ); ok("topo: frame chip click activates cluster", true); // --- topology power policy: an HA-OFF switch can never have green ports --- // the pure fabPortOn policy extracted from the live page (between the // TOPOPORT markers) and probed under deno, like the report's notifDiff const html = await page.content(); const s = html.indexOf("/*TOPOPORT:START*/"), e = html.indexOf("/*TOPOPORT:END*/"); ok("topo: fabPortOn policy present in page", s >= 0 && e > s); if (s >= 0 && e > s) { const fab = new Function(html.slice(s + "/*TOPOPORT:START*/".length, e) + "\nreturn fabPortOn;")(); ok("topo: switch on + occupied -> port green", fab(true, true) === true); ok("topo: switch OFF + occupied -> port NOT green", fab(false, true) === false); ok("topo: switch OFF + idle -> port not green", fab(false, false) === false); ok("topo: switch unknown + occupied -> green (inconclusive)", fab(undefined, true) === true); ok("topo: switch on + idle -> port not green", fab(true, false) === false); } // --- 14. recipes panel: the `sm121 report --serve` page, mounted at /recipes ----- await page.click("#dock .plus"); await page.click('.dockmenu .dock-chip:text-is("recipes")'); await page.waitForSelector("#win-recipes iframe", { timeout: 10000 }); const recipes = page.frameLocator("#win-recipes iframe"); await recipes.locator("table").first().waitFor({ timeout: 10000 }); ok("recipes: panel page loads in its window", (await page.locator("#win-recipes iframe").count()) === 1); ok("recipes: table rendered", (await recipes.locator("tr.row").count()) > 0); ok( "recipes: its own SSE stream is live behind the prefix", (await recipes.locator("#live").innerText()).includes("live"), ); // the iframe inherits the dashboard's colour scheme (report page is // `light dark` and would otherwise follow the OS) const scheme = await page.evaluate(() => getComputedStyle(document.querySelector("#win-recipes iframe")).colorScheme, ); ok("recipes: iframe colour-scheme follows the theme", scheme === "light" || scheme === "dark", scheme); // the report page's own paths move with the mount (dashboard owns /events) ok( "recipes: iframe loads the mounted panel", (await page.locator("#win-recipes iframe").getAttribute("src")) === "/recipes", ); // hosted copy drops its own branding (the window bar names it) and keeps // its live chip only as a warning — the dashboard header already says live ok( "recipes: embedded page hides its own title", (await recipes.locator("h1").isVisible()) === false, ); ok( "recipes: embedded page drops its live chip (same server, same liveness)", (await recipes.locator("#live").evaluate((e) => getComputedStyle(e).display)) === "none", ); ok( "recipes: embedded page keeps its filter controls", (await recipes.locator("#bar").isVisible()) === true, ); ok( "recipes: body is marked as hosted (one implementation, adapted chrome)", (await recipes.locator("body").getAttribute("class")) === "embed", ); // minimize must land in the dock (this window used to vanish: only the // three named windows were in the dock's registry) await page.click("#win-recipes .win-bar .wbtn[data-act='min']"); ok( "recipes: minimize leaves a dock chip", (await page.locator('#dock .dock-chip:has-text("Recipes")').count()) === 1, ); await page.click('#dock .dock-chip:has-text("Recipes")'); await page.waitForTimeout(400); // winIn animation replays on re-display ok( "recipes: dock chip restores the window", (await page.evaluate(() => getComputedStyle(document.querySelector("#win-recipes")).display)) !== "none", ); // maximize fills the layer, a second click restores the old geometry const wBefore = await page.evaluate( () => Math.round(document.querySelector("#win-recipes").getBoundingClientRect().width), ); await page.click("#win-recipes .win-bar .wbtn[data-act='max']"); await page.waitForTimeout(400); const m = await page.evaluate(() => { const w = document.querySelector("#win-recipes"); const cs = getComputedStyle(w); const r = w.getBoundingClientRect(); const lr = document.querySelector("#winlayer").getBoundingClientRect(); return { w: [Math.round(r.width), Math.round(r.height)], l: [Math.round(lr.width), Math.round(lr.height)], strip: Math.round(document.querySelector("#dock").getBoundingClientRect().top - r.bottom), border: cs.borderTopWidth, radius: cs.borderRadius, label: document.querySelector("#maxinfo").getBoundingClientRect().width === 0 ? "" : document.querySelector("#max-title").textContent, }; }); ok( "recipes: maximize fills the layer flush (no strip above the dock)", m.strip === 0 && Math.abs(m.w[0] - m.l[0]) < 2 && Math.abs(m.w[1] - m.l[1]) < 2, JSON.stringify(m), ); ok("recipes: maximized window has no frame", m.border === "0px" && m.radius === "0px", `${m.border} / ${m.radius}`); ok( "recipes: maximized window keeps no titlebar (title lives in the header)", (await page.evaluate(() => getComputedStyle(document.querySelector("#win-recipes .win-bar")).display)) === "none", ); ok( "recipes: maximized content starts at the layer top", await page.evaluate( () => Math.abs( document.querySelector("#win-recipes .win-body").getBoundingClientRect().top - document.querySelector("#winlayer").getBoundingClientRect().top, ) < 2, ), ); ok("recipes: header carries the window controls", (await page.locator("#maxinfo .wbtn").count()) === 3); ok("recipes: maximized title moves into the header", m.label === "Recipes", m.label); // the dock stays reachable under a maximized window ok( "recipes: dock stays usable while maximized", await page.evaluate(() => { const b = document.querySelector("#dock").getBoundingClientRect(); return !!document.elementFromPoint(b.x + 20, b.y + b.height / 2)?.closest("#dock"); }), ); await page.click("#max-restore"); // header restore button await page.waitForTimeout(400); ok( "recipes: header button un-maximizes back to the old size", Math.abs( (await page.evaluate(() => Math.round(document.querySelector("#win-recipes").getBoundingClientRect().width))) - wBefore, ) < 2, ); ok( "recipes: header label cleared after restore", await page.evaluate(() => document.querySelector("#maxinfo").getBoundingClientRect().width === 0), ); // header controls must work while the window has no titlebar of its own await page.click("#win-recipes .win-bar .wbtn[data-act='max']"); await page.waitForTimeout(400); await page.click("#max-min"); await page.waitForTimeout(200); ok( "recipes: header minimize hides the window but keeps its maximize state (no dock chip)", (await page.locator('#dock .dock-chip:has-text("Recipes")').count()) === 0 && (await page.evaluate(() => { const w = document.querySelector("#win-recipes"); return w.style.display === "none" && w.classList.contains("max"); })), ); // the ▦ switcher lists the hidden maximized window and brings it back full-screen await page.click("#winmenu"); await page.waitForTimeout(200); ok( "recipes: switcher lists the hidden maximized window", (await page.locator(".winmenu .dock-chip:has-text('Recipes')").count()) === 1, ); await page.click(".winmenu .dock-chip:has-text('Recipes')"); await page.waitForTimeout(400); ok( "recipes: switcher shows it maximized again (full layer)", await page.evaluate(() => { const w = document.querySelector("#win-recipes"); const lr = document.querySelector("#winlayer").getBoundingClientRect(); return w.style.display !== "none" && w.classList.contains("max") && Math.abs(w.getBoundingClientRect().width - lr.width) < 2; }), ); await page.evaluate(() => document.querySelector("#win-recipes").style.display = "none"); // the map is a fixed-aspect drawing: its window is sized to it, otherwise // the SVG letterboxes and the panel is half empty const band = await page.evaluate(() => { const svg = document.querySelector("#win-topo svg"); const body = document.querySelector("#win-topo .win-body"); const vb = svg.viewBox.baseVal; const bb = body.getBoundingClientRect(); const scale = Math.min(bb.width / vb.width, bb.height / vb.height); return Math.round((bb.height - vb.height * scale) / 2); }); ok("topo: window fits the drawing (no letterbox bands)", band < 8, String(band)); // fan glyph: the rotation must be hub-centered (fill-box origin wobbled it) { const fanHub = await page.evaluate(() => { const h = document.querySelector("#topo .fan .hub"); if (!h) return null; const r = h.getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }); if (fanHub) { await page.waitForTimeout(260); const fanHub2 = await page.evaluate(() => { const h = document.querySelector("#topo .fan .hub"); const r = h.getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; }); const drift = Math.hypot(fanHub[0] - fanHub2[0], fanHub[1] - fanHub2[1]); ok("topo: fan spin is hub-centered (no wobble)", drift < 2, `${drift.toFixed(2)}px drift`); } } // the default layout ends flush at the dock: px geometry is clamped to the // layer, which shrinks when the dock (a sibling) becomes visible const flush = await page.evaluate(() => { const dock = document.querySelector("#dock").getBoundingClientRect(); const shown = [...document.querySelectorAll(".win")].filter( (w) => !w.classList.contains("max") && getComputedStyle(w).display !== "none", ); return Math.round(dock.top - Math.max(...shown.map((w) => w.getBoundingClientRect().bottom))); }); ok("windows end flush at the dock strip (no dead space)", Math.abs(flush) <= 1, String(flush)); // --- 15. window layout survives a reload (localStorage) --------------------------- await page.evaluate(() => { const t = document.querySelector("#win-topo"); t.style.left = "40px"; t.style.top = "20px"; t.style.width = "700px"; t.style.height = "300px"; document.querySelector("#win-state .win-bar .wbtn[data-act='min']").click(); document.querySelector("#win-jobs .win-bar .wbtn[data-act='close']").click(); }); await page.click("#dock .plus"); await page.click('.dockmenu .dock-chip:text-is("recipes")'); await page.waitForSelector("#win-recipes iframe"); await page.waitForTimeout(400); // the window may already come back maximized (state is kept) — only // maximize when it isn't (the button is a toggle) await page.evaluate(() => { const w = document.querySelector("#win-recipes"); if (!w.classList.contains("max")) document.querySelector("#win-recipes .win-bar .wbtn[data-act='max']").click(); }); await page.waitForTimeout(500); const laidOut = await page.evaluate(() => ({ saved: JSON.parse(localStorage.getItem("sm121-wins") || "null"), topo: (() => { const b = document.querySelector("#win-topo").getBoundingClientRect(); return [Math.round(b.x), Math.round(b.y), Math.round(b.width), Math.round(b.height)]; })(), })); ok("layout: state written to localStorage", !!laidOut.saved?.topo?.width, JSON.stringify(laidOut.saved?.topo)); await page.reload({ waitUntil: "domcontentloaded" }); await page.waitForTimeout(2500); const back = await page.evaluate(() => { const box = (sel) => { const e = document.querySelector(sel); if (!e) return null; const b = e.getBoundingClientRect(); return [Math.round(b.x), Math.round(b.y), Math.round(b.width), Math.round(b.height)]; }; return { topo: box("#win-topo"), stateMin: getComputedStyle(document.querySelector("#win-state")).display === "none", jobsClosed: getComputedStyle(document.querySelector("#win-jobs")).display === "none", recipesMax: document.querySelector("#win-recipes")?.classList.contains("max") ?? false, chips: [...document.querySelectorAll("#dock .dock-chip")].map((c) => c.textContent), header: document.querySelector("#maxinfo").getBoundingClientRect().width === 0 ? "" : document.querySelector("#max-title").textContent, }; }); ok( "layout: geometry restored after reload", JSON.stringify(back.topo) === JSON.stringify(laidOut.topo), `${JSON.stringify(laidOut.topo)} -> ${JSON.stringify(back.topo)}`, ); ok("layout: minimized window stays minimized", back.stateMin); ok("layout: closed window stays closed, with a dock chip to reopen", back.jobsClosed && back.chips.includes("+ Jobs"), JSON.stringify(back.chips)); ok("layout: maximized recipes window restored maximized", back.recipesMax && back.header === "Recipes", back.header); // --- topology cable mode: direct (S-curve) and bus + bridges -------------------- { await page.waitForFunction(() => state.snap && state.snap.topo && state.snap.topo.nodes); const hasRow1 = await page.evaluate(() => state.snap.topo.nodes.length > 4); const fab200 = await page.evaluate(() => [...document.querySelectorAll("#topo path.c-200g")].map((p) => p.getAttribute("d")), ); const row1 = fab200.filter((d) => d && d.includes(" 386 ")); if (hasRow1) { ok("topo: row-1 fabric cables present", row1.length > 0, `of ${fab200.length}`); ok( "topo: row-1 fabric cable approaches the port vertically (S-curve, no side entry)", row1.every((d) => / C .* 366 .* 346$/.test(d)), row1.join(" "), ); } else { ok("topo: no bottom-row devices in this fleet (S-curve path n/a)", row1.length === 0); } await page.evaluate(() => document.querySelector("#topo [data-topobus]").dispatchEvent(new MouseEvent("click", { bubbles: true }))); await page.waitForTimeout(400); const bus = await page.evaluate(() => { const t = state.snap.topo; const lane = []; for (const l of t.links.filter((x) => x.kind === "200g")) { const fi = tgIdx(t, l.a); if (fi < 4) continue; const px = tgPos(fi).px; const dx = tgPos(tgIdx(t, l.b)).dcx; lane.push({ x1: Math.min(dx, px), x2: Math.max(dx, px) }); } let expect = 0; for (const l of t.links.filter((x) => x.kind === "10g")) { const bi = tgIdx(t, l.b); if (bi < 4) continue; const x = tgPos(bi).dcx; if (x >= 370 && x <= 690) continue; if (lane.some((h) => x > h.x1 + 2 && x < h.x2 - 2)) expect++; } return { legend: document.querySelector("#topo [data-topobus]").textContent, manhattan: [...document.querySelectorAll("#topo path.c-200g")].filter((p) => (p.getAttribute("d") || "").includes(" L ")).length, bridges: document.querySelectorAll("#topo path.bridge").length, expect, bridgesDrawn: [...document.querySelectorAll("#topo path.bridge")].every((b) => (b.getAttribute("d") || "").includes("A 5 5")), }; }); ok("topo: bus mode active (legend)", bus.legend.includes("bus"), bus.legend); if (hasRow1) ok("topo: bus cables run Manhattan (L segments)", bus.manhattan >= 1, `manhattan=${bus.manhattan}`); ok("topo: bridges rendered exactly at strict crossings", bus.bridges === bus.expect && bus.bridgesDrawn, `bridges=${bus.bridges} expect=${bus.expect}`); await page.evaluate(() => document.querySelector("#topo [data-topobus]").dispatchEvent(new MouseEvent("click", { bubbles: true }))); await page.waitForTimeout(400); const back2 = await page.evaluate(() => ({ legend: document.querySelector("#topo [data-topobus]").textContent, curved: [...document.querySelectorAll("#topo path.c-200g")].filter((p) => (p.getAttribute("d") || "").includes(" C ")).length, })); ok( "topo: back to direct mode", back2.legend.includes("direct") && (fab200.length === 0 || back2.curved >= 1), JSON.stringify(back2), ); ok("topo: mode choice persisted", await page.evaluate(() => localStorage.getItem("sm121-topobus") === "0")); } // --- 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");