All verification complete. Final report: **Phase 120 — Failed-turn retry: verification pass (all 3 tasks were done; final verification + 1 regression fix)** **Verified:** `ChatMessage.failed`/`error` (≤500, `extra="forbid"` intact); `finalizeFailedTurn` funnel on the 3 failure paths (catch-else, stream-drop guard, zero-frame fallback) with `failed: true` + capped detail + `markLastRetryable`; `appendFailedNote` restore branch (Save-as-doc/Tune excluded); `showErrorBanner`/`retryLastTurn` byte-pinned untouched; only the three paths persist `failed: true` (grep + unit pin); no test asserts the old broken behavior. **Defect found & fixed (rule 7):** a real navigate-away mid-turn let the browser's teardown fetch rejection (TypeError, not AbortError) leak into the failed funnel, persisting a phantom failed brain record — `test_sources_midstream_bug.py::test_no_orphan_brain_message_when_navigated_before_first_token` failed (2 `.msg` after reload) and violated the phase-20 navigate-away convention. Fixed: turn-scoped `leftThePage` flag (set unconditionally on `pagehide`, reset in `runTurn`) skips the funnel in the catch-else branch; pinned by new unit test `test_navigate_away_is_not_a_failed_turn`. No phase-overview/PLAN/todo/complete files touched; no commits made. **Gates (exact):** - `uv run pytest` → 2577 passed - `uv run pytest --cov=app --cov-report=term-missing` → TOTAL 4271 stmts, 99% (>90%) - `uv run pytest tests/e2e/test_failed_turn_retry.py -v --no-cov` → 4 passed (isolated) - `uv run ruff check . && uv run pyright` → clean (0 errors) - Regression E2E, isolated: `test_sources_midstream_bug.py` 6/6 (was 5/6); `test_llm_retry`/`test_tool_scaffolding_guardrails`/`test_stop_generation`/`test_navbar_refresh` 17/17 **Completion criteria:** (1) network error → banner + in-bubble Retry, re-ask without re-typing ✅ (E2E A); (2) refresh restores failed bubble + working Retry, no "new chat" ✅ (E2E C); (3) stopped/successful turns byte-identical ✅ (negative E2E, stop suite, byte-identity units); (4) pytest/coverage/lint/types ✅; (5) commit + phase move — left to the harness per pass rules. **Notable:** deviation = the regression fix above (a navigation is not a failed turn; phase-20 partial-persist convention restored). Next pending phase: `121_git_source_tokens`.
397 lines
19 KiB
Python
397 lines
19 KiB
Python
"""Unit: the phase-59 "Save as doc" button (task 05).
|
|
|
|
No Python logic exists beyond the one-line ``app/api/config.py`` flag —
|
|
the behavior lives in ``frontend/assets/app.js`` + ``brand.js`` +
|
|
``styles.css``, and it is E2E-gated by the story suite (task 07). Like
|
|
the other frontend-adjacent unit files (``test_frontend_brand.py``),
|
|
this module pins the JS/CSS markers the story depends on, so a silent
|
|
regression in the button layer is caught without a browser — plus the
|
|
``app/api/config.py`` unit pin (the response dict's
|
|
``docs_repo_configured`` bool tracks ``settings.docs_configured``).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from app.config import Settings
|
|
|
|
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
|
BRAND_JS = FRONTEND / "assets" / "brand.js"
|
|
APP_JS = FRONTEND / "assets" / "app.js"
|
|
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
|
|
|
|
|
def _text(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def _settings(**kwargs: Any) -> Settings:
|
|
"""Build Settings without reading a .env file (deterministic tests).
|
|
|
|
Same house pattern as tests/integration/test_doc_drafts_api.py —
|
|
``_env_file`` exists at runtime (pydantic-settings) but is not in the
|
|
static signature, hence the ignore on the call.
|
|
"""
|
|
kwargs.setdefault("_env_file", None)
|
|
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# app/api/config.py — the unit pin (the response dict gains the flag)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_app_config_dict_carries_the_docs_flag() -> None:
|
|
"""The ``app_config`` response dict gains ``docs_repo_configured`` —
|
|
a real bool that tracks ``settings.docs_configured``: false (inert)
|
|
while BOR_DOCS_REPO is empty, true the moment it is non-empty."""
|
|
from app.api.config import app_config
|
|
|
|
s = _settings()
|
|
body = app_config(s)
|
|
# Phase 62 (task 01): the response grew to the phase-62 UI
|
|
# customization keys; phase 91 (task 03) deleted the retired
|
|
# CSS-file theming's ``theme`` key — the five keys below are the
|
|
# entire endpoint contract.
|
|
assert set(body) == {
|
|
"app_name", "version", "docs_repo_configured",
|
|
"input_placeholder", "footer_text",
|
|
}
|
|
assert body["docs_repo_configured"] is s.docs_configured
|
|
assert body["docs_repo_configured"] is False
|
|
|
|
s2 = _settings(docs_repo="/srv/docs-repo")
|
|
assert app_config(s2)["docs_repo_configured"] is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# brand.js — the flag + promise are surfaced the way app_name is
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_brand_js_surfaces_the_docs_flag_inert_by_default() -> None:
|
|
"""window.BOR_DOCS_REPO_CONFIGURED is a classic-script global: false
|
|
at parse time (inert — hidden for everyone until proven), BEFORE the
|
|
/api/config fetch starts (the same ordering pin as window.BOR_BRAND)."""
|
|
js = _text(BRAND_JS)
|
|
assert "window.BOR_DOCS_REPO_CONFIGURED = false;" in js
|
|
default_idx = js.find("window.BOR_DOCS_REPO_CONFIGURED = false;")
|
|
# The real fetch statement (the file-header comment mentions the
|
|
# fetch too — anchor on the parse-time const, not the comment).
|
|
fetch_idx = js.find('BOR_CONFIG_PROMISE = fetch("/api/config"')
|
|
assert 0 <= default_idx < fetch_idx, (
|
|
"the inert flag default must be set at top level before the fetch"
|
|
)
|
|
|
|
|
|
def test_brand_js_exposes_the_config_promise_and_sets_the_flag() -> None:
|
|
"""The SAME boot fetch's promise is exposed at parse time
|
|
(window.BOR_CONFIG_PROMISE — app.js's boot awaits it), the flag lands
|
|
the moment the answer arrives, and the promise NEVER rejects (the
|
|
error arm warns + resolves null — the loadHealth house style)."""
|
|
js = _text(BRAND_JS)
|
|
assert "window.BOR_CONFIG_PROMISE = BOR_CONFIG_PROMISE;" in js
|
|
assert "window.BOR_DOCS_REPO_CONFIGURED = cfg?.docs_repo_configured === true;" in js
|
|
# The flag is a strict boolean: only the literal JSON true flips it.
|
|
assert "=== true" in js
|
|
assert "console.warn" in js
|
|
assert "return null;" in js
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# app.js — boot wiring: the flag is final before any bubble renders
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_app_js_boot_awaits_config_before_capturing_the_flag() -> None:
|
|
"""The boot IIFE awaits brand.js's parse-time promise (never
|
|
rejecting — a defensive fallback covers a missing global) and then
|
|
captures docsRepoConfigured — BEFORE any bubble renders
|
|
(restoreConversation), so a restored conversation of a configured
|
|
admin gets the button exactly once: no flash, no re-render, no
|
|
second fetch."""
|
|
js = _text(APP_JS)
|
|
assert "let docsRepoConfigured = false;" in js
|
|
await_idx = js.find("await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());")
|
|
capture_idx = js.find("docsRepoConfigured = window.BOR_DOCS_REPO_CONFIGURED === true;")
|
|
restore_idx = js.find("restoreConversation();")
|
|
assert await_idx >= 0 and await_idx < capture_idx, (
|
|
"the flag capture must follow the config-promise await"
|
|
)
|
|
assert restore_idx > 0 and capture_idx < restore_idx, (
|
|
"the flag must be final BEFORE the restored conversation renders"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# app.js — the button: gating, ARIA, one per bubble
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_app_js_button_gates_on_admin_and_configured() -> None:
|
|
"""The single guard: admin (the whoami gate Tune uses) AND
|
|
docs_repo_configured — otherwise the function injects NOTHING
|
|
(anonymous, or unconfigured admin, or deflected scope — same as
|
|
Tune). One button per bubble; the .msg-meta row is reused (or
|
|
created plain) and a role=list row gets a listitem button (ARIA)."""
|
|
js = _text(APP_JS)
|
|
fn_idx = js.find("function appendSaveAsDocButton(wrap, markdown) {")
|
|
assert fn_idx != -1, "appendSaveAsDocButton missing"
|
|
fn_end = js.find("async function saveAsDoc", fn_idx)
|
|
fn_body = js[fn_idx:fn_end]
|
|
assert "if (!isAdmin || !docsRepoConfigured) return;" in fn_body
|
|
assert 'meta.querySelector(".save-as-doc-btn")' in fn_body, (
|
|
"the one-button-per-bubble guard is missing"
|
|
)
|
|
assert "meta.getAttribute(\"role\") === \"list\"" in fn_body
|
|
assert "btn.setAttribute(\"role\", \"listitem\")" in fn_body
|
|
|
|
|
|
def test_app_js_button_carries_the_class_and_label() -> None:
|
|
"""The .save-as-doc-btn class (the CSS right-alignment hook) + the
|
|
house label "Save as doc" (an accessible button name — the icon is
|
|
aria-hidden decoration)."""
|
|
js = _text(APP_JS)
|
|
fn_idx = js.find("function appendSaveAsDocButton(wrap, markdown) {")
|
|
fn_body = js[fn_idx : js.find("async function saveAsDoc", fn_idx)]
|
|
assert 'btn.className = "save-as-doc-btn"' in fn_body
|
|
assert 'btn.type = "button"' in fn_body
|
|
assert "<span>Save as doc</span>" in fn_body
|
|
# The file glyph is aria-hidden decoration (the label carries the
|
|
# accessible name) — the icon constant, which the function consumes.
|
|
icon_idx = js.find("const SAVE_AS_DOC_ICON")
|
|
icon_body = js[icon_idx : js.find("const DOC_TITLE_MAX", icon_idx)]
|
|
assert 'aria-hidden="true"' in icon_body, "the icon must be aria-hidden"
|
|
assert 'SAVE_AS_DOC_ICON + "<span>Save as doc</span>"' in fn_body
|
|
|
|
|
|
def test_app_js_call_sites_pass_the_raw_markdown() -> None:
|
|
"""Three call sites, each passing the RAW persisted markdown (never
|
|
the rendered HTML): the live `done` branch (exactly the string
|
|
rememberBrainTurn stores, so a reload offers the identical draft),
|
|
the empty-answer fallback bubble (parity with the done path), and
|
|
the restore path (m.text). A stopped partial — or a failed turn
|
|
(phase 120: a note, not an answer either) — is excluded: the
|
|
restore gates on !m.stopped && !m.failed; the live stop/failure
|
|
paths and the pagehide partial never call the helper at all."""
|
|
js = _text(APP_JS)
|
|
assert 'appendSaveAsDocButton(wrap, finalText || acc || "…");' in js, (
|
|
"the live done branch must pass the raw persisted text"
|
|
)
|
|
assert "appendSaveAsDocButton(fwrap, fallback);" in js, (
|
|
"the empty-answer fallback bubble must get the button too"
|
|
)
|
|
assert (
|
|
"if (!m.stopped && !m.failed) appendSaveAsDocButton(wrap, m.text);" in js
|
|
), (
|
|
"the restore path must pass m.text and skip stopped + failed records"
|
|
)
|
|
# The live call sits next to the Tune button (same meta row scope).
|
|
tune_idx = js.find("appendTuneButton(wrap); // every completed brain bubble is tunable")
|
|
save_idx = js.find('appendSaveAsDocButton(wrap, finalText || acc || "…");')
|
|
assert tune_idx > 0 and tune_idx < save_idx
|
|
# The stop finalize keeps its Tune button but gains NO save button
|
|
# (a stopped partial is a note, not an answer) — none between the
|
|
# stop call site and the pagehide handler (which persists, it does
|
|
# not render).
|
|
stop_idx = js.find("appendTuneButton(wrap); // admin-only; parity with the restore path")
|
|
pagehide_idx = js.find("pagehide", stop_idx)
|
|
assert stop_idx > 0 and stop_idx < pagehide_idx
|
|
assert "appendSaveAsDocButton" not in js[stop_idx:pagehide_idx], (
|
|
"the stopped partial (note, not answer) must not get the button"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# app.js — the title: the paired user question (phase 115) + the click
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_app_js_default_title_is_the_paired_user_question() -> None:
|
|
"""Phase 115 (task 03): defaultDocTitle(wrap) — the title is the
|
|
text of the user bubble PAIRED with the saved brain bubble: the
|
|
NEAREST preceding .msg.user in the DOM conversation flow (the
|
|
redo-in-place reorders the DOM, and the structural pair IS the
|
|
answer's question by construction — the last conversation record
|
|
can be an unrelated trailing question after a retry). The text is
|
|
read from the bubble's .bubble (the meta rows carry button labels
|
|
— never the whole wrap). No wrap given, or no paired user bubble
|
|
found (first-turn edge / DOM mismatch) → the pre-phase-115
|
|
fallback: the LAST user record in `conversation` (iterate
|
|
backwards). The DOC_TITLE_MAX slice, the whitespace collapse, and
|
|
the defensive "Note" are unchanged."""
|
|
js = _text(APP_JS)
|
|
assert "const DOC_TITLE_MAX = 120;" in js
|
|
fn_idx = js.find("function defaultDocTitle(wrap) {")
|
|
assert fn_idx != -1, "defaultDocTitle(wrap) missing"
|
|
fn_body = js[fn_idx : js.find("function docSlug", fn_idx)]
|
|
# The paired-bubble walk: the wrap's previousElementSibling chain,
|
|
# the first .msg.user wins (nearest preceding user bubble) …
|
|
assert "wrap.previousElementSibling" in fn_body, (
|
|
"the pairing must walk the DOM conversation flow backwards"
|
|
)
|
|
assert 'el.classList.contains("msg")' in fn_body
|
|
assert 'el.classList.contains("user")' in fn_body
|
|
# …with the text read from that bubble's .bubble (not the wrap —
|
|
# the meta rows carry button labels).
|
|
assert 'el.querySelector(".bubble")' in fn_body
|
|
assert "textContent" in fn_body
|
|
# The fallback: the LAST user record in `conversation` (kept for
|
|
# the no-wrap / no-pair edges).
|
|
assert "conversation.length - 1" in fn_body
|
|
assert 'conversation[i].who === "user"' in fn_body
|
|
# The unchanged title rule: collapse, 120-cap, "Note".
|
|
assert 'question.replace(/\\s+/g, " ").trim().slice(0, DOC_TITLE_MAX)' in fn_body
|
|
assert '|| "Note"' in fn_body
|
|
|
|
|
|
def test_app_js_save_as_doc_passes_the_bubble_ancestor() -> None:
|
|
"""Phase 115 (task 03): the saveAsDoc call site passes the
|
|
bubble's ancestor — the .save-as-doc-btn's own .msg.brain wrap
|
|
(the button lives in the bubble's .msg-meta row, so closest climbs
|
|
button → meta → body → wrap) — the title pairs the answer with ITS
|
|
question. No call site may stay on the no-wrap fallback: the
|
|
button always knows its own bubble."""
|
|
js = _text(APP_JS)
|
|
fn_idx = js.find("async function saveAsDoc(btn) {")
|
|
assert fn_idx != -1, "saveAsDoc missing"
|
|
fn_body = js[fn_idx : fn_idx + 3000]
|
|
assert 'defaultDocTitle(btn.closest(".msg.brain"))' in fn_body
|
|
assert "defaultDocTitle()" not in js, (
|
|
"no caller may stay on the no-wrap fallback once the button "
|
|
"knows its own bubble"
|
|
)
|
|
|
|
|
|
def test_app_js_slug_rule() -> None:
|
|
"""The default in-repo path slug: lowercase → runs of
|
|
non-alphanumerics → "-" → trimmed → ≤60 chars → empty → "note"
|
|
(the phase-59 locked assumption; a 60-cut mid dash-run is trimmed
|
|
again so the path never dangles)."""
|
|
js = _text(APP_JS)
|
|
fn_idx = js.find("function docSlug(title) {")
|
|
assert fn_idx != -1, "docSlug missing"
|
|
fn_body = js[fn_idx : js.find("function appendSaveAsDocButton", fn_idx)]
|
|
assert ".toLowerCase()" in fn_body
|
|
assert '.replace(/[^a-z0-9]+/g, "-")' in fn_body
|
|
assert '.replace(/^-+|-+$/g, "")' in fn_body
|
|
assert ".slice(0, 60)" in fn_body
|
|
assert '|| "note"' in fn_body
|
|
# The default in-repo path is docs/<slug>.md.
|
|
assert "docs/${docSlug(title)}.md" in js
|
|
|
|
|
|
def test_app_js_transcript_covers_the_whole_session() -> None:
|
|
"""Phase 75 (TODO L4, A6): buildSessionTranscript() — the draft
|
|
body — is the WHOLE conversation: a numbered section per USER turn
|
|
("## N. <raw question>" + 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 user turn whose brain record
|
|
never landed is a heading-only section. saveAsDoc POSTS the
|
|
transcript (the phase-59 single-bubble body no longer travels)."""
|
|
js = _text(APP_JS)
|
|
fn_idx = js.find("function buildSessionTranscript() {")
|
|
assert fn_idx != -1, "buildSessionTranscript missing"
|
|
fn_end = js.find("function appendSaveAsDocButton", fn_idx)
|
|
assert fn_end > fn_idx
|
|
fn_body = js[fn_idx:fn_end]
|
|
# One section per USER turn, numbered 1-based in record order…
|
|
assert 'm.who === "user"' in fn_body
|
|
assert "sections.length + 1" in fn_body
|
|
# …"## N. <question, RAW text>" — the record's text verbatim
|
|
# (no title transform, no HTML).
|
|
assert "`## ${sections.length + 1}. ${m.text}`" in fn_body
|
|
# …the RAW answer text joins under its question; the question and
|
|
# its answer(s) are blank-line separated, and the sections too.
|
|
assert "open.push(m.text)" in fn_body
|
|
assert 's.answers.join("\\n\\n")' in fn_body
|
|
assert '.join("\\n\\n")' in fn_body
|
|
# ALL trailing whitespace collapses to a single final newline.
|
|
assert 'body.replace(/\\s+$/, "") + "\\n"' in fn_body
|
|
# Only raw text travels: no record field beyond who/text is read.
|
|
assert "m.thinking" not in fn_body
|
|
assert "m.sources" not in fn_body
|
|
assert "m.deflected" not in fn_body
|
|
assert "m.tools" not in fn_body
|
|
# saveAsDoc POSTS the transcript as the body (and the dead
|
|
# single-bubble markdown argument is gone from its signature).
|
|
save_idx = js.find("async function saveAsDoc(btn) {")
|
|
assert save_idx > fn_idx, "saveAsDoc missing (or still carries markdown)"
|
|
save_body = js[save_idx : save_idx + 3000]
|
|
assert "body: buildSessionTranscript()" in save_body
|
|
assert "markdown" not in save_body
|
|
|
|
|
|
def test_app_js_post_payload_and_navigation() -> None:
|
|
"""Click → POST /api/doc-drafts {title, path, body: the
|
|
FULL-SESSION transcript} (phase 75 A6 — every Q/A up to the click,
|
|
in order — never HTML) → 201 →
|
|
location.assign("/doc-edit.html?draft=" + token). A double-click
|
|
guard disables the button until the outcome (released in the
|
|
finally — never stale); failure shows the neutral one-line banner
|
|
(phase-55 convention) and never navigates."""
|
|
js = _text(APP_JS)
|
|
fn_idx = js.find("async function saveAsDoc(btn) {")
|
|
assert fn_idx != -1, "saveAsDoc missing"
|
|
fn_body = js[fn_idx : fn_idx + 3000]
|
|
assert 'fetch("/api/doc-drafts"' in fn_body
|
|
assert "body: buildSessionTranscript()" in fn_body
|
|
assert 'location.assign("/doc-edit.html?draft=" + draft.token)' in fn_body
|
|
assert "btn.disabled = true" in fn_body
|
|
assert "btn.disabled = false" in fn_body
|
|
assert "showErrorBanner(" in fn_body
|
|
# The neutral one-line failure copy (phase-55 convention).
|
|
assert "Couldn't save the answer as a doc" in fn_body
|
|
|
|
|
|
def test_app_js_retry_landing_keeps_save_rightmost() -> None:
|
|
"""markLastRetryable re-appends the save button AFTER the Retry
|
|
button lands on the same (last) bubble — the auto-margined buttons
|
|
split the row's free space between them, so DOM order decides the
|
|
right edge: "Save as doc" stays the bottom-right action even on the
|
|
last bubble (which also carries Retry)."""
|
|
js = _text(APP_JS)
|
|
fn_idx = js.find("function markLastRetryable() {")
|
|
assert fn_idx != -1
|
|
fn_end = js.find("/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right", fn_idx)
|
|
fn_body = js[fn_idx:fn_end]
|
|
assert "appendRetryButton(lastBrainWrap);" in fn_body
|
|
assert 'lastBrainWrap.querySelector(".save-as-doc-btn")' in fn_body
|
|
# "saveDocBtn" — NOT "saveBtn": phase 55 pins the Save pill's
|
|
# identifier gone from app.js (substring), so the local stays distinct.
|
|
assert "saveDocBtn.parentElement.appendChild(saveDocBtn)" in fn_body
|
|
assert "saveBtn" not in _text(APP_JS), (
|
|
"the phase-55 pin: no saveBtn identifier in app.js"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# styles.css — the .tune-btn visual family + the right alignment
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_styles_css_save_as_doc_btn_is_right_aligned() -> None:
|
|
""".save-as-doc-btn exists, carries the bottom-right declaration
|
|
(margin-inline-start: auto) and the .tune-btn visual family (pill,
|
|
>=44px target, line border, ink-soft palette); :focus-visible is
|
|
the global rule, the hover rule is per-class."""
|
|
css = _text(STYLES_CSS)
|
|
m = re.search(r"\.save-as-doc-btn \{[^}]*\}", css)
|
|
assert m, "the .save-as-doc-btn rule is missing"
|
|
block = m.group(0)
|
|
assert "margin-inline-start: auto;" in block, (
|
|
"the bottom-right requirement lives on the button's class"
|
|
)
|
|
assert "min-height: 44px;" in block # WCAG touch target (the family)
|
|
assert "border-radius: 999px;" in block
|
|
assert "border: 1px solid var(--line);" in block
|
|
assert "var(--ink-soft)" in block
|
|
assert ".save-as-doc-btn:hover" in css
|
|
assert ".save-as-doc-btn svg" in css # the 14px house glyph sizing
|
|
assert ":focus-visible" in css # the global focus ring (AGENTS §5)
|