feat(chat): save by default + share anonymously — auto-saved chats, guest-facing Share, success toast, action row
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user