Compare commits

..
5 Commits
49 changed files with 7596 additions and 191 deletions
+17 -2
View File
@@ -40,8 +40,13 @@ BOR_RRF_K=60 # Reciprocal Rank Fusion damping constant
# --- Agent document tools (phase 37: grounded turns may list + read) ---
# BOR_AGENT_MAX_ROUNDS=10 # hard cap on agent tool rounds per turn (0 = no tools)
# --- Import scope (A9 formats; may only narrow, never widen) ---
# BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,container,network,volume,image,pod,kube,swap,os,endpoint,j2
# --- Import scope (A9 default; ANY well-formed extension is allowed) ---
# Comma-separated file extensions (lowercase, no dot) the importer reads.
# Any extension is allowed — the value below is the built-in default (the
# A9 family: the original seven + the quadlet family + jinja ``j2``); add
# your own (e.g. md,sh,toml) or narrow it (e.g. md). A blank list or a
# malformed token (e.g. md,sh!) fails startup loudly, naming the value.
BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,container,network,volume,image,pod,kube,swap,os,endpoint,j2
# BOR_SUGGESTIONS=["How is my Kubernetes cluster set up?"] # JSON list of onboarding chips
# --- Import sources (git; phase 28, admin-managed since phase 35) ---
@@ -77,6 +82,16 @@ BOR_RRF_K=60 # Reciprocal Rank Fusion damping constant
# BOR_UPLOAD_MAX_MB=512 # caps BOTH the compressed upload and the total
# 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) ---
# The app refuses to start while either is empty (names the missing
# 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/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/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/markdown.js --minify --outfile=/out/assets/markdown.js \
&& 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 ----------
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 fastapi import APIRouter, Depends
@@ -9,6 +10,14 @@ router = APIRouter(tags=["config"])
@router.get("/config")
def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str]: # noqa: B008
"""Public app metadata for the frontend brand layer (phase 39)."""
return {"app_name": settings.app_name, "version": settings.app_version}
def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str | bool]: # noqa: B008
"""Public app metadata for the frontend brand layer (phase 39) +
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)
+76 -1
View File
@@ -4,6 +4,11 @@ GET /api/documents/content — one indexed document's full content (feeds the
clickable document viewer, phase 10). DB-only by design: the (source, path)
pair is looked up as a row, so there is no filesystem access and no
path-traversal surface — ``../``-style values simply aren't rows (→ 404).
PATCH /api/documents/summary — the admin summary editor (phase 57):
update or clear ``documents.summary`` and re-embed the ``is_summary``
chunk (embed first, mutate second — a failed LLM call leaves the row and
chunk untouched; the content chunks are never re-embedded, D4).
"""
from __future__ import annotations
@@ -13,10 +18,12 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.api.sync import _sanitize_error
from app.core.auth import require_admin
from app.db import get_db
from app.models import Chunk, Document
from app.schemas import DocContent, DocList, DocSummary
from app.rag.llm import EmbeddingError, LLMClient
from app.schemas import DocContent, DocList, DocSummary, SummaryResult, SummaryUpdate
router = APIRouter(tags=["kb"])
@@ -103,3 +110,71 @@ def get_document_content(
indexed_at=doc.indexed_at.isoformat(),
chunks=chunks,
)
@router.patch("/documents/summary", response_model=SummaryResult)
async def update_document_summary(
payload: SummaryUpdate,
db: Session = Depends(get_db), # noqa: B008
_admin: None = Depends(require_admin), # noqa: B008
) -> SummaryResult:
"""Update or clear a document's stored summary and re-embed it.
Admin-only (phase 57, D4) — the document viewer itself stays
PUBLIC (phase 16 owner decision); only this edit affordance is
gated. The re-embed scope is the ``is_summary`` chunk only (D4):
the summary is the only text that changed, so the document's
content chunks keep their existing embeddings — the total chunk
count is unchanged by an update.
Fail-before-write (phase 57 locked decision): when the stripped
text is non-empty it is embedded **before** any DB mutation — an
embedding failure returns 503 with a sanitized ``detail`` naming
the failure (the ``ModelUnavailableError`` handling of
``app/api/git_sources.py``) and leaves the row and chunk untouched.
An empty/whitespace-only ``summary`` clears instead:
``documents.summary = NULL`` and the ``is_summary`` chunk (if any)
is deleted.
"""
doc = db.scalar(
select(Document).where(
Document.source == payload.source, Document.path == payload.path
)
)
if doc is None:
raise HTTPException(status_code=404, detail="document not found")
summary_chunk = db.scalar(
select(Chunk).where(Chunk.document_id == doc.id, Chunk.is_summary.is_(True))
)
text = payload.summary.strip()
if text:
# Embed first, mutate second — a failed LLM call must never
# leave a half-updated row (phase 57 locked decision).
llm = LLMClient()
try:
vector = (await llm.embed([text]))[0]
except EmbeddingError as e:
raise HTTPException(status_code=503, detail=_sanitize_error(str(e))) from None
if summary_chunk is None:
# Markdown doc, or a phase-30 fail-soft import that indexed
# without a summary chunk — create the position −1 chunk.
summary_chunk = Chunk(document_id=doc.id, position=-1, is_summary=True)
db.add(summary_chunk)
summary_chunk.content = text
summary_chunk.embedding = vector
doc.summary = text
else:
if summary_chunk is not None:
db.delete(summary_chunk)
doc.summary = None
db.commit()
chunks = db.scalar(
select(func.count(Chunk.id))
.select_from(Document)
.outerjoin(Chunk, Chunk.document_id == Document.id)
.where(Document.id == doc.id)
) or 0
return SummaryResult(
source=doc.source, path=doc.path, summary=doc.summary, chunks=chunks
)
+86 -17
View File
@@ -6,18 +6,22 @@ Every setting can be overridden with an environment variable prefixed
from __future__ import annotations
import os
import re
from functools import lru_cache
from pydantic import field_validator
from pydantic import ValidationInfo, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
#: The A9 import formats (PLAN anchor A9, revised 2026-08-21; revised
#: 2026-08-27, owner permission — the full Podman quadlet family
#: The built-in DEFAULT import formats (PLAN anchor A9, revised 2026-08-21;
#: revised 2026-08-27, owner permission — the full Podman quadlet family
#: ``container, network, volume, image, pod, kube, swap, os, endpoint``
#: plus Jinja templates ``j2`` join the allowed set, chunked as plain
#: text). ``BOR_IMPORT_EXTENSIONS`` may narrow — but never widen — this
#: set.
_ALLOWED_IMPORT_EXTENSIONS: frozenset[str] = frozenset(
#: plus Jinja templates ``j2`` join the default, chunked as plain text).
#: This is the default scope AND the ``.env.example`` example — it is NOT
#: a ceiling: ``BOR_IMPORT_EXTENSIONS`` may name **any** well-formed
#: extension (lowercase letters/digits, no dot) or narrow to a subset
#: (owner permission 2026-08-31, phase 56); see
#: :py:attr:`Settings.import_extensions`.
_DEFAULT_IMPORT_EXTENSIONS: frozenset[str] = frozenset(
{
"md", "markdown", "txt", "yaml", "yml", "json", "py",
# A9 revised 2026-08-27 (owner permission): quadlet family + jinja.
@@ -136,13 +140,18 @@ class Settings(BaseSettings):
session_max_age: int = 43_200
session_cookie: str = "bor_session"
# --- Import scope (A9, revised 2026-08-21 and 2026-08-27) ---
# --- Import scope (A9 default; any extension allowed — phase 56) ---
# Comma-separated list of lowercased file extensions (no dot) imported
# by ``scripts/import_docs.py``. Hidden (dot) path components are always
# by ``scripts/import_docs.py``. **Any** well-formed extension is
# allowed (lowercase letters/digits, 1-16 chars — the shape guard
# doubles as the typo guard); the value below is the built-in default
# (the A9 family, incl. the quadlet family + ``j2``) and the documented
# example in ``.env.example``. Hidden (dot) path components are always
# skipped, plus the importer's exclusion list.
# Stored as a raw CSV string (env-native — no JSON) and parsed on demand
# via :py:meth:`import_extension_set`. ``mode="after"`` validation runs
# against the raw string so a typo fails loudly at startup.
# via :py:meth:`import_extension_set`. The validator rejects an empty
# list and malformed tokens so a typo fails loudly at startup (it can
# no longer reject a novel extension).
import_extensions: str = (
"md,markdown,txt,yaml,yml,json,py,"
"container,network,volume,image,pod,kube,swap,os,endpoint,j2"
@@ -169,19 +178,48 @@ class Settings(BaseSettings):
#: pattern).
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")
@classmethod
def _import_extensions_known(cls, v: str) -> str:
"""Reject unknown/empty formats loudly instead of silently importing
nothing (a typo like ``md,jsonn`` would otherwise walk zero files)."""
"""Reject an empty list or malformed tokens loudly instead of
silently importing nothing (a typo like ``md,jsonn`` would
otherwise walk zero files). Any well-formed extension is accepted —
the A9 family is the default, not a ceiling (owner permission
2026-08-31, phase 56)."""
exts = {part.strip().lstrip(".").lower() for part in v.split(",") if part.strip()}
if not exts:
raise ValueError("import_extensions must name at least one format")
unknown = exts - _ALLOWED_IMPORT_EXTENSIONS
if unknown:
malformed = sorted(
ext for ext in exts if re.fullmatch(r"[a-z0-9]{1,16}", ext) is None
)
if malformed:
raise ValueError(
f"unknown import extension(s): {', '.join(sorted(unknown))} — "
f"allowed: {', '.join(sorted(_ALLOWED_IMPORT_EXTENSIONS))}"
f"import_extensions contains malformed token(s): {', '.join(malformed)} — "
"each extension must be lowercase letters/digits only, 1-16 chars, no dot"
)
return v
@@ -201,6 +239,28 @@ class Settings(BaseSettings):
raise ValueError("upload_max_mb must be > 0 (MiB)")
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).
suggestions: list[str] = [
"How is my Kubernetes cluster set up?",
@@ -228,6 +288,15 @@ class Settings(BaseSettings):
"""
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
def effective_api_key(self) -> str:
"""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=
# contract, so the direct URL can never pin stale assets).
"/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
+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,
)
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.git_sources import router as git_sources_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(sync_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.
# /api/shared/<token> is the JSON snapshot; /shared/<token> (the
# 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,
bumped exactly once per KB-changing sync so saved
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
@@ -205,6 +215,62 @@ class GitSource(Base):
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):
"""One owner-saved chat conversation (phase 50).
+3 -2
View File
@@ -1,7 +1,8 @@
"""Knowledge-base importer (PLAN §5 / §9 / §11).
Walks the A9-format files (``md, markdown, txt, yaml, yml, json, py`` by
default — ``BOR_IMPORT_EXTENSIONS``; case-insensitive), diffs by sha256
Walks the in-scope files (the A9 family by default — the original seven
plus the quadlet family and ``j2`` — ``BOR_IMPORT_EXTENSIONS``, which may
name any well-formed extension; case-insensitive), diffs by sha256
against ``documents.content_hash`` and, for every new or changed file, runs
the two-phase upsert:
+103
View File
@@ -141,6 +141,40 @@ class DocContent(BaseModel):
chunks: int
class SummaryUpdate(BaseModel):
"""``PATCH /api/documents/summary`` body (phase 57, task 01).
``source`` / ``path`` name the indexed document (the same pair the
public ``GET /api/documents/content`` looks up); ``summary`` is the
raw new text. The API strips it before storing — an
empty/whitespace-only value is the *clear* operation (a first-class
action, phase 57 D4), not a 422. Unconstrained on purpose: unknown
pairs must 404 as "document not found" (row-lookup semantics),
exactly like the public content endpoint.
"""
source: str
path: str
summary: str
class SummaryResult(BaseModel):
"""``PATCH /api/documents/summary`` response (phase 57, task 01).
``summary`` is the stored text after the change (``null`` after a
clear — the viewer's summary box hides on null) and ``chunks`` the
document's post-change total chunk count: an update leaves the
content chunks untouched (the count is unchanged — only the single
``is_summary`` chunk is replaced), a clear drops one (the
``is_summary`` chunk is deleted).
"""
source: str
path: str
summary: str | None
chunks: int
class SteeringNoteIn(BaseModel):
"""``POST /api/steering`` body: one tuning instruction (phase 15).
@@ -489,3 +523,72 @@ class UnshareOut(BaseModel):
chat_id: uuid.UUID
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;
if (!prev || prev.who !== "user") return;
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)
@@ -1091,6 +1211,10 @@ function renderStoredMessage(m) {
}
appendSources(wrap, m.sources);
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
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");
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() {
if (signInLink) signInLink.hidden = isAdmin;
if (signOutBtn) signOutBtn.hidden = !isAdmin;
@@ -1812,11 +1946,15 @@ async function runTurn(text, { reask = false } = {}) {
appendMaybeTry(wrap, ev.suggestions);
}
appendSources(wrap, ev.sources);
appendTuneButton(wrap); // every completed brain bubble is tunable
// Thinking-without-answer (reasoning can exhaust max_tokens): the
// bubble gets the empty-answer fallback — what the user saw is
// what gets persisted.
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) {
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
}
@@ -1851,6 +1989,7 @@ async function runTurn(text, { reask = false } = {}) {
const fallback = EMPTY_ANSWER_FALLBACK;
const fwrap = addMessage("brain", fallback);
appendTuneButton(fwrap);
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
rememberBrainTurn(fallback, {}); // persist what the user actually saw
lastBrainWrap = fwrap;
markLastRetryable(); // phase 49: the fallback bubble is retryable too
@@ -1965,6 +2104,14 @@ window.addEventListener("pagehide", () => {
(async () => {
await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate
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)
// Phase 55 (task 03): no Share-reveal step — the pill is static,
// always-visible markup (visible to every visitor, phase 51 contract).
+105 -73
View File
@@ -9,7 +9,15 @@
* Contract (phase 39 locked decisions — A11 no CDN, runtime fetch):
* • window.BOR_BRAND = "Brain of Reese" synchronously — the default
* 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
* applied to the DOM:
* 1. document.title — global replace of the literal;
@@ -36,6 +44,12 @@
reading window.BOR_BRAND at evaluation time always find a value. */
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
scripts' own `window.BOR_BRAND || "Brain of Reese"` fallbacks stay in
sync with it. */
@@ -50,83 +64,101 @@ function escapeHTML(s) {
}[c]));
}
function applyBrand() {
fetch("/api/config", { cache: "no-store" })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((cfg) => {
const name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : "";
if (!name) return; // empty / missing: the default stands
window.BOR_BRAND = name;
// 1. The document title (global replace of the literal — covers
// every page's static "<…> · Brain of Reese" titles).
document.title = document.title.replaceAll(BRAND_LITERAL, name);
// 2. The header brand on every page: a name starting "Brain of "
// keeps the bold split (the current look), anything else
// renders plain — the name is always escaped.
for (const el of document.querySelectorAll(".brand-text")) {
if (name.startsWith("Brain of ")) {
const rest = name.slice("Brain of ".length);
el.innerHTML = `Brain of <strong>${escapeHTML(rest)}</strong>`;
} else {
el.textContent = name;
}
}
// 3. Prose: a TreeWalker over the body's text nodes replaces the
// literal (the empty-state h1, any other copy). Text nodes
// inside <script>/<style> are rejected — the page source must
// never be rewritten.
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
const tag = node.parentElement ? node.parentElement.tagName : "";
return tag === "SCRIPT" || tag === "STYLE"
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT;
},
},
);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
for (const node of nodes) {
if (node.nodeValue && node.nodeValue.includes(BRAND_LITERAL)) {
node.nodeValue = node.nodeValue.replaceAll(BRAND_LITERAL, name);
}
}
// 4. Attributes: the #messages aria-label, the composer input
// label, the meta descriptions — aria-label / placeholder /
// meta content only, each replaced in place.
for (const el of document.querySelectorAll(
"[aria-label], [placeholder], meta[content]",
)) {
for (const attr of ["aria-label", "placeholder"]) {
const v = el.getAttribute(attr);
if (v && v.includes(BRAND_LITERAL)) {
el.setAttribute(attr, v.replaceAll(BRAND_LITERAL, name));
}
}
if (el.tagName === "META") {
const v = el.getAttribute("content");
if (v && v.includes(BRAND_LITERAL)) {
el.setAttribute("content", v.replaceAll(BRAND_LITERAL, name));
}
}
}
})
.catch((err) => {
/* The /api/config fetch — started at TOP LEVEL (parse time) so
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(
(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() : "";
if (!name) return; // empty / missing: the default stands
window.BOR_BRAND = name;
// 1. The document title (global replace of the literal — covers
// every page's static "<…> · Brain of Reese" titles).
document.title = document.title.replaceAll(BRAND_LITERAL, name);
// 2. The header brand on every page: a name starting "Brain of "
// keeps the bold split (the current look), anything else
// renders plain — the name is always escaped.
for (const el of document.querySelectorAll(".brand-text")) {
if (name.startsWith("Brain of ")) {
const rest = name.slice("Brain of ".length);
el.innerHTML = `Brain of <strong>${escapeHTML(rest)}</strong>`;
} else {
el.textContent = name;
}
}
// 3. Prose: a TreeWalker over the body's text nodes replaces the
// literal (the empty-state h1, any other copy). Text nodes
// inside <script>/<style> are rejected — the page source must
// never be rewritten.
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
const tag = node.parentElement ? node.parentElement.tagName : "";
return tag === "SCRIPT" || tag === "STYLE"
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT;
},
},
);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
for (const node of nodes) {
if (node.nodeValue && node.nodeValue.includes(BRAND_LITERAL)) {
node.nodeValue = node.nodeValue.replaceAll(BRAND_LITERAL, name);
}
}
// 4. Attributes: the #messages aria-label, the composer input
// label, the meta descriptions — aria-label / placeholder /
// meta content only, each replaced in place.
for (const el of document.querySelectorAll(
"[aria-label], [placeholder], meta[content]",
)) {
for (const attr of ["aria-label", "placeholder"]) {
const v = el.getAttribute(attr);
if (v && v.includes(BRAND_LITERAL)) {
el.setAttribute(attr, v.replaceAll(BRAND_LITERAL, name));
}
}
if (el.tagName === "META") {
const v = el.getAttribute("content");
if (v && v.includes(BRAND_LITERAL)) {
el.setAttribute("content", v.replaceAll(BRAND_LITERAL, name));
}
}
}
});
}
/* The top level only sets the global (synchronously, at parse time);
the DOM passes run once the document is ready. */
/* The DOM passes run once the document is ready AND the config is
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") {
document.addEventListener("DOMContentLoaded", applyBrand);
} 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
})();
+157
View File
@@ -51,6 +51,15 @@
* doc.summary renders as a labeled .doc-summary section above the
* original content on BOTH surfaces (page + modal) through this one
* core; the summary text is a text node (XSS contract unchanged).
*
* Phase 57 (task 02, D4): the panel gains an ADMIN-ONLY edit
* affordance (docAdminReady() gate on the module-cached whoami): an
* Edit button in the panel's header row opens an inline editor
* (prefilled textarea + Save/Cancel + a role=status live region), and
* Save PATCHes /api/documents/summary. The section is built for
* everyone exactly as phase 36 — the public viewer stays
* byte-for-byte identical (no button, no wiring, no admin-only
* network call) until the admin gate resolves true.
*/
import { fetchIsAdmin, initSharedHeader } from "./header.js";
@@ -125,6 +134,14 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
body.textContent = doc.summary; // text node — XSS contract unchanged
section.append(title, body);
contentEl.appendChild(section);
// Phase 57 (task 02, D4): the section above is the phase-36 shape
// for EVERYONE — only an authenticated admin (docAdminReady(), the
// module-cached whoami promise) then gains the header row + Edit
// button + editor wiring. Anonymous / fetch failure: the panel is
// exactly what phase 36 built (byte-for-byte unchanged).
void docAdminReady().then((admin) => {
if (admin) wireSummaryEdit(section, doc);
});
}
if (doc.format === "md" || doc.format === "markdown") {
const wrap = document.createElement("div");
@@ -139,6 +156,146 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
}
}
/* ---------- summary editing (phase 57, task 02 — D4, admin-only) ----------
* The .doc-summary panel is the ONE place the stored summary is edited
* (page + modal through this core). Only an admin (docAdminReady) ever
* gets the affordance; the public viewer is byte-for-byte unchanged. */
/* The admin gate (D4 — the viewer stays public): the module-cached
* /api/whoami promise (header.js's fetchIsAdmin — the SAME single
* request per page the shared header already makes on every surface,
* so this adds no request of its own). A non-admin or any fetch
* failure resolves false → the anonymous viewer. */
async function docAdminReady() {
try {
return (await fetchIsAdmin()) === true;
} catch {
return false;
}
}
/* The edit affordance on one rendered .doc-summary section. The bare
* h2 becomes a header row (label left, Edit button right). Edit swaps
* the .doc-summary-text node for the inline editor — a prefilled
* textarea (value, never innerHTML — XSS contract), Save / Cancel,
* and a role=status live region. Save PATCHes /api/documents/summary
* with { source, path, summary } — the pair comes from the doc object
* (the same values the modal core carries, document-modal.js). Success
* re-renders the text node via textContent and announces "Summary
* updated."; an empty save that clears announces "Summary cleared."
* and removes the panel a short beat later — the confirmation stays
* readable, and the renderer only draws the panel for non-empty
* summaries. Cancel restores the text node. A failure keeps the
* editor open with the user's text and shows neutral retry copy
* (phase-55 convention). */
function wireSummaryEdit(section, doc) {
const title = section.querySelector(".doc-summary-title");
const body = section.querySelector(".doc-summary-text");
if (!title || !body) return;
/* Header row: label left, Edit button right (admin-only — the
* anonymous section keeps its bare h2). */
const head = document.createElement("div");
head.className = "doc-summary-head";
const editBtn = document.createElement("button");
editBtn.type = "button";
editBtn.className = "doc-summary-edit";
editBtn.textContent = "Edit";
head.append(title, editBtn);
section.replaceChildren(head, body);
/* Editor parts (built once; the textarea is rebuilt on every open so
* it always starts from the CURRENT stored summary). */
let editor = null;
const actions = document.createElement("div");
actions.className = "doc-summary-actions";
const saveBtn = document.createElement("button");
saveBtn.type = "button";
saveBtn.className = "doc-summary-save";
saveBtn.textContent = "Save";
const cancelBtn = document.createElement("button");
cancelBtn.type = "button";
cancelBtn.className = "doc-summary-cancel";
cancelBtn.textContent = "Cancel";
actions.append(saveBtn, cancelBtn);
const status = document.createElement("p");
status.className = "doc-summary-status";
status.setAttribute("role", "status");
status.setAttribute("aria-live", "polite");
/* Back to the display state: the text node re-rendered from the
* doc object (the CURRENT stored summary), the live region (the
* announced message), the Edit button available again. If the
* summary is gone (a clear landed while the editor was open — e.g.
* Cancel right after a successful empty save) the panel is gone
* too: the renderer only draws it for non-empty summaries. */
function closeEditor(message) {
status.textContent = message;
editBtn.hidden = false;
if (typeof doc.summary !== "string" || doc.summary.trim() === "") {
section.remove();
return;
}
body.textContent = doc.summary; // text node — the CURRENT stored summary
section.replaceChildren(head, body, status);
editBtn.focus();
}
async function saveSummary() {
const value = editor.value;
saveBtn.disabled = true; // one PATCH at a time (never stale)
status.textContent = "";
try {
const res = await fetch("/api/documents/summary", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: doc.source, path: doc.path, summary: value }),
});
if (!res.ok) {
// Neutral retry copy (phase-55 convention) — the user's text
// stays in the editor (the editor stays open on failure).
status.textContent = "Couldn't update the summary — try again.";
return;
}
const data = await res.json();
if (data.summary === null) {
// Cleared (D4): the panel disappears — the renderer only draws
// it for non-empty summaries. The live-region confirmation
// stays visible for a short beat before the panel leaves the
// DOM (a screen reader must be able to read it; the removal
// is a no-op if the surface re-rendered or closed meanwhile).
doc.summary = null;
status.textContent = "Summary cleared.";
setTimeout(() => section.remove(), 2000);
return;
}
doc.summary = data.summary;
closeEditor("Summary updated.");
} catch {
// Network failure: same neutral shape, the reachable? copy.
status.textContent = "Couldn't update the summary — is the app reachable?";
} finally {
saveBtn.disabled = false;
}
}
function openEditor() {
editor = document.createElement("textarea");
editor.className = "doc-summary-editor";
editor.value = typeof doc.summary === "string" ? doc.summary : ""; // value, never innerHTML
status.textContent = "";
editBtn.hidden = true;
section.replaceChildren(head, editor, actions, status);
editor.focus();
}
editBtn.addEventListener("click", openEditor);
saveBtn.addEventListener("click", () => {
void saveSummary();
});
cancelBtn.addEventListener("click", () => closeEditor(""));
}
/* ---------- /document.html page (phases 10/13/19) ----------
* Phase 26: viewer-page-specific — see the import-safety note in the
* header. The guard is #doc-title: it exists only on this page, so the
+349 -24
View File
@@ -33,11 +33,21 @@
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--header-h: 64px;
/* Reading column: 46rem base (PLAN §7 lineage); 2x on wide desktops
(owner instruction 2026-08-31, TODO L5 / D2 — chat + shared +
document view). */
--chat-column: 46rem;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
/* Phase 60 (owner confirmation 2026-08-31, TODO L3): body must NOT carry a
fixed height — a sticky element's travel range is capped by its
containing block, and `body { height: 100% }` pinned the box to one
viewport, so the navbar un-pinned after ~1 viewport of scroll.
`min-height: 100dvh` on body (below) is what stretches short pages. */
html { height: 100%; }
/* The visible page background lives on <html> (the canvas). <body> must
stay transparent and must NOT create a stacking context, or the
@@ -197,9 +207,11 @@ html::after {
position: sticky;
top: 0;
z-index: 20;
/* Phase 12: body is a definite-height flex column; without this the
header shrinks (flex-shrink:1) to its content minimum on any page
whose content overflows the viewport (e.g. Sources at ≤640px). */
/* Phase 12 (reworded phase 60): body is a flex column that stretches
to at least one viewport (min-height: 100dvh) and grows with its
content; without this the header shrinks (flex-shrink:1) to its
content minimum on any page whose content overflows the viewport
(e.g. Sources at ≤640px). */
flex-shrink: 0;
}
/* 2px brand→cyan gradient hairline under the sticky header (phase 08;
@@ -394,9 +406,11 @@ html::after {
/* Chat is a vertical conversation: a centered, capped column is the
correct layout here (PLAN §UI/UX). The surrounding frame keeps it
from collapsing into a hairline on wide screens. */
from collapsing into a hairline on wide screens. The cap is the
--chat-column token: 46rem base, 2x (92rem) at >=1500px wide
desktops (owner instruction 2026-08-31, TODO.md L5 / D2). */
.chat-shell {
max-width: 46rem;
max-width: var(--chat-column);
margin-inline: auto;
display: flex;
flex-direction: column;
@@ -413,8 +427,9 @@ html::after {
on desktop. The ≤640px block flips this to a vertical stack
(flex-direction: column + align-items: stretch — full-width pills,
New chat above Share; the existing ≤640px pill rules apply to the
stacked pills unchanged). The 46rem column contract is untouched
(PLAN §7). */
stacked pills unchanged). The reading-column contract is untouched
(--chat-column: 46rem base, 92rem at >=1500px — PLAN §7 lineage).
*/
.chat-actions {
display: flex;
flex-direction: row;
@@ -493,7 +508,8 @@ html::after {
/* GFM pipe tables (phase 44, 2026-08-27, TODO.md L6): the shared
renderer wraps every table in .md-table-wrap — the horizontal
scroller, so a wide table scrolls inside the bubble instead of
breaking the 46rem column — around a semantic <table class="md-table">
breaking the reading column (--chat-column: 46rem base, 92rem at
>=1500px) — around a semantic <table class="md-table">
(escape-first cells; alignment colons render left, owner decision).
Phase-08 tokens only: --line hairline borders and the thead tinted
from the plain surface family — --ink on --surface is 14.5:1 (PLAN
@@ -590,9 +606,9 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
own icon, own accent left border. Contrast: --accent-ink on the row's
--surface ≈10.4:1 (11.6:1 on the page bg), and --ink on --brand-soft
in the path `code` ≈11.5:1 — all comfortably AA in the (single dark)
theme. Inline rows only: appending lines never shifts the 46rem chat
column (no new container), and the rows are not interactive — no
focus targets. */
theme. Inline rows only: appending lines never shifts the chat
column (46rem base; 92rem at >=1500px — no new container), and the
rows are not interactive — no focus targets. */
.tool-calls {
display: flex;
flex-direction: column;
@@ -725,6 +741,40 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.retry-btn svg { width: 14px; height: 14px; display: block; }
.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:
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
@@ -1144,7 +1194,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
/* ---------- Composer ---------- */
/* Phase 52 (2026-08-30, TODO.md L3): the composer is PINNED to the
viewport bottom. The page scrolls at the document level and
`.chat-shell` (the centered 46rem column) is the composer's sticky
`.chat-shell` (the centered reading column — 46rem base, 92rem at
>=1500px) is the composer's sticky
containing block, so the box sticks to the bottom edge of the
viewport at every scroll position and settles back into its normal
flow position (above the footer) once the document bottom is
@@ -2138,7 +2189,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
/* ---------- Shared page (phase 51, task 03) ----------
/shared/<token>: the anonymous read-only conversation (owner-locked
2026-08-29, TODO.md L6). The shell maps to the PLAN §7 centered
46rem chat column — the conversation reads exactly like the chat
chat column (--chat-column: 46rem base, 92rem at >=1500px) — the
conversation reads exactly like the chat
page (the .msg/.bubble/.thinking/.tool-calls/.msg-meta rules apply
unchanged) with NO composer, so the column contract holds for a
guest. Zero interactive controls (owner-locked): the chips are
@@ -2150,7 +2202,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
fonts. */
.shared-shell {
width: 100%;
max-width: 46rem; /* the PLAN §7 centered chat column */
max-width: var(--chat-column); /* the PLAN §7 centered chat column
(46rem base; 92rem at >=1500px — phase 58) */
margin-inline: auto;
display: flex;
flex-direction: column;
@@ -2313,10 +2366,12 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
#doc-content { display: flex; flex-direction: column; }
.doc-loading { margin: 1.5rem auto; text-align: center; color: var(--ink-soft); }
/* Markdown: the centered, ≤46rem reading column (PLAN §7.1). */
/* Markdown: the centered reading column — 46rem base, 2x (92rem) at
>=1500px (PLAN §7.1 lineage; owner instruction 2026-08-31, TODO.md
L5 / D2). */
.doc-md {
width: 100%;
max-width: 46rem;
max-width: var(--chat-column);
margin-inline: auto;
background: var(--surface);
border: 1px solid var(--line);
@@ -2361,12 +2416,13 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
margin-bottom: 1rem;
overflow-wrap: anywhere;
}
/* md/markdown: the panel matches the .doc-md ≤46rem centered reading
column — it is the column's label. Raw formats stay full width (the
/* md/markdown: the panel matches the .doc-md centered reading column
(46rem base; 92rem at >=1500px — the same --chat-column token) —
it is the column's label. Raw formats stay full width (the
.doc-raw default above), matching the full-width pre; in engines
without :has() the panel degrades to that full-width default. */
.doc-summary:has(+ .doc-md) {
max-width: 46rem;
max-width: var(--chat-column);
margin-inline: auto;
}
.doc-summary-title {
@@ -2382,6 +2438,97 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
color: var(--ink); /* on --surface ≈14.5:1 */
}
/* Summary edit affordance (phase 57, task 02 — D4, admin-only): the
header row (label + Edit button), the inline editor (prefilled
textarea + Save/Cancel + a role=status live region). House
dark-tech palette (phase-08 tokens), system fonts, no CDN;
:focus-visible via the global 3px outline rule. Anonymous visitors
never see any of it — the button and editor are wired only for
admins (docAdminReady in document.js), so the public panel is
byte-for-byte the phase-36 shape. */
.doc-summary-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.4rem; /* the old .doc-summary-title bottom margin */
}
.doc-summary-head .doc-summary-title { margin: 0; }
.doc-summary-edit {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0.15rem 0.7rem;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
font: inherit;
font-weight: 600;
font-size: 0.78rem;
letter-spacing: 0.02em;
cursor: pointer;
}
.doc-summary-edit:hover { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); }
.doc-summary-edit[hidden] { display: none; } /* the hidden attr must beat the display above */
.doc-summary-editor {
display: block;
width: 100%;
min-height: 8rem;
padding: 0.6rem 0.8rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--bg); /* inset against the --surface panel */
color: var(--ink); /* 16.7:1 on --bg (AA) */
font: inherit;
line-height: 1.5;
resize: vertical;
}
.doc-summary-actions {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
}
.doc-summary-save {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0.35rem 0.95rem;
border: 0;
border-radius: 999px;
background: var(--brand);
color: var(--bg); /* --bg on --brand = 5.2:1 (AA) */
font: inherit;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
}
.doc-summary-save:hover { background: #f55a72; } /* the house hover lightening */
.doc-summary-save:disabled { opacity: 0.6; cursor: default; } /* one PATCH at a time */
.doc-summary-cancel {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0.35rem 0.95rem;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
font: inherit;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
}
.doc-summary-cancel:hover { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
.doc-summary-status {
margin: 0.6rem 0 0;
font-size: 0.85rem;
color: var(--ink-soft); /* 5.1:1 on --surface (AA) */
}
.doc-summary-status:empty { margin-top: 0; }
/* Raw (non-markdown) formats: full-width mono pre, horizontal scroll. */
.doc-raw {
width: 100%;
@@ -2437,7 +2584,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
/* ---------- Document modal (phase 26) ----------
"New documents should open in an almost-fullscreen modal, not in a new
page" (TODO.md L4). The overlay reuses the viewer page's .doc-meta
badge classes, the .doc-md ≤46rem reading column, and the .doc-raw
badge classes, the .doc-md reading column (46rem base; 92rem at
>=1500px), and the .doc-raw
pre — this block only adds the chrome (backdrop, panel, header,
actions, scroll container). Phase-08 tokens only; NO blur (the
phase-08 no-blur perf anchor); no new assets; system fonts.
@@ -2576,7 +2724,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
}
/* The scroll container: vertical scroll lives HERE, never the viewport.
.doc-md keeps its ≤46rem centered reading column inside; .doc-raw keeps
.doc-md keeps its centered reading column inside (46rem base;
92rem at >=1500px, capped there by the 1100px panel); .doc-raw keeps
its own overflow-x. tabindex="-1" in the markup is the JS focus target. */
.doc-modal-content {
flex: 1;
@@ -2591,6 +2740,163 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.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 ---------- */
.app-footer {
border-top: 1px solid var(--line);
@@ -2860,8 +3166,9 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.history-actions { flex-wrap: wrap; }
/* Phase 51: the shared page squeezes like the chat column — the
title and the note step down (the empty-state-title family); the
shell keeps its 46rem column (it is already the narrowest box on
the page) and .msg-body's 92% override above applies. */
shell keeps its base 46rem column (the >=1500px 92rem override
never applies here — it is already the narrowest box on the
page) and .msg-body's 92% override above applies. */
#shared-title { font-size: 1.35rem; }
.shared-note { font-size: 0.88rem; }
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
@@ -2882,6 +3189,24 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
}
}
/* Phase 58: wide desktops (viewport >=1500px) read the 2x column —
--chat-column doubles to 92rem for the four reading shells (chat,
shared, the document viewer's .doc-md and its summary panel). The
chat and shared shells ARE their .container (the token max-width
overrides the .container's 72rem cap — same specificity, later in
the file), but the document page's .container.doc-shell WRAPS the
column, so the 72rem cap would bind first and pin .doc-md at
~1112px: the wide block lifts the shell's cap to the column plus
the container's two 1.25rem gutters, letting .doc-md's own 92rem
cap bind (1472px at the 16px root). Everything below 1500px
renders exactly as before, and .tuning-shell (a form, not a
reading surface) keeps its hard-coded 46rem at every width
(owner instruction 2026-08-31, TODO.md L5 / D2). */
@media (min-width: 1500px) {
:root { --chat-column: 92rem; }
.doc-shell { max-width: calc(var(--chat-column) + 2 * 1.25rem); }
}
/* Phase 46: prefers-reduced-motion stills the mobile menu — no
180ms slide+fade; open/close snaps (the visibility/opacity flip
applies instantly) and stays correct. BOTH states are named: the
+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.
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
import subprocess
from pathlib import Path
__all__ = ["GitSyncError", "clone_or_pull"]
__all__ = ["GitSyncError", "clone_or_pull", "run_git"]
class GitSyncError(RuntimeError):
@@ -40,14 +42,22 @@ def clone_or_pull(url: str, dest: Path | str) -> Path:
dest = Path(dest)
if not dest.exists() or not (dest / ".git").exists():
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:
_run(["git", "pull", "--ff-only"], cwd=dest)
run_git(["git", "pull", "--ff-only"], cwd=dest)
return dest
def _run(argv: list[str], cwd: Path) -> str:
"""Run a git command, capturing output; raise GitSyncError on failure."""
def run_git(argv: list[str], cwd: Path) -> str:
"""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:
proc = subprocess.run(argv, cwd=cwd, capture_output=True, text=True)
except FileNotFoundError:
+5 -2
View File
@@ -26,8 +26,11 @@ precedence order:
``~/Deployments``), kept for backwards compatibility (reached only
while both the table and ``BOR_GIT_SOURCES`` are empty).
Imported formats (PLAN anchor A9, revised): ``md, markdown, txt, yaml,
yml, json, py`` (case-insensitive; narrow with ``BOR_IMPORT_EXTENSIONS``).
Imported formats (PLAN anchor A9, revised; phase 56): the A9 family by
default — ``md, markdown, txt, yaml, yml, json, py`` plus the quadlet
family and ``j2`` (case-insensitive). ``BOR_IMPORT_EXTENSIONS`` may add
ANY well-formed extension or narrow the list (the A9 family is the
default, not a ceiling — owner permission 2026-08-31).
Any path with a dot-prefixed component (hidden files/dirs — vendored
caches) is skipped, along with non-content dirs (``.venv``,
``node_modules``, ``.git``, ``__pycache``, ``.pytest_cache``, ``dist``,
+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:
"""/sources.html, /login.html and /history.html (phase 50): each
document revalidates, and all three pages' stylesheet requests carry
the same process token."""
"""/sources.html, /login.html, /history.html (phase 50) and
/doc-edit.html (phase 59): each document revalidates, and all four
pages' stylesheet requests carry the same process token."""
token = _expected_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")
login_token = navigate("/login.html")
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(
+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)
assert r.status_code == 200
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["docs_repo_configured"] is False
# The shared conftest instance keeps the default (the other
# suites' title/label contract rides on it).
+430
View File
@@ -0,0 +1,430 @@
"""Phase 57 E2E (Playwright): edit the AI-generated summary in the
viewer — and watch it get re-embedded.
TODO.md L4: "Be able to edit the summaries for documents in the RAG.
Click an edit button in summary box and change the summary that the AI
created. re-embed that document after changing the summary."
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_edit_summaries.py -v --no-cov
The fixture KB is a story-dedicated directory
(``tests/fixtures/summary_edit_kb/`` — the shared ``tests/fixtures/docs/``
and the phase-30 ``summary_kb/`` stay pinned at their files) with ONE
non-markdown A9 document:
* ``quadlet/llamacpp.container`` — a podman quadlet unit (the
``container`` extension is in the A9 default family, so the DEFAULT
import scope walks it — no ``BOR_IMPORT_EXTENSIONS`` override). At
import the mock ``lite`` model (``SUMMARY_MODE`` marker,
``tests/e2e/mock_llm.py``) reduces it to the deterministic 24-token
digest — the first 24 tokens of the file, the header comment line —
stored on ``documents.summary`` and indexed as one ``is_summary``
chunk. The rest of the file is deliberately token-diluted
(config keys the digest never contains), and the sentinel
``RESE-EDIT-SUMMARY-SENTINEL-b41d`` sits on the document's LAST line —
outside the 24-token digest — so the raw ``<pre>`` content is
distinguishable from the stored summary (the digest/sentinel mechanic
of ``tests/e2e/test_document_summaries.py``).
DB isolation: the fixture's source name (``summary_edit_kb``) is
distinctive — the suite never asserts on absolute row counts and
deletes the rows it creates in a ``finally`` (other suites' documents
stay untouched in the shared E2E database).
The admin edits through the REAL browser flow (form login → the
viewer's Edit button → the inline editor → Save); the re-embed itself
is then verified against the live database (the chunk's NEW content, a
FRESH non-NULL vector, the content chunks byte-for-byte untouched — the
D4 re-embed scope) and against the public content endpoint (no cookie —
the viewer stays public, phase 16).
"""
from __future__ import annotations
import asyncio
from collections.abc import Iterator
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import select
from app.config import Settings
from app.db import SessionLocal
from app.models import Chunk, Document
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
from e2e.mock_llm import TOKEN_RE
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "summary_edit_kb"
SOURCE = FIXTURES.name # "summary_edit_kb" — distinctive, never asserted by count
DOC_PATH = "quadlet/llamacpp.container"
#: Encoded viewer URL query value (slash → %2F, same as the chips /
#: Sources table links build it).
DOC_URL_PATH = "quadlet%2Fllamacpp.container"
SENTINEL = "RESE-EDIT-SUMMARY-SENTINEL-b41d"
#: The hand-edited summary (test 1) — a distinctive sentence no part of
#: the fixture or its digest contains, so the round-trip assertion can
#: never pass against the old text.
NEW_SUMMARY = (
"Hand-edited: the quadlet unit serves the homelab's local model on "
"port 8081. (RESE-HANDEDIT-77ab)"
)
#: The modal-surface test's own edit (test 4) — likewise distinctive.
MODAL_SUMMARY = (
"Edited from the modal: quadlet unit for the local inference server. "
"(RESE-MODAL-EDIT-3cd9)"
)
# --- Importer + thread helpers (test_document_summaries.py pattern) ---
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.
Playwright's sync API keeps an asyncio loop running on the test thread,
so ``asyncio.run`` cannot be called directly from a test body.
"""
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 = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _delete_source_rows() -> None:
"""Delete every row of this suite's distinctive source (chunks
cascade with the document rows)."""
with SessionLocal() as db:
for doc in db.scalars(select(Document).where(Document.source == SOURCE)).all():
db.delete(doc)
db.commit()
@pytest.fixture(autouse=True)
def seeded_kb(mock_llm: int, db_ready: None) -> Iterator[ImportSummary]:
"""Seed the story-dedicated fixture for one test (the DEFAULT import
scope — ``container`` is A9) and delete every row it creates
afterwards (DB isolation — see the module docstring)."""
_delete_source_rows() # idempotent: leftovers from a crashed run
summary = _run_in_thread(_import_fixtures(mock_llm))
assert summary.formats == {"container": 1}
assert summary.added == 1
assert summary.summaries == 1 and summary.summary_errors == 0
assert summary.errors == 0
try:
yield summary
finally:
_delete_source_rows()
def _expected_summary(content: str, source: str, path: str) -> str:
"""The mock lite model's byte-stable digest + the code pointer line.
Mirrors ``mock_llm.compose_answer``'s ``SUMMARY_MODE`` branch (first
24 tokens of the document content) plus the summarizer's
deterministic ``Source:`` line — no model output is ever trusted.
"""
digest = " ".join(TOKEN_RE.findall(content.lower())[:24])
return f"This document covers {digest}.\nSource: {source}/{path}"
def _chunk_state() -> dict[str, Any]:
"""The fixture document's DB state, read back through a fresh session.
``total`` — the document's chunk count (never a table-wide count —
DB isolation); ``summary`` — ``documents.summary``; ``summary_*`` —
the single ``is_summary`` chunk (content + vector read-back);
``raw_contents`` — the content chunks' text, sorted (the D4 pin:
the content chunks are byte-for-byte untouched by a summary edit).
"""
with SessionLocal() as db:
doc = db.scalar(
select(Document).where(Document.source == SOURCE, Document.path == DOC_PATH)
)
assert doc is not None, "fixture doc was not imported"
chunks = db.scalars(select(Chunk).where(Chunk.document_id == doc.id)).all()
summary_chunks = [c for c in chunks if c.is_summary]
assert len(summary_chunks) <= 1, f"more than one is_summary chunk: {len(summary_chunks)}"
sc = summary_chunks[0] if summary_chunks else None
return {
"total": len(chunks),
"summary": doc.summary,
"summary_count": len(summary_chunks),
"summary_content": sc.content if sc else None,
"summary_vec": list(sc.embedding) if sc and sc.embedding is not None else None,
"raw_contents": sorted(c.content for c in chunks if not c.is_summary),
}
def _api_summary(app_url: str) -> str | None:
"""The public content endpoint's ``summary`` — NO cookie (the viewer
stays public, phase 16; a fresh httpx client carries no session)."""
r = httpx.get(
f"{app_url}/api/documents/content",
params={"source": SOURCE, "path": DOC_PATH},
timeout=10,
)
assert r.status_code == 200, r.text
return r.json()["summary"]
def _doc_url(app_url: str) -> str:
return f"{app_url}/document.html?source={SOURCE}&path={DOC_URL_PATH}"
# ---------------------------------------------------------------------------
# 1. Admin: edit → Save → the panel, the API, and the DB all agree —
# and the is_summary chunk carries a FRESH embedding (D4 re-embed)
# ---------------------------------------------------------------------------
def test_admin_edits_summary(page: Page, app_url: str) -> None:
page.set_default_timeout(30_000)
content = (FIXTURES / DOC_PATH).read_text(encoding="utf-8")
assert SENTINEL in content.splitlines()[-1] # last line, by design
expected = _expected_summary(content, SOURCE, DOC_PATH)
digest_line, pointer_line = expected.split("\n", 1)
before = _chunk_state()
assert before["summary"] == expected # the mock digest is the stored summary
assert before["summary_count"] == 1
assert before["summary_vec"] is not None
login(page, app_url)
page.goto(_doc_url(app_url))
# The .doc-summary panel shows the digest (digest + pointer lines) —
# and nothing from the diluted raw body ("PublishPort" is deeper in
# the file, outside the 24-token digest).
panel = page.locator(".doc-summary")
expect(panel).to_have_count(1)
expect(panel).to_be_visible()
expect(panel.locator(".doc-summary-title")).to_have_text("Summary")
expect(panel).to_contain_text(digest_line)
expect(panel).to_contain_text(pointer_line)
expect(panel).not_to_contain_text("PublishPort")
# The original still renders below, sentinel and all.
expect(page.locator("#doc-content pre.doc-raw")).to_contain_text(SENTINEL)
# The admin-only Edit button (phase 57, D4 — the viewer itself is
# public; only the affordance is gated).
edit = panel.locator(".doc-summary-edit")
expect(edit).to_be_visible()
expect(edit).to_have_text("Edit")
# Edit → inline editor: textarea PREFILLED with the current summary,
# Save / Cancel, and the role=status live region.
edit.click()
editor = page.locator(".doc-summary-editor")
expect(editor).to_be_visible()
expect(editor).to_have_value(expected)
expect(page.locator(".doc-summary-save")).to_be_visible()
expect(page.locator(".doc-summary-cancel")).to_be_visible()
status = page.locator(".doc-summary-status")
expect(status).to_have_attribute("role", "status")
expect(status).to_have_attribute("aria-live", "polite")
# Replace the text with the distinctive hand-edit and Save.
page.fill(".doc-summary-editor", NEW_SUMMARY)
page.click(".doc-summary-save")
# Live-region confirmation, and the panel text is the NEW summary
# (re-rendered through the textContent contract — the digest is gone).
expect(status).to_have_text("Summary updated.")
expect(panel.locator(".doc-summary-text")).to_have_text(NEW_SUMMARY)
expect(panel).not_to_contain_text(digest_line)
# Public read (no cookie): the content endpoint serves the new text.
assert _api_summary(app_url) == NEW_SUMMARY
# The re-embed, verified in the DB (D4): the is_summary chunk's
# content is the new text with a FRESH non-NULL vector; the total
# chunk count is unchanged and the content chunks are byte-for-byte
# untouched — only the summary changed, so only it was re-embedded.
after = _chunk_state()
assert after["total"] == before["total"] # count unchanged by an update
assert after["summary_count"] == 1
assert after["summary"] == NEW_SUMMARY
assert after["summary_content"] == NEW_SUMMARY
assert after["summary_vec"] is not None # re-embedded, non-NULL
assert after["summary_vec"] != before["summary_vec"] # a FRESH vector
assert after["raw_contents"] == before["raw_contents"] # content chunks untouched
# ---------------------------------------------------------------------------
# 2. Admin: an empty save CLEARS — the panel disappears, summary NULL,
# the is_summary chunk row is gone (count −1)
# ---------------------------------------------------------------------------
def test_admin_clears_summary(page: Page, app_url: str) -> None:
page.set_default_timeout(30_000)
content = (FIXTURES / DOC_PATH).read_text(encoding="utf-8")
expected = _expected_summary(content, SOURCE, DOC_PATH)
before = _chunk_state()
assert before["summary"] == expected
assert before["summary_count"] == 1
login(page, app_url)
page.goto(_doc_url(app_url))
panel = page.locator(".doc-summary")
expect(panel).to_have_count(1)
expect(panel.locator(".doc-summary-edit")).to_be_visible()
# Select-all + delete — clear the prefilled editor — then Save.
panel.locator(".doc-summary-edit").click()
page.fill(".doc-summary-editor", "")
page.click(".doc-summary-save")
# "Summary cleared." in the live region, then the panel leaves the
# DOM (the renderer only draws it for non-empty summaries — the
# removal lands a short beat after the confirmation). The original
# content below is untouched.
expect(page.locator(".doc-summary-status")).to_have_text("Summary cleared.")
expect(page.locator(".doc-summary")).to_have_count(0, timeout=8_000)
expect(page.locator("#doc-content pre.doc-raw")).to_contain_text(SENTINEL)
# The public endpoint now reports no summary…
assert _api_summary(app_url) is None
# …and the DB agrees: summary NULL, the is_summary row deleted
# (count −1), the content chunks byte-for-byte untouched.
after = _chunk_state()
assert after["total"] == before["total"] - 1 # the is_summary chunk is gone
assert after["summary"] is None
assert after["summary_count"] == 0
assert after["summary_vec"] is None
assert after["raw_contents"] == before["raw_contents"]
# ---------------------------------------------------------------------------
# 3. Anonymous: the digest renders, but no Edit button — and the
# endpoint is 403 (the public viewer is byte-for-byte phase 36)
# ---------------------------------------------------------------------------
def test_anonymous_cannot(page: Page, app_url: str) -> None:
page.set_default_timeout(30_000)
content = (FIXTURES / DOC_PATH).read_text(encoding="utf-8")
expected = _expected_summary(content, SOURCE, DOC_PATH)
digest_line, _ = expected.split("\n", 1)
# Fresh context (the function-scoped page fixture — no login): the
# panel renders the digest, but the edit affordance is ABSENT — no
# button, no header row, no editor wiring. The section keeps the
# phase-36 byte-for-byte shape: a bare h2 + the text-node <p>.
page.goto(_doc_url(app_url))
panel = page.locator(".doc-summary")
expect(panel).to_have_count(1)
expect(panel).to_be_visible()
expect(panel).to_contain_text(digest_line)
expect(page.locator(".doc-summary-edit")).to_have_count(0)
expect(page.locator(".doc-summary-head")).to_have_count(0)
expect(page.locator(".doc-summary-editor")).to_have_count(0)
children = page.evaluate(
"() => [...document.querySelector('.doc-summary').children]"
".map((el) => el.className)"
)
assert children == ["doc-summary-title", "doc-summary-text"], (
f"anonymous panel drifted from the phase-36 shape: {children}"
)
# The endpoint is admin-gated (D4): an anonymous PATCH → 403
# "admin only" (a fresh httpx client carries no session), and the
# stored summary is untouched.
r = httpx.patch(
f"{app_url}/api/documents/summary",
json={"source": SOURCE, "path": DOC_PATH, "summary": "not allowed"},
timeout=10,
)
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
assert _chunk_state()["summary"] == expected # untouched
# ---------------------------------------------------------------------------
# 4. Modal surface: the SAME shared renderer — the Sources-page modal
# carries the Edit button too, and a save from it round-trips
# ---------------------------------------------------------------------------
def test_admin_modal_surface_edit(page: Page, app_url: str) -> None:
"""One core, two surfaces (phase 26/36): the document modal from the
Sources table renders through the SAME ``renderDocument`` — so the
admin gets the Edit button in the modal as well, and a save from
there hits the same endpoint + DB row (the page test is unchanged
in shape; this pins the second surface)."""
page.set_default_timeout(30_000)
content = (FIXTURES / DOC_PATH).read_text(encoding="utf-8")
expected = _expected_summary(content, SOURCE, DOC_PATH)
digest_line, _ = expected.split("\n", 1)
before = _chunk_state()
assert before["summary"] == expected
login(page, app_url) # lands on /sources.html (the catalog is admin-only)
row = page.locator("#docs-tbody tr", has_text=DOC_PATH)
expect(row).to_have_count(1)
before_tabs = len(page.context.pages)
row.locator("td:nth-child(2) a.doc-link").click()
assert len(page.context.pages) == before_tabs, "row link must not open a new tab"
# The modal shows the panel with the digest + the admin Edit button.
expect(page.locator(".doc-modal")).to_be_visible()
expect(page.locator("#doc-modal-title")).to_have_text("llamacpp")
modal_panel = page.locator("#doc-modal .doc-summary")
expect(modal_panel).to_have_count(1)
expect(modal_panel).to_be_visible()
expect(modal_panel).to_contain_text(digest_line)
edit = modal_panel.locator(".doc-summary-edit")
expect(edit).to_be_visible()
# Edit → prefilled editor → replace → Save → the modal's panel
# reflects the new text and the live region confirms.
edit.click()
editor = page.locator("#doc-modal .doc-summary-editor")
expect(editor).to_be_visible()
expect(editor).to_have_value(expected)
page.fill("#doc-modal .doc-summary-editor", MODAL_SUMMARY)
page.click("#doc-modal .doc-summary-save")
expect(page.locator("#doc-modal .doc-summary-status")).to_have_text("Summary updated.")
expect(modal_panel.locator(".doc-summary-text")).to_have_text(MODAL_SUMMARY)
# Same endpoint, same DB row: the public read and the chunk agree —
# count unchanged, fresh vector, content chunks untouched.
assert _api_summary(app_url) == MODAL_SUMMARY
after = _chunk_state()
assert after["total"] == before["total"]
assert after["summary_count"] == 1
assert after["summary"] == MODAL_SUMMARY
assert after["summary_content"] == MODAL_SUMMARY
assert after["summary_vec"] is not None
assert after["summary_vec"] != before["summary_vec"]
assert after["raw_contents"] == before["raw_contents"]
+163
View File
@@ -0,0 +1,163 @@
"""Phase 56 E2E (Playwright): a NOVEL extension (``.sh``) flows config →
import → chunks → mock summary → Sources page.
TODO.md L6: "Allow the user to specify extensions to be read in .env,
don't hard-code working extensions." The subject is the env-driven
extension scope (``import_extensions="md,sh"``); the story-dedicated
fixture (``tests/fixtures/extension_kb/``) is seeded in-process against
the deterministic mock LLM — the phase-02 seeding-thread pattern, the
fixture, not the subject of the tests.
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_import_extensions_env.py -v --no-cov
DB isolation: the fixture's source name (``extension_kb``) is
distinctive — the suite never asserts on absolute row counts and
deletes the rows it creates in a ``finally`` (other suites' documents
stay untouched in the shared E2E database).
"""
from __future__ import annotations
import asyncio
from collections.abc import Iterator
from pathlib import Path
from threading import Thread
from typing import Any
import pytest
from playwright.sync_api import Page, expect
from sqlalchemy import select
from app.config import Settings
from app.db import SessionLocal
from app.models import Document
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "extension_kb"
SOURCE = FIXTURES.name # "extension_kb" — distinctive, never asserted by count
SH_REL = "homelab/scripts/uptime.sh"
MD_REL = "homelab/notes/note.md"
SENTINEL = "UPTIME-PROBE-SENTINEL-9c2f"
async def _import_fixtures(mock_port: int, extensions: str) -> ImportSummary:
kwargs: dict[str, Any] = {
"_env_file": None,
"llm_base_url": f"http://127.0.0.1:{mock_port}/v1",
"import_extensions": extensions,
}
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.
Playwright's sync API keeps an asyncio loop running on the test
thread, so ``asyncio.run`` cannot be called directly from a test
body.
"""
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 = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _delete_source_rows() -> None:
"""Delete every row of this suite's distinctive source (chunks
cascade with the document rows)."""
with SessionLocal() as db:
for doc in db.scalars(select(Document).where(Document.source == SOURCE)).all():
db.delete(doc)
db.commit()
@pytest.fixture(autouse=True)
def extension_kb(mock_llm: int, db_ready: None) -> Iterator[ImportSummary]:
"""Seed the fixture with the NOVEL scope (``md,sh``) for one test
and delete every row it creates afterwards (DB isolation — see the
module docstring)."""
_delete_source_rows() # idempotent: leftovers from a crashed run
summary = _run_in_thread(_import_fixtures(mock_llm, "md,sh"))
try:
yield summary
finally:
_delete_source_rows()
def test_admin_sources_lists_the_novel_extension(
page: Page, app_url: str, extension_kb: ImportSummary
) -> None:
# The seed saw exactly the two fixture files in their formats — the
# novel .sh extension walked, chunked, and summarized.
assert extension_kb.formats == {"sh": 1, "md": 1}
assert (extension_kb.added, extension_kb.errors) == (2, 0)
login(page, app_url) # phase 16: the catalog is admin-only
# The novel .sh document is listed; the path cell carries the full
# path (the column is ellipsized — the title attribute is the pin).
row = page.locator("#docs-tbody tr", has_text=SH_REL)
expect(row).to_have_count(1)
link = row.locator("td:nth-child(2) a.doc-link")
expect(link).to_have_count(1)
expect(link).to_have_attribute("title", SH_REL)
# The markdown control doc is listed too (never asserted by count —
# other suites' documents may share the shared E2E database).
expect(page.locator("#docs-tbody tr", has_text=MD_REL)).to_have_count(1)
# Format badge: the row's path link opens the same-page modal and
# its meta row shows the .sh format (house assertion style —
# test_document_viewer.py asserts the same locator for yaml/md).
before = len(page.context.pages)
link.click()
assert len(page.context.pages) == before, "clicking a row link must not open a new tab"
expect(page.locator("#doc-modal-meta .doc-source-badge")).to_have_text(SOURCE)
expect(page.locator("#doc-modal-meta .format-badge")).to_have_text("sh")
# Non-markdown content renders as escaped monospace text in a pre —
# the sentinel proves it is THIS document's content.
pre = page.locator("#doc-modal-content pre.doc-raw")
expect(pre).to_have_count(1)
expect(pre).to_contain_text(SENTINEL)
# Still on the Sources page: no navigation happened.
assert page.url == app_url + "/sources.html", f"navigated away: {page.url}"
def test_anonymous_sources_gate_and_no_api_docs(
page: Page, app_url: str, extension_kb: ImportSummary
) -> None:
"""A fresh anonymous context (function-scoped ``page`` = new
browser context, no cookies): the sign-in gate renders and the page
never calls ``/api/docs`` — the phase-16 pin, regression-checked
with the novel-extension KB seeded."""
api_docs_calls: list[str] = []
page.on(
"request",
lambda r: api_docs_calls.append(r.url) if "/api/docs" in r.url else None,
)
page.goto(f"{app_url}/sources.html")
# The gate, with its sign-in link — not a redirect.
gate = page.locator("#sources-gate")
expect(gate).to_be_visible()
expect(gate).to_contain_text("Sign in to view the full catalog")
expect(gate.locator("a[href='/login.html?next=/sources.html']")).to_have_count(1)
# Stat cards + table hidden…
expect(page.locator("#stat-cards")).to_be_hidden()
expect(page.locator("#docs-table")).to_be_hidden()
expect(page.locator("#sources-empty")).to_be_hidden()
# …and NO /api/docs call was ever made.
assert api_docs_calls == [], f"anonymous sources page called /api/docs: {api_docs_calls}"
+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"]
+28 -8
View File
@@ -11,9 +11,13 @@ Test → story mapping:
1. ``test_no_horizontal_overflow_at_viewports`` — 360/375/768/1280/1600 on
both pages: ``documentElement.scrollWidth <= clientWidth``.
2. ``test_chat_column_capped_and_centered`` — at 1600px ``.chat-shell``
≤ 46rem (736px, +2% tolerance) and horizontally centered (±2%); at 768px
the column uses most of the width (no mid-column dead zones).
2. ``test_chat_column_capped_and_centered`` — the reading column rides
--chat-column (46rem base; 92rem at >=1500px, phase 58 / owner
instruction 2026-08-31 TODO L5): at 1600px (a wide desktop) the
``.chat-shell`` is 92rem (1472px, ±2%) and horizontally centered
(±2%); at 1280px (below the wide breakpoint) it stays ≤ 46rem
(736px, +2%); at 768px the column uses most of the width (no
mid-column dead zones).
3. ``test_sources_table_full_width`` — at 1280px ``.table-wrap`` ≥ 80% of
the container; below 640px the table keeps its 640px min-width and the
wrapper scrolls horizontally instead of squeezing.
@@ -51,7 +55,8 @@ REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
VIEWPORTS = ((360, 740), (375, 812), (768, 1024), (1280, 800), (1600, 900))
CHAT_SHELL_CAP_PX = 46 * 16 # 736px — PLAN §7.1
CHAT_SHELL_CAP_PX = 46 * 16 # 736px — the --chat-column base (PLAN §7.1 lineage)
CHAT_SHELL_WIDE_PX = 92 * 16 # 1472px — the 2x wide override (phase 58, >=1500px)
# Mock-LLM marker for a 3s pre-token window (see tests/e2e/mock_llm.py).
SLOW_QUESTION = "pretend to think slowly, please"
@@ -217,15 +222,18 @@ def test_no_horizontal_overflow_at_viewports(
def test_chat_column_capped_and_centered(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""AC1: chat column stays ≤46rem centered at wide viewports and still
uses most of the width on tablets (no mid-column dead zones)."""
"""AC1 (phase 58 contract): the reading column doubles to 92rem on
wide desktops (>=1500px — 1600px here), stays at the 46rem base
below the breakpoint (1280px), and still uses most of the width on
tablets (no mid-column dead zones)."""
page = browser.new_page(viewport={"width": 1600, "height": 900})
try:
page.goto(f"{app_url}/")
box = page.locator(".chat-shell").bounding_box()
assert box is not None
assert box["width"] <= CHAT_SHELL_CAP_PX * 1.02, (
f"chat column {box['width']:.0f}px exceeds the 46rem cap (+2%)"
assert CHAT_SHELL_WIDE_PX * 0.98 <= box["width"] <= CHAT_SHELL_WIDE_PX * 1.02, (
f"at 1600px (>=1500px) the chat column is {box['width']:.0f}px, "
f"not the 92rem wide override (±2%)"
)
center = box["x"] + box["width"] / 2
assert abs(center - 1600 / 2) <= 0.02 * 1600, (
@@ -234,6 +242,18 @@ def test_chat_column_capped_and_centered(
finally:
page.close()
narrow = browser.new_page(viewport={"width": 1280, "height": 800})
try:
narrow.goto(f"{app_url}/")
box = narrow.locator(".chat-shell").bounding_box()
assert box is not None
assert box["width"] <= CHAT_SHELL_CAP_PX * 1.02, (
f"at 1280px (<1500px) the chat column {box['width']:.0f}px exceeds "
f"the 46rem base cap (+2%)"
)
finally:
narrow.close()
tablet = browser.new_page(viewport={"width": 768, "height": 1024})
try:
tablet.goto(f"{app_url}/")
+339
View File
@@ -0,0 +1,339 @@
"""Phase 60 E2E (Playwright): the navbar stays stuck to the top of the
screen at every scroll position — and the short-page layout is intact.
Source: ``TODO.md`` L3 — "The navbar disappears when you scroll down,
should stay stuck to the top of the screen" (no user story file —
TODO-derived phase).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_sticky_navbar.py -v --no-cov
Mechanism under test (owner-locked A1/A2, roadmap confirmation
2026-08-31): the sticky rule was always there — ``.app-header`` (every
page) and the viewer's two-row ``.doc-header`` (``/document.html``)
both carry ``position: sticky; top: 0``. What broke it: ``body {
height: 100% }`` pinned the body box to exactly one viewport, and a
sticky element may only travel inside its containing block — so after
~1 viewport of scroll the header un-pinned and scrolled away with the
body. The CSS-only fix (task 01) dropped ``height`` from the ``body``
rule (``html`` keeps it); the body's ``min-height: 100dvh`` still drives
the short-page stretch. This suite proves the BROWSER behavior:
* on a long page the header's bounding-box top is 0 at a mid-page
scroll AND at the very bottom (the pre-fix value at the bottom was
off-screen negative — ~−1264px in the audit's 2000px repro);
* the document stays the scroll container (phase 52 no-inner-scroller
contract) — the fix added no scroller, it only let the body grow;
* the short-page layout is EXACTLY as it was: body stretched to the
viewport, footer pinned to the viewport bottom, the phase-52
pinned composer (``position: sticky; bottom: env(safe-area-inset-
bottom)``) resting in its in-flow slot above the footer.
Seeding (A3): the long-scroll surfaces are the seeded Sources table
(``tests/fixtures/docs/`` — 13 docs, same harness as the phase-10/26
suites) PLUS a generated scratch dir the module writes (``gen/``: 40
short unique docs + one ``doc-long.md`` with a ~40,000-char body).
``import_sources`` runs in a worker thread against the deterministic
mock embeddings server (the ``test_document_viewer.py`` pattern) — no
real LLM is involved.
Test → story mapping (Playwright Mapping Rule):
1. ``test_app_header_stuck_at_top_on_sources``
2. ``test_doc_header_stuck_at_top_on_long_document``
3. ``test_short_page_stretch_and_resting_composer_intact``
"""
from __future__ import annotations
import asyncio
from collections.abc import Iterator
from pathlib import Path
from threading import Thread
from typing import Any
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
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
#: The conftest ``page`` fixture's viewport (1280×800).
VIEWPORT_W = 1280
VIEWPORT_H = 800
#: Tolerance for "stuck at the top" / "pinned to the viewport edge":
#: the measured positions are sub-pixel stable (0 in the audit), so the
#: slack is rounding headroom only.
TOP_TOL_PX = 1
#: Phase-52 resting-composer constants (test_pinned_composer.py): the
#: band below a resting composer may only be chrome — the footer's own
#: measured height plus the flow padding of the settled slot — and the
#: box must sit in the lower part of the screen.
BOTTOM_SLACK_PX = 56
LOWER_PART = 0.8
#: Seeding: 13 fixture docs (the A9 family, ``.hidden/`` skipped) + the
#: generated scratch docs.
SHORT_DOC_COUNT = 40
LONG_DOC_BODY_CHARS = 40_000
TOTAL_DOCS = 13 + SHORT_DOC_COUNT + 1 # + the one long doc
# ---------------------------------------------------------------------------
# KB seeding (same pattern as the phase 10/26/52 story suites)
# ---------------------------------------------------------------------------
def _write_generated_docs(base: Path) -> Path:
"""The scratch source dir: ``gen/doc-001.md`` … ``gen/doc-040.md``
(one-line bodies, unique titles) plus ``gen/doc-long.md`` with a
~40,000-char body (deterministic numbered paragraphs — real
scrollable content, no injected DOM)."""
gen = base / "gen"
gen.mkdir(parents=True, exist_ok=True)
for i in range(1, SHORT_DOC_COUNT + 1):
(gen / f"doc-{i:03d}.md").write_text(
f"# Sticky probe {i:03d}\n\nShort body line for probe {i:03d}.\n",
encoding="utf-8",
)
paragraphs: list[str] = []
n = 0
while sum(len(p) for p in paragraphs) < LONG_DOC_BODY_CHARS:
n += 1
paragraphs.append(
f"Paragraph {n:04d}: the quick brown fox jumps over the "
"lazy dog near the homelab rack."
)
(gen / "doc-long.md").write_text(
"# Sticky long document\n\n" + "\n\n".join(paragraphs) + "\n",
encoding="utf-8",
)
return gen
async def _import_dirs(mock_port: int, dirs: list[Path]) -> 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(dirs, LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test thread,
so ``asyncio.run`` cannot be called directly from a test body.
"""
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 = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int, dirs: list[Path] | None) -> ImportSummary | None:
"""Truncate the KB (and query log), then optionally re-import."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
if not dirs:
return None
return _run_in_thread(_import_dirs(mock_port, dirs))
@pytest.fixture(scope="module")
def kb_db_ready(app_url: str) -> None:
"""Module-scoped twin of conftest's ``db_ready`` (conftest's one is
function-scoped and cannot back a module-scoped fixture)."""
body = httpx.get(f"{app_url}/api/health", timeout=5).json()
if body["db"] != "up":
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
@pytest.fixture(scope="module")
def seeded_kb(
mock_llm: int, kb_db_ready: None, tmp_path_factory: pytest.TempPathFactory
) -> Iterator[None]:
"""A fresh KB: the 13 fixture docs + the generated scratch dir,
imported once for the whole module (truncated again on teardown)."""
gen = _write_generated_docs(tmp_path_factory.mktemp("sticky_navbar"))
summary = _reset_db(mock_llm, [FIXTURES, gen])
assert summary is not None and summary.added == TOTAL_DOCS, (
f"expected {TOTAL_DOCS} added docs, got {summary.added if summary else None}"
)
yield
_reset_db(mock_llm, None)
# ---------------------------------------------------------------------------
# Measurement helpers
# ---------------------------------------------------------------------------
def _assert_top_is_zero(page: Page, selector: str) -> None:
"""The element's bounding-box top is the viewport top (± TOP_TOL_PX).
``bounding_box`` is viewport-relative and never scrolls the page
itself, so this is exactly "where the bar sits where the user left
it" — the TODO's question. Pre-fix, the header's top here was a
large NEGATIVE number (scrolled away with the capped body).
"""
box = page.locator(selector).bounding_box()
assert box is not None, f"{selector} must be rendered (no bounding box)"
# Playwright's bounding box is x/y/width/height — top == y.
assert abs(box["y"]) <= TOP_TOL_PX, (
f"{selector} is not stuck to the top: rect top={box['y']:.2f} "
f"(want 0 ±{TOP_TOL_PX}) at scrollY={page.evaluate('() => window.scrollY'):.0f}"
)
# ---------------------------------------------------------------------------
# 1. The app navbar on a long Sources page: stuck at the top at a
# mid-page scroll AND at the very bottom
# ---------------------------------------------------------------------------
def test_app_header_stuck_at_top_on_sources(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
login(page, app_url) # → /sources.html (the table is admin-only)
expect(page.locator("#docs-tbody tr")).to_have_count(TOTAL_DOCS)
# The precondition the story needs: the page must actually scroll —
# fail loudly if the table ever stops being long enough.
sh = page.evaluate("() => document.documentElement.scrollHeight")
assert sh > 1.5 * VIEWPORT_H, (
f"the Sources table must be long enough to scroll "
f"(scrollHeight={sh}, want > {1.5 * VIEWPORT_H:.0f} at a "
f"{VIEWPORT_W}×{VIEWPORT_H} viewport) — add more generated docs"
)
# Mid-page scroll (~1 viewport down — the pre-fix un-pin point): the
# navbar is still stuck at the top.
page.evaluate("() => window.scrollTo(0, 800)")
assert (
page.evaluate("() => window.scrollY") >= VIEWPORT_H - TOP_TOL_PX
), "the test scroll must land"
_assert_top_is_zero(page, ".app-header")
# And at the very bottom (the audit's −1264px repro): stuck, and
# still visible.
page.evaluate("() => window.scrollTo(0, 999999)")
_assert_top_is_zero(page, ".app-header")
expect(page.locator(".app-header")).to_be_visible()
# ---------------------------------------------------------------------------
# 2. The document viewer's two-row header on a long doc: stuck at the top
# at the bottom of the scroll
# ---------------------------------------------------------------------------
def test_doc_header_stuck_at_top_on_long_document(
page: Page, app_url: str, seeded_kb: None
) -> None:
page.set_default_timeout(30_000)
# The dedicated viewer page (phase 10/26 contract) on the generated
# long doc — source ``gen``, path ``doc-long.md``.
page.goto(f"{app_url}/document.html?source=gen&path=doc-long.md")
expect(page.locator("#doc-title")).to_have_text("Sticky long document")
expect(page.locator("#doc-content .doc-md")).not_to_be_empty()
# The doc must actually scroll (the ~40,000-char body fills many
# viewports in the 46rem column).
sh = page.evaluate("() => document.documentElement.scrollHeight")
assert sh > VIEWPORT_H, (
f"the long document must make the page scroll "
f"(scrollHeight={sh}, want > {VIEWPORT_H})"
)
# To the very bottom: BOTH header rows stay pinned (the header
# element is content-sized + sticky — phase 34).
page.evaluate("() => window.scrollTo(0, document.documentElement.scrollHeight)")
assert (
page.evaluate("() => window.scrollY") >= sh - VIEWPORT_H - TOP_TOL_PX
), "the test scroll must land at the document bottom"
_assert_top_is_zero(page, ".doc-header")
# ---------------------------------------------------------------------------
# 3. Short-page regression: the phase-52 layout is EXACTLY as it was —
# body stretched to the viewport, footer pinned to the viewport
# bottom, the pinned composer resting in its in-flow slot above the
# footer (the drop of the body's height cap must not have moved a
# single pixel of the non-scrolling layout)
# ---------------------------------------------------------------------------
def test_short_page_stretch_and_resting_composer_intact(
page: Page, app_url: str
) -> None:
page.set_default_timeout(30_000)
page.goto(app_url)
expect(page.locator("#empty-state")).to_be_visible()
# The stretch holds: the body is exactly one viewport tall, and the
# grow must not invent scrollable space.
sh = page.evaluate("() => document.documentElement.scrollHeight")
assert sh <= VIEWPORT_H + TOP_TOL_PX, (
f"an empty chat must not be scrollable (scrollHeight={sh})"
)
body = page.locator("body").bounding_box()
assert body is not None
assert abs(body["height"] - VIEWPORT_H) <= TOP_TOL_PX, (
f"the body must stretch to the viewport (height={body['height']:.2f}, "
f"want {VIEWPORT_H}) — the min-height: 100dvh driver is intact"
)
# The footer is pinned to the viewport bottom (the audit's 800/800).
footer = page.locator(".app-footer").bounding_box()
assert footer is not None
footer_bottom = footer["y"] + footer["height"]
assert abs(footer_bottom - VIEWPORT_H) <= TOP_TOL_PX, (
f"the footer must sit at the viewport bottom "
f"(bottom={footer_bottom:.2f}, want {VIEWPORT_H})"
)
# The phase-52 pinned composer holds: the pin itself (position:
# sticky) and its RESTING geometry — in-flow, at the bottom of the
# screen, with only chrome (the footer + the settled slot's flow
# padding) in the band below it, never overlapping the footer.
assert (
page.evaluate(
"() => getComputedStyle(document.querySelector('#composer')).position"
)
== "sticky"
), "the composer must keep its phase-52 sticky pin"
composer = page.locator("#composer").bounding_box()
assert composer is not None
composer_bottom = composer["y"] + composer["height"]
deadband = VIEWPORT_H - composer_bottom
assert deadband <= footer["height"] + BOTTOM_SLACK_PX, (
f"dead band under the composer: {deadband:.0f}px below it with a "
f"{footer['height']:.0f}px footer — the resting slot moved"
)
assert composer_bottom >= VIEWPORT_H * LOWER_PART, (
f"the composer rests at {composer_bottom / VIEWPORT_H:.0%} of the "
"viewport — it has to sit at the bottom, not under the empty state"
)
assert composer_bottom <= footer["y"] + TOP_TOL_PX, (
f"the composer overlaps the footer (composer bottom="
f"{composer_bottom:.2f}, footer top={footer['y']:.2f})"
)
+432
View File
@@ -0,0 +1,432 @@
"""Phase 58 E2E (Playwright): the 2x reading column on wide desktops —
measured bounding-box widths, not CSS pins.
TODO.md L5 (owner instruction 2026-08-31, roadmap confirmation D2 +
expansion): "The chat response needs to be 2x wider on wide desktops.
there's a lot of unused space." — extended to the document view. The
owner-locked contract: viewport >=1500px doubles ``--chat-column`` to
92rem (1472px at the 16px root) for the four reading shells —
``.chat-shell``, ``.shared-shell``, ``.doc-md`` and
``.doc-summary:has(+ .doc-md)`` — while everything below the breakpoint
renders exactly as before (46rem / 736px) and ``.tuning-shell`` (a
form, not a reading surface) never widens (CSS-pinned by the unit
suite, task 01 — the browser proof of the MEASURED width is this file).
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_wide_desktop_column.py -v --no-cov
Test → contract mapping:
1. ``test_chat_column_wide_vs_base`` — ``/`` at 1920×1080: the
``.chat-shell`` bounding box is 1472px (92rem, ±4px) and centered;
at 1280×800 (below the wide breakpoint) it is back to 736px
(46rem, ±4px).
2. ``test_document_column_wide_vs_base`` — a seeded markdown document
that carries a summary: at 1920 both ``.doc-md`` and the adjacent
``.doc-summary:has(+ .doc-md)`` panel are 1472px; at 1280
``.doc-md`` is 736px.
3. ``test_shared_column_wide`` — a real auto-saved conversation shared
by token: ``/shared/<token>`` at 1920 renders ``.shared-shell`` at
1472px in a fresh anonymous context.
4. ``test_narrow_unchanged`` — 360px: no horizontal overflow and the
``.chat-shell`` is the existing mobile rule — full-bleed at the
viewport width (the shell IS its .container; the 0.9rem mobile
gutters live in its own padding, inside the measured box), 900px:
the shell holds the 46rem base (736px, not the 1472px wide rule —
at 900px a leaked wide override would pin the shell to the 860px
content box instead, so 736px is the discriminator) — the wide
rule does not leak below 1500px.
DB isolation: the shared-chat row is deleted in a ``finally`` (admin
cookie — the house pattern of test_share_chat.py, whose distinctive
question text keeps the auto-title unique); the fixture document rows
follow the story-fixture convention of the sibling suites (truncate +
re-import; the summary document is a direct row insert, the
test_document_viewer.py XSS-fixture pattern — the viewer is
database-only).
"""
from __future__ import annotations
import asyncio
import re
import time
from datetime import UTC, datetime
from pathlib import Path
from threading import Thread
from typing import Any
import httpx
from playwright.sync_api import Browser, Page, ViewportSize, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.models import Document
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
from e2e.auth_helpers import login
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
# The two proof viewports (task spec): wide desktop vs just below the
# 1500px breakpoint, at the 16px root the phase-58 rem contract:
# 92rem = 1472px, 46rem = 736px. ±4px tolerance (task spec).
WIDE_PX = 92 * 16 # 1472px — the 2x wide override (>=1500px)
BASE_PX = 46 * 16 # 736px — the --chat-column base
TOL_PX = 4
WIDE_VIEWPORT: ViewportSize = {"width": 1920, "height": 1080}
BASE_VIEWPORT: ViewportSize = {"width": 1280, "height": 800}
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
#: The seeded markdown document (direct row insert — the viewer is
#: database-only, so no fixture file is needed). Encoded viewer URL
#: values: slashes come out as %2F, same as the chips build them.
FIXTURE_SOURCE = "docs"
FIXTURE_PATH = "notes/wide-column-fixture.md"
FIXTURE_TITLE = "Wide Column Fixture"
FIXTURE_DOC_URL = "/document.html?source=docs&path=notes%2Fwide-column-fixture.md"
# ---------------------------------------------------------------------------
# KB seeding (house pattern — test_document_viewer.py / test_share_chat.py)
# ---------------------------------------------------------------------------
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.
Playwright's sync API keeps an asyncio loop running on the test
thread, so ``asyncio.run`` cannot be called directly from a test
body.
"""
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 = 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) -> ImportSummary | None:
"""Truncate the KB (and query log + steering notes — deterministic
mock answers), then optionally re-import fixtures. ``saved_chats``
is deliberately NOT touched: the shared test cleans up its own
row in a ``finally``."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
db.commit()
if not seed:
return None
return _run_in_thread(_import_fixtures(mock_port))
def _seed_summary_doc() -> None:
"""One markdown document carrying a stored summary — the ONLY shape
that renders both reading-column pins on one page: ``.doc-summary``
(the phase-36 panel) directly above ``.doc-md`` (the ``:has(
+ .doc-md)`` sibling match). Direct row insert — the viewer is
database-only (the test_document_viewer.py XSS-fixture pattern)."""
with SessionLocal() as db:
db.add(
Document(
source=FIXTURE_SOURCE,
path=FIXTURE_PATH,
full_path="/tmp/wide-column-fixture.md",
title=FIXTURE_TITLE,
content=(
"# Wide Column Fixture\n\n"
"Phase-58 width pin: a markdown document with a "
"summary, so the viewer renders the .doc-summary "
"panel directly above the .doc-md column."
),
summary="A phase-58 fixture summary for the wide-column pin.",
content_hash="w" * 64,
indexed_at=datetime.now(UTC),
)
)
db.commit()
# ---------------------------------------------------------------------------
# Measurement + chat-turn helpers
# ---------------------------------------------------------------------------
def _assert_width(page: Page, selector: str, expected_px: int, label: str) -> None:
"""The element's bounding-box width is ``expected_px`` ±4px (task
spec) — the measured rendered width, not the computed style."""
box = page.locator(selector).first.bounding_box()
assert box is not None, f"{selector} not rendered ({label})"
assert abs(box["width"] - expected_px) <= TOL_PX, (
f"{label}: {selector} is {box['width']:.1f}px, "
f"want {expected_px}px ±{TOL_PX}px"
)
def _assert_centered(page: Page, selector: str, viewport_w: int, label: str) -> None:
"""margin-inline: auto — the column center is within ±2% of the
viewport center (the test_responsive_polish.py assertion style)."""
box = page.locator(selector).first.bounding_box()
assert box is not None, f"{selector} not rendered ({label})"
center = box["x"] + box["width"] / 2
assert abs(center - viewport_w / 2) <= 0.02 * viewport_w, (
f"{label}: {selector} center {center:.1f}px is not within ±2% of "
f"the {viewport_w}px viewport center"
)
def _doc_overflow(page: Page) -> tuple[int, int]:
"""(scrollWidth, clientWidth) of the documentElement."""
return page.evaluate(
"() => [document.documentElement.scrollWidth, document.documentElement.clientWidth]"
) # pyright: ignore[reportReturnType]
def _assert_no_doc_overflow(page: Page, label: str) -> None:
scroll, client = _doc_overflow(page)
assert scroll <= client, f"horizontal overflow at {label}: {scroll} > {client}"
def _ask(page: Page, question: str) -> None:
"""Send one turn and wait until the grounded answer has fully
landed (the ``done`` event restored the Send button)."""
page.fill("#message-input", question)
page.click("#send-btn")
expect(page.locator(".msg.user .bubble").last).to_contain_text(question)
expect(page.locator(".msg.brain .bubble").last).to_contain_text(
MOCK_ANSWER_MARKER, timeout=30_000
)
expect(page.locator("#send-btn")).to_be_enabled()
expect(page.locator("#send-label")).to_have_text("Send")
def _admin_cookies(page: Page) -> dict[str, str]:
"""The signed session cookies the browser holds after a form login."""
return {
c["name"]: c["value"]
for c in page.context.cookies()
if "name" in c and "value" in c
}
def _auto_title(question: str) -> str:
"""The phase-50 auto-title convention: the first question,
whitespace-collapsed, capped at 120 chars."""
return " ".join(question.split())[:120]
def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]:
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
assert r.status_code == 200
return r.json()["chats"]
def _wait_saved_row(
app_url: str,
cookies: dict[str, str],
title: str,
messages: int = 2,
) -> dict[str, Any]:
"""Wait for the auto-saved row (phase 55: auto-saves are SILENT —
poll the admin list until the row with the auto-title appears with
the expected message count)."""
deadline = time.monotonic() + 15
last: dict[str, Any] | None = None
while time.monotonic() < deadline:
last = next(
(c for c in _chats(app_url, cookies) if c["title"] == title), None
)
if last is not None and last["message_count"] >= messages:
return last
time.sleep(0.2)
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
def _delete_chat(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
# ---------------------------------------------------------------------------
# 1. Chat: 1472px at 1920, back to 736px at 1280 (centered both ways)
# ---------------------------------------------------------------------------
def test_chat_column_wide_vs_base(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""The chat page's .chat-shell doubles at the 1500px breakpoint:
92rem (1472px, ±4px) at 1920×1080, centered; 46rem (736px, ±4px)
at 1280×800 — the base below the breakpoint."""
wide = browser.new_page(viewport=WIDE_VIEWPORT)
try:
wide.goto(f"{app_url}/")
wide.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
)
_assert_width(wide, ".chat-shell", WIDE_PX, "chat @ 1920px")
_assert_centered(wide, ".chat-shell", 1920, "chat @ 1920px")
finally:
wide.close()
base = browser.new_page(viewport=BASE_VIEWPORT)
try:
base.goto(f"{app_url}/")
base.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
)
_assert_width(base, ".chat-shell", BASE_PX, "chat @ 1280px")
_assert_centered(base, ".chat-shell", 1280, "chat @ 1280px")
finally:
base.close()
# ---------------------------------------------------------------------------
# 2. Document viewer: .doc-md (and its .doc-summary panel) at both
# widths — the owner-expanded surface
# ---------------------------------------------------------------------------
def test_document_column_wide_vs_base(
browser: Browser, app_url: str, mock_llm: int, db_ready: None
) -> None:
"""A seeded md document (with a summary) renders .doc-md at 1472px
at 1920 and 736px at 1280 — and the adjacent .doc-summary panel
matches the column at 1920 (.doc-summary:has(+ .doc-md))."""
_reset_db(mock_llm, seed=True)
_seed_summary_doc()
wide = browser.new_page(viewport=WIDE_VIEWPORT)
try:
wide.goto(app_url + FIXTURE_DOC_URL)
expect(wide.locator("#doc-title")).to_have_text(FIXTURE_TITLE, timeout=15_000)
expect(wide.locator("#doc-content .doc-md")).to_be_visible(timeout=15_000)
expect(
wide.locator(".doc-summary:has(+ .doc-md)")
).to_have_count(1, timeout=15_000)
_assert_width(wide, "#doc-content .doc-md", WIDE_PX, "document @ 1920px")
_assert_width(
wide, ".doc-summary:has(+ .doc-md)", WIDE_PX, "summary panel @ 1920px"
)
finally:
wide.close()
base = browser.new_page(viewport=BASE_VIEWPORT)
try:
base.goto(app_url + FIXTURE_DOC_URL)
expect(base.locator("#doc-title")).to_have_text(FIXTURE_TITLE, timeout=15_000)
expect(base.locator("#doc-content .doc-md")).to_be_visible(timeout=15_000)
_assert_width(base, "#doc-content .doc-md", BASE_PX, "document @ 1280px")
finally:
base.close()
# ---------------------------------------------------------------------------
# 3. Shared page: .shared-shell at 1472px for a guest at 1920
# ---------------------------------------------------------------------------
def test_shared_column_wide(
page: Page,
browser: Browser,
app_url: str,
mock_llm: int,
db_ready: None,
) -> None:
"""A real auto-saved conversation, shared by token, renders its
.shared-shell at 1472px (±4px) at 1920 in a FRESH anonymous
context (no cookies — the guest's only credential is the token)."""
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
login(page, app_url, next="/")
expect(page).to_have_url(app_url + "/", timeout=30_000)
q = "How is my Kubernetes cluster set up? (wide-column)"
_ask(page, q)
cookies = _admin_cookies(page)
row = _wait_saved_row(app_url, cookies, _auto_title(q))
chat_id: str = row["id"]
anon_ctx = None
try:
# Public since phase 55 — the share endpoint takes no session.
r = httpx.post(f"{app_url}/api/chats/{chat_id}/share", timeout=10)
assert r.status_code == 200
share_url = r.json()["share_url"]
assert SHARE_URL_RE.fullmatch(share_url), f"bad share_url shape: {share_url}"
anon_ctx = browser.new_context(viewport=WIDE_VIEWPORT)
anon = anon_ctx.new_page()
anon.set_default_timeout(30_000)
anon.goto(app_url + share_url)
expect(anon.locator("#shared-title")).to_have_text(
_auto_title(q), timeout=15_000
)
expect(anon.locator(".msg.brain .bubble")).to_contain_text(
MOCK_ANSWER_MARKER, timeout=30_000
)
_assert_width(anon, ".shared-shell", WIDE_PX, "shared @ 1920px")
_assert_centered(anon, ".shared-shell", 1920, "shared @ 1920px")
finally:
if anon_ctx is not None:
anon_ctx.close()
_delete_chat(app_url, cookies, chat_id)
# ---------------------------------------------------------------------------
# 4. Below the breakpoint: 360px and 900px are byte-for-byte the old
# rules — no overflow, no leaked wide column
# ---------------------------------------------------------------------------
def test_narrow_unchanged(
browser: Browser, app_url: str, db_ready: None
) -> None:
"""The min-width:1500px override must not leak below the
breakpoint: at 360px no horizontal overflow and the shell is the
existing mobile rule — full-bleed at the viewport width (the shell
IS its .container, so the 0.9rem mobile gutters sit in its own
padding, inside the measured box); at 900px the shell holds the
46rem base (736px — a leaked 92rem rule would pin it to the 860px
content box instead, so 736px is the discriminator)."""
phone = browser.new_page(viewport={"width": 360, "height": 740})
try:
phone.goto(f"{app_url}/")
phone.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
)
_assert_no_doc_overflow(phone, "chat @ 360px")
_assert_width(phone, ".chat-shell", 360, "chat @ 360px")
finally:
phone.close()
tablet = browser.new_page(viewport={"width": 900, "height": 800})
try:
tablet.goto(f"{app_url}/")
tablet.locator("#suggestions .suggestion-chip").first.wait_for(
state="visible", timeout=10_000
)
_assert_no_doc_overflow(tablet, "chat @ 900px")
_assert_width(tablet, ".chat-shell", BASE_PX, "chat @ 900px")
_assert_centered(tablet, ".chat-shell", 900, "chat @ 900px")
finally:
tablet.close()
+6
View File
@@ -0,0 +1,6 @@
# Extension fixture note
A small markdown control document for the phase 56 extension-kb fixture.
It exists so the `md` scope and the novel `sh` scope are told apart when
the importer walks `tests/fixtures/extension_kb/` — with
`import_extensions="md"` only this file should land in the index.
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# UPTIME-PROBE-SENTINEL-9c2f — phase 56 fixture marker: this token exists
# nowhere else, so the extension_kb rows are unambiguous in the shared DB.
#
# uptime.sh — homelab service probe: polls the core services and posts a
# ntfy alert on the first failure. A novel (.sh) file on purpose — it
# only imports when BOR_IMPORT_EXTENSIONS names the sh extension.
set -euo pipefail
ALERT_TOPIC="homelab-alerts"
NTFY_URL="https://ntfy.reeseapps.com"
CHECKS=(
"k3s|https://10.0.1.10:6443/healthz"
"gitlab|https://gitlab.reeseapps.com/-/health_check"
"ntfy|https://ntfy.reeseapps.com/health"
)
probe() {
local name="$1" url="$2"
curl -s -o /dev/null -w "%{http_code}" --max-time 10 "$url"
}
main() {
local line name code
for line in "${CHECKS[@]}"; do
name="${line%%|*}"
code="$(probe "$name" "${line#*|}")"
if [[ "$code" != "200" ]]; then
echo "uptime: $name answered $code (expected 200)" >&2
curl -s -X POST "$NTFY_URL/$ALERT_TOPIC" \
-H "Title: homelab check failed" \
-d "$name is down (HTTP $code)"
fi
done
echo "uptime: round complete"
}
main "$@"
@@ -0,0 +1,22 @@
# llama.cpp quadlet container — the homelab local LLM inference server
[Unit]
Description=llama.cpp server for local inference (qwen3-8b)
Requires=llamacpp-network.net
After=network-online.target
[Container]
Image=docker.io/ggml-org/llama-cpp:0.1.43
ContainerName=llamacpp
Network=llamacpp-network
PublishPort=8081:8080
Volume=/srv/llama/models:/models:ro
Environment=CONTEXT_LENGTH=32768
Environment=BATCH_SIZE=512
Restart=always
[Service]
TimeoutStartSec=300
[Install]
WantedBy=default.target
# RESE-EDIT-SUMMARY-SENTINEL-b41d — phase 57 fixture marker: last line, outside the 24-token digest.
+29 -3
View File
@@ -18,13 +18,16 @@ def test_health_reports_ok(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")
assert r.status_code == 200
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["version"] == get_settings().app_version
assert body["docs_repo_configured"] is False
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")
assert r.status_code == 200
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["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:
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"
+350 -8
View File
@@ -1,19 +1,40 @@
"""Integration tests: GET /api/documents/content — the viewer's data source.
"""Integration tests: the document-content API — the viewer's data source.
Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient:
* 200 with the full field set for a seeded document (all formats);
* ``summary`` surfaced for summarized docs, ``null`` for markdown (phase 36);
* anonymous access stays 200 (phase 16 soft rule — public viewer);
* 404 for an unknown (source, path) pair;
* 404 for traversal-style ``path`` values (no filesystem access → no leak).
* GET: 200 with the full field set for a seeded document (all formats);
* GET: ``summary`` surfaced for summarized docs, ``null`` for markdown
(phase 36);
* GET: anonymous access stays 200 (phase 16 soft rule — public viewer);
* GET: 404 for an unknown (source, path) pair;
* GET: 404 for traversal-style ``path`` values (no filesystem access → no
leak).
PATCH /api/documents/summary (phase 57, task 01) — the admin summary
editor, on the same DB-backed fixtures:
* update: ``documents.summary`` + the ``is_summary`` chunk's content
replaced, fresh embedding, content chunks untouched (count unchanged —
D4 re-embed scope);
* clear: empty/whitespace text → ``summary`` NULL + ``is_summary`` chunk
deleted (idempotent, no embed call);
* markdown doc (no prior ``is_summary`` chunk) → one created at
position −1 with the new embedding;
* 404 unknown (source, path) (incl. traversal strings); 403 anonymous;
* embed failure → 503 sanitized detail, DB byte-for-byte untouched
(fail-before-write — embed before any mutation).
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import text
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select, text
from sqlalchemy.orm import Session
import app.api.docs as docs_api
from app.models import Chunk, Document
from app.rag.llm import EmbeddingError
from tests.fakes import FakeEmbedder
def _seed_doc(
@@ -24,8 +45,14 @@ def _seed_doc(
content: str = "# Kubernetes\n\nTalos on 3 nodes.",
chunks: int = 2,
summary: str | None = None,
summary_chunk: bool = False,
) -> None:
"""Truncate the KB and insert one document with ``chunks`` chunk rows."""
"""Truncate the KB and insert one document with ``chunks`` chunk rows.
``summary_chunk`` additionally indexes the phase-30 ``is_summary``
chunk at position −1 with the seeded vector (requires ``summary`` —
the phase-30 invariant: the chunk mirrors ``documents.summary``).
"""
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
doc = Document(
@@ -45,9 +72,324 @@ def _seed_doc(
Chunk(document_id=doc.id, position=i, content=f"chunk {i}", embedding=[0.01] * 768)
for i in range(chunks)
)
if summary_chunk:
assert summary is not None
db.add(
Chunk(
document_id=doc.id,
position=-1,
content=summary,
embedding=[0.01] * 768,
is_summary=True,
)
)
db.commit()
# ---------------------------------------------------------------------------
# PATCH /api/documents/summary (phase 57, task 01)
# ---------------------------------------------------------------------------
class _DeadEmbedder:
"""An ``LLMClient`` stand-in whose ``embed`` always fails — the
dead-endpoint path (phase 57 fail-before-write). The message mirrors
``LLMClient.embed``'s transport wrap, with embedded credentials that
the sanitizer must mask."""
def __init__(self) -> None:
self.calls: list[list[str]] = []
async def embed(self, texts: list[str]) -> list[list[float]]:
self.calls.append(list(texts))
raise EmbeddingError(
"embeddings request to https://user:secret@aipi.example.com/v1 "
"failed: connection refused"
)
def _seed_yaml_doc(db, *, summary: str, summary_chunk: bool) -> None:
"""One phase-30-style non-markdown doc: 2 content chunks + (optionally)
its ``is_summary`` chunk."""
_seed_doc(
db,
path="container_gitlab/gitlab-compose.yaml",
title="gitlab-compose",
content="services:\n gitlab:\n image: gitlab/gitlab-ce",
summary=summary,
summary_chunk=summary_chunk,
)
def _doc_id(db, path: str):
return db.scalar(select(Document.id).where(Document.path == path))
def _chunk_snapshots(db, doc_id) -> dict:
"""``{chunk id: (position, content, embedding, is_summary)}``.
Before/after comparisons prove exactly which rows a PATCH touched.
Embeddings are compared read-back to read-back: pgvector stores
float4, so raw Python floats do not round-trip exactly (the house
style elsewhere is ``is not None`` + dimension checks)."""
rows = db.scalars(select(Chunk).where(Chunk.document_id == doc_id)).all()
return {
c.id: (
c.position,
c.content,
list(c.embedding) if c.embedding is not None else None,
c.is_summary,
)
for c in rows
}
def test_summary_patch_update_reembeds_summary_chunk(
admin_client: TestClient, client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session
) -> None:
"""Admin PATCH with new text (phase 57, D4): ``documents.summary`` and
the ``is_summary`` chunk's content are replaced and the chunk gets a
fresh embedding — in place (same row id); the content chunks are
untouched (same ids, positions, contents, vectors) and the total
count is unchanged. One ``embed`` call, only with the new text."""
old = "GitLab CE runs in a Podman compose stack on the homelab NAS."
new = "GitLab CE is now backed by external PostgreSQL and MinIO."
_seed_yaml_doc(db, summary=old, summary_chunk=True)
doc_id = _doc_id(db, "container_gitlab/gitlab-compose.yaml")
db.expire_all()
before = _chunk_snapshots(db, doc_id)
assert len(before) == 3
try:
fake = FakeEmbedder()
monkeypatch.setattr(docs_api, "LLMClient", lambda: fake)
r = admin_client.patch(
"/api/documents/summary",
json={
"source": "Homelab",
"path": "container_gitlab/gitlab-compose.yaml",
"summary": new,
},
)
assert r.status_code == 200, r.text
body = r.json()
assert set(body) == {"source", "path", "summary", "chunks"}
assert body["source"] == "Homelab"
assert body["path"] == "container_gitlab/gitlab-compose.yaml"
assert body["summary"] == new
assert body["chunks"] == 3 # 2 content + 1 is_summary — unchanged by an update
db.expire_all()
row = db.get(Document, doc_id)
assert row is not None
assert row.summary == new
after = _chunk_snapshots(db, doc_id)
assert set(after) == set(before) # same rows: nothing added or deleted
sc = next(cid for cid, v in after.items() if v[3])
assert sc in before # replaced in place — the same row id
pos, content, vec, _ = after[sc]
assert pos == -1
assert content == new
assert vec is not None and len(vec) == 768
assert vec != before[sc][2] # fresh embedding, not the seeded one
for cid, v in after.items():
if not v[3]:
assert v == before[cid] # content chunks untouched, byte for byte
assert fake.calls == [[new]] # one embed call, the new text only
# The public viewer's data source now carries the new summary
# verbatim (anonymous — the viewer stays public, phase 16).
g = client.get(
"/api/documents/content",
params={"source": "Homelab", "path": "container_gitlab/gitlab-compose.yaml"},
)
assert g.status_code == 200
assert g.json()["summary"] == new
finally:
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
def test_summary_patch_clear(
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session
) -> None:
"""PATCH with empty/whitespace text clears (phase 57, D4):
``documents.summary`` → NULL and the ``is_summary`` chunk is deleted;
the content chunks are untouched. A second clear is an idempotent 200
and clearing never calls the embedder."""
old = "GitLab CE runs in a Podman compose stack on the homelab NAS."
_seed_yaml_doc(db, summary=old, summary_chunk=True)
doc_id = _doc_id(db, "container_gitlab/gitlab-compose.yaml")
db.expire_all()
before = _chunk_snapshots(db, doc_id)
assert len(before) == 3
try:
fake = FakeEmbedder()
monkeypatch.setattr(docs_api, "LLMClient", lambda: fake)
for body_summary in (" ", ""): # whitespace, then a true empty string
r = admin_client.patch(
"/api/documents/summary",
json={
"source": "Homelab",
"path": "container_gitlab/gitlab-compose.yaml",
"summary": body_summary,
},
)
assert r.status_code == 200, r.text
assert r.json() == {
"source": "Homelab",
"path": "container_gitlab/gitlab-compose.yaml",
"summary": None,
"chunks": 2,
}
db.expire_all()
row = db.get(Document, doc_id)
assert row is not None
assert row.summary is None
after = _chunk_snapshots(db, doc_id)
assert len(after) == 2 # the is_summary chunk row is gone (count −1)
for cid, v in after.items():
assert not v[3]
assert v == before[cid] # content chunks untouched, byte for byte
assert fake.calls == [] # clearing never embeds
finally:
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
def test_summary_patch_markdown_doc_creates_summary_chunk(
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session
) -> None:
"""A markdown doc (no ``is_summary`` chunk by construction — phase 30)
gets exactly one created at position −1 with the new embedding; the
content chunks are untouched and the count goes 2 → 3. This is also
the recovery path for a phase-30 fail-soft import (document indexed
without its summary chunk)."""
new = "Talos Kubernetes on 3 nodes with a CNI of choice."
_seed_doc(db, summary=None, summary_chunk=False) # the default kubernetes.md
doc_id = _doc_id(db, "kubernetes.md")
db.expire_all()
before = _chunk_snapshots(db, doc_id)
assert len(before) == 2
try:
fake = FakeEmbedder()
monkeypatch.setattr(docs_api, "LLMClient", lambda: fake)
r = admin_client.patch(
"/api/documents/summary",
json={"source": "Homelab", "path": "kubernetes.md", "summary": new},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["source"] == "Homelab"
assert body["path"] == "kubernetes.md"
assert body["summary"] == new
assert body["chunks"] == 3 # 2 content + the new is_summary
db.expire_all()
row = db.get(Document, doc_id)
assert row is not None
assert row.summary == new
after = _chunk_snapshots(db, doc_id)
assert len(after) == 3
for cid, v in before.items():
assert after[cid] == v # content chunks untouched, byte for byte
sc = next(cid for cid in after if cid not in before)
pos, content, vec, is_summary = after[sc]
assert (pos, is_summary) == (-1, True)
assert content == new
assert vec is not None and len(vec) == 768
assert fake.calls == [[new]]
finally:
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
def test_summary_patch_404_unknown_pair(admin_client: TestClient, db: Session) -> None:
"""Unknown (source, path) pairs — including traversal strings — are
just missing rows: 404 ``document not found`` (the same shape as the
public GET). A pair that exists under a DIFFERENT source is 404 too."""
_seed_yaml_doc(db, summary="old", summary_chunk=True)
try:
for source, path in (
("Homelab", "nope/missing.md"),
("Deployments", "container_gitlab/gitlab-compose.yaml"),
("Homelab", "../../etc/passwd"),
):
r = admin_client.patch(
"/api/documents/summary",
json={"source": source, "path": path, "summary": "whatever"},
)
assert r.status_code == 404, (source, path)
assert r.json() == {"detail": "document not found"}, (source, path)
finally:
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
def test_summary_patch_403_anonymous(client: TestClient, db: Session) -> None:
"""The edit affordance is admin-only (phase 57, D4 — the viewer stays
public): an anonymous PATCH gets 403 ``admin only`` and touches
nothing."""
old = "GitLab CE runs in a Podman compose stack on the homelab NAS."
_seed_yaml_doc(db, summary=old, summary_chunk=True)
doc_id = _doc_id(db, "container_gitlab/gitlab-compose.yaml")
try:
r = client.patch(
"/api/documents/summary",
json={
"source": "Homelab",
"path": "container_gitlab/gitlab-compose.yaml",
"summary": "not allowed",
},
)
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
db.expire_all()
row = db.get(Document, doc_id)
assert row is not None
assert row.summary == old # untouched
assert len(_chunk_snapshots(db, doc_id)) == 3
finally:
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
def test_summary_patch_embed_failure_503_db_untouched(
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session
) -> None:
"""Fail-before-write (phase 57 locked decision): a dead embedding
endpoint → 503 with a sanitized detail (credentials masked, the
reason survives — the ``git_sources.py`` ``ModelUnavailableError``
style) and the row + every chunk byte-for-byte as before."""
old = "GitLab CE runs in a Podman compose stack on the homelab NAS."
_seed_yaml_doc(db, summary=old, summary_chunk=True)
doc_id = _doc_id(db, "container_gitlab/gitlab-compose.yaml")
db.expire_all()
before = _chunk_snapshots(db, doc_id)
try:
dead = _DeadEmbedder()
monkeypatch.setattr(docs_api, "LLMClient", lambda: dead)
r = admin_client.patch(
"/api/documents/summary",
json={
"source": "Homelab",
"path": "container_gitlab/gitlab-compose.yaml",
"summary": "new text",
},
)
assert r.status_code == 503
detail = r.json()["detail"]
assert "*****@aipi.example.com" in detail # credentials masked
assert "user:secret" not in detail
assert "connection refused" in detail # the reason survives
assert dead.calls == [["new text"]] # the embed was attempted…
db.expire_all()
row = db.get(Document, doc_id)
assert row is not None
assert row.summary == old # …and failed before any mutation
assert _chunk_snapshots(db, doc_id) == before # every row, byte for byte
finally:
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
def test_content_200_all_fields(client, db) -> None:
_seed_doc(db)
try:
@@ -0,0 +1,190 @@
"""Integration test: phase 56 — ``BOR_IMPORT_EXTENSIONS`` is user-extensible.
Proves a NOVEL (non-A9) extension flows through the import machinery
(config → walk → delta → chunk → mock ``SUMMARY_MODE`` digest) against
the story-dedicated fixture directory ``tests/fixtures/extension_kb/``
(the shared ``docs`` / ``summary_kb`` fixtures stay pinned by their own
suites). The LLM is the deterministic mock server (``tests/e2e/mock_llm.py``)
on a scratch port — the integration analogue of the e2e ``mock_llm``
fixture — so the summary is the byte-stable ``SUMMARY_MODE`` digest and
the vectors are genuine token-overlap embeddings. Runs against the local
compose Postgres (the ``db`` fixture from ``tests/conftest.py``); only
the distinctive ``extension_kb`` source rows are created and deleted, so
the rest of the shared KB is untouched.
Runs (DB must be up: ``podman compose up -d db``):
uv run pytest tests/integration/test_import_extensions_env.py -v
"""
from __future__ import annotations
import asyncio
import os
import socket
import subprocess
import sys
import time
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import httpx
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.config import Settings
from app.models import Document
from app.rag.importer import import_sources
from app.rag.llm import LLMClient
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "extension_kb"
SOURCE = FIXTURES.name # "extension_kb" — distinctive, never asserted by count
SH_REL = "homelab/scripts/uptime.sh"
MD_REL = "homelab/notes/note.md"
SENTINEL = "UPTIME-PROBE-SENTINEL-9c2f"
#: The mock's SUMMARY_MODE tokenizes the document (``[a-z0-9]+``) — the
#: hyphenated sentinel lands in the digest in its tokenized form.
SENTINEL_TOKENS = "uptime probe sentinel 9c2f"
def _wait_http(url: str, timeout: float = 30.0) -> None:
deadline = time.monotonic() + timeout
last_err = "unknown"
while time.monotonic() < deadline:
try:
httpx.get(url, timeout=2.0)
return
except Exception as e: # noqa: BLE001 — retry until deadline
last_err = str(e)
time.sleep(0.2)
raise RuntimeError(f"mock LLM at {url} did not come up: {last_err}")
@pytest.fixture(scope="module")
def mock_llm_port() -> Iterator[int]:
"""The deterministic mock LLM (``tests/e2e/mock_llm.py``) on a free
scratch port — same server as the e2e ``mock_llm`` fixture, but
private to this file (integration tests otherwise run network-free)."""
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close()
env = dict(os.environ)
env.pop("DEBUGPY", None)
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "tests.e2e.mock_llm:app",
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"http://127.0.0.1:{port}/v1/models")
yield port
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
def _settings(mock_port: int, extensions: str) -> Settings:
kwargs: dict[str, Any] = {
"_env_file": None,
"llm_base_url": f"http://127.0.0.1:{mock_port}/v1",
"import_extensions": extensions,
}
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
def _cleanup_source(db: Session, source: str) -> None:
for doc in db.scalars(select(Document).where(Document.source == source)).all():
db.delete(doc)
db.commit()
def test_novel_extension_imports_end_to_end(mock_llm_port: int, db: Session) -> None:
"""``md,sh`` (a novel extension) imports the ``.sh`` file end to
end: row + plain-text chunks + the mock ``SUMMARY_MODE`` digest,
with the markdown control doc imported as well."""
settings = _settings(mock_llm_port, "md,sh")
assert settings.import_extension_set == {".md", ".sh"}
summary = asyncio.run(import_sources([FIXTURES], LLMClient(settings), session=db))
try:
assert (
summary.files, summary.added, summary.unchanged, summary.updated, summary.errors
) == (2, 2, 0, 0, 0)
assert summary.formats == {"sh": 1, "md": 1}
# The .sh file is non-markdown → exactly one lite summary (phase 30).
assert (summary.summaries, summary.summary_errors) == (1, 0)
sh = db.scalar(
select(Document).where(
Document.source == SOURCE, Document.path == SH_REL
)
)
assert sh is not None, "the novel .sh extension was not imported"
# Non-markdown: the title comes from the file stem (a ``#`` line
# is a comment, not a heading).
assert sh.title == "uptime"
# Plain-text chunking: the content (incl. the sentinel) is
# chunked and every content chunk is embedded at the 768-dim
# contract.
content = [c for c in sh.chunks if not c.is_summary]
assert content, "the .sh file has no content chunks"
assert all(
c.embedding is not None and len(c.embedding) == 768 for c in content
)
assert any(SENTINEL in c.content for c in content)
# Mock SUMMARY_MODE digest: byte-stable, the tokenized sentinel
# inside it, plus the code-appended pointer line.
assert sh.summary is not None
assert sh.summary.startswith("This document covers")
assert SENTINEL_TOKENS in sh.summary
assert f"Source: {SOURCE}/{SH_REL}" in sh.summary
schunks = [c for c in sh.chunks if c.is_summary]
assert len(schunks) == 1 and schunks[0].position == -1
assert schunks[0].embedding is not None
# The markdown control doc imported too — but markdown never
# gets a summary (phase 30).
note = db.scalar(
select(Document).where(
Document.source == SOURCE, Document.path == MD_REL
)
)
assert note is not None
assert note.summary is None
assert [c for c in note.chunks if not c.is_summary]
finally:
_cleanup_source(db, SOURCE)
def test_narrowing_to_md_still_excludes_the_novel_extension(
mock_llm_port: int, db: Session
) -> None:
"""``md`` (the A9-era narrowing, preserved as a special case): the
``.sh`` file is out of scope, only the control note imports."""
settings = _settings(mock_llm_port, "md")
assert settings.import_extension_set == {".md"}
summary = asyncio.run(import_sources([FIXTURES], LLMClient(settings), session=db))
try:
assert (
summary.files, summary.added, summary.unchanged, summary.updated, summary.errors
) == (1, 1, 0, 0, 0)
assert summary.formats == {"md": 1}
assert summary.summaries == 0
assert db.scalar(
select(Document).where(
Document.source == SOURCE, Document.path == SH_REL
)
) is None
assert db.scalar(
select(Document).where(
Document.source == SOURCE, Document.path == MD_REL
)
) is not None
finally:
_cleanup_source(db, SOURCE)
+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",
"/history.html",
"/shared.html", # phase 51: the shared page's static path
"/doc-edit.html", # phase 59: the doc edit screen (task 06)
):
assert path in caching.HTML_PAGES, f"{path} must be in HTML_PAGES"
+160 -25
View File
@@ -8,7 +8,7 @@ import pytest
from pydantic import ValidationError
from pydantic_settings import SettingsError
from app.config import _ALLOWED_IMPORT_EXTENSIONS, Settings # pyright: ignore[reportPrivateUsage]
from app.config import _DEFAULT_IMPORT_EXTENSIONS, Settings # pyright: ignore[reportPrivateUsage]
def _settings(**kwargs: Any) -> Settings:
@@ -57,15 +57,15 @@ NEW_A9_FORMATS = (
)
def test_allowed_import_extensions_contains_all_seventeen_formats() -> None:
"""The validator's base set is the full A9 set: the original seven
plus the ten added 2026-08-27 (quadlet family + ``j2``). The
never-widen contract bounds :py:data:`import_extensions` against
exactly this set."""
def test_default_import_extensions_is_the_full_a9_family() -> None:
"""Phase 56: the built-in default is the full A9 set — the original
seven plus the ten added 2026-08-27 (quadlet family + ``j2``). It is
the default and the ``.env.example`` example, NOT a ceiling: the
validator accepts any well-formed extension beyond it."""
assert {
"md", "markdown", "txt", "yaml", "yml", "json", "py",
*NEW_A9_FORMATS,
} == _ALLOWED_IMPORT_EXTENSIONS
} == _DEFAULT_IMPORT_EXTENSIONS
def test_default_import_extensions_include_the_ten_new_formats() -> None:
@@ -151,34 +151,51 @@ def test_import_extensions_env_override_is_a_csv_list(monkeypatch) -> None:
assert s.import_extension_set == {".md", ".yml"}
def test_import_extensions_rejects_unknown_format(monkeypatch) -> None:
"""A typo in the CSV fails at startup (loudly), not by silently
walking zero files."""
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,docx")
with pytest.raises(ValidationError, match="docx"):
_settings()
def test_import_extensions_accepts_novel_extension(monkeypatch) -> None:
"""Phase 56 (owner permission 2026-08-31): the A9 family is the
default, not the ceiling — a novel well-formed extension (``sh``) is
accepted and simply becomes importable."""
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,sh")
s = _settings()
assert s.import_extension_set == {".md", ".sh"}
def test_import_extensions_rejects_empty(monkeypatch) -> None:
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", " ")
with pytest.raises(ValidationError):
_settings()
def test_import_extensions_normalizes_case_and_leading_dot(monkeypatch) -> None:
"""Case and a leading dot are both tolerated (unchanged tolerance)."""
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "MD,.Py")
s = _settings()
assert s.import_extension_set == {".md", ".py"}
def test_import_extensions_rejects_empty(monkeypatch: pytest.MonkeyPatch) -> None:
"""A blank list would silently import nothing — fail loudly at
startup, naming the field (empty, whitespace-only, and comma-only
all parse to zero formats)."""
for value in ("", " ", ",,"):
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", value)
with pytest.raises(ValidationError, match="import_extensions"):
_settings()
def test_import_extensions_validator_accepts_new_a9_formats(monkeypatch) -> None:
"""A9 revised 2026-08-27: the new names are first-class — the
never-widen contract now holds against the widened base set, so a
narrowing CSV with quadlet/jinja names is accepted."""
"""A9 revised 2026-08-27: quadlet/jinja names are first-class default
formats — a CSV using them (a narrowing of the default family) is
accepted."""
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,container,j2")
s = _settings()
assert s.import_extension_set == {".md", ".container", ".j2"}
def test_import_extensions_validator_still_rejects_unknown(monkeypatch) -> None:
"""Truly unknown extensions still fail loudly at startup (the
validator is intact — only the allowed base set widened)."""
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,xyz")
with pytest.raises(ValidationError, match="xyz"):
def test_import_extensions_rejects_malformed_tokens(monkeypatch: pytest.MonkeyPatch) -> None:
"""The shape guard (``^[a-z0-9]{1,16}$``) is the typo guard — it
keeps punctuation and path-ish values out of the set, naming the
offending token(s), while any extension a file could actually be
suffixed with still goes through."""
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,sh!")
with pytest.raises(ValidationError, match="sh!"):
_settings()
monkeypatch.setenv("BOR_IMPORT_EXTENSIONS", "md,../x")
with pytest.raises(ValidationError, match=r"/x"):
_settings()
@@ -262,3 +279,121 @@ def test_effective_api_key_fallback(monkeypatch) -> None:
monkeypatch.setenv("AIPI_KEY", "sk-from-env")
s2 = _settings()
assert s2.effective_api_key == "sk-from-env"
# --- Docs push (phase 59) ---
def test_docs_push_defaults_are_inert() -> None:
"""Phase 59, D3: no docs repo by default — the feature is
inert-by-default (button hidden, push endpoint 409s — the
optional-feature pattern of the git-sources env fallback), and the
branch/base defaults + raw work-dir string are in place."""
s = _settings()
assert s.docs_repo == ""
assert s.docs_configured is False
assert s.docs_branch == "bor-docs"
assert s.docs_base_branch == "main"
# Raw string on purpose — Path.expanduser() is applied by the push
# service, not the setting (the sources_dir/upload_dir convention).
assert s.docs_work_dir == "~/bor-docs"
def test_docs_repo_set_is_configured(monkeypatch: pytest.MonkeyPatch) -> None:
"""A non-empty ``BOR_DOCS_REPO`` turns the feature on — a URL or a
local path (D3: generic remote, no scheme parsing here)."""
for repo in ("/path/to/docs-repo", "https://git.example.com/docs.git"):
monkeypatch.setenv("BOR_DOCS_REPO", repo)
s = _settings()
assert s.docs_configured is True
assert s.docs_repo == repo
# Whitespace-only behaves like empty: still inert.
monkeypatch.setenv("BOR_DOCS_REPO", " ")
assert _settings().docs_configured is False
def test_docs_branch_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("BOR_DOCS_BRANCH", raising=False)
monkeypatch.delenv("BOR_DOCS_BASE_BRANCH", raising=False)
assert _settings().docs_branch == "bor-docs"
assert _settings().docs_base_branch == "main"
monkeypatch.setenv("BOR_DOCS_BRANCH", "docs-pr")
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "master")
s = _settings()
assert s.docs_branch == "docs-pr"
assert s.docs_base_branch == "master"
def test_docs_work_dir_env_override_is_raw_string(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("BOR_DOCS_WORK_DIR", "/data/bor/docs")
s = _settings()
assert s.docs_work_dir == "/data/bor/docs"
def test_docs_branch_whitespace_fails_loudly_when_repo_set(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A whitespace-bearing branch would corrupt a ``git checkout``
argument — fail loud at startup, naming the field (the
``agent_max_rounds`` pattern)."""
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
monkeypatch.setenv("BOR_DOCS_BRANCH", "bor docs")
with pytest.raises(ValidationError, match="docs_branch"):
_settings()
def test_docs_branch_dotdot_fails_loudly_when_repo_set(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``..`` is a path-traversal token, never part of a branch name.
A blank branch is rejected too (empty while a repo is set)."""
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
monkeypatch.setenv("BOR_DOCS_BRANCH", "a..b")
with pytest.raises(ValidationError, match="docs_branch"):
_settings()
monkeypatch.setenv("BOR_DOCS_BRANCH", " ")
with pytest.raises(ValidationError, match="docs_branch"):
_settings()
def test_docs_base_branch_invalid_fails_loudly_naming_field(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The base branch gets the same token shape check — the error
names ``docs_base_branch``, not the sibling field."""
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "bad branch")
with pytest.raises(ValidationError, match="docs_base_branch"):
_settings()
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "a..b")
with pytest.raises(ValidationError, match="docs_base_branch"):
_settings()
def test_docs_branchs_valid_when_repo_set(monkeypatch: pytest.MonkeyPatch) -> None:
"""Repo set + well-formed branch tokens boot cleanly and the
feature is configured (dash/dot/slash branch names are legal git
refs and stay accepted)."""
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
s = _settings() # defaults bor-docs / main
assert s.docs_configured is True
monkeypatch.setenv("BOR_DOCS_BRANCH", "feature/docs-update")
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "develop")
s2 = _settings()
assert s2.docs_configured is True
assert s2.docs_branch == "feature/docs-update"
assert s2.docs_base_branch == "develop"
def test_docs_branchs_garbage_ignored_when_repo_unset(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""All-or-nothing: while the repo is empty the feature is inert, so
the (ignored) branch values must NOT block startup — only a
configured repo makes the shape check apply."""
monkeypatch.delenv("BOR_DOCS_REPO", raising=False)
monkeypatch.setenv("BOR_DOCS_BRANCH", "bor docs..")
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "..")
s = _settings()
assert s.docs_configured is False
assert s.docs_branch == "bor docs.." # stored verbatim, never used
+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",
"history.html", # phase 50: the admin saved-chats page
"shared.html", # phase 51: the anonymous shared-conversation page
"doc-edit.html", # phase 59: the admin doc edit screen (flow page)
)
+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")
def test_run_captures_and_returns_stdout(
def test_run_git_captures_and_returns_stdout(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""_run returns the captured stdout on success (git output is not lost)."""
"""run_git returns the captured stdout on success (git output is not lost)."""
calls = _fake_run(monkeypatch, stdout="From example.com\n + abc..def main")
assert git_sync._run(["git", "status"], cwd=tmp_path) == "From example.com\n + abc..def main"
assert git_sync.run_git(["git", "status"], cwd=tmp_path) == "From example.com\n + abc..def main"
assert len(calls) == 1
+33
View File
@@ -44,3 +44,36 @@ def test_documents_unique_source_path() -> None:
and {col.name for col in c.columns} == {"source", "path"}
]
assert uq, "documents must be unique on (source, path) — the upsert key"
def test_doc_drafts_token_is_unique_not_null() -> None:
"""Phase 59: the draft's URL credential — an unguessable uuid4,
UNIQUE + NOT NULL (no "un-drafted" state, unlike the NULLable
``saved_chats.share_token``)."""
drafts = Base.metadata.tables["doc_drafts"]
token = drafts.c["token"]
assert token.nullable is False, "doc_drafts.token must be NOT NULL"
uq = [
c
for c in drafts.constraints
if isinstance(c, UniqueConstraint)
and {col.name for col in c.columns} == {"token"}
]
assert uq, "doc_drafts must be unique on (token) — the URL credential"
def test_doc_drafts_column_contract() -> None:
"""Phase 59: the editable triple (title/path/body) + status +
timestamps are NOT NULL; ``branch`` / ``commit_sha`` are NULL
until the push endpoint records them."""
drafts = Base.metadata.tables["doc_drafts"]
assert set(drafts.c.keys()) == {
"id", "token", "title", "path", "body", "status",
"branch", "commit_sha", "created_at", "updated_at",
}
for name in ("title", "path", "body", "status", "created_at", "updated_at"):
assert drafts.c[name].nullable is False, f"{name} must be NOT NULL"
for name in ("branch", "commit_sha"):
assert drafts.c[name].nullable is True, f"{name} must be NULL until pushed"
assert drafts.c["status"].default is not None, "status needs an ORM default (draft)"
assert drafts.c["token"].default is not None, "token needs an ORM default (uuid4)"
+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)
+14 -6
View File
@@ -19,7 +19,8 @@ silent regression is caught without a browser:
chat page's interactive builders, and the rendered messages contain
no button/form/link — the chips are plain spans, the source chips
carry no ``href``;
* the shared shell's 46rem column mapping + the static-chip and
* the shared shell's reading-column mapping (--chat-column token,
phase 58) + the static-chip and
invalid-state CSS (the ≤640px squeeze included).
"""
from __future__ import annotations
@@ -378,15 +379,22 @@ def test_brand_note_resolves_at_call_time() -> None:
# ---------- styles.css: the shared page ----------
def test_shared_shell_maps_to_the_46rem_column() -> None:
"""The PLAN §7 column contract: .shared-shell is the centered
46rem chat column (the conversation reads exactly like the chat
page's, so the existing .msg/.bubble CSS applies unchanged)."""
def test_shared_shell_maps_to_the_reading_column() -> None:
"""The PLAN §7 column contract (phase 58): .shared-shell is the
centered chat column riding the --chat-column token (46rem base,
92rem at >=1500px wide desktops — owner instruction 2026-08-31,
TODO L5 / D2). The conversation reads exactly like the chat
page's, so the existing .msg/.bubble CSS applies unchanged."""
css = _css()
block = re.search(r"\.shared-shell \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .shared-shell"
body = block.group(1)
assert "max-width: 46rem" in body, "the PLAN §7 centered chat column"
assert "max-width: var(--chat-column)" in body, (
"the PLAN §7 centered chat column (phase 58 token)"
)
assert "46rem" not in body.split("/*")[0], (
"no hard-coded cap — the width rides the token"
)
assert "margin-inline: auto" in body, "centered"
assert "display: flex" in body and "flex-direction: column" in body
+148
View File
@@ -0,0 +1,148 @@
"""Unit: the sticky-header contract (phase 60, TODO L3).
The browser behavior itself is E2E-gated by the phase-60 story suite
(task 02); like the other frontend-adjacent unit files, this module
pins the CSS markers the sticky contract depends on, so a silent
regression is caught without a browser:
* the ROOT CAUSE (owner-locked A1) is the ``body { height: 100% }``
cap — a sticky element's travel range is constrained to its
containing block, and the fixed body height pinned the box to one
viewport, so ``.app-header`` / ``.doc-header`` un-pinned after
~1 viewport of scroll. The FIX (owner-locked A2) is CSS-only:
``html`` keeps ``height: 100%`` (harmless viewport baseline) and
``body`` carries NO ``height:`` declaration — its existing
``min-height: 100dvh`` keeps driving the short-page stretch (the
phase-52 flex-stretch / pinned-composer / footer contract);
* ``.app-header`` (every page) and ``.doc-header`` (document viewer)
keep ``position: sticky; top: 0`` — these pins guard against a
future "simplification" that would drop the sticky rule that was
always the intent.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _block(css: str, selector: str) -> str:
"""The declaration body of the FIRST top-level ``<selector> { … }``
rule. Line-anchored on purpose: the phase-60 provenance comment
quotes ``body { height: 100% }`` literally, so a mid-line match
would capture the comment instead of the rule (real rules start at
column 0)."""
m = re.search(r"(?m)^" + re.escape(selector) + r" \{([\s\S]*?)\n\}", css)
assert m, f"styles.css must style {selector}"
return m.group(1)
# ---------- the root-cause cap is gone (A1 → A2) ----------
def test_body_height_cap_is_gone_from_the_rule() -> None:
"""Phase 60 (A2): the exact old rule ``html, body { height: 100%;
}`` is GONE from styles.css, and only ``html`` carries
``height: 100%`` now (the harmless viewport baseline stays on the
canvas element)."""
css = _css()
assert "html, body { height: 100%; }" not in css, (
"the combined html,body height rule must be gone"
)
assert "html { height: 100%; }" in css, ("html keeps height: 100%")
def test_html_rule_carries_the_phase_60_provenance_comment() -> None:
"""The replacement comment cites the phase 60 provenance (owner
confirmation 2026-08-31, TODO L3) and names the mechanism — the
sticky travel range is capped by the containing block, and
min-height: 100dvh is what stretches short pages."""
css = _css()
i = css.find("html { height: 100%; }")
assert i != -1, "the html height rule must exist"
comment = css[max(0, i - 900) : i]
assert "Phase 60" in comment, "the comment cites the phase 60 provenance"
assert "2026-08-31" in comment, "the comment cites the owner confirmation date"
assert "TODO L3" in comment, "the comment cites the TODO line"
assert "min-height: 100dvh" in comment, (
"the comment names the short-page stretch driver"
)
def test_body_rule_has_no_height_but_keeps_min_height() -> None:
"""The ``body { … }`` rule: NO ``height:`` declaration (the A1 cap
must never come back) and ``min-height: 100dvh`` intact — the
flex-column stretch driver the phase-52 short-page layout (footer
at the viewport bottom, pinned composer) depends on. The
declaration list is otherwise UNCHANGED (the flex column
properties stay)."""
css = _css()
body = _block(css, "body")
assert not re.search(r"(?m)^\s*height\s*:", body), (
"the body rule must carry NO height declaration"
)
assert "min-height: 100dvh" in body, ("the stretch driver stays on body")
for prop in (
"margin: 0",
"display: flex",
"flex-direction: column",
"position: relative",
"background: transparent",
):
assert prop in body, f"the body rule keeps its existing {prop}"
# ---------- the sticky rules stay (both surfaces) ----------
def test_app_header_stays_sticky_at_the_top() -> None:
"""``.app-header`` (the navbar on every page) keeps
``position: sticky; top: 0`` (z-index 20, the 64px --header-h
height) and the phase-12 ``flex-shrink: 0`` guard (reworded phase
60: body stretches via min-height: 100dvh; the guard still covers
content-overflow pages, e.g. Sources at ≤640px)."""
css = _css()
header = _block(css, ".app-header")
assert "position: sticky" in header, ".app-header must stay sticky"
assert "top: 0" in header, ".app-header must pin to the top"
assert "z-index: 20" in header
assert "height: var(--header-h)" in header
assert "flex-shrink: 0" in header, "the shrink guard stays"
def test_doc_header_stays_sticky_at_the_top() -> None:
"""``.doc-header`` (the document viewer's two-row header) keeps
``position: sticky; top: 0`` (z-index 20) and the shrink guard —
the same contract as the app header, so BOTH rows stay pinned
while the document scrolls."""
css = _css()
header = _block(css, ".doc-header")
assert "position: sticky" in header, ".doc-header must stay sticky"
assert "top: 0" in header, ".doc-header must pin to the top"
assert "z-index: 20" in header
assert "flex-shrink: 0" in header, "the shrink guard stays"
def test_sticky_pin_comment_mentions_the_stretch_driver() -> None:
"""The reworded phase-12 comment on ``.app-header`` (inside the
rule, above the guard) matches reality: it names
``min-height: 100dvh`` as the body stretch driver (the
"definite-height" wording of the old cap era is gone) and keeps
the content-overflow rationale for the guard."""
css = _css()
comment = _block(css, ".app-header")
assert "definite-height" not in comment, (
"the stale definite-height wording must be gone"
)
assert "min-height: 100dvh" in comment, (
"the reworded comment names the actual stretch driver"
)
assert "flex-shrink" in comment and "Sources" in comment, (
"the guard's rationale (content-overflow pages) stays"
)
+380
View File
@@ -0,0 +1,380 @@
"""Unit: the admin summary-edit affordance in the viewer (phase 57,
task 02).
The browser behavior itself is E2E-gated by the phase-57 story suite;
like the other frontend-adjacent unit files (test_save_chat_ui.py
pattern), this module pins the JS/CSS markers the edit contract depends
on, so a silent regression is caught without a browser:
* the ``docAdminReady()`` gate — the module-cached /api/whoami promise
(header.js's ``fetchIsAdmin``, the SAME single request per page the
shared header makes — no second whoami call site in document.js);
non-admin / fetch failure → NO button, NO wiring (the public viewer
is byte-for-byte the phase-36 section: the bare ``section.append
(title, body)`` construction stays first, the admin affordance is a
post-render ``.then`` on the gate promise);
* the editor construction — the header row (``.doc-summary-head`` with
the bare h2 + a real ``type="button"`` Edit), the swap-in
``<textarea class="doc-summary-editor">`` prefilled via ``.value``
(never innerHTML — XSS contract), Save / Cancel buttons, and the
``role="status"`` ``aria-live="polite"`` live region;
* the exact PATCH call — ``/api/documents/summary`` with method PATCH
and the ``{source, path, summary}`` body (the pair from the doc
object — the same values the modal core carries);
* the outcomes — success re-renders the text node via ``textContent``
("Summary updated."), an empty save that clears removes the panel
("Summary cleared." — the renderer only draws it for non-empty
summaries), Cancel restores the text node, and a failure (non-ok OR
network) keeps the editor open with the user's text and shows
neutral retry copy (phase-55 convention); the double-click guard
releases in the ``finally`` — never stale;
* styles.css — the five new ``.doc-summary-*`` classes (plus the two
layout wrappers) on the house dark-tech palette (phase-08 tokens),
8rem-min editor, 24px+ edit target, the ``[hidden]`` override,
``:focus-visible`` via the global outline rule, no CDN.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
def _js() -> str:
return DOCUMENT_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 (possibly async, possibly nested) function via
balanced-brace counting (works for top-level and the editor's
inner helpers alike)."""
for prefix in ("async function ", "function "):
start = js.find(f"{prefix}{name}(")
if start != -1:
depth = 0
for i in range(js.find("{", start), len(js)):
if js[i] == "{":
depth += 1
elif js[i] == "}":
depth -= 1
if depth == 0:
return js[start : i + 1]
raise AssertionError(f"unbalanced braces in {name}()")
raise AssertionError(f"{name}() must exist in document.js")
# ---------- the admin gate (D4: the viewer stays public) ----------
def test_doc_admin_ready_wraps_the_cached_whoami_promise() -> None:
"""docAdminReady() exists and resolves the module-cached whoami
promise (header.js's fetchIsAdmin — one request per page, shared
with initSharedHeader) to a strict boolean: a non-admin OR any
fetch failure resolves false (the anonymous viewer)."""
js = _js()
body = _fn(js, "docAdminReady")
assert "await fetchIsAdmin()" in body, (
"the gate must reuse the cached whoami promise (no new call site)"
)
assert "=== true" in body, "a strict boolean — only an authenticated admin"
assert "} catch {" in body and "return false" in body, (
"any failure resolves false — the anonymous viewer"
)
def test_no_second_whoami_call_site_in_document_js() -> None:
"""document.js never fetches /api/whoami itself: header.js's
fetchIsAdmin is the SINGLE whoami call site for the whole frontend
(the cached promise), so the gate adds no request of its own — and
no admin-only network call exists for anonymous visitors (the only
admin call, the PATCH, lives inside the wired editor)."""
js = _js()
assert 'fetch("/api/whoami")' not in js, (
"whoami must come from the header.js cached promise"
)
assert 'from "./header.js"' in js and "fetchIsAdmin" in js
def test_gate_runs_after_the_phase36_base_construction() -> None:
"""The .doc-summary section is built for EVERYONE exactly as phase
36 (the anonymous byte-for-byte shape): the base construction
(className, the bare h2 label, the .doc-summary-text node, the
append) precedes the gate call, and the admin wiring runs ONLY in
the gate's success branch (``if (admin) wireSummaryEdit(...)``)."""
js = _js()
base = js.find('section.className = "doc-summary"')
label = js.find('title.textContent = "Summary"')
text_node = js.find('body.className = "doc-summary-text"')
append = js.find("section.append(title, body)")
mount = js.find("contentEl.appendChild(section)")
gate = js.find("void docAdminReady().then(")
wiring = js.find("if (admin) wireSummaryEdit(section, doc);")
assert 0 < base < label < text_node < append < mount < gate < wiring, (
"phase-36 base construction first; the admin affordance is a "
"post-render gate branch (anonymous DOM is never touched)"
)
assert "wireSummaryEdit(section, doc)" in js[gate:]
# ---------- the editor construction ----------
def test_edit_button_is_a_real_button_in_the_header_row() -> None:
"""The Edit affordance: a real ``type="button"`` with the visible
text "Edit" and the .doc-summary-edit class, added to a
.doc-summary-head row that keeps the bare h2 label (label left,
button right — the anonymous section keeps its bare h2)."""
body = _fn(_js(), "wireSummaryEdit")
assert 'editBtn.type = "button"' in body
assert 'editBtn.className = "doc-summary-edit"' in body
assert 'editBtn.textContent = "Edit"' in body
assert 'head.className = "doc-summary-head"' in body
assert "head.append(title, editBtn)" in body
# The header row REPLACES the bare h2 as the section's first child.
assert "section.replaceChildren(head, body)" in body
def test_editor_swap_builds_textarea_save_cancel_and_live_region() -> None:
"""Edit swaps the .doc-summary-text node for the inline editor:
a <textarea class="doc-summary-editor"> prefilled via ``.value``
(NEVER innerHTML — the XSS contract), Save / Cancel real
type=buttons, and a <p class="doc-summary-status" role="status"
aria-live="polite"> live region. The Edit button hides while the
editor is open (no re-open mid-edit) and the textarea gets focus.
The ENTIRE wiring is textContent/.value-only — no innerHTML
anywhere (summary text is user-storable)."""
js = _js()
body = _fn(js, "wireSummaryEdit")
assert 'editor.className = "doc-summary-editor"' in body
assert (
'editor.value = typeof doc.summary === "string" ? doc.summary : ""' in body
), "prefill via .value — value, not innerHTML"
assert 'saveBtn.type = "button"' in body
assert 'saveBtn.className = "doc-summary-save"' in body
assert 'saveBtn.textContent = "Save"' in body
assert 'cancelBtn.type = "button"' in body
assert 'cancelBtn.className = "doc-summary-cancel"' in body
assert 'cancelBtn.textContent = "Cancel"' in body
assert 'status.className = "doc-summary-status"' in body
assert 'status.setAttribute("role", "status")' in body
assert 'status.setAttribute("aria-live", "polite")' in body
# The swap: text node out, editor parts in, focus in.
assert "section.replaceChildren(head, editor, actions, status)" in body
assert "editor.focus()" in body
hide = body.find("editBtn.hidden = true")
swap = body.find("section.replaceChildren(head, editor, actions, status)")
focus = body.find("editor.focus()")
assert -1 < hide < swap < focus, "hide Edit → swap → focus the textarea"
# The bindings.
assert 'editBtn.addEventListener("click", openEditor)' in body
assert 'cancelBtn.addEventListener("click", () => closeEditor(""))' in body
# XSS contract: the whole affordance is textContent/.value only
# (comments stripped — the word may appear in a note, never in code).
code = re.sub(r"//.*?$|/\*.*?\*/", "", body, flags=re.S | re.M)
assert "innerHTML" not in code, "XSS contract: no innerHTML in the wiring"
# ---------- the PATCH round-trip ----------
def test_save_patches_the_exact_endpoint_with_the_doc_pair() -> None:
"""Save → PATCH /api/documents/summary (the phase-57 admin
endpoint) with the EXACT body shape {source, path, summary} — the
pair from the doc object (the same values the modal core carries),
JSON content type. This is the ONLY admin-only call in document.js
(exactly one call site, inside the wired editor — anonymous
visitors never have it)."""
js = _js()
assert js.count('fetch("/api/documents/summary"') == 1, (
"exactly one PATCH call site (inside wireSummaryEdit)"
)
body = _fn(js, "wireSummaryEdit")
fetch_i = body.find('fetch("/api/documents/summary"')
assert fetch_i != -1, "the PATCH must live in the wired editor"
assert 'method: "PATCH"' in body[fetch_i:]
assert '"Content-Type": "application/json"' in body[fetch_i:]
assert (
"JSON.stringify({ source: doc.source, path: doc.path, summary: value })"
in body
), "the exact body shape: {source, path, summary}"
def test_save_success_rerenders_text_node_and_announces() -> None:
"""A 200 update syncs the doc object (a later re-open prefills the
CURRENT summary), announces "Summary updated." through
closeEditor — which re-renders the text node via textContent ONLY
(XSS contract) from the doc object."""
body = _fn(_js(), "wireSummaryEdit")
ok_i = body.find("if (!res.ok)")
json_i = body.find("await res.json()")
null_i = body.find("if (data.summary === null)")
sync_i = body.find("doc.summary = data.summary")
announce_i = body.find('closeEditor("Summary updated.")')
assert -1 < ok_i < json_i < null_i < sync_i < announce_i, (
"non-ok checked first → JSON → clear branch → doc sync → announce"
)
close = _fn(body, "closeEditor")
assert "body.textContent = doc.summary" in close, (
"the display state re-renders the text node from the doc object"
)
def test_empty_save_clears_and_removes_the_panel() -> None:
"""An empty save that clears (response summary === null, D4) syncs
the doc object, announces "Summary cleared." in the live region,
and removes the panel a short beat LATER (setTimeout — the
confirmation stays readable before the panel leaves the DOM; the
renderer only draws it for non-empty summaries). Failures keep the
panel (their slices carry no removal)."""
body = _fn(_js(), "wireSummaryEdit")
null_i = body.find("if (data.summary === null)")
sync_i = body.find("doc.summary = null")
announce_i = body.find('status.textContent = "Summary cleared."')
remove_i = body.find("setTimeout(() => section.remove(), 2000)")
assert -1 < null_i < sync_i < announce_i < remove_i, (
"the null branch: sync → announce → delayed removal"
)
# Exactly two removals in the whole affordance, both tied to a
# CLEARED state (the clear branch + the closeEditor empty guard —
# no failure path removes the panel).
assert body.count("section.remove()") == 2
def test_cancel_restores_the_text_node() -> None:
"""Cancel restores the display state: the .doc-summary-text node
back in the section, re-rendered from the doc object (the CURRENT
stored summary — the node was never mutated, only swapped out),
the (empty) live region kept, and the Edit button un-hidden +
re-focused (focus returns to the opener). A summary that is GONE
(a clear landed while the editor was open — Cancel right after a
successful empty save) never renders an empty panel: the guard
drops the panel instead."""
body = _fn(_js(), "wireSummaryEdit")
close = _fn(body, "closeEditor")
assert 'status.textContent = message' in close
assert "editBtn.hidden = false" in close
guard = 'typeof doc.summary !== "string" || doc.summary.trim() === ""'
guard_i = close.find(guard)
remove_i = close.find("section.remove()")
sync_i = close.find("body.textContent = doc.summary")
restore_i = close.find("section.replaceChildren(head, body, status)")
focus_i = close.find("editBtn.focus()")
assert -1 < guard_i < remove_i < sync_i < restore_i < focus_i, (
"empty guard first; otherwise re-render → restore → focus"
)
assert 'cancelBtn.addEventListener("click", () => closeEditor(""))' in body
def test_failure_keeps_the_editor_with_neutral_copy() -> None:
"""A failed Save (non-ok HTTP OR network) keeps the editor open
with the user's text (no swap back, no panel removal) and shows
neutral retry copy (phase-55 convention — no sign-in wording):
"…try again." for a non-ok response, "…is the app reachable?" for
the network path."""
body = _fn(_js(), "wireSummaryEdit")
nonok = body.find("if (!res.ok)")
neutral = body.find("Couldn't update the summary — try again.")
assert -1 < nonok < neutral, "the non-ok branch lands on the neutral copy"
catch_i = body.find("} catch {")
reachable = body.find("Couldn't update the summary — is the app reachable?")
assert -1 < catch_i < reachable, "the network path carries the reachable? copy"
assert "signed in" not in body, "no sign-in wording (neutral retry copy)"
# The two FAILURE branches (the non-ok early return and the network
# catch) never restore or remove — the editor stays open with the
# user's text (only the clear branch removes the panel).
nonok_slice = body[nonok:body.find("await res.json()")]
assert "section.remove()" not in nonok_slice
assert "closeEditor" not in nonok_slice
catch_slice = body[catch_i:body.find("finally")]
assert "section.remove()" not in catch_slice
assert "closeEditor" not in catch_slice
def test_save_double_click_guard_releases_in_finally() -> None:
"""One PATCH at a time: Save disables itself BEFORE the fetch and
re-enables in the ``finally`` (every outcome — success, clear,
non-ok, network — never leaves a stale disabled button, PLAN §7.4)."""
body = _fn(_js(), "wireSummaryEdit")
disable_i = body.find("saveBtn.disabled = true")
fetch_i = body.find('fetch("/api/documents/summary"')
finally_i = body.find("finally")
enable_i = body.find("saveBtn.disabled = false")
assert -1 < disable_i < fetch_i < finally_i < enable_i, (
"disable before the fetch; re-enable in the finally"
)
assert body.count("saveBtn.disabled = true") == 1
assert body.count("saveBtn.disabled = false") == 1
# ---------- styles.css ----------
def test_new_summary_edit_classes_present() -> None:
"""styles.css carries the five task-named .doc-summary-* classes
(plus the two layout wrappers the wiring emits) — the house
dark-tech palette (phase-08 tokens), system fonts, no CDN."""
css = _css()
for cls in (
".doc-summary-edit",
".doc-summary-editor",
".doc-summary-save",
".doc-summary-cancel",
".doc-summary-status",
".doc-summary-head",
".doc-summary-actions",
):
assert f"{cls} " in css or f"{cls}." in css or f"{cls}[" in css, (
f"styles.css must style {cls}"
)
assert "url(http" not in css and "@import url(" not in css, (
"no CDN (AGENTS.md rule 6)"
)
def test_summary_edit_css_targets_and_palette() -> None:
"""The house AA palette on the edit affordance: the edit target is
24px+ with a 3px global-outline focus (the global :focus-visible
rule — no local override needed); the editor is a full-width block
textarea with the 8rem min-height; the save pill is the solid
brand family (--bg on --brand = 5.2:1, AA, borderless); the ghost
buttons ride the ink-soft 5.1:1-on-surface pair; the status line
is ink-soft (AA). The [hidden] override must beat the edit
button's display rule (the editor hides Edit while open)."""
css = _css()
edit = css[css.find(".doc-summary-edit {") :]
edit = edit[: edit.find("\n}")]
assert "min-height: 24px" in edit, "the 24px+ edit target (task 02)"
assert "var(--line)" in edit and "var(--ink-soft)" in edit
hidden = css.find(".doc-summary-edit[hidden]")
assert hidden != -1 and "display: none" in css[hidden : hidden + 80], (
"the hidden attr must beat the base display rule"
)
editor = css[css.find(".doc-summary-editor {") :]
editor = editor[: editor.find("\n}")]
for prop in (
"display: block",
"width: 100%",
"min-height: 8rem",
"resize: vertical",
"var(--bg)",
"var(--ink)",
):
assert prop in editor, f".doc-summary-editor must keep {prop}"
save = css[css.find(".doc-summary-save {") :]
save = save[: save.find("\n}")]
assert "background: var(--brand)" in save and "color: var(--bg)" in save
assert "border: 0" in save, "the solid brand pill family (Save/Share)"
status = css[css.find(".doc-summary-status {") :]
status = status[: status.find("\n}")]
assert "var(--ink-soft)" in status, "AA status copy on --surface (5.1:1)"
# :focus-visible via the GLOBAL 3px outline rule (no local
# suppression anywhere for these controls).
assert ":focus-visible {" in css
assert "outline: 3px solid var(--brand)" in css
+204
View File
@@ -0,0 +1,204 @@
"""Unit: the 2x reading column on wide desktops (phase 58, task 01).
The measured-width browser proof is E2E-gated by the phase-58 story
suite (task 02); like the other frontend-adjacent unit files (the
test_save_chat_ui.py pattern), this module pins the styles.css markers
the wide-column contract depends on, so a silent regression is caught
without a browser:
* the ``--chat-column`` custom property in ``:root`` — 46rem base
(the PLAN §7 column lineage) with the provenance comment (owner
instruction 2026-08-31, TODO L5 / D2);
* the ``@media (min-width: 1500px)`` block at the bottom of the
responsive region — the SINGLE place that doubles the token to
92rem (2x);
* the four reading-column selectors — ``.chat-shell``,
``.shared-shell``, ``.doc-md``, ``.doc-summary:has(+ .doc-md)`` —
each capped with ``max-width: var(--chat-column)`` and NOTHING else
in the file uses the token (exactly four rules);
* the negative pin — the form columns (``.tuning-shell``; and from
phase 59, task 06, ``.doc-edit-shell`` — forms, not reading
surfaces) are the only literal ``max-width: 46rem`` rules left in
the file, kept hard-coded so the wide-desktop doubling never
stretches a form;
* the "46rem column contract" block comments were updated to name the
base value + the wide override (the stale "≤46rem" contract claims
are gone from the reading-column comments).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _rule_block(css: str, selector: str) -> str:
"""The declaration block of a top-level (or nested) rule: the
``{…}`` following ``selector`` via balanced-brace counting."""
m = re.search(rf"^{re.escape(selector)} \{{", css, re.MULTILINE)
assert m, f"styles.css must define a rule for {selector}"
start = css.index("{", m.start())
depth = 0
for i in range(start, len(css)):
if css[i] == "{":
depth += 1
elif css[i] == "}":
depth -= 1
if depth == 0:
return css[start : i + 1]
raise AssertionError(f"unbalanced braces in the {selector} rule")
# ---------- the --chat-column token ----------
def test_root_declares_chat_column_46rem_base() -> None:
""":root declares --chat-column: 46rem (the PLAN §7 base) with the
owner-provenance comment (instruction 2026-08-31, TODO L5)."""
css = _css()
root = _rule_block(css, ":root")
assert "--chat-column: 46rem" in root, (
":root must declare the --chat-column base (46rem)"
)
pre = css[: css.index("--chat-column: 46rem")]
comment = pre[pre.rindex("/*") : pre.rindex("*/")]
assert "owner instruction 2026-08-31" in comment, (
"the token's comment must cite the owner instruction "
"(2026-08-31, TODO L5)"
)
def test_wide_media_block_doubles_the_token() -> None:
"""A @media (min-width: 1500px) block sets --chat-column: 92rem on
:root — the single wide override (2x the base)."""
css = _css()
m = re.search(r"@media \(min-width: 1500px\) \{", css)
assert m, "styles.css must carry the @media (min-width: 1500px) block"
start = css.index("{", m.start())
depth = 0
for i in range(start, len(css)):
if css[i] == "{":
depth += 1
elif css[i] == "}":
depth -= 1
if depth == 0:
block = css[m.start() : i + 1]
break
else:
raise AssertionError("unbalanced braces in the wide media block")
assert ":root { --chat-column: 92rem; }" in block, (
"the wide block must set :root { --chat-column: 92rem; }"
)
# The wide block is the ONLY min-width:1500 media in the file and
# the only place 92rem is assigned to the token.
assert css.count("@media (min-width: 1500px)") == 1
assert css.count("--chat-column: 92rem") == 1
def test_wide_block_lives_in_the_bottom_responsive_region() -> None:
"""The min-width sibling sits alongside the max-width responsive
blocks at the bottom of the file (after the <=640px block)."""
css = _css()
wide = css.index("@media (min-width: 1500px)")
mobile = css.rindex("@media (max-width: 640px)")
assert wide > mobile, (
"the wide override belongs in the bottom media-query region"
)
# ---------- the four reading-column selectors ----------
def test_the_four_reading_columns_use_the_token() -> None:
""".chat-shell, .shared-shell, .doc-md and
.doc-summary:has(+ .doc-md) each cap with
max-width: var(--chat-column) — and exactly those four rules use
the token (no other selector)."""
css = _css()
for selector in (
".chat-shell",
".shared-shell",
".doc-md",
".doc-summary:has(+ .doc-md)",
):
assert "max-width: var(--chat-column)" in _rule_block(css, selector), (
f"{selector} must cap with max-width: var(--chat-column)"
)
assert css.count("max-width: var(--chat-column)") == 4, (
"exactly the four reading-column selectors use the token"
)
def test_shared_shell_keeps_the_centered_column_comment() -> None:
""".shared-shell's inline comment keeps the "centered chat column"
wording and notes the wide override (task 01 work item)."""
css = _css()
rule = css[css.index(".shared-shell {") : css.index(".shared-shell {") + 400]
assert "the PLAN §7 centered chat column" in rule
assert "92rem at >=1500px" in rule, "the comment must note the wide override"
def test_doc_md_keeps_width_100_under_the_cap() -> None:
""".doc-md stays width:100% under the token cap (the modal's
1100px panel remains its effective ceiling there)."""
assert "width: 100%" in _rule_block(_css(), ".doc-md")
# ---------- the negative pins ----------
def test_tuning_shell_stays_hardcoded_46rem() -> None:
""".tuning-shell (the form column, out of scope) keeps its
hard-coded max-width: 46rem at every width — it never widens."""
css = _css()
tuning = _rule_block(css, ".tuning-shell")
assert "max-width: 46rem" in tuning, (
".tuning-shell must stay hard-coded 46rem (negative pin)"
)
assert "var(--chat-column)" not in tuning, (
".tuning-shell must NOT reference the reading-column token"
)
def test_no_other_hardcoded_46rem_rule_remains() -> None:
"""After the switch, the form columns are the ONLY rules with a
literal max-width: 46rem: .tuning-shell (phase 27) and
.doc-edit-shell (phase 59, task 06 — the doc edit screen is a
FORM column, not a reading column, so it must not ride
--chat-column and phase 58's wide-desktop doubling must never
stretch the form). Every reading column rides the token (the
--chat-column base declaration is the other non-rule occurrence
of 46rem)."""
css = _css()
assert css.count("max-width: 46rem") == 2, (
"only the form columns (.tuning-shell, .doc-edit-shell) may "
"keep a literal max-width: 46rem"
)
assert "max-width: 46rem" in _rule_block(css, ".tuning-shell")
assert "max-width: 46rem" in _rule_block(css, ".doc-edit-shell")
def test_comments_cite_the_wide_override_with_provenance() -> None:
"""The block comments that claimed the "46rem column contract" now
name base 46rem + the 2x wide override, with the owner
instruction (2026-08-31, TODO L5) as the provenance at the token
and the media block."""
css = _css()
# The stale "≤46rem" contract claims are gone from the file.
assert "≤46rem" not in css, (
"the stale '≤46rem' contract wording must be updated"
)
# Provenance at the two authoritative spots (token + wide block).
token_idx = css.index("--chat-column: 46rem")
wide_idx = css.index("@media (min-width: 1500px)")
assert "owner instruction 2026-08-31" in css[max(0, token_idx - 400) : token_idx]
assert "owner instruction 2026-08-31" in css[max(0, wide_idx - 500) : wide_idx]
# The chat-shell comment names base + override.
chat_comment = css[: css.index(".chat-shell {")]
assert "46rem base" in chat_comment and "92rem" in chat_comment