Skip to main content

static_site_tests: add page testing with Playwright

ID
e616996
date
2026-02-28 09:31:12+00:00
author
Alex Chan <alex@alexwlchan.net>
parent
562176a
message
static_site_tests: add page testing with Playwright
changed files
7 files, 170 additions, 40 deletions

Changed files

.github/workflows/test.yml (2239) → .github/workflows/test.yml (2304)

diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 7715238..38dcf0d 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -71,6 +71,9 @@ jobs:
         chmod +x /usr/local/bin/ffprobe
         which ffprobe
 
+    - name: Install WebKit
+      run: playwright install webkit
+
     - name: Check formatting
       run: |
         ruff check .

CHANGELOG.md (2746) → CHANGELOG.md (3022)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 86dea42..86d5ed8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # CHANGELOG
 
+## v24 - 2026-02-28
+
+In `StaticSiteTestSuite`, add testing with [Playwright](https://playwright.dev) that checks static websites render correctly.
+
+**Breaking change:** the site root must now be specified as a classmethod `get_site_root()` rather than a `site_root` fixture.
+
 ## v23 - 2026-02-20
 
 Change the format of `VideoEntity.subtitles` to be a list of `SubtitlesEntity`, so a single video can have multiple subtitles.

dev_requirements.txt (2646) → dev_requirements.txt (2477)

diff --git a/dev_requirements.txt b/dev_requirements.txt
index 1e3e896..2683edd 100644
--- a/dev_requirements.txt
+++ b/dev_requirements.txt
@@ -2,23 +2,23 @@
 #    uv pip compile dev_requirements.in --output-file dev_requirements.txt
 -e file:.
     # via -r dev_requirements.in
-annotated-types==0.7.0
-    # via pydantic
-anyio==4.12.0
+anyio==4.12.1
     # via httpx
-build==1.3.0
+build==1.4.0
     # via -r dev_requirements.in
-certifi==2025.11.12
+certifi==2026.2.25
     # via
     #   httpcore
     #   httpx
     #   requests
 charset-normalizer==3.4.4
     # via requests
-coverage==7.12.0
+coverage==7.13.4
     # via pytest-cov
-docutils==0.22.3
+docutils==0.22.4
     # via readme-renderer
+greenlet==3.3.2
+    # via playwright
 h11==0.16.0
     # via httpcore
 httpcore==1.0.9
@@ -27,7 +27,7 @@ httpx==0.28.1
     # via alexwlchan-chives
 hyperlink==21.0.0
     # via alexwlchan-chives
-id==1.5.0
+id==1.6.1
     # via twine
 idna==3.11
     # via
@@ -39,15 +39,13 @@ iniconfig==2.3.0
     # via pytest
 jaraco-classes==3.4.0
     # via keyring
-jaraco-context==6.0.1
+jaraco-context==6.1.0
     # via keyring
-jaraco-functools==4.3.0
+jaraco-functools==4.4.0
     # via keyring
-javascript-data-files==1.4.1
-    # via alexwlchan-chives
 keyring==25.7.0
     # via twine
-librt==0.7.3
+librt==0.8.1
     # via mypy
 markdown-it-py==4.0.0
     # via rich
@@ -57,29 +55,29 @@ more-itertools==10.8.0
     # via
     #   jaraco-classes
     #   jaraco-functools
-mypy==1.19.0
+mypy==1.19.1
     # via -r dev_requirements.in
 mypy-extensions==1.1.0
     # via mypy
-nh3==0.3.2
+nh3==0.3.3
     # via readme-renderer
-packaging==25.0
+packaging==26.0
     # via
     #   build
     #   pytest
     #   twine
-pathspec==0.12.1
+pathspec==1.0.4
     # via mypy
-pillow==12.0.0
+pillow==12.1.1
+    # via alexwlchan-chives
+playwright==1.58.0
     # via alexwlchan-chives
 pluggy==1.6.0
     # via
     #   pytest
     #   pytest-cov
-pydantic==2.12.5
-    # via javascript-data-files
-pydantic-core==2.41.5
-    # via pydantic
+pyee==13.0.1
+    # via playwright
 pygments==2.19.2
     # via
     #   pytest
@@ -105,16 +103,15 @@ readme-renderer==44.0
     # via twine
 requests==2.32.5
     # via
-    #   id
     #   requests-toolbelt
     #   twine
 requests-toolbelt==1.0.0
     # via twine
 rfc3986==2.0.0
     # via twine
-rich==14.2.0
+rich==14.3.3
     # via twine
-ruff==0.14.8
+ruff==0.15.4
     # via -r dev_requirements.in
 silver-nitrate==1.8.1
     # via -r dev_requirements.in
@@ -123,16 +120,13 @@ twine==6.2.0
 typing-extensions==4.15.0
     # via
     #   mypy
-    #   pydantic
-    #   pydantic-core
-    #   typing-inspection
-typing-inspection==0.4.2
-    # via pydantic
-urllib3==2.6.0
+    #   pyee
+urllib3==2.6.3
     # via
+    #   id
     #   requests
     #   twine
-vcrpy==8.0.0
+vcrpy==8.1.1
     # via pytest-vcr
-wrapt==2.0.1
+wrapt==2.1.1
     # via vcrpy

pyproject.toml (1336) → pyproject.toml (1350)

diff --git a/pyproject.toml b/pyproject.toml
index 0b2df40..45ef880 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,7 +25,7 @@ license = "MIT"
 
 [project.optional-dependencies]
 media = ["Pillow"]
-static_site_tests = ["pytest", "rapidfuzz"]
+static_site_tests = ["playwright", "pytest", "rapidfuzz"]
 urls = ["httpx", "hyperlink"]
 
 [project.urls]

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

diff --git a/src/chives/__init__.py b/src/chives/__init__.py
index 88f2769..914e546 100644
--- a/src/chives/__init__.py
+++ b/src/chives/__init__.py
@@ -11,4 +11,4 @@ I share across multiple sites.
 
 """
 
-__version__ = "23"
+__version__ = "24"

src/chives/static_site_tests.py (7443) → src/chives/static_site_tests.py (9906)

diff --git a/src/chives/static_site_tests.py b/src/chives/static_site_tests.py
index 5373f33..b5aca15 100644
--- a/src/chives/static_site_tests.py
+++ b/src/chives/static_site_tests.py
@@ -13,7 +13,9 @@ 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 rapidfuzz import fuzz
 
 from chives.dates import date_matches_any_format, find_all_dates
@@ -24,6 +26,15 @@ 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):
     """
     Defines a base set of tests to run against any of my static sites.
@@ -32,9 +43,9 @@ class StaticSiteTestSuite[M](ABC):
     the fixtures and write site-specific tests.
     """
 
+    @classmethod
     @abstractmethod
-    @pytest.fixture
-    def site_root(self) -> Path:
+    def get_site_root(cls) -> Path:
         """
         Returns the path to the folder at the root of the site.
         """
@@ -55,6 +66,13 @@ class StaticSiteTestSuite[M](ABC):
         """
         ...
 
+    @pytest.fixture
+    def site_root(self) -> Path:
+        """
+        Returns the path to the folder at the root of the site.
+        """
+        return self.get_site_root()
+
     def list_tags_in_metadata(self, metadata: M) -> Iterator[str]:  # pragma: no cover
         """
         Returns all the tags used in the metadata, once for every usage.
@@ -67,6 +85,7 @@ class StaticSiteTestSuite[M](ABC):
         """
         yield from []
 
+    @pytest.mark.skipif("SKIP_GIT" in os.environ, reason="skip Git checks")
     def test_no_uncommitted_git_changes(self, site_root: Path) -> None:
         """
         There are no changes which haven't been committed to Git.
@@ -236,3 +255,58 @@ class StaticSiteTestSuite[M](ABC):
         ]
 
         assert similar_tags == [], f"Found similar tags: {similar_tags}"
+
+    pages_to_check: set[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
+
+    @pytest.fixture(scope="session")
+    def browser(self) -> 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()
+
+    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
+        if not full_path.exists():
+            raise FileNotFoundError(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(f"file://{full_path}")
+
+        # 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 == []

tests/test_static_site_tests.py (8753) → tests/test_static_site_tests.py (10439)

diff --git a/tests/test_static_site_tests.py b/tests/test_static_site_tests.py
index 3a9c8f2..1a15550 100644
--- a/tests/test_static_site_tests.py
+++ b/tests/test_static_site_tests.py
@@ -55,12 +55,12 @@ def create_pyfile(
         
         import pytest
         
-        from chives.static_site_tests import StaticSiteTestSuite
+        from chives.static_site_tests import StaticSiteTestSuite, pytest_generate_tests
         
         
         class TestSuite(StaticSiteTestSuite[Any]):
-            @pytest.fixture
-            def site_root(self) -> Path:
+            @classmethod
+            def get_site_root(self) -> Path:
                 return Path({str(site_root or pytester.path)!r})
 
             @pytest.fixture
@@ -274,3 +274,56 @@ def test_checks_for_similar_tags(pytester: Pytester) -> None:
         known_similar_tags={("red robot", "rod robot")},
     )
     pytester.runpytest("-k", keyword).assert_outcomes(passed=1)
+
+
+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)