// The app at the screens people really have, in a real browser: no page overflow, every window inside the viewport, // both timeline lanes visible, the timeline header on one row from 1024px wide, idle and while a render is running (a fake ComfyUI queue and socket). Not part of `npm test` (it needs browsers): run it with `npm run test:viewport`. // FUNUI_BROWSERS=chromium,firefox picks the engines (default: every one that is installed). import playwright from "playwright"; import { start } from "./stub.mjs"; const SIZES = [[1280, 720], [1024, 1024], [1000, 700], [1366, 768], [1920, 1080], [2560, 1440], [768, 1024], [720, 1280], [1080, 1920]]; const WINDOWS = [["Settings", /^Settings$/], ["Log", /^Log$/], ["Composer", /Composer/], ["Sampler", /Sampler/], ["Training", /Training/]]; const CLIENT = "viewport-test"; const wanted = (process.env.FUNUI_BROWSERS || "chromium,firefox").split(","); const inspect = () => { const box = (e) => e.getBoundingClientRect(); const seen = [...document.querySelectorAll(".cx-modal, .cx-popover, .cx-window")].filter((e) => box(e).width > 0); const out = seen.map((e) => ({ name: e.className.toString().split(" ")[0], r: box(e) })); const nle = document.querySelector(".cx-nle"), lanes = [...document.querySelectorAll(".cx-nle-gutter-lane")]; const head = [...document.querySelectorAll(".cx-panel-head")].find((h) => h.textContent.startsWith("Timeline")), act = head && head.querySelector(".cx-panel-actions"); const header = innerWidth < 1024 || !head ? [] : [box(head).height > 50 && `Timeline header is ${Math.round(box(head).height)}px tall (more than one row)`, act.scrollWidth > act.clientWidth + 1 && `Timeline header clips ${act.scrollWidth - act.clientWidth}px of its controls`].filter(Boolean); return { header, over: [document.documentElement.scrollWidth - innerWidth, document.documentElement.scrollHeight - innerHeight], windows: out.filter(({ r }) => r.left < -1 || r.top < -1 || r.right > innerWidth + 1 || r.bottom > innerHeight + 1).map(({ name, r }) => `${name} ${Math.round(r.left)},${Math.round(r.top)}..${Math.round(r.right)},${Math.round(r.bottom)}`), lanes: lanes.length < 2 ? ["fewer than two lanes"] : lanes.filter((l) => box(l).bottom > box(nle).bottom + 1).map((l) => `lane ends ${Math.round(box(l).bottom)}, timeline ${Math.round(box(nle).bottom)}`), slack: nle && nle.querySelector(".cx-nle-stage") && nle.clientHeight - nle.querySelector(".cx-nle-stage").offsetHeight > 6 ? [`${Math.round(nle.clientHeight - nle.querySelector(".cx-nle-stage").offsetHeight)}px of empty space under the last lane`] : [], }; }; // A render in progress, as ComfyUI would show it: the page's own client id in the queue, and the socket reporting step 4 of 5. async function fakeRunning(page) { await page.addInitScript((id) => sessionStorage.setItem("funui.client-id", id), CLIENT); await page.route("**/queue", (route) => route.fulfill({ json: { queue_pending: [], queue_running: [[1, "pid1", {}, { client_id: CLIENT, funui_scene_id: "s1", funui_scene_ids: ["s1"], funui_project_id: "p1" }, []]] } })); await page.routeWebSocket(/\/ws\?/, (ws) => { const say = (type, data) => ws.send(JSON.stringify({ type, data })); setTimeout(() => { say("execution_start", { prompt_id: "pid1" }); say("progress_state", { prompt_id: "pid1", nodes: { 3: { state: "running", value: 4, max: 5, node_id: "3", display_node_id: "3" } } }); }, 300); }); } // Help > Start tour, then Next to the end: every callout must find its target (a step whose target is missing is skipped, so the numbers would jump). async function walkTour(page, note) { await page.getByRole("button", { name: "Help", exact: true }).click(); await page.getByText("Start tour").click(); const seen = []; for (let i = 0; i < 30; i++) { await page.waitForTimeout(150); const head = await page.evaluate(() => (document.querySelector(".cx-popover .cx-label, .cx-popover") || {}).textContent || ""); const m = head.match(/(\d+) \/ (\d+)/); if (!m) break; seen.push(Number(m[1])); if (/· Generate/.test(head)) { // the pointed-at button is redrawn (as when a run starts): the callout must follow the new one await page.evaluate(() => { const b = [...document.querySelectorAll("button")].find((x) => (x.textContent || "").trim().startsWith("▶ Generate")); if (b) { const n = b.cloneNode(true); n.textContent = "Generating…"; b.replaceWith(n); } window.dispatchEvent(new Event("resize")); }); await page.waitForTimeout(400); const gap = await page.evaluate(() => { const b = [...document.querySelectorAll("button")].find((x) => /^(▶ Generate|Generating…)/.test((x.textContent || "").trim())), pop = document.querySelector(".cx-popover"); if (!b || !pop || !/· Generate/.test(pop.textContent)) return null; // still the Generate callout, not skipped on to the next part const r = b.getBoundingClientRect(), q = pop.getBoundingClientRect(); return Math.abs(q.bottom - r.top); // above the button (the sideways place is clamped to the screen edge) }); if (gap === null || gap > 40) note(`the tour callout did not follow the redrawn Generate button (off by ${gap})`); } const done = Number(m[1]) === Number(m[2]); await page.getByRole("button", { name: done ? "Done" : "Next", exact: true }).click(); if (done) break; } if (!seen.length || seen.some((n, i) => n !== i + 1) || seen.length < 5) note(`the tour skipped a step or did not run (saw ${seen.join(",") || "nothing"})`); } const { server, url } = await start(0); const problems = []; let ran = 0; for (const name of wanted) { let browser; try { browser = await playwright[name].launch(); } catch { console.log(`skipping ${name}: not installed`); continue; } try { for (const [w, h, running] of SIZES.flatMap(([w, h]) => [[w, h, false], [w, h, true]])) { const page = await browser.newPage({ viewport: { width: w, height: h } }); const note = (what) => problems.push(`${name} ${w}x${h}${running ? " running" : ""}: ${what}`); if (running) await fakeRunning(page); await page.goto(url); await page.getByText(/Continue with/).click(); await page.waitForTimeout(800); if (running) { await page.waitForTimeout(700); if (!(await page.getByText(/Step 4\/5/).count())) note("the running render's progress is not shown"); if (process.env.FUNUI_SHOTS) await page.screenshot({ path: `${process.env.FUNUI_SHOTS}/running-${name}-${w}x${h}.png` }); } if (!running) await walkTour(page, note); const main = await page.evaluate(inspect); if (main.over[0] > 0 || main.over[1] > 0) note(`page overflows by ${main.over}`); main.lanes.forEach((m) => note(m)); main.slack.forEach((m) => note(m)); main.header.forEach((m) => note(m)); for (const [label, text] of WINDOWS) { await page.getByText(text).first().click({ timeout: 1500 }).catch(() => note(`${label} cannot be opened`)); await page.waitForTimeout(500); (await page.evaluate(inspect)).windows.forEach((m) => note(`${label} window outside the viewport: ${m}`)); await page.keyboard.press("Escape"); await page.waitForTimeout(200); } await page.close(); ran += 1; } } finally { await browser.close(); } // a failed run must not leave a browser holding memory on a small machine } server.close(); console.log(problems.length ? problems.join("\n") : `ok: ${ran} browser/size runs, no findings`); process.exit(problems.length || !ran ? 1 : 0);