Include full contents of all nested repositories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:25:02 +01:00
parent 14ff8fd54c
commit 2401ed446f
7271 changed files with 1310112 additions and 6 deletions

View File

@@ -0,0 +1,116 @@
"""Playwright scenario registry.
This module provides the central registry for all available Playwright scenarios.
Scenarios are registered at import time and looked up by name during execution.
Usage:
from app.playwright_scenarios import get_scenario, list_scenarios
# Get a specific scenario
scenario = get_scenario("nextcloud_initial_setup")
# List all available scenarios
available = list_scenarios()
"""
from typing import Optional
from app.playwright_scenarios.base import BaseScenario, ScenarioOptions, ScenarioResult
# Registry mapping scenario names to scenario classes
_SCENARIO_REGISTRY: dict[str, type[BaseScenario]] = {}
def register_scenario(scenario_class: type[BaseScenario]) -> type[BaseScenario]:
"""Decorator to register a scenario class.
Usage:
@register_scenario
class MyScenario(BaseScenario):
...
Args:
scenario_class: The scenario class to register
Returns:
The scenario class (unchanged)
Raises:
ValueError: If a scenario with the same name is already registered
"""
# Create instance to get the name
instance = scenario_class()
name = instance.name
if name in _SCENARIO_REGISTRY:
raise ValueError(
f"Scenario '{name}' is already registered by {_SCENARIO_REGISTRY[name].__name__}"
)
_SCENARIO_REGISTRY[name] = scenario_class
return scenario_class
def get_scenario(name: str) -> Optional[BaseScenario]:
"""Get a scenario instance by name.
Args:
name: The scenario name (e.g., 'nextcloud_initial_setup')
Returns:
Scenario instance if found, None otherwise
"""
scenario_class = _SCENARIO_REGISTRY.get(name)
if scenario_class is None:
return None
return scenario_class()
def list_scenarios() -> list[dict[str, str]]:
"""List all registered scenarios with their metadata.
Returns:
List of dictionaries with scenario name, description, and required inputs
"""
result = []
for name, scenario_class in sorted(_SCENARIO_REGISTRY.items()):
instance = scenario_class()
result.append({
"name": name,
"description": instance.description,
"required_inputs": instance.required_inputs,
"optional_inputs": instance.optional_inputs,
})
return result
def get_scenario_names() -> list[str]:
"""Get list of all registered scenario names.
Returns:
Sorted list of scenario names
"""
return sorted(_SCENARIO_REGISTRY.keys())
# Import scenario modules to trigger registration
# Add imports here as new scenarios are created:
from app.playwright_scenarios import echo # noqa: F401
from app.playwright_scenarios.nextcloud import initial_setup # noqa: F401
from app.playwright_scenarios.poste import initial_setup as poste_initial_setup # noqa: F401
from app.playwright_scenarios.chatwoot import initial_setup as chatwoot_initial_setup # noqa: F401
from app.playwright_scenarios.keycloak import initial_setup as keycloak_initial_setup # noqa: F401
from app.playwright_scenarios.n8n import initial_setup as n8n_initial_setup # noqa: F401
from app.playwright_scenarios.calcom import initial_setup as calcom_initial_setup # noqa: F401
from app.playwright_scenarios.umami import initial_setup as umami_initial_setup # noqa: F401
from app.playwright_scenarios.uptime_kuma import initial_setup as uptime_kuma_initial_setup # noqa: F401
__all__ = [
"BaseScenario",
"ScenarioOptions",
"ScenarioResult",
"register_scenario",
"get_scenario",
"list_scenarios",
"get_scenario_names",
]

View File

