Skip to main content

browser_fixtures: add a function to verify self-contained web archives

ID
be4fe27
date
2026-09-16 11:37:21+00:00
author
Alex Chan <alex@alexwlchan.net>
parent
d8bb257
message
browser_fixtures: add a function to verify self-contained web archives
changed files
12 files, 618 additions, 23 deletions

Changed files

CHANGELOG.md (7690 → 8289)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 150ac9e..b635f1c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
 # CHANGELOG
 
+## v54 - 2026-10-24
+
+Introduce an experimental `verify_local_page_integrity` function in `browser_fixtures`.
+
+This function checks if a local copy of a web page is completely self-contained -- loading without console errors or warnings, and making no reqwuests for external resources.
+
+I wrote this to ensure that my local web archives are truly self-contained, and that I have all the correct assets (images, CSS, JavaScript, and so on) saved for each page.
+It's been tested with my current web archive of 2,700 pages, and maintaining and extending that archive is the intended user of this code.
+
 ## v53 - 2026-08-26
 
 Static site tests now disallow videos encoded in VP9, in addition to the previously-disallowed AV1.

README.md (3995 → 4022)

diff --git a/README.md b/README.md
index 47a78e4..5fd33c0 100644
--- a/README.md
+++ b/README.md
@@ -37,7 +37,7 @@ My media archives are saved on my personal computer and I want them to last for 
 
 ## Versioning and stability
 
-This library is monotonically versioned (releases simply go up sequentially).
+This library is monotonically versioned (release numbers are an integer that I bump with every release).
 
 I make breaking changes regularly, and I make no promise of compatibility between versions.
 When I make a breaking change, I update all my local media archives to the latest version immediately and fix the breakage at the same time.

javascript/chives-image.js (6561 → 6561)

diff --git a/javascript/chives-image.js b/javascript/chives-image.js
index 020efa3..3d7b12e 100644
--- a/javascript/chives-image.js
+++ b/javascript/chives-image.js
@@ -1,5 +1,5 @@
 // javascript/chives-image.js
-// chives version: 53
+// chives version: 54
 
 // ChivesImage provides a <chives-image> component that renders
 // an ImageEntity on a web page.

javascript/chives-pagination.js (4929 → 4929)

diff --git a/javascript/chives-pagination.js b/javascript/chives-pagination.js
index 583b229..4bf0980 100644
--- a/javascript/chives-pagination.js
+++ b/javascript/chives-pagination.js
@@ -1,5 +1,5 @@
 // javascript/chives-pagination.js
-// chives version: 53
+// chives version: 54
 
 // ChivesPagination manages the pagination of a collection of items.
 //

javascript/chives-sorting.js (5034 → 5034)

diff --git a/javascript/chives-sorting.js b/javascript/chives-sorting.js
index 343c6f5..8e390bd 100644
--- a/javascript/chives-sorting.js
+++ b/javascript/chives-sorting.js
@@ -1,5 +1,5 @@
 // javascript/chives-sorting.js
-// chives version: 53
+// chives version: 54
 
 // ChivesSorting manages the sorting of a collection of items.
 //

javascript/chives-video.js (5147 → 5147)

diff --git a/javascript/chives-video.js b/javascript/chives-video.js
index 81f7773..8df28e6 100644
--- a/javascript/chives-video.js
+++ b/javascript/chives-video.js
@@ -1,5 +1,5 @@
 // javascript/chives-video.js
-// chives version: 53
+// chives version: 54
 
 // ChivesVideo provides a <chives-video> component that renders
 // a VideoEntity on a web page.

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

