summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_asset_versioning.py
blob: 3d84d99f3cd4ff5c4262ffca23c9369af39cc6f3 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
"""
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 _ASSETS, 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"


def test_every_static_script_participates_in_the_fingerprint():
    """
    `_ASSETS` is hand-maintained, and forgetting an entry fails silently: the
    file is imported by the page, so the browser fetches it, but it does not
    feed the content hash — so a change confined to that one file ships at the
    URL a cache already holds. Nothing errors, and the symptom is a fix that
    "doesn't work" on exactly the machines that visited before.

    Found by `source-merge.js`, added to the Search view and left out of the
    list. It happened to be harmless that day because `search-page.js` changed
    in the same commit and *is* listed — which is the worst way for this to go
    unnoticed. The checklist in docs/apps.md §4 step 5 names this trap; this
    enforces it instead of relying on remembering.

    `sw.js` is the one deliberate exclusion — the service worker is served
    unversioned on purpose (`test_the_service_worker_is_not_versioned`).
    """
    on_disk = {p.name for p in STATIC_DIR.glob("*.js")} - {"sw.js"}
    missing = sorted(on_disk - set(_ASSETS))
    assert not missing, (
        f"static scripts missing from webapp._ASSETS: {missing}. A change to "
        "one of these will not move the asset URL, so a browser that cached "
        "the page keeps running the old copy")