ui.playwright.mjs 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  1. // Playwright checks for the embedded web UI (src/web/ui.html).
  2. // Runs against a live `sm121 web` server (base URL in $SM121_WEB_URL,
  3. // provided by the Rust test `ui_playwright_checks` in src/web/tests.rs,
  4. // which boots the server on an ephemeral port with a fake offline fleet).
  5. //
  6. // No npm: uses deno's npm-package specifiers. One-time browser setup:
  7. // deno run -A src/web/pw-install.mjs
  8. //
  9. // Exits 0 with "SKIP" lines when the browser is missing; non-zero on
  10. // real failures.
  11. import { chromium } from "npm:playwright@1.62.1";
  12. const BASE = Deno.env.get("SM121_WEB_URL");
  13. if (!BASE) {
  14. console.log("SKIP SM121_WEB_URL not set (run via cargo test ui_playwright_checks)");
  15. Deno.exit(0);
  16. }
  17. let failed = 0;
  18. const ok = (name, cond, extra = "") => {
  19. if (!cond) failed++;
  20. console.log((cond ? "ok " : "FAIL ") + name + (!cond && extra ? " — " + extra : ""));
  21. };
  22. let browser;
  23. try {
  24. browser = await chromium.launch();
  25. } catch (e) {
  26. console.log("SKIP playwright browser not installed — run: deno run -A src/web/pw-install.mjs");
  27. console.log(String(e).split("\n").find((l) => l.trim()) || "");
  28. Deno.exit(0);
  29. }
  30. const page = await (await browser.newContext()).newPage();
  31. const pageErrors = [];
  32. page.on("pageerror", (e) => pageErrors.push(String(e)));
  33. // (waitUntil: "domcontentloaded" — the page is one self-contained document;
  34. // the "load" event does not fire reliably when Playwright runs under deno)
  35. await page.goto(BASE, { waitUntil: "domcontentloaded" });
  36. await page.waitForSelector("#clusters .cluster", { timeout: 15000 });
  37. // --- 1. load ---------------------------------------------------------------
  38. ok(
  39. "page loads: cluster panel + 2 chips + jobs empty state",
  40. (await page.locator("#clusters .cluster").count()) === 1 &&
  41. (await page.locator("#clusters .chip").count()) === 2 &&
  42. (await page.locator("#jobs .jobs-empty").count()) === 1,
  43. );
  44. // --- topology map (structure) ---------------------------------------------------
  45. // The fake fleet: cluster "fake" (n0) + "fake2" (n1), both lan-only (no
  46. // ib_ip, no entities) → 2 devices, 2 lan cables, 2 frames, the passive
  47. // 10G glyph; no HA switch glyphs.
  48. await page.waitForSelector("#topo .dev", { timeout: 15000 });
  49. await page.waitForTimeout(300); // let the SSE settle — no mid-rebuild tearing
  50. const tc = await page.evaluate(() => ({
  51. dev: document.querySelectorAll("#topo .dev").length,
  52. cab: document.querySelectorAll("#topo path.c-10g:not(.flow)").length,
  53. fr: document.querySelectorAll("#topo .cluster-frame").length,
  54. sw: document.querySelectorAll("#topo .sw").length,
  55. }));
  56. ok(
  57. "topo: 2 devices + 2 lan cables + 2 frames + passive 10G glyph",
  58. tc.dev === 2 && tc.cab === 2 && tc.fr === 2 && tc.sw === 1,
  59. JSON.stringify(tc),
  60. );
  61. // --- 2. offline node rendering ----------------------------------------------
  62. ok(
  63. "offline node: red LED + 'offline' meta",
  64. (await page.locator("#clusters .node .led.off").count()) === 1 &&
  65. (await page.locator("#clusters .nmeta").first().textContent()).includes("offline"),
  66. );
  67. // --- 3. theme ----------------------------------------------------------------
  68. const themeBefore = await page.evaluate(() => document.documentElement.dataset.theme);
  69. await page.click("#theme");
  70. const themeAfter = await page.evaluate(() => document.documentElement.dataset.theme);
  71. ok(
  72. "theme toggle flips + persists to localStorage",
  73. themeBefore !== themeAfter &&
  74. (await page.evaluate(() => localStorage.getItem("sm121-theme"))) === themeAfter,
  75. );
  76. await page.click("#theme");
  77. ok(
  78. "theme toggles back",
  79. (await page.evaluate(() => document.documentElement.dataset.theme)) === themeBefore,
  80. );
  81. // --- 4. chip switch ------------------------------------------------------------
  82. const chips = page.locator("#clusters .chip");
  83. ok("one chip active initially", (await page.locator("#clusters .chip.active").count()) === 1);
  84. await chips.nth(1).click();
  85. ok("chip click activates it", await chips.nth(1).evaluate((el) => el.classList.contains("active")));
  86. ok(
  87. "panel switched to the active cluster",
  88. (await page.locator("#clusters .cluster-head b").first().textContent()) === "fake2",
  89. );
  90. // --- 5. down-cluster action bar -------------------------------------------------
  91. const labels = await page.locator("#clusters .actions .abtn").allTextContents();
  92. ok(
  93. "down cluster offers only 'power on' + 'switch' (no serve/stop)",
  94. JSON.stringify(labels) === JSON.stringify(["power on", "switch"]),
  95. );
  96. // --- 6. power: type-the-name gate ------------------------------------------------
  97. await page.click("#clusters .abtn:has-text('power on')");
  98. ok("power dialog opens", await page.locator("#dlg").isVisible());
  99. ok("type-to-confirm input shown", await page.locator("#dlg-type").isVisible());
  100. ok("confirm hidden initially", !(await page.locator("#dlg-ok").isVisible()));
  101. await page.fill("#dlg-type", "wrong-name");
  102. ok("wrong name keeps confirm hidden", !(await page.locator("#dlg-ok").isVisible()));
  103. await page.fill("#dlg-type", "fake2");
  104. ok("exact cluster name arms confirm", await page.locator("#dlg-ok").isVisible());
  105. await page.keyboard.press("Escape");
  106. ok("escape closes the dialog", !(await page.locator("#dlg").isVisible()));
  107. // --- 7. no DOM churn on an identical state (anti-blink) --------------------------
  108. const html1 = await page.evaluate(() => document.getElementById("clusters").outerHTML);
  109. await page.evaluate(() => onState(state.snap)); // identical snapshot, not forced
  110. const html2 = await page.evaluate(() => document.getElementById("clusters").outerHTML);
  111. ok("identical snapshot: no DOM churn (no blink)", html1 === html2);
  112. // --- 8. serve/stop dialogs (controls stubbed; cancel only — no POST) --------------
  113. await page.evaluate(() => {
  114. const sel = document.createElement("select");
  115. sel.id = "recipe-fake2";
  116. const o = document.createElement("option");
  117. o.value = "deepseek-v4-flash";
  118. o.textContent = "deepseek-v4-flash";
  119. sel.appendChild(o);
  120. document.body.appendChild(sel);
  121. const cb = document.createElement("input");
  122. cb.type = "checkbox";
  123. cb.id = "dry-fake2";
  124. cb.checked = false;
  125. document.body.appendChild(cb);
  126. });
  127. await page.evaluate(() => doServe("fake2"));
  128. ok(
  129. "serve confirm dialog names the recipe",
  130. (await page.locator("#dlg").isVisible()) &&
  131. (await page.locator("#dlg-title").textContent()).includes("deepseek-v4-flash"),
  132. );
  133. await page.click("#dlg-cancel");
  134. ok(
  135. "serve cancel closes with no job created",
  136. !(await page.locator("#dlg").isVisible()) && (await page.locator("#jobs .job").count()) === 0,
  137. );
  138. await page.evaluate(() => doStop("fake2"));
  139. ok(
  140. "stop dialog reports no running containers",
  141. (await page.locator("#dlg").isVisible()) &&
  142. (await page.locator("#dlg-body").textContent()).includes("no containers detected"),
  143. );
  144. ok(
  145. "stop dialog offers per-node scope",
  146. (await page.locator("#stopnode option").count()) === 2 &&
  147. (await page.locator("#stopnode option").last().textContent()).includes("n1"),
  148. );
  149. await page.click("#dlg-cancel");
  150. // --- 9. window manager ---------------------------------------------------------
  151. const before = await page.locator("#win-state").boundingBox();
  152. const barBox = await page.locator("#win-state .win-bar").boundingBox();
  153. await page.mouse.move(barBox.x + barBox.width / 2, barBox.y + 5);
  154. await page.mouse.down();
  155. await page.mouse.move(barBox.x + barBox.width / 2 + 80, barBox.y + 5 + 50, { steps: 5 });
  156. await page.mouse.up();
  157. const after = await page.locator("#win-state").boundingBox();
  158. ok(
  159. "titlebar drag moves the state window",
  160. Math.abs(after.x - (before.x + 80)) < 3 && Math.abs(after.y - (before.y + 50)) < 3,
  161. `dx=${(after.x - before.x).toFixed(1)} dy=${(after.y - before.y).toFixed(1)}`,
  162. );
  163. // --- 10. minimize / restore ------------------------------------------------------
  164. await page.click("#win-state .wbtn[data-act=min]");
  165. ok(
  166. "minimize hides the window and adds a dock chip",
  167. !(await page.locator("#win-state").isVisible()) &&
  168. (await page.locator("#dock .dock-chip:has-text('Cluster state')").count()) === 1,
  169. );
  170. await page.click("#dock .dock-chip:has-text('Cluster state')");
  171. ok(
  172. "dock chip restores the window",
  173. await page.locator("#win-state").isVisible(),
  174. );
  175. // --- 11. log window: spawn, SSE lines, pause, close ------------------------------
  176. await page.evaluate(() => openLog("fake", "n0", "deepseek-v4-flash"));
  177. await page.waitForSelector(".logwin", { timeout: 10000 });
  178. ok("log window spawned (attach l1)", (await page.locator(".logwin").count()) === 1);
  179. await page.evaluate(() => onLog({ type: "log", attach: "l1", line: "hello-from-ui-test" }));
  180. ok(
  181. "SSE log line lands in the window",
  182. (await page.locator(".logwin .logpre").textContent()).includes("hello-from-ui-test"),
  183. );
  184. await page.click(".logwin .logpause");
  185. await page.evaluate(() => onLog({ type: "log", attach: "l1", line: "while-paused" }));
  186. ok(
  187. "paused window buffers the line",
  188. !(await page.locator(".logwin .logpre").textContent()).includes("while-paused"),
  189. );
  190. await page.click(".logwin .logpause");
  191. ok(
  192. "unpause flushes the buffer",
  193. (await page.locator(".logwin .logpre").textContent()).includes("while-paused"),
  194. );
  195. await page.click(".logwin .wbtn[data-act=close]");
  196. ok(
  197. "closing the log window removes it (detach sent)",
  198. (await page.locator(".logwin").count()) === 0,
  199. );
  200. // --- 11b. dead stream: 'stream ended' note + second click re-attaches -----------
  201. // (fake fleet: the SSH stream to the refused port dies instantly, so the full
  202. // dead-stream lifecycle is deterministic here)
  203. await page.evaluate(() => openLog("fake", "n0", "deepseek-v4-flash"));
  204. await page.waitForSelector(".logwin", { timeout: 10000 });
  205. await page.waitForFunction(
  206. () => (document.querySelector(".logwin .lognote").textContent || "").includes("stream ended"),
  207. { timeout: 8000 },
  208. );
  209. ok("dead stream shows the 'stream ended' note", true);
  210. // (dispatchEvent — the open log window overlaps the state window and would
  211. // swallow a physical click at the chip's coordinates)
  212. await page.locator("#clusters .chip").nth(0).dispatchEvent("click"); // back to cluster 'fake' (check 4 left it on 'fake2')
  213. await page.locator("#clusters .node").first().dispatchEvent("click");
  214. const flashed = await page.evaluate(() => document.querySelector(".logwin").classList.contains("flash"));
  215. ok(
  216. "second click gives feedback (flash) without a duplicate window",
  217. flashed && (await page.locator(".logwin").count()) === 1,
  218. );
  219. // --- 12. node click: log window with no containers ---------------------------------
  220. for (const btn of await page.locator(".logwin .wbtn[data-act=close]").all()) {
  221. await btn.click(); // clear any windows left by earlier checks
  222. }
  223. await page.locator("#clusters .chip").nth(1).click(); // fake2 / n1
  224. await page.click("#clusters .node");
  225. ok(
  226. "node click opens a log window (no containers note)",
  227. (await page.locator(".logwin").count()) === 1 &&
  228. (await page.locator(".logwin .lognote").textContent()).includes("no sm121 containers"),
  229. );
  230. ok(
  231. "visible logs button exists on the node row",
  232. (await page.locator("#clusters .node .logsbtn").count()) >= 1,
  233. );
  234. await page.click("#clusters .node .logsbtn");
  235. ok(
  236. "logs button on an already-open node fronts, not duplicates",
  237. (await page.locator(".logwin").count()) === 1,
  238. );
  239. // Regression (win-vs-object compare in onClosed): close used to leave the
  240. // logWins entry behind, so the re-open click just 'fronted' the detached
  241. // window and nothing appeared.
  242. await page.click(".logwin .wbtn[data-act=close]");
  243. ok("closing a log window removes it", (await page.locator(".logwin").count()) === 0);
  244. await page.click("#clusters .node .logsbtn");
  245. ok(
  246. "re-open after close shows a fresh window",
  247. (await page.locator(".logwin").count()) === 1,
  248. );
  249. // the header carries window chrome only while something IS maximized
  250. // (`display: flex` used to defeat the `hidden` attribute → permanent litter)
  251. ok(
  252. "header is bare until a window is maximized",
  253. await page.evaluate(() => document.querySelector("#maxinfo").getBoundingClientRect().width === 0),
  254. );
  255. // --- 13. raw window (via the dock + new-window menu) -------------------------------
  256. await page.click("#dock .plus");
  257. ok(
  258. "dock + menu lists topo / state / jobs / recipes / logs / raw",
  259. (await page.locator(".dockmenu .dock-chip").count()) === 6,
  260. );
  261. await page.click('.dockmenu .dock-chip:has-text("raw")');
  262. await page.waitForSelector(".rawwin", { timeout: 10000 });
  263. ok("raw window opens via the new-window menu", (await page.locator(".rawwin").count()) === 1);
  264. await page.selectOption(".rawwin select", "effective");
  265. await page.waitForFunction(
  266. () => (document.querySelector(".rawwin .rawpre").textContent || "").includes('"clusters"'),
  267. null,
  268. { timeout: 5000 },
  269. );
  270. ok("raw: effective shows the merged config JSON", true);
  271. await page.selectOption(".rawwin select", "config");
  272. await page.waitForFunction(
  273. () => (document.querySelector(".rawwin .rawpre").textContent || "").includes("ssh_user"),
  274. { timeout: 5000 },
  275. );
  276. ok("raw: config shows the file verbatim", true);
  277. ok(
  278. "skeleton shimmer cleared after first state",
  279. (await page.locator(".skeleton").count()) === 0,
  280. );
  281. const monoFont = await page.evaluate(
  282. () => getComputedStyle(document.querySelector(".rawwin .logpre")).fontFamily,
  283. );
  284. ok(
  285. "monospace content uses the alacritty font (DMMono Nerd Font)",
  286. monoFont.replace(/"/g, "").startsWith("DMMono Nerd Font"),
  287. monoFont,
  288. );
  289. // --- topology map (interaction) ---------------------------------------------------
  290. // frame chip click activates the cluster (frames sit behind devices —
  291. // the chip is the always-clickable part); hide the other windows first —
  292. // earlier test steps leave log/raw windows stacked over the map
  293. await page.evaluate(() => {
  294. for (const w of document.querySelectorAll(".win")) if (w.id !== "win-topo") w.style.display = "none";
  295. });
  296. await page.click('#topo .fchip[data-cluster="fake2"]');
  297. await page.waitForFunction(
  298. () => document.querySelector("#clusters .chip.active")?.textContent?.includes("fake2"),
  299. { timeout: 5000 },
  300. );
  301. ok("topo: frame chip click activates cluster", true);
  302. // --- topology power policy: an HA-OFF switch can never have green ports ---
  303. // the pure fabPortOn policy extracted from the live page (between the
  304. // TOPOPORT markers) and probed under deno, like the report's notifDiff
  305. const html = await page.content();
  306. const s = html.indexOf("/*TOPOPORT:START*/"), e = html.indexOf("/*TOPOPORT:END*/");
  307. ok("topo: fabPortOn policy present in page", s >= 0 && e > s);
  308. if (s >= 0 && e > s) {
  309. const fab = new Function(html.slice(s + "/*TOPOPORT:START*/".length, e) + "\nreturn fabPortOn;")();
  310. ok("topo: switch on + occupied -> port green", fab(true, true) === true);
  311. ok("topo: switch OFF + occupied -> port NOT green", fab(false, true) === false);
  312. ok("topo: switch OFF + idle -> port not green", fab(false, false) === false);
  313. ok("topo: switch unknown + occupied -> green (inconclusive)", fab(undefined, true) === true);
  314. ok("topo: switch on + idle -> port not green", fab(true, false) === false);
  315. }
  316. // --- 14. recipes panel: the `sm121 report --serve` page, mounted at /recipes -----
  317. await page.click("#dock .plus");
  318. await page.click('.dockmenu .dock-chip:text-is("recipes")');
  319. await page.waitForSelector("#win-recipes iframe", { timeout: 10000 });
  320. const recipes = page.frameLocator("#win-recipes iframe");
  321. await recipes.locator("table").first().waitFor({ timeout: 10000 });
  322. ok("recipes: panel page loads in its window", (await page.locator("#win-recipes iframe").count()) === 1);
  323. ok("recipes: table rendered", (await recipes.locator("tr.row").count()) > 0);
  324. ok(
  325. "recipes: its own SSE stream is live behind the prefix",
  326. (await recipes.locator("#live").innerText()).includes("live"),
  327. );
  328. // the iframe inherits the dashboard's colour scheme (report page is
  329. // `light dark` and would otherwise follow the OS)
  330. const scheme = await page.evaluate(() =>
  331. getComputedStyle(document.querySelector("#win-recipes iframe")).colorScheme,
  332. );
  333. ok("recipes: iframe colour-scheme follows the theme", scheme === "light" || scheme === "dark", scheme);
  334. // the report page's own paths move with the mount (dashboard owns /events)
  335. ok(
  336. "recipes: iframe loads the mounted panel",
  337. (await page.locator("#win-recipes iframe").getAttribute("src")) === "/recipes",
  338. );
  339. // hosted copy drops its own branding (the window bar names it) and keeps
  340. // its live chip only as a warning — the dashboard header already says live
  341. ok(
  342. "recipes: embedded page hides its own title",
  343. (await recipes.locator("h1").isVisible()) === false,
  344. );
  345. ok(
  346. "recipes: embedded page drops its live chip (same server, same liveness)",
  347. (await recipes.locator("#live").evaluate((e) => getComputedStyle(e).display)) === "none",
  348. );
  349. ok(
  350. "recipes: embedded page keeps its filter controls",
  351. (await recipes.locator("#bar").isVisible()) === true,
  352. );
  353. ok(
  354. "recipes: body is marked as hosted (one implementation, adapted chrome)",
  355. (await recipes.locator("body").getAttribute("class")) === "embed",
  356. );
  357. // minimize must land in the dock (this window used to vanish: only the
  358. // three named windows were in the dock's registry)
  359. await page.click("#win-recipes .win-bar .wbtn[data-act='min']");
  360. ok(
  361. "recipes: minimize leaves a dock chip",
  362. (await page.locator('#dock .dock-chip:has-text("Recipes")').count()) === 1,
  363. );
  364. await page.click('#dock .dock-chip:has-text("Recipes")');
  365. await page.waitForTimeout(400); // winIn animation replays on re-display
  366. ok(
  367. "recipes: dock chip restores the window",
  368. (await page.evaluate(() => getComputedStyle(document.querySelector("#win-recipes")).display)) !== "none",
  369. );
  370. // maximize fills the layer, a second click restores the old geometry
  371. const wBefore = await page.evaluate(
  372. () => Math.round(document.querySelector("#win-recipes").getBoundingClientRect().width),
  373. );
  374. await page.click("#win-recipes .win-bar .wbtn[data-act='max']");
  375. await page.waitForTimeout(400);
  376. const m = await page.evaluate(() => {
  377. const w = document.querySelector("#win-recipes");
  378. const cs = getComputedStyle(w);
  379. const r = w.getBoundingClientRect();
  380. const lr = document.querySelector("#winlayer").getBoundingClientRect();
  381. return {
  382. w: [Math.round(r.width), Math.round(r.height)],
  383. l: [Math.round(lr.width), Math.round(lr.height)],
  384. strip: Math.round(document.querySelector("#dock").getBoundingClientRect().top - r.bottom),
  385. border: cs.borderTopWidth,
  386. radius: cs.borderRadius,
  387. label: document.querySelector("#maxinfo").getBoundingClientRect().width === 0
  388. ? ""
  389. : document.querySelector("#max-title").textContent,
  390. };
  391. });
  392. ok(
  393. "recipes: maximize fills the layer flush (no strip above the dock)",
  394. m.strip === 0 && Math.abs(m.w[0] - m.l[0]) < 2 && Math.abs(m.w[1] - m.l[1]) < 2,
  395. JSON.stringify(m),
  396. );
  397. ok("recipes: maximized window has no frame", m.border === "0px" && m.radius === "0px", `${m.border} / ${m.radius}`);
  398. ok(
  399. "recipes: maximized window keeps no titlebar (title lives in the header)",
  400. (await page.evaluate(() => getComputedStyle(document.querySelector("#win-recipes .win-bar")).display)) === "none",
  401. );
  402. ok(
  403. "recipes: maximized content starts at the layer top",
  404. await page.evaluate(
  405. () =>
  406. Math.abs(
  407. document.querySelector("#win-recipes .win-body").getBoundingClientRect().top -
  408. document.querySelector("#winlayer").getBoundingClientRect().top,
  409. ) < 2,
  410. ),
  411. );
  412. ok("recipes: header carries the window controls", (await page.locator("#maxinfo .wbtn").count()) === 3);
  413. ok("recipes: maximized title moves into the header", m.label === "Recipes", m.label);
  414. // the dock stays reachable under a maximized window
  415. ok(
  416. "recipes: dock stays usable while maximized",
  417. await page.evaluate(() => {
  418. const b = document.querySelector("#dock").getBoundingClientRect();
  419. return !!document.elementFromPoint(b.x + 20, b.y + b.height / 2)?.closest("#dock");
  420. }),
  421. );
  422. await page.click("#max-restore"); // header restore button
  423. await page.waitForTimeout(400);
  424. ok(
  425. "recipes: header button un-maximizes back to the old size",
  426. Math.abs(
  427. (await page.evaluate(() => Math.round(document.querySelector("#win-recipes").getBoundingClientRect().width))) - wBefore,
  428. ) < 2,
  429. );
  430. ok(
  431. "recipes: header label cleared after restore",
  432. await page.evaluate(() => document.querySelector("#maxinfo").getBoundingClientRect().width === 0),
  433. );
  434. // header controls must work while the window has no titlebar of its own
  435. await page.click("#win-recipes .win-bar .wbtn[data-act='max']");
  436. await page.waitForTimeout(400);
  437. await page.click("#max-min");
  438. await page.waitForTimeout(200);
  439. ok(
  440. "recipes: header minimize hides the window but keeps its maximize state (no dock chip)",
  441. (await page.locator('#dock .dock-chip:has-text("Recipes")').count()) === 0 &&
  442. (await page.evaluate(() => {
  443. const w = document.querySelector("#win-recipes");
  444. return w.style.display === "none" && w.classList.contains("max");
  445. })),
  446. );
  447. // the ▦ switcher lists the hidden maximized window and brings it back full-screen
  448. await page.click("#winmenu");
  449. await page.waitForTimeout(200);
  450. ok(
  451. "recipes: switcher lists the hidden maximized window",
  452. (await page.locator(".winmenu .dock-chip:has-text('Recipes')").count()) === 1,
  453. );
  454. await page.click(".winmenu .dock-chip:has-text('Recipes')");
  455. await page.waitForTimeout(400);
  456. ok(
  457. "recipes: switcher shows it maximized again (full layer)",
  458. await page.evaluate(() => {
  459. const w = document.querySelector("#win-recipes");
  460. const lr = document.querySelector("#winlayer").getBoundingClientRect();
  461. return w.style.display !== "none" && w.classList.contains("max") && Math.abs(w.getBoundingClientRect().width - lr.width) < 2;
  462. }),
  463. );
  464. await page.evaluate(() => document.querySelector("#win-recipes").style.display = "none");
  465. // the map is a fixed-aspect drawing: its window is sized to it, otherwise
  466. // the SVG letterboxes and the panel is half empty
  467. const band = await page.evaluate(() => {
  468. const svg = document.querySelector("#win-topo svg");
  469. const body = document.querySelector("#win-topo .win-body");
  470. const vb = svg.viewBox.baseVal;
  471. const bb = body.getBoundingClientRect();
  472. const scale = Math.min(bb.width / vb.width, bb.height / vb.height);
  473. return Math.round((bb.height - vb.height * scale) / 2);
  474. });
  475. ok("topo: window fits the drawing (no letterbox bands)", band < 8, String(band));
  476. // fan glyph: the rotation must be hub-centered (fill-box origin wobbled it)
  477. {
  478. const fanHub = await page.evaluate(() => {
  479. const h = document.querySelector("#topo .fan .hub");
  480. if (!h) return null;
  481. const r = h.getBoundingClientRect();
  482. return [r.x + r.width / 2, r.y + r.height / 2];
  483. });
  484. if (fanHub) {
  485. await page.waitForTimeout(260);
  486. const fanHub2 = await page.evaluate(() => {
  487. const h = document.querySelector("#topo .fan .hub");
  488. const r = h.getBoundingClientRect();
  489. return [r.x + r.width / 2, r.y + r.height / 2];
  490. });
  491. const drift = Math.hypot(fanHub[0] - fanHub2[0], fanHub[1] - fanHub2[1]);
  492. ok("topo: fan spin is hub-centered (no wobble)", drift < 2, `${drift.toFixed(2)}px drift`);
  493. }
  494. }
  495. // the default layout ends flush at the dock: px geometry is clamped to the
  496. // layer, which shrinks when the dock (a sibling) becomes visible
  497. const flush = await page.evaluate(() => {
  498. const dock = document.querySelector("#dock").getBoundingClientRect();
  499. const shown = [...document.querySelectorAll(".win")].filter(
  500. (w) => !w.classList.contains("max") && getComputedStyle(w).display !== "none",
  501. );
  502. return Math.round(dock.top - Math.max(...shown.map((w) => w.getBoundingClientRect().bottom)));
  503. });
  504. ok("windows end flush at the dock strip (no dead space)", Math.abs(flush) <= 1, String(flush));
  505. // --- 15. window layout survives a reload (localStorage) ---------------------------
  506. await page.evaluate(() => {
  507. const t = document.querySelector("#win-topo");
  508. t.style.left = "40px"; t.style.top = "20px"; t.style.width = "700px"; t.style.height = "300px";
  509. document.querySelector("#win-state .win-bar .wbtn[data-act='min']").click();
  510. document.querySelector("#win-jobs .win-bar .wbtn[data-act='close']").click();
  511. });
  512. await page.click("#dock .plus");
  513. await page.click('.dockmenu .dock-chip:text-is("recipes")');
  514. await page.waitForSelector("#win-recipes iframe");
  515. await page.waitForTimeout(400);
  516. // the window may already come back maximized (state is kept) — only
  517. // maximize when it isn't (the button is a toggle)
  518. await page.evaluate(() => {
  519. const w = document.querySelector("#win-recipes");
  520. if (!w.classList.contains("max"))
  521. document.querySelector("#win-recipes .win-bar .wbtn[data-act='max']").click();
  522. });
  523. await page.waitForTimeout(500);
  524. const laidOut = await page.evaluate(() => ({
  525. saved: JSON.parse(localStorage.getItem("sm121-wins") || "null"),
  526. 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)]; })(),
  527. }));
  528. ok("layout: state written to localStorage", !!laidOut.saved?.topo?.width, JSON.stringify(laidOut.saved?.topo));
  529. await page.reload({ waitUntil: "domcontentloaded" });
  530. await page.waitForTimeout(2500);
  531. const back = await page.evaluate(() => {
  532. 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)]; };
  533. return {
  534. topo: box("#win-topo"),
  535. stateMin: getComputedStyle(document.querySelector("#win-state")).display === "none",
  536. jobsClosed: getComputedStyle(document.querySelector("#win-jobs")).display === "none",
  537. recipesMax: document.querySelector("#win-recipes")?.classList.contains("max") ?? false,
  538. chips: [...document.querySelectorAll("#dock .dock-chip")].map((c) => c.textContent),
  539. header: document.querySelector("#maxinfo").getBoundingClientRect().width === 0 ? "" : document.querySelector("#max-title").textContent,
  540. };
  541. });
  542. ok(
  543. "layout: geometry restored after reload",
  544. JSON.stringify(back.topo) === JSON.stringify(laidOut.topo),
  545. `${JSON.stringify(laidOut.topo)} -> ${JSON.stringify(back.topo)}`,
  546. );
  547. ok("layout: minimized window stays minimized", back.stateMin);
  548. ok("layout: closed window stays closed, with a dock chip to reopen", back.jobsClosed && back.chips.includes("+ Jobs"), JSON.stringify(back.chips));
  549. ok("layout: maximized recipes window restored maximized", back.recipesMax && back.header === "Recipes", back.header);
  550. // --- topology cable mode: direct (S-curve) and bus + bridges --------------------
  551. {
  552. await page.waitForFunction(() => state.snap && state.snap.topo && state.snap.topo.nodes);
  553. const hasRow1 = await page.evaluate(() => state.snap.topo.nodes.length > 4);
  554. const fab200 = await page.evaluate(() =>
  555. [...document.querySelectorAll("#topo path.c-200g")].map((p) => p.getAttribute("d")),
  556. );
  557. const row1 = fab200.filter((d) => d && d.includes(" 386 "));
  558. if (hasRow1) {
  559. ok("topo: row-1 fabric cables present", row1.length > 0, `of ${fab200.length}`);
  560. ok(
  561. "topo: row-1 fabric cable approaches the port vertically (S-curve, no side entry)",
  562. row1.every((d) => / C .* 366 .* 346$/.test(d)),
  563. row1.join(" "),
  564. );
  565. } else {
  566. ok("topo: no bottom-row devices in this fleet (S-curve path n/a)", row1.length === 0);
  567. }
  568. await page.evaluate(() => document.querySelector("#topo [data-topobus]").dispatchEvent(new MouseEvent("click", { bubbles: true })));
  569. await page.waitForTimeout(400);
  570. const bus = await page.evaluate(() => {
  571. const t = state.snap.topo;
  572. const lane = [];
  573. for (const l of t.links.filter((x) => x.kind === "200g")) {
  574. const fi = tgIdx(t, l.a);
  575. if (fi < 4) continue;
  576. const px = tgPos(fi).px;
  577. const dx = tgPos(tgIdx(t, l.b)).dcx;
  578. lane.push({ x1: Math.min(dx, px), x2: Math.max(dx, px) });
  579. }
  580. let expect = 0;
  581. for (const l of t.links.filter((x) => x.kind === "10g")) {
  582. const bi = tgIdx(t, l.b);
  583. if (bi < 4) continue;
  584. const x = tgPos(bi).dcx;
  585. if (x >= 370 && x <= 690) continue;
  586. if (lane.some((h) => x > h.x1 + 2 && x < h.x2 - 2)) expect++;
  587. }
  588. return {
  589. legend: document.querySelector("#topo [data-topobus]").textContent,
  590. manhattan: [...document.querySelectorAll("#topo path.c-200g")].filter((p) => (p.getAttribute("d") || "").includes(" L ")).length,
  591. bridges: document.querySelectorAll("#topo path.bridge").length,
  592. expect,
  593. bridgesDrawn: [...document.querySelectorAll("#topo path.bridge")].every((b) => (b.getAttribute("d") || "").includes("A 5 5")),
  594. };
  595. });
  596. ok("topo: bus mode active (legend)", bus.legend.includes("bus"), bus.legend);
  597. if (hasRow1)
  598. ok("topo: bus cables run Manhattan (L segments)", bus.manhattan >= 1, `manhattan=${bus.manhattan}`);
  599. ok("topo: bridges rendered exactly at strict crossings", bus.bridges === bus.expect && bus.bridgesDrawn, `bridges=${bus.bridges} expect=${bus.expect}`);
  600. await page.evaluate(() => document.querySelector("#topo [data-topobus]").dispatchEvent(new MouseEvent("click", { bubbles: true })));
  601. await page.waitForTimeout(400);
  602. const back2 = await page.evaluate(() => ({
  603. legend: document.querySelector("#topo [data-topobus]").textContent,
  604. curved: [...document.querySelectorAll("#topo path.c-200g")].filter((p) => (p.getAttribute("d") || "").includes(" C ")).length,
  605. }));
  606. ok(
  607. "topo: back to direct mode",
  608. back2.legend.includes("direct") && (fab200.length === 0 || back2.curved >= 1),
  609. JSON.stringify(back2),
  610. );
  611. ok("topo: mode choice persisted", await page.evaluate(() => localStorage.getItem("sm121-topobus") === "0"));
  612. }
  613. // --- no page JS errors across the whole flow -------------------------------------
  614. ok("no page JS errors", pageErrors.length === 0, pageErrors.join("; "));
  615. await browser.close();
  616. if (failed) {
  617. console.log(`\n${failed} UI check(s) FAILED`);
  618. Deno.exit(1);
  619. }
  620. console.log("\nall UI checks passed");