From 6d72e023bfbc71bec6dc2f3b8903c7c42ff5e2fa Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 28 Jan 2026 15:32:35 +0100 Subject: [PATCH 1/4] WIP --- tests/ui/constants/ui_constants.py | 3 +- tests/ui/locators/node_deployment_locators.py | 120 ++++++++ tests/ui/pages/node_deployment_page.py | 282 ++++++++++++++++++ tests/ui/test_node_deployment_e2e.py | 271 +++++++++++++++++ 4 files changed, 675 insertions(+), 1 deletion(-) create mode 100644 tests/ui/locators/node_deployment_locators.py create mode 100644 tests/ui/pages/node_deployment_page.py create mode 100644 tests/ui/test_node_deployment_e2e.py diff --git a/tests/ui/constants/ui_constants.py b/tests/ui/constants/ui_constants.py index 2c71594..367b92d 100644 --- a/tests/ui/constants/ui_constants.py +++ b/tests/ui/constants/ui_constants.py @@ -1 +1,2 @@ -TIMEOUT_MAX = 3000 \ No newline at end of file +TIMEOUT_MAX = 3000 +NODE_STATUS_MAX_WAIT = 120000 diff --git a/tests/ui/locators/node_deployment_locators.py b/tests/ui/locators/node_deployment_locators.py new file mode 100644 index 0000000..ba05921 --- /dev/null +++ b/tests/ui/locators/node_deployment_locators.py @@ -0,0 +1,120 @@ +from tests.ui.locators.base_locators import BaseLocators + + +class NodeDeploymentLocators(BaseLocators): + """Locators for node deployment wizard flow.""" + + # Protocol selection page + NODE_SELECTION = ".node-selection" + NODE_SELECTION_TITLE = ".node-selection-title" + NODE_SELECTION_SUBTITLE = ".node-selection-subtitle" + PROTOCOL_SEARCH = ".node-selection-search-input" + PROTOCOL_LIST = ".node-selection-protocol-list" + PROTOCOL_CARD = ".node-selection-protocol-card" + PROTOCOL_NAME = ".node-selection-protocol-name" + SELECT_CONFIG_BUTTON = "button:has-text('Select configuration')" + + # Configuration selection page + NODE_CONFIGURATION = ".node-configuration" + NODE_CONFIGURATION_TITLE = ".node-configuration-title" + NODE_CONFIGURATION_SUBTITLE = ".node-configuration-subtitle" + CONFIG_CARD_GRID = ".node-configuration-card-grid" + CONFIG_CARD = ".node-configuration-card" + CONFIG_PILL = ".node-configuration-pill" + CONFIG_SPEC_LIST = ".node-configuration-spec-list" + CONFIG_SPEC_ITEM = ".node-configuration-spec-item" + CONFIG_CLIENTS = ".node-configuration-clients" + CONFIG_BACK_BUTTON = ".node-configuration-footer-back" + SHOW_SUMMARY_BUTTON = "button:has-text('Show summary')" + + # Summary page + NODE_SUMMARY = ".node-summary" + NODE_SUMMARY_TITLE = ".node-summary-title" + NODE_SUMMARY_SUBTITLE = ".node-summary-subtitle" + SUMMARY_CARD = ".node-summary-card" + SUMMARY_ROW = ".node-summary-row" + SUMMARY_ROW_LABEL = ".node-summary-row-label" + SUMMARY_ROW_VALUE = ".node-summary-row-value" + SUMMARY_BACK_BUTTON = ".node-summary-footer-back" + CREATE_NODE_BUTTON = "button:has-text('Create node')" + + # Node overview page + NODE_DETAILS_LAYOUT = ".node-details-layout" + NODE_DETAILS_HEADER = ".node-details-header" + NODE_DETAILS_TITLE = ".node-details-title" + NODE_DETAILS_BREADCRUMB = ".node-details-breadcrumb" + NODE_DETAILS_STATUS = ".node-details-status-chip" + NODE_DETAILS_META = ".node-details-meta" + NODE_DETAILS_TABS = ".node-details-tabs" + NODE_DETAILS_TAB = ".node-details-tab" + NODE_DETAILS_TAB_ACTIVE = ".node-details-tab-active" + NODE_OVERVIEW = ".node-overview" + NODE_OVERVIEW_INFO_CARD = ".node-overview-info-card" + NODE_OVERVIEW_ENDPOINTS = ".node-overview-endpoints" + + @staticmethod + def tab_by_name(name: str) -> str: + """Get tab by name.""" + return f".node-details-tab:has-text('{name}')" + + # Info card specific fields + NODE_INFO_GRID = ".node-overview-info-grid" + NODE_INFO_CELL = ".node-overview-info-cell" + NODE_INFO_LABEL = ".node-overview-info-label" + NODE_INFO_VALUE = ".node-overview-info-value" + NODE_INFO_NET_VALUE = ".node-overview-info-net-value" + + # Wizard stepper + WIZARD_STEPPER = ".wizard-stepper" + WIZARD_STEP = ".wizard-step" + WIZARD_STEP_ACTIVE = ".wizard-step.is-active" + WIZARD_STEP_COMPLETED = ".wizard-step.is-completed" + WIZARD_STEP_LABEL = ".wizard-step-label" + + @staticmethod + def wizard_step_by_name(name: str) -> str: + """Get wizard step by name.""" + return f".wizard-step:has(.wizard-step-label:text('{name}'))" + + @staticmethod + def protocol_card_by_name(name: str) -> str: + """Get protocol card by name.""" + return f".node-selection-protocol-card:has(.node-selection-protocol-name:text('{name}'))" + + @staticmethod + def config_card_by_name(name: str) -> str: + """Get configuration card by name.""" + return f".node-configuration-card:has(.node-configuration-pill:text('{name}'))" + + # Node settings page + NODE_SETTINGS = ".node-settings" + NODE_SETTINGS_SECTION = ".node-settings-section" + NODE_SETTINGS_DELETE_BLOCK = ".node-settings-delete-block" + DELETE_NODE_BUTTON = "button:has-text('Delete node')" + + # Delete confirmation dialog + DELETE_CONFIRM_CARD = ".node-settings-delete-confirm-card" + DELETE_CONFIRM_TEXT = ".node-settings-delete-text" + DELETE_CANCEL_BUTTON = ".primary-button--cancel" + DELETE_CONFIRM_BUTTON = ".node-settings-delete-confirm-card .primary-button--danger" + + # Nodes list page + NODES_LIST_PAGE = ".nodes-list-page" + NODES_LIST_HEADER = ".nodes-list-header" + NODE_ITEM = ".node-item" + NODE_ITEM_TITLE = ".node-item-title" + NODE_STATUS_DROPDOWN = ".nodes-status-dropdown" + STATUS_DROPDOWN_OPTION = ".nodes-status-option" + ADD_NODE_BUTTON = "button:has-text('+ Add node')" + + @staticmethod + def node_by_name(name: str) -> str: + """Get node item by name using text search in list body.""" + return f".nodes-list-body >> text='{name}'" + + @staticmethod + def dropdown_option_by_name(name: str) -> str: + """Get dropdown option by name.""" + return f".nodes-status-option:has(.nodes-status-option-label:text('{name}'))" + + diff --git a/tests/ui/pages/node_deployment_page.py b/tests/ui/pages/node_deployment_page.py new file mode 100644 index 0000000..f5cb84d --- /dev/null +++ b/tests/ui/pages/node_deployment_page.py @@ -0,0 +1,282 @@ +import allure +from playwright.sync_api import Page, expect +import re +from tests.ui.pages.base_page import BasePage +from tests.ui.locators.node_deployment_locators import NodeDeploymentLocators +from tests.ui.constants.ui_constants import TIMEOUT_MAX + + +class NodeDeploymentPage(BasePage): + """Page object for the node deployment wizard flow.""" + + def __init__(self, page: Page, base_url: str): + super().__init__(page, base_url) + self.locators = NodeDeploymentLocators() + + # Protocol Selection Page Methods + @allure.step("Verify protocol selection page loaded") + def verify_protocol_page_loaded(self): + """Verify protocol selection page is loaded.""" + self.verify_element_visible(self.locators.NODE_SELECTION) + self.verify_element_visible(self.locators.PROTOCOL_LIST) + + @allure.step("Search for protocol: {protocol_name}") + def search_protocol(self, protocol_name: str): + """Search for a protocol.""" + self.fill(self.locators.PROTOCOL_SEARCH, protocol_name) + + @allure.step("Select protocol: {protocol_name}") + def select_protocol(self, protocol_name: str): + """Select a protocol by name.""" + card_selector = self.locators.protocol_card_by_name(protocol_name) + self.click(card_selector) + + @allure.step("Click 'Select configuration' button") + def click_select_configuration(self): + """Click the select configuration button.""" + self.click(self.locators.SELECT_CONFIG_BUTTON) + + @allure.step("Verify protocol card selected: {protocol_name}") + def verify_protocol_selected(self, protocol_name: str): + """Verify a protocol card is selected.""" + card_selector = self.locators.protocol_card_by_name(protocol_name) + # After selection, button should be enabled + expect(self.page.locator(self.locators.SELECT_CONFIG_BUTTON)).to_be_enabled() + + # Configuration Selection Page Methods + @allure.step("Verify configuration page loaded") + def verify_configuration_page_loaded(self): + """Verify configuration selection page is loaded.""" + self.verify_element_visible(self.locators.NODE_CONFIGURATION) + self.verify_element_visible(self.locators.CONFIG_CARD_GRID) + + @allure.step("Select configuration: {config_name}") + def select_configuration(self, config_name: str): + """Select a configuration by name.""" + card_selector = self.locators.config_card_by_name(config_name) + self.click(card_selector) + + @allure.step("Click 'Show summary' button") + def click_show_summary(self): + """Click the show summary button.""" + self.click(self.locators.SHOW_SUMMARY_BUTTON) + + @allure.step("Click back button on configuration page") + def click_config_back(self): + """Click the back button on configuration page.""" + self.click(self.locators.CONFIG_BACK_BUTTON) + + @allure.step("Verify configuration specs visible") + def verify_configuration_specs(self): + """Verify configuration specs are visible.""" + self.verify_element_visible(self.locators.CONFIG_SPEC_LIST) + self.verify_element_visible(self.locators.CONFIG_CLIENTS) + + # Summary Page Methods + @allure.step("Verify summary page loaded") + def verify_summary_page_loaded(self): + """Verify summary page is loaded.""" + self.verify_element_visible(self.locators.NODE_SUMMARY) + self.verify_element_visible(self.locators.SUMMARY_CARD) + + @allure.step("Verify summary shows protocol: {protocol_name}") + def verify_summary_protocol(self, protocol_name: str): + """Verify summary shows correct protocol.""" + summary_text = self.get_text(self.locators.SUMMARY_CARD) + assert protocol_name in summary_text, f"Expected '{protocol_name}' in summary, got '{summary_text}'" + + @allure.step("Click 'Create node' button") + def click_create_node(self): + """Click the create node button.""" + self.click(self.locators.CREATE_NODE_BUTTON) + + @allure.step("Click back button on summary page") + def click_summary_back(self): + """Click the back button on summary page.""" + self.click(self.locators.SUMMARY_BACK_BUTTON) + + # Node Overview Page Methods + @allure.step("Verify node overview page loaded") + def verify_overview_page_loaded(self): + """Verify node overview page is loaded.""" + self.verify_element_visible(self.locators.NODE_DETAILS_LAYOUT) + self.verify_element_visible(self.locators.NODE_DETAILS_HEADER) + + @allure.step("Get node status") + def get_node_status(self) -> str: + """Get the current node status.""" + return self.get_text(self.locators.NODE_DETAILS_STATUS) + + @allure.step("Verify node status is: {expected_status}") + def verify_node_status(self, expected_status: str): + """Verify node has expected status.""" + status = self.get_node_status() + assert expected_status.lower() in status.lower(), f"Expected '{expected_status}' in status, got '{status}'" + + @allure.step("Verify node name in header: {node_name}") + def verify_node_name(self, node_name: str): + """Verify node name is displayed in header.""" + title = self.get_text(self.locators.NODE_DETAILS_TITLE) + assert node_name in title, f"Expected '{node_name}' in title, got '{title}'" + + def get_node_info_from_ui(self) -> dict: + """Extract node info from UI info card.""" + info = {} + + # Get all info cells + cells = self.page.locator(self.locators.NODE_INFO_CELL).all() + + for cell in cells: + label = cell.locator(self.locators.NODE_INFO_LABEL).text_content() or "" + # Try net-value first (for Protocol which has icon), then regular value + value_locator = cell.locator(self.locators.NODE_INFO_NET_VALUE) + if value_locator.count() > 0: + value = value_locator.text_content() or "" + else: + value = cell.locator(self.locators.NODE_INFO_VALUE).text_content() or "" + + info[label.strip()] = value.strip() + + return info + + @allure.step("Verify node info card") + def verify_node_info_card(self, api_client=None, node_id: str = None): + """ + Verify node info card is visible and optionally compare with API data. + + Args: + api_client: Optional API client instance. If provided with node_id, + compares UI data with API response. + node_id: Optional node ID to fetch from API for comparison. + """ + self.verify_element_visible(self.locators.NODE_OVERVIEW_INFO_CARD) + + if api_client and node_id: + api_response = api_client.get_node(node_id) + assert api_response.status_code == 200, f"Failed to get node from API: {api_response.status_code}" + api_data = api_response.json() + + ui_data = self.get_node_info_from_ui() + + if "Protocol" in ui_data: + assert api_data["protocol"] in ui_data["Protocol"], \ + f"Protocol mismatch: API='{api_data['protocol']}', UI='{ui_data['Protocol']}'" + + if "Network" in ui_data: + assert api_data["network"] in ui_data["Network"], \ + f"Network mismatch: API='{api_data['network']}', UI='{ui_data['Network']}'" + + if "Deployment ID" in ui_data: + assert api_data["id"] in ui_data["Deployment ID"], \ + f"Deployment ID mismatch: API='{api_data['id']}', UI='{ui_data['Deployment ID']}'" + + if "Revision ID" in ui_data and "revision" in api_data: + assert api_data["revision"]["id"] in ui_data["Revision ID"], \ + f"Revision ID mismatch: API='{api_data['revision']['id']}', UI='{ui_data['Revision ID']}'" + + allure.attach( + str(api_data), + "API Data", + allure.attachment_type.JSON + ) + allure.attach( + str(ui_data), + "UI Data", + allure.attachment_type.JSON + ) + + @allure.step("Click tab: {tab_name}") + def click_tab(self, tab_name: str): + """Click a tab by name.""" + tab_selector = self.locators.tab_by_name(tab_name) + self.click(tab_selector) + + @allure.step("Verify active tab: {tab_name}") + def verify_active_tab(self, tab_name: str): + """Verify the active tab.""" + active_tab_text = self.get_text(self.locators.NODE_DETAILS_TAB_ACTIVE) + assert tab_name in active_tab_text, f"Expected '{tab_name}' in active tab, got '{active_tab_text}'" + + # Wizard Stepper Methods + @allure.step("Verify wizard step active: {step_name}") + def verify_wizard_step_active(self, step_name: str): + """Verify a wizard step is active.""" + active_step_label = self.get_text(f"{self.locators.WIZARD_STEP_ACTIVE} .wizard-step-label") + assert step_name in active_step_label, f"Expected '{step_name}' in active step, got '{active_step_label}'" + + @allure.step("Verify wizard step completed: {step_name}") + def verify_wizard_step_completed(self, step_name: str): + """Verify a wizard step is completed.""" + step_selector = self.locators.wizard_step_by_name(step_name) + self.verify_element_visible(step_selector) + + @allure.step("Wait for node creation to start") + def wait_for_node_creation(self, timeout: int = TIMEOUT_MAX): + """Wait for node creation process to start and redirect to overview.""" + self.page.wait_for_selector(self.locators.NODE_DETAILS_LAYOUT, timeout=timeout) + + @allure.step("Wait for node to reach {expected_status} status") + def wait_for_particular_status(self, expected_status: str, timeout: int = 120000): + """Wait for node to reach particular status (polling).""" + status_locator = self.page.locator(self.locators.NODE_DETAILS_STATUS) + expect(status_locator).to_contain_text(re.compile(expected_status, re.IGNORECASE), timeout=timeout) + + # Node Settings & Deletion Methods + @allure.step("Click 'Delete node' button") + def click_delete_node(self): + """Click the delete node button on settings page.""" + self.click(self.locators.DELETE_NODE_BUTTON) + + @allure.step("Confirm node deletion") + def confirm_delete_node(self): + """Click 'Yes, I'm sure' to confirm deletion.""" + self.verify_element_visible(self.locators.DELETE_CONFIRM_CARD) + self.click(self.locators.DELETE_CONFIRM_BUTTON) + + @allure.step("Cancel node deletion") + def cancel_delete_node(self): + """Click 'Cancel' to cancel deletion.""" + self.click(self.locators.DELETE_CANCEL_BUTTON) + + @allure.step("Delete node with confirmation") + def delete_node(self): + """Complete node deletion flow: click delete button, then confirm.""" + self.click_delete_node() + self.confirm_delete_node() + + # Nodes List Methods + @allure.step("Click 'Add node' button") + def click_add_node(self): + """Click the add node button on nodes list page.""" + self.click(self.locators.ADD_NODE_BUTTON) + + @allure.step("Verify node exists in list: {node_name}") + def verify_node_in_list(self, node_name: str, timeout: int = TIMEOUT_MAX): + """Verify a node exists in the nodes list.""" + node_locator = self.page.locator(self.locators.node_by_name(node_name)) + expect(node_locator.first).to_be_visible(timeout=timeout) + + @allure.step("Verify node not in list: {node_name}") + def verify_node_not_in_list(self, node_name: str): + """Verify a node does not exist in the nodes list.""" + node_locator = self.page.locator(self.locators.node_by_name(node_name)) + expect(node_locator).to_have_count(0) + + @allure.step("Click node in list: {node_name}") + def click_node_in_list(self, node_name: str): + """Click a node in the nodes list.""" + node_locator = self.page.locator(self.locators.node_by_name(node_name)) + node_locator.first.click() + + @allure.step("Select status filter: {status}") + def select_status_filter(self, status: str): + """Select a status from the dropdown filter.""" + select_locator = self.page.locator(".nodes-list-filter-select") + select_locator.select_option(label=status) + self.page.wait_for_timeout(1000) + + @allure.step("Get node name from title") + def get_node_name_from_title(self) -> str: + """Extract node name from the page title.""" + title = self.get_text(self.locators.NODE_DETAILS_TITLE) + return title.strip() diff --git a/tests/ui/test_node_deployment_e2e.py b/tests/ui/test_node_deployment_e2e.py new file mode 100644 index 0000000..42b999e --- /dev/null +++ b/tests/ui/test_node_deployment_e2e.py @@ -0,0 +1,271 @@ +import pytest +import allure +from playwright.sync_api import Page, expect + +from tests.ui.pages.login_page import LoginPage +from tests.ui.pages.nodes_page import NodesListPage +from tests.ui.pages.node_deployment_page import NodeDeploymentPage +from tests.ui.constants.ui_constants import TIMEOUT_MAX, NODE_STATUS_MAX_WAIT + + +@allure.feature("Nodes") +@allure.story("Node Deployment E2E Flow") +@pytest.mark.ui +@pytest.mark.smoke_ui +class TestNodeDeploymentE2E: + + @allure.title("Complete node deployment flow - {protocol_name}") + @allure.severity(allure.severity_level.CRITICAL) + @pytest.mark.parametrize("protocol_name,config_name", [ + ("Ethereum Sepolia Reth Prysm", "Ethereum Sepolia Reth Prysm Nano"), + ("Ethereum Mainnet Reth Prysm", "Ethereum Mainnet Reth Prysm Nano"), + ("Ethereum Hoodi Reth Prysm", "Ethereum Hoodi Reth Prysm Nano"), + ]) + def test_complete_node_deployment_flow(self, page: Page, base_url: str, config, protocol_name: str, config_name: str): + + with allure.step("Login to the application"): + login_page = LoginPage(page, base_url) + login_page.open() + login_page.login(config.admin_log, config.admin_pass) + + with allure.step("Navigate to nodes list page"): + nodes_page = NodesListPage(page, base_url) + nodes_page.open() + nodes_page.verify_page_loaded() + + with allure.step("Click 'Add node' button"): + page.click("button:has-text('+ Add node')") + + deployment_page = NodeDeploymentPage(page, base_url) + + with allure.step("Verify protocol selection page loaded"): + deployment_page.verify_protocol_page_loaded() + deployment_page.verify_wizard_step_active("Protocol") + + with allure.step(f"Select protocol: {protocol_name}"): + deployment_page.select_protocol(protocol_name) + deployment_page.verify_protocol_selected(protocol_name) + + with allure.step("Click 'Select configuration' button"): + deployment_page.click_select_configuration() + + with allure.step("Verify configuration page loaded"): + deployment_page.verify_configuration_page_loaded() + deployment_page.verify_wizard_step_active("Configuration") + deployment_page.verify_wizard_step_completed("Protocol") + + with allure.step(f"Select configuration: {config_name}"): + deployment_page.select_configuration(config_name) + deployment_page.verify_configuration_specs() + + with allure.step("Click 'Show summary' button"): + deployment_page.click_show_summary() + + with allure.step("Verify summary page loaded"): + deployment_page.verify_summary_page_loaded() + deployment_page.verify_wizard_step_active("Summary") + deployment_page.verify_wizard_step_completed("Configuration") + + with allure.step(f"Verify summary shows protocol: {protocol_name}"): + deployment_page.verify_summary_protocol(protocol_name) + + with allure.step("Click 'Create node' button"): + deployment_page.click_create_node() + + with allure.step("Verify node overview page loaded"): + deployment_page.wait_for_node_creation(timeout=NODE_STATUS_MAX_WAIT) + deployment_page.verify_overview_page_loaded() + deployment_page.verify_node_info_card() + + with allure.step("Verify node status shows Bootstrapping"): + deployment_page.verify_node_status("Bootstrapping") + + with allure.step("Wait for node status to show Running"): + deployment_page.wait_for_particular_status("Running", timeout=NODE_STATUS_MAX_WAIT) + deployment_page.verify_node_status("Running") + + node_name = deployment_page.get_node_name_from_title() + + with allure.step("Navigate to nodes list and verify deployed node is present"): + nodes_page.open() + nodes_page.verify_page_loaded() + deployment_page.verify_node_in_list(node_name) + + with allure.step("Click on deployed node to open details"): + deployment_page.click_node_in_list(node_name) + deployment_page.verify_overview_page_loaded() + + with allure.step("Go to Settings tab and delete node"): + deployment_page.click_tab("Settings") + deployment_page.verify_active_tab("Settings") + deployment_page.delete_node() + + with allure.step("Verify redirected to nodes list page"): + nodes_page.verify_page_loaded() + + with allure.step("Select 'Deleted' status filter and verify deleted node appears"): + deployment_page.select_status_filter("Deleted") + deployment_page.verify_node_in_list(node_name, timeout=NODE_STATUS_MAX_WAIT) + + +@allure.feature("Nodes") +@allure.story("Node Deployment Wizard Navigation") +@pytest.mark.ui +@pytest.mark.regression_ui +class TestNodeDeploymentNavigation: + + @allure.title("Wizard back navigation - Config to Protocol") + @allure.severity(allure.severity_level.NORMAL) + def test_wizard_back_config_to_protocol(self, page: Page, base_url: str, config): + """Test back navigation from configuration to protocol selection.""" + protocol_name = "Ethereum Sepolia Reth Prysm" + + with allure.step("Login and navigate to protocol selection"): + login_page = LoginPage(page, base_url) + login_page.open() + login_page.login(config.admin_log, config.admin_pass) + + nodes_page = NodesListPage(page, base_url) + nodes_page.open() + page.click("button:has-text('+ Add node')") + + deployment_page = NodeDeploymentPage(page, base_url) + + with allure.step("Select protocol and proceed to configuration"): + deployment_page.select_protocol(protocol_name) + deployment_page.click_select_configuration() + deployment_page.verify_configuration_page_loaded() + + with allure.step("Click back button"): + deployment_page.click_config_back() + + with allure.step("Verify returned to protocol selection"): + deployment_page.verify_protocol_page_loaded() + deployment_page.verify_wizard_step_active("Protocol") + + @allure.title("Wizard back navigation - Summary to Config") + @allure.severity(allure.severity_level.NORMAL) + def test_wizard_back_summary_to_config(self, page: Page, base_url: str, config): + """Test back navigation from summary to configuration selection.""" + protocol_name = "Ethereum Sepolia Reth Prysm" + config_name = "Ethereum Sepolia Reth Prysm Nano" + + with allure.step("Login and navigate through wizard to summary"): + login_page = LoginPage(page, base_url) + login_page.open() + login_page.login(config.admin_log, config.admin_pass) + + nodes_page = NodesListPage(page, base_url) + nodes_page.open() + page.click("button:has-text('+ Add node')") + + deployment_page = NodeDeploymentPage(page, base_url) + + with allure.step("Navigate through wizard to summary"): + deployment_page.select_protocol(protocol_name) + deployment_page.click_select_configuration() + deployment_page.select_configuration(config_name) + deployment_page.click_show_summary() + deployment_page.verify_summary_page_loaded() + + with allure.step("Click back button"): + deployment_page.click_summary_back() + + with allure.step("Verify returned to configuration selection"): + deployment_page.verify_configuration_page_loaded() + deployment_page.verify_wizard_step_active("Configuration") + + +@allure.feature("Nodes") +@allure.story("Node Deployment Validation") +@pytest.mark.ui +@pytest.mark.regression_ui +class TestNodeDeploymentValidation: + + @allure.title("Select configuration button disabled until protocol selected") + @allure.severity(allure.severity_level.NORMAL) + def test_select_config_disabled_until_protocol_selected(self, page: Page, base_url: str, config): + """Verify 'Select configuration' button is disabled until protocol is selected.""" + + with allure.step("Login and navigate to protocol selection"): + login_page = LoginPage(page, base_url) + login_page.open() + login_page.login(config.admin_log, config.admin_pass) + + nodes_page = NodesListPage(page, base_url) + nodes_page.open() + page.click("button:has-text('+ Add node')") + + deployment_page = NodeDeploymentPage(page, base_url) + + with allure.step("Verify 'Select configuration' button is initially disabled"): + expect(page.locator(deployment_page.locators.SELECT_CONFIG_BUTTON)).to_be_disabled() + + with allure.step("Select a protocol"): + deployment_page.select_protocol("Ethereum Sepolia Reth Prysm") + + with allure.step("Verify 'Select configuration' button is now enabled"): + expect(page.locator(deployment_page.locators.SELECT_CONFIG_BUTTON)).to_be_enabled() + + @allure.title("Show summary button disabled until configuration selected") + @allure.severity(allure.severity_level.NORMAL) + def test_show_summary_disabled_until_config_selected(self, page: Page, base_url: str, config): + """Verify 'Show summary' button is disabled until configuration is selected.""" + protocol_name = "Ethereum Sepolia Reth Prysm" + + with allure.step("Login and navigate to configuration selection"): + login_page = LoginPage(page, base_url) + login_page.open() + login_page.login(config.admin_log, config.admin_pass) + + nodes_page = NodesListPage(page, base_url) + nodes_page.open() + page.click("button:has-text('+ Add node')") + + deployment_page = NodeDeploymentPage(page, base_url) + + with allure.step("Select protocol and proceed to configuration"): + deployment_page.select_protocol(protocol_name) + deployment_page.click_select_configuration() + + with allure.step("Verify 'Show summary' button is initially disabled"): + expect(page.locator(deployment_page.locators.SHOW_SUMMARY_BUTTON)).to_be_disabled() + + with allure.step("Select a configuration"): + deployment_page.select_configuration("Ethereum Sepolia Reth Prysm Nano") + + with allure.step("Verify 'Show summary' button is now enabled"): + expect(page.locator(deployment_page.locators.SHOW_SUMMARY_BUTTON)).to_be_enabled() + + +@allure.feature("Nodes") +@allure.story("Node Overview Page") +@pytest.mark.ui +@pytest.mark.regression_ui +class TestNodeOverviewPage: + + @allure.title("Node overview page shows correct tabs") + @allure.severity(allure.severity_level.NORMAL) + def test_node_overview_tabs(self, page: Page, base_url: str, config, existing_node_ui): + """Verify node overview page shows correct tabs.""" + node_id = existing_node_ui + + with allure.step("Login and navigate to node overview"): + login_page = LoginPage(page, base_url) + login_page.open() + login_page.login(config.admin_log, config.admin_pass) + page.goto(f"{base_url}/nodes/{node_id}") + + deployment_page = NodeDeploymentPage(page, base_url) + + with allure.step("Verify overview page loaded"): + deployment_page.verify_overview_page_loaded() + + with allure.step("Verify 'Overview' tab is active"): + deployment_page.verify_active_tab("Overview") + + with allure.step("Click 'Settings' tab"): + deployment_page.click_tab("Settings") + + with allure.step("Verify 'Settings' tab is now active"): + deployment_page.verify_active_tab("Settings") From c458f33e3636bf16b7ebc793dc821a1cebe22e00 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 29 Jan 2026 15:05:02 +0100 Subject: [PATCH 2/4] WIP --- tests/ui/locators/node_deployment_locators.py | 16 ++++- tests/ui/pages/node_deployment_page.py | 67 +++++++++++++++++++ tests/ui/test_node_deployment_e2e.py | 15 +++-- 3 files changed, 92 insertions(+), 6 deletions(-) diff --git a/tests/ui/locators/node_deployment_locators.py b/tests/ui/locators/node_deployment_locators.py index ba05921..6e4f33c 100644 --- a/tests/ui/locators/node_deployment_locators.py +++ b/tests/ui/locators/node_deployment_locators.py @@ -101,17 +101,29 @@ def config_card_by_name(name: str) -> str: # Nodes list page NODES_LIST_PAGE = ".nodes-list-page" NODES_LIST_HEADER = ".nodes-list-header" - NODE_ITEM = ".node-item" - NODE_ITEM_TITLE = ".node-item-title" + NODES_LIST_BODY = ".nodes-list-body" + NODES_LIST_ROW = ".nodes-list-row" NODE_STATUS_DROPDOWN = ".nodes-status-dropdown" STATUS_DROPDOWN_OPTION = ".nodes-status-option" ADD_NODE_BUTTON = "button:has-text('+ Add node')" + + # Node list row cells + NODE_LIST_NAME = ".nodes-list-node-name" + NODE_LIST_NETWORK = ".nodes-list-network-sub" + NODE_LIST_CREATED_AT = ".nodes-list-cell:nth-child(3)" + NODE_LIST_UPDATED_AT = ".nodes-list-cell:nth-child(4)" + NODE_LIST_STATUS = ".nodes-list-status-label" @staticmethod def node_by_name(name: str) -> str: """Get node item by name using text search in list body.""" return f".nodes-list-body >> text='{name}'" + @staticmethod + def node_row_by_name(name: str) -> str: + """Get node list row by name.""" + return f".nodes-list-row:has(.nodes-list-node-name:text('{name}'))" + @staticmethod def dropdown_option_by_name(name: str) -> str: """Get dropdown option by name.""" diff --git a/tests/ui/pages/node_deployment_page.py b/tests/ui/pages/node_deployment_page.py index f5cb84d..0444ddf 100644 --- a/tests/ui/pages/node_deployment_page.py +++ b/tests/ui/pages/node_deployment_page.py @@ -280,3 +280,70 @@ def get_node_name_from_title(self) -> str: """Extract node name from the page title.""" title = self.get_text(self.locators.NODE_DETAILS_TITLE) return title.strip() + + @allure.step("Get node ID from URL") + def get_node_id_from_url(self) -> str: + """Extract node ID from the current page URL (e.g., /nodes/{node_id}).""" + url = self.page.url + match = re.search(r'/nodes/([a-f0-9-]{36})', url) + if match: + return match.group(1) + raise ValueError(f"Could not extract node ID from URL: {url}") + + @allure.step("Get node info from list: {node_name}") + def get_node_list_info(self, node_name: str) -> dict: + """Extract node info from a row in the nodes list.""" + row = self.page.locator(self.locators.node_row_by_name(node_name)) + + return { + "name": row.locator(self.locators.NODE_LIST_NAME).text_content().strip(), + "network": row.locator(self.locators.NODE_LIST_NETWORK).text_content().strip(), + "created_at": row.locator(self.locators.NODE_LIST_CREATED_AT).text_content().strip(), + "updated_at": row.locator(self.locators.NODE_LIST_UPDATED_AT).text_content().strip(), + "status": row.locator(self.locators.NODE_LIST_STATUS).text_content().strip(), + } + + @allure.step("Verify node info in list") + def verify_node_list_info(self, node_name: str, api_client=None, node_id: str = None): + """ + Verify node info in the nodes list matches API data. + + Args: + node_name: Node name to find in list + api_client: Optional API client instance. If provided with node_id, + compares UI data with API response. + node_id: Optional node ID to fetch from API for comparison. + """ + ui_data = self.get_node_list_info(node_name) + + if api_client and node_id: + api_response = api_client.get_node(node_id) + assert api_response.status_code == 200, f"Failed to get node from API: {api_response.status_code}" + api_data = api_response.json() + + assert ui_data["name"] == api_data["name"], \ + f"Name mismatch: API='{api_data['name']}', UI='{ui_data['name']}'" + + assert api_data["network"] in ui_data["network"], \ + f"Network mismatch: API='{api_data['network']}', UI='{ui_data['network']}'" + + assert ui_data["status"].lower() == api_data["status"].lower(), \ + f"Status mismatch: API='{api_data['status']}', UI='{ui_data['status']}'" + + assert ui_data["created_at"] == api_data["created_at"], f"Created at should not be empty" + assert ui_data["updated_at"] == api_data["updated_at"], f"Updated at should not be empty" + + allure.attach( + str(api_data), + "API Data", + allure.attachment_type.JSON + ) + allure.attach( + str(ui_data), + "UI Data", + allure.attachment_type.JSON + ) + else: + assert ui_data["name"] == node_name, f"Expected name '{node_name}', got '{ui_data['name']}'" + assert ui_data["created_at"], "Created at should not be empty" + assert ui_data["updated_at"], "Updated at should not be empty" diff --git a/tests/ui/test_node_deployment_e2e.py b/tests/ui/test_node_deployment_e2e.py index 42b999e..e8e10ff 100644 --- a/tests/ui/test_node_deployment_e2e.py +++ b/tests/ui/test_node_deployment_e2e.py @@ -21,7 +21,7 @@ class TestNodeDeploymentE2E: ("Ethereum Mainnet Reth Prysm", "Ethereum Mainnet Reth Prysm Nano"), ("Ethereum Hoodi Reth Prysm", "Ethereum Hoodi Reth Prysm Nano"), ]) - def test_complete_node_deployment_flow(self, page: Page, base_url: str, config, protocol_name: str, config_name: str): + def test_complete_node_deployment_flow(self, page: Page, base_url: str, config, nodes_api_client, protocol_name: str, config_name: str): with allure.step("Login to the application"): login_page = LoginPage(page, base_url) @@ -75,7 +75,9 @@ def test_complete_node_deployment_flow(self, page: Page, base_url: str, config, with allure.step("Verify node overview page loaded"): deployment_page.wait_for_node_creation(timeout=NODE_STATUS_MAX_WAIT) deployment_page.verify_overview_page_loaded() - deployment_page.verify_node_info_card() + node_name = deployment_page.get_node_name_from_title() + node_id = deployment_page.get_node_id_from_url() + deployment_page.verify_node_info_card(api_client=nodes_api_client, node_id=node_id) with allure.step("Verify node status shows Bootstrapping"): deployment_page.verify_node_status("Bootstrapping") @@ -84,13 +86,18 @@ def test_complete_node_deployment_flow(self, page: Page, base_url: str, config, deployment_page.wait_for_particular_status("Running", timeout=NODE_STATUS_MAX_WAIT) deployment_page.verify_node_status("Running") - node_name = deployment_page.get_node_name_from_title() - with allure.step("Navigate to nodes list and verify deployed node is present"): nodes_page.open() nodes_page.verify_page_loaded() deployment_page.verify_node_in_list(node_name) + with allure.step("Verify node info in list matches API data"): + deployment_page.verify_node_list_info( + node_name=node_name, + api_client=nodes_api_client, + node_id=node_id + ) + with allure.step("Click on deployed node to open details"): deployment_page.click_node_in_list(node_name) deployment_page.verify_overview_page_loaded() From 6b1fc2f413c4068b7d79bde0f87cbb927e8d8130 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 30 Jan 2026 13:48:25 +0100 Subject: [PATCH 3/4] Added UI tests for nodes --- control_panel/node.py | 13 ++++- tests/ui/locators/node_deployment_locators.py | 2 + tests/ui/pages/node_deployment_page.py | 49 ++++++++++++++++--- tests/ui/test_node_deployment_e2e.py | 24 +++++---- 4 files changed, 71 insertions(+), 17 deletions(-) diff --git a/control_panel/node.py b/control_panel/node.py index af7c401..8742a1e 100644 --- a/control_panel/node.py +++ b/control_panel/node.py @@ -11,12 +11,23 @@ class NodePreset: ETH_SEPOLIA = "gts.c.cp.presets.blockchain_preset.v1.0~c.cp.presets.evm_preset.v1.0~c.cp.presets.evm_reth_prysm.v1.0~c.cp.presets.eth_sepolia.v1.0" ETH_MAINNET = "gts.c.cp.presets.blockchain_preset.v1.0~c.cp.presets.evm_preset.v1.0~c.cp.presets.evm_reth_prysm.v1.0~c.cp.presets.ethereum_mainnet.v1.0" +class NodeProtocol: + ETH_HOODIE = "Ethereum Hoodi Reth Prysm" + ETH_SEPOLIA = "Ethereum Sepolia Reth Prysm" + ETH_MAINNET = "Ethereum Mainnet Reth Prysm" +class NodeConfig: + ETH_SEPOLIA_NANO = "Ethereum Sepolia Reth Prysm Nano" + ETH_MAINNET_NANO = "Ethereum Mainnet Reth Prysm Nano" + ETH_HOODIE_NANO = "Ethereum Hoodi Reth Prysm Nano" + class Node: - def __init__(self, state: NodeState, preset: NodePreset): + def __init__(self, state: NodeState, preset: NodePreset, protocol: NodeProtocol, config: NodeConfig): self.state = state self.preset = preset + self.protocol = protocol + self.config = config \ No newline at end of file diff --git a/tests/ui/locators/node_deployment_locators.py b/tests/ui/locators/node_deployment_locators.py index 6e4f33c..54ed62f 100644 --- a/tests/ui/locators/node_deployment_locators.py +++ b/tests/ui/locators/node_deployment_locators.py @@ -106,6 +106,8 @@ def config_card_by_name(name: str) -> str: NODE_STATUS_DROPDOWN = ".nodes-status-dropdown" STATUS_DROPDOWN_OPTION = ".nodes-status-option" ADD_NODE_BUTTON = "button:has-text('+ Add node')" + NODES_LIST_SEARCH_INPUT = ".nodes-list-search-input" + NODES_LIST_FILTER_SELECT = ".nodes-list-filter-select" # Node list row cells NODE_LIST_NAME = ".nodes-list-node-name" diff --git a/tests/ui/pages/node_deployment_page.py b/tests/ui/pages/node_deployment_page.py index 0444ddf..b5cabfe 100644 --- a/tests/ui/pages/node_deployment_page.py +++ b/tests/ui/pages/node_deployment_page.py @@ -271,21 +271,53 @@ def click_node_in_list(self, node_name: str): @allure.step("Select status filter: {status}") def select_status_filter(self, status: str): """Select a status from the dropdown filter.""" - select_locator = self.page.locator(".nodes-list-filter-select") + select_locator = self.page.locator(self.locators.NODES_LIST_FILTER_SELECT) select_locator.select_option(label=status) self.page.wait_for_timeout(1000) + @allure.step("Search for node: {search_text}") + def search_nodes(self, search_text: str): + """Search for a node in the nodes list.""" + search_input = self.page.locator(self.locators.NODES_LIST_SEARCH_INPUT) + search_input.fill(search_text) + # Wait for search results to filter + self.page.wait_for_timeout(500) + + @allure.step("Verify only searched node is visible: {node_name}") + def verify_only_node_visible_in_search(self, node_name: str): + """Verify that only the searched node is visible in the list.""" + # Verify the target node is visible + node_locator = self.page.locator(self.locators.node_by_name(node_name)) + expect(node_locator.first).to_be_visible(timeout=5000) + + # Verify only one row is visible in the list + rows = self.page.locator(self.locators.NODES_LIST_ROW) + expect(rows).to_have_count(1, timeout=5000) + + @allure.step("Clear search input") + def clear_search(self): + """Clear the search input.""" + search_input = self.page.locator(self.locators.NODES_LIST_SEARCH_INPUT) + search_input.clear() + @allure.step("Get node name from title") def get_node_name_from_title(self) -> str: """Extract node name from the page title.""" - title = self.get_text(self.locators.NODE_DETAILS_TITLE) - return title.strip() + locator = self.page.locator(self.locators.NODE_DETAILS_TITLE) + expect(locator).not_to_be_empty(timeout=TIMEOUT_MAX) + title = locator.text_content() or "" + title = title.strip() + + if not title: + raise ValueError(f"Title text is empty after wait. URL: {self.page.url}") + + return title @allure.step("Get node ID from URL") def get_node_id_from_url(self) -> str: - """Extract node ID from the current page URL (e.g., /nodes/{node_id}).""" + """Extract node ID from the current page URL (e.g., /node-details/{node_id}).""" url = self.page.url - match = re.search(r'/nodes/([a-f0-9-]{36})', url) + match = re.search(r'/(?:nodes|node-details)/([a-f0-9-]{36})', url) if match: return match.group(1) raise ValueError(f"Could not extract node ID from URL: {url}") @@ -330,8 +362,11 @@ def verify_node_list_info(self, node_name: str, api_client=None, node_id: str = assert ui_data["status"].lower() == api_data["status"].lower(), \ f"Status mismatch: API='{api_data['status']}', UI='{ui_data['status']}'" - assert ui_data["created_at"] == api_data["created_at"], f"Created at should not be empty" - assert ui_data["updated_at"] == api_data["updated_at"], f"Updated at should not be empty" + # Note: UI and API dates use different formats, so just verify they exist + assert ui_data["created_at"], \ + f"Created at should not be empty (API: {api_data.get('created_at', 'N/A')})" + assert ui_data["updated_at"], \ + f"Updated at should not be empty (API: {api_data.get('updated_at', 'N/A')})" allure.attach( str(api_data), diff --git a/tests/ui/test_node_deployment_e2e.py b/tests/ui/test_node_deployment_e2e.py index e8e10ff..b962e2c 100644 --- a/tests/ui/test_node_deployment_e2e.py +++ b/tests/ui/test_node_deployment_e2e.py @@ -6,6 +6,7 @@ from tests.ui.pages.nodes_page import NodesListPage from tests.ui.pages.node_deployment_page import NodeDeploymentPage from tests.ui.constants.ui_constants import TIMEOUT_MAX, NODE_STATUS_MAX_WAIT +from control_panel.node import NodeProtocol, NodeConfig @allure.feature("Nodes") @@ -17,11 +18,11 @@ class TestNodeDeploymentE2E: @allure.title("Complete node deployment flow - {protocol_name}") @allure.severity(allure.severity_level.CRITICAL) @pytest.mark.parametrize("protocol_name,config_name", [ - ("Ethereum Sepolia Reth Prysm", "Ethereum Sepolia Reth Prysm Nano"), - ("Ethereum Mainnet Reth Prysm", "Ethereum Mainnet Reth Prysm Nano"), - ("Ethereum Hoodi Reth Prysm", "Ethereum Hoodi Reth Prysm Nano"), + (NodeProtocol.ETH_SEPOLIA, NodeConfig.ETH_SEPOLIA_NANO), + (NodeProtocol.ETH_MAINNET, NodeConfig.ETH_MAINNET_NANO), + (NodeProtocol.ETH_HOODIE, NodeConfig.ETH_HOODIE_NANO), ]) - def test_complete_node_deployment_flow(self, page: Page, base_url: str, config, nodes_api_client, protocol_name: str, config_name: str): + def test_complete_node_deployment_flow(self, page: Page, base_url: str, config, authenticated_nodes_client, protocol_name: str, config_name: str): with allure.step("Login to the application"): login_page = LoginPage(page, base_url) @@ -77,7 +78,7 @@ def test_complete_node_deployment_flow(self, page: Page, base_url: str, config, deployment_page.verify_overview_page_loaded() node_name = deployment_page.get_node_name_from_title() node_id = deployment_page.get_node_id_from_url() - deployment_page.verify_node_info_card(api_client=nodes_api_client, node_id=node_id) + deployment_page.verify_node_info_card(api_client=authenticated_nodes_client, node_id=node_id) with allure.step("Verify node status shows Bootstrapping"): deployment_page.verify_node_status("Bootstrapping") @@ -94,10 +95,15 @@ def test_complete_node_deployment_flow(self, page: Page, base_url: str, config, with allure.step("Verify node info in list matches API data"): deployment_page.verify_node_list_info( node_name=node_name, - api_client=nodes_api_client, + api_client=authenticated_nodes_client, node_id=node_id ) + with allure.step("Search for node and verify it's visible"): + deployment_page.search_nodes(node_name) + deployment_page.verify_only_node_visible_in_search(node_name) + deployment_page.clear_search() + with allure.step("Click on deployed node to open details"): deployment_page.click_node_in_list(node_name) deployment_page.verify_overview_page_loaded() @@ -253,15 +259,15 @@ class TestNodeOverviewPage: @allure.title("Node overview page shows correct tabs") @allure.severity(allure.severity_level.NORMAL) - def test_node_overview_tabs(self, page: Page, base_url: str, config, existing_node_ui): + def test_node_overview_tabs(self, page: Page, base_url: str, config, existing_node_id): """Verify node overview page shows correct tabs.""" - node_id = existing_node_ui + node_id = existing_node_id with allure.step("Login and navigate to node overview"): login_page = LoginPage(page, base_url) login_page.open() login_page.login(config.admin_log, config.admin_pass) - page.goto(f"{base_url}/nodes/{node_id}") + page.goto(f"{base_url}/node-details/{node_id}") deployment_page = NodeDeploymentPage(page, base_url) From 96b674d8d45935b8e369595614e80b6ea4c396c3 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 30 Jan 2026 14:04:29 +0100 Subject: [PATCH 4/4] Fix --- tests/ui/pages/node_deployment_page.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/ui/pages/node_deployment_page.py b/tests/ui/pages/node_deployment_page.py index b5cabfe..10697a7 100644 --- a/tests/ui/pages/node_deployment_page.py +++ b/tests/ui/pages/node_deployment_page.py @@ -39,7 +39,7 @@ def click_select_configuration(self): @allure.step("Verify protocol card selected: {protocol_name}") def verify_protocol_selected(self, protocol_name: str): """Verify a protocol card is selected.""" - card_selector = self.locators.protocol_card_by_name(protocol_name) + self.locators.protocol_card_by_name(protocol_name) # After selection, button should be enabled expect(self.page.locator(self.locators.SELECT_CONFIG_BUTTON)).to_be_enabled() @@ -328,11 +328,11 @@ def get_node_list_info(self, node_name: str) -> dict: row = self.page.locator(self.locators.node_row_by_name(node_name)) return { - "name": row.locator(self.locators.NODE_LIST_NAME).text_content().strip(), - "network": row.locator(self.locators.NODE_LIST_NETWORK).text_content().strip(), - "created_at": row.locator(self.locators.NODE_LIST_CREATED_AT).text_content().strip(), - "updated_at": row.locator(self.locators.NODE_LIST_UPDATED_AT).text_content().strip(), - "status": row.locator(self.locators.NODE_LIST_STATUS).text_content().strip(), + "name": (row.locator(self.locators.NODE_LIST_NAME).text_content() or "").strip(), + "network": (row.locator(self.locators.NODE_LIST_NETWORK).text_content() or "").strip(), + "created_at": (row.locator(self.locators.NODE_LIST_CREATED_AT).text_content() or "").strip(), + "updated_at": (row.locator(self.locators.NODE_LIST_UPDATED_AT).text_content() or "").strip(), + "status": (row.locator(self.locators.NODE_LIST_STATUS).text_content() or "").strip(), } @allure.step("Verify node info in list")