diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..5275ddef --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,48 @@ +name: Playwright + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + browser: [chromium, firefox, webkit] + steps: + - uses: actions/checkout@v6 + - name: Set up Python 3.14 + uses: actions/setup-python@v6 + with: + python-version: "3.14" + allow-prereleases: true + cache: pip + cache-dependency-path: pyproject.toml + - name: Cache uv + uses: actions/cache@v5 + with: + path: ~/.cache/uv + key: ${{ runner.os }}-py3.14-uv-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-py3.14-uv- + - name: Cache Playwright browsers + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright/ + key: ${{ runner.os }}-playwright-${{ matrix.browser }}-${{ hashFiles('pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-playwright-${{ matrix.browser }}- + - name: Install uv + run: python -m pip install uv + - name: Install dependencies + run: uv sync --group dev --group playwright + - name: Install ${{ matrix.browser }} + run: uv run --group dev --group playwright playwright install --with-deps ${{ matrix.browser }} + - name: Run Playwright tests + run: uv run --group dev --group playwright pytest tests/test_playwright.py --playwright --browser ${{ matrix.browser }} diff --git a/pyproject.toml b/pyproject.toml index 0d136d60..a19dc957 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,6 +81,9 @@ dev = [ "ruamel.yaml", "psutil>=5.9", ] +playwright = [ + "pytest-playwright>=0.8.0", +] [project.optional-dependencies] rich = ["rich"] diff --git a/pytest.ini b/pytest.ini index 29b84ea5..75de6925 100644 --- a/pytest.ini +++ b/pytest.ini @@ -6,4 +6,5 @@ filterwarnings= ignore:Using or importing the ABCs::bs4.element markers = serial: tests to avoid using with pytest-xdist -asyncio_mode = strict \ No newline at end of file + playwright: browser automation tests, skipped unless --playwright is passed +asyncio_mode = strict diff --git a/tests/conftest.py b/tests/conftest.py index 27d6fa77..04b6f8be 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -96,6 +96,15 @@ def pytest_report_header(config): return "SQLite: {}".format(version) +def pytest_addoption(parser): + parser.addoption( + "--playwright", + action="store_true", + default=False, + help="run Playwright browser automation tests", + ) + + def pytest_configure(config): import sys @@ -108,7 +117,13 @@ def pytest_unconfigure(config): del sys._called_from_test -def pytest_collection_modifyitems(items): +def pytest_collection_modifyitems(config, items): + if not config.getoption("--playwright"): + skip_playwright = pytest.mark.skip(reason="need --playwright option to run") + for item in items: + if "playwright" in item.keywords: + item.add_marker(skip_playwright) + # Ensure test_cli.py and test_black.py and test_inspect.py run first before any asyncio code kicks in move_to_front(items, "test_cli") move_to_front(items, "test_black") diff --git a/tests/test_playwright.py b/tests/test_playwright.py new file mode 100644 index 00000000..abe992bc --- /dev/null +++ b/tests/test_playwright.py @@ -0,0 +1,79 @@ +import socket +import subprocess +import sys +import time + +import httpx +import pytest + +from datasette.fixtures import write_fixture_database + + +def find_free_port(): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def wait_for_server(process, url, timeout=10): + deadline = time.monotonic() + timeout + last_error = None + while time.monotonic() < deadline: + if process.poll() is not None: + stdout, stderr = process.communicate() + raise AssertionError( + "Datasette server exited early\n" + f"stdout:\n{stdout}\n" + f"stderr:\n{stderr}" + ) + try: + response = httpx.get(url, timeout=1.0) + if response.status_code < 500: + return + last_error = f"HTTP {response.status_code}: {response.text[:200]}" + except httpx.HTTPError as ex: + last_error = repr(ex) + time.sleep(0.1) + raise AssertionError(f"Timed out waiting for {url}: {last_error}") + + +@pytest.fixture +def datasette_server(tmp_path): + db_path = tmp_path / "fixtures.db" + write_fixture_database(str(db_path)) + port = find_free_port() + process = subprocess.Popen( + [ + sys.executable, + "-m", + "datasette", + str(db_path), + "--host", + "127.0.0.1", + "--port", + str(port), + "--setting", + "num_sql_threads", + "1", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + url = f"http://127.0.0.1:{port}/" + try: + wait_for_server(process, url) + yield url + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +@pytest.mark.playwright +def test_datasette_homepage_contains_datasette(page, datasette_server): + page.goto(datasette_server) + assert "Datasette" in page.locator("body").inner_text()