Repository: openwisp/openwisp-controller (language: python)

Issue:
[bug] Unsaved changes alert when using a template which has default values
**How to replicate**:

- create a template, assign default values to it
- create a device, select the template created in the previous step, save and continue
- wait for the page to reload, try to go back to the list of devices

**Expected outcome**:

List of devices is opened without alerts.

**Actual outcome**:

Unsaved changes alert pops up. 

Let's use a selenium test for this issue!


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ff2eee0f0..fbea83fe0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -45,7 +45,16 @@ jobs:
     - name: Install system packages
       run: |
         sudo apt update
-        sudo apt-get -qq -y install sqlite3 gdal-bin libproj-dev libgeos-dev libspatialite-dev spatialite-bin libsqlite3-mod-spatialite
+        sudo apt-get -qq -y install sqlite3 gdal-bin libproj-dev \
+          libgeos-dev libspatialite-dev spatialite-bin \
+          libsqlite3-mod-spatialite
+
+    - name: Install Chromium for Selenium testing
+      run: |
+        sudo snap install chromium
+        wget https://chromedriver.storage.googleapis.com/91.0.4472.19/chromedriver_linux64.zip
+        sudo unzip chromedriver_linux64.zip -d /usr/local/bin/
+        sudo chmod +x /usr/local/bin/chromedriver
 
     - name: Install npm dependencies
       run: sudo npm install -g jshint stylelint
@@ -70,6 +79,8 @@ jobs:
         coverage run --source=openwisp_controller runtests.py
         # SAMPLE tests do not influence coverage, so we can speed up tests with --parallel
         SAMPLE_APP=1 ./runtests.py --keepdb
+      env:
+        SELENIUM_HEADLESS: 1
 
     - name: Upload Coverage
       run: coveralls --service=github
diff --git a/README.rst b/README.rst
index 39485d0a3..b272b8e1a 100755
--- a/README.rst
+++ b/README.rst
@@ -918,6 +918,7 @@ Install the system dependencies:
 
     sudo apt install -y sqlite3 libsqlite3-dev openssl libssl-dev
     sudo apt install -y gdal-bin libproj-dev libgeos-dev libspatialite-dev libsqlite3-mod-spatialite
+    sudo snap install -y chromium
 
 Fork and clone the forked repository:
 
@@ -959,6 +960,9 @@ Install development dependencies:
     pip install -r requirements-test.txt
     npm install -g jslint stylelint
 
+Install WebDriver for Chromium for your browser version from `<https://chromedriver.chromium.org/home>`_
+and Extract ``chromedriver`` to one of directories from your ``$PATH`` (example: ``~/.local/bin/``).
+
 Create database:
 
 .. code-block:: shell
diff --git a/openwisp_controller/config/static/config/js/widget.js b/openwisp_controller/config/static/config/js/widget.js
index aad748609..d15c010a3 100644
--- a/openwisp_controller/config/static/config/js/widget.js
+++ b/openwisp_controller/config/static/config/js/widget.js
@@ -401,9 +401,15 @@
         // otherwise load immediately
         bindLoadUi();
         // fill device context field with default values of selected templates.
-        $(document).one('owcInitialValuesLoaded', function () {
+        // If unsaved_changes have already mapped values, then fetch defaultValues,
+        // otherwise wait for event to be triggered.
+        if (django._owcInitialValues !== undefined){
             getDefaultValues(true);
-        });
+        } else {
+            $(document).one('owcInitialValuesLoaded', function () {
+                getDefaultValues(true);
+            });
+        }
         $('.sortedm2m-items').on('change', function() {
             getDefaultValues();
         });


