Skip to main content

static_sites: remove automatic Playwright testing; move to browser_fixtures

ID
4be521a
date
2026-05-31 08:48:29+00:00
author
Alex Chan <alex@alexwlchan.net>
parent
45a0d2d
message
static_sites: remove automatic Playwright testing; move to browser_fixtures
changed files
11 files, 257 additions, 138 deletions

Changed files

CHANGELOG.md (4969) → CHANGELOG.md (5156)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e3405c..0486945 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # CHANGELOG
 
+## v42 - 2026-06-01
+
+Remove Playwright testing from `StaticSiteTestSuite`, and instead provide `chives.browser_fixtures` to allow individual static sites to build their own test suites.
+
 ## v41 - 2026-05-17
 
 In `urls.clean_youtube_url()`, remove the `app=desktop` URL query parameter.

pyproject.toml (1474) → pyproject.toml (1512)

diff --git a/pyproject.toml b/pyproject.toml
index 042f2bf..8f2a956 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -18,20 +18,21 @@ classifiers = [
   "Development Status :: 4 - Beta",
   "Programming Language :: Python :: 3.13",
 ]
-requires-python = ">=3.13"
+requires-python = ">=3.14"
 dependencies = []
 dynamic = ["version"]
 license = "MIT"
 
 [project.optional-dependencies]
+browser_fixtures = ["playwright"]
 fetch = ["certifi"]
 media = ["Pillow"]
-static_site_tests = ["playwright", "pytest"]
+static_site_tests = ["pytest"]
 urls = ["certifi"]
 
 [project.urls]
-"Homepage" = "https://github.com/alexwlchan/chives"
-"Changelog" = "https://github.com/alexwlchan/chives/blob/main/CHANGELOG.md"
+"Homepage" = "https://alexwlchan.net/projects/chives/"
+"Changelog" = "https://alexwlchan.net/projects/chives/releases/"
 
 [tool.setuptools.dynamic]
 version = {attr = "chives.__version__"}
@@ -41,7 +42,8 @@ where = ["src"]
 
 [tool.coverage.run]
 branch = true
-source = ["chives", "tests",]
+source = ["chives", "tests"]
+concurrency = ["greenlet"]
 
 [tool.coverage.report]
 show_missing = true

scripts/run_chives_tests.sh (655) → scripts/run_chives_tests.sh (662)

diff --git a/scripts/run_chives_tests.sh b/scripts/run_chives_tests.sh
index 592d818..b4cfc0b 100755
--- a/scripts/run_chives_tests.sh
+++ b/scripts/run_chives_tests.sh
@@ -17,7 +17,7 @@ report_coverage() {
     then
         echo "100% coverage!"
     else
-        python3 -m coverage
+        python3 -m coverage report
     fi
 }
 

src/chives/__init__.py (391) → src/chives/__init__.py (391)

diff --git a/src/chives/__init__.py b/src/chives/__init__.py
index 17ac617..3789c36 100644
--- a/src/chives/__init__.py
+++ b/src/chives/__init__.py
@@ -11,4 +11,4 @@ I share across multiple sites.
 
 """
 
-__version__ = "41"
+__version__ = "42"

src/chives/browser_fixtures.py (0) → src/chives/browser_fixtures.py (1614)

diff --git a/src/chives/browser_fixtures.py b/src/chives/browser_fixtures.py
new file mode 100644
index 0000000..71854b3
--- /dev/null
+++ b/src/chives/browser_fixtures.py
@@ -0,0 +1,65 @@
+"""
+Provide a set of Playwright fixtures to use when testing static sites.
+
+See https://alexwlchan.net/2026/playwright/
+"""
+
+from collections.abc import Iterator
+import os
+from pathlib import Path
+from urllib.request import pathname2url
+
+import pytest
+from playwright.sync_api import Browser, Page, sync_playwright
+
+
+def file_uri(p: Path | str) -> str:
+    """
+    Convert a path to a file:/// URI.
+    """
+    p = Path(p)
+    absolute_path = str(p.absolute())
+    return pathname2url(absolute_path, add_scheme=True)
+
+
+@pytest.fixture
+def browser() -> Iterator[Browser]:
+    """
+    Launch an instance of WebKit we can interact with in tests.
+    """
+    if "SKIP_PLAYWRIGHT" in os.environ:
+        pytest.skip(reason="skip slow Playwright tests")
+
+    with sync_playwright() as p:
+        browser = p.webkit.launch()
+        yield browser
+        browser.close()
+
+
+@pytest.fixture
+def page(browser: Browser) -> Iterator[Page]:
+    """
+    Open a new browser page which checks for errors and warnings.
+    """
+    p = browser.new_page()
+
+    # Capture anything that gets logged to the console.
+    console_messages = []
+    p.on("console", lambda msg: console_messages.append(msg))
+
+    # Capture any page errors
+    page_errors = []
+    p.on("pageerror", lambda err: page_errors.append(err))
+
+    yield p
+
+    # Check there weren't any console errors logged to the page.
+    console_errors = [
+        msg.text
+        for msg in console_messages
+        if msg.type == "error" or msg.type == "warning"
+    ]
+    assert console_errors == []
+
+    # Check there weren't any page errors
+    assert page_errors == []

src/chives/static_site_tests.py (9819) → src/chives/static_site_tests.py (7719)

diff --git a/src/chives/static_site_tests.py b/src/chives/static_site_tests.py
index 97b5f75..75f8196 100644
--- a/src/chives/static_site_tests.py
+++ b/src/chives/static_site_tests.py
@@ -14,9 +14,7 @@ from pathlib import Path
 import subprocess
 from typing import TypeVar
 
-from playwright.sync_api import Browser, sync_playwright
 import pytest
-from _pytest.mark.structures import ParameterSet
 
 from chives.dates import date_matches_any_format, find_all_dates
 from chives.media import is_av1_video
@@ -26,15 +24,6 @@ from chives.urls import is_url_safe
 T = TypeVar("T")
 
 
-def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
-    """
-    Generate parameter sets for tests which can be parametrised and run
-    in parallel.
-    """
-    if metafunc.function.__name__ == "test_loads_page_correctly":
-        metafunc.parametrize("url", metafunc.cls.pages_to_check)
-
-
 class StaticSiteTestSuite[M](ABC):
     """
     Define a base set of tests to run against any of my static sites.
