From c814777fb082f2a0f926fe5eef500f1969a7b88b Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 7 Jan 2026 21:02:26 +0100 Subject: [PATCH 1/2] WIP --- tests/ui/locators/__init__.py | 26 ++++ tests/ui/locators/base_locators.py | 51 ++++++++ tests/ui/locators/monitoring_locators.py | 134 ++++++++++++++++++++ tests/ui/locators/nodes_locators.py | 70 +++++++++++ tests/ui/locators/settings_locators.py | 103 +++++++++++++++ tests/ui/pages/__init__.py | 16 +++ tests/ui/pages/dashboard_page.py | 65 ++++++++++ tests/ui/pages/monitoring_page.py | 152 +++++++++++++++++++++++ tests/ui/pages/nodes_page.py | 116 +++++++++++++++++ tests/ui/pages/settings_page.py | 139 +++++++++++++++++++++ 10 files changed, 872 insertions(+) create mode 100644 tests/ui/locators/__init__.py create mode 100644 tests/ui/locators/base_locators.py create mode 100644 tests/ui/locators/monitoring_locators.py create mode 100644 tests/ui/locators/nodes_locators.py create mode 100644 tests/ui/locators/settings_locators.py create mode 100644 tests/ui/pages/dashboard_page.py create mode 100644 tests/ui/pages/monitoring_page.py create mode 100644 tests/ui/pages/nodes_page.py create mode 100644 tests/ui/pages/settings_page.py diff --git a/tests/ui/locators/__init__.py b/tests/ui/locators/__init__.py new file mode 100644 index 0000000..73a2058 --- /dev/null +++ b/tests/ui/locators/__init__.py @@ -0,0 +1,26 @@ +from .base_locators import BaseLocators, SidebarNavigationLocators +from .login_page_locators import LoginPageLocators +from .dashboard_page_locators import DashboardPageLocators +from .nodes_locators import NodesPageLocators, NodesListLocators +from .settings_locators import SettingsPageLocators, SettingsFormLocators +from .monitoring_locators import ( + MonitoringPageLocators, + MonitoringCommandsLocators, + MonitoringMetricsLocators +) + +__all__ = [ + # Base + "BaseLocators", + "SidebarNavigationLocators", + # Pages + "LoginPageLocators", + "DashboardPageLocators", + "NodesPageLocators", + "NodesListLocators", + "SettingsPageLocators", + "SettingsFormLocators", + "MonitoringPageLocators", + "MonitoringCommandsLocators", + "MonitoringMetricsLocators", +] diff --git a/tests/ui/locators/base_locators.py b/tests/ui/locators/base_locators.py new file mode 100644 index 0000000..2840264 --- /dev/null +++ b/tests/ui/locators/base_locators.py @@ -0,0 +1,51 @@ +"""Base locators for common UI elements across all pages.""" + + +class BaseLocators: + """Common locators shared across all pages.""" + + # App container + APP_CONTAINER = "#app" + CP_SHELL = ".cp-shell" + + # Sidebar + SIDEBAR = ".sidebar" + SIDEBAR_TOP = ".sidebar-top" + SIDEBAR_BOTTOM = ".sidebar-bottom" + SIDEBAR_LOGO_BUTTON = ".sidebar-logo-button" + SIDEBAR_VERSION = ".sidebar-version" + + # Navigation + SIDEBAR_NAV = ".sidebar-nav" + SIDEBAR_NAV_ITEM = ".sidebar-nav-item" + SIDEBAR_NAV_ITEM_ACTIVE = ".sidebar-nav-item-active" + SIDEBAR_NAV_LABEL = ".sidebar-nav-label" + SIDEBAR_NAV_LABEL_ACTIVE = ".sidebar-nav-label-active" + SIDEBAR_NAV_ICON = ".sidebar-nav-icon" + + # Main content + CP_SHELL_MAIN = ".cp-shell-main" + + # SVG icons + SVG_INLINE_ICON = ".svg-inline-icon" + SVG_ICON = ".svg-icon" + + +class SidebarNavigationLocators: + """Locators for sidebar navigation items.""" + + # Navigation items by icon class + NODES_NAV_ITEM = ".svg-inline-icon--nodes" + MONITORING_NAV_ITEM = ".svg-inline-icon--statistics" + DOCS_NAV_ITEM = ".svg-inline-icon--documentation" + ACTIVITY_NAV_ITEM = ".svg-inline-icon--bell" + SETTINGS_NAV_ITEM = ".svg-inline-icon--settings" + + # Navigation items by label text + @staticmethod + def nav_item_by_label(label: str) -> str: + """Get navigation item by label text.""" + return f"button.sidebar-nav-item:has(.sidebar-nav-label:text('{label}'))" + + # Active navigation item + ACTIVE_NAV_ITEM = ".sidebar-nav-item-active" diff --git a/tests/ui/locators/monitoring_locators.py b/tests/ui/locators/monitoring_locators.py new file mode 100644 index 0000000..3bab840 --- /dev/null +++ b/tests/ui/locators/monitoring_locators.py @@ -0,0 +1,134 @@ +"""Locators for Monitoring page elements.""" + + +class MonitoringPageLocators: + """Locators specific to the Monitoring page.""" + + # Page structure + MONITORING_WRAPPER = "div[data-v-3904b986]" + MONITORING_HEADER = ".monitoring-header" + MONITORING_TITLE = ".monitoring-title" + MONITORING_SUBTITLE = ".monitoring-subtitle" + + # Sections + MONITORING_SECTION = ".monitoring-section" + MONITORING_SECTION_TITLE = ".monitoring-section-title" + + # Steps card + MONITORING_STEPS_CARD = ".monitoring-steps-card" + MONITORING_STEP = ".monitoring-step" + MONITORING_STEP_NUMBER = ".monitoring-step-number" + MONITORING_STEP_CONTENT = ".monitoring-step-content" + MONITORING_STEP_TITLE = ".monitoring-step-title" + MONITORING_STEP_TEXT = ".monitoring-step-text" + + # Command section + MONITORING_COMMAND_ROW = ".monitoring-command-row" + MONITORING_COMMAND_TEXT = ".monitoring-command-text" + MONITORING_ICON_BUTTON = ".monitoring-icon-button" + + # Buttons + COPY_BUTTON = "button:has(.monitoring-icon-button)" + + # Specific steps + @staticmethod + def step_by_number(number: int) -> str: + """Get monitoring step by number.""" + return f".monitoring-step:has(.monitoring-step-number:text('{number}'))" + + INSTALL_EXPORTERS_STEP = ".monitoring-step:has(.monitoring-step-title:text('Install Exporters'))" + CONFIGURE_GRAFANA_STEP = ".monitoring-step:has(.monitoring-step-title:text('Configure Grafana'))" + ADD_DATASOURCE_STEP = ".monitoring-step:has(.monitoring-step-title:text('Add Data Source'))" + + # Code/Command blocks + CODE_BLOCK = "code" + COMMAND_CODE = "code.monitoring-command-text" + + # Links + MONITORING_LINK = ".monitoring-link" + EXTERNAL_LINK = "a[target='_blank']" + + # Documentation links + GRAFANA_DOCS_LINK = "a:has-text('Grafana')" + PROMETHEUS_DOCS_LINK = "a:has-text('Prometheus')" + + # Metrics section + METRICS_SECTION = ".metrics-section" + METRICS_CARD = ".metrics-card" + METRICS_TITLE = ".metrics-title" + METRICS_VALUE = ".metrics-value" + METRICS_CHART = ".metrics-chart" + + # Dashboard section + DASHBOARD_SECTION = ".dashboard-section" + DASHBOARD_CARD = ".dashboard-card" + DASHBOARD_TITLE = ".dashboard-title" + DASHBOARD_PREVIEW = ".dashboard-preview" + + +class MonitoringCommandsLocators: + """Locators for monitoring commands and code snippets.""" + + # Command types + KUBECTL_COMMAND = "code:has-text('kubectl')" + CURL_COMMAND = "code:has-text('curl')" + DOCKER_COMMAND = "code:has-text('docker')" + + # Copy functionality + COPY_ICON = ".monitoring-icon-button svg" + COPY_SUCCESS_MESSAGE = ".copy-success" + COPY_ERROR_MESSAGE = ".copy-error" + + # Command sections + @staticmethod + def command_by_text(text: str) -> str: + """Get command element by partial text.""" + return f"code:has-text('{text}')" + + # Specific commands + INSTALL_EXPORTERS_COMMAND = "code:has-text('curl -sSL https://get.chainstack.com/monitoring')" + KUBECTL_APPLY_COMMAND = "code:has-text('kubectl apply')" + + # Command output + COMMAND_OUTPUT = ".command-output" + COMMAND_SUCCESS = ".command-success" + COMMAND_ERROR = ".command-error" + + +class MonitoringMetricsLocators: + """Locators for monitoring metrics and dashboards.""" + + # Metrics display + METRIC_ITEM = ".metric-item" + METRIC_LABEL = ".metric-label" + METRIC_VALUE_TEXT = ".metric-value-text" + METRIC_UNIT = ".metric-unit" + METRIC_TREND = ".metric-trend" + + # Chart elements + CHART_CONTAINER = ".chart-container" + CHART_CANVAS = "canvas" + CHART_LEGEND = ".chart-legend" + CHART_TOOLTIP = ".chart-tooltip" + + # Time range selector + TIME_RANGE_SELECTOR = ".time-range-selector" + TIME_RANGE_BUTTON = ".time-range-button" + TIME_RANGE_CUSTOM = ".time-range-custom" + + # Refresh controls + REFRESH_BUTTON = "button:has-text('Refresh')" + AUTO_REFRESH_TOGGLE = ".auto-refresh-toggle" + REFRESH_INTERVAL = ".refresh-interval" + + # Export options + EXPORT_BUTTON = "button:has-text('Export')" + EXPORT_CSV = "button:has-text('Export CSV')" + EXPORT_PNG = "button:has-text('Export PNG')" + EXPORT_PDF = "button:has-text('Export PDF')" + + # Filters + METRICS_FILTER = ".metrics-filter" + FILTER_BY_NODE = ".filter-by-node" + FILTER_BY_TYPE = ".filter-by-type" + FILTER_BY_STATUS = ".filter-by-status" diff --git a/tests/ui/locators/nodes_locators.py b/tests/ui/locators/nodes_locators.py new file mode 100644 index 0000000..7943488 --- /dev/null +++ b/tests/ui/locators/nodes_locators.py @@ -0,0 +1,70 @@ +"""Locators for Nodes page elements.""" + + +class NodesPageLocators: + """Locators specific to the Nodes page.""" + + # Page wrapper + NODES_WRAPPER = ".nodes-wrapper" + + # First login screen + NODES_FIRST_LOGIN = ".nodes-first-login" + NODES_TITLE = ".nodes-title" + NODES_SUBTITLE = ".nodes-subtitle" + NODES_DESCRIPTION = ".nodes-description" + NODES_HEADER = ".nodes-header" + NODES_HEADER_TEXT = ".nodes-header-text" + + # Buttons + NODES_CREATE_BUTTON = ".nodes-create-button" + NODES_LINK_BUTTON = ".nodes-link-button" + PRIMARY_BUTTON = ".primary-button" + + # Bottom cards + NODES_BOTTOM_CARDS = ".nodes-bottom-cards" + INFO_CARD = ".info-card" + INFO_CARD_HEADER = ".info-card-header" + INFO_CARD_TITLE = ".info-card-title" + INFO_CARD_ICON = ".info-card-icon" + INFO_CARD_TEXT = ".info-card-text" + + # Specific buttons by text + @staticmethod + def button_by_text(text: str) -> str: + """Get button by text content.""" + return f"button:has-text('{text}')" + + # Create node button + CREATE_NODE_BUTTON = "button:has-text('Create node')" + READ_MORE_BUTTON = "button:has-text('Read more about deploying nodes')" + + # Info cards + LEARN_ABOUT_NODES_CARD = "button.info-card:has(.info-card-title:text('Learn about nodes'))" + DOCUMENTATION_CARD = "button.info-card:has(.info-card-title:text('Documentation'))" + SUPPORT_CARD = "button.info-card:has(.info-card-title:text('Support'))" + + +class NodesListLocators: + """Locators for nodes list view.""" + + # List page + NODES_LIST_PAGE = ".nodes-list-page" + NODES_LIST_HEADER = ".nodes-list-header" + NODES_LIST_TITLE = ".nodes-list-title" + + # Tabs + NODES_LIST_TABS = ".nodes-list-tabs" + NODES_LIST_TAB = ".nodes-list-tab" + NODES_LIST_TAB_ACTIVE = ".nodes-list-tab-active" + + # Node items + NODE_ITEM = ".node-item" + NODE_ITEM_HEADER = ".node-item-header" + NODE_ITEM_TITLE = ".node-item-title" + NODE_ITEM_STATUS = ".node-item-status" + NODE_ITEM_ACTIONS = ".node-item-actions" + + # Filters and search + NODES_SEARCH = ".nodes-search" + NODES_FILTER = ".nodes-filter" + NODES_SORT = ".nodes-sort" diff --git a/tests/ui/locators/settings_locators.py b/tests/ui/locators/settings_locators.py new file mode 100644 index 0000000..fde5221 --- /dev/null +++ b/tests/ui/locators/settings_locators.py @@ -0,0 +1,103 @@ +"""Locators for Settings page elements.""" + + +class SettingsPageLocators: + """Locators specific to the Settings page.""" + + # Page structure + SETTINGS_WRAPPER = ".nodes-list-page" + SETTINGS_HEADER = ".nodes-list-header" + SETTINGS_TITLE = ".nodes-list-title" + + # Tabs + SETTINGS_TABS = ".nodes-list-tabs" + SETTINGS_TAB = ".nodes-list-tab" + SETTINGS_TAB_ACTIVE = ".nodes-list-tab-active" + PERSONAL_TAB = "button.nodes-list-tab:has-text('Personal')" + + # Welcome/Settings section + WELCOME_RIGHT = ".welcome-right" + WELCOME_CARD = ".welcome-card" + WELCOME_CARD_TITLE = ".welcome-card-title" + WELCOME_CARD_SECTION = ".welcome-card-section" + WELCOME_CARD_SECTION_GRID = ".welcome-card-section-grid" + WELCOME_CARD_SECTION_TITLE = ".welcome-card-section-title" + WELCOME_CARD_SECTION_SUBTITLE = ".welcome-card-section-subtitle" + + # Form fields + FORM_FIELD = ".form-field" + WELCOME_INPUT = ".welcome-input" + FORM_INPUT_WRAP = ".form-input-wrap" + FORM_INPUT = ".form-input" + FORM_FLOATING_LABEL = ".form-floating-label" + FORM_INPUT_HAS_VALUE = ".form-input.has-value" + FORM_LABEL_FLOATED = ".form-floating-label.is-floated" + + # Specific input fields + USERNAME_INPUT = "input[autocomplete='username']" + EMAIL_INPUT = "input[autocomplete='email']" + PASSWORD_INPUT = "input[type='password']" + + # Buttons + SAVE_BUTTON = "button:has-text('Save')" + CANCEL_BUTTON = "button:has-text('Cancel')" + CHANGE_PASSWORD_BUTTON = "button:has-text('Change password')" + + # Sections by title + @staticmethod + def section_by_title(title: str) -> str: + """Get section by title text.""" + return f".welcome-card:has(.welcome-card-title:text('{title}'))" + + PERSONAL_INFO_SECTION = ".welcome-card:has(.welcome-card-title:text('Personal Information'))" + SECURITY_SECTION = ".welcome-card:has(.welcome-card-title:text('Security'))" + PREFERENCES_SECTION = ".welcome-card:has(.welcome-card-title:text('Preferences'))" + + # Input by placeholder + @staticmethod + def input_by_placeholder(placeholder: str) -> str: + """Get input field by placeholder text.""" + return f".form-field[placeholder='{placeholder}'] input" + + @staticmethod + def input_by_label(label: str) -> str: + """Get input field by label text.""" + return f".form-field:has(label:text('{label}')) input" + + +class SettingsFormLocators: + """Locators for form elements in Settings.""" + + # Form validation + FORM_ERROR = ".form-error" + FORM_SUCCESS = ".form-success" + FORM_HELPER_TEXT = ".form-helper-text" + + # Input states + INPUT_INVALID = "input[aria-invalid='true']" + INPUT_VALID = "input[aria-invalid='false']" + INPUT_DISABLED = "input:disabled" + INPUT_READONLY = "input:read-only" + + # Field groups + FIELD_GROUP = ".field-group" + FIELD_ROW = ".field-row" + FIELD_COLUMN = ".field-column" + + # Action buttons + SUBMIT_BUTTON = "button[type='submit']" + RESET_BUTTON = "button[type='reset']" + + # Modals/Dialogs + MODAL = ".modal" + MODAL_HEADER = ".modal-header" + MODAL_BODY = ".modal-body" + MODAL_FOOTER = ".modal-footer" + MODAL_CLOSE = ".modal-close" + + # Confirmation dialogs + CONFIRM_DIALOG = ".confirm-dialog" + CONFIRM_YES = "button:has-text('Yes')" + CONFIRM_NO = "button:has-text('No')" + CONFIRM_OK = "button:has-text('OK')" + CONFIRM_CANCEL = "button:has-text('Cancel')" diff --git a/tests/ui/pages/__init__.py b/tests/ui/pages/__init__.py index e69de29..cc9eaba 100644 --- a/tests/ui/pages/__init__.py +++ b/tests/ui/pages/__init__.py @@ -0,0 +1,16 @@ +from .base_page import BasePage +from .login_page import LoginPage +from .dashboard_page import DashboardPage +from .nodes_page import NodesPage, NodesListPage +from .settings_page import SettingsPage +from .monitoring_page import MonitoringPage + +__all__ = [ + "BasePage", + "LoginPage", + "DashboardPage", + "NodesPage", + "NodesListPage", + "SettingsPage", + "MonitoringPage", +] diff --git a/tests/ui/pages/dashboard_page.py b/tests/ui/pages/dashboard_page.py new file mode 100644 index 0000000..57ba220 --- /dev/null +++ b/tests/ui/pages/dashboard_page.py @@ -0,0 +1,65 @@ +import allure +from playwright.sync_api import Page +from tests.ui.pages.base_page import BasePage +from tests.ui.locators.dashboard_page_locators import DashboardPageLocators + + +class DashboardPage(BasePage): + """Dashboard/Welcome page object.""" + + def __init__(self, page: Page, base_url: str): + super().__init__(page, base_url) + self.locators = DashboardPageLocators() + + @allure.step("Open dashboard page") + def open(self): + """Navigate to dashboard page.""" + self.navigate("/") + self.wait_for_load() + + @allure.step("Verify page loaded") + def verify_page_loaded(self): + """Verify dashboard page is loaded.""" + self.verify_element_visible(self.locators.welcome_page) + self.verify_element_visible(self.locators.welcome_header) + + @allure.step("Fill setup form") + def fill_setup_form(self, email: str, password: str, password_repeat: str, license_key: str): + """Fill the initial setup form.""" + self.wait_for_element(self.locators.email_input) + self.fill(self.locators.email_input, email) + self.fill(self.locators.password_input, password) + self.fill(self.locators.password_repeat_input, password_repeat) + self.fill(self.locators.license_key_input, license_key) + + @allure.step("Submit setup form") + def submit_setup_form(self): + """Click submit button.""" + self.click(self.locators.submit_button) + + @allure.step("Toggle password visibility") + def toggle_password_visibility(self): + """Toggle password visibility.""" + self.click(self.locators.password_toggle) + + @allure.step("Verify feature cards visible") + def verify_feature_cards_visible(self): + """Verify all feature cards are displayed.""" + self.verify_element_visible(self.locators.one_click_deployment_label) + self.verify_element_visible(self.locators.resource_management_label) + self.verify_element_visible(self.locators.healthcheck_label) + + @allure.step("Click Chainstack console link") + def click_chainstack_console_link(self): + """Click the Chainstack console link.""" + self.click(self.locators.chainstack_console_link) + + @allure.step("Verify form validation error") + def verify_form_error(self, expected_error: str = None): + """Verify form validation error is displayed.""" + self.wait_for_element(self.locators.error_message, timeout=3000) + error_text = self.get_text(self.locators.error_message) + allure.attach(error_text, "Form Error", allure.attachment_type.TEXT) + + if expected_error: + assert expected_error in error_text, f"Expected '{expected_error}' in error, got '{error_text}'" diff --git a/tests/ui/pages/monitoring_page.py b/tests/ui/pages/monitoring_page.py new file mode 100644 index 0000000..a1f690a --- /dev/null +++ b/tests/ui/pages/monitoring_page.py @@ -0,0 +1,152 @@ +import allure +from playwright.sync_api import Page +from tests.ui.pages.base_page import BasePage +from tests.ui.locators.monitoring_locators import ( + MonitoringPageLocators, + MonitoringCommandsLocators, + MonitoringMetricsLocators +) + + +class MonitoringPage(BasePage): + """Monitoring page object.""" + + def __init__(self, page: Page, base_url: str): + super().__init__(page, base_url) + self.locators = MonitoringPageLocators() + self.commands = MonitoringCommandsLocators() + self.metrics = MonitoringMetricsLocators() + + @allure.step("Open monitoring page") + def open(self): + """Navigate to monitoring page.""" + self.navigate("/monitoring") + self.wait_for_load() + + @allure.step("Verify page loaded") + def verify_page_loaded(self): + """Verify monitoring page is loaded.""" + self.verify_element_visible(self.locators.MONITORING_HEADER) + self.verify_element_visible(self.locators.MONITORING_TITLE) + + @allure.step("Verify monitoring steps visible") + def verify_steps_visible(self): + """Verify monitoring setup steps are visible.""" + self.verify_element_visible(self.locators.MONITORING_STEPS_CARD) + self.verify_element_visible(self.locators.MONITORING_STEP) + + @allure.step("Get step count") + def get_step_count(self) -> int: + """Get the number of monitoring steps.""" + return self.page.locator(self.locators.MONITORING_STEP).count() + + @allure.step("Verify step: {step_number}") + def verify_step_visible(self, step_number: int): + """Verify a specific step is visible.""" + step_selector = self.locators.step_by_number(step_number) + self.verify_element_visible(step_selector) + + @allure.step("Get command text from step: {step_number}") + def get_command_text(self, step_number: int) -> str: + """Get command text from a specific step.""" + step_selector = self.locators.step_by_number(step_number) + command_selector = f"{step_selector} {self.locators.MONITORING_COMMAND_TEXT}" + return self.get_text(command_selector) + + @allure.step("Click copy button for command") + def click_copy_command(self): + """Click the copy button for a command.""" + self.click(self.locators.COPY_BUTTON) + + @allure.step("Verify install exporters command visible") + def verify_install_exporters_command_visible(self): + """Verify the install exporters command is visible.""" + self.verify_element_visible(self.commands.INSTALL_EXPORTERS_COMMAND) + + @allure.step("Get install exporters command") + def get_install_exporters_command(self) -> str: + """Get the install exporters command text.""" + return self.get_text(self.commands.INSTALL_EXPORTERS_COMMAND) + + @allure.step("Verify kubectl command visible") + def verify_kubectl_command_visible(self): + """Verify kubectl command is visible.""" + self.verify_element_visible(self.commands.KUBECTL_COMMAND) + + @allure.step("Verify curl command visible") + def verify_curl_command_visible(self): + """Verify curl command is visible.""" + self.verify_element_visible(self.commands.CURL_COMMAND) + + @allure.step("Click external link: {link_text}") + def click_external_link(self, link_text: str): + """Click an external documentation link.""" + link_selector = f"a:has-text('{link_text}')" + self.click(link_selector) + + @allure.step("Verify Grafana docs link visible") + def verify_grafana_docs_link_visible(self): + """Verify Grafana documentation link is visible.""" + self.verify_element_visible(self.locators.GRAFANA_DOCS_LINK) + + @allure.step("Get metrics count") + def get_metrics_count(self) -> int: + """Get the number of metrics displayed.""" + return self.page.locator(self.metrics.METRIC_ITEM).count() + + @allure.step("Verify metric visible: {metric_name}") + def verify_metric_visible(self, metric_name: str): + """Verify a specific metric is visible.""" + metric_selector = f".metric-item:has(.metric-label:text('{metric_name}'))" + self.verify_element_visible(metric_selector) + + @allure.step("Get metric value: {metric_name}") + def get_metric_value(self, metric_name: str) -> str: + """Get the value of a specific metric.""" + metric_selector = f".metric-item:has(.metric-label:text('{metric_name}')) .metric-value-text" + return self.get_text(metric_selector) + + @allure.step("Click refresh button") + def click_refresh(self): + """Click the refresh button.""" + self.click(self.metrics.REFRESH_BUTTON) + + @allure.step("Select time range: {range_name}") + def select_time_range(self, range_name: str): + """Select a time range for metrics.""" + range_selector = f".time-range-button:has-text('{range_name}')" + self.click(range_selector) + + @allure.step("Click export button") + def click_export(self): + """Click the export button.""" + self.click(self.metrics.EXPORT_BUTTON) + + @allure.step("Export as CSV") + def export_as_csv(self): + """Export metrics as CSV.""" + self.click(self.metrics.EXPORT_CSV) + + @allure.step("Verify chart visible") + def verify_chart_visible(self): + """Verify metrics chart is visible.""" + self.verify_element_visible(self.metrics.CHART_CONTAINER) + + @allure.step("Toggle auto-refresh") + def toggle_auto_refresh(self): + """Toggle auto-refresh for metrics.""" + self.click(self.metrics.AUTO_REFRESH_TOGGLE) + + @allure.step("Apply filter: {filter_type} = {filter_value}") + def apply_filter(self, filter_type: str, filter_value: str): + """Apply a filter to metrics.""" + if filter_type == "node": + self.click(self.metrics.FILTER_BY_NODE) + elif filter_type == "type": + self.click(self.metrics.FILTER_BY_TYPE) + elif filter_type == "status": + self.click(self.metrics.FILTER_BY_STATUS) + + # Select filter value + filter_option = f"[role='option']:has-text('{filter_value}')" + self.click(filter_option) diff --git a/tests/ui/pages/nodes_page.py b/tests/ui/pages/nodes_page.py new file mode 100644 index 0000000..63ccba9 --- /dev/null +++ b/tests/ui/pages/nodes_page.py @@ -0,0 +1,116 @@ +import allure +from playwright.sync_api import Page +from tests.ui.pages.base_page import BasePage +from tests.ui.locators.nodes_locators import NodesPageLocators, NodesListLocators + + +class NodesPage(BasePage): + """Nodes page object (first login/welcome screen).""" + + def __init__(self, page: Page, base_url: str): + super().__init__(page, base_url) + self.locators = NodesPageLocators() + + @allure.step("Open nodes page") + def open(self): + """Navigate to nodes page.""" + self.navigate("/nodes") + self.wait_for_load() + + @allure.step("Verify page loaded") + def verify_page_loaded(self): + """Verify nodes welcome page is loaded.""" + self.verify_element_visible(self.locators.NODES_FIRST_LOGIN) + self.verify_element_visible(self.locators.NODES_TITLE) + self.verify_element_visible(self.locators.NODES_CREATE_BUTTON) + + @allure.step("Click create node button") + def click_create_node(self): + """Click the create node button.""" + self.click(self.locators.NODES_CREATE_BUTTON) + + @allure.step("Click read more button") + def click_read_more(self): + """Click the read more about deploying nodes button.""" + self.click(self.locators.NODES_LINK_BUTTON) + + @allure.step("Verify welcome message") + def verify_welcome_message(self): + """Verify welcome message is displayed.""" + title_text = self.get_text(self.locators.NODES_TITLE) + assert "Welcome" in title_text, f"Expected 'Welcome' in title, got '{title_text}'" + + subtitle_text = self.get_text(self.locators.NODES_SUBTITLE) + assert "deploy" in subtitle_text.lower(), f"Expected 'deploy' in subtitle, got '{subtitle_text}'" + + @allure.step("Verify info cards visible") + def verify_info_cards_visible(self): + """Verify info cards are displayed.""" + self.verify_element_visible(self.locators.NODES_BOTTOM_CARDS) + self.verify_element_visible(self.locators.INFO_CARD) + + @allure.step("Click info card: {card_title}") + def click_info_card(self, card_title: str): + """Click an info card by title.""" + card_selector = f".info-card:has(.info-card-title:text('{card_title}'))" + self.click(card_selector) + + +class NodesListPage(BasePage): + """Nodes list page object.""" + + def __init__(self, page: Page, base_url: str): + super().__init__(page, base_url) + self.locators = NodesListLocators() + + @allure.step("Open nodes list page") + def open(self): + """Navigate to nodes list page.""" + self.navigate("/nodes") + self.wait_for_load() + + @allure.step("Verify page loaded") + def verify_page_loaded(self): + """Verify nodes list page is loaded.""" + self.verify_element_visible(self.locators.NODES_LIST_PAGE) + self.verify_element_visible(self.locators.NODES_LIST_HEADER) + + @allure.step("Click tab: {tab_name}") + def click_tab(self, tab_name: str): + """Click a tab by name.""" + tab_selector = f".nodes-list-tab:has-text('{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.NODES_LIST_TAB_ACTIVE) + assert tab_name in active_tab_text, f"Expected '{tab_name}' in active tab, got '{active_tab_text}'" + + @allure.step("Search for node: {search_term}") + def search_nodes(self, search_term: str): + """Search for nodes.""" + self.fill(self.locators.NODES_SEARCH, search_term) + + @allure.step("Get node count") + def get_node_count(self) -> int: + """Get the number of nodes displayed.""" + return self.page.locator(self.locators.NODE_ITEM).count() + + @allure.step("Click node: {node_name}") + def click_node(self, node_name: str): + """Click a node by name.""" + node_selector = f".node-item:has(.node-item-title:text('{node_name}'))" + self.click(node_selector) + + @allure.step("Verify node exists: {node_name}") + def verify_node_exists(self, node_name: str): + """Verify a node exists in the list.""" + node_selector = f".node-item:has(.node-item-title:text('{node_name}'))" + self.verify_element_visible(node_selector) + + @allure.step("Verify no nodes displayed") + def verify_no_nodes(self): + """Verify no nodes are displayed.""" + count = self.get_node_count() + assert count == 0, f"Expected 0 nodes, found {count}" diff --git a/tests/ui/pages/settings_page.py b/tests/ui/pages/settings_page.py new file mode 100644 index 0000000..5133aa4 --- /dev/null +++ b/tests/ui/pages/settings_page.py @@ -0,0 +1,139 @@ +import allure +from playwright.sync_api import Page +from tests.ui.pages.base_page import BasePage +from tests.ui.locators.settings_locators import SettingsPageLocators, SettingsFormLocators + + +class SettingsPage(BasePage): + """Settings page object.""" + + def __init__(self, page: Page, base_url: str): + super().__init__(page, base_url) + self.locators = SettingsPageLocators() + self.form_locators = SettingsFormLocators() + + @allure.step("Open settings page") + def open(self): + """Navigate to settings page.""" + self.navigate("/settings") + self.wait_for_load() + + @allure.step("Verify page loaded") + def verify_page_loaded(self): + """Verify settings page is loaded.""" + self.verify_element_visible(self.locators.SETTINGS_WRAPPER) + self.verify_element_visible(self.locators.SETTINGS_TITLE) + + @allure.step("Click tab: {tab_name}") + def click_tab(self, tab_name: str): + """Click a settings tab.""" + tab_selector = f".nodes-list-tab:has-text('{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.SETTINGS_TAB_ACTIVE) + assert tab_name in active_tab_text, f"Expected '{tab_name}' in active tab, got '{active_tab_text}'" + + @allure.step("Fill username: {username}") + def fill_username(self, username: str): + """Fill username field.""" + self.wait_for_element(self.locators.USERNAME_INPUT) + self.fill(self.locators.USERNAME_INPUT, username) + + @allure.step("Fill email: {email}") + def fill_email(self, email: str): + """Fill email field.""" + self.wait_for_element(self.locators.EMAIL_INPUT) + self.fill(self.locators.EMAIL_INPUT, email) + + @allure.step("Fill password") + def fill_password(self, password: str): + """Fill password field.""" + self.wait_for_element(self.locators.PASSWORD_INPUT) + self.fill(self.locators.PASSWORD_INPUT, password) + + @allure.step("Click save button") + def click_save(self): + """Click save button.""" + self.click(self.locators.SAVE_BUTTON) + + @allure.step("Click cancel button") + def click_cancel(self): + """Click cancel button.""" + self.click(self.locators.CANCEL_BUTTON) + + @allure.step("Click change password button") + def click_change_password(self): + """Click change password button.""" + self.click(self.locators.CHANGE_PASSWORD_BUTTON) + + @allure.step("Get username value") + def get_username_value(self) -> str: + """Get current username value.""" + return self.page.locator(self.locators.USERNAME_INPUT).input_value() + + @allure.step("Get email value") + def get_email_value(self) -> str: + """Get current email value.""" + return self.page.locator(self.locators.EMAIL_INPUT).input_value() + + @allure.step("Verify personal info section visible") + def verify_personal_info_section_visible(self): + """Verify personal information section is visible.""" + self.verify_element_visible(self.locators.PERSONAL_INFO_SECTION) + + @allure.step("Verify form error: {expected_error}") + def verify_form_error(self, expected_error: str = None): + """Verify form validation error.""" + self.wait_for_element(self.form_locators.FORM_ERROR, timeout=3000) + error_text = self.get_text(self.form_locators.FORM_ERROR) + allure.attach(error_text, "Form Error", allure.attachment_type.TEXT) + + if expected_error: + assert expected_error in error_text, f"Expected '{expected_error}' in error, got '{error_text}'" + + @allure.step("Verify form success") + def verify_form_success(self): + """Verify form submission success.""" + self.wait_for_element(self.form_locators.FORM_SUCCESS, timeout=5000) + success_text = self.get_text(self.form_locators.FORM_SUCCESS) + allure.attach(success_text, "Form Success", allure.attachment_type.TEXT) + + @allure.step("Verify input invalid: {field_name}") + def verify_input_invalid(self, field_name: str): + """Verify input field is marked as invalid.""" + input_selector = self.locators.input_by_label(field_name) + invalid_input = self.page.locator(input_selector).get_attribute("aria-invalid") + assert invalid_input == "true", f"Expected input '{field_name}' to be invalid" + + @allure.step("Update personal information") + def update_personal_info(self, username: str = None, email: str = None): + """Update personal information.""" + if username: + self.fill_username(username) + if email: + self.fill_email(email) + self.click_save() + + @allure.step("Verify section visible: {section_title}") + def verify_section_visible(self, section_title: str): + """Verify a section is visible by title.""" + section_selector = self.locators.section_by_title(section_title) + self.verify_element_visible(section_selector) + + @allure.step("Open confirmation dialog") + def verify_confirmation_dialog(self): + """Verify confirmation dialog is displayed.""" + self.verify_element_visible(self.form_locators.CONFIRM_DIALOG) + + @allure.step("Confirm action") + def confirm_action(self): + """Click confirm/yes button in dialog.""" + self.click(self.form_locators.CONFIRM_YES) + + @allure.step("Cancel action") + def cancel_action(self): + """Click cancel/no button in dialog.""" + self.click(self.form_locators.CONFIRM_NO) From a36c64f1a3ee7dc0a19c6d23428a53150b158607 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 12 Jan 2026 16:26:15 +0100 Subject: [PATCH 2/2] Fixes --- tests/ui/constants/ui_constants.py | 1 + tests/ui/locators/settings_locators.py | 4 ++-- tests/ui/pages/dashboard_page.py | 3 ++- tests/ui/pages/login_page.py | 3 ++- tests/ui/pages/monitoring_page.py | 2 ++ tests/ui/pages/settings_page.py | 5 +++-- 6 files changed, 12 insertions(+), 6 deletions(-) create mode 100644 tests/ui/constants/ui_constants.py diff --git a/tests/ui/constants/ui_constants.py b/tests/ui/constants/ui_constants.py new file mode 100644 index 0000000..2c71594 --- /dev/null +++ b/tests/ui/constants/ui_constants.py @@ -0,0 +1 @@ +TIMEOUT_MAX = 3000 \ No newline at end of file diff --git a/tests/ui/locators/settings_locators.py b/tests/ui/locators/settings_locators.py index fde5221..be04467 100644 --- a/tests/ui/locators/settings_locators.py +++ b/tests/ui/locators/settings_locators.py @@ -56,8 +56,8 @@ def section_by_title(title: str) -> str: # Input by placeholder @staticmethod def input_by_placeholder(placeholder: str) -> str: - """Get input field by placeholder text.""" - return f".form-field[placeholder='{placeholder}'] input" + """Get input field by placeholder text on parent form-field.""" + return f".form-field[placeholder='{placeholder}'] .form-input" @staticmethod def input_by_label(label: str) -> str: diff --git a/tests/ui/pages/dashboard_page.py b/tests/ui/pages/dashboard_page.py index 57ba220..6768495 100644 --- a/tests/ui/pages/dashboard_page.py +++ b/tests/ui/pages/dashboard_page.py @@ -2,6 +2,7 @@ from playwright.sync_api import Page from tests.ui.pages.base_page import BasePage from tests.ui.locators.dashboard_page_locators import DashboardPageLocators +from tests.ui.constants.ui_constants import TIMEOUT_MAX class DashboardPage(BasePage): @@ -57,7 +58,7 @@ def click_chainstack_console_link(self): @allure.step("Verify form validation error") def verify_form_error(self, expected_error: str = None): """Verify form validation error is displayed.""" - self.wait_for_element(self.locators.error_message, timeout=3000) + self.wait_for_element(self.locators.error_message, timeout=TIMEOUT_MAX) error_text = self.get_text(self.locators.error_message) allure.attach(error_text, "Form Error", allure.attachment_type.TEXT) diff --git a/tests/ui/pages/login_page.py b/tests/ui/pages/login_page.py index 7dd8efb..c79184e 100644 --- a/tests/ui/pages/login_page.py +++ b/tests/ui/pages/login_page.py @@ -3,6 +3,7 @@ from tests.ui.pages.base_page import BasePage from tests.ui.locators.login_page_locators import LoginPageLocators from tests.ui.pages.dashboard_page import DashboardPage +from tests.ui.constants.ui_constants import TIMEOUT_MAX class LoginPage(BasePage): """Login page object.""" @@ -78,7 +79,7 @@ def verify_login_successful(self): def verify_login_error(self, expected_error: str = None): # Wait for error message or check if login button is still disabled try: - self.wait_for_element(self.error_message, timeout=3000) + self.wait_for_element(self.error_message, timeout=TIMEOUT_MAX) error_text = self.get_text(self.error_message) allure.attach(error_text, "Login Error", allure.attachment_type.TEXT) diff --git a/tests/ui/pages/monitoring_page.py b/tests/ui/pages/monitoring_page.py index a1f690a..9a1fe24 100644 --- a/tests/ui/pages/monitoring_page.py +++ b/tests/ui/pages/monitoring_page.py @@ -146,6 +146,8 @@ def apply_filter(self, filter_type: str, filter_value: str): self.click(self.metrics.FILTER_BY_TYPE) elif filter_type == "status": self.click(self.metrics.FILTER_BY_STATUS) + else: + raise ValueError(f"Invalid filter type: {filter_type}") # Select filter value filter_option = f"[role='option']:has-text('{filter_value}')" diff --git a/tests/ui/pages/settings_page.py b/tests/ui/pages/settings_page.py index 5133aa4..fe58453 100644 --- a/tests/ui/pages/settings_page.py +++ b/tests/ui/pages/settings_page.py @@ -2,6 +2,7 @@ from playwright.sync_api import Page from tests.ui.pages.base_page import BasePage from tests.ui.locators.settings_locators import SettingsPageLocators, SettingsFormLocators +from tests.ui.constants.ui_constants import TIMEOUT_MAX class SettingsPage(BasePage): @@ -87,7 +88,7 @@ def verify_personal_info_section_visible(self): @allure.step("Verify form error: {expected_error}") def verify_form_error(self, expected_error: str = None): """Verify form validation error.""" - self.wait_for_element(self.form_locators.FORM_ERROR, timeout=3000) + self.wait_for_element(self.form_locators.FORM_ERROR, timeout=TIMEOUT_MAX) error_text = self.get_text(self.form_locators.FORM_ERROR) allure.attach(error_text, "Form Error", allure.attachment_type.TEXT) @@ -97,7 +98,7 @@ def verify_form_error(self, expected_error: str = None): @allure.step("Verify form success") def verify_form_success(self): """Verify form submission success.""" - self.wait_for_element(self.form_locators.FORM_SUCCESS, timeout=5000) + self.wait_for_element(self.form_locators.FORM_SUCCESS, timeout=TIMEOUT_MAX) success_text = self.get_text(self.form_locators.FORM_SUCCESS) allure.attach(success_text, "Form Success", allure.attachment_type.TEXT)