Files
brain-of-reese/tests/e2e/test_theme_semantic_completion.py
T
ducoterra 58e9d94cff
Build and Push Containers / build-and-push-app (push) Successful in 4m3s
Build and Push Containers / build-and-push-db (push) Successful in 18s
phase: 100_page_width_consistency
**Phase 100 — final verification pass: all green.**

- Verified the shipped CSS contract directly: `--chat-column: 72rem` in `:root`; 0 literal `max-width: 46rem`; no `@media (min-width: 1500px)` block; exactly 4 token-capped reading columns; tuning/theme/doc-edit shells cap-free, structurally `.sources-shell`; `mock_llm.py` diff is comment-only.
- Defect found & fixed (phase-93 suite): `test_theme_semantic_completion.py::test_reset_removes_tag_byte_identical` raced theme.js's post-PUT refetch — it asserted the `#bor-theme` tag was gone right after the result line, but tag removal lands in the reconcile after the re-fetch (failed ~1 in 5 runs after `test_chat_rag`, reproduced). Fixed with an auto-waiting `expect(...).to_have_count(0)` (settled-state wait, the file's existing house pattern). 6/6 clean on the repro loop after.
- Tests/lint/coverage: `uv run pytest --cov=app --cov-report=term-missing` → 2052 passed, **99%** on `app/`; `uv run ruff check .` + `uv run pyright` → 0 errors.
- E2E in isolation (all passed): `test_wide_desktop_column.py` 3 (the phase suite — chat==tuning==theme==RAG ±4px at 1280 & 1920, ≈1152px; shared ≈1152px; standalone doc ≈1112px; modal unchanged ≈1100px; 360px overflow-free), `test_ui_customization` 4, `test_admin_theme_tab` 5, `test_document_viewer` 7, `test_save_share_ux` 5, `test_sticky_navbar` 3, `test_markdown_tables` 6, `test_responsive_polish` 7, `test_chat_rag` 3, `test_theme_semantic_completion` 8.
- Completion criteria: ① measured 72rem everywhere (≥~1200px, ±4px) + full-width below + 360px clean — **PASS** (E2E); ② zero 46rem rules / no 1500px block / four token selectors — **PASS** (grep + unit pins); ③ B4 byte-identical no-op + mobile squeeze — **PASS** (theme + responsive suites green); ④ full suite / coverage / lint — **PASS**; ⑤ atomic commit — left to the harness per executor protocol (all changes in the working tree, uncommitted).
- Deviations: none from phase decisions; only change in this pass is the race fix above (test-only, behavior unchanged).
- Next pending phase: `98_sync_summary_visibility` (numeric order in `todo/`; also pending: 99, 101–105).
2026-09-12 13:37:19 -04:00

1009 lines
43 KiB
Python

