Files

240 lines
12 KiB
Python

"""P0 user-flow regressions against the isolated preview (never a real ComfyUI gallery).
Run after `npm run build` and `python scripts/preview.py --host 0.0.0.0 --port 8189`:
python scripts/browser_p0.py http://127.0.0.1:8189
API failures/partial results are injected in the browser and do not alter demo files.
"""
from __future__ import annotations
import asyncio
import json
import sys
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"
async def new_gallery(browser, errors, width=1440):
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')"
)
await page.goto(BASE + "/gallery/", wait_until="networkidle")
await expect(page.locator(".ue-gallery-card").first).to_be_visible()
return page
async def open_detail(page):
await page.locator(".ue-gallery-card img").first.click()
await expect(page.locator(".ue-lightbox-shell")).to_be_visible()
async def select_two(page):
cards = page.locator(".ue-gallery-card")
paths = []
for index in range(2):
url = await cards.nth(index).locator("img").get_attribute("src")
paths.append(parse_qs(urlsplit(url or "").query)["relative_path"][0])
await cards.nth(index).locator(".ue-select-btn").click()
await expect(page.locator(".ue-gallery-inspector")).to_contain_text("已选 2 项")
return paths
async def slow_metadata_keeps_draft(browser, errors):
page = await new_gallery(browser, errors)
release = asyncio.Event()
async def slow_metadata(route):
response = await route.fetch()
await asyncio.wait_for(release.wait(), timeout=8)
await route.fulfill(response=response)
await page.route("**/universal_gallery/api/metadata?*", slow_metadata)
try:
await open_detail(page)
await page.locator(".ue-lightbox-toolbar button[aria-label='显示或隐藏侧边信息']").click()
panel = page.locator(".ue-lightbox-inspector.is-open")
title = panel.locator(".ue-detail-form input").first
await expect(panel.locator(".ue-detail-savebar button").first).to_be_disabled()
await title.fill("P0 未提交的标题")
await page.locator(".ue-lightbox-toolbar button[aria-label='下一页']").click()
dialog = page.get_by_role("alertdialog")
await expect(dialog.get_by_role("button", name="保存并继续")).to_be_disabled()
await dialog.get_by_role("button", name="取消").click()
await expect(title).to_have_value("P0 未提交的标题")
release.set()
await expect(panel.locator(".ue-detail-state .ue-loading-orb")).to_have_count(0)
await expect(title).to_have_value("P0 未提交的标题")
await expect(panel.locator(".ue-detail-savecopy strong").first).to_have_text("未保存")
current_name = await page.locator(".ue-lightbox-filmstrip-item.is-active").get_attribute("aria-label")
await page.locator(".ue-lightbox-toolbar button[aria-label='下一页']").click()
await page.get_by_role("alertdialog").get_by_role("button", name="放弃修改").click()
await expect(page.locator(".ue-lightbox-filmstrip-item.is-active")).not_to_have_attribute("aria-label", current_name)
finally:
release.set()
await page.unroute_all(behavior="ignoreErrors")
await page.close()
print("PASS: slow Metadata cannot overwrite draft; navigation offers cancel/discard; saving waits for load")
async def pending_save_locks_editor(browser, errors):
page = await new_gallery(browser, errors)
started, release = asyncio.Event(), asyncio.Event()
async def save_without_changing_demo(route):
updates = route.request.post_data_json["updates"]
started.set()
await asyncio.wait_for(release.wait(), timeout=8)
await route.fulfill(json={
"ok": True,
"state": {"favorite": False, "pinned": False, "boards": [], "category": updates["category"],
"title": updates["title"], "notes": updates["notes"], "updated_at": 1},
"categories": [],
})
await page.route("**/universal_gallery/api/image-state", save_without_changing_demo)
try:
await open_detail(page)
await page.locator(".ue-lightbox-toolbar button[aria-label='显示或隐藏侧边信息']").click()
panel = page.locator(".ue-lightbox-inspector.is-open")
title = panel.locator(".ue-detail-form input").first
await expect(panel.locator(".ue-detail-state .ue-loading-orb")).to_have_count(0)
await title.fill("正在保存时不应再次编辑")
old_name = await page.locator(".ue-lightbox-filmstrip-item.is-active").get_attribute("aria-label")
await page.locator(".ue-lightbox-toolbar button[aria-label='下一页']").click()
await page.get_by_role("alertdialog").get_by_role("button", name="保存并继续").click()
await asyncio.wait_for(started.wait(), timeout=5)
await expect(title).to_be_disabled()
await expect(panel.locator(".ue-detail-form input").nth(1)).to_be_disabled()
await expect(page.locator(".ue-lightbox-toolbar button[aria-label='Pin 图']")).to_be_disabled()
release.set()
await expect(page.locator(".ue-lightbox-filmstrip-item.is-active")).not_to_have_attribute("aria-label", old_name)
finally:
release.set()
await page.unroute_all(behavior="ignoreErrors")
await page.close()
print("PASS: editor is locked while save-and-continue is in flight; save advances only after response")
async def filename_and_workspace_guard(browser, errors):
page = await new_gallery(browser, errors)
try:
await open_detail(page)
await page.locator(".ue-lightbox-toolbar button[aria-label='显示或隐藏侧边信息']").click()
panel = page.locator(".ue-lightbox-inspector.is-open")
filename = panel.locator(".ue-detail-form input").nth(1)
await filename.fill("draft-filename.png")
await expect(panel.locator(".ue-detail-savecopy strong").first).to_have_text("未保存")
await expect(panel.locator(".ue-detail-savebar button").first).to_be_disabled()
# A programmatic top-bar action models keyboard focus reaching behind the lightbox.
# The App's workspace guard must protect the draft even without pointer interaction.
switch_to_library = "document.querySelectorAll('.ue-workspace-switcher .ue-topbar-tab')[1].click()"
await page.evaluate(switch_to_library)
await page.get_by_role("alertdialog").get_by_role("button", name="取消").click()
await expect(filename).to_have_value("draft-filename.png")
await expect(page.locator(".ue-lightbox-shell")).to_be_visible()
await page.evaluate(switch_to_library)
await page.get_by_role("alertdialog").get_by_role("button", name="放弃修改").click()
await expect(page.locator(".ue-lightbox-shell")).to_have_count(0)
await expect(page.locator(".ue-workspace-switcher > summary")).to_contain_text("词库")
finally:
await page.close()
print("PASS: filename-only edit is unsaved; workspace switch is guarded and can be cancelled")
async def single_delete_500(browser, errors):
page = await new_gallery(browser, errors)
await page.route("**/universal_gallery/api/images/delete", lambda route: route.fulfill(
status=500, content_type="application/json", body=json.dumps({"error": "P0 模拟删除失败"})
))
try:
await open_detail(page)
await page.locator(".ue-lightbox-toolbar button[aria-label='删除文件']").click()
await page.get_by_role("alertdialog").get_by_role("button", name="删除").click()
await expect(page.locator(".ue-lightbox-action-error")).to_contain_text("P0 模拟删除失败")
await expect(page.locator(".ue-lightbox-shell")).to_be_visible()
await expect(page.locator(".ue-operation-card--error")).to_contain_text("P0 模拟删除失败")
finally:
await page.close()
print("PASS: HTTP 500 on single delete keeps the detail open and shows an error")
async def partial_move(browser, errors):
page = await new_gallery(browser, errors)
try:
first, second = await select_two(page)
context = await (await page.request.get(BASE + "/universal_gallery/api/context")).json()
requests = []
async def partial_response(route):
requests.append(route.request.post_data_json)
await route.fulfill(json={
"ok": True, "moved": ["target/" + first.rsplit("/", 1)[-1]], "moved_sources": [first],
"missing": [second], "blocked": [], "unchanged": [],
"categories": context["categories"], "subfolders": context["subfolders"],
})
await page.route("**/universal_gallery/api/images/move", partial_response)
panel = page.locator(".ue-gallery-inspector")
await panel.locator("select").select_option(index=2)
await panel.get_by_role("button", name="移动到目录").click()
await page.get_by_role("alertdialog").get_by_role("button", name="移动").click()
await expect(panel).to_contain_text("已选 1 项")
assert requests and requests[0]["relative_paths"] == [first, second], requests
cards = page.locator(".ue-gallery-card")
await expect(cards.nth(0)).not_to_have_class("ue-gallery-card is-selected")
await expect(cards.nth(1)).to_have_class("ue-gallery-card is-selected")
error = page.locator(".ue-operation-card--error")
await expect(error).to_contain_text("已移动 1 张")
await error.locator("summary").click()
await expect(error).to_contain_text(second)
await page.wait_for_timeout(3900)
await expect(error).to_be_visible()
finally:
await page.close()
print("PASS: partial move reports actual counts, retains only failed selection and durable path details")
async def batch_delete_500(browser, errors):
page = await new_gallery(browser, errors)
try:
await select_two(page)
await page.route("**/universal_gallery/api/images/delete", lambda route: route.fulfill(
status=500, content_type="application/json", body=json.dumps({"error": "P0 模拟批量删除失败"})
))
panel = page.locator(".ue-gallery-inspector")
await panel.get_by_role("button", name="删除所选").click()
await page.get_by_role("alertdialog").get_by_role("button", name="删除").click()
await expect(panel).to_contain_text("已选 2 项")
await expect(page.locator(".ue-gallery-card.is-selected")).to_have_count(2)
await expect(page.locator(".ue-operation-card--error")).to_contain_text("P0 模拟批量删除失败")
finally:
await page.close()
print("PASS: HTTP 500 on batch delete keeps both selections and the inspector")
async def main():
errors = []
async with async_playwright() as playwright:
browser = await playwright.chromium.launch(args=["--no-sandbox"])
try:
await slow_metadata_keeps_draft(browser, errors)
await pending_save_locks_editor(browser, errors)
await filename_and_workspace_guard(browser, errors)
await single_delete_500(browser, errors)
await partial_move(browser, errors)
await batch_delete_500(browser, errors)
assert not errors, errors
finally:
await browser.close()
print("PASS: P0 browser acceptance; no page errors; isolated demo unchanged")
if __name__ == "__main__":
asyncio.run(main())