On an Android standalone PWA the composer's bottom control row was rendering about one line below the visible viewport, behind the system gesture bar, so those controls were unreachable. The theme already pads the titlebar with env(safe-area-inset-top/left/right), but the viewport meta never opted into viewport-fit=cover, so every safe-area inset collapsed to 0 and the bottom edge had no reservation. Add viewport-fit=cover to the viewport meta (base patch) so the insets carry real values, and reserve env(safe-area-inset-bottom) at the bottom of the composer (brand.css), additive with the app's existing --keyboard-bottom-inset and absorbed by the flex-1 message scroller so total height stays within the viewport. Inert on desktop (env() resolves to 0). Dockerfile verifies the meta patch landed; brand/dockerfile contracts covered by tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
528 lines
21 KiB
Python
528 lines
21 KiB
Python
"""Canonical icon, PWA, and fail-closed Hermes WebUI identity contracts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
from urllib.parse import urlsplit
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
DOCKERFILES = ROOT / "dockerfiles"
|
|
FIXTURE = ROOT / "testing/fixtures/hermes-webui-0.52.181"
|
|
AGENT_FIXTURE = ROOT / "testing/fixtures/hermes-agent"
|
|
ATLAS_PATCHER = DOCKERFILES / "hermes-webui-atlas-patch.py"
|
|
BRAND_PATCHER = DOCKERFILES / "hermes-webui-brand-patch.py"
|
|
MANIFEST_PATCHER = DOCKERFILES / "hermes-webui-manifest-patch.py"
|
|
RELEASE_PATCHER = DOCKERFILES / "hermes-webui-release-patch.py"
|
|
SMOKE = DOCKERFILES / "hermes-webui-smoke.py"
|
|
ASSETS = DOCKERFILES / "hermes-webui-assets"
|
|
MANIFEST = DOCKERFILES / "hermes-webui-manifest.json"
|
|
BRAND_CSS = DOCKERFILES / "hermes-webui-brand.css"
|
|
|
|
EXPECTED_HASHES = {
|
|
"hermes-agent.ico": "aefe65e6574e6f46d3382588f46c508e1b3f3b3c9ce3dec6d335403a5374add9",
|
|
"hermes-agent-192.png": "0e4102cc715372058dd6ab55e9cde2567e46fee5ab6557b564cdd212ccd2616f",
|
|
"hermes-agent-512.png": "6661e5ca0ecc690af213b9f85f961541e6d3d0e36946ede9d3c2c97dfbd3c23d",
|
|
}
|
|
|
|
|
|
def _load(path: Path, name: str):
|
|
spec = importlib.util.spec_from_file_location(name, path)
|
|
assert spec is not None and spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
def _patched_fixture(tmp_path: Path) -> Path:
|
|
target = tmp_path / "hermes-webui"
|
|
agent_target = tmp_path / "hermes-agent"
|
|
shutil.copytree(FIXTURE, target)
|
|
shutil.copytree(AGENT_FIXTURE, agent_target)
|
|
env = os.environ.copy()
|
|
env["HERMES_WEBUI_PATCH_ROOT"] = str(target)
|
|
env["HERMES_AGENT_PATCH_ROOT"] = str(agent_target)
|
|
env["HERMES_WEBUI_MANIFEST_SOURCE"] = str(MANIFEST)
|
|
for patcher in (ATLAS_PATCHER, BRAND_PATCHER, MANIFEST_PATCHER):
|
|
subprocess.run(
|
|
[sys.executable, str(patcher)],
|
|
cwd=ROOT,
|
|
env=env,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return target
|
|
|
|
|
|
def _png_size(path: Path) -> tuple[int, int]:
|
|
payload = path.read_bytes()
|
|
assert payload.startswith(b"\x89PNG\r\n\x1a\n")
|
|
assert payload[12:16] == b"IHDR"
|
|
return struct.unpack(">II", payload[16:24])
|
|
|
|
|
|
def test_canonical_icon_provenance_format_and_pwa_derivatives() -> None:
|
|
"""The supplied persona is tracked exactly and only resized for PWA use."""
|
|
for name, expected in EXPECTED_HASHES.items():
|
|
payload = (ASSETS / name).read_bytes()
|
|
assert hashlib.sha256(payload).hexdigest() == expected
|
|
|
|
ico = (ASSETS / "hermes-agent.ico").read_bytes()
|
|
reserved, image_type, count = struct.unpack_from("<HHH", ico)
|
|
assert (reserved, image_type, count) == (0, 1, 3)
|
|
sizes = set()
|
|
for index in range(count):
|
|
offset = 6 + index * 16
|
|
width, height, _colors, _reserved, planes, depth, length, start = (
|
|
struct.unpack_from("<BBBBHHII", ico, offset)
|
|
)
|
|
sizes.add((width or 256, height or 256))
|
|
assert (planes, depth) == (1, 32)
|
|
assert ico[start : start + 8] == b"\x89PNG\r\n\x1a\n"
|
|
assert start + length <= len(ico)
|
|
assert sizes == {(16, 16), (32, 32), (48, 48)}
|
|
assert _png_size(ASSETS / "hermes-agent-192.png") == (192, 192)
|
|
assert _png_size(ASSETS / "hermes-agent-512.png") == (512, 512)
|
|
|
|
provenance = (ASSETS / "SOURCE.md").read_text(encoding="utf-8")
|
|
assert "/opt/hermes/web/public/favicon.ico" in provenance
|
|
assert EXPECTED_HASHES["hermes-agent.ico"] in provenance
|
|
assert "LANCZOS" in provenance
|
|
|
|
|
|
def test_manifest_is_installable_scoped_and_uses_only_canonical_persona() -> None:
|
|
"""The app has both mandatory icon sizes and no remote or secret-bearing data."""
|
|
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
|
assert manifest["id"] == "./"
|
|
assert manifest["name"] == "Hermes Chat"
|
|
assert manifest["short_name"] == "Hermes"
|
|
assert manifest["start_url"].startswith("./")
|
|
assert manifest["scope"] == "./"
|
|
assert manifest["display"] == "standalone"
|
|
assert manifest["background_color"] == "#070A12"
|
|
assert manifest["theme_color"] == "#0D1420"
|
|
icons = manifest["icons"]
|
|
assert {(icon["sizes"], icon["type"]) for icon in icons} == {
|
|
("192x192", "image/png"),
|
|
("512x512", "image/png"),
|
|
}
|
|
assert {icon["src"] for icon in icons} == {
|
|
"static/hermes-agent-192.png",
|
|
"static/hermes-agent-512.png",
|
|
}
|
|
assert all(icon["purpose"] == "any" for icon in icons)
|
|
serialized = json.dumps(manifest).lower()
|
|
assert "http:" not in serialized and "https:" not in serialized
|
|
assert "secret" not in serialized and "token" not in serialized
|
|
|
|
|
|
def test_production_patchers_apply_title_icons_theme_and_cache_contract(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Exercise the shipped patchers against pinned upstream source fragments."""
|
|
target = _patched_fixture(tmp_path)
|
|
index = (target / "static/index.html").read_text(encoding="utf-8")
|
|
worker = (target / "static/sw.js").read_text(encoding="utf-8")
|
|
|
|
assert index.count("<title>Hermes Chat</title>") == 1
|
|
assert index.count('id="hermesBrandStyles"') == 1
|
|
assert 'href="static/hermes-agent.ico"' in index
|
|
assert 'sizes="192x192" href="static/hermes-agent-192.png"' in index
|
|
assert 'sizes="512x512" href="static/hermes-agent-512.png"' in index
|
|
assert '<meta name="apple-mobile-web-app-title" content="Hermes Chat">' in index
|
|
assert '<meta name="theme-color" content="#0D1420"' in index
|
|
assert 'id="appTitlebarTitle">Hermes Chat</span>' in index
|
|
assert '<img src="static/hermes-agent-192.png" alt="">' in index
|
|
assert (
|
|
'<img class="hermes-agent-portrait" '
|
|
'src="static/hermes-agent-512.png" alt="Hermes Agent">'
|
|
) in index
|
|
assert 'aria-label="Hermes caduceus"' not in index
|
|
assert "favicon.svg" not in index
|
|
assert "favicon-32.png" not in index
|
|
|
|
assert worker.count("'./static/hermes-brand.css' + VQ") == 1
|
|
for name in EXPECTED_HASHES:
|
|
assert worker.count(f"'./static/{name}'") == 1
|
|
assert "favicon.svg" not in worker
|
|
assert "favicon-32.png" not in worker
|
|
|
|
|
|
def test_brand_patch_rejects_upstream_drift_before_partial_success(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A changed pinned title/favicon context cannot silently ship partial branding."""
|
|
target = _patched_fixture(tmp_path)
|
|
index = target / "static/index.html"
|
|
index.write_text(
|
|
index.read_text(encoding="utf-8").replace(
|
|
"<title>Hermes Chat</title>", "<title>Upstream changed</title>", 1
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
env = os.environ.copy()
|
|
env["HERMES_WEBUI_PATCH_ROOT"] = str(target)
|
|
result = subprocess.run(
|
|
[sys.executable, str(BRAND_PATCHER)],
|
|
cwd=ROOT,
|
|
env=env,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert result.returncode != 0
|
|
assert "brand patch context changed" in result.stderr
|
|
|
|
|
|
def test_manifest_patch_rejects_drift_before_wholesale_replacement(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""The branded file cannot hide a changed manifest in the pinned image."""
|
|
target = tmp_path / "hermes-webui"
|
|
shutil.copytree(FIXTURE, target)
|
|
installed = target / "static/manifest.json"
|
|
installed.write_text(
|
|
installed.read_text(encoding="utf-8").replace(
|
|
'"name": "Hermes"', '"name": "Upstream drift"', 1
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
before = installed.read_bytes()
|
|
env = os.environ.copy()
|
|
env["HERMES_WEBUI_PATCH_ROOT"] = str(target)
|
|
env["HERMES_WEBUI_MANIFEST_SOURCE"] = str(MANIFEST)
|
|
result = subprocess.run(
|
|
[sys.executable, str(MANIFEST_PATCHER)],
|
|
cwd=ROOT,
|
|
env=env,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert result.returncode != 0
|
|
assert "pinned upstream SHA-256 mismatch" in result.stderr
|
|
assert installed.read_bytes() == before
|
|
|
|
|
|
class _SmokeResponse:
|
|
status = 200
|
|
|
|
def __init__(self, body: bytes, url: str) -> None:
|
|
self.body = body
|
|
self.url = url
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
return None
|
|
|
|
def read(self) -> bytes:
|
|
return self.body
|
|
|
|
def geturl(self) -> str:
|
|
return self.url
|
|
|
|
|
|
def test_server_smoke_resolves_served_manifest_icons_and_checks_root() -> None:
|
|
"""The image gate validates HTTP responses, not its own source files."""
|
|
module = _load(SMOKE, "hermes_webui_smoke_contract")
|
|
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
|
release_identity = "exp-v0.52.181-git-0123456789abcdef-build-13"
|
|
root = (
|
|
b'<link href="static/hermes-agent.ico">'
|
|
b'<link href="static/hermes-agent-192.png">'
|
|
b'<link href="static/hermes-agent-512.png">'
|
|
b'<link href="static/hermes-brand.css?v=reviewed">'
|
|
+ b"".join(f'<link href="{item}">'.encode() for item in module.HUX_LINKS)
|
|
+ (
|
|
"<script>window.__HERMES_WEBUI_BUNDLE_VERSION__='"
|
|
f"{release_identity}';</script>"
|
|
).encode()
|
|
)
|
|
calls = []
|
|
|
|
def open_fixture(request, *, timeout):
|
|
assert timeout == 5
|
|
url = request.full_url
|
|
calls.append(url)
|
|
path = urlsplit(url).path
|
|
if path == "/manifest.json":
|
|
return _SmokeResponse(
|
|
json.dumps(manifest).encode(),
|
|
"http://assets.hermes.test/pwa/manifest.json",
|
|
)
|
|
if path == "/":
|
|
return _SmokeResponse(root, url)
|
|
if path == "/api/settings":
|
|
return _SmokeResponse(
|
|
json.dumps(
|
|
{"webui_bundle_version": release_identity}
|
|
).encode(),
|
|
url,
|
|
)
|
|
if path == "/sw.js":
|
|
return _SmokeResponse(
|
|
f"const CACHE_NAME='hermes-shell-{release_identity}';".encode(),
|
|
url,
|
|
)
|
|
if path.startswith("/static/hux/"):
|
|
return _SmokeResponse(b"/* reviewed HUX asset */", url)
|
|
if path in (
|
|
"/pwa/static/hermes-agent-192.png",
|
|
"/pwa/static/hermes-agent-512.png",
|
|
"/static/hermes-agent-192.png",
|
|
):
|
|
return _SmokeResponse(module.PNG_MAGIC + b"fixture", url)
|
|
raise AssertionError(f"unexpected smoke URL: {url}")
|
|
|
|
result = module.smoke("http://hermes.test/", opener=open_fixture)
|
|
assert result["manifest"] == "http://assets.hermes.test/pwa/manifest.json"
|
|
assert result["icon_requests"] == 3
|
|
assert result["release_identity"] == release_identity
|
|
assert result["hux_assets"] == len(module.HUX_LINKS)
|
|
assert calls.count("http://assets.hermes.test/pwa/static/hermes-agent-192.png") == 2
|
|
assert "http://assets.hermes.test/pwa/static/hermes-agent-512.png" in calls
|
|
assert calls.count("http://hermes.test/static/hermes-agent-192.png") == 1
|
|
|
|
|
|
def test_server_smoke_rejects_a_client_server_release_mismatch() -> None:
|
|
"""A build cannot publish the permanently stale banner regression."""
|
|
module = _load(SMOKE, "hermes_webui_smoke_release_mismatch")
|
|
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
|
client = "exp-v0.52.181-git-0123456789abcdef-build-13"
|
|
root = (
|
|
b'<link href="static/hermes-agent.ico">'
|
|
b'<link href="static/hermes-agent-192.png">'
|
|
b'<link href="static/hermes-agent-512.png">'
|
|
b'<link href="static/hermes-brand.css?v=reviewed">'
|
|
+ b"".join(f'<link href="{item}">'.encode() for item in module.HUX_LINKS)
|
|
+ (
|
|
"<script>window.__HERMES_WEBUI_BUNDLE_VERSION__='"
|
|
f"{client}';</script>"
|
|
).encode()
|
|
)
|
|
|
|
def open_mismatch(request, *, timeout):
|
|
assert timeout == 5
|
|
url = request.full_url
|
|
path = urlsplit(url).path
|
|
if path == "/manifest.json":
|
|
return _SmokeResponse(json.dumps(manifest).encode(), url)
|
|
if path == "/":
|
|
return _SmokeResponse(root, url)
|
|
if path == "/api/settings":
|
|
return _SmokeResponse(
|
|
json.dumps(
|
|
{"webui_bundle_version": client + "-different"}
|
|
).encode(),
|
|
url,
|
|
)
|
|
if path.startswith("/static/hux/"):
|
|
return _SmokeResponse(b"/* reviewed HUX asset */", url)
|
|
if path.endswith(".png"):
|
|
return _SmokeResponse(module.PNG_MAGIC + b"fixture", url)
|
|
raise AssertionError(f"unexpected smoke URL: {url}")
|
|
|
|
with pytest.raises(RuntimeError, match="identities do not match"):
|
|
module.smoke("http://hermes.test/", opener=open_mismatch)
|
|
|
|
|
|
def test_server_smoke_rejects_unbranded_served_manifest() -> None:
|
|
"""A successful health check cannot mask the upstream PWA identity."""
|
|
module = _load(SMOKE, "hermes_webui_smoke_unbranded")
|
|
|
|
def open_upstream(request, *, timeout):
|
|
assert timeout == 5
|
|
payload = (FIXTURE / "static/manifest.json").read_bytes()
|
|
return _SmokeResponse(payload, request.full_url)
|
|
|
|
with pytest.raises(RuntimeError, match="not branded"):
|
|
module.smoke("http://hermes.test/", opener=open_upstream)
|
|
|
|
|
|
def test_brand_css_is_accessible_dark_and_reduced_motion_aware() -> None:
|
|
"""Identity colors retain system controls and disable cosmetic motion."""
|
|
css = BRAND_CSS.read_text(encoding="utf-8")
|
|
dark = css.split(":root.dark {", 1)[1]
|
|
assert "color-scheme: dark" in dark
|
|
assert "--bg: #070a12" in dark
|
|
assert "--text: #e8f1f4" in dark
|
|
assert "--accent: #48cfcc" in dark
|
|
assert "--voice-accent: 72, 207, 204" in css
|
|
assert "--voice-accent-secondary: 76, 164, 205" in css
|
|
assert "--hermes-violet: #9d8ee0" in dark
|
|
assert "--hermes-grid: rgba(72, 207, 204, 0.025)" in dark
|
|
assert ".empty-logo .hermes-agent-portrait" in css
|
|
assert "width: 112px" in css
|
|
assert ":root.dark .messages-shell" in css
|
|
assert "background-size: 32px 32px, 32px 32px, auto, auto" in css
|
|
assert "background-repeat: repeat, repeat, no-repeat, no-repeat" in css
|
|
messages = css.rsplit(":root.dark .messages {", 1)[1].split("}", 1)[0]
|
|
assert "background-image: none" in messages
|
|
assert "radial-gradient" not in messages
|
|
assert ":root.dark .session-item.active" in css
|
|
assert ":root.dark .suggestion:focus-visible" in css
|
|
assert "@media (prefers-reduced-motion: reduce)" in css
|
|
reduced = css.split("@media (prefers-reduced-motion: reduce)", 1)[1]
|
|
assert "transition: none !important" in reduced
|
|
assert "transform: none !important" in reduced
|
|
assert "animation:" not in css
|
|
# The composer reserves the device bottom safe-area (home indicator / gesture
|
|
# bar) so its control row is not hidden behind it, additive with the app's
|
|
# keyboard inset. env() is 0 on inset-less devices, so this is desktop-inert.
|
|
assert ".composer-wrap" in css
|
|
composer = css.rsplit(".composer-wrap {", 1)[1].split("}", 1)[0]
|
|
assert "env(safe-area-inset-bottom, 0px)" in composer
|
|
assert "var(--keyboard-bottom-inset, 0px)" in composer
|
|
|
|
|
|
def test_base_patch_and_dockerfile_opt_into_edge_to_edge_safe_area() -> None:
|
|
"""viewport-fit=cover is required for env(safe-area-inset-*) to carry real
|
|
values; the base patch adds it and the Dockerfile verifies it landed."""
|
|
base_patch = (DOCKERFILES / "hermes-webui-base-patch.py").read_text(encoding="utf-8")
|
|
assert "viewport-fit=cover" in base_patch
|
|
assert "user-scalable=no, viewport-fit=cover" in base_patch
|
|
dockerfile = (DOCKERFILES / "Dockerfile.hermes-webui").read_text(encoding="utf-8")
|
|
assert "grep -Fq 'user-scalable=no, viewport-fit=cover'" in dockerfile
|
|
|
|
|
|
def test_empty_state_uses_canonical_character_without_inline_staff(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""The new-chat identity reuses the established full character artwork."""
|
|
index = (_patched_fixture(tmp_path) / "static/index.html").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
empty_state = index.split('<div class="empty-state" id="emptyState">', 1)[1]
|
|
empty_state = empty_state.split('<div class="messages-inner"', 1)[0]
|
|
assert empty_state.count('class="hermes-agent-portrait"') == 1
|
|
assert empty_state.count('src="static/hermes-agent-512.png"') == 1
|
|
assert "<svg" not in empty_state.split('<h2 data-i18n="empty_title">', 1)[0]
|
|
|
|
|
|
def test_release_patch_gives_every_shell_url_an_immutable_build_token(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Atlas releases share one server/client identity and browser cache key."""
|
|
target = _patched_fixture(tmp_path)
|
|
env = os.environ.copy()
|
|
env["HERMES_WEBUI_PATCH_ROOT"] = str(target)
|
|
env["HERMES_WEBUI_RELEASE_ID"] = "git-0123456789abcdef-build-11"
|
|
subprocess.run(
|
|
[sys.executable, str(RELEASE_PATCHER)],
|
|
cwd=ROOT,
|
|
env=env,
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
index = (target / "static/index.html").read_text(encoding="utf-8")
|
|
worker = (target / "static/sw.js").read_text(encoding="utf-8")
|
|
routes = (target / "api/routes.py").read_text(encoding="utf-8")
|
|
panels = (target / "static/panels.js").read_text(encoding="utf-8")
|
|
token = "__WEBUI_VERSION__-git-0123456789abcdef-build-11"
|
|
assert token in index
|
|
assert token in worker
|
|
assert not re.search(r"__WEBUI_VERSION__(?!-git-0123456789abcdef-build-11)", index)
|
|
assert not re.search(r"__WEBUI_VERSION__(?!-git-0123456789abcdef-build-11)", worker)
|
|
assert (
|
|
'settings["webui_bundle_version"] = '
|
|
'f"{WEBUI_VERSION}-git-0123456789abcdef-build-11"'
|
|
) in routes
|
|
assert (
|
|
"settings.webui_bundle_version||settings.webui_version"
|
|
) in panels
|
|
assert "if(client===server) return;" not in panels
|
|
assert "if(banner) banner.style.display='none';" in panels
|
|
assert "if(_isBannerVisible()) return;" not in panels
|
|
assert (
|
|
"if(_isBannerVisible()){ clearInterval(_pollTimer);"
|
|
not in panels
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("release_id", ["", "../escape", "UPPERCASE", "a" * 129])
|
|
def test_release_patch_rejects_missing_or_unsafe_tokens(
|
|
tmp_path: Path, release_id: str
|
|
) -> None:
|
|
"""A malformed build token cannot silently collapse browser cache keys."""
|
|
target = _patched_fixture(tmp_path)
|
|
env = os.environ.copy()
|
|
env["HERMES_WEBUI_PATCH_ROOT"] = str(target)
|
|
env["HERMES_WEBUI_RELEASE_ID"] = release_id
|
|
completed = subprocess.run(
|
|
[sys.executable, str(RELEASE_PATCHER)],
|
|
cwd=ROOT,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert completed.returncode != 0
|
|
assert "safe immutable release token" in completed.stderr
|
|
|
|
|
|
def test_release_patch_rejects_upstream_without_version_markers(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Pinned upstream drift fails the image build before publication."""
|
|
target = _patched_fixture(tmp_path)
|
|
index = target / "static/index.html"
|
|
index.write_text(
|
|
index.read_text(encoding="utf-8").replace("__WEBUI_VERSION__", "fixed"),
|
|
encoding="utf-8",
|
|
)
|
|
env = os.environ.copy()
|
|
env["HERMES_WEBUI_PATCH_ROOT"] = str(target)
|
|
env["HERMES_WEBUI_RELEASE_ID"] = "git-0123456789abcdef-build-11"
|
|
completed = subprocess.run(
|
|
[sys.executable, str(RELEASE_PATCHER)],
|
|
cwd=ROOT,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
assert completed.returncode != 0
|
|
assert "release patch context changed" in completed.stderr
|
|
|
|
|
|
def test_dockerfile_copies_and_verifies_every_tracked_brand_asset() -> None:
|
|
"""The immutable image, not a runtime coordinator path, owns PWA assets."""
|
|
dockerfile = (DOCKERFILES / "Dockerfile.hermes-webui").read_text(encoding="utf-8")
|
|
assert "/opt/hermes/web/public/favicon.ico" not in dockerfile
|
|
assert "COPY dockerfiles/hermes-webui-brand-patch.py" in dockerfile
|
|
assert "python /tmp/hermes-webui-brand-patch.py" in dockerfile
|
|
assert "COPY dockerfiles/hermes-webui-manifest-patch.py" in dockerfile
|
|
assert "python /tmp/hermes-webui-manifest-patch.py" in dockerfile
|
|
assert "COPY dockerfiles/hermes-webui-release-patch.py" in dockerfile
|
|
assert "python /tmp/hermes-webui-release-patch.py" in dockerfile
|
|
assert 'settings["webui_bundle_version"]' in dockerfile
|
|
assert (
|
|
"settings.webui_bundle_version||settings.webui_version"
|
|
in dockerfile
|
|
)
|
|
assert (
|
|
"COPY dockerfiles/hermes-webui-manifest.json /tmp/hermes-webui-manifest.json"
|
|
in dockerfile
|
|
)
|
|
assert (
|
|
"COPY dockerfiles/hermes-webui-manifest.json /opt/hermes-webui/static/manifest.json"
|
|
not in dockerfile
|
|
)
|
|
assert "python /tmp/hermes-webui-smoke.py http://127.0.0.1:18787/" in dockerfile
|
|
assert "COPY dockerfiles/hermes-webui-brand.css" in dockerfile
|
|
for name, digest in EXPECTED_HASHES.items():
|
|
assert f"COPY dockerfiles/hermes-webui-assets/{name}" in dockerfile
|
|
assert digest in dockerfile
|