@@ -255,58 +244,3 @@ class StaticSiteTestSuite[M](ABC):
         ]
 
         assert similar_tags == [], f"Found similar tags: {similar_tags}"
-
-    pages_to_check: list[str | ParameterSet] = [
-        pytest.param("index.html", id="homepage")
-    ]
-
-    # Coverage note: these two functions are tested, but coverage can't
-    # find them. Because there's no tricky branching, don't worry about
-    # checking if these lines are covered.
-    #
-    # See https://github.com/microsoft/playwright-python/issues/313
-
-    def test_loads_page_correctly(
-        self, site_root: Path, url: str, browser: Browser
-    ) -> None:  # pragma: no cover
-        """
-        The page opens in the browser with no errors.
-
-        The parameters for this test are populated by `pytest_generate_tests`.
-        """
-        full_path = site_root.absolute() / url
-        file_uri = f"file://{full_path}"
-
-        p = browser.new_page()
-
-        # Capture anything that gets logged to the console.
-        console_messages = []
-        p.on("console", lambda msg: console_messages.append(msg))
-
-        # Capture any page errors
-        page_errors = []
-        p.on("pageerror", lambda err: page_errors.append(err))
-
-        p.goto(file_uri)
-
-        # Check there weren't any console errors logged to the page.
-        console_errors = [
-            msg.text
-            for msg in console_messages
-            if msg.type == "error" or msg.type == "warning"
-        ]
-        assert console_errors == []
-
-        # Check there weren't any page errors
-        assert page_errors == []
-
-
-@pytest.fixture
-def browser() -> Iterator[Browser]:  # pragma: no cover
-    """
-    Launch an instance of WebKit we can interact with in tests.
-    """
-    with sync_playwright() as p:
-        browser = p.webkit.launch()
-        yield browser
-        browser.close()

src/chives/urls.py (4632) → src/chives/urls.py (4628)

diff --git a/src/chives/urls.py b/src/chives/urls.py
index 001baf8..9c4413c 100644
--- a/src/chives/urls.py
+++ b/src/chives/urls.py
@@ -98,7 +98,7 @@ def is_mastodon_host(hostname: str) -> bool:
     #
     try:
         link_href = nodeinfo["links"][0]["href"]
-    except (KeyError, IndexError):  # pragma: no cover
+    except KeyError, IndexError:  # pragma: no cover
         return False
 
     link_resp = urllib.request.urlopen(link_href, context=ssl_context)
@@ -107,7 +107,7 @@ def is_mastodon_host(hostname: str) -> bool:
 
     try:
         return bool(link_info["software"]["name"] == "mastodon")
-    except (KeyError, IndexError):  # pragma: no cover
+    except KeyError, IndexError:  # pragma: no cover
         return False
 
 

tests/conftest.py (165) → tests/conftest.py (234)

diff --git a/tests/conftest.py b/tests/conftest.py
index 993aeab..2734d38 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,7 +1,8 @@
 """Shared helpers and test fixtures."""
 
 from cassettes import cassette_name, vcr_cassette
