"""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 six-key set — the
# phase-62 UI customization keys ride the SAME endpoint.
assert set(body) == {
"app_name", "version", "docs_repo_configured",
"input_placeholder", "footer_text", "theme",
}
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 "Save as doc" 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 + "Save as doc"' 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 is a note, not an
answer — the restore gates on !m.stopped; the live stop path 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) appendSaveAsDocButton(wrap, m.text);" in js, (
"the restore path must pass m.text and skip stopped 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 click: payload, slug rule, navigation, failure copy
# ---------------------------------------------------------------------------
def test_app_js_default_title_is_the_last_user_question() -> None:
"""The default title: the LAST user question's text,
whitespace-collapsed, ≤120 chars (the phase-50 auto-title
convention — the chat auto-title targets the FIRST question, the
docs default the LAST). Defensive "Note" with no user record."""
js = _text(APP_JS)
assert "const DOC_TITLE_MAX = 120;" in js
fn_idx = js.find("function defaultDocTitle() {")
assert fn_idx != -1, "defaultDocTitle missing"
fn_body = js[fn_idx : js.find("function docSlug", fn_idx)]
assert "conversation.length - 1" in fn_body, (
"the LAST user record wins (iterate backwards)"
)
assert 'conversation[i].who === "user"' in fn_body
assert 'question.replace(/\\s+/g, " ").trim().slice(0, DOC_TITLE_MAX)' in fn_body
assert '|| "Note"' in fn_body
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/.md.
assert "docs/${docSlug(title)}.md" in js
def test_app_js_post_payload_and_navigation() -> None:
"""Click → POST /api/doc-drafts {title, path, body: markdown} (the
raw markdown is the body — 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, markdown) {")
assert fn_idx != -1, "saveAsDoc missing"
fn_body = js[fn_idx : fn_idx + 3000]
assert 'fetch("/api/doc-drafts"' in fn_body
assert 'JSON.stringify({ title, path, body: markdown })' 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)