"""Phase 08 E2E (Playwright): dark tech theme — palette, no emoji, animated background, reduced motion, behavior intact, all assets local. Story: ``.agent/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_animated_background`` — the background layers (fixed, pointer-events none, carrying images): the grid (``body::before``) is STATIC (phase 25, owner 2026-08-25: no movement) and the three glow spots run their opacity-only fades ``bg-glow-a/b/c`` at 26s/34s/42s (``body::after`` + the two ``html`` pseudo-layers). 4. ``test_reduced_motion_honored`` — a context with ``reduced_motion="reduce"`` → ``animation-name: none`` on all four layers (the static grid + spot images remain). 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 import pytest 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 == 9 # A9 formats (phase 44 added tables.md) 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)" def _seconds(value: str) -> float: """Chromium reports animation durations as "60s" — parse as seconds.""" return float(str(value).replace("s", "")) # -------------------------------------------------------------------------- # 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 canvas (rgb(10, 14, 23) # = #0a0e17). itself stays transparent so the z-index:-1 # grid/glow layers (test_animated_background) are not painted over. bg = page.evaluate( "() => getComputedStyle(document.documentElement).backgroundColor" ) assert bg == "rgb(10, 14, 23)", 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. page.goto(f"{app_url}/") 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_animated_background(page: Page, app_url: str, db_ready: None) -> None: """AC3: the background is subtly animated, pure CSS, zero JS, and can never block or dim content. Phase-25 contract (owner 2026-08-25: "not moving. Different bright spots should slowly fade in and out") supersedes the phase-08 recipe: the grid (``body::before``) is a STATIC texture (``animationName: none``, image still painted) and the three glow spots each run their own opacity-only fade — ``body::after`` → ``bg-glow-a`` 26s, ``html::before`` → ``bg-glow-b`` 34s, ``html::after`` → ``bg-glow-c`` 42s — all fixed, pointer-events:none, carrying an image.""" page.goto(app_url) report = page.evaluate( """() => { const pick = (el, pseudo) => { const cs = getComputedStyle(el, pseudo); return { image: cs.backgroundImage, anim: cs.animationName, duration: cs.animationDuration, 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"), }; }""" ) for key in ("grid", "glowA", "glowB", "glowC"): info = report[key] assert info["image"] != "none", f"{key} must carry a background image" assert info["position"] == "fixed", f"{key} must be position:fixed" assert info["pointerEvents"] == "none", f"{key} must not intercept input" # The phase-25 recipe: a static grid + three out-of-phase spot fades. assert report["grid"]["anim"] == "none", ( f"the grid must be static (got {report['grid']['anim']!r})" ) for key, name, seconds in ( ("glowA", "bg-glow-a", 26.0), ("glowB", "bg-glow-b", 34.0), ("glowC", "bg-glow-c", 42.0), ): info = report[key] assert info["anim"] == name, f"{key} must run {name} (got {info['anim']!r})" assert _seconds(info["duration"]) == pytest.approx(seconds), ( f"{key} must run its {seconds:.0f}s fade (got {info['duration']!r})" ) def test_reduced_motion_honored( browser: Browser, app_url: str, db_ready: None ) -> None: """AC4: with prefers-reduced-motion: reduce ALL FOUR background layers stop animating (animation-name: none) — the static grid + spot images remain (phase 25 stills the two html pseudo-layers as well as the body pair).""" 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"), }; }""" ) for key in ("grid", "glowA", "glowB", "glowC"): assert report[key]["anim"] == "none", ( f"{key} must not animate under reduced motion (got {report[key]['anim']!r})" ) assert report[key]["image"] != "none", ( f"{key}: the static background image must remain visible" ) 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) page.goto(app_url) 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}" )