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.
This commit is contained in:
pyr0ball 2026-03-16 23:14:20 -07:00
parent 20c776260f
commit 0cdd97f1c0
8 changed files with 147 additions and 0 deletions

View file

@ -0,0 +1,4 @@
from tests.e2e.pages.base_page import BasePage
class ApplyPage(BasePage):
nav_label = "Apply Workspace"

View file

@ -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

View file

@ -0,0 +1,4 @@
from tests.e2e.pages.base_page import BasePage
class HomePage(BasePage):
nav_label = "Home"

View file

@ -0,0 +1,4 @@
from tests.e2e.pages.base_page import BasePage
class InterviewPrepPage(BasePage):
nav_label = "Interview Prep"

View file

@ -0,0 +1,4 @@
from tests.e2e.pages.base_page import BasePage
class InterviewsPage(BasePage):
nav_label = "Interviews"

View file

@ -0,0 +1,4 @@
from tests.e2e.pages.base_page import BasePage
class JobReviewPage(BasePage):
nav_label = "Job Review"

View file

@ -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

View file

@ -0,0 +1,4 @@
from tests.e2e.pages.base_page import BasePage
class SurveyPage(BasePage):
nav_label = "Survey Assistant"