1#! /usr/bin/env python3 #
2# BitBake Toaster UI tests implementation
3#
4# Copyright (C) 2023 Savoir-faire Linux
5#
6# SPDX-License-Identifier: GPL-2.0-only
7#
8
9import os
10import random
11import string
12from unittest import skip
13import pytest
14from django.urls import reverse
15from django.utils import timezone
16from selenium.webdriver.common.keys import Keys
17from selenium.webdriver.support.select import Select
18from selenium.common.exceptions import TimeoutException
19from tests.functional.functional_helpers import SeleniumFunctionalTestCase
20from orm.models import Build, Project, Target
21from selenium.webdriver.common.by import By
22
23from .utils import get_projectId_from_url, wait_until_build, wait_until_build_cancelled
24
25
26@pytest.mark.django_db
27@pytest.mark.order("last")
28class TestProjectPage(SeleniumFunctionalTestCase):
29    project_id = None
30    PROJECT_NAME = 'TestProjectPage'
31
32    def _create_project(self, project_name):
33        """ Create/Test new project using:
34          - Project Name: Any string
35          - Release: Any string
36          - Merge Toaster settings: True or False
37        """
38        self.get(reverse('newproject'))
39        self.wait_until_visible('#new-project-name')
40        self.find("#new-project-name").send_keys(project_name)
41        select = Select(self.find("#projectversion"))
42        select.select_by_value('3')
43
44        # check merge toaster settings
45        checkbox = self.find('.checkbox-mergeattr')
46        if not checkbox.is_selected():
47            checkbox.click()
48
49        if self.PROJECT_NAME != 'TestProjectPage':
50            # Reset project name if it's not the default one
51            self.PROJECT_NAME = 'TestProjectPage'
52
53        self.find("#create-project-button").click()
54
55        try:
56            self.wait_until_visible('#hint-error-project-name')
57            url = reverse('project', args=(TestProjectPage.project_id, ))
58            self.get(url)
59            self.wait_until_visible('#config-nav', poll=3)
60        except TimeoutException:
61            self.wait_until_visible('#config-nav', poll=3)
62
63    def _random_string(self, length):
64        return ''.join(
65            random.choice(string.ascii_letters) for _ in range(length)
66        )
67
68    def _navigate_to_project_page(self):
69        # Navigate to project page
70        if TestProjectPage.project_id is None:
71            self._create_project(project_name=self._random_string(10))
72            current_url = self.driver.current_url
73            TestProjectPage.project_id = get_projectId_from_url(current_url)
74        else:
75            url = reverse('project', args=(TestProjectPage.project_id,))
76            self.get(url)
77        self.wait_until_visible('#config-nav')
78
79    def _get_create_builds(self, **kwargs):
80        """ Create a build and return the build object """
81        # parameters for builds to associate with the projects
82        now = timezone.now()
83        self.project1_build_success = {
84            'project': Project.objects.get(id=TestProjectPage.project_id),
85            'started_on': now,
86            'completed_on': now,
87            'outcome': Build.SUCCEEDED
88        }
89
90        self.project1_build_failure = {
91            'project': Project.objects.get(id=TestProjectPage.project_id),
92            'started_on': now,
93            'completed_on': now,
94            'outcome': Build.FAILED
95        }
96        build1 = Build.objects.create(**self.project1_build_success)
97        build2 = Build.objects.create(**self.project1_build_failure)
98
99        # add some targets to these builds so they have recipe links
100        # (and so we can find the row in the ToasterTable corresponding to
101        # a particular build)
102        Target.objects.create(build=build1, target='foo')
103        Target.objects.create(build=build2, target='bar')
104
105        if kwargs:
106            # Create kwargs.get('success') builds with success status with target
107            # and kwargs.get('failure') builds with failure status with target
108            for i in range(kwargs.get('success', 0)):
109                now = timezone.now()
110                self.project1_build_success['started_on'] = now
111                self.project1_build_success[
112                    'completed_on'] = now - timezone.timedelta(days=i)
113                build = Build.objects.create(**self.project1_build_success)
114                Target.objects.create(build=build,
115                                      target=f'{i}_success_recipe',
116                                      task=f'{i}_success_task')
117
118            for i in range(kwargs.get('failure', 0)):
119                now = timezone.now()
120                self.project1_build_failure['started_on'] = now
121                self.project1_build_failure[
122                    'completed_on'] = now - timezone.timedelta(days=i)
123                build = Build.objects.create(**self.project1_build_failure)
124                Target.objects.create(build=build,
125                                      target=f'{i}_fail_recipe',
126                                      task=f'{i}_fail_task')
127        return build1, build2
128
129    def _mixin_test_table_edit_column(
130            self,
131            table_id,
132            edit_btn_id,
133            list_check_box_id: list
134    ):
135        # Check edit column
136        edit_column = self.find(f'#{edit_btn_id}')
137        self.assertTrue(edit_column.is_displayed())
138        edit_column.click()
139        # Check dropdown is visible
140        self.wait_until_visible('ul.dropdown-menu.editcol')
141        for check_box_id in list_check_box_id:
142            # Check that we can hide/show table column
143            check_box = self.find(f'#{check_box_id}')
144            th_class = str(check_box_id).replace('checkbox-', '')
145            if check_box.is_selected():
146                # check if column is visible in table
147                self.assertTrue(
148                    self.find(
149                        f'#{table_id} thead th.{th_class}'
150                    ).is_displayed(),
151                    f"The {th_class} column is checked in EditColumn dropdown, but it's not visible in table"
152                )
153                check_box.click()
154                # check if column is hidden in table
155                self.assertFalse(
156                    self.find(
157                        f'#{table_id} thead th.{th_class}'
158                    ).is_displayed(),
159                    f"The {th_class} column is unchecked in EditColumn dropdown, but it's visible in table"
160                )
161            else:
162                # check if column is hidden in table
163                self.assertFalse(
164                    self.find(
165                        f'#{table_id} thead th.{th_class}'
166                    ).is_displayed(),
167                    f"The {th_class} column is unchecked in EditColumn dropdown, but it's visible in table"
168                )
169                check_box.click()
170                # check if column is visible in table
171                self.assertTrue(
172                    self.find(
173                        f'#{table_id} thead th.{th_class}'
174                    ).is_displayed(),
175                    f"The {th_class} column is checked in EditColumn dropdown, but it's not visible in table"
176                )
177
178    def _get_config_nav_item(self, index):
179        config_nav = self.find('#config-nav')
180        return config_nav.find_elements(By.TAG_NAME, 'li')[index]
181
182    def _navigate_to_config_nav(self, nav_id, nav_index):
183        # navigate to the project page
184        self._navigate_to_project_page()
185        # click on "Software recipe" tab
186        soft_recipe = self._get_config_nav_item(nav_index)
187        soft_recipe.click()
188        self.wait_until_visible(f'#{nav_id}')
189
190    def _mixin_test_table_show_rows(self, table_selector, **kwargs):
191        """ Test the show rows feature in the builds table on the all builds page """
192        def test_show_rows(row_to_show, show_row_link):
193            # Check that we can show rows == row_to_show
194            show_row_link.select_by_value(str(row_to_show))
195            self.wait_until_visible(f'#{table_selector} tbody tr', poll=3)
196            # check at least some rows are visible
197            self.assertTrue(
198                len(self.find_all(f'#{table_selector} tbody tr')) > 0
199            )
200        self.wait_until_present(f'#{table_selector} tbody tr')
201        show_rows = self.driver.find_elements(
202            By.XPATH,
203            f'//select[@class="form-control pagesize-{table_selector}"]'
204        )
205        rows_to_show = [10, 25, 50, 100, 150]
206        to_skip = kwargs.get('to_skip', [])
207        # Check show rows
208        for show_row_link in show_rows:
209            show_row_link = Select(show_row_link)
210            for row_to_show in rows_to_show:
211                if row_to_show not in to_skip:
212                    test_show_rows(row_to_show, show_row_link)
213
214    def _mixin_test_table_search_input(self, **kwargs):
215        input_selector, input_text, searchBtn_selector, table_selector, *_ = kwargs.values()
216        # Test search input
217        self.wait_until_visible(f'#{input_selector}')
218        recipe_input = self.find(f'#{input_selector}')
219        recipe_input.send_keys(input_text)
220        self.find(f'#{searchBtn_selector}').click()
221        self.wait_until_visible(f'#{table_selector} tbody tr')
222        rows = self.find_all(f'#{table_selector} tbody tr')
223        self.assertTrue(len(rows) > 0)
224
225    def test_create_project(self):
226        """ Create/Test new project using:
227          - Project Name: Any string
228          - Release: Any string
229          - Merge Toaster settings: True or False
230        """
231        self._create_project(project_name=self.PROJECT_NAME)
232
233    def test_image_recipe_editColumn(self):
234        """ Test the edit column feature in image recipe table on project page """
235        self._get_create_builds(success=10, failure=10)
236
237        url = reverse('projectimagerecipes', args=(TestProjectPage.project_id,))
238        self.get(url)
239        self.wait_until_present('#imagerecipestable tbody tr')
240
241        column_list = [
242            'get_description_or_summary', 'layer_version__get_vcs_reference',
243            'layer_version__layer__name', 'license', 'recipe-file', 'section',
244            'version'
245        ]
246
247        # Check that we can hide the edit column
248        self._mixin_test_table_edit_column(
249            'imagerecipestable',
250            'edit-columns-button',
251            [f'checkbox-{column}' for column in column_list]
252        )
253
254    def test_page_header_on_project_page(self):
255        """ Check page header in project page:
256          - AT LEFT -> Logo of Yocto project, displayed, clickable
257          - "Toaster"+" Information icon", displayed, clickable
258          - "Server Icon" + "All builds", displayed, clickable
259          - "Directory Icon" + "All projects", displayed, clickable
260          - "Book Icon" + "Documentation", displayed, clickable
261          - AT RIGHT -> button "New project", displayed, clickable
262        """
263        # navigate to the project page
264        self._navigate_to_project_page()
265
266        # check page header
267        # AT LEFT -> Logo of Yocto project
268        logo = self.driver.find_element(
269            By.XPATH,
270            "//div[@class='toaster-navbar-brand']",
271        )
272        logo_img = logo.find_element(By.TAG_NAME, 'img')
273        self.assertTrue(logo_img.is_displayed(),
274                        'Logo of Yocto project not found')
275        self.assertTrue(
276            '/static/img/logo.png' in str(logo_img.get_attribute('src')),
277            'Logo of Yocto project not found'
278        )
279        # "Toaster"+" Information icon", clickable
280        toaster = self.driver.find_element(
281            By.XPATH,
282            "//div[@class='toaster-navbar-brand']//a[@class='brand']",
283        )
284        self.assertTrue(toaster.is_displayed(), 'Toaster not found')
285        self.assertTrue(toaster.text == 'Toaster')
286        info_sign = self.find('.glyphicon-info-sign')
287        self.assertTrue(info_sign.is_displayed())
288
289        # "Server Icon" + "All builds"
290        all_builds = self.find('#navbar-all-builds')
291        all_builds_link = all_builds.find_element(By.TAG_NAME, 'a')
292        self.assertTrue("All builds" in all_builds_link.text)
293        self.assertTrue(
294            '/toastergui/builds/' in str(all_builds_link.get_attribute('href'))
295        )
296        server_icon = all_builds.find_element(By.TAG_NAME, 'i')
297        self.assertTrue(
298            server_icon.get_attribute('class') == 'glyphicon glyphicon-tasks'
299        )
300        self.assertTrue(server_icon.is_displayed())
301
302        # "Directory Icon" + "All projects"
303        all_projects = self.find('#navbar-all-projects')
304        all_projects_link = all_projects.find_element(By.TAG_NAME, 'a')
305        self.assertTrue("All projects" in all_projects_link.text)
306        self.assertTrue(
307            '/toastergui/projects/' in str(all_projects_link.get_attribute(
308                'href'))
309        )
310        dir_icon = all_projects.find_element(By.TAG_NAME, 'i')
311        self.assertTrue(
312            dir_icon.get_attribute('class') == 'icon-folder-open'
313        )
314        self.assertTrue(dir_icon.is_displayed())
315
316        # "Book Icon" + "Documentation"
317        toaster_docs_link = self.find('#navbar-docs')
318        toaster_docs_link_link = toaster_docs_link.find_element(By.TAG_NAME,
319                                                                'a')
320        self.assertTrue("Documentation" in toaster_docs_link_link.text)
321        self.assertTrue(
322            toaster_docs_link_link.get_attribute('href') == 'http://docs.yoctoproject.org/toaster-manual/index.html#toaster-user-manual'
323        )
324        book_icon = toaster_docs_link.find_element(By.TAG_NAME, 'i')
325        self.assertTrue(
326            book_icon.get_attribute('class') == 'glyphicon glyphicon-book'
327        )
328        self.assertTrue(book_icon.is_displayed())
329
330        # AT RIGHT -> button "New project"
331        new_project_button = self.find('#new-project-button')
332        self.assertTrue(new_project_button.is_displayed())
333        self.assertTrue(new_project_button.text == 'New project')
334        new_project_button.click()
335        self.assertTrue(
336            '/toastergui/newproject/' in str(self.driver.current_url)
337        )
338
339    def test_edit_project_name(self):
340        """ Test edit project name:
341          - Click on "Edit" icon button
342          - Change project name
343          - Click on "Save" button
344          - Check project name is changed
345        """
346        # navigate to the project page
347        self._navigate_to_project_page()
348
349        # click on "Edit" icon button
350        self.wait_until_visible('#project-name-container')
351        edit_button = self.find('#project-change-form-toggle')
352        edit_button.click()
353        project_name_input = self.find('#project-name-change-input')
354        self.assertTrue(project_name_input.is_displayed())
355        project_name_input.clear()
356        project_name_input.send_keys('New Name')
357        self.find('#project-name-change-btn').click()
358
359        # check project name is changed
360        self.wait_until_visible('#project-name-container')
361        self.assertTrue(
362            'New Name' in str(self.find('#project-name-container').text)
363        )
364
365    def test_project_page_tabs(self):
366        """ Test project tabs:
367          - "configuration" tab
368          - "Builds" tab
369          - "Import layers" tab
370          - "New custom image" tab
371          Check search box used to build recipes
372        """
373        # navigate to the project page
374        self._navigate_to_project_page()
375
376        # check "configuration" tab
377        self.wait_until_visible('#topbar-configuration-tab')
378        config_tab = self.find('#topbar-configuration-tab')
379        self.assertTrue(config_tab.get_attribute('class') == 'active')
380        self.assertTrue('Configuration' in str(config_tab.text))
381        self.assertTrue(
382            f"/toastergui/project/{TestProjectPage.project_id}" in str(self.driver.current_url)
383        )
384
385        def get_tabs():
386            # tabs links list
387            return self.driver.find_elements(
388                By.XPATH,
389                '//div[@id="project-topbar"]//li'
390            )
391
392        def check_tab_link(tab_index, tab_name, url):
393            tab = get_tabs()[tab_index]
394            tab_link = tab.find_element(By.TAG_NAME, 'a')
395            self.assertTrue(url in tab_link.get_attribute('href'))
396            self.assertTrue(tab_name in tab_link.text)
397            self.assertTrue(tab.get_attribute('class') == 'active')
398
399        # check "Builds" tab
400        builds_tab = get_tabs()[1]
401        builds_tab.find_element(By.TAG_NAME, 'a').click()
402        check_tab_link(
403            1,
404            'Builds',
405            f"/toastergui/project/{TestProjectPage.project_id}/builds"
406        )
407
408        # check "Import layers" tab
409        import_layers_tab = get_tabs()[2]
410        import_layers_tab.find_element(By.TAG_NAME, 'a').click()
411        check_tab_link(
412            2,
413            'Import layer',
414            f"/toastergui/project/{TestProjectPage.project_id}/importlayer"
415        )
416
417        # check "New custom image" tab
418        new_custom_image_tab = get_tabs()[3]
419        new_custom_image_tab.find_element(By.TAG_NAME, 'a').click()
420        check_tab_link(
421            3,
422            'New custom image',
423            f"/toastergui/project/{TestProjectPage.project_id}/newcustomimage"
424        )
425
426        # check search box can be use to build recipes
427        search_box = self.find('#build-input')
428        search_box.send_keys('core-image-minimal')
429        self.find('#build-button').click()
430        self.wait_until_visible('#latest-builds')
431        lastest_builds = self.driver.find_elements(
432            By.XPATH,
433            '//div[@id="latest-builds"]',
434        )
435        last_build = lastest_builds[0]
436        self.assertTrue(
437            'core-image-minimal' in str(last_build.text)
438        )
439
440    def test_softwareRecipe_page(self):
441        """ Test software recipe page
442            - Check title "Compatible software recipes" is displayed
443            - Check search input
444            - Check "build recipe" button works
445            - Check software recipe table feature(show/hide column, pagination)
446        """
447        self._navigate_to_config_nav('softwarerecipestable', 4)
448        # check title "Compatible software recipes" is displayed
449        self.assertTrue("Compatible software recipes" in self.get_page_source())
450        # Test search input
451        self._mixin_test_table_search_input(
452            input_selector='search-input-softwarerecipestable',
453            input_text='busybox',
454            searchBtn_selector='search-submit-softwarerecipestable',
455            table_selector='softwarerecipestable'
456        )
457        # check "build recipe" button works
458        rows = self.find_all('#softwarerecipestable tbody tr')
459        image_to_build = rows[0]
460        build_btn = image_to_build.find_element(
461            By.XPATH,
462            '//td[@class="add-del-layers"]//a[1]'
463        )
464        build_btn.click()
465        build_state = wait_until_build(self, 'queued cloning starting parsing failed')
466        lastest_builds = self.driver.find_elements(
467            By.XPATH,
468            '//div[@id="latest-builds"]/div'
469        )
470        self.assertTrue(len(lastest_builds) > 0)
471        last_build = lastest_builds[0]
472        cancel_button = last_build.find_element(
473            By.XPATH,
474            '//span[@class="cancel-build-btn pull-right alert-link"]',
475        )
476        cancel_button.click()
477        if 'starting' not in build_state:  # change build state when cancelled in starting state
478            wait_until_build_cancelled(self)
479
480        # check software recipe table feature(show/hide column, pagination)
481        self._navigate_to_config_nav('softwarerecipestable', 4)
482        column_list = [
483            'get_description_or_summary',
484            'layer_version__get_vcs_reference',
485            'layer_version__layer__name',
486            'license',
487            'recipe-file',
488            'section',
489            'version',
490        ]
491        self._mixin_test_table_edit_column(
492            'softwarerecipestable',
493            'edit-columns-button',
494            [f'checkbox-{column}' for column in column_list]
495        )
496        self._navigate_to_config_nav('softwarerecipestable', 4)
497        # check show rows(pagination)
498        self._mixin_test_table_show_rows(
499            table_selector='softwarerecipestable',
500            to_skip=[150],
501        )
502
503    def test_machines_page(self):
504        """ Test Machine page
505            - Check if title "Compatible machines" is displayed
506            - Check search input
507            - Check "Select machine" button works
508            - Check "Add layer" button works
509            - Check Machine table feature(show/hide column, pagination)
510        """
511        self._navigate_to_config_nav('machinestable', 5)
512        # check title "Compatible software recipes" is displayed
513        self.assertTrue("Compatible machines" in self.get_page_source())
514        # Test search input
515        self._mixin_test_table_search_input(
516            input_selector='search-input-machinestable',
517            input_text='qemux86-64',
518            searchBtn_selector='search-submit-machinestable',
519            table_selector='machinestable'
520        )
521        # check "Select machine" button works
522        rows = self.find_all('#machinestable tbody tr')
523        machine_to_select = rows[0]
524        select_btn = machine_to_select.find_element(
525            By.XPATH,
526            '//td[@class="add-del-layers"]//a[1]'
527        )
528        select_btn.send_keys(Keys.RETURN)
529        self.wait_until_visible('#config-nav')
530        project_machine_name = self.find('#project-machine-name')
531        self.assertTrue(
532            'qemux86-64' in project_machine_name.text
533        )
534        # check "Add layer" button works
535        self._navigate_to_config_nav('machinestable', 5)
536        # Search for a machine whit layer not in project
537        self._mixin_test_table_search_input(
538            input_selector='search-input-machinestable',
539            input_text='qemux86-64-tpm2',
540            searchBtn_selector='search-submit-machinestable',
541            table_selector='machinestable'
542        )
543        self.wait_until_visible('#machinestable tbody tr', poll=3)
544        rows = self.find_all('#machinestable tbody tr')
545        machine_to_add = rows[0]
546        add_btn = machine_to_add.find_element(By.XPATH, '//td[@class="add-del-layers"]')
547        add_btn.click()
548        self.wait_until_visible('#change-notification')
549        change_notification = self.find('#change-notification')
550        self.assertTrue(
551            f'You have added 1 layer to your project' in str(change_notification.text)
552        )
553        # check Machine table feature(show/hide column, pagination)
554        self._navigate_to_config_nav('machinestable', 5)
555        column_list = [
556            'description',
557            'layer_version__get_vcs_reference',
558            'layer_version__layer__name',
559            'machinefile',
560        ]
561        self._mixin_test_table_edit_column(
562            'machinestable',
563            'edit-columns-button',
564            [f'checkbox-{column}' for column in column_list]
565        )
566        self._navigate_to_config_nav('machinestable', 5)
567        # check show rows(pagination)
568        self._mixin_test_table_show_rows(
569            table_selector='machinestable',
570            to_skip=[150],
571        )
572
573    def test_layers_page(self):
574        """ Test layers page
575            - Check if title "Compatible layerss" is displayed
576            - Check search input
577            - Check "Add layer" button works
578            - Check "Remove layer" button works
579            - Check layers table feature(show/hide column, pagination)
580        """
581        self._navigate_to_config_nav('layerstable', 6)
582        # check title "Compatible layers" is displayed
583        self.assertTrue("Compatible layers" in self.get_page_source())
584        # Test search input
585        input_text='meta-tanowrt'
586        self._mixin_test_table_search_input(
587            input_selector='search-input-layerstable',
588            input_text=input_text,
589            searchBtn_selector='search-submit-layerstable',
590            table_selector='layerstable'
591        )
592        # check "Add layer" button works
593        self.wait_until_visible('#layerstable tbody tr', poll=3)
594        rows = self.find_all('#layerstable tbody tr')
595        layer_to_add = rows[0]
596        add_btn = layer_to_add.find_element(
597            By.XPATH,
598            '//td[@class="add-del-layers"]'
599        )
600        add_btn.click()
601        # check modal is displayed
602        self.wait_until_visible('#dependencies-modal', poll=3)
603        list_dependencies = self.find_all('#dependencies-list li')
604        # click on add-layers button
605        add_layers_btn = self.driver.find_element(
606            By.XPATH,
607            '//form[@id="dependencies-modal-form"]//button[@class="btn btn-primary"]'
608        )
609        add_layers_btn.click()
610        self.wait_until_visible('#change-notification')
611        change_notification = self.find('#change-notification')
612        self.assertTrue(
613            f'You have added {len(list_dependencies)+1} layers to your project: {input_text} and its dependencies' in str(change_notification.text)
614        )
615        # check "Remove layer" button works
616        self.wait_until_visible('#layerstable tbody tr', poll=3)
617        rows = self.find_all('#layerstable tbody tr')
618        layer_to_remove = rows[0]
619        remove_btn = layer_to_remove.find_element(
620            By.XPATH,
621            '//td[@class="add-del-layers"]'
622        )
623        remove_btn.click()
624        self.wait_until_visible('#change-notification', poll=2)
625        change_notification = self.find('#change-notification')
626        self.assertTrue(
627            f'You have removed 1 layer from your project: {input_text}' in str(change_notification.text)
628        )
629        # check layers table feature(show/hide column, pagination)
630        self._navigate_to_config_nav('layerstable', 6)
631        column_list = [
632            'dependencies',
633            'revision',
634            'layer__vcs_url',
635            'git_subdir',
636            'layer__summary',
637        ]
638        self._mixin_test_table_edit_column(
639            'layerstable',
640            'edit-columns-button',
641            [f'checkbox-{column}' for column in column_list]
642        )
643        self._navigate_to_config_nav('layerstable', 6)
644        # check show rows(pagination)
645        self._mixin_test_table_show_rows(
646            table_selector='layerstable',
647            to_skip=[150],
648        )
649
650    def test_distro_page(self):
651        """ Test distros page
652            - Check if title "Compatible distros" is displayed
653            - Check search input
654            - Check "Add layer" button works
655            - Check distro table feature(show/hide column, pagination)
656        """
657        self._navigate_to_config_nav('distrostable', 7)
658        # check title "Compatible distros" is displayed
659        self.assertTrue("Compatible Distros" in self.get_page_source())
660        # Test search input
661        input_text='poky-altcfg'
662        self._mixin_test_table_search_input(
663            input_selector='search-input-distrostable',
664            input_text=input_text,
665            searchBtn_selector='search-submit-distrostable',
666            table_selector='distrostable'
667        )
668        # check "Add distro" button works
669        rows = self.find_all('#distrostable tbody tr')
670        distro_to_add = rows[0]
671        add_btn = distro_to_add.find_element(
672            By.XPATH,
673            '//td[@class="add-del-layers"]//a[1]'
674        )
675        add_btn.click()
676        self.wait_until_visible('#change-notification', poll=2)
677        change_notification = self.find('#change-notification')
678        self.assertTrue(
679            f'You have changed the distro to: {input_text}' in str(change_notification.text)
680        )
681        # check distro table feature(show/hide column, pagination)
682        self._navigate_to_config_nav('distrostable', 7)
683        column_list = [
684            'description',
685            'templatefile',
686            'layer_version__get_vcs_reference',
687            'layer_version__layer__name',
688        ]
689        self._mixin_test_table_edit_column(
690            'distrostable',
691            'edit-columns-button',
692            [f'checkbox-{column}' for column in column_list]
693        )
694        self._navigate_to_config_nav('distrostable', 7)
695        # check show rows(pagination)
696        self._mixin_test_table_show_rows(
697            table_selector='distrostable',
698            to_skip=[150],
699        )
700
701    def test_single_layer_page(self):
702        """ Test layer page
703            - Check if title is displayed
704            - Check add/remove layer button works
705            - Check tabs(layers, recipes, machines) are displayed
706            - Check left section is displayed
707                - Check layer name
708                - Check layer summary
709                - Check layer description
710        """
711        url = reverse("layerdetails", args=(TestProjectPage.project_id, 8))
712        self.get(url)
713        self.wait_until_visible('.page-header')
714        # check title is displayed
715        self.assertTrue(self.find('.page-header h1').is_displayed())
716
717        # check add layer button works
718        remove_layer_btn = self.find('#add-remove-layer-btn')
719        remove_layer_btn.click()
720        self.wait_until_visible('#change-notification', poll=2)
721        change_notification = self.find('#change-notification')
722        self.assertTrue(
723            f'You have removed 1 layer from your project' in str(change_notification.text)
724        )
725        # check add layer button works, 18 is the random layer id
726        add_layer_btn = self.find('#add-remove-layer-btn')
727        add_layer_btn.click()
728        self.wait_until_visible('#change-notification')
729        change_notification = self.find('#change-notification')
730        self.assertTrue(
731            f'You have added 1 layer to your project' in str(change_notification.text)
732        )
733        # check tabs(layers, recipes, machines) are displayed
734        tabs = self.find_all('.nav-tabs li')
735        self.assertEqual(len(tabs), 3)
736        # Check first tab
737        tabs[0].click()
738        self.assertTrue(
739            'active' in str(self.find('#information').get_attribute('class'))
740        )
741        # Check second tab
742        tabs[1].click()
743        self.assertTrue(
744            'active' in str(self.find('#recipes').get_attribute('class'))
745        )
746        # Check third tab
747        tabs[2].click()
748        self.assertTrue(
749            'active' in str(self.find('#machines').get_attribute('class'))
750        )
751        # Check left section is displayed
752        section = self.find('.well')
753        # Check layer name
754        self.assertTrue(
755            section.find_element(By.XPATH, '//h2[1]').is_displayed()
756        )
757        # Check layer summary
758        self.assertTrue("Summary" in section.text)
759        # Check layer description
760        self.assertTrue("Description" in section.text)
761
762    def test_single_recipe_page(self):
763        """ Test recipe page
764            - Check if title is displayed
765            - Check add recipe layer displayed
766            - Check left section is displayed
767                - Check recipe: name, summary, description, Version, Section,
768                License, Approx. packages included, Approx. size, Recipe file
769        """
770        url = reverse("recipedetails", args=(TestProjectPage.project_id, 53428))
771        self.get(url)
772        self.wait_until_visible('.page-header')
773        # check title is displayed
774        self.assertTrue(self.find('.page-header h1').is_displayed())
775        # check add recipe layer displayed
776        add_recipe_layer_btn = self.find('#add-layer-btn')
777        self.assertTrue(add_recipe_layer_btn.is_displayed())
778        # check left section is displayed
779        section = self.find('.well')
780        # Check recipe name
781        self.assertTrue(
782            section.find_element(By.XPATH, '//h2[1]').is_displayed()
783        )
784        # Check recipe sections details info are displayed
785        self.assertTrue("Summary" in section.text)
786        self.assertTrue("Description" in section.text)
787        self.assertTrue("Version" in section.text)
788        self.assertTrue("Section" in section.text)
789        self.assertTrue("License" in section.text)
790        self.assertTrue("Approx. packages included" in section.text)
791        self.assertTrue("Approx. package size" in section.text)
792        self.assertTrue("Recipe file" in section.text)
793