78e5717f77
A name, a tagline, a logo, a favicon and the launcher icons derived from it; the Middle-earth strings as data; themes as token sets; and a stylesheet for what none of that reaches. All four are on one page, in one settings group. The snapshot is a Jinja global over a process-level cache, because render() has no session and four render paths never reach it at all -- the sign-in page, the error pages, the offline page and the SSE fragments. A context value would have had to be threaded through every one and would still have missed those. It being a global is also what lets mark() branch on an uploaded logo without any of its six call sites learning about branding; the macro that renders the sidebar link is called brandlink now, because a macro imported as `brand` shadows the global for the whole template and took out every page at once. Defaults in code and overrides in the database, as the prompt fragments do, with one difference stated in the module: an empty fragment means off, an empty flavour string means the shipped wording. And blanked rather than dropped -- settings_store.update merges, so an omitted key leaves what was stored last time and "I typed the default back in" would store something different from "I changed nothing". A custom theme sets a handful of tokens and inherits the rest, and the inheritance is a CSS fact: tokens.css matches [data-base="shire"] as well as [data-theme="shire"], so a custom light theme lands on parchment rather than four light colours on near-black. Values are validated on read rather than on save, because a theme written straight into the settings table still has to produce a stylesheet that parses -- a `}` in a value ends the rule and silently breaks every rule after it. The soft variants are derived from the accent, or a changed accent leaves focus rings in the old hue and reads as half-working. /branding.css is a route, not an inline block: an external stylesheet has no HTML context to escape from. The link carries a content hash, so a save is not left to the browser's cache, and it is deliberately outside the service worker's precache list, which is versioned by the release. The instance name moved off /admin/general rather than being duplicated there. An upgrade keeps it: the general row is read as a seed exactly while the branding row has never mentioned the name, which is `key in row` and not `row[key] is truthy` -- the two read alike would resurrect the old name underneath a cleared one. The theme list stops being a hard-coded pair in five places. Every failure mode in that area is silent, so it is driven under a DOM stub as well as tested. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
168 lines
6.2 KiB
Python
168 lines
6.2 KiB
Python
"""Installing as an app, and the composer's single send/stop button."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from lembas.services import settings_store
|
|
from lembas.web.templating import STATIC_DIR
|
|
|
|
|
|
# --- Manifest ----------------------------------------------------------------
|
|
def test_the_manifest_is_readable_when_signed_out(client: TestClient):
|
|
"""A browser fetches the manifest outside any page's session."""
|
|
response = client.get("/manifest.webmanifest")
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"].startswith("application/manifest+json")
|
|
|
|
|
|
def test_the_manifest_carries_the_instance_name(client: TestClient, db, registered):
|
|
"""The name moved to /admin/customization with the rest of the identity."""
|
|
client.post(
|
|
"/admin/customization/identity",
|
|
data={"instance_name": "Rivendell", "tagline": ""},
|
|
follow_redirects=False,
|
|
)
|
|
assert client.get("/manifest.webmanifest").json()["name"] == "Rivendell"
|
|
|
|
|
|
def test_the_manifest_offers_a_maskable_icon(client: TestClient):
|
|
"""Without one, Android crops the corners off the wafer."""
|
|
icons = client.get("/manifest.webmanifest").json()["icons"]
|
|
assert any(icon["purpose"] == "maskable" for icon in icons)
|
|
assert any(icon["sizes"] == "512x512" and icon["purpose"] == "any" for icon in icons)
|
|
|
|
|
|
def test_every_manifest_icon_exists(client: TestClient):
|
|
for icon in client.get("/manifest.webmanifest").json()["icons"]:
|
|
assert client.get(icon["src"]).status_code == 200, icon["src"]
|
|
|
|
|
|
def test_the_manifest_starts_at_the_chat(client: TestClient):
|
|
payload = client.get("/manifest.webmanifest").json()
|
|
assert payload["start_url"] == "/chat"
|
|
assert payload["scope"] == "/"
|
|
assert payload["display"] == "standalone"
|
|
|
|
|
|
# --- Service worker ----------------------------------------------------------
|
|
def test_the_worker_is_served_from_the_root(client: TestClient):
|
|
"""A worker under /static/js/ would have scope /static/js/ and control
|
|
nothing."""
|
|
response = client.get("/sw.js")
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"].startswith("text/javascript")
|
|
|
|
|
|
def test_the_worker_is_never_cached(client: TestClient):
|
|
"""A stale worker keeps serving a stale cache."""
|
|
assert "no-store" in client.get("/sw.js").headers["cache-control"]
|
|
|
|
|
|
def test_the_worker_leaves_the_api_alone():
|
|
"""The reply stream, the unread poll and attachment downloads all live
|
|
under /api/. A cached response on any of them is at best stale."""
|
|
source = (STATIC_DIR / "js" / "sw.js").read_text()
|
|
assert '"/api/"' in source
|
|
assert "text/event-stream" in source
|
|
|
|
|
|
def test_every_precached_asset_exists(client: TestClient):
|
|
"""addAll is all-or-nothing in most implementations, and a missing entry is
|
|
invisible until someone opens the developer tools."""
|
|
source = (STATIC_DIR / "js" / "sw.js").read_text()
|
|
shell = source.split("var SHELL = [", 1)[1].split("];", 1)[0]
|
|
paths = [line.strip().strip('",') for line in shell.splitlines() if '"' in line]
|
|
|
|
assert paths
|
|
for path in paths:
|
|
assert client.get(path).status_code == 200, path
|
|
|
|
|
|
def test_the_offline_page_stands_on_its_own(client: TestClient):
|
|
"""Cached at install time, so it must render with no user and no chats."""
|
|
response = client.get("/offline")
|
|
assert response.status_code == 200
|
|
assert 'class="sidebar' not in response.text
|
|
assert 'id="thread' not in response.text
|
|
|
|
|
|
def test_the_page_links_the_manifest_and_the_apple_icon(client: TestClient, registered):
|
|
page = client.get("/chat").text
|
|
assert 'rel="manifest"' in page
|
|
assert 'rel="apple-touch-icon"' in page
|
|
assert 'name="theme-color"' in page
|
|
|
|
|
|
# --- Send and Stop -----------------------------------------------------------
|
|
def test_the_hidden_attribute_wins_over_component_styles():
|
|
"""`.btn` is display: inline-flex, which beats the browser's own
|
|
`[hidden] { display: none }`. Without this rule a button hidden from
|
|
JavaScript stays on screen -- which is how Stop came to sit permanently
|
|
beside Send."""
|
|
css = (STATIC_DIR / "css" / "app.css").read_text()
|
|
assert "[hidden]" in css
|
|
assert "display: none !important" in css
|
|
|
|
|
|
def test_the_composer_has_exactly_one_send_button(client: TestClient, db, registered):
|
|
"""One button that becomes Stop, not two that take turns being hidden."""
|
|
_add_a_model(db)
|
|
page = client.get("/chat").text
|
|
assert page.count("data-composer-action") == 1
|
|
|
|
|
|
def test_the_send_button_carries_both_icons(client: TestClient, db, registered):
|
|
"""Rendered together and chosen in CSS, so the swap costs no layout and
|
|
cannot flash an empty button."""
|
|
_add_a_model(db)
|
|
page = client.get("/chat").text
|
|
assert "composer__icon--send" in page
|
|
assert "composer__icon--stop" in page
|
|
|
|
|
|
def test_the_composer_starts_in_the_send_state(client: TestClient, db, registered):
|
|
_add_a_model(db)
|
|
assert 'data-composer-action="send"' in client.get("/chat").text
|
|
|
|
|
|
def _add_a_model(db):
|
|
from lembas.db.models import Connection, Model
|
|
|
|
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
|
|
db.add(connection)
|
|
db.commit()
|
|
db.add(Model(connection_id=connection.id, model_id="m"))
|
|
db.commit()
|
|
|
|
|
|
# --- The icons themselves ----------------------------------------------------
|
|
def test_the_generated_icons_are_committed():
|
|
"""They come from scripts/build_artwork.py and are committed like the SVGs;
|
|
the running application has no rasteriser."""
|
|
for name in (
|
|
"icon-192.png",
|
|
"icon-512.png",
|
|
"icon-maskable-512.png",
|
|
"apple-touch-icon-180.png",
|
|
):
|
|
path = Path(STATIC_DIR) / "img" / name
|
|
assert path.exists(), name
|
|
assert path.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n", name
|
|
|
|
|
|
def test_the_mic_appears_only_when_dictation_is_configured(
|
|
client: TestClient, db, registered
|
|
):
|
|
_add_a_model(db)
|
|
assert "data-mic" not in client.get("/chat").text
|
|
|
|
settings_store.update(
|
|
db,
|
|
{"stt_enabled": True, "stt_base_url": "http://stt"},
|
|
key=settings_store.AUDIO,
|
|
)
|
|
assert "data-mic" in client.get("/chat").text
|