Skip to main content

Merge pull request #11 from alexwlchan/static-site-test-suite

ID
bed1321
date
2025-12-06 13:27:40+00:00
author
Alex Chan <alex@alexwlchan.net>
parents
3bd0cd7, fd73ef1
message
Merge pull request #11 from alexwlchan/static-site-test-suite

Add a set of standard tests for static sites
changed files
9 files, 397 additions, 11 deletions

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)