**Phase 166 — final verification pass: green.** All 3 task files already in `complete/`, so this was verification + defect/doc fixing.
- **Verified:** `.msg .bubble:empty { display: none; }` exists once in the Messages block with provenance; `frontend/` diff is styles.css only (**no app.js change**); `tests/unit/test_no_empty_answer_bubble.py` 26 pins pass; phase-158/161 anchor pins (thinking_timeline + tool_states + edit_message) **75 passed**.
- **Phase E2E in isolation:** `uv run pytest tests/e2e/test_no_empty_box.py --no-cov` → **5 passed**. Mutation check (rule removed) → 2 failures, so the suite genuinely pins the defect.
- **Regression set, isolated:** thinking_timeline 6 · turn_progress_loader 3 · chat_history 5 · share_chat 4 · save_share_ux 5 · failed_turn_retry 4 · chat_answer_clusters 4 · chat_error_honesty 4 → all pass.
- **Full gates:** `uv run pytest --cov=app --cov-report=term-missing` → **3880 passed, app/ 99%** (gate >90%); `uv run ruff check .` → clean; `uv run pyright` → **0 errors**.
- **Whole E2E tree swept file-by-file (150 files, own pytest process each).** 4 suites were broken by *earlier* phases; fixed and re-run green:
- `test_kb_tree`, `test_sync_summary_visibility`, `test_folder_summary_voice` — `td:nth-child(4) span` became a strict-mode violation when phase 156 wrapped the Edit label in `.cell-clip` → scoped to `span.kb-desc-text`.
- `test_search_tool_scope` — pinned the pre-`690fd98` per-run `<summary>`/`open` shape → re-pinned to the ONE `details.reasoning` container + `.tool-calls-summary`.
- `test_header_consistency` — `.doc-meta` pixel bound (`<34px`) was broken by phase 153's 44px touch-target floor at ≤640px → replaced with a shape-first single-line check that still fails a real wrap.
- **Completion criteria:** mid-turn box hidden ✅ · bubble appears at first delta, phase-130 rows below unchanged ✅ · restored/shared empty answer paints no box ✅ · no app.js change + anchors + full gates ✅ · commit/`complete/` move = harness's job (I ran no git index/history commands).
- **Docs:** README — added the phase-166 "no empty answer box mid-turn" note; fixed a **stale** Thinking lead ("collapsible Thinking blocks", pre-dated the 2026-10-06 one-disclosure refinement); QA section now states E2E isolation is mandatory (shared Postgres + fixed ports 8123/8901) with the per-file sweep loop. AGENTS.md — rule 5 UI checklist now names the phase-166 suppression rule.
- **Notable:** running all of `tests/e2e/` in one pytest session is not supported (fixture/port interleaving causes ~126 spurious failures) — documented in README. Restored the tracked `.agents/screenshots/doc_modal_scrollbar_1280.png` that `test_document_viewer.py` rewrites on every run.
- **Next pending phase:** `167_api_payload_diet`.
259 lines
11 KiB
Python
259 lines
11 KiB
Python
"""Phase 12 E2E (Playwright): one header, same size on every page.
|
|
|
|
Story: ``.agents/user_stories/header-consistency.md``
|
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
|
|
|
uv run pytest tests/e2e/test_header_consistency.py -v --no-cov
|
|
|
|
The sticky top bar must be exactly ``--header-h`` tall on Chat, Sources,
|
|
and the document viewer (whose back button says "Sources" — the page
|
|
users compare against chat): 64px desktop, 58px at ≤640px.
|
|
|
|
Test → story mapping (Playwright Mapping Rule):
|
|
1. ``test_header_height_identical_across_pages_desktop``
|
|
2. ``test_header_height_identical_across_pages_mobile``
|
|
3. ``test_viewer_header_content_still_fits`` (phase-10 regression guard)
|
|
|
|
Phase 16 adaptation: the auth control (Sign in / Sign out) joins the chat
|
|
header's ``.header-inner`` — the desktop test verifies its presence in
|
|
both auth states without the bar's height moving (height assertions
|
|
unchanged).
|
|
|
|
Phase 34 adaptation (two-row viewer header, owner confirmation
|
|
2026-08-26): the viewer's ``<header>`` is now TWO rows — row 1 is the
|
|
standard shared bar (``.doc-header .app-header``, the phase-12/19
|
|
``--header-h`` contract) and row 2 is the ``.doc-titlebar`` (back +
|
|
title + meta, content-sized). The height assertions are pointed at ROW
|
|
1 — the standard bar — which must equal the chat/sources bars exactly;
|
|
the titlebar row is asserted present (height > 0), not height-pinned.
|
|
"""
|
|
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"
|
|
VIEWER_URL = "/document.html?source=docs&path=homelab%2Fkubernetes.md"
|
|
SOURCES_URL = "/sources.html"
|
|
|
|
#: The shared header-bar token values (frontend/assets/styles.css :root
|
|
#: and the ≤640px media query).
|
|
DESKTOP_HEADER_H = 64
|
|
MOBILE_HEADER_H = 58
|
|
|
|
|
|
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 owns the test loop)."""
|
|
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_db(mock_port: int) -> None:
|
|
with SessionLocal() as db:
|
|
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
|
db.commit()
|
|
_run_in_thread(_import_fixtures(mock_port))
|
|
|
|
|
|
def _box_height(page: Page, selector: str) -> float:
|
|
box = page.locator(selector).bounding_box()
|
|
assert box is not None, f"{selector} not rendered"
|
|
return box["height"]
|
|
|
|
|
|
def _meta_line_count(page: Page, selector: str) -> int:
|
|
"""How many VISUAL LINES the row's children occupy: cluster the
|
|
children by their vertical center (a 4px tolerance absorbs the
|
|
mixed badge/text/native-control heights on one line) and return the
|
|
number of clusters. ONE cluster = one line; a wrap produces two (the
|
|
second row of children sits a full line lower). Shape-first on
|
|
purpose: the row's own height is content-sized by its tallest child
|
|
— since phase 153 that is the ≥44px touch-target floor on
|
|
`.doc-copy-path` at ≤640px, so a pixel bound on the row would pin
|
|
the control's minimum, not the layout."""
|
|
centers = page.evaluate(
|
|
"""(sel) => [...document.querySelectorAll(sel + ' > *')]
|
|
.map(el => { const b = el.getBoundingClientRect();
|
|
return b.height > 0 ? b.top + b.height / 2 : null; })
|
|
.filter(v => v !== null)""",
|
|
selector,
|
|
)
|
|
assert centers, f"{selector} rendered no visible children"
|
|
clusters: list[list[float]] = []
|
|
for c in sorted(centers):
|
|
if clusters and c - clusters[-1][-1] <= 4:
|
|
clusters[-1].append(c)
|
|
else:
|
|
clusters.append([c])
|
|
return len(clusters)
|
|
|
|
|
|
def _header_heights(page: Page, app_url: str) -> dict[str, float]:
|
|
"""Measured heights of the sticky top bar on the three pages."""
|
|
heights: dict[str, float] = {}
|
|
|
|
page.goto(app_url + "/")
|
|
heights["chat"] = _box_height(page, ".app-header")
|
|
|
|
page.goto(app_url + SOURCES_URL)
|
|
heights["sources"] = _box_height(page, ".app-header")
|
|
|
|
page.goto(app_url + VIEWER_URL)
|
|
expect(page.locator("#doc-title")).to_have_text("Kubernetes Homelab Cluster", timeout=15_000)
|
|
# Phase 34: the viewer header is two rows — measure ROW 1 (the
|
|
# standard bar), which must equal the other pages' bars exactly.
|
|
heights["document"] = _box_height(page, ".doc-header .app-header")
|
|
return heights
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. Desktop: all three pages, one identical 64px bar
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_header_height_identical_across_pages_desktop(
|
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
page.set_viewport_size({"width": 1280, "height": 800})
|
|
_seed_db(mock_llm)
|
|
|
|
# Anonymous: chat + sources bars. Phase 79: the viewer's DATA is
|
|
# require_user-gated (its title row needs the content), so the
|
|
# document bar is measured signed-in, below — the bar height itself
|
|
# is auth-independent, which is exactly what both measurements pin.
|
|
page.goto(app_url + "/")
|
|
heights = {"chat": _box_height(page, ".app-header")}
|
|
page.goto(app_url + SOURCES_URL)
|
|
heights["sources"] = _box_height(page, ".app-header")
|
|
assert heights["chat"] == DESKTOP_HEADER_H, f"chat header {heights['chat']}px"
|
|
assert heights["sources"] == DESKTOP_HEADER_H, f"sources header {heights['sources']}px"
|
|
|
|
# Phase 16: the auth control lives in the same bar — anonymous sees
|
|
# "Sign in", signed-in sees "Sign out", and neither state moves the
|
|
# height.
|
|
page.goto(app_url + "/")
|
|
expect(page.locator("#sign-in-link")).to_be_visible()
|
|
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
|
assert _box_height(page, ".app-header") == DESKTOP_HEADER_H
|
|
login(page, app_url, next="/")
|
|
expect(page.locator("#sign-out-btn")).to_be_visible()
|
|
expect(page.locator("#sign-in-link")).to_be_hidden()
|
|
assert _box_height(page, ".app-header") == DESKTOP_HEADER_H
|
|
|
|
# The document bar (row 1 — the standard bar of the two-row viewer
|
|
# header), signed in: identical to the other pages' bars.
|
|
page.goto(app_url + VIEWER_URL)
|
|
expect(page.locator("#doc-title")).to_have_text(
|
|
"Kubernetes Homelab Cluster", timeout=15_000
|
|
)
|
|
heights["document"] = _box_height(page, ".doc-header .app-header")
|
|
assert heights["document"] == DESKTOP_HEADER_H, (
|
|
f"document header {heights['document']}px (was content-sized)"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. Mobile (≤640px): all three pages, one identical 58px bar
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_header_height_identical_across_pages_mobile(
|
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
page.set_viewport_size({"width": 375, "height": 812})
|
|
_seed_db(mock_llm)
|
|
login(page, app_url, next="/") # phase 79: the viewer's title needs the content
|
|
|
|
heights = _header_heights(page, app_url)
|
|
assert heights["chat"] == MOBILE_HEADER_H, f"chat header {heights['chat']}px"
|
|
assert heights["sources"] == MOBILE_HEADER_H, f"sources header {heights['sources']}px"
|
|
assert heights["document"] == MOBILE_HEADER_H, (
|
|
f"document header {heights['document']}px (meta row must clip, not wrap)"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Regression: the phase-10 viewer header still shows title, badges,
|
|
# back link — single-line on both desktop and mobile
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_viewer_header_content_still_fits(
|
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
"""Phase 34: the phase-10 viewer content (title, badges, back link)
|
|
survives in the titlebar ROW, and row 1 stays the pinned standard
|
|
bar — single-line on both desktop and mobile."""
|
|
_seed_db(mock_llm)
|
|
login(page, app_url, next="/") # phase 79: the viewer content is gated
|
|
|
|
for width, expected_h in ((1280, DESKTOP_HEADER_H), (375, MOBILE_HEADER_H)):
|
|
page.set_viewport_size({"width": width, "height": 800})
|
|
page.goto(app_url + VIEWER_URL)
|
|
expect(
|
|
page.locator("#doc-title")
|
|
).to_have_text("Kubernetes Homelab Cluster", timeout=15_000)
|
|
|
|
# Title on a single line (ellipsis, no wrap growth).
|
|
assert _box_height(page, "#doc-title") < 30 # one line at either size
|
|
|
|
# Meta row: badges + path present and visible, single line.
|
|
# SHAPE, not pixels: the pre-phase absolute bound (<34px) went
|
|
# stale the moment the row gained a control carrying the ≥44px
|
|
# touch-target floor (phase 153: .doc-copy-path min-height 44px
|
|
# at ≤640px — PLAN §7.1), which makes a single, correctly-wrapped
|
|
# mobile line measure 44px. What the contract actually forbids is
|
|
# the row STACKING (a wrap grows the header), so the pin reads the
|
|
# geometry directly: every child centered on the row's own center
|
|
# = ONE line, whatever the tallest control's height (the same
|
|
# shape-first reading the 2026-09-16 note above already moved to).
|
|
assert _meta_line_count(page, ".doc-meta") == 1, (
|
|
"the meta row must stay on ONE line (badges, path, dates and "
|
|
"controls centered on the row's own center)"
|
|
)
|
|
expect(page.locator(".doc-source-badge", has_text="docs")).to_be_visible()
|
|
expect(page.locator(".format-badge", has_text="md")).to_be_visible()
|
|
expect(page.locator(".doc-path", has_text="homelab/kubernetes.md")).to_be_visible()
|
|
|
|
# Back link still there, ≥44px touch target, in the titlebar row.
|
|
back = page.locator("#doc-back")
|
|
expect(back).to_be_visible()
|
|
assert _box_height(page, "#doc-back") >= 44
|
|
|
|
# Phase 34 two-row contract: ROW 1 is the standard bar (the
|
|
# pinned --header-h), and the titlebar row is present below it.
|
|
expect(page.locator(".doc-header .app-header")).to_have_css(
|
|
"height", f"{expected_h}px"
|
|
)
|
|
assert _box_height(page, ".doc-titlebar") > 0, ("the titlebar row must render")
|