test(browser): fixture, env host and pacing, safer colour inputs

Made-with: Cursor
This commit is contained in:
2026-04-12 02:34:46 +12:00
parent edec5ff460
commit a9edda38ef

View File

@@ -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 <input type="color"> 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: