Files
brain-of-reese/tests/e2e/test_dark_tech_theme.py
T
ducoterra 7fce6572d0
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s
feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
Single consolidated commit for four completed, validated phases (77, 78,
79, 80). The pipeline run left all work uncommitted because the harness
commits only with PHASE_COMMIT=1 while child executors are forbidden from
committing; the phases themselves all passed validation and moved to
.agents/phases/complete/.

Phase 77 — navbar view refresh
- router.js dispatches bor:view-refresh on re-show / active re-click /
  popstate (gated on wasMounted; first show and boot exempt)
- History / RAG / Sources / Tuning re-fetch on refresh (admin branch);
  Chat deliberately excluded (stream survival)
- History "Refresh" button (admin-only, in-flight disable + status line)
- New story suite tests/e2e/test_navbar_refresh.py (7 tests)

Phase 78 — static background
- Removed the animated glow layers; static 44px grid over the flat --bg
  canvas; default and reduced-motion renders byte-identical
- Updated background/theme E2E suites; removed bg-glow test pins

Phase 79 — API tokens
- api_tokens model + migration 0012; hash-only token service
- Admin tokens API + Tokens admin view; POST /api/token-auth;
  live-revoking require_user on chat / suggestions / document content
- Frontend token gate with localStorage cache; anonymous E2E suites
  migrated to token login
- New story suite tests/e2e/test_api_tokens.py (9 tests)

Phase 80 — history suggestion chips
- last_questions() endpoint with SEED fallback; startNewChat() refetch
- Seed-semantics docs (config.py, .env.example, README)
- Integration state matrix + E2E suite rewritten to the 4 chip states

Also included: phase-76 report artifacts and the repo restore-test-db
skill (previously untracked), scripts/* ruff fixes from phase 77.

Final gate state (phase 80 final pass, covers everything above):
- uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99%
- uv run ruff check . && uv run pyright → clean, 0 errors
- Per-phase story E2E suites green in isolation
2026-09-07 12:39:01 -04:00

397 lines
16 KiB
Python

"""Phase 08 E2E (Playwright): dark tech theme — palette, no emoji, static
background, reduced motion, behavior intact, all assets local.
Story: ``.agents/user_stories/dark-tech-theme.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_dark_tech_theme.py -v --no-cov
Test → story mapping (Playwright Mapping Rule):
1. ``test_dark_palette_and_contrast`` — dark page background on both pages;
computed ink-on-surface and brand-button text/background pairs >= 4.5:1
(same contrast helper as Phase 07).
2. ``test_no_emoji_in_chrome`` — neither page's ``innerText`` nor raw
``outerHTML`` contains any emoji code point.
3. ``test_static_background`` — the background is fully STATIC (phase
78, owner direction: the animated glow layers were removed as too
resource-intensive): the grid (``body::before``) is a static
texture (fixed, pointer-events none, image still painted,
``animationName: none``) and the three glow pseudo-layers
(``body::after`` + the two ``html`` pseudo-layers) are deleted —
no background-image, no animation, no box.
4. ``test_reduced_motion_honored`` — a context with
``reduced_motion="reduce"`` → the grid stays painted and static
(``animation-name: none``) and the deleted glow pseudo-layers carry
no image or animation.
5. ``test_behavior_unchanged_smoke`` — on-topic question streams an answer
+ a source chip + the send button recovers (state machine intact under
the new skin).
6. ``test_all_assets_local`` — every ``script[src]``/``link[href]`` on both
pages is same-origin or ``data:`` (no CDN).
"""
from __future__ import annotations
import asyncio
import re
import unicodedata
from pathlib import Path
from threading import Thread
from typing import Any
from playwright.sync_api import Browser, Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
QUESTION = "How is my Kubernetes cluster set up?"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
# Emoji code points banned from UI chrome (phase 08) — mirrors the
# integration guard in tests/integration/test_api.py.
EMOJI_RANGES = (
(0x1F300, 0x1FAFF), # symbols & pictographs (🧠 🧑 👋 📂)
(0x2600, 0x27BF), # misc symbols + dingbats (⚠)
(0x2B00, 0x2BFF), # misc symbols & arrows
)
EMOJI_SINGLETONS = frozenset({0xFE0F, 0x200D}) # VS-16, ZWJ
PREVIOUS_GLYPHS = "\U0001F9E0\U0001F9D1\U0001F44B\U0001F4C2\U000026A0"
def _find_emoji(text: str) -> list[str]:
"""Offending characters (with duplicates) in ``text`` — empty if clean."""
hits: list[str] = []
for ch in text:
cp = ord(ch)
if (
any(lo <= cp <= hi for lo, hi in EMOJI_RANGES)
or cp in EMOJI_SINGLETONS
or ch in PREVIOUS_GLYPHS
):
hits.append(ch)
return hits
# --------------------------------------------------------------------------
# KB seeding (same pattern as the earlier story suites)
# --------------------------------------------------------------------------
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test thread,
so ``asyncio.run`` cannot be called directly from a test body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _seed_kb(mock_port: int) -> ImportSummary:
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
summary = _run_in_thread(_import_fixtures(mock_port))
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
return summary
# --------------------------------------------------------------------------
# WCAG 2.1 contrast (same helper as Phase 07, test_responsive_polish.py)
# --------------------------------------------------------------------------
def _rgb(value: str) -> tuple[int, int, int]:
value = value.strip()
hex_match = re.match(r"^#([0-9a-f]{6})$", value, re.IGNORECASE)
if hex_match:
h = hex_match.group(1)
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
match = re.match(r"^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)", value)
assert match, f"unparsable color: {value!r}"
return int(match.group(1)), int(match.group(2)), int(match.group(3))
def _rel_luminance(rgb: tuple[int, int, int]) -> float:
def chan(c: int) -> float:
s = c / 255
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
r, g, b = (chan(c) for c in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast_ratio(fg: str, bg: str) -> float:
l1, l2 = _rel_luminance(_rgb(fg)), _rel_luminance(_rgb(bg))
if l1 < l2:
l1, l2 = l2, l1
return (l1 + 0.05) / (l2 + 0.05)
def _assert_aa(pair: Any, label: str) -> None:
fg, bg = str(pair[0]), str(pair[1])
ratio = contrast_ratio(fg, bg)
assert ratio >= 4.5, f"contrast {label}: {fg} on {bg} = {ratio:.2f}:1 (< 4.5:1)"
# --------------------------------------------------------------------------
# Tests (story → test mapping, see module docstring)
# --------------------------------------------------------------------------
def test_dark_palette_and_contrast(
page: Page, app_url: str, db_ready: None
) -> None:
"""AC1: both pages are dark; the sampled text/background pairs compute
>= 4.5:1 from live computed styles (not eyeballed)."""
for path in ("/", "/sources.html"):
page.goto(f"{app_url}{path}")
# The visible page background is the <html> canvas (rgb(15, 10, 10)
# = #0f0a0a — the 2026-08-28 dark-red rebrand; it was the phase-08
# #0a0e17 canvas). <body> itself stays transparent so the
# z-index:-1 grid layer (test_static_background) is not painted
# over.
bg = page.evaluate(
"() => getComputedStyle(document.documentElement).backgroundColor"
)
assert bg == "rgb(15, 10, 10)", f"expected the dark page bg on {path}, got {bg}"
body_bg = page.evaluate("() => getComputedStyle(document.body).backgroundColor")
assert body_bg == "rgba(0, 0, 0, 0)", (
f"{path}: body must stay transparent (the background layers need to show)"
)
# Chat page pairs. Phase 79: the onboarding chips are drawn from
# /api/suggestions (require_user-gated) — the visitor signs in
# first; the palette pins are auth-independent.
login(page, app_url, next="/")
page.locator("#suggestions .suggestion-chip").first.wait_for(state="visible", timeout=10_000)
pairs = page.evaluate(
"""() => {
const cs = (sel, prop) => getComputedStyle(document.querySelector(sel))[prop];
return {
ink_on_surface: [
cs(".empty-state-title", "color"),
cs(".empty-state", "backgroundColor"),
],
ink_soft_on_surface: [
cs(".empty-state-sub", "color"),
cs(".empty-state", "backgroundColor"),
],
button: [
cs(".send-btn", "color"), cs(".send-btn", "backgroundColor"),
],
chip: [
cs(".suggestion-chip", "color"),
cs(".suggestion-chip", "backgroundColor"),
],
};
}"""
)
_assert_aa(pairs["ink_on_surface"], "ink on surface (chat)")
_assert_aa(pairs["ink_soft_on_surface"], "ink-soft on surface (chat sub)")
_assert_aa(pairs["button"], "dark ink on brand (send button)")
_assert_aa(pairs["chip"], "chip ink on chip bg (chat)")
# Sources page pairs. (Phase 16: the stat cards are admin-only —
# a real form login first.)
login(page, app_url, next="/sources.html")
page.locator(".stat-card").first.wait_for(state="visible", timeout=10_000)
pairs = page.evaluate(
"""() => {
const cs = (sel, prop) => getComputedStyle(document.querySelector(sel))[prop];
return {
ink_soft_on_surface: [
cs(".stat-label", "color"), cs(".stat-card", "backgroundColor"),
],
active_nav: [
cs(".nav-link.is-active", "color"),
cs(".nav-link.is-active", "backgroundColor"),
],
stat_value: [
cs(".stat-value", "color"), cs(".stat-card", "backgroundColor"),
],
};
}"""
)
_assert_aa(pairs["ink_soft_on_surface"], "ink-soft on surface (stat labels)")
_assert_aa(pairs["active_nav"], "dark ink on brand (active nav)")
_assert_aa(pairs["stat_value"], "brand-ink on surface (stat values)")
def test_no_emoji_in_chrome(page: Page, app_url: str, db_ready: None) -> None:
"""AC2: zero emoji in UI chrome — rendered text AND raw markup
(favicon, brand mark, avatars, banner icon, empty-state glyphs) on both
pages."""
for path in ("/", "/sources.html"):
page.goto(f"{app_url}{path}")
page.wait_for_load_state("networkidle")
inner_text = page.evaluate("() => document.body.innerText")
outer_html = page.evaluate("() => document.documentElement.outerHTML")
for label, body in (("innerText", inner_text), ("outerHTML", outer_html)):
hits = _find_emoji(body)
named = [
f"U+{ord(ch):04X} {unicodedata.name(ch, '?')}" for ch in set(hits)
]
assert not hits, f"emoji in {label} on {path}: {sorted(named)}"
def test_static_background(page: Page, app_url: str, db_ready: None) -> None:
"""AC3: the background is fully static, pure CSS, zero JS, and can
never block or dim content. Phase-78 contract (owner direction,
TODO.md L4: the animated background was removed as too
resource-intensive) supersedes the phase-25 recipe: the grid
(``body::before``) is a STATIC texture (``animationName: none``,
image still painted, fixed, pointer-events:none), and the three
glow spots (``body::after`` + the two ``html`` pseudo-layers) are
DELETED — no background-image, no animation, no box."""
page.goto(app_url)
report = page.evaluate(
"""() => {
const pick = (el, pseudo) => {
const cs = getComputedStyle(el, pseudo);
return {
image: cs.backgroundImage,
anim: cs.animationName,
position: cs.position,
pointerEvents: cs.pointerEvents,
};
};
return {
grid: pick(document.body, "::before"),
glowA: pick(document.body, "::after"),
glowB: pick(document.documentElement, "::before"),
glowC: pick(document.documentElement, "::after"),
};
}"""
)
grid = report["grid"]
assert grid["image"] != "none", (
"the static grid must carry a background image"
)
assert grid["anim"] == "none", f"the grid must be static (got {grid['anim']!r})"
assert grid["position"] == "fixed", "the grid must be position:fixed"
assert grid["pointerEvents"] == "none", "the grid must not intercept input"
# The phase-78 recipe: the animated glow layers are gone entirely.
for key in ("glowA", "glowB", "glowC"):
info = report[key]
assert info["image"] == "none", (
f"{key}: the deleted glow layer must carry no background image "
f"(got {info['image']!r})"
)
assert info["anim"] == "none", (
f"{key}: the deleted glow layer must not animate (got {info['anim']!r})"
)
def test_reduced_motion_honored(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC4: with prefers-reduced-motion: reduce the background is
already fully static (phase 78 deleted the animated glow layers) —
the grid stays painted and static (``animation-name: none``) and
the deleted glow pseudo-layers carry no image or animation."""
context = browser.new_context(
reduced_motion="reduce", viewport={"width": 1280, "height": 800}
)
try:
rpage = context.new_page()
rpage.goto(app_url)
report = rpage.evaluate(
"""() => {
const pick = (el, pseudo) => {
const cs = getComputedStyle(el, pseudo);
return { anim: cs.animationName, image: cs.backgroundImage };
};
return {
grid: pick(document.body, "::before"),
glowA: pick(document.body, "::after"),
glowB: pick(document.documentElement, "::before"),
glowC: pick(document.documentElement, "::after"),
};
}"""
)
grid = report["grid"]
assert grid["anim"] == "none", (
f"the grid must stay static under reduced motion (got {grid['anim']!r})"
)
assert grid["image"] != "none", (
"grid: the static background image must remain visible"
)
for key in ("glowA", "glowB", "glowC"):
assert report[key]["anim"] == "none", (
f"{key}: no glow animation may exist under reduced motion "
f"(got {report[key]['anim']!r})"
)
assert report[key]["image"] == "none", (
f"{key}: no glow image may exist under reduced motion "
f"(got {report[key]['image']!r})"
)
finally:
context.close()
def test_behavior_unchanged_smoke(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""AC6: layout/behavior unchanged under the new skin — an on-topic
question streams a grounded answer, renders a source chip, and the send
button recovers (never stale)."""
_seed_kb(mock_llm)
page.set_default_timeout(30_000)
login(page, app_url, next="/") # phase 79: chat is require_user-gated
expect(page.locator("#kb-banner")).to_be_hidden()
page.fill("#message-input", QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble")).to_contain_text(QUESTION)
bubble = page.locator(".msg.brain .bubble")
bubble.first.wait_for(state="visible", timeout=30_000)
expect(bubble.first).to_contain_text(QUESTION, timeout=30_000)
expect(bubble.first).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
expect(page.locator(".msg.brain .source-chip", has_text="kubernetes.md")).to_have_count(1)
# The state machine settled: button re-enabled, label back to "Send".
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Send")
def test_all_assets_local(page: Page, app_url: str, db_ready: None) -> None:
"""AC5: no-CDN invariant on both pages — every script/link reference is
same-origin or a data: URI."""
for path in ("/", "/sources.html"):
page.goto(f"{app_url}{path}")
refs = page.evaluate(
"""() => [...document.querySelectorAll("script[src], link[href]")]
.map((el) => el.src || el.href)"""
)
assert refs, f"expected local asset references on {path}"
for ref in refs:
assert ref.startswith(app_url) or ref.startswith("data:"), (
f"non-local asset reference on {path}: {ref}"
)