diff --git a/src/webwright/run/cli.py b/src/webwright/run/cli.py index cc27632..343ab70 100644 --- a/src/webwright/run/cli.py +++ b/src/webwright/run/cli.py @@ -12,6 +12,7 @@ from webwright.environments import get_environment from webwright.models import get_model from webwright.utils.serialize import UNSET, recursive_merge +from webwright.run.doctor import run_doctor DEFAULT_CONFIGS = ["base.yaml", "model_openai.yaml"] @@ -125,7 +126,9 @@ def run_one( result["_output_dir"] = str(resolved_output_dir) if close_exception is not None: result["_close_exception"] = str(close_exception) - console.print(result.get("final_response") or result.get("submission") or "Task finished.") + console.print( + result.get("final_response") or result.get("submission") or "Task finished." + ) if run_exception is not None: raise run_exception return result @@ -133,12 +136,22 @@ def run_one( @app.command() def main( - task: str = typer.Option(..., "-t", "--task", help="Natural language task description."), - task_id: str | None = typer.Option(None, "--task-id", help="Optional identifier used in the output directory name."), - start_url: str | None = typer.Option(None, "--start-url", help="Optional starting URL for the task."), + task: str = typer.Option( + ..., "-t", "--task", help="Natural language task description." + ), + task_id: str | None = typer.Option( + None, "--task-id", help="Optional identifier used in the output directory name." + ), + start_url: str | None = typer.Option( + None, "--start-url", help="Optional starting URL for the task." + ), config_spec: list[str] = typer.Option(DEFAULT_CONFIGS, "-c", "--config"), output_dir: Path | None = typer.Option(None, "-o", "--output-dir"), - debug: bool = typer.Option(False, "--debug", help="Launch headed local Playwright with devtools and keep it open for inspection."), + debug: bool = typer.Option( + False, + "--debug", + help="Launch headed local Playwright with devtools and keep it open for inspection.", + ), ) -> Any: return run_one( task=task, @@ -150,5 +163,13 @@ def main( ) +@app.command() +def doctor(): + """ + Validate local Webwright setup. + """ + run_doctor() + + if __name__ == "__main__": app() diff --git a/src/webwright/run/doctor.py b/src/webwright/run/doctor.py new file mode 100644 index 0000000..d64d5ed --- /dev/null +++ b/src/webwright/run/doctor.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from importlib.util import find_spec +from pathlib import Path +from rich.console import Console +from rich.table import Table + +console = Console() + + +def check_python(): + version = sys.version_info + + if version >= (3, 10): + return True, f"Python {version.major}.{version.minor}" + + return False, ("Python 3.10+ required\nFix: install Python 3.10 or newer") + + +def check_playwright(): + if find_spec("playwright") is not None: + return True, "playwright installed" + + return False, ("playwright not installed\nFix: pip install playwright") + + +def check_chromium(): + try: + result = subprocess.run( + ["playwright", "install", "--dry-run"], + capture_output=True, + text=True, + ) + + if result.returncode == 0: + return True, "chromium available" + + return False, ("chromium missing\nFix: playwright install chromium") + + except Exception as e: + return False, str(e) + + +def check_screenshot(): + try: + from playwright.sync_api import sync_playwright + + screenshot_path = Path("doctor_test.png") + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + + page = browser.new_page() + + page.set_content("

Webwright Doctor

") + + page.screenshot(path=str(screenshot_path)) + + browser.close() + + if screenshot_path.exists(): + screenshot_path.unlink(missing_ok=True) + + return True, "screenshot capture working" + + return False, "screenshot file was not created" + + except Exception: + return False, ( + "unable to launch Chromium for screenshot validation\n" + "Fix: playwright install" + ) + + +def check_openai_key(): + if os.getenv("OPENAI_API_KEY"): + return True, "OPENAI_API_KEY found" + + return False, ( + "OPENAI_API_KEY missing\nFix: set the OPENAI_API_KEY environment variable" + ) + + +def check_plugin_manifests(): + claude = Path(".claude-plugin/plugin.json") + codex = Path(".codex-plugin/plugin.json") + + missing = [] + + if not claude.exists(): + missing.append("Claude") + + if not codex.exists(): + missing.append("Codex") + + if not missing: + return True, "plugin manifests found" + + return False, ( + f"missing plugin manifests: {', '.join(missing)}\n" + "Fix: configure Claude/Codex plugins" + ) + + +CHECKS = [ + ("Python", check_python), + ("Playwright", check_playwright), + ("Chromium", check_chromium), + ("Screenshot", check_screenshot), + ("OpenAI Key", check_openai_key), + ("Plugins", check_plugin_manifests), +] + + +def run_doctor(): + table = Table(title="Webwright Doctor") + + table.add_column("Check") + table.add_column("Status") + table.add_column("Details") + + passed = 0 + + for name, fn in CHECKS: + ok, message = fn() + + status = "PASS" if ok else "FAIL" + + table.add_row( + name, + status, + message, + ) + + if ok: + passed += 1 + + console.print(table) + + console.print(f"\n{passed}/{len(CHECKS)} checks passed") + + +if __name__ == "__main__": + run_doctor() diff --git a/tests/unit/test_doctor.py b/tests/unit/test_doctor.py new file mode 100644 index 0000000..af2cdd1 --- /dev/null +++ b/tests/unit/test_doctor.py @@ -0,0 +1,94 @@ +from pathlib import Path + +from webwright.run.doctor import ( + check_chromium, + check_openai_key, + check_playwright, + check_plugin_manifests, + check_python, + check_screenshot, +) + + +def test_check_python(): + ok, message = check_python() + + assert isinstance(ok, bool) + assert isinstance(message, str) + + +def test_check_playwright(): + ok, message = check_playwright() + + assert isinstance(ok, bool) + assert isinstance(message, str) + + +def test_check_chromium(): + ok, message = check_chromium() + + assert isinstance(ok, bool) + assert isinstance(message, str) + + +def test_check_screenshot(): + ok, message = check_screenshot() + + assert isinstance(ok, bool) + assert isinstance(message, str) + + +def test_check_openai_key_exists(monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "test-key") + + ok, message = check_openai_key() + + assert ok is True + assert "found" in message + + +def test_check_openai_key_missing(monkeypatch): + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + ok, message = check_openai_key() + + assert ok is False + assert "missing" in message + + +def test_plugin_manifests_exist(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + claude_dir = tmp_path / ".claude-plugin" + codex_dir = tmp_path / ".codex-plugin" + + claude_dir.mkdir() + codex_dir.mkdir() + + (claude_dir / "plugin.json").write_text("{}") + (codex_dir / "plugin.json").write_text("{}") + + ok, message = check_plugin_manifests() + + assert ok is True + assert "found" in message + + +def test_plugin_manifests_missing(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + + ok, message = check_plugin_manifests() + + assert ok is False + assert "missing" in message + + +def test_screenshot_file_cleanup(): + screenshot_path = Path("doctor_test.png") + + if screenshot_path.exists(): + screenshot_path.unlink() + + check_screenshot() + + assert not screenshot_path.exists()