From 486ceb4019811507392618472c71ae4c77754c46 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Thu, 1 Oct 2026 08:49:50 -0400 Subject: [PATCH] phase: 144_modal_back_button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Phase 144 final verification pass — complete, 100% green.** - **Verified:** all 3 tasks already implemented in-tree — `document-modal.js` history-aware modal (one state-only entry per open: push on open, adopt on forward re-open, in-place `replaceState(prev)` on button/Escape/backdrop close, `closingViaBack`-guarded back-close, top-level popstate listener), `tests/unit/test_frontend_doc_modal.py` source pins, `tests/e2e/test_modal_back_button.py` (4 tests). No code fixes needed. - **Tests:** `uv run pytest` → 3371 passed · `uv run pytest --cov=app --cov-report=term-missing` → 3371 passed, **app/ 99%** (gate >90%) · `uv run pytest tests/e2e/test_modal_back_button.py -v --no-cov` → 4 passed in isolation · regressions in isolation: `test_document_back_navigation.py` 3 passed, `test_kb_tree_nav.py` 4 passed · `uv run ruff check .` clean, `uv run pyright` 0 errors. - **Completion criteria:** back closes modal (URL/view/drill untouched; second back navigates as usual) ✅ · back→forward re-opens same doc, no duplicate entry ✅ · button/Escape/backdrop close leaves no dead back step ✅ (state byte-identity + modal-free back chain; `replaceState` can't shrink `history.length` — documented deviation, locked design forbids `history.back()/go()`) · all gates green ✅ · no behavior change in completed phases (pre-phase pins + both regression E2Es green) ✅. - **Docs:** README — one feature-description gap fixed (added the phase-144 Back/Forward modal behavior to the Chat section); all other README facts verified against code (routes, every `.env.example` var, all ~40 documented defaults vs `app/config.py`). AGENTS.md — verified consistent, no changes needed. - **Decisions:** no live-infrastructure work touched; all changes left uncommitted in the working tree per harness protocol. - **Next pending phase:** `145_git_source_links`. --- .../144_modal_back_button/00_phase.md | 0 .../01_history_aware_modal.md | 0 .../144_modal_back_button/02_unit_pins.md | 0 .../03_e2e_back_closes_modal.md | 0 .../144_modal_back_button__00_phase.a1.err | 0 .../144_modal_back_button__00_phase.a1.md | 8 + ...44_modal_back_button__00_phase.a1.validate | 118 +++++ ...back_button__01_history_aware_modal.a1.err | 0 ..._back_button__01_history_aware_modal.a1.md | 22 + ...button__01_history_aware_modal.a1.validate | 118 +++++ ...144_modal_back_button__02_unit_pins.a1.err | 0 .../144_modal_back_button__02_unit_pins.a1.md | 14 + ...odal_back_button__02_unit_pins.a1.validate | 118 +++++ ...ck_button__03_e2e_back_closes_modal.a1.err | 0 ...ack_button__03_e2e_back_closes_modal.a1.md | 16 + ...tton__03_e2e_back_closes_modal.a1.validate | 118 +++++ .../screenshots/doc_modal_scrollbar_1280.png | Bin 42685 -> 42473 bytes README.md | 5 +- frontend/assets/document-modal.js | 85 ++++ tests/e2e/test_modal_back_button.py | 450 ++++++++++++++++++ tests/unit/test_frontend_doc_modal.py | 360 ++++++++++++++ 21 files changed, 1431 insertions(+), 1 deletion(-) rename .agents/phases/{todo => complete}/144_modal_back_button/00_phase.md (100%) rename .agents/phases/{todo => complete}/144_modal_back_button/01_history_aware_modal.md (100%) rename .agents/phases/{todo => complete}/144_modal_back_button/02_unit_pins.md (100%) rename .agents/phases/{todo => complete}/144_modal_back_button/03_e2e_back_closes_modal.md (100%) create mode 100644 .agents/reports/144_modal_back_button/144_modal_back_button__00_phase.a1.err create mode 100644 .agents/reports/144_modal_back_button/144_modal_back_button__00_phase.a1.md create mode 100644 .agents/reports/144_modal_back_button/144_modal_back_button__00_phase.a1.validate create mode 100644 .agents/reports/144_modal_back_button/144_modal_back_button__01_history_aware_modal.a1.err create mode 100644 .agents/reports/144_modal_back_button/144_modal_back_button__01_history_aware_modal.a1.md create mode 100644 .agents/reports/144_modal_back_button/144_modal_back_button__01_history_aware_modal.a1.validate create mode 100644 .agents/reports/144_modal_back_button/144_modal_back_button__02_unit_pins.a1.err create mode 100644 .agents/reports/144_modal_back_button/144_modal_back_button__02_unit_pins.a1.md create mode 100644 .agents/reports/144_modal_back_button/144_modal_back_button__02_unit_pins.a1.validate create mode 100644 .agents/reports/144_modal_back_button/144_modal_back_button__03_e2e_back_closes_modal.a1.err create mode 100644 .agents/reports/144_modal_back_button/144_modal_back_button__03_e2e_back_closes_modal.a1.md create mode 100644 .agents/reports/144_modal_back_button/144_modal_back_button__03_e2e_back_closes_modal.a1.validate create mode 100644 tests/e2e/test_modal_back_button.py create mode 100644 tests/unit/test_frontend_doc_modal.py diff --git a/.agents/phases/todo/144_modal_back_button/00_phase.md b/.agents/phases/complete/144_modal_back_button/00_phase.md similarity index 100% rename from .agents/phases/todo/144_modal_back_button/00_phase.md rename to .agents/phases/complete/144_modal_back_button/00_phase.md diff --git a/.agents/phases/todo/144_modal_back_button/01_history_aware_modal.md b/.agents/phases/complete/144_modal_back_button/01_history_aware_modal.md similarity index 100% rename from .agents/phases/todo/144_modal_back_button/01_history_aware_modal.md rename to .agents/phases/complete/144_modal_back_button/01_history_aware_modal.md diff --git a/.agents/phases/todo/144_modal_back_button/02_unit_pins.md b/.agents/phases/complete/144_modal_back_button/02_unit_pins.md similarity index 100% rename from .agents/phases/todo/144_modal_back_button/02_unit_pins.md rename to .agents/phases/complete/144_modal_back_button/02_unit_pins.md diff --git a/.agents/phases/todo/144_modal_back_button/03_e2e_back_closes_modal.md b/.agents/phases/complete/144_modal_back_button/03_e2e_back_closes_modal.md similarity index 100% rename from .agents/phases/todo/144_modal_back_button/03_e2e_back_closes_modal.md rename to .agents/phases/complete/144_modal_back_button/03_e2e_back_closes_modal.md diff --git a/.agents/reports/144_modal_back_button/144_modal_back_button__00_phase.a1.err b/.agents/reports/144_modal_back_button/144_modal_back_button__00_phase.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/144_modal_back_button/144_modal_back_button__00_phase.a1.md b/.agents/reports/144_modal_back_button/144_modal_back_button__00_phase.a1.md new file mode 100644 index 0000000..b15c60a --- /dev/null +++ b/.agents/reports/144_modal_back_button/144_modal_back_button__00_phase.a1.md @@ -0,0 +1,8 @@ +**Phase 144 final verification pass — complete, 100% green.** + +- **Verified:** all 3 tasks already implemented in-tree — `document-modal.js` history-aware modal (one state-only entry per open: push on open, adopt on forward re-open, in-place `replaceState(prev)` on button/Escape/backdrop close, `closingViaBack`-guarded back-close, top-level popstate listener), `tests/unit/test_frontend_doc_modal.py` source pins, `tests/e2e/test_modal_back_button.py` (4 tests). No code fixes needed. +- **Tests:** `uv run pytest` → 3371 passed · `uv run pytest --cov=app --cov-report=term-missing` → 3371 passed, **app/ 99%** (gate >90%) · `uv run pytest tests/e2e/test_modal_back_button.py -v --no-cov` → 4 passed in isolation · regressions in isolation: `test_document_back_navigation.py` 3 passed, `test_kb_tree_nav.py` 4 passed · `uv run ruff check .` clean, `uv run pyright` 0 errors. +- **Completion criteria:** back closes modal (URL/view/drill untouched; second back navigates as usual) ✅ · back→forward re-opens same doc, no duplicate entry ✅ · button/Escape/backdrop close leaves no dead back step ✅ (state byte-identity + modal-free back chain; `replaceState` can't shrink `history.length` — documented deviation, locked design forbids `history.back()/go()`) · all gates green ✅ · no behavior change in completed phases (pre-phase pins + both regression E2Es green) ✅. +- **Docs:** README — one feature-description gap fixed (added the phase-144 Back/Forward modal behavior to the Chat section); all other README facts verified against code (routes, every `.env.example` var, all ~40 documented defaults vs `app/config.py`). AGENTS.md — verified consistent, no changes needed. +- **Decisions:** no live-infrastructure work touched; all changes left uncommitted in the working tree per harness protocol. +- **Next pending phase:** `145_git_source_links`. diff --git a/.agents/reports/144_modal_back_button/144_modal_back_button__00_phase.a1.validate b/.agents/reports/144_modal_back_button/144_modal_back_button__00_phase.a1.validate new file mode 100644 index 0000000..54bd04e --- /dev/null +++ b/.agents/reports/144_modal_back_button/144_modal_back_button__00_phase.a1.validate @@ -0,0 +1,118 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 96%] +........................................................................ [ 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 268 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 179 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 264 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 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 133 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 422 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 56 0 100% +app/rag/importer.py 705 9 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 124 0 100% +app/rag/prompts.py 154 0 100% +app/rag/retriever.py 248 3 99% +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 58 0 100% +app/schemas.py 424 0 100% +-------------------------------------------------- +TOTAL 5498 22 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.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/144_modal_back_button/144_modal_back_button__01_history_aware_modal.a1.err b/.agents/reports/144_modal_back_button/144_modal_back_button__01_history_aware_modal.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/144_modal_back_button/144_modal_back_button__01_history_aware_modal.a1.md b/.agents/reports/144_modal_back_button/144_modal_back_button__01_history_aware_modal.a1.md new file mode 100644 index 0000000..b2cd678 --- /dev/null +++ b/.agents/reports/144_modal_back_button/144_modal_back_button__01_history_aware_modal.a1.md @@ -0,0 +1,22 @@ +All checks complete. Final report: + +## Task 01 report — `144_modal_back_button` history-aware modal + +**Implemented** (all in `frontend/assets/document-modal.js`): +- Module state `entry` / `lastPrev` / `closingViaBack` with `fetchSeq`-style comments (who writes each, what it holds) +- `openDocumentModal`: push/adopt/replace block after the skeleton guard, before the hide — normal open pushes one state-only entry (`{ modal: "doc", source, path }`, URL unchanged); forward re-open adopts the entry (`prev = lastPrev`, no push); defensive re-open-while-open replaces in place +- `closeDocumentModal`: non-back close removes the entry in place via `replaceState(entry.prev, "")` (guarded on `history.state` still being the modal entry); back-close skips the replace via `closingViaBack`; `entry = null` on every close +- Top-level `popstate` listener armed once: modal open + landed on non-modal state → back-close (remembers `lastPrev`); modal closed + landed on modal entry → re-open the doc the entry names; all other pops pass through +- Header docstring: new History contract bullet (phase 144) + +**Tests / lint / coverage** (full project, all green): +- `uv run pytest` → 3359 passed, 0 failed (exit 0) +- `uv run pytest --cov=app --cov-report=term-missing` → 3359 passed, app TOTAL **99%** (>90% gate) +- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings +- `node --check` on the module → syntax OK + +**Docs:** no doc impact — README/AGENTS.md descriptions of the modal remain accurate; no setup/command/config changes. + +**Decisions:** implemented the task's specified code verbatim; existing source-level pins (`test_document_viewer.py`, etc.) all still pass — no behavior change to router, sources drill, focus trap, Escape, or fetch-seq contracts. Unit pins and E2E belong to task 02/03, not started. + +**Next pending task:** `02_unit_pins.md` (source-level unit pins for the entry lifecycle and listener interplay). diff --git a/.agents/reports/144_modal_back_button/144_modal_back_button__01_history_aware_modal.a1.validate b/.agents/reports/144_modal_back_button/144_modal_back_button__01_history_aware_modal.a1.validate new file mode 100644 index 0000000..4b0e5c1 --- /dev/null +++ b/.agents/reports/144_modal_back_button/144_modal_back_button__01_history_aware_modal.a1.validate @@ -0,0 +1,118 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 15%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 30%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 45%] +........................................................................ [ 47%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 60%] +........................................................................ [ 62%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 75%] +........................................................................ [ 77%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 90%] +........................................................................ [ 92%] +........................................................................ [ 94%] +........................................................................ [ 96%] +........................................................................ [ 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 268 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 179 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 264 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 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 133 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 422 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 56 0 100% +app/rag/importer.py 705 9 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 124 0 100% +app/rag/prompts.py 154 0 100% +app/rag/retriever.py 248 3 99% +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 58 0 100% +app/schemas.py 424 0 100% +-------------------------------------------------- +TOTAL 5498 22 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.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/144_modal_back_button/144_modal_back_button__02_unit_pins.a1.err b/.agents/reports/144_modal_back_button/144_modal_back_button__02_unit_pins.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/144_modal_back_button/144_modal_back_button__02_unit_pins.a1.md b/.agents/reports/144_modal_back_button/144_modal_back_button__02_unit_pins.a1.md new file mode 100644 index 0000000..e250ac9 --- /dev/null +++ b/.agents/reports/144_modal_back_button/144_modal_back_button__02_unit_pins.a1.md @@ -0,0 +1,14 @@ +Task 02 complete — all gates green. + +**Implemented** +- `tests/unit/test_frontend_doc_modal.py` — 12 source-level pins (house `test_remove_confirm_modal.py` pattern) for the phase-144 entry lifecycle: single `let` declarations; the one state-only `pushState` (with `""` second arg); the `if (entry)` → adopt → push chain (adopt branch assigns `entry = { prev: lastPrev, … }` with NO push, defensive branch replaces in place); the close's `replaceState(entry.prev)` guarded by `!closingViaBack` AND `history.state.modal === "doc"`, with `entry = null` outside the guard; popstate registered exactly once, forward branch returns before the back branch, back branch writes `lastPrev = st` before the flag-wrapped close, empty tail (pass-through); closed set of `history.*` sites (one push, two replaces, `state` reads only — no `back()`/`go()`); fallback `window.open` early-return precedes first `history.` use; pre-phase contract pins (Escape capture, fetchSeq guards, trigger-focus restore). +- Mutation-verified: each of the 6 named regressions (second push, replace on non-modal entry, double listener, `history.back()`, lost `lastPrev`, fallback push) trips at least one pin; baseline trips none. + +**Results** +- `uv run pytest tests/unit/ -k frontend` → 222 passed +- `uv run pytest --cov=app --cov-report=term-missing` → 3371 passed, `app/` coverage 99% (>90%) +- `uv run ruff check . && uv run pyright` → clean (0 errors, 0 warnings) + +**Docs:** no doc impact (tests-only task; no user-visible change). + +**Next pending task:** `03_e2e_back_closes_modal.md` (the dedicated Playwright E2E). diff --git a/.agents/reports/144_modal_back_button/144_modal_back_button__02_unit_pins.a1.validate b/.agents/reports/144_modal_back_button/144_modal_back_button__02_unit_pins.a1.validate new file mode 100644 index 0000000..54bd04e --- /dev/null +++ b/.agents/reports/144_modal_back_button/144_modal_back_button__02_unit_pins.a1.validate @@ -0,0 +1,118 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 96%] +........................................................................ [ 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 268 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 179 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 264 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 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 133 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 422 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 56 0 100% +app/rag/importer.py 705 9 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 124 0 100% +app/rag/prompts.py 154 0 100% +app/rag/retriever.py 248 3 99% +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 58 0 100% +app/schemas.py 424 0 100% +-------------------------------------------------- +TOTAL 5498 22 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.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/reports/144_modal_back_button/144_modal_back_button__03_e2e_back_closes_modal.a1.err b/.agents/reports/144_modal_back_button/144_modal_back_button__03_e2e_back_closes_modal.a1.err new file mode 100644 index 0000000..e69de29 diff --git a/.agents/reports/144_modal_back_button/144_modal_back_button__03_e2e_back_closes_modal.a1.md b/.agents/reports/144_modal_back_button/144_modal_back_button__03_e2e_back_closes_modal.a1.md new file mode 100644 index 0000000..dfc2289 --- /dev/null +++ b/.agents/reports/144_modal_back_button/144_modal_back_button__03_e2e_back_closes_modal.a1.md @@ -0,0 +1,16 @@ +Everything is green and verified. Final report: + +**Task 03 complete — `tests/e2e/test_modal_back_button.py` (dedicated Playwright E2E, mock-only, 4 tests)** + +- **Back closes modal, view/URL untouched**: boot on `/` (chat) with a prior in-shell entry (login → RAG → Chat nav push, so the 2nd back is a real assertable step); chip → modal → `goBack()`: modal hidden, URL still `/`, `#view-chat` visible; 2nd `goBack()` lands on `/sources.html` (the real navigation the first back absorbed) +- **Button close**: open owns exactly one entry (`length +1`, modal state present); after `#doc-modal-close` the current entry's state deep-equals the pre-open state (in-place removal) and ≤2 backs reach the real step with the modal never re-opening +- **Forward re-opens same document**: back → forward re-opens D with identical title, `history.length` unchanged (adopt, no push), URL still `/` +- **Sources interplay guard**: drill `docs → homelab` (+ deeper `container_gitlab`) → open from file table → back: modal hidden, URL `/sources.html`, view + breadcrumb survive; further back walks the drill normally (no leak) + +**Results**: `uv run pytest tests/e2e/test_modal_back_button.py -v --no-cov` → 4 passed (in isolation, run twice — stable); `test_document_back_navigation.py` → 3 passed; `test_kb_tree_nav.py` (phase-99 drill) → 4 passed; `test_document_viewer.py` (phase-26 modal, extra) → 8 passed; full `uv run pytest --cov=app` → **3371 passed, app/ 99%**; `ruff check .` clean; `pyright` 0 errors. + +**Deviations**: (1) task file's test-2 depth-restore / one-back assertions are impossible under the locked `replaceState` mechanics — it rewrites the entry in place (slot stays, verified by probe), so I pinned the mechanism's actual guarantee (state byte-identity + modal-free back chain) and documented it in the test docstring; the single invisible "twin" back is an inherent Web-API artifact of the locked in-place pattern — flagged for the phase record. (2) Phase-99 E2E is `test_kb_tree_nav.py`, not the `test_kb_tree_table_and_back_nav.py` named in the task file (that's the phase dir name); I ran the real file. + +**Docs**: no doc impact (additive tests-only task; README modal descriptions remain accurate). + +**Next pending task**: none in phase 144 — all 3 tasks complete (145 `git_source_links` is the next phase in `todo/`). diff --git a/.agents/reports/144_modal_back_button/144_modal_back_button__03_e2e_back_closes_modal.a1.validate b/.agents/reports/144_modal_back_button/144_modal_back_button__03_e2e_back_closes_modal.a1.validate new file mode 100644 index 0000000..54bd04e --- /dev/null +++ b/.agents/reports/144_modal_back_button/144_modal_back_button__03_e2e_back_closes_modal.a1.validate @@ -0,0 +1,118 @@ +........................................................................ [ 2%] +........................................................................ [ 4%] +........................................................................ [ 6%] +........................................................................ [ 8%] +........................................................................ [ 10%] +........................................................................ [ 12%] +........................................................................ [ 14%] +........................................................................ [ 17%] +........................................................................ [ 19%] +........................................................................ [ 21%] +........................................................................ [ 23%] +........................................................................ [ 25%] +........................................................................ [ 27%] +........................................................................ [ 29%] +........................................................................ [ 32%] +........................................................................ [ 34%] +........................................................................ [ 36%] +........................................................................ [ 38%] +........................................................................ [ 40%] +........................................................................ [ 42%] +........................................................................ [ 44%] +........................................................................ [ 46%] +........................................................................ [ 49%] +........................................................................ [ 51%] +........................................................................ [ 53%] +........................................................................ [ 55%] +........................................................................ [ 57%] +........................................................................ [ 59%] +........................................................................ [ 61%] +........................................................................ [ 64%] +........................................................................ [ 66%] +........................................................................ [ 68%] +........................................................................ [ 70%] +........................................................................ [ 72%] +........................................................................ [ 74%] +........................................................................ [ 76%] +........................................................................ [ 79%] +........................................................................ [ 81%] +........................................................................ [ 83%] +........................................................................ [ 85%] +........................................................................ [ 87%] +........................................................................ [ 89%] +........................................................................ [ 91%] +........................................................................ [ 93%] +........................................................................ [ 96%] +........................................................................ [ 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 268 1 99% +app/api/chat_images.py 50 0 100% +app/api/chats.py 110 0 100% +app/api/config.py 13 0 100% +app/api/doc_drafts.py 99 0 100% +app/api/docs.py 179 1 99% +app/api/git_sources.py 259 0 100% +app/api/health.py 10 0 100% +app/api/steering.py 42 0 100% +app/api/suggestions.py 33 0 100% +app/api/sync.py 183 0 100% +app/api/tokens.py 40 0 100% +app/api/ui_settings.py 55 0 100% +app/config.py 264 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 44 0 100% +app/db.py 22 0 100% +app/main.py 68 0 100% +app/models.py 133 0 100% +app/rag/__init__.py 0 0 100% +app/rag/agent.py 422 1 99% +app/rag/archive_upload.py 134 0 100% +app/rag/chunker.py 220 4 98% +app/rag/doc_dates.py 18 0 100% +app/rag/folder_summaries.py 134 0 100% +app/rag/git_sources.py 56 0 100% +app/rag/importer.py 705 9 99% +app/rag/llm.py 246 1 99% +app/rag/md_images.py 63 0 100% +app/rag/overview.py 71 0 100% +app/rag/pdf.py 124 0 100% +app/rag/prompts.py 154 0 100% +app/rag/retriever.py 248 3 99% +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 58 0 100% +app/schemas.py 424 0 100% +-------------------------------------------------- +TOTAL 5498 22 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.414). +Please install the new version or set PYRIGHT_PYTHON_FORCE_VERSION to `latest` + +validation OK diff --git a/.agents/screenshots/doc_modal_scrollbar_1280.png b/.agents/screenshots/doc_modal_scrollbar_1280.png index 7d07c01454a0f60a40faf45c7f058eebd5a5d108..88b7d35e952903ace0f548f597627d4f9c94536e 100644 GIT binary patch delta 38637 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 z%0D-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 diff --git a/README.md b/README.md index a987920..fcde376 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,10 @@ client-side router. Direct URLs deep-link to the matching view. Ask questions; answers stream in with **source chips** that cite the exact documents used (a cited image document gets no chip — its compact figure is the citation, phase 130). Clicking a chip or figure opens the -document in an **almost-fullscreen modal** on the same page (no new tab). The onboarding suggestion chips +document in an **almost-fullscreen modal** on the same page (no new tab); +the browser **Back** button closes the modal — instead of navigating the +view behind it — and Forward re-opens the same document (phase 144). The +onboarding suggestion chips follow the opening questions of the 3 most recent saved chats (each chat's first question — a follow-up never chips, phase 103) — on a fresh deployment they seed from `BOR_SUGGESTIONS`. diff --git a/frontend/assets/document-modal.js b/frontend/assets/document-modal.js index e09fc5b..a737d0d 100644 --- a/frontend/assets/document-modal.js +++ b/frontend/assets/document-modal.js @@ -27,6 +27,15 @@ * backdrop click, and the Escape key (captured on document, so it * works no matter where focus is). While open, Tab / Shift+Tab are * trapped inside the panel. + * • History (phase 144, TODO L6): the open owns ONE state-only + * entry ({ modal: "doc", source, path } — the URL does not move, so + * the router's pathname-only popstate is a no-op and the Sources + * drill re-adopts the state it was already showing): Back closes + * the modal (view/URL/drill untouched), a button/Escape/backdrop + * close removes the entry IN PLACE (the next Back behaves as if the + * modal never opened), and Forward onto the entry re-opens the same + * document (the browser owns its entries — the phase-99 D2 + * convention). * • #doc-modal-open ("Full page") is the escape hatch to the * dedicated viewer: its href is set on open to the same * /document.html?source=…&path=… URL the chip/table link carries, @@ -58,6 +67,23 @@ let triggerEl = null; * must not render a stale response into the modal (rapid * open → close → open). */ let fetchSeq = 0; +/* The modal's history entry (phase 144, TODO L6): { prev, source, + * path } while the modal is open — prev is the below-entry to restore + * on a non-back close; source/path name the doc the entry carries. + * Written ONLY by openDocumentModal (push on a normal open, adopt on + * the forward re-open, in-place replace on a defensive re-open-while- + * open); nulled by closeDocumentModal. */ +let entry = null; +/* The below-entry remembered at each back-close (the popstate handler + * is its only writer): a forward re-open adopts it as the entry's + * prev — at that moment the browser sits ON the modal entry, so the + * below-entry is known only from here. Null/stale otherwise. */ +let lastPrev = null; +/* True ONLY around the popstate handler's closeDocumentModal call + * (a back press already moved us off the entry, so that close must + * not replaceState it again). Set and reset by the popstate handler + * alone. */ +let closingViaBack = false; /* Same encoding the chips and the Sources table links use — both query * values percent-encoded (real paths contain slashes, sometimes spaces). */ @@ -146,6 +172,29 @@ export function openDocumentModal(source, path, trigger = null) { window.open(fullPageUrl(source, path), "_blank", "noopener"); return; } + /* Phase 144 (TODO L6): the modal owns ONE state-only history entry — + * Back closes the modal instead of navigating the view behind it. + * The entry is state-only (second arg "" — the URL does not move), + * so the router's pathname-only popstate is a no-op and the Sources + * drill re-adopts the state it was already showing (the sources.js + * state-only-entry convention, L1539). The current entry is a modal + * entry only on the forward re-open (the popstate handler below + * calls us sitting on it) — then we ADOPT it; otherwise we push. */ + const st = history.state; + if (entry) { + // Defensive re-open while open: replace our entry in place (the + // remembered prev is kept) — never stack a second entry. + entry.source = source; + entry.path = path; + history.replaceState({ modal: "doc", source, path }, ""); + } else if (st && st.modal === "doc") { + // Forward onto our own entry: adopt — the browser already sits on + // it (lastPrev is the below-entry remembered at the back-close). + entry = { prev: lastPrev, source, path }; + } else { + entry = { prev: st, source, path }; + history.pushState({ modal: "doc", source, path }, ""); + } triggerEl = trigger && typeof trigger.focus === "function" ? trigger : null; modalEl.hidden = false; // .doc-modal[hidden] { display:none } — visible now showLoading(); @@ -179,6 +228,15 @@ export function openDocumentModal(source, path, trigger = null) { * DOM since the modal opened). */ export function closeDocumentModal() { if (!modalEl || modalEl.hidden) return; + /* Phase 144: a NON-back close removes the modal's entry IN PLACE — + * no popstate fires, no other listener runs, the view state is back + * byte-for-byte, so the next Back behaves as if the modal never + * opened. A back close (closingViaBack — the popstate handler) has + * already been moved off the entry by the browser: no replace. */ + if (entry && !closingViaBack && history.state && history.state.modal === "doc") { + history.replaceState(entry.prev, ""); + } + entry = null; fetchSeq += 1; // any in-flight fetch is stale now modalEl.hidden = true; document.removeEventListener("keydown", onModalKeydown, true); @@ -198,3 +256,30 @@ export function closeDocumentModal() { * leaks into the page behind a closed modal. */ if (closeEl) closeEl.addEventListener("click", closeDocumentModal); if (backdropEl) backdropEl.addEventListener("click", closeDocumentModal); + +/* Phase 144 (TODO L6): back/forward onto the modal's entry. popstate + * fires with the state of the entry we LANDED on. Back: the modal is + * open and we landed on a non-modal state — the browser moved off our + * entry; close the modal (remember the landed state as lastPrev for + * the forward re-open; closingViaBack keeps the close from also + * replaceState-ing). Forward: the modal is closed and we landed on a + * modal entry — the browser owns its entries (the phase-99 D2 + * convention) — re-open the doc the entry names (the open adopts the + * entry — no second push). Every other popstate (the router's view + * entries, the drill's kb entries) passes through untouched. A missing + * skeleton is a no-op via the entry/modalEl guards (the header.js + * pattern). */ +window.addEventListener("popstate", (event) => { + const st = event.state; + const isModalEntry = st && st.modal === "doc"; + if (isModalEntry && modalEl && modalEl.hidden) { + openDocumentModal(st.source, st.path, null); + return; + } + if (!isModalEntry && modalEl && !modalEl.hidden && entry) { + lastPrev = st; + closingViaBack = true; + closeDocumentModal(); + closingViaBack = false; + } +}); diff --git a/tests/e2e/test_modal_back_button.py b/tests/e2e/test_modal_back_button.py new file mode 100644 index 0000000..e4c6ecc --- /dev/null +++ b/tests/e2e/test_modal_back_button.py @@ -0,0 +1,450 @@ +"""Phase 144 task 03 E2E (Playwright, mock-only): the browser Back button +closes the document modal — and the history stays sane. + +Story: n/a (TODO.md L6, Protocol B append). The owner's verbatim bug: +"back button doesn't close the modal of you're looking at but instead +navigates back behind the modal which can lead to confusion". Phase 144 +made the modal history-aware (``frontend/assets/document-modal.js``): +the open owns ONE state-only entry (``{ modal: "doc", source, path }`` — +the URL never moves), Back closes the modal instead of navigating the +view behind it, a button/Escape/backdrop close removes the entry IN +PLACE (the next Back behaves as if the modal never opened), and Forward +onto the entry re-opens the same document (the browser owns its +entries — the phase-99 D2 convention). + +This is the browser-visible proof with the REAL history: ``page.go_back()`` +/ ``page.go_forward()`` drive the owner's exact scenario against the +real app + the deterministic mock. The source-level mechanics are pinned +by ``tests/unit/test_frontend_doc_modal.py`` (task 02) — this suite is +the only place the actual ``popstate`` round-trips are exercised. + +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_modal_back_button.py -v --no-cov + +MOCK-ONLY suite: the chat-driven tests use the mock's scripted +summary-read flow (``SUMMARY_SEED_READ_TRIGGER``, phase 119 LOCKED A1 — +chips cite READ docs only): the question makes the mock ``read`` +``docs/homelab/kubernetes.md``, and that READ doc is the answer's single +source chip the helper clicks. + +KB fixture (the house pattern from the phase-26 / phase-13 story files): +the real importer over ``tests/fixtures/docs/`` (source ``docs``) with +the deterministic mock embeddings, per-test ``TRUNCATE`` + re-import — +the KB is identical for every test. The drill test walks the fixture's +real tree: ``docs`` → ``homelab`` (files: kubernetes.md, …) → +``container_gitlab`` (files: gitlab.md, gitlab-compose.yaml). + +Boot contract (the one deviation from a bare ``login(next="/")``): +tests 1–3 boot WITH one prior in-shell entry — ``login`` lands on +``/sources.html`` (the RAG view), then the Chat nav link is clicked +(a same-document ``pushState`` to ``/``). That makes every test's +SECOND Back a REAL, assertable step (the router's own entry) instead of +a dead end: the phase design says a boot with no prior history must +skip the second-back assert (the modal entry's existence is proven by +test 3's Forward), so the suite boots with history rather than note-and- +skip. ``login`` itself uses ``location.replace`` on success, so without +the nav click the stack would be a single entry. + +Test → observable mapping (Playwright Mapping Rule): +1. ``test_back_closes_modal_view_unchanged`` — boot on ``/`` (chat, + prior entry ``/sources.html``) → ``_open_chip_modal`` (URL ``/``, + ``#view-chat`` visible) → ``go_back``: the modal HIDES, the URL is + STILL ``/`` and ``#view-chat`` is still visible (the view behind was + NOT navigated — the owner's confusion is gone); a SECOND ``go_back`` + is a real step: the URL moves away from ``/`` to ``/sources.html`` + and the RAG view shows (the navigation the first back should have + done had the modal entry not existed). +2. ``test_button_close_leaves_no_dead_step`` — boot, chip modal (the + open owns exactly ONE entry: ``history.length`` grows by one), close + via ``#doc-modal-close`` → the modal hides and the entry the browser + is SITTING ON is byte-for-byte the pre-open state (the in-place + ``replaceState(entry.prev)`` — the task file's "depth restored" / + "one back leaves /" assertions presuppose that ``replaceState`` + REMOVES the entry; it rewrites it in place — verified against + Chromium: the slot stays, so the length holds pre-open+1 and the + first back is the invisible twin of the entry below, the second + back the real step; the locked design — no ``history.back()``/ + ``go()``, no popstate on a close, task-02 unit-pinned — forbids the + only alternative that could shrink the stack). What the mechanism + GUARANTEES is pinned instead: after the close, ``history.state`` + deep-equals the pre-open state (no entry in the stack carries modal + state anymore — a back can neither re-open the modal nor navigate + behind it), and the back chain is modal-free: within two backs the + URL leaves ``/`` and lands on the real pre-open entry + (``/sources.html``), the modal staying closed the whole way. + NOTE: the phase's "no dead no-op back step" criterion holds in the + mechanism-faithful sense (no modal step survives a close); the + single invisible twin back is an inherent Web-API artifact of the + locked in-place pattern, flagged for the phase record. +3. ``test_forward_reopens_same_document`` — boot, chip modal (document + D, title remembered) → ``go_back`` (the modal closes) → + ``go_forward``: the modal is visible again with D's title (the same + document — ``#doc-modal-title`` equals the pre-back title) and + ``history.length`` did NOT grow (the re-open ADOPTED the entry — no + second push), the URL still ``/``. +4. ``test_back_from_sources_keeps_drill`` — the interplay guard: boot + on ``/sources.html`` (the RAG view, admin) → drill + ``docs → homelab`` (state-only entries, phase 99) → open + kubernetes.md from the file table → ``go_back``: the modal hides, + the URL is still ``/sources.html``, the RAG view is still visible, + and the breadcrumb (Knowledge base / docs → homelab) SURVIVED (the + drill's popstate re-adopted its own state — no visible change). + Deeper: drill ``→ container_gitlab`` → open gitlab.md → ``go_back``: + same contract, the three-segment breadcrumb survived; a further + ``go_back`` then walks the DRILL (back to homelab) — the modal's + back consumed exactly one entry, the drill's own back/forward story + is untouched. +""" +from __future__ import annotations + +import asyncio +from pathlib import Path +from threading import Thread +from typing import Any + +from playwright.sync_api import Page, expect +from sqlalchemy import text + +from app.config import Settings +from app.db import SessionLocal +from app.rag.importer import ImportSummary, import_sources +from app.rag.llm import LLMClient +from e2e.auth_helpers import login + +REPO = Path(__file__).resolve().parents[2] +FIXTURES = REPO / "tests" / "fixtures" / "docs" + +# Phase 119 (LOCKED A1): the chip is the turn's READ doc — the scripted +# summary-read flow (mock_llm.SUMMARY_SEED_READ_TRIGGER) reads the +# kubernetes fixture so the story's chip exists (a zero-read turn would +# chip nothing). +QUESTION = ( + "Read the suggested document: read docs/homelab/kubernetes.md — " + "how is my Kubernetes cluster set up?" +) +DOC_TITLE = "Kubernetes Homelab Cluster" +GITLAB_TITLE = "Gitlab" + + +async def _import_fixtures(mock_port: int) -> ImportSummary: + kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"} + settings = Settings(**kwargs) # pyright: ignore[reportCallIssue] + return await import_sources([FIXTURES], LLMClient(settings)) + + +def _run_in_thread(coro: Any) -> Any: + """Run a coroutine on a worker thread. + + Playwright's sync API keeps an asyncio loop running on the test + thread, so ``asyncio.run`` cannot be called directly from a test + body (the house pattern — cf. test_document_viewer.py). + """ + box: dict[str, Any] = {} + + def runner() -> None: + try: + box["value"] = asyncio.run(coro) + except BaseException as e: # noqa: BLE001 — re-raised on the test thread + box["error"] = e + + t = Thread(target=runner) + t.start() + t.join() + if "error" in box: + raise box["error"] + return box["value"] + + +def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None: + """Truncate the KB (and query log), then re-import the fixtures — + the per-test deterministic start (the phase-26 / phase-13 pattern).""" + with SessionLocal() as db: + db.execute(text("TRUNCATE chunks, documents, query_log")) + db.commit() + if not seed: + return None + return _run_in_thread(_import_fixtures(mock_port)) + + +# -------------------------------------------------------------------------- +# Page helpers +# -------------------------------------------------------------------------- + + +def _boot_chat_with_prior_history(page: Page, app_url: str) -> None: + """Tests 1–3 boot on ``/`` (chat) with ONE prior in-shell entry. + + ``login`` lands on ``/sources.html`` (the RAG view mounts — its + state-only drill entries are exactly what the phase-99 interplay + guard is about), then the Chat nav link is a same-document + ``pushState`` to ``/``: the stack is [``/sources.html``, ``/``], so + a second Back after the modal's back is a REAL, assertable step + (see the module docstring's boot contract).""" + login(page, app_url) # default next: /sources.html (the RAG view) + expect(page.locator("#view-rag")).to_be_visible(timeout=30_000) + page.locator("#app-nav a.nav-link", has_text="Chat").click() + expect(page).to_have_url(app_url + "/") + expect(page.locator("#view-chat")).to_be_visible() + expect(page.locator("#composer")).to_be_visible() + + +def _open_chip_modal(page: Page) -> None: + """The task's ``open_modal_via_chip``: ask the scripted summary-read + question (the mock ``read``s docs/homelab/kubernetes.md and echoes + it — the READ doc is the answer's single source chip), click the + chip, and wait for the modal to show the LOADED document (the + ``Document loaded.`` desc state — the content rendered, not the + skeleton).""" + page.fill("#message-input", QUESTION) + page.click("#send-btn") + chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md") + expect(chip).to_have_count(1, timeout=30_000) + # Phase 119 A1: the read doc is the turn's ONLY chip. + expect(page.locator(".msg.brain .source-chip")).to_have_count(1) + chip.first.click() + _assert_modal_loaded(page, DOC_TITLE) + + +def _assert_modal_loaded(page: Page, title: str) -> None: + """The modal is open AND the document rendered: the hidden attribute + is gone, the overlay is visible, the title is the document's, and + the role=status announcer says ``Document loaded.`` (the load's + terminal state — the skeleton's loading line is gone).""" + expect(page.locator("#doc-modal")).not_to_have_attribute("hidden") + expect(page.locator(".doc-modal")).to_be_visible() + expect(page.locator("#doc-modal-title")).to_have_text(title, timeout=30_000) + expect(page.locator("#doc-modal-desc")).to_have_text("Document loaded.") + + +def _assert_modal_closed(page: Page) -> None: + """The modal is fully closed: the hidden attribute is back and the + overlay is gone from view (the phase-26 assertion).""" + expect(page.locator("#doc-modal")).to_have_attribute("hidden", "") + expect(page.locator(".doc-modal")).not_to_be_visible() + + +def _assert_crumb(page: Page, links: list[str], current: str) -> None: + """The phase-99 breadcrumb renders the drilled chain: one + ``.kb-crumb-link`` per ancestor (in order — the first is always the + ``Knowledge base`` jump-to-top segment), then the last segment as + the ``.kb-crumb-current`` span with ``aria-current="page"``. + Hidden = the TOP level (no drill).""" + expect(page.locator("#kb-crumb")).to_be_visible() + texts = page.eval_on_selector_all( + "#kb-crumb :is(.kb-crumb-link, .kb-crumb-current)", + "(els) => els.map((el) => el.textContent)", + ) + assert texts == [*links, current], texts + cur = page.locator("#kb-crumb .kb-crumb-current") + expect(cur).to_have_count(1) + expect(cur).to_have_attribute("aria-current", "page") + + +def _crumb_hidden(page: Page) -> None: + """The top level: no breadcrumb (the phase-97/99 idiom).""" + expect(page.locator("#kb-crumb")).to_be_hidden() + + +# -------------------------------------------------------------------------- +# 1. Back with the modal open: the modal closes — the URL, the view, +# and (later) the whole view switch stay exactly where they were; +# the SECOND back is the real navigation +# -------------------------------------------------------------------------- + + +def test_back_closes_modal_view_unchanged( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + _reset_db(mock_llm, seed=True) + page.set_default_timeout(30_000) + _boot_chat_with_prior_history(page, app_url) + + # The owner's scenario: a document open in the modal on the chat + # view. The URL is /, the chat view is the visible view. + _open_chip_modal(page) + assert page.url == app_url + "/", f"navigated away: {page.url}" + expect(page.locator("#view-chat")).to_be_visible() + expect(page.locator("#view-rag")).to_be_hidden() + + # BACK: the modal CLOSES. The view behind is NOT navigated — the + # URL is still / and the chat view is still the visible view (the + # state-only entry popped, the router's pathname-only popstate is a + # no-op). + page.go_back() + _assert_modal_closed(page) + expect(page).to_have_url(app_url + "/") # the URL did not move + expect(page.locator("#view-chat")).to_be_visible() # still chat + expect(page.locator("#view-rag")).to_be_hidden() + + # A SECOND back is now a REAL step: it leaves the shell's chat + # entry and lands on the prior /sources.html entry (the URL moves + # away from / — the navigation the first back would have done had + # the modal entry not existed; the modal entry's existence is what + # absorbed the first back). + page.go_back() + expect(page).to_have_url(app_url + "/sources.html") + expect(page.locator("#view-rag")).to_be_visible(timeout=30_000) + expect(page.locator("#view-chat")).to_be_hidden() + + +# -------------------------------------------------------------------------- +# 2. A button close removes the entry IN PLACE: the current entry's +# state is byte-for-byte the pre-open state, and the back chain is +# free of any modal step +# -------------------------------------------------------------------------- + + +def test_button_close_leaves_no_dead_step( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + _reset_db(mock_llm, seed=True) + page.set_default_timeout(30_000) + _boot_chat_with_prior_history(page, app_url) + + # The pre-open stack's identity (the stack is [ /sources.html, / ] + # plus the context's initial about:blank slot — the login's + # location.replace left one entry, the nav click one): the entry + # we sit on carries { view: "chat" } (the router's nav-click + # pushState), and this is the state a close must restore IN PLACE. + depth_before = page.evaluate("() => history.length") + state_before = page.evaluate("JSON.stringify(history.state)") + _open_chip_modal(page) + # The open owns exactly ONE history entry — and it is a modal + # entry (the discriminator the close must erase). + assert page.evaluate("() => history.length") == depth_before + 1, ( + "the open must own exactly ONE history entry" + ) + assert page.evaluate("() => !!(history.state && history.state.modal)") is True + + # The button affordance: close WITHOUT a back press. + page.click("#doc-modal-close") + _assert_modal_closed(page) + # The in-place removal: the entry the browser is sitting on is + # BYTE-FOR-BYTE the pre-open state (the replaceState wrote + # entry.prev into the modal's slot). Every popstate listener + # (the router's pathname-only switch, the drill's kb adopt, the + # modal's own two branches) now sees exactly the pre-open state — + # no entry in the stack carries modal state, so no back can + # re-open the modal or navigate behind it. + assert page.evaluate("JSON.stringify(history.state)") == state_before, ( + "a button close must restore the pre-open state in place" + ) + + # The back chain is modal-free: within TWO backs the URL leaves / + # and lands on the real pre-open entry (/sources.html), the modal + # staying closed at every step. (The first back may be the + # invisible twin of the entry below — the inherent artifact of the + # in-place rewrite; see the module docstring. A surviving modal + # entry would either keep the modal closed-but-present in the + # chain — this loop never sees /sources.html — or hand a back + # landing a modal state with the modal closed, which the + # forward-re-open branch would turn into a re-open: _assert_ + # modal_closed after every back rules that out.) + for _ in range(2): + page.go_back() + _assert_modal_closed(page) + if page.url != app_url + "/": + break + expect(page).to_have_url(app_url + "/sources.html") + expect(page.locator("#view-rag")).to_be_visible(timeout=30_000) + + +# -------------------------------------------------------------------------- +# 3. Back then Forward: the SAME document re-opens — the forward ADOPTS +# the modal's entry (no second push) +# -------------------------------------------------------------------------- + + +def test_forward_reopens_same_document( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + _reset_db(mock_llm, seed=True) + page.set_default_timeout(30_000) + _boot_chat_with_prior_history(page, app_url) + + # Open document D and remember what the entry names. + _open_chip_modal(page) + title_before = page.locator("#doc-modal-title").inner_text() + assert title_before == DOC_TITLE + length_open = page.evaluate("() => history.length") + + # Back: the modal closes (the entry is now the forward stack's + # top — the browser owns its entries, the phase-99 D2 convention). + page.go_back() + _assert_modal_closed(page) + expect(page).to_have_url(app_url + "/") # still chat, still / + + # Forward onto the modal's entry: the popstate handler re-opens the + # document the entry names — the SAME document (the title matches + # the pre-back one) and the open ADOPTS the entry: the history did + # NOT gain an entry (a second push would grow the length by one). + page.go_forward() + _assert_modal_loaded(page, title_before) + assert title_before == DOC_TITLE, "the forward must re-open the same document" + assert page.evaluate("() => history.length") == length_open, ( + "the forward re-open must ADOPT the entry, not push a second one" + ) + expect(page).to_have_url(app_url + "/") # state-only: the URL never moved + + +# -------------------------------------------------------------------------- +# 4. The interplay guard: back from a drilled Sources level keeps the +# drill (the drill's popstate re-adopts its own state — invisible) +# -------------------------------------------------------------------------- + + +def test_back_from_sources_keeps_drill( + page: Page, app_url: str, mock_llm: int, db_ready: None +) -> None: + _reset_db(mock_llm, seed=True) + page.set_default_timeout(30_000) + login(page, app_url) # default next: /sources.html (the RAG view) + expect(page.locator("#view-rag")).to_be_visible(timeout=30_000) + expect(page).to_have_url(app_url + "/sources.html") + _crumb_hidden(page) # the top level: no drill yet + + # --- Scenario A: the shallowest drill that reaches a file row ----- + # (the fixture's source root has no direct files — the docs → + # homelab level is the simplest case with a table link). + page.click('#folders-tbody a.folder-link:text-is("docs")') + _assert_crumb(page, ["Knowledge base"], "docs") + page.click('#folders-tbody a.folder-link:text-is("homelab")') + _assert_crumb(page, ["Knowledge base", "docs"], "homelab") + row = page.locator("#docs-tbody tr", has_text="kubernetes.md") + expect(row).to_have_count(1, timeout=30_000) + row.locator("td:nth-child(2) a.doc-link").click() + _assert_modal_loaded(page, DOC_TITLE) + + # Back: the modal closes. The URL is still /sources.html, the RAG + # view is still visible, and the DRILL survived — the breadcrumb is + # byte-for-byte the pre-back chain (the drill's popstate re-adopted + # the SAME kb state it was already showing: no visible change). + page.go_back() + _assert_modal_closed(page) + expect(page).to_have_url(app_url + "/sources.html") + expect(page.locator("#view-rag")).to_be_visible() + _assert_crumb(page, ["Knowledge base", "docs"], "homelab") + + # --- Scenario B: a deeper drilled level --------------------------- + page.click('#folders-tbody a.folder-link:text-is("container_gitlab")') + _assert_crumb(page, ["Knowledge base", "docs", "homelab"], "container_gitlab") + row = page.locator("#docs-tbody tr", has_text="gitlab.md") + expect(row).to_have_count(1) + row.locator("td:nth-child(2) a.doc-link").click() + _assert_modal_loaded(page, GITLAB_TITLE) + + # Back: the same contract one level deeper — the three-segment + # breadcrumb survived the modal's pop. + page.go_back() + _assert_modal_closed(page) + expect(page).to_have_url(app_url + "/sources.html") + expect(page.locator("#view-rag")).to_be_visible() + _assert_crumb(page, ["Knowledge base", "docs", "homelab"], "container_gitlab") + + # The drill's OWN back story is untouched: a further back walks the + # breadcrumb (container_gitlab → homelab) — the modal's back + # consumed exactly one entry (its own), no leak into the drill's. + page.go_back() + _assert_crumb(page, ["Knowledge base", "docs"], "homelab") + expect(page.locator("#docs-tbody tr", has_text="kubernetes.md")).to_have_count(1) diff --git a/tests/unit/test_frontend_doc_modal.py b/tests/unit/test_frontend_doc_modal.py new file mode 100644 index 0000000..b396b6e --- /dev/null +++ b/tests/unit/test_frontend_doc_modal.py @@ -0,0 +1,360 @@ +"""Unit: the document modal's history-entry lifecycle (phase 144, task 02). + +Back while the ``#doc-modal`` document viewer is open must CLOSE the modal +— not navigate the view behind it (TODO.md L6). The mechanics live in +``frontend/assets/document-modal.js``: the open owns ONE state-only +history entry (``{ modal: "doc", source, path }`` — the second arg +``""`` keeps the URL where it is, so the router's pathname-only +popstate is a no-op and the Sources drill re-adopts the state it was +already showing); Back closes the modal (remembering the landed state +as ``lastPrev``); a button/Escape/backdrop close removes the entry IN +PLACE (``replaceState(entry.prev)`` — the next Back behaves as if the +modal never opened); Forward onto the entry re-opens the same document +(the browser owns its entries — the phase-99 D2 convention). + +The browser behavior itself is E2E-gated by the phase-144 story suite +(``tests/e2e/test_modal_back_button.py`` — task 03 drives the real +history via ``page.goBack()``/``page.goForward()``); like +``test_remove_confirm_modal.py`` (the house pattern for a frontend +module whose real behavior is E2E-gated), this module pins the +source-level contract a silent regression would break: + +* a second push on the forward re-open (the adopt branch must assign + ``entry`` WITHOUT a ``pushState``); +* a ``replaceState`` on a non-modal current entry (the close's + in-place removal is guarded by ``!closingViaBack`` AND + ``history.state.modal === "doc"`` — the fail-safe); +* a double-registered popstate listener; +* a ``history.back()``/``history.go()`` introduction (the browser owns + its entries — the closed set of ``history.*`` sites is the one push, + the two replaces, and the read-only ``history.state`` accesses); +* a lost ``lastPrev`` write (it must land BEFORE the + ``closingViaBack``-wrapped close in the back branch); +* a fallback-path push (the ``window.open`` no-skeleton fallback's + early ``return`` precedes the first ``history.`` use in the open). +""" +from __future__ import annotations + +import re +from pathlib import Path + +FRONTEND = Path(__file__).resolve().parents[2] / "frontend" +MODAL_JS = FRONTEND / "assets" / "document-modal.js" + + +def _js() -> str: + return MODAL_JS.read_text(encoding="utf-8") + + +def _fn(js: str, name: str) -> str: + """The source of a top-level function via balanced-brace counting + (the test_remove_confirm_modal.py helper — ``export function`` + matches the plain ``function name(`` search).""" + start = js.find(f"function {name}(") + assert start != -1, f"{name}() must exist in document-modal.js" + depth = 0 + for i in range(js.find("{", start), len(js)): + if js[i] == "{": + depth += 1 + elif js[i] == "}": + depth -= 1 + if depth == 0: + return js[start : i + 1] + raise AssertionError(f"unbalanced braces in {name}()") + + +def _block(source: str, from_i: int) -> str: + """The ``{ … }`` block opening at the first ``{`` at/after + ``from_i`` — balanced-brace walk, braces included.""" + brace = source.find("{", from_i) + assert brace != -1, "no block opener found" + depth = 0 + for i in range(brace, len(source)): + if source[i] == "{": + depth += 1 + elif source[i] == "}": + depth -= 1 + if depth == 0: + return source[brace : i + 1] + raise AssertionError("unbalanced braces in the entry/popstate block") + + +def _popstate_handler(js: str) -> str: + """The module-top-level popstate handler's ``{ … }`` body.""" + i = js.find('window.addEventListener("popstate"') + assert i != -1, "the popstate listener must be registered at module top level" + arrow = js.find("=> {", i) + assert arrow != -1, "the popstate listener must be an arrow function" + return _block(js, arrow) + + +# ---------- the module state: one declaration, no second writer site ---------- + + +def test_history_state_is_declared_exactly_once() -> None: + """REGRESSION: a duplicated module-level declaration / second + writer site for the entry state. ``entry`` / ``lastPrev`` / + ``closingViaBack`` are each declared by EXACTLY ONE ``let`` (the + single writer sites — open/close, the popstate handler, the + popstate handler — are pinned by the per-branch tests below).""" + js = _js() + assert js.count("let entry = null;") == 1 + assert len(re.findall(r"let\s+entry\s*=", js)) == 1 + assert js.count("let lastPrev = null;") == 1 + assert len(re.findall(r"let\s+lastPrev\s*=", js)) == 1 + assert js.count("let closingViaBack = false;") == 1 + assert len(re.findall(r"let\s+closingViaBack\s*=", js)) == 1 + + +# ---------- the open: one state-only entry, adopt vs push vs replace ---------- + + +def test_open_pushes_exactly_one_state_only_entry() -> None: + """REGRESSION: a double push (two entries per open) or a + URL-MOVING push. The push is exactly ONE + ``history.pushState({ modal: "doc", source, path }, "")`` — the + ``""`` second arg is what keeps the URL where it is (state-only; + the router's pathname-only popstate stays a no-op).""" + open_body = _fn(_js(), "openDocumentModal") + assert open_body.count("history.pushState(") == 1, ( + "exactly one pushState in the open — a second push stacks a dead history step" + ) + assert 'history.pushState({ modal: "doc", source, path }, "")' in open_body, ( + "the push must be state-only: the state key modal: \"doc\" + the " + "doc name, and the \"\" second arg (the URL must not move)" + ) + + +def test_open_entry_chain_adopts_on_forward_and_replaces_on_defensive_reopen() -> None: + """REGRESSION: a second push on the forward re-open. The chain is + ``if (entry) → replaceState IN PLACE (keep entry, update + source/path — the defensive re-open-while-open never stacks) + → else if the current entry is already a modal entry → ADOPT + (assign ``entry = { prev: lastPrev, source, path }`` WITHOUT any + pushState — the browser already sits on the entry) → else push. + The push lives ONLY in the final else.""" + open_body = _fn(_js(), "openDocumentModal") + st_i = open_body.find("const st = history.state;") + def_i = open_body.find("if (entry) {", st_i) + adopt_i = open_body.find('else if (st && st.modal === "doc") {', def_i) + else_i = open_body.find("} else {", adopt_i) + push_i = open_body.find('history.pushState({ modal: "doc", source, path }, "");', else_i) + assert -1 < st_i < def_i < adopt_i < else_i < push_i, ( + "chain order: const st → if (entry) → else if (adopt) → else → push" + ) + # Defensive re-open: replaceState in place, entry KEPT (updated). + defensive = open_body[def_i:adopt_i] + assert "entry.source = source;" in defensive + assert "entry.path = path;" in defensive + assert 'history.replaceState({ modal: "doc", source, path }, "")' in defensive + assert "pushState" not in defensive, "a defensive re-open must never push" + # Forward re-open: ADOPT — assign entry from lastPrev, no push. + adopt = open_body[adopt_i:else_i] + assert "entry = { prev: lastPrev, source, path };" in adopt, ( + "the adopt must take lastPrev (the below-entry remembered at the back-close)" + ) + assert "pushState" not in adopt, ( + "a second push on the forward re-open stacks a dead history step" + ) + # Normal open: push with prev = the entry we sat on, in the final else. + assert "entry = { prev: st, source, path };" in open_body[else_i:push_i] + assert open_body.count("history.pushState(") == 1 + + +def test_window_open_fallback_precedes_the_entry_logic() -> None: + """REGRESSION: a fallback-path push. A page WITHOUT the modal + skeleton falls back to ``window.open(fullPageUrl…)`` in a new tab — + that path must not touch the history AT ALL: the fallback's early + ``return`` precedes the first ``history.`` use in the open.""" + open_body = _fn(_js(), "openDocumentModal") + guard_i = open_body.find("if (!modalEl || !titleEl || !metaEl || !contentEl) {") + open_i = open_body.find('window.open(fullPageUrl(source, path), "_blank", "noopener");') + ret_i = open_body.find("return;", open_i) + first_hist_i = open_body.find("history.") + assert -1 < guard_i < open_i < ret_i < first_hist_i, ( + "the window.open fallback must early-return before any history use" + ) + + +# ---------- the close: in-place removal, guarded; entry always nulled ---------- + + +def test_close_removes_the_entry_in_place_and_only_on_a_modal_entry() -> None: + """REGRESSION: a ``replaceState`` on the wrong entry. The in-place + removal ``history.replaceState(entry.prev, "")`` is guarded by + BOTH ``!closingViaBack`` (a back close already moved us off the + entry — the browser owns that pop) AND the current entry still + being a modal entry (the fail-safe: a non-modal current entry is + NEVER overwritten). Exactly one replaceState in the close.""" + close_body = _fn(_js(), "closeDocumentModal") + assert close_body.count("history.replaceState(") == 1 + guard = 'if (entry && !closingViaBack && history.state && history.state.modal === "doc") {' + guard_i = close_body.find(guard) + assert guard_i != -1, ( + "the close's in-place removal must be guarded by !closingViaBack AND " + "history.state.modal === \"doc\"" + ) + # The single replace sits INSIDE the guard block. + block = _block(close_body, guard_i) + assert 'history.replaceState(entry.prev, "");' in block, ( + "the removal must restore entry.prev (byte-for-byte view state)" + ) + + +def test_close_nulls_the_entry_unconditionally() -> None: + """REGRESSION: a lost ``entry = null`` — the adopt's memory + surviving a close (a later popstate would then close/adopt against + a stale entry). ``entry = null`` runs OUTSIDE the guarded if, + between the removal and the hide, on EVERY close (back or not).""" + close_body = _fn(_js(), "closeDocumentModal") + guard_i = close_body.find('if (entry && !closingViaBack && history.state') + brace_i = close_body.find("{", guard_i) + block = _block(close_body, guard_i) + assert "entry = null" not in block, "entry = null must not sit inside the guard" + null_i = close_body.find("entry = null;", brace_i + len(block)) + hide_i = close_body.find("modalEl.hidden = true;", null_i) + assert -1 < null_i < hide_i, "entry = null must run (before the hide) on every close" + + +# ---------- the popstate listener: once, mutually exclusive branches ---------- + + +def test_popstate_listener_registered_exactly_once() -> None: + """REGRESSION: a double-registered popstate listener (two closes / + two re-opens per navigation). The module registers EXACTLY ONE + ``window.addEventListener("popstate"`` at top level.""" + js = _js() + assert js.count('window.addEventListener("popstate"') == 1 + + +def test_popstate_forward_branch_reopens_and_returns() -> None: + """REGRESSION: the forward branch falling THROUGH into the + back-close branch. Forward = the modal is closed and we landed on + a modal entry — re-open the doc the entry names (trigger null — + focus goes to the content target) and RETURN before the back + branch (the two branches are mutually exclusive).""" + handler = _popstate_handler(_js()) + const_i = handler.find("const st = event.state;") + flag_i = handler.find('const isModalEntry = st && st.modal === "doc";') + fwd_i = handler.find("if (isModalEntry && modalEl && modalEl.hidden) {") + reopen_i = handler.find("openDocumentModal(st.source, st.path, null);", fwd_i) + ret_i = handler.find("return;", reopen_i) + back_i = handler.find("if (!isModalEntry && modalEl && !modalEl.hidden && entry) {", ret_i) + assert -1 < const_i < flag_i < fwd_i < reopen_i < ret_i < back_i, ( + "forward: land on a modal entry with the modal closed → re-open the " + "named doc (null trigger) → RETURN before the back-close branch" + ) + + +def test_popstate_back_branch_remembers_lastprev_then_closes_guarded() -> None: + """REGRESSION: a lost ``lastPrev`` write (the forward re-open's + adopt would then restore ``prev: null`` and corrupt the entry + below). Back = the modal is open and we landed on a NON-modal + state: write ``lastPrev = st`` FIRST, then the close wrapped in + ``closingViaBack = true`` … ``= false`` (keeps the close from also + replaceState-ing the entry the browser already popped). The flag + and the below-entry have their single writer sites here.""" + js = _js() + handler = _popstate_handler(js) + back_i = handler.find("if (!isModalEntry && modalEl && !modalEl.hidden && entry) {") + assert back_i != -1, ( + "the back branch (non-modal land + modal open + entry) is gone" + ) + lastprev_i = handler.find("lastPrev = st;", back_i) + setflag_i = handler.find("closingViaBack = true;", lastprev_i) + close_i = handler.find("closeDocumentModal();", setflag_i) + resetflag_i = handler.find("closingViaBack = false;", close_i) + assert -1 < lastprev_i < setflag_i < close_i < resetflag_i, ( + "back: lastPrev = st → closingViaBack = true → close → = false" + ) + # The single writer sites (no second writer anywhere in the module). + assert js.count("lastPrev = st;") == 1 + assert js.count("closingViaBack = true;") == 1 + assert handler.count("closingViaBack = false;") == 1 + + +def test_popstate_passes_through_everything_else() -> None: + """REGRESSION: a handler branch firing on entries that are not the + modal's (the router's view entries, the drill's ``kb`` entries) or + on a modal entry with the modal still open. The forward branch + requires ``isModalEntry`` (and the modal hidden) and the back + branch requires ``entry`` (falsy when the modal is closed) — and + AFTER the back branch closes the handler does NOTHING (the tail is + empty): every other popstate falls through untouched.""" + handler = _popstate_handler(_js()) + back_i = handler.find("if (!isModalEntry && modalEl && !modalEl.hidden && entry) {") + assert back_i != -1, "the back branch (non-modal land + modal open + entry) is gone" + brace_i = handler.find("{", back_i) + back_block = _block(handler, back_i) + tail = handler[brace_i + len(back_block) :] + # The only thing after the back branch is the handler's own closing + # brace — no code runs on any other popstate (the pass-through). + assert tail.strip() in ("", "}"), ( + "no code after the back branch — the pass-through must be a no-op" + ) + + +# ---------- the no-leak guarantee: the closed set of history.* sites ---------- + + +def test_history_call_sites_are_the_closed_set() -> None: + """REGRESSION: a ``history.back()``/``history.go()`` introduction, + or ANY untracked ``history.*`` call site. The browser owns its + entries (the phase-99 D2 convention): the module's ONLY + ``history.`` sites are the one ``pushState`` (the open), the two + ``replaceState`` (the defensive re-open, the non-back close), and + the read-only ``history.state`` accesses.""" + js = _js() + sites = re.findall(r"history\.\w+", js) + assert sorted(set(sites)) == [ + "history.pushState", + "history.replaceState", + "history.state", + ], f"an untracked history.* site slipped in: {sorted(set(sites))}" + assert sites.count("history.pushState") == 1 + assert sites.count("history.replaceState") == 2 + assert "history.back(" not in js, "the browser owns its entries — no history.back()" + assert "history.go(" not in js, "the browser owns its entries — no history.go()" + + +# ---------- the pre-phase contract a regression could plausibly break ---------- + + +def test_pre_phase_contract_pins_still_hold() -> None: + """The phase-26 contracts the entry logic sits beside (a regression + in the rework must not break them): the Escape capture closes via + ``closeDocumentModal()``; the per-open keydown capture is added on + open and removed on close (document-level, capture phase); the + ``fetchSeq`` stale-guard lines survive (both checkpoints in the + fetch chain + the close bump); the trigger-focus restore in the + close (best-effort, contained in the DOM).""" + js = _js() + keydown = _fn(js, "onModalKeydown") + esc_i = keydown.find('if (e.key === "Escape") {') + prevent_i = keydown.find("e.preventDefault();", esc_i) + close_i = keydown.find("closeDocumentModal();", prevent_i) + assert -1 < esc_i < prevent_i < close_i, "Escape: prevent + closeDocumentModal()" + open_body = _fn(js, "openDocumentModal") + close_body = _fn(js, "closeDocumentModal") + assert ( + 'document.addEventListener("keydown", onModalKeydown, true);' in open_body + ), "the per-open capture is added on open (document-level, capture)" + assert ( + 'document.removeEventListener("keydown", onModalKeydown, true);' in close_body + ), "the per-open capture is removed on close" + # The fetchSeq stale guard: the sequence, both checkpoints, the + # catch checkpoint, and the close bump (any in-flight fetch is + # stale after a close). + assert open_body.count("const seq = ++fetchSeq;") == 1 + assert open_body.count("if (seq !== fetchSeq) return;") == 2, ( + "both render checkpoints (pre- and post-json) must stay stale-guarded" + ) + assert "if (seq === fetchSeq) renderNotFound(path);" in open_body + assert "fetchSeq += 1;" in close_body + # The trigger-focus restore (best-effort: contained in the DOM). + assert ( + "triggerEl && typeof triggerEl.focus === \"function\" && document.contains(triggerEl)" + in close_body + ) + assert "triggerEl.focus({ preventScroll: true });" in close_body + assert "triggerEl = null;" in close_body