feat(ui): version-busting query suffix on all served asset URLs
Browser-tester confirmed on spark-1 that the 7 <script src> tags and all
<link href> tags in the served HTML had no cache-busting suffix. Because
there is no service worker, the standard HTTP cache would keep serving stale
JS/CSS to browsers that had already loaded a previous release — the root
cause of yesterday's 'is the user seeing stale JS?' investigation.
Fix: index_page() now appends ?v=<muxplex_version> to every static-asset
URL (src= and href= attributes whose path starts with / and does not begin
with /api/) before returning the HTML response. The version is read once at
module load from importlib.metadata.version('muxplex') — the same source
used by the doctor command — so it is always in sync with the installed
package. Starlette's StaticFiles handler ignores query parameters when
serving files from disk, so the versioned URLs continue to resolve to the
same bytes; they just carry a new cache key on every release.
Affected assets (7 <script> + 6 <link> + 1 <img>):
/vendor/xterm.js, /vendor/xterm-addon-fit.js,
/vendor/xterm-addon-web-links.js, /vendor/xterm-addon-search.js,
/vendor/addon-image.js, /app.js, /terminal.js
/manifest.json, /favicon.ico, /favicon-32.png, /apple-touch-icon.png,
/vendor/xterm.css, /style.css
/wordmark-on-dark.svg
New tests in muxplex/tests/test_main.py:
- test_index_all_asset_urls_have_version_suffix — every <script src> and
<link href> in GET / carries ?v=<version>
- test_index_vendor_scripts_each_versioned — all 7 expected script URLs
are present with the version suffix
- test_versioned_asset_url_resolves_to_static_file — static handler
serves the file correctly when the query string is present
This commit is contained in:
+21
-1
@@ -17,6 +17,7 @@ import logging
|
||||
import os
|
||||
import pathlib
|
||||
import pwd
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
import shutil
|
||||
@@ -527,6 +528,15 @@ _FRONTEND_DIR = pathlib.Path(__file__).parent / "frontend"
|
||||
# which machine each muxplex instance is running on.
|
||||
_HOSTNAME = socket.gethostname().split(".")[0]
|
||||
|
||||
# Canonical version string — sourced from package metadata (same as `app.version`
|
||||
# and the `doctor` command). Used to append `?v=<version>` to every static-asset
|
||||
# URL so browsers immediately pick up new code on each release.
|
||||
_UI_VERSION: str = importlib.metadata.version("muxplex")
|
||||
|
||||
# Matches src="/<path>" and href="/<path>" in served HTML, excluding /api/ URLs.
|
||||
# Used by index_page() to inject cache-busting version query parameters.
|
||||
_ASSET_URL_RE = re.compile(r'((?:src|href)=")((?!/api/)/[^"?#]*)')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Routes
|
||||
@@ -1204,12 +1214,22 @@ async def federation_terminal_ws_proxy(websocket: WebSocket, device_id: str) ->
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
@app.get("/index.html", response_class=HTMLResponse)
|
||||
async def index_page():
|
||||
"""Serve index.html with hostname injected into the page title."""
|
||||
"""Serve index.html with hostname injected into the page title.
|
||||
|
||||
Also appends ``?v=<version>`` to every static-asset URL (script src, link
|
||||
href) so browsers immediately pick up new code on each release rather than
|
||||
serving stale JS/CSS from the HTTP cache. API URLs (/api/...) are
|
||||
excluded — they are not HTTP-cached by browsers.
|
||||
"""
|
||||
html = (_FRONTEND_DIR / "index.html").read_text()
|
||||
html = html.replace(
|
||||
"<title>muxplex</title>",
|
||||
f"<title>{_HOSTNAME} \u2014 muxplex</title>",
|
||||
)
|
||||
html = _ASSET_URL_RE.sub(
|
||||
lambda m: f"{m.group(1)}{m.group(2)}?v={_UI_VERSION}",
|
||||
html,
|
||||
)
|
||||
return HTMLResponse(html)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Tests for the cache-busting version suffix on static asset URLs served by index_page().
|
||||
|
||||
Verifies that GET / (the main dashboard) injects ?v=<version> on every
|
||||
<script src="…"> and <link href="…"> URL so browsers pick up new code
|
||||
immediately after each release, rather than serving stale JS/CSS from
|
||||
the HTTP cache.
|
||||
"""
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from muxplex.main import app
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixtures (mirror test_api.py setup so tests run cleanly in isolation)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def patch_startup_and_state(tmp_path, monkeypatch):
|
||||
"""Redirect state/PID files to tmp_path and stub out long-running startup tasks."""
|
||||
tmp_state_dir = tmp_path / "state"
|
||||
tmp_state_path = tmp_state_dir / "state.json"
|
||||
monkeypatch.setattr("muxplex.state.STATE_DIR", tmp_state_dir)
|
||||
monkeypatch.setattr("muxplex.state.STATE_PATH", tmp_state_path)
|
||||
|
||||
tmp_pid_dir = tmp_path / "ttyd"
|
||||
tmp_pid_path = tmp_pid_dir / "ttyd.pid"
|
||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_DIR", tmp_pid_dir)
|
||||
monkeypatch.setattr("muxplex.ttyd.TTYD_PID_PATH", tmp_pid_path)
|
||||
|
||||
async def _mock_kill_orphan():
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("muxplex.main.kill_orphan_ttyd", _mock_kill_orphan)
|
||||
|
||||
async def noop_poll_loop() -> None:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_federation_cache():
|
||||
"""Clear _federation_cache before and after each test."""
|
||||
import muxplex.main as main_mod
|
||||
|
||||
main_mod._federation_cache.clear()
|
||||
yield
|
||||
main_mod._federation_cache.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch):
|
||||
"""Authenticated TestClient with the app lifespan active."""
|
||||
monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password")
|
||||
with TestClient(app) as c:
|
||||
from muxplex.auth import create_session_cookie
|
||||
from muxplex.main import _auth_secret, _auth_ttl
|
||||
|
||||
cookie = create_session_cookie(_auth_secret, _auth_ttl)
|
||||
c.cookies.set("muxplex_session", cookie)
|
||||
yield c
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_index_soup(client) -> BeautifulSoup:
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200, f"GET / returned {response.status_code}"
|
||||
return BeautifulSoup(response.text, "html.parser")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 1 — every script src and link href carries the version suffix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_index_all_asset_urls_have_version_suffix(client):
|
||||
"""GET / must inject ?v=<version> on every <script src> and <link href> asset URL.
|
||||
|
||||
Regression guard for the "is the user seeing stale JS?" investigation:
|
||||
verifies that the standard HTTP cache is busted on every release by
|
||||
appending a version query parameter to each static asset reference.
|
||||
"""
|
||||
version = importlib.metadata.version("muxplex")
|
||||
soup = _get_index_soup(client)
|
||||
|
||||
# All <script src="…"> tags
|
||||
script_tags = soup.find_all("script", src=True)
|
||||
assert len(script_tags) >= 7, (
|
||||
f"Expected at least 7 <script src> tags, found {len(script_tags)}"
|
||||
)
|
||||
for tag in script_tags:
|
||||
src = tag["src"]
|
||||
assert f"?v={version}" in src, (
|
||||
f"<script src> missing ?v={version} suffix: {src!r}"
|
||||
)
|
||||
|
||||
# All <link href="…"> tags
|
||||
link_tags = soup.find_all("link", href=True)
|
||||
assert len(link_tags) >= 1, "Expected at least one <link href> tag"
|
||||
for tag in link_tags:
|
||||
href = tag["href"]
|
||||
assert f"?v={version}" in href, (
|
||||
f"<link href> missing ?v={version} suffix: {href!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 2 — vendor scripts are individually versioned (not just app.js)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_index_vendor_scripts_each_versioned(client):
|
||||
"""All five vendor JS bundles must carry the version suffix, not just app.js.
|
||||
|
||||
The browser-tester on spark-1 observed bare vendor URLs. This test
|
||||
ensures that xterm.js and its addons are cache-busted alongside the
|
||||
first-party scripts.
|
||||
"""
|
||||
version = importlib.metadata.version("muxplex")
|
||||
soup = _get_index_soup(client)
|
||||
|
||||
script_srcs = [tag["src"] for tag in soup.find_all("script", src=True)]
|
||||
|
||||
expected_versioned = [
|
||||
f"/vendor/xterm.js?v={version}",
|
||||
f"/vendor/xterm-addon-fit.js?v={version}",
|
||||
f"/vendor/xterm-addon-web-links.js?v={version}",
|
||||
f"/vendor/xterm-addon-search.js?v={version}",
|
||||
f"/vendor/addon-image.js?v={version}",
|
||||
f"/app.js?v={version}",
|
||||
f"/terminal.js?v={version}",
|
||||
]
|
||||
for expected in expected_versioned:
|
||||
assert expected in script_srcs, (
|
||||
f"Expected versioned script {expected!r}; found srcs: {script_srcs}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 3 — versioned asset URLs still resolve to the actual static files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_versioned_asset_url_resolves_to_static_file(client):
|
||||
"""GET /app.js?v=<version> must return HTTP 200 (static handler ignores query string).
|
||||
|
||||
Sanity check: adding the version suffix must not break asset loading.
|
||||
Starlette's StaticFiles handler ignores query parameters when looking up
|
||||
files on disk, so the versioned URL must serve identically to the bare URL.
|
||||
"""
|
||||
version = importlib.metadata.version("muxplex")
|
||||
|
||||
# First-party assets
|
||||
for path in ("/app.js", "/terminal.js", "/style.css"):
|
||||
url = f"{path}?v={version}"
|
||||
resp = client.get(url)
|
||||
assert resp.status_code == 200, (
|
||||
f"Versioned URL {url!r} returned {resp.status_code}, expected 200"
|
||||
)
|
||||
|
||||
# Vendor asset
|
||||
vendor_url = f"/vendor/xterm.js?v={version}"
|
||||
resp = client.get(vendor_url)
|
||||
assert resp.status_code == 200, (
|
||||
f"Versioned vendor URL {vendor_url!r} returned {resp.status_code}, expected 200"
|
||||
)
|
||||
Reference in New Issue
Block a user