All verification is complete. Final report: **Phase 93 — Theme semantic completion: FINAL VERIFICATION PASS — ALL GREEN** - Verified full implementation in tree: migration `0016` (8 nullable semantic columns, applied at head), 17-var `BUILTIN_COLORS`/`COLOR_FIELDS`/`effective_settings`, API validation, `#view-theme` State-colors fieldset (17 pickers), `theme.js` FIELDS/PAIRS (5→8), `.page-head` surface panel (6 shell views + doc-edit + shared.html; login card / document sticky header audited as already-surfaced), mock_llm `content: None` fix - Fixed 2 pre-existing defects (both fail identically on baseline `d4f38ad`, proven via worktree A/B): `test_nav_rename_sources` — expected nav tail missing the phase-91 "Theme" link; `test_stale_ui_copy` — now truncates `saved_chats` before/after (house `test_suggestion_chips` pattern) so the seed-chip contract is deterministic on the shared dev DB (owner's 22 saved chats triggered phase-80 last-3-questions) - Tests: `uv run pytest --cov=app --cov-report=term-missing` → **1868 passed, app/ 99%** (>90% ✓); `uv run ruff check .` → clean; `uv run pyright` → **0 errors** - E2E: dedicated `uv run pytest tests/e2e/test_theme_semantic_completion.py -v --no-cov` → **8/8 in isolation** (all-gray 17-color theme: zero residual color on saved-result/Stale/Revoked/Local/tool-call elements, text labels intact, gray heads non-transparent, pre-paint tag, Reset → byte-identical no-tag); 15 theme/header/nav/responsive suites green in isolation; full 85-file combined run: only the 2 fixed pre-existing failures + 1 combined-run artifact (`test_sync_upload_progress`, green in isolation) - Completion criteria: (1) monochrome E2E ✓ (2) default byte-identical, no `#bor-theme` tag ✓ (3) all page heads on solid surface ✓ (4) suite/coverage/lint/E2E green ✓ (5) phases 01–92 no behavior change ✓ (6) commit left to harness per protocol - Notable: cleaned stray uvicorn leftovers from prior implementation pass (owner's `--reload` dev server untouched); no deviations from the phase design - Next pending phase: `94_ls_tree_drilldown`
782 lines
33 KiB
Python
782 lines
33 KiB
Python
"""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 17 hexes, the ``#bor-theme`` tag's DOM text is the exact
|
|
17-var ``:root{…}`` the next load would serve, and ``<html>``'s
|
|
inline style holds EXACTLY the 17 saved custom properties (no
|
|
stale pick). The server agrees (the raw ``/`` carries the 17-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 17
|
|
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 17-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 17-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 — PLUS the 8
|
|
# semantic state colors (phase 93), each distinct from its built-in.
|
|
# 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",
|
|
"ok_bg": "#101c24",
|
|
"ok_ink": "#86d9c0",
|
|
"err_bg": "#20101c",
|
|
"err_ink": "#e896b4",
|
|
"err_line": "#d1608c",
|
|
"accent_bg": "#1e1a10",
|
|
"accent_ink": "#d9c37a",
|
|
"accent_line": "#b8963e",
|
|
}
|
|
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 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 _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>'
|
|
|
|
|
|
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 17 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 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,
|
|
)
|
|
|
|
|
|
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 20 inputs: the 3 text fields (``strings``, default
|
|
the E2E set) + the 17 color pickers (``palette`` — all 17 vars,
|
|
phase 93)."""
|
|
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 17 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 20 inputs (the 3 strings + the 17-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 17-var :root{…} the next load would serve (task 04's
|
|
# mirror half) and <html>'s inline style holds EXACTLY the 17
|
|
# 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 17 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 17-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 17-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 17 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 17-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 full 17-var palette (phase 93 — the 8
|
|
# semantic state colors join the 9 identity vars): the raw served
|
|
# HTML carries the 17-var tag immediately before </head>,
|
|
# permitted in a real browser only via the phase-91 CSP hash
|
|
# (now 17-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
|