summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_asset_versioning.py
blob: aa2dc6d208558905fb23d259f1e9a12c9eb3f424 (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
129
130
131
132
133
134
135
136
137
138
139
140
141
142
"""
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"


@pytest.mark.parametrize("relative", ["locales/en.js", "vendor/htm-preact.js", "style.css"])
def test_a_change_anywhere_under_the_prefix_moves_the_fingerprint(relative):
    """
    Everything under the static directory is served at `/a/<hash>/` and cached
    as immutable, so everything has to feed the hash — subdirectories included.

    The fingerprint used to cover a hand-kept list of top-level modules, and
    the check that guarded the list globbed `*.js` at the top level only. The
    catalogues and `vendor/` were on neither: a change confined to the English
    catalogue shipped at the URL a phone already held, and it went on showing a
    heading that had been rewritten and deployed. Pull-to-refresh fetches the
    shell, which is `no-store` and was current; it does not refetch an
    immutable file whose URL has not moved.
    """
    before = _asset_version()
    target = STATIC_DIR / relative
    original = target.read_bytes()
    try:
        target.write_bytes(original + b"\n/* touched */\n")
        assert _asset_version() != before, (
            f"the fingerprint did not move when {relative} did, so a browser "
            "that cached it keeps the old copy for a year")
    finally:
        target.write_bytes(original)
    assert _asset_version() == before


def test_a_new_file_moves_the_fingerprint():
    """A rename or an addition changes what is served even when no existing
    file's bytes do."""
    before = _asset_version()
    extra = STATIC_DIR / "locales" / "zz-test-only.js"
    try:
        extra.write_text("export default {};\n")
        assert _asset_version() != before
    finally:
        extra.unlink()
    assert _asset_version() == before