diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 166342d..806dfa1 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -6,14 +6,11 @@ on: types: - completed branches: ["main", "master"] - push: - tags: - - 'v*.*.*' jobs: build-and-push: runs-on: ubuntu-latest - if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'push' }} + if: ${{ github.event.workflow_run.conclusion == 'success' }} steps: - name: Checkout code diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 903ecc2..8126062 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -2,9 +2,9 @@ name: Deploy static content to Pages on: - # Runs after unit tests complete successfully (skipping E2E tests) + # Runs after E2E tests complete successfully workflow_run: - workflows: ["Run Tests"] + workflows: ["E2E Tests"] types: - completed branches: ["main", "master"] diff --git a/e2e-tests/.gitignore b/e2e-tests/.gitignore index 25a5a96..11b9df2 100644 --- a/e2e-tests/.gitignore +++ b/e2e-tests/.gitignore @@ -6,3 +6,4 @@ htmlcov/ *.log venv/ env/ +screenshots/ diff --git a/e2e-tests/tests/test_connector_undo.py b/e2e-tests/tests/test_connector_undo.py new file mode 100644 index 0000000..1d6b956 --- /dev/null +++ b/e2e-tests/tests/test_connector_undo.py @@ -0,0 +1,393 @@ +"""E2E test: place two nodes, connect them, then undo/redo the connector.""" +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("--window-size=1920,1080") + d = webdriver.Remote( + command_executor=get_webdriver_url(), + options=chrome_options, + ) + d.implicitly_wait(10) + yield d + d.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) + return path + + +def dismiss_modals(driver): + """Dismiss all modals, dialogs, and tip popups.""" + try: + driver.execute_script(""" + // Close MUI dialogs + const dialogs = document.querySelectorAll('[role="dialog"], [class*="MuiDialog"]'); + dialogs.forEach(d => { + const closeBtn = d.querySelector('button'); + if (closeBtn) closeBtn.click(); + }); + // Close tip popups (X buttons) + const closeIcons = document.querySelectorAll('[data-testid="CloseIcon"], [data-testid="ClearIcon"]'); + closeIcons.forEach(icon => { + const btn = icon.closest('button'); + if (btn) btn.click(); + }); + // Close anything with a close/dismiss aria-label + document.querySelectorAll('button').forEach(btn => { + const label = (btn.getAttribute('aria-label') || '').toLowerCase(); + if (label.includes('close') || label.includes('dismiss')) btn.click(); + }); + """) + time.sleep(0.5) + # Second pass to catch the lazy loading modal that may appear later + driver.execute_script(""" + const dialogs = document.querySelectorAll('[role="dialog"], [class*="MuiDialog"]'); + dialogs.forEach(d => { + const btns = d.querySelectorAll('button'); + btns.forEach(b => { if (b.textContent.trim() === '×' || b.querySelector('svg')) b.click(); }); + }); + """) + time.sleep(0.3) + except Exception: + pass + + +def count_canvas_images(driver): + return driver.execute_script(""" + const c = document.querySelector('.fossflow-container'); + if (!c) return 0; + return c.querySelectorAll('img').length; + """) + + +def count_connector_polylines(driver): + """Count SVG polylines inside the fossflow container (connector paths).""" + return driver.execute_script(""" + const c = document.querySelector('.fossflow-container'); + if (!c) return 0; + return c.querySelectorAll('svg polyline').length; + """) + + +def get_scene_state(driver): + """Get scene connector count via React fiber store discovery.""" + return driver.execute_script(""" + // Walk React fiber tree to find the scene store + var root = document.getElementById("root"); + var containerKey = Object.keys(root).find(function(k) { + return k.startsWith("__reactContainer"); + }); + if (!containerKey) return {error: "no react container"}; + + var fiber = root[containerKey]; + var queue = [fiber]; + var visited = 0; + var sceneStore = null; + var modelStore = null; + + while (queue.length > 0 && visited < 3000) { + var node = queue.shift(); + if (!node) continue; + visited++; + + if (node.pendingProps && node.pendingProps.value && + typeof node.pendingProps.value === "object" && + node.pendingProps.value !== null && + typeof node.pendingProps.value.getState === "function") { + try { + var state = node.pendingProps.value.getState(); + if (state && state.connectors !== undefined && state.textBoxes !== undefined && state.history) { + sceneStore = node.pendingProps.value; + } + if (state && state.views !== undefined && state.items !== undefined && state.history) { + modelStore = node.pendingProps.value; + } + } catch(e) {} + } + if (node.child) queue.push(node.child); + if (node.sibling) queue.push(node.sibling); + } + + if (!sceneStore || !modelStore) return {error: "stores not found", visited: visited}; + + var s = sceneStore.getState(); + var m = modelStore.getState(); + var cv = m.views && m.views[0]; + return { + connectors: Object.keys(s.connectors || {}).length, + modelItems: (m.items || []).length, + viewConnectors: cv && cv.connectors ? cv.connectors.length : 0, + scenePastLen: s.history.past.length, + sceneFutureLen: s.history.future.length, + modelPastLen: m.history.past.length, + modelFutureLen: m.history.future.length, + }; + """) + + +def place_node_at(driver, x_offset, y_offset): + """Select icon and place at a specific canvas offset.""" + add_btn = driver.find_element(By.CSS_SELECTOR, "button[aria-label*='Add item']") + add_btn.click() + time.sleep(0.8) + + 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(2) + + 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 + + ActionChains(driver).click(first_icon_btn).perform() + time.sleep(0.5) + + canvas = driver.find_element(By.CLASS_NAME, "fossflow-container") + ActionChains(driver).move_to_element_with_offset(canvas, x_offset, y_offset).click().perform() + time.sleep(1) + return True + + +def click_connector_tool(driver): + """Click the Connector tool button in the toolbar.""" + btn = driver.execute_script(""" + return document.querySelector("button[aria-label*='Connector']"); + """) + if btn: + btn.click() + time.sleep(0.5) + return True + return False + + +def click_undo(driver): + btn = driver.execute_script(""" + return document.querySelector("button[aria-label*='Undo']"); + """) + if btn: + btn.click() + time.sleep(1) + return True + return False + + +def click_redo(driver): + btn = driver.execute_script(""" + return document.querySelector("button[aria-label*='Redo']"); + """) + if btn: + btn.click() + time.sleep(1) + return True + return False + + +def test_connector_undo_redo(driver): + """Place 2 nodes, connect them, undo connector, redo connector.""" + base_url = get_base_url() + + # --- Load app --- + 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) + time.sleep(0.5) + + # --- Place two nodes --- + print("\n2. Placing node 1 at (350, 300)...") + assert place_node_at(driver, 350, 300), "Failed to place node 1" + imgs = count_canvas_images(driver) + print(f" Images: {imgs}") + + print(" Placing node 2 at (600, 300)...") + assert place_node_at(driver, 600, 300), "Failed to place node 2" + imgs = count_canvas_images(driver) + print(f" Images: {imgs}") + assert imgs == 2, f"Expected 2 images after placing 2 nodes, got {imgs}" + save_screenshot(driver, "conn_01_two_nodes") + + polylines_before = count_connector_polylines(driver) + state_before = get_scene_state(driver) + print(f" Polylines before connector: {polylines_before}") + print(f" Scene state: {state_before}") + + # Dismiss any late-appearing modals (Lazy Loading popup) + dismiss_modals(driver) + time.sleep(0.5) + save_screenshot(driver, "conn_01b_before_connect") + + # --- Get node image elements for clicking --- + node_imgs = driver.find_elements(By.CSS_SELECTOR, ".fossflow-container img") + print(f" Found {len(node_imgs)} node images") + assert len(node_imgs) >= 2, f"Expected 2+ node images, got {len(node_imgs)}" + + # --- Activate connector tool (click mode) --- + print("\n3. Activating Connector tool...") + assert click_connector_tool(driver), "Failed to find Connector button" + time.sleep(0.5) + + # --- Click on node 1 image (first click - start connector) --- + print(" Clicking on node 1 to start connector...") + ActionChains(driver).click(node_imgs[0]).perform() + time.sleep(1) + save_screenshot(driver, "conn_02_first_click") + + # --- Click on node 2 image (second click - complete connector) --- + print(" Clicking on node 2 to complete connector...") + ActionChains(driver).click(node_imgs[1]).perform() + time.sleep(1) + save_screenshot(driver, "conn_03_connected") + + polylines_after = count_connector_polylines(driver) + state_after = get_scene_state(driver) + print(f" Polylines after connector: {polylines_after}") + print(f" Scene state: {state_after}") + + # Verify connector was created + has_connector = polylines_after > polylines_before + scene_has_connector = ( + isinstance(state_after, dict) and + state_after.get("connectors", 0) > 0 + ) + print(f" DOM has connector: {has_connector}") + print(f" Scene store has connector: {scene_has_connector}") + + assert has_connector or scene_has_connector, ( + f"No connector created. Polylines: {polylines_before} -> {polylines_after}, " + f"Scene state: {state_after}" + ) + + connector_polylines = polylines_after + + # --- Switch back to default mode (press Escape) --- + print("\n4. Pressing Escape to exit connector mode...") + ActionChains(driver).send_keys('\ue00c').perform() # Escape + time.sleep(0.5) + + # --- Undo connector (create + update = 2 history entries) --- + print("\n5. Undoing connector (2 steps: update then create)...") + click_undo(driver) + polylines_mid = count_connector_polylines(driver) + state_mid = get_scene_state(driver) + print(f" After undo 1: polylines={polylines_mid}, scene={state_mid}") + + click_undo(driver) + polylines_undo = count_connector_polylines(driver) + state_undo = get_scene_state(driver) + print(f" After undo 2: polylines={polylines_undo}, scene={state_undo}") + save_screenshot(driver, "conn_04_after_undo") + + assert polylines_undo < connector_polylines or ( + isinstance(state_undo, dict) and state_undo.get("connectors", 0) == 0 + ), ( + f"Undo did not remove connector. Polylines: {connector_polylines} -> {polylines_undo}, " + f"Scene state: {state_undo}" + ) + print(" Connector removed by undo.") + + # Verify nodes are still there + imgs_after_undo = count_canvas_images(driver) + print(f" Images after undo: {imgs_after_undo} (nodes should still be there)") + assert imgs_after_undo == 2, f"Expected 2 images after undoing connector, got {imgs_after_undo}" + + # --- Redo connector (2 steps: create then update) --- + print("\n6. Redoing connector (2 steps)...") + click_redo(driver) + click_redo(driver) + time.sleep(0.5) + + polylines_redo = count_connector_polylines(driver) + state_redo = get_scene_state(driver) + print(f" Polylines after redo: {polylines_redo}") + print(f" Scene state: {state_redo}") + save_screenshot(driver, "conn_05_after_redo") + + assert polylines_redo >= connector_polylines or ( + isinstance(state_redo, dict) and state_redo.get("connectors", 0) > 0 + ), ( + f"Redo did not restore connector. Polylines: {polylines_undo} -> {polylines_redo}, " + f"Scene state: {state_redo}" + ) + print(" Connector restored by redo.") + + # --- Undo/redo cycle again --- + print("\n7. Undoing connector again (2 steps)...") + click_undo(driver) + click_undo(driver) + time.sleep(0.5) + + polylines_undo2 = count_connector_polylines(driver) + state_undo2 = get_scene_state(driver) + print(f" Polylines: {polylines_undo2}, connectors: {state_undo2.get('connectors', '?')}") + assert polylines_undo2 < connector_polylines or ( + isinstance(state_undo2, dict) and state_undo2.get("connectors", 0) == 0 + ), "Second undo cycle did not remove connector" + print(" Connector removed again.") + + print(" Redoing connector again (2 steps)...") + click_redo(driver) + click_redo(driver) + time.sleep(0.5) + + polylines_redo2 = count_connector_polylines(driver) + state_redo2 = get_scene_state(driver) + print(f" Polylines: {polylines_redo2}, connectors: {state_redo2.get('connectors', '?')}") + assert polylines_redo2 >= connector_polylines or ( + isinstance(state_redo2, dict) and state_redo2.get("connectors", 0) > 0 + ), "Second redo cycle did not restore connector" + + save_screenshot(driver, "conn_06_final") + print("\n SUCCESS: Connector undo/redo cycle works correctly!") + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "-s"]) diff --git a/e2e-tests/tests/test_multi_node_undo.py b/e2e-tests/tests/test_multi_node_undo.py new file mode 100644 index 0000000..6f9852e --- /dev/null +++ b/e2e-tests/tests/test_multi_node_undo.py @@ -0,0 +1,255 @@ +"""E2E test: place multiple nodes, then undo/redo through them.""" +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("--window-size=1920,1080") + d = webdriver.Remote( + command_executor=get_webdriver_url(), + options=chrome_options, + ) + d.implicitly_wait(10) + yield d + d.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) + return path + + +def dismiss_modals(driver): + try: + driver.execute_script(""" + const dialogs = document.querySelectorAll('[role="dialog"], [class*="MuiDialog"]'); + dialogs.forEach(d => { + const closeBtn = d.querySelector('button'); + if (closeBtn) closeBtn.click(); + }); + """) + time.sleep(0.5) + except Exception: + pass + + +def count_canvas_images(driver): + return driver.execute_script(""" + const c = document.querySelector('.fossflow-container'); + if (!c) return 0; + return c.querySelectorAll('img').length; + """) + + +def get_model_items_count(driver): + return driver.execute_script(""" + if (!window.__modelStore__) return -1; + var m = window.__modelStore__.getState(); + return (m.items || []).length; + """) + + +def place_node_at(driver, x_offset, y_offset): + """Select icon and place at a specific canvas offset.""" + # Click "Add item (N)" button + add_btn = driver.find_element(By.CSS_SELECTOR, "button[aria-label*='Add item']") + add_btn.click() + time.sleep(0.8) + + # Expand 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(2) + + # Select first icon + 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 + + ActionChains(driver).click(first_icon_btn).perform() + time.sleep(0.5) + + # Click on canvas at specific offset + canvas = driver.find_element(By.CLASS_NAME, "fossflow-container") + ActionChains(driver).move_to_element_with_offset(canvas, x_offset, y_offset).click().perform() + time.sleep(1) + return True + + +def click_undo(driver): + btn = driver.execute_script(""" + return document.querySelector("button[aria-label*='Undo']"); + """) + if btn: + btn.click() + time.sleep(1) + return True + return False + + +def click_redo(driver): + btn = driver.execute_script(""" + return document.querySelector("button[aria-label*='Redo']"); + """) + if btn: + btn.click() + time.sleep(1) + return True + return False + + +def test_multi_node_undo_redo(driver): + """Place 3 nodes, undo all 3, redo all 3, then undo 2 and place a new one (forking history).""" + 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) + time.sleep(0.5) + + baseline_imgs = count_canvas_images(driver) + print(f" Baseline images: {baseline_imgs}") + + # --- Place 3 nodes at different positions --- + positions = [(300, 300), (500, 300), (700, 300)] + for i, (x, y) in enumerate(positions): + print(f"\n2.{i+1}. Placing node {i+1} at ({x}, {y})...") + assert place_node_at(driver, x, y), f"Failed to place node {i+1}" + imgs = count_canvas_images(driver) + items = get_model_items_count(driver) + print(f" Images: {imgs}, Model items: {items}") + assert items == i + 1, f"Expected {i+1} model items, got {items}" + + save_screenshot(driver, "multi_01_three_nodes") + after_3 = count_canvas_images(driver) + items_3 = get_model_items_count(driver) + print(f"\n After 3 nodes: images={after_3}, items={items_3}") + assert items_3 == 3 + + # --- Undo all 3 nodes one by one --- + print("\n3. Undoing all 3 nodes...") + for i in range(3): + click_undo(driver) + imgs = count_canvas_images(driver) + items = get_model_items_count(driver) + expected = 2 - i + print(f" After undo {i+1}: images={imgs}, items={items} (expected {expected})") + assert items == expected, f"After undo {i+1}: expected {expected} items, got {items}" + + save_screenshot(driver, "multi_02_all_undone") + assert get_model_items_count(driver) == 0, "Expected 0 items after undoing all 3" + print(" All 3 nodes undone.") + + # --- Redo all 3 nodes one by one --- + print("\n4. Redoing all 3 nodes...") + for i in range(3): + click_redo(driver) + imgs = count_canvas_images(driver) + items = get_model_items_count(driver) + expected = i + 1 + print(f" After redo {i+1}: images={imgs}, items={items} (expected {expected})") + assert items == expected, f"After redo {i+1}: expected {expected} items, got {items}" + + save_screenshot(driver, "multi_03_all_redone") + assert get_model_items_count(driver) == 3, "Expected 3 items after redoing all" + print(" All 3 nodes redone.") + + # --- Undo 2, then place a new node (fork history) --- + print("\n5. Undoing 2 nodes to fork history...") + click_undo(driver) + click_undo(driver) + items = get_model_items_count(driver) + print(f" After 2 undos: items={items} (expected 1)") + assert items == 1, f"Expected 1 item after 2 undos, got {items}" + + # Check redo is available before fork + can_redo = driver.execute_script(""" + if (!window.__modelStore__) return null; + return window.__modelStore__.getState().actions.canRedo(); + """) + print(f" canRedo before fork: {can_redo}") + assert can_redo, "Should be able to redo before forking" + + print(" Placing new node to fork history...") + assert place_node_at(driver, 400, 300), "Failed to place fork node" + items = get_model_items_count(driver) + print(f" After fork placement: items={items} (expected 2)") + assert items == 2, f"Expected 2 items after fork placement, got {items}" + + # Redo should now be impossible (future was cleared by new action) + can_redo = driver.execute_script(""" + if (!window.__modelStore__) return null; + return window.__modelStore__.getState().actions.canRedo(); + """) + print(f" canRedo after fork: {can_redo}") + assert not can_redo, "Should NOT be able to redo after forking history" + + save_screenshot(driver, "multi_04_forked") + + # --- Undo the fork node --- + print("\n6. Undoing the fork node...") + click_undo(driver) + items = get_model_items_count(driver) + print(f" After undo fork: items={items} (expected 1)") + assert items == 1, f"Expected 1 item after undoing fork, got {items}" + + # --- Redo the fork node --- + print(" Redoing the fork node...") + click_redo(driver) + items = get_model_items_count(driver) + print(f" After redo fork: items={items} (expected 2)") + assert items == 2, f"Expected 2 item after redoing fork, got {items}" + + save_screenshot(driver, "multi_05_final") + print("\n SUCCESS: Multi-node undo/redo with history forking works!") + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "-s"]) diff --git a/e2e-tests/tests/test_node_placement.py b/e2e-tests/tests/test_node_placement.py new file mode 100644 index 0000000..b6ada03 --- /dev/null +++ b/e2e-tests/tests/test_node_placement.py @@ -0,0 +1,588 @@ +""" +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"]) diff --git a/e2e-tests/tests/test_rect_text_undo.py b/e2e-tests/tests/test_rect_text_undo.py new file mode 100644 index 0000000..332c1ba --- /dev/null +++ b/e2e-tests/tests/test_rect_text_undo.py @@ -0,0 +1,366 @@ +"""E2E tests: rectangle and text box creation with undo/redo.""" +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("--window-size=1920,1080") + d = webdriver.Remote( + command_executor=get_webdriver_url(), + options=chrome_options, + ) + d.implicitly_wait(10) + yield d + d.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) + return path + + +def dismiss_modals(driver): + try: + driver.execute_script(""" + document.querySelectorAll('[role="dialog"], [class*="MuiDialog"]').forEach(d => { + const b = d.querySelector('button'); if (b) b.click(); + }); + document.querySelectorAll('[data-testid="CloseIcon"], [data-testid="ClearIcon"]').forEach(icon => { + const b = icon.closest('button'); if (b) b.click(); + }); + document.querySelectorAll('button').forEach(btn => { + const l = (btn.getAttribute('aria-label') || '').toLowerCase(); + if (l.includes('close') || l.includes('dismiss')) btn.click(); + }); + """) + time.sleep(0.5) + except Exception: + pass + + +def get_scene_state(driver): + """Get scene state via React fiber store discovery.""" + return driver.execute_script(""" + var root = document.getElementById("root"); + var ck = Object.keys(root).find(function(k) { return k.startsWith("__reactContainer"); }); + if (!ck) return {error: "no react"}; + var fiber = root[ck], queue = [fiber], v = 0, ss = null, ms = null; + while (queue.length > 0 && v < 3000) { + var n = queue.shift(); if (!n) continue; v++; + if (n.pendingProps && n.pendingProps.value && + typeof n.pendingProps.value === "object" && n.pendingProps.value !== null && + typeof n.pendingProps.value.getState === "function") { + try { + var st = n.pendingProps.value.getState(); + if (st && st.connectors !== undefined && st.textBoxes !== undefined && st.history) ss = n.pendingProps.value; + if (st && st.views !== undefined && st.items !== undefined && st.history) ms = n.pendingProps.value; + } catch(e) {} + } + if (n.child) queue.push(n.child); + if (n.sibling) queue.push(n.sibling); + } + if (!ss || !ms) return {error: "stores not found"}; + var s = ss.getState(), m = ms.getState(); + var cv = m.views && m.views[0]; + return { + rectangles: cv && cv.rectangles ? cv.rectangles.length : 0, + textBoxes: Object.keys(s.textBoxes || {}).length, + connectors: Object.keys(s.connectors || {}).length, + modelItems: (m.items || []).length, + scenePast: s.history.past.length, + sceneFuture: s.history.future.length, + modelPast: m.history.past.length, + modelFuture: m.history.future.length, + }; + """) + + +def count_svg_polygons(driver): + """Count SVG polygon/path elements that represent rectangles.""" + return driver.execute_script(""" + var c = document.querySelector('.fossflow-container'); + if (!c) return 0; + // Rectangles render as SVG with polygon elements inside IsoTileArea + return c.querySelectorAll('svg polygon').length; + """) + + +def count_text_elements(driver): + """Count Typography/text elements from TextBox components. + TextBox renders a
with MuiTypography class containing textbox content. + Default content is 'Text' from TEXTBOX_DEFAULTS. + """ + return driver.execute_script(""" + var c = document.querySelector('.fossflow-container'); + if (!c) return 0; + var all = c.querySelectorAll('p.MuiTypography-root, span.MuiTypography-root'); + // Filter to actual textbox content (not UI labels like 'Untitled') + var count = 0; + for (var i = 0; i < all.length; i++) { + var t = all[i].textContent.trim(); + if (t === 'Text' || t === 'text' || t.length > 0) { + // Check it's inside a positioned container (textbox, not toolbar) + var parent = all[i].closest('[style*="position"]'); + if (parent) count++; + } + } + return count; + """) + + +def click_undo(driver): + btn = driver.execute_script("return document.querySelector(\"button[aria-label*='Undo']\");") + if btn: + btn.click() + time.sleep(1) + return True + return False + + +def click_redo(driver): + btn = driver.execute_script("return document.querySelector(\"button[aria-label*='Redo']\");") + if btn: + btn.click() + time.sleep(1) + return True + return False + + +# --------------------------------------------------------------------------- +# Rectangle test +# --------------------------------------------------------------------------- + +def test_rectangle_undo_redo(driver): + """Draw a rectangle by drag, undo to remove it, redo to restore.""" + 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) + time.sleep(0.5) + + state_before = get_scene_state(driver) + polygons_before = count_svg_polygons(driver) + print(f" Baseline: polygons={polygons_before}, state={state_before}") + save_screenshot(driver, "rect_01_baseline") + + # --- Click Rectangle tool --- + print("\n2. Activating Rectangle tool...") + rect_btn = driver.execute_script( + "return document.querySelector(\"button[aria-label*='Rectangle']\");" + ) + assert rect_btn, "Rectangle button not found" + rect_btn.click() + time.sleep(0.5) + + # --- Draw rectangle: mousedown, drag, mouseup --- + print(" Drawing rectangle by drag...") + canvas = driver.find_element(By.CLASS_NAME, "fossflow-container") + actions = ActionChains(driver) + actions.move_to_element_with_offset(canvas, 400, 250) + actions.click_and_hold() + actions.move_by_offset(200, 150) + actions.release() + actions.perform() + time.sleep(1) + save_screenshot(driver, "rect_02_drawn") + + state_after = get_scene_state(driver) + polygons_after = count_svg_polygons(driver) + print(f" After draw: polygons={polygons_after}, state={state_after}") + + assert isinstance(state_after, dict) and state_after.get("rectangles", 0) > 0, ( + f"No rectangle in store after drawing. State: {state_after}" + ) + rect_polygons = polygons_after + print(f" Rectangle created. Store has {state_after['rectangles']} rectangle(s).") + + # --- Undo rectangle --- + print("\n3. Undoing rectangle...") + # Rectangle draw creates 1 history entry (createRectangle) + potentially + # updateRectangle calls during drag. Undo until rectangles are 0. + max_undos = 5 + for i in range(max_undos): + click_undo(driver) + state_undo = get_scene_state(driver) + if isinstance(state_undo, dict) and state_undo.get("rectangles", 0) == 0: + print(f" Rectangle removed after {i+1} undo(s). State: {state_undo}") + break + else: + state_undo = get_scene_state(driver) + pytest.fail(f"Rectangle still present after {max_undos} undos. State: {state_undo}") + + polygons_undo = count_svg_polygons(driver) + save_screenshot(driver, "rect_03_after_undo") + print(f" Polygons after undo: {polygons_undo}") + + # --- Redo rectangle --- + print("\n4. Redoing rectangle...") + # Redo same number of times as we undid + redo_count = i + 1 + for j in range(redo_count): + click_redo(driver) + + state_redo = get_scene_state(driver) + polygons_redo = count_svg_polygons(driver) + print(f" After {redo_count} redo(s): polygons={polygons_redo}, state={state_redo}") + save_screenshot(driver, "rect_04_after_redo") + + assert isinstance(state_redo, dict) and state_redo.get("rectangles", 0) > 0, ( + f"Redo did not restore rectangle. State: {state_redo}" + ) + print(" Rectangle restored by redo.") + + # --- Undo again to verify cycle --- + print("\n5. Undoing rectangle again...") + for _ in range(redo_count): + click_undo(driver) + + state_undo2 = get_scene_state(driver) + assert isinstance(state_undo2, dict) and state_undo2.get("rectangles", 0) == 0, ( + f"Second undo cycle failed. State: {state_undo2}" + ) + print(" Rectangle removed again.") + + print("\n Redoing rectangle again...") + for _ in range(redo_count): + click_redo(driver) + + state_redo2 = get_scene_state(driver) + assert isinstance(state_redo2, dict) and state_redo2.get("rectangles", 0) > 0, ( + f"Second redo cycle failed. State: {state_redo2}" + ) + + save_screenshot(driver, "rect_05_final") + print("\n SUCCESS: Rectangle undo/redo cycle works!") + + +# --------------------------------------------------------------------------- +# TextBox test +# --------------------------------------------------------------------------- + +def test_textbox_undo_redo(driver): + """Create a text box, undo to remove it, redo to restore.""" + 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) + time.sleep(0.5) + + state_before = get_scene_state(driver) + print(f" Baseline: state={state_before}") + save_screenshot(driver, "text_01_baseline") + + # --- Click Text tool (creates textbox at mouse position immediately) --- + print("\n2. Clicking Text tool...") + text_btn = driver.execute_script( + "return document.querySelector(\"button[aria-label*='Text']\");" + ) + assert text_btn, "Text button not found" + text_btn.click() + time.sleep(0.5) + + # The textbox is created and follows the cursor in TEXTBOX mode. + # We need to click on the canvas to place it (mouseup handler). + print(" Clicking canvas to place text box...") + canvas = driver.find_element(By.CLASS_NAME, "fossflow-container") + ActionChains(driver).move_to_element_with_offset(canvas, 500, 350).click().perform() + time.sleep(1) + save_screenshot(driver, "text_02_placed") + + state_after = get_scene_state(driver) + print(f" After placement: state={state_after}") + + assert isinstance(state_after, dict) and state_after.get("textBoxes", 0) > 0, ( + f"No text box in store after placement. State: {state_after}" + ) + print(f" TextBox created. Store has {state_after['textBoxes']} text box(es).") + + # --- Undo text box (may take multiple steps) --- + print("\n3. Undoing text box...") + undo_steps = 0 + max_undos = 5 + for i in range(max_undos): + click_undo(driver) + undo_steps += 1 + state_undo = get_scene_state(driver) + print(f" After undo {i+1}: state={state_undo}") + if isinstance(state_undo, dict) and state_undo.get("textBoxes", 0) == 0: + break + else: + pytest.fail(f"TextBox still present after {max_undos} undos. State: {state_undo}") + + save_screenshot(driver, "text_03_after_undo") + print(f" TextBox removed after {undo_steps} undo(s).") + + # --- Redo text box --- + print("\n4. Redoing text box...") + for _ in range(undo_steps): + click_redo(driver) + + state_redo = get_scene_state(driver) + print(f" After {undo_steps} redo(s): state={state_redo}") + save_screenshot(driver, "text_04_after_redo") + + assert isinstance(state_redo, dict) and state_redo.get("textBoxes", 0) > 0, ( + f"Redo did not restore text box. State: {state_redo}" + ) + print(" TextBox restored by redo.") + + # --- Second undo/redo cycle --- + print("\n5. Second undo/redo cycle...") + for _ in range(undo_steps): + click_undo(driver) + state_undo2 = get_scene_state(driver) + assert isinstance(state_undo2, dict) and state_undo2.get("textBoxes", 0) == 0, ( + f"Second undo failed. State: {state_undo2}" + ) + print(" TextBox removed again.") + + for _ in range(undo_steps): + click_redo(driver) + state_redo2 = get_scene_state(driver) + assert isinstance(state_redo2, dict) and state_redo2.get("textBoxes", 0) > 0, ( + f"Second redo failed. State: {state_redo2}" + ) + + save_screenshot(driver, "text_05_final") + print("\n SUCCESS: TextBox undo/redo cycle works!") + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "-s"]) diff --git a/e2e-tests/tests/test_store_debug.py b/e2e-tests/tests/test_store_debug.py new file mode 100644 index 0000000..959dfed --- /dev/null +++ b/e2e-tests/tests/test_store_debug.py @@ -0,0 +1,247 @@ +"""Direct store-level undo/redo debugging.""" +import time +import json +from selenium import webdriver +from selenium.webdriver.chrome.options import Options +from selenium.webdriver.common.by import By +from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC + + +def setup_driver(): + opts = Options() + opts.add_argument("--headless=new") + opts.add_argument("--no-sandbox") + opts.add_argument("--disable-dev-shm-usage") + opts.add_argument("--window-size=1920,1080") + opts.set_capability('goog:loggingPrefs', {'browser': 'ALL'}) + d = webdriver.Remote("http://localhost:4444", options=opts) + d.implicitly_wait(10) + return d + + +def dump_store(d, label): + """Dump detailed store state.""" + result = d.execute_script(""" + var ms = window.__modelStore__; + var ss = window.__sceneStore__; + if (!ms || !ss) return {error: "stores not found", hasModel: !!ms, hasScene: !!ss}; + + var m = ms.getState(); + var s = ss.getState(); + var cv = m.views && m.views[0]; + return { + model: { + itemsLen: (m.items || []).length, + itemIds: (m.items || []).map(function(i){return i.id}), + viewsLen: (m.views || []).length, + viewItemsLen: cv ? (cv.items || []).length : -1, + viewItemIds: cv ? (cv.items || []).map(function(i){return i.id}) : [], + iconsLen: (m.icons || []).length, + histPastLen: m.history.past.length, + histFutureLen: m.history.future.length, + canUndo: m.actions.canUndo(), + canRedo: m.actions.canRedo(), + }, + scene: { + connectors: Object.keys(s.connectors || {}).length, + textBoxes: Object.keys(s.textBoxes || {}).length, + histPastLen: s.history.past.length, + histFutureLen: s.history.future.length, + canUndo: s.actions.canUndo(), + canRedo: s.actions.canRedo(), + } + }; + """) + print(f"\n [{label}] Store state: {json.dumps(result, indent=2)}") + return result + + +def count_dom_nodes(d): + """Count images and 'Untitled' labels in the DOM.""" + return d.execute_script(""" + var c = document.querySelector('.fossflow-container'); + if (!c) return {images: 0, labels: 0}; + var imgs = c.querySelectorAll('img').length; + var spans = Array.from(c.querySelectorAll('span, p')); + var labels = spans.filter(function(s){return s.textContent.trim() === 'Untitled'}).length; + return {images: imgs, labels: labels}; + """) + + +def place_node(d): + """Place a node using the same approach as the working e2e test.""" + # Click "Add item (N)" button + add_btn = d.find_element(By.CSS_SELECTOR, "button[aria-label*='Add item']") + add_btn.click() + time.sleep(1) + + # Expand ISOFLOW icon collection + d.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 button via ActionChains (not JS click) + first_icon_btn = d.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: + print(" ERROR: No icon button found") + return False + + ActionChains(d).click(first_icon_btn).perform() + time.sleep(0.5) + + # Click on canvas + canvas = d.find_element(By.CLASS_NAME, "fossflow-container") + ActionChains(d).move_to_element_with_offset(canvas, 500, 400).click().perform() + time.sleep(1) + return True + + +def main(): + d = setup_driver() + try: + d.get("http://localhost:3000") + WebDriverWait(d, 15).until( + EC.presence_of_element_located((By.CLASS_NAME, "fossflow-container")) + ) + time.sleep(3) + + # Dismiss modals/tips + d.execute_script(""" + const dialogs = document.querySelectorAll('[role="dialog"], [class*="MuiDialog"]'); + dialogs.forEach(d => { const b = d.querySelector('button'); if(b) b.click(); }); + """) + time.sleep(0.5) + + # Check stores + has = d.execute_script("return {m: !!window.__modelStore__, s: !!window.__sceneStore__}") + print(f"Stores on window: {json.dumps(has)}") + if not has.get("m") or not has.get("s"): + print("ERROR: Stores not exported to window!") + return + + # 1. Baseline + dump_store(d, "BASELINE") + dom = count_dom_nodes(d) + print(f" DOM: {json.dumps(dom)}") + + # 2. Place node + print("\n--- PLACING NODE ---") + ok = place_node(d) + print(f" place_node returned: {ok}") + dom = count_dom_nodes(d) + print(f" DOM after place: {json.dumps(dom)}") + dump_store(d, "AFTER PLACE") + + if dom.get("images", 0) == 0: + print("\n WARNING: No images in DOM - placement may have failed") + # Screenshot for debugging + d.save_screenshot("/tmp/debug_after_place.png") + print(" Screenshot: /tmp/debug_after_place.png") + + # 3. Direct model undo + print("\n--- MODEL UNDO ---") + undo_result = d.execute_script(""" + var ms = window.__modelStore__.getState(); + var result = ms.actions.undo(); + var after = window.__modelStore__.getState(); + var cv = after.views && after.views[0]; + var f = after.history.future; + return { + result: result, + afterItems: (after.items || []).length, + afterViewItems: cv ? (cv.items || []).length : -1, + pastLen: after.history.past.length, + futureLen: f.length, + // Inspect what's in future[0] + future0: f[0] ? { + items: (f[0].items || []).length, + views: (f[0].views || []).length, + viewItems: f[0].views && f[0].views[0] ? (f[0].views[0].items || []).length : -1, + } : null + }; + """) + print(f" Model undo: {json.dumps(undo_result, indent=2)}") + + # Also undo scene + scene_undo = d.execute_script(""" + var ss = window.__sceneStore__.getState(); + return { result: ss.actions.undo() }; + """) + print(f" Scene undo: {json.dumps(scene_undo)}") + + dump_store(d, "AFTER UNDO") + time.sleep(0.5) + dom = count_dom_nodes(d) + print(f" DOM after undo: {json.dumps(dom)}") + + # 4. Direct model redo + print("\n--- MODEL REDO ---") + redo_result = d.execute_script(""" + var ms = window.__modelStore__.getState(); + var f = ms.history.future; + var beforeInfo = { + items: (ms.items || []).length, + futureLen: f.length, + future0: f[0] ? { + items: (f[0].items || []).length, + views: (f[0].views || []).length, + viewItems: f[0].views && f[0].views[0] ? (f[0].views[0].items || []).length : -1, + } : null + }; + var result = ms.actions.redo(); + var after = window.__modelStore__.getState(); + var cv = after.views && after.views[0]; + return { + before: beforeInfo, + result: result, + afterItems: (after.items || []).length, + afterViewItems: cv ? (cv.items || []).length : -1, + pastLen: after.history.past.length, + futureLen: after.history.future.length, + }; + """) + print(f" Model redo: {json.dumps(redo_result, indent=2)}") + + # Also redo scene + scene_redo = d.execute_script(""" + var ss = window.__sceneStore__.getState(); + return { result: ss.actions.redo() }; + """) + print(f" Scene redo: {json.dumps(scene_redo)}") + + dump_store(d, "AFTER REDO") + time.sleep(0.5) + dom = count_dom_nodes(d) + print(f" DOM after redo: {json.dumps(dom)}") + + print("\n--- ALL TESTS PASSED ---") + + finally: + d.quit() + + +if __name__ == "__main__": + main() diff --git a/packages/fossflow-lib/src/hooks/useInitialDataManager.ts b/packages/fossflow-lib/src/hooks/useInitialDataManager.ts index 10d1c61..197197d 100644 --- a/packages/fossflow-lib/src/hooks/useInitialDataManager.ts +++ b/packages/fossflow-lib/src/hooks/useInitialDataManager.ts @@ -105,7 +105,7 @@ export const useInitialDataManager = () => { } prevInitialData.current = initialData; - model.actions.set(initialData); + model.actions.set(initialData, true); const view = getItemByIdOrThrow( initialData.views, diff --git a/packages/fossflow-lib/src/hooks/useView.ts b/packages/fossflow-lib/src/hooks/useView.ts index 87fa6ed..f62668d 100644 --- a/packages/fossflow-lib/src/hooks/useView.ts +++ b/packages/fossflow-lib/src/hooks/useView.ts @@ -22,7 +22,7 @@ export const useView = () => { ctx: { viewId, state: { model, scene: INITIAL_SCENE_STATE } } }); - sceneActions.set(newState.scene); + sceneActions.set(newState.scene, true); uiStateActions.setView(viewId); }, [uiStateActions, sceneActions] diff --git a/packages/fossflow-lib/src/stores/modelStore.tsx b/packages/fossflow-lib/src/stores/modelStore.tsx index 178d3d0..b014c98 100644 --- a/packages/fossflow-lib/src/stores/modelStore.tsx +++ b/packages/fossflow-lib/src/stores/modelStore.tsx @@ -54,7 +54,7 @@ const initialState = () => { const saveToHistory = () => { set((state) => { const currentModel = extractModelData(state); - const newPast = [...state.history.past, state.history.present]; + const newPast = [...state.history.past, currentModel]; // Limit history size to prevent memory issues if (newPast.length > state.history.maxHistorySize) { @@ -81,13 +81,15 @@ const initialState = () => { const newPast = history.past.slice(0, history.past.length - 1); set((state) => { + // Capture the actual live state (not stale history.present) + const currentModel = extractModelData(state); return { ...previous, history: { ...state.history, past: newPast, present: previous, - future: [state.history.present, ...state.history.future] + future: [currentModel, ...state.history.future] } }; }); @@ -103,11 +105,13 @@ const initialState = () => { const newFuture = history.future.slice(1); set((state) => { + // Capture the actual live state (not stale history.present) + const currentModel = extractModelData(state); return { ...next, history: { ...state.history, - past: [...state.history.past, state.history.present], + past: [...state.history.past, currentModel], present: next, future: newFuture } diff --git a/packages/fossflow-lib/src/stores/sceneStore.tsx b/packages/fossflow-lib/src/stores/sceneStore.tsx index fa95eb8..e037fb0 100644 --- a/packages/fossflow-lib/src/stores/sceneStore.tsx +++ b/packages/fossflow-lib/src/stores/sceneStore.tsx @@ -51,7 +51,7 @@ const initialState = () => { const saveToHistory = () => { set((state) => { const currentScene = extractSceneData(state); - const newPast = [...state.history.past, state.history.present]; + const newPast = [...state.history.past, currentScene]; // Limit history size if (newPast.length > state.history.maxHistorySize) { @@ -78,13 +78,15 @@ const initialState = () => { const newPast = history.past.slice(0, history.past.length - 1); set((state) => { + // Capture the actual live state (not stale history.present) + const currentScene = extractSceneData(state); return { ...previous, history: { ...state.history, past: newPast, present: previous, - future: [state.history.present, ...state.history.future] + future: [currentScene, ...state.history.future] } }; }); @@ -100,11 +102,13 @@ const initialState = () => { const newFuture = history.future.slice(1); set((state) => { + // Capture the actual live state (not stale history.present) + const currentScene = extractSceneData(state); return { ...next, history: { ...state.history, - past: [...state.history.past, state.history.present], + past: [...state.history.past, currentScene], present: next, future: newFuture }