Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/developer/test-utilities.rst
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,8 @@ Example usage:
# the assertion above will fail but this line will be executed
print("This will be printed anyway.")

``openwisp_utils.test_selenium_mixins.SeleniumTestMixin``
---------------------------------------------------------
``openwisp_utils.selenium.SeleniumTestMixin``
---------------------------------------------

This mixin provides the core Selenium setup logic and reusable test
methods that must be used across all OpenWISP modules based on Django to
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
(function () {
const script = document.createElement("script");
script.textContent = `
(function() {
if (window._console_logs) return; // Prevent multiple injections
window._console_logs = [];
function mapLogLevel(method) {
const levelMapping = {
log: "INFO",
info: "INFO",
debug: "DEBUG",
warn: "WARNING",
error: "SEVERE",
trace: "DEBUG",
assert: "SEVERE"
};
return levelMapping[method] || "INFO"; // Default to INFO
}
function captureConsole(method) {
const original = console[method];
console[method] = function(...args) {
const logLevel = mapLogLevel(method);
window._console_logs.push({
level: logLevel,
message: args.map(arg => (typeof arg === "object" ? JSON.stringify(arg) : String(arg))).join(" ")
});
original.apply(console, args);
};
}
const methods = ["log", "info", "debug", "warn", "error", "trace", "assert"];
methods.forEach(captureConsole);
})();
`;

// Ensure script runs in page context
const head = document.head || document.documentElement;
head.appendChild(script);
})();
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"manifest_version": 2,
"name": "Console Capture Extension",
"version": "1.0",
"description": "Captures console logs before any script runs.",
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_start"
}
]
}
206 changes: 206 additions & 0 deletions openwisp_utils/selenium.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import os

from selenium import webdriver
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
from selenium.webdriver.firefox.options import Options
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait


class SeleniumTestMixin:
"""A base Mixin Class for Selenium Browser Tests.

Provides common initialization logic and helper methods.
"""

admin_username = 'admin'
admin_password = 'password'
browser = 'firefox'

@classmethod
def setUpClass(cls):
super().setUpClass()
if cls.browser == 'firefox':
cls.web_driver = cls.get_firefox_webdriver()
else:
cls.web_driver = cls.get_chrome_webdriver()

@classmethod
def get_firefox_webdriver(cls):
options = Options()
options.page_load_strategy = 'eager'
if os.environ.get('SELENIUM_HEADLESS', False):
options.add_argument('--headless')
GECKO_BIN = os.environ.get('GECKO_BIN', None)
if GECKO_BIN:
options.binary_location = GECKO_BIN
options.set_preference('network.stricttransportsecurity.preloadlist', False)
# Enable detailed GeckoDriver logging
options.set_capability('moz:firefoxOptions', {'log': {'level': 'trace'}})
# Use software rendering instead of hardware acceleration
options.set_preference('gfx.webrender.force-disabled', True)
options.set_preference('layers.acceleration.disabled', True)
# Increase timeouts
options.set_preference('marionette.defaultPrefs.update.disabled', True)
options.set_preference('dom.max_script_run_time', 30)
# When running Selenium tests with the "--parallel" flag,
# each TestCase class requires its own browser instance.
# If the same "remote-debugging-port" is used for all
# TestCase classes, it leads to failed test cases.
# Therefore, it is necessary to utilize different remote
# debugging ports for each TestCase. To accomplish this,
# we can leverage the randomized live test server port to
# generate a unique port for each browser instance.
marionette_port = cls.server_thread.port + 100
options.set_capability(
'moz:firefoxOptions', {'args': ['--marionette-port', marionette_port]}
)
kwargs = dict(options=options)
# Optional: Store logs in a file
# Pass GECKO_LOG=1 when running tests
GECKO_LOG = os.environ.get('GECKO_LOG', None)
if GECKO_LOG:
kwargs['service'] = webdriver.FirefoxService(log_output='geckodriver.log')
web_driver = webdriver.Firefox(**kwargs)
# Firefox does not support the WebDriver.get_log API. To work around this,
# we inject JavaScript into the page to override window.console within the
# browser's JS runtime. This allows us to capture and retrieve console errors
# directly from the page.
extension_path = os.path.abspath(
os.path.join(
os.path.dirname(__file__),
'firefox-extensions',
'console_capture_extension',
)
)
web_driver.install_addon(extension_path, temporary=True)
return web_driver

@classmethod
def get_chrome_webdriver(cls):
chrome_options = webdriver.ChromeOptions()
chrome_options.page_load_strategy = 'eager'
if os.environ.get('SELENIUM_HEADLESS', False):
chrome_options.add_argument('--headless')
CHROME_BIN = os.environ.get('CHROME_BIN', None)
if CHROME_BIN:
chrome_options.binary_location = CHROME_BIN
chrome_options.add_argument('--window-size=1366,768')
chrome_options.add_argument('--ignore-certificate-errors')
# When running Selenium tests with the "--parallel" flag,
# each TestCase class requires its own browser instance.
# If the same "remote-debugging-port" is used for all
# TestCase classes, it leads to failed test cases.
# Therefore, it is necessary to utilize different remote
# debugging ports for each TestCase. To accomplish this,
# we can leverage the randomized live test server port to
# generate a unique port for each browser instance.
chrome_options.add_argument(
f'--remote-debugging-port={cls.server_thread.port + 100}'
)
capabilities = DesiredCapabilities.CHROME
capabilities['goog:loggingPrefs'] = {'browser': 'ALL'}
chrome_options.set_capability('cloud:options', capabilities)
web_driver = webdriver.Chrome(
options=chrome_options,
)
return web_driver

@classmethod
def tearDownClass(cls):
cls.web_driver.quit()
super().tearDownClass()

def setUp(self):
self.admin = self._create_admin(
username=self.admin_username, password=self.admin_password
)

def open(self, url, driver=None, timeout=5):
"""Opens a URL.

Input Arguments:

- url: URL to open
- driver: selenium driver (default: cls.base_driver).
"""
driver = driver or self.web_driver
driver.get(f'{self.live_server_url}{url}')
self._wait_until_page_ready(driver=driver)

def _wait_until_page_ready(self, timeout=5, driver=None):
driver = driver or self.web_driver
WebDriverWait(driver, timeout).until(
lambda d: d.execute_script('return document.readyState') == 'complete'
)
self.wait_for_visibility(By.CSS_SELECTOR, '#main-content', timeout, driver)

def get_browser_logs(self, driver=None):
driver = driver or self.web_driver
if self.browser == 'firefox':
return driver.execute_script('return window._console_logs')
return driver.get_log('browser')

def login(self, username=None, password=None, driver=None):
"""Log in to the admin dashboard.

Input Arguments:

- username: username to be used for login (default:
cls.admin_username)
- password: password to be used for login (default:
cls.admin_password)
- driver: selenium driver (default: cls.web_driver).
"""
driver = driver or self.web_driver
if not username:
username = self.admin_username
if not password:
password = self.admin_password
driver.get(f'{self.live_server_url}/admin/login/')
self._wait_until_page_ready(driver=driver)
if 'admin/login' in driver.current_url:
driver.find_element(by=By.NAME, value='username').send_keys(username)
driver.find_element(by=By.NAME, value='password').send_keys(password)
driver.find_element(by=By.XPATH, value='//input[@type="submit"]').click()
self._wait_until_page_ready(driver=driver)

def find_element(self, by, value, timeout=2, driver=None, wait_for='visibility'):
driver = driver or self.web_driver
method = f'wait_for_{wait_for}'
getattr(self, method)(by, value, timeout)
return driver.find_element(by=by, value=value)

def find_elements(self, by, value, timeout=2, driver=None, wait_for='visibility'):
driver = driver or self.web_driver
method = f'wait_for_{wait_for}'
getattr(self, method)(by, value, timeout)
return driver.find_elements(by=by, value=value)

def wait_for_visibility(self, by, value, timeout=2, driver=None):
driver = driver or self.web_driver
return self.wait_for(
'visibility_of_element_located', by, value, timeout, driver
)

def wait_for_invisibility(self, by, value, timeout=2, driver=None):
driver = driver or self.web_driver
return self.wait_for(
'invisibility_of_element_located', by, value, timeout, driver
)

def wait_for_presence(self, by, value, timeout=2, driver=None):
driver = driver or self.web_driver
return self.wait_for('presence_of_element_located', by, value, timeout, driver)

def wait_for(self, method, by, value, timeout=2, driver=None):
driver = driver or self.web_driver
try:
return WebDriverWait(driver, timeout).until(
getattr(EC, method)(((by, value)))
)
except TimeoutException as e:
print(self.get_browser_logs(driver))
self.fail(f'{method} of "{value}" failed: {e}')
117 changes: 3 additions & 114 deletions openwisp_utils/test_selenium_mixins.py
Original file line number Diff line number Diff line change
@@ -1,114 +1,3 @@
import os

from selenium import webdriver
from selenium.common.exceptions import TimeoutException
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.options import Options
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait


class SeleniumTestMixin:
"""A base Mixin Class for Selenium Browser Tests.

Provides common initialization logic and helper methods like login()
and open().
"""

admin_username = 'admin'
admin_password = 'password'

@classmethod
def setUpClass(cls):
super().setUpClass()
options = Options()
options.page_load_strategy = 'eager'
if os.environ.get('SELENIUM_HEADLESS', False):
options.add_argument('--headless')
GECKO_BIN = os.environ.get('GECKO_BIN', None)
if GECKO_BIN:
options.binary_location = GECKO_BIN
options.set_preference('network.stricttransportsecurity.preloadlist', False)
# Enable detailed GeckoDriver logging
options.set_capability('moz:firefoxOptions', {'log': {'level': 'trace'}})
# Use software rendering instead of hardware acceleration
options.set_preference('gfx.webrender.force-disabled', True)
options.set_preference('layers.acceleration.disabled', True)
# Increase timeouts
options.set_preference('marionette.defaultPrefs.update.disabled', True)
options.set_preference('dom.max_script_run_time', 30)
kwargs = dict(options=options)
# Optional: Store logs in a file
# Pass GECKO_LOG=1 when running tests
GECKO_LOG = os.environ.get('GECKO_LOG', None)
if GECKO_LOG:
kwargs['service'] = webdriver.FirefoxService(log_output='geckodriver.log')
cls.web_driver = webdriver.Firefox(**kwargs)

@classmethod
def tearDownClass(cls):
cls.web_driver.quit()
super().tearDownClass()

def open(self, url, driver=None, timeout=5):
"""Opens a URL.

Input Arguments:

- url: URL to open
- driver: selenium driver (default: cls.base_driver).
"""
if not driver:
driver = self.web_driver
driver.get(f'{self.live_server_url}{url}')
WebDriverWait(driver, timeout).until(
lambda d: d.execute_script("return document.readyState") == "complete"
)
WebDriverWait(self.web_driver, timeout).until(
EC.presence_of_element_located((By.CSS_SELECTOR, '#main-content'))
)

def login(self, username=None, password=None, driver=None):
"""Log in to the admin dashboard.

Input Arguments:

- username: username to be used for login (default:
cls.admin_username)
- password: password to be used for login (default:
cls.admin_password)
- driver: selenium driver (default: cls.web_driver).
"""
if not driver:
driver = self.web_driver
if not username:
username = self.admin_username
if not password:
password = self.admin_password
driver.get(f'{self.live_server_url}/admin/login/')
if 'admin/login' in driver.current_url:
driver.find_element(by=By.NAME, value='username').send_keys(username)
driver.find_element(by=By.NAME, value='password').send_keys(password)
driver.find_element(by=By.XPATH, value='//input[@type="submit"]').click()

def find_element(self, by, value, timeout=2, wait_for='visibility'):
method = f'wait_for_{wait_for}'
getattr(self, method)(by, value, timeout)
return self.web_driver.find_element(by=by, value=value)

def wait_for_visibility(self, by, value, timeout=2):
return self.wait_for('visibility_of_element_located', by, value)

def wait_for_invisibility(self, by, value, timeout=2):
return self.wait_for('invisibility_of_element_located', by, value)

def wait_for_presence(self, by, value, timeout=2):
return self.wait_for('presence_of_element_located', by, value)

def wait_for(self, method, by, value, timeout=2):
try:
return WebDriverWait(self.web_driver, timeout).until(
getattr(EC, method)(((by, value)))
)
except TimeoutException as e:
self.fail(f'{method} of "{value}" failed: {e}')
# For backward compatibility
# We can remove this once all modules have been updated
from .selenium import SeleniumTestMixin # noqa
Loading
Loading