test(e2e): Playwright smoke test for the Dashboard SPA

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jason Hoku
2026-11-04 00:05:06 -08:00
co-authored by Claude Fable 5.1
parent 2e37788969
commit bc11dfbdcb
3 changed files with 316 additions and 0 deletions
+154
View File
@@ -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 == []
+2
View File
@@ -0,0 +1,2 @@
playwright==1.64.1
pytest-playwright==0.10.0
+160
View File
@@ -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")