diff --git a/README.md b/README.md index 9cae965..d3ac785 100644 --- a/README.md +++ b/README.md @@ -64,10 +64,9 @@ Need targeted tests? Pass plain-English instructions and an LLM generates custom ```bash pip install qa-agent # standard testing (Playwright only) -playwright install chromium # required — downloads browser binaries ``` -Optional extras: +Chromium is installed automatically on first run. Optional extras: ```bash pip install "qa-agent[pdf]" # PDF reports (adds WeasyPrint) @@ -82,7 +81,7 @@ export ANTHROPIC_API_KEY=sk-ant-... # Anthropic (default) export OPENAI_API_KEY=sk-... # OpenAI ``` -> `playwright install chromium` must run once after every fresh install. See [Troubleshooting](#troubleshooting) if anything goes wrong. +> Chromium is installed automatically on the first run. If you need to reinstall it manually, run `playwright install chromium`. --- @@ -382,7 +381,6 @@ Six built-in suites cover keyboard navigation, mouse interaction, form handling, ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} # or OPENAI_API_KEY run: | pip install qa-agent - playwright install chromium qa-agent --output json --output-dir ./qa-results https://staging.example.com - name: Upload Results @@ -413,11 +411,13 @@ Exits with code `1` when critical or high severity issues are found, failing the ### Playwright browser not found +Chromium is installed automatically on first run. If reinstalling is needed: + ```bash playwright install chromium ``` -Must run once after every fresh install. Easy to forget in CI. +If automatic installation fails, run the above command manually. ### Web UI not working diff --git a/qa_agent/cli.py b/qa_agent/cli.py index a6e1224..2e4af4f 100644 --- a/qa_agent/cli.py +++ b/qa_agent/cli.py @@ -16,6 +16,7 @@ TestMode, ) from .llm_client import LLMProvider +from .playwright_utils import ensure_chromium_installed def parse_auth_config(auth_str: str | None, auth_file: str | None) -> AuthConfig | None: @@ -390,6 +391,9 @@ def main(): invocation_context="cli", ) + # Verify Chromium is available before launching the browser + ensure_chromium_installed() + # Run the agent agent = QAAgent(config) diff --git a/qa_agent/playwright_utils.py b/qa_agent/playwright_utils.py new file mode 100644 index 0000000..9393f67 --- /dev/null +++ b/qa_agent/playwright_utils.py @@ -0,0 +1,60 @@ +"""Utilities for Playwright browser management.""" + +import subprocess +import sys + +from playwright.sync_api import sync_playwright + + +def ensure_chromium_installed() -> None: + """Ensure Chromium browser is installed. Install it if missing. + + This function verifies that Chromium browser binaries are actually + installed by attempting to launch the browser. If the browser is + not found, it automatically runs 'playwright install chromium'. + + Raises: + SystemExit: With code 2 if installation fails. + """ + try: + # Actually verify chromium is installed by trying to launch it + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + browser.close() + # If we got here, chromium is installed + return + except Exception as e: + # Check if this is a "browser not found" error + err_str = str(e).lower() + if "executable" not in err_str and "not found" not in err_str: + # This is some other error, re-raise it + raise + + # Browser not found, attempt to install + print("Installing Chromium browser (this may take a minute)...", file=sys.stderr) + try: + subprocess.run( + [sys.executable, "-m", "playwright", "install", "chromium"], + check=True, + capture_output=True, + text=True, + ) + print("✓ Chromium installed successfully", file=sys.stderr) + except subprocess.CalledProcessError as e: + err_msg = e.stderr.lower() if e.stderr else "" + if "permission" in err_msg or "denied" in err_msg: + print( + "Permission denied installing Chromium. Try:\n" + " playwright install chromium\n" + "Or set PLAYWRIGHT_BROWSERS_PATH to a writable directory:\n" + " export PLAYWRIGHT_BROWSERS_PATH=/tmp/pw-browsers\n" + " qa-agent ", + file=sys.stderr, + ) + else: + print( + f"Failed to install Chromium:\n{e.stderr or 'Unknown error'}\n\n" + "Try manually: playwright install chromium", + file=sys.stderr, + ) + sys.exit(2) diff --git a/tests/_cli_exit_helper.py b/tests/_cli_exit_helper.py index 4883602..6e18a59 100644 --- a/tests/_cli_exit_helper.py +++ b/tests/_cli_exit_helper.py @@ -41,17 +41,20 @@ if scenario in _FINDINGS: session = MagicMock() session.findings_by_severity = _FINDINGS[scenario] - with patch("qa_agent.cli.QAAgent.__init__", return_value=None), \ + with patch("qa_agent.cli.ensure_chromium_installed"), \ + patch("qa_agent.cli.QAAgent.__init__", return_value=None), \ patch("qa_agent.cli.QAAgent.run", return_value=session): _cli_main() elif scenario == "runtime_error": - with patch("qa_agent.cli.QAAgent.__init__", return_value=None), \ + with patch("qa_agent.cli.ensure_chromium_installed"), \ + patch("qa_agent.cli.QAAgent.__init__", return_value=None), \ patch("qa_agent.cli.QAAgent.run", side_effect=RuntimeError("boom")): _cli_main() elif scenario == "keyboard_interrupt": - with patch("qa_agent.cli.QAAgent.__init__", return_value=None), \ + with patch("qa_agent.cli.ensure_chromium_installed"), \ + patch("qa_agent.cli.QAAgent.__init__", return_value=None), \ patch("qa_agent.cli.QAAgent.run", side_effect=KeyboardInterrupt): _cli_main() diff --git a/tests/conftest.py b/tests/conftest.py index 32406c9..e0a9e34 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,7 +4,7 @@ from contextlib import contextmanager from datetime import datetime -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -136,6 +136,25 @@ def factory(): # pytest fixtures # --------------------------------------------------------------------------- +@pytest.fixture(autouse=True) +def _no_chromium_install(request): + """Prevent ensure_chromium_installed from touching real browsers in unit tests. + + Patches the function at every import site (cli, web) so tests that exercise + main() or serve_web_cli() don't attempt to download or launch Chromium. + test_playwright_utils.py tests the function directly and is unaffected because + it imports from qa_agent.playwright_utils, not from these call-site namespaces. + """ + if "test_playwright_utils" in request.fspath.basename: + yield + return + # Patch both the source and the cli call site so importlib.reload() in web + # tests still picks up the mock rather than the real function. + with patch("qa_agent.playwright_utils.ensure_chromium_installed"), \ + patch("qa_agent.cli.ensure_chromium_installed"): + yield + + @pytest.fixture def mock_page(): return _make_mock_page() diff --git a/tests/test_playwright_utils.py b/tests/test_playwright_utils.py new file mode 100644 index 0000000..eda9a65 --- /dev/null +++ b/tests/test_playwright_utils.py @@ -0,0 +1,152 @@ +"""Tests for qa_agent/playwright_utils.py — chromium installation logic.""" + +from __future__ import annotations + +import subprocess +import sys +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from qa_agent.playwright_utils import ensure_chromium_installed + + +class TestEnsureChromiumInstalled: + """Tests for automatic Chromium installation.""" + + def test_chromium_already_installed(self): + """When chromium is installed, should not attempt to install.""" + mock_browser = MagicMock() + + with patch("qa_agent.playwright_utils.sync_playwright") as mock_pw: + mock_p = MagicMock() + mock_p.chromium.launch.return_value = mock_browser + mock_pw.return_value.__enter__.return_value = mock_p + + # Should not raise or print anything + ensure_chromium_installed() + + # Should have tried to launch chromium + mock_p.chromium.launch.assert_called_once_with(headless=True) + # Should have closed the browser + mock_browser.close.assert_called_once() + + def test_chromium_missing_install_succeeds(self, capsys): + """When chromium is missing, should install successfully.""" + # First call raises "executable not found", second call succeeds + mock_browser = MagicMock() + call_count = {"count": 0} + + def launch_side_effect(*args, **kwargs): + call_count["count"] += 1 + if call_count["count"] == 1: + raise Exception("Executable doesn't exist at /path/to/chromium") + return mock_browser + + with patch("qa_agent.playwright_utils.sync_playwright") as mock_pw, \ + patch("qa_agent.playwright_utils.subprocess.run") as mock_run: + + mock_p = MagicMock() + mock_p.chromium.launch.side_effect = launch_side_effect + mock_pw.return_value.__enter__.return_value = mock_p + mock_run.return_value = Mock(returncode=0) + + ensure_chromium_installed() + + # Should have called subprocess to install + mock_run.assert_called_once_with( + [sys.executable, "-m", "playwright", "install", "chromium"], + check=True, + capture_output=True, + text=True, + ) + + # Should show installation message + captured = capsys.readouterr() + assert "Installing Chromium" in captured.err + assert "successfully" in captured.err + + def test_chromium_missing_permission_denied(self, capsys): + """When installation fails due to permissions, should show helpful message.""" + with patch("qa_agent.playwright_utils.sync_playwright") as mock_pw, \ + patch("qa_agent.playwright_utils.subprocess.run") as mock_run: + + mock_p = MagicMock() + mock_p.chromium.launch.side_effect = Exception("Executable not found") + mock_pw.return_value.__enter__.return_value = mock_p + + # Simulate permission error + error = subprocess.CalledProcessError(1, "playwright") + error.stderr = "Permission denied writing to /usr/local/bin" + mock_run.side_effect = error + + with pytest.raises(SystemExit) as exc: + ensure_chromium_installed() + + assert exc.value.code == 2 + + captured = capsys.readouterr() + assert "Permission denied" in captured.err + assert "PLAYWRIGHT_BROWSERS_PATH" in captured.err + + def test_chromium_missing_install_fails_other_error(self, capsys): + """When installation fails for other reasons, should show error.""" + with patch("qa_agent.playwright_utils.sync_playwright") as mock_pw, \ + patch("qa_agent.playwright_utils.subprocess.run") as mock_run: + + mock_p = MagicMock() + mock_p.chromium.launch.side_effect = Exception("Browser executable not found") + mock_pw.return_value.__enter__.return_value = mock_p + + # Simulate generic error + error = subprocess.CalledProcessError(1, "playwright") + error.stderr = "Network timeout downloading chromium" + mock_run.side_effect = error + + with pytest.raises(SystemExit) as exc: + ensure_chromium_installed() + + assert exc.value.code == 2 + + captured = capsys.readouterr() + assert "Failed to install Chromium" in captured.err + assert "Network timeout" in captured.err + + def test_other_exceptions_are_reraised(self): + """Exceptions that aren't 'browser not found' should be re-raised.""" + with patch("qa_agent.playwright_utils.sync_playwright") as mock_pw: + mock_p = MagicMock() + # Simulate an unrelated error (no "executable" or "not found" in message) + mock_p.chromium.launch.side_effect = RuntimeError("Network error") + mock_pw.return_value.__enter__.return_value = mock_p + + with pytest.raises(RuntimeError, match="Network error"): + ensure_chromium_installed() + + def test_import_error_triggers_installation(self, capsys): + """When playwright itself has issues, should attempt installation.""" + with patch("qa_agent.playwright_utils.sync_playwright") as mock_pw, \ + patch("qa_agent.playwright_utils.subprocess.run") as mock_run: + + # First call raises import-related error, second succeeds + mock_browser = MagicMock() + call_count = {"count": 0} + + def context_side_effect(): + call_count["count"] += 1 + if call_count["count"] == 1: + raise ImportError("Browser executable not found") + + mock_p = MagicMock() + mock_p.chromium.launch.return_value = mock_browser + return mock_p + + mock_pw.return_value.__enter__.side_effect = context_side_effect + mock_run.return_value = Mock(returncode=0) + + ensure_chromium_installed() + + # Should have attempted installation + mock_run.assert_called_once() + captured = capsys.readouterr() + assert "Installing Chromium" in captured.err