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 %}
                 
-                
+                
+                    
+                    
+                
             
         
     
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 %}
                 
-                
+                
+                    
+                    
+                
             
         
     
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')