class AssignedTags(ParametrizedView): """ Represents the assigned tag in EditTags menu. To remove the tag, you need to pass category name, e.g.: 'view.form.tags(category).remove()'. To read the value of the tag, pass the category name, e.g.: 'view.form.tags(category).read()'. """ PARAMETERS = ("tag", ) ALL_TAGS = ".//a[contains(@class, 'pf-remove-button')]" tag_remove = Text( ParametrizedLocator( ".//div[@class='category-label'][normalize-space(@title)={tag|quote}]/parent::li/" "following-sibling::li/descendant::a[contains(@class, 'pf-remove-button')]" )) tag_value = Text( ParametrizedLocator( ".//div[@class='category-label'][normalize-space(@title)={tag|quote}]/parent::li/" "following-sibling::li/span")) def remove(self): """Removes the assigned tag by clicking 'x' icon""" self.tag_remove.click() def read(self): """Return the assigned value to a tag category""" return self.browser.get_attribute("title", self.tag_value)
class CategoryChipGroup(ChipGroup): """ Represents a Chip Group with a category label """ ROOT = ParametrizedLocator( f"{CATEGORY_GROUP_ROOT}[{CATEGORY_LABEL}[normalize-space(.)={{@_label|quote}}]]" ) chips = ParametrizedView.nested(Chip) close_button = Button(locator=CATEGORY_CLOSE) def __init__(self, parent, label, logger=None): self._label = label ChipGroup.__init__(self, parent, logger=logger) @property def label(self): elements = self.browser.elements(CATEGORY_LABEL) return self.browser.text(elements[0]) if elements else None @property def can_close(self): return self.close_button.is_displayed def close(self): self.close_button.click()
class ChipGroupToolbarCategory(ParametrizedView, ChipGroup): """ Represents a chip group that is part of a toolbar, identifiable by a category label """ PARAMETERS = ("label",) ROOT = ParametrizedLocator( f"{OLD_GROUP_ROOT}[{TOOLBAR_GROUP_LABEL}[normalize-space(.)={{label|quote}}]]" ) chips = ParametrizedView.nested(OldChip) def __init__(self, *args, **kwargs): ParametrizedView.__init__(self, *args, **kwargs) @property def label(self): elements = self.browser.elements(CATEGORY_LABEL) return self.browser.text(elements[0]) if elements else None @classmethod def all(cls, browser): return [ (browser.text(el),) for el in browser.elements(f"{OLD_GROUP_ROOT}/{TOOLBAR_GROUP_LABEL}") ]
class volume_parts(ParametrizedView): """ Nested view for each volume part. """ PARAMETERS = ("part_id", ) ROOT = ParametrizedLocator( "(.//div[@class='list-group list-view-pf list-view-pf-view" " ng-scope'])[position() = {part_id|quote}]") bricks = Table(".//table", column_widgets={5: Button("Dashboard")}) part_name = Text(".//div[@class='list-group-item-heading " "bold-text sub-volume ng-binding']") brick_count = Text( ".//div[@class='list-view-pf-additional-info-item ng-binding']") utilization = Text(".//utilisation-chart") @classmethod def all(cls, browser): return [ browser.text(e) for e in browser.elements(cls.ALL_VOLUMES) if browser.text(e) is not None and browser.text(e) != '' ] @property def is_expanded(self): return self.browser.elements("div")[0].get_attribute("class").find( "expand" "-active") > 0
class clusters(ParametrizedView): """ Nested view for each cluster """ PARAMETERS = ("cluster_id",) ALL_CLUSTERS = ".//div[@class='list-group-item']" ALL_CLUSTER_IDS = ".//div[@class='list-view-pf-description']/descendant-or-self::*/text()" ROOT = ParametrizedLocator( "//div/*[text()[normalize-space(.)]={cluster_id|quote}]/ancestor-or-self::" "div[@class='list-group-item']") cluster_version = Text(".//div[text() = 'Cluster Version']/following-sibling::h5") managed = Text(".//div[text() = 'Managed']/following-sibling::h5") hosts = Text(".//div[text() = 'Hosts']/following-sibling::h5") volumes = Text(".//div[text() = 'Volumes']/following-sibling::h5") alerts = Text(".//div[text() = 'Alerts']/following-sibling::h5") profiling = Text(".//div[text() = 'Volume Profiling']/following-sibling::h5") status = Text(".//div[@class='list-view-pf-additional-info-item cluster-text']") task_details = Text(".//a[@ng-click='clusterCntrl.goToTaskDetail(cluster)']") import_button = Button("contains", "Import") dashboard_button = Button("Dashboard") actions = Kebab() @property def health(self): return self.browser.elements(".//div[@class='list-view-pf-left']" "/i")[0].get_attribute("uib-tooltip") @classmethod def all(cls, browser): return [browser.text(e) for e in browser.elements(cls.ALL_CLUSTER_IDS) if browser.text(e) is not None and browser.text(e) != '']
class SatSecondaryTab(SatTab): """Secondary level Tab, typically 'List/Remove' or 'Add' sub-tab inside some primary tab. Usage:: @View.nested class listremove(SatSecondaryTab): TAB_NAME = 'List/Remove' """ ROOT = ParametrizedLocator( './/nav[@class="ng-scope" or not(@*)]/following-sibling::div') TAB_LOCATOR = ParametrizedLocator( './/nav[@class="ng-scope" or not(@*)]/ul[contains(@class, "nav-tabs")]' '/li[./a[normalize-space(.)={@tab_name|quote}]]')
class DonutLegendItem(ParametrizedView, ClickableMixin): PARAMETERS = ("label_text", ) ROOT = ParametrizedLocator( ".//*[name()='text']" "/*[name()='tspan' and contains(., '{label_text}')]") ALL_ITEMS = ".//*[name()='text']/*[name()='tspan']" LEGEND_ITEM_REGEX = re.compile(r"(.*?): ([\d]+)") @classmethod def _get_legend_item(cls, text): match = cls.LEGEND_ITEM_REGEX.match(text) if match: return match.group(1), match.group(2) else: return text, None @property def label(self): """Returns the label of a DonutLegendItem as a string""" return self._get_legend_item(self.browser.text(self))[0] @property def value(self): """Returns the value of a DonutLegendItem as a string""" return self._get_legend_item(self.browser.text(self))[1] @classmethod def all(cls, browser): """Returns a list of all items""" return [(browser.text(el), ) for el in browser.elements(cls.ALL_ITEMS)]
class hosts(ParametrizedView): """Nested view for each host""" PARAMETERS = ("hostname", ) ROOT = ParametrizedLocator( ".//div/a[text()[normalize-space(.)]={hostname|quote}]/ancestor-or-self::" "div[@class='ft-row list-group-item ng-scope']") host_name = Text( ".//div[@class='ft-column ft-main host-name bold-text']/a") gluster_version = Text( ".//div[text() = 'Gluster Version']/following-sibling::div") managed = Text(".//div[text() = 'Managed']/following-sibling::div") role = Text(".//div[text() = 'Role']/following-sibling::div") bricks = Text(".//div[text() = 'Bricks']/following-sibling::div") alerts = Text(".//div[text() = 'Alerts']/following-sibling::div") dashboard_button = Button("Dashboard") @property def health(self): return self.browser.elements( ".//div[@class='ft-column ft-icon']" "/i")[0].get_attribute("uib-tooltip-html").strip("'") @classmethod def all(cls, browser): return [ browser.text(e) for e in browser.elements(cls.ALL_HOSTNAMES) if browser.text(e) is not None and browser.text(e) != '' ]
class SatTab(Tab): """Regular primary level ``Tab``. Usage:: @View.nested class mytab(SatTab): TAB_NAME = 'My Tab' Note that ``TAB_NAME`` is optional and if it's absent - capitalized class name is used instead, which is useful for simple tab names like 'Subscriptions':: @View.nested class subscriptions(SatTab): # no need to specify 'TAB_NAME', it will be set to 'Subscriptions' # automatically pass """ ROOT = ParametrizedLocator('.//div[contains(@class, "page-content") or ' 'contains(@class, "tab-content")]') @property def is_displayed(self): return 'ng-hide' not in self.parent_browser.classes(self.TAB_LOCATOR) def read(self): """Do not attempt to read hidden tab contents""" if not self.is_displayed: do_not_read_this_widget() return super().read()
class DonutChart(View): """ Represents the Donut Chart from Patternfly 4 (https://www.patternfly.org/v4/documentation/react/charts/chartdonut) """ ROOT = ParametrizedLocator("{@locator}") BASE_LOCATOR = ".//div[@id={}]" def __init__(self, parent, id=None, locator=None, logger=None): """Create the widget""" Widget.__init__(self, parent, logger=logger) if id: self.locator = self.BASE_LOCATOR.format(quote(id)) elif locator: self.locator = locator else: raise TypeError("You need to specify either id or locator") @View.nested class donut(View): # noqa ROOT = ".//div[*[name()='svg'][*[name()='text'] and not(*[name()='rect'])]]" LABELS_LOCATOR = "./*[name()='svg']/*[name()='text']/*[name()='tspan']" @property def labels(self): return [ self.browser.text(elem) for elem in self.browser.elements(self.LABELS_LOCATOR) ] @View.nested class legend(View): # noqa ROOT = "./div[contains(@class, 'VictoryContainer')]" ALL_ITEMS = "./*[name()='svg']/*[name()='g']/*[name()='text']/*[name()='tspan']" @ParametrizedView.nested class item(ParametrizedView, ClickableMixin): # noqa PARAMETERS = ("label_text", ) ROOT = ParametrizedLocator( ".//*[name()='svg']/*[name()='g']/*[name()='text']" "/*[name()='tspan' and contains(., '{label_text}')]") @property def label(self): return _get_legend_item(self.browser.text(self))[0] @property def value(self): return _get_legend_item(self.browser.text(self))[1] @property def all_items(self): els = self.browser.elements(self.ALL_ITEMS) result = [] for el in els: label, value = _get_legend_item(self.browser.text(el)) result.append({"label": label, "value": value}) return result
class AffectedRepositoriesTab(SatSecondaryTab): """Affected repositories tab contains repositories count inside tab title, making it impossible to rely on exact string value. Using ``starts-with`` instead. """ TAB_NAME = 'Affected Repositories' TAB_LOCATOR = ParametrizedLocator( './/nav[@class="ng-scope" or not(@*)]/ul[contains(@class, "nav-tabs")]' '/li[./a[starts-with(normalize-space(), {@tab_name|quote})]]')
class StandAloneChipGroup(View): """ Represents a chip group that is "on its own", i.e. not a part of a chip group toolbar """ ROOT = ParametrizedLocator("{@locator}") overflow = OverflowChip() chips = ParametrizedView.nested(Chip) def __init__(self, parent, locator=None, logger=None): super().__init__(parent, logger=logger) self.locator = locator or GROUP_ROOT @property def label(self): # It's unlikely we'll have a labelled chip group that is not in a toolbar # ... but just in case elements = self.browser.elements(STANDALONE_GROUP_LABEL) return self.browser.text(elements[0]) if elements else None def show_more(self): self.overflow.show_more() def show_less(self): self.overflow.show_less() @property def is_multiselect(self): return self.overflow.is_displayed def get_chips(self, show_more=True): """ A helper to expand the chip group before reading its chips """ if self.is_multiselect and show_more: self.show_more() return self.chips def __iter__(self): for chip in self.get_chips(): yield chip def remove_chip_by_name(self, name): for chip in self: if chip.text.lower() == name.lower(): chip.remove() break else: raise ValueError(f"Could not find chip with name '{name}'") def remove_all_chips(self): for chip in self: chip.remove() def read(self): return [chip.text for chip in self]
class OldChip(Chip): ROOT = ParametrizedLocator( f"{OLD_CHIP_ROOT}[{CHIP_TEXT}[starts-with(normalize-space(.), {{text|quote}})]]" ) @classmethod def all(cls, browser): """Returns a list of the text of each chip""" return [(cls._get_text_ignoring_badge(browser, el), ) for el in browser.elements(f"{OLD_CHIP_ROOT}/{CHIP_TEXT}")]
class lce(ParametrizedView): """Parametrized view for the lifecycle environement, takes an LCE name on instantiation""" ROOT = ParametrizedLocator( ".//div[@ng-repeat='path in paths'][table//th/a[normalize-space(.)='{lce_name}']]" ) PARAMETERS = ('lce_name', ) LAST_ENV = "//div[@ng-repeat='path in paths']//table//th[last()]" current_env = Text( ParametrizedLocator(".//a[normalize-space(.)='{lce_name}']")) envs_table = Table(locator=".//table") new_child = Text(".//a[contains(@href, '/lifecycle_environments/')]") @classmethod def all(cls, browser): """Helper method which returns list of tuples with all available LCE names (last available environment is used as a name). It's required for :meth:`read` to work properly. """ return [(element.text, ) for element in browser.elements(cls.LAST_ENV)] def read(self): """Returns content views and count hosts count per each available lifecycle environment We get dictionary in next format:: { 'LCE_1': {'Content Views': 0, 'Content Hosts': 1}, 'LCE_2': {'Content Views': 1, 'Content Hosts': 2}, } """ result = {} available_envs = self.envs_table.headers[1:] lce_metric_names = [row[0].text for row in self.envs_table] for column_name in available_envs: metric_values = (int(row[column_name].text) for row in self.envs_table) result[column_name] = {} for row_name in lce_metric_names: result[column_name][row_name] = next(metric_values) return result
class SatTab(Tab): """Regular primary level ``Tab``. Usage:: @View.nested class mytab(SatTab): TAB_NAME = 'My Tab' """ ROOT = ParametrizedLocator('.//div[contains(@class, "page-content") or ' 'contains(@class, "tab-content")]')
class SatTabWithDropdown(TabWithDropdown): """Regular primary level ``Tab`` with dropdown. Usage:: @View.nested class mytab(SatTabWithDropdown): TAB_NAME = 'My Tab' SUB_ITEM = 'My Tab Dropdown Item' """ ROOT = ParametrizedLocator('.//div[contains(@class, "page-content") or ' 'contains(@class, "tab-content")]')
class SatSecondaryTab(Tab): """Secondary level Tab, typically 'List/Remove' or 'Add' sub-tab inside some primary tab. Usage:: @View.nested class listremove(SatSecondaryTab): TAB_NAME = 'List/Remove' """ ROOT = ParametrizedLocator( './/nav[@class="ng-scope"]/following-sibling::div')
class ParametersForm(View): ROOT = ParametrizedLocator( "//generic-object-table-component[@key-type='{@param_type}'] " " | //generic-object-table[@key-type='{@param_type}']" ) ALL_PARAMETERS = './/input[contains(@class, "ng-not-empty")]' add = Button(ParametrizedString('Add {@param_type}')) name = Input(locator='.//input[contains(@class, "ng-empty")]') type_class = BootstrapSelect( locator='.//input[contains(@class, "ng-empty")]//ancestor::tr//button') def __init__(self, parent, param_type, logger=None): View.__init__(self, parent, logger=logger) self.param_type = param_type def all(self): return [(element.get_attribute('value'), element.get_attribute('name')) for element in self.browser.elements(self.ALL_PARAMETERS)] @property def empty_field_is_present(self): try: return self.browser.element(self.name) except NoSuchElementException: return False def add_parameter_row(self): if not self.empty_field_is_present: self.add.click() def fill(self, parameters): if parameters: if isinstance(parameters, dict): for name, type_class in parameters.items(): self.add_parameter_row() type_result = self.type_class.fill(type_class.capitalize()) result = self.name.fill(name) return result and type_result elif isinstance(parameters, list): for name in parameters: self.add_parameter_row() result = self.name.fill(name) return result def delete(self, name): all_params = self.all() for param in all_params: param_name, element_name = param if param_name == name: self.browser.element( '//td[contains(@ng-class, "{}")]/ancestor::tr' '//div[@title = "Delete Row"]'.format(element_name)).click()
class SatVerticalTab(SatTab): """Represent vertical tabs that usually used in location and organization entities Usage:: @View.nested class mytab(SatVerticalTab): TAB_NAME = 'My Tab' """ TAB_LOCATOR = ParametrizedLocator( ".//ul[@data-tabs='pills']" "/li[./a[normalize-space(.)={@tab_name|quote}]]")
class item(ParametrizedView, ClickableMixin): # noqa PARAMETERS = ("label_text", ) ROOT = ParametrizedLocator( ".//*[name()='svg']/*[name()='g']/*[name()='text']" "/*[name()='tspan' and contains(., '{label_text}')]") @property def label(self): return _get_legend_item(self.browser.text(self))[0] @property def value(self): return _get_legend_item(self.browser.text(self))[1]
class ChipGroupToolbar(View): ROOT = ParametrizedLocator("{@locator}") # The parent of the chip group toolbar can be any element type # The locator should be the parent node which holds all the pf-c-chip-group elements TOOLBAR_LOCATOR = ( ".//ul[contains(@class, 'pf-c-chip-group') and " "contains(@class, 'pf-m-toolbar')]/parent::*" ) overflow = OldOverflowChip( locator=("./div[contains(@class, 'pf-c-chip') and contains(@class, 'pf-m-overflow')]") ) groups = ParametrizedView.nested(ChipGroupToolbarCategory) def __init__(self, parent, locator=None, logger=None): self.locator = locator or self.TOOLBAR_LOCATOR super().__init__(parent, logger=logger) def get_groups(self, show_more=True): """ A helper to expand the chip group toolbar before reading its groups """ if self.overflow.is_displayed and show_more: self.overflow.show_more() return self.groups def __iter__(self): for group in self.get_groups(): yield group def read(self): """Returns a dict of chips""" groups = {} for group in self: groups[group.label] = group.read() return groups def show_more(self): """Expands a chip group""" self.overflow.show_more() def show_less(self): """Collapses a chip group""" self.overflow.show_less() @property def has_chips(self): # If we delete all chips the ROOT is still shown thus we need to check if there are # any chips. return self.read() != {}
class events(ParametrizedView): """Nested view for each event""" PARAMETERS = ("event_id", ) ROOT = ParametrizedLocator("(.//div[@class='ft-row list-group-item'])" "[position() = {event_id|quote}]") description = Text(".//div[@class='ft-column ft-main event-desc']/div") date = Text(".//div[@class='ft-column']/div") @classmethod def all(cls, browser): return [ browser.text(e) for e in browser.elements(cls.ALL_VOLUMES) if browser.text(e) is not None and browser.text(e) != '' ]
class events(ParametrizedView): """ Nested view for each event during the task completion """ PARAMETERS = ("event_id",) ALL_IDS = './/div[@class="row list-group-item logs ng-scope"]' ROOT = ParametrizedLocator('.//div[@id={event_id|quote}]') event_type = Text(".//div[@class='col-md-1 ng-binding']") description = Text(".//div[@class='col-md-6 ng-binding']") date = Text(".//div[@class='col-md-2 ng-binding']") @classmethod def all(cls, browser): return [e.text.split(" ")[2] for e in browser.elements(cls.ALL_IDS) if browser.text(e) is not None and browser.text(e) != '']
class RadioGroup(Widget): """ Radio Group Control .. code-block:: python radio_group = RadioGroup(locator=".//div[./label[@for='role']]") radio_group.select(radio_group.button_values()[-1]) """ ROOT = ParametrizedLocator('{@locator}') BUTTONS = './/input[@type="radio"]' BUTTON = './/input[@type="radio" and @value={}]' def __init__(self, parent, locator, logger=None): Widget.__init__(self, parent=parent, logger=logger) self.locator = locator @property def button_values(self): return [ btn.get_attribute("value") for btn in self.browser.elements(self.BUTTONS) ] @property def selected(self): for btn in self.browser.elements(self.BUTTONS): if ("ng-valid-parse" in self.browser.classes(btn) or btn.get_attribute("checked") is not None): return btn.get_attribute("value") else: # radio button doesn't have any marks to make out which button is selected by default. # so, returning first radio button's name return self.button_values[0] def select(self, value): if self.selected != value: self.browser.element(self.BUTTON.format(quote(value))).click() return True return False def read(self): return self.selected def fill(self, name): return self.select(name)
class tasks(ParametrizedView): """ Nested view for each task """ PARAMETERS = ("task_id",) ROOT = ParametrizedLocator( ".//p[contains(text(), {task_id|quote})]/ancestor-or-self::" "div[@class='ft-row list-group-item']") task_name = Text(".//a[@class='bold-text name ng-binding']") submitted_date = Text(".//div[text() = 'Submitted']/following-sibling::div") status = Text(".//div[@class='bold-text ng-binding']") changed_date = Text(".//div[@class='bold-text ng-binding']/following-sibling::div") @classmethod def all(cls, browser): return [e.text.split(" ")[2] for e in browser.elements(cls.ALL_IDS) if browser.text(e) is not None and browser.text(e) != '']
class volumes(ParametrizedView): """Nested view for each volume""" PARAMETERS = ("volume_name", ) ROOT = ParametrizedLocator( ".//div/a[text()[normalize-space(.)]={volume_name|quote}]/ancestor-or-self::" "div[@class='ft-row list-group-item ng-scope']") volname = Text(".//div[@class='bold-text long-volume-name']/a") volume_type = Text(".//div[@class='pull-left vol-type ng-binding']") bricks = Text(".//div[text() = 'Bricks']/following-sibling::div") running = Text(".//div[text() = 'Running']/following-sibling::div") rebalance = Text(".//div[text() = 'Rebalance']/following-sibling::div") profiling = Text( ".//div[text() = 'Volume Profiling']/following-sibling::div") alerts = Text(".//div[text() = 'Alerts']/following-sibling::div") enable_profiling = Button("Enable Profiling") disable_profiling = Button("Disable Profiling") dashboard_button = Button("Dashboard") @property def health(self): """ Returns the corresponding expected value of Grafana Health panel TODO: find out the icon for state 'Failed' """ health = self.browser.elements(".//div[@class='ft-column ft-icon']" "/i")[0].get_attribute("class") if health == "pficon pficon-ok": return "Up" elif health == "pficon pficon-degraded icon-orange": return "Up(Degraded)" elif health == "pficon pficon-degraded icon-red": return "Up(Partial)" elif health == "fa ffont fa-arrow-circle-o-down": return "Down" elif health == "fa ffont fa-question": return "Unknown" else: return "Unexpected state" @classmethod def all(cls, browser): return [ browser.text(e) for e in browser.elements(cls.ALL_VOLUMES) if browser.text(e) is not None and browser.text(e) != '' ]
class Chip(ParametrizedView, _BaseChip): PARAMETERS = ("text",) ROOT = ParametrizedLocator( f"{CHIP_ROOT}[{CHIP_TEXT}[starts-with(normalize-space(.), {{text|quote}})]]" ) @staticmethod def _get_text_ignoring_badge(browser, element): el = element badge_elements = browser.elements(CHIP_BADGE, parent=el) if badge_elements: badge = browser.text(el) return badge.rstrip(browser.text(badge_elements[0])) return browser.text(el) @classmethod def all(cls, browser): """Returns a list of the text of each chip""" return [ (cls._get_text_ignoring_badge(browser, el),) for el in browser.elements(f"{CHIP_ROOT}/{CHIP_TEXT}") ] def __init__(self, *args, **kwargs): ParametrizedView.__init__(self, *args, **kwargs) def remove(self): """Removes a chip""" def _gone(): return not self.is_displayed if not self.read_only: self.button.click() wait_for(_gone, num_sec=3, message="wait for chip to dissappear", delay=0.1) else: raise ChipReadOnlyError(self, "Chip is read-only") @property def read_only(self): """Returns a boolean detailing if the chip is read only""" """ Return whether or not this chip is read-only """ return not self.button.is_displayed
class alerts(ParametrizedView): PARAMETERS = ("alert_id", ) ROOT = ParametrizedLocator("(.//div[@class='list-group-item'])" "[position() = {alert_id|quote}]") description = Text(".//p[@class='ng-binding']") date = Text(".//p[@class='ng-binding']/following-sibling::div") @classmethod def all(cls, browser): return [ browser.text(e) for e in browser.elements(cls.ALL_VOLUMES) if browser.text(e) is not None and browser.text(e) != '' ] @property def severity(self): return self.browser.elements( ".//i[@data-toggle]")[0].get_attribute("title")
class DonutChart(View): """ Represents the Donut Chart from Patternfly 4 (https://www.patternfly.org/v4/documentation/react/charts/chartdonut) """ ROOT = ParametrizedLocator("{@locator}") BASE_LOCATOR = ".//div[@id={}]" donut = View.nested(DonutCircle) legend = View.nested(DonutLegend) def __init__(self, parent, id=None, locator=None, logger=None): """Create the widget""" Widget.__init__(self, parent, logger=logger) if id: self.locator = self.BASE_LOCATOR.format(quote(id)) elif locator: self.locator = locator else: raise TypeError("You need to specify either id or locator")
class SatTabWithDropdown(TabWithDropdown): """Regular primary level ``Tab`` with dropdown. Usage:: @View.nested class mytab(SatTabWithDropdown): TAB_NAME = 'My Tab' SUB_ITEM = 'My Tab Dropdown Item' """ ROOT = ParametrizedLocator('.//div[contains(@class, "page-content") or ' 'contains(@class, "tab-content")]') @property def is_displayed(self): return 'ng-hide' not in self.parent_browser.classes(self.TAB_LOCATOR) def read(self): """Do not attempt to read hidden tab contents""" if not self.is_displayed: do_not_read_this_widget() return super().read()