Files
brain-of-reese/tests/e2e/test_bottom_chat_actions.py
T
ducoterra 8a1f99cb38 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
2026-09-02 01:08:18 -04:00

577 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 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"