feat(web): move the chat action cluster to the pinned bottom and align the button sets
- task 01: relocate the .chat-actions row (New chat + Share, comments byte-identical with a Phase 65 note) from the top of the column to the bottom of .chat-shell, directly above the composer - task 02 (owner-locked A1): wrap the row + #composer in ONE sticky .chat-bottom unit (position: sticky; bottom: env(safe-area-inset-bottom, 0), no z-index) — the pills stay at the bottom of the screen at every scroll position and settle into flow above the footer - task 03 (owner-locked A2): right-align the bottom row to the column's right edge (justify-content: flex-end), mirroring the right-aligned Save-as-doc corner; the five action pills share one 44px / 999px-pill geometry - task 04: dedicated Playwright suite tests/e2e/test_bottom_chat_actions.py (resting geometry, the A1 pin across the sticky range, A2 alignment + DOM order + mobile stack + 360px overflow bound + 44px touch targets, New chat / Share click-through) — green in isolation - task 05: regression matrix green in isolation (pinned_composer 4, save_share_ux 5, chat_persistence 4, share_chat 4, chat_history 5, smoke 3); full gate green — unit + integration pass, app/ coverage 99% (>90%), ruff + pyright clean
This commit is contained in:
@@ -0,0 +1,576 @@
|
||||
"""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
|
||||
|
||||
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)
|
||||
page.goto(app_url)
|
||||
|
||||
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 — static markup, always present
|
||||
# (the page fixture's viewport is the 1280×800 desktop).
|
||||
page.goto(app_url)
|
||||
|
||||
# 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)
|
||||
page.goto(app_url)
|
||||
|
||||
# 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"
|
||||
Reference in New Issue
Block a user