Files
brain-of-reese/tests/e2e/test_share_chat.py
T
ducoterra a5b63f83ad
Build and Push Containers / build-and-push-app (push) Successful in 2m1s
Build and Push Containers / build-and-push-db (push) Successful in 18s
phase: 119_name_signal_read_chips
All verification complete. Final report:

**Phase 119 final verification pass — all criteria verified, one stale pin fixed.**
- Verified implementation of all 6 tasks: D1 component name-hit rule (`name_hit` flag, titles never matched, retired length tie-break), D2 `BOR_NAME_HIT_BONUS` (0.005 default, 0 = byte-identical kill switch, negative fails startup, selection-layer only, `eval_retrieval` `suggested:` line), D3 suggested-folder lines (after `SUGGEST_INTRO`, before first block), D4 cite-discipline `SUGGEST_INTRO` sentence (PERSONA/LOW/`TOOLS_SECTION` byte-pins intact), D5 `done.sources` = read docs only (frontend no-op on empty confirmed), D6 mock `repeat your folder map` echo + new suite + telemetry.
- Battery (replica restored per skill, fingerprint docs=1000/chunks=8866 verified, `eval_retrieval --from-file tests/fixtures/retrieval_battery.txt` re-run): **GATE PASS** — gitea README #4 in suggested top-5, forgejo 5/5 (README #1), gateway README in top-5 (#4), qwen3.8-27b quadlets top-5, Mongolia HIGH/fts=5 unchanged.
- New E2E in isolation: `4 passed` ×2 (deterministic). All 27 modified E2E suites in isolation: 26 green; **1 stale pin fixed** — `test_source_chip_quality.py` durable-record order pin pre-dated the D1 re-rank (`aliases` stem sub-component name-hits `ssh_aliases.txt`, deterministically lifting `backups.md` over `kubernetes.md`; probe-verified 0.016277 vs 0.016036, 4/4 stable) — re-pinned with the phase-119 rationale; suite green ×2.
- Gates: `uv run pytest --cov=app --cov-report=term-missing` → **2547 passed, app coverage 99%** (>90%); `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors.
- Completion criteria: 1 ✅ (battery, recorded), 2 ✅ (folder lines; block/LOW byte-identical pins green), 3 ✅ (read-only chips, zero-read chips nothing, related row + durable record untouched — unit+E2E agree), 4 ✅ (all green), 5 → commit/phase-move left to the harness per pass rules (nothing committed).
- Deviations: battery output + real-model telemetry recorded in `.agents/reports/119_name_signal_read_chips/task06_battery_and_e2e.md` and `TOOL_CALLING_TESTING.md` §11 (task files in `complete/` are immutable to this pass); gateway canonical doc at #4 vs overview's #3 was already documented at task 06 (containment gate met).
- Next pending phase: **none** — `todo/` holds only phase 119.
2026-09-16 15:50:48 -04:00

527 lines
22 KiB
Python

"""Phase 51 E2E (Playwright): share a chat by link — anonymous view.
TODO.md L6 (owner 2026-08-29): "Need a way to share a chat with a link
so others can see it anonymously."
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;
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 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,
user + brain bubbles (the same deterministic answer text the admin
session saw), the thinking block RESTORED COLLAPSED, ZERO source
chips of any kind — the shared turn read nothing, so (phase 119,
LOCKED A1) its ``done`` sources are empty and nothing re-renders as a
chip; where chips exist they are always PLAIN TEXT (zero
``a.source-chip`` — guests cannot open documents, the documents API
is admin-only) — and ZERO interactive controls anywhere (no
composer, no Save/Share pills, no Tune/Retry, no button chips); the
nav's admin-only links stay hidden for a guest;
* **Share from History + unshare** — the History row's Share column:
"Create link" → Copy + Unshare; Unshare is the inline two-step
(no native dialog); after Yes the cell returns to "Create link",
the SAME URL now shows the "invalid or was revoked" state in a
fresh anonymous context, and ``GET /api/chats/<id>`` no longer
carries ``share_url`` (the omission rule — the key is absent, not
null);
* **Bad token** — a well-formed but unknown token renders the invalid
state with no JS crash and the guest header (the page route serves
the HTML for any well-formed token; the client's 404 read drives
the invalid card).
DB isolation: the shared e2e Postgres keeps ``saved_chats`` rows
across suites, so every test here uses a DISTINCTIVE question text
(its auto-title is therefore unique), never asserts on absolute row
counts, and deletes the rows it creates in a ``finally`` (admin
cookie). The KB tables are truncated + re-seeded the house way
(deterministic mock embeddings); ``saved_chats`` is never touched by
the reset.
"""
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, task 03).
INVALID_TEXT = "This share link is invalid or was revoked."
BAD_TOKEN = "00000000-0000-4000-8000-000000000000"
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 _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 _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 admin context.
``http://127.0.0.1`` is a secure context, so ``navigator.clipboard``
exists — but headless Chromium still requires the permission grant
before ``writeText`` resolves (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 _click_share_and_assert_status(page: Page, app_url: str) -> None:
"""Press the chat page's Share pill and pin the owner-locked
outcome on the live region: "Share link copied." when
``navigator.clipboard`` is available in the context, else the
inline fallback link field carrying the ``/shared/<uuid>`` URL.
The branch is on the API's availability (task spec)."""
page.locator("#share-chat-btn").click()
if page.evaluate("() => !!navigator.clipboard"):
expect(page.locator("#send-status")).to_have_text(
"Share link copied.", timeout=15_000
)
expect(page.locator(".share-link-fallback")).to_have_count(0)
else:
expect(page.locator("#send-status")).to_have_text(
"Share link ready — copy it from the field.", timeout=15_000
)
field = page.locator(".share-link-fallback")
expect(field).to_be_visible()
href = field.get_attribute("href")
assert href is not None
assert href.startswith(app_url)
assert SHARE_URL_RE.fullmatch(href.removeprefix(app_url))
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
def test_share_from_chat_page(
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? (share-chat)"
_ask(page, q)
# Admin: the Share pill is revealed (ships hidden, whoami reveals
# 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)
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: 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 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)"
assert SHARE_URL_RE.fullmatch(share_url), f"bad share_url shape: {share_url}"
created = row["id"]
finally:
if created is not None:
_delete_chat(app_url, cookies, created)
# ---------------------------------------------------------------------------
# 2. The anonymous shared view: a FRESH context (no cookies) sees the
# full conversation read-only — thinking collapsed, plain-text chips,
# zero controls, the guest header
# ---------------------------------------------------------------------------
def test_anonymous_shared_view(
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="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
# "think out loud" → the mock streams reasoning first, so the saved
# record (and the shared view) carries a thinking block.
q = "think out loud — how is my kubernetes cluster set up? (share-view)"
_ask(page, q)
# The answer text the admin session saw — the shared view must
# render the SAME deterministic text (same record, same renderer).
answer = page.locator(".msg.brain .bubble").first.inner_text()
_grant_clipboard(page, app_url)
_click_share_and_assert_status(page, app_url)
cookies = _admin_cookies(page)
row = _find_row(_chats(app_url, cookies), _auto_title(q))
assert row is not None and row.get("share_url")
share_url: str = row["share_url"]
created: str | None = row["id"]
anon_ctx: BrowserContext | None = None
try:
# A FRESH context: a separate session with no cookies — the
# guest's only credential is the token in the URL.
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + share_url)
# Title = the auto-title (the shared chat's h1).
expect(anon.locator("#shared-title")).to_have_text(_auto_title(q))
# The conversation rendered: the user question bubble + the
# brain answer with the SAME deterministic text the admin saw.
expect(anon.locator(".msg.user .bubble")).to_have_count(1)
expect(anon.locator(".msg.user .bubble")).to_contain_text(q)
bubble = anon.locator(".msg.brain .bubble").first
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
assert bubble.inner_text() == answer, "the shared answer must match the admin's"
# The thinking block exists and is RESTORED COLLAPSED (the
# phase-17 restore convention — a closed <details> has no
# `open` attribute).
think = anon.locator("details.thinking")
expect(think).to_have_count(1)
expect(think.first).not_to_have_attribute("open")
# Source chips: ZERO — the shared turn read nothing, so
# (phase 119, LOCKED A1) its done sources are empty and the
# shared page re-renders nothing (the retired phase-118 A4
# suggested+read union is gone); and where chips DO exist they
# are always plain text: zero <a.source-chip> anywhere (a
# guest cannot open documents; the documents API is admin-only).
expect(anon.locator(".msg.brain .source-chip")).to_have_count(0)
expect(anon.locator("a.source-chip")).to_have_count(0)
# ZERO interactive controls anywhere in the conversation: no
# composer, no Save/Share pills, no Tune/Retry, and (were the
# turn deflected) the "Maybe try" chips would be spans — never
# buttons.
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)
# The nav's admin-only links stay hidden for a guest (and the
# Sign in link is what the guest gets instead).
for admin_link in ("#nav-sources", "#nav-git-sources", "#nav-tuning", "#nav-history"):
expect(anon.locator(admin_link)).to_be_hidden()
expect(anon.locator("#sign-in-link")).to_be_visible(timeout=15_000)
finally:
if anon_ctx is not None:
anon_ctx.close()
if created is not None:
_delete_chat(app_url, cookies, created)
# ---------------------------------------------------------------------------
# 3. Share from the History row + unshare: Create link → Copy/Unshare →
# the two-step confirm revokes — the same URL goes invalid and the
# API drops share_url
# ---------------------------------------------------------------------------
def test_share_from_history_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)
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
q = "How is my Kubernetes cluster set up? (share-history)"
_ask(page, q)
# 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 = _wait_saved_row(app_url, cookies, _auto_title(q))
chat_id: str = row["id"]
anon_ctx: BrowserContext | None = None
try:
# History: the row's Share cell ships in the unshared state —
# the single "Create link" button.
page.goto(app_url + "/history.html")
tr = page.locator(
"#history-tbody tr", has=page.locator(f"a[href='/?chat={chat_id}']")
)
create = tr.locator("button.history-share-create")
expect(create).to_be_visible(timeout=15_000)
expect(create).to_have_text("Create link")
# Create link → the cell re-renders to the shared state
# (Copy + Unshare) and the outcome lands on the live region
# (clipboard or the inline field — either success line).
create.click()
expect(tr.locator("button.history-share-copy")).to_be_visible(timeout=15_000)
expect(tr.locator("button.history-unshare")).to_be_visible()
expect(page.locator("#history-status")).to_contain_text("Share link")
# The API agrees: the row now carries the share link…
r = httpx.get(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
assert r.status_code == 200
share_url = r.json().get("share_url")
assert share_url is not None
assert SHARE_URL_RE.fullmatch(share_url)
# …and the FRESH anonymous context renders the conversation at
# the URL.
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + share_url)
expect(anon.locator("#shared-title")).to_have_text(_auto_title(q))
expect(anon.locator(".msg.user .bubble")).to_contain_text(q)
expect(anon.locator(".msg.brain .bubble").first).to_contain_text(
MOCK_ANSWER_MARKER, timeout=30_000
)
anon_ctx.close()
anon_ctx = None
# Unshare: the inline two-step (the phase-50 confirm pattern —
# no native dialog; the pair appearing is the pinned contract).
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()
expect(tr.locator(".history-confirm-no")).to_be_visible()
tr.locator(".history-confirm-yes").click()
# The cell returns to the unshared state + the live region.
expect(tr.locator("button.history-share-create")).to_be_visible(timeout=15_000)
expect(tr.locator("button.history-share-copy")).to_have_count(0)
expect(page.locator("#history-status")).to_have_text(f'Unshared "{q}".')
# The SAME URL is revoked now: a fresh anonymous context sees
# the invalid state (no data rendered).
anon_ctx = browser.new_context()
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + share_url)
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)
anon_ctx.close()
anon_ctx = None
# The API agrees: the row is unshared — share_url is ABSENT
# (the omission rule: no null in the wire shape).
r = httpx.get(f"{app_url}/api/chats/{chat_id}", 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()
_delete_chat(app_url, cookies, chat_id)
# ---------------------------------------------------------------------------
# 4. A well-formed but unknown token: the invalid state, no JS crash,
# the guest header renders
# ---------------------------------------------------------------------------
def test_bad_token_invalid_state(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
# No DB reset / no login: the test is anonymous by construction
# (a fresh context from the page fixture) and the page route serves
# the HTML for any well-formed token — the 404 comes from the
# public API read, which needs the DB up (db_ready).
page.set_default_timeout(30_000)
js_errors: list[str] = []
page.on("pageerror", lambda e: js_errors.append(str(e)))
# The page route serves the page (200 HTML) for the well-formed
# token — the invalid state is rendered by the client after its
# 404 API read, not a server error page.
doc = page.goto(app_url + f"/shared/{BAD_TOKEN}")
assert doc is not None
assert doc.status == 200
expect(page.locator("#shared-invalid")).to_be_visible(timeout=15_000)
expect(page.locator("#shared-invalid")).to_contain_text(INVALID_TEXT)
# The title keeps its static fallback and nothing rendered.
expect(page.locator("#shared-title")).to_have_text("Shared conversation")
expect(page.locator(".msg")).to_have_count(0)
# The guest header rendered: Sign in visible, the admin-only nav
# links hidden.
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
for admin_link in ("#nav-sources", "#nav-git-sources", "#nav-tuning", "#nav-history"):
expect(page.locator(admin_link)).to_be_hidden()
# No crash: no uncaught page errors.
assert not js_errors, f"the invalid-state render must not throw: {js_errors}"