+from chives.browser_fixtures import browser, page
 
 pytest_plugins = "pytester"
 
-__all__ = ["cassette_name", "vcr_cassette"]
+__all__ = ["browser", "cassette_name", "page", "vcr_cassette"]

tests/fixtures/html/greeting.html (0) → tests/fixtures/html/greeting.html (19)

diff --git a/tests/fixtures/html/greeting.html b/tests/fixtures/html/greeting.html
new file mode 100644
index 0000000..aa994ea
--- /dev/null
+++ b/tests/fixtures/html/greeting.html
@@ -0,0 +1 @@
+<p>Hello world!</p>
\ No newline at end of file

tests/test_browser_fixtures.py (0) → tests/test_browser_fixtures.py (5258)

diff --git a/tests/test_browser_fixtures.py b/tests/test_browser_fixtures.py
new file mode 100644
index 0000000..3384b9a
--- /dev/null
+++ b/tests/test_browser_fixtures.py
@@ -0,0 +1,173 @@
+"""
+Tests for `chives.browser_fixtures`.
+"""
+
+import os
+from pathlib import Path
+import subprocess
+
+from playwright.sync_api import sync_playwright
+import pytest
+from pytest import Pytester
+
+
+git_root_cmd = ["git", "rev-parse", "--show-toplevel"]
+GIT_ROOT = subprocess.check_output(git_root_cmd, text=True).strip()
+
+
+@pytest.fixture(scope="session")
+def playwright_browsers_path() -> str:
+    """
+    Return the cache directory where Playwright browsers are installed.
+    """
+    with sync_playwright() as p:
+        # In my local builds, this returns a path like:
+        #
+        #    ~/Library/Caches/ms-playwright/webkit-2272/pw_run.sh
+        #
+        # Unwrap two levels to get to the `ms-playwright` folder.
+        return str(Path(p.webkit.executable_path).parent.parent)
+
+
+@pytest.fixture
+def playwright_pytester(pytester: Pytester, playwright_browsers_path: str) -> Pytester:
+    """
+    Return a `Pytester` instance for which tests have access to the
+    `browser` and `page` fixtures, and which use the shared Playwright cache.
+    """
+    if "SKIP_PLAYWRIGHT" in os.environ:  # pragma: no cover
+        pytest.skip(reason="skip slow Playwright tests")
+
+    pytester.makeconftest(f"""
+        import os
+        
+        from chives.browser_fixtures import browser, page
+        
+        os.environ["PLAYWRIGHT_BROWSERS_PATH"] = {playwright_browsers_path!r}
+    """)
+
+    return pytester
+
+
+class TestBrowserFixture:
+    """
+    Tests for the `browser` fixture.
+    """
+
+    def test_browser_fixture(self, playwright_pytester: Pytester) -> None:
+        """
+        Test the browser fixture.
+        """
+        playwright_pytester.makefile(".html", greeting="<p>Hello world!</p>")
+        playwright_pytester.makepyfile(
+            """
+            from playwright.sync_api import Browser, expect
+            from chives.browser_fixtures import file_uri
+
+        
+            def test_browser(browser: Browser) -> None:
+                uri = file_uri("greeting.html")
+
+                p = browser.new_page()
+                p.goto(uri)
+                expect(p.get_by_text("Hello world!")).to_be_visible()
+            """
+        )
+        playwright_pytester.runpytest().assert_outcomes(passed=1)
+
+
+class TestPageFixture:
+    """
+    Tests for the `page` fixture.
+    """
+
+    def test_okay_page(self, playwright_pytester: Pytester) -> None:
+        """
+        Open a page and make a successful assertion about the contents.
+        """
+        playwright_pytester.makefile(".html", greeting="<p>Hello world!</p>")
+        playwright_pytester.makepyfile(
+            """
+            from playwright.sync_api import Page, expect
+            from chives.browser_fixtures import file_uri
+
+        
+            def test_page(page: Page) -> None:
+                uri = file_uri("greeting.html")
+                page.goto(uri)
+                expect(page.get_by_text("Hello world!")).to_be_visible()
+            """
+        )
+        playwright_pytester.runpytest().assert_outcomes(passed=1)
+
+    @pytest.mark.parametrize(
+        "html",
+        [
+            pytest.param("<script>invalid</script>", id="invalid_js"),
+            pytest.param("<script>console.warn('BOOM')</script>", id="console_warning"),
+            pytest.param("<script>console.warn('BOOM')</script>", id="console_error"),
+            pytest.param("<img src='doesnotexist.jpg'>", id="missing_image"),
+        ],
+    )
+    def test_page_with_error(self, html: str, playwright_pytester: Pytester) -> None:
+        """
+        Open a page with errors/warnings, and check the fixture reports
+        an error.
+        """
+        playwright_pytester.makefile(".html", error=html)
+        playwright_pytester.makepyfile(
+            """
+            from playwright.sync_api import Page
+            from chives.browser_fixtures import file_uri
+
+        
+            def test_page(page: Page) -> None:
+                uri = file_uri("error.html")
+                page.goto(uri)
+            """
+        )
+        playwright_pytester.runpytest().assert_outcomes(passed=1, errors=1)
+
+    def test_non_existent_page_is_error(self, playwright_pytester: Pytester) -> None:
+        """
+        Opening a non-existent file with the `page` fixture fails the test.
+        """
+        playwright_pytester.makepyfile(
+            """
+            from playwright.sync_api import Page
+            from chives.browser_fixtures import file_uri
+
+        
+            def test_page(page: Page) -> None:
+                uri = file_uri("does_not_exist.html")
+                page.goto(uri)
+            """
+        )
+        playwright_pytester.runpytest().assert_outcomes(failed=1)
+
+
+def test_skip_playwright(pytester: Pytester) -> None:
+    """
+    If the SKIP_PLAYWRIGHT environment variable is set, the test is skipped.
+    """
+    pytester.makeconftest("""
+        import os
+    
+        from chives.browser_fixtures import browser, page
+    
+        os.environ["SKIP_PLAYWRIGHT"] = "true"
+    """)
+    pytester.makepyfile(
+        """
+        from playwright.sync_api import Browser, Page
+
+    
+        def test_browser(browser: Browser) -> None:
+            pass
+
+
+        def test_page(page: Page) -> None:
+            pass
+        """
+    )
+    pytester.runpytest().assert_outcomes(skipped=2)

