feat(chat): save by default + share anonymously — auto-saved chats, guest-facing Share, success toast, action row
This commit is contained in:
@@ -0,0 +1,562 @@
|
||||
"""Phase 55 E2E (Playwright): save by default + anonymous share + action-row layout.
|
||||
|
||||
TODO.md L3–L6 (owner 2026-08-31, roadmap confirmation — the four items
|
||||
this suite verifies, in the browser):
|
||||
L3 "Share chat should work anonymously without login"
|
||||
L4 "Save shouldn't be a button, every chat should be saved by default"
|
||||
L5 "Need feedback (probably dropdown notification toast) to show share worked"
|
||||
L6 "New Chat and Share buttons should only be vertically stacked when in
|
||||
mobile, otherwise they should be horizontally next to each other"
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_save_share_ux.py -v --no-cov
|
||||
|
||||
The owner-locked loop under test (2026-08-31):
|
||||
|
||||
* **Anonymous auto-save (L4 / A2)** — there is NO Save control in the
|
||||
DOM at any width; a signed-out visitor's first question auto-upserts
|
||||
EXACTLY ONE ``saved_chats`` row (the auto-title, both messages) —
|
||||
verified through the admin's ``GET /api/chats`` (the management
|
||||
surface stays admin-only, phase 55 task 01) with no button press
|
||||
anywhere;
|
||||
* **No duplicate across reload (L4)** — a plain reload restores the
|
||||
conversation from localStorage AND the row link (``chatId`` in the
|
||||
``bor.chat.v1`` record, task 02): a second question updates the SAME
|
||||
row — the title's row count stays one, the message count grows
|
||||
2 → 4;
|
||||
* **Anonymous share + toast (L3 / L5 / A4)** — the Share pill is
|
||||
visible WITHOUT login; clicking it on a non-empty conversation mints
|
||||
the public link (clipboard path, the inline field on non-secure
|
||||
origins) and raises the top-right toast ("Share link copied.",
|
||||
``aria-hidden`` visual-only, a single instance, auto-dismiss ~4s); a
|
||||
FRESH incognito context opens ``/shared/<token>`` read-only (title +
|
||||
both bubbles, the phase-51 zero-controls surface); the admin's
|
||||
History Unshare (inline two-step) revokes — the SAME URL then shows
|
||||
the "invalid or revoked" state;
|
||||
* **Layout (L6 / A5)** — desktop 1280×800: ``#new-chat-btn`` and
|
||||
``#share-chat-btn`` share one horizontal row inside ``.chat-actions``
|
||||
(overlapping y bands, Share's x beyond New chat's x + width, each
|
||||
pill at intrinsic width — never the full 46rem column); mobile
|
||||
390×844: stacked vertically (Share below New chat, full-width);
|
||||
360px wide: no horizontal page overflow;
|
||||
* **Admin still works (A1 sanity)** — the same auto-save machinery
|
||||
fires for a signed-in admin (the write surface is public either way;
|
||||
the row lands in the admin's History).
|
||||
|
||||
DB isolation: the shared e2e Postgres keeps ``saved_chats`` rows across
|
||||
suites, so every test uses a DISTINCTIVE question (its auto-title is
|
||||
therefore unique), selects rows by auto-title (never absolute counts),
|
||||
``_reset_db``s first (the KB tables are truncated + re-seeded the house
|
||||
way — deterministic mock embeddings; ``saved_chats`` is never touched
|
||||
by the reset), and deletes the rows it creates in a ``finally`` (admin
|
||||
cookie).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from playwright.sync_api import Browser, BrowserContext, 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"
|
||||
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
|
||||
#: The invalid/revoked card's line (frontend/shared.html, phase 51).
|
||||
INVALID_TEXT = "This share link is invalid or was revoked."
|
||||
#: The toast's clipboard-path line (app.js shareCurrentChat success).
|
||||
TOAST_CLIP_TEXT = "Share link copied."
|
||||
|
||||
|
||||
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 — deterministic
|
||||
mock answers), then optionally re-import fixtures. ``saved_chats``
|
||||
is deliberately NOT touched: rows persist across suites and every
|
||||
test here cleans up after itself."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
|
||||
db.commit()
|
||||
if not seed:
|
||||
return None
|
||||
return _run_in_thread(_import_fixtures(mock_port))
|
||||
|
||||
|
||||
def _ask(page: Page, question: str) -> None:
|
||||
"""Send one turn and wait until the grounded answer has fully
|
||||
landed (the ``done`` event restored the Send button)."""
|
||||
page.fill("#message-input", question)
|
||||
page.click("#send-btn")
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
|
||||
MOCK_ANSWER_MARKER, timeout=30_000
|
||||
)
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
expect(page.locator("#send-label")).to_have_text("Send")
|
||||
|
||||
|
||||
def _admin_cookies(page: Page) -> dict[str, str]:
|
||||
"""The signed session cookies the browser holds after a form login —
|
||||
used to call the admin API with plain httpx (the test's API side
|
||||
sees exactly what the signed-in browser sees)."""
|
||||
return {
|
||||
c["name"]: c["value"]
|
||||
for c in page.context.cookies()
|
||||
if "name" in c and "value" in c
|
||||
}
|
||||
|
||||
|
||||
def _open_admin(browser: Browser, app_url: str) -> tuple[BrowserContext, Page, dict[str, str]]:
|
||||
"""A SECOND, logged-in context — the admin's eyes for the management
|
||||
surface (``GET /api/chats`` list/detail, delete, the History
|
||||
Unshare), which stays admin-only since phase 55 task 01."""
|
||||
ctx = browser.new_context(viewport={"width": 1280, "height": 800})
|
||||
pg = ctx.new_page()
|
||||
pg.set_default_timeout(30_000)
|
||||
login(pg, app_url, next="/")
|
||||
return ctx, pg, _admin_cookies(pg)
|
||||
|
||||
|
||||
def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]:
|
||||
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
|
||||
assert r.status_code == 200
|
||||
return r.json()["chats"]
|
||||
|
||||
|
||||
def _auto_title(question: str) -> str:
|
||||
"""The phase-50 auto-title convention: the first question,
|
||||
whitespace-collapsed, capped at 120 chars."""
|
||||
return " ".join(question.split())[:120]
|
||||
|
||||
|
||||
def _find_row(rows: list[dict[str, Any]], title: str) -> dict[str, Any] | None:
|
||||
return next((c for c in rows if c["title"] == title), None)
|
||||
|
||||
|
||||
def _title_rows(app_url: str, cookies: dict[str, str], title: str) -> list[dict[str, Any]]:
|
||||
"""Every row carrying ``title`` — the "exactly one row" pin (never
|
||||
an absolute count: the shared DB keeps other suites' rows)."""
|
||||
return [c for c in _chats(app_url, cookies) if c["title"] == title]
|
||||
|
||||
|
||||
def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
|
||||
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
|
||||
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
|
||||
|
||||
|
||||
def _wait_saved_row(
|
||||
app_url: str,
|
||||
cookies: dict[str, str],
|
||||
title: str,
|
||||
messages: int = 2,
|
||||
) -> dict[str, Any]:
|
||||
"""Wait for the auto-saved row (phase 55: auto-saves are SILENT —
|
||||
A2 — so there is no status line to wait on). The upsert is
|
||||
fire-and-forget from the UI's point of view, so poll the admin
|
||||
list until the row with the conversation's auto-title appears with
|
||||
the expected message count."""
|
||||
deadline = time.monotonic() + 15
|
||||
last: dict[str, Any] | None = None
|
||||
while time.monotonic() < deadline:
|
||||
last = _find_row(_chats(app_url, cookies), title)
|
||||
if last is not None and last["message_count"] >= messages:
|
||||
return last
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
|
||||
|
||||
|
||||
def _grant_clipboard(page: Page, app_url: str) -> None:
|
||||
"""Grant the async-clipboard permissions on the context.
|
||||
|
||||
``http://127.0.0.1`` is a secure context, so ``navigator.clipboard``
|
||||
exists — but headless Chromium still requires the permission grant
|
||||
before ``writeText``/``readText`` resolve (without it the
|
||||
owner-locked inline-link fallback fires). The assertion below
|
||||
branches on the API's availability, so a non-secure origin still
|
||||
passes through the fallback branch deterministically.
|
||||
"""
|
||||
page.context.grant_permissions(
|
||||
["clipboard-read", "clipboard-write"], origin=app_url
|
||||
)
|
||||
|
||||
|
||||
def _wait_toast_dismissed(page: Page, timeout_s: float = 8.0) -> None:
|
||||
"""Poll until the toast's ``is-visible`` state class is gone.
|
||||
|
||||
The auto-dismiss (~4s, A4) drops the class (opacity/transform stay
|
||||
— opacity is not part of Playwright's visibility model, so the
|
||||
class is the honest state pin). The ~8s deadline leaves headroom
|
||||
over the 4s contract without masking a stuck toast."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
visible = page.evaluate(
|
||||
"() => { const t = document.querySelector('.toast');"
|
||||
" return !!(t && t.classList.contains('is-visible')); }"
|
||||
)
|
||||
if not visible:
|
||||
return
|
||||
time.sleep(0.2)
|
||||
raise AssertionError("the share toast did not auto-dismiss (~4s contract)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Anonymous auto-save (L4 / A2): no Save control anywhere, one
|
||||
# question → exactly one saved_chats row (auto-title, both messages),
|
||||
# verified through the admin's list — no button press in this test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_auto_save(
|
||||
page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
# Anonymous by construction: the page fixture's context never logs
|
||||
# in — this is the signed-out visitor.
|
||||
page.goto(app_url + "/")
|
||||
|
||||
# L4: there is NO Save control at any width — the element is gone
|
||||
# from the DOM (phase 55, task 02), the pills are New chat + Share.
|
||||
expect(page.locator("#save-chat-btn")).to_have_count(0)
|
||||
expect(page.locator("#new-chat-btn")).to_be_visible()
|
||||
expect(page.locator("#share-chat-btn")).to_be_visible()
|
||||
|
||||
q = "How is my Kubernetes cluster set up? (save-ux-auto)"
|
||||
_ask(page, q) # the ONLY click in this test — no Save press exists
|
||||
|
||||
admin_ctx, _admin, cookies = _open_admin(browser, app_url)
|
||||
created: str | None = None
|
||||
try:
|
||||
# The admin's management list sees the auto-saved row: the
|
||||
# auto-title, both messages, created by the anonymous visitor.
|
||||
row = _wait_saved_row(app_url, cookies, _auto_title(q), messages=2)
|
||||
assert row["message_count"] == 2, "the auto-saved row holds both messages"
|
||||
assert len(_title_rows(app_url, cookies, _auto_title(q))) == 1, (
|
||||
"exactly ONE row for the auto-title — the auto-save must not duplicate"
|
||||
)
|
||||
created = row["id"]
|
||||
# A2 quiet contract: a successful auto-save is SILENT — no toast
|
||||
# (reserved for share) and no error banner on the anonymous page.
|
||||
expect(page.locator(".toast")).to_have_count(0)
|
||||
expect(page.locator("#kb-banner")).to_be_hidden()
|
||||
finally:
|
||||
if created is not None:
|
||||
_delete_chat(app_url, cookies, created)
|
||||
admin_ctx.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. No duplicate across reload (L4): the row link (chatId in
|
||||
# bor.chat.v1, task 02) survives a plain reload — the second
|
||||
# question updates the SAME row (count one, messages 2 → 4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_no_duplicate_row_across_reload(
|
||||
page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
page.goto(app_url + "/")
|
||||
q1 = "How is my Kubernetes cluster set up? (save-ux-reload)"
|
||||
_ask(page, q1)
|
||||
|
||||
admin_ctx, _admin, cookies = _open_admin(browser, app_url)
|
||||
created: str | None = None
|
||||
try:
|
||||
row = _wait_saved_row(app_url, cookies, _auto_title(q1), messages=2)
|
||||
chat_id: str = row["id"]
|
||||
created = chat_id
|
||||
|
||||
# Plain reload: the conversation restores from localStorage
|
||||
# (both bubbles) — and the row link restores with it.
|
||||
page.reload()
|
||||
expect(page.locator(".msg.user .bubble").last).to_contain_text(q1)
|
||||
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
|
||||
MOCK_ANSWER_MARKER, timeout=30_000
|
||||
)
|
||||
|
||||
# The second question must UPDATE the same row — not spawn a
|
||||
# second one (the unlinked-upgrade path would create a fresh
|
||||
# row; the chatId persistence is what prevents that).
|
||||
q2 = "Which node runs the Kubernetes control plane? (save-ux-reload)"
|
||||
_ask(page, q2)
|
||||
row = _wait_saved_row(app_url, cookies, _auto_title(q1), messages=4)
|
||||
assert row["id"] == chat_id, "the reloaded conversation kept its row link"
|
||||
assert row["message_count"] == 4, "both turns' four messages landed on the row"
|
||||
assert len(_title_rows(app_url, cookies, _auto_title(q1))) == 1, (
|
||||
"still exactly ONE row after the reload — no duplicate"
|
||||
)
|
||||
finally:
|
||||
if created is not None:
|
||||
_delete_chat(app_url, cookies, created)
|
||||
admin_ctx.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Anonymous share + toast (L3 / L5 / A4): the Share pill is visible
|
||||
# without login; the click mints the link + the top-right toast; a
|
||||
# fresh incognito context reads it read-only; the admin's History
|
||||
# Unshare revokes the same URL
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_share_toast_and_unshare(
|
||||
page: Page, browser: Browser, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
# Anonymous by construction — no login anywhere in this test.
|
||||
page.goto(app_url + "/")
|
||||
|
||||
# L3: the Share pill is visible WITHOUT login (static markup — the
|
||||
# phase-51 admin-only reveal gate is gone, task 03).
|
||||
share = page.locator("#share-chat-btn")
|
||||
expect(share).to_be_visible()
|
||||
expect(share).to_have_attribute("aria-label", "Share chat")
|
||||
|
||||
q = "How is my Kubernetes cluster set up? (save-ux-share)"
|
||||
_ask(page, q)
|
||||
|
||||
# A4: the toast is reserved for share — nothing before the click
|
||||
# (the auto-save that just fired is silent).
|
||||
expect(page.locator(".toast")).to_have_count(0)
|
||||
|
||||
_grant_clipboard(page, app_url)
|
||||
share.click()
|
||||
|
||||
# The top-right toast: a SINGLE instance, the visible state class,
|
||||
# the success text, aria-hidden (visual only — #send-status is the
|
||||
# a11y announcer and keeps its phase-51 line).
|
||||
toast = page.locator(".toast")
|
||||
expect(toast).to_have_count(1)
|
||||
expect(toast).to_have_class(re.compile(r"\bis-visible\b"), timeout=15_000)
|
||||
expect(toast).to_have_text(TOAST_CLIP_TEXT)
|
||||
expect(toast).to_have_attribute("aria-hidden", "true")
|
||||
expect(page.locator("#send-status")).to_have_text(TOAST_CLIP_TEXT, timeout=15_000)
|
||||
# A4: it auto-dismisses in ~4s (the class drops; the node stays).
|
||||
_wait_toast_dismissed(page)
|
||||
|
||||
# Read the link — the clipboard when the origin allows it, else the
|
||||
# inline fallback field (the phase-51 owner-locked branch).
|
||||
if page.evaluate("() => !!navigator.clipboard"):
|
||||
link: str | None = page.evaluate("() => navigator.clipboard.readText()")
|
||||
expect(page.locator(".share-link-fallback")).to_have_count(0)
|
||||
else:
|
||||
field = page.locator(".share-link-fallback")
|
||||
expect(field).to_be_visible()
|
||||
link = field.get_attribute("href")
|
||||
assert link is not None and link.startswith(app_url), f"bad share link: {link!r}"
|
||||
path = link.removeprefix(app_url)
|
||||
assert SHARE_URL_RE.fullmatch(path), f"bad share path shape: {path!r}"
|
||||
|
||||
admin_ctx, admin, cookies = _open_admin(browser, app_url)
|
||||
anon_ctx: BrowserContext | None = None
|
||||
created: str | None = None
|
||||
try:
|
||||
# A FRESH incognito context: the guest's only credential is the
|
||||
# token in the URL — the conversation renders read-only.
|
||||
anon_ctx = browser.new_context()
|
||||
anon = anon_ctx.new_page()
|
||||
anon.set_default_timeout(30_000)
|
||||
anon.goto(app_url + path)
|
||||
|
||||
expect(anon.locator("#shared-title")).to_have_text(_auto_title(q))
|
||||
expect(anon.locator(".msg.user .bubble")).to_have_count(1)
|
||||
expect(anon.locator(".msg.user .bubble")).to_contain_text(q)
|
||||
expect(anon.locator(".msg.brain .bubble")).to_have_count(1)
|
||||
expect(anon.locator(".msg.brain .bubble").first).to_contain_text(
|
||||
MOCK_ANSWER_MARKER, timeout=30_000
|
||||
)
|
||||
# The phase-51 zero-controls surface: no composer, no pills,
|
||||
# no Tune/Retry, no button chips — and the guest header.
|
||||
expect(anon.locator("#composer")).to_have_count(0)
|
||||
expect(anon.locator("#save-chat-btn, #share-chat-btn")).to_have_count(0)
|
||||
expect(anon.locator(".tune-btn")).to_have_count(0)
|
||||
expect(anon.locator(".retry-btn")).to_have_count(0)
|
||||
expect(anon.locator("button.suggestion-chip")).to_have_count(0)
|
||||
expect(anon.locator("#sign-in-link")).to_be_visible(timeout=15_000)
|
||||
|
||||
# The API agrees (the admin's eyes): the guest's auto-saved row
|
||||
# carries the SAME /shared/<token> link.
|
||||
row = _find_row(_chats(app_url, cookies), _auto_title(q))
|
||||
assert row is not None, "the guest's auto-saved row must exist"
|
||||
created = row["id"]
|
||||
assert row["share_url"] == path, "the shared row carries the link just used"
|
||||
|
||||
# The admin revokes from the History page (inline two-step —
|
||||
# no native dialog).
|
||||
admin.goto(app_url + "/history.html")
|
||||
tr = admin.locator(
|
||||
"#history-tbody tr", has=admin.locator(f"a[href='/?chat={created}']")
|
||||
)
|
||||
expect(tr.locator("button.history-unshare")).to_be_visible(timeout=15_000)
|
||||
tr.locator("button.history-unshare").click()
|
||||
expect(tr.locator(".history-confirm-text")).to_have_text("Unshare?")
|
||||
expect(tr.locator(".history-confirm-yes")).to_be_visible()
|
||||
tr.locator(".history-confirm-yes").click()
|
||||
expect(tr.locator("button.history-share-create")).to_be_visible(timeout=15_000)
|
||||
|
||||
# The SAME URL is revoked now — in the SAME fresh context.
|
||||
anon.goto(app_url + path)
|
||||
expect(anon.locator("#shared-invalid")).to_be_visible(timeout=15_000)
|
||||
expect(anon.locator("#shared-invalid")).to_contain_text(INVALID_TEXT)
|
||||
expect(anon.locator(".msg")).to_have_count(0)
|
||||
|
||||
# The API agrees: share_url is ABSENT (the omission rule).
|
||||
r = httpx.get(f"{app_url}/api/chats/{created}", timeout=10, cookies=cookies)
|
||||
assert r.status_code == 200
|
||||
assert "share_url" not in r.json(), "unshared → share_url must be absent"
|
||||
finally:
|
||||
if anon_ctx is not None:
|
||||
anon_ctx.close()
|
||||
if created is not None:
|
||||
_delete_chat(app_url, cookies, created)
|
||||
admin_ctx.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Action-row layout (L6 / A5): horizontal at desktop, stacked at
|
||||
# ≤640px, no horizontal overflow at 360px
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_action_row_layout(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
# No conversation needed — the pills are static markup, always
|
||||
# present (the page fixture's viewport is the 1280×800 desktop).
|
||||
page.goto(app_url + "/")
|
||||
|
||||
new_btn = page.locator("#new-chat-btn")
|
||||
share_btn = page.locator("#share-chat-btn")
|
||||
expect(new_btn).to_be_visible()
|
||||
expect(share_btn).to_be_visible()
|
||||
|
||||
# Both pills live in ONE .chat-actions row, New chat before Share.
|
||||
expect(page.locator(".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
|
||||
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"
|
||||
|
||||
# Desktop (1280×800): one horizontal row — overlapping y bands,
|
||||
# Share to the right of New chat, each pill at its INTRINSIC width
|
||||
# (never the full 46rem chat column).
|
||||
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 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"
|
||||
)
|
||||
|
||||
# Mobile (390×844): stacked vertically — Share BELOW New chat, both
|
||||
# full-width (the ≤640px stretch rule).
|
||||
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})"
|
||||
)
|
||||
# Full-width within the column: the stacked pills stretch to the
|
||||
# .chat-actions row (the column's content box — .chat-shell is a
|
||||
# .container, whose getBoundingClientRect includes its padding).
|
||||
row_box = page.locator(".chat-actions").bounding_box()
|
||||
assert row_box is not None
|
||||
assert abs(nb["width"] - sb["width"]) < 2, "the stacked pills share one full width"
|
||||
assert abs(nb["width"] - row_box["width"]) < 2, "the stacked pills stretch the column"
|
||||
|
||||
# 360px wide: no horizontal page overflow (the two stacked pills +
|
||||
# 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}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Admin still works (A1 sanity): the same auto-save machinery fires
|
||||
# for a signed-in admin — session or not, the row lands
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_auto_save_still_works(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=True)
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
login(page, app_url, next="/")
|
||||
expect(page).to_have_url(app_url + "/", timeout=30_000)
|
||||
|
||||
q = "How is my Kubernetes cluster set up? (save-ux-admin)"
|
||||
_ask(page, q)
|
||||
|
||||
cookies = _admin_cookies(page)
|
||||
created: str | None = None
|
||||
try:
|
||||
row = _wait_saved_row(app_url, cookies, _auto_title(q), messages=2)
|
||||
assert row["message_count"] == 2, "the admin's auto-saved row holds both messages"
|
||||
created = row["id"]
|
||||
expect(page.locator(".toast")).to_have_count(0) # auto-save is silent (A2)
|
||||
finally:
|
||||
if created is not None:
|
||||
_delete_chat(app_url, cookies, created)
|
||||
Reference in New Issue
Block a user