diff --git a/python/chives/__init__.py b/python/chives/__init__.py
index ade97f4..e460306 100644
--- a/python/chives/__init__.py
+++ b/python/chives/__init__.py
@@ -11,4 +11,4 @@ I share across multiple sites.
 
 """
 
-__version__ = "53"
+__version__ = "54"

python/chives/browser_fixtures.py (3757 → 11811)

diff --git a/python/chives/browser_fixtures.py b/python/chives/browser_fixtures.py
index 012e0a6..eb9108a 100644
--- a/python/chives/browser_fixtures.py
+++ b/python/chives/browser_fixtures.py
@@ -4,6 +4,8 @@ Provide a set of Playwright fixtures to use when testing static sites.
 You can start an instance of a web browser, open a URL or local HTML file,
 and make Playwright assertions about the page.
 
+== browser fixture ==
+
 The `browser` fixture gives you an instance of a Playwright Browser.
 In this example, we open the local file `greeting.html` and check it
 contains the phrase `Hello world!`:
@@ -19,6 +21,8 @@ contains the phrase `Hello world!`:
         p.goto(uri)
         expect(p.get_by_text("Hello world!")).to_be_visible()
 
+== page fixture ==
+
 The `page` fixture gives you an instance of a Playwright Page.
 It automatically listens for console warnings, console errors, and
 page errors, and raises an assertion error on teardown if any occurred:
@@ -32,19 +36,25 @@ page errors, and raises an assertion error on teardown if any occurred:
         page.goto(uri)
         expect(page.get_by_text("Hello world!")).to_be_visible()
 
-For more information, see https://alexwlchan.net/2026/playwright/
+== verify_local_page_integrity function ==
+
+The `verify_local_page_integrity` function lets you verify that a local
+copy of a web page is completely self-contained: it loads without errors
+or warnings, and it doesn't make any requests for external resources.
+
 """
 
 from collections.abc import Iterator
 import os
 from pathlib import Path
+import urllib.parse
 from urllib.request import pathname2url
 
 import pytest
-from playwright.sync_api import Browser, Page, sync_playwright
+from playwright.sync_api import Browser, ConsoleMessage, Page, Request, sync_playwright
 
 
-__all__ = ["file_uri", "browser", "page"]
+__all__ = ["PageError", "file_uri", "browser", "page", "verify_local_page_integrity"]
 
 
 def file_uri(p: Path | str) -> str:
