Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d22d260b8b | ||
|
|
3095c4c577 | ||
|
|
0c8a7b9974 |
@@ -0,0 +1,36 @@
|
||||
# Phase 90 — Upload stops scanning: the RAG page's Sync button owns the scan
|
||||
|
||||
**Source:** `TODO.md` L3 — "Uploading a source archive should not trigger a scan - that should be left to the sync button on the RAG page. Right now the sync starts right away which doesn't give the user time to edit the ignore list. Update the button text as well to read "Upload" rather than "Upload and scan""
|
||||
**Story:** n/a (TODO-derived — owner roadmap confirmation 2026-09-09).
|
||||
**Context:** `POST /api/git-sources/upload` (`app/api/git_sources.py::upload_archive`) does three inline gates (1. name/format 422, 2. one-at-a-time 409, 3. 1 MiB-chunk stream into a dotfile temp with the `upload_max_mb` 413 cap), returns 202 + `UploadAccepted`, then runs `_run_upload` in the background: 4. unpack (traversal/symlink/corrupt/over-cap → `failed`), 5. atomic swap-in (same-name re-upload replaces in place), 6. row upsert by `path` (`kind='local'`, `added_at` preserved, the row's saved `ignore_paths` captured — phase 89), 7. fail-fast `check_models`, 8. `import_sources(…, prune=True, progress=<hook>, ignore_by_root=…)` + change-gated `regenerate_overview`, 9. INFO log line, 10. `success` with the `UploadOut` count fields in `detail`. `GET /api/git-sources/upload/status` mirrors the phase-32 sync-status key set (`state`, `started_at`, `finished_at`, `detail`, `error`, `current_file`, `files_done`, `files_total`) and is what `frontend/assets/git-sources.js` polls (2 s poll, `startUploadPolling`; boot re-attach `initUploadStatus`). The Sources view (`#view-git-sources`, static form in `frontend/index.html`, `#archive-upload-btn` labeled "Upload & scan") and the RAG view (`#view-rag`) where the admin-only `#sync-btn` ("Sync sources") lives; `app/api/sync.py::_run_sync` already mirrors every `git_sources` row, including `kind='local'` (the stored directory, re-verified, missing dir → `local source missing: <path>`), honoring each row's `ignore_paths` (phase 89) — so a registered upload row is imported on the next sync with **no sync change**. The phase-89 per-row "Ignore paths" editor (`#ignore-editor-dialog` in `git-sources.js`) is the UI the deferral exists for: upload → row visible → edit ignore list → sync.
|
||||
|
||||
## Objective
|
||||
Uploading a source archive stores it, unpacks it, and registers the source row — and **nothing else**: no model check, no import, no overview regeneration. The scan is left to the "Sync sources" button on the RAG page, giving the owner time to edit the new source's ignore list first. The upload button reads "Upload" (was "Upload & scan") and all surrounding copy says the upload unpacks only and the sync scans.
|
||||
|
||||
## Dependencies
|
||||
- `89_source_ignore_paths` (complete) — the per-row ignore-list editor is the workflow this phase unlocks; the upload still captures the row's saved `ignore_paths` on re-upload.
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
|
||||
- **What the background run keeps (A1, owner-locked 2026-09-09).** Steps 1–6 unchanged: the three inline gates, the streaming receive, unpack (with all its sanitized `failed` states), the zero-entry `failed`, the atomic swap-in, the row upsert (existing row left as-is — `added_at` preserved, `ignore_paths` preserved — new row inserted with `kind='local'`). The scan = steps 7–8 (and their success detail) leave the upload entirely.
|
||||
- **Status contract (A2, owner-locked 2026-09-09).** Same key set as today. Terminal `success` now carries `detail = {"message": "uploaded"}` (no count fields), `current_file = null`, `files_done = files_total = 0`. `failed` states unchanged in shape and wording (unpack/zero-entry/swap/row). `current_file`/`files_done`/`files_total` stay `null`/`0`/`0` for the whole background run (unpack has no file-level progress hook) — the UI's in-flight label loses its "(n/m)" file count for uploads (sync keeps its live file label; phase 64).
|
||||
- **Sync unchanged (A4, owner-locked 2026-09-09).** `_run_sync` already imports `kind='local'` rows with prune + ignore lists; task 03 proves the full loop in E2E. No change to `app/api/sync.py` in this phase.
|
||||
- **Copy (A3, owner-locked 2026-09-09).** Button label exactly **Upload** (static label, `restoreUploadButton`, and any relabel sites). The success moment keeps the phase-64 toast ("Successfully uploaded — <file>") but the settled result line points at the RAG page: "Uploaded <name> — press **Sync sources** to import it." `#git-sources-hint` and the table caption are re-worded: uploads unpack and register only; the Sync button scans.
|
||||
|
||||
## Tasks
|
||||
1. `01_upload_defers_scan.md` — the background run stops after the row upsert; success status is a no-count "uploaded" payload; unit tests.
|
||||
2. `02_upload_ui_and_copy.md` — "Upload" button, in-flight/result/toast copy, `#git-sources-hint` + caption; affected existing suites updated in place.
|
||||
3. `03_e2e_upload_then_sync.md` — the story's dedicated Playwright E2E: upload → nothing indexed → edit ignore list → Sync sources on the RAG page → docs land, ignores honored.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit/integration: the upload pipeline's new terminal states (row registered, KB untouched, no model call); the status payload shape; the sync-imports-a-fresh-upload-row path already covered by the sync suites (no change) — plus every affected existing test updated, none deleted without replacement.
|
||||
- Coverage: **>90%** on new/modified code (`uv run pytest --cov=app --cov-report=term-missing`).
|
||||
- This phase's Playwright E2E suite: `tests/e2e/test_upload_no_scan.py`, run in isolation (`uv run pytest tests/e2e/test_upload_no_scan.py -v --no-cov`).
|
||||
- Affected existing E2E suites (`test_archive_upload_sources.py`, `test_sync_upload_progress.py`) updated in place and green.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Uploading an archive on the Sources page settles with the "ready for sync" result and indexes **zero** documents; the source row is present with its ignore editor.
|
||||
- [ ] The upload button reads "Upload"; no "Upload and scan" copy remains anywhere (`rg "Upload &" frontend/` → nothing).
|
||||
- [ ] "Sync sources" on the RAG page imports the uploaded source (respecting its edited ignore list) — proven by the isolated E2E.
|
||||
- [ ] Full test suite green, `app/` coverage >90%, `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] One atomic Conventional Commits commit for the phase (`--no-gpg-sign`), `.agents/` phase files moved to `complete/` by the pipeline.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Task 01 — The upload's background run stops after the row upsert (no scan)
|
||||
|
||||
**Phase:** `90_upload_no_scan` · **Source:** `TODO.md` L3 — "Uploading a source archive should not trigger a scan - that should be left to the sync button on the RAG page. Right now the sync starts right away which doesn't give the user time to edit the ignore list."
|
||||
**Story:** n/a (TODO-derived).
|
||||
|
||||
## Objective
|
||||
`_run_upload` keeps the gates, streaming, unpack, swap-in, and row upsert — and drops the model check, the import, and the overview regeneration. The upload's job ends with the source row registered and the folder on disk; the RAG page's Sync button performs the scan.
|
||||
|
||||
## Work
|
||||
1. `app/api/git_sources.py` — `_run_upload` (the post-202 background pipeline):
|
||||
- Keep steps 4–6 byte-for-byte in behavior: unpack (`unpack_archive` + zero-entry `failed`), atomic `swap_in`, the short-lived-session row upsert by `path` (existing row left as-is — `added_at` + `ignore_paths` preserved; new row `kind='local'`, `IntegrityError` backstop message unchanged).
|
||||
- **Delete step 7** (the fail-fast `check_models` — its `ModelUnavailableError → failed` arm goes with it; model availability is the sync's concern) and **step 8** (`import_sources(…, prune=True, progress=…, ignore_by_root=…)` + the change-gated `regenerate_overview` call).
|
||||
- Step 9: replace the scan INFO line with one line in the same PLAN §9 house shape for the upload leg: e.g. `upload: finished name=<name> bytes=<total_bytes> total_ms=<int> state=<state>` (unpack+register only — no file counts).
|
||||
- Step 10: `success` state with `detail = {"message": "uploaded"}`, `current_file = None`, `files_done = files_total = 0` (A2 — the key set is unchanged; the UI composes the user copy).
|
||||
- Rewrite the docstrings that describe the pipeline (`upload_archive`'s numbered docstring — renumber steps 4–6 + the new terminal; `_run_upload`'s docstring — drop the models/import failure modes, keep the unpack/zero-entry/swap/row ones; the module docstring if it summarizes the upload flow) — house style: the docstrings are the contract.
|
||||
- Imports: drop what is now unused (`check_models` / `ModelUnavailableError`, `import_sources`, `regenerate_overview`, the `UploadOut`-related names if the schema goes — see 2.) so `ruff` stays clean.
|
||||
2. `app/schemas.py` — if `UploadOut` (or the upload success-detail model) exists, remove it or reduce it to the new `{"message": "uploaded"}` shape — whichever keeps the schema surface honest; `UploadAccepted` (the 202 body) is unchanged.
|
||||
3. `tests/unit/test_git_sources.py` — rework the upload pipeline tests to the new contract:
|
||||
- success: the folder swapped in, the row upserted (new + same-name re-upload — `added_at` preserved, `ignore_paths` preserved), **no documents created, no chunks, no model calls** (assert via the fake LLM's call log / document count), status `success` with `detail == {"message": "uploaded"}` and `current_file is None`, `files_done == files_total == 0`.
|
||||
- failure states unchanged: bad unpack, zero entries, swap failure, row `IntegrityError` — same sanitized `failed` messages; the `_upload_in_progress` flag released in `finally`.
|
||||
- gates unchanged: format 422 (naming the accepted set), one-at-a-time 409, size 413.
|
||||
- remove any assertion that the upload imports/prunes/embeds.
|
||||
|
||||
- ASSUMPTION: (A1, owner-locked 2026-09-09) unpacking stays part of the upload — the folder must exist on disk for sync to walk it; only the model check + import are deferred.
|
||||
- ASSUMPTION: (A2, owner-locked 2026-09-09) the `upload/status` key set is unchanged; the success `detail` carries `{"message": "uploaded"}` instead of import counts.
|
||||
- ASSUMPTION: (A4, owner-locked 2026-09-09) no change to `app/api/sync.py` — it already imports `kind='local'` rows (prune + `ignore_paths`); the E2E (task 03) proves the loop.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit/integration: the behaviors listed in Work item 3; the existing upload-status and sync suites stay green untouched.
|
||||
- Coverage: **>90%** on this task's new/modified code.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit/test_git_sources.py -v --no-cov` green; no test asserts an upload-driven import.
|
||||
- [ ] `rg "import_sources|check_models|regenerate_overview" app/api/git_sources.py` → no matches.
|
||||
- [ ] Full test suite green; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] no behavior change in completed work (sync, CLI import, ignore paths all untouched).
|
||||
@@ -0,0 +1,41 @@
|
||||
# Task 02 — The Sources view says "Upload" and unpacks-only copy
|
||||
|
||||
**Phase:** `90_upload_no_scan` · **Source:** `TODO.md` L3 — "Update the button text as well to read "Upload" rather than "Upload and scan"" (plus the timing clause — "doesn't give the user time to edit the ignore list" — which the copy now names: edit the ignore list, then Sync).
|
||||
**Story:** n/a (TODO-derived).
|
||||
|
||||
## Objective
|
||||
The upload affordance and every piece of surrounding copy match the new contract: the button reads **Upload**, the in-flight state covers unpacking only (no "(n/m)" file count), the settled result points the owner at the RAG page's Sync button, and the Sources-page hint/caption no longer claim uploads scan. Affected existing test suites are updated in place.
|
||||
|
||||
## Work
|
||||
1. `frontend/index.html`:
|
||||
- `#archive-upload-btn` (in `#archive-upload-form`, `#view-git-sources`): label "Upload & scan" → **Upload** (A3 — exactly "Upload").
|
||||
- The phase-49/64 comment block above the form: re-point the contract summary — 202 at safe-on-disk, toast, processing state covers UNPACK only, success line points at Sync sources.
|
||||
- `#git-sources-hint` (`role="note"`): reword the upload sentence — "Uploads unpack and register the source only — re-uploading the same filename replaces that source in place (no new folder, no duplicate row). Press **Sync sources** on the RAG page to scan it; edit the source's ignore paths first if you want files excluded." The removal/total-removal sentences stay as-is.
|
||||
- The `#git-sources-table` caption: "…and uploaded archives (unpacked under the upload directory)" → drop any scan implication (unpacked + registered; the Sync button imports them).
|
||||
2. `frontend/assets/git-sources.js` (the upload block, phase 64 section):
|
||||
- `restoreUploadButton()`: `"Upload & scan"` → `"Upload"`.
|
||||
- `fmtUploadResult(detail)`: the sync-style count shape is gone — replace with the new success copy: read `detail.message` (task 01's `{"message": "uploaded"}`) and render `Uploaded <name> — press Sync sources to import it.` (the name from the accepted upload; keep the function's single-role: the `role=status` result line text).
|
||||
- Poll tick (running branch): the label is now bare "Processing…" for the whole background run (unpack has no file-level progress — no "(n/m)", no file in the title); keep the 2 s cadence, the `uploadPollTimer` ownership, and the terminal branches (success → result line + announce + `loadSources`, no second toast; failed → sanitized banner + `loadSources`, file selection kept; idle → defensive restore).
|
||||
- Toast (`showUploadToast` + its call site): unchanged wording ("Successfully uploaded — <file>") — it marks the 202, which still means "safely on disk".
|
||||
- `initUploadStatus` boot re-attach: a running run re-enters the (now bare) processing state; a terminal `success` re-renders the new result line.
|
||||
- Module header docstring (the phase 64/65 upload paragraph at the top of the file): update to the unpack-only contract, citing phase 90.
|
||||
- The success announcement ("Archive uploaded: …" line) → reword to name the next step ("Archive uploaded — press Sync sources to import it.").
|
||||
3. Affected existing suites — update in place (assert the NEW contract; do not delete coverage without a replacement):
|
||||
- `tests/e2e/test_archive_upload_sources.py` — the upload leg: button text "Upload", 202 toast, processing without "(n/m)", settled "ready for sync" line, **no indexed documents after upload** (assert the RAG table is unchanged / stats 0 for the source), the source row present with its "Ignore paths" control; the gates (422/409/413) and re-upload-in-place assertions keep their intent against the new flow.
|
||||
- `tests/e2e/test_sync_upload_progress.py` — the upload no longer shows live import progress: rework the upload legs to the unpack-only processing state; the SYNC progress assertions (live "Syncing… <file> (n/m)" label on the RAG page) stay green — add a leg where the sync that follows an upload DOES show its live file label and lands the counts.
|
||||
- `tests/unit/test_frontend_sync_upload.py` — the JS contract assertions it makes (labels, result-line shape, poll ownership) updated to the task-01/02 contract.
|
||||
4. Copy sweep: `rg -n "scan" frontend/ | rg -iv "scanner"` — every remaining "scan" mention in the Sources/upload context must be re-pointed at the RAG page's Sync button (the RAG page's own copy already says it).
|
||||
|
||||
- ASSUMPTION: (A3, owner-locked 2026-09-09) the button label is exactly "Upload" (the current "Upload & scan" and the TODO's "Upload and scan" both become "Upload").
|
||||
- ASSUMPTION: the "Successfully uploaded — <file>" 202 toast wording is kept (phase-64 owner-locked A2 copy) — only the settled result line changes.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit/integration: task 3's suite updates green; `uv run pytest tests/unit/test_frontend_sync_upload.py tests/e2e -v --no-cov` for the touched files.
|
||||
- Coverage: **>90%** on this task's new/modified code (frontend JS is exercised by the E2E suites; keep any Python-side coverage untouched).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `rg -n "Upload & scan|Upload and scan" frontend/ app/ tests/` → no matches.
|
||||
- [ ] The upload flow in a running app: button "Upload" → 202 toast → bare "Processing…" → "Uploaded <name> — press Sync sources to import it." with zero new documents indexed.
|
||||
- [ ] `tests/e2e/test_archive_upload_sources.py` and `tests/e2e/test_sync_upload_progress.py` green (updated in place).
|
||||
- [ ] Full test suite green; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] no behavior change in completed work (sync progress UI, gate behavior, re-upload in place all preserved).
|
||||
@@ -0,0 +1,26 @@
|
||||
# Task 03 — Story E2E: upload → (no scan) → edit ignores → Sync sources
|
||||
|
||||
**Phase:** `90_upload_no_scan` · **Source:** `TODO.md` L3 — "Uploading a source archive should not trigger a scan - that should be left to the sync button on the RAG page. Right now the sync starts right away which doesn't give the user time to edit the ignore list."
|
||||
**Story:** n/a (TODO-derived — one story, one phase, one isolated E2E file: `tests/e2e/test_upload_no_scan.py`).
|
||||
|
||||
## Objective
|
||||
The phase's dedicated Playwright suite proves the whole deferred-scan loop end-to-end against the real pipeline: an upload indexes nothing; the owner edits the new source's ignore list; the RAG page's "Sync sources" button performs the scan and honors the ignores.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_upload_no_scan.py` (NEW — module header in the house style, story gate + isolated-run command + the fixtures it borrows):
|
||||
- **Fixtures** — reuse the patterns from `tests/e2e/test_archive_upload_sources.py` (do NOT import that module; mirror its local helpers): per-module app env (`BOR_UPLOAD_DIR` → scratch dir, `BOR_GIT_SOURCES` forced empty, mock LLM, `db_ready`, per-test `_clean` truncate), `_build_targz` in-test archive, `_admin_git_sources_page` login helper, `_docs` reader. The archive: `e2e-upload-no-scan.tar.gz` → `alpha.md`, `beta.md`, `notes/skipme.md` (markdown sentinels `ALPHA-…` / `BETA-…` / `SKIPME-…`). No `slow_llm` proxy needed on the upload leg (the upload makes zero LLM calls now); the sync leg is short (2 files + 1 ignored) — poll the RAG page's sync status/`#sync-result` with a generous timeout instead of racing a timer.
|
||||
- **Test 1 — upload does not scan:** admin Sources page → assert the button reads exactly **Upload**; upload the archive → 202 "Successfully uploaded — e2e-upload-no-scan" toast → settled result line "Uploaded e2e-upload-no-scan — press Sync sources to import it." → assert **no documents** indexed from the source (the RAG page's table is empty of them, `_docs` shows none, the source's folder exists on the host under `BOR_UPLOAD_DIR` with all three files).
|
||||
- **Test 2 — ignore list, then Sync scans:** (fresh state via the autouse clean) upload the archive → success line → open the row's phase-89 "Ignore paths" editor → add `notes` → save (row shows the "1 ignored" count tag) → navigate to the RAG page (`/sources.html`) → click "Sync sources" (`#sync-btn`) → wait for the sync to settle (`#sync-result` announces the counts, button restored) → assert the RAG table lists `alpha.md` and `beta.md` for the source and **not** `notes/skipme.md`.
|
||||
- **Test 3 — re-upload replaces without a scan:** (fresh state) upload the archive, success, then upload a v2 archive (same basename, `beta.md` modified, `gamma.md` added, `alpha.md` dropped) → success again with exactly one source row (in-place replace, phase-49 contract) and still **zero** documents indexed from it.
|
||||
2. `tests/e2e/conftest.py` / shared helpers — touch only if a genuinely shared helper is missing (the plan above uses per-module local helpers, so expect no changes).
|
||||
|
||||
- ASSUMPTION: (A4, owner-locked 2026-09-09) the sync backend already imports `kind='local'` rows with prune + `ignore_paths` — this suite is the proof; if it fails, the defect is fixed here or in `app/api/sync.py` as a minimal, documented correction (flag it in the task report).
|
||||
|
||||
## Testing & Quality
|
||||
- E2E in isolation (DB up: `podman compose up -d db`): `uv run pytest tests/e2e/test_upload_no_scan.py -v --no-cov` — green.
|
||||
- Coverage: this task adds no `app/` code — the phase coverage gate (>90%) is satisfied by tasks 01/02.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_upload_no_scan.py -v --no-cov` green (3 tests).
|
||||
- [ ] The suite proves: zero docs after upload; ignore edit before sync is honored by the sync; re-upload stays in-place and scan-free.
|
||||
- [ ] no behavior change in completed work.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Phase 91 — Admin Theme tab: pickers + fields, pre-paint theme, CSS-file theming retired
|
||||
|
||||
**Source:** `TODO.md` L4 — "Custom theming isn't really working. The page loads red first and then the theme "pops" into view, replacing words and colors in an obvious way. Remove the custom css file theming. Create a new admin tab that allows the user to change everything the env var and custom css currently supports but with buttons and color pickers. Theme should load immediately, not pop in after the page load."
|
||||
**Story:** n/a (TODO-derived — owner roadmap confirmation 2026-09-09).
|
||||
**Context:** Today's theming: `BOR_THEME=<file>.css` (bare-filename validated at boot in `app/config.py::_theme_bare_css_filename`, fail-loud) → served by `GET /api/config` → `frontend/assets/brand.js` step 7 inserts `<link rel="stylesheet" href="/assets/themes/<file>" id="theme-override">` **after the boot fetch settles** — so the page paints with the built-in red-first palette, then the theme's `:root` overrides swap in: the pop-in the owner saw. A theme file is one `:root` block overriding the **8 identity variables** (`--bg`, `--surface`, `--ink`, `--ink-soft`, `--line`, `--brand`, `--brand-soft`, `--brand-ink` — built-ins in `frontend/assets/styles.css`; authoring guide + the 5 contrast pairs in `frontend/assets/themes/README.md`; the semantic families `--accent-*`/`--ok-*`/`--err-*` are deliberately NOT identity). The env vars also carry 3 strings the same boot fetch applies via `brand.js`: `BOR_APP_NAME` (name passes incl. the TreeWalker prose replace), `BOR_INPUT_PLACEHOLDER`, `BOR_FOOTER_TEXT` (empty = template default = byte-identical contract). `Containerfile` line 27 ships `assets/themes` into the image. **Admin-tab pattern** (Tuning/Tokens, phases 76/79): a hidden `<a class="nav-link" id="nav-<x>" hidden>` in the shell header revealed by `frontend/assets/header.js` when `whoami` says `role === "admin"`; a `<section class="view" id="view-<x>" hidden inert aria-label="…" tabindex="-1">` in `frontend/index.html` (admin gate = the `#sources-gate` pattern, manager content `#<x>-content` hidden until admin); `frontend/assets/router.js` maps the pathname (`PATH_TO_VIEW`, `VIEW_HREFS`, title + meta tables, lazy module import on first show); `app/main.py::_shell_routes` tuple serves the shell at the `.html` path; `app/core/caching.py::HTML_PAGES` gets the entry (no-cache + `?v=<token>` rewrite). **The injection point:** `CachingMiddleware` (phase 33/54) already buffers EVERY known HTML page's response body (and `/shared/<token>` by prefix) to rewrite asset refs — it is the one place that can put the theme into the HTML before first paint with zero client timing.
|
||||
|
||||
## Objective
|
||||
Retire the CSS-file theming entirely (`BOR_THEME`, `frontend/assets/themes/`, the brand.js link insertion, the Containerfile ship line). Ship a new **admin-only Theme tab** (a 7th shell view at `/theme.html`) where the owner sets — with text fields and color pickers, persisted to a new single-row `ui_settings` table — everything the env vars and the custom CSS supported: the app name, the input placeholder, the footer text, and the 8 identity colors. The effective theme is injected as an inline `<style>:root{…}</style>` into every served HTML page at serve time, so a themed deployment renders its palette on the **first paint** — no red flash, no pop-in.
|
||||
|
||||
## Dependencies
|
||||
- `90_upload_no_scan` (todo) — pipeline predecessor (execution order) only; no code dependency (this phase touches `app/config.py`, `app/api/config.py`, `app/core/caching.py`, `app/core/theming.py`, `app/models.py`, a new `app/api/ui_settings.py`, the shell frontend, and the Containerfile — none of which phase 90's pins reach; its suites must stay green unchanged).
|
||||
|
||||
## Design (shared by all tasks — the executor reads this, not the chat)
|
||||
|
||||
- **Storage (task 01).** `ui_settings` — ONE row (PK `id`, the row is created/updated by PUT; GET upserts nothing — a missing row means "defaults"). Columns, all **nullable** (NULL = "use the default"):
|
||||
- `app_name`, `input_placeholder`, `footer_text` — `String(300)`; default = the env value (`settings.app_name` etc. — B1: env vars stay as the fallback; a non-empty DB value wins; an empty/NULL DB value falls back to the env value).
|
||||
- `bg`, `surface`, `ink`, `ink_soft`, `line`, `brand`, `brand_soft`, `brand_ink` — `String(7)` (`#rrggbb`); default = the BUILT-IN value from `app/core/theming.py` (B1: no env fallback for colors — the built-in palette IS the default).
|
||||
- Alembic `0014_ui_settings.py` (revises `0013`; downgrade drops the table).
|
||||
- **Effective values.** One resolver used by BOTH `/api/config` and the API: `app/core/theming.py::effective_settings(session) -> dict` — strings: DB value if it is a non-empty string, else the env value; colors: DB value if set, else the built-in. `app/core/theming.py::BUILTIN_COLORS: dict[str, str]` — the 8 built-ins keyed by variable name WITHOUT the `--` (`{"bg": "#0f0a0a", "surface": "#1a0f0f", "ink": "#f0e6e6", "ink_soft": "#b8a8a8", "line": "#2d1a1a", "brand": "#f43f5e", "brand_soft": "#2d0a0a", "brand_ink": "#fca5a5"}` — must mirror `styles.css` `:root`; a unit test parses `styles.css` and asserts equality so the two can never drift).
|
||||
- **API (task 01).** `app/api/ui_settings.py` — `GET /api/ui-settings` (admin, `require_admin` like the tokens router): the effective values (`{app_name, input_placeholder, footer_text, bg, surface, ink, ink_soft, line, brand, brand_soft, brand_ink}`). `PUT /api/ui-settings` (admin): body `UiSettingsIn` — every field optional `str | None`; each string trimmed, empty → NULL, >300 chars → 422 (fixed detail naming the field); each color must match `^#[0-9a-fA-F]{6}$` (lowercased on store) else 422 naming the field. **Normalization (owner-locked rule):** a color submitted equal to its built-in value is stored as NULL — "save the defaults" must leave the row empty so an unset deployment stays byte-identical (B4's no-op injection). Response: the new effective values.
|
||||
- **`/api/config` (tasks 01 + 03).** Serves the **effective strings** (resolver, DB-over-env) instead of the raw `settings` values; the `theme` key is DELETED (task 03). Needs a DB session — the house pattern (see how the sync/chats endpoints open short-lived sessions via `app/db.py`).
|
||||
- **Pre-paint injection (task 02).** `app/core/theming.py::theme_style_tag(colors: dict[str, str]) -> str` — `""` when every color equals its built-in (the byte-identical contract), else `<style id="bor-theme">:root{--bg:#0f0a0a;--surface:…;}</style>` (all 8, in the README's order). `app/core/caching.py` — in the HTML rewrite branch (the one that already calls `rewrite_asset_refs`): after the rewrite, `html = inject_theme(html, tag)` — a small pure helper that inserts the tag immediately BEFORE the first `</head>` (idempotent: skips if `id="bor-theme"` already present — it can't be, the static files never contain it, but the helper is pure-tested). The middleware fetches the row ONCE per response (short-lived session, the sync pattern) — a single-row SELECT, homelab page traffic; NO process cache (the theme changes at runtime from the tab). Applies to every `HTML_PAGES` entry AND `/shared/<token>` (same branch). Non-theme responses (`/assets/*`, `/api/*`) untouched.
|
||||
- **The tab (tasks 04 + 05).** `#nav-theme` ("Theme", `href="/theme.html"`, hidden; header.js admin reveal, same block as `#nav-tokens`); `#view-theme` section in `index.html` AFTER `#view-tokens` — the admin gate (`#theme-gate`, the exact `#sources-gate` pattern, `?next=/theme.html`) + `#theme-content` (hidden; revealed for admin) holding STATIC form markup (the E2E-stable-selectors house convention): `#theme-form` with 3 labeled text inputs (`#theme-app-name`, `#theme-placeholder`, `#theme-footer`) + 8 labeled `<input type="color">` (`#theme-bg` … `#theme-brand-ink`, each label shows the variable name + role), `#theme-save` (primary), `#theme-reset` (secondary, "Reset to defaults"), `#theme-error` (`role="alert"`, hidden), `#theme-result` (`role="status"`, hidden), `#theme-contrast` (`role="alert"`, hidden — the WCAG warnings). `router.js`: `PATH_TO_VIEW["/theme.html"] = "theme"`, `VIEW_HREFS.theme = "/theme.html"`, title "Theme · Brain of Reese" + meta description, lazy import of `theme.js` on first show (the tuning/tokens module pattern). `main.py`: `"/theme.html"` in the `_shell_routes` tuple. `caching.py`: `"/theme.html"` in `HTML_PAGES`.
|
||||
- **The editor (task 05).** `frontend/assets/theme.js`: mount → GET `/api/ui-settings` (admin; 403/anonymous never mounts — the gate covers it) → populate (inputs show the EFFECTIVE values, so a fresh tab shows the live theme) → live preview: on `input`, `document.documentElement.style.setProperty("--" + var, value)` (and `removeProperty` back to the built-in when a field is cleared / on reset) — the owner sees the change across the whole page while picking → **Save** (§7.4: disable + "Saving…" → PUT form values — cleared text field → `null`; colors always their current hex, the server's built-in→NULL normalization keeps the row empty on defaults) → `#theme-result` "Theme saved." (role=status) + refetch + re-populate (canonical state) → **Reset** → PUT all-null → same lifecycle → "Reset to the built-in theme." → error paths: 422 → `#theme-error` with the server detail, fields kept. **WCAG contrast (the themes README's 5 pairs, client-side):** `--ink` on `--bg`, `--ink` on `--surface`, `--ink-soft` on `--surface`, `--bg` on `--brand` (the button-ink pattern), `--brand-ink` on `--surface` — WCAG relative-luminance ratio; any pair < 4.5:1 → `#theme-contrast` lists the failing pairs ("--ink on --bg: 3.2:1 — needs 4.5:1"), WARNING-ONLY (the owner can still save — it's their homelab palette; AGENTS.md rule 5 is met by the warning + the built-in staying AA).
|
||||
- **Strings stay runtime-applied (B4, owner-locked).** App name / placeholder / footer continue to flow through `/api/config` → `brand.js` exactly as today (the boot fetch + DOM passes) — only the COLORS move to pre-paint injection (the colors are what paint the page; the name/placeholder/footer are text swaps the owner never complained pop).
|
||||
- **Retirement (task 03).** Delete: `Settings.theme` + `_theme_bare_css_filename` (`app/config.py`), the `theme` key in `/api/config` (after task 01 repoints it at the effective strings), `frontend/assets/themes/` (`indigo.css`, `README.md` — the 5 contrast pairs + built-in table are re-homed into `app/core/theming.py`'s docstring + `00_phase.md` before deletion), brand.js step 7 + its docstring paragraphs, the Containerfile `cp -r ./assets/themes` line (fix the `&&` chain).
|
||||
|
||||
## Tasks
|
||||
1. `01_ui_settings_store.md` — the single-row `ui_settings` table + migration `0014`, the `app/core/theming.py` resolver, the admin-gated `GET/PUT /api/ui-settings`, `/api/config` serves effective strings; unit + integration tests.
|
||||
2. `02_inline_theme_injection.md` — `theme_style_tag` + the `CachingMiddleware` before-`</head>` injection (byte-identical no-op when unset); caching/theming unit tests.
|
||||
3. `03_retire_css_file_theming.md` — delete `BOR_THEME` + validator, the `/api/config` theme key, `frontend/assets/themes/`, brand.js step 7, the Containerfile ship line; affected suites updated/deleted in place.
|
||||
4. `04_theme_tab_shell.md` — the 7th shell view: `#nav-theme` + `#view-theme` (gate + static form skeleton), `header.js` admin reveal, `router.js` mapping, `main.py` shell route, `HTML_PAGES` entry.
|
||||
5. `05_theme_editor.md` — `theme.js`: effective-value populate, live preview, Save/Reset (§7.4), WCAG contrast warnings; view styles.
|
||||
6. `06_e2e_theme_tab.md` — the story's dedicated E2E: save a palette → served HTML carries the inline `:root` (first paint, no pop) → anonymous sees it → 403 for non-admins → reset restores byte-identical output.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit/integration: the resolver (DB-over-env strings, built-in color fallback, empty→NULL), the PUT validation (422s naming the field, built-in→NULL normalization), the admin gate (403 anonymous + token user, 200 admin), the injection (placement before `</head>`, no-op byte-identity, idempotence, `BUILTIN_COLORS` ↔ `styles.css` drift test), `/api/config` effective strings; affected existing suites updated in place.
|
||||
- Coverage: **>90%** on new/modified code (`uv run pytest --cov=app --cov-report=term-missing`).
|
||||
- This phase's Playwright E2E suite: `tests/e2e/test_admin_theme_tab.py`, run in isolation (`uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov`).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] An unset deployment serves byte-identical HTML to today's built-in (no `#bor-theme` tag anywhere); `rg "BOR_THEME|themes/" app/ frontend/ Containerfile` → nothing (the doc-history citations in comments excepted, house style).
|
||||
- [ ] Admin-only: `/theme.html` shows the gate to anonymous and the form to admin; `PUT /api/ui-settings` → 403 for anonymous AND token users.
|
||||
- [ ] After a save, every HTML page (incl. `/shared/<token>`) carries `<style id="bor-theme">` before `</head>` and the computed `--brand` matches on first paint — no pop-in.
|
||||
- [ ] Reset restores the built-in palette and byte-identical HTML; the 5 contrast pairs warn below 4.5:1.
|
||||
- [ ] Full test suite green, `app/` coverage >90%, `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] One atomic Conventional Commits commit for the phase (`--no-gpg-sign`), `.agents/` phase files moved to `complete/` by the pipeline.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Task 01 — `ui_settings` store + resolver + admin-gated API
|
||||
|
||||
**Phase:** `91_admin_theme_tab` · **Source:** `TODO.md` L4 — "Create a new admin tab that allows the user to change everything the env var and custom css currently supports but with buttons and color pickers."
|
||||
**Story:** n/a (TODO-derived).
|
||||
|
||||
## Objective
|
||||
The persistence + resolution half: a single-row `ui_settings` table (3 nullable strings, 8 nullable colors), the `app/core/theming.py` resolver (DB-over-env strings, built-in color fallback), the admin-gated `GET/PUT /api/ui-settings`, and `/api/config` serving the **effective** strings. No frontend or injection yet.
|
||||
|
||||
## Work
|
||||
1. `app/models.py` — new `UiSettings` model (table `ui_settings`, house docstring citing phase 91 + the NULL = default rule):
|
||||
- `id: Mapped[int]` PK (`default=1` — the single row is always id 1; `GET` creates nothing, `PUT` upserts).
|
||||
- `app_name`, `input_placeholder`, `footer_text` — `Mapped[str | None]`, `String(300)`, `nullable=True` (NULL/empty = "use the env default" — B1).
|
||||
- `bg`, `surface`, `ink`, `ink_soft`, `line`, `brand`, `brand_soft`, `brand_ink` — `Mapped[str | None]`, `String(7)`, `nullable=True` (NULL = built-in — B1).
|
||||
- Update the module docstring's table list with one line for `ui_settings`.
|
||||
2. `alembic/versions/0014_ui_settings.py` (NEW — house header style, `Revision ID: 0014`, `Revises: 0013`, one-sentence rationale: the phase-91 admin Theme tab persists its values in one row; NULL = default): create the table (PK `id`, the 3 strings `sa.String(300, nullable=True)`, the 8 colors `sa.String(7, nullable=True)`); `downgrade()` drops it.
|
||||
3. `app/core/theming.py` (NEW — module docstring: phase 91, the single source of the built-in identity palette, re-homing the `frontend/assets/themes/README.md` variable table + the 5 contrast pairs BEFORE task 03 deletes that file):
|
||||
- `BUILTIN_COLORS: dict[str, str]` — the 8 built-ins keyed by bare variable name: `{"bg": "#0f0a0a", "surface": "#1a0f0f", "ink": "#f0e6e6", "ink_soft": "#b8a8a8", "line": "#2d1a1a", "brand": "#f43f5e", "brand_soft": "#2d0a0a", "brand_ink": "#fca5a5"}` (copied from `frontend/assets/styles.css` `:root`).
|
||||
- `COLOR_FIELDS: tuple[str, ...]` — the 8 keys in the README's order (used by the resolver, the API, and the tag renderer).
|
||||
- `effective_settings(session) -> dict[str, str]` — strings: the DB value if it is a non-empty `str`, else the env value (`get_settings().app_name` / `.input_placeholder` / `.footer_text`); colors: the DB value if not None, else `BUILTIN_COLORS[k]`. Returns 11 keys (`app_name`, `input_placeholder`, `footer_text`, + the 8 colors).
|
||||
- `theme_style_tag(colors: dict[str, str]) -> str` — `""` when every color equals its built-in (the byte-identical contract), else `<style id="bor-theme">:root{--bg:#0f0a0a;…;}</style>` with all 8 in `COLOR_FIELDS` order (task 02 consumes this; landing it here keeps the pure logic unit-testable in one module).
|
||||
4. `app/schemas.py` — `UiSettingsIn` (all 11 fields `str | None = None`) + `UiSettingsOut` (all 11 fields `str`).
|
||||
5. `app/api/ui_settings.py` (NEW router, house style — the tokens router's `require_admin` precedent):
|
||||
- `GET /` → `effective_settings(session)` → `UiSettingsOut`.
|
||||
- `PUT /` → validate: each string `strip()`, empty → None, `len > 300` → `HTTPException(422, f"{field} is too long (max 300)")`; each color must match `^#[0-9a-fA-F]{6}$` → lowercase, else `HTTPException(422, f"{field} must be a #rrggbb hex color")`; **a color equal to its built-in is stored as None** (defaults leave the row empty — the no-op injection contract). Upsert the id-1 row (SELECT → update-or-insert; a concurrent PUT is single-admin — the last writer wins, note it in the docstring), commit, return the new effective values.
|
||||
- Both routes admin-only (the router-level dependency, `require_admin` — 403 anonymous + token user).
|
||||
6. `app/main.py` — `include_router(ui_settings_router, prefix="/api/ui-settings")` (with the other admin routers).
|
||||
7. `app/api/config.py` — `GET /api/config` now serves the **effective** strings (open the short-lived session the sync endpoints use — `app/db.py` house pattern; the route is sync, matching the middleware world): `app_name` / `input_placeholder` / `footer_text` from `effective_settings`; the `theme` key STAYS for this task (task 03 deletes it) and `docs_repo_configured` / `version` are untouched. Update the module + route docstrings (phase 91: effective values = DB-over-env).
|
||||
8. Tests:
|
||||
- `tests/unit/test_theming.py` (NEW): `BUILTIN_COLORS` equals the `:root` values parsed out of `frontend/assets/styles.css` (drift guard); `effective_settings` — missing row → env strings + built-ins; DB row overrides win; empty-string DB string falls back to env; `theme_style_tag` — all-built-in → `""`, one changed → the tag with all 8 vars in order, exact string shape.
|
||||
- `tests/unit/test_ui_settings.py` (NEW): PUT validation (422 too-long naming the field, 422 bad hex naming the field, lowercase normalization, built-in→None normalization, empty string→None), GET effective merge, upsert creates-then-updates (id 1).
|
||||
- `tests/integration/` (extend the existing API-test convention — find the file that tests `/api/config` or the tokens admin gate and follow it): anonymous GET/PUT → 403; token-user (role "user") → 403; admin → 200 both; `/api/config` — env-only deployment returns the env strings; after an admin PUT, `/api/config` returns the DB strings; the `theme` key still present in THIS task's state.
|
||||
|
||||
- ASSUMPTION: (B1, owner-locked 2026-09-09) colors have NO env fallback — the built-in palette is the default; the three strings keep their env vars as fallbacks, DB value wins when set.
|
||||
- ASSUMPTION: (B3, owner-locked 2026-09-09) the tab covers exactly the 8 identity variables; the semantic families (`--accent-*`, `--ok-*`, `--err-*`) stay non-configurable.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit/integration: the behaviors listed in Work item 8.
|
||||
- Coverage: **>90%** on this task's new/modified code.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run alembic upgrade head` applies `0014` cleanly; `uv run pytest tests/unit/test_theming.py tests/unit/test_ui_settings.py -v --no-cov` green.
|
||||
- [ ] `GET /api/ui-settings` (admin) returns the effective 11 keys; `PUT` persists + normalizes; 403 for anonymous/token users.
|
||||
- [ ] `/api/config` returns DB-over-env strings (integration-proven).
|
||||
- [ ] Full test suite green; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] no behavior change in completed work (brand.js still reads `theme` from `/api/config` — untouched until task 03).
|
||||
@@ -0,0 +1,37 @@
|
||||
# Task 02 — Pre-paint injection: `<style id="bor-theme">` before `</head>`
|
||||
|
||||
**Phase:** `91_admin_theme_tab` · **Source:** `TODO.md` L4 — "Theme should load immediately, not pop in after the page load."
|
||||
**Story:** n/a (TODO-derived).
|
||||
|
||||
## Objective
|
||||
A themed deployment renders its palette on the **first paint**: every served HTML page (all `HTML_PAGES` + `/shared/<token>`) carries an inline `<style id="bor-theme">:root{…}</style>` inserted server-side into the response body, before the browser applies any stylesheet. Unset deployments (no row / all-defaults row) stay byte-identical to today.
|
||||
|
||||
## Work
|
||||
1. `app/core/theming.py` — add the pure injection helper (keeps the middleware DB-agnostic and pure-testable):
|
||||
- `inject_theme(html: str, tag: str) -> str` — `tag == ""` or no first `</head>` occurrence → return `html` unchanged; `id="bor-theme"` already present (defensive idempotence — the static files never contain it) → unchanged; else insert the tag (with a leading newline for readable HTML) immediately BEFORE the first `</head>`.
|
||||
2. `app/core/caching.py` — `CachingMiddleware.dispatch`, the HTML rewrite branch (the `try: new_body = rewrite_asset_refs(…)` block):
|
||||
- After the asset-ref rewrite, build the tag: open a **short-lived session** (the sync-endpoint pattern from `app/db.py`), `tag = theme_style_tag(effective_colors(effective_settings(db)))` (task 01's resolver + renderer — the resolver returns the 11 effective keys; feed the 8 color keys to the renderer; if task 01 shaped `effective_settings` to return colors directly, use that — keep ONE call), close in `finally`. Wrap the whole read in `try/except Exception` → `logger.exception("cache busting: theme read failed for %s — serving without the theme tag", path)` and `tag = ""` (loadHealth house style — a DB blip or a pre-migration boot serves the built-in palette, the page never breaks, and the no-cache headers still apply).
|
||||
- `new_body = inject_theme(html, tag).encode("utf-8")` (replace the current direct `rewrite_asset_refs(…).encode(…)` line).
|
||||
- The no-tag path MUST produce the exact bytes of today: `inject_theme(html, "")` is the identity — assert it in tests (the byte-identical contract, B4).
|
||||
- Module docstring: one paragraph — the phase-91 inline theme tag (the pre-paint theme; the asset rewrite is untouched; `/api/*` and `/assets/*` still byte-identical).
|
||||
- NO process cache for the tag (the theme changes at runtime from the admin tab; the per-response single-row SELECT is the design, owner-locked in `00_phase.md`).
|
||||
3. `tests/unit/test_caching.py` — extend (follow the file's existing app-builder convention):
|
||||
- the unset case: the served page body is byte-identical to the phase-33/54 rewrite-only output (no `#bor-theme`).
|
||||
- the themed case: with a `ui_settings` row (one changed color), `GET /` (and one non-shell page, e.g. `/document.html`, plus a `/shared/<token>`-shaped response via the prefix branch) carries the tag immediately before `</head>`, with all 8 `--*` vars in `COLOR_FIELDS` order.
|
||||
- the DB-failure case: monkeypatch the session/`effective_settings` to raise → the page still serves (200, no tag, no-cache headers intact).
|
||||
- `inject_theme` pure tests (in `tests/unit/test_theming.py`): empty tag identity, no-`</head>` identity, double-injection idempotence, exact placement string.
|
||||
4. Verify (running app, DB up): `uv run alembic upgrade head` then `uv run uvicorn app.main:app` + `curl -s / | rg -c "bor-theme"` → 0 (unset); after an admin `PUT /api/ui-settings` with one color → 1, and the tag sits before `</head>` (manual check — the E2E (task 06) is the permanent proof).
|
||||
|
||||
- ASSUMPTION: (B4, owner-locked 2026-09-09) only the 8 COLORS are injected pre-paint; the 3 strings keep the `brand.js` runtime application.
|
||||
- ASSUMPTION: the tag is computed per response (no process cache) — homelab page traffic makes the single-row SELECT negligible, and a runtime theme change must be visible on the next request without a restart.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit/integration: Work item 3 (caching + theming); the existing caching suite (phase 33/54 assertions: no-304, `?v=` rewrite, immutable assets, SSE pass-through) stays green untouched.
|
||||
- Coverage: **>90%** on this task's new/modified code.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Unset deployment: `curl -s /` is byte-identical to the pre-task output (modulo the existing `?v=` rewrite) — no `#bor-theme` anywhere.
|
||||
- [ ] Themed deployment: every HTML page + `/shared/<token>` carries exactly one `<style id="bor-theme">` immediately before `</head>`; the browser's computed `--brand` equals the saved value on first paint (E2E task 06 proves it).
|
||||
- [ ] `uv run pytest tests/unit/test_caching.py tests/unit/test_theming.py -v --no-cov` green.
|
||||
- [ ] Full test suite green; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] no behavior change in completed work (caching contract, SSE, assets — all untouched).
|
||||
@@ -0,0 +1,34 @@
|
||||
# Task 03 — Retire the CSS-file theming (`BOR_THEME` + `frontend/assets/themes/`)
|
||||
|
||||
**Phase:** `91_admin_theme_tab` · **Source:** `TODO.md` L4 — "Remove the custom css file theming."
|
||||
**Story:** n/a (TODO-derived).
|
||||
|
||||
## Objective
|
||||
The file-based theme mechanism is gone end-to-end: the env var, its validator, the `/api/config` key, the brand.js `<link>` insertion, the themes directory, and the Containerfile ship line. The only remaining theming surface is the admin tab (tasks 01/02 already landed; 04/05/06 follow).
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/themes/README.md` → **re-home before deleting**: the built-in 8-variable table and the 5 contrast pairs must live on in `app/core/theming.py`'s module docstring (task 01 already re-homed them per `00_phase.md` — verify the docstring is complete: variable table with roles + built-in values, the 5 WCAG pairs, the "never white-on-brand" note) so nothing is lost. Then delete the whole directory: `frontend/assets/themes/` (`indigo.css`, `README.md`).
|
||||
2. `app/config.py` — delete the `theme: str = ""` setting (and its docstring block) and the `_theme_bare_css_filename` field_validator; update the module docstring/settings list if it names `BOR_THEME`.
|
||||
3. `app/api/config.py` — drop the `"theme": settings.theme` key from the `/api/config` response; update the route docstring (the phase-62 key list loses `theme`; the phase-91 effective strings stay).
|
||||
4. `frontend/assets/brand.js` — delete step 7 (the `themeName` block: the `#theme-override` guard, the styles-link finder, the `<link>` insertion, the onerror degrade) and its docstring paragraphs (the item-7 contract line, the "7." in the numbered list — renumber nothing else; the no-op-property paragraph's theme mention). The `BOR_CONFIG_PROMISE` fetch and the name/placeholder/footer passes are untouched (B4).
|
||||
5. `Containerfile` — delete the `&& cp -r ./assets/themes /out/assets/themes \` stage-1 line (line 27) and fix the continuation chain so the remaining `cp` lines still concatenate.
|
||||
6. Affected tests — update in place (no deleted coverage without a replacement):
|
||||
- `tests/unit/test_themes.py` — this file tests the retired mechanism: DELETE it (its subject no longer exists; the built-in-value drift guard now lives in `tests/unit/test_theming.py`'s `styles.css` parse test, which is the replacement).
|
||||
- `tests/unit/test_config.py` — remove the `theme` validator tests (boot-refusal on bad filenames, the happy path); keep every other Settings test.
|
||||
- `tests/unit/test_frontend_brand.py` — remove the step-7 assertions (link insertion, `#theme-override` guard, missing-file degrade); keep the name/placeholder/footer/boot-promise assertions.
|
||||
- `tests/e2e/test_ui_customization.py` — strip the theme legs (set `BOR_THEME`, assert the link / themed page); keep + green the app-name / placeholder / footer legs (their behavior is unchanged — `/api/config` now answers with the effective values, which for an env-only deployment are the same strings).
|
||||
- Sweep: `rg -n "BOR_THEME|theme-override|assets/themes|indigo" app/ frontend/ tests/ Containerfile README.md .env.example` → fix or remove every hit (doc-history citations inside completed-phase files under `.agents/phases/complete/` are history — never touched; the `README.md` deployment section naming `BOR_THEME` gets the paragraph re-pointed at the admin Theme tab).
|
||||
|
||||
- ASSUMPTION: (B2, owner-locked 2026-09-09) `BOR_THEME` and `frontend/assets/themes/` are deleted entirely — the Theme tab is the only theming surface; a deployment with `BOR_THEME` set in `.env` simply ignores it (no boot failure — the setting no longer exists; note the removal in the commit body).
|
||||
- ASSUMPTION: the `.env.example` entry for `BOR_THEME` (if present — verify) is deleted; `.env` files are local artifacts (never edited, never committed).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit/integration: the updated suites of Work item 6 green; `rg` sweep clean.
|
||||
- Coverage: **>90%** on this task's new/modified code.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `rg -n "BOR_THEME|theme-override|assets/themes|indigo\.css" app/ frontend/ tests/ Containerfile .env.example` → no matches.
|
||||
- [ ] `frontend/assets/themes/` does not exist; `git status` shows the deletions staged.
|
||||
- [ ] An app booted WITHOUT `BOR_THEME` in `.env` serves byte-identical pages (built-in palette; the no-op injection from task 02).
|
||||
- [ ] `uv run pytest -v --no-cov` (full unit + integration) green; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] no behavior change in completed work (brand name/placeholder/footer application, caching, sync — all green).
|
||||
@@ -0,0 +1,38 @@
|
||||
# Task 04 — The 7th shell view: nav link, `#view-theme` skeleton, router + route wiring
|
||||
|
||||
**Phase:** `91_admin_theme_tab` · **Source:** `TODO.md` L4 — "Create a new admin tab that allows the user to change everything the env var and custom css currently supports but with buttons and color pickers."
|
||||
**Story:** n/a (TODO-derived).
|
||||
|
||||
## Objective
|
||||
The Theme tab exists in the shell exactly like Tuning/Tokens: an admin-only nav link, a `#view-theme` section (gate + static form skeleton), the `router.js` pathname mapping, the `/theme.html` shell route, and the `HTML_PAGES` entry. The editor behavior lands in task 05.
|
||||
|
||||
## Work
|
||||
1. `frontend/index.html` — nav: after the `#nav-tokens` link, `<a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a>` (ship-hidden — `header.js` reveals for admin; no mobile-dropdown copy exists for the other admin nav links, so none is added).
|
||||
2. `frontend/index.html` — new view section AFTER `#view-tokens` (before the doc modal / page footer), the exact house pattern (hidden + inert pair — WCAG contract, AGENTS.md rule 5):
|
||||
- `<section class="view" id="view-theme" hidden inert aria-label="Theme" tabindex="-1">` with a container:
|
||||
- `#theme-gate` — the EXACT `#sources-gate` pattern (phase 16): glyph, `#theme-gate-title` "Sign in to change the theme", sub-copy (the theme + branding is admin-only; chat stays open), `#theme-gate-link` → `/login.html?next=/theme.html`.
|
||||
- `#theme-content` (hidden — `theme.js` reveals it for admin, the `#git-sources-content` pattern) holding:
|
||||
- page-head: `<h1>Theme</h1>` + a sub naming the behavior — "Pick the palette and branding; the theme is baked into the page it is served on — it applies on the first paint, no pop-in."
|
||||
- `#theme-form` (STATIC markup — the E2E-stable-selectors house convention; `onsubmit` handled by `theme.js`, no real submit):
|
||||
- "Branding" fieldset/group: 3 labeled text inputs — `#theme-app-name` (label "App name", `maxlength=300`), `#theme-placeholder` (label "Chat input placeholder", `maxlength=300`), `#theme-footer` (label "Footer line", `maxlength=300`).
|
||||
- "Palette" fieldset/group: 8 labeled `<input type="color">` — `#theme-bg`, `#theme-surface`, `#theme-ink`, `#theme-ink-soft`, `#theme-line`, `#theme-brand`, `#theme-brand-soft`, `#theme-brand-ink` (each visible `<label for=…>` names the variable + its role, e.g. "Brand accent (buttons, links)").
|
||||
- `#theme-save` (type=button, primary — "Save theme") and `#theme-reset` (type=button, secondary — "Reset to defaults").
|
||||
- `#theme-error` (`role="alert"`, hidden), `#theme-result` (`role="status"`, hidden), `#theme-contrast` (`role="alert"`, hidden — the WCAG warnings from task 05).
|
||||
- The per-view footer stays where the other views' footers are (the fold convention of the RAG/Sources sections).
|
||||
3. `frontend/assets/header.js` — in `initSharedHeader()`, after the Tokens nav-link block: the same two-line reveal — `const navTheme = document.querySelector("#nav-theme"); if (navTheme) navTheme.hidden = !admin;` — with a phase-91 comment (admin-only, ship-hidden/reveal-for-admin contract).
|
||||
4. `frontend/assets/router.js` — the seven-view mapping, one entry in each of the five tables (the phase-76/79 pattern): `VIEW["/theme.html"] = "theme"` (pathname → slug); `VIEW_PATH.theme = "/theme.html"` (nav-link active stamping); `TITLES.theme = "Theme · Brain of Reese"`; `DESCRIPTIONS.theme` (one line, the view's one-liner); `VIEW_MODULES.theme = () => import("./theme.js")` (the dynamic-import pattern — the module exports `mount(root)` and the router mounts a view ONCE, root = the view's `<section id="view-theme">`, scoped lookups, the `fetchIsAdmin()` gate from `header.js` — the phase-76 shell-view-module convention).
|
||||
5. `app/main.py` — add `"/theme.html"` to the `_shell_routes(...)` tuple (after `"/tokens.html"`, phase-91 comment) so the deep link serves the shell.
|
||||
6. `app/core/caching.py` — add `"/theme.html"` to `HTML_PAGES` (the no-cache + `?v=<token>` contract — without it the deep link pins stale assets after a deploy).
|
||||
7. `frontend/assets/styles.css` — minimal skeleton styles for the view (the `.theme-shell` container, the fieldset/group spacing, the color-input row — reuse the tuning view's form classes where they fit; the editor's full styling lands in task 05).
|
||||
|
||||
- ASSUMPTION: (B5, owner-locked 2026-09-09) the tab is admin-only, same pattern as Tuning/Tokens: hidden by default, `header.js` reveals for admin; anonymous sees the gate; a token user gets the anonymous branch (link hidden, gate shown).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit/integration: no new Python logic beyond the two tuple entries — the existing shell-route/caching suites must stay green; the view's behavior is proven by task 06's E2E.
|
||||
- Coverage: **>90%** on this task's new/modified code (trivial here).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `curl -s /theme.html` serves the shell (same body as `/`); the router shows `#view-theme` for that pathname.
|
||||
- [ ] Admin session: the "Theme" nav link is visible on every page; the form skeleton renders. Anonymous: link hidden, `#theme-gate` shown.
|
||||
- [ ] The existing E2E suites (nav/header/caching related — `test_tuning_nav_link.py`, `test_cache_busting.py`, `test_asset_cache_revalidation.py`) stay green; `uv run pytest -v --no-cov` (unit + integration) green; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] no behavior change in completed work.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Task 05 — The editor: `theme.js` (pickers, fields, live preview, save/reset, contrast warnings)
|
||||
|
||||
**Phase:** `91_admin_theme_tab` · **Source:** `TODO.md` L4 — "…with buttons and color pickers." (the tab the previous task wired; this task gives it behavior)
|
||||
**Story:** n/a (TODO-derived).
|
||||
|
||||
## Objective
|
||||
`theme.js` makes the Theme tab work: it populates the form with the EFFECTIVE values, previews color changes live across the whole page while picking, saves via the admin API with the §7.4 never-stale button lifecycle, resets to the built-in defaults, and warns (never blocks) when a saved palette would break the 5 WCAG text/background pairs.
|
||||
|
||||
## Work
|
||||
1. `frontend/assets/theme.js` (NEW — the phase-76 shell-view-module contract, the `tuning.js`/`tokens.js` shape):
|
||||
- `export async function mount(root)` — every DOM lookup scoped to `root` (`#theme-gate`, `#theme-content`, `#theme-form`, the 11 inputs, `#theme-save`, `#theme-reset`, `#theme-error`, `#theme-result`, `#theme-contrast`); module-top state only (no top-level DOM access — the explicit-init house convention).
|
||||
- **Gate:** `fetchIsAdmin()` (the cached `header.js` promise — zero extra requests): admin → hide `#theme-gate`, reveal `#theme-content`; anonymous/token-user → gate stays (the nav link is already hidden by `header.js`; the direct-URL case is the gate's job).
|
||||
- **Load:** GET `/api/ui-settings` → populate the 11 inputs with the EFFECTIVE values (the tab always shows the live theme — env defaults when the row is empty). A failed fetch keeps the static form + shows `#theme-error` with a retry (the loadHealth house style — never a blanked panel).
|
||||
- **Live preview:** on `input` of any of the 8 color inputs → `document.documentElement.style.setProperty("--" + var, value)`; on `input` of a text field → no page effect (strings apply on save via `brand.js` on the next load — say so in the fieldset legend/sub-copy). On reset and on every successful save: clear all 8 `setProperty` overrides (`removeProperty`) so the page reflects the served (injected) theme, not stale preview state.
|
||||
- **Save** (`#theme-save`): the §7.4 lifecycle — disable + "Saving…" → PUT `/api/ui-settings` with the 11 form values (a cleared/empty text field → `null`; colors always their current hex — the server's built-in→NULL normalization keeps the row empty when the owner saves the defaults) → 200: `#theme-result` "Theme saved." (role=status), refetch + re-populate (canonical state), clear the preview overrides, clear `#theme-contrast` → re-enable + restore label. Failure: 422 → `#theme-error` with the server detail (naming the field), form KEPT, button restored; other non-2xx → the fixed error line.
|
||||
- **Reset** (`#theme-reset`): same §7.4 lifecycle ("Resetting…") → PUT with all 11 values `null` → `#theme-result` "Reset to the built-in theme." → refetch + re-populate (the env/built-in defaults) + clear preview overrides.
|
||||
- **WCAG contrast (the `00_phase.md` Design's 5 pairs):** a local pure helper — `contrastRatio(fgHex, bgHex)` via WCAG relative luminance (sRGB → linear → L) — evaluated on every `input`/save over the CURRENT form values for the 5 pairs (`--ink` on `--bg`, `--ink` on `--surface`, `--ink-soft` on `--surface`, `--bg` on `--brand`, `--brand-ink` on `--surface`); any pair < 4.5:1 → `#theme-contrast` (role=alert) lists each failing pair ("--ink on --bg: 3.2:1 — needs 4.5:1"); all pass → hidden. **Warning-only** — Save is never disabled by it (the owner's homelab palette; the built-in stays AA).
|
||||
- A `bor:view-refresh` listener (the phase-77 re-show hook) → re-run the load (the tab always shows the settled server state when re-shown).
|
||||
2. `frontend/assets/styles.css` — the view's styling: `.theme-shell` (the container, the 46rem/72rem frame conventions per AGENTS.md rule 5 — this is a form view, the tuning-view width pattern), the fieldset/group headings, the 3-column (desktop) / 1-column (mobile) palette grid with the color swatch inputs sized for touch, the `#theme-contrast` warning styling (the `--err-*` family — states are semantic colors, never themed), the §7.4 disabled-button look (the existing `.sync-btn`/form-button conventions).
|
||||
3. `frontend/index.html` — the `#theme-content` sub-copy naming the preview + save behavior (one line under the h1: "Changes preview live as you pick; **Save theme** bakes the palette into every page it is served on — it applies on the first paint, no pop-in."); the palette fieldset legend noting the 5 pairs are checked against WCAG 2.1 AA (4.5:1).
|
||||
|
||||
- ASSUMPTION: (B4, owner-locked 2026-09-09) the 3 strings keep the `brand.js` runtime application — the tab saves them to `ui_settings` and they take effect via the `/api/config` boot fetch on the next page load (the live preview covers colors only; the sub-copy says so).
|
||||
|
||||
## Testing & Quality
|
||||
- Unit/integration: no new Python logic — the existing suites must stay green; the editor is proven by task 06's E2E (save/reset/preview/gate) — keep `theme.js` free of any behavior that the E2E cannot see.
|
||||
- Coverage: **>90%** on this task's new/modified code (frontend — exercised by the E2E).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] Admin on `/theme.html`: the form shows the effective values; moving a picker repaints the page live; Save/Reset follow the disable-relabel-restore lifecycle and land role=status confirmations; a bad save shows the server detail in `#theme-error` and keeps the form.
|
||||
- [ ] Setting `--ink` to a near-`--bg` value lists the failing pair in `#theme-contrast` and still allows saving; restoring AA hides it.
|
||||
- [ ] Anonymous direct load of `/theme.html`: the gate (not the form).
|
||||
- [ ] `uv run pytest -v --no-cov` (unit + integration) green; `uv run ruff check . && uv run pyright` clean.
|
||||
- [ ] no behavior change in completed work (the other views' mounts/gates untouched).
|
||||
@@ -0,0 +1,28 @@
|
||||
# Task 06 — Story E2E: the Theme tab, the pre-paint proof, the admin gate, the reset
|
||||
|
||||
**Phase:** `91_admin_theme_tab` · **Source:** `TODO.md` L4 — "Custom theming isn't really working. The page loads red first and then the theme "pops" into view, replacing words and colors in an obvious way. Remove the custom css file theming. Create a new admin tab that allows the user to change everything the env var and custom css currently supports but with buttons and color pickers. Theme should load immediately, not pop in after the page load."
|
||||
**Story:** n/a (TODO-derived — one story, one phase, one isolated E2E file: `tests/e2e/test_admin_theme_tab.py`).
|
||||
|
||||
## Objective
|
||||
The phase's dedicated Playwright suite proves the whole contract: the admin tab edits all 11 values with fields and pickers; a saved theme is baked into the served HTML (inline `:root` before `</head>` — the no-pop-in proof) and visible to anonymous visitors; the API and the tab are admin-only; reset restores the built-in palette and byte-identical pages.
|
||||
|
||||
## Work
|
||||
1. `tests/e2e/test_admin_theme_tab.py` (NEW — module header in the house style: story gate, isolated-run command `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov`, DB up: `podman compose up -d db`, the per-module app env pattern from the tuning/tokens suites — a scratch DB state via the autouse clean/truncate helper, `BOR_GIT_SOURCES` forced empty, the env branding vars UNSET so the effective strings start at the template defaults):
|
||||
- **Test 1 — the tab (admin):** sign in as admin → `/theme.html` → nav "Theme" link visible, `#theme-content` revealed, the gate hidden → the form shows the 11 inputs with the effective defaults (3 template strings + the 8 built-in hexes, read straight from `styles.css`'s `:root` in-test so the test can't drift) → set a distinct palette (e.g. `--brand` `#4f46e5`, `--bg` `#0b1020`, `--surface` `#111730`, `--ink` `#e6e9f5`, `--ink-soft` `#a8b0d0`, `--line` `#232a4a`, `--brand-soft` `#1e2447`, `--brand-ink` `#c7d2fe`) + app name "Theme E2E" + placeholder "Ask the themed brain…" + footer "E2E footer" → **Save theme** → button "Saving…" then restored, `#theme-result` "Theme saved." (role=status) → the inputs re-populate to the saved (effective) values.
|
||||
- **Test 2 — pre-paint, for everyone:** after the save, `page.goto("/")` (admin) → assert the RAW served HTML (`page.content()` before any JS mutation is observable) contains `<style id="bor-theme">` immediately before `</head>` with all 8 `--*` vars = the saved hexes, and `getComputedStyle(document.documentElement).getPropertyValue("--brand")` === the saved hex on load (the first-paint proof — the inline tag precedes every stylesheet application); a FRESH anonymous context (no auth) → `page.goto("/")` → the same inline tag + computed values (anonymous visitors see the theme) AND the app name "Theme E2E" applied post-fetch by `brand.js` (the B4 split: colors pre-paint, strings via the boot fetch) → `page.goto("/shared/<token>")`-shaped page is not required (the middleware branch is unit-tested in task 02) — skip.
|
||||
- **Test 3 — the gate + the 403:** anonymous context → `/theme.html` → the gate visible (`#theme-gate`, sign-in link `?next=/theme.html`), `#theme-content` hidden/inert, the "Theme" nav link hidden; API: anonymous `PUT /api/ui-settings` → 403; a token-user session (the phase-79 token-login helper from `tests/e2e/test_api_tokens.py`'s pattern) → `PUT` → 403 and the nav link hidden on their shell.
|
||||
- **Test 4 — reset restores the built-in:** admin → `/theme.html` → **Reset to defaults** → "Reset to the built-in theme." → the form re-populates to the 11 defaults → `page.goto("/")` → NO `<style id="bor-theme">` in the served HTML (byte-identical to the unset deployment) and computed `--brand` back to `#f43f5e`.
|
||||
- **Test 5 — contrast warning:** admin → set only `--ink` to a color within 0.1 ratio of `--bg` (a deterministic failing pair, e.g. `--ink` `#101010` on `--bg` `#0f0a0a`) → `#theme-contrast` lists that pair with the ratio (role=alert) → **Save theme** still succeeds (warning-only) → restore (Reset) so the suite's final state is clean.
|
||||
2. Fixtures/helpers — per-module local (the archive-upload suite's convention): the app-server env, the admin-login helper (reuse `tests/e2e/auth_helpers.py` if it exposes a page-level admin sign-in — check first; otherwise mirror the local pattern), the DB clean helper; the 8 built-in hexes parsed in-test from `frontend/assets/styles.css` (single source of truth — never duplicated in the test body).
|
||||
|
||||
- ASSUMPTION: (B5, owner-locked 2026-09-09) admin-only like Tuning/Tokens — the gate for anonymous, 403 for token users.
|
||||
- ASSUMPTION: the "no pop-in" assertion is made on the served HTML + the computed style at load (the inline tag is the mechanism the middleware unit tests pin; the E2E asserts the observable consequence).
|
||||
|
||||
## Testing & Quality
|
||||
- E2E in isolation (DB up): `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` — green (5 tests).
|
||||
- Coverage: this task adds no `app/` code — the phase coverage gate (>90%) is satisfied by tasks 01/02.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` green.
|
||||
- [ ] Proven end-to-end: save → inline `:root` in the raw HTML before `</head>` → themed computed colors at first paint for admin AND anonymous → gate + 403s for non-admins → contrast warning lists the failing pair without blocking → reset → no tag, built-in palette.
|
||||
- [ ] no behavior change in completed work.
|
||||
@@ -0,0 +1,20 @@
|
||||
All verification is complete and green. Final report:
|
||||
|
||||
**Phase 90 (upload_no_scan) — final verification pass: all criteria verified, no fixes needed**
|
||||
|
||||
- Verified (not re-implemented): all 3 tasks already complete; working tree carries the phase's uncommitted changes
|
||||
- Upload pipeline: `_run_upload` stops after row upsert; success = `{"message": "uploaded"}`, null/0/0 progress; `UploadOut` removed from `app/schemas.py`; gates/unpack/swap/failed states intact
|
||||
- Copy: button "Upload", bare "Processing…", result line "Uploaded \<name\> — press Sync sources to import it."; hint + caption re-pointed at Sync; no "Upload &" remnants in `frontend/`
|
||||
- Tests: `pytest tests/unit/test_git_sources.py tests/integration/test_git_sources_upload.py tests/unit/test_frontend_sync_upload.py` → 56 passed; E2E phase suite `tests/e2e/test_upload_no_scan.py` → 3 passed (zero docs after upload; ignore edit honored by Sync — 2 added, `notes/skipme.md` excluded; re-upload in-place, still zero docs); affected suites `test_archive_upload_sources.py` + `test_sync_upload_progress.py` + `test_source_removal_cleanup.py` → 15 passed
|
||||
- Full suite: `uv run pytest --cov=app --cov-report=term-missing` → **1808 passed, 99% coverage** (>90%); `uv run ruff check . && uv run pyright` → clean, 0 errors
|
||||
|
||||
**Completion criteria:**
|
||||
1. Upload settles "ready for sync", zero docs, row + ignore editor — ✅ (E2E test 1)
|
||||
2. Button "Upload", no "Upload &" copy — ✅ (`rg` empty in frontend/, app/, tests/)
|
||||
3. RAG-page "Sync sources" imports upload honoring edited ignores — ✅ (isolated E2E test 2)
|
||||
4. Suite green, >90% coverage, lint clean — ✅ (99%, ruff+pyright clean)
|
||||
5. Atomic commit — ✅ left to harness per executor rules (no git add/commit run; tree staged-ready)
|
||||
|
||||
**Deviations:** `regenerate_overview` still appears once in `git_sources.py` — inside `delete_git_source` (phase-69 locked total-removal behavior, not the upload leg; upload leg is scan-free). `.agents/PLAN.md` does not exist in this repo; locked decisions A1–A4 were enforced from the phase files.
|
||||
|
||||
**Next pending phase:** `91_admin_theme_tab` (in `todo/`).
|
||||
@@ -0,0 +1,90 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 99%]
|
||||
........ [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 178 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 7 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 50 0 100%
|
||||
app/api/git_sources.py 229 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 29 0 100%
|
||||
app/api/sync.py 101 0 100%
|
||||
app/api/tokens.py 28 0 100%
|
||||
app/config.py 141 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 108 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 19 0 100%
|
||||
app/core/tokens.py 33 0 100%
|
||||
app/db.py 21 0 100%
|
||||
app/main.py 64 0 100%
|
||||
app/models.py 95 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 222 0 100%
|
||||
app/rag/archive_upload.py 128 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 190 3 98%
|
||||
app/rag/llm.py 217 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 227 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 3263 12 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.412).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,15 @@
|
||||
All gates green. Final state verified.
|
||||
|
||||
## Report
|
||||
|
||||
**Implemented (task 01 — upload defers scan):**
|
||||
- `_run_upload` (`app/api/git_sources.py`): dropped step 7 (`check_models`) and step 8 (`import_sources` + `regenerate_overview`); steps 4–6 (unpack/swap/row upsert) unchanged; new single INFO line `upload: finished name=… file=… bytes=… total_ms=… state=…` (both terminal states); success = `detail={"message": "uploaded"}`, `current_file=None`, `files_done=files_total=0`; docstrings rewritten; unused imports dropped (`import_sources`, `check_models`).
|
||||
- `app/schemas.py`: `UploadOut` removed; `UploadAccepted` docstring updated.
|
||||
- `tests/integration/test_git_sources_upload.py`: 30 tests reworked to the new contract (zero docs/chunks/overview per upload, no-count payload, null/0/0 mid-run, sync-seam rework, sanitizer arm moved to a swap failure, models-down test replaced by that arm).
|
||||
- Fixed completed-work defect: phase-69 E2E `test_source_removal_cleanup.py` now syncs after upload before asserting the doc.
|
||||
|
||||
**Gates:** `uv run pytest --cov=app --cov-report=term-missing` → 1807 passed, TOTAL 99% (`git_sources.py` 100%); `uv run ruff check .` + `uv run pyright` clean; upload suite 30/30, phase-69 E2E 6/6, phase-89 E2E 6/6 isolated.
|
||||
|
||||
**Deviations:** task named `tests/unit/test_git_sources.py` for the upload tests, but that's the resolver suite — the upload pipeline tests live in `tests/integration/test_git_sources_upload.py` (reworked there; resolver suite untouched). `rg` criterion: `regenerate_overview` remains only in the phase-69 delete route (still used; task rule is "drop what is now unused"). The two upload-centric E2E suites stay old-contract by design — task 02 updates them in place.
|
||||
|
||||
**Next pending:** `.agents/phases/todo/90_upload_no_scan/02_upload_ui_and_copy.md`.
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 99%]
|
||||
....... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 178 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 7 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 50 0 100%
|
||||
app/api/git_sources.py 229 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 29 0 100%
|
||||
app/api/sync.py 101 0 100%
|
||||
app/api/tokens.py 28 0 100%
|
||||
app/config.py 141 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 108 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 19 0 100%
|
||||
app/core/tokens.py 33 0 100%
|
||||
app/db.py 21 0 100%
|
||||
app/main.py 64 0 100%
|
||||
app/models.py 95 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 222 0 100%
|
||||
app/rag/archive_upload.py 128 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 190 3 98%
|
||||
app/rag/llm.py 217 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 227 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 3263 12 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
validation OK
|
||||
@@ -0,0 +1,12 @@
|
||||
All gates green. Final report:
|
||||
|
||||
**Task 02 (90_upload_no_scan) — complete**
|
||||
|
||||
- Verified + finished the Sources-view "Upload" copy rework (a prior crashed attempt had left most of it in the tree): `#archive-upload-btn` = "Upload", phase-49/64 form comment re-pointed to UNPACK + REGISTER ONLY, `#git-sources-hint` + table caption now say uploads unpack/register only and **Sync sources** scans; `git-sources.js` bare "Processing…" poll (no file/counts/title), `fmtUploadResult` → "Uploaded \<name\> — press Sync sources to import it." (nameless variant after reload), next-step announce, error fallback "The upload failed.", module docstring citing phase 90.
|
||||
- My additions: re-pointed stale "background upload scan" copy in `frontend/assets/sources.js` (7 comment sites) and the `#sync-result` RAG-view comment (comments only — RAG-page two-job behavior untouched, still pinned by unit tests); reworded the three remaining forbidden-literal test mentions (unit guard split so no `Upload & scan` literal survives anywhere).
|
||||
- `rg -n "Upload & scan|Upload and scan" frontend/ app/ tests/` → no matches; `rg "scan" frontend/` → only re-pointed/negated mentions.
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → **1808 passed, TOTAL 99%** (>90%).
|
||||
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors.
|
||||
- E2E in isolation: `test_archive_upload_sources.py` 5/5, `test_sync_upload_progress.py` 4/4 (upload leg now bare-processing + zero-indexed + new sync-after-upload live-label leg), `test_source_removal_cleanup.py` 6/6, `test_source_ignore_paths.py` 6/6, `test_git_sources_admin.py` 6/6, `test_local_directory_sources.py` 3/3, `test_sync_button.py` 3/3, `test_sync_model_down.py` 4/4.
|
||||
- Decisions: RAG page keeps adopting an in-flight upload run (bare "Importing…", never stale) — phase A3/A4 leave sync.py and RAG behavior unchanged; `tests/e2e/slow_llm.py` docstring still describes the old scan timing (shared fixture, out of scope — noted).
|
||||
- Next pending: `.agents/phases/todo/90_upload_no_scan/03_e2e_upload_then_sync.md`.
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 99%]
|
||||
........ [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 178 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 7 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 50 0 100%
|
||||
app/api/git_sources.py 229 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 29 0 100%
|
||||
app/api/sync.py 101 0 100%
|
||||
app/api/tokens.py 28 0 100%
|
||||
app/config.py 141 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 108 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 19 0 100%
|
||||
app/core/tokens.py 33 0 100%
|
||||
app/db.py 21 0 100%
|
||||
app/main.py 64 0 100%
|
||||
app/models.py 95 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 222 0 100%
|
||||
app/rag/archive_upload.py 128 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 190 3 98%
|
||||
app/rag/llm.py 217 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 227 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 3263 12 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
validation OK
|
||||
@@ -0,0 +1,11 @@
|
||||
**Task 03 complete — Phase 90 E2E: upload → (no scan) → edit ignores → Sync sources**
|
||||
|
||||
- Implemented `tests/e2e/test_upload_no_scan.py` (new, 3 tests, house-style module header; per-module app env: scratch `BOR_UPLOAD_DIR`, empty `BOR_GIT_SOURCES`, mock LLM, no `slow_llm` proxy; mirrored local helpers from `test_archive_upload_sources.py`, no cross-suite imports)
|
||||
- `test_upload_does_not_scan`: "Upload" button → 202 toast → "Uploaded … — press Sync sources to import it." + no-count status payload; row + `Ignore paths` control; all 3 files on host; zero docs (`/api/docs` + RAG empty state)
|
||||
- `test_ignore_list_then_sync_scans`: phase-89 editor → `notes` → "1 ignored" tag + stored `ignore_paths` → RAG page `#sync-btn` → "Synced HH:MM" / "2 added" / 2-of-2 files; catalog has alpha+beta, not `notes/skipme.md`
|
||||
- `test_reupload_replaces_without_scan`: v1→v2 same basename → one row, folder = only v2 files, KB empty throughout
|
||||
- `uv run pytest tests/e2e/test_upload_no_scan.py -v --no-cov` → 3 passed (16.21s, isolated, DB up)
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → 1808 passed, app/ coverage **99%** (gate >90% — task adds no app/ code)
|
||||
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
|
||||
- Notable: A4 assumption held — `app/api/sync.py` needed no change; settled-state polling (not live-label racing) used for the short sync leg, per task note. Only the new test file was added; no existing work touched.
|
||||
- Next pending task: none in `90_upload_no_scan` — the phase's final task is done (harness commits/moves files).
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 63%]
|
||||
........................................................................ [ 67%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 75%]
|
||||
........................................................................ [ 79%]
|
||||
........................................................................ [ 83%]
|
||||
........................................................................ [ 87%]
|
||||
........................................................................ [ 91%]
|
||||
........................................................................ [ 95%]
|
||||
........................................................................ [ 99%]
|
||||
........ [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 178 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 7 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 50 0 100%
|
||||
app/api/git_sources.py 229 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 29 0 100%
|
||||
app/api/sync.py 101 0 100%
|
||||
app/api/tokens.py 28 0 100%
|
||||
app/config.py 141 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 108 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 19 0 100%
|
||||
app/core/tokens.py 33 0 100%
|
||||
app/db.py 21 0 100%
|
||||
app/main.py 64 0 100%
|
||||
app/models.py 95 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 222 0 100%
|
||||
app/rag/archive_upload.py 128 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 190 3 98%
|
||||
app/rag/llm.py 217 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 227 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 3263 12 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.412).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,11 @@
|
||||
All verification is complete — this pass needed no code changes. Final report:
|
||||
|
||||
**Phase 91 — Admin Theme tab: final verification pass (all 6 tasks already in `complete/`)**
|
||||
|
||||
- Verified pre-paint theming end-to-end: `ui_settings` store + resolver, admin `GET/PUT /api/ui-settings`, `CachingMiddleware` inline-`<style id="bor-theme">` injection before `</head>` (incl. `/shared/<token>` prefix branch, unit-pinned), CSP sha256 exemption for the inline tag, Theme tab shell + `theme.js` editor, CSS-file theming fully retired.
|
||||
- No defects found; zero changes made — working tree left exactly as the task executors left it.
|
||||
- Tests: `uv run pytest --cov=app` → 1841 passed, 0 failed (TOTAL coverage **99%**; theming/ui_settings/caching all 100%); `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** in isolation.
|
||||
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings.
|
||||
- Criteria: (1) unset deployment byte-identical, no `#bor-theme` anywhere — ✓ (unit no-op test + E2E reset byte-compare); `rg "BOR_THEME|themes/"` → single hit is the permitted doc-history comment in `frontend/index.html`. (2) admin-only gate + 403s for anonymous and token users — ✓ (E2E test 3). (3) saved theme inline before `</head>` on every page incl. `/shared/<token>`, computed `--brand` on first paint for admin + anonymous — ✓ (E2E test 2 + unit). (4) reset → byte-identical; 5 contrast pairs warn <4.5:1, non-blocking — ✓ (E2E tests 4–5). (5) suite green, >90% coverage, lint clean — ✓. (6) commit deferred to harness per rules.
|
||||
- Notable: `.agents/PLAN.md` is absent from the repo — the phase overview's Design section was used as the binding spec; no deviation resulted.
|
||||
- Next pending phase: **none** — 91 is the last phase in `todo/`.
|
||||
@@ -0,0 +1,92 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 97%]
|
||||
......................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 178 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 50 0 100%
|
||||
app/api/git_sources.py 229 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 29 0 100%
|
||||
app/api/sync.py 101 0 100%
|
||||
app/api/tokens.py 28 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 132 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 33 0 100%
|
||||
app/db.py 21 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 109 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 222 0 100%
|
||||
app/rag/archive_upload.py 128 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 190 3 98%
|
||||
app/rag/llm.py 217 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 251 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 3410 12 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.413).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,20 @@
|
||||
Task complete. All gates green.
|
||||
|
||||
**Implemented (phase 91, task 01 — `ui_settings` store + resolver + admin API)**
|
||||
- `UiSettings` model (single id-1 row, 3× String(300) + 8× String(7), all NULL = default) + migration `0014_ui_settings.py` (applies cleanly, downgrade drops)
|
||||
- `app/core/theming.py`: `BUILTIN_COLORS` (re-homed themes-README table + 5 contrast pairs), `COLOR_FIELDS`, `effective_settings()` (DB-over-env strings / DB-over-built-in colors), `theme_style_tag()` ("" when all built-in — byte-identical contract)
|
||||
- `GET/PUT /api/ui-settings` (router-wide `require_admin`): trim/empty→NULL, 422s naming the field (>300, non-`#rrggbb`), lowercase on store, built-in→NULL normalization, id-1 upsert
|
||||
- `/api/config` now serves effective strings via short-lived session (sync house pattern); `theme` key untouched (task 03)
|
||||
- Tests: `tests/unit/test_theming.py` (styles.css drift guard, tag shape, resolver), `tests/unit/test_ui_settings.py`, `tests/integration/test_ui_settings_api.py` (403 anon+token user, DB-over-env config), `tests/integration/test_migration_0014.py`; `test_api.py` config pins + `test_models.py` contract updated in place
|
||||
|
||||
**Results**
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → 1834 passed, 99% (new files 100%)
|
||||
- `uv run pytest tests/unit/test_theming.py tests/unit/test_ui_settings.py -v --no-cov` → 16 passed
|
||||
- `uv run alembic upgrade head` → 0014 applied; `uv run ruff check . && uv run pyright` → clean (0 errors)
|
||||
|
||||
**Notable decisions**
|
||||
- `effective_settings(session, settings=None)`: optional settings param keeps the design call shape but lets routes pass the dependency-injected Settings so existing `dependency_overrides` pins keep working
|
||||
- PUT = full replacement (absent/null → NULL) — pinned by the all-null Reset contract
|
||||
- `/api/config` pins now take the `db` fixture (skip-not-fail when stack down, empty-row state)
|
||||
|
||||
**Next pending task:** `91_admin_theme_tab/02_inline_theme_injection.md`
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 98%]
|
||||
.................................. [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 178 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 50 0 100%
|
||||
app/api/git_sources.py 229 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 29 0 100%
|
||||
app/api/sync.py 101 0 100%
|
||||
app/api/tokens.py 28 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 141 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 108 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 19 0 100%
|
||||
app/core/theming.py 25 0 100%
|
||||
app/core/tokens.py 33 0 100%
|
||||
app/db.py 21 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 109 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 222 0 100%
|
||||
app/rag/archive_upload.py 128 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 190 3 98%
|
||||
app/rag/llm.py 217 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 251 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 3389 12 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.412).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
All green. Final report:
|
||||
|
||||
**Task 02 — pre-paint inline theme injection: complete**
|
||||
|
||||
- `app/core/theming.py::inject_theme(html, tag)`: pure helper — identity for empty tag / no `</head>` / tag already present; else inserts with leading newline immediately before the first `</head>`
|
||||
- `app/core/caching.py::CachingMiddleware`: in the HTML rewrite branch, one short-lived `SessionLocal` per response (closed in `finally`, no process cache) → `theme_style_tag(effective_settings(...))` colors; any exception → `tag=""` + `logger.exception` (DB blip never breaks the page); `new_body = inject_theme(rewrite_asset_refs(...), tag)`; module docstring paragraph added
|
||||
- Tests: 4 pure `inject_theme` tests (empty-tag identity, no-`</head>` identity, exact placement/first-occurrence, double-injection idempotence) + 3 middleware tests (unset = byte-identical to rewrite-only; themed = exactly one tag before `</head>` on `/`, `/document.html`, `/shared/<token>` with all 8 vars in `COLOR_FIELDS` order; DB failure ×2 parametrizations → 200, no tag, no-cache intact)
|
||||
|
||||
**Results**
|
||||
- `uv run pytest tests/unit/test_caching.py tests/unit/test_theming.py -v --no-cov` → 56 passed
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → 1842 passed; app/ 99% (caching.py 100%, theming.py 100%)
|
||||
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
|
||||
- Live check: unset → 0 `bor-theme` (byte-identical); admin PUT one color → 1 tag before `</head>` on all 7 pages; reset → byte-identical, row all-NULL
|
||||
|
||||
**Decisions**
|
||||
- A stale `/tmp/full.log` (prior session, 00:34) showed a sync ignore-map ordering failure; traced to a pre-phase-90-fix tree — the committed phase 90 work already fixed it deterministically. A transient `effective_sources` ordering tweak was reverted; sync code untouched.
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/91_admin_theme_tab/03_retire_css_file_theming.md`
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 42%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 85%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 97%]
|
||||
.......................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 178 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 50 0 100%
|
||||
app/api/git_sources.py 229 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 29 0 100%
|
||||
app/api/sync.py 101 0 100%
|
||||
app/api/tokens.py 28 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 141 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 120 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 19 0 100%
|
||||
app/core/theming.py 30 0 100%
|
||||
app/core/tokens.py 33 0 100%
|
||||
app/db.py 21 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 109 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 222 0 100%
|
||||
app/rag/archive_upload.py 128 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 190 3 98%
|
||||
app/rag/llm.py 217 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 251 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 3406 12 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.413).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
All green. Final state verified.
|
||||
|
||||
**Task 03 — Retire CSS-file theming: complete**
|
||||
|
||||
- Deleted `frontend/assets/themes/` (indigo.css + README — contract verified re-homed in `app/core/theming.py` docstring: 8-var table, 5 WCAG pairs, never-white-on-brand note) and `tests/unit/test_themes.py`
|
||||
- `app/config.py`: removed `Settings.theme` + `_theme_bare_css_filename` validator; `app/api/config.py`: `theme` key dropped (5-key contract); `brand.js`: step-7 block + docstring paras removed (BOR_CONFIG_PROMISE/name/placeholder/footer untouched); Containerfile stage-1 `cp` line removed (chain verified by real build — no `themes/` in image); `.env.example` entry + README row removed, README customization section re-pointed at the admin Theme tab
|
||||
- Updated in place: `test_config.py` (validator tests gone, `theme` field-absence pin added), `test_frontend_brand.py`, `test_save_as_doc_button.py`, `test_api.py`, `test_ui_settings_api.py` (added `test_api_config_carries_no_theme_key`), `test_ui_customization.py` (theme legs stripped; placeholder/footer/brand legs kept), `test_configurable_brand.py`, conftests, `test_big_read_progress.py`
|
||||
- Tests: `uv run pytest -v --no-cov` → 1830 passed; `--cov=app` → 99% (modified modules 100%); `ruff check .` + `pyright` clean; sweep `rg "BOR_THEME|theme-override|assets/themes|indigo.css"` over app/ frontend/ tests/ Containerfile .env.example → no matches; `tests/e2e/test_ui_customization.py -v --no-cov` → 4 passed in isolation (+ configurable_brand 5, big_read 4 passed)
|
||||
- Live checks: boot serves no `#bor-theme`, 5-key `/api/config`; leftover `BOR_THEME=../evil.css` boots fine and is ignored (B2)
|
||||
- Notable: negative-assertion test reworded to avoid the retired literals; one operator-facing `BOR_THEME`-ignored note kept in README (outside the gate's paths)
|
||||
|
||||
Next pending task: `91_admin_theme_tab/04_theme_tab_shell.md`
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 51%]
|
||||
........................................................................ [ 55%]
|
||||
........................................................................ [ 59%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 98%]
|
||||
.............................. [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 178 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 50 0 100%
|
||||
app/api/git_sources.py 229 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 29 0 100%
|
||||
app/api/sync.py 101 0 100%
|
||||
app/api/tokens.py 28 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 132 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 120 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 19 0 100%
|
||||
app/core/theming.py 30 0 100%
|
||||
app/core/tokens.py 33 0 100%
|
||||
app/db.py 21 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 109 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 222 0 100%
|
||||
app/rag/archive_upload.py 128 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 190 3 98%
|
||||
app/rag/llm.py 217 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 251 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 3397 12 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.413).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,19 @@
|
||||
All green. Task 04 is complete.
|
||||
|
||||
**Implemented (phase 91, task 04 — the 7th shell view):**
|
||||
- `frontend/index.html`: `#nav-theme` (ship-hidden, after `#nav-tokens`) + `#view-theme` section (hidden+inert+tabindex=-1) after `#view-tokens`: `#theme-gate` (exact `#sources-gate` pattern, `?next=/theme.html`) and ship-hidden `#theme-content` with page-head, static `#theme-form` (3 labeled maxlength=300 text inputs + 8 labeled `type="color"` inputs `#theme-bg`…`#theme-brand-ink` on built-in values), `#theme-save`/`#theme-reset` (both type=button), `#theme-error`/`#theme-result`/`#theme-contrast` (hidden, role=alert/status/alert)
|
||||
- `header.js`: two-line `#nav-theme` reveal for `role === "admin"` (phase-91 comment); one-bar copies added to document/login/shared.html (the phase-34 parity contract test_nav_consistency pins)
|
||||
- `router.js`: all five table entries (VIEW/VIEW_PATH/VIEW_MODULES lazy `./theme.js`/TITLES "Theme · Brain of Reese"/DESCRIPTIONS)
|
||||
- `app/main.py`: `"/theme.html"` in `_shell_routes`; `app/core/caching.py`: in `HTML_PAGES`
|
||||
- `styles.css`: skeleton styles (46rem form column, form card, color grid 4→2 cols, brand-pill Save + ghost Reset, feedback lines)
|
||||
- `theme.js`: stub with the `mount(root)` module contract (no-op — task 05 lands the editor)
|
||||
|
||||
**Test/lint/coverage (all green):**
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → 1836 passed, `app/` TOTAL **99%** (>90% gate); unit 1375 / integration 464 separately green
|
||||
- `uv run ruff check . && uv run pyright` → clean (0 errors, 0 warnings)
|
||||
- E2E in isolation: `test_tuning_nav_link` 4✅, `test_cache_busting` 4✅, `test_asset_cache_revalidation` 5✅, plus `test_nav_consistency` 7✅, `test_mobile_hamburger_nav` 8✅, `test_header_consistency` 3✅, `test_navbar_refresh` 7✅, `test_history_page_width` 5✅
|
||||
- Live smoke: `GET /theme.html` → 200, `cache-control: no-cache`, `?v=` refs, body byte-identical to `/`; esbuild router bundle builds with the new lazy import
|
||||
|
||||
**Decisions:** (1) `theme.js` stub created here so the Containerfile esbuild stage resolves the router's dynamic import (editor body is task 05); (2) `#nav-theme` copied into the three standalone page headers — required for the one-bar inventory parity (phase-79 did the same for Tokens); (3) `test_wide_column_css.py` 46rem form-column pin updated 2→3 (`.theme-shell`, the phase-59 `.doc-edit-shell` precedent). Test pins extended in place per house convention.
|
||||
|
||||
**Next pending task:** `05_theme_editor.md` (theme.js editor: populate, live preview, Save/Reset, WCAG contrast warnings).
|
||||
@@ -0,0 +1,92 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 47%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 94%]
|
||||
........................................................................ [ 98%]
|
||||
.................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 178 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 50 0 100%
|
||||
app/api/git_sources.py 229 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 29 0 100%
|
||||
app/api/sync.py 101 0 100%
|
||||
app/api/tokens.py 28 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 132 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 120 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 19 0 100%
|
||||
app/core/theming.py 30 0 100%
|
||||
app/core/tokens.py 33 0 100%
|
||||
app/db.py 21 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 109 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 222 0 100%
|
||||
app/rag/archive_upload.py 128 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 190 3 98%
|
||||
app/rag/llm.py 217 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 251 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 3397 12 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.413).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,16 @@
|
||||
Everything is green. Final report:
|
||||
|
||||
**Task 05 complete — Theme editor (`theme.js`) + defect fix (CSP was silently killing the pre-paint theme tag in real browsers)**
|
||||
|
||||
Implemented:
|
||||
- `frontend/assets/theme.js` (replaces task-04 stub): admin gate via cached `fetchIsAdmin()`, GET `/api/ui-settings` populate with effective values (failed fetch → error + retry, form kept), live `:root` preview on color `input` (CSSOM — CSP-safe; cleared on save/reset/re-show), Save/Reset §7.4 lifecycle (disable → "Saving…"/"Resetting…" → restore), 422 server detail in `#theme-error` with form kept, role=status results, WCAG 5-pair contrast warnings (`#theme-contrast`, warning-only), `bor:view-refresh` re-load.
|
||||
- `styles.css`: 3-col desktop / 1-col mobile palette grid, 44px touch swatches, `--err-*` family contrast warning box, `.theme-note` sub-copy. `index.html`: spec'd sub-copy under h1, branding next-load note, WCAG legend on the palette fieldset.
|
||||
- **Defect fixed (task 02 + phase-82 interaction):** the A1 CSP (`default-src 'self'`, no `style-src`) blocked the inline `<style id="bor-theme">` in every browser — unit tests only checked raw bytes. Fix keeps phase-82 posture maximum-strict: themed HTML pages now carry `A1 + style-src 'self' 'sha256-<exact tag content hash>'` (`theming.theme_csp_hash` set by `CachingMiddleware`; `SecurityHeadersMiddleware` preserves an inner layer's CSP). No `'unsafe-inline'` anywhere; unthemed pages keep A1 verbatim (byte- AND header-identical).
|
||||
|
||||
Verification:
|
||||
- `uv run pytest --no-cov` → **1841 passed** (5 new tests; modified modules theming/caching/security_headers at 100%, TOTAL **99%** >90%)
|
||||
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
|
||||
- Live Chromium pass: anon gate / admin form + effective populate, live repaint, contrast warn+hide, 422 detail kept, save → fresh-load computed `--brand` = saved value on first paint, anon sees themed HTML, reset → byte-identical (no tag)
|
||||
|
||||
No forbidden files touched; dev DB `ui_settings` row left clean; no commits made.
|
||||
Next pending task: `.agents/phases/todo/91_admin_theme_tab/06_e2e_theme_tab.md`.
|
||||
@@ -0,0 +1,92 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 97%]
|
||||
......................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 178 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 50 0 100%
|
||||
app/api/git_sources.py 229 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 29 0 100%
|
||||
app/api/sync.py 101 0 100%
|
||||
app/api/tokens.py 28 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 132 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 33 0 100%
|
||||
app/db.py 21 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 109 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 222 0 100%
|
||||
app/rag/archive_upload.py 128 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 190 3 98%
|
||||
app/rag/llm.py 217 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 251 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 3410 12 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.413).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
@@ -0,0 +1,22 @@
|
||||
Everything is green and the working tree is in the expected state (new E2E file untracked; `.agents/` moves are the harness's own from earlier tasks — my target task file is untouched in `todo/`).
|
||||
|
||||
## Final report — task `91_admin_theme_tab/06_e2e_theme_tab`
|
||||
|
||||
**Implemented**
|
||||
- `tests/e2e/test_admin_theme_tab.py` (NEW) — the phase's isolated Playwright suite, 5 tests:
|
||||
1. Admin tab: nav link + gate/form state, 11 inputs show effective defaults (8 built-in hexes parsed from `styles.css` `:root` in-test; template strings from `Settings` class-field defaults), §7.4 "Saving…" lifecycle (deterministic via a held-PUT route intercept, the archive-upload house pattern), "Theme saved." (role=status), re-populate, DB row pin, warning-only contrast on the saved non-AA palette
|
||||
2. Pre-paint proof: raw served HTML (httpx + admin & fresh-anonymous `page.content()`) carries exactly one `<style id="bor-theme">` immediately before `</head>` with all 8 saved hexes; computed `:root` vars = saved hexes at load; CSP `style-src 'self' 'sha256-…'` asserted; B4 strings (name/placeholder/footer) via the brand.js boot fetch
|
||||
3. Gate + 403s: anonymous sees `#theme-gate` (`?next=/theme.html`), form hidden, nav hidden, PUT/GET 403; token user (phase-79 gate login) PUT 403 + nav hidden
|
||||
4. Reset: "Resetting…" → "Reset to the built-in theme.", 11 defaults re-populated, no tag served, and reset-deployment bytes == row-less deployment bytes (byte-identical proof)
|
||||
5. Contrast warning: `--ink` `#101010` lists "--ink on --bg: 1.0:1 — needs 4.5:1" (role=alert) on picker input; Save still succeeds; Reset restores + hides
|
||||
- House fixtures: module-scoped `app_server` (branding vars pinned to code defaults — phase-61/62 leak-guard extended to `BOR_APP_NAME` — `BOR_GIT_SOURCES` forced empty), autouse clean (TRUNCATE `ui_settings`, `e2e-%` token delete, before+after)
|
||||
|
||||
**Results**
|
||||
- `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → 5 passed (run 3×, deterministic)
|
||||
- `uv run pytest --cov=app --cov-report=term-missing` → 1841 passed, `app/` TOTAL 99% (phase-91 files all 100%)
|
||||
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings
|
||||
- Regression: `tests/e2e/test_ui_customization.py` → 4 passed; `rg "BOR_THEME|themes/" app/ frontend/ Containerfile` → only the excepted doc-history comment citation
|
||||
|
||||
**Decisions** — task-suggested indigo palette fails exactly one WCAG pair (bg on brand 3.0:1) — pinned in test 1 as the warning-only proof; no `app/` code added (task is E2E-only).
|
||||
|
||||
**Next pending task:** none — task 06 was the last task of phase 91.
|
||||
@@ -0,0 +1,92 @@
|
||||
........................................................................ [ 3%]
|
||||
........................................................................ [ 7%]
|
||||
........................................................................ [ 11%]
|
||||
........................................................................ [ 15%]
|
||||
........................................................................ [ 19%]
|
||||
........................................................................ [ 23%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 31%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 39%]
|
||||
........................................................................ [ 43%]
|
||||
........................................................................ [ 46%]
|
||||
........................................................................ [ 50%]
|
||||
........................................................................ [ 54%]
|
||||
........................................................................ [ 58%]
|
||||
........................................................................ [ 62%]
|
||||
........................................................................ [ 66%]
|
||||
........................................................................ [ 70%]
|
||||
........................................................................ [ 74%]
|
||||
........................................................................ [ 78%]
|
||||
........................................................................ [ 82%]
|
||||
........................................................................ [ 86%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 93%]
|
||||
........................................................................ [ 97%]
|
||||
......................................... [100%]
|
||||
=============================== warnings summary ===============================
|
||||
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||
from starlette.testclient import TestClient as TestClient # noqa
|
||||
|
||||
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||
================================ tests coverage ================================
|
||||
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||
|
||||
Name Stmts Miss Cover
|
||||
--------------------------------------------------
|
||||
app/__init__.py 1 0 100%
|
||||
app/api/__init__.py 0 0 100%
|
||||
app/api/auth.py 52 0 100%
|
||||
app/api/chat.py 178 0 100%
|
||||
app/api/chats.py 110 0 100%
|
||||
app/api/config.py 13 0 100%
|
||||
app/api/doc_drafts.py 94 0 100%
|
||||
app/api/docs.py 50 0 100%
|
||||
app/api/git_sources.py 229 0 100%
|
||||
app/api/health.py 10 0 100%
|
||||
app/api/steering.py 42 0 100%
|
||||
app/api/suggestions.py 29 0 100%
|
||||
app/api/sync.py 101 0 100%
|
||||
app/api/tokens.py 28 0 100%
|
||||
app/api/ui_settings.py 55 0 100%
|
||||
app/config.py 132 0 100%
|
||||
app/core/__init__.py 0 0 100%
|
||||
app/core/auth.py 45 0 100%
|
||||
app/core/caching.py 124 0 100%
|
||||
app/core/debugging.py 29 2 93%
|
||||
app/core/docs_push.py 39 0 100%
|
||||
app/core/errors.py 5 0 100%
|
||||
app/core/logging.py 13 0 100%
|
||||
app/core/rate_limit.py 44 0 100%
|
||||
app/core/security_headers.py 20 0 100%
|
||||
app/core/theming.py 38 0 100%
|
||||
app/core/tokens.py 33 0 100%
|
||||
app/db.py 21 0 100%
|
||||
app/main.py 66 0 100%
|
||||
app/models.py 109 0 100%
|
||||
app/rag/__init__.py 0 0 100%
|
||||
app/rag/agent.py 222 0 100%
|
||||
app/rag/archive_upload.py 128 0 100%
|
||||
app/rag/chunker.py 206 4 98%
|
||||
app/rag/git_sources.py 14 0 100%
|
||||
app/rag/importer.py 190 3 98%
|
||||
app/rag/llm.py 217 0 100%
|
||||
app/rag/overview.py 71 0 100%
|
||||
app/rag/prompts.py 88 0 100%
|
||||
app/rag/retriever.py 150 3 98%
|
||||
app/rag/scaffolding.py 55 0 100%
|
||||
app/rag/source_removal.py 41 0 100%
|
||||
app/rag/sources_meta.py 16 0 100%
|
||||
app/rag/suggestions.py 27 0 100%
|
||||
app/rag/summarizer.py 24 0 100%
|
||||
app/schemas.py 251 0 100%
|
||||
--------------------------------------------------
|
||||
TOTAL 3410 12 99%
|
||||
coverage gate: app/ 99% (>90%) OK
|
||||
All checks passed!
|
||||
0 errors, 0 warnings, 0 informations
|
||||
WARNING: there is a new pyright version available (v1.1.411 -> v1.1.413).
|
||||
Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest`
|
||||
|
||||
validation OK
|
||||
+3
-1
@@ -7,7 +7,9 @@ BOR_ENVIRONMENT=development
|
||||
# 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)
|
||||
# (Phase 91: the retired CSS-file theme env var is gone — the colors
|
||||
# are set from the admin Theme tab, /theme.html; a leftover value in
|
||||
# a local .env is ignored.)
|
||||
# BOR_LOG_LEVEL=INFO
|
||||
# BOR_STATIC_DIR=frontend # dev default; container sets /app/static
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ RUN mkdir -p /out/assets \
|
||||
&& esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \
|
||||
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
|
||||
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
|
||||
&& cp -r ./assets/themes /out/assets/themes \
|
||||
&& cp ./assets/favicon.svg /out/assets/favicon.svg \
|
||||
&& cp ./index.html ./document.html ./login.html ./shared.html ./doc-edit.html /out/
|
||||
|
||||
|
||||
@@ -403,7 +403,6 @@ asset references of the known pages in flight.
|
||||
| `BOR_APP_NAME` | `Brain of Reese` | Display name everywhere (page titles, header brand, status labels) |
|
||||
| `BOR_INPUT_PLACEHOLDER` | `Ask me anything…` | Chat composer placeholder |
|
||||
| `BOR_FOOTER_TEXT` | `Powered by self-hosted models` | Footer line on every page |
|
||||
| `BOR_THEME` | *(empty)* | Filename under `frontend/assets/themes/` (e.g. `indigo.css`) — a `:root` palette override |
|
||||
| `BOR_DATABASE_URL` | local compose URL | SQLAlchemy URL (psycopg) |
|
||||
| `BOR_LLM_BASE_URL` | `https://aipi.reeseapps.com/v1` | OpenAI-compatible endpoint |
|
||||
| `BOR_LLM_API_KEY` | — (falls back to `$AIPI_KEY`) | aipi API key |
|
||||
@@ -440,11 +439,14 @@ Every identity string is an env var: `BOR_APP_NAME` (display name),
|
||||
`BOR_FOOTER_TEXT` (footer line) — 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 server
|
||||
refuses a malformed `BOR_THEME` at startup; a missing file degrades to the
|
||||
built-in palette. Leave everything unset and the app renders the defaults
|
||||
byte-identically.
|
||||
The colors are set from the admin **Theme tab** (`/theme.html`,
|
||||
admin-only — phase 91): the 8 identity colors plus the three strings
|
||||
above are edited with text fields and color pickers, persisted in the
|
||||
`ui_settings` table, and injected into every served page as an inline
|
||||
`<style>` BEFORE first paint (no red flash, no pop-in). The old
|
||||
CSS-file theming is retired — a leftover `BOR_THEME` line in a
|
||||
deployment's `.env` is simply ignored. Leave everything unset and the
|
||||
app renders the built-in dark-tech palette byte-identically.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""ui_settings: single-row UI settings (phase 91)
|
||||
|
||||
Revision ID: 0014
|
||||
Revises: 0013
|
||||
Create Date: 2026-09-09
|
||||
|
||||
Phase 91 (admin Theme tab: the owner sets the app name, input
|
||||
placeholder, footer text and the 8 identity colors with text fields
|
||||
and color pickers — one additive, fully reversible table, A13):
|
||||
|
||||
* ``ui_settings`` — ONE row (PK ``id``, the row is always id 1): the
|
||||
phase-91 admin Theme tab persists its values here. The 3 strings
|
||||
(``app_name`` / ``input_placeholder`` / ``footer_text``, VARCHAR(300))
|
||||
and the 8 identity colors (``bg`` / ``surface`` / ``ink`` /
|
||||
``ink_soft`` / ``line`` / ``brand`` / ``brand_soft`` / ``brand_ink``,
|
||||
VARCHAR(7) ``#rrggbb``) are ALL nullable — NULL = default (the env
|
||||
value for the strings, the built-in palette for the colors, B1).
|
||||
The row is created only by the PUT upsert (no seed, no server
|
||||
default — a missing row means "defaults", the byte-identical
|
||||
contract).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0014"
|
||||
down_revision = "0013"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"ui_settings",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("app_name", sa.String(length=300), nullable=True),
|
||||
sa.Column("input_placeholder", sa.String(length=300), nullable=True),
|
||||
sa.Column("footer_text", sa.String(length=300), nullable=True),
|
||||
sa.Column("bg", sa.String(length=7), nullable=True),
|
||||
sa.Column("surface", sa.String(length=7), nullable=True),
|
||||
sa.Column("ink", sa.String(length=7), nullable=True),
|
||||
sa.Column("ink_soft", sa.String(length=7), nullable=True),
|
||||
sa.Column("line", sa.String(length=7), nullable=True),
|
||||
sa.Column("brand", sa.String(length=7), nullable=True),
|
||||
sa.Column("brand_soft", sa.String(length=7), nullable=True),
|
||||
sa.Column("brand_ink", sa.String(length=7), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Safe order: the table is the only 0014 artefact — dropping it
|
||||
# leaves 0013's schema byte-identical (A13, fully reversible).
|
||||
op.drop_table("ui_settings")
|
||||
+35
-12
@@ -1,12 +1,27 @@
|
||||
"""Public app metadata (display name + version) for the frontend brand
|
||||
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)."""
|
||||
phase-62 UI customization strings (composer placeholder, footer line).
|
||||
|
||||
Phase 91 (task 01): the three UI strings are now the EFFECTIVE values —
|
||||
the ``ui_settings`` row (admin Theme tab) over the env values (B1: DB
|
||||
wins when set, env is the fallback), resolved by the SAME
|
||||
:func:`app.core.theming.effective_settings` resolver the
|
||||
``/api/ui-settings`` API uses, so the brand layer and the tab can never
|
||||
disagree. The route opens a short-lived session (the sync-endpoint
|
||||
house pattern — the route is sync, matching the middleware world).
|
||||
|
||||
Phase 91 (task 03): the retired CSS-file theming's ``theme`` key is
|
||||
deleted with the mechanism — the five keys below are the entire
|
||||
contract (the colors never rode this endpoint; the server injects
|
||||
them pre-paint, :mod:`app.core.theming`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.core import theming
|
||||
from app.db import SessionLocal
|
||||
|
||||
router = APIRouter(tags=["config"])
|
||||
|
||||
@@ -15,17 +30,25 @@ router = APIRouter(tags=["config"])
|
||||
def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str | bool]: # noqa: B008
|
||||
"""Public app metadata for the frontend brand layer (phase 39) +
|
||||
the phase-59 ``docs_repo_configured`` flag + the phase-62 UI
|
||||
customization keys (``input_placeholder``, ``footer_text``,
|
||||
``theme``) — all display strings, the SAME boot fetch (no new
|
||||
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)."""
|
||||
customization keys (``input_placeholder``, ``footer_text``) — all
|
||||
display strings, the SAME boot fetch (no new network surface) and
|
||||
the same public posture as ``app_name`` (no secrets). Phase 91:
|
||||
``app_name`` / ``input_placeholder`` / ``footer_text`` are the
|
||||
EFFECTIVE values (the admin Theme tab's ``ui_settings`` row over
|
||||
the env values — DB-over-env, B1); the frontend brand layer treats
|
||||
an empty string as "keep the template default" (the unset =>
|
||||
byte-identical contract). Phase 91 (task 03): the retired
|
||||
CSS-file theming's ``theme`` key is gone — the five keys are the
|
||||
entire response."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
effective = theming.effective_settings(db, settings)
|
||||
finally:
|
||||
db.close()
|
||||
return {
|
||||
"app_name": settings.app_name,
|
||||
"app_name": effective["app_name"],
|
||||
"version": settings.app_version,
|
||||
"docs_repo_configured": settings.docs_configured,
|
||||
"input_placeholder": settings.input_placeholder,
|
||||
"footer_text": settings.footer_text,
|
||||
"theme": settings.theme,
|
||||
"input_placeholder": effective["input_placeholder"],
|
||||
"footer_text": effective["footer_text"],
|
||||
}
|
||||
|
||||
+115
-129
@@ -26,16 +26,19 @@ list: 404 unknown id, the required body list is normalized + A4-
|
||||
validated with fixed-detail 422s and REPLACES the row's list wholesale
|
||||
— an empty list clears all; 200 → the ``GitSourceOut`` shape),
|
||||
``POST /upload`` (phase 49, backgrounded in phase
|
||||
64 task 03 — admin archive upload: the ``.tar``/``.tar.gz``/``.tgz``/
|
||||
``.zip`` name/format gate + the 1 MiB-chunk receive with the
|
||||
``upload_max_mb`` cap run **inline** and answered 202 the moment the
|
||||
archive is safely on disk; unpack → swap → row upsert → model check →
|
||||
single-source scan → change-gated overview then run in a **background
|
||||
task** — see :func:`upload_archive` and :func:`_run_upload`),
|
||||
64 task 03, scan deferred in phase 90 — admin archive upload: the
|
||||
``.tar``/``.tar.gz``/``.tgz``/``.zip`` name/format gate + the 1 MiB-
|
||||
chunk receive with the ``upload_max_mb`` cap run **inline** and
|
||||
answered 202 the moment the archive is safely on disk; unpack → swap
|
||||
→ row upsert then run in a **background task** — and nothing else:
|
||||
no model check, no import, no overview refresh (phase 90, A1 — the
|
||||
scan is the RAG page's "Sync sources" button's job) — see
|
||||
:func:`upload_archive` and :func:`_run_upload`),
|
||||
``GET /upload/status`` (the phase-32 ``SyncStatus``-shaped in-memory
|
||||
state of that run — incl. the phase-64 ``current_file`` /
|
||||
``files_done`` / ``files_total`` progress fields; navigating away from
|
||||
the page mid-scan no longer aborts anything), ``DELETE /{source_id}``
|
||||
state of that run — the phase-64 ``current_file`` / ``files_done`` /
|
||||
``files_total`` keys stay in the set but null/0/0 for the whole run:
|
||||
uploads have no file-level progress, phase 90 A2; navigating away from
|
||||
the page mid-upload no longer aborts anything), ``DELETE /{source_id}``
|
||||
(204 — total removal, phase 69: row + the source's documents (chunks +
|
||||
embeddings) committed first, then the app-managed on-disk dir). The
|
||||
whole router sits behind :func:`app.core.auth.require_admin` —
|
||||
@@ -61,12 +64,16 @@ sibling row sharing the source name keeps the shared documents +
|
||||
files (only the row goes), and a pruned KB bumps ``sources_version``
|
||||
exactly once (the phase-53 saved-chat invalidation) with a
|
||||
best-effort overview refresh. The upload route is the other exception
|
||||
(phase 64, task 03): after the 202 receive
|
||||
answer, its background task unpacks the archive, swaps it in, upserts
|
||||
the row, probes the models, scans the single source
|
||||
(``import_sources`` with ``prune=True`` + the change-gated overview
|
||||
refresh), and lands the sync-style counts (the ``UploadOut`` fields)
|
||||
in the status ``detail``.
|
||||
(phase 64, task 03; phase 90): after the 202 receive
|
||||
answer, its background task unpacks the archive, swaps it in, and
|
||||
upserts the row — and **stops there**: no model probe, no import, no
|
||||
overview refresh. The scan is the RAG page's Sync button's job
|
||||
(phase 90, A1 — it gives the owner time to edit the new source's
|
||||
ignore list first; the sync already imports ``kind='local'`` rows
|
||||
with prune + each row's ignore list, A4). The terminal ``success``
|
||||
carries the no-count payload ``{"message": "uploaded"}`` in the status
|
||||
``detail`` (phase 90, A2 — the key set is unchanged; the UI composes
|
||||
the user copy).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -98,8 +105,8 @@ from app.rag.archive_upload import (
|
||||
swap_in,
|
||||
unpack_archive,
|
||||
)
|
||||
from app.rag.importer import import_sources, normalize_ignore_path
|
||||
from app.rag.llm import LLMClient, check_models
|
||||
from app.rag.importer import normalize_ignore_path
|
||||
from app.rag.llm import LLMClient
|
||||
from app.rag.overview import regenerate_overview
|
||||
from app.rag.source_removal import (
|
||||
has_sibling,
|
||||
@@ -151,16 +158,17 @@ class UploadStatus:
|
||||
Mirrors :class:`app.api.sync.SyncStatus` (the phase-32 pattern,
|
||||
phase 64 task 03): ``state`` is the same four-state machine
|
||||
(``idle`` / ``running`` / ``success`` / ``failed``); terminal states
|
||||
carry the run's ``detail`` (success — the ``UploadOut`` fields) or
|
||||
``error`` (failure — sanitized) so the UI can render the last result
|
||||
carry the run's ``detail`` (success — the no-count
|
||||
``{"message": "uploaded"}`` payload, phase 90 A2) or ``error``
|
||||
(failure — sanitized) so the UI can render the last result
|
||||
after a page reload (the re-attach behavior, task 05).
|
||||
|
||||
Phase 64 (task 03) progress fields: ``current_file`` is the
|
||||
``source/relative/path`` the scan is processing right now (null
|
||||
outside the import phase — unpack/swap/row/model-check first — and
|
||||
in terminal states); ``files_done`` / ``files_total`` carry the
|
||||
hook's done/total position and survive a terminal state (the run's
|
||||
last position is useful context next to the error).
|
||||
Phase 64 (task 03) progress keys: ``current_file`` /
|
||||
``files_done`` / ``files_total`` stay null/0/0 for the **whole**
|
||||
run (phase 90, A2 — the key set is unchanged, but uploads have no
|
||||
file-level progress: unpack has no per-file hook and the scan —
|
||||
the only thing that had one — moved to the sync, which keeps its
|
||||
live file label).
|
||||
"""
|
||||
|
||||
state: Literal["idle", "running", "success", "failed"] = "idle"
|
||||
@@ -390,13 +398,21 @@ def patch_git_source(
|
||||
async def upload_archive(
|
||||
file: UploadFile = File(...), # noqa: B008
|
||||
) -> UploadAccepted:
|
||||
"""Receive a source archive; scan it in the background (phase 49,
|
||||
backgrounded in phase 64 task 03 — owner-locked A1/A2).
|
||||
"""Receive a source archive; unpack it and register the source row
|
||||
in the background — and nothing else (phase 49, backgrounded in
|
||||
phase 64 task 03, scan deferred in phase 90 — owner-locked A1/A2).
|
||||
|
||||
The upload's job ends with the source row registered and the folder
|
||||
on disk: no model check, no import, no overview refresh (phase 90,
|
||||
A1 — the scan is the RAG page's "Sync sources" button's job, which
|
||||
gives the owner time to edit the new source's ignore list first;
|
||||
the sync already imports ``kind='local'`` rows with prune + the
|
||||
row's ignore list, A4).
|
||||
|
||||
The **inline (request) work is exactly three gates** — steps 1–3 —
|
||||
everything else runs in a background task behind
|
||||
``GET /upload/status`` (the phase-32 ``SyncStatus`` pattern), so
|
||||
navigating away mid-scan no longer aborts anything:
|
||||
navigating away mid-upload no longer aborts anything:
|
||||
|
||||
1. name/format gate — only ``.tar``/``.tar.gz``/``.tgz``/``.zip``
|
||||
(422 naming the accepted set) and a safe source name
|
||||
@@ -414,30 +430,26 @@ async def upload_archive(
|
||||
4. unpack to a temp sibling (traversal/symlink/device/corrupt/
|
||||
over-cap → ``failed`` with the task-01 user-safe message, temps
|
||||
deleted); a zero-entry archive is ``failed`` ``the archive
|
||||
contains no files`` — an archive with only non-A9 files is a
|
||||
VALID replacement (the scan indexes nothing, prune removes the
|
||||
source's docs);
|
||||
contains no files`` — an archive with only non-importable files
|
||||
is a VALID replacement (the folder lands and the row registers;
|
||||
what the KB indexes with it is the sync's call);
|
||||
5. atomic swap-in — a same-name re-upload replaces the previous
|
||||
folder in place; a failure leaves the previous folder/row/KB
|
||||
untouched;
|
||||
6. upsert the row by ``path`` (``kind='local'``; an existing row is
|
||||
left as-is — ``added_at`` preserved — and the unique index is
|
||||
the backstop: a concurrent insert lands ``failed`` with
|
||||
``a local source with this path already exists: <path>``); the
|
||||
row's saved ``ignore_paths`` are captured for the scan (phase
|
||||
89: a re-upload of an existing source honors the list the owner
|
||||
already saved);
|
||||
7. fail-fast ``check_models`` — ``ModelUnavailableError`` →
|
||||
``failed`` with the sanitized message (the phase-49 503 becomes
|
||||
a status state, A5); the folder/row are already committed, so
|
||||
the next sync/re-upload retries idempotently;
|
||||
8. ``import_sources([folder], llm, prune=True, progress=<hook>,
|
||||
ignore_by_root={folder: row's list})`` (phase 89) + the
|
||||
change-gated ``regenerate_overview`` — the hook feeds the status
|
||||
``current_file`` / ``files_done`` / ``files_total``;
|
||||
9. one INFO log line (PLAN §9 / AGENTS.md rule 10 — ``total_ms`` is
|
||||
the background run's duration);
|
||||
10. ``success`` — ``detail`` = the ``UploadOut`` fields.
|
||||
left as-is — ``added_at`` and ``ignore_paths`` preserved — and
|
||||
the unique index is the backstop: a concurrent insert lands
|
||||
``failed`` with ``a local source with this path already exists:
|
||||
<path>``); the scan the sync later performs reads the row's
|
||||
ignore list straight off it (phase 89);
|
||||
7. one INFO log line (PLAN §9 / AGENTS.md rule 10 —
|
||||
``upload: finished name=… file=… bytes=… total_ms=… state=…``;
|
||||
unpack+register only, no file counts — the state is ``success``
|
||||
or ``failed``, one line per run);
|
||||
8. ``success`` — ``detail = {"message": "uploaded"}`` (no count
|
||||
fields, phase 90 A2), ``current_file = None``,
|
||||
``files_done = files_total = 0`` (the key set is unchanged —
|
||||
the UI composes the user copy).
|
||||
"""
|
||||
# 1. Name/format gate — the accepted formats first (the 422 names
|
||||
# them), then the task-01 safe-name derivation. A BARE suffix
|
||||
@@ -488,7 +500,8 @@ async def upload_archive(
|
||||
)
|
||||
out.write(chunk)
|
||||
# The archive is safely on disk — 202 is the "successfully
|
||||
# uploaded" moment (A2). Steps 4–10 run in the background:
|
||||
# uploaded" moment (phase 64 A2). Steps 4–8 (unpack → swap →
|
||||
# row upsert — no scan, phase 90) run in the background:
|
||||
asyncio.create_task(
|
||||
_run_upload(name, filename, total, upload_root, temp_upload, temp_unpack)
|
||||
)
|
||||
@@ -509,11 +522,12 @@ def upload_status() -> dict[str, Any]:
|
||||
``GET /api/sync/status`` contract, identical key set).
|
||||
|
||||
``started_at`` / ``finished_at`` are ISO-8601 strings or null.
|
||||
``current_file`` (phase 64) is the ``source/relative/path`` the
|
||||
scan is processing right now — null during the unpack/swap/row/
|
||||
model phases and in terminal states; ``files_done`` / ``files_total``
|
||||
carry the hook's position (0/0 idle). The router dependency makes
|
||||
it admin-only like every other route here.
|
||||
``current_file`` / ``files_done`` / ``files_total`` stay null/0/0
|
||||
for the whole run (phase 90, A2 — the key set is unchanged, but
|
||||
uploads have no file-level progress: the scan the progress
|
||||
belonged to moved to the sync button, which keeps its live file
|
||||
label). The router dependency makes it admin-only like every other
|
||||
route here.
|
||||
"""
|
||||
return {
|
||||
"state": _upload_status.state,
|
||||
@@ -540,16 +554,19 @@ async def _run_upload(
|
||||
temp_unpack: Path,
|
||||
) -> None:
|
||||
"""The post-202 upload pipeline, one in-process background task
|
||||
(the phase-32 ``_run_sync`` shape — A1).
|
||||
(the phase-32 ``_run_sync`` shape — phase 64 A1).
|
||||
|
||||
Every failure mode (unpack, zero entries, swap, row, models,
|
||||
import, anything else) lands in the ``failed`` state with a
|
||||
Unpack → swap → row upsert — and nothing else (phase 90, A1: the
|
||||
model check, the import, and the overview refresh are the sync's
|
||||
job, not the upload's). Every failure mode (unpack, zero entries,
|
||||
swap, row, anything else) lands in the ``failed`` state with a
|
||||
sanitized ``error`` string — a background task must die in state,
|
||||
never as an unobserved exception (A5: post-202 failures are status
|
||||
states, never HTTP errors). ``CancelledError`` is deliberately *not*
|
||||
caught: app shutdown cancels the task, and swallowing that would
|
||||
mask a real stop. The ``finally`` cleans both temps (defensive —
|
||||
each step already cleans its own) and clears ``_upload_in_progress``.
|
||||
never as an unobserved exception (phase 64 A5: post-202 failures
|
||||
are status states, never HTTP errors). ``CancelledError`` is
|
||||
deliberately *not* caught: app shutdown cancels the task, and
|
||||
swallowing that would mask a real stop. The ``finally`` cleans both
|
||||
temps (defensive — each step already cleans its own) and clears
|
||||
``_upload_in_progress``.
|
||||
"""
|
||||
global _upload_in_progress
|
||||
started = time.monotonic()
|
||||
@@ -561,6 +578,21 @@ async def _run_upload(
|
||||
_upload_status.files_total = 0
|
||||
_upload_status.detail = {}
|
||||
_upload_status.error = None
|
||||
|
||||
def _log_finished(state: str) -> None:
|
||||
# Per-upload log line (PLAN §9 / AGENTS.md rule 10) — unpack+
|
||||
# register only, no file counts (the scan's counts belong to
|
||||
# the sync, phase 90). One line per run, in BOTH terminal
|
||||
# states; ``total_ms`` is the background run's duration.
|
||||
logger.info(
|
||||
"upload: finished name=%s file=%s bytes=%d total_ms=%d state=%s",
|
||||
name,
|
||||
filename,
|
||||
total_bytes,
|
||||
round((time.monotonic() - started) * 1000),
|
||||
state,
|
||||
)
|
||||
|
||||
try:
|
||||
settings = get_settings()
|
||||
max_bytes = settings.upload_max_mb * 1024 * 1024
|
||||
@@ -570,8 +602,9 @@ async def _run_upload(
|
||||
unpack_archive(temp_upload, temp_unpack, max_bytes)
|
||||
temp_upload.unlink(missing_ok=True)
|
||||
if not any(temp_unpack.iterdir()):
|
||||
# Zero entries = a user error. (Only non-A9 files is NOT an
|
||||
# error — it still has entries and is a valid replacement.)
|
||||
# Zero entries = a user error. (Only non-importable files
|
||||
# is NOT an error — it still has entries and is a valid
|
||||
# replacement.)
|
||||
raise ArchiveUploadError("the archive contains no files")
|
||||
# Step 5 — swap in — a same-name re-upload replaces the
|
||||
# previous folder atomically; a failure leaves it, the row,
|
||||
@@ -585,8 +618,10 @@ async def _run_upload(
|
||||
# background task has no request session to leak locks from
|
||||
# (the old inline ``db.close()`` discipline, now structural).
|
||||
# No duplicates: an existing row is left exactly as it is
|
||||
# (``added_at`` preserved); the unique index is the backstop
|
||||
# for a concurrent insert the pre-check missed.
|
||||
# (``added_at`` and ``ignore_paths`` preserved — the scan the
|
||||
# sync performs later reads the list straight off the row,
|
||||
# phase 89); the unique index is the backstop for a concurrent
|
||||
# insert the pre-check missed.
|
||||
path = str(final_dir)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -601,78 +636,29 @@ async def _run_upload(
|
||||
raise ValueError(
|
||||
f"a local source with this path already exists: {path}"
|
||||
) from None
|
||||
# Phase 89: the row's saved ignore list, copied to plain
|
||||
# values while the row is still usable in this session — a
|
||||
# re-upload of an existing source honors the list the owner
|
||||
# already saved; a fresh row has no list yet.
|
||||
ignore_paths = list(row.ignore_paths or [])
|
||||
finally:
|
||||
db.close()
|
||||
# Step 7 — fail-fast models (phase 41): ``ModelUnavailableError``
|
||||
# lands in the ``failed`` state sanitized (the phase-49 503
|
||||
# becomes a status state, A5). Nothing is rolled back — the
|
||||
# folder/row are committed and the next sync/re-upload retries
|
||||
# idempotently.
|
||||
llm = LLMClient()
|
||||
await check_models(llm)
|
||||
# Step 8 — scan — single source, prune (dropped files leave
|
||||
# the KB), with the phase-64 progress hook feeding the status,
|
||||
# then the change-gated overview refresh (phases 31/32). The
|
||||
# closure captures the module ``_upload_status`` exactly like
|
||||
# the state assignments above.
|
||||
def _hook(source: str, rel: str, done: int, total: int) -> None:
|
||||
_upload_status.current_file = f"{source}/{rel}"
|
||||
_upload_status.files_done = done
|
||||
_upload_status.files_total = total
|
||||
|
||||
summary = await import_sources(
|
||||
[final_dir], llm, prune=True, progress=_hook,
|
||||
ignore_by_root={str(final_dir): ignore_paths},
|
||||
)
|
||||
overview = False
|
||||
if summary.added + summary.updated > 0:
|
||||
overview = await regenerate_overview(llm)
|
||||
# Step 9 — per-upload log line (PLAN §9 / AGENTS.md rule 10)
|
||||
# — moved with the scan: ``total_ms`` is the background run's
|
||||
# duration.
|
||||
logger.info(
|
||||
"upload: name=%s file=%s bytes_in=%d files=%d added=%d updated=%d "
|
||||
"unchanged=%d pruned=%d errors=%d overview=%s total_ms=%d",
|
||||
name,
|
||||
filename,
|
||||
total_bytes,
|
||||
summary.files,
|
||||
summary.added,
|
||||
summary.updated,
|
||||
summary.unchanged,
|
||||
summary.pruned,
|
||||
summary.errors,
|
||||
overview,
|
||||
round((time.monotonic() - started) * 1000),
|
||||
)
|
||||
# Step 10 — success: the ``UploadOut`` fields ride in the
|
||||
# status ``detail`` (the UI renders the same result line from
|
||||
# the status that the sync button renders from its own).
|
||||
# Step 7 — the INFO line (``_log_finished`` — PLAN §9 /
|
||||
# AGENTS.md rule 10) lands together with the terminal state.
|
||||
# Step 8 — success: the no-count "uploaded" payload rides in
|
||||
# the status ``detail`` (phase 90 A2 — the key set is
|
||||
# unchanged; the scan's counts land in the SYNC's status when
|
||||
# the owner presses the button, and the UI composes the
|
||||
# user-facing result line from this payload).
|
||||
_upload_status.state = "success"
|
||||
_upload_status.finished_at = datetime.now(UTC)
|
||||
_upload_status.current_file = None # phase 64: keep the final counts
|
||||
_upload_status.detail = {
|
||||
"source": name,
|
||||
"files": summary.files,
|
||||
"added": summary.added,
|
||||
"updated": summary.updated,
|
||||
"unchanged": summary.unchanged,
|
||||
"pruned": summary.pruned,
|
||||
"errors": summary.errors,
|
||||
"chunks": summary.chunks,
|
||||
"overview": overview,
|
||||
}
|
||||
_upload_status.current_file = None
|
||||
_upload_status.files_done = 0
|
||||
_upload_status.files_total = 0
|
||||
_upload_status.detail = {"message": "uploaded"}
|
||||
_log_finished(_upload_status.state)
|
||||
except Exception as e: # noqa: BLE001 — a background task dies in state, see above
|
||||
logger.exception("upload: failed")
|
||||
_upload_status.state = "failed"
|
||||
_upload_status.finished_at = datetime.now(UTC)
|
||||
_upload_status.error = _sanitize_error(str(e))
|
||||
_upload_status.current_file = None # phase 64: keep the final counts
|
||||
_upload_status.current_file = None
|
||||
_log_finished(_upload_status.state)
|
||||
finally:
|
||||
_upload_in_progress = False
|
||||
# No temp may survive any failure path (defensive — each step
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""UI settings admin API (phase 91, task 01).
|
||||
|
||||
The persistence surface of the admin Theme tab (the tab itself lands in
|
||||
tasks 04/05): the single ``ui_settings`` row (id 1) that stores what the
|
||||
admin sets — the app name, input placeholder, footer text, and the 8
|
||||
identity colors. The whole router sits behind
|
||||
:func:`app.core.auth.require_admin` (router-wide ``dependencies`` — the
|
||||
:mod:`app.api.tokens` pattern): anonymous callers AND token users get
|
||||
403 on every route (only the admin themes the deployment).
|
||||
|
||||
Routes (under ``/api`` via the ``main`` registration):
|
||||
|
||||
* ``GET /api/ui-settings`` — the EFFECTIVE values (the resolver's
|
||||
DB-over-env / DB-over-built-in merge, B1): a missing row reports the
|
||||
env strings + the built-in palette, so a fresh tab shows the live
|
||||
theme. Creates nothing.
|
||||
* ``PUT /api/ui-settings`` — a FULL replacement of the row: each
|
||||
string is trimmed (empty → NULL, >300 → 422 naming the field), each
|
||||
color must match ``^#[0-9a-fA-F]{6}$`` (lowercased on store, else 422
|
||||
naming the field), and — the owner-locked normalization — a color
|
||||
equal to its built-in is stored as NULL, so "save the defaults"
|
||||
leaves the row empty and the served HTML stays byte-identical (the
|
||||
no-op injection contract, task 02). Upserts the id-1 row (SELECT →
|
||||
update-or-insert); a concurrent PUT is single-admin — last writer
|
||||
wins. Returns the new effective values.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.core import theming
|
||||
from app.core.auth import require_admin
|
||||
from app.db import get_db
|
||||
from app.models import UiSettings
|
||||
from app.schemas import UiSettingsIn, UiSettingsOut
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/ui-settings",
|
||||
tags=["ui-settings"],
|
||||
dependencies=[Depends(require_admin)], # phase 91: the Theme tab is admin-only
|
||||
)
|
||||
|
||||
#: The ``#rrggbb`` shape the color pickers produce (case-insensitive;
|
||||
#: lowercased on store so the stored/tagged hex is canonical).
|
||||
_HEX_COLOR = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||
#: The strings' column length (mirrors ``ui_settings`` VARCHAR(300)).
|
||||
_MAX_STRING_LEN = 300
|
||||
|
||||
|
||||
def _validate_strings(payload: UiSettingsIn) -> dict[str, str | None]:
|
||||
"""Trim the 3 display strings: empty after the trim → ``None``
|
||||
(the clear operation), >300 chars after the trim → 422 naming the
|
||||
field (the house fixed-detail style — the detail never varies by
|
||||
value beyond naming the field)."""
|
||||
values: dict[str, str | None] = {}
|
||||
for field in theming.STRING_FIELDS:
|
||||
raw = getattr(payload, field)
|
||||
if raw is None:
|
||||
values[field] = None
|
||||
continue
|
||||
value = raw.strip()
|
||||
if len(value) > _MAX_STRING_LEN:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"{field} is too long (max 300)"
|
||||
)
|
||||
values[field] = value or None
|
||||
return values
|
||||
|
||||
|
||||
def _validate_colors(payload: UiSettingsIn) -> dict[str, str | None]:
|
||||
"""Validate + normalize the 8 identity colors: strict ``#rrggbb``
|
||||
(else 422 naming the field), lowercased on store, and a value equal
|
||||
to its BUILT-IN is stored as ``None`` — the owner-locked
|
||||
normalization that keeps "save the defaults" byte-identical (the
|
||||
row stays empty, the no-op injection contract)."""
|
||||
values: dict[str, str | None] = {}
|
||||
for field in theming.COLOR_FIELDS:
|
||||
raw = getattr(payload, field)
|
||||
if raw is None:
|
||||
values[field] = None
|
||||
continue
|
||||
if _HEX_COLOR.fullmatch(raw) is None:
|
||||
raise HTTPException(
|
||||
status_code=422, detail=f"{field} must be a #rrggbb hex color"
|
||||
)
|
||||
value = raw.lower()
|
||||
values[field] = None if value == theming.BUILTIN_COLORS[field] else value
|
||||
return values
|
||||
|
||||
|
||||
@router.get("", response_model=UiSettingsOut)
|
||||
def get_ui_settings(
|
||||
settings: Settings = Depends(get_settings), # noqa: B008
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> UiSettingsOut:
|
||||
"""The effective UI settings — DB-over-env / DB-over-built-in (B1).
|
||||
|
||||
Reads only: a missing row means "defaults" (the env strings + the
|
||||
built-in palette), so a fresh deployment's tab shows the live theme
|
||||
with an empty row, and nothing is ever upserted by a read.
|
||||
"""
|
||||
return UiSettingsOut(**theming.effective_settings(db, settings))
|
||||
|
||||
|
||||
@router.put("", response_model=UiSettingsOut)
|
||||
def update_ui_settings(
|
||||
payload: UiSettingsIn,
|
||||
settings: Settings = Depends(get_settings), # noqa: B008
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> UiSettingsOut:
|
||||
"""Replace the single row with the body's 11 values (validated and
|
||||
normalized — see the module docstring), then report the new
|
||||
effective values.
|
||||
|
||||
Upsert on the id-1 row (SELECT → update-or-insert; the Python-side
|
||||
``default=1`` supplies the PK on the insert). Concurrency is
|
||||
single-admin (one owner, one tab) — the last writer wins and no
|
||||
lock is taken: a lost race just means the other admin's PUT is the
|
||||
effective one.
|
||||
"""
|
||||
strings = _validate_strings(payload)
|
||||
colors = _validate_colors(payload)
|
||||
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
|
||||
if row is None:
|
||||
row = UiSettings(id=1)
|
||||
db.add(row)
|
||||
for field in theming.STRING_FIELDS:
|
||||
setattr(row, field, strings[field])
|
||||
for field in theming.COLOR_FIELDS:
|
||||
setattr(row, field, colors[field])
|
||||
db.commit()
|
||||
return UiSettingsOut(**theming.effective_settings(db, settings))
|
||||
+3
-25
@@ -48,12 +48,11 @@ class Settings(BaseSettings):
|
||||
|
||||
# --- UI customization (phase 62, TODO L3) ---
|
||||
# Defaults are the phase-61 neutral copy — UNSET => byte-identical UI.
|
||||
# (Phase 91, task 03: the retired CSS-file theme env var is gone —
|
||||
# the admin Theme tab is the only theming surface; a leftover value
|
||||
# in a deployment's .env is simply ignored.)
|
||||
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_url: str = "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese"
|
||||
@@ -238,27 +237,6 @@ class Settings(BaseSettings):
|
||||
#: separate from ``sources_dir`` (the source checkouts).
|
||||
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")
|
||||
@classmethod
|
||||
def _import_extensions_known(cls, v: str) -> str:
|
||||
|
||||
+75
-2
@@ -28,6 +28,36 @@ outbound validators. The asymmetry is deliberate: a 304 on
|
||||
a 304 on an HTML page is never safe — the served body depends on the
|
||||
process token, which the validator ignores.
|
||||
|
||||
Phase 91 (task 02) — the pre-paint theme tag: in the SAME rewrite
|
||||
branch, AFTER the ``?v=<token>`` asset rewrite, the effective
|
||||
``ui_settings`` row (task 01's :func:`app.core.theming.effective_settings`
|
||||
resolver — one short-lived session per response, NO process cache: the
|
||||
owner changes the theme at runtime from the admin tab, so the next
|
||||
request must see it without a restart, and a single-row SELECT is
|
||||
negligible at homelab page traffic) is rendered as an inline
|
||||
``<style id="bor-theme">:root{…}</style>`` and inserted immediately
|
||||
BEFORE the first ``</head>`` (:func:`app.core.theming.inject_theme`),
|
||||
so a themed deployment paints its palette on the FIRST paint — no red
|
||||
flash, no pop-in. An unset/defaults deployment gets ``tag == ""`` —
|
||||
the identity no-op — and serves the EXACT pre-phase-91 rewrite-only
|
||||
bytes (the byte-identical contract, B4); a DB blip (or a pre-migration
|
||||
boot) is the same no-op, the page never breaks. The asset rewrite is
|
||||
untouched, and ``/api/*`` / ``/assets/*`` still pass through
|
||||
byte-identical.
|
||||
|
||||
Phase 91 (task 05, defect fix) — the CSP extension: the phase-82
|
||||
policy (A1, ``default-src 'self'`` with no ``style-src``) BLOCKS the
|
||||
inline tag in every real browser, so a themed HTML page's response
|
||||
also carries ``style-src 'self' 'sha256-<hash>'`` appended to the A1
|
||||
string, where ``<hash>`` is the CSP3 hash of the EXACT tag content
|
||||
(:func:`app.core.theming.theme_csp_hash`) — the current theme is the
|
||||
only inline style ever permitted (no ``'unsafe-inline'``; a different
|
||||
palette or any other inline style is still blocked). The untagged
|
||||
response keeps the plain A1 string (the outer
|
||||
:class:`~app.core.security_headers.SecurityHeadersMiddleware`
|
||||
preserves a CSP an inner layer has already set), and no non-HTML
|
||||
response ever gets the extension.
|
||||
|
||||
The token is computed **once per process** (``functools.cache``, i.e.
|
||||
``lru_cache(maxsize=None)``) — zero per-request git/file cost. It changes
|
||||
when a new commit lands (git path) or the frontend tree's mtimes/sizes
|
||||
@@ -61,6 +91,9 @@ from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core import theming
|
||||
from app.core.security_headers import CSP
|
||||
from app.db import SessionLocal
|
||||
|
||||
logger = logging.getLogger("app")
|
||||
|
||||
@@ -143,6 +176,7 @@ HTML_PAGES: tuple[str, ...] = (
|
||||
"/git-sources.html", # phase 35: the admin git sources page
|
||||
"/history.html", # phase 50: the admin saved-chats page
|
||||
"/tokens.html", # phase 79 task 06: the admin tokens page (shell route)
|
||||
"/theme.html", # phase 91 task 04: the admin theme page (shell route)
|
||||
# phase 51: the shared page's STATIC path (the static mount serves
|
||||
# shared.html at /shared.html as well as the real route serves the
|
||||
# dynamic /shared/<token> — both must carry the no-cache + ?v=
|
||||
@@ -317,8 +351,35 @@ class CachingMiddleware(BaseHTTPMiddleware):
|
||||
response.headers["Cache-Control"] = HTML_CACHE_CONTROL
|
||||
return response
|
||||
|
||||
# Phase 91 (task 02): the pre-paint theme tag. One short-lived
|
||||
# session per response (the sync-endpoint house pattern from
|
||||
# app/db.py — the middleware world is sync); NO process cache —
|
||||
# the theme changes at runtime from the admin tab, so the next
|
||||
# request must see it without a restart. A DB blip (or a
|
||||
# pre-migration boot) must never break the page: fall back to
|
||||
# ``tag == ""`` (the built-in palette) and keep the no-cache
|
||||
# contract (loadHealth house style).
|
||||
tag = ""
|
||||
try:
|
||||
new_body = rewrite_asset_refs(body.decode("utf-8"), token).encode("utf-8")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
effective = theming.effective_settings(db)
|
||||
finally:
|
||||
db.close()
|
||||
tag = theming.theme_style_tag(
|
||||
{key: effective[key] for key in theming.COLOR_FIELDS}
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"cache busting: theme read failed for %s — serving without the theme tag",
|
||||
path,
|
||||
)
|
||||
tag = ""
|
||||
|
||||
try:
|
||||
new_body = theming.inject_theme(
|
||||
rewrite_asset_refs(body.decode("utf-8"), token), tag
|
||||
).encode("utf-8")
|
||||
except Exception:
|
||||
# The body IS buffered — re-serve the ORIGINAL bytes so a
|
||||
# rewrite hiccup never loses the page.
|
||||
@@ -329,10 +390,22 @@ class CachingMiddleware(BaseHTTPMiddleware):
|
||||
headers=_no_cache_headers(response),
|
||||
)
|
||||
|
||||
headers = _no_cache_headers(response)
|
||||
if tag:
|
||||
# Phase 91 (task 05): the inline tag needs a style-src
|
||||
# exemption or the phase-82 CSP blocks it in the browser —
|
||||
# the strictest one: a sha256 hash of the EXACT tag content
|
||||
# (theming.theme_csp_hash), appended to the A1 string. The
|
||||
# outer SecurityHeadersMiddleware preserves this (it only
|
||||
# fills in a missing CSP); the untagged page keeps A1
|
||||
# verbatim — byte- AND header-identical to pre-phase-91.
|
||||
headers["Content-Security-Policy"] = (
|
||||
f"{CSP}; style-src 'self' '{theming.theme_csp_hash(tag)}'"
|
||||
)
|
||||
return Response(
|
||||
content=new_body,
|
||||
status_code=response.status_code,
|
||||
headers=_no_cache_headers(response),
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -55,7 +55,16 @@ class SecurityHeadersMiddleware:
|
||||
Adds exactly three headers to every HTTP response:
|
||||
|
||||
* ``Content-Security-Policy``: the strict same-origin policy above
|
||||
(``frame-ancestors 'none'`` → clickjacking closed, SEC-04);
|
||||
(``frame-ancestors 'none'`` → clickjacking closed, SEC-04) —
|
||||
EXCEPT when an inner layer has already set one: the phase-91
|
||||
(task 05) pre-paint theme tag is an inline ``<style>`` that the
|
||||
A1 policy would block in the browser, so the caching middleware
|
||||
publishes, on themed HTML pages only, the A1 string with
|
||||
``style-src 'self' 'sha256-<tag-content-hash>'`` appended (the
|
||||
current theme is the only inline style ever permitted — no
|
||||
``'unsafe-inline'``). A pre-existing CSP is that inner layer's
|
||||
deliberate one and is preserved; every other response (including
|
||||
every untagged page) gets the plain A1 string.
|
||||
* ``X-Frame-Options: DENY`` — legacy no-framing fallback;
|
||||
* ``X-Content-Type-Options: nosniff`` — MIME-confusion belt.
|
||||
|
||||
@@ -73,6 +82,11 @@ class SecurityHeadersMiddleware:
|
||||
async def send_wrapper(message: Message) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
headers = MutableHeaders(scope=message)
|
||||
# Phase 91 (task 05): preserve a CSP an inner layer set
|
||||
# (the caching middleware's theme-extended policy — see
|
||||
# the class docstring); the A1 string covers every
|
||||
# response without one.
|
||||
if "content-security-policy" not in headers:
|
||||
headers["Content-Security-Policy"] = CSP
|
||||
headers["X-Frame-Options"] = "DENY"
|
||||
headers["X-Content-Type-Options"] = "nosniff"
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""The built-in identity palette + the effective UI-settings resolver
|
||||
(phase 91).
|
||||
|
||||
Single source of the built-in **identity** palette. Phase 62's
|
||||
custom-CSS-file theming (an env var named a drop-in ``:root`` override
|
||||
stylesheet that ``brand.js`` linked AFTER the boot fetch — the "red
|
||||
first, then pop" the owner saw) is retired in this phase: task 03
|
||||
deleted the env var, the example-stylesheet directory, and the link
|
||||
insertion, and the admin Theme tab is now the only theming surface.
|
||||
The contract that directory's authoring guide carried is re-homed here
|
||||
(built-in table, the five contrast pairs, the never-white-on-brand
|
||||
trap — see below), and the 8 variables + built-in values are the
|
||||
authoritative table (the unit drift test parses
|
||||
``frontend/assets/styles.css``'s ``:root`` and asserts equality, so
|
||||
the two can never silently diverge).
|
||||
|
||||
The **8 identity variables** (bare names, README order) and their
|
||||
built-in values (from ``frontend/assets/styles.css`` ``:root``):
|
||||
|
||||
=================== ========== =================================================
|
||||
Variable Built-in Role
|
||||
=================== ========== =================================================
|
||||
``bg`` ``#0f0a0a`` page background (text on it: ``ink``)
|
||||
``surface`` ``#1a0f0f`` cards, panels, code blocks (text: ``ink``)
|
||||
``ink`` ``#f0e6e6`` primary text
|
||||
``ink_soft`` ``#b8a8a8`` secondary text (5.1:1 on ``surface``)
|
||||
``line`` ``#2d1a1a`` decorative 1px borders (no contrast duty)
|
||||
``brand`` ``#f43f5e`` brand accent — buttons, links (text ON
|
||||
it is the DARK ``bg`` ink)
|
||||
``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** (B3,
|
||||
owner-locked 2026-09-09): ``--accent-*`` (deflection amber), ``--ok-*``
|
||||
(success green), ``--err-*`` (error red) encode *states*, are already AA
|
||||
in the built-in theme, and are not configurable from the tab — a theme
|
||||
that keeps them stays honest.
|
||||
|
||||
**The five contrast pairs** that must meet WCAG 2.1 AA (>= 4.5:1,
|
||||
AGENTS.md rule 5) — the pairs the layout actually pairs: ``ink`` on
|
||||
``bg``, ``ink`` on ``surface``, ``ink_soft`` on ``surface``, ``bg`` on
|
||||
``brand`` (the text on brand buttons is the DARK background ink —
|
||||
that is the pattern; never white on brand: white on the built-in
|
||||
``#f43f5e`` is 3.7:1, it fails), and ``brand_ink`` on ``surface``. The
|
||||
tab's client-side warnings (task 05) compute exactly these five ratios
|
||||
against the values being saved; the built-in palette itself passes, so
|
||||
the default deployment stays AA without any warning.
|
||||
|
||||
Effective-value resolution (:func:`effective_settings`) — the DB-over-
|
||||
env / DB-over-built-in merge (B1, owner-locked 2026-09-09): the single
|
||||
``ui_settings`` row (id 1, task 01) wins column-by-column when set; a
|
||||
NULL/empty string column falls back to the ``BOR_`` env value, a NULL
|
||||
color column to the built-in. ONE resolver is used by BOTH
|
||||
``GET /api/ui-settings`` (the tab) and ``GET /api/config`` (the brand
|
||||
layer), so the tab and the running UI can never disagree.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.models import UiSettings
|
||||
|
||||
#: The 8 built-in identity colors, keyed by BARE variable name (no ``--``)
|
||||
#: in the themes-README order. Copied from ``frontend/assets/styles.css``
|
||||
#: ``:root`` — the unit drift test (``tests/unit/test_theming.py``)
|
||||
#: re-parses the stylesheet and asserts equality on every run.
|
||||
BUILTIN_COLORS: dict[str, str] = {
|
||||
"bg": "#0f0a0a",
|
||||
"surface": "#1a0f0f",
|
||||
"ink": "#f0e6e6",
|
||||
"ink_soft": "#b8a8a8",
|
||||
"line": "#2d1a1a",
|
||||
"brand": "#f43f5e",
|
||||
"brand_soft": "#2d0a0a",
|
||||
"brand_ink": "#fca5a5",
|
||||
}
|
||||
|
||||
#: The 8 color field names in the README's order (dicts preserve
|
||||
#: insertion order) — used by the resolver, the API, and the
|
||||
#: ``theme_style_tag`` renderer (task 02).
|
||||
COLOR_FIELDS: tuple[str, ...] = tuple(BUILTIN_COLORS)
|
||||
|
||||
#: The 3 display strings the ``ui_settings`` row carries — env fallback
|
||||
#: (B1: unlike the colors, the env vars stay the strings' default).
|
||||
STRING_FIELDS: tuple[str, ...] = ("app_name", "input_placeholder", "footer_text")
|
||||
|
||||
|
||||
def effective_settings(
|
||||
session: Session, settings: Settings | None = None
|
||||
) -> dict[str, str]:
|
||||
"""Resolve the EFFECTIVE UI settings — DB-over-env / DB-over-built-in.
|
||||
|
||||
Reads the single ``ui_settings`` row (id 1) and merges it over the
|
||||
defaults, column by column:
|
||||
|
||||
* **strings** (``app_name`` / ``input_placeholder`` / ``footer_text``)
|
||||
— the DB value when it is a non-empty string, else the env value
|
||||
(``settings.app_name`` etc. — B1: the env vars stay the fallback);
|
||||
* **colors** (the 8 :data:`COLOR_FIELDS`) — the DB value when not
|
||||
``None``, else :data:`BUILTIN_COLORS` (B1: no env fallback for
|
||||
colors — the built-in palette IS the default).
|
||||
|
||||
A missing row (``GET`` creates nothing) means "defaults" — the env
|
||||
strings + the built-in palette. The ``settings`` parameter names the
|
||||
env-fallback source explicitly (the routes pass their
|
||||
dependency-injected instance so test overrides apply); ``None`` uses
|
||||
the cached :func:`app.config.get_settings`. Returns all 11 keys.
|
||||
"""
|
||||
if settings is None:
|
||||
settings = get_settings()
|
||||
row = session.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
|
||||
effective: dict[str, str] = {}
|
||||
for field in STRING_FIELDS:
|
||||
value = getattr(row, field, None) if row is not None else None
|
||||
effective[field] = value if isinstance(value, str) and value else getattr(settings, field)
|
||||
for key in COLOR_FIELDS:
|
||||
value = getattr(row, key, None) if row is not None else None
|
||||
effective[key] = value if value is not None else BUILTIN_COLORS[key]
|
||||
return effective
|
||||
|
||||
|
||||
def theme_style_tag(colors: dict[str, str]) -> str:
|
||||
"""The pre-paint inline theme tag (task 02's injection input).
|
||||
|
||||
``""`` when every color equals its built-in — the byte-identical
|
||||
contract: an unset (or "defaults saved") deployment must serve
|
||||
exactly the pre-phase-91 HTML, no ``<style>`` tag anywhere.
|
||||
Otherwise one ``<style id="bor-theme">`` tag with ALL 8 variables in
|
||||
:data:`COLOR_FIELDS` order (the non-overridden ones repeat their
|
||||
built-in value — the tag is a complete ``:root`` override, so the
|
||||
page never mixes partial palettes)::
|
||||
|
||||
<style id="bor-theme">:root{--bg:#0f0a0a;…;--brand-ink:#fca5a5}</style>
|
||||
|
||||
Pure function of its input — :func:`inject_theme` places it before
|
||||
the first ``</head>`` of every served HTML page (the phase-91
|
||||
pre-paint injection), so the themed deployment renders its palette
|
||||
on the FIRST paint (no red flash, no pop-in).
|
||||
"""
|
||||
if all(colors[key] == BUILTIN_COLORS[key] for key in COLOR_FIELDS):
|
||||
return ""
|
||||
declarations = "".join(
|
||||
f"--{key.replace('_', '-')}:{colors[key]};" for key in COLOR_FIELDS
|
||||
)
|
||||
return f'<style id="bor-theme">:root{{{declarations}}}</style>'
|
||||
|
||||
|
||||
def inject_theme(html: str, tag: str) -> str:
|
||||
"""Insert ``tag`` immediately BEFORE the first ``</head>`` of
|
||||
``html`` — the pure half of the phase-91 pre-paint injection.
|
||||
|
||||
The :class:`~app.core.caching.CachingMiddleware` (task 02) calls
|
||||
this on every known HTML page's rewritten body, so the helper stays
|
||||
pure (no DB, no app) and unit-testable on its own. Identity rules —
|
||||
the byte-identical contract (B4, owner-locked 2026-09-09):
|
||||
|
||||
* ``tag == ""`` (an unset or "defaults saved" deployment —
|
||||
:func:`theme_style_tag` returns exactly that) → ``html`` is
|
||||
returned EXACTLY as passed in, byte for byte;
|
||||
* no ``</head>`` occurrence → unchanged (nothing to anchor to);
|
||||
* ``id="bor-theme"`` already present → unchanged (defensive
|
||||
idempotence — the static files never contain the id, and one
|
||||
body can never reach the helper twice, but the guarantee is free
|
||||
for a pure function).
|
||||
|
||||
Otherwise the tag is placed with a leading newline (readable HTML)
|
||||
immediately before the FIRST ``</head>`` — the browser meets the
|
||||
complete ``:root`` override before it applies any stylesheet, so
|
||||
the palette is live on the first paint.
|
||||
"""
|
||||
if not tag or "</head>" not in html or 'id="bor-theme"' in html:
|
||||
return html
|
||||
index = html.index("</head>")
|
||||
return html[:index] + "\n" + tag + html[index:]
|
||||
|
||||
|
||||
def theme_csp_hash(tag: str) -> str:
|
||||
"""The CSP3 ``sha256-`` source expression for an inline theme tag.
|
||||
|
||||
Phase 91 (task 05 defect fix): the phase-82 CSP (A1 —
|
||||
``default-src 'self'`` with no explicit ``style-src``) BLOCKS the
|
||||
inline ``<style id="bor-theme">`` tag in every real browser
|
||||
(``style-src`` falls back to ``default-src 'self'``), so the
|
||||
pre-paint injection would be dead bytes in the served HTML. The
|
||||
fix is the strictest one that works: the hashing source expression
|
||||
of the tag's EXACT content (CSP3 §13.4 — the character data between
|
||||
the tags; the rendered content carries no leading/trailing
|
||||
whitespace, so no stripping applies). The caching middleware
|
||||
publishes it on themed HTML pages only, as ``style-src 'self'
|
||||
'sha256-…'`` appended to the A1 string — the current theme is the
|
||||
only inline style ever permitted, and a different palette (or any
|
||||
other inline style) is still blocked. No blanket
|
||||
``'unsafe-inline'`` — the A1 posture holds everywhere else. Returns
|
||||
``""`` for an empty tag (an unset/defaults deployment keeps the
|
||||
plain A1 policy — the byte- AND header-identical contract).
|
||||
"""
|
||||
if not tag:
|
||||
return ""
|
||||
content = tag.split(">", 1)[1].rsplit("</style>", 1)[0]
|
||||
digest = hashlib.sha256(content.encode("utf-8")).digest()
|
||||
return "sha256-" + base64.b64encode(digest).decode("ascii")
|
||||
@@ -40,6 +40,7 @@ from app.api.steering import router as steering_router
|
||||
from app.api.suggestions import router as suggestions_router
|
||||
from app.api.sync import router as sync_router
|
||||
from app.api.tokens import router as tokens_router
|
||||
from app.api.ui_settings import router as ui_settings_router
|
||||
from app.config import get_settings
|
||||
from app.core.auth import ensure_admin_configured
|
||||
from app.core.caching import configure_caching
|
||||
@@ -122,6 +123,10 @@ def create_app() -> FastAPI:
|
||||
# Phase 79: the admin token surface (create/list/revoke) — admin-only
|
||||
# (router-wide require_admin; a token USER stays 403 here, task 03).
|
||||
app.include_router(tokens_router, prefix="/api")
|
||||
# Phase 91 (task 01): the admin UI-settings surface (GET/PUT the
|
||||
# single ui_settings row — the Theme tab's persistence) — admin-only
|
||||
# (router-wide require_admin; anonymous AND token users stay 403).
|
||||
app.include_router(ui_settings_router, prefix="/api")
|
||||
# Phase 51: the anonymous shared-chat read — NO admin dependency.
|
||||
# /api/shared/<token> is the JSON snapshot; /shared/<token> (the
|
||||
# page route below, registered without a prefix) is the page.
|
||||
@@ -156,6 +161,7 @@ def create_app() -> FastAPI:
|
||||
"/git-sources.html",
|
||||
"/history.html",
|
||||
"/tokens.html", # phase 79 task 06: the Tokens view
|
||||
"/theme.html", # phase 91 task 04: the Theme view (shell route)
|
||||
),
|
||||
)
|
||||
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
||||
|
||||
@@ -57,6 +57,12 @@ Data model — see ``.agents/PLAN.md`` §Data Model:
|
||||
on ``POST /api/token-auth`` (task 03 — the only
|
||||
request that presents the token; the in-app gate
|
||||
re-sends the cached token on every page load).
|
||||
* ``ui_settings`` — single-row UI settings (phase 91): the admin
|
||||
Theme tab's app name, input placeholder, footer
|
||||
text and the 8 identity colors, one row
|
||||
(``id = 1``); every column NULL = "use the
|
||||
default" (env value for the strings, the built-in
|
||||
palette for the colors — task 01).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -384,3 +390,40 @@ class ApiToken(Base):
|
||||
#: (enforced immediately on the holder's next request); NULL while
|
||||
#: active.
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class UiSettings(Base):
|
||||
"""Single-row UI settings (phase 91, task 01).
|
||||
|
||||
The admin Theme tab (``/theme.html``, tasks 04/05) persists everything
|
||||
the ``BOR_`` env vars and the retired custom-CSS theming supported in
|
||||
ONE row (``id = 1`` — the single row is always id 1; ``GET`` creates
|
||||
nothing, ``PUT`` upserts). The NULL = default rule (B1, owner-locked
|
||||
2026-09-09): every column is nullable, and a NULL (or empty) column
|
||||
means "use the default" — the env value for the three strings
|
||||
(``settings.app_name`` etc.), the built-in palette
|
||||
(:data:`app.core.theming.BUILTIN_COLORS`) for the eight identity
|
||||
colors (B1: no env fallback for colors). :func:`app.core.theming.
|
||||
effective_settings` resolves the effective 11 values both the
|
||||
``GET /api/ui-settings`` and ``GET /api/config`` endpoints serve.
|
||||
"""
|
||||
|
||||
__tablename__ = "ui_settings"
|
||||
|
||||
#: The single row is always id 1 (the ``kb_overview`` / ``sources_meta``
|
||||
#: id=1 precedent — Python-side default; the migration carries no
|
||||
#: server default because the row is created only by the PUT upsert).
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
||||
# --- Strings (NULL/empty = "use the env default" — B1) ---
|
||||
app_name: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
input_placeholder: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
footer_text: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
# --- The 8 identity colors (NULL = the built-in — B1), #rrggbb ---
|
||||
bg: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||
surface: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||
ink: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||
ink_soft: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||
line: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||
brand: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||
brand_soft: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||
brand_ink: Mapped[str | None] = mapped_column(String(7), nullable=True)
|
||||
|
||||
+60
-29
@@ -421,40 +421,18 @@ class GitSourceList(BaseModel):
|
||||
from_env: bool
|
||||
|
||||
|
||||
class UploadOut(BaseModel):
|
||||
"""The upload run's result fields (phase 49, task 02; phase 64, task 03).
|
||||
|
||||
Phase 64 (task 03): ``POST /api/git-sources/upload`` answers 202 the
|
||||
moment the archive is on disk; these fields become the shape of
|
||||
``GET /api/git-sources/upload/status`` ``detail`` on ``success`` —
|
||||
the uploaded source's name (filename minus the archive suffix) plus
|
||||
the SAME count keys as the admin sync's success ``detail``
|
||||
(``files``, ``added``, ``updated``, ``unchanged``, ``pruned``,
|
||||
``errors``, ``chunks`` — ``app.api.sync._run_sync``) and the
|
||||
``overview`` flag: the Sources page renders the same
|
||||
"N added · N pruned" result line for both.
|
||||
"""
|
||||
|
||||
source: str
|
||||
files: int
|
||||
added: int
|
||||
updated: int
|
||||
unchanged: int
|
||||
pruned: int
|
||||
errors: int
|
||||
chunks: int
|
||||
overview: bool
|
||||
|
||||
|
||||
class UploadAccepted(BaseModel):
|
||||
"""``POST /api/git-sources/upload`` 202 response (phase 64, task 03).
|
||||
|
||||
The archive is **safely on disk** — this is the "successfully
|
||||
uploaded" moment the Sources page toasts on (owner-locked A2). The
|
||||
scan itself (unpack → swap → row upsert → model check → import →
|
||||
overview) runs in a background task behind
|
||||
uploaded" moment the Sources page toasts on (phase 64 A2). The rest
|
||||
(unpack → swap → row upsert — and nothing else: no model check, no
|
||||
import, no overview refresh, phase 90 A1 — the scan is the RAG
|
||||
page's "Sync sources" button's job) runs in a background task behind
|
||||
``GET /api/git-sources/upload/status``, whose ``success`` ``detail``
|
||||
carries the :class:`UploadOut` fields.
|
||||
carries the no-count ``{"message": "uploaded"}`` payload (phase 90
|
||||
A2 — the status key set is unchanged; the UI composes the user
|
||||
copy).
|
||||
"""
|
||||
|
||||
detail: str = "upload received"
|
||||
@@ -834,3 +812,56 @@ class TokenAuthRequest(BaseModel):
|
||||
"""
|
||||
|
||||
token: str
|
||||
|
||||
|
||||
class UiSettingsIn(BaseModel):
|
||||
"""``PUT /api/ui-settings`` body (phase 91, task 01): a FULL
|
||||
replacement of the single ``ui_settings`` row.
|
||||
|
||||
Every field is ``str | None`` — present = a new value (strings are
|
||||
trimmed; empty after the trim is the CLEAR operation, stored as
|
||||
NULL; colors must be ``#rrggbb`` and are lowercased on store),
|
||||
``null``/absent = "back to the default" (stored as NULL — the Reset
|
||||
button's all-null PUT is exactly the "defaults" operation). The
|
||||
API layer runs the trim/length/hex validation so the 422 details
|
||||
name the offending field (the house fixed-detail style); the
|
||||
built-in→NULL normalization (a color equal to its built-in is
|
||||
stored as NULL — "save the defaults" must leave the row empty, the
|
||||
no-op injection contract) happens there too, next to the palette
|
||||
it normalizes against.
|
||||
"""
|
||||
|
||||
app_name: str | None = None
|
||||
input_placeholder: str | None = None
|
||||
footer_text: str | None = None
|
||||
bg: str | None = None
|
||||
surface: str | None = None
|
||||
ink: str | None = None
|
||||
ink_soft: str | None = None
|
||||
line: str | None = None
|
||||
brand: str | None = None
|
||||
brand_soft: str | None = None
|
||||
brand_ink: str | None = None
|
||||
|
||||
|
||||
class UiSettingsOut(BaseModel):
|
||||
"""Effective UI settings (``GET``/``PUT /api/ui-settings`` response,
|
||||
phase 91, task 01).
|
||||
|
||||
All 11 values, all non-null strings: the resolver's
|
||||
DB-over-env / DB-over-built-in merge (B1), so the tab always shows
|
||||
the LIVE theme — a fresh (row-missing) deployment reports the env
|
||||
strings and the built-in palette.
|
||||
"""
|
||||
|
||||
app_name: str
|
||||
input_placeholder: str
|
||||
footer_text: str
|
||||
bg: str
|
||||
surface: str
|
||||
ink: str
|
||||
ink_soft: str
|
||||
line: str
|
||||
brand: str
|
||||
brand_soft: str
|
||||
brand_ink: str
|
||||
|
||||
+20
-64
@@ -32,9 +32,9 @@
|
||||
* 4. an attribute pass — the aria-label / placeholder / meta
|
||||
* content attributes containing the literal (the #messages
|
||||
* 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
|
||||
* 5–6. Phase 62 (owner-locked 2026-09-01, TODO L3) — the SAME
|
||||
* settled config also carries the two UI-customization
|
||||
* string 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:
|
||||
@@ -43,30 +43,22 @@
|
||||
* 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.
|
||||
* operator string can't inject markup via textContent).
|
||||
* Phase 91 (task 03): color theming is no longer a brand.js
|
||||
* job — the retired CSS-file theme link (the old step 7) is
|
||||
* deleted with its env var; the SERVER now injects the
|
||||
* effective palette inline before first paint
|
||||
* (app/core/theming.py), so this layer keeps the text swaps
|
||||
* only.
|
||||
* • fetch failure / empty name → the default stays + console.warn
|
||||
* (the loadHealth house style: progressive enhancement, the page
|
||||
* never breaks).
|
||||
*
|
||||
* No-op property: with the customization env vars (BOR_APP_NAME,
|
||||
* 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.
|
||||
* BOR_INPUT_PLACEHOLDER, BOR_FOOTER_TEXT) 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) — so an unset deployment renders byte-identical.
|
||||
*/
|
||||
|
||||
/* The synchronous default — set BEFORE any fetch, so module scripts
|
||||
@@ -187,11 +179,12 @@ function applyBrand() {
|
||||
}
|
||||
|
||||
// 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).
|
||||
// config also carries the two customization STRING 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). Color theming is
|
||||
// server-side since phase 91 (task 03) — not this layer's job.
|
||||
const placeholder =
|
||||
typeof cfg?.input_placeholder === "string" ? cfg.input_placeholder : "";
|
||||
if (placeholder) {
|
||||
@@ -214,43 +207,6 @@ function applyBrand() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+122
-97
@@ -20,7 +20,7 @@
|
||||
* shell — scoped lookups keep the module honest and testable).
|
||||
* The router mounts a view ONCE (mount-once, hide-forever), so
|
||||
* the bindings and the upload-progress state machine survive
|
||||
* every switch: the scan poller is a self-chaining setTimeout
|
||||
* every switch: the upload poller is a self-chaining setTimeout
|
||||
* started when an upload begins (never at boot), so progress
|
||||
* continues while the user is on another view, and nothing
|
||||
* refetches on re-show. The single toast node/timer stay module
|
||||
@@ -58,37 +58,43 @@
|
||||
* instruction survives. 409/422 details are fixed generic strings
|
||||
* (credential safety — the URL is never echoed).
|
||||
* • upload — #archive-upload-form submit (phase 49, reworked to the
|
||||
* phase-64 202 contract in task 05 — the phase-49 synchronous
|
||||
* 200 paragraph is superseded): POST
|
||||
* phase-64 202 contract in task 05, unpack-only in phase 90 —
|
||||
* the phase-49 synchronous 200 paragraph is superseded): POST
|
||||
* /api/git-sources/upload with a FormData file (NO manual
|
||||
* Content-Type — the browser sets the multipart boundary). The
|
||||
* §7.4 never-stale lifecycle keeps its shape — the button
|
||||
* disables + relabels "Uploading…" while the request is out —
|
||||
* but the transfer is now short: the 202 arrives the moment the
|
||||
* archive is safely on disk (A1). 202 → the page-local
|
||||
* ("Upload") disables + relabels "Uploading…" while the request
|
||||
* is out — but the transfer is short: the 202 arrives the moment
|
||||
* the archive is safely on disk (A1). 202 → the page-local
|
||||
* "Successfully uploaded — <file>" toast fires (showUploadToast,
|
||||
* the phase-55 share-toast pattern; A2: safe to navigate away),
|
||||
* the file input clears, and the button hands over to the scan —
|
||||
* the processing state ("Processing…", disabled, title cleared)
|
||||
* plus startUploadPolling(): a 2 s poll of
|
||||
* GET /api/git-sources/upload/status renders the live
|
||||
* "Processing… <file> (n/m)" label (A4 — bare during unpack; the
|
||||
* full path rides the button title) and settles it: success →
|
||||
* the sync-style count line (fmtUploadResult, the role=status
|
||||
* result line) + the "Archive uploaded: …" announce +
|
||||
* loadSources (the new/updated row lands with the Local badge;
|
||||
* a re-upload refreshes the row — no duplicate; NO second toast
|
||||
* — A2); failure → the sanitized server error in the role=alert
|
||||
* banner + loadSources, the file selection KEPT for a one-click
|
||||
* the file input clears, and the button hands over to the
|
||||
* background run — the processing state (bare "Processing…",
|
||||
* disabled, title cleared) plus startUploadPolling(): a 2 s poll
|
||||
* of GET /api/git-sources/upload/status that renders the bare
|
||||
* "Processing…" label for the WHOLE run (phase 90, A2 — the run
|
||||
* is unpack + register only: no file, no "(n/m)" counts, no
|
||||
* title) and settles it: success → the ready-for-sync line
|
||||
* ("Uploaded <name> — press Sync sources to import it.",
|
||||
* fmtUploadResult off the status's {"message": "uploaded"}
|
||||
* detail — the role=status result line) + the "Archive uploaded
|
||||
* — press Sync sources to import it." announce + loadSources
|
||||
* (the new/updated row lands with the Local badge; a re-upload
|
||||
* refreshes the row — no duplicate; NO second toast — A2);
|
||||
* failure → the sanitized server error in the role=alert banner
|
||||
* + loadSources, the file selection KEPT for a one-click
|
||||
* re-upload. 409 (an upload is already in progress) raises NO
|
||||
* error banner — it re-attaches to the in-flight run (processing
|
||||
* state + poll, never stale). Other non-2xx (422 format/name,
|
||||
* 413 cap, 5xx) keep the phase-49 error banner + the kept file
|
||||
* selection. The submit finally restores the button ONLY when no
|
||||
* poll is active (§7.4). Boot re-attach (initUploadStatus, admin
|
||||
* branch): a running scan re-enters the processing state + poll
|
||||
* (a reload mid-scan re-attaches — no second upload), a terminal
|
||||
* run re-renders its result line / error banner.
|
||||
* branch): a running run re-enters the processing state + poll
|
||||
* (a reload mid-run re-attaches — no second upload), a terminal
|
||||
* run re-renders its result line / error banner (the safe name
|
||||
* was page-local — lastUploadName is null after a reload — so the
|
||||
* re-rendered line is the nameless "Uploaded — press Sync sources
|
||||
* to import it.").
|
||||
* • remove — a row's Remove button opens the page-local
|
||||
* confirmation modal (#remove-confirm-dialog, a real
|
||||
* role="alertdialog" — the native confirm() retired, phase 69):
|
||||
@@ -182,9 +188,12 @@
|
||||
* page's hint box matches. The Sync button still mirrors the
|
||||
* remaining sources (upstream file churn is pruned on that run).
|
||||
* The phase-49 upload is the other in-place exception: it unpacks
|
||||
* and scans the single source in place (the phase-64 background task
|
||||
* — 202 + status endpoint), and its counts render as the result
|
||||
* line.
|
||||
* and registers the source in place (the phase-64 background task —
|
||||
* 202 + status endpoint) and STOPS THERE — no model check, no
|
||||
* import, no overview refresh (phase 90, A1): the scan is the RAG
|
||||
* page's "Sync sources" button's job (it imports the uploaded
|
||||
* kind=local row with prune + the row's ignore list), and the result
|
||||
* line points at that button.
|
||||
*
|
||||
* The shared header module loads through this script's own relative
|
||||
* import ("./header.js") — a hoisted import evaluated before this body
|
||||
@@ -209,7 +218,8 @@ export async function mount(root) {
|
||||
const addError = root.querySelector("#git-source-error");
|
||||
/* Phase 49: the archive upload form (replaces the phase-38 local
|
||||
directory form — same card, a file input instead of a path input).
|
||||
The response counts render in the role=status result line. */
|
||||
The no-count result line (phase 90) renders in the role=status
|
||||
result line. */
|
||||
const uploadFormEl = root.querySelector("#archive-upload-form");
|
||||
const uploadFileInput = root.querySelector("#archive-upload-file");
|
||||
const uploadBtn = root.querySelector("#archive-upload-btn");
|
||||
@@ -821,53 +831,57 @@ export async function mount(root) {
|
||||
idleLabel: "Add source",
|
||||
});
|
||||
|
||||
/* ---------- upload (POST /api/git-sources/upload) — phase 64 (task 05) -------
|
||||
/* ---------- upload (POST /api/git-sources/upload) — phase 64 (task 05), unpack-only (phase 90) -------
|
||||
* The archive upload form follows the phase-64 202 contract (A1):
|
||||
* the file input's selection is posted as FormData (the browser sets
|
||||
* the multipart boundary — no manual Content-Type), and the 202
|
||||
* answers the moment the archive is safely on disk — the "Uploading…"
|
||||
* label covers only that short receive. Then the button HANDS OVER to
|
||||
* the scan: 202 → the page-local "Successfully uploaded — <file>"
|
||||
* toast (showUploadToast — A2, safe to navigate away), the file input
|
||||
* clears, and the processing state ("Processing…", disabled, title
|
||||
* cleared) + startUploadPolling() own it — a 2 s poll of
|
||||
* GET /api/git-sources/upload/status renders the live "Processing…
|
||||
* <file> (n/m)" label (A4 — bare during unpack; the full path rides
|
||||
* the button title) and settles it: success → the sync-style count
|
||||
* line (fmtUploadResult) in the role=status result line + the
|
||||
* "Archive uploaded: …" announce + loadSources (NO second toast — it
|
||||
* already fired at the 202, A2); failure → the sanitized server
|
||||
* error in the role=alert banner + loadSources, the file selection
|
||||
* KEPT for a one-click re-upload. 409 (an upload is already in
|
||||
* progress) raises NO error banner — it re-attaches to the in-flight
|
||||
* run (processing state + poll, never stale); the phase-49 "server
|
||||
* the background run (phase 90: UNPACK + REGISTER only — no scan):
|
||||
* 202 → the page-local "Successfully uploaded — <file>" toast
|
||||
* (showUploadToast — A2, safe to navigate away), the file input
|
||||
* clears, and the processing state (bare "Processing…", disabled,
|
||||
* title cleared) + startUploadPolling() own it — a 2 s poll of
|
||||
* GET /api/git-sources/upload/status that renders the bare
|
||||
* "Processing…" label for the WHOLE run (phase 90, A2 — the unpack
|
||||
* has no file-level progress: no file, no "(n/m)" counts, no title)
|
||||
* and settles it: success → the ready-for-sync line ("Uploaded
|
||||
* <name> — press Sync sources to import it.", fmtUploadResult off
|
||||
* the status's {"message": "uploaded"} detail) in the role=status
|
||||
* result line + the "Archive uploaded — press Sync sources to
|
||||
* import it." announce + loadSources (NO second toast — it already
|
||||
* fired at the 202, A2); failure → the sanitized server error in
|
||||
* the role=alert banner + loadSources, the file selection KEPT for
|
||||
* a one-click re-upload. 409 (an upload is already in progress)
|
||||
* raises NO error banner — it re-attaches to the in-flight run
|
||||
* (processing state + poll, never stale); the phase-49 "server
|
||||
* detail inline for 409" branch is superseded. Other non-2xx (422
|
||||
* format/name, 413 cap, 5xx) keep the phase-49 error banner + the
|
||||
* kept file selection; a network failure keeps the fixed line. The
|
||||
* submit finally restores the button ONLY when no poll is active
|
||||
* (PLAN §7.4 — while startUploadPolling owns the button it stays
|
||||
* disabled / "Processing…"). Boot re-attach (initUploadStatus, the
|
||||
* admin branch): a running scan re-enters the processing state + poll
|
||||
* admin branch): a running run re-enters the processing state + poll
|
||||
* (no second upload, no error); a terminal run re-renders its result
|
||||
* line (success) or error banner (failed); idle does nothing.
|
||||
* (The phase-49 synchronous 200 paragraph is superseded by phase 64.) */
|
||||
* (The phase-49 synchronous 200 paragraph is superseded by phase 64;
|
||||
* the phase-64 scan counts are superseded by phase 90.) */
|
||||
|
||||
/* The success line's text — the sync-result shape (sources.js's
|
||||
fmtSyncResult convention): "N added" always leads, then updated /
|
||||
unchanged / pruned — zero parts omitted (unchanged is shown
|
||||
when nothing was added or updated). Reads exactly the keys the
|
||||
upload status's detail carries (task 03's UploadOut-shaped dict). */
|
||||
function fmtUploadResult(detail) {
|
||||
const d = detail || {};
|
||||
const added = d.added || 0;
|
||||
const updated = d.updated || 0;
|
||||
const parts = [`${added} added`];
|
||||
if (updated > 0) parts.push(`${updated} updated`);
|
||||
if ((d.unchanged || 0) > 0 || (added === 0 && updated === 0)) {
|
||||
parts.push(`${d.unchanged || 0} unchanged`);
|
||||
/* The result line's text (phase 90, A2 — the no-count contract): the
|
||||
status success detail is exactly {"message": "uploaded"} — the
|
||||
sync-style counts the phase-64 line rendered are gone (the scan —
|
||||
and its counts — belong to the Sync button, which renders them on
|
||||
the RAG page). `name` is the accepted 202's safe source name
|
||||
(lastUploadName) when the run started on this page; it is null
|
||||
after a reload or on the 409 re-attach (the line still points at
|
||||
the next step, only without the name). */
|
||||
function fmtUploadResult(detail, name) {
|
||||
if (detail && detail.message === "uploaded") {
|
||||
return name
|
||||
? `Uploaded ${name} — press Sync sources to import it.`
|
||||
: "Uploaded — press Sync sources to import it.";
|
||||
}
|
||||
if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`);
|
||||
return parts.join(" · ");
|
||||
return "The upload finished.";
|
||||
}
|
||||
|
||||
/* Upload-success toast (phase 64 task 05, A2 — owner-locked): the
|
||||
@@ -903,14 +917,15 @@ export async function mount(root) {
|
||||
}, UPLOAD_TOAST_MS);
|
||||
}
|
||||
|
||||
/* The scan poll (phase 64 task 05): a 2 s cadence — the SYNC_POLL_MS
|
||||
* house value. Single timer, one loop at a time (the guard makes a
|
||||
* double-start a no-op, and the submit finally reads this same
|
||||
* variable to know whether the poll OWNS the button). Each tick
|
||||
* fetches GET /api/git-sources/upload/status: running → the live
|
||||
* "Processing… <file> (n/m)" label (A4 — bare "Processing…" during
|
||||
* the unpack phase, before any file is indexed; the full untruncated
|
||||
* path rides the button title) + reschedule; success → stop + the
|
||||
/* The background-run poll (phase 64 task 05, unpack-only in phase
|
||||
* 90): a 2 s cadence — the SYNC_POLL_MS house value. Single timer,
|
||||
* one loop at a time (the guard makes a double-start a no-op, and
|
||||
* the submit finally reads this same variable to know whether the
|
||||
* poll OWNS the button). Each tick fetches
|
||||
* GET /api/git-sources/upload/status: running → the bare
|
||||
* "Processing…" label for the whole run (phase 90, A2 — the unpack
|
||||
* has no file-level progress: no file, no "(n/m)" counts, the title
|
||||
* stays clear) + reschedule; success → stop + the ready-for-sync
|
||||
* result line + the announcement + the row reload (NO toast — it
|
||||
* fired at the 202, A2); failed → stop + the sanitized server error
|
||||
* banner + the row reload (a post-swap failure keeps the row — the
|
||||
@@ -920,6 +935,7 @@ export async function mount(root) {
|
||||
* retries next tick. */
|
||||
const UPLOAD_POLL_MS = 2000; // the SYNC_POLL_MS house value
|
||||
let uploadPollTimer = null; // null = no poll active (the finally's guard)
|
||||
let lastUploadName = null; // phase 90: the accepted 202's safe source name — the result line's <name> (null after a reload / on the 409 re-attach)
|
||||
|
||||
function stopUploadPolling() {
|
||||
if (uploadPollTimer !== null) {
|
||||
@@ -929,8 +945,9 @@ export async function mount(root) {
|
||||
}
|
||||
|
||||
/* The button's processing entry (the 202 + the 409 re-attach): from
|
||||
* here the poll OWNS it — disabled, "Processing…", title cleared (a
|
||||
* live file lands on it at the first tick). */
|
||||
* here the poll OWNS it — disabled, bare "Processing…", title
|
||||
* cleared (a live file never lands on it — phase 90: the run is
|
||||
* unpack + register only, so the label stays bare). */
|
||||
function enterUploadProcessingState() {
|
||||
uploadBtn.disabled = true;
|
||||
uploadBtn.textContent = "Processing…";
|
||||
@@ -941,7 +958,7 @@ export async function mount(root) {
|
||||
* finally, which calls this ONLY when no poll is active — PLAN §7.4). */
|
||||
function restoreUploadButton() {
|
||||
uploadBtn.disabled = false; // never stale — success OR failure
|
||||
uploadBtn.textContent = "Upload & scan";
|
||||
uploadBtn.textContent = "Upload";
|
||||
uploadBtn.removeAttribute("title");
|
||||
}
|
||||
|
||||
@@ -957,28 +974,27 @@ export async function mount(root) {
|
||||
uploadPollTimer = setTimeout(tick, UPLOAD_POLL_MS);
|
||||
return;
|
||||
}
|
||||
// running: the live file label (A4 — bare "Processing…" during
|
||||
// the unpack phase, before any file is indexed).
|
||||
// running: the bare label for the whole background run (phase
|
||||
// 90, A2 — the unpack has no file-level progress, so no file,
|
||||
// no counts, and the title stays clear).
|
||||
if (status.state === "running") {
|
||||
uploadBtn.textContent =
|
||||
"Processing…" +
|
||||
(status.current_file ? ` ${status.current_file}` : "") +
|
||||
(status.files_total > 0 ? ` (${status.files_done}/${status.files_total})` : "");
|
||||
uploadBtn.title = status.current_file || ""; // full path on hover
|
||||
uploadBtn.textContent = "Processing…";
|
||||
uploadBtn.title = "";
|
||||
uploadPollTimer = setTimeout(tick, UPLOAD_POLL_MS);
|
||||
return;
|
||||
}
|
||||
stopUploadPolling();
|
||||
if (status.state === "success") {
|
||||
// The scan finished: the result line (the existing helper reads
|
||||
// exactly these keys), the announcement, the row lands. NO toast
|
||||
// here — it already fired at the 202 (A2).
|
||||
// The run finished (unpack + register only — phase 90): the
|
||||
// ready-for-sync line (fmtUploadResult reads the no-count
|
||||
// detail), the announcement, the row lands. NO toast here — it
|
||||
// already fired at the 202 (A2).
|
||||
const detail = status.detail || {};
|
||||
if (uploadResult) {
|
||||
uploadResult.textContent = fmtUploadResult(detail);
|
||||
uploadResult.textContent = fmtUploadResult(detail, lastUploadName);
|
||||
uploadResult.hidden = false;
|
||||
}
|
||||
announce(`Archive uploaded: ${detail.source}.`);
|
||||
announce("Archive uploaded — press Sync sources to import it.");
|
||||
uploadFileInput.value = "";
|
||||
restoreUploadButton();
|
||||
loadSources(); // the row lands / refreshes
|
||||
@@ -990,7 +1006,7 @@ export async function mount(root) {
|
||||
// one-click re-upload, and the list reloads (a post-swap failure
|
||||
// keeps the row — the list state may have changed).
|
||||
if (uploadError) {
|
||||
uploadError.textContent = status.error || "The upload scan failed.";
|
||||
uploadError.textContent = status.error || "The upload failed.";
|
||||
uploadError.hidden = false;
|
||||
}
|
||||
restoreUploadButton();
|
||||
@@ -1005,12 +1021,15 @@ export async function mount(root) {
|
||||
}
|
||||
|
||||
/* Boot re-attach (phase 64 task 05, the admin branch only): fetch the
|
||||
* upload status ONCE — a running scan re-enters the processing state
|
||||
* + the poll (a reload mid-scan re-attaches instead of dead-ending —
|
||||
* upload status ONCE — a running run re-enters the processing state
|
||||
* + the poll (a reload mid-run re-attaches instead of dead-ending —
|
||||
* no second upload, no error); a finished run re-renders its result
|
||||
* line ONLY (no announce, no toast — the toast fired at the 202, A2);
|
||||
* a failed run re-renders its error banner; idle does nothing (and a
|
||||
* blip is a no-op — the page boots honest either way). */
|
||||
* line ONLY (no announce, no toast — the toast fired at the 202, A2;
|
||||
* the name is unknown after a reload — lastUploadName is null — so
|
||||
* the line is the nameless "Uploaded — press Sync sources to import
|
||||
* it.", phase 90); a failed run re-renders its error banner; idle
|
||||
* does nothing (and a blip is a no-op — the page boots honest
|
||||
* either way). */
|
||||
async function initUploadStatus() {
|
||||
if (!uploadBtn) return;
|
||||
let status;
|
||||
@@ -1029,15 +1048,17 @@ export async function mount(root) {
|
||||
}
|
||||
if (status.state === "success") {
|
||||
// The last run's result line only — no announce, no toast (A2).
|
||||
// The safe name was page-local (lastUploadName is null after a
|
||||
// reload) — the line still points at the next step (phase 90).
|
||||
if (uploadResult) {
|
||||
uploadResult.textContent = fmtUploadResult(status.detail);
|
||||
uploadResult.textContent = fmtUploadResult(status.detail, lastUploadName);
|
||||
uploadResult.hidden = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (status.state === "failed") {
|
||||
if (uploadError) {
|
||||
uploadError.textContent = status.error || "The upload scan failed.";
|
||||
uploadError.textContent = status.error || "The upload failed.";
|
||||
uploadError.hidden = false;
|
||||
}
|
||||
}
|
||||
@@ -1070,11 +1091,13 @@ export async function mount(root) {
|
||||
body: new FormData(uploadFormEl),
|
||||
});
|
||||
if (r.status === 202) {
|
||||
// The archive is safely on disk (A1) — the "successfully
|
||||
// uploaded" moment: the toast fires NOW (A2), the file input
|
||||
// clears, and the scan's poll takes over the button. The 202
|
||||
// body (UploadAccepted) carries the safe source name; a body
|
||||
// parse failure degrades to the picked file's name.
|
||||
// The archive is safely on disk (phase 64 A1) — the
|
||||
// "successfully uploaded" moment: the toast fires NOW (A2),
|
||||
// the file input clears, and the background run's poll takes
|
||||
// over the button. The 202 body (UploadAccepted) carries the
|
||||
// safe source name (the settled result line's <name>, phase
|
||||
// 90 — recorded page-locally); a body parse failure degrades
|
||||
// to the picked file's name.
|
||||
let name = file.name;
|
||||
try {
|
||||
const data = await r.json();
|
||||
@@ -1082,6 +1105,7 @@ export async function mount(root) {
|
||||
} catch {
|
||||
/* body parse failure — the picked file's name degrades fine */
|
||||
}
|
||||
lastUploadName = name;
|
||||
showUploadToast(`Successfully uploaded — ${name}`);
|
||||
uploadFileInput.value = ""; // 202: the archive is on the server
|
||||
enterUploadProcessingState();
|
||||
@@ -1111,7 +1135,7 @@ export async function mount(root) {
|
||||
}
|
||||
} finally {
|
||||
// Never stale (PLAN §7.4) — but ONLY when no poll owns the
|
||||
// button: while startUploadPolling tracks the scan (202 / 409)
|
||||
// button: while startUploadPolling tracks the run (202 / 409)
|
||||
// it stays disabled / "Processing…", so a finally restore here
|
||||
// would race the poll. No poll → the button is ours to restore.
|
||||
if (uploadPollTimer === null) restoreUploadButton();
|
||||
@@ -1159,8 +1183,9 @@ export async function mount(root) {
|
||||
fetch /api/git-sources (the Sources-page gate pattern). */
|
||||
root.addEventListener("bor:view-refresh", () => loadSources());
|
||||
await loadSources();
|
||||
// Phase 64 (task 05): re-attach a running scan (a reload mid-scan
|
||||
// resumes the Processing state) or re-render a terminal run's
|
||||
// result line / error banner.
|
||||
// Phase 64 (task 05): re-attach a running run (a reload mid-run
|
||||
// resumes the bare Processing state) or re-render a terminal
|
||||
// run's result line / error banner (phase 90: the unpack-only,
|
||||
// ready-for-sync line).
|
||||
await initUploadStatus();
|
||||
}
|
||||
|
||||
@@ -219,6 +219,13 @@ export async function initSharedHeader() {
|
||||
// pages) is a no-op. A token user (role "user") never sees it.
|
||||
const navTokens = document.querySelector("#nav-tokens");
|
||||
if (navTokens) navTokens.hidden = !admin;
|
||||
// Phase 91 (task 04): the Theme nav link (the shell's seventh view —
|
||||
// the phase-34 one-bar contract ships it on every page's nav) —
|
||||
// admin-only, the same ship-hidden / reveal-for-admin contract as
|
||||
// the Tokens link above. Null-safe: a page without the link is a
|
||||
// no-op. A token user (role "user") never sees it.
|
||||
const navTheme = document.querySelector("#nav-theme");
|
||||
if (navTheme) navTheme.hidden = !admin;
|
||||
// Phase 34: the steering panel (phase 15) is module-owned. The
|
||||
// navbar #steering-toggle was removed at owner request (2026-08-28)
|
||||
// — the panel ships hidden and is only kept fresh. Admin: refresh
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Brain of Reese — shell router (phase 76, task 01).
|
||||
*
|
||||
* The five navbar views are views of ONE HTML shell (index.html), not
|
||||
* five documents: this module makes a navbar click a CLIENT-SIDE view
|
||||
* The seven navbar views are views of ONE HTML shell (index.html), not
|
||||
* seven documents: this module makes a navbar click a CLIENT-SIDE view
|
||||
* switch — history.pushState + show/hide — never a document load, so
|
||||
* the in-flight chat stream in the hidden view keeps streaming
|
||||
* through any switch and completes when the user returns to Chat.
|
||||
@@ -62,8 +62,10 @@
|
||||
* (module — this file). No CDN, no framework, no bundler dependency:
|
||||
* a plain ES module whose dynamic imports (./tuning.js, task 01;
|
||||
* ./sources.js + ./git-sources.js, task 02; ./history.js in task 03;
|
||||
* ./tokens.js in phase 79 task 06) resolve relatively in dev and are
|
||||
* inlined by the Containerfile's esbuild stage in the image.
|
||||
* ./tokens.js in phase 79 task 06; ./theme.js in phase 91 task 04 —
|
||||
* the wiring lands there, the editor fills it in task 05) resolve
|
||||
* relatively in dev and are inlined by the Containerfile's esbuild
|
||||
* stage in the image.
|
||||
*/
|
||||
|
||||
/* ---------- the view map (pathname → view name) ----------
|
||||
@@ -79,6 +81,7 @@ const VIEW = {
|
||||
"/git-sources.html": "git-sources", // phase 76 task 02: the Sources view
|
||||
"/history.html": "history", // phase 76 task 03: the History view (saved chats)
|
||||
"/tokens.html": "tokens", // phase 79 task 06: the Tokens view (access tokens)
|
||||
"/theme.html": "theme", // phase 91 task 04: the Theme view (admin palette + branding)
|
||||
};
|
||||
|
||||
/* The nav-link href the router stamps active for each view (the
|
||||
@@ -90,6 +93,7 @@ const VIEW_PATH = {
|
||||
"git-sources": "/git-sources.html",
|
||||
history: "/history.html",
|
||||
tokens: "/tokens.html",
|
||||
theme: "/theme.html", // phase 91 task 04: the Theme view (admin-only)
|
||||
};
|
||||
|
||||
/* The lazy view modules — ONLY the non-chat views (chat needs no
|
||||
@@ -103,6 +107,7 @@ const VIEW_MODULES = {
|
||||
"git-sources": () => import("./git-sources.js"), // phase 76 task 02
|
||||
history: () => import("./history.js"), // phase 76 task 03
|
||||
tokens: () => import("./tokens.js"), // phase 79 task 06
|
||||
theme: () => import("./theme.js"), // phase 91 task 04 (wiring) + task 05 (editor)
|
||||
};
|
||||
|
||||
/* Per-view document.head values, carried over from the old pages'
|
||||
@@ -116,6 +121,7 @@ const TITLES = {
|
||||
"git-sources": "Git sources · Brain of Reese", // old git-sources.html <title>
|
||||
history: "Saved chats · Brain of Reese", // old history.html <title>
|
||||
tokens: "Access tokens · Brain of Reese",
|
||||
theme: "Theme · Brain of Reese", // phase 91 task 04: no old page — the view is new
|
||||
};
|
||||
const DESCRIPTIONS = {
|
||||
chat:
|
||||
@@ -128,6 +134,7 @@ const DESCRIPTIONS = {
|
||||
history:
|
||||
"Saved chats — every conversation is saved automatically, one click back.", // old history.html meta
|
||||
tokens: "Generate and revoke the API tokens that let people use the app.",
|
||||
theme: "Set the palette and branding — the theme is baked into every served page, live on the first paint.",
|
||||
};
|
||||
|
||||
/* The brand-resolved display name (phase 39 — brand.js is the single
|
||||
|
||||
+31
-21
@@ -79,15 +79,18 @@ export async function mount(root) {
|
||||
* tree, in order (startSyncPolling):
|
||||
* 1. sync running → "Syncing… <file> (n/m)" — bare "Syncing…" until
|
||||
* the import's first file (clone/pull, A4);
|
||||
* 2. upload running → "Importing <file> (n/m)" — the background
|
||||
* archive scan (the "clicked upload, then opened
|
||||
* 2. upload running → BARE "Importing…" — the background upload
|
||||
* RUN (phase 90: unpack + register only, no
|
||||
* scan — its status never carries a file or
|
||||
* counts; the "clicked upload, then opened
|
||||
* sources" contract, A3);
|
||||
* 3. sync success → the phase-32 settle (counts + catalog refresh);
|
||||
* 4. sync failed → the phase-32 failure (banner + modal);
|
||||
* 5. upload success → settle "Sync sources" + catalog refresh
|
||||
* (loadDocs — the new documents must appear); the
|
||||
* upload's counts live on the Sources page, never
|
||||
* in #sync-result (A3);
|
||||
* (loadDocs — phase 90: an upload no longer
|
||||
* changes the KB, the re-read is a no-op safety
|
||||
* net); the upload's result line lives on the
|
||||
* Sources page, never in #sync-result (A3);
|
||||
* 6. upload failed → settle "Sync sources" — the failure is the
|
||||
* Sources page's error banner, never this page's (A3);
|
||||
* 7. both idle → retry-ready idle.
|
||||
@@ -134,14 +137,16 @@ export async function mount(root) {
|
||||
}
|
||||
|
||||
/* Phase 64 (task 04): the live-file label. `kind` picks the prefix —
|
||||
* "sync" → "Syncing…", "upload" → "Importing" (the background scan's
|
||||
* "sync" → "Syncing…", "upload" → "Importing" (the background run's
|
||||
* word, A3). The current file — the status endpoint's full
|
||||
* source/relative/path (A4) — is appended while one is being processed;
|
||||
* the BARE prefix shows during the clone/pull (sync) or unpack (upload)
|
||||
* phase, before any file is indexed. The counts appear only once the
|
||||
* import has started (total > 0). CSS ellipsizes the button label; the
|
||||
* same untruncated text goes to the button title + #sync-result (the
|
||||
* aria-live announcer). */
|
||||
* the BARE prefix shows during the clone/pull (sync), before any file
|
||||
* is indexed. Phase 90: the upload run is unpack + register only (no
|
||||
* scan), so its status never carries a file or counts — the
|
||||
* "Importing" label is always the bare one. The counts appear only
|
||||
* once the import has started (total > 0). CSS ellipsizes the button
|
||||
* label; the same untruncated text goes to the button title +
|
||||
* #sync-result (the aria-live announcer). */
|
||||
function fmtSyncLabel(kind, currentFile, done, total) {
|
||||
const prefix = kind === "upload" ? "Importing" : "Syncing…";
|
||||
let label = currentFile ? `${prefix} ${currentFile}` : prefix;
|
||||
@@ -283,11 +288,12 @@ export async function mount(root) {
|
||||
}
|
||||
|
||||
/* The 2 s poll (phase 64 task 04): each tick fetches BOTH jobs — the
|
||||
* sync AND the background upload scan — and applies the two-job
|
||||
* decision tree in order (see the section header). The 403 on the SYNC
|
||||
* fetch hides the button (the whoami backstop); a 403 on the UPLOAD
|
||||
* fetch is simply "no upload" (never a hide), and a network blip on
|
||||
* either fetch retries next tick. */
|
||||
* sync AND the background upload run (phase 90: unpack + register,
|
||||
* no scan) — and applies the two-job decision tree in order (see the
|
||||
* section header). The 403 on the SYNC fetch hides the button (the
|
||||
* whoami backstop); a 403 on the UPLOAD fetch is simply "no upload"
|
||||
* (never a hide), and a network blip on either fetch retries next
|
||||
* tick. */
|
||||
function startSyncPolling() {
|
||||
if (syncPollTimer !== null) return;
|
||||
const tick = async () => {
|
||||
@@ -305,7 +311,8 @@ export async function mount(root) {
|
||||
applySyncIdle();
|
||||
return;
|
||||
}
|
||||
// The SECOND job: the background upload scan (admin-only surface).
|
||||
// The SECOND job: the background upload run (phase 90: unpack +
|
||||
// register only — no scan; admin-only surface).
|
||||
try {
|
||||
const ur = await fetch("/api/git-sources/upload/status");
|
||||
if (ur.ok) uploadStatus = await ur.json();
|
||||
@@ -406,9 +413,10 @@ export async function mount(root) {
|
||||
|
||||
/* Load-time re-attach (ADMIN ONLY): a running run re-enters running
|
||||
* state, a terminal run renders its last result. Phase 64 (A3): with
|
||||
* the sync IDLE, an in-flight background upload scan adopts the button
|
||||
* the same way — the "user clicked upload, then opened sources" case;
|
||||
* a terminal upload is a no-op (the boot-time loadDocs() already shows
|
||||
* the sync IDLE, an in-flight background upload RUN (phase 90: unpack
|
||||
* + register — the bare "Importing…" label) adopts the button the
|
||||
* same way — the "user clicked upload, then opened sources" case; a
|
||||
* terminal upload is a no-op (the boot-time loadDocs() already shows
|
||||
* the current catalog). */
|
||||
async function initSyncButton() {
|
||||
if (!syncBtn) return;
|
||||
@@ -436,7 +444,9 @@ export async function mount(root) {
|
||||
applySyncFailure(status);
|
||||
return;
|
||||
}
|
||||
// Sync idle: check the SECOND job — an in-flight upload scan re-attaches.
|
||||
// Sync idle: check the SECOND job — an in-flight upload run
|
||||
// re-attaches (the bare "Importing…" label — phase 90: unpack +
|
||||
// register only).
|
||||
let upload;
|
||||
try {
|
||||
const ur = await fetch("/api/git-sources/upload/status");
|
||||
|
||||
@@ -2951,6 +2951,185 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ---------- Theme view (phase 91, tasks 04 + 05) ----------
|
||||
The shell's seventh view (#view-theme): the admin palette + branding
|
||||
editor (task 05). The centered 46rem column (the .tuning-shell
|
||||
language — this is a form view, the tuning-view width pattern), the
|
||||
form card (the #tune-form language: surface fill, --line hairline,
|
||||
radius, shadow), the fieldset groups (Branding / Palette) with the
|
||||
.theme-note sub-copy, the 3-column (desktop) / 1-column (mobile)
|
||||
palette grid with the color swatch inputs sized for touch (44px —
|
||||
the label above is the second tap affordance), and the Save/Reset
|
||||
row (Save = the brand pill family, --bg ink on --brand 5.2:1 AA;
|
||||
Reset = the ghost button family, --line border; the §7.4
|
||||
disabled-while-in-flight look is the :disabled pair below). The
|
||||
feedback lines: error/result are the house role=status/alert line
|
||||
languages; #theme-contrast (the WCAG warning) uses the --err-*
|
||||
STATE family — states are semantic colors, never themed from the
|
||||
tab (B3). Every pair reuses the Phase-08 AA palette; :focus-visible
|
||||
via the global 3px outline rule. No CDN, system fonts. */
|
||||
.theme-shell {
|
||||
max-width: 46rem;
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
/* The static form — one card holding the two fieldset groups + the
|
||||
actions row (the #tune-form card language). */
|
||||
#theme-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1rem 1.1rem 1.1rem;
|
||||
}
|
||||
/* The fieldset groups: reset the UA border (the card IS the group's
|
||||
frame), the legend rides the group's title line (the
|
||||
.tuning-panel-title language — ink, 1rem, 700). */
|
||||
.theme-group {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.theme-group-title {
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
/* The branding text inputs: the #token-label language (surface is
|
||||
already the card fill — transparent field, --line hairline, ≥44px
|
||||
target, the global :focus-visible ring). */
|
||||
#theme-form fieldset label {
|
||||
font-size: 0.88rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
#theme-form fieldset input[type="text"] {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 0.93rem;
|
||||
}
|
||||
/* The palette note under the branding legend (task 05): the strings
|
||||
apply on the next page load (B4) — the live preview covers the
|
||||
palette only. */
|
||||
.theme-note {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.35;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
/* The color-input grid (task 05): the 8 swatches in 3 columns
|
||||
(3 + 3 + 2 rows) — the cell is a .theme-color (label above the
|
||||
swatch); 1 column at the mobile breakpoint (below). */
|
||||
.theme-colors {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.6rem 0.9rem;
|
||||
}
|
||||
.theme-color {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.theme-color label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.25;
|
||||
}
|
||||
.theme-color input[type="color"] {
|
||||
/* Touch-sized swatch (task 05): 44px tall (the WCAG 2.5.8 target —
|
||||
the label above is the second tap affordance). */
|
||||
inline-size: 56px;
|
||||
block-size: 44px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Save (primary) + Reset (secondary): the pill + ghost families
|
||||
(.history-refresh / .token-revoke languages — the global
|
||||
:focus-visible ring applies, no button-scoped focus override). */
|
||||
.theme-actions {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
#theme-save {
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 1.2rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--brand);
|
||||
color: var(--bg); /* dark ink on brand: 5.2:1 (never white on brand) */
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
#theme-save:hover:not(:disabled) { background: #f55a72; color: var(--bg); }
|
||||
#theme-save:disabled { opacity: 0.6; cursor: wait; }
|
||||
.theme-reset {
|
||||
min-height: 44px;
|
||||
padding: 0.4rem 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.theme-reset:hover:not(:disabled) { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); }
|
||||
.theme-reset:disabled { opacity: 0.6; cursor: wait; }
|
||||
/* The three feedback lines (task 05): error (role=alert — the
|
||||
server's 422 detail) and result (role=status) are the house line
|
||||
languages; min-height holds the layout so a line never reflows the
|
||||
form. #theme-contrast (the WCAG warning, task 05) takes the
|
||||
.tuning-error box treatment in the --err-* STATE family — states
|
||||
are semantic colors, never themed from the tab (B3): a failing
|
||||
pair reads as a warning and the box is warning-only (Save is never
|
||||
disabled by it). */
|
||||
.theme-error,
|
||||
.theme-result,
|
||||
.theme-contrast {
|
||||
display: block;
|
||||
margin: 0;
|
||||
min-height: 1.2em;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
padding-block: 0.25rem;
|
||||
}
|
||||
.theme-error { color: var(--err-ink); }
|
||||
.theme-result { color: var(--ok-ink); }
|
||||
.theme-contrast {
|
||||
background: var(--err-bg);
|
||||
color: var(--err-ink);
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.45rem 0.8rem;
|
||||
}
|
||||
|
||||
/* ---------- Shared page (phase 51, task 03) ----------
|
||||
/shared/<token>: the anonymous read-only conversation (owner-locked
|
||||
2026-08-29, TODO.md L6). The shell maps to the PLAN §7 centered
|
||||
@@ -3979,6 +4158,13 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
#token-once-copy { width: 100%; }
|
||||
.tokens-actions-cell { white-space: normal; }
|
||||
.tokens-actions { flex-wrap: wrap; }
|
||||
/* Phase 91 task 05: the Theme form squeezes — the color grid drops
|
||||
to 1 column (the label + swatch pairs keep their ≥44px targets)
|
||||
and the actions row stacks (Save full width, Reset under it). */
|
||||
.theme-colors { grid-template-columns: 1fr; }
|
||||
.theme-actions { flex-direction: column; align-items: stretch; }
|
||||
#theme-save { width: 100%; }
|
||||
.theme-reset { width: 100%; }
|
||||
/* Phase 51: the shared page squeezes like the chat column — the
|
||||
title and the note step down (the empty-state-title family); the
|
||||
shell keeps its base 46rem column (the >=1500px 92rem override
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
/* Brain of Reese — Theme view module (phase 91, task 05): the admin
|
||||
* palette + branding editor.
|
||||
*
|
||||
* The phase-76 shell-view-module contract (the tuning.js / tokens.js
|
||||
* shape): the router (assets/router.js) lazy-imports this module on
|
||||
* FIRST show of #view-theme and calls mount(root) ONCE (mount-once,
|
||||
* hide-forever — root is the view's <section id="view-theme">, every
|
||||
* DOM lookup scoped to it). The initSharedHeader() call never happens
|
||||
* here: in the shell the shared header boots exactly once, via the
|
||||
* chat module (app.js) at shell boot.
|
||||
*
|
||||
* What the editor does:
|
||||
*
|
||||
* • gate — fetchIsAdmin() (the SAME cached /api/whoami promise
|
||||
* header.js exports, zero extra requests): admin hides #theme-gate
|
||||
* and reveals #theme-content; anonymous / token-user keeps the
|
||||
* gate (the #nav-theme link is already hidden by header.js — the
|
||||
* gate is the DIRECT-URL case, the #tokens-gate pattern). No
|
||||
* /api/ui-settings request is ever made outside the admin branch.
|
||||
* • load — GET /api/ui-settings → populate the 11 inputs with the
|
||||
* EFFECTIVE values (the resolver's DB-over-env / DB-over-built-in
|
||||
* merge): the tab always shows the live theme — env defaults when
|
||||
* the row is empty. A failed fetch keeps the static form (the
|
||||
* built-in values ship in the inputs) and shows #theme-error with
|
||||
* a retry (the loadHealth house style — never a blanked panel).
|
||||
* • live preview (colors only, B4) — on `input` of any of the 8
|
||||
* color pickers the value is written straight onto <html> as an
|
||||
* inline custom property, so the WHOLE page repaints (every view,
|
||||
* the header) while the owner is picking. Text fields have NO page
|
||||
* effect: the 3 strings keep the brand.js runtime application
|
||||
* (owner-locked B4) — they apply via the /api/config boot fetch on
|
||||
* the NEXT page load, and the sub-copy says so. On every
|
||||
* successful save, on Reset, and on a re-show refresh all 8
|
||||
* overrides are removed (removeProperty) so the page reflects the
|
||||
* served (injected) theme, never stale preview state.
|
||||
* • Save — the §7.4 never-stale lifecycle: disable + "Saving…" →
|
||||
* PUT /api/ui-settings with the 11 form values (a cleared/empty
|
||||
* text field → null; colors always their current hex — the
|
||||
* server's built-in→NULL normalization keeps the row empty when
|
||||
* the owner saves the defaults) → 200: #theme-result "Theme
|
||||
* saved." (role=status), refetch + re-populate (canonical state),
|
||||
* clear the preview overrides, re-check the contrast pairs →
|
||||
* re-enable + restore the label (the finally — a click can never
|
||||
* leave a button stuck). 422: #theme-error carries the SERVER
|
||||
* detail (it names the offending field), the form is KEPT (the
|
||||
* owner fixes + retries); any other non-2xx: the fixed error line;
|
||||
* a network error: the "is the app reachable?" line.
|
||||
* • Reset — the same lifecycle ("Resetting…") with all 11 values
|
||||
* null (the API's documented "defaults" operation) → #theme-result
|
||||
* "Reset to the built-in theme." → refetch + re-populate (the
|
||||
* env/built-in defaults) + clear the preview overrides.
|
||||
* • WCAG contrast (the 00_phase design's five pairs — the pairs the
|
||||
* layout actually pairs, see app/core/theming.py's docstring):
|
||||
* ink on bg, ink on surface, ink-soft on surface, bg on brand
|
||||
* (the text on brand buttons is the dark background ink — never
|
||||
* white on brand), brand-ink on surface. Evaluated on every color
|
||||
* `input` and after every load/save over the CURRENT form values,
|
||||
* via WCAG relative luminance (sRGB → linear → L). Any pair under
|
||||
* 4.5:1 is listed in #theme-contrast (role=alert) as "--ink on
|
||||
* --bg: 3.2:1 — needs 4.5:1"; all pass → the warning hides.
|
||||
* WARNING-ONLY: it never disables Save (the owner's homelab
|
||||
* palette — the built-in stays AA, so the default deployment is
|
||||
* warning-free).
|
||||
* • re-show — the phase-77 hook: a user-initiated re-show of this
|
||||
* already-mounted view makes the router dispatch bor:view-refresh
|
||||
* on the section — re-run the load then (the tab always shows the
|
||||
* settled server state when re-shown) and clear the preview
|
||||
* overrides (the page paints the served theme, not a stale pick).
|
||||
* Armed only in the ADMIN branch, after the whoami gate passes:
|
||||
* anonymous shows the gate and never fetches.
|
||||
*
|
||||
* Every value is rendered with textContent / input.value — this file
|
||||
* never builds HTML (the XSS-safe-by-construction house rule).
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin } from "./header.js";
|
||||
|
||||
export async function mount(root) {
|
||||
/* ---------- view elements (the view's section, scoped to root) ---------- */
|
||||
const gateEl = root.querySelector("#theme-gate");
|
||||
const contentEl = root.querySelector("#theme-content");
|
||||
const saveBtn = root.querySelector("#theme-save");
|
||||
const resetBtn = root.querySelector("#theme-reset");
|
||||
const errorEl = root.querySelector("#theme-error");
|
||||
const resultEl = root.querySelector("#theme-result");
|
||||
const contrastEl = root.querySelector("#theme-contrast");
|
||||
|
||||
const SAVE_LABEL = "Save theme";
|
||||
const RESET_LABEL = "Reset to defaults";
|
||||
|
||||
/* The 11 form fields, in the form's order: `field` is the API key
|
||||
(the input's name attribute), `id` the E2E-stable element id,
|
||||
`kind` how the value is read for a PUT — a string field that is
|
||||
empty after the trim sends null (the server stores NULL = "use
|
||||
the default"); a color field always sends its current #rrggbb
|
||||
(the server's built-in→NULL normalization keeps the row empty
|
||||
when the owner saves the defaults). */
|
||||
const FIELDS = [
|
||||
{ field: "app_name", id: "theme-app-name", kind: "string" },
|
||||
{ field: "input_placeholder", id: "theme-placeholder", kind: "string" },
|
||||
{ field: "footer_text", id: "theme-footer", kind: "string" },
|
||||
{ field: "bg", id: "theme-bg", kind: "color" },
|
||||
{ field: "surface", id: "theme-surface", kind: "color" },
|
||||
{ field: "ink", id: "theme-ink", kind: "color" },
|
||||
{ field: "ink_soft", id: "theme-ink-soft", kind: "color" },
|
||||
{ field: "line", id: "theme-line", kind: "color" },
|
||||
{ field: "brand", id: "theme-brand", kind: "color" },
|
||||
{ field: "brand_soft", id: "theme-brand-soft", kind: "color" },
|
||||
{ field: "brand_ink", id: "theme-brand-ink", kind: "color" },
|
||||
];
|
||||
|
||||
const inputs = {};
|
||||
for (const f of FIELDS) inputs[f.field] = root.querySelector("#" + f.id);
|
||||
|
||||
const isHex = (v) => typeof v === "string" && /^#[0-9a-fA-F]{6}$/.test(v);
|
||||
const cssVar = (field) => "--" + field.replace(/_/g, "-");
|
||||
|
||||
/* ---------- WCAG contrast (the five pairs) ----------
|
||||
* Relative luminance per WCAG 2.1: each sRGB channel is linearized
|
||||
* (the 0.04045 threshold) then weighted (0.2126 / 0.7152 / 0.0722);
|
||||
* the ratio is (L_lighter + 0.05) / (L_darker + 0.05). The five
|
||||
* pairs (foreground, background) are exactly the ones the layout
|
||||
* pairs — app/core/theming.py's docstring is the authoritative
|
||||
* table. */
|
||||
function channelLuminance(channel) {
|
||||
const s = channel / 255;
|
||||
return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
function relLuminance(hex) {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return (
|
||||
0.2126 * channelLuminance(r) +
|
||||
0.7152 * channelLuminance(g) +
|
||||
0.0722 * channelLuminance(b)
|
||||
);
|
||||
}
|
||||
|
||||
function contrastRatio(fgHex, bgHex) {
|
||||
const a = relLuminance(fgHex);
|
||||
const b = relLuminance(bgHex);
|
||||
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
||||
}
|
||||
|
||||
const AA_MIN = 4.5; // WCAG 2.1 AA for normal-size text (AGENTS.md rule 5)
|
||||
const PAIRS = [
|
||||
["ink", "bg"],
|
||||
["ink", "surface"],
|
||||
["ink_soft", "surface"],
|
||||
["bg", "brand"],
|
||||
["brand_ink", "surface"],
|
||||
];
|
||||
|
||||
/* Re-evaluate the five pairs over the CURRENT form values. Any pair
|
||||
under 4.5:1 is listed in #theme-contrast (one line per failing
|
||||
pair, " · "-joined — textContent, never HTML); all pass → the
|
||||
warning hides. A pair whose input is not a valid hex (defensive —
|
||||
the color inputs always are) is skipped. WARNING-ONLY: this never
|
||||
touches Save (the owner can still save a failing palette). */
|
||||
function updateContrast() {
|
||||
if (!contrastEl) return;
|
||||
const failures = [];
|
||||
for (const [fg, bg] of PAIRS) {
|
||||
const fgHex = inputs[fg] ? inputs[fg].value : "";
|
||||
const bgHex = inputs[bg] ? inputs[bg].value : "";
|
||||
if (!isHex(fgHex) || !isHex(bgHex)) continue;
|
||||
const ratio = contrastRatio(fgHex, bgHex);
|
||||
if (ratio < AA_MIN) {
|
||||
failures.push(
|
||||
`${cssVar(fg)} on ${cssVar(bg)}: ${ratio.toFixed(1)}:1 — needs 4.5:1`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (failures.length) {
|
||||
contrastEl.textContent = failures.join(" · ");
|
||||
contrastEl.hidden = false;
|
||||
} else {
|
||||
contrastEl.textContent = "";
|
||||
contrastEl.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- feedback lines (never stale, §7.4) ---------- */
|
||||
function clearError() {
|
||||
if (!errorEl) return;
|
||||
errorEl.textContent = "";
|
||||
errorEl.hidden = true;
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
if (!errorEl) return;
|
||||
errorEl.textContent = message; // textContent — the server detail is data
|
||||
errorEl.hidden = false;
|
||||
}
|
||||
|
||||
function showResult(message) {
|
||||
if (!resultEl) return;
|
||||
resultEl.textContent = message; // role=status announces it
|
||||
resultEl.hidden = false;
|
||||
}
|
||||
|
||||
/* FastAPI error bodies: a string detail (the house 422s — the detail
|
||||
names the offending field) or the validation-error array (the
|
||||
first entry's msg). Same extraction as tuning.js. */
|
||||
async function apiDetail(r, fallback) {
|
||||
try {
|
||||
const data = await r.json();
|
||||
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
|
||||
return String(data.detail[0].msg);
|
||||
}
|
||||
if (typeof data.detail === "string" && data.detail) return data.detail;
|
||||
} catch {
|
||||
/* non-JSON error body */
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/* ---------- live preview (colors only — B4) ----------
|
||||
* The value lands on <html> as an inline custom property: the
|
||||
* inline style beats the served <style id="bor-theme"> :root, so
|
||||
* the whole page repaints live while the owner is picking. An empty
|
||||
* value removes the override (the page falls back to the served
|
||||
* theme). Text fields write NOTHING here — the strings apply via
|
||||
* brand.js on the next page load (the sub-copy says so). */
|
||||
function previewColor(field, value) {
|
||||
if (value) {
|
||||
document.documentElement.style.setProperty(cssVar(field), value);
|
||||
} else {
|
||||
document.documentElement.style.removeProperty(cssVar(field));
|
||||
}
|
||||
}
|
||||
|
||||
/* Drop all 8 preview overrides so the page paints the served
|
||||
(injected) theme — the "never stale" half of the contract: after
|
||||
a save / reset / re-show the page shows what the server serves,
|
||||
not a pick that was never (or no longer) saved. */
|
||||
function clearPreview() {
|
||||
for (const f of FIELDS) {
|
||||
if (f.kind === "color") {
|
||||
document.documentElement.style.removeProperty(cssVar(f.field));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- load / populate (effective values) ---------- */
|
||||
|
||||
function populate(settings) {
|
||||
for (const f of FIELDS) {
|
||||
const input = inputs[f.field];
|
||||
const value = settings[f.field];
|
||||
if (input && typeof value === "string" && value) input.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
/* GET /api/ui-settings → populate the 11 inputs with the EFFECTIVE
|
||||
values (the tab always shows the live theme — env defaults when
|
||||
the row is empty) and re-check the five pairs (a SAVED palette
|
||||
can itself fail AA — the warning then tracks it). A failed fetch
|
||||
keeps the static form + shows #theme-error with a retry (the
|
||||
loadHealth house style — never a blanked panel). Returns true
|
||||
when the values are settled. */
|
||||
async function loadSettings() {
|
||||
clearError();
|
||||
let r;
|
||||
try {
|
||||
r = await fetch("/api/ui-settings");
|
||||
} catch {
|
||||
showError("Couldn't load the theme — is the app reachable?");
|
||||
return false;
|
||||
}
|
||||
if (!r.ok) {
|
||||
showError("Couldn't load the theme — try again.");
|
||||
return false;
|
||||
}
|
||||
let settings;
|
||||
try {
|
||||
settings = await r.json();
|
||||
} catch {
|
||||
showError("Couldn't load the theme — try again.");
|
||||
return false;
|
||||
}
|
||||
populate(settings);
|
||||
updateContrast();
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ---------- the PUT (Save + Reset share it) ---------- */
|
||||
|
||||
function collectBody() {
|
||||
const body = {};
|
||||
for (const f of FIELDS) {
|
||||
const input = inputs[f.field];
|
||||
const value = input ? input.value : "";
|
||||
if (f.kind === "string") {
|
||||
body[f.field] = value.trim() || null; // cleared field → null
|
||||
} else {
|
||||
body[f.field] = isHex(value) ? value : null; // colors: their hex
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function setBusy(busy) {
|
||||
if (saveBtn) saveBtn.disabled = busy;
|
||||
if (resetBtn) resetBtn.disabled = busy;
|
||||
}
|
||||
|
||||
/* One action at a time: both buttons are disabled while either PUT
|
||||
is in flight (a double-fire would race the row). */
|
||||
async function putSettings(body, busyLabel, button) {
|
||||
setBusy(true);
|
||||
if (button) button.textContent = busyLabel;
|
||||
clearError();
|
||||
try {
|
||||
const r = await fetch("/api/ui-settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (r.ok) {
|
||||
return { ok: true };
|
||||
}
|
||||
/* 422 → the server detail (it names the field); any other
|
||||
non-2xx → the fixed line. The form is KEPT either way. */
|
||||
const detail =
|
||||
r.status === 422
|
||||
? await apiDetail(r, "Couldn't save the theme — try again.")
|
||||
: "Couldn't save the theme — try again.";
|
||||
return { ok: false, detail };
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
detail: "Couldn't save the theme — is the app reachable?",
|
||||
};
|
||||
} finally {
|
||||
setBusy(false);
|
||||
if (button) button.textContent = button === saveBtn ? SAVE_LABEL : RESET_LABEL;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTheme() {
|
||||
const outcome = await putSettings(collectBody(), "Saving…", saveBtn);
|
||||
if (!outcome.ok) {
|
||||
showError(outcome.detail);
|
||||
return;
|
||||
}
|
||||
showResult("Theme saved."); // role=status
|
||||
await loadSettings(); // refetch + re-populate (canonical state)
|
||||
clearPreview(); // the page paints the served theme, not the pick
|
||||
}
|
||||
|
||||
async function resetTheme() {
|
||||
const body = {};
|
||||
for (const f of FIELDS) body[f.field] = null; // all null = the defaults
|
||||
const outcome = await putSettings(body, "Resetting…", resetBtn);
|
||||
if (!outcome.ok) {
|
||||
showError(outcome.detail);
|
||||
return;
|
||||
}
|
||||
showResult("Reset to the built-in theme."); // role=status
|
||||
await loadSettings(); // the env / built-in defaults, re-rendered
|
||||
clearPreview(); // the page paints the served theme again
|
||||
}
|
||||
|
||||
/* ---------- view boot (phase 91 task 05) ----------
|
||||
* The shared header is NOT booted here — in the shell it runs
|
||||
* exactly once, via the chat module (app.js) at shell boot. The
|
||||
* gate reads fetchIsAdmin() — the SAME cached whoami promise the
|
||||
* header uses (zero extra requests). Anonymous / token-user: the
|
||||
* gate in, the content out — and NO /api/ui-settings request at all
|
||||
* (the router 403s them — the #tokens-gate contract). */
|
||||
if (!(await fetchIsAdmin())) {
|
||||
if (gateEl) gateEl.hidden = false;
|
||||
if (contentEl) contentEl.hidden = true;
|
||||
return;
|
||||
}
|
||||
if (gateEl) gateEl.hidden = true;
|
||||
if (contentEl) contentEl.hidden = false;
|
||||
|
||||
/* Bindings — armed BEFORE the first load: a fast owner can start
|
||||
picking while the GET is still out; the preview writes are
|
||||
idempotent and the settled load re-populates afterwards. Color
|
||||
inputs drive the live preview + the contrast re-check; text
|
||||
inputs drive neither (B4 — the strings apply on the next page
|
||||
load, the sub-copy says so). */
|
||||
for (const f of FIELDS) {
|
||||
const input = inputs[f.field];
|
||||
if (!input || f.kind !== "color") continue;
|
||||
input.addEventListener("input", () => {
|
||||
previewColor(f.field, input.value);
|
||||
updateContrast();
|
||||
});
|
||||
}
|
||||
if (saveBtn) saveBtn.addEventListener("click", () => void saveTheme());
|
||||
if (resetBtn) resetBtn.addEventListener("click", () => void resetTheme());
|
||||
|
||||
/* Phase 77 (the re-show refresh contract): a user-initiated re-show
|
||||
of this already-mounted view makes the router dispatch
|
||||
bor:view-refresh on the section — re-run the load then (the tab
|
||||
always shows the settled server state when re-shown) and drop the
|
||||
preview overrides (the page paints the served theme, not a pick
|
||||
left behind from before the switch). Armed ONLY here, after the
|
||||
whoami gate passed: anonymous shows the gate and never fetches. */
|
||||
root.addEventListener("bor:view-refresh", () => {
|
||||
void loadSettings().then((settled) => {
|
||||
if (settled) clearPreview();
|
||||
});
|
||||
});
|
||||
|
||||
await loadSettings(); // the effective values — the live theme
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
# 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/…`.
|
||||
@@ -1,17 +0,0 @@
|
||||
/* 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;
|
||||
}
|
||||
@@ -65,6 +65,13 @@
|
||||
page — test_nav_consistency pins the inventory parity).
|
||||
Null-safe: header.js is a no-op on a page without it. -->
|
||||
<a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a>
|
||||
<!-- Phase 91 (task 04): the Theme link is admin-only — hidden
|
||||
by default, header.js reveals it once whoami says admin,
|
||||
exactly like the Tokens link above (the phase-34 one-bar
|
||||
contract: the SAME nav ships on every page —
|
||||
test_nav_consistency pins the inventory parity).
|
||||
Null-safe: header.js is a no-op on a page without it. -->
|
||||
<a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
|
||||
+217
-27
@@ -54,6 +54,12 @@
|
||||
view). No mobile dropdown copy is needed: the link lives
|
||||
in the SAME #app-nav element the hamburger opens. -->
|
||||
<a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a>
|
||||
<!-- Phase 91 (task 04): the Theme link is admin-only — hidden
|
||||
by default, header.js reveals it once whoami says admin,
|
||||
exactly like the Tokens link above (the shell's seventh
|
||||
view). No mobile dropdown copy is needed: the link lives
|
||||
in the SAME #app-nav element the hamburger opens. -->
|
||||
<a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
@@ -372,12 +378,15 @@
|
||||
</div>
|
||||
<!-- #sync-result is the aria-live announcer: the last sync
|
||||
result ("N added · …") when a sync settles, and — phase 64 —
|
||||
the LIVE file label while either job runs ("Syncing… <file>
|
||||
(n/m)" / "Importing <file> (n/m)"), UNTRUNCATED (the button's
|
||||
label span ellipsizes; screen readers hear the full
|
||||
source/relative path, which also rides the button title).
|
||||
After an upload settles it stays empty — the upload's counts
|
||||
live on the Sources page (A3). -->
|
||||
the LIVE file label while a sync runs ("Syncing… <file>
|
||||
(n/m)"), UNTRUNCATED (the button's label span ellipsizes;
|
||||
screen readers hear the full source/relative path, which
|
||||
also rides the button title). Phase 90: an in-flight upload
|
||||
run adopts the button with the BARE "Importing…" label (the
|
||||
run is unpack + register only — no scan, so its status
|
||||
never carries a file or counts). After an upload settles it
|
||||
stays empty — the upload's result line lives on the Sources
|
||||
page (A3). -->
|
||||
<span class="sync-result" id="sync-result" role="status" aria-live="polite"></span>
|
||||
<!-- Sync failure banner — role="alert" so a failed sync is announced. -->
|
||||
<div class="kb-banner is-error" id="sync-error-banner" role="alert" hidden>
|
||||
@@ -539,29 +548,35 @@
|
||||
<!-- Phase 49 (owner permission 2026-08-28): the archive upload
|
||||
form replaces the phase-38 local-directory form — an
|
||||
uploaded .tar/.tar.gz/.tgz/.zip is unpacked under
|
||||
BOR_UPLOAD_DIR and scanned; the same filename replaces the
|
||||
source in place (no new folder, no duplicate row). The file
|
||||
control is labeled (visible <label for=…> — WCAG
|
||||
input-label rule); the button runs the §7.4 never-stale
|
||||
BOR_UPLOAD_DIR; the same filename replaces the source in
|
||||
place (no new folder, no duplicate row). The file control
|
||||
is labeled (visible <label for=…> — WCAG input-label
|
||||
rule); the button ("Upload") runs the §7.4 never-stale
|
||||
lifecycle ("Uploading…" while the POST is out). Phase 64
|
||||
(task 05) reworks the rest to the 202 contract (the
|
||||
phase-49 synchronous 200 paragraph is superseded): the 202
|
||||
arrives the moment the archive is safely on disk (A1) — a
|
||||
JS-created "Successfully uploaded — <file>" toast fires
|
||||
then (A2 — the phase-55 .toast node, no markup here; safe
|
||||
to navigate away) and the button settles into the live
|
||||
"Processing… <file> (n/m)" label (A4 — the full path rides
|
||||
the button title) driven by the 2 s poll of
|
||||
(task 05) reworked the rest to the 202 contract (the
|
||||
phase-49 synchronous 200 paragraph is superseded): the
|
||||
202 arrives the moment the archive is safely on disk (A1)
|
||||
— a JS-created "Successfully uploaded — <file>" toast
|
||||
fires then (A2 — the phase-55 .toast node, no markup
|
||||
here; safe to navigate away) and the button settles into
|
||||
the bare "Processing…" label driven by the 2 s poll of
|
||||
GET /api/git-sources/upload/status, until the success line
|
||||
(role=status) or the sanitized error banner (role=alert)
|
||||
lands; 409 re-attaches to the in-flight run — no error
|
||||
banner; the other non-2xx still show the server detail
|
||||
inline. -->
|
||||
inline. Phase 90 (owner-locked A1/A2/A3): the background
|
||||
run is UNPACK + REGISTER ONLY — no model check, no import,
|
||||
no overview refresh — so the processing state covers
|
||||
unpack only (no file, no "(n/m)" counts, no title) and
|
||||
the success line points at the next step: "Uploaded
|
||||
<name> — press Sync sources to import it." The scan is
|
||||
the RAG page's Sync button's job (edit the source's
|
||||
ignore paths first, if you want files excluded). -->
|
||||
<form id="archive-upload-form">
|
||||
<label for="archive-upload-file">Upload a source archive (.tar, .tar.gz, .tgz, .zip)</label>
|
||||
<input id="archive-upload-file" name="file" type="file"
|
||||
accept=".tar,.tar.gz,.tgz,.zip" required>
|
||||
<button type="submit" id="archive-upload-btn">Upload & scan</button>
|
||||
<button type="submit" id="archive-upload-btn">Upload</button>
|
||||
<p class="git-source-error" id="archive-upload-error" role="alert" hidden></p>
|
||||
<p class="git-source-result" id="archive-upload-result" role="status"
|
||||
aria-live="polite" hidden></p>
|
||||
@@ -569,7 +584,7 @@
|
||||
|
||||
<div class="table-wrap" id="git-sources-table-wrap" role="region" aria-label="Sources" tabindex="0">
|
||||
<table class="git-sources-table" id="git-sources-table">
|
||||
<caption class="visually-hidden">Sources the Sync button imports — git repositories it clones, local directories it walks, and uploaded archives (unpacked under the upload directory)</caption>
|
||||
<caption class="visually-hidden">Sources the Sync button imports — git repositories it clones, local directories it walks, and uploaded archives (uploads unpack and register in place here; the Sync button scans them)</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Source</th>
|
||||
@@ -594,18 +609,22 @@
|
||||
touched). Adding still does not clone — the Sync button
|
||||
mirrors the remaining sources (upstream file churn is
|
||||
pruned on that run); the phase-49 upload is the
|
||||
in-place exception (it unpacks and scans, and a
|
||||
same-name re-upload replaces the source in place). -->
|
||||
in-place exception (it unpacks and registers — the scan
|
||||
is the Sync button's job, phase 90 — and a same-name
|
||||
re-upload replaces the source in place). -->
|
||||
<p class="git-source-hint" id="git-sources-hint" role="note">
|
||||
Removing a source is a total removal, done immediately: its
|
||||
entry, its indexed documents, and — for git clones and
|
||||
uploaded archives — its files on the server's disk (the
|
||||
confirmation modal spells out exactly what will be deleted;
|
||||
files in your own local directories are never touched).
|
||||
Uploads unpack and scan immediately — re-uploading the same
|
||||
filename replaces that source in place (no new folder, no
|
||||
duplicate row). The Sync button still mirrors the remaining
|
||||
sources (files removed upstream are pruned on that run).
|
||||
Uploads unpack and register the source only — re-uploading
|
||||
the same filename replaces that source in place (no new
|
||||
folder, no duplicate row). Press <strong>Sync sources</strong>
|
||||
on the RAG page to scan it; edit the source's ignore paths
|
||||
first if you want files excluded. The Sync button still
|
||||
mirrors the remaining sources (files removed upstream are
|
||||
pruned on that run).
|
||||
</p>
|
||||
|
||||
<!-- Phase 69 (owner request 2026-09-02): the remove
|
||||
@@ -930,6 +949,177 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Phase 91 (task 04): the Theme view — the shell's seventh
|
||||
folded view (the phase-76 fold pattern, the phase-79 Tokens
|
||||
view as the most recent precedent): the admin-only palette +
|
||||
branding editor. /theme.html serves THIS document (the shell
|
||||
route in app/main.py); the router shows this section for that
|
||||
pathname. The CSS-file theming (BOR_THEME, phase 62) is
|
||||
retired (task 03): the effective theme is injected into every
|
||||
served page's <head> server-side (app/core/caching.py +
|
||||
app/core/theming.py), so it paints on the FIRST paint — no
|
||||
red flash, no pop-in. SHIPS hidden (anonymous-safe — the gate
|
||||
is what anonymous sees; theme.js reveals the content for
|
||||
admin only). The hidden + inert pair is the WCAG contract:
|
||||
a hidden view must not receive focus or keyboard traversal
|
||||
(AGENTS.md rule 5). Mounted lazily — assets/router.js imports
|
||||
theme.js on first show only (mount-once, hide-forever; the
|
||||
editor lands in task 05). The form is STATIC markup (the
|
||||
E2E-stable-selectors house convention) — the onsubmit
|
||||
binding + live preview + Save/Reset lifecycle land in task 05
|
||||
(theme.js); there is no real submit (every button is
|
||||
type="button"). -->
|
||||
<section class="view" id="view-theme" hidden inert aria-label="Theme" tabindex="-1">
|
||||
<div class="container theme-shell">
|
||||
<!-- Phase 91 (task 04): anonymous sign-in gate — the EXACT
|
||||
#sources-gate pattern (phase 16) and the same .sources-gate
|
||||
visual language: the palette + branding is what the login
|
||||
locks (B5, owner-locked 2026-09-09). Visible for
|
||||
anonymous, hidden for the admin (theme.js). The gate's
|
||||
Sign in returns to the Theme view (the header's ?next=
|
||||
convention; the static href is the no-JS fallback). -->
|
||||
<section class="sources-gate" id="theme-gate" aria-labelledby="theme-gate-title" hidden>
|
||||
<div class="sources-gate-glyph" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
|
||||
</div>
|
||||
<h2 id="theme-gate-title">Sign in to change the theme</h2>
|
||||
<p class="sources-gate-sub">
|
||||
The palette and branding are admin-only. Chat — and any
|
||||
document an answer cites — stays open to everyone.
|
||||
</p>
|
||||
<a class="sources-gate-link" href="/login.html?next=/theme.html">Sign in</a>
|
||||
</section>
|
||||
|
||||
<!-- Phase 91 (task 04): the editor — SHIPS hidden
|
||||
(anonymous-safe; the gate is what anonymous sees). theme.js
|
||||
reveals it once the cached whoami says admin (the
|
||||
#git-sources-content pattern). -->
|
||||
<div id="theme-content" hidden>
|
||||
<div class="page-head">
|
||||
<h1>Theme</h1>
|
||||
<p class="page-sub">
|
||||
Changes preview live as you pick;
|
||||
<strong>Save theme</strong> bakes the palette into every
|
||||
page it is served on — it applies on the first paint, no
|
||||
pop-in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Static form skeleton (E2E-stable selectors; task 05 wires
|
||||
the bindings — effective-value populate, the live preview,
|
||||
the §7.4 Save/Reset lifecycle, the WCAG contrast warnings
|
||||
in #theme-contrast). No real submit: both buttons are
|
||||
type="button"; maxlength=300 mirrors the server's 300-char
|
||||
limit (the server re-validates — 422 naming the field).
|
||||
The color inputs ship the BUILT-IN values (app/core/
|
||||
theming.py BUILTIN_COLORS) — task 05 re-populates them
|
||||
with the EFFECTIVE values on mount. -->
|
||||
<form id="theme-form" novalidate>
|
||||
<fieldset class="theme-group">
|
||||
<legend class="theme-group-title">Branding</legend>
|
||||
<!-- B4 (owner-locked): the strings keep the brand.js
|
||||
runtime application — they apply via the /api/config
|
||||
boot fetch on the NEXT page load; the live preview
|
||||
covers the palette only. An empty field restores the
|
||||
default (the env value). -->
|
||||
<p class="theme-note">
|
||||
These strings apply on the next page load — the live
|
||||
preview covers the palette only. Leaving a field empty
|
||||
restores its default.
|
||||
</p>
|
||||
<label for="theme-app-name">App name</label>
|
||||
<input
|
||||
id="theme-app-name"
|
||||
name="app_name"
|
||||
type="text"
|
||||
maxlength="300"
|
||||
autocomplete="off"
|
||||
>
|
||||
<label for="theme-placeholder">Chat input placeholder</label>
|
||||
<input
|
||||
id="theme-placeholder"
|
||||
name="input_placeholder"
|
||||
type="text"
|
||||
maxlength="300"
|
||||
autocomplete="off"
|
||||
>
|
||||
<label for="theme-footer">Footer line</label>
|
||||
<input
|
||||
id="theme-footer"
|
||||
name="footer_text"
|
||||
type="text"
|
||||
maxlength="300"
|
||||
autocomplete="off"
|
||||
>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="theme-group">
|
||||
<!-- The five text/background pairs are checked against
|
||||
WCAG 2.1 AA (4.5:1) as the owner picks (theme.js —
|
||||
the app/core/theming.py docstring is the authoritative
|
||||
pair table); failures list in #theme-contrast as a
|
||||
warning and never block a save. -->
|
||||
<legend class="theme-group-title">
|
||||
Palette — five pairs checked against WCAG 2.1 AA (4.5:1)
|
||||
</legend>
|
||||
<div class="theme-colors">
|
||||
<div class="theme-color">
|
||||
<label for="theme-bg">Background (--bg)</label>
|
||||
<input id="theme-bg" name="bg" type="color" value="#0f0a0a">
|
||||
</div>
|
||||
<div class="theme-color">
|
||||
<label for="theme-surface">Surface (--surface)</label>
|
||||
<input id="theme-surface" name="surface" type="color" value="#1a0f0f">
|
||||
</div>
|
||||
<div class="theme-color">
|
||||
<label for="theme-ink">Text (--ink)</label>
|
||||
<input id="theme-ink" name="ink" type="color" value="#f0e6e6">
|
||||
</div>
|
||||
<div class="theme-color">
|
||||
<label for="theme-ink-soft">Secondary text (--ink-soft)</label>
|
||||
<input id="theme-ink-soft" name="ink_soft" type="color" value="#b8a8a8">
|
||||
</div>
|
||||
<div class="theme-color">
|
||||
<label for="theme-line">Border (--line)</label>
|
||||
<input id="theme-line" name="line" type="color" value="#2d1a1a">
|
||||
</div>
|
||||
<div class="theme-color">
|
||||
<label for="theme-brand">Brand accent (--brand) — buttons, links</label>
|
||||
<input id="theme-brand" name="brand" type="color" value="#f43f5e">
|
||||
</div>
|
||||
<div class="theme-color">
|
||||
<label for="theme-brand-soft">Brand tint (--brand-soft)</label>
|
||||
<input id="theme-brand-soft" name="brand_soft" type="color" value="#2d0a0a">
|
||||
</div>
|
||||
<div class="theme-color">
|
||||
<label for="theme-brand-ink">Brand text (--brand-ink)</label>
|
||||
<input id="theme-brand-ink" name="brand_ink" type="color" value="#fca5a5">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Save (primary) + Reset to defaults (secondary). Both
|
||||
type="button" (no real submit); task 05 runs the §7.4
|
||||
never-stale lifecycle ("Saving…" while the PUT is out,
|
||||
re-enabled on success AND failure). -->
|
||||
<div class="theme-actions">
|
||||
<button type="button" id="theme-save">Save theme</button>
|
||||
<button type="button" class="theme-reset" id="theme-reset">Reset to defaults</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- task 05 owns all three lines: #theme-error (the server's
|
||||
422 detail — the fields are kept on failure), #theme-result
|
||||
("Theme saved." / "Reset to the built-in theme."),
|
||||
#theme-contrast (the WCAG warnings for the five pairs —
|
||||
warning-only, the owner can still save). -->
|
||||
<p class="theme-error" id="theme-error" role="alert" hidden></p>
|
||||
<p class="theme-result" id="theme-result" role="status" aria-live="polite" hidden></p>
|
||||
<p class="theme-contrast" id="theme-contrast" role="alert" hidden></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Phase 79 (task 05): the in-app token gate — a body-level
|
||||
|
||||
@@ -58,6 +58,13 @@
|
||||
page — test_nav_consistency pins the inventory parity).
|
||||
Null-safe: header.js is a no-op on a page without it. -->
|
||||
<a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a>
|
||||
<!-- Phase 91 (task 04): the Theme link is admin-only — hidden
|
||||
by default, header.js reveals it once whoami says admin,
|
||||
exactly like the Tokens link above (the phase-34 one-bar
|
||||
contract: the SAME nav ships on every page —
|
||||
test_nav_consistency pins the inventory parity).
|
||||
Null-safe: header.js is a no-op on a page without it. -->
|
||||
<a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
|
||||
@@ -59,6 +59,13 @@
|
||||
page — test_nav_consistency pins the inventory parity).
|
||||
Null-safe: header.js is a no-op on a page without it. -->
|
||||
<a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a>
|
||||
<!-- Phase 91 (task 04): the Theme link is admin-only — hidden
|
||||
by default, header.js reveals it once whoami says admin,
|
||||
exactly like the Tokens link above (the phase-34 one-bar
|
||||
contract: the SAME nav ships on every page —
|
||||
test_nav_consistency pins the inventory parity).
|
||||
Null-safe: header.js is a no-op on a page without it. -->
|
||||
<a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a>
|
||||
<!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is
|
||||
outside the nav; see styles.css .sign-in-mobile rules). -->
|
||||
<a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden>
|
||||
|
||||
+4
-3
@@ -40,12 +40,13 @@ os.environ["BOR_SUGGESTIONS"] = json.dumps(_Settings.model_fields["suggestions"]
|
||||
|
||||
# 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
|
||||
# ``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``, and the
|
||||
# default-metadata pins must see the code defaults (derived from
|
||||
# the class fields, same pattern as the suggestions line above).
|
||||
# (Phase 91, task 03: the retired CSS-file theme env var no longer
|
||||
# exists — nothing to pin.)
|
||||
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.main import app as fastapi_app # noqa: E402
|
||||
|
||||
@@ -123,14 +123,13 @@ def app_server(mock_llm: int) -> Iterator[str]:
|
||||
)
|
||||
# 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``).
|
||||
# legitimately carry ``BOR_INPUT_PLACEHOLDER`` /
|
||||
# ``BOR_FOOTER_TEXT``, and the byte-identical default contract
|
||||
# must see the code defaults (derived from the class fields, never
|
||||
# drifts from ``app/config.py``). (Phase 91, task 03: the retired
|
||||
# CSS-file theme env var no longer exists — nothing to pin.)
|
||||
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(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
||||
|
||||
@@ -0,0 +1,733 @@
|
||||
"""Phase 91 E2E (Playwright): the admin Theme tab — the pickers and
|
||||
fields, the pre-paint theme, the admin gate, and the reset.
|
||||
|
||||
Source: ``TODO.md`` L4 — "Custom theming isn't really working. The
|
||||
page loads red first and then the theme 'pops' into view, replacing
|
||||
words and colors in an obvious way. Remove the custom css file
|
||||
theming. Create a new admin tab that allows the user to change
|
||||
everything the env var and custom css currently supports but with
|
||||
buttons and color pickers. Theme should load immediately, not pop in
|
||||
after the page load."
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov
|
||||
|
||||
Test → contract mapping (one story, one phase, one isolated file):
|
||||
|
||||
1. ``test_theme_tab_admin_save`` — "buttons and color pickers": the
|
||||
admin sees the "Theme" nav link and the form (gate hidden); the
|
||||
11 inputs show the effective defaults (the 3 template strings +
|
||||
the 8 built-in hexes parsed out of ``styles.css``'s ``:root``
|
||||
IN-TEST — the suite can never drift from the stylesheet); Save
|
||||
runs the §7.4 lifecycle (disabled + "Saving…" while the PUT is
|
||||
held, then restored) and lands the role=status "Theme saved.";
|
||||
the inputs re-populate to the saved values; the persisted row is
|
||||
the one saved; and the saved non-AA palette lists its failing
|
||||
pair in ``#theme-contrast`` without blocking the save
|
||||
(warning-only).
|
||||
2. ``test_saved_theme_is_pre_paint_for_everyone`` — "the theme
|
||||
should load immediately, not pop in": after a save, the RAW
|
||||
served HTML of ``/`` carries exactly one ``<style
|
||||
id="bor-theme">`` with all 8 vars = the saved hexes, placed
|
||||
IMMEDIATELY before ``</head>`` — for the admin AND a fresh
|
||||
anonymous context — and the computed ``:root`` custom properties
|
||||
equal the saved hexes at load (the inline tag precedes every
|
||||
stylesheet application). The 3 strings stay on the brand.js boot
|
||||
fetch (the B4 split: colors pre-paint, strings via the fetch).
|
||||
3. ``test_anonymous_and_token_user_are_walled`` — the admin gate:
|
||||
anonymous meets ``#theme-gate`` (sign-in link
|
||||
``?next=/theme.html``) with ``#theme-content`` hidden and the
|
||||
nav link hidden, and ``PUT /api/ui-settings`` 403s; a token user
|
||||
(the phase-79 gate login) 403s the PUT too and never sees the
|
||||
nav link (B5: admin-only, like Tuning/Tokens).
|
||||
4. ``test_reset_restores_the_builtin_byte_identical`` — "reset":
|
||||
Reset to defaults runs the §7.4 lifecycle ("Resetting…"), lands
|
||||
the role=status "Reset to the built-in theme.", re-populates the
|
||||
11 defaults, serves NO theme tag, and the served bytes equal a
|
||||
row-less deployment byte for byte (the no-op injection
|
||||
contract).
|
||||
5. ``test_contrast_warning_does_not_block`` — the WCAG warnings:
|
||||
``--ink`` set within 0.1 ratio of ``--bg`` lists the failing
|
||||
pair(s) with the ratio in ``#theme-contrast`` (role=alert) as
|
||||
soon as the picker moves; Save still succeeds (warning-only);
|
||||
Reset restores the AA built-ins and hides the warning (the
|
||||
suite's final state is clean).
|
||||
|
||||
DB isolation: the shared e2e Postgres keeps ``ui_settings`` (the
|
||||
single row the caching middleware reads for EVERY served page — a
|
||||
leftover themed row would repaint other suites' pages) and
|
||||
``api_tokens`` rows across suites. An autouse fixture truncates
|
||||
``ui_settings`` and deletes the ``e2e-``-labeled tokens before AND
|
||||
after every test (never a TRUNCATE on ``api_tokens`` — the shared
|
||||
DB may hold the owner's real tokens).
|
||||
|
||||
Per-module app env (the tuning/tokens/archive-upload pattern): the
|
||||
module-scoped ``app_server`` override boots the same env block as
|
||||
the shared conftest server with the branding vars pinned to the
|
||||
CODE defaults (an operator's local ``.env`` may carry the owner's
|
||||
name/placeholder/footer, and "the effective strings start at the
|
||||
template defaults" must hold regardless — the phase-61/62
|
||||
leak-guard pattern, extended to ``BOR_APP_NAME``) and
|
||||
``BOR_GIT_SOURCES`` forced empty (the dev ``.env``'s git repo must
|
||||
not render as env rows in this suite's app).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Browser, BrowserContext, Page, Route, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings
|
||||
from app.core.theming import COLOR_FIELDS
|
||||
from app.db import SessionLocal
|
||||
from app.models import UiSettings
|
||||
from e2e.auth_helpers import login, login_with_token
|
||||
from e2e.conftest import (
|
||||
ADMIN_PASSWORD,
|
||||
APP_PORT,
|
||||
REPO,
|
||||
SESSION_SECRET,
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
# The distinct E2E palette (task 06): a full non-built-in indigo set —
|
||||
# every value differs from its built-in, so the tag is non-empty and
|
||||
# every saved color is stored as-is (no built-in→NULL collapse).
|
||||
PALETTE: dict[str, str] = {
|
||||
"bg": "#0b1020",
|
||||
"surface": "#111730",
|
||||
"ink": "#e6e9f5",
|
||||
"ink_soft": "#a8b0d0",
|
||||
"line": "#232a4a",
|
||||
"brand": "#4f46e5",
|
||||
"brand_soft": "#1e2447",
|
||||
"brand_ink": "#c7d2fe",
|
||||
}
|
||||
APP_NAME = "Theme E2E"
|
||||
PLACEHOLDER = "Ask the themed brain…"
|
||||
FOOTER = "E2E footer"
|
||||
SAVED_STRINGS: dict[str, str] = {
|
||||
"app_name": APP_NAME,
|
||||
"input_placeholder": PLACEHOLDER,
|
||||
"footer_text": FOOTER,
|
||||
}
|
||||
|
||||
#: The failing-pair leg (test 5): --ink set within 0.1 ratio of --bg
|
||||
#: (the deterministic near-identical pick — 1.0:1 on both dark pairs).
|
||||
FAILING_INK = "#101010"
|
||||
|
||||
#: The E2E-stable color-input ids, in COLOR_FIELDS order (the form's
|
||||
#: own markup — the static E2E-stable-selectors house convention).
|
||||
COLOR_INPUT_IDS: dict[str, str] = {
|
||||
field: f"#theme-{field.replace('_', '-')}" for field in COLOR_FIELDS
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-test constants (single sources of truth — never duplicated)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _builtin_colors() -> dict[str, str]:
|
||||
"""The 8 built-in identity hexes parsed OUT of
|
||||
``frontend/assets/styles.css``'s ``:root`` in-test — the single
|
||||
source of truth, so the suite can't drift from the stylesheet it
|
||||
asserts on."""
|
||||
css = (REPO / "frontend" / "assets" / "styles.css").read_text(encoding="utf-8")
|
||||
root = re.search(r":root\s*\{([^}]*)\}", css, re.DOTALL)
|
||||
assert root is not None, "styles.css must open with its :root block"
|
||||
colors: dict[str, str] = {}
|
||||
for name in COLOR_FIELDS:
|
||||
match = re.search(
|
||||
rf"--{name.replace('_', '-')}\s*:\s*(#[0-9a-fA-F]{{6}})", root.group(1)
|
||||
)
|
||||
assert match is not None, f"--{name} missing from styles.css :root"
|
||||
colors[name] = match.group(1).lower()
|
||||
return colors
|
||||
|
||||
|
||||
def _template_defaults() -> dict[str, str]:
|
||||
"""The 3 template strings from the CODE defaults (derived from
|
||||
the class fields — never drifts from ``app/config.py``; the
|
||||
module server pins the same values, so the effective strings
|
||||
start exactly here)."""
|
||||
return {
|
||||
"app_name": Settings.model_fields["app_name"].default,
|
||||
"input_placeholder": Settings.model_fields["input_placeholder"].default,
|
||||
"footer_text": Settings.model_fields["footer_text"].default,
|
||||
}
|
||||
|
||||
|
||||
def _expected_tag(colors: dict[str, str]) -> str:
|
||||
"""The EXACT inline tag ``theme_style_tag`` renders for
|
||||
``colors``: one ``:root`` override, all 8 vars in COLOR_FIELDS
|
||||
order, no whitespace (the byte the middleware injects)."""
|
||||
declarations = "".join(f"--{k.replace('_', '-')}:{colors[k]};" for k in COLOR_FIELDS)
|
||||
return f'<style id="bor-theme">:root{{{declarations}}}</style>'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-module app env (the tuning/tokens/archive-upload pattern)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(mock_llm: int) -> Iterator[str]:
|
||||
"""The real app under test — per-module env: the branding vars
|
||||
are pinned to the CODE defaults (the effective strings start at
|
||||
the template defaults regardless of an operator's local
|
||||
``.env`` — the phase-61/62 leak-guard pattern the shared conftest
|
||||
server applies to its two string vars; this one pins all three,
|
||||
including ``BOR_APP_NAME``, which the shared server leaves to the
|
||||
process) and ``BOR_GIT_SOURCES`` is forced empty (the dev
|
||||
``.env``'s git repo must not render as env rows in this
|
||||
suite's app)."""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["BOR_ENVIRONMENT"] = "e2e"
|
||||
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||
env["BOR_LLM_BASE_URL"] = (
|
||||
"https://aipi.reeseapps.com/v1"
|
||||
if USE_REAL_LLM
|
||||
else f"http://127.0.0.1:{mock_llm}/v1"
|
||||
)
|
||||
# Mock-calibrated threshold (conftest pattern) — no chat turn is
|
||||
# ever sent in this suite, but the app boots with the same shape.
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
env["BOR_LLM_RETRY_DELAY"] = "0"
|
||||
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
|
||||
env.setdefault(
|
||||
"BOR_DATABASE_URL",
|
||||
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||
)
|
||||
# Phase 16: admin auth must be set or create_app() refuses to boot.
|
||||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||
env["BOR_DOCS_REPO"] = ""
|
||||
env["BOR_SUGGESTIONS"] = json.dumps(
|
||||
Settings.model_fields["suggestions"].default
|
||||
)
|
||||
# The branding vars: "unset" = the template defaults (the code
|
||||
# defaults, derived from the class fields — the local ``.env`` may
|
||||
# carry the owner's values, and this suite's assertions need the
|
||||
# TEMPLATE defaults, not the owner's).
|
||||
env["BOR_APP_NAME"] = Settings.model_fields["app_name"].default
|
||||
env["BOR_INPUT_PLACEHOLDER"] = (
|
||||
Settings.model_fields["input_placeholder"].default
|
||||
)
|
||||
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
|
||||
env["BOR_GIT_SOURCES"] = ""
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
_wait_http(f"{APP_URL}/api/health")
|
||||
yield APP_URL
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_url(app_server: str) -> str:
|
||||
return app_server
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DB isolation + helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _clean_ui_state() -> None:
|
||||
"""Fresh theme + token state per test: truncate the single-row
|
||||
``ui_settings`` (the middleware reads it for EVERY page — a
|
||||
leftover themed row would repaint other suites' pages) and
|
||||
delete this suite's issued tokens (label-scoped on ``e2e-`` —
|
||||
never a TRUNCATE: the shared DB may hold the owner's real
|
||||
tokens)."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE ui_settings"))
|
||||
db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'"))
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean(db_ready: None) -> Iterator[None]:
|
||||
_clean_ui_state()
|
||||
yield
|
||||
_clean_ui_state()
|
||||
|
||||
|
||||
def _cookies(page: Page) -> dict[str, str]:
|
||||
"""The session cookies the browser context holds (the test's API
|
||||
side sees exactly what that browser sees)."""
|
||||
return {
|
||||
c["name"]: c["value"]
|
||||
for c in page.context.cookies()
|
||||
if "name" in c and "value" in c
|
||||
}
|
||||
|
||||
|
||||
def _seed_theme_via_api(app_url: str, cookies: dict[str, str]) -> None:
|
||||
"""Admin ``PUT /api/ui-settings`` with the full theme (the API
|
||||
seed — the UI save itself is test 1's job)."""
|
||||
body = {**PALETTE, **SAVED_STRINGS}
|
||||
r = httpx.put(f"{app_url}/api/ui-settings", json=body, cookies=cookies, timeout=10)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json() == body, "the PUT must echo the new effective values"
|
||||
|
||||
|
||||
def _hold_theme_puts(page: Page, hold_s: float = 0.6) -> None:
|
||||
"""Intercept ``PUT /api/ui-settings`` and hold it for
|
||||
``hold_s`` seconds (the archive-upload suite's §7.4 pattern):
|
||||
while it is held, the page's fetch is guaranteed pending, so the
|
||||
in-flight state (disabled buttons, the "Saving…" / "Resetting…"
|
||||
labels) is observable deterministically — a localhost PUT
|
||||
settles in milliseconds, so without the hold the window is a
|
||||
race. GETs (the load + the save's refetch) pass straight
|
||||
through."""
|
||||
|
||||
def handle(route: Route) -> None:
|
||||
if route.request.method == "PUT":
|
||||
time.sleep(hold_s)
|
||||
route.continue_()
|
||||
|
||||
page.route("**/api/ui-settings", handle)
|
||||
|
||||
|
||||
def _release_theme_puts(page: Page) -> None:
|
||||
page.unroute("**/api/ui-settings")
|
||||
|
||||
|
||||
def _fill_theme_form(
|
||||
page: Page,
|
||||
palette: dict[str, str],
|
||||
strings: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Fill the 11 inputs: the 3 text fields (``strings``, default
|
||||
the E2E set) + the 8 color pickers (``palette``)."""
|
||||
text_values = strings if strings is not None else SAVED_STRINGS
|
||||
page.fill("#theme-app-name", text_values["app_name"])
|
||||
page.fill("#theme-placeholder", text_values["input_placeholder"])
|
||||
page.fill("#theme-footer", text_values["footer_text"])
|
||||
for field, value in palette.items():
|
||||
page.fill(COLOR_INPUT_IDS[field], value)
|
||||
|
||||
|
||||
def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, str]) -> None:
|
||||
"""Assert all 11 inputs show the given effective values."""
|
||||
expect(page.locator("#theme-app-name")).to_have_value(strings["app_name"])
|
||||
expect(page.locator("#theme-placeholder")).to_have_value(strings["input_placeholder"])
|
||||
expect(page.locator("#theme-footer")).to_have_value(strings["footer_text"])
|
||||
for field in COLOR_FIELDS:
|
||||
expect(page.locator(COLOR_INPUT_IDS[field])).to_have_value(colors[field])
|
||||
|
||||
|
||||
def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None:
|
||||
"""The RAW served HTML carries exactly one inline theme tag, with
|
||||
all 8 vars = the given hexes, placed IMMEDIATELY before
|
||||
``</head>`` (``inject_theme``'s exact placement: the tag ends
|
||||
exactly where ``</head>`` begins and carries the injector's
|
||||
single leading newline)."""
|
||||
tag = _expected_tag(colors)
|
||||
assert raw.count(tag) == 1, f"expected exactly one theme tag:\n{tag}"
|
||||
start = raw.index(tag)
|
||||
head = raw.index("</head>")
|
||||
assert start + len(tag) == head, "the tag must end exactly where </head> begins"
|
||||
assert raw[start - 1] == "\n", "the tag must carry the injector's leading newline"
|
||||
|
||||
|
||||
def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_000) -> None:
|
||||
"""The first-paint proof: all 8 computed ``:root`` custom
|
||||
properties equal the given hexes. The inline tag precedes every
|
||||
stylesheet application, so a themed deployment resolves them
|
||||
from the first style pass — no red flash, no pop-in (custom
|
||||
properties return the specified token, so the string compare is
|
||||
stable — the ``.trim()`` rides out any token whitespace)."""
|
||||
expected = {f"--{k.replace('_', '-')}": v for k, v in colors.items()}
|
||||
page.wait_for_function(
|
||||
"""(expected) => {
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
return Object.entries(expected).every(
|
||||
([k, v]) => cs.getPropertyValue(k).trim() === v
|
||||
);
|
||||
}""",
|
||||
arg=expected,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The tab (admin): the form, the effective defaults, the §7.4 save
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
|
||||
defaults = _template_defaults()
|
||||
builtin = _builtin_colors()
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/theme.html")
|
||||
|
||||
# The admin header contract on this page: the ship-hidden "Theme"
|
||||
# nav link is revealed (header.js, role === "admin") and marks
|
||||
# the current page (the router's single-writer nav stamp).
|
||||
expect(page.locator("#nav-theme")).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#nav-theme")).to_have_attribute("aria-current", "page")
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible()
|
||||
|
||||
# The gate is hidden for the admin and the form is revealed
|
||||
# (theme.js's whoami branch — the #git-sources-content pattern).
|
||||
expect(page.locator("#theme-gate")).to_be_hidden()
|
||||
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
|
||||
|
||||
# The 11 inputs show the EFFECTIVE defaults: the 3 template
|
||||
# strings + the 8 built-in hexes parsed straight out of
|
||||
# styles.css's :root (the resolver's missing-row branch).
|
||||
_expect_form_values(page, defaults, builtin)
|
||||
|
||||
# Set a distinct palette + the 3 strings, then Save through the
|
||||
# real form — the PUT held so the §7.4 in-flight state is
|
||||
# observable deterministically.
|
||||
_fill_theme_form(page, PALETTE)
|
||||
_hold_theme_puts(page)
|
||||
try:
|
||||
page.click("#theme-save")
|
||||
# In-flight: BOTH buttons disabled (one action at a time),
|
||||
# the primary relabeled "Saving…" (never stale).
|
||||
expect(page.locator("#theme-save")).to_be_disabled()
|
||||
expect(page.locator("#theme-save")).to_have_text("Saving…")
|
||||
expect(page.locator("#theme-reset")).to_be_disabled()
|
||||
# Settled: the role=status confirmation + the restored
|
||||
# lifecycle (re-enabled, original label).
|
||||
expect(page.locator("#theme-result")).to_have_text(
|
||||
"Theme saved.", timeout=30_000
|
||||
)
|
||||
expect(page.locator("#theme-result")).to_have_attribute("role", "status")
|
||||
expect(page.locator("#theme-save")).to_have_text("Save theme")
|
||||
expect(page.locator("#theme-save")).to_be_enabled()
|
||||
expect(page.locator("#theme-reset")).to_be_enabled()
|
||||
finally:
|
||||
_release_theme_puts(page)
|
||||
|
||||
# The inputs re-populate to the SAVED (effective) values (the
|
||||
# save's refetch is the canonical state).
|
||||
_expect_form_values(page, SAVED_STRINGS, PALETTE)
|
||||
|
||||
# The row landed in Postgres (the id-1 single row, all 11 values
|
||||
# — every palette color differs from its built-in, so nothing
|
||||
# collapsed to NULL).
|
||||
with SessionLocal() as db:
|
||||
row = db.get(UiSettings, 1)
|
||||
assert row is not None, "the PUT must upsert the id-1 row"
|
||||
assert row.app_name == APP_NAME
|
||||
assert row.input_placeholder == PLACEHOLDER
|
||||
assert row.footer_text == FOOTER
|
||||
for field in COLOR_FIELDS:
|
||||
assert getattr(row, field) == PALETTE[field]
|
||||
|
||||
# The saved palette fails ONE of the five pairs — --bg on
|
||||
# --brand (the button-ink pair: 3.0:1 < 4.5:1) — and the
|
||||
# warning lists it. Save was NEVER blocked (the warning-only
|
||||
# contract: the owner's homelab palette; the built-in stays AA).
|
||||
contrast = page.locator("#theme-contrast")
|
||||
expect(contrast).to_have_attribute("role", "alert")
|
||||
expect(contrast).to_be_visible()
|
||||
expect(contrast).to_have_text("--bg on --brand: 3.0:1 — needs 4.5:1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Pre-paint, for everyone: the inline :root in the RAW served HTML
|
||||
# + the computed palette at load (the no-pop-in proof), the B4
|
||||
# strings via the boot fetch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_saved_theme_is_pre_paint_for_everyone(
|
||||
page: Page, browser: Browser, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
# The admin saves the theme (the API seed — test 1 owns the UI
|
||||
# save path).
|
||||
_seed_theme_via_api(app_url, _cookies(page))
|
||||
|
||||
# The RAW served HTML (httpx — no JS at all, the server's own
|
||||
# bytes): exactly one inline theme tag, all 8 vars = the saved
|
||||
# hexes, immediately before </head> (the pre-paint mechanism the
|
||||
# middleware unit tests pin — this is its observable
|
||||
# consequence).
|
||||
r = httpx.get(app_url + "/", timeout=10)
|
||||
assert r.status_code == 200
|
||||
_assert_raw_tag(r.text, PALETTE)
|
||||
# The phase-91 CSP extension: the inline tag is permitted in a
|
||||
# real browser only via the strict sha256 source expression
|
||||
# (style-src 'self' 'sha256-…' appended to the A1 string — no
|
||||
# 'unsafe-inline').
|
||||
csp = r.headers.get("content-security-policy", "")
|
||||
assert "style-src 'self' 'sha256-" in csp, csp
|
||||
|
||||
# The admin's browser: the same tag in the served document, and
|
||||
# the computed custom properties equal the saved hexes at load
|
||||
# (the inline tag precedes every stylesheet application — the
|
||||
# first paint IS the themed paint).
|
||||
page.goto(app_url + "/")
|
||||
_assert_raw_tag(page.content(), PALETTE)
|
||||
_wait_theme_computed(page, PALETTE)
|
||||
|
||||
# A FRESH anonymous context (no auth anywhere): the same inline
|
||||
# tag + computed values — the theme is for EVERYONE, not just
|
||||
# the admin who set it.
|
||||
anon_ctx: BrowserContext | None = None
|
||||
try:
|
||||
anon_ctx = browser.new_context()
|
||||
anon = anon_ctx.new_page()
|
||||
anon.set_default_timeout(30_000)
|
||||
anon.goto(app_url + "/")
|
||||
_assert_raw_tag(anon.content(), PALETTE)
|
||||
_wait_theme_computed(anon, PALETTE)
|
||||
# The B4 split: the 3 strings are NOT pre-paint — they apply
|
||||
# post-fetch via the /api/config boot fetch (brand.js) on the
|
||||
# anonymous page too: the name (header brand + window
|
||||
# global), the placeholder, and the footer line.
|
||||
expect(anon.locator(".brand-text")).to_have_text(APP_NAME, timeout=15_000)
|
||||
assert anon.evaluate("() => window.BOR_BRAND") == APP_NAME
|
||||
expect(anon.locator("#message-input")).to_have_attribute(
|
||||
"placeholder", PLACEHOLDER
|
||||
)
|
||||
expect(anon.locator(".footer-text").first).to_have_text(FOOTER)
|
||||
finally:
|
||||
if anon_ctx is not None:
|
||||
anon_ctx.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. The gate + the 403s: anonymous sees the gate (never the form),
|
||||
# the API 403s anonymous AND token users, the nav link is admin-only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_and_token_user_are_walled(
|
||||
page: Page, browser: Browser, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
# --- anonymous: the gate, the hidden form, the hidden nav link ---
|
||||
page.goto(app_url + "/theme.html")
|
||||
# Phase 79: an anonymous visitor meets the in-app token gate on
|
||||
# the shell — #main is inert behind it…
|
||||
expect(page.locator("#auth-gate")).to_be_visible(timeout=30_000)
|
||||
assert page.evaluate("() => document.getElementById('main').inert") is True
|
||||
# …and the Theme view's OWN gate (the exact #sources-gate
|
||||
# pattern) is the view's visible surface: the sign-in link
|
||||
# returns to the Theme view (?next=/theme.html)…
|
||||
expect(page.locator("#theme-gate")).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#theme-gate a.sources-gate-link")).to_have_attribute(
|
||||
"href", "/login.html?next=/theme.html"
|
||||
)
|
||||
# …while the form stays locked away (theme.js's non-admin
|
||||
# branch) and the admin-only nav link is hidden.
|
||||
expect(page.locator("#theme-content")).to_be_hidden()
|
||||
expect(page.locator("#nav-theme")).to_be_hidden()
|
||||
expect(page.locator("#sign-in-link")).to_be_visible()
|
||||
|
||||
# The API agrees from the context's own (empty) cookies: GET AND
|
||||
# PUT are 403 "admin only" (the whole router sits behind
|
||||
# require_admin — anonymous first).
|
||||
anon_put = page.evaluate(
|
||||
"""async () => (await fetch('/api/ui-settings', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({brand: '#4f46e5'}),
|
||||
})).status"""
|
||||
)
|
||||
assert anon_put == 403, f"anonymous PUT /api/ui-settings → {anon_put}"
|
||||
anon_get = page.evaluate(
|
||||
"() => fetch('/api/ui-settings').then((r) => r.status)"
|
||||
)
|
||||
assert anon_get == 403, f"anonymous GET /api/ui-settings → {anon_get}"
|
||||
|
||||
# --- a token user: the SAME wall (B5: admin-only, like
|
||||
# Tuning/Tokens) ---
|
||||
login(page, app_url, next="/")
|
||||
r = httpx.post(
|
||||
f"{app_url}/api/tokens",
|
||||
json={"label": "e2e-theme-wall"},
|
||||
cookies=_cookies(page),
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
token = r.json()["token"]
|
||||
|
||||
user_ctx: BrowserContext | None = None
|
||||
try:
|
||||
user_ctx = browser.new_context()
|
||||
user = user_ctx.new_page()
|
||||
user.set_default_timeout(30_000)
|
||||
login_with_token(user, app_url, token)
|
||||
# The nav link is hidden on their shell (role "user" — the
|
||||
# header reveals the admin links only for role === "admin")…
|
||||
expect(user.locator("#nav-theme")).to_be_hidden()
|
||||
# …and the API 403s their own session (authenticated, just
|
||||
# not an admin — 403, never 401).
|
||||
put_status = user.evaluate(
|
||||
"""async () => (await fetch('/api/ui-settings', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({brand: '#4f46e5'}),
|
||||
})).status"""
|
||||
)
|
||||
assert put_status == 403, f"token-user PUT /api/ui-settings → {put_status}"
|
||||
finally:
|
||||
if user_ctx is not None:
|
||||
user_ctx.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Reset: the §7.4 lifecycle, the 11 defaults, NO theme tag, and
|
||||
# byte-identical served HTML (the no-op injection contract)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reset_restores_the_builtin_byte_identical(
|
||||
page: Page, app_url: str, db_ready: None
|
||||
) -> None:
|
||||
defaults = _template_defaults()
|
||||
builtin = _builtin_colors()
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/theme.html")
|
||||
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
|
||||
# The form settles on the effective defaults (the row-less
|
||||
# state — the autouse clean truncated the row).
|
||||
_expect_form_values(page, defaults, builtin)
|
||||
|
||||
# Save a distinct theme through the UI (the reset must undo a
|
||||
# REAL save)…
|
||||
_fill_theme_form(page, PALETTE)
|
||||
_hold_theme_puts(page)
|
||||
try:
|
||||
page.click("#theme-save")
|
||||
expect(page.locator("#theme-result")).to_have_text(
|
||||
"Theme saved.", timeout=30_000
|
||||
)
|
||||
finally:
|
||||
_release_theme_puts(page)
|
||||
# …the theme is live server-side (the pre-reset baseline):
|
||||
assert "bor-theme" in httpx.get(app_url + "/", timeout=10).text
|
||||
|
||||
# Reset to defaults: the §7.4 lifecycle again, with the all-null
|
||||
# PUT (the API's documented "defaults" operation).
|
||||
_hold_theme_puts(page)
|
||||
try:
|
||||
page.click("#theme-reset")
|
||||
expect(page.locator("#theme-reset")).to_be_disabled()
|
||||
expect(page.locator("#theme-reset")).to_have_text("Resetting…")
|
||||
expect(page.locator("#theme-save")).to_be_disabled()
|
||||
expect(page.locator("#theme-result")).to_have_text(
|
||||
"Reset to the built-in theme.", timeout=30_000
|
||||
)
|
||||
expect(page.locator("#theme-reset")).to_have_text("Reset to defaults")
|
||||
expect(page.locator("#theme-reset")).to_be_enabled()
|
||||
finally:
|
||||
_release_theme_puts(page)
|
||||
|
||||
# The form re-populates to the 11 defaults (the env/built-in
|
||||
# merge, re-rendered from the refetch)…
|
||||
_expect_form_values(page, defaults, builtin)
|
||||
# …and the WCAG warning is gone (the built-in palette passes all
|
||||
# five pairs).
|
||||
expect(page.locator("#theme-contrast")).to_be_hidden()
|
||||
|
||||
# The served HTML is back to the built-in: NO theme tag anywhere
|
||||
# (the all-NULL row is the no-op)…
|
||||
r = httpx.get(app_url + "/", timeout=10)
|
||||
assert "bor-theme" not in r.text
|
||||
# …and the computed --brand is the stylesheet's built-in again.
|
||||
page.goto(app_url + "/")
|
||||
_wait_theme_computed(page, builtin)
|
||||
|
||||
# The byte-identical contract, proven end to end: the served
|
||||
# bytes of the reset (all-NULL row) deployment equal the served
|
||||
# bytes of a ROW-LESS deployment (the middleware's no-op path —
|
||||
# no tag, plain A1 CSP, identical ?v= rewrite).
|
||||
with_row = httpx.get(app_url + "/", timeout=10).content
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE ui_settings"))
|
||||
db.commit()
|
||||
without_row = httpx.get(app_url + "/", timeout=10).content
|
||||
assert with_row == without_row, (
|
||||
"a defaults-saved row must serve byte-identical HTML"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. The WCAG contrast warning: listed with the ratio on the picker's
|
||||
# input event, never blocks the save, hidden again after the reset
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_contrast_warning_does_not_block(page: Page, app_url: str, db_ready: None) -> None:
|
||||
builtin = _builtin_colors()
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/theme.html")
|
||||
expect(page.locator("#theme-content")).to_be_visible(timeout=15_000)
|
||||
# The form settles on the built-in defaults — the warning is
|
||||
# hidden (the built-in palette passes all five pairs).
|
||||
expect(page.locator("#theme-ink")).to_have_value(builtin["ink"])
|
||||
expect(page.locator("#theme-contrast")).to_be_hidden()
|
||||
|
||||
# Set ONLY --ink to a color within 0.1 ratio of --bg: the
|
||||
# picker's input event previews it live AND re-runs the five
|
||||
# pairs — --ink on --bg (and --ink on --surface, the ink is now
|
||||
# the darker side of that pair too) fail, and each failing pair
|
||||
# is listed with its ratio in the role=alert line.
|
||||
page.fill("#theme-ink", FAILING_INK)
|
||||
contrast = page.locator("#theme-contrast")
|
||||
expect(contrast).to_have_attribute("role", "alert")
|
||||
expect(contrast).to_be_visible(timeout=15_000)
|
||||
expect(contrast).to_contain_text("--ink on --bg: 1.0:1 — needs 4.5:1")
|
||||
expect(contrast).to_contain_text("--ink on --surface: 1.0:1 — needs 4.5:1")
|
||||
|
||||
# WARNING-ONLY: Save is never disabled by the warning (the
|
||||
# owner's homelab palette — the built-in stays AA, so the
|
||||
# default deployment is warning-free).
|
||||
assert page.locator("#theme-save").is_enabled()
|
||||
_hold_theme_puts(page)
|
||||
try:
|
||||
page.click("#theme-save")
|
||||
expect(page.locator("#theme-result")).to_have_text(
|
||||
"Theme saved.", timeout=30_000
|
||||
)
|
||||
# The saved palette still fails the pairs — the warning
|
||||
# tracks the SAVED state (the save's refetch re-checks it).
|
||||
expect(contrast).to_be_visible()
|
||||
finally:
|
||||
_release_theme_puts(page)
|
||||
|
||||
# Restore: Reset clears the failing pick (the suite's final
|
||||
# state is clean) and the warning hides with the AA built-ins.
|
||||
page.click("#theme-reset")
|
||||
expect(page.locator("#theme-result")).to_have_text(
|
||||
"Reset to the built-in theme.", timeout=30_000
|
||||
)
|
||||
expect(contrast).to_be_hidden()
|
||||
expect(page.locator("#theme-ink")).to_have_value(builtin["ink"])
|
||||
@@ -7,76 +7,84 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
The story gate for the **archive upload** form on the admin Sources page
|
||||
(``/git-sources.html``, phase 49 — the phase-38 "Add a local directory"
|
||||
form is gone, replaced by this form): an uploaded ``.tar``/``.tar.gz``/
|
||||
``.tgz``/``.zip`` is safely unpacked under ``BOR_UPLOAD_DIR/<name>/``
|
||||
(name = filename minus the archive suffix), the ``git_sources`` row is
|
||||
upserted (``kind='local'``, no duplicates), and the source is **scanned
|
||||
in a background task** (phase 64, task 03 — owner-locked A1: the POST
|
||||
answers **202 the moment the archive is safely on disk** — the
|
||||
"Successfully uploaded — <source>" toast fires then and the user may
|
||||
navigate away — while unpack → swap → row upsert → model check →
|
||||
single-source ``import_sources`` with ``prune=True`` + the change-gated
|
||||
overview refresh run server-side behind
|
||||
``GET /api/git-sources/upload/status``, the phase-32 ``SyncStatus``
|
||||
pattern with the phase-64 ``current_file``/counts). The result line and
|
||||
the row land from the status ``success`` (same ``UploadOut`` counts,
|
||||
uncompressed in shape) — the real pipeline, against the deterministic
|
||||
mock LLM (no real models, no network beyond the app itself).
|
||||
form is gone, replaced by this form): an uploaded
|
||||
``.tar``/``.tar.gz``/``.tgz``/``.zip`` is safely unpacked under
|
||||
``BOR_UPLOAD_DIR/<name>/`` (name = filename minus the archive suffix)
|
||||
and the ``git_sources`` row is upserted (``kind='local'``, no
|
||||
duplicates) — and **nothing else** (phase 90, owner-locked A1: no
|
||||
model check, no import, no overview refresh). The POST answers **202
|
||||
the moment the archive is safely on disk** (the phase-64 A1 contract —
|
||||
the "Successfully uploaded — <source>" toast fires then and the user
|
||||
may navigate away) while the unpack → swap → row upsert run continues
|
||||
server-side behind ``GET /api/git-sources/upload/status`` (the
|
||||
phase-32 ``SyncStatus`` pattern; the phase-64 key set with
|
||||
``current_file``/``files_done``/``files_total`` null/0/0 for the whole
|
||||
run — phase 90 A2). The settled result line points at the next step:
|
||||
"Uploaded <name> — press Sync sources to import it." (phase 90 A3) —
|
||||
the scan is the RAG page's **Sync sources** button's job, so this
|
||||
suite asserts **zero indexed documents** after every upload.
|
||||
|
||||
**Timing fixture (phase 64):** the mock LLM answers instantly, so this
|
||||
module's app boots behind ``tests/e2e/slow_llm.py`` — a delay-injecting
|
||||
reverse proxy in front of it (``SLOW_DELAY_S`` per request). A 2-file
|
||||
scan is 5 LLM requests ≈ 5 × 0.6 s ≈ 3 s: long enough to outlive the
|
||||
UI's 2 s status poll, so the button's literal
|
||||
"Uploading… → Processing… → restored" lifecycle is observable
|
||||
(the "Processing…" tick even carries the live file, A4) instead of
|
||||
racing the mock.
|
||||
**Timing (phase 90):** the background run is unpack + register only —
|
||||
no LLM call at all — so it settles in well under the UI's 2 s status
|
||||
poll and the phase-64 slow-LLM proxy is GONE from this suite. The
|
||||
"Uploading… → Processing… → restored" lifecycle is made observable the
|
||||
old way (the POST is held in the browser via ``page.route``) plus a
|
||||
held FIRST status GET, so the bare "Processing…" in-run label (no
|
||||
file, no "(n/m)") is asserted across the whole background run.
|
||||
|
||||
The archives are **built in-test** with Python's ``tarfile`` over
|
||||
``tmp_path`` fixture files carrying markdown sentinels (``ALPHA-…`` /
|
||||
``BETA-…`` / ``GAMMA-…``) and are always named
|
||||
``e2e-upload.tar.gz`` — so the source name is ``e2e-upload`` and
|
||||
re-uploading under the same filename exercises the in-place replace
|
||||
(one folder, one row, dropped files pruned from the KB). ``v1`` holds
|
||||
``alpha.md`` + ``beta.md``; ``v2`` (same basename) modifies ``alpha``,
|
||||
drops ``beta``, adds ``gamma``.
|
||||
(one folder, one row). ``v1`` holds ``alpha.md`` + ``beta.md``;
|
||||
``v2`` (same basename) modifies ``alpha``, drops ``beta``, adds
|
||||
``gamma`` — the in-place-replace subject (the on-disk folder swap).
|
||||
|
||||
Per-module app env (the conftest pattern, module-scoped — as in
|
||||
``test_git_sources_admin.py`` / ``test_local_directory_sources.py``):
|
||||
``BOR_UPLOAD_DIR`` points at a scratch dir the suite can inspect from
|
||||
the host (the app runs on the same machine), and
|
||||
``BOR_GIT_SOURCES`` is forced empty so the dev ``.env``'s fallback URL
|
||||
never renders as an env row on the (initially empty) table.
|
||||
the host (the app runs on the same machine), ``BOR_GIT_SOURCES`` is
|
||||
forced empty so the dev ``.env``'s fallback URL never renders as an
|
||||
env row on the (initially empty) table, and ``BOR_LLM_BASE_URL`` is
|
||||
the mock LLM (no LLM call happens in this suite at all — phase 90
|
||||
removed the upload's only LLM leg; the mock keeps the env shape
|
||||
honest).
|
||||
|
||||
Contract under test:
|
||||
|
||||
* the **swap** (task 03): the phase-38 local form is gone (count 0);
|
||||
the upload form is in its place with the labeled file input (accept
|
||||
= the four archive extensions), the "Upload & scan" button, and the
|
||||
hint explains unpack/scan + in-place replace;
|
||||
* **upload → 202 + toast → background scan → list** (§7.4 never-stale,
|
||||
phase-64 A1/A2): the button shows "Uploading…" while the POST is in
|
||||
flight (the request is held in the browser via ``page.route`` so the
|
||||
in-flight state is deterministic); at the 202 the "Successfully
|
||||
uploaded — <source>" toast fires (``.toast.is-visible``,
|
||||
``role="status"``) WHILE the scan is still running, and the button
|
||||
hands over to the scan — "Processing…" (the live-file tick carries
|
||||
the current file, A4) — then restores when the status ``success``
|
||||
lands: the result line shows the added count; the list gains exactly
|
||||
one row for ``e2e-upload`` with the **Local** badge; ``GET
|
||||
/api/docs`` lists both sentinel files under source ``e2e-upload``;
|
||||
the RAG catalog (``/sources.html``) shows them;
|
||||
* **re-upload, same filename** → in-place replace: the result line
|
||||
shows the prune, the SECOND RUN'S STATUS ``detail`` carries the
|
||||
prune/refresh counts, the list still has exactly ONE ``e2e-upload``
|
||||
row (no duplicate), the KB shows the changed ``alpha`` + the new
|
||||
``gamma`` and NOT the dropped ``beta``, and the on-disk folder holds
|
||||
only the new archive's files;
|
||||
* the **swap**: the phase-38 local form is gone (count 0); the upload
|
||||
form is in its place with the labeled file input (accept = the four
|
||||
archive extensions), the "Upload" button (phase 90 A3 — was "Upload
|
||||
& scan"), and the hint explaining unpack + register only (in-place
|
||||
replace, the "Sync sources" next step, the ignore-paths edit) with
|
||||
no "unpack and scan" claim;
|
||||
* **upload → 202 + toast → unpack-only processing → ready-for-sync**
|
||||
(§7.4 never-stale, phase-64 A1/A2, phase-90 A2/A3): the button shows
|
||||
"Uploading…" while the POST is in flight (held via ``page.route``);
|
||||
at the 202 the "Successfully uploaded — <source>" toast fires; the
|
||||
button then carries the BARE "Processing…" label for the whole
|
||||
background run (no file, no "(n/m)" — the first status GET is held
|
||||
so the in-run window outlives the 2 s poll) until the status
|
||||
``success`` lands: the result line reads "Uploaded e2e-upload —
|
||||
press Sync sources to import it.", the button restores ("Upload"),
|
||||
the file input clears, the list gains exactly one row for
|
||||
``e2e-upload`` with the **Local** badge and its "Ignore paths"
|
||||
control (the phase-89 editor the deferral exists for), the terminal
|
||||
status carries the no-count ``{"message": "uploaded"}`` detail with
|
||||
null/0/0 progress — and **zero documents are indexed**:
|
||||
``GET /api/docs`` is empty and the RAG catalog (``/sources.html``)
|
||||
shows no rows;
|
||||
* **re-upload, same filename** → in-place replace, still no index:
|
||||
the folder on disk holds only the new archive's files (the atomic
|
||||
swap), the list still has exactly ONE ``e2e-upload`` row (no
|
||||
duplicate), the result line points at Sync again after each run,
|
||||
and the KB stays empty;
|
||||
* **bad file** → inline 422 (role=alert) naming the accepted formats
|
||||
(UNCHANGED — the name/format/cap gates are inline, pre-202, exactly
|
||||
as before), button restored, the file selection kept, the list
|
||||
unchanged, and a subsequent good upload still works (the form is not
|
||||
wedged);
|
||||
as before), button restored ("Upload"), the file selection kept, the
|
||||
list unchanged, and a subsequent good upload still works (the form
|
||||
is not wedged) — and still indexes nothing;
|
||||
* **anonymous** → the sign-in gate (``#git-sources-gate``) shows, the
|
||||
manager (and thus the upload form) stays hidden, and
|
||||
``POST /api/git-sources/upload`` is 403 — as is the phase-64
|
||||
@@ -84,7 +92,7 @@ Contract under test:
|
||||
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
1. ``test_form_swapped``
|
||||
2. ``test_upload_scans_and_lists``
|
||||
2. ``test_upload_registers_without_indexing``
|
||||
3. ``test_reupload_replaces_in_place``
|
||||
4. ``test_bad_file_inline_error``
|
||||
5. ``test_anonymous_gate``
|
||||
@@ -110,7 +118,6 @@ from app.db import SessionLocal
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import (
|
||||
ADMIN_PASSWORD,
|
||||
MOCK_PORT,
|
||||
SESSION_SECRET,
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
@@ -125,17 +132,6 @@ REPO = Path(__file__).resolve().parents[2]
|
||||
APP_PORT = int(os.environ.get("E2E_APP_PORT_ARCHIVE", "8124"))
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
#: The slow-LLM proxy's port (the conftest's mock LLM stays on MOCK_PORT).
|
||||
SLOW_PORT = int(os.environ.get("E2E_SLOW_LLM_PORT", "8902"))
|
||||
SLOW_URL = f"http://127.0.0.1:{SLOW_PORT}"
|
||||
|
||||
#: Per-LLM-request delay on the proxy — a 2-file scan is 5 LLM requests
|
||||
#: (the check_models embed + chat probe, one embed per file, the
|
||||
#: change-gated overview chat) ≈ 5 × 0.6 s ≈ 3 s: the scan outlives the
|
||||
#: UI's 2 s status poll, so the button's "Uploading… → Processing… →
|
||||
#: restored" lifecycle (with the live-file tick, A4) is observable.
|
||||
SLOW_DELAY_S = "0.6"
|
||||
|
||||
GIT_SOURCES_URL = "/git-sources.html"
|
||||
SOURCES_URL = "/sources.html"
|
||||
|
||||
@@ -144,7 +140,7 @@ SOURCES_URL = "/sources.html"
|
||||
SOURCE_NAME = "e2e-upload"
|
||||
|
||||
#: v1: two sentinel docs. v2 (same filename): alpha CHANGED, beta DROPPED,
|
||||
#: gamma ADDED — the in-place-replace subject.
|
||||
#: gamma ADDED — the in-place-replace subject (the on-disk folder swap).
|
||||
ALPHA_SENTINEL_V1 = "ALPHA-TOKEN-v1-7f31"
|
||||
ALPHA_SENTINEL_V2 = "ALPHA-TOKEN-v2-8b42"
|
||||
BETA_SENTINEL_V1 = "BETA-TOKEN-v1-2c90"
|
||||
@@ -160,7 +156,7 @@ V1_FILES: dict[str, str] = {
|
||||
"beta.md": (
|
||||
"# Beta note\n"
|
||||
"\n"
|
||||
"Only present in v1 — v2 drops it (the prune subject).\n"
|
||||
"Only present in v1 — v2 drops it.\n"
|
||||
f"\nMarker: {BETA_SENTINEL_V1}\n"
|
||||
),
|
||||
}
|
||||
@@ -179,10 +175,10 @@ V2_FILES: dict[str, str] = {
|
||||
),
|
||||
}
|
||||
|
||||
#: The scan runs the full pipeline against the mock LLM (models probe +
|
||||
#: embed batch + per-doc summaries + the change-gated overview) —
|
||||
#: generous, like the sync suites; no client-side hard timeout.
|
||||
UPLOAD_TIMEOUT_MS = 90_000
|
||||
#: Generous settle budget: the unpack-only run settles in milliseconds
|
||||
#: (phase 90), the 4.5 s status hold dominates, and the UI's 2 s poll
|
||||
#: settles one tick after the release.
|
||||
UPLOAD_TIMEOUT_MS = 30_000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -226,47 +222,20 @@ def tarball_v2(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
return _build_targz(root / f"{SOURCE_NAME}.tar.gz", V2_FILES)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def slow_llm(mock_llm: int) -> Iterator[int]:
|
||||
"""The delay-injecting reverse proxy in front of the mock LLM
|
||||
(tests/e2e/slow_llm.py) — this suite's timing fixture: the phase-64
|
||||
button lifecycle ("Uploading… → Processing… → restored") needs the
|
||||
2-file scan to outlive the UI's 2 s status poll (see
|
||||
``SLOW_DELAY_S``)."""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["SLOW_LLM_DELAY_S"] = SLOW_DELAY_S
|
||||
env["E2E_MOCK_PORT"] = str(MOCK_PORT)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "tests.e2e.slow_llm:app",
|
||||
"--host", "127.0.0.1", "--port", str(SLOW_PORT), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
_wait_http(f"{SLOW_URL}/v1/models")
|
||||
yield SLOW_PORT
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(
|
||||
mock_llm: int,
|
||||
slow_llm: int,
|
||||
upload_dir: Path,
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
) -> Iterator[str]:
|
||||
"""The real app under test — per-module env: the LLM base URL is the
|
||||
SLOW PROXY in front of the mock (the timing fixture), uploads unpack
|
||||
into a scratch dir and the env git list is forced empty (the dev
|
||||
``.env``'s ``BOR_GIT_SOURCES`` must not render as env rows on the
|
||||
initially empty table). No sync is triggered here — the upload's own
|
||||
scan is the pipeline under test."""
|
||||
"""The real app under test — per-module env: the LLM base URL is
|
||||
the mock (phase 90 removed the upload's only LLM leg — no LLM call
|
||||
happens in this suite at all; the mock keeps the env shape
|
||||
honest), uploads unpack into a scratch dir, and the env git list
|
||||
is forced empty (the dev ``.env``'s ``BOR_GIT_SOURCES`` must not
|
||||
render as env rows on the initially empty table). No sync is
|
||||
triggered here — the upload is unpack + register only, and the
|
||||
no-index assertions are the point."""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["BOR_ENVIRONMENT"] = "e2e"
|
||||
@@ -274,7 +243,7 @@ def app_server(
|
||||
env["BOR_LLM_BASE_URL"] = (
|
||||
"https://aipi.reeseapps.com/v1"
|
||||
if USE_REAL_LLM
|
||||
else f"{SLOW_URL}/v1"
|
||||
else f"http://127.0.0.1:{mock_llm}/v1"
|
||||
)
|
||||
# Mock-calibrated threshold (conftest pattern) — no chat turn is
|
||||
# ever sent in this suite, but the app boots with the same env shape.
|
||||
@@ -316,11 +285,9 @@ def app_url(app_server: str) -> str:
|
||||
|
||||
def _truncate_all() -> None:
|
||||
"""Fresh registry + KB per test (the E2E isolation pattern): the
|
||||
upload's counts and every ``/api/docs`` assertion must be this
|
||||
test's own doing. The E2E suites share one Postgres, and a leftover
|
||||
git_sources row or document would corrupt the row-count and doc-list
|
||||
assertions (and a leftover document under the same source name would
|
||||
survive the re-upload's single-source prune)."""
|
||||
row-count and doc-list assertions must be this test's own doing.
|
||||
The E2E suites share one Postgres, and a leftover git_sources row
|
||||
or document would corrupt them."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview, git_sources"))
|
||||
db.commit()
|
||||
@@ -371,30 +338,12 @@ def _upload_via_page(page: Page, archive: Path) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def _wait_upload_running(page: Page, app_url: str, timeout_s: float = 15.0) -> dict[str, Any]:
|
||||
"""Poll (cookie-authenticated) the upload status endpoint until the
|
||||
run is ``running`` — the phase-64 single source of truth for the
|
||||
background scan (A1)."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
body: dict[str, Any] = {}
|
||||
while time.monotonic() < deadline:
|
||||
r = page.request.get(f"{app_url}/api/git-sources/upload/status")
|
||||
assert r.status == 200, r.text
|
||||
body = r.json()
|
||||
if body["state"] == "running":
|
||||
return body
|
||||
if body["state"] in ("success", "failed"):
|
||||
raise AssertionError(f"the scan settled too fast to observe: {body}")
|
||||
time.sleep(0.1)
|
||||
raise AssertionError(f"the scan never entered running: {body}")
|
||||
|
||||
|
||||
def _hold_upload_request(page: Page, hold_s: float) -> None:
|
||||
"""Intercept the upload POST and hold the REQUEST in the browser for
|
||||
``hold_s`` seconds before letting it reach the server. While it is
|
||||
held, the page's fetch is guaranteed pending — so the §7.4 in-flight
|
||||
state (disabled button, "Uploading…" label) is observable
|
||||
deterministically instead of racing the mock LLM's fast scan."""
|
||||
deterministically."""
|
||||
|
||||
def handle(route: Any) -> None:
|
||||
time.sleep(hold_s)
|
||||
@@ -403,6 +352,26 @@ def _hold_upload_request(page: Page, hold_s: float) -> None:
|
||||
page.route("**/api/git-sources/upload", handle)
|
||||
|
||||
|
||||
def _hold_first_status_fetch(page: Page, hold_s: float) -> None:
|
||||
"""Intercept the upload-status GETs and hold ONLY THE FIRST one for
|
||||
``hold_s`` seconds (later fetches pass straight through). Install
|
||||
AFTER the page's boot re-attach fetch, before the submit. The
|
||||
poll's first tick fires 2 s after the 202; holding its fetch keeps
|
||||
the button in the in-run state long enough to assert the bare
|
||||
"Processing…" label (no file, no "(n/m)") across the whole
|
||||
background run — phase 90's run settles in milliseconds, so without
|
||||
the hold the in-run window is only the 2 s pre-tick gap."""
|
||||
state = {"held": False}
|
||||
|
||||
def handle(route: Any) -> None:
|
||||
if not state["held"]:
|
||||
state["held"] = True
|
||||
time.sleep(hold_s)
|
||||
route.continue_()
|
||||
|
||||
page.route("**/api/git-sources/upload/status", handle)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The swap: local form out, upload form in
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -411,8 +380,10 @@ def _hold_upload_request(page: Page, hold_s: float) -> None:
|
||||
def test_form_swapped(page: Page, app_url: str, db_ready: None) -> None:
|
||||
"""The phase-38 "Add a local directory" form is GONE and the archive
|
||||
upload form stands in its place: visible file input (accept = the
|
||||
four archive extensions), the "Upload & scan" button, and a hint
|
||||
that explains the unpack/scan + in-place-replace semantics."""
|
||||
four archive extensions), the "Upload" button (phase 90 A3 — the
|
||||
scan-suffixed label is gone), and a hint that explains the unpack
|
||||
+ register semantics (in-place replace, the "Sync sources" next
|
||||
step, the ignore-paths edit) with no claim that an upload scans."""
|
||||
page.set_default_timeout(30_000)
|
||||
_admin_git_sources_page(page, app_url)
|
||||
|
||||
@@ -431,40 +402,48 @@ def test_form_swapped(page: Page, app_url: str, db_ready: None) -> None:
|
||||
btn = page.locator("#archive-upload-btn")
|
||||
expect(btn).to_be_visible()
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).to_have_text("Upload & scan")
|
||||
expect(btn).to_have_text("Upload")
|
||||
# The error/result lines ship (hidden) with the right roles.
|
||||
assert page.locator("#archive-upload-error").get_attribute("role") == "alert"
|
||||
result = page.locator("#archive-upload-result")
|
||||
assert result.get_attribute("role") == "status"
|
||||
expect(result).to_be_hidden()
|
||||
|
||||
# The hint explains unpack/scan + in-place replace (task 03).
|
||||
# The hint explains unpack + register ONLY (phase 90): in-place
|
||||
# replace, the "Sync sources" next step, the ignore-paths edit —
|
||||
# and no "unpack and scan" claim.
|
||||
hint = page.locator("#git-sources-hint")
|
||||
expect(hint).to_be_visible()
|
||||
expect(hint).to_contain_text("unpack")
|
||||
expect(hint).to_contain_text("scan")
|
||||
expect(hint).to_contain_text("unpack and register")
|
||||
expect(hint).to_contain_text("in place")
|
||||
expect(hint).to_contain_text("Sync sources")
|
||||
expect(hint).to_contain_text("ignore paths")
|
||||
assert "unpack and scan" not in (hint.text_content() or "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Upload → scan → list (the §7.4 in-flight state, the counts, the
|
||||
# Local row, the KB, the RAG catalog)
|
||||
# 2. Upload → 202 + toast → unpack-only processing → ready-for-sync line,
|
||||
# the Local row (+ Ignore paths control), ZERO documents indexed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_upload_scans_and_lists(
|
||||
def test_upload_registers_without_indexing(
|
||||
page: Page, app_url: str, db_ready: None, tarball_v1: Path, upload_dir: Path
|
||||
) -> None:
|
||||
"""One real upload through the page: while the POST is in flight the
|
||||
button is disabled and reads "Uploading…"; at the 202 the
|
||||
"Successfully uploaded — <source>" toast fires (A2) WHILE the
|
||||
background scan is still running and the button hands over to it —
|
||||
"Processing…" (the 2 s poll tick carries the live file, A4); when
|
||||
the status ``success`` lands the button restores, the result line
|
||||
shows the added count (2), the file input clears, the list gains
|
||||
exactly ONE row for ``e2e-upload`` with the Local badge,
|
||||
``/api/docs`` lists both sentinel files under the source, and the
|
||||
RAG catalog shows them where the admin expects them."""
|
||||
"Successfully uploaded — <source>" toast fires (A2); the button
|
||||
then carries the BARE "Processing…" label for the whole background
|
||||
run (no file, no "(n/m)" — phase 90 A2, proven across a held first
|
||||
status GET); when the status ``success`` lands the result line
|
||||
reads "Uploaded <source> — press Sync sources to import it."
|
||||
(A3), the button restores ("Upload"), the file input clears, the
|
||||
list gains exactly ONE row for ``e2e-upload`` (Local badge, the
|
||||
"Ignore paths" control), the terminal status carries the no-count
|
||||
``{"message": "uploaded"}`` detail with null/0/0 progress — and
|
||||
**zero documents are indexed**: ``/api/docs`` is empty and the RAG
|
||||
catalog shows no rows (the scan is the Sync button's job, phase 90
|
||||
A1)."""
|
||||
page.set_default_timeout(30_000)
|
||||
_admin_git_sources_page(page, app_url)
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(0)
|
||||
@@ -473,8 +452,12 @@ def test_upload_scans_and_lists(
|
||||
result = page.locator("#archive-upload-result")
|
||||
|
||||
# Hold the upload request in the browser: the in-flight state below
|
||||
# cannot race the receive while it is held.
|
||||
# cannot race the receive while it is held. Hold the FIRST status
|
||||
# GET too (installed now — after the boot re-attach fetch — so only
|
||||
# the poll's ticks hit it): the bare in-run label gets a window
|
||||
# wider than the 2 s pre-tick gap.
|
||||
_hold_upload_request(page, hold_s=0.8)
|
||||
_hold_first_status_fetch(page, hold_s=4.5)
|
||||
page.set_input_files("#archive-upload-file", str(tarball_v1))
|
||||
btn.click()
|
||||
|
||||
@@ -485,9 +468,9 @@ def test_upload_scans_and_lists(
|
||||
|
||||
# The request goes out; the server stores the archive and answers
|
||||
# 202 the moment it is safely on disk (A1) → the toast fires NOW
|
||||
# (A2) — while the scan is still running — and the button hands
|
||||
# over to the scan (bare "Processing…" — A4: no file yet during the
|
||||
# unpack phase).
|
||||
# (A2) — and the button hands over to the background run as the
|
||||
# BARE "Processing…" (phase 90 A2: the unpack has no file-level
|
||||
# progress — no file, no counts, no title).
|
||||
toast = page.locator(".toast")
|
||||
expect(toast).to_have_count(1, timeout=UPLOAD_TIMEOUT_MS)
|
||||
expect(toast).to_have_class(re.compile(r"\bis-visible\b"))
|
||||
@@ -497,27 +480,37 @@ def test_upload_scans_and_lists(
|
||||
expect(btn).to_be_disabled()
|
||||
expect(btn).to_have_text("Processing…", timeout=5_000)
|
||||
|
||||
# The scan is running server-side (the status endpoint is the
|
||||
# single source of truth, A1) — the run the UI's poll tracks.
|
||||
_wait_upload_running(page, app_url)
|
||||
# The run is in flight (or just settled) server-side — and the
|
||||
# label STAYS bare across the whole background run: the first
|
||||
# status GET is held, so the tick that should settle the button is
|
||||
# in flight — the label carries no file and no "(n/m)".
|
||||
time.sleep(2.5) # just past the poll's first tick (the fetch is held)
|
||||
expect(btn).to_have_text("Processing…")
|
||||
|
||||
# …and the button's 2 s poll tick renders the live file label
|
||||
# ("Processing… <file> (n/m)", A4).
|
||||
expect(btn).to_have_text(
|
||||
re.compile(rf"Processing… {re.escape(SOURCE_NAME)}/.+\.md"),
|
||||
timeout=UPLOAD_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
# The status success lands → the result line (the same UploadOut
|
||||
# counts) + the never-stale restore (input cleared).
|
||||
# …release: the status success lands → the ready-for-sync line +
|
||||
# the never-stale restore (input cleared).
|
||||
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
||||
expect(result).to_have_text("2 added")
|
||||
expect(result).to_have_text(
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it."
|
||||
)
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).to_have_text("Upload & scan")
|
||||
expect(btn).to_have_text("Upload")
|
||||
expect(page.locator("#archive-upload-file")).to_have_value("")
|
||||
|
||||
# The terminal status: the no-count payload with null/0/0 progress
|
||||
# (the phase-64 key set, phase 90 A2).
|
||||
r = page.request.get(f"{app_url}/api/git-sources/upload/status")
|
||||
assert r.status == 200, r.text
|
||||
status = r.json()
|
||||
assert status["state"] == "success", status
|
||||
assert status["detail"] == {"message": "uploaded"}, status
|
||||
assert status["current_file"] is None
|
||||
assert status["files_done"] == 0 and status["files_total"] == 0
|
||||
|
||||
# The list gained exactly one row — for the source, with the Local
|
||||
# badge and the full unpacked path in the mono cell.
|
||||
# badge, the full unpacked path in the mono cell, and its
|
||||
# "Ignore paths" control (the phase-89 editor the deferral exists
|
||||
# for).
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(1, timeout=30_000)
|
||||
row = page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)
|
||||
expect(row).to_have_count(1)
|
||||
@@ -525,21 +518,19 @@ def test_upload_scans_and_lists(
|
||||
expect(row.locator("td.git-source-url-cell code")).to_have_text(
|
||||
str(upload_dir / SOURCE_NAME)
|
||||
)
|
||||
expect(row.locator("button.git-source-ignore")).to_have_count(1)
|
||||
expect(row.locator("button.git-source-ignore")).to_have_text("Ignore paths")
|
||||
|
||||
# The KB: both sentinel files, under the source name e2e-upload.
|
||||
assert _docs(page, app_url) == [(SOURCE_NAME, "alpha.md"), (SOURCE_NAME, "beta.md")]
|
||||
|
||||
# The RAG catalog (admin sees it): both docs, under the source.
|
||||
# Phase 90 A1: the upload indexes NOTHING — the KB is empty…
|
||||
assert _docs(page, app_url) == []
|
||||
# …and so is the RAG catalog (the scan is the Sync button's job).
|
||||
page.goto(app_url + SOURCES_URL)
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(2)
|
||||
expect(page.locator("#docs-tbody tr", has_text="alpha.md")).to_have_count(1)
|
||||
expect(page.locator("#docs-tbody tr", has_text="beta.md")).to_have_count(1)
|
||||
expect(page.locator("#docs-tbody tr", has_text=SOURCE_NAME)).to_have_count(2)
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Re-upload, same filename → in-place replace (no duplicate row,
|
||||
# dropped file pruned, changed/new file indexed)
|
||||
# folder swap on disk, still nothing indexed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -551,39 +542,31 @@ def test_reupload_replaces_in_place(
|
||||
tarball_v2: Path,
|
||||
upload_dir: Path,
|
||||
) -> None:
|
||||
"""v1 then v2 under the SAME filename (``e2e-upload.tar.gz``): the
|
||||
result line shows the prune, the SECOND RUN'S STATUS ``detail``
|
||||
carries the prune/refresh counts (phase 64 — the line is rendered
|
||||
from the status success), the list still has exactly ONE
|
||||
``e2e-upload`` row (the row count for that source is invariant — no
|
||||
duplicate), the KB shows the changed ``alpha`` + the new ``gamma``
|
||||
and NOT the dropped ``beta``, and the on-disk folder holds only the
|
||||
new archive's files."""
|
||||
"""v1 then v2 under the SAME filename (``e2e-upload.tar.gz``) —
|
||||
both unpack + register only (phase 90): the list still has exactly
|
||||
ONE ``e2e-upload`` row after the re-upload (no duplicate — the
|
||||
in-place identity), the on-disk folder holds only the new
|
||||
archive's files (the atomic swap), the result line points at
|
||||
"Sync sources" after each run, and the KB stays EMPTY throughout
|
||||
(the scan is the Sync button's job)."""
|
||||
page.set_default_timeout(30_000)
|
||||
_admin_git_sources_page(page, app_url)
|
||||
|
||||
# Baseline: v1 through the page (202 → "2 added", one row).
|
||||
assert _upload_via_page(page, tarball_v1) == "2 added"
|
||||
# Baseline: v1 through the page (202 → the ready-for-sync line,
|
||||
# one row, no index).
|
||||
assert _upload_via_page(page, tarball_v1) == (
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it."
|
||||
)
|
||||
expect(page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)).to_have_count(1)
|
||||
folder = upload_dir / SOURCE_NAME
|
||||
assert {p.name for p in folder.iterdir()} == set(V1_FILES)
|
||||
assert _docs(page, app_url) == []
|
||||
|
||||
# Re-upload v2 — SAME basename, different parent dir (the file
|
||||
# input's selection is replaced wholesale).
|
||||
assert _upload_via_page(page, tarball_v2) is not None
|
||||
result = page.locator("#archive-upload-result")
|
||||
expect(result).to_have_text(re.compile(r"\d+ pruned"))
|
||||
|
||||
# The SECOND RUN's status ``detail`` shows the prune/refresh counts
|
||||
# (phase 64: the result line is rendered from this success).
|
||||
r = page.request.get(f"{app_url}/api/git-sources/upload/status")
|
||||
assert r.status == 200, r.text
|
||||
status = r.json()
|
||||
assert status["state"] == "success", status
|
||||
detail = status["detail"]
|
||||
assert detail["source"] == SOURCE_NAME
|
||||
assert detail["files"] == 2
|
||||
assert detail["added"] == 1 # gamma — new in v2
|
||||
assert detail["updated"] == 1 # alpha — changed in v2
|
||||
assert detail["pruned"] == 1 # beta — dropped in v2
|
||||
assert _upload_via_page(page, tarball_v2) == (
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it."
|
||||
)
|
||||
|
||||
# No duplicate: exactly ONE row for that source (and one row total).
|
||||
expect(page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)).to_have_count(1)
|
||||
@@ -596,21 +579,12 @@ def test_reupload_replaces_in_place(
|
||||
("local", str(upload_dir / SOURCE_NAME))
|
||||
]
|
||||
|
||||
# The KB: gamma + the CHANGED alpha, NOT the dropped beta.
|
||||
assert _docs(page, app_url) == [(SOURCE_NAME, "alpha.md"), (SOURCE_NAME, "gamma.md")]
|
||||
# …and the indexed alpha is the v2 one (in-place replace, proven in
|
||||
# the KB, not just the filesystem).
|
||||
content = page.request.get(
|
||||
f"{app_url}/api/documents/content?source={SOURCE_NAME}&path=alpha.md"
|
||||
)
|
||||
assert content.status == 200, content.text
|
||||
assert ALPHA_SENTINEL_V2 in content.json()["content"]
|
||||
assert ALPHA_SENTINEL_V1 not in content.json()["content"]
|
||||
|
||||
# The on-disk folder holds ONLY v2's files (the swap replaced the
|
||||
# whole folder — no stale v1 file survived).
|
||||
folder = upload_dir / SOURCE_NAME
|
||||
# whole folder in place — no stale v1 file survived)…
|
||||
assert {p.name for p in folder.iterdir()} == set(V2_FILES)
|
||||
# …and the KB is STILL empty (the upload never scans, phase 90 A1
|
||||
# — the Sync button is what will index v2's files).
|
||||
assert _docs(page, app_url) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -625,7 +599,7 @@ def test_bad_file_inline_error(
|
||||
422 detail naming the accepted formats, the button restores, the
|
||||
file selection is KEPT (the fix is one re-pick), the list is
|
||||
unchanged — and a subsequent good upload still works (the form is
|
||||
not wedged)."""
|
||||
not wedged), indexing nothing (phase 90 A1)."""
|
||||
page.set_default_timeout(30_000)
|
||||
_admin_git_sources_page(page, app_url)
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(0)
|
||||
@@ -645,7 +619,7 @@ def test_bad_file_inline_error(
|
||||
|
||||
# Never stale + the selection kept + no result line + list unchanged.
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).to_have_text("Upload & scan")
|
||||
expect(btn).to_have_text("Upload")
|
||||
# The selection is kept (the fix is one re-pick) — Chromium reports
|
||||
# a fake path (``…/notes.txt``), so assert on the basename.
|
||||
bad_value = page.locator("#archive-upload-file").input_value()
|
||||
@@ -653,11 +627,14 @@ def test_bad_file_inline_error(
|
||||
expect(page.locator("#archive-upload-result")).to_be_hidden()
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(0)
|
||||
|
||||
# The form is not wedged: a good upload right after still works.
|
||||
assert _upload_via_page(page, tarball_v1) == "2 added"
|
||||
# The form is not wedged: a good upload right after still works —
|
||||
# and indexes nothing (phase 90 A1).
|
||||
assert _upload_via_page(page, tarball_v1) == (
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it."
|
||||
)
|
||||
expect(error).to_be_hidden()
|
||||
expect(page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)).to_have_count(1)
|
||||
assert _docs(page, app_url) == [(SOURCE_NAME, "alpha.md"), (SOURCE_NAME, "beta.md")]
|
||||
assert _docs(page, app_url) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -331,7 +331,6 @@ def app_server(mock_llm: int, slow_llm: int) -> Iterator[str]:
|
||||
)
|
||||
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(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
||||
|
||||
@@ -148,25 +148,28 @@ def test_api_config_serves_both_names(testy_server: str, app_server: str) -> Non
|
||||
# Phase 59 (task 05): the third key is the docs-push flag — the
|
||||
# "Save as doc" gating; both instances run with BOR_DOCS_REPO
|
||||
# empty, so it is the inert false here. Phase 62 (task 01): the
|
||||
# endpoint grew to six keys — this suite's instances carry no
|
||||
# UI-customization overrides, so the three new keys are their
|
||||
# defaults.
|
||||
# endpoint grew with the UI-customization keys; phase 91
|
||||
# (task 03) deleted the retired CSS-file theming's ``theme`` key —
|
||||
# the five keys below are the entire contract (this suite's
|
||||
# instances carry no UI-customization overrides, so the string
|
||||
# keys are their defaults).
|
||||
assert set(body) == {
|
||||
"app_name", "version", "docs_repo_configured",
|
||||
"input_placeholder", "footer_text", "theme",
|
||||
"input_placeholder", "footer_text",
|
||||
}
|
||||
assert body["app_name"] == TESTY_NAME
|
||||
assert body["docs_repo_configured"] is False
|
||||
|
||||
# The shared conftest instance keeps the default (the other
|
||||
# suites' title/label contract rides on it) — and its key set
|
||||
# grew with the endpoint (phase 62).
|
||||
# tracks the endpoint contract (five keys after phase 91,
|
||||
# task 03).
|
||||
r2 = httpx.get(f"{app_server}/api/config", timeout=5)
|
||||
assert r2.status_code == 200
|
||||
r2_body = r2.json()
|
||||
assert set(r2_body) == {
|
||||
"app_name", "version", "docs_repo_configured",
|
||||
"input_placeholder", "footer_text", "theme",
|
||||
"input_placeholder", "footer_text",
|
||||
}
|
||||
assert r2_body["app_name"] == DEFAULT_NAME
|
||||
|
||||
|
||||
@@ -25,13 +25,16 @@ the test process and the app subprocess resolve the same ``.env``
|
||||
(``BOR_SOURCES_DIR`` / ``BOR_UPLOAD_DIR``) and the same-host ``pathlib``
|
||||
assertions hit the very directories the DELETE handler cleans.
|
||||
|
||||
The suite triggers **no sync** (the git rows are ``example.com`` URLs
|
||||
that are never cloned); the only real artifact is the API-driven
|
||||
upload of one small archive with a unique name (``phase69-<8-hex>.tar.gz``,
|
||||
one ``.md`` file) — its background scan runs the mock-LLM pipeline
|
||||
(no network beyond the app itself). Seeded ``Document`` rows
|
||||
(``SessionLocal``, the ``test_git_sources_admin.py`` pattern) give the
|
||||
prune assertions a deterministic KB.
|
||||
The suite triggers **one sync** (test 1 only — phase 90: the upload's
|
||||
background run unpacks + registers only, so the uploaded row is
|
||||
imported via ``POST /api/sync`` on the mock-LLM pipeline, no network
|
||||
beyond the app itself); the git rows are ``example.com`` URLs that
|
||||
are never cloned, and every other test stays sync-free. The only real
|
||||
on-disk artifact is the API-driven upload of one small archive with a
|
||||
unique name (``phase69-<8-hex>.tar.gz``, one ``.md`` file). Seeded
|
||||
``Document`` rows (``SessionLocal``, the
|
||||
``test_git_sources_admin.py`` pattern) give the prune assertions a
|
||||
deterministic KB.
|
||||
|
||||
Per-module app env (the conftest pattern, module-scoped): the same env
|
||||
shape as ``test_git_sources_admin.py`` with ``BOR_GIT_SOURCES`` forced
|
||||
@@ -41,14 +44,15 @@ the table); **no** ``BOR_SOURCES_DIR`` / ``BOR_UPLOAD_DIR`` override
|
||||
|
||||
Contract under test:
|
||||
|
||||
* **upload → modal → total removal**: the uploaded folder exists on
|
||||
disk and its document is in ``GET /api/docs``; the row's Remove →
|
||||
the alertdialog opens (``#remove-confirm-source`` = the upload path,
|
||||
focus on ``#remove-confirm-cancel``) → "Remove source" → the
|
||||
"Removing…" in-flight state (both buttons disabled) → settled: the
|
||||
row is gone, the document is pruned, **the folder is gone from
|
||||
disk**, exactly one DELETE went out, and the announcer carries the
|
||||
success line;
|
||||
* **upload → sync → modal → total removal** (phase 90: the upload
|
||||
unpacks + registers only — the sync performs the scan): the
|
||||
uploaded folder exists on disk and, after the sync, its document is
|
||||
in ``GET /api/docs``; the row's Remove → the alertdialog opens
|
||||
(``#remove-confirm-source`` = the upload path, focus on
|
||||
``#remove-confirm-cancel``) → "Remove source" → the "Removing…"
|
||||
in-flight state (both buttons disabled) → settled: the row is gone,
|
||||
the document is pruned, **the folder is gone from disk**, exactly
|
||||
one DELETE went out, and the announcer carries the success line;
|
||||
* **git checkout removal**: a seeded git row + a hand-made checkout
|
||||
dir (marker file) + a seeded document → modal removal → the row is
|
||||
gone, **the checkout dir is gone from disk** (marker included) and
|
||||
@@ -312,7 +316,10 @@ def _build_targz(path: Path, files: dict[str, str]) -> Path:
|
||||
def _upload_and_wait_success(page: Page, app_url: str, archive: Path) -> dict[str, Any]:
|
||||
"""POST the archive through the logged-in page's request context
|
||||
(the admin cookie rides along) and poll the phase-64 status
|
||||
endpoint to ``success`` — returns the terminal status body."""
|
||||
endpoint to ``success`` — returns the terminal status body.
|
||||
Phase 90: the upload run is unpack + register only — no scan — so
|
||||
the caller follows with :func:`_run_sync` (the new owner flow) to
|
||||
import the registered row."""
|
||||
r = page.request.post(
|
||||
f"{app_url}/api/git-sources/upload",
|
||||
multipart={
|
||||
@@ -333,9 +340,29 @@ def _upload_and_wait_success(page: Page, app_url: str, archive: Path) -> dict[st
|
||||
if body["state"] == "success":
|
||||
return body
|
||||
if body["state"] == "failed":
|
||||
raise AssertionError(f"the upload scan failed: {body}")
|
||||
raise AssertionError(f"the upload run failed: {body}")
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(f"the upload scan never settled: {body}")
|
||||
raise AssertionError(f"the upload run never settled: {body}")
|
||||
|
||||
|
||||
def _run_sync(page: Page, app_url: str) -> dict[str, Any]:
|
||||
"""``POST /api/sync`` → poll ``GET /api/sync/status`` to a terminal
|
||||
state (the ``test_sync_button.py`` idiom) — phase 90: the scan the
|
||||
upload deferred lands here (the sync imports the uploaded
|
||||
``kind='local'`` row with prune). Returns the terminal body."""
|
||||
r = page.request.post(f"{app_url}/api/sync")
|
||||
assert r.status == 202, f"sync POST failed: {r.status} {r.text}"
|
||||
deadline = time.monotonic() + 60.0
|
||||
body: dict[str, Any] = {}
|
||||
while time.monotonic() < deadline:
|
||||
r = page.request.get(f"{app_url}/api/sync/status")
|
||||
assert r.status == 200, r.text
|
||||
body = r.json()
|
||||
if body["state"] in ("success", "failed"):
|
||||
assert body["state"] == "success", f"sync failed: {body}"
|
||||
return body
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(f"sync never settled: {body}")
|
||||
|
||||
|
||||
def _open_remove_modal(page: Page, value: str) -> None:
|
||||
@@ -385,11 +412,13 @@ def test_uploaded_source_removal_cleans_index_and_disk(
|
||||
page: Page, app_url: str, db_ready: None, upload_dir: Path, tmp_path: Path
|
||||
) -> None:
|
||||
"""A uniquely named archive uploaded through the API (202 → status
|
||||
success): the folder exists on disk and its document is in the KB;
|
||||
then the row's Remove → the alertdialog (the upload path named,
|
||||
focus on Cancel) → "Remove source" → the "Removing…" in-flight
|
||||
state → settled: row gone, document pruned, **the folder is gone
|
||||
from disk**, one DELETE, the announcer's success line."""
|
||||
success — phase 90: unpack + register only, no scan), then the
|
||||
sync that performs the scan (the new owner flow): the folder
|
||||
exists on disk and the document is in the KB; then the row's
|
||||
Remove → the alertdialog (the upload path named, focus on Cancel)
|
||||
→ "Remove source" → the "Removing…" in-flight state → settled:
|
||||
row gone, document pruned, **the folder is gone from disk**, one
|
||||
DELETE, the announcer's success line."""
|
||||
page.set_default_timeout(30_000)
|
||||
# Unique per run — never collides with a real (or a crashed-run's)
|
||||
# upload, so the disk assertions are safe on the shared dir.
|
||||
@@ -407,14 +436,18 @@ def test_uploaded_source_removal_cleans_index_and_disk(
|
||||
|
||||
_admin_git_sources_page(page, app_url)
|
||||
|
||||
# The API-driven upload (202) + the background scan (success).
|
||||
# The API-driven upload (202) + the background unpack + register
|
||||
# (success — the no-count payload, phase 90). Phase 90: the upload
|
||||
# does NOT scan — the scan is the sync's job (the new owner flow:
|
||||
# upload → [edit ignore list] → sync), so the uploaded row is
|
||||
# imported by a sync before the preconditions below.
|
||||
status = _upload_and_wait_success(page, app_url, archive)
|
||||
assert status["detail"]["source"] == name
|
||||
assert status["detail"]["added"] == 1
|
||||
assert status["detail"] == {"message": "uploaded"}
|
||||
_run_sync(page, app_url)
|
||||
|
||||
# Preconditions — the artifact is real: the folder on disk (the
|
||||
# app's resolved upload dir — this process resolved the same one),
|
||||
# the document in the KB, the row in the registry.
|
||||
# the document in the KB (via the sync), the row in the registry.
|
||||
folder = upload_dir / name
|
||||
assert (folder / "note.md").is_file(), f"{folder}/note.md missing on disk"
|
||||
assert _docs(page, app_url) == [(name, "note.md")]
|
||||
|
||||
@@ -4,77 +4,93 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_sync_upload_progress.py -v --no-cov
|
||||
|
||||
The story gate for the phase's executable proof (owner-locked A1–A5):
|
||||
both long-running KB jobs report **which file is being processed right
|
||||
now** — not just "Syncing…"/"Uploading…" — and the archive upload is
|
||||
fully **backgrounded**: ``POST /api/git-sources/upload`` answers 202 the
|
||||
moment the archive is on disk (the "Successfully uploaded — <file>"
|
||||
toast fires — the user may navigate away), the unpack/scan continues
|
||||
server-side behind ``GET /api/git-sources/upload/status`` (the phase-32
|
||||
``SyncStatus`` pattern), and the RAG-page sync button
|
||||
(``/sources.html``) animates with the upload's current file while that
|
||||
scan runs.
|
||||
The story gate for the phase's executable proof (owner-locked A1–A5),
|
||||
re-pointed by **phase 90** (the upload no longer scans): the
|
||||
long-running KB job that reports **which file is being processed
|
||||
right now** is the **sync** — and the archive upload is fully
|
||||
**backgrounded but unpack-only**: ``POST /api/git-sources/upload``
|
||||
answers 202 the moment the archive is on disk (the "Successfully
|
||||
uploaded — <file>" toast fires — the user may navigate away), the
|
||||
unpack → swap → row upsert continues server-side behind
|
||||
``GET /api/git-sources/upload/status`` (the phase-32 ``SyncStatus``
|
||||
pattern; the phase-64 key set with ``current_file``/``files_done``/
|
||||
``files_total`` null/0/0 for the whole run — phase 90 A2), and the
|
||||
upload's UI processing state is the BARE "Processing…" (no file, no
|
||||
"(n/m)") until the no-count "Uploaded <name> — press Sync sources to
|
||||
import it." result line lands (phase 90 A3). The scan — with its live
|
||||
file label — is the RAG page's **Sync sources** button's job, and the
|
||||
suite proves the new loop: upload → nothing indexed → **the sync that
|
||||
follows the upload shows its live file label and lands the counts**.
|
||||
|
||||
**Timing fixture (the phase's fixture note):** the mock LLM indexes
|
||||
fast — the in-progress state is real but brief (a 25-file scan against
|
||||
it takes ≈0.4 s, well under the UI's 2 s status poll). This module's
|
||||
app therefore boots behind ``tests/e2e/slow_llm.py`` — a delay-injecting
|
||||
reverse proxy in front of the mock LLM (``SLOW_DELAY_S`` per request →
|
||||
a 25-file scan is 28 LLM requests ≈ 4.2 s), so the scan outlives the
|
||||
2 s poll and the live-file label is asserted at BOTH layers the task
|
||||
pins:
|
||||
**Timing fixture (the phase's fixture note, phase-90 re-pointed):**
|
||||
the mock LLM indexes fast — an in-progress state is real but brief.
|
||||
The UPLOAD run no longer calls the LLM at all (phase 90 removed the
|
||||
model check + import), so it settles in milliseconds: the upload-side
|
||||
assertions lean on (a) the deterministic terminal status shape
|
||||
(null/0/0 progress, the ``{"message": "uploaded"}`` detail — every
|
||||
running tick the recorder catches is asserted bare) and (b) a held
|
||||
first status GET that widens the bare "Processing…" window past the
|
||||
UI's 2 s poll. The SYNC leg still needs ``tests/e2e/slow_llm.py`` —
|
||||
the delay-injecting reverse proxy in front of the mock LLM
|
||||
(``SLOW_DELAY_S`` per request → a 25-file sync is 28 LLM requests
|
||||
≈ 4.2 s) — so the sync outlives the 2 s poll and the live-file label
|
||||
is asserted at BOTH layers the task pins:
|
||||
|
||||
* **deterministic** — the status endpoints (``page.request`` / the
|
||||
concurrent recorder, ~100 ms cadence): ``state == "running"`` with a
|
||||
non-null ``current_file`` (``source/relative/path``) observed at some
|
||||
tick, the counts advancing, and the file-less ticks (unpack/row/
|
||||
model probe — A4) preceding the first file tick;
|
||||
* **UI** — polling the button labels for the ``Importing`` /
|
||||
``Syncing…`` / ``Processing…`` prefix plus a file path (generous
|
||||
timeout), which the pages' own 2 s poll ticks render.
|
||||
non-null ``current_file`` (``source/relative/path``) observed at
|
||||
some tick, the counts advancing, and the file-less ticks (model
|
||||
probe — A4) preceding the first file tick;
|
||||
* **UI** — polling the label for the ``Syncing…`` prefix plus a file
|
||||
path (generous timeout), which the page's own 2 s poll ticks render.
|
||||
|
||||
The upload archive is built in-test with Python's ``tarfile`` from
|
||||
**25 small ``.md`` files** (``e2e-prog.tar.gz`` → source ``e2e-prog``);
|
||||
the sync subject is a host temp dir (``sync-corpus/``, 25 small
|
||||
``.md`` files under ``notes/``) registered as a ``kind=local`` row —
|
||||
the ``test_sync_button.py`` / ``test_local_directory_sources.py``
|
||||
fixture styles. Per-module app env (the conftest pattern):
|
||||
**25 small ``.md`` files** (``e2e-prog.tar.gz`` → source
|
||||
``e2e-prog``); the sync subjects are the uploaded row itself (the new
|
||||
leg) and a host temp dir (``sync-corpus/``, 25 small ``.md`` files
|
||||
under ``notes/``) registered as a ``kind=local`` row — the
|
||||
``test_sync_button.py`` / ``test_local_directory_sources.py`` fixture
|
||||
styles. Per-module app env (the conftest pattern):
|
||||
``BOR_UPLOAD_DIR`` scratch, ``BOR_GIT_SOURCES`` forced empty (the sync
|
||||
sources are this suite's own local row), ``BOR_LLM_BASE_URL`` the slow
|
||||
proxy.
|
||||
sources are this suite's own local rows), ``BOR_LLM_BASE_URL`` the
|
||||
slow proxy.
|
||||
|
||||
Contract under test:
|
||||
|
||||
* **toast → navigate away (A2 + A3)**: on ``/git-sources.html`` the
|
||||
"Successfully uploaded — <source>" toast (``.toast.is-visible``,
|
||||
``role="status"``) appears while the scan is still running;
|
||||
navigating to ``/sources.html`` shows the sync button animating
|
||||
(spinner + ``aria-busy``) with the ``Importing <file>`` label; on
|
||||
completion the button settles to "Sync sources" (no error UI,
|
||||
``#sync-result`` stays empty — the upload's counts never render
|
||||
there, A3) and the catalog shows the uploaded documents (the
|
||||
phase-63 listing, untouched);
|
||||
* **upload progress (A4)**: during the scan the status endpoint
|
||||
reports a non-null ``current_file`` (``source/relative/path`` shape,
|
||||
full denominator, advancing counts) at running ticks, the upload
|
||||
button shows "Processing… <file>" (bare "Processing…" during unpack)
|
||||
before the result line lands, and the toast fired earlier in the run
|
||||
— the result line itself comes from the status ``success``;
|
||||
* **toast → navigate away → the sync does the scan (A1/A2 + phase
|
||||
90)**: on ``/git-sources.html`` the "Successfully uploaded —
|
||||
<source>" toast (``.toast.is-visible``, ``role="status"``) fires at
|
||||
the 202; navigating to ``/sources.html`` shows **zero indexed
|
||||
documents** (the upload unpacked + registered only) and the sync
|
||||
button settled idle with no error UI; clicking **Sync sources**
|
||||
then imports the uploaded row with the LIVE "Syncing… <file> (n/m)"
|
||||
label (both layers) and lands the counts ("N added", the catalog
|
||||
refreshes);
|
||||
* **upload processing (phase 90 A2)**: the upload button shows the
|
||||
BARE "Processing…" for the whole background run (no file, no
|
||||
"(n/m)", no title — proven across a held first status GET); every
|
||||
running tick the recorder catches carries a null ``current_file``
|
||||
and 0/0 counts; the terminal status is ``success`` with the no-count
|
||||
``{"message": "uploaded"}`` detail and null/0/0 progress; the
|
||||
result line points at the Sync button; the KB stays empty;
|
||||
* **sync live file (A4)**: a multi-file local source; clicking
|
||||
**Sync sources** on ``/sources.html`` shows "Syncing…" (bare, the
|
||||
pre-64 click state) then "Syncing… <file> (n/m)" (both layers), then
|
||||
the pre-64 success settle — "Synced HH:MM" + the counts result line —
|
||||
preserved, plus the file in the label;
|
||||
* **reload re-attach (A1 + A2)**: starting an upload and reloading
|
||||
``/git-sources.html`` mid-scan leaves the button in the Processing
|
||||
state (disabled) with no error banner and NO second upload (the
|
||||
status endpoint's single run is still the one from before the
|
||||
reload — pinned on its ``started_at``); it then settles with the
|
||||
result line and the list shows exactly one row for the archive.
|
||||
pre-64 click state) then "Syncing… <file> (n/m)" (both layers),
|
||||
then the pre-64 success settle — "Synced HH:MM" + the counts result
|
||||
line — preserved, plus the file in the label;
|
||||
* **reload re-attach (A1 + A2, phase 90)**: starting an upload and
|
||||
reloading ``/git-sources.html`` (the sub-second run may still be in
|
||||
flight — the boot re-attach enters the bare Processing state — or
|
||||
has settled — the boot re-renders the result line, the NAMELESS
|
||||
variant: the safe name was page-local) leaves the page with no
|
||||
error banner, no toast, and NO second upload (the status
|
||||
endpoint's single run is still the one from before the reload —
|
||||
pinned on its ``started_at``); the list shows exactly one row for
|
||||
the archive (in-place identity preserved).
|
||||
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
1. ``test_upload_toast_then_navigate_away``
|
||||
2. ``test_upload_progress_shows_current_file``
|
||||
2. ``test_upload_progress_is_bare``
|
||||
3. ``test_sync_live_file_label``
|
||||
4. ``test_upload_reattach_after_reload``
|
||||
"""
|
||||
@@ -89,6 +105,7 @@ import tarfile
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -124,16 +141,16 @@ SOURCES_URL = "/sources.html"
|
||||
SLOW_PORT = int(os.environ.get("E2E_SLOW_LLM_PORT", "8902"))
|
||||
SLOW_URL = f"http://127.0.0.1:{SLOW_PORT}"
|
||||
|
||||
#: Per-LLM-request delay on the proxy — the scan's duration becomes
|
||||
#: deterministic: an N-file archive scan issues N + 3 LLM requests
|
||||
#: (the check_models embed + chat probe, one embed per file, the
|
||||
#: change-gated overview chat), so a 25-file upload takes ≈ 28 × 0.15 s
|
||||
#: Per-LLM-request delay on the proxy — the SYNC's duration becomes
|
||||
#: deterministic: an N-file sync issues N + 3 LLM requests (the
|
||||
#: check_models embed + chat probe, one embed per file, the
|
||||
#: change-gated overview chat), so a 25-file sync takes ≈ 28 × 0.15 s
|
||||
#: ≈ 4.2 s — long enough to outlive the UI's 2 s status poll (see the
|
||||
#: module docstring's timing-fixture note).
|
||||
#: module docstring's timing-fixture note). The upload run is
|
||||
#: unaffected — phase 90 removed its LLM calls.
|
||||
SLOW_DELAY_S = "0.15"
|
||||
|
||||
#: The uploaded archive: 25 small docs under ``docs/`` (the phase's
|
||||
#: fixture note — 20+ files so the scan outlives the 2 s poll).
|
||||
#: The uploaded archive: 25 small docs under ``docs/``.
|
||||
UPLOAD_NAME = "e2e-prog"
|
||||
UPLOAD_ARCHIVE = f"{UPLOAD_NAME}.tar.gz"
|
||||
N_FILES = 25
|
||||
@@ -155,7 +172,7 @@ SYNC_FILES: dict[str, str] = {
|
||||
#: fmtSyncTime), any hour/minute (test_sync_button.py's pattern).
|
||||
SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}")
|
||||
|
||||
#: Generous settle budget: a 25-file scan against the slowed LLM is
|
||||
#: Generous settle budget: a 25-file sync against the slowed LLM is
|
||||
#: ≈4.2 s; the UI's 2 s poll settles at most one tick after the
|
||||
#: terminal state lands.
|
||||
SETTLE_TIMEOUT_MS = 45_000
|
||||
@@ -181,9 +198,9 @@ def _build_targz(path: Path, files: dict[str, str]) -> Path:
|
||||
@pytest.fixture(scope="module")
|
||||
def slow_llm(mock_llm: int) -> Iterator[int]:
|
||||
"""The delay-injecting reverse proxy in front of the mock LLM
|
||||
(tests/e2e/slow_llm.py) — this suite's timing fixture: the live-file
|
||||
contract needs the scan to outlive the UI's 2 s poll (see
|
||||
``SLOW_DELAY_S``)."""
|
||||
(tests/e2e/slow_llm.py) — this suite's timing fixture for the SYNC
|
||||
legs: the live-file contract needs the sync to outlive the UI's
|
||||
2 s poll (see ``SLOW_DELAY_S``)."""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["SLOW_LLM_DELAY_S"] = SLOW_DELAY_S
|
||||
@@ -239,10 +256,11 @@ def app_server(
|
||||
upload_dir: Path,
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
) -> Iterator[str]:
|
||||
"""The real app under test — per-module env: the LLM base URL is the
|
||||
SLOW PROXY in front of the mock (the timing fixture), uploads unpack
|
||||
into a scratch dir, and the env git list is forced empty (the sync
|
||||
sources are this suite's own ``kind=local`` row, seeded per test)."""
|
||||
"""The real app under test — per-module env: the LLM base URL is
|
||||
the SLOW PROXY in front of the mock (the sync-leg timing fixture;
|
||||
the upload run makes no LLM calls — phase 90), uploads unpack into
|
||||
a scratch dir, and the env git list is forced empty (the sync
|
||||
sources are this suite's own ``kind=local`` rows, seeded per test)."""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["BOR_ENVIRONMENT"] = "e2e"
|
||||
@@ -362,22 +380,24 @@ def _status(page: Page, app_url: str, path: str) -> dict[str, Any]:
|
||||
return r.json()
|
||||
|
||||
|
||||
def _wait_running_started_at(
|
||||
page: Page, app_url: str, path: str, timeout_s: float = 15.0
|
||||
) -> str:
|
||||
"""Poll the status endpoint until the run is ``running``; return its
|
||||
``started_at`` (the run's identity — a second run would reset it)."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
body: dict[str, Any] = {}
|
||||
while time.monotonic() < deadline:
|
||||
body = _status(page, app_url, path)
|
||||
if body["state"] == "running":
|
||||
assert body["started_at"] is not None
|
||||
return str(body["started_at"])
|
||||
if body["state"] in ("success", "failed"):
|
||||
raise AssertionError(f"the run settled too fast to observe: {body}")
|
||||
time.sleep(0.1)
|
||||
raise AssertionError(f"the run never entered running: {body}")
|
||||
def _hold_first_status_fetch(page: Page, hold_s: float) -> None:
|
||||
"""Intercept the upload-status GETs and hold ONLY THE FIRST one for
|
||||
``hold_s`` seconds (later fetches pass straight through). Install
|
||||
AFTER the page's boot re-attach fetch, before the submit. The
|
||||
poll's first tick fires 2 s after the 202; holding its fetch keeps
|
||||
the button in the in-run state long enough to assert the bare
|
||||
"Processing…" label (no file, no "(n/m)") across the whole
|
||||
background run — phase 90's run settles in milliseconds, so without
|
||||
the hold the in-run window is only the 2 s pre-tick gap."""
|
||||
state = {"held": False}
|
||||
|
||||
def handle(route: Any) -> None:
|
||||
if not state["held"]:
|
||||
state["held"] = True
|
||||
time.sleep(hold_s)
|
||||
route.continue_()
|
||||
|
||||
page.route("**/api/git-sources/upload/status", handle)
|
||||
|
||||
|
||||
class _TickRecorder:
|
||||
@@ -387,19 +407,45 @@ class _TickRecorder:
|
||||
endpoint with its OWN admin session (``httpx`` — the browser page
|
||||
drives itself in the meantime; the Playwright sync API is not
|
||||
thread-safe, so the thread never touches it), recording every tick
|
||||
from the first poll: the idle prelude, the running ticks (file-less
|
||||
unpack/row/probe phase, then the per-file ticks), and the terminal
|
||||
body. The thread only READS the same endpoint the UI's 2 s poll
|
||||
reads — it starts no jobs and cannot skew the run."""
|
||||
from the first poll: the idle prelude, the running ticks, and the
|
||||
terminal body. The thread only READS the same endpoint the UI's
|
||||
2 s poll reads — it starts no jobs and cannot skew the run.
|
||||
|
||||
def __init__(self, app_url: str, path: str) -> None:
|
||||
``require_running`` (phase 90): the unpack-only upload run settles
|
||||
in milliseconds, so a fast machine can miss every running tick —
|
||||
with the flag off, a terminal is accepted when the run it names
|
||||
started at or after this recorder's start (the stale-terminal
|
||||
guard: the run state lives in the app's memory, so a previous
|
||||
test's terminal must not be mistaken for this run's)."""
|
||||
|
||||
def __init__(self, app_url: str, path: str, require_running: bool = True) -> None:
|
||||
self._url = f"{app_url}{path}"
|
||||
self._login_url = f"{app_url}/api/login"
|
||||
self._require_running = require_running
|
||||
self._t0 = time.time()
|
||||
self._ticks: list[dict[str, Any]] = []
|
||||
self._terminal: dict[str, Any] | None = None
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def _terminal_is_this_run(self, body: dict[str, Any], saw_running: bool) -> bool:
|
||||
"""See the class docstring — a terminal is accepted when the
|
||||
run it names is THIS recorder's: a running tick was observed,
|
||||
or (``require_running=False``) its ``started_at`` is no earlier
|
||||
than the recorder's start."""
|
||||
if saw_running:
|
||||
return True
|
||||
if self._require_running:
|
||||
return False
|
||||
started = body.get("started_at")
|
||||
if not started:
|
||||
return False
|
||||
try:
|
||||
started_at = datetime.fromisoformat(str(started)).timestamp()
|
||||
except ValueError:
|
||||
return False
|
||||
return started_at >= self._t0 - 2.0 # tolerance for the pre-submit gap
|
||||
|
||||
def start(self) -> None:
|
||||
def run() -> None:
|
||||
with httpx.Client(timeout=5.0) as client:
|
||||
@@ -413,15 +459,12 @@ class _TickRecorder:
|
||||
if r.status_code == 200:
|
||||
body = r.json()
|
||||
self._ticks.append(body)
|
||||
# The run status is in the app's memory and
|
||||
# SURVIVES across this module's tests: a
|
||||
# residual terminal state (a previous test's
|
||||
# run) must not be mistaken for this test's
|
||||
# own terminal — accept it only AFTER this
|
||||
# run's "running" has been observed.
|
||||
if body["state"] == "running":
|
||||
saw_running = True
|
||||
elif body["state"] in ("success", "failed") and saw_running:
|
||||
elif (
|
||||
body["state"] in ("success", "failed")
|
||||
and self._terminal_is_this_run(body, saw_running)
|
||||
):
|
||||
self._terminal = body
|
||||
return
|
||||
except Exception: # noqa: BLE001 — blip: retry next tick
|
||||
@@ -457,8 +500,8 @@ def _assert_live_file_ticks(
|
||||
) -> None:
|
||||
"""A4 against the recorded running ticks (the deterministic layer):
|
||||
|
||||
* the file-less ticks (unpack/row/model probe — before any file is
|
||||
indexed) come FIRST (the label is the bare prefix then);
|
||||
* the file-less ticks (model probe — before any file is indexed)
|
||||
come FIRST (the label is the bare prefix then);
|
||||
* SOME tick reports a non-null ``current_file`` in the
|
||||
``source/relative/path`` shape;
|
||||
* ``files_total`` is the full pre-walk count from the first file
|
||||
@@ -480,15 +523,16 @@ def _assert_live_file_ticks(
|
||||
# body instead of a recorded tick (100 ms cadence vs ≈160 ms file).
|
||||
assert max(dones) >= n_files - 1, f"files_done never advanced: {dones}"
|
||||
pre = [t for t in ticks if t["current_file"] is None]
|
||||
assert pre, f"no file-less running tick (the unpack phase): {ticks[:6]}"
|
||||
assert pre, f"no file-less running tick (the probe phase): {ticks[:6]}"
|
||||
assert ticks.index(pre[0]) < ticks.index(first_with), (
|
||||
"a file tick preceded the file-less unpack ticks"
|
||||
"a file tick preceded the file-less probe ticks"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Toast on 202 → navigate away → sync button animates with the
|
||||
# upload's current file → settle + catalog refresh (A2 + A3)
|
||||
# 1. Toast on 202 → navigate away → nothing indexed → the sync that
|
||||
# follows shows its live file label and lands the counts (A1/A2 +
|
||||
# the phase-90 leg)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -496,17 +540,18 @@ def test_upload_toast_then_navigate_away(
|
||||
page: Page, app_url: str, db_ready: None, upload_archive: Path
|
||||
) -> None:
|
||||
"""On ``/git-sources.html``: pick the multi-file archive, submit →
|
||||
the "Successfully uploaded — <source>" toast appears WHILE the scan
|
||||
is still running; immediately navigate to ``/sources.html`` → the
|
||||
sync button is present, animating (icon ``is-spinning``,
|
||||
``aria-busy``) with the ``Importing`` label; wait for the settle →
|
||||
button idle ("Sync sources"), no error UI, and the catalog table
|
||||
shows the uploaded documents (the phase-63 listing, untouched)."""
|
||||
the "Successfully uploaded — <source>" toast fires at the 202
|
||||
(A2); immediately navigate to ``/sources.html`` → **zero indexed
|
||||
documents** (phase 90 A1: the upload unpacked + registered only)
|
||||
and the sync button settled idle with no error UI; then click
|
||||
**Sync sources** (the new leg, phase 90) → the LIVE
|
||||
"Syncing… <file> (n/m)" label while the sync imports the uploaded
|
||||
row (both layers), then the success settle with the counts and the
|
||||
catalog refresh."""
|
||||
page.set_default_timeout(30_000)
|
||||
_admin_git_sources_page(page, app_url)
|
||||
# (A previous test's terminal run may re-render its result line at
|
||||
# boot — the task-05 re-attach contract; the submit below clears
|
||||
# it, and that is the "clean start" asserted after the click.)
|
||||
# boot — the re-attach contract; the submit below clears it.)
|
||||
|
||||
page.set_input_files("#archive-upload-file", str(upload_archive))
|
||||
page.click("#archive-upload-btn")
|
||||
@@ -518,38 +563,66 @@ def test_upload_toast_then_navigate_away(
|
||||
expect(toast).to_have_class(re.compile(r"\bis-visible\b"))
|
||||
assert toast.get_attribute("role") == "status"
|
||||
expect(toast).to_have_text(f"Successfully uploaded — {UPLOAD_NAME}")
|
||||
# …and the scan is still running — the result line is not up yet
|
||||
# (the toast precedes the scan's completion, A2).
|
||||
expect(page.locator("#archive-upload-result")).to_be_hidden()
|
||||
started_at = _wait_running_started_at(page, app_url, "/api/git-sources/upload/status")
|
||||
assert started_at is not None
|
||||
|
||||
# Navigate away immediately (A1: the scan no longer dies with the
|
||||
# Navigate away immediately (A1: the run no longer dies with the
|
||||
# page).
|
||||
page.goto(app_url + SOURCES_URL)
|
||||
|
||||
# The RAG-page sync button re-attaches to the in-flight upload scan
|
||||
# (A3): present, animating (spinner + aria-busy), disabled, with
|
||||
# the live "Importing <file>" label — no error UI on this page
|
||||
# (the upload's failure UI lives on the Sources page, A3).
|
||||
# Phase 90 A1: the upload indexed NOTHING — the catalog is empty…
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(0)
|
||||
# …and the sync button settles idle with no error UI (the
|
||||
# sub-second upload run is over by the time this page's 2 s poll
|
||||
# first ticks; the upload's counts never render here — A3).
|
||||
btn = page.locator("#sync-btn")
|
||||
expect(btn).to_be_visible(timeout=30_000)
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
expect(page.locator("#sync-label")).to_have_text(
|
||||
"Sync sources", timeout=SETTLE_TIMEOUT_MS
|
||||
)
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).not_to_have_attribute("aria-busy")
|
||||
|
||||
# The new leg (phase 90): the Sync button does the scan the upload
|
||||
# deferred — live file label at both layers, counts + catalog on
|
||||
# the settle.
|
||||
recorder = _TickRecorder(app_url, "/api/sync/status")
|
||||
recorder.start()
|
||||
btn.click()
|
||||
|
||||
# The click's immediate state (A4 — the bare prefix until the
|
||||
# import's first file): disabled, aria-busy, spinning icon, no
|
||||
# error…
|
||||
expect(btn).to_be_disabled()
|
||||
expect(btn).to_have_attribute("aria-busy", "true")
|
||||
expect(btn.locator(".sync-icon")).to_have_class(re.compile(r"\bis-spinning\b"))
|
||||
expect(page.locator("#sync-label")).to_have_text(
|
||||
re.compile(rf"Importing {re.escape(UPLOAD_NAME)}/"), timeout=SETTLE_TIMEOUT_MS
|
||||
)
|
||||
expect(page.locator("#sync-label")).to_have_text("Syncing…")
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
|
||||
# Settle: the button returns to idle, the sync-result line never
|
||||
# rendered the upload's counts (A3), and the catalog refreshes with
|
||||
# the uploaded documents — the phase-63 listing, untouched.
|
||||
expect(page.locator("#sync-label")).to_have_text("Sync sources", timeout=SETTLE_TIMEOUT_MS)
|
||||
# UI layer: the label gains the live file at the page's 2 s poll
|
||||
# tick ("Syncing… <source/relative/path> (n/m)").
|
||||
expect(page.locator("#sync-label")).to_have_text(
|
||||
re.compile(rf"Syncing… {re.escape(UPLOAD_NAME)}/.+\.md \(\d+/{N_FILES}\)"),
|
||||
timeout=SETTLE_TIMEOUT_MS,
|
||||
)
|
||||
|
||||
# Deterministic layer: the recorder's full tick series — file-less
|
||||
# model-check ticks first, then the per-file ticks (full
|
||||
# denominator, advancing counts).
|
||||
terminal = recorder.stop()
|
||||
assert terminal["state"] == "success", terminal
|
||||
_assert_live_file_ticks(recorder.running_ticks, UPLOAD_NAME, N_FILES)
|
||||
assert terminal["current_file"] is None
|
||||
assert terminal["files_done"] == N_FILES
|
||||
assert terminal["files_total"] == N_FILES
|
||||
assert terminal["detail"]["added"] == N_FILES
|
||||
|
||||
# The success settle: "Synced HH:MM" + the counts result line, the
|
||||
# button re-enabled, no error — and the catalog lists the
|
||||
# imported docs (the upload's row, scanned by the sync).
|
||||
expect(page.locator("#sync-label")).to_have_text(SYNCED_LABEL, timeout=30_000)
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).not_to_have_attribute("aria-busy")
|
||||
expect(btn.locator(".sync-icon")).not_to_have_class(re.compile(r"\bis-spinning\b"))
|
||||
expect(page.locator("#sync-result")).to_have_text("")
|
||||
expect(page.locator("#sync-result")).to_have_text(f"{N_FILES} added")
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(N_FILES, timeout=30_000)
|
||||
expect(page.locator("#docs-tbody tr", has_text="docs/00.md")).to_have_count(1)
|
||||
expect(page.locator("#docs-tbody tr", has_text="docs/24.md")).to_have_count(1)
|
||||
@@ -557,77 +630,90 @@ def test_upload_toast_then_navigate_away(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Upload progress: live current file at BOTH layers; the toast fired
|
||||
# earlier than the result (A4)
|
||||
# 2. Upload processing: BARE "Processing…" for the whole run, the
|
||||
# no-count terminal shape, nothing indexed (phase 90 A2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_upload_progress_shows_current_file(
|
||||
def test_upload_progress_is_bare(
|
||||
page: Page, app_url: str, db_ready: None, upload_archive: Path
|
||||
) -> None:
|
||||
"""During the scan: the status endpoint reports a non-null
|
||||
``current_file`` (``source/relative/path`` shape) at some running
|
||||
tick; the upload button label shows "Processing…" with a file path
|
||||
(UI layer) BEFORE the result line lands; the toast fired earlier in
|
||||
the run (not after the result). The result line + the settle come
|
||||
from the status ``success``."""
|
||||
"""Phase 90 A2: the upload's background run has NO file-level
|
||||
progress. The button shows the BARE "Processing…" for the whole
|
||||
run (no file, no "(n/m)", no title — proven across a held first
|
||||
status GET); every running tick the recorder catches carries a
|
||||
null ``current_file`` and 0/0 counts; the terminal status is
|
||||
``success`` with the no-count ``{"message": "uploaded"}`` detail
|
||||
and null/0/0 progress; the settled result line points at the Sync
|
||||
button; and the KB stays empty (no scan)."""
|
||||
page.set_default_timeout(30_000)
|
||||
_admin_git_sources_page(page, app_url)
|
||||
|
||||
recorder = _TickRecorder(app_url, "/api/git-sources/upload/status")
|
||||
# require_running=False — phase 90: the unpack-only run settles in
|
||||
# milliseconds, so a fast machine can miss every running tick; the
|
||||
# stale-terminal guard (started_at vs. the recorder's start) keeps
|
||||
# a previous test's terminal from being mistaken for this run's.
|
||||
recorder = _TickRecorder(
|
||||
app_url, "/api/git-sources/upload/status", require_running=False
|
||||
)
|
||||
recorder.start()
|
||||
# Hold the FIRST status GET (installed after the boot re-attach
|
||||
# fetch, before the submit): the bare in-run label gets a window
|
||||
# wider than the 2 s pre-tick gap.
|
||||
_hold_first_status_fetch(page, hold_s=4.5)
|
||||
page.set_input_files("#archive-upload-file", str(upload_archive))
|
||||
page.click("#archive-upload-btn")
|
||||
# A new attempt starts clean (the submit handler hides the result
|
||||
# line — any previous run's re-rendered line is gone by now).
|
||||
expect(page.locator("#archive-upload-result")).to_be_hidden()
|
||||
|
||||
# The toast fires at the 202 — earlier in the run, NOT after the
|
||||
# result (the result line is still down when the toast is up).
|
||||
# The toast fires at the 202 — NOT after the result (the result
|
||||
# line is still down when the toast is up).
|
||||
toast = page.locator(".toast")
|
||||
expect(toast).to_have_count(1, timeout=SETTLE_TIMEOUT_MS)
|
||||
expect(toast).to_have_class(re.compile(r"\bis-visible\b"))
|
||||
expect(toast).to_have_text(f"Successfully uploaded — {UPLOAD_NAME}")
|
||||
expect(page.locator("#archive-upload-result")).to_be_hidden()
|
||||
|
||||
# UI layer: the button hands over to the scan — bare "Processing…"
|
||||
# at the 202 (A4: no file yet during the unpack phase)…
|
||||
# The button hands over to the run — the BARE "Processing…" (phase
|
||||
# 90 A2: no file, no counts, no title)…
|
||||
btn = page.locator("#archive-upload-btn")
|
||||
expect(btn).to_be_disabled()
|
||||
expect(btn).to_have_text("Processing…", timeout=5_000)
|
||||
# …then the live file label ("Processing… <file> (n/m)") at the
|
||||
# page's next 2 s poll tick, still before the result line lands.
|
||||
expect(btn).to_have_text(
|
||||
re.compile(rf"Processing… {re.escape(UPLOAD_NAME)}/.+\.md \(\d+/{N_FILES}\)"),
|
||||
timeout=SETTLE_TIMEOUT_MS,
|
||||
)
|
||||
expect(page.locator("#archive-upload-result")).to_be_hidden()
|
||||
expect(btn).to_have_attribute("title", "")
|
||||
# …and it STAYS bare across the whole background run: the first
|
||||
# status GET is held, so the settling tick is in flight — no file
|
||||
# and no "(n/m)" can have rendered.
|
||||
time.sleep(2.5)
|
||||
expect(btn).to_have_text("Processing…")
|
||||
|
||||
# Deterministic layer: the recorder's full tick series (the same
|
||||
# endpoint the UI's 2 s poll reads) — file-less unpack ticks first,
|
||||
# then the per-file ticks with the full denominator.
|
||||
# Deterministic layer: every running tick the recorder caught is
|
||||
# bare (null file, 0/0 counts — phase 90 A2).
|
||||
for t in recorder.running_ticks:
|
||||
assert t["current_file"] is None, t
|
||||
assert t["files_done"] == 0 and t["files_total"] == 0, t
|
||||
terminal = recorder.stop()
|
||||
assert terminal["state"] == "success", terminal
|
||||
_assert_live_file_ticks(recorder.running_ticks, UPLOAD_NAME, N_FILES)
|
||||
# Phase 64: current_file is null in terminal states (the final
|
||||
# counts survive).
|
||||
assert terminal["detail"] == {"message": "uploaded"}, terminal
|
||||
assert terminal["current_file"] is None
|
||||
assert terminal["files_done"] == N_FILES
|
||||
assert terminal["files_total"] == N_FILES
|
||||
# The result line is rendered from the status success (the
|
||||
# UploadOut-shaped detail, counts unchanged in shape).
|
||||
assert terminal["detail"]["files"] == N_FILES
|
||||
assert terminal["detail"]["added"] == N_FILES
|
||||
assert terminal["detail"]["source"] == UPLOAD_NAME
|
||||
assert terminal["files_done"] == 0
|
||||
assert terminal["files_total"] == 0
|
||||
|
||||
# Settle: the result line lands, the button restores, the input
|
||||
# cleared, and the list has exactly one row for the archive.
|
||||
# Settle (the held GET released): the ready-for-sync result line,
|
||||
# the button restored ("Upload"), the input cleared, one row for
|
||||
# the archive — and the KB empty (no scan, phase 90 A1).
|
||||
result = page.locator("#archive-upload-result")
|
||||
expect(result).to_have_text(f"{N_FILES} added", timeout=30_000)
|
||||
expect(result).to_have_text(
|
||||
f"Uploaded {UPLOAD_NAME} — press Sync sources to import it.",
|
||||
timeout=30_000,
|
||||
)
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).to_have_text("Upload & scan")
|
||||
expect(btn).to_have_text("Upload")
|
||||
expect(page.locator("#archive-upload-file")).to_have_value("")
|
||||
expect(page.locator("#git-sources-tbody tr", has_text=UPLOAD_NAME)).to_have_count(1)
|
||||
r = page.request.get(f"{app_url}/api/docs")
|
||||
assert r.status == 200, r.text
|
||||
assert r.json()["documents"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -698,20 +784,23 @@ def test_sync_live_file_label(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Reload mid-scan → re-attach: no error, no second upload (A1 + A2)
|
||||
# 4. Reload → re-attach: no dead-end, no error, no toast, no second
|
||||
# upload (A1 + A2, phase 90)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_upload_reattach_after_reload(
|
||||
page: Page, app_url: str, db_ready: None, upload_archive: Path
|
||||
) -> None:
|
||||
"""Start the upload and, DURING the scan, reload
|
||||
``/git-sources.html`` → the button is in the Processing state
|
||||
(disabled) with no error banner and NO second upload (the status
|
||||
"""Start the upload and, while the sub-second run is in flight (or
|
||||
has just settled), reload ``/git-sources.html`` → the page never
|
||||
dead-ends: the boot re-attach either enters the bare Processing
|
||||
state + poll (run still in flight) or re-renders the settled
|
||||
result line (the NAMELESS variant — the safe name was page-local),
|
||||
with no error banner, no toast, and NO second upload (the status
|
||||
endpoint's single run is still the one from before the reload —
|
||||
pinned on its ``started_at``); it then settles with the result line
|
||||
and the list shows exactly one row for the archive (in-place
|
||||
identity preserved)."""
|
||||
pinned on its ``started_at``); the list shows exactly one row for
|
||||
the archive (in-place identity preserved)."""
|
||||
page.set_default_timeout(30_000)
|
||||
_admin_git_sources_page(page, app_url)
|
||||
|
||||
@@ -724,29 +813,28 @@ def test_upload_reattach_after_reload(
|
||||
expect(toast).to_have_class(re.compile(r"\bis-visible\b"))
|
||||
# …and the run's identity: the status's started_at (a second run
|
||||
# would reset it — the single-run claim is pinned on it).
|
||||
started_at = _wait_running_started_at(page, app_url, "/api/git-sources/upload/status")
|
||||
started_at = _status(page, app_url, "/api/git-sources/upload/status")["started_at"]
|
||||
|
||||
# Reload mid-scan — the page must re-attach, not dead-end.
|
||||
# Reload — the run may be in flight (the boot re-attach enters the
|
||||
# bare Processing state + poll) or settled (the boot re-renders
|
||||
# the result line); either way the page must not dead-end.
|
||||
page.reload()
|
||||
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#git-sources-content")).to_be_visible()
|
||||
|
||||
# The boot re-attach (task 05): the button is in the Processing
|
||||
# state (disabled, "Processing…") with no error banner and no
|
||||
# result line yet…
|
||||
btn = page.locator("#archive-upload-btn")
|
||||
expect(btn).to_be_disabled(timeout=15_000)
|
||||
expect(btn).to_have_text(re.compile(r"Processing…"), timeout=15_000)
|
||||
# No error banner, no toast at boot (A2 — the toast fired at the
|
||||
# 202, on the previous document life)…
|
||||
expect(page.locator("#archive-upload-error")).to_be_hidden()
|
||||
expect(page.locator("#archive-upload-result")).to_be_hidden()
|
||||
|
||||
# …and it settles with the result line from the status success…
|
||||
expect(page.locator(".toast")).to_have_count(0)
|
||||
# …and the settled result line: the nameless variant (the safe
|
||||
# name was page-local — lastUploadName is null after a reload).
|
||||
expect(page.locator("#archive-upload-result")).to_have_text(
|
||||
f"{N_FILES} added", timeout=SETTLE_TIMEOUT_MS
|
||||
"Uploaded — press Sync sources to import it.",
|
||||
timeout=SETTLE_TIMEOUT_MS,
|
||||
)
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).to_have_text("Upload & scan")
|
||||
expect(page.locator("#archive-upload-btn")).to_be_enabled()
|
||||
expect(page.locator("#archive-upload-btn")).to_have_text("Upload")
|
||||
# …and the list shows exactly one row for the archive.
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
|
||||
row = page.locator("#git-sources-tbody tr", has_text=UPLOAD_NAME)
|
||||
@@ -757,7 +845,8 @@ def test_upload_reattach_after_reload(
|
||||
# started_at is the one from before the reload.
|
||||
terminal = _status(page, app_url, "/api/git-sources/upload/status")
|
||||
assert terminal["state"] == "success", terminal
|
||||
assert str(terminal["started_at"]) == started_at, (
|
||||
assert str(terminal["started_at"]) == str(started_at), (
|
||||
f"the run's identity changed (a second upload ran): {terminal['started_at']}"
|
||||
)
|
||||
assert terminal["current_file"] is None
|
||||
assert terminal["detail"] == {"message": "uploaded"}
|
||||
|
||||
@@ -1,50 +1,53 @@
|
||||
"""Phase 62 E2E (Playwright): UI customization — placeholder, footer, theme.
|
||||
"""Phase 62 E2E (Playwright): UI customization — placeholder + footer.
|
||||
|
||||
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).
|
||||
``BOR_FOOTER_TEXT`` — A4). Phase 91 (task 03) retired the story's
|
||||
color-theming half — the CSS-file theme env var and its brand.js
|
||||
``<link>`` insertion are gone; the admin Theme tab (phases 91,
|
||||
tasks 04–06) is the only theming surface now, with its own dedicated
|
||||
E2E suite.
|
||||
|
||||
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``);
|
||||
* an instance booted with BOTH customization vars set shows the custom
|
||||
look end-to-end: the ``GET /api/config`` overrides (now the five-key
|
||||
set — the retired theming's ``theme`` key is gone), the chat composer
|
||||
placeholder (``#message-input``), and the footer line on multiple
|
||||
pages (``.footer-text``);
|
||||
* 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.
|
||||
footer, the built-in ``--brand: #f43f5e``;
|
||||
* the app NAME stays the default on the custom instance (this suite
|
||||
does not re-test ``BOR_APP_NAME`` — that is the phase-39 suite's
|
||||
job); the response's ``app_name`` key is the EFFECTIVE value (phase
|
||||
91: DB-over-env — for an env-only deployment, the env string itself).
|
||||
|
||||
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
|
||||
the mock-calibrated threshold) with exactly two 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:
|
||||
collide) and the two 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.
|
||||
fetch (the two string keys are applied asynchronously, in the SAME
|
||||
fetch's settled ``.then`` — no second network call).
|
||||
|
||||
Test → contract mapping (Playwright Mapping Rule):
|
||||
1. ``test_config_serves_the_overrides``
|
||||
2. ``test_chat_page_shows_custom_placeholder_footer_theme``
|
||||
2. ``test_chat_page_shows_custom_placeholder_and_footer``
|
||||
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
|
||||
@@ -67,13 +70,10 @@ from e2e.conftest import (
|
||||
|
||||
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:
|
||||
# The two overrides (phase 62, task 05) — the surviving story legs:
|
||||
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"
|
||||
@@ -84,7 +84,7 @@ 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
|
||||
"""A SECOND app instance, booted with both customization string
|
||||
overrides.
|
||||
|
||||
The shared conftest ``app_server`` keeps the defaults (every other
|
||||
@@ -92,9 +92,9 @@ def custom_server(mock_llm: int) -> Iterator[str]:
|
||||
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
|
||||
``BOR_RELEVANCE_THRESHOLD``) with exactly two changes: port
|
||||
``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1``) and the
|
||||
three env overrides below. Started after ``mock_llm`` is available
|
||||
two env overrides below. Started after ``mock_llm`` is available
|
||||
(its fixture dependency).
|
||||
"""
|
||||
env = dict(os.environ)
|
||||
@@ -117,10 +117,9 @@ def custom_server(mock_llm: int) -> Iterator[str]:
|
||||
# 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:
|
||||
# Phase 62 (owner-locked 2026-09-01, TODO L3) — the surviving legs:
|
||||
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"],
|
||||
@@ -138,28 +137,13 @@ def custom_server(mock_llm: int) -> Iterator[str]:
|
||||
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)."""
|
||||
normalization), so the string compare is stable: ``#f43f5e`` is
|
||||
exactly what styles.css declares (the built-in — the page the
|
||||
shared default server serves, with no ui_settings row, carries no
|
||||
inline theme tag and the stylesheet value stands)."""
|
||||
page.wait_for_function(
|
||||
"""(expected) =>
|
||||
getComputedStyle(document.documentElement)
|
||||
@@ -171,8 +155,8 @@ def expect_brand_var(page: Page, expected: str, timeout: int = 15_000) -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The endpoint the brand layer reads — the three overrides, the
|
||||
# six-key set, and the theme file served from the dev static dir
|
||||
# 1. The endpoint the brand layer reads — the two overrides, the
|
||||
# five-key set (the retired theming's theme key is gone)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -180,33 +164,27 @@ 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).
|
||||
# The five-key set (the phase-39/59/62 endpoint contract, phase 91
|
||||
# task 03: the retired CSS-file theming's ``theme`` key is gone)
|
||||
# with the two 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",
|
||||
"input_placeholder", "footer_text",
|
||||
}
|
||||
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
|
||||
# 2. The chat page — placeholder + footer (the theme legs are retired:
|
||||
# colors are injected pre-paint server-side, phase 91 task 02)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_page_shows_custom_placeholder_footer_theme(
|
||||
def test_chat_page_shows_custom_placeholder_and_footer(
|
||||
page: Page, custom_server: str
|
||||
) -> None:
|
||||
page.goto(custom_server + "/")
|
||||
@@ -219,13 +197,6 @@ def test_chat_page_shows_custom_placeholder_footer_theme(
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -243,10 +214,6 @@ def test_footer_text_applies_on_other_pages(page: Page, custom_server: str) -> N
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -257,75 +224,14 @@ def test_footer_text_applies_on_other_pages(page: Page, custom_server: str) -> N
|
||||
|
||||
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.
|
||||
# The phase-39/61 no-op contract: the template defaults stand
|
||||
# (positive assertions on the static HTML — the brand layer's
|
||||
# no-op paths touch nothing when the env vars are unset).
|
||||
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.
|
||||
# The built-in dark-tech palette stands: with no ui_settings row
|
||||
# the server injects no inline theme tag (phase 91 task 02's
|
||||
# no-op), so the stylesheet's --brand is the computed value.
|
||||
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
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
"""Phase 90 story E2E (Playwright): upload → (no scan) → edit ignores →
|
||||
Sync sources.
|
||||
|
||||
Source: ``TODO.md`` L3 — "Uploading a source archive should not trigger a
|
||||
scan — that should be left to the sync button on the RAG page. Right now
|
||||
the sync starts right away which doesn't give the user time to edit the
|
||||
ignore list."
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_upload_no_scan.py -v --no-cov
|
||||
|
||||
The story gate for the whole **deferred-scan loop** end-to-end against the
|
||||
real pipeline: an upload indexes **nothing** (phase 90 A1 — unpack +
|
||||
register only, the button reads "Upload"), the owner edits the new
|
||||
source's ignore list in the phase-89 per-row editor while the scan is
|
||||
still owed, and the RAG page's **Sync sources** button performs the scan
|
||||
and honors the edited ignores (phase 90 A4 — the sync's existing
|
||||
``kind='local'`` + prune + ``ignore_paths`` path is the proof, no sync
|
||||
change in this phase).
|
||||
|
||||
**Timing (the task's note):** the upload leg needs NO ``slow_llm`` proxy
|
||||
— the upload makes **zero LLM calls** now (phase 90 removed the model
|
||||
check + import), so its background run settles in milliseconds and the
|
||||
2 s status poll settles one tick after the 202. The sync leg is short
|
||||
(2 files + 1 ignored → a handful of mock-LLM requests), so instead of
|
||||
racing a timer the suite polls the RAG page's settled sync UI
|
||||
(``#sync-label`` "Synced HH:MM" + ``#sync-result`` counts) with generous
|
||||
timeouts — the live "Syncing… <file> (n/m)" label is the
|
||||
``test_sync_upload_progress.py`` suite's subject, not this one's.
|
||||
|
||||
The archive is **built in-test** with Python's ``tarfile``:
|
||||
``e2e-upload-no-scan.tar.gz`` (source name ``e2e-upload-no-scan``) holds
|
||||
``alpha.md`` + ``beta.md`` + ``notes/skipme.md`` with markdown sentinels
|
||||
(``ALPHA-…`` / ``BETA-…`` / ``SKIPME-…``); the v2 archive (test 3, same
|
||||
basename) modifies ``beta.md``, adds ``gamma.md`` (``GAMMA-…``), and
|
||||
drops ``alpha.md`` — the in-place-replace subject. ``notes`` is the
|
||||
phase-89 ignore entry: the pure prefix rule
|
||||
(``is_ignored("notes/skipme.md", ("notes",))``) excludes the skipme file
|
||||
from the sync's walk — and from its ``files_total`` (the pre-walk uses
|
||||
the same ignore tuple), so a 3-file upload imports exactly 2 documents.
|
||||
|
||||
Per-module app env (the conftest pattern, module-scoped — mirroring
|
||||
``test_archive_upload_sources.py``'s local helpers, NOT importing that
|
||||
module): ``BOR_UPLOAD_DIR`` points at a scratch dir the host-side
|
||||
assertions inspect (the app runs on the same machine), ``BOR_GIT_SOURCES``
|
||||
is forced empty (the dev ``.env``'s fallback URL must never render as an
|
||||
env row or become a second sync source), ``BOR_LLM_BASE_URL`` is the mock
|
||||
LLM (the sync leg's model check + embeds + overview), and the autouse
|
||||
``_clean`` truncates the shared Postgres (plus waits for no running
|
||||
background job — this suite's sync must never leak into the next test).
|
||||
|
||||
Contract under test:
|
||||
|
||||
* **test 1 — upload does not scan**: the "Upload" button (phase 90 A3)
|
||||
→ the archive → the 202 "Successfully uploaded — <source>" toast →
|
||||
the settled result line "Uploaded <source> — press Sync sources to
|
||||
import it." with the terminal no-count status payload
|
||||
(``{"message": "uploaded"}``, null/0/0 progress — phase 90 A2); the
|
||||
source row is present (Local badge + the phase-89 "Ignore paths"
|
||||
control) and its folder exists on the host under ``BOR_UPLOAD_DIR``
|
||||
with all three files — but **zero documents are indexed**:
|
||||
``GET /api/docs`` is empty and the RAG catalog (``/sources.html``)
|
||||
settles on its empty state;
|
||||
* **test 2 — ignore list, then Sync scans**: fresh state → upload →
|
||||
success line → the row's phase-89 "Ignore paths" editor: type
|
||||
``notes``, Save → the row shows the "1 ignored" count tag (the A5
|
||||
round-trip through ``GET``) and the stored row carries
|
||||
``ignore_paths == ["notes"]``; navigate to the RAG page → click
|
||||
**Sync sources** (``#sync-btn``) → the sync settles ("Synced HH:MM",
|
||||
"2 added", counts on the status endpoint) and the catalog lists
|
||||
``alpha.md`` + ``beta.md`` for the source and **not**
|
||||
``notes/skipme.md`` — the edit made BEFORE the sync is honored;
|
||||
* **test 3 — re-upload replaces without a scan**: fresh state → upload
|
||||
v1, success → upload v2 (same basename: the in-place identity) →
|
||||
success again with exactly ONE source row (phase-49 contract — one
|
||||
folder, one row) whose on-disk contents are ONLY v2's files — and
|
||||
still **zero** documents indexed from it.
|
||||
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
1. ``test_upload_does_not_scan``
|
||||
2. ``test_ignore_list_then_sync_scans``
|
||||
3. ``test_reupload_replaces_without_scan``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import SessionLocal
|
||||
from e2e.auth_helpers import login
|
||||
from e2e.conftest import (
|
||||
ADMIN_PASSWORD,
|
||||
SESSION_SECRET,
|
||||
USE_REAL_LLM,
|
||||
_wait_http,
|
||||
)
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Phase 79 (task 04, full inventory): in a combined session run the
|
||||
# conftest session app already owns its port — a second uvicorn on it
|
||||
# dies on bind and this suite would silently drive the wrong server.
|
||||
# The module app binds its own port instead (env-overridable).
|
||||
APP_PORT = int(os.environ.get("E2E_APP_PORT_UPLOADSCAN", "8135"))
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
|
||||
GIT_SOURCES_URL = "/git-sources.html"
|
||||
SOURCES_URL = "/sources.html"
|
||||
|
||||
#: The archive basename (both versions) — the source/folder name is the
|
||||
#: filename minus the archive suffix (the phase-49 naming rule).
|
||||
SOURCE_NAME = "e2e-upload-no-scan"
|
||||
|
||||
#: v1: two sentinel docs + the notes/ file the phase-89 ignore entry
|
||||
#: (``notes``) excludes from the sync. v2 (same basename): beta CHANGED,
|
||||
#: alpha DROPPED, gamma ADDED — the in-place-replace subject (the
|
||||
#: on-disk folder swap).
|
||||
ALPHA_SENTINEL = "ALPHA-NO-SCAN-7f31"
|
||||
BETA_SENTINEL_V1 = "BETA-NO-SCAN-v1-2c90"
|
||||
BETA_SENTINEL_V2 = "BETA-NO-SCAN-v2-8b42"
|
||||
SKIPME_SENTINEL = "SKIPME-NO-SCAN-5e44"
|
||||
GAMMA_SENTINEL = "GAMMA-NO-SCAN-9d16"
|
||||
|
||||
V1_FILES: dict[str, str] = {
|
||||
"alpha.md": (
|
||||
"# Alpha note\n"
|
||||
"\n"
|
||||
"First version of the alpha note — v2 drops it.\n"
|
||||
f"\nMarker: {ALPHA_SENTINEL}\n"
|
||||
),
|
||||
"beta.md": (
|
||||
"# Beta note\n"
|
||||
"\n"
|
||||
"First version of the beta note — it changes in v2.\n"
|
||||
f"\nMarker: {BETA_SENTINEL_V1}\n"
|
||||
),
|
||||
"notes/skipme.md": (
|
||||
"# Skip me\n"
|
||||
"\n"
|
||||
"Lives under the notes/ dir the owner ignores before the sync.\n"
|
||||
f"\nMarker: {SKIPME_SENTINEL}\n"
|
||||
),
|
||||
}
|
||||
V2_FILES: dict[str, str] = {
|
||||
"beta.md": (
|
||||
"# Beta note\n"
|
||||
"\n"
|
||||
"Second version of the beta note — modified in place.\n"
|
||||
f"\nMarker: {BETA_SENTINEL_V2}\n"
|
||||
),
|
||||
"gamma.md": (
|
||||
"# Gamma note\n"
|
||||
"\n"
|
||||
"Brand new in v2 — the add subject of the re-upload.\n"
|
||||
f"\nMarker: {GAMMA_SENTINEL}\n"
|
||||
),
|
||||
}
|
||||
|
||||
#: The phase-89 ignore entry typed into the editor — the pure prefix
|
||||
#: rule excludes ``notes/skipme.md`` (and every other notes/ file).
|
||||
IGNORE_ENTRY = "notes"
|
||||
|
||||
#: "Synced HH:MM" — the local-time last-result label (sources.js's
|
||||
#: fmtSyncTime), any hour/minute (test_sync_button.py's pattern).
|
||||
SYNCED_LABEL = re.compile(r"Synced \d{1,2}:\d{2}")
|
||||
|
||||
#: Generous settle budgets: the upload run settles in milliseconds
|
||||
#: (phase 90) and the UI's 2 s poll lands one tick after; the sync leg
|
||||
#: is short (2 files against the fast mock LLM) and may settle between
|
||||
#: the 2 s poll ticks — the settled-state assertions retry until then.
|
||||
UPLOAD_TIMEOUT_MS = 30_000
|
||||
SYNC_TIMEOUT_MS = 45_000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_targz(path: Path, files: dict[str, str]) -> Path:
|
||||
"""A deterministic ``.tar.gz`` (mtime 0) over the given files."""
|
||||
with tarfile.open(path, "w:gz") as tf:
|
||||
for rel, content in files.items():
|
||||
data = content.encode("utf-8")
|
||||
info = tarfile.TarInfo(rel)
|
||||
info.size = len(data)
|
||||
info.mtime = 0
|
||||
tf.addfile(info, io.BytesIO(data))
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def upload_dir(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""The app's ``BOR_UPLOAD_DIR`` for this suite — a scratch dir the
|
||||
host-side assertions inspect (the app server runs on the same
|
||||
machine). The app creates it on the first upload."""
|
||||
return tmp_path_factory.mktemp("bor_uploads") / "uploads"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tarball_v1(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""v1 — in its OWN subdirectory so v2 can reuse the same basename
|
||||
(``e2e-upload-no-scan.tar.gz``): the in-place-replace identity IS
|
||||
the filename, and ``set_input_files`` sends the path's basename."""
|
||||
root = tmp_path_factory.mktemp("bor_archive_v1")
|
||||
return _build_targz(root / f"{SOURCE_NAME}.tar.gz", V1_FILES)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tarball_v2(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""v2 — same basename as v1 (a different parent dir)."""
|
||||
root = tmp_path_factory.mktemp("bor_archive_v2")
|
||||
return _build_targz(root / f"{SOURCE_NAME}.tar.gz", V2_FILES)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(
|
||||
mock_llm: int,
|
||||
upload_dir: Path,
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
) -> Iterator[str]:
|
||||
"""The real app under test — per-module env: uploads unpack into a
|
||||
scratch dir, the env git list is forced empty (this suite's own
|
||||
upload row is the only sync source), and the LLM base URL is the
|
||||
mock (the upload makes zero LLM calls — phase 90; the mock serves
|
||||
the SYNC leg's model check + embeds + overview). No ``slow_llm``
|
||||
proxy: the sync leg is 2 files and the suite polls the settled sync
|
||||
UI with generous timeouts instead of racing a live label."""
|
||||
env = dict(os.environ)
|
||||
env.pop("DEBUGPY", None)
|
||||
env["BOR_ENVIRONMENT"] = "e2e"
|
||||
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||
env["BOR_LLM_BASE_URL"] = (
|
||||
"https://aipi.reeseapps.com/v1"
|
||||
if USE_REAL_LLM
|
||||
else f"http://127.0.0.1:{mock_llm}/v1"
|
||||
)
|
||||
# Mock-calibrated threshold (conftest pattern) — no chat turn is
|
||||
# ever sent in this suite, but the app boots with the same env shape.
|
||||
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
|
||||
env["BOR_GIT_SOURCES"] = ""
|
||||
# Phase 49: unpack uploads into the suite's scratch dir (host-
|
||||
# inspectable) and keep the (unused) git checkouts out of the dev
|
||||
# location.
|
||||
env["BOR_UPLOAD_DIR"] = str(upload_dir)
|
||||
env["BOR_SOURCES_DIR"] = str(tmp_path_factory.mktemp("bor_checkouts"))
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=env,
|
||||
)
|
||||
try:
|
||||
_wait_http(f"{APP_URL}/api/health")
|
||||
yield APP_URL
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_url(app_server: str) -> str:
|
||||
return app_server
|
||||
|
||||
|
||||
def _truncate_all() -> None:
|
||||
"""Fresh registry + KB per test (the E2E isolation pattern): the
|
||||
row-count and doc-list assertions must be this test's own doing.
|
||||
The E2E suites share one Postgres, and a leftover git_sources row
|
||||
or document would corrupt them (a leftover row would be a SECOND
|
||||
sync source, skewing the sync's counts)."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview, git_sources"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _wait_no_running_jobs(app_url: str) -> None:
|
||||
"""No background job may leak across tests (the run states live in
|
||||
the app's memory, and a still-running sync would keep importing
|
||||
into the NEXT test's truncated KB): wait for both status endpoints
|
||||
to be non-running BEFORE the truncate. Own admin session (the
|
||||
endpoints are admin-only) — usually a no-op: test 2 settles only
|
||||
after its sync's terminal state, and the upload run is
|
||||
sub-second."""
|
||||
with httpx.Client(timeout=5.0) as client:
|
||||
client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
|
||||
for path in ("/api/sync/status", "/api/git-sources/upload/status"):
|
||||
body: dict[str, Any] = {}
|
||||
deadline = time.monotonic() + 90
|
||||
while time.monotonic() < deadline:
|
||||
r = client.get(f"{app_url}{path}")
|
||||
if r.status_code == 200:
|
||||
body = r.json()
|
||||
if body["state"] != "running":
|
||||
break
|
||||
time.sleep(0.2)
|
||||
assert body["state"] != "running", (
|
||||
f"a background job was still running at test boundary: {path} {body}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean(app_url: str, db_ready: None) -> Iterator[None]:
|
||||
_wait_no_running_jobs(app_url)
|
||||
_truncate_all()
|
||||
yield
|
||||
_truncate_all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _admin_git_sources_page(page: Page, app_url: str) -> None:
|
||||
"""Real form login landing on the git sources page (admin settled:
|
||||
Sign out visible, the manager revealed by the page module)."""
|
||||
login(page, app_url, next=GIT_SOURCES_URL)
|
||||
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#git-sources-gate")).to_be_hidden()
|
||||
expect(page.locator("#git-sources-content")).to_be_visible()
|
||||
|
||||
|
||||
def _docs(page: Page, app_url: str) -> list[tuple[str, str]]:
|
||||
"""``GET /api/docs`` as the signed-in page → sorted (source, path)
|
||||
pairs (the admin cookie rides the browser context)."""
|
||||
r = page.request.get(f"{app_url}/api/docs")
|
||||
assert r.status == 200, r.text
|
||||
return sorted((d["source"], d["path"]) for d in r.json()["documents"])
|
||||
|
||||
|
||||
def _upload_via_page(page: Page, archive: Path) -> str:
|
||||
"""Pick the archive, submit the form, and wait for the result line
|
||||
(the phase-64 202 path: toast at the 202, then the button's status
|
||||
polling renders the line from the run's ``success``) — returns its
|
||||
text. Every upload in this suite succeeds (the failure path is
|
||||
another suite's subject), so any non-result outcome here is a test
|
||||
error."""
|
||||
page.set_input_files("#archive-upload-file", str(archive))
|
||||
page.click("#archive-upload-btn")
|
||||
result = page.locator("#archive-upload-result")
|
||||
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
||||
text = result.text_content()
|
||||
assert text is not None
|
||||
return text
|
||||
|
||||
|
||||
def _folder_files(folder: Path) -> set[str]:
|
||||
"""The unpacked folder's file set, source-relative POSIX paths."""
|
||||
return {
|
||||
p.relative_to(folder).as_posix()
|
||||
for p in folder.rglob("*")
|
||||
if p.is_file()
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Upload → 202 + toast → ready-for-sync line, the row + folder on
|
||||
# disk — and ZERO documents indexed (the scan is the Sync button's
|
||||
# job, phase 90 A1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_upload_does_not_scan(
|
||||
page: Page, app_url: str, db_ready: None, tarball_v1: Path, upload_dir: Path
|
||||
) -> None:
|
||||
"""One real upload through the page: the button reads **Upload**
|
||||
(phase 90 A3); the 202 fires the "Successfully uploaded — <source>"
|
||||
toast; the settled result line points at the next step — "Uploaded
|
||||
<source> — press Sync sources to import it." (A3) — with the
|
||||
terminal no-count status payload (``{"message": "uploaded"}``,
|
||||
null/0/0 progress — A2). The source row is present (Local badge +
|
||||
the phase-89 "Ignore paths" control) and its folder exists on the
|
||||
host under ``BOR_UPLOAD_DIR`` with all three files — but
|
||||
**zero documents are indexed**: ``GET /api/docs`` is empty and the
|
||||
RAG catalog settles on its empty state (phase 90 A1 — the scan is
|
||||
the RAG page's Sync button's job)."""
|
||||
page.set_default_timeout(30_000)
|
||||
_admin_git_sources_page(page, app_url)
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(0)
|
||||
|
||||
# The button reads exactly "Upload" (phase 90 A3 — the
|
||||
# scan-suffixed label is gone).
|
||||
btn = page.locator("#archive-upload-btn")
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).to_have_text("Upload")
|
||||
|
||||
page.set_input_files("#archive-upload-file", str(tarball_v1))
|
||||
btn.click()
|
||||
|
||||
# The 202 moment (phase-64 A2): a single .toast node, visible,
|
||||
# role=status, naming the safe source name…
|
||||
toast = page.locator(".toast")
|
||||
expect(toast).to_have_count(1, timeout=UPLOAD_TIMEOUT_MS)
|
||||
expect(toast).to_have_class(re.compile(r"\bis-visible\b"))
|
||||
assert toast.get_attribute("role") == "status"
|
||||
expect(toast).to_have_text(f"Successfully uploaded — {SOURCE_NAME}")
|
||||
|
||||
# …and the background run (unpack + register only) settles at the
|
||||
# next 2 s poll tick: the ready-for-sync result line (A3), the
|
||||
# never-stale restore (button + input)…
|
||||
result = page.locator("#archive-upload-result")
|
||||
expect(result).to_be_visible(timeout=UPLOAD_TIMEOUT_MS)
|
||||
expect(result).to_have_text(
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it."
|
||||
)
|
||||
expect(btn).to_be_enabled()
|
||||
expect(btn).to_have_text("Upload")
|
||||
expect(page.locator("#archive-upload-file")).to_have_value("")
|
||||
|
||||
# …and the terminal status is the no-count payload with null/0/0
|
||||
# progress (the phase-64 key set, phase 90 A2).
|
||||
r = page.request.get(f"{app_url}/api/git-sources/upload/status")
|
||||
assert r.status == 200, r.text
|
||||
status = r.json()
|
||||
assert status["state"] == "success", status
|
||||
assert status["detail"] == {"message": "uploaded"}, status
|
||||
assert status["current_file"] is None
|
||||
assert status["files_done"] == 0 and status["files_total"] == 0
|
||||
|
||||
# The row landed — Local badge, the unpacked path in the mono cell,
|
||||
# and the phase-89 "Ignore paths" control (the editor the
|
||||
# deferral exists for — test 2 opens it).
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(1, timeout=30_000)
|
||||
row = page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)
|
||||
expect(row).to_have_count(1)
|
||||
expect(row.locator("span.git-source-kind")).to_have_text("Local")
|
||||
expect(row.locator("td.git-source-url-cell code")).to_have_text(
|
||||
str(upload_dir / SOURCE_NAME)
|
||||
)
|
||||
expect(row.locator("button.git-source-ignore")).to_have_count(1)
|
||||
expect(row.locator("button.git-source-ignore")).to_have_text("Ignore paths")
|
||||
|
||||
# Phase 90 A1: the upload indexes NOTHING — the KB is empty…
|
||||
assert _docs(page, app_url) == []
|
||||
# …and the folder exists on the host with ALL THREE files unpacked
|
||||
# (the notes/ file included — the ignore list, not the upload,
|
||||
# decides what the sync walks).
|
||||
folder = upload_dir / SOURCE_NAME
|
||||
assert folder.is_dir(), f"the unpacked folder is missing: {folder}"
|
||||
assert _folder_files(folder) == {"alpha.md", "beta.md", "notes/skipme.md"}, (
|
||||
f"unexpected unpacked files: {_folder_files(folder)}"
|
||||
)
|
||||
# …and so is the RAG catalog: it settles on its empty state (the
|
||||
# visible #sources-empty is the deterministic "the load finished
|
||||
# with zero documents" signal — a count-0 check alone would race
|
||||
# the boot loadDocs fetch).
|
||||
page.goto(app_url + SOURCES_URL)
|
||||
expect(page.locator("#sources-empty")).to_be_visible(timeout=30_000)
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(0)
|
||||
expect(page.locator("#stat-docs")).to_have_text("0")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. The phase-89 ignore list is edited BEFORE the scan — and the RAG
|
||||
# page's Sync sources button honors it (phase 90 A4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ignore_list_then_sync_scans(
|
||||
page: Page, app_url: str, db_ready: None, tarball_v1: Path, upload_dir: Path
|
||||
) -> None:
|
||||
"""The whole deferred-scan loop: upload (nothing indexed) → open
|
||||
the row's phase-89 "Ignore paths" editor → type ``notes`` → Save
|
||||
(the row shows the "1 ignored" count tag; the stored row carries
|
||||
``ignore_paths == ["notes"]`` — the A5 round-trip) → navigate to
|
||||
the RAG page → click **Sync sources** → the sync settles
|
||||
("Synced HH:MM" label, "2 added" result, the status endpoint's
|
||||
counts: added 2 / pruned 0 / 2 of 2 files — the ignored file is
|
||||
excluded from the walk AND the denominator) → the catalog lists
|
||||
``alpha.md`` + ``beta.md`` for the source and **not**
|
||||
``notes/skipme.md``: the edit made before the sync is honored
|
||||
(phase 90 A4 — the sync's existing kind='local' + prune +
|
||||
ignore_paths path, unchanged in this phase)."""
|
||||
page.set_default_timeout(30_000)
|
||||
_admin_git_sources_page(page, app_url)
|
||||
|
||||
# Upload: the ready-for-sync line, and still nothing indexed.
|
||||
assert _upload_via_page(page, tarball_v1) == (
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it."
|
||||
)
|
||||
assert _docs(page, app_url) == []
|
||||
expect(page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)).to_have_count(1)
|
||||
|
||||
# The phase-89 per-row editor: open it from the row's button…
|
||||
row = page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)
|
||||
row.locator("button.git-source-ignore").click()
|
||||
dialog = page.locator("#ignore-editor-dialog")
|
||||
expect(dialog).to_be_visible(timeout=15_000)
|
||||
expect(page.locator("#ignore-editor-source")).to_have_text(
|
||||
str(upload_dir / SOURCE_NAME)
|
||||
)
|
||||
# …type the ignore entry (one path per line; the prefix rule
|
||||
# excludes everything under notes/), and save.
|
||||
page.fill("#ignore-editor-textarea", IGNORE_ENTRY)
|
||||
page.click("#ignore-editor-save")
|
||||
expect(dialog).to_be_hidden(timeout=30_000)
|
||||
# The A5 round-trip: the row re-loads and shows the "1 ignored"
|
||||
# count tag…
|
||||
expect(row.locator(".git-source-ignore-count")).to_have_text(
|
||||
"1 ignored", timeout=30_000
|
||||
)
|
||||
# …and the stored row carries the normalized list the sync will
|
||||
# read.
|
||||
r = page.request.get(f"{app_url}/api/git-sources")
|
||||
assert r.status == 200, r.text
|
||||
body = r.json()
|
||||
assert [(s["kind"], s["path"], s["ignore_paths"]) for s in body["sources"]] == [
|
||||
("local", str(upload_dir / SOURCE_NAME), [IGNORE_ENTRY])
|
||||
]
|
||||
|
||||
# The RAG page — the catalog is still empty before the scan…
|
||||
page.goto(app_url + SOURCES_URL)
|
||||
expect(page.locator("#sync-btn")).to_be_visible(timeout=30_000)
|
||||
expect(page.locator("#sources-empty")).to_be_visible(timeout=30_000)
|
||||
expect(page.locator("#sync-label")).to_have_text("Sync sources")
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
|
||||
# Click Sync sources (the button the deferral exists for). The
|
||||
# short sync (2 files against the fast mock LLM) may settle between
|
||||
# the 2 s poll ticks — the settled-state assertions below retry
|
||||
# with generous timeouts instead of racing a live label.
|
||||
page.click("#sync-btn")
|
||||
expect(page.locator("#sync-error-banner")).to_be_hidden()
|
||||
expect(page.locator("#sync-label")).to_have_text(
|
||||
SYNCED_LABEL, timeout=SYNC_TIMEOUT_MS
|
||||
)
|
||||
expect(page.locator("#sync-result")).to_have_text("2 added", timeout=SYNC_TIMEOUT_MS)
|
||||
expect(page.locator("#sync-btn")).to_be_enabled()
|
||||
expect(page.locator("#sync-btn")).not_to_have_attribute("aria-busy")
|
||||
|
||||
# The status endpoint agrees: 2 added, nothing pruned, and the
|
||||
# ignored file is absent from the walk's denominator (2 of 2 —
|
||||
# the pre-walk uses the row's ignore list).
|
||||
r = page.request.get(f"{app_url}/api/sync/status")
|
||||
assert r.status == 200, r.text
|
||||
status = r.json()
|
||||
assert status["state"] == "success", status
|
||||
assert status["detail"]["added"] == 2, status
|
||||
assert status["detail"]["pruned"] == 0, status
|
||||
assert status["current_file"] is None
|
||||
assert status["files_done"] == 2 and status["files_total"] == 2
|
||||
|
||||
# The catalog: exactly the two non-ignored docs, for the source —
|
||||
# and the ignored one is NOT there.
|
||||
expect(page.locator("#docs-tbody tr")).to_have_count(2, timeout=30_000)
|
||||
expect(page.locator("#docs-tbody tr", has_text=SOURCE_NAME)).to_have_count(2)
|
||||
expect(page.locator("#docs-tbody tr", has_text="alpha.md")).to_have_count(1)
|
||||
expect(page.locator("#docs-tbody tr", has_text="beta.md")).to_have_count(1)
|
||||
expect(page.locator("#docs-tbody tr", has_text="notes/skipme.md")).to_have_count(0)
|
||||
assert _docs(page, app_url) == [
|
||||
(SOURCE_NAME, "alpha.md"),
|
||||
(SOURCE_NAME, "beta.md"),
|
||||
], f"the ignore list was not honored: {_docs(page, app_url)}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Re-upload, same filename → in-place replace (no duplicate row,
|
||||
# folder swap on disk) — and still nothing indexed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reupload_replaces_without_scan(
|
||||
page: Page,
|
||||
app_url: str,
|
||||
db_ready: None,
|
||||
tarball_v1: Path,
|
||||
tarball_v2: Path,
|
||||
upload_dir: Path,
|
||||
) -> None:
|
||||
"""v1 then v2 under the SAME filename (``e2e-upload-no-scan.tar.gz``
|
||||
— v2 modifies ``beta.md``, adds ``gamma.md``, drops ``alpha.md``):
|
||||
both unpack + register only (phase 90). After the re-upload the
|
||||
list has exactly ONE ``e2e-upload-no-scan`` row (the in-place
|
||||
identity, phase-49 contract — no duplicate), the registry agrees
|
||||
(one kind=local row at the unpacked path), the on-disk folder holds
|
||||
ONLY v2's files (the atomic swap) — and the KB is empty
|
||||
THROUGHOUT (zero documents indexed by either upload: the scan is
|
||||
the Sync button's job, never the upload's)."""
|
||||
page.set_default_timeout(30_000)
|
||||
_admin_git_sources_page(page, app_url)
|
||||
|
||||
# Baseline: v1 through the page (202 → the ready-for-sync line,
|
||||
# one row, no index).
|
||||
assert _upload_via_page(page, tarball_v1) == (
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it."
|
||||
)
|
||||
expect(page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)).to_have_count(1)
|
||||
folder = upload_dir / SOURCE_NAME
|
||||
assert _folder_files(folder) == set(V1_FILES)
|
||||
assert _docs(page, app_url) == []
|
||||
|
||||
# Re-upload v2 — SAME basename, different parent dir (the file
|
||||
# input's selection is replaced wholesale).
|
||||
assert _upload_via_page(page, tarball_v2) == (
|
||||
f"Uploaded {SOURCE_NAME} — press Sync sources to import it."
|
||||
)
|
||||
|
||||
# No duplicate: exactly ONE row for that source (and one row
|
||||
# total)…
|
||||
expect(page.locator("#git-sources-tbody tr", has_text=SOURCE_NAME)).to_have_count(1)
|
||||
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
|
||||
# …and the registry agrees: one kind=local row, the unpacked path.
|
||||
r = page.request.get(f"{app_url}/api/git-sources")
|
||||
assert r.status == 200, r.text
|
||||
body = r.json()
|
||||
assert [(s["kind"], s["path"]) for s in body["sources"]] == [
|
||||
("local", str(upload_dir / SOURCE_NAME))
|
||||
]
|
||||
|
||||
# The on-disk folder holds ONLY v2's files (the swap replaced the
|
||||
# whole folder in place — no stale v1 file survived)…
|
||||
assert _folder_files(folder) == set(V2_FILES), (
|
||||
f"unexpected unpacked files: {_folder_files(folder)}"
|
||||
)
|
||||
# …and the KB is STILL empty (the upload never scans, phase 90 A1
|
||||
# — the Sync button is what will index v2's files).
|
||||
assert _docs(page, app_url) == []
|
||||
@@ -9,6 +9,14 @@ Phase 80 note: the suggestions pins are the exception — the chips are
|
||||
the last 3 questions asked once any are saved, so the env-override
|
||||
pin (the override is the SEED) needs an empty ``saved_chats``;
|
||||
the full state matrix lives in ``test_suggestions_api.py``.
|
||||
|
||||
Phase 91 (task 01) note: the ``/api/config`` pins are now DB-backed —
|
||||
the three UI strings are the EFFECTIVE values (the ``ui_settings`` row
|
||||
over the env values, B1), resolved through a short-lived session, so
|
||||
the pins take the ``db`` fixture (skip when the stack is down) and
|
||||
start from an empty ``ui_settings`` table (the env-only-deployment
|
||||
state; the DB-over-env behaviour itself is pinned in
|
||||
test_ui_settings_api.py).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -22,6 +30,14 @@ from app.config import get_settings
|
||||
from tests.conftest import ADMIN_PASSWORD
|
||||
|
||||
|
||||
def _clear_ui_settings(db: Session) -> None:
|
||||
"""The env-only-deployment state for the /api/config pins: no
|
||||
ui_settings row, so the effective strings are the env values
|
||||
(phase 91, task 01)."""
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_health_reports_ok(client) -> None:
|
||||
r = client.get("/api/health")
|
||||
assert r.status_code == 200
|
||||
@@ -31,34 +47,41 @@ def test_health_reports_ok(client) -> None:
|
||||
assert body["version"]
|
||||
|
||||
|
||||
def test_config_returns_default_app_metadata(client) -> None:
|
||||
"""GET /api/config is public (anonymous) and returns exactly six
|
||||
def test_config_returns_default_app_metadata(client, db: Session) -> None:
|
||||
"""GET /api/config is public (anonymous) and returns exactly five
|
||||
keys — the phase-39 app metadata, the phase-59 docs flag (inert
|
||||
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)."""
|
||||
footer line). Phase 91: with an empty ui_settings table the
|
||||
effective strings are the env defaults (B1 — DB-over-env, the row
|
||||
absent here); the retired CSS-file theming's ``theme`` key is gone
|
||||
(task 03 — the five keys are the entire contract)."""
|
||||
_clear_ui_settings(db)
|
||||
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",
|
||||
"input_placeholder", "footer_text",
|
||||
}
|
||||
assert body["app_name"] == "Brain of Reese"
|
||||
assert body["version"] == get_settings().app_version
|
||||
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.
|
||||
# byte-identical contract).
|
||||
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:
|
||||
"""GET /api/config reflects a Settings override (e.g. BOR_APP_NAME)."""
|
||||
def test_config_follows_overridden_app_name(client, db: Session) -> None:
|
||||
"""GET /api/config reflects a Settings override (e.g. BOR_APP_NAME).
|
||||
Phase 91: the override is the ENV side of the DB-over-env resolver —
|
||||
with an empty ui_settings row the effective app_name is the
|
||||
overridden env value."""
|
||||
from app.config import Settings
|
||||
from app.main import app as fastapi_app
|
||||
|
||||
_clear_ui_settings(db)
|
||||
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
|
||||
app_name="Brain of Testy"
|
||||
)
|
||||
@@ -68,7 +91,7 @@ def test_config_follows_overridden_app_name(client) -> None:
|
||||
body = r.json()
|
||||
assert set(body) == {
|
||||
"app_name", "version", "docs_repo_configured",
|
||||
"input_placeholder", "footer_text", "theme",
|
||||
"input_placeholder", "footer_text",
|
||||
}
|
||||
assert body["app_name"] == "Brain of Testy"
|
||||
assert body["version"] == "0.1.0"
|
||||
@@ -77,18 +100,21 @@ def test_config_follows_overridden_app_name(client) -> None:
|
||||
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."""
|
||||
def test_config_serves_ui_customization_overrides(client, db: Session) -> None:
|
||||
"""Phase 62: the UI customization string keys mirror Settings
|
||||
overrides (``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``) — the
|
||||
values the frontend brand layer applies at boot, so this dict is
|
||||
the whole contract. Phase 91: placeholder + footer are the
|
||||
EFFECTIVE strings — the env overrides win with an empty
|
||||
ui_settings row (B1); the retired theming's ``theme`` key is gone
|
||||
(task 03)."""
|
||||
from app.config import Settings
|
||||
from app.main import app as fastapi_app
|
||||
|
||||
_clear_ui_settings(db)
|
||||
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")
|
||||
@@ -96,16 +122,15 @@ def test_config_serves_ui_customization_overrides(client) -> None:
|
||||
body = r.json()
|
||||
assert set(body) == {
|
||||
"app_name", "version", "docs_repo_configured",
|
||||
"input_placeholder", "footer_text", "theme",
|
||||
"input_placeholder", "footer_text",
|
||||
}
|
||||
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, db: Session) -> None:
|
||||
"""Phase 59 (task 05): ``docs_repo_configured`` mirrors
|
||||
``settings.docs_configured`` — a real bool (never a truthy string)
|
||||
that flips true the moment BOR_DOCS_REPO is non-empty: that flag is
|
||||
@@ -113,6 +138,7 @@ def test_config_docs_flag_tracks_settings(client) -> None:
|
||||
from app.config import Settings
|
||||
from app.main import app as fastapi_app
|
||||
|
||||
_clear_ui_settings(db)
|
||||
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
|
||||
app_name="Brain of Testy",
|
||||
docs_repo="/srv/docs-repo",
|
||||
@@ -206,6 +232,10 @@ def test_suggestions_honors_bor_suggestions_env_override(
|
||||
# shell-body marker (the Tokens view section is inside the
|
||||
# shell; the per-view title is client-side now).
|
||||
("/tokens.html", 'id="view-tokens"'), # phase 79: shell route
|
||||
# Phase 91 (task 04): /theme.html is a SHELL route too — the
|
||||
# shell-body marker (the Theme view section is inside the
|
||||
# shell; the per-view title is client-side now).
|
||||
("/theme.html", 'id="view-theme"'), # phase 91: shell route
|
||||
("/shared.html", "Shared conversation"), # phase 51: anonymous shared page
|
||||
],
|
||||
)
|
||||
@@ -246,6 +276,7 @@ def test_index_page_no_cache_with_versioned_asset_refs(client) -> None:
|
||||
["/sources.html", "/document.html", "/login.html", "/tuning.html",
|
||||
"/git-sources.html", "/history.html", # phase 50: + History (shell route, task 03)
|
||||
"/tokens.html", # phase 79 task 06: + Tokens (shell route)
|
||||
"/theme.html", # phase 91 task 04: + Theme (shell route)
|
||||
"/shared.html"], # phase 51: + the anonymous shared page
|
||||
)
|
||||
def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
|
||||
@@ -271,6 +302,11 @@ def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
|
||||
# client-side title: the pin asserts the shell never carries
|
||||
# the per-view title statically (the router writes it).
|
||||
("/tokens.html", 'id="view-tokens"', "Access tokens · Brain of Reese"), # phase 79 task 06
|
||||
# phase 91 task 04: the seventh view — there was never a
|
||||
# standalone theme.html, so "old_title" is the router's
|
||||
# client-side title: the pin asserts the shell never carries
|
||||
# the per-view title statically (the router writes it).
|
||||
("/theme.html", 'id="view-theme"', "Theme · Brain of Reese"), # phase 91 task 04
|
||||
],
|
||||
)
|
||||
def test_shell_routes_serve_the_shell_no_cache_versioned(
|
||||
|
||||
@@ -87,6 +87,7 @@ SHELL_BACKED_PAGES = {
|
||||
"/git-sources.html": "index.html", # phase 76 task 02
|
||||
"/history.html": "index.html", # phase 76 task 03
|
||||
"/tokens.html": "index.html", # phase 79 task 06
|
||||
"/theme.html": "index.html", # phase 91 task 04
|
||||
}
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,193 @@
|
||||
"""Integration: migration 0014 (ui_settings) schema contract.
|
||||
|
||||
Drives the **real Alembic engine** against the live dev database
|
||||
(``podman compose up -d db``), mirroring the house pattern of
|
||||
``test_migration_0012.py`` (information_schema assertions on the state
|
||||
the migration must leave). The tests target revision ``0014``
|
||||
explicitly so later migrations cannot break them:
|
||||
|
||||
* upgrade 0013 → 0014 → the ``ui_settings`` table exists with the full
|
||||
column contract (``id`` INTEGER PK; the 3 strings VARCHAR(300) NULL;
|
||||
the 8 identity colors VARCHAR(7) NULL — NULL = default, B1); no
|
||||
server defaults anywhere (a missing row means "defaults");
|
||||
* an inserted id-1 row round-trips its values (the PUT upsert's shape);
|
||||
* downgrade to 0013 → the table is gone (A13 — reversible), the rest of
|
||||
the schema (e.g. ``api_tokens.token_hash``) survives;
|
||||
* upgrade back to 0014 → the table is back (round-trip).
|
||||
|
||||
The ``alembic`` fixture guarantees the DB ends at head even if a test
|
||||
fails or the process is interrupted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from alembic import command
|
||||
from app.db import db_available
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def alembic(db: Session) -> Iterator[Config]:
|
||||
"""Real Alembic config bound to the dev DB (URL from app settings).
|
||||
|
||||
Starts at head (repairs an interrupted earlier run); teardown upgrades
|
||||
to head no matter what happened, so the dev DB is never left below
|
||||
head.
|
||||
"""
|
||||
if not db_available():
|
||||
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
|
||||
cfg.set_main_option("script_location", "alembic")
|
||||
command.upgrade(cfg, "head")
|
||||
try:
|
||||
yield cfg
|
||||
finally:
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
|
||||
def _version(db: Session) -> str | None:
|
||||
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||
|
||||
|
||||
def _table_exists(db: Session, table: str) -> bool:
|
||||
count: Any = db.execute(
|
||||
text(
|
||||
"SELECT count(*) FROM information_schema.tables"
|
||||
" WHERE table_schema = 'public' AND table_name = :t"
|
||||
),
|
||||
{"t": table},
|
||||
).scalar()
|
||||
assert count is not None, "information_schema count must be an int"
|
||||
return int(count) == 1
|
||||
|
||||
|
||||
def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None:
|
||||
"""(data_type, is_nullable, column_default, character_maximum_length)
|
||||
for one table column."""
|
||||
row = db.execute(
|
||||
text(
|
||||
"SELECT data_type, is_nullable, column_default, character_maximum_length"
|
||||
" FROM information_schema.columns"
|
||||
" WHERE table_name = :t AND column_name = :c"
|
||||
),
|
||||
{"t": table, "c": column},
|
||||
).fetchone()
|
||||
return tuple(row) if row is not None else None
|
||||
|
||||
|
||||
def _insert_row(db: Session, *, app_name: str | None, brand: str | None) -> None:
|
||||
"""Insert the single row (the PUT upsert's shape) with two values set
|
||||
and the rest NULL — the NULL = default state the resolver merges."""
|
||||
db.execute(
|
||||
text(
|
||||
"INSERT INTO ui_settings (id, app_name, brand) VALUES (1, :n, :b)"
|
||||
),
|
||||
{"n": app_name, "b": brand},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def _delete_row(db: Session) -> None:
|
||||
db.execute(text("DELETE FROM ui_settings WHERE id = 1"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_upgrade_to_0014_adds_ui_settings(db: Session, alembic: Config) -> None:
|
||||
"""Upgrade 0013 → 0014: the table exists with the full column
|
||||
contract (the Integer PK, the 3 strings VARCHAR(300) NULL, the 8
|
||||
colors VARCHAR(7) NULL — no server defaults anywhere: a missing row
|
||||
means "defaults"); the table is absent at 0013."""
|
||||
command.downgrade(alembic, "0013") # start from the pre-0014 state
|
||||
assert _version(db) == "0013"
|
||||
assert not _table_exists(db, "ui_settings"), "ui_settings must be absent at 0013"
|
||||
|
||||
command.upgrade(alembic, "0014")
|
||||
assert _version(db) == "0014", "alembic_version must be at 0014"
|
||||
assert _table_exists(db, "ui_settings"), "ui_settings must exist at 0014"
|
||||
|
||||
id_col = _column(db, "ui_settings", "id")
|
||||
assert id_col is not None, "ui_settings.id is missing"
|
||||
assert id_col[0] == "integer", "ui_settings.id must be INTEGER"
|
||||
assert id_col[1] == "NO", "ui_settings.id must be NOT NULL (PK)"
|
||||
|
||||
for name in ("app_name", "input_placeholder", "footer_text"):
|
||||
col = _column(db, "ui_settings", name)
|
||||
assert col is not None, f"ui_settings.{name} is missing"
|
||||
assert col[0] == "character varying", f"ui_settings.{name} must be VARCHAR"
|
||||
assert col[1] == "YES", f"ui_settings.{name} must be NULL (env default, B1)"
|
||||
assert col[2] is None, f"ui_settings.{name} must have no server default"
|
||||
assert col[3] == 300, f"ui_settings.{name} must be String(300)"
|
||||
|
||||
for name in ("bg", "surface", "ink", "ink_soft", "line",
|
||||
"brand", "brand_soft", "brand_ink"):
|
||||
col = _column(db, "ui_settings", name)
|
||||
assert col is not None, f"ui_settings.{name} is missing"
|
||||
assert col[0] == "character varying", f"ui_settings.{name} must be VARCHAR"
|
||||
assert col[1] == "YES", f"ui_settings.{name} must be NULL (the built-in, B1)"
|
||||
assert col[2] is None, f"ui_settings.{name} must have no server default"
|
||||
assert col[3] == 7, f"ui_settings.{name} must be String(7) — #rrggbb"
|
||||
|
||||
|
||||
def test_inserted_id_1_row_round_trips_values(db: Session, alembic: Config) -> None:
|
||||
"""At 0014, the single row (id 1, the PUT upsert's shape) round-trips
|
||||
its set values verbatim and keeps the unset columns NULL."""
|
||||
command.upgrade(alembic, "head")
|
||||
_insert_row(db, app_name="Brain of Testy", brand="#818cf8")
|
||||
try:
|
||||
row = db.execute(
|
||||
text(
|
||||
"SELECT id, app_name, input_placeholder, footer_text, brand"
|
||||
" FROM ui_settings WHERE id = 1"
|
||||
)
|
||||
).fetchone()
|
||||
assert row is not None, "the ui_settings row must exist"
|
||||
assert row[0] == 1, "the single row is always id 1"
|
||||
assert row[1] == "Brain of Testy", "app_name must round-trip verbatim"
|
||||
assert row[2] is None, "input_placeholder must stay NULL (the default)"
|
||||
assert row[3] is None, "footer_text must stay NULL (the default)"
|
||||
assert row[4] == "#818cf8", "brand must round-trip verbatim"
|
||||
finally:
|
||||
_delete_row(db)
|
||||
|
||||
|
||||
def test_downgrade_to_0013_drops_the_table(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade to 0013: the table is gone (A13 — reversible) while the
|
||||
rest of the schema survives."""
|
||||
command.downgrade(alembic, "0013")
|
||||
assert _version(db) == "0013"
|
||||
assert not _table_exists(db, "ui_settings"), "ui_settings must be dropped"
|
||||
|
||||
token_col = _column(db, "api_tokens", "token_hash")
|
||||
assert token_col is not None and token_col[0] == "character varying", (
|
||||
"api_tokens.token_hash must survive the downgrade"
|
||||
)
|
||||
ignore_col = _column(db, "git_sources", "ignore_paths")
|
||||
assert ignore_col is not None and ignore_col[0] == "jsonb", (
|
||||
"git_sources.ignore_paths must survive the downgrade"
|
||||
)
|
||||
|
||||
|
||||
def test_upgrade_round_trip_restores_the_table(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade to 0013, then upgrade back to 0014: the table is back
|
||||
with the column contract intact."""
|
||||
command.downgrade(alembic, "0013")
|
||||
command.upgrade(alembic, "0014")
|
||||
assert _version(db) == "0014", "round-trip upgrade must land at 0014"
|
||||
|
||||
assert _table_exists(db, "ui_settings"), "ui_settings must be back"
|
||||
|
||||
id_col = _column(db, "ui_settings", "id")
|
||||
assert id_col is not None and id_col[0] == "integer", (
|
||||
"id must be INTEGER after the round-trip"
|
||||
)
|
||||
brand = _column(db, "ui_settings", "brand")
|
||||
assert brand is not None and brand[1] == "YES", (
|
||||
"brand must be VARCHAR NULL after the round-trip"
|
||||
)
|
||||
assert brand[3] == 7, "brand must be String(7) after the round-trip"
|
||||
@@ -30,8 +30,12 @@ from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core import theming
|
||||
from app.core.security_headers import CSP
|
||||
from app.models import UiSettings
|
||||
|
||||
#: The exact owner-approved A1 policy string (phase 82). The constant is
|
||||
#: the single source of truth; the unit suite additionally pins that the
|
||||
@@ -54,9 +58,14 @@ def _assert_security_headers(response: httpx.Response) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_page_carries_all_three_headers(client: TestClient) -> None:
|
||||
def test_page_carries_all_three_headers(client: TestClient, db: Session) -> None:
|
||||
"""``GET /`` (the shell page) — 200 + all three headers, CSP exactly
|
||||
the A1 string."""
|
||||
the A1 string. The ``ui_settings`` row is cleared first (phase 91,
|
||||
task 05: a themed page carries the A1 string EXTENDED with the
|
||||
style-src hash — the plain-A1 pin is the UNTHAMED page's
|
||||
contract, and the dev database must not leak a theme into it)."""
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.commit()
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
_assert_security_headers(response)
|
||||
@@ -102,3 +111,44 @@ def test_caching_rewrite_still_runs_under_headers_middleware(client: TestClient)
|
||||
"the ?v=<token> asset rewrite no longer runs — the outermost "
|
||||
"security-header middleware altered or swallowed the body"
|
||||
)
|
||||
|
||||
|
||||
def test_themed_page_carries_a1_plus_style_src_theme_hash(
|
||||
client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""Phase 91 (task 05, defect fix): the A1 CSP would BLOCK the
|
||||
inline ``<style id="bor-theme">`` pre-paint tag in every real
|
||||
browser (``style-src`` falls back to ``default-src 'self'``) — so a
|
||||
THemed HTML page carries the A1 string EXTENDED with
|
||||
``style-src 'self' 'sha256-<hash>'``, the CSP3 hash of the exact
|
||||
tag content: the current theme is the only inline style ever
|
||||
permitted, no blanket ``'unsafe-inline'``, a different palette is
|
||||
still blocked. The unthemed page keeps the plain A1 string (no
|
||||
exemption for a tag that is not served). Pinned against the real
|
||||
app (the unit suite pins the two middleware halves in isolation).
|
||||
"""
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.commit()
|
||||
try:
|
||||
db.add(UiSettings(id=1, brand="#818cf8"))
|
||||
db.commit()
|
||||
colors = dict(theming.BUILTIN_COLORS)
|
||||
colors["brand"] = "#818cf8"
|
||||
tag = theming.theme_style_tag(colors)
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
assert tag in response.text # the themed page serves the tag
|
||||
assert response.headers["content-security-policy"] == (
|
||||
f"{CSP}; style-src 'self' '{theming.theme_csp_hash(tag)}'"
|
||||
)
|
||||
assert "unsafe-inline" not in response.headers["content-security-policy"]
|
||||
# The other two phase-82 headers ride along, unchanged.
|
||||
assert response.headers["x-frame-options"] == "DENY"
|
||||
assert response.headers["x-content-type-options"] == "nosniff"
|
||||
finally:
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.commit()
|
||||
# The UNthemed page after the row is gone: plain A1, no tag.
|
||||
response = client.get("/")
|
||||
assert response.headers["content-security-policy"] == CSP
|
||||
assert "bor-theme" not in response.text
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Integration: the admin UI-settings gate + the /api/config effective
|
||||
strings (phase 91, task 01).
|
||||
|
||||
The auth + public-contract half of task 01, driven through the real app
|
||||
(TestClient keeps the cookie jar — the house ``test_auth_api`` /
|
||||
``test_tokens_api`` admin-login pattern):
|
||||
|
||||
* the admin gate — ``GET /api/ui-settings`` and ``PUT`` are 403
|
||||
``admin only`` for anonymous callers AND for a signed-in token USER
|
||||
(role ``"user"`` — the router-wide ``require_admin`` closes the
|
||||
surface on every method, the phase-79 token matrix contract), 200 for
|
||||
the admin on both;
|
||||
* ``/api/config`` effective strings (B1: DB-over-env) — an env-only
|
||||
deployment (no ``ui_settings`` row) returns the env strings; after an
|
||||
admin PUT, the ANONYMOUS ``/api/config`` returns the DB strings;
|
||||
* the five-key /api/config contract: the retired CSS-file theming's
|
||||
``theme`` key is GONE (task 03) — the app metadata, the docs flag,
|
||||
and the two effective strings are the entire response.
|
||||
|
||||
Real Postgres (``podman compose up -d db``); no LLM involved.
|
||||
|
||||
Requires: podman compose up -d db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core import theming
|
||||
from app.main import app as fastapi_app
|
||||
from tests.conftest import ADMIN_PASSWORD
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_state(db: Session) -> Iterator[None]:
|
||||
"""Both touched tables are global state: the single ui_settings row
|
||||
and the api_tokens the token-user test creates (the house
|
||||
TRUNCATE/DELETE reset pattern)."""
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.execute(text("TRUNCATE api_tokens"))
|
||||
db.commit()
|
||||
yield
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.execute(text("TRUNCATE api_tokens"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _admin_client() -> TestClient:
|
||||
"""A fresh client signed in as the admin (the ``_admin_client``
|
||||
pattern from test_auth_api.py)."""
|
||||
c = TestClient(fastapi_app)
|
||||
r = c.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
|
||||
return c
|
||||
|
||||
|
||||
def test_anonymous_get_and_put_403(client: TestClient) -> None:
|
||||
"""Router-level ``require_admin``: both routes are 403 ``admin only``
|
||||
for the unsigned-in caller (one fixed detail — no enumeration)."""
|
||||
r = client.get("/api/ui-settings")
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
r = client.put("/api/ui-settings", json={"app_name": "nope"})
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
|
||||
|
||||
def test_token_user_get_and_put_403(client: TestClient) -> None:
|
||||
"""A signed-in token USER (role ``"user"``) is NOT the admin: the
|
||||
Theme tab's surface is closed to them on both methods (the
|
||||
phase-79 token matrix contract — only the admin themes the
|
||||
deployment)."""
|
||||
admin = _admin_client()
|
||||
r = admin.post("/api/tokens", json={"label": "alice"})
|
||||
assert r.status_code == 201, r.text
|
||||
token = r.json()["token"]
|
||||
assert client.post("/api/token-auth", json={"token": token}).status_code == 204
|
||||
assert client.get("/api/whoami").json() == {"authenticated": True, "role": "user"}
|
||||
|
||||
r = client.get("/api/ui-settings")
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
r = client.put("/api/ui-settings", json={"brand": "#123456"})
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
|
||||
|
||||
def test_admin_get_and_put_200(client: TestClient, db: Session) -> None:
|
||||
"""The admin passes the gate on both methods: GET reports the
|
||||
effective defaults (row missing), PUT persists + reports the new
|
||||
effective values, and a follow-up GET reads them back."""
|
||||
client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
||||
|
||||
r = client.get("/api/ui-settings")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
|
||||
assert body["app_name"] == get_settings().app_name
|
||||
assert {k: body[k] for k in theming.COLOR_FIELDS} == theming.BUILTIN_COLORS
|
||||
|
||||
r = client.put(
|
||||
"/api/ui-settings",
|
||||
json={"app_name": "Reese Brain", "brand": "#818cf8"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["app_name"] == "Reese Brain"
|
||||
assert r.json()["brand"] == "#818cf8"
|
||||
|
||||
r = client.get("/api/ui-settings")
|
||||
assert r.status_code == 200
|
||||
assert r.json()["app_name"] == "Reese Brain"
|
||||
assert r.json()["brand"] == "#818cf8"
|
||||
# Untouched fields stay at their defaults (DB-over-env / -built-in).
|
||||
assert r.json()["footer_text"] == get_settings().footer_text
|
||||
assert r.json()["bg"] == theming.BUILTIN_COLORS["bg"]
|
||||
|
||||
|
||||
def _config_keys() -> set[str]:
|
||||
"""The /api/config key set after task 03: the five phase-39/59/62
|
||||
keys — the retired CSS-file theming's ``theme`` key is gone."""
|
||||
return {"app_name", "version", "docs_repo_configured",
|
||||
"input_placeholder", "footer_text"}
|
||||
|
||||
|
||||
def test_api_config_env_only_deployment_returns_env_strings(client: TestClient) -> None:
|
||||
"""B1 with an empty ui_settings table: /api/config serves the ENV
|
||||
strings (the code defaults — conftest pins them) and the key set is
|
||||
the five-key contract (the retired theming's ``theme`` key is gone
|
||||
— task 03)."""
|
||||
r = client.get("/api/config")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == _config_keys()
|
||||
assert body["app_name"] == get_settings().app_name
|
||||
assert body["input_placeholder"] == get_settings().input_placeholder
|
||||
assert body["footer_text"] == get_settings().footer_text
|
||||
|
||||
|
||||
def test_api_config_carries_no_theme_key(client: TestClient) -> None:
|
||||
"""Phase 91 (task 03): the retired CSS-file theming left NO trace
|
||||
in the endpoint — the response has no ``theme`` key at all (an
|
||||
env-only deployment and a themed one answer with the same keys; the
|
||||
colors are injected pre-paint, they never ride this fetch)."""
|
||||
r = client.get("/api/config")
|
||||
assert r.status_code == 200
|
||||
assert "theme" not in r.json()
|
||||
|
||||
|
||||
def test_api_config_returns_db_strings_after_admin_put(
|
||||
client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""B1 with a set row: after an admin PUT, the ANONYMOUS /api/config
|
||||
(the frontend's boot fetch — no admin needed) serves the DB strings
|
||||
over the env values; the untouched fields keep the env values; the
|
||||
five-key set is unchanged (the retired ``theme`` key is absent)."""
|
||||
admin = _admin_client()
|
||||
r = admin.put(
|
||||
"/api/ui-settings",
|
||||
json={
|
||||
"app_name": "Brain of Testy",
|
||||
"input_placeholder": "Ask the vault…",
|
||||
"footer_text": "Powered by my own models",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
r = client.get("/api/config")
|
||||
assert r.status_code == 200 # /api/config stays PUBLIC (no gate)
|
||||
body = r.json()
|
||||
assert set(body) == _config_keys()
|
||||
assert body["app_name"] == "Brain of Testy"
|
||||
assert body["input_placeholder"] == "Ask the vault…"
|
||||
assert body["footer_text"] == "Powered by my own models"
|
||||
# The colors never ride /api/config (the pre-paint injection is
|
||||
# task 02; brand.js's surface is the five keys).
|
||||
assert "theme" not in body
|
||||
assert body["version"] == get_settings().app_version
|
||||
@@ -28,12 +28,17 @@ import pytest
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from starlette.responses import FileResponse
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
import app.core.caching as caching
|
||||
from app.config import Settings
|
||||
from app.core import theming
|
||||
from app.core.caching import asset_version, rewrite_asset_refs
|
||||
from app.core.security_headers import CSP
|
||||
from app.models import UiSettings
|
||||
|
||||
TOKEN = "abc123"
|
||||
|
||||
@@ -212,6 +217,7 @@ def test_html_pages_include_history() -> None:
|
||||
"/git-sources.html",
|
||||
"/history.html",
|
||||
"/tokens.html", # phase 79 task 06: the admin tokens page (shell route)
|
||||
"/theme.html", # phase 91 task 04: the admin theme page (shell route)
|
||||
"/shared.html", # phase 51: the shared page's static path
|
||||
"/doc-edit.html", # phase 59: the doc edit screen (task 06)
|
||||
):
|
||||
@@ -718,3 +724,160 @@ def test_assets_path_keeps_validators_and_304(
|
||||
assert r304.status_code == 304 # versioned-URL 304s stay safe
|
||||
assert r304.content == b""
|
||||
assert r304.headers["cache-control"] == caching.ASSET_CACHE_CONTROL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 91 (task 02): the pre-paint inline theme tag
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The middleware's rewrite branch now ALSO builds the theme tag from the
|
||||
# effective ``ui_settings`` row (task 01's resolver — one short-lived
|
||||
# session per response, no process cache) and inserts it before the
|
||||
# first ``</head>``. Unset/defaults → ``tag == ""`` → the served bytes
|
||||
# are EXACTLY the phase-33/54 rewrite-only output (B4's byte-identical
|
||||
# contract); a DB blip is the same no-op (the page never breaks).
|
||||
|
||||
|
||||
def _theme_page(name: str) -> str:
|
||||
"""The ``text/html`` body the fixture routes below serve."""
|
||||
return (
|
||||
f"<html><head><title>{name}</title>"
|
||||
'<link rel="stylesheet" href="/assets/styles.css">'
|
||||
f"</head><body><main>{name}</main></body></html>"
|
||||
)
|
||||
|
||||
|
||||
def _theme_page_app() -> FastAPI:
|
||||
"""A bare app with the middleware: the shell page at ``/`` plus a
|
||||
second known page (``/document.html``) and the phase-51 dynamic
|
||||
``/shared/<token>`` route (the prefix branch) — every served
|
||||
``text/html`` with one versionable asset ref."""
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index() -> str:
|
||||
return _theme_page("index")
|
||||
|
||||
@app.get("/document.html", response_class=HTMLResponse)
|
||||
def document() -> str:
|
||||
return _theme_page("document")
|
||||
|
||||
@app.get("/shared/{token}", response_class=HTMLResponse)
|
||||
def shared(token: str) -> str:
|
||||
return _theme_page(f"shared-{token}")
|
||||
|
||||
caching.configure_caching(app)
|
||||
return app
|
||||
|
||||
|
||||
def test_middleware_unset_page_is_byte_identical_to_rewrite_only(db: Session) -> None:
|
||||
"""THE byte-identical contract (B4): with NO ``ui_settings`` row the
|
||||
served body is EXACTLY the phase-33/54 rewrite-only output — not a
|
||||
single byte differs, no ``#bor-theme`` anywhere."""
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.commit()
|
||||
client = TestClient(_theme_page_app())
|
||||
token = caching.asset_version()
|
||||
for path, name in (("/", "index"), ("/document.html", "document")):
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
expected = caching.rewrite_asset_refs(_theme_page(name), token)
|
||||
assert r.content == expected.encode("utf-8") # byte-identical
|
||||
assert "bor-theme" not in r.text
|
||||
# No tag → no style-src exemption: the response carries no CSP
|
||||
# of its own (this bare app has no security-headers layer), so
|
||||
# the outer middleware's plain A1 string stands untouched.
|
||||
assert "content-security-policy" not in r.headers
|
||||
|
||||
|
||||
def test_middleware_themed_injects_tag_before_head_on_every_page(db: Session) -> None:
|
||||
"""A ``ui_settings`` row with ONE changed color: every HTML page —
|
||||
``/``, the non-shell ``/document.html``, and the dynamic
|
||||
``/shared/<token>`` (the prefix branch) — carries EXACTLY ONE
|
||||
``<style id="bor-theme">`` IMMEDIATELY before ``</head>`` (a leading
|
||||
newline, nothing between), with all 8 ``--*`` vars in ``COLOR_FIELDS``
|
||||
order and the changed value; the ``?v=`` asset rewrite still applies
|
||||
alongside."""
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.add(UiSettings(id=1, brand="#818cf8"))
|
||||
db.commit()
|
||||
try:
|
||||
colors = dict(theming.BUILTIN_COLORS)
|
||||
colors["brand"] = "#818cf8" # one changed color, rest built-in
|
||||
tag = theming.theme_style_tag(colors)
|
||||
client = TestClient(_theme_page_app())
|
||||
token = caching.asset_version()
|
||||
shared_token = uuid.uuid4().hex
|
||||
for path in ("/", "/document.html", f"/shared/{shared_token}"):
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
# Exactly one tag …
|
||||
assert r.text.count('id="bor-theme"') == 1
|
||||
# … with a leading newline immediately before the first </head>
|
||||
# (nothing between the tag and the close).
|
||||
assert "\n" + tag + "</head>" in r.text
|
||||
assert r.text.index(tag) == r.text.index("</head>") - len(tag)
|
||||
# All 8 vars, COLOR_FIELDS order, the changed value present.
|
||||
declared = re.search(r'<style id="bor-theme">:root\{([^}]*)\}</style>', r.text)
|
||||
assert declared is not None
|
||||
names = re.findall(r"--([a-z-]+):", declared.group(1))
|
||||
assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS]
|
||||
assert "--brand:#818cf8;" in r.text
|
||||
# Phase 91 (task 05): the inline tag is blocked by the
|
||||
# phase-82 CSP in a real browser unless this response also
|
||||
# carries the style-src exemption — the A1 string plus a
|
||||
# sha256 hash of the EXACT tag content (the current theme
|
||||
# is the only inline style ever permitted; no
|
||||
# 'unsafe-inline').
|
||||
assert r.headers["content-security-policy"] == (
|
||||
f"{CSP}; style-src 'self' '{theming.theme_csp_hash(tag)}'"
|
||||
)
|
||||
assert "unsafe-inline" not in r.headers["content-security-policy"]
|
||||
# The phase-33/54 asset rewrite is untouched and applies too.
|
||||
assert f'href="/assets/styles.css?v={token}"' in r.text
|
||||
finally:
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("what",),
|
||||
[("session",), ("resolver",)],
|
||||
ids=["session-open-fails", "resolver-fails"],
|
||||
)
|
||||
def test_middleware_db_failure_serves_page_without_tag(
|
||||
monkeypatch: pytest.MonkeyPatch, what: str
|
||||
) -> None:
|
||||
"""A DB blip must NEVER break the page (loadHealth house style):
|
||||
whether the session fails to open or the row read raises, the page
|
||||
still 200s with the byte-identical rewrite-only body (no tag) and
|
||||
the no-cache contract intact — a pre-migration boot is the same
|
||||
path."""
|
||||
if what == "session":
|
||||
|
||||
def _boom_session() -> object:
|
||||
raise RuntimeError("db down")
|
||||
|
||||
monkeypatch.setattr(caching, "SessionLocal", _boom_session)
|
||||
else:
|
||||
|
||||
def _boom_resolver(session: object) -> dict[str, str]:
|
||||
raise RuntimeError("select failed")
|
||||
|
||||
monkeypatch.setattr(caching.theming, "effective_settings", _boom_resolver)
|
||||
client = TestClient(_theme_page_app())
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert r.headers["cache-control"] == "no-cache"
|
||||
token = caching.asset_version()
|
||||
assert r.content == caching.rewrite_asset_refs(
|
||||
_theme_page("index"), token
|
||||
).encode("utf-8")
|
||||
assert "bor-theme" not in r.text
|
||||
# The DB-failure fallback is the UNSET shape: no tag, no style-src
|
||||
# exemption (the plain A1 policy stands — the page degrades to the
|
||||
# built-in palette, never to an inline-style exemption for a tag
|
||||
# that is not there).
|
||||
assert "content-security-policy" not in r.headers
|
||||
|
||||
@@ -481,70 +481,25 @@ def test_docs_branchs_garbage_ignored_when_repo_unset(
|
||||
|
||||
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."""
|
||||
phase-61 copy is the DEFAULT (composer placeholder + footer line).
|
||||
Phase 91 (task 03): the retired CSS-file theme env var is gone —
|
||||
``Settings`` no longer has a theme field at all (a leftover value
|
||||
in a deployment's .env is ignored, not a boot failure)."""
|
||||
s = _settings()
|
||||
assert s.input_placeholder == "Ask me anything…"
|
||||
assert s.footer_text == "Powered by self-hosted models"
|
||||
assert s.theme == ""
|
||||
assert "theme" not in type(s).model_fields
|
||||
|
||||
|
||||
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``);
|
||||
"""The two string settings honor their ``BOR_`` env vars
|
||||
(``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``);
|
||||
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()
|
||||
|
||||
@@ -99,14 +99,16 @@ def test_brand_js_reskins_title_brand_text_prose_and_attributes() -> None:
|
||||
|
||||
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).
|
||||
/api/config answer also drives the two customization STRING keys —
|
||||
the #message-input placeholder and every .footer-text node. Phase
|
||||
91 (task 03): the retired CSS-file theme link (the old step 7)
|
||||
and its id-guard / styles.css-finder / onerror-degrade machinery
|
||||
are GONE — color theming is server-side inline injection
|
||||
(app/core/theming.py), never a brand.js DOM write.
|
||||
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"
|
||||
"the 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).
|
||||
@@ -116,28 +118,14 @@ def test_brand_js_applies_phase_62_customization_from_the_same_fetch() -> None:
|
||||
# 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
|
||||
# Phase 91 (task 03): the retired theme-link machinery is absent —
|
||||
# no theme key read, no link insertion (the whole step-7 block
|
||||
# lived inside the themeName guard — themeName gone, block gone).
|
||||
assert "themeName" not in js
|
||||
assert 'insertAdjacentElement' 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) {"):
|
||||
for guard in ("if (placeholder) {", "if (footerText) {"):
|
||||
assert guard in js, (
|
||||
f"an empty value must skip its application ({guard})"
|
||||
)
|
||||
|
||||
@@ -104,8 +104,8 @@ def test_view_map_covers_the_shell_paths() -> None:
|
||||
"""The VIEW map is pathname → view name: the shell's own two URLs
|
||||
("/" and "/index.html") are the chat view, plus one entry per
|
||||
folded view (tasks 01–03: tuning, rag, git-sources, history;
|
||||
phase 79 task 06: tokens — all five non-chat navbar views are
|
||||
in)."""
|
||||
phase 79 task 06: tokens; phase 91 task 04: theme — all six
|
||||
non-chat navbar views are in)."""
|
||||
js = _js()
|
||||
view_start = js.find("const VIEW = {")
|
||||
assert view_start != -1, "the VIEW map must exist"
|
||||
@@ -123,8 +123,11 @@ def test_view_map_covers_the_shell_paths() -> None:
|
||||
assert '"/tokens.html": "tokens"' in view_body, (
|
||||
"phase 79 task 06 folds the Tokens view into the shell"
|
||||
)
|
||||
assert '"/theme.html": "theme"' in view_body, (
|
||||
"phase 91 task 04 folds the Theme view into the shell"
|
||||
)
|
||||
# The view names are the #view-<name> section slugs in index.html.
|
||||
for name in ("chat", "tuning", "history", "tokens"):
|
||||
for name in ("chat", "tuning", "history", "tokens", "theme"):
|
||||
assert f'id="view-{name}"' in _html(), f"missing the #view-{name} section"
|
||||
|
||||
|
||||
@@ -218,6 +221,9 @@ def test_only_non_chat_views_have_lazy_modules() -> None:
|
||||
assert 'tokens: () => import("./tokens.js")' in mods_body, (
|
||||
"the Tokens view module is lazy-imported on first show"
|
||||
)
|
||||
assert 'theme: () => import("./theme.js")' in mods_body, (
|
||||
"the Theme view module is lazy-imported on first show (phase 91)"
|
||||
)
|
||||
assert '"chat"' not in mods_body, "the chat view has no lazy module"
|
||||
assert 'import("./app.js")' not in js, "app.js must never be lazy-imported"
|
||||
|
||||
@@ -275,6 +281,11 @@ def test_router_writes_active_state_title_and_meta() -> None:
|
||||
assert "Saved chats — every conversation is saved automatically, one click back." in js
|
||||
assert 'tokens: "Access tokens · Brain of Reese"' in js
|
||||
assert "Generate and revoke the API tokens that let people use the app." in js
|
||||
assert 'theme: "Theme · Brain of Reese"' in js
|
||||
assert (
|
||||
"Set the palette and branding — the theme is baked into every served page, "
|
||||
"live on the first paint."
|
||||
) in js
|
||||
# The brand composition (phase 39's window.BOR_BRAND, read at
|
||||
# write time — never a hardcoded stamp).
|
||||
assert 'window.BOR_BRAND || "Brain of Reese"' in js
|
||||
@@ -351,6 +362,15 @@ def test_shell_markup_has_one_main_two_views_and_chat_only_active() -> None:
|
||||
tokens_link = tokens_match.group(0)
|
||||
assert "hidden" in tokens_link, "#nav-tokens ships hidden (admin-only)"
|
||||
assert "is-active" not in tokens_link, "no static active stamp on the Tokens link"
|
||||
# The Theme nav link (phase 91 task 04) ships hidden (admin-only)
|
||||
# and UNstamped too — the router is the single writer of the active
|
||||
# state, and a token user (role "user") must never see the link
|
||||
# (header.js reveals it for admin only).
|
||||
theme_match = re.search(r'<a[^>]*id="nav-theme"[^>]*>', html)
|
||||
assert theme_match, "the shell must carry the #nav-theme nav link"
|
||||
theme_link = theme_match.group(0)
|
||||
assert "hidden" in theme_link, "#nav-theme ships hidden (admin-only)"
|
||||
assert "is-active" not in theme_link, "no static active stamp on the Theme link"
|
||||
|
||||
|
||||
# ---------- phase 76 task 04: the header is shell-owned ----------
|
||||
@@ -869,3 +889,153 @@ def test_tokens_view_scaffold_in_the_shell() -> None:
|
||||
# The Actions column header is visually-hidden (the row buttons
|
||||
# carry their own aria-labels — the history-table convention).
|
||||
assert '<th scope="col"><span class="visually-hidden">Actions</span></th>' in body
|
||||
|
||||
|
||||
# ---------- phase 91 task 04: the Theme view (skeleton) ----------
|
||||
|
||||
|
||||
def test_theme_view_scaffold_in_the_shell() -> None:
|
||||
"""Phase 91 task 04: the shell carries the #view-theme section —
|
||||
hidden AND inert + focusable (the WCAG pair, AGENTS.md rule 5) —
|
||||
with the #theme-gate (the EXACT #sources-gate pattern, ship-hidden,
|
||||
its Sign in returning to the Theme view via ?next=/theme.html) and
|
||||
the ship-hidden #theme-content (the #git-sources-content pattern)
|
||||
holding the STATIC form skeleton: the page-head (h1 "Theme"), the
|
||||
#theme-form with the 3 labeled branding text inputs (maxlength=300
|
||||
— the server re-validates) + the 8 labeled type=color palette inputs
|
||||
(the 8 identity variables, in the theming.COLOR_FIELDS order), the
|
||||
#theme-save (primary) + #theme-reset (secondary) — BOTH type="button"
|
||||
(no real submit), and the three task-05 feedback lines: #theme-error
|
||||
(role=alert), #theme-result (role=status), #theme-contrast
|
||||
(role=alert) — all ship hidden. The editor behavior (populate,
|
||||
live preview, Save/Reset, the contrast warnings) lands in task 05;
|
||||
this pin keeps the E2E-stable skeleton from drifting."""
|
||||
html = _html()
|
||||
view = html.find('<section class="view" id="view-theme"')
|
||||
assert view != -1, "the #view-theme section must be in the shell"
|
||||
tag_end = html.find(">", view)
|
||||
tag = html[view:tag_end]
|
||||
assert "hidden" in tag and "inert" in tag, (
|
||||
"the folded view ships hidden AND inert"
|
||||
)
|
||||
assert 'tabindex="-1"' in tag, "the target view is focusable"
|
||||
main_end = html.find("</main>", view)
|
||||
assert view < main_end, "the view section lives inside the single main"
|
||||
body = html[view:main_end]
|
||||
# The gate: the exact #sources-gate pattern (class + ship-hidden +
|
||||
# its ?next= returning to the Theme view — the no-JS fallback).
|
||||
gate = re.search(r'<section[^>]*id="theme-gate"[^>]*>', body)
|
||||
assert gate and "hidden" in gate.group(0), "#theme-gate must ship hidden"
|
||||
assert 'class="sources-gate"' in gate.group(0), (
|
||||
"the gate reuses the .sources-gate visual language"
|
||||
)
|
||||
assert "<h2 id=\"theme-gate-title\">Sign in to change the theme</h2>" in body
|
||||
assert 'href="/login.html?next=/theme.html"' in body, (
|
||||
"the gate's Sign in returns to the Theme view (no-JS fallback)"
|
||||
)
|
||||
# The content ships hidden (theme.js reveals it for admin only —
|
||||
# the #git-sources-content pattern).
|
||||
content = re.search(r'<div[^>]*id="theme-content"[^>]*>', body)
|
||||
assert content and "hidden" in content.group(0), (
|
||||
"#theme-content must ship hidden (anonymous-safe)"
|
||||
)
|
||||
# The static form skeleton (the E2E-stable-selectors house
|
||||
# convention): the 3 labeled branding text inputs (maxlength=300)
|
||||
# and the 8 labeled type=color palette inputs (the 8 identity
|
||||
# variables — one per theming.COLOR_FIELDS field).
|
||||
assert re.search(r'<form[^>]*id="theme-form"[^>]*>', body), (
|
||||
"the #theme-form must be STATIC markup in the shell"
|
||||
)
|
||||
for field_id in ("theme-app-name", "theme-placeholder", "theme-footer"):
|
||||
assert re.search(
|
||||
rf'<label[^>]*for="{field_id}"[^>]*>', body
|
||||
), f"missing the visible label for #{field_id}"
|
||||
assert re.search(
|
||||
rf'<input[^>]*id="{field_id}"[^>]*maxlength="300"[^>]*>', body
|
||||
), f"#{field_id} must be a text input with maxlength=300"
|
||||
for field_id in (
|
||||
"theme-bg",
|
||||
"theme-surface",
|
||||
"theme-ink",
|
||||
"theme-ink-soft",
|
||||
"theme-line",
|
||||
"theme-brand",
|
||||
"theme-brand-soft",
|
||||
"theme-brand-ink",
|
||||
):
|
||||
assert re.search(
|
||||
rf'<label[^>]*for="{field_id}"[^>]*>', body
|
||||
), f"missing the visible label for #{field_id}"
|
||||
assert re.search(
|
||||
rf'<input[^>]*id="{field_id}"[^>]*type="color"[^>]*>', body
|
||||
), f"#{field_id} must be a type=color input"
|
||||
# Save (primary) + Reset (secondary) — BOTH type="button" (no real
|
||||
# submit; theme.js owns the onsubmit handling + the §7.4 lifecycle).
|
||||
save = re.search(r'<button[^>]*id="theme-save"[^>]*>', body)
|
||||
assert save and 'type="button"' in save.group(0), (
|
||||
"#theme-save must be a type=button (no real submit)"
|
||||
)
|
||||
reset = re.search(r'<button[^>]*id="theme-reset"[^>]*>', body)
|
||||
assert reset and 'type="button"' in reset.group(0), (
|
||||
"#theme-reset must be a type=button (no real submit)"
|
||||
)
|
||||
assert "Save theme" in body, "the Save button's label"
|
||||
assert "Reset to defaults" in body, "the Reset button's label"
|
||||
# The three task-05 feedback lines, all ship hidden.
|
||||
assert re.search(r'<[^>]*id="theme-error"[^>]*role="alert"[^>]*hidden', body)
|
||||
assert re.search(r'<[^>]*id="theme-result"[^>]*role="status"[^>]*hidden', body)
|
||||
assert re.search(r'<[^>]*id="theme-contrast"[^>]*role="alert"[^>]*hidden', body)
|
||||
|
||||
|
||||
def test_theme_nav_link_ships_on_every_page_header() -> None:
|
||||
"""Phase 91 task 04: the phase-34 one-bar contract — the SAME nav
|
||||
ships on every page (test_nav_consistency pins the header inventory
|
||||
PARITY across the shell pages, the document viewer, and the login
|
||||
page), so #nav-theme (ship-hidden, admin-only) must be in the
|
||||
#app-nav of EVERY header-bearing page: the shell + document.html +
|
||||
login.html + shared.html. The doc-edit flow page ships the reduced
|
||||
header (no admin links at all) and is out of the contract."""
|
||||
for page in (
|
||||
FRONTEND / "index.html",
|
||||
FRONTEND / "document.html",
|
||||
FRONTEND / "login.html",
|
||||
FRONTEND / "shared.html",
|
||||
):
|
||||
text = page.read_text(encoding="utf-8")
|
||||
match = re.search(r'<a[^>]*id="nav-theme"[^>]*>', text)
|
||||
assert match, f"{page.name} must carry the #nav-theme nav link (one-bar)"
|
||||
link = match.group(0)
|
||||
assert 'href="/theme.html"' in link, f"{page.name}: the Theme link's href"
|
||||
assert "hidden" in link, (
|
||||
f"{page.name}: #nav-theme ships hidden (admin-only)"
|
||||
)
|
||||
assert "is-active" not in link, (
|
||||
f"{page.name}: no static active stamp on the Theme link"
|
||||
)
|
||||
|
||||
|
||||
def test_header_js_reveals_the_theme_link_for_admin_only() -> None:
|
||||
"""Phase 91 task 04: header.js reveals #nav-theme for role admin —
|
||||
the same ship-hidden / reveal-for-admin contract as the other
|
||||
admin-only links: the two-line reveal (`hidden = !admin`) sits in
|
||||
initSharedHeader, null-safe (a page without the link is a no-op),
|
||||
and the gate is the `admin` flag (role === "admin") — a token user
|
||||
(role "user") never sees the link."""
|
||||
header_js = (ASSETS / "header.js").read_text(encoding="utf-8")
|
||||
fn = header_js.find("export async function initSharedHeader")
|
||||
assert fn != -1, "initSharedHeader must exist"
|
||||
body = header_js[fn:]
|
||||
lookup = body.find('document.querySelector("#nav-theme")')
|
||||
assert lookup != -1, "header.js must look up #nav-theme"
|
||||
reveal = body.find("navTheme.hidden = !admin")
|
||||
assert 0 <= lookup < reveal, (
|
||||
"the reveal must be the two-line pattern: null-safe lookup, "
|
||||
"then hidden = !admin (the admin flag — role === \"admin\")"
|
||||
)
|
||||
# The lookup + reveal sit AFTER the whoami resolution (the admin
|
||||
# flag exists only once fetchWhoami has settled).
|
||||
whoami = body.find("const whoami = await fetchWhoami()")
|
||||
admin_flag = body.find('const admin = whoami.role === "admin"')
|
||||
assert 0 <= whoami < admin_flag < lookup, (
|
||||
"the reveal keys off the resolved admin flag"
|
||||
)
|
||||
|
||||
@@ -10,18 +10,30 @@ writing the full untruncated path to the button title + #sync-result,
|
||||
the two-job tick decision tree (sync running > upload running > sync
|
||||
success > sync failed > upload success > upload failed > idle; the A3
|
||||
settle never renders upload counts into #sync-result), and the
|
||||
load-time re-attach of an in-flight upload scan — so a silent
|
||||
load-time re-attach of an in-flight upload run (phase 90: unpack +
|
||||
register only — the bare "Importing…" label, no file) — so a silent
|
||||
regression is caught without a browser.
|
||||
|
||||
Phase 64 task 05 adds the Sources-page (git-sources.js) upload
|
||||
contract: the "Successfully uploaded — <file>" toast on the 202
|
||||
(page-local, phase-55 pattern, success-only), the live
|
||||
"Processing… <file> (n/m)" label + full-path title driven by the 2 s
|
||||
GET /api/git-sources/upload/status poll, the 409 re-attach (no error
|
||||
banner), the poll's terminal decision tree (success → result line +
|
||||
announce + reload, NO second toast; failed → sanitized error banner
|
||||
+ reload; idle → defensive restore), the finally's never-restore-
|
||||
while-polling guard (PLAN §7.4), and the boot re-attach branches.
|
||||
(page-local, phase-55 pattern, success-only), the "Processing…"
|
||||
label driven by the 2 s GET /api/git-sources/upload/status poll, the
|
||||
409 re-attach (no error banner), the poll's terminal decision tree
|
||||
(success → result line + announce + reload, NO second toast; failed
|
||||
→ sanitized error banner + reload; idle → defensive restore), the
|
||||
finally's never-restore-while-polling guard (PLAN §7.4), and the boot
|
||||
re-attach branches.
|
||||
|
||||
Phase 90 re-points the upload contract to UNPACK + REGISTER ONLY:
|
||||
the processing state is the BARE "Processing…" for the whole
|
||||
background run (no current_file, no "(n/m)" counts, no title — the
|
||||
scan's progress moved to the RAG page's Sync button), the button
|
||||
reads exactly "Upload" (the phase-64 scan-suffixed label is gone), the
|
||||
settled result line is
|
||||
"Uploaded <name> — press Sync sources to import it." (fmtUploadResult
|
||||
off the status's {"message": "uploaded"} detail; the nameless variant
|
||||
after a reload / on the 409 re-attach), and the success announce
|
||||
names the next step.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -140,11 +152,13 @@ def _tick(js: str) -> str:
|
||||
|
||||
def test_fmt_sync_label_signature_and_prefixes() -> None:
|
||||
"""fmtSyncLabel(kind, currentFile, done, total): `kind` picks the
|
||||
prefix — "upload" → "Importing" (the background scan's word, A3),
|
||||
prefix — "upload" → "Importing" (the background run's word, A3),
|
||||
anything else → "Syncing…". The file is appended only when present
|
||||
(the bare prefix shows during clone/pull or unpack, before any file
|
||||
is indexed — A4); the counts are appended only when total > 0 (the
|
||||
import has started); file before counts."""
|
||||
(sync: the bare prefix shows during clone/pull, before any file is
|
||||
indexed — A4; phase 90: the upload run never carries a file or
|
||||
counts, so its label is always the bare "Importing…"); the counts
|
||||
are appended only when total > 0 (the import has started); file
|
||||
before counts."""
|
||||
body = _fn(_js(), "fmtSyncLabel")
|
||||
assert "function fmtSyncLabel(kind, currentFile, done, total)" in body
|
||||
assert 'kind === "upload"' in body
|
||||
@@ -200,7 +214,8 @@ def test_enter_running_state_writes_full_path_to_title_and_announcer() -> None:
|
||||
|
||||
def test_tick_fetches_both_jobs_with_403_and_blip_rules() -> None:
|
||||
"""Each tick fetches BOTH status endpoints (the sync and the
|
||||
background upload scan). The 403 backstop (button hidden) applies
|
||||
background upload run — phase 90: unpack + register, no scan).
|
||||
The 403 backstop (button hidden) applies
|
||||
to the SYNC fetch only — a 403 on the upload fetch is simply "no
|
||||
upload" (never a hide); a network blip on either fetch retries next
|
||||
tick (the tick reschedules, it never dies on a failed fetch)."""
|
||||
@@ -315,10 +330,11 @@ def test_reattach_adopts_a_running_upload_only() -> None:
|
||||
"""initSyncButton: the sync branches are unchanged (running
|
||||
re-enters with the live file; the terminals render the last
|
||||
result). With the sync IDLE it fetches the upload status: a RUNNING
|
||||
upload scan re-attaches (running state, upload kind + live file,
|
||||
the synthetic running frame, the poll starts); a terminal upload is
|
||||
a no-op — the fall-through is the plain idle settle (the boot-time
|
||||
loadDocs() already shows the current catalog)."""
|
||||
upload run re-attaches (running state, upload kind — phase 90: no
|
||||
live file, the label stays bare "Importing…" — the synthetic
|
||||
running frame, the poll starts); a terminal upload is a no-op —
|
||||
the fall-through is the plain idle settle (the boot-time loadDocs()
|
||||
already shows the current catalog)."""
|
||||
body = _fn(_js(), "initSyncButton")
|
||||
assert "await fetchIsAdmin()" in body, "admin-only (no extra fetch)"
|
||||
assert 'fetch("/api/git-sources/upload/status")' in body
|
||||
@@ -360,16 +376,20 @@ def test_section_header_documents_the_two_job_contract() -> None:
|
||||
|
||||
def test_sources_html_comment_documents_the_live_announcer() -> None:
|
||||
"""The #sync-result comment in the shell's RAG view (formerly
|
||||
sources.html) documents the phase-64 dual role: the live file
|
||||
label (both kinds) while either job runs, untruncated for the
|
||||
aria-live announcer, and empty after an upload settles (A3 — the
|
||||
upload's counts live on the Sources page)."""
|
||||
sources.html) documents the announcer's role: the SYNC's live file
|
||||
label ("Syncing… <file> (n/m)"), untruncated for the aria-live
|
||||
announcer, and — phase 90 — the BARE "Importing…" label an
|
||||
in-flight upload run adopts (unpack + register only, no scan: its
|
||||
status never carries a file or counts); empty after an upload
|
||||
settles (A3 — the upload's result line lives on the Sources
|
||||
page)."""
|
||||
html = _html()
|
||||
idx = html.find('id="sync-result"')
|
||||
assert idx != -1
|
||||
comment = html[max(0, idx - 900):idx]
|
||||
assert "Importing <file>" in comment, "the upload kind is documented"
|
||||
assert "Syncing…" in comment, "the sync kind is documented"
|
||||
assert "Syncing… <file>" in comment, "the sync live-file label is documented"
|
||||
assert '"Importing…"' in comment, "the (bare) upload label is documented"
|
||||
assert "Phase 90" in comment, "the unpack-only rework is documented"
|
||||
assert "A3" in comment, "the settle contract is documented"
|
||||
|
||||
|
||||
@@ -478,6 +498,9 @@ def test_toast_fires_on_202_with_the_safe_name() -> None:
|
||||
assert "let name = file.name;" in branch, "the degrade-to-picked-name fallback"
|
||||
assert "await r.json()" in branch, "the UploadAccepted body is parsed"
|
||||
assert "data.name" in branch, "the safe source name comes from the 202 body"
|
||||
assert "lastUploadName = name" in branch, (
|
||||
"the accepted 202's safe name is recorded for the result line (phase 90)"
|
||||
)
|
||||
assert "showUploadToast(`Successfully uploaded — ${name}`)" in branch
|
||||
assert 'uploadFileInput.value = ""' in branch, "the file input clears at 202"
|
||||
assert "enterUploadProcessingState()" in branch
|
||||
@@ -491,38 +514,74 @@ def test_toast_fires_on_202_with_the_safe_name() -> None:
|
||||
# ---------- the processing state + the live label ----------
|
||||
|
||||
|
||||
def test_processing_state_and_live_label_builder() -> None:
|
||||
"""The button's processing entry (202 / 409): disabled,
|
||||
"Processing…", title cleared (the poll owns it from here). The
|
||||
tick's running branch builds the live label — the base prefix,
|
||||
the file appended ONLY when present, the counts appended ONLY
|
||||
when total > 0 (A4 — bare "Processing…" during the unpack phase,
|
||||
before any file is indexed) — and rides the FULL untruncated path
|
||||
on the button title (empty until a file exists), then
|
||||
reschedules at the 2 s house cadence."""
|
||||
def test_processing_state_is_bare_for_the_whole_run() -> None:
|
||||
"""Phase 90 (A2): the button's processing entry (202 / 409) is
|
||||
disabled, BARE "Processing…", title cleared — and the tick's
|
||||
RUNNING branch renders exactly that same bare label for the whole
|
||||
background run: the unpack has no file-level progress, so the
|
||||
phase-64 live-file interpolation is gone (no current_file, no
|
||||
"(n/m)" counts, no file in the title), then it reschedules at the
|
||||
2 s house cadence."""
|
||||
js = _gjs()
|
||||
state = _gfn(js, "enterUploadProcessingState")
|
||||
assert "uploadBtn.disabled = true" in state
|
||||
assert 'uploadBtn.textContent = "Processing…"' in state
|
||||
assert 'uploadBtn.title = "";' in state, "a live file lands on the title at the first tick"
|
||||
assert 'uploadBtn.title = "";' in state, "the title stays clear (no live file)"
|
||||
assert "const UPLOAD_POLL_MS = 2000" in js, "the SYNC_POLL_MS house value"
|
||||
tick = _utick(js)
|
||||
i_run = tick.find('status.state === "running"')
|
||||
i_stop = tick.find("stopUploadPolling();")
|
||||
assert -1 < i_run < i_stop, "the running branch precedes the terminal stop"
|
||||
run = tick[i_run:i_stop]
|
||||
assert '"Processing…"' in run, "the base prefix"
|
||||
assert "(status.current_file ? ` ${status.current_file}` : \"\")" in run, (
|
||||
"the file is appended only when present"
|
||||
assert 'uploadBtn.textContent = "Processing…"' in run, (
|
||||
"the bare label — no file, no counts (phase 90, A2)"
|
||||
)
|
||||
counts_expr = '(status.files_total > 0 ? ` (${status.files_done}/${status.files_total})` : "")'
|
||||
assert counts_expr in run, "the counts appear only when total > 0"
|
||||
assert 'uploadBtn.title = status.current_file || "";' in run, (
|
||||
"the full path on hover (empty until a file exists)"
|
||||
assert 'uploadBtn.title = "";' in run, "the title stays clear"
|
||||
assert "status.current_file" not in run, (
|
||||
"no live file — the scan's progress moved to the sync"
|
||||
)
|
||||
assert "files_total" not in run and "files_done" not in run, "no (n/m) counts"
|
||||
assert "uploadPollTimer = setTimeout(tick, UPLOAD_POLL_MS)" in run, "reschedule"
|
||||
|
||||
|
||||
def test_fmt_upload_result_is_the_ready_for_sync_line() -> None:
|
||||
"""Phase 90 (A2/A3): the result line reads the no-count
|
||||
{"message": "uploaded"} detail and renders "Uploaded <name> —
|
||||
press Sync sources to import it." (the name from the accepted
|
||||
202); without a name (a reload re-render, the 409 re-attach) it
|
||||
renders the nameless variant; the old sync-style count keys
|
||||
(added / updated / unchanged / pruned) are no longer read at all.
|
||||
The 202 branch records the safe name (lastUploadName); the poll's
|
||||
settle and the boot re-attach both render the line from
|
||||
(detail, lastUploadName); the button's idle label is "Upload"."""
|
||||
js = _gjs()
|
||||
body = _gfn(js, "fmtUploadResult")
|
||||
assert "function fmtUploadResult(detail, name)" in body
|
||||
assert 'detail.message === "uploaded"' in body
|
||||
assert "Uploaded ${name} — press Sync sources to import it." in body
|
||||
assert "Uploaded — press Sync sources to import it." in body, (
|
||||
"the nameless variant (reload / 409 re-attach)"
|
||||
)
|
||||
for key in ("added", "updated", "unchanged", "pruned"):
|
||||
assert key not in body, f"the sync-style count {key!r} is gone"
|
||||
# The 202 branch records the safe name for the settled line.
|
||||
sub = _usubmit(js)
|
||||
i202 = sub.find("r.status === 202")
|
||||
i409 = sub.find("r.status === 409")
|
||||
assert "lastUploadName = name" in sub[i202:i409]
|
||||
assert "let lastUploadName = null" in js, "page-local (null after a reload)"
|
||||
# Both render sites pass (detail, lastUploadName).
|
||||
assert "fmtUploadResult(detail, lastUploadName)" in _utick(js)
|
||||
assert "fmtUploadResult(status.detail, lastUploadName)" in _gfn(js, "initUploadStatus")
|
||||
# The button's idle label (static + the poll's restores).
|
||||
restore = _gfn(js, "restoreUploadButton")
|
||||
assert 'uploadBtn.textContent = "Upload"' in restore
|
||||
# The phase-64 scan-suffixed label is gone (exactly "Upload") — the
|
||||
# needles are split so this file carries no forbidden literal (the
|
||||
# phase-90 rg criterion sweeps frontend/ app/ tests/ for it).
|
||||
assert ("Upload " + "& scan") not in js and ("Upload " + "and scan") not in js
|
||||
|
||||
|
||||
def test_start_upload_polling_double_start_guard() -> None:
|
||||
"""startUploadPolling: single timer, one loop at a time — the
|
||||
first statement bails when a poll is already active (the guard a
|
||||
@@ -594,12 +653,13 @@ def test_upload_polling_decision_tree() -> None:
|
||||
i_ok = tick.find('status.state === "success"')
|
||||
i_fail = tick.find('status.state === "failed"')
|
||||
assert -1 < i_run < i_stop < i_ok < i_fail, "running < stop < success < failed"
|
||||
# success: result line + announce + reload, NO toast.
|
||||
# success: the ready-for-sync line (phase 90 A2/A3) + the
|
||||
# next-step announce + reload, NO toast.
|
||||
ok = tick[i_ok:i_fail]
|
||||
for line in (
|
||||
"fmtUploadResult(detail)",
|
||||
"fmtUploadResult(detail, lastUploadName)",
|
||||
"uploadResult.hidden = false",
|
||||
"announce(`Archive uploaded: ${detail.source}.`)",
|
||||
'announce("Archive uploaded — press Sync sources to import it.")',
|
||||
'uploadFileInput.value = ""',
|
||||
"restoreUploadButton()",
|
||||
"loadSources()",
|
||||
@@ -660,7 +720,9 @@ def test_boot_reattach_branches() -> None:
|
||||
assert "startUploadPolling()" in run
|
||||
assert "uploadError" not in run and "showUploadToast" not in run
|
||||
ok = body[i_ok:i_fail]
|
||||
assert "fmtUploadResult(status.detail)" in ok, "the last result line"
|
||||
# The last result line — the nameless variant after a reload
|
||||
# (lastUploadName is null: the safe name was page-local, phase 90).
|
||||
assert "fmtUploadResult(status.detail, lastUploadName)" in ok
|
||||
assert "uploadResult.hidden = false" in ok
|
||||
assert "announce(" not in ok, "no announce at boot (A2)"
|
||||
assert "showUploadToast" not in ok, "no toast at boot (A2)"
|
||||
@@ -686,9 +748,11 @@ def test_git_sources_html_comment_documents_the_202_contract() -> None:
|
||||
"""The #archive-upload-form comment in the shell's Sources view
|
||||
(formerly git-sources.html) documents the phase-64 202 contract
|
||||
(the phase-49 synchronous paragraph marked superseded): the 202 =
|
||||
"safely on disk" + the JS-created toast (no markup), the live
|
||||
"Processing…" label via the status poll, and the 409 re-attach
|
||||
without an error banner."""
|
||||
"safely on disk" + the JS-created toast (no markup), the bare
|
||||
"Processing…" label via the status poll, the 409 re-attach without
|
||||
an error banner — and phase 90's unpack-only rework (the run is
|
||||
UNPACK + REGISTER ONLY and the success line points at the RAG
|
||||
page's "Sync sources" button)."""
|
||||
html = _ghtml()
|
||||
idx = html.find('id="archive-upload-form"')
|
||||
assert idx != -1
|
||||
@@ -696,6 +760,9 @@ def test_git_sources_html_comment_documents_the_202_contract() -> None:
|
||||
assert "Phase 64" in comment
|
||||
assert "superseded" in comment, "the phase-49 synchronous paragraph is marked superseded"
|
||||
assert "Successfully uploaded" in comment, "the toast is documented"
|
||||
assert "Processing…" in comment, "the live label is documented"
|
||||
assert "Processing…" in comment, "the (now bare) label is documented"
|
||||
assert "GET /api/git-sources/upload/status" in comment, "the polling endpoint"
|
||||
assert "409 re-attaches" in comment, "the re-attach without an error banner"
|
||||
assert "Phase 90" in comment, "the unpack-only rework is documented"
|
||||
assert "UNPACK + REGISTER ONLY" in comment
|
||||
assert "Sync sources" in comment, "the result line points at the RAG page's button"
|
||||
|
||||
@@ -18,6 +18,33 @@ def test_all_tables_registered() -> None:
|
||||
assert "documents" in tables
|
||||
assert "chunks" in tables
|
||||
assert "query_log" in tables
|
||||
assert "ui_settings" in tables # phase 91: the single-row UI settings
|
||||
|
||||
|
||||
def test_ui_settings_single_row_nullable_contract() -> None:
|
||||
"""Phase 91: the single-row UI settings table — Integer PK ``id``
|
||||
with the Python-side ``default=1`` (the row is always id 1), the 3
|
||||
strings VARCHAR(300) and the 8 identity colors VARCHAR(7), ALL
|
||||
nullable (NULL = default — B1: env value for the strings, the
|
||||
built-in palette for the colors)."""
|
||||
settings_table = Base.metadata.tables["ui_settings"]
|
||||
assert set(settings_table.c.keys()) == {
|
||||
"id", "app_name", "input_placeholder", "footer_text",
|
||||
"bg", "surface", "ink", "ink_soft", "line",
|
||||
"brand", "brand_soft", "brand_ink",
|
||||
}
|
||||
pk = settings_table.c["id"]
|
||||
assert pk.primary_key is True, "ui_settings.id must be the PK"
|
||||
assert pk.default is not None, "id needs the Python-side default=1"
|
||||
for name in ("app_name", "input_placeholder", "footer_text"):
|
||||
col = settings_table.c[name]
|
||||
assert col.nullable is True, f"{name} must be NULL (env default)"
|
||||
assert getattr(col.type, "length", None) == 300, f"{name} must be String(300)"
|
||||
for name in ("bg", "surface", "ink", "ink_soft", "line",
|
||||
"brand", "brand_soft", "brand_ink"):
|
||||
col = settings_table.c[name]
|
||||
assert col.nullable is True, f"{name} must be NULL (the built-in)"
|
||||
assert getattr(col.type, "length", None) == 7, f"{name} must be String(7) — #rrggbb"
|
||||
|
||||
|
||||
def test_chunks_embedding_is_vector_768() -> None:
|
||||
|
||||
@@ -51,11 +51,13 @@ def test_app_config_dict_carries_the_docs_flag() -> None:
|
||||
|
||||
s = _settings()
|
||||
body = app_config(s)
|
||||
# Phase 62 (task 01): the response grew to the six-key set — the
|
||||
# phase-62 UI customization keys ride the SAME endpoint.
|
||||
# Phase 62 (task 01): the response grew to the phase-62 UI
|
||||
# customization keys; phase 91 (task 03) deleted the retired
|
||||
# CSS-file theming's ``theme`` key — the five keys below are the
|
||||
# entire endpoint contract.
|
||||
assert set(body) == {
|
||||
"app_name", "version", "docs_repo_configured",
|
||||
"input_placeholder", "footer_text", "theme",
|
||||
"input_placeholder", "footer_text",
|
||||
}
|
||||
assert body["docs_repo_configured"] is s.docs_configured
|
||||
assert body["docs_repo_configured"] is False
|
||||
|
||||
@@ -149,6 +149,32 @@ def test_404_shaped_response_carries_all_three_headers() -> None:
|
||||
assert body_msg["body"] == b"not found"
|
||||
|
||||
|
||||
def test_pre_existing_csp_from_an_inner_layer_is_preserved() -> None:
|
||||
"""Phase 91 (task 05): the caching middleware publishes, on themed
|
||||
HTML pages only, the A1 string EXTENDED with a ``style-src`` sha256
|
||||
hash for the inline theme tag (the A1 policy would block the tag in
|
||||
every real browser). A CSP an inner layer has already set is that
|
||||
layer's deliberate one and must survive the outer middleware —
|
||||
while the other two headers are still added."""
|
||||
themed = (
|
||||
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; "
|
||||
"style-src 'self' 'sha256-2rm3wPcQfXmE8q1s9vBzK7hN4tY5uJ6gW3oR0cAeDfH='"
|
||||
)
|
||||
wrapped = SecurityHeadersMiddleware(
|
||||
_plain_app(
|
||||
200,
|
||||
b"<html></html>",
|
||||
headers=[[b"content-security-policy", themed.encode("ascii")]],
|
||||
)
|
||||
)
|
||||
sent = _drive(wrapped, _http_scope())
|
||||
|
||||
start = sent[0]
|
||||
assert _header(start, "content-security-policy") == themed # not clobbered
|
||||
assert _header(start, "x-frame-options") == "DENY"
|
||||
assert _header(start, "x-content-type-options") == "nosniff"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The SSE streaming passthrough pin
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
"""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}"
|
||||
@@ -0,0 +1,310 @@
|
||||
"""Unit: the built-in identity palette + the effective-settings resolver
|
||||
(phase 91, task 01).
|
||||
|
||||
Covers ``app/core/theming.py`` — the single source of the built-in
|
||||
identity palette (re-homed from the retired phase-62 CSS-file themes'
|
||||
authoring guide before task 03 deleted it) and the DB-over-env /
|
||||
DB-over-built-in resolver shared by ``/api/ui-settings`` and
|
||||
``/api/config``:
|
||||
|
||||
* ``BUILTIN_COLORS`` — the DRIFT GUARD: the 8 built-ins must equal the
|
||||
values parsed straight out of ``frontend/assets/styles.css``'s
|
||||
``:root`` block, so the Python palette and the stylesheet can never
|
||||
silently diverge;
|
||||
* ``theme_style_tag`` — the byte-identical contract (all built-in →
|
||||
``""``) and the exact tag shape (all 8 variables, ``COLOR_FIELDS``
|
||||
order, lowercased hex);
|
||||
* ``effective_settings`` — missing row → env strings + built-ins; a DB
|
||||
row's set columns win; an empty-string DB string falls back to env
|
||||
(the resolver treats "" as unset, B1). House DB-test pattern (the
|
||||
``test_tokens`` precedent): the real compose Postgres, skipped with
|
||||
clear instructions when the stack is not up.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.core import theming
|
||||
from app.models import UiSettings
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
STYLES_CSS = REPO_ROOT / "frontend" / "assets" / "styles.css"
|
||||
|
||||
|
||||
def _delete_row() -> Any:
|
||||
from sqlalchemy import delete
|
||||
|
||||
return delete(UiSettings).where(UiSettings.id == 1)
|
||||
|
||||
|
||||
def _root_declarations() -> dict[str, str]:
|
||||
"""The ``--name: value`` declarations of styles.css's (first)
|
||||
``:root`` block, comments stripped, in file order."""
|
||||
css = STYLES_CSS.read_text(encoding="utf-8")
|
||||
match = re.search(r":root\s*\{", css)
|
||||
assert match is not None, "styles.css must have a :root block"
|
||||
block = css[match.end() : css.index("}", match.end())]
|
||||
block = re.sub(r"/\*.*?\*/", "", block, flags=re.S)
|
||||
return dict(re.findall(r"(--[a-z-]+)\s*:\s*([^;]+);", block))
|
||||
|
||||
|
||||
def test_builtin_colors_match_styles_css_root() -> None:
|
||||
"""The drift guard: every built-in equals the stylesheet's ``:root``
|
||||
value for the same variable (and ``BUILTIN_COLORS`` names exactly
|
||||
the 8 identity variables — no more, no fewer)."""
|
||||
decls = _root_declarations()
|
||||
builtin_names = set(theming.BUILTIN_COLORS)
|
||||
assert builtin_names == {
|
||||
"bg", "surface", "ink", "ink_soft", "line",
|
||||
"brand", "brand_soft", "brand_ink",
|
||||
}, f"BUILTIN_COLORS must name exactly the 8 identity variables, got {sorted(builtin_names)}"
|
||||
for name, value in theming.BUILTIN_COLORS.items():
|
||||
css_name = f"--{name.replace('_', '-')}"
|
||||
assert css_name in decls, f"styles.css :root is missing {css_name}"
|
||||
assert decls[css_name].strip() == value, (
|
||||
f"{css_name} drifted: BUILTIN_COLORS has {value!r}, "
|
||||
f"styles.css has {decls[css_name].strip()!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_color_fields_are_the_eight_keys_in_readme_order() -> None:
|
||||
"""``COLOR_FIELDS`` is the 8 keys in the themes-README order — the
|
||||
order the resolver, the API, and the tag renderer all rely on."""
|
||||
assert theming.COLOR_FIELDS == (
|
||||
"bg", "surface", "ink", "ink_soft",
|
||||
"line", "brand", "brand_soft", "brand_ink",
|
||||
)
|
||||
assert theming.STRING_FIELDS == ("app_name", "input_placeholder", "footer_text")
|
||||
|
||||
|
||||
def _env_settings() -> Settings:
|
||||
"""An explicit env source (the resolver's optional ``settings``
|
||||
parameter) — deterministic values, independent of the local ``.env``
|
||||
(the ``/api/config`` env pins in test_api.py own the env-file
|
||||
behaviour; this unit module only needs stable fallbacks)."""
|
||||
return Settings(
|
||||
app_name="Env Name",
|
||||
input_placeholder="Env placeholder…",
|
||||
footer_text="Env footer",
|
||||
)
|
||||
|
||||
|
||||
# ---------- effective_settings (real Postgres — house DB-test pattern) ----------
|
||||
|
||||
|
||||
def test_effective_missing_row_is_env_strings_plus_builtins(db: Session) -> None:
|
||||
"""A missing row (GET creates nothing) means "defaults": the env
|
||||
strings + the built-in palette, all 11 keys."""
|
||||
row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
|
||||
assert row is None, "the test starts from a row-missing state"
|
||||
effective = theming.effective_settings(db, _env_settings())
|
||||
assert set(effective) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
|
||||
assert effective["app_name"] == "Env Name"
|
||||
assert effective["input_placeholder"] == "Env placeholder…"
|
||||
assert effective["footer_text"] == "Env footer"
|
||||
assert {k: effective[k] for k in theming.COLOR_FIELDS} == theming.BUILTIN_COLORS
|
||||
|
||||
|
||||
def test_effective_db_row_wins_column_by_column(db: Session) -> None:
|
||||
"""Set columns win, unset columns fall back — per column, so a
|
||||
partial row (only ``bg`` set) mixes the DB color with the built-ins
|
||||
and the env strings."""
|
||||
db.add(UiSettings(id=1, bg="#111111", app_name="DB Name"))
|
||||
db.commit()
|
||||
try:
|
||||
effective = theming.effective_settings(db, _env_settings())
|
||||
assert effective["bg"] == "#111111" # DB wins
|
||||
assert effective["app_name"] == "DB Name" # DB wins
|
||||
# Unset columns: env strings + the built-in colors.
|
||||
assert effective["input_placeholder"] == "Env placeholder…"
|
||||
assert effective["footer_text"] == "Env footer"
|
||||
for key in theming.COLOR_FIELDS:
|
||||
if key != "bg":
|
||||
assert effective[key] == theming.BUILTIN_COLORS[key]
|
||||
finally:
|
||||
db.execute(_delete_row())
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_effective_empty_string_db_string_falls_back_to_env(db: Session) -> None:
|
||||
"""B1: an EMPTY string in the DB is unset — the resolver falls back
|
||||
to the env value (a hand-edited row with '' can't blank the UI).
|
||||
Colors: ``None`` → the built-in (an empty color is impossible through
|
||||
the API — the hex validator — the resolver's not-None rule covers
|
||||
the hand-edited edge by returning whatever the row holds)."""
|
||||
db.add(UiSettings(id=1, app_name=""))
|
||||
db.commit()
|
||||
try:
|
||||
effective = theming.effective_settings(db, _env_settings())
|
||||
assert effective["app_name"] == "Env Name" # "" → env fallback
|
||||
assert effective["footer_text"] == "Env footer"
|
||||
assert effective["brand"] == theming.BUILTIN_COLORS["brand"] # None → built-in
|
||||
finally:
|
||||
db.execute(_delete_row())
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_effective_without_explicit_settings_uses_get_settings(db: Session) -> None:
|
||||
"""``settings=None`` (the design's call shape) resolves the env
|
||||
fallback from the cached :func:`app.config.get_settings` — the
|
||||
values it reports must be real ``str``s for all 11 keys."""
|
||||
from app.config import get_settings
|
||||
|
||||
effective = theming.effective_settings(db)
|
||||
assert set(effective) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
|
||||
assert effective["app_name"] == get_settings().app_name
|
||||
for field in theming.STRING_FIELDS:
|
||||
assert isinstance(effective[field], str) and effective[field]
|
||||
assert all(re.fullmatch(r"#[0-9a-f]{6}", effective[k]) for k in theming.COLOR_FIELDS)
|
||||
assert {k: effective[k] for k in theming.COLOR_FIELDS} == theming.BUILTIN_COLORS
|
||||
|
||||
|
||||
# ---------- theme_style_tag (pure) ----------
|
||||
|
||||
|
||||
def test_theme_style_tag_all_builtins_is_empty_string() -> None:
|
||||
"""The byte-identical contract: an unset (or "defaults saved")
|
||||
deployment serves NO tag — exactly the pre-phase-91 HTML."""
|
||||
assert theming.theme_style_tag(dict(theming.BUILTIN_COLORS)) == ""
|
||||
# The tag is PURE string-equality: uppercase hex is NOT the built-in
|
||||
# (the API's lowercasing-before-store is what makes stored hex
|
||||
# canonical — pin the renderer's own contract here).
|
||||
colors = {k: v.upper() for k, v in theming.BUILTIN_COLORS.items()}
|
||||
assert theming.theme_style_tag(colors) != ""
|
||||
|
||||
|
||||
def test_theme_style_tag_one_changed_carries_all_eight_in_order() -> None:
|
||||
"""A single non-built-in color still emits ALL 8 variables, in
|
||||
``COLOR_FIELDS`` order, with the exact tag shape (no whitespace)."""
|
||||
colors = dict(theming.BUILTIN_COLORS)
|
||||
colors["brand"] = "#818cf8"
|
||||
tag = theming.theme_style_tag(colors)
|
||||
assert tag == (
|
||||
'<style id="bor-theme">:root{'
|
||||
"--bg:#0f0a0a;--surface:#1a0f0f;--ink:#f0e6e6;--ink-soft:#b8a8a8;"
|
||||
"--line:#2d1a1a;--brand:#818cf8;--brand-soft:#2d0a0a;--brand-ink:#fca5a5;"
|
||||
"}</style>"
|
||||
)
|
||||
# The changed value lands under the dashed CSS name…
|
||||
assert "--brand:#818cf8;" in tag
|
||||
# …and the underscored field (ink_soft) renders as --ink-soft.
|
||||
assert "--ink-soft:#b8a8a8;" in tag
|
||||
assert "--ink_soft" not in tag
|
||||
|
||||
|
||||
def test_theme_style_tag_multiple_changed() -> None:
|
||||
"""Two changed colors: both values present, the rest built-in, order
|
||||
unchanged (the tag is a complete :root override — the page never
|
||||
mixes a partial palette)."""
|
||||
colors = dict(theming.BUILTIN_COLORS)
|
||||
colors["bg"] = "#0a0e1a"
|
||||
colors["brand_ink"] = "#c7d2fe"
|
||||
tag = theming.theme_style_tag(colors)
|
||||
assert tag.startswith('<style id="bor-theme">:root{--bg:#0a0e1a;')
|
||||
assert "--brand-ink:#c7d2fe;" in tag
|
||||
assert tag.endswith("}</style>")
|
||||
# The order of the 8 dashed names is the COLOR_FIELDS order.
|
||||
names = re.findall(r"--([a-z-]+):", tag)
|
||||
assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS]
|
||||
|
||||
|
||||
# ---------- inject_theme (pure — task 02's injection helper) ----------
|
||||
|
||||
_HEAD_HTML = "<html><head><title>t</title></head><body><p>b</p></body></html>"
|
||||
|
||||
|
||||
def test_inject_theme_empty_tag_is_identity() -> None:
|
||||
"""``tag == ""`` (the unset / "defaults saved" deployment — what
|
||||
``theme_style_tag" returns for all-built-in colors) → the html is
|
||||
returned EXACTLY as passed in, byte for byte (B4's no-op
|
||||
contract)."""
|
||||
assert theming.inject_theme(_HEAD_HTML, "") == _HEAD_HTML
|
||||
# Whitespace is NOT an empty tag — a real tag is always inserted.
|
||||
assert theming.inject_theme(_HEAD_HTML, " ") != _HEAD_HTML
|
||||
|
||||
|
||||
def test_inject_theme_missing_head_is_identity() -> None:
|
||||
"""No ``</head>`` occurrence → unchanged (nothing to anchor to);
|
||||
the empty string (no ``</head>`` either) is identity too."""
|
||||
tag = '<style id="bor-theme">:root{--bg:#111111;}</style>'
|
||||
html = "<html><body>no head</body></html>"
|
||||
assert theming.inject_theme(html, tag) == html
|
||||
assert theming.inject_theme("", tag) == ""
|
||||
|
||||
|
||||
def test_inject_theme_exact_placement_before_first_head_close() -> None:
|
||||
"""The tag lands with a leading newline immediately BEFORE the
|
||||
first ``</head>`` — nothing between the tag and the close, nothing
|
||||
moved after it."""
|
||||
tag = '<style id="bor-theme">:root{--bg:#111111;}</style>'
|
||||
html = "<html><head><title>t</title></head><body>after</body></html>"
|
||||
assert theming.inject_theme(html, tag) == (
|
||||
"<html><head><title>t</title>\n" + tag + "</head><body>after</body></html>"
|
||||
)
|
||||
# A LATER ``</head>``-shaped stretch of text is not the anchor — the
|
||||
# FIRST occurrence wins (the one that closes the real head).
|
||||
html2 = "<head></head><script>if (x) { a() }</head></script></head>"
|
||||
assert theming.inject_theme(html2, tag) == (
|
||||
"<head>\n" + tag + "</head><script>if (x) { a() }</head></script></head>"
|
||||
)
|
||||
|
||||
|
||||
def test_inject_theme_double_injection_is_idempotent() -> None:
|
||||
"""The defensive idempotence rule keys on the id: once a
|
||||
``id="bor-theme"`` tag is present the helper is the identity — the
|
||||
page can never carry two theme tags, even for a different tag."""
|
||||
tag = '<style id="bor-theme">:root{--bg:#111111;}</style>'
|
||||
once = theming.inject_theme(_HEAD_HTML, tag)
|
||||
assert once.count('id="bor-theme"') == 1
|
||||
assert theming.inject_theme(once, tag) == once
|
||||
other = '<style id="bor-theme">:root{--bg:#222222;}</style>'
|
||||
assert theming.inject_theme(once, other) == once
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 91 (task 05, defect fix): theme_csp_hash — the CSP3 hash of the
|
||||
# inline tag's content (the phase-82 CSP would otherwise BLOCK the tag
|
||||
# in every real browser; the caching middleware publishes the hash as a
|
||||
# style-src exemption on themed HTML pages only).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_theme_csp_hash_empty_tag_is_empty_string() -> None:
|
||||
"""No tag (unset/defaults deployment) → no hash — the plain A1
|
||||
policy stands and the header stays byte-identical to pre-91."""
|
||||
assert theming.theme_csp_hash("") == ""
|
||||
|
||||
|
||||
def test_theme_csp_hash_is_sha256_of_the_tag_content() -> None:
|
||||
"""CSP3 §13.4: the hash covers the character data BETWEEN the tags
|
||||
(the ``:root{…}`` declarations — the rendered content carries no
|
||||
leading/trailing whitespace, so no stripping applies), base64
|
||||
after SHA-256, ``sha256-`` prefixed."""
|
||||
tag = '<style id="bor-theme">:root{--bg:#111111}</style>'
|
||||
expected = "sha256-" + base64.b64encode(
|
||||
hashlib.sha256(b":root{--bg:#111111}").digest()
|
||||
).decode("ascii")
|
||||
assert theming.theme_csp_hash(tag) == expected
|
||||
|
||||
|
||||
def test_theme_csp_hash_changes_with_the_palette() -> None:
|
||||
"""A different palette → a different hash: the browser keeps
|
||||
blocking the OLD tag once the theme changes (the exemption always
|
||||
matches exactly the served bytes, never a stale palette)."""
|
||||
colors = dict(theming.BUILTIN_COLORS)
|
||||
colors["brand"] = "#818cf8"
|
||||
first = theming.theme_csp_hash(theming.theme_style_tag(colors))
|
||||
colors["brand"] = "#22c55e"
|
||||
second = theming.theme_csp_hash(theming.theme_style_tag(colors))
|
||||
assert first
|
||||
assert first != second
|
||||
assert first.startswith("sha256-")
|
||||
assert second.startswith("sha256-")
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Unit: the admin UI-settings API (phase 91, task 01).
|
||||
|
||||
Covers ``app/api/ui_settings.py`` — the PUT validation + normalization
|
||||
contract and the GET/PUT persistence on the single ``ui_settings`` row:
|
||||
|
||||
* PUT validation — the 422s NAME the offending field (fixed details):
|
||||
a >300-char string after the trim, a non-``#rrggbb`` color (wrong
|
||||
prefix, 3-digit shorthand, 8 hex chars, missing ``#``);
|
||||
* normalization — colors are lowercased on store; a color EQUAL to its
|
||||
built-in is stored as NULL (the owner-locked rule: "save the defaults"
|
||||
must leave the row empty — the no-op injection contract); an empty /
|
||||
whitespace-only string is the clear operation (NULL);
|
||||
* GET — the effective merge (a partial row reports the DB values over
|
||||
the env/built-in defaults);
|
||||
* upsert — the first PUT CREATES the id-1 row, the second UPDATES that
|
||||
same row (one row, always id 1).
|
||||
|
||||
House pattern (the ``test_tokens_api`` precedent): the real app via
|
||||
TestClient (cookie jar = the house admin-login fixture) against the real
|
||||
compose Postgres; the single row is global state, so an autouse fixture
|
||||
resets it around every test.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core import theming
|
||||
from app.models import UiSettings
|
||||
|
||||
ALL_NULL_BODY: dict[str, str | None] = {
|
||||
"app_name": None, "input_placeholder": None, "footer_text": None,
|
||||
"bg": None, "surface": None, "ink": None, "ink_soft": None,
|
||||
"line": None, "brand": None, "brand_soft": None, "brand_ink": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_ui_settings(db: Session) -> Iterator[None]:
|
||||
"""ui_settings holds ONE row of global state: reset it around every
|
||||
test (the ``clean_tokens`` house pattern, DELETE — the row is
|
||||
created only by the PUT upsert, so "absent" is the natural
|
||||
pristine state)."""
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.commit()
|
||||
yield
|
||||
db.execute(text("DELETE FROM ui_settings"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _row(db: Session) -> UiSettings | None:
|
||||
return db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first()
|
||||
|
||||
|
||||
def test_put_too_long_string_422_names_the_field(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""Each of the 3 strings: >300 chars AFTER the trim is a 422 naming
|
||||
that field; a rejected PUT half-writes nothing; exactly 300 still
|
||||
passes (the column is VARCHAR(300))."""
|
||||
for field in theming.STRING_FIELDS:
|
||||
r = admin_client.put("/api/ui-settings", json={field: "x" * 301})
|
||||
assert r.status_code == 422, (field, r.text)
|
||||
assert r.json()["detail"] == f"{field} is too long (max 300)"
|
||||
# A whitespace-padded 301 is still 301 after the trim…
|
||||
r = admin_client.put("/api/ui-settings", json={field: " x" * 151})
|
||||
assert r.status_code == 422, (field, r.text)
|
||||
# No rejected PUT created the row — the upsert runs after validation.
|
||||
assert _row(db) is None
|
||||
# Exactly 300 passes — stored, trimmed.
|
||||
r = admin_client.put("/api/ui-settings", json={"app_name": "y" * 300})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["app_name"] == "y" * 300
|
||||
|
||||
|
||||
def test_put_bad_hex_422_names_the_field(admin_client: TestClient) -> None:
|
||||
"""Each of the 8 colors: anything not ``^#[0-9a-fA-F]{6}$`` is a 422
|
||||
naming that field — 3-digit shorthand, 8 hex digits, a bare hex
|
||||
without ``#``, a named color, and the empty string (the color clear
|
||||
operation is ``null``, not ``""``)."""
|
||||
for field in theming.COLOR_FIELDS:
|
||||
for bad in ("fff", "#ff", "ff00aa", "#12345678", "red"):
|
||||
r = admin_client.put("/api/ui-settings", json={field: bad})
|
||||
assert r.status_code == 422, (field, bad, r.text)
|
||||
assert r.json()["detail"] == f"{field} must be a #rrggbb hex color"
|
||||
|
||||
|
||||
def test_put_lowercases_colors_on_store(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""Uppercase hex passes the validator and is stored LOWERCASE — the
|
||||
canonical form the tag renderer and the drift comparison rely on."""
|
||||
r = admin_client.put("/api/ui-settings", json={"brand": "#818CF8"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["brand"] == "#818cf8"
|
||||
row = _row(db)
|
||||
assert row is not None
|
||||
assert row.brand == "#818cf8" # the stored column, not just the response
|
||||
|
||||
|
||||
def test_put_built_in_color_is_stored_as_null(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""The owner-locked normalization: a color equal to its built-in is
|
||||
stored as NULL — PUTting the whole built-in palette (with one value
|
||||
in uppercase, proving the compare happens AFTER the lowercase)
|
||||
leaves the row COMPLETELY empty: "save the defaults" must keep an
|
||||
unset deployment byte-identical (the no-op injection contract)."""
|
||||
body = dict(ALL_NULL_BODY)
|
||||
for key, value in theming.BUILTIN_COLORS.items():
|
||||
body[key] = value.upper() if key == "brand" else value
|
||||
r = admin_client.put("/api/ui-settings", json=body)
|
||||
assert r.status_code == 200, r.text
|
||||
# The response is the effective values — still the built-ins…
|
||||
for key in theming.COLOR_FIELDS:
|
||||
assert r.json()[key] == theming.BUILTIN_COLORS[key]
|
||||
# …and the row itself is empty (the upsert created a row of NULLs).
|
||||
row = _row(db)
|
||||
assert row is not None, "the PUT upsert creates the id-1 row"
|
||||
assert row.id == 1
|
||||
for field in (*theming.STRING_FIELDS, *theming.COLOR_FIELDS):
|
||||
assert getattr(row, field) is None, f"{field} must be stored as NULL"
|
||||
|
||||
|
||||
def test_put_empty_string_is_the_clear_operation(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""A whitespace-only (or empty) string trims to empty → NULL — the
|
||||
clear operation, not a 422 and not a stored blank: the effective
|
||||
value falls back to the env default."""
|
||||
r = admin_client.put("/api/ui-settings", json={"app_name": " ", "footer_text": ""})
|
||||
assert r.status_code == 200, r.text
|
||||
row = _row(db)
|
||||
assert row is not None
|
||||
assert row.app_name is None
|
||||
assert row.footer_text is None
|
||||
# The response reports the effective (env) fallback, not "".
|
||||
assert r.json()["app_name"] == get_settings().app_name
|
||||
assert r.json()["footer_text"] == get_settings().footer_text
|
||||
|
||||
|
||||
def test_get_effective_merge_partial_row(admin_client: TestClient, db: Session) -> None:
|
||||
"""GET reports the DB values over the defaults, column by column: a
|
||||
row with ONLY ``bg`` set (hand-inserted) reports that color plus the
|
||||
built-ins and the env strings — all 11 keys, no nulls."""
|
||||
db.add(UiSettings(id=1, bg="#123456"))
|
||||
db.commit()
|
||||
r = admin_client.get("/api/ui-settings")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS)
|
||||
assert body["bg"] == "#123456" # the DB value wins
|
||||
for key in theming.COLOR_FIELDS:
|
||||
if key != "bg":
|
||||
assert body[key] == theming.BUILTIN_COLORS[key]
|
||||
assert body["app_name"] == get_settings().app_name
|
||||
assert body["input_placeholder"] == get_settings().input_placeholder
|
||||
assert body["footer_text"] == get_settings().footer_text
|
||||
|
||||
|
||||
def test_upsert_creates_then_updates_the_id_1_row(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""The first PUT creates the id-1 row; the second updates the SAME
|
||||
row (still exactly one row, still id 1 — the single-row contract)."""
|
||||
r1 = admin_client.put(
|
||||
"/api/ui-settings", json={"brand": "#123abc", "app_name": "First"}
|
||||
)
|
||||
assert r1.status_code == 200, r1.text
|
||||
row = _row(db)
|
||||
assert row is not None and row.id == 1
|
||||
assert row.brand == "#123abc"
|
||||
assert row.app_name == "First"
|
||||
|
||||
r2 = admin_client.put(
|
||||
"/api/ui-settings", json={"brand": "#abcdef", "input_placeholder": "Second"}
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["brand"] == "#abcdef"
|
||||
assert r2.json()["input_placeholder"] == "Second"
|
||||
|
||||
db.expire_all() # drop the test session's pre-second-PUT view (house pattern)
|
||||
rows = db.execute(select(UiSettings)).scalars().all()
|
||||
assert len(rows) == 1, "the upsert must never create a second row"
|
||||
assert rows[0].id == 1
|
||||
assert rows[0].brand == "#abcdef" # updated, not appended
|
||||
assert rows[0].input_placeholder == "Second" # the new string landed
|
||||
assert rows[0].app_name is None # absent in the second body → NULL
|
||||
@@ -168,20 +168,23 @@ def test_tuning_shell_stays_hardcoded_46rem() -> None:
|
||||
|
||||
def test_no_other_hardcoded_46rem_rule_remains() -> None:
|
||||
"""After the switch, the form columns are the ONLY rules with a
|
||||
literal max-width: 46rem: .tuning-shell (phase 27) and
|
||||
literal max-width: 46rem: .tuning-shell (phase 27),
|
||||
.doc-edit-shell (phase 59, task 06 — the doc edit screen is a
|
||||
FORM column, not a reading column, so it must not ride
|
||||
--chat-column and phase 58's wide-desktop doubling must never
|
||||
stretch the form). Every reading column rides the token (the
|
||||
--chat-column base declaration is the other non-rule occurrence
|
||||
of 46rem)."""
|
||||
stretch the form), and .theme-shell (phase 91 task 04 — the
|
||||
admin Theme editor is a form column too: the palette grid +
|
||||
fieldsets must never ride the wide-desktop doubling). Every
|
||||
reading column rides the token (the --chat-column base
|
||||
declaration is the other non-rule occurrence of 46rem)."""
|
||||
css = _css()
|
||||
assert css.count("max-width: 46rem") == 2, (
|
||||
"only the form columns (.tuning-shell, .doc-edit-shell) may "
|
||||
"keep a literal max-width: 46rem"
|
||||
assert css.count("max-width: 46rem") == 3, (
|
||||
"only the form columns (.tuning-shell, .doc-edit-shell, "
|
||||
".theme-shell) may keep a literal max-width: 46rem"
|
||||
)
|
||||
assert "max-width: 46rem" in _rule_block(css, ".tuning-shell")
|
||||
assert "max-width: 46rem" in _rule_block(css, ".doc-edit-shell")
|
||||
assert "max-width: 46rem" in _rule_block(css, ".theme-shell")
|
||||
|
||||
|
||||
def test_comments_cite_the_wide_override_with_provenance() -> None:
|
||||
|
||||
Reference in New Issue
Block a user