Files
brain-of-reese/tests/e2e/test_dark_tech_theme.py
T
ducoterra a5b63f83ad
Build and Push Containers / build-and-push-app (push) Successful in 2m1s
Build and Push Containers / build-and-push-db (push) Successful in 18s
phase: 119_name_signal_read_chips
All verification complete. Final report:

**Phase 119 final verification pass — all criteria verified, one stale pin fixed.**
- Verified implementation of all 6 tasks: D1 component name-hit rule (`name_hit` flag, titles never matched, retired length tie-break), D2 `BOR_NAME_HIT_BONUS` (0.005 default, 0 = byte-identical kill switch, negative fails startup, selection-layer only, `eval_retrieval` `suggested:` line), D3 suggested-folder lines (after `SUGGEST_INTRO`, before first block), D4 cite-discipline `SUGGEST_INTRO` sentence (PERSONA/LOW/`TOOLS_SECTION` byte-pins intact), D5 `done.sources` = read docs only (frontend no-op on empty confirmed), D6 mock `repeat your folder map` echo + new suite + telemetry.
- Battery (replica restored per skill, fingerprint docs=1000/chunks=8866 verified, `eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` re-run): **GATE PASS** — gitea README #4 in suggested top-5, forgejo 5/5 (README #1), gateway README in top-5 (#4), qwen3.8-27b quadlets top-5, Mongolia HIGH/fts=5 unchanged.
- New E2E in isolation: `4 passed` ×2 (deterministic). All 27 modified E2E suites in isolation: 26 green; **1 stale pin fixed** — `test_source_chip_quality.py` durable-record order pin pre-dated the D1 re-rank (`aliases` stem sub-component name-hits `ssh_aliases.txt`, deterministically lifting `backups.md` over `kubernetes.md`; probe-verified 0.016277 vs 0.016036, 4/4 stable) — re-pinned with the phase-119 rationale; suite green ×2.
- Gates: `uv run pytest --cov=app --cov-report=term-missing` → **2547 passed, app coverage 99%** (>90%); `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- Completion criteria: 1 ✅ (battery, recorded), 2 ✅ (folder lines; block/LOW byte-identical pins green), 3 ✅ (read-only chips, zero-read chips nothing, related row + durable record untouched — unit+E2E agree), 4 ✅ (all green), 5 → commit/phase-move left to the harness per pass rules (nothing committed).
- Deviations: battery output + real-model telemetry recorded in `.agents/reports/119_name_signal_read_chips/task06_battery_and_e2e.md` and `TOOL_CALLING_TESTING.md` §11 (task files in `complete/` are immutable to this pass); gateway canonical doc at #4 vs overview's #3 was already documented at task 06 (containment gate met).
- Next pending phase: **none** — `todo/` holds only phase 119.
2026-09-16 15:50:48 -04:00

399 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 (phase 119, LOCKED A1: a zero-read
turn renders ZERO source chips — the retired phase-118 A4
suggested-chip is gone) 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)
# Phase 119 (LOCKED A1): zero-read turn → zero citation chips.
expect(page.locator(".msg.brain .source-chip")).to_have_count(0)
# 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}"
)