diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..936d451 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,154 @@ +"""Fixtures for the Dashboard SPA browser smoke test. + +The page is the REAL html_generator output (loaded under a private module name, +because tests/conftest.py registers a stub `html_generator`), served from a fake +origin through `page.route`, so no web server or ComfyUI is involved. +""" +import importlib.util +import io +import json +import os + +import pytest + +_NODE_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +ORIGIN = "http://uscg.test" +SESSION = "smoke" +N_ITEMS = 24 + + +def load_real_html_generator(): + spec = importlib.util.spec_from_file_location( + "uscg_real_html_generator", os.path.join(_NODE_ROOT, "html_generator.py")) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def build_dashboard_page(manifest, node_id="7"): + """The exact HTML string the Dashboard node shows in its iframe srcdoc.""" + return load_real_html_generator().get_html_template(SESSION, manifest, node_id) + + +def make_item(i, model, sampler, scheduler, cfg, steps, lora): + prompt = f"a photo of a cat, variant {i % 2}" + return { + "id": 1000 + i, + "gen_index": i, + "file": f"/view?filename=smoke_{i:04d}.png&type=output&subfolder=benchmarks/{SESSION}", + "favorited": False, + "rejected": False, + "note": "", + "positive": prompt, + "config_positive": prompt, + "negative": "blurry", + "model": model, + "lora": lora, + "sampler": sampler, + "scheduler": scheduler, + "steps": steps, + "cfg": cfg, + "seed": 100 + i, + "denoise": 1.0, + "width": 512, + "height": 768, + "duration": round(1 + 0.1 * i, 2), + "batch_idx": 0, + } + + +def make_manifest(): + """24 items, every config tuple unique. Newest first, like the real manifest.""" + built = [] + i = 0 + for model in ("sdxl_base.safetensors", "juggernautXL_v9.safetensors"): + for sampler in ("euler", "dpmpp_2m", "uni_pc"): + for scheduler in ("normal", "karras"): + for cfg in (4.0, 7.0): + steps = 20 if i % 2 == 0 else 30 + lora = "None" if i % 4 < 2 else "detail_tweaker.safetensors:0.8" + built.append(make_item(i, model, sampler, scheduler, cfg, steps, lora)) + i += 1 + return { + "session_name": SESSION, + "meta": {"model": "", "positive": "", "negative": ""}, + "items": list(reversed(built)), + } + + +_PNG_CACHE = {} + + +def png_bytes(name): + if name not in _PNG_CACHE: + from PIL import Image + h = (sum(ord(c) * (k + 7) for k, c in enumerate(name)) * 2654435761) & 0xFFFFFF + img = Image.new("RGB", (64, 96), ((h >> 16) & 255, (h >> 8) & 255, h & 255)) + buf = io.BytesIO() + img.save(buf, format="PNG") + _PNG_CACHE[name] = buf.getvalue() + return _PNG_CACHE[name] + + +class Dashboard: + """The page plus a request log and a few helpers.""" + + def __init__(self, page, manifest): + self.page = page + self.manifest = manifest + self.request_log = [] # (method, path, parsed_json_body_or_None) + self.pageerrors = [] + + make_item = staticmethod(make_item) + + def ev(self, js): + return self.page.evaluate(js) + + @property + def visible_cards(self): + # Pooled cards stay in the DOM with display:none: always filter on :visible. + return self.page.locator("#grid .card:visible") + + def handle(self, route): + req = route.request + if not req.url.startswith(ORIGIN + "/"): + return route.abort() # e.g. the GitHub stars fetch + body = None + if req.method == "POST": + try: + body = json.loads(req.post_data or "null") + except ValueError: + body = None + path, _, query = req.url[len(ORIGIN):].partition("?") + self.request_log.append((req.method, path, body)) + if path == "/": + return route.fulfill(status=200, content_type="text/html; charset=utf-8", + body=build_dashboard_page(self.manifest)) + if path == "/view": + params = dict(kv.partition("=")[::2] for kv in query.split("&") if kv) + name = params.get("filename", "") + if name == "manifest.json": + return route.fulfill(status=200, content_type="application/json", + body=json.dumps(self.manifest)) + return route.fulfill(status=200, content_type="image/png", body=png_bytes(name)) + if path in ("/config_tester/save_changes", "/config_tester/save_manifest"): + return route.fulfill(status=200, content_type="application/json", + body='{"status":"ok"}') + if path == "/config_tester/list_sessions": + return route.fulfill(status=200, content_type="application/json", body="[]") + return route.fulfill(status=200, content_type="application/json", body="{}") + + +@pytest.fixture +def dashboard(page): + from playwright.sync_api import expect + d = Dashboard(page, make_manifest()) + page.set_viewport_size({"width": 1600, "height": 1200}) + page.on("pageerror", lambda e: d.pageerrors.append(str(e))) + page.route("**/*", d.handle) + page.goto(ORIGIN + "/") + page.wait_for_function( + f"typeof processedData!=='undefined' && processedData.length==={N_ITEMS}") + expect(page.locator("#grid .card:visible")).to_have_count(N_ITEMS) + yield d + assert d.pageerrors == [] diff --git a/tests/e2e/requirements.txt b/tests/e2e/requirements.txt new file mode 100644 index 0000000..98177a9 --- /dev/null +++ b/tests/e2e/requirements.txt @@ -0,0 +1,2 @@ +playwright==1.64.1 +pytest-playwright==0.10.0 diff --git a/tests/e2e/test_dashboard_smoke.py b/tests/e2e/test_dashboard_smoke.py new file mode 100644 index 0000000..68b900d --- /dev/null +++ b/tests/e2e/test_dashboard_smoke.py @@ -0,0 +1,160 @@ +"""Browser smoke test of the Dashboard SPA (resources/ inlined by html_generator). + +Needs `pip install -r tests/e2e/requirements.txt` and `python -m playwright install chromium`. +With USCG_REQUIRE_E2E=1 (CI) a missing playwright is a failure instead of a skip. +""" +import json +import os +import re + +import pytest + +if os.environ.get("USCG_REQUIRE_E2E") == "1": + import playwright.sync_api # noqa: F401 +else: + pytest.importorskip("playwright.sync_api") + +from playwright.sync_api import expect # noqa: E402 + +pytestmark = pytest.mark.e2e + +ACTIVE = re.compile(r"\bactive\b") +FAVORITED = re.compile(r"\bfavorited\b") + + +def test_cards_render(dashboard): + page = dashboard.page + cards = dashboard.visible_cards + assert cards.count() == 24 + ids = {c.get_attribute("id") for c in cards.all()} + assert ids == {f"card-{1000 + i}" for i in range(24)} + expect(page.locator("#session-landing")).to_be_hidden() + expect(page.locator("#viewport")).to_be_visible() + page.wait_for_function( + "[...document.querySelectorAll('#grid .card')].some(c => c.style.display !== 'none'" + " && c.querySelector('img') && c.querySelector('img').naturalWidth > 0)") + expect(page.locator("#json-btn-good")).to_have_text("View Configs (24)") + + +def test_favorite_toggle_updates_state_and_gold_json_bar(dashboard): + page = dashboard.page + star = page.locator("#card-1000 .favorite-btn") + with page.expect_request(lambda r: r.url.endswith("/config_tester/save_changes")) as sent: + star.click() + change = sent.value.post_data_json["changed_items"][0] + assert change["id"] == 1000 and change["favorited"] is True + expect(star).to_have_class(FAVORITED) + expect(star).to_have_text("★") + expect(page.locator("#json-btn-favorite")).to_have_text("View Favorited (1)") + page.locator("#json-btn-favorite").click() + bar = page.locator("#json-bar-favorite") + expect(bar).to_be_visible() + entries = json.loads(bar.inner_text()) + assert len(entries) == 1 + src = next(m for m in dashboard.manifest["items"] if m["id"] == 1000) + assert entries[0]["sampler"] == src["sampler"] + assert entries[0]["model"] == src["model"] + star.click() + expect(star).to_have_text("☆") + expect(page.locator("#json-btn-favorite")).to_have_text("View Favorited (0)") + + +def test_reject_hides_card(dashboard): + page = dashboard.page + with page.expect_request(lambda r: r.url.endswith("/config_tester/save_changes")) as sent: + page.locator("#card-1005 .reject-btn").click() + change = sent.value.post_data_json["changed_items"][0] + assert change["id"] == 1005 and change["rejected"] is True + expect(page.locator("#card-1005")).to_be_hidden() + expect(dashboard.visible_cards).to_have_count(23) + assert dashboard.ev("processedData.length") == 23 + expect(page.locator("#json-btn-bad")).to_have_text("View Rejected (1)") + page.locator(".filter-popup-btn").click() + page.locator("#top-filter-rejected").click() + expect(page.locator("#top-filter-rejected")).to_have_class(ACTIVE) + expect(dashboard.visible_cards).to_have_count(24) + + +def test_filter_group_toggle(dashboard): + page = dashboard.page + page.locator(".filter-popup-btn").click() + expect(page.locator("#filters-popup")).to_be_visible() + expect(page.locator("#filter-sampler .filter-btn")).to_have_count(3) + euler = page.locator("#btn-sampler-euler") + euler.click() + expect(euler).not_to_have_class(ACTIVE) + expect(dashboard.visible_cards).to_have_count(16) + assert dashboard.ev("processedData.every(d => d.sampler !== 'euler')") + euler.click() + expect(dashboard.visible_cards).to_have_count(24) + unipc = page.locator("#btn-sampler-unipc") + unipc.dblclick() + expect(dashboard.visible_cards).to_have_count(8) + unipc.dblclick() + expect(dashboard.visible_cards).to_have_count(24) + + +def test_lightbox_opens_and_arrows_navigate(dashboard): + page = dashboard.page + first = dashboard.ev("processedData[0]") + pan_before = dashboard.ev("panOffsetX") + page.locator(f"#card-{first['id']} .revise-btn").click() + expect(page.locator("#modal")).to_be_visible() + expect(page.locator("#modal-nav-counter")).to_have_text("1 / 24") + assert page.locator("#m-img").get_attribute("src").endswith(first["file"]) + page.keyboard.press("ArrowRight") + expect(page.locator("#modal-nav-counter")).to_have_text("2 / 24") + second = dashboard.ev("processedData[1]") + assert page.locator("#m-img").get_attribute("src").endswith(second["file"]) + page.keyboard.press("ArrowLeft") + expect(page.locator("#modal-nav-counter")).to_have_text("1 / 24") + page.locator(".modal-close-x").click() + expect(page.locator("#modal")).to_be_hidden() + assert dashboard.ev("window._modalOpen") is False + assert dashboard.ev("panOffsetX") == pan_before + + +def test_sort_popup_cycles_primary_sort(dashboard): + page = dashboard.page + assert dashboard.ev("processedData[0].gen_index") == 0 + expect(page.locator("#sort-primary-label")).to_contain_text("Oldest") + steps = [("Newest", 23, "newest"), ("Fastest", 0, "fastest"), ("Oldest", 0, "oldest")] + # picking a PRIMARY option leaves the popup open (only a secondary pick closes it) + page.locator("#sort-main-btn").click() + expect(page.locator("#sort-popup")).to_be_visible() + for label, gen_index, sort_id in steps: + expect(page.locator("#sort-list-primary .sort-option")).to_have_count(12) + page.locator("#sort-list-primary .sort-option").get_by_text(label, exact=True).click() + expect(page.locator("#sort-primary-label")).to_contain_text(label) + expect(page.locator("#sort-list-primary .sort-option.selected")).to_have_text(label) + page.wait_for_function( + f"currentSort === '{sort_id}' && processedData[0].gen_index === {gen_index}") + assert dashboard.ev("localStorage.getItem('ultimate_grid_sort')") == sort_id + top = page.locator(f"#card-{dashboard.ev('processedData[0].id')}") + expect(top).to_have_css("left", "0px") + expect(top).to_have_css("top", "0px") + page.locator("#sort-main-btn").click() + expect(page.locator("#sort-popup")).to_be_hidden() + + +def test_shift_digit_sets_columns(dashboard): + page = dashboard.page + auto = dashboard.ev("columnsCount") + page.keyboard.press("Shift+Digit3") + page.wait_for_function("columnsCount === 3") + expect(page.locator("#col-count")).to_have_value("3") + assert dashboard.ev("localStorage.getItem('ultimate_grid_cols')") == "3" + lefts = dashboard.ev("[...document.querySelectorAll('#grid .card')]" + ".filter(c => c.style.display !== 'none').map(c => c.style.left)") + assert len(set(lefts)) == 3 + page.keyboard.press("Shift+Digit0") + expect(page.locator("#col-count")).to_have_value("") + page.wait_for_function(f"columnsCount === {auto}") + + +def test_update_data_message_adds_cards(dashboard): + page = dashboard.page + item = dashboard.make_item(24, "sdxl_base.safetensors", "euler", "normal", 9.0, 40, "None") + page.evaluate("item => window.postMessage({type:'update_data', data:{new_items:[item]}}, '*')", + item) + page.wait_for_function("processedData.length === 25")