letsbe-sysadmin/app/playwright_scenarios/base.py

163 lines
5.2 KiB
Python

"""Base classes for Playwright scenario execution.
Scenarios are deterministic, reusable browser automation sequences
that execute specific UI workflows against tenant applications.
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
from playwright.async_api import Page
@dataclass
class ScenarioOptions:
"""Configuration options for scenario execution.
Attributes:
timeout_ms: Default timeout for actions in milliseconds
screenshot_on_failure: Capture screenshot when scenario fails
screenshot_on_success: Capture screenshot when scenario succeeds
save_trace: Save Playwright trace for debugging
allowed_domains: List of domains the scenario can access (REQUIRED for security)
artifacts_dir: Directory to save screenshots and traces
"""
timeout_ms: int = 60000
screenshot_on_failure: bool = True
screenshot_on_success: bool = False
save_trace: bool = False
allowed_domains: list[str] = field(default_factory=list)
artifacts_dir: Optional[Path] = None
def __post_init__(self) -> None:
if self.artifacts_dir and isinstance(self.artifacts_dir, str):
self.artifacts_dir = Path(self.artifacts_dir)
@dataclass
class ScenarioResult:
"""Result of a scenario execution.
Attributes:
success: Whether the scenario completed successfully
data: Scenario-specific result data
screenshots: List of paths to captured screenshots
error: Error message if scenario failed
trace_path: Path to trace file if tracing was enabled
"""
success: bool
data: dict[str, Any]
screenshots: list[str] = field(default_factory=list)
error: Optional[str] = None
trace_path: Optional[str] = None
class BaseScenario(ABC):
"""Abstract base class for Playwright scenarios.
Each scenario implements a specific UI automation workflow.
Scenarios are registered by name and dispatched by the PlaywrightExecutor.
Example implementation:
class NextcloudInitialSetup(BaseScenario):
@property
def name(self) -> str:
return "nextcloud_initial_setup"
@property
def required_inputs(self) -> list[str]:
return ["base_url", "admin_username", "admin_password"]
async def execute(self, page, inputs, options) -> ScenarioResult:
# Perform setup steps...
return ScenarioResult(success=True, data={"setup": "complete"})
"""
@property
@abstractmethod
def name(self) -> str:
"""Unique name identifying this scenario.
This name is used in task payloads to select the scenario.
Convention: lowercase_with_underscores (e.g., 'nextcloud_initial_setup')
"""
...
@property
@abstractmethod
def required_inputs(self) -> list[str]:
"""List of required input keys for this scenario.
The executor validates that all required inputs are present
before executing the scenario.
"""
...
@property
def optional_inputs(self) -> list[str]:
"""List of optional input keys for this scenario.
Override this property to declare optional inputs with defaults.
"""
return []
@property
def description(self) -> str:
"""Human-readable description of what this scenario does.
Override this property to provide documentation.
"""
return f"Scenario: {self.name}"
@abstractmethod
async def execute(
self,
page: Page,
inputs: dict[str, Any],
options: ScenarioOptions,
) -> ScenarioResult:
"""Execute the scenario against the provided page.
Args:
page: Playwright Page object with domain restrictions applied
inputs: Dictionary of input values (validated by executor)
options: Scenario options including timeout and artifact settings
Returns:
ScenarioResult with success status and any result data
Note:
- Domain restrictions are already enforced by the executor
- Screenshots on failure are handled by the executor
- Focus on the business logic of the UI workflow
"""
...
async def setup(self, page: Page, options: ScenarioOptions) -> None:
"""Optional setup hook called before execute().
Override to perform setup actions like setting viewport size,
configuring page settings, etc.
"""
pass
async def teardown(self, page: Page, options: ScenarioOptions) -> None:
"""Optional teardown hook called after execute().
Override to perform cleanup actions. Called even if execute() fails.
"""
pass
def validate_inputs(self, inputs: dict[str, Any]) -> list[str]:
"""Validate inputs and return list of missing required keys.
Args:
inputs: Dictionary of inputs to validate
Returns:
List of missing required input keys (empty if all present)
"""
return [key for key in self.required_inputs if key not in inputs]