"""Phase 75 story E2E (Playwright): "Save as doc" captures the WHOLE chat session. TODO.md L4 (owner 2026-09-05): "Then, update the 'save as doc' process to include the output from the entire chat session rather than the last response. The user can edit out anything they don't want to keep from previous replies." Run in isolation (DB must be up: ``podman compose up -d db``; ``git`` on PATH — the suite skips without it): uv run pytest tests/e2e/test_save_doc_session.py -v --no-cov The loop under test: a MULTI-turn session (three DISTINCT on-topic questions in one chat page — the mock's default composed answer embeds each question's first 80 chars, so the three answers are byte-distinct and assertable) → "Save as doc" on any completed brain bubble drafts the FULL-SESSION transcript (phase 75 A6: ``## N. `` + the answer's raw markdown, every turn up to the click, in order — NOT just the clicked bubble) → the edit screen shows it prefilled → the user edits an unwanted previous reply OUT of the body (A7: the existing free-form body field) → Push commits + pushes to the ``.env``- configured branch of the ``.env``-configured repo. Every success assertion reads the **bare repo itself** (``git show :`` == the EDITED body byte-for-byte; ``git rev-parse`` for the sha the UI reported) — the UI text is only the entry point (the phase-59 convention, D3: no PR is ever created or attempted). Turn 1 is asked with the phase-17 ``think out loud`` trigger, so its brain record carries a ``thinking`` block (the deterministic scratchpad) — the transcript must EXCLUDE it (A6: only the raw ``m.text`` travels), and both the draft body and the pushed file are asserted free of the scratchpad text. App boots (the conftest pattern, module-scoped — as in ``test_response_to_docs.py``): * the module app boots with ``BOR_DOCS_REPO=/docs.git`` (a local BARE repo seeded with one commit on ``main``), ``BOR_DOCS_BRANCH= bor-docs``, ``BOR_DOCS_BASE_BRANCH=main``, ``BOR_DOCS_WORK_DIR= /docs-work``; * the KB is the ``tests/fixtures/docs/`` set (the ``test_chat_rag.py`` fixture) — the three questions gate HIGH, so every turn is a grounded answer with the deterministic marker. Test → story mapping (Playwright Mapping Rule): 1. ``test_full_session_save_and_edit_out`` 2. ``test_earlier_bubble_button_saves_whole_session`` """ from __future__ import annotations import asyncio import json import os import re import subprocess import sys from collections.abc import Iterator from pathlib import Path from types import SimpleNamespace from typing import Any from urllib.parse import parse_qs, urlsplit import httpx import pytest from playwright.sync_api import Locator, 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 from e2e.conftest import ( ADMIN_PASSWORD, APP_PORT, SESSION_SECRET, USE_REAL_LLM, _wait_http, ) REPO = Path(__file__).resolve().parents[2] FIXTURES = REPO / "tests" / "fixtures" / "docs" APP_URL = f"http://127.0.0.1:{APP_PORT}" BRANCH = "bor-docs" BASE_BRANCH = "main" #: Three DISTINCT on-topic questions in ONE session — the house #: phrasings proven HIGH-gate in other suites, so every turn renders a #: grounded answer with the deterministic marker (never a deflection). #: Turn 1 carries the phase-17 thinking trigger (its brain record then #: carries a ``thinking`` block the transcript must exclude); the #: phase-74 suite pins the exact grounded behavior of this phrasing. Q1 = "think out loud — how is my Kubernetes cluster set up?" Q2 = "What is in the new-service deployment?" Q3 = "How did I install gitlab?" MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E" #: Fixed lines of the mock's deterministic scratchpad #: (``mock_llm.compose_thinking``) — present in turn 1's persisted #: ``thinking`` block, and ABSENT from every composed answer and from #: the questions themselves, so their absence from the draft body and #: the pushed file proves the thinking block never reached the doc. THINKING_LINES = ( "Step 1: Read the question carefully", "Scratch 3: versions and ports are the facts", ) #: The edit screen's URL shape (the save action navigates with the #: uuid4 token). DRAFT_URL_RE = re.compile(r"/doc-edit\.html\?draft=[0-9a-f-]{36}") #: The success line (doc-edit.js): `Pushed to — commit .` SUCCESS_SHA_RE = re.compile(r"commit ([0-9a-f]{7})\.$") def _git_available() -> bool: try: return subprocess.run( ["git", "--version"], capture_output=True, timeout=10 ).returncode == 0 except (FileNotFoundError, subprocess.TimeoutExpired): return False pytestmark = pytest.mark.skipif( not _git_available(), reason="git is not on PATH (the docs push is real git)" ) def _git(args: list[str], cwd: Path | None = None) -> str: """One git command (the bare repo is the source of truth); fail loud.""" proc = subprocess.run( ["git", *args], cwd=cwd, capture_output=True, text=True, timeout=60 ) assert proc.returncode == 0, f"git {' '.join(args)} failed: {proc.stderr}" return proc.stdout def _branch_tip(bare: Path) -> str | None: """The branch's tip sha, or ``None`` while the branch does not exist yet (a cancel-only test may run before any push created it).""" proc = subprocess.run( ["git", "-C", str(bare), "rev-parse", BRANCH], capture_output=True, text=True, timeout=30, ) return proc.stdout.strip() if proc.returncode == 0 else None def doc_slug(title: str) -> str: """The app.js slug rule (phase 59 locked assumption), ported: lowercase, runs of non-alphanumerics → ``-``, trimmed, ≤60 chars, empty → ``note`` (the trailing trim survives a mid-dash 60-cut).""" slug = ( re.sub(r"[^a-z0-9]+", "-", title.lower()) .strip("-")[:60] .rstrip("-") ) return slug or "note" def session_transcript(turns: list[tuple[str, str]]) -> str: """The phase-75 draft body (app.js ``buildSessionTranscript``, A6) for an N-turn session, as STORED: a numbered section per user turn (``## N. `` + blank line + the answer's raw markdown), sections blank-line separated. The builder's single trailing newline is stripped by the draft API's ``.strip()`` (and the edit screen's push trims again), so the stored — and pushed — bytes end at the last answer's last char (``rstrip`` mirrors both).""" sections = [ f"## {i}. {question}\n\n{answer}" for i, (question, answer) in enumerate(turns, start=1) ] return "\n\n".join(sections).rstrip() # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(scope="module") def docs_repo(tmp_path_factory: pytest.TempPathFactory) -> SimpleNamespace: """The local BARE docs repo (the .env remote, D3-generic): one seed commit (``README.md``) pushed as ``main``. ``work`` is where the app's ``BOR_DOCS_WORK_DIR`` checkout lands (it persists for the whole module — the push exercises the existing-checkout path).""" base = tmp_path_factory.mktemp("docs-git") bare = base / "docs.git" _git(["init", "--bare", str(bare)]) seed = base / "seed" _git(["init", "-b", "main", str(seed)]) (seed / "README.md").write_text("# e2e docs repo\n", encoding="utf-8") _git(["add", "--", "README.md"], cwd=seed) # -c identity + no GPG signing: the machine's global git config # (gpgsign=true here) must not leak into the fixture. _git( [ "-c", "user.name=E2E Seeder", "-c", "user.email=e2e@local", "-c", "commit.gpgsign=false", "commit", "-m", "seed: README", ], cwd=seed, ) _git(["remote", "add", "origin", str(bare)], cwd=seed) _git(["push", "origin", "main"], cwd=seed) return SimpleNamespace(bare=bare, work=base / "docs-work") def _spawn_app(port: int, mock_port: int, docs_env: dict[str, str]) -> subprocess.Popen: """One uvicorn boot (the conftest app_server env shape, the ``test_response_to_docs.py`` pattern).""" env = dict(os.environ) env.pop("DEBUGPY", None) env["BOR_ENVIRONMENT"] = "e2e" env["BOR_STATIC_DIR"] = str(REPO / "frontend") env["BOR_LLM_BASE_URL"] = ( "https://aipi.reeseapps.com/v1" if USE_REAL_LLM else f"http://127.0.0.1:{mock_port}/v1" ) # Mock-calibrated threshold (conftest pattern): the fixture questions # gate HIGH, so every turn is a grounded answer with the marker. env["BOR_RELEVANCE_THRESHOLD"] = "0.30" env.setdefault( "BOR_DATABASE_URL", "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese", ) # Phase 16: admin auth must be set or create_app() refuses to boot. env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD env["BOR_SESSION_SECRET"] = SESSION_SECRET env.update(docs_env) return subprocess.Popen( [sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"], cwd=REPO, env=env, ) def _stop(proc: subprocess.Popen) -> None: proc.terminate() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() @pytest.fixture(scope="module") def app_server(mock_llm: int, docs_repo: SimpleNamespace) -> Iterator[str]: """The configured app under test (module scope — shadows the conftest session app; an isolated run never starts two).""" proc = _spawn_app( APP_PORT, mock_llm, { "BOR_DOCS_REPO": str(docs_repo.bare), "BOR_DOCS_BRANCH": BRANCH, "BOR_DOCS_BASE_BRANCH": BASE_BRANCH, "BOR_DOCS_WORK_DIR": str(docs_repo.work), }, ) try: _wait_http(f"{APP_URL}/api/health") yield APP_URL finally: _stop(proc) @pytest.fixture(scope="module") def app_url(app_server: str) -> str: return app_server # --------------------------------------------------------------------------- # KB + table hygiene (the E2E isolation pattern — this suite owns the # KB tables and doc_drafts; both are reset around every test) # --------------------------------------------------------------------------- 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 (the Playwright sync API keeps an asyncio loop on the test thread — the test_chat_rag.py helper).""" import threading 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 = threading.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) -> None: with SessionLocal() as db: db.execute(text("TRUNCATE chunks, documents, query_log, doc_drafts")) db.commit() if seed: summary = _run_in_thread(_import_fixtures(mock_port)) assert summary.added == 13 # the A9 fixture set (test_chat_rag.py) @pytest.fixture(autouse=True) def _kb_and_clean_drafts(mock_llm: int, db_ready: None) -> Iterator[None]: """Fresh KB (the deterministic mock embeddings — the grounded questions gate HIGH) + an empty ``doc_drafts`` table per test.""" _reset_db(mock_llm, seed=True) yield _reset_db(mock_llm, seed=False) # --------------------------------------------------------------------------- # Story helpers # --------------------------------------------------------------------------- def _stream_chat_answer(app_url: str, message: str) -> str: """Replay one turn through the raw SSE endpoint (the ``test_chat_rag.py`` transport pattern) and return the EXACT answer text — the markdown source the UI accumulates into ``m.text``, byte-identical for the deterministic mock. The mock's composed answer is a pure function of the LAST user message + the document context (both identical whether or not the browser's phase-74 history rode along), so a bare replay recovers the same bytes the multi-turn browser session rendered.""" frames: list[dict[str, Any]] = [] with httpx.stream( "POST", f"{app_url}/api/chat", json={"message": message}, timeout=120.0 ) as r: assert r.status_code == 200 buf = "" for part in r.iter_text(): buf += part while "\n\n" in buf: frame, buf = buf.split("\n\n", 1) if frame.strip().startswith("data:"): frames.append( json.loads(frame.strip().removeprefix("data:").strip()) ) deltas = [f for f in frames if f.get("type") == "delta"] assert deltas, "the SSE stream must deliver deltas" return "".join(d["text"] for d in deltas) def _ask(page: Page, question: str) -> None: """One grounded turn to its DONE state — the phase-74 pattern: the LAST user bubble carries the question, the LAST brain bubble the marker, and the Send button is re-enabled (``done`` settled the turn; the meta-row buttons have landed).""" 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=60_000 ) expect(page.locator("#send-label")).to_have_text("Send") def _session(page: Page, app_url: str) -> list[tuple[str, str]]: """Drive the three-turn session and return each turn's EXACT answer bytes (distinct: the composed answer embeds the question's first 80 chars, and the three questions differ).""" _ask(page, Q1) _ask(page, Q2) _ask(page, Q3) answers = [ _stream_chat_answer(app_url, q) for q in (Q1, Q2, Q3) ] for q, a in zip((Q1, Q2, Q3), answers, strict=True): assert q in a, f"the composed answer must quote its question: {a!r}" assert MOCK_ANSWER_MARKER in a assert len(set(answers)) == 3, "the three answers must be byte-distinct" return list(zip((Q1, Q2, Q3), answers, strict=True)) def _login_admin(page: Page, app_url: str) -> None: """Real form login landing on the chat (admin settled).""" login(page, app_url, next="/") expect(page).to_have_url(app_url + "/", timeout=30_000) expect(page.locator("#sign-out-btn")).to_be_visible(timeout=30_000) def _open_edit_screen(page: Page, btn: Locator) -> str: """Click ONE bubble's save action, wait for the navigation, return the draft token from the URL (the uuid4 credential).""" btn.click() page.wait_for_url(DRAFT_URL_RE, timeout=30_000) token = parse_qs(urlsplit(page.url).query).get("draft", [""])[0] assert re.fullmatch(r"[0-9a-f-]{36}", token), f"no draft token in {page.url}" expect(page.locator("#doc-edit-gate")).to_be_hidden(timeout=30_000) expect(page.locator("#doc-edit-content")).to_be_visible(timeout=30_000) return token def _push_and_read_sha(page: Page) -> tuple[str, str]: """Submit the edit screen's push; wait for the success line and return (branch, sha7) exactly as the live region reported them.""" page.click("#push-doc-btn") status = page.locator("#push-status") expect(status).to_contain_text(f"Pushed to {BRANCH}", timeout=60_000) line = status.inner_text().strip() m = SUCCESS_SHA_RE.search(line) assert m, f"the success line carries no commit sha: {line!r}" return BRANCH, m.group(1) def _assert_no_thinking_leak(body: str) -> None: """A6: the transcript carries ONLY the raw m.text of each record — turn 1's persisted ``thinking`` block (the deterministic scratchpad) must never reach the document.""" for line in THINKING_LINES: assert line not in body, f"thinking scratchpad text leaked into the doc: {line!r}" # --------------------------------------------------------------------------- # 1. The whole loop: 3 turns → save → all turns in order → edit a # previous reply out → push → the bare repo agrees byte-for-byte # --------------------------------------------------------------------------- def test_full_session_save_and_edit_out( page: Page, app_url: str, mock_llm: int, db_ready: None, docs_repo: SimpleNamespace, ) -> None: page.set_default_timeout(30_000) _login_admin(page, app_url) turns = _session(page, app_url) (q1, a1), (q2, a2), (q3, a3) = turns # Every completed brain bubble carries the bottom-right action… expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(3) # …and the one on the LAST bubble opens the edit screen… _open_edit_screen(page, page.locator(".msg.brain .save-as-doc-btn").last) # Title / path: UNCHANGED by phase 75 — the last question (the # phase-50 auto-title convention; Q3 is whitespace-free and # ≤120 chars, so it arrives verbatim) + docs/.md. expect(page.locator("#draft-title")).to_have_value(q3) expect(page.locator("#draft-path")).to_have_value(f"docs/{doc_slug(q3)}.md") # Body: the WHOLE session (A6) — ## 1./## 2./## 3. IN ORDER, each # followed by that turn's answer byte-exact against the mock # (never the clicked bubble alone, never rendered HTML). expected = session_transcript(turns) expect(page.locator("#draft-body")).to_have_value(expected) body = page.input_value("#draft-body") i1, i2, i3 = ( body.index(f"## {i}. {q}") for i, q in ((1, q1), (2, q2), (3, q3)) ) assert i1 < i2 < i3, "the sections must appear in session order" for _q, a in turns: assert a in body, f"an answer is missing from the transcript: {a[:60]!r}…" _assert_no_thinking_leak(body) assert "<" not in body and ">" not in body, ( "the draft body must be markdown, not HTML" ) # Edit out a PREVIOUS reply (A7: the free-form body field is the # user's means) — delete the entire section-2 block (heading + # answer) and push. edited = f"## 1. {q1}\n\n{a1}\n\n## 3. {q3}\n\n{a3}".rstrip() page.fill("#draft-body", edited) branch, sha7 = _push_and_read_sha(page) assert branch == BRANCH # GIT-VERIFY: the bare repo's file is EXACTLY the edited body… path = f"docs/{doc_slug(q3)}.md" shown = _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path}"]) assert shown == edited # …section 2 is provably gone (both question and answer)… assert q2 not in shown, "section 2's question survived the edit-out" assert a2 not in shown, "section 2's answer survived the edit-out" # …sections 1 and 3 are byte-exact and in order… assert shown.index(f"## 1. {q1}") < shown.index(f"## 3. {q3}") assert a1 in shown and a3 in shown # …and the UI's sha prefix is the branch's real tip. tip = _git(["-C", str(docs_repo.bare), "rev-parse", BRANCH]).strip() assert tip.startswith(sha7), f"UI sha {sha7} != bare repo tip {tip}" _assert_no_thinking_leak(shown) # --------------------------------------------------------------------------- # 2. The button on an EARLIER bubble still drafts the whole session # (A6: the transcript is the session at click time, not the bubble), # and canceling leaves the repo untouched # --------------------------------------------------------------------------- def test_earlier_bubble_button_saves_whole_session( page: Page, app_url: str, mock_llm: int, db_ready: None, docs_repo: SimpleNamespace, ) -> None: page.set_default_timeout(30_000) _login_admin(page, app_url) turns = _session(page, app_url) (q1, _a1), (_q2, _a2), (q3, _a3) = turns expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(3) # The branch tip BEFORE the attempt (None while no push has ever # created it — the test must pass in file order AND alone). tip_before = _branch_tip(docs_repo.bare) # Click the save action on the FIRST brain bubble — the draft must # still carry the ENTIRE session (all three sections, byte-exact)… _open_edit_screen( page, page.locator(".msg.brain .save-as-doc-btn").first ) # …with the UNCHANGED title (the last question, not the first). expect(page.locator("#draft-title")).to_have_value(q3) expect(page.locator("#draft-path")).to_have_value(f"docs/{doc_slug(q3)}.md") body = session_transcript(turns) expect(page.locator("#draft-body")).to_have_value(body) _assert_no_thinking_leak(body) # …then cancel out (Back to chat — NO push): the branch is # untouched — same tip as before (or still absent). page.click("#doc-edit-content a.doc-edit-back") page.wait_for_url(APP_URL + "/", timeout=30_000) tip_after = _branch_tip(docs_repo.bare) assert tip_after == tip_before, ( f"canceling moved the docs branch: {tip_before} -> {tip_after}" )