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.
633 lines
24 KiB
Python
633 lines
24 KiB
Python
"""Phase 59 story E2E (Playwright): the save → edit → push loop, with
|
|
the BARE REPO as source of truth.
|
|
|
|
Story: n/a (TODO-derived — "Convert response to documentation that gets
|
|
committed back to a repo specified in .env … allows you to modify the
|
|
new documentation before [pushing] to the specified repo").
|
|
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_response_to_docs.py -v --no-cov
|
|
|
|
The loop under test: a completed brain bubble carries a bottom-right
|
|
"Save as doc" action (admin + a configured ``BOR_DOCS_REPO``) → it
|
|
opens ``/doc-edit.html?draft=<token>`` prefilled (auto-title from the
|
|
last question, path ``docs/<slug>.md``, body = the FULL-SESSION
|
|
TRANSCRIPT — phase 75 A6; every session in this suite is single-turn,
|
|
so it is ``## 1. <the question>`` + the answer's markdown source —
|
|
never the rendered HTML) → 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>``, ``git rev-list``, ``git rev-parse``) — the UI text
|
|
is only the entry point (D3: no PR is ever created or attempted — the
|
|
flow ends at the push to the branch).
|
|
|
|
App boots (the conftest pattern, module-scoped — as in
|
|
``test_git_sources_admin.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``;
|
|
* ``test_unconfigured_hides_button`` boots a SECOND app (separate
|
|
fixture, ``APP_PORT + 1``) with NO docs env — the inert default:
|
|
no button for anyone, draft creation still allowed (drafts are
|
|
repo-independent), push 409s naming ``BOR_DOCS_REPO``.
|
|
|
|
The mock LLM keeps every answer byte-deterministic: the suite replays
|
|
the same question through ``POST /api/chat`` (raw SSE, the
|
|
``test_chat_rag.py`` pattern) to recover the exact answer bytes the
|
|
draft must carry — so "body == the single-turn transcript of that
|
|
answer" is an exact-byte assertion, not a contains check.
|
|
|
|
Test → story mapping (Playwright Mapping Rule):
|
|
1. ``test_save_edit_push``
|
|
2. ``test_second_push_fast_forwards``
|
|
3. ``test_guest_has_no_button``
|
|
4. ``test_unconfigured_hides_button``
|
|
"""
|
|
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 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}"
|
|
#: The unconfigured app's port (task 07: a second app boot WITHOUT
|
|
#: ``BOR_DOCS_REPO`` — a separate fixture on the next port, so it can
|
|
#: run alongside the module app).
|
|
UNCONF_URL = f"http://127.0.0.1:{APP_PORT + 1}"
|
|
|
|
BRANCH = "bor-docs"
|
|
BASE_BRANCH = "main"
|
|
|
|
#: On-topic fixture questions (the house phrasing — proven HIGH gate in
|
|
#: test_chat_rag.py / test_pinned_composer.py, so every turn renders a
|
|
#: grounded answer with the deterministic marker, never a deflection).
|
|
QUESTION_1 = "How is my Kubernetes cluster set up?"
|
|
QUESTION_2 = "What is in the new-service deployment?"
|
|
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
|
|
|
#: The distinctive line test 1 appends to the body before pushing —
|
|
#: ASCII on purpose (git's output must match byte-for-byte), and a
|
|
#: module constant so test 2 can reconstruct file 1's expected content
|
|
#: (deterministic: the mock answer + this exact suffix).
|
|
E2E_MARKER = "E2E-DOCS-MARKER (appended by the response-to-docs story suite)"
|
|
|
|
#: The edit screen's URL shape (task 05 navigates with the uuid4 token).
|
|
DRAFT_URL_RE = re.compile(r"/doc-edit\.html\?draft=[0-9a-f-]{36}")
|
|
#: The success line (task 06): `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 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 transcript(question: str, answer: str) -> str:
|
|
"""The phase-75 draft body (app.js ``buildSessionTranscript``,
|
|
A6) for the SINGLE-turn sessions this suite drives, as stored:
|
|
``## 1. <question>`` + blank line + the answer's raw markdown.
|
|
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 answer."""
|
|
return f"## 1. {question}\n\n{answer}"
|
|
|
|
|
|
def _admin_cookies(page: Page) -> dict[str, str]:
|
|
"""The signed session cookies the browser holds after a form login
|
|
— used to call the admin API with plain httpx (the
|
|
``test_cache_busting.py`` pattern)."""
|
|
return {
|
|
c["name"]: c["value"]
|
|
for c in page.context.cookies()
|
|
if "name" in c and "value" in c
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 second 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] | None) -> subprocess.Popen:
|
|
"""One uvicorn boot (the conftest app_server env shape); ``None``
|
|
docs_env = NO docs variables at all (the unconfigured app)."""
|
|
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
|
|
if docs_env is None:
|
|
for var in (
|
|
"BOR_DOCS_REPO",
|
|
"BOR_DOCS_BRANCH",
|
|
"BOR_DOCS_BASE_BRANCH",
|
|
"BOR_DOCS_WORK_DIR",
|
|
):
|
|
env.pop(var, None)
|
|
# Phase 61 (defect fix): the pop only clears the process env —
|
|
# pydantic-settings would still pick up ``BOR_DOCS_REPO`` from an
|
|
# operator's local (gitignored) ``.env`` (``cwd=REPO``). Force
|
|
# empty so the "unconfigured" boot really is the inert default.
|
|
env["BOR_DOCS_REPO"] = ""
|
|
else:
|
|
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
|
|
|
|
|
|
@pytest.fixture()
|
|
def unconfigured_app(mock_llm: int) -> Iterator[str]:
|
|
"""The SECOND app boot (task 07): NO ``BOR_DOCS_REPO`` — the inert
|
|
default the suite must see as absent-for-everyone + 409 push."""
|
|
proc = _spawn_app(APP_PORT + 1, mock_llm, None)
|
|
try:
|
|
_wait_http(f"{UNCONF_URL}/api/health")
|
|
yield UNCONF_URL
|
|
finally:
|
|
_stop(proc)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 (same KB, same question)."""
|
|
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, app_url: str, question: str) -> None:
|
|
"""One grounded turn to its DONE state (marker in the bubble + the
|
|
send button re-enabled — the meta-row buttons land on done)."""
|
|
page.fill("#message-input", question)
|
|
page.click("#send-btn")
|
|
bubble = page.locator(".msg.brain .bubble:not(.typing)").first
|
|
expect(bubble).to_contain_text(question, timeout=30_000)
|
|
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
|
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
|
expect(page.locator("#send-label")).to_have_text("Send")
|
|
|
|
|
|
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) -> str:
|
|
"""Click the save action, wait for the navigation, return the draft
|
|
token from the URL (the uuid4 credential)."""
|
|
page.click(".save-as-doc-btn")
|
|
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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1. The whole loop: save → edit → push → the bare repo agrees
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_save_edit_push(
|
|
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)
|
|
_ask(page, app_url, QUESTION_1)
|
|
|
|
# The "Save as doc" action is on the completed brain bubble…
|
|
btn = page.locator(".msg.brain .save-as-doc-btn")
|
|
expect(btn).to_have_count(1)
|
|
expect(btn).to_contain_text("Save as doc")
|
|
# …bottom-right: its left edge sits past the bubble's midline
|
|
# (margin-inline-start: auto in the meta row).
|
|
msg_box = page.locator(".msg.brain").bounding_box()
|
|
btn_box = btn.bounding_box()
|
|
assert msg_box is not None and btn_box is not None
|
|
midline = msg_box["x"] + msg_box["width"] / 2
|
|
assert btn_box["x"] > midline, (
|
|
f"save button x={btn_box['x']:.0f} is not past the bubble midline "
|
|
f"{midline:.0f} — it must sit bottom-right"
|
|
)
|
|
|
|
# Click → /doc-edit.html?draft=<uuid4>, prefilled.
|
|
_open_edit_screen(page)
|
|
expect(page.locator("#draft-title")).to_have_value(QUESTION_1) # auto-title
|
|
expect(page.locator("#draft-path")).to_have_value(
|
|
f"docs/{doc_slug(QUESTION_1)}.md"
|
|
)
|
|
# Body == the FULL-SESSION transcript (phase 75 A6): this
|
|
# session is single-turn, so it is "## 1. <the question>" + blank
|
|
# line + the answer's raw markdown. The SSE replay recovers the
|
|
# exact answer bytes the UI accumulated (the mock is
|
|
# byte-deterministic on the same KB + question) — and they are
|
|
# plain markdown, not rendered HTML.
|
|
raw = _stream_chat_answer(app_url, QUESTION_1)
|
|
assert MOCK_ANSWER_MARKER in raw and QUESTION_1 in raw
|
|
assert "<" not in raw and ">" not in raw, "the draft body must be markdown, not HTML"
|
|
body = transcript(QUESTION_1, raw)
|
|
expect(page.locator("#draft-body")).to_have_value(body)
|
|
|
|
# Modify the doc (the story's "modify before [pushing]"): a
|
|
# distinctive marker line the bare repo must show after the push.
|
|
edited = f"{body}\n\n{E2E_MARKER}"
|
|
page.fill("#draft-body", edited)
|
|
|
|
# Push → the live region reports the branch + a 7-char commit sha…
|
|
branch, sha7 = _push_and_read_sha(page)
|
|
assert branch == BRANCH
|
|
|
|
# …and the BARE REPO agrees (the source of truth — not the UI):
|
|
# the file on the branch is exactly the edited body…
|
|
path = f"docs/{doc_slug(QUESTION_1)}.md"
|
|
shown = _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path}"])
|
|
assert shown == edited
|
|
# …and the branch tip's first 7 chars are the sha the UI reported.
|
|
tip = _git(["-C", str(docs_repo.bare), "rev-parse", BRANCH]).strip()
|
|
assert tip.startswith(sha7), f"UI sha {sha7} != bare repo tip {tip}"
|
|
# First push: the branch exists and is exactly one commit beyond
|
|
# main (created by the push — the remote had no bor-docs before).
|
|
assert (
|
|
_git(["-C", str(docs_repo.bare), "rev-list", "--count", f"{BASE_BRANCH}..{BRANCH}"])
|
|
.strip()
|
|
== "1"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2. A second save fast-forwards: two commits, file 1 untouched
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_second_push_fast_forwards(
|
|
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)
|
|
_ask(page, app_url, QUESTION_2)
|
|
|
|
# Save the second answer (different question → different slug);
|
|
# its session is single-turn too, so the body is the single-turn
|
|
# transcript of that answer…
|
|
expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(1)
|
|
_open_edit_screen(page)
|
|
expect(page.locator("#draft-title")).to_have_value(QUESTION_2)
|
|
expect(page.locator("#draft-path")).to_have_value(
|
|
f"docs/{doc_slug(QUESTION_2)}.md"
|
|
)
|
|
raw2 = _stream_chat_answer(app_url, QUESTION_2)
|
|
expect(page.locator("#draft-body")).to_have_value(transcript(QUESTION_2, raw2))
|
|
|
|
# …and push WITHOUT editing — a new commit on the same branch.
|
|
_push_and_read_sha(page)
|
|
|
|
# The bare repo: exactly two commits beyond main (fast-forward,
|
|
# never a force-push or a reset)…
|
|
assert (
|
|
_git(["-C", str(docs_repo.bare), "rev-list", "--count", f"{BASE_BRANCH}..{BRANCH}"])
|
|
.strip()
|
|
== "2"
|
|
)
|
|
# …file 2 landed with its unedited body (the single-turn
|
|
# transcript)…
|
|
path2 = f"docs/{doc_slug(QUESTION_2)}.md"
|
|
assert (
|
|
_git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path2}"])
|
|
== transcript(QUESTION_2, raw2)
|
|
)
|
|
# …and file 1 from test 1 is still at its path, byte-for-byte
|
|
# (deterministic reconstruction: the single-turn transcript + the
|
|
# marker line).
|
|
path1 = f"docs/{doc_slug(QUESTION_1)}.md"
|
|
expected_first = (
|
|
f"{transcript(QUESTION_1, _stream_chat_answer(app_url, QUESTION_1))}"
|
|
f"\n\n{E2E_MARKER}"
|
|
)
|
|
assert _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path1}"]) == expected_first
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3. Guest: no button, 403 on the draft API, the edit screen gates
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_guest_has_no_button(
|
|
page: Page, app_url: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
page.set_default_timeout(30_000)
|
|
|
|
# No login (the conftest page fixture is a fresh context). Track
|
|
# every /api/doc-drafts request the PAGES make — the guest flow
|
|
# must never reach the admin API.
|
|
drafts_calls: list[str] = []
|
|
page.on(
|
|
"request",
|
|
lambda r: drafts_calls.append(r.url) if "/api/doc-drafts" in r.url else None,
|
|
)
|
|
|
|
page.goto(app_url)
|
|
expect(page.locator("#sign-in-link")).to_be_visible(timeout=30_000)
|
|
_ask(page, app_url, QUESTION_1) # the grounded answer streams for guests too
|
|
|
|
# The "Save as doc" action is admin-only: ABSENT (not hidden) on
|
|
# the completed bubble, whatever the docs config says.
|
|
expect(page.locator(".save-as-doc-btn")).to_have_count(0)
|
|
|
|
# The draft API 403s anonymous callers (httpx, no cookie at all).
|
|
r = httpx.post(
|
|
f"{app_url}/api/doc-drafts",
|
|
json={"title": "guest", "path": "docs/guest.md", "body": "nope"},
|
|
timeout=10,
|
|
)
|
|
assert r.status_code == 403
|
|
|
|
# The edit screen renders the admin gate with NO draft data in the
|
|
# DOM, and the page itself made zero draft API calls.
|
|
page.goto(app_url + "/doc-edit.html")
|
|
expect(page.locator("#doc-edit-gate")).to_be_visible(timeout=30_000)
|
|
expect(page.locator("#doc-edit-content")).to_be_hidden()
|
|
assert page.input_value("#draft-title") == ""
|
|
assert page.input_value("#draft-path") == ""
|
|
assert page.input_value("#draft-body") == ""
|
|
assert drafts_calls == [], f"guest pages called the draft API: {drafts_calls}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4. Unconfigured (BOR_DOCS_REPO empty): inert for everyone, 409 push
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_unconfigured_hides_button(
|
|
page: Page, unconfigured_app: str, mock_llm: int, db_ready: None
|
|
) -> None:
|
|
page.set_default_timeout(30_000)
|
|
_login_admin(page, unconfigured_app)
|
|
_ask(page, unconfigured_app, QUESTION_1)
|
|
|
|
# docs_repo_configured false → the button is hidden for EVERYONE,
|
|
# admin included (the optional-feature pattern — inert by default).
|
|
expect(page.locator(".save-as-doc-btn")).to_have_count(0)
|
|
|
|
# Drafts are repo-independent: an admin can still create one…
|
|
cookies = _admin_cookies(page)
|
|
r = httpx.post(
|
|
f"{unconfigured_app}/api/doc-drafts",
|
|
json={
|
|
"title": "Unconfigured draft",
|
|
"path": "docs/unconfigured.md",
|
|
"body": "A draft while no docs repo is configured.",
|
|
},
|
|
timeout=10,
|
|
cookies=cookies,
|
|
)
|
|
assert r.status_code == 201, r.text
|
|
token = r.json()["token"]
|
|
|
|
# …but pushing 409s, naming the missing variable (D3: fail loud,
|
|
# inert by default).
|
|
r = httpx.post(
|
|
f"{unconfigured_app}/api/doc-drafts/{token}/push", timeout=10, cookies=cookies
|
|
)
|
|
assert r.status_code == 409
|
|
assert "BOR_DOCS_REPO" in r.json()["detail"]
|