"""Isolated real-browser regression for gallery -> ComfyUI LoRA-stack actions. Build the gallery, start `python scripts/preview.py --host 0.0.0.0 --port 8189`, then `python scripts/browser_lora_stack.py http://127.0.0.1:8189`. The metadata and ComfyUI receiver are intercepted/injected; no real workflow or image files are changed. The actual bridge is separately tested by Node. """ from __future__ import annotations import asyncio import sys 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" RECEIVER = r""" (() => { localStorage.setItem('universal-extractor-locale', 'zh-CN'); localStorage.setItem('universal-extractor:onboarding-tour-v1-completed', 'true'); localStorage.setItem('universal-extractor:ui-preferences', JSON.stringify({ enableImagePrefetch: false, enableLiveGalleryRefresh: false })); const channel = new BroadcastChannel('universal-extractor-workflow'); const info = window.__ueTest = { received: [], wrongSent: false, replyError: '' }; channel.addEventListener('message', ({data}) => { if (data.type === 'universal-extractor:workflow-probe') { channel.postMessage({type: 'universal-extractor:workflow-ack', instanceId:'background',probeId:data.probeId,focused:false,visibilityState:'hidden',loraStackProtocol:2}); channel.postMessage({type: 'universal-extractor:workflow-ack', instanceId:'active',probeId:data.probeId,focused:true,visibilityState:'visible',loraStackProtocol:2}); } if (data.type === 'universal-extractor:lora-stack-message') { info.received.push(data); const id = data.payload.id; // A background ComfyUI tab tries to claim delivery first: it must not // finish the gallery operation that is addressed to the active tab. setTimeout(() => { channel.postMessage({type:'universal-extractor:lora-stack-delivered', instanceId:'background',payloadId:id,ok:true,loraStackProtocol:2,summary:{target:'WRONG',applied:50,added:50,updated:0,removed:0}}); info.wrongSent = true; }, 40); setTimeout(() => { const replace = data.payload.mode === 'replace'; channel.postMessage({type:'universal-extractor:lora-stack-delivered', instanceId:'active',payloadId:id,ok:!info.replyError,loraStackProtocol:2, error:info.replyError || null, summary:{target:'LoRA target',applied:2,added:replace?2:1,updated:replace?0:1,removed:replace?3:0}}); }, 800); } }); })(); """ async def main(): errors = [] state = {"valid": True} async with async_playwright() as playwright: browser = await playwright.chromium.launch(args=["--no-sandbox"]) page = await browser.new_page(viewport={"width": 1100, "height": 780}, reduced_motion="reduce") page.on("pageerror", lambda error: errors.append(str(error))) await page.add_init_script(RECEIVER) async def metadata(route): response = await route.fetch() data = await response.json() recipe = data.get("recipe") or {} stack = [ {"name": "A", "strength_model": .8, "strength_clip": .8, "enabled": True}, {"name": "A", "strength_model": 1.2, "strength_clip": 0, "enabled": True}, {"name": "B", "strength_model": .7, "strength_clip": .7, "enabled": True}, ] if state["valid"] else [] recipe["lora_manager"] = {"detected": state["valid"], "raw_stack": "", "loras": stack} recipe["loras"] = stack data["recipe"] = recipe await route.fulfill(json=data) await page.route("**/universal_gallery/api/metadata?*", metadata) await page.goto(BASE + "/gallery/", wait_until="networkidle") card = page.locator(".ue-gallery-card").first await expect(card).to_be_visible() async def open_from_context(): await card.click(button="right") await page.get_by_role("button", name="应用 LoRA 堆到当前工作流").click() dialog = page.get_by_role("alertdialog") await expect(dialog).to_be_visible() return dialog dialog = await open_from_context() text = await dialog.inner_text() assert "2 个 LoRA" in text, text # deduped before confirming assert "CLIP 强度与主强度不同" in text and "同步覆盖 CLIP" in text, text assert await dialog.locator("#ue-confirm-body").evaluate("el => getComputedStyle(el).whiteSpace") == "pre-line" for label in ["取消", "追加到原堆", "覆盖旧堆"]: await expect(dialog.get_by_role("button", name=label)).to_be_visible() await dialog.get_by_role("button", name="取消").click() assert await page.evaluate("window.__ueTest.received.length") == 0 dialog = await open_from_context() await dialog.get_by_role("button", name="追加到原堆").click() # The preview's CSP intentionally disallows eval-based polling. await asyncio.sleep(1.2) # probe (900 ms) + the wrong receipt (40 ms) assert await page.evaluate("window.__ueTest.wrongSent") assert await page.locator(".ue-operation-card--success").filter(has_text="WRONG").count() == 0 assert await page.locator(".ue-operation-card--pending").filter(has_text="应用 LoRA").count() == 1, "a background tab must not end the operation" await expect(page.locator(".ue-operation-card--success").filter(has_text="已将 2 个 LoRA 合并到“LoRA target”")).to_be_visible(timeout=6000) await expect(page.locator(".ue-operation-card--success").filter(has_text="新增 1 项,同名更新 1 项")).to_be_visible() received = await page.evaluate("window.__ueTest.received") assert len(received) == 1 and received[0]["targetInstanceId"] == "active", received assert received[0]["payload"]["mode"] == "append", received # The detail-page button must have exactly the same three choices. await card.locator("img").first.click() detail = page.locator(".ue-lightbox-shell") await expect(detail).to_be_visible() detail_button = detail.get_by_role("button", name="应用 LoRA 堆到当前工作流") await expect(detail_button).to_be_visible() await detail_button.click() dialog = page.get_by_role("alertdialog") await expect(dialog).to_be_visible() await page.set_viewport_size({"width": 390, "height": 780}) await expect(dialog.get_by_role("button", name="覆盖旧堆")).to_be_visible() assert not await page.evaluate("document.documentElement.scrollWidth > innerWidth"), "mobile horizontal overflow" for label in ["取消", "追加到原堆", "覆盖旧堆"]: rect = await dialog.get_by_role("button", name=label).bounding_box() assert rect and rect["x"] >= 0 and rect["x"] + rect["width"] <= 391, (label, rect) await dialog.get_by_role("button", name="覆盖旧堆").click() await expect(page.locator(".ue-operation-card--success").filter(has_text="已用 2 个 LoRA 覆盖“LoRA target”")).to_be_visible(timeout=6000) await expect(page.locator(".ue-operation-card--success").filter(has_text="移除旧条目 3 项")).to_be_visible() received = await page.evaluate("window.__ueTest.received") assert len(received) == 2 and received[-1]["payload"]["mode"] == "replace", received # A receiver failure stays visible; an empty recipe must never open # a destructive confirmation or dispatch a third stack. state["valid"] = False await detail_button.click() await expect(page.locator(".ue-operation-card--error").filter(has_text="未识别到可用")).to_be_visible() await expect(page.get_by_role("alertdialog")).to_have_count(0) assert await page.evaluate("window.__ueTest.received.length") == 2 state["valid"] = True await page.evaluate("window.__ueTest.replyError = 'receiver refused the stack'") await detail_button.click() await page.get_by_role("alertdialog").get_by_role("button", name="追加到原堆").click() await expect(page.locator(".ue-operation-card--error").filter(has_text="receiver refused the stack")).to_be_visible(timeout=6000) assert await page.evaluate("window.__ueTest.received.length") == 3 assert not errors, errors await browser.close() print("PASS: context and detail append/replace/cancel, deduplicated count, CLIP warning, mobile actions, targeted async receipts, missing-stack and error feedback. No page errors.") if __name__ == "__main__": asyncio.run(main())