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
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))