fix: Fixed issues with history not fully working, undo/redo was hit or miss. Additionally added a huge amount of CI/CD testing using selenium so that we can simulate creating a diagram, placing nodes, connceting them, undo/redo, and rectangles/text as well, with love, Stan

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Stan
2026-02-15 09:31:19 +00:00
parent d831e50bce
commit 047df92785
12 changed files with 1869 additions and 14 deletions

View File

@@ -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

View File

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

View File

@@ -6,3 +6,4 @@ htmlcov/
*.log
venv/
env/
screenshots/

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()

View File

@@ -105,7 +105,7 @@ export const useInitialDataManager = () => {
}
prevInitialData.current = initialData;
model.actions.set(initialData);
model.actions.set(initialData, true);
const view = getItemByIdOrThrow(
initialData.views,

View File

@@ -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]

View File

@@ -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
}

View File

@@ -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
}