All green — this was the final verification pass; everything from the four completed tasks was already in the working tree and verified. **Phase 115 — Doc drafts: Discard + DELETE route + title fix — verification report** - Verified all 4 task deliverables present: DELETE route (`app/api/doc_drafts.py`), Discard UI (`doc-edit.html` + `doc-edit.js` + `.discard-draft` CSS), title fix (`defaultDocTitle(wrap)` pairing + `saveAsDoc` call site), and all test pins (integration, frontend unit, E2E). No code changes needed. - **Completion criteria:** 1. ✅ Orphaned draft discardable from edit screen; row gone — `test_delete_removes_row_and_invalidates_token` (204 → GET 404), unknown-token 404, admin-gate 403 on all routes, E2E `test_discard_draft_from_edit_screen` all pass. 2. ✅ Title after retry redo = redone answer's own question — E2E `test_save_title_is_the_redo_question_after_retry` passes. 3. ✅ Push flow byte-identical — `git diff` shows only the new DELETE route + module docstring; all 7 existing push tests green. 4. ✅ `uv run pytest --cov=app` → **2457 passed**, app coverage **99%** (>90%); `uv run pytest tests/e2e/test_save_doc_session.py -v --no-cov` → **4 passed**; `uv run ruff check .` → clean; `uv run pyright` → 0 errors. 5. ⏳ Commit + phase-dir move left to the harness (per executor rules, no `git` run; all changes left in the working tree). - No defects found; no deviations. - Next pending phase: none in `todo/` other than this one (`115_doc_draft_discard` is the last).
742 lines
30 KiB
Python
742 lines
30 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.
|
|
|
|
Phase 115 (TODO L7) extends this suite with the discard + title
|
|
acceptance: an orphaned draft can be DISCARDED from the edit screen
|
|
(confirm → DELETE → back on the chat, the row gone — the API check
|
|
confirms the 404), and a save-as-doc after a Retry redo-in-place
|
|
(phase 49) is titled with the redone answer's OWN question (the
|
|
paired user bubble), not the unrelated trailing question that landed
|
|
in the conversation after the redo (the pre-115 last-record rule).
|
|
|
|
Test → story mapping (Playwright Mapping Rule):
|
|
1. ``test_full_session_save_and_edit_out``
|
|
2. ``test_earlier_bubble_button_saves_whole_session``
|
|
3. ``test_discard_draft_from_edit_screen`` (phase 115)
|
|
4. ``test_save_title_is_the_redo_question_after_retry`` (phase 115)
|
|
"""
|
|
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 Dialog, 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,
|
|
SESSION_SECRET,
|
|
USE_REAL_LLM,
|
|
_wait_http,
|
|
)
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
|
|
|
# Phase 79 (task 04, full inventory): the conftest session app owns its
|
|
# port in a combined run — this module app binds its own port instead
|
|
# (a same-port second uvicorn dies on bind and would drive the wrong
|
|
# server). Env-overridable.
|
|
APP_PORT = int(os.environ.get("E2E_APP_PORT_SAVEDOC", "8129"))
|
|
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.
|
|
|
|
Phase 79: POST /api/chat is require_user-gated — the replay client
|
|
signs in as the admin first (the caller is an admin-flow test)."""
|
|
client = httpx.Client(timeout=120.0)
|
|
r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
|
|
assert r.status_code == 204
|
|
|
|
frames: list[dict[str, Any]] = []
|
|
with client.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}"
|
|
|
|
|
|
def _draft_status(app_url: str, token: str) -> httpx.Response:
|
|
"""One GET of the draft by its uuid4 token, as the ADMIN (the
|
|
drafts API is admin-only — anonymous gets 403, which would hide a
|
|
real 404): 200 while the draft exists, 404 ``draft not found``
|
|
once discarded (the phase-115 acceptance's row-gone check — the
|
|
API is the authority, the same trust model as the edit screen)."""
|
|
client = httpx.Client(timeout=30.0)
|
|
try:
|
|
r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD})
|
|
assert r.status_code == 204, "the E2E admin login must succeed"
|
|
return client.get(f"{app_url}/api/doc-drafts/{token}")
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 phase-115 title: the QUESTION THE ANSWER ANSWERED —
|
|
# the first bubble's paired user bubble (Q1, whitespace-free and
|
|
# ≤120 chars, so it arrives verbatim), no longer the
|
|
# conversation's last record (the retry-redo mismatch fix —
|
|
# saving from an earlier bubble titles that bubble's own
|
|
# question, while the LAST bubble's button still yields Q3).
|
|
expect(page.locator("#draft-title")).to_have_value(q1)
|
|
expect(page.locator("#draft-path")).to_have_value(f"docs/{doc_slug(q1)}.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}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Phase 115 (TODO L7): an orphaned draft can be DISCARDED from the
|
|
# edit screen — confirm → DELETE → back on the chat; the row is
|
|
# gone afterward (the API 404s, the row is out of Postgres)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_discard_draft_from_edit_screen(
|
|
page: Page,
|
|
app_url: str,
|
|
mock_llm: int,
|
|
db_ready: None,
|
|
) -> None:
|
|
"""The discard acceptance: save as doc → the edit screen →
|
|
Discard → the confirm dialog (destructive + irreversible) →
|
|
back on the chat page, and the draft is GONE (the API check:
|
|
GET by the now-dead token 404s with the unknown-token message, and
|
|
the row is out of Postgres). The confirm is the gate: DISMISSING
|
|
it leaves the screen AND the draft untouched (no request, no
|
|
navigation, the button re-armable)."""
|
|
page.set_default_timeout(30_000)
|
|
_login_admin(page, app_url)
|
|
_ask(page, Q3)
|
|
|
|
# One grounded turn → one save action → the edit screen (token).
|
|
expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(1)
|
|
token = _open_edit_screen(page, page.locator(".msg.brain .save-as-doc-btn").last)
|
|
alive = _draft_status(app_url, token)
|
|
assert alive.status_code == 200, "the draft must exist before the discard"
|
|
|
|
# The confirm is the gate (a listener-less dialog is auto-dismissed
|
|
# by Playwright, so the policy rides a single registered handler —
|
|
# the house pattern, e.g. test_steering.py): the FIRST confirm is
|
|
# dismissed, the second accepted. The handler runs while the page's
|
|
# JS thread is blocked in confirm() — during the click call.
|
|
dialogs: list[Dialog] = []
|
|
|
|
def _handle(d: Dialog) -> None:
|
|
dialogs.append(d)
|
|
if len(dialogs) == 1:
|
|
d.dismiss() # first attempt: the user changes their mind
|
|
else:
|
|
d.accept() # second attempt: the discard is meant
|
|
|
|
page.on("dialog", _handle)
|
|
|
|
# DISMISS the confirm: no delete, no navigation — the screen stays,
|
|
# the draft is intact, the control is armed for a real attempt.
|
|
page.click("#discard-draft")
|
|
assert len(dialogs) == 1, "the Discard control must confirm before deleting"
|
|
dismissed = dialogs[0]
|
|
assert dismissed.type == "confirm"
|
|
assert "cannot be undone" in dismissed.message
|
|
expect(page).to_have_url(f"{APP_URL}/doc-edit.html?draft={token}")
|
|
expect(page.locator("#discard-draft")).to_be_enabled()
|
|
still = _draft_status(app_url, token)
|
|
assert still.status_code == 200, (
|
|
"a dismissed confirm must not delete the draft"
|
|
)
|
|
|
|
# ACCEPT the confirm: the DELETE lands, the 204 returns to the
|
|
# chat (the draft's only other home — no drafts list exists).
|
|
page.click("#discard-draft")
|
|
assert len(dialogs) == 2, "the second attempt must confirm again"
|
|
page.wait_for_url(APP_URL + "/", timeout=30_000)
|
|
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=30_000)
|
|
|
|
# The acceptance: the draft row is GONE — the token is dead (GET
|
|
# 404s with the exact unknown-token message) …
|
|
gone = _draft_status(app_url, token)
|
|
assert gone.status_code == 404
|
|
assert gone.json() == {"detail": "draft not found"}
|
|
# …and the row is out of Postgres (the 404 alone would pass for
|
|
# any unknown token — the row count is the acceptance).
|
|
with SessionLocal() as db:
|
|
n = db.execute(
|
|
text("SELECT count(*) FROM doc_drafts WHERE token = :t"),
|
|
{"t": token},
|
|
).scalar_one()
|
|
assert n == 0, "the discarded draft's row must be gone from Postgres"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Phase 115 (TODO L7): the draft title after a Retry redo-in-place
|
|
# — the redone answer's OWN question (its paired user bubble), not
|
|
# the unrelated trailing question that followed the redo
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_save_title_is_the_redo_question_after_retry(
|
|
page: Page,
|
|
app_url: str,
|
|
mock_llm: int,
|
|
db_ready: None,
|
|
) -> None:
|
|
"""The title acceptance: ask → the answer → Retry (redo-in-place,
|
|
phase 49) → an UNRELATED trailing question → save as doc on the
|
|
REDONE answer. The pre-115 rule (the LAST user record in the
|
|
conversation) titled the draft with the trailing question — the
|
|
junk-title edge case. The fixed rule pairs the answer with ITS
|
|
question: the edit screen's title field is the REDONE question
|
|
(the body stays the whole session — the redo replaced turn 1 in
|
|
place, so the transcript is the same shape as a fresh two-turn
|
|
session)."""
|
|
page.set_default_timeout(30_000)
|
|
_login_admin(page, app_url)
|
|
_ask(page, Q2)
|
|
|
|
# Redo in place: tag the current (only) wrap, click the Retry
|
|
# button on it — the OLD wrap must leave the DOM and the fresh
|
|
# answer stream into its place (the mock is byte-stable: the redo
|
|
# of Q2 quotes Q2). The question is never duplicated.
|
|
page.evaluate(
|
|
"""() => {
|
|
const wraps = document.querySelectorAll("#messages > .msg.brain");
|
|
wraps[wraps.length - 1].setAttribute("data-retry-marker", "old-b1");
|
|
}"""
|
|
)
|
|
expect(page.locator(".retry-btn")).to_have_count(1)
|
|
page.locator(".retry-btn").click()
|
|
expect(page.locator("#send-label")).to_have_text("Stop", timeout=5_000)
|
|
expect(page.locator("#send-label")).to_have_text("Send", timeout=30_000)
|
|
expect(page.locator("[data-retry-marker='old-b1']")).to_have_count(0)
|
|
expect(page.locator(".msg.user .bubble")).to_have_count(1)
|
|
|
|
# The unrelated trailing question — the LAST user record once it
|
|
# lands (the pre-115 title source, the junk-question mismatch).
|
|
_ask(page, Q3)
|
|
|
|
# Save the REDONE answer (the FIRST brain bubble): the title is the
|
|
# redone question — NOT the trailing one …
|
|
expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(2)
|
|
_open_edit_screen(page, page.locator(".msg.brain .save-as-doc-btn").first)
|
|
expect(page.locator("#draft-title")).to_have_value(Q2)
|
|
expect(page.locator("#draft-path")).to_have_value(f"docs/{doc_slug(Q2)}.md")
|
|
assert Q3 not in page.input_value("#draft-title"), (
|
|
"the title must be the redone question, not the trailing one"
|
|
)
|
|
|
|
# The body is still the WHOLE session (the redo replaced turn 1 in
|
|
# place — the transcript is the fresh two-turn shape, byte-exact
|
|
# against the mock: the composed answer is a pure function of the
|
|
# last user message + the document context, history-independent).
|
|
a2 = _stream_chat_answer(app_url, Q2)
|
|
a3 = _stream_chat_answer(app_url, Q3)
|
|
expect(page.locator("#draft-body")).to_have_value(
|
|
session_transcript([(Q2, a2), (Q3, a3)])
|
|
)
|
|
_assert_no_thinking_leak(page.input_value("#draft-body"))
|