Files

478 lines
25 KiB
Python

"""P1/P2 browser regression against scripts/preview.py's TEMPORARY demo data only.
Build first, then start `python scripts/preview.py --host 0.0.0.0 --port 8189`.
Run `python scripts/browser_p12.py http://127.0.0.1:8189`.
The import case writes a single image into the ephemeral preview directory, not ComfyUI.
Large-library performance, real ComfyUI workflow delivery, and other engines/devices
require separate manual acceptance and are NOT covered by this script.
"""
from __future__ import annotations
import asyncio
import json
import sys
import time
from urllib.parse import parse_qs, urlsplit
from playwright.async_api import async_playwright, expect
BASE = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://127.0.0.1:8189"
def query(url: str, key: str) -> str:
return parse_qs(urlsplit(url).query).get(key, [""])[0]
async def new_page(browser, errors, *, path="/gallery/", width=1440, live=False, before=None):
page = await browser.new_page(viewport={"width": width, "height": 960 if width > 600 else 844})
page.on("pageerror", lambda error: errors.append(str(error)))
await page.add_init_script("""
localStorage.setItem('universal-extractor-locale','zh-CN');
localStorage.setItem('universal-extractor:onboarding-tour-v1-completed','true');
localStorage.setItem('universal-extractor:ui-preferences',JSON.stringify({enableLiveGalleryRefresh:%s}));
""" % ("true" if live else "false"))
if before:
await before(page)
await page.goto(BASE + path, wait_until="networkidle")
return page
async def gallery(browser, errors, **kwargs):
page = await new_page(browser, errors, **kwargs)
await expect(page.locator(".ue-gallery-card").first).to_be_visible()
return page
async def switch_tab(page, index):
await page.locator('[data-tour-id="workspace-switcher"]').click()
await page.locator('.ue-workspace-switcher .ue-topbar-tab').nth(index).click()
async def open_mode(page, name):
await page.get_by_role("combobox", name="图库模式").select_option("organize" if name == "开启双栏目录整理" else "variants")
async def slow_source_and_empty(browser, errors):
page = await gallery(browser, errors)
started, release = asyncio.Event(), asyncio.Event()
async def slow_input(route):
if query(route.request.url, "subfolder") == "default_input::":
started.set()
await asyncio.wait_for(release.wait(), 12)
await route.continue_()
await page.route("**/universal_gallery/api/images?*", slow_input)
try:
await page.locator(".ue-gallery-card .ue-select-btn").first.click()
await expect(page.locator(".ue-gallery-inspector")).to_contain_text("已选 1 项")
await page.locator(".ue-sidebar-quick button").filter(has_text="输入图库").click()
await asyncio.wait_for(started.wait(), 7)
await expect(page.locator(".ue-filter-kicker")).to_have_text("ComfyUI Input")
await expect(page.locator(".ue-gallery-card")).to_have_count(0)
await expect(page.locator(".ue-gallery-inspector")).to_have_count(0)
await expect(page.locator(".ue-filter-summary strong")).to_have_text("0")
release.set()
await expect(page.locator(".ue-gallery-state--empty h3")).to_have_text("这个图源还没有图片")
assert query(page.url, "folder") == "default_input::", page.url
finally:
release.set()
await page.close()
print("PASS P1-1/P2-1: delayed source switch hides stale pictures/selection, labels input, shows empty-source guidance")
async def library_race_and_ime(browser, errors):
page = await gallery(browser, errors)
started, latest_started, release = asyncio.Event(), asyncio.Event(), asyncio.Event()
queries = []
async def slow_soft(route):
term = query(route.request.url, "search")
queries.append(term)
if term == "Soft":
started.set()
response = await route.fetch()
await asyncio.wait_for(release.wait(), 12)
await route.fulfill(response=response)
else:
if term == "neverxyz":
latest_started.set()
await route.continue_()
try:
await switch_tab(page, 1)
await page.locator(".ue-library-main").first.click()
await expect(page.locator(".ue-library-row")).to_have_count(2)
# Keyboard activation is a native button, not an inaccessible clickable div.
await page.locator(".ue-library-main").first.focus()
await page.keyboard.press("Enter")
await expect(page.locator(".ue-library-row")).to_have_count(2)
await page.locator('.ue-topbar-search-wrap > button').click()
search = page.locator("#ue-topbar-search")
await page.route("**/universal_gallery/api/library/entries?*", slow_soft)
await search.fill("Soft")
await asyncio.wait_for(started.wait(), 6)
await search.fill("neverxyz")
await asyncio.wait_for(latest_started.wait(), 6)
await expect(page.locator(".ue-library-row")).to_have_count(0)
release.set()
await page.wait_for_timeout(100)
await expect(page.locator(".ue-library-row")).to_have_count(0)
await expect(search).to_have_value("neverxyz")
assert query(page.url, "library") == "Demo prompts.json", page.url
assert "Soft" in queries and "neverxyz" in queries, queries
# During composition, no intermediate pinyin query leaves the browser.
queries.clear()
await search.dispatch_event("compositionstart", {"data": "n"})
await search.fill("ni")
await page.wait_for_timeout(390)
assert "ni" not in queries, queries
await search.dispatch_event("compositionend", {"data": "ni"})
await expect(page.locator(".ue-library-row")).to_have_count(0)
await page.wait_for_timeout(310)
assert "ni" in queries, queries
finally:
release.set()
await page.close()
print("PASS P1-2/P1-3: out-of-order library search cannot replace latest results; IME and keyboard row")
async def detail_focus_and_history_guard(browser, errors):
page = await gallery(browser, errors)
try:
opener = page.locator(".ue-gallery-card img[role='button']").first
await opener.focus()
await opener.click()
modal = page.locator(".ue-modal-backdrop--lightbox")
await expect(modal).to_be_visible()
assert await page.evaluate("document.querySelector('#root').inert === true")
assert await modal.evaluate("dialog => dialog.contains(document.activeElement)")
for key in ["Tab", "Shift+Tab", "Control+a", "Tab"]:
await page.keyboard.press(key)
assert await modal.evaluate("dialog => dialog.contains(document.activeElement)"), key
await expect(page.locator(".ue-gallery-inspector")).to_have_count(0)
await page.keyboard.press("Escape")
await expect(modal).to_have_count(0)
await expect(opener).to_be_focused()
assert not await page.evaluate("document.querySelector('#root').inert")
# A browser Back that leaves a dirty detail first asks about the draft.
await page.locator(".ue-sidebar-quick button").filter(has_text="输入图库").click()
await expect(page.locator(".ue-gallery-state--empty h3")).to_be_visible()
await page.go_back(wait_until="domcontentloaded")
await expect(page.locator(".ue-gallery-card").first).to_be_visible()
await opener.focus()
await opener.click()
await page.locator(".ue-lightbox-toolbar button[aria-label='显示或隐藏侧边信息']").click()
field = page.locator(".ue-lightbox-inspector.is-open .ue-detail-form input").first
await field.fill("P12 pending draft")
await page.evaluate("history.forward()")
dialog = page.get_by_role("alertdialog")
await expect(dialog).to_be_visible()
assert await dialog.evaluate("element => element.contains(document.activeElement)")
await page.keyboard.press("Tab")
assert await dialog.evaluate("element => element.contains(document.activeElement)")
await dialog.get_by_role("button", name="取消").click()
await expect(field).to_have_value("P12 pending draft")
await expect(modal).to_be_visible()
# Cancelling a pop records the protected URL as a new history entry;
# the previous (blocked) entry is now reachable with Back.
await page.evaluate("history.back()")
await expect(dialog).to_be_visible()
await dialog.get_by_role("button", name="放弃修改").click()
await expect(page.locator(".ue-gallery-state--empty h3")).to_be_visible()
finally:
await page.close()
print("PASS P1-3/P2-3: modal focus/inert/shortcuts/return, browser history protects unsaved detail")
async def settings_and_library_history_guard(browser, errors):
page = await gallery(browser, errors)
try:
await switch_tab(page, 3)
await expect(page.locator(".ue-settings-preferences")).to_be_visible()
await page.locator(".ue-settings-source-list > button").click()
field = page.locator(".ue-settings-form input").first
await field.fill("P12 unsaved source")
await page.evaluate("history.back()")
dialog = page.get_by_role("alertdialog")
await expect(dialog).to_be_visible()
await dialog.get_by_role("button", name="取消").click()
await expect(field).to_have_value("P12 unsaved source")
await page.evaluate("history.back()")
await expect(dialog).to_be_visible()
await dialog.get_by_role("button", name="放弃修改").click()
await expect(page.locator(".ue-gallery-card").first).to_be_visible()
finally:
await page.close()
page = await gallery(browser, errors)
try:
await switch_tab(page, 1)
await page.locator(".ue-library-main").first.click()
await expect(page.locator(".ue-library-row")).to_have_count(2)
await page.locator(".ue-library-actions button[aria-label='编辑 JSON']").click()
editor = page.locator(".ue-json-editor")
await expect(editor).to_be_visible()
await editor.fill((await editor.input_value()) + " ")
await page.evaluate("history.back()")
dialog = page.get_by_role("alertdialog")
await expect(dialog).to_be_visible()
await dialog.get_by_role("button", name="取消").click()
await expect(editor).to_contain_text(" ")
await page.evaluate("history.back()")
await expect(dialog).to_be_visible()
await dialog.get_by_role("button", name="放弃修改").click()
await expect(page.locator(".ue-library-row")).to_have_count(0)
assert query(page.url, "library") == "", page.url
finally:
await page.close()
print("PASS P2-3: browser Back protects unsaved settings and library JSON, with cancel/discard choices")
async def detail_scopes(browser, errors):
page = await gallery(browser, errors)
try:
await open_mode(page, "开启双栏目录整理")
right = page.locator(".ue-dual-pane").nth(1)
await right.locator(".ue-folder-combobox-trigger").click()
await page.get_by_role("option", name="01 · Landscapes").click()
await expect(right.locator(".ue-dual-card")).to_have_count(6)
filename = await right.locator(".ue-dual-card strong[title]").first.get_attribute("title")
await right.locator(".ue-dual-card").first.click()
await expect(page.locator(".ue-gallery-inspector")).to_contain_text("已选 1 项")
await right.locator(".ue-dual-card").first.dblclick()
detail = page.locator(".ue-modal-backdrop--lightbox")
await expect(detail).to_have_attribute("aria-label", filename)
await expect(detail.locator(".ue-lightbox-filmstrip-item")).to_have_count(6)
await detail.locator(".ue-lightbox-side-nav--next").click()
assert await detail.get_attribute("aria-label") != filename
await page.keyboard.press("Escape")
await open_mode(page, "变体")
near_duplicate = page.locator(".ue-variant-card").filter(has_text="Near duplicate").first
await expect(near_duplicate).to_be_visible(timeout=10_000)
gallery_count = int(await page.locator(".ue-filter-summary strong").inner_text())
await near_duplicate.click()
await expect(page.locator(".ue-gallery-card").first).to_be_visible()
variant_items = await page.locator(".ue-variant-detail-head strong").inner_text()
await page.locator(".ue-gallery-card img[role='button']").first.click()
await expect(detail.locator(".ue-lightbox-filmstrip-item").first).to_be_visible()
variant_count = await detail.locator(".ue-lightbox-filmstrip-item").count()
assert 2 <= variant_count < gallery_count, (variant_count, gallery_count)
await detail.locator(".ue-lightbox-side-nav--next").click()
await expect(detail.locator(".ue-lightbox-filmstrip-item.is-active")).to_have_count(1)
await page.keyboard.press("Escape")
await page.locator(".ue-gallery-card .ue-select-btn").first.click()
await expect(page.locator(".ue-gallery-inspector")).to_contain_text("已选 1 项")
assert variant_items
finally:
await page.close()
print("PASS P1-4: dual right-pane and variant detail navigate only their actual lists; both feed the Inspector")
async def refresh_pagination_search_url(browser, errors):
page = await new_page(browser, errors)
response = await page.request.get(BASE + "/universal_gallery/api/images?page=1&limit=60&subfolder=default_output%3A%3A")
image_page = await response.json()
async def paged(route):
if query(route.request.url, "search") or query(route.request.url, "subfolder") != "default_output::":
await route.continue_()
return
next_page = int(query(route.request.url, "page") or "1")
payload = dict(image_page)
payload["total"] = 90
payload["page"] = next_page
payload["images"] = image_page["images"][:2] if next_page == 2 else image_page["images"]
await route.fulfill(json=payload)
await page.route("**/universal_gallery/api/images?*", paged)
started, release = asyncio.Event(), asyncio.Event()
async def slow_refresh(route):
if query(route.request.url, "force_refresh") == "true":
started.set()
await asyncio.wait_for(release.wait(), 12)
await paged(route)
await page.unroute("**/universal_gallery/api/images?*", paged)
await page.route("**/universal_gallery/api/images?*", slow_refresh)
try:
await page.reload(wait_until="networkidle")
await expect(page.locator(".ue-gallery-card").first).to_be_visible()
await page.locator('[data-tour-id="topbar-refresh"]').click()
await asyncio.wait_for(started.wait(), 6)
await expect(page.locator(".ue-operation-card--pending")).to_be_visible()
release.set()
await expect(page.locator(".ue-operation-card--success")).to_be_visible()
# Pagination resets the *main scroll container*, not the window.
await page.locator(".ue-main-shell").evaluate("element => element.scrollTop = 650")
await page.locator(".ue-pagination-actions button[aria-label='下一页']").click()
await expect(page.locator(".ue-gallery-card")).to_have_count(2)
assert query(page.url, "page") == "2", page.url
assert await page.locator(".ue-main-shell").evaluate("element => element.scrollTop") == 0
await page.locator(".ue-gallery-card img[role='button']").first.click()
await expect(page.locator(".ue-modal-backdrop--lightbox .ue-lightbox-filmstrip-item")).to_have_count(2)
await page.keyboard.press("Escape")
await page.reload(wait_until="networkidle")
await expect(page.locator(".ue-gallery-card")).to_have_count(2)
await page.go_back(wait_until="domcontentloaded")
await expect(page.locator(".ue-gallery-card")).to_have_count(len(image_page["images"]))
assert query(page.url, "page") == "", page.url
search = page.locator(".ue-gallery-search-input")
async with page.expect_request(lambda request: query(request.url, "search") == "neverxyz"):
await search.fill("neverxyz")
await expect(page.locator(".ue-gallery-state--empty h3")).to_have_text("没有符合筛选条件的图片")
await page.wait_for_function("new URL(location.href).searchParams.get('q') === 'neverxyz'")
await page.reload(wait_until="networkidle")
await expect(search).to_have_value("neverxyz")
await page.locator(".ue-gallery-state--empty button").click()
await expect(page.locator(".ue-gallery-card").first).to_be_visible()
finally:
release.set()
await page.close()
print("PASS P2-1/2/3: refresh waits for fetch, page scroll/URL/reload/Back, filtered empty reset, debounced search URL")
async def empty_states_and_retry(browser, errors):
async def no_sources(page):
context = await (await page.request.get(BASE + "/universal_gallery/api/context")).json()
context.update(sources=[], active_source_count=0, subfolders=[], move_targets=[])
await page.route("**/universal_gallery/api/context*", lambda route: route.fulfill(json=context))
await page.route("**/universal_gallery/api/images?*", lambda route: route.fulfill(json={
"images": [], "total": 0, "page": 1, "limit": 60,
}))
page = await new_page(browser, errors, before=no_sources)
try:
await expect(page.locator(".ue-gallery-state--empty h3")).to_have_text("暂无可用图源")
await page.locator(".ue-gallery-state--empty button").click()
await expect(page.locator(".ue-settings-preferences")).to_be_visible()
finally:
await page.close()
failures = []
async def fail_initial(page):
async def respond(route):
failures.append(route.request.url)
if len(failures) == 1:
await route.fulfill(status=500, json={"error": "P12 injected image load error"})
else:
await route.continue_()
await page.route("**/universal_gallery/api/images?*", respond)
page = await new_page(browser, errors, before=fail_initial)
try:
await expect(page.locator(".ue-gallery-state--empty h3")).to_have_text("图库加载失败")
await expect(page.locator(".ue-gallery-state--empty")).to_contain_text("P12 injected image load error")
await page.locator(".ue-gallery-state--empty button").click()
await expect(page.locator(".ue-gallery-card").first).to_be_visible()
assert len(failures) >= 2
finally:
await page.close()
print("PASS P2-1: no-source action opens settings; failed image request offers a working retry")
async def background_refresh_and_inspector_layout(browser, errors):
async def changed_freshness(page):
# This scenario requires overflow even with the current six-column default.
await page.add_init_script("localStorage.setItem('universal-extractor:grid-columns','3')")
await page.route("**/universal_gallery/api/images/freshness?*", lambda route: route.fulfill(json={
"changed": True, "fingerprint": "p12-updated", "total": 19,
"latest_relative_path": "new-image.png", "checked_at": 1, "subfolder": "default_output::",
}))
page = await gallery(browser, errors, live=True, before=changed_freshness)
try:
first_name = await page.locator(".ue-gallery-card img[role='button']").first.get_attribute("alt")
await page.locator(".ue-main-shell").evaluate("element => element.scrollTop = 450")
assert await page.locator(".ue-main-shell").evaluate("element => element.scrollTop") > 80
await expect(page.locator(".ue-live-refresh-pill")).to_be_visible(timeout=10_000)
assert await page.locator(".ue-main-shell").evaluate("element => element.scrollTop") > 80
assert await page.locator(".ue-gallery-card img[role='button']").first.get_attribute("alt") == first_name
await page.locator(".ue-live-refresh-pill").click()
await expect(page.locator(".ue-live-refresh-pill")).to_have_count(0)
finally:
await page.close()
for width in [960, 1280]:
page = await gallery(browser, errors, width=width)
try:
await page.locator(".ue-gallery-card .ue-select-btn").first.click()
await expect(page.locator(".ue-gallery-inspector")).to_be_visible()
assert await page.locator(".ue-gallery-card").first.is_visible()
layout = await page.evaluate("""() => {
const main = document.querySelector('.ue-main-shell').getBoundingClientRect();
const inspector = document.querySelector('.ue-gallery-inspector-layer').getBoundingClientRect();
const card = document.querySelector('.ue-gallery-card').getBoundingClientRect();
return {main:{right:main.right,bottom:main.bottom}, inspector:{left:inspector.left,top:inspector.top,right:inspector.right},
card:{right:card.right,bottom:card.bottom}, overflow:document.documentElement.scrollWidth > innerWidth};
}""")
assert not layout["overflow"], (width, layout)
assert layout["inspector"]["right"] <= width + 2, (width, layout)
if width > 960:
assert layout["main"]["right"] <= layout["inspector"]["left"] + 2, (width, layout)
else:
assert layout["main"]["bottom"] <= layout["inspector"]["top"] + 2, (width, layout)
finally:
await page.close()
print("PASS P2-2/5: scrolled live additions show a non-disruptive notice; 960/1280px Inspector leaves usable space")
async def mobile_density(browser, errors):
page = await gallery(browser, errors, width=390)
try:
trigger = page.get_by_role("button", name="网格密度")
await trigger.click()
panel = page.get_by_role("dialog", name="网格密度")
await expect(panel.locator(".ue-density-note")).to_contain_text("当前实际每行 1 列")
await panel.get_by_role("button", name="紧凑双列").click()
await expect(panel.locator(".ue-density-note")).to_contain_text("当前实际每行 2 列")
assert await page.locator(".ue-gallery-card").first.evaluate("element => element.getAttribute('data-lane')") == "0"
assert await page.locator(".ue-gallery-card[data-lane='1']").count() > 0
for label in ["舒适单列", "紧凑双列", "列表"]:
box = await panel.get_by_role("button", name=label).bounding_box()
assert box and box["height"] >= 44, (label, box)
await panel.get_by_role("button", name="列表").click()
await expect(page.locator(".ue-gallery-list-row").first).to_be_visible()
assert not await page.evaluate("document.documentElement.scrollWidth > innerWidth")
finally:
await page.close()
print("PASS P2-4: 390px effective single/double columns, 44px touch targets, list, no overflow")
async def import_target(browser, errors):
page = await gallery(browser, errors)
try:
await page.locator(".ue-toolbar-more > summary").click()
target = page.get_by_role("combobox", name="导入目标")
await expect(target.locator("option:checked")).to_contain_text("ComfyUI Output / universal_gallery_imports/")
# A File is assembled from the preview's own sample; never touches real user files.
filename = f"P12-browser-import-{time.time_ns()}.png"
await page.evaluate("""async (name) => {
const url = document.querySelector('.ue-gallery-card img').src;
const blob = await (await fetch(url)).blob();
const transfer = new DataTransfer();
transfer.items.add(new File([blob], name, {type:'image/png'}));
document.querySelector('.ue-drop-shell').dispatchEvent(new DragEvent('drop', {bubbles:true, dataTransfer:transfer}));
}""", filename)
dialog = page.get_by_role("alertdialog")
await expect(dialog).to_contain_text("ComfyUI Output / universal_gallery_imports/")
await dialog.get_by_role("button", name="导入", exact=True).click()
await expect(page.locator(".ue-inline-success")).to_contain_text("universal_gallery_imports/")
await page.get_by_role("button", name="打开导入目录").click()
assert query(page.url, "folder") == "default_output::universal_gallery_imports", page.url
await expect(page.locator(f".ue-gallery-card img[alt='{filename}']")).to_be_visible()
finally:
await page.close()
print("PASS P1-5: selector and preflight disclose subfolder; real isolated import links to server-returned directory")
async def main():
errors = []
async with async_playwright() as playwright:
browser = await playwright.chromium.launch(args=["--no-sandbox"])
try:
for case in [slow_source_and_empty, library_race_and_ime, detail_focus_and_history_guard,
settings_and_library_history_guard, detail_scopes, refresh_pagination_search_url, empty_states_and_retry,
background_refresh_and_inspector_layout, mobile_density, import_target]:
await case(browser, errors)
assert not errors, errors
finally:
await browser.close()
print("PASS P1/P2 browser regression: 10 isolated scenarios; no page errors; one temporary demo import")
if __name__ == "__main__":
asyncio.run(main())