Files
brain-of-reese/tests/e2e/test_history_page_width.py
ducoterra ad7585d474
Build and Push Containers / build-and-push-app (push) Successful in 2m11s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 97_kb_tree_catalog
All completion criteria verified — everything is green, no defects found. Final report:

## Phase 97 final verification pass — ALL GREEN

**Verified (no code changes needed):**
- `GET /api/docs/tree` (admin), `build_kb_tree` pure builder, `PATCH /api/folders/summary`, migration 0018 (`manually_edited`, head confirmed), generator skip/keep + `kept_manual` stat, RAG tree UI + edit affordance in `sources.js`/`index.html`/`styles.css`
- `tests/e2e/test_kb_tree.py`: 8 passed — top level, drill source/folder, edit round-trip, clear, manual-desc-survives-sync, reload fallback, anonymous gate
- Integration: tree shape/order/403/empty/indexed-only + PATCH update/create/root/clear/404/403/no-LLM + stat-walk equivalence (in `test_docs_api.py`); 3-field `folder_summaries=` import token preserved

**Gates (exact commands):**
- `uv run pytest --cov=app --cov-report=term-missing` → **2053 passed**, TOTAL coverage **99%** (>90% ✓)
- `uv run ruff check . && uv run pyright` → **All checks passed / 0 errors**
- `uv run pytest tests/e2e/test_kb_tree.py -v --no-cov` → **8 passed** in isolation
- 30 story/RAG-view E2E suites run **one per process**: all passed, incl. `test_ls_tree_drilldown` (agent `ls` byte-identical ✓), `test_import_documents`, `test_edit_summaries`, `test_admin_auth`, `test_kb_overview`

**Completion criteria:** tree view ✓ · edit round-trip + clear ✓ · manual persists/clear resets ✓ · `ls` unchanged ✓ · pytest/coverage/lint ✓ · E2E isolation ✓ · commit — left to harness per protocol (working tree untouched, `git add/commit` not run)

**Deviations:** none. **Next pending phase:** none — `todo/` contains only 97 (96 already committed).
2026-09-11 22:48:02 -04:00

