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 deletionsCHANGELOG.mdREADME.mdjavascript/chives-image.jsjavascript/chives-pagination.jsjavascript/chives-sorting.jsjavascript/chives-video.jspython/chives/__init__.pypython/chives/browser_fixtures.pytests/fixtures/assets/fa-v4compatibility.ttftests/fixtures/media/PTF55F_W.wofftests/fixtures/media/Roboto.woff2tests/python/test_browser_fixtures.py
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:")