diff --git a/config/k8s/files/default.json5 b/config/k8s/files/default.json5 index ca2cd8f6..c34b26b6 100644 --- a/config/k8s/files/default.json5 +++ b/config/k8s/files/default.json5 @@ -1,5 +1,5 @@ { // KUBECONFIG environment variable on the cloud_platform system. "kubeconfig": "/etc/kubernetes/admin.conf", - "dashboard_port": "8443" + "dashboard_port": "30000" } \ No newline at end of file diff --git a/framework/web/webdriver_core.py b/framework/web/webdriver_core.py index db5b5a1c..cc59afef 100644 --- a/framework/web/webdriver_core.py +++ b/framework/web/webdriver_core.py @@ -2,6 +2,8 @@ import time from typing import List import selenium +from selenium import webdriver + from config.configuration_manager import ConfigurationManager from framework.logging.automation_logger import get_logger from framework.web.action.web_action_click import WebActionClick @@ -11,7 +13,6 @@ from framework.web.condition.web_condition import WebCondition from framework.web.condition.web_condition_text_equals import WebConditionTextEquals from framework.web.web_action_executor import WebActionExecutor from framework.web.web_locator import WebLocator -from selenium import webdriver class WebDriverCore: @@ -57,6 +58,7 @@ class WebDriverCore: is_navigation_success = True timeout = time.time() + 30 + reload_attempt_timeout = 2 while not is_navigation_success and time.time() < timeout: for condition in conditions: @@ -66,9 +68,10 @@ class WebDriverCore: if not is_navigation_success: get_logger().log_debug(f"Failed to load page with URL: {url}") - get_logger().log_debug("Sleep for 2 seconds and try reloading the page.") - time.sleep(2) + get_logger().log_debug(f"Reload page and sleep for {reload_attempt_timeout} seconds ") self.driver.get(url) + time.sleep(reload_attempt_timeout) + reload_attempt_timeout += 2 if is_navigation_success: get_logger().log_debug(f"Navigation to {url} successful.") diff --git a/keywords/cloud_platform/openstack/endpoint/openstack_endpoint_list_keywords.py b/keywords/cloud_platform/openstack/endpoint/openstack_endpoint_list_keywords.py index 7581ce59..2502fa63 100644 --- a/keywords/cloud_platform/openstack/endpoint/openstack_endpoint_list_keywords.py +++ b/keywords/cloud_platform/openstack/endpoint/openstack_endpoint_list_keywords.py @@ -13,12 +13,12 @@ class OpenStackEndpointListKeywords(BaseKeyword): def __init__(self, ssh_connection: SSHConnection): self.ssh_connection = ssh_connection - def endpoint_list(self): + def endpoint_list(self) -> OpenStackEndpointListOutput: """ - Keyword for openstack endpoint list + Executes the 'openstack endpoint list' command and parses the output. Returns: - OpenStackEndpointListOutput object + OpenStackEndpointListOutput: Parsed output of the 'openstack endpoint list' command. """ output = self.ssh_connection.send(source_openrc("openstack endpoint list")) self.validate_success_return_code(self.ssh_connection) @@ -32,5 +32,5 @@ class OpenStackEndpointListKeywords(BaseKeyword): """ endpoint_output = self.endpoint_list() url = endpoint_output.get_endpoint("keystone", "public").get_url().rsplit(":", 1)[0] - end_point = f"{url}:{ConfigurationManager.get_k8s_config().get_dashboard_port()}" + end_point = f"{url}:{ConfigurationManager.get_k8s_config().get_dashboard_port()}/" return end_point diff --git a/keywords/files/yaml_keywords.py b/keywords/files/yaml_keywords.py index f67fba7d..0ae54daa 100644 --- a/keywords/files/yaml_keywords.py +++ b/keywords/files/yaml_keywords.py @@ -1,10 +1,11 @@ import os import yaml +from jinja2 import Environment, FileSystemLoader, Template + from config.configuration_manager import ConfigurationManager from framework.logging.automation_logger import get_logger from framework.ssh.ssh_connection import SSHConnection -from jinja2 import Environment, FileSystemLoader, Template from keywords.base_keyword import BaseKeyword from keywords.files.file_keywords import FileKeywords @@ -22,25 +23,26 @@ class YamlKeywords(BaseKeyword): """ self.ssh_connection = ssh_connection - def generate_yaml_file_from_template(self, template_file: str, replacement_dictionary: str, target_file_name: str, target_remote_location: str) -> str: + def generate_yaml_file_from_template(self, template_file: str, replacement_dictionary: str, target_file_name: str, target_remote_location: str, copy_to_remote: bool = True) -> str: """ This function will generate a YAML file from the specified template. The parameters in the file will get substituted by using the key-value pairs from the replacement_dictionary. A copy of the file will be stored in the logs folder as 'target_file_name'. - It will then be SCPed over to 'target_remote_location' on the machine to which this SSH connection is connected. + It will then be SCPed over to 'target_remote_location' on the machine to which this SSH connection is connected Args: - template_file: Path in the repo to the Template YAML file. e.g. 'resources/cloud_platform/folder/file_name' - replacement_dictionary: A dictionary containing all the variables to replace in the template and the values that you want. - e.g. { pod_name: 'awesome_pod_name', memory: '2Gb'} - target_file_name: The name of the 'new' file that will get generated from this function. - target_remote_location: The folder location on the 'sshed-server' where we want to place the new file. - - Returns: The file name and path of the target_remote_file. + template_file (str): Path to the template YAML file. + Example: 'resources/cloud_platform/folder/file_name'. + replacement_dictionary (dict): Dictionary containing placeholder keys and their replacement values. + Example: { 'pod_name': 'awesome_pod_name', 'memory': '2Gb' }. + target_file_name (str): Name of the generated YAML file. + target_remote_location (str): Remote directory path where the file will be uploaded if `copy_to_remote` is True. + copy_to_remote (bool, optional): Flag indicating whether to upload the file to a remote location. Defaults to True. + Returns: + str: Path to the generated YAML file, either local or remote depending on `copy_to_remote`. """ - # Load the Template YAML file. - with open(template_file, 'r') as template_file: + with open(template_file, "r") as template_file: yaml_template = template_file.read() # Render the YAML file by replacing the tokens. @@ -52,11 +54,12 @@ class YamlKeywords(BaseKeyword): # Create the new file in the log folder. log_folder = ConfigurationManager.get_logger_config().get_test_case_resources_log_location() rendered_yaml_file_location = os.path.join(log_folder, target_file_name) - with open(rendered_yaml_file_location, 'w') as f: + with open(rendered_yaml_file_location, "w") as f: f.write(rendered_yaml) get_logger().log_info(f"Generated YAML file from template: {rendered_yaml_file_location}") - # Upload the file to the remote location - target_remote_file = f"{target_remote_location}/{target_file_name}" - FileKeywords(self.ssh_connection).upload_file(rendered_yaml_file_location, target_remote_file) - return target_remote_file + if copy_to_remote: + target_remote_file = f"{target_remote_location}/{target_file_name}" + FileKeywords(self.ssh_connection).upload_file(rendered_yaml_file_location, target_remote_file) + return target_remote_file + return rendered_yaml_file_location diff --git a/keywords/k8s/serviceaccount/kubectl_delete_serviceaccount_keywords.py b/keywords/k8s/serviceaccount/kubectl_delete_serviceaccount_keywords.py index a15589b0..c1a8ee08 100644 --- a/keywords/k8s/serviceaccount/kubectl_delete_serviceaccount_keywords.py +++ b/keywords/k8s/serviceaccount/kubectl_delete_serviceaccount_keywords.py @@ -18,40 +18,35 @@ class KubectlDeleteServiceAccountKeywords(BaseKeyword): """ self.ssh_connection = ssh_connection - def delete_serviceaccount(self, serviceaccount_name: str, nspace: str = None) -> str: + def delete_serviceaccount(self, serviceaccount_name: str, namespace: str = None): """ Deletes the specified Kubernetes service account. Args: serviceaccount_name (str): The name of the service account to delete. - nspace (str, optional): The namespace of the service account. Defaults to None. - - Returns: - str: The output of the kubectl delete command. + namespace (str, optional): The namespace of the service account. Defaults to None. """ args = "" - if nspace: - args += f" -n {nspace} " + if namespace: + args += f" -n {namespace} " args += f"{serviceaccount_name}" - output = self.ssh_connection.send(export_k8s_config(f"kubectl delete serviceaccount {args}")) + self.ssh_connection.send(export_k8s_config(f"kubectl delete serviceaccount {args}")) self.validate_success_return_code(self.ssh_connection) - return output - - def cleanup_serviceaccount(self, serviceaccount_name: str, nspace: str = None) -> str: + def cleanup_serviceaccount(self, serviceaccount_name: str, namespace: str = None) -> int: """ - Deletes a Kubernetes ServiceAccount,method is used for cleanup purposes. + Deletes a Kubernetes ServiceAccount. This method is used for cleanup purposes. Args: serviceaccount_name (str): The name of the ServiceAccount to delete. - nspace (str, optional): The namespace of the ServiceAccount. Defaults to None. + namespace (str, optional): The namespace of the ServiceAccount. Defaults to None. Returns: - str: The output of the command. + int: The return code of the kubectl delete command. """ args = "" - if nspace: - args += f" -n {nspace} " + if namespace: + args += f" -n {namespace} " args += f"{serviceaccount_name}" self.ssh_connection.send(export_k8s_config(f"kubectl delete serviceaccount {args}")) rc = self.ssh_connection.get_return_code() diff --git a/keywords/k8s/token/kubectl_create_token_keywords.py b/keywords/k8s/token/kubectl_create_token_keywords.py index 4d3e0c1d..8d748194 100644 --- a/keywords/k8s/token/kubectl_create_token_keywords.py +++ b/keywords/k8s/token/kubectl_create_token_keywords.py @@ -1,3 +1,5 @@ +from pytest import fail + from framework.ssh.ssh_connection import SSHConnection from keywords.base_keyword import BaseKeyword from keywords.k8s.k8s_command_wrapper import export_k8s_config @@ -17,19 +19,23 @@ class KubectlCreateTokenKeywords(BaseKeyword): """ self.ssh_connection = ssh_connection - def create_token(self, nspace: str, user: str) -> str: + def create_token(self, namespace: str, user: str) -> list: """ - Creates a Kubernetes token for a specified user in a given namespace. + Create a Kubernetes token for a specified user in a given namespace. Args: - nspace (str): The Kubernetes namespace where the token will be created. - user (str): The user for whom the token will be created. + namespace (str): The Kubernetes namespace where the token will be created. + user (str): The name of the Kubernetes service account for which the token will be created. Returns: - str: The output from the command execution. - + list: The output from the kubectl command execution. """ - args = f"{user} -n {nspace}" + args = f"{user} -n {namespace}" output = self.ssh_connection.send(export_k8s_config(f"kubectl create token {args}")) + if output and len(output) == 1: + output = output[0] + else: + fail("Token creation failed.") + self.validate_success_return_code(self.ssh_connection) return output diff --git a/resources/cloud_platform/containers/k8s_dashboard/kubeconfig.yaml b/resources/cloud_platform/containers/k8s_dashboard/kubeconfig.yaml index 4cb2435d..2d314a72 100644 --- a/resources/cloud_platform/containers/k8s_dashboard/kubeconfig.yaml +++ b/resources/cloud_platform/containers/k8s_dashboard/kubeconfig.yaml @@ -12,4 +12,4 @@ users: user: client-certificate-data: REDACTED client-key-data: REDACTED - token: {} + token: "{{token_value}}" diff --git a/testcases/cloud_platform/regression/containers/test_k8s_dashboard.py b/testcases/cloud_platform/regression/containers/test_k8s_dashboard.py index 91dcae70..b19afca2 100644 --- a/testcases/cloud_platform/regression/containers/test_k8s_dashboard.py +++ b/testcases/cloud_platform/regression/containers/test_k8s_dashboard.py @@ -1,5 +1,4 @@ import os -import time from pytest import fixture, mark @@ -9,9 +8,11 @@ from framework.logging.automation_logger import get_logger from framework.resources.resource_finder import get_stx_resource_path from framework.rest.rest_client import RestClient from framework.ssh.ssh_connection import SSHConnection +from framework.web.webdriver_core import WebDriverCore from keywords.cloud_platform.openstack.endpoint.openstack_endpoint_list_keywords import OpenStackEndpointListKeywords from keywords.cloud_platform.ssh.lab_connection_keywords import LabConnectionKeywords from keywords.files.file_keywords import FileKeywords +from keywords.files.yaml_keywords import YamlKeywords from keywords.k8s.files.kubectl_file_apply_keywords import KubectlFileApplyKeywords from keywords.k8s.files.kubectl_file_delete_keywords import KubectlFileDeleteKeywords from keywords.k8s.namespace.kubectl_create_namespace_keywords import KubectlCreateNamespacesKeywords @@ -23,6 +24,7 @@ from keywords.k8s.secret.kubectl_delete_secret_keywords import KubectlDeleteSecr from keywords.k8s.serviceaccount.kubectl_delete_serviceaccount_keywords import KubectlDeleteServiceAccountKeywords from keywords.k8s.token.kubectl_create_token_keywords import KubectlCreateTokenKeywords from keywords.openssl.openssl_keywords import OpenSSLKeywords +from web_pages.k8s_dashboard.login.k8s_login_page import K8sLoginPage def check_url_access(url: str) -> tuple: @@ -49,7 +51,7 @@ def copy_k8s_files(request: fixture, ssh_connection: SSHConnection): ssh_connection (SSHConnection): ssh connection object """ k8s_dashboard_dir = "k8s_dashboard" - dashboard_file_names = ["admin-user.yaml", "kubeconfig.yaml", "k8s_dashboard.yaml"] + dashboard_file_names = ["admin-user.yaml", "k8s_dashboard.yaml"] get_logger().log_info("Creating k8s_dashboard directory") ssh_connection.send("mkdir -p {}".format(k8s_dashboard_dir)) for dashboard_file_name in dashboard_file_names: @@ -112,9 +114,6 @@ def create_k8s_dashboard(request: fixture, namespace: str, con_ssh: SSHConnectio request.addfinalizer(teardown) KubectlApplyPatchKeywords(ssh_connection=con_ssh).apply_patch_service(svc_name=name, namespace=namespace, args_port=arg_port) - get_logger().log_info("Waiting 30s for the service to be up") - time.sleep(30) - get_logger().log_info(f"Verify that {name} is working") end_point = OpenStackEndpointListKeywords(ssh_connection=con_ssh).get_k8s_dashboard_url() status_code, _ = check_url_access(end_point) @@ -148,11 +147,11 @@ def get_k8s_token(request: fixture, con_ssh: SSHConnection) -> str: KubectlFileApplyKeywords(ssh_connection=con_ssh).apply_resource_from_yaml(admin_user_file_path) get_logger().log_info("Creating the token for admin-user") - token = KubectlCreateTokenKeywords(ssh_connection=con_ssh).create_token(nspace="kube-system", user=serviceaccount) + token = KubectlCreateTokenKeywords(ssh_connection=con_ssh).create_token("kube-system", serviceaccount) def teardown(): get_logger().log_info(f"Removing serviceaccount {serviceaccount} in kube-system") - KubectlDeleteServiceAccountKeywords(ssh_connection=con_ssh).cleanup_serviceaccount(serviceaccount_name=serviceaccount, nspace="kube-system") + KubectlDeleteServiceAccountKeywords(ssh_connection=con_ssh).cleanup_serviceaccount(serviceaccount, "kube-system") request.addfinalizer(teardown) @@ -160,6 +159,32 @@ def get_k8s_token(request: fixture, con_ssh: SSHConnection) -> str: return token +def get_local_kubeconfig_path() -> str: + """ + Get the local path to the kubeconfig file. + + Returns: + str: The local path to the kubeconfig.yaml file. + """ + kubeconfig_file = "kubeconfig.yaml" + local_path = get_stx_resource_path(f"resources/cloud_platform/containers/k8s_dashboard/{kubeconfig_file}") + return local_path + + +def update_token_in_local_kubeconfig(token: str) -> str: + """ + Update the token in the local kubeconfig file and save it to a temporary location. + + Args: + token (str): The token to be updated in the kubeconfig file. + + Returns: + str: The path to the updated temporary kubeconfig file. + """ + tmp_kubeconfig_path = YamlKeywords(ssh_connection=None).generate_yaml_file_from_template(template_file=get_local_kubeconfig_path(), target_file_name="kubeconfig_tmp.yaml", replacement_dictionary={"token_value": token}, target_remote_location=None, copy_to_remote=False) + return tmp_kubeconfig_path + + @mark.p0 def test_k8s_dashboard_access(request): """ @@ -173,6 +198,26 @@ def test_k8s_dashboard_access(request): - Check the copies on the SystemController. Step 2: Create namespace kubernetes-dashboard - Check that the dashboard is correctly created + Step 3: Create the necessary k8s dashboard resources + - Create SSL certificate for the dashboard. + - Create the necessary secrets. + - Apply the k8s dashboard yaml file. + - Expose the dashboard service on port 30000. + - Verify that the dashboard is accessible. + Step 4: Create the token for the dashboard + - Create the admin-user service-account. + - Bind the cluster-admin ClusterRoleBinding to the admin-user. + - Create a token for the admin-user. + Step 5: Navigate to K8s dashboard login page + - Get the k8s dashboard URL. + - Open the k8s dashboard login page. + - Login to the dashboard using the token. + Step 6 : Logout from the dashboard + - Logout from the dashboard + Step 7 : Login to the dashboard using kubeconfig file + - Update the token in the kubeconfig file + - Open the k8s dashboard login page. + - Login to the dashboard using the kubeconfig file. Teardown: - Delete the kubernetes-dashboard namespace @@ -185,7 +230,7 @@ def test_k8s_dashboard_access(request): # Opens an SSH session to active controller. ssh_connection = LabConnectionKeywords().get_active_controller_ssh() copy_k8s_files(request, ssh_connection) - # Create Dashboard namespace + # Step 2: Create Dashboard namespace namespace_name = "kubernetes-dashboard" kubectl_create_ns_keyword = KubectlCreateNamespacesKeywords(ssh_connection) kubectl_create_ns_keyword.create_namespaces(namespace_name) @@ -201,9 +246,26 @@ def test_k8s_dashboard_access(request): request.addfinalizer(teardown) - # Step 2: Create the necessary k8s dashboard resources + # Step 3: Create the necessary k8s dashboard resources test_namespace = "kubernetes-dashboard" create_k8s_dashboard(request, namespace=test_namespace, con_ssh=ssh_connection) - # Step 3: Create the token for the dashboard - get_k8s_token(request=request, con_ssh=ssh_connection) + # Step 4: Create the token for the dashboard + token = get_k8s_token(request=request, con_ssh=ssh_connection) + + # Step 5: Navigate to K8s dashboard login page + + k8s_dashboard_url = OpenStackEndpointListKeywords(ssh_connection=ssh_connection).get_k8s_dashboard_url() + driver = WebDriverCore() + request.addfinalizer(lambda: driver.close()) + + login_page = K8sLoginPage(driver) + login_page.navigate_to_login_page(k8s_dashboard_url) + # Login to the dashboard using the token. + login_page.login_with_token(token) + # Step 6: Logout from dashboard + login_page.logout() + + # Step 7: Login to the dashboard using kubeconfig file + kubeconfig_tmp_path = update_token_in_local_kubeconfig(token=token) + login_page.login_with_kubeconfig(kubeconfig_tmp_path) diff --git a/web_pages/k8s_dashboard/__init__.py b/web_pages/k8s_dashboard/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/web_pages/k8s_dashboard/login/__init__.py b/web_pages/k8s_dashboard/login/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/web_pages/k8s_dashboard/login/k8s_login_page.py b/web_pages/k8s_dashboard/login/k8s_login_page.py new file mode 100644 index 00000000..a1b94fd6 --- /dev/null +++ b/web_pages/k8s_dashboard/login/k8s_login_page.py @@ -0,0 +1,95 @@ +from framework.web.condition.web_condition_element_visible import WebConditionElementVisible +from framework.web.webdriver_core import WebDriverCore +from web_pages.base_page import BasePage +from web_pages.k8s_dashboard.login.k8s_login_page_locators import K8sLoginPageLocators + + +class K8sLoginPage(BasePage): + """ + Page class that contains operations for the Login Page. + """ + + def __init__(self, driver: WebDriverCore): + self.locators = K8sLoginPageLocators() + self.driver = driver + + def navigate_to_login_page(self, dashboard_url: str) -> None: + """ + Navigates to the Kubernetes Dashboard Login Page. + + Args: + dashboard_url (str): The URL of the Kubernetes Dashboard. + """ + signin_btn = self.locators.get_locator_signin_button() + condition = WebConditionElementVisible(signin_btn) + self.driver.navigate_to_url(dashboard_url, [condition]) + + def login_with_token(self, token: str) -> None: + """ + Logs in to the Kubernetes Dashboard using the provided token. + + Args: + token (str): The authentication token to use for login. + """ + self.set_token(token) + self.click_signin() + + def login_with_kubeconfig(self, kubeconfig_path: str) -> None: + """ + Logs in to the Kubernetes Dashboard using the provided kubeconfig file. + + Args: + kubeconfig_path (str): The file path to the kubeconfig file. + """ + condition = WebConditionElementVisible(self.locators.get_locator_input_kubeconfig_file()) + kubeconfig_option = self.locators.get_locator_kubeconfig_option() + self.driver.click(kubeconfig_option, conditions=[condition]) + + # this actually needs to be changed to send_keys + kubeconfig_input = self.locators.get_locator_input_kubeconfig_file() + self.driver.set_text(kubeconfig_input, kubeconfig_path) + + self.click_signin() + + def click_user_button(self) -> None: + """ + This function will click on the User button. + """ + condition = WebConditionElementVisible(self.locators.get_locator_sign_out_button()) + self.driver.click(locator=self.locators.get_locator_user_button(), conditions=[condition]) + + def logout(self) -> None: + """ + This function will logout from the k8s dashboard. + """ + # click at user button first + self.click_user_button() + # click at logout button + self.click_signout() + + def set_token(self, token: str) -> None: + """ + Sets the provided authentication token in the token input field. + + Args: + token (str): The authentication token to be entered in the input field. + """ + token_input = self.locators.get_locator_token_input() + self.driver.set_text(token_input, token) + + def click_signin(self): + """ + This function will click on the Signin button and check if the dashboard appears + """ + condition = WebConditionElementVisible(self.locators.get_locator_overview_dashboard()) + + signin_button = self.locators.get_locator_signin_button() + self.driver.click(signin_button, conditions=[condition]) + + def click_signout(self): + """ + This function will click on the Signout button and check if the login page appears + """ + condition = WebConditionElementVisible(self.locators.get_locator_signin_button()) + signin_button = self.locators.get_locator_sign_out_button() + self.driver.click(signin_button, conditions=[condition]) diff --git a/web_pages/k8s_dashboard/login/k8s_login_page_locators.py b/web_pages/k8s_dashboard/login/k8s_login_page_locators.py new file mode 100644 index 00000000..e4e6fdd6 --- /dev/null +++ b/web_pages/k8s_dashboard/login/k8s_login_page_locators.py @@ -0,0 +1,65 @@ +from selenium.webdriver.common.by import By + +from framework.web.web_locator import WebLocator + + +class K8sLoginPageLocators: + """ + Page Elements class that contains elements for the Login Page. + """ + + def get_locator_token_input(self) -> WebLocator: + """ + Locator for the Token Input field. + + Returns: WebLocator + """ + return WebLocator("token", By.ID) + + def get_locator_signin_button(self) -> WebLocator: + """ + Locator for the Login Button + + Returns: WebLocator + """ + return WebLocator("//span[contains(text(),'Sign in')]", By.XPATH) + + def get_locator_overview_dashboard(self) -> WebLocator: + """ + Locator for the Overview Dashboard element. + + Returns: WebLocator + """ + return WebLocator("//div[contains(@class,'kd-toolbar-tools')]", By.XPATH) + + def get_locator_kubeconfig_option(self) -> WebLocator: + """ + Locator for the Kubeconfig Option. + + Returns: WebLocator + """ + return WebLocator("//input[contains(@value,'kubeconfig')]/..", By.XPATH) + + def get_locator_input_kubeconfig_file(self) -> WebLocator: + """ + Locator for the Kubeconfig File Input. + + Returns: WebLocator + """ + return WebLocator("""[title="fileInput"]""", By.CSS_SELECTOR) + + def get_locator_user_button(self) -> WebLocator: + """ + Locator for the User Button. + + Returns: WebLocator + """ + return WebLocator(".kd-user-panel-icon", By.CSS_SELECTOR) + + def get_locator_sign_out_button(self) -> WebLocator: + """ + Locator for the Sign Out Button. + + Returns: WebLocator + """ + return WebLocator("//button[contains(text(),'Sign out')]", By.XPATH)