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).
496 lines
23 KiB
Python
496 lines
23 KiB
Python
"""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()
|