From ab2a6fa8270fdaff4831473a4e27f45985b42bbf Mon Sep 17 00:00:00 2001 From: Sam Cofer Date: Tue, 21 Jul 2026 15:46:20 -0500 Subject: [PATCH 1/2] fix(workbench): skip disabled resource profiles in capacity test Workbench renders resource profiles the authenticated user is not entitled to as visible [role='option'] elements marked aria-disabled='true' / data-disabled. The capacity test detected every option as launchable and then clicked it, so Playwright blocked waiting for the disabled element to become enabled until the 5s timeout blew up. Detect the disabled state up front: - _detect_profiles now reports each profile's disabled flag; auto-detect filters out disabled profiles and skips the scenario only when every profile is disabled (nothing launchable). - _launch_session checks the resolved option before clicking and raises ResourceProfileDisabled instead of clicking a disabled option; an explicitly configured but disabled profile skips with a clear reason naming the likely group/entitlement restriction. Fixes #514 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workbench/test_session_capacity.py | 76 +++++++++++++++++-- 1 file changed, 69 insertions(+), 7 deletions(-) diff --git a/src/vip_tests/workbench/test_session_capacity.py b/src/vip_tests/workbench/test_session_capacity.py index ee36a48b..7f680a4e 100644 --- a/src/vip_tests/workbench/test_session_capacity.py +++ b/src/vip_tests/workbench/test_session_capacity.py @@ -38,13 +38,47 @@ _SESSION_PREFIX = f"_vip_cap_{int(__import__('time').time())}_" +class ResourceProfileDisabled(Exception): + """Raised when the target resource profile is present but disabled for the user. + + Workbench renders resource profiles the authenticated user is not entitled + to (e.g. a group-restricted profile) as visible options with + ``aria-disabled='true'`` / ``data-disabled``. Clicking one just blocks + until Playwright's timeout, so ``_launch_session`` raises this instead and + lets the caller decide whether to skip the profile or the whole scenario. + """ + + def __init__(self, profile: str) -> None: + super().__init__(profile) + self.profile = profile + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- -def _detect_profiles(page: Page) -> list[str]: - """Open the New Session dialog and read available resource profiles.""" +def _option_is_disabled(option) -> bool: + """Return True if a ``[role='option']`` is disabled for the current user. + + Radix-based selects mark unavailable options with ``aria-disabled='true'`` + and an (empty-valued) ``data-disabled`` attribute; ``get_attribute`` + returns ``""`` for the latter, so test for presence rather than truthiness. + """ + return ( + option.get_attribute("aria-disabled") == "true" + or option.get_attribute("data-disabled") is not None + ) + + +def _detect_profiles(page: Page) -> list[dict[str, object]]: + """Open the New Session dialog and read available resource profiles. + + Returns one dict per profile with its ``name`` and whether it is + ``disabled`` for the authenticated user. Disabled profiles are still + reported so the caller can distinguish "no profiles at all" from "all + profiles disabled for this user" and skip with an accurate reason. + """ page.locator(Homepage.NEW_SESSION_BUTTON).first.click(timeout=TIMEOUT_DIALOG) dialog = page.locator(NewSessionDialog.DIALOG) @@ -64,11 +98,12 @@ def _detect_profiles(page: Page) -> list[str]: options = page.locator("[role='option']") options.first.wait_for(state="visible", timeout=TIMEOUT_QUICK) count = options.count() - profiles = [] + profiles: list[dict[str, object]] = [] for i in range(count): - text = (options.nth(i).text_content() or "").strip() + option = options.nth(i) + text = (option.text_content() or "").strip() if text: - profiles.append(text) + profiles.append({"name": text, "disabled": _option_is_disabled(option)}) # Close the dropdown, then close the dialog via Escape. page.keyboard.press("Escape") @@ -109,6 +144,15 @@ def _launch_session( profile_dropdown.click() page.wait_for_timeout(500) option = page.locator(f"[role='option']:has-text('{profile}')").first + option.wait_for(state="visible", timeout=TIMEOUT_QUICK) + if _option_is_disabled(option): + # Profile is offered but disabled for this user (e.g. a + # group-restricted profile). Clicking would just block + # until timeout, so close the dialog and signal the caller. + page.keyboard.press("Escape") + page.keyboard.press("Escape") + expect(dialog).to_be_hidden(timeout=TIMEOUT_DIALOG) + raise ResourceProfileDisabled(profile) option.click(timeout=TIMEOUT_QUICK) else: pytest.skip(f"Resource profile dropdown not available; cannot select '{profile}'") @@ -164,7 +208,15 @@ def launch_sessions(page: Page, vip_config): # Auto-detect from the dropdown. detected = _detect_profiles(page) if detected: - profiles_to_test = detected + enabled = [p["name"] for p in detected if not p["disabled"]] + if not enabled: + # Every profile is offered but disabled for this user — nothing + # is launchable, so there is no capacity to exercise. + names = ", ".join(str(p["name"]) for p in detected) + pytest.skip( + f"All resource profiles are disabled for the authenticated user: {names}" + ) + profiles_to_test = enabled else: # No profiles dropdown — launch with default. profiles_to_test = [None] @@ -177,7 +229,17 @@ def launch_sessions(page: Page, vip_config): for i in range(session_count): label = profile or "default" name = f"{_SESSION_PREFIX}{label}_{i}" - _launch_session(page, name, profile) + try: + _launch_session(page, name, profile) + except ResourceProfileDisabled as exc: + # Configured profile the current user cannot launch. Treat as an + # environment condition (entitlement/group restriction), not a + # failure, so the suite still passes on a correctly-restricted + # test account. + pytest.skip( + f"Resource profile '{exc.profile}' is disabled for the " + "authenticated user (likely a group/entitlement restriction)" + ) all_sessions.append({"name": name, "profile": profile}) return all_sessions From 681d21526cea895348aae6e9fbc647b4686e76dc Mon Sep 17 00:00:00 2001 From: Ian Flores Siaca <18703558+ian-flores@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:53:33 -0700 Subject: [PATCH 2/2] fix(workbench): skip disabled profiles per-profile and patch k8s capacity twin Address review feedback on PR #515's disabled-resource-profile handling: - launch_sessions records a disabled configured profile and continues to the remaining profiles instead of aborting the whole scenario on the first one; it skips only when nothing was launchable across all profiles. - Move _option_is_disabled and ResourceProfileDisabled into workbench/conftest.py and apply the disabled-option check in test_session_capacity_k8s.py, whose diverged copy of _launch_session still hung on disabled options. - Replace the list[dict[str, object]] profile records with a DetectedProfile frozen dataclass, dropping the str() casts and object-typed leakage. --- src/vip_tests/workbench/conftest.py | 36 ++++++++ .../workbench/test_session_capacity.py | 87 ++++++++++--------- .../workbench/test_session_capacity_k8s.py | 33 ++++++- 3 files changed, 112 insertions(+), 44 deletions(-) diff --git a/src/vip_tests/workbench/conftest.py b/src/vip_tests/workbench/conftest.py index 34764734..48d50577 100644 --- a/src/vip_tests/workbench/conftest.py +++ b/src/vip_tests/workbench/conftest.py @@ -92,6 +92,42 @@ def pytest_collection_modifyitems( # instead of timing out on an opaque "Locator expected to be visible" error. TERMINAL_SESSION_FAILURE_STATES = ("Failed",) +# --------------------------------------------------------------------------- +# Resource profile helpers +# Shared between test_session_capacity.py and test_session_capacity_k8s.py, +# both of which need to detect and skip resource profiles that Workbench +# renders as visible-but-disabled for the authenticated user. +# --------------------------------------------------------------------------- + + +class ResourceProfileDisabled(Exception): + """Raised when the target resource profile is present but disabled for the user. + + Workbench renders resource profiles the authenticated user is not entitled + to (e.g. a group-restricted profile) as visible options with + ``aria-disabled='true'`` / ``data-disabled``. Clicking one just blocks + until Playwright's timeout, so ``_launch_session`` raises this instead and + lets the caller record the profile as unavailable and move on. + """ + + def __init__(self, profile: str) -> None: + super().__init__(profile) + self.profile = profile + + +def _option_is_disabled(option: Locator) -> bool: + """Return True if a ``[role='option']`` is disabled for the current user. + + Radix-based selects mark unavailable options with ``aria-disabled='true'`` + and an (empty-valued) ``data-disabled`` attribute; ``get_attribute`` + returns ``""`` for the latter, so test for presence rather than truthiness. + """ + return ( + option.get_attribute("aria-disabled") == "true" + or option.get_attribute("data-disabled") is not None + ) + + # --------------------------------------------------------------------------- diff --git a/src/vip_tests/workbench/test_session_capacity.py b/src/vip_tests/workbench/test_session_capacity.py index 7f680a4e..244568da 100644 --- a/src/vip_tests/workbench/test_session_capacity.py +++ b/src/vip_tests/workbench/test_session_capacity.py @@ -16,6 +16,8 @@ from __future__ import annotations +from dataclasses import dataclass + import pytest from playwright.sync_api import Page, expect from pytest_bdd import scenarios, then, when @@ -23,6 +25,8 @@ from vip_tests.workbench.conftest import ( TIMEOUT_DIALOG, TIMEOUT_QUICK, + ResourceProfileDisabled, + _option_is_disabled, _quit_vip_sessions_via_cookies, format_capacity_failure, wait_for_session_active, @@ -38,19 +42,17 @@ _SESSION_PREFIX = f"_vip_cap_{int(__import__('time').time())}_" -class ResourceProfileDisabled(Exception): - """Raised when the target resource profile is present but disabled for the user. +@dataclass(frozen=True) +class DetectedProfile: + """A resource profile discovered from the New Session dialog's dropdown. - Workbench renders resource profiles the authenticated user is not entitled - to (e.g. a group-restricted profile) as visible options with - ``aria-disabled='true'`` / ``data-disabled``. Clicking one just blocks - until Playwright's timeout, so ``_launch_session`` raises this instead and - lets the caller decide whether to skip the profile or the whole scenario. + Disabled profiles are still reported so the caller can distinguish "no + profiles at all" from "all profiles disabled for this user" and skip with + an accurate reason. """ - def __init__(self, profile: str) -> None: - super().__init__(profile) - self.profile = profile + name: str + disabled: bool # --------------------------------------------------------------------------- @@ -58,26 +60,11 @@ def __init__(self, profile: str) -> None: # --------------------------------------------------------------------------- -def _option_is_disabled(option) -> bool: - """Return True if a ``[role='option']`` is disabled for the current user. - - Radix-based selects mark unavailable options with ``aria-disabled='true'`` - and an (empty-valued) ``data-disabled`` attribute; ``get_attribute`` - returns ``""`` for the latter, so test for presence rather than truthiness. - """ - return ( - option.get_attribute("aria-disabled") == "true" - or option.get_attribute("data-disabled") is not None - ) - - -def _detect_profiles(page: Page) -> list[dict[str, object]]: +def _detect_profiles(page: Page) -> list[DetectedProfile]: """Open the New Session dialog and read available resource profiles. - Returns one dict per profile with its ``name`` and whether it is - ``disabled`` for the authenticated user. Disabled profiles are still - reported so the caller can distinguish "no profiles at all" from "all - profiles disabled for this user" and skip with an accurate reason. + Returns one :class:`DetectedProfile` per profile discovered, naming it and + recording whether it is disabled for the authenticated user. """ page.locator(Homepage.NEW_SESSION_BUTTON).first.click(timeout=TIMEOUT_DIALOG) @@ -98,12 +85,12 @@ def _detect_profiles(page: Page) -> list[dict[str, object]]: options = page.locator("[role='option']") options.first.wait_for(state="visible", timeout=TIMEOUT_QUICK) count = options.count() - profiles: list[dict[str, object]] = [] + profiles: list[DetectedProfile] = [] for i in range(count): option = options.nth(i) text = (option.text_content() or "").strip() if text: - profiles.append({"name": text, "disabled": _option_is_disabled(option)}) + profiles.append(DetectedProfile(name=text, disabled=_option_is_disabled(option))) # Close the dropdown, then close the dialog via Escape. page.keyboard.press("Escape") @@ -118,7 +105,11 @@ def _launch_session( session_name: str, profile: str | None = None, ) -> None: - """Open the New Session dialog, optionally select a resource profile, and launch.""" + """Open the New Session dialog, optionally select a resource profile, and launch. + + Raises ``ResourceProfileDisabled`` if the selected profile is disabled for + the authenticated user. + """ page.locator(Homepage.NEW_SESSION_BUTTON).first.click(timeout=TIMEOUT_DIALOG) dialog = page.locator(NewSessionDialog.DIALOG) @@ -208,11 +199,11 @@ def launch_sessions(page: Page, vip_config): # Auto-detect from the dropdown. detected = _detect_profiles(page) if detected: - enabled = [p["name"] for p in detected if not p["disabled"]] + enabled = [p.name for p in detected if not p.disabled] if not enabled: # Every profile is offered but disabled for this user — nothing # is launchable, so there is no capacity to exercise. - names = ", ".join(str(p["name"]) for p in detected) + names = ", ".join(p.name for p in detected) pytest.skip( f"All resource profiles are disabled for the authenticated user: {names}" ) @@ -225,22 +216,36 @@ def launch_sessions(page: Page, vip_config): session_count = 1 all_sessions: list[dict[str, str | None]] = [] + disabled_profiles: list[str] = [] for profile in profiles_to_test: + profile_disabled = False for i in range(session_count): label = profile or "default" name = f"{_SESSION_PREFIX}{label}_{i}" try: _launch_session(page, name, profile) except ResourceProfileDisabled as exc: - # Configured profile the current user cannot launch. Treat as an - # environment condition (entitlement/group restriction), not a - # failure, so the suite still passes on a correctly-restricted - # test account. - pytest.skip( - f"Resource profile '{exc.profile}' is disabled for the " - "authenticated user (likely a group/entitlement restriction)" - ) + # Configured profile the current user cannot launch. Treat as + # an environment condition (entitlement/group restriction): + # record it and move on to the remaining profiles rather than + # aborting the whole scenario. + disabled_profiles.append(exc.profile) + profile_disabled = True + break all_sessions.append({"name": name, "profile": profile}) + if profile_disabled: + continue + + if not all_sessions and disabled_profiles: + # No configured profile was launchable — there is no capacity to + # exercise. Skip rather than fail so the scenario is reported as + # skipped (not passed) on a correctly-restricted test account, + # distinct from an actual capacity failure. + names = ", ".join(disabled_profiles) + pytest.skip( + f"Resource profile(s) '{names}' are disabled for the authenticated " + "user (likely a group/entitlement restriction)" + ) return all_sessions diff --git a/src/vip_tests/workbench/test_session_capacity_k8s.py b/src/vip_tests/workbench/test_session_capacity_k8s.py index e4b3c13c..727ec412 100644 --- a/src/vip_tests/workbench/test_session_capacity_k8s.py +++ b/src/vip_tests/workbench/test_session_capacity_k8s.py @@ -26,6 +26,8 @@ from vip_tests.workbench.conftest import ( TIMEOUT_DIALOG, TIMEOUT_QUICK, + ResourceProfileDisabled, + _option_is_disabled, _quit_vip_sessions_via_cookies, wait_for_session_active, ) @@ -46,7 +48,11 @@ def _launch_session(page: Page, session_name: str, profile: str | None = None) -> None: - """Open the New Session dialog and launch with an optional resource profile.""" + """Open the New Session dialog and launch with an optional resource profile. + + Raises ``ResourceProfileDisabled`` if the selected profile is disabled for + the authenticated user. + """ page.locator(Homepage.NEW_SESSION_BUTTON).first.click(timeout=TIMEOUT_DIALOG) dialog = page.locator(NewSessionDialog.DIALOG) @@ -68,6 +74,15 @@ def _launch_session(page: Page, session_name: str, profile: str | None = None) - profile_dropdown.click() page.wait_for_timeout(500) option = page.locator(f"[role='option']:has-text('{profile}')").first + option.wait_for(state="visible", timeout=TIMEOUT_QUICK) + if _option_is_disabled(option): + # Profile is offered but disabled for this user (e.g. a + # group-restricted profile). Clicking would just block + # until timeout, so close the dialog and signal the caller. + page.keyboard.press("Escape") + page.keyboard.press("Escape") + expect(dialog).to_be_hidden(timeout=TIMEOUT_DIALOG) + raise ResourceProfileDisabled(profile) option.click(timeout=TIMEOUT_QUICK) else: pytest.skip(f"Resource profile dropdown not available; cannot select '{profile}'") @@ -233,7 +248,13 @@ def launch_profiled_session(page: Page, vip_config) -> list[dict]: profile_map = vip_config.workbench.kubernetes.node_pool_profiles profile = next(iter(profile_map.values())) name = f"{_SESSION_PREFIX}prof_0" - _launch_session(page, name, profile=profile) + try: + _launch_session(page, name, profile=profile) + except ResourceProfileDisabled as exc: + pytest.skip( + f"Resource profile '{exc.profile}' is disabled for the " + "authenticated user (likely a group/entitlement restriction)" + ) return [{"name": name, "profile": profile}] @@ -248,7 +269,13 @@ def launch_limited_session(page: Page, vip_config) -> list[dict]: ) profile = profiles[0] if profiles else None name = f"{_SESSION_PREFIX}lim_0" - _launch_session(page, name, profile=profile) + try: + _launch_session(page, name, profile=profile) + except ResourceProfileDisabled as exc: + pytest.skip( + f"Resource profile '{exc.profile}' is disabled for the " + "authenticated user (likely a group/entitlement restriction)" + ) return [{"name": name, "profile": profile}]