diff options
Diffstat (limited to 'lib/toaster/tests/functional')
-rw-r--r-- | lib/toaster/tests/functional/functional_helpers.py | 84 | ||||
-rw-r--r-- | lib/toaster/tests/functional/test_create_new_project.py | 179 | ||||
-rw-r--r-- | lib/toaster/tests/functional/test_functional_basic.py | 198 | ||||
-rw-r--r-- | lib/toaster/tests/functional/test_project_config.py | 341 | ||||
-rw-r--r-- | lib/toaster/tests/functional/test_project_page.py | 792 | ||||
-rw-r--r-- | lib/toaster/tests/functional/test_project_page_tab_config.py | 528 | ||||
-rw-r--r-- | lib/toaster/tests/functional/utils.py | 89 |
7 files changed, 2097 insertions, 114 deletions
diff --git a/lib/toaster/tests/functional/functional_helpers.py b/lib/toaster/tests/functional/functional_helpers.py index 6a3f74baa..7c20437d1 100644 --- a/lib/toaster/tests/functional/functional_helpers.py +++ b/lib/toaster/tests/functional/functional_helpers.py @@ -1,4 +1,4 @@ -#! /usr/bin/env python +#! /usr/bin/env python3 # # BitBake Toaster functional tests implementation # @@ -11,35 +11,55 @@ import os import logging import subprocess import signal -import time import re from tests.browser.selenium_helpers_base import SeleniumTestCaseBase -from tests.builds.buildtest import load_build_environment +from selenium.webdriver.common.by import By +from selenium.common.exceptions import NoSuchElementException logger = logging.getLogger("toaster") +toaster_processes = [] class SeleniumFunctionalTestCase(SeleniumTestCaseBase): - wait_toaster_time = 5 + wait_toaster_time = 10 @classmethod def setUpClass(cls): # So that the buildinfo helper uses the test database' if os.environ.get('DJANGO_SETTINGS_MODULE', '') != \ 'toastermain.settings_test': - raise RuntimeError("Please initialise django with the tests settings: " \ + raise RuntimeError("Please initialise django with the tests settings: " "DJANGO_SETTINGS_MODULE='toastermain.settings_test'") - load_build_environment() + # Wait for any known toaster processes to exit + global toaster_processes + for toaster_process in toaster_processes: + try: + os.waitpid(toaster_process, os.WNOHANG) + except ChildProcessError: + pass # start toaster cmd = "bash -c 'source toaster start'" - p = subprocess.Popen( + start_process = subprocess.Popen( cmd, cwd=os.environ.get("BUILDDIR"), shell=True) - if p.wait() != 0: - raise RuntimeError("Can't initialize toaster") + toaster_processes = [start_process.pid] + if start_process.wait() != 0: + port_use = os.popen("lsof -i -P -n | grep '8000 (LISTEN)'").read().strip() + message = '' + if port_use: + process_id = port_use.split()[1] + process = os.popen(f"ps -o cmd= -p {process_id}").read().strip() + message = f"Port 8000 occupied by {process}" + raise RuntimeError(f"Can't initialize toaster. {message}") + + builddir = os.environ.get("BUILDDIR") + with open(os.path.join(builddir, '.toastermain.pid'), 'r') as f: + toaster_processes.append(int(f.read())) + with open(os.path.join(builddir, '.runbuilds.pid'), 'r') as f: + toaster_processes.append(int(f.read())) super(SeleniumFunctionalTestCase, cls).setUpClass() cls.live_server_url = 'http://localhost:8000/' @@ -48,22 +68,30 @@ class SeleniumFunctionalTestCase(SeleniumTestCaseBase): def tearDownClass(cls): super(SeleniumFunctionalTestCase, cls).tearDownClass() - # XXX: source toaster stop gets blocked, to review why? - # from now send SIGTERM by hand - time.sleep(cls.wait_toaster_time) - builddir = os.environ.get("BUILDDIR") + global toaster_processes - with open(os.path.join(builddir, '.toastermain.pid'), 'r') as f: - toastermain_pid = int(f.read()) - os.kill(toastermain_pid, signal.SIGTERM) - with open(os.path.join(builddir, '.runbuilds.pid'), 'r') as f: - runbuilds_pid = int(f.read()) - os.kill(runbuilds_pid, signal.SIGTERM) + cmd = "bash -c 'source toaster stop'" + stop_process = subprocess.Popen( + cmd, + cwd=os.environ.get("BUILDDIR"), + shell=True) + # Toaster stop has been known to hang in these tests so force kill if it stalls + try: + if stop_process.wait(cls.wait_toaster_time) != 0: + raise Exception('Toaster stop process failed') + except Exception as e: + if e is subprocess.TimeoutExpired: + print('Toaster stop process took too long. Force killing toaster...') + else: + print('Toaster stop process failed. Force killing toaster...') + stop_process.kill() + for toaster_process in toaster_processes: + os.kill(toaster_process, signal.SIGTERM) def get_URL(self): rc=self.get_page_source() - project_url=re.search("(projectPageUrl\s:\s\")(.*)(\",)",rc) + project_url=re.search(r"(projectPageUrl\s:\s\")(.*)(\",)",rc) return project_url.group(2) @@ -74,8 +102,8 @@ class SeleniumFunctionalTestCase(SeleniumTestCaseBase): """ try: table_element = self.get_table_element(table_id) - element = table_element.find_element_by_link_text(link_text) - except NoSuchElementException as e: + element = table_element.find_element(By.LINK_TEXT, link_text) + except NoSuchElementException: print('no element found') raise return element @@ -85,8 +113,8 @@ class SeleniumFunctionalTestCase(SeleniumTestCaseBase): #return whole-table element element_xpath = "//*[@id='" + table_id + "']" try: - element = self.driver.find_element_by_xpath(element_xpath) - except NoSuchElementException as e: + element = self.driver.find_element(By.XPATH, element_xpath) + except NoSuchElementException: raise return element row = coordinate[0] @@ -95,8 +123,8 @@ class SeleniumFunctionalTestCase(SeleniumTestCaseBase): #return whole-row element element_xpath = "//*[@id='" + table_id + "']/tbody/tr[" + str(row) + "]" try: - element = self.driver.find_element_by_xpath(element_xpath) - except NoSuchElementException as e: + element = self.driver.find_element(By.XPATH, element_xpath) + except NoSuchElementException: return False return element #now we are looking for an element with specified X and Y @@ -104,7 +132,7 @@ class SeleniumFunctionalTestCase(SeleniumTestCaseBase): element_xpath = "//*[@id='" + table_id + "']/tbody/tr[" + str(row) + "]/td[" + str(column) + "]" try: - element = self.driver.find_element_by_xpath(element_xpath) - except NoSuchElementException as e: + element = self.driver.find_element(By.XPATH, element_xpath) + except NoSuchElementException: return False return element diff --git a/lib/toaster/tests/functional/test_create_new_project.py b/lib/toaster/tests/functional/test_create_new_project.py new file mode 100644 index 000000000..cdfdd9ab2 --- /dev/null +++ b/lib/toaster/tests/functional/test_create_new_project.py @@ -0,0 +1,179 @@ +#! /usr/bin/env python3 +# BitBake Toaster UI tests implementation +# +# Copyright (C) 2023 Savoir-faire Linux +# +# SPDX-License-Identifier: GPL-2.0-only +# + +import re +import pytest +from django.urls import reverse +from selenium.webdriver.support.select import Select +from tests.functional.functional_helpers import SeleniumFunctionalTestCase +from orm.models import Project +from selenium.webdriver.common.by import By + + +@pytest.mark.django_db +@pytest.mark.order("last") +class TestCreateNewProject(SeleniumFunctionalTestCase): + + def _create_test_new_project( + self, + project_name, + release, + release_title, + merge_toaster_settings, + ): + """ Create/Test new project using: + - Project Name: Any string + - Release: Any string + - Merge Toaster settings: True or False + """ + self.get(reverse('newproject')) + self.wait_until_visible('#new-project-name', poll=3) + self.driver.find_element(By.ID, + "new-project-name").send_keys(project_name) + + select = Select(self.find('#projectversion')) + select.select_by_value(release) + + # check merge toaster settings + checkbox = self.find('.checkbox-mergeattr') + if merge_toaster_settings: + if not checkbox.is_selected(): + checkbox.click() + else: + if checkbox.is_selected(): + checkbox.click() + + self.driver.find_element(By.ID, "create-project-button").click() + + element = self.wait_until_visible('#project-created-notification', poll=3) + self.assertTrue( + self.element_exists('#project-created-notification'), + f"Project:{project_name} creation notification not shown" + ) + self.assertTrue( + project_name in element.text, + f"New project name:{project_name} not in new project notification" + ) + self.assertTrue( + Project.objects.filter(name=project_name).count(), + f"New project:{project_name} not found in database" + ) + + # check release + self.assertTrue(re.search( + release_title, + self.driver.find_element(By.XPATH, + "//span[@id='project-release-title']" + ).text), + 'The project release is not defined') + + def test_create_new_project_master(self): + """ Test create new project using: + - Project Name: Any string + - Release: Yocto Project master (option value: 3) + - Merge Toaster settings: False + """ + release = '3' + release_title = 'Yocto Project master' + project_name = 'projectmaster' + self._create_test_new_project( + project_name, + release, + release_title, + False, + ) + + def test_create_new_project_scarthgap(self): + """ Test create new project using: + - Project Name: Any string + - Release: Yocto Project 5.0 "Scarthgap" (option value: 1) + - Merge Toaster settings: True + """ + release = '1' + release_title = 'Yocto Project 5.0 "Scarthgap"' + project_name = 'projectscarthgap' + self._create_test_new_project( + project_name, + release, + release_title, + True, + ) + + def test_create_new_project_kirkstone(self): + """ Test create new project using: + - Project Name: Any string + - Release: Yocto Project 4.0 "Kirkstone" (option value: 4) + - Merge Toaster settings: True + """ + release = '4' + release_title = 'Yocto Project 4.0 "Kirkstone"' + project_name = 'projectkirkstone' + self._create_test_new_project( + project_name, + release, + release_title, + True, + ) + + def test_create_new_project_local(self): + """ Test create new project using: + - Project Name: Any string + - Release: Local Yocto Project (option value: 2) + - Merge Toaster settings: True + """ + release = '2' + release_title = 'Local Yocto Project' + project_name = 'projectlocal' + self._create_test_new_project( + project_name, + release, + release_title, + True, + ) + + def test_create_new_project_without_name(self): + """ Test create new project without project name """ + self.get(reverse('newproject')) + + select = Select(self.find('#projectversion')) + select.select_by_value(str(3)) + + # Check input name has required attribute + input_name = self.driver.find_element(By.ID, "new-project-name") + self.assertIsNotNone(input_name.get_attribute('required'), + 'Input name has not required attribute') + + # Check create button is disabled + create_btn = self.driver.find_element(By.ID, "create-project-button") + self.assertIsNotNone(create_btn.get_attribute('disabled'), + 'Create button is not disabled') + + def test_import_new_project(self): + """ Test import new project using: + - Project Name: Any string + - Project type: select (Import command line project) + - Import existing project directory: Wrong Path + """ + project_name = 'projectimport' + self.get(reverse('newproject')) + self.driver.find_element(By.ID, + "new-project-name").send_keys(project_name) + # select import project + self.find('#type-import').click() + + # set wrong path + wrong_path = '/wrongpath' + self.driver.find_element(By.ID, + "import-project-dir").send_keys(wrong_path) + self.driver.find_element(By.ID, "create-project-button").click() + + # check error message + self.assertTrue(self.element_exists('.alert-danger'), + 'Allert message not shown') + self.assertTrue(wrong_path in self.find('.alert-danger').text, + "Wrong path not in alert message") diff --git a/lib/toaster/tests/functional/test_functional_basic.py b/lib/toaster/tests/functional/test_functional_basic.py index 2b3a2886c..e4070fbb8 100644 --- a/lib/toaster/tests/functional/test_functional_basic.py +++ b/lib/toaster/tests/functional/test_functional_basic.py @@ -1,4 +1,4 @@ -#! /usr/bin/env python +#! /usr/bin/env python3 # # BitBake Toaster functional tests implementation # @@ -7,106 +7,130 @@ # SPDX-License-Identifier: GPL-2.0-only # -import time import re +from django.urls import reverse +import pytest from tests.functional.functional_helpers import SeleniumFunctionalTestCase from orm.models import Project +from selenium.webdriver.common.by import By +from tests.functional.utils import get_projectId_from_url + + +@pytest.mark.django_db +@pytest.mark.order("second_to_last") class FuntionalTestBasic(SeleniumFunctionalTestCase): + """Basic functional tests for Toaster""" + project_id = None + + def setUp(self): + super(FuntionalTestBasic, self).setUp() + if not FuntionalTestBasic.project_id: + self._create_slenium_project() + current_url = self.driver.current_url + FuntionalTestBasic.project_id = get_projectId_from_url(current_url) # testcase (1514) - def test_create_slenium_project(self): + def _create_slenium_project(self): project_name = 'selenium-project' - self.get('') - self.driver.find_element_by_link_text("To start building, create your first Toaster project").click() - self.driver.find_element_by_id("new-project-name").send_keys(project_name) - self.driver.find_element_by_id('projectversion').click() - self.driver.find_element_by_id("create-project-button").click() - element = self.wait_until_visible('#project-created-notification') + self.get(reverse('newproject')) + self.wait_until_visible('#new-project-name', poll=3) + self.driver.find_element(By.ID, "new-project-name").send_keys(project_name) + self.driver.find_element(By.ID, 'projectversion').click() + self.driver.find_element(By.ID, "create-project-button").click() + element = self.wait_until_visible('#project-created-notification', poll=10) self.assertTrue(self.element_exists('#project-created-notification'),'Project creation notification not shown') self.assertTrue(project_name in element.text, "New project name not in new project notification") self.assertTrue(Project.objects.filter(name=project_name).count(), "New project not found in database") + return Project.objects.last().id # testcase (1515) def test_verify_left_bar_menu(self): - self.get('') - self.wait_until_visible('#projectstable') + self.get(reverse('all-projects')) + self.wait_until_present('#projectstable', poll=10) self.find_element_by_link_text_in_table('projectstable', 'selenium-project').click() + self.wait_until_present('#config-nav', poll=10) self.assertTrue(self.element_exists('#config-nav'),'Configuration Tab does not exist') project_URL=self.get_URL() - self.driver.find_element_by_xpath('//a[@href="'+project_URL+'"]').click() + self.driver.find_element(By.XPATH, '//a[@href="'+project_URL+'"]').click() + self.wait_until_present('#config-nav', poll=10) try: - self.driver.find_element_by_xpath("//*[@id='config-nav']/ul/li/a[@href="+'"'+project_URL+'customimages/"'+"]").click() - self.assertTrue(re.search("Custom images",self.driver.find_element_by_xpath("//div[@class='col-md-10']").text),'Custom images information is not loading properly') + self.driver.find_element(By.XPATH, "//*[@id='config-nav']/ul/li/a[@href="+'"'+project_URL+'customimages/"'+"]").click() + self.wait_until_present('#config-nav', poll=10) + self.assertTrue(re.search("Custom images",self.driver.find_element(By.XPATH, "//div[@class='col-md-10']").text),'Custom images information is not loading properly') except: self.fail(msg='No Custom images tab available') try: - self.driver.find_element_by_xpath("//*[@id='config-nav']/ul/li/a[@href="+'"'+project_URL+'images/"'+"]").click() - self.assertTrue(re.search("Compatible image recipes",self.driver.find_element_by_xpath("//div[@class='col-md-10']").text),'The Compatible image recipes information is not loading properly') + self.driver.find_element(By.XPATH, "//*[@id='config-nav']/ul/li/a[@href="+'"'+project_URL+'images/"'+"]").click() + self.wait_until_present('#config-nav', poll=10) + self.assertTrue(re.search("Compatible image recipes",self.driver.find_element(By.XPATH, "//div[@class='col-md-10']").text),'The Compatible image recipes information is not loading properly') except: self.fail(msg='No Compatible image tab available') try: - self.driver.find_element_by_xpath("//*[@id='config-nav']/ul/li/a[@href="+'"'+project_URL+'softwarerecipes/"'+"]").click() - self.assertTrue(re.search("Compatible software recipes",self.driver.find_element_by_xpath("//div[@class='col-md-10']").text),'The Compatible software recipe information is not loading properly') + self.driver.find_element(By.XPATH, "//*[@id='config-nav']/ul/li/a[@href="+'"'+project_URL+'softwarerecipes/"'+"]").click() + self.wait_until_present('#config-nav', poll=10) + self.assertTrue(re.search("Compatible software recipes",self.driver.find_element(By.XPATH, "//div[@class='col-md-10']").text),'The Compatible software recipe information is not loading properly') except: self.fail(msg='No Compatible software recipe tab available') try: - self.driver.find_element_by_xpath("//*[@id='config-nav']/ul/li/a[@href="+'"'+project_URL+'machines/"'+"]").click() - self.assertTrue(re.search("Compatible machines",self.driver.find_element_by_xpath("//div[@class='col-md-10']").text),'The Compatible machine information is not loading properly') + self.driver.find_element(By.XPATH, "//*[@id='config-nav']/ul/li/a[@href="+'"'+project_URL+'machines/"'+"]").click() + self.wait_until_present('#config-nav', poll=10) + self.assertTrue(re.search("Compatible machines",self.driver.find_element(By.XPATH, "//div[@class='col-md-10']").text),'The Compatible machine information is not loading properly') except: self.fail(msg='No Compatible machines tab available') try: - self.driver.find_element_by_xpath("//*[@id='config-nav']/ul/li/a[@href="+'"'+project_URL+'layers/"'+"]").click() - self.assertTrue(re.search("Compatible layers",self.driver.find_element_by_xpath("//div[@class='col-md-10']").text),'The Compatible layer information is not loading properly') + self.driver.find_element(By.XPATH, "//*[@id='config-nav']/ul/li/a[@href="+'"'+project_URL+'layers/"'+"]").click() + self.wait_until_present('#config-nav', poll=10) + self.assertTrue(re.search("Compatible layers",self.driver.find_element(By.XPATH, "//div[@class='col-md-10']").text),'The Compatible layer information is not loading properly') except: self.fail(msg='No Compatible layers tab available') try: - self.driver.find_element_by_xpath("//*[@id='config-nav']/ul/li/a[@href="+'"'+project_URL+'configuration"'+"]").click() - self.assertTrue(re.search("Bitbake variables",self.driver.find_element_by_xpath("//div[@class='col-md-10']").text),'The Bitbake variables information is not loading properly') + self.driver.find_element(By.XPATH, "//*[@id='config-nav']/ul/li/a[@href="+'"'+project_URL+'configuration"'+"]").click() + self.wait_until_present('#config-nav', poll=10) + self.assertTrue(re.search("Bitbake variables",self.driver.find_element(By.XPATH, "//div[@class='col-md-10']").text),'The Bitbake variables information is not loading properly') except: self.fail(msg='No Bitbake variables tab available') # testcase (1516) def test_review_configuration_information(self): - self.get('') - self.driver.find_element_by_xpath("//div[@id='global-nav']/ul/li/a[@href="+'"'+'/toastergui/projects/'+'"'+"]").click() - self.wait_until_visible('#projectstable') + self.get(reverse('all-projects')) + self.wait_until_present('#projectstable', poll=10) self.find_element_by_link_text_in_table('projectstable', 'selenium-project').click() project_URL=self.get_URL() - + self.wait_until_present('#config-nav', poll=10) try: self.assertTrue(self.element_exists('#machine-section'),'Machine section for the project configuration page does not exist') - self.assertTrue(re.search("qemux86",self.driver.find_element_by_xpath("//span[@id='project-machine-name']").text),'The machine type is not assigned') - self.driver.find_element_by_xpath("//span[@id='change-machine-toggle']").click() - self.wait_until_visible('#select-machine-form') - self.wait_until_visible('#cancel-machine-change') - self.driver.find_element_by_xpath("//form[@id='select-machine-form']/a[@id='cancel-machine-change']").click() + self.assertTrue(re.search("qemux86-64",self.driver.find_element(By.XPATH, "//span[@id='project-machine-name']").text),'The machine type is not assigned') + self.driver.find_element(By.XPATH, "//span[@id='change-machine-toggle']").click() + self.wait_until_visible('#select-machine-form', poll=10) + self.wait_until_visible('#cancel-machine-change', poll=10) + self.driver.find_element(By.XPATH, "//form[@id='select-machine-form']/a[@id='cancel-machine-change']").click() except: self.fail(msg='The machine information is wrong in the configuration page') try: - self.driver.find_element_by_id('no-most-built') + self.driver.find_element(By.ID, 'no-most-built') except: self.fail(msg='No Most built information in project detail page') try: - self.assertTrue(re.search("Yocto Project master",self.driver.find_element_by_xpath("//span[@id='project-release-title']").text),'The project release is not defined') + self.assertTrue(re.search("Yocto Project master",self.driver.find_element(By.XPATH, "//span[@id='project-release-title']").text),'The project release is not defined') except: self.fail(msg='No project release title information in project detail page') try: - self.driver.find_element_by_xpath("//div[@id='layer-container']") - self.assertTrue(re.search("3",self.driver.find_element_by_id("project-layers-count").text),'There should be 3 layers listed in the layer count') - layer_list = self.driver.find_element_by_id("layers-in-project-list") - layers = layer_list.find_elements_by_tag_name("li") + self.driver.find_element(By.XPATH, "//div[@id='layer-container']") + self.assertTrue(re.search("3",self.driver.find_element(By.ID, "project-layers-count").text),'There should be 3 layers listed in the layer count') + layer_list = self.driver.find_element(By.ID, "layers-in-project-list") + layers = layer_list.find_elements(By.TAG_NAME, "li") for layer in layers: if re.match ("openembedded-core",layer.text): print ("openembedded-core layer is a default layer in the project configuration") @@ -121,61 +145,60 @@ class FuntionalTestBasic(SeleniumFunctionalTestCase): # testcase (1517) def test_verify_machine_information(self): - self.get('') - self.driver.find_element_by_xpath("//div[@id='global-nav']/ul/li/a[@href="+'"'+'/toastergui/projects/'+'"'+"]").click() - self.wait_until_visible('#projectstable') + self.get(reverse('all-projects')) + self.wait_until_present('#projectstable', poll=10) self.find_element_by_link_text_in_table('projectstable', 'selenium-project').click() + self.wait_until_present('#config-nav', poll=10) try: self.assertTrue(self.element_exists('#machine-section'),'Machine section for the project configuration page does not exist') - self.assertTrue(re.search("qemux86",self.driver.find_element_by_id("project-machine-name").text),'The machine type is not assigned') - self.driver.find_element_by_id("change-machine-toggle").click() - self.wait_until_visible('#select-machine-form') - self.wait_until_visible('#cancel-machine-change') - self.driver.find_element_by_id("cancel-machine-change").click() + self.assertTrue(re.search("qemux86-64",self.driver.find_element(By.ID, "project-machine-name").text),'The machine type is not assigned') + self.driver.find_element(By.ID, "change-machine-toggle").click() + self.wait_until_visible('#select-machine-form', poll=10) + self.wait_until_visible('#cancel-machine-change', poll=10) + self.driver.find_element(By.ID, "cancel-machine-change").click() except: self.fail(msg='The machine information is wrong in the configuration page') # testcase (1518) def test_verify_most_built_recipes_information(self): - self.get('') - self.driver.find_element_by_xpath("//div[@id='global-nav']/ul/li/a[@href="+'"'+'/toastergui/projects/'+'"'+"]").click() - self.wait_until_visible('#projectstable') + self.get(reverse('all-projects')) + self.wait_until_present('#projectstable', poll=10) self.find_element_by_link_text_in_table('projectstable', 'selenium-project').click() + self.wait_until_present('#config-nav', poll=10) project_URL=self.get_URL() - try: - self.assertTrue(re.search("You haven't built any recipes yet",self.driver.find_element_by_id("no-most-built").text),'Default message of no builds is not present') - self.driver.find_element_by_xpath("//div[@id='no-most-built']/p/a[@href="+'"'+project_URL+'images/"'+"]").click() - self.assertTrue(re.search("Compatible image recipes",self.driver.find_element_by_xpath("//div[@class='col-md-10']").text),'The Choose a recipe to build link is not working properly') + self.assertTrue(re.search("You haven't built any recipes yet",self.driver.find_element(By.ID, "no-most-built").text),'Default message of no builds is not present') + self.driver.find_element(By.XPATH, "//div[@id='no-most-built']/p/a[@href="+'"'+project_URL+'images/"'+"]").click() + self.wait_until_present('#config-nav', poll=10) + self.assertTrue(re.search("Compatible image recipes",self.driver.find_element(By.XPATH, "//div[@class='col-md-10']").text),'The Choose a recipe to build link is not working properly') except: self.fail(msg='No Most built information in project detail page') # testcase (1519) def test_verify_project_release_information(self): - self.get('') - self.driver.find_element_by_xpath("//div[@id='global-nav']/ul/li/a[@href="+'"'+'/toastergui/projects/'+'"'+"]").click() - self.wait_until_visible('#projectstable') + self.get(reverse('all-projects')) + self.wait_until_present('#projectstable', poll=10) self.find_element_by_link_text_in_table('projectstable', 'selenium-project').click() + self.wait_until_present('#config-nav', poll=10) try: - self.assertTrue(re.search("Yocto Project master",self.driver.find_element_by_id("project-release-title").text),'The project release is not defined') + self.assertTrue(re.search("Yocto Project master",self.driver.find_element(By.ID, "project-release-title").text),'The project release is not defined') except: self.fail(msg='No project release title information in project detail page') # testcase (1520) def test_verify_layer_information(self): - self.get('') - self.driver.find_element_by_xpath("//div[@id='global-nav']/ul/li/a[@href="+'"'+'/toastergui/projects/'+'"'+"]").click() - self.wait_until_visible('#projectstable') + self.get(reverse('all-projects')) + self.wait_until_present('#projectstable', poll=10) self.find_element_by_link_text_in_table('projectstable', 'selenium-project').click() + self.wait_until_present('#config-nav', poll=10) project_URL=self.get_URL() - try: - self.driver.find_element_by_xpath("//div[@id='layer-container']") - self.assertTrue(re.search("3",self.driver.find_element_by_id("project-layers-count").text),'There should be 3 layers listed in the layer count') - layer_list = self.driver.find_element_by_id("layers-in-project-list") - layers = layer_list.find_elements_by_tag_name("li") + self.driver.find_element(By.XPATH, "//div[@id='layer-container']") + self.assertTrue(re.search("3",self.driver.find_element(By.ID, "project-layers-count").text),'There should be 3 layers listed in the layer count') + layer_list = self.driver.find_element(By.ID, "layers-in-project-list") + layers = layer_list.find_elements(By.TAG_NAME, "li") for layer in layers: if re.match ("openembedded-core",layer.text): @@ -187,43 +210,46 @@ class FuntionalTestBasic(SeleniumFunctionalTestCase): else: self.fail(msg='default layers are missing from the project configuration') - self.driver.find_element_by_xpath("//input[@id='layer-add-input']") - self.driver.find_element_by_xpath("//button[@id='add-layer-btn']") - self.driver.find_element_by_xpath("//div[@id='layer-container']/form[@class='form-inline']/p/a[@id='view-compatible-layers']") - self.driver.find_element_by_xpath("//div[@id='layer-container']/form[@class='form-inline']/p/a[@href="+'"'+project_URL+'importlayer"'+"]") + self.driver.find_element(By.XPATH, "//input[@id='layer-add-input']") + self.driver.find_element(By.XPATH, "//button[@id='add-layer-btn']") + self.driver.find_element(By.XPATH, "//div[@id='layer-container']/form[@class='form-inline']/p/a[@id='view-compatible-layers']") + self.driver.find_element(By.XPATH, "//div[@id='layer-container']/form[@class='form-inline']/p/a[@href="+'"'+project_URL+'importlayer"'+"]") except: self.fail(msg='No Layer information in project detail page') # testcase (1521) def test_verify_project_detail_links(self): - self.get('') - self.driver.find_element_by_xpath("//div[@id='global-nav']/ul/li/a[@href="+'"'+'/toastergui/projects/'+'"'+"]").click() - self.wait_until_visible('#projectstable') + self.get(reverse('all-projects')) + self.wait_until_present('#projectstable', poll=10) self.find_element_by_link_text_in_table('projectstable', 'selenium-project').click() + self.wait_until_present('#config-nav', poll=10) project_URL=self.get_URL() - - self.driver.find_element_by_xpath("//div[@id='project-topbar']/ul[@class='nav nav-tabs']/li[@id='topbar-configuration-tab']/a[@href="+'"'+project_URL+'"'+"]").click() - self.assertTrue(re.search("Configuration",self.driver.find_element_by_xpath("//div[@id='project-topbar']/ul[@class='nav nav-tabs']/li[@id='topbar-configuration-tab']/a[@href="+'"'+project_URL+'"'+"]").text), 'Configuration tab in project topbar is misspelled') + self.driver.find_element(By.XPATH, "//div[@id='project-topbar']/ul[@class='nav nav-tabs']/li[@id='topbar-configuration-tab']/a[@href="+'"'+project_URL+'"'+"]").click() + self.wait_until_present('#config-nav', poll=10) + self.assertTrue(re.search("Configuration",self.driver.find_element(By.XPATH, "//div[@id='project-topbar']/ul[@class='nav nav-tabs']/li[@id='topbar-configuration-tab']/a[@href="+'"'+project_URL+'"'+"]").text), 'Configuration tab in project topbar is misspelled') try: - self.driver.find_element_by_xpath("//div[@id='project-topbar']/ul[@class='nav nav-tabs']/li/a[@href="+'"'+project_URL+'builds/"'+"]").click() - self.assertTrue(re.search("Builds",self.driver.find_element_by_xpath("//div[@id='project-topbar']/ul[@class='nav nav-tabs']/li/a[@href="+'"'+project_URL+'builds/"'+"]").text), 'Builds tab in project topbar is misspelled') - self.driver.find_element_by_xpath("//div[@id='empty-state-projectbuildstable']") + self.driver.find_element(By.XPATH, "//div[@id='project-topbar']/ul[@class='nav nav-tabs']/li/a[@href="+'"'+project_URL+'builds/"'+"]").click() + self.wait_until_visible('#project-topbar', poll=10) + self.assertTrue(re.search("Builds",self.driver.find_element(By.XPATH, "//div[@id='project-topbar']/ul[@class='nav nav-tabs']/li/a[@href="+'"'+project_URL+'builds/"'+"]").text), 'Builds tab in project topbar is misspelled') + self.driver.find_element(By.XPATH, "//div[@id='empty-state-projectbuildstable']") except: self.fail(msg='Builds tab information is not present') try: - self.driver.find_element_by_xpath("//div[@id='project-topbar']/ul[@class='nav nav-tabs']/li/a[@href="+'"'+project_URL+'importlayer"'+"]").click() - self.assertTrue(re.search("Import layer",self.driver.find_element_by_xpath("//div[@id='project-topbar']/ul[@class='nav nav-tabs']/li/a[@href="+'"'+project_URL+'importlayer"'+"]").text), 'Import layer tab in project topbar is misspelled') - self.driver.find_element_by_xpath("//fieldset[@id='repo-select']") - self.driver.find_element_by_xpath("//fieldset[@id='git-repo']") + self.driver.find_element(By.XPATH, "//div[@id='project-topbar']/ul[@class='nav nav-tabs']/li/a[@href="+'"'+project_URL+'importlayer"'+"]").click() + self.wait_until_visible('#project-topbar', poll=10) + self.assertTrue(re.search("Import layer",self.driver.find_element(By.XPATH, "//div[@id='project-topbar']/ul[@class='nav nav-tabs']/li/a[@href="+'"'+project_URL+'importlayer"'+"]").text), 'Import layer tab in project topbar is misspelled') + self.driver.find_element(By.XPATH, "//fieldset[@id='repo-select']") + self.driver.find_element(By.XPATH, "//fieldset[@id='git-repo']") except: self.fail(msg='Import layer tab not loading properly') try: - self.driver.find_element_by_xpath("//div[@id='project-topbar']/ul[@class='nav nav-tabs']/li/a[@href="+'"'+project_URL+'newcustomimage/"'+"]").click() - self.assertTrue(re.search("New custom image",self.driver.find_element_by_xpath("//div[@id='project-topbar']/ul[@class='nav nav-tabs']/li/a[@href="+'"'+project_URL+'newcustomimage/"'+"]").text), 'New custom image tab in project topbar is misspelled') - self.assertTrue(re.search("Select the image recipe you want to customise",self.driver.find_element_by_xpath("//div[@class='col-md-12']/h2").text),'The new custom image tab is not loading correctly') + self.driver.find_element(By.XPATH, "//div[@id='project-topbar']/ul[@class='nav nav-tabs']/li/a[@href="+'"'+project_URL+'newcustomimage/"'+"]").click() + self.wait_until_visible('#project-topbar', poll=10) + self.assertTrue(re.search("New custom image",self.driver.find_element(By.XPATH, "//div[@id='project-topbar']/ul[@class='nav nav-tabs']/li/a[@href="+'"'+project_URL+'newcustomimage/"'+"]").text), 'New custom image tab in project topbar is misspelled') + self.assertTrue(re.search("Select the image recipe you want to customise",self.driver.find_element(By.XPATH, "//div[@class='col-md-12']/h2").text),'The new custom image tab is not loading correctly') except: self.fail(msg='New custom image tab not loading properly') diff --git a/lib/toaster/tests/functional/test_project_config.py b/lib/toaster/tests/functional/test_project_config.py new file mode 100644 index 000000000..dbee36aa4 --- /dev/null +++ b/lib/toaster/tests/functional/test_project_config.py @@ -0,0 +1,341 @@ +#! /usr/bin/env python3 # +# BitBake Toaster UI tests implementation +# +# Copyright (C) 2023 Savoir-faire Linux +# +# SPDX-License-Identifier: GPL-2.0-only +# + +import string +import random +import pytest +from django.urls import reverse +from selenium.webdriver import Keys +from selenium.webdriver.support.select import Select +from selenium.common.exceptions import TimeoutException +from tests.functional.functional_helpers import SeleniumFunctionalTestCase +from selenium.webdriver.common.by import By + +from .utils import get_projectId_from_url + + +@pytest.mark.django_db +@pytest.mark.order("last") +class TestProjectConfig(SeleniumFunctionalTestCase): + project_id = None + PROJECT_NAME = 'TestProjectConfig' + INVALID_PATH_START_TEXT = 'The directory path should either start with a /' + INVALID_PATH_CHAR_TEXT = 'The directory path cannot include spaces or ' \ + 'any of these characters' + + def _create_project(self, project_name): + """ Create/Test new project using: + - Project Name: Any string + - Release: Any string + - Merge Toaster settings: True or False + """ + self.get(reverse('newproject')) + self.wait_until_visible('#new-project-name', poll=2) + self.find("#new-project-name").send_keys(project_name) + select = Select(self.find("#projectversion")) + select.select_by_value('3') + + # check merge toaster settings + checkbox = self.find('.checkbox-mergeattr') + if not checkbox.is_selected(): + checkbox.click() + + if self.PROJECT_NAME != 'TestProjectConfig': + # Reset project name if it's not the default one + self.PROJECT_NAME = 'TestProjectConfig' + + self.find("#create-project-button").click() + + try: + self.wait_until_visible('#hint-error-project-name', poll=2) + url = reverse('project', args=(TestProjectConfig.project_id, )) + self.get(url) + self.wait_until_visible('#config-nav', poll=3) + except TimeoutException: + self.wait_until_visible('#config-nav', poll=3) + + def _random_string(self, length): + return ''.join( + random.choice(string.ascii_letters) for _ in range(length) + ) + + def _get_config_nav_item(self, index): + config_nav = self.find('#config-nav') + return config_nav.find_elements(By.TAG_NAME, 'li')[index] + + def _navigate_bbv_page(self): + """ Navigate to project BitBake variables page """ + # check if the menu is displayed + if TestProjectConfig.project_id is None: + self._create_project(project_name=self._random_string(10)) + current_url = self.driver.current_url + TestProjectConfig.project_id = get_projectId_from_url(current_url) + else: + url = reverse('projectconf', args=(TestProjectConfig.project_id,)) + self.get(url) + self.wait_until_visible('#config-nav', poll=3) + bbv_page_link = self._get_config_nav_item(9) + bbv_page_link.click() + self.wait_until_visible('#config-nav', poll=3) + + def test_no_underscore_iamgefs_type(self): + """ + Should not accept IMAGEFS_TYPE with an underscore + """ + self._navigate_bbv_page() + imagefs_type = "foo_bar" + + self.wait_until_visible('#change-image_fstypes-icon', poll=2) + + self.click('#change-image_fstypes-icon') + + self.enter_text('#new-imagefs_types', imagefs_type) + + element = self.wait_until_visible('#hintError-image-fs_type', poll=2) + + self.assertTrue(("A valid image type cannot include underscores" in element.text), + "Did not find underscore error message") + + def test_checkbox_verification(self): + """ + Should automatically check the checkbox if user enters value + text box, if value is there in the checkbox. + """ + self._navigate_bbv_page() + + imagefs_type = "btrfs" + + self.wait_until_visible('#change-image_fstypes-icon', poll=2) + + self.click('#change-image_fstypes-icon') + + self.enter_text('#new-imagefs_types', imagefs_type) + + checkboxes = self.driver.find_elements(By.XPATH, "//input[@class='fs-checkbox-fstypes']") + + for checkbox in checkboxes: + if checkbox.get_attribute("value") == "btrfs": + self.assertEqual(checkbox.is_selected(), True) + + def test_textbox_with_checkbox_verification(self): + """ + Should automatically add or remove value in textbox, if user checks + or unchecks checkboxes. + """ + self._navigate_bbv_page() + + self.wait_until_visible('#change-image_fstypes-icon', poll=2) + + self.click('#change-image_fstypes-icon') + + checkboxes_selector = '.fs-checkbox-fstypes' + + self.wait_until_visible(checkboxes_selector, poll=2) + checkboxes = self.find_all(checkboxes_selector) + + for checkbox in checkboxes: + if checkbox.get_attribute("value") == "cpio": + checkbox.click() + element = self.driver.find_element(By.ID, 'new-imagefs_types') + + self.wait_until_visible('#new-imagefs_types', poll=2) + + self.assertTrue(("cpio" in element.get_attribute('value'), + "Imagefs not added into the textbox")) + checkbox.click() + self.assertTrue(("cpio" not in element.text), + "Image still present in the textbox") + + def test_set_download_dir(self): + """ + Validate the allowed and disallowed types in the directory field for + DL_DIR + """ + self._navigate_bbv_page() + + # activate the input to edit download dir + try: + change_dl_dir_btn = self.wait_until_visible('#change-dl_dir-icon', poll=2) + except TimeoutException: + # If download dir is not displayed, test is skipped + change_dl_dir_btn = None + + if change_dl_dir_btn: + change_dl_dir_btn = self.wait_until_visible('#change-dl_dir-icon', poll=2) + change_dl_dir_btn.click() + + # downloads dir path doesn't start with / or ${...} + input_field = self.wait_until_visible('#new-dl_dir', poll=2) + input_field.clear() + self.enter_text('#new-dl_dir', 'home/foo') + element = self.wait_until_visible('#hintError-initialChar-dl_dir', poll=2) + + msg = 'downloads directory path starts with invalid character but ' \ + 'treated as valid' + self.assertTrue((self.INVALID_PATH_START_TEXT in element.text), msg) + + # downloads dir path has a space + self.driver.find_element(By.ID, 'new-dl_dir').clear() + self.enter_text('#new-dl_dir', '/foo/bar a') + + element = self.wait_until_visible('#hintError-dl_dir', poll=2) + msg = 'downloads directory path characters invalid but treated as valid' + self.assertTrue((self.INVALID_PATH_CHAR_TEXT in element.text), msg) + + # downloads dir path starts with ${...} but has a space + self.driver.find_element(By.ID,'new-dl_dir').clear() + self.enter_text('#new-dl_dir', '${TOPDIR}/down foo') + + element = self.wait_until_visible('#hintError-dl_dir', poll=2) + msg = 'downloads directory path characters invalid but treated as valid' + self.assertTrue((self.INVALID_PATH_CHAR_TEXT in element.text), msg) + + # downloads dir path starts with / + self.driver.find_element(By.ID,'new-dl_dir').clear() + self.enter_text('#new-dl_dir', '/bar/foo') + + hidden_element = self.driver.find_element(By.ID,'hintError-dl_dir') + self.assertEqual(hidden_element.is_displayed(), False, + 'downloads directory path valid but treated as invalid') + + # downloads dir path starts with ${...} + self.driver.find_element(By.ID,'new-dl_dir').clear() + self.enter_text('#new-dl_dir', '${TOPDIR}/down') + + hidden_element = self.driver.find_element(By.ID,'hintError-dl_dir') + self.assertEqual(hidden_element.is_displayed(), False, + 'downloads directory path valid but treated as invalid') + + def test_set_sstate_dir(self): + """ + Validate the allowed and disallowed types in the directory field for + SSTATE_DIR + """ + self._navigate_bbv_page() + + try: + btn_chg_sstate_dir = self.wait_until_visible( + '#change-sstate_dir-icon', + poll=2 + ) + self.click('#change-sstate_dir-icon') + except TimeoutException: + # If sstate_dir is not displayed, test is skipped + btn_chg_sstate_dir = None + + if btn_chg_sstate_dir: # Skip continuation if sstate_dir is not displayed + # path doesn't start with / or ${...} + input_field = self.wait_until_visible('#new-sstate_dir', poll=2) + input_field.clear() + self.enter_text('#new-sstate_dir', 'home/foo') + element = self.wait_until_visible('#hintError-initialChar-sstate_dir', poll=2) + + msg = 'sstate directory path starts with invalid character but ' \ + 'treated as valid' + self.assertTrue((self.INVALID_PATH_START_TEXT in element.text), msg) + + # path has a space + self.driver.find_element(By.ID, 'new-sstate_dir').clear() + self.enter_text('#new-sstate_dir', '/foo/bar a') + + element = self.wait_until_visible('#hintError-sstate_dir', poll=2) + msg = 'sstate directory path characters invalid but treated as valid' + self.assertTrue((self.INVALID_PATH_CHAR_TEXT in element.text), msg) + + # path starts with ${...} but has a space + self.driver.find_element(By.ID,'new-sstate_dir').clear() + self.enter_text('#new-sstate_dir', '${TOPDIR}/down foo') + + element = self.wait_until_visible('#hintError-sstate_dir', poll=2) + msg = 'sstate directory path characters invalid but treated as valid' + self.assertTrue((self.INVALID_PATH_CHAR_TEXT in element.text), msg) + + # path starts with / + self.driver.find_element(By.ID,'new-sstate_dir').clear() + self.enter_text('#new-sstate_dir', '/bar/foo') + + hidden_element = self.driver.find_element(By.ID, 'hintError-sstate_dir') + self.assertEqual(hidden_element.is_displayed(), False, + 'sstate directory path valid but treated as invalid') + + # paths starts with ${...} + self.driver.find_element(By.ID, 'new-sstate_dir').clear() + self.enter_text('#new-sstate_dir', '${TOPDIR}/down') + + hidden_element = self.driver.find_element(By.ID, 'hintError-sstate_dir') + self.assertEqual(hidden_element.is_displayed(), False, + 'sstate directory path valid but treated as invalid') + + def _change_bbv_value(self, **kwargs): + var_name, field, btn_id, input_id, value, save_btn, *_ = kwargs.values() + """ Change bitbake variable value """ + self._navigate_bbv_page() + self.wait_until_visible(f'#{btn_id}', poll=2) + if kwargs.get('new_variable'): + self.find(f"#{btn_id}").clear() + self.enter_text(f"#{btn_id}", f"{var_name}") + else: + self.click(f'#{btn_id}') + self.wait_until_visible(f'#{input_id}', poll=2) + + if kwargs.get('is_select'): + select = Select(self.find(f'#{input_id}')) + select.select_by_visible_text(value) + else: + self.find(f"#{input_id}").clear() + self.enter_text(f'#{input_id}', f'{value}') + self.click(f'#{save_btn}') + value_displayed = str(self.wait_until_visible(f'#{field}').text).lower() + msg = f'{var_name} variable not changed' + self.assertTrue(str(value).lower() in value_displayed, msg) + + def test_change_distro_var(self): + """ Test changing distro variable """ + self._change_bbv_value( + var_name='DISTRO', + field='distro', + btn_id='change-distro-icon', + input_id='new-distro', + value='poky-changed', + save_btn="apply-change-distro", + ) + + def test_set_image_install_append_var(self): + """ Test setting IMAGE_INSTALL:append variable """ + self._change_bbv_value( + var_name='IMAGE_INSTALL:append', + field='image_install', + btn_id='change-image_install-icon', + input_id='new-image_install', + value='bash, apt, busybox', + save_btn="apply-change-image_install", + ) + + def test_set_package_classes_var(self): + """ Test setting PACKAGE_CLASSES variable """ + self._change_bbv_value( + var_name='PACKAGE_CLASSES', + field='package_classes', + btn_id='change-package_classes-icon', + input_id='package_classes-select', + value='package_deb', + save_btn="apply-change-package_classes", + is_select=True, + ) + + def test_create_new_bbv(self): + """ Test creating new bitbake variable """ + self._change_bbv_value( + var_name='New_Custom_Variable', + field='configvar-list', + btn_id='variable', + input_id='value', + value='new variable value', + save_btn="add-configvar-button", + new_variable=True + ) diff --git a/lib/toaster/tests/functional/test_project_page.py b/lib/toaster/tests/functional/test_project_page.py new file mode 100644 index 000000000..0e36b44ff --- /dev/null +++ b/lib/toaster/tests/functional/test_project_page.py @@ -0,0 +1,792 @@ +#! /usr/bin/env python3 # +# BitBake Toaster UI tests implementation +# +# Copyright (C) 2023 Savoir-faire Linux +# +# SPDX-License-Identifier: GPL-2.0-only +# + +import os +import random +import string +from unittest import skip +import pytest +from django.urls import reverse +from django.utils import timezone +from selenium.webdriver.common.keys import Keys +from selenium.webdriver.support.select import Select +from selenium.common.exceptions import TimeoutException +from tests.functional.functional_helpers import SeleniumFunctionalTestCase +from orm.models import Build, Project, Target +from selenium.webdriver.common.by import By + +from .utils import get_projectId_from_url, wait_until_build, wait_until_build_cancelled + + +@pytest.mark.django_db +@pytest.mark.order("last") +class TestProjectPage(SeleniumFunctionalTestCase): + project_id = None + PROJECT_NAME = 'TestProjectPage' + + def _create_project(self, project_name): + """ Create/Test new project using: + - Project Name: Any string + - Release: Any string + - Merge Toaster settings: True or False + """ + self.get(reverse('newproject')) + self.wait_until_visible('#new-project-name') + self.find("#new-project-name").send_keys(project_name) + select = Select(self.find("#projectversion")) + select.select_by_value('3') + + # check merge toaster settings + checkbox = self.find('.checkbox-mergeattr') + if not checkbox.is_selected(): + checkbox.click() + + if self.PROJECT_NAME != 'TestProjectPage': + # Reset project name if it's not the default one + self.PROJECT_NAME = 'TestProjectPage' + + self.find("#create-project-button").click() + + try: + self.wait_until_visible('#hint-error-project-name') + url = reverse('project', args=(TestProjectPage.project_id, )) + self.get(url) + self.wait_until_visible('#config-nav', poll=3) + except TimeoutException: + self.wait_until_visible('#config-nav', poll=3) + + def _random_string(self, length): + return ''.join( + random.choice(string.ascii_letters) for _ in range(length) + ) + + def _navigate_to_project_page(self): + # Navigate to project page + if TestProjectPage.project_id is None: + self._create_project(project_name=self._random_string(10)) + current_url = self.driver.current_url + TestProjectPage.project_id = get_projectId_from_url(current_url) + else: + url = reverse('project', args=(TestProjectPage.project_id,)) + self.get(url) + self.wait_until_visible('#config-nav') + + def _get_create_builds(self, **kwargs): + """ Create a build and return the build object """ + # parameters for builds to associate with the projects + now = timezone.now() + self.project1_build_success = { + 'project': Project.objects.get(id=TestProjectPage.project_id), + 'started_on': now, + 'completed_on': now, + 'outcome': Build.SUCCEEDED + } + + self.project1_build_failure = { + 'project': Project.objects.get(id=TestProjectPage.project_id), + 'started_on': now, + 'completed_on': now, + 'outcome': Build.FAILED + } + build1 = Build.objects.create(**self.project1_build_success) + build2 = Build.objects.create(**self.project1_build_failure) + + # add some targets to these builds so they have recipe links + # (and so we can find the row in the ToasterTable corresponding to + # a particular build) + Target.objects.create(build=build1, target='foo') + Target.objects.create(build=build2, target='bar') + + if kwargs: + # Create kwargs.get('success') builds with success status with target + # and kwargs.get('failure') builds with failure status with target + for i in range(kwargs.get('success', 0)): + now = timezone.now() + self.project1_build_success['started_on'] = now + self.project1_build_success[ + 'completed_on'] = now - timezone.timedelta(days=i) + build = Build.objects.create(**self.project1_build_success) + Target.objects.create(build=build, + target=f'{i}_success_recipe', + task=f'{i}_success_task') + + for i in range(kwargs.get('failure', 0)): + now = timezone.now() + self.project1_build_failure['started_on'] = now + self.project1_build_failure[ + 'completed_on'] = now - timezone.timedelta(days=i) + build = Build.objects.create(**self.project1_build_failure) + Target.objects.create(build=build, + target=f'{i}_fail_recipe', + task=f'{i}_fail_task') + return build1, build2 + + def _mixin_test_table_edit_column( + self, + table_id, + edit_btn_id, + list_check_box_id: list + ): + # Check edit column + edit_column = self.find(f'#{edit_btn_id}') + self.assertTrue(edit_column.is_displayed()) + edit_column.click() + # Check dropdown is visible + self.wait_until_visible('ul.dropdown-menu.editcol') + for check_box_id in list_check_box_id: + # Check that we can hide/show table column + check_box = self.find(f'#{check_box_id}') + th_class = str(check_box_id).replace('checkbox-', '') + if check_box.is_selected(): + # check if column is visible in table + self.assertTrue( + self.find( + f'#{table_id} thead th.{th_class}' + ).is_displayed(), + f"The {th_class} column is checked in EditColumn dropdown, but it's not visible in table" + ) + check_box.click() + # check if column is hidden in table + self.assertFalse( + self.find( + f'#{table_id} thead th.{th_class}' + ).is_displayed(), + f"The {th_class} column is unchecked in EditColumn dropdown, but it's visible in table" + ) + else: + # check if column is hidden in table + self.assertFalse( + self.find( + f'#{table_id} thead th.{th_class}' + ).is_displayed(), + f"The {th_class} column is unchecked in EditColumn dropdown, but it's visible in table" + ) + check_box.click() + # check if column is visible in table + self.assertTrue( + self.find( + f'#{table_id} thead th.{th_class}' + ).is_displayed(), + f"The {th_class} column is checked in EditColumn dropdown, but it's not visible in table" + ) + + def _get_config_nav_item(self, index): + config_nav = self.find('#config-nav') + return config_nav.find_elements(By.TAG_NAME, 'li')[index] + + def _navigate_to_config_nav(self, nav_id, nav_index): + # navigate to the project page + self._navigate_to_project_page() + # click on "Software recipe" tab + soft_recipe = self._get_config_nav_item(nav_index) + soft_recipe.click() + self.wait_until_visible(f'#{nav_id}') + + def _mixin_test_table_show_rows(self, table_selector, **kwargs): + """ Test the show rows feature in the builds table on the all builds page """ + def test_show_rows(row_to_show, show_row_link): + # Check that we can show rows == row_to_show + show_row_link.select_by_value(str(row_to_show)) + self.wait_until_visible(f'#{table_selector} tbody tr', poll=3) + # check at least some rows are visible + self.assertTrue( + len(self.find_all(f'#{table_selector} tbody tr')) > 0 + ) + self.wait_until_present(f'#{table_selector} tbody tr') + show_rows = self.driver.find_elements( + By.XPATH, + f'//select[@class="form-control pagesize-{table_selector}"]' + ) + rows_to_show = [10, 25, 50, 100, 150] + to_skip = kwargs.get('to_skip', []) + # Check show rows + for show_row_link in show_rows: + show_row_link = Select(show_row_link) + for row_to_show in rows_to_show: + if row_to_show not in to_skip: + test_show_rows(row_to_show, show_row_link) + + def _mixin_test_table_search_input(self, **kwargs): + input_selector, input_text, searchBtn_selector, table_selector, *_ = kwargs.values() + # Test search input + self.wait_until_visible(f'#{input_selector}') + recipe_input = self.find(f'#{input_selector}') + recipe_input.send_keys(input_text) + self.find(f'#{searchBtn_selector}').click() + self.wait_until_visible(f'#{table_selector} tbody tr') + rows = self.find_all(f'#{table_selector} tbody tr') + self.assertTrue(len(rows) > 0) + + def test_create_project(self): + """ Create/Test new project using: + - Project Name: Any string + - Release: Any string + - Merge Toaster settings: True or False + """ + self._create_project(project_name=self.PROJECT_NAME) + + def test_image_recipe_editColumn(self): + """ Test the edit column feature in image recipe table on project page """ + self._get_create_builds(success=10, failure=10) + + url = reverse('projectimagerecipes', args=(TestProjectPage.project_id,)) + self.get(url) + self.wait_until_present('#imagerecipestable tbody tr') + + column_list = [ + 'get_description_or_summary', 'layer_version__get_vcs_reference', + 'layer_version__layer__name', 'license', 'recipe-file', 'section', + 'version' + ] + + # Check that we can hide the edit column + self._mixin_test_table_edit_column( + 'imagerecipestable', + 'edit-columns-button', + [f'checkbox-{column}' for column in column_list] + ) + + def test_page_header_on_project_page(self): + """ Check page header in project page: + - AT LEFT -> Logo of Yocto project, displayed, clickable + - "Toaster"+" Information icon", displayed, clickable + - "Server Icon" + "All builds", displayed, clickable + - "Directory Icon" + "All projects", displayed, clickable + - "Book Icon" + "Documentation", displayed, clickable + - AT RIGHT -> button "New project", displayed, clickable + """ + # navigate to the project page + self._navigate_to_project_page() + + # check page header + # AT LEFT -> Logo of Yocto project + logo = self.driver.find_element( + By.XPATH, + "//div[@class='toaster-navbar-brand']", + ) + logo_img = logo.find_element(By.TAG_NAME, 'img') + self.assertTrue(logo_img.is_displayed(), + 'Logo of Yocto project not found') + self.assertTrue( + '/static/img/logo.png' in str(logo_img.get_attribute('src')), + 'Logo of Yocto project not found' + ) + # "Toaster"+" Information icon", clickable + toaster = self.driver.find_element( + By.XPATH, + "//div[@class='toaster-navbar-brand']//a[@class='brand']", + ) + self.assertTrue(toaster.is_displayed(), 'Toaster not found') + self.assertTrue(toaster.text == 'Toaster') + info_sign = self.find('.glyphicon-info-sign') + self.assertTrue(info_sign.is_displayed()) + + # "Server Icon" + "All builds" + all_builds = self.find('#navbar-all-builds') + all_builds_link = all_builds.find_element(By.TAG_NAME, 'a') + self.assertTrue("All builds" in all_builds_link.text) + self.assertTrue( + '/toastergui/builds/' in str(all_builds_link.get_attribute('href')) + ) + server_icon = all_builds.find_element(By.TAG_NAME, 'i') + self.assertTrue( + server_icon.get_attribute('class') == 'glyphicon glyphicon-tasks' + ) + self.assertTrue(server_icon.is_displayed()) + + # "Directory Icon" + "All projects" + all_projects = self.find('#navbar-all-projects') + all_projects_link = all_projects.find_element(By.TAG_NAME, 'a') + self.assertTrue("All projects" in all_projects_link.text) + self.assertTrue( + '/toastergui/projects/' in str(all_projects_link.get_attribute( + 'href')) + ) + dir_icon = all_projects.find_element(By.TAG_NAME, 'i') + self.assertTrue( + dir_icon.get_attribute('class') == 'icon-folder-open' + ) + self.assertTrue(dir_icon.is_displayed()) + + # "Book Icon" + "Documentation" + toaster_docs_link = self.find('#navbar-docs') + toaster_docs_link_link = toaster_docs_link.find_element(By.TAG_NAME, + 'a') + self.assertTrue("Documentation" in toaster_docs_link_link.text) + self.assertTrue( + toaster_docs_link_link.get_attribute('href') == 'http://docs.yoctoproject.org/toaster-manual/index.html#toaster-user-manual' + ) + book_icon = toaster_docs_link.find_element(By.TAG_NAME, 'i') + self.assertTrue( + book_icon.get_attribute('class') == 'glyphicon glyphicon-book' + ) + self.assertTrue(book_icon.is_displayed()) + + # AT RIGHT -> button "New project" + new_project_button = self.find('#new-project-button') + self.assertTrue(new_project_button.is_displayed()) + self.assertTrue(new_project_button.text == 'New project') + new_project_button.click() + self.assertTrue( + '/toastergui/newproject/' in str(self.driver.current_url) + ) + + def test_edit_project_name(self): + """ Test edit project name: + - Click on "Edit" icon button + - Change project name + - Click on "Save" button + - Check project name is changed + """ + # navigate to the project page + self._navigate_to_project_page() + + # click on "Edit" icon button + self.wait_until_visible('#project-name-container') + edit_button = self.find('#project-change-form-toggle') + edit_button.click() + project_name_input = self.find('#project-name-change-input') + self.assertTrue(project_name_input.is_displayed()) + project_name_input.clear() + project_name_input.send_keys('New Name') + self.find('#project-name-change-btn').click() + + # check project name is changed + self.wait_until_visible('#project-name-container') + self.assertTrue( + 'New Name' in str(self.find('#project-name-container').text) + ) + + def test_project_page_tabs(self): + """ Test project tabs: + - "configuration" tab + - "Builds" tab + - "Import layers" tab + - "New custom image" tab + Check search box used to build recipes + """ + # navigate to the project page + self._navigate_to_project_page() + + # check "configuration" tab + self.wait_until_visible('#topbar-configuration-tab') + config_tab = self.find('#topbar-configuration-tab') + self.assertTrue(config_tab.get_attribute('class') == 'active') + self.assertTrue('Configuration' in str(config_tab.text)) + self.assertTrue( + f"/toastergui/project/{TestProjectPage.project_id}" in str(self.driver.current_url) + ) + + def get_tabs(): + # tabs links list + return self.driver.find_elements( + By.XPATH, + '//div[@id="project-topbar"]//li' + ) + + def check_tab_link(tab_index, tab_name, url): + tab = get_tabs()[tab_index] + tab_link = tab.find_element(By.TAG_NAME, 'a') + self.assertTrue(url in tab_link.get_attribute('href')) + self.assertTrue(tab_name in tab_link.text) + self.assertTrue(tab.get_attribute('class') == 'active') + + # check "Builds" tab + builds_tab = get_tabs()[1] + builds_tab.find_element(By.TAG_NAME, 'a').click() + check_tab_link( + 1, + 'Builds', + f"/toastergui/project/{TestProjectPage.project_id}/builds" + ) + + # check "Import layers" tab + import_layers_tab = get_tabs()[2] + import_layers_tab.find_element(By.TAG_NAME, 'a').click() + check_tab_link( + 2, + 'Import layer', + f"/toastergui/project/{TestProjectPage.project_id}/importlayer" + ) + + # check "New custom image" tab + new_custom_image_tab = get_tabs()[3] + new_custom_image_tab.find_element(By.TAG_NAME, 'a').click() + check_tab_link( + 3, + 'New custom image', + f"/toastergui/project/{TestProjectPage.project_id}/newcustomimage" + ) + + # check search box can be use to build recipes + search_box = self.find('#build-input') + search_box.send_keys('core-image-minimal') + self.find('#build-button').click() + self.wait_until_visible('#latest-builds') + lastest_builds = self.driver.find_elements( + By.XPATH, + '//div[@id="latest-builds"]', + ) + last_build = lastest_builds[0] + self.assertTrue( + 'core-image-minimal' in str(last_build.text) + ) + + def test_softwareRecipe_page(self): + """ Test software recipe page + - Check title "Compatible software recipes" is displayed + - Check search input + - Check "build recipe" button works + - Check software recipe table feature(show/hide column, pagination) + """ + self._navigate_to_config_nav('softwarerecipestable', 4) + # check title "Compatible software recipes" is displayed + self.assertTrue("Compatible software recipes" in self.get_page_source()) + # Test search input + self._mixin_test_table_search_input( + input_selector='search-input-softwarerecipestable', + input_text='busybox', + searchBtn_selector='search-submit-softwarerecipestable', + table_selector='softwarerecipestable' + ) + # check "build recipe" button works + rows = self.find_all('#softwarerecipestable tbody tr') + image_to_build = rows[0] + build_btn = image_to_build.find_element( + By.XPATH, + '//td[@class="add-del-layers"]//a[1]' + ) + build_btn.click() + build_state = wait_until_build(self, 'queued cloning starting parsing failed') + lastest_builds = self.driver.find_elements( + By.XPATH, + '//div[@id="latest-builds"]/div' + ) + self.assertTrue(len(lastest_builds) > 0) + last_build = lastest_builds[0] + cancel_button = last_build.find_element( + By.XPATH, + '//span[@class="cancel-build-btn pull-right alert-link"]', + ) + cancel_button.click() + if 'starting' not in build_state: # change build state when cancelled in starting state + wait_until_build_cancelled(self) + + # check software recipe table feature(show/hide column, pagination) + self._navigate_to_config_nav('softwarerecipestable', 4) + column_list = [ + 'get_description_or_summary', + 'layer_version__get_vcs_reference', + 'layer_version__layer__name', + 'license', + 'recipe-file', + 'section', + 'version', + ] + self._mixin_test_table_edit_column( + 'softwarerecipestable', + 'edit-columns-button', + [f'checkbox-{column}' for column in column_list] + ) + self._navigate_to_config_nav('softwarerecipestable', 4) + # check show rows(pagination) + self._mixin_test_table_show_rows( + table_selector='softwarerecipestable', + to_skip=[150], + ) + + def test_machines_page(self): + """ Test Machine page + - Check if title "Compatible machines" is displayed + - Check search input + - Check "Select machine" button works + - Check "Add layer" button works + - Check Machine table feature(show/hide column, pagination) + """ + self._navigate_to_config_nav('machinestable', 5) + # check title "Compatible software recipes" is displayed + self.assertTrue("Compatible machines" in self.get_page_source()) + # Test search input + self._mixin_test_table_search_input( + input_selector='search-input-machinestable', + input_text='qemux86-64', + searchBtn_selector='search-submit-machinestable', + table_selector='machinestable' + ) + # check "Select machine" button works + rows = self.find_all('#machinestable tbody tr') + machine_to_select = rows[0] + select_btn = machine_to_select.find_element( + By.XPATH, + '//td[@class="add-del-layers"]//a[1]' + ) + select_btn.send_keys(Keys.RETURN) + self.wait_until_visible('#config-nav') + project_machine_name = self.find('#project-machine-name') + self.assertTrue( + 'qemux86-64' in project_machine_name.text + ) + # check "Add layer" button works + self._navigate_to_config_nav('machinestable', 5) + # Search for a machine whit layer not in project + self._mixin_test_table_search_input( + input_selector='search-input-machinestable', + input_text='qemux86-64-tpm2', + searchBtn_selector='search-submit-machinestable', + table_selector='machinestable' + ) + self.wait_until_visible('#machinestable tbody tr', poll=3) + rows = self.find_all('#machinestable tbody tr') + machine_to_add = rows[0] + add_btn = machine_to_add.find_element(By.XPATH, '//td[@class="add-del-layers"]') + add_btn.click() + self.wait_until_visible('#change-notification') + change_notification = self.find('#change-notification') + self.assertTrue( + f'You have added 1 layer to your project' in str(change_notification.text) + ) + # check Machine table feature(show/hide column, pagination) + self._navigate_to_config_nav('machinestable', 5) + column_list = [ + 'description', + 'layer_version__get_vcs_reference', + 'layer_version__layer__name', + 'machinefile', + ] + self._mixin_test_table_edit_column( + 'machinestable', + 'edit-columns-button', + [f'checkbox-{column}' for column in column_list] + ) + self._navigate_to_config_nav('machinestable', 5) + # check show rows(pagination) + self._mixin_test_table_show_rows( + table_selector='machinestable', + to_skip=[150], + ) + + def test_layers_page(self): + """ Test layers page + - Check if title "Compatible layerss" is displayed + - Check search input + - Check "Add layer" button works + - Check "Remove layer" button works + - Check layers table feature(show/hide column, pagination) + """ + self._navigate_to_config_nav('layerstable', 6) + # check title "Compatible layers" is displayed + self.assertTrue("Compatible layers" in self.get_page_source()) + # Test search input + input_text='meta-tanowrt' + self._mixin_test_table_search_input( + input_selector='search-input-layerstable', + input_text=input_text, + searchBtn_selector='search-submit-layerstable', + table_selector='layerstable' + ) + # check "Add layer" button works + self.wait_until_visible('#layerstable tbody tr', poll=3) + rows = self.find_all('#layerstable tbody tr') + layer_to_add = rows[0] + add_btn = layer_to_add.find_element( + By.XPATH, + '//td[@class="add-del-layers"]' + ) + add_btn.click() + # check modal is displayed + self.wait_until_visible('#dependencies-modal', poll=3) + list_dependencies = self.find_all('#dependencies-list li') + # click on add-layers button + add_layers_btn = self.driver.find_element( + By.XPATH, + '//form[@id="dependencies-modal-form"]//button[@class="btn btn-primary"]' + ) + add_layers_btn.click() + self.wait_until_visible('#change-notification') + change_notification = self.find('#change-notification') + self.assertTrue( + f'You have added {len(list_dependencies)+1} layers to your project: {input_text} and its dependencies' in str(change_notification.text) + ) + # check "Remove layer" button works + self.wait_until_visible('#layerstable tbody tr', poll=3) + rows = self.find_all('#layerstable tbody tr') + layer_to_remove = rows[0] + remove_btn = layer_to_remove.find_element( + By.XPATH, + '//td[@class="add-del-layers"]' + ) + remove_btn.click() + self.wait_until_visible('#change-notification', poll=2) + change_notification = self.find('#change-notification') + self.assertTrue( + f'You have removed 1 layer from your project: {input_text}' in str(change_notification.text) + ) + # check layers table feature(show/hide column, pagination) + self._navigate_to_config_nav('layerstable', 6) + column_list = [ + 'dependencies', + 'revision', + 'layer__vcs_url', + 'git_subdir', + 'layer__summary', + ] + self._mixin_test_table_edit_column( + 'layerstable', + 'edit-columns-button', + [f'checkbox-{column}' for column in column_list] + ) + self._navigate_to_config_nav('layerstable', 6) + # check show rows(pagination) + self._mixin_test_table_show_rows( + table_selector='layerstable', + to_skip=[150], + ) + + def test_distro_page(self): + """ Test distros page + - Check if title "Compatible distros" is displayed + - Check search input + - Check "Add layer" button works + - Check distro table feature(show/hide column, pagination) + """ + self._navigate_to_config_nav('distrostable', 7) + # check title "Compatible distros" is displayed + self.assertTrue("Compatible Distros" in self.get_page_source()) + # Test search input + input_text='poky-altcfg' + self._mixin_test_table_search_input( + input_selector='search-input-distrostable', + input_text=input_text, + searchBtn_selector='search-submit-distrostable', + table_selector='distrostable' + ) + # check "Add distro" button works + rows = self.find_all('#distrostable tbody tr') + distro_to_add = rows[0] + add_btn = distro_to_add.find_element( + By.XPATH, + '//td[@class="add-del-layers"]//a[1]' + ) + add_btn.click() + self.wait_until_visible('#change-notification', poll=2) + change_notification = self.find('#change-notification') + self.assertTrue( + f'You have changed the distro to: {input_text}' in str(change_notification.text) + ) + # check distro table feature(show/hide column, pagination) + self._navigate_to_config_nav('distrostable', 7) + column_list = [ + 'description', + 'templatefile', + 'layer_version__get_vcs_reference', + 'layer_version__layer__name', + ] + self._mixin_test_table_edit_column( + 'distrostable', + 'edit-columns-button', + [f'checkbox-{column}' for column in column_list] + ) + self._navigate_to_config_nav('distrostable', 7) + # check show rows(pagination) + self._mixin_test_table_show_rows( + table_selector='distrostable', + to_skip=[150], + ) + + def test_single_layer_page(self): + """ Test layer page + - Check if title is displayed + - Check add/remove layer button works + - Check tabs(layers, recipes, machines) are displayed + - Check left section is displayed + - Check layer name + - Check layer summary + - Check layer description + """ + url = reverse("layerdetails", args=(TestProjectPage.project_id, 7)) + self.get(url) + self.wait_until_visible('.page-header') + # check title is displayed + self.assertTrue(self.find('.page-header h1').is_displayed()) + + # check add layer button works + remove_layer_btn = self.find('#add-remove-layer-btn') + remove_layer_btn.click() + self.wait_until_visible('#change-notification', poll=2) + change_notification = self.find('#change-notification') + self.assertTrue( + f'You have removed 1 layer from your project' in str(change_notification.text) + ) + # check add layer button works, 18 is the random layer id + add_layer_btn = self.find('#add-remove-layer-btn') + add_layer_btn.click() + self.wait_until_visible('#change-notification') + change_notification = self.find('#change-notification') + self.assertTrue( + f'You have added 1 layer to your project' in str(change_notification.text) + ) + # check tabs(layers, recipes, machines) are displayed + tabs = self.find_all('.nav-tabs li') + self.assertEqual(len(tabs), 3) + # Check first tab + tabs[0].click() + self.assertTrue( + 'active' in str(self.find('#information').get_attribute('class')) + ) + # Check second tab + tabs[1].click() + self.assertTrue( + 'active' in str(self.find('#recipes').get_attribute('class')) + ) + # Check third tab + tabs[2].click() + self.assertTrue( + 'active' in str(self.find('#machines').get_attribute('class')) + ) + # Check left section is displayed + section = self.find('.well') + # Check layer name + self.assertTrue( + section.find_element(By.XPATH, '//h2[1]').is_displayed() + ) + # Check layer summary + self.assertTrue("Summary" in section.text) + # Check layer description + self.assertTrue("Description" in section.text) + + def test_single_recipe_page(self): + """ Test recipe page + - Check if title is displayed + - Check add recipe layer displayed + - Check left section is displayed + - Check recipe: name, summary, description, Version, Section, + License, Approx. packages included, Approx. size, Recipe file + """ + url = reverse("recipedetails", args=(TestProjectPage.project_id, 53428)) + self.get(url) + self.wait_until_visible('.page-header') + # check title is displayed + self.assertTrue(self.find('.page-header h1').is_displayed()) + # check add recipe layer displayed + add_recipe_layer_btn = self.find('#add-layer-btn') + self.assertTrue(add_recipe_layer_btn.is_displayed()) + # check left section is displayed + section = self.find('.well') + # Check recipe name + self.assertTrue( + section.find_element(By.XPATH, '//h2[1]').is_displayed() + ) + # Check recipe sections details info are displayed + self.assertTrue("Summary" in section.text) + self.assertTrue("Description" in section.text) + self.assertTrue("Version" in section.text) + self.assertTrue("Section" in section.text) + self.assertTrue("License" in section.text) + self.assertTrue("Approx. packages included" in section.text) + self.assertTrue("Approx. package size" in section.text) + self.assertTrue("Recipe file" in section.text) diff --git a/lib/toaster/tests/functional/test_project_page_tab_config.py b/lib/toaster/tests/functional/test_project_page_tab_config.py new file mode 100644 index 000000000..eb905ddf3 --- /dev/null +++ b/lib/toaster/tests/functional/test_project_page_tab_config.py @@ -0,0 +1,528 @@ +#! /usr/bin/env python3 # +# BitBake Toaster UI tests implementation +# +# Copyright (C) 2023 Savoir-faire Linux +# +# SPDX-License-Identifier: GPL-2.0-only +# + +import string +import random +import pytest +from django.urls import reverse +from selenium.webdriver import Keys +from selenium.webdriver.support.select import Select +from selenium.common.exceptions import ElementClickInterceptedException, NoSuchElementException, TimeoutException +from orm.models import Project +from tests.functional.functional_helpers import SeleniumFunctionalTestCase +from selenium.webdriver.common.by import By + +from .utils import get_projectId_from_url, wait_until_build, wait_until_build_cancelled + + +@pytest.mark.django_db +@pytest.mark.order("last") +class TestProjectConfigTab(SeleniumFunctionalTestCase): + PROJECT_NAME = 'TestProjectConfigTab' + project_id = None + + def _create_project(self, project_name, **kwargs): + """ Create/Test new project using: + - Project Name: Any string + - Release: Any string + - Merge Toaster settings: True or False + """ + release = kwargs.get('release', '3') + self.get(reverse('newproject')) + self.wait_until_visible('#new-project-name') + self.find("#new-project-name").send_keys(project_name) + select = Select(self.find("#projectversion")) + select.select_by_value(release) + + # check merge toaster settings + checkbox = self.find('.checkbox-mergeattr') + if not checkbox.is_selected(): + checkbox.click() + + if self.PROJECT_NAME != 'TestProjectConfigTab': + # Reset project name if it's not the default one + self.PROJECT_NAME = 'TestProjectConfigTab' + + self.find("#create-project-button").click() + + try: + self.wait_until_visible('#hint-error-project-name', poll=3) + url = reverse('project', args=(TestProjectConfigTab.project_id, )) + self.get(url) + self.wait_until_visible('#config-nav', poll=3) + except TimeoutException: + self.wait_until_visible('#config-nav', poll=3) + + def _random_string(self, length): + return ''.join( + random.choice(string.ascii_letters) for _ in range(length) + ) + + def _navigate_to_project_page(self): + # Navigate to project page + if TestProjectConfigTab.project_id is None: + self._create_project(project_name=self._random_string(10)) + current_url = self.driver.current_url + TestProjectConfigTab.project_id = get_projectId_from_url( + current_url) + else: + url = reverse('project', args=(TestProjectConfigTab.project_id,)) + self.get(url) + self.wait_until_visible('#config-nav') + + def _create_builds(self): + # check search box can be use to build recipes + search_box = self.find('#build-input') + search_box.send_keys('foo') + self.find('#build-button').click() + self.wait_until_present('#latest-builds') + # loop until reach the parsing state + wait_until_build(self, 'queued cloning starting parsing failed') + lastest_builds = self.driver.find_elements( + By.XPATH, + '//div[@id="latest-builds"]/div', + ) + last_build = lastest_builds[0] + self.assertTrue( + 'foo' in str(last_build.text) + ) + last_build = lastest_builds[0] + try: + cancel_button = last_build.find_element( + By.XPATH, + '//span[@class="cancel-build-btn pull-right alert-link"]', + ) + cancel_button.click() + except NoSuchElementException: + # Skip if the build is already cancelled + pass + wait_until_build_cancelled(self) + + def _get_tabs(self): + # tabs links list + return self.driver.find_elements( + By.XPATH, + '//div[@id="project-topbar"]//li' + ) + + def _get_config_nav_item(self, index): + config_nav = self.find('#config-nav') + return config_nav.find_elements(By.TAG_NAME, 'li')[index] + + def test_project_config_nav(self): + """ Test project config tab navigation: + - Check if the menu is displayed and contains the right elements: + - Configuration + - COMPATIBLE METADATA + - Custom images + - Image recipes + - Software recipes + - Machines + - Layers + - Distro + - EXTRA CONFIGURATION + - Bitbake variables + - Actions + - Delete project + """ + self._navigate_to_project_page() + + def _get_config_nav_item(index): + config_nav = self.find('#config-nav') + return config_nav.find_elements(By.TAG_NAME, 'li')[index] + + def check_config_nav_item(index, item_name, url): + item = _get_config_nav_item(index) + self.assertTrue(item_name in item.text) + self.assertTrue(item.get_attribute('class') == 'active') + self.assertTrue(url in self.driver.current_url) + + # check if the menu contains the right elements + # COMPATIBLE METADATA + compatible_metadata = _get_config_nav_item(1) + self.assertTrue( + "compatible metadata" in compatible_metadata.text.lower() + ) + # EXTRA CONFIGURATION + extra_configuration = _get_config_nav_item(8) + self.assertTrue( + "extra configuration" in extra_configuration.text.lower() + ) + # Actions + actions = _get_config_nav_item(10) + self.assertTrue("actions" in str(actions.text).lower()) + + conf_nav_list = [ + # config + [0, 'Configuration', + f"/toastergui/project/{TestProjectConfigTab.project_id}"], + # custom images + [2, 'Custom images', + f"/toastergui/project/{TestProjectConfigTab.project_id}/customimages"], + # image recipes + [3, 'Image recipes', + f"/toastergui/project/{TestProjectConfigTab.project_id}/images"], + # software recipes + [4, 'Software recipes', + f"/toastergui/project/{TestProjectConfigTab.project_id}/softwarerecipes"], + # machines + [5, 'Machines', + f"/toastergui/project/{TestProjectConfigTab.project_id}/machines"], + # layers + [6, 'Layers', + f"/toastergui/project/{TestProjectConfigTab.project_id}/layers"], + # distro + [7, 'Distros', + f"/toastergui/project/{TestProjectConfigTab.project_id}/distros"], + # [9, 'BitBake variables', f"/toastergui/project/{TestProjectConfigTab.project_id}/configuration"], # bitbake variables + ] + for index, item_name, url in conf_nav_list: + item = _get_config_nav_item(index) + if item.get_attribute('class') != 'active': + item.click() + check_config_nav_item(index, item_name, url) + + def test_image_recipe_editColumn(self): + """ Test the edit column feature in image recipe table on project page """ + def test_edit_column(check_box_id): + # Check that we can hide/show table column + check_box = self.find(f'#{check_box_id}') + th_class = str(check_box_id).replace('checkbox-', '') + if check_box.is_selected(): + # check if column is visible in table + self.assertTrue( + self.find( + f'#imagerecipestable thead th.{th_class}' + ).is_displayed(), + f"The {th_class} column is checked in EditColumn dropdown, but it's not visible in table" + ) + check_box.click() + # check if column is hidden in table + self.assertFalse( + self.find( + f'#imagerecipestable thead th.{th_class}' + ).is_displayed(), + f"The {th_class} column is unchecked in EditColumn dropdown, but it's visible in table" + ) + else: + # check if column is hidden in table + self.assertFalse( + self.find( + f'#imagerecipestable thead th.{th_class}' + ).is_displayed(), + f"The {th_class} column is unchecked in EditColumn dropdown, but it's visible in table" + ) + check_box.click() + # check if column is visible in table + self.assertTrue( + self.find( + f'#imagerecipestable thead th.{th_class}' + ).is_displayed(), + f"The {th_class} column is checked in EditColumn dropdown, but it's not visible in table" + ) + + self._navigate_to_project_page() + # navigate to project image recipe page + recipe_image_page_link = self._get_config_nav_item(3) + recipe_image_page_link.click() + self.wait_until_present('#imagerecipestable tbody tr') + + # Check edit column + edit_column = self.find('#edit-columns-button') + self.assertTrue(edit_column.is_displayed()) + edit_column.click() + # Check dropdown is visible + self.wait_until_visible('ul.dropdown-menu.editcol') + + # Check that we can hide the edit column + test_edit_column('checkbox-get_description_or_summary') + test_edit_column('checkbox-layer_version__get_vcs_reference') + test_edit_column('checkbox-layer_version__layer__name') + test_edit_column('checkbox-license') + test_edit_column('checkbox-recipe-file') + test_edit_column('checkbox-section') + test_edit_column('checkbox-version') + + def test_image_recipe_show_rows(self): + """ Test the show rows feature in image recipe table on project page """ + def test_show_rows(row_to_show, show_row_link): + # Check that we can show rows == row_to_show + show_row_link.select_by_value(str(row_to_show)) + self.wait_until_visible('#imagerecipestable tbody tr', poll=3) + # check at least some rows are visible + self.assertTrue( + len(self.find_all('#imagerecipestable tbody tr')) > 0 + ) + + self._navigate_to_project_page() + # navigate to project image recipe page + recipe_image_page_link = self._get_config_nav_item(3) + recipe_image_page_link.click() + self.wait_until_present('#imagerecipestable tbody tr') + + show_rows = self.driver.find_elements( + By.XPATH, + '//select[@class="form-control pagesize-imagerecipestable"]' + ) + # Check show rows + for show_row_link in show_rows: + show_row_link = Select(show_row_link) + test_show_rows(10, show_row_link) + test_show_rows(25, show_row_link) + test_show_rows(50, show_row_link) + test_show_rows(100, show_row_link) + test_show_rows(150, show_row_link) + + def test_project_config_tab_right_section(self): + """ Test project config tab right section contains five blocks: + - Machine: + - check 'Machine' is displayed + - check can change Machine + - Distro: + - check 'Distro' is displayed + - check can change Distro + - Most built recipes: + - check 'Most built recipes' is displayed + - check can select a recipe and build it + - Project release: + - check 'Project release' is displayed + - check project has right release displayed + - Layers: + - check can add a layer if exists + - check at least three layers are displayed + - openembedded-core + - meta-poky + - meta-yocto-bsp + """ + # Create a new project for this test + project_name = self._random_string(10) + self._create_project(project_name=project_name) + # check if the menu is displayed + self.wait_until_visible('#project-page') + block_l = self.driver.find_element( + By.XPATH, '//*[@id="project-page"]/div[2]') + project_release = self.driver.find_element( + By.XPATH, '//*[@id="project-page"]/div[1]/div[4]') + layers = block_l.find_element(By.ID, 'layer-container') + + def check_machine_distro(self, item_name, new_item_name, block_id): + block = self.find(f'#{block_id}') + title = block.find_element(By.TAG_NAME, 'h3') + self.assertTrue(item_name.capitalize() in title.text) + edit_btn = self.find(f'#change-{item_name}-toggle') + edit_btn.click() + self.wait_until_visible(f'#{item_name}-change-input') + name_input = self.find(f'#{item_name}-change-input') + name_input.clear() + name_input.send_keys(new_item_name) + change_btn = self.find(f'#{item_name}-change-btn') + change_btn.click() + self.wait_until_visible(f'#project-{item_name}-name') + project_name = self.find(f'#project-{item_name}-name') + self.assertTrue(new_item_name in project_name.text) + # check change notificaiton is displayed + change_notification = self.find('#change-notification') + self.assertTrue( + f'You have changed the {item_name} to: {new_item_name}' in change_notification.text + ) + + # Machine + check_machine_distro(self, 'machine', 'qemux86-64', 'machine-section') + # Distro + check_machine_distro(self, 'distro', 'poky-altcfg', 'distro-section') + + # Project release + title = project_release.find_element(By.TAG_NAME, 'h3') + self.assertTrue("Project release" in title.text) + self.assertTrue( + "Yocto Project master" in self.find('#project-release-title').text + ) + # Layers + title = layers.find_element(By.TAG_NAME, 'h3') + self.assertTrue("Layers" in title.text) + # check at least three layers are displayed + # openembedded-core + # meta-poky + # meta-yocto-bsp + layers_list = layers.find_element(By.ID, 'layers-in-project-list') + layers_list_items = layers_list.find_elements(By.TAG_NAME, 'li') + # remove all layers except the first three layers + for i in range(3, len(layers_list_items)): + layers_list_items[i].find_element(By.TAG_NAME, 'span').click() + # check can add a layer if exists + add_layer_input = layers.find_element(By.ID, 'layer-add-input') + add_layer_input.send_keys('meta-oe') + self.wait_until_visible('#layer-container > form > div > span > div') + dropdown_item = self.driver.find_element( + By.XPATH, + '//*[@id="layer-container"]/form/div/span/div' + ) + try: + dropdown_item.click() + except ElementClickInterceptedException: + self.skipTest( + "layer-container dropdown item click intercepted. Element not properly visible.") + add_layer_btn = layers.find_element(By.ID, 'add-layer-btn') + add_layer_btn.click() + self.wait_until_visible('#layers-in-project-list') + # check layer is added + layers_list_items = layers_list.find_elements(By.TAG_NAME, 'li') + self.assertTrue(len(layers_list_items) == 4) + + def test_most_build_recipes(self): + """ Test most build recipes block contains""" + def rebuild_from_most_build_recipes(recipe_list_items): + checkbox = recipe_list_items[0].find_element(By.TAG_NAME, 'input') + checkbox.click() + build_btn = self.find('#freq-build-btn') + build_btn.click() + self.wait_until_visible('#latest-builds') + wait_until_build(self, 'queued cloning starting parsing failed') + lastest_builds = self.driver.find_elements( + By.XPATH, + '//div[@id="latest-builds"]/div' + ) + self.assertTrue(len(lastest_builds) >= 2) + last_build = lastest_builds[0] + try: + cancel_button = last_build.find_element( + By.XPATH, + '//span[@class="cancel-build-btn pull-right alert-link"]', + ) + cancel_button.click() + except NoSuchElementException: + # Skip if the build is already cancelled + pass + wait_until_build_cancelled(self) + # Create a new project for remaining asserts + project_name = self._random_string(10) + self._create_project(project_name=project_name, release='2') + current_url = self.driver.current_url + TestProjectConfigTab.project_id = get_projectId_from_url(current_url) + url = current_url.split('?')[0] + + # Create a new builds + self._create_builds() + + # back to project page + self.driver.get(url) + + self.wait_until_visible('#project-page', poll=3) + + # Most built recipes + most_built_recipes = self.driver.find_element( + By.XPATH, '//*[@id="project-page"]/div[1]/div[3]') + title = most_built_recipes.find_element(By.TAG_NAME, 'h3') + self.assertTrue("Most built recipes" in title.text) + # check can select a recipe and build it + self.wait_until_visible('#freq-build-list', poll=3) + recipe_list = self.find('#freq-build-list') + recipe_list_items = recipe_list.find_elements(By.TAG_NAME, 'li') + self.assertTrue( + len(recipe_list_items) > 0, + msg="Any recipes found in the most built recipes list", + ) + rebuild_from_most_build_recipes(recipe_list_items) + TestProjectConfigTab.project_id = None # reset project id + + def test_project_page_tab_importlayer(self): + """ Test project page tab import layer """ + self._navigate_to_project_page() + # navigate to "Import layers" tab + import_layers_tab = self._get_tabs()[2] + import_layers_tab.find_element(By.TAG_NAME, 'a').click() + self.wait_until_visible('#layer-git-repo-url') + + # Check git repo radio button + git_repo_radio = self.find('#git-repo-radio') + git_repo_radio.click() + + # Set git repo url + input_repo_url = self.find('#layer-git-repo-url') + input_repo_url.send_keys('git://git.yoctoproject.org/meta-fake') + # Blur the input to trigger the validation + input_repo_url.send_keys(Keys.TAB) + + # Check name is set + input_layer_name = self.find('#import-layer-name') + self.assertTrue(input_layer_name.get_attribute('value') == 'meta-fake') + + # Set branch + input_branch = self.find('#layer-git-ref') + input_branch.send_keys('master') + + # Import layer + self.find('#import-and-add-btn').click() + + # Check layer is added + self.wait_until_visible('#layer-container') + block_l = self.driver.find_element( + By.XPATH, '//*[@id="project-page"]/div[2]') + layers = block_l.find_element(By.ID, 'layer-container') + layers_list = layers.find_element(By.ID, 'layers-in-project-list') + layers_list_items = layers_list.find_elements(By.TAG_NAME, 'li') + self.assertTrue( + 'meta-fake' in str(layers_list_items[-1].text) + ) + + def test_project_page_custom_image_no_image(self): + """ Test project page tab "New custom image" when no custom image """ + project_name = self._random_string(10) + self._create_project(project_name=project_name) + current_url = self.driver.current_url + TestProjectConfigTab.project_id = get_projectId_from_url(current_url) + # navigate to "Custom image" tab + custom_image_section = self._get_config_nav_item(2) + custom_image_section.click() + self.wait_until_visible('#empty-state-customimagestable') + + # Check message when no custom image + self.assertTrue( + "You have not created any custom images yet." in str( + self.find('#empty-state-customimagestable').text + ) + ) + div_empty_msg = self.find('#empty-state-customimagestable') + link_create_custom_image = div_empty_msg.find_element( + By.TAG_NAME, 'a') + self.assertTrue(TestProjectConfigTab.project_id is not None) + self.assertTrue( + f"/toastergui/project/{TestProjectConfigTab.project_id}/newcustomimage" in str( + link_create_custom_image.get_attribute('href') + ) + ) + self.assertTrue( + "Create your first custom image" in str( + link_create_custom_image.text + ) + ) + TestProjectConfigTab.project_id = None # reset project id + + def test_project_page_image_recipe(self): + """ Test project page section images + - Check image recipes are displayed + - Check search input + - Check image recipe build button works + - Check image recipe table features(show/hide column, pagination) + """ + self._navigate_to_project_page() + # navigate to "Images section" + images_section = self._get_config_nav_item(3) + images_section.click() + self.wait_until_visible('#imagerecipestable') + rows = self.find_all('#imagerecipestable tbody tr') + self.assertTrue(len(rows) > 0) + + # Test search input + self.wait_until_visible('#search-input-imagerecipestable') + recipe_input = self.find('#search-input-imagerecipestable') + recipe_input.send_keys('core-image-minimal') + self.find('#search-submit-imagerecipestable').click() + self.wait_until_visible('#imagerecipestable tbody tr') + rows = self.find_all('#imagerecipestable tbody tr') + self.assertTrue(len(rows) > 0) diff --git a/lib/toaster/tests/functional/utils.py b/lib/toaster/tests/functional/utils.py new file mode 100644 index 000000000..7269fa180 --- /dev/null +++ b/lib/toaster/tests/functional/utils.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# BitBake Toaster UI tests implementation +# +# Copyright (C) 2023 Savoir-faire Linux +# +# SPDX-License-Identifier: GPL-2.0-only + + +from time import sleep +from selenium.common.exceptions import NoSuchElementException, StaleElementReferenceException, TimeoutException +from selenium.webdriver.common.by import By + +from orm.models import Build + + +def wait_until_build(test_instance, state): + timeout = 60 + start_time = 0 + build_state = '' + while True: + try: + if start_time > timeout: + raise TimeoutException( + f'Build did not reach {state} state within {timeout} seconds' + ) + last_build_state = test_instance.driver.find_element( + By.XPATH, + '//*[@id="latest-builds"]/div[1]//div[@class="build-state"]', + ) + build_state = last_build_state.get_attribute( + 'data-build-state') + state_text = state.lower().split() + if any(x in str(build_state).lower() for x in state_text): + return str(build_state).lower() + if 'failed' in str(build_state).lower(): + break + except NoSuchElementException: + continue + except TimeoutException: + break + start_time += 1 + sleep(1) # take a breath and try again + +def wait_until_build_cancelled(test_instance): + """ Cancel build take a while sometime, the method is to wait driver action + until build being cancelled + """ + timeout = 30 + start_time = 0 + build = None + while True: + try: + if start_time > timeout: + raise TimeoutException( + f'Build did not reach cancelled state within {timeout} seconds' + ) + last_build_state = test_instance.driver.find_element( + By.XPATH, + '//*[@id="latest-builds"]/div[1]//div[@class="build-state"]', + ) + build_state = last_build_state.get_attribute( + 'data-build-state') + if 'failed' in str(build_state).lower(): + break + if 'cancelling' in str(build_state).lower(): + # Change build state to cancelled + if not build: # get build object only once + build = Build.objects.last() + build.outcome = Build.CANCELLED + build.save() + if 'cancelled' in str(build_state).lower(): + break + except NoSuchElementException: + continue + except StaleElementReferenceException: + continue + except TimeoutException: + break + start_time += 1 + sleep(1) # take a breath and try again + +def get_projectId_from_url(url): + # url = 'http://domainename.com/toastergui/project/1656/whatever + # or url = 'http://domainename.com/toastergui/project/1/ + # or url = 'http://domainename.com/toastergui/project/186 + assert '/toastergui/project/' in url, "URL is not valid" + url_to_list = url.split('/toastergui/project/') + return int(url_to_list[1].split('/')[0]) # project_id |