Files
Taremin 26e910b4f1 chore(test): ComfyUIの複数バージョン自動検証基盤の導入とテスト安定化
テスト時に指定された複数のComfyUIバージョンを自動ダウンロードし、最適な仮想環境を構築した上でテストを実行する仕組みを導入。また、Playwright同期APIに起因するデッドロックやキャッシュ汚染によるテストのハングアップ問題を解消。

- 設定ファイルで複数バージョンの定義をサポート
- 該当するComfyUIおよびEmbeddable Pythonを自動取得するdownloaderモジュールを追加
- pytest_generate_testsによりバージョン別のテストを動的にパラメータ化
- min_comfy_versionマーカーによるバージョン制限テストの条件付きスキップをサポート
- Windows環境でのゾンビプロセス防止のためtaskkillによる強制終了処理を追加
- バージョン切り替え時のブラウザキャッシュ汚染を防ぐためキャッシュを無効化
- Playwright同期APIのダイアログ処理をJavaScript側でのモック化へ移行しデッドロックを回避
- 設定画面でのCSVトグル描画有無によるテストアプローチの動的切り替えを実装
- リサイズ操作のsteps指定など各種タイミング競合とタイムアウト処理を強化
2026-07-13 21:13:11 +09:00

347 lines
16 KiB
Python

