test(e2e): Playwright smoke test for the Dashboard SPA
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
2e37788969
commit
bc11dfbdcb
@@ -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 == []
|
||||
@@ -0,0 +1,2 @@
|
||||
playwright==1.64.1
|
||||
pytest-playwright==0.10.0
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user