Skip to main content

browser_fixtures: tidy up verify_local_page_integrity

ID
b26fde7
date
2026-11-01 11:13:48+00:00
author
Alex Chan <alex@alexwlchan.net>
parent
9e5d7ce
message
browser_fixtures: tidy up verify_local_page_integrity

* Give it a clearer name and docstring
* Create a page inside the function, so it's cleaned up properly
* Break out non-GET requests as a separate check
changed files
8 files, 110 additions, 84 deletions

Changed files

CHANGELOG.md (9375 → 9718)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index a7c411d..62cd507 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
 # CHANGELOG
 
+## v60 - 2026-11-01
+
+Rename `verify_local_page_integrity` to `assert_page_is_self_contained`.
+
+Also, change the signature.
+The first argument is now a `Browser` or `BrowserContext`, and it creates a new page that only lasts as long as the check.
+This makes it clearer that you can pass contexts, and ensures the page is cleaned up correctly.
+
 ## v59 - 2026-10-31
 
 The `<chives-image>` and `<chives-video>` web components now allow setting a `basePath`, which prepends a path to the rendered media files.

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

diff --git a/javascript/chives-image.js b/javascript/chives-image.js
index f058b0f..e28373b 100644
--- a/javascript/chives-image.js
+++ b/javascript/chives-image.js
@@ -1,5 +1,5 @@
 // javascript/chives-image.js
-// chives version: 59
+// chives version: 60
 
 // 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 44a0028..53f8973 100644
--- a/javascript/chives-pagination.js
+++ b/javascript/chives-pagination.js
@@ -1,5 +1,5 @@
 // javascript/chives-pagination.js
-// chives version: 59
+// chives version: 60
 
 // 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 dc98319..9e847e5 100644
--- a/javascript/chives-sorting.js
+++ b/javascript/chives-sorting.js
@@ -1,5 +1,5 @@
 // javascript/chives-sorting.js
-// chives version: 59
+// chives version: 60
 
 // ChivesSorting manages the sorting of a collection of items.
 //

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

diff --git a/javascript/chives-video.js b/javascript/chives-video.js
index be5514c..8ebc5ea 100644
--- a/javascript/chives-video.js
+++ b/javascript/chives-video.js
@@ -1,5 +1,5 @@
 // javascript/chives-video.js
-// chives version: 59
+// chives version: 60
 
 // 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 35b9dcc..f537da1 100644
--- a/python/chives/__init__.py
+++ b/python/chives/__init__.py
@@ -11,4 +11,4 @@ I share across multiple sites.
 
 """
 
-__version__ = "59"
+__version__ = "60"

python/chives/browser_fixtures.py (12144 → 13225)

diff --git a/python/chives/browser_fixtures.py b/python/chives/browser_fixtures.py
index 3c0715b..a39ff78 100644
--- a/python/chives/browser_fixtures.py
+++ b/python/chives/browser_fixtures.py
@@ -51,10 +51,17 @@ import urllib.parse
 from urllib.request import pathname2url
 
 import pytest
-from playwright.sync_api import Browser, ConsoleMessage, Page, Request, sync_playwright
+from playwright.sync_api import (
+    Browser,
+    BrowserContext,
+    ConsoleMessage,
+    Page,
+    Request,
+    sync_playwright,
+)
 
 
-__all__ = ["PageError", "file_uri", "browser", "page", "verify_local_page_integrity"]
+__all__ = ["PageError", "file_uri", "browser", "page", "assert_page_is_self_contained"]
 
 
 def file_uri(p: Path | str) -> str:
@@ -128,6 +135,9 @@ def page(browser: Browser) -> Iterator[Page]:
     # Check there weren't any page errors
     assert page_errors == [], page_errors
 
+    # Close the page
+    p.close()
+
 
 class PageError(Exception):
     """
