Files
brain-of-reese/tests/e2e/test_modal_back_button.py
T
ducoterra 632fb88e43
Build and Push Containers / build-and-push-app (push) Successful in 1m41s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 185_sync_defer_content_fast_path
All gates green. Report:

**Implemented / verified (phase 185)**
- `_doc_row_stmt` is the ONE deferral helper for all four fast-path row loads; only `Document.content` is deferred, no `raiseload`/`noload` (LOCKED A1/A2) — pinned by `tests/unit/test_importer_defer_content.py`.
- `tests/integration/test_import_fast_path_columns.py` (5), `tests/e2e/test_fast_path_deferred_content.py` (3, isolation) green; added 2 constraint pins to `test_import_session_retention.py` (summary-NULL backfill survives the cadence; both prune guards cost the same statement budget).
- **Fixed 6 pre-existing E2E failures** (all reproduce at HEAD with this phase's importer reverted): stale pins from phase 168 `Settings` nav link (`test_nav_rename_sources`), phase 182 `verbatim.js` script count (`test_steering`), phase 175 `/api/chats?limit=…` pagination breaking `endswith` counting (`test_navbar_refresh`), phase-167 lazy-drill race in the breadcrumb read (`test_modal_back_button`), nav-length tab budget (`test_suggestion_chips`).
- **Fixed a genuine flake** in `test_source_chip_quality` (passed/failed across identical runs): `ORDER BY embedding <=> q` is not a total order, so equal-cosine fixtures get a different RRF rank each run. Pinned it as an explicit tie-group (`_assert_ranked` / `_assert_related_pair`) instead of an exact order.

**Tests / lint (all 100% green)**
- `uv run pytest --cov=app --cov-report=term-missing` → **4519 passed**, `app/rag/importer.py` **99%**, `TOTAL` **99%** (>90%).
- `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- Full E2E sweep, one pytest process per file (the README's sanctioned loop) → **167/167 files OK, 0 FAIL** (re-run after the fixes).

**Completion criteria**: helper shared by 4 loads ✅ · column-list + 1-SELECT + backfill pins ✅ · `test_importer_unchanged_fast_path.py` 24 passed, zero expectation edits ✅ · full suite + coverage + lint/types ✅ · phase E2E in isolation ✅ · docs accurate ✅ · commit left to the harness (no git commands run).

**Documentation**: README + AGENTS.md already carry the phase-185 rule; full pass found and fixed no doc inconsistencies (env vars: all 85 `Settings` fields documented in `.env.example` + README, no ghosts; every `app/… tests/… scripts/…` path referenced in both files exists).

**Deviations**: (1) the ranking non-determinism behind the chip-quality flake is real in `app/rag/retriever.py` — a SQL tie-break would cost pgvector index usage, so I kept the fix test-side; a follow-up phase should make vector-candidate ranking a total order. (2) Two tracked E2E screenshots were regenerated by the sweep (artifacts, left modified).

**Next pending phase**: `186_git_log_streaming_dates`.
2026-10-16 04:54:01 -04:00

468 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
import time
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).
The chain is POLLED, not read once: phase 167 made a drill LAZY (the
level and its breadcrumb render when ``/api/docs/tree/node`` resolves),
so the single read that sufficed while the whole tree shipped at boot
now races the fetch and intermittently returns the level the user came
FROM.
"""
expected = [*links, current]
expect(page.locator("#kb-crumb")).to_be_visible()
def _crumb_texts() -> list[str]:
return page.eval_on_selector_all(
"#kb-crumb :is(.kb-crumb-link, .kb-crumb-current)",
"(els) => els.map((el) => el.textContent)",
)
deadline = time.monotonic() + 30.0
texts = _crumb_texts()
while texts != expected and time.monotonic() < deadline:
page.wait_for_timeout(50)
texts = _crumb_texts()
assert texts == expected, 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)