"""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"])