diff --git a/tests/test_browser.py b/tests/test_browser.py index 123ee70..f0c0a34 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -1,22 +1,37 @@ #!/usr/bin/env python3 """ Browser automation tests using Selenium. -Tests run against the device at 192.168.4.1 in an actual browser. +Tests run against the device in an actual browser. Target host defaults to +``192.168.4.1``; 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 sys import time import requests from typing import Optional, List @@ -28,10 +43,46 @@ 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 +from selenium.common.exceptions import ( + TimeoutException, + NoSuchElementException, + ElementNotInteractableException, +) + +_DEFAULT_DEVICE_HOST = "192.168.4.1" + + +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 = "http://192.168.4.1" +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.""" @@ -57,7 +108,7 @@ class BrowserTest: opts.add_argument('--disable-gpu') opts.add_argument('--window-size=1920,1080') self.driver = webdriver.Chrome(options=opts) - self.driver.implicitly_wait(5) + self.driver.implicitly_wait(_implicit_wait_s()) print("✓ Browser started (Chrome)") return True except Exception as e: @@ -68,7 +119,7 @@ class BrowserTest: if self.headless: opts.add_argument('--headless') self.driver = webdriver.Firefox(options=opts) - self.driver.implicitly_wait(5) + self.driver.implicitly_wait(_implicit_wait_s()) print("✓ Browser started (Firefox)") return True except Exception as e: @@ -92,7 +143,7 @@ class BrowserTest: url = f"{self.base_url}{path}" try: self.driver.get(url) - time.sleep(1) # Wait for page load + _browser_sleep(1) # Wait for page load return True except Exception as e: print(f"✗ Failed to navigate to {url}: {e}") @@ -107,12 +158,19 @@ class BrowserTest: 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) @@ -122,7 +180,7 @@ class BrowserTest: element.click() except Exception: self.driver.execute_script("arguments[0].click();", element) - time.sleep(0.5) # Wait for action + _browser_sleep(0.5) # Wait for action return True return False except Exception as e: @@ -137,7 +195,7 @@ class BrowserTest: alert.accept() else: alert.dismiss() - time.sleep(0.3) + _browser_sleep(0.3) return True except TimeoutException: return False @@ -243,8 +301,34 @@ class BrowserTest: 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() - element.send_keys(text) + 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: @@ -273,7 +357,7 @@ class BrowserTest: try: actions = ActionChains(self.driver) actions.drag_and_drop(source_element, target_element).perform() - time.sleep(0.5) # Wait for drop to complete + _browser_sleep(0.5) # Wait for drop to complete return True except Exception as e: print(f"✗ Drag and drop failed: {e}") @@ -284,12 +368,18 @@ class BrowserTest: try: actions = ActionChains(self.driver) actions.drag_and_drop_by_offset(element, x_offset, y_offset).perform() - time.sleep(0.5) + _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...") @@ -333,7 +423,7 @@ def test_zones_ui(browser: BrowserTest) -> bool: if browser.click_element(By.ID, 'zones-btn'): print("✓ Clicked Zones button") # Wait for modal to appear - time.sleep(0.5) + _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") @@ -353,7 +443,7 @@ def test_zones_ui(browser: BrowserTest) -> bool: # Click create button if browser.click_element(By.ID, 'create-zone-btn'): print(" ✓ Clicked create button") - time.sleep(1) # Wait for creation + _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: @@ -386,9 +476,9 @@ def test_zones_ui(browser: BrowserTest) -> bool: try: # First, close and reopen modal to refresh browser.click_element(By.ID, 'zones-close-btn') - time.sleep(0.5) + _browser_sleep(0.5) browser.click_element(By.ID, 'zones-btn') - time.sleep(0.5) + _browser_sleep(0.5) # Right-click the row corresponding to 'Browser Test Zone' try: @@ -402,7 +492,7 @@ def test_zones_ui(browser: BrowserTest) -> bool: if tab_row: actions = ActionChains(browser.driver) actions.context_click(tab_row).perform() - time.sleep(0.5) + _browser_sleep(0.5) # Check if edit modal opened edit_modal = browser.wait_for_element(By.ID, 'edit-zone-modal') @@ -415,7 +505,7 @@ def test_zones_ui(browser: BrowserTest) -> bool: edit_form = browser.wait_for_element(By.ID, 'edit-zone-form') if edit_form: browser.driver.execute_script("arguments[0].submit();", edit_form) - time.sleep(1) # Wait for update + _browser_sleep(1) # Wait for update print("✓ Submitted edit form") passed += 1 else: @@ -476,7 +566,7 @@ def test_profiles_ui(browser: BrowserTest) -> bool: total += 1 if browser.click_element(By.ID, 'profiles-btn'): print("✓ Clicked Profiles button") - time.sleep(0.5) + _browser_sleep(0.5) modal = browser.wait_for_element(By.ID, 'profiles-modal') if modal: print("✓ Profiles modal opened") @@ -491,7 +581,7 @@ def test_profiles_ui(browser: BrowserTest) -> bool: print(" ✓ Filled profile name") if browser.click_element(By.ID, 'create-profile-btn'): print(" ✓ Clicked create button") - time.sleep(1) + _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: @@ -535,7 +625,7 @@ def test_mobile_tab_presets_two_columns(): 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() - time.sleep(1) + _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" @@ -577,7 +667,7 @@ def test_presets_ui(browser: BrowserTest) -> bool: total += 1 if browser.click_element(By.ID, 'presets-btn'): print("✓ Clicked Presets button") - time.sleep(0.5) + _browser_sleep(0.5) modal = browser.wait_for_element(By.ID, 'presets-modal') if modal: print("✓ Presets modal opened") @@ -590,7 +680,7 @@ def test_presets_ui(browser: BrowserTest) -> bool: try: if browser.click_element(By.ID, 'preset-add-btn'): print(" ✓ Clicked Add Preset button") - time.sleep(0.5) + _browser_sleep(0.5) editor_modal = browser.wait_for_element(By.ID, 'preset-editor-modal') if editor_modal: print("✓ Preset editor modal opened") @@ -628,7 +718,7 @@ def test_presets_ui(browser: BrowserTest) -> bool: # Save preset if browser.click_element(By.ID, 'preset-save-btn'): print(" ✓ Clicked save button") - time.sleep(1) + _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: @@ -645,7 +735,7 @@ def test_presets_ui(browser: BrowserTest) -> bool: # Close editor modal browser.click_element(By.ID, 'preset-editor-close-btn', use_js=True) - time.sleep(0.5) + _browser_sleep(0.5) # Close presets modal browser.click_element(By.ID, 'presets-close-btn', use_js=True) @@ -683,7 +773,7 @@ def test_color_palette_ui(browser: BrowserTest) -> bool: total += 1 if browser.click_element(By.ID, 'color-palette-btn'): print("✓ Clicked Color Palette button") - time.sleep(0.5) + _browser_sleep(0.5) modal = browser.wait_for_element(By.ID, 'color-palette-modal') if modal: print("✓ Color palette modal opened") @@ -703,7 +793,7 @@ def test_color_palette_ui(browser: BrowserTest) -> bool: # Click add color button if browser.click_element(By.ID, 'palette-add-color-btn'): print(" ✓ Clicked Add Color button") - time.sleep(0.5) + _browser_sleep(0.5) # Handle alert if color already exists browser.handle_alert(accept=True, timeout=1) # Check if color appears in palette @@ -735,7 +825,7 @@ def test_color_palette_ui(browser: BrowserTest) -> bool: target = color_swatches[1] if browser.drag_and_drop(source, target): print("✓ Dragged color to reorder") - time.sleep(0.5) + _browser_sleep(0.5) passed += 1 else: print("✗ Drag and drop failed") @@ -781,7 +871,7 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool: # Test 2: Open zones modal and create/select a zone total += 1 browser.click_element(By.ID, 'zones-btn') - time.sleep(0.5) + _browser_sleep(0.5) # Check if we have zones, if not create one tabs_list = browser.wait_for_element(By.ID, 'zones-list-modal') @@ -789,13 +879,13 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool: # Create a zone browser.fill_input(By.ID, 'new-zone-name', 'Drag Test Zone') browser.click_element(By.ID, 'create-zone-btn') - time.sleep(1) + _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() - time.sleep(1) + _browser_sleep(1) print("✓ Selected a zone") passed += 1 else: @@ -805,42 +895,42 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool: return False browser.click_element(By.ID, 'zones-close-btn', use_js=True) - time.sleep(0.5) + _browser_sleep(0.5) # Test 3: Open presets modal and create presets total += 1 browser.click_element(By.ID, 'presets-btn') - time.sleep(0.5) + _browser_sleep(0.5) # Create first preset browser.click_element(By.ID, 'preset-add-btn') - time.sleep(0.5) + _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') - time.sleep(1) + _browser_sleep(1) # Create second preset browser.click_element(By.ID, 'preset-add-btn') - time.sleep(0.5) + _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') - time.sleep(1) + _browser_sleep(1) # Create third preset browser.click_element(By.ID, 'preset-add-btn') - time.sleep(0.5) + _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') - time.sleep(1) + _browser_sleep(1) browser.click_element(By.ID, 'presets-close-btn', use_js=True) - time.sleep(0.5) + _browser_sleep(0.5) print("✓ Created 3 presets for drag test") passed += 1 @@ -858,24 +948,24 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool: "if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }", zone_id ) - time.sleep(1) + _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]) - time.sleep(1.5) + _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]) - time.sleep(1.5) + _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]) - time.sleep(1.5) + _browser_sleep(1.5) browser.handle_alert(accept=True, timeout=1) print(" ✓ Added 1 preset to zone") passed += 1 @@ -894,16 +984,16 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool: # 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: - time.sleep(1) # Wait for presets to render + _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() - time.sleep(0.5) + _browser_sleep(0.5) # Find draggable preset elements - wait a bit more for rendering - time.sleep(1) + _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") @@ -919,7 +1009,7 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool: # Use ActionChains for drag and drop actions = ActionChains(browser.driver) actions.click_and_hold(source).move_to_element(target).release().perform() - time.sleep(1) # Wait for reorder to complete + _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') @@ -945,18 +1035,18 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool: "if (window.tabsManager && window.tabsManager.openEditTabModal) { window.tabsManager.openEditTabModal(arguments[0], null); }", zone_id ) - time.sleep(1) + _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]) - time.sleep(1.5) + _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 - time.sleep(1) + _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...") @@ -964,7 +1054,7 @@ def test_preset_drag_and_drop(browser: BrowserTest) -> bool: target = draggable_presets[1] actions = ActionChains(browser.driver) actions.click_and_hold(source).move_to_element(target).release().perform() - time.sleep(1) + _browser_sleep(1) print("✓ Performed drag and drop") passed += 1 else: