diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40a720396..b4078958b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,20 +15,6 @@ jobs: name: Python==${{ matrix.python-version }} | ${{ matrix.django-version }} runs-on: ubuntu-22.04 - services: - redis: - image: redis - ports: - - 6379:6379 - postgres: - image: postgis/postgis:13-3.3-alpine - env: - POSTGRES_PASSWORD: openwisp2 - POSTGRES_USER: openwisp2 - POSTGRES_DB: openwisp2 - ports: - - 5432:5432 - strategy: fail-fast: false matrix: @@ -46,6 +32,19 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} + - name: Cache APT packages + uses: actions/cache@v4 + with: + path: /var/cache/apt/archives + key: apt-${{ runner.os }}-${{ hashFiles('.github/workflows/ci.yml') }} + restore-keys: | + apt-${{ runner.os }}- + + - name: Disable man page auto-update + run: | + echo 'set man-db/auto-update false' | sudo debconf-communicate >/dev/null + sudo dpkg-reconfigure man-db + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: @@ -54,19 +53,6 @@ jobs: cache-dependency-path: | **/requirements*.txt - - uses: browser-actions/setup-chrome@v1 - # Using a fixed version, see here for more information on why: - # https://github.com/openwisp/openwisp-controller/issues/902#issuecomment-2266219715 - # TODO: find a solution to allow using recent versions - with: - chrome-version: 125 - install-chromedriver: true - id: setup-chrome - - - run: | - ${{ steps.setup-chrome.outputs.chrome-path }} --version - chromedriver --version - - name: Install Dependencies id: deps run: | @@ -80,6 +66,10 @@ jobs: pip install -U -e . pip install ${{ matrix.django-version }} + - name: Start postgres and redis + if: ${{ !cancelled() && steps.deps.conclusion == 'success' }} + run: docker compose up -d postgres redis + - name: QA checks run: ./run-qa-checks @@ -87,16 +77,17 @@ jobs: if: ${{ !cancelled() && steps.deps.conclusion == 'success' }} run: | coverage run runtests.py --parallel - # the following command runs tests with Postgres/PostGIS but - # only for specific test cases which are tagged with "db_tests" - POSTGRESQL=1 coverage run runtests.py --parallel --keepdb # tests the extension capability - SAMPLE_APP=1 coverage run ./runtests.py --parallel --keepdb + SAMPLE_APP=1 coverage run ./runtests.py --parallel --keepdb --exclude-tag=selenium_tests coverage combine coverage xml env: SELENIUM_HEADLESS: 1 - CHROME_BIN: ${{ steps.setup-chrome.outputs.chrome-path }} + GECKO_LOG: 1 + + - name: Show gecko web driver log on failures + if: ${{ failure() }} + run: cat geckodriver.log - name: Upload Coverage if: ${{ success() }} diff --git a/docs/developer/installation.rst b/docs/developer/installation.rst index 0c2d17364..ef55485ac 100644 --- a/docs/developer/installation.rst +++ b/docs/developer/installation.rst @@ -41,7 +41,7 @@ Launch Redis and PostgreSQL: .. code-block:: shell - docker-compose up -d redis postgres + docker compose up -d redis postgres Setup and activate a virtual-environment (we'll be using `virtualenv `_): @@ -92,7 +92,8 @@ Launch development server: You can access the admin interface at ``http://127.0.0.1:8000/admin/``. -Run tests with: +Run tests with (make sure you have the :ref:`selenium dependencies +` installed locally first): .. code-block:: shell @@ -106,9 +107,6 @@ specific tests as follows: .. code-block:: shell - # Run database tests against PostgreSQL backend - POSTGRESQL=1 ./runtests.py --parallel - # Run only specific selenium tests classes cd tests/ DJANGO_SETTINGS_MODULE=openwisp2.postgresql_settings ./manage.py test openwisp_controller.config.tests.test_selenium.TestDeviceAdmin @@ -162,13 +160,13 @@ Build from the Dockerfile: .. code-block:: shell - docker-compose build + docker compose build Run the docker container: .. code-block:: shell - docker-compose up + docker compose up Troubleshooting Steps for Common Installation Issues ---------------------------------------------------- diff --git a/openwisp_controller/config/static/config/css/device-delete-confirmation.css b/openwisp_controller/config/static/config/css/device-delete-confirmation.css index 52065d658..33910cc39 100644 --- a/openwisp_controller/config/static/config/css/device-delete-confirmation.css +++ b/openwisp_controller/config/static/config/css/device-delete-confirmation.css @@ -1,6 +1,15 @@ #deactivating-warning .warning p { margin-top: 0px; } +#deactivating-warning .messagelist button { + font-size: 15px; + vertical-align: middle; + line-height: inherit !important; + padding: 0.625rem 1rem; +} +#deactivating-warning .messagelist button + button { + margin-left: 10px; +} #main ul.messagelist li.warning ul li { display: list-item; padding: 0px; diff --git a/openwisp_controller/config/static/config/js/device-delete-confirmation.js b/openwisp_controller/config/static/config/js/device-delete-confirmation.js index 8960d5ea9..23201d800 100644 --- a/openwisp_controller/config/static/config/js/device-delete-confirmation.js +++ b/openwisp_controller/config/static/config/js/device-delete-confirmation.js @@ -3,7 +3,6 @@ (function ($) { $(document).ready(function () { $("#warning-ack").click(function (event) { - event.preventDefault(); $("#deactivating-warning").slideUp("fast"); $("#delete-confirm-container").slideDown("fast"); $('input[name="force_delete"]').val("true"); diff --git a/openwisp_controller/config/templates/admin/config/device/delete_confirmation.html b/openwisp_controller/config/templates/admin/config/device/delete_confirmation.html index 6917f224f..951a865a0 100644 --- a/openwisp_controller/config/templates/admin/config/device/delete_confirmation.html +++ b/openwisp_controller/config/templates/admin/config/device/delete_confirmation.html @@ -46,11 +46,10 @@ but its configuration will remain active. {% endblocktranslate %}

-
- - {% translate 'No, take me back' %} -
+ + + + diff --git a/openwisp_controller/config/templates/admin/config/device/delete_selected_confirmation.html b/openwisp_controller/config/templates/admin/config/device/delete_selected_confirmation.html index e88f8cd33..a46e8b745 100644 --- a/openwisp_controller/config/templates/admin/config/device/delete_selected_confirmation.html +++ b/openwisp_controller/config/templates/admin/config/device/delete_selected_confirmation.html @@ -50,11 +50,10 @@ but their configurations will remain active. {% endblocktranslate %}

-
- - {% translate 'No, take me back' %} -
+ + + + diff --git a/openwisp_controller/config/tests/test_selenium.py b/openwisp_controller/config/tests/test_selenium.py index dfce1b8e2..d9e9657fd 100644 --- a/openwisp_controller/config/tests/test_selenium.py +++ b/openwisp_controller/config/tests/test_selenium.py @@ -1,11 +1,9 @@ +import time + from django.contrib.staticfiles.testing import StaticLiveServerTestCase from django.test import tag from django.urls.base import reverse -from selenium.common.exceptions import ( - StaleElementReferenceException, - TimeoutException, - UnexpectedAlertPresentException, -) +from selenium.common.exceptions import TimeoutException from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.alert import Alert from selenium.webdriver.common.by import By @@ -14,77 +12,43 @@ from selenium.webdriver.support.ui import Select, WebDriverWait from swapper import load_model -from openwisp_utils.test_selenium_mixins import SeleniumTestMixin +from openwisp_utils.tests import SeleniumTestMixin from .utils import CreateConfigTemplateMixin, TestWireguardVpnMixin Device = load_model('config', 'Device') -class SeleniumBaseMixin(CreateConfigTemplateMixin, SeleniumTestMixin): - def setUp(self): - self.admin = self._create_admin( - username=self.admin_username, password=self.admin_password - ) - - @tag('selenium_tests') class TestDeviceAdmin( - SeleniumBaseMixin, + SeleniumTestMixin, + CreateConfigTemplateMixin, StaticLiveServerTestCase, ): - def tearDown(self): - # Accept unsaved changes alert to allow other tests to run - try: - self.web_driver.refresh() - except UnexpectedAlertPresentException: - alert = Alert(self.web_driver) - alert.accept() - else: - try: - WebDriverWait(self.web_driver, 1).until(EC.alert_is_present()) - except TimeoutException: - pass - else: - alert = Alert(self.web_driver) - alert.accept() - self.web_driver.refresh() - WebDriverWait(self.web_driver, 2).until( - EC.visibility_of_element_located((By.XPATH, '//*[@id="site-name"]')) - ) - def test_create_new_device(self): required_template = self._create_template(name='Required', required=True) default_template = self._create_template(name='Default', default=True) org = self._get_org() self.login() self.open(reverse('admin:config_device_add')) - self.web_driver.find_element(by=By.NAME, value='name').send_keys( - '11:22:33:44:55:66' - ) - self.web_driver.find_element( + self.find_element(by=By.NAME, value='name').send_keys('11:22:33:44:55:66') + self.find_element( by=By.CSS_SELECTOR, value='#select2-id_organization-container' ).click() - WebDriverWait(self.web_driver, 2).until( - EC.invisibility_of_element_located( - (By.CSS_SELECTOR, '.select2-results__option.loading-results') - ) + self.wait_for_invisibility( + By.CSS_SELECTOR, '.select2-results__option.loading-results' ) - self.web_driver.find_element( - by=By.CLASS_NAME, value='select2-search__field' - ).send_keys(org.name) - WebDriverWait(self.web_driver, 2).until( - EC.invisibility_of_element_located( - (By.CSS_SELECTOR, '.select2-results__option.loading-results') - ) + self.find_element(by=By.CLASS_NAME, value='select2-search__field').send_keys( + org.name ) - self.web_driver.find_element( - by=By.CLASS_NAME, value='select2-results__option' - ).click() - self.web_driver.find_element(by=By.NAME, value='mac_address').send_keys( + self.wait_for_invisibility( + By.CSS_SELECTOR, '.select2-results__option.loading-results' + ) + self.find_element(by=By.CLASS_NAME, value='select2-results__option').click() + self.find_element(by=By.NAME, value='mac_address').send_keys( '11:22:33:44:55:66' ) - self.web_driver.find_element( + self.find_element( by=By.XPATH, value='//*[@id="config-group"]/fieldset/div[2]/a' ).click() try: @@ -107,10 +71,10 @@ def test_create_new_device(self): ) except TimeoutException: self.fail('Relevant templates logic was not executed') - required_template_element = self.web_driver.find_element( + required_template_element = self.find_element( by=By.XPATH, value=f'//*[@value="{required_template.id}"]' ) - default_template_element = self.web_driver.find_element( + default_template_element = self.find_element( by=By.XPATH, value=f'//*[@value="{default_template.id}"]' ) self.assertEqual(required_template_element.is_enabled(), False) @@ -121,17 +85,10 @@ def test_create_new_device(self): self.web_driver.execute_script( 'document.querySelector("#ow-user-tools").style.display="none"' ) - self.web_driver.find_element(by=By.NAME, value='_save').click() - try: - WebDriverWait(self.web_driver, 5).until( - EC.presence_of_element_located( - (By.CSS_SELECTOR, '.messagelist .success') - ) - ) - except TimeoutException: - self.fail('Device added success message timed out') + self.find_element(by=By.NAME, value='_save').click() + self.wait_for_presence(By.CSS_SELECTOR, '.messagelist .success') self.assertEqual( - self.web_driver.find_elements(by=By.CLASS_NAME, value='success')[0].text, + self.find_elements(by=By.CLASS_NAME, value='success')[0].text, 'The Device “11:22:33:44:55:66” was added successfully.', ) @@ -141,66 +98,19 @@ def test_device_preview_keyboard_shortcuts(self): self.open(reverse('admin:config_device_changelist')) try: self.open(reverse('admin:config_device_change', args=[device.id])) + self.hide_loading_overlay() except TimeoutException: self.fail('Device detail page did not load in time') with self.subTest('press ALT + P and expect overlay to be shown'): actions = ActionChains(self.web_driver) actions.key_down(Keys.ALT).send_keys('p').key_up(Keys.ALT).perform() - try: - WebDriverWait(self.web_driver, 1).until( - EC.visibility_of_element_located( - (By.CSS_SELECTOR, '.djnjc-overlay:not(.loading)') - ) - ) - except TimeoutException: - self.fail('The preview overlay is unexpectedly not visible') + self.wait_for_visibility(By.CSS_SELECTOR, '.djnjc-overlay:not(.loading)') with self.subTest('press ESC to close preview overlay'): actions = ActionChains(self.web_driver) actions.send_keys(Keys.ESCAPE).perform() - try: - WebDriverWait(self.web_driver, 1).until( - EC.invisibility_of_element_located( - (By.CSS_SELECTOR, '.djnjc-overlay:not(.loading)') - ) - ) - except TimeoutException: - self.fail('The preview overlay has not been closed as expected') - - def test_unsaved_changes(self): - self.login() - device = self._create_config(organization=self._get_org()).device - self.open(reverse('admin:config_device_change', args=[device.id])) - with self.subTest('Alert should not be displayed without any change'): - self.web_driver.refresh() - try: - WebDriverWait(self.web_driver, 1).until(EC.alert_is_present()) - except TimeoutException: - pass - else: - self.fail('Unsaved changes alert displayed without any change') - - with self.subTest('Alert should be displayed after making changes'): - # simulate hand gestures - self.web_driver.find_element(by=By.TAG_NAME, value='body').click() - self.web_driver.find_element(by=By.NAME, value='name').click() - # set name - self.web_driver.find_element(by=By.NAME, value='name').send_keys( - 'new.device.name' - ) - # simulate hand gestures - self.web_driver.find_element(by=By.TAG_NAME, value='body').click() - self.web_driver.refresh() - try: - WebDriverWait(self.web_driver, 1).until(EC.alert_is_present()) - except TimeoutException: - for entry in self.web_driver.get_log('browser'): - print(entry) - self.fail('Timed out wating for unsaved changes alert') - else: - alert = Alert(self.web_driver) - alert.accept() + self.wait_for_invisibility(By.CSS_SELECTOR, '.djnjc-overlay:not(.loading)') def test_multiple_organization_templates(self): shared_required_template = self._create_template( @@ -232,37 +142,20 @@ def test_multiple_organization_templates(self): reverse('admin:config_device_change', args=[org1_device.id]) + '#config-group' ) - wait = WebDriverWait(self.web_driver, 2) + self.hide_loading_overlay() # org2 templates should not be visible - try: - wait.until( - EC.invisibility_of_element_located( - (By.XPATH, f'//*[@value="{org2_required_template.id}"]') - ) - ) - wait.until( - EC.invisibility_of_element_located( - (By.XPATH, f'//*[@value="{org2_default_template.id}"]') - ) - ) - except (TimeoutException, StaleElementReferenceException): - self.fail('Template belonging to other organization found') - - # org1 and shared templates should be visible - wait.until( - EC.visibility_of_any_elements_located( - (By.XPATH, f'//*[@value="{org1_required_template.id}"]') - ) + self.wait_for_invisibility( + By.XPATH, f'//*[@value="{org2_required_template.id}"]' ) - wait.until( - EC.visibility_of_any_elements_located( - (By.XPATH, f'//*[@value="{org1_default_template.id}"]') - ) + self.wait_for_invisibility( + By.XPATH, f'//*[@value="{org2_default_template.id}"]' ) - wait.until( - EC.visibility_of_any_elements_located( - (By.XPATH, f'//*[@value="{shared_required_template.id}"]') - ) + + # org1 and shared templates should be visible + self.wait_for_visibility(By.XPATH, f'//*[@value="{org1_required_template.id}"]') + self.wait_for_visibility(By.XPATH, f'//*[@value="{org1_default_template.id}"]') + self.wait_for_visibility( + By.XPATH, f'//*[@value="{shared_required_template.id}"]' ) def test_change_config_backend(self): @@ -273,20 +166,14 @@ def test_change_config_backend(self): self.open( reverse('admin:config_device_change', args=[device.id]) + '#config-group' ) - self.web_driver.find_element(by=By.XPATH, value=f'//*[@value="{template.id}"]') + self.hide_loading_overlay() + self.find_element(by=By.XPATH, value=f'//*[@value="{template.id}"]') # Change config backed to config_backend_select = Select( - self.web_driver.find_element(by=By.NAME, value='config-0-backend') + self.find_element(by=By.NAME, value='config-0-backend') ) config_backend_select.select_by_visible_text('OpenWISP Firmware 1.x') - try: - WebDriverWait(self.web_driver, 1).until( - EC.invisibility_of_element_located( - (By.XPATH, f'//*[@value="{template.id}"]') - ) - ) - except TimeoutException: - self.fail('Template for other config backend found') + self.wait_for_invisibility(By.XPATH, f'//*[@value="{template.id}"]') def test_template_context_variables(self): self._create_template( @@ -300,6 +187,7 @@ def test_template_context_variables(self): self.open( reverse('admin:config_device_change', args=[device.id]) + '#config-group' ) + self.hide_loading_overlay() try: WebDriverWait(self.web_driver, 2).until( EC.text_to_be_present_in_element_value( @@ -312,7 +200,7 @@ def test_template_context_variables(self): ) except TimeoutException: self.fail('Timed out wating for configuration variabled to get loaded') - self.web_driver.find_element( + self.find_element( by=By.XPATH, value='//*[@id="main-content"]/div[2]/a[3]' ).click() try: @@ -333,8 +221,16 @@ def test_force_delete_device_with_deactivating_config(self): self.login() self.open(reverse('admin:config_device_change', args=[device.id])) - self.web_driver.find_elements( - by=By.CSS_SELECTOR, value='input.deletelink[type="submit"]' + self.hide_loading_overlay() + # The webpage has two "submit-row" sections, each containing a "Deactivate" + # button. The first (top) "Deactivate" button is hidden, causing + # `wait_for_visibility` to fail. To avoid this issue, we use + # `wait_for='presence'` instead, ensuring we locat the elements regardless + # of visibility. We then select the last (visible) button and click it. + self.find_elements( + by=By.CSS_SELECTOR, + value='input.deletelink[type="submit"]', + wait_for='presence', )[-1].click() device.refresh_from_db() config.refresh_from_db() @@ -342,19 +238,23 @@ def test_force_delete_device_with_deactivating_config(self): self.assertEqual(config.is_deactivating(), True) self.open(reverse('admin:config_device_change', args=[device.id])) - self.web_driver.find_elements(by=By.CSS_SELECTOR, value='a.deletelink')[ - -1 - ].click() - WebDriverWait(self.web_driver, 5).until( - EC.visibility_of_element_located( - (By.CSS_SELECTOR, '#deactivating-warning .messagelist .warning p') - ) + self.hide_loading_overlay() + # Use `presence` instead of `visibility` for `wait_for`, + # as the same issue described above applies here. + self.find_elements( + by=By.CSS_SELECTOR, value='a.deletelink', wait_for='presence' + )[-1].click() + self.wait_for_visibility( + By.CSS_SELECTOR, '#deactivating-warning .messagelist .warning p' ) - self.web_driver.find_element(by=By.CSS_SELECTOR, value='#warning-ack').click() - delete_confirm = WebDriverWait(self.web_driver, 2).until( - EC.visibility_of_element_located( - (By.CSS_SELECTOR, 'form[method="post"] input[type="submit"]') - ) + self.find_element(by=By.CSS_SELECTOR, value='#warning-ack').click() + # After accepting the warning, wee need to wait for the animation + # to complete before trying to interact with the button, + # otherwise the test may fail due to the button not being fully + # visible or clickable yet. + time.sleep(1) + delete_confirm = self.find_element( + By.CSS_SELECTOR, 'form[method="post"] input[type="submit"]' ) delete_confirm.click() self.assertEqual(Device.objects.count(), 0) @@ -375,66 +275,112 @@ def test_force_delete_multiple_devices_with_deactivating_config(self): self.login() self.open(reverse('admin:config_device_changelist')) - self.web_driver.find_element(by=By.CSS_SELECTOR, value='#action-toggle').click() - select = Select(self.web_driver.find_element(by=By.NAME, value='action')) + self.find_element(by=By.CSS_SELECTOR, value='#action-toggle').click() + select = Select(self.find_element(by=By.NAME, value='action')) select.select_by_value('delete_selected') - self.web_driver.find_element( + self.find_element( by=By.CSS_SELECTOR, value='button[type="submit"][name="index"][value="0"]' ).click() - WebDriverWait(self.web_driver, 5).until( - EC.visibility_of_element_located( - (By.CSS_SELECTOR, '#deactivating-warning .messagelist .warning p') - ) + self.wait_for_visibility( + By.CSS_SELECTOR, '#deactivating-warning .messagelist .warning p' ) - self.web_driver.find_element(by=By.CSS_SELECTOR, value='#warning-ack').click() - delete_confirm = WebDriverWait(self.web_driver, 2).until( - EC.visibility_of_element_located( - (By.CSS_SELECTOR, 'form[method="post"] input[type="submit"]') - ) + self.find_element(by=By.CSS_SELECTOR, value='#warning-ack').click() + # After accepting the warning, wee need to wait for the animation + # to complete before trying to interact with the button, + # otherwise the test may fail due to the button not being fully + # visible or clickable yet. + time.sleep(1) + delete_confirm = self.find_element( + By.CSS_SELECTOR, 'form[method="post"] input[type="submit"]' ) delete_confirm.click() self.assertEqual(Device.objects.count(), 0) -class TestVpnAdmin(SeleniumBaseMixin, TestWireguardVpnMixin, StaticLiveServerTestCase): +@tag('selenium_tests') +class TestDeviceAdminUnsavedChanges( + SeleniumTestMixin, + CreateConfigTemplateMixin, + StaticLiveServerTestCase, +): + browser = 'chrome' + + def test_unsaved_changes(self): + """ + Execute this test using Chrome instead of Firefox. + Firefox automatically accepts the beforeunload alert, which makes it + impossible to test the unsaved changes alert. + """ + self.login() + device = self._create_config(organization=self._get_org()).device + path = reverse('admin:config_device_change', args=[device.id]) + + with self.subTest('Alert should not be displayed without any change'): + self.open(path) + self.hide_loading_overlay() + try: + WebDriverWait(self.web_driver, 1).until(EC.alert_is_present()) + except TimeoutException: + pass + else: + self.fail('Unsaved changes alert displayed without any change') + + with self.subTest('Alert should be displayed after making changes'): + # The WebDriver automatically accepts the + # beforeunload confirmation dialog. To verify the message, + # we log it to the console and check its content. + # + # our own JS code sets e.returnValue when triggered + # so we just need to ensure it's set as expected + self.web_driver.execute_script( + 'django.jQuery(window).on("beforeunload", function(e) {' + ' console.warn(e.returnValue); });' + ) + # simulate hand gestures + self.find_element(by=By.TAG_NAME, value='body').click() + self.find_element(by=By.NAME, value='name').click() + # set name + self.find_element(by=By.NAME, value='name').send_keys('new.device.name') + # simulate hand gestures + self.find_element(by=By.TAG_NAME, value='body').click() + self.web_driver.refresh() + for entry in self.get_browser_logs(): + if ( + entry['level'] == 'WARNING' + and "You haven\'t saved your changes yet!" in entry['message'] + ): + break + else: + self.fail('Unsaved changes code was not executed.') + + +@tag('selenium_tests') +class TestVpnAdmin( + SeleniumTestMixin, + CreateConfigTemplateMixin, + TestWireguardVpnMixin, + StaticLiveServerTestCase, +): def test_vpn_edit(self): self.login() device, vpn, template = self._create_wireguard_vpn_template() self.open(reverse('admin:config_vpn_change', args=[vpn.id])) with self.subTest('Ca and Cert should not be visible'): - el = self.web_driver.find_element(by=By.CLASS_NAME, value='field-ca') - self.assertFalse(el.is_displayed()) - el = self.web_driver.find_element(by=By.CLASS_NAME, value='field-cert') - self.assertFalse(el.is_displayed()) + self.wait_for_invisibility(by=By.CLASS_NAME, value='field-ca') + self.wait_for_invisibility(by=By.CLASS_NAME, value='field-cert') with self.subTest('PrivateKey is shown in configuration preview'): - self.web_driver.find_element( - by=By.CSS_SELECTOR, value='.previewlink' - ).click() - WebDriverWait(self.web_driver, 2).until( - EC.visibility_of_element_located( - (By.CSS_SELECTOR, '.djnjc-preformatted') - ) - ) + self.find_element(by=By.CSS_SELECTOR, value='.previewlink').click() + self.wait_for_visibility(By.CSS_SELECTOR, '.djnjc-preformatted') self.assertIn( f'PrivateKey = {vpn.private_key}', - self.web_driver.find_element( - by=By.CSS_SELECTOR, value='.djnjc-preformatted' - ).text, + self.find_element(by=By.CSS_SELECTOR, value='.djnjc-preformatted').text, ) # Close the configuration preview - self.web_driver.find_element( - by=By.CSS_SELECTOR, value='.djnjc-overlay a.close' - ).click() + self.find_element(by=By.CSS_SELECTOR, value='.djnjc-overlay a.close').click() with self.subTest('Changing VPN backend should hide webhook and authtoken'): - backend = Select(self.web_driver.find_element(by=By.ID, value='id_backend')) + backend = Select(self.find_element(by=By.ID, value='id_backend')) backend.select_by_visible_text('OpenVPN') - el = self.web_driver.find_element( - by=By.CLASS_NAME, value='field-webhook_endpoint' - ) - self.assertFalse(el.is_displayed()) - el = self.web_driver.find_element( - by=By.CLASS_NAME, value='field-auth_token' - ) - self.assertFalse(el.is_displayed()) + self.wait_for_invisibility(by=By.CLASS_NAME, value='field-webhook_endpoint') + self.wait_for_invisibility(by=By.CLASS_NAME, value='field-auth_token') diff --git a/openwisp_controller/connection/static/connection/js/commands.js b/openwisp_controller/connection/static/connection/js/commands.js index 47bead9e9..35f03c162 100644 --- a/openwisp_controller/connection/static/connection/js/commands.js +++ b/openwisp_controller/connection/static/connection/js/commands.js @@ -27,7 +27,6 @@ django.jQuery(function ($) { timeoutInterval: 7000, }, ); - commandWebSocket.open(); let selector = $("#id_command_set-0-type"), showFields = function () { var fields = $( @@ -40,18 +39,24 @@ django.jQuery(function ($) { $("#command_set-2-group fieldset .dynamic-command_set-2:first"); fields.show(); } + }, + init = function () { + showFields(); + initCommandDropdown($); + initCommandOverlay($); + initCommandWebSockets($, commandWebSocket); }; selector.change(function () { showFields(); }); - - $("#id_command_set-0-input").one("jsonschema-schemaloaded", function () { - showFields(); - - initCommandDropdown($); - initCommandOverlay($); - initCommandWebSockets($, commandWebSocket); - }); + if (django._schemas[$("#id_command_set-0-input").data("schema-url")]) { + // It is possible that the schema is loaded before the event handler + // is attached to the element. In that case, we need to manually call + // the init function. + init(); + } else { + $("#id_command_set-0-input").one("jsonschema-schemaloaded", init); + } }); function initCommandDropdown($) { @@ -542,6 +547,7 @@ function initCommandOverlay($) { } function initCommandWebSockets($, commandWebSocket) { + commandWebSocket.open(); commandWebSocket.addEventListener("message", function (e) { let data = JSON.parse(e.data); // Done for keeping future use of these websocket diff --git a/openwisp_controller/connection/tests/test_selenium.py b/openwisp_controller/connection/tests/test_selenium.py index 5d7134217..0fdb69e1d 100644 --- a/openwisp_controller/connection/tests/test_selenium.py +++ b/openwisp_controller/connection/tests/test_selenium.py @@ -2,11 +2,9 @@ from django.test import tag from django.urls import reverse from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as EC -from selenium.webdriver.support.ui import WebDriverWait from swapper import load_model -from openwisp_utils.test_selenium_mixins import SeleniumTestMixin +from openwisp_utils.tests import SeleniumTestMixin from .utils import CreateConnectionsMixin @@ -39,31 +37,21 @@ def test_command_widget_on_device(self): self.login() path = reverse(f'admin:{self.config_app_label}_device_change', args=[device.id]) self.open(path) + self.hide_loading_overlay() # The "Send Command" widget is not visible on devices which do # not have a DeviceConnection object - WebDriverWait(self.web_driver, 2).until( - EC.invisibility_of_element_located( - (By.CSS_SELECTOR, 'ul.object-tools a#send-command') - ) - ) + self.wait_for_invisibility(By.CSS_SELECTOR, 'ul.object-tools a#send-command') self._create_device_connection(device=device, credentials=creds) - self.web_driver.refresh() - WebDriverWait(self.web_driver, 2).until( - EC.visibility_of_element_located( - (By.CSS_SELECTOR, 'ul.object-tools a#send-command') - ) - ) + self.assertEqual(device.deviceconnection_set.count(), 1) + self.open(path) + self.hide_loading_overlay() # Send reboot command to the device - self.web_driver.find_element( - by=By.CSS_SELECTOR, value='ul.object-tools a#send-command' + self.find_element( + by=By.CSS_SELECTOR, value='ul.object-tools a#send-command', timeout=5 ).click() - self.web_driver.find_element( + self.find_element( by=By.CSS_SELECTOR, value='button.ow-command-btn[data-command="reboot"]' ).click() - self.web_driver.find_element( - by=By.CSS_SELECTOR, value='#ow-command-confirm-yes' - ).click() - WebDriverWait(self.web_driver, 10).until( - EC.visibility_of_element_located((By.CSS_SELECTOR, '#command_set-2-group')) - ) + self.find_element(by=By.CSS_SELECTOR, value='#ow-command-confirm-yes').click() + self.assertEqual(Command.objects.count(), 1) diff --git a/openwisp_controller/geo/static/leaflet/draw/leaflet.draw.i18n.js b/openwisp_controller/geo/static/leaflet/draw/leaflet.draw.i18n.js new file mode 100644 index 000000000..2a18610f5 --- /dev/null +++ b/openwisp_controller/geo/static/leaflet/draw/leaflet.draw.i18n.js @@ -0,0 +1,116 @@ +{ + /** + * Internationalization setup for Leaflet.draw + * Adapted from django-leaflet: + * https://github.com/makinacorpus/django-leaflet/blob/master/leaflet/static/leaflet/draw/leaflet.draw.i18n.js + * + * Using block scope to prevent 'withForms' variable leaking to global scope, + * which would cause errors when this script is included multiple times + * (particularly in Django inline formsets). + */ + const withForms = document.getElementById("with-forms") + ? JSON.parse(document.getElementById("with-forms").textContent) + : false; + if (withForms) { + L.drawLocal.draw.toolbar.actions.title = JSON.parse( + document.getElementById("draw-toolbar-actions-title").textContent, + ); + L.drawLocal.draw.toolbar.actions.text = JSON.parse( + document.getElementById("draw-toolbar-actions-text").textContent, + ); + L.drawLocal.draw.toolbar.undo.title = JSON.parse( + document.getElementById("draw-toolbar-undo-title").textContent, + ); + L.drawLocal.draw.toolbar.undo.text = JSON.parse( + document.getElementById("draw-toolbar-undo-text").textContent, + ); + L.drawLocal.draw.toolbar.buttons.polyline = JSON.parse( + document.getElementById("draw-toolbar-buttons-polyline").textContent, + ); + L.drawLocal.draw.toolbar.buttons.polygon = JSON.parse( + document.getElementById("draw-toolbar-buttons-polygon").textContent, + ); + L.drawLocal.draw.toolbar.buttons.rectangle = JSON.parse( + document.getElementById("draw-toolbar-buttons-rectangle").textContent, + ); + L.drawLocal.draw.toolbar.buttons.circle = JSON.parse( + document.getElementById("draw-toolbar-buttons-circle").textContent, + ); + L.drawLocal.draw.toolbar.buttons.marker = JSON.parse( + document.getElementById("draw-toolbar-buttons-marker").textContent, + ); + L.drawLocal.draw.handlers.circle.tooltip.start = JSON.parse( + document.getElementById("draw-handlers-circle-tooltip-start").textContent, + ); + L.drawLocal.draw.handlers.marker.tooltip.start = JSON.parse( + document.getElementById("draw-handlers-marker-tooltip-start").textContent, + ); + L.drawLocal.draw.handlers.polygon.tooltip.start = JSON.parse( + document.getElementById("draw-handlers-polygon-tooltip-start") + .textContent, + ); + L.drawLocal.draw.handlers.polygon.tooltip.cont = JSON.parse( + document.getElementById("draw-handlers-polygon-tooltip-cont").textContent, + ); + L.drawLocal.draw.handlers.polygon.tooltip.end = JSON.parse( + document.getElementById("draw-handlers-polygon-tooltip-end").textContent, + ); + L.drawLocal.draw.handlers.polyline.error = JSON.parse( + document.getElementById("draw-handlers-polyline-error").textContent, + ); + L.drawLocal.draw.handlers.polyline.tooltip.start = JSON.parse( + document.getElementById("draw-handlers-polyline-tooltip-start") + .textContent, + ); + L.drawLocal.draw.handlers.polyline.tooltip.cont = JSON.parse( + document.getElementById("draw-handlers-polyline-tooltip-cont") + .textContent, + ); + L.drawLocal.draw.handlers.polyline.tooltip.end = JSON.parse( + document.getElementById("draw-handlers-polyline-tooltip-end").textContent, + ); + L.drawLocal.draw.handlers.rectangle.tooltip.start = JSON.parse( + document.getElementById("draw-handlers-rectangle-tooltip-start") + .textContent, + ); + L.drawLocal.draw.handlers.simpleshape.tooltip.end = JSON.parse( + document.getElementById("draw-handlers-simpleshape-tooltip-end") + .textContent, + ); + + L.drawLocal.edit.toolbar.actions.save.title = JSON.parse( + document.getElementById("edit-toolbar-actions-save-title").textContent, + ); + L.drawLocal.edit.toolbar.actions.save.text = JSON.parse( + document.getElementById("edit-toolbar-actions-save-text").textContent, + ); + L.drawLocal.edit.toolbar.actions.cancel.title = JSON.parse( + document.getElementById("edit-toolbar-actions-cancel-title").textContent, + ); + L.drawLocal.edit.toolbar.actions.cancel.text = JSON.parse( + document.getElementById("edit-toolbar-actions-cancel-text").textContent, + ); + L.drawLocal.edit.toolbar.buttons.edit = JSON.parse( + document.getElementById("edit-toolbar-buttons-edit").textContent, + ); + L.drawLocal.edit.toolbar.buttons.editDisabled = JSON.parse( + document.getElementById("edit-toolbar-buttons-editDisabled").textContent, + ); + L.drawLocal.edit.toolbar.buttons.remove = JSON.parse( + document.getElementById("edit-toolbar-buttons-remove").textContent, + ); + L.drawLocal.edit.toolbar.buttons.removeDisabled = JSON.parse( + document.getElementById("edit-toolbar-buttons-removeDisabled") + .textContent, + ); + L.drawLocal.edit.handlers.edit.tooltip.text = JSON.parse( + document.getElementById("edit-handlers-edit-tooltip-text").textContent, + ); + L.drawLocal.edit.handlers.edit.tooltip.subtext = JSON.parse( + document.getElementById("edit-handlers-edit-tooltip-subtext").textContent, + ); + L.drawLocal.edit.handlers.remove.tooltip.text = JSON.parse( + document.getElementById("edit-handlers-remove-tooltip-text").textContent, + ); + } +} diff --git a/openwisp_controller/tests/test_selenium.py b/openwisp_controller/tests/test_selenium.py index 5a16bc789..e866446e8 100644 --- a/openwisp_controller/tests/test_selenium.py +++ b/openwisp_controller/tests/test_selenium.py @@ -13,7 +13,7 @@ from openwisp_controller.connection.tests.utils import CreateConnectionsMixin from openwisp_controller.geo.tests.utils import TestGeoMixin -from openwisp_utils.test_selenium_mixins import SeleniumTestMixin +from openwisp_utils.tests import SeleniumTestMixin Device = load_model('config', 'Device') DeviceConnection = load_model('connection', 'DeviceConnection') @@ -62,7 +62,7 @@ def test_restoring_deleted_device(self, *args): self.open( reverse(f'admin:{self.config_app_label}_device_delete', args=[device.id]) ) - self.web_driver.find_element( + self.find_element( by=By.CSS_SELECTOR, value='#content form input[type="submit"]' ).click() # Delete location object @@ -89,9 +89,12 @@ def test_restoring_deleted_device(self, *args): # is logged at WARNING level. # By checking that there are no WARNING level errors logged in the # browser console, we ensure that this issue is not happening. - for error in self.web_driver.get_log('browser'): - self.assertNotEqual(error['level'], 'WARNING') - self.web_driver.find_element( + for error in self.get_browser_logs(): + if error['level'] == 'WARNING' and error['message'] not in [ + 'wrong event specified: touchleave' + ]: + self.fail(f'Browser console error: {error["message"]}') + self.find_element( by=By.XPATH, value='//*[@id="device_form"]/div/div[1]/input[1]' ).click() try: diff --git a/pytest.ini b/pytest.ini index 929a44763..dd341ef23 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,3 +3,4 @@ addopts = -p no:warnings --create-db --reuse-db --nomigrations DJANGO_SETTINGS_MODULE = openwisp2.settings python_files = pytest*.py python_classes = *Test* +pythonpath = tests diff --git a/runtests.py b/runtests.py index 6683ceb9f..63e1f195d 100755 --- a/runtests.py +++ b/runtests.py @@ -2,37 +2,58 @@ # -*- coding: utf-8 -*- import os +import subprocess import sys import pytest -if __name__ == '__main__': - sys.path.insert(0, 'tests') - if os.environ.get('POSTGRESQL', False): - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'openwisp2.postgresql_settings') - else: - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'openwisp2.settings') - from django.core.management import execute_from_command_line - args = sys.argv - args.insert(1, 'test') +def run_tests(extra_args, settings_module, test_app): + """ + Run Django tests with the specified settings module in a separate subprocess. + """ + args = [ + './tests/manage.py', + 'test', + test_app, + '--settings', + settings_module, + '--pythonpath', + 'tests', + ] + args.extend(extra_args) + if os.environ.get('COVERAGE_RUN', False): + # Since the Django tests are run in a separate process (using subprocess), + # we need to run coverage in the subprocess as well. + args = ['coverage', 'run'] + args + result = subprocess.run(args) + if result.returncode != 0: + sys.exit(result.returncode) - if not os.environ.get('SAMPLE_APP', False): - args.insert(2, 'openwisp_controller') - else: - args.insert(2, 'openwisp2') - - if os.environ.get('POSTGRESQL', False): - args.extend(['--tag', 'db_tests']) - args.extend(['--tag', 'selenium_tests']) - else: - args.extend(['--exclude-tag', 'selenium_tests']) - - execute_from_command_line(args) +if __name__ == '__main__': + # Configure Django settings for test execution + # (sets Celery to eager mode, configures in-memory channels layer, etc.) + os.environ.setdefault('TESTING', '1') + base_args = sys.argv.copy()[1:] if not os.environ.get('SAMPLE_APP', False): + test_app = 'openwisp_controller' app_dir = 'openwisp_controller/' else: + test_app = 'openwisp2' app_dir = 'tests/openwisp2/' - + # Run all tests except Selenium tests using SQLite + sqlite_args = ['--exclude-tag', 'selenium_tests'] + base_args + run_tests(sqlite_args, 'openwisp2.settings', test_app) + + # Run Selenium tests using PostgreSQL + psql_args = [ + '--tag', + 'db_tests', + '--tag', + 'selenium_tests', + ] + base_args + run_tests(psql_args, 'openwisp2.postgresql_settings', test_app) + + # Run pytest tests sys.exit(pytest.main([app_dir])) diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py index dde934710..f626695e9 100644 --- a/tests/openwisp2/settings.py +++ b/tests/openwisp2/settings.py @@ -3,7 +3,7 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DEBUG = True -TESTING = sys.argv[1:2] == ['test'] +TESTING = os.environ.get('TESTING', False) or sys.argv[1:2] == ['test'] SELENIUM_HEADLESS = True if os.environ.get('SELENIUM_HEADLESS', False) else False SHELL = 'shell' in sys.argv or 'shell_plus' in sys.argv REDIS_URL = os.getenv('REDIS_URL', 'redis://localhost:6379')