Merge pull request #15 from alexwlchan/playwright
- ID
d507b13- date
2026-02-28 10:06:21+00:00- author
Alex Chan <alex@alexwlchan.net>- parents
562176a,e616996- message
Merge pull request #15 from alexwlchan/playwright static_site_tests: add page testing with Playwright- changed files
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)