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
+10
View File
@@ -82,6 +82,16 @@ BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,container,network,volume,
# BOR_UPLOAD_MAX_MB=512 # caps BOTH the compressed upload and the total # BOR_UPLOAD_MAX_MB=512 # caps BOTH the compressed upload and the total
# extracted bytes (zip-bomb guard); must be > 0 # extracted bytes (zip-bomb guard); must be > 0
# --- Docs push (phase 59: save a chat answer as documentation) ---
# The git repo chat answers can be committed to — any remote (URL or
# local path). While empty, the "Save as doc" action is hidden and the
# push endpoint 409s. Commits land on BOR_DOCS_BRANCH (push --ff-only);
# open the PR yourself.
# BOR_DOCS_REPO=/path/to/docs-repo
# BOR_DOCS_BRANCH=bor-docs
# BOR_DOCS_BASE_BRANCH=main
# BOR_DOCS_WORK_DIR=~/bor-docs
# --- Admin & sign-in (single-admin password login; BOTH required) --- # --- Admin & sign-in (single-admin password login; BOTH required) ---
# The app refuses to start while either is empty (names the missing # The app refuses to start while either is empty (names the missing
# variable(s) — README "Admin & sign-in"). Generate the secret with: # variable(s) — README "Admin & sign-in"). Generate the secret with:
+2 -1
View File
@@ -22,10 +22,11 @@ RUN mkdir -p /out/assets \
&& esbuild ./assets/git-sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/git-sources.js \ && esbuild ./assets/git-sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/git-sources.js \
&& esbuild ./assets/history.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/history.js \ && esbuild ./assets/history.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/history.js \
&& esbuild ./assets/shared.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/shared.js \ && esbuild ./assets/shared.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/shared.js \
&& esbuild ./assets/doc-edit.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/doc-edit.js \
&& esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \ && esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \ && esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \ && esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
&& cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html ./history.html ./shared.html /out/ && cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html ./history.html ./shared.html ./doc-edit.html /out/
# ---------- Stage 2: python dependencies ---------- # ---------- Stage 2: python dependencies ----------
FROM docker.io/python:3.12-slim AS python FROM docker.io/python:3.12-slim AS python
+79
View File
@@ -0,0 +1,79 @@
"""doc_drafts: server-side drafts of chat answers saved as docs (phase 59)
Revision ID: 0011
Revises: 0010
Create Date: 2026-09-01
Phase 59 (save chat answers as docs — edit screen → commit + push to the
.env docs branch, owner revision D3 2026-08-31: no PR tooling — one
additive, reversible table, no other schema change, A13):
* ``doc_drafts`` — one row per "Save as doc" action: the long answer
body must live on the **server**, never in a URL. ``token`` is an
unguessable 128-bit ``uuid4`` — the edit screen's URL credential
(``/doc-edit.html?draft=<token>``, the share-token trust model,
phase 51) — UNIQUE (``ix_doc_drafts_token``) + NOT NULL. Unlike the
NULLable ``saved_chats.share_token`` there is no "un-drafted" state,
so no NULLs ever occur; the unique index is the guard against
duplicate tokens.
* ``title`` / ``path`` / ``body`` — the editable triple (TEXT NOT NULL;
the body is the answer's raw markdown, never HTML — the
``bor.chat.v1`` record shape).
* ``status`` — plain TEXT + server default ``'draft'`` (the
``git_sources.kind`` phase-38 precedent — the ``draft`` | ``pushed``
domain is enforced by the API layer, not a CHECK constraint).
* ``branch`` / ``commit_sha`` — TEXT NULL: set by the push endpoint
(task 04) when it commits + pushes the file to the ``BOR_DOCS_REPO``
branch, recording the branch + the pushed branch's new HEAD (the UI's
branch + sha feedback).
* ``created_at`` / ``updated_at`` — TIMESTAMPTZ NOT NULL, stamped
server-side (``updated_at`` bumps on every row update via the ORM
``onupdate`` — the ``saved_chats`` precedent).
"""
from __future__ import annotations
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision = "0011"
down_revision = "0010"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"doc_drafts",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("token", postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("title", sa.Text(), nullable=False),
sa.Column("path", sa.Text(), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("status", sa.Text(), nullable=False, server_default=sa.text("'draft'")),
sa.Column("branch", sa.Text(), nullable=True),
sa.Column("commit_sha", sa.Text(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
# The token is the URL credential — a unique handle (the
# saved_chats.share_token unique-index precedent, phase 51).
op.create_index("ix_doc_drafts_token", "doc_drafts", ["token"], unique=True)
def downgrade() -> None:
# Safe order: drop the token index first, then the table (A13 —
# fully reversible, no other schema change).
op.drop_index("ix_doc_drafts_token", table_name="doc_drafts")
op.drop_table("doc_drafts")
+13 -4
View File
@@ -1,4 +1,5 @@
"""Public app metadata (display name + version) for the frontend brand layer.""" """Public app metadata (display name + version) for the frontend brand
layer, plus the phase-59 docs-push flag (the "Save as doc" gating)."""
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@@ -9,6 +10,14 @@ router = APIRouter(tags=["config"])
@router.get("/config") @router.get("/config")
def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str]: # noqa: B008 def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str | bool]: # noqa: B008
"""Public app metadata for the frontend brand layer (phase 39).""" """Public app metadata for the frontend brand layer (phase 39) +
return {"app_name": settings.app_name, "version": settings.app_version} the phase-59 ``docs_repo_configured`` flag — the chat page's
"Save as doc" button gating, surfaced the way ``app_name`` is
(the SAME boot fetch, no new network surface). Inert false while
``BOR_DOCS_REPO`` is empty (the feature is off, D3)."""
return {
"app_name": settings.app_name,
"version": settings.app_version,
"docs_repo_configured": settings.docs_configured,
}
+292
View File
@@ -0,0 +1,292 @@
"""Doc-drafts API — the draft lifecycle the edit screen runs on
(phase 59, task 02).
The "Save as doc" action (task 05) POSTs a completed answer here, the
edit screen (task 06) GETs/PUTs it by token, and the push endpoint
(task 04) commits + pushes it. A draft's long answer body lives on the
**server** — never in a URL: the row is keyed by an unguessable 128-bit
``uuid4`` ``token`` (the edit screen's URL credential,
``/doc-edit.html?draft=<token>`` — the share-token trust model,
phase 51).
The whole router sits behind :func:`app.core.auth.require_admin`
(router-wide ``dependencies`` — the :mod:`app.api.steering` pattern):
drafts are admin-only, so anonymous callers get 403 on every route.
Routes: ``POST /api/doc-drafts`` (create — 201, ``token = uuid4``,
``status = "draft"``), ``GET /api/doc-drafts/{token}`` (fetch by
token — 404 ``draft not found`` when unknown), ``PUT
/api/doc-drafts/{token}`` (partial update — absent fields unchanged,
``updated_at`` bumped; editing a ``pushed`` draft resets ``status``
back to ``draft`` — the stored sha no longer describes the current
body, so the next push re-commits; phase 59 D3 ASSUMPTION), ``POST
/api/doc-drafts/{token}/push`` (the single mutation the edit screen
triggers — commit + ``git push`` the draft's file to the
``BOR_DOCS_REPO`` ``BOR_DOCS_BRANCH`` via
:func:`app.core.docs_push.push_document`; success records
``status`` / ``branch`` / ``commit_sha`` on the row and returns
``DocDraftPushed``; 409 while unconfigured, 422 on a path that no
longer passes the guard-rails, 502 on git failure with git's stderr
in the detail — the row untouched).
Every ``path`` (create, update **and** push) passes the shared
:func:`validate_draft_path` guard, so no draft can ever be created,
edited or pushed with a path that escapes the repo root.
"""
from __future__ import annotations
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.config import Settings, get_settings
from app.core.auth import require_admin
from app.core.docs_push import DocsPushError, push_document
from app.db import get_db
from app.models import DocDraft
from app.schemas import DocDraft as DocDraftOut
from app.schemas import (
DocDraftCreate,
DocDraftPushed,
DocDraftUpdate,
)
router = APIRouter(
prefix="/doc-drafts",
tags=["doc-drafts"],
dependencies=[Depends(require_admin)], # phase 59: drafts are admin-only
)
def validate_draft_path(raw: str) -> str:
"""Guard-rail an in-repo doc path (phase 59, task 02).
Shared by the create/update routes **and** the push endpoint (task
04): the file will later be written inside the ``BOR_DOCS_REPO``
checkout, so a path that escapes the repo root is a security hole,
not a typo. Rules (first violation wins, each 422 names its rule):
* non-empty after strip — a blank path names no file;
* not absolute — ``/etc/passwd`` would leave the checkout;
* no ``.``/``..`` components — ``../x.md`` and ``a/b/../c.md`` walk
out of the checkout (checked via ``Path(p).parts``, so the
traversal is rejected wherever it sits);
* carries a file suffix (``Path(p).suffix`` non-empty) — the
committed file must be a real file, e.g. ``docs/note.md``.
Returns the stripped path (the value that gets stored); raises
:class:`fastapi.HTTPException` (422) on the first violated rule.
"""
path = raw.strip()
if not path:
raise HTTPException(status_code=422, detail="path must not be empty")
if Path(path).is_absolute():
raise HTTPException(status_code=422, detail="path must not be an absolute path")
if any(part in (".", "..") for part in Path(path).parts):
raise HTTPException(
status_code=422,
detail="path must not contain '.' or '..' path components",
)
if not Path(path).suffix:
raise HTTPException(
status_code=422, detail="path must carry a file suffix (e.g. docs/note.md)"
)
return path
def _to_out(row: DocDraft) -> DocDraftOut:
"""The full-payload response shape (create/get/put)."""
return DocDraftOut(
token=row.token,
title=row.title,
path=row.path,
body=row.body,
status=row.status,
branch=row.branch,
commit_sha=row.commit_sha,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _get_draft_or_404(db: Session, token: uuid.UUID) -> DocDraft:
"""One draft by its URL credential; 404 when the token is unknown."""
row = db.execute(select(DocDraft).where(DocDraft.token == token)).scalars().first()
if row is None:
raise HTTPException(status_code=404, detail="draft not found")
return row
@router.post("", response_model=DocDraftOut, status_code=201)
def create_draft(
payload: DocDraftCreate,
db: Session = Depends(get_db), # noqa: B008
) -> DocDraftOut:
"""Store one completed answer as a draft (201).
``title`` / ``body`` must be non-empty after strip (422 — the
``SteeringNoteIn`` pattern; pydantic's ``min_length=1`` alone would
let a whitespace-only value through). ``path`` passes
:func:`validate_draft_path` (422 naming the violated rule).
``token`` is a fresh ``uuid4`` — set on the PENDING row, so it ships
in the same INSERT (the ``share_token`` precedent, phase 51);
``status`` starts as ``draft``.
"""
title = payload.title.strip()
if not title:
raise HTTPException(status_code=422, detail="title must not be empty")
body = payload.body.strip()
if not body:
raise HTTPException(status_code=422, detail="body must not be empty")
path = validate_draft_path(payload.path)
row = DocDraft(
token=uuid.uuid4(), # the URL credential — set before INSERT
title=title,
path=path,
body=body,
)
db.add(row)
db.commit()
db.refresh(row)
return _to_out(row)
@router.get("/{token}", response_model=DocDraftOut)
def get_draft(
token: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
) -> DocDraftOut:
"""One draft by its URL credential (the edit screen's load); 404
``draft not found`` when the token is unknown (no enumeration — one
message for every unknown token)."""
return _to_out(_get_draft_or_404(db, token))
@router.put("/{token}", response_model=DocDraftOut)
def update_draft(
token: uuid.UUID,
payload: DocDraftUpdate,
db: Session = Depends(get_db), # noqa: B008
) -> DocDraftOut:
"""Partial edit of a draft (the edit screen's Save); 404 when the
token is unknown.
Each field is replaced only when supplied (absent keeps the row's
current value; present must be non-empty after strip — 422). A
supplied ``path`` re-runs :func:`validate_draft_path`; the same
guard-rails apply on update as on create. ``updated_at`` is bumped
on every PUT — via the model's ``onupdate=func.now()`` when a stored
value changes, via an explicit raw ``UPDATE`` when the PUT is a
no-op (empty body, or every supplied value identical — the ORM
flushes nothing, so the onupdate default never fires).
Editing a ``pushed`` draft resets ``status`` to ``draft`` (phase 59
D3 ASSUMPTION): the stored ``commit_sha`` no longer describes the
current body, so the next push re-commits. The last push's
``branch``/``commit_sha`` stay visible (the previous state) until
the next push overwrites them.
"""
row = _get_draft_or_404(db, token)
# Validate everything BEFORE assigning anything, so a 422 on one
# field can never leave a half-applied edit pending.
new_title = payload.title.strip() if payload.title is not None else None
if payload.title is not None and not new_title:
raise HTTPException(status_code=422, detail="title must not be empty")
new_path = validate_draft_path(payload.path) if payload.path is not None else None
new_body = payload.body.strip() if payload.body is not None else None
if payload.body is not None and not new_body:
raise HTTPException(status_code=422, detail="body must not be empty")
changed = False
if new_title is not None and row.title != new_title:
row.title = new_title
changed = True
if new_path is not None and row.path != new_path:
row.path = new_path
changed = True
if new_body is not None and row.body != new_body:
row.body = new_body
changed = True
if row.status == "pushed":
# D3 ASSUMPTION — see the docstring above.
row.status = "draft"
changed = True
if not changed:
# No-op PUT: the onupdate default does not fire for a flush that
# changed nothing, so bump updated_at explicitly (raw SQL — the
# ORM object is already in its final state).
db.execute(
text("UPDATE doc_drafts SET updated_at = now() WHERE id = :id"),
{"id": row.id},
)
db.commit()
db.refresh(row)
return _to_out(row)
@router.post("/{token}/push", response_model=DocDraftPushed)
def push_doc_draft(
token: uuid.UUID,
db: Session = Depends(get_db), # noqa: B008
settings: Settings = Depends(get_settings), # noqa: B008
) -> DocDraftPushed:
"""Commit + push the draft's file (phase 59, task 04).
The single mutation the edit screen (task 06) triggers: take the
draft's (title, path, body), commit + push through
:func:`app.core.docs_push.push_document`, and record the outcome
on the draft. Outcomes (checked in this order):
1. unknown token → 404 ``draft not found`` (same message as GET/
PUT — no enumeration);
2. ``settings.docs_configured`` false → 409 naming
``BOR_DOCS_REPO`` (D3: the feature is inert by default — the
optional-feature pattern of the git-sources env fallback);
3. the stored ``path`` re-runs :func:`validate_draft_path` → 422
on the first violated rule (a row must not be pushable into a
bad path, whatever wrote it);
4. :class:`DocsPushError` → 502 with ``detail=str(exc)`` — git's
stderr, the ``GitSyncError`` → ``detail`` mapping from
:mod:`app.api.git_sources`. Only a SUCCESS mutates the row:
the failed push leaves ``status`` / ``branch`` / ``commit_sha``
exactly as found (no partial commit).
On success the row becomes ``status = "pushed"`` with the landed
``branch`` and ``commit_sha`` (must equal ``git rev-parse
<branch>`` in the repo) and a bumped ``updated_at`` (the column's
``onupdate`` fires — a successful push always rewrites at least
``commit_sha``, so a flush always happens); the response is
``DocDraftPushed(status="pushed", branch, commit_sha=sha)`` — the
edit screen's branch + sha feedback (D3: no PR, no URL — the
owner opens the PR themselves).
"""
row = _get_draft_or_404(db, token)
if not settings.docs_configured:
raise HTTPException(
status_code=409, detail="docs repo not configured (BOR_DOCS_REPO)"
)
path = validate_draft_path(row.path)
try:
branch, sha = push_document(
repo=settings.docs_repo,
base_branch=settings.docs_base_branch,
branch=settings.docs_branch,
work_dir=settings.docs_work_dir,
rel_path=path,
content=row.body,
commit_message=f"docs: {row.title}",
)
except DocsPushError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from None
row.status = "pushed"
row.branch = branch
row.commit_sha = sha
db.commit()
db.refresh(row)
return DocDraftPushed(status="pushed", branch=branch, commit_sha=sha)
+56 -1
View File
@@ -9,7 +9,7 @@ import os
import re import re
from functools import lru_cache from functools import lru_cache
from pydantic import field_validator from pydantic import ValidationInfo, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
#: The built-in DEFAULT import formats (PLAN anchor A9, revised 2026-08-21; #: The built-in DEFAULT import formats (PLAN anchor A9, revised 2026-08-21;
@@ -178,6 +178,30 @@ class Settings(BaseSettings):
#: pattern). #: pattern).
upload_max_mb: int = 512 upload_max_mb: int = 512
# --- Docs push (phase 59: save a chat answer as documentation) ---
#: The git repo a saved chat answer is committed to (phase 59, D3):
#: **any** remote — a URL (``https://``, ``ssh://``, ``git@``) or a
#: local path (generic git remote — no ``gh``, no GitHub assumption).
#: While empty the feature is inert: the "Save as doc" action is
#: hidden and the push endpoint 409s (the optional-feature pattern of
#: the git-sources env fallback).
docs_repo: str = ""
#: The branch pushes land on (phase 59): each push cuts it fresh from
#: ``docs_base_branch`` and ``git push --ff-only``s it — the owner
#: opens the PR themselves (D3: no PR tooling). A git branch token,
#: so no whitespace and no ``..`` (the validator below —
#: all-or-nothing with ``docs_repo``).
docs_branch: str = "bor-docs"
#: The branch each push bases off (fetched/reset before the
#: ``checkout -B`` of ``docs_branch``). Same token shape rules as
#: ``docs_branch``.
docs_base_branch: str = "main"
#: Where ``docs_repo`` is checked out on the server. Raw string —
#: ``Path.expanduser()`` is applied by the push service, not here
#: (the ``sources_dir``/``upload_dir`` convention). Deliberately kept
#: separate from ``sources_dir`` (the source checkouts).
docs_work_dir: str = "~/bor-docs"
@field_validator("import_extensions") @field_validator("import_extensions")
@classmethod @classmethod
def _import_extensions_known(cls, v: str) -> str: def _import_extensions_known(cls, v: str) -> str:
@@ -215,6 +239,28 @@ class Settings(BaseSettings):
raise ValueError("upload_max_mb must be > 0 (MiB)") raise ValueError("upload_max_mb must be > 0 (MiB)")
return v return v
@field_validator("docs_branch", "docs_base_branch")
@classmethod
def _docs_branch_tokens(cls, v: str, info: ValidationInfo) -> str:
"""Git branch-token shape guard (phase 59, D3) — all-or-nothing:
while ``docs_repo`` is empty the feature is inert, so the
(ignored) branch values must not block startup; once a repo IS
set, a blank / whitespace-bearing / ``..``-bearing branch is a
typo that would corrupt a ``git checkout`` argument, so it fails
loudly at startup (the ``agent_max_rounds`` pattern), naming the
field."""
repo = info.data.get("docs_repo")
if not isinstance(repo, str) or not repo.strip():
return v
name = info.field_name or "docs branch"
if not v.strip():
raise ValueError(f"{name} must not be empty while docs_repo is set")
if re.search(r"\s", v):
raise ValueError(f"{name} must not contain whitespace (a git branch token)")
if ".." in v:
raise ValueError(f"{name} must not contain '..' (a git branch token)")
return v
# Suggested questions (onboarding + empty state). # Suggested questions (onboarding + empty state).
suggestions: list[str] = [ suggestions: list[str] = [
"How is my Kubernetes cluster set up?", "How is my Kubernetes cluster set up?",
@@ -242,6 +288,15 @@ class Settings(BaseSettings):
""" """
return [part.strip() for part in self.git_sources.split(",") if part.strip()] return [part.strip() for part in self.git_sources.split(",") if part.strip()]
@property
def docs_configured(self) -> bool:
"""True while a docs repo is configured (phase 59): the "Save as
doc" surface is live. Empty (or whitespace-only) ``docs_repo``
→ the feature is inert — no button for anyone, the push
endpoint 409s (the optional-feature pattern of the git-sources
env fallback)."""
return bool(self.docs_repo.strip())
@property @property
def effective_api_key(self) -> str: def effective_api_key(self) -> str:
"""API key for aipi: explicit setting, then $AIPI_KEY, then a placeholder.""" """API key for aipi: explicit setting, then $AIPI_KEY, then a placeholder."""
+2
View File
@@ -147,6 +147,8 @@ HTML_PAGES: tuple[str, ...] = (
# dynamic /shared/<token> — both must carry the no-cache + ?v= # dynamic /shared/<token> — both must carry the no-cache + ?v=
# contract, so the direct URL can never pin stale assets). # contract, so the direct URL can never pin stale assets).
"/shared.html", "/shared.html",
# phase 59: the doc edit screen (the flow page task 06 ships).
"/doc-edit.html",
) )
#: Prefix of the versioned static assets (header-only caching; the body is #: Prefix of the versioned static assets (header-only caching; the body is
+164
View File
@@ -0,0 +1,164 @@
"""Docs-push service (phase 59, task 03).
Commits a document into the configured docs repo and pushes it to the
configured branch. **D3 (owner-locked, 2026-08-31): push only** — the
flow ends at the push to the branch named in ``.env``
(``BOR_DOCS_REPO`` + ``BOR_DOCS_BRANCH``); no PR is ever created or
attempted (no ``gh``, no PR URL, no URL construction, no
GitHub-specific logic). The remote is **generic** — a local path,
``https://``, ``ssh://``, any host — and git is invoked exclusively
through :func:`scripts.git_sync.run_git` (A11: stdlib ``subprocess``
only, no new packages): :func:`push_document` is the only caller of
git for this feature besides :mod:`scripts.git_sync` itself.
The push is a plain ``git push origin <branch>`` — git 2.55 no longer
accepts the ``--ff-only`` flag on ``push`` — and a push without
``-f`` / a ``+`` refspec already refuses non-fast-forward updates
(client-side, against the remote's live refs), so a
concurrently-advanced remote fails loudly: never a force-push, never
a merge. Every failure raises :class:`DocsPushError` carrying git's
stderr (the ``GitSyncError`` style), and the checkout under
``work_dir`` is left as-is for inspection.
"""
from __future__ import annotations
from pathlib import Path
from scripts.git_sync import GitSyncError, run_git
__all__ = ["DocsPushError", "push_document"]
# Fixed per-invocation commit identity (phase 59 ASSUMPTION) — passed
# with ``-c`` on the commit itself, so the push never relies on the
# machine's global git config. ``commit.gpgsign=false`` keeps a
# headless server from ever prompting for a GPG pinentry (this repo's
# own no-GPG-signing policy).
_COMMIT_NAME = "Brain of Reese"
_COMMIT_EMAIL = "bor@local"
class DocsPushError(RuntimeError):
"""A docs push failed (or git is missing); carries git's stderr."""
def _refuse_unsafe_rel_path(rel_path: str) -> None:
"""Defensively re-assert the upstream path guard-rails (task 02).
The file is written inside the docs checkout, so a blank path, an
absolute path, or any ``.``/``..`` component is refused here as
well — even though the drafts API already guard-railed the value.
"""
rel = Path(rel_path)
if not rel_path.strip() or rel.is_absolute() or any(part in (".", "..") for part in rel.parts):
raise DocsPushError(f"refusing unsafe rel_path: {rel_path!r}")
def push_document(
repo: str,
base_branch: str,
branch: str,
work_dir: str,
rel_path: str,
content: str,
commit_message: str,
) -> tuple[str, str]:
"""Commit *content* at *rel_path* and push (ff-only) to *branch*.
Step sequence (each step via :func:`scripts.git_sync.run_git`;
``cwd=work_dir`` once the checkout exists):
1. Ensure the checkout: without ``.git`` →
``git clone --depth 1 --branch <base_branch> <repo> <work_dir>``
(the base branch is explicit — the remote's default may differ).
2. ``git fetch --depth 1 origin <base_branch>`` — re-sync the base
before every push. The fetched base tip (``FETCH_HEAD``) is
captured immediately — a later *failed* fetch clears
``FETCH_HEAD``.
3. ``git fetch --depth 100 origin <branch>`` — **failure is
expected** while the branch does not exist on the remote yet
(swallowed and continued); on success the fetched branch tip is
captured the same way.
4. Attach the local branch:
- a local branch left by a previous push → ``git checkout
<branch>`` — keep its own history, so a
concurrently-advanced remote then fails the push loudly in
step 7 instead of being silently re-based or merged;
- otherwise ``git checkout -B <branch>`` onto the captured
branch tip (re-attach onto the previously pushed branch — its
history, so the push can fast-forward) or, on first push,
onto the captured base tip (a new branch from the fresh
base). The captured shas are used instead of symbolic refs:
git's local transport (a local-path remote) does not create
``refs/remotes/origin/<branch>`` for a newly fetched branch,
and a failed fetch clears ``FETCH_HEAD``.
5. Write the file (parent dirs created;
:func:`_refuse_unsafe_rel_path` re-asserts the parts check
defensively).
6. ``git add -- <rel_path>`` + ``commit -m <commit_message>`` with
the fixed per-invocation identity (``-c user.name`` /
``-c user.email`` — no reliance on global git config).
7. ``git push origin <branch>`` — creates the remote branch on
first push; a concurrently-advanced remote is refused
non-fast-forward (never a force-push, never a merge).
8. ``sha = git rev-parse HEAD``; return ``(branch, sha)``.
Raises :class:`DocsPushError` (git's stderr in the message) on any
failure — the checkout is left as-is for inspection.
"""
_refuse_unsafe_rel_path(rel_path)
work = Path(work_dir).expanduser()
try:
if not (work / ".git").exists():
work.parent.mkdir(parents=True, exist_ok=True)
run_git(
[
"git",
"clone",
"--depth",
"1",
"--branch",
base_branch,
repo,
str(work),
],
cwd=work.parent,
)
run_git(["git", "fetch", "--depth", "1", "origin", base_branch], cwd=work)
# Capture the fetched base tip now — a *failed* fetch clears
# FETCH_HEAD, so the symbolic ref cannot be reused later.
base_sha = run_git(["git", "rev-parse", "FETCH_HEAD"], cwd=work).strip()
try:
run_git(["git", "fetch", "--depth", "100", "origin", branch], cwd=work)
branch_sha = run_git(["git", "rev-parse", "FETCH_HEAD"], cwd=work).strip()
except GitSyncError:
branch_sha = None # the branch does not exist on the remote yet
if run_git(["git", "branch", "--list", branch], cwd=work).strip():
run_git(["git", "checkout", branch], cwd=work)
else:
anchor = branch_sha if branch_sha is not None else base_sha
run_git(["git", "checkout", "-B", branch, anchor], cwd=work)
target = work / rel_path
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
run_git(["git", "add", "--", rel_path], cwd=work)
run_git(
[
"git",
"-c",
f"user.name={_COMMIT_NAME}",
"-c",
f"user.email={_COMMIT_EMAIL}",
"-c",
"commit.gpgsign=false",
"commit",
"-m",
commit_message,
],
cwd=work,
)
run_git(["git", "push", "origin", branch], cwd=work)
sha = run_git(["git", "rev-parse", "HEAD"], cwd=work).strip()
except GitSyncError as err:
raise DocsPushError(str(err)) from err
return branch, sha
+2
View File
@@ -31,6 +31,7 @@ from app.api.chats import (
shared_page_router as chats_shared_page_router, shared_page_router as chats_shared_page_router,
) )
from app.api.config import router as config_router from app.api.config import router as config_router
from app.api.doc_drafts import router as doc_drafts_router
from app.api.docs import router as docs_router from app.api.docs import router as docs_router
from app.api.git_sources import router as git_sources_router from app.api.git_sources import router as git_sources_router
from app.api.health import router as health_router from app.api.health import router as health_router
@@ -81,6 +82,7 @@ def create_app() -> FastAPI:
app.include_router(steering_router, prefix="/api") app.include_router(steering_router, prefix="/api")
app.include_router(sync_router, prefix="/api") app.include_router(sync_router, prefix="/api")
app.include_router(chats_router, prefix="/api") app.include_router(chats_router, prefix="/api")
app.include_router(doc_drafts_router, prefix="/api")
# Phase 51: the anonymous shared-chat read — NO admin dependency. # Phase 51: the anonymous shared-chat read — NO admin dependency.
# /api/shared/<token> is the JSON snapshot; /shared/<token> (the # /api/shared/<token> is the JSON snapshot; /shared/<token> (the
# page route below, registered without a prefix) is the page. # page route below, registered without a prefix) is the page.
+66
View File
@@ -31,6 +31,16 @@ Data model — see ``.agent/PLAN.md`` §Data Model:
generation of the knowledge base is current, generation of the knowledge base is current,
bumped exactly once per KB-changing sync so saved bumped exactly once per KB-changing sync so saved
chats can be marked stale (phase 53). chats can be marked stale (phase 53).
* ``doc_drafts`` — server-side drafts of chat answers saved as
documentation: one row per "Save as doc" action
(the long answer body lives here, never in a URL),
keyed by an unguessable ``uuid4`` ``token`` (the
edit screen's URL credential — the share-token
trust model, phase 51); ``status`` moves
``draft`` → ``pushed`` (``branch`` +
``commit_sha`` recorded) when the push endpoint
commits + pushes the file to the
``BOR_DOCS_REPO`` branch (phase 59).
""" """
from __future__ import annotations from __future__ import annotations
@@ -205,6 +215,62 @@ class GitSource(Base):
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class DocDraft(Base):
"""One server-side draft of a chat answer saved as documentation
(phase 59, task 01).
A long answer body must live on the **server**, never in a URL: the
"Save as doc" action POSTs the answer's raw markdown to
``POST /api/doc-drafts`` (task 02), which stores it here and hands
back an unguessable 128-bit ``uuid4`` ``token`` — the edit
screen's URL credential (``/doc-edit.html?draft=<token>``, the
share-token trust model, phase 51). ``status`` stays ``draft``
until the push endpoint (task 04) commits + pushes the file to the
``BOR_DOCS_REPO`` branch — then it is ``pushed``, with ``branch``
and ``commit_sha`` recorded (the UI's branch + sha feedback; D3:
no PR tooling — the owner opens the PR themselves).
"""
__tablename__ = "doc_drafts"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
#: The URL credential (``/doc-edit.html?draft=<token>``): an
#: unguessable 128-bit ``uuid4`` — never the row id, never
#: sequential/guessable. Unique NOT NULL: unlike the NULLable
#: ``saved_chats.share_token`` there is no "un-drafted" state, so
#: NULLs never occur (always set on create).
token: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), unique=True, nullable=False, default=uuid.uuid4
)
#: The document's title. Defaults client-side to the last user
#: question (whitespace-collapsed, ≤120 chars — the chat auto-title
#: convention, phase 50); the edit screen changes anything.
title: Mapped[str] = mapped_column(Text)
#: The in-repo file path (default ``docs/<slug>.md``). Guard-railled
#: by the API layer (task 02 — repo-relative, no ``..``); the
#: column itself is plain TEXT (the ``documents.path`` precedent).
path: Mapped[str] = mapped_column(Text)
#: The markdown body — the answer's raw text (never HTML — the
#: ``bor.chat.v1`` record's ``text``), edited on the edit screen.
body: Mapped[str] = mapped_column(Text)
#: "draft" until the push endpoint commits + pushes the file, then
#: "pushed" — the domain is enforced by the API layer (the
#: ``git_sources.kind`` phase-38 precedent: plain TEXT + server
#: default, no CHECK constraint).
status: Mapped[str] = mapped_column(Text, default="draft", server_default="'draft'")
#: Set on push (task 04): the branch the commit landed on (the
#: ``BOR_DOCS_BRANCH`` name); NULL while still a draft.
branch: Mapped[str | None] = mapped_column(Text)
#: ... and the pushed branch's new HEAD sha (must equal
#: ``git rev-parse <branch>`` in the repo); NULL while still a
#: draft.
commit_sha: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class SavedChat(Base): class SavedChat(Base):
"""One owner-saved chat conversation (phase 50). """One owner-saved chat conversation (phase 50).
+69
View File
@@ -523,3 +523,72 @@ class UnshareOut(BaseModel):
chat_id: uuid.UUID chat_id: uuid.UUID
shared: bool shared: bool
class DocDraftCreate(BaseModel):
"""``POST /api/doc-drafts`` body (phase 59, task 02): one completed
chat answer about to become documentation.
``title`` arrives client-side as the last user question
(whitespace-collapsed, ≤120 chars — the chat auto-title convention,
phase 50); ``path`` as ``docs/<slug>.md``; ``body`` is the answer's
raw markdown (never HTML — the ``bor.chat.v1`` record's ``text``,
the phase-50/51 round-trip convention). The path guard-rails (task
02 — repo-relative, no ``..``, no absolute path) run in the API
layer so the 422 details stay fixed strings; the max lengths mirror
the ``documents`` table (title 500, path 1000).
"""
title: str = Field(min_length=1, max_length=500)
path: str = Field(min_length=1, max_length=1000)
body: str = Field(min_length=1)
class DocDraftUpdate(BaseModel):
"""``PUT /api/doc-drafts/{token}`` body (phase 59, task 02): a
partial update — each field is replaced only when supplied (absent
keeps the row's current value; present must be non-empty — the
``SavedChatUpdate`` optional-title pattern, extended to all three
editable fields). The same path guard-rails as create run in the
API layer when ``path`` is supplied.
"""
title: str | None = Field(default=None, min_length=1, max_length=500)
path: str | None = Field(default=None, min_length=1, max_length=1000)
body: str | None = Field(default=None, min_length=1)
class DocDraft(BaseModel):
"""One draft row, full payload (create/get/put response, phase 59).
``token`` is the URL credential (``/doc-edit.html?draft=<token>``
— the unguessable ``uuid4``, the share-token trust model, phase
51). ``status`` is ``draft`` until the push endpoint commits +
pushes the file, then ``pushed`` with ``branch`` / ``commit_sha``
recorded (both NULL while still a draft). Datetimes serialize
ISO-8601 on the wire (pydantic default).
"""
token: uuid.UUID
title: str
path: str
body: str
status: str
branch: str | None = None
commit_sha: str | None = None
created_at: datetime
updated_at: datetime
class DocDraftPushed(BaseModel):
"""``POST /api/doc-drafts/{token}/push`` success response (phase 59,
task 04): the commit + ``git push --ff-only`` landed — ``branch``
is the ``BOR_DOCS_BRANCH`` name and ``commit_sha`` the pushed
branch's new HEAD (the edit screen's branch + sha feedback; it must
equal ``git rev-parse <branch>`` in the repo — the E2E source of
truth is the bare repo's state, not the UI alone).
"""
status: Literal["pushed"] = "pushed"
branch: str
commit_sha: str
+148 -1
View File
@@ -495,6 +495,126 @@ function markLastRetryable() {
const prev = lastIdx > 0 ? conversation[lastIdx - 1] : null; const prev = lastIdx > 0 ? conversation[lastIdx - 1] : null;
if (!prev || prev.who !== "user") return; if (!prev || prev.who !== "user") return;
appendRetryButton(lastBrainWrap); appendRetryButton(lastBrainWrap);
// Phase 59: "Save as doc" stays the meta row's rightmost action —
// when the Retry button lands on the SAME bubble, re-append the save
// button after it (the auto margins split the free space between the
// right-aligned buttons; DOM order decides the right edge).
const saveDocBtn = lastBrainWrap.querySelector(".save-as-doc-btn");
if (saveDocBtn && saveDocBtn.parentElement)
saveDocBtn.parentElement.appendChild(saveDocBtn);
}
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right
* "Save as doc" action of EVERY completed brain bubble (deflected
* included — same scope as Tune; a stopped partial is a note, not an
* answer, so m.stopped records never get it — the restore call site
* gates on it). Gate: admin (the whoami gate Tune uses) AND a
* configured docs repo (docsRepoConfigured — /api/config, settled in
* the boot IIFE before any bubble renders). `markdown` is the RAW
* persisted answer text — m.text on the restore path, the
* done/fallback raw text on the live path — NEVER the rendered HTML.
* The .save-as-doc-btn's margin-inline-start: auto pushes it to the
* row's right edge (the TODO's "bottom right"); markLastRetryable
* keeps it rightmost when the last bubble also carries the Retry
* button.
*
* Click: default title (the LAST user question, whitespace-collapsed,
* ≤120 chars — the phase-50 auto-title convention) + default in-repo
* path (docs/<slug>.md) → POST /api/doc-drafts {title, path, body} →
* 201 → /doc-edit.html?draft=<token> (the edit screen, task 06, owns
* the rest). Failure → the neutral one-line banner (phase-55
* convention), the conversation unblocked, no navigation. */
const SAVE_AS_DOC_ICON =
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H6a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 16h4"/></svg>';
const DOC_TITLE_MAX = 120; // the phase-50 auto-title cap (owner-locked)
/* The default doc title: the LAST user question's text,
* whitespace-collapsed, truncated to 120 chars — the phase-50
* auto-title convention (server-side: " ".join(text.split())[:120])
* applied to the last question. Defensive "Note" when the
* conversation has no user record (the UI cannot produce one).
* " ".join(split()) == replace(/\s+/g, " ").trim() for non-empty
* input; the trim keeps the leading/trailing-whitespace edge identical. */
function defaultDocTitle() {
let question = "";
for (let i = conversation.length - 1; i >= 0; i -= 1) {
if (conversation[i].who === "user") {
question = conversation[i].text;
break;
}
}
return question.replace(/\s+/g, " ").trim().slice(0, DOC_TITLE_MAX) || "Note";
}
/* The default in-repo path slug (phase 59 locked assumption):
* lowercase, runs of non-alphanumerics → "-", trimmed, ≤60 chars,
* empty → "note". The 60-cut can land mid dash-run — the trailing
* trim again keeps the path from ending in a dangling "-". */
function docSlug(title) {
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60)
.replace(/-+$/g, "");
return slug || "note";
}
/* The bottom-right "Save as doc" button — the appendTuneButton
* pattern: reuses the .msg-meta row when it exists (role=list → the
* button joins as a listitem so ARIA stays valid), otherwise creates
* a plain meta row; one button per bubble. */
function appendSaveAsDocButton(wrap, markdown) {
if (!isAdmin || !docsRepoConfigured) return; // phase 59: admin + configured
const body = wrap.querySelector(".msg-body");
if (!body) return;
let meta = body.querySelector(".msg-meta");
if (!meta) {
meta = document.createElement("div");
meta.className = "msg-meta";
body.appendChild(meta);
}
if (meta.querySelector(".save-as-doc-btn")) return; // one per bubble
const btn = document.createElement("button");
btn.type = "button";
btn.className = "save-as-doc-btn"; // margin-inline-start: auto → bottom-right
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
btn.innerHTML = SAVE_AS_DOC_ICON + "<span>Save as doc</span>";
btn.addEventListener("click", () => saveAsDoc(btn, markdown));
meta.appendChild(btn);
}
/* Create the draft from the bubble's RAW markdown and hand off to the
* edit screen. Double-click guard: one save at a time (the button is
* disabled until the outcome — released in the finally, never stale,
* PLAN §7.4). */
async function saveAsDoc(btn, markdown) {
if (btn.disabled) return; // one save at a time (double-click guard)
btn.disabled = true;
try {
const title = defaultDocTitle();
const path = `docs/${docSlug(title)}.md`;
const res = await fetch("/api/doc-drafts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, path, body: markdown }),
});
if (!res.ok) {
// Neutral one-line copy (phase-55 convention) — the detail may
// be a guard-rail 422 or a server hiccup; neither is actionable
// here, and the conversation stays unblocked (no navigation).
showErrorBanner("Couldn't save the answer as a doc — try again.");
return;
}
const draft = await res.json();
// 201: the draft's uuid4 token IS the edit screen's credential.
location.assign("/doc-edit.html?draft=" + draft.token);
} catch {
showErrorBanner("Couldn't save the answer as a doc — is the app reachable?");
} finally {
btn.disabled = false; // released on EVERY outcome — never stale
}
} }
/* Inline tuning form under the bubble: labeled textarea (maxlength 2000) /* Inline tuning form under the bubble: labeled textarea (maxlength 2000)
@@ -1091,6 +1211,10 @@ function renderStoredMessage(m) {
} }
appendSources(wrap, m.sources); appendSources(wrap, m.sources);
appendTuneButton(wrap); // restored brain answers are tunable too appendTuneButton(wrap); // restored brain answers are tunable too
// Phase 59: the RAW persisted markdown (m.text — HTML is never
// persisted). A stopped partial (m.stopped) is a note, not an answer
// — no button (the live stop path adds none either).
if (!m.stopped) appendSaveAsDocButton(wrap, m.text);
if (m.stopped) appendStoppedNote(wrap); // phase 48: the stop marker restores if (m.stopped) appendStoppedNote(wrap); // phase 48: the stop marker restores
lastBrainWrap = wrap; // phase 49: the LAST restored brain bubble wins lastBrainWrap = wrap; // phase 49: the LAST restored brain bubble wins
} }
@@ -1538,6 +1662,16 @@ const signInLink = document.querySelector("#sign-in-link");
const signOutBtn = document.querySelector("#sign-out-btn"); const signOutBtn = document.querySelector("#sign-out-btn");
let isAdmin = false; let isAdmin = false;
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the docs-push gate
* — GET /api/config's ``docs_repo_configured`` (settings.docs_configured
* server-side), surfaced by brand.js as window.BOR_DOCS_REPO_CONFIGURED
* (the way app_name is: a window global, false until the boot fetch
* proves otherwise). Captured ONCE in the boot IIFE after the fetch
* settles, so the "Save as doc" buttons render exactly once: present
* for a configured admin, absent for everyone else — and while
* BOR_DOCS_REPO is empty the feature is inert (D3). */
let docsRepoConfigured = false;
function applyAuthState() { function applyAuthState() {
if (signInLink) signInLink.hidden = isAdmin; if (signInLink) signInLink.hidden = isAdmin;
if (signOutBtn) signOutBtn.hidden = !isAdmin; if (signOutBtn) signOutBtn.hidden = !isAdmin;
@@ -1812,11 +1946,15 @@ async function runTurn(text, { reask = false } = {}) {
appendMaybeTry(wrap, ev.suggestions); appendMaybeTry(wrap, ev.suggestions);
} }
appendSources(wrap, ev.sources); appendSources(wrap, ev.sources);
appendTuneButton(wrap); // every completed brain bubble is tunable
// Thinking-without-answer (reasoning can exhaust max_tokens): the // Thinking-without-answer (reasoning can exhaust max_tokens): the
// bubble gets the empty-answer fallback — what the user saw is // bubble gets the empty-answer fallback — what the user saw is
// what gets persisted. // what gets persisted.
const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : ""); const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "");
appendTuneButton(wrap); // every completed brain bubble is tunable
// Phase 59: the RAW persisted markdown (never the rendered
// HTML) — exactly the string rememberBrainTurn stores below,
// so a reload (the restore path) offers the identical draft.
appendSaveAsDocButton(wrap, finalText || acc || "…");
if (!acc && sawThinking) { if (!acc && sawThinking) {
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText); wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
} }
@@ -1851,6 +1989,7 @@ async function runTurn(text, { reask = false } = {}) {
const fallback = EMPTY_ANSWER_FALLBACK; const fallback = EMPTY_ANSWER_FALLBACK;
const fwrap = addMessage("brain", fallback); const fwrap = addMessage("brain", fallback);
appendTuneButton(fwrap); appendTuneButton(fwrap);
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
rememberBrainTurn(fallback, {}); // persist what the user actually saw rememberBrainTurn(fallback, {}); // persist what the user actually saw
lastBrainWrap = fwrap; lastBrainWrap = fwrap;
markLastRetryable(); // phase 49: the fallback bubble is retryable too markLastRetryable(); // phase 49: the fallback bubble is retryable too
@@ -1965,6 +2104,14 @@ window.addEventListener("pagehide", () => {
(async () => { (async () => {
await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate
isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami
// Phase 59: /api/config is settled BEFORE any bubble renders —
// brand.js's single boot fetch (window.BOR_CONFIG_PROMISE, never
// rejecting) has set window.BOR_DOCS_REPO_CONFIGURED (false until
// proven), so a restored conversation of a configured admin gets the
// "Save as doc" button exactly once: no flash, no re-render, no
// second fetch (the brand fetch IS the config fetch).
await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());
docsRepoConfigured = window.BOR_DOCS_REPO_CONFIGURED === true;
applyAuthState(); // chat page: the auth pair (idempotent with header.js) applyAuthState(); // chat page: the auth pair (idempotent with header.js)
// Phase 55 (task 03): no Share-reveal step — the pill is static, // Phase 55 (task 03): no Share-reveal step — the pill is static,
// always-visible markup (visible to every visitor, phase 51 contract). // always-visible markup (visible to every visitor, phase 51 contract).
+43 -11
View File
@@ -9,7 +9,15 @@
* Contract (phase 39 locked decisions — A11 no CDN, runtime fetch): * Contract (phase 39 locked decisions — A11 no CDN, runtime fetch):
* • window.BOR_BRAND = "Brain of Reese" synchronously — the default * • window.BOR_BRAND = "Brain of Reese" synchronously — the default
* name renders immediately, no blank flash; * name renders immediately, no blank flash;
* • fetch("/api/config", { cache: "no-store" }) — on success with a * • Phase 59: window.BOR_DOCS_REPO_CONFIGURED = false synchronously
* (inert until proven) and window.BOR_CONFIG_PROMISE — the SAME
* fetch's promise, exposed at parse time so the chat page's boot
* (app.js) can await it BEFORE rendering any bubble; the "Save as
* doc" gating flag is then final, and a restored conversation of a
* configured admin never misses (or flashes) the button. The
* promise NEVER rejects — the error arm warns and resolves null;
* • fetch("/api/config", { cache: "no-store" }) — on success the
* docs flag is set from cfg.docs_repo_configured, and on a
* non-empty app_name, window.BOR_BRAND is updated and the name is * non-empty app_name, window.BOR_BRAND is updated and the name is
* applied to the DOM: * applied to the DOM:
* 1. document.title — global replace of the literal; * 1. document.title — global replace of the literal;
@@ -36,6 +44,12 @@
reading window.BOR_BRAND at evaluation time always find a value. */ reading window.BOR_BRAND at evaluation time always find a value. */
window.BOR_BRAND = "Brain of Reese"; window.BOR_BRAND = "Brain of Reese";
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the docs-push flag —
surfaced the way app_name is (a window global, inert until the boot
fetch proves otherwise). false = the "Save as doc" action is hidden
for everyone (BOR_DOCS_REPO empty — the feature is off). */
window.BOR_DOCS_REPO_CONFIGURED = false;
/* The literal the DOM passes replace — the default name. The page /* The literal the DOM passes replace — the default name. The page
scripts' own `window.BOR_BRAND || "Brain of Reese"` fallbacks stay in scripts' own `window.BOR_BRAND || "Brain of Reese"` fallbacks stay in
sync with it. */ sync with it. */
@@ -50,10 +64,31 @@ function escapeHTML(s) {
}[c])); }[c]));
} }
function applyBrand() { /* The /api/config fetch — started at TOP LEVEL (parse time) so
fetch("/api/config", { cache: "no-store" }) window.BOR_CONFIG_PROMISE exists before the page's module scripts
evaluate (app.js's boot awaits it, above). Phase 59: the flag lands
here, the moment the answer arrives — before any DOM pass. The
promise NEVER rejects: the error arm warns (the loadHealth house
style — the page never breaks) and resolves to null, so the default
name + false flag stand. */
const BOR_CONFIG_PROMISE = fetch("/api/config", { cache: "no-store" })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((cfg) => { .then(
(cfg) => {
window.BOR_DOCS_REPO_CONFIGURED = cfg?.docs_repo_configured === true;
return cfg;
},
(err) => {
// Fetch failure (or a non-JSON body): the default name stays —
// the page never breaks (the loadHealth house style).
console.warn("brand: /api/config did not answer — keeping the default name.", err);
return null;
},
);
window.BOR_CONFIG_PROMISE = BOR_CONFIG_PROMISE;
function applyBrand() {
BOR_CONFIG_PROMISE.then((cfg) => {
const name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : ""; const name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : "";
if (!name) return; // empty / missing: the default stands if (!name) return; // empty / missing: the default stands
window.BOR_BRAND = name; window.BOR_BRAND = name;
@@ -117,16 +152,13 @@ function applyBrand() {
} }
} }
} }
})
.catch((err) => {
// Fetch failure (or a non-JSON body): the default name stays —
// the page never breaks (the loadHealth house style).
console.warn("brand: /api/config did not answer — keeping the default name.", err);
}); });
} }
/* The top level only sets the global (synchronously, at parse time); /* The DOM passes run once the document is ready AND the config is
the DOM passes run once the document is ready. */ settled (applyBrand awaits the parse-time promise) — the fetch may
resolve before or after DOMContentLoaded; both orderings apply the
brand exactly once. */
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", applyBrand); document.addEventListener("DOMContentLoaded", applyBrand);
} else { } else {
+287
View File
@@ -0,0 +1,287 @@
/* Brain of Reese — doc edit screen (phase 59, task 06).
*
* The standalone, admin-gated flow page
* ``/doc-edit.html?draft=<token>``: load the draft the chat page's
* "Save as doc" action (task 05) just created, edit the three fields
* (title, in-repo path, markdown body), and push — the commit lands on
* the .env-configured docs branch of the .env-configured repo, and the
* owner opens the PR themselves (D3: no PR tooling anywhere).
*
* The page is static; the API is the authority. The whoami gate is the
* ``sources-gate`` pattern (phases 16/35/50): anonymous visitors see
* the sign-in gate and NO /api/doc-drafts call is made (the draft
* endpoints are admin-only regardless — no draft data can leak through
* the page).
*
* Boot (admin): read ``?draft=<token>`` — missing → the error banner
* "No draft specified."; a malformed (non-uuid) token is treated as
* unknown → "Draft not found." with NO fetch (the shared.js malformed-
* token precedent) — then ``GET /api/doc-drafts/<token>``: 404 →
* "Draft not found.", any other non-2xx → the server's detail line, a
* network failure → the fixed one-line copy. On 200 the three fields
* are filled with VALUES (``.value`` only — never as markup; the body
* is user-derived markdown).
*
* Push (the §7.4 never-stale lifecycle, on #push-doc-btn):
* 1. client-side sanity FIRST — non-empty title/body, no ".." in the
* path (the server re-runs its guard-rails and is the authority;
* the browser's native ``required`` is the first line, these the
* second);
* 2. the button disables + relabels "Pushing…" and the live region
* says "Pushing…";
* 3. ``PUT /api/doc-drafts/<token>`` with all three fields — the
* push endpoint (task 04) commits the ROW's title/path/body, so
* the current edits must land on the row first (an unsaved edit
* would otherwise push the stale text);
* 4. ``POST /api/doc-drafts/<token>/push``:
* • 200 → the live region: `Pushed to <branch> — commit <sha7>.`
* (the full sha comes from the API, the first seven chars are
* shown — a re-push after further edits is a NEW commit on the
* same branch, the D3 ASSUMPTION); the button re-enables with
* its idle label;
* • non-2xx → the #push-error banner with the API's detail — for
* a git failure (502) that is git's stderr, trimmed to its
* first meaningful lines; the fields are PRESERVED (the fix is
* an edit, not a re-type) and the button re-enables;
* • network failure → the fixed one-line copy, same recovery.
*
* The shared header module loads through this script's own relative
* import ("./header.js") — a hoisted import evaluated before this body
* runs (single-evaluation design: no direct <script> tag; esbuild
* inlines it into the page bundle in the image build). The slim flow
* page carries no nav / auth pair / steering panel, so initSharedHeader
* would settle nothing — the import exists for the CACHED whoami
* (fetchIsAdmin) the gate runs on and for the Containerfile stage-1
* contract (every page module imports ./header.js).
*/
import { fetchIsAdmin } from "./header.js";
/* ---------- page elements (doc-edit.html, task 06) ---------- */
const gateEl = document.querySelector("#doc-edit-gate");
const contentEl = document.querySelector("#doc-edit-content");
const formEl = document.querySelector("#doc-edit-form");
const titleInput = document.querySelector("#draft-title");
const pathInput = document.querySelector("#draft-path");
const bodyInput = document.querySelector("#draft-body");
const pushBtn = document.querySelector("#push-doc-btn");
const statusEl = document.querySelector("#push-status");
const errorEl = document.querySelector("#push-error");
/* The button's idle label (restored in the finally — never stale). */
const IDLE_LABEL = "Push to docs branch";
/* The URL credential's shape — a uuid4 token (task 05 navigated with
it). A non-uuid value is unknown, full stop: "Draft not found." with
no fetch (the shared.js malformed-token precedent — a 422
validation line would be framework noise, not a house message). */
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/* Set by boot once whoami says admin and the token is present — the
push handler refuses to run without it (the form is unusable in
that state anyway: "No draft specified." is on the banner). */
let draftToken = null;
/* ---------- feedback channels (§7.4 never stale) ---------- */
/* The polite live region (role="status"): the push lifecycle line —
textContent only (the branch/sha are server data). */
function setStatus(message) {
if (statusEl) statusEl.textContent = message;
}
/* The error banner (role="alert"): shown with a message, hidden on a
fresh attempt. */
function showError(message) {
if (errorEl) {
errorEl.textContent = message;
errorEl.hidden = false;
}
}
function clearError() {
if (errorEl) errorEl.hidden = true;
}
/* FastAPI error bodies: a string detail or the validation-error array
(the first entry's msg is the human line). Same extraction as
git-sources.js — 422 shape-aware. */
async function apiDetail(r, fallback) {
try {
const data = await r.json();
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
return String(data.detail[0].msg);
}
if (typeof data.detail === "string" && data.detail) return data.detail;
} catch {
/* non-JSON error body */
}
return fallback;
}
/* Git's stderr, trimmed to its first meaningful lines (task 06):
blank lines and the "hint:" chatter are dropped, at most three
lines are kept — the banner stays one compact line, and a
single-line detail (409 unconfigured, 422 guard-rail) passes
through untouched. */
function trimGitDetail(detail) {
const lines = String(detail)
.split("\n")
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("hint:"));
return lines.slice(0, 3).join(" ") || "The push failed.";
}
/* ---------- load (GET /api/doc-drafts/<token>) ----------
* The 200 body fills the three fields — VALUES only (input.value /
* textarea.value), never as markup: the body is user-derived markdown
* and the title/path may contain anything but markup. */
async function loadDraft(token) {
let r;
try {
r = await fetch(`/api/doc-drafts/${token}`);
} catch {
showError("Could not reach the server — is the app running?");
return;
}
if (r.status === 404) {
showError("Draft not found.");
return;
}
if (!r.ok) {
showError(await apiDetail(r, `The server could not load the draft (${r.status}).`));
return;
}
let draft;
try {
draft = await r.json();
} catch {
showError("The server sent an unreadable draft — try again.");
return;
}
if (titleInput) titleInput.value = draft.title;
if (pathInput) pathInput.value = draft.path;
if (bodyInput) bodyInput.value = draft.body;
}
/* ---------- push (PUT the edits, then POST /push) ----------
* The push endpoint commits the ROW's title/path/body, so the current
* field values are PUT first (all three — a partial PUT would keep a
* stale field) and the push runs only once that lands. Every failure
* path lands the error banner (the server's detail line — git's
* stderr, trimmed, for 502s) and re-enables the button in the
* finally: never stale, success OR failure. */
function wirePush() {
if (!formEl || !pushBtn) return;
formEl.addEventListener("submit", async (e) => {
e.preventDefault();
if (!draftToken) {
showError("No draft specified.");
return;
}
// Client-side sanity (the server is the authority — it re-runs the
// guard-rails): non-empty title/body, no ".." in the path. The
// browser's native `required` is the first line, these the second
// (whitespace-only values included).
const title = titleInput.value.trim();
const path = pathInput.value.trim();
const body = bodyInput.value.trim();
if (!title) {
showError("Enter a title for the doc.");
titleInput.focus();
return;
}
if (!body) {
showError("The doc body must not be empty.");
bodyInput.focus();
return;
}
if (path.includes("..")) {
showError("The path must not contain '..'.");
pathInput.focus();
return;
}
clearError(); // a new attempt starts clean
setStatus("Pushing…");
pushBtn.disabled = true; // §7.4: one push per click
pushBtn.textContent = "Pushing…";
try {
const put = await fetch(`/api/doc-drafts/${draftToken}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, path, body }),
});
if (!put.ok) {
// 404 (the row vanished) / 422 (a field no longer passes the
// guard-rails) — the server line, the edits preserved, and
// any stale success line cleared (one claim at a time).
setStatus("");
showError(await apiDetail(put, "Could not save the doc edits — try again."));
return;
}
const r = await fetch(`/api/doc-drafts/${draftToken}/push`, {
method: "POST",
});
if (!r.ok) {
// 409 (repo unconfigured), 422 (the stored path), 502 (git's
// stderr) — the detail is the actionable line, trimmed to its
// first meaningful lines; the fields are preserved and the
// stale success line (if any) is cleared.
setStatus("");
showError(trimGitDetail(await apiDetail(r, "The push failed — try again.")));
return;
}
const pushed = await r.json();
// The full sha comes from the API; the first seven chars are the
// display value (a re-push after further edits is a NEW commit —
// the D3 ASSUMPTION — so the button re-enables for it).
setStatus(
`Pushed to ${pushed.branch} — commit ${String(pushed.commit_sha).slice(0, 7)}.`,
);
} catch {
setStatus("");
showError("Could not reach the server — is the app running?");
} finally {
pushBtn.disabled = false; // never stale — success OR failure
pushBtn.textContent = IDLE_LABEL;
}
});
}
wirePush();
/* ---------- boot ----------
* The admin gate FIRST (the sources-gate pattern — one cached
* whoami): anonymous visitors get the gate and NO /api/doc-drafts
* call (the endpoints are admin-only regardless — no draft data
* leaks through the page). The admin gets the form, then the draft
* load from ?draft=<token>. */
(async () => {
const admin = await fetchIsAdmin();
if (!admin) {
if (gateEl) gateEl.hidden = false;
if (contentEl) contentEl.hidden = true; // ships hidden — stays hidden
return;
}
if (gateEl) gateEl.hidden = true;
if (contentEl) contentEl.hidden = false;
// The URL credential (task 05's navigation: the 201 token).
const token = new URLSearchParams(window.location.search).get("draft");
if (!token) {
showError("No draft specified.");
return;
}
if (!UUID_RE.test(token)) {
// A non-uuid token is unknown — no fetch (the shared.js
// malformed-token precedent: a 422 validation line is framework
// noise, not a house message).
showError("Draft not found.");
return;
}
draftToken = token;
await loadDraft(token);
if (titleInput) titleInput.focus(); // land the caret in the first field
})();
+191
View File
@@ -734,6 +734,40 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.retry-btn svg { width: 14px; height: 14px; display: block; } .retry-btn svg { width: 14px; height: 14px; display: block; }
.retry-btn:hover { background: var(--brand-soft); color: var(--ink); } .retry-btn:hover { background: var(--brand-soft); color: var(--ink); }
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the "Save as doc"
button — the bottom-right action of every completed brain bubble's
meta row (the JS gates: admin + a configured docs repo; this rule
only styles). The exact visual family of .tune-btn / .retry-btn
(same pill size/spacing, the global :focus-visible ring, >=44px via
min-height) so the meta actions read as one set — the brand hover
pair like Tune (a docs action), the file glyph rides currentColor.
margin-inline-start: auto pushes it to the row's RIGHT edge (the
TODO's "bottom right"; markLastRetryable keeps it rightmost when
the last bubble also carries the Retry button). Contrast:
ink-soft on --bg ~8.6:1, hover brand-ink on --brand-soft — AA,
same as the family. */
.save-as-doc-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 44px;
margin-inline-start: auto;
padding: 0.35rem 0.8rem;
border-radius: 999px;
border: 1px solid var(--line);
background: transparent;
color: var(--ink-soft);
font: inherit;
font-weight: 600;
font-size: 0.82rem;
white-space: nowrap;
cursor: pointer;
}
.save-as-doc-btn svg { width: 14px; height: 14px; display: block; }
.save-as-doc-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
.save-as-doc-btn:disabled { opacity: 0.6; cursor: wait; } /* draft POST in flight */
/* Phase 48: the "Stopped" note in a stopped brain bubble's meta row: /* Phase 48: the "Stopped" note in a stopped brain bubble's meta row:
ink-soft on the surface bubble ≈6.9:1, the 10px filled-square glyph ink-soft on the surface bubble ≈6.9:1, the 10px filled-square glyph
centered with the row (the Tune button shares the row), and centered with the row (the Tune button shares the row), and
@@ -2699,6 +2733,163 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.doc-modal-backdrop { transition: none; } .doc-modal-backdrop { transition: none; }
} }
/* ---------- Doc edit screen (phase 59, task 06) ----------
/doc-edit.html: the admin-gated edit screen for a doc draft (title,
in-repo path, markdown body) — a FLOW page, not one of the app's
pages, so the header is SLIM (brand + "← Back to chat" only). The
46rem base column is HARD-CODED: a form column, not a reading
column — it does not ride --chat-column, so phase 58's wide-desktop
doubling never stretches the form. The .sources-gate gate is reused
verbatim (phases 16/35/50). Every pair reuses the Phase-08 AA
palette; touch targets >=44px; :focus-visible via the global 3px
outline rule. No CDN, system fonts. */
.doc-edit-shell {
width: 100%;
max-width: 46rem; /* the 46rem base column (hard-coded — see above) */
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 1.25rem;
flex: 1;
}
/* The slim header's back link — the .doc-back ghost language
(document.html): >=44px target, --line border, ink-soft (5.1:1 on
the --surface bar) rising to ink on hover; pushed to the bar's right
edge by the header-inner flex (margin-left: auto — the nav's
own pattern). */
.doc-edit-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
min-height: 44px;
margin-left: auto;
padding: 0.4rem 0.9rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: transparent;
color: var(--ink-soft);
font-weight: 600;
text-decoration: none;
}
.doc-edit-back:hover { color: var(--ink); border-color: var(--ink-soft); }
.doc-edit-back svg { width: 16px; height: 16px; display: block; }
/* The edit form — the tuning form's card as a vertical stack: labeled
title input, mono path input, the mono markdown textarea (min-height
20rem — the body is the star), and the actions row (the brand Push
button + the back link). Inset fields (bg fill on the surface card). */
#doc-edit-form {
display: flex;
flex-direction: column;
gap: 0.9rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.5rem 1.5rem 1.75rem;
}
#doc-edit-form label {
font-weight: 600;
font-size: 0.95rem;
}
#draft-title,
#draft-path {
width: 100%;
font: inherit;
font-size: 1rem;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.55rem 0.75rem;
min-height: 44px;
}
/* The in-repo path is machine data — mono (the git-sources URL-input
convention). */
#draft-path {
font-family: var(--mono);
font-size: 0.92rem;
}
/* The markdown body: mono, tall (min-height 20rem), vertical resize.
ink on bg = 16.7:1. */
#draft-body {
width: 100%;
font-family: var(--mono);
font-size: 0.92rem;
line-height: 1.5;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.75rem 0.9rem;
min-height: 20rem;
resize: vertical;
}
/* Actions row: the primary Push button (brand, dark ink on brand
5.2:1 — never white on brand) + the back link; wraps at narrow
widths. */
.doc-edit-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
#push-doc-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
border: 0;
border-radius: var(--radius-sm);
background: var(--brand);
color: var(--bg); /* dark ink on brand: 5.2:1 */
font: inherit;
font-weight: 700;
cursor: pointer;
padding-inline: 1.25rem;
}
#push-doc-btn:hover:not(:disabled) { background: #7d88f5; }
#push-doc-btn:disabled { opacity: 0.6; cursor: wait; }
/* The success status line (role=status): the ok family (ok-ink on
ok-bg 10.6:1) when a push outcome has landed — min-height holds the
line's space so the layout never jumps when the text lands. Empty
(before the first push, or after a failure cleared the stale line)
it is the dashed placeholder (the #archive-upload-result language). */
.doc-edit-status {
margin: 0;
min-height: 1.5rem;
background: var(--ok-bg);
color: var(--ok-ink);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 0.45rem 0.8rem;
font-size: 0.9rem;
font-weight: 600;
}
.doc-edit-status:empty {
background: transparent;
border-style: dashed;
color: var(--ink-soft);
}
/* The error banner (role=alert): the err family (err-ink on err-bg
9.3:1, err-line border) — git's stderr may carry long paths, so
long words break instead of overflowing the card. */
.doc-edit-error {
margin: 0;
background: var(--err-bg);
color: var(--err-ink);
border: 1px solid var(--err-line);
border-radius: var(--radius-sm);
padding: 0.5rem 0.8rem;
font-size: 0.85rem;
font-weight: 600;
overflow-wrap: anywhere;
}
/* ---------- Footer ---------- */ /* ---------- Footer ---------- */
.app-footer { .app-footer {
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
+134
View File
@@ -0,0 +1,134 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="Edit a saved chat answer before it is committed to the docs repository (admin-only).">
<title>Edit doc · Brain of Reese</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<!-- Phase 59 task 06: the SLIM header — this is a flow page (the
login.html / shared.html minimal-flow-page lineage), not one of
the app's pages: no nav, no auth pair, no hamburger. Brand +
the "← Back to chat" link are the whole chrome. -->
<header class="app-header">
<div class="container header-inner">
<span class="brand">
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
<span class="brand-text">Brain of <strong>Reese</strong></span>
</span>
<a class="doc-edit-back" href="/">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
<span>Back to chat</span>
</a>
</div>
</header>
<main id="main" class="app-main" tabindex="-1">
<div class="container doc-edit-shell">
<!-- The 46rem base column (a FORM column — it hard-codes 46rem,
it does not ride --chat-column, so phase 58's wide-desktop
doubling never stretches the form). -->
<div class="page-head">
<h1>Edit doc</h1>
<p class="page-sub">
Review the saved answer, adjust anything, then push it to the
docs branch — the commit lands in the configured docs repo;
you open the PR yourself.
</p>
</div>
<!-- Phase 59 task 06: the admin gate — the EXACT #sources-gate
pattern (phase 16) and the same .sources-gate visual
language (phases 35/50). The page is static; the API is the
authority — the draft endpoints are admin-only regardless,
so a non-admin visitor gets the gate and NO draft data
(doc-edit.js makes no /api/doc-drafts call before whoami
says admin). -->
<section class="sources-gate" id="doc-edit-gate" aria-labelledby="doc-edit-gate-title" hidden>
<div class="sources-gate-glyph" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
</div>
<h2 id="doc-edit-gate-title">Sign in to edit docs</h2>
<p class="sources-gate-sub">
Saving a chat answer as documentation is admin-only. Chat —
and any document an answer cites — stays open to everyone.
</p>
<a class="sources-gate-link" href="/login.html?next=/doc-edit.html">Sign in</a>
</section>
<!-- SHIPS hidden (anonymous-safe; the gate is what anonymous
visitors see). doc-edit.js reveals it once the cached whoami
says admin, then loads the draft from ?draft=<token> (the
uuid4 token task 05's button navigated with). -->
<div id="doc-edit-content" hidden>
<form id="doc-edit-form">
<label for="draft-title">Title</label>
<input
id="draft-title"
name="title"
type="text"
autocomplete="off"
required
>
<label for="draft-path">In-repo path</label>
<input
id="draft-path"
name="path"
type="text"
autocomplete="off"
required
>
<label for="draft-body">Body — markdown</label>
<textarea id="draft-body" name="body" required></textarea>
<div class="doc-edit-actions">
<button type="submit" id="push-doc-btn">Push to docs branch</button>
<a class="doc-edit-back" href="/">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
<span>Back to chat</span>
</a>
</div>
<!-- §7.4 never-stale: the polite live region carries the
push lifecycle — "Pushing…" while the request is out,
then `Pushed to <branch> — commit <sha7>.` on success.
doc-edit.js owns the text (textContent only). -->
<p class="doc-edit-status" id="push-status" role="status" aria-live="polite"></p>
<!-- The error banner (role=alert), hidden until a load or
push failure: the server's detail (git's stderr,
trimmed to its first meaningful lines) lands here and
the fields are preserved — the fix is an edit, not a
re-type. -->
<div class="doc-edit-error" id="push-error" role="alert" hidden></div>
</form>
</div>
</div>
</main>
<footer class="app-footer">
<div class="container footer-inner">
<span>Powered by Reese's self-hosted models</span>
</div>
</footer>
<!-- Phase 39: the brand layer — classic script, first on the page:
window.BOR_BRAND at parse time, refreshed from /api/config.
Phase 59 task 06: the page module loads the shared header module
through its own relative `import "./header.js"` — a hoisted
import evaluated before this body runs (single-evaluation
design: no direct header.js <script> tag; esbuild inlines it
into the page bundle in the image build). On this slim flow
page the import is the cached whoami (fetchIsAdmin) the admin
gate runs on. -->
<script src="assets/brand.js"></script>
<script type="module" src="/assets/doc-edit.js"></script>
</body>
</html>
+16 -6
View File
@@ -10,14 +10,16 @@ No credentials are stored here; whatever the URL/SSH config supplies is
used. used.
This module is the only place the ``git`` CLI is invoked (A11: stdlib This module is the only place the ``git`` CLI is invoked (A11: stdlib
``subprocess`` only, no new packages). ``subprocess`` only, no new packages) — every git command goes through
:func:`run_git`: the clone/pull in :func:`clone_or_pull` and the
docs-push sequence in :mod:`app.core.docs_push` (phase 59).
""" """
from __future__ import annotations from __future__ import annotations
import subprocess import subprocess
from pathlib import Path from pathlib import Path
__all__ = ["GitSyncError", "clone_or_pull"] __all__ = ["GitSyncError", "clone_or_pull", "run_git"]
class GitSyncError(RuntimeError): class GitSyncError(RuntimeError):
@@ -40,14 +42,22 @@ def clone_or_pull(url: str, dest: Path | str) -> Path:
dest = Path(dest) dest = Path(dest)
if not dest.exists() or not (dest / ".git").exists(): if not dest.exists() or not (dest / ".git").exists():
dest.parent.mkdir(parents=True, exist_ok=True) dest.parent.mkdir(parents=True, exist_ok=True)
_run(["git", "clone", "--depth", "1", url, str(dest)], cwd=dest.parent) run_git(["git", "clone", "--depth", "1", url, str(dest)], cwd=dest.parent)
else: else:
_run(["git", "pull", "--ff-only"], cwd=dest) run_git(["git", "pull", "--ff-only"], cwd=dest)
return dest return dest
def _run(argv: list[str], cwd: Path) -> str: def run_git(argv: list[str], cwd: Path) -> str:
"""Run a git command, capturing output; raise GitSyncError on failure.""" """Run one git command, capturing output; raise GitSyncError on failure.
The single ``git`` invocation point for the whole app (A11). Every
step of :func:`clone_or_pull` and of the docs-push sequence
(:mod:`app.core.docs_push`, phase 59) goes through here, so error
handling stays uniform: captured stdout on success, and
:class:`GitSyncError` carrying git's stderr on a non-zero exit (or
when the git binary is missing from PATH).
"""
try: try:
proc = subprocess.run(argv, cwd=cwd, capture_output=True, text=True) proc = subprocess.run(argv, cwd=cwd, capture_output=True, text=True)
except FileNotFoundError: except FileNotFoundError:
+5 -4
View File
@@ -116,9 +116,9 @@ def test_html_pages_are_no_cache_and_versioned(page: Page, app_url: str) -> None
def test_other_pages_share_the_token(page: Page, app_url: str) -> None: def test_other_pages_share_the_token(page: Page, app_url: str) -> None:
"""/sources.html, /login.html and /history.html (phase 50): each """/sources.html, /login.html, /history.html (phase 50) and
document revalidates, and all three pages' stylesheet requests carry /doc-edit.html (phase 59): each document revalidates, and all four
the same process token.""" pages' stylesheet requests carry the same process token."""
token = _expected_token() token = _expected_token()
assert token assert token
@@ -134,7 +134,8 @@ def test_other_pages_share_the_token(page: Page, app_url: str) -> None:
sources_token = navigate("/sources.html") sources_token = navigate("/sources.html")
login_token = navigate("/login.html") login_token = navigate("/login.html")
history_token = navigate("/history.html") # phase 50: the new page history_token = navigate("/history.html") # phase 50: the new page
assert sources_token == login_token == history_token == token docedit_token = navigate("/doc-edit.html") # phase 59: the doc edit screen
assert sources_token == login_token == history_token == docedit_token == token
def test_shared_page_is_no_cache_and_versioned( def test_shared_page_is_no_cache_and_versioned(
+5 -1
View File
@@ -144,8 +144,12 @@ def test_api_config_serves_both_names(testy_server: str, app_server: str) -> Non
r = httpx.get(f"{TESTY_URL}/api/config", timeout=5) r = httpx.get(f"{TESTY_URL}/api/config", timeout=5)
assert r.status_code == 200 assert r.status_code == 200
body = r.json() body = r.json()
assert set(body) == {"app_name", "version"} # Phase 59 (task 05): the third key is the docs-push flag — the
# "Save as doc" gating; both instances run with BOR_DOCS_REPO
# empty, so it is the inert false here.
assert set(body) == {"app_name", "version", "docs_repo_configured"}
assert body["app_name"] == TESTY_NAME assert body["app_name"] == TESTY_NAME
assert body["docs_repo_configured"] is False
# The shared conftest instance keeps the default (the other # The shared conftest instance keeps the default (the other
# suites' title/label contract rides on it). # suites' title/label contract rides on it).
+602
View File
@@ -0,0 +1,602 @@
"""Phase 59 story E2E (Playwright): the save → edit → push loop, with
the BARE REPO as source of truth.
Story: n/a (TODO-derived — "Convert response to documentation that gets
committed back to a repo specified in .env … allows you to modify the
new documentation before [pushing] to the specified repo").
Run in isolation (DB must be up: ``podman compose up -d db``; ``git``
on PATH — the suite skips without it):
uv run pytest tests/e2e/test_response_to_docs.py -v --no-cov
The loop under test: a completed brain bubble carries a bottom-right
"Save as doc" action (admin + a configured ``BOR_DOCS_REPO``) → it
opens ``/doc-edit.html?draft=<token>`` prefilled (auto-title from the
last question, path ``docs/<slug>.md``, body = the answer's MARKDOWN
SOURCE — never the rendered HTML) → Push commits + pushes to the
``.env``-configured branch of the ``.env``-configured repo. Every
success assertion reads the **bare repo itself** (``git show
<branch>:<path>``, ``git rev-list``, ``git rev-parse``) — the UI text
is only the entry point (D3: no PR is ever created or attempted — the
flow ends at the push to the branch).
App boots (the conftest pattern, module-scoped — as in
``test_git_sources_admin.py``):
* the module app boots with ``BOR_DOCS_REPO=<tmp>/docs.git`` (a local
BARE repo seeded with one commit on ``main``), ``BOR_DOCS_BRANCH=
bor-docs``, ``BOR_DOCS_BASE_BRANCH=main``, ``BOR_DOCS_WORK_DIR=
<tmp>/docs-work``;
* ``test_unconfigured_hides_button`` boots a SECOND app (separate
fixture, ``APP_PORT + 1``) with NO docs env — the inert default:
no button for anyone, draft creation still allowed (drafts are
repo-independent), push 409s naming ``BOR_DOCS_REPO``.
The mock LLM keeps every answer byte-deterministic: the suite replays
the same question through ``POST /api/chat`` (raw SSE, the
``test_chat_rag.py`` pattern) to recover the exact markdown source the
draft must carry — so "body == the answer's markdown source" is an
exact-byte assertion, not a contains check.
Test → story mapping (Playwright Mapping Rule):
1. ``test_save_edit_push``
2. ``test_second_push_fast_forwards``
3. ``test_guest_has_no_button``
4. ``test_unconfigured_hides_button``
"""
from __future__ import annotations
import asyncio
import json
import os
import re
import subprocess
import sys
from collections.abc import Iterator
from pathlib import Path
from types import SimpleNamespace
from typing import Any
from urllib.parse import parse_qs, urlsplit
import httpx
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
APP_URL = f"http://127.0.0.1:{APP_PORT}"
#: The unconfigured app's port (task 07: a second app boot WITHOUT
#: ``BOR_DOCS_REPO`` — a separate fixture on the next port, so it can
#: run alongside the module app).
UNCONF_URL = f"http://127.0.0.1:{APP_PORT + 1}"
BRANCH = "bor-docs"
BASE_BRANCH = "main"
#: On-topic fixture questions (the house phrasing — proven HIGH gate in
#: test_chat_rag.py / test_pinned_composer.py, so every turn renders a
#: grounded answer with the deterministic marker, never a deflection).
QUESTION_1 = "How is my Kubernetes cluster set up?"
QUESTION_2 = "What is in the new-service deployment?"
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
#: The distinctive line test 1 appends to the body before pushing —
#: ASCII on purpose (git's output must match byte-for-byte), and a
#: module constant so test 2 can reconstruct file 1's expected content
#: (deterministic: the mock answer + this exact suffix).
E2E_MARKER = "E2E-DOCS-MARKER (appended by the response-to-docs story suite)"
#: The edit screen's URL shape (task 05 navigates with the uuid4 token).
DRAFT_URL_RE = re.compile(r"/doc-edit\.html\?draft=[0-9a-f-]{36}")
#: The success line (task 06): `Pushed to <branch> — commit <sha7>.`
SUCCESS_SHA_RE = re.compile(r"commit ([0-9a-f]{7})\.$")
def _git_available() -> bool:
try:
return subprocess.run(
["git", "--version"], capture_output=True, timeout=10
).returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
pytestmark = pytest.mark.skipif(
not _git_available(), reason="git is not on PATH (the docs push is real git)"
)
def _git(args: list[str], cwd: Path | None = None) -> str:
"""One git command (the bare repo is the source of truth); fail loud."""
proc = subprocess.run(
["git", *args], cwd=cwd, capture_output=True, text=True, timeout=60
)
assert proc.returncode == 0, f"git {' '.join(args)} failed: {proc.stderr}"
return proc.stdout
def doc_slug(title: str) -> str:
"""The app.js slug rule (phase 59 locked assumption), ported:
lowercase, runs of non-alphanumerics → ``-``, trimmed, ≤60 chars,
empty → ``note`` (the trailing trim survives a mid-dash 60-cut)."""
slug = (
re.sub(r"[^a-z0-9]+", "-", title.lower())
.strip("-")[:60]
.rstrip("-")
)
return slug or "note"
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login
— used to call the admin API with plain httpx (the
``test_cache_busting.py`` pattern)."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def docs_repo(tmp_path_factory: pytest.TempPathFactory) -> SimpleNamespace:
"""The local BARE docs repo (the .env remote, D3-generic): one
seed commit (``README.md``) pushed as ``main``. ``work`` is where
the app's ``BOR_DOCS_WORK_DIR`` checkout lands (it persists for the
whole module — the second push exercises the existing-checkout
path)."""
base = tmp_path_factory.mktemp("docs-git")
bare = base / "docs.git"
_git(["init", "--bare", str(bare)])
seed = base / "seed"
_git(["init", "-b", "main", str(seed)])
(seed / "README.md").write_text("# e2e docs repo\n", encoding="utf-8")
_git(["add", "--", "README.md"], cwd=seed)
# -c identity + no GPG signing: the machine's global git config
# (gpgsign=true here) must not leak into the fixture.
_git(
[
"-c", "user.name=E2E Seeder",
"-c", "user.email=e2e@local",
"-c", "commit.gpgsign=false",
"commit", "-m", "seed: README",
],
cwd=seed,
)
_git(["remote", "add", "origin", str(bare)], cwd=seed)
_git(["push", "origin", "main"], cwd=seed)
return SimpleNamespace(bare=bare, work=base / "docs-work")
def _spawn_app(port: int, mock_port: int, docs_env: dict[str, str] | None) -> subprocess.Popen:
"""One uvicorn boot (the conftest app_server env shape); ``None``
docs_env = NO docs variables at all (the unconfigured app)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1" if USE_REAL_LLM else f"http://127.0.0.1:{mock_port}/v1"
)
# Mock-calibrated threshold (conftest pattern): the fixture questions
# gate HIGH, so every turn is a grounded answer with the marker.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
if docs_env is None:
for var in (
"BOR_DOCS_REPO",
"BOR_DOCS_BRANCH",
"BOR_DOCS_BASE_BRANCH",
"BOR_DOCS_WORK_DIR",
):
env.pop(var, None)
else:
env.update(docs_env)
return subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
cwd=REPO,
env=env,
)
def _stop(proc: subprocess.Popen) -> None:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_server(mock_llm: int, docs_repo: SimpleNamespace) -> Iterator[str]:
"""The configured app under test (module scope — shadows the
conftest session app; an isolated run never starts two)."""
proc = _spawn_app(
APP_PORT,
mock_llm,
{
"BOR_DOCS_REPO": str(docs_repo.bare),
"BOR_DOCS_BRANCH": BRANCH,
"BOR_DOCS_BASE_BRANCH": BASE_BRANCH,
"BOR_DOCS_WORK_DIR": str(docs_repo.work),
},
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
_stop(proc)
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
@pytest.fixture()
def unconfigured_app(mock_llm: int) -> Iterator[str]:
"""The SECOND app boot (task 07): NO ``BOR_DOCS_REPO`` — the inert
default the suite must see as absent-for-everyone + 409 push."""
proc = _spawn_app(APP_PORT + 1, mock_llm, None)
try:
_wait_http(f"{UNCONF_URL}/api/health")
yield UNCONF_URL
finally:
_stop(proc)
# ---------------------------------------------------------------------------
# KB + table hygiene (the E2E isolation pattern — this suite owns the
# KB tables and doc_drafts; both are reset around every test)
# ---------------------------------------------------------------------------
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {
"_env_file": None,
"llm_base_url": f"http://127.0.0.1:{mock_port}/v1",
}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread (the Playwright sync API keeps
an asyncio loop on the test thread — the test_chat_rag.py helper)."""
import threading
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = threading.Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int, seed: bool) -> None:
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, doc_drafts"))
db.commit()
if seed:
summary = _run_in_thread(_import_fixtures(mock_port))
assert summary.added == 13 # the A9 fixture set (test_chat_rag.py)
@pytest.fixture(autouse=True)
def _kb_and_clean_drafts(mock_llm: int, db_ready: None) -> Iterator[None]:
"""Fresh KB (the deterministic mock embeddings — the grounded
questions gate HIGH) + an empty ``doc_drafts`` table per test."""
_reset_db(mock_llm, seed=True)
yield
_reset_db(mock_llm, seed=False)
# ---------------------------------------------------------------------------
# Story helpers
# ---------------------------------------------------------------------------
def _stream_chat_answer(app_url: str, message: str) -> str:
"""Replay one turn through the raw SSE endpoint (the
``test_chat_rag.py`` transport pattern) and return the EXACT answer
text — the markdown source the UI accumulates into ``m.text``,
byte-identical for the deterministic mock (same KB, same question)."""
frames: list[dict[str, Any]] = []
with httpx.stream(
"POST", f"{app_url}/api/chat", json={"message": message}, timeout=120.0
) as r:
assert r.status_code == 200
buf = ""
for part in r.iter_text():
buf += part
while "\n\n" in buf:
frame, buf = buf.split("\n\n", 1)
if frame.strip().startswith("data:"):
frames.append(
json.loads(frame.strip().removeprefix("data:").strip())
)
deltas = [f for f in frames if f.get("type") == "delta"]
assert deltas, "the SSE stream must deliver deltas"
return "".join(d["text"] for d in deltas)
def _ask(page: Page, app_url: str, question: str) -> None:
"""One grounded turn to its DONE state (marker in the bubble + the
send button re-enabled — the meta-row buttons land on done)."""
page.fill("#message-input", question)
page.click("#send-btn")
bubble = page.locator(".msg.brain .bubble:not(.typing)").first
expect(bubble).to_contain_text(question, timeout=30_000)
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
expect(page.locator("#send-label")).to_have_text("Send")
def _login_admin(page: Page, app_url: str) -> None:
"""Real form login landing on the chat (admin settled)."""
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=30_000)
def _open_edit_screen(page: Page) -> str:
"""Click the save action, wait for the navigation, return the draft
token from the URL (the uuid4 credential)."""
page.click(".save-as-doc-btn")
page.wait_for_url(DRAFT_URL_RE, timeout=30_000)
token = parse_qs(urlsplit(page.url).query).get("draft", [""])[0]
assert re.fullmatch(r"[0-9a-f-]{36}", token), f"no draft token in {page.url}"
expect(page.locator("#doc-edit-gate")).to_be_hidden(timeout=30_000)
expect(page.locator("#doc-edit-content")).to_be_visible(timeout=30_000)
return token
def _push_and_read_sha(page: Page) -> tuple[str, str]:
"""Submit the edit screen's push; wait for the success line and
return (branch, sha7) exactly as the live region reported them."""
page.click("#push-doc-btn")
status = page.locator("#push-status")
expect(status).to_contain_text(f"Pushed to {BRANCH}", timeout=60_000)
line = status.inner_text().strip()
m = SUCCESS_SHA_RE.search(line)
assert m, f"the success line carries no commit sha: {line!r}"
return BRANCH, m.group(1)
# ---------------------------------------------------------------------------
# 1. The whole loop: save → edit → push → the bare repo agrees
# ---------------------------------------------------------------------------
def test_save_edit_push(
page: Page,
app_url: str,
mock_llm: int,
db_ready: None,
docs_repo: SimpleNamespace,
) -> None:
page.set_default_timeout(30_000)
_login_admin(page, app_url)
_ask(page, app_url, QUESTION_1)
# The "Save as doc" action is on the completed brain bubble…
btn = page.locator(".msg.brain .save-as-doc-btn")
expect(btn).to_have_count(1)
expect(btn).to_contain_text("Save as doc")
# …bottom-right: its left edge sits past the bubble's midline
# (margin-inline-start: auto in the meta row).
msg_box = page.locator(".msg.brain").bounding_box()
btn_box = btn.bounding_box()
assert msg_box is not None and btn_box is not None
midline = msg_box["x"] + msg_box["width"] / 2
assert btn_box["x"] > midline, (
f"save button x={btn_box['x']:.0f} is not past the bubble midline "
f"{midline:.0f} — it must sit bottom-right"
)
# Click → /doc-edit.html?draft=<uuid4>, prefilled.
_open_edit_screen(page)
expect(page.locator("#draft-title")).to_have_value(QUESTION_1) # auto-title
expect(page.locator("#draft-path")).to_have_value(
f"docs/{doc_slug(QUESTION_1)}.md"
)
# Body == the rendered answer's MARKDOWN SOURCE: the SSE replay
# recovers the exact bytes the UI accumulated (the mock is
# byte-deterministic on the same KB + question) — and they are
# plain markdown, not rendered HTML.
raw = _stream_chat_answer(app_url, QUESTION_1)
assert MOCK_ANSWER_MARKER in raw and QUESTION_1 in raw
assert "<" not in raw and ">" not in raw, "the draft body must be markdown, not HTML"
expect(page.locator("#draft-body")).to_have_value(raw)
# Modify the doc (the story's "modify before [pushing]"): a
# distinctive marker line the bare repo must show after the push.
edited = f"{raw}\n\n{E2E_MARKER}"
page.fill("#draft-body", edited)
# Push → the live region reports the branch + a 7-char commit sha…
branch, sha7 = _push_and_read_sha(page)
assert branch == BRANCH
# …and the BARE REPO agrees (the source of truth — not the UI):
# the file on the branch is exactly the edited body…
path = f"docs/{doc_slug(QUESTION_1)}.md"
shown = _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path}"])
assert shown == edited
# …and the branch tip's first 7 chars are the sha the UI reported.
tip = _git(["-C", str(docs_repo.bare), "rev-parse", BRANCH]).strip()
assert tip.startswith(sha7), f"UI sha {sha7} != bare repo tip {tip}"
# First push: the branch exists and is exactly one commit beyond
# main (created by the push — the remote had no bor-docs before).
assert (
_git(["-C", str(docs_repo.bare), "rev-list", "--count", f"{BASE_BRANCH}..{BRANCH}"])
.strip()
== "1"
)
# ---------------------------------------------------------------------------
# 2. A second save fast-forwards: two commits, file 1 untouched
# ---------------------------------------------------------------------------
def test_second_push_fast_forwards(
page: Page,
app_url: str,
mock_llm: int,
db_ready: None,
docs_repo: SimpleNamespace,
) -> None:
page.set_default_timeout(30_000)
_login_admin(page, app_url)
_ask(page, app_url, QUESTION_2)
# Save the second answer (different question → different slug)…
expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(1)
_open_edit_screen(page)
expect(page.locator("#draft-title")).to_have_value(QUESTION_2)
expect(page.locator("#draft-path")).to_have_value(
f"docs/{doc_slug(QUESTION_2)}.md"
)
raw2 = _stream_chat_answer(app_url, QUESTION_2)
expect(page.locator("#draft-body")).to_have_value(raw2)
# …and push WITHOUT editing — a new commit on the same branch.
_push_and_read_sha(page)
# The bare repo: exactly two commits beyond main (fast-forward,
# never a force-push or a reset)…
assert (
_git(["-C", str(docs_repo.bare), "rev-list", "--count", f"{BASE_BRANCH}..{BRANCH}"])
.strip()
== "2"
)
# …file 2 landed with its unedited body…
path2 = f"docs/{doc_slug(QUESTION_2)}.md"
assert _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path2}"]) == raw2
# …and file 1 from test 1 is still at its path, byte-for-byte
# (deterministic reconstruction: the mock answer + the marker line).
path1 = f"docs/{doc_slug(QUESTION_1)}.md"
expected_first = f"{_stream_chat_answer(app_url, QUESTION_1)}\n\n{E2E_MARKER}"
assert _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path1}"]) == expected_first
# ---------------------------------------------------------------------------
# 3. Guest: no button, 403 on the draft API, the edit screen gates
# ---------------------------------------------------------------------------
def test_guest_has_no_button(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
page.set_default_timeout(30_000)
# No login (the conftest page fixture is a fresh context). Track
# every /api/doc-drafts request the PAGES make — the guest flow
# must never reach the admin API.
drafts_calls: list[str] = []
page.on(
"request",
lambda r: drafts_calls.append(r.url) if "/api/doc-drafts" in r.url else None,
)
page.goto(app_url)
expect(page.locator("#sign-in-link")).to_be_visible(timeout=30_000)
_ask(page, app_url, QUESTION_1) # the grounded answer streams for guests too
# The "Save as doc" action is admin-only: ABSENT (not hidden) on
# the completed bubble, whatever the docs config says.
expect(page.locator(".save-as-doc-btn")).to_have_count(0)
# The draft API 403s anonymous callers (httpx, no cookie at all).
r = httpx.post(
f"{app_url}/api/doc-drafts",
json={"title": "guest", "path": "docs/guest.md", "body": "nope"},
timeout=10,
)
assert r.status_code == 403
# The edit screen renders the admin gate with NO draft data in the
# DOM, and the page itself made zero draft API calls.
page.goto(app_url + "/doc-edit.html")
expect(page.locator("#doc-edit-gate")).to_be_visible(timeout=30_000)
expect(page.locator("#doc-edit-content")).to_be_hidden()
assert page.input_value("#draft-title") == ""
assert page.input_value("#draft-path") == ""
assert page.input_value("#draft-body") == ""
assert drafts_calls == [], f"guest pages called the draft API: {drafts_calls}"
# ---------------------------------------------------------------------------
# 4. Unconfigured (BOR_DOCS_REPO empty): inert for everyone, 409 push
# ---------------------------------------------------------------------------
def test_unconfigured_hides_button(
page: Page, unconfigured_app: str, mock_llm: int, db_ready: None
) -> None:
page.set_default_timeout(30_000)
_login_admin(page, unconfigured_app)
_ask(page, unconfigured_app, QUESTION_1)
# docs_repo_configured false → the button is hidden for EVERYONE,
# admin included (the optional-feature pattern — inert by default).
expect(page.locator(".save-as-doc-btn")).to_have_count(0)
# Drafts are repo-independent: an admin can still create one…
cookies = _admin_cookies(page)
r = httpx.post(
f"{unconfigured_app}/api/doc-drafts",
json={
"title": "Unconfigured draft",
"path": "docs/unconfigured.md",
"body": "A draft while no docs repo is configured.",
},
timeout=10,
cookies=cookies,
)
assert r.status_code == 201, r.text
token = r.json()["token"]
# …but pushing 409s, naming the missing variable (D3: fail loud,
# inert by default).
r = httpx.post(
f"{unconfigured_app}/api/doc-drafts/{token}/push", timeout=10, cookies=cookies
)
assert r.status_code == 409
assert "BOR_DOCS_REPO" in r.json()["detail"]
+29 -3
View File
@@ -18,13 +18,16 @@ def test_health_reports_ok(client) -> None:
def test_config_returns_default_app_metadata(client) -> None: def test_config_returns_default_app_metadata(client) -> None:
"""GET /api/config is public (anonymous) and returns exactly two keys.""" """GET /api/config is public (anonymous) and returns exactly three
keys — the phase-39 app metadata + the phase-59 docs flag (inert
false while BOR_DOCS_REPO is empty — the "Save as doc" gating)."""
r = client.get("/api/config") r = client.get("/api/config")
assert r.status_code == 200 assert r.status_code == 200
body = r.json() body = r.json()
assert set(body) == {"app_name", "version"} assert set(body) == {"app_name", "version", "docs_repo_configured"}
assert body["app_name"] == "Brain of Reese" assert body["app_name"] == "Brain of Reese"
assert body["version"] == get_settings().app_version assert body["version"] == get_settings().app_version
assert body["docs_repo_configured"] is False
def test_config_follows_overridden_app_name(client) -> None: def test_config_follows_overridden_app_name(client) -> None:
@@ -39,9 +42,32 @@ def test_config_follows_overridden_app_name(client) -> None:
r = client.get("/api/config") r = client.get("/api/config")
assert r.status_code == 200 assert r.status_code == 200
body = r.json() body = r.json()
assert set(body) == {"app_name", "version"} assert set(body) == {"app_name", "version", "docs_repo_configured"}
assert body["app_name"] == "Brain of Testy" assert body["app_name"] == "Brain of Testy"
assert body["version"] == "0.1.0" assert body["version"] == "0.1.0"
assert body["docs_repo_configured"] is False
finally:
fastapi_app.dependency_overrides.clear()
def test_config_docs_flag_tracks_settings(client) -> None:
"""Phase 59 (task 05): ``docs_repo_configured`` mirrors
``settings.docs_configured`` — a real bool (never a truthy string)
that flips true the moment BOR_DOCS_REPO is non-empty: that flag is
the entire frontend gating of the "Save as doc" button."""
from app.config import Settings
from app.main import app as fastapi_app
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
app_name="Brain of Testy",
docs_repo="/srv/docs-repo",
)
try:
r = client.get("/api/config")
assert r.status_code == 200
body = r.json()
assert isinstance(body["docs_repo_configured"], bool)
assert body["docs_repo_configured"] is True
finally: finally:
fastapi_app.dependency_overrides.clear() fastapi_app.dependency_overrides.clear()
+536
View File
@@ -0,0 +1,536 @@
"""Integration: doc-drafts API (phase 59, task 02) — create / get / update.
The draft lifecycle the edit screen runs on: create (from a response),
fetch by token, update (modify before push) — all admin-only
(router-wide ``require_admin``), all path-guard-railed (no path that can
escape the repo root).
Real Postgres (``podman compose up -d db``); no LLM involved — drafts
are plain rows, so the suite is deterministic without a fake.
Requires: podman compose up -d db
"""
from __future__ import annotations
import subprocess
import uuid
from collections.abc import Iterator
from datetime import datetime
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select, text
from app.config import Settings, get_settings
from app.main import app as fastapi_app
from app.models import DocDraft
TITLE = "How do I deploy a new service?"
PATH = "docs/note.md"
BODY = "# Answer\n\nSome **markdown** body."
BASE_BRANCH = "main"
DOCS_BRANCH = "bor-docs"
def _settings(**kwargs: Any) -> Settings:
"""Build Settings without reading a .env file (deterministic tests)."""
kwargs.setdefault("_env_file", None)
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
def _git_available() -> bool:
try:
proc = subprocess.run(["git", "--version"], capture_output=True, check=False)
return proc.returncode == 0
except (FileNotFoundError, OSError):
return False
#: The push tests drive a real local git repo — skipped (not failed) on a
#: machine without the git CLI (the task-03 unit-suite guard).
GIT = _git_available()
def _git(cwd: Path, *argv: str) -> str:
"""Run git for the tests themselves (fixture setup + assertions)."""
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
@pytest.fixture(autouse=True)
def clean_drafts(db) -> Iterator[None]:
"""doc_drafts is global state: reset around every test."""
db.execute(text("TRUNCATE doc_drafts"))
db.commit()
yield
db.execute(text("TRUNCATE doc_drafts"))
db.commit()
def _create(admin_client: TestClient, **overrides) -> dict:
"""POST a well-formed draft (201) and return the response body."""
payload = {"title": TITLE, "path": PATH, "body": BODY, **overrides}
r = admin_client.post("/api/doc-drafts", json=payload)
assert r.status_code == 201, r.text
return r.json()
def _backdate_updated_at(db, token: uuid.UUID) -> None:
"""Push the row's ``updated_at`` one hour back (raw SQL — a hand-
written UPDATE does not trigger the column's onupdate default), so
a subsequent API write's bump is observable deterministically."""
db.execute(
text("UPDATE doc_drafts SET updated_at = now() - interval '1 hour' WHERE token = :t"),
{"t": token},
)
db.commit()
# ---------- create (POST) ----------
def test_create_returns_201_with_all_fields_and_draft_status(
admin_client: TestClient, db
) -> None:
r = admin_client.post("/api/doc-drafts", json={"title": TITLE, "path": PATH, "body": BODY})
assert r.status_code == 201
body = r.json()
assert body["title"] == TITLE
assert body["path"] == PATH
assert body["body"] == BODY
assert body["status"] == "draft"
# The push feedback columns are NULL while still a draft.
assert body["branch"] is None
assert body["commit_sha"] is None
# The token: present, non-NULL, a valid (unguessable) UUID.
assert body["token"]
tok = uuid.UUID(body["token"])
assert body["created_at"]
assert body["updated_at"]
# The row is in Postgres under the same token (the URL credential).
row = db.execute(select(DocDraft).where(DocDraft.token == tok)).scalars().one()
assert row.title == TITLE
assert row.path == PATH
assert row.body == BODY
assert row.status == "draft"
def test_create_strips_title_body_and_path(admin_client: TestClient) -> None:
body = _create(
admin_client, title=f" {TITLE} ", path=f" {PATH} ", body=f"\n{BODY}\n"
)
assert body["title"] == TITLE
assert body["path"] == PATH
assert body["body"] == BODY
def test_create_rejects_blank_title_body_path(admin_client: TestClient) -> None:
# Whitespace-only values: past pydantic's min_length=1, caught by the
# API's non-empty-after-strip rule (422), nothing stored.
for overrides in ({"title": " "}, {"body": " \t\n "}, {"path": " "}):
payload = {"title": TITLE, "path": PATH, "body": BODY, **overrides}
assert admin_client.post("/api/doc-drafts", json=payload).status_code == 422
# Truly empty title/body: pydantic 422 (min_length=1).
empty_title = {"title": "", "path": PATH, "body": BODY}
assert admin_client.post("/api/doc-drafts", json=empty_title).status_code == 422
empty_body = {"title": TITLE, "path": PATH, "body": ""}
assert admin_client.post("/api/doc-drafts", json=empty_body).status_code == 422
# ---------- path guard-rails (shared with the push endpoint) ----------
@pytest.mark.parametrize(
("bad_path", "rule_in_detail"),
[
("/etc/passwd", "absolute"),
("../x.md", "'..'"),
("a/b/../c.md", "'..'"),
("no-suffix", "suffix"),
(" ", "empty"),
],
)
def test_create_path_guard_rejects_each_rule_with_422(
admin_client: TestClient, bad_path: str, rule_in_detail: str
) -> None:
r = admin_client.post(
"/api/doc-drafts", json={"title": TITLE, "path": bad_path, "body": BODY}
)
assert r.status_code == 422
assert rule_in_detail in r.json()["detail"]
def test_create_accepts_repo_relative_path_with_suffix(admin_client: TestClient) -> None:
body = _create(admin_client, path="docs/note.md")
assert body["path"] == "docs/note.md"
def test_put_path_guard_rejects_traversal(admin_client: TestClient) -> None:
created = _create(admin_client)
for bad in ("/etc/passwd", "../x.md", "a/b/../c.md", "no-suffix"):
r = admin_client.put(
f"/api/doc-drafts/{created['token']}", json={"path": bad}
)
assert r.status_code == 422, bad
# The row is untouched by the rejected updates.
body = admin_client.get(f"/api/doc-drafts/{created['token']}").json()
assert body["path"] == PATH
# ---------- get (by token) ----------
def test_get_round_trips_created_draft(admin_client: TestClient) -> None:
created = _create(admin_client)
r = admin_client.get(f"/api/doc-drafts/{created['token']}")
assert r.status_code == 200
assert r.json() == created
def test_get_unknown_token_returns_404(admin_client: TestClient) -> None:
r = admin_client.get(f"/api/doc-drafts/{uuid.uuid4()}")
assert r.status_code == 404
assert r.json() == {"detail": "draft not found"}
def test_get_malformed_token_returns_422(admin_client: TestClient) -> None:
assert admin_client.get("/api/doc-drafts/not-a-uuid").status_code == 422
# ---------- update (PUT) ----------
def test_put_partial_body_only_keeps_title_and_path(admin_client: TestClient, db) -> None:
created = _create(admin_client)
token = uuid.UUID(created["token"])
_backdate_updated_at(db, token)
r = admin_client.put(f"/api/doc-drafts/{token}", json={"body": "# v2\n\nEdited."})
assert r.status_code == 200
body = r.json()
assert body["title"] == TITLE # absent → unchanged
assert body["path"] == PATH # absent → unchanged
assert body["body"] == "# v2\n\nEdited."
assert body["status"] == "draft"
assert body["created_at"] == created["created_at"] # editing does not redate creation
# updated_at was bumped past the backdated value.
assert datetime.fromisoformat(body["updated_at"]) > datetime.fromisoformat(
created["updated_at"]
)
def test_put_replaces_all_fields_when_supplied(admin_client: TestClient) -> None:
created = _create(admin_client)
r = admin_client.put(
f"/api/doc-drafts/{created['token']}",
json={"title": "New title", "path": "docs/other.md", "body": "New body."},
)
assert r.status_code == 200
body = r.json()
assert body["title"] == "New title"
assert body["path"] == "docs/other.md"
assert body["body"] == "New body."
assert body["status"] == "draft"
def test_put_noop_still_bumps_updated_at(admin_client: TestClient, db) -> None:
"""A PUT whose supplied values are all identical (or empty body)
changes no stored value — the ORM flushes nothing — yet the contract
is that a PUT bumps ``updated_at`` (the raw-UPDATE fallback)."""
created = _create(admin_client)
token = uuid.UUID(created["token"])
_backdate_updated_at(db, token)
r = admin_client.put(f"/api/doc-drafts/{token}", json={"body": BODY}) # identical
assert r.status_code == 200
assert r.json()["body"] == BODY
assert datetime.fromisoformat(r.json()["updated_at"]) > datetime.fromisoformat(
created["updated_at"]
)
# And an empty partial body (no fields at all) does the same.
_backdate_updated_at(db, token)
r2 = admin_client.put(f"/api/doc-drafts/{token}", json={})
assert r2.status_code == 200
assert datetime.fromisoformat(r2.json()["updated_at"]) > datetime.fromisoformat(
created["updated_at"]
)
def test_put_resets_pushed_draft_to_draft(admin_client: TestClient, db) -> None:
created = _create(admin_client)
token = uuid.UUID(created["token"])
# Mark the draft pushed directly in the DB (the push endpoint's job
# lands in task 04 — here we pin the edit-side consequence).
row = db.execute(select(DocDraft).where(DocDraft.token == token)).scalars().one()
row.status = "pushed"
row.branch = "bor-docs"
row.commit_sha = "a" * 40
db.commit()
r = admin_client.put(f"/api/doc-drafts/{token}", json={"body": "Edited after push."})
assert r.status_code == 200
body = r.json()
assert body["status"] == "draft" # the stored sha no longer describes the body
assert body["body"] == "Edited after push."
assert body["title"] == TITLE # absent → unchanged
assert body["path"] == PATH # absent → unchanged
# The last push stays visible until the next push overwrites it.
assert body["branch"] == "bor-docs"
assert body["commit_sha"] == "a" * 40
def test_put_unknown_token_returns_404(admin_client: TestClient) -> None:
r = admin_client.put(f"/api/doc-drafts/{uuid.uuid4()}", json={"body": "x"})
assert r.status_code == 404
assert r.json() == {"detail": "draft not found"}
def test_put_malformed_token_returns_422(admin_client: TestClient) -> None:
assert admin_client.put("/api/doc-drafts/not-a-uuid", json={"body": "x"}).status_code == 422
def test_put_rejects_blank_fields_and_leaves_row_unchanged(admin_client: TestClient) -> None:
created = _create(admin_client)
for bad in ({"title": " "}, {"body": " \t "}, {"path": " "}):
assert admin_client.put(f"/api/doc-drafts/{created['token']}", json=bad).status_code == 422
assert admin_client.get(f"/api/doc-drafts/{created['token']}").json() == created
# ---------- auth: anonymous gets 403 on every route ----------
def test_anonymous_gets_403_on_all_routes(admin_client: TestClient, db) -> None:
created = _create(admin_client, body="admin-created")
anon = TestClient(fastapi_app) # fresh jar: truly anonymous (no cookie)
r = anon.post("/api/doc-drafts", json={"title": "x", "path": "docs/x.md", "body": "y"})
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
assert anon.get(f"/api/doc-drafts/{created['token']}").status_code == 403
assert anon.put(f"/api/doc-drafts/{created['token']}", json={"body": "nope"}).status_code == 403
# The anonymous attempts changed nothing: exactly the admin's draft
# exists, untouched.
rows = db.execute(select(DocDraft)).scalars().all()
assert len(rows) == 1
assert rows[0].body == "admin-created"
# ---------- push (POST /{token}/push — task 04) ----------
#
# The push tests run against a **real local bare repo** (the task-03
# unit pattern) and inject the endpoint's settings via the app's
# ``Depends(get_settings)`` override (the house pattern —
# ``app/api/config.py`` takes ``settings: Settings = Depends(
# get_settings)``). Result assertions read the bare repo's state
# (``git show <branch>:<path>``, ``git rev-parse``), not the response
# alone.
@pytest.fixture()
def bare_docs_repo(tmp_path: Path) -> Path:
"""A bare origin seeded with one commit on ``main`` (``README.md``)."""
if not GIT:
pytest.skip("git is not available on this machine")
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_BRANCH)
_git(
seed,
"-c", "commit.gpgsign=false",
"-c", "user.name=Test",
"-c", "user.email=t@example.com",
"add", "README.md",
)
_git(
seed,
"-c", "commit.gpgsign=false",
"-c", "user.name=Test",
"-c", "user.email=t@example.com",
"commit", "-m", "seed README",
)
_git(seed, "push", "origin", BASE_BRANCH)
return bare
@pytest.fixture()
def docs_push_settings(bare_docs_repo: Path, tmp_path: Path) -> Iterator[Settings]:
"""Settings pointing at the fixture bare repo, injected into the
endpoint's settings dependency; the override is removed after the
test (no leak into other tests' settings)."""
settings = _settings(
docs_repo=str(bare_docs_repo),
docs_branch=DOCS_BRANCH,
docs_base_branch=BASE_BRANCH,
docs_work_dir=str(tmp_path / "docs-workdir"),
)
fastapi_app.dependency_overrides[get_settings] = lambda: settings
try:
yield settings
finally:
fastapi_app.dependency_overrides.pop(get_settings, None)
def test_push_success_commits_and_records_branch_and_sha(
admin_client: TestClient, db, docs_push_settings: Settings, bare_docs_repo: Path
) -> None:
created = _create(admin_client)
_backdate_updated_at(db, uuid.UUID(created["token"]))
r = admin_client.post(f"/api/doc-drafts/{created['token']}/push")
assert r.status_code == 200, r.text
body = r.json()
assert body["status"] == "pushed"
assert body["branch"] == DOCS_BRANCH
sha = body["commit_sha"]
assert len(sha) == 40
# The source of truth is the bare repo's state — not the response:
# the file landed on the branch at exactly the returned sha, with
# the draft's body, under the fixed per-invocation identity.
assert _git(bare_docs_repo, "rev-parse", DOCS_BRANCH).strip() == sha
assert _git(bare_docs_repo, "show", f"{DOCS_BRANCH}:{PATH}") == BODY
ident = _git(bare_docs_repo, "log", "-1", DOCS_BRANCH, "--format=%an <%ae>").strip()
assert ident == "Brain of Reese <bor@local>"
assert _git(bare_docs_repo, "log", "-1", DOCS_BRANCH, "--format=%s").strip() == (
f"docs: {TITLE}"
)
# The DB row records the outcome (status + branch + sha), and the
# GET endpoint reports it.
row = db.execute(
select(DocDraft).where(DocDraft.token == uuid.UUID(created["token"]))
).scalars().one()
assert row.status == "pushed"
assert row.branch == DOCS_BRANCH
assert row.commit_sha == sha
got = admin_client.get(f"/api/doc-drafts/{created['token']}").json()
assert got["status"] == "pushed"
assert got["branch"] == DOCS_BRANCH
assert got["commit_sha"] == sha
# The success bumped updated_at (past the backdated value).
assert datetime.fromisoformat(got["updated_at"]) > datetime.fromisoformat(
created["updated_at"]
)
def test_push_unconfigured_returns_409_naming_variable(admin_client: TestClient, db) -> None:
"""Default settings (``docs_repo=""``) → 409 naming the variable;
the row stays a draft (D3: inert by default)."""
created = _create(admin_client)
fastapi_app.dependency_overrides[get_settings] = lambda: _settings() # docs_repo=""
try:
r = admin_client.post(f"/api/doc-drafts/{created['token']}/push")
finally:
fastapi_app.dependency_overrides.pop(get_settings, None)
assert r.status_code == 409
assert r.json() == {"detail": "docs repo not configured (BOR_DOCS_REPO)"}
body = admin_client.get(f"/api/doc-drafts/{created['token']}").json()
assert body["status"] == "draft"
assert body["branch"] is None
assert body["commit_sha"] is None
def test_push_non_repo_dir_returns_502_with_git_stderr(
admin_client: TestClient, db, tmp_path: Path
) -> None:
"""A configured repo that is not a git repo → 502 with git's stderr
in the detail (the ``GitSyncError`` → ``detail`` mapping);
the row stays a draft (only a success mutates)."""
plain = tmp_path / "not-a-repo"
plain.mkdir()
(plain / "file.txt").write_text("not a repo\n", encoding="utf-8")
fastapi_app.dependency_overrides[
get_settings
] = lambda: _settings(
docs_repo=str(plain),
docs_branch=DOCS_BRANCH,
docs_base_branch=BASE_BRANCH,
docs_work_dir=str(tmp_path / "docs-workdir"),
)
created = _create(admin_client)
try:
r = admin_client.post(f"/api/doc-drafts/{created['token']}/push")
finally:
fastapi_app.dependency_overrides.pop(get_settings, None)
assert r.status_code == 502
detail = r.json()["detail"]
# git's stderr is surfaced (the clone refusal of a non-repo dir).
assert "failed" in detail
assert "fatal: repository" in detail
body = admin_client.get(f"/api/doc-drafts/{created['token']}").json()
assert body["status"] == "draft"
assert body["branch"] is None
assert body["commit_sha"] is None
def test_push_unknown_token_returns_404(
admin_client: TestClient, docs_push_settings: Settings
) -> None:
r = admin_client.post(f"/api/doc-drafts/{uuid.uuid4()}/push")
assert r.status_code == 404
assert r.json() == {"detail": "draft not found"}
def test_push_rejects_bad_stored_path_with_422(
admin_client: TestClient, db, docs_push_settings: Settings
) -> None:
"""A row whose stored path no longer passes the guard-rails must not
be pushable (422 naming the rule — re-validated on push, task 02
helper); the row is untouched."""
bad = DocDraft(token=uuid.uuid4(), title=TITLE, path="../evil.md", body=BODY)
db.add(bad)
db.commit()
db.refresh(bad)
r = admin_client.post(f"/api/doc-drafts/{bad.token}/push")
assert r.status_code == 422
assert "'..'" in r.json()["detail"]
row = db.get(DocDraft, bad.id)
assert row is not None
assert row.status == "draft"
assert row.branch is None
assert row.commit_sha is None
def test_push_anonymous_returns_403(
admin_client: TestClient,
db,
docs_push_settings: Settings,
bare_docs_repo: Path,
) -> None:
created = _create(admin_client)
anon = TestClient(fastapi_app) # fresh jar: truly anonymous (no cookie)
r = anon.post(f"/api/doc-drafts/{created['token']}/push")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
# The anonymous push attempt changed nothing: no branch on the bare
# repo, the row is still a draft (guest reads 403 too).
assert _git(bare_docs_repo, "branch", "--list", DOCS_BRANCH).strip() == ""
assert anon.get(f"/api/doc-drafts/{created['token']}").status_code == 403
row = db.execute(
select(DocDraft).where(DocDraft.token == uuid.UUID(created["token"]))
).scalars().one()
assert row.status == "draft"
+322
View File
@@ -0,0 +1,322 @@
"""Integration: migration 0011 (doc_drafts) schema contract.
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0010.py`` (information_schema / pg_indexes assertions
on the state the migration must leave). The tests target revision
``0011`` explicitly so later migrations cannot break them:
* upgrade 0010 → 0011 → the ``doc_drafts`` table exists with the full
column contract (``id`` UUID PK; ``token`` UUID NOT NULL + the UNIQUE
index ``ix_doc_drafts_token`` — the URL credential; ``title`` /
``path`` / ``body`` TEXT NOT NULL; ``status`` TEXT NOT NULL default
'draft'; ``branch`` / ``commit_sha`` TEXT NULL; ``created_at`` /
``updated_at`` TIMESTAMPTZ NOT NULL default now());
* inserted rows round-trip: an omitted ``status`` defaults to 'draft'
with NULL ``branch`` / ``commit_sha`` (the pre-push state) and both
timestamps are stamped server-side; explicit push-state values
round-trip verbatim;
* two identical tokens are rejected by the unique index (the token is
a unique handle — the share-token precedent, phase 51);
* downgrade to 0010 → the table and index are gone (A13 — reversible),
the rest of the schema (e.g. ``saved_chats.share_token``) survives;
* upgrade back to 0011 → the table and the unique index are back
(round-trip).
The ``alembic`` fixture guarantees the DB ends at head even if a test
fails or the process is interrupted.
"""
from __future__ import annotations
import uuid
from collections.abc import Iterator
from typing import Any
import pytest
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from alembic import command
from app.db import db_available
@pytest.fixture()
def alembic(db: Session) -> Iterator[Config]:
"""Real Alembic config bound to the dev DB (URL from app settings).
Starts at head (repairs an interrupted earlier run); teardown upgrades
to head no matter what happened, so the dev DB is never left below
head.
"""
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
cfg.set_main_option("script_location", "alembic")
command.upgrade(cfg, "head")
try:
yield cfg
finally:
command.upgrade(cfg, "head")
def _version(db: Session) -> str | None:
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
def _table_exists(db: Session, table: str) -> bool:
count: Any = db.execute(
text(
"SELECT count(*) FROM information_schema.tables"
" WHERE table_schema = 'public' AND table_name = :t"
),
{"t": table},
).scalar()
assert count is not None, "information_schema count must be an int"
return int(count) == 1
def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None:
"""(data_type, is_nullable, column_default) for one table column."""
row = db.execute(
text(
"SELECT data_type, is_nullable, column_default"
" FROM information_schema.columns"
" WHERE table_name = :t AND column_name = :c"
),
{"t": table, "c": column},
).fetchone()
return tuple(row) if row is not None else None
def _unique_token_index(db: Session) -> int:
"""1 iff ``ix_doc_drafts_token`` exists as a UNIQUE index."""
count: Any = db.execute(
text(
"SELECT count(*) FROM pg_indexes"
" WHERE tablename = 'doc_drafts'"
" AND indexname = 'ix_doc_drafts_token'"
),
).scalar()
assert count is not None, "pg_indexes count must be an int"
is_unique: Any = db.execute(
text(
"SELECT indisunique FROM pg_index"
" WHERE indexrelid = (SELECT oid FROM pg_class WHERE relname = 'ix_doc_drafts_token')"
),
).scalar()
return int(count) if is_unique else 0
def _insert(
db: Session,
*,
token: uuid.UUID | None = None,
status: str | None = None,
branch: str | None = None,
commit_sha: str | None = None,
) -> uuid.UUID:
"""Insert one doc_drafts row. ``status=None`` omits the column
(server-default path); a ``token`` is always supplied — the
migration carries no server default (the ORM/API supplies it)."""
cols = ["id", "token", "title", "path", "body"]
params: dict[str, Any] = {
"t": "Mig 0011",
"p": "docs/mig-0011.md",
"b": "# Phase 59 migration probe\n",
}
if token is not None:
params["tok"] = token
if status is not None:
cols.append("status")
params["s"] = status
if branch is not None:
cols.append("branch")
params["br"] = branch
if commit_sha is not None:
cols.append("commit_sha")
params["sha"] = commit_sha
sql = (
f"INSERT INTO doc_drafts ({', '.join(cols)}) VALUES ("
"gen_random_uuid(), :tok, :t, :p, :b"
+ (", :s" if status is not None else "")
+ (", :br" if branch is not None else "")
+ (", :sha" if commit_sha is not None else "")
+ ") RETURNING id"
)
draft_id: uuid.UUID = db.execute(text(sql), params).scalar_one()
db.commit()
return draft_id
def _delete(db: Session, draft_id: uuid.UUID) -> None:
db.execute(text("DELETE FROM doc_drafts WHERE id = :i"), {"i": draft_id})
db.commit()
def test_upgrade_to_0011_adds_doc_drafts(db: Session, alembic: Config) -> None:
"""Upgrade 0010 → 0011: the table + the unique token index exist
with the full column contract; the table is absent at 0010."""
command.downgrade(alembic, "0010") # start from the pre-0011 state
assert _version(db) == "0010"
assert not _table_exists(db, "doc_drafts"), "doc_drafts must be absent at 0010"
assert _unique_token_index(db) == 0, "the token index must be absent at 0010"
command.upgrade(alembic, "0011")
assert _version(db) == "0011", "alembic_version must be at 0011"
assert _table_exists(db, "doc_drafts"), "doc_drafts must exist at 0011"
id_col = _column(db, "doc_drafts", "id")
assert id_col is not None, "doc_drafts.id is missing"
assert id_col[0] == "uuid", "doc_drafts.id must be UUID"
assert id_col[1] == "NO", "doc_drafts.id must be NOT NULL (PK)"
token = _column(db, "doc_drafts", "token")
assert token is not None, "doc_drafts.token is missing"
assert token[0] == "uuid", "doc_drafts.token must be UUID"
assert token[1] == "NO", "doc_drafts.token must be NOT NULL (no un-drafted state)"
assert _unique_token_index(db) == 1, "the unique token index is missing"
for name in ("title", "path", "body"):
col = _column(db, "doc_drafts", name)
assert col is not None, f"doc_drafts.{name} is missing"
assert col[0] == "text", f"doc_drafts.{name} must be TEXT"
assert col[1] == "NO", f"doc_drafts.{name} must be NOT NULL"
status = _column(db, "doc_drafts", "status")
assert status is not None, "doc_drafts.status is missing"
assert status[0] == "text", "doc_drafts.status must be TEXT"
assert status[1] == "NO", "doc_drafts.status must be NOT NULL"
assert str(status[2]).startswith("'draft'"), (
"doc_drafts.status must have server default 'draft'"
)
for name in ("branch", "commit_sha"):
col = _column(db, "doc_drafts", name)
assert col is not None, f"doc_drafts.{name} is missing"
assert col[0] == "text", f"doc_drafts.{name} must be TEXT"
assert col[1] == "YES", f"doc_drafts.{name} must be NULL until pushed"
for name in ("created_at", "updated_at"):
col = _column(db, "doc_drafts", name)
assert col is not None, f"doc_drafts.{name} is missing"
assert col[0] == "timestamp with time zone", (
f"doc_drafts.{name} must be TIMESTAMPTZ"
)
assert col[1] == "NO", f"doc_drafts.{name} must be NOT NULL"
assert str(col[2]).startswith("now("), (
f"doc_drafts.{name} must have server default now()"
)
def test_inserted_rows_round_trip_the_pre_push_and_pushed_states(
db: Session, alembic: Config
) -> None:
"""At 0011, an omitted status defaults to 'draft' with NULL
branch/commit_sha (the pre-push state) and both timestamps are
stamped server-side; explicit push-state values round-trip
verbatim."""
command.upgrade(alembic, "head")
draft_token = uuid.uuid4()
draft_id = _insert(db, token=draft_token)
pushed_token = uuid.uuid4()
pushed_id = _insert(
db,
token=pushed_token,
status="pushed",
branch="bor-docs",
commit_sha="a" * 40,
)
try:
row = db.execute(
text(
"SELECT token, status, branch, commit_sha, created_at, updated_at"
" FROM doc_drafts WHERE id = :i"
),
{"i": draft_id},
).fetchone()
assert row is not None, "the draft row must exist"
assert row[0] == draft_token, "the token must round-trip verbatim"
assert row[1] == "draft", "an omitted status must default to 'draft'"
assert row[2] is None and row[3] is None, (
"branch/commit_sha must be NULL before the push endpoint runs"
)
assert row[4] is not None and row[5] is not None, (
"created_at/updated_at must be stamped server-side"
)
pushed = db.execute(
text(
"SELECT status, branch, commit_sha FROM doc_drafts WHERE id = :i"
),
{"i": pushed_id},
).fetchone()
assert pushed is not None, "the pushed row must exist"
assert tuple(pushed) == ("pushed", "bor-docs", "a" * 40), (
"explicit push-state values must round-trip verbatim"
)
finally:
_delete(db, draft_id)
_delete(db, pushed_id)
def test_unique_index_rejects_duplicate_tokens(db: Session, alembic: Config) -> None:
"""Two identical tokens are rejected by the unique index — the
token is the unique URL credential (the share-token precedent,
phase 51); a distinct token still lands."""
command.upgrade(alembic, "head")
dup_token = uuid.uuid4()
first_id = _insert(db, token=dup_token)
other_id: uuid.UUID | None = None
try:
try:
_insert(db, token=dup_token)
except IntegrityError:
db.rollback() # the aborted transaction must not leak
else:
pytest.fail("a duplicate doc_drafts.token must be rejected")
# A different token is fine — only the exact duplicate is unique.
other_id = _insert(db, token=uuid.uuid4())
finally:
_delete(db, first_id)
if other_id is not None:
_delete(db, other_id)
def test_downgrade_to_0010_drops_the_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0010: the table and the unique index are gone
(A13 — reversible) while the rest of the schema survives."""
command.downgrade(alembic, "0010")
assert _version(db) == "0010"
assert not _table_exists(db, "doc_drafts"), "doc_drafts must be dropped"
assert _unique_token_index(db) == 0, "the token index must be dropped"
token_col = _column(db, "saved_chats", "share_token")
assert token_col is not None and token_col[0] == "uuid", (
"saved_chats.share_token must survive the downgrade"
)
meta = _column(db, "sources_meta", "version")
assert meta is not None and meta[0] == "integer", (
"sources_meta.version must survive the downgrade"
)
def test_upgrade_round_trip_restores_the_table(db: Session, alembic: Config) -> None:
"""Downgrade to 0010, then upgrade back to 0011: the table and the
unique index are back."""
command.downgrade(alembic, "0010")
command.upgrade(alembic, "0011")
assert _version(db) == "0011", "round-trip upgrade must land at 0011"
assert _table_exists(db, "doc_drafts"), "doc_drafts must be back"
assert _unique_token_index(db) == 1, "the unique token index must be back"
status = _column(db, "doc_drafts", "status")
assert status is not None and status[1] == "NO", (
"status must be TEXT NOT NULL after the round-trip"
)
assert str(status[2]).startswith("'draft'"), (
"status must default to 'draft' after the round-trip"
)
+1
View File
@@ -212,6 +212,7 @@ def test_html_pages_include_history() -> None:
"/git-sources.html", "/git-sources.html",
"/history.html", "/history.html",
"/shared.html", # phase 51: the shared page's static path "/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" assert path in caching.HTML_PAGES, f"{path} must be in HTML_PAGES"
+118
View File
@@ -279,3 +279,121 @@ def test_effective_api_key_fallback(monkeypatch) -> None:
monkeypatch.setenv("AIPI_KEY", "sk-from-env") monkeypatch.setenv("AIPI_KEY", "sk-from-env")
s2 = _settings() s2 = _settings()
assert s2.effective_api_key == "sk-from-env" 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
+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)"
+200
View File
@@ -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()
+1
View File
@@ -30,6 +30,7 @@ HTML_PAGES = (
"git-sources.html", "git-sources.html",
"history.html", # phase 50: the admin saved-chats page "history.html", # phase 50: the admin saved-chats page
"shared.html", # phase 51: the anonymous shared-conversation page "shared.html", # phase 51: the anonymous shared-conversation page
"doc-edit.html", # phase 59: the admin doc edit screen (flow page)
) )
+3 -3
View File
@@ -143,11 +143,11 @@ def test_missing_git_raises_named_error(
clone_or_pull("https://example.com/homelab.git", tmp_path / "homelab") 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 tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: ) -> 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") 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 assert len(calls) == 1
+33
View File
@@ -44,3 +44,36 @@ def test_documents_unique_source_path() -> None:
and {col.name for col in c.columns} == {"source", "path"} and {col.name for col in c.columns} == {"source", "path"}
] ]
assert uq, "documents must be unique on (source, path) — the upsert key" 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)"
+303
View File
@@ -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)
+17 -10
View File
@@ -16,10 +16,11 @@ without a browser:
``.shared-shell``, ``.doc-md``, ``.doc-summary:has(+ .doc-md)`` — ``.shared-shell``, ``.doc-md``, ``.doc-summary:has(+ .doc-md)`` —
each capped with ``max-width: var(--chat-column)`` and NOTHING else each capped with ``max-width: var(--chat-column)`` and NOTHING else
in the file uses the token (exactly four rules); in the file uses the token (exactly four rules);
* the negative pin — ``.tuning-shell`` (a form, not a reading * the negative pin — the form columns (``.tuning-shell``; and from
surface) keeps its hard-coded ``max-width: 46rem`` at every width, phase 59, task 06, ``.doc-edit-shell`` — forms, not reading
and it is the only literal ``max-width: 46rem`` rule left in the surfaces) are the only literal ``max-width: 46rem`` rules left in
file; 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 * the "46rem column contract" block comments were updated to name the
base value + the wide override (the stale "≤46rem" contract claims base value + the wide override (the stale "≤46rem" contract claims
are gone from the reading-column comments). 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: def test_no_other_hardcoded_46rem_rule_remains() -> None:
"""After the switch, the .tuning-shell rule is the ONLY rule with """After the switch, the form columns are the ONLY rules with a
a literal max-width: 46rem — every reading column rides the literal max-width: 46rem: .tuning-shell (phase 27) and
token (the --chat-column base declaration is the other .doc-edit-shell (phase 59, task 06 — the doc edit screen is a
non-rule occurrence of 46rem).""" 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() css = _css()
assert css.count("max-width: 46rem") == 1, ( assert css.count("max-width: 46rem") == 2, (
"only .tuning-shell may keep a literal max-width: 46rem" "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, ".tuning-shell")
assert "max-width: 46rem" in _rule_block(css, ".doc-edit-shell")
def test_comments_cite_the_wide_override_with_provenance() -> None: def test_comments_cite_the_wide_override_with_provenance() -> None: