Files
Taremin 6dc652fd47 chore: ComfyUIレガシーUI(V1)対応コードの削除とテストコードの整理
ComfyUI v0.2.0以降の新フロントエンド(V2 UI)の標準化に伴い、不要となった古いレガシーUI(V1)向けの実装やテストコードの検証、曖昧な「V2」表記のコメントをクリーンアップ。

- 設定画面内のカスタム項目(プリセット管理ボタンやCSV一覧)のDOM存在検証を削除
- それに伴う不要なUserWarning警告処理を廃止
- 設定ダイアログを開くテストヘルパーからV1向けのID探索やモーダル消去処理を削除
- ソースコードおよびテストコードのコメントから曖昧な「V2」「V2 UI」といった表記を具体的な「ComfyUI設定画面」や「ダイアログ」などの表現に修正
- テストコード内の不要になった項目番号コメント(「3. 」など)の修正
2026-07-13 22:32:09 +09:00

582 lines
25 KiB
Python

import os
import sys
import json
import subprocess
import time
import socket
import pytest
from pathlib import Path
# テストディレクトリと設定ファイルのパス
TESTS_DIR = Path(__file__).parent
SETTINGS_PATH = TESTS_DIR / "test_settings.json"
# パス解決のため tests ディレクトリを sys.path に追加
sys.path.append(str(TESTS_DIR))
def pytest_addoption(parser):
"""コマンドライン引数の追加"""
parser.addoption(
"--comfy-version",
action="store",
default=None,
help="Comma-separated list of ComfyUI versions to test against (e.g., 'v0.2.0,v0.2.2')"
)
def pytest_generate_tests(metafunc):
"""comfyui_version 引数を持つテストに動的にバージョンパラメータを注入する"""
if "comfyui_version" in metafunc.fixturenames:
version_opt = metafunc.config.getoption("comfy_version")
if version_opt:
versions = [v.strip() for v in version_opt.split(",") if v.strip()]
else:
try:
with open(SETTINGS_PATH, "r", encoding="utf-8") as f:
settings = json.load(f)
versions = settings.get("comfyui_versions", [])
if not versions:
active = settings.get("active_version")
versions = [active] if active else ["local"]
except Exception:
versions = ["local"]
versions = [str(v) for v in versions if v]
metafunc.parametrize("comfyui_version", versions, scope="session")
# グローバルなタイムアウト定数
DEFAULT_TEST_TIMEOUT = 10000 # 10秒
UI_RENDER_TIMEOUT = 10000 # UI要素の描画やアニメーション待ち
NODE_LOAD_TIMEOUT = 30000 # ノードの登録・読み込み待ち (これだけは少し長めを許容)
@pytest.fixture(autouse=True)
def setup_playwright_timeout(page):
"""すべてのテストでPlaywrightのデフォルトタイムアウトを自動設定する"""
page.set_default_timeout(DEFAULT_TEST_TIMEOUT)
@pytest.fixture(scope="session")
def browser_context_args(browser_context_args):
"""HTTP キャッシュを無効化してバージョン間のアセット混在を防ぐ"""
return {
**browser_context_args,
"extra_http_headers": {
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
}
@pytest.fixture
def wmp_helpers():
class Helpers:
@staticmethod
def load_comfyui(page, comfyui_server, wait_for_comfyui):
"""ComfyUIを開き、初期ロードが完了するまで待機する"""
page.on("console", lambda msg: print(f"BROWSER CONSOLE [{msg.type}]: {msg.text}"))
page.on("response", lambda resp: print(f"BROWSER NET ERROR [{resp.status}]: {resp.url}") if resp.status >= 400 else None)
try:
page.context.clear_cookies()
except Exception as e:
print(f"Warning clearing cookies: {e}")
# 一旦 blank ページへ遷移
page.goto("about:blank")
# Service Worker & Cache Storage の登録解除・削除を完全に同期して待つ
try:
page.evaluate("""async () => {
if (navigator.serviceWorker) {
const regs = await navigator.serviceWorker.getRegistrations();
for (let r of regs) {
await r.unregister();
}
}
if (window.caches) {
const keys = await caches.keys();
for (let k of keys) {
await caches.delete(k);
}
}
}""")
except Exception as e:
print(f"Warning clearing Service Workers: {e}")
page.goto(comfyui_server)
wait_for_comfyui(page)
@staticmethod
def wait_for_editor(page):
# Editorの初期化は重い環境だと時間がかかるため、必要に応じて専用のタイムアウトを使用
try:
page.wait_for_selector("prompt-editor", state="attached", timeout=NODE_LOAD_TIMEOUT)
except:
pass
page.wait_for_selector(".monaco-editor", state="visible", timeout=NODE_LOAD_TIMEOUT)
@staticmethod
def wait_for_graph_clear(page):
page.evaluate("() => { if (typeof app !== 'undefined' && app.graph) { app.graph.clear(); } }")
page.wait_for_function("() => app.graph && app.graph._nodes.length === 0", timeout=UI_RENDER_TIMEOUT)
@staticmethod
def wait_for_ui_stabilize(page, timeout=1000):
page.wait_for_timeout(timeout)
@staticmethod
def create_node(page, type_name, pos=(100, 100)):
"""JS経由でノードを作成してグラフに追加する"""
return page.evaluate(f"""(args) => {{
const node = LiteGraph.createNode(args.type);
node.pos = args.pos;
app.graph.add(node);
if (app.canvas) app.canvas.centerOnNode(node);
return node.id;
}}""", {"type": type_name, "pos": pos})
@staticmethod
def run_and_wait_output(page, preview_node_type="PreviewAny"):
"""プロンプトを実行し、指定したタイプのノードに出力が現れるまで待機する"""
page.evaluate("app.queuePrompt(0)")
page.wait_for_function(f"""() => {{
const preview = app.graph._nodes.find(n => n.type === "{preview_node_type}");
if (!preview || !preview.widgets || !preview.widgets[0]) return false;
const val = preview.widgets[0].value;
return val && String(val).length > 0;
}}""", timeout=NODE_LOAD_TIMEOUT)
return page.evaluate(f"""() => {{
const preview = app.graph._nodes.find(n => n.type === "{preview_node_type}");
return preview.widgets[0].value;
}}""")
@staticmethod
def open_settings(page):
"""ComfyUIの設定ダイアログを開く"""
# サイドバーの Settings ボタンをクリック
try:
# Wait for sidebar button to be present and click it
btn = page.locator('button[aria-label^="Settings"]').first
btn.wait_for(state="visible", timeout=2000)
btn.click()
except:
# キーボードショートカット (Ctrl + ,) を試行
page.keyboard.press("Control+,")
# 設定ダイアログまたは検索入力ボックスが表示されるのを待機
try:
page.wait_for_selector(".p-dialog:visible, dialog:visible, [role='dialog']:visible, input[placeholder*='Search' i]:visible, input[placeholder*='検索' i]:visible", timeout=10000)
except:
# API経由で設定ダイアログを表示
page.evaluate("""() => {
const getApp = () => window.app || (window.comfyAPI && window.comfyAPI.app) || window.ComfyApp;
const app = getApp();
if (app && app.ui && app.ui.settings) app.ui.settings.show();
}""")
page.wait_for_selector(".p-dialog:visible, dialog:visible, [role='dialog']:visible, input[placeholder*='Search' i]:visible, input[placeholder*='検索' i]:visible", timeout=UI_RENDER_TIMEOUT)
@staticmethod
def switch_settings_category(page, category_name="WebuiMonacoPrompt"):
"""V2設定ダイアログでカテゴリを切り替える"""
try:
# カテゴリがDOMにレンダリングされるまで安全に待機
page.wait_for_selector(f'li[aria-label="{category_name}"], .p-listbox-item:has-text("{category_name}")', timeout=5000)
except Exception as e:
print(f"Warning: Category selector wait timed out: {e}")
page.evaluate(f"""(name) => {{
const items = document.querySelectorAll('li[aria-label], .p-listbox-item');
for (const item of items) {{
if (item.getAttribute('aria-label') === name || item.textContent.trim() === name) {{
item.click();
return true;
}}
}}
return false;
}}""", category_name)
page.wait_for_timeout(500)
@staticmethod
def set_comfy_setting(page, setting_id, value):
"""ComfyUIの設定値をセットし、localStorageと同期させる"""
page.evaluate(f"""(args) => {{
const k = args.id;
const v = args.val;
const getApp = () => window.app || (window.comfyAPI && window.comfyAPI.app) || window.ComfyApp;
const app = getApp();
// 1. localStorage (V2用の Comfy.Settings 重層化構造)
try {{
const raw = localStorage.getItem("Comfy.Settings") || "{{}}";
const settings = JSON.parse(raw);
settings[k] = v;
localStorage.setItem("Comfy.Settings", JSON.stringify(settings));
localStorage.setItem("Comfy.Settings." + k, JSON.stringify(v));
}} catch(e) {{ console.error("LS sync failed", e); }}
// 2. App 内部状態同期
if (app && app.ui && app.ui.settings) {{
app.ui.settings.setSettingValue(k, v);
if (app.ui.settings.values) app.ui.settings.values[k] = v;
// save() を手動で呼ぶと多重リクエストになりサーバーエラーを引き起こすため削除
}}
// 通知イベント
window.dispatchEvent(new CustomEvent("comfy-settings-changed", {{ detail: {{ id: k, value: v }} }}));
}}""", {"id": setting_id, "val": value})
page.wait_for_timeout(500)
@staticmethod
def get_comfy_setting(page, setting_id):
"""ComfyUIの設定値を取得する"""
return page.evaluate(f"""(id) => {{
const getApp = () => window.app || (window.comfyAPI && window.comfyAPI.app) || window.ComfyApp;
const app = getApp();
if (app && app.ui && app.ui.settings) {{
return app.ui.settings.getSettingValue(id);
}}
const raw = localStorage.getItem("Comfy.Settings") || "{{}}";
return JSON.parse(raw)[id];
}}""", setting_id)
@staticmethod
def open_preset_dialog(page):
"""Manage Presets ダイアログを開く"""
clicked = page.evaluate("""() => {
// ボタンのテキストで検索してクリック
const allBtns = Array.from(document.querySelectorAll('button'));
const openBtn = allBtns.find(b => b.textContent === "Open Dialog" || b.textContent.includes("Manage Language Presets"));
if (openBtn) { openBtn.click(); return true; }
return false;
}""")
if not clicked:
try:
# 最終手段: Playwright locator (timeout を短くして失敗を早く検知)
page.get_by_role("button", name="Open Dialog").first.click(timeout=3000)
clicked = True
except Exception as e:
print(f"Warning: Failed to click Open Dialog button: {e}")
# API 経由で直接ダイアログを開くフォールバック
print("Falling back to API to open preset dialog...")
page.evaluate("""() => {
if (window.WebuiMonacoPrompt && window.WebuiMonacoPrompt.getPresetDialog) {
window.WebuiMonacoPrompt.getPresetDialog().show();
}
}""")
clicked = True
if clicked:
page.wait_for_selector("#webui-monaco-preset-dialog", state="visible", timeout=UI_RENDER_TIMEOUT)
return Helpers
@pytest.fixture(scope="session")
def test_settings():
"""test_settings.json から設定を読み込む"""
if not SETTINGS_PATH.exists():
pytest.fail(f"Configuration file not found: {SETTINGS_PATH}. Please create it from {SETTINGS_PATH}.example")
with open(SETTINGS_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def wait_for_server(url, timeout=300.0):
"""サーバーがHTTPリクエストに応答するまで待機する"""
import urllib.request
print(f"[{time.strftime('%H:%M:%S')}] Connecting to {url} (timeout_per_try=5s, total_timeout={timeout}s)...")
start_time = time.time()
while True:
try:
with urllib.request.urlopen(url, timeout=5) as response:
if response.status == 200:
return True
except Exception:
if time.time() - start_time > timeout:
return False
time.sleep(1.0)
@pytest.fixture(scope="session")
def comfyui_server(test_settings, comfyui_version):
"""ComfyUIサーバーを起動し、そのベースURLを返すフィクスチャ"""
port = test_settings["test_port"]
if comfyui_version == "local":
comfyui_path = Path(test_settings["comfyui_path"]).resolve()
python_exe = Path(test_settings["python_executable"]).resolve()
else:
print(f"\n[Fixture] Ensuring ComfyUI version '{comfyui_version}' is set up...")
from downloader import ComfyUIDownloader
downloader = ComfyUIDownloader()
comfyui_path, python_exe = downloader.setup_environment(comfyui_version)
comfyui_path = comfyui_path.resolve()
python_exe = python_exe.resolve()
# ComfyUIの起動コマンド (E2Eテスト用に軽量化)
cmd = [
str(python_exe), "main.py",
"--port", str(port),
"--listen", "127.0.0.1",
"--cpu",
"--disable-smart-memory",
"--disable-xformers"
]
# 環境変数の準備
env = os.environ.copy()
# ComfyUIのパスを絶対パスでPYTHONPATHに追加(Stability Matrix環境で必要)
env["PYTHONPATH"] = str(comfyui_path.absolute())
print(f"\nStarting ComfyUI server: {comfyui_path} using {python_exe} on port {port}")
process = subprocess.Popen(
cmd,
cwd=comfyui_path,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
encoding="utf-8",
errors="replace",
env=env,
bufsize=1,
universal_newlines=True
)
# サーバーの出力を非ブロッキングで読み取りつつ、サーバーの応答を待機
start_time = time.time()
base_url = f"http://127.0.0.1:{port}"
print(f"\nWaiting for ComfyUI server to respond at {base_url}...")
# stdoutを非ブロッキングで読み取るための準備 (Windows対応)
import threading
import queue
log_file = open("comfyui_server.log", "w", encoding="utf-8", errors="replace", buffering=1)
output_queue = queue.Queue()
def enqueue_output(out, queue, log):
for line in iter(out.readline, ''):
queue.put(line)
log.write(line)
out.close()
log.close()
t = threading.Thread(target=enqueue_output, args=(process.stdout, output_queue, log_file))
t.daemon = True
t.start()
# サーバーの応答をポーリング
is_ready = False
while True:
# ログの出力
try:
while True:
line = output_queue.get_nowait()
print(f"ComfyUI: {line.strip()}")
except queue.Empty:
pass
# サーバーが立ち上がったか確認
if wait_for_server(base_url, timeout=2.0):
elapsed = time.time() - start_time
print(f"\nComfyUI server is READY! (Startup time: {elapsed:.2f} seconds)")
is_ready = True
break
if process.poll() is not None:
print("ComfyUI process exited prematurely.")
break
if time.time() - start_time > 300:
print("Timeout waiting for ComfyUI server.")
break
time.sleep(0.5)
if not is_ready:
process.terminate()
pytest.fail(f"ComfyUI server failed to respond at {base_url} within timeout.")
# テストコード側から拡張機能の実際のインストール先パスを参照できるように環境変数をセット
if comfyui_version == "local":
extension_path = Path(os.getcwd()).resolve()
else:
extension_path = (comfyui_path / "custom_nodes" / "webui-monaco-prompt").resolve()
os.environ["COMFYUI_EXTENSION_PATH"] = str(extension_path.absolute())
yield base_url
print("\nShutting down ComfyUI server...")
os.environ.pop("COMFYUI_EXTENSION_PATH", None)
if os.name == "nt":
try:
# Windowsでは taskkill /F /T を使用して子プロセスツリーごと強制終了する
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(process.pid)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
except Exception as e:
print(f"Failed to taskkill ComfyUI process tree on Windows: {e}")
process.kill()
# サーバーのプロセス終了完了を待つ
if os.name != "nt":
process.wait()
# ポートがOSによって完全に解放(バインド可能に)されるまで最大10秒間ポーリング待機する
print(f"Waiting for port {port} to be released by OS...")
import socket
for i in range(10):
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# SO_REUSEADDRを無効にした状態でバインドを試み、完全にポートが解放されているか検証
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
s.bind(("127.0.0.1", port))
s.close()
print(f"Port {port} has been successfully released and is ready for reuse.")
break
except Exception:
time.sleep(1.0)
@pytest.fixture
def wait_for_comfyui():
"""ComfyUIのUIがロードされるまで待機する共通ヘルパーフィクスチャ"""
from playwright.sync_api import Page
def _wait(page: Page):
print(f"[{time.strftime('%H:%M:%S')}] Waiting for basic ComfyUI elements (timeout 60s)...")
try:
page.wait_for_selector(".comfy-menu, .comfyui-menu, .side-bar-button, #comfy-canvas-container, body", state="attached", timeout=60000)
print(f"[{time.strftime('%H:%M:%S')}] Basic elements attached.")
except Exception as e:
print(f"[{time.strftime('%H:%M:%S')}] Wait for selector failed: {e}")
page.screenshot(path="tests/debug_selector_fail.png")
# app.graph が準備できるまで待つ (複数のパスを試行)
print(f"[{time.strftime('%H:%M:%S')}] Waiting for window.app.graph (timeout 180s)...")
try:
page.wait_for_function("""
() => {
const findApp = (root) => {
if (root.app) return root.app;
if (root.comfyAPI && root.comfyAPI.app) return root.comfyAPI.app;
if (root.ComfyApp) return root.ComfyApp;
// V2のカスタムエレメントを探す
const canvas = document.querySelector("comfy-canvas");
if (canvas && canvas.app) return canvas.app;
const menu = document.querySelector("comfy-menu");
if (menu && menu.app) return menu.app;
return null;
};
const app = findApp(window) || (window.parent && findApp(window.parent));
return !!(app && app.graph);
}
""", timeout=180000)
print(f"[{time.strftime('%H:%M:%S')}] window.app.graph is READY.")
except Exception as e:
print(f"[{time.strftime('%H:%M:%S')}] Wait for function failed: {e}")
page.screenshot(path="tests/debug_function_fail.png")
print(f"Current URL: {page.url}")
raise e
# ローディング画面が消えるのを待つ
print(f"[{time.strftime('%H:%M:%S')}] Waiting for loading overlay to hide (timeout 30s)...")
try:
page.wait_for_selector("#comfy-file-input-overlay", state="hidden", timeout=30000)
print(f"[{time.strftime('%H:%M:%S')}] Loading overlay hidden.")
except:
print(f"[{time.strftime('%H:%M:%S')}] Loading overlay wait timed out or element not found (continuing anyway).")
pass
return _wait
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
# execute all other hooks to obtain the report object
outcome = yield
rep = outcome.get_result()
# we only look at "call" phase failures (not setup/teardown)
if rep.when != "call":
return
if not (rep.failed or (hasattr(rep, "wasxfail") and not rep.passed)):
return
# Check if the test has a 'page' fixture
if "page" not in item.funcargs:
return
page = item.funcargs["page"]
try:
import datetime
timestamp = datetime.datetime.now().strftime("%H%M%S")
# Clean test name for filename
safe_name = item.name.replace("[", "_").replace("]", "_").replace("/", "_").replace(":", "_")
dump_dir = Path("debug_dumps")
dump_dir.mkdir(parents=True, exist_ok=True)
html_filename = dump_dir / f"failure_{safe_name}_{timestamp}.html"
with open(html_filename, "w", encoding="utf-8") as f:
f.write(page.content())
print(f"\n[DEBUG] Captured HTML failure dump to {html_filename}")
# Screenshot too if possible (wrapped in try-except with short timeout to prevent hang on fonts load)
png_filename = dump_dir / f"failure_{safe_name}_{timestamp}.png"
try:
page.screenshot(path=str(png_filename), timeout=3000)
print(f"[DEBUG] Captured screenshot to {png_filename}")
except Exception as screenshot_err:
print(f"\n[DEBUG] Failed to capture failure screenshot: {screenshot_err}")
except Exception as e:
print(f"\n[DEBUG] Failed to capture failure info: {e}")
def parse_version(v_str):
"""バージョン文字列を比較可能なタプルに変換する簡易ヘルパー"""
cleaned = v_str.lstrip("vr").split("-")[0]
parts = []
for p in cleaned.split("."):
digits = "".join([c for c in p if c.isdigit()])
parts.append(int(digits) if digits else 0)
return tuple(parts)
def pytest_runtest_setup(item):
"""min_comfy_version マーカーを処理して条件付きでスキップする"""
min_version_marker = item.get_closest_marker("min_comfy_version")
if min_version_marker:
required_version_str = min_version_marker.args[0]
# item.callspec.params から現在のパラメータ(バージョン)を取得
if hasattr(item, "callspec") and "comfyui_version" in item.callspec.params:
current_version_str = item.callspec.params["comfyui_version"]
if current_version_str == "local":
return
try:
# packaging.version があればそれを使用、なければ自前フォールバック
try:
from packaging import version
curr_v = version.parse(current_version_str.lstrip("v"))
req_v = version.parse(required_version_str.lstrip("v"))
is_lower = curr_v < req_v
except ImportError:
curr_v = parse_version(current_version_str)
req_v = parse_version(required_version_str)
is_lower = curr_v < req_v
if is_lower:
pytest.skip(
f"Skipping test: requires ComfyUI version >= {required_version_str}, "
f"but running with {current_version_str}"
)
except Exception as e:
print(f"Version comparison failed for {current_version_str} vs {required_version_str}: {e}")