Files
brain-of-reese/tests/e2e/test_dark_tech_theme.py
T
ducoterra bc70ce36e0 feat(chat): render markdown tables in answers, viewer, and thinking
GFM pipe tables in the shared renderer (TODO.md L6): a table-protection
pass in frontend/assets/markdown.js (fences -> tables -> escape order)
pulls each header+separator+body block out as a placeholder, renders
cells escape-first with the same inline transforms, and reinserts a
semantic <table class="md-table"> inside a horizontal-overflow
.md-table-wrap — so a pipe table in a chat answer, the document
viewer/modal, and the thinking block all render the same semantic
table. Fences win over tables; lone pipes stay text.

- styles.css: .md-table palette rules (PLAN §7.2 tokens, no motion);
  min-width: max-content so a WIDE table keeps its natural width and
  the wrapper is the real scroller (width:100% alone wrapped the wide
  table's cells — proven by the new E2E).
- mock_llm.py: TABLE_TRIGGER ("show me a table") -> byte-stable
  TABLE_ANSWER (3-column table, <img onerror> XSS probe line, wide
  5-column table), checked before DEFLECT_MODE like SUMMARY_MODE.
- tests/fixtures/docs/homelab/tables.md: 3x3 pipe table + pipe-heavy
  fenced block (viewer/fence subject); the shared fixture set grows
  8 -> 9 docs, so every suite pinning the count (added/formats/
  stat-docs/EXPECTED_ROWS) is updated accordingly.
- tests/e2e/test_markdown_tables.py (new, story suite): chat table
  shape + non-deflection, wide-table wrapper scroll (no page
  overflow), XSS probe inert, viewer modal table, fence-not-a-table,
  lone pipe stays text.
- tests/e2e/test_agent_document_tools.py: fix a pre-existing flake —
  the "Calling tool…" label window is ~0.4 s at the mock's 0.1 s
  tool-frame pacing, and a polling expect could stride over it
  (failed 3 of 5 runs on the committed baseline). The pre-submit
  MutationObserver record is the deterministic source of truth; the
  racy to_have_text gate is gone.

uv run pytest: 738 passed, app/ coverage 99% (TOTAL unchanged);
ruff + pyright clean; story E2E 6/6 in isolation; regression E2E
suites (chat_rag, document_viewer, document_summaries, smoke) green.
2026-08-28 03:35:50 -04:00

391 lines
16 KiB
Python

"""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 <html> canvas (rgb(10, 14, 23)
# = #0a0e17). <body> 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}"
)