@@ -0,0 +1,162 @@
"""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]

View File

@@ -0,0 +1,5 @@
"""Cal.com browser automation scenarios."""
from app.playwright_scenarios.calcom.initial_setup import CalcomInitialSetup
__all__ = ["CalcomInitialSetup"]

View File

@@ -0,0 +1,254 @@
"""Cal.com initial setup scenario.
Automates the first-time setup for a fresh Cal.com installation.
This scenario:
1. Navigates to the Cal.com setup page
2. Creates the admin account
3. Completes onboarding steps
"""
import secrets
import string
from typing import Any
from playwright.async_api import Page
from app.playwright_scenarios import register_scenario
from app.playwright_scenarios.base import BaseScenario, ScenarioOptions, ScenarioResult
def generate_secure_password(length: int = 24) -> str:
"""Generate a cryptographically secure password.
Args:
length: Password length (default: 24)
Returns:
A secure random password with mixed characters
"""
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
password = [
secrets.choice(string.ascii_lowercase),
secrets.choice(string.ascii_uppercase),
secrets.choice(string.digits),
secrets.choice("!@#$%^&*"),
]
password.extend(secrets.choice(alphabet) for _ in range(length - 4))
password_list = list(password)
secrets.SystemRandom().shuffle(password_list)
return "".join(password_list)
@register_scenario
class CalcomInitialSetup(BaseScenario):
"""Automate Cal.com first-time admin account setup.
This scenario handles the initial account creation when
Cal.com is freshly installed. It navigates to the signup page,
fills in account details, and completes the onboarding wizard.
Required inputs:
base_url: The Cal.com instance URL (e.g., https://cal.example.com)
admin_email: Email address for the admin account
Optional inputs:
admin_password: Password for admin account (auto-generated if not provided)
admin_username: Username for the admin account (default: "admin")
admin_name: Display name for the admin account (default: "Admin")
Result data:
setup_completed: Whether initial setup was completed
admin_email: The configured admin email address
admin_password: The password (generated or provided) - STORE SECURELY
already_configured: True if Cal.com was already set up
"""
@property
def name(self) -> str:
return "calcom_initial_setup"
@property
def required_inputs(self) -> list[str]:
return ["base_url", "admin_email"]
@property
def optional_inputs(self) -> list[str]:
return ["admin_password", "admin_username", "admin_name"]
@property
def description(self) -> str:
return "Automate Cal.com first-time admin account setup"
async def execute(
self,
page: Page,
inputs: dict[str, Any],
options: ScenarioOptions,
) -> ScenarioResult:
"""Execute the Cal.com initial setup.
Args:
page: Playwright Page object
inputs: Scenario inputs (base_url, admin_email)
options: Scenario options
Returns:
ScenarioResult with setup status and credentials
"""
base_url = inputs["base_url"].rstrip("/")
admin_email = inputs["admin_email"]
admin_password = inputs.get("admin_password") or generate_secure_password()
admin_username = inputs.get("admin_username", "admin")
admin_name = inputs.get("admin_name", "Admin")
screenshots = []
result_data = {
"setup_completed": False,
"admin_email": admin_email,
"admin_password": admin_password,
"already_configured": False,
}
try:
# Navigate to Cal.com
await page.goto(base_url, wait_until="networkidle")
current_url = page.url
# Check if already configured (redirects to login)
if "/auth/login" in current_url:
result_data["already_configured"] = True
result_data["setup_completed"] = True
return ScenarioResult(
success=True,
data=result_data,
screenshots=screenshots,
error=None,
)
# Navigate to signup page
signup_url = f"{base_url}/signup"
await page.goto(signup_url, wait_until="networkidle")
# If redirected to login, the instance may already be set up
if "/auth/login" in page.url and "/signup" not in page.url:
result_data["already_configured"] = True
result_data["setup_completed"] = True
return ScenarioResult(
success=True,
data=result_data,
screenshots=screenshots,
error=None,
)
# Fill in the signup form
# Username
username_input = page.locator(
'input[name="username"], '
'input[id="username"], '
'input[placeholder*="username" i]'
).first
if await username_input.count() > 0:
await username_input.wait_for(state="visible", timeout=10000)
await username_input.fill(admin_username)
# Full name
name_input = page.locator(
'input[name="name"], '
'input[name="full_name"], '
'input[placeholder*="name" i]'
).first
if await name_input.count() > 0:
await name_input.fill(admin_name)
# Email
email_input = page.locator(
'input[name="email"], '
'input[type="email"], '
'input[placeholder*="email" i]'
).first
await email_input.wait_for(state="visible", timeout=10000)
await email_input.fill(admin_email)
# Password
password_input = page.locator(
'input[name="password"], '
'input[type="password"]'
).first
await password_input.fill(admin_password)
# Take screenshot before submitting
if options.screenshot_on_success and options.artifacts_dir:
pre_submit_path = options.artifacts_dir / "calcom_pre_submit.png"
await page.screenshot(path=str(pre_submit_path))
screenshots.append(str(pre_submit_path))
# Click Sign up / Create Account button
submit_button = page.locator(
'button:has-text("Sign up"), '
'button:has-text("Create"), '
'button:has-text("Register"), '
'button[type="submit"]'
).first
await submit_button.click()
# Wait for onboarding or dashboard
await page.wait_for_timeout(3000)
# Cal.com has an onboarding wizard after signup
# Skip through onboarding steps
for _ in range(5):
skip_button = page.locator(
'button:has-text("Skip"), '
'a:has-text("Skip"), '
'button:has-text("Next"), '
'button:has-text("Continue"), '
'button:has-text("Finish")'
)
if await skip_button.count() > 0:
await skip_button.first.click()
await page.wait_for_timeout(2000)
else:
break
# Check if we reached the dashboard or event types page
await page.wait_for_timeout(2000)
current_url = page.url
if any(kw in current_url for kw in ["/event-types", "/dashboard", "/bookings", "/settings"]):
result_data["setup_completed"] = True
else:
# Check for dashboard indicators
dashboard_el = page.locator(
'[class*="event-type"], '
'[class*="dashboard"], '
':has-text("Event Types")'
)
if await dashboard_el.count() > 0:
result_data["setup_completed"] = True
# Take final screenshot
if options.screenshot_on_success and options.artifacts_dir:
final_path = options.artifacts_dir / "calcom_setup_complete.png"
await page.screenshot(path=str(final_path))
screenshots.append(str(final_path))
return ScenarioResult(
success=result_data["setup_completed"],
data=result_data,
screenshots=screenshots,
error=None if result_data["setup_completed"] else "Setup may not have completed",
)
except Exception as e:
if options.screenshot_on_failure and options.artifacts_dir:
error_path = options.artifacts_dir / "calcom_setup_error.png"
await page.screenshot(path=str(error_path))
screenshots.append(str(error_path))
return ScenarioResult(
success=False,
data=result_data,
screenshots=screenshots,
error=f"Cal.com setup failed: {str(e)}",
)

View File

@@ -0,0 +1,5 @@
"""Chatwoot browser automation scenarios."""
from app.playwright_scenarios.chatwoot.initial_setup import ChatwootInitialSetup
__all__ = ["ChatwootInitialSetup"]

View File

@@ -0,0 +1,291 @@
"""Chatwoot initial setup scenario.
Automates the first-time setup for a fresh Chatwoot installation.
This scenario:
1. Navigates to the Chatwoot installation wizard
2. Fills in admin account details (name, company, email, password)
3. Unchecks the newsletter subscription
4. Completes the setup
"""
import secrets
import string
from typing import Any
from playwright.async_api import Page
from app.playwright_scenarios import register_scenario
from app.playwright_scenarios.base import BaseScenario, ScenarioOptions, ScenarioResult
def generate_secure_password(length: int = 24) -> str:
"""Generate a cryptographically secure password.
Args:
length: Password length (default: 24)
Returns:
A secure random password with mixed characters
"""
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
password = [
secrets.choice(string.ascii_lowercase),
secrets.choice(string.ascii_uppercase),
secrets.choice(string.digits),
secrets.choice("!@#$%^&*"),
]
password.extend(secrets.choice(alphabet) for _ in range(length - 4))
password_list = list(password)
secrets.SystemRandom().shuffle(password_list)
return "".join(password_list)
@register_scenario
class ChatwootInitialSetup(BaseScenario):
"""Automate Chatwoot first-time setup wizard.
This scenario handles the initial super admin account creation when
Chatwoot is freshly installed. It fills in the account details,
unchecks the newsletter subscription, and completes the setup.
Required inputs:
base_url: The Chatwoot instance URL (e.g., https://chatwoot.example.com)
admin_name: Full name for the admin account
company_name: Company/organization name
admin_email: Email address for the admin account
Optional inputs:
admin_password: Password for admin account (auto-generated if not provided)
Result data:
setup_completed: Whether initial setup was completed
admin_email: The configured admin email address
admin_password: The password (generated or provided) - STORE SECURELY
already_configured: True if Chatwoot was already set up
"""
@property
def name(self) -> str:
return "chatwoot_initial_setup"
@property
def required_inputs(self) -> list[str]:
return ["base_url", "admin_name", "company_name", "admin_email"]
@property
def optional_inputs(self) -> list[str]:
return ["admin_password"]
@property
def description(self) -> str:
return "Automate Chatwoot first-time admin account setup"
async def execute(
self,
page: Page,
inputs: dict[str, Any],
options: ScenarioOptions,
) -> ScenarioResult:
"""Execute the Chatwoot initial setup.
Args:
page: Playwright Page object
inputs: Scenario inputs (base_url, admin_name, company_name, admin_email)
options: Scenario options
Returns:
ScenarioResult with setup status and credentials
"""
base_url = inputs["base_url"].rstrip("/")
admin_name = inputs["admin_name"]
company_name = inputs["company_name"]
admin_email = inputs["admin_email"]
# Generate password if not provided
admin_password = inputs.get("admin_password") or generate_secure_password()
screenshots = []
result_data = {
"setup_completed": False,
"admin_name": admin_name,
"company_name": company_name,
"admin_email": admin_email,
"admin_password": admin_password, # Return for secure storage
"already_configured": False,
}
try:
# Navigate to Chatwoot
await page.goto(base_url, wait_until="networkidle")
current_url = page.url
# Check if we're on the setup page or already configured
# Chatwoot setup page typically at /app/login or /super_admin/setup
if "/app/login" in current_url and "installation" not in current_url:
# Already configured - login page without setup
result_data["already_configured"] = True
result_data["setup_completed"] = True
return ScenarioResult(
success=True,
data=result_data,
screenshots=screenshots,
error=None,
)
# Look for the super admin setup form
# Try common setup URL patterns
setup_urls = [
f"{base_url}/super_admin/setup",
f"{base_url}/installation/onboarding",
base_url, # Sometimes the root redirects to setup
]
setup_found = False
for setup_url in setup_urls:
await page.goto(setup_url, wait_until="networkidle")
# Check for setup form elements
name_input = page.locator('input[name="name"], input[placeholder*="name" i]')
if await name_input.count() > 0:
setup_found = True
break
if not setup_found:
# Check if already configured
if "/app" in page.url or "/dashboard" in page.url:
result_data["already_configured"] = True
result_data["setup_completed"] = True
return ScenarioResult(
success=True,
data=result_data,
screenshots=screenshots,
error=None,
)
return ScenarioResult(
success=False,
data=result_data,
screenshots=screenshots,
error="Could not find Chatwoot setup page",
)
# Fill in the setup form
# Name field
name_input = page.locator(
'input[name="name"], '
'input[placeholder*="name" i], '
'input[id*="name" i]'
).first
await name_input.wait_for(state="visible", timeout=10000)
await name_input.fill(admin_name)
# Company name field
company_input = page.locator(
'input[name="company_name"], '
'input[name="account_name"], '
'input[placeholder*="company" i], '
'input[placeholder*="account" i]'
).first
if await company_input.count() > 0:
await company_input.fill(company_name)
# Email field
email_input = page.locator(
'input[name="email"], '
'input[type="email"], '
'input[placeholder*="email" i]'
).first
await email_input.fill(admin_email)
# Password field
password_input = page.locator(
'input[name="password"], '
'input[type="password"]'
).first
await password_input.fill(admin_password)
# Uncheck newsletter subscription if present
newsletter_checkbox = page.locator(
'input[type="checkbox"][name*="subscribe" i], '
'input[type="checkbox"][name*="newsletter" i], '
'input[type="checkbox"][id*="subscribe" i], '
'label:has-text("Subscribe") input[type="checkbox"], '
'label:has-text("newsletter") input[type="checkbox"]'
)
if await newsletter_checkbox.count() > 0:
checkbox = newsletter_checkbox.first
is_checked = await checkbox.is_checked()
if is_checked:
await checkbox.uncheck()
# Take screenshot before submitting if requested
if options.screenshot_on_success and options.artifacts_dir:
pre_submit_path = options.artifacts_dir / "chatwoot_pre_submit.png"
await page.screenshot(path=str(pre_submit_path))
screenshots.append(str(pre_submit_path))
# Click Finish Setup / Submit button
submit_button = page.locator(
'button:has-text("Finish"), '
'button:has-text("Setup"), '
'button:has-text("Create"), '
'button[type="submit"], '
'input[type="submit"]'
).first
await submit_button.click()
# Wait for setup to complete - should redirect to login or dashboard
try:
await page.wait_for_url(
lambda url: "/app" in url or "/dashboard" in url or "/login" in url,
timeout=60000,
)
result_data["setup_completed"] = True
except Exception:
# Check if there's an error message
error_el = page.locator('.error, .alert-danger, [class*="error"]')
if await error_el.count() > 0:
error_text = await error_el.first.text_content()
return ScenarioResult(
success=False,
data=result_data,
screenshots=screenshots,
error=f"Setup failed: {error_text}",
)
# Check if we're on a success page
success_indicators = page.locator(
':has-text("success"), '
':has-text("Welcome"), '
':has-text("Dashboard")'
)
if await success_indicators.count() > 0:
result_data["setup_completed"] = True
# Take final screenshot
if options.screenshot_on_success and options.artifacts_dir:
final_path = options.artifacts_dir / "chatwoot_setup_complete.png"
await page.screenshot(path=str(final_path))
screenshots.append(str(final_path))
return ScenarioResult(
success=result_data["setup_completed"],
data=result_data,
screenshots=screenshots,
error=None if result_data["setup_completed"] else "Setup may not have completed",
)
except Exception as e:
# Take error screenshot
if options.screenshot_on_failure and options.artifacts_dir:
error_path = options.artifacts_dir / "chatwoot_setup_error.png"
await page.screenshot(path=str(error_path))
screenshots.append(str(error_path))
return ScenarioResult(
success=False,
data=result_data,
screenshots=screenshots,
error=f"Chatwoot setup failed: {str(e)}",
)

View File

@@ -0,0 +1,120 @@
"""Echo scenario for testing Playwright executor.
This simple scenario navigates to a URL and verifies the page loads.
Useful for testing the Playwright infrastructure without complex workflows.
"""
from typing import Any
from playwright.async_api import Page
from app.playwright_scenarios import register_scenario
from app.playwright_scenarios.base import BaseScenario, ScenarioOptions, ScenarioResult
@register_scenario
class EchoScenario(BaseScenario):
"""Simple echo scenario for testing Playwright executor.
This scenario navigates to a URL and returns basic page information.
Useful for verifying:
- Playwright is installed and working
- Domain restrictions are enforced
- Screenshots are captured correctly
Required inputs:
url: The URL to navigate to
Optional inputs:
wait_for_selector: CSS selector to wait for (default: body)
expected_title: Expected page title (optional validation)
Result data:
title: Page title after load
url: Final URL after any redirects
content_length: Approximate content length
"""
@property
def name(self) -> str:
return "echo"
@property
def required_inputs(self) -> list[str]:
return ["url"]
@property
def optional_inputs(self) -> list[str]:
return ["wait_for_selector", "expected_title"]
@property
def description(self) -> str:
return "Navigate to URL and return page info (test scenario)"
async def execute(
self,
page: Page,
inputs: dict[str, Any],
options: ScenarioOptions,
) -> ScenarioResult:
"""Navigate to URL and capture page information.
Args:
page: Playwright Page object
inputs: Scenario inputs (url, optional wait_for_selector)
options: Scenario options
Returns:
ScenarioResult with page information
"""
url = inputs["url"]
wait_for_selector = inputs.get("wait_for_selector", "body")
expected_title = inputs.get("expected_title")
screenshots = []
result_data = {}
try:
# Navigate to the URL
response = await page.goto(url, wait_until="networkidle")
# Wait for specified selector
if wait_for_selector:
await page.wait_for_selector(wait_for_selector, timeout=options.timeout_ms)
# Collect page information
result_data = {
"title": await page.title(),
"url": page.url,
"status_code": response.status if response else None,
"content_length": len(await page.content()),
}
# Validate title if expected
if expected_title and result_data["title"] != expected_title:
return ScenarioResult(
success=False,
data=result_data,
screenshots=screenshots,
error=f"Title mismatch: expected '{expected_title}', got '{result_data['title']}'",
)
# Take screenshot if requested
if options.screenshot_on_success and options.artifacts_dir:
screenshot_path = options.artifacts_dir / "echo_result.png"
await page.screenshot(path=str(screenshot_path))
screenshots.append(str(screenshot_path))
return ScenarioResult(
success=True,
data=result_data,
screenshots=screenshots,
)
except Exception as e:
return ScenarioResult(
success=False,
data=result_data,
screenshots=screenshots,
error=f"Echo scenario failed: {str(e)}",
)

View File

@@ -0,0 +1,5 @@
"""Keycloak browser automation scenarios."""
from app.playwright_scenarios.keycloak.initial_setup import KeycloakInitialSetup
__all__ = ["KeycloakInitialSetup"]

View File

@@ -0,0 +1,272 @@
"""Keycloak initial setup scenario.
Automates the first-time setup for a fresh Keycloak installation.
This scenario:
1. Navigates to the Keycloak admin console
2. Logs in with the admin credentials (set via env vars)
3. Creates a "letsbe" realm
4. Configures basic realm settings
"""
from typing import Any
from playwright.async_api import Page
from app.playwright_scenarios import register_scenario
from app.playwright_scenarios.base import BaseScenario, ScenarioOptions, ScenarioResult
@register_scenario
class KeycloakInitialSetup(BaseScenario):
"""Automate Keycloak initial realm setup.
This scenario handles the initial configuration after Keycloak is deployed.
It logs into the admin console and creates the "letsbe" realm with
appropriate settings.
Keycloak admin credentials are set via environment variables during
deployment (KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD), so this scenario
only needs to create the realm.
Required inputs:
base_url: The Keycloak instance URL (e.g., https://auth.example.com)
admin_user: Admin username (set during deployment)
admin_password: Admin password (set during deployment)
Optional inputs:
realm_name: Name of the realm to create (default: "letsbe")
Result data:
login_successful: Whether admin login succeeded
realm_created: Whether the realm was created
realm_name: Name of the created realm
already_configured: True if realm already exists
"""
@property
def name(self) -> str:
return "keycloak_initial_setup"
@property
def required_inputs(self) -> list[str]:
return ["base_url", "admin_user", "admin_password"]
@property
def optional_inputs(self) -> list[str]:
return ["realm_name"]
@property
def description(self) -> str:
return "Automate Keycloak admin login and realm creation"
async def execute(
self,
page: Page,
inputs: dict[str, Any],
options: ScenarioOptions,
) -> ScenarioResult:
"""Execute the Keycloak initial setup.
Args:
page: Playwright Page object
inputs: Scenario inputs (base_url, admin_user, admin_password)
options: Scenario options
Returns:
ScenarioResult with setup status
"""
base_url = inputs["base_url"].rstrip("/")
admin_user = inputs["admin_user"]
admin_password = inputs["admin_password"]
realm_name = inputs.get("realm_name", "letsbe")
screenshots = []
result_data = {
"login_successful": False,
"realm_created": False,
"realm_name": realm_name,
"already_configured": False,
}
try:
# Navigate to Keycloak admin console
admin_url = f"{base_url}/admin/master/console/"
await page.goto(admin_url, wait_until="networkidle")
# Keycloak redirects to login page
# Wait for the login form
username_input = page.locator('input#username, input[name="username"]')
await username_input.wait_for(state="visible", timeout=15000)
# Fill login form
await username_input.fill(admin_user)
password_input = page.locator('input#password, input[name="password"]')
await password_input.fill(admin_password)
# Click login button
login_button = page.locator(
'button#kc-login, '
'input#kc-login, '
'button[type="submit"], '
'input[type="submit"]'
)
await login_button.click()
# Wait for admin console to load
try:
await page.wait_for_url(
lambda url: "/admin" in url and "login" not in url.lower(),
timeout=30000,
)
result_data["login_successful"] = True
except Exception:
# Check for error message
error_el = page.locator('.alert-error, .kc-feedback-text, #input-error')
if await error_el.count() > 0:
error_text = await error_el.first.text_content()
return ScenarioResult(
success=False,
data=result_data,
screenshots=screenshots,
error=f"Login failed: {error_text}",
)
return ScenarioResult(
success=False,
data=result_data,
screenshots=screenshots,
error="Login failed - could not reach admin console",
)
# Check if realm already exists by navigating to realm selector
# Look for the realm dropdown or realm list
realm_selector = page.locator(
'[data-testid="realmSelector"], '
'.pf-c-dropdown__toggle, '
'#realm-select'
)
if await realm_selector.count() > 0:
await realm_selector.first.click()
await page.wait_for_timeout(1000)
# Check if our realm already exists in the dropdown
existing_realm = page.locator(
f'a:has-text("{realm_name}"), '
f'button:has-text("{realm_name}"), '
f'[data-testid="realmSelector"] >> text="{realm_name}"'
)
if await existing_realm.count() > 0:
result_data["already_configured"] = True
result_data["realm_created"] = True
# Click away to close dropdown
await page.keyboard.press("Escape")
return ScenarioResult(
success=True,
data=result_data,
screenshots=screenshots,
error=None,
)
# Close dropdown
await page.keyboard.press("Escape")
# Create new realm
# Navigate to realm creation page
create_realm_button = page.locator(
'a:has-text("Create Realm"), '
'button:has-text("Create Realm"), '
'a:has-text("Create realm"), '
'button:has-text("Create realm"), '
'[data-testid="add-realm"]'
)
if await create_realm_button.count() > 0:
await create_realm_button.first.click()
else:
# Try navigating directly
await page.goto(
f"{base_url}/admin/master/console/#/create/realm",
wait_until="networkidle",
)
await page.wait_for_timeout(2000)
# Fill in realm name
realm_name_input = page.locator(
'input#kc-realm, '
'input[name="realm"], '
'input[data-testid="realmName"], '
'input#name'
)
await realm_name_input.wait_for(state="visible", timeout=10000)
await realm_name_input.fill(realm_name)
# Ensure realm is enabled
enabled_toggle = page.locator(
'input[name="enabled"], '
'[data-testid="realmEnabled"]'
)
if await enabled_toggle.count() > 0:
is_checked = await enabled_toggle.first.is_checked()
if not is_checked:
await enabled_toggle.first.click()
# Take screenshot before creating
if options.screenshot_on_success and options.artifacts_dir:
pre_create_path = options.artifacts_dir / "keycloak_pre_create.png"
await page.screenshot(path=str(pre_create_path))
screenshots.append(str(pre_create_path))
# Click Create button
create_button = page.locator(
'button:has-text("Create"), '
'button[type="submit"]'
).first
await create_button.click()
# Wait for realm to be created (redirects to realm settings)
await page.wait_for_timeout(3000)
# Verify realm was created by checking URL or page content
current_url = page.url
if realm_name in current_url or "realm-settings" in current_url:
result_data["realm_created"] = True
else:
# Check for success notification
success_el = page.locator(
'.pf-c-alert.pf-m-success, '
'[class*="success"], '
':has-text("Realm created")'
)
if await success_el.count() > 0:
result_data["realm_created"] = True
# Take final screenshot
if options.screenshot_on_success and options.artifacts_dir:
final_path = options.artifacts_dir / "keycloak_setup_complete.png"
await page.screenshot(path=str(final_path))
screenshots.append(str(final_path))
return ScenarioResult(
success=result_data["realm_created"],
data=result_data,
screenshots=screenshots,
error=None if result_data["realm_created"] else "Realm creation may not have completed",
)
except Exception as e:
# Take error screenshot
if options.screenshot_on_failure and options.artifacts_dir:
error_path = options.artifacts_dir / "keycloak_setup_error.png"
await page.screenshot(path=str(error_path))
screenshots.append(str(error_path))
return ScenarioResult(
success=False,
data=result_data,
screenshots=screenshots,
error=f"Keycloak setup failed: {str(e)}",
)

View File

@@ -0,0 +1,5 @@
"""n8n browser automation scenarios."""
from app.playwright_scenarios.n8n.initial_setup import N8nInitialSetup
__all__ = ["N8nInitialSetup"]

View File

@@ -0,0 +1,264 @@
"""n8n initial setup scenario.
Automates the first-time setup for a fresh n8n installation.
This scenario:
1. Navigates to the n8n setup page
2. Creates the owner account with email and password
3. Skips optional setup steps
"""
import secrets
import string
from typing import Any
from playwright.async_api import Page
from app.playwright_scenarios import register_scenario
from app.playwright_scenarios.base import BaseScenario, ScenarioOptions, ScenarioResult
def generate_secure_password(length: int = 24) -> str:
"""Generate a cryptographically secure password.
Args:
length: Password length (default: 24)
Returns:
A secure random password with mixed characters
"""
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
password = [
secrets.choice(string.ascii_lowercase),
secrets.choice(string.ascii_uppercase),
secrets.choice(string.digits),
secrets.choice("!@#$%^&*"),
]
password.extend(secrets.choice(alphabet) for _ in range(length - 4))
password_list = list(password)
secrets.SystemRandom().shuffle(password_list)
return "".join(password_list)
@register_scenario
class N8nInitialSetup(BaseScenario):
"""Automate n8n first-time owner account setup.
This scenario handles the initial owner account creation when
n8n is freshly installed. It fills in the account details
and completes the setup wizard.
Required inputs:
base_url: The n8n instance URL (e.g., https://n8n.example.com)
admin_email: Email address for the owner account
Optional inputs:
admin_password: Password for owner account (auto-generated if not provided)
admin_first_name: First name for the owner (default: "Admin")
admin_last_name: Last name for the owner (default: "User")
Result data:
setup_completed: Whether initial setup was completed
admin_email: The configured owner email address
admin_password: The password (generated or provided) - STORE SECURELY
already_configured: True if n8n was already set up
"""
@property
def name(self) -> str:
return "n8n_initial_setup"
@property
def required_inputs(self) -> list[str]:
return ["base_url", "admin_email"]
@property
def optional_inputs(self) -> list[str]:
return ["admin_password", "admin_first_name", "admin_last_name"]
@property
def description(self) -> str:
return "Automate n8n first-time owner account setup"
async def execute(
self,
page: Page,
inputs: dict[str, Any],
options: ScenarioOptions,
) -> ScenarioResult:
"""Execute the n8n initial setup.
Args:
page: Playwright Page object
inputs: Scenario inputs (base_url, admin_email)
options: Scenario options
Returns:
ScenarioResult with setup status and credentials
"""
base_url = inputs["base_url"].rstrip("/")
admin_email = inputs["admin_email"]
admin_password = inputs.get("admin_password") or generate_secure_password()
admin_first_name = inputs.get("admin_first_name", "Admin")
admin_last_name = inputs.get("admin_last_name", "User")
screenshots = []
result_data = {
"setup_completed": False,
"admin_email": admin_email,
"admin_password": admin_password,
"already_configured": False,
}
try:
# Navigate to n8n
await page.goto(base_url, wait_until="networkidle")
current_url = page.url
# Check if already configured (redirects to signin)
if "/signin" in current_url:
result_data["already_configured"] = True
result_data["setup_completed"] = True
return ScenarioResult(
success=True,
data=result_data,
screenshots=screenshots,
error=None,
)
# n8n setup page should show the owner setup form
# Look for setup form elements
email_input = page.locator(
'input[name="email"], '
'input[type="email"], '
'input[placeholder*="email" i], '
'input[autocomplete="email"]'
)
if await email_input.count() == 0:
# Try navigating to setup URL
await page.goto(f"{base_url}/setup", wait_until="networkidle")
if "/signin" in page.url:
result_data["already_configured"] = True
result_data["setup_completed"] = True
return ScenarioResult(
success=True,
data=result_data,
screenshots=screenshots,
error=None,
)
# Fill in the owner setup form
# First name
first_name_input = page.locator(
'input[name="firstName"], '
'input[name="first_name"], '
'input[placeholder*="first" i], '
'input[autocomplete="given-name"]'
).first
if await first_name_input.count() > 0:
await first_name_input.wait_for(state="visible", timeout=10000)
await first_name_input.fill(admin_first_name)
# Last name
last_name_input = page.locator(
'input[name="lastName"], '
'input[name="last_name"], '
'input[placeholder*="last" i], '
'input[autocomplete="family-name"]'
).first
if await last_name_input.count() > 0:
await last_name_input.fill(admin_last_name)
# Email
email_input = page.locator(
'input[name="email"], '
'input[type="email"], '
'input[placeholder*="email" i]'
).first
await email_input.wait_for(state="visible", timeout=10000)
await email_input.fill(admin_email)
# Password
password_input = page.locator(
'input[name="password"], '
'input[type="password"]'
).first
await password_input.fill(admin_password)
# Take screenshot before submitting
if options.screenshot_on_success and options.artifacts_dir:
pre_submit_path = options.artifacts_dir / "n8n_pre_submit.png"
await page.screenshot(path=str(pre_submit_path))
screenshots.append(str(pre_submit_path))
# Click Next / Create Account button
submit_button = page.locator(
'button:has-text("Next"), '
'button:has-text("Create"), '
'button:has-text("Get started"), '
'button[type="submit"]'
).first
await submit_button.click()
# Wait for next step or dashboard
await page.wait_for_timeout(3000)
# n8n may show additional setup steps (personalization, usage, etc.)
# Skip through them
for _ in range(3):
skip_button = page.locator(
'button:has-text("Skip"), '
'a:has-text("Skip"), '
'button:has-text("Get started"), '
'button:has-text("Next")'
)
if await skip_button.count() > 0:
await skip_button.first.click()
await page.wait_for_timeout(2000)
else:
break
# Check if we reached the workflow editor or dashboard
await page.wait_for_timeout(2000)
current_url = page.url
if any(kw in current_url for kw in ["/workflow", "/home", "/dashboard"]):
result_data["setup_completed"] = True
else:
# Check for indicators of successful setup
canvas = page.locator(
'.workflow-canvas, '
'[class*="workflow"], '
'[class*="canvas"], '
'#app'
)
if await canvas.count() > 0:
result_data["setup_completed"] = True
# Take final screenshot
if options.screenshot_on_success and options.artifacts_dir:
final_path = options.artifacts_dir / "n8n_setup_complete.png"
await page.screenshot(path=str(final_path))
screenshots.append(str(final_path))
return ScenarioResult(
success=result_data["setup_completed"],
data=result_data,
screenshots=screenshots,
error=None if result_data["setup_completed"] else "Setup may not have completed",
)
except Exception as e:
if options.screenshot_on_failure and options.artifacts_dir:
error_path = options.artifacts_dir / "n8n_setup_error.png"
await page.screenshot(path=str(error_path))
screenshots.append(str(error_path))
return ScenarioResult(
success=False,
data=result_data,
screenshots=screenshots,
error=f"n8n setup failed: {str(e)}",
)

View File

@@ -0,0 +1,5 @@
"""Nextcloud browser automation scenarios."""
from app.playwright_scenarios.nextcloud.initial_setup import NextcloudInitialSetup
__all__ = ["NextcloudInitialSetup"]

View File

@@ -0,0 +1,231 @@
"""Nextcloud initial setup scenario.
Automates the first-time setup wizard for a fresh Nextcloud installation.
This scenario:
1. Navigates to the Nextcloud instance
2. Creates the admin account
3. Optionally skips recommended apps installation
4. Verifies successful login to the dashboard
"""
from typing import Any
from playwright.async_api import Page, expect
from app.playwright_scenarios import register_scenario
from app.playwright_scenarios.base import BaseScenario, ScenarioOptions, ScenarioResult
@register_scenario
class NextcloudInitialSetup(BaseScenario):
"""Automate Nextcloud first-time setup wizard.
This scenario handles the initial admin account creation when
Nextcloud is freshly installed. It's idempotent - if setup is
already complete, it will detect this and succeed.
Required inputs:
base_url: The Nextcloud instance URL (e.g., https://cloud.example.com)
admin_username: Username for the admin account
admin_password: Password for the admin account
Optional inputs:
skip_recommended_apps: Skip the recommended apps step (default: True)
Result data:
admin_created: Whether a new admin was created (False if already setup)
login_successful: Whether login to dashboard succeeded
setup_skipped: True if Nextcloud was already configured
"""
@property
def name(self) -> str:
return "nextcloud_initial_setup"
@property
def required_inputs(self) -> list[str]:
return ["base_url", "admin_username", "admin_password"]
@property
def optional_inputs(self) -> list[str]:
return ["skip_recommended_apps"]
@property
def description(self) -> str:
return "Automate Nextcloud first-time admin setup wizard"
async def execute(
self,
page: Page,
inputs: dict[str, Any],
options: ScenarioOptions,
) -> ScenarioResult:
"""Execute the Nextcloud initial setup.
Args:
page: Playwright Page object
inputs: Scenario inputs (base_url, admin_username, admin_password)
options: Scenario options
Returns:
ScenarioResult with setup status
"""
base_url = inputs["base_url"].rstrip("/")
admin_username = inputs["admin_username"]
admin_password = inputs["admin_password"]
skip_recommended_apps = inputs.get("skip_recommended_apps", True)
screenshots = []
result_data = {
"admin_created": False,
"login_successful": False,
"setup_skipped": False,
}
try:
# Navigate to Nextcloud
await page.goto(base_url, wait_until="networkidle")
# Check if we're on the setup page or login page
current_url = page.url
# Detect if setup is already complete (redirects to login)
if "/login" in current_url or await page.locator('input[name="user"]').count() > 0:
# Already configured, try to login
result_data["setup_skipped"] = True
login_success = await self._try_login(
page, admin_username, admin_password
)
result_data["login_successful"] = login_success
return ScenarioResult(
success=login_success,
data=result_data,
screenshots=screenshots,
error=None if login_success else "Login failed - check credentials",
)
# We're on the setup page - create admin account
# Wait for the setup form to be visible
admin_user_input = page.locator('input[id="adminlogin"], input[name="adminlogin"]')
await admin_user_input.wait_for(state="visible", timeout=10000)
# Fill in admin credentials
await admin_user_input.fill(admin_username)
admin_pass_input = page.locator('input[id="adminpass"], input[name="adminpass"]')
await admin_pass_input.fill(admin_password)
# Check for data directory input (may or may not be present)
data_dir_input = page.locator('input[id="directory"]')
if await data_dir_input.count() > 0 and await data_dir_input.is_visible():
# Keep default data directory
pass
# Click install/finish setup button
# Nextcloud uses various button texts depending on version
install_button = page.locator(
'input[type="submit"][value*="Install"], '
'input[type="submit"][value*="Finish"], '
'button:has-text("Install"), '
'button:has-text("Finish setup")'
)
await install_button.click()
# Wait for installation to complete (this can take a while)
# Look for either dashboard or recommended apps screen
try:
await page.wait_for_url(
lambda url: "/apps" in url or "/index.php" in url or "dashboard" in url.lower(),
timeout=120000, # 2 minutes for installation
)
except Exception:
# May be on recommended apps screen
pass
result_data["admin_created"] = True
# Handle recommended apps screen if present
if skip_recommended_apps:
skip_button = page.locator(
'button:has-text("Skip"), '
'a:has-text("Skip"), '
'.skip-button'
)
if await skip_button.count() > 0:
await skip_button.first.click()
await page.wait_for_load_state("networkidle")
# Verify we're logged in by checking for user menu or dashboard elements
dashboard_indicators = page.locator(
'#user-menu, '
'.user-menu, '
'[data-id="dashboard"], '
'#nextcloud, '
'.app-dashboard'
)
try:
await dashboard_indicators.first.wait_for(state="visible", timeout=30000)
result_data["login_successful"] = True
except Exception:
# Try one more check - look for any indication we're logged in
if await page.locator('.header-menu').count() > 0:
result_data["login_successful"] = True
# Take a screenshot of the final state if requested
if options.screenshot_on_success and options.artifacts_dir:
screenshot_path = options.artifacts_dir / "setup_complete.png"
await page.screenshot(path=str(screenshot_path))
screenshots.append(str(screenshot_path))
success = result_data["admin_created"] and result_data["login_successful"]
return ScenarioResult(
success=success,
data=result_data,
screenshots=screenshots,
error=None if success else "Setup completed but verification failed",
)
except Exception as e:
return ScenarioResult(
success=False,
data=result_data,
screenshots=screenshots,
error=f"Nextcloud setup failed: {str(e)}",
)
async def _try_login(self, page: Page, username: str, password: str) -> bool:
"""Attempt to login to an already-configured Nextcloud.
Args:
page: Playwright Page object (should be on login page)
username: Username to login with
password: Password to login with
Returns:
True if login succeeded, False otherwise
"""
try:
# Fill login form
await page.locator('input[name="user"]').fill(username)
await page.locator('input[name="password"]').fill(password)
# Submit login
await page.locator('input[type="submit"], button[type="submit"]').click()
# Wait for redirect to dashboard
await page.wait_for_url(
lambda url: "/login" not in url,
timeout=30000,
)
# Check for login error message
error_msg = page.locator('.warning, .error, [class*="error"]')
if await error_msg.count() > 0 and await error_msg.first.is_visible():
return False
return True
except Exception:
return False

View File

@@ -0,0 +1,5 @@
"""Poste.io browser automation scenarios."""
from app.playwright_scenarios.poste.initial_setup import PosteInitialSetup
__all__ = ["PosteInitialSetup"]

View File

@@ -0,0 +1,233 @@
"""Poste.io initial setup scenario.
Automates the first-time setup for a fresh Poste.io mail server installation.
This scenario:
1. Navigates to the Poste.io admin setup page
2. Configures the mailserver hostname
3. Creates the admin email account with a generated password
4. Returns the generated credentials for secure storage
"""
import secrets
import string
from typing import Any
from playwright.async_api import Page
from app.playwright_scenarios import register_scenario
from app.playwright_scenarios.base import BaseScenario, ScenarioOptions, ScenarioResult
def generate_secure_password(length: int = 24) -> str:
"""Generate a cryptographically secure password.
Args:
length: Password length (default: 24)
Returns:
A secure random password with mixed characters
"""
# Use a mix of letters, digits, and safe special characters
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
# Ensure at least one of each type
password = [
secrets.choice(string.ascii_lowercase),
secrets.choice(string.ascii_uppercase),
secrets.choice(string.digits),
secrets.choice("!@#$%^&*"),
]
# Fill the rest randomly
password.extend(secrets.choice(alphabet) for _ in range(length - 4))
# Shuffle to avoid predictable positions
password_list = list(password)
secrets.SystemRandom().shuffle(password_list)
return "".join(password_list)
@register_scenario
class PosteInitialSetup(BaseScenario):
"""Automate Poste.io first-time setup wizard.
This scenario handles the initial server configuration when
Poste.io is freshly installed. It configures the mailserver
hostname and creates the administrator email account.
Required inputs:
base_url: The Poste.io instance URL (e.g., https://mail.example.com)
admin_email: Admin email address (e.g., admin@example.com)
Optional inputs:
admin_password: Password for admin account (auto-generated if not provided)
mailserver_hostname: Override mailserver hostname (defaults to URL hostname)
Result data:
setup_completed: Whether initial setup was completed
admin_email: The configured admin email address
admin_password: The password (generated or provided) - STORE SECURELY
mailserver_hostname: The configured hostname
already_configured: True if Poste was already set up
"""
@property
def name(self) -> str:
return "poste_initial_setup"
@property
def required_inputs(self) -> list[str]:
return ["base_url", "admin_email"]
@property
def optional_inputs(self) -> list[str]:
return ["admin_password", "mailserver_hostname"]
@property
def description(self) -> str:
return "Automate Poste.io first-time mail server setup"
async def execute(
self,
page: Page,
inputs: dict[str, Any],
options: ScenarioOptions,
) -> ScenarioResult:
"""Execute the Poste.io initial setup.
Args:
page: Playwright Page object
inputs: Scenario inputs (base_url, admin_email, optional password)
options: Scenario options
Returns:
ScenarioResult with setup status and credentials
"""
base_url = inputs["base_url"].rstrip("/")
admin_email = inputs["admin_email"]
# Generate password if not provided
admin_password = inputs.get("admin_password") or generate_secure_password()
# Extract hostname from URL if not provided
from urllib.parse import urlparse
parsed_url = urlparse(base_url)
mailserver_hostname = inputs.get("mailserver_hostname") or parsed_url.netloc
screenshots = []
result_data = {
"setup_completed": False,
"admin_email": admin_email,
"admin_password": admin_password, # Return for secure storage
"mailserver_hostname": mailserver_hostname,
"already_configured": False,
}
try:
# Navigate to Poste.io
await page.goto(base_url, wait_until="networkidle")
current_url = page.url
# Check if we're on the setup page
if "/admin/install/server" not in current_url:
# Check if redirected to login (already configured)
if "/admin/login" in current_url or "/webmail" in current_url:
result_data["already_configured"] = True
result_data["setup_completed"] = True
return ScenarioResult(
success=True,
data=result_data,
screenshots=screenshots,
error=None,
)
# Try navigating directly to setup page
await page.goto(f"{base_url}/admin/install/server", wait_until="networkidle")
# If still not on setup, it's already configured
if "/admin/install/server" not in page.url:
result_data["already_configured"] = True
result_data["setup_completed"] = True
return ScenarioResult(
success=True,
data=result_data,
screenshots=screenshots,
error=None,
)
# We're on the setup page - configure the mail server
# Wait for the hostname input to be visible
hostname_input = page.locator('input[placeholder*="mail.example.com"]')
await hostname_input.wait_for(state="visible", timeout=10000)
# Clear and fill hostname (may be pre-filled)
await hostname_input.clear()
await hostname_input.fill(mailserver_hostname)
# Fill admin email
admin_email_input = page.locator('input[placeholder*="admin@example.com"]')
await admin_email_input.wait_for(state="visible", timeout=5000)
await admin_email_input.fill(admin_email)
# Fill password
password_input = page.locator('input[type="password"], input[placeholder*="Password"]').last
await password_input.wait_for(state="visible", timeout=5000)
await password_input.fill(admin_password)
# Take screenshot before submitting if requested
if options.screenshot_on_success and options.artifacts_dir:
pre_submit_path = options.artifacts_dir / "poste_pre_submit.png"
await page.screenshot(path=str(pre_submit_path))
screenshots.append(str(pre_submit_path))
# Click Submit button
submit_button = page.locator('button:has-text("Submit")')
await submit_button.click()
# Wait for setup to complete - should redirect away from install page
try:
await page.wait_for_url(
lambda url: "/admin/install" not in url,
timeout=60000, # 60 seconds for setup
)
result_data["setup_completed"] = True
except Exception:
# Check if there's an error message
error_el = page.locator('.error, .alert-danger, [class*="error"]')
if await error_el.count() > 0:
error_text = await error_el.first.text_content()
return ScenarioResult(
success=False,
data=result_data,
screenshots=screenshots,
error=f"Setup failed: {error_text}",
)
# Still on page but no error - might have succeeded
result_data["setup_completed"] = True
# Take final screenshot
if options.screenshot_on_success and options.artifacts_dir:
final_path = options.artifacts_dir / "poste_setup_complete.png"
await page.screenshot(path=str(final_path))
screenshots.append(str(final_path))
return ScenarioResult(
success=result_data["setup_completed"],
data=result_data,
screenshots=screenshots,
error=None,
)
except Exception as e:
# Take error screenshot
if options.screenshot_on_failure and options.artifacts_dir:
error_path = options.artifacts_dir / "poste_setup_error.png"
await page.screenshot(path=str(error_path))
screenshots.append(str(error_path))
return ScenarioResult(
success=False,
data=result_data,
screenshots=screenshots,
error=f"Poste.io setup failed: {str(e)}",
)

View File

@@ -0,0 +1,5 @@
"""Umami browser automation scenarios."""
from app.playwright_scenarios.umami.initial_setup import UmamiInitialSetup
__all__ = ["UmamiInitialSetup"]

View File

@@ -0,0 +1,291 @@
"""Umami initial setup scenario.
Automates the first-time setup for a fresh Umami installation.
This scenario:
1. Navigates to the Umami login page
2. Logs in with default credentials (admin / umami)
3. Changes the admin password
4. Optionally adds the first website to track
"""
import secrets
import string
from typing import Any
from playwright.async_api import Page
from app.playwright_scenarios import register_scenario
from app.playwright_scenarios.base import BaseScenario, ScenarioOptions, ScenarioResult
def generate_secure_password(length: int = 24) -> str:
"""Generate a cryptographically secure password.
Args:
length: Password length (default: 24)
Returns:
A secure random password with mixed characters
"""
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
password = [
secrets.choice(string.ascii_lowercase),
secrets.choice(string.ascii_uppercase),
secrets.choice(string.digits),
secrets.choice("!@#$%^&*"),
]
password.extend(secrets.choice(alphabet) for _ in range(length - 4))
password_list = list(password)
secrets.SystemRandom().shuffle(password_list)
return "".join(password_list)
@register_scenario
class UmamiInitialSetup(BaseScenario):
"""Automate Umami first-time setup.
This scenario handles the initial configuration after Umami is deployed.
Umami ships with default credentials (admin / umami). This scenario
logs in with those defaults, changes the password, and optionally
adds the first website to track.
Required inputs:
base_url: The Umami instance URL (e.g., https://analytics.example.com)
Optional inputs:
admin_password: New password for admin (auto-generated if not provided)
website_name: Name of the first website to add
website_url: URL of the first website to track
Result data:
setup_completed: Whether initial setup was completed
admin_password: The new admin password - STORE SECURELY
password_changed: Whether the default password was changed
website_added: Whether a website was added
already_configured: True if default password no longer works
"""
@property
def name(self) -> str:
return "umami_initial_setup"
@property
def required_inputs(self) -> list[str]:
return ["base_url"]
@property
def optional_inputs(self) -> list[str]:
return ["admin_password", "website_name", "website_url"]
@property
def description(self) -> str:
return "Automate Umami first-time password change and website setup"
async def execute(
self,
page: Page,
inputs: dict[str, Any],
options: ScenarioOptions,
) -> ScenarioResult:
"""Execute the Umami initial setup.
Args:
page: Playwright Page object
inputs: Scenario inputs (base_url)
options: Scenario options
Returns:
ScenarioResult with setup status and credentials
"""
base_url = inputs["base_url"].rstrip("/")
new_password = inputs.get("admin_password") or generate_secure_password()
website_name = inputs.get("website_name")
website_url = inputs.get("website_url")
screenshots = []
result_data = {
"setup_completed": False,
"admin_password": new_password,
"password_changed": False,
"website_added": False,
"already_configured": False,
}
try:
# Navigate to Umami login page
login_url = f"{base_url}/login"
await page.goto(login_url, wait_until="networkidle")
# Look for login form
username_input = page.locator(
'input[name="username"], '
'input[id="username"], '
'input[placeholder*="username" i]'
)
await username_input.wait_for(state="visible", timeout=10000)
# Try default credentials: admin / umami
await username_input.fill("admin")
password_input = page.locator(
'input[name="password"], '
'input[type="password"]'
).first
await password_input.fill("umami")
# Click login
login_button = page.locator(
'button:has-text("Login"), '
'button:has-text("Sign in"), '
'button[type="submit"]'
).first
await login_button.click()
# Wait for navigation
await page.wait_for_timeout(3000)
# Check if login succeeded
current_url = page.url
if "/login" in current_url:
# Default password may have already been changed
error_el = page.locator(
'.error, [class*="error"], [class*="alert"]'
)
if await error_el.count() > 0:
result_data["already_configured"] = True
result_data["setup_completed"] = True
return ScenarioResult(
success=True,
data=result_data,
screenshots=screenshots,
error=None,
)
# Logged in successfully with default password - change it
# Navigate to profile/settings to change password
settings_url = f"{base_url}/settings/profile"
await page.goto(settings_url, wait_until="networkidle")
# Look for password change form
current_password_input = page.locator(
'input[name="currentPassword"], '
'input[name="current_password"], '
'input[placeholder*="current" i]'
).first
if await current_password_input.count() > 0:
await current_password_input.wait_for(state="visible", timeout=10000)
await current_password_input.fill("umami")
new_password_input = page.locator(
'input[name="newPassword"], '
'input[name="new_password"], '
'input[placeholder*="new" i]'
).first
await new_password_input.fill(new_password)
confirm_password_input = page.locator(
'input[name="confirmPassword"], '
'input[name="confirm_password"], '
'input[placeholder*="confirm" i]'
).first
if await confirm_password_input.count() > 0:
await confirm_password_input.fill(new_password)
# Save password
save_button = page.locator(
'button:has-text("Save"), '
'button:has-text("Change"), '
'button:has-text("Update"), '
'button[type="submit"]'
).first
await save_button.click()
await page.wait_for_timeout(2000)
# Check for success
success_el = page.locator(
'[class*="success"], '
':has-text("saved"), '
':has-text("updated")'
)
if await success_el.count() > 0:
result_data["password_changed"] = True
else:
# Assume success if no error visible
error_el = page.locator('[class*="error"]')
if await error_el.count() == 0:
result_data["password_changed"] = True
# Optionally add first website
if website_name and website_url:
websites_url = f"{base_url}/settings/websites"
await page.goto(websites_url, wait_until="networkidle")
# Click Add Website button
add_button = page.locator(
'button:has-text("Add website"), '
'button:has-text("Add"), '
'a:has-text("Add website")'
).first
if await add_button.count() > 0:
await add_button.click()
await page.wait_for_timeout(1000)
# Fill website name
name_input = page.locator(
'input[name="name"], '
'input[placeholder*="name" i]'
).first
if await name_input.count() > 0:
await name_input.fill(website_name)
# Fill website URL/domain
url_input = page.locator(
'input[name="domain"], '
'input[name="url"], '
'input[placeholder*="domain" i], '
'input[placeholder*="url" i]'
).first
if await url_input.count() > 0:
await url_input.fill(website_url)
# Save
save_button = page.locator(
'button:has-text("Save"), '
'button:has-text("Create"), '
'button[type="submit"]'
).first
await save_button.click()
await page.wait_for_timeout(2000)
result_data["website_added"] = True
result_data["setup_completed"] = True
# Take final screenshot
if options.screenshot_on_success and options.artifacts_dir:
final_path = options.artifacts_dir / "umami_setup_complete.png"
await page.screenshot(path=str(final_path))
screenshots.append(str(final_path))
return ScenarioResult(
success=True,
data=result_data,
screenshots=screenshots,
error=None,
)
except Exception as e:
if options.screenshot_on_failure and options.artifacts_dir:
error_path = options.artifacts_dir / "umami_setup_error.png"
await page.screenshot(path=str(error_path))
screenshots.append(str(error_path))
return ScenarioResult(
success=False,
data=result_data,
screenshots=screenshots,
error=f"Umami setup failed: {str(e)}",
)

View File

@@ -0,0 +1,5 @@
"""Uptime Kuma browser automation scenarios."""
from app.playwright_scenarios.uptime_kuma.initial_setup import UptimeKumaInitialSetup
__all__ = ["UptimeKumaInitialSetup"]

View File

@@ -0,0 +1,229 @@
"""Uptime Kuma initial setup scenario.
Automates the first-time setup for a fresh Uptime Kuma installation.
This scenario:
1. Navigates to the Uptime Kuma setup page
2. Creates the admin account with username and password
"""
import secrets
import string
from typing import Any
from playwright.async_api import Page
from app.playwright_scenarios import register_scenario
from app.playwright_scenarios.base import BaseScenario, ScenarioOptions, ScenarioResult
def generate_secure_password(length: int = 24) -> str:
"""Generate a cryptographically secure password.
Args:
length: Password length (default: 24)
Returns:
A secure random password with mixed characters
"""
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
password = [
secrets.choice(string.ascii_lowercase),
secrets.choice(string.ascii_uppercase),
secrets.choice(string.digits),
secrets.choice("!@#$%^&*"),
]
password.extend(secrets.choice(alphabet) for _ in range(length - 4))
password_list = list(password)
secrets.SystemRandom().shuffle(password_list)
return "".join(password_list)
@register_scenario
class UptimeKumaInitialSetup(BaseScenario):
"""Automate Uptime Kuma first-time admin account setup.
This scenario handles the initial admin account creation when
Uptime Kuma is freshly installed. On first launch, Uptime Kuma
shows a setup page to create the admin account.
Required inputs:
base_url: The Uptime Kuma instance URL (e.g., https://status.example.com)
Optional inputs:
admin_username: Username for the admin account (default: "admin")
admin_password: Password for admin account (auto-generated if not provided)
Result data:
setup_completed: Whether initial setup was completed
admin_username: The configured admin username
admin_password: The password (generated or provided) - STORE SECURELY
already_configured: True if Uptime Kuma was already set up
"""
@property
def name(self) -> str:
return "uptime_kuma_initial_setup"
@property
def required_inputs(self) -> list[str]:
return ["base_url"]
@property
def optional_inputs(self) -> list[str]:
return ["admin_username", "admin_password"]
@property
def description(self) -> str:
return "Automate Uptime Kuma first-time admin account setup"
async def execute(
self,
page: Page,
inputs: dict[str, Any],
options: ScenarioOptions,
) -> ScenarioResult:
"""Execute the Uptime Kuma initial setup.
Args:
page: Playwright Page object
inputs: Scenario inputs (base_url)
options: Scenario options
Returns:
ScenarioResult with setup status and credentials
"""
base_url = inputs["base_url"].rstrip("/")
admin_username = inputs.get("admin_username", "admin")
admin_password = inputs.get("admin_password") or generate_secure_password()
screenshots = []
result_data = {
"setup_completed": False,
"admin_username": admin_username,
"admin_password": admin_password,
"already_configured": False,
}
try:
# Navigate to Uptime Kuma
await page.goto(base_url, wait_until="networkidle")
current_url = page.url
# Uptime Kuma shows setup page on first visit, login page after
# Check if we're on the setup page
setup_heading = page.locator(
'h1:has-text("Setup"), '
':has-text("Create your admin account")'
)
# Check if already configured (shows login form)
login_form = page.locator(
'form:has(input[autocomplete="username"]), '
'h1:has-text("Login")'
)
if await login_form.count() > 0 and await setup_heading.count() == 0:
result_data["already_configured"] = True
result_data["setup_completed"] = True
return ScenarioResult(
success=True,
data=result_data,
screenshots=screenshots,
error=None,
)
# We're on the setup page - fill in the admin account
# Username field
username_input = page.locator(
'input[autocomplete="username"], '
'input[name="username"], '
'input[id="floatingInput"], '
'input[placeholder*="username" i]'
).first
await username_input.wait_for(state="visible", timeout=10000)
await username_input.fill(admin_username)
# Password field
password_input = page.locator(
'input[type="password"][autocomplete="new-password"], '
'input[name="password"], '
'input[type="password"]'
).first
await password_input.fill(admin_password)
# Confirm password field (Uptime Kuma requires password confirmation)
confirm_input = page.locator(
'input[type="password"][autocomplete="new-password"]'
)
if await confirm_input.count() > 1:
# Second password field is the confirm field
await confirm_input.nth(1).fill(admin_password)
else:
# Try alternative selector
confirm_input = page.locator(
'input[name="repeatPassword"], '
'input[name="confirm_password"], '
'input[placeholder*="repeat" i], '
'input[placeholder*="confirm" i]'
).first
if await confirm_input.count() > 0:
await confirm_input.fill(admin_password)
# Take screenshot before submitting
if options.screenshot_on_success and options.artifacts_dir:
pre_submit_path = options.artifacts_dir / "uptime_kuma_pre_submit.png"
await page.screenshot(path=str(pre_submit_path))
screenshots.append(str(pre_submit_path))
# Click Create / Submit button
submit_button = page.locator(
'button:has-text("Create"), '
'button:has-text("Submit"), '
'button:has-text("Register"), '
'button[type="submit"]'
).first
await submit_button.click()
# Wait for redirect to dashboard
try:
await page.wait_for_url(
lambda url: "/dashboard" in url or "/setup" not in url,
timeout=30000,
)
result_data["setup_completed"] = True
except Exception:
# Check if on dashboard by looking for dashboard elements
dashboard_el = page.locator(
'.dashboard, '
'[class*="dashboard"], '
':has-text("Add New Monitor")'
)
if await dashboard_el.count() > 0:
result_data["setup_completed"] = True
# Take final screenshot
if options.screenshot_on_success and options.artifacts_dir:
final_path = options.artifacts_dir / "uptime_kuma_setup_complete.png"
await page.screenshot(path=str(final_path))
screenshots.append(str(final_path))
return ScenarioResult(
success=result_data["setup_completed"],
data=result_data,
screenshots=screenshots,
error=None if result_data["setup_completed"] else "Setup may not have completed",
)
except Exception as e:
if options.screenshot_on_failure and options.artifacts_dir:
error_path = options.artifacts_dir / "uptime_kuma_setup_error.png"
await page.screenshot(path=str(error_path))
screenshots.append(str(error_path))
return ScenarioResult(
success=False,
data=result_data,
screenshots=screenshots,
error=f"Uptime Kuma setup failed: {str(e)}",
)