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
572 lines
25 KiB
Python
572 lines
25 KiB
Python
"""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). Phase 79 note: chat is
|
||
now ``require_user``-gated (only shared chats stay open), so the
|
||
"signed-out visitor" of the L3/L4 pins signs in first — the mechanism
|
||
pins (no Save control, silent auto-save, exactly one row, share toast,
|
||
unshare, layout) are unchanged.
|
||
|
||
* **Auto-save (L4 / A2)** — there is NO Save control in the
|
||
DOM at any width; the 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)
|
||
|
||
# Phase 79: chat is require_user-gated — the phase-55 "signed-out
|
||
# visitor" no longer exists (shared chats are the only open
|
||
# surface), so the default visitor signs in first; every other pin
|
||
# (no Save control, silent auto-save, exactly one row) is unchanged.
|
||
login(page, app_url, next="/")
|
||
|
||
# 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)
|
||
|
||
login(page, app_url, next="/") # phase 79: chat is require_user-gated
|
||
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)
|
||
|
||
# Phase 79: the phase-55 "no login anywhere" visitor no longer
|
||
# exists (chat is gated) — the visitor signs in first; the FRESH
|
||
# incognito context below (the read-only shared view) stays
|
||
# anonymous, which is the open surface this story pins.
|
||
login(page, app_url, next="/")
|
||
|
||
# L3: the Share pill is visible (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)
|