From 0cdd97f1c0e9cf3b4748ae23275aa0b8f6e5abe9 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Mon, 16 Mar 2026 23:14:20 -0700 Subject: [PATCH] feat(e2e): add BasePage and 7 page objects BasePage provides navigation, error capture, and interactable discovery with fnmatch-based expected_failure matching. SettingsPage extends it with tab-aware discovery. All conftest imports are deferred to method bodies so the module loads without a live browser fixture. --- tests/e2e/pages/apply_page.py | 4 ++ tests/e2e/pages/base_page.py | 79 ++++++++++++++++++++++++++ tests/e2e/pages/home_page.py | 4 ++ tests/e2e/pages/interview_prep_page.py | 4 ++ tests/e2e/pages/interviews_page.py | 4 ++ tests/e2e/pages/job_review_page.py | 4 ++ tests/e2e/pages/settings_page.py | 44 ++++++++++++++ tests/e2e/pages/survey_page.py | 4 ++ 8 files changed, 147 insertions(+) create mode 100644 tests/e2e/pages/apply_page.py create mode 100644 tests/e2e/pages/base_page.py create mode 100644 tests/e2e/pages/home_page.py create mode 100644 tests/e2e/pages/interview_prep_page.py create mode 100644 tests/e2e/pages/interviews_page.py create mode 100644 tests/e2e/pages/job_review_page.py create mode 100644 tests/e2e/pages/settings_page.py create mode 100644 tests/e2e/pages/survey_page.py diff --git a/tests/e2e/pages/apply_page.py b/tests/e2e/pages/apply_page.py new file mode 100644 index 0000000..8593e15 --- /dev/null +++ b/tests/e2e/pages/apply_page.py @@ -0,0 +1,4 @@ +from tests.e2e.pages.base_page import BasePage + +class ApplyPage(BasePage): + nav_label = "Apply Workspace" diff --git a/tests/e2e/pages/base_page.py b/tests/e2e/pages/base_page.py new file mode 100644 index 0000000..6ffd86a --- /dev/null +++ b/tests/e2e/pages/base_page.py @@ -0,0 +1,79 @@ +"""Base page object — navigation, error capture, interactable discovery.""" +from __future__ import annotations +import logging +import warnings +import fnmatch +from dataclasses import dataclass + +from playwright.sync_api import Page + +from tests.e2e.models import ErrorRecord, ModeConfig + +log = logging.getLogger(__name__) + +INTERACTABLE_SELECTORS = [ + '[data-testid="baseButton-primary"] button', + '[data-testid="baseButton-secondary"] button', + '[data-testid="stTab"] button[role="tab"]', + '[data-testid="stSelectbox"]', + '[data-testid="stCheckbox"] input', +] + + +@dataclass +class InteractableElement: + label: str + selector: str + index: int + + +class BasePage: + """Base page object for all Peregrine pages.""" + + nav_label: str = "" + + def __init__(self, page: Page, mode: ModeConfig, console_messages: list): + self.page = page + self.mode = mode + self._console_messages = console_messages + + def navigate(self) -> None: + """Navigate to this page by clicking its sidebar nav link.""" + from tests.e2e.conftest import wait_for_streamlit + sidebar = self.page.locator('[data-testid="stSidebarNav"]') + sidebar.get_by_text(self.nav_label, exact=False).first.click() + wait_for_streamlit(self.page) + + def get_errors(self) -> list[ErrorRecord]: + from tests.e2e.conftest import get_page_errors + return get_page_errors(self.page) + + def get_console_errors(self) -> list[str]: + from tests.e2e.conftest import get_console_errors + return get_console_errors(self._console_messages) + + def discover_interactables(self, skip_sidebar: bool = True) -> list[InteractableElement]: + """Find all interactive elements on the current page, excluding sidebar.""" + found: list[InteractableElement] = [] + + for selector in INTERACTABLE_SELECTORS: + elements = self.page.query_selector_all(selector) + for i, el in enumerate(elements): + if skip_sidebar and el.evaluate( + "el => el.closest('[data-testid=\"stSidebar\"]') !== null" + ): + continue + label = (el.inner_text() or el.get_attribute("aria-label") or f"element-{i}").strip() + label = label[:80] + found.append(InteractableElement(label=label, selector=selector, index=i)) + + for pattern in self.mode.expected_failures: + matches = [e for e in found if fnmatch.fnmatch(e.label, pattern)] + if len(matches) > 1: + warnings.warn( + f"expected_failure pattern '{pattern}' matches {len(matches)} elements: " + + ", ".join(f'"{m.label}"' for m in matches), + stacklevel=2, + ) + + return found diff --git a/tests/e2e/pages/home_page.py b/tests/e2e/pages/home_page.py new file mode 100644 index 0000000..18ff47b --- /dev/null +++ b/tests/e2e/pages/home_page.py @@ -0,0 +1,4 @@ +from tests.e2e.pages.base_page import BasePage + +class HomePage(BasePage): + nav_label = "Home" diff --git a/tests/e2e/pages/interview_prep_page.py b/tests/e2e/pages/interview_prep_page.py new file mode 100644 index 0000000..aa600e4 --- /dev/null +++ b/tests/e2e/pages/interview_prep_page.py @@ -0,0 +1,4 @@ +from tests.e2e.pages.base_page import BasePage + +class InterviewPrepPage(BasePage): + nav_label = "Interview Prep" diff --git a/tests/e2e/pages/interviews_page.py b/tests/e2e/pages/interviews_page.py new file mode 100644 index 0000000..bba755c --- /dev/null +++ b/tests/e2e/pages/interviews_page.py @@ -0,0 +1,4 @@ +from tests.e2e.pages.base_page import BasePage + +class InterviewsPage(BasePage): + nav_label = "Interviews" diff --git a/tests/e2e/pages/job_review_page.py b/tests/e2e/pages/job_review_page.py new file mode 100644 index 0000000..513c893 --- /dev/null +++ b/tests/e2e/pages/job_review_page.py @@ -0,0 +1,4 @@ +from tests.e2e.pages.base_page import BasePage + +class JobReviewPage(BasePage): + nav_label = "Job Review" diff --git a/tests/e2e/pages/settings_page.py b/tests/e2e/pages/settings_page.py new file mode 100644 index 0000000..06f47b6 --- /dev/null +++ b/tests/e2e/pages/settings_page.py @@ -0,0 +1,44 @@ +"""Settings page — tab-aware page object.""" +from __future__ import annotations +import logging + +from tests.e2e.pages.base_page import BasePage, InteractableElement + +log = logging.getLogger(__name__) + + +class SettingsPage(BasePage): + nav_label = "Settings" + + def discover_interactables(self, skip_sidebar: bool = True) -> list[InteractableElement]: + """ + Settings has multiple tabs. Click each expected tab, collect interactables, + return the full combined list. + """ + from tests.e2e.conftest import wait_for_streamlit + + all_elements: list[InteractableElement] = [] + tab_labels = self.mode.settings_tabs + + for tab_label in tab_labels: + # Match on full label text — handles emoji correctly. + # Do NOT use tab_label.split()[-1]: "My Profile" and "Resume Profile" + # both end in "Profile", causing a silent collision. + tab_btn = self.page.locator( + '[data-testid="stTab"] button[role="tab"]' + ).filter(has_text=tab_label) + if tab_btn.count() == 0: + log.warning("Settings tab not found: %s", tab_label) + continue + tab_btn.first.click() + wait_for_streamlit(self.page) + + tab_elements = super().discover_interactables(skip_sidebar=skip_sidebar) + # Exclude tab buttons (already handled by clicking) + tab_elements = [ + e for e in tab_elements + if 'role="tab"' not in e.selector + ] + all_elements.extend(tab_elements) + + return all_elements diff --git a/tests/e2e/pages/survey_page.py b/tests/e2e/pages/survey_page.py new file mode 100644 index 0000000..a9b9c1e --- /dev/null +++ b/tests/e2e/pages/survey_page.py @@ -0,0 +1,4 @@ +from tests.e2e.pages.base_page import BasePage + +class SurveyPage(BasePage): + nav_label = "Survey Assistant"