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