feat(docs): save chat answers as docs — edit screen, commit + push to the .env docs branch

This commit is contained in:
2026-09-01 03:52:03 -04:00
parent 7b7a834a1a
commit 725af9fac1
32 changed files with 4356 additions and 107 deletions
+545
View File
@@ -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)"