tests/test_static_site_tests.py (10676) → tests/test_static_site_tests.py (8827)

diff --git a/tests/test_static_site_tests.py b/tests/test_static_site_tests.py
index a1b9cd8..b3f01f7 100644
--- a/tests/test_static_site_tests.py
+++ b/tests/test_static_site_tests.py
@@ -2,7 +2,6 @@
 Tests for `chives.static_site_tests`.
 """
 
-import os
 from pathlib import Path
 import shutil
 import subprocess
@@ -56,11 +55,7 @@ def create_pyfile(
 
         import pytest
 
-        from chives.static_site_tests import (
-            StaticSiteTestSuite,
-            browser,
-            pytest_generate_tests,
-        )
+        from chives.static_site_tests import StaticSiteTestSuite
 
 
         class TestSuite(StaticSiteTestSuite[Any]):
@@ -288,59 +283,3 @@ def test_checks_for_similar_tags(
         known_similar_tags=known_similar_tags,
     )
     pytester.runpytest("-k", keyword).assert_outcomes(**expected_outcome)
-
-
-@pytest.mark.skipif(
-    "SKIP_PLAYWRIGHT" in os.environ, reason="skip slow Playwright tests"
-)
-class TestLoadsPageCorrectly:
-    """
-    Tests for `test_loads_page_correctly`.
-    """
-
-    def test_okay(self, pytester: Pytester, site_root: Path) -> None:
-        """
-        If the page contains valid HTML, the test passes.
-        """
-        keyword = "test_loads_page_correctly"
-
-        subprocess.check_call(["playwright", "install", "webkit"], cwd=pytester.path)
-
-        (site_root / "index.html").write_text("<p>Hello world!</p>")
-
-        create_pyfile(pytester, site_root)
-        pytester.runpytest("-k", keyword).assert_outcomes(passed=1)
-
-    def test_noexist(self, pytester: Pytester) -> None:
-        """
-        If the page doesn't exist, the test fails.
-        """
-        keyword = "test_loads_page_correctly"
-
-        subprocess.check_call(["playwright", "install", "webkit"], cwd=pytester.path)
-
-        create_pyfile(pytester)
-        pytester.runpytest("-k", keyword).assert_outcomes(failed=1)
-
-    @pytest.mark.parametrize(
-        "html",
-        [
-            # Invalid JavaScript
-            "<script>???</script>",
-            #
-            # Valid JavaScript that logs an error.
-            "<script>console.error('boom!')</script>",
-        ],
-    )
-    def test_bad_js(self, pytester: Pytester, site_root: Path, html: str) -> None:
-        """
-        If the page loads but the JavaScript errors, the test fails.
-        """
-        keyword = "test_loads_page_correctly"
-
-        subprocess.check_call(["playwright", "install", "webkit"], cwd=pytester.path)
-
-        (site_root / "index.html").write_text(html)
-
-        create_pyfile(pytester, site_root)
-        pytester.runpytest("-k", keyword).assert_outcomes(failed=1)