**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`.
737 lines
31 KiB
Python
737 lines
31 KiB
Python
"""Phase 91 E2E (Playwright): the admin Theme tab — the pickers and
|
|
fields, the pre-paint theme, the admin gate, and the reset.
|
|
|
|
Source: ``TODO.md`` L4 — "Custom theming isn't really working. The
|
|
page loads red first and then the theme 'pops' into view, replacing
|
|
words and colors in an obvious way. Remove the custom css file
|
|
theming. Create a new admin tab that allows the user to change
|
|
everything the env var and custom css currently supports but with
|
|
buttons and color pickers. Theme should load immediately, not pop in
|
|
after the page load."
|
|
|
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
|
|
|
uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov
|
|
|
|
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
|
|
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.";
|
|
the inputs re-populate to the saved values; the persisted row is
|
|
the one saved; and the saved non-AA palette lists its failing
|
|
pair in ``#theme-contrast`` without blocking the save
|
|
(warning-only).
|
|
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 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
|
|
stylesheet application). The 3 strings stay on the brand.js boot
|
|
fetch (the B4 split: colors pre-paint, strings via the fetch).
|
|
3. ``test_anonymous_and_token_user_are_walled`` — the admin gate:
|
|
anonymous meets ``#theme-gate`` (sign-in link
|
|
``?next=/theme.html``) with ``#theme-content`` hidden and the
|
|
nav link hidden, and ``PUT /api/ui-settings`` 403s; a token user
|
|
(the phase-79 gate login) 403s the PUT too and never sees the
|
|
nav link (B5: admin-only, like Tuning/Tokens).
|
|
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
|
|
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:
|
|
``--ink`` set within 0.1 ratio of ``--bg`` lists the failing
|
|
pair(s) with the ratio in ``#theme-contrast`` (role=alert) as
|
|
soon as the picker moves; Save still succeeds (warning-only);
|
|
Reset restores the AA built-ins and hides the warning (the
|
|
suite's final state is clean).
|
|
|
|
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).
|
|
"""
|
|
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 Browser, BrowserContext, 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 app.models import UiSettings
|
|
from e2e.auth_helpers import login, login_with_token
|
|
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 (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] = {
|
|
"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 failing-pair leg (test 5): --ink set within 0.1 ratio of --bg
|
|
#: (the deterministic near-identical pick — 1.0:1 on both dark pairs).
|
|
FAILING_INK = "#101010"
|
|
|
|
#: 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 _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 _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>'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, str]) -> None:
|
|
"""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"])
|
|
for field in COLOR_FIELDS:
|
|
expect(page.locator(COLOR_INPUT_IDS[field])).to_have_value(colors[field])
|
|
|
|
|
|
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"
|
|
|
|
|
|
def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_000) -> None:
|
|
"""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
|
|
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 (admin): the form, the effective defaults, the §7.4 save
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
|
|
defaults = _template_defaults()
|
|
builtin = _builtin_colors()
|
|
page.set_default_timeout(30_000)
|
|
login(page, app_url, next="/theme.html")
|
|
|
|
# The admin header contract on this page: the ship-hidden "Theme"
|
|
# nav link is revealed (header.js, role === "admin") and marks
|
|
# the current page (the router's single-writer nav stamp).
|
|
expect(page.locator("#nav-theme")).to_be_visible(timeout=15_000)
|
|
expect(page.locator("#nav-theme")).to_have_attribute("aria-current", "page")
|
|
expect(page.locator("#sign-out-btn")).to_be_visible()
|
|
|
|
# The gate is hidden for the admin and the form is revealed
|
|
# (theme.js's whoami branch — the #git-sources-content pattern).
|
|
expect(page.locator("#theme-gate")).to_be_hidden()
|
|
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
|
|
|
|
# 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)
|
|
|
|
# Set a distinct palette + the 3 strings, then Save through the
|
|
# real form — the PUT held so the §7.4 in-flight state is
|
|
# observable deterministically.
|
|
_fill_theme_form(page, PALETTE)
|
|
_hold_theme_puts(page)
|
|
try:
|
|
page.click("#theme-save")
|
|
# In-flight: BOTH buttons disabled (one action at a time),
|
|
# the primary relabeled "Saving…" (never stale).
|
|
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()
|
|
# Settled: the role=status confirmation + the restored
|
|
# lifecycle (re-enabled, original label).
|
|
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)
|
|
|
|
# The inputs re-populate to the SAVED (effective) values (the
|
|
# 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 12 values
|
|
# — every palette color differs from its 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"
|
|
assert row.app_name == APP_NAME
|
|
assert row.input_placeholder == PLACEHOLDER
|
|
assert row.footer_text == FOOTER
|
|
for field in COLOR_FIELDS:
|
|
assert getattr(row, field) == PALETTE[field]
|
|
|
|
# The saved palette fails ONE of the five pairs — --bg on
|
|
# --brand (the button-ink pair: 3.0:1 < 4.5:1) — and the
|
|
# warning lists it. Save was NEVER blocked (the warning-only
|
|
# contract: the owner's homelab palette; the built-in stays AA).
|
|
contrast = page.locator("#theme-contrast")
|
|
expect(contrast).to_have_attribute("role", "alert")
|
|
expect(contrast).to_be_visible()
|
|
expect(contrast).to_have_text("--bg on --brand: 3.0:1 — needs 4.5:1")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Pre-paint, for everyone: the inline :root in the RAW served HTML
|
|
# + the computed palette at load (the no-pop-in proof), the B4
|
|
# strings via the boot fetch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_saved_theme_is_pre_paint_for_everyone(
|
|
page: Page, browser: Browser, app_url: str, db_ready: None
|
|
) -> None:
|
|
page.set_default_timeout(30_000)
|
|
login(page, app_url, next="/")
|
|
|
|
# The admin saves the theme (the API seed — test 1 owns the UI
|
|
# save path).
|
|
_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 9 vars = the saved
|
|
# hexes, immediately before </head> (the pre-paint mechanism the
|
|
# middleware unit tests pin — this is its observable
|
|
# consequence).
|
|
r = httpx.get(app_url + "/", timeout=10)
|
|
assert r.status_code == 200
|
|
_assert_raw_tag(r.text, PALETTE)
|
|
# The phase-91 CSP extension: the inline tag is permitted in a
|
|
# real browser only via the strict sha256 source expression
|
|
# (style-src 'self' 'sha256-…' appended to the A1 string — no
|
|
# 'unsafe-inline').
|
|
csp = r.headers.get("content-security-policy", "")
|
|
assert "style-src 'self' 'sha256-" in csp, csp
|
|
|
|
# The admin's browser: the same tag in the served document, and
|
|
# the computed custom properties equal the saved hexes at load
|
|
# (the inline tag precedes every stylesheet application — the
|
|
# first paint IS the themed paint).
|
|
page.goto(app_url + "/")
|
|
_assert_raw_tag(page.content(), PALETTE)
|
|
_wait_theme_computed(page, PALETTE)
|
|
|
|
# A FRESH anonymous context (no auth anywhere): the same inline
|
|
# tag + computed values — the theme is for EVERYONE, not just
|
|
# the admin who set it.
|
|
anon_ctx: BrowserContext | None = None
|
|
try:
|
|
anon_ctx = browser.new_context()
|
|
anon = anon_ctx.new_page()
|
|
anon.set_default_timeout(30_000)
|
|
anon.goto(app_url + "/")
|
|
_assert_raw_tag(anon.content(), PALETTE)
|
|
_wait_theme_computed(anon, PALETTE)
|
|
# The B4 split: the 3 strings are NOT pre-paint — they apply
|
|
# post-fetch via the /api/config boot fetch (brand.js) on the
|
|
# anonymous page too: the name (header brand + window
|
|
# global), the placeholder, and the footer line.
|
|
expect(anon.locator(".brand-text")).to_have_text(APP_NAME, timeout=15_000)
|
|
assert anon.evaluate("() => window.BOR_BRAND") == APP_NAME
|
|
expect(anon.locator("#message-input")).to_have_attribute(
|
|
"placeholder", PLACEHOLDER
|
|
)
|
|
expect(anon.locator(".footer-text").first).to_have_text(FOOTER)
|
|
finally:
|
|
if anon_ctx is not None:
|
|
anon_ctx.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. The gate + the 403s: anonymous sees the gate (never the form),
|
|
# the API 403s anonymous AND token users, the nav link is admin-only
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_anonymous_and_token_user_are_walled(
|
|
page: Page, browser: Browser, app_url: str, db_ready: None
|
|
) -> None:
|
|
page.set_default_timeout(30_000)
|
|
|
|
# --- anonymous: the gate, the hidden form, the hidden nav link ---
|
|
page.goto(app_url + "/theme.html")
|
|
# Phase 79: an anonymous visitor meets the in-app token gate on
|
|
# the shell — #main is inert behind it…
|
|
expect(page.locator("#auth-gate")).to_be_visible(timeout=30_000)
|
|
assert page.evaluate("() => document.getElementById('main').inert") is True
|
|
# …and the Theme view's OWN gate (the exact #sources-gate
|
|
# pattern) is the view's visible surface: the sign-in link
|
|
# returns to the Theme view (?next=/theme.html)…
|
|
expect(page.locator("#theme-gate")).to_be_visible(timeout=15_000)
|
|
expect(page.locator("#theme-gate a.sources-gate-link")).to_have_attribute(
|
|
"href", "/login.html?next=/theme.html"
|
|
)
|
|
# …while the form stays locked away (theme.js's non-admin
|
|
# branch) and the admin-only nav link is hidden.
|
|
expect(page.locator("#theme-content")).to_be_hidden()
|
|
expect(page.locator("#nav-theme")).to_be_hidden()
|
|
expect(page.locator("#sign-in-link")).to_be_visible()
|
|
|
|
# The API agrees from the context's own (empty) cookies: GET AND
|
|
# PUT are 403 "admin only" (the whole router sits behind
|
|
# require_admin — anonymous first).
|
|
anon_put = page.evaluate(
|
|
"""async () => (await fetch('/api/ui-settings', {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({brand: '#4f46e5'}),
|
|
})).status"""
|
|
)
|
|
assert anon_put == 403, f"anonymous PUT /api/ui-settings → {anon_put}"
|
|
anon_get = page.evaluate(
|
|
"() => fetch('/api/ui-settings').then((r) => r.status)"
|
|
)
|
|
assert anon_get == 403, f"anonymous GET /api/ui-settings → {anon_get}"
|
|
|
|
# --- a token user: the SAME wall (B5: admin-only, like
|
|
# Tuning/Tokens) ---
|
|
login(page, app_url, next="/")
|
|
r = httpx.post(
|
|
f"{app_url}/api/tokens",
|
|
json={"label": "e2e-theme-wall"},
|
|
cookies=_cookies(page),
|
|
timeout=10,
|
|
)
|
|
assert r.status_code == 201, r.text
|
|
token = r.json()["token"]
|
|
|
|
user_ctx: BrowserContext | None = None
|
|
try:
|
|
user_ctx = browser.new_context()
|
|
user = user_ctx.new_page()
|
|
user.set_default_timeout(30_000)
|
|
login_with_token(user, app_url, token)
|
|
# The nav link is hidden on their shell (role "user" — the
|
|
# header reveals the admin links only for role === "admin")…
|
|
expect(user.locator("#nav-theme")).to_be_hidden()
|
|
# …and the API 403s their own session (authenticated, just
|
|
# not an admin — 403, never 401).
|
|
put_status = user.evaluate(
|
|
"""async () => (await fetch('/api/ui-settings', {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({brand: '#4f46e5'}),
|
|
})).status"""
|
|
)
|
|
assert put_status == 403, f"token-user PUT /api/ui-settings → {put_status}"
|
|
finally:
|
|
if user_ctx is not None:
|
|
user_ctx.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Reset: the §7.4 lifecycle, the 12 defaults, NO theme tag, and
|
|
# byte-identical served HTML (the no-op injection contract)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_reset_restores_the_builtin_byte_identical(
|
|
page: Page, app_url: str, db_ready: None
|
|
) -> None:
|
|
defaults = _template_defaults()
|
|
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)
|
|
# The form settles on the effective defaults (the row-less
|
|
# state — the autouse clean truncated the row).
|
|
_expect_form_values(page, defaults, builtin)
|
|
|
|
# Save a distinct theme through the UI (the reset must undo a
|
|
# REAL save)…
|
|
_fill_theme_form(page, PALETTE)
|
|
_hold_theme_puts(page)
|
|
try:
|
|
page.click("#theme-save")
|
|
expect(page.locator("#theme-result")).to_have_text(
|
|
"Theme saved.", timeout=30_000
|
|
)
|
|
finally:
|
|
_release_theme_puts(page)
|
|
# …the theme is live server-side (the pre-reset baseline):
|
|
assert "bor-theme" in httpx.get(app_url + "/", timeout=10).text
|
|
|
|
# Reset to defaults: the §7.4 lifecycle again, with 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)
|
|
|
|
# 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
|
|
# five pairs).
|
|
expect(page.locator("#theme-contrast")).to_be_hidden()
|
|
|
|
# The served HTML is back to the built-in: NO theme tag anywhere
|
|
# (the all-NULL row is the no-op)…
|
|
r = httpx.get(app_url + "/", timeout=10)
|
|
assert "bor-theme" not in r.text
|
|
# …and the computed --brand is the stylesheet's built-in again.
|
|
page.goto(app_url + "/")
|
|
_wait_theme_computed(page, builtin)
|
|
|
|
# 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"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5. The WCAG contrast warning: listed with the ratio on the picker's
|
|
# input event, never blocks the save, hidden again after the reset
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_contrast_warning_does_not_block(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)
|
|
# The form settles on the built-in defaults — the warning is
|
|
# hidden (the built-in palette passes all five pairs).
|
|
expect(page.locator("#theme-ink")).to_have_value(builtin["ink"])
|
|
expect(page.locator("#theme-contrast")).to_be_hidden()
|
|
|
|
# Set ONLY --ink to a color within 0.1 ratio of --bg: the
|
|
# picker's input event previews it live AND re-runs the five
|
|
# pairs — --ink on --bg (and --ink on --surface, the ink is now
|
|
# the darker side of that pair too) fail, and each failing pair
|
|
# is listed with its ratio in the role=alert line.
|
|
page.fill("#theme-ink", FAILING_INK)
|
|
contrast = page.locator("#theme-contrast")
|
|
expect(contrast).to_have_attribute("role", "alert")
|
|
expect(contrast).to_be_visible(timeout=15_000)
|
|
expect(contrast).to_contain_text("--ink on --bg: 1.0:1 — needs 4.5:1")
|
|
expect(contrast).to_contain_text("--ink on --surface: 1.0:1 — needs 4.5:1")
|
|
|
|
# WARNING-ONLY: Save is never disabled by the warning (the
|
|
# owner's homelab palette — the built-in stays AA, so the
|
|
# default deployment is warning-free).
|
|
assert page.locator("#theme-save").is_enabled()
|
|
_hold_theme_puts(page)
|
|
try:
|
|
page.click("#theme-save")
|
|
expect(page.locator("#theme-result")).to_have_text(
|
|
"Theme saved.", timeout=30_000
|
|
)
|
|
# The saved palette still fails the pairs — the warning
|
|
# tracks the SAVED state (the save's refetch re-checks it).
|
|
expect(contrast).to_be_visible()
|
|
finally:
|
|
_release_theme_puts(page)
|
|
|
|
# Restore: Reset clears the failing pick (the suite's final
|
|
# state is clean) and the warning hides with the AA built-ins.
|
|
page.click("#theme-reset")
|
|
expect(page.locator("#theme-result")).to_have_text(
|
|
"Reset to the built-in theme.", timeout=30_000
|
|
)
|
|
expect(contrast).to_be_hidden()
|
|
expect(page.locator("#theme-ink")).to_have_value(builtin["ink"])
|