@@ -135,17 +145,38 @@ class PageError(Exception):
     """
 
 
-def verify_local_page_integrity(page: Page, url: str) -> None:
+def assert_page_is_self_contained(ctx: BrowserContext | Browser, url: str) -> None:
+    """
+    Open a new page, navigate to a URL, and assert that it loads cleanly
+    as a self-contained archive.
+
+    This acts as a heuristic validation check during initial page load.
+    It raises a `PageError` if any of the following occur:
+    - Uncaught page exceptions or errors
+    - Console warnings or errors
+    - Non-GET requests
+    - Requests targeting external resources
+
+    Note: This only covers the initial page load and will not catch dynamic
+    requests triggered later by user interaction or JavaScript.
     """
-    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.
+    page = None
+    try:
+        page = ctx.new_page()
+        _assert_page_is_self_contained(page, url)
+    finally:
+        if page is not None:
+            page.close()
+        else:  # pragma: no cover
+            pass
+
 
-    This can be used to verify that a local copy of a web page is
-    a self-contained archive.
+def _assert_page_is_self_contained(page: Page, url: str) -> None:
+    """
+    Validate page integrity on an existing Playwright page.
 
-    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.
+    See `assert_local_page_integrity` for details on checks performed and
+    exceptions raised.
     """
     # Capture all console logs, page errors, and requests.
     console_messages = []
@@ -184,7 +215,10 @@ def verify_local_page_integrity(page: Page, url: str) -> None:
     if console_errors:
         raise PageError(f"Unexpected console warnings/errors: {console_errors}")
 
-    assert page_errors == []
+    # Check there weren't any non-GET requests
+    non_get_requests = [r for r in requests if r.method != "GET"]
+    if non_get_requests:  # pragma: no cover
+        raise PageError(f"Page made non-GET requests: {non_get_requests}")
 
     # Check there weren't any HTTP requests to external resources
     external_requests = [r for r in requests if _is_external_request(r, url)]
@@ -335,15 +369,17 @@ def _is_important_message(
 
 def _is_external_request(req: Request, original_url: str) -> bool:
     """
-    Return whether this request is external to the server that served
-    the original URL.
+    Check if a request has a different origin than the original URL.
+
+    A request is considered internal if:
+      - it matches the original URL exactly
+      - it has the same scheme, host, and port as the original URL
+      - it uses a `blob:` scheme (for example, in-memory media representations)
+
     """
     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)
@@ -359,4 +395,6 @@ def _is_external_request(req: Request, original_url: str) -> bool:
     if req_url.scheme == "blob":
         return False
 
+    # Fail open: if we can't decide if a request was internal or external,
+    # assume it was external.
     return True

tests/python/test_browser_fixtures.py (20777 → 20700)

diff --git a/tests/python/test_browser_fixtures.py b/tests/python/test_browser_fixtures.py
index f4592c8..7aeea85 100644
--- a/tests/python/test_browser_fixtures.py
+++ b/tests/python/test_browser_fixtures.py
@@ -14,7 +14,7 @@ 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
+from chives.browser_fixtures import file_uri, assert_page_is_self_contained, PageError
 
 
 git_root_cmd = ["git", "rev-parse", "--show-toplevel"]
@@ -216,29 +216,11 @@ def static_server(tmp_path: Path) -> Iterator[str]:
     proc.wait()
 
 
-class TestVerifyLocalPageIntegrity:
+class TestAssertPageIsSelfContained:
     """
-    Tests for `verify_local_page_integrity`.
+    Tests for `assert_page_is_self_contained`.
     """
 
-    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.
@@ -246,7 +228,7 @@ class TestVerifyLocalPageIntegrity:
         p = tmp_path / "greeting.html"
         p.write_text("<p>hello world!</p>")
 
-        self.assert_page_verifies_ok(browser, p)
+        assert_page_is_self_contained(browser, url=file_uri(p))
 
     def test_loading_local_resource(self, tmp_path: Path, browser: Browser) -> None:
         """
@@ -257,7 +239,7 @@ class TestVerifyLocalPageIntegrity:
         p = tmp_path / "greeting.html"
         p.write_text("<img src='470906.png'>")
 
