phase: 92_theme_save_and_coverage
Build and Push Containers / build-and-push-app (push) Successful in 1m47s
Build and Push Containers / build-and-push-db (push) Successful in 11s

**Phase 92 final verification pass — all green.** This pass re-verified the completed tasks (all 5 task files already in `complete/`) against every completion criterion; no defects found, nothing to fix.

- Verified: 9th identity var `grid_line` end-to-end (migration `0015` at head, model/`theming.py`/schemas/API, 422 + built-in→NULL tests present); `styles.css` zero hardcoded literals outside `:root` + derived `--brand-*` vars; 9th picker in theme form; wordmark themed; `theme.js` save/reset/re-show/mount live-sync; dedicated E2E suite + phase-91 suite updated.
- `uv run pytest --cov=app --cov-report=term-missing` → **1845 passed, exit 0, TOTAL 99%** (>90%)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
- `uv run pytest tests/e2e/test_theme_save_and_coverage.py -v --no-cov` → **3 passed** (save-live, reset-live, whole-site)
- `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed**
- Criteria: (1) Save/Reset repaint open page, no nav, SPA-nav survives, pre-paint intact ✅; (2) both `rg` gates green (only `:root` + documented `#fff` Stop label; zero SVG hex attrs), grid/selection/hovers/wash/wordmark E2E-proven ✅; (3) no-op contract live-checked: row-less `/` = no tag + exact A1 CSP, grid-only row = 9-var tag in `COLOR_FIELDS` order + sha256 CSP, with-row ≡ row-less bytes ✅; (4) full suite/coverage/lint/both E2E ✅; (5) commit left to the harness per instructions.
- Deviations (previously made, probe-verified, kept): live repaint uses CSSOM `<html>` overrides because Chromium blocks `<style>` textContent mutations under the locked sha256-only CSP (tag text still mirrors the next load; `<html>` style exact-saved after Save, empty after Reset); wordmark themed via 3 `.brand-mark` CSS rules instead of inline styles (task 03's inline attrs were CSP-blocked — fixed during task 04).
- Next pending phase: none — `todo/` contains only `92_theme_save_and_coverage`.
This commit is contained in:
2026-09-10 00:23:08 -04:00
parent d22d260b8b
commit df91c6316c
49 changed files with 2282 additions and 189 deletions
+21 -18
View File
@@ -17,8 +17,8 @@ Test → contract mapping (one story, one phase, one isolated file):
1. ``test_theme_tab_admin_save`` — "buttons and color pickers": the
admin sees the "Theme" nav link and the form (gate hidden); the
11 inputs show the effective defaults (the 3 template strings +
the 8 built-in hexes parsed out of ``styles.css``'s ``:root``
12 inputs show the effective defaults (the 3 template strings +
the 9 built-in hexes parsed out of ``styles.css``'s ``:root``
IN-TEST — the suite can never drift from the stylesheet); Save
runs the §7.4 lifecycle (disabled + "Saving…" while the PUT is
held, then restored) and lands the role=status "Theme saved.";
@@ -29,7 +29,7 @@ Test → contract mapping (one story, one phase, one isolated file):
2. ``test_saved_theme_is_pre_paint_for_everyone`` — "the theme
should load immediately, not pop in": after a save, the RAW
served HTML of ``/`` carries exactly one ``<style
id="bor-theme">`` with all 8 vars = the saved hexes, placed
id="bor-theme">`` with all 9 vars = the saved hexes, placed
IMMEDIATELY before ``</head>`` — for the admin AND a fresh
anonymous context — and the computed ``:root`` custom properties
equal the saved hexes at load (the inline tag precedes every
@@ -44,7 +44,7 @@ Test → contract mapping (one story, one phase, one isolated file):
4. ``test_reset_restores_the_builtin_byte_identical`` — "reset":
Reset to defaults runs the §7.4 lifecycle ("Resetting…"), lands
the role=status "Reset to the built-in theme.", re-populates the
11 defaults, serves NO theme tag, and the served bytes equal a
12 defaults, serves NO theme tag, and the served bytes equal a
row-less deployment byte for byte (the no-op injection
contract).
5. ``test_contrast_warning_does_not_block`` — the WCAG warnings:
@@ -103,7 +103,9 @@ from e2e.conftest import (
APP_URL = f"http://127.0.0.1:{APP_PORT}"
# The distinct E2E palette (task 06): a full non-built-in indigo set —
# The distinct E2E palette (task 06, extended in phase 92 task 05:
# grid_line — the 9th identity var, distinct from its built-in
# #4a2626 and from line #232a4a): a full non-built-in indigo set —
# every value differs from its built-in, so the tag is non-empty and
# every saved color is stored as-is (no built-in→NULL collapse).
PALETTE: dict[str, str] = {
@@ -112,6 +114,7 @@ PALETTE: dict[str, str] = {
"ink": "#e6e9f5",
"ink_soft": "#a8b0d0",
"line": "#232a4a",
"grid_line": "#2b3550",
"brand": "#4f46e5",
"brand_soft": "#1e2447",
"brand_ink": "#c7d2fe",
@@ -142,7 +145,7 @@ COLOR_INPUT_IDS: dict[str, str] = {
def _builtin_colors() -> dict[str, str]:
"""The 8 built-in identity hexes parsed OUT of
"""The 9 built-in identity hexes 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."""
@@ -173,7 +176,7 @@ def _template_defaults() -> dict[str, str]:
def _expected_tag(colors: dict[str, str]) -> str:
"""The EXACT inline tag ``theme_style_tag`` renders for
``colors``: one ``:root`` override, all 8 vars in COLOR_FIELDS
``colors``: one ``:root`` override, all 9 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>'
@@ -323,8 +326,8 @@ def _fill_theme_form(
palette: dict[str, str],
strings: dict[str, str] | None = None,
) -> None:
"""Fill the 11 inputs: the 3 text fields (``strings``, default
the E2E set) + the 8 color pickers (``palette``)."""
"""Fill the 12 inputs: the 3 text fields (``strings``, default
the E2E set) + the 9 color pickers (``palette``)."""
text_values = strings if strings is not None else SAVED_STRINGS
page.fill("#theme-app-name", text_values["app_name"])
page.fill("#theme-placeholder", text_values["input_placeholder"])
@@ -334,7 +337,7 @@ def _fill_theme_form(
def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, str]) -> None:
"""Assert all 11 inputs show the given effective values."""
"""Assert all 12 inputs show the given effective values."""
expect(page.locator("#theme-app-name")).to_have_value(strings["app_name"])
expect(page.locator("#theme-placeholder")).to_have_value(strings["input_placeholder"])
expect(page.locator("#theme-footer")).to_have_value(strings["footer_text"])
@@ -344,7 +347,7 @@ def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, s
def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None:
"""The RAW served HTML carries exactly one inline theme tag, with
all 8 vars = the given hexes, placed IMMEDIATELY before
all 9 vars = the given hexes, placed IMMEDIATELY before
``</head>`` (``inject_theme``'s exact placement: the tag ends
exactly where ``</head>`` begins and carries the injector's
single leading newline)."""
@@ -357,7 +360,7 @@ def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None:
def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_000) -> None:
"""The first-paint proof: all 8 computed ``:root`` custom
"""The first-paint proof: all 9 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
@@ -399,8 +402,8 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
expect(page.locator("#theme-gate")).to_be_hidden()
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The 11 inputs show the EFFECTIVE defaults: the 3 template
# strings + the 8 built-in hexes parsed straight out of
# The 12 inputs show the EFFECTIVE defaults: the 3 template
# strings + the 9 built-in hexes parsed straight out of
# styles.css's :root (the resolver's missing-row branch).
_expect_form_values(page, defaults, builtin)
@@ -432,7 +435,7 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
# save's refetch is the canonical state).
_expect_form_values(page, SAVED_STRINGS, PALETTE)
# The row landed in Postgres (the id-1 single row, all 11 values
# The row landed in Postgres (the id-1 single row, all 12 values
# — every palette color differs from its built-in, so nothing
# collapsed to NULL).
with SessionLocal() as db:
@@ -472,7 +475,7 @@ def test_saved_theme_is_pre_paint_for_everyone(
_seed_theme_via_api(app_url, _cookies(page))
# The RAW served HTML (httpx — no JS at all, the server's own
# bytes): exactly one inline theme tag, all 8 vars = the saved
# bytes): exactly one inline theme tag, all 9 vars = the saved
# hexes, immediately before </head> (the pre-paint mechanism the
# middleware unit tests pin — this is its observable
# consequence).
@@ -603,7 +606,7 @@ def test_anonymous_and_token_user_are_walled(
# ---------------------------------------------------------------------------
# 4. Reset: the §7.4 lifecycle, the 11 defaults, NO theme tag, and
# 4. Reset: the §7.4 lifecycle, the 12 defaults, NO theme tag, and
# byte-identical served HTML (the no-op injection contract)
# ---------------------------------------------------------------------------
@@ -650,7 +653,7 @@ def test_reset_restores_the_builtin_byte_identical(
finally:
_release_theme_puts(page)
# The form re-populates to the 11 defaults (the env/built-in
# The form re-populates to the 12 defaults (the env/built-in
# merge, re-rendered from the refetch)…
_expect_form_values(page, defaults, builtin)
# …and the WCAG warning is gone (the built-in palette passes all
+767
View File
@@ -0,0 +1,767 @@
"""Phase 92 E2E (Playwright): Save/Reset repaint the OPEN page without a
reload, and the Theme tab's variables drive the ENTIRE site.
Source: owner chat defect report (post-phase-91): (1) "Clicking 'save
theme' reverts the theme back to the previous theme, a refresh is
required to see the new theme."; (2) "Not everything is controllable
via the theme controls. Certain buttons and text are still light pink
on highlight, for example. The background grid never changes color." —
"The theme controls should allow manipulating the entire site's theme."
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_theme_save_and_coverage.py -v --no-cov
Test → contract mapping (one story, one phase, one isolated file):
1. ``test_save_applies_live_without_reload`` — defect 1 (Save): the
§7.4 save lifecycle lands, and — with NO navigation — the OPEN
document mirrors the saved state: the computed ``:root`` palette is
the saved 9 hexes, the ``#bor-theme`` tag's DOM text is the exact
9-var ``:root{…}`` the next load would serve, and ``<html>``'s
inline style holds EXACTLY the 9 saved custom properties (no stale
pick). The server agrees (the raw ``/`` carries the 9-var tag), and
an SPA nav to Chat (same document) keeps the saved palette on the
computed ``--brand`` and the ``.send-btn`` fill.
2. ``test_reset_applies_live_without_reload`` — defect 1 (Reset): on a
THEMED load (the served tag is present), the §7.4 reset lifecycle
lands, and — with NO navigation — the ``#bor-theme`` tag is REMOVED
from the live document, the computed ``:root`` palette is the 9
built-ins (parsed from ``styles.css`` in-test), ``<html>`` carries
no overrides, the served HTML has no tag, and the with-row bytes
equal a row-less deployment byte for byte (the no-op contract end
to end, now 9-wide).
3. ``test_theme_controls_drive_the_whole_site`` — defect 2: with the
palette seeded, a fresh load's first paint is the themed paint
(the raw HTML carries the 9-var tag immediately before ``</head>``
+ the phase-91 CSP ``style-src 'self' 'sha256-…'``), and the
browser-COMPUTED values prove every themed surface follows the tab:
the background grid texture (``body::before`` — the owner's named
defect) at 60% ``--grid-line``, ``::selection`` at 45% ``--brand``,
the button hovers at the derived ``--brand-hover`` (and NOT the
legacy indigo ``#7d88f5``), the nav-link wash at the saved
``--brand-soft`` (exact), and the wordmark at the saved
``--surface`` (exact).
CSP reality (why the open-page repaint rides the ``<html>`` overrides
— the one deviation from the ``00_phase.md`` design): the repo's strict
policy (phase 82/91 — A1 + ``style-src 'self' 'sha256-<served tag>'``,
no ``'unsafe-inline'``) makes the design's "sync the tag, then clear
the preview" shape impossible in a real browser: Chromium re-checks
``style-src`` on EVERY DOM-API content change to a ``<style>`` element
(``textContent`` on the served tag, ``createElement`` +
``appendChild``, ``replaceChildren`` — all blocked for a fresh
palette; verified by a standalone probe against this repo's CSP
shape), so a cleared preview would fall back to the STALE served tag —
the original defect. The only CSP-clean repaint path is CSSOM
``setProperty`` on ``<html>`` (the phase-91 live preview's mechanism —
an unchecked mutation), which task 04's ``applyServedTheme`` uses as
the PAINT half, while the tag's DOM TEXT is still synced (the MIRROR
half — what the next load serves, inert until the reload that serves
matching content + hash). Test 1 therefore asserts the paint half as
"the ``<html>`` overrides are EXACTLY the saved palette" (not an empty
style attribute, as the cleared-preview shape would leave — on a
reset the attribute IS empty, and test 2 asserts exactly that).
Computed-value assertions read the browser's SERIALIZED colors
(``rgb(…)`` for plain-var surfaces, ``color(srgb …)`` for
``color-mix()`` results — custom properties return tokens, USED
properties resolve) with the ±1/channel (±1/255 float) tolerance; the
tolerance absorbs un-pinned browser rounding yet still fails any
legacy hardcoded value by orders of magnitude.
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).
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 (an operator's local ``.env`` may carry the owner's
name/placeholder/footer, and "the effective strings start at the
template defaults" must hold regardless — the phase-61/62
leak-guard pattern, extended to ``BOR_APP_NAME``) and
``BOR_GIT_SOURCES`` forced empty (the dev ``.env``'s git repo must
not render as env rows in this suite's app). The phase-91 file is the
copy source — the two modules' scaffolds stay in lockstep so a
future conftest refactor touches both at once.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import time
from collections.abc import Iterator
import httpx
import pytest
from playwright.sync_api import Page, Route, expect
from sqlalchemy import text
from app.config import Settings
from app.core.theming import COLOR_FIELDS
from app.db import SessionLocal
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
REPO,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
APP_URL = f"http://127.0.0.1:{APP_PORT}"
# The distinct E2E palette: the phase-91 8-value indigo set PLUS the
# 9th identity var (phase 92, task 01) — grid_line #2b3550, distinct
# from its built-in #4a2626 and from line #232a4a. Every value differs
# from its built-in, so the tag is non-empty and every saved color is
# stored as-is (no built-in→NULL collapse).
PALETTE: dict[str, str] = {
"bg": "#0b1020",
"surface": "#111730",
"ink": "#e6e9f5",
"ink_soft": "#a8b0d0",
"line": "#232a4a",
"grid_line": "#2b3550",
"brand": "#4f46e5",
"brand_soft": "#1e2447",
"brand_ink": "#c7d2fe",
}
APP_NAME = "Theme E2E"
PLACEHOLDER = "Ask the themed brain…"
FOOTER = "E2E footer"
SAVED_STRINGS: dict[str, str] = {
"app_name": APP_NAME,
"input_placeholder": PLACEHOLDER,
"footer_text": FOOTER,
}
#: The E2E-stable color-input ids, in COLOR_FIELDS order (the form's
#: own markup — the static E2E-stable-selectors house convention).
COLOR_INPUT_IDS: dict[str, str] = {
field: f"#theme-{field.replace('_', '-')}" for field in COLOR_FIELDS
}
# ---------------------------------------------------------------------------
# In-test constants (single sources of truth — never duplicated)
# ---------------------------------------------------------------------------
def _builtin_colors() -> dict[str, str]:
"""The 9 built-in identity hexes 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 _expected_tag(colors: dict[str, str]) -> str:
"""The EXACT inline tag ``theme_style_tag`` renders for
``colors``: one ``:root`` override, all 9 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>'
def _expected_tag_content(colors: dict[str, str]) -> str:
"""The tag's INNER ``:root{…}`` string — the ``#bor-theme``
element's ``textContent`` after a settled save (task 04's mirror
half is byte-identical to it: same 9 fields, same order, the
resolver's lowercased hexes)."""
declarations = "".join(f"--{k.replace('_', '-')}:{colors[k]};" for k in COLOR_FIELDS)
return f":root{{{declarations}}}"
def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_000) -> None:
"""All 9 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,
)
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)."""
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 _wait_mount_settled(page: Page) -> None:
"""The theme view's mount-time load has SETTLED: its populate is
the last visible step — the app-name input carries the effective
default (the static markup ships it empty, so only a settled
``loadSettings`` can have written it). From here on, no in-flight
mount load can race the test's own save/reset refetch (an
out-of-order settle would re-reconcile the open document onto a
stale read)."""
expect(page.locator("#theme-app-name")).to_have_value(
_template_defaults()["app_name"], timeout=15_000
)
def _expected_overrides(colors: dict[str, str], builtins: dict[str, str]) -> dict[str, str]:
"""The ``<html>`` inline custom properties task 04's
``applyServedTheme`` leaves after settling on ``colors`` (the
CSSOM PAINT half — the only CSP-clean repaint path, see the module
docstring): exactly the vars that differ from their built-in, at
the saved values (empty for the built-in palette)."""
return {
f"--{field.replace('_', '-')}": value
for field, value in colors.items()
if value != builtins[field]
}
def _wait_settled_open_document(
page: Page, tag_text: str | None, overrides: dict[str, str]
) -> None:
"""The OPEN document mirrors the settled state (defect 1): the
``#bor-theme`` tag's DOM text is ``tag_text`` (``None`` = the tag
is REMOVED — the no-op/reset case) and ``<html>``'s inline style
holds EXACTLY the ``overrides`` custom properties (no stale pick —
the pre-task-04 code fails this wait: its save cleared the preview
onto the stale served tag, leaving neither the synced tag text
nor the paint-half overrides). The absent-tag case rides the ``""``
sentinel: Playwright's wait_for_function serializes a Python
``None`` arg as JS ``undefined`` (not ``null`` — probe-verified),
and the real tag content is never empty anyway."""
page.wait_for_function(
"""(expected) => {
const el = document.getElementById('bor-theme');
if (expected.tag === '') {
if (el !== null) return false;
} else if (el === null || el.textContent !== expected.tag) {
return false;
}
const s = document.documentElement.style;
const actual = {};
for (let i = 0; i < s.length; i++) {
const p = s[i];
if (p.startsWith('--')) actual[p] = s.getPropertyValue(p);
}
const keys = Object.keys(actual).sort();
const expKeys = Object.keys(expected.overrides).sort();
if (keys.length !== expKeys.length) return false;
return keys.every(
(k, i) => k === expKeys[i] && actual[k] === expected.overrides[k]
);
}""",
arg={"tag": "" if tag_text is None else tag_text, "overrides": overrides},
timeout=15_000,
)
# ---------------------------------------------------------------------------
# color-mix resolution (the browser's sRGB interpolation, for the
# used-surface assertions — the serialized strings are compared with
# the ±1/255 tolerance, never raw color-mix(…) tokens)
# ---------------------------------------------------------------------------
#: The modern serialization the browser uses for color-mix() results:
#: ``color(srgb R G B[/ A])`` — 0..1 float channels, alpha optional
#: (opaque). Plain-var surfaces serialize as ``rgb(R, G, B)`` (8-bit).
_COLOR_SRGB = re.compile(
r"color\(srgb\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)(?:\s*/\s*([0-9.]+))?"
)
#: The ±1/channel tolerance (±1/255 in the 0..1 float space) — browser
#: rounding is not pinned by the spec, and the window still fails any
#: legacy hardcoded value by orders of magnitude.
_TOL = 1.0 / 255.0 + 1e-9
def _hex_channels(hex_str: str) -> tuple[float, float, float]:
"""``#rrggbb`` → (r, g, b) in 0..1 floats."""
return (
int(hex_str[1:3], 16) / 255.0,
int(hex_str[3:5], 16) / 255.0,
int(hex_str[5:7], 16) / 255.0,
)
def _mix(
a_hex: str, percent: float, b_hex: str | None = None
) -> tuple[float, float, float, float]:
"""The browser's ``color-mix(in srgb, A p%, B)`` — CSS Color 4:
sRGB interpolation is PREMULTIPLIED (with ``B = transparent`` =
(0,0,0,0) — ``b_hex=None`` — the result is simply A at alpha
``p/100``). Returns (r, g, b, a) in 0..1 straight channels."""
a = (*_hex_channels(a_hex), 1.0)
b: tuple[float, float, float, float] = (
(0.0, 0.0, 0.0, 0.0)
if b_hex is None
else (*_hex_channels(b_hex), 1.0)
)
w = percent / 100.0
alpha = a[3] * w + b[3] * (1.0 - w)
if alpha == 0.0:
return (0.0, 0.0, 0.0, 0.0)
prem = tuple(a[i] * a[3] * w + b[i] * b[3] * (1.0 - w) for i in range(3))
return (prem[0] / alpha, prem[1] / alpha, prem[2] / alpha, alpha)
def _parse_color_srgb(ser: str) -> tuple[float, float, float, float]:
"""Parse the browser's ``color(srgb R G B[/ A])`` serialization
(the color-mix() result form) into 0..1 floats (opaque → a=1)."""
match = _COLOR_SRGB.search(ser)
assert match is not None, f"no color(srgb …) serialization in {ser!r}"
r, g, b = (float(match.group(i)) for i in (1, 2, 3))
a = float(match.group(4)) if match.group(4) is not None else 1.0
return (r, g, b, a)
def _close(got: tuple[float, float, float, float], want: tuple[float, float, float, float]) -> None:
"""±1/channel (float) on r/g/b, ~exact on alpha (the browser
serializes the exact mix alpha)."""
for i in range(3):
assert abs(got[i] - want[i]) <= _TOL, (
f"channel {i}: {got[i]} !~ {want[i]} (full: {got} vs {want})"
)
assert abs(got[3] - want[3]) <= 1e-4, f"alpha: {got[3]} != {want[3]}"
# ---------------------------------------------------------------------------
# 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 the shared conftest
server applies to its two string vars; this one pins all three,
including ``BOR_APP_NAME``, which the shared server leaves to the
process) and ``BOR_GIT_SOURCES`` is forced empty (the dev
``.env``'s git repo must not render as env rows in this
suite's app)."""
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) — no chat turn is
# ever sent in this suite, but the app boots with the same shape.
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
)
# The branding vars: "unset" = the template defaults (the code
# defaults, derived from the class fields — the local ``.env`` may
# carry the owner's values, and this suite's assertions need the
# TEMPLATE defaults, not the owner's).
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
# ---------------------------------------------------------------------------
# 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_via_api(app_url: str, cookies: dict[str, str]) -> None:
"""Admin ``PUT /api/ui-settings`` with the full theme (the API
seed — the UI save itself is test 1's job)."""
body = {**PALETTE, **SAVED_STRINGS}
r = httpx.put(f"{app_url}/api/ui-settings", json=body, cookies=cookies, timeout=10)
assert r.status_code == 200, r.text
assert r.json() == body, "the PUT must echo the new effective values"
def _hold_theme_puts(page: Page, hold_s: float = 0.6) -> None:
"""Intercept ``PUT /api/ui-settings`` and hold it for
``hold_s`` seconds (the archive-upload suite's §7.4 pattern):
while it is held, the page's fetch is guaranteed pending, so the
in-flight state (disabled buttons, the "Saving…" / "Resetting…"
labels) is observable deterministically — a localhost PUT
settles in milliseconds, so without the hold the window is a
race. GETs (the load + the save's refetch) pass straight
through."""
def handle(route: Route) -> None:
if route.request.method == "PUT":
time.sleep(hold_s)
route.continue_()
page.route("**/api/ui-settings", handle)
def _release_theme_puts(page: Page) -> None:
page.unroute("**/api/ui-settings")
def _fill_theme_form(
page: Page,
palette: dict[str, str],
strings: dict[str, str] | None = None,
) -> None:
"""Fill the 12 inputs: the 3 text fields (``strings``, default
the E2E set) + the 9 color pickers (``palette``)."""
text_values = strings if strings is not None else SAVED_STRINGS
page.fill("#theme-app-name", text_values["app_name"])
page.fill("#theme-placeholder", text_values["input_placeholder"])
page.fill("#theme-footer", text_values["footer_text"])
for field, value in palette.items():
page.fill(COLOR_INPUT_IDS[field], value)
def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None:
"""The RAW served HTML carries exactly one inline theme tag, with
all 9 vars = the given hexes, 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(colors)
assert raw.count(tag) == 1, f"expected exactly one theme tag:\n{tag}"
start = raw.index(tag)
head = raw.index("</head>")
assert start + len(tag) == head, "the tag must end exactly where </head> begins"
assert raw[start - 1] == "\n", "the tag must carry the injector's leading newline"
# ---------------------------------------------------------------------------
# 1. Defect 1 (Save): the §7.4 lifecycle lands and the OPEN page
# repaints the saved palette — no navigation, no reload
# ---------------------------------------------------------------------------
def test_save_applies_live_without_reload(page: Page, app_url: str, db_ready: None) -> None:
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-gate")).to_be_hidden()
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# The mount's initial load has settled (the row-less state — no
# tag, no overrides) before the test touches the form: no
# in-flight load can race the save's own refetch.
_wait_mount_settled(page)
# Fill the 12 inputs (the 3 strings + the 9-color palette) and Save
# through the real form — the PUT held so the §7.4 in-flight state
# is observable deterministically (the same lifecycle assertions
# as the phase-91 suite — the save's contract is unchanged).
_fill_theme_form(page, PALETTE)
_hold_theme_puts(page)
try:
page.click("#theme-save")
expect(page.locator("#theme-save")).to_be_disabled()
expect(page.locator("#theme-save")).to_have_text("Saving…")
expect(page.locator("#theme-reset")).to_be_disabled()
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
expect(page.locator("#theme-result")).to_have_attribute("role", "status")
expect(page.locator("#theme-save")).to_have_text("Save theme")
expect(page.locator("#theme-save")).to_be_enabled()
expect(page.locator("#theme-reset")).to_be_enabled()
finally:
_release_theme_puts(page)
# NO navigation (the URL never leaves the Theme view): the OPEN
# document mirrors the settled save — the tag's DOM text is the
# exact 9-var :root{…} the next load would serve (task 04's mirror
# half) and <html>'s inline style holds EXACTLY the 9 saved
# custom properties (the paint half — no stale pick; the pre-fix
# code cleared the preview onto the STALE served tag and fails
# this wait).
expect(page).to_have_url(APP_URL + "/theme.html")
_wait_settled_open_document(
page, _expected_tag_content(PALETTE), _expected_overrides(PALETTE, builtin)
)
# …and all 9 computed :root custom properties ARE the saved hexes
# (the open page painted the saved palette — defect 1 gone).
_wait_theme_computed(page, PALETTE)
# The server agrees: the RAW served HTML (a fresh request) carries
# the 9-var tag immediately before </head>.
r = httpx.get(app_url + "/", timeout=10)
assert r.status_code == 200
_assert_raw_tag(r.text, PALETTE)
# SPA navigation (the router's view switch — same document, NO
# reload): the theme view hides, the chat view shows, and the
# saved palette survives on the live computed values.
page.click('a.nav-link[href="/"]')
expect(page.locator("#view-theme")).to_be_hidden()
expect(page.locator("#view-chat")).to_be_visible(timeout=15_000)
assert (
page.evaluate(
"() => getComputedStyle(document.documentElement)"
".getPropertyValue('--brand').trim()"
)
== PALETTE["brand"]
)
# .send-btn { background: var(--brand) } — the used color is the
# saved brand, exact (8-bit hex → rgb() serialization).
expect(page.locator(".send-btn")).to_have_css(
"background-color", "rgb(79, 70, 229)"
)
# ---------------------------------------------------------------------------
# 2. Defect 1 (Reset): on a themed load, Reset removes the tag from
# the live document and paints the built-ins — no navigation
# ---------------------------------------------------------------------------
def test_reset_applies_live_without_reload(page: Page, app_url: str, db_ready: None) -> None:
builtin = _builtin_colors()
page.set_default_timeout(30_000)
login(page, app_url, next="/theme.html")
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
# Seed the theme via the API, THEN load the shell: the served
# document carries the 9-var tag (a THEMED load — the reset must
# remove it from the live document, not just stop serving it).
_seed_theme_via_api(app_url, _cookies(page))
page.goto(app_url + "/theme.html")
# Served-state sanity: the first paint IS the themed paint.
_wait_theme_computed(page, PALETTE)
# AND the mount's initial load has settled: its applyServedTheme
# has run (the <html> overrides exist — the document mirrors the
# served theme). From here on, only the reset's own refetch can
# re-reconcile the open document.
_wait_settled_open_document(
page, _expected_tag_content(PALETTE), _expected_overrides(PALETTE, builtin)
)
# Reset to defaults: the §7.4 lifecycle (the all-null PUT — the
# API's documented "defaults" operation).
_hold_theme_puts(page)
try:
page.click("#theme-reset")
expect(page.locator("#theme-reset")).to_be_disabled()
expect(page.locator("#theme-reset")).to_have_text("Resetting…")
expect(page.locator("#theme-save")).to_be_disabled()
expect(page.locator("#theme-result")).to_have_text(
"Reset to the built-in theme.", timeout=30_000
)
expect(page.locator("#theme-reset")).to_have_text("Reset to defaults")
expect(page.locator("#theme-reset")).to_be_enabled()
finally:
_release_theme_puts(page)
# NO navigation: the #bor-theme tag is REMOVED from the live
# document (effective = the built-ins → content null → the tag
# goes) and the page paints the 9 built-ins (parsed from
# styles.css in-test) with <html> carrying no overrides at all —
# the reset is the one case where the cleared-preview shape and
# the overrides shape agree: the style attribute is empty.
expect(page).to_have_url(APP_URL + "/theme.html")
_wait_settled_open_document(page, None, {})
_wait_theme_computed(page, builtin)
assert (
page.evaluate("() => (document.documentElement.getAttribute('style') || '').trim()")
== ""
)
# The server agrees: no tag served (the all-NULL row is the
# no-op)…
r = httpx.get(app_url + "/", timeout=10)
assert "bor-theme" not in r.text
# …and the byte-identical contract end to end: the with-row bytes
# equal a ROW-LESS deployment byte for byte (now 9-wide — a
# defaults-saved row never adds a byte).
with_row = r.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"
)
# ---------------------------------------------------------------------------
# 3. Defect 2: the tab's variables drive EVERY themed surface — the
# grid, the selection, the hovers, the wordmark (computed values)
# ---------------------------------------------------------------------------
def test_theme_controls_drive_the_whole_site(page: Page, app_url: str, db_ready: None) -> None:
page.set_default_timeout(30_000)
login(page, app_url, next="/")
_seed_theme_via_api(app_url, _cookies(page))
# Pre-paint with the 9th var: the raw served HTML carries the
# 9-var tag immediately before </head>, permitted in a real
# browser only via the phase-91 CSP hash (now 9-wide).
r = httpx.get(app_url + "/", timeout=10)
assert r.status_code == 200
_assert_raw_tag(r.text, PALETTE)
csp = r.headers.get("content-security-policy", "")
assert "style-src 'self' 'sha256-" in csp, csp
# Fresh load: the first paint is the themed paint.
page.goto(app_url + "/")
_wait_theme_computed(page, PALETTE)
# The background grid (the owner's named defect: "the background
# grid never changes color") — body::before's 1px line stops are
# color-mix(in srgb, var(--grid-line) 60%, transparent), which the
# browser serializes as color(srgb … / 0.6) at the grid line's
# channels (premultiplied sRGB with transparent = the source at
# the mix alpha).
grid_image = page.evaluate(
"() => getComputedStyle(document.body, '::before').backgroundImage"
)
want = _mix(PALETTE["grid_line"], 60.0, None)
stops = [
(float(m.group(1)), float(m.group(2)), float(m.group(3)),
float(m.group(4)) if m.group(4) is not None else 1.0)
for m in _COLOR_SRGB.finditer(grid_image)
]
assert any(
abs(stop[3] - want[3]) <= 1e-4
and all(
abs(got - want_c) <= _TOL
for got, want_c in zip(stop[:3], want[:3], strict=True)
)
for stop in stops
), f"the 60% --grid-line stop {want} is not in the grid: {grid_image!r}"
# ::selection — color-mix(in srgb, var(--brand) 45%, transparent):
# the brand's channels at alpha exactly 0.45.
selection = page.evaluate(
"() => getComputedStyle(document.documentElement, '::selection').backgroundColor"
)
_close(_parse_color_srgb(selection), _mix(PALETTE["brand"], 45.0, None))
# The button hovers (the "light pink on highlight" defect) — the
# derived --brand-hover: the brand at 86% toward white.
page.hover(".new-chat-btn")
new_chat_bg = page.evaluate(
"() => getComputedStyle(document.querySelector('.new-chat-btn')).backgroundColor"
)
_close(_parse_color_srgb(new_chat_bg), _mix(PALETTE["brand"], 86.0, "#ffffff"))
# Explicitly NOT the legacy indigo hover (#7d88f5 — the incoherent
# pre-phase-92 literal that survived under the rose brand):
assert new_chat_bg != "rgb(125, 136, 245)", new_chat_bg
assert abs(_parse_color_srgb(new_chat_bg)[0] - 125 / 255.0) > _TOL
page.hover(".send-btn")
send_bg = page.evaluate(
"() => getComputedStyle(document.querySelector('.send-btn')).backgroundColor"
)
_close(_parse_color_srgb(send_bg), _mix(PALETTE["brand"], 86.0, "#ffffff"))
# The house nav-link hover wash — the saved --brand-soft, EXACT
# (a plain var resolves to the 8-bit hex serialization).
page.hover("#nav-sources")
nav_bg = page.evaluate(
"() => getComputedStyle(document.querySelector('#nav-sources')).backgroundColor"
)
assert nav_bg == "rgb(30, 36, 71)", nav_bg
# The wordmark (the static brand-mark SVG that "never themes") —
# its first path's fill is var(--surface): the saved surface,
# EXACT.
fill = page.evaluate(
"() => getComputedStyle(document.querySelector('.brand-mark path')).fill"
)
assert fill == "rgb(17, 23, 48)", fill