Add a set of standard tests for static sites
- ID
fd73ef1- date
2025-12-06 13:26:26+00:00- author
Alex Chan <alex@alexwlchan.net>- parent
3bd0cd7- message
Add a set of standard tests for static sites- changed files
Changed files
.github/workflows/test.yml (2240) → .github/workflows/test.yml (2228)
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 51ec186..f663f05 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -15,7 +15,7 @@ jobs:
strategy:
matrix:
python-version: ["3.13", "3.14"]
-
+
steps:
- uses: actions/checkout@v6
@@ -51,7 +51,7 @@ jobs:
tar -xzf dominant_colours.tar.gz --directory /usr/local/bin
chmod +x /usr/local/bin/dominant_colours
which dominant_colours
-
+
- name: Install get_live_text
env:
GH_TOKEN: ${{ github.token }}
@@ -63,7 +63,7 @@ jobs:
tar -xzf get_live_text.tar.gz --directory /usr/local/bin
chmod +x /usr/local/bin/get_live_text
which get_live_text
-
+
- name: Install ffprobe
run: |
curl -O https://evermeet.cx/ffmpeg/ffprobe-8.0.1.7z
CHANGELOG.md (1221) → CHANGELOG.md (1415)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c4f077d..eb88e56 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
# CHANGELOG
+## v11 - 2025-12-06
+
+Add a new class `StaticSiteTestSuite` which runs my standard set of tests for a static site, e.g. checking every file is saved, checking timestamps use the correct format.
+
## v10 - 2025-12-05
Add a new `is_url_safe()` function for checking if a path can be safely used in a URL.
dev_requirements.in (82) → dev_requirements.in (100)
diff --git a/dev_requirements.in b/dev_requirements.in
index d43b3e6..730f3d7 100644
--- a/dev_requirements.in
+++ b/dev_requirements.in
@@ -1,4 +1,4 @@
--e file:.[media,urls]
+-e file:.[media,static_site_tests,urls]
build
mypy
dev_requirements.txt (2268) → dev_requirements.txt (2600)
diff --git a/dev_requirements.txt b/dev_requirements.txt
index 38923ca..03365bb 100644
--- a/dev_requirements.txt
+++ b/dev_requirements.txt
@@ -2,6 +2,8 @@
# 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
# via httpx
build==1.3.0
@@ -41,9 +43,11 @@ jaraco-context==6.0.1
# via keyring
jaraco-functools==4.3.0
# via keyring
+javascript-data-files==1.4.1
+ # via alexwlchan-chives
keyring==25.7.0
# via twine
-librt==0.6.3
+librt==0.7.0
# via mypy
markdown-it-py==4.0.0
# via rich
@@ -72,6 +76,10 @@ pluggy==1.6.0
# via
# pytest
# pytest-cov
+pydantic==2.12.5
+ # via javascript-data-files
+pydantic-core==2.41.5
+ # via pydantic
pygments==2.19.2
# via
# pytest
@@ -81,6 +89,7 @@ pyproject-hooks==1.2.0
# via build
pytest==9.0.1
# via
+ # alexwlchan-chives
# pytest-cov
# pytest-vcr
# silver-nitrate
@@ -110,8 +119,14 @@ silver-nitrate==1.8.1
twine==6.2.0
# via -r dev_requirements.in
typing-extensions==4.15.0
- # via mypy
-urllib3==2.5.0
+ # via
+ # mypy
+ # pydantic
+ # pydantic-core
+ # typing-inspection
+typing-inspection==0.4.2
+ # via pydantic
+urllib3==2.6.0
# via
# requests
# twine
pyproject.toml (1282) → pyproject.toml (1345)
diff --git a/pyproject.toml b/pyproject.toml
index fd19dd2..5387fa3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -25,6 +25,7 @@ license = "MIT"
[project.optional-dependencies]
media = ["Pillow"]
+static_site_tests = ["javascript-data-files[typed]", "pytest"]
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 4a7fae3..5654aa7 100644
--- a/src/chives/__init__.py
+++ b/src/chives/__init__.py
@@ -11,4 +11,4 @@ I share across multiple sites.
"""
-__version__ = "10"
+__version__ = "11"
src/chives/static_site_tests.py (0) → src/chives/static_site_tests.py (5255)
diff --git a/src/chives/static_site_tests.py b/src/chives/static_site_tests.py
new file mode 100644
index 0000000..92d9bf9
--- /dev/null
+++ b/src/chives/static_site_tests.py
@@ -0,0 +1,177 @@
+"""
+Defines a set of common tests and test helpers used for all my static sites.
+"""
+
+from abc import ABC, abstractmethod
+import glob
+import os
+from pathlib import Path
+import subprocess
+from typing import cast, TypedDict, TypeVar
+
+from javascript_data_files import read_typed_js
+import pytest
+
+from chives.dates import date_matches_any_format, find_all_dates
+from chives.media import is_av1_video
+from chives.urls import is_url_safe
+
+
+T = TypeVar("T")
+
+
+class StaticSiteTestSuite[M](ABC):
+ """
+ Defines a base set of tests to run against any of my static sites.
+
+ This should be subclassed as a Test* class, which allows you to use
+ the fixtures and write site-specific tests.
+ """
+
+ @abstractmethod
+ @pytest.fixture
+ def site_root(self) -> Path:
+ """
+ Returns the path to the folder at the root of the site.
+ """
+ ...
+
+ @abstractmethod
+ @pytest.fixture
+ def metadata(self, site_root: Path) -> M:
+ """
+ Returns all the metadata for this project.
+ """
+ ...
+
+ @abstractmethod
+ def list_paths_in_metadata(self, metadata: M) -> set[Path]:
+ """
+ Returns a set of paths described in the metadata.
+ """
+ ...
+
+ def test_no_uncommitted_git_changes(self, site_root: Path) -> None:
+ """
+ There are no changes which haven't been committed to Git.
+
+ This is especially useful when I run a script that tests all
+ my static sites, that none of them have unsaved changes.
+ """
+ rc = subprocess.call(["git", "diff", "--exit-code", "--quiet"], cwd=site_root)
+
+ assert rc == 0, "There are uncommitted changes!"
+
+ def list_paths_saved_locally(self, site_root: Path) -> set[Path]:
+ """
+ Returns a set of paths saved locally.
+ """
+ paths_saved_locally = set()
+
+ for root, _, filenames in site_root.walk():
+ # Ignore certain top-level folders I don't care about.
+ try:
+ top_level_folder = root.relative_to(site_root).parts[0]
+ except IndexError:
+ pass
+ else:
+ if top_level_folder in {
+ ".git",
+ ".pytest_cache",
+ ".ruff_cache",
+ ".venv",
+ "scripts",
+ "static",
+ "tests",
+ "viewer",
+ }:
+ continue
+
+ for f in filenames:
+ if f == ".DS_Store":
+ continue
+
+ if root == site_root and f in {"Icon\r", ".gitignore", "index.html"}:
+ continue
+
+ if root == site_root and f.endswith(".js"):
+ continue
+
+ paths_saved_locally.add((root / f).relative_to(site_root))
+
+ return paths_saved_locally
+
+ def test_every_file_in_metadata_is_saved_locally(
+ self, metadata: M, site_root: Path
+ ) -> None:
+ """
+ Every file described in the metadata is saved locally.
+ """
+ paths_in_metadata = self.list_paths_in_metadata(metadata)
+ paths_saved_locally = self.list_paths_saved_locally(site_root)
+
+ assert paths_in_metadata - paths_saved_locally == set()
+
+ def test_every_local_file_is_in_metadata(
+ self, metadata: M, site_root: Path
+ ) -> None:
+ """
+ Every file saved locally is described in the metadata.
+ """
+ paths_in_metadata = self.list_paths_in_metadata(metadata)
+ paths_saved_locally = self.list_paths_saved_locally(site_root)
+
+ assert paths_saved_locally - paths_in_metadata == set()
+
+ def test_every_path_is_url_safe(self, site_root: Path) -> None:
+ """
+ Every path has a URL-safe path.
+ """
+ bad_paths = set()
+
+ for root, _, filenames in site_root.walk():
+ for f in filenames:
+ p = site_root / root / f
+ if not is_url_safe(p):
+ bad_paths.add(p)
+
+ assert bad_paths == set()
+
+ @pytest.mark.skipif("SKIP_AV1" in os.environ, reason="skip slow test")
+ def test_no_videos_are_av1(self, site_root: Path) -> None:
+ """
+ No videos are encoded in AV1 (which doesn't play on my iPhone).
+
+ This test can be removed when I upgrade all my devices to ones with
+ hardware AV1 decoding support.
+
+ See https://alexwlchan.net/2025/av1-on-my-iphone/
+ """
+ av1_videos = set()
+
+ av1_videos = {
+ p
+ for p in glob.glob("**/*.mp4", root_dir=site_root, recursive=True)
+ if is_av1_video(site_root / p)
+ }
+
+ assert av1_videos == set()
+
+ date_formats = [
+ "%Y-%m-%dT%H:%M:%SZ",
+ "%Y-%m-%d",
+ ]
+
+ def test_all_timestamps_are_consistent(self, metadata: M) -> None:
+ """
+ All the timestamps in my JSON use a consistent format.
+
+ See https://alexwlchan.net/2025/messy-dates-in-json/
+ """
+ bad_date_strings = {
+ date_string
+ for _, _, date_string in find_all_dates(metadata)
+ if not date_matches_any_format(date_string, self.date_formats)
+ }
+
+ assert bad_date_strings == set()
src/chives/urls.py (3470) → src/chives/urls.py (3523)
diff --git a/src/chives/urls.py b/src/chives/urls.py
index 93589d1..04bba03 100644
--- a/src/chives/urls.py
+++ b/src/chives/urls.py
@@ -4,9 +4,6 @@ from pathlib import Path
import re
from typing import TypedDict
-import httpx
-import hyperlink
-
__all__ = [
"clean_youtube_url",
@@ -22,6 +19,8 @@ def clean_youtube_url(url: str) -> str:
Remove any query parameters from a YouTube URL that I don't
want to include.
"""
+ import hyperlink
+
u = hyperlink.parse(url)
u = u.remove("list")
@@ -58,6 +57,8 @@ def is_mastodon_host(hostname: str) -> bool:
# ]
# }
#
+ import httpx
+
nodeinfo_resp = httpx.get(f"https://{hostname}/.well-known/nodeinfo")
try:
nodeinfo_resp.raise_for_status()
@@ -94,6 +95,8 @@ def parse_mastodon_post_url(url: str) -> tuple[str, str, str]:
Parse a Mastodon post URL into its component parts:
server, account, post ID.
"""
+ import hyperlink
+
u = hyperlink.parse(url)
if len(u.path) != 2:
@@ -120,6 +123,8 @@ def parse_tumblr_post_url(url: str) -> tuple[str, str]:
Returns a tuple (blog_identifier, post ID).
"""
+ import hyperlink
+
u = hyperlink.parse(url)
if u.host == "www.tumblr.com":
tests/test_static_site_tests.py (0) → tests/test_static_site_tests.py (5808)
diff --git a/tests/test_static_site_tests.py b/tests/test_static_site_tests.py
new file mode 100644
index 0000000..9a70c38
--- /dev/null
+++ b/tests/test_static_site_tests.py
@@ -0,0 +1,184 @@
+"""
+Tests for `chives.static_site_tests`.
+"""
+
+from pathlib import Path
+import shutil
+import subprocess
+from typing import TypeVar
+
+import pytest
+
+from chives.static_site_tests import StaticSiteTestSuite
+
+
+M = TypeVar("M")
+
+
+@pytest.fixture
+def site_root(tmp_path: Path) -> Path:
+ """
+ Return a temp directory to use as a site root.
+ """
+ return tmp_path
+
+
+def create_test_suite[M](
+ site_root: Path, metadata: M, paths_in_metadata: set[Path]
+) -> StaticSiteTestSuite[M]:
+ """
+ Create a new instance of StaticSiteTestSuite with the hard-coded data
+ provided.
+ """
+
+ class TestSuite(StaticSiteTestSuite[M]):
+ def site_root(self) -> Path: # pragma: no cover
+ return site_root
+
+ def metadata(self, site_root: Path) -> M: # pragma: no cover
+ return metadata
+
+ def list_paths_in_metadata(self, metadata: M) -> set[Path]:
+ return paths_in_metadata
+
+ return TestSuite()
+
+
+def test_paths_saved_locally_match_metadata(site_root: Path) -> None:
+ """
+ The tests check that the set of paths saved locally match the metadata.
+ """
+ # Create a series of paths in tmp_path.
+ for filename in [
+ "index.html",
+ "metadata.js",
+ "media/cat.jpg",
+ "media/dog.png",
+ "media/emu.gif",
+ "viewer/index.html",
+ ".DS_Store",
+ ]:
+ p = site_root / filename
+ p.parent.mkdir(exist_ok=True)
+ p.write_text("test")
+
+ metadata = [Path("media/cat.jpg"), Path("media/dog.png"), Path("media/emu.gif")]
+
+ t = create_test_suite(site_root, metadata, paths_in_metadata=set(metadata))
+ t.test_every_file_in_metadata_is_saved_locally(metadata, site_root)
+ t.test_every_local_file_is_in_metadata(metadata, site_root)
+
+ # Add a new file locally, and check the test starts failing.
+ (site_root / "media/fish.tiff").write_text("test")
+
+ with pytest.raises(AssertionError):
+ t.test_every_local_file_is_in_metadata(metadata, site_root)
+
+ (site_root / "media/fish.tiff").unlink()
+
+ # Delete one of the local files, and check the test starts failing.
+ (site_root / "media/cat.jpg").unlink()
+
+ with pytest.raises(AssertionError):
+ t.test_every_file_in_metadata_is_saved_locally(metadata, site_root)
+
+
+def test_checks_for_git_changes(site_root: Path) -> None:
+ """
+ The tests check that there are no uncommitted Git changes.
+ """
+ t = create_test_suite(site_root, metadata=[1, 2, 3], paths_in_metadata=set())
+
+ # Initially this should fail, because there isn't a Git repo in
+ # the folder.
+ with pytest.raises(AssertionError):
+ t.test_no_uncommitted_git_changes(site_root)
+
+ # Create a Git repo, add a file, and commit it.
+ (site_root / "README.md").write_text("hello world")
+ subprocess.check_call(["git", "init"], cwd=site_root)
+ subprocess.check_call(["git", "add", "README.md"], cwd=site_root)
+ subprocess.check_call(["git", "commit", "-m", "initial commit"], cwd=site_root)
+
+ # Check there are no uncommitted Git changes
+ t.test_no_uncommitted_git_changes(site_root)
+
+ # Make a new change, and check it's spotted
+ (site_root / "README.md").write_text("a different hello world")
+
+ with pytest.raises(AssertionError):
+ t.test_no_uncommitted_git_changes(site_root)
+
+
+def test_checks_for_url_safe_paths(site_root: Path) -> None:
+ """
+ The tests check for URL-safe paths.
+ """
+ t = create_test_suite(site_root, metadata=[1, 2, 3], paths_in_metadata=set())
+
+ # This should pass trivially when the site is empty.
+ t.test_every_path_is_url_safe(site_root)
+
+ # Now write some files with URL-safe names, and check it's still okay.
+ for filename in [
+ "index.html",
+ "metadata.js",
+ ".DS_Store",
+ ]:
+ (site_root / filename).write_text("test")
+
+ t.test_every_path_is_url_safe(site_root)
+
+ # Write another file with a URL-unsafe name, and check it's caught
+ # by the test.
+ (site_root / "a#b#c").write_text("test")
+
+ with pytest.raises(AssertionError):
+ t.test_every_path_is_url_safe(site_root)
+
+
+def test_checks_for_av1_videos(site_root: Path) -> None:
+ """
+ The tests check for AV1-encoded videos.
+ """
+ t = create_test_suite(site_root, metadata=[1, 2, 3], paths_in_metadata=set())
+
+ # This should pass trivially when the site is empty.
+ t.test_no_videos_are_av1(site_root)
+
+ # Copy in an H.264-encoded video, and check it's not flagged.
+ shutil.copyfile(
+ "tests/fixtures/media/Sintel_360_10s_1MB_H264.mp4",
+ site_root / "Sintel_360_10s_1MB_H264.mp4",
+ )
+ t.test_no_videos_are_av1(site_root)
+
+ # Copy in an AV1-encoded video, and check it's caught by the test
+ shutil.copyfile(
+ "tests/fixtures/media/Sintel_360_10s_1MB_AV1.mp4",
+ site_root / "Sintel_360_10s_1MB_AV1.mp4",
+ )
+ with pytest.raises(AssertionError):
+ t.test_no_videos_are_av1(site_root)
+
+
+def test_checks_for_date_formats(site_root: Path) -> None:
+ """
+ The tests check for inconsistent date formats.
+ """
+ # Check a site with correct metadata
+ metadata1 = {"date_saved": "2025-12-06"}
+ t1 = create_test_suite(site_root, metadata1, paths_in_metadata=set())
+ t1.test_all_timestamps_are_consistent(metadata1)
+
+ # Check a site with incorrect metadata
+ metadata2 = {"date_saved": "AAAA-BB-CC"}
+ t2 = create_test_suite(site_root, metadata2, paths_in_metadata=set())
+ with pytest.raises(AssertionError):
+ t2.test_all_timestamps_are_consistent(metadata2)
+
+ # Check we can override the timestamp format
+ metadata3 = {"date_saved": "AAAA-BB-CC"}
+ t3 = create_test_suite(site_root, metadata=metadata3, paths_in_metadata=set())
+ t3.date_formats.append("AAAA-BB-CC")
+ t3.test_all_timestamps_are_consistent(metadata3)