Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions tests/ui/constants/ui_constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
TIMEOUT_MAX = 3000
26 changes: 26 additions & 0 deletions tests/ui/locators/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
51 changes: 51 additions & 0 deletions tests/ui/locators/base_locators.py
Original file line number Diff line number Diff line change
@@ -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"
134 changes: 134 additions & 0 deletions tests/ui/locators/monitoring_locators.py
Original file line number Diff line number Diff line change
@@ -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"
Comment on lines +7 to +11
Copy link

@coderabbitai coderabbitai bot Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fragile selector: data-v-* attributes are auto-generated by Vue.

MONITORING_WRAPPER = "div[data-v-3904b986]" uses a Vue scoped CSS hash that will change when the component is recompiled. This makes the locator brittle and likely to break across builds.

Consider using a stable selector such as a semantic class name, data-testid attribute, or another structural selector.

🐛 Suggested fix
     # Page structure
-    MONITORING_WRAPPER = "div[data-v-3904b986]"
+    MONITORING_WRAPPER = ".monitoring-wrapper"  # Add this class to the component or use data-testid
🤖 Prompt for AI Agents
In @tests/ui/locators/monitoring_locators.py around lines 7 - 11,
MONITORING_WRAPPER currently uses a Vue-generated attribute selector
("div[data-v-...]" ) which is brittle; update the MONITORING_WRAPPER constant in
monitoring_locators.py to use a stable selector (e.g., a semantic class like
".monitoring-wrapper" or a data-testid such as
'div[data-testid="monitoring-wrapper"]'), and then update any tests referencing
MONITORING_WRAPPER to use the new stable selector so locators won’t break when
Vue recompiles scoped styles.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All selectors will be revised once we have a stable UI without many fixes.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@0xc7c5, understood! That makes sense—it's practical to wait until the UI is more stable before investing time in refactoring all the selectors. Feel free to revisit this when you're ready to lock down the locator strategy. 👍


# 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"
70 changes: 70 additions & 0 deletions tests/ui/locators/nodes_locators.py
Original file line number Diff line number Diff line change
@@ -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"
103 changes: 103 additions & 0 deletions tests/ui/locators/settings_locators.py
Original file line number Diff line number Diff line change
@@ -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 on parent form-field."""
return f".form-field[placeholder='{placeholder}'] .form-input"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems still incorrect. The placeholder attribute belongs to elements, not to .form-field wrapper. This selector will never match anything.
Am I right that it should be return f"input[placeholder='{placeholder}']"?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, because actual input has placeholder=" ". Therefore to get input via placeholder we should address it through parent.


@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')"
Loading