2026-08-23 01:08:35 +00:00
|
|
|
"""Canonical icon, PWA, and fail-closed Hermes WebUI identity contracts."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import hashlib
|
2026-08-23 04:42:08 +00:00
|
|
|
import importlib.util
|
2026-08-23 01:08:35 +00:00
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
from pathlib import Path
|
2026-08-23 19:22:26 -03:00
|
|
|
import re
|
2026-08-23 01:08:35 +00:00
|
|
|
import shutil
|
|
|
|
|
import struct
|
|
|
|
|
import subprocess
|
|
|
|
|
import sys
|
2026-08-23 04:42:08 +00:00
|
|
|
from urllib.parse import urlsplit
|
|
|
|
|
|
|
|
|
|
import pytest
|
2026-08-23 01:08:35 +00:00
|
|
|
|
|
|
|
|
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"
|
2026-08-23 04:42:08 +00:00
|
|
|
MANIFEST_PATCHER = DOCKERFILES / "hermes-webui-manifest-patch.py"
|
2026-08-23 19:22:26 -03:00
|
|
|
RELEASE_PATCHER = DOCKERFILES / "hermes-webui-release-patch.py"
|
2026-08-23 04:42:08 +00:00
|
|
|
SMOKE = DOCKERFILES / "hermes-webui-smoke.py"
|
2026-08-23 01:08:35 +00:00
|
|
|
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",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 04:42:08 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 01:08:35 +00:00
|
|
|
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)
|
2026-08-23 04:42:08 +00:00
|
|
|
env["HERMES_WEBUI_MANIFEST_SOURCE"] = str(MANIFEST)
|
|
|
|
|
for patcher in (ATLAS_PATCHER, BRAND_PATCHER, MANIFEST_PATCHER):
|
2026-08-23 01:08:35 +00:00
|
|
|
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
|
2026-08-23 17:13:05 -03:00
|
|
|
assert (
|
|
|
|
|
'<img class="hermes-agent-portrait" '
|
2026-08-23 19:22:26 -03:00
|
|
|
'src="static/hermes-agent-512.png" alt="Hermes Agent">'
|
2026-08-23 17:13:05 -03:00
|
|
|
) in index
|
|
|
|
|
assert 'aria-label="Hermes caduceus"' not in index
|
2026-08-23 01:08:35 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 04:42:08 +00:00
|
|
|
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"))
|
|
|
|
|
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">'
|
|
|
|
|
)
|
|
|
|
|
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 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 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_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)
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 01:08:35 +00:00
|
|
|
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
|
2026-08-23 17:13:05 -03:00
|
|
|
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" in css
|
|
|
|
|
assert "background-size: 32px 32px, 32px 32px, auto, auto" in css
|
|
|
|
|
assert ":root.dark .session-item.active" in css
|
|
|
|
|
assert ":root.dark .suggestion:focus-visible" in css
|
2026-08-23 01:08:35 +00:00
|
|
|
assert "@media (prefers-reduced-motion: reduce)" in css
|
|
|
|
|
reduced = css.split("@media (prefers-reduced-motion: reduce)", 1)[1]
|
|
|
|
|
assert "transition: none !important" in reduced
|
2026-08-23 17:13:05 -03:00
|
|
|
assert "transform: none !important" in reduced
|
2026-08-23 01:08:35 +00:00
|
|
|
assert "animation:" not in css
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 17:13:05 -03:00
|
|
|
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
|
2026-08-23 19:22:26 -03:00
|
|
|
assert empty_state.count('src="static/hermes-agent-512.png"') == 1
|
2026-08-23 17:13:05 -03:00
|
|
|
assert "<svg" not in empty_state.split('<h2 data-i18n="empty_title">', 1)[0]
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 19:22:26 -03:00
|
|
|
def test_release_patch_gives_every_shell_url_an_immutable_build_token(
|
|
|
|
|
tmp_path: Path,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Atlas image releases never share a 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")
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
|
|
|
|
|
|
2026-08-23 01:08:35 +00:00
|
|
|
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
|
2026-08-23 04:42:08 +00:00
|
|
|
assert "COPY dockerfiles/hermes-webui-manifest-patch.py" in dockerfile
|
|
|
|
|
assert "python /tmp/hermes-webui-manifest-patch.py" in dockerfile
|
2026-08-23 19:22:26 -03:00
|
|
|
assert "COPY dockerfiles/hermes-webui-release-patch.py" in dockerfile
|
|
|
|
|
assert "python /tmp/hermes-webui-release-patch.py" in dockerfile
|
2026-08-23 04:42:08 +00:00
|
|
|
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
|
2026-08-23 01:08:35 +00:00
|
|
|
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
|