496 lines
23 KiB
Python
Raw Permalink 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 86 E2E (Playwright): the table pages are viewport-width — the
owner's "the page is the width of the table" bug, pinned.
Source: ``TODO.md`` L4 (owner bug report, 2026-09-07): "The history page
appears to be the width of the table despite the table being scrollable.
On mobile this results in half the page being blank and awkwardly
scrollable."
Story: n/a (owner bug report — ``TODO.md`` L4 is the story; no
``.agents/user_stories/`` file).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_history_page_width.py -v --no-cov
The bug (confirmed by live reproduction, 375×812, signed-in admin):
the History and Tokens tables carry ``position: absolute``
``.visually-hidden`` elements (the ``<caption>`` + the Actions column
header span). No ancestor in the chain was positioned, so their 1px
boxes were laid out against the INITIAL containing block — the Actions
span at the 640px table's right edge — and leaked into the DOCUMENT's
scrollable overflow, bypassing the card's own ``overflow-x: auto``
scrolling. Measured ``documentElement.scrollWidth`` at 375px:
``/history.html`` **626**, ``/tokens.html`` **618** (the byte-identical
defect, folded in per owner decision A3), ``/sources.html`` **375**
(clean — the RAG table's headers are visible text). The fix is ONE CSS
property (phase 86, task 01): ``position: relative`` on the shared
``.table-wrap`` card, which becomes the containing block for those
spans. This suite pins the FIXED contract: the document never pans
past the viewport, the 640px tables still scroll INSIDE their cards
(the phase-07 / AGENTS.md-rule-5 full-width contract), the a11y spans
stay in the DOM, and the RAG view + the desktop layout are unchanged.
Counterfactual (verified red→green in the phase session): with the
task-01 rule reverted (no ``position: relative`` on ``.table-wrap``),
``test_history_page_is_viewport_width`` fails at its
``scrollWidth <= innerWidth`` pin (626 > 375); restored, it is green.
Test → contract mapping (Playwright Mapping Rule):
1. ``test_history_page_is_viewport_width`` — THE TODO.md L4 pin: admin
at 375×812, direct ``/history.html`` —
``documentElement.scrollWidth <= innerWidth`` (626 → ≤375), the
table still scrolls INSIDE ``#history-table-wrap``
(``scrollWidth > clientWidth`` — the 640px ``min-width``), the card
is the container's content width (≤ the viewport), and the
visually-hidden Actions header + caption survived the fix (the a11y
names are intact).
2. ``test_tokens_page_is_viewport_width`` — the folded-in identical
defect (618 pre-fix): the same two pins on direct ``/tokens.html``
for ``#tokens-table-wrap``.
3. ``test_spa_switch_keeps_page_width`` — the invariant survives the
router, not just direct loads: at 375px on ``/history.html`` the
mobile menu (a REAL ``#nav-toggle`` click — the phase-85 contract)
opens, the Tokens link switches the view IN THE SAME DOCUMENT
(the phase-76 window-sentinel proof — a real navigation would wipe
``window`` globals), and the Tokens page is still viewport-width
with its table still scrolling in-card.
4. ``test_rag_view_regression`` — ``/sources.html`` at 375px: no
overflow (it was already clean at 375 pre-fix — pinned so the
shared-rule change cannot regress it) AND the RAG table is still
full-width inside its card (in-card scroll — the 640px
``min-width`` still engages).
5. ``test_desktop_unchanged`` — 1280×800 regression on ALL THREE
views: no document overflow at any width, and the tables render at
container width (the cards need no internal scroll —
``scrollWidth <= clientWidth + 1``, the card is far wider than the
mobile 346px).
House conventions: a fresh 375×812 page per mobile test via the
session ``browser`` fixture (the conftest ``page`` is 1280×800),
``e2e.auth_helpers.login`` for the real form login, ``_wait_settled_admin``
copied from ``test_mobile_hamburger_nav.py`` (suites stay
self-contained — helpers copied, only ``auth_helpers`` imported).
Data: the History/Tokens contract holds with EMPTY tables (static
thead + the 640px ``min-width`` — no rows needed, and the suite never
depends on saved chats or issued tokens — test 5 ENFORCES that state,
resetting ``saved_chats`` (TRUNCATE, the house precedent) and the
``e2e-``-labeled tokens (label-scoped delete) before pinning, because
other suites leave chats on the shared dev DB and a single History
row pushes the table's auto-layout minimum width past the 1110px
desktop card). The RAG view, by contrast,
HIDES its table and shows the "Nothing indexed yet" empty state on an
empty KB, so tests 4 and 5 seed the KB the house way (truncate +
import the 13 fixture docs, deterministic mock embeddings) and pin
nothing about the rows themselves — only that the table is up and
full-width.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from threading import Thread
from typing import Any
from playwright.sync_api import Browser, JSHandle, Page, ViewportSize, 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"
MOBILE: ViewportSize = {"width": 375, "height": 812} # the bug-report phone viewport
DESKTOP: ViewportSize = {"width": 1280, "height": 800} # the conftest page size
async def _import_fixtures(mock_port: int) -> ImportSummary:
"""The house KB seed (copied from ``test_api_tokens.py``): import
the fixture docs against the mock LLM's embeddings."""
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.
"""
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 _seed_kb(mock_port: int) -> ImportSummary:
"""House reset (truncate the KB + the prompt-shaping tables so the
mock answers stay deterministic) + the fixture re-import — the
RAG view hides its table on an empty KB, so the two tests that
pin it need at least the fixture docs present. ``saved_chats`` and
``api_tokens`` are deliberately NOT touched (the house pattern)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
db.commit()
return _run_in_thread(_import_fixtures(mock_port))
def _mobile_page(browser: Browser) -> Page:
"""A fresh 375×812 page (the conftest ``page`` is 1280×800)."""
return browser.new_page(viewport=MOBILE)
def _wait_settled_admin(page: Page) -> None:
"""Wait until whoami has resolved for the admin: the whoami reveal
has un-hidden the admin-only nav links — the viewport-independent
settled signal (copied from ``test_mobile_hamburger_nav.py``; the
sign-out control is viewport-dependent and not a cross-viewport
probe)."""
page.wait_for_function(
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
timeout=10_000,
)
def _open_menu(page: Page) -> None:
"""Open the mobile menu with a REAL click on the toggle (the
phase-85 contract: the gate sits below the header, so a real tap
reaches the toggle; for a signed-in admin the gate is hidden
anyway)."""
page.click("#nav-toggle")
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
def _drill_to_files(page: Page) -> None:
"""Phase 97: the catalog is the drill-down tree — the top level
lists the sources (the file table is per-level, hidden at the
top). The 640px ``min-width`` pin targets the FILE table, so both
RAG-view tests drill to a level that has files (the fixture
source + its ``homelab`` folder) before pinning."""
for name in ("docs", "homelab"):
page.click(f'#folders-tbody a.folder-link:text-is("{name}")')
def _wrap_handle(page: Page, view: str) -> JSHandle:
"""The table card (``.table-wrap``) of ``view``: the id'd cards for
history/tokens, the RAG card found through its FILE table (phase 97:
the RAG view carries TWO cards — ``#folders-wrap`` at the top level
and the per-level file table's card; the 640px pin is the file
table's, and the tests drill there first)."""
if view == "sources":
return page.evaluate_handle(
"() => document.querySelector('#docs-table').closest('.table-wrap')"
)
return page.evaluate_handle(f"() => document.querySelector('#{view}-table-wrap')")
def _reset_history_and_e2e_tokens() -> None:
"""The desktop pin's data state (see ``test_desktop_unchanged``):
an empty History table + no e2e-issued tokens — the row-width
interaction (auto table layout vs the 72rem card) is not the
contract under test, and other suites leave saved chats on the
shared dev DB. ``saved_chats``: TRUNCATE (the
``test_bottom_chat_actions.py`` precedent); ``api_tokens``:
label-scoped ``e2e-`` delete only (the ``test_api_tokens.py``
pattern — the dev DB may hold the owner's real tokens)."""
with SessionLocal() as db:
db.execute(text("TRUNCATE saved_chats"))
db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'"))
db.commit()
def _assert_viewport_width(page: Page, label: str) -> None:
"""THE pin (TODO.md L4): the document itself never pans past the
viewport — pre-fix, /history.html measured 626 and /tokens.html
618 at this 375px viewport."""
report = page.evaluate(
"() => ({ doc: document.documentElement.scrollWidth, inner: window.innerWidth })"
)
assert report["doc"] <= report["inner"], (
f"{label}: the document panned to {report['doc']}px at a "
f"{report['inner']}px viewport — the TODO.md L4 defect "
'(the page is "the width of the table"); the .table-wrap card '
"must be the containing block for the positioned "
".visually-hidden spans (position: relative, phase 86)"
)
def _assert_in_card_scroll(page: Page, wrap: JSHandle, label: str) -> None:
"""The phase-07 / AGENTS.md-rule-5 contract the fix preserves: the
640px table scrolls INSIDE the card (``overflow-x: auto``), the
card is the container's content width (≤ the viewport), and the
table's ``min-width: 640px`` is what the in-card scroll is over."""
report = wrap.evaluate(
"""el => {
const c = el.closest('.container');
const cs = getComputedStyle(c);
const ws = getComputedStyle(el);
return {
scroll: el.scrollWidth,
client: el.clientWidth,
// The container's content width minus the card's own
// borders (clientWidth excludes them) — the card fills
// the row edge to edge.
content: c.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)
- parseFloat(ws.borderLeftWidth) - parseFloat(ws.borderRightWidth),
inner: window.innerWidth,
table: el.querySelector('table').scrollWidth,
};
}"""
)
assert report["table"] >= 640, (
f"{label}: the table must keep its 640px min-width, got {report['table']}px"
)
assert report["scroll"] > report["client"], (
f"{label}: the 640px table must still scroll INSIDE the card "
f"(card {report['client']}px < table {report['table']}px)"
)
assert report["client"] <= report["inner"], (
f"{label}: the card must not exceed the viewport "
f"({report['client']}px > {report['inner']}px)"
)
assert abs(report["client"] - report["content"]) <= 1, (
f"{label}: the card must fill the container's content width "
f"(card {report['client']}px vs container {report['content']}px)"
)
def _assert_hidden_a11y_spans(page: Page, wrap_sel: str) -> None:
"""The a11y names survived the fix: the visually-hidden Actions
column header and the table caption are still in the DOM (the fix
repositioned their CONTAINING BLOCK — it did not remove them)."""
expect(page.locator(f"{wrap_sel} th .visually-hidden")).to_have_count(1)
expect(page.locator(f"{wrap_sel} th .visually-hidden")).to_have_text("Actions")
expect(page.locator(f"{wrap_sel} caption.visually-hidden")).to_have_count(1)
# ---------------------------------------------------------------------------
# 1. THE TODO.md L4 pin: /history.html is viewport-width at 375px
# ---------------------------------------------------------------------------
def test_history_page_is_viewport_width(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""THE TODO.md L4 pin: admin at 375×812, direct ``/history.html`` —
the document is viewport-width (pre-fix ``scrollWidth`` 626 — the
page panned ~250px into a blank region, "half the page being
blank"), the 640px table still scrolls INSIDE
``#history-table-wrap`` (the phase-07 / AGENTS.md-rule-5
full-width contract), and the visually-hidden Actions header +
caption are still in the DOM (the a11y names survived the fix)."""
page = _mobile_page(browser)
try:
login(page, app_url, next="/history.html")
_wait_settled_admin(page)
expect(page.locator("#history-table-wrap")).to_be_visible(timeout=15_000)
_assert_viewport_width(page, "/history.html (direct)")
_assert_in_card_scroll(page, _wrap_handle(page, "history"), "/history.html")
_assert_hidden_a11y_spans(page, "#history-table-wrap")
finally:
page.close()
# ---------------------------------------------------------------------------
# 2. The folded-in identical defect: /tokens.html (618 pre-fix)
# ---------------------------------------------------------------------------
def test_tokens_page_is_viewport_width(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""The owner-confirmed A3 fold-in: the Tokens view carries the
byte-identical defect (measured ``scrollWidth`` 618 pre-fix — the
same visually-hidden Actions header + caption, no positioned
ancestor) and is fixed by the SAME one-rule ``.table-wrap`` fix.
Same two pins as test 1, on direct ``/tokens.html`` for
``#tokens-table-wrap``."""
page = _mobile_page(browser)
try:
login(page, app_url, next="/tokens.html")
_wait_settled_admin(page)
expect(page.locator("#tokens-table-wrap")).to_be_visible(timeout=15_000)
_assert_viewport_width(page, "/tokens.html (direct)")
_assert_in_card_scroll(page, _wrap_handle(page, "tokens"), "/tokens.html")
_assert_hidden_a11y_spans(page, "#tokens-table-wrap")
finally:
page.close()
# ---------------------------------------------------------------------------
# 3. The invariant survives the router (not just direct loads)
# ---------------------------------------------------------------------------
def test_spa_switch_keeps_page_width(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""At 375px on ``/history.html``: open the mobile menu (a REAL
``#nav-toggle`` click — the phase-85 contract; at this width the
nav links live in the dropdown), click the Tokens link — a
CLIENT-SIDE view switch in the phase-76 shell, so the same-document
proof is the phase-76 canonical one: the window sentinel set
before the click is still readable after it (a real document load
would wipe ``window`` globals; the navigation-timeline count is
deliberately NOT used — a real load resets it, so it cannot
distinguish pushState from a reload). On arrival the Tokens page
keeps BOTH pins: viewport-width AND the table still scrolling
inside its card."""
page = _mobile_page(browser)
try:
login(page, app_url, next="/history.html")
_wait_settled_admin(page)
expect(page.locator("#history-table-wrap")).to_be_visible(timeout=15_000)
_assert_viewport_width(page, "/history.html (pre-switch)")
# THE SWITCH (mobile menu → Tokens link), same document:
page.evaluate("() => { window.__phase86_width = 'phase86'; }")
_open_menu(page)
page.click("#nav-tokens")
expect(page).to_have_url(app_url + "/tokens.html", timeout=15_000)
assert page.evaluate("() => window.__phase86_width") == "phase86", (
"a real document load would have wiped the window sentinel — "
"the view switch must be same-document (the phase-76 router)"
)
expect(page.locator("#tokens-table-wrap")).to_be_visible(timeout=15_000)
_assert_viewport_width(page, "/tokens.html (SPA switch)")
_assert_in_card_scroll(page, _wrap_handle(page, "tokens"), "/tokens.html (SPA)")
finally:
page.close()
# ---------------------------------------------------------------------------
# 4. Regression: the RAG view (already clean) stays clean + full-width
# ---------------------------------------------------------------------------
def test_rag_view_regression(
browser: Browser, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""``/sources.html`` at 375px (admin): the RAG view measured clean
pre-fix (``scrollWidth`` 375 — the ``.docs-table`` headers are
visible text, no positioned hidden spans at the table's right
edge). Pinned so the shared ``.table-wrap`` rule change cannot
regress it — AND its table is still full-width inside the card:
the 640px ``min-width`` still engages, so the card keeps its
in-card scroll (the shared rule added a containing block, not a
width).
Phase 97: the catalog is the drill-down tree — the top level lists
the sources, so the FILE table (the 640px pin's subject) is reached
by drilling ``docs`` → ``homelab`` first; the drill is the only
change to this pin."""
summary = _seed_kb(mock_llm)
assert summary is not None and summary.added == 13 # A9 formats (phase 47 added quadlet+j2)
page = _mobile_page(browser)
try:
login(page, app_url, next="/sources.html")
_wait_settled_admin(page)
# Phase 97: the top level lists the sources (the source row is
# the catalog-rendered signal); the 640px in-card-scroll pin
# targets the FILE table, so drill to a level that has files
# first (the drill is the only change).
page.locator("#folders-tbody tr").first.wait_for(state="visible", timeout=15_000)
_drill_to_files(page)
expect(page.locator("#docs-tbody tr").first).to_be_visible(timeout=15_000)
_assert_viewport_width(page, "/sources.html (direct)")
_assert_in_card_scroll(page, _wrap_handle(page, "sources"), "/sources.html")
finally:
page.close()
# ---------------------------------------------------------------------------
# 5. Desktop regression: no overflow anywhere, tables at container width
# ---------------------------------------------------------------------------
def test_desktop_unchanged(
browser: Browser, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""1280×800 regression on ALL THREE table views: no document
overflow at any width, and each table renders at container width —
the cards need no internal scroll
(``wrap.scrollWidth <= wrap.clientWidth + 1``) and are far wider
than the mobile 346px card. The zero-offset
``position: relative`` changed no layout, so desktop is
byte-identical in behavior. The KB is seeded the house way for
the RAG view's table (it hides itself on an empty KB). Phase 97:
the RAG view's table is the FILE table — the top level lists the
sources, so the sources pass drills ``docs`` → ``homelab`` first
(the drill is the only change).
Data state: the desktop pin is enforced on the EMPTY-table state
the module docstring declares ("the History/Tokens contract holds
with EMPTY tables") — the History table's auto layout cannot fit
ROWS in the 1110px 72rem card (the nowrap date/share/actions
cells + the ellipsized title column at its 34rem ``max-width``
sum to ~1130px of minimum column width), and other suites on the
shared dev DB (e.g. ``test_responsive_polish``) leave saved chats
behind. So this test resets the state it pins: ``saved_chats``
TRUNCATEd (the ``test_bottom_chat_actions.py`` house precedent —
the table is rendered empty here, rows are not under test) and
the ``e2e-``-labeled tokens deleted (the ``test_api_tokens.py``
label-scoped pattern — never a TRUNCATE; the shared dev DB may
hold the owner's real tokens)."""
summary = _seed_kb(mock_llm)
assert summary is not None and summary.added == 13
_reset_history_and_e2e_tokens()
page = browser.new_page(viewport=DESKTOP)
try:
login(page, app_url, next="/history.html")
_wait_settled_admin(page)
views = (
("history", "/history.html"),
("tokens", "/tokens.html"),
("sources", "/sources.html"),
)
for i, (view, path) in enumerate(views):
if i: # the first view is the login landing
page.goto(app_url + path)
_wait_settled_admin(page)
if view == "sources":
# Phase 97: the top level lists the sources — the
# container-width pin is the FILE table's (the 640px
# min-width's desktop counterpart), so drill to a level
# that has files first.
page.locator("#folders-tbody tr").first.wait_for(state="visible", timeout=15_000)
_drill_to_files(page)
marker = ("#docs-tbody tr" if view == "sources" else f"#{view}-table-wrap")
expect(page.locator(marker).first).to_be_visible(timeout=15_000)
_assert_viewport_width(page, f"{path} (desktop)")
report = _wrap_handle(page, view).evaluate(
"el => ({ scroll: el.scrollWidth, client: el.clientWidth })"
)
assert report["client"] > 346, (
f"{path} (desktop): the table card must render at container "
f"width, got {report['client']}px (the mobile card is 346px)"
)
assert report["scroll"] <= report["client"] + 1, (
f"{path} (desktop): no in-card scroll is needed at this width "
f"(table {report['scroll']}px in a {report['client']}px card)"
)
finally:
page.close()