mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2026-02-20 08:06:44 -05:00
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:
5
.github/workflows/docker.yml
vendored
5
.github/workflows/docker.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/pages.yml
vendored
4
.github/workflows/pages.yml
vendored
@@ -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"]
|
||||
|
||||
1
e2e-tests/.gitignore
vendored
1
e2e-tests/.gitignore
vendored
@@ -6,3 +6,4 @@ htmlcov/
|
||||
*.log
|
||||
venv/
|
||||
env/
|
||||
screenshots/
|
||||
|
||||
393
e2e-tests/tests/test_connector_undo.py
Normal file
393
e2e-tests/tests/test_connector_undo.py
Normal 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"])
|
||||
255
e2e-tests/tests/test_multi_node_undo.py
Normal file
255
e2e-tests/tests/test_multi_node_undo.py
Normal 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"])
|
||||
588
e2e-tests/tests/test_node_placement.py
Normal file
588
e2e-tests/tests/test_node_placement.py
Normal 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"])
|
||||
366
e2e-tests/tests/test_rect_text_undo.py
Normal file
366
e2e-tests/tests/test_rect_text_undo.py
Normal 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"])
|
||||
247
e2e-tests/tests/test_store_debug.py
Normal file
247
e2e-tests/tests/test_store_debug.py
Normal 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()
|
||||
@@ -105,7 +105,7 @@ export const useInitialDataManager = () => {
|
||||
}
|
||||
|
||||
prevInitialData.current = initialData;
|
||||
model.actions.set(initialData);
|
||||
model.actions.set(initialData, true);
|
||||
|
||||
const view = getItemByIdOrThrow(
|
||||
initialData.views,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user