Test patch:
diff --git a/openwisp_controller/config/tests/test_selenium.py b/openwisp_controller/config/tests/test_selenium.py
new file mode 100644
index 000000000..03a92142c
--- /dev/null
+++ b/openwisp_controller/config/tests/test_selenium.py
@@ -0,0 +1,253 @@
+from django.conf import settings
+from django.urls.base import reverse
+from selenium import webdriver
+from selenium.common.exceptions import (
+    StaleElementReferenceException,
+    TimeoutException,
+    UnexpectedAlertPresentException,
+)
+from selenium.webdriver.common.by import By
+from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
+from selenium.webdriver.support import expected_conditions as EC
+from selenium.webdriver.support.ui import Select, WebDriverWait
+
+from openwisp_users.tests.utils import TestOrganizationMixin
+
+from .utils import CreateConfigTemplateMixin, SeleniumTestCase
+
+
+class TestDeviceAdmin(
+    TestOrganizationMixin, CreateConfigTemplateMixin, SeleniumTestCase
+):
+    admin_username = 'admin'
+    admin_password = 'password'
+
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        chrome_options = webdriver.ChromeOptions()
+        if getattr(settings, 'SELENIUM_HEADLESS', True):
+            chrome_options.add_argument('--headless')
+        chrome_options.add_argument('--window-size=1366,768')
+        chrome_options.add_argument('--ignore-certificate-errors')
+        chrome_options.add_argument('--remote-debugging-port=9222')
+        capabilities = DesiredCapabilities.CHROME
+        capabilities['goog:loggingPrefs'] = {'browser': 'ALL'}
+        cls.web_driver = webdriver.Chrome(
+            options=chrome_options, desired_capabilities=capabilities
+        )
+
+    @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 tearDown(self):
+        # Accept unsaved changes alert to allow other tests to run
+        try:
+            self.web_driver.refresh()
+        except UnexpectedAlertPresentException:
+            self.web_driver.switch_to_alert().accept()
+        else:
+            try:
+                WebDriverWait(self.web_driver, 1).until(EC.alert_is_present())
+            except TimeoutException:
+                pass
+            else:
+                self.web_driver.switch_to_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_name('name').send_keys('11:22:33:44:55:66')
+        Select(self.web_driver.find_element_by_name('organization')).select_by_value(
+            str(org.id)
+        )
+        self.web_driver.find_element_by_name('mac_address').send_keys(
+            '11:22:33:44:55:66'
+        )
+        self.web_driver.find_element_by_xpath(
+            '//*[@id="config-group"]/fieldset/div[2]/a'
+        ).click()
+
+        try:
+            WebDriverWait(self.web_driver, 2).until(
+                EC.element_to_be_clickable(
+                    (By.XPATH, f'//*[@value="{default_template.id}"]')
+                )
+            )
+        except TimeoutException:
+            self.fail('Default template clickable timed out')
+
+        required_template_element = self.web_driver.find_element_by_xpath(
+            f'//*[@value="{required_template.id}"]'
+        )
+        default_template_element = self.web_driver.find_element_by_xpath(
+            f'//*[@value="{default_template.id}"]'
+        )
+        self.assertEqual(required_template_element.is_enabled(), False)
+        self.assertEqual(required_template_element.is_selected(), True)
+        self.assertEqual(default_template_element.is_enabled(), True)
+        self.assertEqual(default_template_element.is_selected(), True)
+
+        self.web_driver.find_element_by_name('_save').click()
+        self.assertEqual(
+            self.web_driver.find_elements_by_class_name('success')[0].text,
+            'The Device “11:22:33:44:55:66” was added successfully.',
+        )
+
+    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'):
+            self.web_driver.find_element_by_name('name').send_keys('new.device.name')
+            self.web_driver.refresh()
+            try:
+                WebDriverWait(self.web_driver, 1).until(EC.alert_is_present())
+            except TimeoutException:
+                self.fail('Timed out wating for unsaved changes alert')
+            else:
+                self.web_driver.switch_to_alert().accept()
+
+    def test_multiple_organization_templates(self):
+        shared_required_template = self._create_template(
+            name='shared required', organization=None
+        )
+
+        org1 = self._create_org(name='org1', slug='org1')
+        org1_required_template = self._create_template(
+            name='org1 required', organization=org1, required=True
+        )
+        org1_default_template = self._create_template(
+            name='org1 default', organization=org1, default=True
+        )
+
+        org2 = self._create_org(name='org2', slug='org2')
+        org2_required_template = self._create_template(
+            name='org2 required', organization=org2, required=True
+        )
+        org2_default_template = self._create_template(
+            name='org2 default', organization=org2, default=True
+        )
+
+        org1_device = self._create_config(
+            device=self._create_device(organization=org1)
+        ).device
+
+        self.login()
+        self.open(
+            reverse('admin:config_device_change', args=[org1_device.id])
+            + '#config-group'
+        )
+        wait = WebDriverWait(self.web_driver, 2)
+        # 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}"]')
+            )
+        )
+        wait.until(
+            EC.visibility_of_any_elements_located(
+                (By.XPATH, f'//*[@value="{org1_default_template.id}"]')
+            )
+        )
+        wait.until(
+            EC.visibility_of_any_elements_located(
+                (By.XPATH, f'//*[@value="{shared_required_template.id}"]')
+            )
+        )
+
+    def test_change_config_backend(self):
+        device = self._create_config(organization=self._get_org()).device
+        template = self._create_template()
+
+        self.login()
+        self.open(
+            reverse('admin:config_device_change', args=[device.id]) + '#config-group'
+        )
+        self.web_driver.find_element_by_xpath(f'//*[@value="{template.id}"]')
+        # Change config backed to
+        config_backend_select = Select(
+            self.web_driver.find_element_by_name('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')
+
+    def test_template_context_variables(self):
+        self._create_template(
+            name='Template1', default_values={'vni': '1'}, required=True
+        )
+        self._create_template(
+            name='Template2', default_values={'vni': '2'}, required=True
+        )
+        device = self._create_config(organization=self._get_org()).device
+        self.login()
+        self.open(
+            reverse('admin:config_device_change', args=[device.id]) + '#config-group'
+        )
+        try:
+            WebDriverWait(self.web_driver, 2).until(
+                EC.text_to_be_present_in_element_value(
+                    (
+                        By.XPATH,
+                        '//*[@id="flat-json-config-0-context"]/div[2]/div/div/input[1]',
+                    ),
+                    'vni',
+                )
+            )
+        except TimeoutException:
+            self.fail('Timed out wating for configuration variabled to get loaded')
+        self.web_driver.find_element_by_xpath(
+            '//*[@id="container"]/div[2]/a[3]'
+        ).click()
+        try:
+            WebDriverWait(self.web_driver, 2).until(EC.alert_is_present())
+        except TimeoutException:
+            pass
+        else:
+            self.web_driver.switch_to_alert().accept()
+            self.fail('Unsaved changes alert displayed without any change')
diff --git a/openwisp_controller/config/tests/utils.py b/openwisp_controller/config/tests/utils.py
index 9a9cb3d7f..828d8a0a7 100644
--- a/openwisp_controller/config/tests/utils.py
+++ b/openwisp_controller/config/tests/utils.py
@@ -6,6 +6,7 @@
 from unittest import mock
 from uuid import uuid4
 
+from django.contrib.staticfiles.testing import StaticLiveServerTestCase
 from swapper import load_model
 
 from ...pki.tests.utils import TestPkiMixin
@@ -144,3 +145,41 @@ def _create_config(self, **kwargs):
                 name='test-device', organization=kwargs.pop('organization')
             )
         return super()._create_config(**kwargs)
