Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15a16a8fe0 | ||
|
|
c738105932 | ||
|
|
baefcde668 |
@@ -5,6 +5,9 @@
|
|||||||
# --- App ---
|
# --- App ---
|
||||||
BOR_ENVIRONMENT=development
|
BOR_ENVIRONMENT=development
|
||||||
# BOR_APP_NAME=Brain of Reese # display name on all pages — titles, header brand, status labels, aria text (phase 39)
|
# BOR_APP_NAME=Brain of Reese # display name on all pages — titles, header brand, status labels, aria text (phase 39)
|
||||||
|
# BOR_INPUT_PLACEHOLDER=Ask me anything… # composer placeholder, chat page (phase 62)
|
||||||
|
# BOR_FOOTER_TEXT=Powered by self-hosted models # footer line on every page (phase 62)
|
||||||
|
# BOR_THEME= # filename under frontend/assets/themes/ (e.g. indigo.css) — overrides the built-in palette; empty = built-in (phase 62)
|
||||||
# BOR_LOG_LEVEL=INFO
|
# BOR_LOG_LEVEL=INFO
|
||||||
# BOR_STATIC_DIR=frontend # dev default; container sets /app/static
|
# BOR_STATIC_DIR=frontend # dev default; container sets /app/static
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ RUN mkdir -p /out/assets \
|
|||||||
&& esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \
|
&& esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \
|
||||||
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
|
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
|
||||||
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
|
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
|
||||||
|
&& cp -r ./assets/themes /out/assets/themes \
|
||||||
&& cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html ./history.html ./shared.html ./doc-edit.html /out/
|
&& cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html ./history.html ./shared.html ./doc-edit.html /out/
|
||||||
|
|
||||||
# ---------- Stage 2: python dependencies ----------
|
# ---------- Stage 2: python dependencies ----------
|
||||||
|
|||||||
@@ -165,7 +165,8 @@ example-record-file.json"), the model can extend its own context with two
|
|||||||
server-side tools — on **grounded** (high-relevance) turns only:
|
server-side tools — on **grounded** (high-relevance) turns only:
|
||||||
|
|
||||||
* **`list_documents`** — lists every indexed document, one
|
* **`list_documents`** — lists every indexed document, one
|
||||||
`source/path — title` line each (the same order as the Sources page);
|
`source: X | path: Y | title: Z` line each (the same order as the
|
||||||
|
Sources page);
|
||||||
* **`read_document(source, path)`** — appends the **full** text of
|
* **`read_document(source, path)`** — appends the **full** text of
|
||||||
one more indexed document to the context (never truncated).
|
one more indexed document to the context (never truncated).
|
||||||
|
|
||||||
@@ -732,6 +733,9 @@ served locally (no CDN), `BOR_ENVIRONMENT=production`.
|
|||||||
| Env | Default | Meaning |
|
| Env | Default | Meaning |
|
||||||
|-----|---------|---------|
|
|-----|---------|---------|
|
||||||
| `BOR_APP_NAME` | `Brain of Reese` | the display name everywhere (phase 39): every page `<title>`, the header brand, the chat status labels ("… is thinking"), the empty-state greeting, and the aria/placeholder text. Served to the frontend by `GET /api/config` and applied by `assets/brand.js`; a name starting `Brain of ` keeps the bold split (`Brain of <strong>rest</strong>`), any other name renders in normal weight. Unset ⇒ byte-identical to the default |
|
| `BOR_APP_NAME` | `Brain of Reese` | the display name everywhere (phase 39): every page `<title>`, the header brand, the chat status labels ("… is thinking"), the empty-state greeting, and the aria/placeholder text. Served to the frontend by `GET /api/config` and applied by `assets/brand.js`; a name starting `Brain of ` keeps the bold split (`Brain of <strong>rest</strong>`), any other name renders in normal weight. Unset ⇒ byte-identical to the default |
|
||||||
|
| `BOR_INPUT_PLACEHOLDER` | `Ask me anything…` | the chat composer placeholder (`#message-input`, chat page only); applied by `assets/brand.js` from `GET /api/config`; unset ⇒ the template default |
|
||||||
|
| `BOR_FOOTER_TEXT` | `Powered by self-hosted models` | the footer line on all 9 pages (the `.footer-text` spans); same mechanism; unset ⇒ the template default |
|
||||||
|
| `BOR_THEME` | *(empty)* | a filename under `frontend/assets/themes/` (e.g. `indigo.css`) — a `:root` palette override injected after `styles.css` (later wins the cascade); the server refuses a malformed name at startup (bare `^[a-z0-9_-]+\.css$` filename); a missing file degrades to the built-in theme; unset ⇒ the built-in dark-tech palette |
|
||||||
| `BOR_DATABASE_URL` | local compose URL | SQLAlchemy URL (psycopg) |
|
| `BOR_DATABASE_URL` | local compose URL | SQLAlchemy URL (psycopg) |
|
||||||
| `BOR_LLM_BASE_URL` | `https://aipi.reeseapps.com/v1` | OpenAI-compatible endpoint |
|
| `BOR_LLM_BASE_URL` | `https://aipi.reeseapps.com/v1` | OpenAI-compatible endpoint |
|
||||||
| `BOR_LLM_API_KEY` | — (falls back to `$AIPI_KEY`) | aipi API key |
|
| `BOR_LLM_API_KEY` | — (falls back to `$AIPI_KEY`) | aipi API key |
|
||||||
@@ -761,6 +765,21 @@ served locally (no CDN), `BOR_ENVIRONMENT=production`.
|
|||||||
| `DEBUGPY` | `0` | `1` ⇒ attach-on-demand debugpy on `DEBUGPY_PORT` (default 5678) |
|
| `DEBUGPY` | `0` | `1` ⇒ attach-on-demand debugpy on `DEBUGPY_PORT` (default 5678) |
|
||||||
| `BOR_LOG_LEVEL` | `INFO` | app log level |
|
| `BOR_LOG_LEVEL` | `INFO` | app log level |
|
||||||
|
|
||||||
|
### Customizing the look
|
||||||
|
|
||||||
|
The app ships as “Brain of Reese”, but every identity string is an env
|
||||||
|
var: `BOR_APP_NAME` (display name), `BOR_INPUT_PLACEHOLDER` (chat composer
|
||||||
|
placeholder), and `BOR_FOOTER_TEXT` (the footer line on every page) — all
|
||||||
|
served by `GET /api/config` and applied by `assets/brand.js` at boot.
|
||||||
|
Color themes are plain CSS variable overrides: write a `:root` block in
|
||||||
|
`frontend/assets/themes/` and point `BOR_THEME` at the filename
|
||||||
|
([the themes `README.md`](frontend/assets/themes/README.md) is the
|
||||||
|
authoring guide, `indigo.css` the working example). The server refuses a
|
||||||
|
malformed `BOR_THEME` at startup, and a missing theme file degrades to the
|
||||||
|
built-in palette — the page never breaks. Leave everything unset and the
|
||||||
|
app renders the defaults byte-identically: the dark-tech palette shown
|
||||||
|
throughout this README is the no-config default.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
- **`401` from aipi** — set `BOR_LLM_API_KEY` (or `$AIPI_KEY`).
|
- **`401` from aipi** — set `BOR_LLM_API_KEY` (or `$AIPI_KEY`).
|
||||||
|
|||||||
+13
-5
@@ -1,5 +1,7 @@
|
|||||||
"""Public app metadata (display name + version) for the frontend brand
|
"""Public app metadata (display name + version) for the frontend brand
|
||||||
layer, plus the phase-59 docs-push flag (the "Save as doc" gating)."""
|
layer, the phase-59 docs-push flag (the "Save as doc" gating), and the
|
||||||
|
phase-62 UI customization strings (composer placeholder, footer line,
|
||||||
|
theme file name)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
@@ -12,12 +14,18 @@ router = APIRouter(tags=["config"])
|
|||||||
@router.get("/config")
|
@router.get("/config")
|
||||||
def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str | bool]: # noqa: B008
|
def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str | bool]: # noqa: B008
|
||||||
"""Public app metadata for the frontend brand layer (phase 39) +
|
"""Public app metadata for the frontend brand layer (phase 39) +
|
||||||
the phase-59 ``docs_repo_configured`` flag — the chat page's
|
the phase-59 ``docs_repo_configured`` flag + the phase-62 UI
|
||||||
"Save as doc" button gating, surfaced the way ``app_name`` is
|
customization keys (``input_placeholder``, ``footer_text``,
|
||||||
(the SAME boot fetch, no new network surface). Inert false while
|
``theme``) — all display strings, the SAME boot fetch (no new
|
||||||
``BOR_DOCS_REPO`` is empty (the feature is off, D3)."""
|
network surface) and the same public posture as ``app_name``
|
||||||
|
(no secrets). Values are passed through verbatim: the frontend
|
||||||
|
brand layer treats an empty string as "keep the template default"
|
||||||
|
(the unset => byte-identical contract)."""
|
||||||
return {
|
return {
|
||||||
"app_name": settings.app_name,
|
"app_name": settings.app_name,
|
||||||
"version": settings.app_version,
|
"version": settings.app_version,
|
||||||
"docs_repo_configured": settings.docs_configured,
|
"docs_repo_configured": settings.docs_configured,
|
||||||
|
"input_placeholder": settings.input_placeholder,
|
||||||
|
"footer_text": settings.footer_text,
|
||||||
|
"theme": settings.theme,
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-4
@@ -46,6 +46,15 @@ class Settings(BaseSettings):
|
|||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
static_dir: str = "frontend"
|
static_dir: str = "frontend"
|
||||||
|
|
||||||
|
# --- UI customization (phase 62, TODO L3) ---
|
||||||
|
# Defaults are the phase-61 neutral copy — UNSET => byte-identical UI.
|
||||||
|
input_placeholder: str = "Ask me anything…"
|
||||||
|
footer_text: str = "Powered by self-hosted models"
|
||||||
|
#: Theme file NAME under frontend/assets/themes/ (e.g. "indigo.css");
|
||||||
|
#: empty = the built-in dark-tech palette. Validated: bare filename
|
||||||
|
#: only — no paths, no ".." (no-CDN: served from the static dir).
|
||||||
|
theme: str = ""
|
||||||
|
|
||||||
# --- Database (PostgreSQL 17 + pgvector) ---
|
# --- Database (PostgreSQL 17 + pgvector) ---
|
||||||
database_url: str = "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese"
|
database_url: str = "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese"
|
||||||
|
|
||||||
@@ -202,6 +211,27 @@ class Settings(BaseSettings):
|
|||||||
#: separate from ``sources_dir`` (the source checkouts).
|
#: separate from ``sources_dir`` (the source checkouts).
|
||||||
docs_work_dir: str = "~/bor-docs"
|
docs_work_dir: str = "~/bor-docs"
|
||||||
|
|
||||||
|
@field_validator("theme", mode="after")
|
||||||
|
@classmethod
|
||||||
|
def _theme_bare_css_filename(cls, v: str) -> str:
|
||||||
|
r"""Phase 62 (A5): the theme is a FILE NAME under
|
||||||
|
``frontend/assets/themes/``, served from the static dir
|
||||||
|
(no-CDN) — so only a bare lowercase ``.css`` filename is legal
|
||||||
|
(``^[a-z0-9_-]+\.css$``). Anything else (a path, ``..``,
|
||||||
|
uppercase, a missing extension) is a typo that would silently
|
||||||
|
404 at runtime — fail loudly at startup instead, naming the
|
||||||
|
offending value and the allowed shape (the phase-56 fail-loud
|
||||||
|
house style)."""
|
||||||
|
if v == "":
|
||||||
|
return v # empty = the built-in dark-tech palette
|
||||||
|
if re.fullmatch(r"[a-z0-9_-]+\.css", v) is None:
|
||||||
|
raise ValueError(
|
||||||
|
"theme must be a bare .css filename under "
|
||||||
|
"frontend/assets/themes/ (lowercase letters/digits/"
|
||||||
|
f"'_'/'-', e.g. 'indigo.css') — got {v!r}"
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
@field_validator("import_extensions")
|
@field_validator("import_extensions")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _import_extensions_known(cls, v: str) -> str:
|
def _import_extensions_known(cls, v: str) -> str:
|
||||||
@@ -263,10 +293,10 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Suggested questions (onboarding + empty state).
|
# Suggested questions (onboarding + empty state).
|
||||||
suggestions: list[str] = [
|
suggestions: list[str] = [
|
||||||
"How is my Kubernetes cluster set up?",
|
"What documents are in the knowledge base?",
|
||||||
"What's my backup strategy?",
|
"Which source does each answer come from?",
|
||||||
"How do I deploy a new service?",
|
"How do I add a new source?",
|
||||||
"What's currently running in the homelab?",
|
"Summarize the most recent document.",
|
||||||
]
|
]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
+11
-7
@@ -26,7 +26,8 @@ task 04):
|
|||||||
path (the kill switch).
|
path (the kill switch).
|
||||||
2. Each tool call the model emits is executed server-side against
|
2. Each tool call the model emits is executed server-side against
|
||||||
Postgres only (no LLM, no network): ``list_documents`` returns the
|
Postgres only (no LLM, no network): ``list_documents`` returns the
|
||||||
indexed catalog — one ``source/path — title`` line per document,
|
indexed catalog — one ``source: X | path: Y | title: Z`` line per
|
||||||
|
document (phase 63: labeled fields — unambiguous for LLM parsing),
|
||||||
``GET /api/docs`` order (uncapped in v1; the UI never shows it, only
|
``GET /api/docs`` order (uncapped in v1; the UI never shows it, only
|
||||||
the model does) — and ``read_document`` returns the document's **full**
|
the model does) — and ``read_document`` returns the document's **full**
|
||||||
content (A7-revised contract: never truncated).
|
content (A7-revised contract: never truncated).
|
||||||
@@ -85,7 +86,7 @@ AGENT_TOOLS: list[dict[str, Any]] = [
|
|||||||
"name": "list_documents",
|
"name": "list_documents",
|
||||||
"description": (
|
"description": (
|
||||||
"List every document indexed in the knowledge base, one "
|
"List every document indexed in the knowledge base, one "
|
||||||
"`source/path — title` line each"
|
"`source: X | path: Y | title: Z` line each"
|
||||||
),
|
),
|
||||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||||
},
|
},
|
||||||
@@ -104,15 +105,17 @@ AGENT_TOOLS: list[dict[str, Any]] = [
|
|||||||
"source": {
|
"source": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": (
|
"description": (
|
||||||
"The document's source (a directory basename, "
|
"The document's source, as shown after 'source: ' in the "
|
||||||
"e.g. 'Homelab')."
|
"list_documents output (e.g. 'Homelab' from "
|
||||||
|
"'source: Homelab | path: homelab/aws-route53.md')."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"path": {
|
"path": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": (
|
"description": (
|
||||||
"The document's path relative to its source "
|
"The document's path, as shown after 'path: ' in the "
|
||||||
"directory."
|
"list_documents output (e.g. 'homelab/aws-route53.md' from "
|
||||||
|
"'source: Homelab | path: homelab/aws-route53.md')."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -186,7 +189,8 @@ def _execute_tool(
|
|||||||
if call.name == "list_documents":
|
if call.name == "list_documents":
|
||||||
rows = list_catalog(db)
|
rows = list_catalog(db)
|
||||||
listing = f"{len(rows)} documents:\n" + "\n".join(
|
listing = f"{len(rows)} documents:\n" + "\n".join(
|
||||||
f"{source}/{path} — {title}" for source, path, title in rows
|
f"source: {source} | path: {path} | title: {title}"
|
||||||
|
for source, path, title in rows
|
||||||
)
|
)
|
||||||
holder.tool_calls += 1
|
holder.tool_calls += 1
|
||||||
return listing
|
return listing
|
||||||
|
|||||||
+157
-58
@@ -31,13 +31,42 @@
|
|||||||
* skipped so page source is never mutated;
|
* skipped so page source is never mutated;
|
||||||
* 4. an attribute pass — the aria-label / placeholder / meta
|
* 4. an attribute pass — the aria-label / placeholder / meta
|
||||||
* content attributes containing the literal (the #messages
|
* content attributes containing the literal (the #messages
|
||||||
* aria-label, the input label, the meta descriptions).
|
* aria-label, the input label, the meta descriptions);
|
||||||
|
* 5–7. Phase 62 (owner-locked 2026-09-01, TODO L3) — the SAME
|
||||||
|
* settled config also carries the three UI-customization
|
||||||
|
* keys, applied in this same .then, AFTER the app_name
|
||||||
|
* passes and INDEPENDENT of them (they apply even when the
|
||||||
|
* name is the default/empty). Each empty value is a no-op —
|
||||||
|
* an unset deployment stays byte-identical:
|
||||||
|
* 5. input_placeholder — non-empty → the #message-input
|
||||||
|
* placeholder (the chat page only; every other page
|
||||||
|
* no-ops via the null guard);
|
||||||
|
* 6. footer_text — non-empty → every .footer-text node's
|
||||||
|
* textContent (all 9 pages, the phase-61 hook; an
|
||||||
|
* operator string can't inject markup via textContent);
|
||||||
|
* 7. theme — non-empty → a <link rel="stylesheet"> inserted
|
||||||
|
* IMMEDIATELY AFTER the styles.css link (the theme's
|
||||||
|
* :root overrides win by cascade order). The styles.css
|
||||||
|
* finder matches the RAW attribute path with any query/
|
||||||
|
* fragment stripped — the phase-33/54 cache-busting
|
||||||
|
* middleware serves the HTML with the asset refs rewritten
|
||||||
|
* to "…/styles.css?v=<token>", and el.href (the absolute
|
||||||
|
* URL) would never end with "styles.css" once versioned.
|
||||||
|
* The filename is validated server-side (a bare *.css
|
||||||
|
* name — no path can reach here via /api/config); a
|
||||||
|
* MISSING file degrades to the built-in theme (onerror →
|
||||||
|
* console.warn — A5, the page never breaks). Guarded by
|
||||||
|
* #theme-override: never inserted twice.
|
||||||
* • fetch failure / empty name → the default stays + console.warn
|
* • fetch failure / empty name → the default stays + console.warn
|
||||||
* (the loadHealth house style: progressive enhancement, the page
|
* (the loadHealth house style: progressive enhancement, the page
|
||||||
* never breaks).
|
* never breaks).
|
||||||
*
|
*
|
||||||
* No-op property: with BOR_APP_NAME unset the /api/config answer IS the
|
* No-op property: with the customization env vars (BOR_APP_NAME,
|
||||||
* literal, so every replacement below is a byte-identical no-op.
|
* BOR_INPUT_PLACEHOLDER, BOR_FOOTER_TEXT, BOR_THEME) unset, /api/config
|
||||||
|
* answers with the template defaults themselves — the name IS the
|
||||||
|
* literal, the placeholder and footer are the phase-61 copy (re-setting
|
||||||
|
* them is invisible), the theme is empty (the link is skipped) — so an
|
||||||
|
* unset deployment renders byte-identical.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* The synchronous default — set BEFORE any fetch, so module scripts
|
/* The synchronous default — set BEFORE any fetch, so module scripts
|
||||||
@@ -90,65 +119,135 @@ window.BOR_CONFIG_PROMISE = BOR_CONFIG_PROMISE;
|
|||||||
function applyBrand() {
|
function applyBrand() {
|
||||||
BOR_CONFIG_PROMISE.then((cfg) => {
|
BOR_CONFIG_PROMISE.then((cfg) => {
|
||||||
const name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : "";
|
const name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : "";
|
||||||
if (!name) return; // empty / missing: the default stands
|
if (name) {
|
||||||
window.BOR_BRAND = name;
|
// A configured name: apply it (empty / missing: the default
|
||||||
|
// stands — and the phase-62 keys below are INDEPENDENT of the
|
||||||
|
// name, so they still apply).
|
||||||
|
window.BOR_BRAND = name;
|
||||||
|
|
||||||
// 1. The document title (global replace of the literal — covers
|
// 1. The document title (global replace of the literal — covers
|
||||||
// every page's static "<…> · Brain of Reese" titles).
|
// every page's static "<…> · Brain of Reese" titles).
|
||||||
document.title = document.title.replaceAll(BRAND_LITERAL, name);
|
document.title = document.title.replaceAll(BRAND_LITERAL, name);
|
||||||
|
|
||||||
// 2. The header brand on every page: a name starting "Brain of "
|
// 2. The header brand on every page: a name starting "Brain of "
|
||||||
// keeps the bold split (the current look), anything else
|
// keeps the bold split (the current look), anything else
|
||||||
// renders plain — the name is always escaped.
|
// renders plain — the name is always escaped.
|
||||||
for (const el of document.querySelectorAll(".brand-text")) {
|
for (const el of document.querySelectorAll(".brand-text")) {
|
||||||
if (name.startsWith("Brain of ")) {
|
if (name.startsWith("Brain of ")) {
|
||||||
const rest = name.slice("Brain of ".length);
|
const rest = name.slice("Brain of ".length);
|
||||||
el.innerHTML = `Brain of <strong>${escapeHTML(rest)}</strong>`;
|
el.innerHTML = `Brain of <strong>${escapeHTML(rest)}</strong>`;
|
||||||
} else {
|
} else {
|
||||||
el.textContent = name;
|
el.textContent = name;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Prose: a TreeWalker over the body's text nodes replaces the
|
|
||||||
// literal (the empty-state h1, any other copy). Text nodes
|
|
||||||
// inside <script>/<style> are rejected — the page source must
|
|
||||||
// never be rewritten.
|
|
||||||
const walker = document.createTreeWalker(
|
|
||||||
document.body,
|
|
||||||
NodeFilter.SHOW_TEXT,
|
|
||||||
{
|
|
||||||
acceptNode(node) {
|
|
||||||
const tag = node.parentElement ? node.parentElement.tagName : "";
|
|
||||||
return tag === "SCRIPT" || tag === "STYLE"
|
|
||||||
? NodeFilter.FILTER_REJECT
|
|
||||||
: NodeFilter.FILTER_ACCEPT;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const nodes = [];
|
|
||||||
while (walker.nextNode()) nodes.push(walker.currentNode);
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.nodeValue && node.nodeValue.includes(BRAND_LITERAL)) {
|
|
||||||
node.nodeValue = node.nodeValue.replaceAll(BRAND_LITERAL, name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Attributes: the #messages aria-label, the composer input
|
|
||||||
// label, the meta descriptions — aria-label / placeholder /
|
|
||||||
// meta content only, each replaced in place.
|
|
||||||
for (const el of document.querySelectorAll(
|
|
||||||
"[aria-label], [placeholder], meta[content]",
|
|
||||||
)) {
|
|
||||||
for (const attr of ["aria-label", "placeholder"]) {
|
|
||||||
const v = el.getAttribute(attr);
|
|
||||||
if (v && v.includes(BRAND_LITERAL)) {
|
|
||||||
el.setAttribute(attr, v.replaceAll(BRAND_LITERAL, name));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (el.tagName === "META") {
|
|
||||||
const v = el.getAttribute("content");
|
// 3. Prose: a TreeWalker over the body's text nodes replaces the
|
||||||
if (v && v.includes(BRAND_LITERAL)) {
|
// literal (the empty-state h1, any other copy). Text nodes
|
||||||
el.setAttribute("content", v.replaceAll(BRAND_LITERAL, name));
|
// inside <script>/<style> are rejected — the page source must
|
||||||
|
// never be rewritten.
|
||||||
|
const walker = document.createTreeWalker(
|
||||||
|
document.body,
|
||||||
|
NodeFilter.SHOW_TEXT,
|
||||||
|
{
|
||||||
|
acceptNode(node) {
|
||||||
|
const tag = node.parentElement ? node.parentElement.tagName : "";
|
||||||
|
return tag === "SCRIPT" || tag === "STYLE"
|
||||||
|
? NodeFilter.FILTER_REJECT
|
||||||
|
: NodeFilter.FILTER_ACCEPT;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const nodes = [];
|
||||||
|
while (walker.nextNode()) nodes.push(walker.currentNode);
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.nodeValue && node.nodeValue.includes(BRAND_LITERAL)) {
|
||||||
|
node.nodeValue = node.nodeValue.replaceAll(BRAND_LITERAL, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Attributes: the #messages aria-label, the composer input
|
||||||
|
// label, the meta descriptions — aria-label / placeholder /
|
||||||
|
// meta content only, each replaced in place.
|
||||||
|
for (const el of document.querySelectorAll(
|
||||||
|
"[aria-label], [placeholder], meta[content]",
|
||||||
|
)) {
|
||||||
|
for (const attr of ["aria-label", "placeholder"]) {
|
||||||
|
const v = el.getAttribute(attr);
|
||||||
|
if (v && v.includes(BRAND_LITERAL)) {
|
||||||
|
el.setAttribute(attr, v.replaceAll(BRAND_LITERAL, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (el.tagName === "META") {
|
||||||
|
const v = el.getAttribute("content");
|
||||||
|
if (v && v.includes(BRAND_LITERAL)) {
|
||||||
|
el.setAttribute("content", v.replaceAll(BRAND_LITERAL, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
|
||||||
|
// config also carries the three customization keys — applied here,
|
||||||
|
// INDEPENDENT of the app_name block above (they apply even when
|
||||||
|
// the name is the default/empty). Each empty value is a no-op, so
|
||||||
|
// an unset deployment stays byte-identical (no attribute or
|
||||||
|
// element touched, no second network call).
|
||||||
|
const placeholder =
|
||||||
|
typeof cfg?.input_placeholder === "string" ? cfg.input_placeholder : "";
|
||||||
|
if (placeholder) {
|
||||||
|
// 5. The composer placeholder — the chat page only:
|
||||||
|
// #message-input exists only on index.html, so every other
|
||||||
|
// page no-ops via the null guard.
|
||||||
|
document.querySelector("#message-input")?.setAttribute(
|
||||||
|
"placeholder",
|
||||||
|
placeholder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const footerText =
|
||||||
|
typeof cfg?.footer_text === "string" ? cfg.footer_text : "";
|
||||||
|
if (footerText) {
|
||||||
|
// 6. The footer line on every page (all 9 carry the phase-61
|
||||||
|
// .footer-text hook). textContent on purpose: an operator
|
||||||
|
// string can't inject markup.
|
||||||
|
document.querySelectorAll(".footer-text").forEach((el) => {
|
||||||
|
el.textContent = footerText;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeName = typeof cfg?.theme === "string" ? cfg.theme : "";
|
||||||
|
if (themeName) {
|
||||||
|
// 7. The theme stylesheet — a <link> inserted IMMEDIATELY AFTER
|
||||||
|
// the existing styles.css link, so the theme's :root
|
||||||
|
// overrides win by cascade order. The filename is validated
|
||||||
|
// server-side (task 01: a bare *.css name) — no path input
|
||||||
|
// can reach here via /api/config. Guarded by #theme-override:
|
||||||
|
// never applied twice (the loadHealth house style — never
|
||||||
|
// break the page, never double-apply). A missing file
|
||||||
|
// degrades to the built-in theme (A5): the onerror warns,
|
||||||
|
// nothing else.
|
||||||
|
if (!document.getElementById("theme-override")) {
|
||||||
|
// Phase 33/54: the served HTML may carry the cache-bust query
|
||||||
|
// (?v=<token>) on the asset ref — match on the RAW attribute
|
||||||
|
// path with query/fragment stripped, never on el.href (the
|
||||||
|
// absolute URL, which would include the token).
|
||||||
|
const stylesLink = Array.from(
|
||||||
|
document.querySelectorAll('link[rel="stylesheet"]'),
|
||||||
|
).find((el) => {
|
||||||
|
const ref = (el.getAttribute("href") || "").split(/[?#]/)[0];
|
||||||
|
return ref.endsWith("styles.css");
|
||||||
|
});
|
||||||
|
if (stylesLink) {
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.href = "/assets/themes/" + themeName;
|
||||||
|
link.id = "theme-override";
|
||||||
|
link.onerror = () =>
|
||||||
|
console.warn(
|
||||||
|
"brand: theme " + themeName +
|
||||||
|
" did not load — the built-in theme stands.",
|
||||||
|
);
|
||||||
|
stylesLink.insertAdjacentElement("afterend", link);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Themes — authoring guide (phase 62)
|
||||||
|
|
||||||
|
A theme is a small CSS file that overrides the `:root` palette variables.
|
||||||
|
That is the entire mechanism — no component CSS is theme-aware, every
|
||||||
|
color in the app reads a `--*` variable, so a later stylesheet wins by
|
||||||
|
cascade order.
|
||||||
|
|
||||||
|
## How a theme loads
|
||||||
|
|
||||||
|
1. Set `BOR_THEME=<file>` (a bare FILENAME, e.g. `BOR_THEME=indigo.css`).
|
||||||
|
`app/config.py` validates it at startup — anything not matching
|
||||||
|
`^[a-z0-9_-]+\.css$` (a path, `..`, uppercase, a missing extension)
|
||||||
|
refuses to boot, naming the value (the phase-56 fail-loud house
|
||||||
|
style).
|
||||||
|
2. The value rides the existing boot fetch: `GET /api/config` →
|
||||||
|
`frontend/assets/brand.js` inserts
|
||||||
|
`<link rel="stylesheet" href="/assets/themes/<file>">` IMMEDIATELY
|
||||||
|
AFTER the `styles.css` link — later wins the cascade.
|
||||||
|
3. A theme file MISSING at runtime (typo past the validator, or the file
|
||||||
|
deleted after the image was built) degrades to the built-in theme —
|
||||||
|
`brand.js` warns in the console, the page never breaks (the
|
||||||
|
loadHealth house style, A5).
|
||||||
|
4. UNSET (`BOR_THEME` empty) ⇒ no link is inserted at all — the
|
||||||
|
deployment renders byte-identical to the built-in dark-tech palette.
|
||||||
|
Loading is opt-in via the env var, never by directory scanning.
|
||||||
|
|
||||||
|
## The variables
|
||||||
|
|
||||||
|
A theme overrides the **8 identity variables** in a single `:root` block.
|
||||||
|
Built-in values (from `frontend/assets/styles.css`) for reference:
|
||||||
|
|
||||||
|
| Variable | Built-in | Role |
|
||||||
|
| -------------- | ---------- | ----------------------------------------------------------- |
|
||||||
|
| `--bg` | `#0f0a0a` | page background (text on it: `--ink`) |
|
||||||
|
| `--surface` | `#1a0f0f` | cards, panels, code blocks (text on it: `--ink`) |
|
||||||
|
| `--ink` | `#f0e6e6` | primary text |
|
||||||
|
| `--ink-soft` | `#b8a8a8` | secondary text (5.1:1 on `--surface`) |
|
||||||
|
| `--line` | `#2d1a1a` | decorative 1px borders (no contrast obligation) |
|
||||||
|
| `--brand` | `#f43f5e` | brand accent — buttons, links (text ON it is `--bg`) |
|
||||||
|
| `--brand-soft` | `#2d0a0a` | brand-tinted surface (chips, hover washes) |
|
||||||
|
| `--brand-ink` | `#fca5a5` | brand-tinted text (9.0:1 on `--surface`) |
|
||||||
|
|
||||||
|
The **semantic families are deliberately NOT identity** — do not
|
||||||
|
override them: `--accent-*` (deflection amber), `--ok-*` (success
|
||||||
|
green), `--err-*` (error red) encode *states*, and they are already AA
|
||||||
|
in the built-in theme. A theme that keeps them stays honest: your
|
||||||
|
indigo app still tells success from error.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Filename:** `^[a-z0-9_-]+\.css$` — lowercase, bare filename, in this
|
||||||
|
directory. The server validator rejects anything else at startup
|
||||||
|
(naming the value), so keep the env var and the filename in lockstep.
|
||||||
|
- **One `:root` block.** No selectors, no `@media`, no other
|
||||||
|
declarations — the file overrides variables and nothing else (the
|
||||||
|
cascade does the rest). `indigo.css` is the reference shape.
|
||||||
|
- **Every text/background pair ≥ 4.5:1** (AGENTS.md rule 5, WCAG 2.1
|
||||||
|
AA). The pairs that matter: `--ink` on `--bg` and on `--surface`,
|
||||||
|
`--ink-soft` on `--surface`, `--bg` on `--brand` (the text on brand
|
||||||
|
buttons is the DARK background ink — that is the pattern), and
|
||||||
|
`--brand-ink` on `--surface`.
|
||||||
|
- **Never white-on-brand.** The built-in documents the trap: white on
|
||||||
|
`#f43f5e` is 3.7:1 — it fails. Pick a `--brand` whose luminance
|
||||||
|
carries the dark `--bg` ink at ≥ 4.5:1 (indigo.css: 6.5:1).
|
||||||
|
- Keep `--line` close to `--surface` (a 1px step, not a wall) — the
|
||||||
|
layout reads by surfaces, not borders.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- **Dev:** works immediately — the file is served from the static dir
|
||||||
|
(`frontend/`, `BOR_STATIC_DIR`), so drop the file in, set
|
||||||
|
`BOR_THEME`, restart uvicorn.
|
||||||
|
- **Container:** rebuild the image. Stage 1 ships the WHOLE directory
|
||||||
|
(`cp -r ./assets/themes /out/assets/themes` — no per-file esbuild), so
|
||||||
|
a new or edited theme file needs **no Containerfile change** (A7):
|
||||||
|
whatever is in `frontend/assets/themes/` at build time is what the
|
||||||
|
image serves at `/assets/themes/…`.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/* Phase 62 example theme — dark indigo/slate.
|
||||||
|
Overrides the :root identity palette from styles.css; every
|
||||||
|
text/background pair meets WCAG 2.1 AA (>= 4.5:1):
|
||||||
|
ink on bg 15.8:1 · ink on surface 14.7:1 · ink-soft on surface 8.3:1
|
||||||
|
dark bg ink on brand 6.5:1 · brand-ink on surface 12.0:1.
|
||||||
|
Semantic families (accent/ok/err) inherit the built-in theme.
|
||||||
|
Load with BOR_THEME=indigo.css (authoring guide: themes/README.md). */
|
||||||
|
:root {
|
||||||
|
--bg: #0a0e1a;
|
||||||
|
--surface: #111726;
|
||||||
|
--ink: #e6e9f0;
|
||||||
|
--ink-soft: #a8b0c8;
|
||||||
|
--line: #232c44;
|
||||||
|
--brand: #818cf8;
|
||||||
|
--brand-soft: #1a1f38;
|
||||||
|
--brand-ink: #c7d2fe;
|
||||||
|
}
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
|
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
<div class="container footer-inner">
|
<div class="container footer-inner">
|
||||||
<span>Powered by Reese's self-hosted models</span>
|
<span class="footer-text">Powered by self-hosted models</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -147,7 +147,7 @@
|
|||||||
|
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
<div class="container footer-inner">
|
<div class="container footer-inner">
|
||||||
<span>Powered by Reese's self-hosted models</span>
|
<span class="footer-text">Powered by self-hosted models</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
maxlength="500"
|
maxlength="500"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
placeholder="https://github.com/you/homelab.git"
|
placeholder="https://github.com/you/your-repo.git"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<button type="submit" id="git-source-add">Add source</button>
|
<button type="submit" id="git-source-add">Add source</button>
|
||||||
@@ -240,7 +240,7 @@
|
|||||||
|
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
<div class="container footer-inner">
|
<div class="container footer-inner">
|
||||||
<span>Powered by Reese's self-hosted models</span>
|
<span class="footer-text">Powered by self-hosted models</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,7 @@
|
|||||||
|
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
<div class="container footer-inner">
|
<div class="container footer-inner">
|
||||||
<span>Powered by Reese's self-hosted models</span>
|
<span class="footer-text">Powered by self-hosted models</span>
|
||||||
<span class="footer-version" id="app-version"></span>
|
<span class="footer-version" id="app-version"></span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
+4
-4
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="description" content="Ask Brain of Reese anything about the homelab and deployments.">
|
<meta name="description" content="Ask anything about your indexed documents — every answer cites the exact doc.">
|
||||||
<title>Brain of Reese</title>
|
<title>Brain of Reese</title>
|
||||||
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||||
<link rel="stylesheet" href="/assets/styles.css">
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<h1 class="empty-state-title">Hey! I'm Brain of Reese.</h1>
|
<h1 class="empty-state-title">Hey! I'm Brain of Reese.</h1>
|
||||||
<p class="empty-state-sub">
|
<p class="empty-state-sub">
|
||||||
I've read through the homelab and deployment notes — ask me anything,
|
I've read through your documents — ask me anything,
|
||||||
and I'll point you at the exact doc. You've got this.
|
and I'll point you at the exact doc. You've got this.
|
||||||
</p>
|
</p>
|
||||||
<div class="suggestions" id="suggestions" role="list" aria-label="Suggested questions">
|
<div class="suggestions" id="suggestions" role="list" aria-label="Suggested questions">
|
||||||
@@ -228,7 +228,7 @@
|
|||||||
id="message-input"
|
id="message-input"
|
||||||
name="message"
|
name="message"
|
||||||
rows="1"
|
rows="1"
|
||||||
placeholder="Ask me about the homelab…"
|
placeholder="Ask me anything…"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
></textarea>
|
></textarea>
|
||||||
<button type="submit" class="send-btn" id="send-btn">
|
<button type="submit" class="send-btn" id="send-btn">
|
||||||
@@ -242,7 +242,7 @@
|
|||||||
|
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
<div class="container footer-inner">
|
<div class="container footer-inner">
|
||||||
<span>Powered by Reese's self-hosted models</span>
|
<span class="footer-text">Powered by self-hosted models</span>
|
||||||
<span class="footer-version" id="app-version"></span>
|
<span class="footer-version" id="app-version"></span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
+1
-1
@@ -128,7 +128,7 @@
|
|||||||
|
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
<div class="container footer-inner">
|
<div class="container footer-inner">
|
||||||
<span>Powered by Reese's self-hosted models</span>
|
<span class="footer-text">Powered by self-hosted models</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -144,7 +144,7 @@
|
|||||||
|
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
<div class="container footer-inner">
|
<div class="container footer-inner">
|
||||||
<span>Powered by Reese's self-hosted models</span>
|
<span class="footer-text">Powered by self-hosted models</span>
|
||||||
<span class="footer-version" id="app-version"></span>
|
<span class="footer-version" id="app-version"></span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -108,9 +108,9 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="page-sub">
|
<p class="page-sub">
|
||||||
Every <code>*.md</code> file indexed from <code>~/Homelab</code> and
|
Every file indexed from your configured sources — git repositories,
|
||||||
<code>~/Deployments</code>. Press <strong>Sync sources</strong> to clone
|
local directories, and uploaded archives. Press <strong>Sync sources</strong>
|
||||||
the repos and re-import.
|
to pull the latest and re-import.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<!-- #sync-result is the aria-live announcer for the last sync
|
<!-- #sync-result is the aria-live announcer for the last sync
|
||||||
@@ -183,7 +183,7 @@
|
|||||||
|
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
<div class="container footer-inner">
|
<div class="container footer-inner">
|
||||||
<span>Powered by Reese's self-hosted models</span>
|
<span class="footer-text">Powered by self-hosted models</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@
|
|||||||
|
|
||||||
<footer class="app-footer">
|
<footer class="app-footer">
|
||||||
<div class="container footer-inner">
|
<div class="container footer-inner">
|
||||||
<span>Powered by Reese's self-hosted models</span>
|
<span class="footer-text">Powered by self-hosted models</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Shared fixtures for unit + integration tests."""
|
"""Shared fixtures for unit + integration tests."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
|
|
||||||
@@ -23,6 +24,29 @@ SESSION_SECRET = "test-session-secret-0123456789abcdef0123456789abcdef"
|
|||||||
os.environ.setdefault("BOR_ADMIN_PASSWORD", ADMIN_PASSWORD)
|
os.environ.setdefault("BOR_ADMIN_PASSWORD", ADMIN_PASSWORD)
|
||||||
os.environ.setdefault("BOR_SESSION_SECRET", SESSION_SECRET)
|
os.environ.setdefault("BOR_SESSION_SECRET", SESSION_SECRET)
|
||||||
|
|
||||||
|
# Phase 61 (defect fix): the app under test must see the code DEFAULTS,
|
||||||
|
# not an operator's local (gitignored) ``.env`` — ``Settings`` loads
|
||||||
|
# ``env_file=".env"`` from the repo root, and a machine-specific corpus
|
||||||
|
# (e.g. ``BOR_SUGGESTIONS``, ``BOR_DOCS_REPO``) leaked into the tests
|
||||||
|
# broke the default-metadata pins. pydantic-settings ranks process env
|
||||||
|
# vars ABOVE the ``.env`` file, so force the defaults explicitly here,
|
||||||
|
# before ``app.main`` (below) caches settings. The suggestions default
|
||||||
|
# is derived from the class field so this can never drift from
|
||||||
|
# ``app/config.py``; docs-push stays inert (empty repo).
|
||||||
|
from app.config import Settings as _Settings # noqa: E402
|
||||||
|
|
||||||
|
os.environ["BOR_DOCS_REPO"] = ""
|
||||||
|
os.environ["BOR_SUGGESTIONS"] = json.dumps(_Settings.model_fields["suggestions"].default)
|
||||||
|
|
||||||
|
# Phase 62: the same leak class for the new UI customization settings —
|
||||||
|
# an operator's local ``.env`` may legitimately carry
|
||||||
|
# ``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` / ``BOR_THEME``, and
|
||||||
|
# the default-metadata pins must see the code defaults (derived from
|
||||||
|
# the class fields, same pattern as the suggestions line above).
|
||||||
|
os.environ["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
|
||||||
|
os.environ["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
|
||||||
|
os.environ["BOR_THEME"] = _Settings.model_fields["theme"].default
|
||||||
|
|
||||||
from app.db import SessionLocal, db_available # noqa: E402
|
from app.db import SessionLocal, db_available # noqa: E402
|
||||||
from app.main import app as fastapi_app # noqa: E402
|
from app.main import app as fastapi_app # noqa: E402
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Prerequisite for story tests that touch the database:
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -25,6 +26,8 @@ import httpx
|
|||||||
import pytest
|
import pytest
|
||||||
from playwright.sync_api import Browser, Page, sync_playwright
|
from playwright.sync_api import Browser, Page, sync_playwright
|
||||||
|
|
||||||
|
from app.config import Settings as _Settings
|
||||||
|
|
||||||
REPO = Path(__file__).resolve().parents[2]
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
APP_PORT = int(os.environ.get("E2E_APP_PORT", "8123"))
|
APP_PORT = int(os.environ.get("E2E_APP_PORT", "8123"))
|
||||||
MOCK_PORT = int(os.environ.get("E2E_MOCK_PORT", "8901"))
|
MOCK_PORT = int(os.environ.get("E2E_MOCK_PORT", "8901"))
|
||||||
@@ -99,6 +102,26 @@ def app_server(mock_llm: int) -> Iterator[str]:
|
|||||||
# Phase 16: admin auth must be set or create_app() refuses to boot.
|
# Phase 16: admin auth must be set or create_app() refuses to boot.
|
||||||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||||
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||||
|
# Phase 61 (defect fix): force the code DEFAULTS so an operator's local
|
||||||
|
# (gitignored) ``.env`` — loaded by pydantic-settings from ``cwd=REPO``
|
||||||
|
# — cannot leak corpus-specific chips / a docs repo into the app under
|
||||||
|
# test: process env ranks above the ``.env`` file. The suggestions
|
||||||
|
# default is derived from the class field (never drifts from
|
||||||
|
# ``app/config.py``); docs-push stays inert (empty repo).
|
||||||
|
env["BOR_DOCS_REPO"] = ""
|
||||||
|
env["BOR_SUGGESTIONS"] = json.dumps(
|
||||||
|
_Settings.model_fields["suggestions"].default
|
||||||
|
)
|
||||||
|
# Phase 62: the same leak class for the new UI customization
|
||||||
|
# settings — an operator's local (gitignored) ``.env`` may
|
||||||
|
# legitimately carry ``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``
|
||||||
|
# / ``BOR_THEME``, and the byte-identical default contract (task
|
||||||
|
# 05's ``test_default_server_is_byte_identical``) must see the code
|
||||||
|
# defaults (derived from the class fields, never drifts from
|
||||||
|
# ``app/config.py``).
|
||||||
|
env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default
|
||||||
|
env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default
|
||||||
|
env["BOR_THEME"] = _Settings.model_fields["theme"].default
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||||
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
||||||
|
|||||||
+25
-18
@@ -65,9 +65,10 @@ Implements just enough of the aipi surface:
|
|||||||
``call_0``, no arguments), ``finish_reason: "tool_calls"``, no
|
``call_0``, no arguments), ``finish_reason: "tool_calls"``, no
|
||||||
content;
|
content;
|
||||||
* request 2 (a ``tool``-role catalog result in the messages):
|
* request 2 (a ``tool``-role catalog result in the messages):
|
||||||
parse the FIRST catalog line (``source/path — title`` → split on
|
parse the FIRST catalog line (``source: X | path: Y | title: Z``
|
||||||
``" — "`` → ``rsplit("/", 1)``) and stream a ``tool_calls`` delta
|
— the labeled ``source:`` / ``path:`` fields, phase 63) and
|
||||||
calling ``read_document`` on it (id ``call_1``);
|
stream a ``tool_calls`` delta calling ``read_document`` on it
|
||||||
|
(id ``call_1``);
|
||||||
* request 3 (a ``tool``-role read result in the messages): a
|
* request 3 (a ``tool``-role read result in the messages): a
|
||||||
content answer, deterministic: ``Read <source/path>. <first 80
|
content answer, deterministic: ``Read <source/path>. <first 80
|
||||||
chars of the read document's content>`` — so a suite can assert
|
chars of the read document's content>`` — so a suite can assert
|
||||||
@@ -269,6 +270,14 @@ TABLE_ANSWER = (
|
|||||||
#: ``_execute_tool``): ``"Document <source/path>:\n<content>"``.
|
#: ``_execute_tool``): ``"Document <source/path>:\n<content>"``.
|
||||||
_READ_RESULT_PREFIX = "Document "
|
_READ_RESULT_PREFIX = "Document "
|
||||||
|
|
||||||
|
#: One line of the agent's ``list_documents`` output (app.rag.agent
|
||||||
|
#: ``_execute_tool``, phase 63): labeled, pipe-delimited fields —
|
||||||
|
#: ``source: X | path: Y | title: Z`` — unambiguous for LLM parsing even
|
||||||
|
#: when the path contains ``/`` characters.
|
||||||
|
_CATALOG_LINE_RE = re.compile(
|
||||||
|
r"^source: (?P<source>.+?) \| path: (?P<path>.+?) \| title: .+$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _read_results(body: dict[str, Any]) -> list[tuple[str, str]]:
|
def _read_results(body: dict[str, Any]) -> list[tuple[str, str]]:
|
||||||
"""The read results in the messages, in order: ``(source/path, content)``.
|
"""The read results in the messages, in order: ``(source/path, content)``.
|
||||||
@@ -291,15 +300,15 @@ def _read_results(body: dict[str, Any]) -> list[tuple[str, str]]:
|
|||||||
|
|
||||||
|
|
||||||
def _catalog_docs(body: dict[str, Any]) -> list[tuple[str, str]]:
|
def _catalog_docs(body: dict[str, Any]) -> list[tuple[str, str]]:
|
||||||
"""Every ``source/path`` in the catalog tool result, in listing order.
|
"""Every ``(source, path)`` in the catalog tool result, in listing order.
|
||||||
|
|
||||||
Catalog lines are ``source/path — title`` (the agent's
|
Catalog lines are ``source: X | path: Y | title: Z`` (the agent's
|
||||||
``list_documents`` output): split on ``" — "``, keep the head, and
|
``list_documents`` output — phase 63: labeled, pipe-delimited
|
||||||
recover ``(source, path)`` with ``rsplit("/", 1)`` (``rpartition``)
|
fields, unambiguous even for paths full of ``/``): the line-level
|
||||||
— the same convention the single-read flow's read step uses. The
|
regex recovers the ``source`` and ``path`` fields directly. The
|
||||||
``"N documents:"`` header line carries no ``/`` and is skipped; read-
|
``"N documents:"`` header line matches no line and is skipped;
|
||||||
result messages are full documents, not listings, and are skipped
|
read-result messages are full documents, not listings, and are
|
||||||
too.
|
skipped too.
|
||||||
"""
|
"""
|
||||||
docs: list[tuple[str, str]] = []
|
docs: list[tuple[str, str]] = []
|
||||||
for m in _messages(body):
|
for m in _messages(body):
|
||||||
@@ -309,11 +318,9 @@ def _catalog_docs(body: dict[str, Any]) -> list[tuple[str, str]]:
|
|||||||
if content.startswith(_READ_RESULT_PREFIX):
|
if content.startswith(_READ_RESULT_PREFIX):
|
||||||
continue
|
continue
|
||||||
for line in content.splitlines():
|
for line in content.splitlines():
|
||||||
head = line.split(" — ", 1)[0].strip()
|
match = _CATALOG_LINE_RE.match(line)
|
||||||
if "/" in head:
|
if match:
|
||||||
source, _, path = head.rpartition("/")
|
docs.append((match.group("source"), match.group("path")))
|
||||||
if source and path:
|
|
||||||
docs.append((source, path))
|
|
||||||
return docs
|
return docs
|
||||||
|
|
||||||
|
|
||||||
@@ -327,8 +334,8 @@ def _tool_flow(body: dict[str, Any]) -> tuple[str, ...] | None:
|
|||||||
are in the messages yet: the model lists the catalog.
|
are in the messages yet: the model lists the catalog.
|
||||||
* ``("read", source, path, "call_1")`` — a ``tool``-role catalog
|
* ``("read", source, path, "call_1")`` — a ``tool``-role catalog
|
||||||
result is in the messages: the model reads its FIRST
|
result is in the messages: the model reads its FIRST
|
||||||
``source/path — title`` line (split on ``" — "``, then
|
``source: X | path: Y | title: Z`` line (the labeled
|
||||||
``rsplit("/", 1)``).
|
``source:`` / ``path:`` fields, phase 63).
|
||||||
* ``("answer", "source/path", content)`` — a ``tool``-role read
|
* ``("answer", "source/path", content)`` — a ``tool``-role read
|
||||||
result (``"Document <source/path>:\n<content>"``) is in the
|
result (``"Document <source/path>:\n<content>"``) is in the
|
||||||
messages: the model answers, quoting the read document. Reached
|
messages: the model answers, quoting the read document. Reached
|
||||||
|
|||||||
@@ -146,16 +146,28 @@ def test_api_config_serves_both_names(testy_server: str, app_server: str) -> Non
|
|||||||
body = r.json()
|
body = r.json()
|
||||||
# Phase 59 (task 05): the third key is the docs-push flag — the
|
# Phase 59 (task 05): the third key is the docs-push flag — the
|
||||||
# "Save as doc" gating; both instances run with BOR_DOCS_REPO
|
# "Save as doc" gating; both instances run with BOR_DOCS_REPO
|
||||||
# empty, so it is the inert false here.
|
# empty, so it is the inert false here. Phase 62 (task 01): the
|
||||||
assert set(body) == {"app_name", "version", "docs_repo_configured"}
|
# endpoint grew to six keys — this suite's instances carry no
|
||||||
|
# UI-customization overrides, so the three new keys are their
|
||||||
|
# defaults.
|
||||||
|
assert set(body) == {
|
||||||
|
"app_name", "version", "docs_repo_configured",
|
||||||
|
"input_placeholder", "footer_text", "theme",
|
||||||
|
}
|
||||||
assert body["app_name"] == TESTY_NAME
|
assert body["app_name"] == TESTY_NAME
|
||||||
assert body["docs_repo_configured"] is False
|
assert body["docs_repo_configured"] is False
|
||||||
|
|
||||||
# The shared conftest instance keeps the default (the other
|
# The shared conftest instance keeps the default (the other
|
||||||
# suites' title/label contract rides on it).
|
# suites' title/label contract rides on it) — and its key set
|
||||||
|
# grew with the endpoint (phase 62).
|
||||||
r2 = httpx.get(f"{app_server}/api/config", timeout=5)
|
r2 = httpx.get(f"{app_server}/api/config", timeout=5)
|
||||||
assert r2.status_code == 200
|
assert r2.status_code == 200
|
||||||
assert r2.json()["app_name"] == DEFAULT_NAME
|
r2_body = r2.json()
|
||||||
|
assert set(r2_body) == {
|
||||||
|
"app_name", "version", "docs_repo_configured",
|
||||||
|
"input_placeholder", "footer_text", "theme",
|
||||||
|
}
|
||||||
|
assert r2_body["app_name"] == DEFAULT_NAME
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -216,6 +216,11 @@ def _spawn_app(port: int, mock_port: int, docs_env: dict[str, str] | None) -> su
|
|||||||
"BOR_DOCS_WORK_DIR",
|
"BOR_DOCS_WORK_DIR",
|
||||||
):
|
):
|
||||||
env.pop(var, None)
|
env.pop(var, None)
|
||||||
|
# Phase 61 (defect fix): the pop only clears the process env —
|
||||||
|
# pydantic-settings would still pick up ``BOR_DOCS_REPO`` from an
|
||||||
|
# operator's local (gitignored) ``.env`` (``cwd=REPO``). Force
|
||||||
|
# empty so the "unconfigured" boot really is the inert default.
|
||||||
|
env["BOR_DOCS_REPO"] = ""
|
||||||
else:
|
else:
|
||||||
env.update(docs_env)
|
env.update(docs_env)
|
||||||
return subprocess.Popen(
|
return subprocess.Popen(
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""Phase 61 E2E (Playwright): the retired homelab-era copy is gone — the
|
||||||
|
visitor SEES the locked neutral copy on the shared default server.
|
||||||
|
|
||||||
|
Story: n/a (TODO-derived — TODO.md L4 "Clean up the UI, there's text
|
||||||
|
that references old features…"). Run in isolation (DB must be up:
|
||||||
|
``podman compose up -d db``):
|
||||||
|
|
||||||
|
uv run pytest tests/e2e/test_stale_ui_copy.py -v --no-cov
|
||||||
|
|
||||||
|
The shared conftest server boots with the default env — and the code
|
||||||
|
defaults are exactly this phase's locked copy (A1/A2; the conftest
|
||||||
|
forces ``BOR_SUGGESTIONS`` from the ``Settings`` field so an operator's
|
||||||
|
local ``.env`` cannot leak corpus-specific chips). Every assertion is a
|
||||||
|
settled-state check: static HTML + one ``GET /api/suggestions`` fetch;
|
||||||
|
Playwright's ``expect`` retries ride out the chip rendering.
|
||||||
|
|
||||||
|
Test → lock mapping (Playwright Mapping Rule):
|
||||||
|
1. ``test_chat_page_placeholder_meta_footer_are_the_locked_copy``
|
||||||
|
2. ``test_chat_page_shows_no_homelab_or_deployment_text``
|
||||||
|
3. ``test_chat_page_suggestion_chips_are_the_locked_list``
|
||||||
|
4. ``test_sources_page_sub_describes_the_current_source_model``
|
||||||
|
5. ``test_git_sources_example_url_is_neutral``
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
|
||||||
|
# The locked replacement copy (owner-locked A1 / A2 / A3 — same literals
|
||||||
|
# as tests/unit/test_stale_ui_copy.py; the unit pins guard the files,
|
||||||
|
# this suite guards what the visitor actually sees).
|
||||||
|
META = "Ask anything about your indexed documents — every answer cites the exact doc."
|
||||||
|
PLACEHOLDER = "Ask me anything…"
|
||||||
|
FOOTER = "Powered by self-hosted models"
|
||||||
|
GIT_EXAMPLE = "https://github.com/you/your-repo.git"
|
||||||
|
|
||||||
|
CHIPS = [
|
||||||
|
"What documents are in the knowledge base?",
|
||||||
|
"Which source does each answer come from?",
|
||||||
|
"How do I add a new source?",
|
||||||
|
"Summarize the most recent document.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_page_placeholder_meta_footer_are_the_locked_copy(
|
||||||
|
page: Page, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""The composer placeholder, the <meta description> content and the
|
||||||
|
footer span all read the locked (A1) copy — read from the DOM a
|
||||||
|
visitor gets."""
|
||||||
|
page.goto(f"{app_url}/")
|
||||||
|
expect(page.locator("#message-input")).to_have_attribute(
|
||||||
|
"placeholder", PLACEHOLDER
|
||||||
|
)
|
||||||
|
meta = page.eval_on_selector('meta[name="description"]', "el => el.content")
|
||||||
|
assert meta == META, "the rendered meta description must be the locked copy"
|
||||||
|
expect(page.locator(".footer-text").first).to_have_text(FOOTER)
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_page_shows_no_homelab_or_deployment_text(
|
||||||
|
page: Page, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""The entire rendered chat page (nav, empty state, composer, footer)
|
||||||
|
shows no homelab-era text — the empty-state sub AND the four
|
||||||
|
rendered suggestion chips are covered by this body scan (case-
|
||||||
|
insensitive; a fresh page context has no saved chats, so the empty
|
||||||
|
state is what renders)."""
|
||||||
|
page.goto(f"{app_url}/")
|
||||||
|
expect(page.locator("#suggestions .suggestion-chip")).to_have_count(len(CHIPS))
|
||||||
|
body = page.evaluate("() => document.body.innerText.toLowerCase()")
|
||||||
|
assert "homelab" not in body, f"homelab-era text rendered on the chat page: {body!r}"
|
||||||
|
assert "deployment" not in body, (
|
||||||
|
f"homelab-era text rendered on the chat page: {body!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_page_suggestion_chips_are_the_locked_list(
|
||||||
|
page: Page, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""The four rendered empty-state chips (from ``GET
|
||||||
|
/api/suggestions``, the code default) are the locked (A2) list, in
|
||||||
|
order."""
|
||||||
|
page.goto(f"{app_url}/")
|
||||||
|
chips = page.locator("#suggestions .suggestion-chip")
|
||||||
|
expect(chips).to_have_count(len(CHIPS))
|
||||||
|
for i, chip in enumerate(CHIPS):
|
||||||
|
expect(chips.nth(i)).to_have_text(chip)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_page_sub_describes_the_current_source_model(
|
||||||
|
page: Page, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""The KB page-sub (the string the TODO cited by name) no longer
|
||||||
|
names ~/Homelab or ~/Deployments and describes the current source
|
||||||
|
model. The page is anonymously viewable — the catalog gate hides
|
||||||
|
the table, not the page-head."""
|
||||||
|
page.goto(f"{app_url}/sources.html")
|
||||||
|
sub = page.locator(".page-sub").first
|
||||||
|
expect(sub).to_be_visible()
|
||||||
|
text = sub.inner_text().lower()
|
||||||
|
assert "homelab" not in text, f"retired copy in the page-sub: {text!r}"
|
||||||
|
assert "deployments" not in text, f"retired copy in the page-sub: {text!r}"
|
||||||
|
assert "configured sources" in text, (
|
||||||
|
f"the page-sub must name the current source model: {text!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_sources_example_url_is_neutral(
|
||||||
|
page: Page, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
"""The repo-URL form example (A3) is the neutral your-repo.git —
|
||||||
|
read from the DOM; the element exists even while
|
||||||
|
``#git-sources-content`` is hidden for an anonymous visitor (no
|
||||||
|
sign-in needed)."""
|
||||||
|
page.goto(f"{app_url}/git-sources.html")
|
||||||
|
expect(page.locator("#git-source-url")).to_have_attribute(
|
||||||
|
"placeholder", GIT_EXAMPLE
|
||||||
|
)
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
"""Phase 62 E2E (Playwright): UI customization — placeholder, footer, theme.
|
||||||
|
|
||||||
|
Source: ``TODO.md`` L3 — "Allow UI customization. This is brain of reese,
|
||||||
|
but I want anyone to be able to deploy it with their name… custom
|
||||||
|
message-input placeholder, custom footer-inner text, custom color
|
||||||
|
themes…" (owner-locked 2026-09-01: ``BOR_INPUT_PLACEHOLDER``,
|
||||||
|
``BOR_FOOTER_TEXT``, ``BOR_THEME`` — A4/A5).
|
||||||
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||||
|
|
||||||
|
uv run pytest tests/e2e/test_ui_customization.py -v --no-cov
|
||||||
|
|
||||||
|
Contract under test:
|
||||||
|
|
||||||
|
* an instance booted with ALL THREE customization vars set shows the
|
||||||
|
custom look end-to-end: the ``GET /api/config`` overrides, the chat
|
||||||
|
composer placeholder (``#message-input``), the footer line on multiple
|
||||||
|
pages (``.footer-text``), and the computed ``:root --brand`` from the
|
||||||
|
inserted ``<link id="theme-override" href="/assets/themes/indigo.css">``
|
||||||
|
(the indigo example theme, ``--brand: #818cf8``);
|
||||||
|
* with NOTHING set the shared conftest server is byte-identical to the
|
||||||
|
phase-39/61 no-op contract: the default placeholder, the default
|
||||||
|
footer, NO theme link, the built-in ``--brand: #f43f5e``;
|
||||||
|
* a malformed ``BOR_THEME`` (``../evil.css``) refuses startup loudly,
|
||||||
|
naming the value — the phase-56 fail-loud style, proven end-to-end
|
||||||
|
via a real boot attempt, not just the validator unit test.
|
||||||
|
|
||||||
|
Determinism note: this story needs a SECOND app instance — the shared
|
||||||
|
conftest server keeps the defaults (every other suite's
|
||||||
|
placeholder/footer/palette assertions depend on it), so ``custom_server``
|
||||||
|
boots the same env block the phase-39 brand suite's ``testy_server``
|
||||||
|
boots (same DB, the mock-LLM base URL, the admin auth, the static dir,
|
||||||
|
the mock-calibrated threshold) with exactly three changes: port
|
||||||
|
``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1`` — do not
|
||||||
|
collide) and the three env overrides. Every assertion is settled-state:
|
||||||
|
Playwright's ``expect`` retries ride out the brand.js ``/api/config``
|
||||||
|
fetch (the three keys are applied asynchronously, in the SAME fetch's
|
||||||
|
settled ``.then`` — no second network call). The one absence assertion
|
||||||
|
(no ``#theme-override`` on the default server) first waits for
|
||||||
|
``window.BOR_CONFIG_PROMISE`` to settle, so it cannot race the fetch.
|
||||||
|
|
||||||
|
Test → contract mapping (Playwright Mapping Rule):
|
||||||
|
1. ``test_config_serves_the_overrides``
|
||||||
|
2. ``test_chat_page_shows_custom_placeholder_footer_theme``
|
||||||
|
3. ``test_footer_text_applies_on_other_pages``
|
||||||
|
4. ``test_default_server_is_byte_identical``
|
||||||
|
5. ``test_malformed_theme_refuses_startup``
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from collections.abc import Iterator
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import Page, expect
|
||||||
|
|
||||||
|
from e2e.conftest import (
|
||||||
|
ADMIN_PASSWORD,
|
||||||
|
APP_PORT,
|
||||||
|
REPO,
|
||||||
|
SESSION_SECRET,
|
||||||
|
USE_REAL_LLM,
|
||||||
|
_wait_http,
|
||||||
|
)
|
||||||
|
|
||||||
|
CUSTOM_PORT = APP_PORT + 2 # the brand suite owns APP_PORT + 1 — no collision
|
||||||
|
CUSTOM_URL = f"http://127.0.0.1:{CUSTOM_PORT}"
|
||||||
|
MALFORMED_PORT = APP_PORT + 3 # the refused boot never starts listening
|
||||||
|
|
||||||
|
# The three overrides (task 05) — the whole story:
|
||||||
|
CUSTOM_PLACEHOLDER = "Ask the archive…"
|
||||||
|
CUSTOM_FOOTER = "Custom footer line"
|
||||||
|
CUSTOM_THEME = "indigo.css"
|
||||||
|
INDIGO_BRAND = "#818cf8" # indigo.css's --brand (the computed token)
|
||||||
|
|
||||||
|
# The phase-39/61 no-op contract on the shared default server:
|
||||||
|
DEFAULT_NAME = "Brain of Reese"
|
||||||
|
DEFAULT_PLACEHOLDER = "Ask me anything…"
|
||||||
|
DEFAULT_FOOTER = "Powered by self-hosted models"
|
||||||
|
BUILTIN_BRAND = "#f43f5e" # styles.css's built-in --brand
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def custom_server(mock_llm: int) -> Iterator[str]:
|
||||||
|
"""A SECOND app instance, booted with all three customization
|
||||||
|
overrides.
|
||||||
|
|
||||||
|
The shared conftest ``app_server`` keeps the defaults (every other
|
||||||
|
suite's placeholder/footer/palette assertions depend on it) — so
|
||||||
|
this fixture copies the phase-39 brand suite's ``testy_server`` env
|
||||||
|
block verbatim (same DB, the mock-LLM base URL,
|
||||||
|
``BOR_ADMIN_PASSWORD``/``BOR_SESSION_SECRET``, ``BOR_STATIC_DIR``,
|
||||||
|
``BOR_RELEVANCE_THRESHOLD``) with exactly three changes: port
|
||||||
|
``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1``) and the
|
||||||
|
three env overrides below. Started after ``mock_llm`` is available
|
||||||
|
(its fixture dependency).
|
||||||
|
"""
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
# The mock's token-overlap embeddings have their own score
|
||||||
|
# distribution — the same mock-calibrated threshold as the shared
|
||||||
|
# instance, so this suite's pages behave like every other story's.
|
||||||
|
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||||
|
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
|
||||||
|
# Phase 62 (owner-locked 2026-09-01, TODO L3) — the whole story:
|
||||||
|
env["BOR_INPUT_PLACEHOLDER"] = CUSTOM_PLACEHOLDER
|
||||||
|
env["BOR_FOOTER_TEXT"] = CUSTOM_FOOTER
|
||||||
|
env["BOR_THEME"] = CUSTOM_THEME
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||||
|
"--host", "127.0.0.1", "--port", str(CUSTOM_PORT), "--log-level", "warning"],
|
||||||
|
cwd=REPO,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_wait_http(f"{CUSTOM_URL}/api/health")
|
||||||
|
yield CUSTOM_URL
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_brand_settled(page: Page, timeout: int = 15_000) -> None:
|
||||||
|
"""Wait for the brand layer's boot fetch to settle.
|
||||||
|
|
||||||
|
The three customization keys are applied asynchronously, in the
|
||||||
|
settled ``/api/config`` promise's ``.then`` — absence assertions
|
||||||
|
(no ``#theme-override``) must not race that fetch. The promise
|
||||||
|
NEVER rejects (the brand.js contract), so its resolution means the
|
||||||
|
DOM pass has already run: ``applyBrand`` registered its callback on
|
||||||
|
the same promise at page load, before this wait's callback, and
|
||||||
|
promise callbacks run in registration order."""
|
||||||
|
page.wait_for_function(
|
||||||
|
"() => window.BOR_CONFIG_PROMISE.then(() => true)",
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def expect_brand_var(page: Page, expected: str, timeout: int = 15_000) -> None:
|
||||||
|
"""Retrying computed ``:root --brand`` equality. Custom properties
|
||||||
|
return the SPECIFIED token from ``getComputedStyle`` (no color
|
||||||
|
normalization), so the string compare is stable: ``#818cf8`` is
|
||||||
|
exactly what indigo.css declares, ``#f43f5e`` exactly what
|
||||||
|
styles.css declares (the built-in)."""
|
||||||
|
page.wait_for_function(
|
||||||
|
"""(expected) =>
|
||||||
|
getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue("--brand")
|
||||||
|
.trim() === expected""",
|
||||||
|
arg=expected,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. The endpoint the brand layer reads — the three overrides, the
|
||||||
|
# six-key set, and the theme file served from the dev static dir
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_serves_the_overrides(custom_server: str) -> None:
|
||||||
|
r = httpx.get(f"{CUSTOM_URL}/api/config", timeout=5)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
# The six-key set (the phase-39/59/62 endpoint contract) with the
|
||||||
|
# three customization overrides — the app NAME stays the default
|
||||||
|
# (this suite does not re-test BOR_APP_NAME; that is the phase-39
|
||||||
|
# suite's job).
|
||||||
|
assert set(body) == {
|
||||||
|
"app_name", "version", "docs_repo_configured",
|
||||||
|
"input_placeholder", "footer_text", "theme",
|
||||||
|
}
|
||||||
|
assert body["app_name"] == DEFAULT_NAME
|
||||||
|
assert body["input_placeholder"] == CUSTOM_PLACEHOLDER
|
||||||
|
assert body["footer_text"] == CUSTOM_FOOTER
|
||||||
|
assert body["theme"] == CUSTOM_THEME
|
||||||
|
|
||||||
|
# Served in dev from the static dir (the no-CDN rule): the theme
|
||||||
|
# file the boot fetch names is reachable at its served path, and
|
||||||
|
# it is the indigo example (its --brand is the E2E's theme proof).
|
||||||
|
r2 = httpx.get(f"{CUSTOM_URL}/assets/themes/{CUSTOM_THEME}", timeout=5)
|
||||||
|
assert r2.status_code == 200
|
||||||
|
assert f"--brand: {INDIGO_BRAND}" in r2.text
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. The chat page — placeholder, footer, the theme link + effect
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_page_shows_custom_placeholder_footer_theme(
|
||||||
|
page: Page, custom_server: str
|
||||||
|
) -> None:
|
||||||
|
page.goto(custom_server + "/")
|
||||||
|
# 5. The composer placeholder — the retry rides out the brand.js
|
||||||
|
# /api/config fetch that applies it.
|
||||||
|
expect(page.locator("#message-input")).to_have_attribute(
|
||||||
|
"placeholder", CUSTOM_PLACEHOLDER, timeout=15_000
|
||||||
|
)
|
||||||
|
# 6. The footer line on the chat page.
|
||||||
|
expect(page.locator(".footer-text").first).to_have_text(
|
||||||
|
CUSTOM_FOOTER, timeout=15_000
|
||||||
|
)
|
||||||
|
# 7. The theme link in <head> — rel=stylesheet, the served path.
|
||||||
|
expect(page.locator('head link#theme-override[rel="stylesheet"]')).to_have_attribute(
|
||||||
|
"href", f"/assets/themes/{CUSTOM_THEME}", timeout=15_000
|
||||||
|
)
|
||||||
|
# And it takes effect: the computed :root --brand is the indigo
|
||||||
|
# value (the built-in #f43f5e means the theme never loaded).
|
||||||
|
expect_brand_var(page, INDIGO_BRAND)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. A second page — the footer applies multi-page; the placeholder
|
||||||
|
# application no-ops without a composer
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_footer_text_applies_on_other_pages(page: Page, custom_server: str) -> None:
|
||||||
|
page.goto(custom_server + "/login.html")
|
||||||
|
# This page has NO composer — the placeholder application no-ops
|
||||||
|
# there via the null guard (no error, no element touched).
|
||||||
|
expect(page.locator("#message-input")).to_have_count(0)
|
||||||
|
# The footer line applies on every page (the phase-61 hook).
|
||||||
|
expect(page.locator(".footer-text").first).to_have_text(
|
||||||
|
CUSTOM_FOOTER, timeout=15_000
|
||||||
|
)
|
||||||
|
# The theme link rides in <head> on every page too.
|
||||||
|
expect(page.locator('head link#theme-override[rel="stylesheet"]')).to_have_attribute(
|
||||||
|
"href", f"/assets/themes/{CUSTOM_THEME}", timeout=15_000
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. The no-op regression — the shared default server is byte-identical
|
||||||
|
# to the phase-39/61 contract
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_server_is_byte_identical(page: Page, app_server: str) -> None:
|
||||||
|
page.goto(app_server + "/")
|
||||||
|
# Settle the boot fetch BEFORE the absence assertion — it must not
|
||||||
|
# race the (absent) theme-link insertion.
|
||||||
|
wait_for_brand_settled(page)
|
||||||
|
# The phase-39/61 no-op contract: the template defaults stand.
|
||||||
|
expect(page.locator("#message-input")).to_have_attribute(
|
||||||
|
"placeholder", DEFAULT_PLACEHOLDER
|
||||||
|
)
|
||||||
|
expect(page.locator(".footer-text").first).to_have_text(DEFAULT_FOOTER)
|
||||||
|
# With BOR_THEME unset the brand layer inserts NO theme link:
|
||||||
|
assert page.locator("#theme-override").count() == 0, (
|
||||||
|
"with BOR_THEME unset the brand layer must NOT insert a theme "
|
||||||
|
"link (the byte-identical no-op contract)"
|
||||||
|
)
|
||||||
|
# The built-in dark-tech palette stands.
|
||||||
|
expect_brand_var(page, BUILTIN_BRAND)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. The fail-loud boot check — a malformed BOR_THEME refuses startup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_theme_refuses_startup() -> None:
|
||||||
|
"""A malformed ``BOR_THEME`` (``../evil.css`` — a path, exactly the
|
||||||
|
shape the A5 lock names as illegal) kills startup with the value
|
||||||
|
NAMED on stderr (the phase-56 fail-loud house style), proven
|
||||||
|
end-to-end via a real uvicorn boot attempt: the process exits
|
||||||
|
non-zero within the timeout without ever starting to listen.
|
||||||
|
|
||||||
|
``app.main`` builds its settings at import time
|
||||||
|
(``settings = get_settings()``), so the validator fires during the
|
||||||
|
ASGI app import — before admin auth, before the port binds."""
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.pop("DEBUGPY", None)
|
||||||
|
env["BOR_ENVIRONMENT"] = "e2e"
|
||||||
|
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||||
|
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||||
|
env.setdefault(
|
||||||
|
"BOR_DATABASE_URL",
|
||||||
|
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||||
|
)
|
||||||
|
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||||
|
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||||
|
# The whole point: a malformed theme value.
|
||||||
|
env["BOR_THEME"] = "../evil.css"
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||||
|
"--host", "127.0.0.1", "--port", str(MALFORMED_PORT), "--log-level", "warning"],
|
||||||
|
cwd=REPO,
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=60)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
proc.wait()
|
||||||
|
pytest.fail(
|
||||||
|
"the app kept running with BOR_THEME='../evil.css' — a "
|
||||||
|
"malformed theme must refuse startup, not silently 404"
|
||||||
|
)
|
||||||
|
assert proc.returncode != 0, (
|
||||||
|
"the malformed BOR_THEME must make uvicorn exit non-zero"
|
||||||
|
)
|
||||||
|
stderr = proc.stderr.read() if proc.stderr else ""
|
||||||
|
# Fail-loud names the offending value (phase-56 house style):
|
||||||
|
assert "'../evil.css'" in stderr, (
|
||||||
|
f"stderr must name the offending value, got tail: {stderr[-2000:]}"
|
||||||
|
)
|
||||||
|
assert "theme must be a bare .css filename" in stderr
|
||||||
@@ -18,16 +18,26 @@ def test_health_reports_ok(client) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_config_returns_default_app_metadata(client) -> None:
|
def test_config_returns_default_app_metadata(client) -> None:
|
||||||
"""GET /api/config is public (anonymous) and returns exactly three
|
"""GET /api/config is public (anonymous) and returns exactly six
|
||||||
keys — the phase-39 app metadata + the phase-59 docs flag (inert
|
keys — the phase-39 app metadata, the phase-59 docs flag (inert
|
||||||
false while BOR_DOCS_REPO is empty — the "Save as doc" gating)."""
|
false while BOR_DOCS_REPO is empty — the "Save as doc" gating),
|
||||||
|
and the phase-62 UI customization strings (composer placeholder,
|
||||||
|
footer line, theme file name)."""
|
||||||
r = client.get("/api/config")
|
r = client.get("/api/config")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert set(body) == {"app_name", "version", "docs_repo_configured"}
|
assert set(body) == {
|
||||||
|
"app_name", "version", "docs_repo_configured",
|
||||||
|
"input_placeholder", "footer_text", "theme",
|
||||||
|
}
|
||||||
assert body["app_name"] == "Brain of Reese"
|
assert body["app_name"] == "Brain of Reese"
|
||||||
assert body["version"] == get_settings().app_version
|
assert body["version"] == get_settings().app_version
|
||||||
assert body["docs_repo_configured"] is False
|
assert body["docs_repo_configured"] is False
|
||||||
|
# Phase 62: UNSET => the phase-61 neutral copy stands (the
|
||||||
|
# byte-identical contract); an empty theme = the built-in palette.
|
||||||
|
assert body["input_placeholder"] == "Ask me anything…"
|
||||||
|
assert body["footer_text"] == "Powered by self-hosted models"
|
||||||
|
assert body["theme"] == ""
|
||||||
|
|
||||||
|
|
||||||
def test_config_follows_overridden_app_name(client) -> None:
|
def test_config_follows_overridden_app_name(client) -> None:
|
||||||
@@ -42,7 +52,10 @@ def test_config_follows_overridden_app_name(client) -> None:
|
|||||||
r = client.get("/api/config")
|
r = client.get("/api/config")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert set(body) == {"app_name", "version", "docs_repo_configured"}
|
assert set(body) == {
|
||||||
|
"app_name", "version", "docs_repo_configured",
|
||||||
|
"input_placeholder", "footer_text", "theme",
|
||||||
|
}
|
||||||
assert body["app_name"] == "Brain of Testy"
|
assert body["app_name"] == "Brain of Testy"
|
||||||
assert body["version"] == "0.1.0"
|
assert body["version"] == "0.1.0"
|
||||||
assert body["docs_repo_configured"] is False
|
assert body["docs_repo_configured"] is False
|
||||||
@@ -50,6 +63,34 @@ def test_config_follows_overridden_app_name(client) -> None:
|
|||||||
fastapi_app.dependency_overrides.clear()
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_serves_ui_customization_overrides(client) -> None:
|
||||||
|
"""Phase 62: the three UI customization keys mirror Settings
|
||||||
|
overrides (``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` /
|
||||||
|
``BOR_THEME``) verbatim — the values the frontend brand layer
|
||||||
|
applies at boot, so this dict is the whole contract."""
|
||||||
|
from app.config import Settings
|
||||||
|
from app.main import app as fastapi_app
|
||||||
|
|
||||||
|
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
|
||||||
|
input_placeholder="Ask the vault…",
|
||||||
|
footer_text="Powered by my own models",
|
||||||
|
theme="indigo.css",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
r = client.get("/api/config")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert set(body) == {
|
||||||
|
"app_name", "version", "docs_repo_configured",
|
||||||
|
"input_placeholder", "footer_text", "theme",
|
||||||
|
}
|
||||||
|
assert body["input_placeholder"] == "Ask the vault…"
|
||||||
|
assert body["footer_text"] == "Powered by my own models"
|
||||||
|
assert body["theme"] == "indigo.css"
|
||||||
|
finally:
|
||||||
|
fastapi_app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
def test_config_docs_flag_tracks_settings(client) -> None:
|
def test_config_docs_flag_tracks_settings(client) -> None:
|
||||||
"""Phase 59 (task 05): ``docs_repo_configured`` mirrors
|
"""Phase 59 (task 05): ``docs_repo_configured`` mirrors
|
||||||
``settings.docs_configured`` — a real bool (never a truthy string)
|
``settings.docs_configured`` — a real bool (never a truthy string)
|
||||||
|
|||||||
@@ -110,6 +110,18 @@ def test_agent_tools_names_and_parameters() -> None:
|
|||||||
assert by_name["read_document"]["function"]["description"] == (
|
assert by_name["read_document"]["function"]["description"] == (
|
||||||
"Add the full content of one more indexed document to your context"
|
"Add the full content of one more indexed document to your context"
|
||||||
)
|
)
|
||||||
|
# Phase 63 (A2): the parameter descriptions point the LLM at the
|
||||||
|
# labeled `source:` / `path:` fields of the list_documents output.
|
||||||
|
assert read_params["properties"]["source"]["description"] == (
|
||||||
|
"The document's source, as shown after 'source: ' in the "
|
||||||
|
"list_documents output (e.g. 'Homelab' from "
|
||||||
|
"'source: Homelab | path: homelab/aws-route53.md')."
|
||||||
|
)
|
||||||
|
assert read_params["properties"]["path"]["description"] == (
|
||||||
|
"The document's path, as shown after 'path: ' in the "
|
||||||
|
"list_documents output (e.g. 'homelab/aws-route53.md' from "
|
||||||
|
"'source: Homelab | path: homelab/aws-route53.md')."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------- happy path: list → read → answer ----------
|
# ---------- happy path: list → read → answer ----------
|
||||||
@@ -184,8 +196,8 @@ def test_list_then_read_then_answer(
|
|||||||
"tool_call_id": "call_1",
|
"tool_call_id": "call_1",
|
||||||
"content": (
|
"content": (
|
||||||
"2 documents:\n"
|
"2 documents:\n"
|
||||||
"Deployments/backups.md — Backup Strategy\n"
|
"source: Deployments | path: backups.md | title: Backup Strategy\n"
|
||||||
"Homelab/aws-route53.md — AWS Route53 Records"
|
"source: Homelab | path: aws-route53.md | title: AWS Route53 Records"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
# The second follow-up request carries the read call + the FULL text.
|
# The second follow-up request carries the read call + the FULL text.
|
||||||
@@ -243,7 +255,7 @@ def test_always_list_bounded_by_round_cap(monkeypatch: pytest.MonkeyPatch) -> No
|
|||||||
``agent_max_rounds`` tool rounds, then one forced ``tools=None``
|
``agent_max_rounds`` tool rounds, then one forced ``tools=None``
|
||||||
request streams the answer — the cap is the only forced exit."""
|
request streams the answer — the cap is the only forced exit."""
|
||||||
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
|
monkeypatch.setattr(agent, "list_catalog", lambda db: [("S", "a.md", "A")])
|
||||||
listing = "1 documents:\nS/a.md — A"
|
listing = "1 documents:\nsource: S | path: a.md | title: A"
|
||||||
holder = AgentHolder()
|
holder = AgentHolder()
|
||||||
llm = ScriptedLLM(
|
llm = ScriptedLLM(
|
||||||
[ToolCallPiece(id="call_1", name="list_documents", arguments={})],
|
[ToolCallPiece(id="call_1", name="list_documents", arguments={})],
|
||||||
@@ -359,8 +371,8 @@ def test_relist_executes_and_counts(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
assert holder.tool_calls == 2 # both re-lists executed and counted
|
assert holder.tool_calls == 2 # both re-lists executed and counted
|
||||||
listing = (
|
listing = (
|
||||||
"2 documents:\n"
|
"2 documents:\n"
|
||||||
"Deployments/backups.md — Backup Strategy\n"
|
"source: Deployments | path: backups.md | title: Backup Strategy\n"
|
||||||
"Homelab/aws-route53.md — AWS Route53 Records"
|
"source: Homelab | path: aws-route53.md | title: AWS Route53 Records"
|
||||||
)
|
)
|
||||||
# The answer request carries the catalog a second time as a tool result.
|
# The answer request carries the catalog a second time as a tool result.
|
||||||
assert llm.requests[2][0][3]["content"] == listing # first listing
|
assert llm.requests[2][0][3]["content"] == listing # first listing
|
||||||
|
|||||||
@@ -397,3 +397,77 @@ def test_docs_branchs_garbage_ignored_when_repo_unset(
|
|||||||
s = _settings()
|
s = _settings()
|
||||||
assert s.docs_configured is False
|
assert s.docs_configured is False
|
||||||
assert s.docs_branch == "bor docs.." # stored verbatim, never used
|
assert s.docs_branch == "bor docs.." # stored verbatim, never used
|
||||||
|
|
||||||
|
|
||||||
|
# --- UI customization (phase 62, TODO L3) ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_ui_customization_defaults_are_the_phase_61_copy() -> None:
|
||||||
|
"""UNSET => byte-identical to the phase-61 neutral UI: the locked
|
||||||
|
phase-61 copy is the DEFAULT (composer placeholder + footer line),
|
||||||
|
and an empty theme = the built-in dark-tech palette."""
|
||||||
|
s = _settings()
|
||||||
|
assert s.input_placeholder == "Ask me anything…"
|
||||||
|
assert s.footer_text == "Powered by self-hosted models"
|
||||||
|
assert s.theme == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_ui_customization_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""The three settings honor their ``BOR_`` env vars
|
||||||
|
(``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` / ``BOR_THEME``);
|
||||||
|
placeholder/footer accept any string (empty is legal — the brand
|
||||||
|
layer then keeps the template default)."""
|
||||||
|
monkeypatch.setenv("BOR_INPUT_PLACEHOLDER", "Ask the vault…")
|
||||||
|
monkeypatch.setenv("BOR_FOOTER_TEXT", "Powered by my own models")
|
||||||
|
monkeypatch.setenv("BOR_THEME", "indigo.css")
|
||||||
|
s = _settings()
|
||||||
|
assert s.input_placeholder == "Ask the vault…"
|
||||||
|
assert s.footer_text == "Powered by my own models"
|
||||||
|
assert s.theme == "indigo.css"
|
||||||
|
monkeypatch.setenv("BOR_INPUT_PLACEHOLDER", "")
|
||||||
|
assert _settings().input_placeholder == "" # empty stands
|
||||||
|
|
||||||
|
|
||||||
|
def test_theme_validator_accepts_empty_and_bare_css_filename() -> None:
|
||||||
|
"""Phase 62 (A5): empty = the built-in palette; a bare lowercase
|
||||||
|
``.css`` filename (the ``indigo.css`` example) is the only
|
||||||
|
non-empty shape — dashes/underscores/digits are legal tokens."""
|
||||||
|
assert _settings().theme == "" # "" passes
|
||||||
|
assert _settings(theme="indigo.css").theme == "indigo.css"
|
||||||
|
assert _settings(theme="dark-2026_v2.css").theme == "dark-2026_v2.css"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("bad", "match"),
|
||||||
|
[
|
||||||
|
# uppercase — the shape is lowercase-only
|
||||||
|
("Indigo.css", "Indigo.css"),
|
||||||
|
# path escape — a theme is a filename, never a path
|
||||||
|
("../evil.css", r"\.\./evil\.css"),
|
||||||
|
("a/b.css", r"a/b\.css"),
|
||||||
|
("/abs.css", r"got '/abs\.css'"),
|
||||||
|
# a missing extension is not a theme file
|
||||||
|
("indigo", r"got 'indigo'"), # must not match the example text
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_theme_validator_rejects_malformed_naming_the_value(
|
||||||
|
bad: str,
|
||||||
|
match: str,
|
||||||
|
) -> None:
|
||||||
|
"""A typo in ``BOR_THEME`` must kill startup, not silently 404 at
|
||||||
|
runtime — the rejection names the offending value (the phase-56
|
||||||
|
fail-loud house style) alongside the allowed shape."""
|
||||||
|
with pytest.raises(ValidationError, match=match):
|
||||||
|
_settings(theme=bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bor_theme_env_malformed_fails_startup_naming_value(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""The startup path: a malformed ``BOR_THEME`` in the environment
|
||||||
|
fails Settings construction loudly (the app builds its settings at
|
||||||
|
import time, so this is a refused boot), naming the value — the
|
||||||
|
E2E boots-check lands in task 05."""
|
||||||
|
monkeypatch.setenv("BOR_THEME", "../evil.css")
|
||||||
|
with pytest.raises(ValidationError, match=r"\.\./evil\.css"):
|
||||||
|
_settings()
|
||||||
|
|||||||
@@ -95,6 +95,60 @@ def test_brand_js_reskins_title_brand_text_prose_and_attributes() -> None:
|
|||||||
assert marker in js, f"the attribute pass must cover {marker}"
|
assert marker in js, f"the attribute pass must cover {marker}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_brand_js_applies_phase_62_customization_from_the_same_fetch() -> None:
|
||||||
|
"""Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled
|
||||||
|
/api/config answer also drives the three customization keys — the
|
||||||
|
#message-input placeholder, every .footer-text node, and the theme
|
||||||
|
stylesheet link (inserted right after the styles.css link, guarded
|
||||||
|
by #theme-override, degrading with a console.warn on 404 — A5).
|
||||||
|
No second network call: the keys ride the existing boot fetch."""
|
||||||
|
js = _text(BRAND_JS)
|
||||||
|
assert js.count('= fetch("/api/config"') == 1, (
|
||||||
|
"the three keys must ride the existing boot fetch — no new call"
|
||||||
|
)
|
||||||
|
# 5. The composer placeholder (chat page only — the null guard
|
||||||
|
# no-ops on every other page).
|
||||||
|
assert 'document.querySelector("#message-input")' in js
|
||||||
|
assert "input_placeholder" in js
|
||||||
|
# 6. The footer line on all 9 pages (the phase-61 hook) — via
|
||||||
|
# textContent: an operator string can't inject markup.
|
||||||
|
assert 'document.querySelectorAll(".footer-text")' in js
|
||||||
|
assert "footer_text" in js
|
||||||
|
# 7. The theme link: /assets/themes/<name>, inserted right after
|
||||||
|
# the styles.css link, tagged #theme-override (the idempotency
|
||||||
|
# guard), with the A5 degradation warn.
|
||||||
|
assert '"/assets/themes/"' in js
|
||||||
|
assert 'link.id = "theme-override"' in js
|
||||||
|
assert 'getElementById("theme-override")' in js
|
||||||
|
assert 'insertAdjacentElement("afterend", link)' in js
|
||||||
|
assert "link.onerror" in js
|
||||||
|
assert '"brand: theme " + themeName' in js
|
||||||
|
# The styles.css finder must survive the phase-33/54 cache-bust
|
||||||
|
# rewrite: the SERVED HTML carries the asset ref with a
|
||||||
|
# ?v=<token> query (and el.href is the absolute URL), so the match
|
||||||
|
# has to run on the RAW attribute path with query/fragment
|
||||||
|
# stripped — el.href.endsWith(…) would silently skip the insertion
|
||||||
|
# (the theme never applied; found by the task-05 E2E).
|
||||||
|
assert 'getAttribute("href")' in js
|
||||||
|
assert "split(/[?#]/)[0]" in js
|
||||||
|
assert 'endsWith("styles.css")' in js
|
||||||
|
assert "el.href.endsWith" not in js
|
||||||
|
# The empty-skip no-op contract: each key is guarded before any
|
||||||
|
# DOM write, so an unset deployment stays byte-identical.
|
||||||
|
for guard in ("if (placeholder) {", "if (footerText) {", "if (themeName) {"):
|
||||||
|
assert guard in js, (
|
||||||
|
f"an empty value must skip its application ({guard})"
|
||||||
|
)
|
||||||
|
# Independence: the phase-62 block sits AFTER the app_name passes
|
||||||
|
# in the same .then — never gated by the name.
|
||||||
|
assert js.index("// 4. Attributes:") < js.index("// Phase 62"), (
|
||||||
|
"the customization keys must apply after the app_name block, "
|
||||||
|
"even when the name is the default/empty"
|
||||||
|
)
|
||||||
|
# The app_name literal default pin still holds.
|
||||||
|
assert 'window.BOR_BRAND = "Brain of Reese"' in js
|
||||||
|
|
||||||
|
|
||||||
def test_page_scripts_keep_the_default_literal_exactly_once() -> None:
|
def test_page_scripts_keep_the_default_literal_exactly_once() -> None:
|
||||||
"""The fallback literal lives in the page scripts' brand() reads —
|
"""The fallback literal lives in the page scripts' brand() reads —
|
||||||
exactly one copy per file (a second copy could drift out of sync)."""
|
exactly one copy per file (a second copy could drift out of sync)."""
|
||||||
|
|||||||
@@ -33,21 +33,25 @@ SYSTEM_LOW = "<relevance>LOW</relevance>\n"
|
|||||||
TOOLS = [{"type": "function", "function": {"name": "list_documents"}}]
|
TOOLS = [{"type": "function", "function": {"name": "list_documents"}}]
|
||||||
|
|
||||||
#: The agent's ``list_documents`` output for a two-document KB
|
#: The agent's ``list_documents`` output for a two-document KB
|
||||||
# (``app/rag/agent.py`` ``_execute_tool``): one ``source/path — title``
|
#: (``app/rag/agent.py`` ``_execute_tool``): one
|
||||||
#: line per document, ``(source, path)`` order.
|
#: ``source: X | path: Y | title: Z`` line per document (phase 63: labeled,
|
||||||
|
#: unambiguous fields), ``(source, path)`` order.
|
||||||
CATALOG_2 = (
|
CATALOG_2 = (
|
||||||
"2 documents:\n"
|
"2 documents:\n"
|
||||||
"Deployments/example-record-file.json — Example Record File\n"
|
"source: Deployments | path: example-record-file.json | title: Example Record File\n"
|
||||||
"Homelab/aws-route53.md — AWS Route 53 Notes"
|
"source: Homelab | path: aws-route53.md | title: AWS Route 53 Notes"
|
||||||
)
|
)
|
||||||
|
|
||||||
CATALOG_1 = "1 documents:\nDeployments/example-record-file.json — Example Record File"
|
CATALOG_1 = (
|
||||||
|
"1 documents:\n"
|
||||||
|
"source: Deployments | path: example-record-file.json | title: Example Record File"
|
||||||
|
)
|
||||||
|
|
||||||
CATALOG_3 = (
|
CATALOG_3 = (
|
||||||
"3 documents:\n"
|
"3 documents:\n"
|
||||||
"Deployments/aaa.md — AAA\n"
|
"source: Deployments | path: aaa.md | title: AAA\n"
|
||||||
"Deployments/bbb.md — BBB\n"
|
"source: Deployments | path: bbb.md | title: BBB\n"
|
||||||
"Homelab/ccc.md — CCC"
|
"source: Homelab | path: ccc.md | title: CCC"
|
||||||
)
|
)
|
||||||
|
|
||||||
DOC1_SP = "Deployments/example-record-file.json"
|
DOC1_SP = "Deployments/example-record-file.json"
|
||||||
@@ -116,10 +120,23 @@ def test_single_flow_list_step() -> None:
|
|||||||
|
|
||||||
def test_single_flow_read_step_first_catalog_line() -> None:
|
def test_single_flow_read_step_first_catalog_line() -> None:
|
||||||
flow = _tool_flow(_body(SINGLE_USER, (CATALOG_3,)))
|
flow = _tool_flow(_body(SINGLE_USER, (CATALOG_3,)))
|
||||||
# The FIRST listing line (Deployments/aaa.md), rsplit convention.
|
# The FIRST listing line (Deployments/aaa.md), labeled fields.
|
||||||
assert flow == ("read", "Deployments", "aaa.md", "call_1")
|
assert flow == ("read", "Deployments", "aaa.md", "call_1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_step_nested_path_stays_intact() -> None:
|
||||||
|
# Phase 63 bug report: the path itself contains ``/`` — the old
|
||||||
|
# ``source/path — title`` + ``rpartition("/")`` parse misread the
|
||||||
|
# split (``source=brain-of-reese-main/homelab``). The labeled fields
|
||||||
|
# recover the nested path intact, however deep.
|
||||||
|
catalog = (
|
||||||
|
"1 documents:\n"
|
||||||
|
"source: brain-of-reese-main | path: homelab/aws-route53.md | title: aws-route53"
|
||||||
|
)
|
||||||
|
flow = _tool_flow(_body(SINGLE_USER, (catalog,)))
|
||||||
|
assert flow == ("read", "brain-of-reese-main", "homelab/aws-route53.md", "call_1")
|
||||||
|
|
||||||
|
|
||||||
def test_single_flow_answer_step_with_tools_offered() -> None:
|
def test_single_flow_answer_step_with_tools_offered() -> None:
|
||||||
# Phase 45: the round cap keeps the tools offered until it is hit —
|
# Phase 45: the round cap keeps the tools offered until it is hit —
|
||||||
# the answer step fires regardless of the ``tools`` parameter.
|
# the answer step fires regardless of the ``tools`` parameter.
|
||||||
|
|||||||
@@ -51,7 +51,12 @@ def test_app_config_dict_carries_the_docs_flag() -> None:
|
|||||||
|
|
||||||
s = _settings()
|
s = _settings()
|
||||||
body = app_config(s)
|
body = app_config(s)
|
||||||
assert set(body) == {"app_name", "version", "docs_repo_configured"}
|
# Phase 62 (task 01): the response grew to the six-key set — the
|
||||||
|
# phase-62 UI customization keys ride the SAME endpoint.
|
||||||
|
assert set(body) == {
|
||||||
|
"app_name", "version", "docs_repo_configured",
|
||||||
|
"input_placeholder", "footer_text", "theme",
|
||||||
|
}
|
||||||
assert body["docs_repo_configured"] is s.docs_configured
|
assert body["docs_repo_configured"] is s.docs_configured
|
||||||
assert body["docs_repo_configured"] is False
|
assert body["docs_repo_configured"] is False
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""Unit: phase-61 text pins — the retired homelab-era copy is GONE, the
|
||||||
|
locked neutral replacements are PRESENT (house pattern: read the files
|
||||||
|
as text, assert substrings — no browser, cf. test_frontend_brand.py).
|
||||||
|
|
||||||
|
The browser-visible layer is gated by the story suite
|
||||||
|
(``tests/e2e/test_stale_ui_copy.py``); these pins catch a silent
|
||||||
|
regression in the templates / the ``Settings`` defaults without it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||||
|
ROOT = FRONTEND.parent
|
||||||
|
CONFIG_PY = ROOT / "app" / "config.py"
|
||||||
|
|
||||||
|
#: Every page in the app ships a footer (the phase-39 ``HTML_PAGES``
|
||||||
|
#: tuple, repeated here so this file stands alone).
|
||||||
|
HTML_PAGES = (
|
||||||
|
"index.html",
|
||||||
|
"sources.html",
|
||||||
|
"tuning.html",
|
||||||
|
"document.html",
|
||||||
|
"login.html",
|
||||||
|
"git-sources.html",
|
||||||
|
"history.html",
|
||||||
|
"shared.html",
|
||||||
|
"doc-edit.html",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- the locked replacement copy (owner-locked A1 / A2) ----------------
|
||||||
|
|
||||||
|
META = "Ask anything about your indexed documents — every answer cites the exact doc."
|
||||||
|
EMPTY_SUB = (
|
||||||
|
"I've read through your documents — ask me anything, and I'll point "
|
||||||
|
"you at the exact doc. You've got this."
|
||||||
|
)
|
||||||
|
PLACEHOLDER = "Ask me anything…"
|
||||||
|
PAGE_SUB = (
|
||||||
|
"Every file indexed from your configured sources — git repositories, "
|
||||||
|
"local directories, and uploaded archives. Press <strong>Sync "
|
||||||
|
"sources</strong> to pull the latest and re-import."
|
||||||
|
)
|
||||||
|
FOOTER = "Powered by self-hosted models"
|
||||||
|
|
||||||
|
CHIPS = [
|
||||||
|
"What documents are in the knowledge base?",
|
||||||
|
"Which source does each answer come from?",
|
||||||
|
"How do I add a new source?",
|
||||||
|
"Summarize the most recent document.",
|
||||||
|
]
|
||||||
|
|
||||||
|
# --- the retired homelab-era copy (must be nowhere) --------------------
|
||||||
|
|
||||||
|
OLD_META_FRAG = "the homelab and deployments"
|
||||||
|
OLD_SUB_FRAG = "the homelab and deployment notes"
|
||||||
|
OLD_PLACEHOLDER_FRAG = "Ask me about the homelab"
|
||||||
|
OLD_FOOTER = "Reese's self-hosted models"
|
||||||
|
OLD_GIT_EXAMPLE = "github.com/you/homelab"
|
||||||
|
|
||||||
|
OLD_CHIPS = [
|
||||||
|
"How is my Kubernetes cluster set up?",
|
||||||
|
"What's my backup strategy?",
|
||||||
|
"How do I deploy a new service?",
|
||||||
|
"What's currently running in the homelab?",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _text(path: Path) -> str:
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(text: str) -> str:
|
||||||
|
"""Collapse whitespace runs — the templates wrap long lines, so the
|
||||||
|
locked copy is pinned against the normalized text."""
|
||||||
|
return re.sub(r"\s+", " ", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_page_old_copy_is_gone() -> None:
|
||||||
|
"""index.html: the old meta fragment, the old empty-state sub
|
||||||
|
fragment, the old composer placeholder and the old footer are all
|
||||||
|
retired. (The L176 code comment still says 'homelab' — A3: comments
|
||||||
|
are not user-visible and stay untouched, so only the exact old
|
||||||
|
user-visible strings are pinned.)"""
|
||||||
|
html = _text(FRONTEND / "index.html")
|
||||||
|
for frag in (OLD_META_FRAG, OLD_SUB_FRAG, OLD_PLACEHOLDER_FRAG, OLD_FOOTER):
|
||||||
|
assert frag not in html, f"retired chat copy still present: {frag!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_page_old_copy_is_gone() -> None:
|
||||||
|
"""sources.html: the ~/Homelab + ~/Deployments page-sub citations and
|
||||||
|
the old footer are retired."""
|
||||||
|
html = _text(FRONTEND / "sources.html")
|
||||||
|
for frag in ("~/Homelab", "~/Deployments", OLD_FOOTER):
|
||||||
|
assert frag not in html, f"retired sources copy still present: {frag!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_sources_page_old_copy_is_gone() -> None:
|
||||||
|
"""git-sources.html: the old example repo URL (A3) and the old
|
||||||
|
footer are retired."""
|
||||||
|
html = _text(FRONTEND / "git-sources.html")
|
||||||
|
for frag in (OLD_GIT_EXAMPLE, OLD_FOOTER):
|
||||||
|
assert frag not in html, f"retired git-sources copy still present: {frag!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_chat_page_locked_copy_present_exactly_once() -> None:
|
||||||
|
"""The three locked (A1) chat strings, each exactly once in
|
||||||
|
index.html (a second copy could drift out of sync)."""
|
||||||
|
html = _norm(_text(FRONTEND / "index.html"))
|
||||||
|
assert html.count(META) == 1, "the locked meta description"
|
||||||
|
assert html.count(EMPTY_SUB) == 1, "the locked empty-state sub"
|
||||||
|
assert html.count(PLACEHOLDER) == 1, "the locked composer placeholder"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sources_page_sub_is_the_locked_copy() -> None:
|
||||||
|
"""The KB .page-sub (the string the TODO cited by name) reads the
|
||||||
|
locked (A1) copy, including the <strong> around Sync sources —
|
||||||
|
pinned inside the .page-sub element, not anywhere in the file."""
|
||||||
|
html = _text(FRONTEND / "sources.html")
|
||||||
|
m = re.search(r'<p class="page-sub">(.*?)</p>', html, re.DOTALL)
|
||||||
|
assert m, "sources.html must keep the .page-sub"
|
||||||
|
assert _norm(m.group(1)) == PAGE_SUB
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_nine_footers_are_the_locked_neutral_default() -> None:
|
||||||
|
"""Every page carries the locked (A1) footer inside exactly one
|
||||||
|
``class="footer-text"`` span (the stable hook phase 62's
|
||||||
|
BOR_FOOTER_TEXT env var drives)."""
|
||||||
|
span = f'<span class="footer-text">{FOOTER}</span>'
|
||||||
|
for page in HTML_PAGES:
|
||||||
|
html = _text(FRONTEND / page)
|
||||||
|
assert html.count('class="footer-text"') == 1, (
|
||||||
|
f"{page}: exactly one .footer-text span"
|
||||||
|
)
|
||||||
|
assert html.count(span) == 1, f"{page}: the locked neutral footer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_default_chips_are_the_locked_list_in_order() -> None:
|
||||||
|
"""The ``Settings.suggestions`` default (app/config.py) is exactly
|
||||||
|
the four locked (A2) chips, in order (the E2E conftest derives the
|
||||||
|
app-under-test env from this field — it can never drift)."""
|
||||||
|
config = _text(CONFIG_PY)
|
||||||
|
m = re.search(r"suggestions: list\[str\] = \[(.*?)\]", config, re.DOTALL)
|
||||||
|
assert m, "the suggestions default list must exist in app/config.py"
|
||||||
|
block = m.group(1)
|
||||||
|
positions = []
|
||||||
|
for chip in CHIPS:
|
||||||
|
assert f'"{chip}"' in block, f"locked chip missing from the default: {chip!r}"
|
||||||
|
positions.append(block.index(f'"{chip}"'))
|
||||||
|
assert positions == sorted(positions), "the chips must keep the locked order"
|
||||||
|
|
||||||
|
|
||||||
|
def test_old_chips_are_retired_not_relocated() -> None:
|
||||||
|
"""The four old default chips are retired, not relocated: they must
|
||||||
|
appear NOWHERE in the app source (app/ + frontend/) — config.py
|
||||||
|
included (the scan covers it)."""
|
||||||
|
for base in (ROOT / "app", FRONTEND):
|
||||||
|
for path in sorted(base.rglob("*")):
|
||||||
|
if not path.is_file() or "__pycache__" in path.parts:
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
for chip in OLD_CHIPS:
|
||||||
|
assert chip not in text, (
|
||||||
|
f"retired chip {chip!r} relocated to {path.relative_to(ROOT)}"
|
||||||
|
)
|
||||||
@@ -102,13 +102,18 @@ def test_sync_error_banner_is_a_hidden_alert() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_page_sub_copy_mentions_the_button() -> None:
|
def test_page_sub_copy_mentions_the_button() -> None:
|
||||||
"""The page-sub copy names the button as the one-click way to clone
|
"""The page-sub copy names the button as the one-click way to pull
|
||||||
the repos and re-import (the import CLI docs live elsewhere)."""
|
the latest and re-import (the import CLI docs live elsewhere).
|
||||||
|
Phase 61: the copy describes the current source model (git repos +
|
||||||
|
local directories + uploaded archives), not the old ~/Homelab +
|
||||||
|
~/Deployments clone."""
|
||||||
sub = re.search(r'<p class="page-sub">(.*?)</p>', _text(SOURCES_HTML), re.DOTALL)
|
sub = re.search(r'<p class="page-sub">(.*?)</p>', _text(SOURCES_HTML), re.DOTALL)
|
||||||
assert sub, "sources.html must keep the .page-sub copy"
|
assert sub, "sources.html must keep the .page-sub copy"
|
||||||
copy = re.sub(r"\s+", " ", sub.group(1)) # the markup wraps lines
|
copy = re.sub(r"\s+", " ", sub.group(1)) # the markup wraps lines
|
||||||
assert "Press <strong>Sync sources</strong>" in copy
|
assert "Press <strong>Sync sources</strong>" in copy
|
||||||
assert "clone the repos and re-import" in copy
|
assert "pull the latest and re-import" in copy
|
||||||
|
assert "git repositories," in copy
|
||||||
|
assert "local directories, and uploaded archives" in copy
|
||||||
|
|
||||||
|
|
||||||
def test_sources_page_stays_cdn_free() -> None:
|
def test_sources_page_stays_cdn_free() -> None:
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Unit: the phase-62 example theme (``frontend/assets/themes/``) and
|
||||||
|
the Containerfile line that ships it (A7).
|
||||||
|
|
||||||
|
No Python logic exists for this task — the mechanism lives in
|
||||||
|
``brand.js`` (pinned by test_frontend_brand.py) and the theme is a
|
||||||
|
drop-in stylesheet. Like the other frontend-adjacent unit files, this
|
||||||
|
module pins the assets as text, so a silent regression (a theme file
|
||||||
|
gaining a selector, a declaration drifting, the Containerfile line
|
||||||
|
vanishing) is caught without a browser. The browser-visible layer
|
||||||
|
(computed ``--brand``, the inserted ``<link>``) is E2E-gated by
|
||||||
|
``tests/e2e/test_ui_customization.py`` (task 05).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
FRONTEND = REPO_ROOT / "frontend"
|
||||||
|
THEMES = FRONTEND / "assets" / "themes"
|
||||||
|
INDIGO = THEMES / "indigo.css"
|
||||||
|
GUIDE = THEMES / "README.md"
|
||||||
|
CONTAINERFILE = REPO_ROOT / "Containerfile"
|
||||||
|
|
||||||
|
#: The 8 identity variables a theme may override — and the EXACT set
|
||||||
|
#: indigo.css ships (the semantic families accent/ok/err are states,
|
||||||
|
#: not identity: a theme that overrides them stops being honest).
|
||||||
|
IDENTITY_VARS = (
|
||||||
|
"--bg",
|
||||||
|
"--surface",
|
||||||
|
"--ink",
|
||||||
|
"--ink-soft",
|
||||||
|
"--line",
|
||||||
|
"--brand",
|
||||||
|
"--brand-soft",
|
||||||
|
"--brand-ink",
|
||||||
|
)
|
||||||
|
|
||||||
|
INDIGO_VALUES: dict[str, str] = {
|
||||||
|
"--bg": "#0a0e1a",
|
||||||
|
"--surface": "#111726",
|
||||||
|
"--ink": "#e6e9f0",
|
||||||
|
"--ink-soft": "#a8b0c8",
|
||||||
|
"--line": "#232c44",
|
||||||
|
"--brand": "#818cf8",
|
||||||
|
"--brand-soft": "#1a1f38",
|
||||||
|
"--brand-ink": "#c7d2fe",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _text(path: Path) -> str:
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_comments(css: str) -> str:
|
||||||
|
"""Drop ``/* … */`` comments — the pins assert against declarations,
|
||||||
|
not prose."""
|
||||||
|
return re.sub(r"/\*.*?\*/", "", css, flags=re.S)
|
||||||
|
|
||||||
|
|
||||||
|
def _declarations(css: str) -> dict[str, str]:
|
||||||
|
"""The ``--name: value`` declarations of the (single) ``:root``
|
||||||
|
block, in file order."""
|
||||||
|
return dict(re.findall(r"(--[a-z-]+)\s*:\s*([^;]+);", css))
|
||||||
|
|
||||||
|
|
||||||
|
def test_example_theme_files_exist() -> None:
|
||||||
|
"""The example theme and its authoring guide ship in the static
|
||||||
|
dir (served at /assets/themes/… in dev AND in the image)."""
|
||||||
|
assert INDIGO.is_file(), f"missing example theme: {INDIGO}"
|
||||||
|
assert GUIDE.is_file(), f"missing authoring guide: {GUIDE}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_indigo_starts_with_a_single_root_block_and_nothing_else() -> None:
|
||||||
|
"""The whole file is ONE ``:root`` block (the cascade is the entire
|
||||||
|
mechanism): after stripping comments the first non-whitespace
|
||||||
|
content is ``:root``, and no other rule, selector, or declaration
|
||||||
|
exists anywhere in the file."""
|
||||||
|
css = _strip_comments(_text(INDIGO))
|
||||||
|
assert css.lstrip().startswith(":root"), (
|
||||||
|
"indigo.css must start with the :root block (after its header "
|
||||||
|
"comment) — nothing may precede it"
|
||||||
|
)
|
||||||
|
assert re.fullmatch(r"\s*:root\s*\{[^{}]*\}\s*", css, re.S) is not None, (
|
||||||
|
"indigo.css must be exactly one :root block — no selectors, "
|
||||||
|
"no @media, no nested or extra rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_indigo_overrides_exactly_the_eight_identity_variables() -> None:
|
||||||
|
"""EXACTLY the 8 identity overrides with the locked values — no
|
||||||
|
other declarations (a 9th declaration here would be the theme
|
||||||
|
reaching past the palette), and the semantic families
|
||||||
|
(accent/ok/err) must be untouched (they encode states)."""
|
||||||
|
decls = _declarations(_strip_comments(_text(INDIGO)))
|
||||||
|
assert set(decls) == set(IDENTITY_VARS), (
|
||||||
|
f"indigo.css must override exactly the 8 identity variables, got "
|
||||||
|
f"{sorted(decls)}"
|
||||||
|
)
|
||||||
|
for name in IDENTITY_VARS:
|
||||||
|
assert decls[name].strip() == INDIGO_VALUES[name], (
|
||||||
|
f"{name} drifted from the locked value "
|
||||||
|
f"{INDIGO_VALUES[name]!r}, got {decls[name].strip()!r}"
|
||||||
|
)
|
||||||
|
for family in ("--accent-", "--ok-", "--err-"):
|
||||||
|
assert not any(k.startswith(family) for k in decls), (
|
||||||
|
f"semantic {family}* variables must stay the built-in "
|
||||||
|
f"theme (they encode states)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_indigo_identity_pairs_meet_wcag_aa() -> None:
|
||||||
|
"""The five identity text/background pairs, computed from the file's
|
||||||
|
OWN hex values (not re-typed), each meet WCAG 2.1 AA (>= 4.5:1) —
|
||||||
|
AGENTS.md rule 5. The pairs are the ones the layout actually pairs:
|
||||||
|
ink on bg/surface, ink-soft on surface, the dark bg ink on brand
|
||||||
|
(text on brand buttons is --bg, never white — the built-in's
|
||||||
|
documented 3.7:1 trap), brand-ink on surface."""
|
||||||
|
decls = _declarations(_strip_comments(_text(INDIGO)))
|
||||||
|
|
||||||
|
def lum(hexcolor: str) -> float:
|
||||||
|
h = hexcolor.lstrip("#")
|
||||||
|
chans = (int(h[i : i + 2], 16) / 255.0 for i in (0, 2, 4))
|
||||||
|
lin = [
|
||||||
|
c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
||||||
|
for c in chans
|
||||||
|
]
|
||||||
|
r, g, b = lin
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||||
|
|
||||||
|
def ratio(fg: str, bg: str) -> float:
|
||||||
|
l1, l2 = lum(fg), lum(bg)
|
||||||
|
return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
|
||||||
|
|
||||||
|
pairs = (
|
||||||
|
("ink on bg", decls["--ink"], decls["--bg"]),
|
||||||
|
("ink on surface", decls["--ink"], decls["--surface"]),
|
||||||
|
("ink-soft on surface", decls["--ink-soft"], decls["--surface"]),
|
||||||
|
("bg ink on brand", decls["--bg"], decls["--brand"]),
|
||||||
|
("brand-ink on surface", decls["--brand-ink"], decls["--surface"]),
|
||||||
|
)
|
||||||
|
for name, fg, bg in pairs:
|
||||||
|
r = ratio(fg, bg)
|
||||||
|
assert r >= 4.5, f"{name}: {r:.2f}:1 < 4.5:1 (WCAG 2.1 AA)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_containerfile_ships_the_whole_themes_directory() -> None:
|
||||||
|
"""A7: stage 1 copies the WHOLE themes directory (no per-file
|
||||||
|
esbuild — a future theme file needs no Containerfile edit), and it
|
||||||
|
does so AFTER the styles.css minify line (so the served /assets/
|
||||||
|
tree is complete before the pages cp)."""
|
||||||
|
cf = _text(CONTAINERFILE)
|
||||||
|
stage1 = cf.split("AS frontend", 1)[1].split("\nFROM", 1)[0]
|
||||||
|
lines = stage1.splitlines()
|
||||||
|
cp_idxs = [
|
||||||
|
i
|
||||||
|
for i, ln in enumerate(lines)
|
||||||
|
if re.search(r"\bcp\s+-r\s+\./assets/themes\s+/out/assets/themes\b", ln)
|
||||||
|
]
|
||||||
|
assert len(cp_idxs) == 1, (
|
||||||
|
"stage 1 must ship the themes directory with exactly one "
|
||||||
|
"'cp -r ./assets/themes /out/assets/themes' line"
|
||||||
|
)
|
||||||
|
styles_idxs = [
|
||||||
|
i for i, ln in enumerate(lines) if "esbuild ./assets/styles.css" in ln
|
||||||
|
]
|
||||||
|
assert len(styles_idxs) == 1, "stage 1 must minify styles.css"
|
||||||
|
assert cp_idxs[0] > styles_idxs[0], (
|
||||||
|
"the themes cp must come AFTER the styles.css minify line"
|
||||||
|
)
|
||||||
|
cp_line = lines[cp_idxs[0]]
|
||||||
|
assert "--bundle" not in cp_line and "esbuild" not in cp_line, (
|
||||||
|
"A7: the themes directory is copied verbatim — no per-file "
|
||||||
|
"esbuild minify"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_authoring_guide_pins_the_contract() -> None:
|
||||||
|
"""The guide documents the load path (BOR_THEME → /api/config →
|
||||||
|
brand.js link after styles.css), the filename validator regex, the
|
||||||
|
8-variable table, the 4.5:1 bar, the never-white-on-brand trap,
|
||||||
|
and the A7 rebuild story (a new file needs no Containerfile edit)."""
|
||||||
|
guide = _text(GUIDE)
|
||||||
|
for marker in (
|
||||||
|
"BOR_THEME",
|
||||||
|
"/api/config",
|
||||||
|
"styles.css",
|
||||||
|
r"^[a-z0-9_-]+\.css$",
|
||||||
|
"4.5:1",
|
||||||
|
"white-on-brand",
|
||||||
|
"cp -r ./assets/themes /out/assets/themes",
|
||||||
|
):
|
||||||
|
assert marker in guide, f"themes/README.md must document {marker!r}"
|
||||||
|
for var in IDENTITY_VARS:
|
||||||
|
assert var in guide, f"the variable table must list {var}"
|
||||||
Reference in New Issue
Block a user