feat(chat): save by default + share anonymously — auto-saved chats, guest-facing Share, success toast, action row

This commit is contained in:
2026-08-31 05:20:25 -04:00
parent c564e317ed
commit 914097abcf
17 changed files with 1803 additions and 491 deletions
+69 -45
View File
@@ -6,13 +6,17 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_chat_history.py -v --no-cov
The owner-locked loop under test (A10 extension, 2026-08-29):
The owner-locked loop under test (A10 extension, 2026-08-29; phase 55
replaced the Save pill with auto-save — the tests below wait for the
auto-saved row via the admin list, since auto-saves are SILENT, A2):
* **Save** — on the chat page, admin-only (the pill ships hidden and
whoami reveals it): the current conversation POSTs to ``/api/chats``
(auto-title = the first question, whitespace-collapsed, 120-char cap)
and links to the created row; a re-Save PUTs the SAME row (upsert);
"New chat" unlinks, so the next Save creates again;
* **Auto-save** — on the chat page, no control (the pill is GONE,
phase 55): the current conversation upserts itself at the save
points — create on the first user message (auto-title = the first
question, whitespace-collapsed, 120-char cap) and update on each
brain-done; the SAME row updates (upsert — the conversation never
spawns a second row, the link survives reloads); "New chat" unlinks,
so the next conversation creates a fresh row;
* **History** — ``/history.html`` lists the saved chats in a full-width
table (Title | Messages | Updated | Actions); the Title cell IS the
Open link (``/?chat=<id>`` — "return to that history with a click"),
@@ -24,9 +28,12 @@ The owner-locked loop under test (A10 extension, 2026-08-29):
session (pixel-identical), links it, and a subsequent Save updates
that row; a deleted/unknown id degrades to the local restore with the
error banner;
* **Anonymous** — no Save button, no History nav link, the History page
shows the gated state WITHOUT ever fetching ``/api/chats`` (the router
403s them — pinned via the request log), and the API 403s.
* **Anonymous** — no Save control (the element is absent from the DOM
at every width — phase 55), but the Share pill IS visible (phase 55
task 03 — the write surface is public, the pill is static markup),
no History nav link, the History page shows the gated state WITHOUT
ever fetching ``/api/chats`` (the router 403s them — pinned via the
request log), and the API 403s.
DB isolation: the shared e2e Postgres keeps ``saved_chats`` rows across
suites, so every test here uses a DISTINCTIVE question text (its
@@ -38,6 +45,7 @@ embeddings); ``saved_chats`` is never touched by the reset.
from __future__ import annotations
import asyncio
import time
from pathlib import Path
from threading import Thread
from typing import Any
@@ -144,15 +152,30 @@ def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
def _save(page: Page) -> None:
"""Press Save and wait for the live-region confirmation (the
never-stale contract: the status line is the success feedback)."""
page.locator("#save-chat-btn").click()
expect(page.locator("#send-status")).to_have_text("Conversation saved.")
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})")
# ---------------------------------------------------------------------------
# 1. Save on the chat page → the row exists (UI + API agree)
# 1. Auto-save on the chat page (no Save control) → the row exists
# (API is the proof — auto-saves are silent, A2)
# ---------------------------------------------------------------------------
@@ -168,21 +191,19 @@ def test_save_and_see_history(
q = "How is my Kubernetes cluster set up? (hist-save)"
_ask(page, q)
# Admin: the Save pill is revealed (ship-hidden, whoami reveals it).
save = page.locator("#save-chat-btn")
expect(save).to_be_visible()
expect(save).to_have_attribute("aria-label", "Save chat")
save.click()
expect(page.locator("#send-status")).to_have_text("Conversation saved.")
# Phase 55: there is NO Save control — the conversation auto-saved
# at the save points (create on the first question, update on the
# brain-done). There is nothing to click and no status line to wait
# on (A2 silent): the API is the proof.
# Also: the pill is gone from the DOM at every width.
expect(page.locator("#save-chat-btn")).to_have_count(0)
cookies = _admin_cookies(page)
created: str | None = None
try:
# The API agrees: the row exists, auto-titled from the first
# question (whitespace-collapsed, <=120 chars), two messages.
row = _find_row(_chats(app_url, cookies), " ".join(q.split())[:120])
assert row is not None, "the saved chat row must exist"
row = _wait_saved_row(app_url, cookies, " ".join(q.split())[:120])
assert row["message_count"] == 2
created = row["id"]
@@ -221,10 +242,10 @@ def test_open_chat_returns_to_history(
# The answer text the History session saw (rendered bubble).
answer_before = page.locator(".msg.brain .bubble").first.inner_text()
_save(page)
# Phase 55: the conversation auto-saved (no Save pill) — wait for
# the row via the admin list.
cookies = _admin_cookies(page)
row = _find_row(_chats(app_url, cookies), q)
assert row is not None
row = _wait_saved_row(app_url, cookies, q)
chat_id = row["id"]
try:
# From the History page, the title IS the Open link…
@@ -267,12 +288,13 @@ def test_open_chat_returns_to_history(
_ask(page, "How is my Kubernetes cluster set up? (hist-open-2)")
expect(page.locator(".msg.user .bubble")).to_have_count(2)
# …and a re-Save UPSERTS: the same single row, count grown to 4.
_save(page)
# …and the next brain-done auto-save UPSERTS: the same single
# row, count grown to 4 (phase 55 — no Save pill).
row2 = _wait_saved_row(app_url, cookies, q, messages=4)
mine = [c for c in _chats(app_url, cookies) if c["title"] == q]
assert len(mine) == 1, "the re-Save must not spawn a second row"
assert mine[0]["id"] == chat_id, "the re-Save updates the SAME row"
assert mine[0]["message_count"] == 4
assert len(mine) == 1, "the auto-save must not spawn a second row"
assert mine[0]["id"] == chat_id, "the auto-save updates the SAME row"
assert row2["message_count"] == 4
finally:
_delete_chat(app_url, cookies, chat_id)
@@ -294,12 +316,10 @@ def test_new_chat_unlinks(
q1 = "How is my Kubernetes cluster set up? (hist-unlink)"
_ask(page, q1)
_save(page)
cookies = _admin_cookies(page)
cleanup: list[str] = []
try:
row1 = _find_row(_chats(app_url, cookies), q1)
assert row1 is not None
row1 = _wait_saved_row(app_url, cookies, q1) # auto-saved (phase 55)
cleanup.append(row1["id"])
# New chat clears the conversation AND unlinks it from the row.
@@ -307,11 +327,12 @@ def test_new_chat_unlinks(
expect(page.locator("#send-status")).to_contain_text("New chat started")
expect(page.locator(".msg")).to_have_count(0)
# A fresh conversation, saved: a NEW row (a create, not the
# previous row's update) — the list now carries two of ours.
# A fresh conversation, auto-saved (phase 55): a NEW row (a
# create, not the previous row's update) — the list now carries
# two of ours.
q2 = "How is my Kubernetes cluster set up? (hist-unlink-2)"
_ask(page, q2)
_save(page)
_wait_saved_row(app_url, cookies, q2) # wait for the fresh auto-save
rows = _chats(app_url, cookies)
mine = [c for c in rows if c["title"] in (q1, q2)]
assert len(mine) == 2, "Save after New chat must create a second row"
@@ -343,10 +364,8 @@ def test_delete_two_step(
q = "How is my Kubernetes cluster set up? (hist-delete)"
_ask(page, q)
_save(page)
cookies = _admin_cookies(page)
row = _find_row(_chats(app_url, cookies), q)
assert row is not None
row = _wait_saved_row(app_url, cookies, q) # auto-saved (phase 55)
chat_id = row["id"]
try:
page.goto(app_url + "/history.html")
@@ -396,7 +415,8 @@ def test_delete_two_step(
# ---------------------------------------------------------------------------
# 5. Anonymous: no Save button, no History nav link, the History page is
# 5. Anonymous: no Save control (absent), the Share pill visible
# (phase 55 task 03), no History nav link, the History page is
# gated WITHOUT fetching /api/chats, and the API 403s
# ---------------------------------------------------------------------------
@@ -414,9 +434,13 @@ def test_anonymous_cannot(
page.goto(app_url + "/")
# Settled anonymous state (the whoami round-trip has landed)…
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
# …and the phase-50 surface is absent for anonymous: no Save pill,
# no History nav link (both ship hidden and stay hidden).
expect(page.locator("#save-chat-btn")).to_be_hidden()
# …and the phase-50 surface is absent for anonymous: no Save control
# (the element is GONE from the DOM — phase 55 — there is nothing
# to hide) and no History nav link (ships hidden and stays hidden)
# — but the Share pill IS visible (phase 55 task 03: the write
# surface is public, the pill is static markup).
expect(page.locator("#save-chat-btn")).to_have_count(0)
expect(page.locator("#share-chat-btn")).to_be_visible()
expect(page.locator("#nav-history")).to_be_hidden()
# Direct visit to the History page: it loads and shows the gated
+562
View File
@@ -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)
+53 -24
View File
@@ -6,15 +6,21 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_share_chat.py -v --no-cov
The owner-locked loop under test (2026-08-29, roadmap confirmation):
The owner-locked loop under test (2026-08-29, roadmap confirmation;
phase 55 replaced the Save pill with auto-save — by the time the answer
settles the conversation is already saved, so the chat page's Share
click is the idempotent share of the linked row):
* **Share from the chat page** — the Share pill (admin-only, ships
hidden) on an UNSAVED conversation saves AND shares in ONE action
(``POST /api/chats`` with ``share: true`` — the row appears in
``GET /api/chats`` with a non-null ``share_url`` of the shape
``/shared/<uuid4>``); the absolute URL is copied to the clipboard,
with the inline-link fallback on a non-secure origin (the assertion
branches on ``navigator.clipboard`` availability);
hidden) on the AUTO-SAVED conversation shares the linked row via
``POST /api/chats/<id>/share`` — the row appears in ``GET
/api/chats`` with a non-null ``share_url`` of the shape
``/shared/<uuid4>`` (the unlinked save-then-share one-action wire
path — ``POST /api/chats`` with ``share: true`` — is pinned by the
integration suite, guest-reachable since phase 55 task 01); the
absolute URL is copied to the clipboard, with the inline-link
fallback on a non-secure origin (the assertion branches on
``navigator.clipboard`` availability);
* **Anonymous view** — a FRESH browser context (a separate session, no
cookies) opening ``/shared/<token>`` sees the full conversation
read-only through the same record shape: title = the auto-title,
@@ -49,6 +55,7 @@ from __future__ import annotations
import asyncio
import re
import time
from pathlib import Path
from threading import Thread
from typing import Any
@@ -163,6 +170,27 @@ def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
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 admin context.
@@ -203,8 +231,9 @@ def _click_share_and_assert_status(page: Page, app_url: str) -> None:
# ---------------------------------------------------------------------------
# 1. Share from the chat page: an UNSAVED conversation is saved + shared
# in one action; the API row carries the /shared/<uuid> link
# 1. Share from the chat page: the AUTO-SAVED conversation is shared
# (the idempotent share of the linked row — phase 55); the API row
# carries the /shared/<uuid> link
# ---------------------------------------------------------------------------
@@ -221,25 +250,28 @@ def test_share_from_chat_page(
_ask(page, q)
# Admin: the Share pill is revealed (ships hidden, whoami reveals
# it — the same block as Save). The conversation is UNSAVED at this
# point: no row exists yet under the auto-title.
# it). Phase 55: the conversation is AUTO-SAVED by the time the
# answer settles (the Save pill is gone) — the row exists under the
# auto-title, and the Share click below shares the linked row.
share = page.locator("#share-chat-btn")
expect(share).to_be_visible()
expect(share).to_have_attribute("aria-label", "Share chat")
cookies = _admin_cookies(page)
assert (
_find_row(_chats(app_url, cookies), _auto_title(q)) is None
), "the conversation is unsaved before the Share click"
row = _wait_saved_row(app_url, cookies, _auto_title(q))
assert row["message_count"] == 2, "auto-saved with both messages before the Share click"
_grant_clipboard(page, app_url)
_click_share_and_assert_status(page, app_url)
created: str | None = None
try:
# The API agrees: ONE action saved AND shared — the new row
# exists with a non-null share_url of the token shape.
# The API agrees: the Share click shared the auto-saved row —
# it carries a non-null share_url of the token shape. (The
# unlinked save-then-share one-action path is pinned by the
# integration suite — from the chat page the conversation is
# always linked by the time there is anything to share.)
row = _find_row(_chats(app_url, cookies), _auto_title(q))
assert row is not None, "the Share click must have saved the conversation"
assert row is not None, "the auto-saved row must exist"
assert row["message_count"] == 2, "the saved conversation holds both messages"
share_url = row.get("share_url")
assert share_url is not None, "the share_url must be present (non-null)"
@@ -370,15 +402,12 @@ def test_share_from_history_and_unshare(
q = "How is my Kubernetes cluster set up? (share-history)"
_ask(page, q)
# Save first (this test drives the History column, not the
# save-then-share one-action path — test 1 covers that).
page.locator("#save-chat-btn").click()
expect(page.locator("#send-status")).to_have_text("Conversation saved.")
# Phase 55: the conversation is already AUTO-SAVED by the time the
# answer settles (no Save pill) — wait for the row via the admin
# list (this test drives the History column).
cookies = _admin_cookies(page)
_grant_clipboard(page, app_url)
row = _find_row(_chats(app_url, cookies), _auto_title(q))
assert row is not None
row = _wait_saved_row(app_url, cookies, _auto_title(q))
chat_id: str = row["id"]
anon_ctx: BrowserContext | None = None
try:
+8 -2
View File
@@ -255,10 +255,16 @@ def test_no_orphan_brain_message_when_navigated_before_first_token(
# Wait until the scratchpad's tail is rendered (phase-17 thinking body,
# ~2 700 chars / ≈4.5s, lengthened in phase 21) — the 4s pre-content
# pause (SLOW_PRETOKEN_TRIGGER) is now running, so the navigation below
# lands inside pure thinking with a wide margin.
# lands inside pure thinking with a wide margin. Explicit timeout:
# the mock streams the tail at ≈12 chars / 0.02s (≈4.7s end-to-end),
# which outruns Playwright's 5s assertion auto-wait on a loaded host
# (the wait started at the FIRST thinking frame — the pre-existing
# race, phase 55 task 02 fix).
thinking = page.locator(".msg.brain").last.locator("details.thinking")
thinking.wait_for(state="attached", timeout=10_000)
expect(thinking.locator(".thinking-text")).to_contain_text(THINKING_TAIL)
expect(thinking.locator(".thinking-text")).to_contain_text(
THINKING_TAIL, timeout=30_000
)
# Still pre-token: the button is the enabled Stop control (phase 48 —
# the old disabled "Thinking…" busy state is gone).
expect(page.locator("#send-btn")).to_be_enabled()
+29 -16
View File
@@ -8,10 +8,11 @@ Run in isolation (DB must be up: ``podman compose up -d db``):
The full user-visible invalidation loop under test:
* **Save (fresh)** — as admin: ask (the mock answers deterministically),
Save via the chat-page pill; ``GET /api/chats`` (admin cookie) reports
the row with ``stale: false``; opening the FRESH row at ``/?chat=<id>``
shows NO banner;
* **Auto-save (fresh, phase 55)** — as admin: ask (the mock answers
deterministically) — the conversation auto-saves at the save points
(the Save pill is gone); ``GET /api/chats`` (admin cookie) reports
the row with ``stale: false``; opening the FRESH row at
``/?chat=<id>`` shows NO banner;
* **KB change** — the test process (which shares the app's environment)
bumps the ``sources_meta`` seed row through ``bump_sources_version``
over a short ``SessionLocal()`` — the exact helper BOTH real sync
@@ -60,6 +61,7 @@ the click.
from __future__ import annotations
import asyncio
import time
import uuid
from pathlib import Path
from threading import Thread
@@ -174,11 +176,25 @@ def _ask(page: Page, question: str) -> None:
expect(page.locator("#send-label")).to_have_text("Send")
def _save(page: Page) -> None:
"""Press Save and wait for the live-region confirmation (the
never-stale contract: the status line is the success feedback)."""
page.locator("#save-chat-btn").click()
expect(page.locator("#send-status")).to_have_text("Conversation saved.")
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 _admin_cookies(page: Page) -> dict[str, str]:
@@ -298,11 +314,10 @@ def test_full_invalidation_loop(
q = "How is my Kubernetes cluster set up? (stale-loop)"
_ask(page, q)
# --- Save (fresh): the API agrees, and a fresh open shows NO banner.
_save(page)
# --- Auto-save (fresh, phase 55 — no Save pill): the API agrees,
# and a fresh open shows NO banner.
cookies = _admin_cookies(page)
row = _find_row(_chats(app_url, cookies), _auto_title(q))
assert row is not None, "the saved chat row must exist"
row = _wait_saved_row(app_url, cookies, _auto_title(q))
assert row["message_count"] == 2
assert row["stale"] is False, "a just-saved chat is fresh, not stale"
chat_id: str = row["id"]
@@ -467,11 +482,9 @@ def test_anonymous_shared_snapshot_has_no_staleness_surface(
q = "How is my Kubernetes cluster set up? (stale-share)"
_ask(page, q)
_save(page)
cookies = _admin_cookies(page)
row = _find_row(_chats(app_url, cookies), _auto_title(q))
assert row is not None
row = _wait_saved_row(app_url, cookies, _auto_title(q)) # auto-saved (phase 55)
chat_id: str = row["id"]
anon_ctx: BrowserContext | None = None
try: