#!/usr/bin/env python3 """ Browser automation tests using Selenium. Tests run against the device in an actual browser. Target host defaults to ``127.0.0.1:5000``; override with ``LED_CONTROLLER_DEVICE_IP`` (IP or hostname, or a full ``http://`` / ``https://`` base URL). Fixed delays between UI steps use ``LED_CONTROLLER_BROWSER_SLEEP_SCALE`` (default ``0.5``, i.e. half the nominal pause). Set to ``1`` for the old pacing, or ``0`` to skip fixed sleeps (may flake). Driver implicit wait defaults to ``2`` seconds; override with ``LED_CONTROLLER_BROWSER_IMPLICIT_WAIT``. On Pi OS Lite (no desktop) these tests are skipped unless headless Chromium and chromedriver are installed (e.g. chromium-browser chromium-chromedriver). """ import os import sys import pytest if os.environ.get("LED_CONTROLLER_RUN_BROWSER_TESTS") != "1": # pytest catches Skipped; plain `python tests/test_browser.py` does not. if __name__ == "__main__": print( "Browser tests are disabled by default. " "Set LED_CONTROLLER_RUN_BROWSER_TESTS=1 to run.", file=sys.stderr, ) raise SystemExit(0) pytest.skip( "Legacy device browser automation script; enable explicitly to run.", allow_module_level=True, ) import time import requests from typing import Optional, List from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.chrome.options import Options as ChromeOptions from selenium.webdriver.firefox.options import Options as FirefoxOptions from selenium.webdriver.common.action_chains import ActionChains from selenium.common.exceptions import ( TimeoutException, NoSuchElementException, ElementNotInteractableException, ) _DEFAULT_DEVICE_HOST = "127.0.0.1:5000" def _device_base_url() -> str: raw = os.environ.get("LED_CONTROLLER_DEVICE_IP", _DEFAULT_DEVICE_HOST).strip() if not raw: raw = _DEFAULT_DEVICE_HOST if raw.startswith(("http://", "https://")): return raw.rstrip("/") return f"http://{raw}" # Base URL for the device BASE_URL = _device_base_url() def _browser_sleep(seconds: float) -> None: """Scale fixed UI pauses via LED_CONTROLLER_BROWSER_SLEEP_SCALE (default 0.5).""" try: scale = float(os.environ.get("LED_CONTROLLER_BROWSER_SLEEP_SCALE", "0.5")) except ValueError: scale = 0.5 if scale <= 0: return time.sleep(max(0.0, float(seconds)) * scale) def _implicit_wait_s() -> int: try: v = float(os.environ.get("LED_CONTROLLER_BROWSER_IMPLICIT_WAIT", "2")) except ValueError: v = 2.0 return int(max(0, min(60, round(v)))) class BrowserTest: """Browser automation test class.""" def __init__(self, base_url: str = BASE_URL, headless: bool = False): self.base_url = base_url self.driver = None self.headless = headless self.created_zones: List[str] = [] self.created_profiles: List[str] = [] self.created_presets: List[str] = [] def setup(self): """Set up the browser driver. Tries Chrome first, then Firefox.""" err_chrome, err_firefox = None, None # Try Chrome first try: opts = ChromeOptions() if self.headless: opts.add_argument('--headless') opts.add_argument('--no-sandbox') opts.add_argument('--disable-dev-shm-usage') opts.add_argument('--disable-gpu') opts.add_argument('--window-size=1920,1080') self.driver = webdriver.Chrome(options=opts) self.driver.implicitly_wait(_implicit_wait_s()) print("✓ Browser started (Chrome)") return True except Exception as e: err_chrome = e # Fallback to Firefox try: opts = FirefoxOptions() if self.headless: opts.add_argument('--headless') self.driver = webdriver.Firefox(options=opts) self.driver.implicitly_wait(_implicit_wait_s()) print("✓ Browser started (Firefox)") return True except Exception as e: err_firefox = e print("✗ Failed to start browser.") if err_chrome: print(f" Chrome: {err_chrome}") if err_firefox: print(f" Firefox: {err_firefox}") print(" On Raspberry Pi (aarch64), install: chromium-browser and chromium-chromedriver") return False def teardown(self): """Close the browser.""" if self.driver: self.driver.quit() print("✓ Browser closed") def navigate(self, path: str = '/'): """Navigate to a URL.""" url = f"{self.base_url}{path}" try: self.driver.get(url) _browser_sleep(1) # Wait for page load return True except Exception as e: print(f"✗ Failed to navigate to {url}: {e}") return False def wait_for_element(self, by, value, timeout=10): """Wait for an element to appear.""" try: element = WebDriverWait(self.driver, timeout).until( EC.presence_of_element_located((by, value)) ) return element except TimeoutException: return None def _scroll_into_view(self, element) -> None: self.driver.execute_script( "arguments[0].scrollIntoView({block: 'center', inline: 'nearest'});", element, ) def click_element(self, by, value, timeout=10, use_js=False): """Click an element.""" try: element = self.wait_for_element(by, value, timeout) if element: self._scroll_into_view(element) if use_js: # Use JavaScript click for elements that might be intercepted self.driver.execute_script("arguments[0].click();", element) else: # Try normal click first, fall back to JS if it fails try: element.click() except Exception: self.driver.execute_script("arguments[0].click();", element) _browser_sleep(0.5) # Wait for action return True return False except Exception as e: print(f"✗ Failed to click {value}: {e}") return False def handle_alert(self, accept=True, timeout=2): """Handle an alert dialog.""" try: alert = WebDriverWait(self.driver, timeout).until(EC.alert_is_present()) if accept: alert.accept() else: alert.dismiss() _browser_sleep(0.3) return True except TimeoutException: return False except Exception as e: print(f"✗ Failed to handle alert: {e}") return False def cleanup_test_data(self): """Clean up test data created during tests.""" print("\n Cleaning up test data...") try: # Use requests to make API calls for cleanup session = requests.Session() # Delete created presets by ID for preset_id in self.created_presets: try: response = session.delete(f"{self.base_url}/presets/{preset_id}") if response.status_code == 200: print(f" ✓ Cleaned up preset: {preset_id}") except Exception as e: print(f" ⚠ Failed to cleanup preset {preset_id}: {e}") # Delete created zones by ID for zone_id in self.created_zones: try: response = session.delete(f"{self.base_url}/zones/{zone_id}") if response.status_code == 200: print(f" ✓ Cleaned up zone: {zone_id}") except Exception as e: print(f" ⚠ Failed to cleanup zone {zone_id}: {e}") # Delete created profiles by ID for profile_id in self.created_profiles: try: response = session.delete(f"{self.base_url}/profiles/{profile_id}") if response.status_code == 200: print(f" ✓ Cleaned up profile: {profile_id}") except Exception as e: print(f" ⚠ Failed to cleanup profile {profile_id}: {e}") # Also try to cleanup by name pattern (in case IDs weren't tracked) test_names = ['Browser Test Zone', 'Browser Test Profile', 'Browser Test Preset', 'Preset 1', 'Preset 2', 'Preset 3', 'Edited Browser Zone'] # Cleanup zones by name try: zones_response = session.get(f"{self.base_url}/zones") if zones_response.status_code == 200: zones_data = zones_response.json() zones_map = zones_data.get('zones', {}) for zone_id, zone_row in zones_map.items(): if isinstance(zone_row, dict) and zone_row.get('name') in test_names: try: session.delete(f"{self.base_url}/zones/{zone_id}") print(f" ✓ Cleaned up zone by name: {zone_row.get('name')}") except: pass except: pass # Cleanup profiles by name try: profiles_response = session.get(f"{self.base_url}/profiles") if profiles_response.status_code == 200: profiles_data = profiles_response.json() profiles = profiles_data.get('profiles', {}) for profile_id, profile_data in profiles.items(): if isinstance(profile_data, dict) and profile_data.get('name') in test_names: try: session.delete(f"{self.base_url}/profiles/{profile_id}") print(f" ✓ Cleaned up profile by name: {profile_data.get('name')}") except: pass except: pass # Cleanup presets by name try: presets_response = session.get(f"{self.base_url}/presets") if presets_response.status_code == 200: presets_data = presets_response.json() presets = presets_data.get('presets', {}) if isinstance(presets_data, dict) else presets_data for preset_id, preset_data in presets.items(): if isinstance(preset_data, dict) and preset_data.get('name') in test_names: try: session.delete(f"{self.base_url}/presets/{preset_id}") print(f" ✓ Cleaned up preset by name: {preset_data.get('name')}") except: pass except: pass # Clear the lists self.created_zones.clear() self.created_profiles.clear() self.created_presets.clear() except Exception as e: print(f" ⚠ Cleanup error: {e}") def fill_input(self, by, value, text, timeout=10): """Fill an input field.""" try: element = self.wait_for_element(by, value, timeout) if element: self._scroll_into_view(element) # Chrome often reports as not interactable for clear/send_keys. if (element.get_attribute("type") or "").lower() == "color": hex_v = text.strip() if hex_v and not hex_v.startswith("#"): hex_v = "#" + hex_v self.driver.execute_script( """ var el = arguments[0], v = arguments[1]; el.value = v; el.dispatchEvent(new Event('input', {bubbles: true})); el.dispatchEvent(new Event('change', {bubbles: true})); """, element, hex_v, ) return True element.clear() try: element.send_keys(text) except ElementNotInteractableException: self.driver.execute_script( "arguments[0].value = arguments[1];" "arguments[0].dispatchEvent(new Event('input', {bubbles: true}));" "arguments[0].dispatchEvent(new Event('change', {bubbles: true}));", element, text, ) return True return False except Exception as e: print(f"✗ Failed to fill {value}: {e}") return False def get_text(self, by, value, timeout=10): """Get text from an element.""" try: element = self.wait_for_element(by, value, timeout) if element: return element.text return None except Exception as e: return None def get_cookie(self, name: str): """Get a cookie value.""" try: return self.driver.get_cookie(name) except: return None def drag_and_drop(self, source_element, target_element): """Perform drag and drop operation.""" try: actions = ActionChains(self.driver) actions.drag_and_drop(source_element, target_element).perform() _browser_sleep(0.5) # Wait for drop to complete return True except Exception as e: print(f"✗ Drag and drop failed: {e}") return False def drag_and_drop_by_offset(self, element, x_offset, y_offset): """Perform drag and drop by offset.""" try: actions = ActionChains(self.driver) actions.drag_and_drop_by_offset(element, x_offset, y_offset).perform() _browser_sleep(0.5) return True except Exception as e: print(f"✗ Drag and drop by offset failed: {e}") return False @pytest.fixture def browser() -> BrowserTest: return BrowserTest() def test_browser_connection(browser: BrowserTest) -> bool: """Test basic browser connection.""" print("Testing browser connection...") if not browser.setup(): return False try: if browser.navigate('/'): print("✓ Successfully loaded page") title = browser.driver.title print(f" Page title: {title}") return True else: print("✗ Failed to load page") return False finally: browser.teardown() def test_zones_ui(browser: BrowserTest) -> bool: """Test zones UI in browser.""" print("\n=== Testing Zones UI in Browser ===") passed = 0 total = 0 if not browser.setup(): return False try: # Test 1: Load page total += 1 if browser.navigate('/'): print("✓ Loaded main page") passed += 1 else: print("✗ Failed to load main page") browser.teardown() return False # Test 2: Open zones modal total += 1 if browser.click_element(By.ID, 'zones-btn'): print("✓ Clicked Zones button") # Wait for modal to appear _browser_sleep(0.5) modal = browser.wait_for_element(By.ID, 'zones-modal') if modal and 'active' in modal.get_attribute('class'): print("✓ Zones modal opened") passed += 1 else: print("✗ Zones modal didn't open") else: print("✗ Failed to click Zones button") # Test 3: Create a zone via UI total += 1 try: # Fill in zone name if browser.fill_input(By.ID, 'new-zone-name', 'Browser Test Zone'): print(" ✓ Filled zone name") # Devices default from registry or placeholder name "1" # Click create button if browser.click_element(By.ID, 'create-zone-btn'): print(" ✓ Clicked create button") _browser_sleep(1) # Wait for creation # Check if zone appears in list and extract ID tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal') if tabs_list: list_text = tabs_list.text if 'Browser Test Zone' in list_text: print("✓ Created zone via UI") # Try to extract zone ID from the list (look for data-zone-id attribute) try: tab_rows = browser.driver.find_elements(By.CSS_SELECTOR, '#zones-list-modal .profiles-row') for row in tab_rows: if 'Browser Test Zone' in row.text: zone_id = row.get_attribute('data-zone-id') if zone_id: browser.created_zones.append(zone_id) break except: pass # If we can't extract ID, cleanup will try by name passed += 1 else: print("✗ Zone not found in list after creation") else: print("✗ Zones list not found") else: print("✗ Failed to click create button") except Exception as e: print(f"✗ Failed to create zone via UI: {e}") # Test 4: Edit a zone via UI (right-click in zones list) total += 1 try: # First, close and reopen modal to refresh browser.click_element(By.ID, 'zones-close-btn') _browser_sleep(0.5) browser.click_element(By.ID, 'zones-btn') _browser_sleep(0.5) # Right-click the row corresponding to 'Browser Test Zone' try: tab_row = browser.driver.find_element( By.XPATH, "//div[@id='zones-list-modal']//div[contains(@class,'profiles-row')][.//span[contains(text(), 'Browser Test Zone')]]" ) except Exception: tab_row = None if tab_row: actions = ActionChains(browser.driver) actions.context_click(tab_row).perform() _browser_sleep(0.5) # Check if edit modal opened edit_modal = browser.wait_for_element(By.ID, 'edit-zone-modal') if edit_modal: print("✓ Edit modal opened via right-click") # Fill in new name if browser.fill_input(By.ID, 'edit-zone-name', 'Edited Browser Zone'): print(" ✓ Filled new zone name") # Submit form edit_form = browser.wait_for_element(By.ID, 'edit-zone-form') if edit_form: browser.driver.execute_script("arguments[0].submit();", edit_form) _browser_sleep(1) # Wait for update print("✓ Submitted edit form") passed += 1 else: print("✗ Edit form not found") else: print("✗ Edit modal didn't open after right-click") else: print("✗ Could not find zone row for 'Browser Test Zone'") except Exception as e: print(f"✗ Failed to edit zone via UI: {e}") import traceback traceback.print_exc() # Test 5: Check current zone cookie total += 1 cookie = browser.get_cookie('current_zone') if cookie: print(f"✓ Found current_zone cookie: {cookie.get('value')}") passed += 1 else: print("⚠ No current_zone cookie found (might be normal if no zone selected)") passed += 1 # Not a failure, just informational # Close modal browser.click_element(By.ID, 'zones-close-btn') except Exception as e: print(f"✗ Browser test error: {e}") import traceback traceback.print_exc() finally: browser.cleanup_test_data() browser.teardown() print(f"\nBrowser zones UI tests: {passed}/{total} passed") return passed == total def test_profiles_ui(browser: BrowserTest) -> bool: """Test profiles UI in browser.""" print("\n=== Testing Profiles UI in Browser ===") passed = 0 total = 0 if not browser.setup(): return False try: # Test 1: Load page total += 1 if browser.navigate('/'): print("✓ Loaded main page") passed += 1 else: browser.teardown() return False # Test 2: Open profiles modal total += 1 if browser.click_element(By.ID, 'profiles-btn'): print("✓ Clicked Profiles button") _browser_sleep(0.5) modal = browser.wait_for_element(By.ID, 'profiles-modal') if modal: print("✓ Profiles modal opened") passed += 1 else: print("✗ Profiles modal didn't open") # Test 3: Create profile via UI total += 1 try: if browser.fill_input(By.ID, 'new-profile-name', 'Browser Test Profile'): print(" ✓ Filled profile name") if browser.click_element(By.ID, 'create-profile-btn'): print(" ✓ Clicked create button") _browser_sleep(1) # Check if profile appears profiles_list = browser.wait_for_element(By.ID, 'profiles-list') if profiles_list and 'Browser Test Profile' in profiles_list.text: print("✓ Created profile via UI") passed += 1 else: print("✗ Profile not found in list") except Exception as e: print(f"✗ Failed to create profile: {e}") # Close modal browser.click_element(By.ID, 'profiles-close-btn') except Exception as e: print(f"✗ Browser test error: {e}") import traceback traceback.print_exc() finally: browser.cleanup_test_data() browser.teardown() print(f"\nBrowser profiles UI tests: {passed}/{total} passed") return passed == total def test_mobile_tab_presets_two_columns(): """ Verify that the zone preset selecting area shows roughly two preset tiles per row on a phone-sized viewport. """ bt = BrowserTest(base_url=BASE_URL, headless=True) if not bt.setup(): assert False, "Failed to start browser" try: # Simulate a mobile viewport bt.driver.set_window_size(400, 800) assert bt.navigate('/'), "Failed to load main page" # Click the first zone button to load presets for that zone first_tab = bt.wait_for_element(By.CSS_SELECTOR, '.zone-button', timeout=10) assert first_tab is not None, "No zone buttons found" first_tab.click() _browser_sleep(1) container = bt.wait_for_element(By.ID, 'presets-list-zone', timeout=10) assert container is not None, "presets-list-zone not found" tiles = bt.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .preset-tile-row') # Need at least 2 presets to make this meaningful assert len(tiles) >= 2, "Fewer than 2 presets found for zone" container_width = container.size['width'] first_width = tiles[0].size['width'] # Each tile should be about half the container width (tolerate some margin) assert 0.4 * container_width <= first_width <= 0.6 * container_width, ( f"Preset tile width {first_width} not ~half of container {container_width}" ) finally: bt.teardown() def test_presets_ui(browser: BrowserTest) -> bool: """Test presets UI in browser.""" print("\n=== Testing Presets UI in Browser ===") passed = 0 total = 0 if not browser.setup(): return False try: # Test 1: Load page total += 1 if browser.navigate('/'): print("✓ Loaded main page") passed += 1 else: browser.teardown() return False # Test 2: Open presets modal total += 1 if browser.click_element(By.ID, 'presets-btn'): print("✓ Clicked Presets button") _browser_sleep(0.5) modal = browser.wait_for_element(By.ID, 'presets-modal') if modal: print("✓ Presets modal opened") passed += 1 else: print("✗ Presets modal didn't open") # Test 3: Click Add button to open preset editor total += 1 try: if browser.click_element(By.ID, 'preset-add-btn'): print(" ✓ Clicked Add Preset button") _browser_sleep(0.5) editor_modal = browser.wait_for_element(By.ID, 'preset-editor-modal') if editor_modal: print("✓ Preset editor modal opened") passed += 1 else: print("✗ Preset editor modal didn't open") else: print("✗ Failed to click Add Preset button") except Exception as e: print(f"✗ Failed to open preset editor: {e}") # Test 4: Create a preset via UI total += 1 try: # Fill preset name if browser.fill_input(By.ID, 'preset-name-input', 'Browser Test Preset'): print(" ✓ Filled preset name") # Select a pattern pattern_select = browser.wait_for_element(By.ID, 'preset-pattern-input') if pattern_select: # Try to select first available pattern from selenium.webdriver.support.ui import Select select = Select(pattern_select) if len(select.options) > 1: # More than just the placeholder select.select_by_index(1) # Select first real pattern print(" ✓ Selected pattern") # Add a color if browser.fill_input(By.ID, 'preset-new-color', '#ff0000'): print(" ✓ Set color") if browser.click_element(By.ID, 'preset-add-color-btn'): print(" ✓ Added color") # Set brightness if browser.fill_input(By.ID, 'preset-brightness-input', '200'): print(" ✓ Set brightness") # Save preset if browser.click_element(By.ID, 'preset-save-btn'): print(" ✓ Clicked save button") _browser_sleep(1) # Check if preset appears in list presets_list = browser.wait_for_element(By.ID, 'presets-list') if presets_list and 'Browser Test Preset' in presets_list.text: print("✓ Created preset via UI") passed += 1 else: print("✗ Preset not found in list after creation") else: print("✗ Failed to click save button") except Exception as e: print(f"✗ Failed to create preset: {e}") import traceback traceback.print_exc() # Close editor modal browser.click_element(By.ID, 'preset-editor-close-btn', use_js=True) _browser_sleep(0.5) # Close presets modal browser.click_element(By.ID, 'presets-close-btn', use_js=True) except Exception as e: print(f"✗ Browser test error: {e}") import traceback traceback.print_exc() finally: browser.cleanup_test_data() browser.teardown() print(f"\nBrowser presets UI tests: {passed}/{total} passed") return passed == total def test_color_palette_ui(browser: BrowserTest) -> bool: """Test color palette UI in browser.""" print("\n=== Testing Color Palette UI in Browser ===") passed = 0 total = 0 if not browser.setup(): return False try: # Test 1: Load page total += 1 if browser.navigate('/'): print("✓ Loaded main page") passed += 1 else: browser.teardown() return False # Test 2: Open color palette modal total += 1 if browser.click_element(By.ID, 'color-palette-btn'): print("✓ Clicked Color Palette button") _browser_sleep(0.5) modal = browser.wait_for_element(By.ID, 'color-palette-modal') if modal: print("✓ Color palette modal opened") passed += 1 else: print("✗ Color palette modal didn't open") # Test 3: Add a color to palette total += 1 try: # Set color picker - use a unique color that's likely not in palette color_input = browser.wait_for_element(By.ID, 'palette-new-color') if color_input: # Use a unique color (purple) that's less likely to be in palette browser.driver.execute_script("arguments[0].value = '#9b59b6'; arguments[0].dispatchEvent(new Event('input'));", color_input) print(" ✓ Set color to #9b59b6") # Click add color button if browser.click_element(By.ID, 'palette-add-color-btn'): print(" ✓ Clicked Add Color button") _browser_sleep(0.5) # Handle alert if color already exists browser.handle_alert(accept=True, timeout=1) # Check if color appears in palette palette_container = browser.wait_for_element(By.ID, 'palette-container') if palette_container: # Look for color swatches color_swatches = browser.driver.find_elements(By.CSS_SELECTOR, '#palette-container .draggable-color-swatch, #palette-container [style*="background-color"]') if color_swatches: print("✓ Added color to palette") passed += 1 else: print("✗ Color not found in palette after adding") else: print("✗ Palette container not found") else: print("✗ Failed to click Add Color button") except Exception as e: print(f"✗ Failed to add color: {e}") import traceback traceback.print_exc() # Test 4: Test drag and drop colors (if multiple colors exist) total += 1 try: color_swatches = browser.driver.find_elements(By.CSS_SELECTOR, '#palette-container .draggable-color-swatch, #palette-container [draggable="true"]') if len(color_swatches) >= 2: # Drag first color to second position source = color_swatches[0] target = color_swatches[1] if browser.drag_and_drop(source, target): print("✓ Dragged color to reorder") _browser_sleep(0.5) passed += 1 else: print("✗ Drag and drop failed") else: print("⚠ Not enough colors to test drag and drop (need at least 2)") passed += 1 # Not a failure except Exception as e: print(f"✗ Drag and drop test error: {e}") # Close modal browser.click_element(By.ID, 'color-palette-close-btn', use_js=True) except Exception as e: print(f"✗ Browser test error: {e}") import traceback traceback.print_exc() finally: browser.cleanup_test_data() browser.teardown() print(f"\nBrowser color palette UI tests: {passed}/{total} passed") return passed >= total - 1 # Allow one failure (alert handling might be flaky) def test_preset_drag_and_drop(browser: BrowserTest) -> bool: """Test dragging presets around in a zone.""" print("\n=== Testing Preset Drag and Drop in Zone ===") passed = 0 total = 0 if not browser.setup(): return False try: # Test 1: Load page and ensure we have a zone total += 1 if browser.navigate('/'): print("✓ Loaded main page") passed += 1 else: browser.teardown() return False # Test 2: Open zones modal and create/select a zone total += 1 browser.click_element(By.ID, 'zones-btn') _browser_sleep(0.5) # Check if we have zones, if not create one tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal') if tabs_list and 'No zones found' in tabs_list.text: # Create a zone browser.fill_input(By.ID, 'new-zone-name', 'Drag Test Zone') browser.click_element(By.ID, 'create-zone-btn') _browser_sleep(1) # Select first zone (or the one we just created) select_buttons = browser.driver.find_elements(By.XPATH, "//button[contains(text(), 'Select')]") if select_buttons: select_buttons[0].click() _browser_sleep(1) print("✓ Selected a zone") passed += 1 else: print("✗ No zones available to select") browser.click_element(By.ID, 'zones-close-btn') browser.teardown() return False browser.click_element(By.ID, 'zones-close-btn', use_js=True) _browser_sleep(0.5) # Test 3: Open presets modal and create presets total += 1 browser.click_element(By.ID, 'presets-btn') _browser_sleep(0.5) # Create first preset browser.click_element(By.ID, 'preset-add-btn') _browser_sleep(0.5) browser.fill_input(By.ID, 'preset-name-input', 'Preset 1') browser.fill_input(By.ID, 'preset-new-color', '#ff0000') browser.click_element(By.ID, 'preset-add-color-btn') browser.click_element(By.ID, 'preset-save-btn') _browser_sleep(1) # Create second preset browser.click_element(By.ID, 'preset-add-btn') _browser_sleep(0.5) browser.fill_input(By.ID, 'preset-name-input', 'Preset 2') browser.fill_input(By.ID, 'preset-new-color', '#00ff00') browser.click_element(By.ID, 'preset-add-color-btn') browser.click_element(By.ID, 'preset-save-btn') _browser_sleep(1) # Create third preset browser.click_element(By.ID, 'preset-add-btn') _browser_sleep(0.5) browser.fill_input(By.ID, 'preset-name-input', 'Preset 3') browser.fill_input(By.ID, 'preset-new-color', '#0000ff') browser.click_element(By.ID, 'preset-add-color-btn') browser.click_element(By.ID, 'preset-save-btn') _browser_sleep(1) browser.click_element(By.ID, 'presets-close-btn', use_js=True) _browser_sleep(0.5) print("✓ Created 3 presets for drag test") passed += 1 # Test 4: Add presets to the zone (via Edit Zone modal – Add buttons in list) total += 1 try: zone_id = browser.driver.execute_script( "return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;" ) if not zone_id: print("✗ Could not get current zone id") else: browser.driver.execute_script( "if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }", zone_id ) _browser_sleep(1) list_el = browser.wait_for_element(By.ID, 'edit-zone-presets-list', timeout=5) if list_el: select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']") if len(select_buttons) >= 2: browser.driver.execute_script("arguments[0].click();", select_buttons[0]) _browser_sleep(1.5) browser.handle_alert(accept=True, timeout=1) select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']") if len(select_buttons) >= 1: browser.driver.execute_script("arguments[0].click();", select_buttons[0]) _browser_sleep(1.5) browser.handle_alert(accept=True, timeout=1) print(" ✓ Added 2 presets to zone") passed += 1 elif len(select_buttons) == 1: browser.driver.execute_script("arguments[0].click();", select_buttons[0]) _browser_sleep(1.5) browser.handle_alert(accept=True, timeout=1) print(" ✓ Added 1 preset to zone") passed += 1 else: print(" ⚠ No presets available to add (all already in zone)") else: print("✗ Edit zone presets list not found") except Exception as e: print(f"✗ Failed to add presets to zone: {e}") import traceback traceback.print_exc() # Test 5: Find presets in zone and test drag and drop (Edit mode only) total += 1 try: # Wait for presets to load in the zone presets_list_tab = browser.wait_for_element(By.ID, 'presets-list-zone', timeout=5) if presets_list_tab: _browser_sleep(1) # Wait for presets to render # Reordering is only available in Edit mode (tiles get .draggable-preset) mode_toggle = browser.wait_for_element(By.CSS_SELECTOR, '.ui-mode-toggle', timeout=5) if mode_toggle and mode_toggle.get_attribute('aria-pressed') == 'false': mode_toggle.click() _browser_sleep(0.5) # Find draggable preset elements - wait a bit more for rendering _browser_sleep(1) draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset') if len(draggable_presets) >= 2: print(f" ✓ Found {len(draggable_presets)} draggable presets") # Get initial order initial_order = [p.text for p in draggable_presets] print(f" Initial order: {initial_order[:3]}") # Show first 3 # Drag first preset down source = draggable_presets[0] target = draggable_presets[1] # Use ActionChains for drag and drop actions = ActionChains(browser.driver) actions.click_and_hold(source).move_to_element(target).release().perform() _browser_sleep(1) # Wait for reorder to complete # Check if order changed draggable_presets_after = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset') if len(draggable_presets_after) >= 2: new_order = [p.text for p in draggable_presets_after] print(f" New order: {new_order[:3]}") if initial_order != new_order: print("✓ Preset drag and drop worked - order changed") passed += 1 else: print("⚠ Order didn't change (might need to verify with API)") passed += 1 # Not necessarily a failure else: print("✗ Presets disappeared after drag") elif len(draggable_presets) == 1: print(f"⚠ Only 1 preset found in zone (need 2 for drag test). Preset: {draggable_presets[0].text}") zone_id = browser.driver.execute_script( "return (window.tabsManager && window.tabsManager.getCurrentTabId && window.tabsManager.getCurrentTabId()) || null;" ) if zone_id: browser.driver.execute_script( "if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }", zone_id ) _browser_sleep(1) select_buttons = browser.driver.find_elements(By.XPATH, "//div[@id='edit-zone-presets-list']//button[text()='Add']") if select_buttons: print(" Attempting to add another preset...") browser.driver.execute_script("arguments[0].click();", select_buttons[0]) _browser_sleep(1.5) browser.handle_alert(accept=True, timeout=1) try: browser.driver.execute_script("document.getElementById('edit-zone-modal').classList.remove('active');") except Exception: pass _browser_sleep(1) draggable_presets = browser.driver.find_elements(By.CSS_SELECTOR, '#presets-list-zone .draggable-preset') if len(draggable_presets) >= 2: print(" ✓ Added another preset, now testing drag...") source = draggable_presets[0] target = draggable_presets[1] actions = ActionChains(browser.driver) actions.click_and_hold(source).move_to_element(target).release().perform() _browser_sleep(1) print("✓ Performed drag and drop") passed += 1 else: print(f" ✗ Still only {len(draggable_presets)} preset(s) after adding") else: print(" ✗ No Add buttons found in Edit Zone modal") else: print(f"✗ No presets found in zone (found {len(draggable_presets)})") else: print("✗ Presets list in zone not found") except Exception as e: print(f"✗ Drag and drop test error: {e}") import traceback traceback.print_exc() except Exception as e: print(f"✗ Browser test error: {e}") import traceback traceback.print_exc() finally: browser.cleanup_test_data() browser.teardown() print(f"\nBrowser preset drag and drop tests: {passed}/{total} passed") return passed >= total - 2 # Allow up to 2 failures (drag test and adding presets can be flaky) def main(): """Run all browser tests.""" print("=" * 60) print("LED Controller Browser Tests") print(f"Testing against: {BASE_URL}") print("=" * 60) # On Pi OS Lite there is no browser by default; skip with exit 0 instead of failing browser = BrowserTest(headless=True) if not browser.setup(): print("\nSkipped (Pi OS Lite / no browser). Install chromium-browser and") print("chromium-chromedriver to run browser tests, or run on Pi OS with desktop.") sys.exit(0) browser.teardown() browser = BrowserTest(headless=False) # Set to True for headless mode results = [] # Run browser tests results.append(("Browser Connection", test_browser_connection(browser))) results.append(("Zones UI", test_zones_ui(browser))) results.append(("Profiles UI", test_profiles_ui(browser))) results.append(("Presets UI", test_presets_ui(browser))) results.append(("Color Palette UI", test_color_palette_ui(browser))) results.append(("Preset Drag and Drop", test_preset_drag_and_drop(browser))) # Summary print("\n" + "=" * 60) print("Browser Test Summary") print("=" * 60) all_passed = True for name, passed in results: status = "✓ PASS" if passed else "✗ FAIL" print(f"{status} - {name}") if not passed: all_passed = False print("=" * 60) if all_passed: print("✓ All browser tests passed!") sys.exit(0) else: print("✗ Some browser tests failed") sys.exit(1) if __name__ == "__main__": main()