Compare commits
5
Commits
281f3555c3
...
5fa620fde5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fa620fde5 | ||
|
|
725af9fac1 | ||
|
|
7b7a834a1a | ||
|
|
140b97ebf3 | ||
|
|
d94f3d5a52 |
+17
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})();
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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``,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"]
|
||||
@@ -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}"
|
||||
@@ -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"]
|
||||
@@ -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}/")
|
||||
|
||||
@@ -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})"
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
"""Unit: the phase-59 task-06 doc edit screen (``/doc-edit.html``).
|
||||
|
||||
No Python logic exists for this task — the behavior lives in
|
||||
``frontend/doc-edit.html`` + ``frontend/assets/doc-edit.js`` +
|
||||
``styles.css``, and it is E2E-gated by the story suite (task 07). Like
|
||||
the other frontend-adjacent unit files (``test_history_page.py``,
|
||||
``test_save_as_doc_button.py``), this module pins the HTML/JS/CSS
|
||||
markers the edit loop depends on, so a silent regression is caught
|
||||
without a browser:
|
||||
|
||||
* the house shell (AGENTS.md rule 5 + the login.html/shared.html
|
||||
minimal-flow-page lineage): skip-link, the SLIM header (brand +
|
||||
"Back to chat" — no nav), the 46rem base column (hard-coded — a form
|
||||
column, NOT ``--chat-column``), the ``container`` frame;
|
||||
* the form contract: ``#draft-title`` / ``#draft-path`` /
|
||||
``#draft-body`` with visible labels, ``#push-doc-btn`` (the exact
|
||||
"Push to docs branch" copy) + the back link, ``#push-status``
|
||||
(``role="status" aria-live="polite"``) and the hidden ``#push-error``
|
||||
(``role="alert"``);
|
||||
* the admin gate — the ``sources-gate`` pattern, ship-hidden, with the
|
||||
no-JS ``?next=`` fallback (the page is static; the API is the
|
||||
authority — the draft endpoints are admin-only regardless);
|
||||
* the JS: the whoami gate (anonymous branch makes NO ``/api/doc-drafts``
|
||||
call), the token handling (missing → "No draft specified.",
|
||||
non-uuid → "Draft not found." with no fetch), the three API paths
|
||||
(GET draft / PUT edits / POST push — the PUT runs BEFORE the push:
|
||||
the endpoint commits the row, so unsaved edits would push stale
|
||||
text), the §7.4 never-stale lifecycle (disable + "Pushing…",
|
||||
re-enable in the finally), the success line
|
||||
(``Pushed to <branch> — commit <sha7>.``), the failure banner
|
||||
(git's stderr trimmed to its first meaningful lines, fields
|
||||
preserved), and VALUES-not-innerHTML everywhere.
|
||||
|
||||
The Containerfile stage-1 coverage (doc-edit.html copied, doc-edit.js
|
||||
bundled) and the cache-busting registration (``/doc-edit.html`` in
|
||||
``HTML_PAGES``) are pinned by ``test_containerfile_assets.py`` /
|
||||
``test_caching.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
ASSETS = FRONTEND / "assets"
|
||||
DOC_EDIT_HTML = FRONTEND / "doc-edit.html"
|
||||
DOC_EDIT_JS = ASSETS / "doc-edit.js"
|
||||
STYLES_CSS = ASSETS / "styles.css"
|
||||
|
||||
|
||||
def _html() -> str:
|
||||
assert DOC_EDIT_HTML.is_file(), "frontend/doc-edit.html is missing"
|
||||
return DOC_EDIT_HTML.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
assert DOC_EDIT_JS.is_file(), "frontend/assets/doc-edit.js is missing"
|
||||
return DOC_EDIT_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of a top-level ``function <name>(...)`` (to its close)."""
|
||||
start = js.find(f"function {name}(")
|
||||
assert start != -1, f"{name}() must exist in doc-edit.js"
|
||||
return js[start : js.find("\n}\n", start) + 4]
|
||||
|
||||
|
||||
# ---------- the house shell (AGENTS.md rule 5) ----------
|
||||
|
||||
|
||||
def test_page_scaffold_slim_header_and_landmarks() -> None:
|
||||
"""The minimal-flow-page scaffold (the login.html/shared.html
|
||||
lineage): skip-link, the SLIM header (brand + the "Back to chat"
|
||||
link to / — and NO nav: this is a flow page, not one of the app's
|
||||
pages), ``<main id="main" class="app-main" tabindex="-1">`` with
|
||||
the ``container`` frame, and the house footer."""
|
||||
html = _html()
|
||||
assert '<a class="skip-link" href="#main">Skip to content</a>' in html
|
||||
assert 'class="app-header"' in html
|
||||
# The slim header: the brand + the back link.
|
||||
assert '<span class="brand-text">Brain of <strong>Reese</strong></span>' in html
|
||||
back = re.search(r'<a[^>]*class="doc-edit-back"[^>]*href="/"[^>]*>', html)
|
||||
assert back, "the header must carry the 'Back to chat' link to /"
|
||||
assert "<span>Back to chat</span>" in html
|
||||
# And NO nav — the flow-page lineage (no hamburger, no app-nav).
|
||||
assert 'id="app-nav"' not in html, "the slim header ships no nav"
|
||||
assert 'id="nav-toggle"' not in html, "the slim header ships no hamburger"
|
||||
assert "<main id=\"main\" class=\"app-main\" tabindex=\"-1\">" in html
|
||||
assert '<div class="container doc-edit-shell">' in html
|
||||
assert 'class="app-footer"' in html
|
||||
|
||||
|
||||
def test_page_title_and_description() -> None:
|
||||
"""The page's identity: the house title shape (<name> · Brain of
|
||||
Reese) + a description naming the admin-only flow."""
|
||||
html = _html()
|
||||
assert "<title>Edit doc · Brain of Reese</title>" in html
|
||||
desc = re.search(r'<meta name="description" content="([^"]+)"', html)
|
||||
assert desc, "the page must carry a meta description"
|
||||
assert "admin" in desc.group(1).lower()
|
||||
|
||||
|
||||
# ---------- the form contract ----------
|
||||
|
||||
|
||||
def test_form_fields_have_labels_and_ids() -> None:
|
||||
"""The three fields (task 06): #draft-title (text), #draft-path
|
||||
(text), #draft-body (textarea) — each with a VISIBLE
|
||||
``<label for=…>`` (WCAG input-label rule), the text inputs
|
||||
``required`` (the browser's native prompt is the first line of
|
||||
sanity), the body a <textarea>."""
|
||||
html = _html()
|
||||
for field_id, tag in (
|
||||
("draft-title", "input"),
|
||||
("draft-path", "input"),
|
||||
("draft-body", "textarea"),
|
||||
):
|
||||
assert f'<label for="{field_id}">' in html, (
|
||||
f"#{field_id} needs a visible label"
|
||||
)
|
||||
field = re.search(rf"<{tag}[^>]*id=\"{field_id}\"[^>]*>", html)
|
||||
assert field, f"#{field_id} is missing"
|
||||
title = re.search(r"<input[^>]*id=\"draft-title\"[^>]*>", html)
|
||||
path = re.search(r"<input[^>]*id=\"draft-path\"[^>]*>", html)
|
||||
body = re.search(r"<textarea[^>]*id=\"draft-body\"[^>]*>", html)
|
||||
assert title and path and body, "the draft field tags are missing"
|
||||
title, path, body = title.group(0), path.group(0), body.group(0)
|
||||
for f in (title, path, body):
|
||||
assert "required" in f, "the native `required` is the first line"
|
||||
assert "type=\"text\"" in title and "type=\"text\"" in path
|
||||
|
||||
|
||||
def test_push_button_and_back_link_actions() -> None:
|
||||
"""The actions (task 06): #push-doc-btn — the primary, exact copy
|
||||
"Push to docs branch" — and the back link to / (the form's second
|
||||
action; the header carries its own copy)."""
|
||||
html = _html()
|
||||
btn = re.search(r"<button[^>]*id=\"push-doc-btn\"[^>]*>", html)
|
||||
assert btn, "#push-doc-btn is missing"
|
||||
assert "type=\"submit\"" in btn.group(0), (
|
||||
"the push button submits the form (the handler preventDefaults)"
|
||||
)
|
||||
assert ">Push to docs branch</button>" in html, (
|
||||
"the exact house copy: 'Push to docs branch'"
|
||||
)
|
||||
# A back link inside the actions row (href="/").
|
||||
actions = html[html.find('class="doc-edit-actions"'):]
|
||||
actions = actions[: actions.find("</form>")]
|
||||
assert re.search(r'<a[^>]*class="doc-edit-back"[^>]*href="/"[^>]*>', actions), (
|
||||
"the actions row carries its own back link to /"
|
||||
)
|
||||
|
||||
|
||||
def test_feedback_live_region_and_error_banner() -> None:
|
||||
"""The "never stale" feedback contract (phase 55 convention,
|
||||
task 06): #push-status is the polite live region
|
||||
(role="status" aria-live="polite"); #push-error is the alert
|
||||
banner — SHIPS hidden (role="alert")."""
|
||||
html = _html()
|
||||
status = re.search(r'<[a-z]+[^>]*id="push-status"[^>]*>', html)
|
||||
assert status, "#push-status is missing"
|
||||
assert 'role="status"' in status.group(0)
|
||||
assert 'aria-live="polite"' in status.group(0)
|
||||
error = re.search(r'<[a-z]+[^>]*id="push-error"[^>]*>', html)
|
||||
assert error, "#push-error is missing"
|
||||
assert 'role="alert"' in error.group(0)
|
||||
assert "hidden" in error.group(0), "the error banner ships hidden"
|
||||
|
||||
|
||||
# ---------- the admin gate (the sources-gate pattern) ----------
|
||||
|
||||
|
||||
def test_admin_gate_ships_hidden_with_no_js_fallback() -> None:
|
||||
"""The gate: the EXACT .sources-gate pattern (phase 16/35/50),
|
||||
ship-hidden (the admin never sees it; the content div ships hidden
|
||||
too — anonymous-safe), the labelled h2, and the Sign in link whose
|
||||
static ?next= returns the admin to THIS page after login (the
|
||||
no-JS fallback)."""
|
||||
html = _html()
|
||||
gate = re.search(r'<section[^>]*class="sources-gate"[^>]*id="doc-edit-gate"[^>]*>', html)
|
||||
assert gate, "the #doc-edit-gate section (sources-gate pattern) is missing"
|
||||
assert "hidden" in gate.group(0), "the gate ships hidden"
|
||||
assert 'aria-labelledby="doc-edit-gate-title"' in gate.group(0)
|
||||
assert '<h2 id="doc-edit-gate-title">' in html
|
||||
assert '<a class="sources-gate-link" href="/login.html?next=/doc-edit.html">Sign in</a>' in html
|
||||
# The content ships hidden too (the gate is what anonymous sees).
|
||||
content = re.search(r'<div[^>]*id="doc-edit-content"[^>]*>', html)
|
||||
assert content and "hidden" in content.group(0), (
|
||||
"#doc-edit-content must ship hidden (anonymous-safe)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- scripts + no CDN ----------
|
||||
|
||||
|
||||
def test_script_load_order_and_no_cdn() -> None:
|
||||
"""The house script order: brand.js (classic) FIRST, the doc-edit.js
|
||||
module second; NO direct header.js <script> tag (single-evaluation
|
||||
design — doc-edit.js imports it relatively); no external
|
||||
src=/href= (AGENTS.md rule 6 — No CDN)."""
|
||||
html = _html()
|
||||
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
|
||||
assert srcs == ["assets/brand.js", "/assets/doc-edit.js"], (
|
||||
f"doc-edit.html must load brand.js (classic, first) + the "
|
||||
f"doc-edit.js module, got {srcs}"
|
||||
)
|
||||
js = _js()
|
||||
assert 'from "./header.js"' in js, (
|
||||
"doc-edit.js must import the shared header module relatively"
|
||||
)
|
||||
assert '"/assets/header.js"' not in js
|
||||
assert 'src="http' not in html and 'href="http' not in html, (
|
||||
"no CDN: every asset is local (AGENTS.md rule 6)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- boot: the whoami gate ----------
|
||||
|
||||
|
||||
def test_anonymous_boot_makes_no_drafts_request() -> None:
|
||||
"""The whoami gate in the boot IIFE: ``fetchIsAdmin()`` (the
|
||||
header.js cached whoami — the single /api/whoami call site) decides
|
||||
the gate. Anonymous: the gate shows, the content stays hidden, a
|
||||
bare return — and NO /api/doc-drafts call on the wire (the draft
|
||||
API is admin-only regardless; the story E2E pins the request
|
||||
log). Only the admin path reaches the token read + loadDraft."""
|
||||
js = _js()
|
||||
assert "fetchIsAdmin" in js, "the gate must run on the cached whoami"
|
||||
boot = js[js.find("(async () => {"):]
|
||||
assert boot, "the boot IIFE must exist"
|
||||
gate_i = boot.find("const admin = await fetchIsAdmin();")
|
||||
assert gate_i != -1, "boot must await fetchIsAdmin() first"
|
||||
branch = boot[gate_i : boot.find("return;", gate_i)]
|
||||
assert "fetch(" not in branch, (
|
||||
"the anonymous branch must not fetch anything (no draft leak)"
|
||||
)
|
||||
assert "gateEl.hidden = false" in branch
|
||||
assert "contentEl.hidden = true" in branch
|
||||
# The admin path: the gate hides, the content reveals, the token
|
||||
# is read, and only THEN does the draft load.
|
||||
after = boot[boot.find("return;", gate_i):]
|
||||
assert "gateEl.hidden = true" in after
|
||||
assert "contentEl.hidden = false" in after
|
||||
token_i = after.find('new URLSearchParams(window.location.search).get("draft")')
|
||||
assert token_i != -1, "boot must read ?draft=<token>"
|
||||
assert after.find("await loadDraft(token)") > token_i
|
||||
|
||||
|
||||
def test_token_missing_and_malformed_copy() -> None:
|
||||
"""The token handling: missing → the error banner "No draft
|
||||
specified."; a non-uuid token → "Draft not found." with NO fetch
|
||||
(the shared.js malformed-token precedent — a 422 validation line
|
||||
is framework noise, not a house message)."""
|
||||
js = _js()
|
||||
boot = js[js.find("(async () => {"):]
|
||||
missing_i = boot.find('showError("No draft specified.")')
|
||||
assert missing_i != -1, "the missing-token banner copy is pinned"
|
||||
# The uuid shape check gates the fetch (malformed → no request).
|
||||
malformed_i = boot.find("UUID_RE.test(token)")
|
||||
assert malformed_i != -1, "the uuid shape check must gate the fetch"
|
||||
after_malformed = boot[malformed_i : malformed_i + 300]
|
||||
assert 'showError("Draft not found.")' in after_malformed
|
||||
assert "fetch(" not in after_malformed, ("a malformed token must not fetch")
|
||||
# The regex is the 8-4-4-4-12 uuid shape (case-insensitive).
|
||||
assert "/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i" in js
|
||||
# And draftToken (the push's credential) is set only after the
|
||||
# checks — the load follows.
|
||||
assert boot.find("draftToken = token;") > malformed_i
|
||||
assert boot.find("await loadDraft(token)") > boot.find("draftToken = token;")
|
||||
|
||||
|
||||
# ---------- the three API paths ----------
|
||||
|
||||
|
||||
def test_the_three_api_paths() -> None:
|
||||
"""The edit loop's three draft API paths (task 06): GET
|
||||
/api/doc-drafts/<token> (load — in loadDraft), PUT
|
||||
/api/doc-drafts/<token> (persist the edits) and POST
|
||||
/api/doc-drafts/<token>/push (the single mutation). The PUT runs
|
||||
BEFORE the push: the push endpoint commits the ROW's
|
||||
title/path/body, so an unsaved edit would push stale text."""
|
||||
js = _js()
|
||||
load = _fn(js, "loadDraft")
|
||||
assert 'fetch(`/api/doc-drafts/${token}`)' in load, (
|
||||
"loadDraft must GET the draft by token"
|
||||
)
|
||||
assert "push" not in load.lower().replace("pushing", ""), (
|
||||
"loadDraft must not push (it only loads)"
|
||||
)
|
||||
push_fn = js[js.find("function wirePush() {"):]
|
||||
put_i = push_fn.find('method: "PUT"')
|
||||
post_i = push_fn.find("/push")
|
||||
assert put_i != -1 and post_i != -1, "the PUT + the POST /push must both exist"
|
||||
assert put_i < post_i, "the PUT (persist edits) must run BEFORE the push"
|
||||
assert 'fetch(`/api/doc-drafts/${draftToken}/push`, {' in push_fn, (
|
||||
"the push endpoint is POST /api/doc-drafts/<token>/push"
|
||||
)
|
||||
# Exactly one call per path — no duplicate fetch sites.
|
||||
assert js.count("doc-drafts") >= 3
|
||||
|
||||
|
||||
def test_load_fill_is_values_not_innerhtml() -> None:
|
||||
"""The 200 body fills the three fields with VALUES
|
||||
(``.value`` = textContent discipline) — NEVER innerHTML: the body
|
||||
is user-derived markdown, and the title/path may contain anything
|
||||
but markup. The whole file builds no HTML at all (the page markup
|
||||
is static; JS only reads/sets values and hidden flags)."""
|
||||
js = _js()
|
||||
load = _fn(js, "loadDraft")
|
||||
assert "titleInput.value = draft.title" in load
|
||||
assert "pathInput.value = draft.path" in load
|
||||
assert "bodyInput.value = draft.body" in load
|
||||
assert "innerHTML" not in js, "doc-edit.js must never build HTML"
|
||||
|
||||
|
||||
def test_load_outcome_copy() -> None:
|
||||
"""loadDraft's failure lines: 404 → "Draft not found." (no
|
||||
enumeration — one message for every unknown token), other non-2xx
|
||||
→ the server's detail (422 shape-aware), a network failure → the
|
||||
fixed one-line copy."""
|
||||
js = _js()
|
||||
load = _fn(js, "loadDraft")
|
||||
assert 'showError("Draft not found.")' in load
|
||||
assert "r.status === 404" in load
|
||||
assert "apiDetail(" in load, "non-2xx must surface the server detail"
|
||||
assert "is the app running?" in load, "the network-failure line"
|
||||
# The 404 check runs before the generic non-2xx arm.
|
||||
assert load.find("r.status === 404") < load.find("if (!r.ok)")
|
||||
|
||||
|
||||
# ---------- push: the never-stale lifecycle ----------
|
||||
|
||||
|
||||
def test_push_sanity_checks_before_any_request() -> None:
|
||||
"""The client-side sanity (the server is the authority — it
|
||||
re-runs the guard-rails): non-empty title, non-empty body, no
|
||||
".." in the path. Each violation lands the error banner, focuses
|
||||
the offending field, and returns BEFORE any fetch — and without a
|
||||
token the banner says "No draft specified." (no fetch)."""
|
||||
js = _js()
|
||||
fn = js[js.find("function wirePush() {"):]
|
||||
title_i = fn.find('showError("Enter a title for the doc.")')
|
||||
body_i = fn.find('showError("The doc body must not be empty.")')
|
||||
path_i = fn.find("path.includes(\"..\")")
|
||||
assert title_i != -1 and body_i != -1 and path_i != -1, (
|
||||
"the three sanity checks must exist"
|
||||
)
|
||||
assert title_i < body_i < path_i, "title, body, path — in field order"
|
||||
# Each violation focuses its field (keyboard a11y).
|
||||
assert "titleInput.focus()" in fn
|
||||
assert "bodyInput.focus()" in fn
|
||||
assert "pathInput.focus()" in fn
|
||||
# No token → the banner, no fetch (the first fetch comes later).
|
||||
notoken_i = fn.find('showError("No draft specified.")')
|
||||
first_fetch = fn.find("await fetch(")
|
||||
assert -1 < notoken_i < first_fetch
|
||||
|
||||
|
||||
def test_push_disables_relabels_and_reenables() -> None:
|
||||
"""The §7.4 never-stale lifecycle: the button disables +
|
||||
relabels "Pushing…" AND the live region says "Pushing…" while the
|
||||
request is out; the finally re-enables the button with its idle
|
||||
label (IDLE_LABEL = the exact static copy) on EVERY outcome —
|
||||
success OR failure, success AND failure."""
|
||||
js = _js()
|
||||
fn = js[js.find("function wirePush() {"):]
|
||||
disable_i = fn.find("pushBtn.disabled = true")
|
||||
relabel_i = fn.find('pushBtn.textContent = "Pushing…"')
|
||||
status_i = fn.find('setStatus("Pushing…")')
|
||||
assert disable_i != -1 and relabel_i != -1 and status_i != -1, (
|
||||
"disable + relabel + status before the requests"
|
||||
)
|
||||
fetch_i = fn.find("await fetch(")
|
||||
assert -1 < status_i < fetch_i, "the status line precedes the first request"
|
||||
finally_i = fn.find("} finally {")
|
||||
assert finally_i != -1, "the finally block is the never-stale guarantee"
|
||||
after_finally = fn[finally_i:]
|
||||
assert "pushBtn.disabled = false" in after_finally
|
||||
assert "pushBtn.textContent = IDLE_LABEL" in after_finally
|
||||
# The idle label IS the static button copy (a mismatch would
|
||||
# relabel the button into an unknown state on success).
|
||||
assert 'const IDLE_LABEL = "Push to docs branch";' in js
|
||||
|
||||
|
||||
def test_push_success_line_branch_and_sha7() -> None:
|
||||
"""The 200 outcome: the live region reads
|
||||
`Pushed to <branch> — commit <sha7>.` — the branch from the API,
|
||||
the commit sha TRUNCATED to its first seven chars for display (the
|
||||
full value stays in the API/draft row), the exact em-dash shape.
|
||||
The button re-enables (a re-push after further edits is a NEW
|
||||
commit — the D3 ASSUMPTION)."""
|
||||
js = _js()
|
||||
fn = js[js.find("function wirePush() {"):]
|
||||
assert (
|
||||
"`Pushed to ${pushed.branch} — commit ${String(pushed.commit_sha).slice(0, 7)}.`"
|
||||
in fn
|
||||
), "the success line is 'Pushed to <branch> — commit <sha7>.'"
|
||||
# The success line is set AFTER the push response is read.
|
||||
json_i = fn.find("await r.json()")
|
||||
ok_i = fn.find('`Pushed to ${pushed.branch}')
|
||||
assert -1 < json_i < ok_i
|
||||
|
||||
|
||||
def test_push_failure_banner_trims_git_detail_and_keeps_fields() -> None:
|
||||
"""The failure outcome: the #push-error banner with the API's
|
||||
detail — for a git 502 that is git's stderr, trimmed to its first
|
||||
meaningful lines (trimGitDetail: blank lines + the "hint:" chatter
|
||||
dropped, at most three lines, single-line details untouched) — the
|
||||
fields are PRESERVED (no input is cleared anywhere in the file)
|
||||
and the stale success line is cleared so only the error claims the
|
||||
outcome. Network failure → the fixed one-line copy."""
|
||||
js = _js()
|
||||
fn = js[js.find("function wirePush() {"):]
|
||||
assert "trimGitDetail(" in fn, "the failure detail must pass the trimmer"
|
||||
assert "apiDetail(" in fn, "the detail must be the API's (422-shape-aware)"
|
||||
# No field is ever cleared: the user's edits survive a failed push.
|
||||
for field in ('titleInput.value = ""', 'pathInput.value = ""',
|
||||
'bodyInput.value = ""', "titleInput.value=''",
|
||||
"pathInput.value=''", "bodyInput.value=''"):
|
||||
assert field not in js, f"a failed push must keep the edits, not {field!r}"
|
||||
assert "is the app running?" in fn, "the network-failure line"
|
||||
# The stale success line is cleared on failure (one claim at a
|
||||
# time — the PUT-failure arm clears it too).
|
||||
fail_i = fn.find("trimGitDetail(")
|
||||
assert fn.rfind('setStatus("")', 0, fail_i) > 0, (
|
||||
"a failed push clears the status line before the banner"
|
||||
)
|
||||
put_fail_i = fn.find('showError(await apiDetail(put')
|
||||
assert put_fail_i != -1
|
||||
assert fn.rfind('setStatus("")', 0, put_fail_i) > 0, (
|
||||
"a failed PUT also clears the stale status line"
|
||||
)
|
||||
|
||||
trim = _fn(js, "trimGitDetail")
|
||||
assert 'l.startsWith("hint:")' in trim, "the 'hint:' chatter is dropped"
|
||||
assert "slice(0, 3)" in trim, "at most three meaningful lines"
|
||||
assert "filter(" in trim and ".trim()" in trim
|
||||
|
||||
|
||||
def test_trim_git_detail_behavior_is_pinned_by_the_markers() -> None:
|
||||
"""The trimmer's contract in one place: non-empty lines that are
|
||||
not hint: lines, up to three, space-joined, with a fallback for an
|
||||
all-hint/empty detail (the banner must never be blank)."""
|
||||
js = _js()
|
||||
trim = _fn(js, "trimGitDetail")
|
||||
assert "split(\"\\n\")" in trim
|
||||
assert 'join(" ")' in trim
|
||||
assert '|| "The push failed."' in trim, "the empty-detail fallback"
|
||||
|
||||
|
||||
# ---------- styles.css: the new classes ----------
|
||||
|
||||
|
||||
def test_doc_edit_shell_is_the_hardcoded_46rem_column() -> None:
|
||||
""".doc-edit-shell: the 46rem base column — HARD-CODED 46rem (a
|
||||
form column, not a reading column — it must NOT ride
|
||||
--chat-column, so phase 58's wide-desktop doubling never stretches
|
||||
the form), centered, a flex column on the container frame."""
|
||||
css = _css()
|
||||
block = re.search(r"\.doc-edit-shell \{([\s\S]*?)\n\}", css)
|
||||
assert block, "styles.css must style .doc-edit-shell"
|
||||
body = block.group(1)
|
||||
assert "max-width: 46rem" in body, "the 46rem base column (hard-coded)"
|
||||
assert "--chat-column" not in body, (
|
||||
"the form column does not ride --chat-column (phase 58 must "
|
||||
"not stretch it)"
|
||||
)
|
||||
assert "margin-inline: auto" in body
|
||||
assert "flex-direction: column" in body
|
||||
|
||||
|
||||
def test_back_link_and_push_button_css() -> None:
|
||||
""".doc-edit-back: the ghost language (>=44px target, --line
|
||||
border, ink-soft on the --surface bar), pushed right
|
||||
(margin-left: auto); #push-doc-btn: the brand primary (dark ink on
|
||||
brand 5.2:1 — never white on brand), >=44px, a :disabled state
|
||||
(the "Pushing…" affordance)."""
|
||||
css = _css()
|
||||
back = re.search(r"\.doc-edit-back \{([\s\S]*?)\n\}", css)
|
||||
assert back, "styles.css must style .doc-edit-back"
|
||||
bbody = back.group(1)
|
||||
assert "min-height: 44px" in bbody
|
||||
assert "border: 1px solid var(--line)" in bbody
|
||||
assert "var(--ink-soft)" in bbody
|
||||
assert "margin-left: auto" in bbody
|
||||
btn = re.search(r"#push-doc-btn \{([\s\S]*?)\n\}", css)
|
||||
assert btn, "styles.css must style #push-doc-btn"
|
||||
tbody = btn.group(1)
|
||||
assert "background: var(--brand)" in tbody
|
||||
assert "color: var(--bg)" in tbody, "dark ink on brand (never white)"
|
||||
assert "min-height: 44px" in tbody
|
||||
assert re.search(r"#push-doc-btn:disabled \{[^}]*opacity[^}]*\}", css), (
|
||||
"the disabled (Pushing…) state must be styled"
|
||||
)
|
||||
|
||||
|
||||
def test_form_fields_css_mono_and_min_height() -> None:
|
||||
"""#draft-path and #draft-body are MONO (the path is machine data;
|
||||
the body is markdown) on the inset bg fill; #draft-body carries
|
||||
the pinned min-height: 20rem; the inputs keep the 44px floor."""
|
||||
css = _css()
|
||||
pair = re.search(
|
||||
r"#draft-title,\n#draft-path \{([\s\S]*?)\n\}", css
|
||||
)
|
||||
assert pair, "styles.css must style the two text inputs"
|
||||
assert "min-height: 44px" in pair.group(1)
|
||||
# The DEDICATED #draft-path rule (the pair above shares the name in
|
||||
# its selector list — search past the pair's closing brace).
|
||||
path = re.search(
|
||||
r"#draft-path \{([\s\S]*?)\n\}", css[pair.end():]
|
||||
)
|
||||
assert path and "var(--mono)" in path.group(1), "#draft-path must be mono"
|
||||
body = re.search(r"#draft-body \{([\s\S]*?)\n\}", css)
|
||||
assert body, "styles.css must style #draft-body"
|
||||
bbody = body.group(1)
|
||||
assert "var(--mono)" in bbody, "#draft-body must be mono"
|
||||
assert "min-height: 20rem" in bbody, "the pinned 20rem body floor"
|
||||
assert "resize: vertical" in bbody
|
||||
|
||||
|
||||
def test_status_and_error_css_families() -> None:
|
||||
""".doc-edit-status: the ok family (ok-ink on ok-bg 10.6:1) when
|
||||
a push outcome has landed, the dashed placeholder when empty;
|
||||
.doc-edit-error: the err family (err-ink on err-bg 9.3:1,
|
||||
err-line border) with long-word breaking (git paths). The global
|
||||
3px :focus-visible ring covers the new controls (AGENTS.md rule 5)."""
|
||||
css = _css()
|
||||
status = re.search(r"\.doc-edit-status \{([\s\S]*?)\n\}", css)
|
||||
assert status, "styles.css must style .doc-edit-status"
|
||||
sbody = status.group(1)
|
||||
assert "var(--ok-bg)" in sbody and "var(--ok-ink)" in sbody
|
||||
assert re.search(r"\.doc-edit-status:empty \{", css), (
|
||||
"the empty status must be the dashed placeholder"
|
||||
)
|
||||
error = re.search(r"\.doc-edit-error \{([\s\S]*?)\n\}", css)
|
||||
assert error, "styles.css must style .doc-edit-error"
|
||||
ebody = error.group(1)
|
||||
assert "var(--err-bg)" in ebody and "var(--err-ink)" in ebody
|
||||
assert "var(--err-line)" in ebody
|
||||
assert "overflow-wrap: anywhere" in ebody
|
||||
assert ":focus-visible" in css, "the global focus ring (AGENTS.md rule 5)"
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Unit tests: docs-push service (phase 59, task 03).
|
||||
|
||||
``push_document`` is exercised against a **real local git repo** — a
|
||||
bare origin in ``tmp_path`` plus the working clones the service creates
|
||||
itself — and every result assertion reads the bare repo's state
|
||||
directly (``git show <branch>:<path>``, ``git rev-list``), not the
|
||||
return value alone. The remote is a plain local path, so no network is
|
||||
ever involved.
|
||||
|
||||
The module skips (``pytest.skip``) when ``git --version`` fails — a
|
||||
machine without git must not see hard failures.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.docs_push import DocsPushError, push_document
|
||||
|
||||
BASE = "main"
|
||||
BRANCH = "bor-docs"
|
||||
REL = "docs/note.md"
|
||||
IDENTITY = ("-c", "commit.gpgsign=false", "-c", "user.name=Test", "-c", "user.email=t@example.com")
|
||||
|
||||
|
||||
def _git_available() -> bool:
|
||||
try:
|
||||
proc = subprocess.run(["git", "--version"], capture_output=True, check=False)
|
||||
return proc.returncode == 0
|
||||
except (FileNotFoundError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _require_git() -> None:
|
||||
"""Skip the whole module when the git CLI is missing."""
|
||||
if not _git_available():
|
||||
pytest.skip("git is not available on this machine")
|
||||
|
||||
|
||||
def _git(cwd: Path, *argv: str) -> str:
|
||||
"""Run git for the tests themselves (setup + assertions); loud on failure."""
|
||||
proc = subprocess.run(["git", *argv], cwd=cwd, capture_output=True, text=True, check=False)
|
||||
assert proc.returncode == 0, f"git {' '.join(argv)} failed: {proc.stderr}"
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _push(bare: Path, work: Path, content: str, message: str = "docs: note") -> tuple[str, str]:
|
||||
"""push_document against the fixture bare repo (plain local path)."""
|
||||
return push_document(
|
||||
repo=str(bare),
|
||||
base_branch=BASE,
|
||||
branch=BRANCH,
|
||||
work_dir=str(work),
|
||||
rel_path=REL,
|
||||
content=content,
|
||||
commit_message=message,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def bare_repo(tmp_path: Path) -> Path:
|
||||
"""A bare origin seeded with one commit on ``main`` (``README.md``)."""
|
||||
bare = tmp_path / "bare.git"
|
||||
_git(tmp_path, "init", "--bare", str(bare))
|
||||
seed = tmp_path / "seed"
|
||||
_git(tmp_path, "clone", str(bare), str(seed))
|
||||
(seed / "README.md").write_text("# docs\n", encoding="utf-8")
|
||||
_git(seed, "checkout", "-B", BASE)
|
||||
_git(seed, *IDENTITY, "add", "README.md")
|
||||
_git(seed, *IDENTITY, "commit", "-m", "seed README")
|
||||
_git(seed, "push", "origin", BASE)
|
||||
return bare
|
||||
|
||||
|
||||
def test_first_push_creates_branch_and_returns_sha(bare_repo: Path, tmp_path: Path) -> None:
|
||||
"""First push: clones the base, creates the branch, lands the file."""
|
||||
work = tmp_path / "work" # absent — push_document clones it
|
||||
branch, sha = _push(bare_repo, work, "# Note\n\nbody one\n")
|
||||
|
||||
assert branch == BRANCH
|
||||
assert (work / ".git").is_dir()
|
||||
assert (work / REL).read_text(encoding="utf-8") == "# Note\n\nbody one\n"
|
||||
# The file lands on the branch of the BARE repo, at the returned sha.
|
||||
assert _git(bare_repo, "show", f"{BRANCH}:{REL}") == "# Note\n\nbody one\n"
|
||||
assert _git(bare_repo, "rev-parse", BRANCH).strip() == sha
|
||||
assert len(sha) == 40
|
||||
# Exactly one commit beyond main.
|
||||
assert _git(bare_repo, "rev-list", "--count", f"main..{BRANCH}").strip() == "1"
|
||||
# Fixed per-invocation identity + message (no global git config reliance).
|
||||
ident = _git(bare_repo, "log", "-1", BRANCH, "--format=%an <%ae>").strip()
|
||||
assert ident == "Brain of Reese <bor@local>"
|
||||
assert _git(bare_repo, "log", "-1", BRANCH, "--format=%s").strip() == "docs: note"
|
||||
|
||||
|
||||
def test_second_push_fast_forwards_same_branch(bare_repo: Path, tmp_path: Path) -> None:
|
||||
"""Second push (edited content, same path): fast-forward, 2 commits."""
|
||||
work = tmp_path / "work"
|
||||
sha1 = _push(bare_repo, work, "v1\n")[1]
|
||||
sha2 = _push(bare_repo, work, "v2 edited\n")[1]
|
||||
|
||||
assert sha1 != sha2
|
||||
assert _git(bare_repo, "show", f"{BRANCH}:{REL}") == "v2 edited\n"
|
||||
assert _git(bare_repo, "rev-list", "--count", f"main..{BRANCH}").strip() == "2"
|
||||
# Fast-forward, no force: the first commit is still an ancestor.
|
||||
_git(bare_repo, "merge-base", "--is-ancestor", sha1, sha2)
|
||||
|
||||
|
||||
def test_fresh_checkout_reattaches_onto_remote_branch(bare_repo: Path, tmp_path: Path) -> None:
|
||||
"""An absent checkout re-attaches onto the existing remote branch
|
||||
(its history) so the push still fast-forwards."""
|
||||
work1 = tmp_path / "work1"
|
||||
sha1 = _push(bare_repo, work1, "v1\n")[1]
|
||||
work2 = tmp_path / "work2" # different dir — push_document clones anew
|
||||
branch, sha2 = _push(bare_repo, work2, "v2\n")
|
||||
|
||||
assert branch == BRANCH
|
||||
assert _git(bare_repo, "rev-list", "--count", f"main..{BRANCH}").strip() == "2"
|
||||
assert _git(bare_repo, "rev-parse", BRANCH).strip() == sha2
|
||||
# work2's commit sits on work1's commit (re-attach, not a fork).
|
||||
_git(bare_repo, "merge-base", "--is-ancestor", sha1, sha2)
|
||||
|
||||
|
||||
def test_concurrently_advanced_remote_fails_loudly(bare_repo: Path, tmp_path: Path) -> None:
|
||||
"""Remote advanced by a second clone → the first clone's push is a
|
||||
non-fast-forward: DocsPushError carrying git's stderr, remote kept."""
|
||||
work_a = tmp_path / "work_a"
|
||||
_push(bare_repo, work_a, "from A\n")
|
||||
|
||||
# A second clone advances the branch on the bare repo.
|
||||
work_b = tmp_path / "work_b"
|
||||
_git(tmp_path, "clone", "--depth", "1", "--branch", BRANCH, str(bare_repo), str(work_b))
|
||||
(work_b / "docs" / "other.md").write_text("from B\n", encoding="utf-8")
|
||||
_git(work_b, *IDENTITY, "add", "docs/other.md")
|
||||
_git(work_b, *IDENTITY, "commit", "-m", "docs: other")
|
||||
_git(work_b, "push", "origin", BRANCH)
|
||||
remote_tip_before = _git(bare_repo, "rev-parse", BRANCH).strip()
|
||||
|
||||
with pytest.raises(DocsPushError) as excinfo:
|
||||
_push(bare_repo, work_a, "from A again\n")
|
||||
|
||||
msg = str(excinfo.value)
|
||||
# git's stderr is surfaced (the non-fast-forward refusal).
|
||||
assert "non-fast-forward" in msg
|
||||
assert "rejected" in msg
|
||||
# The remote branch was NOT touched (no force-push, no merge).
|
||||
assert _git(bare_repo, "rev-parse", BRANCH).strip() == remote_tip_before
|
||||
assert _git(bare_repo, "show", f"{BRANCH}:{REL}") == "from A\n"
|
||||
|
||||
|
||||
def test_missing_repo_path_fails_loudly(tmp_path: Path) -> None:
|
||||
"""No such repo → DocsPushError naming the failed git step."""
|
||||
with pytest.raises(DocsPushError, match="git clone .* failed"):
|
||||
push_document(
|
||||
repo=str(tmp_path / "no-such-repo"),
|
||||
base_branch=BASE,
|
||||
branch=BRANCH,
|
||||
work_dir=str(tmp_path / "w"),
|
||||
rel_path=REL,
|
||||
content="x\n",
|
||||
commit_message="docs: x",
|
||||
)
|
||||
# No fake checkout is left behind.
|
||||
assert not (tmp_path / "w" / ".git").exists()
|
||||
|
||||
|
||||
def test_non_repo_dir_fails_loudly(tmp_path: Path) -> None:
|
||||
"""A plain directory (not a git repo) as the remote → DocsPushError."""
|
||||
plain = tmp_path / "plain"
|
||||
plain.mkdir()
|
||||
(plain / "file.txt").write_text("not a repo\n", encoding="utf-8")
|
||||
with pytest.raises(DocsPushError, match="failed"):
|
||||
push_document(
|
||||
repo=str(plain),
|
||||
base_branch=BASE,
|
||||
branch=BRANCH,
|
||||
work_dir=str(tmp_path / "w"),
|
||||
rel_path=REL,
|
||||
content="x\n",
|
||||
commit_message="docs: x",
|
||||
)
|
||||
|
||||
|
||||
def test_unsafe_rel_path_is_refused_before_any_git(bare_repo: Path, tmp_path: Path) -> None:
|
||||
"""The defensive parts re-assertion refuses traversal paths."""
|
||||
for bad in ("../evil.md", "/etc/passwd", "a/b/../c.md"):
|
||||
with pytest.raises(DocsPushError, match="unsafe rel_path"):
|
||||
push_document(
|
||||
repo=str(bare_repo),
|
||||
base_branch=BASE,
|
||||
branch=BRANCH,
|
||||
work_dir=str(tmp_path / "w"),
|
||||
rel_path=bad,
|
||||
content="x\n",
|
||||
commit_message="docs: x",
|
||||
)
|
||||
# No checkout was even attempted.
|
||||
assert not (tmp_path / "w").exists()
|
||||
@@ -30,6 +30,7 @@ HTML_PAGES = (
|
||||
"git-sources.html",
|
||||
"history.html", # phase 50: the admin saved-chats page
|
||||
"shared.html", # phase 51: the anonymous shared-conversation page
|
||||
"doc-edit.html", # phase 59: the admin doc edit screen (flow page)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -143,11 +143,11 @@ def test_missing_git_raises_named_error(
|
||||
clone_or_pull("https://example.com/homelab.git", tmp_path / "homelab")
|
||||
|
||||
|
||||
def test_run_captures_and_returns_stdout(
|
||||
def test_run_git_captures_and_returns_stdout(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""_run returns the captured stdout on success (git output is not lost)."""
|
||||
"""run_git returns the captured stdout on success (git output is not lost)."""
|
||||
calls = _fake_run(monkeypatch, stdout="From example.com\n + abc..def main")
|
||||
|
||||
assert git_sync._run(["git", "status"], cwd=tmp_path) == "From example.com\n + abc..def main"
|
||||
assert git_sync.run_git(["git", "status"], cwd=tmp_path) == "From example.com\n + abc..def main"
|
||||
assert len(calls) == 1
|
||||
|
||||
@@ -44,3 +44,36 @@ def test_documents_unique_source_path() -> None:
|
||||
and {col.name for col in c.columns} == {"source", "path"}
|
||||
]
|
||||
assert uq, "documents must be unique on (source, path) — the upsert key"
|
||||
|
||||
|
||||
def test_doc_drafts_token_is_unique_not_null() -> None:
|
||||
"""Phase 59: the draft's URL credential — an unguessable uuid4,
|
||||
UNIQUE + NOT NULL (no "un-drafted" state, unlike the NULLable
|
||||
``saved_chats.share_token``)."""
|
||||
drafts = Base.metadata.tables["doc_drafts"]
|
||||
token = drafts.c["token"]
|
||||
assert token.nullable is False, "doc_drafts.token must be NOT NULL"
|
||||
uq = [
|
||||
c
|
||||
for c in drafts.constraints
|
||||
if isinstance(c, UniqueConstraint)
|
||||
and {col.name for col in c.columns} == {"token"}
|
||||
]
|
||||
assert uq, "doc_drafts must be unique on (token) — the URL credential"
|
||||
|
||||
|
||||
def test_doc_drafts_column_contract() -> None:
|
||||
"""Phase 59: the editable triple (title/path/body) + status +
|
||||
timestamps are NOT NULL; ``branch`` / ``commit_sha`` are NULL
|
||||
until the push endpoint records them."""
|
||||
drafts = Base.metadata.tables["doc_drafts"]
|
||||
assert set(drafts.c.keys()) == {
|
||||
"id", "token", "title", "path", "body", "status",
|
||||
"branch", "commit_sha", "created_at", "updated_at",
|
||||
}
|
||||
for name in ("title", "path", "body", "status", "created_at", "updated_at"):
|
||||
assert drafts.c[name].nullable is False, f"{name} must be NOT NULL"
|
||||
for name in ("branch", "commit_sha"):
|
||||
assert drafts.c[name].nullable is True, f"{name} must be NULL until pushed"
|
||||
assert drafts.c["status"].default is not None, "status needs an ORM default (draft)"
|
||||
assert drafts.c["token"].default is not None, "token needs an ORM default (uuid4)"
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Unit: the phase-59 "Save as doc" button (task 05).
|
||||
|
||||
No Python logic exists beyond the one-line ``app/api/config.py`` flag —
|
||||
the behavior lives in ``frontend/assets/app.js`` + ``brand.js`` +
|
||||
``styles.css``, and it is E2E-gated by the story suite (task 07). Like
|
||||
the other frontend-adjacent unit files (``test_frontend_brand.py``),
|
||||
this module pins the JS/CSS markers the story depends on, so a silent
|
||||
regression in the button layer is caught without a browser — plus the
|
||||
``app/api/config.py`` unit pin (the response dict's
|
||||
``docs_repo_configured`` bool tracks ``settings.docs_configured``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
BRAND_JS = FRONTEND / "assets" / "brand.js"
|
||||
APP_JS = FRONTEND / "assets" / "app.js"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _settings(**kwargs: Any) -> Settings:
|
||||
"""Build Settings without reading a .env file (deterministic tests).
|
||||
|
||||
Same house pattern as tests/integration/test_doc_drafts_api.py —
|
||||
``_env_file`` exists at runtime (pydantic-settings) but is not in the
|
||||
static signature, hence the ignore on the call.
|
||||
"""
|
||||
kwargs.setdefault("_env_file", None)
|
||||
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# app/api/config.py — the unit pin (the response dict gains the flag)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_app_config_dict_carries_the_docs_flag() -> None:
|
||||
"""The ``app_config`` response dict gains ``docs_repo_configured`` —
|
||||
a real bool that tracks ``settings.docs_configured``: false (inert)
|
||||
while BOR_DOCS_REPO is empty, true the moment it is non-empty."""
|
||||
from app.api.config import app_config
|
||||
|
||||
s = _settings()
|
||||
body = app_config(s)
|
||||
assert set(body) == {"app_name", "version", "docs_repo_configured"}
|
||||
assert body["docs_repo_configured"] is s.docs_configured
|
||||
assert body["docs_repo_configured"] is False
|
||||
|
||||
s2 = _settings(docs_repo="/srv/docs-repo")
|
||||
assert app_config(s2)["docs_repo_configured"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# brand.js — the flag + promise are surfaced the way app_name is
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_brand_js_surfaces_the_docs_flag_inert_by_default() -> None:
|
||||
"""window.BOR_DOCS_REPO_CONFIGURED is a classic-script global: false
|
||||
at parse time (inert — hidden for everyone until proven), BEFORE the
|
||||
/api/config fetch starts (the same ordering pin as window.BOR_BRAND)."""
|
||||
js = _text(BRAND_JS)
|
||||
assert "window.BOR_DOCS_REPO_CONFIGURED = false;" in js
|
||||
default_idx = js.find("window.BOR_DOCS_REPO_CONFIGURED = false;")
|
||||
# The real fetch statement (the file-header comment mentions the
|
||||
# fetch too — anchor on the parse-time const, not the comment).
|
||||
fetch_idx = js.find('BOR_CONFIG_PROMISE = fetch("/api/config"')
|
||||
assert 0 <= default_idx < fetch_idx, (
|
||||
"the inert flag default must be set at top level before the fetch"
|
||||
)
|
||||
|
||||
|
||||
def test_brand_js_exposes_the_config_promise_and_sets_the_flag() -> None:
|
||||
"""The SAME boot fetch's promise is exposed at parse time
|
||||
(window.BOR_CONFIG_PROMISE — app.js's boot awaits it), the flag lands
|
||||
the moment the answer arrives, and the promise NEVER rejects (the
|
||||
error arm warns + resolves null — the loadHealth house style)."""
|
||||
js = _text(BRAND_JS)
|
||||
assert "window.BOR_CONFIG_PROMISE = BOR_CONFIG_PROMISE;" in js
|
||||
assert "window.BOR_DOCS_REPO_CONFIGURED = cfg?.docs_repo_configured === true;" in js
|
||||
# The flag is a strict boolean: only the literal JSON true flips it.
|
||||
assert "=== true" in js
|
||||
assert "console.warn" in js
|
||||
assert "return null;" in js
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# app.js — boot wiring: the flag is final before any bubble renders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_app_js_boot_awaits_config_before_capturing_the_flag() -> None:
|
||||
"""The boot IIFE awaits brand.js's parse-time promise (never
|
||||
rejecting — a defensive fallback covers a missing global) and then
|
||||
captures docsRepoConfigured — BEFORE any bubble renders
|
||||
(restoreConversation), so a restored conversation of a configured
|
||||
admin gets the button exactly once: no flash, no re-render, no
|
||||
second fetch."""
|
||||
js = _text(APP_JS)
|
||||
assert "let docsRepoConfigured = false;" in js
|
||||
await_idx = js.find("await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());")
|
||||
capture_idx = js.find("docsRepoConfigured = window.BOR_DOCS_REPO_CONFIGURED === true;")
|
||||
restore_idx = js.find("restoreConversation();")
|
||||
assert await_idx >= 0 and await_idx < capture_idx, (
|
||||
"the flag capture must follow the config-promise await"
|
||||
)
|
||||
assert restore_idx > 0 and capture_idx < restore_idx, (
|
||||
"the flag must be final BEFORE the restored conversation renders"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# app.js — the button: gating, ARIA, one per bubble
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_app_js_button_gates_on_admin_and_configured() -> None:
|
||||
"""The single guard: admin (the whoami gate Tune uses) AND
|
||||
docs_repo_configured — otherwise the function injects NOTHING
|
||||
(anonymous, or unconfigured admin, or deflected scope — same as
|
||||
Tune). One button per bubble; the .msg-meta row is reused (or
|
||||
created plain) and a role=list row gets a listitem button (ARIA)."""
|
||||
js = _text(APP_JS)
|
||||
fn_idx = js.find("function appendSaveAsDocButton(wrap, markdown) {")
|
||||
assert fn_idx != -1, "appendSaveAsDocButton missing"
|
||||
fn_end = js.find("async function saveAsDoc", fn_idx)
|
||||
fn_body = js[fn_idx:fn_end]
|
||||
assert "if (!isAdmin || !docsRepoConfigured) return;" in fn_body
|
||||
assert 'meta.querySelector(".save-as-doc-btn")' in fn_body, (
|
||||
"the one-button-per-bubble guard is missing"
|
||||
)
|
||||
assert "meta.getAttribute(\"role\") === \"list\"" in fn_body
|
||||
assert "btn.setAttribute(\"role\", \"listitem\")" in fn_body
|
||||
|
||||
|
||||
def test_app_js_button_carries_the_class_and_label() -> None:
|
||||
"""The .save-as-doc-btn class (the CSS right-alignment hook) + the
|
||||
house label "Save as doc" (an accessible button name — the icon is
|
||||
aria-hidden decoration)."""
|
||||
js = _text(APP_JS)
|
||||
fn_idx = js.find("function appendSaveAsDocButton(wrap, markdown) {")
|
||||
fn_body = js[fn_idx : js.find("async function saveAsDoc", fn_idx)]
|
||||
assert 'btn.className = "save-as-doc-btn"' in fn_body
|
||||
assert 'btn.type = "button"' in fn_body
|
||||
assert "<span>Save as doc</span>" in fn_body
|
||||
# The file glyph is aria-hidden decoration (the label carries the
|
||||
# accessible name) — the icon constant, which the function consumes.
|
||||
icon_idx = js.find("const SAVE_AS_DOC_ICON")
|
||||
icon_body = js[icon_idx : js.find("const DOC_TITLE_MAX", icon_idx)]
|
||||
assert 'aria-hidden="true"' in icon_body, "the icon must be aria-hidden"
|
||||
assert 'SAVE_AS_DOC_ICON + "<span>Save as doc</span>"' in fn_body
|
||||
|
||||
|
||||
def test_app_js_call_sites_pass_the_raw_markdown() -> None:
|
||||
"""Three call sites, each passing the RAW persisted markdown (never
|
||||
the rendered HTML): the live `done` branch (exactly the string
|
||||
rememberBrainTurn stores, so a reload offers the identical draft),
|
||||
the empty-answer fallback bubble (parity with the done path), and
|
||||
the restore path (m.text). A stopped partial is a note, not an
|
||||
answer — the restore gates on !m.stopped; the live stop path and
|
||||
the pagehide partial never call the helper at all."""
|
||||
js = _text(APP_JS)
|
||||
assert 'appendSaveAsDocButton(wrap, finalText || acc || "…");' in js, (
|
||||
"the live done branch must pass the raw persisted text"
|
||||
)
|
||||
assert "appendSaveAsDocButton(fwrap, fallback);" in js, (
|
||||
"the empty-answer fallback bubble must get the button too"
|
||||
)
|
||||
assert "if (!m.stopped) appendSaveAsDocButton(wrap, m.text);" in js, (
|
||||
"the restore path must pass m.text and skip stopped records"
|
||||
)
|
||||
# The live call sits next to the Tune button (same meta row scope).
|
||||
tune_idx = js.find("appendTuneButton(wrap); // every completed brain bubble is tunable")
|
||||
save_idx = js.find('appendSaveAsDocButton(wrap, finalText || acc || "…");')
|
||||
assert tune_idx > 0 and tune_idx < save_idx
|
||||
# The stop finalize keeps its Tune button but gains NO save button
|
||||
# (a stopped partial is a note, not an answer) — none between the
|
||||
# stop call site and the pagehide handler (which persists, it does
|
||||
# not render).
|
||||
stop_idx = js.find("appendTuneButton(wrap); // admin-only; parity with the restore path")
|
||||
pagehide_idx = js.find("pagehide", stop_idx)
|
||||
assert stop_idx > 0 and stop_idx < pagehide_idx
|
||||
assert "appendSaveAsDocButton" not in js[stop_idx:pagehide_idx], (
|
||||
"the stopped partial (note, not answer) must not get the button"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# app.js — the click: payload, slug rule, navigation, failure copy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_app_js_default_title_is_the_last_user_question() -> None:
|
||||
"""The default title: the LAST user question's text,
|
||||
whitespace-collapsed, ≤120 chars (the phase-50 auto-title
|
||||
convention — the chat auto-title targets the FIRST question, the
|
||||
docs default the LAST). Defensive "Note" with no user record."""
|
||||
js = _text(APP_JS)
|
||||
assert "const DOC_TITLE_MAX = 120;" in js
|
||||
fn_idx = js.find("function defaultDocTitle() {")
|
||||
assert fn_idx != -1, "defaultDocTitle missing"
|
||||
fn_body = js[fn_idx : js.find("function docSlug", fn_idx)]
|
||||
assert "conversation.length - 1" in fn_body, (
|
||||
"the LAST user record wins (iterate backwards)"
|
||||
)
|
||||
assert 'conversation[i].who === "user"' in fn_body
|
||||
assert 'question.replace(/\\s+/g, " ").trim().slice(0, DOC_TITLE_MAX)' in fn_body
|
||||
assert '|| "Note"' in fn_body
|
||||
|
||||
|
||||
def test_app_js_slug_rule() -> None:
|
||||
"""The default in-repo path slug: lowercase → runs of
|
||||
non-alphanumerics → "-" → trimmed → ≤60 chars → empty → "note"
|
||||
(the phase-59 locked assumption; a 60-cut mid dash-run is trimmed
|
||||
again so the path never dangles)."""
|
||||
js = _text(APP_JS)
|
||||
fn_idx = js.find("function docSlug(title) {")
|
||||
assert fn_idx != -1, "docSlug missing"
|
||||
fn_body = js[fn_idx : js.find("function appendSaveAsDocButton", fn_idx)]
|
||||
assert ".toLowerCase()" in fn_body
|
||||
assert '.replace(/[^a-z0-9]+/g, "-")' in fn_body
|
||||
assert '.replace(/^-+|-+$/g, "")' in fn_body
|
||||
assert ".slice(0, 60)" in fn_body
|
||||
assert '|| "note"' in fn_body
|
||||
# The default in-repo path is docs/<slug>.md.
|
||||
assert "docs/${docSlug(title)}.md" in js
|
||||
|
||||
|
||||
def test_app_js_post_payload_and_navigation() -> None:
|
||||
"""Click → POST /api/doc-drafts {title, path, body: markdown} (the
|
||||
raw markdown is the body — never HTML) → 201 →
|
||||
location.assign("/doc-edit.html?draft=" + token). A double-click
|
||||
guard disables the button until the outcome (released in the
|
||||
finally — never stale); failure shows the neutral one-line banner
|
||||
(phase-55 convention) and never navigates."""
|
||||
js = _text(APP_JS)
|
||||
fn_idx = js.find("async function saveAsDoc(btn, markdown) {")
|
||||
assert fn_idx != -1, "saveAsDoc missing"
|
||||
fn_body = js[fn_idx : fn_idx + 3000]
|
||||
assert 'fetch("/api/doc-drafts"' in fn_body
|
||||
assert 'JSON.stringify({ title, path, body: markdown })' in fn_body
|
||||
assert 'location.assign("/doc-edit.html?draft=" + draft.token)' in fn_body
|
||||
assert "btn.disabled = true" in fn_body
|
||||
assert "btn.disabled = false" in fn_body
|
||||
assert "showErrorBanner(" in fn_body
|
||||
# The neutral one-line failure copy (phase-55 convention).
|
||||
assert "Couldn't save the answer as a doc" in fn_body
|
||||
|
||||
|
||||
def test_app_js_retry_landing_keeps_save_rightmost() -> None:
|
||||
"""markLastRetryable re-appends the save button AFTER the Retry
|
||||
button lands on the same (last) bubble — the auto-margined buttons
|
||||
split the row's free space between them, so DOM order decides the
|
||||
right edge: "Save as doc" stays the bottom-right action even on the
|
||||
last bubble (which also carries Retry)."""
|
||||
js = _text(APP_JS)
|
||||
fn_idx = js.find("function markLastRetryable() {")
|
||||
assert fn_idx != -1
|
||||
fn_end = js.find("/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right", fn_idx)
|
||||
fn_body = js[fn_idx:fn_end]
|
||||
assert "appendRetryButton(lastBrainWrap);" in fn_body
|
||||
assert 'lastBrainWrap.querySelector(".save-as-doc-btn")' in fn_body
|
||||
# "saveDocBtn" — NOT "saveBtn": phase 55 pins the Save pill's
|
||||
# identifier gone from app.js (substring), so the local stays distinct.
|
||||
assert "saveDocBtn.parentElement.appendChild(saveDocBtn)" in fn_body
|
||||
assert "saveBtn" not in _text(APP_JS), (
|
||||
"the phase-55 pin: no saveBtn identifier in app.js"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# styles.css — the .tune-btn visual family + the right alignment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_styles_css_save_as_doc_btn_is_right_aligned() -> None:
|
||||
""".save-as-doc-btn exists, carries the bottom-right declaration
|
||||
(margin-inline-start: auto) and the .tune-btn visual family (pill,
|
||||
>=44px target, line border, ink-soft palette); :focus-visible is
|
||||
the global rule, the hover rule is per-class."""
|
||||
css = _text(STYLES_CSS)
|
||||
m = re.search(r"\.save-as-doc-btn \{[^}]*\}", css)
|
||||
assert m, "the .save-as-doc-btn rule is missing"
|
||||
block = m.group(0)
|
||||
assert "margin-inline-start: auto;" in block, (
|
||||
"the bottom-right requirement lives on the button's class"
|
||||
)
|
||||
assert "min-height: 44px;" in block # WCAG touch target (the family)
|
||||
assert "border-radius: 999px;" in block
|
||||
assert "border: 1px solid var(--line);" in block
|
||||
assert "var(--ink-soft)" in block
|
||||
assert ".save-as-doc-btn:hover" in css
|
||||
assert ".save-as-doc-btn svg" in css # the 14px house glyph sizing
|
||||
assert ":focus-visible" in css # the global focus ring (AGENTS §5)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user