@@ -77,7 +87,7 @@ def browser() -> Iterator[Browser]:
     If the environment variable `SKIP_PLAYWRIGHT` is set, this fixture will
     automatically skip any tests that depend on it to speed up test runs.
     """
-    if "SKIP_PLAYWRIGHT" in os.environ:
+    if os.environ.get("SKIP_PLAYWRIGHT", "").lower() in {"1", "true", "yes"}:
         pytest.skip(reason="skip slow Playwright tests")
 
     with sync_playwright() as p:
@@ -117,3 +127,226 @@ def page(browser: Browser) -> Iterator[Page]:
 
     # Check there weren't any page errors
     assert page_errors == []
+
+
+class PageError(Exception):
+    """
+    Thrown when there's an error verifying a page.
+    """
+
+
+def verify_local_page_integrity(page: Page, url: str) -> None:
+    """
+    Navigate the page to a URL, then check that it loads without exceptions,
+    console warnings or errors, and doesn't make requests for external resources.
+
+    This can be used to verify that a local copy of a web page is
+    a self-contained archive.
+
+    Note that this is a heuristic rather than a guarantee: it only covers
+    the initial page load, and won't catch additional requests triggered
+    later by user interaction or JavaScript execution.
+    """
+    # Capture all console logs, page errors, and requests.
+    console_messages = []
+    page.on("console", lambda msg: console_messages.append(msg))
+
+    page_errors = []
+    page.on("pageerror", lambda err: page_errors.append(err))
+
+    requests = []
+    page.on("request", lambda req: requests.append(req))
+
+    page.goto(url, wait_until="networkidle")
+
+    # 2. Force a layout/paint to trigger CSS asset downloads (like background images)
+    page.evaluate("document.body.offsetHeight")
+
+    # Wait for the fonts to load, so we get any errors about missing fonts.
+    # Otherwise we can miss the error because we return too quickly.
+    # See https://developer.mozilla.org/en-US/docs/Web/API/Document/fonts#doing_operation_after_fonts_are_loaded
+    page.evaluate("document.fonts.ready")
+
+    # Give the browser a small buffer tick to register the requests
+    # page.wait_for_timeout(500)
+
+    # Check there weren't any page errors
+    if page_errors:
+        raise PageError(f"Unexpected page errors: {page_errors}")
+
+    # Check the console logs for errors and warnings.
+    console_errors = [
+        msg for msg in console_messages if _is_important_message(url, requests, msg)
+    ]
+
+    if console_errors:
+        raise PageError(f"Unexpected console warnings/errors: {console_errors}")
+
+    assert page_errors == []
+
+    # Check there weren't any HTTP requests to external resources
+    external_requests = [r for r in requests if _is_external_request(r, url)]
+    if external_requests:
+        raise PageError(
+            f"Page made requests for external resources: {external_requests}"
+        )
+
+
+def _file_url_exists(url: str, suffixes: set[str] | None = None) -> bool:
+    """
+    Return True if a URL is a file:/// URL, optionally with a required suffix.
+    """
+    u = urllib.parse.urlsplit(url)
+    if u.scheme != "file":  # pragma: no cover
+        return False
+    path = Path(u.path)
+    if suffixes and path.suffix not in suffixes:
+        return False
+    return path.exists()
+
+
+def _alternative_font_urls(url: str) -> set[str]:
+    """
+    Return a set of URLs that are equivalent to the current URL.
+    """
+    u = urllib.parse.urlsplit(url)
+    if not u.path.endswith((".otf", ".svg", ".ttf", ".woff", ".woff2")):
+        return set()
+
+    alt_suffixes = (".otf", ".ttf", ".woff", ".woff2")
+
+    _, suffix = os.path.splitext(u.path)
+    alt_paths = {u.path.replace(suffix, asf) for asf in alt_suffixes}
+
+    if u.path.endswith("-webfont.svg"):
+        alt_paths = alt_paths.union(
+            {u.path.replace("-webfont.svg", asf) for asf in alt_suffixes}
+        )
+
+    return {
+        urllib.parse.urlunsplit((u.scheme, u.netloc, path, "", ""))
+        for path in alt_paths
+    }
+
+
+def _path_only_url(url: str) -> str:
+    """
+    Return a URL without a query or fragment.
+    """
+    u = urllib.parse.urlsplit(url)
+    return urllib.parse.urlunsplit((u.scheme, u.netloc, u.path, "", ""))
+
+
+def _is_important_message(
+    page_url: str, requests: list[Request], msg: ConsoleMessage
+) -> bool:
+    """
+    Return whether this console message should be raised as an error.
+    """
+    if msg.type not in {"error", "warning"}:
+        return False
+
+    # Ignore errors about missing font files if:
+    #
+    #   1. The browser has loaded the font in a different format, or
+    #   2. The browser hasn't loaded the font, but it's saved in
+    #      a different format
+    #
+    # I don't entirely understand how WebKit decides which font files
+    # to fetch. If a font is actually used on the page, it tries all the
+    # different variants. If a font isn't used, it looks for the TTF and
+    # then gives up, which returns a console error.
+    #
+    # I'm not going to save every variant of a font; I just want to know
+    # that my archive has a copy of this font somewhere.
+    font_err_prefix = "Not allowed to load local resource:"
+    if msg.text.startswith(font_err_prefix):
+        url = msg.location["url"]
+        if url == "":
+            url = urllib.parse.unquote(msg.text.split(font_err_prefix, 1)[1].strip())
+
+        # Has the browser loaded the font in a different format?
+        request_urls = {_path_only_url(req.url) for req in requests}
+        if set.intersection(request_urls, _alternative_font_urls(url)):
+            return False
+
+        # Is the font saved in a different format?
+        if any(_file_url_exists(u) for u in _alternative_font_urls(url)):
+            return False
+
+    # Ignore errors related to loading SVG files from file:/// URLs like:
+    #
+    #     Unsafe attempt to load URL file:///Volumes/…/icons.svg#info
+    #     from origin null. Domains, protocols and ports must match.
+    #
+    # This is blocked as a cross-origin request. Ignore it as long as
+    # the SVG exists on disk.
+    #
+    # See https://alexwlchan.net/2025/learning-how-to-make-websites/#file_uris
+    svg_err_prefix = "Unsafe attempt to load URL file:///"
+    svg_err_suffix = "from origin null. Domains, protocols and ports must match.\n"
+    if (
+        page_url.startswith("file:///")
+        and msg.text.startswith(svg_err_prefix)
+        and msg.text.endswith(svg_err_suffix)
+    ):
+        url = "file:///" + msg.text[len(svg_err_prefix) : -len(svg_err_suffix)]
+
+        if _file_url_exists(urllib.parse.unquote(url), suffixes={".svg"}):
+            return False
+        else:  # pragma: no cover
+            assert 0, "missing svg test case"
+
+    # Ignore errors related to loading image files from file:/// URLs like:
+    #
+    #     Origin null is not allowed by Access-Control-Allow-Origin.
+    #     Status code: 0
+    #
+    # This is blocked as a cross-origin request. We don't get any more
+    # information about this request, so we have to treat it as okay.
+    if (
+        page_url.startswith("file:///")
+        and msg.text
+        == "Origin null is not allowed by Access-Control-Allow-Origin. Status code: 0"
+    ):
+        return False
+
+    if (
+        page_url.startswith("file:///")
+        and msg.text
+        == "Failed to load resource: Origin null is not allowed by Access-Control-Allow-Origin. Status code: 0"  # noqa: E501
+    ):
+        url = urllib.parse.unquote(msg.location["url"])
+        if _file_url_exists(url, suffixes={".png", ".svg"}):
+            return False
+
+    return True
+
+
+def _is_external_request(req: Request, original_url: str) -> bool:
+    """
+    Return whether this request is external to the server that served
+    the original URL.
+    """
+    if req.url == original_url:
+        return False
+
+    if req.method != "GET":  # pragma: no cover
+        return True
+
+    # Compare the scheme/host of the request to that of the original URL.
+    org_url = urllib.parse.urlsplit(original_url)
+    req_url = urllib.parse.urlsplit(req.url)
+
+    if org_url.scheme == req_url.scheme and org_url.netloc == req_url.netloc:
+        return False
+
+    # Ignore URLs like blob:null/<uuid>
+    if (
+        req_url.scheme == "blob"
+        and req_url.netloc == ""
+        and req_url.path.startswith("null/")
+    ):
+        return False
+
+    return True

tests/fixtures/assets/fa-v4compatibility.ttf (0 → 10832)

diff --git a/tests/fixtures/assets/fa-v4compatibility.ttf b/tests/fixtures/assets/fa-v4compatibility.ttf
new file mode 100644
index 0000000..b175aa8
Binary files /dev/null and b/tests/fixtures/assets/fa-v4compatibility.ttf differ

tests/fixtures/media/PTF55F_W.woff (0 → 107400)

diff --git a/tests/fixtures/media/PTF55F_W.woff b/tests/fixtures/media/PTF55F_W.woff
new file mode 100644
index 0000000..8261bfb
Binary files /dev/null and b/tests/fixtures/media/PTF55F_W.woff differ

tests/fixtures/media/Roboto.woff2 (0 → 11028)

diff --git a/tests/fixtures/media/Roboto.woff2 b/tests/fixtures/media/Roboto.woff2
new file mode 100644
index 0000000..b65a361
Binary files /dev/null and b/tests/fixtures/media/Roboto.woff2 differ

tests/python/test_browser_fixtures.py (5258 → 17185)

diff --git a/tests/python/test_browser_fixtures.py b/tests/python/test_browser_fixtures.py
index 3384b9a..220888e 100644
--- a/tests/python/test_browser_fixtures.py
+++ b/tests/python/test_browser_fixtures.py
@@ -2,14 +2,18 @@
 Tests for `chives.browser_fixtures`.
 """
 