import pytest
import os
import json
from playwright.sync_api import Page, expect
def test_multitext_layout_and_features(page: Page, comfyui_server, wait_for_comfyui, wmp_helpers):
"""レイアウト、リサイズ、D&D機能を包括的に検証する"""
page.set_viewport_size({"width": 1280, "height": 720})
screenshot_dir = os.path.join(os.getcwd(), "tests", "screenshots_features")
os.makedirs(screenshot_dir, exist_ok=True)
page.on("console", lambda msg: print(f"BROWSER: {msg.text}"))
wmp_helpers.load_comfyui(page, comfyui_server, wait_for_comfyui)
wmp_helpers.wait_for_graph_clear(page)
print("DEBUG - Starting browser automation...")
page.evaluate("console.log('BROWSER: HELLO - Console redirection check')")
# MultiTextノードを作成
wmp_helpers.create_node(page, "WebuiMonacoPromptMultiText", [100, 100])
wmp_helpers.wait_for_editor(page)
page.evaluate("""() => {
const node = window.app.graph._nodes[0];
node.setSize([800, 600]);
if (node.onResize) node.onResize(node.size);
}""")
# ---------------------------------------------------------
# 1. レイアウト検証(スクリーン座標・はみ出しチェック)
# ---------------------------------------------------------
# 多少の待機(リサイズ反映待ち)
wmp_helpers.wait_for_ui_stabilize(page, 1000)
layout_info = page.evaluate("""() => {
const findByPart = (root, part) => {
if (root.className && typeof root.className === 'string' && root.className.includes(part)) return root;
const children = Array.from(root.children || []);
for (const child of children) {
const found = findByPart(child, part);
if (found) return found;
}
if (root.shadowRoot) {
const found = findByPart(root.shadowRoot, part);
if (found) return found;
}
return null;
};
const container = findByPart(document.body, "multitext-container");
if (!container) return { error: "Container not found" };
const node = window.app.graph._nodes[0];
const containerRect = container.getBoundingClientRect();
return {
"nodeSize": node.size,
"actual": {
"width": containerRect.width,
"height": containerRect.height,
"visible": containerRect.width > 0 && containerRect.height > 0
}
};
}""")
print(f"DEBUG - Layout Dump: {json.dumps(layout_info, indent=2)}")
assert "error" not in layout_info, f"Layout verification failed: {layout_info.get('error')}"
actual = layout_info['actual']
assert actual['visible'], "Widget should be visible"
assert actual['width'] > 100, f"Widget width {actual['width']} is too small"
assert actual['height'] > 100, f"Widget height {actual['height']} is too small"
page.screenshot(path=os.path.join(screenshot_dir, "01_layout_check.png"))
# ---------------------------------------------------------
# 2. リサイズ検証
# ---------------------------------------------------------
sidebar = page.locator("[class*='multitext-sidebar']:not([class*='toolbar']):not([class*='search'])")
initial_width = sidebar.bounding_box()["width"]
page.evaluate("window.RESIZE_DEBUG = []")
resizer = page.locator("[class*='multitext-resizer']")
resizer_box = resizer.bounding_box()
print(f"DEBUG - Resizer Bounding Box: {resizer_box}")
start_x = resizer_box["x"] + resizer_box["width"] / 2
start_y = resizer_box["y"] + resizer_box["height"] / 2
# イベント泥棒(最前面要素)の特定
hit_element = page.evaluate(f"""() => {{
const el = document.elementFromPoint({start_x}, {start_y});
if (!el) return 'null';
const style = window.getComputedStyle(el);
return el.tagName + (el.className ? '.' + el.className.split(' ').join('.') : '') + ' (z-index: ' + style.zIndex + ', pointer-events: ' + style.pointerEvents + ')';
}}""")
print(f"DEBUG - Element at resizer coords: {hit_element}")
# ハンドルの中央を掴んで 150px 右へ
page.mouse.move(start_x, start_y)
page.mouse.down()
page.mouse.move(start_x + 150, start_y)
page.mouse.up()
wmp_helpers.wait_for_ui_stabilize(page, 1000)
debug_logs = page.evaluate("window.RESIZE_DEBUG")
print(f"DEBUG - Browser Resize Logs: {json.dumps(debug_logs, indent=2)}")
new_width = page.locator("[class*='multitext-sidebar']:not([class*='toolbar']):not([class*='search'])").bounding_box()["width"]
print(f"DEBUG - Initial Width: {initial_width}, New Width: {new_width}")
assert new_width > initial_width + 100, f"Sidebar should be resized, but got {new_width}"
page.screenshot(path=os.path.join(screenshot_dir, "02_after_resize.png"))
# ---------------------------------------------------------
# 2.5 タブのはみ出し検証 (新規追加)
# ---------------------------------------------------------
page.evaluate("""() => {
const node = window.app.graph._nodes[0];
for (let i = 0; i < 20; i++) {
const id = node.multitext_widget.addItemWithName('file', `overflow_test_${i}.txt`, undefined, `content ${i}`);
node.multitext_widget.openFile(id);
}
}""")
# ウィジェット要素が出現するのを待つ (難読化対応属性セレクタ)
page.wait_for_selector("[class*='multitext-tabs-container']", state="attached")
# デバッグ: クラス名ダンプ
all_classes = page.evaluate("""() => {
const getClasses = (root) => {
let res = [];
if (root.className) {
const c = root.className;
res.push(typeof c === 'string' ? c : (c.baseVal || ""));
}
for (const child of root.children || []) res = res.concat(getClasses(child));
if (root.shadowRoot) res = res.concat(getClasses(root.shadowRoot));
return res;
};
return getClasses(document.body).filter(c => typeof c === 'string' && c.includes("webui-monaco-prompt"));
}""")
print(f"DEBUG - All monaco classes: {all_classes}")
# CSS Modules 難読化対応のセレクタで検証
tabs_info = page.evaluate("""() => {
const findByPart = (root, part) => {
if (root.className && typeof root.className === 'string' && root.className.includes(part)) return root;
for (const child of Array.from(root.children || [])) {
const found = findByPart(child, part);
if (found) return found;
}
if (root.shadowRoot) {
const found = findByPart(root.shadowRoot, part);
if (found) return found;
}
return null;
};
const tabs = findByPart(document.body, "multitext-tabs-container");
const container = findByPart(document.body, "multitext-container");
if (!tabs || !container) return { error: "Elements not found", tabsFound: !!tabs, containerFound: !!container };
return {
tabsRight: tabs.getBoundingClientRect().right,
containerRight: container.getBoundingClientRect().right,
overflowX: window.getComputedStyle(tabs).overflowX,
scrollWidth: tabs.scrollWidth,
clientWidth: tabs.clientWidth
};
}""")
print(f"DEBUG - Overflow Tabs Info: {tabs_info}")
assert "error" not in tabs_info, f"Should find tab elements: {tabs_info.get('error')}"
assert tabs_info["overflowX"] == "hidden", f"Tabs should be hidden overflow, but got {tabs_info['overflowX']}"
assert tabs_info["tabsRight"] <= tabs_info["containerRight"] + 15, f"Tabs should be clipped within container: tabsRight({tabs_info['tabsRight']}) > containerRight({tabs_info['containerRight']})"
# スクロール検証
scroll_debug = page.evaluate("""() => {
const findByPart = (root, part) => {
if (root.className && typeof root.className === 'string' && root.className.includes(part) && !root.className.includes("toolbar")) return root;
for (const child of Array.from(root.children || [])) {
const found = findByPart(child, part);
if (found) return found;
}
if (root.shadowRoot) {
const found = findByPart(root.shadowRoot, part);
if (found) return found;
}
return null;
};
const tabs = findByPart(document.body, "multitext-tabs-container");
if (!tabs) return { error: "Tabs not found" };
return {
scrollLeft: tabs.scrollLeft,
scrollWidth: tabs.scrollWidth,
clientWidth: tabs.clientWidth,
rect: tabs.getBoundingClientRect()
};
}""")
print(f"DEBUG - Scroll Initial State: {scroll_debug}")
assert "error" not in scroll_debug, "Tabs container for scroll should be found"
# スクロール検証: 内容がコンテナ幅を超えていることを確認
assert scroll_debug["scrollWidth"] > scroll_debug["clientWidth"], f"Content should be wider than container: {scroll_debug['scrollWidth']} > {scroll_debug['clientWidth']}"
# 既に右端までスクロールされている可能性があるため、一度左へ戻してからスクロール検証
page.evaluate("""() => {
const findByPart = (root, part) => {
if (root.className && typeof root.className === 'string' && root.className.includes(part) && !root.className.includes("toolbar")) return root;
for (const child of Array.from(root.children || [])) {
const found = findByPart(child, part);
if (found) return found;
}
if (root.shadowRoot) {
const found = findByPart(root.shadowRoot, part);
if (found) return found;
}
return null;
};
const tabs = findByPart(document.body, "multitext-tabs-container");
if (tabs) tabs.scrollLeft = 0;
}""")
wmp_helpers.wait_for_ui_stabilize(page, 500)
initial_scroll = 0
page.hover("[class*='multitext-tabs-container']")
# JSでスクロールさせる(overflow: hidden の場合、ホイールイベントだけではスクロールしない場合があるため)
page.evaluate("""() => {
const findByPart = (root, part) => {
if (root.className && typeof root.className === 'string' && root.className.includes(part) && !root.className.includes("toolbar")) return root;
for (const child of Array.from(root.children || [])) {
const found = findByPart(child, part);
if (found) return found;
}
if (root.shadowRoot) {
const found = findByPart(root.shadowRoot, part);
if (found) return found;
}
return null;
};
const tabs = findByPart(document.body, "multitext-tabs-container");
if (tabs) tabs.scrollLeft += 100;
}""")
wmp_helpers.wait_for_ui_stabilize(page, 500)
new_scroll = page.evaluate("""() => {
const findByPart = (root, part) => {
if (root.className && typeof root.className === 'string' && root.className.includes(part) && !root.className.includes("toolbar")) return root;
for (const child of Array.from(root.children || [])) {
const found = findByPart(child, part);
if (found) return found;
}
if (root.shadowRoot) {
const found = findByPart(root.shadowRoot, part);
if (found) return found;
}
return null;
};
const tabs = findByPart(document.body, "multitext-tabs-container");
return tabs ? tabs.scrollLeft : -1;
}""")
print(f"DEBUG - Scroll check: Initial={initial_scroll}, New={new_scroll}")
# 少なくとも 0 より大きくなっていることを確認
assert new_scroll > 0, f"Should be able to scroll tabs via JS: {new_scroll}"
page.screenshot(path=os.path.join(screenshot_dir, "02_5_tab_overflow_fix.png"))
# ---------------------------------------------------------
# 3. D&D検証
# ---------------------------------------------------------
page.evaluate("""() => {
const node = window.app.graph._nodes[0];
node.multitext_widget.addItemWithName('folder', 'TargetFolder');
node.multitext_widget.addItemWithName('file', 'DraggedFile');
}""")
page.wait_for_selector("text=DraggedFile")
page.wait_for_selector("text=TargetFolder")
print("DEBUG - Starting D&D verification...")
# Drag & Drop 実行
drag_handle = page.locator("text=DraggedFile")
drop_target = page.locator("text=TargetFolder")
# JavaScript による Drag & Drop の実行 (Playwright の drag_to ハング回避)
print("DEBUG - Executing D&D via JavaScript events...")
page.evaluate("""({sourceSelector, targetSelector}) => {
const source = document.evaluate(`//*[text()='${sourceSelector}']`, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
const target = document.evaluate(`//*[text()='${targetSelector}']`, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
if (!source || !target) return {ok: false, error: 'Elements not found'};
const dataTransfer = new DataTransfer();
source.dispatchEvent(new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer }));
target.dispatchEvent(new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer }));
target.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer }));
target.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer }));
source.dispatchEvent(new DragEvent('dragend', { bubbles: true, cancelable: true, dataTransfer }));
return {ok: true};
}""", {"sourceSelector": "DraggedFile", "targetSelector": "TargetFolder"})
print("DEBUG - D&D Events dispatched, waiting for stabilization...")
wmp_helpers.wait_for_ui_stabilize(page, 1000)
is_moved = page.evaluate("""() => {
const node = window.app.graph._nodes[0];
const folder = node.multitext_widget.data.tree.find(i => i.name === 'TargetFolder');
return folder && folder.children && folder.children.some(i => i.name === 'DraggedFile');
}""")
assert is_moved, "File should be moved into TargetFolder"
page.screenshot(path=os.path.join(screenshot_dir, "03_after_dnd.png"))
# ---------------------------------------------------------
# 4. 永続性検証
# ---------------------------------------------------------
# ComfyUI のオートセーブが完了するまで待機と強制トリガー
page.evaluate("""() => {
if (window.comfyAPI && window.comfyAPI.api && window.comfyAPI.api.api) {
window.comfyAPI.api.api.dispatchEvent(new CustomEvent('graphChanged'));
} else if (window.api) {
window.api.dispatchEvent(new CustomEvent('graphChanged'));
} else {
app.graph.change();
}
}""")
wmp_helpers.wait_for_ui_stabilize(page, 2000)
page.reload()
wait_for_comfyui(page)
# リロード後のリストアには時間がかかる可能性があるため、ある程度リトライする
is_persisted = False
for i in range(10):
is_persisted = page.evaluate("""() => {
const node = window.app.graph && window.app.graph._nodes && window.app.graph._nodes.find(n => n.type && n.type.includes('MultiText'));
if (!node || !node.multitext_widget || !node.multitext_widget.data || !node.multitext_widget.data.tree) return false;
const folder = node.multitext_widget.data.tree.find(i => i.name === 'TargetFolder');
return folder && folder.children && folder.children.some(i => i.name === 'DraggedFile');
}""")
if is_persisted:
break
wmp_helpers.wait_for_ui_stabilize(page, 1000)
assert is_persisted, "Data should be persisted after reload"
page.screenshot(path=os.path.join(screenshot_dir, "04_after_reload.png"))