Files
FossFLOW/e2e-tests/tests/test_node_placement.py

589 lines
22 KiB
Python

"""
E2E tests for placing nodes on the FossFLOW canvas and undo/redo.
Takes screenshots at each step to visually verify state.
"""
import os
import time
import pytest
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.action_chains import ActionChains
SCREENSHOT_DIR = os.path.join(os.path.dirname(__file__), "..", "screenshots")
def get_base_url():
return os.getenv("FOSSFLOW_TEST_URL", "http://localhost:3000")
def get_webdriver_url():
return os.getenv("WEBDRIVER_URL", "http://localhost:4444")
@pytest.fixture(scope="function")
def driver():
chrome_options = Options()
chrome_options.add_argument("--headless=new")
chrome_options.add_argument("--no-sandbox")
chrome_options.add_argument("--disable-dev-shm-usage")
chrome_options.add_argument("--enable-webgl")
chrome_options.add_argument("--use-gl=swiftshader")
chrome_options.add_argument("--enable-accelerated-2d-canvas")
chrome_options.add_argument("--window-size=1920,1080")
chrome_options.add_argument("--disable-blink-features=AutomationControlled")
chrome_options.set_capability('goog:loggingPrefs', {'browser': 'ALL'})
driver = webdriver.Remote(
command_executor=get_webdriver_url(),
options=chrome_options,
)
driver.implicitly_wait(10)
yield driver
driver.quit()
def save_screenshot(driver, name):
os.makedirs(SCREENSHOT_DIR, exist_ok=True)
path = os.path.join(SCREENSHOT_DIR, f"{name}.png")
driver.save_screenshot(path)
print(f" Screenshot saved: {path}")
return path
def dismiss_modals(driver):
"""Close any popup modals/dialogs that appear on first load."""
try:
driver.execute_script("""
const dialogs = document.querySelectorAll('[role="dialog"], [class*="MuiDialog"]');
dialogs.forEach(d => {
const closeBtn = d.querySelector('button');
if (closeBtn) closeBtn.click();
});
const closeBtns = document.querySelectorAll('button[aria-label="Close"], button[aria-label="close"]');
closeBtns.forEach(b => b.click());
""")
time.sleep(0.5)
except Exception:
pass
def dismiss_tips(driver):
"""Close tip popups (Import Diagrams, Creating Connectors tips)."""
try:
driver.execute_script("""
const allButtons = document.querySelectorAll('button');
for (const btn of allButtons) {
const ariaLabel = btn.getAttribute('aria-label') || '';
if (ariaLabel.toLowerCase().includes('close') ||
ariaLabel.toLowerCase().includes('dismiss')) {
btn.click();
}
}
const closeIcons = document.querySelectorAll('[data-testid="CloseIcon"], [data-testid="ClearIcon"]');
closeIcons.forEach(icon => {
const btn = icon.closest('button');
if (btn) btn.click();
});
""")
time.sleep(0.3)
except Exception:
pass
def count_canvas_nodes(driver):
"""Count placed nodes on the canvas by checking for node images and labels."""
return driver.execute_script("""
const container = document.querySelector('.fossflow-container');
if (!container) return { images: 0, untitledLabels: 0, hasUntitled: false };
const allImgs = container.querySelectorAll('img');
const allText = container.innerText || '';
// Filter out "Untitled Diagram" and "Untitled view" from the count
// We only want "Untitled" that appears as a standalone node label
const hasUntitled = allText.includes('Untitled');
// Count standalone "Untitled" spans (node labels), but exclude
// the bottom bar which has "Untitled Diagram > Untitled view"
const spans = Array.from(container.querySelectorAll('span, p'));
const untitledLabels = spans.filter(s => {
const text = s.textContent.trim();
// Must be exactly "Untitled" (node label), not "Untitled Diagram" etc.
return text === 'Untitled';
});
return {
images: allImgs.length,
untitledLabels: untitledLabels.length,
hasUntitled: hasUntitled,
allImgAlts: Array.from(allImgs).map(img => img.getAttribute('alt') || '(none)')
};
""")
def place_node(driver, screenshot_prefix=""):
"""Open icon panel, select first icon, click canvas to place a node.
Returns True if node was placed successfully.
"""
pfx = f"{screenshot_prefix}_" if screenshot_prefix else ""
# Click "Add item (N)" button
add_btn = driver.find_element(By.CSS_SELECTOR, "button[aria-label*='Add item']")
add_btn.click()
time.sleep(1)
# Expand the ISOFLOW icon collection
driver.execute_script("""
const buttons = document.querySelectorAll('button');
for (const btn of buttons) {
const text = btn.textContent.trim().toUpperCase();
if (text.includes('ISOFLOW') && !text.includes('IMPORT')) {
btn.click();
return;
}
}
""")
time.sleep(3)
# Select first icon with a small image (icon grid item)
first_icon_btn = driver.execute_script("""
const buttons = document.querySelectorAll('button');
for (const btn of buttons) {
const img = btn.querySelector('img');
if (img && img.naturalWidth > 0 && img.naturalWidth <= 100) {
return btn;
}
}
for (const btn of buttons) {
const img = btn.querySelector('img');
if (img) return btn;
}
return null;
""")
if first_icon_btn is None:
return False
actions = ActionChains(driver)
actions.click(first_icon_btn).perform()
time.sleep(0.5)
# Click on the canvas to place
canvas = driver.find_element(By.CLASS_NAME, "fossflow-container")
actions = ActionChains(driver)
actions.move_to_element_with_offset(canvas, 500, 400)
actions.click()
actions.perform()
time.sleep(1)
if screenshot_prefix:
save_screenshot(driver, f"{pfx}placed")
return True
def find_toolbar_button(driver, name_substring):
"""Find a toolbar button by matching its tooltip/name text.
The IconButton component wraps MUI Button inside a Tooltip.
We find buttons whose parent tooltip has a matching title.
"""
btn = driver.execute_script("""
const target = arguments[0].toLowerCase();
// Try aria-label first
const byAria = document.querySelector(`button[aria-label*='${arguments[0]}']`);
if (byAria) return byAria;
// Try title attribute
const byTitle = document.querySelector(`button[title*='${arguments[0]}']`);
if (byTitle) return byTitle;
// Try finding via MUI Tooltip data attribute or svg icon
const allButtons = document.querySelectorAll('button');
for (const btn of allButtons) {
// Check if button or parent has matching tooltip text
const title = btn.getAttribute('title') || '';
const ariaLabel = btn.getAttribute('aria-label') || '';
const ariaDescribedBy = btn.getAttribute('aria-describedby') || '';
if (title.toLowerCase().includes(target) ||
ariaLabel.toLowerCase().includes(target)) {
return btn;
}
}
return null;
""", name_substring)
return btn
def get_undo_redo_debug_info(driver):
"""Get debug info about undo/redo buttons and store state."""
return driver.execute_script("""
const allButtons = Array.from(document.querySelectorAll('button'));
const buttonInfo = allButtons.map(btn => ({
text: btn.textContent.trim().substring(0, 30),
ariaLabel: btn.getAttribute('aria-label'),
title: btn.getAttribute('title'),
disabled: btn.disabled,
className: btn.className.substring(0, 50)
})).filter(b => b.ariaLabel || b.title);
// Find undo/redo specific buttons
const undoBtn = allButtons.find(b =>
(b.getAttribute('aria-label') || '').includes('Undo') ||
(b.getAttribute('title') || '').includes('Undo'));
const redoBtn = allButtons.find(b =>
(b.getAttribute('aria-label') || '').includes('Redo') ||
(b.getAttribute('title') || '').includes('Redo'));
return {
totalButtons: allButtons.length,
buttonsWithLabels: buttonInfo,
undoButton: undoBtn ? {
found: true,
disabled: undoBtn.disabled,
ariaLabel: undoBtn.getAttribute('aria-label'),
title: undoBtn.getAttribute('title'),
tagName: undoBtn.tagName,
innerHTML: undoBtn.innerHTML.substring(0, 100)
} : { found: false },
redoButton: redoBtn ? {
found: true,
disabled: redoBtn.disabled,
ariaLabel: redoBtn.getAttribute('aria-label'),
title: redoBtn.getAttribute('title'),
} : { found: false }
};
""")
def click_undo(driver):
"""Click the Undo button in the toolbar."""
# Debug: check button state before clicking
debug = get_undo_redo_debug_info(driver)
print(f" DEBUG Undo button: {debug['undoButton']}")
btn = find_toolbar_button(driver, "Undo")
if btn is None:
# Fallback: try keyboard shortcut
print(" WARNING: Undo button not found, trying Ctrl+Z")
actions = ActionChains(driver)
actions.key_down('\ue009').send_keys('z').key_up('\ue009').perform()
time.sleep(1)
return
is_disabled = driver.execute_script("return arguments[0].disabled", btn)
print(f" DEBUG Undo button disabled={is_disabled}")
if is_disabled:
print(" WARNING: Undo button is DISABLED - canUndo is false!")
btn.click()
time.sleep(1)
def click_redo(driver):
"""Click the Redo button in the toolbar."""
debug = get_undo_redo_debug_info(driver)
print(f" DEBUG Redo button: {debug['redoButton']}")
btn = find_toolbar_button(driver, "Redo")
if btn is None:
print(" WARNING: Redo button not found, trying Ctrl+Y")
actions = ActionChains(driver)
actions.key_down('\ue009').send_keys('y').key_up('\ue009').perform()
time.sleep(1)
return
is_disabled = driver.execute_script("return arguments[0].disabled", btn)
print(f" DEBUG Redo button disabled={is_disabled}")
btn.click()
time.sleep(1)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_place_node_on_canvas(driver):
"""Place a node on the canvas and verify it appears."""
base_url = get_base_url()
print(f"\n1. Loading app at {base_url}")
driver.get(base_url)
WebDriverWait(driver, 15).until(
EC.presence_of_element_located((By.CLASS_NAME, "fossflow-container"))
)
time.sleep(2)
dismiss_modals(driver)
dismiss_tips(driver)
time.sleep(0.5)
save_screenshot(driver, "place_01_clean")
# Count nodes before
before = count_canvas_nodes(driver)
print(f"2. Nodes before: images={before['images']}, labels={before['untitledLabels']}")
# Place a node
print("3. Placing node...")
assert place_node(driver, "place"), "Failed to find icon to place"
save_screenshot(driver, "place_02_after")
# Count nodes after
after = count_canvas_nodes(driver)
print(f"4. Nodes after: images={after['images']}, labels={after['untitledLabels']}")
assert after['images'] > before['images'] or after['untitledLabels'] > 0, (
f"Node was NOT placed. Before: {before}, After: {after}"
)
print(" SUCCESS: Node placed on canvas!")
def test_undo_redo_node(driver):
"""Place a node, undo to remove it, redo to restore it."""
base_url = get_base_url()
# --- Setup: load app, dismiss popups ---
print(f"\n1. Loading app at {base_url}")
driver.get(base_url)
WebDriverWait(driver, 15).until(
EC.presence_of_element_located((By.CLASS_NAME, "fossflow-container"))
)
time.sleep(2)
dismiss_modals(driver)
dismiss_tips(driver)
time.sleep(0.5)
# --- Baseline: empty canvas ---
baseline = count_canvas_nodes(driver)
print(f"2. Baseline (empty canvas): images={baseline['images']}, labels={baseline['untitledLabels']}")
save_screenshot(driver, "undo_01_baseline")
# --- Place a node ---
print("3. Placing node...")
assert place_node(driver, "undo"), "Failed to find icon to place"
after_place = count_canvas_nodes(driver)
print(f"4. After placement: images={after_place['images']}, labels={after_place['untitledLabels']}")
save_screenshot(driver, "undo_02_node_placed")
assert after_place['images'] > baseline['images'] or after_place['untitledLabels'] > 0, (
f"Node was not placed. Baseline: {baseline}, After: {after_place}"
)
placed_images = after_place['images']
# --- Debug: dump store state BEFORE undo via React fiber ---
print("5. Inspecting store state before undo (via React fiber)...")
store_state = driver.execute_script("""
// Walk React fiber tree to find Zustand store contexts
function findStores() {
const root = document.getElementById('root');
if (!root) return { error: 'No root element' };
// Get the React fiber root
const fiberKey = Object.keys(root).find(k => k.startsWith('__reactFiber'));
if (!fiberKey) return { error: 'No React fiber found' };
let fiber = root[fiberKey];
// Walk the fiber tree looking for context values that look like Zustand stores
const stores = {};
let visited = 0;
const queue = [fiber];
while (queue.length > 0 && visited < 5000) {
const node = queue.shift();
visited++;
// Check memoizedState for context values
if (node && node.memoizedState) {
let st = node.memoizedState;
while (st) {
if (st.queue && st.queue.lastRenderedState) {
const state = st.queue.lastRenderedState;
// Look for model store (has 'views', 'items', 'history')
if (state && state.views && state.items && state.history) {
stores.modelStore = state;
}
// Look for scene store (has 'connectors', 'textBoxes', 'history')
if (state && state.connectors !== undefined && state.textBoxes !== undefined && state.history) {
stores.sceneStore = state;
}
}
st = st.next;
}
}
// Check context
if (node && node.pendingProps && node.pendingProps.value) {
const val = node.pendingProps.value;
if (typeof val === 'object' && val !== null && typeof val.getState === 'function') {
try {
const state = val.getState();
if (state && state.views && state.items && state.history) {
stores.modelStoreApi = val;
}
if (state && state.connectors !== undefined && state.textBoxes !== undefined && state.history) {
stores.sceneStoreApi = val;
}
} catch(e) {}
}
}
if (node && node.child) queue.push(node.child);
if (node && node.sibling) queue.push(node.sibling);
}
// If we found the stores via context providers, use those
if (stores.modelStoreApi) {
window.__modelStore__ = stores.modelStoreApi;
const ms = stores.modelStoreApi.getState();
const modelHistory = ms.history;
const views = ms.views || [];
const currentView = views[0];
let sceneInfo = {};
if (stores.sceneStoreApi) {
window.__sceneStore__ = stores.sceneStoreApi;
const ss = stores.sceneStoreApi.getState();
sceneInfo = {
sceneHistoryPastLength: ss.history ? ss.history.past.length : -1,
canUndoScene: ss.actions ? ss.actions.canUndo() : 'N/A',
};
}
return {
found: true,
modelHistoryPastLength: modelHistory ? modelHistory.past.length : -1,
modelHistoryFutureLength: modelHistory ? modelHistory.future.length : -1,
currentModelItemsCount: (ms.items || []).length,
currentViewItemsCount: currentView ? (currentView.items || []).length : -1,
currentViewsCount: views.length,
canUndoModel: ms.actions ? ms.actions.canUndo() : 'N/A',
canRedoModel: ms.actions ? ms.actions.canRedo() : 'N/A',
...sceneInfo,
visited: visited,
};
}
return { error: 'Stores not found via fiber', visited: visited };
}
return findStores();
""")
print(f" Store state: {store_state}")
# --- Click Undo ---
print("6. Clicking Undo button...")
click_undo(driver)
time.sleep(0.5)
# --- Debug: dump store state AFTER undo ---
store_after_undo = driver.execute_script("""
if (!window.__modelStore__) return { error: 'No store ref' };
const ms = window.__modelStore__.getState();
const modelHistory = ms.history;
const views = ms.views || [];
const currentView = views[0];
const viewItems = currentView ? (currentView.items || []) : [];
return {
modelHistoryPastLength: modelHistory ? modelHistory.past.length : -1,
modelHistoryFutureLength: modelHistory ? modelHistory.future.length : -1,
currentModelItemsCount: (ms.items || []).length,
currentViewItemsCount: viewItems.length,
canUndoModel: ms.actions.canUndo(),
canRedoModel: ms.actions.canRedo(),
};
""")
print(f" Store after undo: {store_after_undo}")
after_undo = count_canvas_nodes(driver)
print(f"7. After undo: images={after_undo['images']}, labels={after_undo['untitledLabels']}")
save_screenshot(driver, "undo_03_after_undo")
# If button click didn't work, try calling undo directly via JS
if after_undo['images'] >= placed_images:
print(" Button undo didn't remove node. Trying direct store undo via JS...")
direct_result = driver.execute_script("""
if (!window.__modelStore__) return { error: 'No store ref' };
const ms = window.__modelStore__.getState();
// First check state before undo
const beforeItems = (ms.items || []).length;
const beforeViews = ms.views || [];
const beforeViewItems = beforeViews[0] ? (beforeViews[0].items || []).length : -1;
const beforePast = ms.history.past.length;
// Call undo directly
const modelUndoResult = ms.actions.undo();
// Check state after direct undo
const msAfter = window.__modelStore__.getState();
const views = msAfter.views || [];
const currentView = views[0];
return {
modelUndoResult: modelUndoResult,
beforeItems: beforeItems,
beforeViewItems: beforeViewItems,
beforePast: beforePast,
afterModelItemsCount: (msAfter.items || []).length,
afterViewItemsCount: currentView ? (currentView.items || []).length : -1,
afterPastLength: msAfter.history.past.length,
afterFutureLength: msAfter.history.future.length,
};
""")
print(f" Direct undo result: {direct_result}")
time.sleep(1)
after_undo = count_canvas_nodes(driver)
print(f" After direct undo: images={after_undo['images']}, labels={after_undo['untitledLabels']}")
save_screenshot(driver, "undo_03b_after_direct_undo")
assert after_undo['images'] < placed_images, (
f"Undo did NOT remove the node. "
f"Before undo: {placed_images} images, After undo: {after_undo['images']} images"
)
print(" Undo removed the node.")
# --- Click Redo ---
print("7. Clicking Redo...")
click_redo(driver)
after_redo = count_canvas_nodes(driver)
print(f"8. After redo: images={after_redo['images']}, labels={after_redo['untitledLabels']}")
save_screenshot(driver, "undo_04_after_redo")
assert after_redo['images'] >= placed_images, (
f"Redo did NOT restore the node. "
f"After place: {placed_images} images, After redo: {after_redo['images']} images"
)
print(" Redo restored the node.")
# --- Undo again (verify cycle works) ---
print("9. Clicking Undo again...")
click_undo(driver)
after_undo2 = count_canvas_nodes(driver)
print(f"10. After second undo: images={after_undo2['images']}, labels={after_undo2['untitledLabels']}")
save_screenshot(driver, "undo_05_after_undo2")
assert after_undo2['images'] < placed_images, (
f"Second undo did NOT remove the node. "
f"After redo: {placed_images} images, After undo2: {after_undo2['images']} images"
)
print(" Second undo removed the node again.")
print("\n SUCCESS: Undo/Redo/Undo cycle works correctly!")
if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])