+from collections.abc import Iterator
 import os
 from pathlib import Path
+import shutil
 import subprocess
 
-from playwright.sync_api import sync_playwright
+from playwright.sync_api import sync_playwright, Browser
 import pytest
 from pytest import Pytester
 
+from chives.browser_fixtures import file_uri, verify_local_page_integrity, PageError
+
 
 git_root_cmd = ["git", "rev-parse", "--show-toplevel"]
 GIT_ROOT = subprocess.check_output(git_root_cmd, text=True).strip()
@@ -40,9 +44,9 @@ def playwright_pytester(pytester: Pytester, playwright_browsers_path: str) -> Py
 
     pytester.makeconftest(f"""
         import os
-        
+
         from chives.browser_fixtures import browser, page
-        
+
         os.environ["PLAYWRIGHT_BROWSERS_PATH"] = {playwright_browsers_path!r}
     """)
 
@@ -64,7 +68,7 @@ class TestBrowserFixture:
             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")
 
@@ -91,7 +95,7 @@ class TestPageFixture:
             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)
@@ -120,7 +124,7 @@ class TestPageFixture:
             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)
@@ -137,7 +141,7 @@ class TestPageFixture:
             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)
@@ -146,22 +150,31 @@ class TestPageFixture:
         playwright_pytester.runpytest().assert_outcomes(failed=1)
 
 
