Files
brain-of-reese/tests/e2e/test_save_share_ux.py
T
ducoterra 7fce6572d0
Build and Push Containers / build-and-push-app (push) Successful in 1m45s
Build and Push Containers / build-and-push-db (push) Successful in 13s
feat: phases 77–80 — navbar view refresh, static background, API tokens, history suggestion chips
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
2026-09-07 12:39:01 -04:00

572 lines
25 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 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)