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/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..54ed62f --- /dev/null +++ b/tests/ui/locators/node_deployment_locators.py @@ -0,0 +1,134 @@ +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" + 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')" + 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" + 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.""" + 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..10697a7 --- /dev/null +++ b/tests/ui/pages/node_deployment_page.py @@ -0,0 +1,384 @@ +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.""" + 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(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.""" + 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., /node-details/{node_id}).""" + url = self.page.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}") + + @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() 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") + 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']}'" + + # 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), + "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 new file mode 100644 index 0000000..b962e2c --- /dev/null +++ b/tests/ui/test_node_deployment_e2e.py @@ -0,0 +1,284 @@ +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 +from control_panel.node import NodeProtocol, NodeConfig + + +@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", [ + (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, authenticated_nodes_client, 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() + 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=authenticated_nodes_client, node_id=node_id) + + 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") + + 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=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() + + 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_id): + """Verify node overview page shows correct tabs.""" + 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}/node-details/{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")