"""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; phase 122 (task 01) added the # ``images`` flag — the six keys below are the entire endpoint # contract. assert set(body) == { "app_name", "version", "docs_repo_configured", "images", "input_placeholder", "footer_text", } assert body["docs_repo_configured"] is s.docs_configured assert body["docs_repo_configured"] is False assert body["images"] is False # LOCKED A3: off by default s2 = _settings(docs_repo="/srv/docs-repo") assert app_config(s2)["docs_repo_configured"] is True s3 = _settings(images=True) assert app_config(s3)["images"] 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 — 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/.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. " + 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. " — 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)