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)