-        self.assert_page_verifies_ok(browser, p)
+        assert_page_is_self_contained(browser, url=file_uri(p))
 
     def test_page_with_external_img_is_error(
         self, tmp_path: Path, browser: Browser
@@ -269,9 +251,10 @@ class TestVerifyLocalPageIntegrity:
         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"
-        )
+        with pytest.raises(
+            PageError, match="Page made requests for external resources"
+        ):
+            assert_page_is_self_contained(browser, url=file_uri(p))
 
     def test_page_with_broken_js_is_error(
         self, tmp_path: Path, browser: Browser
@@ -282,7 +265,8 @@ class TestVerifyLocalPageIntegrity:
         p = tmp_path / "badscript.html"
         p.write_text("<script>console.log(doesNotExist);</script>")
 
-        self.assert_page_fails_verification(browser, p, match="Unexpected page errors")
+        with pytest.raises(PageError, match="Unexpected page errors"):
+            assert_page_is_self_contained(browser, url=file_uri(p))
 
     def test_page_with_console_warning_is_error(
         self, tmp_path: Path, browser: Browser
@@ -293,9 +277,8 @@ class TestVerifyLocalPageIntegrity:
         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"
-        )
+        with pytest.raises(PageError, match="Unexpected console warnings/errors"):
+            assert_page_is_self_contained(browser, url=file_uri(p))
 
     def test_page_with_console_error_is_error(
         self, tmp_path: Path, browser: Browser
@@ -306,9 +289,8 @@ class TestVerifyLocalPageIntegrity:
         p = tmp_path / "badimage.html"
         p.write_text("<img src='doesnotexist.jpg'>")
 
-        self.assert_page_fails_verification(
-            browser, p, match="Unexpected console warnings/errors"
-        )
+        with pytest.raises(PageError, match="Unexpected console warnings/errors"):
+            assert_page_is_self_contained(browser, url=file_uri(p))
 
     def test_page_with_console_log_is_not_error(
         self, tmp_path: Path, browser: Browser
@@ -320,7 +302,7 @@ class TestVerifyLocalPageIntegrity:
         p = tmp_path / "console.html"
         p.write_text("<script>console.log('everything is fine')</script>")
 
-        self.assert_page_verifies_ok(browser, p)
+        assert_page_is_self_contained(browser, url=file_uri(p))
 
     @pytest.mark.parametrize(
         "font_url, font_format",
@@ -372,7 +354,7 @@ class TestVerifyLocalPageIntegrity:
             <body>Hello world!</body>
         """)
 
-        self.assert_page_verifies_ok(browser, p)
+        assert_page_is_self_contained(browser, url=file_uri(p))
 
     def test_load_missing_fonts_url_escape(
         self, browser: Browser, tmp_path: Path
@@ -406,7 +388,7 @@ class TestVerifyLocalPageIntegrity:
         p = parent_dir / "font_awesome.html"
         p.write_text('<link rel="stylesheet" href="font-awesome.css">')
 
-        self.assert_page_verifies_ok(browser, p)
+        assert_page_is_self_contained(browser, url=file_uri(p))
 
     @pytest.mark.parametrize("dirname", ["site", "site with spaces"])
     def test_loading_unrecognised_fonts(
@@ -436,9 +418,8 @@ class TestVerifyLocalPageIntegrity:
         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"
-        )
+        with pytest.raises(PageError, match="Unexpected console warnings/errors"):
+            assert_page_is_self_contained(browser, url=file_uri(p))
 
     def test_audio_empty(self, tmp_path: Path, browser: Browser) -> None:
         """
@@ -450,7 +431,7 @@ class TestVerifyLocalPageIntegrity:
         p = tmp_path / "audio.html"
         p.write_text('<audio controls=""></audio>')
 
-        self.assert_page_verifies_ok(browser, p)
+        assert_page_is_self_contained(browser, url=file_uri(p))
 
     def test_audio_local(self, tmp_path: Path, browser: Browser) -> None:
         """
@@ -463,7 +444,7 @@ class TestVerifyLocalPageIntegrity:
         p = tmp_path / "audio.html"
         p.write_text('<audio src="A_Tale_of_Distant_Lands.mp3"></audio>')
 
-        self.assert_page_verifies_ok(browser, p)
+        assert_page_is_self_contained(browser, url=file_uri(p))
 
     def test_audio_external(self, tmp_path: Path, browser: Browser) -> None:
         """
@@ -474,9 +455,10 @@ class TestVerifyLocalPageIntegrity:
             '<audio src="https://upload.wikimedia.org/wikipedia/commons/e/ec/A_Tale_of_Distant_Lands.mp3"></audio>'
         )
 
-        self.assert_page_fails_verification(
-            browser, p, match="Page made requests for external resources"
-        )
+        with pytest.raises(
+            PageError, match="Page made requests for external resources"
+        ):
+            assert_page_is_self_contained(browser, url=file_uri(p))
 
     def test_video_local_http(
         self, tmp_path: Path, static_server: str, browser: Browser
@@ -496,8 +478,7 @@ class TestVerifyLocalPageIntegrity:
             </video>
             """
         )
-        page = browser.new_page()
-        verify_local_page_integrity(page, url=static_server + "/video.html")
+        assert_page_is_self_contained(browser, url=static_server + "/video.html")
 
     def test_video_external(
         self, tmp_path: Path, static_server: str, browser: Browser
@@ -514,9 +495,10 @@ class TestVerifyLocalPageIntegrity:
             """
         )
 
-        self.assert_page_fails_verification(
-            browser, p, match="Page made requests for external resources"
-        )
+        with pytest.raises(
+            PageError, match="Page made requests for external resources"
+        ):
+            assert_page_is_self_contained(browser, url=file_uri(p))
 
     def test_loading_svg_from_file_uri(self, tmp_path: Path, browser: Browser) -> None:
         """
@@ -527,9 +509,8 @@ class TestVerifyLocalPageIntegrity:
         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"
-        )
+        with pytest.raises(PageError, match="Unexpected console warnings/errors"):
+            assert_page_is_self_contained(browser, url=file_uri(p))
 
         # Passes validation if the SVG exists.
         (tmp_path / "icon font.svg").write_text("""
@@ -540,7 +521,7 @@ class TestVerifyLocalPageIntegrity:
             </svg>
         """)
 
-        self.assert_page_verifies_ok(browser, p)
+        assert_page_is_self_contained(browser, url=file_uri(p))
 
     def test_load_svg_as_mask_image(self, tmp_path: Path, browser: Browser) -> None:
         """
@@ -560,9 +541,8 @@ class TestVerifyLocalPageIntegrity:
         """)
 
         # Validation error if the SVG doesn't exist.
-        self.assert_page_fails_verification(
-            browser, p, match="Unexpected console warnings/errors"
-        )
+        with pytest.raises(PageError, match="Unexpected console warnings/errors"):
+            assert_page_is_self_contained(browser, url=file_uri(p))
 
         # Passes validation if the SVG exists.
         (tmp_path / "mask icon.svg").write_text("""
@@ -573,7 +553,7 @@ class TestVerifyLocalPageIntegrity:
             </svg>
         """)
 
-        self.assert_page_verifies_ok(browser, p)
+        assert_page_is_self_contained(browser, url=file_uri(p))
 
     def test_cross_origin_file_is_error(self, tmp_path: Path, browser: Browser) -> None:
         """
@@ -591,15 +571,14 @@ class TestVerifyLocalPageIntegrity:
         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"
-        )
+        with pytest.raises(PageError, match="Unexpected console warnings/errors"):
+            assert_page_is_self_contained(browser, url=file_uri(p))
 
         # 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)
+        assert_page_is_self_contained(browser, url=file_uri(p))
 
     def test_waits_for_network_idle(self, tmp_path: Path, browser: Browser) -> None:
         """
@@ -619,7 +598,8 @@ class TestVerifyLocalPageIntegrity:
             </script>
         """)
 
-        self.assert_page_fails_verification(browser, p, match="Unexpected page errors:")
+        with pytest.raises(PageError, match="Unexpected page errors:"):
+            assert_page_is_self_contained(browser, url=file_uri(p))
 
     def test_handles_svg(self, tmp_path: Path, browser: Browser) -> None:
         """
@@ -634,4 +614,4 @@ class TestVerifyLocalPageIntegrity:
         """.strip()
         )
 
-        self.assert_page_verifies_ok(browser, p)
+        assert_page_is_self_contained(browser, url=file_uri(p))