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.
399 lines
16 KiB
Python
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}"
|
|
)
|