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.
This commit is contained in:
@@ -0,0 +1,549 @@
|
||||
"""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}"
|
||||
)
|
||||
Reference in New Issue
Block a user