mirror of
https://github.com/jokob-sk/NetAlertX.git
synced 2026-10-14 20:46:32 -04:00
Add a standalone fixture using the real topology renderer and styles. Check painted pixels in WebKit, Chromium and Firefox because DOM bounds can remain correct when WebKit paints foreignObject content incorrectly. Exercise node clicks, collapse/expand, wheel zoom and drag pan, including assertions that pointer gestures change the SVG transform. Document dependency installation and execution separately from pytest. The regression fails with the original frontend and passes with the fix.
437 lines
12 KiB
Markdown
437 lines
12 KiB
Markdown
# UI Testing Guide
|
|
|
|
## Overview
|
|
This directory contains Selenium-based UI tests for NetAlertX. Tests validate both API endpoints and browser functionality.
|
|
|
|
## Test Types
|
|
|
|
### 1. Page Load Tests (Basic)
|
|
```python
|
|
def test_page_loads(driver):
|
|
"""Test: Page loads without errors"""
|
|
driver.get(f"{BASE_URL}/page.php")
|
|
time.sleep(2)
|
|
assert "fatal" not in driver.page_source.lower()
|
|
```
|
|
|
|
### 2. Element Presence Tests
|
|
```python
|
|
def test_button_present(driver):
|
|
"""Test: Button exists on page"""
|
|
driver.get(f"{BASE_URL}/page.php")
|
|
time.sleep(2)
|
|
button = driver.find_element(By.ID, "myButton")
|
|
assert button.is_displayed(), "Button should be visible"
|
|
```
|
|
|
|
### 3. Functional Tests (Button Clicks)
|
|
```python
|
|
def test_button_click_works(driver):
|
|
"""Test: Button click executes action"""
|
|
driver.get(f"{BASE_URL}/page.php")
|
|
time.sleep(2)
|
|
|
|
# Find button
|
|
button = driver.find_element(By.ID, "myButton")
|
|
|
|
# Verify it's clickable
|
|
assert button.is_enabled(), "Button should be enabled"
|
|
|
|
# Click it
|
|
button.click()
|
|
|
|
# Wait for result
|
|
time.sleep(1)
|
|
|
|
# Verify action happened (check for success message, modal, etc.)
|
|
success_msg = driver.find_elements(By.CSS_SELECTOR, ".alert-success")
|
|
assert len(success_msg) > 0, "Success message should appear"
|
|
```
|
|
|
|
### 4. Form Input Tests
|
|
```python
|
|
def test_form_submission(driver):
|
|
"""Test: Form accepts input and submits"""
|
|
driver.get(f"{BASE_URL}/form.php")
|
|
time.sleep(2)
|
|
|
|
# Fill form fields
|
|
name_field = driver.find_element(By.ID, "deviceName")
|
|
name_field.clear()
|
|
name_field.send_keys("Test Device")
|
|
|
|
# Select dropdown
|
|
from selenium.webdriver.support.select import Select
|
|
dropdown = Select(driver.find_element(By.ID, "deviceType"))
|
|
dropdown.select_by_visible_text("Router")
|
|
|
|
# Click submit
|
|
submit_btn = driver.find_element(By.ID, "btnSave")
|
|
submit_btn.click()
|
|
|
|
time.sleep(2)
|
|
|
|
# Verify submission
|
|
assert "success" in driver.page_source.lower()
|
|
```
|
|
|
|
### 5. AJAX/Fetch Tests
|
|
```python
|
|
def test_ajax_request(driver):
|
|
"""Test: AJAX request completes successfully"""
|
|
driver.get(f"{BASE_URL}/page.php")
|
|
time.sleep(2)
|
|
|
|
# Click button that triggers AJAX
|
|
ajax_btn = driver.find_element(By.ID, "loadData")
|
|
ajax_btn.click()
|
|
|
|
# Wait for AJAX to complete (look for loading indicator to disappear)
|
|
WebDriverWait(driver, 10).until(
|
|
EC.invisibility_of_element((By.CLASS_NAME, "spinner"))
|
|
)
|
|
|
|
# Verify data loaded
|
|
data_table = driver.find_element(By.ID, "dataTable")
|
|
assert len(data_table.text) > 0, "Data should be loaded"
|
|
```
|
|
|
|
### 6. API Endpoint Tests
|
|
```python
|
|
def test_api_endpoint(api_token):
|
|
"""Test: API endpoint returns correct data"""
|
|
response = api_get("/devices", api_token)
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data["success"] == True
|
|
assert len(data["results"]) > 0
|
|
```
|
|
|
|
### 7. Multi-Step Workflow Tests
|
|
```python
|
|
def test_device_edit_workflow(driver):
|
|
"""Test: Complete device edit workflow"""
|
|
# Step 1: Navigate to devices page
|
|
driver.get(f"{BASE_URL}/devices.php")
|
|
time.sleep(2)
|
|
|
|
# Step 2: Click first device
|
|
first_device = driver.find_element(By.CSS_SELECTOR, "table tbody tr:first-child a")
|
|
first_device.click()
|
|
time.sleep(2)
|
|
|
|
# Step 3: Edit device name
|
|
name_field = driver.find_element(By.ID, "deviceName")
|
|
original_name = name_field.get_attribute("value")
|
|
name_field.clear()
|
|
name_field.send_keys("Updated Name")
|
|
|
|
# Step 4: Save changes
|
|
save_btn = driver.find_element(By.ID, "btnSave")
|
|
save_btn.click()
|
|
time.sleep(2)
|
|
|
|
# Step 5: Verify save succeeded
|
|
assert "success" in driver.page_source.lower()
|
|
|
|
# Step 6: Restore original name
|
|
name_field = driver.find_element(By.ID, "deviceName")
|
|
name_field.clear()
|
|
name_field.send_keys(original_name)
|
|
save_btn = driver.find_element(By.ID, "btnSave")
|
|
save_btn.click()
|
|
```
|
|
|
|
## Common Selenium Patterns
|
|
|
|
### Finding Elements
|
|
```python
|
|
# By ID (fastest, most reliable)
|
|
element = driver.find_element(By.ID, "myButton")
|
|
|
|
# By CSS selector (flexible)
|
|
element = driver.find_element(By.CSS_SELECTOR, ".btn-primary")
|
|
elements = driver.find_elements(By.CSS_SELECTOR, "table tr")
|
|
|
|
# By XPath (powerful but slow)
|
|
element = driver.find_element(By.XPATH, "//button[@type='submit']")
|
|
|
|
# By link text
|
|
element = driver.find_element(By.LINK_TEXT, "Edit Device")
|
|
|
|
# By partial link text
|
|
element = driver.find_element(By.PARTIAL_LINK_TEXT, "Edit")
|
|
|
|
# Check if element exists (don't fail if missing)
|
|
elements = driver.find_elements(By.ID, "optional_element")
|
|
if len(elements) > 0:
|
|
elements[0].click()
|
|
```
|
|
|
|
### Waiting for Elements
|
|
```python
|
|
from selenium.webdriver.support.ui import WebDriverWait
|
|
from selenium.webdriver.support import expected_conditions as EC
|
|
|
|
# Wait up to 10 seconds for element to be present
|
|
element = WebDriverWait(driver, 10).until(
|
|
EC.presence_of_element_located((By.ID, "myElement"))
|
|
)
|
|
|
|
# Wait for element to be clickable
|
|
element = WebDriverWait(driver, 10).until(
|
|
EC.element_to_be_clickable((By.ID, "myButton"))
|
|
)
|
|
|
|
# Wait for element to disappear
|
|
WebDriverWait(driver, 10).until(
|
|
EC.invisibility_of_element((By.CLASS_NAME, "loading-spinner"))
|
|
)
|
|
|
|
# Wait for text to be present
|
|
WebDriverWait(driver, 10).until(
|
|
EC.text_to_be_present_in_element((By.ID, "status"), "Complete")
|
|
)
|
|
```
|
|
|
|
### Interacting with Elements
|
|
```python
|
|
# Click
|
|
button.click()
|
|
|
|
# Type text
|
|
input_field.send_keys("Hello World")
|
|
|
|
# Clear and type
|
|
input_field.clear()
|
|
input_field.send_keys("New Text")
|
|
|
|
# Get text
|
|
text = element.text
|
|
|
|
# Get attribute
|
|
value = input_field.get_attribute("value")
|
|
href = link.get_attribute("href")
|
|
|
|
# Check visibility
|
|
if element.is_displayed():
|
|
element.click()
|
|
|
|
# Check if enabled
|
|
if button.is_enabled():
|
|
button.click()
|
|
|
|
# Check if selected (checkboxes/radio)
|
|
if checkbox.is_selected():
|
|
checkbox.click() # Uncheck it
|
|
```
|
|
|
|
### Handling Alerts/Modals
|
|
```python
|
|
# Wait for alert
|
|
WebDriverWait(driver, 5).until(EC.alert_is_present())
|
|
|
|
# Accept alert (click OK)
|
|
alert = driver.switch_to.alert
|
|
alert.accept()
|
|
|
|
# Dismiss alert (click Cancel)
|
|
alert.dismiss()
|
|
|
|
# Get alert text
|
|
alert_text = alert.text
|
|
|
|
# Bootstrap modals
|
|
modal = driver.find_element(By.ID, "myModal")
|
|
assert modal.is_displayed(), "Modal should be visible"
|
|
```
|
|
|
|
### Handling Dropdowns
|
|
```python
|
|
from selenium.webdriver.support.select import Select
|
|
|
|
# Select by visible text
|
|
dropdown = Select(driver.find_element(By.ID, "myDropdown"))
|
|
dropdown.select_by_visible_text("Option 1")
|
|
|
|
# Select by value
|
|
dropdown.select_by_value("option1")
|
|
|
|
# Select by index
|
|
dropdown.select_by_index(0)
|
|
|
|
# Get selected option
|
|
selected = dropdown.first_selected_option
|
|
print(selected.text)
|
|
|
|
# Get all options
|
|
all_options = dropdown.options
|
|
for option in all_options:
|
|
print(option.text)
|
|
```
|
|
|
|
## Running Tests
|
|
|
|
### Run all tests
|
|
```bash
|
|
pytest test/ui/
|
|
```
|
|
|
|
### Run specific test file
|
|
```bash
|
|
pytest test/ui/test_ui_dashboard.py
|
|
```
|
|
|
|
### Run specific test
|
|
```bash
|
|
pytest test/ui/test_ui_dashboard.py::test_dashboard_loads
|
|
```
|
|
|
|
### Run with verbose output
|
|
```bash
|
|
pytest test/ui/ -v
|
|
```
|
|
|
|
### Run with very verbose output (show page source on failures)
|
|
```bash
|
|
pytest test/ui/ -vv
|
|
```
|
|
|
|
### Run and stop on first failure
|
|
```bash
|
|
pytest test/ui/ -x
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
1. **Use explicit waits** instead of `time.sleep()` when possible
|
|
2. **Test the behavior, not implementation** - focus on what users see/do
|
|
3. **Keep tests independent** - each test should work alone
|
|
4. **Clean up after tests** - reset any changes made during testing
|
|
5. **Use descriptive test names** - `test_export_csv_button_downloads_file` not `test_1`
|
|
6. **Add docstrings** - explain what each test validates
|
|
7. **Test error cases** - not just happy paths
|
|
8. **Use CSS selectors over XPath** when possible (faster, more readable)
|
|
9. **Group related tests** - keep page-specific tests in same file
|
|
10. **Avoid hardcoded waits** - use WebDriverWait with conditions
|
|
|
|
## Debugging Failed Tests
|
|
|
|
### Take screenshot on failure
|
|
```python
|
|
try:
|
|
assert something
|
|
except AssertionError:
|
|
driver.save_screenshot("/tmp/test_failure.png")
|
|
raise
|
|
```
|
|
|
|
### Print page source
|
|
```python
|
|
print(driver.page_source)
|
|
```
|
|
|
|
### Print current URL
|
|
```python
|
|
print(driver.current_url)
|
|
```
|
|
|
|
### Check console logs (JavaScript errors)
|
|
```python
|
|
logs = driver.get_log('browser')
|
|
for log in logs:
|
|
print(log)
|
|
```
|
|
|
|
### Run in non-headless mode (see what's happening)
|
|
Modify `test_helpers.py`:
|
|
```python
|
|
# Comment out this line:
|
|
# chrome_options.add_argument('--headless=new')
|
|
```
|
|
|
|
## Example: Complete Functional Test
|
|
|
|
```python
|
|
def test_device_delete_workflow(driver, api_token):
|
|
"""Test: Complete device deletion workflow"""
|
|
# Setup: Create a test device via API
|
|
import requests
|
|
headers = {"Authorization": f"Bearer {api_token}"}
|
|
test_device = {
|
|
"mac": "00:11:22:33:44:55",
|
|
"name": "Test Device",
|
|
"type": "Other"
|
|
}
|
|
create_response = requests.post(
|
|
f"{API_BASE_URL}/device",
|
|
headers=headers,
|
|
json=test_device
|
|
)
|
|
assert create_response.status_code == 200
|
|
|
|
# Navigate to devices page
|
|
driver.get(f"{BASE_URL}/devices.php")
|
|
time.sleep(2)
|
|
|
|
# Search for the test device
|
|
search_box = driver.find_element(By.CSS_SELECTOR, ".dataTables_filter input")
|
|
search_box.send_keys("Test Device")
|
|
time.sleep(1)
|
|
|
|
# Click delete button for the device
|
|
delete_btn = driver.find_element(By.CSS_SELECTOR, "button.btn-delete")
|
|
delete_btn.click()
|
|
|
|
# Confirm deletion in modal
|
|
time.sleep(0.5)
|
|
confirm_btn = driver.find_element(By.ID, "btnConfirmDelete")
|
|
confirm_btn.click()
|
|
|
|
# Wait for success message
|
|
WebDriverWait(driver, 10).until(
|
|
EC.presence_of_element_located((By.CLASS_NAME, "alert-success"))
|
|
)
|
|
|
|
# Verify device is gone via API
|
|
verify_response = requests.get(
|
|
f"{API_BASE_URL}/device/00:11:22:33:44:55",
|
|
headers=headers
|
|
)
|
|
assert verify_response.status_code == 404, "Device should be deleted"
|
|
```
|
|
|
|
## Settings Form Submission Tests
|
|
|
|
The `test_ui_settings.py` file includes tests for validating the settings save workflow via PHP form submission:
|
|
|
|
### `test_save_settings_with_form_submission(driver)`
|
|
Tests that the settings form submits correctly to `php/server/util.php` with `function: 'savesettings'`. Validates that the config file is generated correctly and no errors appear on save.
|
|
|
|
### `test_save_settings_no_loss_of_data(driver)`
|
|
Verifies that all settings are preserved when saved (no data loss during save operation).
|
|
|
|
**Key Coverage**: Form submission flow → PHP `saveSettings()` → Config file generation with Python-compatible formatting
|
|
# Network topology rendering in WebKit, Chromium and Firefox
|
|
|
|
The standalone regression uses the real Treeviz bundle, network renderer and styles
|
|
with four fixture devices. No running NetAlertX backend or database is required.
|
|
It checks painted pixels, node clicks, collapse/expand, wheel zoom and drag pan.
|
|
Pixel checks are necessary because WebKit may return correct DOM bounds while
|
|
painting positioned or translucent HTML at the SVG origin instead.
|
|
|
|
Run from the repository root (Node.js required):
|
|
|
|
```sh
|
|
npm install --prefix /tmp/netalertx-browser-tests playwright pngjs
|
|
/tmp/netalertx-browser-tests/node_modules/.bin/playwright install --with-deps webkit chromium firefox
|
|
NODE_PATH=/tmp/netalertx-browser-tests/node_modules node test/ui/test_network_topology.cjs
|
|
```
|
|
|
|
Screenshots are written to `/tmp/netalertx-topology`, or to the directory specified
|
|
by `NETALERTX_TOPOLOGY_ARTIFACTS`. This regression runs separately from pytest.
|
|
Also verify the fixture in native Safari when a Mac is available; Playwright
|
|
WebKit is useful regression coverage but is not the Safari application.
|
|
|