Single consolidated commit for four completed, validated phases (77, 78, 79, 80). The pipeline run left all work uncommitted because the harness commits only with PHASE_COMMIT=1 while child executors are forbidden from committing; the phases themselves all passed validation and moved to .agents/phases/complete/. Phase 77 — navbar view refresh - router.js dispatches bor:view-refresh on re-show / active re-click / popstate (gated on wasMounted; first show and boot exempt) - History / RAG / Sources / Tuning re-fetch on refresh (admin branch); Chat deliberately excluded (stream survival) - History "Refresh" button (admin-only, in-flight disable + status line) - New story suite tests/e2e/test_navbar_refresh.py (7 tests) Phase 78 — static background - Removed the animated glow layers; static 44px grid over the flat --bg canvas; default and reduced-motion renders byte-identical - Updated background/theme E2E suites; removed bg-glow test pins Phase 79 — API tokens - api_tokens model + migration 0012; hash-only token service - Admin tokens API + Tokens admin view; POST /api/token-auth; live-revoking require_user on chat / suggestions / document content - Frontend token gate with localStorage cache; anonymous E2E suites migrated to token login - New story suite tests/e2e/test_api_tokens.py (9 tests) Phase 80 — history suggestion chips - last_questions() endpoint with SEED fallback; startNewChat() refetch - Seed-semantics docs (config.py, .env.example, README) - Integration state matrix + E2E suite rewritten to the 4 chip states Also included: phase-76 report artifacts and the repo restore-test-db skill (previously untracked), scripts/* ruff fixes from phase 77. Final gate state (phase 80 final pass, covers everything above): - uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99% - uv run ruff check . && uv run pyright → clean, 0 errors - Per-phase story E2E suites green in isolation
580 lines
26 KiB
Python
580 lines
26 KiB
Python
"""Phase 65 E2E (Playwright): the chat action cluster is the pinned bottom.
|
||
|
||
Source: ``TODO.md`` L3 — "Move the new chat and share button to the
|
||
tune/retry/save doc cluster area so it's always at the bottom of the
|
||
screen and easily accessble. Make the button clusters look better,
|
||
neater, more aligned" (no user story file — TODO-derived phase).
|
||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||
|
||
uv run pytest tests/e2e/test_bottom_chat_actions.py -v --no-cov
|
||
|
||
Mock-only, no admin login (chat is public). The owner-locked contracts
|
||
(2026-09-01, roadmap confirmation) under test — observed in a real
|
||
browser:
|
||
|
||
* **A1 — the pinned bottom cluster:** the ``.chat-bottom`` wrapper
|
||
(the ``.chat-actions`` row + the ``#composer``, ONE sticky unit —
|
||
the LAST child of ``.chat-shell``; ``position: sticky; bottom:
|
||
env(safe-area-inset-bottom, 0)``, NO z-index) is fully inside the
|
||
viewport with its bottom flush with the viewport bottom at EVERY
|
||
scroll position inside the sticky range — the New chat + Share
|
||
pills are literally always at the bottom of the screen, even while
|
||
the reader is scrolled up through a long conversation — and at the
|
||
document bottom the whole unit settles back into normal flow above
|
||
the ``.app-footer`` (never floating over it);
|
||
* **A2 — the right-aligned bottom row:** on desktop the row hugs the
|
||
column's RIGHT edge (mirroring the right-aligned "Save as doc"
|
||
corner of every brain bubble above — the bottom-right of the chat
|
||
reads as one aligned action column); at ≤640px the row is a
|
||
full-width vertical stack (alignment is moot when stretched);
|
||
* **the click-through survived the move:** ``#new-chat-btn``
|
||
(header.js → the ``bor:new-chat`` window event → the app.js reset)
|
||
clears the conversation (``.msg`` count 0, ``#empty-state`` back,
|
||
``bor.chat.v1`` gone from localStorage, "New chat started" in
|
||
``#send-status``); ``#share-chat-btn`` on the now-empty chat is the
|
||
guarded no-op (app.js ``shareCurrentChat``: "Nothing to share
|
||
yet.", no toast, no navigation).
|
||
|
||
Determinism: the mock's grounded answers quote the question and end in
|
||
the ``Deterministic mock answer for E2E`` marker; overflow is built
|
||
from real UI turns (6 short grounded turns — the house
|
||
``SHORT_QUESTIONS`` phrasing, the same overflow recipe as
|
||
``test_pinned_composer.py``); all scrolling is done by the tests via
|
||
``window.scrollTo`` (the phase-42 never-auto-scroll contract — the
|
||
app, and the phase-65 sticky wrapper, add no scroll call site).
|
||
|
||
Test → contract mapping (Playwright Mapping Rule):
|
||
1. ``test_empty_chat_row_rests_at_screen_bottom`` — the resting
|
||
A1/A2 geometry on a fresh page (static markup, no KB seed)
|
||
2. ``test_bottom_cluster_pinned_at_every_scroll_position`` — A1
|
||
across the whole sticky range + the settle into flow
|
||
3. ``test_row_geometry_and_alignment`` — A2 geometry + the A5 DOM
|
||
order + the mobile stack + the 360px overflow bound + the 44px
|
||
touch targets
|
||
4. ``test_buttons_still_work_from_the_bottom`` — the click-through
|
||
contracts (New chat reset, Share no-op)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from collections.abc import Iterator
|
||
from pathlib import Path
|
||
from threading import Thread
|
||
from typing import Any
|
||
|
||
import pytest
|
||
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"
|
||
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||
STORAGE_KEY = "bor.chat.v1"
|
||
|
||
#: Six short grounded questions (the house phrasing + on-topic variants
|
||
#: — the honesty gate is HIGH for all of them, so every turn renders an
|
||
#: answer bubble + source chips). Six turns overflow the 800px viewport
|
||
#: by a wide margin; the tests assert the overflow, so the count can
|
||
#: never silently stop being enough.
|
||
SHORT_QUESTIONS = (
|
||
"How is my Kubernetes cluster set up?",
|
||
"How do my backups work?",
|
||
"How did I install gitlab?",
|
||
"How does my homelab networking work?",
|
||
"What is in the new-service deployment?",
|
||
"What scripts do I have in the homelab?",
|
||
)
|
||
|
||
#: Tolerance for "flush with the viewport bottom": `env(safe-area-inset-
|
||
#: bottom)` resolves to 0 on a desktop/notched-free viewport, so the only
|
||
#: slack is sub-pixel rounding (the same headroom `test_pinned_composer.py`
|
||
#: allows the composer).
|
||
FLUSH_PX = 4
|
||
#: The resting row must sit in the LOWER part of the screen — the pre-phase
|
||
#: 65 geometry had it at the very top of the column (the exact defect this
|
||
#: phase removes).
|
||
LOWER_PART = 0.75
|
||
|
||
# The typing indicator is itself a .msg.brain — exclude its bubble.
|
||
ANSWER = ".msg.brain .bubble:not(.typing)"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# KB seeding (same pattern as the phase 42/52/55 story suites)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
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.
|
||
"""
|
||
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, steering notes, saved chats —
|
||
fully deterministic per test), then optionally re-import fixtures.
|
||
``saved_chats`` IS truncated here (unlike the phase-55 suite, which
|
||
keeps rows across its tests): the click-through tests below touch
|
||
the auto-save path, so each test starts from an empty table."""
|
||
with SessionLocal() as db:
|
||
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes, saved_chats"))
|
||
db.commit()
|
||
if not seed:
|
||
return None
|
||
return _run_in_thread(_import_fixtures(mock_port))
|
||
|
||
|
||
@pytest.fixture()
|
||
def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[None]:
|
||
"""A fresh KB seeded from ``tests/fixtures/docs`` (13 docs, A9
|
||
formats), truncated again on teardown. ``db_ready`` (conftest)
|
||
skips with clear instructions when Postgres is down."""
|
||
summary = _reset_db(mock_llm, seed=True)
|
||
assert summary is not None and summary.added == 13
|
||
yield
|
||
_reset_db(mock_llm, seed=False)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Measurement + flow helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def scroll_state(page: Page) -> dict[str, float]:
|
||
"""The document scroller's state (there is no inner scroll container)."""
|
||
return page.evaluate(
|
||
"() => ({ y: window.scrollY, "
|
||
"sh: document.documentElement.scrollHeight, "
|
||
"ch: window.innerHeight })"
|
||
)
|
||
|
||
|
||
def wait_settled(page: Page, timeout: int = 30_000) -> None:
|
||
"""The turn is over: the label is back to "Send" (phase 48 — the
|
||
in-flight state is the enabled Stop control, so the label carries
|
||
the wait)."""
|
||
expect(page.locator("#send-btn")).to_be_enabled(timeout=timeout)
|
||
expect(page.locator("#send-label")).to_have_text("Send", timeout=timeout)
|
||
|
||
|
||
def submit(page: Page, question: str) -> None:
|
||
"""Submit through the composer (the real-user flow)."""
|
||
page.fill("#message-input", question)
|
||
page.click("#send-btn")
|
||
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
|
||
|
||
|
||
def build_conversation(page: Page, n: int = 6) -> None:
|
||
"""Settle ``n`` short grounded turns through the UI (the overflow is
|
||
real conversation content, not injected DOM)."""
|
||
for question in SHORT_QUESTIONS[:n]:
|
||
submit(page, question)
|
||
wait_settled(page)
|
||
|
||
|
||
def box_of(page: Page, selector: str) -> dict[str, float]:
|
||
"""The selector's viewport-relative box as a plain dict.
|
||
|
||
Measured WITHOUT scrolling (``bounding_box`` never scrolls the page),
|
||
so the answer is "where the box sits where the user left it" —
|
||
exactly the question this phase asks (always at the bottom).
|
||
"""
|
||
rect = page.locator(selector).bounding_box()
|
||
assert rect is not None, f"{selector} must be rendered (no bounding box)"
|
||
return {
|
||
"x": rect["x"],
|
||
"y": rect["y"],
|
||
"width": rect["width"],
|
||
"height": rect["height"],
|
||
}
|
||
|
||
|
||
def column_right_edge(page: Page) -> float:
|
||
"""The right edge of the chat column's CONTENT (A2's reference edge).
|
||
|
||
``.chat-shell`` is a ``.container`` (``padding-inline`` 1.25rem
|
||
desktop / 0.9rem ≤640px), so its bounding box includes the padding —
|
||
but the column's visible content (every bubble, the meta rows, the
|
||
bottom row) ends at the padding edge. Measured live so the
|
||
breakpoint's padding value is never hard-coded."""
|
||
return page.evaluate(
|
||
"() => {"
|
||
" const s = document.querySelector('.chat-shell');"
|
||
" const r = s.getBoundingClientRect();"
|
||
" return r.right - parseFloat(getComputedStyle(s).paddingRight);"
|
||
" }"
|
||
)
|
||
|
||
|
||
def assert_cluster_flush_with_viewport_bottom(page: Page) -> dict[str, float]:
|
||
"""The ``.chat-bottom`` unit is FULLY inside the viewport with its
|
||
bottom edge at the viewport bottom (± ``FLUSH_PX``), and the row
|
||
AND the composer are each fully visible — the pills are literally
|
||
always at the bottom of the screen (A1) at every reading
|
||
position. This is the whole story: nothing in the cluster hides
|
||
behind the fold while the reader is scrolled up."""
|
||
box = box_of(page, ".chat-bottom")
|
||
ch = scroll_state(page)["ch"]
|
||
assert box["y"] >= -FLUSH_PX, (
|
||
f"the pinned cluster is clipped at the TOP of the viewport "
|
||
f"(y={box['y']:.1f}, viewport={ch:.0f})"
|
||
)
|
||
bottom = box["y"] + box["height"]
|
||
assert bottom <= ch + FLUSH_PX, (
|
||
f"the cluster runs BELOW the viewport bottom "
|
||
f"(bottom={bottom:.1f}, viewport={ch:.0f})"
|
||
)
|
||
assert bottom >= ch - FLUSH_PX, (
|
||
f"the cluster is not pinned to the bottom edge "
|
||
f"(bottom={bottom:.1f}, viewport={ch:.0f})"
|
||
)
|
||
# The unit's two members are each fully visible too — the row is
|
||
# the contract (the pills), the composer rides along with it.
|
||
row = box_of(page, ".chat-actions")
|
||
composer = box_of(page, "#composer")
|
||
for name, b in ((".chat-actions", row), ("#composer", composer)):
|
||
assert b["y"] >= -FLUSH_PX and b["y"] + b["height"] <= ch + FLUSH_PX, (
|
||
f"{name} is not fully inside the viewport at a reading position "
|
||
f"(box={b}, viewport={ch:.0f})"
|
||
)
|
||
return box
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. Resting geometry: a fresh chat is not scrollable and the row rests
|
||
# in the lower part of the screen, directly above the composer,
|
||
# hugging the column's right edge (A2)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_empty_chat_row_rests_at_screen_bottom(
|
||
page: Page, app_url: str, db_ready: None
|
||
) -> None:
|
||
page.set_default_timeout(30_000)
|
||
page.goto(app_url)
|
||
|
||
# Exactly ONE pinned unit, holding exactly the row + the composer —
|
||
# the static markup of the new bottom cluster (no KB seed needed).
|
||
expect(page.locator(".chat-bottom")).to_have_count(1)
|
||
cluster = page.locator(".chat-bottom")
|
||
expect(cluster.locator(".chat-actions")).to_have_count(1)
|
||
expect(cluster.locator("#composer")).to_have_count(1)
|
||
|
||
# A fresh visitor: the empty state, nothing to scroll — the wrapper
|
||
# must not invent scrollable space (the phase-52 contract, kept:
|
||
# the wrapper adds no height the row did not already carry).
|
||
expect(page.locator("#empty-state")).to_be_visible()
|
||
state = scroll_state(page)
|
||
assert state["sh"] <= state["ch"] + 1, (
|
||
f"an empty chat must not be scrollable (sh={state['sh']:.0f}, "
|
||
f"ch={state['ch']:.0f}) — the wrapper must not invent scrollable space"
|
||
)
|
||
ch = state["ch"]
|
||
|
||
row = box_of(page, ".chat-actions")
|
||
composer = box_of(page, "#composer")
|
||
share = box_of(page, "#share-chat-btn")
|
||
|
||
# The row rests in the LOWER part of the screen ... (pre-phase-65 it
|
||
# sat at the top of the column and scrolled off-screen immediately).
|
||
assert row["y"] + row["height"] >= ch * LOWER_PART, (
|
||
f"the resting row is at {row['y'] + row['height']:.0f}px of a "
|
||
f"{ch:.0f}px viewport — it has to sit at the bottom of the screen"
|
||
)
|
||
# ... directly ABOVE the composer (the unit's 0.5rem gap sits
|
||
# between them — no overlap, no dead band).
|
||
assert row["y"] + row["height"] <= composer["y"] + FLUSH_PX, (
|
||
f"the row must sit directly above the composer (row={row}, "
|
||
f"composer={composer})"
|
||
)
|
||
# A2: the row hugs the column's right edge — the row box spans the
|
||
# column's content width, and the rightmost pill (Share) is flush
|
||
# with that edge (justify-content: flex-end — a left-aligned group
|
||
# would leave a dead band on the right).
|
||
right = column_right_edge(page)
|
||
assert abs(row["x"] + row["width"] - right) <= 2, (
|
||
f"the row's right edge ({row['x'] + row['width']:.1f}) does not align "
|
||
f"with the column's right edge ({right:.1f})"
|
||
)
|
||
assert abs(share["x"] + share["width"] - right) <= 2, (
|
||
f"the rightmost pill is not flush with the column's right edge "
|
||
f"(share right={share['x'] + share['width']:.1f}, column={right:.1f})"
|
||
)
|
||
# The composer is not clipped below the viewport (it rests in flow,
|
||
# above the footer — never hanging off the bottom of the screen).
|
||
assert composer["y"] + composer["height"] <= ch + FLUSH_PX, (
|
||
f"the resting composer hangs below the viewport bottom "
|
||
f"(bottom={composer['y'] + composer['height']:.1f}, viewport={ch:.0f})"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. The A1 pin: an over-viewport conversation keeps the whole cluster
|
||
# (row + composer) flush with the viewport bottom at every scroll
|
||
# position, settling into flow above the footer at the document bottom
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_bottom_cluster_pinned_at_every_scroll_position(
|
||
page: Page, app_url: str, seeded_kb: None
|
||
) -> None:
|
||
page.set_default_timeout(30_000)
|
||
login(page, app_url, next="/")
|
||
|
||
build_conversation(page, n=len(SHORT_QUESTIONS))
|
||
|
||
# The phase's precondition: a conversation longer than the viewport
|
||
# (the reader has content to scroll through — the moment the old
|
||
# top-of-column row used to leave the screen).
|
||
state = scroll_state(page)
|
||
assert state["sh"] > state["ch"] + 200, (
|
||
f"the conversation must overflow the viewport (sh={state['sh']:.0f}, "
|
||
f"ch={state['ch']:.0f}) — more turns are needed"
|
||
)
|
||
|
||
# Scrolled to the very top (the "reading the beginning" position):
|
||
# the WHOLE cluster is inside the viewport, flush with its bottom —
|
||
# the pills are reachable without any scrolling.
|
||
page.evaluate("() => window.scrollTo(0, 0)")
|
||
assert scroll_state(page)["y"] <= 1
|
||
assert_cluster_flush_with_viewport_bottom(page)
|
||
|
||
# And at every stop through the sticky range. The range is bounded by
|
||
# the cluster's CONTAINING BLOCK (`.chat-shell` — the wrapper's
|
||
# parent): the pin holds while that column's bottom edge sits below
|
||
# the viewport bottom (the same math as
|
||
# `test_pinned_composer.py::test_composer_pinned_at_every_scroll_position`).
|
||
limits = page.evaluate(
|
||
"() => ({ shellBottom: document.querySelector('.chat-shell')"
|
||
".getBoundingClientRect().bottom + window.scrollY, "
|
||
"sh: document.documentElement.scrollHeight, ch: window.innerHeight })"
|
||
)
|
||
max_scroll = limits["sh"] - limits["ch"]
|
||
pin_limit = limits["shellBottom"] - limits["ch"]
|
||
assert pin_limit > 200, (
|
||
f"the conversation must keep the cluster inside its sticky range "
|
||
f"for at least a screen of scrolling (limit={pin_limit:.0f})"
|
||
)
|
||
assert max_scroll > pin_limit, (
|
||
"the document bottom must leave the sticky range, so the settle "
|
||
"back into flow is covered too"
|
||
)
|
||
for y in (0, pin_limit * 0.25, pin_limit * 0.5, pin_limit * 0.75, pin_limit - 1):
|
||
page.evaluate("y => window.scrollTo(0, y)", y)
|
||
assert abs(scroll_state(page)["y"] - y) <= 1, f"the test scroll to {y} must land"
|
||
assert_cluster_flush_with_viewport_bottom(page)
|
||
|
||
# Settled at the document bottom: the unit has returned to its NORMAL
|
||
# FLOW position — above the footer, not hovering over it, the composer
|
||
# no longer glued to the viewport edge, the row still directly above it.
|
||
page.evaluate("() => window.scrollTo(0, document.documentElement.scrollHeight)")
|
||
assert abs(scroll_state(page)["y"] - max_scroll) <= 1, "must land at the document bottom"
|
||
ch = scroll_state(page)["ch"]
|
||
cluster = box_of(page, ".chat-bottom")
|
||
row = box_of(page, ".chat-actions")
|
||
composer = box_of(page, "#composer")
|
||
footer = box_of(page, ".app-footer") # the footer must be rendered
|
||
assert cluster["y"] + cluster["height"] <= footer["y"] + FLUSH_PX, (
|
||
f"the settled cluster overlaps the footer (cluster={cluster}, "
|
||
f"footer={footer}) — the pin must not float the unit over chrome"
|
||
)
|
||
assert composer["y"] + composer["height"] < ch - FLUSH_PX, (
|
||
"at the document bottom the composer settles into flow — it is no "
|
||
"longer glued to the viewport edge"
|
||
)
|
||
assert row["y"] + row["height"] <= composer["y"] + FLUSH_PX, (
|
||
"in its settled slot the row must still sit directly above the "
|
||
f"composer (row={row}, composer={composer})"
|
||
)
|
||
|
||
# The conversation itself is intact (6 turns, all answers landed) —
|
||
# the pin changed the geometry, not the content.
|
||
expect(page.locator(".msg.user .bubble")).to_have_count(len(SHORT_QUESTIONS))
|
||
expect(page.locator(".msg.brain .bubble")).to_have_count(len(SHORT_QUESTIONS))
|
||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(MOCK_ANSWER_MARKER)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3. Row geometry + alignment (A2): ONE row with the two pills in the A5
|
||
# DOM order — horizontal + right-hugging at desktop, full-width stack
|
||
# at ≤640px, no horizontal overflow at 360px, 44px touch targets
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_row_geometry_and_alignment(
|
||
page: Page, app_url: str, seeded_kb: None
|
||
) -> None:
|
||
page.set_default_timeout(30_000)
|
||
|
||
# No conversation needed for the row's GEOMETRY — static markup,
|
||
# always present (the page fixture's viewport is the 1280×800
|
||
# desktop). Phase 79: the touch-target section below seeds ONE
|
||
# turn, so the visitor signs in first (chat is require_user).
|
||
login(page, app_url, next="/")
|
||
|
||
# Exactly ONE .chat-actions, inside the pinned unit, holding exactly
|
||
# the two pills — and the A5 DOM order: New chat, then Share (the
|
||
# same compareDocumentPosition check as test_save_share_ux.py).
|
||
expect(page.locator(".chat-actions")).to_have_count(1)
|
||
expect(page.locator(".chat-bottom .chat-actions")).to_have_count(1)
|
||
row_el = page.locator(".chat-actions")
|
||
assert row_el.locator("#new-chat-btn").count() == 1
|
||
assert row_el.locator("#share-chat-btn").count() == 1
|
||
expect(page.locator("#new-chat-btn")).to_have_count(1)
|
||
expect(page.locator("#share-chat-btn")).to_have_count(1)
|
||
assert page.evaluate(
|
||
"() => {"
|
||
" const n = document.getElementById('new-chat-btn');"
|
||
" const s = document.getElementById('share-chat-btn');"
|
||
" return !!(n && s && (n.compareDocumentPosition(s) & Node.DOCUMENT_POSITION_FOLLOWING));"
|
||
" }"
|
||
), "the DOM order must be New chat, then Share"
|
||
|
||
new_btn = page.locator("#new-chat-btn")
|
||
share_btn = page.locator("#share-chat-btn")
|
||
|
||
# Desktop (1280×800): one horizontal row — overlapping y bands,
|
||
# Share to the right of New chat, each pill at its INTRINSIC width
|
||
# (never stretched), and the row hugging the column's RIGHT edge (A2).
|
||
nb = new_btn.bounding_box()
|
||
sb = share_btn.bounding_box()
|
||
assert nb is not None and sb is not None
|
||
assert nb["y"] < sb["y"] + sb["height"] and sb["y"] < nb["y"] + nb["height"], (
|
||
f"the pills must share one horizontal row (new={nb}, share={sb})"
|
||
)
|
||
assert sb["x"] > nb["x"] + nb["width"], "Share must sit right of New chat"
|
||
column_w = page.evaluate(
|
||
"() => document.querySelector('.chat-shell').getBoundingClientRect().width"
|
||
)
|
||
assert nb["width"] < column_w / 2 and sb["width"] < column_w / 2, (
|
||
"each pill must keep its intrinsic width on desktop, not stretch the column"
|
||
)
|
||
row_box = page.locator(".chat-actions").bounding_box()
|
||
assert row_box is not None
|
||
right = column_right_edge(page)
|
||
assert abs(row_box["x"] + row_box["width"] - right) <= 2, (
|
||
f"the row's right edge ({row_box['x'] + row_box['width']:.1f}) does not "
|
||
f"align with the column's right edge ({right:.1f}) — A2"
|
||
)
|
||
assert abs(sb["x"] + sb["width"] - right) <= 2, (
|
||
f"the rightmost pill is not flush with the column's right edge "
|
||
f"(share right={sb['x'] + sb['width']:.1f}, column={right:.1f}) — A2"
|
||
)
|
||
|
||
# Mobile (390×844): a vertical stack — Share BELOW New chat, both
|
||
# pills the SAME width, each stretched to the unit's full content
|
||
# width (the ≤640px stack rule — alignment is moot when stretched).
|
||
page.set_viewport_size({"width": 390, "height": 844})
|
||
nb = new_btn.bounding_box()
|
||
sb = share_btn.bounding_box()
|
||
assert nb is not None and sb is not None
|
||
assert sb["y"] > nb["y"] + nb["height"], (
|
||
f"the pills must stack at 390px, Share below New chat (new={nb}, share={sb})"
|
||
)
|
||
cluster_box = page.locator(".chat-bottom").bounding_box()
|
||
assert cluster_box is not None
|
||
assert abs(nb["width"] - sb["width"]) <= 2, "the stacked pills share one full width"
|
||
# .chat-bottom carries no padding/border — its box width IS the
|
||
# unit's content width the pills must fill.
|
||
assert abs(nb["width"] - cluster_box["width"]) <= 2, (
|
||
"the stacked New chat pill must stretch the unit's full width"
|
||
)
|
||
assert abs(sb["width"] - cluster_box["width"]) <= 2, (
|
||
"the stacked Share pill must stretch the unit's full width"
|
||
)
|
||
|
||
# 360px wide: no horizontal page overflow (the two stacked pills +
|
||
# the container padding must fit).
|
||
page.set_viewport_size({"width": 360, "height": 800})
|
||
scroll_w = page.evaluate("() => document.documentElement.scrollWidth")
|
||
assert scroll_w <= 360, f"horizontal overflow at 360px: scrollWidth={scroll_w}"
|
||
|
||
# Touch targets: the bottom cluster's pills render ≥44px tall at BOTH
|
||
# widths — and so does the .retry-btn the app injects on the last
|
||
# brain bubble (one seeded turn makes it exist; the ghost family
|
||
# shares the 44px floor with the solid family).
|
||
page.set_viewport_size({"width": 1280, "height": 800})
|
||
submit(page, SHORT_QUESTIONS[0])
|
||
wait_settled(page)
|
||
expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER)
|
||
expect(page.locator(".retry-btn")).to_have_count(1)
|
||
for selector in ("#new-chat-btn", "#share-chat-btn", ".retry-btn"):
|
||
assert box_of(page, selector)["height"] >= 44, (
|
||
f"{selector} must keep the 44px touch floor at 1280px"
|
||
)
|
||
page.set_viewport_size({"width": 390, "height": 844})
|
||
for selector in ("#new-chat-btn", "#share-chat-btn", ".retry-btn"):
|
||
assert box_of(page, selector)["height"] >= 44, (
|
||
f"{selector} must keep the 44px touch floor at 390px"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 4. The click-through survived the move: New chat resets the
|
||
# conversation from the bottom cluster; Share on the empty chat is
|
||
# the guarded no-op
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_buttons_still_work_from_the_bottom(
|
||
page: Page, app_url: str, seeded_kb: None
|
||
) -> None:
|
||
page.set_default_timeout(30_000)
|
||
login(page, app_url, next="/")
|
||
|
||
# One real conversation: the buttons have something to act on (and
|
||
# the auto-save path fires — the row is cleaned up by _reset_db).
|
||
submit(page, SHORT_QUESTIONS[0])
|
||
wait_settled(page)
|
||
expect(page.locator(".msg")).to_have_count(2)
|
||
expect(page.locator(ANSWER).last).to_contain_text(MOCK_ANSWER_MARKER)
|
||
|
||
# New chat (header.js → the `bor:new-chat` window event → the app.js
|
||
# reset): the conversation is cleared from the DOM, from localStorage,
|
||
# and the a11y announcer reports the reset — from its new home in the
|
||
# bottom cluster (the binding is id-based and position-independent).
|
||
page.click("#new-chat-btn")
|
||
expect(page.locator(".msg")).to_have_count(0)
|
||
expect(page.locator("#empty-state")).to_be_visible()
|
||
assert page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')") is None, (
|
||
"the cleared conversation must be gone from localStorage (bor.chat.v1)"
|
||
)
|
||
expect(page.locator("#send-status")).to_contain_text("New chat started")
|
||
|
||
# Share on the now-empty chat: the guarded no-op (app.js
|
||
# shareCurrentChat) — the a11y status line, no toast, no navigation.
|
||
url_before = page.url
|
||
page.click("#share-chat-btn")
|
||
expect(page.locator("#send-status")).to_contain_text("Nothing to share yet.")
|
||
expect(page.locator(".toast")).to_have_count(0)
|
||
assert page.url == url_before, "the empty-chat share no-op must not navigate"
|