summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_asset_versioning.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_asset_versioning.py')
-rw-r--r--packages/meshbay-hub/tests/test_asset_versioning.py103
1 files changed, 103 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_asset_versioning.py b/packages/meshbay-hub/tests/test_asset_versioning.py
new file mode 100644
index 0000000..e5889f0
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_asset_versioning.py
@@ -0,0 +1,103 @@
+"""
+Making sure the browser runs the build we deployed.
+
+A fix can be written, tested, deployed, and served, and still not be what runs.
+`Cache-Control: no-cache` requires a browser to revalidate — but it only binds
+one that asks, and a browser that cached the SPA *before* that header existed
+applies heuristic freshness instead: a fraction of the file's age, which for a
+file dated weeks ago is days. It does not ask, so it never learns.
+
+That happened here. A phone ran a player without the read-ahead bound and filled
+the browser's buffer ceiling at 106 MB — the exact symptom the bound was written
+to remove — for an hour after the bounded player went live. Two sessions were
+spent looking at the node.
+
+So the URL now carries a fingerprint of what is being served, and the whole
+module graph lives under it: `/a/<hash>/app.js` importing `./i18n.js` resolves
+to `/a/<hash>/i18n.js`. A URL that changes with the content cannot serve
+yesterday's build, and cannot serve half of each.
+"""
+
+import re
+
+import pytest
+from fastapi.testclient import TestClient
+
+from meshbay_hub.api.webapp import ASSET_V, STATIC_DIR, _asset_version
+from meshbay_hub.app import create_app
+
+
+@pytest.fixture(scope="module")
+def client():
+ return TestClient(create_app())
+
+
+def _shell_refs(html: str) -> list[str]:
+ return re.findall(r'(?:src|href)="([^"]+)"', html)
+
+
+def test_every_asset_the_shell_loads_is_versioned(client):
+ refs = _shell_refs(client.get("/").text)
+ assert refs, "the shell references nothing at all"
+ stale = [u for u in refs if not u.startswith(f"/a/{ASSET_V}/")]
+ assert not stale, (
+ f"served from an unversioned URL, so a stale cache can win: {stale}")
+
+
+def test_the_module_graph_resolves_under_the_same_prefix(client):
+ """The point of a path prefix rather than a query string.
+
+ `import './i18n.js'` from a versioned app.js resolves against the versioned
+ directory. A query string on app.js alone would not: its imports would fall
+ back to the bare paths, and the graph could be assembled from two builds —
+ which does not render a stale page, it fails to link at all.
+ """
+ for path in ("app.js", "i18n.js", "locales/fr.js", "transport.js",
+ "vendor/argon2.min.js"):
+ r = client.get(f"/a/{ASSET_V}/{path}")
+ assert r.status_code == 200, f"{path} is not served under the version"
+
+
+def test_versioned_assets_may_be_cached_hard(client):
+ """Which is the trade the fingerprint buys."""
+ cc = client.get(f"/a/{ASSET_V}/app.js").headers.get("cache-control", "")
+ assert "immutable" in cc and "max-age=31536000" in cc, (
+ f"versioned assets are served as {cc!r} — the whole point of a URL that "
+ "changes with the content is that it need never be revalidated")
+
+
+def test_the_unversioned_path_still_revalidates(client):
+ """It still answers — old bookmarks, and sw.js has to live there."""
+ r = client.get("/app.js")
+ assert r.status_code == 200
+ assert r.headers.get("cache-control") == "no-cache"
+
+
+def test_the_service_worker_is_not_versioned(client):
+ """Its scope is its own path.
+
+ Served from /a/<hash>/sw.js it would only control /a/<hash>/, and the
+ pages whose downloads it exists to intercept are not under there.
+ """
+ assert client.get("/sw.js").status_code == 200
+ assert "/sw.js" not in _shell_refs(client.get("/").text)
+
+
+def test_the_fingerprint_follows_the_content(tmp_path, monkeypatch):
+ """Otherwise it is decoration.
+
+ Pinning this to the file contents rather than to the package version is
+ deliberate: a redeploy without a version bump is the common case during a
+ debugging session, and that is exactly when a stale player costs the most.
+ """
+ before = _asset_version()
+ target = STATIC_DIR / "app.js"
+ original = target.read_bytes()
+ try:
+ target.write_bytes(original + b"\n// touched\n")
+ assert _asset_version() != before, (
+ "the fingerprint did not move when app.js did, so a redeploy "
+ "serves the new file at the old URL")
+ finally:
+ target.write_bytes(original)
+ assert _asset_version() == before, "the fingerprint is not reproducible"