feat(docs): save chat answers as docs — edit screen, commit + push to the .env docs branch
This commit is contained in:
@@ -212,6 +212,7 @@ def test_html_pages_include_history() -> None:
|
||||
"/git-sources.html",
|
||||
"/history.html",
|
||||
"/shared.html", # phase 51: the shared page's static path
|
||||
"/doc-edit.html", # phase 59: the doc edit screen (task 06)
|
||||
):
|
||||
assert path in caching.HTML_PAGES, f"{path} must be in HTML_PAGES"
|
||||
|
||||
|
||||
@@ -279,3 +279,121 @@ def test_effective_api_key_fallback(monkeypatch) -> None:
|
||||
monkeypatch.setenv("AIPI_KEY", "sk-from-env")
|
||||
s2 = _settings()
|
||||
assert s2.effective_api_key == "sk-from-env"
|
||||
|
||||
|
||||
# --- Docs push (phase 59) ---
|
||||
|
||||
|
||||
def test_docs_push_defaults_are_inert() -> None:
|
||||
"""Phase 59, D3: no docs repo by default — the feature is
|
||||
inert-by-default (button hidden, push endpoint 409s — the
|
||||
optional-feature pattern of the git-sources env fallback), and the
|
||||
branch/base defaults + raw work-dir string are in place."""
|
||||
s = _settings()
|
||||
assert s.docs_repo == ""
|
||||
assert s.docs_configured is False
|
||||
assert s.docs_branch == "bor-docs"
|
||||
assert s.docs_base_branch == "main"
|
||||
# Raw string on purpose — Path.expanduser() is applied by the push
|
||||
# service, not the setting (the sources_dir/upload_dir convention).
|
||||
assert s.docs_work_dir == "~/bor-docs"
|
||||
|
||||
|
||||
def test_docs_repo_set_is_configured(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A non-empty ``BOR_DOCS_REPO`` turns the feature on — a URL or a
|
||||
local path (D3: generic remote, no scheme parsing here)."""
|
||||
for repo in ("/path/to/docs-repo", "https://git.example.com/docs.git"):
|
||||
monkeypatch.setenv("BOR_DOCS_REPO", repo)
|
||||
s = _settings()
|
||||
assert s.docs_configured is True
|
||||
assert s.docs_repo == repo
|
||||
# Whitespace-only behaves like empty: still inert.
|
||||
monkeypatch.setenv("BOR_DOCS_REPO", " ")
|
||||
assert _settings().docs_configured is False
|
||||
|
||||
|
||||
def test_docs_branch_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("BOR_DOCS_BRANCH", raising=False)
|
||||
monkeypatch.delenv("BOR_DOCS_BASE_BRANCH", raising=False)
|
||||
assert _settings().docs_branch == "bor-docs"
|
||||
assert _settings().docs_base_branch == "main"
|
||||
monkeypatch.setenv("BOR_DOCS_BRANCH", "docs-pr")
|
||||
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "master")
|
||||
s = _settings()
|
||||
assert s.docs_branch == "docs-pr"
|
||||
assert s.docs_base_branch == "master"
|
||||
|
||||
|
||||
def test_docs_work_dir_env_override_is_raw_string(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("BOR_DOCS_WORK_DIR", "/data/bor/docs")
|
||||
s = _settings()
|
||||
assert s.docs_work_dir == "/data/bor/docs"
|
||||
|
||||
|
||||
def test_docs_branch_whitespace_fails_loudly_when_repo_set(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A whitespace-bearing branch would corrupt a ``git checkout``
|
||||
argument — fail loud at startup, naming the field (the
|
||||
``agent_max_rounds`` pattern)."""
|
||||
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
|
||||
monkeypatch.setenv("BOR_DOCS_BRANCH", "bor docs")
|
||||
with pytest.raises(ValidationError, match="docs_branch"):
|
||||
_settings()
|
||||
|
||||
|
||||
def test_docs_branch_dotdot_fails_loudly_when_repo_set(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``..`` is a path-traversal token, never part of a branch name.
|
||||
A blank branch is rejected too (empty while a repo is set)."""
|
||||
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
|
||||
monkeypatch.setenv("BOR_DOCS_BRANCH", "a..b")
|
||||
with pytest.raises(ValidationError, match="docs_branch"):
|
||||
_settings()
|
||||
monkeypatch.setenv("BOR_DOCS_BRANCH", " ")
|
||||
with pytest.raises(ValidationError, match="docs_branch"):
|
||||
_settings()
|
||||
|
||||
|
||||
def test_docs_base_branch_invalid_fails_loudly_naming_field(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The base branch gets the same token shape check — the error
|
||||
names ``docs_base_branch``, not the sibling field."""
|
||||
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
|
||||
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "bad branch")
|
||||
with pytest.raises(ValidationError, match="docs_base_branch"):
|
||||
_settings()
|
||||
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "a..b")
|
||||
with pytest.raises(ValidationError, match="docs_base_branch"):
|
||||
_settings()
|
||||
|
||||
|
||||
def test_docs_branchs_valid_when_repo_set(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Repo set + well-formed branch tokens boot cleanly and the
|
||||
feature is configured (dash/dot/slash branch names are legal git
|
||||
refs and stay accepted)."""
|
||||
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
|
||||
s = _settings() # defaults bor-docs / main
|
||||
assert s.docs_configured is True
|
||||
monkeypatch.setenv("BOR_DOCS_BRANCH", "feature/docs-update")
|
||||
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "develop")
|
||||
s2 = _settings()
|
||||
assert s2.docs_configured is True
|
||||
assert s2.docs_branch == "feature/docs-update"
|
||||
assert s2.docs_base_branch == "develop"
|
||||
|
||||
|
||||
def test_docs_branchs_garbage_ignored_when_repo_unset(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""All-or-nothing: while the repo is empty the feature is inert, so
|
||||
the (ignored) branch values must NOT block startup — only a
|
||||
configured repo makes the shape check apply."""
|
||||
monkeypatch.delenv("BOR_DOCS_REPO", raising=False)
|
||||
monkeypatch.setenv("BOR_DOCS_BRANCH", "bor docs..")
|
||||
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "..")
|
||||
s = _settings()
|
||||
assert s.docs_configured is False
|
||||
assert s.docs_branch == "bor docs.." # stored verbatim, never used
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
"""Unit: the phase-59 task-06 doc edit screen (``/doc-edit.html``).
|
||||
|
||||
No Python logic exists for this task — the behavior lives in
|
||||
``frontend/doc-edit.html`` + ``frontend/assets/doc-edit.js`` +
|
||||
``styles.css``, and it is E2E-gated by the story suite (task 07). Like
|
||||
the other frontend-adjacent unit files (``test_history_page.py``,
|
||||
``test_save_as_doc_button.py``), this module pins the HTML/JS/CSS
|
||||
markers the edit loop depends on, so a silent regression is caught
|
||||
without a browser:
|
||||
|
||||
* the house shell (AGENTS.md rule 5 + the login.html/shared.html
|
||||
minimal-flow-page lineage): skip-link, the SLIM header (brand +
|
||||
"Back to chat" — no nav), the 46rem base column (hard-coded — a form
|
||||
column, NOT ``--chat-column``), the ``container`` frame;
|
||||
* the form contract: ``#draft-title`` / ``#draft-path`` /
|
||||
``#draft-body`` with visible labels, ``#push-doc-btn`` (the exact
|
||||
"Push to docs branch" copy) + the back link, ``#push-status``
|
||||
(``role="status" aria-live="polite"``) and the hidden ``#push-error``
|
||||
(``role="alert"``);
|
||||
* the admin gate — the ``sources-gate`` pattern, ship-hidden, with the
|
||||
no-JS ``?next=`` fallback (the page is static; the API is the
|
||||
authority — the draft endpoints are admin-only regardless);
|
||||
* the JS: the whoami gate (anonymous branch makes NO ``/api/doc-drafts``
|
||||
call), the token handling (missing → "No draft specified.",
|
||||
non-uuid → "Draft not found." with no fetch), the three API paths
|
||||
(GET draft / PUT edits / POST push — the PUT runs BEFORE the push:
|
||||
the endpoint commits the row, so unsaved edits would push stale
|
||||
text), the §7.4 never-stale lifecycle (disable + "Pushing…",
|
||||
re-enable in the finally), the success line
|
||||
(``Pushed to <branch> — commit <sha7>.``), the failure banner
|
||||
(git's stderr trimmed to its first meaningful lines, fields
|
||||
preserved), and VALUES-not-innerHTML everywhere.
|
||||
|
||||
The Containerfile stage-1 coverage (doc-edit.html copied, doc-edit.js
|
||||
bundled) and the cache-busting registration (``/doc-edit.html`` in
|
||||
``HTML_PAGES``) are pinned by ``test_containerfile_assets.py`` /
|
||||
``test_caching.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
ASSETS = FRONTEND / "assets"
|
||||
DOC_EDIT_HTML = FRONTEND / "doc-edit.html"
|
||||
DOC_EDIT_JS = ASSETS / "doc-edit.js"
|
||||
STYLES_CSS = ASSETS / "styles.css"
|
||||
|
||||
|
||||
def _html() -> str:
|
||||
assert DOC_EDIT_HTML.is_file(), "frontend/doc-edit.html is missing"
|
||||
return DOC_EDIT_HTML.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
assert DOC_EDIT_JS.is_file(), "frontend/assets/doc-edit.js is missing"
|
||||
return DOC_EDIT_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of a top-level ``function <name>(...)`` (to its close)."""
|
||||
start = js.find(f"function {name}(")
|
||||
assert start != -1, f"{name}() must exist in doc-edit.js"
|
||||
return js[start : js.find("\n}\n", start) + 4]
|
||||
|
||||
|
||||
# ---------- the house shell (AGENTS.md rule 5) ----------
|
||||
|
||||
|
||||
def test_page_scaffold_slim_header_and_landmarks() -> None:
|
||||
"""The minimal-flow-page scaffold (the login.html/shared.html
|
||||
lineage): skip-link, the SLIM header (brand + the "Back to chat"
|
||||
link to / — and NO nav: this is a flow page, not one of the app's
|
||||
pages), ``<main id="main" class="app-main" tabindex="-1">`` with
|
||||
the ``container`` frame, and the house footer."""
|
||||
html = _html()
|
||||
assert '<a class="skip-link" href="#main">Skip to content</a>' in html
|
||||
assert 'class="app-header"' in html
|
||||
# The slim header: the brand + the back link.
|
||||
assert '<span class="brand-text">Brain of <strong>Reese</strong></span>' in html
|
||||
back = re.search(r'<a[^>]*class="doc-edit-back"[^>]*href="/"[^>]*>', html)
|
||||
assert back, "the header must carry the 'Back to chat' link to /"
|
||||
assert "<span>Back to chat</span>" in html
|
||||
# And NO nav — the flow-page lineage (no hamburger, no app-nav).
|
||||
assert 'id="app-nav"' not in html, "the slim header ships no nav"
|
||||
assert 'id="nav-toggle"' not in html, "the slim header ships no hamburger"
|
||||
assert "<main id=\"main\" class=\"app-main\" tabindex=\"-1\">" in html
|
||||
assert '<div class="container doc-edit-shell">' in html
|
||||
assert 'class="app-footer"' in html
|
||||
|
||||
|
||||
def test_page_title_and_description() -> None:
|
||||
"""The page's identity: the house title shape (<name> · Brain of
|
||||
Reese) + a description naming the admin-only flow."""
|
||||
html = _html()
|
||||
assert "<title>Edit doc · Brain of Reese</title>" in html
|
||||
desc = re.search(r'<meta name="description" content="([^"]+)"', html)
|
||||
assert desc, "the page must carry a meta description"
|
||||
assert "admin" in desc.group(1).lower()
|
||||
|
||||
|
||||
# ---------- the form contract ----------
|
||||
|
||||
|
||||
def test_form_fields_have_labels_and_ids() -> None:
|
||||
"""The three fields (task 06): #draft-title (text), #draft-path
|
||||
(text), #draft-body (textarea) — each with a VISIBLE
|
||||
``<label for=…>`` (WCAG input-label rule), the text inputs
|
||||
``required`` (the browser's native prompt is the first line of
|
||||
sanity), the body a <textarea>."""
|
||||
html = _html()
|
||||
for field_id, tag in (
|
||||
("draft-title", "input"),
|
||||
("draft-path", "input"),
|
||||
("draft-body", "textarea"),
|
||||
):
|
||||
assert f'<label for="{field_id}">' in html, (
|
||||
f"#{field_id} needs a visible label"
|
||||
)
|
||||
field = re.search(rf"<{tag}[^>]*id=\"{field_id}\"[^>]*>", html)
|
||||
assert field, f"#{field_id} is missing"
|
||||
title = re.search(r"<input[^>]*id=\"draft-title\"[^>]*>", html)
|
||||
path = re.search(r"<input[^>]*id=\"draft-path\"[^>]*>", html)
|
||||
body = re.search(r"<textarea[^>]*id=\"draft-body\"[^>]*>", html)
|
||||
assert title and path and body, "the draft field tags are missing"
|
||||
title, path, body = title.group(0), path.group(0), body.group(0)
|
||||
for f in (title, path, body):
|
||||
assert "required" in f, "the native `required` is the first line"
|
||||
assert "type=\"text\"" in title and "type=\"text\"" in path
|
||||
|
||||
|
||||
def test_push_button_and_back_link_actions() -> None:
|
||||
"""The actions (task 06): #push-doc-btn — the primary, exact copy
|
||||
"Push to docs branch" — and the back link to / (the form's second
|
||||
action; the header carries its own copy)."""
|
||||
html = _html()
|
||||
btn = re.search(r"<button[^>]*id=\"push-doc-btn\"[^>]*>", html)
|
||||
assert btn, "#push-doc-btn is missing"
|
||||
assert "type=\"submit\"" in btn.group(0), (
|
||||
"the push button submits the form (the handler preventDefaults)"
|
||||
)
|
||||
assert ">Push to docs branch</button>" in html, (
|
||||
"the exact house copy: 'Push to docs branch'"
|
||||
)
|
||||
# A back link inside the actions row (href="/").
|
||||
actions = html[html.find('class="doc-edit-actions"'):]
|
||||
actions = actions[: actions.find("</form>")]
|
||||
assert re.search(r'<a[^>]*class="doc-edit-back"[^>]*href="/"[^>]*>', actions), (
|
||||
"the actions row carries its own back link to /"
|
||||
)
|
||||
|
||||
|
||||
def test_feedback_live_region_and_error_banner() -> None:
|
||||
"""The "never stale" feedback contract (phase 55 convention,
|
||||
task 06): #push-status is the polite live region
|
||||
(role="status" aria-live="polite"); #push-error is the alert
|
||||
banner — SHIPS hidden (role="alert")."""
|
||||
html = _html()
|
||||
status = re.search(r'<[a-z]+[^>]*id="push-status"[^>]*>', html)
|
||||
assert status, "#push-status is missing"
|
||||
assert 'role="status"' in status.group(0)
|
||||
assert 'aria-live="polite"' in status.group(0)
|
||||
error = re.search(r'<[a-z]+[^>]*id="push-error"[^>]*>', html)
|
||||
assert error, "#push-error is missing"
|
||||
assert 'role="alert"' in error.group(0)
|
||||
assert "hidden" in error.group(0), "the error banner ships hidden"
|
||||
|
||||
|
||||
# ---------- the admin gate (the sources-gate pattern) ----------
|
||||
|
||||
|
||||
def test_admin_gate_ships_hidden_with_no_js_fallback() -> None:
|
||||
"""The gate: the EXACT .sources-gate pattern (phase 16/35/50),
|
||||
ship-hidden (the admin never sees it; the content div ships hidden
|
||||
too — anonymous-safe), the labelled h2, and the Sign in link whose
|
||||
static ?next= returns the admin to THIS page after login (the
|
||||
no-JS fallback)."""
|
||||
html = _html()
|
||||
gate = re.search(r'<section[^>]*class="sources-gate"[^>]*id="doc-edit-gate"[^>]*>', html)
|
||||
assert gate, "the #doc-edit-gate section (sources-gate pattern) is missing"
|
||||
assert "hidden" in gate.group(0), "the gate ships hidden"
|
||||
assert 'aria-labelledby="doc-edit-gate-title"' in gate.group(0)
|
||||
assert '<h2 id="doc-edit-gate-title">' in html
|
||||
assert '<a class="sources-gate-link" href="/login.html?next=/doc-edit.html">Sign in</a>' in html
|
||||
# The content ships hidden too (the gate is what anonymous sees).
|
||||
content = re.search(r'<div[^>]*id="doc-edit-content"[^>]*>', html)
|
||||
assert content and "hidden" in content.group(0), (
|
||||
"#doc-edit-content must ship hidden (anonymous-safe)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- scripts + no CDN ----------
|
||||
|
||||
|
||||
def test_script_load_order_and_no_cdn() -> None:
|
||||
"""The house script order: brand.js (classic) FIRST, the doc-edit.js
|
||||
module second; NO direct header.js <script> tag (single-evaluation
|
||||
design — doc-edit.js imports it relatively); no external
|
||||
src=/href= (AGENTS.md rule 6 — No CDN)."""
|
||||
html = _html()
|
||||
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
|
||||
assert srcs == ["assets/brand.js", "/assets/doc-edit.js"], (
|
||||
f"doc-edit.html must load brand.js (classic, first) + the "
|
||||
f"doc-edit.js module, got {srcs}"
|
||||
)
|
||||
js = _js()
|
||||
assert 'from "./header.js"' in js, (
|
||||
"doc-edit.js must import the shared header module relatively"
|
||||
)
|
||||
assert '"/assets/header.js"' not in js
|
||||
assert 'src="http' not in html and 'href="http' not in html, (
|
||||
"no CDN: every asset is local (AGENTS.md rule 6)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- boot: the whoami gate ----------
|
||||
|
||||
|
||||
def test_anonymous_boot_makes_no_drafts_request() -> None:
|
||||
"""The whoami gate in the boot IIFE: ``fetchIsAdmin()`` (the
|
||||
header.js cached whoami — the single /api/whoami call site) decides
|
||||
the gate. Anonymous: the gate shows, the content stays hidden, a
|
||||
bare return — and NO /api/doc-drafts call on the wire (the draft
|
||||
API is admin-only regardless; the story E2E pins the request
|
||||
log). Only the admin path reaches the token read + loadDraft."""
|
||||
js = _js()
|
||||
assert "fetchIsAdmin" in js, "the gate must run on the cached whoami"
|
||||
boot = js[js.find("(async () => {"):]
|
||||
assert boot, "the boot IIFE must exist"
|
||||
gate_i = boot.find("const admin = await fetchIsAdmin();")
|
||||
assert gate_i != -1, "boot must await fetchIsAdmin() first"
|
||||
branch = boot[gate_i : boot.find("return;", gate_i)]
|
||||
assert "fetch(" not in branch, (
|
||||
"the anonymous branch must not fetch anything (no draft leak)"
|
||||
)
|
||||
assert "gateEl.hidden = false" in branch
|
||||
assert "contentEl.hidden = true" in branch
|
||||
# The admin path: the gate hides, the content reveals, the token
|
||||
# is read, and only THEN does the draft load.
|
||||
after = boot[boot.find("return;", gate_i):]
|
||||
assert "gateEl.hidden = true" in after
|
||||
assert "contentEl.hidden = false" in after
|
||||
token_i = after.find('new URLSearchParams(window.location.search).get("draft")')
|
||||
assert token_i != -1, "boot must read ?draft=<token>"
|
||||
assert after.find("await loadDraft(token)") > token_i
|
||||
|
||||
|
||||
def test_token_missing_and_malformed_copy() -> None:
|
||||
"""The token handling: missing → the error banner "No draft
|
||||
specified."; a non-uuid token → "Draft not found." with NO fetch
|
||||
(the shared.js malformed-token precedent — a 422 validation line
|
||||
is framework noise, not a house message)."""
|
||||
js = _js()
|
||||
boot = js[js.find("(async () => {"):]
|
||||
missing_i = boot.find('showError("No draft specified.")')
|
||||
assert missing_i != -1, "the missing-token banner copy is pinned"
|
||||
# The uuid shape check gates the fetch (malformed → no request).
|
||||
malformed_i = boot.find("UUID_RE.test(token)")
|
||||
assert malformed_i != -1, "the uuid shape check must gate the fetch"
|
||||
after_malformed = boot[malformed_i : malformed_i + 300]
|
||||
assert 'showError("Draft not found.")' in after_malformed
|
||||
assert "fetch(" not in after_malformed, ("a malformed token must not fetch")
|
||||
# The regex is the 8-4-4-4-12 uuid shape (case-insensitive).
|
||||
assert "/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i" in js
|
||||
# And draftToken (the push's credential) is set only after the
|
||||
# checks — the load follows.
|
||||
assert boot.find("draftToken = token;") > malformed_i
|
||||
assert boot.find("await loadDraft(token)") > boot.find("draftToken = token;")
|
||||
|
||||
|
||||
# ---------- the three API paths ----------
|
||||
|
||||
|
||||
def test_the_three_api_paths() -> None:
|
||||
"""The edit loop's three draft API paths (task 06): GET
|
||||
/api/doc-drafts/<token> (load — in loadDraft), PUT
|
||||
/api/doc-drafts/<token> (persist the edits) and POST
|
||||
/api/doc-drafts/<token>/push (the single mutation). The PUT runs
|
||||
BEFORE the push: the push endpoint commits the ROW's
|
||||
title/path/body, so an unsaved edit would push stale text."""
|
||||
js = _js()
|
||||
load = _fn(js, "loadDraft")
|
||||
assert 'fetch(`/api/doc-drafts/${token}`)' in load, (
|
||||
"loadDraft must GET the draft by token"
|
||||
)
|
||||
assert "push" not in load.lower().replace("pushing", ""), (
|
||||
"loadDraft must not push (it only loads)"
|
||||
)
|
||||
push_fn = js[js.find("function wirePush() {"):]
|
||||
put_i = push_fn.find('method: "PUT"')
|
||||
post_i = push_fn.find("/push")
|
||||
assert put_i != -1 and post_i != -1, "the PUT + the POST /push must both exist"
|
||||
assert put_i < post_i, "the PUT (persist edits) must run BEFORE the push"
|
||||
assert 'fetch(`/api/doc-drafts/${draftToken}/push`, {' in push_fn, (
|
||||
"the push endpoint is POST /api/doc-drafts/<token>/push"
|
||||
)
|
||||
# Exactly one call per path — no duplicate fetch sites.
|
||||
assert js.count("doc-drafts") >= 3
|
||||
|
||||
|
||||
def test_load_fill_is_values_not_innerhtml() -> None:
|
||||
"""The 200 body fills the three fields with VALUES
|
||||
(``.value`` = textContent discipline) — NEVER innerHTML: the body
|
||||
is user-derived markdown, and the title/path may contain anything
|
||||
but markup. The whole file builds no HTML at all (the page markup
|
||||
is static; JS only reads/sets values and hidden flags)."""
|
||||
js = _js()
|
||||
load = _fn(js, "loadDraft")
|
||||
assert "titleInput.value = draft.title" in load
|
||||
assert "pathInput.value = draft.path" in load
|
||||
assert "bodyInput.value = draft.body" in load
|
||||
assert "innerHTML" not in js, "doc-edit.js must never build HTML"
|
||||
|
||||
|
||||
def test_load_outcome_copy() -> None:
|
||||
"""loadDraft's failure lines: 404 → "Draft not found." (no
|
||||
enumeration — one message for every unknown token), other non-2xx
|
||||
→ the server's detail (422 shape-aware), a network failure → the
|
||||
fixed one-line copy."""
|
||||
js = _js()
|
||||
load = _fn(js, "loadDraft")
|
||||
assert 'showError("Draft not found.")' in load
|
||||
assert "r.status === 404" in load
|
||||
assert "apiDetail(" in load, "non-2xx must surface the server detail"
|
||||
assert "is the app running?" in load, "the network-failure line"
|
||||
# The 404 check runs before the generic non-2xx arm.
|
||||
assert load.find("r.status === 404") < load.find("if (!r.ok)")
|
||||
|
||||
|
||||
# ---------- push: the never-stale lifecycle ----------
|
||||
|
||||
|
||||
def test_push_sanity_checks_before_any_request() -> None:
|
||||
"""The client-side sanity (the server is the authority — it
|
||||
re-runs the guard-rails): non-empty title, non-empty body, no
|
||||
".." in the path. Each violation lands the error banner, focuses
|
||||
the offending field, and returns BEFORE any fetch — and without a
|
||||
token the banner says "No draft specified." (no fetch)."""
|
||||
js = _js()
|
||||
fn = js[js.find("function wirePush() {"):]
|
||||
title_i = fn.find('showError("Enter a title for the doc.")')
|
||||
body_i = fn.find('showError("The doc body must not be empty.")')
|
||||
path_i = fn.find("path.includes(\"..\")")
|
||||
assert title_i != -1 and body_i != -1 and path_i != -1, (
|
||||
"the three sanity checks must exist"
|
||||
)
|
||||
assert title_i < body_i < path_i, "title, body, path — in field order"
|
||||
# Each violation focuses its field (keyboard a11y).
|
||||
assert "titleInput.focus()" in fn
|
||||
assert "bodyInput.focus()" in fn
|
||||
assert "pathInput.focus()" in fn
|
||||
# No token → the banner, no fetch (the first fetch comes later).
|
||||
notoken_i = fn.find('showError("No draft specified.")')
|
||||
first_fetch = fn.find("await fetch(")
|
||||
assert -1 < notoken_i < first_fetch
|
||||
|
||||
|
||||
def test_push_disables_relabels_and_reenables() -> None:
|
||||
"""The §7.4 never-stale lifecycle: the button disables +
|
||||
relabels "Pushing…" AND the live region says "Pushing…" while the
|
||||
request is out; the finally re-enables the button with its idle
|
||||
label (IDLE_LABEL = the exact static copy) on EVERY outcome —
|
||||
success OR failure, success AND failure."""
|
||||
js = _js()
|
||||
fn = js[js.find("function wirePush() {"):]
|
||||
disable_i = fn.find("pushBtn.disabled = true")
|
||||
relabel_i = fn.find('pushBtn.textContent = "Pushing…"')
|
||||
status_i = fn.find('setStatus("Pushing…")')
|
||||
assert disable_i != -1 and relabel_i != -1 and status_i != -1, (
|
||||
"disable + relabel + status before the requests"
|
||||
)
|
||||
fetch_i = fn.find("await fetch(")
|
||||
assert -1 < status_i < fetch_i, "the status line precedes the first request"
|
||||
finally_i = fn.find("} finally {")
|
||||
assert finally_i != -1, "the finally block is the never-stale guarantee"
|
||||
after_finally = fn[finally_i:]
|
||||
assert "pushBtn.disabled = false" in after_finally
|
||||
assert "pushBtn.textContent = IDLE_LABEL" in after_finally
|
||||
# The idle label IS the static button copy (a mismatch would
|
||||
# relabel the button into an unknown state on success).
|
||||
assert 'const IDLE_LABEL = "Push to docs branch";' in js
|
||||
|
||||
|
||||
def test_push_success_line_branch_and_sha7() -> None:
|
||||
"""The 200 outcome: the live region reads
|
||||
`Pushed to <branch> — commit <sha7>.` — the branch from the API,
|
||||
the commit sha TRUNCATED to its first seven chars for display (the
|
||||
full value stays in the API/draft row), the exact em-dash shape.
|
||||
The button re-enables (a re-push after further edits is a NEW
|
||||
commit — the D3 ASSUMPTION)."""
|
||||
js = _js()
|
||||
fn = js[js.find("function wirePush() {"):]
|
||||
assert (
|
||||
"`Pushed to ${pushed.branch} — commit ${String(pushed.commit_sha).slice(0, 7)}.`"
|
||||
in fn
|
||||
), "the success line is 'Pushed to <branch> — commit <sha7>.'"
|
||||
# The success line is set AFTER the push response is read.
|
||||
json_i = fn.find("await r.json()")
|
||||
ok_i = fn.find('`Pushed to ${pushed.branch}')
|
||||
assert -1 < json_i < ok_i
|
||||
|
||||
|
||||
def test_push_failure_banner_trims_git_detail_and_keeps_fields() -> None:
|
||||
"""The failure outcome: the #push-error banner with the API's
|
||||
detail — for a git 502 that is git's stderr, trimmed to its first
|
||||
meaningful lines (trimGitDetail: blank lines + the "hint:" chatter
|
||||
dropped, at most three lines, single-line details untouched) — the
|
||||
fields are PRESERVED (no input is cleared anywhere in the file)
|
||||
and the stale success line is cleared so only the error claims the
|
||||
outcome. Network failure → the fixed one-line copy."""
|
||||
js = _js()
|
||||
fn = js[js.find("function wirePush() {"):]
|
||||
assert "trimGitDetail(" in fn, "the failure detail must pass the trimmer"
|
||||
assert "apiDetail(" in fn, "the detail must be the API's (422-shape-aware)"
|
||||
# No field is ever cleared: the user's edits survive a failed push.
|
||||
for field in ('titleInput.value = ""', 'pathInput.value = ""',
|
||||
'bodyInput.value = ""', "titleInput.value=''",
|
||||
"pathInput.value=''", "bodyInput.value=''"):
|
||||
assert field not in js, f"a failed push must keep the edits, not {field!r}"
|
||||
assert "is the app running?" in fn, "the network-failure line"
|
||||
# The stale success line is cleared on failure (one claim at a
|
||||
# time — the PUT-failure arm clears it too).
|
||||
fail_i = fn.find("trimGitDetail(")
|
||||
assert fn.rfind('setStatus("")', 0, fail_i) > 0, (
|
||||
"a failed push clears the status line before the banner"
|
||||
)
|
||||
put_fail_i = fn.find('showError(await apiDetail(put')
|
||||
assert put_fail_i != -1
|
||||
assert fn.rfind('setStatus("")', 0, put_fail_i) > 0, (
|
||||
"a failed PUT also clears the stale status line"
|
||||
)
|
||||
|
||||
trim = _fn(js, "trimGitDetail")
|
||||
assert 'l.startsWith("hint:")' in trim, "the 'hint:' chatter is dropped"
|
||||
assert "slice(0, 3)" in trim, "at most three meaningful lines"
|
||||
assert "filter(" in trim and ".trim()" in trim
|
||||
|
||||
|
||||
def test_trim_git_detail_behavior_is_pinned_by_the_markers() -> None:
|
||||
"""The trimmer's contract in one place: non-empty lines that are
|
||||
not hint: lines, up to three, space-joined, with a fallback for an
|
||||
all-hint/empty detail (the banner must never be blank)."""
|
||||
js = _js()
|
||||
trim = _fn(js, "trimGitDetail")
|
||||
assert "split(\"\\n\")" in trim
|
||||
assert 'join(" ")' in trim
|
||||
assert '|| "The push failed."' in trim, "the empty-detail fallback"
|
||||
|
||||
|
||||
# ---------- styles.css: the new classes ----------
|
||||
|
||||
|
||||
def test_doc_edit_shell_is_the_hardcoded_46rem_column() -> None:
|
||||
""".doc-edit-shell: the 46rem base column — HARD-CODED 46rem (a
|
||||
form column, not a reading column — it must NOT ride
|
||||
--chat-column, so phase 58's wide-desktop doubling never stretches
|
||||
the form), centered, a flex column on the container frame."""
|
||||
css = _css()
|
||||
block = re.search(r"\.doc-edit-shell \{([\s\S]*?)\n\}", css)
|
||||
assert block, "styles.css must style .doc-edit-shell"
|
||||
body = block.group(1)
|
||||
assert "max-width: 46rem" in body, "the 46rem base column (hard-coded)"
|
||||
assert "--chat-column" not in body, (
|
||||
"the form column does not ride --chat-column (phase 58 must "
|
||||
"not stretch it)"
|
||||
)
|
||||
assert "margin-inline: auto" in body
|
||||
assert "flex-direction: column" in body
|
||||
|
||||
|
||||
def test_back_link_and_push_button_css() -> None:
|
||||
""".doc-edit-back: the ghost language (>=44px target, --line
|
||||
border, ink-soft on the --surface bar), pushed right
|
||||
(margin-left: auto); #push-doc-btn: the brand primary (dark ink on
|
||||
brand 5.2:1 — never white on brand), >=44px, a :disabled state
|
||||
(the "Pushing…" affordance)."""
|
||||
css = _css()
|
||||
back = re.search(r"\.doc-edit-back \{([\s\S]*?)\n\}", css)
|
||||
assert back, "styles.css must style .doc-edit-back"
|
||||
bbody = back.group(1)
|
||||
assert "min-height: 44px" in bbody
|
||||
assert "border: 1px solid var(--line)" in bbody
|
||||
assert "var(--ink-soft)" in bbody
|
||||
assert "margin-left: auto" in bbody
|
||||
btn = re.search(r"#push-doc-btn \{([\s\S]*?)\n\}", css)
|
||||
assert btn, "styles.css must style #push-doc-btn"
|
||||
tbody = btn.group(1)
|
||||
assert "background: var(--brand)" in tbody
|
||||
assert "color: var(--bg)" in tbody, "dark ink on brand (never white)"
|
||||
assert "min-height: 44px" in tbody
|
||||
assert re.search(r"#push-doc-btn:disabled \{[^}]*opacity[^}]*\}", css), (
|
||||
"the disabled (Pushing…) state must be styled"
|
||||
)
|
||||
|
||||
|
||||
def test_form_fields_css_mono_and_min_height() -> None:
|
||||
"""#draft-path and #draft-body are MONO (the path is machine data;
|
||||
the body is markdown) on the inset bg fill; #draft-body carries
|
||||
the pinned min-height: 20rem; the inputs keep the 44px floor."""
|
||||
css = _css()
|
||||
pair = re.search(
|
||||
r"#draft-title,\n#draft-path \{([\s\S]*?)\n\}", css
|
||||
)
|
||||
assert pair, "styles.css must style the two text inputs"
|
||||
assert "min-height: 44px" in pair.group(1)
|
||||
# The DEDICATED #draft-path rule (the pair above shares the name in
|
||||
# its selector list — search past the pair's closing brace).
|
||||
path = re.search(
|
||||
r"#draft-path \{([\s\S]*?)\n\}", css[pair.end():]
|
||||
)
|
||||
assert path and "var(--mono)" in path.group(1), "#draft-path must be mono"
|
||||
body = re.search(r"#draft-body \{([\s\S]*?)\n\}", css)
|
||||
assert body, "styles.css must style #draft-body"
|
||||
bbody = body.group(1)
|
||||
assert "var(--mono)" in bbody, "#draft-body must be mono"
|
||||
assert "min-height: 20rem" in bbody, "the pinned 20rem body floor"
|
||||
assert "resize: vertical" in bbody
|
||||
|
||||
|
||||
def test_status_and_error_css_families() -> None:
|
||||
""".doc-edit-status: the ok family (ok-ink on ok-bg 10.6:1) when
|
||||
a push outcome has landed, the dashed placeholder when empty;
|
||||
.doc-edit-error: the err family (err-ink on err-bg 9.3:1,
|
||||
err-line border) with long-word breaking (git paths). The global
|
||||
3px :focus-visible ring covers the new controls (AGENTS.md rule 5)."""
|
||||
css = _css()
|
||||
status = re.search(r"\.doc-edit-status \{([\s\S]*?)\n\}", css)
|
||||
assert status, "styles.css must style .doc-edit-status"
|
||||
sbody = status.group(1)
|
||||
assert "var(--ok-bg)" in sbody and "var(--ok-ink)" in sbody
|
||||
assert re.search(r"\.doc-edit-status:empty \{", css), (
|
||||
"the empty status must be the dashed placeholder"
|
||||
)
|
||||
error = re.search(r"\.doc-edit-error \{([\s\S]*?)\n\}", css)
|
||||
assert error, "styles.css must style .doc-edit-error"
|
||||
ebody = error.group(1)
|
||||
assert "var(--err-bg)" in ebody and "var(--err-ink)" in ebody
|
||||
assert "var(--err-line)" in ebody
|
||||
assert "overflow-wrap: anywhere" in ebody
|
||||
assert ":focus-visible" in css, "the global focus ring (AGENTS.md rule 5)"
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Unit tests: docs-push service (phase 59, task 03).
|
||||
|
||||
``push_document`` is exercised against a **real local git repo** — a
|
||||
bare origin in ``tmp_path`` plus the working clones the service creates
|
||||
itself — and every result assertion reads the bare repo's state
|
||||
directly (``git show <branch>:<path>``, ``git rev-list``), not the
|
||||
return value alone. The remote is a plain local path, so no network is
|
||||
ever involved.
|
||||
|
||||
The module skips (``pytest.skip``) when ``git --version`` fails — a
|
||||
machine without git must not see hard failures.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.docs_push import DocsPushError, push_document
|
||||
|
||||
BASE = "main"
|
||||
BRANCH = "bor-docs"
|
||||
REL = "docs/note.md"
|
||||
IDENTITY = ("-c", "commit.gpgsign=false", "-c", "user.name=Test", "-c", "user.email=t@example.com")
|
||||
|
||||
|
||||
def _git_available() -> bool:
|
||||
try:
|
||||
proc = subprocess.run(["git", "--version"], capture_output=True, check=False)
|
||||
return proc.returncode == 0
|
||||
except (FileNotFoundError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _require_git() -> None:
|
||||
"""Skip the whole module when the git CLI is missing."""
|
||||
if not _git_available():
|
||||
pytest.skip("git is not available on this machine")
|
||||
|
||||
|
||||
def _git(cwd: Path, *argv: str) -> str:
|
||||
"""Run git for the tests themselves (setup + assertions); loud on failure."""
|
||||
proc = subprocess.run(["git", *argv], cwd=cwd, capture_output=True, text=True, check=False)
|
||||
assert proc.returncode == 0, f"git {' '.join(argv)} failed: {proc.stderr}"
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _push(bare: Path, work: Path, content: str, message: str = "docs: note") -> tuple[str, str]:
|
||||
"""push_document against the fixture bare repo (plain local path)."""
|
||||
return push_document(
|
||||
repo=str(bare),
|
||||
base_branch=BASE,
|
||||
branch=BRANCH,
|
||||
work_dir=str(work),
|
||||
rel_path=REL,
|
||||
content=content,
|
||||
commit_message=message,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def bare_repo(tmp_path: Path) -> Path:
|
||||
"""A bare origin seeded with one commit on ``main`` (``README.md``)."""
|
||||
bare = tmp_path / "bare.git"
|
||||
_git(tmp_path, "init", "--bare", str(bare))
|
||||
seed = tmp_path / "seed"
|
||||
_git(tmp_path, "clone", str(bare), str(seed))
|
||||
(seed / "README.md").write_text("# docs\n", encoding="utf-8")
|
||||
_git(seed, "checkout", "-B", BASE)
|
||||
_git(seed, *IDENTITY, "add", "README.md")
|
||||
_git(seed, *IDENTITY, "commit", "-m", "seed README")
|
||||
_git(seed, "push", "origin", BASE)
|
||||
return bare
|
||||
|
||||
|
||||
def test_first_push_creates_branch_and_returns_sha(bare_repo: Path, tmp_path: Path) -> None:
|
||||
"""First push: clones the base, creates the branch, lands the file."""
|
||||
work = tmp_path / "work" # absent — push_document clones it
|
||||
branch, sha = _push(bare_repo, work, "# Note\n\nbody one\n")
|
||||
|
||||
assert branch == BRANCH
|
||||
assert (work / ".git").is_dir()
|
||||
assert (work / REL).read_text(encoding="utf-8") == "# Note\n\nbody one\n"
|
||||
# The file lands on the branch of the BARE repo, at the returned sha.
|
||||
assert _git(bare_repo, "show", f"{BRANCH}:{REL}") == "# Note\n\nbody one\n"
|
||||
assert _git(bare_repo, "rev-parse", BRANCH).strip() == sha
|
||||
assert len(sha) == 40
|
||||
# Exactly one commit beyond main.
|
||||
assert _git(bare_repo, "rev-list", "--count", f"main..{BRANCH}").strip() == "1"
|
||||
# Fixed per-invocation identity + message (no global git config reliance).
|
||||
ident = _git(bare_repo, "log", "-1", BRANCH, "--format=%an <%ae>").strip()
|
||||
assert ident == "Brain of Reese <bor@local>"
|
||||
assert _git(bare_repo, "log", "-1", BRANCH, "--format=%s").strip() == "docs: note"
|
||||
|
||||
|
||||
def test_second_push_fast_forwards_same_branch(bare_repo: Path, tmp_path: Path) -> None:
|
||||
"""Second push (edited content, same path): fast-forward, 2 commits."""
|
||||
work = tmp_path / "work"
|
||||
sha1 = _push(bare_repo, work, "v1\n")[1]
|
||||
sha2 = _push(bare_repo, work, "v2 edited\n")[1]
|
||||
|
||||
assert sha1 != sha2
|
||||
assert _git(bare_repo, "show", f"{BRANCH}:{REL}") == "v2 edited\n"
|
||||
assert _git(bare_repo, "rev-list", "--count", f"main..{BRANCH}").strip() == "2"
|
||||
# Fast-forward, no force: the first commit is still an ancestor.
|
||||
_git(bare_repo, "merge-base", "--is-ancestor", sha1, sha2)
|
||||
|
||||
|
||||
def test_fresh_checkout_reattaches_onto_remote_branch(bare_repo: Path, tmp_path: Path) -> None:
|
||||
"""An absent checkout re-attaches onto the existing remote branch
|
||||
(its history) so the push still fast-forwards."""
|
||||
work1 = tmp_path / "work1"
|
||||
sha1 = _push(bare_repo, work1, "v1\n")[1]
|
||||
work2 = tmp_path / "work2" # different dir — push_document clones anew
|
||||
branch, sha2 = _push(bare_repo, work2, "v2\n")
|
||||
|
||||
assert branch == BRANCH
|
||||
assert _git(bare_repo, "rev-list", "--count", f"main..{BRANCH}").strip() == "2"
|
||||
assert _git(bare_repo, "rev-parse", BRANCH).strip() == sha2
|
||||
# work2's commit sits on work1's commit (re-attach, not a fork).
|
||||
_git(bare_repo, "merge-base", "--is-ancestor", sha1, sha2)
|
||||
|
||||
|
||||
def test_concurrently_advanced_remote_fails_loudly(bare_repo: Path, tmp_path: Path) -> None:
|
||||
"""Remote advanced by a second clone → the first clone's push is a
|
||||
non-fast-forward: DocsPushError carrying git's stderr, remote kept."""
|
||||
work_a = tmp_path / "work_a"
|
||||
_push(bare_repo, work_a, "from A\n")
|
||||
|
||||
# A second clone advances the branch on the bare repo.
|
||||
work_b = tmp_path / "work_b"
|
||||
_git(tmp_path, "clone", "--depth", "1", "--branch", BRANCH, str(bare_repo), str(work_b))
|
||||
(work_b / "docs" / "other.md").write_text("from B\n", encoding="utf-8")
|
||||
_git(work_b, *IDENTITY, "add", "docs/other.md")
|
||||
_git(work_b, *IDENTITY, "commit", "-m", "docs: other")
|
||||
_git(work_b, "push", "origin", BRANCH)
|
||||
remote_tip_before = _git(bare_repo, "rev-parse", BRANCH).strip()
|
||||
|
||||
with pytest.raises(DocsPushError) as excinfo:
|
||||
_push(bare_repo, work_a, "from A again\n")
|
||||
|
||||
msg = str(excinfo.value)
|
||||
# git's stderr is surfaced (the non-fast-forward refusal).
|
||||
assert "non-fast-forward" in msg
|
||||
assert "rejected" in msg
|
||||
# The remote branch was NOT touched (no force-push, no merge).
|
||||
assert _git(bare_repo, "rev-parse", BRANCH).strip() == remote_tip_before
|
||||
assert _git(bare_repo, "show", f"{BRANCH}:{REL}") == "from A\n"
|
||||
|
||||
|
||||
def test_missing_repo_path_fails_loudly(tmp_path: Path) -> None:
|
||||
"""No such repo → DocsPushError naming the failed git step."""
|
||||
with pytest.raises(DocsPushError, match="git clone .* failed"):
|
||||
push_document(
|
||||
repo=str(tmp_path / "no-such-repo"),
|
||||
base_branch=BASE,
|
||||
branch=BRANCH,
|
||||
work_dir=str(tmp_path / "w"),
|
||||
rel_path=REL,
|
||||
content="x\n",
|
||||
commit_message="docs: x",
|
||||
)
|
||||
# No fake checkout is left behind.
|
||||
assert not (tmp_path / "w" / ".git").exists()
|
||||
|
||||
|
||||
def test_non_repo_dir_fails_loudly(tmp_path: Path) -> None:
|
||||
"""A plain directory (not a git repo) as the remote → DocsPushError."""
|
||||
plain = tmp_path / "plain"
|
||||
plain.mkdir()
|
||||
(plain / "file.txt").write_text("not a repo\n", encoding="utf-8")
|
||||
with pytest.raises(DocsPushError, match="failed"):
|
||||
push_document(
|
||||
repo=str(plain),
|
||||
base_branch=BASE,
|
||||
branch=BRANCH,
|
||||
work_dir=str(tmp_path / "w"),
|
||||
rel_path=REL,
|
||||
content="x\n",
|
||||
commit_message="docs: x",
|
||||
)
|
||||
|
||||
|
||||
def test_unsafe_rel_path_is_refused_before_any_git(bare_repo: Path, tmp_path: Path) -> None:
|
||||
"""The defensive parts re-assertion refuses traversal paths."""
|
||||
for bad in ("../evil.md", "/etc/passwd", "a/b/../c.md"):
|
||||
with pytest.raises(DocsPushError, match="unsafe rel_path"):
|
||||
push_document(
|
||||
repo=str(bare_repo),
|
||||
base_branch=BASE,
|
||||
branch=BRANCH,
|
||||
work_dir=str(tmp_path / "w"),
|
||||
rel_path=bad,
|
||||
content="x\n",
|
||||
commit_message="docs: x",
|
||||
)
|
||||
# No checkout was even attempted.
|
||||
assert not (tmp_path / "w").exists()
|
||||
@@ -30,6 +30,7 @@ HTML_PAGES = (
|
||||
"git-sources.html",
|
||||
"history.html", # phase 50: the admin saved-chats page
|
||||
"shared.html", # phase 51: the anonymous shared-conversation page
|
||||
"doc-edit.html", # phase 59: the admin doc edit screen (flow page)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -143,11 +143,11 @@ def test_missing_git_raises_named_error(
|
||||
clone_or_pull("https://example.com/homelab.git", tmp_path / "homelab")
|
||||
|
||||
|
||||
def test_run_captures_and_returns_stdout(
|
||||
def test_run_git_captures_and_returns_stdout(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""_run returns the captured stdout on success (git output is not lost)."""
|
||||
"""run_git returns the captured stdout on success (git output is not lost)."""
|
||||
calls = _fake_run(monkeypatch, stdout="From example.com\n + abc..def main")
|
||||
|
||||
assert git_sync._run(["git", "status"], cwd=tmp_path) == "From example.com\n + abc..def main"
|
||||
assert git_sync.run_git(["git", "status"], cwd=tmp_path) == "From example.com\n + abc..def main"
|
||||
assert len(calls) == 1
|
||||
|
||||
@@ -44,3 +44,36 @@ def test_documents_unique_source_path() -> None:
|
||||
and {col.name for col in c.columns} == {"source", "path"}
|
||||
]
|
||||
assert uq, "documents must be unique on (source, path) — the upsert key"
|
||||
|
||||
|
||||
def test_doc_drafts_token_is_unique_not_null() -> None:
|
||||
"""Phase 59: the draft's URL credential — an unguessable uuid4,
|
||||
UNIQUE + NOT NULL (no "un-drafted" state, unlike the NULLable
|
||||
``saved_chats.share_token``)."""
|
||||
drafts = Base.metadata.tables["doc_drafts"]
|
||||
token = drafts.c["token"]
|
||||
assert token.nullable is False, "doc_drafts.token must be NOT NULL"
|
||||
uq = [
|
||||
c
|
||||
for c in drafts.constraints
|
||||
if isinstance(c, UniqueConstraint)
|
||||
and {col.name for col in c.columns} == {"token"}
|
||||
]
|
||||
assert uq, "doc_drafts must be unique on (token) — the URL credential"
|
||||
|
||||
|
||||
def test_doc_drafts_column_contract() -> None:
|
||||
"""Phase 59: the editable triple (title/path/body) + status +
|
||||
timestamps are NOT NULL; ``branch`` / ``commit_sha`` are NULL
|
||||
until the push endpoint records them."""
|
||||
drafts = Base.metadata.tables["doc_drafts"]
|
||||
assert set(drafts.c.keys()) == {
|
||||
"id", "token", "title", "path", "body", "status",
|
||||
"branch", "commit_sha", "created_at", "updated_at",
|
||||
}
|
||||
for name in ("title", "path", "body", "status", "created_at", "updated_at"):
|
||||
assert drafts.c[name].nullable is False, f"{name} must be NOT NULL"
|
||||
for name in ("branch", "commit_sha"):
|
||||
assert drafts.c[name].nullable is True, f"{name} must be NULL until pushed"
|
||||
assert drafts.c["status"].default is not None, "status needs an ORM default (draft)"
|
||||
assert drafts.c["token"].default is not None, "token needs an ORM default (uuid4)"
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""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)
|
||||
assert set(body) == {"app_name", "version", "docs_repo_configured"}
|
||||
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 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/<slug>.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)
|
||||
@@ -16,10 +16,11 @@ without a browser:
|
||||
``.shared-shell``, ``.doc-md``, ``.doc-summary:has(+ .doc-md)`` —
|
||||
each capped with ``max-width: var(--chat-column)`` and NOTHING else
|
||||
in the file uses the token (exactly four rules);
|
||||
* the negative pin — ``.tuning-shell`` (a form, not a reading
|
||||
surface) keeps its hard-coded ``max-width: 46rem`` at every width,
|
||||
and it is the only literal ``max-width: 46rem`` rule left in the
|
||||
file;
|
||||
* the negative pin — the form columns (``.tuning-shell``; and from
|
||||
phase 59, task 06, ``.doc-edit-shell`` — forms, not reading
|
||||
surfaces) are the only literal ``max-width: 46rem`` rules left in
|
||||
the file, kept hard-coded so the wide-desktop doubling never
|
||||
stretches a form;
|
||||
* the "46rem column contract" block comments were updated to name the
|
||||
base value + the wide override (the stale "≤46rem" contract claims
|
||||
are gone from the reading-column comments).
|
||||
@@ -166,15 +167,21 @@ def test_tuning_shell_stays_hardcoded_46rem() -> None:
|
||||
|
||||
|
||||
def test_no_other_hardcoded_46rem_rule_remains() -> None:
|
||||
"""After the switch, the .tuning-shell rule is the ONLY rule with
|
||||
a literal max-width: 46rem — every reading column rides the
|
||||
token (the --chat-column base declaration is the other
|
||||
non-rule occurrence of 46rem)."""
|
||||
"""After the switch, the form columns are the ONLY rules with a
|
||||
literal max-width: 46rem: .tuning-shell (phase 27) and
|
||||
.doc-edit-shell (phase 59, task 06 — the doc edit screen is a
|
||||
FORM column, not a reading column, so it must not ride
|
||||
--chat-column and phase 58's wide-desktop doubling must never
|
||||
stretch the form). Every reading column rides the token (the
|
||||
--chat-column base declaration is the other non-rule occurrence
|
||||
of 46rem)."""
|
||||
css = _css()
|
||||
assert css.count("max-width: 46rem") == 1, (
|
||||
"only .tuning-shell may keep a literal max-width: 46rem"
|
||||
assert css.count("max-width: 46rem") == 2, (
|
||||
"only the form columns (.tuning-shell, .doc-edit-shell) may "
|
||||
"keep a literal max-width: 46rem"
|
||||
)
|
||||
assert "max-width: 46rem" in _rule_block(css, ".tuning-shell")
|
||||
assert "max-width: 46rem" in _rule_block(css, ".doc-edit-shell")
|
||||
|
||||
|
||||
def test_comments_cite_the_wide_override_with_provenance() -> None:
|
||||
|
||||
Reference in New Issue
Block a user