"""Phase 93 E2E (Playwright): a complete monochrome theme — the
dedicated story suite (the story gate for the whole TODO item).
Source: ``TODO.md`` L3 (owner 2026-09-10) — "I created a black/white/
gray theme for brain of reese and found multiple cases of color still
in the UI which tells me the customization is not complete. Screenshots
are in the theme_fixes/ folder. Note the green text 'Theme saved', the
red 'Revoked' tag, the red 'Stale' tag, The green 'Local' tag, The
yellow 'Listing documents' and 'Reading' tool calls. Also the header
and description of each page needs a background - the grid makes it
hard to read."
This suite proves the TODO is done: with an all-gray 17-color theme
saved from the admin Theme tab, every state element the owner
screenshot computes GRAY (``r == g == b`` — the "still color"
detector), every state keeps its TEXT label (B5 — "text + color,
never color alone", so a grayscale theme conveys state by words),
every page head sits on a non-transparent, AA-readable surface panel,
a fresh load paints the gray palette pre-paint, and Reset restores the
byte-identical default (no ``#bor-theme`` tag).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_theme_semantic_completion.py -v --no-cov
The grayscale ramp (``GRAY`` below) is R=G=B on every channel of all
17 colors and passes all EIGHT WCAG 2.1 AA contrast pairs (>= 4.5:1 —
the weakest is --bg on --brand at 6.7:1). The module-level asserts
re-derive BOTH properties on every run, so the suite cannot silently
regress to a colored or failing palette. The same eight pairs are the
client's ``theme.js`` ``PAIRS`` and the ``app/core/theming.py``
docstring table — the three mirrors must never diverge.
Test → screenshot mapping (every ``theme_fixes/`` shot is encoded as a
computed-style assertion):
1. ``test_tab_ui_save_gray_roundtrip`` — the form wiring (the point of
the task): the 8 NEW semantic pickers (plus the 9 identity) are
filled and the Save PUT body carries all 17 grays + the 3 default
strings; "Theme saved." (screenshot 7) computes GRAY ok-ink with
its text intact; the contrast warning stays hidden (all eight pairs
pass); the inputs re-populate to the saved grays; the id-1 row
stores all 17.
2. ``test_pre_paint_gray_first_paint`` — persistence + first paint: a
direct-PUT seed; the RAW served HTML (no JS) carries exactly one
``#bor-theme`` tag with all 17 gray vars immediately before
``</head>`` (the CSP carries its strict hash); a fresh
``page.goto`` paints the saved gray ``bg`` on the page canvas (the
``<html>`` element — the body stays transparent by contract) AND
the gray grid texture on the first paint, and all 17 computed
``:root`` custom properties equal the saved hexes at load.
3. ``test_page_heads_gray_panel_readable`` — screenshots 2, 3, 4, 6, 8:
on the seven shell views (the chat view's head is the sticky
navbar — phase 93 task 03 audited ``#view-chat``: it carries no
``.page-head``, the navbar IS the head) + the login page, the
h1/lede block's computed background is NON-TRANSPARENT and the gray
surface, the ink is the gray ink, and the OBSERVED ink-on-panel
ratio is >= 4.5 (the headings stay readable text).
4. ``test_stale_pill_gray_labeled`` — screenshot 5: a
out-of-generation saved chat (the phase-53 seed pattern) renders
the "Stale" pill in History — gray err-ink on gray err-bg with the
gray err-line border, text intact.
5. ``test_revoked_pill_gray_labeled`` — screenshot 6: a token
generated + revoked THROUGH the Tokens UI renders the "Revoked"
pill — the same gray err-family computed colors, text intact.
6. ``test_local_badge_gray_labeled`` — screenshot 3: a registered
local-directory source renders the "Local" badge on Git sources —
gray ok-ink on gray ok-bg, text intact.
7. ``test_tool_call_lines_gray`` — screenshot 1: a mock-LLM turn that
executes the tools renders the "Listing documents" / "Reading
<source/path>" lines — gray accent-ink text, the gray accent-line
left border, and the gray brand-soft path chip, all text intact.
8. ``test_reset_removes_tag_byte_identical`` — the no-op contract: the
gray tag is present in the live document pre-reset; Reset to
defaults removes it from the LIVE document, a fresh load serves NO
tag, the computed palette returns to the built-ins parsed from
``styles.css``, and the served bytes equal a row-less deployment
byte for byte (B4).
DB isolation: the shared e2e Postgres keeps ``ui_settings`` (the
single row the caching middleware reads for EVERY served page — a
leftover themed row would repaint other suites' pages) and
``api_tokens`` rows across suites. An autouse fixture truncates
``ui_settings`` and deletes the ``e2e-``-labeled tokens before AND
after every test (never a TRUNCATE on ``api_tokens`` — the shared DB
may hold the owner's real tokens). The stale-chat test inserts its own
``saved_chats`` row (deleted in a ``finally`` — the shared DB may hold
the owner's saved chats); the local-source test registers + removes
its own row; the tool test resets the KB tables the house way
(suites truncate/re-import per module and run in isolation).
Per-module app env (the tuning/tokens/archive-upload pattern): the
module-scoped ``app_server`` override boots the same env block as the
shared conftest server with the branding vars pinned to the CODE
defaults (the phase-61/62 leak-guard pattern, extended to
``BOR_APP_NAME`` — "the effective strings start at the template
defaults" must hold regardless of an operator's local ``.env``) and
``BOR_GIT_SOURCES`` forced empty (the dev ``.env``'s git repos must
not render as env rows under the table the local-badge test asserts
on).
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import subprocess
import sys
import uuid
from collections.abc import Iterator
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import httpx
import pytest
from playwright.sync_api import Browser, BrowserContext, Locator, Page, Route, expect
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.config import Settings
from app.core.theming import COLOR_FIELDS
from app.db import SessionLocal
from app.models import Chunk, Document, GitSource, SavedChat, UiSettings
from app.rag.sources_meta import bump_sources_version
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
REPO,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
from tests.e2e.mock_llm import embed_text
# Phase 79 (task 04, full inventory): the conftest session app owns its
# port in a combined run — this module app binds its own port instead
# (a same-port second uvicorn dies on bind and would drive the wrong
# server). Env-overridable.
APP_PORT = int(os.environ.get("E2E_APP_PORT_THEME93", "8131"))
APP_URL = f"http://127.0.0.1:{APP_PORT}"
# ---------------------------------------------------------------------------
# The all-gray 17-color ramp (the task's example, verified)
# ---------------------------------------------------------------------------
#: Every channel R=G=B on every color (a true monochrome ramp), and all
#: EIGHT WCAG 2.1 AA contrast pairs pass >= 4.5:1 (weakest: --bg on
#: --brand, the button-ink pair — 6.7:1). The module-level asserts
#: below re-derive both properties so a future edit to this table fails
#: loudly at collection time, not mid-suite.
GRAY: dict[str, str] = {
"bg": "#111111",
"surface": "#1e1e1e",
"ink": "#f2f2f2",
"ink_soft": "#b3b3b3",
"line": "#3a3a3a",
"grid_line": "#2b2b2b",
"brand": "#9a9a9a",
"brand_soft": "#2c2c2c",
"brand_ink": "#d4d4d4",
"ok_bg": "#161616",
"ok_ink": "#e0e0e0",
"err_bg": "#191919",
"err_ink": "#e6e6e6",
"err_line": "#6a6a6a",
"accent_bg": "#1c1c1c",
"accent_ink": "#dedede",
"accent_line": "#787878",
}
assert set(GRAY) == set(COLOR_FIELDS), "GRAY must cover all 17 palette colors"
#: The eight contrast pairs (the app/core/theming.py docstring table —
#: the authoritative mirror of theme.js PAIRS; the two must never
#: diverge).
PAIRS: tuple[tuple[str, str], ...] = (
("ink", "bg"),
("ink", "surface"),
("ink_soft", "surface"),
("bg", "brand"),
("brand_ink", "surface"),
("ok_ink", "ok_bg"),
("err_ink", "err_bg"),
("accent_ink", "accent_bg"),
)
def _hex_rgb(hexc: str) -> tuple[int, int, int]:
return (int(hexc[1:3], 16), int(hexc[3:5], 16), int(hexc[5:7], 16))
def _wcag_luminance(rgb: tuple[float, float, float]) -> float:
"""WCAG 2.1 relative luminance (the same math theme.js runs)."""
def lin(c: float) -> float:
s = c / 255
return s / 12.92 if s <= 0.03928 else ((s + 0.055) / 1.055) ** 2.4
r, g, b = rgb
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
def _wcag_ratio_rgb(
fg: tuple[float, float, float], bg: tuple[float, float, float]
) -> float:
l1, l2 = _wcag_luminance(fg), _wcag_luminance(bg)
return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
for _field, _value in GRAY.items():
_r, _g, _b = _hex_rgb(_value)
assert _r == _g == _b, f"GRAY[{_field}] = {_value} is not grayscale (R=G=B required)"
for _fg, _bg in PAIRS:
_ratio = _wcag_ratio_rgb(_hex_rgb(GRAY[_fg]), _hex_rgb(GRAY[_bg]))
assert _ratio >= 4.5, f"--{_fg} on --{_bg}: {_ratio:.2f}:1 < 4.5:1 (AA)"
# ---------------------------------------------------------------------------
# In-test constants (single sources of truth — never duplicated)
# ---------------------------------------------------------------------------
def _builtin_colors() -> dict[str, str]:
"""The 17 built-in palette hexes (the 9 identity + the 8 semantic
state, phase 93) parsed OUT of ``frontend/assets/styles.css``'s
``:root`` in-test — the single source of truth, so the suite can't
drift from the stylesheet it asserts on."""
css = (REPO / "frontend" / "assets" / "styles.css").read_text(encoding="utf-8")
root = re.search(r":root\s*\{([^}]*)\}", css, re.DOTALL)
assert root is not None, "styles.css must open with its :root block"
colors: dict[str, str] = {}
for name in COLOR_FIELDS:
match = re.search(
rf"--{name.replace('_', '-')}\s*:\s*(#[0-9a-fA-F]{{6}})", root.group(1)
)
assert match is not None, f"--{name} missing from styles.css :root"
colors[name] = match.group(1).lower()
return colors
def _template_defaults() -> dict[str, str]:
"""The 3 template strings from the CODE defaults (derived from the
class fields — never drifts from ``app/config.py``; the module
server pins the same values, so the effective strings start exactly
here — the strings stay default through this suite)."""
return {
"app_name": Settings.model_fields["app_name"].default,
"input_placeholder": Settings.model_fields["input_placeholder"].default,
"footer_text": Settings.model_fields["footer_text"].default,
}
def _expected_tag(colors: dict[str, str]) -> str:
"""The EXACT inline tag ``theme_style_tag`` renders for
``colors``: one ``:root`` override, all 17 vars in COLOR_FIELDS
order, no whitespace (the byte the middleware injects)."""
declarations = "".join(f"--{k.replace('_', '-')}:{colors[k]};" for k in COLOR_FIELDS)
return f'<style id="bor-theme">:root{{{declarations}}}</style>'
# ---------------------------------------------------------------------------
# Per-module app env (the tuning/tokens/archive-upload pattern)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def app_server(mock_llm: int) -> Iterator[str]:
"""The real app under test — per-module env: the branding vars are
pinned to the CODE defaults (the effective strings start at the
template defaults regardless of an operator's local ``.env`` — the
phase-61/62 leak-guard pattern, extended to ``BOR_APP_NAME``) and
``BOR_GIT_SOURCES`` is forced empty (the dev ``.env``'s git repo
must not render as env rows in this suite's Git-sources table)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"http://127.0.0.1:{mock_llm}/v1"
)
# Mock-calibrated threshold (conftest pattern) — the tool turn must
# be grounded against the mock's token-overlap embeddings.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env["BOR_LLM_RETRY_DELAY"] = "0"
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
env["BOR_DOCS_REPO"] = ""
env["BOR_SUGGESTIONS"] = json.dumps(Settings.model_fields["suggestions"].default)
env["BOR_APP_NAME"] = Settings.model_fields["app_name"].default
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
env["BOR_GIT_SOURCES"] = ""
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
@pytest.fixture(scope="module")
def local_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
"""A plain (non-git) host directory the local-badge test registers
as a source (the app server runs on the same host, so the path is
visible to it — the ``test_local_directory_sources.py`` pattern)."""
root = tmp_path_factory.mktemp("bor_theme93_local")
(root / "note.md").write_text(
"# Local theme fixture\n\n"
"One small note that exists only to be a local source.\n",
encoding="utf-8",
)
assert (root / "note.md").is_file()
assert not (root / ".git").exists() # the story: NOT a git repo
return root
# ---------------------------------------------------------------------------
# DB isolation + helpers
# ---------------------------------------------------------------------------
def _clean_ui_state() -> None:
"""Fresh theme + token state per test: truncate the single-row
``ui_settings`` (the middleware reads it for EVERY page — a
leftover themed row would repaint other suites' pages) and delete
this suite's issued tokens (label-scoped on ``e2e-`` — never a
TRUNCATE: the shared DB may hold the owner's real tokens)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE ui_settings"))
db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'"))
db.commit()
@pytest.fixture(autouse=True)
def _clean(db_ready: None) -> Iterator[None]:
_clean_ui_state()
yield
_clean_ui_state()
def _cookies(page: Page) -> dict[str, str]:
"""The session cookies the browser context holds (the test's API
side sees exactly what that browser sees)."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _seed_theme(app_url: str, cookies: dict[str, str]) -> None:
"""The DIRECT-PUT seed (the API path — the UI save itself is
test 1's job): all 17 gray colors, strings left at their defaults
(omitted — the PUT replaces the row, absent = back to default)."""
r = httpx.put(
f"{app_url}/api/ui-settings", json=dict(GRAY), cookies=cookies, timeout=10
)
assert r.status_code == 200, r.text
# Every gray differs from its built-in, so the effective values are
# exactly the saved ramp (no built-in→NULL collapse happened).
body = r.json()
for field in COLOR_FIELDS:
assert body[field] == GRAY[field], f"effective {field} != {GRAY[field]}"
#: One computed-style read: ``getComputedStyle(el)[prop]`` parsed into
#: ``[r, g, b, alpha]`` (alpha 1 when the shorthand carries none).
_RGB_EL_JS = """(el, prop) => {
const m = getComputedStyle(el)[prop].match(
/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)(?:,\\s*([\\d.]+))?\\)/
);
return m
? [Number(m[1]), Number(m[2]), Number(m[3]),
m[4] === undefined ? 1 : Number(m[4])]
: null;
}"""
def _assert_gray(
loc: Locator,
prop: str,
expect_hex: str,
*,
label: str,
expect_opaque: bool = False,
) -> tuple[float, float, float, float]:
"""The "still color" detector (TODO.md L3): the element's COMPUTED
``prop`` must be GRAYSCALE (``r == g == b``) AND equal to the saved
ramp's hex for the variable the stylesheet declares there. Returns
the observed ``[r, g, b, a]`` (the ratio checks reuse it)."""
rgb = loc.evaluate(_RGB_EL_JS, prop)
assert rgb is not None, f"{label}: no element for {prop!r}"
r, g, b, a = (float(v) for v in rgb)
assert r == g == b, (
f"{label} {prop} is still COLOR: rgba({r:.0f}, {g:.0f}, {b:.0f}, {a})"
)
assert (r, g, b) == _hex_rgb(expect_hex), (
f"{label} {prop}: rgb({r:.0f}, {g:.0f}, {b:.0f}) != {expect_hex}"
)
if expect_opaque:
assert a == 1, f"{label} {prop} is transparent (alpha {a})"
return (r, g, b, a)
def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_000) -> None:
"""The first-paint proof: all 17 computed ``:root`` custom
properties equal the given hexes. The inline tag precedes every
stylesheet application, so a themed deployment resolves them from
the first style pass — no red flash, no pop-in (custom properties
return the specified token, so the string compare is stable — the
``.trim()`` rides out any token whitespace)."""
expected = {f"--{k.replace('_', '-')}": v for k, v in colors.items()}
page.wait_for_function(
"""(expected) => {
const cs = getComputedStyle(document.documentElement);
return Object.entries(expected).every(
([k, v]) => cs.getPropertyValue(k).trim() === v
);
}""",
arg=expected,
timeout=timeout,
)
# ---------------------------------------------------------------------------
# 1. The tab UI: the 8 new pickers + Save carry all 17 grays over the
# wire; "Theme saved." computes gray; no contrast warning
# ---------------------------------------------------------------------------
def test_tab_ui_save_gray_roundtrip(page: Page, app_url: str, db_ready: None) -> None:
defaults = _template_defaults()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# Fill ALL 17 pickers — the 9 identity + the 8 NEW semantic state
# pickers (the form wiring this task exists to prove: the pickers
# feed the PUT body, the live preview, and the refetch).
for field in COLOR_FIELDS:
page.fill(f"#theme-{field.replace('_', '-')}", GRAY[field])
# The 3 strings stay default (untouched — they hold the effective
# template defaults the module server pins).
# Capture the PUT body the form builds (the wiring proof: all 17
# grays ride the wire together with the 3 default strings).
captured: dict[str, Any] = {}
def handle(route: Route) -> None:
if route.request.method == "PUT":
captured["body"] = route.request.post_data_json
route.continue_()
page.route("**/api/ui-settings", handle)
try:
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
finally:
page.unroute("**/api/ui-settings")
# Screenshot 7: "Theme saved." — the green result text — now
# computes GRAY ok-ink, text intact, role=status preserved.
expect(page.locator("#theme-result")).to_have_attribute("role", "status")
_assert_gray(page.locator("#theme-result"), "color", GRAY["ok_ink"], label="result text")
# All EIGHT AA pairs pass on the saved ramp → the client's warning
# line (theme.js PAIRS — the mirror of the theming.py table) stays
# hidden (it re-checks the saved state on the save's refetch).
expect(page.locator("#theme-contrast")).to_be_hidden()
# The PUT carried every field: the 17 grays + the 3 default strings
# (the strings were never touched, so they ride at their defaults).
body = captured.get("body")
assert isinstance(body, dict), "the Save PUT was not captured (route hook?)"
assert body == {**GRAY, **defaults}, f"unexpected PUT body: {body}"
# The inputs re-populate to the SAVED (effective) grays (the
# save's refetch is the canonical state)…
for field in COLOR_FIELDS:
expect(page.locator(f"#theme-{field.replace('_', '-')}")).to_have_value(
GRAY[field]
)
# …and the id-1 row stores all 17 (none equal a built-in, so
# nothing collapsed to NULL).
with SessionLocal() as db:
row = db.get(UiSettings, 1)
assert row is not None, "the PUT must upsert the id-1 row"
for field in COLOR_FIELDS:
assert getattr(row, field) == GRAY[field]
# ---------------------------------------------------------------------------
# 2. Pre-paint + persistence: the 17-var gray tag on the wire AND the
# gray first paint (canvas + grid + computed custom properties)
# ---------------------------------------------------------------------------
def test_pre_paint_gray_first_paint(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_seed_theme(app_url, _cookies(page))
# The RAW served HTML (httpx — no JS at all, the server's own
# bytes): exactly one inline theme tag with all 17 gray vars,
# placed IMMEDIATELY before </head> (inject_theme's exact
# placement — the tag ends exactly where </head> begins and
# carries the injector's single leading newline).
tag = _expected_tag(GRAY)
r = httpx.get(app_url + "/", timeout=10)
assert r.status_code == 200
assert r.text.count(tag) == 1, "expected exactly one theme tag in the raw HTML"
start = r.text.index(tag)
head = r.text.index("</head>")
assert start + len(tag) == head, "the tag must end exactly where </head> begins"
assert r.text[start - 1] == "\n", "the tag must carry the injector's leading newline"
# The CSP extension covers the 17-var tag (the strict sha256 source
# expression — no 'unsafe-inline').
csp = r.headers.get("content-security-policy", "")
assert "style-src 'self' 'sha256-" in csp, csp
# A FRESH navigation: the document carries the tag, and the FIRST
# paint is gray — the page background canvas is the <html> element
# (the body stays transparent by the phase-08 contract), and the
# grid texture (body::before, 60% --grid-line) is gray too.
page.goto(app_url + "/")
assert tag in page.content(), "the served document must carry the theme tag"
_assert_gray(
page.locator("html"), "backgroundColor", GRAY["bg"],
label="page canvas", expect_opaque=True,
)
grid_image = page.evaluate(
"() => getComputedStyle(document.body, '::before').backgroundImage"
)
m = re.search(r"rgba?\((\d+),\s*(\d+),\s*(\d+)", grid_image)
assert m is not None, f"no grid texture gradient found: {grid_image!r}"
assert m.group(1) == m.group(2) == m.group(3), (
f"the grid texture is still colored: {grid_image!r}"
)
# All 17 computed :root custom properties equal the saved hexes at
# load (the inline tag precedes every stylesheet application — the
# first paint IS the themed paint — no red flash, no pop-in).
_wait_theme_computed(page, GRAY)
# ---------------------------------------------------------------------------
# 3. Page heads: non-transparent gray surface panel + AA ink on EVERY
# page (the seven shell views + the login page)
# ---------------------------------------------------------------------------
#: ``(path, head selector, ink selector)`` — the seven shell views +
#: the login page (the login check runs in a fresh ANONYMOUS context —
#: a signed-in context is redirected straight to the app by
#: login.js's boot whoami). The chat view's head is the STICKY NAVBAR
#: (phase 93 task 03 audited #view-chat: it carries no .page-head —
#: the navbar IS the head), so its pair is the app-header + the
#: wordmark (ink on surface — the same pair as every .page-head).
HEADS: tuple[tuple[str, str, str], ...] = (
("/", "header.app-header", "header.app-header .brand"),
("/tuning.html", "#view-tuning .page-head", "#view-tuning .page-head h1"),
("/sources.html", "#view-rag .page-head", "#view-rag .page-head h1"),
("/git-sources.html", "#view-git-sources .page-head", "#view-git-sources .page-head h1"),
("/history.html", "#view-history .page-head", "#view-history .page-head h1"),
("/tokens.html", "#view-tokens .page-head", "#view-tokens .page-head h1"),
("/theme.html", "#view-theme .page-head", "#view-theme .page-head h1"),
)
def _assert_head_gray(page: Page, head_sel: str, ink_sel: str) -> None:
"""One page head: NON-TRANSPARENT gray surface panel, gray ink, and
the OBSERVED ink-on-panel ratio >= 4.5 (WCAG 2.1 AA) — the
heading text stays present (readable is the point)."""
head = page.locator(head_sel)
ink = page.locator(ink_sel)
expect(head).to_be_visible()
head_rgb = _assert_gray(
head, "backgroundColor", GRAY["surface"],
label=f"head panel {head_sel}", expect_opaque=True,
)
ink_rgb = _assert_gray(ink, "color", GRAY["ink"], label=f"head ink {ink_sel}")
ratio = _wcag_ratio_rgb(ink_rgb[:3], head_rgb[:3])
assert ratio >= 4.5, f"{ink_sel} on {head_sel}: {ratio:.2f}:1 < 4.5:1"
expect(ink).not_to_have_text("") # the heading text is intact
def test_page_heads_gray_panel_readable(
page: Page, browser: Browser, app_url: str, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_seed_theme(app_url, _cookies(page))
# The seven shell views (each a fresh load — the themed shell
# document with the router activating the view).
for path, head_sel, ink_sel in HEADS:
page.goto(app_url + path)
_assert_head_gray(page, head_sel, ink_sel)
# The login page: its head block is the .login-card (phase 93
# task 03 audited it — already a surface card, deliberately
# untouched). A fresh anonymous context (the signed-in context
# would redirect away before the card renders).
anon_ctx: BrowserContext | None = None
try:
anon_ctx = browser.new_context(viewport={"width": 1280, "height": 800})
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + "/login.html")
_assert_head_gray(anon, ".login-card", "#login-title")
finally:
if anon_ctx is not None:
anon_ctx.close()
# ---------------------------------------------------------------------------
# 4. History: the "Stale" pill (screenshot 5) — gray err family, text
# intact (an out-of-generation saved chat — the phase-53 pattern)
# ---------------------------------------------------------------------------
STALE_TITLE = "Gray theme stale pin (phase 93)"
def test_stale_pill_gray_labeled(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_seed_theme(app_url, _cookies(page))
cookies = _cookies(page)
# Seed an out-of-generation saved chat (the phase-53 pattern: the
# version is monotonic — a row stamped against an OLDER generation
# is stale, computed server-side). Bump once so the stamp-0 row is
# strictly out of generation (stale = sources_version < current).
chat_id = uuid.uuid4()
with SessionLocal() as db:
bump_sources_version(db)
db.add(
SavedChat(
id=chat_id,
title=STALE_TITLE,
messages=[
{"who": "user", "text": "a question"},
{"who": "brain", "text": "an answer"},
],
sources_version=0,
)
)
db.commit()
try:
page.goto(app_url + "/history.html")
row = page.locator(
"#history-tbody tr", has=page.locator(f"a[href='/?chat={chat_id}']")
)
expect(row.locator("a.history-title-link")).to_be_visible(timeout=15_000)
# Screenshot 5: the red "Stale" pill — now gray err-ink on
# gray err-bg with the gray err-line border, text intact.
pill = row.locator(".stale-pill")
expect(pill).to_have_count(1)
expect(pill).to_have_text("Stale")
_assert_gray(pill, "color", GRAY["err_ink"], label="stale pill text")
_assert_gray(pill, "backgroundColor", GRAY["err_bg"], label="stale pill bg")
_assert_gray(
pill, "borderTopColor", GRAY["err_line"], label="stale pill border"
)
# The row's heading text (the title link) is intact too —
# state is words + color, never color alone (B5).
expect(row.locator("a.history-title-link")).to_have_text(STALE_TITLE)
finally:
# The shared DB may hold the owner's saved chats — delete only
# this suite's row (a 404 — already deleted — is fine).
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
# ---------------------------------------------------------------------------
# 5. Tokens: the "Revoked" pill (screenshot 6) — gray err family, text
# intact (generated + revoked THROUGH the UI two-step)
# ---------------------------------------------------------------------------
def test_revoked_pill_gray_labeled(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_seed_theme(app_url, _cookies(page))
page.goto(app_url + "/tokens.html")
expect(page.locator("#token-create")).to_be_visible(timeout=15_000)
# Generate a token through the UI (the phase-79 contract: the
# plaintext appears exactly once).
page.fill("#token-label", "e2e-theme93")
page.click("#token-generate")
row = page.locator("#tokens-tbody tr", has_text="e2e-theme93")
expect(row).to_have_count(1, timeout=15_000)
expect(row.locator(".stale-pill")).to_have_count(0) # Active: the em-dash
# Revoke through the UI two-step (Revoke → Yes — the inline
# confirm, no native dialog).
row.locator("button.token-revoke").click()
expect(row.locator(".history-confirm-yes")).to_be_visible()
row.locator(".history-confirm-yes").click()
# Screenshot 6: the red "Revoked" pill — now gray err-ink on gray
# err-bg with the gray err-line border, text intact.
revoked = page.locator("#tokens-tbody tr", has_text="e2e-theme93")
pill = revoked.locator(".stale-pill")
expect(pill).to_have_count(1)
expect(pill).to_have_text("Revoked", timeout=15_000)
_assert_gray(pill, "color", GRAY["err_ink"], label="revoked pill text")
_assert_gray(pill, "backgroundColor", GRAY["err_bg"], label="revoked pill bg")
_assert_gray(
pill, "borderTopColor", GRAY["err_line"], label="revoked pill border"
)
expect(page.locator("#tokens-status")).to_have_text('Revoked "e2e-theme93".')
# ---------------------------------------------------------------------------
# 6. Git sources: the "Local" badge (screenshot 3) — gray ok family,
# text intact (a registered local-directory source)
# ---------------------------------------------------------------------------
def test_local_badge_gray_labeled(
page: Page, app_url: str, local_dir: Path, db_ready: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_seed_theme(app_url, _cookies(page))
# Register a local-directory source through the authenticated API
# (the phase-38 contract the phase-49 page used to wrap — the admin
# cookie rides the context's request API).
r = page.request.post(
f"{app_url}/api/git-sources", data={"kind": "local", "path": str(local_dir)}
)
assert r.status == 201, f"expected 201 for the local dir: {r.status} {r.text}"
source_id: str = r.json()["id"]
try:
page.goto(app_url + "/git-sources.html")
row = page.locator("#git-sources-tbody tr", has_text=str(local_dir))
expect(row).to_have_count(1, timeout=15_000)
# Screenshot 3: the green "Local" badge — now gray ok-ink on
# gray ok-bg, text intact.
badge = row.locator("span.git-source-kind.is-local")
expect(badge).to_have_count(1)
expect(badge).to_have_text("Local")
_assert_gray(badge, "color", GRAY["ok_ink"], label="local badge text")
_assert_gray(
badge, "backgroundColor", GRAY["ok_bg"], label="local badge bg"
)
finally:
# The shared DB may hold the owner's sources — remove only
# this suite's row (removal prunes nothing here: no sync ran).
page.request.delete(f"{app_url}/api/git-sources/{source_id}")
# ---------------------------------------------------------------------------
# 7. Chat: the "Listing documents" / "Reading" tool lines (screenshot 1)
# — gray accent family, text intact (a mock-LLM turn that runs the
# tools — the phase-37 deterministic ls → read → answer flow)
# ---------------------------------------------------------------------------
#: The two-document pair from the phase-37 seeding pattern — one RETRIEVABLE
#: document (a chunk carrying the mock's own bag-of-words embedding) that
#: grounds the turn, and one CATALOG-ONLY document (indexed, no chunks) the
#: mock's single-read flow reads. The catalog-only document sorts FIRST
#: ("Checklist" < "ThemeNotes") — the mock reads the first catalog line, and
#: it must NOT be the in-context retrieval document: the agent's read tool
#: refuses documents already in the prompt (ALREADY_IN_CONTEXT), and a
#: refused single-read flow would re-loop ls/read to the round cap.
READ_SOURCE = "Checklist"
READ_PATH = "read-me.md"
READ_SP = f"{READ_SOURCE}/{READ_PATH}"
READ_DOC_CONTENT = (
"# Read Me\n\n"
"The checklist front page: gray theme pin.\n"
)
TOOL_DOC_SOURCE = "ThemeNotes"
TOOL_DOC_PATH = "gray-theme.md"
TOOL_DOC_SP = f"{TOOL_DOC_SOURCE}/{TOOL_DOC_PATH}"
#: Repeats the marker question's key tokens (gray, theme, notes, page,
#: head) — the mock's token-overlap embeddings cosine well past the
#: E2E 0.30 threshold (plus FTS hits), so the turn is solidly grounded
#: and the <tools> section is offered.
TOOL_DOC_CONTENT = (
"# Gray Theme Notes\n\n"
"## Checklist\n\n"
+ (
"Every page head gets the gray surface panel and the gray theme "
"notes keep the gray state labels readable.\n"
)
* 6
)
MARKER_QUESTION = (
"Use your tools: what do the gray theme notes say about the page head?"
)
def _seed_tool_docs(db: Session) -> None:
"""The two-document pair (see the constants above): the retrievable
grounding document (one chunk carrying the mock's own bag-of-words
embedding) + the catalog-only read target (no chunks — retrieval
never puts it in context, so the agent's read tool accepts it).
Phase 94: the drill-down ``ls`` top level reads the registry — the
seed registers BOTH sources (TRUNCATEd in
``_reset_kb_with_tool_docs``), ``Checklist`` FIRST: registry order
is ``(added_at, id)``, so the mock's drill (first source of the
listing) — and therefore the read — lands on ``read-me.md``
deterministically, independent of the shared dev DB's leftover
registry rows."""
# COMMIT between the inserts (not flush): ``added_at`` is
# ``server_default now()`` — the transaction timestamp — and the
# tie-break is the random uuid ``id``, so one-transaction rows order
# nondeterministically.
db.add(GitSource(url=READ_SOURCE, kind="local"))
db.commit()
db.add(GitSource(url=TOOL_DOC_SOURCE, kind="local"))
db.add(
Document(
source=TOOL_DOC_SOURCE,
path=TOOL_DOC_PATH,
full_path=f"/tmp/{TOOL_DOC_PATH}",
title="Gray Theme Notes",
content=TOOL_DOC_CONTENT,
content_hash=hashlib.sha256(TOOL_DOC_CONTENT.encode()).hexdigest(),
indexed_at=datetime.now(UTC),
)
)
db.flush()
doc = db.scalar(
select(Document).where(
Document.source == TOOL_DOC_SOURCE, Document.path == TOOL_DOC_PATH
)
)
assert doc is not None, "the seed flush must assign the document id"
db.add(
Chunk(
document_id=doc.id,
position=0,
content=TOOL_DOC_CONTENT,
embedding=embed_text(TOOL_DOC_CONTENT),
)
)
db.add(
Document(
source=READ_SOURCE,
path=READ_PATH,
full_path=f"/tmp/{READ_PATH}",
title="Read Me",
content=READ_DOC_CONTENT,
content_hash=hashlib.sha256(READ_DOC_CONTENT.encode()).hexdigest(),
indexed_at=datetime.now(UTC),
)
)
def _reset_kb_with_tool_docs() -> None:
"""Truncate the KB tables (the house reset) and seed the pair — a
direct DB seed, so the turn is genuinely grounded and the mock's
single-read flow runs to completion: ls → drill ls (phase 94 — the
top level lists sources only) → read on the first file line (the
catalog-only document) → the quoted answer. Phase 94: ``git_sources``
joins the TRUNCATE — the top-level listing reads the registry, so
the leftover rows of other suites would change what the drill
targets."""
with SessionLocal() as db:
db.execute(
text(
"TRUNCATE chunks, documents, query_log, steering_notes, "
"kb_overview, git_sources"
)
)
db.commit()
_seed_tool_docs(db)
db.commit()
@pytest.mark.skipif(
USE_REAL_LLM,
reason="the deterministic ls → read tool flow is a mock-LLM contract (phase 37)",
)
def test_tool_call_lines_gray(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
_reset_kb_with_tool_docs()
login(page, app_url, next="/")
_seed_theme(app_url, _cookies(page))
# The login's navigation PRE-DATES the save — a fresh load is the
# pre-paint gray document (the theme applies on first paint, so the
# computed checks below see the saved ramp, not the built-ins).
page.goto(app_url + "/")
page.fill("#message-input", MARKER_QUESTION)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(MARKER_QUESTION)
# The turn settles: the grounded answer lands (the mock's "Read
# <source/path>. <first 80 chars>" — the read document reached the
# model) and the Send control recovers.
expect(
page.locator(".msg.brain .bubble:not(.typing)").last
).not_to_have_text("", timeout=60_000)
expect(page.locator("#send-label")).to_have_text("Send", timeout=60_000)
# Screenshot 1: the yellow "Listing documents" / "Reading" lines —
# now gray accent-ink text with the gray accent-line left border,
# the lines' text intact. Phase 94: the drill-down ls adds a THIRD
# line between them — the drill ls scoped to the first source of
# the top level (registry order: Checklist — the read target's
# source).
lines = page.locator(".msg.brain .tool-call")
expect(lines).to_have_count(3)
expect(lines.nth(0)).to_contain_text("Listing documents")
expect(lines.nth(1)).to_contain_text("Listing documents in")
expect(lines.nth(1)).to_contain_text(READ_SOURCE)
expect(lines.nth(2)).to_contain_text("Reading")
expect(lines.nth(2)).to_contain_text(READ_SP)
_assert_gray(lines.nth(0), "color", GRAY["accent_ink"], label="ls line text")
_assert_gray(
lines.nth(0), "borderLeftColor", GRAY["accent_line"], label="ls line border"
)
_assert_gray(lines.nth(2), "color", GRAY["accent_ink"], label="read line text")
# The path chip on the Reading line: gray brand-soft background +
# gray ink (the screenshot's code chip — still gray under the ramp).
code = lines.nth(2).locator("code")
_assert_gray(code, "backgroundColor", GRAY["brand_soft"], label="read chip bg")
_assert_gray(code, "color", GRAY["ink"], label="read chip text")
# ---------------------------------------------------------------------------
# 8. Reset: the gray tag leaves the LIVE document, a fresh load serves
# NO tag, the built-ins return, and the bytes equal a row-less
# deployment (the B4 no-op contract end to end)
# ---------------------------------------------------------------------------
def test_reset_removes_tag_byte_identical(page: Page, app_url: str, db_ready: None) -> None:
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_seed_theme(app_url, _cookies(page))
# The pre-reset baseline: the 17-var gray tag is in the LIVE
# document (pre-paint on this load) AND on the wire.
page.goto(app_url + "/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
assert 'id="bor-theme"' in page.content(), "the live document must carry the tag"
assert "bor-theme" in httpx.get(app_url + "/", timeout=10).text
# Reset to defaults (the all-null PUT — the API's "defaults"
# operation; the §7.4 lifecycle lands the role=status line).
page.click("#theme-reset")
expect(page.locator("#theme-result")).to_have_text(
"Reset to the built-in theme.", timeout=30_000
)
# The tag is GONE from the live document (theme.js reconciles the
# #bor-theme tag's DOM text to the settled, now-default values —
# the no-op case removes the tag and clears the <html>
# overrides). The removal lands in the post-PUT refetch's
# reconcile (the result line above only signals the PUT landed),
# so WAIT for the settled tag-free state rather than racing the
# refetch round-trip…
expect(page.locator("#bor-theme")).to_have_count(0, timeout=30_000)
# …and a FRESH load serves NO tag at all (the all-NULL row is the
# no-op injection).
r = httpx.get(app_url + "/", timeout=10)
assert "bor-theme" not in r.text, "a defaults-saved row must serve no theme tag"
# The byte-identical contract, proven end to end: the served bytes
# of the reset (all-NULL row) deployment equal the served bytes of
# a ROW-LESS deployment (the middleware's no-op path — no tag,
# plain A1 CSP, identical ?v= rewrite).
with_row = httpx.get(app_url + "/", timeout=10).content
with SessionLocal() as db:
db.execute(text("TRUNCATE ui_settings"))
db.commit()
without_row = httpx.get(app_url + "/", timeout=10).content
assert with_row == without_row, (
"a defaults-saved row must serve byte-identical HTML"
)
# And the palette is back to the built-ins (parsed from the
# stylesheet — no second copy).
page.goto(app_url + "/")
_wait_theme_computed(page, builtin)