2025-12-08 15:55:16 +01:00
|
|
|
"""Playwright browser automation executor.
|
2025-12-03 11:05:54 +01:00
|
|
|
|
2025-12-08 15:55:16 +01:00
|
|
|
Executes deterministic, scenario-based browser automation tasks.
|
|
|
|
|
Each scenario is a reusable workflow registered in the scenario registry.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import time
|
|
|
|
|
import uuid
|
|
|
|
|
from pathlib import Path
|
2025-12-03 11:05:54 +01:00
|
|
|
from typing import Any
|
|
|
|
|
|
2025-12-08 15:55:16 +01:00
|
|
|
from playwright.async_api import async_playwright, Route, Request
|
|
|
|
|
|
|
|
|
|
from app.config import get_settings
|
2025-12-03 11:05:54 +01:00
|
|
|
from app.executors.base import BaseExecutor, ExecutionResult
|
2025-12-08 15:55:16 +01:00
|
|
|
from app.playwright_scenarios import get_scenario, get_scenario_names, ScenarioOptions
|
|
|
|
|
from app.utils.validation import is_domain_allowed, validate_allowed_domains, ValidationError
|
2025-12-03 11:05:54 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class PlaywrightExecutor(BaseExecutor):
|
2025-12-08 15:55:16 +01:00
|
|
|
"""Browser automation executor using Playwright scenarios.
|
2025-12-03 11:05:54 +01:00
|
|
|
|
2025-12-08 15:55:16 +01:00
|
|
|
Executes pre-defined browser automation scenarios with strict security controls.
|
|
|
|
|
Each execution creates an isolated browser context with domain restrictions.
|
2025-12-03 11:05:54 +01:00
|
|
|
|
2025-12-08 15:55:16 +01:00
|
|
|
Payload:
|
2025-12-03 11:05:54 +01:00
|
|
|
{
|
2025-12-08 15:55:16 +01:00
|
|
|
"scenario": "nextcloud_initial_setup", # Required: registered scenario name
|
|
|
|
|
"inputs": { # Required: scenario-specific inputs
|
|
|
|
|
"base_url": "https://cloud.example.com",
|
|
|
|
|
"admin_username": "admin",
|
|
|
|
|
"admin_password": "secret123"
|
|
|
|
|
},
|
|
|
|
|
"options": { # Optional configuration
|
|
|
|
|
"timeout_ms": 60000, # Action timeout (default: 60000)
|
|
|
|
|
"screenshot_on_failure": true, # Screenshot on fail (default: true)
|
|
|
|
|
"screenshot_on_success": false, # Screenshot on success (default: false)
|
|
|
|
|
"save_trace": false, # Save trace file (default: false)
|
|
|
|
|
"allowed_domains": ["cloud.example.com"] # REQUIRED: domain allowlist
|
|
|
|
|
}
|
2025-12-03 11:05:54 +01:00
|
|
|
}
|
2025-12-08 15:55:16 +01:00
|
|
|
|
|
|
|
|
Security:
|
|
|
|
|
- allowed_domains is REQUIRED - blocks all requests to non-listed domains
|
|
|
|
|
- Browser runs in headless mode only (not configurable)
|
|
|
|
|
- Each execution gets an isolated browser context
|
|
|
|
|
- Artifacts are stored in per-task directories
|
2025-12-03 11:05:54 +01:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
def task_type(self) -> str:
|
|
|
|
|
return "PLAYWRIGHT"
|
|
|
|
|
|
|
|
|
|
async def execute(self, payload: dict[str, Any]) -> ExecutionResult:
|
2025-12-08 15:55:16 +01:00
|
|
|
"""Execute a Playwright scenario.
|
2025-12-03 11:05:54 +01:00
|
|
|
|
|
|
|
|
Args:
|
2025-12-08 15:55:16 +01:00
|
|
|
payload: Task payload with scenario, inputs, and options
|
2025-12-03 11:05:54 +01:00
|
|
|
|
|
|
|
|
Returns:
|
2025-12-08 15:55:16 +01:00
|
|
|
ExecutionResult with scenario output and artifact paths
|
2025-12-03 11:05:54 +01:00
|
|
|
"""
|
2025-12-08 15:55:16 +01:00
|
|
|
start_time = time.time()
|
|
|
|
|
settings = get_settings()
|
2025-12-03 11:05:54 +01:00
|
|
|
|
2025-12-08 15:55:16 +01:00
|
|
|
try:
|
|
|
|
|
# Validate required fields
|
|
|
|
|
self.validate_payload(payload, ["scenario", "inputs"])
|
|
|
|
|
|
|
|
|
|
scenario_name = payload["scenario"]
|
|
|
|
|
inputs = payload["inputs"]
|
|
|
|
|
options_dict = payload.get("options", {})
|
|
|
|
|
|
|
|
|
|
# Validate allowed_domains is present
|
|
|
|
|
allowed_domains = options_dict.get("allowed_domains")
|
|
|
|
|
if not allowed_domains:
|
|
|
|
|
return ExecutionResult(
|
|
|
|
|
success=False,
|
|
|
|
|
data={"scenario": scenario_name},
|
|
|
|
|
error="Security error: 'allowed_domains' is required in options",
|
|
|
|
|
duration_ms=(time.time() - start_time) * 1000,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Validate domain patterns
|
|
|
|
|
try:
|
|
|
|
|
allowed_domains = validate_allowed_domains(allowed_domains)
|
|
|
|
|
except ValidationError as e:
|
|
|
|
|
return ExecutionResult(
|
|
|
|
|
success=False,
|
|
|
|
|
data={"scenario": scenario_name},
|
|
|
|
|
error=f"Invalid allowed_domains: {e}",
|
|
|
|
|
duration_ms=(time.time() - start_time) * 1000,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Get scenario from registry
|
|
|
|
|
scenario = get_scenario(scenario_name)
|
|
|
|
|
if scenario is None:
|
|
|
|
|
available = get_scenario_names()
|
|
|
|
|
return ExecutionResult(
|
|
|
|
|
success=False,
|
|
|
|
|
data={
|
|
|
|
|
"scenario": scenario_name,
|
|
|
|
|
"available_scenarios": available,
|
|
|
|
|
},
|
|
|
|
|
error=f"Unknown scenario: '{scenario_name}'. Available: {available}",
|
|
|
|
|
duration_ms=(time.time() - start_time) * 1000,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Validate scenario inputs
|
|
|
|
|
missing_inputs = scenario.validate_inputs(inputs)
|
|
|
|
|
if missing_inputs:
|
|
|
|
|
return ExecutionResult(
|
|
|
|
|
success=False,
|
|
|
|
|
data={
|
|
|
|
|
"scenario": scenario_name,
|
|
|
|
|
"missing_inputs": missing_inputs,
|
|
|
|
|
"required_inputs": scenario.required_inputs,
|
|
|
|
|
},
|
|
|
|
|
error=f"Missing required inputs: {missing_inputs}",
|
|
|
|
|
duration_ms=(time.time() - start_time) * 1000,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Create artifacts directory for this execution
|
|
|
|
|
task_id = str(uuid.uuid4())[:8]
|
|
|
|
|
artifacts_dir = Path(settings.playwright_artifacts_dir) / f"task-{task_id}"
|
|
|
|
|
artifacts_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
# Build scenario options
|
|
|
|
|
scenario_options = ScenarioOptions(
|
|
|
|
|
timeout_ms=options_dict.get("timeout_ms", settings.playwright_default_timeout_ms),
|
|
|
|
|
screenshot_on_failure=options_dict.get("screenshot_on_failure", True),
|
|
|
|
|
screenshot_on_success=options_dict.get("screenshot_on_success", False),
|
|
|
|
|
save_trace=options_dict.get("save_trace", False),
|
|
|
|
|
allowed_domains=allowed_domains,
|
|
|
|
|
artifacts_dir=artifacts_dir,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
self.logger.info(
|
|
|
|
|
"playwright_scenario_starting",
|
|
|
|
|
scenario=scenario_name,
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
allowed_domains=allowed_domains,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Execute scenario with browser
|
|
|
|
|
result = await self._run_scenario(
|
|
|
|
|
scenario=scenario,
|
|
|
|
|
inputs=inputs,
|
|
|
|
|
options=scenario_options,
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
duration_ms = (time.time() - start_time) * 1000
|
|
|
|
|
|
|
|
|
|
self.logger.info(
|
|
|
|
|
"playwright_scenario_completed",
|
|
|
|
|
scenario=scenario_name,
|
|
|
|
|
success=result.success,
|
|
|
|
|
duration_ms=duration_ms,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return ExecutionResult(
|
|
|
|
|
success=result.success,
|
|
|
|
|
data={
|
|
|
|
|
"scenario": scenario_name,
|
|
|
|
|
"result": result.data,
|
|
|
|
|
"screenshots": result.screenshots,
|
|
|
|
|
"artifacts_dir": str(artifacts_dir),
|
|
|
|
|
"trace_path": result.trace_path,
|
|
|
|
|
},
|
|
|
|
|
error=result.error,
|
|
|
|
|
duration_ms=duration_ms,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
except ValueError as e:
|
|
|
|
|
# Validation errors
|
|
|
|
|
return ExecutionResult(
|
|
|
|
|
success=False,
|
|
|
|
|
data={},
|
|
|
|
|
error=str(e),
|
|
|
|
|
duration_ms=(time.time() - start_time) * 1000,
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
self.logger.error(
|
|
|
|
|
"playwright_executor_error",
|
|
|
|
|
error=str(e),
|
|
|
|
|
error_type=type(e).__name__,
|
|
|
|
|
)
|
|
|
|
|
return ExecutionResult(
|
|
|
|
|
success=False,
|
|
|
|
|
data={},
|
|
|
|
|
error=f"Playwright executor error: {e}",
|
|
|
|
|
duration_ms=(time.time() - start_time) * 1000,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def _run_scenario(
|
|
|
|
|
self,
|
|
|
|
|
scenario,
|
|
|
|
|
inputs: dict[str, Any],
|
|
|
|
|
options: ScenarioOptions,
|
|
|
|
|
task_id: str,
|
|
|
|
|
):
|
|
|
|
|
"""Run a scenario with browser and domain restrictions.
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
scenario: The scenario instance to execute
|
|
|
|
|
inputs: Scenario inputs
|
|
|
|
|
options: Scenario options
|
|
|
|
|
task_id: Task identifier for logging
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
ScenarioResult from the scenario execution
|
|
|
|
|
"""
|
|
|
|
|
from app.playwright_scenarios import ScenarioResult
|
|
|
|
|
|
|
|
|
|
settings = get_settings()
|
|
|
|
|
blocked_requests: list[str] = []
|
|
|
|
|
|
|
|
|
|
async def route_handler(route: Route, request: Request) -> None:
|
|
|
|
|
"""Block requests to non-allowed domains."""
|
|
|
|
|
url = request.url
|
|
|
|
|
|
|
|
|
|
if is_domain_allowed(url, options.allowed_domains):
|
|
|
|
|
await route.continue_()
|
|
|
|
|
else:
|
|
|
|
|
blocked_requests.append(url)
|
|
|
|
|
self.logger.warning(
|
|
|
|
|
"playwright_blocked_request",
|
|
|
|
|
url=url,
|
|
|
|
|
task_id=task_id,
|
|
|
|
|
)
|
|
|
|
|
await route.abort("blockedbyclient")
|
|
|
|
|
|
|
|
|
|
async with async_playwright() as p:
|
|
|
|
|
# Launch browser in headless mode (always)
|
|
|
|
|
browser = await p.chromium.launch(
|
|
|
|
|
headless=True,
|
|
|
|
|
args=[
|
|
|
|
|
"--no-sandbox",
|
|
|
|
|
"--disable-setuid-sandbox",
|
|
|
|
|
"--disable-dev-shm-usage",
|
|
|
|
|
"--disable-gpu",
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Create isolated context
|
|
|
|
|
context = await browser.new_context(
|
|
|
|
|
viewport={"width": 1280, "height": 720},
|
|
|
|
|
user_agent="LetsBe-SysAdmin-Agent/1.0 Playwright",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Set default timeouts
|
|
|
|
|
context.set_default_timeout(options.timeout_ms)
|
|
|
|
|
context.set_default_navigation_timeout(
|
|
|
|
|
settings.playwright_navigation_timeout_ms
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Start tracing if enabled
|
|
|
|
|
if options.save_trace and options.artifacts_dir:
|
|
|
|
|
await context.tracing.start(
|
|
|
|
|
screenshots=True,
|
|
|
|
|
snapshots=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Apply domain restrictions via route interception
|
|
|
|
|
await context.route("**/*", route_handler)
|
|
|
|
|
|
|
|
|
|
# Create page
|
|
|
|
|
page = await context.new_page()
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Run scenario setup hook
|
|
|
|
|
await scenario.setup(page, options)
|
|
|
|
|
|
|
|
|
|
# Execute the scenario
|
|
|
|
|
result = await scenario.execute(page, inputs, options)
|
|
|
|
|
|
|
|
|
|
# Take success screenshot if enabled
|
|
|
|
|
if options.screenshot_on_success and options.artifacts_dir:
|
|
|
|
|
screenshot_path = options.artifacts_dir / "success.png"
|
|
|
|
|
await page.screenshot(path=str(screenshot_path))
|
|
|
|
|
result.screenshots.append(str(screenshot_path))
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
# Capture failure screenshot
|
|
|
|
|
screenshots = []
|
|
|
|
|
if options.screenshot_on_failure and options.artifacts_dir:
|
|
|
|
|
try:
|
|
|
|
|
screenshot_path = options.artifacts_dir / "failure.png"
|
|
|
|
|
await page.screenshot(path=str(screenshot_path))
|
|
|
|
|
screenshots.append(str(screenshot_path))
|
|
|
|
|
except Exception as screenshot_error:
|
|
|
|
|
self.logger.warning(
|
|
|
|
|
"playwright_screenshot_failed",
|
|
|
|
|
error=str(screenshot_error),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = ScenarioResult(
|
|
|
|
|
success=False,
|
|
|
|
|
data={"blocked_requests": blocked_requests},
|
|
|
|
|
screenshots=screenshots,
|
|
|
|
|
error=str(e),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
finally:
|
|
|
|
|
# Run scenario teardown hook
|
|
|
|
|
try:
|
|
|
|
|
await scenario.teardown(page, options)
|
|
|
|
|
except Exception as teardown_error:
|
|
|
|
|
self.logger.warning(
|
|
|
|
|
"playwright_teardown_error",
|
|
|
|
|
error=str(teardown_error),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Stop tracing and save
|
|
|
|
|
if options.save_trace and options.artifacts_dir:
|
|
|
|
|
trace_path = options.artifacts_dir / "trace.zip"
|
|
|
|
|
await context.tracing.stop(path=str(trace_path))
|
|
|
|
|
result.trace_path = str(trace_path)
|
|
|
|
|
|
|
|
|
|
# Add blocked requests info
|
|
|
|
|
if blocked_requests:
|
|
|
|
|
result.data["blocked_requests"] = blocked_requests
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
finally:
|
|
|
|
|
await browser.close()
|