Files
remnawave-minishop/tests/test_docker_webapp_assets.py
3252a8 a2ce29da45 fix: ship hashed webapp assets in backend image
The backend renders the Mini App shell and rewrites the stylesheet and
script tags to content-hashed names (subscription_webapp.<hash>.css).
Those hashed files are gitignored build artifacts, so a clean checkout
has none of them and the backend image was built without any webapp
assets. The resolver therefore stat()-ed a missing file and fell back to
the bare /subscription_webapp.css URL.

That bare URL never changes between deploys and is served no-store. Most
clients re-fetch it, but iOS WebViews (WKWebView) ignore no-store for
subresources and keep serving a stale cached copy, so after every deploy
the CSS no longer matched the markup and the Mini App looked broken on
iOS only. The earlier no-store / ?v= / Clear-Site-Data attempts could not
help because none of them gave iOS a new URL to fetch.

Copy the freshly built assets from the frontend-builder stage into the
backend image (frontend-builder is reordered ahead of the backend stage
so the copy resolves). The build is deterministic, so the hash matches
the one the nginx image serves; the shell now emits immutable, hashed
URLs that change on every asset change and force iOS to fetch fresh CSS.
2026-06-08 22:34:03 +03:00

63 lines
2.6 KiB
Python

"""Guards the Docker packaging of the Mini App's hashed web assets.
The backend renders the Mini App shell and rewrites the stylesheet/script tags
to content-hashed asset names (``subscription_webapp.<hash>.css``). Those hashed
files are gitignored build artifacts, so the backend image only sees them if the
Dockerfile copies them in from the ``frontend-builder`` stage. Without that copy
the asset resolver falls back to the bare ``/subscription_webapp.css`` URL, which
never changes between deploys and is served ``no-store`` -- iOS WebViews cache it
aggressively and render a stale, broken-looking Mini App.
These checks fail loudly if a future Dockerfile refactor drops the copy or moves
the stages so the copy can no longer resolve.
"""
import re
import unittest
from pathlib import Path
DOCKERFILE_PATH = Path(__file__).resolve().parents[1] / "deploy" / "docker" / "Dockerfile"
class DockerWebappAssetTests(unittest.TestCase):
def setUp(self) -> None:
self.dockerfile = DOCKERFILE_PATH.read_text(encoding="utf-8")
def test_backend_stage_copies_built_webapp_assets(self) -> None:
self.assertRegex(
self.dockerfile,
r"COPY\s+--from=frontend-builder\s+\S*backend/bot/app/web/templates/"
r"\s+\S*backend/bot/app/web/templates/",
"backend image must copy the freshly built (hashed) webapp assets so the "
"shell emits immutable, cache-busting asset URLs",
)
def test_frontend_builder_is_defined_before_backend_stage(self) -> None:
builder_idx = self.dockerfile.find("AS frontend-builder")
backend_idx = self.dockerfile.find("AS backend")
self.assertNotEqual(builder_idx, -1, "frontend-builder stage is missing")
self.assertNotEqual(backend_idx, -1, "backend stage is missing")
self.assertLess(
builder_idx,
backend_idx,
"frontend-builder must be defined before the backend stage that copies from it",
)
def test_frontend_builder_builds_the_webapp_assets(self) -> None:
self.assertIn("npm run build:webapp", self.dockerfile)
def test_worker_stage_does_not_copy_webapp_assets(self) -> None:
# The worker runs background jobs and never serves the web shell, so it
# should stay lean and not depend on the frontend build.
worker_match = re.search(
r"FROM\s+python-base\s+AS\s+worker(?P<body>.*?)(?:\nFROM\s|\Z)",
self.dockerfile,
flags=re.DOTALL,
)
self.assertIsNotNone(worker_match, "worker stage is missing")
self.assertNotIn("frontend-builder", worker_match.group("body"))
if __name__ == "__main__":
unittest.main()