test: カスタムノードのE2E機能を検証するテスト環境およびコードを追加

- pytestとPlaywrightを用いた基盤設定(conftest.py, pytest.ini)の追加
- 各環境開発者向けの設定テンプレート(test_settings.json.example)とREADMEの追加
- .gitignore にテスト生成物やローカル設定ファイル用の除外設定を追加
- StringToolsConcatノードのポート自動生成テストの追加
- StringToolsConcatのグラフ実行と文字列結合結果の出力検証テストを追加
- StringToolsRandomChoice, BalancedChoiceの固定シードによる出力検証テストの追加
This commit is contained in:
Taremin
2026-02-28 06:07:00 +09:00
parent 3a6a349426
commit 944115809b
6 changed files with 572 additions and 4 deletions
+15 -4
View File
@@ -1,4 +1,15 @@
node_modules/
settings/*
*.pyc
.vscode/*
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/
+47
View File
@@ -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
```
+169
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
[pytest]
testpaths = tests
python_files = test_*.py
asyncio_mode = auto
asyncio_default_test_loop_scope = function
+5
View File
@@ -0,0 +1,5 @@
{
"comfyui_path": "../../..",
"python_executable": "../../../../python_embeded/python.exe",
"test_port": 9876
}
+331
View File
@@ -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."