+
+
+class SeleniumTestCase(StaticLiveServerTestCase):
+    """
+    A base test case for Selenium, providing helped methods for generating
+    clients and logging in profiles.
+    """
+
+    def open(self, url, driver=None):
+        """
+        Opens a URL
+        Argument:
+            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}')
+
+    def login(self, username=None, password=None, driver=None):
+        """
+        Log in to the admin dashboard
+        Argument:
+            driver: selenium driver (default: cls.web_driver)
+            username: username to be used for login (default: cls.admin.username)
+            password: password to be used for login (default: cls.admin.password)
+        """
+        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_name('username').send_keys(username)
+            driver.find_element_by_name('password').send_keys(password)
+            driver.find_element_by_xpath('//input[@type="submit"]').click()
diff --git a/requirements-test.txt b/requirements-test.txt
index e4bad84cd..a5a57a960 100644
--- a/requirements-test.txt
+++ b/requirements-test.txt
@@ -8,3 +8,4 @@ pytest-asyncio~=0.14.0
 pytest-cov~=2.10.0
 mock-ssh-server>=0.8.0,<0.9.0
 responses~=0.12.1
+selenium~=3.141.0
diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py
index ea66156d7..a468daef2 100644
--- a/tests/openwisp2/settings.py
+++ b/tests/openwisp2/settings.py
@@ -4,6 +4,7 @@
 BASE_DIR = os.path.dirname(os.path.abspath(__file__))
 DEBUG = True
 TESTING = 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
 
 ALLOWED_HOSTS = ['*']