Files
brain-of-reese/tests/e2e/test_save_doc_session.py
T
ducoterra 0e4651c779
Build and Push Containers / build-and-push-app (push) Successful in 1m46s
Build and Push Containers / build-and-push-db (push) Successful in 12s
feat(docs): save the whole chat session as a doc
Phase 75 (TODO.md L4): "Save as doc" now drafts a document from the
ENTIRE chat session — every question and answer up to the click, in
order — instead of only the clicked bubble's answer; the existing
doc-edit screen's free-form body editing is how the user edits out
anything they don't want to keep from previous replies (no new UI
surface).

Task 01 (frontend):
- app.js buildSessionTranscript(): walks the bor.chat.v1 conversation
  record in order — a numbered section per user turn ("## N.
  <question, raw>" + blank line + the raw answer text; more answers
  join under the same heading), sections blank-line separated, all
  trailing whitespace collapsed to one final newline. Only the raw
  persisted text travels (m.who + m.text — no thinking blocks, no
  source chips, no tune metadata); a brain record before the first
  user record is skipped; a heading-only section marks a user turn
  whose answer never landed (A6, owner-confirmed 2026-09-08).
- saveAsDoc(btn): the draft body is buildSessionTranscript(); the
  dead single-bubble markdown parameter is dropped (the button's
  appendSaveAsDocButton signature is unchanged — one button per
  bubble). Title/path/double-click guard/hand-off are unchanged
  (defaultDocTitle: the last question, whitespace-collapsed,
  <=120 chars; docs/<slug>.md).
- Unit: the app.js source pins move to the transcript shape (whole
  session, no thinking, no dead parameter).

Task 02 (E2E):
- tests/e2e/test_save_doc_session.py (bare-repo fixture, the
  phase-59 convention — git as source of truth): three DISTINCT
  on-topic turns in one session (turn 1 carries the phase-17
  "think out loud" trigger so its record has a thinking block the
  transcript must exclude) -> save on the LAST bubble -> the
  prefilled body is ## 1./## 2./## 3. in order, byte-exact against
  the deterministic mock, thinking-free -> edit the whole
  section-2 block out of the body -> push -> git show
  bor-docs:<path> equals the EDITED body byte-for-byte (section 2's
  question and answer provably absent; sections 1 and 3 byte-exact;
  the UI's sha prefix is git rev-parse bor-docs). Second test:
  the button on the FIRST bubble still drafts the whole session
  (A6 — the transcript is the session at click time, title stays
  the last question); canceling leaves the branch tip untouched.
- tests/e2e/test_response_to_docs.py: the phase-59 single-turn body
  expectation moves to the transcript shape ("## 1. <question>" +
  the answer's markdown) — the rest of the suite unchanged.

Also lands the phase-74 file moves (00_phase.md /
03_mock_marker_e2e.md -> complete/) and the phase reports — the
house convention of committing .agents/ with the phase.
2026-09-05 16:59:31 -04:00

550 lines
21 KiB
Python

"""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. <question>`` + 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 <branch>:<path>``
== 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=<tmp>/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=
<tmp>/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 <branch> — commit <sha7>.`
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. <question>`` + 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/<slug>.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}"
)