Include full contents of all nested repositories
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
116
letsbe-sysadmin-agent/app/playwright_scenarios/__init__.py
Normal file
116
letsbe-sysadmin-agent/app/playwright_scenarios/__init__.py
Normal 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",
|
||||
]
|
||||
162
letsbe-sysadmin-agent/app/playwright_scenarios/base.py
Normal file
162
letsbe-sysadmin-agent/app/playwright_scenarios/base.py
Normal 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]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Cal.com browser automation scenarios."""
|
||||
|
||||
from app.playwright_scenarios.calcom.initial_setup import CalcomInitialSetup
|
||||
|
||||
__all__ = ["CalcomInitialSetup"]
|
||||
@@ -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)}",
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Chatwoot browser automation scenarios."""
|
||||
|
||||
from app.playwright_scenarios.chatwoot.initial_setup import ChatwootInitialSetup
|
||||
|
||||
__all__ = ["ChatwootInitialSetup"]
|
||||
@@ -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)}",
|
||||
)
|
||||
120
letsbe-sysadmin-agent/app/playwright_scenarios/echo.py
Normal file
120
letsbe-sysadmin-agent/app/playwright_scenarios/echo.py
Normal 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)}",
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Keycloak browser automation scenarios."""
|
||||
|
||||
from app.playwright_scenarios.keycloak.initial_setup import KeycloakInitialSetup
|
||||
|
||||
__all__ = ["KeycloakInitialSetup"]
|
||||
@@ -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)}",
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""n8n browser automation scenarios."""
|
||||
|
||||
from app.playwright_scenarios.n8n.initial_setup import N8nInitialSetup
|
||||
|
||||
__all__ = ["N8nInitialSetup"]
|
||||
@@ -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)}",
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Nextcloud browser automation scenarios."""
|
||||
|
||||
from app.playwright_scenarios.nextcloud.initial_setup import NextcloudInitialSetup
|
||||
|
||||
__all__ = ["NextcloudInitialSetup"]
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Poste.io browser automation scenarios."""
|
||||
|
||||
from app.playwright_scenarios.poste.initial_setup import PosteInitialSetup
|
||||
|
||||
__all__ = ["PosteInitialSetup"]
|
||||
@@ -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)}",
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Umami browser automation scenarios."""
|
||||
|
||||
from app.playwright_scenarios.umami.initial_setup import UmamiInitialSetup
|
||||
|
||||
__all__ = ["UmamiInitialSetup"]
|
||||
@@ -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)}",
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Uptime Kuma browser automation scenarios."""
|
||||
|
||||
from app.playwright_scenarios.uptime_kuma.initial_setup import UptimeKumaInitialSetup
|
||||
|
||||
__all__ = ["UptimeKumaInitialSetup"]
|
||||
@@ -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)}",
|
||||
)
|
||||
Reference in New Issue
Block a user