-def test_skip_playwright(pytester: Pytester) -> None:
+@pytest.fixture
+def skip_playwright_envvar() -> Iterator[None]:
+    """
+    Set the SKIP_PLAYWRIGHT environment variable for the duration
+    of a single test.
+    """
+    try:
+        os.environ["SKIP_PLAYWRIGHT"] = "true"
+        yield
+    finally:
+        del os.environ["SKIP_PLAYWRIGHT"]
+
+
+def test_skip_playwright(pytester: Pytester, skip_playwright_envvar: None) -> 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
 
@@ -171,3 +184,343 @@ def test_skip_playwright(pytester: Pytester) -> None:
         """
     )
     pytester.runpytest().assert_outcomes(skipped=2)
+
+
+class TestVerifyLocalPageIntegrity:
+    """
+    Tests for `verify_local_page_integrity`.
+    """
+
+    def assert_page_verifies_ok(self, browser: Browser, path: Path) -> None:
+        """
+        Assert that a page passes validation with `verify_local_page_integrity`.
+        """
+        page = browser.new_page()
+        verify_local_page_integrity(page, url=file_uri(path))
+
+    def assert_page_fails_verification(
+        self, browser: Browser, path: Path, match: str
+    ) -> None:
+        """
+        Assert that a page fails validation with `verify_local_page_integrity`.
+        """
+        page = browser.new_page()
+
+        with pytest.raises(PageError, match=match):
+            verify_local_page_integrity(page, url=file_uri(path))
+
+    def test_self_contained_page(self, tmp_path: Path, browser: Browser) -> None:
+        """
+        Verifying a self-contained page doesn't throw any errors.
+        """
+        p = tmp_path / "greeting.html"
+        p.write_text("<p>hello world!</p>")
+
+        self.assert_page_verifies_ok(browser, p)
+
+    def test_loading_local_resource(self, tmp_path: Path, browser: Browser) -> None:
+        """
+        A page which loads local resources doesn't throw any errors.
+        """
+        shutil.copyfile("tests/fixtures/media/470906.png", tmp_path / "470906.png")
+
+        p = tmp_path / "greeting.html"
+        p.write_text("<img src='470906.png'>")
+
+        self.assert_page_verifies_ok(browser, p)
+
+    def test_page_with_external_img_is_error(
+        self, tmp_path: Path, browser: Browser
+    ) -> None:
+        """
+        Verifying a page which loads an <img> from an external source
+        returns an error.
+        """
+        p = tmp_path / "image.html"
+        p.write_text("<img src='https://alexwlchan.net/images/profile_green_sq.jpg'>")
+
+        self.assert_page_fails_verification(
+            browser, p, match="Page made requests for external resources"
+        )
+
+    def test_page_with_broken_js_is_error(
+        self, tmp_path: Path, browser: Browser
+    ) -> None:
+        """
+        Verifying a page with a JavaScript mistake returns an error.
+        """
+        p = tmp_path / "badscript.html"
+        p.write_text("<script>console.log(doesNotExist);</script>")
+
+        self.assert_page_fails_verification(browser, p, match="Unexpected page errors")
+
+    def test_page_with_console_warning_is_error(
+        self, tmp_path: Path, browser: Browser
+    ) -> None:
+        """
+        Verifying a page with a console warning returns an error.
+        """
+        p = tmp_path / "warning.html"
+        p.write_text("<script>console.warn('uh oh!')</script>")
+
+        self.assert_page_fails_verification(
+            browser, p, match="Unexpected console warnings/errors"
+        )
+
+    def test_page_with_console_error_is_error(
+        self, tmp_path: Path, browser: Browser
+    ) -> None:
+        """
+        Verifying a page with a console warning returns an error.
+        """
+        p = tmp_path / "badimage.html"
+        p.write_text("<img src='doesnotexist.jpg'>")
+
+        self.assert_page_fails_verification(
+            browser, p, match="Unexpected console warnings/errors"
+        )
+
+    def test_page_with_console_log_is_not_error(
+        self, tmp_path: Path, browser: Browser
+    ) -> None:
+        """
+        Verifying a page with a console log which is INFO or lower
+        does not raise an error.
+        """
+        p = tmp_path / "console.html"
+        p.write_text("<script>console.log('everything is fine')</script>")
+
+        self.assert_page_verifies_ok(browser, p)
+
+    @pytest.mark.parametrize(
+        "font_url, font_format",
+        [
+            ("PTF55F_W.ttf", "truetype"),
+            ("PTF55F_W.ttf?1", "truetype"),
+            ("PTF55F_W.svg", "svg"),
+            ("PTF55F_W.svg#pt-serif", "svg"),
+            ("PTF55F_W-webfont.svg", "svg"),
+            ("PTF55F_W-webfont.svg#pt-serif", "svg"),
+        ],
+    )
+    @pytest.mark.parametrize("dirname", ["site", "site with spaces"])
+    def test_loading_missing_fonts(
+        self,
+        tmp_path: Path,
+        browser: Browser,
+        font_url: str,
+        font_format: str,
+        dirname: str,
+    ) -> None:
+        """
+        Missing fonts are not reported as a validation error.
+        """
+        parent_dir = tmp_path / dirname
+        parent_dir.mkdir()
+
+        # This represents a common form of error in my archives: the font
+        # presents multiple font formats, and my browser reports an error
+        # for TTF even though it later finds the WOFF.
+        (parent_dir / "style.css").write_text(f"""
+            @font-face {{
+                font-family: 'PT Serif';
+                src: url('PTF55F_W.woff') format('woff'),
+                     url('{font_url}') format('{font_format}');
+            }}
+
+            body {{
+                font-family: PT Serif;
+            }}
+        """)
+        shutil.copyfile(
+            "tests/fixtures/media/PTF55F_W.woff", parent_dir / "PTF55F_W.woff"
+        )
+
+        p = parent_dir / "hasfont.html"
+        p.write_text("""
+            <link rel='stylesheet' href='style.css'>
+            <body>Hello world!</body>
+        """)
+
+        self.assert_page_verifies_ok(browser, p)
+
+    def test_load_missing_fonts_url_escape(
+        self, browser: Browser, tmp_path: Path
+    ) -> None:
+        """
+        Test the validator can find missing fonts, even if there are
+        spaces/punctuation in the URL.
+
+        This is a regression test for a specific bug in one of my pages.
+        """
+        parent_dir = tmp_path / "Media (With Spaces)"
+        parent_dir.mkdir()
+
+        shutil.copyfile(
+            "tests/fixtures/assets/fa-v4compatibility.ttf",
+            parent_dir / "fa-v4compatibility.ttf",
+        )
+
+        (parent_dir / "font-awesome.css").write_text(
+            """
+            @font-face{
+                font-family: "FontAwesome";
+                font-display: block;
+                src: url(fa-v4compatibility.woff2) format("woff2"),
+                     url(fa-v4compatibility.ttf) format("truetype");
+                unicode-range:u+f041;
+            }
+            """
+        )
+
+        p = parent_dir / "font_awesome.html"
+        p.write_text('<link rel="stylesheet" href="font-awesome.css">')
+
+        self.assert_page_verifies_ok(browser, p)
+
+    @pytest.mark.parametrize("dirname", ["site", "site with spaces"])
+    def test_loading_unrecognised_fonts(
+        self, tmp_path: Path, dirname: str, browser: Browser
+    ) -> None:
+        """
+        A missing, unrecognised font is reported as a validation error.
+
+        This is a test for the `document.fonts.ready` wait -- without it,
+        this test becomes flaky because sometimes we complete before
+        registering the missing font.
+        """
+        parent_dir = tmp_path / dirname
+        parent_dir.mkdir()
+
+        (parent_dir / "style.css").write_text("""
+            @font-face {
+                font-family: 'PT Serif';
+                src: url('PTF55F_W.woff3');
+            }
+
+            body {
+                font-family: PT Serif;
+            }
+        """)
+
+        p = parent_dir / "hasfont.html"
+        p.write_text("<link rel='stylesheet' href='static/style.css'>")
+
+        self.assert_page_fails_verification(
+            browser, p, match="Unexpected console warnings/errors"
+        )
+
+    def test_loading_empty_audio(self, tmp_path: Path, browser: Browser) -> None:
+        """
+        An empty <audio> tag is not reported as a validation error.
+        """
+        # This triggers a series of requests for blob:null/<uuid>;
+        # They aren't requests for external requests; I think it's the
+        # browser generating empty data which gets sent to the audio engine.
+        p = tmp_path / "audio.html"
+        p.write_text('<audio controls=""></audio>')
+
+        self.assert_page_verifies_ok(browser, p)
+
+    def test_loading_svg_from_file_uri(self, tmp_path: Path, browser: Browser) -> None:
+        """
+        Loading an external SVG from a file:/// URI is fine if the SVG exists,
+        but a validation error if the file doesn't exist.
+        """
+        p = tmp_path / "uses_svg.html"
+        p.write_text('<svg class="icon"><use href="icon%20font.svg#empty"></use></svg>')
+
+        # Validation error if the SVG doesn't exist.
+        self.assert_page_fails_verification(
+            browser, p, match="Unexpected console warnings/errors"
+        )
+
+        # Passes validation if the SVG exists.
+        (tmp_path / "icon font.svg").write_text("""
+            <svg xmlns="http://www.w3.org/2000/svg">
+                <defs>
+                    <marker id="empty"></marker>
+                </defs>
+            </svg>
+        """)
+
+        self.assert_page_verifies_ok(browser, p)
+
+    def test_load_svg_as_mask_image(self, tmp_path: Path, browser: Browser) -> None:
+        """
+        Loading an SVG as a mask-image in CSS is a validation error if
+        the SVG doesn't exist.
+        """
+        p = tmp_path / "uses_svg_mask.html"
+        p.write_text("""
+            <style>
+              div:before{
+                content: "";
+                -webkit-mask-image: url(mask%20icon.svg);
+                mask-image:         url(mask%20icon.svg);
+              }
+            </style>
+            <div></div>
+        """)
+
+        # Validation error if the SVG doesn't exist.
+        self.assert_page_fails_verification(
+            browser, p, match="Unexpected console warnings/errors"
+        )
+
+        # Passes validation if the SVG exists.
+        (tmp_path / "mask icon.svg").write_text("""
+            <svg xmlns="http://www.w3.org/2000/svg">
+                <defs>
+                    <marker id="empty"></marker>
+                </defs>
+            </svg>
+        """)
+
+        self.assert_page_verifies_ok(browser, p)
+
+    def test_cross_origin_file_is_error(self, tmp_path: Path, browser: Browser) -> None:
+        """
+        A file:/// request with `crossorigin="anonymous"` is an error.
+
+        This triggers a CORS request, and two file:/// URLs are considered
+        cross-origin, so the request fails. The fix is to remove them from
+        my local pages.
+        """
+        p = tmp_path / "uses_font.html"
+        p.write_text("""
+            <link href="Roboto.woff2" rel="preload" type="font/woff2" as="font"
+            crossorigin="anonymous">
+        """)
+        shutil.copyfile("tests/fixtures/media/Roboto.woff2", tmp_path / "Roboto.woff2")
+
+        # Validation error trying to fetch the font.
+        self.assert_page_fails_verification(
+            browser, p, match="Unexpected console warnings/errors"
+        )
+
+        # Passes validation if `crossorigin="anonymous"` isn't set.
+        p.write_text("""
+            <link href="Roboto.woff2" rel="preload" type="font/woff2" as="font">
+        """)
+        self.assert_page_verifies_ok(browser, p)
+
+    def test_waits_for_network_idle(self, tmp_path: Path, browser: Browser) -> None:
+        """
+        The validator waits for the network to quiet down before completing.
+
+        This test becomes flaky if I don't set `wait_until="networkidle"`
+        in the `page.goto()` call.
+        """
+        p = tmp_path / "waits_for_request.html"
+        p.write_text("""
+            <script>
+            window.addEventListener('load', () => {
+              setTimeout(() => {
+                fetch('https://example.com/api/data').catch(() => {});
+              }, 100);
+            });
+            </script>
+        """)
+
+        self.assert_page_fails_verification(browser, p, match="Unexpected page errors:")