Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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`.

---

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions qa_agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
60 changes: 60 additions & 0 deletions qa_agent/playwright_utils.py
Original file line number Diff line number Diff line change
@@ -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 <url>",
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)
9 changes: 6 additions & 3 deletions tests/_cli_exit_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
21 changes: 20 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
Expand Down
152 changes: 152 additions & 0 deletions tests/test_playwright_utils.py
Original file line number Diff line number Diff line change
@@ -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
Loading