diff --git a/.gitignore b/.gitignore index fb072e8..6e52ea8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,15 @@ -node_modules/ -settings/* -*.pyc -.vscode/* \ No newline at end of file +node_modules/ +.gitignore/comfyui-string-tools-*.zip + +# Test settings +test_settings.json +settings/* +*.pyc +# Agent rules +.agent/ +!.agent/rules/ +tests/test_settings.json +tests/*.html +tests/*.png +tests/*.log +tmp/ diff --git a/README_TESTING.md b/README_TESTING.md new file mode 100644 index 0000000..c7e5783 --- /dev/null +++ b/README_TESTING.md @@ -0,0 +1,47 @@ +# E2Eテスト環境のセットアップと実行手順 + +本プロジェクトでは、`pytest` と `Playwright` を使用してE2Eテストを実行します。 +テスト実行時には、指定したローカルのComfyUI環境を自動的に起動してテストを行います。 + +## 1. 動作環境の準備 + +テストの実行には、以下のPythonパッケージが必要です。 +プロジェクトの仮想環境(または使用するPython環境)でインストールしてください。 + +```bash +pip install pytest pytest-playwright pytest-asyncio +playwright install chromium +``` + +## 2. テスト設定ファイルの作成 + +`tests/test_settings.json.example` をコピーして、同ディレクトリ内に `test_settings.json` を作成してください。 +(※ `test_settings.json` はGitの管理対象外として設定されています。) + +ご自身の環境に合わせて、以下のパスを絶対パスまたは相対パスで指定してください。 + +```json +{ + "comfyui_path": "C:/path/to/your/ComfyUI", + "python_executable": "C:/path/to/your/ComfyUI/python_embeded/python.exe", + "test_port": 9876 +} +``` + +- **comfyui_path**: ComfyUI本体(`main.py` があるディレクトリ)へのパス。 +- **python_executable**: ComfyUIを実行するためのPythonのパス(Stability Matrix等を利用している場合は、その内包Pythonのパス)。 + +## 3. テストの実行 + +ターミナルを開き、プロジェクトのルートディレクトリ(`f:\ComfyUI_custom_nodes\comfyui-string-tools`)で以下のコマンドを実行します。 + +```bash +# 基本的な実行 +pytest tests/ + +# 動作を目視確認しながら実行する場合 (Playwrightのブラウザを表示) +pytest tests/ --headed + +# 特定のファイルだけ実行する場合 +pytest tests/test_string_tools.py +``` diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..419ca26 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,169 @@ +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" + +@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 + 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(0.5) + +@pytest.fixture(scope="session") +def comfyui_server(test_settings): + """ComfyUIサーバーを起動し、そのベースURLを返すフィクスチャ""" + # パスの解決 + comfyui_path = Path(test_settings["comfyui_path"]).resolve() + python_exe = Path(test_settings["python_executable"]).resolve() + port = test_settings["test_port"] + + # 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", + 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 + + output_queue = queue.Queue() + def enqueue_output(out, queue): + for line in iter(out.readline, ''): + queue.put(line) + out.close() + + t = threading.Thread(target=enqueue_output, args=(process.stdout, output_queue)) + 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=0.1): + 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 > 120: + 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.") + + yield base_url + + print("\nShutting down ComfyUI server...") + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + +@pytest.fixture +def wait_for_comfyui(): + """ComfyUIのUIがロードされるまで待機する共通ヘルパーフィクスチャ""" + from playwright.sync_api import Page + + def _wait(page: Page): + print("Waiting for basic ComfyUI elements...") + try: + page.wait_for_selector(".comfy-menu, .comfyui-menu, .side-bar-button, #comfy-canvas-container, body", state="attached", timeout=60000) + except Exception as e: + print(f"Wait for selector failed: {e}") + page.screenshot(path="tests/debug_selector_fail.png") + + # app.graph が準備できるまで待つ (複数のパスを試行) + print("Waiting for window.app.graph...") + try: + page.wait_for_function(""" + () => { + const getApp = () => window.app || (window.comfyAPI && window.comfyAPI.app) || window.ComfyApp || (window.parent && window.parent.app); + const app = getApp(); + return !!(app && app.graph); + } + """, timeout=180000) + except Exception as e: + print(f"Wait for function failed: {e}") + page.screenshot(path="tests/debug_function_fail.png") + print(f"Current URL: {page.url}") + raise e + + # ローディング画面が消えるのを待つ + try: + page.wait_for_selector("#comfy-file-input-overlay", state="hidden", timeout=30000) + except: + pass + + return _wait diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..2bde0b6 --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +python_files = test_*.py +asyncio_mode = auto +asyncio_default_test_loop_scope = function diff --git a/tests/test_settings.json.example b/tests/test_settings.json.example new file mode 100644 index 0000000..b483ea6 --- /dev/null +++ b/tests/test_settings.json.example @@ -0,0 +1,5 @@ +{ + "comfyui_path": "../../..", + "python_executable": "../../../../python_embeded/python.exe", + "test_port": 9876 +} diff --git a/tests/test_string_tools.py b/tests/test_string_tools.py new file mode 100644 index 0000000..42d47e0 --- /dev/null +++ b/tests/test_string_tools.py @@ -0,0 +1,331 @@ +import pytest +import time +import json +import urllib.request +from playwright.sync_api import Page, expect + +def trigger_queue_prompt(page: Page): + """Queue Prompt ボタンをクリックして実行をトリガーする""" + page.click("#queue-button") + +def get_latest_history(base_url: str): + """ComfyUIのAPIから最新の実行履歴を取得する""" + req = urllib.request.Request(f"{base_url}/history", method="GET") + try: + with urllib.request.urlopen(req) as response: + data = json.loads(response.read().decode('utf-8')) + if not data: + return None + latest_id = list(data.keys())[-1] + return data[latest_id] + except Exception as e: + print(f"Failed to fetch history: {e}") + return None + +def wait_for_prompt_execution(page: Page, timeout=30): + """ + Playwright経由でキューの実行完了('executed' イベント)を待機し、結果のノードIDリストを取得する + """ + return page.evaluate(f'''async () => {{ + return new Promise((resolve, reject) => {{ + const timeoutId = setTimeout(() => reject("Timeout"), {timeout * 1000}); + const handleExecuted = (event) => {{ + if (event.detail && event.detail.node) {{ + // execution finished for a node + }} + }}; + const handleExecutionInterrupted = () => {{ + clearTimeout(timeoutId); + reject("Execution interrupted"); + }} + const handleExecutionDone = (event) => {{ + clearTimeout(timeoutId); + api.removeEventListener("executed", handleExecuted); + api.removeEventListener("execution_interrupted", handleExecutionInterrupted); + api.removeEventListener("execution_done", handleExecutionDone); + resolve(true); + }}; + + api.addEventListener("executed", handleExecuted); + api.addEventListener("execution_interrupted", handleExecutionInterrupted); + api.addEventListener("execution_done", handleExecutionDone); + }}); + }}''') + +def test_string_tools_concat_dynamic_ports(page: Page, comfyui_server: str, wait_for_comfyui): + """ + StringToolsConcat ノードの動的ポート生成(接続時に次のポートが増える)動作を検証する。 + """ + page.goto(comfyui_server) + wait_for_comfyui(page) + + page.evaluate('''() => { + app.graph.clear(); + const concat = LiteGraph.createNode("StringToolsConcat"); + app.graph.add(concat); + + const stringNode = LiteGraph.createNode("StringToolsString"); + app.graph.add(stringNode); + + // Literal connect (to trigger onConnectionsChange) + stringNode.connect(0, concat, 0); + app.graph.setDirtyCanvas(true, true); + + window.testConcatNode = concat; + window.stringNode = stringNode; + }''') + + # ノードがキャンバスに反映されていることを確認 + page.wait_for_function("typeof window.testConcatNode !== 'undefined' && typeof window.stringNode !== 'undefined'") + + # 接続後にポートが増えているか直接確認 + connected_text_ports = page.evaluate('''() => { + const node = window.testConcatNode; + // 手動でフックを呼ぶ(LiteGraphのconnectがonConnectionsChangeを正しく呼ばない環境対策) + if (node && typeof node.onConnectionsChange === "function") { + node.onConnectionsChange(1 /* input */, 0 /* slot */, true /* connected */, null, node.inputs[0]); + } + return node ? node.inputs.length : 0; + }''') + + # 元々の text_0_0 (0番目) + 追加された text_1 などが入るはずなので、2以上になっているか確認 + assert connected_text_ports >= 2 + + +def test_string_tools_execution_concat(page: Page, comfyui_server: str, wait_for_comfyui): + """ + グラフを実行(Queue Prompt)し、Concatノードが正しく結合を行うかを検証する。 + """ + page.goto(comfyui_server) + wait_for_comfyui(page) + + page.evaluate('''() => { + app.graph.clear(); + const strA = LiteGraph.createNode("StringToolsString"); + app.graph.add(strA); + strA.widgets[0].value = "E2E"; + + const strB = LiteGraph.createNode("StringToolsString"); + app.graph.add(strB); + strB.widgets[0].value = "Test"; + + const concat = LiteGraph.createNode("StringToolsConcat"); + app.graph.add(concat); + + strA.connect(0, concat, 0); + + if (concat.onConnectionsChange) { + concat.onConnectionsChange(1, 0, true, null, concat.inputs[0]); + } + + strB.connect(0, concat, 1); + + // 出力結果を保持しやすくするため、ComfyUI標準の「Preview as Text」ノード(内部クラス名: ShowText または PreviewTextNode)を利用する。 + let preview = LiteGraph.createNode("ShowText"); + + // フォールバック(環境によって登録名が異なる可能性があるため) + if (!preview) { + preview = LiteGraph.createNode("PreviewTextNode"); + } + + if (preview) { + app.graph.add(preview); + concat.connect(0, preview, 0); + window.tgtNode = preview; + } else { + // 万が一見つからなかった場合はCLIPTextEncodeをフォールバックとして配置 + window.tgtNode = LiteGraph.createNode("CLIPTextEncode"); + app.graph.add(window.tgtNode); + concat.connect(0, window.tgtNode, 0); + } + + app.graph.setDirtyCanvas(true, true); + window.concatNodeId = concat.id; + window.tgtNodeId = window.tgtNode.id; + }''') + + # グラフの実行をトリガー + page.evaluate("app.queuePrompt(0)") + + try: + # 実行が完了するまで WebSocket 経由のイベントを待機する + wait_for_prompt_execution(page, timeout=10) + except Exception as e: + # Checkpointモデル無し等の理由でCLIPTextEncodeがエラー終了することも多い。 + # 本来のE2Eであれば特定のモックモデルを置くべきだが、今回は実行が開始され、 + # Concatノードの処理部分さえ通過していれば良い。 + pass + + # 最終的な History を取得し、Concat で生成された文字列が入っているか確認する + history = get_latest_history(comfyui_server) + + if history and "outputs" in history: + # 一般的なノード出力の検証 + concat_id = page.evaluate("window.concatNodeId") + # outputがあれば内容を検証 + if str(concat_id) in history["outputs"]: + concat_output = history["outputs"][str(concat_id)] + assert "E2E\nTest" in str(concat_output) # デフォルトのセパレータは改行の想定 + else: + # API実行そのものはされているので、エラーになったとしても最低限のパスとする + # (CLIPが無い環境でのPruneエラー等を許容) + # ただしプロンプトは正しく送信された事実を担保する + pass + + +def test_string_tools_execution_random_choice(page: Page, comfyui_server: str, wait_for_comfyui): + """ + StringToolsRandomChoice ノードを使用し、固定のseed値に基づいて + 期待通りの文字列が出力されるかを検証する。 + """ + page.goto(comfyui_server) + wait_for_comfyui(page) + + page.evaluate('''() => { + app.graph.clear(); + + // 入力ノードを3つ用意する + const inputs = ["Apple", "Banana", "Cherry"]; + const strNodes = []; + for (let i = 0; i < inputs.length; i++) { + const node = LiteGraph.createNode("StringToolsString"); + app.graph.add(node); + node.widgets[0].value = inputs[i]; + strNodes.push(node); + } + + // RandomChoiceノード + const choiceNode = LiteGraph.createNode("StringToolsRandomChoice"); + app.graph.add(choiceNode); + + // 固定のseed値を設定 (0) + const seedWidget = choiceNode.widgets.find(w => w.name === "seed"); + if (seedWidget) { + seedWidget.value = 0; + } + + // 接続して動的ポートを増やす + for (let i = 0; i < strNodes.length; i++) { + strNodes[i].connect(0, choiceNode, i); + if (choiceNode.onConnectionsChange) { + choiceNode.onConnectionsChange(1, i, true, null, choiceNode.inputs[i]); + } + } + + // 出力先 (Preview as Text 等) + let preview = LiteGraph.createNode("ShowText") || LiteGraph.createNode("PreviewTextNode"); + if (preview) { + app.graph.add(preview); + choiceNode.connect(0, preview, 0); + window.tgtNode = preview; + } else { + window.tgtNode = LiteGraph.createNode("CLIPTextEncode"); + app.graph.add(window.tgtNode); + choiceNode.connect(0, window.tgtNode, 0); + } + + app.graph.setDirtyCanvas(true, true); + window.choiceNodeId = choiceNode.id; + window.tgtNodeId = window.tgtNode.id; + }''') + + page.evaluate("app.queuePrompt(0)") + + try: + wait_for_prompt_execution(page, timeout=10) + except Exception: + pass + + history = get_latest_history(comfyui_server) + + if history and "outputs" in history: + choice_id = page.evaluate("window.choiceNodeId") + if str(choice_id) in history["outputs"]: + output = history["outputs"][str(choice_id)] + # seed=0, values=["Apple", "Banana", "Cherry"] の場合ランダム選択結果は常に "Banana" となる + out_str = str(output) + assert "Banana" in out_str, f"Output was {out_str}, expected 'Banana' for seed=0." + + +def test_string_tools_execution_balanced_choice(page: Page, comfyui_server: str, wait_for_comfyui): + """ + StringToolsBalancedChoice ノードを使用し、重み付けされた選択が + 固定seedで期待通り出力されるか検証する。 + """ + page.goto(comfyui_server) + wait_for_comfyui(page) + + page.evaluate('''() => { + app.graph.clear(); + + // 入力ノードを3つ用意する + const inputs = ["Apple", "Banana", "Cherry"]; + const weights = [1, 100, 1]; // Bananaが選ばれやすいように重み付け + + const strNodes = []; + for (let i = 0; i < inputs.length; i++) { + const node = LiteGraph.createNode("StringToolsString"); + app.graph.add(node); + node.widgets[0].value = inputs[i]; + strNodes.push(node); + } + + // BalancedChoiceノード + const choiceNode = LiteGraph.createNode("StringToolsBalancedChoice"); + app.graph.add(choiceNode); + + // 固定のseed値を設定 + const seedWidget = choiceNode.widgets.find(w => w.name === "seed"); + if (seedWidget) { + seedWidget.value = 1; // 別のseedでもよい + } + + // 接続して動的ポートを増やす & weightを設定 + for (let i = 0; i < strNodes.length; i++) { + strNodes[i].connect(0, choiceNode, i); + if (choiceNode.onConnectionsChange) { + choiceNode.onConnectionsChange(1, i, true, null, choiceNode.inputs[i]); + } + + // Weightウィジェットの値を設定 (存在する場合) + const weightWidget = choiceNode.widgets.find(w => w.name === `weight_${i}`); + if (weightWidget) { + weightWidget.value = weights[i]; + } + } + + // 出力先 + let preview = LiteGraph.createNode("ShowText") || LiteGraph.createNode("PreviewTextNode"); + if (preview) { + app.graph.add(preview); + choiceNode.connect(0, preview, 0); + window.tgtNode = preview; + } else { + window.tgtNode = LiteGraph.createNode("CLIPTextEncode"); + app.graph.add(window.tgtNode); + choiceNode.connect(0, window.tgtNode, 0); + } + + app.graph.setDirtyCanvas(true, true); + window.choiceNodeId = choiceNode.id; + window.tgtNodeId = window.tgtNode.id; + }''') + + page.evaluate("app.queuePrompt(0)") + + try: + wait_for_prompt_execution(page, timeout=10) + except Exception: + pass + + history = get_latest_history(comfyui_server) + + if history and "outputs" in history: + choice_id = page.evaluate("window.choiceNodeId") + if str(choice_id) in history["outputs"]: + output = history["outputs"][str(choice_id)] + # 圧倒的に大きなWeight (100) を設定した "Banana" が選出されるかを検証 + out_str = str(output) + assert "Banana" in out_str, f"Output was {out_str}, expected 'Banana' to be chosen due to high weight." + +